@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /package/src/{report → ui}/terminal-signals.ts +0 -0
package/src/index.ts CHANGED
@@ -41,9 +41,10 @@ export type {
41
41
  TaskConfig,
42
42
  ExecConfig,
43
43
  ExecEnv,
44
- CacheConfig,
45
44
  InputsConfig,
46
- CacheOutputs,
45
+ TaskOutputs,
46
+ WorkspaceInputs,
47
+ WorkspaceOutputs,
47
48
  InputStability,
48
49
  SandboxConfig,
49
50
  SandboxGrants,
@@ -145,7 +146,7 @@ export {
145
146
  taskNameProblem,
146
147
  } from './workspace/index.js'
147
148
  // Output components: a plugin draws its lines with core's, so they look alike.
148
- export { ui } from './util/index.js'
149
+ export { ui } from './ui/index.js'
149
150
  export type {
150
151
  ApplyMigrationArgs,
151
152
  MigrationFormat,
@@ -186,10 +187,12 @@ export type {
186
187
  CacheFacts,
187
188
  TaskArtifacts,
188
189
  FingerprintChange,
190
+ FingerprintDigests,
189
191
  FingerprintClaim,
190
192
  FingerprintContext,
191
193
  GraphHookContext,
192
194
  KeyHookContext,
195
+ LockedFile,
193
196
  PluginCommand,
194
197
  ProjectHookContext,
195
198
  ScheduleHookContext,
@@ -243,6 +246,7 @@ export type {
243
246
  ServerSpec,
244
247
  TaskExecutor,
245
248
  TaskPlacement,
249
+ WorkerAdvice,
246
250
  } from './exec/index.js'
247
251
 
248
252
  // Telemetry — THE canonical, versioned data-export contract every exporter
@@ -186,7 +186,7 @@ export function admitTasks(
186
186
  const cacheable =
187
187
  !isGroupTask(node) &&
188
188
  node.config.exec?.persistent === undefined &&
189
- node.config.cache !== undefined &&
189
+ node.config.cache === true &&
190
190
  canRead &&
191
191
  canWrite
192
192
  // A restore-tier task (confirmed local hit, may run before its deps)
@@ -1,5 +1,5 @@
1
1
  // When each command of the run ran and where it may write, so
2
- // `inputStability.strategy: 'mutable'` can tell a task's own identical
2
+ // `inputs.stability.strategy: 'mutable'` can tell a task's own identical
3
3
  // rewrite from another task's write and restore (STATUS item 28): a file
4
4
  // whose bytes match the key after a write is the task's own only if no
5
5
  // other command that may write it ran between the describe and that write.
@@ -115,7 +115,7 @@ export class DeferredOutputs {
115
115
  // Project outputs only: a task declaring `outputs.workspaceFiles` is
116
116
  // never deferred (`deferralEligibility` forces it eager), so nothing
117
117
  // workspace-anchored can reach this path.
118
- const outputs = producer.config.cache?.outputs.files ?? []
118
+ const outputs = producer.config.outputs?.files ?? []
119
119
  const nestedProjectDirs = this.args.nestedDirsByProject.get(producer.projectName) ?? []
120
120
  const cleanArgs = {
121
121
  projectDir: producer.projectDir,
@@ -19,8 +19,8 @@ import {
19
19
  machineMemoryBytes,
20
20
  machineParallelism,
21
21
  treeBytes,
22
- ui,
23
22
  } from '../util/index.js'
23
+ import * as ui from '../ui/index.js'
24
24
  import { vxPath } from '../handoff.js'
25
25
  import { VERSION } from '../version.js'
26
26
  import { probeSandbox, resetSandbox, untracedReason } from '../exec/index.js'
@@ -44,14 +44,12 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
44
44
  if (list) list.push({ taskId: n.id, prefixes, via })
45
45
  else readersByProject.set(n.projectName, [{ taskId: n.id, prefixes, via }])
46
46
  }
47
- // A task with no `cache` block keys on its inputs, and a cached task that
47
+ // A task without `cache: true` keys on its inputs, and a cached task that
48
48
  // depends on it folds that key: deferring a producer there moved both
49
49
  // keys with the transfer flag. Only such a task is a reader — one nothing
50
50
  // cached folds keys nothing that lasts.
51
51
  const folded = new Set<string>()
52
- const stack = [...nodes.values()]
53
- .filter((n) => n.config.cache !== undefined)
54
- .flatMap((n) => n.deps)
52
+ const stack = [...nodes.values()].filter((n) => n.config.cache === true).flatMap((n) => n.deps)
55
53
  for (let id = stack.pop(); id !== undefined; id = stack.pop()) {
56
54
  if (folded.has(id)) continue
57
55
  folded.add(id)
@@ -63,7 +61,7 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
63
61
  const inputs = n.config.inputs
64
62
  // A task with no inputs has no key to read; an uncached one with inputs
65
63
  // is read only for the cached tasks that fold its key.
66
- if (inputs === undefined || (n.config.cache === undefined && !folded.has(n.id))) continue
64
+ if (inputs === undefined || (n.config.cache !== true && !folded.has(n.id))) continue
67
65
  if ((inputs?.workspaceFiles?.length ?? 0) > 0) workspaceReader ??= n.id
68
66
  // A `runtime` input is a SHELL COMMAND whose reads are unknowable —
69
67
  // the same reason vx refuses to infer inputs by tracing. It can `cat` a
@@ -86,11 +84,11 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
86
84
  }
87
85
 
88
86
  for (const n of nodes.values()) {
89
- const outputs = n.config.cache?.outputs
87
+ const outputs = n.config.outputs
90
88
  if ((outputs?.workspaceFiles?.length ?? 0) > 0) {
91
89
  ineligible.set(
92
90
  n.id,
93
- 'declares cache.outputs.workspaceFiles — root-anchored outputs can land where any project-relative input reads',
91
+ 'declares outputs.workspaceFiles — root-anchored outputs can land where any project-relative input reads',
94
92
  )
95
93
  continue
96
94
  }
@@ -117,7 +117,7 @@ export function excludedTaint(nodes: ReadonlyMap<string, TaskNode>): {
117
117
  let unsaved = 0
118
118
  for (const id of reached) {
119
119
  const cfg = nodes.get(id)!.config
120
- if (cfg.cache !== undefined && cfg.exec?.persistent === undefined) unsaved++
120
+ if (cfg.cache === true && cfg.exec?.persistent === undefined) unsaved++
121
121
  }
122
122
  return { seeds, unsaved }
123
123
  }
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path'
2
- import type { ExecConfig, TaskConfig, CacheConfig, InputStability } from '../config.js'
2
+ import type { ExecConfig, TaskConfig, InputsConfig, InputStability } from '../config.js'
3
3
  import {
4
4
  ArtifactVanishedError,
5
5
  type CacheEntry,
@@ -58,6 +58,7 @@ import { executorLabel, nameExecutorFailure } from './plugin-host.js'
58
58
  import type { CommandWindows } from './command-windows.js'
59
59
  import {
60
60
  commandWriteReach,
61
+ dependencyReads,
61
62
  mayWriteFingerprint,
62
63
  type Placeholder,
63
64
  reachedWithheld,
@@ -72,6 +73,7 @@ import {
72
73
  bracketLiteralHint,
73
74
  markUnsaved,
74
75
  saveMiss,
76
+ unsavedOutputBytes,
75
77
  type OutputDirSnapshot,
76
78
  type SaveFacts,
77
79
  } from './miss-save.js'
@@ -182,17 +184,23 @@ export interface ExecuteArgs {
182
184
  * byte-identical with and without it.
183
185
  */
184
186
  retries?: number
187
+ /**
188
+ * Whether the run history knows the task as flaky (`knownFlaky`): a keyed
189
+ * task with no `retries` of its own or of the run that fails gets ONE more
190
+ * attempt when it does. Decided at run time, so never in the key.
191
+ */
192
+ knownFlaky?: (node: TaskNode, hash: string) => boolean
185
193
  /**
186
194
  * `run.timeout` (ms), already resolved from flag, variable and
187
195
  * workspace. Per-task `exec.timeout` wins. Never hashed.
188
196
  */
189
197
  timeout?: number
190
198
  /**
191
- * The run's `inputStability` layers around the task's own: `run` from
199
+ * The run's `inputs.stability` layers around the task's own: `run` from
192
200
  * the env vars (above the task), `workspace` from `.vx/workspace` (below).
193
201
  */
194
202
  inputStability?: { run: InputStability; workspace?: InputStability }
195
- /** `cache.outputs.clean` for tasks without their own: `.vx/workspace`. */
203
+ /** `outputs.clean` for tasks without their own: `.vx/workspace`. */
196
204
  cleanOutputs?: boolean
197
205
  /** Per-run memo for `git ls-files` (one entry per project dir). */
198
206
  gitFilesCache?: GitFilesCache
@@ -235,6 +243,11 @@ export interface ExecuteArgs {
235
243
  * granted a linked workspace package only when it is in this set.
236
244
  */
237
245
  keyedProjects: (node: TaskNode) => ReadonlySet<string>
246
+ /**
247
+ * The run's lookup of the tasks whose outputs a sandboxed task may read
248
+ * (keyed-projects.ts `readableDeps`). Absent: none.
249
+ */
250
+ readableDeps?: (node: TaskNode) => readonly TaskNode[]
238
251
  /**
239
252
  * Run-scoped list the miss path appends its output-directory snapshot
240
253
  * request to, taken at run end (run.ts) instead of right after the save:
@@ -344,7 +357,7 @@ function readsCache(args: ExecuteArgs): boolean {
344
357
  const policy = args.cachePolicy ?? FULL_CACHE_POLICY
345
358
  return (
346
359
  args.remoteOnly !== true &&
347
- args.node.config.cache !== undefined &&
360
+ args.node.config.cache === true &&
348
361
  (policy.localRead || policy.remoteRead)
349
362
  )
350
363
  }
@@ -396,16 +409,16 @@ async function restoreOrMiss(
396
409
 
397
410
  /**
398
411
  * What a write to an input during the command means, each field highest
399
- * first: the env var (`layers.run`), the task's `cache.inputStability`,
412
+ * first: the env var (`layers.run`), the task's `inputs.stability`,
400
413
  * the workspace's, then `'immutable'` and `'warn'` (2026-10-12), so
401
414
  * by default any such write withholds the save, on CI too, since a
402
415
  * concurrent task can change an input and put it back.
403
416
  */
404
417
  function inputStabilityOf(
405
- cache: CacheConfig | undefined,
418
+ inputs: InputsConfig | undefined,
406
419
  layers: ExecuteArgs['inputStability'],
407
420
  ): Required<InputStability> {
408
- const task = cache?.inputStability
421
+ const task = inputs?.stability
409
422
  return {
410
423
  strategy: layers?.run.strategy ?? task?.strategy ?? layers?.workspace?.strategy ?? 'immutable',
411
424
  severity: layers?.run.severity ?? task?.severity ?? layers?.workspace?.severity ?? 'warn',
@@ -517,9 +530,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
517
530
  const { node, upstream, cache, log } = args
518
531
  const cfg: TaskConfig = node.config
519
532
  const step = cfg.exec as ExecConfig // dispatcher guarantees exec is present
520
- const cacheCfg: CacheConfig | undefined = cfg.cache
533
+ const cfgOut = cfg.outputs
521
534
  const policy = args.cachePolicy ?? FULL_CACHE_POLICY
522
- const cfgCacheable = cacheCfg !== undefined
535
+ const cfgCacheable = cfg.cache === true
523
536
  // We may READ when at least one read axis is on (the cache layer
524
537
  // refines local vs remote), and WRITE when at least one write axis is
525
538
  // on. Output management (clean before exec) keys off writes — so
@@ -570,8 +583,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
570
583
  const deferralRequested = args.download === 'deferred'
571
584
  const capture: CaptureConfig = { stdout: false, stderr: false }
572
585
 
573
- const outputs = cacheCfg?.outputs.files ?? []
574
- const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
586
+ const outputs = cfgOut?.files ?? []
587
+ const wsOutputs = cfgOut?.workspaceFiles ?? []
575
588
  const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
576
589
  // Per-task `exec.timeout` beats `run.timeout` (flag, variable,
577
590
  // workspace; resolved into `args.timeout` by run.ts).
@@ -734,12 +747,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
734
747
  const additive = (node.addsToOutputsOf?.length ?? 0) > 0
735
748
  let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
736
749
  let wsStampedBefore: ReadonlyMap<string, OutputStamp> | undefined
737
- // `cache.outputs.clean: false` leaves the outputs to the first attempt's
750
+ // `outputs.clean: false` leaves the outputs to the first attempt's
738
751
  // command, and the save stores whatever they hold after it, a leftover
739
752
  // included: the user opted out of the clean-tree guarantee
740
753
  // (2026-10-15). A retry still starts clean. An additive task never cleans
741
754
  // by glob anyway.
742
- const keepOutputs = !additive && (cacheCfg?.outputs.clean ?? args.cleanOutputs ?? true) === false
755
+ const keepOutputs = !additive && (cfgOut?.clean ?? args.cleanOutputs ?? true) === false
743
756
 
744
757
  // Cache lookup. On hit, time the user-perceived restore op
745
758
  // (clean+restore+log-replay) — that's what the framed-block footer
@@ -888,7 +901,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
888
901
  // Cache miss path (or caching disabled), up to `1 + retries` attempts.
889
902
  // Explicit config wins over the run-level `--retries` default, including
890
903
  // an explicit `retries: 0`.
891
- const maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
904
+ const retriesSet = step.retries !== undefined || args.retries !== undefined
905
+ let maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
892
906
  let attempt = 0
893
907
  let result: ExecuteResult
894
908
  let effectiveExitCode: number
@@ -1172,6 +1186,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1172
1186
  const storeMask = secrets ?? undefined
1173
1187
  const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [], storeMask)
1174
1188
  const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
1189
+ // Each failed attempt's output, beside `failedAttempts`: kept for `vx last --log`.
1190
+ const attemptOutputs: string[] = []
1175
1191
  // The sampling of the attempt's process tree, stopped once it settles.
1176
1192
  let untrack: (() => TreeLoad | undefined) | undefined
1177
1193
  // What the last attempt's samples saw, for the run history.
@@ -1210,6 +1226,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1210
1226
  if (a.withdrawn) {
1211
1227
  attempt--
1212
1228
  failedAttempts.pop()
1229
+ attemptOutputs.pop()
1213
1230
  break
1214
1231
  }
1215
1232
  forgetUndeclaredWrites(args, writeReach)
@@ -1251,12 +1268,27 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1251
1268
  }
1252
1269
  }
1253
1270
 
1271
+ // A known flake gets the one retry its config did not ask for; a
1272
+ // timeout always fails, and a task whose key names nothing (no cache
1273
+ // block, or keyed on a keyless upstream) has no record to know it by.
1274
+ if (
1275
+ effectiveExitCode !== 0 &&
1276
+ attempt === 1 &&
1277
+ !retriesSet &&
1278
+ result.timedOut !== true &&
1279
+ cfgCacheable &&
1280
+ !keyless &&
1281
+ args.failFast?.aborted !== true &&
1282
+ args.knownFlaky?.(node, hash) === true
1283
+ )
1284
+ maxAttempts = 2
1254
1285
  if (effectiveExitCode === 0 || attempt >= maxAttempts || args.failFast?.aborted === true) break
1255
1286
  failedAttempts.push({
1256
1287
  endedAt: Date.now(),
1257
1288
  exitCode: effectiveExitCode,
1258
1289
  ...(result.timedOut === true ? { timedOut: true as const } : {}),
1259
1290
  })
1291
+ attemptOutputs.push(output === undefined ? '' : failedOutputLog(output, true, storeMask))
1260
1292
  log.taskStderr(
1261
1293
  node,
1262
1294
  `${midLine ? '\n' : ''}vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${result.timedOut === true ? 'a timeout' : `exit ${effectiveExitCode}`}\n`,
@@ -1284,6 +1316,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1284
1316
  flushMasked = () => masked?.end()
1285
1317
  const base: ExecuteRequest = {
1286
1318
  taskId: node.id,
1319
+ ...(attempt > 1 ? { attempt } : {}),
1287
1320
  workspaceRoot: args.workspaceRoot,
1288
1321
  command: step.command,
1289
1322
  forwardArgs: effectiveForwardArgs,
@@ -1335,6 +1368,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1335
1368
  cfgCacheable ? args.keyedProjects(node) : undefined,
1336
1369
  args.nestedProjectDirs,
1337
1370
  args.cacheDir,
1371
+ await dependencyReads(args.readableDeps?.(node) ?? [], args.workspaceRoot),
1338
1372
  )
1339
1373
  placeholders = sb.placeholders
1340
1374
  withheld = sb.withheld
@@ -1343,10 +1377,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1343
1377
 
1344
1378
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
1345
1379
 
1346
- const stability = inputStabilityOf(args.node.config.cache, args.inputStability)
1380
+ const stability = inputStabilityOf(args.node.config.inputs, args.inputStability)
1347
1381
  let movedFails: string | undefined
1348
1382
  let own: Awaited<ReturnType<typeof ownOutputs>>
1349
1383
  let saved = false
1384
+ // An unsaved miss's outputs, as a save's are counted: its row's `outputs`.
1385
+ let unsavedBytes: number | undefined
1350
1386
  if (
1351
1387
  effectiveExitCode === 0 &&
1352
1388
  willSave &&
@@ -1408,7 +1444,17 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1408
1444
  // entry is in; without one, the save is taken as done.
1409
1445
  if (args.deferredSaves !== undefined) args.deferredSaves.set(node.id, landed)
1410
1446
  else saved = true
1411
- } else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants !== true) {
1447
+ } else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants === true) {
1448
+ // Nothing reads after it, so nothing to mark: only its row's `outputs`.
1449
+ if (effectiveExitCode === 0)
1450
+ unsavedBytes = await unsavedOutputBytes({
1451
+ node,
1452
+ workspaceRoot: args.workspaceRoot,
1453
+ nestedProjectDirs: args.nestedProjectDirs,
1454
+ outputs,
1455
+ wsOutputs,
1456
+ })
1457
+ } else if (cfgCacheable && !remoteOnly && !deferralRequested) {
1412
1458
  // Ran here and saves nothing — failed, a read-only policy, a tainted
1413
1459
  // upstream, a key that no longer held — yet it wrote what a save would
1414
1460
  // have marked, and may have rewritten an input the re-check before a
@@ -1422,7 +1468,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1422
1468
  if (moved !== undefined) {
1423
1469
  forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
1424
1470
  } else {
1425
- await markUnsaved({
1471
+ unsavedBytes = await markUnsaved({
1426
1472
  node,
1427
1473
  workspaceRoot: args.workspaceRoot,
1428
1474
  nestedProjectDirs: args.nestedProjectDirs,
@@ -1527,11 +1573,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1527
1573
  const hint =
1528
1574
  moved === null || rewroteBy !== undefined
1529
1575
  ? ''
1530
- : '; if the task writes it, declare it in cache.outputs'
1576
+ : '; if the task writes it, declare it in outputs'
1531
1577
  if (stability.severity === 'error') {
1532
1578
  warnOf(log)(
1533
1579
  `[vx] ${node.id}: ${what} — the result is not saved, ` +
1534
- `and inputStability.severity is 'error', so the task fails${hint}`,
1580
+ `and inputs.stability.severity is 'error', so the task fails${hint}`,
1535
1581
  )
1536
1582
  movedFails = moved === null ? '' : printable(relPosix(args.workspaceRoot, moved))
1537
1583
  } else {
@@ -1564,7 +1610,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1564
1610
  durationMs: spentMs,
1565
1611
  hash,
1566
1612
  ...(attempt > 1 ? { attempts: attempt } : {}),
1567
- ...(failedAttempts.length > 0 ? { failedAttempts } : {}),
1613
+ ...(failedAttempts.length > 0 ? { failedAttempts, attemptOutputs } : {}),
1568
1614
  ...(described !== undefined
1569
1615
  ? { inputFiles: captured.reduce((n, c) => (c.kind === 'file' ? n + 1 : n), 0) }
1570
1616
  : {}),
@@ -1572,6 +1618,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1572
1618
  ...(unkeyed ? { unkeyed: true as const } : {}),
1573
1619
  ...(keylessBy !== undefined ? { keylessBy } : {}),
1574
1620
  ...(saved ? { saved: true as const } : {}),
1621
+ ...(unsavedBytes !== undefined ? { outputBytes: unsavedBytes } : {}),
1575
1622
  ...(effectiveExitCode !== 0 && output !== undefined
1576
1623
  ? { failedOutput: failedOutputLog(output, willSave || args.keepRun !== undefined, storeMask) }
1577
1624
  : {}),
@@ -12,7 +12,12 @@
12
12
  // it is never remembered.
13
13
 
14
14
  import type { FoldMemo } from '../cache/index.js'
15
- import { buildTaskGraph, declaredTask, type TaskNode } from '../graph/index.js'
15
+ import {
16
+ buildTaskGraph,
17
+ computeReverseDepCount,
18
+ declaredTask,
19
+ type TaskNode,
20
+ } from '../graph/index.js'
16
21
  import { xxh3hex } from '../util/index.js'
17
22
  import type { ProjectMeta } from '../workspace/index.js'
18
23
  import { codeIdentity } from './run-keys.js'
@@ -138,3 +143,47 @@ function rebuilt(
138
143
  }
139
144
  return nodes
140
145
  }
146
+
147
+ /**
148
+ * The run's dependents count, the scheduler's baseline and every row's
149
+ * `blocks`, read back with the graph it was counted on: the closure over a
150
+ * 9,603-task graph (11k nodes) was ~100 ms of every warm run, the same each
151
+ * time (2026-10-29). `token` names the graph's edges (`memoGraph`, undefined
152
+ * once anything edited them), the node ids which of them this run kept
153
+ * (`--affected` prunes under the same token).
154
+ */
155
+ export function graphCounts(
156
+ nodes: ReadonlyMap<string, TaskNode>,
157
+ folds: FoldMemo | undefined,
158
+ token: string | undefined,
159
+ ): Map<string, number> {
160
+ if (folds === undefined || token === undefined) return computeReverseDepCount(nodes)
161
+ // Not `counts`: those rows held a second count the rows no longer show.
162
+ const id = `\0dependents\0${token}`
163
+ const probe = xxh3hex([...nodes.keys()].join('\0'))
164
+ const row = folds.get(id, probe)
165
+ if (row !== undefined) {
166
+ const back = countsOf(row, nodes)
167
+ if (back !== undefined) return back
168
+ }
169
+ const counts = computeReverseDepCount(nodes)
170
+ folds.set(id, probe, JSON.stringify([...nodes.keys()].map((k) => counts.get(k)!)))
171
+ return counts
172
+ }
173
+
174
+ function countsOf(
175
+ row: string,
176
+ nodes: ReadonlyMap<string, TaskNode>,
177
+ ): Map<string, number> | undefined {
178
+ let all: number[]
179
+ try {
180
+ all = JSON.parse(row) as number[]
181
+ } catch {
182
+ return undefined
183
+ }
184
+ if (all?.length !== nodes.size) return undefined
185
+ const counts = new Map<string, number>()
186
+ let i = 0
187
+ for (const id of nodes.keys()) counts.set(id, all[i++]!)
188
+ return counts
189
+ }
@@ -29,6 +29,12 @@ export async function applyGraphStage(
29
29
  // keyed and cleaned its outputs there: past the hard project boundary.
30
30
  const dirs = new Map(args.projectMetas.map((m) => [m.name, path.resolve(m.dir)]))
31
31
  const shown = (dir: string): string => relPosix(args.workspaceRoot, dir) || '.'
32
+ // The lock shares a task config two tasks declare alike: an edit in place
33
+ // works on this node's own copy.
34
+ if (plugins.some((p) => p.graph !== undefined)) {
35
+ for (const n of nodes.values())
36
+ if (!isDefaultBuild(n.config)) n.config = structuredClone(n.config)
37
+ }
32
38
  await applyGraphHooks(
33
39
  plugins,
34
40
  nodes,
@@ -9,7 +9,6 @@
9
9
  // 2026-09-10 as pure code motion, the mirror of miss-save.ts.
10
10
 
11
11
  import path from 'node:path'
12
- import type { CacheConfig } from '../config.js'
13
12
  import {
14
13
  type CacheEntry,
15
14
  cleanOutputPaths,
@@ -58,9 +57,9 @@ export interface RestoreHitArgs {
58
57
  export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
59
58
  const { args, hash } = given
60
59
  const { node } = args
61
- const cacheCfg: CacheConfig | undefined = node.config.cache
62
- const outputs = cacheCfg?.outputs.files ?? []
63
- const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
60
+ const cfgOut = node.config.outputs
61
+ const outputs = cfgOut?.files ?? []
62
+ const wsOutputs = cfgOut?.workspaceFiles ?? []
64
63
  const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
65
64
  // A layer that handed no rows: one SELECT, read by both paths below.
66
65
  const restore =
@@ -399,8 +398,8 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
399
398
  export function currentHit(restore: RestoreHitArgs): (() => TaskOutcome) | undefined {
400
399
  const { args, hash, hit } = restore
401
400
  const { node } = args
402
- const outputs = node.config.cache?.outputs.files ?? []
403
- if ((node.config.cache?.outputs.workspaceFiles?.length ?? 0) > 0) return undefined
401
+ const outputs = node.config.outputs?.files ?? []
402
+ if ((node.config.outputs?.workspaceFiles?.length ?? 0) > 0) return undefined
404
403
  if (outputs.length > 0) {
405
404
  const dirPrefixes = dirPrefixesOf(outputs)
406
405
  if (dirPrefixes === null || args.cache.outputDirsCurrent === undefined) return undefined
@@ -60,6 +60,7 @@ export {
60
60
  type CommandContext,
61
61
  type GraphHookContext,
62
62
  type KeyHookContext,
63
+ type LockedFile,
63
64
  type PluginCommand,
64
65
  type ProjectHookContext,
65
66
  type ScheduleHookContext,
@@ -78,6 +79,7 @@ export {
78
79
  type RunReport,
79
80
  type RunReportTask,
80
81
  type FingerprintChange,
82
+ type FingerprintDigests,
81
83
  type FingerprintClaim,
82
84
  type FingerprintContext,
83
85
  type VxPlugin,
@@ -95,6 +97,7 @@ export {
95
97
  applyScheduleHooks,
96
98
  claimedAffected,
97
99
  claimedExtraFiles,
100
+ claimedDigests,
98
101
  fingerprintClaims,
99
102
  hasHook,
100
103
  REMOTE_CACHE_METHODS,
@@ -63,3 +63,31 @@ export function keyedProjects(
63
63
  return below.get(root.id)!
64
64
  }
65
65
  }
66
+
67
+ /**
68
+ * A run's lookup of the exec tasks whose outputs a sandboxed task may read:
69
+ * those its key folds, through a group to its members, never past an exec
70
+ * task. Their outputs are what their keys answer for, and this key folds
71
+ * those keys, so a read of them can never replay stale (the toolchain
72
+ * rule: a `tools` project builds Node, every dependant runs it). A
73
+ * dependency `inputs.tasks` leaves out is ordered, not read.
74
+ */
75
+ export function readableDeps(
76
+ nodes: ReadonlyMap<string, TaskNode>,
77
+ keyOnly: ReadonlyMap<string, TaskNode> = new Map(),
78
+ ): (node: TaskNode) => readonly TaskNode[] {
79
+ const nodeOf = (id: string): TaskNode => nodes.get(id) ?? keyOnly.get(id)!
80
+ return (root) => {
81
+ const out = new Map<string, TaskNode>()
82
+ const seen = new Set<string>()
83
+ const stack = foldedDeps(root, nodeOf).map((c) => c.node)
84
+ while (stack.length > 0) {
85
+ const dep = stack.pop()!
86
+ if (seen.has(dep.id)) continue
87
+ seen.add(dep.id)
88
+ if (!isGroupTask(dep)) out.set(dep.id, dep)
89
+ else for (const { node } of foldedDeps(dep, nodeOf)) stack.push(node)
90
+ }
91
+ return [...out.values()]
92
+ }
93
+ }
@@ -1,6 +1,9 @@
1
- // The live list's `cpu` and `mem`: what each running task's process tree
2
- // uses now. Looked at on a timer only once the list asks for a row, so a
3
- // run with no live list (a pipe, CI) registers pids and reads nothing.
1
+ // The live list's `cpu` and `mem`: each running task's process tree's
2
+ // average cores since it started and its peak memory so far, the values
3
+ // its results row shows once it ends (2026-10-29: the live row showed the
4
+ // last half second's, and a task's row changed meaning as it finished).
5
+ // Looked at on a timer only once the list asks for a row, so a run with
6
+ // no live list (a pipe, CI) registers pids and reads nothing.
4
7
  // The look is async and off the dispatch path: a slow one skips a tick.
5
8
 
6
9
  import { sampleTrees, type TreeUsage } from '../exec/index.js'
@@ -19,7 +22,10 @@ const everyInterval: Every = (ms, fn) => {
19
22
 
20
23
  interface Tracked {
21
24
  pid: number
22
- last?: { ts: number; cpuMs: number }
25
+ startedAt: number
26
+ /** The most CPU time the tree has shown: a descendant that exits takes its share out of the sum. */
27
+ cpuMs: number
28
+ peakRssBytes: number
23
29
  now?: LiveUsage
24
30
  }
25
31
 
@@ -51,13 +57,12 @@ export function createLiveUsage(
51
57
  for (const [id, t] of entries) {
52
58
  const u = usage.get(t.pid)
53
59
  if (u === undefined || tracked.get(id) !== t) continue
54
- // A descendant that exited takes its CPU out of the live sum, so
55
- // an interval can read negative: none is shown below zero.
56
- if (t.last !== undefined && ts > t.last.ts) {
57
- const cores = Math.max(0, (u.cpuMs - t.last.cpuMs) / (ts - t.last.ts))
58
- t.now = { cores: Math.round(cores * 100) / 100, rssBytes: u.rssBytes }
60
+ t.cpuMs = Math.max(t.cpuMs, u.cpuMs)
61
+ t.peakRssBytes = Math.max(t.peakRssBytes, u.rssBytes)
62
+ if (ts > t.startedAt) {
63
+ const cores = t.cpuMs / (ts - t.startedAt)
64
+ t.now = { cores: Math.round(cores * 100) / 100, rssBytes: t.peakRssBytes }
59
65
  }
60
- t.last = { ts, cpuMs: u.cpuMs }
61
66
  }
62
67
  } catch {
63
68
  // A failed look is a missing point, never a broken run.
@@ -71,7 +76,7 @@ export function createLiveUsage(
71
76
 
72
77
  return {
73
78
  track(taskId, pid) {
74
- const t: Tracked = { pid }
79
+ const t: Tracked = { pid, startedAt: now(), cpuMs: 0, peakRssBytes: 0 }
75
80
  tracked.set(taskId, t)
76
81
  start()
77
82
  return () => {
@@ -32,7 +32,7 @@
32
32
  // - Only STABLE-key, cacheable, local-read tasks are classified. A
33
33
  // stable miss stays in the normal (dep-gated) schedule; an unstable
34
34
  // task is never probed here.
35
- // - A task that declares `cache.outputs.workspaceFiles` (the
35
+ // - A task that declares `outputs.workspaceFiles` (the
36
36
  // boundary-ignoring escape hatch) can write where a restore touches,
37
37
  // so every task whose project directory a workspace-output glob's
38
38
  // static prefix REACHES stays out of the restore tier, and so does
@@ -261,7 +261,7 @@ export function restoreTierExclusions(
261
261
  const prefixes: string[] = []
262
262
  let everything = false
263
263
  for (const node of nodes.values()) {
264
- for (const raw of node.config.cache?.outputs.workspaceFiles ?? []) {
264
+ for (const raw of node.config.outputs?.workspaceFiles ?? []) {
265
265
  // A `!` entry needs no skip: its prefix (`!gen`) names no directory,
266
266
  // so it excludes nothing (A-44; the `continue` that stood here was
267
267
  // dropped in item 640).