@vzn/vx 0.0.12 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -89
- package/launcher.mjs +36 -28
- package/package.json +5 -5
- package/src/cache/cas-backend.ts +2 -2
- package/src/cache/index.ts +1 -2
- package/src/cache/inputs.ts +6 -0
- package/src/cache/layered-cache.ts +22 -2
- package/src/cli/cache.ts +5 -10
- package/src/cli/help.ts +8 -2
- package/src/cli/info.ts +0 -3
- package/src/cli/run.ts +32 -10
- package/src/config.ts +40 -1
- package/src/graph/dependency-spec.ts +16 -0
- package/src/graph/index.ts +10 -1
- package/src/graph/scheduler.ts +230 -35
- package/src/graph/task-graph.ts +69 -17
- package/src/index.ts +29 -4
- package/src/orchestrator/execute-task.ts +66 -10
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/metrics.ts +351 -0
- package/src/orchestrator/options.ts +26 -5
- package/src/orchestrator/plugin-host.ts +5 -10
- package/src/orchestrator/prepare.ts +20 -11
- package/src/orchestrator/protocol.ts +16 -2
- package/src/orchestrator/resources.ts +59 -0
- package/src/orchestrator/run.ts +45 -0
- package/src/orchestrator/summary.ts +24 -0
- package/src/orchestrator/task-hash.ts +17 -2
- package/src/orchestrator/telemetry.ts +9 -1
- package/src/orchestrator/upstream.ts +34 -6
- package/src/orchestrator/verify.ts +34 -11
- package/src/util/index.ts +1 -0
- package/src/util/size.ts +16 -0
- package/src/workspace/project-loader.ts +43 -1
- package/src/cache/remote-cache.ts +0 -251
- package/src/orchestrator/remote-cache-setup.ts +0 -60
package/src/cli/run.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from '../orchestrator/index.js'
|
|
27
27
|
import type { ContinueMode } from '../graph/index.js'
|
|
28
28
|
import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
|
|
29
|
+
import { parseSize } from '../util/index.js'
|
|
29
30
|
import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
|
|
30
31
|
import { localBackend } from './backend.js'
|
|
31
32
|
|
|
@@ -68,13 +69,21 @@ export interface RunArgs {
|
|
|
68
69
|
*/
|
|
69
70
|
timeout: number | undefined
|
|
70
71
|
/**
|
|
71
|
-
* `--
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* undeclared reads. `allow` (from `--verify-allow=<pkg#task>,…`) exempts
|
|
75
|
-
* tasks from failing the run on a divergence.
|
|
72
|
+
* `--memory <size>` / `--memory=<size>`: memory budget (resolved to
|
|
73
|
+
* bytes) for `exec.resources.memory` reservations. Defaults to
|
|
74
|
+
* os.totalmem() when not passed — pass it in cgroup-limited containers.
|
|
76
75
|
*/
|
|
77
|
-
|
|
76
|
+
memory: number | undefined
|
|
77
|
+
/**
|
|
78
|
+
* `--verify[=determinism|inputs|fingerprint|all]`: cache-correctness
|
|
79
|
+
* verification. Undefined when not passed. `determinism` re-runs and
|
|
80
|
+
* content-compares outputs; `inputs` sandboxes with the declared-input
|
|
81
|
+
* baseline and flags undeclared reads; `fingerprint` ships output-tree
|
|
82
|
+
* fingerprints for the cross-machine diff (no re-run; the determinism
|
|
83
|
+
* modes set it too, for free). `allow` (from `--verify-allow=<pkg#task>,…`)
|
|
84
|
+
* exempts tasks from failing the run on a divergence.
|
|
85
|
+
*/
|
|
86
|
+
verify: { determinism: boolean; inputs: boolean; fingerprint: boolean } | undefined
|
|
78
87
|
verifyAllow: string[]
|
|
79
88
|
outputLogs?: 'full' | 'errors-only' | 'none'
|
|
80
89
|
forwardArgs: string[]
|
|
@@ -108,6 +117,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
108
117
|
frozen: false,
|
|
109
118
|
retries: undefined,
|
|
110
119
|
timeout: undefined,
|
|
120
|
+
memory: undefined,
|
|
111
121
|
verify: undefined,
|
|
112
122
|
verifyAllow: [],
|
|
113
123
|
forwardArgs: [],
|
|
@@ -174,18 +184,28 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
174
184
|
return { ...out, error: `--timeout must be a positive integer (ms), got: ${v}` }
|
|
175
185
|
}
|
|
176
186
|
out.timeout = n
|
|
187
|
+
} else if (a === '--memory' || a?.startsWith('--memory=')) {
|
|
188
|
+
const v = a === '--memory' ? before[++i] : a.slice('--memory='.length)
|
|
189
|
+
if (v === undefined || v === '') return { ...out, error: `--memory requires a value` }
|
|
190
|
+
const bytes = parseSize(v)
|
|
191
|
+
if (bytes === null || bytes <= 0) {
|
|
192
|
+
return { ...out, error: `--memory must be a size like 8GB or 512MB, got: ${v}` }
|
|
193
|
+
}
|
|
194
|
+
out.memory = bytes
|
|
177
195
|
} else if (a === '--verify' || a?.startsWith('--verify=')) {
|
|
178
196
|
const what = a === '--verify' ? 'determinism' : a.slice('--verify='.length)
|
|
179
197
|
if (what === 'determinism') {
|
|
180
|
-
out.verify = { determinism: true, inputs: false }
|
|
198
|
+
out.verify = { determinism: true, inputs: false, fingerprint: true }
|
|
181
199
|
} else if (what === 'inputs') {
|
|
182
|
-
out.verify = { determinism: false, inputs: true }
|
|
200
|
+
out.verify = { determinism: false, inputs: true, fingerprint: false }
|
|
201
|
+
} else if (what === 'fingerprint') {
|
|
202
|
+
out.verify = { determinism: false, inputs: false, fingerprint: true }
|
|
183
203
|
} else if (what === 'all') {
|
|
184
|
-
out.verify = { determinism: true, inputs: true }
|
|
204
|
+
out.verify = { determinism: true, inputs: true, fingerprint: true }
|
|
185
205
|
} else {
|
|
186
206
|
return {
|
|
187
207
|
...out,
|
|
188
|
-
error: `--verify must be determinism | inputs | all (or bare --verify), got: ${what}`,
|
|
208
|
+
error: `--verify must be determinism | inputs | fingerprint | all (or bare --verify), got: ${what}`,
|
|
189
209
|
}
|
|
190
210
|
}
|
|
191
211
|
} else if (a?.startsWith('--verify-allow=')) {
|
|
@@ -405,11 +425,13 @@ export async function resolveRunOptions(
|
|
|
405
425
|
if (projects !== undefined) opts.projects = projects
|
|
406
426
|
if (parsed.retries !== undefined) opts.retries = parsed.retries
|
|
407
427
|
if (parsed.timeout !== undefined) opts.timeout = parsed.timeout
|
|
428
|
+
if (parsed.memory !== undefined) opts.memory = parsed.memory
|
|
408
429
|
if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
|
|
409
430
|
if (parsed.verify !== undefined) {
|
|
410
431
|
opts.verify = {
|
|
411
432
|
determinism: parsed.verify.determinism,
|
|
412
433
|
inputs: parsed.verify.inputs,
|
|
434
|
+
fingerprint: parsed.verify.fingerprint,
|
|
413
435
|
allow: new Set(parsed.verifyAllow),
|
|
414
436
|
}
|
|
415
437
|
}
|
package/src/config.ts
CHANGED
|
@@ -239,6 +239,16 @@ export interface ExecConfig {
|
|
|
239
239
|
* default, including `retries: 0`.
|
|
240
240
|
*/
|
|
241
241
|
retries?: number
|
|
242
|
+
/**
|
|
243
|
+
* Resource RESERVATIONS for admission control — NOT enforcement: vx
|
|
244
|
+
* does not cgroup-limit, nice, or kill the task; it only decides what
|
|
245
|
+
* to co-schedule so concurrent reservations never exceed the CPU /
|
|
246
|
+
* memory budget. Each axis defaults to 0 = reserve nothing: the task
|
|
247
|
+
* runs subject only to the concurrency-count limit. A pure scheduling
|
|
248
|
+
* hint — the whole object is stripped from the cache key, so tuning a
|
|
249
|
+
* reservation never invalidates a cached result.
|
|
250
|
+
*/
|
|
251
|
+
resources?: ResourcesConfig
|
|
242
252
|
/**
|
|
243
253
|
* Long-running / continuous task (dev server, watcher, daemon).
|
|
244
254
|
* When present, the task is spawned but the runner does NOT wait
|
|
@@ -255,6 +265,27 @@ export interface ExecConfig {
|
|
|
255
265
|
persistent?: PersistentConfig
|
|
256
266
|
}
|
|
257
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Per-task resource reservation (see `ExecConfig.resources`). Grouped so
|
|
270
|
+
* a future axis slots in without new top-level `exec` fields; the loader
|
|
271
|
+
* rejects unknown keys.
|
|
272
|
+
*/
|
|
273
|
+
export interface ResourcesConfig {
|
|
274
|
+
/**
|
|
275
|
+
* CPU units (fractional ok, e.g. `0.5`), or a `"<n>%"` string of the
|
|
276
|
+
* CPU budget (the run's `concurrency`). `cpus: "50%"` on a budget of 8
|
|
277
|
+
* reserves 4 units.
|
|
278
|
+
*/
|
|
279
|
+
cpus?: number | string
|
|
280
|
+
/**
|
|
281
|
+
* Bytes, a size string (`"2GB"`, `"512MB"` — K/M/G/T, powers of 1024),
|
|
282
|
+
* or a `"<n>%"` string of the memory budget (`os.totalmem()` unless
|
|
283
|
+
* overridden with `--memory` — pass `--memory` in cgroup-limited
|
|
284
|
+
* containers, where `os.totalmem()` reports the HOST's RAM).
|
|
285
|
+
*/
|
|
286
|
+
memory?: number | string
|
|
287
|
+
}
|
|
288
|
+
|
|
258
289
|
export interface PersistentConfig {
|
|
259
290
|
/**
|
|
260
291
|
* Regex pattern (string) matched against stdout/stderr line-by-line.
|
|
@@ -394,8 +425,16 @@ export interface CacheInputs {
|
|
|
394
425
|
* here, so any `^`-prefixed string is allowed.
|
|
395
426
|
* - `` `${string}#${string}` `` — a specific package's task
|
|
396
427
|
* (`'pkg#build'`); cross-project, not key-checked.
|
|
428
|
+
* - `` `${string}*${string}` `` — a task-name pattern (`'build.*'`);
|
|
429
|
+
* expands at graph build, so it can't be
|
|
430
|
+
* key-checked (a bare `'*'` still fails at
|
|
431
|
+
* runtime — bare wildcards are filter-only).
|
|
397
432
|
*/
|
|
398
|
-
type DependsOnEntry<K extends string> =
|
|
433
|
+
type DependsOnEntry<K extends string> =
|
|
434
|
+
| K
|
|
435
|
+
| `^${string}`
|
|
436
|
+
| `${string}#${string}`
|
|
437
|
+
| `${string}*${string}`
|
|
399
438
|
|
|
400
439
|
/**
|
|
401
440
|
* Identity function — exists only so TypeScript narrows literal types
|
|
@@ -32,6 +32,22 @@ export class DependencySpecError extends Error {
|
|
|
32
32
|
* builder vs the filter) decide which ones make semantic sense and
|
|
33
33
|
* surface their own validation errors. This keeps the parser pure.
|
|
34
34
|
*/
|
|
35
|
+
/** True when a task form is a `*` pattern (`build.*`) rather than an exact name. */
|
|
36
|
+
export function isTaskPattern(task: string): boolean {
|
|
37
|
+
return task.includes('*')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compile a `*`-only glob over task names: `build.*` → /^build\..*$/.
|
|
42
|
+
* `*` is the sole metacharacter — everything else matches literally, so the
|
|
43
|
+
* dotted-namespace convention (`build.bun.linux-x64`) needs no escaping by
|
|
44
|
+
* the user.
|
|
45
|
+
*/
|
|
46
|
+
export function compileTaskPattern(pattern: string): RegExp {
|
|
47
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
|
|
48
|
+
return new RegExp(`^${escaped}$`)
|
|
49
|
+
}
|
|
50
|
+
|
|
35
51
|
export function parseDependencySpec(raw: string): DependencySpec {
|
|
36
52
|
if (raw.length === 0) throw new DependencySpecError(raw, 'empty spec')
|
|
37
53
|
|
package/src/graph/index.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
// Module contract. Cross-module imports must come through here; see
|
|
2
2
|
// docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
|
|
3
3
|
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
DependencySpecError,
|
|
6
|
+
compileTaskPattern,
|
|
7
|
+
isTaskPattern,
|
|
8
|
+
parseDependencySpec,
|
|
9
|
+
type DependencySpec,
|
|
10
|
+
} from './dependency-spec.js'
|
|
5
11
|
export {
|
|
6
12
|
type ContinueMode,
|
|
13
|
+
type OutputFingerprint,
|
|
14
|
+
type ResourceCost,
|
|
7
15
|
runGraph,
|
|
8
16
|
type TaskOutcome,
|
|
9
17
|
type TaskStatus,
|
|
10
18
|
type VerifyVerdict,
|
|
19
|
+
ZERO_COST,
|
|
11
20
|
} from './scheduler.js'
|
|
12
21
|
export {
|
|
13
22
|
buildTaskGraph,
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -42,6 +42,31 @@ export type VerifyVerdict =
|
|
|
42
42
|
/** Didn't execute (cache hit) / not cacheable / group / persistent. */
|
|
43
43
|
| { kind: 'not-verified' }
|
|
44
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
|
+
|
|
45
70
|
export interface TaskOutcome {
|
|
46
71
|
node: TaskNode
|
|
47
72
|
status: TaskStatus
|
|
@@ -94,10 +119,30 @@ export interface TaskOutcome {
|
|
|
94
119
|
* mode; a plain run leaves it undefined. Pure side-channel (never hashed).
|
|
95
120
|
*/
|
|
96
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
|
|
97
128
|
}
|
|
98
129
|
|
|
99
130
|
export type ContinueMode = 'never' | 'deps-ok' | 'always'
|
|
100
131
|
|
|
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
|
+
|
|
101
146
|
export interface ScheduleOptions {
|
|
102
147
|
nodes: Map<string, TaskNode>
|
|
103
148
|
concurrency: number
|
|
@@ -147,6 +192,18 @@ export interface ScheduleOptions {
|
|
|
147
192
|
* second cache.get. When undefined/empty, behavior is byte-identical.
|
|
148
193
|
*/
|
|
149
194
|
restoreTier?: ReadonlySet<string>
|
|
195
|
+
/**
|
|
196
|
+
* Resolved per-task resource reservations (`exec.resources`, resolved
|
|
197
|
+
* by the orchestrator against the run's budgets). An absent id means
|
|
198
|
+
* zero cost; undefined/empty means no task opted in — the scheduler
|
|
199
|
+
* takes the legacy path byte-identically. Admission control only —
|
|
200
|
+
* nothing is enforced on the child process.
|
|
201
|
+
*/
|
|
202
|
+
resourceCosts?: ReadonlyMap<string, ResourceCost>
|
|
203
|
+
/** CPU budget reservations pack against. Defaults to `concurrency`. */
|
|
204
|
+
cpuBudget?: number
|
|
205
|
+
/** Memory budget (bytes). Defaults to Infinity (axis off). */
|
|
206
|
+
memBudget?: number
|
|
150
207
|
}
|
|
151
208
|
|
|
152
209
|
/**
|
|
@@ -269,9 +326,14 @@ class ReadyHeap {
|
|
|
269
326
|
this.seq[i] = this.seq[j]!
|
|
270
327
|
this.seq[j] = si
|
|
271
328
|
}
|
|
272
|
-
|
|
329
|
+
/**
|
|
330
|
+
* `seq` defaults to a fresh monotonic counter. A parked-then-repushed
|
|
331
|
+
* task passes its ORIGINAL seq back in so FIFO-among-equals survives
|
|
332
|
+
* the round trip (a fresh seq would demote it behind later arrivals).
|
|
333
|
+
*/
|
|
334
|
+
push(id: string, seq: number = this.next++): void {
|
|
273
335
|
this.ids.push(id)
|
|
274
|
-
this.seq.push(
|
|
336
|
+
this.seq.push(seq)
|
|
275
337
|
let i = this.ids.length - 1
|
|
276
338
|
while (i > 0) {
|
|
277
339
|
const parent = (i - 1) >> 1
|
|
@@ -280,6 +342,10 @@ class ReadyHeap {
|
|
|
280
342
|
i = parent
|
|
281
343
|
}
|
|
282
344
|
}
|
|
345
|
+
/** The head's enqueue seq (capture before `pop` for a possible repush). */
|
|
346
|
+
peekSeq(): number {
|
|
347
|
+
return this.seq[0] ?? -1
|
|
348
|
+
}
|
|
283
349
|
pop(): string | undefined {
|
|
284
350
|
const n = this.ids.length
|
|
285
351
|
if (n === 0) return undefined
|
|
@@ -362,11 +428,89 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
362
428
|
let active = 0
|
|
363
429
|
let resolved = false
|
|
364
430
|
|
|
431
|
+
// Resource admission (2-D bin packing over the count limit). Inactive
|
|
432
|
+
// (no task opted in) → the tick loop short-circuits before any of this
|
|
433
|
+
// and behaves byte-identically to the count-only scheduler.
|
|
434
|
+
const costs = options.resourceCosts
|
|
435
|
+
const resourcesActive = costs !== undefined && costs.size > 0
|
|
436
|
+
const cpuBudget = options.cpuBudget ?? concurrency
|
|
437
|
+
const memBudget = options.memBudget ?? Infinity
|
|
438
|
+
// Reservations are FLOAT sums (fractional cpus, and percent-of-budget
|
|
439
|
+
// resolves to non-representable values like `0.30000000000000004`), so
|
|
440
|
+
// add/release cycles leave ~1e-17 residue instead of an exact 0. Two
|
|
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
|
|
456
|
+
|
|
457
|
+
// A restore-tier task is a confirmed local cache hit: its "execution"
|
|
458
|
+
// is a cheap tar extract, not the task's real work — it reserves ZERO
|
|
459
|
+
// regardless of what the config declares, so it never holds budget
|
|
460
|
+
// against a real executor (and never parks).
|
|
461
|
+
const costOf = (id: string): ResourceCost =>
|
|
462
|
+
options.restoreTier?.has(id) ? ZERO_COST : (costs?.get(id) ?? ZERO_COST)
|
|
463
|
+
|
|
464
|
+
// Zero never blocks; a within-budget cost needs headroom (with an
|
|
465
|
+
// exact-fill epsilon); an over-budget cost can never have headroom, so
|
|
466
|
+
// it solo-clamps: admitted only when the axis is idle (no holders — an
|
|
467
|
+
// idle pool always admits at least one ready task, no deadlock).
|
|
468
|
+
const fitsAxis = (cost: number, reserved: number, holders: number, budget: number): boolean =>
|
|
469
|
+
cost === 0 ? true : cost <= budget ? reserved + cost <= budget + budget * 1e-9 : holders === 0
|
|
470
|
+
|
|
471
|
+
const fits = (id: string): boolean => {
|
|
472
|
+
const c = costOf(id)
|
|
473
|
+
return (
|
|
474
|
+
fitsAxis(c.cpu, reservedCpu, holdersCpu, cpuBudget) &&
|
|
475
|
+
fitsAxis(c.mem, reservedMem, holdersMem, memBudget)
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Reserve/release keep the float sum AND the integer holder count in
|
|
480
|
+
// lockstep; releasing the last holder on an axis snaps its sum to 0.
|
|
481
|
+
const reserve = (c: ResourceCost): void => {
|
|
482
|
+
if (c.cpu > 0) {
|
|
483
|
+
reservedCpu += c.cpu
|
|
484
|
+
holdersCpu++
|
|
485
|
+
}
|
|
486
|
+
if (c.mem > 0) {
|
|
487
|
+
reservedMem += c.mem
|
|
488
|
+
holdersMem++
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const release = (c: ResourceCost): void => {
|
|
492
|
+
if (c.cpu > 0 && --holdersCpu === 0) reservedCpu = 0
|
|
493
|
+
else reservedCpu -= c.cpu
|
|
494
|
+
if (c.mem > 0 && --holdersMem === 0) reservedMem = 0
|
|
495
|
+
else reservedMem -= c.mem
|
|
496
|
+
}
|
|
497
|
+
|
|
365
498
|
return new Promise<Map<string, TaskOutcome>>((resolve) => {
|
|
366
499
|
const finishOne = (id: string, outcome: TaskOutcome): void => {
|
|
367
500
|
if (continueMode === 'never' && outcome.status === 'failed') failFastTripped = true
|
|
368
501
|
outcomes.set(id, outcome)
|
|
369
|
-
|
|
502
|
+
// Observer hook (the logger's taskComplete). Crash-isolated: a
|
|
503
|
+
// throwing observer must NOT break scheduling — it would otherwise
|
|
504
|
+
// skip the dependent-enqueue + tick below and hang the run. Same
|
|
505
|
+
// "observability never breaks a run" rule the telemetry/eventSink
|
|
506
|
+
// paths hold. Isolated here (not the completion arm) so a throw
|
|
507
|
+
// can't be mistaken for the task itself failing.
|
|
508
|
+
try {
|
|
509
|
+
onFinish?.(outcome)
|
|
510
|
+
} catch (err) {
|
|
511
|
+
const m = err instanceof Error ? err.message : String(err)
|
|
512
|
+
process.stderr.write(`[vx] onFinish observer threw for ${id}: ${m}\n`)
|
|
513
|
+
}
|
|
370
514
|
const ds = dependents.get(id)
|
|
371
515
|
if (!ds) return
|
|
372
516
|
for (const d of ds) {
|
|
@@ -378,57 +522,102 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
378
522
|
}
|
|
379
523
|
}
|
|
380
524
|
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
525
|
+
// True when the task would be finished as `skipped` without running
|
|
526
|
+
// (fail-fast tripped, or a failed/skipped upstream under
|
|
527
|
+
// continueMode !== 'always'). ONE predicate shared by the admission
|
|
528
|
+
// parker and the dispatch loop's skip branch — a would-skip task
|
|
529
|
+
// executes nothing, so it must never park on a resource fit (a
|
|
530
|
+
// too-big doomed task parking forever would hang the run).
|
|
531
|
+
//
|
|
532
|
+
// Restore-tier tasks BYPASS the dep check: their key is independent
|
|
533
|
+
// of any dep's success (pure-input transitive hashing), so a valid
|
|
534
|
+
// cached output is reported `cache-hit` even if a dep failed — and
|
|
535
|
+
// they're dep-independent, so they typically restore before a dep
|
|
536
|
+
// could fail anyway.
|
|
537
|
+
const willSkip = (id: string): boolean => {
|
|
538
|
+
if (failFastTripped) return true
|
|
539
|
+
if (restoreTier?.has(id)) return false
|
|
540
|
+
if (continueMode === 'always') return false
|
|
541
|
+
const node = nodes.get(id) as TaskNode
|
|
542
|
+
return node.deps.some((d) => {
|
|
543
|
+
const u = outcomes.get(d)
|
|
544
|
+
return u?.status === 'failed' || u?.status === 'skipped'
|
|
545
|
+
})
|
|
546
|
+
}
|
|
385
547
|
|
|
386
548
|
const tick = (): void => {
|
|
387
549
|
if (resolved) return
|
|
550
|
+
// Exec-tier tasks parked THIS tick on a failed resource fit.
|
|
551
|
+
// Within one synchronous tick `reserved` only increases (release
|
|
552
|
+
// happens in the async completion callbacks, which run a fresh
|
|
553
|
+
// tick), so a task that doesn't fit now cannot fit later in the
|
|
554
|
+
// same tick — parking it for the tick's remainder is exact, and
|
|
555
|
+
// each id pops at most once per tick (O(R log R)).
|
|
556
|
+
const parked: Array<[string, number]> = []
|
|
388
557
|
|
|
389
|
-
|
|
390
|
-
|
|
558
|
+
// Highest-priority admissible task: exec tier first (misses own
|
|
559
|
+
// the pool), then restore tier. With no reservations declared this
|
|
560
|
+
// short-circuits to exactly the legacy takeReady. A would-skip
|
|
561
|
+
// task returns without a fit check (finishing it is free); restore
|
|
562
|
+
// tasks cost zero by construction, so they never park.
|
|
563
|
+
const takeFitting = (): string | undefined => {
|
|
564
|
+
while (execReady.size > 0) {
|
|
565
|
+
const seq = execReady.peekSeq()
|
|
566
|
+
const id = execReady.pop() as string
|
|
567
|
+
if (!resourcesActive || willSkip(id) || fits(id)) return id
|
|
568
|
+
parked.push([id, seq])
|
|
569
|
+
}
|
|
570
|
+
return restoreReady.pop()
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
while (active < concurrency) {
|
|
574
|
+
const id = takeFitting()
|
|
575
|
+
if (id === undefined) break // nothing ready is admissible right now
|
|
391
576
|
const node = nodes.get(id) as TaskNode
|
|
392
|
-
const isRestore = restoreTier?.has(id) === true
|
|
393
577
|
|
|
394
|
-
// If any upstream failed/skipped, propagate skip synchronously
|
|
395
|
-
// without running. Skipped tasks still flow through this queue
|
|
396
|
-
// because dependents are pushed when `pending` hits 0 regardless
|
|
397
|
-
// of outcome — keeps the propagation logic in one place.
|
|
398
|
-
//
|
|
399
|
-
// Restore-tier tasks BYPASS this check: their key is independent
|
|
400
|
-
// of any dep's success (pure-input transitive hashing), so a
|
|
401
|
-
// valid cached output is reported `cache-hit` even if a dep
|
|
402
|
-
// failed — and they're dep-independent, so they typically
|
|
403
|
-
// restore before a dep could fail anyway.
|
|
404
|
-
//
|
|
405
578
|
// For a restore-tier task running BEFORE its deps finish, the
|
|
406
579
|
// `upstream` entries below can be undefined (the cast lies) —
|
|
407
580
|
// the preProbed hit path in execute-task never reads them, and
|
|
408
581
|
// nothing on the restore path may.
|
|
409
582
|
const upstream = node.deps.map((d) => outcomes.get(d) as TaskOutcome)
|
|
410
|
-
|
|
583
|
+
|
|
584
|
+
// If any upstream failed/skipped, propagate skip synchronously
|
|
585
|
+
// without running. Skipped tasks still flow through this queue
|
|
586
|
+
// because dependents are pushed when `pending` hits 0 regardless
|
|
587
|
+
// of outcome — keeps the propagation logic in one place.
|
|
588
|
+
if (willSkip(id)) {
|
|
411
589
|
finishOne(id, { node, status: 'skipped', exitCode: 1, durationMs: 0 })
|
|
412
590
|
continue
|
|
413
591
|
}
|
|
414
|
-
if (!isRestore && continueMode !== 'always') {
|
|
415
|
-
const failedDep = upstream.find((u) => u.status === 'failed' || u.status === 'skipped')
|
|
416
|
-
if (failedDep) {
|
|
417
|
-
finishOne(id, { node, status: 'skipped', exitCode: 1, durationMs: 0 })
|
|
418
|
-
continue
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
592
|
|
|
422
593
|
active++
|
|
423
|
-
|
|
594
|
+
// Reserve on dispatch; capture the cost so the release in the
|
|
595
|
+
// completion callbacks is symmetric even if the maps change.
|
|
596
|
+
const cost = costOf(id)
|
|
597
|
+
reserve(cost)
|
|
598
|
+
// Crash-isolated observer hook — a throwing onStart must not abort
|
|
599
|
+
// the dispatch loop (it would strand the tick with reservations held).
|
|
600
|
+
try {
|
|
601
|
+
onStart?.(node)
|
|
602
|
+
} catch (err) {
|
|
603
|
+
const m = err instanceof Error ? err.message : String(err)
|
|
604
|
+
process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
|
|
605
|
+
}
|
|
424
606
|
|
|
425
|
-
|
|
426
|
-
|
|
607
|
+
// `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
|
|
608
|
+
// rejection arm handles ONLY `execute()` rejecting; a throw from
|
|
609
|
+
// the fulfillment arm (finishOne / onFinish / tick) must NOT also
|
|
610
|
+
// run the rejection arm, or `active`/`reserved` release twice —
|
|
611
|
+
// and a double release drives `reserved` negative, permanently
|
|
612
|
+
// wedging the solo-clamp gate.
|
|
613
|
+
execute(node, upstream).then(
|
|
614
|
+
(outcome) => {
|
|
427
615
|
active--
|
|
616
|
+
release(cost)
|
|
428
617
|
finishOne(id, outcome)
|
|
429
618
|
tick()
|
|
430
|
-
}
|
|
431
|
-
|
|
619
|
+
},
|
|
620
|
+
(err: unknown) => {
|
|
432
621
|
const message = err instanceof Error ? err.message : String(err)
|
|
433
622
|
const outcome: TaskOutcome = {
|
|
434
623
|
node,
|
|
@@ -437,6 +626,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
437
626
|
durationMs: 0,
|
|
438
627
|
}
|
|
439
628
|
active--
|
|
629
|
+
release(cost)
|
|
440
630
|
finishOne(id, outcome)
|
|
441
631
|
// Surface the error live; the outcome itself doesn't
|
|
442
632
|
// carry captured stderr (that's the logger's job). A
|
|
@@ -450,9 +640,14 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
450
640
|
process.stderr.write(`[vx] internal error in ${id}: ${named}${message}\n`)
|
|
451
641
|
}
|
|
452
642
|
tick()
|
|
453
|
-
}
|
|
643
|
+
},
|
|
644
|
+
)
|
|
454
645
|
}
|
|
455
646
|
|
|
647
|
+
// Repush parked ids with their ORIGINAL seqs — FIFO-among-equals
|
|
648
|
+
// is exactly preserved for the next tick's admission pass.
|
|
649
|
+
for (const [id, seq] of parked) execReady.push(id, seq)
|
|
650
|
+
|
|
456
651
|
if (outcomes.size === nodes.size && active === 0) {
|
|
457
652
|
resolved = true
|
|
458
653
|
resolve(outcomes)
|