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