@vzn/vx 0.0.632 → 0.0.634
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/README.md +14 -10
- package/docs/caching.md +2250 -0
- package/docs/cli.md +3283 -0
- package/docs/execution.md +708 -0
- package/docs/features.md +174 -0
- package/docs/patterns.md +219 -0
- package/docs/schema.md +2151 -0
- package/docs/security.md +209 -0
- package/package.json +12 -5
- package/schemas/docs.json +28 -0
- package/schemas/error.json +5 -1
- package/schemas/init.json +57 -0
- package/schemas/lock.json +21 -0
- package/schemas/plan.json +4 -0
- package/skills/vx/SKILL.md +13 -3
- package/src/cache/cache.ts +556 -122
- package/src/cache/chained-cache.ts +9 -0
- package/src/cache/inputs.ts +71 -30
- package/src/cache/layer.ts +8 -4
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/schema.ts +42 -16
- package/src/cli/docs-corpus.ts +21 -0
- package/src/cli/docs-text.d.ts +5 -0
- package/src/cli/docs.ts +173 -0
- package/src/cli/help.ts +14 -3
- package/src/cli/index.ts +2 -0
- package/src/cli/init.ts +44 -8
- package/src/cli/lock.ts +43 -15
- package/src/cli/plan-format.ts +3 -1
- package/src/cli/run.ts +8 -2
- package/src/cli/upgrade.ts +18 -6
- package/src/config.ts +9 -0
- package/src/graph/priorities.ts +72 -6
- package/src/graph/scheduler.ts +17 -4
- package/src/graph/task-graph.ts +56 -18
- package/src/orchestrator/metrics.ts +11 -4
- package/src/orchestrator/miss-reason.ts +3 -5
- package/src/orchestrator/miss-save.ts +6 -5
- package/src/orchestrator/projects.ts +2 -2
- package/src/orchestrator/remote-prefetch.ts +8 -2
- package/src/orchestrator/run.ts +13 -1
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/util/errors.ts +9 -2
- package/src/util/paths.ts +32 -0
- package/src/util/verbs.ts +1 -0
- package/src/workspace/config-schema.ts +26 -8
- package/src/workspace/lockfile.ts +29 -2
- package/src/workspace/migration.ts +30 -3
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
# Task execution lifecycle
|
|
2
|
+
|
|
3
|
+
This document traces what happens between `vx run build` typed at the
|
|
4
|
+
terminal and a task succeeding or failing. Read it alongside
|
|
5
|
+
[`architecture.md`](./architecture.md) (module map) and
|
|
6
|
+
[`caching.md`](./caching.md) (cache mechanics).
|
|
7
|
+
|
|
8
|
+
## End-to-end timeline
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
┌─ CLI dispatch (src/bin.ts → src/cli/index.ts → src/cli/run.ts)
|
|
12
|
+
│ 1. bin.ts spawns; forwards process.argv to cli.run().
|
|
13
|
+
│ 2. cli/index.ts dispatches by subcommand (run / watch / cache /
|
|
14
|
+
│ lock / init / upgrade / show / info / why / last / docs /
|
|
15
|
+
│ completions / help / version); any other verb is asked of the
|
|
16
|
+
│ workspace's plugins (`commands` seam — `vx mcp` is one).
|
|
17
|
+
│ 3. cli/run.ts:parseRunArgs(argv) → RunArgs (validated; resolves
|
|
18
|
+
│ the 4-axis cache policy from --cache / --no-cache / --force).
|
|
19
|
+
│ 4. cli/run.ts:runCmd resolves the project scope:
|
|
20
|
+
│ - bare positionals respect --all / --filter / --affected / cwd
|
|
21
|
+
│ - anchored positionals (pkg#task) target directly
|
|
22
|
+
│ - no positionals + TTY → interactive picker → pkg#task
|
|
23
|
+
│ 5. The options map to RunOptions and the run executes IN THIS
|
|
24
|
+
│ PROCESS — always. (A whole-run `backend` seam existed until
|
|
25
|
+
│ 2026-08; it moved the scheduler server-side, which is why it
|
|
26
|
+
│ went. Per-task placement is the `executor` capability.)
|
|
27
|
+
│ --dry / --graph short-circuit into planRun instead.
|
|
28
|
+
│
|
|
29
|
+
├─ Prepare (src/orchestrator/prepare.ts:prepareRun — shared with planRun)
|
|
30
|
+
│ 1. findWorkspaceRoot(cwd) — walks up over pnpm-workspace.yaml,
|
|
31
|
+
│ package.json with a `workspaces` field (npm/yarn/bun), or a
|
|
32
|
+
│ bare package.json (single-project mode). The nearest one whose
|
|
33
|
+
│ package globs CLAIM cwd wins, so running from inside a member
|
|
34
|
+
│ resolves the declaring root, not the member; the first
|
|
35
|
+
│ directory holding pnpm-workspace.yaml stops the walk whether
|
|
36
|
+
│ or not its globs claim cwd (pnpm's rule); when nothing claims
|
|
37
|
+
│ cwd the nearest candidate wins. An UNSCOPED run (no explicit
|
|
38
|
+
│ project scope, at least one bare task name) then starts
|
|
39
|
+
│ step 12's git enumeration over the whole tree, overlapping
|
|
40
|
+
│ steps 2–11; a scoped run waits, since its pathspecs depend on
|
|
41
|
+
│ which projects the task graph holds.
|
|
42
|
+
│ 2. loadWorkspace — parses the appropriate manifest. Bun.YAML
|
|
43
|
+
│ for pnpm; the package.json forms read as bytes (readOnce,
|
|
44
|
+
│ shared with step 1) and JSON.parse.
|
|
45
|
+
│ 3. loadWorkspacePlugins — loadWorkspaceConfig reads the optional
|
|
46
|
+
│ vx.workspace.{ts,mts,js,mjs,cts,cjs} at the root (concurrency /
|
|
47
|
+
│ cacheDir / timeout / cacheRetention / affectedBase / cacheScope /
|
|
48
|
+
│ rules / schedule / plugins),
|
|
49
|
+
│ then the plugin `config` stage runs on it.
|
|
50
|
+
│ 4. listProjects — globs every workspace member's package.json,
|
|
51
|
+
│ finds sibling vx.config.* files, detects duplicate package
|
|
52
|
+
│ names (hard error with both paths).
|
|
53
|
+
│ 5. buildPackageGraph — workspace dep edges from package.json.
|
|
54
|
+
│ 6. Local cache open: new Cache(cacheDir, { read, write }, root)
|
|
55
|
+
│ with the policy's local slice — BEFORE the configs load,
|
|
56
|
+
│ because it also holds their cached evaluations. Then
|
|
57
|
+
│ computeWorkspaceFingerprints: one read of every supported
|
|
58
|
+
│ lockfile + pnpm-workspace.yaml + .yarnrc.yml + .npmrc + bunfig.toml at the root
|
|
59
|
+
│ yields `all` (every file; keys the config-evaluation cache),
|
|
60
|
+
│ `unclaimed` (minus the files a `fingerprint` plugin claims —
|
|
61
|
+
│ `@vzn/vx-lockfile` keys those per project; reused for every
|
|
62
|
+
│ task's cache key) and `files` (the bytes, for the mid-run
|
|
63
|
+
│ fingerprint check).
|
|
64
|
+
│ 7. SCOPED config loading — only in-scope projects plus their
|
|
65
|
+
│ transitive dependency closure evaluate ('^task' frontier
|
|
66
|
+
│ expansion never escapes the closure); one staged load,
|
|
67
|
+
│ loadProjects, shared with every reading verb. Under --frozen,
|
|
68
|
+
│ configs load from vx-lock.json — no evaluation, no staleness
|
|
69
|
+
│ check of its own (vx lock --check is the audit), frozen-env
|
|
70
|
+
│ semantics; a missing lock or entry is a hard UserError.
|
|
71
|
+
│ Otherwise loadProjectConfigs in one batch: native Bun await
|
|
72
|
+
│ import() with a content-hash query-string bust, cached
|
|
73
|
+
│ evaluations served for pure configs; the loader validates
|
|
74
|
+
│ each TaskConfig shape, and the plugin `project` stage runs
|
|
75
|
+
│ on each config with a re-validation after every plugin.
|
|
76
|
+
│ 8. computeNestedProjectDirs — set of projects rooted inside each
|
|
77
|
+
│ project, computed over EVERY config-bearing project (loaded
|
|
78
|
+
│ or not) for boundary enforcement.
|
|
79
|
+
│ 9. expandRequested (see Task selection below).
|
|
80
|
+
│ 10. Cache layer: an injected RunOptions.remoteCache is composed
|
|
81
|
+
│ with the local cache into a LayeredCache (it wins); else
|
|
82
|
+
│ resolveCache lets a plugin's `cache` capability wrap or
|
|
83
|
+
│ replace it; else bare local.
|
|
84
|
+
│ 11. buildTaskGraph (see below).
|
|
85
|
+
│ 12. Bulk git populate — the enumeration step 1 started is
|
|
86
|
+
│ awaited, or a scoped run starts it here over the projects
|
|
87
|
+
│ that own a task (not the dependency closure step 7 loaded,
|
|
88
|
+
│ which a `lint` of one package does not key). THREE spawns at
|
|
89
|
+
│ the root, concurrent, and the repository's facts asked while
|
|
90
|
+
│ they run (`ls-files -s -v -z` for the index: every tracked
|
|
91
|
+
│ path's OID and its cache-state flag;
|
|
92
|
+
│ `status --porcelain -z -uall` for
|
|
93
|
+
│ the dirty AND untracked sets, the one worktree walk; `var -l`
|
|
94
|
+
│ for core.autocrlf and the attributes files git reads outside
|
|
95
|
+
│ the tree; the facts — prefix, common dir, object format, index
|
|
96
|
+
│ file — read off a plain `.git` directory, else one
|
|
97
|
+
│ `rev-parse --show-prefix --git-common-dir --show-object-format
|
|
98
|
+
│ --git-path index`,
|
|
99
|
+
│ memoized per process and shared with the file hasher)
|
|
100
|
+
│ fill the per-project GitFilesCache with file lists + index
|
|
101
|
+
│ OIDs. `ls-files --others` is NOT among them — status's
|
|
102
|
+
│ `-uall` already answers untracked, and asking git twice
|
|
103
|
+
│ walked the same tree again. A fifth, `check-attr`, runs only
|
|
104
|
+
│ when an attributes file could rewrite bytes. An index the
|
|
105
|
+
│ cache holds no blob-size verdict for (keyed by the index
|
|
106
|
+
│ file's hash, A-60) adds `ls-files --debug` for the recorded
|
|
107
|
+
│ sizes and `cat-file --batch-check` for the blob sizes
|
|
108
|
+
│ `blob_sizes` lacks: an OID whose blob is not the recorded size
|
|
109
|
+
│ is not trusted. The run's HashCache is created
|
|
110
|
+
│ after it.
|
|
111
|
+
├─ Task selection (graph/task-graph.ts:expandRequested)
|
|
112
|
+
│ Bare task names fan out across the resolved candidate projects
|
|
113
|
+
│ (every project that declares the task). Anchored entries
|
|
114
|
+
│ (`pkg#task`) resolve directly. Duplicates are deduped.
|
|
115
|
+
│ Empty result → run returns `{ ok: false, outcomes: [] }` —
|
|
116
|
+
│ no task is treated as a CI footgun. The exception: a diff-scoped
|
|
117
|
+
│ run (--affected) whose task names are all declared by projects
|
|
118
|
+
│ outside the selection is `none-affected` and returns
|
|
119
|
+
│ `{ ok: true, outcomes: [] }`.
|
|
120
|
+
│
|
|
121
|
+
├─ Task graph (src/graph/task-graph.ts:buildTaskGraph)
|
|
122
|
+
│ Starting from the resolved {project, task}[] pairs, walk
|
|
123
|
+
│ dependsOn:
|
|
124
|
+
│ - 'name' → same-project task
|
|
125
|
+
│ - '^name' → task in the nearest deps declaring it
|
|
126
|
+
│ (frontier walk; non-holders passed through)
|
|
127
|
+
│ - 'pkg#name' → specific package's task
|
|
128
|
+
│ Detect cycles — throws with the path. After the graph and key
|
|
129
|
+
│ stages, excluded edges (per --exclude-dependencies[=names]) leave
|
|
130
|
+
│ the SCHEDULE; each dropped task is still keyed and folded into
|
|
131
|
+
│ its dependant (orchestrator/excluded-keys.ts).
|
|
132
|
+
│ Each node carries: id (`${project}#${task}`), projectName,
|
|
133
|
+
│ projectDir, taskName, config, sorted deps, `requested: boolean`.
|
|
134
|
+
│ markSurfacedDeps then flags the display-only `surfaced` tasks a
|
|
135
|
+
│ requested GROUP stands for (transparent folders).
|
|
136
|
+
│
|
|
137
|
+
├─ Plugins + telemetry (src/orchestrator/run.ts)
|
|
138
|
+
│ 1. installPlugins — always called; each plugin's optional
|
|
139
|
+
│ setup() hook runs; a throw aborts the run with a clean
|
|
140
|
+
│ UserError naming it. With no plugins it runs nothing.
|
|
141
|
+
│ 2. Run context capture — always, for the `invocations` row:
|
|
142
|
+
│ commit + branch read from `.git` directly, `git rev-parse`
|
|
143
|
+
│ only when that reader does not understand the layout; dirty
|
|
144
|
+
│ reuses the GitFilesCache's status; CI-env detection,
|
|
145
|
+
│ host/os/arch. Never fails a run.
|
|
146
|
+
│ 3. Telemetry — only when a plugin has a `telemetry` hook or
|
|
147
|
+
│ RunOptions.telemetrySinks is passed: the run-context record
|
|
148
|
+
│ (adding captureWorkspaceIdentity) is built and
|
|
149
|
+
│ subscribeTelemetry collects the sinks; with ZERO sinks it
|
|
150
|
+
│ returns undefined and NOTHING subscribes (the no-telemetry
|
|
151
|
+
│ hot path is byte-identical).
|
|
152
|
+
│
|
|
153
|
+
├─ Run-level state
|
|
154
|
+
│ • runId — UUIDv7 stamped once per `vx run` invocation; every
|
|
155
|
+
│ task in the resulting graph carries it.
|
|
156
|
+
│ • runStartHrTimeNs — hrtime.bigint() anchor; per-task wallclock
|
|
157
|
+
│ spans are stored relative to it.
|
|
158
|
+
│ • persistentRegistry — Map<taskId, Subprocess> of long-running
|
|
159
|
+
│ children.
|
|
160
|
+
│ • liveChildren — Set<Subprocess> of in-flight children; the
|
|
161
|
+
│ runner adds/removes each around its spawn.
|
|
162
|
+
│ • SIGINT/SIGTERM/SIGHUP handlers (removed in a finally): on
|
|
163
|
+
│ signal, forward it (SIGHUP as SIGTERM) to everything
|
|
164
|
+
│ in liveChildren + persistentRegistry (and kill any
|
|
165
|
+
│ running cache.inputs.runtime probe, C-65), wait
|
|
166
|
+
│ VX_KILL_GRACE_MS (2 s) for their groups, SIGKILL
|
|
167
|
+
│ what is still there, let the run finish its own
|
|
168
|
+
│ end (flush, teardown, cache close), then
|
|
169
|
+
│ die of the signal (SIGINT → 130, SIGTERM → 143,
|
|
170
|
+
│ SIGHUP → 129), so a shell script running vx stops
|
|
171
|
+
│ too; a run that handed a task the terminal exits
|
|
172
|
+
│ with that code instead, so Bun restores it. SIGHUP is registered because a task
|
|
173
|
+
│ runs in its own session, so a closing terminal
|
|
174
|
+
│ reaches vx and nothing else. A second signal
|
|
175
|
+
│ SIGKILLs and exits at once.
|
|
176
|
+
│
|
|
177
|
+
├─ Cache acceleration (before scheduling)
|
|
178
|
+
│ • REMOTE PREFETCH (a cache layer with a remote only) — derive every
|
|
179
|
+
│ stable-key cacheable task's key up front (reusing the run's
|
|
180
|
+
│ hashCache memo) and fire the remote GETs in the background
|
|
181
|
+
│ under a bounded pool. Not awaited before scheduling (the
|
|
182
|
+
│ overlap is the point); drained before cache.close(). At most
|
|
183
|
+
│ one remote GET per key (shared in-flight map with the lazy
|
|
184
|
+
│ read-through).
|
|
185
|
+
│ • LOCAL SHORT-CIRCUIT (no remote layer, local reads on, ≥1
|
|
186
|
+
│ task) — derive the same stable keys and probe them in ONE
|
|
187
|
+
│ batched cache.getMany (a layer without getMany: cache.get once
|
|
188
|
+
│ per task under a bounded pool) → a `preProbed` map (hits AND
|
|
189
|
+
│ stable misses; execute reuses these probes) + a `restoreTier`
|
|
190
|
+
│ set (confirmed hits). Awaited before scheduling; never throws
|
|
191
|
+
│ (degrades to the normal schedule).
|
|
192
|
+
│
|
|
193
|
+
├─ Scheduling (src/graph/scheduler.ts:runGraph — two-tier)
|
|
194
|
+
│ Two ready queues, each on its own lane:
|
|
195
|
+
│ - EXEC tier — dep-gated; ready when every dep completed; up
|
|
196
|
+
│ to N (concurrency) at once.
|
|
197
|
+
│ Priority: transitive-reverse-dependent count (bitset
|
|
198
|
+
│ closure), optionally overridden by a `priorities` map.
|
|
199
|
+
│ - RESTORE tier — confirmed stable local hits; ready
|
|
200
|
+
│ IMMEDIATELY (no dep gate, no failed-dep→skip check — their
|
|
201
|
+
│ key is dep-independent) at LOW priority, on a restore lane
|
|
202
|
+
│ of 2×N. The drain rule: exec-tier first; a restore never
|
|
203
|
+
│ takes an exec slot and the two lanes never wait on each
|
|
204
|
+
│ other — except at N = 1, where they share the one slot.
|
|
205
|
+
│ On failure: exec-tier dependents are marked `skipped` (exit 1,
|
|
206
|
+
│ durationMs 0, no spawn); independent siblings keep running.
|
|
207
|
+
│ The scheduler doesn't know about caching; the execute callback
|
|
208
|
+
│ is the seam. (An embedder running concurrent runs in one process
|
|
209
|
+
│ may pass a shared `inflight` map; admission.ts then dedupes
|
|
210
|
+
│ identical-key tasks across them. A plain `vx run` passes none.)
|
|
211
|
+
│
|
|
212
|
+
├─ Per-task execution (src/orchestrator/execute-task.ts:executeTask)
|
|
213
|
+
│ Each task takes one of three paths:
|
|
214
|
+
│
|
|
215
|
+
│ A. GROUP — no `exec`.
|
|
216
|
+
│ Return success with a derived hash rolled up from upstream
|
|
217
|
+
│ outcomes. No spawn, no I/O. Wallclock = 0.
|
|
218
|
+
│
|
|
219
|
+
│ B. PERSISTENT — `exec.persistent` set.
|
|
220
|
+
│ 1. Build isolated env (essentials + passThrough + define +
|
|
221
|
+
│ <projectDir>/node_modules/.bin, then
|
|
222
|
+
│ <workspaceRoot>/node_modules/.bin, prepended to PATH).
|
|
223
|
+
│ 2. runPersistent — Bun.spawn the command; subscribe to
|
|
224
|
+
│ stdout/stderr chunk-by-chunk.
|
|
225
|
+
│ 3. Resolve `ready` when:
|
|
226
|
+
│ - no readyWhen → immediately on spawn
|
|
227
|
+
│ - readyWhen matches the output (complete lines OR the
|
|
228
|
+
│ trailing partial line) → on that match
|
|
229
|
+
│ - child exits before the match → reject with
|
|
230
|
+
│ PersistentReadyError('persistent task exited before
|
|
231
|
+
│ becoming ready (exit N)'); its output already streamed
|
|
232
|
+
│ through the logger
|
|
233
|
+
│ - readyWhen set and the timeout (exec.timeout, else the
|
|
234
|
+
│ run default) elapses first → fail, SIGTERM, SIGKILL
|
|
235
|
+
│ after the grace
|
|
236
|
+
│ 4. On ready: stash child in persistentRegistry; return
|
|
237
|
+
│ success. Downstream tasks unblock.
|
|
238
|
+
│ Note: cache + persistent is a config error (rejected by the
|
|
239
|
+
│ project loader). Persistent tasks never write to cache.
|
|
240
|
+
│
|
|
241
|
+
│ C. NORMAL — `exec.command` only.
|
|
242
|
+
│ 1. resolveInputs(files, env, runtime)
|
|
243
|
+
│ - glob inputs.files (git-backed, declared-outputs-
|
|
244
|
+
│ excluded, nested-projects-excluded)
|
|
245
|
+
│ - glob inputs.workspaceFiles from the WORKSPACE ROOT
|
|
246
|
+
│ (git-aware, NO boundary rule — the documented escape
|
|
247
|
+
│ hatch); resolved paths join the same input list
|
|
248
|
+
│ - read host process.env values for inputs.env names
|
|
249
|
+
│ - resolve inputs.runtime / workspaceRuntime command
|
|
250
|
+
│ output (deduped per run; a non-zero exit throws a
|
|
251
|
+
│ UserError that fails this task — its dependents skip,
|
|
252
|
+
│ independent tasks continue)
|
|
253
|
+
│ 2. hashTaskConfig + project package.json hash (both memoized
|
|
254
|
+
│ per run via HashCache)
|
|
255
|
+
│ 3. filterUpstreamHashes (apply cache.inputs.tasks filter)
|
|
256
|
+
│ 4. cache.key({ taskId, workspaceFingerprint,
|
|
257
|
+
│ projectPackageJsonHash, taskConfigHash,
|
|
258
|
+
│ forwardArgs, envValues, runtimeValues,
|
|
259
|
+
│ workspaceRuntimeValues, upstreamHashes,
|
|
260
|
+
│ pluginParts, inputFiles }) → 16-hex xxh3
|
|
261
|
+
│ (pluginParts is whatever the `key` stage contributed,
|
|
262
|
+
│ folded between the upstream keys and the input files —
|
|
263
|
+
│ see caching.md § Cache key derivation for the order.)
|
|
264
|
+
│ (Skipped when the local short-circuit pre-derived it.)
|
|
265
|
+
│ 5. If willRead (cache block + a read axis on):
|
|
266
|
+
│ consume the up-front probe when present, else
|
|
267
|
+
│ cache.get(hash)
|
|
268
|
+
│ · hit → up-to-date short-circuit when the on-disk tree
|
|
269
|
+
│ already matches; else cleanOutputs →
|
|
270
|
+
│ restoreOutputs → replay stored stdout →
|
|
271
|
+
│ return cache-hit / cache-hit-remote
|
|
272
|
+
│ · miss → fall through; cleanOutputs first (when
|
|
273
|
+
│ willWrite) so a stale prior build can't
|
|
274
|
+
│ survive the fresh exec
|
|
275
|
+
│ 6. Build isolated env. For a cacheable task,
|
|
276
|
+
│ describeTaskInputs({ captureInto }) then describes the input
|
|
277
|
+
│ set ONCE, before the spawn: the executor gets the input
|
|
278
|
+
│ values and the save reuses the captured per-component input
|
|
279
|
+
│ fingerprint (a re-fold over the memos, no I/O).
|
|
280
|
+
│ 7. runCommand (or runSandboxed when `sandbox` is declared) →
|
|
281
|
+
│ Bun.spawn shell with `command` + forwardArgs
|
|
282
|
+
│ (shell-quoted). Buffer chunks via onStdout/onStderr.
|
|
283
|
+
│ exec.timeout SIGTERMs an overrun → real `failed`, never
|
|
284
|
+
│ cached. An attempt that ends while the run is STOPPING
|
|
285
|
+
│ (Ctrl-C teardown) classifies as `aborted` instead, whatever
|
|
286
|
+
│ its exit — not counted, not recorded, not retried.
|
|
287
|
+
│ 8. On exit 0 + willSave (willWrite, no tainted upstream):
|
|
288
|
+
│ keyStillTrue() — if the workspace fingerprint or an
|
|
289
|
+
│ input moved since the key was taken, the result stands
|
|
290
|
+
│ but the save is withheld (said on the status line).
|
|
291
|
+
│ saveMiss — in the slot: resolveOutputs(outputs) +
|
|
292
|
+
│ resolveWorkspaceOutputs → file lists, and the
|
|
293
|
+
│ project's git snapshot notes the written output paths
|
|
294
|
+
│ so a same-project downstream task doesn't re-spawn git
|
|
295
|
+
│ unless its globs overlap. Deferred onto the run's save
|
|
296
|
+
│ lane (2×concurrency wide): cache.save(...) — pack
|
|
297
|
+
│ <hash>.tar.zst with stdout + outputs/<rel>
|
|
298
|
+
│ (+ workspace-outputs/<rel-to-root>), upsert entries +
|
|
299
|
+
│ output_files + entry_inputs rows in one transaction.
|
|
300
|
+
│ The slot frees at once; dependents wait on the save's
|
|
301
|
+
│ `landed` promise (the scheduler's settledOf). Under a
|
|
302
|
+
│ LayeredCache with remote writes on, the remote upload
|
|
303
|
+
│ fires in the BACKGROUND (drained at end of run).
|
|
304
|
+
│ 9. Return TaskOutcome { node, status, exitCode, durationMs,
|
|
305
|
+
│ hash, cpuMs?, peakRssBytes?, restored?,
|
|
306
|
+
│ wallclockStartNs, wallclockEndNs }.
|
|
307
|
+
│
|
|
308
|
+
└─ End-of-run
|
|
309
|
+
1. SIGTERM dependency-only persistent children; persistent tasks
|
|
310
|
+
the user REQUESTED, those a requested group stands for (through
|
|
311
|
+
nested groups, not through a one-shot), and the persistent tasks
|
|
312
|
+
they depend on, are kept alive (see below).
|
|
313
|
+
2. Run summary footer (projects / tasks / cache meters + info +
|
|
314
|
+
time + result) — counts only real tasks (with `exec`); group tasks
|
|
315
|
+
don't pollute the totals; failure frames replay just above it.
|
|
316
|
+
3. Optional --summarize JSON (default <cacheDir>/runs/<run_id>.json)
|
|
317
|
+
and --profile Chrome-trace JSON (default profile.json).
|
|
318
|
+
4. recordRunBundle — one transaction writing a `runs` row per real
|
|
319
|
+
task plus the `invocations` header row (command, git/CI/host
|
|
320
|
+
context, tags, counts). Group + aborted tasks skipped; a run
|
|
321
|
+
a signal stopped records nothing.
|
|
322
|
+
5. Telemetry: emit the RunSummaryRecord to every active sink +
|
|
323
|
+
await their flush (crash-isolated; skipped when no sink).
|
|
324
|
+
6. Drain background remote prefetches/uploads; cache.close();
|
|
325
|
+
sandbox teardown when any task was sandboxed.
|
|
326
|
+
7. Return { ok, outcomes }; ok = every task ended success or a
|
|
327
|
+
cache hit (any failed/skipped/aborted → ok = false → exit 1),
|
|
328
|
+
and no persistent child exited on its own before the run
|
|
329
|
+
stopped it.
|
|
330
|
+
8. FOREGROUND ONLY: if the user requested persistent tasks (dev
|
|
331
|
+
servers) and nothing else failed (or under `--continue=always`),
|
|
332
|
+
the process now blocks until ONE of them, or a persistent task they
|
|
333
|
+
depend on, exits — the
|
|
334
|
+
summary is already printed, `▸ <id> running` rows list what's
|
|
335
|
+
alive, Ctrl-C reaps the process group. That first exit ends the
|
|
336
|
+
session: the others are torn down (SIGTERM, grace, SIGKILL) and
|
|
337
|
+
a non-zero exit makes the run exit 1, so a script's `vx run dev`
|
|
338
|
+
fails when the server it started fell over. That server then reads
|
|
339
|
+
`failed` with its own exit in the rewritten `--summarize` and in the
|
|
340
|
+
report `--report-file` writes; one a Ctrl-C stopped does not. The
|
|
341
|
+
stdout `--report` prints above the footer, so it reads as the
|
|
342
|
+
footer does, before the wait. Under
|
|
343
|
+
`holdPersistent` (the watch loop) run() instead returns them on
|
|
344
|
+
`RunSummary.persistent`, still running, for the caller to stop;
|
|
345
|
+
one that dies on its own after that is said, its `stop()` is not.
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## One command per task
|
|
349
|
+
|
|
350
|
+
`exec.command` is a single shell command — there's no multi-step
|
|
351
|
+
sequence. Three ways to chain:
|
|
352
|
+
|
|
353
|
+
- **Shell composition** — `&&`, `;`, pipes. The shell is the API.
|
|
354
|
+
```ts
|
|
355
|
+
exec: {
|
|
356
|
+
command: 'gen && tsc && cp -r assets dist/'
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
- **Separate tasks** linked by `dependsOn`:
|
|
360
|
+
```ts
|
|
361
|
+
codegen: { exec: { command: 'gen' }, ... },
|
|
362
|
+
build: { exec: { command: 'tsc' }, dependsOn: ['codegen'], ... },
|
|
363
|
+
```
|
|
364
|
+
- **Group task** that fans out:
|
|
365
|
+
```ts
|
|
366
|
+
release: {
|
|
367
|
+
dependsOn: ['build', 'test', 'package']
|
|
368
|
+
}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Per-task caching is the right granularity for invalidation. Splitting
|
|
372
|
+
gives you per-step caching naturally; combining with `&&` gives you
|
|
373
|
+
one cache slot for the whole chain.
|
|
374
|
+
|
|
375
|
+
A task is over when its command's own process exits. What a descendant
|
|
376
|
+
writes after that — one the command detached with `setsid … &`, or
|
|
377
|
+
daemonized — is not the task's output: the save has already run, and a
|
|
378
|
+
later hit's restore removes it ([caching](./caching.md#cache-write)).
|
|
379
|
+
`wait` for what the command backgrounds.
|
|
380
|
+
|
|
381
|
+
## Env isolation
|
|
382
|
+
|
|
383
|
+
The child process gets, in priority order (lowest first):
|
|
384
|
+
|
|
385
|
+
1. **Essential allowlist** (`PATH`, `HOME`, `SHELL`, `USER`, `LOGNAME`,
|
|
386
|
+
`TMPDIR`, `TEMP`, `TMP`, `LANG`, `LC_ALL`, `LC_CTYPE`, `TERM`,
|
|
387
|
+
`COLORTERM`, `FORCE_COLOR`, `NO_COLOR`, `CI`, `NODE_OPTIONS`,
|
|
388
|
+
`COREPACK_HOME`, `PNPM_HOME` — the list is
|
|
389
|
+
`ESSENTIAL_ENV` in `src/exec/env.ts`).
|
|
390
|
+
2. **`exec.env.passThrough`** names → values from host `process.env`.
|
|
391
|
+
3. **`exec.env.define`** literal name/value pairs.
|
|
392
|
+
4. **PATH augmentation** — `<projectDir>/node_modules/.bin`, then
|
|
393
|
+
`<workspaceRoot>/node_modules/.bin`, are prepended so installed
|
|
394
|
+
tools (`oxlint`, `vite`, etc.) work without `npx`. Never a sibling
|
|
395
|
+
project's bin; those stay invisible. A bin directory whose path holds
|
|
396
|
+
PATH's delimiter (`:`) is left out: PATH cannot name it, and split it
|
|
397
|
+
became two entries, one relative to the task's cwd. A task whose shell exits 127 or 126 gets one more frame line
|
|
398
|
+
(`orchestrator/shell-verdict.ts`): for a bare word, that 127 is the
|
|
399
|
+
shell's "command not found", the word (when the command is a plain
|
|
400
|
+
`word args…`), the two bin directories vx puts first, and that a
|
|
401
|
+
sibling project's bin is never visible (or, when a bin directory was
|
|
402
|
+
left out for its `:`, that directory and the fix: move the
|
|
403
|
+
workspace), and on 126 `chmod +x`; for
|
|
404
|
+
a word with a slash, what the file says — missing (the resolved
|
|
405
|
+
path), a directory, not executable by this user, a `#!` interpreter that does
|
|
406
|
+
not exist (a CRLF line ending is named as such), or no `#!` line.
|
|
407
|
+
An exit above 128 gets the same line for its signal: which one, and
|
|
408
|
+
what sends it (the OOM killer, a crash in native code, an abort, a
|
|
409
|
+
reader that left a pipe, a ulimit, a seccomp refusal); vx's own
|
|
410
|
+
timeout and a shutdown's SIGTERM keep their own lines, and a task the
|
|
411
|
+
run's stop killed (the SIGKILL past the grace too) gets none.
|
|
412
|
+
|
|
413
|
+
Anything not in these four layers is invisible to the child, except
|
|
414
|
+
the two vx sets itself — `VX_RUN_WORKSPACE` and `VX_RUN_TASK` — so a
|
|
415
|
+
task that shells out to `vx run` in its own workspace is refused
|
|
416
|
+
before it forks without bound, and `npm_execpath`, the workspace's
|
|
417
|
+
package manager (unkeyed; `schema.md` § `env`). A sandboxed task also gets the sandbox's own
|
|
418
|
+
proxy, CA and `TMPDIR` values
|
|
419
|
+
([`modules/sandbox-runtime.md`](./modules/sandbox-runtime.md#the-environment-srt-sets)). This prevents incidental env leakage
|
|
420
|
+
between machines and gives reproducible runs.
|
|
421
|
+
|
|
422
|
+
The allowlist + isolation contract lives in
|
|
423
|
+
[`modules/env.md`](./modules/env.md) and is the only field the
|
|
424
|
+
contract assumes for "what every command needs to function" on
|
|
425
|
+
Linux and macOS (Windows is WSL, which is Linux). Adding to the allowlist would be a deliberate
|
|
426
|
+
schema-extending change (consumer code expects a particular set;
|
|
427
|
+
broader access has cache-stability implications).
|
|
428
|
+
|
|
429
|
+
## Failure handling
|
|
430
|
+
|
|
431
|
+
| Failure | Behavior |
|
|
432
|
+
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
433
|
+
| Exec exits non-zero | Task is `failed`; cache NOT written; output streamed live + the failure frame replays at run end |
|
|
434
|
+
| `exec.timeout` overrun | SIGTERM to the task's group, SIGKILL for whoever is left after the grace; task is `failed` (timed out), exit 143 (137 when the SIGKILL took it, and the line says so), never cached |
|
|
435
|
+
| Child killed by Ctrl-C teardown (SIGINT/SIGTERM/SIGHUP), or by an embedder's `RunOptions.signal` abort — which also completes every never-started task `aborted` | Task is `aborted` — not counted, not recorded. An attempt that ends while the run is stopping is `aborted` whatever its exit: a trap that exits 0 is not cached, and a failure is not retried (item 962). What it printed still shows in its frame. A child killed by a signal while the run is NOT stopping (a supervisor's SIGTERM, a `kill` from another shell) is `failed (exit 143, 128 + SIGTERM)`: retried, shown, and fail-fast trips (item 1100) |
|
|
436
|
+
| `execute()` throws (internal error) | Task marked `failed`; stderr written `[vx] internal error in <id>` (a `UserError` reports plainly); a plugin executor's throw reports plainly as `plugin '<p>' (executor '<e>') failed in execute: <reason>` (C-63, C-85) |
|
|
437
|
+
| Persistent task exits before ready | Task marked `failed` with `exited before becoming ready (exit N)`; its output already streamed live; `aborted` when the run's stop killed it (C-62) |
|
|
438
|
+
| Upstream task fails | Under the default `--continue=deps-ok`, dependents marked `skipped` (exit 1, durationMs 0); no command runs — EXCEPT a restore-tier task, whose confirmed cache hit still restores (its key is dep-independent). `--continue=never` stops dispatch; `--continue=always` runs dependents, never saved |
|
|
439
|
+
| Sandbox violation (macOS monitor / Linux structural) | Task is `failed`; violations render in the frame; nothing cached |
|
|
440
|
+
| Remote-cache error (500, timeout, corrupt artifact) | Degrades to a cache miss; never fails the run |
|
|
441
|
+
| No pnpm-workspace.yaml or package.json in cwd or any parent | `findWorkspaceRoot` throws (UserError); `vx run` exits 1 |
|
|
442
|
+
| Same-project task referenced in `dependsOn` not declared | `buildTaskGraph` throws with the offending edge |
|
|
443
|
+
| Duplicate workspace package name | `listProjects` throws with both paths |
|
|
444
|
+
| Cycle in task graph | `detectCycle` throws with the cycle path |
|
|
445
|
+
| Malformed config | `loadProjectConfigs` throws (UserError) with file + field |
|
|
446
|
+
| `cache.inputs.runtime` command exits non-zero | UserError naming the command + exit code; that task is `failed`, dependents skip |
|
|
447
|
+
|
|
448
|
+
By default (`--continue=deps-ok`) failures don't kill the scheduler —
|
|
449
|
+
independent tasks already in flight finish, and unrelated tasks not yet
|
|
450
|
+
started still run. `--continue=never` stops dispatch at the first
|
|
451
|
+
failure; `--continue=always` runs dependents too
|
|
452
|
+
([`cli.md` § Failure propagation](./cli.md#failure-propagation----continue)). The
|
|
453
|
+
overall exit code is 1 if any task ended in `failed`, `skipped` or
|
|
454
|
+
`aborted` status, or a persistent child exited before the run stopped
|
|
455
|
+
it.
|
|
456
|
+
|
|
457
|
+
## Output capture and rendering
|
|
458
|
+
|
|
459
|
+
The orchestrator emits `RunEvent`s through an in-process bus; the
|
|
460
|
+
terminal renderer is the always-on subscriber (plugins, telemetry, and
|
|
461
|
+
wire forwarders attach beside it). What renders:
|
|
462
|
+
|
|
463
|
+
- **Flow-aware policy.** What gets rendered depends on the run's
|
|
464
|
+
intent: FOCUSED (no selection flag) streams the requested task's
|
|
465
|
+
output raw and live and silences successful dependencies; BROAD
|
|
466
|
+
(`--all` / `--filter` / `--affected`) prints news only — one grid
|
|
467
|
+
one-liner per executed task, failure frames replayed at run end,
|
|
468
|
+
silence for cache hits; truthy `CI` env (and the programmatic
|
|
469
|
+
default) keeps full grouped output. `--output-logs` overrides
|
|
470
|
+
everything. Full table in [`cli.md`](./cli.md#output).
|
|
471
|
+
- **The glyph grid.** Reported task lines share one column grid —
|
|
472
|
+
`<glyph> <time> <status> <cache> <name>`. Glyph SHAPE = cache axis
|
|
473
|
+
(`⏺` miss / `►` fresh / `⇢` local / `⇣` remote / `◼` failed / `⊘`
|
|
474
|
+
skipped, plus `▸` for a pinned persistent row; a live worker row has
|
|
475
|
+
no glyph — its ticking elapsed time leads instead); glyph COLOR + the
|
|
476
|
+
status word = task axis
|
|
477
|
+
(success / failed / skipped / running); the cache word (miss /
|
|
478
|
+
fresh / local / remote) spells it out.
|
|
479
|
+
- **Buffered, framed (non-focused paths).** `runCommand` listens to
|
|
480
|
+
the child's stdout/stderr and calls `onStdout` / `onStderr` per
|
|
481
|
+
chunk. Outside focused streaming, the default logger buffers the
|
|
482
|
+
chunks per-task and dumps the full body as a framed block on task
|
|
483
|
+
completion. No per-line prefix, no interleaving between concurrent
|
|
484
|
+
tasks.
|
|
485
|
+
- **Cache write.** The captured stdout — its first and last 8 MiB,
|
|
486
|
+
the cut middle named — is stored in the entry; replay is stdout-only.
|
|
487
|
+
- **Cache hit replay.** The stored stdout is fed through the same
|
|
488
|
+
logger path, so it renders per the active flow (streamed raw for a
|
|
489
|
+
focused requested task, framed in full mode, silent in broad).
|
|
490
|
+
- **Live stream for failures.** Even though cached output is not
|
|
491
|
+
written for failures, the live stream means the user sees the
|
|
492
|
+
failure as it happens; the full frames replay together at run end,
|
|
493
|
+
right above the summary.
|
|
494
|
+
- **Status region.** On TTY stdout outside CI, a multi-row region
|
|
495
|
+
tracks the run live: a blank separator, pinned `▸` persistent rows,
|
|
496
|
+
one row per worker slot (the ticking elapsed time IS the motion —
|
|
497
|
+
no spinner), and the live summary section (the same meters as the
|
|
498
|
+
final footer). Redrawn in place; forced redraws coalesce under a
|
|
499
|
+
30 ms floor; erased before the summary prints. All default-logger
|
|
500
|
+
writes serialize through one writer so content and the region never
|
|
501
|
+
interleave.
|
|
502
|
+
- **GitHub Actions.** With `GITHUB_ACTIONS` truthy in full mode, task
|
|
503
|
+
blocks collapse under `::group::` commands; failures stay open and
|
|
504
|
+
emit `::error` annotations.
|
|
505
|
+
|
|
506
|
+
A task's stdout and stderr are pipes, so `echo x > /dev/stdout` and
|
|
507
|
+
`cmd | tee /dev/stderr` work as in a terminal or under Turbo. Bun's own
|
|
508
|
+
`'pipe'` is a socketpair, which Linux will not open by path, and they
|
|
509
|
+
failed with "No such device or address" until X-113
|
|
510
|
+
(`tests/runner.test.ts` › "a task's stdout and stderr are pipes it can
|
|
511
|
+
open by path"; sandboxed in `tests/sandbox-runtime.unsafe.test.ts`).
|
|
512
|
+
|
|
513
|
+
There is no special handling for binary output. Stdin is never the
|
|
514
|
+
terminal unless the task declares `exec.interactive` and vx's stdin is
|
|
515
|
+
a TTY (schema.md § `interactive`): then the task gets vx's stdin,
|
|
516
|
+
stdout and stderr, runs alone, and nothing of it passes through vx.
|
|
517
|
+
Otherwise the rule splits on `exec.persistent`:
|
|
518
|
+
|
|
519
|
+
- **A one-shot task** gets `'ignore'`: it sees EOF at once, so a task
|
|
520
|
+
that reads stdin finishes instead of hanging CI
|
|
521
|
+
(`tests/runner.test.ts` › "a task that reads stdin sees EOF at once,
|
|
522
|
+
never a hang"). A task that needs TTY input declares
|
|
523
|
+
`exec.interactive`.
|
|
524
|
+
- **A persistent task** gets a pipe vx holds and never writes: it stays
|
|
525
|
+
open while vx lives and ends when vx does. A dev server that exits on
|
|
526
|
+
stdin EOF — esbuild `--watch`, the Vite case in turborepo#8915 —
|
|
527
|
+
became ready and exited 0 under `'ignore'`, and `vx run dev` ended
|
|
528
|
+
green (fixed 2026-09-24, `tests/keep-alive.test.ts` › "a server that
|
|
529
|
+
exits on stdin EOF stays up while vx runs"). Each task has its own
|
|
530
|
+
pipe, so several persistent tasks never compete for keystrokes; none
|
|
531
|
+
receives any, so a server's keyboard shortcuts (Vite's `h`, `r`) do
|
|
532
|
+
not reach it unless it declares `exec.interactive`. Not the terminal, because
|
|
533
|
+
several servers would steal each other's input, and a CI's
|
|
534
|
+
`/dev/null` stdin is the same EOF. Turbo's stream mode draws the same
|
|
535
|
+
line: its pin is named `nonpersistent_task_sees_eof_on_stdin`.
|
|
536
|
+
|
|
537
|
+
A one-shot task ends when its shell exits, not when its pipes close. A
|
|
538
|
+
process the task left running (`server & echo up`) keeps stdout and
|
|
539
|
+
stderr open, so vx reads on for 250 ms after the shell exits and then
|
|
540
|
+
stops. That bound is what keeps a leftover server from hanging the run.
|
|
541
|
+
What the leftover writes after it is not captured — not in the frame,
|
|
542
|
+
not in the cache entry — and the frame says so with one line on stderr:
|
|
543
|
+
`[vx] output after the task's shell exited was cut: a process it left
|
|
544
|
+
running still held its stdout/stderr 250 ms later`. Until 2026-09-24 the
|
|
545
|
+
cut was silent (nx#35302 reproduced on vx; `tests/runner.test.ts` ›
|
|
546
|
+
"output a backgrounded child writes after the drain bound is cut, and
|
|
547
|
+
the frame says so"). A task whose output matters waits for what it
|
|
548
|
+
starts (`wait`).
|
|
549
|
+
|
|
550
|
+
Every surface uses one outcome vocabulary: task axis `success` /
|
|
551
|
+
`failed` / `skipped` / `aborted` (+ `running` live), cache axis
|
|
552
|
+
`miss` / `up-to-date` (fresh) / `local` / `remote`. The `--verbosity 1`
|
|
553
|
+
table spells the combinations out as `success` / `restored-local` /
|
|
554
|
+
`restored-remote` / `up-to-date`; `--report` keeps the two axes as its
|
|
555
|
+
Status and Cache columns.
|
|
556
|
+
|
|
557
|
+
The colors / framing modules:
|
|
558
|
+
|
|
559
|
+
- `orchestrator/colors.ts` — ANSI truecolor (`ansi-16m`), gated by
|
|
560
|
+
`NO_COLOR` / `FORCE_COLOR` / `isTTY`. Programmatic-logger callers
|
|
561
|
+
always see plain text.
|
|
562
|
+
- `orchestrator/framed-output.ts` — the `┌─` frames, the glyph grid
|
|
563
|
+
(`formatTaskRow`), one-liners, persistent markers.
|
|
564
|
+
- `orchestrator/status-line.ts` — the serialized writer + the worker
|
|
565
|
+
region.
|
|
566
|
+
- `orchestrator/logger.ts` — composes them; resolves the output view
|
|
567
|
+
and applies the per-flow visibility policy.
|
|
568
|
+
- `orchestrator/summary.ts` — the closing footer (wordmark rule,
|
|
569
|
+
projects/tasks/cache meters, info, time and result rows).
|
|
570
|
+
|
|
571
|
+
## Concurrency
|
|
572
|
+
|
|
573
|
+
- **Default** — the cores this process may use: `navigator.hardwareConcurrency`
|
|
574
|
+
capped by the cgroup CPU quota a container runs under (`cpu.max` on v2,
|
|
575
|
+
`cpu.cfs_quota_us` on v1, an ancestor's quota binding too; a 1.5-core
|
|
576
|
+
quota is two workers, never below one — `util/cgroup.ts`), or
|
|
577
|
+
`vx.workspace.ts`'s `concurrency` field when set. Inside a container the
|
|
578
|
+
raw count is the HOST's, and eight workers on a two-core quota is
|
|
579
|
+
oversubscription by four; `--concurrency <n>%` is a percentage of the
|
|
580
|
+
same capped count.
|
|
581
|
+
- **Override** — `--concurrency N` (CLI). CLI wins over workspace
|
|
582
|
+
config.
|
|
583
|
+
- **`concurrency: 1`** serializes everything while still respecting
|
|
584
|
+
topo order: execs and restores share the one slot.
|
|
585
|
+
- The scheduler never exceeds the cap; tasks queue. Above 1,
|
|
586
|
+
restore-tier tasks run on their own lane, `2 × concurrency` wide,
|
|
587
|
+
and never take an exec slot.
|
|
588
|
+
- Failure of a task doesn't pause the scheduler — independent
|
|
589
|
+
siblings continue running and starting (unless `--continue=never`).
|
|
590
|
+
|
|
591
|
+
### Executor pools
|
|
592
|
+
|
|
593
|
+
`concurrency` counts LOCAL worker slots. An executor that runs its tasks
|
|
594
|
+
somewhere else declares its own `capacity`, and the tasks placed on it are
|
|
595
|
+
admitted against that number instead — so a 64-wide worker pool is not
|
|
596
|
+
throttled by a 10-core laptop, and a `--concurrency 1` run still keeps the
|
|
597
|
+
pool full. A `capacity` that is not a positive integer is refused:
|
|
598
|
+
`plugin '<p>' returned executor '<e>' with capacity <v>: it must be a
|
|
599
|
+
positive integer`.
|
|
600
|
+
|
|
601
|
+
- **Placement is decided once per task, before scheduling.** `run()` asks
|
|
602
|
+
the declared executors, in order, which one takes each task (see
|
|
603
|
+
[`schema.md` § `remote`](./schema.md#remote-optional) for what pins a
|
|
604
|
+
task here); the scheduler then knows which pool every task will occupy.
|
|
605
|
+
- **A pooled task holds nothing on this machine.** The `admit` stage is
|
|
606
|
+
asked only for tasks about to run here, with the tasks running here; a
|
|
607
|
+
task on an executor pool is never asked and never counted, so it can
|
|
608
|
+
never park behind a local reservation.
|
|
609
|
+
- **Restore-tier tasks always use the restore lane**, even when placed
|
|
610
|
+
on an executor with a `capacity`: a restore is local disk work.
|
|
611
|
+
- **No pooled executor declared = the legacy path.** With every task on
|
|
612
|
+
the local pool the admission gate is byte-identical to before pools
|
|
613
|
+
existed.
|
|
614
|
+
|
|
615
|
+
## Cache control flags
|
|
616
|
+
|
|
617
|
+
`--no-cache` turns all four cache axes off: every task runs, nothing
|
|
618
|
+
is read or written, and `cleanOutputs` is skipped — the user is
|
|
619
|
+
debugging and managing the tree themselves; silently wiping `dist/`
|
|
620
|
+
mid-debug would be hostile.
|
|
621
|
+
|
|
622
|
+
`--force` turns only the READ axes off: every task re-executes, but
|
|
623
|
+
fresh artifacts are still written (outputs ARE cleaned so the saved
|
|
624
|
+
snapshot is clean). `--cache=<spec>` gives per-layer control. Full
|
|
625
|
+
semantics: [`cli.md` § Cache control](./cli.md#cache-control---cache---no-cache---force)
|
|
626
|
+
and [`caching.md` § Cache policy](./caching.md#cache-policy-readwrite-axes).
|
|
627
|
+
|
|
628
|
+
## Planning paths (`--dry`, `--graph`)
|
|
629
|
+
|
|
630
|
+
Both short-circuit execution. The planner (`orchestrator/plan.ts`)
|
|
631
|
+
runs the same setup steps — workspace discovery, config load, package
|
|
632
|
+
graph, task graph — and the same per-task hash derivation, then
|
|
633
|
+
probes the cache. The probe is read-only; against a remote layer it
|
|
634
|
+
is an existence check only (no artifact download, no ingest). The one
|
|
635
|
+
deliberate side effect: `cache.inputs.runtime` / `workspaceRuntime`
|
|
636
|
+
probe commands DO run, because predicting a key requires resolving
|
|
637
|
+
them — keep runtime inputs side-effect-free.
|
|
638
|
+
|
|
639
|
+
The two flags differ only in output format:
|
|
640
|
+
|
|
641
|
+
- `--dry[=text|json]` → text (default) or JSON list of predicted
|
|
642
|
+
outcomes per task: `hit-local`, `hit-remote`, `miss`, `no-cache`,
|
|
643
|
+
`group`. Formatter: `cli/plan-format.ts:formatPlanText` /
|
|
644
|
+
`formatPlanJson`. When the workspace declares more than one executor,
|
|
645
|
+
each line also names the executor the task would be PLACED on — the only
|
|
646
|
+
surface that makes `exec.remote` checkable before the run. Resolving the
|
|
647
|
+
executors is a plugin-factory call, the same class the cache capability
|
|
648
|
+
already makes at plan time; a plugin that throws costs the label, never
|
|
649
|
+
the plan.
|
|
650
|
+
- `--graph[=<path>]` → Graphviz DOT, colored by predicted status.
|
|
651
|
+
Pipe to `dot` for SVG/PNG render. Formatter:
|
|
652
|
+
`formatGraphDot`.
|
|
653
|
+
|
|
654
|
+
Mutually exclusive: `--dry` and `--graph` together is a parse error.
|
|
655
|
+
Combining either with `--summarize`, `--profile`, `--report` or
|
|
656
|
+
`--report-file` is a parse error (those need a real run; the last two
|
|
657
|
+
were accepted and wrote nothing until item 992).
|
|
658
|
+
|
|
659
|
+
## Run artifacts
|
|
660
|
+
|
|
661
|
+
- **`--summarize[=<path>]`** — per-run JSON to
|
|
662
|
+
`<cacheDir>/runs/<run_id>.json` by default (or the explicit path).
|
|
663
|
+
Mirrors the `runs` table shape — one task entry per real task, hits
|
|
664
|
+
included, with status, hash, duration, cpu_ms, peak RSS, hrtime spans
|
|
665
|
+
(bigint serialized as strings to preserve ns precision).
|
|
666
|
+
- **`--profile[=<path>]`** — Chrome-trace JSON of every task's
|
|
667
|
+
wallclock span. Default path: `profile.json` (cwd-relative). One
|
|
668
|
+
`tid` per project so concurrent tasks render on distinct lanes.
|
|
669
|
+
Open with `chrome://tracing` or https://ui.perfetto.dev.
|
|
670
|
+
- **`--report[=markdown]`** — a markdown table to stdout
|
|
671
|
+
above the footer (CI step summaries), through `RunOptions.beforeFooter`.
|
|
672
|
+
- **`--report-file <path>`** — the same report APPENDED to `<path>`
|
|
673
|
+
(`$GITHUB_STEP_SUMMARY`).
|
|
674
|
+
|
|
675
|
+
Writers live in `orchestrator/run-artifacts.ts:writeRunSummary` /
|
|
676
|
+
`writeRunProfile` and `orchestrator/run-report.ts`. A `--summarize` or
|
|
677
|
+
`--profile` write error is surfaced via `log.status`, a `--report-file`
|
|
678
|
+
one on stderr (`cli/run.ts`); neither changes the run's exit code — the
|
|
679
|
+
run already happened.
|
|
680
|
+
|
|
681
|
+
## Why each rule
|
|
682
|
+
|
|
683
|
+
- **Cache failures are never cached.** Caching a failure prevents
|
|
684
|
+
retry flows. The next run gets the same failure even after the
|
|
685
|
+
user fixes the cause.
|
|
686
|
+
- **Group tasks are silent in the summary.** Including them in the
|
|
687
|
+
count is confusing — "3 of 4 cached" when one was a group that
|
|
688
|
+
ran nothing isn't informative.
|
|
689
|
+
- **Project scope defaults to cwd.** Most invocations are "build/
|
|
690
|
+
test the thing I'm working on". `--all` / `--filter` exist for
|
|
691
|
+
the workspace-wide case.
|
|
692
|
+
- **The scheduler doesn't bail on first failure by default.** A flaky
|
|
693
|
+
test failing shouldn't stop an unrelated build. Independent siblings
|
|
694
|
+
continue; only dependents are skipped. `--continue=never` opts into
|
|
695
|
+
fail-fast.
|
|
696
|
+
- **Misses own the worker pool.** A miss is the critical path, so
|
|
697
|
+
the exec lane is `concurrency` wide and only executions take it.
|
|
698
|
+
Restores run on their own lane, so warm work never queues behind
|
|
699
|
+
ordering it doesn't need and never takes a slot from real work.
|
|
700
|
+
- **Forwarded args don't reach upstream tasks.** Otherwise
|
|
701
|
+
`vx run build -- --watch` would set `--watch` on every upstream's
|
|
702
|
+
build, and upstream cache keys would partition by CLI args that
|
|
703
|
+
don't change their behavior.
|
|
704
|
+
- **`recordRunBundle` skips group tasks.** They aren't real runs;
|
|
705
|
+
analytics queries that sum duration would double-count without it.
|
|
706
|
+
- **A requested persistent task keeps the run alive.** The dev server
|
|
707
|
+
IS the point of the run; tearing it down the instant it became
|
|
708
|
+
ready would make `vx run dev` useless.
|