@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/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
- * `--verify[=determinism|inputs|all]`: cache-correctness verification.
72
- * Undefined when not passed. `determinism` re-runs and content-compares
73
- * outputs; `inputs` sandboxes with the declared-input baseline and flags
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
- verify: { determinism: boolean; inputs: boolean } | undefined
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> = K | `^${string}` | `${string}#${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
 
@@ -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 { DependencySpecError, parseDependencySpec, type DependencySpec } from './dependency-spec.js'
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,
@@ -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
- push(id: string): void {
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(this.next++)
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
- onFinish?.(outcome)
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
- // Drain a worker slot: prefer an exec-tier task (misses own the
382
- // pool); only when none is ready does a restore-tier task backfill.
383
- const takeReady = (): string | undefined =>
384
- execReady.size > 0 ? execReady.pop() : restoreReady.pop()
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
- while (active < concurrency && (execReady.size > 0 || restoreReady.size > 0)) {
390
- const id = takeReady() as string
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
- if (failFastTripped) {
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
- onStart?.(node)
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
- execute(node, upstream)
426
- .then((outcome) => {
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
- .catch((err: unknown) => {
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)