@vzn/vx 0.0.651 → 0.0.653
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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +142 -6
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +58 -31
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
package/src/orchestrator/run.ts
CHANGED
|
@@ -4,15 +4,26 @@
|
|
|
4
4
|
|
|
5
5
|
import type { InputStability } from '../config.js'
|
|
6
6
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
EMPTY_LOCK,
|
|
9
|
+
findProjectConfigs,
|
|
10
|
+
inputStabilityEnv,
|
|
11
|
+
LOCK_INVALID,
|
|
12
|
+
lockEntryBroken,
|
|
13
|
+
lockKey,
|
|
14
|
+
readLockfile,
|
|
15
|
+
} from '../workspace/index.js'
|
|
8
16
|
import { realpathSync } from 'node:fs'
|
|
9
17
|
import path from 'node:path'
|
|
10
18
|
import {
|
|
19
|
+
historyFiles,
|
|
11
20
|
indexDb,
|
|
12
21
|
Cache,
|
|
13
22
|
type CachePolicy,
|
|
14
23
|
cachesNothing,
|
|
15
24
|
formatCachePolicy,
|
|
25
|
+
holdCacheDir,
|
|
26
|
+
HistoryStream,
|
|
16
27
|
networkConcurrency,
|
|
17
28
|
keepRunNotes,
|
|
18
29
|
stopRuntimeProbes,
|
|
@@ -27,11 +38,13 @@ import {
|
|
|
27
38
|
VX_RUN_WORKSPACE_ENV,
|
|
28
39
|
} from '../exec/index.js'
|
|
29
40
|
import { DeferredOutputs } from './deferred-outputs.js'
|
|
41
|
+
import { workspaceCleanOf } from './task-hash.js'
|
|
30
42
|
import { resolveDownloadModes } from './download-policy.js'
|
|
31
|
-
import type { MachineReport, TaskExecutor } from '../exec/index.js'
|
|
43
|
+
import type { MachineReport, TaskExecutor, WorkerAdvice } from '../exec/index.js'
|
|
32
44
|
import {
|
|
33
45
|
deadServerBehind,
|
|
34
46
|
declaredTask,
|
|
47
|
+
isCacheHit,
|
|
35
48
|
isGroupTask,
|
|
36
49
|
markSurfacedDeps,
|
|
37
50
|
runGraph,
|
|
@@ -56,6 +69,7 @@ import {
|
|
|
56
69
|
isUserError,
|
|
57
70
|
SecretBroker,
|
|
58
71
|
thrownMessage,
|
|
72
|
+
relPosix,
|
|
59
73
|
} from '../util/index.js'
|
|
60
74
|
import * as ui from '../ui/index.js'
|
|
61
75
|
|
|
@@ -65,7 +79,7 @@ function printTimings(): void {
|
|
|
65
79
|
if (lines !== undefined) ui.print('err', ui.trace(lines))
|
|
66
80
|
}
|
|
67
81
|
import { detectColors, type ColorSupport } from '../ui/index.js'
|
|
68
|
-
import { keyedProjects } from './keyed-projects.js'
|
|
82
|
+
import { keyedProjects, nestedDirsOf, readableDeps } from './keyed-projects.js'
|
|
69
83
|
import { isDefaultBuild } from '../graph/index.js'
|
|
70
84
|
import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
|
|
71
85
|
import { recordOutputDirSnapshots, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
|
|
@@ -86,13 +100,16 @@ import {
|
|
|
86
100
|
formatPersistentList,
|
|
87
101
|
formatRunSummary,
|
|
88
102
|
type Logger,
|
|
103
|
+
neverStarted,
|
|
89
104
|
plainOutput,
|
|
90
105
|
resolveOutputView,
|
|
106
|
+
type RunEvent,
|
|
91
107
|
terminalSubscriber,
|
|
92
108
|
warnOf,
|
|
93
109
|
} from '../report/index.js'
|
|
94
110
|
import { installPlugins, PluginSetupError, type RunReport } from './plugin.js'
|
|
95
111
|
import { keepsFailures, reportOf, RunKeeper } from './run-report.js'
|
|
112
|
+
import { freezeDeep } from './read-only.js'
|
|
96
113
|
import {
|
|
97
114
|
buildAdmission,
|
|
98
115
|
executorDemand,
|
|
@@ -109,9 +126,21 @@ import { isPassStatus } from '../graph/index.js'
|
|
|
109
126
|
import type { RunContextRecord } from './telemetry.js'
|
|
110
127
|
import { plan, type RunPlan } from './plan.js'
|
|
111
128
|
import type { AffectedReason } from './affected-tasks.js'
|
|
112
|
-
import {
|
|
113
|
-
|
|
114
|
-
|
|
129
|
+
import {
|
|
130
|
+
namesRemote,
|
|
131
|
+
openRemotes,
|
|
132
|
+
outputWallsOf,
|
|
133
|
+
prepareRunLocked,
|
|
134
|
+
type PreparedRun,
|
|
135
|
+
} from './prepare.js'
|
|
136
|
+
import { acquireRunLock, runLockPath, type RunLockRelease } from './run-lock.js'
|
|
137
|
+
import {
|
|
138
|
+
exitOfSignal,
|
|
139
|
+
type HeldSignals,
|
|
140
|
+
holdSignals,
|
|
141
|
+
terminateChildren,
|
|
142
|
+
unlessStopped,
|
|
143
|
+
} from './signals.js'
|
|
115
144
|
import { CYCLE_INTERRUPT, runStop } from './run-stop.js'
|
|
116
145
|
import {
|
|
117
146
|
hasPooledExecutor,
|
|
@@ -138,14 +167,22 @@ import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
|
|
|
138
167
|
import { askRemote, serversToHold } from './held-servers.js'
|
|
139
168
|
|
|
140
169
|
import { writeRunFailures } from './run-failures.js'
|
|
141
|
-
import {
|
|
170
|
+
import {
|
|
171
|
+
assembleRunRecords,
|
|
172
|
+
recordLateRemoteErrors,
|
|
173
|
+
type RecordedOutcome,
|
|
174
|
+
runRecordOf,
|
|
175
|
+
} from './run-records.js'
|
|
142
176
|
import {
|
|
143
177
|
hasEnded,
|
|
144
178
|
disposeServers,
|
|
145
179
|
failServer,
|
|
146
180
|
holdServers,
|
|
147
181
|
sessionEnd,
|
|
182
|
+
serverUsers,
|
|
183
|
+
stopServer,
|
|
148
184
|
takeHeldServers,
|
|
185
|
+
unneededServers,
|
|
149
186
|
} from './persistent.js'
|
|
150
187
|
import { serverKey } from './persistent-task.js'
|
|
151
188
|
import { writeRunProfile } from './run-artifacts.js'
|
|
@@ -155,6 +192,7 @@ import {
|
|
|
155
192
|
createMissExplainer,
|
|
156
193
|
detectFlaky,
|
|
157
194
|
type FlakyCandidate,
|
|
195
|
+
knownFlaky,
|
|
158
196
|
LocalHistoryProvider,
|
|
159
197
|
} from '../history/index.js'
|
|
160
198
|
import type { RunOptions, RunSummary } from './options.js'
|
|
@@ -380,11 +418,21 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
380
418
|
unsubscribeTerminal = bus.subscribe(guardedOutput(claimed, render))
|
|
381
419
|
}
|
|
382
420
|
let held = false
|
|
421
|
+
const signals: { held?: HeldSignals } = {}
|
|
383
422
|
try {
|
|
384
423
|
// vx's own steps are timed only where something renders them.
|
|
385
424
|
const showsSteps =
|
|
386
425
|
terminal !== null ? terminal.showsSteps : options.log?.stepStart !== undefined
|
|
387
|
-
const summary = await runOnBus(
|
|
426
|
+
const summary = await runOnBus(
|
|
427
|
+
options,
|
|
428
|
+
bus,
|
|
429
|
+
warned,
|
|
430
|
+
colors,
|
|
431
|
+
terminal,
|
|
432
|
+
showsSteps,
|
|
433
|
+
signals,
|
|
434
|
+
takeOutput,
|
|
435
|
+
)
|
|
388
436
|
if (summary.persistent === undefined) return summary
|
|
389
437
|
// Servers handed back still running still write: `vx watch dev` printed
|
|
390
438
|
// none of its server's log while it idled, the renderer gone with this
|
|
@@ -400,6 +448,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
400
448
|
}
|
|
401
449
|
} finally {
|
|
402
450
|
if (!held) detach()
|
|
451
|
+
// A signal heard before the run's stop took over: the run left before
|
|
452
|
+
// its tasks (a refusal, a setup that threw), its plugins torn down and
|
|
453
|
+
// its lock released, and vx leaves as that signal says.
|
|
454
|
+
const heard = signals.held?.takeOver()
|
|
455
|
+
if (heard !== undefined) await exitOfSignal(heard)
|
|
403
456
|
}
|
|
404
457
|
}
|
|
405
458
|
|
|
@@ -410,6 +463,7 @@ async function runOnBus(
|
|
|
410
463
|
colors: ColorSupport,
|
|
411
464
|
terminal: DefaultLogger | null,
|
|
412
465
|
showsSteps: boolean,
|
|
466
|
+
signals: { held?: HeldSignals },
|
|
413
467
|
takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
|
|
414
468
|
): Promise<RunSummary> {
|
|
415
469
|
const log = busLogger(bus)
|
|
@@ -418,17 +472,82 @@ async function runOnBus(
|
|
|
418
472
|
// A server's output goes through a cell of its own: a later watch cycle
|
|
419
473
|
// that keeps the server points it at that cycle's bus (`RunOptions.keep`).
|
|
420
474
|
const serverOuts = new Map<string, { bus: EventBus }>()
|
|
475
|
+
// What a server writes from ready until its outcome is logged: renderers
|
|
476
|
+
// keep output before the outcome for its frame, which a ready server's
|
|
477
|
+
// row never prints, so its first lines since ready were lost.
|
|
478
|
+
const sinceReady = new Map<string, RunEvent[]>()
|
|
421
479
|
const serverLog = (id: string): Logger => {
|
|
422
480
|
const out = { bus }
|
|
423
481
|
serverOuts.set(id, out)
|
|
482
|
+
const emit = (event: RunEvent): void => {
|
|
483
|
+
const held = sinceReady.get(id)
|
|
484
|
+
if (held === undefined) out.bus.emit(event)
|
|
485
|
+
else held.push(event)
|
|
486
|
+
}
|
|
424
487
|
return {
|
|
425
488
|
...log,
|
|
426
|
-
taskStdout: (node, chunk) =>
|
|
427
|
-
taskStderr: (node, chunk) =>
|
|
489
|
+
taskStdout: (node, chunk) => emit({ kind: 'task:stdout', node, chunk }),
|
|
490
|
+
taskStderr: (node, chunk) => emit({ kind: 'task:stderr', node, chunk }),
|
|
428
491
|
}
|
|
429
492
|
}
|
|
493
|
+
const releaseSinceReady = (id: string): void => {
|
|
494
|
+
const held = sinceReady.get(id)
|
|
495
|
+
if (held === undefined) return
|
|
496
|
+
sinceReady.delete(id)
|
|
497
|
+
const out = serverOuts.get(id) ?? { bus }
|
|
498
|
+
for (const event of held) out.bus.emit(event)
|
|
499
|
+
}
|
|
430
500
|
|
|
431
|
-
|
|
501
|
+
// Taken before the cache opens, never after: a run that held the store
|
|
502
|
+
// while it waited here, and a `vx cache clean` that holds this lock while
|
|
503
|
+
// it waits for every process with the store open, waited on each other
|
|
504
|
+
// for ever; and a clear deleted the store under a run opening it.
|
|
505
|
+
let heldLock: RunLockRelease | undefined
|
|
506
|
+
const takeRunLock = async (root: string, sharedCacheDir: string | undefined): Promise<void> => {
|
|
507
|
+
// A nested run is refused below with its own reason, holding nothing.
|
|
508
|
+
const outer = process.env[VX_RUN_WORKSPACE_ENV] || undefined
|
|
509
|
+
if (outer !== undefined && canonical(outer) === canonical(root)) return
|
|
510
|
+
// Heard from here, not only from the stop below: a signal between the
|
|
511
|
+
// lock and that stop killed vx with the lock held (`holdSignals`).
|
|
512
|
+
if (options.handleSignals ?? true) signals.held = holdSignals()
|
|
513
|
+
const held = signals.held?.signal
|
|
514
|
+
const signal =
|
|
515
|
+
held !== undefined && options.signal !== undefined
|
|
516
|
+
? AbortSignal.any([options.signal, held])
|
|
517
|
+
: (held ?? options.signal)
|
|
518
|
+
const lock = await step('vx:run-lock', () => acquireRunLock(root, { log: warnOf(log), signal }))
|
|
519
|
+
heldLock = lock
|
|
520
|
+
// Another workspace's clean of a shared cache directory takes no lock of
|
|
521
|
+
// this one's: it waits for this hold, and this for it. Released with the
|
|
522
|
+
// run lock.
|
|
523
|
+
if (sharedCacheDir !== undefined) {
|
|
524
|
+
const dir = await holdCacheDir(sharedCacheDir, 'shared', warnOf(log), signal)
|
|
525
|
+
heldLock = Object.assign(
|
|
526
|
+
async (): Promise<void> => {
|
|
527
|
+
dir()
|
|
528
|
+
await lock()
|
|
529
|
+
},
|
|
530
|
+
{ path: lock.path },
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
// A wait the signal ended holds no lock: opening the store now could
|
|
534
|
+
// meet a `vx cache clean` deleting it. run() exits of the signal.
|
|
535
|
+
if (held?.aborted === true) throw new Error(`stopped by ${String(held.reason)}`)
|
|
536
|
+
}
|
|
537
|
+
let prepared: PreparedRun
|
|
538
|
+
try {
|
|
539
|
+
prepared = await step('vx:load', () => prepareRunLocked(options, log, takeRunLock, true))
|
|
540
|
+
} catch (err) {
|
|
541
|
+
await heldLock?.()
|
|
542
|
+
throw err
|
|
543
|
+
}
|
|
544
|
+
const releaseRunLock: RunLockRelease =
|
|
545
|
+
heldLock ?? Object.assign(async () => {}, { path: runLockPath(prepared.workspaceRoot) })
|
|
546
|
+
// The cache and the lock go together on every exit before the run's own.
|
|
547
|
+
const closeUnrun = async (): Promise<void> => {
|
|
548
|
+
prepared.cache.close()
|
|
549
|
+
await releaseRunLock()
|
|
550
|
+
}
|
|
432
551
|
mark('plugin stages')
|
|
433
552
|
// Every exit from here tears the plugins down: their capability
|
|
434
553
|
// factories ran in prepareRun, and teardown is what releases what those
|
|
@@ -452,16 +571,15 @@ async function runOnBus(
|
|
|
452
571
|
options = withRunSettings(options, prepared.workspaceConfig)
|
|
453
572
|
inputStability = {
|
|
454
573
|
run: inputStabilityEnv(),
|
|
455
|
-
...(prepared.workspaceConfig?.
|
|
456
|
-
? { workspace: prepared.workspaceConfig.
|
|
574
|
+
...(prepared.workspaceConfig?.inputs?.stability !== undefined
|
|
575
|
+
? { workspace: prepared.workspaceConfig.inputs.stability }
|
|
457
576
|
: {}),
|
|
458
577
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
| undefined
|
|
578
|
+
// Resolved once, beside the keys that fold it (prepare.ts).
|
|
579
|
+
cleanOutputs = workspaceCleanOf(prepared.hashCache)
|
|
462
580
|
} catch (err) {
|
|
463
581
|
await teardown()
|
|
464
|
-
|
|
582
|
+
await closeUnrun()
|
|
465
583
|
throw err
|
|
466
584
|
}
|
|
467
585
|
// A task whose command re-enters `vx run` in the workspace running it
|
|
@@ -477,7 +595,7 @@ async function runOnBus(
|
|
|
477
595
|
const outerRoot = process.env[VX_RUN_WORKSPACE_ENV] || undefined
|
|
478
596
|
if (outerRoot !== undefined && canonical(outerRoot) === canonical(prepared.workspaceRoot)) {
|
|
479
597
|
await teardown()
|
|
480
|
-
|
|
598
|
+
await closeUnrun()
|
|
481
599
|
throw new UserError(
|
|
482
600
|
`task ${process.env[VX_RUN_TASK_ENV] ?? '<unknown>'} runs \`vx run\` inside its own workspace: a nested run is invisible to the outer graph (its tasks escape the schedule, the concurrency budget and the cache key) and a loop back to this task forks without bound. Declare what it needs with dependsOn instead.`,
|
|
483
601
|
)
|
|
@@ -493,13 +611,13 @@ async function runOnBus(
|
|
|
493
611
|
if (prepared.unresolvedTasks.length > 0) {
|
|
494
612
|
const refused = `no projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${await unresolvedHint(prepared)}`
|
|
495
613
|
await teardown()
|
|
496
|
-
|
|
614
|
+
await closeUnrun()
|
|
497
615
|
return { ok: false, outcomes: [], refused }
|
|
498
616
|
}
|
|
499
617
|
if (prepared.empty === 'none-affected') {
|
|
500
618
|
log.status(noneAffected(prepared, options))
|
|
501
619
|
await teardown()
|
|
502
|
-
|
|
620
|
+
await closeUnrun()
|
|
503
621
|
return { ok: true, outcomes: [] }
|
|
504
622
|
}
|
|
505
623
|
if (prepared.empty !== null) {
|
|
@@ -512,7 +630,7 @@ async function runOnBus(
|
|
|
512
630
|
? `no projects declare task(s): ${options.tasks.join(', ')}.${elsewhereHint(prepared.declaredElsewhere)}${await initHint(prepared)}`
|
|
513
631
|
: 'no tasks to run.'
|
|
514
632
|
await teardown()
|
|
515
|
-
|
|
633
|
+
await closeUnrun()
|
|
516
634
|
return { ok: false, outcomes: [], refused }
|
|
517
635
|
}
|
|
518
636
|
// Install user plugins as additional bus subscribers BEFORE the run
|
|
@@ -525,6 +643,7 @@ async function runOnBus(
|
|
|
525
643
|
// carries it so analytics queries can group by invocation; plugins get it
|
|
526
644
|
// in their teardown's `RunReport`.
|
|
527
645
|
const runId = ulid()
|
|
646
|
+
prepared.localCache.runId = runId
|
|
528
647
|
// A killed run's restore staging (`.vx/tmp/<pid>-0`), once per run.
|
|
529
648
|
sweepStaging(prepared.workspaceRoot)
|
|
530
649
|
const head = captureGitContext(prepared.workspaceRoot)
|
|
@@ -544,7 +663,7 @@ async function runOnBus(
|
|
|
544
663
|
: new RunKeeper({
|
|
545
664
|
runId,
|
|
546
665
|
workspaceRoot: prepared.workspaceRoot,
|
|
547
|
-
nestedDirsByProject: prepared.nestedDirsByProject,
|
|
666
|
+
nestedDirsByProject: outputWallsOf(prepared.nestedDirsByProject),
|
|
548
667
|
keep: prepared.keepRunEntry,
|
|
549
668
|
failures: keepRun.failures,
|
|
550
669
|
traffic: prepared.localCache.traffic,
|
|
@@ -558,6 +677,7 @@ async function runOnBus(
|
|
|
558
677
|
terminal === null ? undefined : () => terminal.holdStatus(),
|
|
559
678
|
)
|
|
560
679
|
const remotesClosed = await openRemotes(prepared.remotes, ciContext.ci, secrets)
|
|
680
|
+
signals.held?.arm()
|
|
561
681
|
try {
|
|
562
682
|
disposePlugins = await installPlugins({
|
|
563
683
|
plugins: prepared.plugins,
|
|
@@ -570,11 +690,12 @@ async function runOnBus(
|
|
|
570
690
|
branch: prepared.branch(),
|
|
571
691
|
secrets,
|
|
572
692
|
step,
|
|
693
|
+
signal: signals.held?.signal,
|
|
573
694
|
})
|
|
574
695
|
} catch (err) {
|
|
575
696
|
disposePlugins?.()
|
|
576
697
|
await teardown(err instanceof PluginSetupError ? err.plugin : undefined)
|
|
577
|
-
|
|
698
|
+
await closeUnrun()
|
|
578
699
|
throw err
|
|
579
700
|
}
|
|
580
701
|
// Until the run's own try below, a throw leaves through here.
|
|
@@ -584,7 +705,7 @@ async function runOnBus(
|
|
|
584
705
|
} catch (err) {
|
|
585
706
|
disposePlugins?.()
|
|
586
707
|
await teardown()
|
|
587
|
-
|
|
708
|
+
await closeUnrun()
|
|
588
709
|
throw err
|
|
589
710
|
}
|
|
590
711
|
}
|
|
@@ -612,22 +733,26 @@ async function runOnBus(
|
|
|
612
733
|
if (options.signal?.aborted === true) {
|
|
613
734
|
disposePlugins?.()
|
|
614
735
|
await teardown()
|
|
615
|
-
|
|
736
|
+
await closeUnrun()
|
|
616
737
|
return { ok: true, outcomes: [] }
|
|
617
738
|
}
|
|
618
739
|
}
|
|
619
740
|
|
|
620
741
|
// Resolved ONCE per run, in declaration order, the local executor last.
|
|
621
|
-
// A broken factory aborts here, before any task starts
|
|
742
|
+
// A broken factory aborts here, before any task starts, and a held
|
|
743
|
+
// signal ends the wait on one that never settles.
|
|
622
744
|
const executors: readonly TaskExecutor[] = await abandoning(() =>
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
745
|
+
unlessStopped(
|
|
746
|
+
resolveExecutors(prepared.plugins, {
|
|
747
|
+
workspaceRoot: prepared.workspaceRoot,
|
|
748
|
+
cacheDir: prepared.cacheDir,
|
|
749
|
+
warn: warnOf(log),
|
|
750
|
+
concurrency,
|
|
751
|
+
artifacts: taskArtifacts(prepared.cache, prepared.workspaceRoot),
|
|
752
|
+
cacheFacts: () => cacheFacts(prepared.remotes, prepared.cacheRefs),
|
|
753
|
+
}),
|
|
754
|
+
signals.held?.signal,
|
|
755
|
+
),
|
|
631
756
|
)
|
|
632
757
|
// Placement: decided ONCE per task, before scheduling, so the scheduler
|
|
633
758
|
// can admit a remote-pooled task against its pool instead of a local
|
|
@@ -639,15 +764,15 @@ async function runOnBus(
|
|
|
639
764
|
// deliberate — on a machine with no remote pool the ambient state already
|
|
640
765
|
// is what the task would have produced — but it must not be SILENT: a task
|
|
641
766
|
// reporting success while doing nothing is indistinguishable from one that
|
|
642
|
-
// worked, and the usual cause (no `cache
|
|
767
|
+
// worked, and the usual cause (no `cache: true`, so no executor can
|
|
643
768
|
// describe its inputs to a worker) cannot be read off the outcome.
|
|
644
769
|
if (placements.remoteOnlyNoop.size > 0) {
|
|
645
770
|
const anyRemote = executors.some((e) => e.remote === true)
|
|
646
771
|
for (const id of placements.remoteOnlyNoop) {
|
|
647
772
|
const why = !anyRemote
|
|
648
773
|
? 'no remote executor is declared'
|
|
649
|
-
: nodes.get(id)?.config.cache
|
|
650
|
-
? 'no remote executor accepted it — the task
|
|
774
|
+
: nodes.get(id)?.config.cache !== true
|
|
775
|
+
? 'no remote executor accepted it — the task has no `cache: true`, so there is no input set to ship'
|
|
651
776
|
: 'no remote executor accepted it'
|
|
652
777
|
warnOf(log)(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
|
|
653
778
|
}
|
|
@@ -679,26 +804,28 @@ async function runOnBus(
|
|
|
679
804
|
// `cache.download` (default `all`) decides ONCE per task, here, whether a
|
|
680
805
|
// remote execution's outputs come home.
|
|
681
806
|
const downloadPolicy = options.download ?? 'all'
|
|
682
|
-
const localPlaced = locallyPlaced(placements
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
807
|
+
const localPlaced = locallyPlaced(placements)
|
|
808
|
+
// `all` keeps every task eager: no per-task map to say so, as `--dry`'s.
|
|
809
|
+
const download =
|
|
810
|
+
downloadPolicy === 'all'
|
|
811
|
+
? undefined
|
|
812
|
+
: resolveDownloadModes({
|
|
813
|
+
nodes,
|
|
814
|
+
policy: downloadPolicy,
|
|
815
|
+
localPlaced,
|
|
816
|
+
remoteOnly: placements.remoteOnly,
|
|
817
|
+
})
|
|
689
818
|
|
|
690
819
|
// `run.timeout` (ms), for any task WITHOUT its own `exec.timeout`.
|
|
691
820
|
const taskTimeoutDefault = options.timeout
|
|
692
821
|
|
|
693
|
-
// The held servers this run took over, spared by a `CYCLE_INTERRUPT`.
|
|
694
|
-
const handed = new Set<ReturnType<typeof Bun.spawn>>()
|
|
695
822
|
const stop = runStop({
|
|
696
|
-
handed: (child) => handed.has(child),
|
|
697
823
|
log,
|
|
698
824
|
cache,
|
|
699
825
|
external: options.signal,
|
|
700
826
|
handleSignals: options.handleSignals ?? true,
|
|
701
827
|
handsTerminal: holders.size > 0,
|
|
828
|
+
held: signals.held,
|
|
702
829
|
teardowns: prepared.plugins.filter((p) => p.teardown).length,
|
|
703
830
|
onStop: () => {
|
|
704
831
|
log.runStop?.()
|
|
@@ -722,6 +849,7 @@ async function runOnBus(
|
|
|
722
849
|
const closeCache = async (): Promise<void> => {
|
|
723
850
|
if (cacheClosed) return
|
|
724
851
|
cacheClosed = true
|
|
852
|
+
await prepared.localCache.stopCheckpoints()
|
|
725
853
|
cache.close()
|
|
726
854
|
live?.unlisten()
|
|
727
855
|
// Released with the handle: before a persistent task's wait, on every
|
|
@@ -733,16 +861,6 @@ async function runOnBus(
|
|
|
733
861
|
// waited, but a directory per run is litter the box never sheds.
|
|
734
862
|
await releaseRunLock()
|
|
735
863
|
}
|
|
736
|
-
// Taken after the early exits above (nothing they do touches a tree) and
|
|
737
|
-
// before the schedule: from here on tasks clean, restore and write.
|
|
738
|
-
const releaseRunLock = await abandoning(() =>
|
|
739
|
-
step('vx:run-lock', () =>
|
|
740
|
-
acquireRunLock(prepared.workspaceRoot, {
|
|
741
|
-
log: warnOf(log),
|
|
742
|
-
signal: stop.signal,
|
|
743
|
-
}),
|
|
744
|
-
),
|
|
745
|
-
)
|
|
746
864
|
// Answers `vx last --live` while the lock is held (run-live.ts).
|
|
747
865
|
live = serveLive({
|
|
748
866
|
socketPath: liveSocketOf(releaseRunLock.path),
|
|
@@ -765,26 +883,17 @@ async function runOnBus(
|
|
|
765
883
|
// clean their outputs before every exec for a save that never
|
|
766
884
|
// happened.
|
|
767
885
|
const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, remoteCacheEnabled)
|
|
768
|
-
|
|
769
|
-
const
|
|
770
|
-
options.remoteRequested ??
|
|
771
|
-
(options.cache === undefined &&
|
|
772
|
-
options.cacheFlags?.noCache !== true &&
|
|
773
|
-
namesRemote(prepared.workspaceConfig?.cache?.policy)
|
|
774
|
-
? 'cache.policy'
|
|
775
|
-
: undefined)
|
|
776
|
-
if (remoteNamed !== undefined && !prepared.hasRemoteLayer) {
|
|
777
|
-
warnOf(log)(
|
|
778
|
-
`vx: ${remoteNamed} named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run`,
|
|
779
|
-
)
|
|
780
|
-
}
|
|
886
|
+
warnNoRemote(prepared, options, log)
|
|
887
|
+
const windows = options.commandWindows ?? commandWindows()
|
|
781
888
|
const deferredOutputs = new DeferredOutputs({
|
|
782
889
|
nodes,
|
|
783
890
|
cache,
|
|
784
891
|
workspaceRoot,
|
|
785
|
-
nestedDirsByProject,
|
|
892
|
+
nestedDirsByProject: outputWallsOf(nestedDirsByProject),
|
|
786
893
|
...(gitFilesCache !== undefined ? { gitFilesCache } : {}),
|
|
787
894
|
localWrite: policy.localWrite,
|
|
895
|
+
log,
|
|
896
|
+
commandWindows: windows,
|
|
788
897
|
})
|
|
789
898
|
|
|
790
899
|
// Per-run context for the Tier-3 `invocations` header row. Captured
|
|
@@ -853,7 +962,7 @@ async function runOnBus(
|
|
|
853
962
|
// SRT's proxies holding the process open.
|
|
854
963
|
const surelyRuns = (n: TaskNode): boolean =>
|
|
855
964
|
n.config.exec?.persistent !== undefined ||
|
|
856
|
-
n.config.cache
|
|
965
|
+
n.config.cache !== true ||
|
|
857
966
|
!(policy.localRead || policy.remoteRead)
|
|
858
967
|
// Only what runs here arms this machine's sandbox: a `sandbox` executor
|
|
859
968
|
// enforces it where it runs the task, so a distributed run's
|
|
@@ -865,7 +974,7 @@ async function runOnBus(
|
|
|
865
974
|
(n) =>
|
|
866
975
|
n.config.exec?.sandbox !== undefined &&
|
|
867
976
|
(n.config.exec.persistent === undefined
|
|
868
|
-
? localPlaced
|
|
977
|
+
? localPlaced(n.id)
|
|
869
978
|
: !placements.carried.has(n.id)),
|
|
870
979
|
)
|
|
871
980
|
let prewarming: Promise<void> | undefined
|
|
@@ -874,15 +983,23 @@ async function runOnBus(
|
|
|
874
983
|
}
|
|
875
984
|
if (sandboxed.some(surelyRuns)) prewarm()
|
|
876
985
|
const keyed = keyedProjects(nodes, prepared.keyOnly)
|
|
986
|
+
const readable = readableDeps(nodes, prepared.keyOnly)
|
|
877
987
|
const outputDirSnapshots: OutputDirSnapshot[] = []
|
|
878
988
|
// Saves run off the execution slot, twice the cap at once (memory:
|
|
879
989
|
// each pack holds an artifact's bytes); a failed save is a miss next
|
|
880
|
-
// time, said once — the task's work ran.
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
990
|
+
// time, said once — the task's work ran. A full disk fails every save
|
|
991
|
+
// after the first the same way: the first is said with its task, the
|
|
992
|
+
// rest named once the graph ends.
|
|
993
|
+
const unsaved: string[] = []
|
|
994
|
+
const saveLane = createSaveLane(2 * concurrency, (err, taskId) => {
|
|
995
|
+
if (unsaved.push(taskId) > 1) return
|
|
996
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
997
|
+
// The artifact ceiling's refusal names its task itself.
|
|
998
|
+
warnOf(log)(
|
|
999
|
+
`[vx] cache save failed: ${msg.startsWith(`${taskId} `) ? msg : `${taskId}: ${msg}`}`,
|
|
1000
|
+
)
|
|
1001
|
+
})
|
|
884
1002
|
const deferredSaves = new Map<string, Promise<SaveFacts>>()
|
|
885
|
-
const windows = commandWindows()
|
|
886
1003
|
// A server held from an earlier run (`vx watch`) runs through this one
|
|
887
1004
|
// too, so it may write a mutable input while it is described.
|
|
888
1005
|
for (const id of heldBefore.kept.keys()) {
|
|
@@ -924,9 +1041,9 @@ async function runOnBus(
|
|
|
924
1041
|
const footers = executors.filter((e) => e.footer !== undefined)
|
|
925
1042
|
const placers = executors.filter((e) => e.running !== undefined)
|
|
926
1043
|
// The scheduler's dependents count, and every row's `blocks` (the live
|
|
927
|
-
// list's and the results'): one closure over the graph,
|
|
928
|
-
// first that asks.
|
|
929
|
-
let counted: ReadonlyMap<string, number> | undefined
|
|
1044
|
+
// list's and the results'): one closure over the graph, counted by
|
|
1045
|
+
// `prepareRun` beside git when it could, else on the first that asks.
|
|
1046
|
+
let counted: ReadonlyMap<string, number> | undefined = prepared.dependents
|
|
930
1047
|
const blocksNow = (): ReadonlyMap<string, number> =>
|
|
931
1048
|
(counted ??= graphCounts(nodes, hashCache.folds, hashCache.graph))
|
|
932
1049
|
// Every row's `usual` and the forecast's timings: history read once, on
|
|
@@ -1114,7 +1231,7 @@ async function runOnBus(
|
|
|
1114
1231
|
(probe.hit !== null ? hits : missed).push(id)
|
|
1115
1232
|
// An empty store was not asked: it holds none of them.
|
|
1116
1233
|
if (coldLocal && !remote)
|
|
1117
|
-
for (const [id, n] of nodes) if (n.config.cache
|
|
1234
|
+
for (const [id, n] of nodes) if (n.config.cache === true) missed.push(id)
|
|
1118
1235
|
demand.settle(hits, missed)
|
|
1119
1236
|
}
|
|
1120
1237
|
}
|
|
@@ -1139,9 +1256,16 @@ async function runOnBus(
|
|
|
1139
1256
|
// dead runs on a failure the outcomes do not hold yet (the server's
|
|
1140
1257
|
// says `success` until the run ends), so it is seeded here, at its
|
|
1141
1258
|
// dispatch, and the tracker carries it to what is built on it.
|
|
1259
|
+
// A server can also die under a task that is running (`serverDiedUnder`),
|
|
1260
|
+
// which seeds it then, and so does a task that left a process running
|
|
1261
|
+
// (`leftRunning`).
|
|
1142
1262
|
const taintSeeds = new Set(excluded.seeds)
|
|
1143
|
-
const
|
|
1144
|
-
|
|
1263
|
+
const hasServers = [...nodes.values()].some((n) => n.config.exec?.persistent !== undefined)
|
|
1264
|
+
const continueAlways = options.continueMode === 'always'
|
|
1265
|
+
let taint = taintTracker(continueAlways, taintSeeds, nodes, hasServers)
|
|
1266
|
+
let taintOn = continueAlways || taintSeeds.size > 0 || hasServers
|
|
1267
|
+
// Each task that ran tainted, and the task at the root of it: its
|
|
1268
|
+
// outcome's `taintedBy` once it finishes.
|
|
1145
1269
|
const taintedRan = new Map<string, string>()
|
|
1146
1270
|
const depended = dependedOn(nodes.values())
|
|
1147
1271
|
|
|
@@ -1153,6 +1277,8 @@ async function runOnBus(
|
|
|
1153
1277
|
// The scheduler's fail-fast stop, as the retry loop of a task already
|
|
1154
1278
|
// in flight sees it.
|
|
1155
1279
|
const failFast = new AbortController()
|
|
1280
|
+
// Tasks a plugin's shared record names flaky (`ctx.flaky`).
|
|
1281
|
+
const sharedFlaky = new Set<string>()
|
|
1156
1282
|
const scope: RunScope = {
|
|
1157
1283
|
workspaceRoot,
|
|
1158
1284
|
cache,
|
|
@@ -1168,6 +1294,8 @@ async function runOnBus(
|
|
|
1168
1294
|
probesAfterWrites: lateProbes,
|
|
1169
1295
|
fingerprintWatch,
|
|
1170
1296
|
keyedProjects: keyed,
|
|
1297
|
+
readableDeps: readable,
|
|
1298
|
+
nestedDirsOf: nestedDirsOf(nodes, prepared.keyOnly, nestedDirsByProject),
|
|
1171
1299
|
outputDirSnapshots,
|
|
1172
1300
|
deferSave: saveLane.defer,
|
|
1173
1301
|
deferredSaves,
|
|
@@ -1175,8 +1303,51 @@ async function runOnBus(
|
|
|
1175
1303
|
stopSignal: stop.signal,
|
|
1176
1304
|
failFast: failFast.signal,
|
|
1177
1305
|
inputStability,
|
|
1306
|
+
serverReady: (id) => void sinceReady.set(id, []),
|
|
1307
|
+
}
|
|
1308
|
+
if (hasServers)
|
|
1309
|
+
scope.serverDiedUnder = async (node) => {
|
|
1310
|
+
const behind = (pick: (id: string) => boolean): string | undefined => {
|
|
1311
|
+
for (const d of node.deps) {
|
|
1312
|
+
const s = deadServerBehind(nodes, pick, d)
|
|
1313
|
+
if (s !== undefined) return s
|
|
1314
|
+
}
|
|
1315
|
+
return undefined
|
|
1316
|
+
}
|
|
1317
|
+
let dead = behind(serverDied)
|
|
1318
|
+
// A server that died just before the task ended may be heard after
|
|
1319
|
+
// it: both exits can land in one turn of the event loop, the task's
|
|
1320
|
+
// handled first and its save decided in that turn's microtasks. The
|
|
1321
|
+
// next turn has handled the rest.
|
|
1322
|
+
if (dead === undefined && behind((id) => persistentRegistry.has(id)) !== undefined) {
|
|
1323
|
+
await new Promise<void>((resolve) => setImmediate(resolve))
|
|
1324
|
+
dead = behind(serverDied)
|
|
1325
|
+
}
|
|
1326
|
+
if (dead === undefined) return false
|
|
1327
|
+
taintSeeds.add(node.id)
|
|
1328
|
+
taintedRan.set(node.id, dead)
|
|
1329
|
+
return true
|
|
1330
|
+
}
|
|
1331
|
+
scope.leftRunning = (node) => {
|
|
1332
|
+
// The check stays off until the first leftover: it is seeded before
|
|
1333
|
+
// the task settles, so every outcome a dependent's judge reads is
|
|
1334
|
+
// recorded by the tracker made here.
|
|
1335
|
+
if (!taintOn) {
|
|
1336
|
+
taint = taintTracker(continueAlways, taintSeeds, nodes, true)
|
|
1337
|
+
taintOn = true
|
|
1338
|
+
}
|
|
1339
|
+
taintSeeds.add(node.id)
|
|
1178
1340
|
}
|
|
1179
1341
|
if (options.retries !== undefined) scope.retries = options.retries
|
|
1342
|
+
scope.knownFlaky = (node, hash) => {
|
|
1343
|
+
if (sharedFlaky.has(node.id)) return true
|
|
1344
|
+
try {
|
|
1345
|
+
return knownFlaky(indexDb(prepared.localCache), node.projectName, node.taskName, hash)
|
|
1346
|
+
} catch {
|
|
1347
|
+
// History is observability: an unreadable one retries nothing.
|
|
1348
|
+
return false
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1180
1351
|
if (taskTimeoutDefault !== undefined) scope.timeout = taskTimeoutDefault
|
|
1181
1352
|
if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
|
|
1182
1353
|
const sinkTrack = telemetry?.track
|
|
@@ -1190,13 +1361,14 @@ async function runOnBus(
|
|
|
1190
1361
|
}
|
|
1191
1362
|
if (explainMiss !== undefined) scope.explainMiss = explainMiss
|
|
1192
1363
|
if (keepRun !== undefined) scope.keepRun = keepRun
|
|
1364
|
+
if (options.watch === true) scope.watch = true
|
|
1193
1365
|
if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
|
|
1194
1366
|
if (demand.any) scope.executing = demand.executing
|
|
1195
1367
|
const argsOf = executeArgsOf(scope, {
|
|
1196
1368
|
log: (node) => (node.config.exec?.persistent !== undefined ? serverLog(node.id) : log),
|
|
1197
1369
|
placements,
|
|
1198
1370
|
nestedDirsByProject,
|
|
1199
|
-
downloadModeOf: download
|
|
1371
|
+
downloadModeOf: download?.modeOf,
|
|
1200
1372
|
shortCircuit,
|
|
1201
1373
|
dependedOn: depended,
|
|
1202
1374
|
holders,
|
|
@@ -1253,6 +1425,9 @@ async function runOnBus(
|
|
|
1253
1425
|
if (reprioritize !== undefined) reprioritize(merged)
|
|
1254
1426
|
else if (!graphOver) refinedEarly = merged
|
|
1255
1427
|
},
|
|
1428
|
+
flaky: (ids) => {
|
|
1429
|
+
for (const id of ids) if (nodes.has(id)) sharedFlaky.add(id)
|
|
1430
|
+
},
|
|
1256
1431
|
})
|
|
1257
1432
|
mark('schedule')
|
|
1258
1433
|
// Judged as each task finishes, against the history BEFORE this run's
|
|
@@ -1262,7 +1437,7 @@ async function runOnBus(
|
|
|
1262
1437
|
const historyDb = indexDb(prepared.localCache)
|
|
1263
1438
|
const cacheOff = cachesNothing(policy)
|
|
1264
1439
|
const judgeFlaky = (o: TaskOutcome): void => {
|
|
1265
|
-
const candidates = flakyCandidates([o]
|
|
1440
|
+
const candidates = flakyCandidates([o])
|
|
1266
1441
|
if (candidates.length === 0) return
|
|
1267
1442
|
try {
|
|
1268
1443
|
const found = detectFlaky(historyDb, candidates)[0]
|
|
@@ -1278,7 +1453,6 @@ async function runOnBus(
|
|
|
1278
1453
|
heldBefore.kept.delete(node.id)
|
|
1279
1454
|
if (hasEnded(server.child)) return undefined
|
|
1280
1455
|
server.handOver()
|
|
1281
|
-
handed.add(server.child)
|
|
1282
1456
|
server.out.bus = bus
|
|
1283
1457
|
serverOuts.set(node.id, server.out)
|
|
1284
1458
|
persistentRegistry.set(node.id, server.child)
|
|
@@ -1294,6 +1468,76 @@ async function runOnBus(
|
|
|
1294
1468
|
}
|
|
1295
1469
|
let lockBroken: boolean | undefined
|
|
1296
1470
|
prepared.localCache.storeHeldBlobSizes()
|
|
1471
|
+
prepared.localCache.checkpointOffThread()
|
|
1472
|
+
// A hit's row is whole when it finishes, so it is written while the
|
|
1473
|
+
// graph runs (history-stream.ts); every other row waits for the bundle.
|
|
1474
|
+
// A row with forward args is the bundle's too: only it digests them.
|
|
1475
|
+
const historyAt = historyFiles(prepared.localCache)
|
|
1476
|
+
let expectedHits = 0
|
|
1477
|
+
for (const probe of shortCircuit.preProbed.values()) if (probe.hit !== null) expectedHits++
|
|
1478
|
+
const stream =
|
|
1479
|
+
historyAt === undefined ? undefined : new HistoryStream<TaskOutcome>(historyAt, expectedHits)
|
|
1480
|
+
const forwards = (options.forwardArgs?.length ?? 0) > 0
|
|
1481
|
+
const streamHit = (o: TaskOutcome): void => {
|
|
1482
|
+
if (stream === undefined || isGroupTask(o.node)) return
|
|
1483
|
+
if (!isCacheHit(o.status)) return
|
|
1484
|
+
if (o.wallclockStartNs === undefined || o.wallclockEndNs === undefined) return
|
|
1485
|
+
if (forwards && o.node.requested) return
|
|
1486
|
+
stream.add(
|
|
1487
|
+
o,
|
|
1488
|
+
runRecordOf(o as RecordedOutcome, {
|
|
1489
|
+
runId,
|
|
1490
|
+
startedAtMs: endedAtMsAtStart,
|
|
1491
|
+
endedAtMs: endedAtMsAtStart,
|
|
1492
|
+
blocks: blocksNow(),
|
|
1493
|
+
remoteTraffic: prepared.localCache.traffic.keys,
|
|
1494
|
+
}),
|
|
1495
|
+
)
|
|
1496
|
+
}
|
|
1497
|
+
// A dependency-only server stops once no task left uses it, not at the
|
|
1498
|
+
// end of the graph: it held a worker's cores for nothing. Not under a
|
|
1499
|
+
// hold, whose next cycle may reuse it.
|
|
1500
|
+
const unneeded =
|
|
1501
|
+
options.holdPersistent === true
|
|
1502
|
+
? undefined
|
|
1503
|
+
: unneededServers(nodes, options.log === undefined && (options.handleSignals ?? true))
|
|
1504
|
+
const serving = executors.filter((e) => e.servers === true && e.unneeded !== undefined)
|
|
1505
|
+
const released = new Set<string>()
|
|
1506
|
+
const stopping: Promise<void>[] = []
|
|
1507
|
+
// A server stops after the servers using it (`serverUsers`), which this
|
|
1508
|
+
// same release reached first.
|
|
1509
|
+
const usersOf = unneeded === undefined ? undefined : serverUsers(nodes)
|
|
1510
|
+
const stops = new Map<string, Promise<void>>()
|
|
1511
|
+
const release = (id: string): void => {
|
|
1512
|
+
// A stop signals every server once and waits out its grace: a second
|
|
1513
|
+
// signal here cut a server's cleanup short.
|
|
1514
|
+
if (stop.signal.aborted) return
|
|
1515
|
+
for (const s of unneeded?.(id) ?? []) {
|
|
1516
|
+
// Copies an executor started beside the dependants it ran.
|
|
1517
|
+
for (const e of serving)
|
|
1518
|
+
try {
|
|
1519
|
+
e.unneeded!(s)
|
|
1520
|
+
} catch {
|
|
1521
|
+
// Its copies stop at its teardown, as before.
|
|
1522
|
+
}
|
|
1523
|
+
const child = persistentRegistry.get(s)
|
|
1524
|
+
// One that died on its own stays: the end of the graph reports it.
|
|
1525
|
+
if (child === undefined || hasEnded(child)) continue
|
|
1526
|
+
released.add(s)
|
|
1527
|
+
persistentRegistry.delete(s)
|
|
1528
|
+
const users: Promise<void>[] = []
|
|
1529
|
+
for (const u of usersOf?.get(s) ?? []) {
|
|
1530
|
+
const p = stops.get(u)
|
|
1531
|
+
if (p !== undefined) users.push(p)
|
|
1532
|
+
}
|
|
1533
|
+
const p =
|
|
1534
|
+
users.length === 0
|
|
1535
|
+
? stopServer(child)
|
|
1536
|
+
: Promise.allSettled(users).then(() => stopServer(child))
|
|
1537
|
+
stops.set(s, p)
|
|
1538
|
+
stopping.push(p)
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1297
1541
|
const outcomes = await runGraph({
|
|
1298
1542
|
nodes,
|
|
1299
1543
|
concurrency,
|
|
@@ -1308,21 +1552,30 @@ async function runOnBus(
|
|
|
1308
1552
|
onFailFast: () => failFast.abort(),
|
|
1309
1553
|
signal: stop.signal,
|
|
1310
1554
|
onStart: (node) => {
|
|
1555
|
+
// A platform skip never starts, like a skip blocked upstream: its
|
|
1556
|
+
// start opened a single task's frame that nothing closed.
|
|
1557
|
+
if (placements.otherPlatform.has(node.id)) return
|
|
1311
1558
|
log.taskStart?.(node)
|
|
1312
1559
|
},
|
|
1313
1560
|
onFinish: (o) => {
|
|
1314
1561
|
if (cacheOff) o.cacheOff = true
|
|
1562
|
+
const root = taintedRan.get(o.node.id)
|
|
1563
|
+
if (root !== undefined && o.blockedBy === undefined) o.taintedBy = root
|
|
1315
1564
|
taint.settled(o)
|
|
1316
1565
|
judgeFlaky(o)
|
|
1317
1566
|
keeper?.taskDone(o)
|
|
1318
1567
|
log.taskComplete(o.node, o)
|
|
1568
|
+
if (sinceReady.size > 0) releaseSinceReady(o.node.id)
|
|
1569
|
+
streamHit(o)
|
|
1319
1570
|
demand.finished(o.node.id)
|
|
1571
|
+
release(o.node.id)
|
|
1320
1572
|
// A server that dies while the graph runs is no longer running:
|
|
1321
1573
|
// its pin stayed `running` under the line naming its exit.
|
|
1322
1574
|
const server = o.status === 'success' ? persistentRegistry.get(o.node.id) : undefined
|
|
1323
1575
|
if (server !== undefined && o.serverExit === undefined)
|
|
1324
1576
|
void server.exited.then((code) => {
|
|
1325
|
-
if (code !== 0 && !graphOver && !stop.signal.aborted
|
|
1577
|
+
if (code !== 0 && !graphOver && !stop.signal.aborted && !released.has(o.node.id))
|
|
1578
|
+
o.serverExit = code
|
|
1326
1579
|
})
|
|
1327
1580
|
},
|
|
1328
1581
|
// A crash a hand-broken lock entry caused is named as that; asked
|
|
@@ -1394,6 +1647,14 @@ async function runOnBus(
|
|
|
1394
1647
|
stop.graphDone()
|
|
1395
1648
|
reprioritize = undefined
|
|
1396
1649
|
graphOver = true
|
|
1650
|
+
// Each task finishes once its save settles (`settledOf`): the list is whole.
|
|
1651
|
+
if (unsaved.length > 1) {
|
|
1652
|
+
const more = unsaved.slice(1)
|
|
1653
|
+
const named = more.slice(0, 5).join(', ') + (more.length > 5 ? ', …' : '')
|
|
1654
|
+
warnOf(log)(
|
|
1655
|
+
`[vx] cache save failed for ${more.length} more task${more.length === 1 ? '' : 's'}: ${named}`,
|
|
1656
|
+
)
|
|
1657
|
+
}
|
|
1397
1658
|
|
|
1398
1659
|
// Which persistent children outlive the graph, and the bounded SIGTERM
|
|
1399
1660
|
// of the rest, before the summary prints. Scoped to the real CLI
|
|
@@ -1410,6 +1671,7 @@ async function runOnBus(
|
|
|
1410
1671
|
// A stop already signalled every server once and waits out its grace:
|
|
1411
1672
|
// a SIGTERM here, while a server ran its cleanup, cut it short.
|
|
1412
1673
|
if (stop.signal.aborted) await stop.aborting?.catch(() => {})
|
|
1674
|
+
await Promise.all(stopping)
|
|
1413
1675
|
const { keepAlive, crashed: crashedPersistent } = await disposeServers({
|
|
1414
1676
|
registry: persistentRegistry,
|
|
1415
1677
|
nodes,
|
|
@@ -1420,6 +1682,9 @@ async function runOnBus(
|
|
|
1420
1682
|
endedBeforeStop: stop.endedBeforeStop,
|
|
1421
1683
|
})
|
|
1422
1684
|
|
|
1685
|
+
// Every batch the stream sent has answered: what landed is not written again.
|
|
1686
|
+
const streamed = await stream?.finish()
|
|
1687
|
+
const landed = new Set(streamed?.landed.map((s) => s.item))
|
|
1423
1688
|
mark('run graph')
|
|
1424
1689
|
|
|
1425
1690
|
// Clear the status line for good before the summary prints.
|
|
@@ -1499,7 +1764,6 @@ async function runOnBus(
|
|
|
1499
1764
|
const recordsOf = (final: TaskOutcome[], runOk: boolean) =>
|
|
1500
1765
|
assembleRunRecords({
|
|
1501
1766
|
outcomes: final,
|
|
1502
|
-
tainted: taintedRan,
|
|
1503
1767
|
runId,
|
|
1504
1768
|
startedAtMs: endedAtMsAtStart,
|
|
1505
1769
|
endedAtMs,
|
|
@@ -1520,6 +1784,7 @@ async function runOnBus(
|
|
|
1520
1784
|
withTelemetry: telemetry !== undefined,
|
|
1521
1785
|
blocks: blocksNow(),
|
|
1522
1786
|
remoteTraffic: prepared.localCache.traffic.keys,
|
|
1787
|
+
landed,
|
|
1523
1788
|
})
|
|
1524
1789
|
const records = recordsOf(list, ok)
|
|
1525
1790
|
const recordHistory = (write: () => void): void => {
|
|
@@ -1538,6 +1803,11 @@ async function runOnBus(
|
|
|
1538
1803
|
// cache closes: its history is written once the wait has decided, or it
|
|
1539
1804
|
// read `ok` over the exit 1 and fed the flaky list a pass (X-23).
|
|
1540
1805
|
const historyAfterWait = keepAlive.children.length > 0 && !hold && stop.stoppedBy === undefined
|
|
1806
|
+
// The servers this run is done with end now; the kept ones once the wait decides.
|
|
1807
|
+
telemetry?.endServers(
|
|
1808
|
+
list,
|
|
1809
|
+
historyAfterWait ? new Set(keepAlive.nodes.map((n) => n.id)) : undefined,
|
|
1810
|
+
)
|
|
1541
1811
|
// A failed task's output, for `vx last` and an agent (run-failures.ts).
|
|
1542
1812
|
// A cache directory this user cannot write keeps none of the run's
|
|
1543
1813
|
// records: the run's one notice said so.
|
|
@@ -1558,7 +1828,7 @@ async function runOnBus(
|
|
|
1558
1828
|
// measured, or the next run knows none of it (2026-10-21).
|
|
1559
1829
|
const ended = list.filter((o) => o.status !== 'aborted' && o.status !== 'skipped')
|
|
1560
1830
|
stepSync('vx:history', () => {
|
|
1561
|
-
recordHistory(() => prepared.localCache.recordRuns(recordsOf(ended, false).runs))
|
|
1831
|
+
recordHistory(() => prepared.localCache.recordRuns(recordsOf(ended, false).runs, head))
|
|
1562
1832
|
recordFailures(ended)
|
|
1563
1833
|
})
|
|
1564
1834
|
}
|
|
@@ -1603,8 +1873,21 @@ async function runOnBus(
|
|
|
1603
1873
|
}
|
|
1604
1874
|
// An upload that failed after the rows went in: its row says so too.
|
|
1605
1875
|
if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
|
|
1606
|
-
recordHistory(() =>
|
|
1607
|
-
|
|
1876
|
+
recordHistory(() =>
|
|
1877
|
+
recordLateRemoteErrors(
|
|
1878
|
+
prepared.localCache,
|
|
1879
|
+
[...records.runs, ...(streamed?.landed.map((s) => s.record) ?? [])],
|
|
1880
|
+
runId,
|
|
1881
|
+
),
|
|
1882
|
+
)
|
|
1883
|
+
// A stop passes none (`VxPlugin.teardown`): the run did not reach its end.
|
|
1884
|
+
// Built and frozen only for a teardown that reads it.
|
|
1885
|
+
const report =
|
|
1886
|
+
stop.stoppedBy === undefined && prepared.plugins.some((p) => p.teardown !== undefined)
|
|
1887
|
+
? freezeDeep(
|
|
1888
|
+
reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper),
|
|
1889
|
+
)
|
|
1890
|
+
: undefined
|
|
1608
1891
|
// Hand the per-run summary to the telemetry sinks + drain them. Only
|
|
1609
1892
|
// when a sink is active (telemetry !== undefined) — otherwise this
|
|
1610
1893
|
// whole block is skipped and the run is byte-identical to before.
|
|
@@ -1612,22 +1895,37 @@ async function runOnBus(
|
|
|
1612
1895
|
// the run; flush is the sink's last chance to ship buffered records.
|
|
1613
1896
|
// After the drain, so the summary says what the uploads moved; a live
|
|
1614
1897
|
// sink's task records went out as each task ended.
|
|
1615
|
-
|
|
1616
|
-
|
|
1898
|
+
const sinks = telemetry
|
|
1899
|
+
const summarize = async (
|
|
1900
|
+
final: TaskOutcome[],
|
|
1901
|
+
runOk: boolean,
|
|
1902
|
+
timed?: typeof step,
|
|
1903
|
+
ended = { at: endedAtMs, totalMs },
|
|
1904
|
+
): Promise<void> => {
|
|
1905
|
+
if (sinks === undefined || runContextRecord === undefined) return
|
|
1906
|
+
sinks.endServers(final)
|
|
1907
|
+
const tasks = final === list ? records.telemetryTasks : recordsOf(final, runOk).telemetryTasks
|
|
1908
|
+
const summary = assembleRunSummary(runContextRecord, tasks, {
|
|
1617
1909
|
startedAt: endedAtMsAtStart,
|
|
1618
|
-
endedAt:
|
|
1619
|
-
totalDurationMs: Math.round(totalMs),
|
|
1620
|
-
exitOk:
|
|
1621
|
-
|
|
1910
|
+
endedAt: ended.at,
|
|
1911
|
+
totalDurationMs: Math.round(ended.totalMs),
|
|
1912
|
+
exitOk: runOk,
|
|
1913
|
+
// As the terminal counts them: `1 aborted · 1 not run`.
|
|
1914
|
+
abortedCount: tasks.filter((t) => t.status === 'aborted').length,
|
|
1915
|
+
notRunCount: final.filter((o) => neverStarted(o) && !isGroupTask(o.node)).length,
|
|
1622
1916
|
stages: stageTimes(),
|
|
1623
1917
|
uploads: prepared.localCache.uploads,
|
|
1624
1918
|
machines: executors.flatMap((e) => executorMachines(e, fault)),
|
|
1625
1919
|
idealMs: executors.map((e) => executorIdeal(e, fault)).find((ms) => ms !== undefined),
|
|
1920
|
+
advice: executors.flatMap((e) => executorAdvice(e, fault)),
|
|
1626
1921
|
})
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
await sinks.flush(step)
|
|
1922
|
+
sinks.emitSummary(summary)
|
|
1923
|
+
await sinks.flush(timed)
|
|
1630
1924
|
}
|
|
1925
|
+
// A kept server can still fail the run in the wait below: its summary
|
|
1926
|
+
// goes out once the wait has decided, as its history does, or every
|
|
1927
|
+
// sink (vx-ci's job summary) read a pass over the exit 1.
|
|
1928
|
+
if (!historyAfterWait) await summarize(list, ok, step)
|
|
1631
1929
|
// End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
|
|
1632
1930
|
// + time-bounded inside teardownPlugins, so a faulty plugin can neither
|
|
1633
1931
|
// fail nor hang the run. Here on the normal path, after the drains
|
|
@@ -1738,6 +2036,28 @@ async function runOnBus(
|
|
|
1738
2036
|
local.close()
|
|
1739
2037
|
}
|
|
1740
2038
|
})
|
|
2039
|
+
// After the footer and the plugins' teardown: no step line there.
|
|
2040
|
+
// The run ended with the session, not with the summary above. A stop
|
|
2041
|
+
// reads as one, as a stop anywhere else does: the servers it took
|
|
2042
|
+
// down are aborted there. The history keeps them as a dev session's
|
|
2043
|
+
// usual end (item 854).
|
|
2044
|
+
const stopped = stop.signal.aborted
|
|
2045
|
+
const told = stopped
|
|
2046
|
+
? final.map((o) =>
|
|
2047
|
+
keepAlive.nodes.some((n) => n.id === o.node.id) && isPassStatus(o.status)
|
|
2048
|
+
? {
|
|
2049
|
+
...o,
|
|
2050
|
+
status: 'aborted' as const,
|
|
2051
|
+
exitCode:
|
|
2052
|
+
typeof first.code === 'number' ? first.code : signalExitCode(first.code),
|
|
2053
|
+
}
|
|
2054
|
+
: o,
|
|
2055
|
+
)
|
|
2056
|
+
: final
|
|
2057
|
+
await summarize(told, ok && !crashed && !stopped, undefined, {
|
|
2058
|
+
at: Date.now(),
|
|
2059
|
+
totalMs: Number(process.hrtime.bigint() - runStartHrTimeNs) / 1_000_000,
|
|
2060
|
+
})
|
|
1741
2061
|
}
|
|
1742
2062
|
return { ok: ok && !crashed, outcomes: final }
|
|
1743
2063
|
}
|
|
@@ -1793,16 +2113,38 @@ function noneAffected(prepared: PreparedRun, options: RunOptions): string {
|
|
|
1793
2113
|
: `No affected project declares task(s): ${options.tasks.join(', ')}.`
|
|
1794
2114
|
}
|
|
1795
2115
|
|
|
2116
|
+
/**
|
|
2117
|
+
* One line when a policy names the remote cache and no plugin supplies
|
|
2118
|
+
* one; a plan (`--dry`) gets it as the run does. The workspace's
|
|
2119
|
+
* `cache.policy` asks as a flag does: it was the one source never told.
|
|
2120
|
+
*/
|
|
2121
|
+
function warnNoRemote(prepared: PreparedRun, options: RunOptions, log: Logger): void {
|
|
2122
|
+
if (prepared.hasRemoteLayer) return
|
|
2123
|
+
const remoteNamed =
|
|
2124
|
+
options.remoteRequested ??
|
|
2125
|
+
(options.cache === undefined &&
|
|
2126
|
+
options.cacheFlags?.noCache !== true &&
|
|
2127
|
+
namesRemote(prepared.workspaceConfig?.cache?.policy)
|
|
2128
|
+
? 'cache.policy'
|
|
2129
|
+
: undefined)
|
|
2130
|
+
if (remoteNamed === undefined) return
|
|
2131
|
+
warnOf(log)(
|
|
2132
|
+
`vx: ${remoteNamed} named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run`,
|
|
2133
|
+
)
|
|
2134
|
+
}
|
|
2135
|
+
|
|
1796
2136
|
/**
|
|
1797
2137
|
* Planning mode. Same setup as `run()` — workspace discovery, config
|
|
1798
2138
|
* load, package graph, task graph — but stops short of execution.
|
|
1799
2139
|
* Returns a `RunPlan` predicting the cache hit/miss outcome of every
|
|
1800
2140
|
* task. Used by `--dry`.
|
|
1801
2141
|
*
|
|
1802
|
-
* Side-effects are limited to
|
|
1803
|
-
*
|
|
1804
|
-
*
|
|
1805
|
-
*
|
|
2142
|
+
* Side-effects are limited to running `inputs.runtime` probe commands,
|
|
2143
|
+
* which key derivation requires: with no remote the local cache opens to
|
|
2144
|
+
* read only (`prepareRunLocked`'s `planning`); with one, the remote hits
|
|
2145
|
+
* found are kept in the index for the run after. Probing is the
|
|
2146
|
+
* byte-free `cache.has()` existence check — no artifact download, no
|
|
2147
|
+
* ingest, no accessed_at bump.
|
|
1806
2148
|
*/
|
|
1807
2149
|
export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
1808
2150
|
refuseRunOptions(options)
|
|
@@ -1810,7 +2152,13 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1810
2152
|
// what a stage says on the way goes to stderr (C-6).
|
|
1811
2153
|
const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
|
|
1812
2154
|
const reasons = new Map<string, AffectedReason>()
|
|
1813
|
-
const prepared = await
|
|
2155
|
+
const prepared = await prepareRunLocked(
|
|
2156
|
+
{ ...options, affectedReasons: reasons },
|
|
2157
|
+
log,
|
|
2158
|
+
undefined,
|
|
2159
|
+
false,
|
|
2160
|
+
true,
|
|
2161
|
+
)
|
|
1814
2162
|
try {
|
|
1815
2163
|
options = withRunSettings(options, prepared.workspaceConfig)
|
|
1816
2164
|
if (prepared.unresolvedTasks.length > 0) {
|
|
@@ -1830,6 +2178,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1830
2178
|
if (prepared.empty === 'none-affected')
|
|
1831
2179
|
return { tasks: [], noneAffected: noneAffected(prepared, options) }
|
|
1832
2180
|
if (prepared.empty !== null) return { tasks: [] }
|
|
2181
|
+
warnNoRemote(prepared, options, log)
|
|
1833
2182
|
// The plan orders nothing, but it refuses what the run would refuse.
|
|
1834
2183
|
await prepared.priorities()
|
|
1835
2184
|
terminalHolders(prepared.nodes, options.tty === true)
|
|
@@ -1920,7 +2269,7 @@ async function planRestorable(
|
|
|
1920
2269
|
/** The names a project outside the run's selection declares, and how to reach them. */
|
|
1921
2270
|
function elsewhereHint(names: readonly string[]): string {
|
|
1922
2271
|
if (names.length === 0) return ''
|
|
1923
|
-
return ` Only projects outside the selection declare ${names.join(', ')} — pass --all, or --filter
|
|
2272
|
+
return ` Only projects outside the selection declare ${names.join(', ')} — pass --all, or a --filter that includes them.`
|
|
1924
2273
|
}
|
|
1925
2274
|
|
|
1926
2275
|
/**
|
|
@@ -1929,6 +2278,7 @@ function elsewhereHint(names: readonly string[]): string {
|
|
|
1929
2278
|
* from: a task just added to a vx.config is not in it until `vx lock`.
|
|
1930
2279
|
*/
|
|
1931
2280
|
async function unresolvedHint(prepared: {
|
|
2281
|
+
workspaceRoot: string
|
|
1932
2282
|
unresolvedTasks: readonly string[]
|
|
1933
2283
|
hintProjects: ReadonlyMap<string, ProjectEntry>
|
|
1934
2284
|
declaredElsewhere: readonly string[]
|
|
@@ -1942,10 +2292,18 @@ async function unresolvedHint(prepared: {
|
|
|
1942
2292
|
prepared.hintProjects,
|
|
1943
2293
|
prepared.declaredElsewhere,
|
|
1944
2294
|
prepared.taskless,
|
|
2295
|
+
prepared.workspaceRoot,
|
|
1945
2296
|
)
|
|
1946
2297
|
const init = await initHint(prepared)
|
|
2298
|
+
// "No project declares a task yet" beside configs that declare one: the
|
|
2299
|
+
// lock holds none of them (a `vx lock` that failed), so say that.
|
|
2300
|
+
if (near.includes(NO_TASK)) {
|
|
2301
|
+
const unlocked = await unlockedAnywhere(prepared.workspaceRoot)
|
|
2302
|
+
if (unlocked !== undefined)
|
|
2303
|
+
return `${elsewhere}${near.replace(NO_TASK, `The lock holds no project for ${unlocked}: run \`vx lock\` (did it fail?).`)}${init}`
|
|
2304
|
+
}
|
|
1947
2305
|
const lock =
|
|
1948
|
-
init === '' && elsewhere === '' && !near.startsWith(' Did you mean')
|
|
2306
|
+
init === '' && elsewhere === '' && !near.startsWith(' Did you mean') && !near.includes(NO_TASK)
|
|
1949
2307
|
? ' A run reads the lock: after editing a vx.config, run `vx lock`.'
|
|
1950
2308
|
: ''
|
|
1951
2309
|
return `${elsewhere}${near}${init}${lock}`
|
|
@@ -1968,6 +2326,19 @@ async function initHint(prepared: {
|
|
|
1968
2326
|
return ` ${EMPTY_LOCK}.`
|
|
1969
2327
|
}
|
|
1970
2328
|
|
|
2329
|
+
/** A project config the lock holds no project for, found by the walk `vx lock` makes. */
|
|
2330
|
+
async function unlockedAnywhere(root: string): Promise<string | undefined> {
|
|
2331
|
+
const [{ configs }, lock] = await Promise.all([findProjectConfigs(root), readLockfile(root)])
|
|
2332
|
+
const held = new Set(Object.keys(lock?.projects ?? {}))
|
|
2333
|
+
const c = configs.find((x) => !held.has(lockKey(root, x.dir)))
|
|
2334
|
+
return c === undefined ? undefined : relPosix(root, c.configPath)
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// The state right after `vx init`: the lock is current, so the lock hint
|
|
2338
|
+
// alone sent a new user to a command that changed nothing.
|
|
2339
|
+
const NO_TASK =
|
|
2340
|
+
'No project declares a task yet: add one under `tasks` in a vx.config, then run `vx lock`.'
|
|
2341
|
+
|
|
1971
2342
|
/**
|
|
1972
2343
|
* A typo's nearest declared name, when one is within two edits — the
|
|
1973
2344
|
* message names the fix instead of only the mistake. A bare name is
|
|
@@ -1980,13 +2351,17 @@ function didYouMean(
|
|
|
1980
2351
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
1981
2352
|
elsewhere: readonly string[],
|
|
1982
2353
|
taskless: (name: string) => boolean,
|
|
2354
|
+
root: string,
|
|
1983
2355
|
): string {
|
|
1984
|
-
|
|
1985
|
-
//
|
|
2356
|
+
// A default `build` is no declared task (X-102): neither a bare name's
|
|
2357
|
+
// match nor a menu item or hint for `p#typo`, which led to `p#build`
|
|
2358
|
+
// and its refusal ("p declares no build").
|
|
2359
|
+
const tasksOf = (p: ProjectEntry | undefined): string[] =>
|
|
2360
|
+
Object.entries(p?.config.tasks ?? {})
|
|
2361
|
+
.filter(([, c]) => !isDefaultBuild(c))
|
|
2362
|
+
.map(([t]) => t)
|
|
1986
2363
|
const allTasks = new Set<string>()
|
|
1987
|
-
for (const p of projects.values())
|
|
1988
|
-
for (const t of tasksOf(p)) if (!isDefaultBuild(p.config.tasks![t])) allTasks.add(t)
|
|
1989
|
-
}
|
|
2364
|
+
for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
|
|
1990
2365
|
// A Set: two typos of the same task hint it once, not once per typo.
|
|
1991
2366
|
const hints = new Set<string>()
|
|
1992
2367
|
// A typo past two edits named nothing to pick from (M-56): with no near
|
|
@@ -2005,7 +2380,7 @@ function didYouMean(
|
|
|
2005
2380
|
// A name outside the selection is already said, with `--all`.
|
|
2006
2381
|
else if (allTasks.size > 0 && !elsewhere.includes(spec)) {
|
|
2007
2382
|
lists.add(`Tasks: ${listed(allTasks)}.`)
|
|
2008
|
-
}
|
|
2383
|
+
} else if (allTasks.size === 0 && elsewhere.length === 0) lists.add(NO_TASK)
|
|
2009
2384
|
continue
|
|
2010
2385
|
}
|
|
2011
2386
|
const [proj, task] = [spec.slice(0, at), spec.slice(at + 1)]
|
|
@@ -2031,7 +2406,20 @@ function didYouMean(
|
|
|
2031
2406
|
continue
|
|
2032
2407
|
}
|
|
2033
2408
|
if (!projects.has(proj)) {
|
|
2034
|
-
|
|
2409
|
+
// NFC typed, NFD on disk (or back): two edits apart, so no hint, and
|
|
2410
|
+
// the list named the project exactly as typed.
|
|
2411
|
+
const form = [...projects.keys()].find((n) => n.normalize('NFC') === proj.normalize('NFC'))
|
|
2412
|
+
if (form !== undefined) {
|
|
2413
|
+
lists.add(
|
|
2414
|
+
`${proj} is not the project ${form}, though they look the same: the two are written in different Unicode forms (NFC and NFD). Set \`name\` in its vx.config to the form you type.`,
|
|
2415
|
+
)
|
|
2416
|
+
continue
|
|
2417
|
+
}
|
|
2418
|
+
// A project's directory first: by edit distance, `p/a` hinted `p/b`.
|
|
2419
|
+
const p =
|
|
2420
|
+
projectNamed(proj, projects) ??
|
|
2421
|
+
projectAtDir(proj, root, projects.values()) ??
|
|
2422
|
+
nearest(proj, projects.keys())
|
|
2035
2423
|
if (p !== undefined) {
|
|
2036
2424
|
const tasks = tasksOf(projects.get(p))
|
|
2037
2425
|
const t = tasks.includes(task) ? task : nearest(task, tasks)
|
|
@@ -2087,23 +2475,34 @@ export function projectNamed(
|
|
|
2087
2475
|
return scoped.length === 1 ? scoped[0] : undefined
|
|
2088
2476
|
}
|
|
2089
2477
|
|
|
2478
|
+
/**
|
|
2479
|
+
* The project whose directory a typed name is, relative to the workspace
|
|
2480
|
+
* root, as `--filter p/a` reads it: `alpha` for `p/a`.
|
|
2481
|
+
*/
|
|
2482
|
+
export function projectAtDir(
|
|
2483
|
+
typed: string,
|
|
2484
|
+
root: string,
|
|
2485
|
+
projects: Iterable<{ name: string; dir: string }>,
|
|
2486
|
+
): string | undefined {
|
|
2487
|
+
const dir = path.resolve(root, typed)
|
|
2488
|
+
for (const p of projects) if (path.resolve(p.dir) === dir) return p.name
|
|
2489
|
+
return undefined
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2090
2492
|
/** The executed, keyed outcomes of a run — what flakiness is judged on. */
|
|
2091
|
-
function flakyCandidates(
|
|
2092
|
-
outcomes: readonly TaskOutcome[],
|
|
2093
|
-
tainted: ReadonlyMap<string, string>,
|
|
2094
|
-
): FlakyCandidate[] {
|
|
2493
|
+
function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
|
|
2095
2494
|
const out: FlakyCandidate[] = []
|
|
2096
2495
|
for (const o of outcomes) {
|
|
2097
2496
|
if (o.status !== 'success' && o.status !== 'failed') continue
|
|
2098
2497
|
// "Same inputs, failed after passing" is a claim only a task with declared
|
|
2099
|
-
// inputs can make: a task
|
|
2498
|
+
// inputs can make: a task without `cache: true` keys on its config alone
|
|
2100
2499
|
// and runs every time, so one bad network day would read as a flake for
|
|
2101
2500
|
// thirty days. Groups do no work.
|
|
2102
|
-
if (o.hash === undefined || o.node.config.cache
|
|
2501
|
+
if (o.hash === undefined || o.node.config.cache !== true || isGroupTask(o.node)) continue
|
|
2103
2502
|
// Behind a failed dependency the outcome is not the key's: a failure is
|
|
2104
2503
|
// the dependency's, and a pass built on bytes the key never named, so a
|
|
2105
2504
|
// later failure relapsed from it and read as a flake for thirty days.
|
|
2106
|
-
if (
|
|
2505
|
+
if (o.taintedBy !== undefined) continue
|
|
2107
2506
|
out.push({
|
|
2108
2507
|
project: o.node.projectName,
|
|
2109
2508
|
task: o.node.taskName,
|
|
@@ -2184,6 +2583,16 @@ function executorMachines(e: TaskExecutor, fault: ExecutorFault): readonly Machi
|
|
|
2184
2583
|
}
|
|
2185
2584
|
}
|
|
2186
2585
|
|
|
2586
|
+
function executorAdvice(e: TaskExecutor, fault: ExecutorFault): readonly WorkerAdvice[] {
|
|
2587
|
+
try {
|
|
2588
|
+
const a = e.advice?.()
|
|
2589
|
+
return Array.isArray(a) ? a : []
|
|
2590
|
+
} catch (err) {
|
|
2591
|
+
fault(e, 'advice', err)
|
|
2592
|
+
return []
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2187
2596
|
function executorIdeal(e: TaskExecutor, fault: ExecutorFault): number | undefined {
|
|
2188
2597
|
try {
|
|
2189
2598
|
const ms = e.idealMs?.()
|