@vzn/vx 0.0.652 → 0.0.653

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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -10,6 +10,7 @@ import {
10
10
  parseDependencySpec,
11
11
  type DependencySpec,
12
12
  } from '../util/index.js'
13
+ import { standInsOf, type StandIns } from './stand-ins.js'
13
14
  import type { TaskNode } from './task-graph.js'
14
15
 
15
16
  /**
@@ -36,9 +37,13 @@ export function keyedDeps(node: TaskNode): readonly string[] {
36
37
  * A filter selects among the task's own dependencies, never through a
37
38
  * group: `dependsOn: ['build']` over a group `build` of `build.a` and
38
39
  * `build.b` offers the filter `build`, not its members. A pattern that
39
- * matches only a member (`build.*`) therefore selected nothing, and the key stopped moving
40
- * with that member's inputs: a stale hit under a green run. Such a spec
41
- * is refused, once, over the graph every key site reads. One that matches
40
+ * matches a member (`build.*`) the filter folds neither directly nor
41
+ * through a selected group therefore left it out, and the key stopped
42
+ * moving with that member's inputs: a stale hit under a green run. That
43
+ * held when the pattern also matched another dependency directly
44
+ * (`dependsOn: ['build', 'build.a']`), so such a spec is refused, once,
45
+ * over the graph every key site reads, unless it matches the group
46
+ * itself (a negation of the group is a decision). One that matches
42
47
  * nothing anywhere stays legal (a preset's pattern needn't match in every
43
48
  * project). An exact name is refused earlier, at load, unless `dependsOn`
44
49
  * names it (`assertFilterNamesDeclaredDeps`).
@@ -49,20 +54,37 @@ export function refuseFiltersThroughGroups(nodes: ReadonlyMap<string, TaskNode>)
49
54
  if (filter === undefined || filter.length === 0) continue
50
55
  const deps = keyedDeps(node).flatMap((id) => nodes.get(id) ?? [])
51
56
  const groups = deps.filter(isGroupTask)
57
+ const folded = new Set<TaskNode>()
58
+ if (groups.length > 0) {
59
+ const units = deps.map((n) => ({ node: n, unit: n.id }))
60
+ const picked = selectFoldedDeps(units, filter, node.projectName, node.id, standInsOf(node))
61
+ for (const { node: n } of picked) {
62
+ folded.add(n)
63
+ if (isGroupTask(n)) for (const m of groupMembers(n, nodes)) folded.add(m)
64
+ }
65
+ }
52
66
  for (const raw of filter) {
53
67
  const spec = parseSpec(raw, node.id)
54
68
  if (spec.negated) continue
55
69
  const matches = specMatcher(spec)
56
70
  const hit = (n: TaskNode): boolean => matches(n, n.projectName === node.projectName)
57
- if (deps.some(hit)) continue
71
+ const direct = deps.some(hit)
58
72
  for (const group of groups) {
59
- const member = groupMembers(group, nodes).find(hit)
73
+ if (hit(group)) continue
74
+ const member = groupMembers(group, nodes).find(
75
+ (m) => hit(m) && !folded.has(m) && !deps.includes(m),
76
+ )
60
77
  if (member === undefined) continue
78
+ const outcome = direct ? 'so the key leaves it out' : 'so it selects nothing'
61
79
  throw new UserError(
62
- `${node.id}: inputs.tasks: '${raw}' matches ${member.id} only through the group ${group.id}, so it selects nothing; name '${group.taskName}' or depend on ${member.id} directly`,
80
+ `${node.id}: inputs.tasks: '${raw}' matches ${member.id} only through the group ${group.id}, ${outcome}; name '${group.taskName}' or depend on ${member.id} directly`,
63
81
  )
64
82
  }
65
- if (namesNoDependency(spec, node.config.dependsOn ?? [])) {
83
+ if (direct) continue
84
+ if (
85
+ namesNoDependency(spec, node.config.dependsOn ?? []) ||
86
+ namesTransitiveDependency(spec, node, nodes, hit)
87
+ ) {
66
88
  const named = deps.map((d) => d.id).join(', ') || 'none'
67
89
  throw new UserError(
68
90
  `${node.id}: inputs.tasks: '${raw}' names none of its dependencies (${named}), so it selects nothing and the key would not move with them; fix the name, or use [] to decouple on purpose`,
@@ -100,6 +122,34 @@ function namesNoDependency(spec: DependencySpec, dependsOn: readonly string[]):
100
122
  })
101
123
  }
102
124
 
125
+ /**
126
+ * An exact `pkg#task` filter that matched no dependency but names a task the
127
+ * key reaches only through one. `namesNoDependency` lets it through when a
128
+ * `^` entry names the task (the preset whose project lacks that dependency),
129
+ * but `a → b → c` with `dependsOn: ['^build']` and `['c#build']` selected
130
+ * none of a's direct dependencies, and an edit to c replayed a's hit.
131
+ */
132
+ function namesTransitiveDependency(
133
+ spec: DependencySpec,
134
+ node: TaskNode,
135
+ nodes: ReadonlyMap<string, TaskNode>,
136
+ hit: (n: TaskNode) => boolean,
137
+ ): boolean {
138
+ if (spec.kind !== 'cross' || isTaskPattern(spec.project) || isTaskPattern(spec.task)) return false
139
+ const seen = new Set<string>([node.id])
140
+ const stack = [...keyedDeps(node)]
141
+ while (stack.length > 0) {
142
+ const id = stack.pop()!
143
+ if (seen.has(id)) continue
144
+ seen.add(id)
145
+ const n = nodes.get(id)
146
+ if (n === undefined) continue
147
+ if (hit(n)) return true
148
+ stack.push(...keyedDeps(n))
149
+ }
150
+ return false
151
+ }
152
+
103
153
  function parseSpec(raw: string, selfTaskId: string): DependencySpec {
104
154
  try {
105
155
  return parseDependencySpec(raw, 'inputs.tasks')
@@ -162,16 +212,26 @@ export interface FoldCandidate {
162
212
  * - `filter === undefined` → every candidate, as given.
163
213
  * - `filter === []` → none (fully decoupled).
164
214
  *
165
- * Otherwise deduped by `unit`, first candidate kept. The unit is the
166
- * fold's own identity, not the task's: two groups over the same members
167
- * share one hash, so excluding either excludes both, and a selection by
168
- * task id would keep the other while the key folded neither.
215
+ * Otherwise deduped by `unit`, first selected candidate kept. The unit is
216
+ * the fold's own identity, not the task's: two groups over the same members
217
+ * share one hash. A negation drops only the candidates it names, and the
218
+ * dedup runs after every entry, so a unit still selected through another
219
+ * candidate stays folded: deleting by unit let `['^*', '!c#build']` drop
220
+ * b#build's group with c#build's, and a change beneath both replayed the
221
+ * old hit.
222
+ *
223
+ * `standIns` is the task's `standInsOf`: an edge added past a
224
+ * default build on a package cycle is selected whenever an edge it went
225
+ * past is. That build folds nothing of its cycle, so `['build']` on
226
+ * `a ↔ b` selected a#build alone, dropped b#build, and an edit to b
227
+ * replayed the old hit.
169
228
  */
170
229
  export function selectFoldedDeps(
171
230
  deps: readonly FoldCandidate[],
172
231
  filter: readonly string[] | undefined,
173
232
  selfProjectName: string,
174
233
  selfTaskId: string,
234
+ standIns?: StandIns,
175
235
  ): FoldCandidate[] {
176
236
  if (filter === undefined) return [...deps]
177
237
 
@@ -180,18 +240,25 @@ export function selectFoldedDeps(
180
240
  // Per-spec predicate, compiled once (exact compares + patterns).
181
241
  const matchers = specs.map((spec) => specMatcher(spec))
182
242
 
183
- const selected = new Map<string, FoldCandidate>()
243
+ const selected = new Set<FoldCandidate>()
184
244
  for (let i = 0; i < specs.length; i++) {
185
245
  const spec = specs[i]!
186
246
  const matches = matchers[i]!
187
247
  for (const d of deps) {
188
248
  const isSelf = d.node.projectName === selfProjectName
189
249
  if (!matches(d.node, isSelf)) continue
190
- if (spec.negated) selected.delete(d.unit)
191
- else if (!selected.has(d.unit)) selected.set(d.unit, d)
250
+ if (spec.negated) selected.delete(d)
251
+ else selected.add(d)
192
252
  }
193
253
  }
194
- return [...selected.values()]
254
+ if (standIns !== undefined) {
255
+ const ids = new Set<string>()
256
+ for (const d of selected) ids.add(d.node.id)
257
+ for (const d of deps) if (standIns[d.node.id]?.some((p) => ids.has(p))) selected.add(d)
258
+ }
259
+ const byUnit = new Map<string, FoldCandidate>()
260
+ for (const d of selected) if (!byUnit.has(d.unit)) byUnit.set(d.unit, d)
261
+ return [...byUnit.values()]
195
262
  }
196
263
 
197
264
  /** Exact-name compare, or the shared `*`-glob when the name is a pattern. */
@@ -250,7 +317,13 @@ export function foldedDeps(node: TaskNode, nodeOf: (id: string) => TaskNode): Fo
250
317
  candidates.push({ node: dep, unit: foldUnit(dep) })
251
318
  }
252
319
  if (isGroupTask(node) && node.config.inputs === undefined) return candidates
253
- return selectFoldedDeps(candidates, node.config.inputs?.tasks, node.projectName, node.id)
320
+ return selectFoldedDeps(
321
+ candidates,
322
+ node.config.inputs?.tasks,
323
+ node.projectName,
324
+ node.id,
325
+ standInsOf(node),
326
+ )
254
327
  }
255
328
 
256
329
  /**
@@ -18,6 +18,7 @@ export {
18
18
  } from './folds.js'
19
19
  export { DEFAULT_BUILD, isDefaultBuild } from './default-build.js'
20
20
  export { computeReverseDepCount } from './priorities.js'
21
+ export { setStandIns, type StandIns, standInsOf } from './stand-ins.js'
21
22
  export { criticalPath } from './critical-path.js'
22
23
  export { taskDefinition, taskDefinitions } from './definition.js'
23
24
  export {
@@ -26,6 +27,13 @@ export {
26
27
  idleServers,
27
28
  type InputChanges,
28
29
  RestoreDemoted,
30
+ isKeyFailed,
31
+ KeyFailed,
32
+ markKeyFailed,
33
+ markOutputsFailed,
34
+ OutputsFailed,
35
+ outputsFailedOf,
36
+ RestoreFailed,
29
37
  runGraph,
30
38
  type TaskOutcome,
31
39
  } from './scheduler.js'
@@ -347,10 +347,14 @@ export function strategyPriorities(
347
347
  return out
348
348
  }
349
349
 
350
+ /**
351
+ * A task's merged weight, read as asked: a merged copy held an entry per
352
+ * task of the run beside the two maps it was made of.
353
+ */
350
354
  export function mergePriorities(
351
355
  baseline: ReadonlyMap<string, number>,
352
356
  overrides: ReadonlyMap<string, number>,
353
- ): ReadonlyMap<string, number> {
357
+ ): (id: string) => number {
354
358
  // The override caller scored "expected critical-path duration" in ms.
355
359
  // Baseline reverse-deps counts are bounded by N; scale the override so
356
360
  // it sorts above the baseline for any node it covers, and add the
@@ -361,10 +365,9 @@ export function mergePriorities(
361
365
  const SCALE = 1 << 20
362
366
  let lo = 0
363
367
  for (const w of overrides.values()) if (w < lo) lo = w
364
- const out = new Map(baseline)
365
- for (const [id, w] of overrides) {
368
+ return (id) => {
366
369
  const b = baseline.get(id) ?? 0
367
- out.set(id, (w - lo + 1) * SCALE + b)
370
+ const w = overrides.get(id)
371
+ return w === undefined ? b : (w - lo + 1) * SCALE + b
368
372
  }
369
- return out
370
373
  }
@@ -33,6 +33,81 @@ export class RestoreDemoted extends Error {
33
33
  }
34
34
  }
35
35
 
36
+ /**
37
+ * Thrown by `execute` when restoring a cache hit threw (a full disk, a
38
+ * path in the way, a link out of the project). The run reports `error`
39
+ * as any throw, and the failed outcome says `restoreFailed`: a failed
40
+ * task, counted as neither a miss nor a hit.
41
+ */
42
+ export class RestoreFailed extends Error {
43
+ constructor(readonly error: unknown) {
44
+ super(thrownMessage(error))
45
+ this.name = 'RestoreFailed'
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Thrown by `execute` when deriving the task's key threw (a runtime probe
51
+ * that exited non-zero, a refused `inputs.env` value). The command never
52
+ * ran: the failed outcome is marked (`isKeyFailed`), and the line says so.
53
+ */
54
+ export class KeyFailed extends Error {
55
+ constructor(readonly error: unknown) {
56
+ super(thrownMessage(error))
57
+ this.name = 'KeyFailed'
58
+ }
59
+ }
60
+
61
+ // A mark, not a field: `TaskOutcome` and `RunPlan` are public types. A symbol
62
+ // rides a spread copy and never reaches JSON.
63
+ const KEY_FAILED = Symbol('keyFailed')
64
+
65
+ /** Mark a failed outcome (or a plan holding one) as having no derivable key. */
66
+ export function markKeyFailed<T extends object>(o: T): T {
67
+ ;(o as Record<symbol, unknown>)[KEY_FAILED] = true
68
+ return o
69
+ }
70
+
71
+ /** Whether `markKeyFailed` marked it: no key, so no command ran and no cache was asked. */
72
+ export function isKeyFailed(o: object): boolean {
73
+ return (o as Record<symbol, unknown>)[KEY_FAILED] === true
74
+ }
75
+
76
+ /**
77
+ * Thrown by `execute` when vx's own work on a miss's outputs threw: the
78
+ * clean before an attempt, or the save after an exit 0 (a name that is
79
+ * not UTF-8). The outcome keeps `exitCode` and `durationMs` (what the
80
+ * command returned and took so far) and is marked (`outputsFailedOf`),
81
+ * so no label names an exit the command never returned.
82
+ */
83
+ export class OutputsFailed extends Error {
84
+ constructor(
85
+ readonly error: unknown,
86
+ readonly step: OutputsStep,
87
+ readonly exitCode: number,
88
+ readonly durationMs: number,
89
+ ) {
90
+ super(thrownMessage(error))
91
+ this.name = 'OutputsFailed'
92
+ }
93
+ }
94
+
95
+ type OutputsStep = 'clean' | 'save'
96
+
97
+ // A mark, as KEY_FAILED: `TaskOutcome` is a public type.
98
+ const OUTPUTS_FAILED = Symbol('outputsFailed')
99
+
100
+ /** Mark a failed outcome (or its view) with the output step that failed it. */
101
+ export function markOutputsFailed<T extends object>(o: T, step: OutputsStep): T {
102
+ ;(o as Record<symbol, unknown>)[OUTPUTS_FAILED] = step
103
+ return o
104
+ }
105
+
106
+ /** The output step `markOutputsFailed` marked, if any. */
107
+ export function outputsFailedOf(o: object): OutputsStep | undefined {
108
+ return (o as Record<symbol, OutputsStep | undefined>)[OUTPUTS_FAILED]
109
+ }
110
+
36
111
  /** What moved in a missed task's key since the last entry saved for it. */
37
112
  export interface InputChanges {
38
113
  /** How many components changed, appeared or went. 0: the same key, its entry gone. */
@@ -128,6 +203,13 @@ export interface TaskOutcome {
128
203
  * so a dependant that folds `hash` saves nothing either (A-12).
129
204
  */
130
205
  unkeyed?: true
206
+ /**
207
+ * The input this task's own re-check found moved while it ran (absolute),
208
+ * or null when the key re-derived before the command already differed.
209
+ * The command read a state of it no `vx watch` judgement saw, so the
210
+ * loop counts its next settled state as a change.
211
+ */
212
+ driftedInput?: string | null
131
213
  /**
132
214
  * The task with no `inputs` this task's key folds (this task, or one
133
215
  * upstream): no run can derive that key again, so it neither read nor
@@ -148,6 +230,14 @@ export interface TaskOutcome {
148
230
  * read this one field (item 267).
149
231
  */
150
232
  blockedBy?: string
233
+ /**
234
+ * On an outcome that ran behind a failed dependency (`continueMode:
235
+ * 'always'`), one `--exclude-dependencies` dropped, or a server that died
236
+ * while it ran: the task at the root of it. Its result is not its key's, so no flaky verdict reads it and
237
+ * its run row names the root as a skip's `blockedBy` does. Set by `run()`
238
+ * before any observer (a plugin's `onTaskComplete`) hears the outcome.
239
+ */
240
+ taintedBy?: string
151
241
  /**
152
242
  * On a `skipped` outcome: its `exec.platforms` leave out this machine.
153
243
  * Its dependants are skipped with `blockedBy` naming it, and neither
@@ -206,6 +296,12 @@ export interface TaskOutcome {
206
296
  outputs?: 'deferred'
207
297
  /** The task ran here and its result went to the cache. The footer's `↑`. */
208
298
  saved?: true
299
+ /**
300
+ * On a `failed` outcome: a cache hit whose restore threw, so no command
301
+ * ran. It counts as failed only, in no cache column: not a miss, not
302
+ * its layer's hit.
303
+ */
304
+ restoreFailed?: true
209
305
  /**
210
306
  * v11 analytics: hrtime span relative to the parent run's t=0.
211
307
  * Lets downstream analytics reconstruct the actual parallel timeline
@@ -336,6 +432,8 @@ export interface ScheduleOptions {
336
432
  * dispatch, the slot and the promise around it were ~10 µs of each of a
337
433
  * warm run's 1,090 groups (X-192); a hit whose tree is current does no
338
434
  * more. A restore-tier task's `upstream` may hold undefined entries.
435
+ * The thunk's outcome is the scheduler's: its queue time is set on it,
436
+ * not on a copy (a copy per hit, 2026-11-07).
339
437
  */
340
438
  settleNow?: (node: TaskNode, upstream: TaskOutcome[]) => (() => TaskOutcome) | undefined
341
439
  /**
@@ -431,23 +529,23 @@ export interface ScheduleOptions {
431
529
  class ReadyHeap {
432
530
  private readonly ids: string[] = []
433
531
  private readonly seq: number[] = []
434
- // Each slot's priority, read once at push: a map lookup per comparison
532
+ // Each slot's priority, read once at push: a lookup per comparison
435
533
  // was two per heap level on every push and pop.
436
534
  private readonly prio: number[] = []
437
535
  private next = 0
438
536
  /** Queued ids `counts` holds true for. */
439
537
  counted = 0
440
538
  constructor(
441
- private priority: ReadonlyMap<string, number>,
539
+ private priority: (id: string) => number,
442
540
  private readonly counts?: (id: string) => boolean,
443
541
  ) {}
444
542
  get size(): number {
445
543
  return this.ids.length
446
544
  }
447
545
  /** Re-rank every queued id under `priority`, and every later push. */
448
- reprioritize(priority: ReadonlyMap<string, number>): void {
546
+ reprioritize(priority: (id: string) => number): void {
449
547
  this.priority = priority
450
- for (let i = 0; i < this.ids.length; i++) this.prio[i] = priority.get(this.ids[i]!) ?? 0
548
+ for (let i = 0; i < this.ids.length; i++) this.prio[i] = priority(this.ids[i]!)
451
549
  for (let i = (this.ids.length >> 1) - 1; i >= 0; i--) this.down(i)
452
550
  }
453
551
  private down(i: number): void {
@@ -498,7 +596,7 @@ class ReadyHeap {
498
596
  if (this.counts?.(id) === true) this.counted++
499
597
  this.ids.push(id)
500
598
  this.seq.push(seq)
501
- this.prio.push(this.priority.get(id) ?? 0)
599
+ this.prio.push(this.priority(id))
502
600
  let i = this.ids.length - 1
503
601
  while (i > 0) {
504
602
  const parent = (i - 1) >> 1
@@ -530,11 +628,13 @@ class ReadyHeap {
530
628
  }
531
629
 
532
630
  /**
533
- * The dead server a dependency stands for: itself, or one a group reaches.
534
- * A group is a name for its deps, and it finished the moment the server
535
- * was ready, so a task behind it ran against the dead one. Walked with a
536
- * stack and a seen set: recursion threw `RangeError` on a deep chain of
537
- * groups, and re-walked a group each path reached it by.
631
+ * The dead server a dependency stands for: itself, or one a group or a
632
+ * server reaches. A group is a name for its deps, and it finished the
633
+ * moment the server was ready, so a task behind it ran against the dead
634
+ * one; a server stays up on the servers it depends on, so a task behind
635
+ * `web` runs against the `api` under it too. Walked with a stack and a
636
+ * seen set: recursion threw `RangeError` on a deep chain of groups, and
637
+ * re-walked a group each path reached it by.
538
638
  */
539
639
  export function deadServerBehind(
540
640
  nodes: ReadonlyMap<string, TaskNode>,
@@ -549,7 +649,7 @@ export function deadServerBehind(
549
649
  seen.add(at)
550
650
  if (serverDied(at)) return at
551
651
  const n = nodes.get(at)
552
- if (n === undefined || !isGroupTask(n)) continue
652
+ if (n === undefined || (!isGroupTask(n) && n.config.exec?.persistent === undefined)) continue
553
653
  // Reversed, so the first dep is walked first, as the order it names them.
554
654
  for (let i = n.deps.length - 1; i >= 0; i--) stack.push(n.deps[i]!)
555
655
  }
@@ -606,9 +706,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
606
706
  : options.restoreTier !== undefined && options.restoreTier.size > 0
607
707
  ? tieredReverseDepCount(nodes, options.restoreTier)
608
708
  : (options.reverseDeps?.() ?? computeReverseDepCount(nodes))
609
- const priority: ReadonlyMap<string, number> = options.priorities
709
+ const priority: (id: string) => number = options.priorities
610
710
  ? mergePriorities(baseline, options.priorities)
611
- : baseline
711
+ : (id) => baseline.get(id) ?? 0
612
712
 
613
713
  // Two ready queues — exec-tier (cache misses + unstable tasks) and
614
714
  // restore-tier (confirmed stable local hits). Each is a max-heap keyed by
@@ -627,7 +727,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
627
727
  // (`dormant`) until a dependant is demoted, which starts it, or until
628
728
  // every dependant has finished, which settles it unspawned. Settled
629
729
  // without an outcome: it is not part of the run, as a pruned task is not.
630
- // A group between it and its hits is idle with it.
730
+ // A group or a server between it and its hits is idle with it.
631
731
  const idleGroups = new Set<string>()
632
732
  const idle = idleServers(nodes, dependents, restoreTier, idleGroups)
633
733
  for (const g of idleGroups) idle.add(g)
@@ -684,7 +784,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
684
784
  // complete.
685
785
  // When each queued id became ready; dispatch takes it out.
686
786
  const readyAt = new Map<string, number>()
687
- const startedAt = Date.now()
787
+ // Elapsed-time stamps (queue and hold waits), immune to wall clock steps.
788
+ const startedAt = performance.now()
688
789
  for (const node of nodes.values()) {
689
790
  if (restoreTier?.has(node.id)) restoreReady.push(node.id)
690
791
  else if (node.deps.length === 0) execReady.push(node.id)
@@ -896,7 +997,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
896
997
  if (heldRelease.delete(d)) enter(d)
897
998
  else if (!inRestoreTier(d)) {
898
999
  execReady.push(d)
899
- readyAt.set(d, Date.now())
1000
+ readyAt.set(d, performance.now())
900
1001
  }
901
1002
  }
902
1003
  }
@@ -1027,7 +1128,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1027
1128
  if (hasRoom(id)) {
1028
1129
  if (!admitActive || admits(id)) return id
1029
1130
  // A free worker, refused by the policy: the hold starts now.
1030
- if (!heldSince.has(id)) heldSince.set(id, Date.now())
1131
+ if (!heldSince.has(id)) heldSince.set(id, performance.now())
1031
1132
  }
1032
1133
  parked.push([id, seq])
1033
1134
  }
@@ -1107,7 +1208,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1107
1208
  continue
1108
1209
  }
1109
1210
 
1110
- const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
1211
+ const readySince = readyAt.get(id)
1212
+ const queuedMs = readySince === undefined ? 0 : Math.round(performance.now() - readySince)
1111
1213
  readyAt.delete(id)
1112
1214
  const settle = settleInPlace(node, upstream)
1113
1215
  let now: TaskOutcome | undefined
@@ -1120,7 +1222,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1120
1222
  }
1121
1223
  }
1122
1224
  if (now !== undefined) {
1123
- finishOne(id, queuedMs === 0 ? now : { ...now, queuedMs })
1225
+ if (queuedMs !== 0) now.queuedMs = queuedMs
1226
+ finishOne(id, now)
1124
1227
  continue
1125
1228
  }
1126
1229
  const leave = admit(id)
@@ -1128,7 +1231,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1128
1231
  // tick sees it; the completion callbacks unlist it.
1129
1232
  const untrack = track(id)
1130
1233
  const since = admitActive ? heldSince.get(id) : undefined
1131
- const heldMs = since === undefined ? 0 : Math.max(1, Date.now() - since)
1234
+ const heldMs = since === undefined ? 0 : Math.max(1, Math.round(performance.now() - since))
1132
1235
  if (since !== undefined) heldSince.delete(id)
1133
1236
  const withHold = (o: TaskOutcome): TaskOutcome =>
1134
1237
  heldMs === 0 && queuedMs === 0
@@ -1177,8 +1280,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1177
1280
  }
1178
1281
  void settled.then(done, () => done())
1179
1282
  },
1180
- (err: unknown) => {
1181
- if (err instanceof RestoreDemoted) {
1283
+ (thrown: unknown) => {
1284
+ if (thrown instanceof RestoreDemoted) {
1182
1285
  leave()
1183
1286
  untrack()
1184
1287
  demoted.add(id)
@@ -1186,16 +1289,22 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1186
1289
  idle.delete(p)
1187
1290
  if (dormant.delete(p)) {
1188
1291
  execReady.push(p)
1189
- readyAt.set(p, Date.now())
1292
+ readyAt.set(p, performance.now())
1190
1293
  }
1191
1294
  })
1192
1295
  if (pending.get(id) === 0) {
1193
1296
  execReady.push(id)
1194
- readyAt.set(id, Date.now())
1297
+ readyAt.set(id, performance.now())
1195
1298
  }
1196
1299
  tick()
1197
1300
  return
1198
1301
  }
1302
+ const err =
1303
+ thrown instanceof RestoreFailed ||
1304
+ thrown instanceof KeyFailed ||
1305
+ thrown instanceof OutputsFailed
1306
+ ? thrown.error
1307
+ : thrown
1199
1308
  const message = thrownMessage(err)
1200
1309
  // After the run's stop a rejection is the stop's doing (a probe
1201
1310
  // it killed, C-65): aborted, as any task it kills, and unsaid.
@@ -1203,9 +1312,12 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1203
1312
  const outcome: TaskOutcome = withHold({
1204
1313
  node,
1205
1314
  status: stopped ? 'aborted' : 'failed',
1206
- exitCode: 1,
1207
- durationMs: 0,
1315
+ exitCode: thrown instanceof OutputsFailed ? thrown.exitCode : 1,
1316
+ durationMs: thrown instanceof OutputsFailed ? thrown.durationMs : 0,
1317
+ ...(thrown instanceof RestoreFailed && !stopped ? { restoreFailed: true } : {}),
1208
1318
  })
1319
+ if (thrown instanceof KeyFailed && !stopped) markKeyFailed(outcome)
1320
+ if (thrown instanceof OutputsFailed && !stopped) markOutputsFailed(outcome, thrown.step)
1209
1321
  leave()
1210
1322
  untrack()
1211
1323
  if (stopped) {
@@ -1220,9 +1332,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1220
1332
  // Out of descriptors is the environment's limit too: a stack
1221
1333
  // naming a file that was fine sent the user after the file.
1222
1334
  if (isUserError(err) || isFsRefusal(err) || isOutOfFds(err)) {
1223
- const text = isUserError(err)
1335
+ const said = isUserError(err)
1224
1336
  ? message
1225
1337
  : `${message} — ${isOutOfFds(err) ? OUT_OF_FDS_HINT : fsRefusalHint(err)}`
1338
+ const text = thrown instanceof KeyFailed ? `cache key not derived: ${said}` : said
1226
1339
  // In a task's own output block, in full: that block is read
1227
1340
  // alone, and "as <task> above" named one printed elsewhere or
1228
1341
  // not at all (2026-10-22). On bare stderr the first is above.
@@ -1290,9 +1403,25 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1290
1403
  continue
1291
1404
  promoted.add(d)
1292
1405
  restoreReady.push(d)
1293
- readyAt.set(d, Date.now())
1406
+ readyAt.set(d, performance.now())
1407
+ }
1408
+ // Idle as `idleServers` finds it: through the groups and servers
1409
+ // nobody asked for above it. The servers beneath it are held on the
1410
+ // same keys, so the walk idles them with it; a server already
1411
+ // answered, or never held, keeps its course.
1412
+ if (found.length > 0) {
1413
+ const tier =
1414
+ demoted.size === 0 ? restoreTier : new Set([...restoreTier!].filter(inRestoreTier))
1415
+ const between = new Set<string>()
1416
+ const holdsHere = (s: string): boolean =>
1417
+ s === id || unanswered.has(s) || isGroupTask(nodes.get(s) as TaskNode)
1418
+ for (const s of idleServers(nodes, dependents, tier, between)) if (holdsHere(s)) idle.add(s)
1419
+ for (const g of between)
1420
+ if (holdsHere(g) && !idleGroups.has(g)) {
1421
+ idleGroups.add(g)
1422
+ idle.add(g)
1423
+ }
1294
1424
  }
1295
- if (dependents.get(id)?.every((d) => inRestoreTier(d)) === true) idle.add(id)
1296
1425
  if (heldReady.delete(id)) execReady.push(id)
1297
1426
  tick()
1298
1427
  }
@@ -1320,10 +1449,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1320
1449
 
1321
1450
  /**
1322
1451
  * Persistent tasks nobody asked for whose every dependant is a restore-tier
1323
- * hit, or a group nobody asked for that only such dependants stand behind:
1324
- * a group runs nothing, so `env: { dependsOn: ['dev'] }` between the server
1325
- * and its hits booted the server on every warm run. Those groups go in
1326
- * `groups`.
1452
+ * hit, or a group or server nobody asked for that only such dependants
1453
+ * stand behind: a group runs nothing, so `env: { dependsOn: ['dev'] }`
1454
+ * between the server and its hits booted the server on every warm run, and
1455
+ * `api` under an idle `web` serves nobody either. Those groups and servers
1456
+ * go in `groups`: the scheduler settles them as it settles a group.
1327
1457
  */
1328
1458
  export function idleServers(
1329
1459
  nodes: ReadonlyMap<string, TaskNode>,
@@ -1334,10 +1464,10 @@ export function idleServers(
1334
1464
  const out = new Set<string>()
1335
1465
  if (restoreTier === undefined || restoreTier.size === 0) return out
1336
1466
  const unasked = (n: TaskNode): boolean => !n.requested && n.surfaced !== true
1337
- // Per group: whether only hits stand behind it. Post-order on a stack, as
1338
- // groups nest as deep as the graph.
1467
+ // Per group or server: whether only hits stand behind it. Post-order on a
1468
+ // stack, as groups and servers nest as deep as the graph.
1339
1469
  const spare = new Map<string, boolean>()
1340
- const spareGroup = (root: string): boolean => {
1470
+ const spareNode = (root: string): boolean => {
1341
1471
  const stack = [root]
1342
1472
  while (stack.length > 0) {
1343
1473
  const id = stack[stack.length - 1]!
@@ -1347,7 +1477,12 @@ export function idleServers(
1347
1477
  }
1348
1478
  const n = nodes.get(id)
1349
1479
  const ds = dependents.get(id)
1350
- if (n === undefined || !isGroupTask(n) || !unasked(n) || ds === undefined) {
1480
+ if (
1481
+ n === undefined ||
1482
+ (!isGroupTask(n) && n.config.exec?.persistent === undefined) ||
1483
+ !unasked(n) ||
1484
+ ds === undefined
1485
+ ) {
1351
1486
  spare.set(id, false)
1352
1487
  stack.pop()
1353
1488
  continue
@@ -1368,7 +1503,7 @@ export function idleServers(
1368
1503
  for (const n of nodes.values()) {
1369
1504
  if (n.config.exec?.persistent === undefined || !unasked(n)) continue
1370
1505
  const ds = dependents.get(n.id)
1371
- if (ds === undefined || !ds.every((d) => restoreTier.has(d) || spareGroup(d))) continue
1506
+ if (ds === undefined || !ds.every((d) => restoreTier.has(d) || spareNode(d))) continue
1372
1507
  out.add(n.id)
1373
1508
  if (groups === undefined) continue
1374
1509
  const stack = ds.filter((d) => !restoreTier.has(d))