@vzn/vx 0.0.646 → 0.0.648

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -18,8 +18,15 @@ import {
18
18
  type ResolvedRemote,
19
19
  } from '../cache/index.js'
20
20
  import { localExecutor, type TaskExecutor } from '../exec/index.js'
21
- import { maskedLine, settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
22
- import type { ProjectConfig, WorkspaceConfig } from '../config.js'
21
+ import {
22
+ errorCode,
23
+ maskedLine,
24
+ settleWithin,
25
+ teardownTimeoutMs,
26
+ thrownMessage,
27
+ UserError,
28
+ } from '../util/index.js'
29
+ import { PLATFORMS, type ProjectConfig, type WorkspaceConfig } from '../config.js'
23
30
  import { checkGraph, type GraphRules, type TaskNode } from '../graph/index.js'
24
31
  import type {
25
32
  CacheContext,
@@ -58,6 +65,8 @@ function isRecord(v: unknown): v is Record<string, unknown> {
58
65
  /** Every method a `cache` hook's `RemoteCacheLayer` requires (`hasMany` is optional). */
59
66
  export const REMOTE_CACHE_METHODS: readonly string[] = ['has', 'get', 'put']
60
67
 
68
+ const OPTIONAL_REMOTE_METHODS = ['open', 'lend', 'hasMany', 'missing'] as const
69
+
61
70
  /** What a capability hook handed back must be what the seam runs. */
62
71
  function assertShape(
63
72
  plugin: VxPlugin,
@@ -91,10 +100,11 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
91
100
  // Masked here, not by whoever prints it: an embedder of `run()` and
92
101
  // a plugin's verb print it as they get it, and the throw quotes what the
93
102
  // plugin was told (a remote's reply, a header) (L-11).
103
+ // A plugin's own code survives (cli.md § Error codes): an agent
104
+ // branches on it.
94
105
  throw new UserError(
95
- maskedLine(
96
- `plugin '${plugin.name}' failed in ${hook}: ${err instanceof Error ? err.message : String(err)}`,
97
- ),
106
+ maskedLine(`plugin '${plugin.name}' failed in ${hook}: ${thrownMessage(err)}`),
107
+ errorCode(err) === 'VX_E_INTERNAL' ? 'VX_E_REFUSED' : errorCode(err),
98
108
  )
99
109
  }
100
110
  }
@@ -267,6 +277,14 @@ function checkNodeShapes(nodes: Map<string, TaskNode>): void {
267
277
  throw new Error(`${key}'s ${field} is ${shown(fields[field])}, not a name`)
268
278
  }
269
279
  }
280
+ // A node copied under a new id kept the old taskName: the key read
281
+ // the id, `--exclude-dependencies` the name, and the two disagreed.
282
+ const id = (node as { id?: unknown }).id
283
+ if (id !== `${fields.projectName as string}#${fields.taskName as string}`) {
284
+ throw new Error(
285
+ `${key}'s id is ${shown(id)}, not its projectName#taskName (${fields.projectName as string}#${fields.taskName as string})`,
286
+ )
287
+ }
270
288
  const dir = fields.projectDir
271
289
  if (typeof dir !== 'string' || !path.isAbsolute(dir)) {
272
290
  throw new Error(`${key}'s projectDir is ${shown(dir)}, not an absolute path`)
@@ -274,6 +292,10 @@ function checkNodeShapes(nodes: Map<string, TaskNode>): void {
274
292
  }
275
293
  }
276
294
 
295
+ // With `u`, a well-formed pair is one code point, so `Cs` matches only a lone half.
296
+ const LONE_SURROGATE = /\p{Cs}/u
297
+ const LONE_SURROGATES = /\p{Cs}/gu
298
+
277
299
  /**
278
300
  * `key` stage: every plugin may add `{ name: value }` material to every
279
301
  * task. Stored on the node as sorted `[plugin/name, value]` pairs so the
@@ -313,6 +335,13 @@ export async function applyKeyHooks(
313
335
  `plugin '${plugin.name}' failed in key: a name on ${node.id} holds a NUL, the fold's delimiter`,
314
336
  )
315
337
  }
338
+ // The fold hashes a part's UTF-8, which writes every lone surrogate
339
+ // as U+FFFD: `'\ud800'` and `'\ud801'` folded one key.
340
+ if (LONE_SURROGATE.test(name) || LONE_SURROGATE.test(value)) {
341
+ throw new UserError(
342
+ `plugin '${plugin.name}' failed in key: part '${name.replace(LONE_SURROGATES, '�')}' on ${node.id} holds a lone UTF-16 surrogate, which the key cannot tell from another`,
343
+ )
344
+ }
316
345
  parts.push([`${plugin.name}/${name}`, value])
317
346
  }
318
347
  }
@@ -364,18 +393,35 @@ export async function claimedAffected(
364
393
  ): Promise<Set<string> | undefined> {
365
394
  const answer = await safe(plugin, 'fingerprint', () => plugin.fingerprint!.affected(change, ctx))
366
395
  if (answer === undefined) return undefined
367
- if (typeof answer === 'string' || typeof answer !== 'object' || answer === null) {
396
+ // A plain object is no list: `for…of` threw "is not iterable", naming
397
+ // neither the plugin nor the hook.
398
+ if (
399
+ typeof answer === 'string' ||
400
+ typeof answer !== 'object' ||
401
+ answer === null ||
402
+ typeof (answer as Partial<Iterable<unknown>>)[Symbol.iterator] !== 'function'
403
+ ) {
368
404
  throw new UserError(
369
405
  `plugin '${plugin.name}' failed in fingerprint: returned ${describeValue(answer)}, not a list of project names`,
370
406
  )
371
407
  }
372
408
  const names = new Set<string>()
373
- for (const name of answer as Iterable<unknown>) {
409
+ // A name no project carries selected nothing, and the tasks it meant to
410
+ // select did not run, with no word said.
411
+ const known = new Set(ctx.projects.map((p) => p.name))
412
+ // A lazy list (a generator) runs the plugin's code as it is read.
413
+ const read = await safe(plugin, 'fingerprint', () => [...(answer as Iterable<unknown>)])
414
+ for (const name of read) {
374
415
  if (typeof name !== 'string') {
375
416
  throw new UserError(
376
417
  `plugin '${plugin.name}' failed in fingerprint: affected project ${describeValue(name)} is not a name`,
377
418
  )
378
419
  }
420
+ if (!known.has(name)) {
421
+ throw new UserError(
422
+ `plugin '${plugin.name}' failed in fingerprint: affected project ${JSON.stringify(name)} is not a project in this workspace`,
423
+ )
424
+ }
379
425
  names.add(name)
380
426
  }
381
427
  return names
@@ -386,21 +432,20 @@ export async function claimedAffected(
386
432
  * (`FingerprintClaim.extraFiles`); `undefined` when it names none it can
387
433
  * answer for, and a plugin that throws is refused by its name.
388
434
  */
389
- export function claimedExtraFiles(
435
+ export async function claimedExtraFiles(
390
436
  plugin: VxPlugin,
391
437
  file: string,
392
438
  bytes: Uint8Array,
393
- ): readonly string[] | undefined {
439
+ ctx: FingerprintContext,
440
+ ): Promise<readonly string[] | undefined> {
394
441
  const read = plugin.fingerprint?.extraFiles
395
442
  if (read === undefined) return undefined
396
443
  let names: unknown
397
444
  try {
398
- names = read.call(plugin.fingerprint, file, bytes)
445
+ names = await read.call(plugin.fingerprint, file, bytes, ctx)
399
446
  } catch (err) {
400
447
  throw new UserError(
401
- maskedLine(
402
- `plugin '${plugin.name}' failed in fingerprint: ${err instanceof Error ? err.message : String(err)}`,
403
- ),
448
+ maskedLine(`plugin '${plugin.name}' failed in fingerprint: ${thrownMessage(err)}`),
404
449
  )
405
450
  }
406
451
  if (!Array.isArray(names) || names.some((n) => typeof n !== 'string')) {
@@ -427,12 +472,16 @@ export interface ScheduleRun {
427
472
  export async function applyScheduleHooks(
428
473
  plugins: readonly VxPlugin[],
429
474
  nodes: ReadonlyMap<string, TaskNode>,
430
- ctx: Omit<ScheduleHookContext, 'cache' | 'refine' | 'onRefine'>,
475
+ ctx: Omit<ScheduleHookContext, 'cache' | 'refine' | 'onRefine' | 'refining'>,
431
476
  cacheFor: (plugin: string) => PluginCache = () => absentPluginCache,
432
477
  run: ScheduleRun = {},
433
478
  ): Promise<ReadonlyMap<string, number>> {
434
479
  const sets: (ReadonlyMap<string, number> | undefined)[] = []
435
480
  const listeners: { plugin: VxPlugin; i: number; listener: () => void }[] = []
481
+ const promised = new Set<Promise<void>>()
482
+ const refining = async (): Promise<void> => {
483
+ while (promised.size > 0) await Promise.all(promised)
484
+ }
436
485
  const merged = (): ReadonlyMap<string, number> => {
437
486
  const out = new Map<string, number>()
438
487
  for (const set of sets) if (set !== undefined) for (const [id, w] of set) out.set(id, w)
@@ -440,12 +489,27 @@ export async function applyScheduleHooks(
440
489
  }
441
490
  for (const [i, plugin] of plugins.entries()) {
442
491
  if (plugin.schedule === undefined) continue
443
- const refine = (weights: ReadonlyMap<string, number>): void => {
492
+ const refine = (
493
+ weights: ReadonlyMap<string, number> | Promise<ReadonlyMap<string, number> | undefined>,
494
+ ): void => {
495
+ if (weights instanceof Promise) {
496
+ const p: Promise<void> = weights
497
+ .then(
498
+ (w) => {
499
+ if (w !== undefined) refine(w)
500
+ },
501
+ (err: unknown) =>
502
+ ctx.warn(`[vx] plugin '${plugin.name}' failed to refine: ${thrownMessage(err)}`),
503
+ )
504
+ .finally(() => promised.delete(p))
505
+ promised.add(p)
506
+ return
507
+ }
444
508
  if (run.refined === undefined) return
445
509
  try {
446
510
  sets[i] = scheduleWeights(plugin, weights, nodes)
447
511
  } catch (err) {
448
- ctx.warn(`[vx] ${err instanceof Error ? err.message : String(err)}; refine ignored`)
512
+ ctx.warn(`[vx] ${thrownMessage(err)}; refine ignored`)
449
513
  return
450
514
  }
451
515
  run.refined(merged())
@@ -454,16 +518,14 @@ export async function applyScheduleHooks(
454
518
  try {
455
519
  l.listener()
456
520
  } catch (err) {
457
- ctx.warn(
458
- `[vx] plugin '${l.plugin.name}' failed in onRefine: ${err instanceof Error ? err.message : String(err)}`,
459
- )
521
+ ctx.warn(`[vx] plugin '${l.plugin.name}' failed in onRefine: ${thrownMessage(err)}`)
460
522
  }
461
523
  }
462
524
  }
463
525
  const onRefine = (listener: () => void): void => {
464
526
  if (run.refined !== undefined) listeners.push({ plugin, i, listener })
465
527
  }
466
- const own = { ...ctx, cache: cacheFor(plugin.name), refine, onRefine }
528
+ const own = { ...ctx, cache: cacheFor(plugin.name), refine, onRefine, refining }
467
529
  const call = (): Promise<ReadonlyMap<string, number> | undefined> =>
468
530
  safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
469
531
  const weights = await (run.step === undefined
@@ -543,6 +605,11 @@ export function buildAdmission(
543
605
  verdict.catch(() => {})
544
606
  throw new Error('returned a Promise; admit is synchronous')
545
607
  }
608
+ // `0` or `42` admitted beside a running task with no word: a policy
609
+ // answering a count enforced nothing.
610
+ if (verdict !== undefined && typeof verdict !== 'boolean') {
611
+ throw new Error(`returned ${describeValue(verdict)}, not true or false`)
612
+ }
546
613
  if (verdict !== false) continue
547
614
  if (running.size > 0) return false
548
615
  if (!overridden.has(plugin)) {
@@ -553,7 +620,7 @@ export function buildAdmission(
553
620
  }
554
621
  } catch (err) {
555
622
  broken.add(plugin)
556
- const m = err instanceof Error ? err.message : String(err)
623
+ const m = thrownMessage(err)
557
624
  warn(
558
625
  `[vx] plugin '${plugin.name}' failed in admit: ${m}; admitting every task from here on`,
559
626
  )
@@ -578,8 +645,8 @@ export interface CacheHost extends CacheContext {
578
645
  /**
579
646
  * Collect every plugin's remote (`cache` hook) in declaration order and
580
647
  * layer each over the local store: a lookup asks the local store first,
581
- * then each remote in order (owner, 2026-10-13), and a save reaches every
582
- * remote. The run's policy alone gates each remote (M, 2026-10-16: a
648
+ * then each remote in order (2026-10-13), and a save reaches every
649
+ * remote. The run's policy alone gates each remote (2026-10-16: a
583
650
  * plugin moves bytes, it never decides reads or writes); a remote's throw is a miss and a warning naming its plugin. No
584
651
  * remote → the local store alone.
585
652
  */
@@ -596,6 +663,17 @@ export async function resolveCache(
596
663
  const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
597
664
  if (remote === undefined) continue
598
665
  assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
666
+ // `hasMany: true` failed every batch probe, and `missing: true` every
667
+ // upload, each said per call as the remote's own fault.
668
+ const odd = OPTIONAL_REMOTE_METHODS.filter(
669
+ (m) => remote[m] !== undefined && typeof remote[m] !== 'function',
670
+ )
671
+ if (odd.length > 0) {
672
+ throw new UserError(
673
+ `plugin '${plugin.name}' returned from cache something that is not a remote cache layer: ` +
674
+ `${odd.join(', ')} ${odd.length === 1 ? 'not a function' : 'not functions'}`,
675
+ )
676
+ }
599
677
  const entry: ResolvedRemote = {
600
678
  remote,
601
679
  plugin: plugin.name,
@@ -626,9 +704,7 @@ const executorPlugin = new WeakMap<TaskExecutor, string>()
626
704
  */
627
705
  export function nameExecutorFailure(executor: TaskExecutor, err: unknown): unknown {
628
706
  if (!executorPlugin.has(executor)) return err
629
- const named = new UserError(
630
- `${executorLabel(executor)} failed in execute: ${err instanceof Error ? err.message : String(err)}`,
631
- )
707
+ const named = new UserError(`${executorLabel(executor)} failed in execute: ${thrownMessage(err)}`)
632
708
  named.cause = err
633
709
  return named
634
710
  }
@@ -641,23 +717,47 @@ export function executorLabel(executor: TaskExecutor): string {
641
717
  : `plugin '${plugin}' (executor '${executor.name}')`
642
718
  }
643
719
 
720
+ /** What `executorDemand` returns: the run's three calls into it. */
721
+ export interface ExecutorDemand {
722
+ /** Whether any executor asked: with none, every call does nothing. */
723
+ readonly any: boolean
724
+ /** A task finished: dropped, and its executor told once demand settled. */
725
+ finished(id: string): void
726
+ /**
727
+ * The placed tasks the run's cache answers: dropped, and each executor
728
+ * told its demand for the first time. Before it a finish narrows
729
+ * silently, so an executor never sizes for what restores. The first
730
+ * call counts; the rest do nothing.
731
+ */
732
+ settle(hits: Iterable<string>, missed?: Iterable<string>): void
733
+ /** A task about to execute: back in if `settle` dropped it (its artifact was gone). */
734
+ executing(id: string): void
735
+ }
736
+
737
+ const NO_DEMAND: ExecutorDemand = {
738
+ any: false,
739
+ finished: () => {},
740
+ settle: () => {},
741
+ executing: () => {},
742
+ }
743
+
644
744
  /**
645
745
  * Demand: what each executor still has left to run. An executor that
646
746
  * provisions per task (a container, an allocation, a pod) otherwise has to
647
747
  * guess from `capacity` alone and hold everything until teardown. Built
648
748
  * ONLY for executors that asked: with none declared there is no map, no
649
- * set, and the returned narrower does nothing.
749
+ * set, and every call does nothing.
650
750
  *
651
751
  * The SAME set is handed back every time, narrowed in place: a consumer
652
752
  * reading it later sees the truth rather than a stale snapshot. A hint,
653
753
  * as `admit` is: one that throws is named once and asked no more, where
654
754
  * it surfaced as a bare stack, or mid-run from the completion path
655
- * (item 1022). Returns the call that drops a finished task's id.
755
+ * (item 1022).
656
756
  */
657
757
  export function executorDemand(
658
758
  placed: ReadonlyMap<string, TaskExecutor>,
659
759
  warn: (message: string) => void,
660
- ): (id: string) => void {
760
+ ): ExecutorDemand {
661
761
  const demandOf = new Map<TaskExecutor, Set<string>>()
662
762
  for (const [id, executor] of placed) {
663
763
  if (executor.demand === undefined) continue
@@ -665,31 +765,72 @@ export function executorDemand(
665
765
  if (remaining === undefined) demandOf.set(executor, (remaining = new Set()))
666
766
  remaining.add(id)
667
767
  }
668
- if (demandOf.size === 0) return () => {}
768
+ if (demandOf.size === 0) return NO_DEMAND
769
+ // Of the placed tasks, those the cache was asked and holds nowhere.
770
+ const missed = new Set<string>()
669
771
  const tell = (executor: TaskExecutor, remaining: ReadonlySet<string>): void => {
670
772
  const failed = (err: unknown): void => {
671
773
  if (!demandOf.delete(executor)) return
672
- const m = err instanceof Error ? err.message : String(err)
774
+ const m = thrownMessage(err)
673
775
  warn(`[vx] ${executorLabel(executor)} failed in demand: ${m}; not asked again this run`)
674
776
  }
675
777
  try {
676
778
  // An `async demand` that rejects did so where no one listened: an
677
779
  // unhandled rejection, a stack, the run killed. Its rejection is a
678
780
  // throw like any other.
679
- const ret: unknown = executor.demand!(remaining)
781
+ const ret: unknown = executor.demand!(remaining, missed)
680
782
  if (ret instanceof Promise) ret.catch(failed)
681
783
  } catch (err) {
682
784
  failed(err)
683
785
  }
684
786
  }
685
- for (const [executor, remaining] of demandOf) tell(executor, remaining)
686
- return (id) => {
687
- for (const [executor, remaining] of demandOf) {
688
- if (remaining.delete(id)) tell(executor, remaining)
689
- }
787
+ let settled = false
788
+ const done = new Set<string>()
789
+ const ran = new Set<string>()
790
+ const dropped = new Set<string>()
791
+ return {
792
+ any: true,
793
+ finished(id) {
794
+ done.add(id)
795
+ missed.delete(id)
796
+ for (const [executor, remaining] of demandOf) {
797
+ if (remaining.delete(id) && settled) tell(executor, remaining)
798
+ }
799
+ },
800
+ settle(hits, misses = []) {
801
+ if (settled) return
802
+ settled = true
803
+ for (const id of misses) if (placed.has(id) && !done.has(id)) missed.add(id)
804
+ for (const id of hits) {
805
+ if (ran.has(id) || done.has(id)) continue
806
+ const executor = placed.get(id)
807
+ if (executor !== undefined && demandOf.get(executor)?.delete(id)) dropped.add(id)
808
+ }
809
+ for (const [executor, remaining] of demandOf) tell(executor, remaining)
810
+ },
811
+ executing(id) {
812
+ ran.add(id)
813
+ if (!dropped.delete(id)) return
814
+ const executor = placed.get(id)!
815
+ const remaining = demandOf.get(executor)
816
+ if (remaining === undefined) return
817
+ remaining.add(id)
818
+ tell(executor, remaining)
819
+ },
690
820
  }
691
821
  }
692
822
 
823
+ const EXECUTOR_FLAGS = ['remote', 'sandbox', 'servers', 'runtime', 'exclusive', 'timeouts'] as const
824
+ const EXECUTOR_HOOKS = [
825
+ 'accepts',
826
+ 'demand',
827
+ 'footer',
828
+ 'machines',
829
+ 'idealMs',
830
+ 'profile',
831
+ 'running',
832
+ ] as const
833
+
693
834
  /**
694
835
  * Collect every plugin's `executor`, in declaration order, with core's
695
836
  * own appended at the tail: per task, `selectExecutor` takes the first
@@ -716,6 +857,35 @@ export async function resolveExecutors(
716
857
  `plugin '${plugin.name}' returned executor '${executor.name}' with capacity ${typeof capacity === 'number' ? String(capacity) : JSON.stringify(capacity)}: it must be a positive integer`,
717
858
  )
718
859
  }
860
+ // Read as `=== true`: `remote: 'yes'` ran a remote executor as a local one.
861
+ for (const flag of EXECUTOR_FLAGS) {
862
+ const v: unknown = executor[flag]
863
+ if (v !== undefined && typeof v !== 'boolean') {
864
+ throw new UserError(
865
+ `plugin '${plugin.name}' returned executor '${executor.name}' with ${flag} ${typeof v === 'string' ? JSON.stringify(v) : typeof v === 'object' && v !== null ? describeValue(v) : String(v as number)}: it must be true or false`,
866
+ )
867
+ }
868
+ }
869
+ const platforms: unknown = executor.platforms
870
+ if (
871
+ platforms !== undefined &&
872
+ !(
873
+ Array.isArray(platforms) &&
874
+ platforms.length > 0 &&
875
+ platforms.every((p) => (PLATFORMS as readonly unknown[]).includes(p))
876
+ )
877
+ ) {
878
+ throw new UserError(
879
+ `plugin '${plugin.name}' returned executor '${executor.name}' with platforms ${JSON.stringify(platforms)}: it must be a non-empty array of ${PLATFORMS.join(', ')}`,
880
+ )
881
+ }
882
+ for (const hook of EXECUTOR_HOOKS) {
883
+ const v: unknown = executor[hook]
884
+ if (v !== undefined && typeof v !== 'function')
885
+ throw new UserError(
886
+ `plugin '${plugin.name}' returned executor '${executor.name}' with ${hook} that is not a function`,
887
+ )
888
+ }
719
889
  executorPlugin.set(executor, plugin.name)
720
890
  executors.push(executor)
721
891
  }
@@ -728,7 +898,10 @@ export async function resolveExecutors(
728
898
 
729
899
  /**
730
900
  * End-of-run plugin lifecycle: each plugin's optional `teardown()`, in
731
- * declaration order. Crash-isolated — a throwing teardown is logged and
901
+ * declaration order, a plugin that supplies a remote cache after every
902
+ * other: a teardown may write through the cache (`RunReport`), and one
903
+ * that revoked its token first refused the write (main 7bdbf01: the
904
+ * schedule's timings, put after the Vercel token's revoke). Crash-isolated — a throwing teardown is logged and
732
905
  * skipped, never propagated — and each call is time-bounded by
733
906
  * {@link teardownTimeoutMs}. Runs on every exit of a run once its plugins'
734
907
  * factories ran, and of a plan (item 1021); the finally-path disposers
@@ -749,7 +922,8 @@ export async function teardownPlugins(
749
922
  report?: RunReport,
750
923
  step: <T>(id: string, work: () => Promise<T>) => Promise<T> = (_id, work) => work(),
751
924
  ): Promise<void> {
752
- for (const plugin of plugins) {
925
+ const last = (p: VxPlugin): number => (p.cache === undefined ? 0 : 1)
926
+ for (const plugin of plugins.toSorted((a, b) => last(a) - last(b))) {
753
927
  if (plugin.teardown === undefined) continue
754
928
  const ms = teardownTimeoutMs()
755
929
  try {
@@ -763,9 +937,7 @@ export async function teardownPlugins(
763
937
  warn(`[vx] plugin '${plugin.name}' teardown timed out after ${ms}ms; it did not complete`)
764
938
  }
765
939
  } catch (err) {
766
- warn(
767
- `[vx] plugin '${plugin.name}' failed in teardown: ${err instanceof Error ? err.message : String(err)}`,
768
- )
940
+ warn(`[vx] plugin '${plugin.name}' failed in teardown: ${thrownMessage(err)}`)
769
941
  }
770
942
  }
771
943
  }