@vzn/vx 0.0.18 → 0.0.22
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 +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- 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-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- 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/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
package/src/graph/scheduler.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
import { isUserError } from '../util/index.js'
|
|
1
|
+
import { fsRefusalHint, isFsRefusal, isUserError } from '../util/index.js'
|
|
2
|
+
import { computeReverseDepCount, mergePriorities, tieredReverseDepCount } from './priorities.js'
|
|
2
3
|
import type { TaskNode } from './task-graph.js'
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Thrown by `execute` for a restore-tier task that turned out to have
|
|
7
|
+
* nothing to restore (its artifact vanished after the up-front probe). It
|
|
8
|
+
* may be running ahead of its deps, so it cannot run its command in that
|
|
9
|
+
* slot: the scheduler releases the slot and dispatches it again as an
|
|
10
|
+
* exec-tier task once its deps are done. It records no outcome, and its
|
|
11
|
+
* dependents wait for the second dispatch.
|
|
12
|
+
*/
|
|
13
|
+
export class RestoreDemoted extends Error {
|
|
14
|
+
constructor(readonly taskId: string) {
|
|
15
|
+
super(`${taskId}: restore demoted to a run`)
|
|
16
|
+
this.name = 'RestoreDemoted'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
4
20
|
export type TaskStatus =
|
|
5
21
|
| 'success'
|
|
6
22
|
| 'cache-hit'
|
|
@@ -14,65 +30,6 @@ export type TaskStatus =
|
|
|
14
30
|
// are partial.
|
|
15
31
|
| 'aborted'
|
|
16
32
|
|
|
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
33
|
export interface TaskOutcome {
|
|
77
34
|
node: TaskNode
|
|
78
35
|
status: TaskStatus
|
|
@@ -90,6 +47,22 @@ export interface TaskOutcome {
|
|
|
90
47
|
* Undefined on every non-hit outcome — nothing was skipped.
|
|
91
48
|
*/
|
|
92
49
|
storedDurationMs?: number
|
|
50
|
+
/**
|
|
51
|
+
* For cache-hit statuses: what the PRODUCING execution used, read from
|
|
52
|
+
* the entry (it rides the artifact, so a remote hit on a fresh machine
|
|
53
|
+
* carries it too). The same split as `storedDurationMs`: `cpuMs` /
|
|
54
|
+
* `peakRssBytes` are what THIS run spent and stay absent on a hit.
|
|
55
|
+
*/
|
|
56
|
+
storedCpuMs?: number
|
|
57
|
+
storedPeakRssBytes?: number
|
|
58
|
+
/**
|
|
59
|
+
* How long an `admit` policy held this task after it was ready with a
|
|
60
|
+
* free worker — the wait the plugin, not the count gate, imposed. Set
|
|
61
|
+
* only when a policy refused it at least once (so a run with no policy
|
|
62
|
+
* carries the field on no outcome); `--summarize` rows, the event
|
|
63
|
+
* stream and the summary footer show it.
|
|
64
|
+
*/
|
|
65
|
+
admissionHeldMs?: number
|
|
93
66
|
/** v11 analytics: CPU time + peak RSS for this task's child process. */
|
|
94
67
|
cpuMs?: number
|
|
95
68
|
peakRssBytes?: number
|
|
@@ -103,6 +76,26 @@ export interface TaskOutcome {
|
|
|
103
76
|
* outcomes only; never folded into any key.
|
|
104
77
|
*/
|
|
105
78
|
groupUpstream?: readonly TaskOutcome[]
|
|
79
|
+
/**
|
|
80
|
+
* On a `skipped` outcome: the task at the ROOT of what blocked it — the
|
|
81
|
+
* failed (or aborted) upstream, followed through any chain of skips
|
|
82
|
+
* between. Absent when the skip was fail-fast's (no upstream failed).
|
|
83
|
+
* The footer, `--summarize`, the telemetry record and the run row all
|
|
84
|
+
* read this one field (item 267).
|
|
85
|
+
*/
|
|
86
|
+
blockedBy?: string
|
|
87
|
+
/**
|
|
88
|
+
* On a `failed` outcome: vx's own `timeout` killed the final attempt.
|
|
89
|
+
* The exit is the shell's 143, and every label says "timed out" instead
|
|
90
|
+
* of reading it as a signal (item 268).
|
|
91
|
+
*/
|
|
92
|
+
timedOut?: true
|
|
93
|
+
/**
|
|
94
|
+
* On a failed persistent task: why it never became ready — the readiness
|
|
95
|
+
* deadline fired, the child exited first (`exitCode` is then its own), or
|
|
96
|
+
* the spawn itself failed. Every label reads this instead of the exit.
|
|
97
|
+
*/
|
|
98
|
+
notReady?: 'timeout' | 'exited' | 'spawn'
|
|
106
99
|
/** Executor-reported placement label (`ExecuteResult.where`) — set only
|
|
107
100
|
* when the task ran somewhere other than this host. Telemetry-only. */
|
|
108
101
|
where?: string
|
|
@@ -134,10 +127,10 @@ export interface TaskOutcome {
|
|
|
134
127
|
*/
|
|
135
128
|
attempts?: number
|
|
136
129
|
/**
|
|
137
|
-
* Count of sandbox violations captured during this task's exec
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
130
|
+
* Count of sandbox violations captured during this task's exec (a task
|
|
131
|
+
* declaring `exec.sandbox`). Non-zero means the task touched something
|
|
132
|
+
* it did not declare; execute-task fails the task on any violation, so
|
|
133
|
+
* nothing built that way is ever saved or replayed.
|
|
141
134
|
*/
|
|
142
135
|
sandboxViolations?: number
|
|
143
136
|
/**
|
|
@@ -146,38 +139,19 @@ export interface TaskOutcome {
|
|
|
146
139
|
* inline in the task's block instead of as loose status output.
|
|
147
140
|
*/
|
|
148
141
|
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
142
|
}
|
|
161
143
|
|
|
162
144
|
export type ContinueMode = 'never' | 'deps-ok' | 'always'
|
|
163
145
|
|
|
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
146
|
export interface ScheduleOptions {
|
|
179
147
|
nodes: Map<string, TaskNode>
|
|
180
148
|
concurrency: number
|
|
149
|
+
/**
|
|
150
|
+
* An aborted run dispatches nothing further: every task not yet
|
|
151
|
+
* started completes `aborted` (the ones in flight are being killed by
|
|
152
|
+
* the orchestrator and report `aborted` through the runner's signal).
|
|
153
|
+
*/
|
|
154
|
+
signal?: AbortSignal
|
|
181
155
|
/**
|
|
182
156
|
* Failure propagation (default 'deps-ok' — the historical behavior):
|
|
183
157
|
* - 'deps-ok': a failed/skipped/aborted upstream skips its
|
|
@@ -186,18 +160,25 @@ export interface ScheduleOptions {
|
|
|
186
160
|
* finish naturally, everything not yet started completes as
|
|
187
161
|
* skipped (restore-tier included: a fail-fast run stops
|
|
188
162
|
* 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.
|
|
163
|
+
* - 'always': dependents run even when an upstream failed. Their
|
|
164
|
+
* keys are the ones a healthy run derives (failed outcomes carry
|
|
165
|
+
* the upstream's INPUT key, computed before exec), which is
|
|
166
|
+
* exactly why the orchestrator withholds their SAVE: the bytes
|
|
167
|
+
* were built on a partial tree, and a healthy key over them would
|
|
168
|
+
* be the next clean run's stale hit (`ExecuteArgs.taintedUpstream`).
|
|
196
169
|
*/
|
|
197
170
|
continueMode?: ContinueMode
|
|
198
171
|
execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
|
|
199
172
|
onStart?: (node: TaskNode) => void
|
|
200
173
|
onFinish?: (outcome: TaskOutcome) => void
|
|
174
|
+
/**
|
|
175
|
+
* What an outcome still owes before its dependents may start — a cache
|
|
176
|
+
* save that runs off the execution slot (the orchestrator's save lane):
|
|
177
|
+
* a dependent reads the upstream's entry (its output rows travel in the
|
|
178
|
+
* execute request), so it waits for the save to land, while the freed
|
|
179
|
+
* slot admits other work at once. Undefined: nothing owed.
|
|
180
|
+
*/
|
|
181
|
+
settledOf?: (outcome: TaskOutcome) => Promise<void> | undefined
|
|
201
182
|
/**
|
|
202
183
|
* Optional priority override: callers pass their own per-node weight
|
|
203
184
|
* (e.g. `computePredictedPriorities` from the orchestrator's history
|
|
@@ -218,7 +199,9 @@ export interface ScheduleOptions {
|
|
|
218
199
|
* it);
|
|
219
200
|
* - bypasses the `failedDep`→`skipped` check (its key is independent
|
|
220
201
|
* of dep success — pure-input transitive hashing — so a valid cached
|
|
221
|
-
* output is reported `cache-hit` regardless of a dep failing)
|
|
202
|
+
* output is reported `cache-hit` regardless of a dep failing);
|
|
203
|
+
* - leaves the tier when `execute` throws `RestoreDemoted`, and is
|
|
204
|
+
* dispatched again once its deps are done, with the dep check.
|
|
222
205
|
* It still runs through `execute()` (so the logger frame is unchanged);
|
|
223
206
|
* the orchestrator's execute reuses the up-front probe, so there is no
|
|
224
207
|
* second cache.get. When undefined/empty, behavior is byte-identical.
|
|
@@ -233,109 +216,16 @@ export interface ScheduleOptions {
|
|
|
233
216
|
*/
|
|
234
217
|
poolOf?: (id: string) => { name: string; capacity: number } | undefined
|
|
235
218
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
219
|
+
* Admission over the worker count: asked for every exec-tier task about
|
|
220
|
+
* to start on this machine, with the ids of the exec-tier tasks running
|
|
221
|
+
* here right now (added on dispatch, so two asks in one tick see each
|
|
222
|
+
* other). `false` parks the task until something finishes. Undefined →
|
|
223
|
+
* count-only, the legacy path byte for byte. Restore-tier hits and
|
|
224
|
+
* pooled tasks hold no local resources and are never asked.
|
|
241
225
|
*/
|
|
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
|
|
226
|
+
admit?: (id: string, running: ReadonlySet<string>) => boolean
|
|
247
227
|
}
|
|
248
228
|
|
|
249
|
-
/**
|
|
250
|
-
* Compute, for each task in the graph, how many OTHER tasks are
|
|
251
|
-
* transitively blocked on it. Tasks with the highest count are the
|
|
252
|
-
* most valuable to schedule first — finishing them unlocks the most
|
|
253
|
-
* downstream work and minimizes worker idle time at the end of the
|
|
254
|
-
* run. Matches Nx's `calculateReverseDeps`-driven schedule sort
|
|
255
|
-
* (`packages/nx/src/tasks-runner/tasks-schedule.ts:166-207`).
|
|
256
|
-
*/
|
|
257
|
-
// Exported for the perf guard in tests/scheduler.test.ts, which times
|
|
258
|
-
// this function in isolation — end-to-end runGraph timing drowns the
|
|
259
|
-
// closure cost in task-promise overhead and flakes on slow CI.
|
|
260
|
-
export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string, number> {
|
|
261
|
-
// Exact transitive-dependent COUNTS need closure SETS (diamonds
|
|
262
|
-
// double-count under naive summing). Set-of-strings closures are
|
|
263
|
-
// O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
|
|
264
|
-
// bitsets make the same closure O(E·N/32) time and N²/8 bits of
|
|
265
|
-
// memory (3270 tasks ≈ 1.3 MB) — single-digit ms at that scale.
|
|
266
|
-
const ids = [...nodes.keys()]
|
|
267
|
-
const index = new Map<string, number>()
|
|
268
|
-
for (let i = 0; i < ids.length; i++) index.set(ids[i]!, i)
|
|
269
|
-
const n = ids.length
|
|
270
|
-
const words = (n + 31) >>> 5
|
|
271
|
-
|
|
272
|
-
// Direct dependents as index lists + in-degree for the topo pass.
|
|
273
|
-
const directReverse: number[][] = Array.from({ length: n }, () => [])
|
|
274
|
-
const indegree = new Uint32Array(n)
|
|
275
|
-
for (const node of nodes.values()) {
|
|
276
|
-
const ni = index.get(node.id)!
|
|
277
|
-
for (const dep of node.deps) {
|
|
278
|
-
const di = index.get(dep)
|
|
279
|
-
if (di === undefined) continue
|
|
280
|
-
directReverse[di]!.push(ni)
|
|
281
|
-
indegree[ni]!++
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Kahn topo order over dependency edges (deps before dependents).
|
|
286
|
-
// Insertion order is topo today, but the closure's correctness
|
|
287
|
-
// must not hinge on an unstated property of buildTaskGraph.
|
|
288
|
-
const topo = new Int32Array(n)
|
|
289
|
-
let head = 0
|
|
290
|
-
let tail = 0
|
|
291
|
-
for (let i = 0; i < n; i++) if (indegree[i] === 0) topo[tail++] = i
|
|
292
|
-
while (head < tail) {
|
|
293
|
-
const v = topo[head++]!
|
|
294
|
-
for (const r of directReverse[v]!) {
|
|
295
|
-
if (--indegree[r]! === 0) topo[tail++] = r
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Reverse-topo sweep: every direct dependent's closure is final
|
|
300
|
-
// before its dependency folds it in. closure[i] = bitset over node
|
|
301
|
-
// indices of i's transitive dependents.
|
|
302
|
-
const closure = new Uint32Array(n * words)
|
|
303
|
-
const counts = new Map<string, number>()
|
|
304
|
-
for (let t = tail - 1; t >= 0; t--) {
|
|
305
|
-
const i = topo[t]!
|
|
306
|
-
const base = i * words
|
|
307
|
-
for (const r of directReverse[i]!) {
|
|
308
|
-
closure[base + (r >>> 5)]! |= 1 << (r & 31)
|
|
309
|
-
const rbase = r * words
|
|
310
|
-
for (let w = 0; w < words; w++) closure[base + w]! |= closure[rbase + w]!
|
|
311
|
-
}
|
|
312
|
-
let count = 0
|
|
313
|
-
for (let w = 0; w < words; w++) {
|
|
314
|
-
let v = closure[base + w]!
|
|
315
|
-
v = v - ((v >>> 1) & 0x55555555)
|
|
316
|
-
v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
|
|
317
|
-
count += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24
|
|
318
|
-
}
|
|
319
|
-
counts.set(ids[i]!, count)
|
|
320
|
-
}
|
|
321
|
-
// Cycle-stranded nodes (never topo-visited) can't occur — the graph
|
|
322
|
-
// builder rejects cycles — but a missing Map entry would silently
|
|
323
|
-
// sort as undefined, so default them defensively to 0.
|
|
324
|
-
for (const id of ids) if (!counts.has(id)) counts.set(id, 0)
|
|
325
|
-
return counts
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Run the task graph. Independent tasks run in parallel up to `concurrency`.
|
|
330
|
-
* If a task fails, its dependents are marked `skipped` but unrelated tasks
|
|
331
|
-
* keep running so the user gets maximum information per invocation.
|
|
332
|
-
*
|
|
333
|
-
* Scheduling: when more than one task is ready, the scheduler picks the
|
|
334
|
-
* one that blocks the most downstream work (most transitive reverse
|
|
335
|
-
* dependents). Ties break in graph-insertion order (which is the topo
|
|
336
|
-
* order produced by `buildTaskGraph`). Minimizes worker idle at the
|
|
337
|
-
* end of the run.
|
|
338
|
-
*/
|
|
339
229
|
/**
|
|
340
230
|
* A binary max-heap of ready task ids, ordered by (priority DESC, enqueue-seq
|
|
341
231
|
* ASC) so `pop()` returns the highest-priority task and equal-priority ties
|
|
@@ -368,8 +258,18 @@ class ReadyHeap {
|
|
|
368
258
|
}
|
|
369
259
|
/**
|
|
370
260
|
* `seq` defaults to a fresh monotonic counter. A parked-then-repushed
|
|
371
|
-
* task passes its ORIGINAL seq back in
|
|
372
|
-
*
|
|
261
|
+
* task passes its ORIGINAL seq back in, which makes FIFO-among-equals a
|
|
262
|
+
* property of the heap rather than of the tick loop's shape.
|
|
263
|
+
*
|
|
264
|
+
* It is NOT the difference between correct and incorrect order today,
|
|
265
|
+
* and the comment here used to claim it was ("a fresh seq would demote
|
|
266
|
+
* it behind later arrivals"). It would not: `parked` is a subsequence
|
|
267
|
+
* of the tick's pop order, so it is already in ranking order, and every
|
|
268
|
+
* push that follows takes a later seq — so fresh seqs handed out in
|
|
269
|
+
* parked order reproduce the same ranking. Measured over 800 randomized
|
|
270
|
+
* scenarios (random DAGs, costs, budgets, concurrency, failures, all
|
|
271
|
+
* three continue modes): zero order differences, item 493. Kept because
|
|
272
|
+
* it holds without that argument.
|
|
373
273
|
*/
|
|
374
274
|
push(id: string, seq: number = this.next++): void {
|
|
375
275
|
this.ids.push(id)
|
|
@@ -411,6 +311,17 @@ class ReadyHeap {
|
|
|
411
311
|
}
|
|
412
312
|
}
|
|
413
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Run the task graph. Independent tasks run in parallel up to `concurrency`.
|
|
316
|
+
* If a task fails, its dependents are marked `skipped` but unrelated tasks
|
|
317
|
+
* keep running so the user gets maximum information per invocation.
|
|
318
|
+
*
|
|
319
|
+
* Scheduling: when more than one task is ready, the scheduler picks the
|
|
320
|
+
* one that blocks the most downstream work (most transitive reverse
|
|
321
|
+
* dependents). Ties break in graph-insertion order (which is the topo
|
|
322
|
+
* order produced by `buildTaskGraph`). Minimizes worker idle at the
|
|
323
|
+
* end of the run.
|
|
324
|
+
*/
|
|
414
325
|
export async function runGraph(options: ScheduleOptions): Promise<Map<string, TaskOutcome>> {
|
|
415
326
|
const { nodes, concurrency, execute, onStart, onFinish } = options
|
|
416
327
|
const continueMode = options.continueMode ?? 'deps-ok'
|
|
@@ -439,7 +350,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
439
350
|
// priorities map (e.g. from history-aware critical-path estimation),
|
|
440
351
|
// use those weights. Falls back to the reverse-deps-count heuristic
|
|
441
352
|
// for nodes the caller didn't score, so partial coverage works.
|
|
442
|
-
const baseline =
|
|
353
|
+
const baseline =
|
|
354
|
+
options.restoreTier !== undefined && options.restoreTier.size > 0
|
|
355
|
+
? tieredReverseDepCount(nodes, options.restoreTier)
|
|
356
|
+
: computeReverseDepCount(nodes)
|
|
443
357
|
const priority: ReadonlyMap<string, number> = options.priorities
|
|
444
358
|
? mergePriorities(baseline, options.priorities)
|
|
445
359
|
: baseline
|
|
@@ -453,6 +367,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
453
367
|
// idle capacity (or run when an exec is blocked on a restorable dep and
|
|
454
368
|
// nothing else is runnable).
|
|
455
369
|
const restoreTier = options.restoreTier
|
|
370
|
+
// Restore-tier tasks whose execute threw `RestoreDemoted`: from then on
|
|
371
|
+
// they are exec-tier tasks, gated on their deps like any other.
|
|
372
|
+
const demoted = new Set<string>()
|
|
373
|
+
const inRestoreTier = (id: string): boolean => restoreTier?.has(id) === true && !demoted.has(id)
|
|
456
374
|
const execReady = new ReadyHeap(priority)
|
|
457
375
|
const restoreReady = new ReadyHeap(priority)
|
|
458
376
|
// A restore-tier task is dep-independent (a stable hit's restore needs
|
|
@@ -466,33 +384,26 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
466
384
|
}
|
|
467
385
|
|
|
468
386
|
let active = 0
|
|
387
|
+
// Restore-tier work is disk I/O — a confirmed hit's tar extract is ~8
|
|
388
|
+
// filesystem round trips and no CPU — so it gets its own lane, twice
|
|
389
|
+
// the exec cap: on the 1,000-project bench with every task a restore,
|
|
390
|
+
// 4 → 8 workers cut the run-graph stage 683–754 → 556–595 ms and 16 was
|
|
391
|
+
// no better (2026-09-10). Exec-tier work keeps the CPU-shaped cap; the
|
|
392
|
+
// two lanes never wait on each other. `--concurrency 1` stays serial.
|
|
393
|
+
let activeRestore = 0
|
|
394
|
+
const restoreConcurrency = concurrency === 1 ? 1 : 2 * concurrency
|
|
469
395
|
let resolved = false
|
|
470
396
|
|
|
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
|
|
397
|
+
// Admission policy over the count limit (a plugin's `admit`). Inactive
|
|
398
|
+
// → the tick loop short-circuits before any of this and behaves
|
|
399
|
+
// byte-identically to the count-only scheduler.
|
|
400
|
+
const admitPolicy = options.admit
|
|
401
|
+
const admitActive = admitPolicy !== undefined
|
|
402
|
+
const running = new Set<string>()
|
|
403
|
+
// When a policy first refused each parked task — so its outcome can say
|
|
404
|
+
// how long the policy, not the count gate, held it. Nothing with no
|
|
405
|
+
// policy: the map is never touched.
|
|
406
|
+
const heldSince = new Map<string, number>()
|
|
496
407
|
|
|
497
408
|
// A restore-tier task is a confirmed local cache hit: its "execution"
|
|
498
409
|
// is a cheap tar extract, not the task's real work — it reserves ZERO
|
|
@@ -504,12 +415,20 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
504
415
|
// against `concurrency`; pooled tasks reserve no local resources.
|
|
505
416
|
const hasRoom = (id: string): boolean => {
|
|
506
417
|
const pool = poolOf?.(id)
|
|
507
|
-
if (pool === undefined)
|
|
418
|
+
if (pool === undefined) {
|
|
419
|
+
return inRestoreTier(id) ? activeRestore < restoreConcurrency : active < concurrency
|
|
420
|
+
}
|
|
508
421
|
return (poolActive.get(pool.name) ?? 0) < pool.capacity
|
|
509
422
|
}
|
|
510
423
|
const admit = (id: string): (() => void) => {
|
|
511
424
|
const pool = poolOf?.(id)
|
|
512
425
|
if (pool === undefined) {
|
|
426
|
+
if (inRestoreTier(id)) {
|
|
427
|
+
activeRestore++
|
|
428
|
+
return () => {
|
|
429
|
+
activeRestore--
|
|
430
|
+
}
|
|
431
|
+
}
|
|
513
432
|
active++
|
|
514
433
|
return () => {
|
|
515
434
|
active--
|
|
@@ -520,44 +439,20 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
520
439
|
poolActive.set(pool.name, (poolActive.get(pool.name) ?? 1) - 1)
|
|
521
440
|
}
|
|
522
441
|
}
|
|
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++
|
|
442
|
+
// The policy sees exec-tier local tasks only; a restore is a tar
|
|
443
|
+
// extract and a pooled task runs on someone else's capacity.
|
|
444
|
+
const local = (id: string): boolean => !inRestoreTier(id) && poolOf?.(id) === undefined
|
|
445
|
+
const admits = (id: string): boolean => !local(id) || admitPolicy!(id, running)
|
|
446
|
+
// With no policy nothing reads `running`, so nothing is tracked: the
|
|
447
|
+
// count-only dispatch allocates no closure and touches no set per task.
|
|
448
|
+
const untracked = (): void => {}
|
|
449
|
+
const track = (id: string): (() => void) => {
|
|
450
|
+
if (!admitActive || !local(id)) return untracked
|
|
451
|
+
running.add(id)
|
|
452
|
+
return () => {
|
|
453
|
+
running.delete(id)
|
|
553
454
|
}
|
|
554
455
|
}
|
|
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
456
|
|
|
562
457
|
return new Promise<Map<string, TaskOutcome>>((resolve) => {
|
|
563
458
|
const finishOne = (id: string, outcome: TaskOutcome): void => {
|
|
@@ -575,14 +470,54 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
575
470
|
const m = err instanceof Error ? err.message : String(err)
|
|
576
471
|
process.stderr.write(`[vx] onFinish observer threw for ${id}: ${m}\n`)
|
|
577
472
|
}
|
|
473
|
+
// A restore-tier task runs before its deps, but its DEPENDENTS keep
|
|
474
|
+
// the order `dependsOn` states: they are released once its own deps
|
|
475
|
+
// have settled too. Released at its own finish, an `e2e` ran beside
|
|
476
|
+
// the `db:migrate` two edges up whenever the middle task was a hit,
|
|
477
|
+
// and ran green after that dep failed (item 963).
|
|
478
|
+
if (inRestoreTier(id) && (pending.get(id) ?? 0) > 0) {
|
|
479
|
+
heldRelease.add(id)
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
release(id)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// A restore-tier task whose deps did not all succeed is still a
|
|
486
|
+
// `cache-hit` (its key is theirs, not their outcome), but it passes the
|
|
487
|
+
// block down: its dependents are skipped as a failed dep's would be.
|
|
488
|
+
// Its root: a failed or aborted dep, or the root a skipped or blocked
|
|
489
|
+
// one carries.
|
|
490
|
+
const blockedRestores = new Map<string, string | undefined>()
|
|
491
|
+
const heldRelease = new Set<string>()
|
|
492
|
+
const release = (id: string): void => {
|
|
493
|
+
if (inRestoreTier(id) && continueMode !== 'always') {
|
|
494
|
+
for (const dep of (nodes.get(id) as TaskNode).deps) {
|
|
495
|
+
const u = outcomes.get(dep)
|
|
496
|
+
if (u?.status === 'failed' || u?.status === 'aborted') {
|
|
497
|
+
blockedRestores.set(id, dep)
|
|
498
|
+
break
|
|
499
|
+
}
|
|
500
|
+
if (u?.status === 'skipped') {
|
|
501
|
+
blockedRestores.set(id, u.blockedBy)
|
|
502
|
+
break
|
|
503
|
+
}
|
|
504
|
+
if (blockedRestores.has(dep)) {
|
|
505
|
+
blockedRestores.set(id, blockedRestores.get(dep))
|
|
506
|
+
break
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
578
510
|
const ds = dependents.get(id)
|
|
579
511
|
if (!ds) return
|
|
580
512
|
for (const d of ds) {
|
|
581
513
|
const rem = (pending.get(d) ?? 0) - 1
|
|
582
514
|
pending.set(d, rem)
|
|
515
|
+
if (rem !== 0) continue
|
|
583
516
|
// Restore-tier dependents were already enqueued at startup (they
|
|
584
|
-
// don't wait on deps);
|
|
585
|
-
|
|
517
|
+
// don't wait on deps); one that finished early releases its own
|
|
518
|
+
// dependents now. Only an exec-tier dependent is enqueued.
|
|
519
|
+
if (heldRelease.delete(d)) release(d)
|
|
520
|
+
else if (!inRestoreTier(d)) execReady.push(d)
|
|
586
521
|
}
|
|
587
522
|
}
|
|
588
523
|
|
|
@@ -598,9 +533,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
598
533
|
// cached output is reported `cache-hit` even if a dep failed — and
|
|
599
534
|
// they're dep-independent, so they typically restore before a dep
|
|
600
535
|
// could fail anyway.
|
|
536
|
+
const aborted = (): boolean => options.signal?.aborted === true
|
|
601
537
|
const willSkip = (id: string): boolean => {
|
|
602
|
-
if (failFastTripped) return true
|
|
603
|
-
if (
|
|
538
|
+
if (failFastTripped || aborted()) return true
|
|
539
|
+
if (inRestoreTier(id)) return false
|
|
604
540
|
if (continueMode === 'always') return false
|
|
605
541
|
const node = nodes.get(id) as TaskNode
|
|
606
542
|
return node.deps.some((d) => {
|
|
@@ -611,37 +547,54 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
611
547
|
// from them — under the key a healthy run derives, since the fold
|
|
612
548
|
// takes the upstream's INPUT key — so the next run replays those
|
|
613
549
|
// bytes as a green hit.
|
|
614
|
-
return
|
|
550
|
+
return (
|
|
551
|
+
u?.status === 'failed' ||
|
|
552
|
+
u?.status === 'skipped' ||
|
|
553
|
+
u?.status === 'aborted' ||
|
|
554
|
+
blockedRestores.has(d)
|
|
555
|
+
)
|
|
615
556
|
})
|
|
616
557
|
}
|
|
617
558
|
|
|
618
559
|
const tick = (): void => {
|
|
619
560
|
if (resolved) return
|
|
620
|
-
// Exec-tier tasks parked THIS tick on a
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
561
|
+
// Exec-tier tasks parked THIS tick on a refused admission. Within
|
|
562
|
+
// one synchronous tick `running` only grows (tasks leave it in the
|
|
563
|
+
// async completion callbacks, which run a fresh tick), so a task
|
|
564
|
+
// refused now cannot be admitted later in the same tick — parking
|
|
565
|
+
// it for the tick's remainder is exact, and each id pops at most
|
|
566
|
+
// once per tick (O(R log R)).
|
|
626
567
|
const parked: Array<[string, number]> = []
|
|
627
568
|
|
|
628
569
|
// Highest-priority admissible task: exec tier first (misses own
|
|
629
|
-
// the pool), then restore tier. With no
|
|
570
|
+
// the pool), then restore tier. With no admission policy this
|
|
630
571
|
// short-circuits to exactly the legacy takeReady. A would-skip
|
|
631
|
-
// task returns without
|
|
632
|
-
// tasks
|
|
572
|
+
// task returns without asking (finishing it is free); restore
|
|
573
|
+
// tasks are never asked, so they never park.
|
|
633
574
|
const takeFitting = (): string | undefined => {
|
|
634
|
-
// No pools
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
575
|
+
// No pools, no policy and a full exec lane: nothing on the
|
|
576
|
+
// exec queue can be admitted, so it is not scanned — scanning it
|
|
577
|
+
// would pop and re-park every ready exec task on every tick, and
|
|
578
|
+
// with a wide frontier that is O(R) per tick, O(R²) per run (the
|
|
579
|
+
// 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
|
|
580
|
+
// first cut let the scan run past a full exec lane, 2026-09-10).
|
|
581
|
+
// This is the legacy O(1) gate, kept per lane.
|
|
582
|
+
const execAdmissible = poolOf !== undefined || admitActive || active < concurrency
|
|
583
|
+
if (execAdmissible) {
|
|
584
|
+
while (execReady.size > 0) {
|
|
585
|
+
const seq = execReady.peekSeq()
|
|
586
|
+
const id = execReady.pop() as string
|
|
587
|
+
if (willSkip(id)) return id
|
|
588
|
+
if (hasRoom(id)) {
|
|
589
|
+
if (!admitActive || admits(id)) return id
|
|
590
|
+
// A free worker, refused by the policy: the hold starts now.
|
|
591
|
+
if (!heldSince.has(id)) heldSince.set(id, Date.now())
|
|
592
|
+
}
|
|
593
|
+
parked.push([id, seq])
|
|
594
|
+
}
|
|
642
595
|
}
|
|
643
|
-
// Restore-tier tasks are local work
|
|
644
|
-
return
|
|
596
|
+
// Restore-tier tasks are local disk work, on their own lane.
|
|
597
|
+
return activeRestore < restoreConcurrency ? restoreReady.pop() : undefined
|
|
645
598
|
}
|
|
646
599
|
|
|
647
600
|
for (;;) {
|
|
@@ -660,47 +613,98 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
660
613
|
// because dependents are pushed when `pending` hits 0 regardless
|
|
661
614
|
// of outcome — keeps the propagation logic in one place.
|
|
662
615
|
if (willSkip(id)) {
|
|
663
|
-
|
|
616
|
+
// The root of the block: a failed or aborted upstream names
|
|
617
|
+
// itself; a skipped one hands down its own root.
|
|
618
|
+
const blocker = upstream.find(
|
|
619
|
+
(u) => u !== undefined && (u.status === 'failed' || u.status === 'aborted'),
|
|
620
|
+
)
|
|
621
|
+
const viaSkip = upstream.find((u) => u !== undefined && u.status === 'skipped')
|
|
622
|
+
const viaRestore = node.deps.find((d) => blockedRestores.has(d))
|
|
623
|
+
const blockedBy =
|
|
624
|
+
blocker?.node.id ??
|
|
625
|
+
viaSkip?.blockedBy ??
|
|
626
|
+
(viaRestore === undefined ? undefined : blockedRestores.get(viaRestore))
|
|
627
|
+
finishOne(id, {
|
|
628
|
+
node,
|
|
629
|
+
status: aborted() ? 'aborted' : 'skipped',
|
|
630
|
+
exitCode: 1,
|
|
631
|
+
durationMs: 0,
|
|
632
|
+
...(blockedBy !== undefined ? { blockedBy } : {}),
|
|
633
|
+
})
|
|
664
634
|
continue
|
|
665
635
|
}
|
|
666
636
|
|
|
667
637
|
const leave = admit(id)
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
const
|
|
671
|
-
|
|
638
|
+
// Listed as running on dispatch, so the policy's next ask in this
|
|
639
|
+
// tick sees it; the completion callbacks unlist it.
|
|
640
|
+
const untrack = track(id)
|
|
641
|
+
const since = admitActive ? heldSince.get(id) : undefined
|
|
642
|
+
const heldMs = since === undefined ? 0 : Math.max(1, Date.now() - since)
|
|
643
|
+
if (since !== undefined) heldSince.delete(id)
|
|
644
|
+
const withHold = (o: TaskOutcome): TaskOutcome =>
|
|
645
|
+
heldMs > 0 ? { ...o, admissionHeldMs: heldMs } : o
|
|
672
646
|
// Crash-isolated observer hook — a throwing onStart must not abort
|
|
673
|
-
// the dispatch loop (it would strand the tick with
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
647
|
+
// the dispatch loop (it would strand the tick with the slot held).
|
|
648
|
+
// A demoted task's second dispatch is the same task, already started.
|
|
649
|
+
if (!demoted.has(id)) {
|
|
650
|
+
try {
|
|
651
|
+
onStart?.(node)
|
|
652
|
+
} catch (err) {
|
|
653
|
+
const m = err instanceof Error ? err.message : String(err)
|
|
654
|
+
process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
|
|
655
|
+
}
|
|
679
656
|
}
|
|
680
657
|
|
|
681
658
|
// `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
|
|
682
659
|
// rejection arm handles ONLY `execute()` rejecting; a throw from
|
|
683
|
-
// the fulfillment arm
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
660
|
+
// the fulfillment arm must NOT also run the rejection arm, or
|
|
661
|
+
// `active` releases twice — and a double release wedges the count
|
|
662
|
+
// gate. NOT `onFinish`, which this comment used to cite: it is
|
|
663
|
+
// isolated inside `finishOne`, so it never reaches here (which is
|
|
664
|
+
// why the row named for it cannot tell the two shapes apart —
|
|
665
|
+
// item 560). What is left unisolated is the caller's
|
|
666
|
+
// `settledOf`, and under THIS shape a throw there strands the
|
|
667
|
+
// task's outcome and hangs the run rather than double-releasing.
|
|
668
|
+
// The two-arm form is still the correct choice; there is simply
|
|
669
|
+
// no reachable throw that makes the difference observable.
|
|
687
670
|
execute(node, upstream).then(
|
|
688
|
-
(
|
|
671
|
+
(raw) => {
|
|
672
|
+
const outcome = withHold(raw)
|
|
689
673
|
leave()
|
|
690
|
-
|
|
691
|
-
|
|
674
|
+
untrack()
|
|
675
|
+
// The slot is free now; the dependents wait for what the
|
|
676
|
+
// outcome still owes (a save landing), if anything.
|
|
677
|
+
const settled = options.settledOf?.(outcome)
|
|
678
|
+
if (settled === undefined) {
|
|
679
|
+
finishOne(id, outcome)
|
|
680
|
+
tick()
|
|
681
|
+
return
|
|
682
|
+
}
|
|
692
683
|
tick()
|
|
684
|
+
const done = (): void => {
|
|
685
|
+
finishOne(id, outcome)
|
|
686
|
+
tick()
|
|
687
|
+
}
|
|
688
|
+
void settled.then(done, done)
|
|
693
689
|
},
|
|
694
690
|
(err: unknown) => {
|
|
691
|
+
if (err instanceof RestoreDemoted) {
|
|
692
|
+
leave()
|
|
693
|
+
untrack()
|
|
694
|
+
demoted.add(id)
|
|
695
|
+
if (pending.get(id) === 0) execReady.push(id)
|
|
696
|
+
tick()
|
|
697
|
+
return
|
|
698
|
+
}
|
|
695
699
|
const message = err instanceof Error ? err.message : String(err)
|
|
696
|
-
const outcome: TaskOutcome = {
|
|
700
|
+
const outcome: TaskOutcome = withHold({
|
|
697
701
|
node,
|
|
698
702
|
status: 'failed',
|
|
699
703
|
exitCode: 1,
|
|
700
704
|
durationMs: 0,
|
|
701
|
-
}
|
|
705
|
+
})
|
|
702
706
|
leave()
|
|
703
|
-
|
|
707
|
+
untrack()
|
|
704
708
|
finishOne(id, outcome)
|
|
705
709
|
// Surface the error live; the outcome itself doesn't
|
|
706
710
|
// carry captured stderr (that's the logger's job). A
|
|
@@ -709,20 +713,37 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
709
713
|
// plainly, never as an "internal error".
|
|
710
714
|
if (isUserError(err)) {
|
|
711
715
|
process.stderr.write(`[vx] ${id}: ${message}\n`)
|
|
716
|
+
} else if (isFsRefusal(err)) {
|
|
717
|
+
process.stderr.write(`[vx] ${id}: ${message} — ${fsRefusalHint(err)}\n`)
|
|
712
718
|
} else {
|
|
713
719
|
const named = err instanceof Error && err.name !== 'Error' ? `${err.name}: ` : ''
|
|
714
|
-
|
|
720
|
+
// A wrapped error's cause is the fact the reader needs (a
|
|
721
|
+
// CorruptArtifactError over an ENOENT is a race, not a bad
|
|
722
|
+
// archive); print it, since the stack is not printed here.
|
|
723
|
+
const cause =
|
|
724
|
+
err instanceof Error && err.cause instanceof Error ? err.cause : undefined
|
|
725
|
+
const because =
|
|
726
|
+
cause === undefined
|
|
727
|
+
? ''
|
|
728
|
+
: ` (cause: ${(cause as NodeJS.ErrnoException).code ?? cause.name}: ${cause.message})`
|
|
729
|
+
process.stderr.write(`[vx] internal error in ${id}: ${named}${message}${because}\n`)
|
|
715
730
|
}
|
|
716
731
|
tick()
|
|
717
732
|
},
|
|
718
733
|
)
|
|
719
734
|
}
|
|
720
735
|
|
|
721
|
-
// Repush parked ids with their ORIGINAL seqs
|
|
722
|
-
//
|
|
736
|
+
// Repush parked ids with their ORIGINAL seqs, so the next tick's
|
|
737
|
+
// admission pass does not depend on `parked` having been built in
|
|
738
|
+
// ranking order (it is, but see `push`).
|
|
723
739
|
for (const [id, seq] of parked) execReady.push(id, seq)
|
|
724
740
|
|
|
725
|
-
|
|
741
|
+
// The outcome count alone: a slot is released before its outcome
|
|
742
|
+
// lands (`leave()` precedes `finishOne` in both arms, and a skip
|
|
743
|
+
// takes none), so a task still holding one has no outcome yet, and
|
|
744
|
+
// the lane counters could add nothing — deleting both from this
|
|
745
|
+
// condition survived the whole core suite (item 644).
|
|
746
|
+
if (outcomes.size === nodes.size) {
|
|
726
747
|
resolved = true
|
|
727
748
|
resolve(outcomes)
|
|
728
749
|
}
|
|
@@ -731,21 +752,3 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
731
752
|
tick()
|
|
732
753
|
})
|
|
733
754
|
}
|
|
734
|
-
|
|
735
|
-
export function mergePriorities(
|
|
736
|
-
baseline: ReadonlyMap<string, number>,
|
|
737
|
-
overrides: ReadonlyMap<string, number>,
|
|
738
|
-
): ReadonlyMap<string, number> {
|
|
739
|
-
// The override caller scored "expected critical-path duration" in ms.
|
|
740
|
-
// Baseline reverse-deps counts are bounded by N; scale the override so
|
|
741
|
-
// it sorts above the baseline for any node it covers, and add the
|
|
742
|
-
// baseline as a tie-break for parity within the override set.
|
|
743
|
-
const SCALE = 1 << 20
|
|
744
|
-
const out = new Map<string, number>()
|
|
745
|
-
for (const [id, w] of baseline) out.set(id, w)
|
|
746
|
-
for (const [id, w] of overrides) {
|
|
747
|
-
const b = baseline.get(id) ?? 0
|
|
748
|
-
out.set(id, w * SCALE + b)
|
|
749
|
-
}
|
|
750
|
-
return out
|
|
751
|
-
}
|