@vzn/vx 0.0.16 → 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 +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- 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 +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- 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/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- 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 +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
package/src/graph/scheduler.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isUserError } from '../util/index.js'
|
|
2
2
|
import type { TaskNode } from './task-graph.js'
|
|
3
3
|
|
|
4
4
|
export type TaskStatus =
|
|
@@ -8,65 +8,12 @@ export type TaskStatus =
|
|
|
8
8
|
| 'failed'
|
|
9
9
|
| 'skipped'
|
|
10
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
|
-
//
|
|
11
|
+
// the task never finished on its own terms, so it reverts to aborted —
|
|
12
|
+
// excluded from the tally and the run history. It still PROPAGATES like
|
|
13
|
+
// a failure (see `willSkip`): the child died mid-write, so its outputs
|
|
14
|
+
// are partial.
|
|
13
15
|
| 'aborted'
|
|
14
16
|
|
|
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
|
-
/**
|
|
46
|
-
* Content fingerprint of a task's output tree, computed under a `--verify*`
|
|
47
|
-
* mode on the executed (miss) path. Never hashed into any key; pure telemetry
|
|
48
|
-
* side-channel for the cross-machine diff (a serve pairs fingerprints for the
|
|
49
|
-
* SAME cache key across platforms and names diverging outputs). Declared here
|
|
50
|
-
* (structurally) because `graph` can't import `orchestrator` — the
|
|
51
|
-
* `VerifyVerdict` pattern. See docs/design/verify-cross-machine-2026-07.md.
|
|
52
|
-
*/
|
|
53
|
-
export interface OutputFingerprint {
|
|
54
|
-
/** Roll-up: xxh3hex over the sorted (key, hash) pairs, folded as
|
|
55
|
-
* `key \0 hash \n` (\0 boundaries — the v18 lesson). Always present;
|
|
56
|
-
* divergence DETECTION never depends on the per-file map. */
|
|
57
|
-
tree: string
|
|
58
|
-
/** Total files in the tree (pre-truncation). */
|
|
59
|
-
fileCount: number
|
|
60
|
-
/** Per-file map as sorted [outputKey, xxh3hex] pairs, capped at
|
|
61
|
-
* FP_MAX_FILES (500). Deterministic truncation — sorted by key,
|
|
62
|
-
* first N — so two machines' truncated maps cover the same subset
|
|
63
|
-
* and partial diffs still name real rels. */
|
|
64
|
-
files?: ReadonlyArray<readonly [string, string]>
|
|
65
|
-
/** Set when `files` was truncated to the cap (or dropped by the
|
|
66
|
-
* sink's run budget). */
|
|
67
|
-
truncated?: boolean
|
|
68
|
-
}
|
|
69
|
-
|
|
70
17
|
export interface TaskOutcome {
|
|
71
18
|
node: TaskNode
|
|
72
19
|
status: TaskStatus
|
|
@@ -75,9 +22,51 @@ export interface TaskOutcome {
|
|
|
75
22
|
/** Cache key hash, if one was computed. Folded into dependents' keys
|
|
76
23
|
* (pure-input transitive — the upstream's input key, not its output). */
|
|
77
24
|
hash?: string
|
|
25
|
+
/**
|
|
26
|
+
* For cache-hit statuses: the exec time the ENTRY was stored with — i.e.
|
|
27
|
+
* the work this hit skipped. Distinct from `durationMs`, which is always
|
|
28
|
+
* what THIS run spent (a hit's restore cost). The two differ by whatever
|
|
29
|
+
* the exec:restore ratio happens to be, so a surface reporting "time
|
|
30
|
+
* saved" must read this one; a surface reporting "time spent" must not.
|
|
31
|
+
* Undefined on every non-hit outcome — nothing was skipped.
|
|
32
|
+
*/
|
|
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
|
|
78
50
|
/** v11 analytics: CPU time + peak RSS for this task's child process. */
|
|
79
51
|
cpuMs?: number
|
|
80
52
|
peakRssBytes?: number
|
|
53
|
+
/**
|
|
54
|
+
* A GROUP task's own dependency outcomes — the tasks it stands for.
|
|
55
|
+
* A group has no `exec` and therefore no outputs and no cache entry: its
|
|
56
|
+
* hash is a synthetic roll-up (`computeGroupHash`), so asking the local
|
|
57
|
+
* index what it produced returns nothing. A dependent needs the real
|
|
58
|
+
* tasks beneath it to describe its own input set, and only the group
|
|
59
|
+
* itself is ever in a position to say which those are. Set on group
|
|
60
|
+
* outcomes only; never folded into any key.
|
|
61
|
+
*/
|
|
62
|
+
groupUpstream?: readonly TaskOutcome[]
|
|
63
|
+
/** Executor-reported placement label (`ExecuteResult.where`) — set only
|
|
64
|
+
* when the task ran somewhere other than this host. Telemetry-only. */
|
|
65
|
+
where?: string
|
|
66
|
+
/** `'deferred'` when the task's outputs were left in the remote store
|
|
67
|
+
* (`--download=none`) instead of landing on this machine. Absent for
|
|
68
|
+
* every ordinary outcome. Telemetry-only. */
|
|
69
|
+
outputs?: 'deferred'
|
|
81
70
|
/**
|
|
82
71
|
* v11 analytics: hrtime span relative to the parent run's t=0.
|
|
83
72
|
* Lets downstream analytics reconstruct the actual parallel timeline
|
|
@@ -102,10 +91,10 @@ export interface TaskOutcome {
|
|
|
102
91
|
*/
|
|
103
92
|
attempts?: number
|
|
104
93
|
/**
|
|
105
|
-
* Count of sandbox violations captured during this task's exec
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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.
|
|
109
98
|
*/
|
|
110
99
|
sandboxViolations?: number
|
|
111
100
|
/**
|
|
@@ -114,58 +103,46 @@ export interface TaskOutcome {
|
|
|
114
103
|
* inline in the task's block instead of as loose status output.
|
|
115
104
|
*/
|
|
116
105
|
sandboxViolationLines?: string[]
|
|
117
|
-
/**
|
|
118
|
-
* Cache-correctness verdict under `vx run --verify`. Set only in verify
|
|
119
|
-
* mode; a plain run leaves it undefined. Pure side-channel (never hashed).
|
|
120
|
-
*/
|
|
121
|
-
verify?: VerifyVerdict
|
|
122
|
-
/**
|
|
123
|
-
* Output-tree fingerprint under a fingerprinting `--verify*` mode
|
|
124
|
-
* (`--verify` / `=all` / `=fingerprint`), executed + cacheable +
|
|
125
|
-
* output-declaring tasks only. A plain run leaves it undefined.
|
|
126
|
-
*/
|
|
127
|
-
outputFp?: OutputFingerprint
|
|
128
106
|
}
|
|
129
107
|
|
|
130
108
|
export type ContinueMode = 'never' | 'deps-ok' | 'always'
|
|
131
109
|
|
|
132
|
-
/**
|
|
133
|
-
* Resolved per-task resource reservation, in absolute units (cpu may be
|
|
134
|
-
* fractional; mem is bytes). Declared here (structurally) because `graph`
|
|
135
|
-
* can't import `orchestrator`, where the resolver lives — same pattern as
|
|
136
|
-
* `VerifyVerdict`. A `0` axis means "reserve nothing, run freely": the
|
|
137
|
-
* task is exempt from that axis entirely (needs no headroom, holds none).
|
|
138
|
-
*/
|
|
139
|
-
export interface ResourceCost {
|
|
140
|
-
cpu: number
|
|
141
|
-
mem: number
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export const ZERO_COST: ResourceCost = { cpu: 0, mem: 0 }
|
|
145
|
-
|
|
146
110
|
export interface ScheduleOptions {
|
|
147
111
|
nodes: Map<string, TaskNode>
|
|
148
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
|
|
149
119
|
/**
|
|
150
120
|
* Failure propagation (default 'deps-ok' — the historical behavior):
|
|
151
|
-
* - 'deps-ok': a failed/skipped upstream skips its
|
|
152
|
-
* independent siblings keep running.
|
|
121
|
+
* - 'deps-ok': a failed/skipped/aborted upstream skips its
|
|
122
|
+
* dependents; independent siblings keep running.
|
|
153
123
|
* - 'never': the first failure stops dispatch — in-flight tasks
|
|
154
124
|
* finish naturally, everything not yet started completes as
|
|
155
125
|
* skipped (restore-tier included: a fail-fast run stops
|
|
156
126
|
* restoring too).
|
|
157
|
-
* - 'always': dependents run even when an upstream failed.
|
|
158
|
-
*
|
|
159
|
-
* upstream's INPUT key
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* 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`).
|
|
164
133
|
*/
|
|
165
134
|
continueMode?: ContinueMode
|
|
166
135
|
execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
|
|
167
136
|
onStart?: (node: TaskNode) => void
|
|
168
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
|
|
169
146
|
/**
|
|
170
147
|
* Optional priority override: callers pass their own per-node weight
|
|
171
148
|
* (e.g. `computePredictedPriorities` from the orchestrator's history
|
|
@@ -193,17 +170,22 @@ export interface ScheduleOptions {
|
|
|
193
170
|
*/
|
|
194
171
|
restoreTier?: ReadonlySet<string>
|
|
195
172
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
173
|
+
* Pool assignment for tasks placed on an executor with its own capacity
|
|
174
|
+
* (a remote pool). Such a task occupies one of the pool's `capacity` slots
|
|
175
|
+
* instead of a local worker slot and reserves no local resources, so a
|
|
176
|
+
* remote pool is never throttled by the local CPU count. Undefined (or
|
|
177
|
+
* a `undefined` result) = the local pool.
|
|
201
178
|
*/
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
179
|
+
poolOf?: (id: string) => { name: string; capacity: number } | undefined
|
|
180
|
+
/**
|
|
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.
|
|
187
|
+
*/
|
|
188
|
+
admit?: (id: string, running: ReadonlySet<string>) => boolean
|
|
207
189
|
}
|
|
208
190
|
|
|
209
191
|
/**
|
|
@@ -426,73 +408,76 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
426
408
|
}
|
|
427
409
|
|
|
428
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
|
|
429
419
|
let resolved = false
|
|
430
420
|
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
const
|
|
435
|
-
const
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
// guards keep that residue from corrupting admission:
|
|
442
|
-
// - the solo-clamp gate ("is the axis idle?") reads INTEGER holder
|
|
443
|
-
// counts, never the float sum === 0 — a residue would otherwise
|
|
444
|
-
// park an over-budget task forever (active===0, no future tick =
|
|
445
|
-
// a silent hang / exit-0-without-running);
|
|
446
|
-
// - `reserved` snaps back to EXACT 0 whenever its holder count hits
|
|
447
|
-
// 0, so residue can't accumulate across busy periods.
|
|
448
|
-
// The within-budget comparison also carries a tiny relative epsilon so
|
|
449
|
-
// an exact-fill (`reserved + cost == budget`) can't mis-round into a
|
|
450
|
-
// spurious block. Admission is a hint, so the epsilon's sub-ulp
|
|
451
|
-
// over-admission is harmless.
|
|
452
|
-
let reservedCpu = 0
|
|
453
|
-
let reservedMem = 0
|
|
454
|
-
let holdersCpu = 0
|
|
455
|
-
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>()
|
|
456
431
|
|
|
457
432
|
// A restore-tier task is a confirmed local cache hit: its "execution"
|
|
458
433
|
// is a cheap tar extract, not the task's real work — it reserves ZERO
|
|
459
434
|
// regardless of what the config declares, so it never holds budget
|
|
460
435
|
// against a real executor (and never parks).
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
return (
|
|
474
|
-
fitsAxis(c.cpu, reservedCpu, holdersCpu, cpuBudget) &&
|
|
475
|
-
fitsAxis(c.mem, reservedMem, holdersMem, memBudget)
|
|
476
|
-
)
|
|
436
|
+
const poolOf = options.poolOf
|
|
437
|
+
const poolActive = new Map<string, number>()
|
|
438
|
+
// A pooled task is admitted against ITS pool's capacity, a local one
|
|
439
|
+
// against `concurrency`; pooled tasks reserve no local resources.
|
|
440
|
+
const hasRoom = (id: string): boolean => {
|
|
441
|
+
const pool = poolOf?.(id)
|
|
442
|
+
if (pool === undefined) {
|
|
443
|
+
return options.restoreTier?.has(id)
|
|
444
|
+
? activeRestore < restoreConcurrency
|
|
445
|
+
: active < concurrency
|
|
446
|
+
}
|
|
447
|
+
return (poolActive.get(pool.name) ?? 0) < pool.capacity
|
|
477
448
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
449
|
+
const admit = (id: string): (() => void) => {
|
|
450
|
+
const pool = poolOf?.(id)
|
|
451
|
+
if (pool === undefined) {
|
|
452
|
+
if (options.restoreTier?.has(id)) {
|
|
453
|
+
activeRestore++
|
|
454
|
+
return () => {
|
|
455
|
+
activeRestore--
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
active++
|
|
459
|
+
return () => {
|
|
460
|
+
active--
|
|
461
|
+
}
|
|
485
462
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
463
|
+
poolActive.set(pool.name, (poolActive.get(pool.name) ?? 0) + 1)
|
|
464
|
+
return () => {
|
|
465
|
+
poolActive.set(pool.name, (poolActive.get(pool.name) ?? 1) - 1)
|
|
489
466
|
}
|
|
490
467
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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)
|
|
480
|
+
}
|
|
496
481
|
}
|
|
497
482
|
|
|
498
483
|
return new Promise<Map<string, TaskOutcome>>((resolve) => {
|
|
@@ -502,7 +487,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
502
487
|
// Observer hook (the logger's taskComplete). Crash-isolated: a
|
|
503
488
|
// throwing observer must NOT break scheduling — it would otherwise
|
|
504
489
|
// skip the dependent-enqueue + tick below and hang the run. Same
|
|
505
|
-
// "observability never breaks a run" rule the telemetry
|
|
490
|
+
// "observability never breaks a run" rule the telemetry
|
|
506
491
|
// paths hold. Isolated here (not the completion arm) so a throw
|
|
507
492
|
// can't be mistaken for the task itself failing.
|
|
508
493
|
try {
|
|
@@ -523,7 +508,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
523
508
|
}
|
|
524
509
|
|
|
525
510
|
// True when the task would be finished as `skipped` without running
|
|
526
|
-
// (fail-fast tripped, or a failed/skipped upstream under
|
|
511
|
+
// (fail-fast tripped, or a failed/skipped/aborted upstream under
|
|
527
512
|
// continueMode !== 'always'). ONE predicate shared by the admission
|
|
528
513
|
// parker and the dispatch loop's skip branch — a would-skip task
|
|
529
514
|
// executes nothing, so it must never park on a resource fit (a
|
|
@@ -534,43 +519,66 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
534
519
|
// cached output is reported `cache-hit` even if a dep failed — and
|
|
535
520
|
// they're dep-independent, so they typically restore before a dep
|
|
536
521
|
// could fail anyway.
|
|
522
|
+
const aborted = (): boolean => options.signal?.aborted === true
|
|
537
523
|
const willSkip = (id: string): boolean => {
|
|
538
|
-
if (failFastTripped) return true
|
|
524
|
+
if (failFastTripped || aborted()) return true
|
|
539
525
|
if (restoreTier?.has(id)) return false
|
|
540
526
|
if (continueMode === 'always') return false
|
|
541
527
|
const node = nodes.get(id) as TaskNode
|
|
542
528
|
return node.deps.some((d) => {
|
|
543
529
|
const u = outcomes.get(d)
|
|
544
|
-
|
|
530
|
+
// `aborted` propagates even though it is counted nowhere: the
|
|
531
|
+
// upstream was killed mid-write, so its declared outputs are
|
|
532
|
+
// partial. A dependent that ran anyway would CACHE what it built
|
|
533
|
+
// from them — under the key a healthy run derives, since the fold
|
|
534
|
+
// takes the upstream's INPUT key — so the next run replays those
|
|
535
|
+
// bytes as a green hit.
|
|
536
|
+
return u?.status === 'failed' || u?.status === 'skipped' || u?.status === 'aborted'
|
|
545
537
|
})
|
|
546
538
|
}
|
|
547
539
|
|
|
548
540
|
const tick = (): void => {
|
|
549
541
|
if (resolved) return
|
|
550
|
-
// Exec-tier tasks parked THIS tick on a
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
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)).
|
|
556
548
|
const parked: Array<[string, number]> = []
|
|
557
549
|
|
|
558
550
|
// Highest-priority admissible task: exec tier first (misses own
|
|
559
|
-
// the pool), then restore tier. With no
|
|
551
|
+
// the pool), then restore tier. With no admission policy this
|
|
560
552
|
// short-circuits to exactly the legacy takeReady. A would-skip
|
|
561
|
-
// task returns without
|
|
562
|
-
// tasks
|
|
553
|
+
// task returns without asking (finishing it is free); restore
|
|
554
|
+
// tasks are never asked, so they never park.
|
|
563
555
|
const takeFitting = (): string | undefined => {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
+
}
|
|
569
576
|
}
|
|
570
|
-
|
|
577
|
+
// Restore-tier tasks are local disk work, on their own lane.
|
|
578
|
+
return activeRestore < restoreConcurrency ? restoreReady.pop() : undefined
|
|
571
579
|
}
|
|
572
580
|
|
|
573
|
-
|
|
581
|
+
for (;;) {
|
|
574
582
|
const id = takeFitting()
|
|
575
583
|
if (id === undefined) break // nothing ready is admissible right now
|
|
576
584
|
const node = nodes.get(id) as TaskNode
|
|
@@ -586,17 +594,26 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
586
594
|
// because dependents are pushed when `pending` hits 0 regardless
|
|
587
595
|
// of outcome — keeps the propagation logic in one place.
|
|
588
596
|
if (willSkip(id)) {
|
|
589
|
-
finishOne(id, {
|
|
597
|
+
finishOne(id, {
|
|
598
|
+
node,
|
|
599
|
+
status: aborted() ? 'aborted' : 'skipped',
|
|
600
|
+
exitCode: 1,
|
|
601
|
+
durationMs: 0,
|
|
602
|
+
})
|
|
590
603
|
continue
|
|
591
604
|
}
|
|
592
605
|
|
|
593
|
-
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
const
|
|
597
|
-
|
|
606
|
+
const leave = admit(id)
|
|
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
|
|
598
615
|
// Crash-isolated observer hook — a throwing onStart must not abort
|
|
599
|
-
// the dispatch loop (it would strand the tick with
|
|
616
|
+
// the dispatch loop (it would strand the tick with the slot held).
|
|
600
617
|
try {
|
|
601
618
|
onStart?.(node)
|
|
602
619
|
} catch (err) {
|
|
@@ -607,33 +624,45 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
607
624
|
// `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
|
|
608
625
|
// rejection arm handles ONLY `execute()` rejecting; a throw from
|
|
609
626
|
// the fulfillment arm (finishOne / onFinish / tick) must NOT also
|
|
610
|
-
// run the rejection arm, or `active
|
|
611
|
-
//
|
|
612
|
-
// wedging the solo-clamp gate.
|
|
627
|
+
// run the rejection arm, or `active` releases twice — and a
|
|
628
|
+
// double release wedges the count gate.
|
|
613
629
|
execute(node, upstream).then(
|
|
614
|
-
(
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
630
|
+
(raw) => {
|
|
631
|
+
const outcome = withHold(raw)
|
|
632
|
+
leave()
|
|
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
|
+
}
|
|
618
642
|
tick()
|
|
643
|
+
const done = (): void => {
|
|
644
|
+
finishOne(id, outcome)
|
|
645
|
+
tick()
|
|
646
|
+
}
|
|
647
|
+
void settled.then(done, done)
|
|
619
648
|
},
|
|
620
649
|
(err: unknown) => {
|
|
621
650
|
const message = err instanceof Error ? err.message : String(err)
|
|
622
|
-
const outcome: TaskOutcome = {
|
|
651
|
+
const outcome: TaskOutcome = withHold({
|
|
623
652
|
node,
|
|
624
653
|
status: 'failed',
|
|
625
654
|
exitCode: 1,
|
|
626
655
|
durationMs: 0,
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
|
|
656
|
+
})
|
|
657
|
+
leave()
|
|
658
|
+
untrack()
|
|
630
659
|
finishOne(id, outcome)
|
|
631
660
|
// Surface the error live; the outcome itself doesn't
|
|
632
661
|
// carry captured stderr (that's the logger's job). A
|
|
633
662
|
// UserError is a config/input failure (e.g. a failed
|
|
634
663
|
// `cache.inputs.runtime` command), not a vx bug — report it
|
|
635
664
|
// plainly, never as an "internal error".
|
|
636
|
-
if (err
|
|
665
|
+
if (isUserError(err)) {
|
|
637
666
|
process.stderr.write(`[vx] ${id}: ${message}\n`)
|
|
638
667
|
} else {
|
|
639
668
|
const named = err instanceof Error && err.name !== 'Error' ? `${err.name}: ` : ''
|
|
@@ -648,7 +677,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
648
677
|
// is exactly preserved for the next tick's admission pass.
|
|
649
678
|
for (const [id, seq] of parked) execReady.push(id, seq)
|
|
650
679
|
|
|
651
|
-
if (outcomes.size === nodes.size && active === 0) {
|
|
680
|
+
if (outcomes.size === nodes.size && active === 0 && activeRestore === 0) {
|
|
652
681
|
resolved = true
|
|
653
682
|
resolve(outcomes)
|
|
654
683
|
}
|
|
@@ -658,7 +687,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
658
687
|
})
|
|
659
688
|
}
|
|
660
689
|
|
|
661
|
-
function mergePriorities(
|
|
690
|
+
export function mergePriorities(
|
|
662
691
|
baseline: ReadonlyMap<string, number>,
|
|
663
692
|
overrides: ReadonlyMap<string, number>,
|
|
664
693
|
): ReadonlyMap<string, number> {
|