@vzn/vx 0.0.15 → 0.0.18
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 +91 -130
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +129 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +27 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
package/src/orchestrator/run.ts
CHANGED
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
// run with caching. Each step delegates to a single-purpose sibling file
|
|
3
3
|
// so the layers can be swapped without touching the others.
|
|
4
4
|
|
|
5
|
+
import type { ProjectEntry } from '../workspace/index.js'
|
|
5
6
|
import os from 'node:os'
|
|
6
7
|
import {
|
|
7
8
|
type CacheLayer,
|
|
8
9
|
type CachePolicy,
|
|
9
10
|
FULL_CACHE_POLICY,
|
|
10
11
|
type InvocationRecord,
|
|
11
|
-
LayeredCache,
|
|
12
12
|
type RunRecord,
|
|
13
13
|
} from '../cache/index.js'
|
|
14
14
|
import { VERSION } from '../version.js'
|
|
15
15
|
import { initSandbox, probeSandbox, resetSandbox, signalExitCode } from '../exec/index.js'
|
|
16
|
+
import { DeferredOutputs } from './deferred-outputs.js'
|
|
17
|
+
import { resolveDownloadModes } from './download-policy.js'
|
|
18
|
+
import { selectExecutor, type TaskExecutor } from '../exec/index.js'
|
|
16
19
|
import {
|
|
17
20
|
isGroupTask,
|
|
18
21
|
markSurfacedDeps,
|
|
@@ -20,24 +23,24 @@ import {
|
|
|
20
23
|
type TaskNode,
|
|
21
24
|
type TaskOutcome,
|
|
22
25
|
} from '../graph/index.js'
|
|
23
|
-
import { ulid, UserError } from '../util/index.js'
|
|
26
|
+
import { mark, MAX_TIMEOUT_MS, printTimings, ulid, UserError, editDistance } from '../util/index.js'
|
|
24
27
|
import { executeTask } from './execute-task.js'
|
|
25
28
|
import { resolveResourceCosts } from './resources.js'
|
|
26
29
|
import { computeTaskHash } from './task-hash.js'
|
|
27
30
|
import { busLogger, createEventBus, terminalSubscriber } from './events.js'
|
|
28
31
|
import { installPlugins } from './plugin.js'
|
|
29
|
-
import
|
|
30
|
-
import { subscribeEventSinks, teardownPlugins } from './plugin-host.js'
|
|
31
|
-
import type { SubscribedEventSinks } from './plugin-host.js'
|
|
32
|
+
import { resolveExecutors, teardownPlugins } from './plugin-host.js'
|
|
32
33
|
import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
33
|
-
import { deriveCacheSource,
|
|
34
|
-
import type { RunContextRecord,
|
|
35
|
-
import { defaultLogger, resolveOutputView } from './logger.js'
|
|
34
|
+
import { assembleRunSummary, deriveCacheSource, isCacheHit, isPassStatus } from './telemetry.js'
|
|
35
|
+
import type { RunContextRecord, TaskTelemetry } from './telemetry.js'
|
|
36
|
+
import { defaultLogger, resolveOutputView, type Logger } from './logger.js'
|
|
36
37
|
import { detectColors } from './colors.js'
|
|
37
38
|
import { formatPersistentList } from './framed-output.js'
|
|
39
|
+
import { LocalHistoryProvider } from './history.js'
|
|
38
40
|
import { plan, type RunPlan } from './plan.js'
|
|
39
41
|
import { prepareRun } from './prepare.js'
|
|
40
42
|
import {
|
|
43
|
+
captureDefaultBranch,
|
|
41
44
|
captureGitContext,
|
|
42
45
|
captureHostContext,
|
|
43
46
|
captureWorkspaceIdentity,
|
|
@@ -48,21 +51,38 @@ import { startLocalShortCircuit, type ShortCircuit } from './local-shortcircuit.
|
|
|
48
51
|
import { formatVerifySection } from './verify.js'
|
|
49
52
|
|
|
50
53
|
const EMPTY_SHORT_CIRCUIT: ShortCircuit = { preProbed: new Map(), restoreTier: new Set() }
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Grace after SIGTERMing the dependency-only persistent tasks at end-of-run
|
|
57
|
+
* before force-killing any that trap or ignore it — so a wedged mock server
|
|
58
|
+
* can't hang a normal run at completion. Well-behaved servers exit far under
|
|
59
|
+
* this, so the happy path never waits it out.
|
|
60
|
+
*/
|
|
61
|
+
const PERSISTENT_SHUTDOWN_GRACE_MS = 2000
|
|
51
62
|
import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
|
|
52
|
-
import { formatRunSummary } from './summary.js'
|
|
63
|
+
import { formatAbortedSection, formatRunSummary } from './summary.js'
|
|
53
64
|
import type { RunOptions, RunSummary } from './options.js'
|
|
54
65
|
|
|
55
66
|
/**
|
|
56
67
|
* Parse the `VX_TASK_TIMEOUT` env var (ms) — the "global" run-level task
|
|
57
68
|
* timeout default. A missing/empty/non-positive-integer value yields
|
|
58
69
|
* `undefined` (ignored), so a typo never silently disables a task's own
|
|
59
|
-
* `exec.timeout`.
|
|
70
|
+
* `exec.timeout`. A value past `MAX_TIMEOUT_MS` is clamped to it — see below.
|
|
71
|
+
*
|
|
72
|
+
* Exported for `tests/options-resolve.test.ts`, which pins every accepted
|
|
73
|
+
* and ignored form of this rung; it has no other caller outside this file.
|
|
60
74
|
*/
|
|
61
|
-
function readTaskTimeoutEnv(): number | undefined {
|
|
75
|
+
export function readTaskTimeoutEnv(): number | undefined {
|
|
62
76
|
const raw = process.env['VX_TASK_TIMEOUT']
|
|
63
77
|
if (raw === undefined || raw === '') return undefined
|
|
64
78
|
const n = Number(raw)
|
|
65
|
-
|
|
79
|
+
if (!Number.isInteger(n) || n <= 0) return undefined
|
|
80
|
+
// CLAMPED, not refused — unlike `exec.timeout` and `--timeout`, this rung's
|
|
81
|
+
// contract is already "unusable value → fall back", so it never throws. But
|
|
82
|
+
// falling back is wrong here: someone who typed a huge number wants a long
|
|
83
|
+
// timeout, and the largest expressible one IS long (~24.8 days). Handing it
|
|
84
|
+
// to `setTimeout` unbounded would mean 1 ms — killing every task instantly.
|
|
85
|
+
return Math.min(n, MAX_TIMEOUT_MS)
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
/**
|
|
@@ -79,32 +99,49 @@ function compactCachePolicy(p: CachePolicy): string {
|
|
|
79
99
|
return parts.join(',')
|
|
80
100
|
}
|
|
81
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The policy that will actually govern the run. The remote axes are inert
|
|
104
|
+
* without a remote layer to serve them: a `remote:w` policy over a bare
|
|
105
|
+
* local cache writes NOWHERE, yet the write axis reads as on. Normalising
|
|
106
|
+
* here — the ONE place both `run()` and `planRun()` derive it from — is
|
|
107
|
+
* what keeps `--dry` describing the run you are about to get: reading the
|
|
108
|
+
* raw request made the plan label `--cache=local:,remote:rw` "cache miss —
|
|
109
|
+
* would exec" (a result that will be stored) for a run in which caching is
|
|
110
|
+
* entirely off.
|
|
111
|
+
*/
|
|
112
|
+
function effectiveCachePolicy(requested: CachePolicy, hasRemoteLayer: boolean): CachePolicy {
|
|
113
|
+
return hasRemoteLayer ? requested : { ...requested, remoteRead: false, remoteWrite: false }
|
|
114
|
+
}
|
|
115
|
+
|
|
82
116
|
/**
|
|
83
117
|
* The perf firewall for the local short-circuit. Always-on; the only
|
|
84
118
|
* gates are correctness/no-op gates:
|
|
85
|
-
* - LOCAL-ONLY cache.
|
|
119
|
+
* - LOCAL-ONLY cache. Behind a remote layer, `cache.get` is a remote
|
|
86
120
|
* READ-THROUGH — the up-front classify is awaited before scheduling,
|
|
87
121
|
* so N remote GETs would land on the critical path before any task
|
|
88
122
|
* starts. Remote runs are owned by `startRemotePrefetch`, which
|
|
89
123
|
* overlaps the GETs with execution instead (fire-and-forget) and
|
|
90
|
-
* ingests hits into local for execute-task's lazy probe.
|
|
124
|
+
* ingests hits into local for execute-task's lazy probe. The gate asks
|
|
125
|
+
* the layer (`hasRemote`), not `instanceof LayeredCache` — a
|
|
126
|
+
* third-party remote layer stalls on the classify just as hard.
|
|
91
127
|
* - the policy reads locally (a `--no-cache`/`--force`/`--cache=local:`
|
|
92
128
|
* run reads nothing locally → nothing to restore → skip);
|
|
93
|
-
* -
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
129
|
+
* - there is at least one task.
|
|
130
|
+
* A flat graph has no ordering to bypass, but the classify pass is worth
|
|
131
|
+
* paying for anyway: it probes every stable key through ONE batched
|
|
132
|
+
* `getMany` instead of a `cache.get` per task inside the run, which
|
|
133
|
+
* measured 84 → 78 ms on 100 dep-free tasks and 249 → 237 ms on 1000
|
|
134
|
+
* (2026-09-03, interleaved arms). The per-task correctness gates (stable
|
|
135
|
+
* key, no workspace-outputs in graph) live in `startLocalShortCircuit`.
|
|
98
136
|
*/
|
|
99
137
|
export function shouldShortCircuit(
|
|
100
138
|
nodes: Map<string, TaskNode>,
|
|
101
139
|
policy: CachePolicy,
|
|
102
140
|
cache: CacheLayer,
|
|
103
141
|
): boolean {
|
|
104
|
-
if (cache
|
|
142
|
+
if (cache.hasRemote === true) return false
|
|
105
143
|
if (!policy.localRead) return false
|
|
106
|
-
|
|
107
|
-
return false
|
|
144
|
+
return nodes.size > 0
|
|
108
145
|
}
|
|
109
146
|
|
|
110
147
|
export async function run(options: RunOptions): Promise<RunSummary> {
|
|
@@ -130,6 +167,20 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
130
167
|
const log = busLogger(bus)
|
|
131
168
|
|
|
132
169
|
const prepared = await prepareRun(options, log)
|
|
170
|
+
mark('prepare (graph)')
|
|
171
|
+
// A requested name that matched no project is a typo (or a stray
|
|
172
|
+
// positional from an `=`-only flag written with a space). Failing the
|
|
173
|
+
// whole run — even when OTHER requested tasks resolved — is the point:
|
|
174
|
+
// a CI job that renames a task must go red, not silently stop running
|
|
175
|
+
// it. When EVERY name is unresolved this is the `no-tasks-declared`
|
|
176
|
+
// case too; the message is identical, so that branch stays below.
|
|
177
|
+
if (prepared.unresolvedTasks.length > 0) {
|
|
178
|
+
log.status(
|
|
179
|
+
`No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${didYouMean(prepared.unresolvedTasks, prepared.projects)}`,
|
|
180
|
+
)
|
|
181
|
+
prepared.cache.close()
|
|
182
|
+
return { ok: false, outcomes: [] }
|
|
183
|
+
}
|
|
133
184
|
if (prepared.empty !== null) {
|
|
134
185
|
// `no-tasks-declared` is almost always a typo in CI; we surface
|
|
135
186
|
// a clear message and return NOT-ok so the script exits 1.
|
|
@@ -145,35 +196,23 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
145
196
|
}
|
|
146
197
|
// Install user plugins as additional bus subscribers BEFORE the run
|
|
147
198
|
// starts emitting events. `installPlugins` runs each plugin's optional
|
|
148
|
-
// `setup` hook
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// plugin; eventSink init failures are isolated (observability never
|
|
152
|
-
// breaks a run). With no plugin, both are no-ops.
|
|
199
|
+
// `setup` hook and fails fast on a throw with a clean UserError naming
|
|
200
|
+
// the plugin. A plugin without `setup` (the local executor and cache)
|
|
201
|
+
// subscribes nothing.
|
|
153
202
|
let disposePlugins: (() => void) | undefined
|
|
154
|
-
let eventSinks: SubscribedEventSinks | undefined
|
|
155
203
|
let telemetry: TelemetryHandle | undefined
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
204
|
+
try {
|
|
205
|
+
disposePlugins = await installPlugins({
|
|
206
|
+
plugins: prepared.plugins as never,
|
|
207
|
+
bus,
|
|
159
208
|
workspaceRoot: prepared.workspaceRoot,
|
|
160
209
|
cacheDir: prepared.cacheDir,
|
|
161
|
-
warn: (m
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
workspaceRoot: prepared.workspaceRoot,
|
|
168
|
-
cacheDir: prepared.cacheDir,
|
|
169
|
-
warn: (m) => log.status(m),
|
|
170
|
-
})
|
|
171
|
-
eventSinks = await subscribeEventSinks(plugins, bus, pluginCtx)
|
|
172
|
-
} catch (err) {
|
|
173
|
-
disposePlugins?.()
|
|
174
|
-
prepared.cache.close()
|
|
175
|
-
throw err
|
|
176
|
-
}
|
|
210
|
+
warn: (m) => log.status(m),
|
|
211
|
+
})
|
|
212
|
+
} catch (err) {
|
|
213
|
+
disposePlugins?.()
|
|
214
|
+
prepared.cache.close()
|
|
215
|
+
throw err
|
|
177
216
|
}
|
|
178
217
|
const {
|
|
179
218
|
workspaceRoot,
|
|
@@ -192,6 +231,103 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
192
231
|
workspaceConfig?.concurrency ??
|
|
193
232
|
Math.max(1, navigator.hardwareConcurrency)
|
|
194
233
|
|
|
234
|
+
// Resolved ONCE per run, in declaration order. A broken factory — or a
|
|
235
|
+
// workspace that declared no executor — aborts here, before any task
|
|
236
|
+
// starts.
|
|
237
|
+
let executors: readonly TaskExecutor[]
|
|
238
|
+
try {
|
|
239
|
+
executors = await resolveExecutors(prepared.plugins, {
|
|
240
|
+
workspaceRoot: prepared.workspaceRoot,
|
|
241
|
+
cacheDir: prepared.cacheDir,
|
|
242
|
+
warn: (m: string) => log.status(m),
|
|
243
|
+
concurrency,
|
|
244
|
+
})
|
|
245
|
+
} catch (err) {
|
|
246
|
+
disposePlugins?.()
|
|
247
|
+
prepared.cache.close()
|
|
248
|
+
throw err
|
|
249
|
+
}
|
|
250
|
+
// Placement: decided ONCE per task, before scheduling, so the scheduler
|
|
251
|
+
// can admit a remote-pooled task against its pool instead of a local
|
|
252
|
+
// worker slot. Group tasks run nothing; persistent tasks never reach an
|
|
253
|
+
// executor (local by construction) — both stay off the map.
|
|
254
|
+
//
|
|
255
|
+
// `--verify=inputs` pins EVERYTHING local: the input-completeness proof
|
|
256
|
+
// is the OS sandbox, which is local machinery a remote executor silently
|
|
257
|
+
// ignores — so a remotely-executed task would pass the verify VACUOUSLY,
|
|
258
|
+
// leaky or not. A verify run is a local proof procedure by definition;
|
|
259
|
+
// determinism/fingerprint modes are unaffected (no sandbox involved).
|
|
260
|
+
const placements = placeTasks(nodes, executors, options.verify?.inputs === true)
|
|
261
|
+
// A `remote: 'only'` task nobody takes succeeds WITHOUT running. That is
|
|
262
|
+
// deliberate — on a machine with no remote pool the ambient state already
|
|
263
|
+
// is what the task would have produced — but it must not be SILENT: a task
|
|
264
|
+
// reporting success while doing nothing is indistinguishable from one that
|
|
265
|
+
// worked, and the usual cause (no `cache` block, so no executor can
|
|
266
|
+
// describe its inputs to a worker) cannot be read off the outcome.
|
|
267
|
+
if (placements.remoteOnlyNoop.size > 0) {
|
|
268
|
+
const anyRemote = executors.some((e) => e.remote === true)
|
|
269
|
+
for (const id of placements.remoteOnlyNoop) {
|
|
270
|
+
const why = !anyRemote
|
|
271
|
+
? 'no remote executor is declared'
|
|
272
|
+
: nodes.get(id)?.config.cache === undefined
|
|
273
|
+
? 'no remote executor accepted it — the task declares no `cache` block, so there is no input set to ship'
|
|
274
|
+
: 'no remote executor accepted it'
|
|
275
|
+
log.status(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Demand: what each executor still has left to run. An executor that
|
|
279
|
+
// provisions per task (a container, an allocation, a pod) otherwise has to
|
|
280
|
+
// guess from `capacity` alone and hold everything until teardown. Built
|
|
281
|
+
// ONLY for executors that asked — with none declared there is no map, no
|
|
282
|
+
// set, and no call in the completion path.
|
|
283
|
+
const demandOf = new Map<TaskExecutor, Set<string>>()
|
|
284
|
+
for (const [id, executor] of placements.executors) {
|
|
285
|
+
if (executor.demand === undefined) continue
|
|
286
|
+
let remaining = demandOf.get(executor)
|
|
287
|
+
if (remaining === undefined) demandOf.set(executor, (remaining = new Set()))
|
|
288
|
+
remaining.add(id)
|
|
289
|
+
}
|
|
290
|
+
// The SAME set is handed back every time, narrowed in place: a consumer
|
|
291
|
+
// reading it later sees the truth rather than a stale snapshot.
|
|
292
|
+
for (const [executor, remaining] of demandOf) executor.demand!(remaining)
|
|
293
|
+
const narrowDemand =
|
|
294
|
+
demandOf.size === 0
|
|
295
|
+
? (): void => {}
|
|
296
|
+
: (id: string): void => {
|
|
297
|
+
for (const [executor, remaining] of demandOf) {
|
|
298
|
+
if (remaining.delete(id)) executor.demand!(remaining)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// `--download` (default `all`) decides ONCE per task, here, whether a
|
|
303
|
+
// remote execution's outputs come home. `--verify` pins every task local
|
|
304
|
+
// (so nothing defers under a proof) and `all` keeps today's behaviour
|
|
305
|
+
// byte for byte.
|
|
306
|
+
// A proof must observe what it proves. `--verify=inputs` already pins
|
|
307
|
+
// every task LOCAL (so nothing could defer), but determinism and
|
|
308
|
+
// fingerprint modes do NOT — and a deferred task's outputs are absent
|
|
309
|
+
// when the verifier looks, which reported `no-outputs` for a task that
|
|
310
|
+
// declares outputs: an n/a verdict for work the proof never examined.
|
|
311
|
+
// Deferral is transfer tuning; a verify run is a rare, deliberate
|
|
312
|
+
// correctness run. Eager wins, and says so when it overrides.
|
|
313
|
+
const downloadPolicy = options.verify !== undefined ? 'all' : (options.download ?? 'all')
|
|
314
|
+
if (
|
|
315
|
+
options.verify !== undefined &&
|
|
316
|
+
options.download !== undefined &&
|
|
317
|
+
options.download !== 'all'
|
|
318
|
+
) {
|
|
319
|
+
log.status(`vx: --verify observes outputs on disk — ignoring --download=${options.download}`)
|
|
320
|
+
}
|
|
321
|
+
const localPlaced = new Set(
|
|
322
|
+
[...nodes.keys()].filter((id) => placements.executors.get(id)?.remote !== true),
|
|
323
|
+
)
|
|
324
|
+
const download = resolveDownloadModes({
|
|
325
|
+
nodes,
|
|
326
|
+
policy: downloadPolicy,
|
|
327
|
+
localPlaced,
|
|
328
|
+
remoteOnly: placements.remoteOnly,
|
|
329
|
+
})
|
|
330
|
+
|
|
195
331
|
// Run-level default task timeout (ms), applied to any task WITHOUT its own
|
|
196
332
|
// `exec.timeout`. Precedence, highest first: `--timeout`/RunOptions.timeout
|
|
197
333
|
// → `VX_TASK_TIMEOUT` env → workspace `timeout`. A malformed env value is
|
|
@@ -241,26 +377,68 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
241
377
|
process.on('SIGINT', onSigint)
|
|
242
378
|
process.on('SIGTERM', onSigterm)
|
|
243
379
|
}
|
|
380
|
+
// The cache handle must be released on EVERY exit path, not just the
|
|
381
|
+
// happy one: `close()` is also where the run's deferred `accessed_at`
|
|
382
|
+
// bumps are flushed, so a throw between opening the cache and the
|
|
383
|
+
// normal close leaked the SQLite handle (it matters in a long-lived
|
|
384
|
+
// host) AND lost this run's touch record, after which an LRU
|
|
385
|
+
// `vx cache prune` can evict entries the run just hit. Once-only
|
|
386
|
+
// because the normal path closes before the persistent-task wait —
|
|
387
|
+
// holding the handle open for a dev server's whole lifetime would be
|
|
388
|
+
// worse — and because close() re-runs its retention DELETEs.
|
|
389
|
+
let cacheClosed = false
|
|
390
|
+
const closeCache = (): void => {
|
|
391
|
+
if (cacheClosed) return
|
|
392
|
+
cacheClosed = true
|
|
393
|
+
cache.close()
|
|
394
|
+
}
|
|
244
395
|
try {
|
|
245
396
|
// One run-id per `vx run` invocation. Every task in the resulting
|
|
246
397
|
// graph carries it so analytics queries can group by invocation.
|
|
247
398
|
const runId = ulid()
|
|
248
399
|
const runStartHrTimeNs = process.hrtime.bigint()
|
|
249
400
|
const endedAtMsAtStart = Date.now()
|
|
250
|
-
const remoteCacheEnabled =
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
prepared.
|
|
401
|
+
const remoteCacheEnabled = prepared.hasRemoteLayer
|
|
402
|
+
// Normalised ONCE so every consumer — the verify gate, execute-task,
|
|
403
|
+
// the dedup predicate, the recorded invocation row — reads the policy
|
|
404
|
+
// that actually governed the run. Reading the raw request here made
|
|
405
|
+
// tasks clean their outputs before every exec for a save that never
|
|
406
|
+
// happened, and `--verify` clean them AGAIN and restore an artifact
|
|
407
|
+
// that was never written (wiping a successful build's tree and
|
|
408
|
+
// reporting it failed).
|
|
409
|
+
const policy: CachePolicy = effectiveCachePolicy(
|
|
410
|
+
options.cache ?? FULL_CACHE_POLICY,
|
|
411
|
+
prepared.hasRemoteLayer,
|
|
412
|
+
)
|
|
413
|
+
const deferredOutputs = new DeferredOutputs({
|
|
414
|
+
nodes,
|
|
415
|
+
cache,
|
|
416
|
+
workspaceRoot,
|
|
417
|
+
nestedDirsByProject,
|
|
418
|
+
...(gitFilesCache !== undefined ? { gitFilesCache } : {}),
|
|
419
|
+
localWrite: policy.localWrite,
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
// `--verify` observes the miss-then-save path and then RESTORES attempt
|
|
423
|
+
// 1 from the artifact that save wrote, so the tree ends byte-identical
|
|
424
|
+
// to what was cached regardless of the verdict. That restore reads the
|
|
425
|
+
// LOCAL artifact file — `restoreOutputs` is a local extraction on every
|
|
426
|
+
// layer, by design — so without the local WRITE axis there is nothing to
|
|
427
|
+
// restore from: verify cleaned a successful build's declared outputs and
|
|
428
|
+
// then failed the run on the missing artifact. A remote fallback would
|
|
429
|
+
// not fix that, only narrow it to "whenever the remote is reachable at
|
|
430
|
+
// that instant", turning a guarantee into a coin flip with data loss on
|
|
431
|
+
// the losing side. So the honest gate is the local write axis, and
|
|
432
|
+
// `--no-cache` / `--cache=local:,…` / `--cache=local:r,…` are refused
|
|
433
|
+
// loudly — silently verifying nothing is the one failure mode
|
|
434
|
+
// verification must never have (same platform-honesty rule as the
|
|
435
|
+
// sandbox-unavailable error). `--force --verify` re-verifies a warm graph.
|
|
436
|
+
if (options.verify !== undefined && !policy.localWrite) {
|
|
261
437
|
throw new UserError(
|
|
262
|
-
'--verify needs
|
|
263
|
-
'
|
|
438
|
+
'--verify needs the LOCAL cache write axis: it re-runs the task, then restores ' +
|
|
439
|
+
'attempt 1 from the local artifact so the outputs on disk are the ones that were ' +
|
|
440
|
+
'cached. Enable local writes (e.g. --cache=local:w,remote:rw), drop --no-cache, ' +
|
|
441
|
+
'or use --force --verify to re-execute and verify a warm graph',
|
|
264
442
|
)
|
|
265
443
|
}
|
|
266
444
|
|
|
@@ -275,16 +453,21 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
275
453
|
|
|
276
454
|
// The canonical run-context record — the same git/CI/host data the
|
|
277
455
|
// invocation header uses, shaped as the telemetry export contract.
|
|
278
|
-
// Built + consulted ONLY when
|
|
279
|
-
//
|
|
456
|
+
// Built + consulted ONLY when a plugin CONTRIBUTES `telemetry`, and
|
|
457
|
+
// BEFORE run:start is emitted so a sink
|
|
458
|
+
// catches the whole stream.
|
|
280
459
|
// subscribeTelemetry returns undefined when no sink is contributed (no
|
|
281
460
|
// telemetry plugin, or all declined — e.g. otel() with no OTLP endpoint),
|
|
282
461
|
// so a plain run does ZERO extra work: no record allocation, no bus
|
|
283
462
|
// subscriber, no summary building. The hot path stays off-limits.
|
|
284
463
|
let runContextRecord: RunContextRecord | undefined
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
464
|
+
// Gate on a plugin that can actually CONTRIBUTE a sink, not on declaring
|
|
465
|
+
// any plugin at all: a cache-only plugin has no telemetry hook to
|
|
466
|
+
// consult, so paying for the record (2 git spawns + a `.vx/workspace-id`
|
|
467
|
+
// write on a remote-less repo) buys nothing. A plugin that has the hook
|
|
468
|
+
// but declines still pays — its answer is only knowable by asking.
|
|
469
|
+
const hasTelemetryPlugin = prepared.plugins.some((p) => p.telemetry !== undefined)
|
|
470
|
+
if (hasTelemetryPlugin || options.telemetrySinks !== undefined) {
|
|
288
471
|
// Workspace identity (telemetry v2): one git spawn, paid only when a
|
|
289
472
|
// telemetry consumer can exist — a plain run never reaches here.
|
|
290
473
|
const wsIdentity = captureWorkspaceIdentity(workspaceRoot)
|
|
@@ -298,6 +481,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
298
481
|
flow: options.flow ?? null,
|
|
299
482
|
commitSha: gitContext.commitSha,
|
|
300
483
|
branch: gitContext.branch,
|
|
484
|
+
defaultBranch: captureDefaultBranch(process.env, workspaceRoot),
|
|
301
485
|
dirty: gitContext.dirty,
|
|
302
486
|
ci: ciContext.ci,
|
|
303
487
|
ciProvider: ciContext.provider,
|
|
@@ -309,7 +493,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
309
493
|
tags: options.tags ?? {},
|
|
310
494
|
}
|
|
311
495
|
telemetry = await subscribeTelemetry(
|
|
312
|
-
|
|
496
|
+
prepared.plugins,
|
|
313
497
|
bus,
|
|
314
498
|
{ workspaceRoot, cacheDir, warn: (m: string) => log.status(m) },
|
|
315
499
|
runContextRecord,
|
|
@@ -329,7 +513,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
329
513
|
if (anySandboxed) {
|
|
330
514
|
const avail = await probeSandbox()
|
|
331
515
|
if (!avail.available) {
|
|
332
|
-
prepared.cache.close()
|
|
333
516
|
throw new UserError(
|
|
334
517
|
verifyInputs
|
|
335
518
|
? `--verify=inputs needs the sandbox, which is not available: ${avail.reason}`
|
|
@@ -370,7 +553,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
370
553
|
// Nothing declared → empty map → fields omitted from the scheduler
|
|
371
554
|
// AND the footer → byte-identical legacy path.
|
|
372
555
|
const memBudget = options.memory ?? os.totalmem()
|
|
373
|
-
const resourceCosts = resolveResourceCosts(nodes
|
|
556
|
+
const resourceCosts = resolveResourceCosts(nodes)
|
|
374
557
|
|
|
375
558
|
// Run context for the footer. The top-of-run header is gone — the
|
|
376
559
|
// banner now lives in the summary, where the eye lands at the end.
|
|
@@ -385,17 +568,23 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
385
568
|
|
|
386
569
|
// Lifecycle hooks drive the default logger's dynamic status line
|
|
387
570
|
// (TTY-only); custom loggers may ignore them.
|
|
388
|
-
log.runStart?.({
|
|
571
|
+
log.runStart?.({
|
|
572
|
+
total: taskCount,
|
|
573
|
+
concurrency,
|
|
574
|
+
requestedCount,
|
|
575
|
+
context: runContext,
|
|
576
|
+
startedAtMs: endedAtMsAtStart,
|
|
577
|
+
})
|
|
389
578
|
|
|
390
579
|
// Remote-only: kick off background prefetches so remote-GET latency
|
|
391
580
|
// overlaps execution. Fire-and-forget — execution starts on the next
|
|
392
|
-
// line;
|
|
581
|
+
// line; the layer ingests hits into local and de-dups so
|
|
393
582
|
// execute-task's cache.get awaits the in-flight promise (one remote
|
|
394
583
|
// GET per key). Gated entirely on a remote layer being configured;
|
|
395
584
|
// local-only runs never reach here, so their behavior + perf is
|
|
396
585
|
// unchanged (no upfront key pass, no local probing).
|
|
397
586
|
let prefetchDone: Promise<void> = Promise.resolve()
|
|
398
|
-
if (
|
|
587
|
+
if (prepared.hasRemoteLayer) {
|
|
399
588
|
prefetchDone = startRemotePrefetch({
|
|
400
589
|
nodes,
|
|
401
590
|
cache,
|
|
@@ -416,8 +605,8 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
416
605
|
// restore-tier the scheduler runs ahead of their deps but only as
|
|
417
606
|
// worker-slot backfill (misses own the pool) — and execute reuses
|
|
418
607
|
// each probe, so there is no second cache.get. Gated by
|
|
419
|
-
// shouldShortCircuit (
|
|
420
|
-
// empty and the run is byte-identical.
|
|
608
|
+
// shouldShortCircuit (local reads on, no remote layer); when off, both
|
|
609
|
+
// maps are empty and the run is byte-identical.
|
|
421
610
|
let shortCircuit: ShortCircuit = EMPTY_SHORT_CIRCUIT
|
|
422
611
|
if (shouldShortCircuit(nodes, policy, cache)) {
|
|
423
612
|
shortCircuit = await startLocalShortCircuit({
|
|
@@ -447,6 +636,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
447
636
|
...(taskTimeoutDefault !== undefined ? { timeout: taskTimeoutDefault } : {}),
|
|
448
637
|
...(options.verify !== undefined ? { verify: options.verify } : {}),
|
|
449
638
|
log,
|
|
639
|
+
executor: placements.executors.get(node.id) ?? UNPLACED_EXECUTOR,
|
|
640
|
+
...(download.modeOf.get(node.id) === 'deferred' ? { download: 'deferred' as const } : {}),
|
|
641
|
+
deferred: deferredOutputs,
|
|
642
|
+
...(placements.remoteOnlyNoop.has(node.id) ? { remoteOnlyNoop: true } : {}),
|
|
643
|
+
...(placements.remoteOnly.has(node.id) ? { remoteOnly: true } : {}),
|
|
450
644
|
nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
|
|
451
645
|
runStartHrTimeNs,
|
|
452
646
|
persistentRegistry,
|
|
@@ -526,9 +720,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
526
720
|
}
|
|
527
721
|
}
|
|
528
722
|
|
|
723
|
+
mark('classify + probe')
|
|
529
724
|
const outcomes = await runGraph({
|
|
530
725
|
nodes,
|
|
531
726
|
concurrency,
|
|
727
|
+
...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
|
|
532
728
|
...(resourceCosts.size > 0 ? { resourceCosts, cpuBudget: concurrency, memBudget } : {}),
|
|
533
729
|
...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
|
|
534
730
|
onStart: (node) => {
|
|
@@ -536,11 +732,12 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
536
732
|
},
|
|
537
733
|
onFinish: (o) => {
|
|
538
734
|
log.taskComplete(o.node, o)
|
|
735
|
+
narrowDemand(o.node.id)
|
|
539
736
|
},
|
|
540
737
|
execute: executeWithDedup,
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
priorities: prepared.priorities,
|
|
738
|
+
// A `schedule` plugin's weights; the scheduler keeps its structural
|
|
739
|
+
// baseline as the tie-break. Empty map → baseline only.
|
|
740
|
+
...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
|
|
544
741
|
// Local short-circuit: confirmed stable local hits the scheduler
|
|
545
742
|
// runs ahead of their deps as low-priority worker-slot backfill.
|
|
546
743
|
// Empty when the short-circuit didn't fire → byte-identical.
|
|
@@ -571,27 +768,39 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
571
768
|
}
|
|
572
769
|
const keepAliveSet = new Set(keepAlive)
|
|
573
770
|
|
|
574
|
-
// Shut down the dependency-only persistent tasks before reporting the
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
|
|
771
|
+
// Shut down the dependency-only persistent tasks before reporting the final
|
|
772
|
+
// summary. SIGTERM gives well-behaved servers (vite, next, esbuild --watch)
|
|
773
|
+
// a moment to clean up. Bun's Subprocess.kill is idempotent on an
|
|
774
|
+
// already-exited child. Bound the wait: a persistent dep that traps or
|
|
775
|
+
// ignores SIGTERM (a wedged mock server) would otherwise hang the run at
|
|
776
|
+
// NORMAL completion forever — after a grace, SIGKILL the stragglers and move
|
|
777
|
+
// on. Well-behaved servers exit in well under the grace, so the happy path
|
|
778
|
+
// pays nothing; the timer is cleared + unref'd so a fast shutdown never
|
|
779
|
+
// delays CLI exit.
|
|
780
|
+
const dyingChildren = [...persistentRegistry.values()].filter((c) => !keepAliveSet.has(c))
|
|
781
|
+
for (const child of dyingChildren) child.kill('SIGTERM')
|
|
782
|
+
const allExited = Promise.allSettled(dyingChildren.map((c) => c.exited))
|
|
783
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
784
|
+
const winner = await Promise.race([
|
|
785
|
+
allExited.then(() => 'exited' as const),
|
|
786
|
+
new Promise<'grace'>((resolve) => {
|
|
787
|
+
graceTimer = setTimeout(() => resolve('grace'), PERSISTENT_SHUTDOWN_GRACE_MS)
|
|
788
|
+
graceTimer.unref?.()
|
|
789
|
+
}),
|
|
790
|
+
])
|
|
791
|
+
if (graceTimer !== undefined) clearTimeout(graceTimer)
|
|
792
|
+
if (winner === 'grace') {
|
|
793
|
+
for (const child of dyingChildren) child.kill('SIGKILL')
|
|
794
|
+
await allExited
|
|
581
795
|
}
|
|
582
|
-
await Promise.allSettled(
|
|
583
|
-
[...persistentRegistry.values()].filter((c) => !keepAliveSet.has(c)).map((c) => c.exited),
|
|
584
|
-
)
|
|
585
796
|
|
|
797
|
+
mark('run graph')
|
|
586
798
|
// Clear the status line for good before the summary prints.
|
|
587
799
|
log.runEnd?.()
|
|
588
800
|
|
|
589
801
|
const list = [...outcomes.values()]
|
|
590
802
|
const ok =
|
|
591
|
-
list.every(
|
|
592
|
-
(o) =>
|
|
593
|
-
o.status === 'success' || o.status === 'cache-hit' || o.status === 'cache-hit-remote',
|
|
594
|
-
) &&
|
|
803
|
+
list.every((o) => isPassStatus(o.status)) &&
|
|
595
804
|
// `--verify`: a provably-unsafe cache entry (non-deterministic outputs, a
|
|
596
805
|
// re-run that failed, or a read of undeclared inputs) turns the run red so
|
|
597
806
|
// CI catches it.
|
|
@@ -613,6 +822,19 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
613
822
|
for (const line of formatPersistentList(keepAliveNodes, colors)) log.status(line)
|
|
614
823
|
}
|
|
615
824
|
for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
|
|
825
|
+
// A task killed by a shutdown signal is in no bucket above, yet it makes
|
|
826
|
+
// `ok` false — name it, or the red exit is undiagnosable.
|
|
827
|
+
for (const line of formatAbortedSection(list)) log.status(line)
|
|
828
|
+
// Outputs that never came home are not an error, but a silent `dist/`
|
|
829
|
+
// that is empty-or-stale would be: name every task whose bytes are
|
|
830
|
+
// still remote.
|
|
831
|
+
const stillDeferred = deferredOutputs.pending()
|
|
832
|
+
if (stillDeferred.length > 0) {
|
|
833
|
+
log.status('')
|
|
834
|
+
log.status(
|
|
835
|
+
` Deferred: ${stillDeferred.length} task(s) left outputs remote (--download=none): ${stillDeferred.join(', ')}`,
|
|
836
|
+
)
|
|
837
|
+
}
|
|
616
838
|
if (options.verify !== undefined) {
|
|
617
839
|
for (const line of formatVerifySection(list)) log.status(line)
|
|
618
840
|
// A fingerprint-only run attaches no verdicts, so the verdict-driven
|
|
@@ -644,6 +866,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
644
866
|
startedAtMs: endedAtMsAtStart,
|
|
645
867
|
endedAtMs,
|
|
646
868
|
totalMs,
|
|
869
|
+
ok,
|
|
647
870
|
outcomes: list,
|
|
648
871
|
})
|
|
649
872
|
log.status(`vx: summary written to ${wrote}`)
|
|
@@ -683,7 +906,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
683
906
|
let hitLocalCount = 0
|
|
684
907
|
let hitRemoteCount = 0
|
|
685
908
|
for (const o of list) {
|
|
686
|
-
if (!o.hash) continue
|
|
687
909
|
if (isGroupTask(o.node)) continue
|
|
688
910
|
// aborted (killed by a shutdown signal) isn't a real run.
|
|
689
911
|
if (o.status === 'aborted') continue
|
|
@@ -700,6 +922,8 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
700
922
|
if (o.hash !== undefined) t.hash = o.hash
|
|
701
923
|
if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
|
|
702
924
|
if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
|
|
925
|
+
if (o.where !== undefined) t.where = o.where
|
|
926
|
+
if (o.outputs !== undefined) t.outputs = o.outputs
|
|
703
927
|
if (o.attempts !== undefined) t.attempts = o.attempts
|
|
704
928
|
if (o.verify !== undefined) t.verify = o.verify
|
|
705
929
|
if (o.outputFp !== undefined) t.outputFp = o.outputFp
|
|
@@ -707,8 +931,17 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
707
931
|
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
|
708
932
|
summaryTasks.push(t)
|
|
709
933
|
}
|
|
934
|
+
// Every non-group, non-aborted outcome gets a row — the same set
|
|
935
|
+
// `tallyOutcomes` counts, so `invocations.task_count` equals both the
|
|
936
|
+
// terminal's "N total" and `COUNT(*) FROM runs WHERE run_id = ?`.
|
|
937
|
+
// An outcome with NO hash (a `skipped` task never probed the cache; a
|
|
938
|
+
// `persistent` one is never cacheable) used to be dropped here because
|
|
939
|
+
// `runs.hash` is NOT NULL — which made a failing persistent task record
|
|
940
|
+
// `0 tasks, 0 failures` on a run the terminal called red, and a failed
|
|
941
|
+
// task with a skipped dependent record 1 of 2. `bindRun` stores `''` for
|
|
942
|
+
// those instead; the key-diff readers guard it.
|
|
710
943
|
toRecord.push({
|
|
711
|
-
hash: o.hash,
|
|
944
|
+
...(o.hash !== undefined ? { hash: o.hash } : {}),
|
|
712
945
|
project: o.node.projectName,
|
|
713
946
|
task: o.node.taskName,
|
|
714
947
|
status: o.status,
|
|
@@ -717,9 +950,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
717
950
|
...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
|
|
718
951
|
// Anchor to the REAL per-task wall-clock window: run-start wall time +
|
|
719
952
|
// the task's ns offset (captured for hits and executed tasks alike).
|
|
720
|
-
// The `now - duration` fallback
|
|
721
|
-
//
|
|
722
|
-
//
|
|
953
|
+
// The `now - duration` fallback applies to outcomes without an offset —
|
|
954
|
+
// today only `skipped`, which the scheduler finishes synchronously with
|
|
955
|
+
// no span, so it collapses to a zero-width mark at the run's end. Using
|
|
956
|
+
// run-end-minus-duration for EVERYTHING was the old bug that piled every
|
|
957
|
+
// task at the right edge of the timeline.
|
|
723
958
|
startedAt:
|
|
724
959
|
o.wallclockStartNs !== undefined
|
|
725
960
|
? endedAtMsAtStart + Math.round(Number(o.wallclockStartNs) / 1e6)
|
|
@@ -733,7 +968,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
733
968
|
...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
|
|
734
969
|
...(o.wallclockStartNs !== undefined ? { wallclockStartNs: o.wallclockStartNs } : {}),
|
|
735
970
|
...(o.wallclockEndNs !== undefined ? { wallclockEndNs: o.wallclockEndNs } : {}),
|
|
736
|
-
cacheHit: o.status
|
|
971
|
+
cacheHit: isCacheHit(o.status),
|
|
737
972
|
...(o.attempts !== undefined ? { attempts: o.attempts } : {}),
|
|
738
973
|
})
|
|
739
974
|
if (o.status === 'failed') failedCount++
|
|
@@ -768,41 +1003,26 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
768
1003
|
tags: JSON.stringify(options.tags ?? {}),
|
|
769
1004
|
}
|
|
770
1005
|
cache.recordRunBundle({ runs: toRecord, invocation })
|
|
1006
|
+
mark('record history')
|
|
771
1007
|
// Hand the per-run summary to the telemetry sinks + drain them. Only
|
|
772
1008
|
// when a sink is active (telemetry !== undefined) — otherwise this
|
|
773
1009
|
// whole block is skipped and the run is byte-identical to before.
|
|
774
1010
|
// emitSummary/flush are crash-isolated, so a faulty sink can't fail
|
|
775
1011
|
// the run; flush is the sink's last chance to ship buffered records.
|
|
776
1012
|
if (telemetry !== undefined && runContextRecord !== undefined) {
|
|
777
|
-
const summary
|
|
778
|
-
v: TELEMETRY_SCHEMA_VERSION,
|
|
779
|
-
run: runContextRecord,
|
|
1013
|
+
const summary = assembleRunSummary(runContextRecord, summaryTasks, {
|
|
780
1014
|
startedAt: endedAtMsAtStart,
|
|
781
1015
|
endedAt: endedAtMs,
|
|
782
1016
|
totalDurationMs: Math.round(totalMs),
|
|
783
|
-
taskCount: toRecord.length,
|
|
784
|
-
failedCount,
|
|
785
|
-
hitCount: hitLocalCount + hitRemoteCount,
|
|
786
|
-
hitLocalCount,
|
|
787
|
-
hitRemoteCount,
|
|
788
1017
|
exitOk: ok,
|
|
789
|
-
|
|
790
|
-
}
|
|
1018
|
+
})
|
|
791
1019
|
telemetry.emitSummary(summary)
|
|
792
1020
|
await telemetry.flush()
|
|
793
1021
|
}
|
|
794
|
-
// End-of-run plugin lifecycle: each
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
// only — the finally below just unsubscribes.
|
|
799
|
-
if (prepared.workspaceConfig?.plugins && prepared.workspaceConfig.plugins.length > 0) {
|
|
800
|
-
await teardownPlugins(
|
|
801
|
-
prepared.workspaceConfig.plugins as readonly VxPlugin[],
|
|
802
|
-
eventSinks?.sinks ?? [],
|
|
803
|
-
(m) => log.status(m),
|
|
804
|
-
)
|
|
805
|
-
}
|
|
1022
|
+
// End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
|
|
1023
|
+
// + time-bounded inside teardownPlugins, so a faulty plugin can neither
|
|
1024
|
+
// fail nor hang the run. Normal completion path only — the finally
|
|
1025
|
+
// below just unsubscribes.
|
|
806
1026
|
// Drain any still-in-flight background prefetches before closing the
|
|
807
1027
|
// cache handle — a prefetch ingesting into a closed SQLite DB would
|
|
808
1028
|
// throw. Tasks that resolved as local hits never awaited their
|
|
@@ -811,9 +1031,19 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
811
1031
|
// before scheduling, so nothing of its is in flight here.) The
|
|
812
1032
|
// background write-through uploads queued by LayeredCache.save
|
|
813
1033
|
// settle here for the same reason.
|
|
1034
|
+
//
|
|
1035
|
+
// BEFORE teardownPlugins, and that order is load-bearing: the drain
|
|
1036
|
+
// pushes bytes through a layer a PLUGIN provided, so a plugin that
|
|
1037
|
+
// releases its client in `teardown()` would otherwise have the
|
|
1038
|
+
// channel shut from under the upload — silently losing every remote
|
|
1039
|
+
// write with nothing but a warning. The seam's contract is that a
|
|
1040
|
+
// cache layer stays usable until the run's uploads have settled.
|
|
814
1041
|
await prefetchDone
|
|
815
|
-
|
|
816
|
-
|
|
1042
|
+
await cache.drainUploads?.()
|
|
1043
|
+
await teardownPlugins(prepared.plugins, (m) => log.status(m))
|
|
1044
|
+
closeCache()
|
|
1045
|
+
mark('close')
|
|
1046
|
+
printTimings()
|
|
817
1047
|
|
|
818
1048
|
// Tear down SRT's network bridge + (on macOS) log monitor. No-op if
|
|
819
1049
|
// no task was sandboxed; otherwise SRT keeps proxy servers alive and
|
|
@@ -849,8 +1079,17 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
849
1079
|
// Plugins installed at the top of run() get their bus subscriptions
|
|
850
1080
|
// released here. Idempotent; safe even if installPlugins threw.
|
|
851
1081
|
disposePlugins?.()
|
|
852
|
-
eventSinks?.dispose()
|
|
853
1082
|
telemetry?.dispose()
|
|
1083
|
+
// No-op after the normal path's close. On a throw this is the only
|
|
1084
|
+
// close there is, and it must not itself throw — that would replace
|
|
1085
|
+
// the run's real error with a teardown one. Background uploads are
|
|
1086
|
+
// NOT drained here: they hold no database state, and awaiting a
|
|
1087
|
+
// wedged remote would turn a failing run into a hanging one.
|
|
1088
|
+
try {
|
|
1089
|
+
closeCache()
|
|
1090
|
+
} catch {
|
|
1091
|
+
// teardown must not throw on the way out
|
|
1092
|
+
}
|
|
854
1093
|
}
|
|
855
1094
|
}
|
|
856
1095
|
|
|
@@ -869,19 +1108,248 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
869
1108
|
const log = options.log ?? defaultLogger()
|
|
870
1109
|
const prepared = await prepareRun(options, log)
|
|
871
1110
|
try {
|
|
1111
|
+
if (prepared.unresolvedTasks.length > 0) {
|
|
1112
|
+
return { tasks: [], unresolvedTasks: prepared.unresolvedTasks }
|
|
1113
|
+
}
|
|
872
1114
|
if (prepared.empty !== null) return { tasks: [] }
|
|
873
1115
|
return await plan({
|
|
874
1116
|
nodes: prepared.nodes,
|
|
875
1117
|
workspaceRoot: prepared.workspaceRoot,
|
|
876
1118
|
workspaceFingerprint: prepared.workspaceFingerprint,
|
|
877
1119
|
cache: prepared.cache,
|
|
878
|
-
cachePolicy:
|
|
1120
|
+
cachePolicy: effectiveCachePolicy(
|
|
1121
|
+
options.cache ?? FULL_CACHE_POLICY,
|
|
1122
|
+
prepared.hasRemoteLayer,
|
|
1123
|
+
),
|
|
879
1124
|
forwardArgs: options.forwardArgs,
|
|
880
1125
|
nestedDirsByProject: prepared.nestedDirsByProject,
|
|
881
1126
|
gitFilesCache: prepared.gitFilesCache,
|
|
882
1127
|
hashCache: prepared.hashCache,
|
|
1128
|
+
// Plan-time duration prediction (dev-scenarios S2): the same local
|
|
1129
|
+
// history the predictive scheduler reads, folded into the plan as
|
|
1130
|
+
// per-task p50s + a predicted wall-clock. `--dry` is an explicit
|
|
1131
|
+
// inspection command, so the history read's cost is fine here even
|
|
1132
|
+
// though it stays OFF the default run path.
|
|
1133
|
+
history: new LocalHistoryProvider(prepared.localCache.dbHandle()),
|
|
1134
|
+
// Placement, by the same rules the run applies. Only worth showing
|
|
1135
|
+
// when there is a choice to show — with one declared executor every
|
|
1136
|
+
// line would carry the same label. Resolving the executors here is
|
|
1137
|
+
// the same plugin-factory call `prepareRun` already makes for the
|
|
1138
|
+
// cache capability, so plan mode gains no new class of side effect.
|
|
1139
|
+
...(await planExecutorOf(prepared, log, options.download ?? 'all')),
|
|
883
1140
|
})
|
|
884
1141
|
} finally {
|
|
885
1142
|
prepared.cache.close()
|
|
886
1143
|
}
|
|
887
1144
|
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* A task is pinned to this machine when it is persistent, transitively
|
|
1148
|
+
* depends on a persistent task (a worker cannot reach a port on the
|
|
1149
|
+
* submitter), or declares `exec.remote: false`.
|
|
1150
|
+
*/
|
|
1151
|
+
function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
|
|
1152
|
+
const pinned = new Set<string>()
|
|
1153
|
+
const memo = new Map<string, boolean>()
|
|
1154
|
+
const visit = (id: string): boolean => {
|
|
1155
|
+
const known = memo.get(id)
|
|
1156
|
+
if (known !== undefined) return known
|
|
1157
|
+
const node = nodes.get(id)
|
|
1158
|
+
if (node === undefined) return false
|
|
1159
|
+
memo.set(id, false) // cycle guard; the graph builder already rejects cycles
|
|
1160
|
+
const result =
|
|
1161
|
+
node.config.exec?.persistent !== undefined ||
|
|
1162
|
+
node.config.exec?.remote === false ||
|
|
1163
|
+
node.deps.some((d) => visit(d))
|
|
1164
|
+
memo.set(id, result)
|
|
1165
|
+
if (result) pinned.add(id)
|
|
1166
|
+
return result
|
|
1167
|
+
}
|
|
1168
|
+
for (const id of nodes.keys()) visit(id)
|
|
1169
|
+
return pinned
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
interface Placements {
|
|
1173
|
+
executors: Map<string, TaskExecutor>
|
|
1174
|
+
/**
|
|
1175
|
+
* `exec.remote: 'only'` tasks that no REMOTE executor took — a NO-OP on
|
|
1176
|
+
* this machine: never executed, declared outputs never cleaned or
|
|
1177
|
+
* restored. The task exists to produce a remote input tree; without a
|
|
1178
|
+
* remote pool, dependents use the machine's ambient state exactly as they
|
|
1179
|
+
* did before the field existed.
|
|
1180
|
+
*/
|
|
1181
|
+
remoteOnlyNoop: Set<string>
|
|
1182
|
+
/** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
|
|
1183
|
+
remoteOnly: Set<string>
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function placeTasks(
|
|
1187
|
+
nodes: Map<string, TaskNode>,
|
|
1188
|
+
executors: readonly TaskExecutor[],
|
|
1189
|
+
pinAllLocal = false,
|
|
1190
|
+
): Placements {
|
|
1191
|
+
const pinned = pinnedLocalSet(nodes)
|
|
1192
|
+
const placements: Placements = {
|
|
1193
|
+
executors: new Map(),
|
|
1194
|
+
remoteOnlyNoop: new Set(),
|
|
1195
|
+
remoteOnly: new Set(),
|
|
1196
|
+
}
|
|
1197
|
+
for (const node of nodes.values()) {
|
|
1198
|
+
if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
|
|
1199
|
+
const executor = selectExecutor(executors, {
|
|
1200
|
+
taskId: node.id,
|
|
1201
|
+
projectName: node.projectName,
|
|
1202
|
+
projectDir: node.projectDir,
|
|
1203
|
+
command: node.config.exec!.command,
|
|
1204
|
+
pinnedLocal: pinAllLocal || pinned.has(node.id),
|
|
1205
|
+
cacheable: node.config.cache !== undefined,
|
|
1206
|
+
...(node.config.exec?.resources === undefined
|
|
1207
|
+
? {}
|
|
1208
|
+
: { resources: node.config.exec.resources }),
|
|
1209
|
+
})
|
|
1210
|
+
placements.executors.set(node.id, executor)
|
|
1211
|
+
if (node.config.exec?.remote === 'only') {
|
|
1212
|
+
// A pinned 'only' task (it transitively depends on a persistent one)
|
|
1213
|
+
// lands here too: pinning wins, so it noops rather than shipping.
|
|
1214
|
+
if (executor.remote === true) placements.remoteOnly.add(node.id)
|
|
1215
|
+
else placements.remoteOnlyNoop.add(node.id)
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
return placements
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* `executorOf` for `planRun`, or nothing. Declining plugins, a single
|
|
1223
|
+
* executor, or a resolution error all yield nothing: `--dry` is an
|
|
1224
|
+
* inspection command and must not fail over a label.
|
|
1225
|
+
*/
|
|
1226
|
+
async function planExecutorOf(
|
|
1227
|
+
prepared: Awaited<ReturnType<typeof prepareRun>>,
|
|
1228
|
+
log: Logger,
|
|
1229
|
+
policy: 'all' | 'toplevel' | 'none',
|
|
1230
|
+
): Promise<{
|
|
1231
|
+
executorOf?: (id: string) => string | undefined
|
|
1232
|
+
downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
|
|
1233
|
+
downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
|
|
1234
|
+
}> {
|
|
1235
|
+
let executors: readonly TaskExecutor[]
|
|
1236
|
+
try {
|
|
1237
|
+
executors = await resolveExecutors(prepared.plugins, {
|
|
1238
|
+
workspaceRoot: prepared.workspaceRoot,
|
|
1239
|
+
cacheDir: prepared.cacheDir,
|
|
1240
|
+
warn: (m: string) => log.status(m),
|
|
1241
|
+
concurrency: Math.max(1, navigator.hardwareConcurrency),
|
|
1242
|
+
})
|
|
1243
|
+
} catch {
|
|
1244
|
+
return {}
|
|
1245
|
+
}
|
|
1246
|
+
const placements = placeTasks(prepared.nodes, executors)
|
|
1247
|
+
// Download modes need placement regardless of how many executors there
|
|
1248
|
+
// are (a single REMOTE one still defers); executor LABELS only earn their
|
|
1249
|
+
// column when there is a choice to report.
|
|
1250
|
+
const download =
|
|
1251
|
+
policy === 'all'
|
|
1252
|
+
? undefined
|
|
1253
|
+
: resolveDownloadModes({
|
|
1254
|
+
nodes: prepared.nodes,
|
|
1255
|
+
policy,
|
|
1256
|
+
localPlaced: new Set(
|
|
1257
|
+
[...prepared.nodes.keys()].filter(
|
|
1258
|
+
(id) => placements.executors.get(id)?.remote !== true,
|
|
1259
|
+
),
|
|
1260
|
+
),
|
|
1261
|
+
remoteOnly: placements.remoteOnly,
|
|
1262
|
+
})
|
|
1263
|
+
return {
|
|
1264
|
+
...(executors.length < 2
|
|
1265
|
+
? {}
|
|
1266
|
+
: {
|
|
1267
|
+
executorOf: (id: string) =>
|
|
1268
|
+
placements.remoteOnlyNoop.has(id) ? 'noop' : placements.executors.get(id)?.name,
|
|
1269
|
+
}),
|
|
1270
|
+
...(download === undefined
|
|
1271
|
+
? {}
|
|
1272
|
+
: {
|
|
1273
|
+
downloadOf: (id: string) => download.modeOf.get(id),
|
|
1274
|
+
downloadDowngrades: [...download.downgrades].map(([taskId, reason]) => ({
|
|
1275
|
+
taskId,
|
|
1276
|
+
reason,
|
|
1277
|
+
})),
|
|
1278
|
+
}),
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Stands in for the executor of a task that was never placed — a group task
|
|
1284
|
+
* (runs nothing) or a persistent one (`executePersistentTask` owns it and
|
|
1285
|
+
* never reads this field). It THROWS rather than silently picking some
|
|
1286
|
+
* executor from the list, so a refactor that routes such a task through the
|
|
1287
|
+
* exec path fails loudly instead of shipping a localhost server to a worker.
|
|
1288
|
+
*/
|
|
1289
|
+
const UNPLACED_EXECUTOR: TaskExecutor = {
|
|
1290
|
+
name: 'unplaced',
|
|
1291
|
+
execute: (req) => {
|
|
1292
|
+
throw new Error(`internal error: ${req.taskId} reached an executor without being placed`)
|
|
1293
|
+
},
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function hasPooledExecutor(executors: readonly TaskExecutor[]): boolean {
|
|
1297
|
+
return executors.some((e) => e.capacity !== undefined)
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function poolOfPlacement(
|
|
1301
|
+
placements: Placements,
|
|
1302
|
+
): (id: string) => { name: string; capacity: number } | undefined {
|
|
1303
|
+
return (id) => {
|
|
1304
|
+
const executor = placements.executors.get(id)
|
|
1305
|
+
return executor?.capacity === undefined
|
|
1306
|
+
? undefined
|
|
1307
|
+
: { name: executor.name, capacity: executor.capacity }
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* A typo's nearest declared name, when one is within two edits — the
|
|
1313
|
+
* message names the fix instead of only the mistake. A bare name is
|
|
1314
|
+
* matched against every declared task; `pkg#task` against the project
|
|
1315
|
+
* names first (the task kept) and then against that project's tasks, so
|
|
1316
|
+
* the hint is a spec the user can run.
|
|
1317
|
+
*/
|
|
1318
|
+
function didYouMean(
|
|
1319
|
+
unresolved: readonly string[],
|
|
1320
|
+
projects: ReadonlyMap<string, ProjectEntry>,
|
|
1321
|
+
): string {
|
|
1322
|
+
const nearest = (name: string, candidates: Iterable<string>): string | undefined => {
|
|
1323
|
+
let best: string | undefined
|
|
1324
|
+
let bestD = 3
|
|
1325
|
+
for (const c of candidates) {
|
|
1326
|
+
const d = editDistance(name, c)
|
|
1327
|
+
if (d < bestD) {
|
|
1328
|
+
bestD = d
|
|
1329
|
+
best = c
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
return best === name ? undefined : best
|
|
1333
|
+
}
|
|
1334
|
+
const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
|
|
1335
|
+
const allTasks = new Set<string>()
|
|
1336
|
+
for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
|
|
1337
|
+
const hints: string[] = []
|
|
1338
|
+
for (const spec of unresolved) {
|
|
1339
|
+
const at = spec.indexOf('#')
|
|
1340
|
+
if (at < 0) {
|
|
1341
|
+
const t = nearest(spec, allTasks)
|
|
1342
|
+
if (t !== undefined) hints.push(t)
|
|
1343
|
+
continue
|
|
1344
|
+
}
|
|
1345
|
+
const [proj, task] = [spec.slice(0, at), spec.slice(at + 1)]
|
|
1346
|
+
if (!projects.has(proj)) {
|
|
1347
|
+
const p = nearest(proj, projects.keys())
|
|
1348
|
+
if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.push(`${p}#${task}`)
|
|
1349
|
+
continue
|
|
1350
|
+
}
|
|
1351
|
+
const t = nearest(task, tasksOf(projects.get(proj)))
|
|
1352
|
+
if (t !== undefined) hints.push(`${proj}#${t}`)
|
|
1353
|
+
}
|
|
1354
|
+
return hints.length === 0 ? '' : ` Did you mean ${hints.join(', ')}?`
|
|
1355
|
+
}
|