@vzn/vx 0.0.18 → 0.0.21
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 +1 -1
- package/README.md +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/schedule-history/index.ts +0 -109
package/src/graph/scheduler.ts
CHANGED
|
@@ -14,65 +14,6 @@ export type TaskStatus =
|
|
|
14
14
|
// are partial.
|
|
15
15
|
| 'aborted'
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
* Cache-correctness verdict for a task under `vx run --verify` (Phase 1:
|
|
19
|
-
* determinism; Phase 2: input-completeness). Declared here (structurally)
|
|
20
|
-
* because `graph` can't import `orchestrator` where the verifier lives —
|
|
21
|
-
* same pattern as `inputComponents`. A pure side-channel: never hashed,
|
|
22
|
-
* never persisted in Phase 1/2.
|
|
23
|
-
*/
|
|
24
|
-
export type VerifyVerdict =
|
|
25
|
-
| { kind: 'proven-deterministic' }
|
|
26
|
-
/** Phase 2: input-completeness proved — the task read nothing outside its
|
|
27
|
-
* declared inputs (from `--verify=inputs` when determinism wasn't also
|
|
28
|
-
* requested; an `--verify=all` pass reports the stronger deterministic). */
|
|
29
|
-
| { kind: 'proven-complete' }
|
|
30
|
-
/** Re-ran; outputs differ from the cached ones. `changed` names the rels. */
|
|
31
|
-
| { kind: 'nondeterministic'; changed: readonly string[] }
|
|
32
|
-
/** Phase 2: read a workspace path outside the declared inputs — the declared
|
|
33
|
-
* `cache.inputs` are incomplete, so a hit could serve stale bytes. `paths`
|
|
34
|
-
* names the undeclared reads (workspace-relative; empty when the sandbox
|
|
35
|
-
* denied structurally but strace wasn't available to name them). */
|
|
36
|
-
| { kind: 'undeclared-inputs'; paths: readonly string[] }
|
|
37
|
-
/** Diverged but the task is on `--verify-allow` — reported, not failed. */
|
|
38
|
-
| { kind: 'allowed-nondeterministic'; changed: readonly string[] }
|
|
39
|
-
/** The verify re-run exited non-zero / timed out (nondeterministic by
|
|
40
|
-
* definition — identical inputs, different outcome). */
|
|
41
|
-
| { kind: 'rerun-failed'; exitCode: number }
|
|
42
|
-
/** Cacheable + executed but declares no outputs — nothing to replay. */
|
|
43
|
-
| { kind: 'no-outputs' }
|
|
44
|
-
/** Didn't execute (cache hit) / not cacheable / group / persistent. */
|
|
45
|
-
| { kind: 'not-verified' }
|
|
46
|
-
/** A `remote: 'only'` task under `--verify=inputs`: verify pins placement
|
|
47
|
-
* local, so the task no-ops — there is no execution to sandbox, locally
|
|
48
|
-
* or anywhere. Reported, not silent: the proof does not cover it. */
|
|
49
|
-
| { kind: 'unverifiable-remote-only' }
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Content fingerprint of a task's output tree, computed under a `--verify*`
|
|
53
|
-
* mode on the executed (miss) path. Never hashed into any key; pure telemetry
|
|
54
|
-
* side-channel for the cross-machine diff (a serve pairs fingerprints for the
|
|
55
|
-
* SAME cache key across platforms and names diverging outputs). Declared here
|
|
56
|
-
* (structurally) because `graph` can't import `orchestrator` — the
|
|
57
|
-
* `VerifyVerdict` pattern. See docs/design/verify-cross-machine-2026-07.md.
|
|
58
|
-
*/
|
|
59
|
-
export interface OutputFingerprint {
|
|
60
|
-
/** Roll-up: xxh3hex over the sorted (key, hash) pairs, folded as
|
|
61
|
-
* `key \0 hash \n` (\0 boundaries — the v18 lesson). Always present;
|
|
62
|
-
* divergence DETECTION never depends on the per-file map. */
|
|
63
|
-
tree: string
|
|
64
|
-
/** Total files in the tree (pre-truncation). */
|
|
65
|
-
fileCount: number
|
|
66
|
-
/** Per-file map as sorted [outputKey, xxh3hex] pairs, capped at
|
|
67
|
-
* FP_MAX_FILES (500). Deterministic truncation — sorted by key,
|
|
68
|
-
* first N — so two machines' truncated maps cover the same subset
|
|
69
|
-
* and partial diffs still name real rels. */
|
|
70
|
-
files?: ReadonlyArray<readonly [string, string]>
|
|
71
|
-
/** Set when `files` was truncated to the cap (or dropped by the
|
|
72
|
-
* sink's run budget). */
|
|
73
|
-
truncated?: boolean
|
|
74
|
-
}
|
|
75
|
-
|
|
76
17
|
export interface TaskOutcome {
|
|
77
18
|
node: TaskNode
|
|
78
19
|
status: TaskStatus
|
|
@@ -90,6 +31,22 @@ export interface TaskOutcome {
|
|
|
90
31
|
* Undefined on every non-hit outcome — nothing was skipped.
|
|
91
32
|
*/
|
|
92
33
|
storedDurationMs?: number
|
|
34
|
+
/**
|
|
35
|
+
* For cache-hit statuses: what the PRODUCING execution used, read from
|
|
36
|
+
* the entry (it rides the artifact, so a remote hit on a fresh machine
|
|
37
|
+
* carries it too). The same split as `storedDurationMs`: `cpuMs` /
|
|
38
|
+
* `peakRssBytes` are what THIS run spent and stay absent on a hit.
|
|
39
|
+
*/
|
|
40
|
+
storedCpuMs?: number
|
|
41
|
+
storedPeakRssBytes?: number
|
|
42
|
+
/**
|
|
43
|
+
* How long an `admit` policy held this task after it was ready with a
|
|
44
|
+
* free worker — the wait the plugin, not the count gate, imposed. Set
|
|
45
|
+
* only when a policy refused it at least once (so a run with no policy
|
|
46
|
+
* carries the field on no outcome); `--summarize` rows, the event
|
|
47
|
+
* stream and the summary footer show it.
|
|
48
|
+
*/
|
|
49
|
+
admissionHeldMs?: number
|
|
93
50
|
/** v11 analytics: CPU time + peak RSS for this task's child process. */
|
|
94
51
|
cpuMs?: number
|
|
95
52
|
peakRssBytes?: number
|
|
@@ -134,10 +91,10 @@ export interface TaskOutcome {
|
|
|
134
91
|
*/
|
|
135
92
|
attempts?: number
|
|
136
93
|
/**
|
|
137
|
-
* Count of sandbox violations captured during this task's exec
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
94
|
+
* Count of sandbox violations captured during this task's exec (a task
|
|
95
|
+
* declaring `exec.sandbox`). Non-zero means the task touched something
|
|
96
|
+
* it did not declare; execute-task fails the task on any violation, so
|
|
97
|
+
* nothing built that way is ever saved or replayed.
|
|
141
98
|
*/
|
|
142
99
|
sandboxViolations?: number
|
|
143
100
|
/**
|
|
@@ -146,38 +103,19 @@ export interface TaskOutcome {
|
|
|
146
103
|
* inline in the task's block instead of as loose status output.
|
|
147
104
|
*/
|
|
148
105
|
sandboxViolationLines?: string[]
|
|
149
|
-
/**
|
|
150
|
-
* Cache-correctness verdict under `vx run --verify`. Set only in verify
|
|
151
|
-
* mode; a plain run leaves it undefined. Pure side-channel (never hashed).
|
|
152
|
-
*/
|
|
153
|
-
verify?: VerifyVerdict
|
|
154
|
-
/**
|
|
155
|
-
* Output-tree fingerprint under a fingerprinting `--verify*` mode
|
|
156
|
-
* (`--verify` / `=all` / `=fingerprint`), executed + cacheable +
|
|
157
|
-
* output-declaring tasks only. A plain run leaves it undefined.
|
|
158
|
-
*/
|
|
159
|
-
outputFp?: OutputFingerprint
|
|
160
106
|
}
|
|
161
107
|
|
|
162
108
|
export type ContinueMode = 'never' | 'deps-ok' | 'always'
|
|
163
109
|
|
|
164
|
-
/**
|
|
165
|
-
* Resolved per-task resource reservation, in absolute units (cpu may be
|
|
166
|
-
* fractional; mem is bytes). Declared here (structurally) because `graph`
|
|
167
|
-
* can't import `orchestrator`, where the resolver lives — same pattern as
|
|
168
|
-
* `VerifyVerdict`. A `0` axis means "reserve nothing, run freely": the
|
|
169
|
-
* task is exempt from that axis entirely (needs no headroom, holds none).
|
|
170
|
-
*/
|
|
171
|
-
export interface ResourceCost {
|
|
172
|
-
cpu: number
|
|
173
|
-
mem: number
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const ZERO_COST: ResourceCost = { cpu: 0, mem: 0 }
|
|
177
|
-
|
|
178
110
|
export interface ScheduleOptions {
|
|
179
111
|
nodes: Map<string, TaskNode>
|
|
180
112
|
concurrency: number
|
|
113
|
+
/**
|
|
114
|
+
* An aborted run dispatches nothing further: every task not yet
|
|
115
|
+
* started completes `aborted` (the ones in flight are being killed by
|
|
116
|
+
* the orchestrator and report `aborted` through the runner's signal).
|
|
117
|
+
*/
|
|
118
|
+
signal?: AbortSignal
|
|
181
119
|
/**
|
|
182
120
|
* Failure propagation (default 'deps-ok' — the historical behavior):
|
|
183
121
|
* - 'deps-ok': a failed/skipped/aborted upstream skips its
|
|
@@ -186,18 +124,25 @@ export interface ScheduleOptions {
|
|
|
186
124
|
* finish naturally, everything not yet started completes as
|
|
187
125
|
* skipped (restore-tier included: a fail-fast run stops
|
|
188
126
|
* restoring too).
|
|
189
|
-
* - 'always': dependents run even when an upstream failed.
|
|
190
|
-
*
|
|
191
|
-
* upstream's INPUT key
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* entry, never a stale hit.
|
|
127
|
+
* - 'always': dependents run even when an upstream failed. Their
|
|
128
|
+
* keys are the ones a healthy run derives (failed outcomes carry
|
|
129
|
+
* the upstream's INPUT key, computed before exec), which is
|
|
130
|
+
* exactly why the orchestrator withholds their SAVE: the bytes
|
|
131
|
+
* were built on a partial tree, and a healthy key over them would
|
|
132
|
+
* be the next clean run's stale hit (`ExecuteArgs.taintedUpstream`).
|
|
196
133
|
*/
|
|
197
134
|
continueMode?: ContinueMode
|
|
198
135
|
execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
|
|
199
136
|
onStart?: (node: TaskNode) => void
|
|
200
137
|
onFinish?: (outcome: TaskOutcome) => void
|
|
138
|
+
/**
|
|
139
|
+
* What an outcome still owes before its dependents may start — a cache
|
|
140
|
+
* save that runs off the execution slot (the orchestrator's save lane):
|
|
141
|
+
* a dependent reads the upstream's entry (its output rows travel in the
|
|
142
|
+
* execute request), so it waits for the save to land, while the freed
|
|
143
|
+
* slot admits other work at once. Undefined: nothing owed.
|
|
144
|
+
*/
|
|
145
|
+
settledOf?: (outcome: TaskOutcome) => Promise<void> | undefined
|
|
201
146
|
/**
|
|
202
147
|
* Optional priority override: callers pass their own per-node weight
|
|
203
148
|
* (e.g. `computePredictedPriorities` from the orchestrator's history
|
|
@@ -233,17 +178,14 @@ export interface ScheduleOptions {
|
|
|
233
178
|
*/
|
|
234
179
|
poolOf?: (id: string) => { name: string; capacity: number } | undefined
|
|
235
180
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
181
|
+
* Admission over the worker count: asked for every exec-tier task about
|
|
182
|
+
* to start on this machine, with the ids of the exec-tier tasks running
|
|
183
|
+
* here right now (added on dispatch, so two asks in one tick see each
|
|
184
|
+
* other). `false` parks the task until something finishes. Undefined →
|
|
185
|
+
* count-only, the legacy path byte for byte. Restore-tier hits and
|
|
186
|
+
* pooled tasks hold no local resources and are never asked.
|
|
241
187
|
*/
|
|
242
|
-
|
|
243
|
-
/** CPU budget reservations pack against. Defaults to `concurrency`. */
|
|
244
|
-
cpuBudget?: number
|
|
245
|
-
/** Memory budget (bytes). Defaults to Infinity (axis off). */
|
|
246
|
-
memBudget?: number
|
|
188
|
+
admit?: (id: string, running: ReadonlySet<string>) => boolean
|
|
247
189
|
}
|
|
248
190
|
|
|
249
191
|
/**
|
|
@@ -466,33 +408,26 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
466
408
|
}
|
|
467
409
|
|
|
468
410
|
let active = 0
|
|
411
|
+
// Restore-tier work is disk I/O — a confirmed hit's tar extract is ~8
|
|
412
|
+
// filesystem round trips and no CPU — so it gets its own lane, twice
|
|
413
|
+
// the exec cap: on the 1,000-project bench with every task a restore,
|
|
414
|
+
// 4 → 8 workers cut the run-graph stage 683–754 → 556–595 ms and 16 was
|
|
415
|
+
// no better (2026-09-10). Exec-tier work keeps the CPU-shaped cap; the
|
|
416
|
+
// two lanes never wait on each other. `--concurrency 1` stays serial.
|
|
417
|
+
let activeRestore = 0
|
|
418
|
+
const restoreConcurrency = concurrency === 1 ? 1 : 2 * concurrency
|
|
469
419
|
let resolved = false
|
|
470
420
|
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
const
|
|
475
|
-
const
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
// guards keep that residue from corrupting admission:
|
|
482
|
-
// - the solo-clamp gate ("is the axis idle?") reads INTEGER holder
|
|
483
|
-
// counts, never the float sum === 0 — a residue would otherwise
|
|
484
|
-
// park an over-budget task forever (active===0, no future tick =
|
|
485
|
-
// a silent hang / exit-0-without-running);
|
|
486
|
-
// - `reserved` snaps back to EXACT 0 whenever its holder count hits
|
|
487
|
-
// 0, so residue can't accumulate across busy periods.
|
|
488
|
-
// The within-budget comparison also carries a tiny relative epsilon so
|
|
489
|
-
// an exact-fill (`reserved + cost == budget`) can't mis-round into a
|
|
490
|
-
// spurious block. Admission is a hint, so the epsilon's sub-ulp
|
|
491
|
-
// over-admission is harmless.
|
|
492
|
-
let reservedCpu = 0
|
|
493
|
-
let reservedMem = 0
|
|
494
|
-
let holdersCpu = 0
|
|
495
|
-
let holdersMem = 0
|
|
421
|
+
// Admission policy over the count limit (a plugin's `admit`). Inactive
|
|
422
|
+
// → the tick loop short-circuits before any of this and behaves
|
|
423
|
+
// byte-identically to the count-only scheduler.
|
|
424
|
+
const admitPolicy = options.admit
|
|
425
|
+
const admitActive = admitPolicy !== undefined
|
|
426
|
+
const running = new Set<string>()
|
|
427
|
+
// When a policy first refused each parked task — so its outcome can say
|
|
428
|
+
// how long the policy, not the count gate, held it. Nothing with no
|
|
429
|
+
// policy: the map is never touched.
|
|
430
|
+
const heldSince = new Map<string, number>()
|
|
496
431
|
|
|
497
432
|
// A restore-tier task is a confirmed local cache hit: its "execution"
|
|
498
433
|
// is a cheap tar extract, not the task's real work — it reserves ZERO
|
|
@@ -504,12 +439,22 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
504
439
|
// against `concurrency`; pooled tasks reserve no local resources.
|
|
505
440
|
const hasRoom = (id: string): boolean => {
|
|
506
441
|
const pool = poolOf?.(id)
|
|
507
|
-
if (pool === undefined)
|
|
442
|
+
if (pool === undefined) {
|
|
443
|
+
return options.restoreTier?.has(id)
|
|
444
|
+
? activeRestore < restoreConcurrency
|
|
445
|
+
: active < concurrency
|
|
446
|
+
}
|
|
508
447
|
return (poolActive.get(pool.name) ?? 0) < pool.capacity
|
|
509
448
|
}
|
|
510
449
|
const admit = (id: string): (() => void) => {
|
|
511
450
|
const pool = poolOf?.(id)
|
|
512
451
|
if (pool === undefined) {
|
|
452
|
+
if (options.restoreTier?.has(id)) {
|
|
453
|
+
activeRestore++
|
|
454
|
+
return () => {
|
|
455
|
+
activeRestore--
|
|
456
|
+
}
|
|
457
|
+
}
|
|
513
458
|
active++
|
|
514
459
|
return () => {
|
|
515
460
|
active--
|
|
@@ -520,44 +465,20 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
520
465
|
poolActive.set(pool.name, (poolActive.get(pool.name) ?? 1) - 1)
|
|
521
466
|
}
|
|
522
467
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
//
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
const fits = (id: string): boolean => {
|
|
536
|
-
const c = costOf(id)
|
|
537
|
-
return (
|
|
538
|
-
fitsAxis(c.cpu, reservedCpu, holdersCpu, cpuBudget) &&
|
|
539
|
-
fitsAxis(c.mem, reservedMem, holdersMem, memBudget)
|
|
540
|
-
)
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// Reserve/release keep the float sum AND the integer holder count in
|
|
544
|
-
// lockstep; releasing the last holder on an axis snaps its sum to 0.
|
|
545
|
-
const reserve = (c: ResourceCost): void => {
|
|
546
|
-
if (c.cpu > 0) {
|
|
547
|
-
reservedCpu += c.cpu
|
|
548
|
-
holdersCpu++
|
|
549
|
-
}
|
|
550
|
-
if (c.mem > 0) {
|
|
551
|
-
reservedMem += c.mem
|
|
552
|
-
holdersMem++
|
|
468
|
+
// The policy sees exec-tier local tasks only; a restore is a tar
|
|
469
|
+
// extract and a pooled task runs on someone else's capacity.
|
|
470
|
+
const local = (id: string): boolean => !options.restoreTier?.has(id) && poolOf?.(id) === undefined
|
|
471
|
+
const admits = (id: string): boolean => !local(id) || admitPolicy!(id, running)
|
|
472
|
+
// With no policy nothing reads `running`, so nothing is tracked: the
|
|
473
|
+
// count-only dispatch allocates no closure and touches no set per task.
|
|
474
|
+
const untracked = (): void => {}
|
|
475
|
+
const track = (id: string): (() => void) => {
|
|
476
|
+
if (!admitActive || !local(id)) return untracked
|
|
477
|
+
running.add(id)
|
|
478
|
+
return () => {
|
|
479
|
+
running.delete(id)
|
|
553
480
|
}
|
|
554
481
|
}
|
|
555
|
-
const release = (c: ResourceCost): void => {
|
|
556
|
-
if (c.cpu > 0 && --holdersCpu === 0) reservedCpu = 0
|
|
557
|
-
else reservedCpu -= c.cpu
|
|
558
|
-
if (c.mem > 0 && --holdersMem === 0) reservedMem = 0
|
|
559
|
-
else reservedMem -= c.mem
|
|
560
|
-
}
|
|
561
482
|
|
|
562
483
|
return new Promise<Map<string, TaskOutcome>>((resolve) => {
|
|
563
484
|
const finishOne = (id: string, outcome: TaskOutcome): void => {
|
|
@@ -598,8 +519,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
598
519
|
// cached output is reported `cache-hit` even if a dep failed — and
|
|
599
520
|
// they're dep-independent, so they typically restore before a dep
|
|
600
521
|
// could fail anyway.
|
|
522
|
+
const aborted = (): boolean => options.signal?.aborted === true
|
|
601
523
|
const willSkip = (id: string): boolean => {
|
|
602
|
-
if (failFastTripped) return true
|
|
524
|
+
if (failFastTripped || aborted()) return true
|
|
603
525
|
if (restoreTier?.has(id)) return false
|
|
604
526
|
if (continueMode === 'always') return false
|
|
605
527
|
const node = nodes.get(id) as TaskNode
|
|
@@ -617,31 +539,43 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
617
539
|
|
|
618
540
|
const tick = (): void => {
|
|
619
541
|
if (resolved) return
|
|
620
|
-
// Exec-tier tasks parked THIS tick on a
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
542
|
+
// Exec-tier tasks parked THIS tick on a refused admission. Within
|
|
543
|
+
// one synchronous tick `running` only grows (tasks leave it in the
|
|
544
|
+
// async completion callbacks, which run a fresh tick), so a task
|
|
545
|
+
// refused now cannot be admitted later in the same tick — parking
|
|
546
|
+
// it for the tick's remainder is exact, and each id pops at most
|
|
547
|
+
// once per tick (O(R log R)).
|
|
626
548
|
const parked: Array<[string, number]> = []
|
|
627
549
|
|
|
628
550
|
// Highest-priority admissible task: exec tier first (misses own
|
|
629
|
-
// the pool), then restore tier. With no
|
|
551
|
+
// the pool), then restore tier. With no admission policy this
|
|
630
552
|
// short-circuits to exactly the legacy takeReady. A would-skip
|
|
631
|
-
// task returns without
|
|
632
|
-
// tasks
|
|
553
|
+
// task returns without asking (finishing it is free); restore
|
|
554
|
+
// tasks are never asked, so they never park.
|
|
633
555
|
const takeFitting = (): string | undefined => {
|
|
634
|
-
// No pools
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
556
|
+
// No pools, no policy and a full exec lane: nothing on the
|
|
557
|
+
// exec queue can be admitted, so it is not scanned — scanning it
|
|
558
|
+
// would pop and re-park every ready exec task on every tick, and
|
|
559
|
+
// with a wide frontier that is O(R) per tick, O(R²) per run (the
|
|
560
|
+
// 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
|
|
561
|
+
// first cut let the scan run past a full exec lane, 2026-09-10).
|
|
562
|
+
// This is the legacy O(1) gate, kept per lane.
|
|
563
|
+
const execAdmissible = poolOf !== undefined || admitActive || active < concurrency
|
|
564
|
+
if (execAdmissible) {
|
|
565
|
+
while (execReady.size > 0) {
|
|
566
|
+
const seq = execReady.peekSeq()
|
|
567
|
+
const id = execReady.pop() as string
|
|
568
|
+
if (willSkip(id)) return id
|
|
569
|
+
if (hasRoom(id)) {
|
|
570
|
+
if (!admitActive || admits(id)) return id
|
|
571
|
+
// A free worker, refused by the policy: the hold starts now.
|
|
572
|
+
if (!heldSince.has(id)) heldSince.set(id, Date.now())
|
|
573
|
+
}
|
|
574
|
+
parked.push([id, seq])
|
|
575
|
+
}
|
|
642
576
|
}
|
|
643
|
-
// Restore-tier tasks are local work
|
|
644
|
-
return
|
|
577
|
+
// Restore-tier tasks are local disk work, on their own lane.
|
|
578
|
+
return activeRestore < restoreConcurrency ? restoreReady.pop() : undefined
|
|
645
579
|
}
|
|
646
580
|
|
|
647
581
|
for (;;) {
|
|
@@ -660,17 +594,26 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
660
594
|
// because dependents are pushed when `pending` hits 0 regardless
|
|
661
595
|
// of outcome — keeps the propagation logic in one place.
|
|
662
596
|
if (willSkip(id)) {
|
|
663
|
-
finishOne(id, {
|
|
597
|
+
finishOne(id, {
|
|
598
|
+
node,
|
|
599
|
+
status: aborted() ? 'aborted' : 'skipped',
|
|
600
|
+
exitCode: 1,
|
|
601
|
+
durationMs: 0,
|
|
602
|
+
})
|
|
664
603
|
continue
|
|
665
604
|
}
|
|
666
605
|
|
|
667
606
|
const leave = admit(id)
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
const
|
|
671
|
-
|
|
607
|
+
// Listed as running on dispatch, so the policy's next ask in this
|
|
608
|
+
// tick sees it; the completion callbacks unlist it.
|
|
609
|
+
const untrack = track(id)
|
|
610
|
+
const since = admitActive ? heldSince.get(id) : undefined
|
|
611
|
+
const heldMs = since === undefined ? 0 : Math.max(1, Date.now() - since)
|
|
612
|
+
if (since !== undefined) heldSince.delete(id)
|
|
613
|
+
const withHold = (o: TaskOutcome): TaskOutcome =>
|
|
614
|
+
heldMs > 0 ? { ...o, admissionHeldMs: heldMs } : o
|
|
672
615
|
// Crash-isolated observer hook — a throwing onStart must not abort
|
|
673
|
-
// the dispatch loop (it would strand the tick with
|
|
616
|
+
// the dispatch loop (it would strand the tick with the slot held).
|
|
674
617
|
try {
|
|
675
618
|
onStart?.(node)
|
|
676
619
|
} catch (err) {
|
|
@@ -681,26 +624,38 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
681
624
|
// `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
|
|
682
625
|
// rejection arm handles ONLY `execute()` rejecting; a throw from
|
|
683
626
|
// the fulfillment arm (finishOne / onFinish / tick) must NOT also
|
|
684
|
-
// run the rejection arm, or `active
|
|
685
|
-
//
|
|
686
|
-
// wedging the solo-clamp gate.
|
|
627
|
+
// run the rejection arm, or `active` releases twice — and a
|
|
628
|
+
// double release wedges the count gate.
|
|
687
629
|
execute(node, upstream).then(
|
|
688
|
-
(
|
|
630
|
+
(raw) => {
|
|
631
|
+
const outcome = withHold(raw)
|
|
689
632
|
leave()
|
|
690
|
-
|
|
691
|
-
|
|
633
|
+
untrack()
|
|
634
|
+
// The slot is free now; the dependents wait for what the
|
|
635
|
+
// outcome still owes (a save landing), if anything.
|
|
636
|
+
const settled = options.settledOf?.(outcome)
|
|
637
|
+
if (settled === undefined) {
|
|
638
|
+
finishOne(id, outcome)
|
|
639
|
+
tick()
|
|
640
|
+
return
|
|
641
|
+
}
|
|
692
642
|
tick()
|
|
643
|
+
const done = (): void => {
|
|
644
|
+
finishOne(id, outcome)
|
|
645
|
+
tick()
|
|
646
|
+
}
|
|
647
|
+
void settled.then(done, done)
|
|
693
648
|
},
|
|
694
649
|
(err: unknown) => {
|
|
695
650
|
const message = err instanceof Error ? err.message : String(err)
|
|
696
|
-
const outcome: TaskOutcome = {
|
|
651
|
+
const outcome: TaskOutcome = withHold({
|
|
697
652
|
node,
|
|
698
653
|
status: 'failed',
|
|
699
654
|
exitCode: 1,
|
|
700
655
|
durationMs: 0,
|
|
701
|
-
}
|
|
656
|
+
})
|
|
702
657
|
leave()
|
|
703
|
-
|
|
658
|
+
untrack()
|
|
704
659
|
finishOne(id, outcome)
|
|
705
660
|
// Surface the error live; the outcome itself doesn't
|
|
706
661
|
// carry captured stderr (that's the logger's job). A
|
|
@@ -722,7 +677,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
722
677
|
// is exactly preserved for the next tick's admission pass.
|
|
723
678
|
for (const [id, seq] of parked) execReady.push(id, seq)
|
|
724
679
|
|
|
725
|
-
if (outcomes.size === nodes.size && active === 0) {
|
|
680
|
+
if (outcomes.size === nodes.size && active === 0 && activeRestore === 0) {
|
|
726
681
|
resolved = true
|
|
727
682
|
resolve(outcomes)
|
|
728
683
|
}
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -289,7 +289,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
289
289
|
`Task ${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
|
|
290
290
|
)
|
|
291
291
|
}
|
|
292
|
-
// CLI `--
|
|
292
|
+
// CLI `--exclude-dependencies=name1,name2` drops edges whose target
|
|
293
293
|
// task name matches, regardless of bucket (self / deps / cross).
|
|
294
294
|
// Pattern specs re-apply the filter per EXPANDED name below.
|
|
295
295
|
if (skipNames?.has(spec.task)) continue
|
|
@@ -331,7 +331,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
331
331
|
// With a pattern (`^build.*`), a holder is a package declaring
|
|
332
332
|
// AT LEAST ONE matching task and it receives edges to ALL its
|
|
333
333
|
// matches — holder-ness is about declaration, so a holder still
|
|
334
|
-
// stops the walk even when every match is --
|
|
334
|
+
// stops the walk even when every match is --exclude-dependencies'd.
|
|
335
335
|
//
|
|
336
336
|
// The declaring project seeds `visited`: package graphs may legally
|
|
337
337
|
// contain cycles (the common "b devDepends on a for its tests"
|