@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.
- package/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- 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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
187
|
-
* into its
|
|
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
|
|
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
|
+
}
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -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,
|
|
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
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
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
|
|
152
|
-
|
|
162
|
+
const reads = policy.localRead || policy.remoteRead
|
|
163
|
+
const writes = policy.localWrite || policy.remoteWrite
|
|
153
164
|
let status: CacheStatus
|
|
154
|
-
if (!
|
|
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
|
}
|