@vzn/vx 0.0.15 → 0.0.18

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 (111) hide show
  1. package/README.md +91 -130
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +129 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +27 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
@@ -2,17 +2,20 @@
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 type { ProjectEntry } from '../workspace/index.js'
5
6
  import os from 'node:os'
6
7
  import {
7
8
  type CacheLayer,
8
9
  type CachePolicy,
9
10
  FULL_CACHE_POLICY,
10
11
  type InvocationRecord,
11
- LayeredCache,
12
12
  type RunRecord,
13
13
  } from '../cache/index.js'
14
14
  import { VERSION } from '../version.js'
15
15
  import { initSandbox, probeSandbox, resetSandbox, signalExitCode } from '../exec/index.js'
16
+ import { DeferredOutputs } from './deferred-outputs.js'
17
+ import { resolveDownloadModes } from './download-policy.js'
18
+ import { selectExecutor, type TaskExecutor } from '../exec/index.js'
16
19
  import {
17
20
  isGroupTask,
18
21
  markSurfacedDeps,
@@ -20,24 +23,24 @@ import {
20
23
  type TaskNode,
21
24
  type TaskOutcome,
22
25
  } from '../graph/index.js'
23
- import { ulid, UserError } from '../util/index.js'
26
+ import { mark, MAX_TIMEOUT_MS, printTimings, ulid, UserError, editDistance } from '../util/index.js'
24
27
  import { executeTask } from './execute-task.js'
25
28
  import { resolveResourceCosts } from './resources.js'
26
29
  import { computeTaskHash } from './task-hash.js'
27
30
  import { busLogger, createEventBus, terminalSubscriber } from './events.js'
28
31
  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'
32
+ import { resolveExecutors, teardownPlugins } from './plugin-host.js'
32
33
  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'
35
- import { defaultLogger, resolveOutputView } from './logger.js'
34
+ import { assembleRunSummary, deriveCacheSource, isCacheHit, isPassStatus } from './telemetry.js'
35
+ import type { RunContextRecord, TaskTelemetry } from './telemetry.js'
36
+ import { defaultLogger, resolveOutputView, type Logger } from './logger.js'
36
37
  import { detectColors } from './colors.js'
37
38
  import { formatPersistentList } from './framed-output.js'
39
+ import { LocalHistoryProvider } from './history.js'
38
40
  import { plan, type RunPlan } from './plan.js'
39
41
  import { prepareRun } from './prepare.js'
40
42
  import {
43
+ captureDefaultBranch,
41
44
  captureGitContext,
42
45
  captureHostContext,
43
46
  captureWorkspaceIdentity,
@@ -48,21 +51,38 @@ import { startLocalShortCircuit, type ShortCircuit } from './local-shortcircuit.
48
51
  import { formatVerifySection } from './verify.js'
49
52
 
50
53
  const EMPTY_SHORT_CIRCUIT: ShortCircuit = { preProbed: new Map(), restoreTier: new Set() }
54
+
55
+ /**
56
+ * Grace after SIGTERMing the dependency-only persistent tasks at end-of-run
57
+ * before force-killing any that trap or ignore it — so a wedged mock server
58
+ * can't hang a normal run at completion. Well-behaved servers exit far under
59
+ * this, so the happy path never waits it out.
60
+ */
61
+ const PERSISTENT_SHUTDOWN_GRACE_MS = 2000
51
62
  import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
52
- import { formatRunSummary } from './summary.js'
63
+ import { formatAbortedSection, formatRunSummary } from './summary.js'
53
64
  import type { RunOptions, RunSummary } from './options.js'
54
65
 
55
66
  /**
56
67
  * Parse the `VX_TASK_TIMEOUT` env var (ms) — the "global" run-level task
57
68
  * timeout default. A missing/empty/non-positive-integer value yields
58
69
  * `undefined` (ignored), so a typo never silently disables a task's own
59
- * `exec.timeout`.
70
+ * `exec.timeout`. A value past `MAX_TIMEOUT_MS` is clamped to it — see below.
71
+ *
72
+ * Exported for `tests/options-resolve.test.ts`, which pins every accepted
73
+ * and ignored form of this rung; it has no other caller outside this file.
60
74
  */
61
- function readTaskTimeoutEnv(): number | undefined {
75
+ export function readTaskTimeoutEnv(): number | undefined {
62
76
  const raw = process.env['VX_TASK_TIMEOUT']
63
77
  if (raw === undefined || raw === '') return undefined
64
78
  const n = Number(raw)
65
- return Number.isInteger(n) && n > 0 ? n : undefined
79
+ if (!Number.isInteger(n) || n <= 0) return undefined
80
+ // CLAMPED, not refused — unlike `exec.timeout` and `--timeout`, this rung's
81
+ // contract is already "unusable value → fall back", so it never throws. But
82
+ // falling back is wrong here: someone who typed a huge number wants a long
83
+ // timeout, and the largest expressible one IS long (~24.8 days). Handing it
84
+ // to `setTimeout` unbounded would mean 1 ms — killing every task instantly.
85
+ return Math.min(n, MAX_TIMEOUT_MS)
66
86
  }
67
87
 
68
88
  /**
@@ -79,32 +99,49 @@ function compactCachePolicy(p: CachePolicy): string {
79
99
  return parts.join(',')
80
100
  }
81
101
 
102
+ /**
103
+ * The policy that will actually govern the run. The remote axes are inert
104
+ * without a remote layer to serve them: a `remote:w` policy over a bare
105
+ * local cache writes NOWHERE, yet the write axis reads as on. Normalising
106
+ * here — the ONE place both `run()` and `planRun()` derive it from — is
107
+ * what keeps `--dry` describing the run you are about to get: reading the
108
+ * raw request made the plan label `--cache=local:,remote:rw` "cache miss —
109
+ * would exec" (a result that will be stored) for a run in which caching is
110
+ * entirely off.
111
+ */
112
+ function effectiveCachePolicy(requested: CachePolicy, hasRemoteLayer: boolean): CachePolicy {
113
+ return hasRemoteLayer ? requested : { ...requested, remoteRead: false, remoteWrite: false }
114
+ }
115
+
82
116
  /**
83
117
  * The perf firewall for the local short-circuit. Always-on; the only
84
118
  * gates are correctness/no-op gates:
85
- * - LOCAL-ONLY cache. Under a LayeredCache, `cache.get` is a remote
119
+ * - LOCAL-ONLY cache. Behind a remote layer, `cache.get` is a remote
86
120
  * READ-THROUGH — the up-front classify is awaited before scheduling,
87
121
  * so N remote GETs would land on the critical path before any task
88
122
  * starts. Remote runs are owned by `startRemotePrefetch`, which
89
123
  * overlaps the GETs with execution instead (fire-and-forget) and
90
- * ingests hits into local for execute-task's lazy probe.
124
+ * ingests hits into local for execute-task's lazy probe. The gate asks
125
+ * the layer (`hasRemote`), not `instanceof LayeredCache` — a
126
+ * third-party remote layer stalls on the classify just as hard.
91
127
  * - the policy reads locally (a `--no-cache`/`--force`/`--cache=local:`
92
128
  * 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`.
129
+ * - there is at least one task.
130
+ * A flat graph has no ordering to bypass, but the classify pass is worth
131
+ * paying for anyway: it probes every stable key through ONE batched
132
+ * `getMany` instead of a `cache.get` per task inside the run, which
133
+ * measured 84 → 78 ms on 100 dep-free tasks and 249 → 237 ms on 1000
134
+ * (2026-09-03, interleaved arms). The per-task correctness gates (stable
135
+ * key, no workspace-outputs in graph) live in `startLocalShortCircuit`.
98
136
  */
99
137
  export function shouldShortCircuit(
100
138
  nodes: Map<string, TaskNode>,
101
139
  policy: CachePolicy,
102
140
  cache: CacheLayer,
103
141
  ): boolean {
104
- if (cache instanceof LayeredCache) return false
142
+ if (cache.hasRemote === true) return false
105
143
  if (!policy.localRead) return false
106
- for (const node of nodes.values()) if (node.deps.length > 0) return true
107
- return false
144
+ return nodes.size > 0
108
145
  }
109
146
 
110
147
  export async function run(options: RunOptions): Promise<RunSummary> {
@@ -130,6 +167,20 @@ export async function run(options: RunOptions): Promise<RunSummary> {
130
167
  const log = busLogger(bus)
131
168
 
132
169
  const prepared = await prepareRun(options, log)
170
+ mark('prepare (graph)')
171
+ // A requested name that matched no project is a typo (or a stray
172
+ // positional from an `=`-only flag written with a space). Failing the
173
+ // whole run — even when OTHER requested tasks resolved — is the point:
174
+ // a CI job that renames a task must go red, not silently stop running
175
+ // it. When EVERY name is unresolved this is the `no-tasks-declared`
176
+ // case too; the message is identical, so that branch stays below.
177
+ if (prepared.unresolvedTasks.length > 0) {
178
+ log.status(
179
+ `No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${didYouMean(prepared.unresolvedTasks, prepared.projects)}`,
180
+ )
181
+ prepared.cache.close()
182
+ return { ok: false, outcomes: [] }
183
+ }
133
184
  if (prepared.empty !== null) {
134
185
  // `no-tasks-declared` is almost always a typo in CI; we surface
135
186
  // a clear message and return NOT-ok so the script exits 1.
@@ -145,35 +196,23 @@ export async function run(options: RunOptions): Promise<RunSummary> {
145
196
  }
146
197
  // Install user plugins as additional bus subscribers BEFORE the run
147
198
  // 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.
199
+ // `setup` hook and fails fast on a throw with a clean UserError naming
200
+ // the plugin. A plugin without `setup` (the local executor and cache)
201
+ // subscribes nothing.
153
202
  let disposePlugins: (() => void) | undefined
154
- let eventSinks: SubscribedEventSinks | undefined
155
203
  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 = {
204
+ try {
205
+ disposePlugins = await installPlugins({
206
+ plugins: prepared.plugins as never,
207
+ bus,
159
208
  workspaceRoot: prepared.workspaceRoot,
160
209
  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
- }
210
+ warn: (m) => log.status(m),
211
+ })
212
+ } catch (err) {
213
+ disposePlugins?.()
214
+ prepared.cache.close()
215
+ throw err
177
216
  }
178
217
  const {
179
218
  workspaceRoot,
@@ -192,6 +231,103 @@ export async function run(options: RunOptions): Promise<RunSummary> {
192
231
  workspaceConfig?.concurrency ??
193
232
  Math.max(1, navigator.hardwareConcurrency)
194
233
 
234
+ // Resolved ONCE per run, in declaration order. A broken factory — or a
235
+ // workspace that declared no executor — aborts here, before any task
236
+ // starts.
237
+ let executors: readonly TaskExecutor[]
238
+ try {
239
+ executors = await resolveExecutors(prepared.plugins, {
240
+ workspaceRoot: prepared.workspaceRoot,
241
+ cacheDir: prepared.cacheDir,
242
+ warn: (m: string) => log.status(m),
243
+ concurrency,
244
+ })
245
+ } catch (err) {
246
+ disposePlugins?.()
247
+ prepared.cache.close()
248
+ throw err
249
+ }
250
+ // Placement: decided ONCE per task, before scheduling, so the scheduler
251
+ // can admit a remote-pooled task against its pool instead of a local
252
+ // worker slot. Group tasks run nothing; persistent tasks never reach an
253
+ // executor (local by construction) — both stay off the map.
254
+ //
255
+ // `--verify=inputs` pins EVERYTHING local: the input-completeness proof
256
+ // is the OS sandbox, which is local machinery a remote executor silently
257
+ // ignores — so a remotely-executed task would pass the verify VACUOUSLY,
258
+ // leaky or not. A verify run is a local proof procedure by definition;
259
+ // determinism/fingerprint modes are unaffected (no sandbox involved).
260
+ const placements = placeTasks(nodes, executors, options.verify?.inputs === true)
261
+ // A `remote: 'only'` task nobody takes succeeds WITHOUT running. That is
262
+ // deliberate — on a machine with no remote pool the ambient state already
263
+ // is what the task would have produced — but it must not be SILENT: a task
264
+ // reporting success while doing nothing is indistinguishable from one that
265
+ // worked, and the usual cause (no `cache` block, so no executor can
266
+ // describe its inputs to a worker) cannot be read off the outcome.
267
+ if (placements.remoteOnlyNoop.size > 0) {
268
+ const anyRemote = executors.some((e) => e.remote === true)
269
+ for (const id of placements.remoteOnlyNoop) {
270
+ const why = !anyRemote
271
+ ? 'no remote executor is declared'
272
+ : nodes.get(id)?.config.cache === undefined
273
+ ? 'no remote executor accepted it — the task declares no `cache` block, so there is no input set to ship'
274
+ : 'no remote executor accepted it'
275
+ log.status(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
276
+ }
277
+ }
278
+ // Demand: what each executor still has left to run. An executor that
279
+ // provisions per task (a container, an allocation, a pod) otherwise has to
280
+ // guess from `capacity` alone and hold everything until teardown. Built
281
+ // ONLY for executors that asked — with none declared there is no map, no
282
+ // set, and no call in the completion path.
283
+ const demandOf = new Map<TaskExecutor, Set<string>>()
284
+ for (const [id, executor] of placements.executors) {
285
+ if (executor.demand === undefined) continue
286
+ let remaining = demandOf.get(executor)
287
+ if (remaining === undefined) demandOf.set(executor, (remaining = new Set()))
288
+ remaining.add(id)
289
+ }
290
+ // The SAME set is handed back every time, narrowed in place: a consumer
291
+ // reading it later sees the truth rather than a stale snapshot.
292
+ for (const [executor, remaining] of demandOf) executor.demand!(remaining)
293
+ const narrowDemand =
294
+ demandOf.size === 0
295
+ ? (): void => {}
296
+ : (id: string): void => {
297
+ for (const [executor, remaining] of demandOf) {
298
+ if (remaining.delete(id)) executor.demand!(remaining)
299
+ }
300
+ }
301
+
302
+ // `--download` (default `all`) decides ONCE per task, here, whether a
303
+ // remote execution's outputs come home. `--verify` pins every task local
304
+ // (so nothing defers under a proof) and `all` keeps today's behaviour
305
+ // byte for byte.
306
+ // A proof must observe what it proves. `--verify=inputs` already pins
307
+ // every task LOCAL (so nothing could defer), but determinism and
308
+ // fingerprint modes do NOT — and a deferred task's outputs are absent
309
+ // when the verifier looks, which reported `no-outputs` for a task that
310
+ // declares outputs: an n/a verdict for work the proof never examined.
311
+ // Deferral is transfer tuning; a verify run is a rare, deliberate
312
+ // correctness run. Eager wins, and says so when it overrides.
313
+ const downloadPolicy = options.verify !== undefined ? 'all' : (options.download ?? 'all')
314
+ if (
315
+ options.verify !== undefined &&
316
+ options.download !== undefined &&
317
+ options.download !== 'all'
318
+ ) {
319
+ log.status(`vx: --verify observes outputs on disk — ignoring --download=${options.download}`)
320
+ }
321
+ const localPlaced = new Set(
322
+ [...nodes.keys()].filter((id) => placements.executors.get(id)?.remote !== true),
323
+ )
324
+ const download = resolveDownloadModes({
325
+ nodes,
326
+ policy: downloadPolicy,
327
+ localPlaced,
328
+ remoteOnly: placements.remoteOnly,
329
+ })
330
+
195
331
  // Run-level default task timeout (ms), applied to any task WITHOUT its own
196
332
  // `exec.timeout`. Precedence, highest first: `--timeout`/RunOptions.timeout
197
333
  // → `VX_TASK_TIMEOUT` env → workspace `timeout`. A malformed env value is
@@ -241,26 +377,68 @@ export async function run(options: RunOptions): Promise<RunSummary> {
241
377
  process.on('SIGINT', onSigint)
242
378
  process.on('SIGTERM', onSigterm)
243
379
  }
380
+ // The cache handle must be released on EVERY exit path, not just the
381
+ // happy one: `close()` is also where the run's deferred `accessed_at`
382
+ // bumps are flushed, so a throw between opening the cache and the
383
+ // normal close leaked the SQLite handle (it matters in a long-lived
384
+ // host) AND lost this run's touch record, after which an LRU
385
+ // `vx cache prune` can evict entries the run just hit. Once-only
386
+ // because the normal path closes before the persistent-task wait —
387
+ // holding the handle open for a dev server's whole lifetime would be
388
+ // worse — and because close() re-runs its retention DELETEs.
389
+ let cacheClosed = false
390
+ const closeCache = (): void => {
391
+ if (cacheClosed) return
392
+ cacheClosed = true
393
+ cache.close()
394
+ }
244
395
  try {
245
396
  // One run-id per `vx run` invocation. Every task in the resulting
246
397
  // graph carries it so analytics queries can group by invocation.
247
398
  const runId = ulid()
248
399
  const runStartHrTimeNs = process.hrtime.bigint()
249
400
  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()
401
+ const remoteCacheEnabled = prepared.hasRemoteLayer
402
+ // Normalised ONCE so every consumer — the verify gate, execute-task,
403
+ // the dedup predicate, the recorded invocation row — reads the policy
404
+ // that actually governed the run. Reading the raw request here made
405
+ // tasks clean their outputs before every exec for a save that never
406
+ // happened, and `--verify` clean them AGAIN and restore an artifact
407
+ // that was never written (wiping a successful build's tree and
408
+ // reporting it failed).
409
+ const policy: CachePolicy = effectiveCachePolicy(
410
+ options.cache ?? FULL_CACHE_POLICY,
411
+ prepared.hasRemoteLayer,
412
+ )
413
+ const deferredOutputs = new DeferredOutputs({
414
+ nodes,
415
+ cache,
416
+ workspaceRoot,
417
+ nestedDirsByProject,
418
+ ...(gitFilesCache !== undefined ? { gitFilesCache } : {}),
419
+ localWrite: policy.localWrite,
420
+ })
421
+
422
+ // `--verify` observes the miss-then-save path and then RESTORES attempt
423
+ // 1 from the artifact that save wrote, so the tree ends byte-identical
424
+ // to what was cached regardless of the verdict. That restore reads the
425
+ // LOCAL artifact file — `restoreOutputs` is a local extraction on every
426
+ // layer, by design — so without the local WRITE axis there is nothing to
427
+ // restore from: verify cleaned a successful build's declared outputs and
428
+ // then failed the run on the missing artifact. A remote fallback would
429
+ // not fix that, only narrow it to "whenever the remote is reachable at
430
+ // that instant", turning a guarantee into a coin flip with data loss on
431
+ // the losing side. So the honest gate is the local write axis, and
432
+ // `--no-cache` / `--cache=local:,…` / `--cache=local:r,…` are refused
433
+ // loudly — silently verifying nothing is the one failure mode
434
+ // verification must never have (same platform-honesty rule as the
435
+ // sandbox-unavailable error). `--force --verify` re-verifies a warm graph.
436
+ if (options.verify !== undefined && !policy.localWrite) {
261
437
  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',
438
+ '--verify needs the LOCAL cache write axis: it re-runs the task, then restores ' +
439
+ 'attempt 1 from the local artifact so the outputs on disk are the ones that were ' +
440
+ 'cached. Enable local writes (e.g. --cache=local:w,remote:rw), drop --no-cache, ' +
441
+ 'or use --force --verify to re-execute and verify a warm graph',
264
442
  )
265
443
  }
266
444
 
@@ -275,16 +453,21 @@ export async function run(options: RunOptions): Promise<RunSummary> {
275
453
 
276
454
  // The canonical run-context record — the same git/CI/host data the
277
455
  // 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.
456
+ // Built + consulted ONLY when a plugin CONTRIBUTES `telemetry`, and
457
+ // BEFORE run:start is emitted so a sink
458
+ // catches the whole stream.
280
459
  // subscribeTelemetry returns undefined when no sink is contributed (no
281
460
  // telemetry plugin, or all declined — e.g. otel() with no OTLP endpoint),
282
461
  // so a plain run does ZERO extra work: no record allocation, no bus
283
462
  // subscriber, no summary building. The hot path stays off-limits.
284
463
  let runContextRecord: RunContextRecord | undefined
285
- const hasPlugins =
286
- prepared.workspaceConfig?.plugins !== undefined && prepared.workspaceConfig.plugins.length > 0
287
- if (hasPlugins || options.telemetrySinks !== undefined) {
464
+ // Gate on a plugin that can actually CONTRIBUTE a sink, not on declaring
465
+ // any plugin at all: a cache-only plugin has no telemetry hook to
466
+ // consult, so paying for the record (2 git spawns + a `.vx/workspace-id`
467
+ // write on a remote-less repo) buys nothing. A plugin that has the hook
468
+ // but declines still pays — its answer is only knowable by asking.
469
+ const hasTelemetryPlugin = prepared.plugins.some((p) => p.telemetry !== undefined)
470
+ if (hasTelemetryPlugin || options.telemetrySinks !== undefined) {
288
471
  // Workspace identity (telemetry v2): one git spawn, paid only when a
289
472
  // telemetry consumer can exist — a plain run never reaches here.
290
473
  const wsIdentity = captureWorkspaceIdentity(workspaceRoot)
@@ -298,6 +481,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
298
481
  flow: options.flow ?? null,
299
482
  commitSha: gitContext.commitSha,
300
483
  branch: gitContext.branch,
484
+ defaultBranch: captureDefaultBranch(process.env, workspaceRoot),
301
485
  dirty: gitContext.dirty,
302
486
  ci: ciContext.ci,
303
487
  ciProvider: ciContext.provider,
@@ -309,7 +493,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
309
493
  tags: options.tags ?? {},
310
494
  }
311
495
  telemetry = await subscribeTelemetry(
312
- (prepared.workspaceConfig?.plugins ?? []) as readonly VxPlugin[],
496
+ prepared.plugins,
313
497
  bus,
314
498
  { workspaceRoot, cacheDir, warn: (m: string) => log.status(m) },
315
499
  runContextRecord,
@@ -329,7 +513,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
329
513
  if (anySandboxed) {
330
514
  const avail = await probeSandbox()
331
515
  if (!avail.available) {
332
- prepared.cache.close()
333
516
  throw new UserError(
334
517
  verifyInputs
335
518
  ? `--verify=inputs needs the sandbox, which is not available: ${avail.reason}`
@@ -370,7 +553,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
370
553
  // Nothing declared → empty map → fields omitted from the scheduler
371
554
  // AND the footer → byte-identical legacy path.
372
555
  const memBudget = options.memory ?? os.totalmem()
373
- const resourceCosts = resolveResourceCosts(nodes, concurrency, memBudget)
556
+ const resourceCosts = resolveResourceCosts(nodes)
374
557
 
375
558
  // Run context for the footer. The top-of-run header is gone — the
376
559
  // banner now lives in the summary, where the eye lands at the end.
@@ -385,17 +568,23 @@ export async function run(options: RunOptions): Promise<RunSummary> {
385
568
 
386
569
  // Lifecycle hooks drive the default logger's dynamic status line
387
570
  // (TTY-only); custom loggers may ignore them.
388
- log.runStart?.({ total: taskCount, concurrency, requestedCount, context: runContext })
571
+ log.runStart?.({
572
+ total: taskCount,
573
+ concurrency,
574
+ requestedCount,
575
+ context: runContext,
576
+ startedAtMs: endedAtMsAtStart,
577
+ })
389
578
 
390
579
  // Remote-only: kick off background prefetches so remote-GET latency
391
580
  // overlaps execution. Fire-and-forget — execution starts on the next
392
- // line; LayeredCache ingests hits into local and de-dups so
581
+ // line; the layer ingests hits into local and de-dups so
393
582
  // execute-task's cache.get awaits the in-flight promise (one remote
394
583
  // GET per key). Gated entirely on a remote layer being configured;
395
584
  // local-only runs never reach here, so their behavior + perf is
396
585
  // unchanged (no upfront key pass, no local probing).
397
586
  let prefetchDone: Promise<void> = Promise.resolve()
398
- if (cache instanceof LayeredCache) {
587
+ if (prepared.hasRemoteLayer) {
399
588
  prefetchDone = startRemotePrefetch({
400
589
  nodes,
401
590
  cache,
@@ -416,8 +605,8 @@ export async function run(options: RunOptions): Promise<RunSummary> {
416
605
  // restore-tier the scheduler runs ahead of their deps but only as
417
606
  // worker-slot backfill (misses own the pool) — and execute reuses
418
607
  // 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.
608
+ // shouldShortCircuit (local reads on, no remote layer); when off, both
609
+ // maps are empty and the run is byte-identical.
421
610
  let shortCircuit: ShortCircuit = EMPTY_SHORT_CIRCUIT
422
611
  if (shouldShortCircuit(nodes, policy, cache)) {
423
612
  shortCircuit = await startLocalShortCircuit({
@@ -447,6 +636,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
447
636
  ...(taskTimeoutDefault !== undefined ? { timeout: taskTimeoutDefault } : {}),
448
637
  ...(options.verify !== undefined ? { verify: options.verify } : {}),
449
638
  log,
639
+ executor: placements.executors.get(node.id) ?? UNPLACED_EXECUTOR,
640
+ ...(download.modeOf.get(node.id) === 'deferred' ? { download: 'deferred' as const } : {}),
641
+ deferred: deferredOutputs,
642
+ ...(placements.remoteOnlyNoop.has(node.id) ? { remoteOnlyNoop: true } : {}),
643
+ ...(placements.remoteOnly.has(node.id) ? { remoteOnly: true } : {}),
450
644
  nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
451
645
  runStartHrTimeNs,
452
646
  persistentRegistry,
@@ -526,9 +720,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
526
720
  }
527
721
  }
528
722
 
723
+ mark('classify + probe')
529
724
  const outcomes = await runGraph({
530
725
  nodes,
531
726
  concurrency,
727
+ ...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
532
728
  ...(resourceCosts.size > 0 ? { resourceCosts, cpuBudget: concurrency, memBudget } : {}),
533
729
  ...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
534
730
  onStart: (node) => {
@@ -536,11 +732,12 @@ export async function run(options: RunOptions): Promise<RunSummary> {
536
732
  },
537
733
  onFinish: (o) => {
538
734
  log.taskComplete(o.node, o)
735
+ narrowDemand(o.node.id)
539
736
  },
540
737
  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,
738
+ // A `schedule` plugin's weights; the scheduler keeps its structural
739
+ // baseline as the tie-break. Empty map → baseline only.
740
+ ...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
544
741
  // Local short-circuit: confirmed stable local hits the scheduler
545
742
  // runs ahead of their deps as low-priority worker-slot backfill.
546
743
  // Empty when the short-circuit didn't fire → byte-identical.
@@ -571,27 +768,39 @@ export async function run(options: RunOptions): Promise<RunSummary> {
571
768
  }
572
769
  const keepAliveSet = new Set(keepAlive)
573
770
 
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')
771
+ // Shut down the dependency-only persistent tasks before reporting the final
772
+ // summary. SIGTERM gives well-behaved servers (vite, next, esbuild --watch)
773
+ // a moment to clean up. Bun's Subprocess.kill is idempotent on an
774
+ // already-exited child. Bound the wait: a persistent dep that traps or
775
+ // ignores SIGTERM (a wedged mock server) would otherwise hang the run at
776
+ // NORMAL completion forever — after a grace, SIGKILL the stragglers and move
777
+ // on. Well-behaved servers exit in well under the grace, so the happy path
778
+ // pays nothing; the timer is cleared + unref'd so a fast shutdown never
779
+ // delays CLI exit.
780
+ const dyingChildren = [...persistentRegistry.values()].filter((c) => !keepAliveSet.has(c))
781
+ for (const child of dyingChildren) child.kill('SIGTERM')
782
+ const allExited = Promise.allSettled(dyingChildren.map((c) => c.exited))
783
+ let graceTimer: ReturnType<typeof setTimeout> | undefined
784
+ const winner = await Promise.race([
785
+ allExited.then(() => 'exited' as const),
786
+ new Promise<'grace'>((resolve) => {
787
+ graceTimer = setTimeout(() => resolve('grace'), PERSISTENT_SHUTDOWN_GRACE_MS)
788
+ graceTimer.unref?.()
789
+ }),
790
+ ])
791
+ if (graceTimer !== undefined) clearTimeout(graceTimer)
792
+ if (winner === 'grace') {
793
+ for (const child of dyingChildren) child.kill('SIGKILL')
794
+ await allExited
581
795
  }
582
- await Promise.allSettled(
583
- [...persistentRegistry.values()].filter((c) => !keepAliveSet.has(c)).map((c) => c.exited),
584
- )
585
796
 
797
+ mark('run graph')
586
798
  // Clear the status line for good before the summary prints.
587
799
  log.runEnd?.()
588
800
 
589
801
  const list = [...outcomes.values()]
590
802
  const ok =
591
- list.every(
592
- (o) =>
593
- o.status === 'success' || o.status === 'cache-hit' || o.status === 'cache-hit-remote',
594
- ) &&
803
+ list.every((o) => isPassStatus(o.status)) &&
595
804
  // `--verify`: a provably-unsafe cache entry (non-deterministic outputs, a
596
805
  // re-run that failed, or a read of undeclared inputs) turns the run red so
597
806
  // CI catches it.
@@ -613,6 +822,19 @@ export async function run(options: RunOptions): Promise<RunSummary> {
613
822
  for (const line of formatPersistentList(keepAliveNodes, colors)) log.status(line)
614
823
  }
615
824
  for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
825
+ // A task killed by a shutdown signal is in no bucket above, yet it makes
826
+ // `ok` false — name it, or the red exit is undiagnosable.
827
+ for (const line of formatAbortedSection(list)) log.status(line)
828
+ // Outputs that never came home are not an error, but a silent `dist/`
829
+ // that is empty-or-stale would be: name every task whose bytes are
830
+ // still remote.
831
+ const stillDeferred = deferredOutputs.pending()
832
+ if (stillDeferred.length > 0) {
833
+ log.status('')
834
+ log.status(
835
+ ` Deferred: ${stillDeferred.length} task(s) left outputs remote (--download=none): ${stillDeferred.join(', ')}`,
836
+ )
837
+ }
616
838
  if (options.verify !== undefined) {
617
839
  for (const line of formatVerifySection(list)) log.status(line)
618
840
  // A fingerprint-only run attaches no verdicts, so the verdict-driven
@@ -644,6 +866,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
644
866
  startedAtMs: endedAtMsAtStart,
645
867
  endedAtMs,
646
868
  totalMs,
869
+ ok,
647
870
  outcomes: list,
648
871
  })
649
872
  log.status(`vx: summary written to ${wrote}`)
@@ -683,7 +906,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
683
906
  let hitLocalCount = 0
684
907
  let hitRemoteCount = 0
685
908
  for (const o of list) {
686
- if (!o.hash) continue
687
909
  if (isGroupTask(o.node)) continue
688
910
  // aborted (killed by a shutdown signal) isn't a real run.
689
911
  if (o.status === 'aborted') continue
@@ -700,6 +922,8 @@ export async function run(options: RunOptions): Promise<RunSummary> {
700
922
  if (o.hash !== undefined) t.hash = o.hash
701
923
  if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
702
924
  if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
925
+ if (o.where !== undefined) t.where = o.where
926
+ if (o.outputs !== undefined) t.outputs = o.outputs
703
927
  if (o.attempts !== undefined) t.attempts = o.attempts
704
928
  if (o.verify !== undefined) t.verify = o.verify
705
929
  if (o.outputFp !== undefined) t.outputFp = o.outputFp
@@ -707,8 +931,17 @@ export async function run(options: RunOptions): Promise<RunSummary> {
707
931
  if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
708
932
  summaryTasks.push(t)
709
933
  }
934
+ // Every non-group, non-aborted outcome gets a row — the same set
935
+ // `tallyOutcomes` counts, so `invocations.task_count` equals both the
936
+ // terminal's "N total" and `COUNT(*) FROM runs WHERE run_id = ?`.
937
+ // An outcome with NO hash (a `skipped` task never probed the cache; a
938
+ // `persistent` one is never cacheable) used to be dropped here because
939
+ // `runs.hash` is NOT NULL — which made a failing persistent task record
940
+ // `0 tasks, 0 failures` on a run the terminal called red, and a failed
941
+ // task with a skipped dependent record 1 of 2. `bindRun` stores `''` for
942
+ // those instead; the key-diff readers guard it.
710
943
  toRecord.push({
711
- hash: o.hash,
944
+ ...(o.hash !== undefined ? { hash: o.hash } : {}),
712
945
  project: o.node.projectName,
713
946
  task: o.node.taskName,
714
947
  status: o.status,
@@ -717,9 +950,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
717
950
  ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
718
951
  // Anchor to the REAL per-task wall-clock window: run-start wall time +
719
952
  // 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.
953
+ // The `now - duration` fallback applies to outcomes without an offset —
954
+ // today only `skipped`, which the scheduler finishes synchronously with
955
+ // no span, so it collapses to a zero-width mark at the run's end. Using
956
+ // run-end-minus-duration for EVERYTHING was the old bug that piled every
957
+ // task at the right edge of the timeline.
723
958
  startedAt:
724
959
  o.wallclockStartNs !== undefined
725
960
  ? endedAtMsAtStart + Math.round(Number(o.wallclockStartNs) / 1e6)
@@ -733,7 +968,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
733
968
  ...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
734
969
  ...(o.wallclockStartNs !== undefined ? { wallclockStartNs: o.wallclockStartNs } : {}),
735
970
  ...(o.wallclockEndNs !== undefined ? { wallclockEndNs: o.wallclockEndNs } : {}),
736
- cacheHit: o.status === 'cache-hit' || o.status === 'cache-hit-remote',
971
+ cacheHit: isCacheHit(o.status),
737
972
  ...(o.attempts !== undefined ? { attempts: o.attempts } : {}),
738
973
  })
739
974
  if (o.status === 'failed') failedCount++
@@ -768,41 +1003,26 @@ export async function run(options: RunOptions): Promise<RunSummary> {
768
1003
  tags: JSON.stringify(options.tags ?? {}),
769
1004
  }
770
1005
  cache.recordRunBundle({ runs: toRecord, invocation })
1006
+ mark('record history')
771
1007
  // Hand the per-run summary to the telemetry sinks + drain them. Only
772
1008
  // when a sink is active (telemetry !== undefined) — otherwise this
773
1009
  // whole block is skipped and the run is byte-identical to before.
774
1010
  // emitSummary/flush are crash-isolated, so a faulty sink can't fail
775
1011
  // the run; flush is the sink's last chance to ship buffered records.
776
1012
  if (telemetry !== undefined && runContextRecord !== undefined) {
777
- const summary: RunSummaryRecord = {
778
- v: TELEMETRY_SCHEMA_VERSION,
779
- run: runContextRecord,
1013
+ const summary = assembleRunSummary(runContextRecord, summaryTasks, {
780
1014
  startedAt: endedAtMsAtStart,
781
1015
  endedAt: endedAtMs,
782
1016
  totalDurationMs: Math.round(totalMs),
783
- taskCount: toRecord.length,
784
- failedCount,
785
- hitCount: hitLocalCount + hitRemoteCount,
786
- hitLocalCount,
787
- hitRemoteCount,
788
1017
  exitOk: ok,
789
- tasks: summaryTasks,
790
- }
1018
+ })
791
1019
  telemetry.emitSummary(summary)
792
1020
  await telemetry.flush()
793
1021
  }
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
- }
1022
+ // End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
1023
+ // + time-bounded inside teardownPlugins, so a faulty plugin can neither
1024
+ // fail nor hang the run. Normal completion path only — the finally
1025
+ // below just unsubscribes.
806
1026
  // Drain any still-in-flight background prefetches before closing the
807
1027
  // cache handle — a prefetch ingesting into a closed SQLite DB would
808
1028
  // throw. Tasks that resolved as local hits never awaited their
@@ -811,9 +1031,19 @@ export async function run(options: RunOptions): Promise<RunSummary> {
811
1031
  // before scheduling, so nothing of its is in flight here.) The
812
1032
  // background write-through uploads queued by LayeredCache.save
813
1033
  // settle here for the same reason.
1034
+ //
1035
+ // BEFORE teardownPlugins, and that order is load-bearing: the drain
1036
+ // pushes bytes through a layer a PLUGIN provided, so a plugin that
1037
+ // releases its client in `teardown()` would otherwise have the
1038
+ // channel shut from under the upload — silently losing every remote
1039
+ // write with nothing but a warning. The seam's contract is that a
1040
+ // cache layer stays usable until the run's uploads have settled.
814
1041
  await prefetchDone
815
- if (cache instanceof LayeredCache) await cache.drainUploads()
816
- cache.close()
1042
+ await cache.drainUploads?.()
1043
+ await teardownPlugins(prepared.plugins, (m) => log.status(m))
1044
+ closeCache()
1045
+ mark('close')
1046
+ printTimings()
817
1047
 
818
1048
  // Tear down SRT's network bridge + (on macOS) log monitor. No-op if
819
1049
  // no task was sandboxed; otherwise SRT keeps proxy servers alive and
@@ -849,8 +1079,17 @@ export async function run(options: RunOptions): Promise<RunSummary> {
849
1079
  // Plugins installed at the top of run() get their bus subscriptions
850
1080
  // released here. Idempotent; safe even if installPlugins threw.
851
1081
  disposePlugins?.()
852
- eventSinks?.dispose()
853
1082
  telemetry?.dispose()
1083
+ // No-op after the normal path's close. On a throw this is the only
1084
+ // close there is, and it must not itself throw — that would replace
1085
+ // the run's real error with a teardown one. Background uploads are
1086
+ // NOT drained here: they hold no database state, and awaiting a
1087
+ // wedged remote would turn a failing run into a hanging one.
1088
+ try {
1089
+ closeCache()
1090
+ } catch {
1091
+ // teardown must not throw on the way out
1092
+ }
854
1093
  }
855
1094
  }
856
1095
 
@@ -869,19 +1108,248 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
869
1108
  const log = options.log ?? defaultLogger()
870
1109
  const prepared = await prepareRun(options, log)
871
1110
  try {
1111
+ if (prepared.unresolvedTasks.length > 0) {
1112
+ return { tasks: [], unresolvedTasks: prepared.unresolvedTasks }
1113
+ }
872
1114
  if (prepared.empty !== null) return { tasks: [] }
873
1115
  return await plan({
874
1116
  nodes: prepared.nodes,
875
1117
  workspaceRoot: prepared.workspaceRoot,
876
1118
  workspaceFingerprint: prepared.workspaceFingerprint,
877
1119
  cache: prepared.cache,
878
- cachePolicy: options.cache ?? FULL_CACHE_POLICY,
1120
+ cachePolicy: effectiveCachePolicy(
1121
+ options.cache ?? FULL_CACHE_POLICY,
1122
+ prepared.hasRemoteLayer,
1123
+ ),
879
1124
  forwardArgs: options.forwardArgs,
880
1125
  nestedDirsByProject: prepared.nestedDirsByProject,
881
1126
  gitFilesCache: prepared.gitFilesCache,
882
1127
  hashCache: prepared.hashCache,
1128
+ // Plan-time duration prediction (dev-scenarios S2): the same local
1129
+ // history the predictive scheduler reads, folded into the plan as
1130
+ // per-task p50s + a predicted wall-clock. `--dry` is an explicit
1131
+ // inspection command, so the history read's cost is fine here even
1132
+ // though it stays OFF the default run path.
1133
+ history: new LocalHistoryProvider(prepared.localCache.dbHandle()),
1134
+ // Placement, by the same rules the run applies. Only worth showing
1135
+ // when there is a choice to show — with one declared executor every
1136
+ // line would carry the same label. Resolving the executors here is
1137
+ // the same plugin-factory call `prepareRun` already makes for the
1138
+ // cache capability, so plan mode gains no new class of side effect.
1139
+ ...(await planExecutorOf(prepared, log, options.download ?? 'all')),
883
1140
  })
884
1141
  } finally {
885
1142
  prepared.cache.close()
886
1143
  }
887
1144
  }
1145
+
1146
+ /**
1147
+ * A task is pinned to this machine when it is persistent, transitively
1148
+ * depends on a persistent task (a worker cannot reach a port on the
1149
+ * submitter), or declares `exec.remote: false`.
1150
+ */
1151
+ function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
1152
+ const pinned = new Set<string>()
1153
+ const memo = new Map<string, boolean>()
1154
+ const visit = (id: string): boolean => {
1155
+ const known = memo.get(id)
1156
+ if (known !== undefined) return known
1157
+ const node = nodes.get(id)
1158
+ if (node === undefined) return false
1159
+ memo.set(id, false) // cycle guard; the graph builder already rejects cycles
1160
+ const result =
1161
+ node.config.exec?.persistent !== undefined ||
1162
+ node.config.exec?.remote === false ||
1163
+ node.deps.some((d) => visit(d))
1164
+ memo.set(id, result)
1165
+ if (result) pinned.add(id)
1166
+ return result
1167
+ }
1168
+ for (const id of nodes.keys()) visit(id)
1169
+ return pinned
1170
+ }
1171
+
1172
+ interface Placements {
1173
+ executors: Map<string, TaskExecutor>
1174
+ /**
1175
+ * `exec.remote: 'only'` tasks that no REMOTE executor took — a NO-OP on
1176
+ * this machine: never executed, declared outputs never cleaned or
1177
+ * restored. The task exists to produce a remote input tree; without a
1178
+ * remote pool, dependents use the machine's ambient state exactly as they
1179
+ * did before the field existed.
1180
+ */
1181
+ remoteOnlyNoop: Set<string>
1182
+ /** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
1183
+ remoteOnly: Set<string>
1184
+ }
1185
+
1186
+ function placeTasks(
1187
+ nodes: Map<string, TaskNode>,
1188
+ executors: readonly TaskExecutor[],
1189
+ pinAllLocal = false,
1190
+ ): Placements {
1191
+ const pinned = pinnedLocalSet(nodes)
1192
+ const placements: Placements = {
1193
+ executors: new Map(),
1194
+ remoteOnlyNoop: new Set(),
1195
+ remoteOnly: new Set(),
1196
+ }
1197
+ for (const node of nodes.values()) {
1198
+ if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
1199
+ const executor = selectExecutor(executors, {
1200
+ taskId: node.id,
1201
+ projectName: node.projectName,
1202
+ projectDir: node.projectDir,
1203
+ command: node.config.exec!.command,
1204
+ pinnedLocal: pinAllLocal || pinned.has(node.id),
1205
+ cacheable: node.config.cache !== undefined,
1206
+ ...(node.config.exec?.resources === undefined
1207
+ ? {}
1208
+ : { resources: node.config.exec.resources }),
1209
+ })
1210
+ placements.executors.set(node.id, executor)
1211
+ if (node.config.exec?.remote === 'only') {
1212
+ // A pinned 'only' task (it transitively depends on a persistent one)
1213
+ // lands here too: pinning wins, so it noops rather than shipping.
1214
+ if (executor.remote === true) placements.remoteOnly.add(node.id)
1215
+ else placements.remoteOnlyNoop.add(node.id)
1216
+ }
1217
+ }
1218
+ return placements
1219
+ }
1220
+
1221
+ /**
1222
+ * `executorOf` for `planRun`, or nothing. Declining plugins, a single
1223
+ * executor, or a resolution error all yield nothing: `--dry` is an
1224
+ * inspection command and must not fail over a label.
1225
+ */
1226
+ async function planExecutorOf(
1227
+ prepared: Awaited<ReturnType<typeof prepareRun>>,
1228
+ log: Logger,
1229
+ policy: 'all' | 'toplevel' | 'none',
1230
+ ): Promise<{
1231
+ executorOf?: (id: string) => string | undefined
1232
+ downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
1233
+ downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
1234
+ }> {
1235
+ let executors: readonly TaskExecutor[]
1236
+ try {
1237
+ executors = await resolveExecutors(prepared.plugins, {
1238
+ workspaceRoot: prepared.workspaceRoot,
1239
+ cacheDir: prepared.cacheDir,
1240
+ warn: (m: string) => log.status(m),
1241
+ concurrency: Math.max(1, navigator.hardwareConcurrency),
1242
+ })
1243
+ } catch {
1244
+ return {}
1245
+ }
1246
+ const placements = placeTasks(prepared.nodes, executors)
1247
+ // Download modes need placement regardless of how many executors there
1248
+ // are (a single REMOTE one still defers); executor LABELS only earn their
1249
+ // column when there is a choice to report.
1250
+ const download =
1251
+ policy === 'all'
1252
+ ? undefined
1253
+ : resolveDownloadModes({
1254
+ nodes: prepared.nodes,
1255
+ policy,
1256
+ localPlaced: new Set(
1257
+ [...prepared.nodes.keys()].filter(
1258
+ (id) => placements.executors.get(id)?.remote !== true,
1259
+ ),
1260
+ ),
1261
+ remoteOnly: placements.remoteOnly,
1262
+ })
1263
+ return {
1264
+ ...(executors.length < 2
1265
+ ? {}
1266
+ : {
1267
+ executorOf: (id: string) =>
1268
+ placements.remoteOnlyNoop.has(id) ? 'noop' : placements.executors.get(id)?.name,
1269
+ }),
1270
+ ...(download === undefined
1271
+ ? {}
1272
+ : {
1273
+ downloadOf: (id: string) => download.modeOf.get(id),
1274
+ downloadDowngrades: [...download.downgrades].map(([taskId, reason]) => ({
1275
+ taskId,
1276
+ reason,
1277
+ })),
1278
+ }),
1279
+ }
1280
+ }
1281
+
1282
+ /**
1283
+ * Stands in for the executor of a task that was never placed — a group task
1284
+ * (runs nothing) or a persistent one (`executePersistentTask` owns it and
1285
+ * never reads this field). It THROWS rather than silently picking some
1286
+ * executor from the list, so a refactor that routes such a task through the
1287
+ * exec path fails loudly instead of shipping a localhost server to a worker.
1288
+ */
1289
+ const UNPLACED_EXECUTOR: TaskExecutor = {
1290
+ name: 'unplaced',
1291
+ execute: (req) => {
1292
+ throw new Error(`internal error: ${req.taskId} reached an executor without being placed`)
1293
+ },
1294
+ }
1295
+
1296
+ function hasPooledExecutor(executors: readonly TaskExecutor[]): boolean {
1297
+ return executors.some((e) => e.capacity !== undefined)
1298
+ }
1299
+
1300
+ function poolOfPlacement(
1301
+ placements: Placements,
1302
+ ): (id: string) => { name: string; capacity: number } | undefined {
1303
+ return (id) => {
1304
+ const executor = placements.executors.get(id)
1305
+ return executor?.capacity === undefined
1306
+ ? undefined
1307
+ : { name: executor.name, capacity: executor.capacity }
1308
+ }
1309
+ }
1310
+
1311
+ /**
1312
+ * A typo's nearest declared name, when one is within two edits — the
1313
+ * message names the fix instead of only the mistake. A bare name is
1314
+ * matched against every declared task; `pkg#task` against the project
1315
+ * names first (the task kept) and then against that project's tasks, so
1316
+ * the hint is a spec the user can run.
1317
+ */
1318
+ function didYouMean(
1319
+ unresolved: readonly string[],
1320
+ projects: ReadonlyMap<string, ProjectEntry>,
1321
+ ): string {
1322
+ const nearest = (name: string, candidates: Iterable<string>): string | undefined => {
1323
+ let best: string | undefined
1324
+ let bestD = 3
1325
+ for (const c of candidates) {
1326
+ const d = editDistance(name, c)
1327
+ if (d < bestD) {
1328
+ bestD = d
1329
+ best = c
1330
+ }
1331
+ }
1332
+ return best === name ? undefined : best
1333
+ }
1334
+ const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
1335
+ const allTasks = new Set<string>()
1336
+ for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
1337
+ const hints: string[] = []
1338
+ for (const spec of unresolved) {
1339
+ const at = spec.indexOf('#')
1340
+ if (at < 0) {
1341
+ const t = nearest(spec, allTasks)
1342
+ if (t !== undefined) hints.push(t)
1343
+ continue
1344
+ }
1345
+ const [proj, task] = [spec.slice(0, at), spec.slice(at + 1)]
1346
+ if (!projects.has(proj)) {
1347
+ const p = nearest(proj, projects.keys())
1348
+ if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.push(`${p}#${task}`)
1349
+ continue
1350
+ }
1351
+ const t = nearest(task, tasksOf(projects.get(proj)))
1352
+ if (t !== undefined) hints.push(`${proj}#${t}`)
1353
+ }
1354
+ return hints.length === 0 ? '' : ` Did you mean ${hints.join(', ')}?`
1355
+ }