@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -8,6 +8,7 @@ import type { TaskOutcome } from '../graph/index.js'
8
8
  import type { EventBus } from './events.js'
9
9
  import type { TelemetrySink } from './telemetry.js'
10
10
  import type { Logger } from './logger.js'
11
+ import type { ProjectEntry } from '../workspace/index.js'
11
12
 
12
13
  export interface RunOptions {
13
14
  cwd: string
@@ -19,6 +20,16 @@ export interface RunOptions {
19
20
  */
20
21
  tasks: readonly string[]
21
22
  projects?: string[]
23
+ /**
24
+ * Configs a selection pass in this same process already loaded and
25
+ * staged with the same `cacheDir` and `frozen` (the CLI walks every
26
+ * `pkg#task` edge for `--filter 'app...'` and `--affected`). The run
27
+ * seeds and scopes as it always does and reuses these entries instead
28
+ * of evaluating and staging them again. One run only — never carry a
29
+ * map across runs, a config can change between them (`vx watch` drops
30
+ * it per cycle).
31
+ */
32
+ staged?: ReadonlyMap<string, ProjectEntry>
22
33
  concurrency?: number
23
34
  /**
24
35
  * Cache directory override (`--cache-dir <path>`). Absolute, or relative
@@ -34,6 +45,13 @@ export interface RunOptions {
34
45
  * both reads false (re-execute, still refresh the cache).
35
46
  */
36
47
  cache?: CachePolicy
48
+ /**
49
+ * The caller asked for a remote axis by name (`--cache=remote:rw`). With
50
+ * no cache plugin supplying a remote layer the axes are inert, and a CI
51
+ * job that believes it is filling a shared cache deserves one line
52
+ * saying it is not.
53
+ */
54
+ remoteRequested?: boolean
37
55
  /**
38
56
  * CI mode: load configs FROM the committed vx-lock.json instead of
39
57
  * evaluating them (frozen-env reproducibility). Requires the lock
@@ -62,7 +80,7 @@ export interface RunOptions {
62
80
  * task in the run actually needs them (Bazel's "build without the
63
81
  * bytes").
64
82
  * Pure transfer tuning: it can never change what a command produces,
65
- * so it is NEVER folded into a cache key (a RunOption, like --verify).
83
+ * so it is NEVER folded into a cache key: it is a RunOption, not config.
66
84
  * Locally-placed tasks always write in place and ignore this.
67
85
  */
68
86
  download?: 'all' | 'toplevel' | 'none'
@@ -82,24 +100,6 @@ export interface RunOptions {
82
100
  * the same run with and without `--retry` derives identical keys.
83
101
  */
84
102
  retries?: number
85
- /**
86
- * Cache-correctness verification (`vx run --verify`). When set, an
87
- * executed + cacheable task is re-run after its save and its outputs are
88
- * content-compared: a divergence means the task is non-hermetic and its
89
- * cache entry is provably unsafe (see docs/design/cache-correctness).
90
- * `fingerprint` computes + ships an output-tree fingerprint on executed
91
- * tasks (no re-run — the cross-machine diff feed; `--verify`/`=all` set
92
- * it too, for free). `allow` (from `--verify-allow=<pkg#task>,…`) exempts
93
- * known-nondeterministic tasks from failing the run. A pure side-channel —
94
- * never folded into a cache key; the re-run never saves. Undefined = off
95
- * (a plain run is byte-identical).
96
- */
97
- verify?: {
98
- determinism: boolean
99
- inputs: boolean
100
- fingerprint: boolean
101
- allow: ReadonlySet<string>
102
- }
103
103
  /**
104
104
  * Run-level default task timeout (ms), for tasks that declare no
105
105
  * `exec.timeout`. Highest of the run-level defaults: `--timeout` /
@@ -109,14 +109,6 @@ export interface RunOptions {
109
109
  * cached), so a `--timeout` run cache-hits a plain run's entry.
110
110
  */
111
111
  timeout?: number
112
- /**
113
- * Memory budget (bytes) that per-task `exec.resources.memory`
114
- * reservations pack against (`--memory <size>`). Defaults to
115
- * `os.totalmem()` — override it in cgroup-limited containers, where
116
- * totalmem() reports the HOST's RAM, not the limit. A per-run
117
- * scheduling knob only — never folded into any cache key.
118
- */
119
- memory?: number
120
112
  /**
121
113
  * Failure propagation: 'never' stops dispatch on the first failure
122
114
  * (in-flight tasks finish; everything queued skips), 'deps-ok'
@@ -147,13 +139,33 @@ export interface RunOptions {
147
139
  */
148
140
  profile?: string
149
141
  /**
150
- * Install SIGINT/SIGTERM handlers for the run's duration: forward
151
- * SIGTERM to every live child, close the cache, exit 128+signo
152
- * (130/143). Default true. The watch loop disables this — it owns
142
+ * Install SIGINT/SIGTERM handlers for the run's duration: forward the
143
+ * signal received to every live child's group (a SIGHUP as SIGTERM),
144
+ * close the cache, exit 128+signo (130/143). Default true. The watch loop disables this — it owns
153
145
  * signal disposition for its whole lifetime and a cycle must never
154
146
  * exit the process out from under it.
155
147
  */
156
148
  handleSignals?: boolean
149
+ /**
150
+ * Abort the run from outside. On `abort` every live and persistent
151
+ * child is sent SIGINT when the abort reason is `'SIGINT'` (a Ctrl-C
152
+ * the embedder received), SIGTERM otherwise, given the kill grace,
153
+ * then SIGKILLed; nothing
154
+ * further is dispatched; run() returns with the killed tasks and the
155
+ * never-started ones `aborted`. The seam for an embedder that owns its
156
+ * process's lifetime instead of `handleSignals` — the watch loop aborts
157
+ * its in-flight cycle on SIGINT/SIGTERM this way, so a cancellation
158
+ * never orphans a cycle's children.
159
+ */
160
+ signal?: AbortSignal
161
+ /**
162
+ * Hand the requested persistent tasks still running back on
163
+ * `RunSummary.persistent` instead of stopping them when the graph ends.
164
+ * The caller owns them from then on and stops them with its `stop()`.
165
+ * The watch loop sets this: a dev server stays up while watch idles and
166
+ * is replaced only when the next cycle starts.
167
+ */
168
+ holdPersistent?: boolean
157
169
  log?: Logger
158
170
  /**
159
171
  * Inject the run's event bus. When provided, the orchestrator emits
@@ -168,7 +180,8 @@ export interface RunOptions {
168
180
  bus?: EventBus
169
181
  /**
170
182
  * Shared in-flight execution registry, keyed by task hash. Supplied by a
171
- * long-lived service (`vx serve`) so concurrent runs DEDUP work: a task
183
+ * long-lived embedder (a daemon built on the façade; core ships none) so
184
+ * concurrent runs DEDUP work: a task
172
185
  * already executing for one run is awaited by another (which then
173
186
  * restores the just-saved artifact from cache) instead of re-running.
174
187
  * A stateless `vx run` passes none and is byte-identical to before.
@@ -183,8 +196,8 @@ export interface RunOptions {
183
196
  /**
184
197
  * Additional observe-only telemetry sinks, merged with whatever the
185
198
  * workspace plugins contribute. The embedder seam: a host executing
186
- * runs on behalf of others (a serve recording delegated runs
187
- * into its ingest store) attaches a sink without needing a workspace
199
+ * runs on behalf of others (a daemon recording delegated runs
200
+ * into its own store) attaches a sink without needing a workspace
188
201
  * plugin. Undefined → zero cost, identical to before; the telemetry
189
202
  * host's zero-sink invariant still applies when both sources are
190
203
  * empty.
@@ -200,16 +213,32 @@ export interface RunOptions {
200
213
  /**
201
214
  * An injected remote cache layer — the embedder seam mirroring
202
215
  * `telemetrySinks`: a host that already holds a wire client (a
203
- * distribution agent, a serve executing on behalf of a submitter)
216
+ * distribution agent, a daemon executing on behalf of a submitter)
204
217
  * passes it here and run() composes `LayeredCache(local, injected)`.
205
218
  * Explicit injection WINS over the plugin `cache` capability (the
206
219
  * host knows best; prevents double-wrapping when the workspace also
207
220
  * declares a cache plugin). Undefined → identical to before.
208
221
  */
209
222
  remoteCache?: RemoteCacheLayer
223
+ /**
224
+ * The largest artifact, decoded, the local cache saves, ingests or
225
+ * restores; default 2 GiB. A seam for a test, which cannot produce 2 GiB
226
+ * of output to prove the refusal: the CLI never sets it, and no config
227
+ * or variable reaches it.
228
+ */
229
+ artifactCeiling?: number
210
230
  }
211
231
 
212
232
  export interface RunSummary {
213
233
  ok: boolean
214
234
  outcomes: TaskOutcome[]
235
+ /** The persistent tasks `RunOptions.holdPersistent` handed back; absent when none is running. */
236
+ persistent?: HeldPersistent
237
+ }
238
+
239
+ export interface HeldPersistent {
240
+ /** The held tasks' ids, `pkg#task`. */
241
+ ids: readonly string[]
242
+ /** Send every held task `signal` (default SIGTERM), then SIGKILL after the kill grace; resolves once each is gone. */
243
+ stop(signal?: 'SIGINT' | 'SIGTERM'): Promise<void>
215
244
  }
@@ -0,0 +1,99 @@
1
+ // End-of-run disposition of persistent children (dev servers, watchers).
2
+ // `executeTask` spawns them and returns at "ready" without awaiting exit;
3
+ // ownership moves to the run's registry, and this is the one place that
4
+ // decides which of them outlive the graph and how the rest go down.
5
+
6
+ import type { TaskNode } from '../graph/index.js'
7
+ import { killGraceMs } from '../util/index.js'
8
+ import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
9
+
10
+ type Child = ReturnType<typeof Bun.spawn>
11
+
12
+ /**
13
+ * Grace after SIGTERMing the dependency-only persistent tasks before
14
+ * force-killing any that trap or ignore it — so a wedged mock server can't
15
+ * hang a normal run at completion. Well-behaved servers exit far under
16
+ * this, so the happy path never waits it out.
17
+ */
18
+ const PERSISTENT_SHUTDOWN_GRACE_MS = 2000
19
+
20
+ export interface KeepAlive {
21
+ nodes: TaskNode[]
22
+ children: Child[]
23
+ }
24
+
25
+ /**
26
+ * A persistent task the user REQUESTED (or one surfaced for display) is the
27
+ * run's whole purpose — it is left running and blocked on at the very end,
28
+ * after the summary. Only in the real CLI foreground: a custom logger or
29
+ * `handleSignals: false` (watch mode, embedders) expects `run()` to return,
30
+ * not to sit on a server.
31
+ */
32
+ export function selectKeepAlive(
33
+ registry: ReadonlyMap<string, Child>,
34
+ nodes: ReadonlyMap<string, TaskNode>,
35
+ foreground: boolean,
36
+ ): KeepAlive {
37
+ const out: KeepAlive = { nodes: [], children: [] }
38
+ if (!foreground) return out
39
+ for (const [id, child] of registry) {
40
+ const n = nodes.get(id)
41
+ if (n !== undefined && (n.requested || n.surfaced === true)) {
42
+ out.nodes.push(n)
43
+ out.children.push(child)
44
+ }
45
+ }
46
+ return out
47
+ }
48
+
49
+ /**
50
+ * SIGTERM every persistent child not kept alive and wait for them, bounded:
51
+ * SIGTERM gives well-behaved servers (vite, next, esbuild --watch) a moment
52
+ * to clean up; past the grace every group with a member left is SIGKILLed,
53
+ * so the run's normal completion cannot hang on one that traps the signal.
54
+ * The GROUP, not the leader: a `server & wait` shell dies on the SIGTERM
55
+ * while a server that ignores it lives on, holding the task's pipe, and
56
+ * vx printed its summary and never exited (nx#8286 reproduced on vx,
57
+ * 2026-09-24).
58
+ */
59
+ export async function shutdownPersistent(
60
+ registry: ReadonlyMap<string, Child>,
61
+ keepAlive: readonly Child[],
62
+ graceMs: number = killGraceMs(PERSISTENT_SHUTDOWN_GRACE_MS),
63
+ ): Promise<CrashedPersistent[]> {
64
+ const kept = new Set(keepAlive)
65
+ const crashed: CrashedPersistent[] = []
66
+ for (const [id, child] of registry) {
67
+ if (kept.has(child) || !hasEnded(child) || child.exitCode === 0) continue
68
+ crashed.push({ id, code: child.exitCode ?? child.signalCode ?? 'unknown' })
69
+ }
70
+ const dying = [...registry.values()].filter((c) => !kept.has(c))
71
+ if (dying.length === 0) return crashed
72
+ const letGo = holdGroups(dying)
73
+ try {
74
+ for (const child of dying) killTree(child, 'SIGTERM')
75
+ for (const child of await untilGroupsGone(dying, graceMs)) killTree(child, 'SIGKILL')
76
+ await Promise.allSettled(dying.map((c) => c.exited))
77
+ } finally {
78
+ letGo()
79
+ }
80
+ return crashed
81
+ }
82
+
83
+ /**
84
+ * A dependency-only persistent task that ended on its own, not by the stop
85
+ * at the end of the graph, and not cleanly. Its dependants may still have
86
+ * passed, and until item 892 the run did too: the server was reported
87
+ * `success` and its crash was said nowhere, while a requested server's
88
+ * crash failed the run.
89
+ */
90
+ export interface CrashedPersistent {
91
+ id: string
92
+ /** The exit code, or the signal that killed it. */
93
+ code: number | string
94
+ }
95
+
96
+ /** The child has exited or been killed; Bun sets these once it reaps it. */
97
+ export function hasEnded(child: Child): boolean {
98
+ return child.exitCode !== null || child.signalCode !== null
99
+ }
@@ -0,0 +1,212 @@
1
+ // Where each task runs: the placement of a graph over the resolved
2
+ // executors, and the plan-mode view of it. Split from run.ts on 2026-09-10
3
+ // (pure motion). A task is pinned to this machine when it is persistent,
4
+ // depends on a persistent task, is sandboxed, or says `exec.remote: false`;
5
+ // everything else asks the executors in declaration order, and the local
6
+ // floor takes what nothing claimed.
7
+
8
+ import { machineParallelism } from '../util/index.js'
9
+ import { selectExecutor, type TaskExecutor } from '../exec/index.js'
10
+ import { isGroupTask, type TaskNode } from '../graph/index.js'
11
+ import { resolveDownloadModes } from './download-policy.js'
12
+ import type { Logger } from './logger.js'
13
+ import { resolveExecutors } from './plugin-host.js'
14
+ import type { prepareRun } from './prepare.js'
15
+
16
+ /**
17
+ * A task is pinned to this machine when it is persistent, transitively
18
+ * depends on a persistent task (a worker cannot reach a port on the
19
+ * submitter), declares `exec.sandbox` (the sandbox is this machine's
20
+ * machinery — a worker has none of it, and a boundary "verified" where it
21
+ * is not enforced passes vacuously), or declares `exec.remote: false`. A
22
+ * dependant of a pinned task is pinned with it.
23
+ */
24
+ export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
25
+ const pinned = new Set<string>()
26
+ for (const node of nodes.values()) {
27
+ if (
28
+ node.config.exec?.persistent !== undefined ||
29
+ node.config.exec?.sandbox !== undefined ||
30
+ node.config.exec?.remote === false
31
+ ) {
32
+ pinned.add(node.id)
33
+ }
34
+ }
35
+ if (pinned.size === 0) return pinned
36
+ // Pinning flows up the dependant edges from what pins itself, one walk on
37
+ // an explicit stack: a chain is as deep as the graph, and a recursion
38
+ // per edge threw `RangeError` on the 50,000 the builder takes (item 737).
39
+ const dependants = new Map<string, string[]>()
40
+ for (const node of nodes.values()) {
41
+ for (const dep of node.deps) {
42
+ const list = dependants.get(dep)
43
+ if (list === undefined) dependants.set(dep, [node.id])
44
+ else list.push(node.id)
45
+ }
46
+ }
47
+ const stack = [...pinned]
48
+ while (stack.length > 0) {
49
+ for (const up of dependants.get(stack.pop()!) ?? []) {
50
+ if (pinned.has(up)) continue
51
+ pinned.add(up)
52
+ stack.push(up)
53
+ }
54
+ }
55
+ return pinned
56
+ }
57
+
58
+ export interface Placements {
59
+ executors: Map<string, TaskExecutor>
60
+ /**
61
+ * `exec.remote: 'only'` tasks that no REMOTE executor took — a NO-OP on
62
+ * this machine: never executed, declared outputs never cleaned or
63
+ * restored. The task exists to produce a remote input tree; without a
64
+ * remote pool, dependents use the machine's ambient state exactly as they
65
+ * did before the field existed.
66
+ */
67
+ remoteOnlyNoop: Set<string>
68
+ /** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
69
+ remoteOnly: Set<string>
70
+ }
71
+
72
+ /**
73
+ * The tasks that write IN PLACE — everything not placed on a remote
74
+ * executor. `resolveDownloadModes` reads it as "the outputs are already
75
+ * here", so a task wrongly left out is one vx thinks it must fetch from a
76
+ * CAS that never held it.
77
+ *
78
+ * `remote` is a THREE-state field: `true` (remote), `false` (an explicit
79
+ * local plugin) and undefined — which is core's own local floor, since
80
+ * `localExecutor()` declares no `remote` at all. So the question is "not
81
+ * remote", never "declared local"; asking the latter drops every task on
82
+ * the floor executor, i.e. every task in a workspace with no executor
83
+ * plugin. It lives here, shared, because `run()` and `--dry` each built
84
+ * this set inline from the same predicate and a second copy is how the
85
+ * plan and the run would disagree about what comes home (441, 442, 445).
86
+ */
87
+ export function locallyPlaced(placements: Placements, ids: Iterable<string>): Set<string> {
88
+ return new Set([...ids].filter((id) => placements.executors.get(id)?.remote !== true))
89
+ }
90
+
91
+ export function placeTasks(
92
+ nodes: Map<string, TaskNode>,
93
+ executors: readonly TaskExecutor[],
94
+ ): Placements {
95
+ const pinned = pinnedLocalSet(nodes)
96
+ const placements: Placements = {
97
+ executors: new Map(),
98
+ remoteOnlyNoop: new Set(),
99
+ remoteOnly: new Set(),
100
+ }
101
+ for (const node of nodes.values()) {
102
+ if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
103
+ const executor = selectExecutor(executors, {
104
+ taskId: node.id,
105
+ projectName: node.projectName,
106
+ projectDir: node.projectDir,
107
+ command: node.config.exec!.command,
108
+ pinnedLocal: pinned.has(node.id),
109
+ cacheable: node.config.cache !== undefined,
110
+ })
111
+ placements.executors.set(node.id, executor)
112
+ if (node.config.exec?.remote === 'only') {
113
+ // A pinned 'only' task (it transitively depends on a persistent one)
114
+ // lands here too: pinning wins, so it noops rather than shipping.
115
+ if (executor.remote === true) placements.remoteOnly.add(node.id)
116
+ else placements.remoteOnlyNoop.add(node.id)
117
+ }
118
+ }
119
+ return placements
120
+ }
121
+
122
+ /**
123
+ * `executorOf` for `planRun`, or nothing. Declining plugins, a single
124
+ * executor, or a resolution error all yield nothing: `--dry` is an
125
+ * inspection command and must not fail over a label. The error is still
126
+ * said, on the status line, in the plugin's name: the run this plan
127
+ * previews would refuse on it, and a plan that hid that would read as
128
+ * "everything lands locally".
129
+ */
130
+ export async function planExecutorOf(
131
+ prepared: Awaited<ReturnType<typeof prepareRun>>,
132
+ log: Logger,
133
+ policy: 'all' | 'toplevel' | 'none',
134
+ ): Promise<{
135
+ executorOf?: (id: string) => string | undefined
136
+ downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
137
+ downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
138
+ }> {
139
+ let executors: readonly TaskExecutor[]
140
+ try {
141
+ executors = await resolveExecutors(prepared.plugins, {
142
+ workspaceRoot: prepared.workspaceRoot,
143
+ cacheDir: prepared.cacheDir,
144
+ warn: (m: string) => log.status(m),
145
+ concurrency: machineParallelism(),
146
+ })
147
+ } catch (err) {
148
+ log.status(
149
+ `[vx] placement not shown — ${err instanceof Error ? err.message : String(err)} (the run would refuse on it)`,
150
+ )
151
+ return {}
152
+ }
153
+ const placements = placeTasks(prepared.nodes, executors)
154
+ // Download modes need placement regardless of how many executors there
155
+ // are (a single REMOTE one still defers); executor LABELS only earn their
156
+ // column when there is a choice to report.
157
+ const download =
158
+ policy === 'all'
159
+ ? undefined
160
+ : resolveDownloadModes({
161
+ nodes: prepared.nodes,
162
+ policy,
163
+ localPlaced: locallyPlaced(placements, prepared.nodes.keys()),
164
+ remoteOnly: placements.remoteOnly,
165
+ })
166
+ return {
167
+ ...(executors.length < 2
168
+ ? {}
169
+ : {
170
+ executorOf: (id: string) =>
171
+ placements.remoteOnlyNoop.has(id) ? 'noop' : placements.executors.get(id)?.name,
172
+ }),
173
+ ...(download === undefined
174
+ ? {}
175
+ : {
176
+ downloadOf: (id: string) => download.modeOf.get(id),
177
+ downloadDowngrades: [...download.downgrades].map(([taskId, reason]) => ({
178
+ taskId,
179
+ reason,
180
+ })),
181
+ }),
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Stands in for the executor of a task that was never placed — a group task
187
+ * (runs nothing) or a persistent one (`executePersistentTask` owns it and
188
+ * never reads this field). It THROWS rather than silently picking some
189
+ * executor from the list, so a refactor that routes such a task through the
190
+ * exec path fails loudly instead of shipping a localhost server to a worker.
191
+ */
192
+ export const UNPLACED_EXECUTOR: TaskExecutor = {
193
+ name: 'unplaced',
194
+ execute: (req) => {
195
+ throw new Error(`internal error: ${req.taskId} reached an executor without being placed`)
196
+ },
197
+ }
198
+
199
+ export function hasPooledExecutor(executors: readonly TaskExecutor[]): boolean {
200
+ return executors.some((e) => e.capacity !== undefined)
201
+ }
202
+
203
+ export function poolOfPlacement(
204
+ placements: Placements,
205
+ ): (id: string) => { name: string; capacity: number } | undefined {
206
+ return (id) => {
207
+ const executor = placements.executors.get(id)
208
+ return executor?.capacity === undefined
209
+ ? undefined
210
+ : { name: executor.name, capacity: executor.capacity }
211
+ }
212
+ }
@@ -16,6 +16,7 @@ import { FULL_CACHE_POLICY } from '../cache/index.js'
16
16
  import { isGroupTask, runGraph, type TaskNode, type TaskOutcome } from '../graph/index.js'
17
17
  import type { HistoryProvider } from './history.js'
18
18
  import { computeGroupHash, computeTaskHash } from './task-hash.js'
19
+ import { keyUpstream } from './upstream.js'
19
20
 
20
21
  export type CacheStatus =
21
22
  | 'hit-local' // entry exists in local cache
@@ -124,11 +125,20 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
124
125
  const outcomes = await runGraph({
125
126
  nodes: args.nodes,
126
127
  concurrency: 1,
127
- execute: async (node, upstream) => {
128
+ execute: async (node, live) => {
129
+ const upstream = keyUpstream(node, live)
128
130
  if (isGroupTask(node)) {
129
131
  cacheStatusById.set(node.id, 'group')
130
132
  return planOutcome(node, computeGroupHash(upstream))
131
133
  }
134
+ // A persistent task has no key on the live path (its outcome carries
135
+ // none, `stable-keys.ts`), so a dependant folds nothing for it. The
136
+ // plan keyed it and folded that, and `--dry` then called a dependant
137
+ // a miss under a key the run never looks up (item 766).
138
+ if (node.config.exec?.persistent !== undefined) {
139
+ cacheStatusById.set(node.id, 'no-cache')
140
+ return planOutcome(node, undefined)
141
+ }
132
142
 
133
143
  const hash = await computeTaskHash({
134
144
  node,
@@ -142,17 +152,20 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
142
152
  ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
143
153
  })
144
154
 
145
- // Prediction keys off READS: a no-read policy (--no-cache /
146
- // --force / --cache=local:,remote:) would re-execute every task,
147
- // so we predict misses for it. The probe below routes through the
148
- // policy-aware cache layer, which itself respects local/remote
149
- // read gating.
155
+ // Prediction keys off READS: a no-read policy re-executes every task,
156
+ // so nothing is probed. Whether that run is a miss or no cache at all
157
+ // is the WRITE axes': `--force` and `--cache=local:w` save what they
158
+ // run, and the plan called them `no-cache`, the label a task with no
159
+ // `cache` block gets (item 991). The probe below routes through the
160
+ // policy-aware cache layer, which itself respects read gating.
150
161
  const policy = args.cachePolicy ?? FULL_CACHE_POLICY
151
- const cacheEnabled =
152
- node.config.cache !== undefined && (policy.localRead || policy.remoteRead)
162
+ const reads = policy.localRead || policy.remoteRead
163
+ const writes = policy.localWrite || policy.remoteWrite
153
164
  let status: CacheStatus
154
- if (!cacheEnabled) {
165
+ if (node.config.cache === undefined || (!reads && !writes)) {
155
166
  status = 'no-cache'
167
+ } else if (!reads) {
168
+ status = 'miss'
156
169
  } else {
157
170
  const where = await args.cache.has(hash)
158
171
  status = where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local'
@@ -261,12 +274,12 @@ function predictPlan(tasks: PlannedTask[]): PlanPrediction {
261
274
  return { wallMs, workMs, unknownCount }
262
275
  }
263
276
 
264
- function planOutcome(node: TaskNode, hash: string): TaskOutcome {
277
+ function planOutcome(node: TaskNode, hash: string | undefined): TaskOutcome {
265
278
  return {
266
279
  node,
267
280
  status: 'success',
268
281
  exitCode: 0,
269
282
  durationMs: 0,
270
- hash,
283
+ ...(hash !== undefined ? { hash } : {}),
271
284
  }
272
285
  }