@vzn/vx 0.0.646 → 0.0.649
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 +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
package/src/graph/scheduler.ts
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
isOutOfFds,
|
|
5
5
|
isUserError,
|
|
6
6
|
OUT_OF_FDS_HINT,
|
|
7
|
+
thrownMessage,
|
|
8
|
+
ui,
|
|
7
9
|
} from '../util/index.js'
|
|
8
10
|
import {
|
|
9
11
|
computeReverseDepCount,
|
|
@@ -141,6 +143,12 @@ export interface TaskOutcome {
|
|
|
141
143
|
* read this one field (item 267).
|
|
142
144
|
*/
|
|
143
145
|
blockedBy?: string
|
|
146
|
+
/**
|
|
147
|
+
* On a `skipped` outcome: its `exec.platforms` leave out this machine.
|
|
148
|
+
* Its dependants are skipped with `blockedBy` naming it, and neither
|
|
149
|
+
* fails the run.
|
|
150
|
+
*/
|
|
151
|
+
otherPlatform?: { readonly needs: readonly string[]; readonly host: string }
|
|
144
152
|
/**
|
|
145
153
|
* On a `failed` outcome: vx's own `timeout` killed the final attempt.
|
|
146
154
|
* The exit is the shell's 143, and every label says "timed out" instead
|
|
@@ -165,6 +173,12 @@ export interface TaskOutcome {
|
|
|
165
173
|
* the spawn itself failed. Every label reads this instead of the exit.
|
|
166
174
|
*/
|
|
167
175
|
notReady?: 'timeout' | 'exited' | 'spawn'
|
|
176
|
+
/**
|
|
177
|
+
* On a ready persistent task: its child exited on its own while the run
|
|
178
|
+
* went on — a setup step's 0 at ready, a crash's code after it — so no
|
|
179
|
+
* label calls it running.
|
|
180
|
+
*/
|
|
181
|
+
serverExit?: number
|
|
168
182
|
/**
|
|
169
183
|
* The input that changed while the command ran, failing the task under
|
|
170
184
|
* `inputStability.severity: 'error'` (workspace-relative; '' when vx
|
|
@@ -174,9 +188,13 @@ export interface TaskOutcome {
|
|
|
174
188
|
/** Executor-reported placement label (`ExecuteResult.where`) — set only
|
|
175
189
|
* when the task ran somewhere other than this host: telemetry and the task's row. */
|
|
176
190
|
where?: string
|
|
191
|
+
/** A persistent task each dependant's executor starts where it runs: nothing ran here, and it is no execution of its own. */
|
|
192
|
+
carried?: true
|
|
177
193
|
/** The cores and memory of the machine `where` names (`ExecuteResult.machine`). */
|
|
178
194
|
whereCores?: number
|
|
179
195
|
whereMemoryBytes?: number
|
|
196
|
+
/** `ExecuteResult.machine.cpuShare`: the share of a core its threads got there. */
|
|
197
|
+
cpuShare?: number
|
|
180
198
|
/** `'deferred'` when the task's outputs were left in the remote store
|
|
181
199
|
* (`--cache-download=none`) instead of landing on this machine. Absent for
|
|
182
200
|
* every ordinary outcome. Telemetry-only. */
|
|
@@ -210,7 +228,7 @@ export interface TaskOutcome {
|
|
|
210
228
|
failedAttempts?: readonly { endedAt: number; exitCode: number; timedOut?: true }[]
|
|
211
229
|
/**
|
|
212
230
|
* Set when this run proved the task flaky: its key failed after it had
|
|
213
|
-
* passed (this run's included) or it
|
|
231
|
+
* passed (this run's included) or it passed only on a retry. Its row carries it.
|
|
214
232
|
*/
|
|
215
233
|
flaky?: { passes: number; failures: number }
|
|
216
234
|
/**
|
|
@@ -303,14 +321,16 @@ export interface ScheduleOptions {
|
|
|
303
321
|
*/
|
|
304
322
|
settledOf?: (outcome: TaskOutcome) => Promise<Partial<TaskOutcome> | void> | undefined
|
|
305
323
|
/**
|
|
306
|
-
*
|
|
307
|
-
* dispatch it through `execute`. Asked of
|
|
308
|
-
* room for, after the skip check;
|
|
309
|
-
*
|
|
310
|
-
* unkeyed group runs nothing, and the
|
|
311
|
-
* around it were ~10 µs of each of a
|
|
324
|
+
* A task the caller settles in place: a thunk returning its outcome, or
|
|
325
|
+
* undefined to dispatch it through `execute`. Asked of a task its lane
|
|
326
|
+
* has room for, after the skip check; `onStart` hears it, then the thunk
|
|
327
|
+
* runs (a hit replays its output there), and its outcome lands in the
|
|
328
|
+
* same tick, holding no slot. An unkeyed group runs nothing, and the
|
|
329
|
+
* dispatch, the slot and the promise around it were ~10 µs of each of a
|
|
330
|
+
* warm run's 1,090 groups (X-192); a hit whose tree is current does no
|
|
331
|
+
* more. A restore-tier task's `upstream` may hold undefined entries.
|
|
312
332
|
*/
|
|
313
|
-
settleNow?: (node: TaskNode, upstream: TaskOutcome[]) => TaskOutcome | undefined
|
|
333
|
+
settleNow?: (node: TaskNode, upstream: TaskOutcome[]) => (() => TaskOutcome) | undefined
|
|
314
334
|
/**
|
|
315
335
|
* Optional priority override: callers pass their own per-node weight
|
|
316
336
|
* (e.g. `computePredictedPriorities` from the orchestrator's history
|
|
@@ -403,7 +423,12 @@ class ReadyHeap {
|
|
|
403
423
|
// was two per heap level on every push and pop.
|
|
404
424
|
private readonly prio: number[] = []
|
|
405
425
|
private next = 0
|
|
406
|
-
|
|
426
|
+
/** Queued ids `counts` holds true for. */
|
|
427
|
+
counted = 0
|
|
428
|
+
constructor(
|
|
429
|
+
private priority: ReadonlyMap<string, number>,
|
|
430
|
+
private readonly counts?: (id: string) => boolean,
|
|
431
|
+
) {}
|
|
407
432
|
get size(): number {
|
|
408
433
|
return this.ids.length
|
|
409
434
|
}
|
|
@@ -458,6 +483,7 @@ class ReadyHeap {
|
|
|
458
483
|
* it holds without that argument.
|
|
459
484
|
*/
|
|
460
485
|
push(id: string, seq: number = this.next++): void {
|
|
486
|
+
if (this.counts?.(id) === true) this.counted++
|
|
461
487
|
this.ids.push(id)
|
|
462
488
|
this.seq.push(seq)
|
|
463
489
|
this.prio.push(this.priority.get(id) ?? 0)
|
|
@@ -477,6 +503,7 @@ class ReadyHeap {
|
|
|
477
503
|
const n = this.ids.length
|
|
478
504
|
if (n === 0) return undefined
|
|
479
505
|
const top = this.ids[0]!
|
|
506
|
+
if (this.counts?.(top) === true) this.counted--
|
|
480
507
|
const lastId = this.ids.pop()!
|
|
481
508
|
const lastSeq = this.seq.pop()!
|
|
482
509
|
const lastPrio = this.prio.pop()!
|
|
@@ -588,14 +615,50 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
588
615
|
// (`dormant`) until a dependant is demoted, which starts it, or until
|
|
589
616
|
// every dependant has finished, which settles it unspawned. Settled
|
|
590
617
|
// without an outcome: it is not part of the run, as a pruned task is not.
|
|
591
|
-
|
|
618
|
+
// A group between it and its hits is idle with it.
|
|
619
|
+
const idleGroups = new Set<string>()
|
|
620
|
+
const idle = idleServers(nodes, dependents, restoreTier, idleGroups)
|
|
621
|
+
for (const g of idleGroups) idle.add(g)
|
|
592
622
|
const dormant = new Set<string>()
|
|
623
|
+
// The idle tasks below `id`, through idle groups: those its outcome may
|
|
624
|
+
// let settle, and those its demotion wakes.
|
|
625
|
+
const idleBelow = (id: string, each: (p: string) => void): void => {
|
|
626
|
+
const stack = [...(nodes.get(id) as TaskNode).deps]
|
|
627
|
+
const seen = new Set<string>()
|
|
628
|
+
while (stack.length > 0) {
|
|
629
|
+
const p = stack.pop()!
|
|
630
|
+
if (seen.has(p) || !idle.has(p)) continue
|
|
631
|
+
seen.add(p)
|
|
632
|
+
each(p)
|
|
633
|
+
if (idleGroups.has(p)) stack.push(...(nodes.get(p) as TaskNode).deps)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// Every task above an idle one, through idle groups, has finished.
|
|
637
|
+
const settledAbove = (id: string): boolean => {
|
|
638
|
+
const stack = [id]
|
|
639
|
+
const seen = new Set<string>()
|
|
640
|
+
while (stack.length > 0) {
|
|
641
|
+
for (const d of dependents.get(stack.pop()!) ?? []) {
|
|
642
|
+
if (outcomes.has(d)) continue
|
|
643
|
+
if (!idleGroups.has(d) || !idle.has(d)) return false
|
|
644
|
+
if (!seen.has(d)) {
|
|
645
|
+
seen.add(d)
|
|
646
|
+
stack.push(d)
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return true
|
|
651
|
+
}
|
|
593
652
|
// Held servers not yet answered, and those of them that became ready.
|
|
594
653
|
const unanswered = new Set(options.heldServers?.keys() ?? [])
|
|
595
654
|
for (const id of idle) unanswered.delete(id)
|
|
596
655
|
const heldReady = new Set<string>()
|
|
597
656
|
let settledIdle = 0
|
|
598
|
-
|
|
657
|
+
// Counting those no pool takes: with every pool full, only one of them can start.
|
|
658
|
+
const execReady = new ReadyHeap(
|
|
659
|
+
priority,
|
|
660
|
+
options.poolOf === undefined ? undefined : (id) => options.poolOf!(id) === undefined,
|
|
661
|
+
)
|
|
599
662
|
const restoreReady = new ReadyHeap(priority)
|
|
600
663
|
options.reprioritize?.((weights) => {
|
|
601
664
|
const merged = mergePriorities(baseline, weights)
|
|
@@ -634,6 +697,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
634
697
|
const restoreRoom = (): boolean =>
|
|
635
698
|
serial ? active + activeRestore < 1 : activeRestore < restoreConcurrency
|
|
636
699
|
let resolved = false
|
|
700
|
+
let stopListening = (): void => {}
|
|
637
701
|
// A refusal's text → the first task that said it. A corrupt cache index
|
|
638
702
|
// reaches every lookup as one UserError; on bare stderr a repeat names the first.
|
|
639
703
|
const refusedBy = new Map<string, string>()
|
|
@@ -655,6 +719,18 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
655
719
|
// against a real executor (and never parks).
|
|
656
720
|
const poolOf = options.poolOf
|
|
657
721
|
const poolActive = new Map<string, number>()
|
|
722
|
+
// Every pool a task of the run is placed in, by name: a task's pool is
|
|
723
|
+
// fixed for the run, so read once here.
|
|
724
|
+
const pools = new Map<string, number>()
|
|
725
|
+
if (poolOf !== undefined)
|
|
726
|
+
for (const id of nodes.keys()) {
|
|
727
|
+
const p = poolOf(id)
|
|
728
|
+
if (p !== undefined) pools.set(p.name, p.capacity)
|
|
729
|
+
}
|
|
730
|
+
const poolRoom = (): boolean => {
|
|
731
|
+
for (const [name, capacity] of pools) if ((poolActive.get(name) ?? 0) < capacity) return true
|
|
732
|
+
return false
|
|
733
|
+
}
|
|
658
734
|
// A pooled task is admitted against ITS pool's capacity, a local one
|
|
659
735
|
// against `concurrency`; pooled tasks reserve no local resources. A
|
|
660
736
|
// restore is local disk work wherever its task was placed: on the pool's
|
|
@@ -723,7 +799,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
723
799
|
// A dormant server is asked again: the dispatch settles it once its
|
|
724
800
|
// last dependant is in.
|
|
725
801
|
if (dormant.size > 0) {
|
|
726
|
-
|
|
802
|
+
idleBelow(id, (p) => {
|
|
803
|
+
if (dormant.delete(p)) execReady.push(p)
|
|
804
|
+
})
|
|
727
805
|
}
|
|
728
806
|
// Observer hook (the logger's taskComplete). Crash-isolated: a
|
|
729
807
|
// throwing observer must NOT break scheduling — it would otherwise
|
|
@@ -734,8 +812,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
734
812
|
try {
|
|
735
813
|
onFinish?.(outcome)
|
|
736
814
|
} catch (err) {
|
|
737
|
-
const m =
|
|
738
|
-
|
|
815
|
+
const m = thrownMessage(err)
|
|
816
|
+
ui.say('warn', `[vx] onFinish observer threw for ${id}: ${m}\n`)
|
|
739
817
|
}
|
|
740
818
|
// A restore-tier task runs before its deps, but its DEPENDENTS keep
|
|
741
819
|
// the order `dependsOn` states: they are released once its own deps
|
|
@@ -756,16 +834,19 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
756
834
|
// one carries.
|
|
757
835
|
const blockedRestores = new Map<string, string | undefined>()
|
|
758
836
|
const heldRelease = new Set<string>()
|
|
759
|
-
const
|
|
760
|
-
if (inRestoreTier(id)
|
|
837
|
+
const blockOf = (id: string): void => {
|
|
838
|
+
if (inRestoreTier(id)) {
|
|
761
839
|
for (const dep of (nodes.get(id) as TaskNode).deps) {
|
|
762
840
|
const u = outcomes.get(dep)
|
|
763
|
-
if (u?.status === 'failed' || u?.status === 'aborted') {
|
|
841
|
+
if (continueMode !== 'always' && (u?.status === 'failed' || u?.status === 'aborted')) {
|
|
764
842
|
blockedRestores.set(id, dep)
|
|
765
843
|
break
|
|
766
844
|
}
|
|
767
845
|
if (u?.status === 'skipped') {
|
|
768
|
-
blockedRestores.set(
|
|
846
|
+
blockedRestores.set(
|
|
847
|
+
id,
|
|
848
|
+
u.blockedBy ?? (u.otherPlatform !== undefined ? dep : undefined),
|
|
849
|
+
)
|
|
769
850
|
break
|
|
770
851
|
}
|
|
771
852
|
if (blockedRestores.has(dep)) {
|
|
@@ -774,16 +855,33 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
774
855
|
}
|
|
775
856
|
}
|
|
776
857
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
858
|
+
}
|
|
859
|
+
// Its block, then its dependents: one whose pending hits 0 is enqueued,
|
|
860
|
+
// or, when it is a held hit, released in turn before the next sibling.
|
|
861
|
+
// A frame stack, not recursion: a chain of held hits is released in one
|
|
862
|
+
// walk, and recursion threw RangeError on a 30,000-deep one.
|
|
863
|
+
const release = (root: string): void => {
|
|
864
|
+
const frames: { ds: readonly string[]; i: number }[] = []
|
|
865
|
+
const enter = (id: string): void => {
|
|
866
|
+
blockOf(id)
|
|
867
|
+
const ds = dependents.get(id)
|
|
868
|
+
if (ds !== undefined) frames.push({ ds, i: 0 })
|
|
869
|
+
}
|
|
870
|
+
enter(root)
|
|
871
|
+
while (frames.length > 0) {
|
|
872
|
+
const f = frames[frames.length - 1]!
|
|
873
|
+
if (f.i === f.ds.length) {
|
|
874
|
+
frames.pop()
|
|
875
|
+
continue
|
|
876
|
+
}
|
|
877
|
+
const d = f.ds[f.i++]!
|
|
780
878
|
const rem = (pending.get(d) ?? 0) - 1
|
|
781
879
|
pending.set(d, rem)
|
|
782
880
|
if (rem !== 0) continue
|
|
783
881
|
// Restore-tier dependents were already enqueued at startup (they
|
|
784
882
|
// don't wait on deps); one that finished early releases its own
|
|
785
883
|
// dependents now. Only an exec-tier dependent is enqueued.
|
|
786
|
-
if (heldRelease.delete(d))
|
|
884
|
+
if (heldRelease.delete(d)) enter(d)
|
|
787
885
|
else if (!inRestoreTier(d)) {
|
|
788
886
|
execReady.push(d)
|
|
789
887
|
readyAt.set(d, Date.now())
|
|
@@ -824,8 +922,13 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
824
922
|
return true
|
|
825
923
|
}
|
|
826
924
|
if (inRestoreTier(id)) return false
|
|
827
|
-
if (continueMode === 'always') return false
|
|
828
925
|
const node = nodes.get(id) as TaskNode
|
|
926
|
+
// `always` runs past a failed upstream, never past one that did not
|
|
927
|
+
// run on this platform: under it, a skip has no other cause.
|
|
928
|
+
if (continueMode === 'always')
|
|
929
|
+
return node.deps.some(
|
|
930
|
+
(d) => outcomes.get(d)?.status === 'skipped' || blockedRestores.has(d),
|
|
931
|
+
)
|
|
829
932
|
return node.deps.some((d) => {
|
|
830
933
|
const u = outcomes.get(d)
|
|
831
934
|
// `aborted` propagates even though it is counted nowhere: the
|
|
@@ -845,7 +948,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
845
948
|
}
|
|
846
949
|
|
|
847
950
|
// A throw falls back to the dispatch, whose `execute` reports it.
|
|
848
|
-
const settleInPlace = (
|
|
951
|
+
const settleInPlace = (
|
|
952
|
+
node: TaskNode,
|
|
953
|
+
upstream: TaskOutcome[],
|
|
954
|
+
): (() => TaskOutcome) | undefined => {
|
|
849
955
|
if (options.settleNow === undefined) return undefined
|
|
850
956
|
try {
|
|
851
957
|
return options.settleNow(node, upstream)
|
|
@@ -853,6 +959,14 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
853
959
|
return undefined
|
|
854
960
|
}
|
|
855
961
|
}
|
|
962
|
+
const started = (node: TaskNode): void => {
|
|
963
|
+
try {
|
|
964
|
+
onStart?.(node)
|
|
965
|
+
} catch (err) {
|
|
966
|
+
const m = thrownMessage(err)
|
|
967
|
+
ui.say('warn', `[vx] onStart observer threw for ${node.id}: ${m}\n`)
|
|
968
|
+
}
|
|
969
|
+
}
|
|
856
970
|
|
|
857
971
|
const tick = (): void => {
|
|
858
972
|
if (resolved) return
|
|
@@ -879,8 +993,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
879
993
|
// with a wide frontier that is O(R) per tick, O(R²) per run (the
|
|
880
994
|
// 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
|
|
881
995
|
// first cut let the scan run past a full exec lane, 2026-09-10).
|
|
882
|
-
// This is the legacy O(1) gate, kept per lane.
|
|
883
|
-
|
|
996
|
+
// This is the legacy O(1) gate, kept per lane. Every pool full is
|
|
997
|
+
// the same: a remote pool of 6 slots before 2000 ready tasks
|
|
998
|
+
// re-popped them all on each end (DTE, 2026-10-25).
|
|
999
|
+
const execAdmissible =
|
|
1000
|
+
poolOf === undefined ? execRoom() : (execRoom() && execReady.counted > 0) || poolRoom()
|
|
884
1001
|
if (execAdmissible) {
|
|
885
1002
|
while (execReady.size > 0) {
|
|
886
1003
|
const seq = execReady.peekSeq()
|
|
@@ -918,35 +1035,60 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
918
1035
|
// nothing on the restore path may.
|
|
919
1036
|
const upstream = node.deps.map((d) => outcomes.get(d) as TaskOutcome)
|
|
920
1037
|
|
|
1038
|
+
// An idle task the stop or fail-fast would skip settles with no
|
|
1039
|
+
// outcome, as it would have run: the run never meant to start it.
|
|
1040
|
+
// Below a failed dep it is skipped as before, so the hits above it
|
|
1041
|
+
// pass the block down.
|
|
1042
|
+
const settlesIdle =
|
|
1043
|
+
idle.has(id) &&
|
|
1044
|
+
(!willSkip(id) ||
|
|
1045
|
+
aborted() ||
|
|
1046
|
+
(failFastTripped &&
|
|
1047
|
+
!node.deps.some((d) => {
|
|
1048
|
+
const s = outcomes.get(d)?.status
|
|
1049
|
+
return (
|
|
1050
|
+
s === 'failed' || s === 'skipped' || s === 'aborted' || blockedRestores.has(d)
|
|
1051
|
+
)
|
|
1052
|
+
})))
|
|
1053
|
+
|
|
921
1054
|
// If any upstream failed/skipped, propagate skip synchronously
|
|
922
1055
|
// without running. Skipped tasks still flow through this queue
|
|
923
1056
|
// because dependents are pushed when `pending` hits 0 regardless
|
|
924
1057
|
// of outcome — keeps the propagation logic in one place.
|
|
925
|
-
if (willSkip(id)) {
|
|
1058
|
+
if (!settlesIdle && willSkip(id)) {
|
|
926
1059
|
// The root of the block: a failed or aborted upstream names
|
|
927
1060
|
// itself; a skipped one hands down its own root.
|
|
928
|
-
const blocker =
|
|
929
|
-
|
|
930
|
-
|
|
1061
|
+
const blocker =
|
|
1062
|
+
continueMode === 'always'
|
|
1063
|
+
? undefined
|
|
1064
|
+
: upstream.find(
|
|
1065
|
+
(u) => u !== undefined && (u.status === 'failed' || u.status === 'aborted'),
|
|
1066
|
+
)
|
|
931
1067
|
const viaSkip = upstream.find((u) => u !== undefined && u.status === 'skipped')
|
|
932
1068
|
const viaRestore = node.deps.find((d) => blockedRestores.has(d))
|
|
933
1069
|
const blockedBy =
|
|
934
1070
|
blocker?.node.id ??
|
|
935
1071
|
node.deps.map(deadServerVia).find((d) => d !== undefined) ??
|
|
936
1072
|
viaSkip?.blockedBy ??
|
|
1073
|
+
(viaSkip?.otherPlatform !== undefined ? viaSkip.node.id : undefined) ??
|
|
937
1074
|
(viaRestore === undefined ? undefined : blockedRestores.get(viaRestore))
|
|
1075
|
+
// Behind a platform skip is no failure: the run passes, so the exit does.
|
|
1076
|
+
const notHere =
|
|
1077
|
+
!aborted() &&
|
|
1078
|
+
blockedBy !== undefined &&
|
|
1079
|
+
outcomes.get(blockedBy)?.otherPlatform !== undefined
|
|
938
1080
|
finishOne(id, {
|
|
939
1081
|
node,
|
|
940
1082
|
status: aborted() ? 'aborted' : 'skipped',
|
|
941
|
-
exitCode: 1,
|
|
1083
|
+
exitCode: notHere ? 0 : 1,
|
|
942
1084
|
durationMs: 0,
|
|
943
1085
|
...(blockedBy !== undefined ? { blockedBy } : {}),
|
|
944
1086
|
})
|
|
945
1087
|
continue
|
|
946
1088
|
}
|
|
947
|
-
if (
|
|
1089
|
+
if (settlesIdle) {
|
|
948
1090
|
readyAt.delete(id)
|
|
949
|
-
if (
|
|
1091
|
+
if (settledAbove(id)) {
|
|
950
1092
|
settledIdle++
|
|
951
1093
|
release(id)
|
|
952
1094
|
} else dormant.add(id)
|
|
@@ -955,14 +1097,17 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
955
1097
|
|
|
956
1098
|
const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
|
|
957
1099
|
readyAt.delete(id)
|
|
958
|
-
const
|
|
959
|
-
|
|
1100
|
+
const settle = settleInPlace(node, upstream)
|
|
1101
|
+
let now: TaskOutcome | undefined
|
|
1102
|
+
if (settle !== undefined) {
|
|
1103
|
+
if (!demoted.has(id)) started(node)
|
|
960
1104
|
try {
|
|
961
|
-
|
|
962
|
-
} catch
|
|
963
|
-
|
|
964
|
-
process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
|
|
1105
|
+
now = settle()
|
|
1106
|
+
} catch {
|
|
1107
|
+
// Dispatched below, already started: `execute` reports it.
|
|
965
1108
|
}
|
|
1109
|
+
}
|
|
1110
|
+
if (now !== undefined) {
|
|
966
1111
|
finishOne(id, queuedMs === 0 ? now : { ...now, queuedMs })
|
|
967
1112
|
continue
|
|
968
1113
|
}
|
|
@@ -983,15 +1128,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
983
1128
|
}
|
|
984
1129
|
// Crash-isolated observer hook — a throwing onStart must not abort
|
|
985
1130
|
// the dispatch loop (it would strand the tick with the slot held).
|
|
986
|
-
// A demoted task's second dispatch is the same task, already started
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
onStart?.(node)
|
|
990
|
-
} catch (err) {
|
|
991
|
-
const m = err instanceof Error ? err.message : String(err)
|
|
992
|
-
process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
|
|
993
|
-
}
|
|
994
|
-
}
|
|
1131
|
+
// A demoted task's second dispatch is the same task, already started,
|
|
1132
|
+
// as is one whose settle threw.
|
|
1133
|
+
if (!demoted.has(id) && settle === undefined) started(node)
|
|
995
1134
|
|
|
996
1135
|
// `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
|
|
997
1136
|
// rejection arm handles ONLY `execute()` rejecting; a throw from
|
|
@@ -1031,12 +1170,13 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1031
1170
|
leave()
|
|
1032
1171
|
untrack()
|
|
1033
1172
|
demoted.add(id)
|
|
1034
|
-
|
|
1035
|
-
|
|
1173
|
+
idleBelow(id, (p) => {
|
|
1174
|
+
idle.delete(p)
|
|
1175
|
+
if (dormant.delete(p)) {
|
|
1036
1176
|
execReady.push(p)
|
|
1037
1177
|
readyAt.set(p, Date.now())
|
|
1038
1178
|
}
|
|
1039
|
-
}
|
|
1179
|
+
})
|
|
1040
1180
|
if (pending.get(id) === 0) {
|
|
1041
1181
|
execReady.push(id)
|
|
1042
1182
|
readyAt.set(id, Date.now())
|
|
@@ -1044,7 +1184,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1044
1184
|
tick()
|
|
1045
1185
|
return
|
|
1046
1186
|
}
|
|
1047
|
-
const message =
|
|
1187
|
+
const message = thrownMessage(err)
|
|
1048
1188
|
// After the run's stop a rejection is the stop's doing (a probe
|
|
1049
1189
|
// it killed, C-65): aborted, as any task it kills, and unsaid.
|
|
1050
1190
|
const stopped = aborted()
|
|
@@ -1073,7 +1213,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1073
1213
|
: `${message} — ${isOutOfFds(err) ? OUT_OF_FDS_HINT : fsRefusalHint(err)}`
|
|
1074
1214
|
// In a task's own output block, in full: that block is read
|
|
1075
1215
|
// alone, and "as <task> above" named one printed elsewhere or
|
|
1076
|
-
// not at all (
|
|
1216
|
+
// not at all (2026-10-22). On bare stderr the first is above.
|
|
1077
1217
|
const first = options.onError === undefined ? refusedBy.get(text) : undefined
|
|
1078
1218
|
if (!refusedBy.has(text)) refusedBy.set(text, id)
|
|
1079
1219
|
line = `[vx] ${id}: ${first === undefined ? text : `as ${first} above`}\n`
|
|
@@ -1091,10 +1231,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1091
1231
|
line = `[vx] internal error in ${id}: ${named}${message}${because}\n`
|
|
1092
1232
|
}
|
|
1093
1233
|
try {
|
|
1094
|
-
if (options.onError === undefined)
|
|
1234
|
+
if (options.onError === undefined) ui.say('error', line)
|
|
1095
1235
|
else options.onError(node, line, err)
|
|
1096
1236
|
} catch {
|
|
1097
|
-
|
|
1237
|
+
ui.say('error', line)
|
|
1098
1238
|
}
|
|
1099
1239
|
finishOne(id, outcome)
|
|
1100
1240
|
tick()
|
|
@@ -1114,16 +1254,28 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1114
1254
|
// condition survived the whole core suite (item 644).
|
|
1115
1255
|
if (outcomes.size + settledIdle === nodes.size) {
|
|
1116
1256
|
resolved = true
|
|
1257
|
+
stopListening()
|
|
1117
1258
|
resolve(outcomes)
|
|
1118
1259
|
}
|
|
1119
1260
|
}
|
|
1120
1261
|
|
|
1121
1262
|
// A held server's answer: the dependants it found go on the restore
|
|
1122
|
-
// lane, and the server is idle when they were all of them.
|
|
1263
|
+
// lane, and the server is idle when they were all of them. One whose
|
|
1264
|
+
// deps all settled is already on the exec queue, running, or done:
|
|
1265
|
+
// pushed again it ran twice (its server skipped, the exec lane full).
|
|
1123
1266
|
const answer = (id: string, found: readonly string[]): void => {
|
|
1124
1267
|
if (resolved || !unanswered.delete(id)) return
|
|
1125
1268
|
for (const d of found) {
|
|
1126
|
-
|
|
1269
|
+
// In `readyAt`: already queued, released to the exec queue when its
|
|
1270
|
+
// server was skipped before this answer. On both lanes it ran twice.
|
|
1271
|
+
if (
|
|
1272
|
+
promoted.has(d) ||
|
|
1273
|
+
outcomes.has(d) ||
|
|
1274
|
+
readyAt.has(d) ||
|
|
1275
|
+
pending.get(d) === 0 ||
|
|
1276
|
+
!inRestoreTier(d)
|
|
1277
|
+
)
|
|
1278
|
+
continue
|
|
1127
1279
|
promoted.add(d)
|
|
1128
1280
|
restoreReady.push(d)
|
|
1129
1281
|
readyAt.set(d, Date.now())
|
|
@@ -1139,23 +1291,81 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1139
1291
|
() => answer(id, []),
|
|
1140
1292
|
)
|
|
1141
1293
|
}
|
|
1294
|
+
// A stop reaches the scheduler through a completion, and a held server
|
|
1295
|
+
// waiting on the remote has none: the run waited out the remote's answer.
|
|
1296
|
+
const signal = options.signal
|
|
1297
|
+
if (unanswered.size > 0 && signal !== undefined) {
|
|
1298
|
+
const stop = (): void => {
|
|
1299
|
+
for (const id of [...unanswered]) answer(id, [])
|
|
1300
|
+
}
|
|
1301
|
+
signal.addEventListener('abort', stop, { once: true })
|
|
1302
|
+
stopListening = () => signal.removeEventListener('abort', stop)
|
|
1303
|
+
}
|
|
1142
1304
|
|
|
1143
1305
|
tick()
|
|
1144
1306
|
})
|
|
1145
1307
|
}
|
|
1146
1308
|
|
|
1147
|
-
/**
|
|
1309
|
+
/**
|
|
1310
|
+
* Persistent tasks nobody asked for whose every dependant is a restore-tier
|
|
1311
|
+
* hit, or a group nobody asked for that only such dependants stand behind:
|
|
1312
|
+
* a group runs nothing, so `env: { dependsOn: ['dev'] }` between the server
|
|
1313
|
+
* and its hits booted the server on every warm run. Those groups go in
|
|
1314
|
+
* `groups`.
|
|
1315
|
+
*/
|
|
1148
1316
|
export function idleServers(
|
|
1149
1317
|
nodes: ReadonlyMap<string, TaskNode>,
|
|
1150
1318
|
dependents: ReadonlyMap<string, string[]>,
|
|
1151
1319
|
restoreTier: ReadonlySet<string> | undefined,
|
|
1320
|
+
groups?: Set<string>,
|
|
1152
1321
|
): Set<string> {
|
|
1153
1322
|
const out = new Set<string>()
|
|
1154
1323
|
if (restoreTier === undefined || restoreTier.size === 0) return out
|
|
1324
|
+
const unasked = (n: TaskNode): boolean => !n.requested && n.surfaced !== true
|
|
1325
|
+
// Per group: whether only hits stand behind it. Post-order on a stack, as
|
|
1326
|
+
// groups nest as deep as the graph.
|
|
1327
|
+
const spare = new Map<string, boolean>()
|
|
1328
|
+
const spareGroup = (root: string): boolean => {
|
|
1329
|
+
const stack = [root]
|
|
1330
|
+
while (stack.length > 0) {
|
|
1331
|
+
const id = stack[stack.length - 1]!
|
|
1332
|
+
if (spare.has(id)) {
|
|
1333
|
+
stack.pop()
|
|
1334
|
+
continue
|
|
1335
|
+
}
|
|
1336
|
+
const n = nodes.get(id)
|
|
1337
|
+
const ds = dependents.get(id)
|
|
1338
|
+
if (n === undefined || !isGroupTask(n) || !unasked(n) || ds === undefined) {
|
|
1339
|
+
spare.set(id, false)
|
|
1340
|
+
stack.pop()
|
|
1341
|
+
continue
|
|
1342
|
+
}
|
|
1343
|
+
const next = ds.find((d) => !restoreTier.has(d) && !spare.has(d))
|
|
1344
|
+
if (next !== undefined) {
|
|
1345
|
+
stack.push(next)
|
|
1346
|
+
continue
|
|
1347
|
+
}
|
|
1348
|
+
stack.pop()
|
|
1349
|
+
spare.set(
|
|
1350
|
+
id,
|
|
1351
|
+
ds.every((d) => restoreTier.has(d) || spare.get(d) === true),
|
|
1352
|
+
)
|
|
1353
|
+
}
|
|
1354
|
+
return spare.get(root)!
|
|
1355
|
+
}
|
|
1155
1356
|
for (const n of nodes.values()) {
|
|
1156
|
-
if (n.config.exec?.persistent === undefined || n
|
|
1357
|
+
if (n.config.exec?.persistent === undefined || !unasked(n)) continue
|
|
1157
1358
|
const ds = dependents.get(n.id)
|
|
1158
|
-
if (ds
|
|
1359
|
+
if (ds === undefined || !ds.every((d) => restoreTier.has(d) || spareGroup(d))) continue
|
|
1360
|
+
out.add(n.id)
|
|
1361
|
+
if (groups === undefined) continue
|
|
1362
|
+
const stack = ds.filter((d) => !restoreTier.has(d))
|
|
1363
|
+
while (stack.length > 0) {
|
|
1364
|
+
const g = stack.pop()!
|
|
1365
|
+
if (groups.has(g)) continue
|
|
1366
|
+
groups.add(g)
|
|
1367
|
+
for (const d of dependents.get(g)!) if (!restoreTier.has(d)) stack.push(d)
|
|
1368
|
+
}
|
|
1159
1369
|
}
|
|
1160
1370
|
return out
|
|
1161
1371
|
}
|