@vzn/vx 0.0.637 → 0.0.639
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/docs/caching.md +84 -28
- package/docs/cli.md +273 -408
- package/docs/execution.md +19 -44
- package/docs/features.md +13 -15
- package/docs/patterns.md +5 -7
- package/docs/schema.md +68 -61
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/plan.json +2 -2
- package/skills/vx/SKILL.md +19 -6
- package/src/bin.ts +13 -7
- package/src/cache/cache.ts +60 -11
- package/src/cache/git-inputs.ts +6 -0
- package/src/cache/index.ts +10 -2
- package/src/cache/inputs.ts +0 -24
- package/src/cache/layer.ts +32 -12
- package/src/cache/layered-cache.ts +112 -315
- package/src/cache/plugin-cache.ts +240 -0
- package/src/cache/policy.ts +9 -0
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +92 -8
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +44 -13
- package/src/cache/scoped-cache.ts +22 -2
- package/src/cli/cache.ts +33 -16
- package/src/cli/completions.ts +11 -7
- package/src/cli/foreign-flags.ts +10 -22
- package/src/cli/help.ts +22 -38
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +8 -6
- package/src/cli/init.ts +14 -15
- package/src/cli/last.ts +20 -10
- package/src/cli/lock.ts +39 -27
- package/src/cli/plan-format.ts +10 -61
- package/src/cli/plugin-commands.ts +77 -6
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +31 -191
- package/src/cli/select.ts +18 -9
- package/src/cli/show.ts +72 -40
- package/src/cli/task-verb.ts +4 -4
- package/src/cli/upgrade.ts +132 -39
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +7 -2
- package/src/cli/watch-judge.ts +6 -1
- package/src/cli/watch.ts +73 -34
- package/src/cli/why.ts +14 -5
- package/src/cli/workspace-config.ts +2 -2
- package/src/config.ts +0 -6
- package/src/exec/executor.ts +1 -1
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/index.ts +9 -1
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +12 -16
- package/src/graph/task-graph.ts +119 -21
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +20 -11
- package/src/{orchestrator → history}/history.ts +61 -5
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +14 -23
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +21 -29
- package/src/orchestrator/affected-tasks.ts +8 -3
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +47 -429
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +3 -74
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +3 -3
- package/src/orchestrator/plan.ts +18 -5
- package/src/orchestrator/plugin-host.ts +77 -47
- package/src/orchestrator/plugin.ts +86 -14
- package/src/orchestrator/prepare.ts +74 -31
- package/src/orchestrator/projects.ts +3 -0
- package/src/orchestrator/remote-prefetch.ts +23 -6
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +36 -4
- package/src/orchestrator/run-report.ts +81 -179
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +313 -555
- package/src/orchestrator/sandbox-fix.ts +0 -16
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +107 -3
- package/src/orchestrator/steps.ts +50 -0
- package/src/orchestrator/task-hash.ts +3 -16
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +1 -1
- package/src/orchestrator/telemetry.ts +6 -83
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +39 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +166 -21
- package/src/{orchestrator → report}/status-line.ts +84 -7
- package/src/{orchestrator → report}/summary.ts +179 -63
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +13 -23
- package/src/{orchestrator → util}/colors.ts +49 -4
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +32 -0
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +25 -3
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +39 -0
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +197 -0
- package/src/workspace/affected.ts +20 -23
- package/src/workspace/dependency-rules.ts +12 -4
- package/src/workspace/migration.ts +87 -91
- package/src/workspace/workspace.ts +5 -4
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -276
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
package/src/orchestrator/run.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type CacheLayer,
|
|
12
12
|
type CachePolicy,
|
|
13
13
|
cachesNothing,
|
|
14
|
+
formatCachePolicy,
|
|
14
15
|
stopRuntimeProbes,
|
|
15
16
|
} from '../cache/index.js'
|
|
16
17
|
import { VERSION } from '../version.js'
|
|
@@ -35,7 +36,6 @@ import {
|
|
|
35
36
|
import {
|
|
36
37
|
mark,
|
|
37
38
|
stageTimes,
|
|
38
|
-
killGraceMs,
|
|
39
39
|
MAX_TIMEOUT_MS,
|
|
40
40
|
printTimings,
|
|
41
41
|
ulid,
|
|
@@ -44,57 +44,64 @@ import {
|
|
|
44
44
|
nearest,
|
|
45
45
|
UserError,
|
|
46
46
|
machineParallelism,
|
|
47
|
-
teardownTimeoutMs,
|
|
48
47
|
secretMask,
|
|
49
48
|
isFsRefusal,
|
|
50
49
|
isUserError,
|
|
50
|
+
detectColors,
|
|
51
|
+
type ColorSupport,
|
|
51
52
|
} from '../util/index.js'
|
|
52
53
|
import { keyedProjects } from './keyed-projects.js'
|
|
53
54
|
import { isDefaultBuild } from './projects.js'
|
|
54
55
|
import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
|
|
55
|
-
import type
|
|
56
|
+
import { recordOutputDirSnapshots, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
|
|
56
57
|
import { admitTasks, taintTracker } from './admission.js'
|
|
57
58
|
import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
|
|
59
|
+
import { executeArgsOf, type RunScope } from './execute-args.js'
|
|
58
60
|
import { excludedTaint } from './excluded-keys.js'
|
|
59
61
|
import { keyUpstream } from './upstream.js'
|
|
60
|
-
import {
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
import {
|
|
63
|
+
busLogger,
|
|
64
|
+
createEventBus,
|
|
65
|
+
createForecast,
|
|
66
|
+
defaultLogger,
|
|
67
|
+
type DefaultLogger,
|
|
68
|
+
type EventBus,
|
|
69
|
+
formatOutcomeTable,
|
|
70
|
+
formatPersistentList,
|
|
71
|
+
formatRunSummary,
|
|
72
|
+
type Logger,
|
|
73
|
+
plainOutput,
|
|
74
|
+
resolveOutputView,
|
|
75
|
+
terminalSubscriber,
|
|
76
|
+
warnOf,
|
|
77
|
+
} from '../report/index.js'
|
|
78
|
+
import { installPlugins, PluginSetupError, type RunReport } from './plugin.js'
|
|
79
|
+
import { keepRunBytes } from './run-report.js'
|
|
80
|
+
import { buildAdmission, executorDemand, resolveExecutors, teardownPlugins } from './plugin-host.js'
|
|
81
|
+
import { createSteps } from './steps.js'
|
|
63
82
|
import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
64
83
|
import { claimOutput, guardedOutput } from './output-host.js'
|
|
65
|
-
import { assembleRunSummary
|
|
84
|
+
import { assembleRunSummary } from './telemetry.js'
|
|
85
|
+
import { isPassStatus } from '../graph/index.js'
|
|
66
86
|
import type { RunContextRecord } from './telemetry.js'
|
|
67
|
-
import { defaultLogger, type DefaultLogger, resolveOutputView, type Logger } from './logger.js'
|
|
68
|
-
import { detectColors, type ColorSupport } from './colors.js'
|
|
69
|
-
import { plainOutput } from './plain-output.js'
|
|
70
|
-
import { formatPersistentList } from './framed-output.js'
|
|
71
|
-
import { createForecast } from './forecast.js'
|
|
72
|
-
import { LocalHistoryProvider } from './history.js'
|
|
73
87
|
import { plan, type RunPlan } from './plan.js'
|
|
74
88
|
import type { AffectedReason } from './affected-tasks.js'
|
|
75
89
|
import { prepareRun, type PreparedRun } from './prepare.js'
|
|
76
90
|
import { acquireRunLock } from './run-lock.js'
|
|
77
|
-
import {
|
|
78
|
-
|
|
79
|
-
type StopSignal,
|
|
80
|
-
forwardSignals,
|
|
81
|
-
SIGNAL_SHUTDOWN_GRACE_MS,
|
|
82
|
-
terminateChildren,
|
|
83
|
-
} from './signals.js'
|
|
91
|
+
import { terminateChildren } from './signals.js'
|
|
92
|
+
import { runStop } from './run-stop.js'
|
|
84
93
|
import {
|
|
85
94
|
hasPooledExecutor,
|
|
86
95
|
placeTasks,
|
|
87
96
|
planExecutorOf,
|
|
88
97
|
poolOfPlacement,
|
|
89
98
|
terminalHolders,
|
|
90
|
-
UNPLACED_EXECUTOR,
|
|
91
99
|
locallyPlaced,
|
|
92
100
|
} from './placement.js'
|
|
93
101
|
import {
|
|
94
|
-
|
|
102
|
+
telemetryRunContext,
|
|
95
103
|
captureGitContext,
|
|
96
104
|
captureHostContext,
|
|
97
|
-
captureWorkspaceIdentity,
|
|
98
105
|
detectCi,
|
|
99
106
|
} from './run-context.js'
|
|
100
107
|
import { startRemotePrefetch } from './remote-prefetch.js'
|
|
@@ -106,26 +113,25 @@ import {
|
|
|
106
113
|
import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
|
|
107
114
|
|
|
108
115
|
import { writeRunFailures } from './run-failures.js'
|
|
109
|
-
import { assembleRunRecords } from './run-records.js'
|
|
116
|
+
import { assembleRunRecords, recordLateRemoteErrors } from './run-records.js'
|
|
110
117
|
import {
|
|
111
118
|
hasEnded,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
disposeServers,
|
|
120
|
+
failServer,
|
|
121
|
+
holdServers,
|
|
122
|
+
sessionEnd,
|
|
115
123
|
takeHeldServers,
|
|
116
124
|
} from './persistent.js'
|
|
117
|
-
import {
|
|
118
|
-
runSummaryJson,
|
|
119
|
-
writeRunProfile,
|
|
120
|
-
writeRunSummary,
|
|
121
|
-
type RunSummaryJson,
|
|
122
|
-
} from './run-artifacts.js'
|
|
125
|
+
import { writeRunProfile } from './run-artifacts.js'
|
|
123
126
|
import { createSaveLane } from './save-lane.js'
|
|
124
127
|
import { commandWindows } from './command-windows.js'
|
|
125
|
-
import {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
import {
|
|
129
|
+
createMissExplainer,
|
|
130
|
+
detectFlaky,
|
|
131
|
+
type FlakyCandidate,
|
|
132
|
+
LocalHistoryProvider,
|
|
133
|
+
} from '../history/index.js'
|
|
134
|
+
import type { RunOptions, RunSummary } from './options.js'
|
|
129
135
|
import { withRunSettings } from './run-settings.js'
|
|
130
136
|
import { resolveSetting } from '../settings.js'
|
|
131
137
|
|
|
@@ -138,20 +144,6 @@ const emptyShortCircuit = (): ShortCircuit => ({
|
|
|
138
144
|
groupKeys: new Map(),
|
|
139
145
|
})
|
|
140
146
|
|
|
141
|
-
/**
|
|
142
|
-
* Compact the 4-axis cache policy into the `invocations.cache_policy`
|
|
143
|
-
* column string. Each enabled axis contributes its flag; a fully-on
|
|
144
|
-
* policy reads `'lR,lW,rR,rW'`. Pure presentation — never affects the key.
|
|
145
|
-
*/
|
|
146
|
-
function compactCachePolicy(p: CachePolicy): string {
|
|
147
|
-
const parts: string[] = []
|
|
148
|
-
if (p.localRead) parts.push('lR')
|
|
149
|
-
if (p.localWrite) parts.push('lW')
|
|
150
|
-
if (p.remoteRead) parts.push('rR')
|
|
151
|
-
if (p.remoteWrite) parts.push('rW')
|
|
152
|
-
return parts.join(',')
|
|
153
|
-
}
|
|
154
|
-
|
|
155
147
|
/**
|
|
156
148
|
* The policy that will actually govern the run. The remote axes are inert
|
|
157
149
|
* without a remote layer to serve them: a `remote:w` policy over a bare
|
|
@@ -201,17 +193,6 @@ function maskInvocation(command: string): string {
|
|
|
201
193
|
return secretMask([process.env])?.mask(command) ?? command
|
|
202
194
|
}
|
|
203
195
|
|
|
204
|
-
/**
|
|
205
|
-
* A run's `--tag`s, stored on its history row and handed to telemetry
|
|
206
|
-
* (`vx.tag.<key>`): a tag that carries a secret is masked as the
|
|
207
|
-
* invocation line that spells it is (L-38).
|
|
208
|
-
*/
|
|
209
|
-
function maskTags(tags: Readonly<Record<string, string>>): Record<string, string> {
|
|
210
|
-
const mask = secretMask([process.env])
|
|
211
|
-
if (mask === null) return { ...tags }
|
|
212
|
-
return Object.fromEntries(Object.entries(tags).map(([k, v]) => [mask.mask(k), mask.mask(v)]))
|
|
213
|
-
}
|
|
214
|
-
|
|
215
196
|
/**
|
|
216
197
|
* The command line a run hands to its telemetry sinks, with what follows
|
|
217
198
|
* `--` counted, not quoted. Those are the task's arguments,
|
|
@@ -286,15 +267,11 @@ function refuseRunOptions(options: RunOptions): void {
|
|
|
286
267
|
refuse('concurrency', concurrency, 'a positive integer')
|
|
287
268
|
if (retries !== undefined && !(Number.isInteger(retries) && retries >= 0))
|
|
288
269
|
refuse('retries', retries, 'a non-negative integer')
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
] as const) {
|
|
295
|
-
if (ms !== undefined && !(Number.isInteger(ms) && ms > 0 && ms <= MAX_TIMEOUT_MS))
|
|
296
|
-
refuse(name, ms, `a positive integer of ms, at most ${MAX_TIMEOUT_MS}`)
|
|
297
|
-
}
|
|
270
|
+
if (
|
|
271
|
+
timeout !== undefined &&
|
|
272
|
+
!(Number.isInteger(timeout) && timeout > 0 && timeout <= MAX_TIMEOUT_MS)
|
|
273
|
+
)
|
|
274
|
+
refuse('timeout', timeout, `a positive integer of ms, at most ${MAX_TIMEOUT_MS}`)
|
|
298
275
|
}
|
|
299
276
|
|
|
300
277
|
export async function run(options: RunOptions): Promise<RunSummary> {
|
|
@@ -315,17 +292,12 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
315
292
|
// See docs/design/event-stream-2026-06.md.
|
|
316
293
|
const terminal =
|
|
317
294
|
options.log === undefined
|
|
318
|
-
? defaultLogger(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
options.
|
|
322
|
-
{
|
|
323
|
-
|
|
324
|
-
...(options.tty === true ? { tty: true } : {}),
|
|
325
|
-
...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
|
|
326
|
-
...(options.footer === false ? { footer: false } : {}),
|
|
327
|
-
},
|
|
328
|
-
)
|
|
295
|
+
? defaultLogger(colors, resolveOutputView(options), process.stdout, {
|
|
296
|
+
coalesce: true,
|
|
297
|
+
...(options.tty === true ? { tty: true } : {}),
|
|
298
|
+
...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
|
|
299
|
+
...(options.footer === false ? { footer: false } : {}),
|
|
300
|
+
})
|
|
329
301
|
: null
|
|
330
302
|
const sink = options.log ?? terminal!
|
|
331
303
|
// An injected bus already has surfaces subscribed; we add the terminal
|
|
@@ -355,7 +327,10 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
355
327
|
}
|
|
356
328
|
let held = false
|
|
357
329
|
try {
|
|
358
|
-
|
|
330
|
+
// vx's own steps are timed only where something renders them.
|
|
331
|
+
const showsSteps =
|
|
332
|
+
terminal !== null ? terminal.showsSteps : options.log?.stepStart !== undefined
|
|
333
|
+
const summary = await runOnBus(options, bus, colors, terminal, showsSteps, takeOutput)
|
|
359
334
|
if (summary.persistent === undefined) return summary
|
|
360
335
|
// Servers handed back still running still write: `vx watch dev` printed
|
|
361
336
|
// none of its server's log while it idled, the renderer gone with this
|
|
@@ -379,9 +354,11 @@ async function runOnBus(
|
|
|
379
354
|
bus: EventBus,
|
|
380
355
|
colors: ColorSupport,
|
|
381
356
|
terminal: DefaultLogger | null,
|
|
357
|
+
showsSteps: boolean,
|
|
382
358
|
takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
|
|
383
359
|
): Promise<RunSummary> {
|
|
384
360
|
const log = busLogger(bus)
|
|
361
|
+
const { step, stepSync } = createSteps(log, showsSteps)
|
|
385
362
|
// A server's output goes through a cell of its own: a later watch cycle
|
|
386
363
|
// that keeps the server points it at that cycle's bus (`RunOptions.keep`).
|
|
387
364
|
const serverOuts = new Map<string, { bus: EventBus }>()
|
|
@@ -395,7 +372,7 @@ async function runOnBus(
|
|
|
395
372
|
}
|
|
396
373
|
}
|
|
397
374
|
|
|
398
|
-
const prepared = await prepareRun(options, log)
|
|
375
|
+
const prepared = await step('vx:load', () => prepareRun(options, log))
|
|
399
376
|
options = withRunSettings(options, prepared.workspaceConfig)
|
|
400
377
|
mark('plugin stages')
|
|
401
378
|
// Every exit from here tears the plugins down: their capability
|
|
@@ -405,11 +382,11 @@ async function runOnBus(
|
|
|
405
382
|
// per cycle under `vx watch`. A plugin whose own setup threw set up
|
|
406
383
|
// nothing, and is the one left out.
|
|
407
384
|
let tornDown = false
|
|
408
|
-
const teardown = async (except?: unknown): Promise<void> => {
|
|
385
|
+
const teardown = async (except?: unknown, report?: RunReport): Promise<void> => {
|
|
409
386
|
if (tornDown) return
|
|
410
387
|
tornDown = true
|
|
411
388
|
const plugins = prepared.plugins.filter((p) => p !== except)
|
|
412
|
-
await teardownPlugins(plugins, (
|
|
389
|
+
await teardownPlugins(plugins, warnOf(log), report, step)
|
|
413
390
|
}
|
|
414
391
|
// A task whose command re-enters `vx run` in the workspace running it
|
|
415
392
|
// is refused. When the inner run reaches this task again it forks a run
|
|
@@ -437,7 +414,7 @@ async function runOnBus(
|
|
|
437
414
|
// A refusal is the caller's to print (on stderr, as `vx run:`): as a
|
|
438
415
|
// status line it went to stdout, into whatever a script piped there.
|
|
439
416
|
if (prepared.unresolvedTasks.length > 0) {
|
|
440
|
-
const refused = `no projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${
|
|
417
|
+
const refused = `no projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${await unresolvedHint(prepared)}`
|
|
441
418
|
await teardown()
|
|
442
419
|
prepared.cache.close()
|
|
443
420
|
return { ok: false, outcomes: [], refused }
|
|
@@ -467,13 +444,24 @@ async function runOnBus(
|
|
|
467
444
|
// the plugin. A plugin without `setup` subscribes nothing.
|
|
468
445
|
let disposePlugins: (() => void) | undefined
|
|
469
446
|
let telemetry: TelemetryHandle | undefined
|
|
447
|
+
// One run-id per `vx run` invocation. Every task in the resulting graph
|
|
448
|
+
// carries it so analytics queries can group by invocation; plugins get it
|
|
449
|
+
// in their teardown's `RunReport`.
|
|
450
|
+
const runId = ulid()
|
|
451
|
+
const head = captureGitContext(prepared.workspaceRoot)
|
|
452
|
+
const ciContext = detectCi(process.env)
|
|
453
|
+
const keepRunLog = prepared.keepRunEntry !== undefined
|
|
470
454
|
try {
|
|
471
455
|
disposePlugins = await installPlugins({
|
|
472
456
|
plugins: prepared.plugins,
|
|
473
457
|
bus,
|
|
474
458
|
workspaceRoot: prepared.workspaceRoot,
|
|
475
459
|
cacheDir: prepared.cacheDir,
|
|
476
|
-
warn: (
|
|
460
|
+
warn: warnOf(log),
|
|
461
|
+
cache: prepared.cacheFor,
|
|
462
|
+
ci: ciContext.ci,
|
|
463
|
+
branch: prepared.branch(),
|
|
464
|
+
step,
|
|
477
465
|
})
|
|
478
466
|
} catch (err) {
|
|
479
467
|
disposePlugins?.()
|
|
@@ -526,7 +514,7 @@ async function runOnBus(
|
|
|
526
514
|
resolveExecutors(prepared.plugins, {
|
|
527
515
|
workspaceRoot: prepared.workspaceRoot,
|
|
528
516
|
cacheDir: prepared.cacheDir,
|
|
529
|
-
warn: (
|
|
517
|
+
warn: warnOf(log),
|
|
530
518
|
concurrency,
|
|
531
519
|
}),
|
|
532
520
|
)
|
|
@@ -550,51 +538,11 @@ async function runOnBus(
|
|
|
550
538
|
: nodes.get(id)?.config.cache === undefined
|
|
551
539
|
? 'no remote executor accepted it — the task declares no `cache` block, so there is no input set to ship'
|
|
552
540
|
: 'no remote executor accepted it'
|
|
553
|
-
log
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
// Demand: what each executor still has left to run. An executor that
|
|
557
|
-
// provisions per task (a container, an allocation, a pod) otherwise has to
|
|
558
|
-
// guess from `capacity` alone and hold everything until teardown. Built
|
|
559
|
-
// ONLY for executors that asked — with none declared there is no map, no
|
|
560
|
-
// set, and no call in the completion path.
|
|
561
|
-
const demandOf = new Map<TaskExecutor, Set<string>>()
|
|
562
|
-
for (const [id, executor] of placements.executors) {
|
|
563
|
-
if (executor.demand === undefined) continue
|
|
564
|
-
let remaining = demandOf.get(executor)
|
|
565
|
-
if (remaining === undefined) demandOf.set(executor, (remaining = new Set()))
|
|
566
|
-
remaining.add(id)
|
|
567
|
-
}
|
|
568
|
-
// The SAME set is handed back every time, narrowed in place: a consumer
|
|
569
|
-
// reading it later sees the truth rather than a stale snapshot. A hint,
|
|
570
|
-
// as `admit` is: one that throws is named once and asked no more, where
|
|
571
|
-
// it surfaced as a bare stack, or mid-run from the completion path
|
|
572
|
-
// (item 1022).
|
|
573
|
-
const tellDemand = (executor: TaskExecutor, remaining: ReadonlySet<string>): void => {
|
|
574
|
-
const failed = (err: unknown): void => {
|
|
575
|
-
if (!demandOf.delete(executor)) return
|
|
576
|
-
const m = err instanceof Error ? err.message : String(err)
|
|
577
|
-
log.status(`[vx] ${executorLabel(executor)} failed in demand: ${m}; not asked again this run`)
|
|
578
|
-
}
|
|
579
|
-
try {
|
|
580
|
-
// An `async demand` that rejects did so where no one listened: an
|
|
581
|
-
// unhandled rejection, a stack, the run killed. Its rejection is a
|
|
582
|
-
// throw like any other.
|
|
583
|
-
const ret: unknown = executor.demand!(remaining)
|
|
584
|
-
if (ret instanceof Promise) ret.catch(failed)
|
|
585
|
-
} catch (err) {
|
|
586
|
-
failed(err)
|
|
541
|
+
warnOf(log)(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
|
|
587
542
|
}
|
|
588
543
|
}
|
|
589
|
-
|
|
590
|
-
const narrowDemand =
|
|
591
|
-
demandOf.size === 0
|
|
592
|
-
? (): void => {}
|
|
593
|
-
: (id: string): void => {
|
|
594
|
-
for (const [executor, remaining] of demandOf) {
|
|
595
|
-
if (remaining.delete(id)) tellDemand(executor, remaining)
|
|
596
|
-
}
|
|
597
|
-
}
|
|
544
|
+
// Demand: what each executor still has left to run (`executorDemand`).
|
|
545
|
+
const narrowDemand = executorDemand(placements.executors, warnOf(log))
|
|
598
546
|
|
|
599
547
|
// `cache.download` (default `all`) decides ONCE per task, here, whether a
|
|
600
548
|
// remote execution's outputs come home.
|
|
@@ -621,88 +569,19 @@ async function runOnBus(
|
|
|
621
569
|
| boolean
|
|
622
570
|
| undefined
|
|
623
571
|
|
|
624
|
-
|
|
625
|
-
// - `liveChildren`: in-flight children. The runner adds/removes
|
|
626
|
-
// each child around its spawn (persistent children stay until
|
|
627
|
-
// they exit).
|
|
628
|
-
// - `persistentRegistry`: ready persistent tasks (dev servers,
|
|
629
|
-
// watchers). executeTask spawns them but does NOT await their
|
|
630
|
-
// exit; ownership moves here so the orchestrator can SIGTERM
|
|
631
|
-
// them once the rest of the graph finishes.
|
|
632
|
-
//
|
|
633
|
-
// A SIGINT/SIGTERM mid-run forwards that signal to everything in both
|
|
634
|
-
// (`signals.ts`); the handlers are removed in the finally below so
|
|
635
|
-
// repeated run() calls (test suites) never stack listeners.
|
|
636
|
-
const liveChildren = new Set<ReturnType<typeof Bun.spawn>>()
|
|
637
|
-
// A server that ends on its own while the graph still runs is said at
|
|
638
|
-
// once: its dependants were failing against it, and the end of the run
|
|
639
|
-
// was the first word of it (C-69). Not once the graph is done (the end
|
|
640
|
-
// of the run and the keep-alive wait say it) nor under a stop.
|
|
641
|
-
let graphDone = false
|
|
642
|
-
const persistentRegistry = new (class extends Map<string, ReturnType<typeof Bun.spawn>> {
|
|
643
|
-
override set(id: string, child: ReturnType<typeof Bun.spawn>): this {
|
|
644
|
-
super.set(id, child)
|
|
645
|
-
void child.exited.then((code) => {
|
|
646
|
-
if (code !== 0 && !graphDone && !stopRun.signal.aborted)
|
|
647
|
-
log.status(`vx: ${id} exited with code ${code} while the run went on`)
|
|
648
|
-
})
|
|
649
|
-
return this
|
|
650
|
-
}
|
|
651
|
-
})()
|
|
652
|
-
// One stop for the run: an embedder's `RunOptions.signal` and a process
|
|
653
|
-
// signal both abort it. The scheduler stops dispatching (it reads the
|
|
654
|
-
// signal), the children are torn down, and run() returns through its own
|
|
655
|
-
// end-of-run path. Detached in the finally below.
|
|
656
|
-
const stopRun = new AbortController()
|
|
657
|
-
const forwardAbort = (): void => stopRun.abort(options.signal?.reason)
|
|
658
|
-
if (options.signal?.aborted === true) forwardAbort()
|
|
659
|
-
else options.signal?.addEventListener('abort', forwardAbort, { once: true })
|
|
660
|
-
// A run a process signal stopped records no history: until item 849 the
|
|
661
|
-
// handler exited before any outcome landed, and a Ctrl-C'd run then read
|
|
662
|
-
// in `vx last` as FAILED with 0 tasks (item 854). An embedder's abort is
|
|
663
|
-
// its own call and keeps its record.
|
|
664
|
-
let stoppedBy: StopSignal | undefined
|
|
665
|
-
let leftRun = (): void => {}
|
|
666
|
-
const runLeft = new Promise<void>((resolve) => {
|
|
667
|
-
leftRun = resolve
|
|
668
|
-
})
|
|
669
|
-
const signals = forwardSignals({
|
|
670
|
-
enabled: options.handleSignals ?? true,
|
|
572
|
+
const stop = runStop({
|
|
671
573
|
log,
|
|
672
574
|
cache,
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
stop: (signal) => {
|
|
676
|
-
stoppedBy = signal
|
|
677
|
-
stopRun.abort(signal)
|
|
678
|
-
},
|
|
679
|
-
done: runLeft,
|
|
575
|
+
external: options.signal,
|
|
576
|
+
handleSignals: options.handleSignals ?? true,
|
|
680
577
|
handsTerminal: holders.size > 0,
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
2_000,
|
|
578
|
+
teardowns: prepared.plugins.filter((p) => p.teardown).length,
|
|
579
|
+
onStop: () => {
|
|
580
|
+
stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
|
|
581
|
+
cache.stopUploads?.()
|
|
582
|
+
},
|
|
687
583
|
})
|
|
688
|
-
|
|
689
|
-
// once while a child it backgrounded ignores it and dies only to the
|
|
690
|
-
// SIGKILL after the grace, and a run that returned first left that child
|
|
691
|
-
// running past vx's exit (turborepo#14043's class).
|
|
692
|
-
let aborting: Promise<void> | undefined
|
|
693
|
-
// The servers that had ended when the stop landed: those crashed. One that
|
|
694
|
-
// ends after it is the stop's own kill, and was named a crash (item 1061).
|
|
695
|
-
let endedBeforeStop: ReadonlySet<ReturnType<typeof Bun.spawn>> | undefined
|
|
696
|
-
const onAbort = (): void => {
|
|
697
|
-
stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
|
|
698
|
-
endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
|
|
699
|
-
aborting = terminateChildren(
|
|
700
|
-
() => [...liveChildren, ...persistentRegistry.values()],
|
|
701
|
-
forwardedSignal(stopRun.signal.reason),
|
|
702
|
-
)
|
|
703
|
-
}
|
|
704
|
-
if (stopRun.signal.aborted) onAbort()
|
|
705
|
-
else stopRun.signal.addEventListener('abort', onAbort, { once: true })
|
|
584
|
+
const { liveChildren, persistentRegistry } = stop
|
|
706
585
|
// The cache handle must be released on EVERY exit path, not just the
|
|
707
586
|
// happy one: `close()` is also where the run's deferred `accessed_at`
|
|
708
587
|
// bumps are flushed, so a throw between opening the cache and the
|
|
@@ -729,15 +608,14 @@ async function runOnBus(
|
|
|
729
608
|
// Taken after the early exits above (nothing they do touches a tree) and
|
|
730
609
|
// before the schedule: from here on tasks clean, restore and write.
|
|
731
610
|
const releaseRunLock = await abandoning(() =>
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
611
|
+
step('vx:run-lock', () =>
|
|
612
|
+
acquireRunLock(prepared.workspaceRoot, {
|
|
613
|
+
log: warnOf(log),
|
|
614
|
+
signal: stop.signal,
|
|
615
|
+
}),
|
|
616
|
+
),
|
|
736
617
|
)
|
|
737
618
|
try {
|
|
738
|
-
// One run-id per `vx run` invocation. Every task in the resulting
|
|
739
|
-
// graph carries it so analytics queries can group by invocation.
|
|
740
|
-
const runId = ulid()
|
|
741
619
|
const runStartHrTimeNs = process.hrtime.bigint()
|
|
742
620
|
const endedAtMsAtStart = Date.now()
|
|
743
621
|
const remoteCacheEnabled = prepared.hasRemoteLayer
|
|
@@ -748,7 +626,7 @@ async function runOnBus(
|
|
|
748
626
|
// happened.
|
|
749
627
|
const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
|
|
750
628
|
if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
|
|
751
|
-
log
|
|
629
|
+
warnOf(log)(
|
|
752
630
|
'vx: --cache-policy named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run',
|
|
753
631
|
)
|
|
754
632
|
}
|
|
@@ -768,10 +646,10 @@ async function runOnBus(
|
|
|
768
646
|
// second status spawn.
|
|
769
647
|
// `run.branch` names the branch over git's (`prepared.branch`).
|
|
770
648
|
const gitContext = {
|
|
771
|
-
...
|
|
649
|
+
...head,
|
|
650
|
+
dirty: gitFilesCache.worktreeDirty,
|
|
772
651
|
branch: prepared.branch(),
|
|
773
652
|
}
|
|
774
|
-
const ciContext = detectCi(process.env)
|
|
775
653
|
const hostContext = captureHostContext()
|
|
776
654
|
|
|
777
655
|
// The canonical run-context record — the same git/CI/host data the
|
|
@@ -791,51 +669,29 @@ async function runOnBus(
|
|
|
791
669
|
// but declines still pays — its answer is only knowable by asking.
|
|
792
670
|
const hasTelemetryPlugin = prepared.plugins.some((p) => p.telemetry !== undefined)
|
|
793
671
|
if (hasTelemetryPlugin || options.telemetrySinks !== undefined) {
|
|
794
|
-
|
|
795
|
-
// consumer can exist — a plain run never reaches here. The remote URL
|
|
796
|
-
// is the enumeration's `git var -l`: a spawn here cost ~4 ms.
|
|
797
|
-
const wsIdentity = captureWorkspaceIdentity(
|
|
798
|
-
workspaceRoot,
|
|
799
|
-
gitFilesCache.configValue('remote.origin.url'),
|
|
800
|
-
)
|
|
801
|
-
runContextRecord = {
|
|
672
|
+
runContextRecord = telemetryRunContext({
|
|
802
673
|
runId,
|
|
803
|
-
vxVersion: VERSION,
|
|
804
674
|
// An embedder's `command` is redacted as the argv is: it passed a
|
|
805
675
|
// token after `--` to every sink verbatim (C-67).
|
|
806
|
-
// Masked as the stored line is:
|
|
676
|
+
// Masked as the stored line is: a flag's secret value sits before
|
|
807
677
|
// the `--` the count covers (L-44).
|
|
808
678
|
command: maskInvocation(
|
|
809
679
|
invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
|
|
810
680
|
),
|
|
811
|
-
requestedTasks:
|
|
812
|
-
cachePolicy:
|
|
681
|
+
requestedTasks: options.tasks,
|
|
682
|
+
cachePolicy: formatCachePolicy(policy),
|
|
813
683
|
concurrency,
|
|
814
684
|
flow: options.flow ?? null,
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
...(ciContext.runUrl !== undefined ? { ciRunUrl: ciContext.runUrl } : {}),
|
|
822
|
-
...(ciContext.change !== undefined ? { ciChange: ciContext.change } : {}),
|
|
823
|
-
...(ciContext.pipeline !== undefined ? { ciPipeline: ciContext.pipeline } : {}),
|
|
824
|
-
...(ciContext.job !== undefined ? { ciJob: ciContext.job } : {}),
|
|
825
|
-
...(ciContext.attempt !== undefined ? { ciAttempt: ciContext.attempt } : {}),
|
|
826
|
-
host: hostContext.host,
|
|
827
|
-
os: hostContext.os,
|
|
828
|
-
arch: hostContext.arch,
|
|
829
|
-
workspaceId: wsIdentity.id,
|
|
830
|
-
workspaceName: wsIdentity.name,
|
|
831
|
-
...(wsIdentity.repository !== undefined ? { repository: wsIdentity.repository } : {}),
|
|
832
|
-
...(wsIdentity.path !== undefined ? { workspacePath: wsIdentity.path } : {}),
|
|
833
|
-
tags: maskTags(options.tags ?? {}),
|
|
834
|
-
}
|
|
685
|
+
git: gitContext,
|
|
686
|
+
ci: ciContext,
|
|
687
|
+
host: hostContext,
|
|
688
|
+
workspaceRoot,
|
|
689
|
+
originUrl: gitFilesCache.configValue('remote.origin.url'),
|
|
690
|
+
})
|
|
835
691
|
telemetry = await subscribeTelemetry(
|
|
836
692
|
prepared.plugins,
|
|
837
693
|
bus,
|
|
838
|
-
{ workspaceRoot, cacheDir, warn: (
|
|
694
|
+
{ workspaceRoot, cacheDir, warn: warnOf(log) },
|
|
839
695
|
runContextRecord,
|
|
840
696
|
options.telemetrySinks,
|
|
841
697
|
nodes,
|
|
@@ -868,7 +724,7 @@ async function runOnBus(
|
|
|
868
724
|
// each pack holds an artifact's bytes); a failed save is a miss next
|
|
869
725
|
// time, said once — the task's work ran.
|
|
870
726
|
const saveLane = createSaveLane(2 * concurrency, (err) =>
|
|
871
|
-
log
|
|
727
|
+
warnOf(log)(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
872
728
|
)
|
|
873
729
|
const deferredSaves = new Map<string, Promise<SaveFacts>>()
|
|
874
730
|
const windows = commandWindows()
|
|
@@ -884,9 +740,10 @@ async function runOnBus(
|
|
|
884
740
|
// for display. Marks `node.surfaced`; never touches `requested`.
|
|
885
741
|
markSurfacedDeps(nodes)
|
|
886
742
|
|
|
887
|
-
// Header counts: unique projects
|
|
888
|
-
// dependsOn-pulled deps, not just the user-requested
|
|
889
|
-
//
|
|
743
|
+
// Header counts: unique projects with a real (non-group) task in the
|
|
744
|
+
// graph (including dependsOn-pulled deps, not just the user-requested
|
|
745
|
+
// set; a `^build` placeholder in a project with no build is not one),
|
|
746
|
+
// and the total number of real task executions. Mirrors the
|
|
890
747
|
// count the end-of-run summary reports under "total". The
|
|
891
748
|
// requested count drives the focused logger's live-vs-buffered
|
|
892
749
|
// decision, so surfaced nodes count toward it too — they display
|
|
@@ -895,8 +752,8 @@ async function runOnBus(
|
|
|
895
752
|
let taskCount = 0
|
|
896
753
|
let requestedCount = 0
|
|
897
754
|
for (const node of nodes.values()) {
|
|
898
|
-
packagesInScope.add(node.projectName)
|
|
899
755
|
if (!isGroupTask(node)) {
|
|
756
|
+
packagesInScope.add(node.projectName)
|
|
900
757
|
taskCount++
|
|
901
758
|
if (node.requested || node.surfaced === true) requestedCount++
|
|
902
759
|
}
|
|
@@ -904,7 +761,7 @@ async function runOnBus(
|
|
|
904
761
|
// Admission over the worker count is a plugin's (`admit`): built once
|
|
905
762
|
// into the predicate the scheduler asks at every local dispatch. No
|
|
906
763
|
// plugin answers → undefined → the count-only path, byte for byte.
|
|
907
|
-
const admit = buildAdmission(prepared.plugins, nodes, concurrency, (
|
|
764
|
+
const admit = buildAdmission(prepared.plugins, nodes, concurrency, warnOf(log))
|
|
908
765
|
|
|
909
766
|
// Run context for the footer. The top-of-run header is gone — the
|
|
910
767
|
// banner now lives in the summary, where the eye lands at the end.
|
|
@@ -912,20 +769,25 @@ async function runOnBus(
|
|
|
912
769
|
version: VERSION,
|
|
913
770
|
packageCount: packagesInScope.size,
|
|
914
771
|
remoteCacheEnabled,
|
|
772
|
+
cache: {
|
|
773
|
+
local: policy.localRead || policy.localWrite,
|
|
774
|
+
localWrite: policy.localWrite,
|
|
775
|
+
remote: remoteCacheEnabled && (policy.remoteRead || policy.remoteWrite),
|
|
776
|
+
traffic: prepared.localCache.traffic,
|
|
777
|
+
notDownloaded: () => deferredOutputs.pending().length,
|
|
778
|
+
},
|
|
915
779
|
concurrency,
|
|
916
780
|
workspaceProjectCount,
|
|
917
781
|
}
|
|
918
782
|
|
|
919
783
|
// Asked only when a plugin declares `output`: a plain run pays nothing.
|
|
920
784
|
if (takeOutput !== undefined && prepared.plugins.some((p) => p.output !== undefined)) {
|
|
921
|
-
const json = options.json === true
|
|
922
785
|
const claimed = await claimOutput(prepared.plugins, {
|
|
923
786
|
workspaceRoot,
|
|
924
787
|
cacheDir,
|
|
925
|
-
warn: (
|
|
926
|
-
isTTY:
|
|
788
|
+
warn: warnOf(log),
|
|
789
|
+
isTTY: process.stdout.isTTY === true,
|
|
927
790
|
ci: ciContext.ci,
|
|
928
|
-
json,
|
|
929
791
|
})
|
|
930
792
|
if (claimed !== undefined) takeOutput(claimed)
|
|
931
793
|
}
|
|
@@ -961,18 +823,21 @@ async function runOnBus(
|
|
|
961
823
|
// unchanged (no upfront key pass, no local probing).
|
|
962
824
|
let prefetchDone: Promise<void> = Promise.resolve()
|
|
963
825
|
if (prepared.hasRemoteLayer) {
|
|
964
|
-
prefetchDone =
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
826
|
+
prefetchDone = step('vx:prefetch', () =>
|
|
827
|
+
startRemotePrefetch({
|
|
828
|
+
nodes,
|
|
829
|
+
cache,
|
|
830
|
+
workspaceRoot,
|
|
831
|
+
claimedFiles: fingerprintWatch.names,
|
|
832
|
+
forwardArgs: options.forwardArgs,
|
|
833
|
+
nestedDirsByProject,
|
|
834
|
+
gitFilesCache,
|
|
835
|
+
hashCache,
|
|
836
|
+
concurrency,
|
|
837
|
+
remoteRead: policy.remoteRead,
|
|
838
|
+
remoteWrite: policy.remoteWrite,
|
|
839
|
+
}),
|
|
840
|
+
)
|
|
976
841
|
}
|
|
977
842
|
|
|
978
843
|
// Local cache short-circuit (default-on). Up-front classify: derive
|
|
@@ -993,17 +858,19 @@ async function runOnBus(
|
|
|
993
858
|
prepared.localCache.holdsNothing()
|
|
994
859
|
if (coldLocal) prewarm()
|
|
995
860
|
else if (shouldShortCircuit(nodes, policy, cache)) {
|
|
996
|
-
shortCircuit = await
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
861
|
+
shortCircuit = await step('vx:keys', () =>
|
|
862
|
+
startLocalShortCircuit({
|
|
863
|
+
nodes,
|
|
864
|
+
cache,
|
|
865
|
+
workspaceRoot,
|
|
866
|
+
claimedFiles: fingerprintWatch.names,
|
|
867
|
+
forwardArgs: options.forwardArgs,
|
|
868
|
+
nestedDirsByProject,
|
|
869
|
+
gitFilesCache,
|
|
870
|
+
hashCache,
|
|
871
|
+
concurrency,
|
|
872
|
+
}),
|
|
873
|
+
)
|
|
1007
874
|
// A confirmed miss runs too.
|
|
1008
875
|
if (sandboxed.some((n) => shortCircuit.preProbed.get(n.id)?.hit === null)) prewarm()
|
|
1009
876
|
}
|
|
@@ -1014,7 +881,7 @@ async function runOnBus(
|
|
|
1014
881
|
// admission.ts.
|
|
1015
882
|
const excluded = excludedTaint(nodes)
|
|
1016
883
|
if (excluded.unsaved > 0) {
|
|
1017
|
-
log
|
|
884
|
+
warnOf(log)(
|
|
1018
885
|
`[vx] --exclude-dependencies: ${excluded.unsaved} cached task(s) build on a skipped dependency; what they build is not saved`,
|
|
1019
886
|
)
|
|
1020
887
|
}
|
|
@@ -1043,19 +910,50 @@ async function runOnBus(
|
|
|
1043
910
|
// The scheduler's fail-fast stop, as the retry loop of a task already
|
|
1044
911
|
// in flight sees it.
|
|
1045
912
|
const failFast = new AbortController()
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
913
|
+
const scope: RunScope = {
|
|
914
|
+
workspaceRoot,
|
|
915
|
+
cache,
|
|
916
|
+
cachePolicy: policy,
|
|
917
|
+
forwardArgs: options.forwardArgs,
|
|
918
|
+
deferred: deferredOutputs,
|
|
919
|
+
cacheDir,
|
|
920
|
+
runStartHrTimeNs,
|
|
921
|
+
persistentRegistry,
|
|
922
|
+
liveChildren,
|
|
923
|
+
gitFilesCache,
|
|
924
|
+
hashCache,
|
|
925
|
+
probesAfterWrites: lateProbes,
|
|
926
|
+
fingerprintWatch,
|
|
927
|
+
keyedProjects: keyed,
|
|
928
|
+
outputDirSnapshots,
|
|
929
|
+
deferSave: saveLane.defer,
|
|
930
|
+
deferredSaves,
|
|
931
|
+
commandWindows: windows,
|
|
932
|
+
stopSignal: stop.signal,
|
|
933
|
+
failFast: failFast.signal,
|
|
934
|
+
inputStability,
|
|
935
|
+
}
|
|
936
|
+
if (options.retries !== undefined) scope.retries = options.retries
|
|
937
|
+
if (taskTimeoutDefault !== undefined) scope.timeout = taskTimeoutDefault
|
|
938
|
+
if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
|
|
939
|
+
if (telemetry?.track !== undefined) scope.track = telemetry.track
|
|
940
|
+
if (explainMiss !== undefined) scope.explainMiss = explainMiss
|
|
941
|
+
if (keepRunLog) scope.keepRunLog = true
|
|
942
|
+
if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
|
|
943
|
+
const argsOf = executeArgsOf(scope, {
|
|
944
|
+
log: (node) => (node.config.exec?.persistent !== undefined ? serverLog(node.id) : log),
|
|
945
|
+
placements,
|
|
946
|
+
nestedDirsByProject,
|
|
947
|
+
downloadModeOf: download.modeOf,
|
|
948
|
+
shortCircuit,
|
|
949
|
+
dependedOn,
|
|
950
|
+
holders,
|
|
951
|
+
})
|
|
1051
952
|
const buildExecuteArgs = (
|
|
1052
953
|
node: TaskNode,
|
|
1053
954
|
upstream: TaskOutcome[],
|
|
1054
955
|
reuseProbe = true,
|
|
1055
956
|
): ExecuteArgs => {
|
|
1056
|
-
const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
|
|
1057
|
-
const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
|
|
1058
|
-
const upfrontGroupKey = shortCircuit.groupKeys.get(node.id)
|
|
1059
957
|
let deadServer: string | undefined
|
|
1060
958
|
if (options.continueMode === 'always') {
|
|
1061
959
|
for (const d of node.deps) {
|
|
@@ -1066,51 +964,7 @@ async function runOnBus(
|
|
|
1066
964
|
}
|
|
1067
965
|
const tainted = taint.judge(node, upstream)
|
|
1068
966
|
if (tainted) taintedRan.set(node.id, deadServer ?? taint.cause(node, upstream))
|
|
1069
|
-
|
|
1070
|
-
node,
|
|
1071
|
-
upstream,
|
|
1072
|
-
workspaceRoot,
|
|
1073
|
-
cache,
|
|
1074
|
-
cachePolicy: policy,
|
|
1075
|
-
forwardArgs: options.forwardArgs,
|
|
1076
|
-
log: node.config.exec?.persistent !== undefined ? serverLog(node.id) : log,
|
|
1077
|
-
executor: placements.executors.get(node.id) ?? UNPLACED_EXECUTOR,
|
|
1078
|
-
deferred: deferredOutputs,
|
|
1079
|
-
nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
|
|
1080
|
-
cacheDir,
|
|
1081
|
-
runStartHrTimeNs,
|
|
1082
|
-
persistentRegistry,
|
|
1083
|
-
liveChildren,
|
|
1084
|
-
gitFilesCache,
|
|
1085
|
-
hashCache,
|
|
1086
|
-
probesAfterWrites: lateProbes,
|
|
1087
|
-
fingerprintWatch,
|
|
1088
|
-
keyedProjects: keyed,
|
|
1089
|
-
outputDirSnapshots,
|
|
1090
|
-
deferSave: saveLane.defer,
|
|
1091
|
-
deferredSaves,
|
|
1092
|
-
commandWindows: windows,
|
|
1093
|
-
stopSignal: stopRun.signal,
|
|
1094
|
-
failFast: failFast.signal,
|
|
1095
|
-
}
|
|
1096
|
-
if (options.retries !== undefined) a.retries = options.retries
|
|
1097
|
-
if (taskTimeoutDefault !== undefined) a.timeout = taskTimeoutDefault
|
|
1098
|
-
if (options.readyNotice !== undefined) a.readyNotice = options.readyNotice
|
|
1099
|
-
a.inputStability = inputStability
|
|
1100
|
-
if (cleanOutputs !== undefined) a.cleanOutputs = cleanOutputs
|
|
1101
|
-
if (download.modeOf.get(node.id) === 'deferred') a.download = 'deferred'
|
|
1102
|
-
if (placements.remoteOnlyNoop.has(node.id)) a.remoteOnlyNoop = true
|
|
1103
|
-
if (placements.remoteOnly.has(node.id)) a.remoteOnly = true
|
|
1104
|
-
if (track !== undefined) a.track = track
|
|
1105
|
-
if (explainMiss !== undefined) a.explainMiss = explainMiss
|
|
1106
|
-
if (probe !== undefined) a.preProbed = probe
|
|
1107
|
-
if (upfrontKey !== undefined) a.upfrontKey = upfrontKey
|
|
1108
|
-
if (upfrontGroupKey !== undefined) a.upfrontGroupKey = upfrontGroupKey
|
|
1109
|
-
if (tainted) a.taintedUpstream = true
|
|
1110
|
-
if (!dependedOn.has(node.id)) a.noDependants = true
|
|
1111
|
-
if (holders.has(node.id)) a.terminal = true
|
|
1112
|
-
if (armSandbox !== undefined) a.armSandbox = armSandbox
|
|
1113
|
-
return a
|
|
967
|
+
return argsOf(node, upstream, reuseProbe, tainted)
|
|
1114
968
|
}
|
|
1115
969
|
|
|
1116
970
|
const executeWithDedup = admitTasks({
|
|
@@ -1136,13 +990,12 @@ async function runOnBus(
|
|
|
1136
990
|
(n) => n.config.exec?.command === undefined || shortCircuit.restoreTier.has(n.id),
|
|
1137
991
|
)
|
|
1138
992
|
? new Map<string, number>()
|
|
1139
|
-
: await prepared.priorities()
|
|
993
|
+
: await step('vx:schedule', () => prepared.priorities())
|
|
1140
994
|
mark('schedule')
|
|
1141
995
|
// Judged as each task finishes, against the history BEFORE this run's
|
|
1142
996
|
// rows land, so the row that prints next carries the verdict: nothing
|
|
1143
997
|
// prints below the footer. A hit or a skip asks nothing; a green miss
|
|
1144
998
|
// is one probe of the failed-row index.
|
|
1145
|
-
const flaky: FlakyFinding[] = []
|
|
1146
999
|
const historyDb = prepared.localCache.dbHandle()
|
|
1147
1000
|
const cacheOff = cachesNothing(policy)
|
|
1148
1001
|
const judgeFlaky = (o: TaskOutcome): void => {
|
|
@@ -1151,7 +1004,6 @@ async function runOnBus(
|
|
|
1151
1004
|
try {
|
|
1152
1005
|
const found = detectFlaky(historyDb, candidates)[0]
|
|
1153
1006
|
if (found === undefined) return
|
|
1154
|
-
flaky.push(found)
|
|
1155
1007
|
o.flaky = { passes: found.passes, failures: found.failures }
|
|
1156
1008
|
} catch {
|
|
1157
1009
|
// History is observability: an unreadable one judges nothing.
|
|
@@ -1187,7 +1039,7 @@ async function runOnBus(
|
|
|
1187
1039
|
...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
|
|
1188
1040
|
serverDied,
|
|
1189
1041
|
onFailFast: () => failFast.abort(),
|
|
1190
|
-
signal:
|
|
1042
|
+
signal: stop.signal,
|
|
1191
1043
|
onStart: (node) => {
|
|
1192
1044
|
log.taskStart?.(node)
|
|
1193
1045
|
},
|
|
@@ -1238,7 +1090,7 @@ async function runOnBus(
|
|
|
1238
1090
|
// Empty when the short-circuit didn't fire → byte-identical.
|
|
1239
1091
|
restoreTier: shortCircuit.restoreTier,
|
|
1240
1092
|
})
|
|
1241
|
-
graphDone
|
|
1093
|
+
stop.graphDone()
|
|
1242
1094
|
|
|
1243
1095
|
// Which persistent children outlive the graph, and the bounded SIGTERM
|
|
1244
1096
|
// of the rest, before the summary prints. Scoped to the real CLI
|
|
@@ -1248,49 +1100,19 @@ async function runOnBus(
|
|
|
1248
1100
|
// expect run() to return, not block on a server.
|
|
1249
1101
|
const foreground = options.log === undefined && (options.handleSignals ?? true)
|
|
1250
1102
|
// An aborted run's children are already being torn down: nothing to hold.
|
|
1251
|
-
const hold = options.holdPersistent === true && !
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
![...outcomes.values()].every((o) => isPassStatus(o.status)) ||
|
|
1262
|
-
[...persistentRegistry.values()].some(
|
|
1263
|
-
(c) => hasEnded(c) && c.exitCode !== 0 && !keepAlive.children.includes(c),
|
|
1264
|
-
)
|
|
1265
|
-
if (failedElsewhere && !hold && options.continueMode !== 'always')
|
|
1266
|
-
keepAlive = { nodes: [], children: [] }
|
|
1267
|
-
const crashedPersistent = (
|
|
1268
|
-
await shutdownPersistent(persistentRegistry, keepAlive.children)
|
|
1269
|
-
).filter((c) => endedBeforeStop?.has(persistentRegistry.get(c.id)!) ?? true)
|
|
1103
|
+
const hold = options.holdPersistent === true && !stop.signal.aborted
|
|
1104
|
+
const { keepAlive, crashed: crashedPersistent } = await disposeServers({
|
|
1105
|
+
registry: persistentRegistry,
|
|
1106
|
+
nodes,
|
|
1107
|
+
outcomes,
|
|
1108
|
+
keep: foreground || hold,
|
|
1109
|
+
hold,
|
|
1110
|
+
continueAlways: options.continueMode === 'always',
|
|
1111
|
+
endedBeforeStop: stop.endedBeforeStop,
|
|
1112
|
+
})
|
|
1270
1113
|
|
|
1271
1114
|
mark('run graph')
|
|
1272
1115
|
|
|
1273
|
-
// A server that died on its own before the stop failed, whatever its
|
|
1274
|
-
// outcome said when it became ready: the footer counted it a success
|
|
1275
|
-
// over the run's red exit (item 1071). A kept one that has already
|
|
1276
|
-
// died is the same; the keep-alive wait below names it. Its code is its
|
|
1277
|
-
// own, or the shell's report of the signal that took it.
|
|
1278
|
-
const failServer = (id: string, code: number | string): void => {
|
|
1279
|
-
const o = outcomes.get(id)
|
|
1280
|
-
if (o === undefined) return
|
|
1281
|
-
// In place: the renderer holds this outcome (events carry live refs)
|
|
1282
|
-
// and closes the server's output block from it at runEnd, where a
|
|
1283
|
-
// copy left it reading `running` under its exit line (WD-10).
|
|
1284
|
-
o.status = 'failed'
|
|
1285
|
-
o.exitCode = typeof code === 'number' ? code : signalExitCode(code)
|
|
1286
|
-
}
|
|
1287
|
-
for (const c of crashedPersistent) failServer(c.id, c.code)
|
|
1288
|
-
keepAlive.nodes.forEach((n, i) => {
|
|
1289
|
-
const child = keepAlive.children[i]!
|
|
1290
|
-
if (!hasEnded(child) || child.exitCode === 0) return
|
|
1291
|
-
if (endedBeforeStop !== undefined && !endedBeforeStop.has(child)) return
|
|
1292
|
-
failServer(n.id, child.exitCode ?? child.signalCode ?? 'unknown')
|
|
1293
|
-
})
|
|
1294
1116
|
// Clear the status line for good before the summary prints.
|
|
1295
1117
|
log.runEnd?.()
|
|
1296
1118
|
const list = [...outcomes.values()]
|
|
@@ -1316,7 +1138,7 @@ async function runOnBus(
|
|
|
1316
1138
|
// 1102).
|
|
1317
1139
|
for (const c of crashedPersistent) {
|
|
1318
1140
|
const code = typeof c.code === 'number' ? c.code : signalExitCode(c.code)
|
|
1319
|
-
log
|
|
1141
|
+
warnOf(log)(`vx: ${c.id} exited with code ${code} before the run stopped it`)
|
|
1320
1142
|
}
|
|
1321
1143
|
// Outputs that never came home are not an error, but a silent `dist/`
|
|
1322
1144
|
// that is empty-or-stale would be: name every task whose bytes are
|
|
@@ -1324,7 +1146,7 @@ async function runOnBus(
|
|
|
1324
1146
|
const stillDeferred = deferredOutputs.pending()
|
|
1325
1147
|
if (stillDeferred.length > 0) {
|
|
1326
1148
|
log.status(
|
|
1327
|
-
` Deferred:
|
|
1149
|
+
` Deferred: left outputs remote (--cache-download=none): ${stillDeferred.join(', ')}`,
|
|
1328
1150
|
)
|
|
1329
1151
|
}
|
|
1330
1152
|
|
|
@@ -1332,56 +1154,20 @@ async function runOnBus(
|
|
|
1332
1154
|
// change the run's exit code — the run already happened.
|
|
1333
1155
|
// Written again after the keep-alive wait: a kept server's crash or a
|
|
1334
1156
|
// Ctrl-C there is the process's exit, and the first write said ok.
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
if (options.json === true) {
|
|
1338
|
-
json = runSummaryJson({
|
|
1339
|
-
runId,
|
|
1340
|
-
startedAtMs: endedAtMsAtStart,
|
|
1341
|
-
endedAtMs,
|
|
1342
|
-
totalMs,
|
|
1343
|
-
ok: runOk,
|
|
1344
|
-
...(stoppedBy !== undefined && { exitCode: signalExitCode(stoppedBy) }),
|
|
1345
|
-
outcomes: final,
|
|
1346
|
-
flaky,
|
|
1347
|
-
})
|
|
1348
|
-
}
|
|
1349
|
-
if (options.summarize === undefined) return
|
|
1350
|
-
// The rewrite after the keep-alive wait says nothing: the footer is
|
|
1351
|
-
// the run's last word.
|
|
1352
|
-
const quiet = final !== list
|
|
1353
|
-
try {
|
|
1354
|
-
const wrote = await writeRunSummary({
|
|
1355
|
-
target: options.summarize,
|
|
1356
|
-
cacheDir,
|
|
1357
|
-
cwd: options.cwd,
|
|
1358
|
-
runId,
|
|
1359
|
-
startedAtMs: endedAtMsAtStart,
|
|
1360
|
-
endedAtMs,
|
|
1361
|
-
totalMs,
|
|
1362
|
-
ok: runOk,
|
|
1363
|
-
...(stoppedBy !== undefined && { exitCode: signalExitCode(stoppedBy) }),
|
|
1364
|
-
outcomes: final,
|
|
1365
|
-
flaky,
|
|
1366
|
-
})
|
|
1367
|
-
if (!quiet) log.status(`vx: summary written to ${wrote}`)
|
|
1368
|
-
} catch (err) {
|
|
1369
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
1370
|
-
log.status(`vx: failed to write summary: ${msg}`)
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
await summarize(ok)
|
|
1374
|
-
if (options.profile !== undefined) {
|
|
1157
|
+
const profile = options.profile
|
|
1158
|
+
if (profile !== undefined) {
|
|
1375
1159
|
try {
|
|
1376
|
-
const wrote = await
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1160
|
+
const wrote = await step('vx:profile', () =>
|
|
1161
|
+
writeRunProfile({
|
|
1162
|
+
target: profile,
|
|
1163
|
+
cwd: options.cwd,
|
|
1164
|
+
outcomes: list,
|
|
1165
|
+
}),
|
|
1166
|
+
)
|
|
1381
1167
|
log.status(`vx: profile written to ${wrote}`)
|
|
1382
1168
|
} catch (err) {
|
|
1383
1169
|
const msg = err instanceof Error ? err.message : String(err)
|
|
1384
|
-
log
|
|
1170
|
+
warnOf(log)(`vx: failed to write profile: ${msg}`)
|
|
1385
1171
|
}
|
|
1386
1172
|
}
|
|
1387
1173
|
// Record each task to the run history in a single SQLite transaction
|
|
@@ -1405,15 +1191,15 @@ async function runOnBus(
|
|
|
1405
1191
|
// own output masks it, so cache.db holds no plaintext value.
|
|
1406
1192
|
command: maskInvocation(options.command ?? process.argv.slice(1).join(' ')),
|
|
1407
1193
|
requestedTasks: options.tasks,
|
|
1408
|
-
cachePolicy:
|
|
1194
|
+
cachePolicy: formatCachePolicy(policy),
|
|
1409
1195
|
concurrency,
|
|
1410
1196
|
flow: options.flow ?? null,
|
|
1411
1197
|
forwardArgs: options.forwardArgs,
|
|
1412
|
-
tags: maskTags(options.tags ?? {}),
|
|
1413
1198
|
git: gitContext,
|
|
1414
1199
|
ci: ciContext,
|
|
1415
1200
|
host: hostContext,
|
|
1416
1201
|
withTelemetry: telemetry !== undefined,
|
|
1202
|
+
remoteTraffic: prepared.localCache.traffic.keys,
|
|
1417
1203
|
})
|
|
1418
1204
|
const records = recordsOf(list, ok)
|
|
1419
1205
|
const recordHistory = (write: () => void): void => {
|
|
@@ -1425,27 +1211,21 @@ async function runOnBus(
|
|
|
1425
1211
|
// an unprivileged user on a 2 MiB disk, 2026-09-16). The run said
|
|
1426
1212
|
// what happened; `vx last` and the flaky list will not know this one.
|
|
1427
1213
|
const message = err instanceof Error ? err.message : String(err)
|
|
1428
|
-
log
|
|
1214
|
+
warnOf(log)(`[vx] run history not recorded: ${message} — the verdict above stands`)
|
|
1429
1215
|
}
|
|
1430
1216
|
}
|
|
1431
1217
|
// A server kept in the foreground can still fail the run after the
|
|
1432
1218
|
// cache closes: its history is written once the wait has decided, or it
|
|
1433
1219
|
// read `ok` over the exit 1 and fed the flaky list a pass (X-23).
|
|
1434
|
-
const historyAfterWait = keepAlive.children.length > 0 && !hold && stoppedBy === undefined
|
|
1435
|
-
// A failed task's output, for `vx last` and an agent
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
log.status(`[vx] failed tasks' output not kept: ${err.message} — the verdict above stands`)
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
if (stoppedBy === undefined && !historyAfterWait) {
|
|
1447
|
-
recordHistory(() => cache.recordRunBundle(records))
|
|
1448
|
-
recordFailures(list)
|
|
1220
|
+
const historyAfterWait = keepAlive.children.length > 0 && !hold && stop.stoppedBy === undefined
|
|
1221
|
+
// A failed task's output, for `vx last` and an agent (run-failures.ts).
|
|
1222
|
+
const recordFailures = (final: readonly TaskOutcome[]): void =>
|
|
1223
|
+
writeRunFailures(prepared.cacheDir, runId, final, warnOf(log))
|
|
1224
|
+
if (stop.stoppedBy === undefined && !historyAfterWait) {
|
|
1225
|
+
stepSync('vx:history', () => {
|
|
1226
|
+
recordHistory(() => cache.recordRunBundle(records))
|
|
1227
|
+
recordFailures(list)
|
|
1228
|
+
})
|
|
1449
1229
|
}
|
|
1450
1230
|
mark('record history')
|
|
1451
1231
|
// Drain any still-in-flight background prefetches before closing the
|
|
@@ -1463,13 +1243,29 @@ async function runOnBus(
|
|
|
1463
1243
|
// channel shut from under the upload — silently losing every remote
|
|
1464
1244
|
// write with nothing but a warning. The seam's contract is that a
|
|
1465
1245
|
// cache layer stays usable until the run's uploads have settled.
|
|
1466
|
-
await prefetchDone
|
|
1467
1246
|
// Every deferred save has settled by here: the scheduler finishes a
|
|
1468
1247
|
// task only once its `settledOf` promise (the lane's `landed`) has,
|
|
1469
1248
|
// so `runGraph` resolved with every save in the cache. A second
|
|
1470
1249
|
// drain of the lane stood here until item 634; deleting it changed
|
|
1471
1250
|
// nothing the suite could see, because this gate is the rule.
|
|
1472
|
-
await
|
|
1251
|
+
await step('vx:uploads', async () => {
|
|
1252
|
+
await prefetchDone
|
|
1253
|
+
await cache.drainUploads?.()
|
|
1254
|
+
})
|
|
1255
|
+
// An upload that failed after the rows went in: its row says so too.
|
|
1256
|
+
if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
|
|
1257
|
+
recordHistory(() => recordLateRemoteErrors(prepared.localCache, records.runs, runId))
|
|
1258
|
+
const report = await step('vx:keep-run', () =>
|
|
1259
|
+
keepRunBytes(list, {
|
|
1260
|
+
runId,
|
|
1261
|
+
commit: head.commitSha,
|
|
1262
|
+
ciRun: ciContext,
|
|
1263
|
+
ok,
|
|
1264
|
+
workspaceRoot: prepared.workspaceRoot,
|
|
1265
|
+
nestedDirsByProject: prepared.nestedDirsByProject,
|
|
1266
|
+
keep: prepared.keepRunEntry,
|
|
1267
|
+
}),
|
|
1268
|
+
)
|
|
1473
1269
|
// Hand the per-run summary to the telemetry sinks + drain them. Only
|
|
1474
1270
|
// when a sink is active (telemetry !== undefined) — otherwise this
|
|
1475
1271
|
// whole block is skipped and the run is byte-identical to before.
|
|
@@ -1488,72 +1284,52 @@ async function runOnBus(
|
|
|
1488
1284
|
uploads: prepared.localCache.uploads,
|
|
1489
1285
|
})
|
|
1490
1286
|
telemetry.emitSummary(summary)
|
|
1491
|
-
|
|
1287
|
+
const sinks = telemetry
|
|
1288
|
+
await step('vx:telemetry', () => sinks.flush())
|
|
1492
1289
|
}
|
|
1493
1290
|
// End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
|
|
1494
1291
|
// + time-bounded inside teardownPlugins, so a faulty plugin can neither
|
|
1495
1292
|
// fail nor hang the run. Here on the normal path, after the drains
|
|
1496
1293
|
// above; every other exit takes it through `teardown` too.
|
|
1497
|
-
// The miss path's output-directory snapshots
|
|
1498
|
-
|
|
1499
|
-
// miss-save.ts). A few at a time: each is an lstat + readdir per
|
|
1500
|
-
// prefix; the rows land together at close, in one transaction.
|
|
1501
|
-
// Each walk is held to its entry's rows (item 1087), read here in one
|
|
1502
|
-
// batch: read per snapshot, each read also flushed the snapshots before
|
|
1503
|
-
// it, one transaction apiece (+110 ms at 1,000).
|
|
1504
|
-
const snapshotRows = new Map<string, ReadonlyArray<{ path: string }>>()
|
|
1505
|
-
for (let i = 0; i < outputDirSnapshots.length; i += 900) {
|
|
1506
|
-
const batch = cache.loadOutputFilesBatch(
|
|
1507
|
-
outputDirSnapshots.slice(i, i + 900).map((s) => s.hash),
|
|
1508
|
-
)
|
|
1509
|
-
for (const [hash, rows] of batch) snapshotRows.set(hash, rows)
|
|
1510
|
-
}
|
|
1511
|
-
for (let i = 0; i < outputDirSnapshots.length; i += 32) {
|
|
1512
|
-
await Promise.all(
|
|
1513
|
-
outputDirSnapshots
|
|
1514
|
-
.slice(i, i + 32)
|
|
1515
|
-
.map((s) =>
|
|
1516
|
-
cache.recordOutputDirs?.(s.hash, s.projectDir, s.prefixes, (files) =>
|
|
1517
|
-
s.holds(files, snapshotRows.get(s.hash) ?? []),
|
|
1518
|
-
),
|
|
1519
|
-
),
|
|
1520
|
-
)
|
|
1521
|
-
}
|
|
1294
|
+
// The miss path's output-directory snapshots first (miss-save.ts).
|
|
1295
|
+
await step('vx:snapshots', () => recordOutputDirSnapshots(cache, outputDirSnapshots))
|
|
1522
1296
|
mark('output dir snapshots')
|
|
1523
1297
|
// Not on a stopped run: one stopped while it waited on another run's
|
|
1524
1298
|
// lock never held it, and its prune evicted under that run (item 858).
|
|
1525
|
-
if (!
|
|
1299
|
+
if (!stop.signal.aborted) await step('vx:retention', () => applyCacheRetention(prepared))
|
|
1526
1300
|
// A plugin hears the run until its teardown and nothing after: released
|
|
1527
1301
|
// only in the finally, its handlers heard a kept server through the
|
|
1528
1302
|
// whole keep-alive wait below (C-66). Idempotent; the finally's stay.
|
|
1529
1303
|
disposePlugins?.()
|
|
1530
1304
|
telemetry?.dispose()
|
|
1531
|
-
await teardown()
|
|
1532
|
-
await closeCache
|
|
1305
|
+
await teardown(undefined, report)
|
|
1306
|
+
await step('vx:close', closeCache)
|
|
1533
1307
|
mark('close')
|
|
1534
1308
|
printTimings()
|
|
1535
1309
|
|
|
1536
1310
|
// Tear down SRT's network bridge + (on macOS) log monitor. No-op if
|
|
1537
1311
|
// no task was sandboxed; otherwise SRT keeps proxy servers alive and
|
|
1538
1312
|
// the next vx run would init on top of stale state.
|
|
1539
|
-
await
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1313
|
+
await step('vx:sandbox', async () => {
|
|
1314
|
+
await prewarming
|
|
1315
|
+
if (sandboxArmer?.armed) {
|
|
1316
|
+
try {
|
|
1317
|
+
await resetSandbox()
|
|
1318
|
+
} catch (err) {
|
|
1319
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
1320
|
+
warnOf(log)(`vx: sandbox cleanup failed: ${msg}`)
|
|
1321
|
+
}
|
|
1546
1322
|
}
|
|
1547
|
-
}
|
|
1323
|
+
})
|
|
1548
1324
|
|
|
1549
1325
|
// The footer is the run's last word (owner, 2026-10-06): it prints once
|
|
1550
1326
|
// the run's own work is done, history, uploads, plugin teardown and the
|
|
1551
1327
|
// sandbox reset included, so any warning they raise lands above it. A
|
|
1552
1328
|
// task's own facts (flaky, blocked) ride its row. Only a kept server's
|
|
1553
1329
|
// own output follows.
|
|
1330
|
+
// The footer replaces the live region in place.
|
|
1331
|
+
log.runDone?.()
|
|
1554
1332
|
if (options.summaryTable === true) for (const line of formatOutcomeTable(list)) log.status(line)
|
|
1555
|
-
const above = options.beforeFooter?.(list, ok)
|
|
1556
|
-
if (above) log.status(above.replace(/\n$/, ''))
|
|
1557
1333
|
if (options.footer !== false)
|
|
1558
1334
|
for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
|
|
1559
1335
|
|
|
@@ -1572,57 +1348,18 @@ async function runOnBus(
|
|
|
1572
1348
|
// ended the session and its code — the summary above said `success`
|
|
1573
1349
|
// for it, and an exit 1 with no word about why is a mystery in a log.
|
|
1574
1350
|
if (keepAlive.children.length > 0 && hold) {
|
|
1575
|
-
const held = keepAlive.children
|
|
1576
|
-
// A held server that dies on its own is said, as the foreground's
|
|
1577
|
-
// keep-alive says it: `vx watch` sat on "watching" over a dead dev
|
|
1578
|
-
// server. The holder's own stop() is not such a death.
|
|
1579
|
-
let stopping = false
|
|
1580
|
-
// Taken by a later run (`RunOptions.keep`): kept or stopped there.
|
|
1581
|
-
const handed = new Set<ReturnType<typeof Bun.spawn>>()
|
|
1582
|
-
const servers = new Map<string, HeldServer>()
|
|
1583
|
-
keepAlive.nodes.forEach((n, i) => {
|
|
1584
|
-
const child = held[i]!
|
|
1585
|
-
servers.set(n.id, {
|
|
1586
|
-
child,
|
|
1587
|
-
config: serverConfig(n, options.forwardArgs),
|
|
1588
|
-
out: serverOuts.get(n.id) ?? { bus },
|
|
1589
|
-
handOver: () => void handed.add(child),
|
|
1590
|
-
})
|
|
1591
|
-
// One that died during the graph was said then.
|
|
1592
|
-
if (hasEnded(child)) return
|
|
1593
|
-
void child.exited.then((code) => {
|
|
1594
|
-
if (!stopping && !handed.has(child) && code !== 0)
|
|
1595
|
-
log.status(`vx: ${n.id} exited with code ${code}`)
|
|
1596
|
-
})
|
|
1597
|
-
})
|
|
1598
1351
|
return {
|
|
1599
1352
|
ok,
|
|
1600
1353
|
outcomes: list,
|
|
1601
|
-
persistent: {
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
return terminateChildren(() => held.filter((c) => !handed.has(c)), signal)
|
|
1607
|
-
},
|
|
1608
|
-
},
|
|
1354
|
+
persistent: holdServers(keepAlive, {
|
|
1355
|
+
forwardArgs: options.forwardArgs,
|
|
1356
|
+
outOf: (id) => serverOuts.get(id) ?? { bus },
|
|
1357
|
+
warn: warnOf(log),
|
|
1358
|
+
}),
|
|
1609
1359
|
}
|
|
1610
1360
|
}
|
|
1611
1361
|
if (keepAlive.children.length > 0) {
|
|
1612
|
-
|
|
1613
|
-
// forked and returned (schema.md), not the end of the session: a
|
|
1614
|
-
// `db: 'docker compose up -d'` under `app#dev` stopped the dev server
|
|
1615
|
-
// right after the summary (WD-3). It ends the wait only as the last.
|
|
1616
|
-
const first = await new Promise<{ code: number; i: number }>((resolve) => {
|
|
1617
|
-
let left = keepAlive.children.length
|
|
1618
|
-
keepAlive.children.forEach((c, i) => {
|
|
1619
|
-
void c.exited.then((code) => {
|
|
1620
|
-
left--
|
|
1621
|
-
const n = keepAlive.nodes[i]!
|
|
1622
|
-
if (code !== 0 || n.requested || n.surfaced === true || left === 0) resolve({ code, i })
|
|
1623
|
-
})
|
|
1624
|
-
})
|
|
1625
|
-
})
|
|
1362
|
+
const first = await sessionEnd(keepAlive)
|
|
1626
1363
|
const node = keepAlive.nodes[first.i]!
|
|
1627
1364
|
// Only the ones still up are stopped: a kept server already dead was
|
|
1628
1365
|
// counted as one (WD-11).
|
|
@@ -1630,8 +1367,8 @@ async function runOnBus(
|
|
|
1630
1367
|
// Not when the run was stopped: the server ended because the user
|
|
1631
1368
|
// stopped it, and "exited with code 130" read as a crash after every
|
|
1632
1369
|
// Ctrl-C once a stop let run() finish its own path (item 852).
|
|
1633
|
-
if (!
|
|
1634
|
-
log
|
|
1370
|
+
if (!stop.signal.aborted) {
|
|
1371
|
+
warnOf(log)(
|
|
1635
1372
|
`vx: ${node.id} exited with code ${first.code}` +
|
|
1636
1373
|
(others > 0
|
|
1637
1374
|
? `; stopping ${others} other persistent task${others === 1 ? '' : 's'}`
|
|
@@ -1641,14 +1378,11 @@ async function runOnBus(
|
|
|
1641
1378
|
// Under a stop the abort is already taking them down with the stop's
|
|
1642
1379
|
// signal; a second SIGTERM in its grace cut short the graceful
|
|
1643
1380
|
// shutdown of a server that reads it as "quit now" (WD-16).
|
|
1644
|
-
if (
|
|
1381
|
+
if (stop.signal.aborted) await stop.aborting?.catch(() => {})
|
|
1645
1382
|
else await terminateChildren(() => keepAlive.children)
|
|
1646
|
-
// The server that ended the session on its own, not cleanly, failed
|
|
1647
|
-
|
|
1648
|
-
// and `failed: 0`, and so did the outcomes `--report` renders (C-53).
|
|
1649
|
-
if (!stopRun.signal.aborted && first.code !== 0) failServer(node.id, first.code)
|
|
1383
|
+
// The server that ended the session on its own, not cleanly, failed.
|
|
1384
|
+
if (!stop.signal.aborted && first.code !== 0) failServer(outcomes, node.id, first.code)
|
|
1650
1385
|
const final = [...outcomes.values()]
|
|
1651
|
-
await summarize(ok && first.code === 0, final)
|
|
1652
1386
|
if (historyAfterWait) {
|
|
1653
1387
|
recordHistory(() => {
|
|
1654
1388
|
// A writer's open: another vx version may have reset the index
|
|
@@ -1662,10 +1396,10 @@ async function runOnBus(
|
|
|
1662
1396
|
})
|
|
1663
1397
|
recordFailures(final)
|
|
1664
1398
|
}
|
|
1665
|
-
return { ok: ok && first.code === 0, outcomes: final
|
|
1399
|
+
return { ok: ok && first.code === 0, outcomes: final }
|
|
1666
1400
|
}
|
|
1667
1401
|
|
|
1668
|
-
return { ok, outcomes: list
|
|
1402
|
+
return { ok, outcomes: list }
|
|
1669
1403
|
} finally {
|
|
1670
1404
|
// Idempotent. The status-line ticker starts in runStart, and every
|
|
1671
1405
|
// call between it and the success path's runEnd is crash-isolated
|
|
@@ -1675,9 +1409,8 @@ async function runOnBus(
|
|
|
1675
1409
|
// here), so deleting this line reddens nothing (item 635). It is the
|
|
1676
1410
|
// floor for a throw a later change adds, not a path a row can drive.
|
|
1677
1411
|
log.runEnd?.()
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
stopRun.signal.removeEventListener('abort', onAbort)
|
|
1412
|
+
log.runDone?.()
|
|
1413
|
+
stop.detach()
|
|
1681
1414
|
// Plugins installed at the top of run() get their bus subscriptions
|
|
1682
1415
|
// released here. Idempotent; safe even if installPlugins threw.
|
|
1683
1416
|
disposePlugins?.()
|
|
@@ -1694,9 +1427,9 @@ async function runOnBus(
|
|
|
1694
1427
|
} catch {
|
|
1695
1428
|
// teardown must not throw on the way out
|
|
1696
1429
|
}
|
|
1697
|
-
await aborting?.catch(() => {})
|
|
1430
|
+
await stop.aborting?.catch(() => {})
|
|
1698
1431
|
// Last: a process signal waits for this before it exits.
|
|
1699
|
-
|
|
1432
|
+
stop.left()
|
|
1700
1433
|
}
|
|
1701
1434
|
}
|
|
1702
1435
|
|
|
@@ -1741,7 +1474,7 @@ function noneAffected(prepared: PreparedRun, options: RunOptions): string {
|
|
|
1741
1474
|
* Planning mode. Same setup as `run()` — workspace discovery, config
|
|
1742
1475
|
* load, package graph, task graph — but stops short of execution.
|
|
1743
1476
|
* Returns a `RunPlan` predicting the cache hit/miss outcome of every
|
|
1744
|
-
* task. Used by `--dry
|
|
1477
|
+
* task. Used by `--dry`.
|
|
1745
1478
|
*
|
|
1746
1479
|
* Side-effects are limited to opening + closing the local Cache handle
|
|
1747
1480
|
* (and running `cache.inputs.runtime` probe commands, which key
|
|
@@ -1761,7 +1494,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1761
1494
|
return {
|
|
1762
1495
|
tasks: [],
|
|
1763
1496
|
unresolvedTasks: prepared.unresolvedTasks,
|
|
1764
|
-
unresolvedHint:
|
|
1497
|
+
unresolvedHint: await unresolvedHint(prepared),
|
|
1765
1498
|
}
|
|
1766
1499
|
}
|
|
1767
1500
|
if (prepared.empty === 'no-tasks-declared' && prepared.declaredElsewhere.length > 0) {
|
|
@@ -1816,7 +1549,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1816
1549
|
} finally {
|
|
1817
1550
|
// The plan called the cache and executor factories; teardown releases
|
|
1818
1551
|
// what they opened, as a run's end does (item 1021).
|
|
1819
|
-
await teardownPlugins(prepared.plugins, (
|
|
1552
|
+
await teardownPlugins(prepared.plugins, warnOf(log))
|
|
1820
1553
|
prepared.cache.close()
|
|
1821
1554
|
// The same table a run prints: a dry run is how the prepare stages
|
|
1822
1555
|
// (discovery, config load, the git enumeration) get profiled on a
|
|
@@ -1865,6 +1598,31 @@ function elsewhereHint(names: readonly string[]): string {
|
|
|
1865
1598
|
return ` Only projects outside the selection declare ${names.join(', ')} — pass --all, or --filter to pick them.`
|
|
1866
1599
|
}
|
|
1867
1600
|
|
|
1601
|
+
/**
|
|
1602
|
+
* What follows "no projects declare task(s): …": where the name is, its
|
|
1603
|
+
* nearest spelling, and, with neither, the lock a run reads its configs
|
|
1604
|
+
* from: a task just added to a vx.config is not in it until `vx lock`.
|
|
1605
|
+
*/
|
|
1606
|
+
async function unresolvedHint(prepared: {
|
|
1607
|
+
unresolvedTasks: readonly string[]
|
|
1608
|
+
hintProjects: ReadonlyMap<string, ProjectEntry>
|
|
1609
|
+
declaredElsewhere: readonly string[]
|
|
1610
|
+
anyProjectConfig: boolean
|
|
1611
|
+
}): Promise<string> {
|
|
1612
|
+
const elsewhere = elsewhereHint(prepared.declaredElsewhere)
|
|
1613
|
+
const near = didYouMean(
|
|
1614
|
+
prepared.unresolvedTasks,
|
|
1615
|
+
prepared.hintProjects,
|
|
1616
|
+
prepared.declaredElsewhere,
|
|
1617
|
+
)
|
|
1618
|
+
const init = await initHint(prepared)
|
|
1619
|
+
const lock =
|
|
1620
|
+
init === '' && elsewhere === '' && !near.startsWith(' Did you mean')
|
|
1621
|
+
? ' A run reads the lock: after editing a vx.config, run `vx lock`.'
|
|
1622
|
+
: ''
|
|
1623
|
+
return `${elsewhere}${near}${init}${lock}`
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1868
1626
|
/**
|
|
1869
1627
|
* The first-run case, told apart from a typo: no project in the workspace
|
|
1870
1628
|
* has a `vx.config.*` at all, so no name could have resolved. Reached only
|