@vzn/vx 0.0.0 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +332 -1
- package/launcher.mjs +84 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +29 -0
- package/src/cache/inputs.ts +833 -0
- package/src/cache/layered-cache.ts +335 -0
- package/src/cache/remote-cache.ts +251 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +101 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +123 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +83 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +661 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +421 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +69 -0
- package/src/graph/index.ts +18 -0
- package/src/graph/scheduler.ts +482 -0
- package/src/graph/task-graph.ts +325 -0
- package/src/index.ts +197 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +883 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +163 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1576 -0
- package/src/orchestrator/options.ts +176 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +190 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +297 -0
- package/src/orchestrator/protocol.ts +142 -0
- package/src/orchestrator/remote-cache-setup.ts +60 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +842 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +291 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +238 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +350 -0
- package/src/orchestrator/upstream.ts +93 -0
- package/src/orchestrator/verify.ts +141 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +7 -0
- package/src/util/paths.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +470 -0
- package/src/workspace/workspace.ts +176 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { UserError } from '../util/index.js'
|
|
2
|
+
import type { TaskNode } from './task-graph.js'
|
|
3
|
+
|
|
4
|
+
export type TaskStatus =
|
|
5
|
+
| 'success'
|
|
6
|
+
| 'cache-hit'
|
|
7
|
+
| 'cache-hit-remote'
|
|
8
|
+
| 'failed'
|
|
9
|
+
| 'skipped'
|
|
10
|
+
// A child killed by a shutdown signal (SIGINT/SIGTERM — e.g. Ctrl-C):
|
|
11
|
+
// the task never finished on its own terms, so it reverts to
|
|
12
|
+
// aborted — not counted, not shown (the run is tearing down).
|
|
13
|
+
| 'aborted'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Cache-correctness verdict for a task under `vx run --verify` (Phase 1:
|
|
17
|
+
* determinism; Phase 2: input-completeness). Declared here (structurally)
|
|
18
|
+
* because `graph` can't import `orchestrator` where the verifier lives —
|
|
19
|
+
* same pattern as `inputComponents`. A pure side-channel: never hashed,
|
|
20
|
+
* never persisted in Phase 1/2.
|
|
21
|
+
*/
|
|
22
|
+
export type VerifyVerdict =
|
|
23
|
+
| { kind: 'proven-deterministic' }
|
|
24
|
+
/** Phase 2: input-completeness proved — the task read nothing outside its
|
|
25
|
+
* declared inputs (from `--verify=inputs` when determinism wasn't also
|
|
26
|
+
* requested; an `--verify=all` pass reports the stronger deterministic). */
|
|
27
|
+
| { kind: 'proven-complete' }
|
|
28
|
+
/** Re-ran; outputs differ from the cached ones. `changed` names the rels. */
|
|
29
|
+
| { kind: 'nondeterministic'; changed: readonly string[] }
|
|
30
|
+
/** Phase 2: read a workspace path outside the declared inputs — the declared
|
|
31
|
+
* `cache.inputs` are incomplete, so a hit could serve stale bytes. `paths`
|
|
32
|
+
* names the undeclared reads (workspace-relative; empty when the sandbox
|
|
33
|
+
* denied structurally but strace wasn't available to name them). */
|
|
34
|
+
| { kind: 'undeclared-inputs'; paths: readonly string[] }
|
|
35
|
+
/** Diverged but the task is on `--verify-allow` — reported, not failed. */
|
|
36
|
+
| { kind: 'allowed-nondeterministic'; changed: readonly string[] }
|
|
37
|
+
/** The verify re-run exited non-zero / timed out (nondeterministic by
|
|
38
|
+
* definition — identical inputs, different outcome). */
|
|
39
|
+
| { kind: 'rerun-failed'; exitCode: number }
|
|
40
|
+
/** Cacheable + executed but declares no outputs — nothing to replay. */
|
|
41
|
+
| { kind: 'no-outputs' }
|
|
42
|
+
/** Didn't execute (cache hit) / not cacheable / group / persistent. */
|
|
43
|
+
| { kind: 'not-verified' }
|
|
44
|
+
|
|
45
|
+
export interface TaskOutcome {
|
|
46
|
+
node: TaskNode
|
|
47
|
+
status: TaskStatus
|
|
48
|
+
exitCode: number
|
|
49
|
+
durationMs: number
|
|
50
|
+
/** Cache key hash, if one was computed. Folded into dependents' keys
|
|
51
|
+
* (pure-input transitive — the upstream's input key, not its output). */
|
|
52
|
+
hash?: string
|
|
53
|
+
/** v11 analytics: CPU time + peak RSS for this task's child process. */
|
|
54
|
+
cpuMs?: number
|
|
55
|
+
peakRssBytes?: number
|
|
56
|
+
/**
|
|
57
|
+
* v11 analytics: hrtime span relative to the parent run's t=0.
|
|
58
|
+
* Lets downstream analytics reconstruct the actual parallel timeline
|
|
59
|
+
* (overlapping tasks, idle gaps) rather than just summing durations.
|
|
60
|
+
*/
|
|
61
|
+
wallclockStartNs?: bigint
|
|
62
|
+
wallclockEndNs?: bigint
|
|
63
|
+
/**
|
|
64
|
+
* For cache-hit statuses: true if outputs were actually written to
|
|
65
|
+
* disk this run, false if the on-disk state already matched the
|
|
66
|
+
* cached snapshot (no materialization needed). Lets the formatter
|
|
67
|
+
* surface "up-to-date" vs "local-cache" / "remote-cache" in the
|
|
68
|
+
* framed block. Undefined on non-cache-hit outcomes (success /
|
|
69
|
+
* failed / skipped) — irrelevant there.
|
|
70
|
+
*/
|
|
71
|
+
restored?: boolean
|
|
72
|
+
/**
|
|
73
|
+
* How many times the task executed this run, when `exec.retries` /
|
|
74
|
+
* `--retry` re-ran failed attempts. Set only when > 1 — a plain
|
|
75
|
+
* single-attempt run carries nothing. Not persisted; telemetry-side
|
|
76
|
+
* flaky detection reads it off the outcome stream.
|
|
77
|
+
*/
|
|
78
|
+
attempts?: number
|
|
79
|
+
/**
|
|
80
|
+
* Count of sandbox violations captured during this task's exec.
|
|
81
|
+
* Populated only when `--sandbox` was set and the task is cached.
|
|
82
|
+
* Non-zero values mean the task read files outside its declared
|
|
83
|
+
* inputs; `cache.save()` was skipped so the result can't be replayed.
|
|
84
|
+
*/
|
|
85
|
+
sandboxViolations?: number
|
|
86
|
+
/**
|
|
87
|
+
* Raw violation log lines (one per access denial). Populated alongside
|
|
88
|
+
* `sandboxViolations` so the framed-output renderer can show them
|
|
89
|
+
* inline in the task's block instead of as loose status output.
|
|
90
|
+
*/
|
|
91
|
+
sandboxViolationLines?: string[]
|
|
92
|
+
/**
|
|
93
|
+
* Cache-correctness verdict under `vx run --verify`. Set only in verify
|
|
94
|
+
* mode; a plain run leaves it undefined. Pure side-channel (never hashed).
|
|
95
|
+
*/
|
|
96
|
+
verify?: VerifyVerdict
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type ContinueMode = 'never' | 'deps-ok' | 'always'
|
|
100
|
+
|
|
101
|
+
export interface ScheduleOptions {
|
|
102
|
+
nodes: Map<string, TaskNode>
|
|
103
|
+
concurrency: number
|
|
104
|
+
/**
|
|
105
|
+
* Failure propagation (default 'deps-ok' — the historical behavior):
|
|
106
|
+
* - 'deps-ok': a failed/skipped upstream skips its dependents;
|
|
107
|
+
* independent siblings keep running.
|
|
108
|
+
* - 'never': the first failure stops dispatch — in-flight tasks
|
|
109
|
+
* finish naturally, everything not yet started completes as
|
|
110
|
+
* skipped (restore-tier included: a fail-fast run stops
|
|
111
|
+
* restoring too).
|
|
112
|
+
* - 'always': dependents run even when an upstream failed. Sound
|
|
113
|
+
* under pure-input transitive hashing: failed outcomes carry the
|
|
114
|
+
* upstream's INPUT key (computed before exec), so dependents'
|
|
115
|
+
* keys fold exactly what a healthy run folds. An upstream that
|
|
116
|
+
* died before hashing folds nothing — that key is derivable by
|
|
117
|
+
* no healthy run, so the worst case is an unreachable cache
|
|
118
|
+
* entry, never a stale hit.
|
|
119
|
+
*/
|
|
120
|
+
continueMode?: ContinueMode
|
|
121
|
+
execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
|
|
122
|
+
onStart?: (node: TaskNode) => void
|
|
123
|
+
onFinish?: (outcome: TaskOutcome) => void
|
|
124
|
+
/**
|
|
125
|
+
* Optional priority override: callers pass their own per-node weight
|
|
126
|
+
* (e.g. `computePredictedPriorities` from the orchestrator's history
|
|
127
|
+
* data). The scheduler picks the highest-weight ready task next.
|
|
128
|
+
* When undefined, falls back to the static reverse-deps-count
|
|
129
|
+
* heuristic (the default and historically the only behavior).
|
|
130
|
+
*/
|
|
131
|
+
priorities?: ReadonlyMap<string, number>
|
|
132
|
+
/**
|
|
133
|
+
* Restore-tier task ids: confirmed stable-key LOCAL cache hits (the
|
|
134
|
+
* local short-circuit). A restore-tier task:
|
|
135
|
+
* - becomes READY IMMEDIATELY — its `pending` does NOT gate it, since
|
|
136
|
+
* a stable hit's restore needs none of its deps' output;
|
|
137
|
+
* - is LOW priority — exec-tier work (cache misses + unstable tasks)
|
|
138
|
+
* owns the worker pool, restores only backfill idle capacity (or
|
|
139
|
+
* run when an exec is blocked on a restorable dep and nothing else
|
|
140
|
+
* is runnable — then the restore is the only ready task and unblocks
|
|
141
|
+
* it);
|
|
142
|
+
* - bypasses the `failedDep`→`skipped` check (its key is independent
|
|
143
|
+
* of dep success — pure-input transitive hashing — so a valid cached
|
|
144
|
+
* output is reported `cache-hit` regardless of a dep failing).
|
|
145
|
+
* It still runs through `execute()` (so the logger frame is unchanged);
|
|
146
|
+
* the orchestrator's execute reuses the up-front probe, so there is no
|
|
147
|
+
* second cache.get. When undefined/empty, behavior is byte-identical.
|
|
148
|
+
*/
|
|
149
|
+
restoreTier?: ReadonlySet<string>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Compute, for each task in the graph, how many OTHER tasks are
|
|
154
|
+
* transitively blocked on it. Tasks with the highest count are the
|
|
155
|
+
* most valuable to schedule first — finishing them unlocks the most
|
|
156
|
+
* downstream work and minimizes worker idle time at the end of the
|
|
157
|
+
* run. Matches Nx's `calculateReverseDeps`-driven schedule sort
|
|
158
|
+
* (`packages/nx/src/tasks-runner/tasks-schedule.ts:166-207`).
|
|
159
|
+
*/
|
|
160
|
+
// Exported for the perf guard in tests/scheduler.test.ts, which times
|
|
161
|
+
// this function in isolation — end-to-end runGraph timing drowns the
|
|
162
|
+
// closure cost in task-promise overhead and flakes on slow CI.
|
|
163
|
+
export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string, number> {
|
|
164
|
+
// Exact transitive-dependent COUNTS need closure SETS (diamonds
|
|
165
|
+
// double-count under naive summing). Set-of-strings closures are
|
|
166
|
+
// O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
|
|
167
|
+
// bitsets make the same closure O(E·N/32) time and N²/8 bits of
|
|
168
|
+
// memory (3270 tasks ≈ 1.3 MB) — single-digit ms at that scale.
|
|
169
|
+
const ids = [...nodes.keys()]
|
|
170
|
+
const index = new Map<string, number>()
|
|
171
|
+
for (let i = 0; i < ids.length; i++) index.set(ids[i]!, i)
|
|
172
|
+
const n = ids.length
|
|
173
|
+
const words = (n + 31) >>> 5
|
|
174
|
+
|
|
175
|
+
// Direct dependents as index lists + in-degree for the topo pass.
|
|
176
|
+
const directReverse: number[][] = Array.from({ length: n }, () => [])
|
|
177
|
+
const indegree = new Uint32Array(n)
|
|
178
|
+
for (const node of nodes.values()) {
|
|
179
|
+
const ni = index.get(node.id)!
|
|
180
|
+
for (const dep of node.deps) {
|
|
181
|
+
const di = index.get(dep)
|
|
182
|
+
if (di === undefined) continue
|
|
183
|
+
directReverse[di]!.push(ni)
|
|
184
|
+
indegree[ni]!++
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Kahn topo order over dependency edges (deps before dependents).
|
|
189
|
+
// Insertion order is topo today, but the closure's correctness
|
|
190
|
+
// must not hinge on an unstated property of buildTaskGraph.
|
|
191
|
+
const topo = new Int32Array(n)
|
|
192
|
+
let head = 0
|
|
193
|
+
let tail = 0
|
|
194
|
+
for (let i = 0; i < n; i++) if (indegree[i] === 0) topo[tail++] = i
|
|
195
|
+
while (head < tail) {
|
|
196
|
+
const v = topo[head++]!
|
|
197
|
+
for (const r of directReverse[v]!) {
|
|
198
|
+
if (--indegree[r]! === 0) topo[tail++] = r
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Reverse-topo sweep: every direct dependent's closure is final
|
|
203
|
+
// before its dependency folds it in. closure[i] = bitset over node
|
|
204
|
+
// indices of i's transitive dependents.
|
|
205
|
+
const closure = new Uint32Array(n * words)
|
|
206
|
+
const counts = new Map<string, number>()
|
|
207
|
+
for (let t = tail - 1; t >= 0; t--) {
|
|
208
|
+
const i = topo[t]!
|
|
209
|
+
const base = i * words
|
|
210
|
+
for (const r of directReverse[i]!) {
|
|
211
|
+
closure[base + (r >>> 5)]! |= 1 << (r & 31)
|
|
212
|
+
const rbase = r * words
|
|
213
|
+
for (let w = 0; w < words; w++) closure[base + w]! |= closure[rbase + w]!
|
|
214
|
+
}
|
|
215
|
+
let count = 0
|
|
216
|
+
for (let w = 0; w < words; w++) {
|
|
217
|
+
let v = closure[base + w]!
|
|
218
|
+
v = v - ((v >>> 1) & 0x55555555)
|
|
219
|
+
v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
|
|
220
|
+
count += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24
|
|
221
|
+
}
|
|
222
|
+
counts.set(ids[i]!, count)
|
|
223
|
+
}
|
|
224
|
+
// Cycle-stranded nodes (never topo-visited) can't occur — the graph
|
|
225
|
+
// builder rejects cycles — but a missing Map entry would silently
|
|
226
|
+
// sort as undefined, so default them defensively to 0.
|
|
227
|
+
for (const id of ids) if (!counts.has(id)) counts.set(id, 0)
|
|
228
|
+
return counts
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Run the task graph. Independent tasks run in parallel up to `concurrency`.
|
|
233
|
+
* If a task fails, its dependents are marked `skipped` but unrelated tasks
|
|
234
|
+
* keep running so the user gets maximum information per invocation.
|
|
235
|
+
*
|
|
236
|
+
* Scheduling: when more than one task is ready, the scheduler picks the
|
|
237
|
+
* one that blocks the most downstream work (most transitive reverse
|
|
238
|
+
* dependents). Ties break in graph-insertion order (which is the topo
|
|
239
|
+
* order produced by `buildTaskGraph`). Minimizes worker idle at the
|
|
240
|
+
* end of the run.
|
|
241
|
+
*/
|
|
242
|
+
/**
|
|
243
|
+
* A binary max-heap of ready task ids, ordered by (priority DESC, enqueue-seq
|
|
244
|
+
* ASC) so `pop()` returns the highest-priority task and equal-priority ties
|
|
245
|
+
* break in enqueue order — the exact contract the prior sorted-array kept, but
|
|
246
|
+
* O(log R) push/pop instead of O(R) splice/shift. On a wide ready frontier (the
|
|
247
|
+
* 1000-package startup enqueue, or a fan-out completion), that turns the
|
|
248
|
+
* queue's O(R²) maintenance into O(R log R).
|
|
249
|
+
*/
|
|
250
|
+
class ReadyHeap {
|
|
251
|
+
private readonly ids: string[] = []
|
|
252
|
+
private readonly seq: number[] = []
|
|
253
|
+
private next = 0
|
|
254
|
+
constructor(private readonly priority: ReadonlyMap<string, number>) {}
|
|
255
|
+
get size(): number {
|
|
256
|
+
return this.ids.length
|
|
257
|
+
}
|
|
258
|
+
/** True if slot i outranks slot j (higher priority, or equal priority + earlier seq). */
|
|
259
|
+
private higher(i: number, j: number): boolean {
|
|
260
|
+
const pi = this.priority.get(this.ids[i]!) ?? 0
|
|
261
|
+
const pj = this.priority.get(this.ids[j]!) ?? 0
|
|
262
|
+
return pi !== pj ? pi > pj : this.seq[i]! < this.seq[j]!
|
|
263
|
+
}
|
|
264
|
+
private swap(i: number, j: number): void {
|
|
265
|
+
const ti = this.ids[i]!
|
|
266
|
+
this.ids[i] = this.ids[j]!
|
|
267
|
+
this.ids[j] = ti
|
|
268
|
+
const si = this.seq[i]!
|
|
269
|
+
this.seq[i] = this.seq[j]!
|
|
270
|
+
this.seq[j] = si
|
|
271
|
+
}
|
|
272
|
+
push(id: string): void {
|
|
273
|
+
this.ids.push(id)
|
|
274
|
+
this.seq.push(this.next++)
|
|
275
|
+
let i = this.ids.length - 1
|
|
276
|
+
while (i > 0) {
|
|
277
|
+
const parent = (i - 1) >> 1
|
|
278
|
+
if (!this.higher(i, parent)) break
|
|
279
|
+
this.swap(i, parent)
|
|
280
|
+
i = parent
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
pop(): string | undefined {
|
|
284
|
+
const n = this.ids.length
|
|
285
|
+
if (n === 0) return undefined
|
|
286
|
+
const top = this.ids[0]!
|
|
287
|
+
const lastId = this.ids.pop()!
|
|
288
|
+
const lastSeq = this.seq.pop()!
|
|
289
|
+
if (n > 1) {
|
|
290
|
+
this.ids[0] = lastId
|
|
291
|
+
this.seq[0] = lastSeq
|
|
292
|
+
let i = 0
|
|
293
|
+
for (;;) {
|
|
294
|
+
const l = 2 * i + 1
|
|
295
|
+
const r = 2 * i + 2
|
|
296
|
+
let best = i
|
|
297
|
+
if (l < this.ids.length && this.higher(l, best)) best = l
|
|
298
|
+
if (r < this.ids.length && this.higher(r, best)) best = r
|
|
299
|
+
if (best === i) break
|
|
300
|
+
this.swap(i, best)
|
|
301
|
+
i = best
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return top
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function runGraph(options: ScheduleOptions): Promise<Map<string, TaskOutcome>> {
|
|
309
|
+
const { nodes, concurrency, execute, onStart, onFinish } = options
|
|
310
|
+
const continueMode = options.continueMode ?? 'deps-ok'
|
|
311
|
+
// 'never': set on the first failed outcome; from then on every
|
|
312
|
+
// dequeued task is skipped instead of dispatched (in-flight tasks
|
|
313
|
+
// finish naturally and their dependents drain through the same path).
|
|
314
|
+
let failFastTripped = false
|
|
315
|
+
const outcomes = new Map<string, TaskOutcome>()
|
|
316
|
+
|
|
317
|
+
// Reverse adjacency + pending dep counts. Built once. A task becomes
|
|
318
|
+
// ready when its `pending` hits 0, at which point it's pushed to the
|
|
319
|
+
// ready queue. This replaces the old "scan all of scheduleOrder on
|
|
320
|
+
// every tick" pattern which was O(N²) over a full run.
|
|
321
|
+
const dependents = new Map<string, string[]>()
|
|
322
|
+
const pending = new Map<string, number>()
|
|
323
|
+
for (const node of nodes.values()) {
|
|
324
|
+
pending.set(node.id, node.deps.length)
|
|
325
|
+
for (const dep of node.deps) {
|
|
326
|
+
const list = dependents.get(dep)
|
|
327
|
+
if (list) list.push(node.id)
|
|
328
|
+
else dependents.set(dep, [node.id])
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Override hook (predictive scheduling): when the caller passes a
|
|
333
|
+
// priorities map (e.g. from history-aware critical-path estimation),
|
|
334
|
+
// use those weights. Falls back to the reverse-deps-count heuristic
|
|
335
|
+
// for nodes the caller didn't score, so partial coverage works.
|
|
336
|
+
const baseline = computeReverseDepCount(nodes)
|
|
337
|
+
const priority: ReadonlyMap<string, number> = options.priorities
|
|
338
|
+
? mergePriorities(baseline, options.priorities)
|
|
339
|
+
: baseline
|
|
340
|
+
|
|
341
|
+
// Two ready queues — exec-tier (cache misses + unstable tasks) and
|
|
342
|
+
// restore-tier (confirmed stable local hits). Each is a max-heap keyed by
|
|
343
|
+
// (priority DESC, enqueue-seq ASC), so the highest-priority task pops first
|
|
344
|
+
// and equal-priority ties break in graph-insertion order — same contract the
|
|
345
|
+
// prior `scheduleOrder` sort provided via stable sort. The tick loop drains
|
|
346
|
+
// execReady FIRST, so misses own the worker pool and restores only backfill
|
|
347
|
+
// idle capacity (or run when an exec is blocked on a restorable dep and
|
|
348
|
+
// nothing else is runnable).
|
|
349
|
+
const restoreTier = options.restoreTier
|
|
350
|
+
const execReady = new ReadyHeap(priority)
|
|
351
|
+
const restoreReady = new ReadyHeap(priority)
|
|
352
|
+
// A restore-tier task is dep-independent (a stable hit's restore needs
|
|
353
|
+
// none of its deps' output), so it's ready immediately; its `pending`
|
|
354
|
+
// decrements still happen (in finishOne) but never re-enqueue it.
|
|
355
|
+
// Everything else enqueues on the exec-tier the moment its deps
|
|
356
|
+
// complete.
|
|
357
|
+
for (const node of nodes.values()) {
|
|
358
|
+
if (restoreTier?.has(node.id)) restoreReady.push(node.id)
|
|
359
|
+
else if (node.deps.length === 0) execReady.push(node.id)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let active = 0
|
|
363
|
+
let resolved = false
|
|
364
|
+
|
|
365
|
+
return new Promise<Map<string, TaskOutcome>>((resolve) => {
|
|
366
|
+
const finishOne = (id: string, outcome: TaskOutcome): void => {
|
|
367
|
+
if (continueMode === 'never' && outcome.status === 'failed') failFastTripped = true
|
|
368
|
+
outcomes.set(id, outcome)
|
|
369
|
+
onFinish?.(outcome)
|
|
370
|
+
const ds = dependents.get(id)
|
|
371
|
+
if (!ds) return
|
|
372
|
+
for (const d of ds) {
|
|
373
|
+
const rem = (pending.get(d) ?? 0) - 1
|
|
374
|
+
pending.set(d, rem)
|
|
375
|
+
// Restore-tier dependents were already enqueued at startup (they
|
|
376
|
+
// don't wait on deps); only re-enqueue an exec-tier dependent.
|
|
377
|
+
if (rem === 0 && !restoreTier?.has(d)) execReady.push(d)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Drain a worker slot: prefer an exec-tier task (misses own the
|
|
382
|
+
// pool); only when none is ready does a restore-tier task backfill.
|
|
383
|
+
const takeReady = (): string | undefined =>
|
|
384
|
+
execReady.size > 0 ? execReady.pop() : restoreReady.pop()
|
|
385
|
+
|
|
386
|
+
const tick = (): void => {
|
|
387
|
+
if (resolved) return
|
|
388
|
+
|
|
389
|
+
while (active < concurrency && (execReady.size > 0 || restoreReady.size > 0)) {
|
|
390
|
+
const id = takeReady() as string
|
|
391
|
+
const node = nodes.get(id) as TaskNode
|
|
392
|
+
const isRestore = restoreTier?.has(id) === true
|
|
393
|
+
|
|
394
|
+
// If any upstream failed/skipped, propagate skip synchronously
|
|
395
|
+
// without running. Skipped tasks still flow through this queue
|
|
396
|
+
// because dependents are pushed when `pending` hits 0 regardless
|
|
397
|
+
// of outcome — keeps the propagation logic in one place.
|
|
398
|
+
//
|
|
399
|
+
// Restore-tier tasks BYPASS this check: their key is independent
|
|
400
|
+
// of any dep's success (pure-input transitive hashing), so a
|
|
401
|
+
// valid cached output is reported `cache-hit` even if a dep
|
|
402
|
+
// failed — and they're dep-independent, so they typically
|
|
403
|
+
// restore before a dep could fail anyway.
|
|
404
|
+
//
|
|
405
|
+
// For a restore-tier task running BEFORE its deps finish, the
|
|
406
|
+
// `upstream` entries below can be undefined (the cast lies) —
|
|
407
|
+
// the preProbed hit path in execute-task never reads them, and
|
|
408
|
+
// nothing on the restore path may.
|
|
409
|
+
const upstream = node.deps.map((d) => outcomes.get(d) as TaskOutcome)
|
|
410
|
+
if (failFastTripped) {
|
|
411
|
+
finishOne(id, { node, status: 'skipped', exitCode: 1, durationMs: 0 })
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
if (!isRestore && continueMode !== 'always') {
|
|
415
|
+
const failedDep = upstream.find((u) => u.status === 'failed' || u.status === 'skipped')
|
|
416
|
+
if (failedDep) {
|
|
417
|
+
finishOne(id, { node, status: 'skipped', exitCode: 1, durationMs: 0 })
|
|
418
|
+
continue
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
active++
|
|
423
|
+
onStart?.(node)
|
|
424
|
+
|
|
425
|
+
execute(node, upstream)
|
|
426
|
+
.then((outcome) => {
|
|
427
|
+
active--
|
|
428
|
+
finishOne(id, outcome)
|
|
429
|
+
tick()
|
|
430
|
+
})
|
|
431
|
+
.catch((err: unknown) => {
|
|
432
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
433
|
+
const outcome: TaskOutcome = {
|
|
434
|
+
node,
|
|
435
|
+
status: 'failed',
|
|
436
|
+
exitCode: 1,
|
|
437
|
+
durationMs: 0,
|
|
438
|
+
}
|
|
439
|
+
active--
|
|
440
|
+
finishOne(id, outcome)
|
|
441
|
+
// Surface the error live; the outcome itself doesn't
|
|
442
|
+
// carry captured stderr (that's the logger's job). A
|
|
443
|
+
// UserError is a config/input failure (e.g. a failed
|
|
444
|
+
// `cache.inputs.runtime` command), not a vx bug — report it
|
|
445
|
+
// plainly, never as an "internal error".
|
|
446
|
+
if (err instanceof UserError) {
|
|
447
|
+
process.stderr.write(`[vx] ${id}: ${message}\n`)
|
|
448
|
+
} else {
|
|
449
|
+
const named = err instanceof Error && err.name !== 'Error' ? `${err.name}: ` : ''
|
|
450
|
+
process.stderr.write(`[vx] internal error in ${id}: ${named}${message}\n`)
|
|
451
|
+
}
|
|
452
|
+
tick()
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (outcomes.size === nodes.size && active === 0) {
|
|
457
|
+
resolved = true
|
|
458
|
+
resolve(outcomes)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
tick()
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function mergePriorities(
|
|
467
|
+
baseline: ReadonlyMap<string, number>,
|
|
468
|
+
overrides: ReadonlyMap<string, number>,
|
|
469
|
+
): ReadonlyMap<string, number> {
|
|
470
|
+
// The override caller scored "expected critical-path duration" in ms.
|
|
471
|
+
// Baseline reverse-deps counts are bounded by N; scale the override so
|
|
472
|
+
// it sorts above the baseline for any node it covers, and add the
|
|
473
|
+
// baseline as a tie-break for parity within the override set.
|
|
474
|
+
const SCALE = 1 << 20
|
|
475
|
+
const out = new Map<string, number>()
|
|
476
|
+
for (const [id, w] of baseline) out.set(id, w)
|
|
477
|
+
for (const [id, w] of overrides) {
|
|
478
|
+
const b = baseline.get(id) ?? 0
|
|
479
|
+
out.set(id, w * SCALE + b)
|
|
480
|
+
}
|
|
481
|
+
return out
|
|
482
|
+
}
|