@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -2,17 +2,14 @@
2
2
  // run with caching. Each step delegates to a single-purpose sibling file
3
3
  // so the layers can be swapped without touching the others.
4
4
 
5
- import os from 'node:os'
6
- import {
7
- type CacheLayer,
8
- type CachePolicy,
9
- FULL_CACHE_POLICY,
10
- type InvocationRecord,
11
- LayeredCache,
12
- type RunRecord,
13
- } from '../cache/index.js'
5
+ import type { ProjectEntry } from '../workspace/index.js'
6
+ import path from 'node:path'
7
+ import { type CacheLayer, type CachePolicy, FULL_CACHE_POLICY } from '../cache/index.js'
14
8
  import { VERSION } from '../version.js'
15
- import { initSandbox, probeSandbox, resetSandbox, signalExitCode } from '../exec/index.js'
9
+ import { resetSandbox, VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from '../exec/index.js'
10
+ import { DeferredOutputs } from './deferred-outputs.js'
11
+ import { resolveDownloadModes } from './download-policy.js'
12
+ import type { TaskExecutor } from '../exec/index.js'
16
13
  import {
17
14
  isGroupTask,
18
15
  markSurfacedDeps,
@@ -20,24 +17,40 @@ import {
20
17
  type TaskNode,
21
18
  type TaskOutcome,
22
19
  } from '../graph/index.js'
23
- import { ulid, UserError } from '../util/index.js'
24
- import { executeTask } from './execute-task.js'
25
- import { resolveResourceCosts } from './resources.js'
26
- import { computeTaskHash } from './task-hash.js'
20
+ import {
21
+ mark,
22
+ MAX_TIMEOUT_MS,
23
+ printTimings,
24
+ ulid,
25
+ nearest,
26
+ UserError,
27
+ machineParallelism,
28
+ } from '../util/index.js'
29
+ import { prepareSandbox } from './sandbox-request.js'
30
+ import type { OutputDirSnapshot } from './miss-save.js'
31
+ import { admitTasks, taintTracker } from './admission.js'
27
32
  import { busLogger, createEventBus, terminalSubscriber } from './events.js'
28
33
  import { installPlugins } from './plugin.js'
29
- import type { VxPlugin } from './plugin.js'
30
- import { subscribeEventSinks, teardownPlugins } from './plugin-host.js'
31
- import type { SubscribedEventSinks } from './plugin-host.js'
34
+ import { buildAdmission, resolveExecutors, teardownPlugins } from './plugin-host.js'
32
35
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
33
- import { deriveCacheSource, TELEMETRY_SCHEMA_VERSION } from './telemetry.js'
34
- import type { RunContextRecord, RunSummaryRecord, TaskTelemetry } from './telemetry.js'
36
+ import { assembleRunSummary, isPassStatus } from './telemetry.js'
37
+ import type { RunContextRecord } from './telemetry.js'
35
38
  import { defaultLogger, resolveOutputView } from './logger.js'
36
39
  import { detectColors } from './colors.js'
37
40
  import { formatPersistentList } from './framed-output.js'
41
+ import { LocalHistoryProvider } from './history.js'
38
42
  import { plan, type RunPlan } from './plan.js'
39
43
  import { prepareRun } from './prepare.js'
44
+ import { forwardSignals, terminateChildren } from './signals.js'
40
45
  import {
46
+ hasPooledExecutor,
47
+ placeTasks,
48
+ planExecutorOf,
49
+ poolOfPlacement,
50
+ UNPLACED_EXECUTOR,
51
+ } from './placement.js'
52
+ import {
53
+ captureDefaultBranch,
41
54
  captureGitContext,
42
55
  captureHostContext,
43
56
  captureWorkspaceIdentity,
@@ -45,24 +58,39 @@ import {
45
58
  } from './run-context.js'
46
59
  import { startRemotePrefetch } from './remote-prefetch.js'
47
60
  import { startLocalShortCircuit, type ShortCircuit } from './local-shortcircuit.js'
48
- import { formatVerifySection } from './verify.js'
49
61
 
50
- const EMPTY_SHORT_CIRCUIT: ShortCircuit = { preProbed: new Map(), restoreTier: new Set() }
62
+ import { assembleRunRecords } from './run-records.js'
63
+ import { selectKeepAlive, shutdownPersistent } from './persistent.js'
51
64
  import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
52
- import { formatRunSummary } from './summary.js'
65
+ import { createSaveLane } from './save-lane.js'
66
+ import { formatAbortedSection, formatFlakySection, formatRunSummary } from './summary.js'
67
+ import { detectFlaky, type FlakyCandidate } from './failure-mode.js'
53
68
  import type { RunOptions, RunSummary } from './options.js'
54
69
 
70
+ // Per run, never shared: a `vx watch` process runs many, and a shared map
71
+ // is one `preProbed.set` away from leaking a hit across cycles.
72
+ const emptyShortCircuit = (): ShortCircuit => ({ preProbed: new Map(), restoreTier: new Set() })
73
+
55
74
  /**
56
75
  * Parse the `VX_TASK_TIMEOUT` env var (ms) — the "global" run-level task
57
76
  * timeout default. A missing/empty/non-positive-integer value yields
58
77
  * `undefined` (ignored), so a typo never silently disables a task's own
59
- * `exec.timeout`.
78
+ * `exec.timeout`. A value past `MAX_TIMEOUT_MS` is clamped to it — see below.
79
+ *
80
+ * Exported for `tests/options-resolve.test.ts`, which pins every accepted
81
+ * and ignored form of this rung; it has no other caller outside this file.
60
82
  */
61
- function readTaskTimeoutEnv(): number | undefined {
83
+ export function readTaskTimeoutEnv(): number | undefined {
62
84
  const raw = process.env['VX_TASK_TIMEOUT']
63
85
  if (raw === undefined || raw === '') return undefined
64
86
  const n = Number(raw)
65
- return Number.isInteger(n) && n > 0 ? n : undefined
87
+ if (!Number.isInteger(n) || n <= 0) return undefined
88
+ // CLAMPED, not refused — unlike `exec.timeout` and `--timeout`, this rung's
89
+ // contract is already "unusable value → fall back", so it never throws. But
90
+ // falling back is wrong here: someone who typed a huge number wants a long
91
+ // timeout, and the largest expressible one IS long (~24.8 days). Handing it
92
+ // to `setTimeout` unbounded would mean 1 ms — killing every task instantly.
93
+ return Math.min(n, MAX_TIMEOUT_MS)
66
94
  }
67
95
 
68
96
  /**
@@ -79,32 +107,49 @@ function compactCachePolicy(p: CachePolicy): string {
79
107
  return parts.join(',')
80
108
  }
81
109
 
110
+ /**
111
+ * The policy that will actually govern the run. The remote axes are inert
112
+ * without a remote layer to serve them: a `remote:w` policy over a bare
113
+ * local cache writes NOWHERE, yet the write axis reads as on. Normalising
114
+ * here — the ONE place both `run()` and `planRun()` derive it from — is
115
+ * what keeps `--dry` describing the run you are about to get: reading the
116
+ * raw request made the plan label `--cache=local:,remote:rw` "cache miss —
117
+ * would exec" (a result that will be stored) for a run in which caching is
118
+ * entirely off.
119
+ */
120
+ function effectiveCachePolicy(requested: CachePolicy, hasRemoteLayer: boolean): CachePolicy {
121
+ return hasRemoteLayer ? requested : { ...requested, remoteRead: false, remoteWrite: false }
122
+ }
123
+
82
124
  /**
83
125
  * The perf firewall for the local short-circuit. Always-on; the only
84
126
  * gates are correctness/no-op gates:
85
- * - LOCAL-ONLY cache. Under a LayeredCache, `cache.get` is a remote
127
+ * - LOCAL-ONLY cache. Behind a remote layer, `cache.get` is a remote
86
128
  * READ-THROUGH — the up-front classify is awaited before scheduling,
87
129
  * so N remote GETs would land on the critical path before any task
88
130
  * starts. Remote runs are owned by `startRemotePrefetch`, which
89
131
  * overlaps the GETs with execution instead (fire-and-forget) and
90
- * ingests hits into local for execute-task's lazy probe.
132
+ * ingests hits into local for execute-task's lazy probe. The gate asks
133
+ * the layer (`hasRemote`), not `instanceof LayeredCache` — a
134
+ * third-party remote layer stalls on the classify just as hard.
91
135
  * - the policy reads locally (a `--no-cache`/`--force`/`--cache=local:`
92
136
  * run reads nothing locally → nothing to restore → skip);
93
- * - the graph has at least one dependency edge (a flat graph has no
94
- * ordering to bypass — restoring is what execute() already does, so
95
- * there's no upside and we avoid the upfront probe pass).
96
- * The per-task correctness gates (stable key, no workspace-outputs in
97
- * graph) live in `startLocalShortCircuit`.
137
+ * - there is at least one task.
138
+ * A flat graph has no ordering to bypass, but the classify pass is worth
139
+ * paying for anyway: it probes every stable key through ONE batched
140
+ * `getMany` instead of a `cache.get` per task inside the run, which
141
+ * measured 84 → 78 ms on 100 dep-free tasks and 249 → 237 ms on 1000
142
+ * (2026-09-03, interleaved arms). The per-task correctness gates (stable
143
+ * key, no workspace-outputs in graph) live in `startLocalShortCircuit`.
98
144
  */
99
145
  export function shouldShortCircuit(
100
146
  nodes: Map<string, TaskNode>,
101
147
  policy: CachePolicy,
102
148
  cache: CacheLayer,
103
149
  ): boolean {
104
- if (cache instanceof LayeredCache) return false
150
+ if (cache.hasRemote === true) return false
105
151
  if (!policy.localRead) return false
106
- for (const node of nodes.values()) if (node.deps.length > 0) return true
107
- return false
152
+ return nodes.size > 0
108
153
  }
109
154
 
110
155
  export async function run(options: RunOptions): Promise<RunSummary> {
@@ -130,6 +175,34 @@ export async function run(options: RunOptions): Promise<RunSummary> {
130
175
  const log = busLogger(bus)
131
176
 
132
177
  const prepared = await prepareRun(options, log)
178
+ mark('plugin stages')
179
+ // A task whose command re-enters `vx run` in the workspace running it
180
+ // is refused. When the inner run reaches this task again it forks a run
181
+ // per run until the machine gives out; when it does not (`ci` shelling
182
+ // out to `vx run lint`) it is a nested run the outer graph cannot see —
183
+ // its tasks escape the schedule, the concurrency budget and this task's
184
+ // cache key. The markers `taskEnv` sets on every child (exec/env.ts)
185
+ // name the task; the check is on the root so both shapes are caught.
186
+ const outerRoot = process.env[VX_RUN_WORKSPACE_ENV]
187
+ if (outerRoot !== undefined && path.resolve(outerRoot) === path.resolve(prepared.workspaceRoot)) {
188
+ prepared.cache.close()
189
+ throw new UserError(
190
+ `task ${process.env[VX_RUN_TASK_ENV] ?? '<unknown>'} runs \`vx run\` inside its own workspace: a nested run is invisible to the outer graph (its tasks escape the schedule, the concurrency budget and the cache key) and a loop back to this task forks without bound. Declare what it needs with dependsOn instead.`,
191
+ )
192
+ }
193
+ // A requested name that matched no project is a typo (or a stray
194
+ // positional from an `=`-only flag written with a space). Failing the
195
+ // whole run — even when OTHER requested tasks resolved — is the point:
196
+ // a CI job that renames a task must go red, not silently stop running
197
+ // it. When EVERY name is unresolved this is the `no-tasks-declared`
198
+ // case too; the message is identical, so that branch stays below.
199
+ if (prepared.unresolvedTasks.length > 0) {
200
+ log.status(
201
+ `No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${didYouMean(prepared.unresolvedTasks, prepared.projects)}${initHint(prepared)}`,
202
+ )
203
+ prepared.cache.close()
204
+ return { ok: false, outcomes: [] }
205
+ }
133
206
  if (prepared.empty !== null) {
134
207
  // `no-tasks-declared` is almost always a typo in CI; we surface
135
208
  // a clear message and return NOT-ok so the script exits 1.
@@ -137,7 +210,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
137
210
  // buildTaskGraph semantics but logged just in case.
138
211
  const msg =
139
212
  prepared.empty === 'no-tasks-declared'
140
- ? `No projects declare task(s): ${options.tasks.join(', ')}.`
213
+ ? `No projects declare task(s): ${options.tasks.join(', ')}.${initHint(prepared)}`
141
214
  : 'No tasks to run.'
142
215
  log.status(msg)
143
216
  prepared.cache.close()
@@ -145,35 +218,22 @@ export async function run(options: RunOptions): Promise<RunSummary> {
145
218
  }
146
219
  // Install user plugins as additional bus subscribers BEFORE the run
147
220
  // starts emitting events. `installPlugins` runs each plugin's optional
148
- // `setup` hook (the old observe-only path); `subscribeEventSinks` wires
149
- // each plugin's `eventSink` capability onto the bus via wireForwarder.
150
- // Both fail-fast on a setup() throw with a clean UserError naming the
151
- // plugin; eventSink init failures are isolated (observability never
152
- // breaks a run). With no plugin, both are no-ops.
221
+ // `setup` hook and fails fast on a throw with a clean UserError naming
222
+ // the plugin. A plugin without `setup` subscribes nothing.
153
223
  let disposePlugins: (() => void) | undefined
154
- let eventSinks: SubscribedEventSinks | undefined
155
224
  let telemetry: TelemetryHandle | undefined
156
- if (prepared.workspaceConfig?.plugins && prepared.workspaceConfig.plugins.length > 0) {
157
- const plugins = prepared.workspaceConfig.plugins as readonly VxPlugin[]
158
- const pluginCtx = {
225
+ try {
226
+ disposePlugins = await installPlugins({
227
+ plugins: prepared.plugins as never,
228
+ bus,
159
229
  workspaceRoot: prepared.workspaceRoot,
160
230
  cacheDir: prepared.cacheDir,
161
- warn: (m: string) => log.status(m),
162
- }
163
- try {
164
- disposePlugins = await installPlugins({
165
- plugins: prepared.workspaceConfig.plugins as never,
166
- bus,
167
- workspaceRoot: prepared.workspaceRoot,
168
- cacheDir: prepared.cacheDir,
169
- warn: (m) => log.status(m),
170
- })
171
- eventSinks = await subscribeEventSinks(plugins, bus, pluginCtx)
172
- } catch (err) {
173
- disposePlugins?.()
174
- prepared.cache.close()
175
- throw err
176
- }
231
+ warn: (m) => log.status(m),
232
+ })
233
+ } catch (err) {
234
+ disposePlugins?.()
235
+ prepared.cache.close()
236
+ throw err
177
237
  }
178
238
  const {
179
239
  workspaceRoot,
@@ -187,10 +247,81 @@ export async function run(options: RunOptions): Promise<RunSummary> {
187
247
  hashCache,
188
248
  workspaceProjectCount,
189
249
  } = prepared
190
- const concurrency =
191
- options.concurrency ??
192
- workspaceConfig?.concurrency ??
193
- Math.max(1, navigator.hardwareConcurrency)
250
+ const concurrency = options.concurrency ?? workspaceConfig?.concurrency ?? machineParallelism()
251
+
252
+ // Resolved ONCE per run, in declaration order, the local executor last.
253
+ // A broken factory aborts here, before any task starts.
254
+ let executors: readonly TaskExecutor[]
255
+ try {
256
+ executors = await resolveExecutors(prepared.plugins, {
257
+ workspaceRoot: prepared.workspaceRoot,
258
+ cacheDir: prepared.cacheDir,
259
+ warn: (m: string) => log.status(m),
260
+ concurrency,
261
+ })
262
+ } catch (err) {
263
+ disposePlugins?.()
264
+ prepared.cache.close()
265
+ throw err
266
+ }
267
+ // Placement: decided ONCE per task, before scheduling, so the scheduler
268
+ // can admit a remote-pooled task against its pool instead of a local
269
+ // worker slot. Group tasks run nothing; persistent tasks never reach an
270
+ // executor (local by construction) — both stay off the map.
271
+ const placements = placeTasks(nodes, executors, false)
272
+ // A `remote: 'only'` task nobody takes succeeds WITHOUT running. That is
273
+ // deliberate — on a machine with no remote pool the ambient state already
274
+ // is what the task would have produced — but it must not be SILENT: a task
275
+ // reporting success while doing nothing is indistinguishable from one that
276
+ // worked, and the usual cause (no `cache` block, so no executor can
277
+ // describe its inputs to a worker) cannot be read off the outcome.
278
+ if (placements.remoteOnlyNoop.size > 0) {
279
+ const anyRemote = executors.some((e) => e.remote === true)
280
+ for (const id of placements.remoteOnlyNoop) {
281
+ const why = !anyRemote
282
+ ? 'no remote executor is declared'
283
+ : nodes.get(id)?.config.cache === undefined
284
+ ? 'no remote executor accepted it — the task declares no `cache` block, so there is no input set to ship'
285
+ : 'no remote executor accepted it'
286
+ log.status(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
287
+ }
288
+ }
289
+ // Demand: what each executor still has left to run. An executor that
290
+ // provisions per task (a container, an allocation, a pod) otherwise has to
291
+ // guess from `capacity` alone and hold everything until teardown. Built
292
+ // ONLY for executors that asked — with none declared there is no map, no
293
+ // set, and no call in the completion path.
294
+ const demandOf = new Map<TaskExecutor, Set<string>>()
295
+ for (const [id, executor] of placements.executors) {
296
+ if (executor.demand === undefined) continue
297
+ let remaining = demandOf.get(executor)
298
+ if (remaining === undefined) demandOf.set(executor, (remaining = new Set()))
299
+ remaining.add(id)
300
+ }
301
+ // The SAME set is handed back every time, narrowed in place: a consumer
302
+ // reading it later sees the truth rather than a stale snapshot.
303
+ for (const [executor, remaining] of demandOf) executor.demand!(remaining)
304
+ const narrowDemand =
305
+ demandOf.size === 0
306
+ ? (): void => {}
307
+ : (id: string): void => {
308
+ for (const [executor, remaining] of demandOf) {
309
+ if (remaining.delete(id)) executor.demand!(remaining)
310
+ }
311
+ }
312
+
313
+ // `--download` (default `all`) decides ONCE per task, here, whether a
314
+ // remote execution's outputs come home.
315
+ const downloadPolicy = options.download ?? 'all'
316
+ const localPlaced = new Set(
317
+ [...nodes.keys()].filter((id) => placements.executors.get(id)?.remote !== true),
318
+ )
319
+ const download = resolveDownloadModes({
320
+ nodes,
321
+ policy: downloadPolicy,
322
+ localPlaced,
323
+ remoteOnly: placements.remoteOnly,
324
+ })
194
325
 
195
326
  // Run-level default task timeout (ms), applied to any task WITHOUT its own
196
327
  // `exec.timeout`. Precedence, highest first: `--timeout`/RunOptions.timeout
@@ -208,38 +339,39 @@ export async function run(options: RunOptions): Promise<RunSummary> {
208
339
  // exit; ownership moves here so the orchestrator can SIGTERM
209
340
  // them once the rest of the graph finishes.
210
341
  //
211
- // A SIGINT/SIGTERM mid-run forwards SIGTERM to everything live,
212
- // closes the cache handle, and exits 128+signo (130/143). Without
213
- // this, a programmatic signal to the vx process alone (CI
214
- // cancellation, `kill <pid>`) orphans every running child —
215
- // terminal Ctrl-C only worked via process-group propagation. The
216
- // handlers are removed in the finally below so repeated run()
217
- // calls (test suites) never stack listeners.
342
+ // A SIGINT/SIGTERM mid-run forwards SIGTERM to everything in both
343
+ // (`signals.ts`); the handlers are removed in the finally below so
344
+ // repeated run() calls (test suites) never stack listeners.
218
345
  const liveChildren = new Set<ReturnType<typeof Bun.spawn>>()
219
346
  const persistentRegistry = new Map<string, ReturnType<typeof Bun.spawn>>()
220
- const onSignal = (signal: 'SIGINT' | 'SIGTERM'): void => {
221
- // Clear the live worker/status region BEFORE exiting so a TTY isn't
222
- // left with a frozen region frozen in the scrollback (the documented
223
- // KNOWN-OPEN). runEnd is idempotent and a no-op for non-TTY loggers.
224
- try {
225
- log.runEnd?.()
226
- } catch {
227
- // teardown must not throw on the way out
228
- }
229
- for (const child of liveChildren) child.kill('SIGTERM')
230
- for (const child of persistentRegistry.values()) child.kill('SIGTERM')
231
- try {
232
- cache.close()
233
- } catch {
234
- // double-close race with the normal path; we're exiting anyway
235
- }
236
- process.exit(signalExitCode(signal))
347
+ const signals = forwardSignals({
348
+ enabled: options.handleSignals ?? true,
349
+ log,
350
+ cache,
351
+ liveChildren,
352
+ persistentRegistry,
353
+ })
354
+ // `RunOptions.signal`: the same teardown the process handler runs, minus
355
+ // the exit — the scheduler stops dispatching (it reads the signal) and
356
+ // run() returns to its caller. Detached in the finally below.
357
+ const onAbort = (): void => {
358
+ void terminateChildren(() => [...liveChildren, ...persistentRegistry.values()])
237
359
  }
238
- const onSigint = (): void => onSignal('SIGINT')
239
- const onSigterm = (): void => onSignal('SIGTERM')
240
- if (options.handleSignals ?? true) {
241
- process.on('SIGINT', onSigint)
242
- process.on('SIGTERM', onSigterm)
360
+ options.signal?.addEventListener('abort', onAbort, { once: true })
361
+ // The cache handle must be released on EVERY exit path, not just the
362
+ // happy one: `close()` is also where the run's deferred `accessed_at`
363
+ // bumps are flushed, so a throw between opening the cache and the
364
+ // normal close leaked the SQLite handle (it matters in a long-lived
365
+ // host) AND lost this run's touch record, after which an LRU
366
+ // `vx cache prune` can evict entries the run just hit. Once-only
367
+ // because the normal path closes before the persistent-task wait —
368
+ // holding the handle open for a dev server's whole lifetime would be
369
+ // worse — and because close() re-runs its retention DELETEs.
370
+ let cacheClosed = false
371
+ const closeCache = (): void => {
372
+ if (cacheClosed) return
373
+ cacheClosed = true
374
+ cache.close()
243
375
  }
244
376
  try {
245
377
  // One run-id per `vx run` invocation. Every task in the resulting
@@ -247,22 +379,29 @@ export async function run(options: RunOptions): Promise<RunSummary> {
247
379
  const runId = ulid()
248
380
  const runStartHrTimeNs = process.hrtime.bigint()
249
381
  const endedAtMsAtStart = Date.now()
250
- const remoteCacheEnabled = cache instanceof LayeredCache
251
- const policy: CachePolicy = options.cache ?? FULL_CACHE_POLICY
252
-
253
- // `--verify` observes the miss-then-save path, so a policy with NO write
254
- // axis (`--no-cache`, `--cache=local:,remote:`) verifies nothing — every
255
- // task would come back green with zero verdicts. The user asked for a
256
- // proof; silently skipping it is the one failure mode verification must
257
- // never have (same platform-honesty rule as the sandbox-unavailable
258
- // error). `--force --verify` is the supported re-verify-warm recipe.
259
- if (options.verify !== undefined && !policy.localWrite && !policy.remoteWrite) {
260
- prepared.cache.close()
261
- throw new UserError(
262
- '--verify needs cache writes to prove anything (it verifies the save path); ' +
263
- 'drop --no-cache, or use --force --verify to re-execute and verify a warm graph',
382
+ const remoteCacheEnabled = prepared.hasRemoteLayer
383
+ // Normalised ONCE so every consumer — execute-task, the dedup
384
+ // predicate, the recorded invocation row — reads the policy that
385
+ // actually governed the run. Reading the raw request here made tasks
386
+ // clean their outputs before every exec for a save that never
387
+ // happened.
388
+ const policy: CachePolicy = effectiveCachePolicy(
389
+ options.cache ?? FULL_CACHE_POLICY,
390
+ prepared.hasRemoteLayer,
391
+ )
392
+ if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
393
+ log.status(
394
+ 'vx: --cache named the remote cache, but no cache plugin in vx.workspace.* supplies one — the remote axes are off for this run',
264
395
  )
265
396
  }
397
+ const deferredOutputs = new DeferredOutputs({
398
+ nodes,
399
+ cache,
400
+ workspaceRoot,
401
+ nestedDirsByProject,
402
+ ...(gitFilesCache !== undefined ? { gitFilesCache } : {}),
403
+ localWrite: policy.localWrite,
404
+ })
266
405
 
267
406
  // Per-run context for the Tier-3 `invocations` header row. Captured
268
407
  // ONCE (git is ONE spawn for commit+branch, behind try/catch; never
@@ -275,16 +414,21 @@ export async function run(options: RunOptions): Promise<RunSummary> {
275
414
 
276
415
  // The canonical run-context record — the same git/CI/host data the
277
416
  // invocation header uses, shaped as the telemetry export contract.
278
- // Built + consulted ONLY when plugins are declared (an explicit opt-in),
279
- // and BEFORE run:start is emitted so a sink catches the whole stream.
417
+ // Built + consulted ONLY when a plugin CONTRIBUTES `telemetry`, and
418
+ // BEFORE run:start is emitted so a sink
419
+ // catches the whole stream.
280
420
  // subscribeTelemetry returns undefined when no sink is contributed (no
281
421
  // telemetry plugin, or all declined — e.g. otel() with no OTLP endpoint),
282
422
  // so a plain run does ZERO extra work: no record allocation, no bus
283
423
  // subscriber, no summary building. The hot path stays off-limits.
284
424
  let runContextRecord: RunContextRecord | undefined
285
- const hasPlugins =
286
- prepared.workspaceConfig?.plugins !== undefined && prepared.workspaceConfig.plugins.length > 0
287
- if (hasPlugins || options.telemetrySinks !== undefined) {
425
+ // Gate on a plugin that can actually CONTRIBUTE a sink, not on declaring
426
+ // any plugin at all: a cache-only plugin has no telemetry hook to
427
+ // consult, so paying for the record (2 git spawns + a `.vx/workspace-id`
428
+ // write on a remote-less repo) buys nothing. A plugin that has the hook
429
+ // but declines still pays — its answer is only knowable by asking.
430
+ const hasTelemetryPlugin = prepared.plugins.some((p) => p.telemetry !== undefined)
431
+ if (hasTelemetryPlugin || options.telemetrySinks !== undefined) {
288
432
  // Workspace identity (telemetry v2): one git spawn, paid only when a
289
433
  // telemetry consumer can exist — a plain run never reaches here.
290
434
  const wsIdentity = captureWorkspaceIdentity(workspaceRoot)
@@ -298,6 +442,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
298
442
  flow: options.flow ?? null,
299
443
  commitSha: gitContext.commitSha,
300
444
  branch: gitContext.branch,
445
+ defaultBranch: captureDefaultBranch(process.env, workspaceRoot),
301
446
  dirty: gitContext.dirty,
302
447
  ci: ciContext.ci,
303
448
  ciProvider: ciContext.provider,
@@ -309,7 +454,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
309
454
  tags: options.tags ?? {},
310
455
  }
311
456
  telemetry = await subscribeTelemetry(
312
- (prepared.workspaceConfig?.plugins ?? []) as readonly VxPlugin[],
457
+ prepared.plugins,
313
458
  bus,
314
459
  { workspaceRoot, cacheDir, warn: (m: string) => log.status(m) },
315
460
  runContextRecord,
@@ -317,27 +462,15 @@ export async function run(options: RunOptions): Promise<RunSummary> {
317
462
  )
318
463
  }
319
464
 
320
- // Lazy SRT init: fire it up if at least one task opts into sandboxing
321
- // via its `sandbox: {...}` block, OR `--verify=inputs`/`=all` is on (it
322
- // forces the declared-input baseline sandbox onto every cacheable task to
323
- // prove input-completeness). Tasks that need sandboxing on an unsupported
324
- // platform get a hard error so they don't silently run unsandboxed —
325
- // `--verify=inputs` in particular must fail loud, never falsely "pass".
326
- const verifyInputs = options.verify?.inputs === true
327
- const anySandboxed =
328
- verifyInputs || [...nodes.values()].some((n) => n.config.sandbox !== undefined)
329
- if (anySandboxed) {
330
- const avail = await probeSandbox()
331
- if (!avail.available) {
332
- prepared.cache.close()
333
- throw new UserError(
334
- verifyInputs
335
- ? `--verify=inputs needs the sandbox, which is not available: ${avail.reason}`
336
- : `sandbox not available: ${avail.reason}`,
337
- )
338
- }
339
- await initSandbox()
340
- }
465
+ const sandboxArmer = prepareSandbox(nodes.values())
466
+ const outputDirSnapshots: OutputDirSnapshot[] = []
467
+ // Saves run off the execution slot, twice the cap at once (memory:
468
+ // each pack holds an artifact's bytes); a failed save is a miss next
469
+ // time, said once — the task's work ran.
470
+ const saveLane = createSaveLane(2 * concurrency, (err) =>
471
+ log.status(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
472
+ )
473
+ const deferredSaves = new Map<string, Promise<void>>()
341
474
 
342
475
  // Focused flow: a requested GROUP has no output of its own, so
343
476
  // surface the same-project, non-group tasks it chains (one level)
@@ -361,16 +494,10 @@ export async function run(options: RunOptions): Promise<RunSummary> {
361
494
  if (node.requested || node.surfaced === true) requestedCount++
362
495
  }
363
496
  }
364
- // Resource-aware admission: resolve every task's `exec.resources`
365
- // into absolute costs ONCE, up front (percent forms against the
366
- // budgets), so the scheduler's inner loop is a plain Map.get. The
367
- // CPU budget is the run's concurrency; the memory budget is
368
- // os.totalmem() unless `--memory` overrides it (pass `--memory` in
369
- // cgroup-limited containers — totalmem() reports the HOST's RAM).
370
- // Nothing declared → empty map → fields omitted from the scheduler
371
- // AND the footer → byte-identical legacy path.
372
- const memBudget = options.memory ?? os.totalmem()
373
- const resourceCosts = resolveResourceCosts(nodes, concurrency, memBudget)
497
+ // Admission over the worker count is a plugin's (`admit`): built once
498
+ // into the predicate the scheduler asks at every local dispatch. No
499
+ // plugin answers → undefined → the count-only path, byte for byte.
500
+ const admit = buildAdmission(prepared.plugins, nodes, concurrency, (m) => log.status(m))
374
501
 
375
502
  // Run context for the footer. The top-of-run header is gone — the
376
503
  // banner now lives in the summary, where the eye lands at the end.
@@ -380,22 +507,27 @@ export async function run(options: RunOptions): Promise<RunSummary> {
380
507
  remoteCacheEnabled,
381
508
  concurrency,
382
509
  workspaceProjectCount,
383
- ...(resourceCosts.size > 0 ? { cpuBudget: concurrency, memBudget } : {}),
384
510
  }
385
511
 
386
512
  // Lifecycle hooks drive the default logger's dynamic status line
387
513
  // (TTY-only); custom loggers may ignore them.
388
- log.runStart?.({ total: taskCount, concurrency, requestedCount, context: runContext })
514
+ log.runStart?.({
515
+ total: taskCount,
516
+ concurrency,
517
+ requestedCount,
518
+ context: runContext,
519
+ startedAtMs: endedAtMsAtStart,
520
+ })
389
521
 
390
522
  // Remote-only: kick off background prefetches so remote-GET latency
391
523
  // overlaps execution. Fire-and-forget — execution starts on the next
392
- // line; LayeredCache ingests hits into local and de-dups so
524
+ // line; the layer ingests hits into local and de-dups so
393
525
  // execute-task's cache.get awaits the in-flight promise (one remote
394
526
  // GET per key). Gated entirely on a remote layer being configured;
395
527
  // local-only runs never reach here, so their behavior + perf is
396
528
  // unchanged (no upfront key pass, no local probing).
397
529
  let prefetchDone: Promise<void> = Promise.resolve()
398
- if (cache instanceof LayeredCache) {
530
+ if (prepared.hasRemoteLayer) {
399
531
  prefetchDone = startRemotePrefetch({
400
532
  nodes,
401
533
  cache,
@@ -416,9 +548,9 @@ export async function run(options: RunOptions): Promise<RunSummary> {
416
548
  // restore-tier the scheduler runs ahead of their deps but only as
417
549
  // worker-slot backfill (misses own the pool) — and execute reuses
418
550
  // each probe, so there is no second cache.get. Gated by
419
- // shouldShortCircuit (localRead + has-deps); when off, both maps are
420
- // empty and the run is byte-identical.
421
- let shortCircuit: ShortCircuit = EMPTY_SHORT_CIRCUIT
551
+ // shouldShortCircuit (local reads on, no remote layer); when off, both
552
+ // maps are empty and the run is byte-identical.
553
+ let shortCircuit: ShortCircuit = emptyShortCircuit()
422
554
  if (shouldShortCircuit(nodes, policy, cache)) {
423
555
  shortCircuit = await startLocalShortCircuit({
424
556
  nodes,
@@ -433,8 +565,14 @@ export async function run(options: RunOptions): Promise<RunSummary> {
433
565
  })
434
566
  }
435
567
 
568
+ // Whether this task runs behind a failure (`continueMode: 'always'`
569
+ // only) — its save is withheld and the taint propagates; see
570
+ // admission.ts.
571
+ const isTainted = taintTracker(options.continueMode === 'always')
572
+
436
573
  const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
437
574
  const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
575
+ const taint = isTainted(node, upstream)
438
576
  return {
439
577
  node,
440
578
  upstream,
@@ -445,8 +583,12 @@ export async function run(options: RunOptions): Promise<RunSummary> {
445
583
  forwardArgs: options.forwardArgs,
446
584
  ...(options.retries !== undefined ? { retries: options.retries } : {}),
447
585
  ...(taskTimeoutDefault !== undefined ? { timeout: taskTimeoutDefault } : {}),
448
- ...(options.verify !== undefined ? { verify: options.verify } : {}),
449
586
  log,
587
+ executor: placements.executors.get(node.id) ?? UNPLACED_EXECUTOR,
588
+ ...(download.modeOf.get(node.id) === 'deferred' ? { download: 'deferred' as const } : {}),
589
+ deferred: deferredOutputs,
590
+ ...(placements.remoteOnlyNoop.has(node.id) ? { remoteOnlyNoop: true } : {}),
591
+ ...(placements.remoteOnly.has(node.id) ? { remoteOnly: true } : {}),
450
592
  nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
451
593
  runStartHrTimeNs,
452
594
  persistentRegistry,
@@ -454,153 +596,72 @@ export async function run(options: RunOptions): Promise<RunSummary> {
454
596
  gitFilesCache,
455
597
  hashCache,
456
598
  ...(probe !== undefined ? { preProbed: probe } : {}),
599
+ ...(taint ? { taintedUpstream: true } : {}),
600
+ ...(sandboxArmer !== null ? { armSandbox: () => sandboxArmer.arm() } : {}),
601
+ outputDirSnapshots,
602
+ deferSave: saveLane.defer,
603
+ deferredSaves,
457
604
  }
458
605
  }
459
606
 
460
- // In-flight dedup. Only when a service supplies a shared `inflight`
461
- // registry (concurrent runs in one `vx serve`); a stateless `vx run`
462
- // passes none and takes the untouched path. Gated to cacheable tasks —
463
- // the join works by waiting for the sibling to populate the cache, then
464
- // letting executeTask cache-hit on it. executeTask stays unchanged.
465
- const inflight = options.inflight
466
- const executeWithDedup = async (
467
- node: TaskNode,
468
- upstream: TaskOutcome[],
469
- ): Promise<TaskOutcome> => {
470
- // Dedup only helps when the sibling will WRITE the artifact and
471
- // this task can READ it back — i.e. both axes effectively on.
472
- const canRead = policy.localRead || policy.remoteRead
473
- const canWrite = policy.localWrite || policy.remoteWrite
474
- const cacheable =
475
- !isGroupTask(node) &&
476
- node.config.exec?.persistent === undefined &&
477
- node.config.cache !== undefined &&
478
- canRead &&
479
- canWrite
480
- // A restore-tier task (confirmed local hit, may run before its
481
- // deps) needs no dedup — it's a restore, not an executor, and its
482
- // live `upstream` is incomplete, so the dedup hash recompute would
483
- // be wrong. Route it straight to executeTask, which reuses the
484
- // up-front probe.
485
- const restorable = shortCircuit.restoreTier.has(node.id)
486
- if (inflight === undefined || !cacheable || restorable) {
487
- return executeTask(buildExecuteArgs(node, upstream))
488
- }
489
- const hash = await computeTaskHash({
490
- node,
491
- upstream,
607
+ const executeWithDedup = admitTasks({
608
+ inflight: options.inflight,
609
+ policy,
610
+ shortCircuit,
611
+ hashArgs: {
492
612
  workspaceRoot,
493
613
  workspaceFingerprint,
494
614
  cache,
495
615
  forwardArgs: options.forwardArgs,
496
- nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
616
+ nestedDirsByProject,
497
617
  gitFilesCache,
498
618
  hashCache,
499
- })
500
- const existing = inflight.get(hash)
501
- if (existing !== undefined) {
502
- // Join a sibling already computing this exact task: wait, then
503
- // executeTask cache-hits on the artifact it just saved. The
504
- // up-front probe (preProbed) predates the sibling's save — its
505
- // "confirmed stable miss" would skip the lazy cache.get and
506
- // re-execute, defeating the dedup — so the join path drops it
507
- // and lets executeTask probe fresh.
508
- await existing.catch(() => {})
509
- return executeTask(buildExecuteArgs(node, upstream, false))
510
- }
511
- // Become the executor: register a barrier siblings await. get→set has
512
- // no await between, so registration is atomic — at most one executor
513
- // per hash. Released on every exit (success / failure / throw).
514
- let release!: () => void
515
- inflight.set(
516
- hash,
517
- new Promise<void>((resolve) => {
518
- release = resolve
519
- }),
520
- )
521
- try {
522
- return await executeTask(buildExecuteArgs(node, upstream))
523
- } finally {
524
- inflight.delete(hash)
525
- release()
526
- }
527
- }
619
+ },
620
+ buildExecuteArgs,
621
+ })
528
622
 
623
+ mark('classify + probe')
529
624
  const outcomes = await runGraph({
530
625
  nodes,
531
626
  concurrency,
532
- ...(resourceCosts.size > 0 ? { resourceCosts, cpuBudget: concurrency, memBudget } : {}),
627
+ settledOf: (o) => deferredSaves.get(o.node.id),
628
+ ...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
629
+ ...(admit !== undefined ? { admit } : {}),
533
630
  ...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
631
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
534
632
  onStart: (node) => {
535
633
  log.taskStart?.(node)
536
634
  },
537
635
  onFinish: (o) => {
538
636
  log.taskComplete(o.node, o)
637
+ narrowDemand(o.node.id)
539
638
  },
540
639
  execute: executeWithDedup,
541
- // Predictive scheduling: empty map when not opted in, in which
542
- // case the scheduler keeps the static baseline behavior.
543
- priorities: prepared.priorities,
640
+ // A `schedule` plugin's weights; the scheduler keeps its structural
641
+ // baseline as the tie-break. Empty map → baseline only.
642
+ ...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
544
643
  // Local short-circuit: confirmed stable local hits the scheduler
545
644
  // runs ahead of their deps as low-priority worker-slot backfill.
546
645
  // Empty when the short-circuit didn't fire → byte-identical.
547
646
  restoreTier: shortCircuit.restoreTier,
548
647
  })
549
648
 
550
- // A persistent task the user REQUESTED (a dev server / watcher) is
551
- // the run's whole purpose — don't tear it down the instant it's
552
- // ready. We leave those running and block on them at the very end
553
- // (after the normal summary prints); everything else (persistent
554
- // tasks pulled in only as dependencies of now-finished work) is
555
- // SIGTERMed here as before. Scoped to the real CLI foreground:
556
- // `options.log === undefined` means the default logger (a `vx run`
557
- // invocation), and `handleSignals` excludes watch mode (own signal
558
- // loop) and embedders that manage lifecycle themselves — both expect
559
- // run() to return, not block on a server.
649
+ // Which persistent children outlive the graph, and the bounded SIGTERM
650
+ // of the rest, before the summary prints. Scoped to the real CLI
651
+ // foreground: `options.log === undefined` means the default logger (a
652
+ // `vx run` invocation), and `handleSignals` excludes watch mode (own
653
+ // signal loop) and embedders that manage lifecycle themselves — both
654
+ // expect run() to return, not block on a server.
560
655
  const foreground = options.log === undefined && (options.handleSignals ?? true)
561
- const keepAliveNodes: TaskNode[] = []
562
- const keepAlive: ReturnType<typeof Bun.spawn>[] = []
563
- if (foreground) {
564
- for (const [id, child] of persistentRegistry) {
565
- const n = nodes.get(id)
566
- if (n !== undefined && (n.requested || n.surfaced === true)) {
567
- keepAliveNodes.push(n)
568
- keepAlive.push(child)
569
- }
570
- }
571
- }
572
- const keepAliveSet = new Set(keepAlive)
573
-
574
- // Shut down the dependency-only persistent tasks before reporting the
575
- // final summary. SIGTERM gives well-behaved servers (vite, next,
576
- // esbuild --watch) a moment to clean up; we don't escalate to SIGKILL
577
- // — process-group propagation on Ctrl-C handles the unhappy case.
578
- // Bun's Subprocess.kill is idempotent on an already-exited child.
579
- for (const child of persistentRegistry.values()) {
580
- if (!keepAliveSet.has(child)) child.kill('SIGTERM')
581
- }
582
- await Promise.allSettled(
583
- [...persistentRegistry.values()].filter((c) => !keepAliveSet.has(c)).map((c) => c.exited),
584
- )
656
+ const keepAlive = selectKeepAlive(persistentRegistry, nodes, foreground)
657
+ await shutdownPersistent(persistentRegistry, keepAlive.children)
585
658
 
659
+ mark('run graph')
586
660
  // Clear the status line for good before the summary prints.
587
661
  log.runEnd?.()
588
662
 
589
663
  const list = [...outcomes.values()]
590
- const ok =
591
- list.every(
592
- (o) =>
593
- o.status === 'success' || o.status === 'cache-hit' || o.status === 'cache-hit-remote',
594
- ) &&
595
- // `--verify`: a provably-unsafe cache entry (non-deterministic outputs, a
596
- // re-run that failed, or a read of undeclared inputs) turns the run red so
597
- // CI catches it.
598
- !list.some(
599
- (o) =>
600
- o.verify?.kind === 'nondeterministic' ||
601
- o.verify?.kind === 'rerun-failed' ||
602
- o.verify?.kind === 'undeclared-inputs',
603
- )
664
+ const ok = list.every((o) => isPassStatus(o.status))
604
665
 
605
666
  // The summary + artifact writers + recordRun pass all exclude group
606
667
  // tasks via the shared tallyOutcomes helper. We pass the full
@@ -609,27 +670,27 @@ export async function run(options: RunOptions): Promise<RunSummary> {
609
670
  const totalMs = Number(process.hrtime.bigint() - runStartHrTimeNs) / 1_000_000
610
671
  // Foreground dev mode: between the task frame and the footer, list
611
672
  // the persistent tasks still running (see the keep-alive block below).
612
- if (keepAliveNodes.length > 0) {
613
- for (const line of formatPersistentList(keepAliveNodes, colors)) log.status(line)
673
+ if (keepAlive.nodes.length > 0) {
674
+ for (const line of formatPersistentList(keepAlive.nodes, colors)) log.status(line)
614
675
  }
615
676
  for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
616
- if (options.verify !== undefined) {
617
- for (const line of formatVerifySection(list)) log.status(line)
618
- // A fingerprint-only run attaches no verdicts, so the verdict-driven
619
- // section above prints nothing — report what actually happened.
620
- if (options.verify.fingerprint && !options.verify.determinism && !options.verify.inputs) {
621
- const n = list.filter((o) => o.outputFp !== undefined).length
622
- log.status('')
623
- log.status(
624
- ` Verify: fingerprinted ${n} task output trees (cross-machine diff via a connected serve)`,
625
- )
626
- // Only EXECUTED tasks fingerprint — a warm all-hit run reports 0.
627
- // A per-platform matrix wired without `--force` produces nothing
628
- // forever, so name the cause instead of a bare 0.
629
- if (n === 0) {
630
- log.status(' (0 executed — cache hits do not fingerprint; pair with --force)')
631
- }
632
- }
677
+ // A task killed by a shutdown signal is in no bucket above, yet it makes
678
+ // `ok` false — name it, or the red exit is undiagnosable.
679
+ for (const line of formatAbortedSection(list)) log.status(line)
680
+ // Judged against the history BEFORE this run's rows land, so the query
681
+ // is one scan over the executed tasks' keys and nothing at all on a run
682
+ // that executed none (every hit, every skip).
683
+ const flaky = detectFlaky(prepared.localCache.dbHandle(), flakyCandidates(list))
684
+ for (const line of formatFlakySection(flaky)) log.status(line)
685
+ // Outputs that never came home are not an error, but a silent `dist/`
686
+ // that is empty-or-stale would be: name every task whose bytes are
687
+ // still remote.
688
+ const stillDeferred = deferredOutputs.pending()
689
+ if (stillDeferred.length > 0) {
690
+ log.status('')
691
+ log.status(
692
+ ` Deferred: ${stillDeferred.length} task(s) left outputs remote (--download=none): ${stillDeferred.join(', ')}`,
693
+ )
633
694
  }
634
695
 
635
696
  // Optional artifacts. Errors are surfaced to the user but don't
@@ -644,7 +705,9 @@ export async function run(options: RunOptions): Promise<RunSummary> {
644
705
  startedAtMs: endedAtMsAtStart,
645
706
  endedAtMs,
646
707
  totalMs,
708
+ ok,
647
709
  outcomes: list,
710
+ flaky,
648
711
  })
649
712
  log.status(`vx: summary written to ${wrote}`)
650
713
  } catch (err) {
@@ -667,142 +730,52 @@ export async function run(options: RunOptions): Promise<RunSummary> {
667
730
  }
668
731
 
669
732
  // Record each task to the run history in a single SQLite transaction
670
- // (one fsync instead of N). Group tasks (no `exec`) are skipped —
671
- // they aren't real runs and the `runs` table is analytics-focused.
672
- // One invocation header row is written alongside, atomically via
673
- // recordRunBundle. The Tier-3 input-fingerprint rows (entry_inputs)
674
- // are NOT built here — they're persisted inside each entry's save
675
- // transaction (miss path only), so a warm all-cache-hit run does no
676
- // extra recording work.
677
- const now = endedAtMs
678
- const toRecord: RunRecord[] = []
679
- // Per-task telemetry mirrors `toRecord` 1:1 — built only when a
680
- // telemetry sink is active, so a no-telemetry run allocates nothing.
681
- const summaryTasks: TaskTelemetry[] = []
682
- let failedCount = 0
683
- let hitLocalCount = 0
684
- let hitRemoteCount = 0
685
- for (const o of list) {
686
- if (!o.hash) continue
687
- if (isGroupTask(o.node)) continue
688
- // aborted (killed by a shutdown signal) isn't a real run.
689
- if (o.status === 'aborted') continue
690
- if (telemetry !== undefined) {
691
- const t: TaskTelemetry = {
692
- taskId: o.node.id,
693
- project: o.node.projectName,
694
- task: o.node.taskName,
695
- status: o.status,
696
- cacheSource: deriveCacheSource(o.status),
697
- exitCode: o.exitCode,
698
- durationMs: o.durationMs,
699
- }
700
- if (o.hash !== undefined) t.hash = o.hash
701
- if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
702
- if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
703
- if (o.attempts !== undefined) t.attempts = o.attempts
704
- if (o.verify !== undefined) t.verify = o.verify
705
- if (o.outputFp !== undefined) t.outputFp = o.outputFp
706
- if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
707
- if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
708
- summaryTasks.push(t)
709
- }
710
- toRecord.push({
711
- hash: o.hash,
712
- project: o.node.projectName,
713
- task: o.node.taskName,
714
- status: o.status,
715
- exitCode: o.exitCode,
716
- durationMs: o.durationMs,
717
- ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
718
- // Anchor to the REAL per-task wall-clock window: run-start wall time +
719
- // the task's ns offset (captured for hits and executed tasks alike).
720
- // The `now - duration` fallback only applies to outcomes without an
721
- // offset (none today). Using run-end-minus-duration for everything was
722
- // the old bug that piled every task at the right edge of the timeline.
723
- startedAt:
724
- o.wallclockStartNs !== undefined
725
- ? endedAtMsAtStart + Math.round(Number(o.wallclockStartNs) / 1e6)
726
- : now - o.durationMs,
727
- endedAt:
728
- o.wallclockEndNs !== undefined
729
- ? endedAtMsAtStart + Math.round(Number(o.wallclockEndNs) / 1e6)
730
- : now,
731
- runId,
732
- ...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
733
- ...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
734
- ...(o.wallclockStartNs !== undefined ? { wallclockStartNs: o.wallclockStartNs } : {}),
735
- ...(o.wallclockEndNs !== undefined ? { wallclockEndNs: o.wallclockEndNs } : {}),
736
- cacheHit: o.status === 'cache-hit' || o.status === 'cache-hit-remote',
737
- ...(o.attempts !== undefined ? { attempts: o.attempts } : {}),
738
- })
739
- if (o.status === 'failed') failedCount++
740
- if (o.status === 'cache-hit') hitLocalCount++
741
- if (o.status === 'cache-hit-remote') hitRemoteCount++
742
- }
743
- const invocation: InvocationRecord = {
733
+ // (one fsync instead of N), with the invocation header row alongside,
734
+ // atomically via recordRunBundle. The Tier-3 input-fingerprint rows
735
+ // (entry_inputs) are NOT built here — they're persisted inside each
736
+ // entry's save transaction (miss path only), so a warm all-cache-hit
737
+ // run does no extra recording work. The telemetry mirror is built in
738
+ // the same pass, only when a sink is active.
739
+ const records = assembleRunRecords({
740
+ outcomes: list,
744
741
  runId,
742
+ startedAtMs: endedAtMsAtStart,
743
+ endedAtMs,
744
+ totalMs,
745
+ ok,
745
746
  command: options.command ?? process.argv.slice(1).join(' '),
746
- requestedTasks: JSON.stringify([...options.tasks]),
747
+ requestedTasks: options.tasks,
747
748
  cachePolicy: compactCachePolicy(policy),
748
749
  concurrency,
749
750
  flow: options.flow ?? null,
750
- startedAt: endedAtMsAtStart,
751
- endedAt: endedAtMs,
752
- totalDurationMs: Math.round(totalMs),
753
- taskCount: toRecord.length,
754
- failedCount,
755
- hitCount: hitLocalCount + hitRemoteCount,
756
- hitLocalCount,
757
- hitRemoteCount,
758
- exitOk: ok,
759
- commitSha: gitContext.commitSha,
760
- branch: gitContext.branch,
761
- dirty: gitContext.dirty,
762
- ci: ciContext.ci,
763
- ciProvider: ciContext.provider,
764
- host: hostContext.host,
765
- os: hostContext.os,
766
- arch: hostContext.arch,
767
- vxVersion: VERSION,
768
- tags: JSON.stringify(options.tags ?? {}),
769
- }
770
- cache.recordRunBundle({ runs: toRecord, invocation })
751
+ forwardArgs: options.forwardArgs,
752
+ tags: options.tags ?? {},
753
+ git: gitContext,
754
+ ci: ciContext,
755
+ host: hostContext,
756
+ withTelemetry: telemetry !== undefined,
757
+ })
758
+ cache.recordRunBundle(records)
759
+ mark('record history')
771
760
  // Hand the per-run summary to the telemetry sinks + drain them. Only
772
761
  // when a sink is active (telemetry !== undefined) — otherwise this
773
762
  // whole block is skipped and the run is byte-identical to before.
774
763
  // emitSummary/flush are crash-isolated, so a faulty sink can't fail
775
764
  // the run; flush is the sink's last chance to ship buffered records.
776
765
  if (telemetry !== undefined && runContextRecord !== undefined) {
777
- const summary: RunSummaryRecord = {
778
- v: TELEMETRY_SCHEMA_VERSION,
779
- run: runContextRecord,
766
+ const summary = assembleRunSummary(runContextRecord, records.telemetryTasks, {
780
767
  startedAt: endedAtMsAtStart,
781
768
  endedAt: endedAtMs,
782
769
  totalDurationMs: Math.round(totalMs),
783
- taskCount: toRecord.length,
784
- failedCount,
785
- hitCount: hitLocalCount + hitRemoteCount,
786
- hitLocalCount,
787
- hitRemoteCount,
788
770
  exitOk: ok,
789
- tasks: summaryTasks,
790
- }
771
+ })
791
772
  telemetry.emitSummary(summary)
792
773
  await telemetry.flush()
793
774
  }
794
- // End-of-run plugin lifecycle: each event sink's flush() (its last
795
- // chance to ship buffered records) and each plugin's teardown().
796
- // Crash-isolated + time-bounded inside teardownPlugins, so a faulty
797
- // plugin can neither fail nor hang the run. Normal completion path
798
- // only — the finally below just unsubscribes.
799
- if (prepared.workspaceConfig?.plugins && prepared.workspaceConfig.plugins.length > 0) {
800
- await teardownPlugins(
801
- prepared.workspaceConfig.plugins as readonly VxPlugin[],
802
- eventSinks?.sinks ?? [],
803
- (m) => log.status(m),
804
- )
805
- }
775
+ // End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
776
+ // + time-bounded inside teardownPlugins, so a faulty plugin can neither
777
+ // fail nor hang the run. Normal completion path only — the finally
778
+ // below just unsubscribes.
806
779
  // Drain any still-in-flight background prefetches before closing the
807
780
  // cache handle — a prefetch ingesting into a closed SQLite DB would
808
781
  // throw. Tasks that resolved as local hits never awaited their
@@ -811,14 +784,41 @@ export async function run(options: RunOptions): Promise<RunSummary> {
811
784
  // before scheduling, so nothing of its is in flight here.) The
812
785
  // background write-through uploads queued by LayeredCache.save
813
786
  // settle here for the same reason.
787
+ //
788
+ // BEFORE teardownPlugins, and that order is load-bearing: the drain
789
+ // pushes bytes through a layer a PLUGIN provided, so a plugin that
790
+ // releases its client in `teardown()` would otherwise have the
791
+ // channel shut from under the upload — silently losing every remote
792
+ // write with nothing but a warning. The seam's contract is that a
793
+ // cache layer stays usable until the run's uploads have settled.
814
794
  await prefetchDone
815
- if (cache instanceof LayeredCache) await cache.drainUploads()
816
- cache.close()
795
+ // Every deferred save settles first: the upload drain below carries
796
+ // the entries the saves queued, and the snapshot loop after it reads
797
+ // what the saves pushed.
798
+ await saveLane.drain()
799
+ mark('save lane')
800
+ await cache.drainUploads?.()
801
+ // The miss path's output-directory snapshots, taken now that the
802
+ // directories are old enough for the snapshot's racy window (see
803
+ // miss-save.ts). A few at a time: each is an lstat + readdir per
804
+ // prefix and one index transaction.
805
+ for (let i = 0; i < outputDirSnapshots.length; i += 32) {
806
+ await Promise.all(
807
+ outputDirSnapshots
808
+ .slice(i, i + 32)
809
+ .map((s) => cache.recordOutputDirs?.(s.hash, s.projectDir, s.prefixes)),
810
+ )
811
+ }
812
+ mark('output dir snapshots')
813
+ await teardownPlugins(prepared.plugins, (m) => log.status(m))
814
+ closeCache()
815
+ mark('close')
816
+ printTimings()
817
817
 
818
818
  // Tear down SRT's network bridge + (on macOS) log monitor. No-op if
819
819
  // no task was sandboxed; otherwise SRT keeps proxy servers alive and
820
820
  // the next vx run would init on top of stale state.
821
- if (anySandboxed) {
821
+ if (sandboxArmer?.armed) {
822
822
  try {
823
823
  await resetSandbox()
824
824
  } catch (err) {
@@ -831,12 +831,30 @@ export async function run(options: RunOptions): Promise<RunSummary> {
831
831
  // persistent task (dev server / watcher). The run is "done" in every
832
832
  // bookkeeping sense — summary printed, history recorded — but the
833
833
  // server is still up and that's the point. Stay in the foreground
834
- // until it exits: Ctrl-C hits the whole process group (the server
835
- // dies; our SIGINT handler also exits 130), and a crash resolves the
836
- // wait so the run returns. Nothing here prints — the UI is unchanged.
837
- if (keepAlive.length > 0) {
838
- await Promise.allSettled(keepAlive.map((c) => c.exited))
839
- for (const child of keepAlive) child.kill('SIGTERM')
834
+ // until ONE of them exits: Ctrl-C hits the whole process group (the
835
+ // server dies; our SIGINT handler also exits 130), and a crash ends
836
+ // the session — the others are torn down (SIGTERM, grace, SIGKILL)
837
+ // and a non-zero exit makes the run not ok, so `vx run dev` in a
838
+ // script fails when the server it started fell over. Until
839
+ // 2026-09-10 this waited for EVERY server, so the SIGTERM after it
840
+ // was dead code and a crashed server left the rest running under a
841
+ // run that never returned. One status line names the server that
842
+ // ended the session and its code — the summary above said `success`
843
+ // for it, and an exit 1 with no word about why is a mystery in a log.
844
+ if (keepAlive.children.length > 0) {
845
+ const first = await Promise.race(
846
+ keepAlive.children.map((c, i) => c.exited.then((code) => ({ code, i }))),
847
+ )
848
+ const node = keepAlive.nodes[first.i]!
849
+ const others = keepAlive.nodes.length - 1
850
+ log.status(
851
+ `vx: ${node.id} exited with code ${first.code}` +
852
+ (others > 0
853
+ ? `; stopping ${others} other persistent task${others === 1 ? '' : 's'}`
854
+ : ''),
855
+ )
856
+ await terminateChildren(() => keepAlive.children)
857
+ return { ok: ok && first.code === 0, outcomes: list }
840
858
  }
841
859
 
842
860
  return { ok, outcomes: list }
@@ -844,13 +862,22 @@ export async function run(options: RunOptions): Promise<RunSummary> {
844
862
  // Idempotent; also reached on mid-run throws, so a crashed cycle
845
863
  // can't leave a live status-line ticker behind.
846
864
  log.runEnd?.()
847
- process.off('SIGINT', onSigint)
848
- process.off('SIGTERM', onSigterm)
865
+ signals.remove()
866
+ options.signal?.removeEventListener('abort', onAbort)
849
867
  // Plugins installed at the top of run() get their bus subscriptions
850
868
  // released here. Idempotent; safe even if installPlugins threw.
851
869
  disposePlugins?.()
852
- eventSinks?.dispose()
853
870
  telemetry?.dispose()
871
+ // No-op after the normal path's close. On a throw this is the only
872
+ // close there is, and it must not itself throw — that would replace
873
+ // the run's real error with a teardown one. Background uploads are
874
+ // NOT drained here: they hold no database state, and awaiting a
875
+ // wedged remote would turn a failing run into a hanging one.
876
+ try {
877
+ closeCache()
878
+ } catch {
879
+ // teardown must not throw on the way out
880
+ }
854
881
  }
855
882
  }
856
883
 
@@ -869,19 +896,109 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
869
896
  const log = options.log ?? defaultLogger()
870
897
  const prepared = await prepareRun(options, log)
871
898
  try {
899
+ if (prepared.unresolvedTasks.length > 0) {
900
+ return { tasks: [], unresolvedTasks: prepared.unresolvedTasks }
901
+ }
872
902
  if (prepared.empty !== null) return { tasks: [] }
873
903
  return await plan({
874
904
  nodes: prepared.nodes,
875
905
  workspaceRoot: prepared.workspaceRoot,
876
906
  workspaceFingerprint: prepared.workspaceFingerprint,
877
907
  cache: prepared.cache,
878
- cachePolicy: options.cache ?? FULL_CACHE_POLICY,
908
+ cachePolicy: effectiveCachePolicy(
909
+ options.cache ?? FULL_CACHE_POLICY,
910
+ prepared.hasRemoteLayer,
911
+ ),
879
912
  forwardArgs: options.forwardArgs,
880
913
  nestedDirsByProject: prepared.nestedDirsByProject,
881
914
  gitFilesCache: prepared.gitFilesCache,
882
915
  hashCache: prepared.hashCache,
916
+ // Plan-time duration prediction (dev-scenarios S2): the same local
917
+ // history the predictive scheduler reads, folded into the plan as
918
+ // per-task p50s + a predicted wall-clock. `--dry` is an explicit
919
+ // inspection command, so the history read's cost is fine here even
920
+ // though it stays OFF the default run path.
921
+ history: new LocalHistoryProvider(prepared.localCache.dbHandle()),
922
+ // Placement, by the same rules the run applies. Only worth showing
923
+ // when there is a choice to show — with one declared executor every
924
+ // line would carry the same label. Resolving the executors here is
925
+ // the same plugin-factory call `prepareRun` already makes for the
926
+ // cache capability, so plan mode gains no new class of side effect.
927
+ ...(await planExecutorOf(prepared, log, options.download ?? 'all')),
883
928
  })
884
929
  } finally {
885
930
  prepared.cache.close()
931
+ // The same table a run prints: a dry run is how the prepare stages
932
+ // (discovery, config load, the git enumeration) get profiled on a
933
+ // real repo that has no install and no cache to run against.
934
+ mark('close')
935
+ printTimings()
936
+ }
937
+ }
938
+
939
+ /**
940
+ * The first-run case, told apart from a typo: no package in the workspace
941
+ * has a `vx.config.*` at all, so no name could have resolved. Reached only
942
+ * on the error path.
943
+ */
944
+ function initHint(prepared: { anyProjectConfig: boolean }): string {
945
+ return prepared.anyProjectConfig
946
+ ? ''
947
+ : ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
948
+ }
949
+
950
+ /**
951
+ * A typo's nearest declared name, when one is within two edits — the
952
+ * message names the fix instead of only the mistake. A bare name is
953
+ * matched against every declared task; `pkg#task` against the project
954
+ * names first (the task kept) and then against that project's tasks, so
955
+ * the hint is a spec the user can run.
956
+ */
957
+ function didYouMean(
958
+ unresolved: readonly string[],
959
+ projects: ReadonlyMap<string, ProjectEntry>,
960
+ ): string {
961
+ const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
962
+ const allTasks = new Set<string>()
963
+ for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
964
+ // A Set: two typos of the same task hint it once, not once per typo.
965
+ const hints = new Set<string>()
966
+ for (const spec of unresolved) {
967
+ const at = spec.indexOf('#')
968
+ if (at < 0) {
969
+ const t = nearest(spec, allTasks)
970
+ if (t !== undefined) hints.add(t)
971
+ continue
972
+ }
973
+ const [proj, task] = [spec.slice(0, at), spec.slice(at + 1)]
974
+ if (!projects.has(proj)) {
975
+ const p = nearest(proj, projects.keys())
976
+ if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.add(`${p}#${task}`)
977
+ continue
978
+ }
979
+ const t = nearest(task, tasksOf(projects.get(proj)))
980
+ if (t !== undefined) hints.add(`${proj}#${t}`)
981
+ }
982
+ return hints.size === 0 ? '' : ` Did you mean ${[...hints].join(', ')}?`
983
+ }
984
+
985
+ /** The executed, keyed outcomes of a run — what flakiness is judged on. */
986
+ function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
987
+ const out: FlakyCandidate[] = []
988
+ for (const o of outcomes) {
989
+ if (o.status !== 'success' && o.status !== 'failed') continue
990
+ // "Same inputs, different outcome" is a claim only a task with declared
991
+ // inputs can make: a task with no `cache` block keys on its config alone
992
+ // and runs every time, so one bad network day would read as a flake for
993
+ // thirty days. Groups do no work.
994
+ if (o.hash === undefined || o.node.config.cache === undefined || isGroupTask(o.node)) continue
995
+ out.push({
996
+ project: o.node.projectName,
997
+ task: o.node.taskName,
998
+ hash: o.hash,
999
+ status: o.status,
1000
+ attempts: o.attempts ?? 1,
1001
+ })
886
1002
  }
1003
+ return out
887
1004
  }