@vzn/vx 0.0.18 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
package/src/orchestrator/run.ts
CHANGED
|
@@ -3,19 +3,14 @@
|
|
|
3
3
|
// so the layers can be swapped without touching the others.
|
|
4
4
|
|
|
5
5
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
type CachePolicy,
|
|
10
|
-
FULL_CACHE_POLICY,
|
|
11
|
-
type InvocationRecord,
|
|
12
|
-
type RunRecord,
|
|
13
|
-
} from '../cache/index.js'
|
|
6
|
+
import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { type CacheLayer, type CachePolicy, FULL_CACHE_POLICY } from '../cache/index.js'
|
|
14
9
|
import { VERSION } from '../version.js'
|
|
15
|
-
import {
|
|
10
|
+
import { resetSandbox, VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from '../exec/index.js'
|
|
16
11
|
import { DeferredOutputs } from './deferred-outputs.js'
|
|
17
12
|
import { resolveDownloadModes } from './download-policy.js'
|
|
18
|
-
import {
|
|
13
|
+
import type { TaskExecutor } from '../exec/index.js'
|
|
19
14
|
import {
|
|
20
15
|
isGroupTask,
|
|
21
16
|
markSurfacedDeps,
|
|
@@ -23,22 +18,53 @@ import {
|
|
|
23
18
|
type TaskNode,
|
|
24
19
|
type TaskOutcome,
|
|
25
20
|
} from '../graph/index.js'
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
import {
|
|
22
|
+
formatBytes,
|
|
23
|
+
mark,
|
|
24
|
+
killGraceMs,
|
|
25
|
+
MAX_TIMEOUT_MS,
|
|
26
|
+
parseDuration,
|
|
27
|
+
parseSize,
|
|
28
|
+
printTimings,
|
|
29
|
+
ulid,
|
|
30
|
+
nearest,
|
|
31
|
+
UserError,
|
|
32
|
+
machineParallelism,
|
|
33
|
+
teardownTimeoutMs,
|
|
34
|
+
} from '../util/index.js'
|
|
35
|
+
import { keyedProjects } from './keyed-projects.js'
|
|
36
|
+
import { prepareSandbox } from './sandbox-request.js'
|
|
37
|
+
import type { OutputDirSnapshot } from './miss-save.js'
|
|
38
|
+
import { admitTasks, taintTracker } from './admission.js'
|
|
39
|
+
import { excludedTaint } from './excluded-keys.js'
|
|
40
|
+
import { keyUpstream } from './upstream.js'
|
|
41
|
+
import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js'
|
|
31
42
|
import { installPlugins } from './plugin.js'
|
|
32
|
-
import { resolveExecutors, teardownPlugins } from './plugin-host.js'
|
|
43
|
+
import { buildAdmission, resolveExecutors, teardownPlugins } from './plugin-host.js'
|
|
33
44
|
import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
34
|
-
import { assembleRunSummary,
|
|
35
|
-
import type { RunContextRecord
|
|
45
|
+
import { assembleRunSummary, isPassStatus } from './telemetry.js'
|
|
46
|
+
import type { RunContextRecord } from './telemetry.js'
|
|
36
47
|
import { defaultLogger, resolveOutputView, type Logger } from './logger.js'
|
|
37
|
-
import { detectColors } from './colors.js'
|
|
48
|
+
import { detectColors, type ColorSupport } from './colors.js'
|
|
38
49
|
import { formatPersistentList } from './framed-output.js'
|
|
39
50
|
import { LocalHistoryProvider } from './history.js'
|
|
40
51
|
import { plan, type RunPlan } from './plan.js'
|
|
41
|
-
import { prepareRun } from './prepare.js'
|
|
52
|
+
import { prepareRun, type PreparedRun } from './prepare.js'
|
|
53
|
+
import { acquireRunLock } from './run-lock.js'
|
|
54
|
+
import {
|
|
55
|
+
forwardedSignal,
|
|
56
|
+
forwardSignals,
|
|
57
|
+
SIGNAL_SHUTDOWN_GRACE_MS,
|
|
58
|
+
terminateChildren,
|
|
59
|
+
} from './signals.js'
|
|
60
|
+
import {
|
|
61
|
+
hasPooledExecutor,
|
|
62
|
+
placeTasks,
|
|
63
|
+
planExecutorOf,
|
|
64
|
+
poolOfPlacement,
|
|
65
|
+
UNPLACED_EXECUTOR,
|
|
66
|
+
locallyPlaced,
|
|
67
|
+
} from './placement.js'
|
|
42
68
|
import {
|
|
43
69
|
captureDefaultBranch,
|
|
44
70
|
captureGitContext,
|
|
@@ -48,21 +74,24 @@ import {
|
|
|
48
74
|
} from './run-context.js'
|
|
49
75
|
import { startRemotePrefetch } from './remote-prefetch.js'
|
|
50
76
|
import { startLocalShortCircuit, type ShortCircuit } from './local-shortcircuit.js'
|
|
51
|
-
import { formatVerifySection } from './verify.js'
|
|
52
|
-
|
|
53
|
-
const EMPTY_SHORT_CIRCUIT: ShortCircuit = { preProbed: new Map(), restoreTier: new Set() }
|
|
54
77
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
* before force-killing any that trap or ignore it — so a wedged mock server
|
|
58
|
-
* can't hang a normal run at completion. Well-behaved servers exit far under
|
|
59
|
-
* this, so the happy path never waits it out.
|
|
60
|
-
*/
|
|
61
|
-
const PERSISTENT_SHUTDOWN_GRACE_MS = 2000
|
|
78
|
+
import { assembleRunRecords } from './run-records.js'
|
|
79
|
+
import { hasEnded, selectKeepAlive, shutdownPersistent } from './persistent.js'
|
|
62
80
|
import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
|
|
63
|
-
import {
|
|
81
|
+
import { createSaveLane } from './save-lane.js'
|
|
82
|
+
import {
|
|
83
|
+
formatAbortedSection,
|
|
84
|
+
formatFlakySection,
|
|
85
|
+
formatRunSummary,
|
|
86
|
+
formatSkippedSection,
|
|
87
|
+
} from './summary.js'
|
|
88
|
+
import { detectFlaky, type FlakyCandidate } from './failure-mode.js'
|
|
64
89
|
import type { RunOptions, RunSummary } from './options.js'
|
|
65
90
|
|
|
91
|
+
// Per run, never shared: a `vx watch` process runs many, and a shared map
|
|
92
|
+
// is one `preProbed.set` away from leaking a hit across cycles.
|
|
93
|
+
const emptyShortCircuit = (): ShortCircuit => ({ preProbed: new Map(), restoreTier: new Set() })
|
|
94
|
+
|
|
66
95
|
/**
|
|
67
96
|
* Parse the `VX_TASK_TIMEOUT` env var (ms) — the "global" run-level task
|
|
68
97
|
* timeout default. A missing/empty/non-positive-integer value yields
|
|
@@ -159,15 +188,49 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
159
188
|
// additional subscribers. The fan-out is synchronous and order-
|
|
160
189
|
// preserving, so terminal output is byte-identical to a direct call.
|
|
161
190
|
// See docs/design/event-stream-2026-06.md.
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
191
|
+
const terminal =
|
|
192
|
+
options.log === undefined
|
|
193
|
+
? defaultLogger(colors, resolveOutputView(options), process.stdout, { coalesce: true })
|
|
194
|
+
: null
|
|
195
|
+
const sink = options.log ?? terminal!
|
|
196
|
+
// An injected bus already has surfaces subscribed; we add the terminal
|
|
197
|
+
// renderer for this run and take it off again on every way out: the bus
|
|
198
|
+
// outlives the run, and a renderer left behind reported the next run on
|
|
199
|
+
// it twice (item 635). Otherwise a fresh internal bus.
|
|
165
200
|
const bus = options.bus ?? createEventBus()
|
|
166
|
-
bus.subscribe(terminalSubscriber(sink))
|
|
201
|
+
const unsubscribeTerminal = bus.subscribe(terminalSubscriber(sink))
|
|
202
|
+
try {
|
|
203
|
+
return await runOnBus(options, bus, colors, () => terminal?.failureRecap() ?? [])
|
|
204
|
+
} finally {
|
|
205
|
+
unsubscribeTerminal()
|
|
206
|
+
terminal?.settle()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function runOnBus(
|
|
211
|
+
options: RunOptions,
|
|
212
|
+
bus: EventBus,
|
|
213
|
+
colors: ColorSupport,
|
|
214
|
+
failureRecap: () => string[],
|
|
215
|
+
): Promise<RunSummary> {
|
|
167
216
|
const log = busLogger(bus)
|
|
168
217
|
|
|
169
218
|
const prepared = await prepareRun(options, log)
|
|
170
|
-
mark('
|
|
219
|
+
mark('plugin stages')
|
|
220
|
+
// A task whose command re-enters `vx run` in the workspace running it
|
|
221
|
+
// is refused. When the inner run reaches this task again it forks a run
|
|
222
|
+
// per run until the machine gives out; when it does not (`ci` shelling
|
|
223
|
+
// out to `vx run lint`) it is a nested run the outer graph cannot see —
|
|
224
|
+
// its tasks escape the schedule, the concurrency budget and this task's
|
|
225
|
+
// cache key. The markers `taskEnv` sets on every child (exec/env.ts)
|
|
226
|
+
// name the task; the check is on the root so both shapes are caught.
|
|
227
|
+
const outerRoot = process.env[VX_RUN_WORKSPACE_ENV]
|
|
228
|
+
if (outerRoot !== undefined && path.resolve(outerRoot) === path.resolve(prepared.workspaceRoot)) {
|
|
229
|
+
prepared.cache.close()
|
|
230
|
+
throw new UserError(
|
|
231
|
+
`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.`,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
171
234
|
// A requested name that matched no project is a typo (or a stray
|
|
172
235
|
// positional from an `=`-only flag written with a space). Failing the
|
|
173
236
|
// whole run — even when OTHER requested tasks resolved — is the point:
|
|
@@ -176,7 +239,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
176
239
|
// case too; the message is identical, so that branch stays below.
|
|
177
240
|
if (prepared.unresolvedTasks.length > 0) {
|
|
178
241
|
log.status(
|
|
179
|
-
`No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${didYouMean(prepared.unresolvedTasks, prepared.projects)}`,
|
|
242
|
+
`No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${didYouMean(prepared.unresolvedTasks, prepared.projects)}${await initHint(prepared)}`,
|
|
180
243
|
)
|
|
181
244
|
prepared.cache.close()
|
|
182
245
|
return { ok: false, outcomes: [] }
|
|
@@ -188,7 +251,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
188
251
|
// buildTaskGraph semantics but logged just in case.
|
|
189
252
|
const msg =
|
|
190
253
|
prepared.empty === 'no-tasks-declared'
|
|
191
|
-
? `No projects declare task(s): ${options.tasks.join(', ')}
|
|
254
|
+
? `No projects declare task(s): ${options.tasks.join(', ')}.${await initHint(prepared)}`
|
|
192
255
|
: 'No tasks to run.'
|
|
193
256
|
log.status(msg)
|
|
194
257
|
prepared.cache.close()
|
|
@@ -197,8 +260,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
197
260
|
// Install user plugins as additional bus subscribers BEFORE the run
|
|
198
261
|
// starts emitting events. `installPlugins` runs each plugin's optional
|
|
199
262
|
// `setup` hook and fails fast on a throw with a clean UserError naming
|
|
200
|
-
// the plugin. A plugin without `setup`
|
|
201
|
-
// subscribes nothing.
|
|
263
|
+
// the plugin. A plugin without `setup` subscribes nothing.
|
|
202
264
|
let disposePlugins: (() => void) | undefined
|
|
203
265
|
let telemetry: TelemetryHandle | undefined
|
|
204
266
|
try {
|
|
@@ -221,19 +283,16 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
221
283
|
cache,
|
|
222
284
|
nodes,
|
|
223
285
|
workspaceFingerprint,
|
|
286
|
+
fingerprintWatch,
|
|
224
287
|
nestedDirsByProject,
|
|
225
288
|
gitFilesCache,
|
|
226
289
|
hashCache,
|
|
227
290
|
workspaceProjectCount,
|
|
228
291
|
} = prepared
|
|
229
|
-
const concurrency =
|
|
230
|
-
options.concurrency ??
|
|
231
|
-
workspaceConfig?.concurrency ??
|
|
232
|
-
Math.max(1, navigator.hardwareConcurrency)
|
|
292
|
+
const concurrency = options.concurrency ?? workspaceConfig?.concurrency ?? machineParallelism()
|
|
233
293
|
|
|
234
|
-
// Resolved ONCE per run, in declaration order
|
|
235
|
-
//
|
|
236
|
-
// starts.
|
|
294
|
+
// Resolved ONCE per run, in declaration order, the local executor last.
|
|
295
|
+
// A broken factory aborts here, before any task starts.
|
|
237
296
|
let executors: readonly TaskExecutor[]
|
|
238
297
|
try {
|
|
239
298
|
executors = await resolveExecutors(prepared.plugins, {
|
|
@@ -251,13 +310,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
251
310
|
// can admit a remote-pooled task against its pool instead of a local
|
|
252
311
|
// worker slot. Group tasks run nothing; persistent tasks never reach an
|
|
253
312
|
// executor (local by construction) — both stay off the map.
|
|
254
|
-
|
|
255
|
-
// `--verify=inputs` pins EVERYTHING local: the input-completeness proof
|
|
256
|
-
// is the OS sandbox, which is local machinery a remote executor silently
|
|
257
|
-
// ignores — so a remotely-executed task would pass the verify VACUOUSLY,
|
|
258
|
-
// leaky or not. A verify run is a local proof procedure by definition;
|
|
259
|
-
// determinism/fingerprint modes are unaffected (no sandbox involved).
|
|
260
|
-
const placements = placeTasks(nodes, executors, options.verify?.inputs === true)
|
|
313
|
+
const placements = placeTasks(nodes, executors)
|
|
261
314
|
// A `remote: 'only'` task nobody takes succeeds WITHOUT running. That is
|
|
262
315
|
// deliberate — on a machine with no remote pool the ambient state already
|
|
263
316
|
// is what the task would have produced — but it must not be SILENT: a task
|
|
@@ -300,27 +353,9 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
300
353
|
}
|
|
301
354
|
|
|
302
355
|
// `--download` (default `all`) decides ONCE per task, here, whether a
|
|
303
|
-
// remote execution's outputs come home.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
// A proof must observe what it proves. `--verify=inputs` already pins
|
|
307
|
-
// every task LOCAL (so nothing could defer), but determinism and
|
|
308
|
-
// fingerprint modes do NOT — and a deferred task's outputs are absent
|
|
309
|
-
// when the verifier looks, which reported `no-outputs` for a task that
|
|
310
|
-
// declares outputs: an n/a verdict for work the proof never examined.
|
|
311
|
-
// Deferral is transfer tuning; a verify run is a rare, deliberate
|
|
312
|
-
// correctness run. Eager wins, and says so when it overrides.
|
|
313
|
-
const downloadPolicy = options.verify !== undefined ? 'all' : (options.download ?? 'all')
|
|
314
|
-
if (
|
|
315
|
-
options.verify !== undefined &&
|
|
316
|
-
options.download !== undefined &&
|
|
317
|
-
options.download !== 'all'
|
|
318
|
-
) {
|
|
319
|
-
log.status(`vx: --verify observes outputs on disk — ignoring --download=${options.download}`)
|
|
320
|
-
}
|
|
321
|
-
const localPlaced = new Set(
|
|
322
|
-
[...nodes.keys()].filter((id) => placements.executors.get(id)?.remote !== true),
|
|
323
|
-
)
|
|
356
|
+
// remote execution's outputs come home.
|
|
357
|
+
const downloadPolicy = options.download ?? 'all'
|
|
358
|
+
const localPlaced = locallyPlaced(placements, nodes.keys())
|
|
324
359
|
const download = resolveDownloadModes({
|
|
325
360
|
nodes,
|
|
326
361
|
policy: downloadPolicy,
|
|
@@ -344,39 +379,59 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
344
379
|
// exit; ownership moves here so the orchestrator can SIGTERM
|
|
345
380
|
// them once the rest of the graph finishes.
|
|
346
381
|
//
|
|
347
|
-
// A SIGINT/SIGTERM mid-run forwards
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
// cancellation, `kill <pid>`) orphans every running child —
|
|
351
|
-
// terminal Ctrl-C only worked via process-group propagation. The
|
|
352
|
-
// handlers are removed in the finally below so repeated run()
|
|
353
|
-
// calls (test suites) never stack listeners.
|
|
382
|
+
// A SIGINT/SIGTERM mid-run forwards that signal to everything in both
|
|
383
|
+
// (`signals.ts`); the handlers are removed in the finally below so
|
|
384
|
+
// repeated run() calls (test suites) never stack listeners.
|
|
354
385
|
const liveChildren = new Set<ReturnType<typeof Bun.spawn>>()
|
|
355
386
|
const persistentRegistry = new Map<string, ReturnType<typeof Bun.spawn>>()
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
387
|
+
// One stop for the run: an embedder's `RunOptions.signal` and a process
|
|
388
|
+
// signal both abort it. The scheduler stops dispatching (it reads the
|
|
389
|
+
// signal), the children are torn down, and run() returns through its own
|
|
390
|
+
// end-of-run path. Detached in the finally below.
|
|
391
|
+
const stopRun = new AbortController()
|
|
392
|
+
const forwardAbort = (): void => stopRun.abort(options.signal?.reason)
|
|
393
|
+
if (options.signal?.aborted === true) forwardAbort()
|
|
394
|
+
else options.signal?.addEventListener('abort', forwardAbort, { once: true })
|
|
395
|
+
// A run a process signal stopped records no history: until item 849 the
|
|
396
|
+
// handler exited before any outcome landed, and a Ctrl-C'd run then read
|
|
397
|
+
// in `vx last` as FAILED with 0 tasks (item 854). An embedder's abort is
|
|
398
|
+
// its own call and keeps its record.
|
|
399
|
+
let stoppedBySignal = false
|
|
400
|
+
let leftRun = (): void => {}
|
|
401
|
+
const runLeft = new Promise<void>((resolve) => {
|
|
402
|
+
leftRun = resolve
|
|
403
|
+
})
|
|
404
|
+
const signals = forwardSignals({
|
|
405
|
+
enabled: options.handleSignals ?? true,
|
|
406
|
+
log,
|
|
407
|
+
cache,
|
|
408
|
+
liveChildren,
|
|
409
|
+
persistentRegistry,
|
|
410
|
+
stop: (signal) => {
|
|
411
|
+
stoppedBySignal = true
|
|
412
|
+
stopRun.abort(signal)
|
|
413
|
+
},
|
|
414
|
+
done: runLeft,
|
|
415
|
+
// The grace, then one flush and each teardown at their own bound, and
|
|
416
|
+
// slack for the summary and the cache close.
|
|
417
|
+
boundMs:
|
|
418
|
+
killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS) +
|
|
419
|
+
teardownTimeoutMs() * (1 + prepared.plugins.filter((p) => p.teardown).length) +
|
|
420
|
+
2_000,
|
|
421
|
+
})
|
|
422
|
+
// Awaited before run() leaves: a task's shell can die of the signal at
|
|
423
|
+
// once while a child it backgrounded ignores it and dies only to the
|
|
424
|
+
// SIGKILL after the grace, and a run that returned first left that child
|
|
425
|
+
// running past vx's exit (turborepo#14043's class).
|
|
426
|
+
let aborting: Promise<void> | undefined
|
|
427
|
+
const onAbort = (): void => {
|
|
428
|
+
aborting = terminateChildren(
|
|
429
|
+
() => [...liveChildren, ...persistentRegistry.values()],
|
|
430
|
+
forwardedSignal(stopRun.signal.reason),
|
|
431
|
+
)
|
|
379
432
|
}
|
|
433
|
+
if (stopRun.signal.aborted) onAbort()
|
|
434
|
+
else stopRun.signal.addEventListener('abort', onAbort, { once: true })
|
|
380
435
|
// The cache handle must be released on EVERY exit path, not just the
|
|
381
436
|
// happy one: `close()` is also where the run's deferred `accessed_at`
|
|
382
437
|
// bumps are flushed, so a throw between opening the cache and the
|
|
@@ -387,11 +442,25 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
387
442
|
// holding the handle open for a dev server's whole lifetime would be
|
|
388
443
|
// worse — and because close() re-runs its retention DELETEs.
|
|
389
444
|
let cacheClosed = false
|
|
390
|
-
const closeCache = (): void => {
|
|
445
|
+
const closeCache = async (): Promise<void> => {
|
|
391
446
|
if (cacheClosed) return
|
|
392
447
|
cacheClosed = true
|
|
393
448
|
cache.close()
|
|
449
|
+
// Released with the handle: before a persistent task's wait, on every
|
|
450
|
+
// exit path (see run-lock.ts for why a run holds one at all). AWAITED:
|
|
451
|
+
// fired and forgotten, the release lost the race with the CLI's exit —
|
|
452
|
+
// `bin.ts` ends the process as soon as run() resolves — and every CLI
|
|
453
|
+
// run left its lock directory in the temp dir (658 of them after one
|
|
454
|
+
// day's gates, 2026-09-16); the next run reclaimed it, so nothing
|
|
455
|
+
// waited, but a directory per run is litter the box never sheds.
|
|
456
|
+
await releaseRunLock()
|
|
394
457
|
}
|
|
458
|
+
// Taken after the early exits above (nothing they do touches a tree) and
|
|
459
|
+
// before the schedule: from here on tasks clean, restore and write.
|
|
460
|
+
const releaseRunLock = await acquireRunLock(prepared.workspaceRoot, {
|
|
461
|
+
log: (m) => log.status(m),
|
|
462
|
+
signal: stopRun.signal,
|
|
463
|
+
})
|
|
395
464
|
try {
|
|
396
465
|
// One run-id per `vx run` invocation. Every task in the resulting
|
|
397
466
|
// graph carries it so analytics queries can group by invocation.
|
|
@@ -399,17 +468,20 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
399
468
|
const runStartHrTimeNs = process.hrtime.bigint()
|
|
400
469
|
const endedAtMsAtStart = Date.now()
|
|
401
470
|
const remoteCacheEnabled = prepared.hasRemoteLayer
|
|
402
|
-
// Normalised ONCE so every consumer —
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
// happened
|
|
407
|
-
// that was never written (wiping a successful build's tree and
|
|
408
|
-
// reporting it failed).
|
|
471
|
+
// Normalised ONCE so every consumer — execute-task, the dedup
|
|
472
|
+
// predicate, the recorded invocation row — reads the policy that
|
|
473
|
+
// actually governed the run. Reading the raw request here made tasks
|
|
474
|
+
// clean their outputs before every exec for a save that never
|
|
475
|
+
// happened.
|
|
409
476
|
const policy: CachePolicy = effectiveCachePolicy(
|
|
410
477
|
options.cache ?? FULL_CACHE_POLICY,
|
|
411
478
|
prepared.hasRemoteLayer,
|
|
412
479
|
)
|
|
480
|
+
if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
|
|
481
|
+
log.status(
|
|
482
|
+
'vx: --cache named the remote cache, but no cache plugin in vx.workspace.* supplies one — the remote axes are off for this run',
|
|
483
|
+
)
|
|
484
|
+
}
|
|
413
485
|
const deferredOutputs = new DeferredOutputs({
|
|
414
486
|
nodes,
|
|
415
487
|
cache,
|
|
@@ -419,29 +491,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
419
491
|
localWrite: policy.localWrite,
|
|
420
492
|
})
|
|
421
493
|
|
|
422
|
-
// `--verify` observes the miss-then-save path and then RESTORES attempt
|
|
423
|
-
// 1 from the artifact that save wrote, so the tree ends byte-identical
|
|
424
|
-
// to what was cached regardless of the verdict. That restore reads the
|
|
425
|
-
// LOCAL artifact file — `restoreOutputs` is a local extraction on every
|
|
426
|
-
// layer, by design — so without the local WRITE axis there is nothing to
|
|
427
|
-
// restore from: verify cleaned a successful build's declared outputs and
|
|
428
|
-
// then failed the run on the missing artifact. A remote fallback would
|
|
429
|
-
// not fix that, only narrow it to "whenever the remote is reachable at
|
|
430
|
-
// that instant", turning a guarantee into a coin flip with data loss on
|
|
431
|
-
// the losing side. So the honest gate is the local write axis, and
|
|
432
|
-
// `--no-cache` / `--cache=local:,…` / `--cache=local:r,…` are refused
|
|
433
|
-
// loudly — silently verifying nothing is the one failure mode
|
|
434
|
-
// verification must never have (same platform-honesty rule as the
|
|
435
|
-
// sandbox-unavailable error). `--force --verify` re-verifies a warm graph.
|
|
436
|
-
if (options.verify !== undefined && !policy.localWrite) {
|
|
437
|
-
throw new UserError(
|
|
438
|
-
'--verify needs the LOCAL cache write axis: it re-runs the task, then restores ' +
|
|
439
|
-
'attempt 1 from the local artifact so the outputs on disk are the ones that were ' +
|
|
440
|
-
'cached. Enable local writes (e.g. --cache=local:w,remote:rw), drop --no-cache, ' +
|
|
441
|
-
'or use --force --verify to re-execute and verify a warm graph',
|
|
442
|
-
)
|
|
443
|
-
}
|
|
444
|
-
|
|
445
494
|
// Per-run context for the Tier-3 `invocations` header row. Captured
|
|
446
495
|
// ONCE (git is ONE spawn for commit+branch, behind try/catch; never
|
|
447
496
|
// fails a run). `dirty` reuses the `git status --porcelain` the
|
|
@@ -501,26 +550,16 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
501
550
|
)
|
|
502
551
|
}
|
|
503
552
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const avail = await probeSandbox()
|
|
515
|
-
if (!avail.available) {
|
|
516
|
-
throw new UserError(
|
|
517
|
-
verifyInputs
|
|
518
|
-
? `--verify=inputs needs the sandbox, which is not available: ${avail.reason}`
|
|
519
|
-
: `sandbox not available: ${avail.reason}`,
|
|
520
|
-
)
|
|
521
|
-
}
|
|
522
|
-
await initSandbox()
|
|
523
|
-
}
|
|
553
|
+
const sandboxArmer = prepareSandbox(nodes.values())
|
|
554
|
+
const keyed = keyedProjects(nodes)
|
|
555
|
+
const outputDirSnapshots: OutputDirSnapshot[] = []
|
|
556
|
+
// Saves run off the execution slot, twice the cap at once (memory:
|
|
557
|
+
// each pack holds an artifact's bytes); a failed save is a miss next
|
|
558
|
+
// time, said once — the task's work ran.
|
|
559
|
+
const saveLane = createSaveLane(2 * concurrency, (err) =>
|
|
560
|
+
log.status(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
561
|
+
)
|
|
562
|
+
const deferredSaves = new Map<string, Promise<void>>()
|
|
524
563
|
|
|
525
564
|
// Focused flow: a requested GROUP has no output of its own, so
|
|
526
565
|
// surface the same-project, non-group tasks it chains (one level)
|
|
@@ -544,16 +583,10 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
544
583
|
if (node.requested || node.surfaced === true) requestedCount++
|
|
545
584
|
}
|
|
546
585
|
}
|
|
547
|
-
//
|
|
548
|
-
// into
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
// os.totalmem() unless `--memory` overrides it (pass `--memory` in
|
|
552
|
-
// cgroup-limited containers — totalmem() reports the HOST's RAM).
|
|
553
|
-
// Nothing declared → empty map → fields omitted from the scheduler
|
|
554
|
-
// AND the footer → byte-identical legacy path.
|
|
555
|
-
const memBudget = options.memory ?? os.totalmem()
|
|
556
|
-
const resourceCosts = resolveResourceCosts(nodes)
|
|
586
|
+
// Admission over the worker count is a plugin's (`admit`): built once
|
|
587
|
+
// into the predicate the scheduler asks at every local dispatch. No
|
|
588
|
+
// plugin answers → undefined → the count-only path, byte for byte.
|
|
589
|
+
const admit = buildAdmission(prepared.plugins, nodes, concurrency, (m) => log.status(m))
|
|
557
590
|
|
|
558
591
|
// Run context for the footer. The top-of-run header is gone — the
|
|
559
592
|
// banner now lives in the summary, where the eye lands at the end.
|
|
@@ -563,7 +596,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
563
596
|
remoteCacheEnabled,
|
|
564
597
|
concurrency,
|
|
565
598
|
workspaceProjectCount,
|
|
566
|
-
...(resourceCosts.size > 0 ? { cpuBudget: concurrency, memBudget } : {}),
|
|
567
599
|
}
|
|
568
600
|
|
|
569
601
|
// Lifecycle hooks drive the default logger's dynamic status line
|
|
@@ -607,7 +639,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
607
639
|
// each probe, so there is no second cache.get. Gated by
|
|
608
640
|
// shouldShortCircuit (local reads on, no remote layer); when off, both
|
|
609
641
|
// maps are empty and the run is byte-identical.
|
|
610
|
-
let shortCircuit: ShortCircuit =
|
|
642
|
+
let shortCircuit: ShortCircuit = emptyShortCircuit()
|
|
611
643
|
if (shouldShortCircuit(nodes, policy, cache)) {
|
|
612
644
|
shortCircuit = await startLocalShortCircuit({
|
|
613
645
|
nodes,
|
|
@@ -622,8 +654,23 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
622
654
|
})
|
|
623
655
|
}
|
|
624
656
|
|
|
657
|
+
// Whether this task runs behind a failure (`continueMode: 'always'`
|
|
658
|
+
// only) or on the key of a dependency `--exclude-dependencies` kept
|
|
659
|
+
// from running — its save is withheld and the taint propagates; see
|
|
660
|
+
// admission.ts.
|
|
661
|
+
const excluded = excludedTaint(nodes)
|
|
662
|
+
if (excluded.unsaved > 0) {
|
|
663
|
+
log.status(
|
|
664
|
+
`[vx] --exclude-dependencies: ${excluded.unsaved} cached task(s) build on a skipped dependency; what they build is not saved`,
|
|
665
|
+
)
|
|
666
|
+
}
|
|
667
|
+
const isTainted = taintTracker(options.continueMode === 'always', excluded.seeds)
|
|
668
|
+
const dependedOn = new Set<string>()
|
|
669
|
+
for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
|
|
670
|
+
|
|
625
671
|
const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
|
|
626
672
|
const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
|
|
673
|
+
const taint = isTainted(node, upstream)
|
|
627
674
|
return {
|
|
628
675
|
node,
|
|
629
676
|
upstream,
|
|
@@ -634,7 +681,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
634
681
|
forwardArgs: options.forwardArgs,
|
|
635
682
|
...(options.retries !== undefined ? { retries: options.retries } : {}),
|
|
636
683
|
...(taskTimeoutDefault !== undefined ? { timeout: taskTimeoutDefault } : {}),
|
|
637
|
-
...(options.verify !== undefined ? { verify: options.verify } : {}),
|
|
638
684
|
log,
|
|
639
685
|
executor: placements.executors.get(node.id) ?? UNPLACED_EXECUTOR,
|
|
640
686
|
...(download.modeOf.get(node.id) === 'deferred' ? { download: 'deferred' as const } : {}),
|
|
@@ -648,85 +694,43 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
648
694
|
gitFilesCache,
|
|
649
695
|
hashCache,
|
|
650
696
|
...(probe !== undefined ? { preProbed: probe } : {}),
|
|
697
|
+
...(taint ? { taintedUpstream: true } : {}),
|
|
698
|
+
...(dependedOn.has(node.id) ? {} : { noDependants: true as const }),
|
|
699
|
+
fingerprintWatch,
|
|
700
|
+
...(sandboxArmer !== null ? { armSandbox: () => sandboxArmer.arm() } : {}),
|
|
701
|
+
keyedProjects: keyed,
|
|
702
|
+
outputDirSnapshots,
|
|
703
|
+
deferSave: saveLane.defer,
|
|
704
|
+
deferredSaves,
|
|
705
|
+
stopSignal: stopRun.signal,
|
|
651
706
|
}
|
|
652
707
|
}
|
|
653
708
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
const inflight = options.inflight
|
|
660
|
-
const executeWithDedup = async (
|
|
661
|
-
node: TaskNode,
|
|
662
|
-
upstream: TaskOutcome[],
|
|
663
|
-
): Promise<TaskOutcome> => {
|
|
664
|
-
// Dedup only helps when the sibling will WRITE the artifact and
|
|
665
|
-
// this task can READ it back — i.e. both axes effectively on.
|
|
666
|
-
const canRead = policy.localRead || policy.remoteRead
|
|
667
|
-
const canWrite = policy.localWrite || policy.remoteWrite
|
|
668
|
-
const cacheable =
|
|
669
|
-
!isGroupTask(node) &&
|
|
670
|
-
node.config.exec?.persistent === undefined &&
|
|
671
|
-
node.config.cache !== undefined &&
|
|
672
|
-
canRead &&
|
|
673
|
-
canWrite
|
|
674
|
-
// A restore-tier task (confirmed local hit, may run before its
|
|
675
|
-
// deps) needs no dedup — it's a restore, not an executor, and its
|
|
676
|
-
// live `upstream` is incomplete, so the dedup hash recompute would
|
|
677
|
-
// be wrong. Route it straight to executeTask, which reuses the
|
|
678
|
-
// up-front probe.
|
|
679
|
-
const restorable = shortCircuit.restoreTier.has(node.id)
|
|
680
|
-
if (inflight === undefined || !cacheable || restorable) {
|
|
681
|
-
return executeTask(buildExecuteArgs(node, upstream))
|
|
682
|
-
}
|
|
683
|
-
const hash = await computeTaskHash({
|
|
684
|
-
node,
|
|
685
|
-
upstream,
|
|
709
|
+
const executeWithDedup = admitTasks({
|
|
710
|
+
inflight: options.inflight,
|
|
711
|
+
policy,
|
|
712
|
+
shortCircuit,
|
|
713
|
+
hashArgs: {
|
|
686
714
|
workspaceRoot,
|
|
687
715
|
workspaceFingerprint,
|
|
688
716
|
cache,
|
|
689
717
|
forwardArgs: options.forwardArgs,
|
|
690
|
-
|
|
718
|
+
nestedDirsByProject,
|
|
691
719
|
gitFilesCache,
|
|
692
720
|
hashCache,
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
// Join a sibling already computing this exact task: wait, then
|
|
697
|
-
// executeTask cache-hits on the artifact it just saved. The
|
|
698
|
-
// up-front probe (preProbed) predates the sibling's save — its
|
|
699
|
-
// "confirmed stable miss" would skip the lazy cache.get and
|
|
700
|
-
// re-execute, defeating the dedup — so the join path drops it
|
|
701
|
-
// and lets executeTask probe fresh.
|
|
702
|
-
await existing.catch(() => {})
|
|
703
|
-
return executeTask(buildExecuteArgs(node, upstream, false))
|
|
704
|
-
}
|
|
705
|
-
// Become the executor: register a barrier siblings await. get→set has
|
|
706
|
-
// no await between, so registration is atomic — at most one executor
|
|
707
|
-
// per hash. Released on every exit (success / failure / throw).
|
|
708
|
-
let release!: () => void
|
|
709
|
-
inflight.set(
|
|
710
|
-
hash,
|
|
711
|
-
new Promise<void>((resolve) => {
|
|
712
|
-
release = resolve
|
|
713
|
-
}),
|
|
714
|
-
)
|
|
715
|
-
try {
|
|
716
|
-
return await executeTask(buildExecuteArgs(node, upstream))
|
|
717
|
-
} finally {
|
|
718
|
-
inflight.delete(hash)
|
|
719
|
-
release()
|
|
720
|
-
}
|
|
721
|
-
}
|
|
721
|
+
},
|
|
722
|
+
buildExecuteArgs,
|
|
723
|
+
})
|
|
722
724
|
|
|
723
725
|
mark('classify + probe')
|
|
724
726
|
const outcomes = await runGraph({
|
|
725
727
|
nodes,
|
|
726
728
|
concurrency,
|
|
729
|
+
settledOf: (o) => deferredSaves.get(o.node.id),
|
|
727
730
|
...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
|
|
728
|
-
...(
|
|
731
|
+
...(admit !== undefined ? { admit } : {}),
|
|
729
732
|
...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
|
|
733
|
+
signal: stopRun.signal,
|
|
730
734
|
onStart: (node) => {
|
|
731
735
|
log.taskStart?.(node)
|
|
732
736
|
},
|
|
@@ -734,7 +738,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
734
738
|
log.taskComplete(o.node, o)
|
|
735
739
|
narrowDemand(o.node.id)
|
|
736
740
|
},
|
|
737
|
-
execute: executeWithDedup,
|
|
741
|
+
execute: (node, upstream) => executeWithDedup(node, keyUpstream(node, upstream)),
|
|
738
742
|
// A `schedule` plugin's weights; the scheduler keeps its structural
|
|
739
743
|
// baseline as the tie-break. Empty map → baseline only.
|
|
740
744
|
...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
|
|
@@ -744,72 +748,24 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
744
748
|
restoreTier: shortCircuit.restoreTier,
|
|
745
749
|
})
|
|
746
750
|
|
|
747
|
-
//
|
|
748
|
-
// the
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
// `options.log === undefined` means the default logger (a `vx run`
|
|
754
|
-
// invocation), and `handleSignals` excludes watch mode (own signal
|
|
755
|
-
// loop) and embedders that manage lifecycle themselves — both expect
|
|
756
|
-
// run() to return, not block on a server.
|
|
751
|
+
// Which persistent children outlive the graph, and the bounded SIGTERM
|
|
752
|
+
// of the rest, before the summary prints. Scoped to the real CLI
|
|
753
|
+
// foreground: `options.log === undefined` means the default logger (a
|
|
754
|
+
// `vx run` invocation), and `handleSignals` excludes watch mode (own
|
|
755
|
+
// signal loop) and embedders that manage lifecycle themselves — both
|
|
756
|
+
// expect run() to return, not block on a server.
|
|
757
757
|
const foreground = options.log === undefined && (options.handleSignals ?? true)
|
|
758
|
-
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
const n = nodes.get(id)
|
|
763
|
-
if (n !== undefined && (n.requested || n.surfaced === true)) {
|
|
764
|
-
keepAliveNodes.push(n)
|
|
765
|
-
keepAlive.push(child)
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
const keepAliveSet = new Set(keepAlive)
|
|
770
|
-
|
|
771
|
-
// Shut down the dependency-only persistent tasks before reporting the final
|
|
772
|
-
// summary. SIGTERM gives well-behaved servers (vite, next, esbuild --watch)
|
|
773
|
-
// a moment to clean up. Bun's Subprocess.kill is idempotent on an
|
|
774
|
-
// already-exited child. Bound the wait: a persistent dep that traps or
|
|
775
|
-
// ignores SIGTERM (a wedged mock server) would otherwise hang the run at
|
|
776
|
-
// NORMAL completion forever — after a grace, SIGKILL the stragglers and move
|
|
777
|
-
// on. Well-behaved servers exit in well under the grace, so the happy path
|
|
778
|
-
// pays nothing; the timer is cleared + unref'd so a fast shutdown never
|
|
779
|
-
// delays CLI exit.
|
|
780
|
-
const dyingChildren = [...persistentRegistry.values()].filter((c) => !keepAliveSet.has(c))
|
|
781
|
-
for (const child of dyingChildren) child.kill('SIGTERM')
|
|
782
|
-
const allExited = Promise.allSettled(dyingChildren.map((c) => c.exited))
|
|
783
|
-
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
784
|
-
const winner = await Promise.race([
|
|
785
|
-
allExited.then(() => 'exited' as const),
|
|
786
|
-
new Promise<'grace'>((resolve) => {
|
|
787
|
-
graceTimer = setTimeout(() => resolve('grace'), PERSISTENT_SHUTDOWN_GRACE_MS)
|
|
788
|
-
graceTimer.unref?.()
|
|
789
|
-
}),
|
|
790
|
-
])
|
|
791
|
-
if (graceTimer !== undefined) clearTimeout(graceTimer)
|
|
792
|
-
if (winner === 'grace') {
|
|
793
|
-
for (const child of dyingChildren) child.kill('SIGKILL')
|
|
794
|
-
await allExited
|
|
795
|
-
}
|
|
758
|
+
// An aborted run's children are already being torn down: nothing to hold.
|
|
759
|
+
const hold = options.holdPersistent === true && !stopRun.signal.aborted
|
|
760
|
+
const keepAlive = selectKeepAlive(persistentRegistry, nodes, foreground || hold)
|
|
761
|
+
const crashedPersistent = await shutdownPersistent(persistentRegistry, keepAlive.children)
|
|
796
762
|
|
|
797
763
|
mark('run graph')
|
|
798
764
|
// Clear the status line for good before the summary prints.
|
|
799
765
|
log.runEnd?.()
|
|
800
766
|
|
|
801
767
|
const list = [...outcomes.values()]
|
|
802
|
-
const ok =
|
|
803
|
-
list.every((o) => isPassStatus(o.status)) &&
|
|
804
|
-
// `--verify`: a provably-unsafe cache entry (non-deterministic outputs, a
|
|
805
|
-
// re-run that failed, or a read of undeclared inputs) turns the run red so
|
|
806
|
-
// CI catches it.
|
|
807
|
-
!list.some(
|
|
808
|
-
(o) =>
|
|
809
|
-
o.verify?.kind === 'nondeterministic' ||
|
|
810
|
-
o.verify?.kind === 'rerun-failed' ||
|
|
811
|
-
o.verify?.kind === 'undeclared-inputs',
|
|
812
|
-
)
|
|
768
|
+
const ok = list.every((o) => isPassStatus(o.status)) && crashedPersistent.length === 0
|
|
813
769
|
|
|
814
770
|
// The summary + artifact writers + recordRun pass all exclude group
|
|
815
771
|
// tasks via the shared tallyOutcomes helper. We pass the full
|
|
@@ -818,13 +774,28 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
818
774
|
const totalMs = Number(process.hrtime.bigint() - runStartHrTimeNs) / 1_000_000
|
|
819
775
|
// Foreground dev mode: between the task frame and the footer, list
|
|
820
776
|
// the persistent tasks still running (see the keep-alive block below).
|
|
821
|
-
|
|
822
|
-
|
|
777
|
+
// Only the ones still up: a requested server that crashed while its
|
|
778
|
+
// dependants ran is dead, and the wait below says so.
|
|
779
|
+
const stillUp = keepAlive.nodes.filter((_, i) => !hasEnded(keepAlive.children[i]!))
|
|
780
|
+
if (stillUp.length > 0) {
|
|
781
|
+
for (const line of formatPersistentList(stillUp, colors)) log.status(line)
|
|
823
782
|
}
|
|
824
783
|
for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
|
|
825
784
|
// A task killed by a shutdown signal is in no bucket above, yet it makes
|
|
826
785
|
// `ok` false — name it, or the red exit is undiagnosable.
|
|
827
786
|
for (const line of formatAbortedSection(list)) log.status(line)
|
|
787
|
+
// Likewise a dependency-only server that died before the end of the
|
|
788
|
+
// graph stopped it: its outcome says `success` (it became ready).
|
|
789
|
+
for (const c of crashedPersistent)
|
|
790
|
+
log.status(`vx: ${c.id} exited with code ${c.code} before the run stopped it`)
|
|
791
|
+
// The footer's "N skipped" names no task; this names each under the
|
|
792
|
+
// failure that blocked it.
|
|
793
|
+
for (const line of formatSkippedSection(list)) log.status(line)
|
|
794
|
+
// Judged against the history BEFORE this run's rows land, so the query
|
|
795
|
+
// is one scan over the executed tasks' keys and nothing at all on a run
|
|
796
|
+
// that executed none (every hit, every skip).
|
|
797
|
+
const flaky = detectFlaky(prepared.localCache.dbHandle(), flakyCandidates(list))
|
|
798
|
+
for (const line of formatFlakySection(flaky)) log.status(line)
|
|
828
799
|
// Outputs that never came home are not an error, but a silent `dist/`
|
|
829
800
|
// that is empty-or-stale would be: name every task whose bytes are
|
|
830
801
|
// still remote.
|
|
@@ -835,24 +806,8 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
835
806
|
` Deferred: ${stillDeferred.length} task(s) left outputs remote (--download=none): ${stillDeferred.join(', ')}`,
|
|
836
807
|
)
|
|
837
808
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
// A fingerprint-only run attaches no verdicts, so the verdict-driven
|
|
841
|
-
// section above prints nothing — report what actually happened.
|
|
842
|
-
if (options.verify.fingerprint && !options.verify.determinism && !options.verify.inputs) {
|
|
843
|
-
const n = list.filter((o) => o.outputFp !== undefined).length
|
|
844
|
-
log.status('')
|
|
845
|
-
log.status(
|
|
846
|
-
` Verify: fingerprinted ${n} task output trees (cross-machine diff via a connected serve)`,
|
|
847
|
-
)
|
|
848
|
-
// Only EXECUTED tasks fingerprint — a warm all-hit run reports 0.
|
|
849
|
-
// A per-platform matrix wired without `--force` produces nothing
|
|
850
|
-
// forever, so name the cause instead of a bare 0.
|
|
851
|
-
if (n === 0) {
|
|
852
|
-
log.status(' (0 executed — cache hits do not fingerprint; pair with --force)')
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
}
|
|
809
|
+
// Last, where a long log's reader lands: each failure's own last lines.
|
|
810
|
+
for (const line of failureRecap()) log.status(line)
|
|
856
811
|
|
|
857
812
|
// Optional artifacts. Errors are surfaced to the user but don't
|
|
858
813
|
// change the run's exit code — the run already happened.
|
|
@@ -868,6 +823,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
868
823
|
totalMs,
|
|
869
824
|
ok,
|
|
870
825
|
outcomes: list,
|
|
826
|
+
flaky,
|
|
871
827
|
})
|
|
872
828
|
log.status(`vx: summary written to ${wrote}`)
|
|
873
829
|
} catch (err) {
|
|
@@ -890,119 +846,41 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
890
846
|
}
|
|
891
847
|
|
|
892
848
|
// Record each task to the run history in a single SQLite transaction
|
|
893
|
-
// (one fsync instead of N)
|
|
894
|
-
//
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
const toRecord: RunRecord[] = []
|
|
902
|
-
// Per-task telemetry mirrors `toRecord` 1:1 — built only when a
|
|
903
|
-
// telemetry sink is active, so a no-telemetry run allocates nothing.
|
|
904
|
-
const summaryTasks: TaskTelemetry[] = []
|
|
905
|
-
let failedCount = 0
|
|
906
|
-
let hitLocalCount = 0
|
|
907
|
-
let hitRemoteCount = 0
|
|
908
|
-
for (const o of list) {
|
|
909
|
-
if (isGroupTask(o.node)) continue
|
|
910
|
-
// aborted (killed by a shutdown signal) isn't a real run.
|
|
911
|
-
if (o.status === 'aborted') continue
|
|
912
|
-
if (telemetry !== undefined) {
|
|
913
|
-
const t: TaskTelemetry = {
|
|
914
|
-
taskId: o.node.id,
|
|
915
|
-
project: o.node.projectName,
|
|
916
|
-
task: o.node.taskName,
|
|
917
|
-
status: o.status,
|
|
918
|
-
cacheSource: deriveCacheSource(o.status),
|
|
919
|
-
exitCode: o.exitCode,
|
|
920
|
-
durationMs: o.durationMs,
|
|
921
|
-
}
|
|
922
|
-
if (o.hash !== undefined) t.hash = o.hash
|
|
923
|
-
if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
|
|
924
|
-
if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
|
|
925
|
-
if (o.where !== undefined) t.where = o.where
|
|
926
|
-
if (o.outputs !== undefined) t.outputs = o.outputs
|
|
927
|
-
if (o.attempts !== undefined) t.attempts = o.attempts
|
|
928
|
-
if (o.verify !== undefined) t.verify = o.verify
|
|
929
|
-
if (o.outputFp !== undefined) t.outputFp = o.outputFp
|
|
930
|
-
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
931
|
-
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
|
932
|
-
summaryTasks.push(t)
|
|
933
|
-
}
|
|
934
|
-
// Every non-group, non-aborted outcome gets a row — the same set
|
|
935
|
-
// `tallyOutcomes` counts, so `invocations.task_count` equals both the
|
|
936
|
-
// terminal's "N total" and `COUNT(*) FROM runs WHERE run_id = ?`.
|
|
937
|
-
// An outcome with NO hash (a `skipped` task never probed the cache; a
|
|
938
|
-
// `persistent` one is never cacheable) used to be dropped here because
|
|
939
|
-
// `runs.hash` is NOT NULL — which made a failing persistent task record
|
|
940
|
-
// `0 tasks, 0 failures` on a run the terminal called red, and a failed
|
|
941
|
-
// task with a skipped dependent record 1 of 2. `bindRun` stores `''` for
|
|
942
|
-
// those instead; the key-diff readers guard it.
|
|
943
|
-
toRecord.push({
|
|
944
|
-
...(o.hash !== undefined ? { hash: o.hash } : {}),
|
|
945
|
-
project: o.node.projectName,
|
|
946
|
-
task: o.node.taskName,
|
|
947
|
-
status: o.status,
|
|
948
|
-
exitCode: o.exitCode,
|
|
949
|
-
durationMs: o.durationMs,
|
|
950
|
-
...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
|
|
951
|
-
// Anchor to the REAL per-task wall-clock window: run-start wall time +
|
|
952
|
-
// the task's ns offset (captured for hits and executed tasks alike).
|
|
953
|
-
// The `now - duration` fallback applies to outcomes without an offset —
|
|
954
|
-
// today only `skipped`, which the scheduler finishes synchronously with
|
|
955
|
-
// no span, so it collapses to a zero-width mark at the run's end. Using
|
|
956
|
-
// run-end-minus-duration for EVERYTHING was the old bug that piled every
|
|
957
|
-
// task at the right edge of the timeline.
|
|
958
|
-
startedAt:
|
|
959
|
-
o.wallclockStartNs !== undefined
|
|
960
|
-
? endedAtMsAtStart + Math.round(Number(o.wallclockStartNs) / 1e6)
|
|
961
|
-
: now - o.durationMs,
|
|
962
|
-
endedAt:
|
|
963
|
-
o.wallclockEndNs !== undefined
|
|
964
|
-
? endedAtMsAtStart + Math.round(Number(o.wallclockEndNs) / 1e6)
|
|
965
|
-
: now,
|
|
966
|
-
runId,
|
|
967
|
-
...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
|
|
968
|
-
...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
|
|
969
|
-
...(o.wallclockStartNs !== undefined ? { wallclockStartNs: o.wallclockStartNs } : {}),
|
|
970
|
-
...(o.wallclockEndNs !== undefined ? { wallclockEndNs: o.wallclockEndNs } : {}),
|
|
971
|
-
cacheHit: isCacheHit(o.status),
|
|
972
|
-
...(o.attempts !== undefined ? { attempts: o.attempts } : {}),
|
|
973
|
-
})
|
|
974
|
-
if (o.status === 'failed') failedCount++
|
|
975
|
-
if (o.status === 'cache-hit') hitLocalCount++
|
|
976
|
-
if (o.status === 'cache-hit-remote') hitRemoteCount++
|
|
977
|
-
}
|
|
978
|
-
const invocation: InvocationRecord = {
|
|
849
|
+
// (one fsync instead of N), with the invocation header row alongside,
|
|
850
|
+
// atomically via recordRunBundle. The Tier-3 input-fingerprint rows
|
|
851
|
+
// (entry_inputs) are NOT built here — they're persisted inside each
|
|
852
|
+
// entry's save transaction (miss path only), so a warm all-cache-hit
|
|
853
|
+
// run does no extra recording work. The telemetry mirror is built in
|
|
854
|
+
// the same pass, only when a sink is active.
|
|
855
|
+
const records = assembleRunRecords({
|
|
856
|
+
outcomes: list,
|
|
979
857
|
runId,
|
|
858
|
+
startedAtMs: endedAtMsAtStart,
|
|
859
|
+
endedAtMs,
|
|
860
|
+
totalMs,
|
|
861
|
+
ok,
|
|
980
862
|
command: options.command ?? process.argv.slice(1).join(' '),
|
|
981
|
-
requestedTasks:
|
|
863
|
+
requestedTasks: options.tasks,
|
|
982
864
|
cachePolicy: compactCachePolicy(policy),
|
|
983
865
|
concurrency,
|
|
984
866
|
flow: options.flow ?? null,
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
arch: hostContext.arch,
|
|
1002
|
-
vxVersion: VERSION,
|
|
1003
|
-
tags: JSON.stringify(options.tags ?? {}),
|
|
867
|
+
forwardArgs: options.forwardArgs,
|
|
868
|
+
tags: options.tags ?? {},
|
|
869
|
+
git: gitContext,
|
|
870
|
+
ci: ciContext,
|
|
871
|
+
host: hostContext,
|
|
872
|
+
withTelemetry: telemetry !== undefined,
|
|
873
|
+
})
|
|
874
|
+
try {
|
|
875
|
+
if (!stoppedBySignal) cache.recordRunBundle(records)
|
|
876
|
+
} catch (err) {
|
|
877
|
+
// History is observability: a full cache disk at the very end must
|
|
878
|
+
// not turn a finished run's verdict into a stack and exit 1 (seen as
|
|
879
|
+
// an unprivileged user on a 2 MiB disk, 2026-09-16). The run said
|
|
880
|
+
// what happened; `vx last` and the flaky list will not know this one.
|
|
881
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
882
|
+
log.status(`[vx] run history not recorded: ${message} — the verdict above stands`)
|
|
1004
883
|
}
|
|
1005
|
-
cache.recordRunBundle({ runs: toRecord, invocation })
|
|
1006
884
|
mark('record history')
|
|
1007
885
|
// Hand the per-run summary to the telemetry sinks + drain them. Only
|
|
1008
886
|
// when a sink is active (telemetry !== undefined) — otherwise this
|
|
@@ -1010,11 +888,12 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
1010
888
|
// emitSummary/flush are crash-isolated, so a faulty sink can't fail
|
|
1011
889
|
// the run; flush is the sink's last chance to ship buffered records.
|
|
1012
890
|
if (telemetry !== undefined && runContextRecord !== undefined) {
|
|
1013
|
-
const summary = assembleRunSummary(runContextRecord,
|
|
891
|
+
const summary = assembleRunSummary(runContextRecord, records.telemetryTasks, {
|
|
1014
892
|
startedAt: endedAtMsAtStart,
|
|
1015
893
|
endedAt: endedAtMs,
|
|
1016
894
|
totalDurationMs: Math.round(totalMs),
|
|
1017
895
|
exitOk: ok,
|
|
896
|
+
abortedCount: list.filter((o) => o.status === 'aborted' && !isGroupTask(o.node)).length,
|
|
1018
897
|
})
|
|
1019
898
|
telemetry.emitSummary(summary)
|
|
1020
899
|
await telemetry.flush()
|
|
@@ -1039,16 +918,36 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
1039
918
|
// write with nothing but a warning. The seam's contract is that a
|
|
1040
919
|
// cache layer stays usable until the run's uploads have settled.
|
|
1041
920
|
await prefetchDone
|
|
921
|
+
// Every deferred save has settled by here: the scheduler finishes a
|
|
922
|
+
// task only once its `settledOf` promise (the lane's `landed`) has,
|
|
923
|
+
// so `runGraph` resolved with every save in the cache. A second
|
|
924
|
+
// drain of the lane stood here until item 634; deleting it changed
|
|
925
|
+
// nothing the suite could see, because this gate is the rule.
|
|
1042
926
|
await cache.drainUploads?.()
|
|
927
|
+
// The miss path's output-directory snapshots, taken now that the
|
|
928
|
+
// directories are old enough for the snapshot's racy window (see
|
|
929
|
+
// miss-save.ts). A few at a time: each is an lstat + readdir per
|
|
930
|
+
// prefix; the rows land together at close, in one transaction.
|
|
931
|
+
for (let i = 0; i < outputDirSnapshots.length; i += 32) {
|
|
932
|
+
await Promise.all(
|
|
933
|
+
outputDirSnapshots
|
|
934
|
+
.slice(i, i + 32)
|
|
935
|
+
.map((s) => cache.recordOutputDirs?.(s.hash, s.projectDir, s.prefixes)),
|
|
936
|
+
)
|
|
937
|
+
}
|
|
938
|
+
mark('output dir snapshots')
|
|
939
|
+
// Not on a stopped run: one stopped while it waited on another run's
|
|
940
|
+
// lock never held it, and its prune evicted under that run (item 858).
|
|
941
|
+
if (!stopRun.signal.aborted) await applyCacheRetention(prepared, log)
|
|
1043
942
|
await teardownPlugins(prepared.plugins, (m) => log.status(m))
|
|
1044
|
-
closeCache()
|
|
943
|
+
await closeCache()
|
|
1045
944
|
mark('close')
|
|
1046
945
|
printTimings()
|
|
1047
946
|
|
|
1048
947
|
// Tear down SRT's network bridge + (on macOS) log monitor. No-op if
|
|
1049
948
|
// no task was sandboxed; otherwise SRT keeps proxy servers alive and
|
|
1050
949
|
// the next vx run would init on top of stale state.
|
|
1051
|
-
if (
|
|
950
|
+
if (sandboxArmer?.armed) {
|
|
1052
951
|
try {
|
|
1053
952
|
await resetSandbox()
|
|
1054
953
|
} catch (err) {
|
|
@@ -1061,21 +960,61 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
1061
960
|
// persistent task (dev server / watcher). The run is "done" in every
|
|
1062
961
|
// bookkeeping sense — summary printed, history recorded — but the
|
|
1063
962
|
// server is still up and that's the point. Stay in the foreground
|
|
1064
|
-
// until
|
|
1065
|
-
// dies; our SIGINT handler also exits 130), and a crash
|
|
1066
|
-
//
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
963
|
+
// until ONE of them exits: Ctrl-C hits the whole process group (the
|
|
964
|
+
// server dies; our SIGINT handler also exits 130), and a crash ends
|
|
965
|
+
// the session — the others are torn down (SIGTERM, grace, SIGKILL)
|
|
966
|
+
// and a non-zero exit makes the run not ok, so `vx run dev` in a
|
|
967
|
+
// script fails when the server it started fell over. Until
|
|
968
|
+
// 2026-09-10 this waited for EVERY server, so the SIGTERM after it
|
|
969
|
+
// was dead code and a crashed server left the rest running under a
|
|
970
|
+
// run that never returned. One status line names the server that
|
|
971
|
+
// ended the session and its code — the summary above said `success`
|
|
972
|
+
// for it, and an exit 1 with no word about why is a mystery in a log.
|
|
973
|
+
if (keepAlive.children.length > 0 && hold) {
|
|
974
|
+
const held = keepAlive.children
|
|
975
|
+
return {
|
|
976
|
+
ok,
|
|
977
|
+
outcomes: list,
|
|
978
|
+
persistent: {
|
|
979
|
+
ids: keepAlive.nodes.map((n) => n.id),
|
|
980
|
+
stop: (signal) => terminateChildren(() => held, signal),
|
|
981
|
+
},
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
if (keepAlive.children.length > 0) {
|
|
985
|
+
const first = await Promise.race(
|
|
986
|
+
keepAlive.children.map((c, i) => c.exited.then((code) => ({ code, i }))),
|
|
987
|
+
)
|
|
988
|
+
const node = keepAlive.nodes[first.i]!
|
|
989
|
+
const others = keepAlive.nodes.length - 1
|
|
990
|
+
// Not when the run was stopped: the server ended because the user
|
|
991
|
+
// stopped it, and "exited with code 130" read as a crash after every
|
|
992
|
+
// Ctrl-C once a stop let run() finish its own path (item 852).
|
|
993
|
+
if (!stopRun.signal.aborted) {
|
|
994
|
+
log.status(
|
|
995
|
+
`vx: ${node.id} exited with code ${first.code}` +
|
|
996
|
+
(others > 0
|
|
997
|
+
? `; stopping ${others} other persistent task${others === 1 ? '' : 's'}`
|
|
998
|
+
: ''),
|
|
999
|
+
)
|
|
1000
|
+
}
|
|
1001
|
+
await terminateChildren(() => keepAlive.children)
|
|
1002
|
+
return { ok: ok && first.code === 0, outcomes: list }
|
|
1070
1003
|
}
|
|
1071
1004
|
|
|
1072
1005
|
return { ok, outcomes: list }
|
|
1073
1006
|
} finally {
|
|
1074
|
-
// Idempotent
|
|
1075
|
-
//
|
|
1007
|
+
// Idempotent. The status-line ticker starts in runStart, and every
|
|
1008
|
+
// call between it and the success path's runEnd is crash-isolated
|
|
1009
|
+
// today (admit, the observers, execute's rejection arm, the
|
|
1010
|
+
// short-circuit, the prefetch, killTree; the bus swallows a
|
|
1011
|
+
// subscriber's throw, so even a logger's own runStart cannot reach
|
|
1012
|
+
// here), so deleting this line reddens nothing (item 635). It is the
|
|
1013
|
+
// floor for a throw a later change adds, not a path a row can drive.
|
|
1076
1014
|
log.runEnd?.()
|
|
1077
|
-
|
|
1078
|
-
|
|
1015
|
+
signals.remove()
|
|
1016
|
+
options.signal?.removeEventListener('abort', forwardAbort)
|
|
1017
|
+
stopRun.signal.removeEventListener('abort', onAbort)
|
|
1079
1018
|
// Plugins installed at the top of run() get their bus subscriptions
|
|
1080
1019
|
// released here. Idempotent; safe even if installPlugins threw.
|
|
1081
1020
|
disposePlugins?.()
|
|
@@ -1086,10 +1025,50 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
1086
1025
|
// NOT drained here: they hold no database state, and awaiting a
|
|
1087
1026
|
// wedged remote would turn a failing run into a hanging one.
|
|
1088
1027
|
try {
|
|
1089
|
-
closeCache()
|
|
1028
|
+
await closeCache()
|
|
1090
1029
|
} catch {
|
|
1091
1030
|
// teardown must not throw on the way out
|
|
1092
1031
|
}
|
|
1032
|
+
await aborting?.catch(() => {})
|
|
1033
|
+
// Last: a process signal waits for this before it exits.
|
|
1034
|
+
leftRun()
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* The workspace's `cacheRetention`, after every save and upload of this run
|
|
1040
|
+
* has landed (so nothing this run wrote is mid-flight) and before the cache
|
|
1041
|
+
* closes. Housekeeping, not the run's work: a failure is one warning, never
|
|
1042
|
+
* a failed run. Declared nowhere → one property read.
|
|
1043
|
+
*/
|
|
1044
|
+
async function applyCacheRetention(prepared: PreparedRun, log: Logger): Promise<void> {
|
|
1045
|
+
const retention = prepared.workspaceConfig?.cacheRetention
|
|
1046
|
+
if (retention === undefined) return
|
|
1047
|
+
// Validated at load (`validateRetention`), so both parse.
|
|
1048
|
+
const maxAgeMs =
|
|
1049
|
+
retention.olderThan === undefined ? undefined : parseDuration(retention.olderThan)!
|
|
1050
|
+
const maxBytes = retention.maxSize === undefined ? undefined : parseSize(retention.maxSize)!
|
|
1051
|
+
try {
|
|
1052
|
+
const result = await prepared.localCache.evictIfDue({
|
|
1053
|
+
...(maxAgeMs !== undefined ? { maxAgeMs } : {}),
|
|
1054
|
+
...(maxBytes !== undefined ? { maxBytes } : {}),
|
|
1055
|
+
})
|
|
1056
|
+
if (result !== null && (result.evicted > 0 || result.orphans > 0)) {
|
|
1057
|
+
const said: string[] = []
|
|
1058
|
+
if (result.evicted > 0) {
|
|
1059
|
+
said.push(
|
|
1060
|
+
`evicted ${result.evicted} entr${result.evicted === 1 ? 'y' : 'ies'} (${formatBytes(result.bytesFreed)})`,
|
|
1061
|
+
)
|
|
1062
|
+
}
|
|
1063
|
+
if (result.orphans > 0) {
|
|
1064
|
+
said.push(
|
|
1065
|
+
`reaped ${result.orphans} orphaned artifact${result.orphans === 1 ? '' : 's'} (${formatBytes(result.orphanBytes)})`,
|
|
1066
|
+
)
|
|
1067
|
+
}
|
|
1068
|
+
log.status(`vx: cache retention ${said.join(', ')}`)
|
|
1069
|
+
}
|
|
1070
|
+
} catch (err) {
|
|
1071
|
+
log.status(`vx: cache retention skipped: ${err instanceof Error ? err.message : String(err)}`)
|
|
1093
1072
|
}
|
|
1094
1073
|
}
|
|
1095
1074
|
|
|
@@ -1112,7 +1091,10 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1112
1091
|
return { tasks: [], unresolvedTasks: prepared.unresolvedTasks }
|
|
1113
1092
|
}
|
|
1114
1093
|
if (prepared.empty !== null) return { tasks: [] }
|
|
1115
|
-
|
|
1094
|
+
// Its own mark: a dry run's plan (every task's hash, the cache lookups,
|
|
1095
|
+
// the history p50s) was booked under `close`, the next mark, and read
|
|
1096
|
+
// as 105 ms of closing a cache at 1,000 projects (item 601).
|
|
1097
|
+
const planned = await plan({
|
|
1116
1098
|
nodes: prepared.nodes,
|
|
1117
1099
|
workspaceRoot: prepared.workspaceRoot,
|
|
1118
1100
|
workspaceFingerprint: prepared.workspaceFingerprint,
|
|
@@ -1138,174 +1120,38 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1138
1120
|
// cache capability, so plan mode gains no new class of side effect.
|
|
1139
1121
|
...(await planExecutorOf(prepared, log, options.download ?? 'all')),
|
|
1140
1122
|
})
|
|
1123
|
+
mark('plan')
|
|
1124
|
+
return planned
|
|
1141
1125
|
} finally {
|
|
1142
1126
|
prepared.cache.close()
|
|
1127
|
+
// The same table a run prints: a dry run is how the prepare stages
|
|
1128
|
+
// (discovery, config load, the git enumeration) get profiled on a
|
|
1129
|
+
// real repo that has no install and no cache to run against.
|
|
1130
|
+
mark('close')
|
|
1131
|
+
printTimings()
|
|
1143
1132
|
}
|
|
1144
1133
|
}
|
|
1145
1134
|
|
|
1146
1135
|
/**
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
|
|
1151
|
-
function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
|
|
1152
|
-
const pinned = new Set<string>()
|
|
1153
|
-
const memo = new Map<string, boolean>()
|
|
1154
|
-
const visit = (id: string): boolean => {
|
|
1155
|
-
const known = memo.get(id)
|
|
1156
|
-
if (known !== undefined) return known
|
|
1157
|
-
const node = nodes.get(id)
|
|
1158
|
-
if (node === undefined) return false
|
|
1159
|
-
memo.set(id, false) // cycle guard; the graph builder already rejects cycles
|
|
1160
|
-
const result =
|
|
1161
|
-
node.config.exec?.persistent !== undefined ||
|
|
1162
|
-
node.config.exec?.remote === false ||
|
|
1163
|
-
node.deps.some((d) => visit(d))
|
|
1164
|
-
memo.set(id, result)
|
|
1165
|
-
if (result) pinned.add(id)
|
|
1166
|
-
return result
|
|
1167
|
-
}
|
|
1168
|
-
for (const id of nodes.keys()) visit(id)
|
|
1169
|
-
return pinned
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
interface Placements {
|
|
1173
|
-
executors: Map<string, TaskExecutor>
|
|
1174
|
-
/**
|
|
1175
|
-
* `exec.remote: 'only'` tasks that no REMOTE executor took — a NO-OP on
|
|
1176
|
-
* this machine: never executed, declared outputs never cleaned or
|
|
1177
|
-
* restored. The task exists to produce a remote input tree; without a
|
|
1178
|
-
* remote pool, dependents use the machine's ambient state exactly as they
|
|
1179
|
-
* did before the field existed.
|
|
1180
|
-
*/
|
|
1181
|
-
remoteOnlyNoop: Set<string>
|
|
1182
|
-
/** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
|
|
1183
|
-
remoteOnly: Set<string>
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
function placeTasks(
|
|
1187
|
-
nodes: Map<string, TaskNode>,
|
|
1188
|
-
executors: readonly TaskExecutor[],
|
|
1189
|
-
pinAllLocal = false,
|
|
1190
|
-
): Placements {
|
|
1191
|
-
const pinned = pinnedLocalSet(nodes)
|
|
1192
|
-
const placements: Placements = {
|
|
1193
|
-
executors: new Map(),
|
|
1194
|
-
remoteOnlyNoop: new Set(),
|
|
1195
|
-
remoteOnly: new Set(),
|
|
1196
|
-
}
|
|
1197
|
-
for (const node of nodes.values()) {
|
|
1198
|
-
if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
|
|
1199
|
-
const executor = selectExecutor(executors, {
|
|
1200
|
-
taskId: node.id,
|
|
1201
|
-
projectName: node.projectName,
|
|
1202
|
-
projectDir: node.projectDir,
|
|
1203
|
-
command: node.config.exec!.command,
|
|
1204
|
-
pinnedLocal: pinAllLocal || pinned.has(node.id),
|
|
1205
|
-
cacheable: node.config.cache !== undefined,
|
|
1206
|
-
...(node.config.exec?.resources === undefined
|
|
1207
|
-
? {}
|
|
1208
|
-
: { resources: node.config.exec.resources }),
|
|
1209
|
-
})
|
|
1210
|
-
placements.executors.set(node.id, executor)
|
|
1211
|
-
if (node.config.exec?.remote === 'only') {
|
|
1212
|
-
// A pinned 'only' task (it transitively depends on a persistent one)
|
|
1213
|
-
// lands here too: pinning wins, so it noops rather than shipping.
|
|
1214
|
-
if (executor.remote === true) placements.remoteOnly.add(node.id)
|
|
1215
|
-
else placements.remoteOnlyNoop.add(node.id)
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
return placements
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
/**
|
|
1222
|
-
* `executorOf` for `planRun`, or nothing. Declining plugins, a single
|
|
1223
|
-
* executor, or a resolution error all yield nothing: `--dry` is an
|
|
1224
|
-
* inspection command and must not fail over a label.
|
|
1225
|
-
*/
|
|
1226
|
-
async function planExecutorOf(
|
|
1227
|
-
prepared: Awaited<ReturnType<typeof prepareRun>>,
|
|
1228
|
-
log: Logger,
|
|
1229
|
-
policy: 'all' | 'toplevel' | 'none',
|
|
1230
|
-
): Promise<{
|
|
1231
|
-
executorOf?: (id: string) => string | undefined
|
|
1232
|
-
downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
|
|
1233
|
-
downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
|
|
1234
|
-
}> {
|
|
1235
|
-
let executors: readonly TaskExecutor[]
|
|
1236
|
-
try {
|
|
1237
|
-
executors = await resolveExecutors(prepared.plugins, {
|
|
1238
|
-
workspaceRoot: prepared.workspaceRoot,
|
|
1239
|
-
cacheDir: prepared.cacheDir,
|
|
1240
|
-
warn: (m: string) => log.status(m),
|
|
1241
|
-
concurrency: Math.max(1, navigator.hardwareConcurrency),
|
|
1242
|
-
})
|
|
1243
|
-
} catch {
|
|
1244
|
-
return {}
|
|
1245
|
-
}
|
|
1246
|
-
const placements = placeTasks(prepared.nodes, executors)
|
|
1247
|
-
// Download modes need placement regardless of how many executors there
|
|
1248
|
-
// are (a single REMOTE one still defers); executor LABELS only earn their
|
|
1249
|
-
// column when there is a choice to report.
|
|
1250
|
-
const download =
|
|
1251
|
-
policy === 'all'
|
|
1252
|
-
? undefined
|
|
1253
|
-
: resolveDownloadModes({
|
|
1254
|
-
nodes: prepared.nodes,
|
|
1255
|
-
policy,
|
|
1256
|
-
localPlaced: new Set(
|
|
1257
|
-
[...prepared.nodes.keys()].filter(
|
|
1258
|
-
(id) => placements.executors.get(id)?.remote !== true,
|
|
1259
|
-
),
|
|
1260
|
-
),
|
|
1261
|
-
remoteOnly: placements.remoteOnly,
|
|
1262
|
-
})
|
|
1263
|
-
return {
|
|
1264
|
-
...(executors.length < 2
|
|
1265
|
-
? {}
|
|
1266
|
-
: {
|
|
1267
|
-
executorOf: (id: string) =>
|
|
1268
|
-
placements.remoteOnlyNoop.has(id) ? 'noop' : placements.executors.get(id)?.name,
|
|
1269
|
-
}),
|
|
1270
|
-
...(download === undefined
|
|
1271
|
-
? {}
|
|
1272
|
-
: {
|
|
1273
|
-
downloadOf: (id: string) => download.modeOf.get(id),
|
|
1274
|
-
downloadDowngrades: [...download.downgrades].map(([taskId, reason]) => ({
|
|
1275
|
-
taskId,
|
|
1276
|
-
reason,
|
|
1277
|
-
})),
|
|
1278
|
-
}),
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
/**
|
|
1283
|
-
* Stands in for the executor of a task that was never placed — a group task
|
|
1284
|
-
* (runs nothing) or a persistent one (`executePersistentTask` owns it and
|
|
1285
|
-
* never reads this field). It THROWS rather than silently picking some
|
|
1286
|
-
* executor from the list, so a refactor that routes such a task through the
|
|
1287
|
-
* exec path fails loudly instead of shipping a localhost server to a worker.
|
|
1136
|
+
* The first-run case, told apart from a typo: no package in the workspace
|
|
1137
|
+
* has a `vx.config.*` at all, so no name could have resolved. Reached only
|
|
1138
|
+
* on the error path. A workspace whose package globs match NOTHING is
|
|
1139
|
+
* told that instead — `vx init` would find no package to write for.
|
|
1288
1140
|
*/
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
return executors.some((e) => e.capacity !== undefined)
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
function poolOfPlacement(
|
|
1301
|
-
placements: Placements,
|
|
1302
|
-
): (id: string) => { name: string; capacity: number } | undefined {
|
|
1303
|
-
return (id) => {
|
|
1304
|
-
const executor = placements.executors.get(id)
|
|
1305
|
-
return executor?.capacity === undefined
|
|
1306
|
-
? undefined
|
|
1307
|
-
: { name: executor.name, capacity: executor.capacity }
|
|
1141
|
+
async function initHint(prepared: {
|
|
1142
|
+
anyProjectConfig: boolean
|
|
1143
|
+
workspaceProjectCount: number
|
|
1144
|
+
workspaceRoot: string
|
|
1145
|
+
}): Promise<string> {
|
|
1146
|
+
if (prepared.anyProjectConfig) return ''
|
|
1147
|
+
if (prepared.workspaceProjectCount === 0) {
|
|
1148
|
+
return " No package matched the workspace's package globs — check `workspaces` in package.json (or pnpm-workspace.yaml)."
|
|
1308
1149
|
}
|
|
1150
|
+
// Single-project mode with packages the root's missing `workspaces`
|
|
1151
|
+
// never reaches: `vx init` would find no scripts either (item 248).
|
|
1152
|
+
const unreached = await unreachedPackages(await loadWorkspace(prepared.workspaceRoot))
|
|
1153
|
+
if (unreached.length > 0) return ` ${unreachedHint(unreached)}`
|
|
1154
|
+
return ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
|
|
1309
1155
|
}
|
|
1310
1156
|
|
|
1311
1157
|
/**
|
|
@@ -1319,37 +1165,47 @@ function didYouMean(
|
|
|
1319
1165
|
unresolved: readonly string[],
|
|
1320
1166
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
1321
1167
|
): string {
|
|
1322
|
-
const nearest = (name: string, candidates: Iterable<string>): string | undefined => {
|
|
1323
|
-
let best: string | undefined
|
|
1324
|
-
let bestD = 3
|
|
1325
|
-
for (const c of candidates) {
|
|
1326
|
-
const d = editDistance(name, c)
|
|
1327
|
-
if (d < bestD) {
|
|
1328
|
-
bestD = d
|
|
1329
|
-
best = c
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
return best === name ? undefined : best
|
|
1333
|
-
}
|
|
1334
1168
|
const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
|
|
1335
1169
|
const allTasks = new Set<string>()
|
|
1336
1170
|
for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
|
|
1337
|
-
|
|
1171
|
+
// A Set: two typos of the same task hint it once, not once per typo.
|
|
1172
|
+
const hints = new Set<string>()
|
|
1338
1173
|
for (const spec of unresolved) {
|
|
1339
1174
|
const at = spec.indexOf('#')
|
|
1340
1175
|
if (at < 0) {
|
|
1341
1176
|
const t = nearest(spec, allTasks)
|
|
1342
|
-
if (t !== undefined) hints.
|
|
1177
|
+
if (t !== undefined) hints.add(t)
|
|
1343
1178
|
continue
|
|
1344
1179
|
}
|
|
1345
1180
|
const [proj, task] = [spec.slice(0, at), spec.slice(at + 1)]
|
|
1346
1181
|
if (!projects.has(proj)) {
|
|
1347
1182
|
const p = nearest(proj, projects.keys())
|
|
1348
|
-
if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.
|
|
1183
|
+
if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.add(`${p}#${task}`)
|
|
1349
1184
|
continue
|
|
1350
1185
|
}
|
|
1351
1186
|
const t = nearest(task, tasksOf(projects.get(proj)))
|
|
1352
|
-
if (t !== undefined) hints.
|
|
1187
|
+
if (t !== undefined) hints.add(`${proj}#${t}`)
|
|
1188
|
+
}
|
|
1189
|
+
return hints.size === 0 ? '' : ` Did you mean ${[...hints].join(', ')}?`
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** The executed, keyed outcomes of a run — what flakiness is judged on. */
|
|
1193
|
+
function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
|
|
1194
|
+
const out: FlakyCandidate[] = []
|
|
1195
|
+
for (const o of outcomes) {
|
|
1196
|
+
if (o.status !== 'success' && o.status !== 'failed') continue
|
|
1197
|
+
// "Same inputs, different outcome" is a claim only a task with declared
|
|
1198
|
+
// inputs can make: a task with no `cache` block keys on its config alone
|
|
1199
|
+
// and runs every time, so one bad network day would read as a flake for
|
|
1200
|
+
// thirty days. Groups do no work.
|
|
1201
|
+
if (o.hash === undefined || o.node.config.cache === undefined || isGroupTask(o.node)) continue
|
|
1202
|
+
out.push({
|
|
1203
|
+
project: o.node.projectName,
|
|
1204
|
+
task: o.node.taskName,
|
|
1205
|
+
hash: o.hash,
|
|
1206
|
+
status: o.status,
|
|
1207
|
+
attempts: o.attempts ?? 1,
|
|
1208
|
+
})
|
|
1353
1209
|
}
|
|
1354
|
-
return
|
|
1210
|
+
return out
|
|
1355
1211
|
}
|