@vzn/vx 0.0.644 → 0.0.646
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +22 -48
- package/package.json +5 -5
- package/schemas/init.json +5 -0
- package/schemas/last.json +15 -0
- package/skills/vx/SKILL.md +1 -1
- package/src/bin.ts +1 -1
- package/src/cache/archive.ts +12 -76
- package/src/cache/cache.ts +72 -42
- package/src/cache/git-inputs.ts +55 -10
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +61 -11
- package/src/cache/layer.ts +30 -8
- package/src/cache/layered-cache.ts +138 -44
- package/src/cache/output-index.ts +25 -11
- package/src/cache/plugin-cache.ts +9 -14
- package/src/cache/remote-lane.ts +60 -22
- package/src/cache/run-history.ts +13 -6
- package/src/cache/schema.ts +19 -2
- package/src/cache/scoped-cache.ts +3 -80
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/help.ts +8 -10
- package/src/cli/index.ts +0 -12
- package/src/cli/init.ts +159 -25
- package/src/cli/last.ts +4 -2
- package/src/cli/plan-format.ts +6 -2
- package/src/cli/plugin-commands.ts +98 -6
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +2 -8
- package/src/cli/select.ts +5 -3
- package/src/cli/upgrade.ts +5 -6
- package/src/config.ts +2 -4
- package/src/exec/executor.ts +112 -9
- package/src/exec/index.ts +2 -0
- package/src/exec/proc-sample.ts +67 -5
- package/src/exec/runner.ts +4 -0
- package/src/exec/sandbox-runtime.ts +11 -4
- package/src/graph/scheduler.ts +59 -8
- package/src/history/history.ts +119 -7
- package/src/history/metrics.ts +53 -16
- package/src/index.ts +16 -1
- package/src/orchestrator/affected-tasks.ts +0 -18
- package/src/orchestrator/execute-args.ts +3 -0
- package/src/orchestrator/execute-task.ts +28 -7
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +11 -4
- package/src/orchestrator/index.ts +18 -1
- package/src/orchestrator/local-floor.ts +104 -0
- package/src/orchestrator/local-shortcircuit.ts +26 -7
- package/src/orchestrator/miss-save.ts +10 -4
- package/src/orchestrator/options.ts +0 -8
- package/src/orchestrator/persistent-task.ts +14 -0
- package/src/orchestrator/placement.ts +84 -9
- package/src/orchestrator/plan.ts +7 -1
- package/src/orchestrator/plugin-host.ts +22 -7
- package/src/orchestrator/plugin.ts +48 -7
- package/src/orchestrator/prepare.ts +14 -49
- package/src/orchestrator/run-context.ts +39 -1
- package/src/orchestrator/run-keys.ts +164 -0
- package/src/orchestrator/run-records.ts +6 -0
- package/src/orchestrator/run.ts +95 -6
- package/src/orchestrator/sandbox-request.ts +9 -3
- package/src/orchestrator/task-artifacts.ts +142 -0
- package/src/orchestrator/task-hash.ts +12 -4
- package/src/orchestrator/task-process.ts +16 -0
- package/src/orchestrator/telemetry-host.ts +2 -1
- package/src/orchestrator/telemetry.ts +60 -17
- package/src/report/forecast.ts +6 -27
- package/src/report/framed-output.ts +41 -5
- package/src/report/logger.ts +60 -20
- package/src/report/status-line.ts +38 -18
- package/src/report/summary.ts +75 -9
- package/src/settings.ts +8 -7
- package/src/util/colors.ts +16 -1
- package/src/util/index.ts +1 -0
- package/src/util/paths.ts +30 -0
- package/src/util/ui.ts +4 -1
- package/src/workspace/affected.ts +0 -39
- package/src/workspace/config-bytecode.ts +276 -0
- package/src/workspace/index.ts +0 -2
- package/src/workspace/migration.ts +4 -32
- package/src/workspace/project-loader.ts +92 -14
- package/src/cli/foreign-flags.ts +0 -727
package/src/history/history.ts
CHANGED
|
@@ -30,6 +30,9 @@ export const PASS_STATUSES = `(${TASK_STATUSES.filter(isPassStatus)
|
|
|
30
30
|
.map((s) => `'${s}'`)
|
|
31
31
|
.join(', ')})`
|
|
32
32
|
|
|
33
|
+
/** The share of a machine a task must use to have filled it. */
|
|
34
|
+
const SATURATED = 0.9
|
|
35
|
+
|
|
33
36
|
/** Per (project#task) — last RECENT runs collapsed into a summary. */
|
|
34
37
|
export interface TaskHistory {
|
|
35
38
|
/** Total runs in the recent window. */
|
|
@@ -58,6 +61,16 @@ export interface TaskHistory {
|
|
|
58
61
|
* whole task. Undefined when no row carried both numbers.
|
|
59
62
|
*/
|
|
60
63
|
maxCpuParallelism?: number
|
|
64
|
+
/**
|
|
65
|
+
* The most cores, and the most memory, of a machine an executor ran it
|
|
66
|
+
* on (`ExecuteResult.machine`) that it used all of: 90% of the cores
|
|
67
|
+
* over its whole run, or 90% of the memory. A burst to every core is
|
|
68
|
+
* not one: tasks beside it slow for a moment, and booking a machine for
|
|
69
|
+
* it left CI agents half idle (M, 2026-10-22). A
|
|
70
|
+
* task that filled a machine wanted a bigger one. Set by `resourcesFor`.
|
|
71
|
+
*/
|
|
72
|
+
saturatedCores?: number
|
|
73
|
+
saturatedMemoryBytes?: number
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
/** Map keyed by `project#task`. */
|
|
@@ -66,7 +79,11 @@ export type HistoryTable = ReadonlyMap<string, TaskHistory>
|
|
|
66
79
|
/** What a scheduler learns from a task's history: its p50 and its resource maxima. */
|
|
67
80
|
export type TaskResources = Pick<
|
|
68
81
|
TaskHistory,
|
|
69
|
-
|
|
82
|
+
| 'p50DurationMs'
|
|
83
|
+
| 'maxPeakRssBytes'
|
|
84
|
+
| 'maxCpuParallelism'
|
|
85
|
+
| 'saturatedCores'
|
|
86
|
+
| 'saturatedMemoryBytes'
|
|
70
87
|
>
|
|
71
88
|
|
|
72
89
|
export interface HistoryProvider {
|
|
@@ -238,20 +255,38 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
238
255
|
|
|
239
256
|
/**
|
|
240
257
|
* Keep default-branch commit `sha`'s timings (task id → ms) as forecast
|
|
241
|
-
* rows
|
|
242
|
-
*
|
|
258
|
+
* rows, each with the task's measured need from `needs` when it has one
|
|
259
|
+
* (what `resourcesFor` answers for a task with no run here): one row per
|
|
260
|
+
* task, so a repeat import, by this worktree or another at once, adds
|
|
261
|
+
* nothing.
|
|
243
262
|
*/
|
|
244
|
-
importTimings(
|
|
263
|
+
importTimings(
|
|
264
|
+
sha: string,
|
|
265
|
+
timings: ReadonlyMap<string, number>,
|
|
266
|
+
needs: ReadonlyMap<string, TaskResources> = new Map(),
|
|
267
|
+
): void {
|
|
245
268
|
const insert = this.db.query(
|
|
246
|
-
`INSERT OR IGNORE INTO task_durations(project, task, source, sha, duration_ms, recorded_at
|
|
247
|
-
|
|
269
|
+
`INSERT OR IGNORE INTO task_durations(project, task, source, sha, duration_ms, recorded_at,
|
|
270
|
+
cpu_parallelism, peak_rss_bytes, saturated_cores, saturated_memory_bytes)
|
|
271
|
+
VALUES (?, ?, 'remote', ?, ?, ?, ?, ?, ?, ?)`,
|
|
248
272
|
)
|
|
249
273
|
const now = Date.now()
|
|
250
274
|
this.db
|
|
251
275
|
.transaction(() => {
|
|
252
276
|
for (const [id, ms] of timings) {
|
|
253
277
|
const [project, task] = splitTaskId(id)
|
|
254
|
-
|
|
278
|
+
const n = needs.get(id)
|
|
279
|
+
insert.run(
|
|
280
|
+
project,
|
|
281
|
+
task,
|
|
282
|
+
sha,
|
|
283
|
+
Math.round(ms),
|
|
284
|
+
now,
|
|
285
|
+
n?.maxCpuParallelism ?? null,
|
|
286
|
+
n?.maxPeakRssBytes ?? null,
|
|
287
|
+
n?.saturatedCores ?? null,
|
|
288
|
+
n?.saturatedMemoryBytes ?? null,
|
|
289
|
+
)
|
|
255
290
|
}
|
|
256
291
|
})
|
|
257
292
|
.immediate()
|
|
@@ -388,11 +423,88 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
388
423
|
...(cpu !== -Infinity ? { maxCpuParallelism: cpu } : {}),
|
|
389
424
|
})
|
|
390
425
|
}
|
|
426
|
+
// A failure counts: a task killed for memory filled its machine too.
|
|
427
|
+
type Saturated = { project: string; task: string; cores: number | null; memory: number | null }
|
|
428
|
+
const saturated = this.db
|
|
429
|
+
.query(
|
|
430
|
+
`SELECT project, task,
|
|
431
|
+
MAX(CASE WHEN cpu_ms * 1.0 / duration_ms >= ${SATURATED} * placement_cores
|
|
432
|
+
THEN placement_cores END) AS cores,
|
|
433
|
+
MAX(CASE WHEN peak_rss_bytes >= ${SATURATED} * placement_memory
|
|
434
|
+
THEN placement_memory END) AS memory
|
|
435
|
+
FROM runs
|
|
436
|
+
WHERE id >= ? AND placement_cores IS NOT NULL AND duration_ms > 0
|
|
437
|
+
AND status IN ('success', 'failed') AND (cache_hit IS NULL OR cache_hit = 0)
|
|
438
|
+
GROUP BY project, task`,
|
|
439
|
+
)
|
|
440
|
+
.all(floor) as Saturated[]
|
|
441
|
+
for (const row of saturated) {
|
|
442
|
+
const key = `${row.project}#${row.task}`
|
|
443
|
+
if (!wanted.has(key) || (row.cores === null && row.memory === null)) continue
|
|
444
|
+
out.set(key, {
|
|
445
|
+
...(out.get(key) ?? { p50DurationMs: p50s.get(key) }),
|
|
446
|
+
...(row.cores !== null ? { saturatedCores: row.cores } : {}),
|
|
447
|
+
...(row.memory !== null ? { saturatedMemoryBytes: row.memory } : {}),
|
|
448
|
+
})
|
|
449
|
+
}
|
|
391
450
|
// A forecast reads past the window: a task with none in it still has one.
|
|
392
451
|
for (const [key, p50] of p50s) if (!out.has(key)) out.set(key, { p50DurationMs: p50 })
|
|
452
|
+
this.remoteNeeds(wanted, out)
|
|
393
453
|
return out
|
|
394
454
|
}
|
|
395
455
|
|
|
456
|
+
/**
|
|
457
|
+
* A task never measured here takes its need from the newest imported
|
|
458
|
+
* row that has one (`importTimings`): a fresh CI machine packs by what
|
|
459
|
+
* the default branch measured. One probe skips a store with none.
|
|
460
|
+
*/
|
|
461
|
+
private remoteNeeds(wanted: ReadonlySet<string>, out: Map<string, TaskResources>): void {
|
|
462
|
+
const pending = [...wanted].filter((id) => out.get(id)?.maxCpuParallelism === undefined)
|
|
463
|
+
if (pending.length === 0) return
|
|
464
|
+
if (
|
|
465
|
+
this.db
|
|
466
|
+
.query(
|
|
467
|
+
`SELECT 1 FROM task_durations WHERE source = 'remote' AND cpu_parallelism IS NOT NULL LIMIT 1`,
|
|
468
|
+
)
|
|
469
|
+
.get() === null
|
|
470
|
+
)
|
|
471
|
+
return
|
|
472
|
+
const rows = this.db
|
|
473
|
+
.query(
|
|
474
|
+
`SELECT j.key AS i, d.cpu_parallelism AS cpu, d.peak_rss_bytes AS rss,
|
|
475
|
+
d.saturated_cores AS sc, d.saturated_memory_bytes AS sm
|
|
476
|
+
FROM json_each(?) AS j
|
|
477
|
+
JOIN task_durations d ON d.id = (
|
|
478
|
+
SELECT id FROM task_durations
|
|
479
|
+
WHERE project = j.value ->> 0 AND task = j.value ->> 1 AND source = 'remote'
|
|
480
|
+
AND cpu_parallelism IS NOT NULL
|
|
481
|
+
ORDER BY id DESC LIMIT 1)`,
|
|
482
|
+
)
|
|
483
|
+
.all(JSON.stringify(pending.map(splitTaskId))) as {
|
|
484
|
+
i: number
|
|
485
|
+
cpu: number
|
|
486
|
+
rss: number | null
|
|
487
|
+
sc: number | null
|
|
488
|
+
sm: number | null
|
|
489
|
+
}[]
|
|
490
|
+
for (const r of rows) {
|
|
491
|
+
const id = pending[r.i]!
|
|
492
|
+
const had = out.get(id)
|
|
493
|
+
out.set(id, {
|
|
494
|
+
p50DurationMs: had?.p50DurationMs,
|
|
495
|
+
maxCpuParallelism: r.cpu,
|
|
496
|
+
...(r.rss !== null ? { maxPeakRssBytes: r.rss } : {}),
|
|
497
|
+
// A saturation measured here wins.
|
|
498
|
+
...(r.sc !== null ? { saturatedCores: r.sc } : {}),
|
|
499
|
+
...(r.sm !== null ? { saturatedMemoryBytes: r.sm } : {}),
|
|
500
|
+
...(had?.saturatedCores !== undefined ? { saturatedCores: had.saturatedCores } : {}),
|
|
501
|
+
...(had?.saturatedMemoryBytes !== undefined
|
|
502
|
+
? { saturatedMemoryBytes: had.saturatedMemoryBytes }
|
|
503
|
+
: {}),
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
396
508
|
/** The first `runs` id of the newest `recent` invocations (see `loadFor`). */
|
|
397
509
|
private floor(): number {
|
|
398
510
|
const row = this.db
|
package/src/history/metrics.ts
CHANGED
|
@@ -44,6 +44,12 @@ export interface RunSummaryRow {
|
|
|
44
44
|
hash: string
|
|
45
45
|
cpuMs: number | null
|
|
46
46
|
peakRssBytes: number | null
|
|
47
|
+
/** The most cores one sampling interval burned (v43); null when nothing sampled. */
|
|
48
|
+
peakCores: number | null
|
|
49
|
+
/** The mean resident memory over the samples, in bytes (v43); null when nothing sampled. */
|
|
50
|
+
avgRssBytes: number | null
|
|
51
|
+
/** Where an executor ran it (`small#1`, v44); null = here. */
|
|
52
|
+
placement: string | null
|
|
47
53
|
// High-precision spans relative to run start. Decimal strings on the wire
|
|
48
54
|
// (bigints aren't JSON-safe); use BigInt or Number on the client.
|
|
49
55
|
wallclockStartNs: string | null
|
|
@@ -114,6 +120,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
|
|
|
114
120
|
duration_ms AS durationMs, started_at AS startedAt, ended_at AS endedAt,
|
|
115
121
|
cache_hit AS cacheHit, restored, cached, hash,
|
|
116
122
|
cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
|
|
123
|
+
peak_cores AS peakCores, avg_rss_bytes AS avgRssBytes, placement,
|
|
117
124
|
wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs,
|
|
118
125
|
blocked_by AS blockedBy, timed_out AS timedOut,
|
|
119
126
|
sandbox_violations AS sandboxViolations, not_ready AS notReady,
|
|
@@ -486,11 +493,19 @@ export function latestRunId(db: Database, taskId: string): string | null {
|
|
|
486
493
|
return row?.runId ?? null
|
|
487
494
|
}
|
|
488
495
|
|
|
496
|
+
/** Why a key that folds a task with no `inputs` never hits, and the fix. */
|
|
497
|
+
function keylessNote(taskId: string, keylessBy: string): string {
|
|
498
|
+
return keylessBy === taskId
|
|
499
|
+
? 'never cached: this task declares no `inputs`, so each run derives a new key; declare its `inputs`'
|
|
500
|
+
: `never cached: its key folds ${keylessBy}, which declares no \`inputs\`, so each run derives a new key; declare inputs on ${keylessBy}`
|
|
501
|
+
}
|
|
502
|
+
|
|
489
503
|
export function whyDidThisRerun(db: Database, runId: string, taskId: string): WhyDidThisRerun {
|
|
490
504
|
const [project, task] = splitTaskId(taskId)
|
|
491
505
|
const this_ = db
|
|
492
506
|
.query(
|
|
493
|
-
`SELECT id, hash, status, cache_hit AS cacheHit, restored, cached, started_at AS startedAt
|
|
507
|
+
`SELECT id, hash, status, cache_hit AS cacheHit, restored, cached, started_at AS startedAt,
|
|
508
|
+
keyless_by AS keylessBy
|
|
494
509
|
FROM runs WHERE run_id = ? AND project = ? AND task = ?`,
|
|
495
510
|
)
|
|
496
511
|
.get(runId, project, task) as
|
|
@@ -502,6 +517,7 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
502
517
|
restored: number | null
|
|
503
518
|
cached: number | null
|
|
504
519
|
startedAt: number
|
|
520
|
+
keylessBy: string | null
|
|
505
521
|
}
|
|
506
522
|
| undefined
|
|
507
523
|
if (!this_) {
|
|
@@ -563,17 +579,19 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
563
579
|
? // Not a cache decision at all: the task runs every time by design,
|
|
564
580
|
// and its key is derived only so dependents can fold it.
|
|
565
581
|
'this task declares no `cache` block — it runs on every invocation; its key is folded by dependents only'
|
|
566
|
-
:
|
|
567
|
-
?
|
|
568
|
-
: prev
|
|
569
|
-
?
|
|
570
|
-
|
|
571
|
-
//
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
582
|
+
: this_.keylessBy !== null
|
|
583
|
+
? keylessNote(taskId, this_.keylessBy)
|
|
584
|
+
: prev && prev.hash !== this_.hash
|
|
585
|
+
? 'cache key changed between the previous run and this one (inputs differ)'
|
|
586
|
+
: prev
|
|
587
|
+
? // An unchanged key has two very different endings, and calling
|
|
588
|
+
// both a "re-run" answered the question wrong: a cache HIT did not
|
|
589
|
+
// re-run at all, so blaming `--no-cache` named a cause that cannot
|
|
590
|
+
// have applied. Only a run that EXECUTED on an unchanged key is
|
|
591
|
+
// the case this verb exists to explain. A row with no recorded
|
|
592
|
+
// cacheHit (older rows) is neither — say that, do not guess.
|
|
593
|
+
unchangedKeyNote(db, runId, this_, prev)
|
|
594
|
+
: 'no prior run for this (project, task)',
|
|
577
595
|
}
|
|
578
596
|
}
|
|
579
597
|
|
|
@@ -688,10 +706,17 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
688
706
|
const [project, task] = splitTaskId(taskId)
|
|
689
707
|
const this_ = db
|
|
690
708
|
.query(
|
|
691
|
-
'SELECT id, hash, cached, status, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
|
|
709
|
+
'SELECT id, hash, cached, status, started_at AS startedAt, keyless_by AS keylessBy FROM runs WHERE run_id = ? AND project = ? AND task = ?',
|
|
692
710
|
)
|
|
693
711
|
.get(runId, project, task) as
|
|
694
|
-
| {
|
|
712
|
+
| {
|
|
713
|
+
id: number
|
|
714
|
+
hash: string
|
|
715
|
+
cached: number | null
|
|
716
|
+
status: string
|
|
717
|
+
startedAt: number
|
|
718
|
+
keylessBy: string | null
|
|
719
|
+
}
|
|
695
720
|
| undefined
|
|
696
721
|
if (!this_) {
|
|
697
722
|
return {
|
|
@@ -720,6 +745,18 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
720
745
|
}
|
|
721
746
|
}
|
|
722
747
|
|
|
748
|
+
if (this_.keylessBy !== null) {
|
|
749
|
+
return {
|
|
750
|
+
runId,
|
|
751
|
+
taskId,
|
|
752
|
+
found: true,
|
|
753
|
+
previousRunId: null,
|
|
754
|
+
entries: [],
|
|
755
|
+
unchangedCount: 0,
|
|
756
|
+
note: `${this_.keylessBy} declares no \`inputs\`, so no fingerprints are kept to diff`,
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
723
760
|
const prev = db
|
|
724
761
|
.query(
|
|
725
762
|
`SELECT run_id AS runId, hash, status FROM runs
|
|
@@ -809,8 +846,8 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
809
846
|
note:
|
|
810
847
|
this_.cached === 0
|
|
811
848
|
? // The verdict already says the task is uncached; this line says
|
|
812
|
-
//
|
|
813
|
-
'no `cache` block
|
|
849
|
+
// why no input can be named.
|
|
850
|
+
'with no `cache` block it saves no entry, so no input fingerprints are kept to say which of its `inputs` moved its key; with no `inputs`, every run gives it a new key'
|
|
814
851
|
: unsaved !== null
|
|
815
852
|
? `cache key changed but input fingerprints are unavailable — ${unsaved} and saved no entry; only the key change is known`
|
|
816
853
|
: 'cache key changed but input fingerprints are unavailable — the entry was pruned (or the row predates the `cached` column); only the key change is known',
|
package/src/index.ts
CHANGED
|
@@ -83,8 +83,9 @@ export { Cache } from './cache/index.js'
|
|
|
83
83
|
export type {
|
|
84
84
|
CacheLayer,
|
|
85
85
|
PluginCache,
|
|
86
|
+
ArtifactInfo,
|
|
87
|
+
LentAccess,
|
|
86
88
|
RemoteCacheLayer,
|
|
87
|
-
RemoteKey,
|
|
88
89
|
RemoteOpenContext,
|
|
89
90
|
RunRecord,
|
|
90
91
|
InvocationRecord,
|
|
@@ -163,6 +164,10 @@ export type {
|
|
|
163
164
|
ExecutorContext,
|
|
164
165
|
OutputContext,
|
|
165
166
|
CommandContext,
|
|
167
|
+
ArtifactRun,
|
|
168
|
+
ArtifactTask,
|
|
169
|
+
CacheFacts,
|
|
170
|
+
TaskArtifacts,
|
|
166
171
|
FingerprintChange,
|
|
167
172
|
FingerprintClaim,
|
|
168
173
|
FingerprintContext,
|
|
@@ -197,11 +202,21 @@ export { executorFallback } from './exec/index.js'
|
|
|
197
202
|
// trailing comment or a template literal's closing newline lost the args
|
|
198
203
|
// there while the local floor kept them (X-112).
|
|
199
204
|
export { withForwardArgs } from './exec/index.js'
|
|
205
|
+
// Core's local executor, sandbox included, for a plugin that runs tasks
|
|
206
|
+
// itself: `@vzn/vx-dte`'s orchestrator and its agents.
|
|
207
|
+
export { localFloor, type LocalFloor } from './orchestrator/index.js'
|
|
208
|
+
// A task's sandbox request built where an agent runs it: the paths and the
|
|
209
|
+
// walls are that machine's (`@vzn/vx-dte`, an orchestrator of another OS).
|
|
210
|
+
export { sandboxRequestFor, type SandboxRequest } from './orchestrator/index.js'
|
|
211
|
+
// A runtime probe answered where a `runtime` executor runs the task.
|
|
212
|
+
export { answerRuntime } from './cache/index.js'
|
|
200
213
|
export type {
|
|
201
214
|
ExecuteRequest,
|
|
202
215
|
ExecuteResult,
|
|
203
216
|
ExecuteSandbox,
|
|
217
|
+
MachineReport,
|
|
204
218
|
ResolvedSandboxConfig,
|
|
219
|
+
ServerSpec,
|
|
205
220
|
TaskExecutor,
|
|
206
221
|
TaskPlacement,
|
|
207
222
|
} from './exec/index.js'
|
|
@@ -229,24 +229,6 @@ export function affectedRoots(
|
|
|
229
229
|
return kept
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
/**
|
|
233
|
-
* The tasks of `nodes` whose key the change can move (a branch's cache
|
|
234
|
-
* scope, owner 2026-10-17): a changed path one of its inputs declares, a
|
|
235
|
-
* whole project, or a task its closure holds. Unlike `--affected`, a path
|
|
236
|
-
* no task declares (a README) reaches none: its key is the default
|
|
237
|
-
* branch's, so it reads the default key.
|
|
238
|
-
*/
|
|
239
|
-
export function keyedByChange(
|
|
240
|
-
nodes: ReadonlyMap<string, TaskNode>,
|
|
241
|
-
changes: AffectedChanges,
|
|
242
|
-
projects: ReadonlyMap<string, ProjectEntry>,
|
|
243
|
-
packageGraph: PackageGraph,
|
|
244
|
-
): Set<string> {
|
|
245
|
-
return new Set(
|
|
246
|
-
affectedRoots(nodes, [...nodes.keys()], changes, projects, packageGraph, undefined, true),
|
|
247
|
-
)
|
|
248
|
-
}
|
|
249
|
-
|
|
250
232
|
/**
|
|
251
233
|
* The requested tasks `--affected` keeps, in their order: one the user
|
|
252
234
|
* named (`pkg#task`), one in a project another include selected outright
|
|
@@ -69,6 +69,9 @@ export function executeArgsOf(
|
|
|
69
69
|
if (facts.downloadModeOf.get(id) === 'deferred') a.download = 'deferred'
|
|
70
70
|
if (placements.remoteOnlyNoop.has(id)) a.remoteOnlyNoop = true
|
|
71
71
|
if (placements.remoteOnly.has(id)) a.remoteOnly = true
|
|
72
|
+
if (placements.carried.has(id)) a.carried = true
|
|
73
|
+
const servers = placements.servers.get(id)
|
|
74
|
+
if (servers !== undefined) a.servers = servers
|
|
72
75
|
if (probe !== undefined) a.preProbed = probe
|
|
73
76
|
if (upfrontKey !== undefined) a.upfrontKey = upfrontKey
|
|
74
77
|
if (upfrontGroupKey !== undefined) a.upfrontGroupKey = upfrontGroupKey
|
|
@@ -76,6 +76,7 @@ import { sandboxFix } from './sandbox-fix.js'
|
|
|
76
76
|
// The hit path's entry stays importable from here (tests).
|
|
77
77
|
export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
|
|
78
78
|
import type { DeferredOutputs } from './deferred-outputs.js'
|
|
79
|
+
import type { TreeLoad } from './telemetry.js'
|
|
79
80
|
import { type Logger, warnOf } from '../report/index.js'
|
|
80
81
|
import {
|
|
81
82
|
computeGroupHash,
|
|
@@ -94,6 +95,7 @@ import {
|
|
|
94
95
|
forgetUndeclaredWrites,
|
|
95
96
|
isAborted,
|
|
96
97
|
taskBinDirs,
|
|
98
|
+
serverSpec,
|
|
97
99
|
taskEnv,
|
|
98
100
|
taskTimeoutMs,
|
|
99
101
|
} from './task-process.js'
|
|
@@ -161,7 +163,7 @@ export interface ExecuteArgs {
|
|
|
161
163
|
*/
|
|
162
164
|
liveChildren?: Set<ReturnType<typeof Bun.spawn>>
|
|
163
165
|
/** Sample a spawned task's process tree (`TelemetrySource.track`); absent when nobody samples. */
|
|
164
|
-
track?: (taskId: string, pid: number) => () =>
|
|
166
|
+
track?: (taskId: string, pid: number) => () => TreeLoad | undefined
|
|
165
167
|
/** Name what a missed key changed (`createMissExplainer`); absent when nobody listens. */
|
|
166
168
|
explainMiss?: MissExplainer
|
|
167
169
|
/**
|
|
@@ -270,6 +272,10 @@ export interface ExecuteArgs {
|
|
|
270
272
|
noDependants?: true
|
|
271
273
|
/** The task holds vx's terminal (`terminalHolders`): its stdio is vx's own. */
|
|
272
274
|
terminal?: true
|
|
275
|
+
/** A persistent task every dependant of which starts it where it runs: never spawned here. */
|
|
276
|
+
carried?: true
|
|
277
|
+
/** The persistent tasks its `servers` executor starts where it runs it. */
|
|
278
|
+
servers?: readonly TaskNode[]
|
|
273
279
|
/** When each command of the run ran and where it may write (command-windows.ts). */
|
|
274
280
|
commandWindows?: CommandWindows
|
|
275
281
|
}
|
|
@@ -498,8 +504,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
498
504
|
filterUpstreamHashes(present, cfg.inputs?.tasks, node.projectName, node.id).map(([id]) => id),
|
|
499
505
|
)
|
|
500
506
|
const foldedTasks = expandGroupUpstream(present.filter((u) => folded.has(u.node.id)))
|
|
501
|
-
const
|
|
502
|
-
|
|
507
|
+
const keylessBy = isKeyless(cfg)
|
|
508
|
+
? node.id
|
|
509
|
+
: foldedTasks
|
|
510
|
+
.map((u) => u.keylessBy ?? (isKeyless(u.node.config) ? u.node.id : undefined))
|
|
511
|
+
.find((id) => id !== undefined)
|
|
512
|
+
const keyless = keylessBy !== undefined
|
|
503
513
|
const reads = policy.localRead || policy.remoteRead
|
|
504
514
|
const willRead = !remoteOnly && cfgCacheable && !keyless && reads
|
|
505
515
|
const willWrite = !remoteOnly && cfgCacheable && (policy.localWrite || policy.remoteWrite)
|
|
@@ -964,7 +974,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
964
974
|
unlistenStop?.()
|
|
965
975
|
unlistenStop = undefined
|
|
966
976
|
flushMasked()
|
|
967
|
-
untrack?.()
|
|
977
|
+
load = untrack?.() ?? load
|
|
968
978
|
untrack = undefined
|
|
969
979
|
})
|
|
970
980
|
endExec()
|
|
@@ -1088,7 +1098,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1088
1098
|
const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [])
|
|
1089
1099
|
const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
|
|
1090
1100
|
// The sampling of the attempt's process tree, stopped once it settles.
|
|
1091
|
-
let untrack: (() =>
|
|
1101
|
+
let untrack: (() => TreeLoad | undefined) | undefined
|
|
1102
|
+
// What the last attempt's samples saw, for the run history.
|
|
1103
|
+
let load: TreeLoad | undefined
|
|
1092
1104
|
// The entry's command is shown by `vx why` and sent with the entry to a
|
|
1093
1105
|
// remote cache: a value a config interpolated stays out of both.
|
|
1094
1106
|
const storedCommand = secrets?.mask(step.command) ?? step.command
|
|
@@ -1160,6 +1172,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1160
1172
|
hash,
|
|
1161
1173
|
wallclockStartNs,
|
|
1162
1174
|
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
1175
|
+
...(result.where !== undefined ? { where: result.where } : {}),
|
|
1163
1176
|
}
|
|
1164
1177
|
}
|
|
1165
1178
|
|
|
@@ -1225,12 +1238,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1225
1238
|
...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
|
|
1226
1239
|
outputs: { files: outputs, workspaceFiles: wsOutputs },
|
|
1227
1240
|
...(args.terminal === true ? { terminal: true as const } : {}),
|
|
1241
|
+
...(args.servers === undefined
|
|
1242
|
+
? {}
|
|
1243
|
+
: { servers: args.servers.map((s) => serverSpec(s, args.workspaceRoot)) }),
|
|
1228
1244
|
}
|
|
1229
1245
|
// The signal last: it arms a plugin's `exec.timeout`, and the sandbox's arming
|
|
1230
1246
|
// below is vx's work, not the task's. Armed first, a 60 ms timeout
|
|
1231
1247
|
// expired before the spawn and failed `echo` as timed out, unrun.
|
|
1232
1248
|
if (!userSandbox) return { ...base, signal: requestSignal(local) }
|
|
1233
|
-
|
|
1249
|
+
// A remote executor enforces it where it runs the task.
|
|
1250
|
+
if (args.executor.remote !== true) await args.armSandbox?.()
|
|
1234
1251
|
const sb = await sandboxRequestFor(
|
|
1235
1252
|
node,
|
|
1236
1253
|
step.sandbox!,
|
|
@@ -1463,7 +1480,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1463
1480
|
: {}),
|
|
1464
1481
|
...(inputChanges !== undefined ? { inputChanges } : {}),
|
|
1465
1482
|
...(unkeyed ? { unkeyed: true as const } : {}),
|
|
1466
|
-
...(
|
|
1483
|
+
...(keylessBy !== undefined ? { keylessBy } : {}),
|
|
1467
1484
|
...(saved ? { saved: true as const } : {}),
|
|
1468
1485
|
...(effectiveExitCode !== 0 && output !== undefined
|
|
1469
1486
|
? { failedOutput: failedOutputLog(output, willSave || args.keepRun !== undefined) }
|
|
@@ -1477,7 +1494,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1477
1494
|
...(movedFails !== undefined ? { inputMoved: movedFails } : {}),
|
|
1478
1495
|
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
1479
1496
|
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
1497
|
+
...(load !== undefined ? { peakCores: load.peakCores, avgRssBytes: load.avgRssBytes } : {}),
|
|
1480
1498
|
...(result.where !== undefined ? { where: result.where } : {}),
|
|
1499
|
+
...(result.where !== undefined && result.machine !== undefined
|
|
1500
|
+
? { whereCores: result.machine.cores, whereMemoryBytes: result.machine.memoryBytes }
|
|
1501
|
+
: {}),
|
|
1481
1502
|
...(deferralRequested && result.outputs?.kind === 'deferred'
|
|
1482
1503
|
? { outputs: 'deferred' as const }
|
|
1483
1504
|
: {}),
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// A server the run's local store cannot settle: some dependant missed here
|
|
2
|
+
// but may be a hit the remote holds. The scheduler holds such a server
|
|
3
|
+
// until the remote answers; when every dependant hit, the server never
|
|
4
|
+
// starts (M 2026-10-22), as it never starts behind local hits.
|
|
5
|
+
|
|
6
|
+
import type { TaskNode } from '../graph/index.js'
|
|
7
|
+
import type { CacheEntry, CacheLayer } from '../cache/index.js'
|
|
8
|
+
import type { ShortCircuit } from './local-shortcircuit.js'
|
|
9
|
+
import { getContext } from './remote-prefetch.js'
|
|
10
|
+
|
|
11
|
+
/** The persistent tasks nobody asked for whose every dependant is a local hit or a key the remote may hold. */
|
|
12
|
+
export function serversToHold(
|
|
13
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
14
|
+
restoreTier: ReadonlySet<string>,
|
|
15
|
+
remoteAsk: ReadonlyMap<string, string>,
|
|
16
|
+
): Map<string, string[]> {
|
|
17
|
+
const out = new Map<string, string[]>()
|
|
18
|
+
if (remoteAsk.size === 0) return out
|
|
19
|
+
const dependents = new Map<string, string[]>()
|
|
20
|
+
for (const n of nodes.values())
|
|
21
|
+
for (const d of n.deps) {
|
|
22
|
+
const list = dependents.get(d)
|
|
23
|
+
if (list === undefined) dependents.set(d, [n.id])
|
|
24
|
+
else list.push(n.id)
|
|
25
|
+
}
|
|
26
|
+
for (const n of nodes.values()) {
|
|
27
|
+
if (n.config.exec?.persistent === undefined || n.requested || n.surfaced === true) continue
|
|
28
|
+
const ds = dependents.get(n.id)
|
|
29
|
+
if (
|
|
30
|
+
ds !== undefined &&
|
|
31
|
+
ds.every((d) => restoreTier.has(d) || remoteAsk.has(d)) &&
|
|
32
|
+
ds.some((d) => remoteAsk.has(d))
|
|
33
|
+
)
|
|
34
|
+
out.set(n.id, ds)
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* For each held server, whether the remote holds every dependant: one
|
|
41
|
+
* `get` per dependant through the layered cache, which shares the
|
|
42
|
+
* prefetch's request. All hits join the restore tier at once, so the
|
|
43
|
+
* scheduler finds the server idle; the first miss answers at once, with
|
|
44
|
+
* nothing promoted, so the server starts without waiting on the rest.
|
|
45
|
+
*/
|
|
46
|
+
export function askRemote(
|
|
47
|
+
held: ReadonlyMap<string, readonly string[]>,
|
|
48
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
49
|
+
shortCircuit: Pick<ShortCircuit, 'preProbed' | 'restoreTier' | 'remoteAsk'>,
|
|
50
|
+
cache: CacheLayer,
|
|
51
|
+
): Map<string, Promise<readonly string[]>> {
|
|
52
|
+
const asked = new Map<string, Promise<CacheEntry | null>>()
|
|
53
|
+
const ask = (id: string): Promise<CacheEntry | null> => {
|
|
54
|
+
let p = asked.get(id)
|
|
55
|
+
if (p === undefined) {
|
|
56
|
+
const node = nodes.get(id)!
|
|
57
|
+
p = cache
|
|
58
|
+
.get(shortCircuit.remoteAsk.get(id)!, getContext(node, node.config.exec?.command ?? ''))
|
|
59
|
+
.catch(() => null)
|
|
60
|
+
asked.set(id, p)
|
|
61
|
+
}
|
|
62
|
+
return p
|
|
63
|
+
}
|
|
64
|
+
const out = new Map<string, Promise<readonly string[]>>()
|
|
65
|
+
for (const [server, ds] of held) {
|
|
66
|
+
const remote = ds.filter((d) => shortCircuit.remoteAsk.has(d))
|
|
67
|
+
out.set(
|
|
68
|
+
server,
|
|
69
|
+
new Promise((resolve) => {
|
|
70
|
+
const hits = new Map<string, CacheEntry>()
|
|
71
|
+
for (const d of remote)
|
|
72
|
+
void ask(d).then((hit) => {
|
|
73
|
+
if (hit === null) return resolve([])
|
|
74
|
+
hits.set(d, hit)
|
|
75
|
+
if (hits.size < remote.length) return
|
|
76
|
+
for (const [id, entry] of hits) {
|
|
77
|
+
shortCircuit.preProbed.set(id, { hash: shortCircuit.remoteAsk.get(id)!, hit: entry })
|
|
78
|
+
shortCircuit.restoreTier.add(id)
|
|
79
|
+
}
|
|
80
|
+
resolve(remote)
|
|
81
|
+
})
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
type CacheEntry,
|
|
15
15
|
cleanOutputPaths,
|
|
16
16
|
committedFiles,
|
|
17
|
+
OUTPUT_DIRS_OVER_CAP,
|
|
18
|
+
outputWalkTakes,
|
|
17
19
|
resolveOutputs,
|
|
18
20
|
resolveWorkspaceOutputs,
|
|
19
21
|
sweepRestoreTemps,
|
|
@@ -27,7 +29,7 @@ import {
|
|
|
27
29
|
span,
|
|
28
30
|
splitNegations,
|
|
29
31
|
taskGlob,
|
|
30
|
-
|
|
32
|
+
outputDirPrefixes,
|
|
31
33
|
} from '../util/index.js'
|
|
32
34
|
import type { ExecuteArgs } from './execute-task.js'
|
|
33
35
|
import { entryHolds } from './miss-save.js'
|
|
@@ -202,7 +204,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
202
204
|
// The walk ran and proved the tree current (rows absent, or a
|
|
203
205
|
// directory had moved — a benign touch): snapshot the directories
|
|
204
206
|
// so the next hit skips the walk.
|
|
205
|
-
if (
|
|
207
|
+
if (
|
|
208
|
+
skipRestore &&
|
|
209
|
+
!setKnown &&
|
|
210
|
+
dirPrefixes !== null &&
|
|
211
|
+
hit.outputDirRows?.some((r) => r.mtimeMs === OUTPUT_DIRS_OVER_CAP) !== true
|
|
212
|
+
) {
|
|
206
213
|
await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
|
|
207
214
|
}
|
|
208
215
|
}
|
|
@@ -323,7 +330,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
323
330
|
hash,
|
|
324
331
|
projectDir: node.projectDir,
|
|
325
332
|
prefixes: dirPrefixes,
|
|
326
|
-
holds: entryHolds(node),
|
|
333
|
+
holds: entryHolds(node, outputWalkTakes(node.projectDir, outputs, args.nestedProjectDirs)),
|
|
327
334
|
})
|
|
328
335
|
}
|
|
329
336
|
// Restored outputs changed the project's tree — but on this
|
|
@@ -396,7 +403,7 @@ function dirPrefixesOf(outputs: readonly string[]): readonly string[] | null {
|
|
|
396
403
|
const key = JSON.stringify(outputs)
|
|
397
404
|
let prefixes = prefixMemo.get(key)
|
|
398
405
|
if (prefixes === undefined) {
|
|
399
|
-
prefixes =
|
|
406
|
+
prefixes = outputDirPrefixes(splitNegations(outputs).positive)
|
|
400
407
|
prefixMemo.set(key, prefixes)
|
|
401
408
|
}
|
|
402
409
|
return prefixes
|