@vzn/vx 0.0.638 → 0.0.640

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 (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -3,10 +3,11 @@
3
3
  // ownership moves to the run's registry, and this is the one place that
4
4
  // decides which of them outlive the graph and how the rest go down.
5
5
 
6
- import { isGroupTask, type TaskNode } from '../graph/index.js'
6
+ import { isGroupTask, isPassStatus, type TaskNode, type TaskOutcome } from '../graph/index.js'
7
7
  import { killGraceMs } from '../util/index.js'
8
- import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
9
- import type { HeldServer } from './options.js'
8
+ import { holdGroups, killTree, signalExitCode, untilGroupsGone } from '../exec/index.js'
9
+ import type { HeldPersistent, HeldServer } from './options.js'
10
+ import { terminateChildren } from './signals.js'
10
11
 
11
12
  type Child = ReturnType<typeof Bun.spawn>
12
13
 
@@ -152,3 +153,129 @@ export function takeHeldServers(
152
153
  }
153
154
  return { kept, stale }
154
155
  }
156
+
157
+ /**
158
+ * The kept server whose exit ends a foreground session, and its code. A
159
+ * server kept only as a dependency that exits 0 is a daemon that forked
160
+ * and returned (schema.md), not the end of the session: a
161
+ * `db: 'docker compose up -d'` under `app#dev` stopped the dev server
162
+ * right after the summary (WD-3). It ends the wait only as the last.
163
+ */
164
+ export function sessionEnd(keepAlive: KeepAlive): Promise<{ code: number; i: number }> {
165
+ return new Promise((resolve) => {
166
+ let left = keepAlive.children.length
167
+ keepAlive.children.forEach((c, i) => {
168
+ void c.exited.then((code) => {
169
+ left--
170
+ const n = keepAlive.nodes[i]!
171
+ if (code !== 0 || n.requested || n.surfaced === true || left === 0) resolve({ code, i })
172
+ })
173
+ })
174
+ })
175
+ }
176
+
177
+ export interface HoldArgs {
178
+ forwardArgs: readonly string[] | undefined
179
+ /** Where a held server's output goes. */
180
+ outOf: (id: string) => HeldServer['out']
181
+ warn: (message: string) => void
182
+ }
183
+
184
+ /** The kept servers handed to the caller (`RunOptions.hold`) instead of waited on. */
185
+ export function holdServers(keepAlive: KeepAlive, args: HoldArgs): HeldPersistent {
186
+ const held = keepAlive.children
187
+ // A held server that dies on its own is said, as the foreground's
188
+ // keep-alive says it: `vx watch` sat on "watching" over a dead dev
189
+ // server. The holder's own stop() is not such a death.
190
+ let stopping = false
191
+ // Taken by a later run (`RunOptions.keep`): kept or stopped there.
192
+ const handed = new Set<Child>()
193
+ const servers = new Map<string, HeldServer>()
194
+ keepAlive.nodes.forEach((n, i) => {
195
+ const child = held[i]!
196
+ servers.set(n.id, {
197
+ child,
198
+ config: serverConfig(n, args.forwardArgs),
199
+ out: args.outOf(n.id),
200
+ handOver: () => void handed.add(child),
201
+ })
202
+ // One that died during the graph was said then.
203
+ if (hasEnded(child)) return
204
+ void child.exited.then((code) => {
205
+ if (!stopping && !handed.has(child) && code !== 0)
206
+ args.warn(`vx: ${n.id} exited with code ${code}`)
207
+ })
208
+ })
209
+ return {
210
+ ids: keepAlive.nodes.map((n) => n.id),
211
+ servers,
212
+ stop: (signal) => {
213
+ stopping = true
214
+ return terminateChildren(() => held.filter((c) => !handed.has(c)), signal)
215
+ },
216
+ }
217
+ }
218
+
219
+ /**
220
+ * A server that died on its own before the stop failed, whatever its
221
+ * outcome said when it became ready: the footer counted it a success over
222
+ * the run's red exit (item 1071). Its code is its own, or the shell's
223
+ * report of the signal that took it. In place: the renderer holds this
224
+ * outcome (events carry live refs) and closes the server's output block
225
+ * from it at runEnd, where a copy left it reading `running` under its exit
226
+ * line (WD-10).
227
+ */
228
+ export function failServer(
229
+ outcomes: ReadonlyMap<string, TaskOutcome>,
230
+ id: string,
231
+ code: number | string,
232
+ ): void {
233
+ const o = outcomes.get(id)
234
+ if (o === undefined) return
235
+ o.status = 'failed'
236
+ o.exitCode = typeof code === 'number' ? code : signalExitCode(code)
237
+ }
238
+
239
+ /**
240
+ * The end of the graph for its servers: which stay up (`selectKeepAlive`),
241
+ * the bounded shutdown of the rest, and each one that died on its own
242
+ * failed. A run that failed elsewhere keeps nothing: `vx run dev --all`
243
+ * with one server that never became ready sat on the others for good, so
244
+ * a script hung, and the Ctrl-C that ended it read 130 over the failure
245
+ * (C-60). A kept server's own crash ends the keep-alive wait as before,
246
+ * and `--continue=always` asked to keep going, and does. The watch loop
247
+ * (`hold`) still holds: a failing test in a cycle stopped the dev server
248
+ * the next change would restart anyway. A kept server that has already
249
+ * died fails too; the keep-alive wait names it.
250
+ */
251
+ export async function disposeServers(args: {
252
+ registry: ReadonlyMap<string, Child>
253
+ nodes: ReadonlyMap<string, TaskNode>
254
+ outcomes: ReadonlyMap<string, TaskOutcome>
255
+ /** Keep the requested servers up: the CLI foreground, or a hold. */
256
+ keep: boolean
257
+ hold: boolean
258
+ continueAlways: boolean
259
+ /** The servers that had ended when a stop landed; undefined without a stop. */
260
+ endedBeforeStop: ReadonlySet<Child> | undefined
261
+ }): Promise<{ keepAlive: KeepAlive; crashed: CrashedPersistent[] }> {
262
+ const { registry, outcomes, endedBeforeStop } = args
263
+ let keepAlive = selectKeepAlive(registry, args.nodes, args.keep)
264
+ const failedElsewhere =
265
+ ![...outcomes.values()].every((o) => isPassStatus(o.status)) ||
266
+ [...registry.values()].some(
267
+ (c) => hasEnded(c) && c.exitCode !== 0 && !keepAlive.children.includes(c),
268
+ )
269
+ if (failedElsewhere && !args.hold && !args.continueAlways) keepAlive = { nodes: [], children: [] }
270
+ const crashed = (await shutdownPersistent(registry, keepAlive.children)).filter(
271
+ (c) => endedBeforeStop?.has(registry.get(c.id)!) ?? true,
272
+ )
273
+ for (const c of crashed) failServer(outcomes, c.id, c.code)
274
+ keepAlive.nodes.forEach((n, i) => {
275
+ const child = keepAlive.children[i]!
276
+ if (!hasEnded(child) || child.exitCode === 0) return
277
+ if (endedBeforeStop !== undefined && !endedBeforeStop.has(child)) return
278
+ failServer(outcomes, n.id, child.exitCode ?? child.signalCode ?? 'unknown')
279
+ })
280
+ return { keepAlive, crashed }
281
+ }
@@ -11,7 +11,7 @@ import { machineParallelism, UserError } from '../util/index.js'
11
11
  import { isLocalExecutor, selectExecutor, type TaskExecutor } from '../exec/index.js'
12
12
  import { isGroupTask, type TaskNode } from '../graph/index.js'
13
13
  import { resolveDownloadModes } from './download-policy.js'
14
- import type { Logger } from './logger.js'
14
+ import { type Logger, warnOf } from '../report/index.js'
15
15
  import { executorLabel, resolveExecutors } from './plugin-host.js'
16
16
  import type { prepareRun } from './prepare.js'
17
17
 
@@ -59,7 +59,7 @@ export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
59
59
  }
60
60
 
61
61
  /**
62
- * A key that folds `cache.inputs.runtime` / `workspaceRuntime` holds what
62
+ * A key that folds `inputs.runtime` / `workspaceRuntime` holds what
63
63
  * THIS machine answered (`node -v`); a worker runs its own runtime, which no
64
64
  * executor can prove equal, and its output saved under this key is a stale
65
65
  * hit here (C-2). Such a task runs here. Its dependants are not pinned with
@@ -68,7 +68,7 @@ export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
68
68
  */
69
69
  function withProbedRuntime(nodes: Map<string, TaskNode>, pinned: Set<string>): Set<string> {
70
70
  for (const node of nodes.values()) {
71
- const inputs = node.config.cache?.inputs
71
+ const inputs = node.config.inputs
72
72
  if ((inputs?.runtime?.length ?? 0) > 0 || (inputs?.workspaceRuntime?.length ?? 0) > 0) {
73
73
  pinned.add(node.id)
74
74
  }
@@ -218,11 +218,11 @@ export async function planExecutorOf(
218
218
  executors = await resolveExecutors(prepared.plugins, {
219
219
  workspaceRoot: prepared.workspaceRoot,
220
220
  cacheDir: prepared.cacheDir,
221
- warn: (m: string) => log.status(m),
221
+ warn: warnOf(log),
222
222
  concurrency: machineParallelism(),
223
223
  })
224
224
  } catch (err) {
225
- log.status(
225
+ warnOf(log)(
226
226
  `[vx] placement not shown — ${err instanceof Error ? err.message : String(err)} (the run would refuse on it)`,
227
227
  )
228
228
  return {}
@@ -1,22 +1,22 @@
1
1
  // Planning: walk the task graph, compute every task's cache key, and
2
2
  // probe the cache to predict what would happen if `vx run` actually
3
- // ran. Used by `--dry-run` (preview output) and `--graph` (DOT export).
3
+ // ran. Used by `--dry`.
4
4
  //
5
5
  // No TASK execution happens: no task spawn, no cleanOutputs, no
6
6
  // restoreOutputs. Cache probing uses the byte-free `cache.has()`
7
7
  // existence probe (a remote layer answers it its own way — no
8
8
  // artifact download, no local ingest), so the plan is read-only — with
9
- // one deliberate exception: `cache.inputs.runtime` / `workspaceRuntime`
9
+ // one deliberate exception: `inputs.runtime` / `workspaceRuntime`
10
10
  // probe commands DO run, because predicting a task's key requires
11
11
  // resolving them (same as a real run). Keep runtime inputs
12
12
  // side-effect-free pure probes.
13
13
 
14
14
  import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
15
15
  import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
16
- import { ranNoCache } from './events.js'
16
+ import { ranNoCache } from '../report/index.js'
17
17
  import { idleServers, isGroupTask, runGraph, type TaskNode } from '../graph/index.js'
18
18
  import type { AffectedReason } from './affected-tasks.js'
19
- import type { HistoryProvider } from './history.js'
19
+ import type { HistoryProvider } from '../history/index.js'
20
20
  import { computeGroupKey, computeTaskHash } from './task-hash.js'
21
21
  import { keyedOutcome, keyUpstream } from './upstream.js'
22
22
 
@@ -139,6 +139,9 @@ export interface PlanArgs {
139
139
  */
140
140
  export async function plan(args: PlanArgs): Promise<RunPlan> {
141
141
  const cacheStatusById = new Map<string, CacheStatus>()
142
+ // Keyed first, probed after: a remote that can batch (`hasMany`) is
143
+ // asked once for every key, one that cannot is asked per key.
144
+ const probed = new Map<string, string>()
142
145
 
143
146
  const outcomes = await runGraph({
144
147
  nodes: args.nodes,
@@ -176,13 +179,23 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
176
179
  } else if (!reads) {
177
180
  status = 'miss'
178
181
  } else {
179
- const where = await args.cache.has(hash)
180
- status = where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local'
182
+ probed.set(node.id, hash)
183
+ return keyedOutcome(node, hash)
181
184
  }
182
185
  cacheStatusById.set(node.id, status)
183
186
  return keyedOutcome(node, hash)
184
187
  },
185
188
  })
189
+ if (probed.size > 0) {
190
+ await args.cache.remoteHasMany?.([...new Set(probed.values())])
191
+ for (const [id, hash] of probed) {
192
+ const where = await args.cache.has(hash)
193
+ cacheStatusById.set(
194
+ id,
195
+ where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local',
196
+ )
197
+ }
198
+ }
186
199
 
187
200
  if (args.restorable !== undefined) {
188
201
  const restoreTier = new Set(
@@ -10,13 +10,12 @@
10
10
  import path from 'node:path'
11
11
  import {
12
12
  type Cache,
13
- ChainedCache,
14
13
  type CacheLayer,
15
14
  type CachePolicy,
16
15
  LayeredCache,
17
16
  absentPluginCache,
18
17
  type PluginCache,
19
- type PluginCacheRemote,
18
+ type ResolvedRemote,
20
19
  } from '../cache/index.js'
21
20
  import { localExecutor, type TaskExecutor } from '../exec/index.js'
22
21
  import { maskedLine, settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
@@ -382,41 +381,81 @@ export async function claimedAffected(
382
381
  return names
383
382
  }
384
383
 
384
+ /** What a run hands the `schedule` stage: a step per plugin, and where refined weights go. */
385
+ export interface ScheduleRun {
386
+ readonly step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
387
+ readonly refined?: (merged: ReadonlyMap<string, number>) => void
388
+ }
389
+
385
390
  /**
386
391
  * `schedule` stage: the merged priorities map (a later plugin's weight for
387
- * a task overrides an earlier one's). Empty when no plugin answered.
392
+ * a task overrides an earlier one's). Empty when no plugin answered. Each
393
+ * plugin's call is the step `<plugin>:schedule`. A plugin's `ctx.refine`
394
+ * replaces its own weights and hands `refined` the merge again; with no
395
+ * `refined` (a plan), it is ignored.
388
396
  */
389
397
  export async function applyScheduleHooks(
390
398
  plugins: readonly VxPlugin[],
391
399
  nodes: ReadonlyMap<string, TaskNode>,
392
- ctx: Omit<ScheduleHookContext, 'cache'>,
400
+ ctx: Omit<ScheduleHookContext, 'cache' | 'refine'>,
393
401
  cacheFor: (plugin: string) => PluginCache = () => absentPluginCache,
402
+ run: ScheduleRun = {},
394
403
  ): Promise<ReadonlyMap<string, number>> {
395
- const merged = new Map<string, number>()
396
- for (const plugin of plugins) {
404
+ const sets: (ReadonlyMap<string, number> | undefined)[] = []
405
+ const merged = (): ReadonlyMap<string, number> => {
406
+ const out = new Map<string, number>()
407
+ for (const set of sets) if (set !== undefined) for (const [id, w] of set) out.set(id, w)
408
+ return out
409
+ }
410
+ for (const [i, plugin] of plugins.entries()) {
397
411
  if (plugin.schedule === undefined) continue
398
- const own = { ...ctx, cache: cacheFor(plugin.name) }
399
- const weights = await safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
400
- if (weights === undefined) continue
401
- // Iterating a string destructures its characters into `[id, w]` pairs
402
- // that match no task — a plugin returning `'fast'` used to be a no-op
403
- // with no word said.
404
- if (!(weights instanceof Map)) {
412
+ const refine = (weights: ReadonlyMap<string, number>): void => {
413
+ if (run.refined === undefined) return
414
+ try {
415
+ sets[i] = scheduleWeights(plugin, weights, nodes)
416
+ } catch (err) {
417
+ ctx.warn(`[vx] ${err instanceof Error ? err.message : String(err)}; refine ignored`)
418
+ return
419
+ }
420
+ run.refined(merged())
421
+ }
422
+ const own = { ...ctx, cache: cacheFor(plugin.name), refine }
423
+ const call = (): Promise<ReadonlyMap<string, number> | undefined> =>
424
+ safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
425
+ const weights = await (run.step === undefined
426
+ ? call()
427
+ : run.step(`${plugin.name}:schedule`, call))
428
+ // A refine that landed while the call ran is the newer set.
429
+ if (weights !== undefined) sets[i] ??= scheduleWeights(plugin, weights, nodes)
430
+ }
431
+ return merged()
432
+ }
433
+
434
+ /** A `schedule` answer's weights for tasks in the graph, refused by name when it is not a Map of finite numbers. */
435
+ function scheduleWeights(
436
+ plugin: VxPlugin,
437
+ weights: unknown,
438
+ nodes: ReadonlyMap<string, TaskNode>,
439
+ ): ReadonlyMap<string, number> {
440
+ // Iterating a string destructures its characters into `[id, w]` pairs
441
+ // that match no task — a plugin returning `'fast'` used to be a no-op
442
+ // with no word said.
443
+ if (!(weights instanceof Map)) {
444
+ throw new UserError(
445
+ `plugin '${plugin.name}' failed in schedule: returned ${describeValue(weights)}, not a Map of task id → weight`,
446
+ )
447
+ }
448
+ const out = new Map<string, number>()
449
+ for (const [id, w] of weights as Map<unknown, unknown>) {
450
+ if (typeof id !== 'string' || !nodes.has(id)) continue
451
+ if (typeof w !== 'number' || !Number.isFinite(w)) {
405
452
  throw new UserError(
406
- `plugin '${plugin.name}' failed in schedule: returned ${describeValue(weights)}, not a Map of task id → weight`,
453
+ `plugin '${plugin.name}' failed in schedule: weight for ${id} is not a finite number`,
407
454
  )
408
455
  }
409
- for (const [id, w] of weights) {
410
- if (!nodes.has(id)) continue
411
- if (typeof w !== 'number' || !Number.isFinite(w)) {
412
- throw new UserError(
413
- `plugin '${plugin.name}' failed in schedule: weight for ${id} is not a finite number`,
414
- )
415
- }
416
- merged.set(id, w)
417
- }
456
+ out.set(id, w)
418
457
  }
419
- return merged
458
+ return out
420
459
  }
421
460
 
422
461
  /**
@@ -485,70 +524,39 @@ export interface CacheHost extends CacheContext {
485
524
  readonly localCache: Cache
486
525
  readonly policy: CachePolicy
487
526
  /** Filled with each remote and what its policy lets it do, for the plugins' `ctx.cache`. */
488
- readonly remotes?: PluginCacheRemote[]
527
+ readonly remotes?: ResolvedRemote[]
489
528
  }
490
529
 
491
530
  /**
492
531
  * Collect every plugin's remote (`cache` hook) in declaration order and
493
532
  * layer each over the local store: a lookup asks the local store first,
494
533
  * then each remote in order (owner, 2026-10-13), and a save reaches every
495
- * remote. The run's policy, narrowed by a remote's `access`, and its scope
496
- * gate each remote; a remote's throw is a miss and a warning naming its
497
- * plugin. No remote → the local store alone.
534
+ * remote. The run's policy, narrowed by a remote's `access`, gates each
535
+ * remote; a remote's throw is a miss and a warning naming its plugin. No
536
+ * remote → the local store alone.
498
537
  */
499
538
  export async function resolveCache(
500
539
  plugins: readonly VxPlugin[],
501
540
  host: CacheHost,
502
541
  ): Promise<CacheLayer> {
503
542
  const { localCache, policy, remotes, ...ctx } = host
504
- const layers: CacheLayer[] = []
505
- const owners: string[] = []
543
+ const resolved: ResolvedRemote[] = []
506
544
  for (const plugin of plugins) {
507
545
  if (plugin.cache === undefined) continue
508
546
  const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
509
547
  if (remote === undefined) continue
510
- // The hook returned a whole layer until 0.0.637: name the fix, not the
511
- // three methods such a layer happens to have.
512
- if (isRecordLike(remote) && typeof remote['restoreOutputs'] === 'function') {
513
- throw new UserError(
514
- `plugin '${plugin.name}' returned a cache layer from cache: since vx 0.0.637 the hook ` +
515
- 'returns the remote alone (a RemoteCacheLayer: has, get, put) and core layers it over ' +
516
- 'the local cache — return the remote you passed to LayeredCache',
517
- )
518
- }
519
548
  assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
520
549
  const access = remote.access
521
- const own: CachePolicy = {
522
- ...policy,
523
- remoteRead: policy.remoteRead && access?.read !== false,
524
- remoteWrite: policy.remoteWrite && access?.write !== false,
550
+ const entry: ResolvedRemote = {
551
+ remote,
552
+ read: policy.remoteRead && access?.read !== false,
553
+ write: policy.remoteWrite && access?.write !== false,
554
+ onError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
525
555
  }
526
- remotes?.push({ remote, read: own.remoteRead, write: own.remoteWrite })
527
- layers.push(
528
- new LayeredCache(localCache, remote, {
529
- policy: own,
530
- onRemoteError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
531
- }),
532
- )
533
- owners.push(`the cache layer of plugin '${plugin.name}'`)
556
+ resolved.push(entry)
557
+ remotes?.push(entry)
534
558
  }
535
- if (layers.length === 0) return localCache
536
- if (layers.length === 1) return layers[0]!
537
- // One line per layer and method: a layer that throws on every lookup
538
- // would otherwise print once per task.
539
- const told = new Set<string>()
540
- return new ChainedCache(layers, (i, method, err) => {
541
- if (told.has(`${i}\0${method}`)) return
542
- told.add(`${i}\0${method}`)
543
- const m = err instanceof Error ? err.message : String(err)
544
- const then =
545
- method === 'save' ? 'the other layers still save' : 'a miss there; the next layer answers'
546
- ctx.warn(`[vx] ${owners[i]} failed in cache ${method}: ${m}; ${then}`)
547
- })
548
- }
549
-
550
- function isRecordLike(v: unknown): v is Record<string, unknown> {
551
- return typeof v === 'object' && v !== null
559
+ return resolved.length === 0 ? localCache : new LayeredCache(localCache, resolved)
552
560
  }
553
561
 
554
562
  /** The plugin each resolved executor came from, so a hook of it that throws is named. */
@@ -581,6 +589,55 @@ export function executorLabel(executor: TaskExecutor): string {
581
589
  : `plugin '${plugin}' (executor '${executor.name}')`
582
590
  }
583
591
 
592
+ /**
593
+ * Demand: what each executor still has left to run. An executor that
594
+ * provisions per task (a container, an allocation, a pod) otherwise has to
595
+ * guess from `capacity` alone and hold everything until teardown. Built
596
+ * ONLY for executors that asked: with none declared there is no map, no
597
+ * set, and the returned narrower does nothing.
598
+ *
599
+ * The SAME set is handed back every time, narrowed in place: a consumer
600
+ * reading it later sees the truth rather than a stale snapshot. A hint,
601
+ * as `admit` is: one that throws is named once and asked no more, where
602
+ * it surfaced as a bare stack, or mid-run from the completion path
603
+ * (item 1022). Returns the call that drops a finished task's id.
604
+ */
605
+ export function executorDemand(
606
+ placed: ReadonlyMap<string, TaskExecutor>,
607
+ warn: (message: string) => void,
608
+ ): (id: string) => void {
609
+ const demandOf = new Map<TaskExecutor, Set<string>>()
610
+ for (const [id, executor] of placed) {
611
+ if (executor.demand === undefined) continue
612
+ let remaining = demandOf.get(executor)
613
+ if (remaining === undefined) demandOf.set(executor, (remaining = new Set()))
614
+ remaining.add(id)
615
+ }
616
+ if (demandOf.size === 0) return () => {}
617
+ const tell = (executor: TaskExecutor, remaining: ReadonlySet<string>): void => {
618
+ const failed = (err: unknown): void => {
619
+ if (!demandOf.delete(executor)) return
620
+ const m = err instanceof Error ? err.message : String(err)
621
+ warn(`[vx] ${executorLabel(executor)} failed in demand: ${m}; not asked again this run`)
622
+ }
623
+ try {
624
+ // An `async demand` that rejects did so where no one listened: an
625
+ // unhandled rejection, a stack, the run killed. Its rejection is a
626
+ // throw like any other.
627
+ const ret: unknown = executor.demand!(remaining)
628
+ if (ret instanceof Promise) ret.catch(failed)
629
+ } catch (err) {
630
+ failed(err)
631
+ }
632
+ }
633
+ for (const [executor, remaining] of demandOf) tell(executor, remaining)
634
+ return (id) => {
635
+ for (const [executor, remaining] of demandOf) {
636
+ if (remaining.delete(id)) tell(executor, remaining)
637
+ }
638
+ }
639
+ }
640
+
584
641
  /**
585
642
  * Collect every plugin's `executor`, in declaration order, with core's
586
643
  * own appended at the tail: per task, `selectExecutor` takes the first
@@ -638,12 +695,15 @@ export async function teardownPlugins(
638
695
  plugins: readonly VxPlugin[],
639
696
  warn: (message: string) => void,
640
697
  report?: RunReport,
698
+ step: <T>(id: string, work: () => Promise<T>) => Promise<T> = (_id, work) => work(),
641
699
  ): Promise<void> {
642
700
  for (const plugin of plugins) {
643
701
  if (plugin.teardown === undefined) continue
644
702
  const ms = teardownTimeoutMs()
645
703
  try {
646
- const settled = await settleWithin(Promise.resolve(plugin.teardown(report)), ms)
704
+ const settled = await step(`${plugin.name}:teardown`, () =>
705
+ settleWithin(Promise.resolve(plugin.teardown?.(report)), ms),
706
+ )
647
707
  // Same rule as the flush above. A teardown that never settles has left
648
708
  // whatever it owns un-released, and the run exits anyway — silence would
649
709
  // present that as a clean shutdown.
@@ -22,10 +22,9 @@ import type { TaskExecutor } from '../exec/index.js'
22
22
  import type { TaskNode, TaskOutcome, TaskStatus } from '../graph/index.js'
23
23
  import { maskedLine, nearest, UserError } from '../util/index.js'
24
24
  import type { ProjectMeta } from '../workspace/index.js'
25
- import type { EventBus, RunStartInfo } from './events.js'
25
+ import type { EventBus, Logger, RunStartInfo } from '../report/index.js'
26
26
  import type { CiContext } from './run-context.js'
27
27
  import type { TelemetryContext, TelemetrySink } from './telemetry.js'
28
- import type { Logger } from './logger.js'
29
28
 
30
29
  /**
31
30
  * A vx plugin. Contributes any subset of the run-level capabilities —
@@ -138,6 +137,9 @@ export interface VxPlugin {
138
137
  * only when a task will start: a run of confirmed local hits restores
139
138
  * in no order a weight changes. A later plugin's weight for a task
140
139
  * overrides an earlier one's. Return undefined to leave the baseline.
140
+ * The run waits for this answer before its first task, so it should
141
+ * come from what is at hand (local history); better weights that need
142
+ * a fetch go to `ctx.refine` when they arrive, without holding the run.
141
143
  */
142
144
  schedule?(
143
145
  nodes: ReadonlyMap<string, TaskNode>,
@@ -245,8 +247,9 @@ export interface VxPlugin {
245
247
  /**
246
248
  * What a finished run hands each plugin's `teardown`: its facts and every
247
249
  * task, with the names core kept the task's bytes under. Core keeps them
248
- * only when a remote takes the run's writes (CI by default): a failed
249
- * task's log and the files its outputs left, an uncached task's log. Read
250
+ * only when a remote takes the run's writes (CI by default): the log of a
251
+ * task that saved no entry, and a failed task's log and the files its
252
+ * outputs left when `cache.upload.failures` covers the run. Read
250
253
  * them with `ctx.cache.getRun(name)`. Core keeps no manifest of its own; a
251
254
  * plugin that wants one writes it from here (`ctx.cache.put`).
252
255
  */
@@ -449,6 +452,12 @@ export interface ScheduleHookContext extends BaseContext {
449
452
  readonly branch: string | null
450
453
  /** This plugin's entries in the workspace's cache, as `setup` has them. */
451
454
  readonly cache: PluginCache
455
+ /**
456
+ * Replace this plugin's weights while the run goes on: the tasks not yet
457
+ * started re-rank at once. For weights that arrive late (timings fetched
458
+ * from a remote). Ignored in a plan and once the run's tasks are done.
459
+ */
460
+ refine(weights: ReadonlyMap<string, number>): void
452
461
  }
453
462
 
454
463
  /** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
@@ -462,12 +471,10 @@ export interface CacheContext extends BaseContext {
462
471
  }
463
472
 
464
473
  export interface OutputContext extends BaseContext {
465
- /** Core would write to a terminal: stdout (stderr under `json`) is a TTY. */
474
+ /** Core would write to a terminal: stdout is a TTY. */
466
475
  readonly isTTY: boolean
467
476
  /** The run is on CI (`CI` set, or a provider core detects). */
468
477
  readonly ci: boolean
469
- /** `--format json`: stdout carries the machine-readable result. */
470
- readonly json: boolean
471
478
  }
472
479
 
473
480
  export interface ExecutorContext extends BaseContext {
@@ -688,6 +695,8 @@ export interface InstallPluginsArgs {
688
695
  ci?: boolean
689
696
  /** Default null. */
690
697
  branch?: string | null
698
+ /** Each setup as the step `<plugin>:setup`; default: untimed. */
699
+ step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
691
700
  }
692
701
 
693
702
  /**
@@ -824,7 +833,8 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
824
833
  }
825
834
 
826
835
  try {
827
- await plugin.setup(ctx)
836
+ if (args.step === undefined) await plugin.setup(ctx)
837
+ else await args.step(`${plugin.name}:setup`, async () => plugin.setup?.(ctx))
828
838
  } catch (err) {
829
839
  // Earlier plugins' subscriptions too: the caller never gets the
830
840
  // disposer, and a bus that outlives the run would keep them.