@vzn/vx 0.0.645 → 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 (186) hide show
  1. package/README.md +39 -50
  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 +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  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 +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -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,11 +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'>,
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)[] = []
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
+ }
435
485
  const merged = (): ReadonlyMap<string, number> => {
436
486
  const out = new Map<string, number>()
437
487
  for (const set of sets) if (set !== undefined) for (const [id, w] of set) out.set(id, w)
@@ -439,17 +489,43 @@ export async function applyScheduleHooks(
439
489
  }
440
490
  for (const [i, plugin] of plugins.entries()) {
441
491
  if (plugin.schedule === undefined) continue
442
- 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
+ }
443
508
  if (run.refined === undefined) return
444
509
  try {
445
510
  sets[i] = scheduleWeights(plugin, weights, nodes)
446
511
  } catch (err) {
447
- ctx.warn(`[vx] ${err instanceof Error ? err.message : String(err)}; refine ignored`)
512
+ ctx.warn(`[vx] ${thrownMessage(err)}; refine ignored`)
448
513
  return
449
514
  }
450
515
  run.refined(merged())
516
+ for (const l of listeners) {
517
+ if (l.i === i) continue
518
+ try {
519
+ l.listener()
520
+ } catch (err) {
521
+ ctx.warn(`[vx] plugin '${l.plugin.name}' failed in onRefine: ${thrownMessage(err)}`)
522
+ }
523
+ }
451
524
  }
452
- const own = { ...ctx, cache: cacheFor(plugin.name), refine }
525
+ const onRefine = (listener: () => void): void => {
526
+ if (run.refined !== undefined) listeners.push({ plugin, i, listener })
527
+ }
528
+ const own = { ...ctx, cache: cacheFor(plugin.name), refine, onRefine, refining }
453
529
  const call = (): Promise<ReadonlyMap<string, number> | undefined> =>
454
530
  safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
455
531
  const weights = await (run.step === undefined
@@ -529,6 +605,11 @@ export function buildAdmission(
529
605
  verdict.catch(() => {})
530
606
  throw new Error('returned a Promise; admit is synchronous')
531
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
+ }
532
613
  if (verdict !== false) continue
533
614
  if (running.size > 0) return false
534
615
  if (!overridden.has(plugin)) {
@@ -539,7 +620,7 @@ export function buildAdmission(
539
620
  }
540
621
  } catch (err) {
541
622
  broken.add(plugin)
542
- const m = err instanceof Error ? err.message : String(err)
623
+ const m = thrownMessage(err)
543
624
  warn(
544
625
  `[vx] plugin '${plugin.name}' failed in admit: ${m}; admitting every task from here on`,
545
626
  )
@@ -557,13 +638,15 @@ export interface CacheHost extends CacheContext {
557
638
  readonly remotes?: ResolvedRemote[]
558
639
  /** Uploads at once, per remote (`cache.concurrency.upload`). */
559
640
  readonly uploadConcurrency?: number
641
+ /** What another machine's run lent, by plugin: each plugin's `ctx.lent`. */
642
+ readonly lentBy?: () => Readonly<Record<string, string>> | undefined
560
643
  }
561
644
 
562
645
  /**
563
646
  * Collect every plugin's remote (`cache` hook) in declaration order and
564
647
  * layer each over the local store: a lookup asks the local store first,
565
- * then each remote in order (owner, 2026-10-13), and a save reaches every
566
- * 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
567
650
  * plugin moves bytes, it never decides reads or writes); a remote's throw is a miss and a warning naming its plugin. No
568
651
  * remote → the local store alone.
569
652
  */
@@ -571,13 +654,26 @@ export async function resolveCache(
571
654
  plugins: readonly VxPlugin[],
572
655
  host: CacheHost,
573
656
  ): Promise<CacheLayer> {
574
- const { localCache, policy, remotes, uploadConcurrency, ...ctx } = host
657
+ const { localCache, policy, remotes, uploadConcurrency, lentBy, ...base } = host
575
658
  const resolved: ResolvedRemote[] = []
576
659
  for (const plugin of plugins) {
577
660
  if (plugin.cache === undefined) continue
661
+ const ctx: CacheContext =
662
+ lentBy === undefined ? base : { ...base, lent: () => lentBy()?.[plugin.name] }
578
663
  const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
579
664
  if (remote === undefined) continue
580
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
+ }
581
677
  const entry: ResolvedRemote = {
582
678
  remote,
583
679
  plugin: plugin.name,
@@ -608,9 +704,7 @@ const executorPlugin = new WeakMap<TaskExecutor, string>()
608
704
  */
609
705
  export function nameExecutorFailure(executor: TaskExecutor, err: unknown): unknown {
610
706
  if (!executorPlugin.has(executor)) return err
611
- const named = new UserError(
612
- `${executorLabel(executor)} failed in execute: ${err instanceof Error ? err.message : String(err)}`,
613
- )
707
+ const named = new UserError(`${executorLabel(executor)} failed in execute: ${thrownMessage(err)}`)
614
708
  named.cause = err
615
709
  return named
616
710
  }
@@ -623,23 +717,47 @@ export function executorLabel(executor: TaskExecutor): string {
623
717
  : `plugin '${plugin}' (executor '${executor.name}')`
624
718
  }
625
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
+
626
744
  /**
627
745
  * Demand: what each executor still has left to run. An executor that
628
746
  * provisions per task (a container, an allocation, a pod) otherwise has to
629
747
  * guess from `capacity` alone and hold everything until teardown. Built
630
748
  * ONLY for executors that asked: with none declared there is no map, no
631
- * set, and the returned narrower does nothing.
749
+ * set, and every call does nothing.
632
750
  *
633
751
  * The SAME set is handed back every time, narrowed in place: a consumer
634
752
  * reading it later sees the truth rather than a stale snapshot. A hint,
635
753
  * as `admit` is: one that throws is named once and asked no more, where
636
754
  * it surfaced as a bare stack, or mid-run from the completion path
637
- * (item 1022). Returns the call that drops a finished task's id.
755
+ * (item 1022).
638
756
  */
639
757
  export function executorDemand(
640
758
  placed: ReadonlyMap<string, TaskExecutor>,
641
759
  warn: (message: string) => void,
642
- ): (id: string) => void {
760
+ ): ExecutorDemand {
643
761
  const demandOf = new Map<TaskExecutor, Set<string>>()
644
762
  for (const [id, executor] of placed) {
645
763
  if (executor.demand === undefined) continue
@@ -647,31 +765,72 @@ export function executorDemand(
647
765
  if (remaining === undefined) demandOf.set(executor, (remaining = new Set()))
648
766
  remaining.add(id)
649
767
  }
650
- 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>()
651
771
  const tell = (executor: TaskExecutor, remaining: ReadonlySet<string>): void => {
652
772
  const failed = (err: unknown): void => {
653
773
  if (!demandOf.delete(executor)) return
654
- const m = err instanceof Error ? err.message : String(err)
774
+ const m = thrownMessage(err)
655
775
  warn(`[vx] ${executorLabel(executor)} failed in demand: ${m}; not asked again this run`)
656
776
  }
657
777
  try {
658
778
  // An `async demand` that rejects did so where no one listened: an
659
779
  // unhandled rejection, a stack, the run killed. Its rejection is a
660
780
  // throw like any other.
661
- const ret: unknown = executor.demand!(remaining)
781
+ const ret: unknown = executor.demand!(remaining, missed)
662
782
  if (ret instanceof Promise) ret.catch(failed)
663
783
  } catch (err) {
664
784
  failed(err)
665
785
  }
666
786
  }
667
- for (const [executor, remaining] of demandOf) tell(executor, remaining)
668
- return (id) => {
669
- for (const [executor, remaining] of demandOf) {
670
- if (remaining.delete(id)) tell(executor, remaining)
671
- }
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
+ },
672
820
  }
673
821
  }
674
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
+
675
834
  /**
676
835
  * Collect every plugin's `executor`, in declaration order, with core's
677
836
  * own appended at the tail: per task, `selectExecutor` takes the first
@@ -698,6 +857,35 @@ export async function resolveExecutors(
698
857
  `plugin '${plugin.name}' returned executor '${executor.name}' with capacity ${typeof capacity === 'number' ? String(capacity) : JSON.stringify(capacity)}: it must be a positive integer`,
699
858
  )
700
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
+ }
701
889
  executorPlugin.set(executor, plugin.name)
702
890
  executors.push(executor)
703
891
  }
@@ -710,7 +898,10 @@ export async function resolveExecutors(
710
898
 
711
899
  /**
712
900
  * End-of-run plugin lifecycle: each plugin's optional `teardown()`, in
713
- * 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
714
905
  * skipped, never propagated — and each call is time-bounded by
715
906
  * {@link teardownTimeoutMs}. Runs on every exit of a run once its plugins'
716
907
  * factories ran, and of a plan (item 1021); the finally-path disposers
@@ -731,7 +922,8 @@ export async function teardownPlugins(
731
922
  report?: RunReport,
732
923
  step: <T>(id: string, work: () => Promise<T>) => Promise<T> = (_id, work) => work(),
733
924
  ): Promise<void> {
734
- 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))) {
735
927
  if (plugin.teardown === undefined) continue
736
928
  const ms = teardownTimeoutMs()
737
929
  try {
@@ -745,9 +937,7 @@ export async function teardownPlugins(
745
937
  warn(`[vx] plugin '${plugin.name}' teardown timed out after ${ms}ms; it did not complete`)
746
938
  }
747
939
  } catch (err) {
748
- warn(
749
- `[vx] plugin '${plugin.name}' failed in teardown: ${err instanceof Error ? err.message : String(err)}`,
750
- )
940
+ warn(`[vx] plugin '${plugin.name}' failed in teardown: ${thrownMessage(err)}`)
751
941
  }
752
942
  }
753
943
  }