@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.
Files changed (83) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +22 -48
  3. package/package.json +5 -5
  4. package/schemas/init.json +5 -0
  5. package/schemas/last.json +15 -0
  6. package/skills/vx/SKILL.md +1 -1
  7. package/src/bin.ts +1 -1
  8. package/src/cache/archive.ts +12 -76
  9. package/src/cache/cache.ts +72 -42
  10. package/src/cache/git-inputs.ts +55 -10
  11. package/src/cache/index.ts +5 -2
  12. package/src/cache/inputs.ts +61 -11
  13. package/src/cache/layer.ts +30 -8
  14. package/src/cache/layered-cache.ts +138 -44
  15. package/src/cache/output-index.ts +25 -11
  16. package/src/cache/plugin-cache.ts +9 -14
  17. package/src/cache/remote-lane.ts +60 -22
  18. package/src/cache/run-history.ts +13 -6
  19. package/src/cache/schema.ts +19 -2
  20. package/src/cache/scoped-cache.ts +3 -80
  21. package/src/cli/core-alias.ts +5 -2
  22. package/src/cli/help.ts +8 -10
  23. package/src/cli/index.ts +0 -12
  24. package/src/cli/init.ts +159 -25
  25. package/src/cli/last.ts +4 -2
  26. package/src/cli/plan-format.ts +6 -2
  27. package/src/cli/plugin-commands.ts +98 -6
  28. package/src/cli/plugin-templates.ts +2 -2
  29. package/src/cli/run.ts +2 -8
  30. package/src/cli/select.ts +5 -3
  31. package/src/cli/upgrade.ts +5 -6
  32. package/src/config.ts +2 -4
  33. package/src/exec/executor.ts +112 -9
  34. package/src/exec/index.ts +2 -0
  35. package/src/exec/proc-sample.ts +67 -5
  36. package/src/exec/runner.ts +4 -0
  37. package/src/exec/sandbox-runtime.ts +11 -4
  38. package/src/graph/scheduler.ts +59 -8
  39. package/src/history/history.ts +119 -7
  40. package/src/history/metrics.ts +53 -16
  41. package/src/index.ts +16 -1
  42. package/src/orchestrator/affected-tasks.ts +0 -18
  43. package/src/orchestrator/execute-args.ts +3 -0
  44. package/src/orchestrator/execute-task.ts +28 -7
  45. package/src/orchestrator/held-servers.ts +86 -0
  46. package/src/orchestrator/hit-restore.ts +11 -4
  47. package/src/orchestrator/index.ts +18 -1
  48. package/src/orchestrator/local-floor.ts +104 -0
  49. package/src/orchestrator/local-shortcircuit.ts +26 -7
  50. package/src/orchestrator/miss-save.ts +10 -4
  51. package/src/orchestrator/options.ts +0 -8
  52. package/src/orchestrator/persistent-task.ts +14 -0
  53. package/src/orchestrator/placement.ts +84 -9
  54. package/src/orchestrator/plan.ts +7 -1
  55. package/src/orchestrator/plugin-host.ts +22 -7
  56. package/src/orchestrator/plugin.ts +48 -7
  57. package/src/orchestrator/prepare.ts +14 -49
  58. package/src/orchestrator/run-context.ts +39 -1
  59. package/src/orchestrator/run-keys.ts +164 -0
  60. package/src/orchestrator/run-records.ts +6 -0
  61. package/src/orchestrator/run.ts +95 -6
  62. package/src/orchestrator/sandbox-request.ts +9 -3
  63. package/src/orchestrator/task-artifacts.ts +142 -0
  64. package/src/orchestrator/task-hash.ts +12 -4
  65. package/src/orchestrator/task-process.ts +16 -0
  66. package/src/orchestrator/telemetry-host.ts +2 -1
  67. package/src/orchestrator/telemetry.ts +60 -17
  68. package/src/report/forecast.ts +6 -27
  69. package/src/report/framed-output.ts +41 -5
  70. package/src/report/logger.ts +60 -20
  71. package/src/report/status-line.ts +38 -18
  72. package/src/report/summary.ts +75 -9
  73. package/src/settings.ts +8 -7
  74. package/src/util/colors.ts +16 -1
  75. package/src/util/index.ts +1 -0
  76. package/src/util/paths.ts +30 -0
  77. package/src/util/ui.ts +4 -1
  78. package/src/workspace/affected.ts +0 -39
  79. package/src/workspace/config-bytecode.ts +276 -0
  80. package/src/workspace/index.ts +0 -2
  81. package/src/workspace/migration.ts +4 -32
  82. package/src/workspace/project-loader.ts +92 -14
  83. package/src/cli/foreign-flags.ts +0 -727
@@ -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
- 'p50DurationMs' | 'maxPeakRssBytes' | 'maxCpuParallelism'
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: one per task, so a repeat import, by this worktree or another
242
- * at once, adds nothing.
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(sha: string, timings: ReadonlyMap<string, number>): void {
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
- VALUES (?, ?, 'remote', ?, ?, ?)`,
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
- insert.run(project, task, sha, Math.round(ms), now)
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
@@ -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
- : prev && prev.hash !== this_.hash
567
- ? 'cache key changed between the previous run and this one (inputs differ)'
568
- : prev
569
- ? // An unchanged key has two very different endings, and calling
570
- // both a "re-run" answered the question wrong: a cache HIT did not
571
- // re-run at all, so blaming `--no-cache` named a cause that cannot
572
- // have applied. Only a run that EXECUTED on an unchanged key is
573
- // the case this verb exists to explain. A row with no recorded
574
- // cacheHit (older rows) is neither — say that, do not guess.
575
- unchangedKeyNote(db, runId, this_, prev)
576
- : 'no prior run for this (project, task)',
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
- | { id: number; hash: string; cached: number | null; status: string; startedAt: number }
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
- // what moved a key that declares nothing.
813
- 'no `cache` block declares its inputs, so its key folds every file in its project — a file it wrote, or any untracked file, moves it — and no fingerprints are kept to say which'
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) => () => void
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 keyless =
502
- isKeyless(cfg) || foldedTasks.some((u) => u.keyless === true || isKeyless(u.node.config))
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: (() => void) | undefined
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
- await args.armSandbox?.()
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
- ...(keyless ? { keyless: true as const } : {}),
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
- wholeSubtreePrefixes,
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 (skipRestore && !setKnown && dirPrefixes !== null) {
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 = wholeSubtreePrefixes(splitNegations(outputs).positive)
406
+ prefixes = outputDirPrefixes(splitNegations(outputs).positive)
400
407
  prefixMemo.set(key, prefixes)
401
408
  }
402
409
  return prefixes