@vzn/vx 0.0.645 → 0.0.648
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -50
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +326 -65
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +65 -4
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +282 -0
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- package/src/cli/foreign-flags.ts +0 -727
package/src/orchestrator/run.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
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 type { InputStability } from '../config.js'
|
|
5
6
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
6
|
-
import { inputStabilityEnv, LOCK_INVALID, lockEntryBroken } from '../workspace/index.js'
|
|
7
|
+
import { EMPTY_LOCK, inputStabilityEnv, LOCK_INVALID, lockEntryBroken } from '../workspace/index.js'
|
|
7
8
|
import { realpathSync } from 'node:fs'
|
|
8
9
|
import path from 'node:path'
|
|
9
10
|
import {
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
} from '../cache/index.js'
|
|
18
19
|
import { VERSION } from '../version.js'
|
|
19
20
|
import {
|
|
21
|
+
hostPlatform,
|
|
20
22
|
resetSandbox,
|
|
21
23
|
signalExitCode,
|
|
22
24
|
VX_RUN_TASK_ENV,
|
|
@@ -24,13 +26,14 @@ import {
|
|
|
24
26
|
} from '../exec/index.js'
|
|
25
27
|
import { DeferredOutputs } from './deferred-outputs.js'
|
|
26
28
|
import { resolveDownloadModes } from './download-policy.js'
|
|
27
|
-
import type { TaskExecutor } from '../exec/index.js'
|
|
29
|
+
import type { MachineReport, TaskExecutor } from '../exec/index.js'
|
|
28
30
|
import {
|
|
29
31
|
deadServerBehind,
|
|
30
32
|
declaredTask,
|
|
31
33
|
isGroupTask,
|
|
32
34
|
markSurfacedDeps,
|
|
33
35
|
runGraph,
|
|
36
|
+
taskDefinitions,
|
|
34
37
|
type TaskNode,
|
|
35
38
|
type TaskOutcome,
|
|
36
39
|
} from '../graph/index.js'
|
|
@@ -51,16 +54,18 @@ import {
|
|
|
51
54
|
detectColors,
|
|
52
55
|
type ColorSupport,
|
|
53
56
|
SecretBroker,
|
|
57
|
+
thrownMessage,
|
|
58
|
+
type ui,
|
|
54
59
|
} from '../util/index.js'
|
|
55
60
|
import { keyedProjects } from './keyed-projects.js'
|
|
56
|
-
import { isDefaultBuild } from '
|
|
61
|
+
import { isDefaultBuild } from '../graph/index.js'
|
|
57
62
|
import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
|
|
58
63
|
import { recordOutputDirSnapshots, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
|
|
59
64
|
import { admitTasks, taintTracker } from './admission.js'
|
|
60
|
-
import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
|
|
65
|
+
import { settleProbedHit, unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
|
|
61
66
|
import { executeArgsOf, type RunScope } from './execute-args.js'
|
|
62
67
|
import { excludedTaint } from './excluded-keys.js'
|
|
63
|
-
import { keyUpstream } from './upstream.js'
|
|
68
|
+
import { dependedOn, keyUpstream } from './upstream.js'
|
|
64
69
|
import {
|
|
65
70
|
busLogger,
|
|
66
71
|
createEventBus,
|
|
@@ -79,9 +84,16 @@ import {
|
|
|
79
84
|
} from '../report/index.js'
|
|
80
85
|
import { installPlugins, PluginSetupError, type RunReport } from './plugin.js'
|
|
81
86
|
import { keepsFailures, reportOf, RunKeeper } from './run-report.js'
|
|
82
|
-
import {
|
|
87
|
+
import {
|
|
88
|
+
buildAdmission,
|
|
89
|
+
executorDemand,
|
|
90
|
+
executorLabel,
|
|
91
|
+
resolveExecutors,
|
|
92
|
+
teardownPlugins,
|
|
93
|
+
} from './plugin-host.js'
|
|
83
94
|
import { createSteps } from './steps.js'
|
|
84
|
-
import {
|
|
95
|
+
import { liveSocketOf, serveLive } from './run-live.js'
|
|
96
|
+
import { writeRunSteps, writeRunWarnings } from './run-steps.js'
|
|
85
97
|
import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
86
98
|
import { claimOutput, guardedOutput } from './output-host.js'
|
|
87
99
|
import { assembleRunSummary } from './telemetry.js'
|
|
@@ -89,14 +101,15 @@ import { isPassStatus } from '../graph/index.js'
|
|
|
89
101
|
import type { RunContextRecord } from './telemetry.js'
|
|
90
102
|
import { plan, type RunPlan } from './plan.js'
|
|
91
103
|
import type { AffectedReason } from './affected-tasks.js'
|
|
92
|
-
import { openRemotes, prepareRun, type PreparedRun } from './prepare.js'
|
|
104
|
+
import { namesRemote, openRemotes, prepareRun, type PreparedRun } from './prepare.js'
|
|
93
105
|
import { acquireRunLock } from './run-lock.js'
|
|
94
106
|
import { terminateChildren } from './signals.js'
|
|
95
|
-
import { runStop } from './run-stop.js'
|
|
107
|
+
import { CYCLE_INTERRUPT, runStop } from './run-stop.js'
|
|
96
108
|
import {
|
|
97
109
|
hasPooledExecutor,
|
|
98
110
|
placeTasks,
|
|
99
111
|
planExecutorOf,
|
|
112
|
+
platformVerdict,
|
|
100
113
|
poolOfPlacement,
|
|
101
114
|
terminalHolders,
|
|
102
115
|
locallyPlaced,
|
|
@@ -114,6 +127,7 @@ import {
|
|
|
114
127
|
type ShortCircuit,
|
|
115
128
|
} from './local-shortcircuit.js'
|
|
116
129
|
import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
|
|
130
|
+
import { askRemote, serversToHold } from './held-servers.js'
|
|
117
131
|
|
|
118
132
|
import { writeRunFailures } from './run-failures.js'
|
|
119
133
|
import { assembleRunRecords, recordLateRemoteErrors } from './run-records.js'
|
|
@@ -125,6 +139,7 @@ import {
|
|
|
125
139
|
sessionEnd,
|
|
126
140
|
takeHeldServers,
|
|
127
141
|
} from './persistent.js'
|
|
142
|
+
import { serverKey } from './persistent-task.js'
|
|
128
143
|
import { writeRunProfile } from './run-artifacts.js'
|
|
129
144
|
import { createSaveLane } from './save-lane.js'
|
|
130
145
|
import { commandWindows } from './command-windows.js'
|
|
@@ -136,6 +151,7 @@ import {
|
|
|
136
151
|
} from '../history/index.js'
|
|
137
152
|
import type { RunOptions, RunSummary } from './options.js'
|
|
138
153
|
import { withRunSettings } from './run-settings.js'
|
|
154
|
+
import { cacheFacts, taskArtifacts } from './task-artifacts.js'
|
|
139
155
|
import { resolveSetting } from '../settings.js'
|
|
140
156
|
|
|
141
157
|
// Per run, never shared: a `vx watch` process runs many, and a shared map
|
|
@@ -145,6 +161,7 @@ const emptyShortCircuit = (): ShortCircuit => ({
|
|
|
145
161
|
restoreTier: new Set(),
|
|
146
162
|
uncachedKeys: new Map(),
|
|
147
163
|
groupKeys: new Map(),
|
|
164
|
+
remoteAsk: new Map(),
|
|
148
165
|
})
|
|
149
166
|
|
|
150
167
|
/**
|
|
@@ -184,18 +201,37 @@ export function shouldShortCircuit(nodes: Map<string, TaskNode>, policy: CachePo
|
|
|
184
201
|
return nodes.size > 0
|
|
185
202
|
}
|
|
186
203
|
|
|
187
|
-
|
|
188
|
-
|
|
204
|
+
/**
|
|
205
|
+
* The run's own secrets: vx's environment and every selected task's
|
|
206
|
+
* `define` and `exec.env.secret`, as each task's output masks them. With
|
|
207
|
+
* the environment's alone, a secret only a config names reached the stored
|
|
208
|
+
* line `vx last` prints in full.
|
|
209
|
+
*/
|
|
210
|
+
function invocationMask(nodes: Iterable<TaskNode>): (command: string) => string {
|
|
211
|
+
const sources: Array<Readonly<Record<string, string>>> = [process.env as Record<string, string>]
|
|
212
|
+
const named = new Set<string>()
|
|
213
|
+
for (const n of nodes) {
|
|
214
|
+
const env = n.config.exec?.env
|
|
215
|
+
if (env?.define !== undefined) sources.push(env.define)
|
|
216
|
+
for (const name of env?.secret ?? []) named.add(name)
|
|
217
|
+
}
|
|
218
|
+
const mask = secretMask(sources, [...named])
|
|
219
|
+
return (command) => mask?.mask(command) ?? command
|
|
189
220
|
}
|
|
190
221
|
|
|
191
222
|
/**
|
|
192
223
|
* The command line a run hands to its telemetry sinks, with what follows
|
|
193
224
|
* `--` counted, not quoted. Those are the task's arguments,
|
|
194
|
-
* often a token (`-- --token=…`), and the line reached an OTLP span
|
|
195
|
-
* GitHub job summary
|
|
196
|
-
* 1057). Local history (`vx last`) keeps the whole line, on this machine.
|
|
225
|
+
* often a token (`-- --token=…`), and the line reached an OTLP span and
|
|
226
|
+
* the GitHub job summary verbatim (item 1057). Local history (`vx last`) keeps the whole line, on this machine.
|
|
197
227
|
*/
|
|
198
|
-
export function invocationCommand(
|
|
228
|
+
export function invocationCommand(line: readonly string[] | string): string {
|
|
229
|
+
// A line is shell-quoted (the CLI's): its words as written, so `'a b'`
|
|
230
|
+
// counts one argument, not the two a split at blanks made.
|
|
231
|
+
const argv =
|
|
232
|
+
typeof line === 'string'
|
|
233
|
+
? (line.match(/(?:[^\s'"\\]|\\.|'[^']*'|"(?:[^"\\]|\\.)*")+/g) ?? [])
|
|
234
|
+
: line
|
|
199
235
|
const sep = argv.indexOf('--')
|
|
200
236
|
if (sep === -1) return argv.join(' ')
|
|
201
237
|
const rest = argv.length - sep - 1
|
|
@@ -309,8 +345,14 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
309
345
|
// bytes as written, as the cache holds them: it formats its own output.
|
|
310
346
|
const render = terminalSubscriber(terminal === null || colors.enabled ? sink : plainOutput(sink))
|
|
311
347
|
let unsubscribeTerminal = bus.subscribe(render)
|
|
348
|
+
// What the run warned of, no task's: the footer repeats it (`warn`).
|
|
349
|
+
const warned: string[] = []
|
|
350
|
+
const unsubscribeWarned = bus.subscribe((e) => {
|
|
351
|
+
if (e.kind === 'run:status' && 'warn' in e) warned.push(e.line)
|
|
352
|
+
})
|
|
312
353
|
const detach = (): void => {
|
|
313
354
|
unsubscribeTerminal()
|
|
355
|
+
unsubscribeWarned()
|
|
314
356
|
terminal?.settle()
|
|
315
357
|
}
|
|
316
358
|
// A plugin's `output` renders the run from its start; core's renderer
|
|
@@ -329,7 +371,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
329
371
|
// vx's own steps are timed only where something renders them.
|
|
330
372
|
const showsSteps =
|
|
331
373
|
terminal !== null ? terminal.showsSteps : options.log?.stepStart !== undefined
|
|
332
|
-
const summary = await runOnBus(options, bus, colors, terminal, showsSteps, takeOutput)
|
|
374
|
+
const summary = await runOnBus(options, bus, warned, colors, terminal, showsSteps, takeOutput)
|
|
333
375
|
if (summary.persistent === undefined) return summary
|
|
334
376
|
// Servers handed back still running still write: `vx watch dev` printed
|
|
335
377
|
// none of its server's log while it idled, the renderer gone with this
|
|
@@ -351,6 +393,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
351
393
|
async function runOnBus(
|
|
352
394
|
options: RunOptions,
|
|
353
395
|
bus: EventBus,
|
|
396
|
+
warned: readonly string[],
|
|
354
397
|
colors: ColorSupport,
|
|
355
398
|
terminal: DefaultLogger | null,
|
|
356
399
|
showsSteps: boolean,
|
|
@@ -373,7 +416,6 @@ async function runOnBus(
|
|
|
373
416
|
}
|
|
374
417
|
|
|
375
418
|
const prepared = await step('vx:load', () => prepareRun(options, log))
|
|
376
|
-
options = withRunSettings(options, prepared.workspaceConfig)
|
|
377
419
|
mark('plugin stages')
|
|
378
420
|
// Every exit from here tears the plugins down: their capability
|
|
379
421
|
// factories ran in prepareRun, and teardown is what releases what those
|
|
@@ -388,6 +430,27 @@ async function runOnBus(
|
|
|
388
430
|
const plugins = prepared.plugins.filter((p) => p !== except)
|
|
389
431
|
await teardownPlugins(plugins, warnOf(log), report, step)
|
|
390
432
|
}
|
|
433
|
+
// The setting variables are read before any task, so a bad value is
|
|
434
|
+
// refused here, not mid-run; an embedder's (the CLI refuses it first)
|
|
435
|
+
// threw past the teardown and the cache close.
|
|
436
|
+
let inputStability: { run: InputStability; workspace?: InputStability }
|
|
437
|
+
let cleanOutputs: boolean | undefined
|
|
438
|
+
try {
|
|
439
|
+
options = withRunSettings(options, prepared.workspaceConfig)
|
|
440
|
+
inputStability = {
|
|
441
|
+
run: inputStabilityEnv(),
|
|
442
|
+
...(prepared.workspaceConfig?.cache?.inputStability !== undefined
|
|
443
|
+
? { workspace: prepared.workspaceConfig.cache.inputStability }
|
|
444
|
+
: {}),
|
|
445
|
+
}
|
|
446
|
+
cleanOutputs = resolveSetting('cache.outputs.clean', prepared.workspaceConfig).value as
|
|
447
|
+
| boolean
|
|
448
|
+
| undefined
|
|
449
|
+
} catch (err) {
|
|
450
|
+
await teardown()
|
|
451
|
+
prepared.cache.close()
|
|
452
|
+
throw err
|
|
453
|
+
}
|
|
391
454
|
// A task whose command re-enters `vx run` in the workspace running it
|
|
392
455
|
// is refused. When the inner run reaches this task again it forks a run
|
|
393
456
|
// per run until the machine gives out; when it does not (`ci` shelling
|
|
@@ -397,7 +460,8 @@ async function runOnBus(
|
|
|
397
460
|
// name the task; the check is on the root so both shapes are caught.
|
|
398
461
|
// Compared canonical: a root reached through a symlink (`run({ cwd })`,
|
|
399
462
|
// macOS's /var/folders) and the inner vx's realpath'd cwd are one tree.
|
|
400
|
-
|
|
463
|
+
// Empty is unset: resolved, '' named the cwd.
|
|
464
|
+
const outerRoot = process.env[VX_RUN_WORKSPACE_ENV] || undefined
|
|
401
465
|
if (outerRoot !== undefined && canonical(outerRoot) === canonical(prepared.workspaceRoot)) {
|
|
402
466
|
await teardown()
|
|
403
467
|
prepared.cache.close()
|
|
@@ -513,7 +577,6 @@ async function runOnBus(
|
|
|
513
577
|
}
|
|
514
578
|
const {
|
|
515
579
|
workspaceRoot,
|
|
516
|
-
workspaceConfig,
|
|
517
580
|
cacheDir,
|
|
518
581
|
cache,
|
|
519
582
|
nodes,
|
|
@@ -523,12 +586,14 @@ async function runOnBus(
|
|
|
523
586
|
hashCache,
|
|
524
587
|
workspaceProjectCount,
|
|
525
588
|
} = prepared
|
|
589
|
+
const maskInvocation = invocationMask(nodes.values())
|
|
526
590
|
const concurrency = options.concurrency ?? machineParallelism()
|
|
527
591
|
// An earlier cycle's servers: the ones this graph starts again unchanged
|
|
528
592
|
// stay up; the others go now, before anything binds their ports. A stop
|
|
529
593
|
// that lands meanwhile ends the run here, having run nothing: an aborted
|
|
530
594
|
// graph printed a `not run` row and a footer above `stopped` (WD-22).
|
|
531
595
|
const heldBefore = takeHeldServers(options.keep?.servers, nodes, options.forwardArgs)
|
|
596
|
+
for (const server of heldBefore.kept.values()) server.out.bus = bus
|
|
532
597
|
if (heldBefore.stale.length > 0) {
|
|
533
598
|
await terminateChildren(() => heldBefore.stale)
|
|
534
599
|
if (options.signal?.aborted === true) {
|
|
@@ -547,6 +612,8 @@ async function runOnBus(
|
|
|
547
612
|
cacheDir: prepared.cacheDir,
|
|
548
613
|
warn: warnOf(log),
|
|
549
614
|
concurrency,
|
|
615
|
+
artifacts: taskArtifacts(prepared.cache, prepared.workspaceRoot),
|
|
616
|
+
cacheFacts: () => cacheFacts(prepared.remotes, prepared.cacheRefs),
|
|
550
617
|
}),
|
|
551
618
|
)
|
|
552
619
|
// Placement: decided ONCE per task, before scheduling, so the scheduler
|
|
@@ -572,8 +639,29 @@ async function runOnBus(
|
|
|
572
639
|
warnOf(log)(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
|
|
573
640
|
}
|
|
574
641
|
}
|
|
575
|
-
|
|
576
|
-
|
|
642
|
+
const platforms = platformVerdict(placements, ciContext.ci)
|
|
643
|
+
if (platforms !== undefined && 'refuse' in platforms) {
|
|
644
|
+
await abandoning(() => {
|
|
645
|
+
throw new UserError(platforms.refuse)
|
|
646
|
+
})
|
|
647
|
+
}
|
|
648
|
+
if (platforms !== undefined && 'warn' in platforms) warnOf(log)(platforms.warn)
|
|
649
|
+
/** A task its `exec.platforms` skip here: it never starts, and nor does what depends on it. */
|
|
650
|
+
const platformSkip = (node: TaskNode): TaskOutcome | undefined => {
|
|
651
|
+
const needs = placements.otherPlatform.get(node.id)
|
|
652
|
+
return needs === undefined
|
|
653
|
+
? undefined
|
|
654
|
+
: {
|
|
655
|
+
node,
|
|
656
|
+
status: 'skipped',
|
|
657
|
+
exitCode: 0,
|
|
658
|
+
durationMs: 0,
|
|
659
|
+
otherPlatform: { needs, host: hostPlatform() },
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// Demand: what each executor still has left to run (`executorDemand`),
|
|
663
|
+
// told once the cache has said what it answers.
|
|
664
|
+
const demand = executorDemand(placements.executors, warnOf(log))
|
|
577
665
|
|
|
578
666
|
// `cache.download` (default `all`) decides ONCE per task, here, whether a
|
|
579
667
|
// remote execution's outputs come home.
|
|
@@ -588,19 +676,11 @@ async function runOnBus(
|
|
|
588
676
|
|
|
589
677
|
// `run.timeout` (ms), for any task WITHOUT its own `exec.timeout`.
|
|
590
678
|
const taskTimeoutDefault = options.timeout
|
|
591
|
-
// Read once, before any task: a bad value is refused here, not mid-run.
|
|
592
|
-
const inputStability = {
|
|
593
|
-
run: inputStabilityEnv(),
|
|
594
|
-
...(workspaceConfig?.cache?.inputStability !== undefined
|
|
595
|
-
? { workspace: workspaceConfig.cache.inputStability }
|
|
596
|
-
: {}),
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
const cleanOutputs = resolveSetting('cache.outputs.clean', workspaceConfig).value as
|
|
600
|
-
| boolean
|
|
601
|
-
| undefined
|
|
602
679
|
|
|
680
|
+
// The held servers this run took over, spared by a `CYCLE_INTERRUPT`.
|
|
681
|
+
const handed = new Set<ReturnType<typeof Bun.spawn>>()
|
|
603
682
|
const stop = runStop({
|
|
683
|
+
handed: (child) => handed.has(child),
|
|
604
684
|
log,
|
|
605
685
|
cache,
|
|
606
686
|
external: options.signal,
|
|
@@ -624,11 +704,13 @@ async function runOnBus(
|
|
|
624
704
|
// because the normal path closes before the persistent-task wait —
|
|
625
705
|
// holding the handle open for a dev server's whole lifetime would be
|
|
626
706
|
// worse.
|
|
707
|
+
let live: ReturnType<typeof serveLive> | undefined
|
|
627
708
|
let cacheClosed = false
|
|
628
709
|
const closeCache = async (): Promise<void> => {
|
|
629
710
|
if (cacheClosed) return
|
|
630
711
|
cacheClosed = true
|
|
631
712
|
cache.close()
|
|
713
|
+
live?.unlisten()
|
|
632
714
|
// Released with the handle: before a persistent task's wait, on every
|
|
633
715
|
// exit path (see run-lock.ts for why a run holds one at all). AWAITED:
|
|
634
716
|
// fired and forgotten, the release lost the race with the CLI's exit —
|
|
@@ -648,6 +730,16 @@ async function runOnBus(
|
|
|
648
730
|
}),
|
|
649
731
|
),
|
|
650
732
|
)
|
|
733
|
+
// Answers `vx last --live` while the lock is held (run-live.ts).
|
|
734
|
+
live = serveLive({
|
|
735
|
+
socketPath: liveSocketOf(releaseRunLock.path),
|
|
736
|
+
bus,
|
|
737
|
+
runId,
|
|
738
|
+
command: maskInvocation(invocationCommand(options.command ?? process.argv.slice(1))),
|
|
739
|
+
startedAt: Date.now(),
|
|
740
|
+
nodes: () => nodes.values(),
|
|
741
|
+
warnings: () => warned,
|
|
742
|
+
})
|
|
651
743
|
try {
|
|
652
744
|
const runStartHrTimeNs = process.hrtime.bigint()
|
|
653
745
|
const endedAtMsAtStart = Date.now()
|
|
@@ -660,9 +752,17 @@ async function runOnBus(
|
|
|
660
752
|
// clean their outputs before every exec for a save that never
|
|
661
753
|
// happened.
|
|
662
754
|
const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, remoteCacheEnabled)
|
|
663
|
-
|
|
755
|
+
// The workspace's `cache.policy` asks as a flag does: it was the one source never told.
|
|
756
|
+
const remoteNamed =
|
|
757
|
+
options.remoteRequested ??
|
|
758
|
+
(options.cache === undefined &&
|
|
759
|
+
options.cacheFlags?.noCache !== true &&
|
|
760
|
+
namesRemote(prepared.workspaceConfig?.cache?.policy)
|
|
761
|
+
? 'cache.policy'
|
|
762
|
+
: undefined)
|
|
763
|
+
if (remoteNamed !== undefined && !prepared.hasRemoteLayer) {
|
|
664
764
|
warnOf(log)(
|
|
665
|
-
|
|
765
|
+
`vx: ${remoteNamed} named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run`,
|
|
666
766
|
)
|
|
667
767
|
}
|
|
668
768
|
const deferredOutputs = new DeferredOutputs({
|
|
@@ -710,9 +810,7 @@ async function runOnBus(
|
|
|
710
810
|
// token after `--` to every sink verbatim (C-67).
|
|
711
811
|
// Masked as the stored line is: a flag's secret value sits before
|
|
712
812
|
// the `--` the count covers (L-44).
|
|
713
|
-
command: maskInvocation(
|
|
714
|
-
invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
|
|
715
|
-
),
|
|
813
|
+
command: maskInvocation(invocationCommand(options.command ?? process.argv.slice(1))),
|
|
716
814
|
requestedTasks: options.tasks,
|
|
717
815
|
cachePolicy: formatCachePolicy(policy),
|
|
718
816
|
concurrency,
|
|
@@ -744,10 +842,19 @@ async function runOnBus(
|
|
|
744
842
|
n.config.exec?.persistent !== undefined ||
|
|
745
843
|
n.config.cache === undefined ||
|
|
746
844
|
!(policy.localRead || policy.remoteRead)
|
|
845
|
+
// Only what runs here arms this machine's sandbox: a `sandbox` executor
|
|
846
|
+
// enforces it where it runs the task, so a distributed run's
|
|
847
|
+
// orchestrator needs none (2026-10-22).
|
|
747
848
|
const sandboxed =
|
|
748
849
|
sandboxArmer === null
|
|
749
850
|
? []
|
|
750
|
-
: [...nodes.values()].filter(
|
|
851
|
+
: [...nodes.values()].filter(
|
|
852
|
+
(n) =>
|
|
853
|
+
n.config.exec?.sandbox !== undefined &&
|
|
854
|
+
(n.config.exec.persistent === undefined
|
|
855
|
+
? localPlaced.has(n.id)
|
|
856
|
+
: !placements.carried.has(n.id)),
|
|
857
|
+
)
|
|
751
858
|
let prewarming: Promise<void> | undefined
|
|
752
859
|
const prewarm = (): void => {
|
|
753
860
|
prewarming ??= sandboxArmer?.arm().catch(() => {})
|
|
@@ -800,7 +907,19 @@ async function runOnBus(
|
|
|
800
907
|
|
|
801
908
|
// Run context for the footer. The top-of-run header is gone — the
|
|
802
909
|
// banner now lives in the summary, where the eye lands at the end.
|
|
910
|
+
const fault = executorFaults(warnOf(log))
|
|
911
|
+
const footers = executors.filter((e) => e.footer !== undefined)
|
|
912
|
+
const placers = executors.filter((e) => e.running !== undefined)
|
|
803
913
|
const runContext = {
|
|
914
|
+
...(footers.length > 0
|
|
915
|
+
? { executorRows: () => footers.flatMap((e) => executorFooter(e, fault)) }
|
|
916
|
+
: {}),
|
|
917
|
+
...(placers.length > 0
|
|
918
|
+
? {
|
|
919
|
+
whereRunning: (id: string) =>
|
|
920
|
+
placers.map((e) => executorWhere(e, id, fault)).find((w) => w !== undefined),
|
|
921
|
+
}
|
|
922
|
+
: {}),
|
|
804
923
|
version: VERSION,
|
|
805
924
|
packageCount: packagesInScope.size,
|
|
806
925
|
remoteCacheEnabled,
|
|
@@ -816,7 +935,9 @@ async function runOnBus(
|
|
|
816
935
|
},
|
|
817
936
|
concurrency,
|
|
818
937
|
workspaceProjectCount,
|
|
819
|
-
|
|
938
|
+
// Live too: a long run is read while it runs (2026-10-21).
|
|
939
|
+
runId,
|
|
940
|
+
warnings: () => warned,
|
|
820
941
|
}
|
|
821
942
|
|
|
822
943
|
// Asked only when a plugin declares `output`: a plain run pays nothing.
|
|
@@ -854,6 +975,7 @@ async function runOnBus(
|
|
|
854
975
|
prepared.localCache.dbHandle(),
|
|
855
976
|
undefined,
|
|
856
977
|
prepared.branch(),
|
|
978
|
+
taskDefinitions(nodes.values()),
|
|
857
979
|
).p50sFor(ids)
|
|
858
980
|
return createForecast(nodes, p50s, concurrency)
|
|
859
981
|
},
|
|
@@ -867,9 +989,20 @@ async function runOnBus(
|
|
|
867
989
|
// local-only runs never reach here, so their behavior + perf is
|
|
868
990
|
// unchanged (no upfront key pass, no local probing).
|
|
869
991
|
let prefetchDone: Promise<void> = Promise.resolve()
|
|
992
|
+
/** `--profile`'s run events beside the tasks: the cache probe's. */
|
|
993
|
+
const runSpans: Record<string, unknown>[] = []
|
|
994
|
+
// A remote that is read answers which tasks it holds: demand settles
|
|
995
|
+
// on that answer, a failed prefetch settling it on none.
|
|
996
|
+
const prefetchSettles = prepared.hasRemoteLayer && policy.remoteRead
|
|
870
997
|
if (prepared.hasRemoteLayer) {
|
|
871
998
|
prefetchDone = steps.background('vx:prefetch', () =>
|
|
872
999
|
startRemotePrefetch({
|
|
1000
|
+
...(demand.any
|
|
1001
|
+
? {
|
|
1002
|
+
held: (ids: readonly string[], missed: readonly string[]) =>
|
|
1003
|
+
demand.settle(ids, missed),
|
|
1004
|
+
}
|
|
1005
|
+
: {}),
|
|
873
1006
|
nodes,
|
|
874
1007
|
cache,
|
|
875
1008
|
workspaceRoot,
|
|
@@ -881,6 +1014,7 @@ async function runOnBus(
|
|
|
881
1014
|
concurrency: options.downloadConcurrency ?? networkConcurrency(),
|
|
882
1015
|
remoteRead: policy.remoteRead,
|
|
883
1016
|
remoteWrite: policy.remoteWrite,
|
|
1017
|
+
spans: options.profile === undefined ? undefined : runSpans,
|
|
884
1018
|
}),
|
|
885
1019
|
)
|
|
886
1020
|
}
|
|
@@ -902,10 +1036,19 @@ async function runOnBus(
|
|
|
902
1036
|
shouldShortCircuit(nodes, policy) && (remote || cache === prepared.localCache)
|
|
903
1037
|
? prepared.localCache.holdsNothing()
|
|
904
1038
|
: false
|
|
1039
|
+
// Behind a remote, a server's dependants need their keys up front even
|
|
1040
|
+
// on an empty store: the remote may hold them all, and then the server
|
|
1041
|
+
// never starts (`held-servers.ts`).
|
|
1042
|
+
const askForServers =
|
|
1043
|
+
remote &&
|
|
1044
|
+
policy.remoteRead &&
|
|
1045
|
+
[...nodes.values()].some(
|
|
1046
|
+
(n) => n.config.exec?.persistent !== undefined && !n.requested && n.surfaced !== true,
|
|
1047
|
+
)
|
|
905
1048
|
// A remote may still hold what an empty local store does not: the
|
|
906
1049
|
// prefetch asks it, and no confirmed miss warms the sandbox early.
|
|
907
|
-
if (coldLocal && !remote) prewarm()
|
|
908
|
-
else if (!coldLocal && shouldShortCircuit(nodes, policy)) {
|
|
1050
|
+
if (coldLocal && !remote && sandboxed.length > 0) prewarm()
|
|
1051
|
+
else if ((!coldLocal || askForServers) && shouldShortCircuit(nodes, policy)) {
|
|
909
1052
|
shortCircuit = await step('vx:keys', () =>
|
|
910
1053
|
startLocalShortCircuit({
|
|
911
1054
|
nodes,
|
|
@@ -926,6 +1069,19 @@ async function runOnBus(
|
|
|
926
1069
|
if (!remote && sandboxed.some((n) => shortCircuit.preProbed.get(n.id)?.hit === null))
|
|
927
1070
|
prewarm()
|
|
928
1071
|
}
|
|
1072
|
+
if (demand.any) {
|
|
1073
|
+
if (prefetchSettles) void prefetchDone.finally(() => demand.settle([]))
|
|
1074
|
+
else {
|
|
1075
|
+
const hits: string[] = []
|
|
1076
|
+
const missed: string[] = []
|
|
1077
|
+
for (const [id, probe] of shortCircuit.preProbed)
|
|
1078
|
+
(probe.hit !== null ? hits : missed).push(id)
|
|
1079
|
+
// An empty store was not asked: it holds none of them.
|
|
1080
|
+
if (coldLocal && !remote)
|
|
1081
|
+
for (const [id, n] of nodes) if (n.config.cache !== undefined) missed.push(id)
|
|
1082
|
+
demand.settle(hits, missed)
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
929
1085
|
|
|
930
1086
|
// Whether this task runs behind a failure (`continueMode: 'always'`
|
|
931
1087
|
// only) or on the key of a dependency `--exclude-dependencies` kept
|
|
@@ -951,8 +1107,7 @@ async function runOnBus(
|
|
|
951
1107
|
const taint = taintTracker(options.continueMode === 'always', taintSeeds, nodes)
|
|
952
1108
|
// Each task that ran tainted, and the task at the root of it.
|
|
953
1109
|
const taintedRan = new Map<string, string>()
|
|
954
|
-
const
|
|
955
|
-
for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
|
|
1110
|
+
const depended = dependedOn(nodes.values())
|
|
956
1111
|
|
|
957
1112
|
// Why a task missed, for a sink: a read of the history per miss, so only
|
|
958
1113
|
// when a sink exists.
|
|
@@ -992,13 +1147,14 @@ async function runOnBus(
|
|
|
992
1147
|
if (explainMiss !== undefined) scope.explainMiss = explainMiss
|
|
993
1148
|
if (keepRun !== undefined) scope.keepRun = keepRun
|
|
994
1149
|
if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
|
|
1150
|
+
if (demand.any) scope.executing = demand.executing
|
|
995
1151
|
const argsOf = executeArgsOf(scope, {
|
|
996
1152
|
log: (node) => (node.config.exec?.persistent !== undefined ? serverLog(node.id) : log),
|
|
997
1153
|
placements,
|
|
998
1154
|
nestedDirsByProject,
|
|
999
1155
|
downloadModeOf: download.modeOf,
|
|
1000
1156
|
shortCircuit,
|
|
1001
|
-
dependedOn,
|
|
1157
|
+
dependedOn: depended,
|
|
1002
1158
|
holders,
|
|
1003
1159
|
})
|
|
1004
1160
|
const buildExecuteArgs = (
|
|
@@ -1078,6 +1234,7 @@ async function runOnBus(
|
|
|
1078
1234
|
heldBefore.kept.delete(node.id)
|
|
1079
1235
|
if (hasEnded(server.child)) return undefined
|
|
1080
1236
|
server.handOver()
|
|
1237
|
+
handed.add(server.child)
|
|
1081
1238
|
server.out.bus = bus
|
|
1082
1239
|
serverOuts.set(node.id, server.out)
|
|
1083
1240
|
persistentRegistry.set(node.id, server.child)
|
|
@@ -1113,7 +1270,14 @@ async function runOnBus(
|
|
|
1113
1270
|
judgeFlaky(o)
|
|
1114
1271
|
keeper?.taskDone(o)
|
|
1115
1272
|
log.taskComplete(o.node, o)
|
|
1116
|
-
|
|
1273
|
+
demand.finished(o.node.id)
|
|
1274
|
+
// A server that dies while the graph runs is no longer running:
|
|
1275
|
+
// its pin stayed `running` under the line naming its exit.
|
|
1276
|
+
const server = o.status === 'success' ? persistentRegistry.get(o.node.id) : undefined
|
|
1277
|
+
if (server !== undefined && o.serverExit === undefined)
|
|
1278
|
+
void server.exited.then((code) => {
|
|
1279
|
+
if (code !== 0 && !graphOver && !stop.signal.aborted) o.serverExit = code
|
|
1280
|
+
})
|
|
1117
1281
|
},
|
|
1118
1282
|
// A crash a hand-broken lock entry caused is named as that; asked
|
|
1119
1283
|
// only here, so a run that does not crash pays nothing.
|
|
@@ -1125,24 +1289,36 @@ async function runOnBus(
|
|
|
1125
1289
|
: line,
|
|
1126
1290
|
),
|
|
1127
1291
|
execute: (node, upstream) => {
|
|
1292
|
+
const skip = platformSkip(node)
|
|
1293
|
+
if (skip !== undefined) return Promise.resolve(skip)
|
|
1128
1294
|
const kept = keepServer(node)
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1295
|
+
if (kept === undefined) return executeWithDedup(node, keyUpstream(node, upstream))
|
|
1296
|
+
// Keyed as a started server is: a cached dependant folds its key,
|
|
1297
|
+
// and without it an edit to the server's sources hit stale.
|
|
1298
|
+
return Promise.resolve(
|
|
1299
|
+
serverKey(argsOf(node, keyUpstream(node, upstream), true, false)),
|
|
1300
|
+
).then((hash) => (hash === undefined ? kept : { ...kept, hash }))
|
|
1132
1301
|
},
|
|
1133
|
-
// An unkeyed group runs nothing
|
|
1134
|
-
//
|
|
1302
|
+
// An unkeyed group runs nothing, and a restore-tier hit whose tree is
|
|
1303
|
+
// current restores nothing: settled in place, unless a taint may ride
|
|
1304
|
+
// it, which `buildExecuteArgs` records on dispatch.
|
|
1135
1305
|
...(options.continueMode !== 'always' && taintSeeds.size === 0
|
|
1136
1306
|
? {
|
|
1137
|
-
settleNow: (node: TaskNode, upstream: TaskOutcome[]) =>
|
|
1138
|
-
|
|
1139
|
-
|
|
1307
|
+
settleNow: (node: TaskNode, upstream: TaskOutcome[]) => {
|
|
1308
|
+
const skip = platformSkip(node)
|
|
1309
|
+
if (skip !== undefined) return () => skip
|
|
1310
|
+
if (isGroupTask(node) && node.config.inputs === undefined)
|
|
1311
|
+
return () =>
|
|
1312
|
+
unkeyedGroupOutcome(
|
|
1140
1313
|
node,
|
|
1141
1314
|
keyUpstream(node, upstream),
|
|
1142
1315
|
shortCircuit.groupKeys.get(node.id),
|
|
1143
1316
|
runStartHrTimeNs,
|
|
1144
1317
|
)
|
|
1145
|
-
|
|
1318
|
+
if (!shortCircuit.restoreTier.has(node.id) || heldBefore.kept.has(node.id))
|
|
1319
|
+
return undefined
|
|
1320
|
+
return settleProbedHit(buildExecuteArgs(node, keyUpstream(node, upstream)))
|
|
1321
|
+
},
|
|
1146
1322
|
}
|
|
1147
1323
|
: {}),
|
|
1148
1324
|
// A `schedule` plugin's weights; the scheduler keeps its structural
|
|
@@ -1158,6 +1334,16 @@ async function runOnBus(
|
|
|
1158
1334
|
// runs ahead of their deps as low-priority worker-slot backfill.
|
|
1159
1335
|
// Empty when the short-circuit didn't fire → byte-identical.
|
|
1160
1336
|
restoreTier: shortCircuit.restoreTier,
|
|
1337
|
+
...(askForServers
|
|
1338
|
+
? {
|
|
1339
|
+
heldServers: askRemote(
|
|
1340
|
+
serversToHold(nodes, shortCircuit.restoreTier, shortCircuit.remoteAsk),
|
|
1341
|
+
nodes,
|
|
1342
|
+
shortCircuit,
|
|
1343
|
+
cache,
|
|
1344
|
+
),
|
|
1345
|
+
}
|
|
1346
|
+
: {}),
|
|
1161
1347
|
})
|
|
1162
1348
|
stop.graphDone()
|
|
1163
1349
|
reprioritize = undefined
|
|
@@ -1170,8 +1356,11 @@ async function runOnBus(
|
|
|
1170
1356
|
// signal loop) and embedders that manage lifecycle themselves — both
|
|
1171
1357
|
// expect run() to return, not block on a server.
|
|
1172
1358
|
const foreground = options.log === undefined && (options.handleSignals ?? true)
|
|
1173
|
-
// An aborted run's children are already being torn down: nothing to
|
|
1174
|
-
|
|
1359
|
+
// An aborted run's children are already being torn down: nothing to
|
|
1360
|
+
// hold, but the servers a `CYCLE_INTERRUPT` spared.
|
|
1361
|
+
const hold =
|
|
1362
|
+
options.holdPersistent === true &&
|
|
1363
|
+
(!stop.signal.aborted || stop.signal.reason === CYCLE_INTERRUPT)
|
|
1175
1364
|
const { keepAlive, crashed: crashedPersistent } = await disposeServers({
|
|
1176
1365
|
registry: persistentRegistry,
|
|
1177
1366
|
nodes,
|
|
@@ -1187,7 +1376,14 @@ async function runOnBus(
|
|
|
1187
1376
|
// Clear the status line for good before the summary prints.
|
|
1188
1377
|
log.runEnd?.()
|
|
1189
1378
|
const list = [...outcomes.values()]
|
|
1190
|
-
|
|
1379
|
+
// A platform skip, and a skip it blocked, is no failure: the task is not this machine's.
|
|
1380
|
+
const ok = list.every(
|
|
1381
|
+
(o) =>
|
|
1382
|
+
isPassStatus(o.status) ||
|
|
1383
|
+
(o.status === 'skipped' &&
|
|
1384
|
+
(o.otherPlatform !== undefined ||
|
|
1385
|
+
(o.blockedBy !== undefined && placements.otherPlatform.has(o.blockedBy)))),
|
|
1386
|
+
)
|
|
1191
1387
|
|
|
1192
1388
|
// The summary + artifact writers + recordRun pass all exclude group
|
|
1193
1389
|
// tasks via the shared tallyOutcomes helper. We pass the full
|
|
@@ -1217,7 +1413,7 @@ async function runOnBus(
|
|
|
1217
1413
|
const stillDeferred = deferredOutputs.pending()
|
|
1218
1414
|
if (stillDeferred.length > 0) {
|
|
1219
1415
|
log.status(
|
|
1220
|
-
` Deferred: left outputs remote (--cache-download
|
|
1416
|
+
` Deferred: left outputs remote${downloadPolicy === 'all' ? '' : ` (--cache-download=${downloadPolicy})`}: ${stillDeferred.join(', ')}`,
|
|
1221
1417
|
)
|
|
1222
1418
|
}
|
|
1223
1419
|
|
|
@@ -1233,6 +1429,9 @@ async function runOnBus(
|
|
|
1233
1429
|
target: profile,
|
|
1234
1430
|
cwd: options.cwd,
|
|
1235
1431
|
outcomes: list,
|
|
1432
|
+
runStartNs: runStartHrTimeNs,
|
|
1433
|
+
executors: executors.map((e) => executorProfile(e, fault)),
|
|
1434
|
+
spans: runSpans,
|
|
1236
1435
|
}),
|
|
1237
1436
|
)
|
|
1238
1437
|
log.status(`vx: profile written to ${wrote}`)
|
|
@@ -1290,16 +1489,27 @@ async function runOnBus(
|
|
|
1290
1489
|
// read `ok` over the exit 1 and fed the flaky list a pass (X-23).
|
|
1291
1490
|
const historyAfterWait = keepAlive.children.length > 0 && !hold && stop.stoppedBy === undefined
|
|
1292
1491
|
// A failed task's output, for `vx last` and an agent (run-failures.ts).
|
|
1293
|
-
|
|
1294
|
-
|
|
1492
|
+
// A cache directory this user cannot write keeps none of the run's
|
|
1493
|
+
// records: the run's one notice said so.
|
|
1494
|
+
const keeps = prepared.localCache.readOnlyNotice() === null
|
|
1495
|
+
const recordFailures = (final: readonly TaskOutcome[]): void => {
|
|
1496
|
+
if (keeps) writeRunFailures(prepared.cacheDir, runId, final, warnOf(log))
|
|
1497
|
+
}
|
|
1295
1498
|
if (stop.stoppedBy === undefined && !historyAfterWait) {
|
|
1296
1499
|
stepSync('vx:history', () => {
|
|
1297
1500
|
recordHistory(() => cache.recordRunBundle(records))
|
|
1298
1501
|
recordFailures(list)
|
|
1299
1502
|
})
|
|
1503
|
+
} else if (stop.stoppedBy !== undefined) {
|
|
1504
|
+
// Stopped: not a run `vx last` lists (item 854), but what finished is
|
|
1505
|
+
// measured, or the next run knows none of it (2026-10-21).
|
|
1506
|
+
const ended = list.filter((o) => o.status !== 'aborted' && o.status !== 'skipped')
|
|
1507
|
+
stepSync('vx:history', () => {
|
|
1508
|
+
recordHistory(() => prepared.localCache.recordRuns(recordsOf(ended, false).runs))
|
|
1509
|
+
recordFailures(ended)
|
|
1510
|
+
})
|
|
1300
1511
|
}
|
|
1301
1512
|
mark('record history')
|
|
1302
|
-
runContext.runId = runId
|
|
1303
1513
|
// Drain any still-in-flight background prefetches before closing the
|
|
1304
1514
|
// cache handle — a prefetch ingesting into a closed SQLite DB would
|
|
1305
1515
|
// throw. Tasks that resolved as local hits never awaited their
|
|
@@ -1324,7 +1534,10 @@ async function runOnBus(
|
|
|
1324
1534
|
await prefetchDone
|
|
1325
1535
|
await Promise.all([cache.drainUploads?.(), keeper?.settled()])
|
|
1326
1536
|
})
|
|
1327
|
-
|
|
1537
|
+
if (keeps) {
|
|
1538
|
+
writeRunSteps(prepared.cacheDir, runId, steps.ended, warnOf(log))
|
|
1539
|
+
writeRunWarnings(prepared.cacheDir, runId, warned, warnOf(log))
|
|
1540
|
+
}
|
|
1328
1541
|
// An upload that failed after the rows went in: its row says so too.
|
|
1329
1542
|
if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
|
|
1330
1543
|
recordHistory(() => recordLateRemoteErrors(prepared.localCache, records.runs, runId))
|
|
@@ -1345,6 +1558,8 @@ async function runOnBus(
|
|
|
1345
1558
|
abortedCount: list.filter((o) => o.status === 'aborted' && !isGroupTask(o.node)).length,
|
|
1346
1559
|
stages: stageTimes(),
|
|
1347
1560
|
uploads: prepared.localCache.uploads,
|
|
1561
|
+
machines: executors.flatMap((e) => executorMachines(e, fault)),
|
|
1562
|
+
idealMs: executors.map((e) => executorIdeal(e, fault)).find((ms) => ms !== undefined),
|
|
1348
1563
|
})
|
|
1349
1564
|
telemetry.emitSummary(summary)
|
|
1350
1565
|
const sinks = telemetry
|
|
@@ -1382,16 +1597,20 @@ async function runOnBus(
|
|
|
1382
1597
|
}
|
|
1383
1598
|
})
|
|
1384
1599
|
|
|
1385
|
-
// The footer is the run's last word (
|
|
1600
|
+
// The footer is the run's last word (2026-10-06): it prints once
|
|
1386
1601
|
// the run's own work is done, history, uploads, plugin teardown and the
|
|
1387
1602
|
// sandbox reset included, so any warning they raise lands above it. A
|
|
1388
1603
|
// task's own facts (flaky, blocked) ride its row. Only a kept server's
|
|
1389
1604
|
// own output follows.
|
|
1390
1605
|
// The footer replaces the live region in place.
|
|
1391
1606
|
log.runDone?.()
|
|
1392
|
-
if (options.summaryTable === true)
|
|
1607
|
+
if (options.summaryTable === true)
|
|
1608
|
+
for (const line of formatOutcomeTable(list, colors)) log.status(line)
|
|
1609
|
+
// A run a signal stopped records nothing `vx last` opens: no id to copy.
|
|
1610
|
+
const footerContext =
|
|
1611
|
+
stop.stoppedBy === undefined ? runContext : { ...runContext, runId: undefined }
|
|
1393
1612
|
if (options.footer !== false)
|
|
1394
|
-
for (const line of formatRunSummary(list, totalMs, colors,
|
|
1613
|
+
for (const line of formatRunSummary(list, totalMs, colors, footerContext)) log.status(line)
|
|
1395
1614
|
|
|
1396
1615
|
// Edge case the summary already reported: the user requested a
|
|
1397
1616
|
// persistent task (dev server / watcher). The run is "done" in every
|
|
@@ -1414,7 +1633,6 @@ async function runOnBus(
|
|
|
1414
1633
|
persistent: holdServers(keepAlive, {
|
|
1415
1634
|
forwardArgs: options.forwardArgs,
|
|
1416
1635
|
outOf: (id) => serverOuts.get(id) ?? { bus },
|
|
1417
|
-
warn: warnOf(log),
|
|
1418
1636
|
}),
|
|
1419
1637
|
}
|
|
1420
1638
|
}
|
|
@@ -1441,7 +1659,9 @@ async function runOnBus(
|
|
|
1441
1659
|
if (stop.signal.aborted) await stop.aborting?.catch(() => {})
|
|
1442
1660
|
else await terminateChildren(() => keepAlive.children)
|
|
1443
1661
|
// The server that ended the session on its own, not cleanly, failed.
|
|
1444
|
-
|
|
1662
|
+
// A stop's own kill is no crash: the user ended the session.
|
|
1663
|
+
const crashed = !stop.signal.aborted && first.code !== 0
|
|
1664
|
+
if (crashed) failServer(outcomes, node.id, first.code)
|
|
1445
1665
|
const final = [...outcomes.values()]
|
|
1446
1666
|
if (historyAfterWait) {
|
|
1447
1667
|
recordHistory(() => {
|
|
@@ -1449,14 +1669,14 @@ async function runOnBus(
|
|
|
1449
1669
|
// to its schema during the session, and a mismatch resets silently.
|
|
1450
1670
|
const local = new Cache(prepared.cacheDir, undefined, prepared.workspaceRoot)
|
|
1451
1671
|
try {
|
|
1452
|
-
local.recordRunBundle(recordsOf(final, ok &&
|
|
1672
|
+
local.recordRunBundle(recordsOf(final, ok && !crashed))
|
|
1453
1673
|
} finally {
|
|
1454
1674
|
local.close()
|
|
1455
1675
|
}
|
|
1456
1676
|
})
|
|
1457
1677
|
recordFailures(final)
|
|
1458
1678
|
}
|
|
1459
|
-
return { ok: ok &&
|
|
1679
|
+
return { ok: ok && !crashed, outcomes: final }
|
|
1460
1680
|
}
|
|
1461
1681
|
|
|
1462
1682
|
return { ok, outcomes: list }
|
|
@@ -1470,6 +1690,7 @@ async function runOnBus(
|
|
|
1470
1690
|
// floor for a throw a later change adds, not a path a row can drive.
|
|
1471
1691
|
log.runEnd?.()
|
|
1472
1692
|
log.runDone?.()
|
|
1693
|
+
live?.dispose()
|
|
1473
1694
|
stop.detach()
|
|
1474
1695
|
// Plugins installed at the top of run() get their bus subscriptions
|
|
1475
1696
|
// released here. Idempotent; safe even if installPlugins threw.
|
|
@@ -1527,8 +1748,8 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1527
1748
|
const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
|
|
1528
1749
|
const reasons = new Map<string, AffectedReason>()
|
|
1529
1750
|
const prepared = await prepareRun({ ...options, affectedReasons: reasons }, log)
|
|
1530
|
-
options = withRunSettings(options, prepared.workspaceConfig)
|
|
1531
1751
|
try {
|
|
1752
|
+
options = withRunSettings(options, prepared.workspaceConfig)
|
|
1532
1753
|
if (prepared.unresolvedTasks.length > 0) {
|
|
1533
1754
|
return {
|
|
1534
1755
|
tasks: [],
|
|
@@ -1548,6 +1769,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1548
1769
|
if (prepared.empty !== null) return { tasks: [] }
|
|
1549
1770
|
// The plan orders nothing, but it refuses what the run would refuse.
|
|
1550
1771
|
await prepared.priorities()
|
|
1772
|
+
terminalHolders(prepared.nodes, options.tty === true)
|
|
1551
1773
|
// Its own mark: a dry run's plan (every task's hash, the cache lookups,
|
|
1552
1774
|
// the history p50s) was booked under `close`, the next mark, and read
|
|
1553
1775
|
// as 105 ms of closing a cache at 1,000 projects (item 601).
|
|
@@ -1570,13 +1792,14 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1570
1792
|
prepared.localCache.dbHandle(),
|
|
1571
1793
|
undefined,
|
|
1572
1794
|
prepared.branch(),
|
|
1795
|
+
taskDefinitions(prepared.nodes.values()),
|
|
1573
1796
|
),
|
|
1574
1797
|
// Placement, by the same rules the run applies. Only worth showing
|
|
1575
1798
|
// when there is a choice to show — with one declared executor every
|
|
1576
1799
|
// line would carry the same label. Resolving the executors here is
|
|
1577
1800
|
// the same plugin-factory call `prepareRun` already makes for the
|
|
1578
1801
|
// cache capability, so plan mode gains no new class of side effect.
|
|
1579
|
-
...(await planExecutorOf(prepared, log, options.download ?? 'all')),
|
|
1802
|
+
...(await planExecutorOf(prepared, log, options.download ?? 'all', detectCi(process.env).ci)),
|
|
1580
1803
|
...(await planRestorable(prepared, policy, options.forwardArgs)),
|
|
1581
1804
|
})
|
|
1582
1805
|
mark('plan')
|
|
@@ -1647,12 +1870,15 @@ async function unresolvedHint(prepared: {
|
|
|
1647
1870
|
hintProjects: ReadonlyMap<string, ProjectEntry>
|
|
1648
1871
|
declaredElsewhere: readonly string[]
|
|
1649
1872
|
anyProjectConfig: boolean
|
|
1873
|
+
taskless: (name: string) => boolean
|
|
1874
|
+
unlockedConfig: () => string | undefined
|
|
1650
1875
|
}): Promise<string> {
|
|
1651
1876
|
const elsewhere = elsewhereHint(prepared.declaredElsewhere)
|
|
1652
1877
|
const near = didYouMean(
|
|
1653
1878
|
prepared.unresolvedTasks,
|
|
1654
1879
|
prepared.hintProjects,
|
|
1655
1880
|
prepared.declaredElsewhere,
|
|
1881
|
+
prepared.taskless,
|
|
1656
1882
|
)
|
|
1657
1883
|
const init = await initHint(prepared)
|
|
1658
1884
|
const lock =
|
|
@@ -1663,13 +1889,20 @@ async function unresolvedHint(prepared: {
|
|
|
1663
1889
|
}
|
|
1664
1890
|
|
|
1665
1891
|
/**
|
|
1666
|
-
* The first-run case, told apart from a typo:
|
|
1667
|
-
*
|
|
1668
|
-
*
|
|
1892
|
+
* The first-run case, told apart from a typo: the lock holds no project,
|
|
1893
|
+
* so no name could have resolved. Said of the lock, the only thing a run
|
|
1894
|
+
* reads: "no directory holds a vx.config" was false beside configs never
|
|
1895
|
+
* locked. A config written after the lock is no first run: its fix is
|
|
1896
|
+
* `vx lock`. Reached only on the error path.
|
|
1669
1897
|
*/
|
|
1670
|
-
async function initHint(prepared: {
|
|
1898
|
+
async function initHint(prepared: {
|
|
1899
|
+
anyProjectConfig: boolean
|
|
1900
|
+
unlockedConfig: () => string | undefined
|
|
1901
|
+
}): Promise<string> {
|
|
1671
1902
|
if (prepared.anyProjectConfig) return ''
|
|
1672
|
-
|
|
1903
|
+
const unlocked = prepared.unlockedConfig()
|
|
1904
|
+
if (unlocked !== undefined) return ` ${unlocked} is not in the lock yet — run \`vx lock\`.`
|
|
1905
|
+
return ` ${EMPTY_LOCK}.`
|
|
1673
1906
|
}
|
|
1674
1907
|
|
|
1675
1908
|
/**
|
|
@@ -1682,7 +1915,8 @@ async function initHint(prepared: { anyProjectConfig: boolean }): Promise<string
|
|
|
1682
1915
|
function didYouMean(
|
|
1683
1916
|
unresolved: readonly string[],
|
|
1684
1917
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
1685
|
-
elsewhere: readonly string[]
|
|
1918
|
+
elsewhere: readonly string[],
|
|
1919
|
+
taskless: (name: string) => boolean,
|
|
1686
1920
|
): string {
|
|
1687
1921
|
const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
|
|
1688
1922
|
// What a bare name can select: a default `build` is no match (X-102).
|
|
@@ -1727,10 +1961,20 @@ function didYouMean(
|
|
|
1727
1961
|
)
|
|
1728
1962
|
continue
|
|
1729
1963
|
}
|
|
1964
|
+
if (taskless(proj)) {
|
|
1965
|
+
lists.add(
|
|
1966
|
+
`${proj} has no vx config, and no configured project depends on it: it has no tasks.`,
|
|
1967
|
+
)
|
|
1968
|
+
continue
|
|
1969
|
+
}
|
|
1730
1970
|
if (!projects.has(proj)) {
|
|
1731
1971
|
const p = projectNamed(proj, projects) ?? nearest(proj, projects.keys())
|
|
1732
|
-
if (p !== undefined
|
|
1733
|
-
|
|
1972
|
+
if (p !== undefined) {
|
|
1973
|
+
const tasks = tasksOf(projects.get(p))
|
|
1974
|
+
const t = tasks.includes(task) ? task : nearest(task, tasks)
|
|
1975
|
+
if (t !== undefined) hints.add(`${p}#${t}`)
|
|
1976
|
+
else lists.add(`No project is named ${proj}; ${p}'s tasks: ${listed(tasks)}.`)
|
|
1977
|
+
} else if (projects.size > 0) {
|
|
1734
1978
|
lists.add(`No project is named ${proj}; projects: ${listed(projects.keys())}.`)
|
|
1735
1979
|
}
|
|
1736
1980
|
continue
|
|
@@ -1816,3 +2060,89 @@ function canonical(dir: string): string {
|
|
|
1816
2060
|
return path.resolve(dir)
|
|
1817
2061
|
}
|
|
1818
2062
|
}
|
|
2063
|
+
|
|
2064
|
+
/** What an executor's report threw: said once by `executorFaults`, the report left out. */
|
|
2065
|
+
type ExecutorFault = (e: TaskExecutor, what: string, err: unknown) => void
|
|
2066
|
+
|
|
2067
|
+
/**
|
|
2068
|
+
* Each executor report that throws is said once and left out: isolated,
|
|
2069
|
+
* it was dropped with nothing said, a summary short its machines.
|
|
2070
|
+
*/
|
|
2071
|
+
function executorFaults(warn: (line: string) => void): ExecutorFault {
|
|
2072
|
+
const said = new Set<string>()
|
|
2073
|
+
return (e, what, err) => {
|
|
2074
|
+
const k = `${e.name}\0${what}`
|
|
2075
|
+
if (said.has(k)) return
|
|
2076
|
+
said.add(k)
|
|
2077
|
+
warn(`[vx] ${executorLabel(e)}: ${what}() threw: ${thrownMessage(err)} — left out`)
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
/** An executor's footer rows; a plugin's throw or bad shape shows none. */
|
|
2082
|
+
function executorFooter(e: TaskExecutor, fault: ExecutorFault): (readonly [string, ui.Inline])[] {
|
|
2083
|
+
try {
|
|
2084
|
+
const rows = e.footer?.()
|
|
2085
|
+
if (!Array.isArray(rows)) return []
|
|
2086
|
+
return rows.filter(
|
|
2087
|
+
(r): r is readonly [string, ui.Inline] =>
|
|
2088
|
+
Array.isArray(r) &&
|
|
2089
|
+
typeof r[0] === 'string' &&
|
|
2090
|
+
(typeof r[1] === 'string' || (typeof r[1] === 'object' && r[1] !== null)),
|
|
2091
|
+
)
|
|
2092
|
+
} catch (err) {
|
|
2093
|
+
fault(e, 'footer', err)
|
|
2094
|
+
return []
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
/** An executor's `--profile` events; a throw or a bad shape adds none. */
|
|
2099
|
+
function executorProfile(
|
|
2100
|
+
e: TaskExecutor,
|
|
2101
|
+
fault: ExecutorFault,
|
|
2102
|
+
): readonly Record<string, unknown>[] {
|
|
2103
|
+
try {
|
|
2104
|
+
const events = e.profile?.()
|
|
2105
|
+
return Array.isArray(events)
|
|
2106
|
+
? events.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null)
|
|
2107
|
+
: []
|
|
2108
|
+
} catch (err) {
|
|
2109
|
+
fault(e, 'profile', err)
|
|
2110
|
+
return []
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function executorMachines(e: TaskExecutor, fault: ExecutorFault): readonly MachineReport[] {
|
|
2115
|
+
try {
|
|
2116
|
+
const m = e.machines?.()
|
|
2117
|
+
return Array.isArray(m) ? m : []
|
|
2118
|
+
} catch (err) {
|
|
2119
|
+
fault(e, 'machines', err)
|
|
2120
|
+
return []
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function executorIdeal(e: TaskExecutor, fault: ExecutorFault): number | undefined {
|
|
2125
|
+
try {
|
|
2126
|
+
const ms = e.idealMs?.()
|
|
2127
|
+
return typeof ms === 'number' && Number.isFinite(ms) && ms >= 0 ? Math.round(ms) : undefined
|
|
2128
|
+
} catch (err) {
|
|
2129
|
+
fault(e, 'idealMs', err)
|
|
2130
|
+
return undefined
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
/** Where `e` says a task runs, or `null` while it waits; a throw or anything else says nothing. */
|
|
2135
|
+
function executorWhere(
|
|
2136
|
+
e: TaskExecutor,
|
|
2137
|
+
id: string,
|
|
2138
|
+
fault: ExecutorFault,
|
|
2139
|
+
): string | null | undefined {
|
|
2140
|
+
try {
|
|
2141
|
+
const w = e.running?.(id)
|
|
2142
|
+
if (w === null) return null
|
|
2143
|
+
return typeof w === 'string' && w !== '' ? w : undefined
|
|
2144
|
+
} catch (err) {
|
|
2145
|
+
fault(e, 'running', err)
|
|
2146
|
+
return undefined
|
|
2147
|
+
}
|
|
2148
|
+
}
|