@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -46,6 +46,7 @@ import type {
46
46
  } from './plugin.js'
47
47
  import type { ProjectMeta } from '../workspace/index.js'
48
48
  import { hashExtra } from './lockfile-claim.js'
49
+ import { readOnly, readOnlyNodes } from './read-only.js'
49
50
 
50
51
  /** `a string`, `an array`, `null`, `a number` — for a refusal that names what came back. */
51
52
  function describeValue(v: unknown): string {
@@ -99,18 +100,61 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
99
100
  try {
100
101
  return await fn()
101
102
  } catch (err) {
102
- // Masked here, not by whoever prints it: an embedder of `run()` and
103
- // a plugin's verb print it as they get it, and the throw quotes what the
104
- // plugin was told (a remote's reply, a header) (L-11).
105
- // A plugin's own code survives (cli.md § Error codes): an agent
106
- // branches on it.
107
- throw new UserError(
108
- maskedLine(`plugin '${plugin.name}' failed in ${hook}: ${thrownMessage(err)}`),
109
- errorCode(err) === 'VX_E_INTERNAL' ? 'VX_E_REFUSED' : errorCode(err),
110
- )
103
+ throw pluginFailure(plugin, hook, err)
111
104
  }
112
105
  }
113
106
 
107
+ /**
108
+ * Reads and checks what a hook answered, in the plugin's name as `safe`
109
+ * runs the hook: a getter or Proxy trap in the answer that threw crashed
110
+ * vx with a raw stack. Core's own refusals pass as they are. What `read`
111
+ * returns is what the caller uses, so a getter cannot answer the check one
112
+ * value and the run another.
113
+ */
114
+ function reading<T>(plugin: VxPlugin, hook: string, read: () => T): T {
115
+ try {
116
+ return read()
117
+ } catch (err) {
118
+ if (err instanceof UserError) throw err
119
+ throw pluginFailure(plugin, hook, err)
120
+ }
121
+ }
122
+
123
+ function pluginFailure(plugin: VxPlugin, hook: string, err: unknown): UserError {
124
+ // Masked here, not by whoever prints it: an embedder of `run()` and
125
+ // a plugin's verb print it as they get it, and the throw quotes what the
126
+ // plugin was told (a remote's reply, a header) (L-11).
127
+ // A plugin's own code survives (cli.md § Error codes): an agent
128
+ // branches on it.
129
+ // A plugin's bug (a TypeError, not a message it chose to throw) names
130
+ // where it threw: the message alone left its author no file or line.
131
+ const at = isBug(err) ? pluginThrowSite(err) : undefined
132
+ return new UserError(
133
+ maskedLine(
134
+ `plugin '${plugin.name}' failed in ${hook}: ${thrownMessage(err)}${at === undefined ? '' : ` (at ${at})`}`,
135
+ ),
136
+ errorCode(err) === 'VX_E_INTERNAL' ? 'VX_E_REFUSED' : errorCode(err),
137
+ )
138
+ }
139
+
140
+ const VX_SRC = `${path.resolve(import.meta.dir, '..')}${path.sep}`
141
+
142
+ const isBug = (err: unknown): err is Error =>
143
+ err instanceof TypeError ||
144
+ err instanceof ReferenceError ||
145
+ err instanceof RangeError ||
146
+ err instanceof SyntaxError
147
+
148
+ /** The first stack frame outside vx's own source, relative to the cwd when under it. */
149
+ function pluginThrowSite(err: Error): string | undefined {
150
+ const site = [...String(err.stack ?? '').matchAll(/^\s+at (?:.*\()?(\/[^()\n]+:\d+:\d+)\)?$/gm)]
151
+ .map((m) => m[1]!)
152
+ .find((s) => !s.startsWith(VX_SRC))
153
+ if (site === undefined) return undefined
154
+ const rel = path.relative(process.cwd(), site)
155
+ return rel.startsWith('..') ? site : rel
156
+ }
157
+
114
158
  // --- Pipeline stages ---------------------------------------------------------
115
159
  //
116
160
  // Each stage hands plugins the object core is about to use, in declaration
@@ -156,7 +200,9 @@ export async function applyConfigHooks(
156
200
  for (const plugin of plugins) {
157
201
  if (plugin.config === undefined) continue
158
202
  const returned = await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
159
- assertEditedInPlace(plugin, 'config', returned, workspace, 'the workspace config')
203
+ reading(plugin, 'config', () =>
204
+ assertEditedInPlace(plugin, 'config', returned, workspace, 'the workspace config'),
205
+ )
160
206
  afterEach?.(plugin)
161
207
  }
162
208
  }
@@ -180,13 +226,18 @@ export async function applyDiscoverHooks(
180
226
  const edges: DiscoveredEdges[] = []
181
227
  for (const plugin of plugins) {
182
228
  if (plugin.discover === undefined) continue
183
- const named = await safe(plugin, 'discover', () => plugin.discover!({ ...ctx, projects }))
184
- if (!Array.isArray(named)) {
185
- throw new UserError(
186
- `plugin '${plugin.name}' failed in discover: expected an array, got ${describeValue(named)}`,
187
- )
188
- }
189
- for (const entry of named as readonly DiscoveredProject[]) {
229
+ const answer: unknown = await safe(plugin, 'discover', () =>
230
+ plugin.discover!({ ...ctx, projects: readOnly(projects) }),
231
+ )
232
+ const named = reading(plugin, 'discover', () => {
233
+ if (!Array.isArray(answer)) {
234
+ throw new UserError(
235
+ `plugin '${plugin.name}' failed in discover: expected an array, got ${describeValue(answer)}`,
236
+ )
237
+ }
238
+ return Array.from(answer as unknown[], plainEntry)
239
+ })
240
+ for (const entry of named) {
190
241
  const { meta, added } = await add(entry, plugin)
191
242
  if (added) projects.push(meta)
192
243
  if (entry.deps !== undefined || entry.reach !== undefined) {
@@ -197,6 +248,22 @@ export async function applyDiscoverHooks(
197
248
  return edges
198
249
  }
199
250
 
251
+ /**
252
+ * A `discover` entry's fields, each read once: deps that read as a list
253
+ * to the check and as 5 after it crashed the edge link. `add` checks the
254
+ * copy's shape.
255
+ */
256
+ function plainEntry(entry: unknown): DiscoveredProject {
257
+ if (typeof entry !== 'object' || entry === null) return entry as DiscoveredProject
258
+ const { dir, name, deps, reach } = entry as Record<string, unknown>
259
+ return {
260
+ dir,
261
+ name,
262
+ deps: Array.isArray(deps) ? [...(deps as unknown[])] : deps,
263
+ reach: Array.isArray(reach) ? [...(reach as unknown[])] : reach,
264
+ } as DiscoveredProject
265
+ }
266
+
200
267
  export interface DiscoveredEdges {
201
268
  readonly by: string
202
269
  readonly meta: ProjectMeta
@@ -216,7 +283,9 @@ export async function applyProjectHooks(
216
283
  for (const plugin of plugins) {
217
284
  if (plugin.project === undefined) continue
218
285
  const returned = await safe(plugin, 'project', () => plugin.project!(config, ctx))
219
- assertEditedInPlace(plugin, 'project', returned, config, "the project's config")
286
+ reading(plugin, 'project', () =>
287
+ assertEditedInPlace(plugin, 'project', returned, config, "the project's config"),
288
+ )
220
289
  afterEach?.(plugin)
221
290
  }
222
291
  }
@@ -244,7 +313,9 @@ export async function applyGraphHooks(
244
313
  for (const plugin of plugins) {
245
314
  if (plugin.graph === undefined) continue
246
315
  const returned = await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
247
- assertEditedInPlace(plugin, 'graph', returned, nodes, 'the task graph')
316
+ reading(plugin, 'graph', () =>
317
+ assertEditedInPlace(plugin, 'graph', returned, nodes, 'the task graph'),
318
+ )
248
319
  await safe(plugin, 'graph', () => checkNodeShapes(nodes))
249
320
  afterEach?.(plugin)
250
321
  last = plugin
@@ -310,21 +381,25 @@ export async function applyKeyHooks(
310
381
  ): Promise<void> {
311
382
  for (const node of nodes.values()) {
312
383
  const parts: Array<readonly [string, string]> = []
384
+ const task = readOnly(node)
313
385
  for (const plugin of plugins) {
314
386
  if (plugin.key === undefined) continue
315
- const material = await safe(plugin, 'key', () => plugin.key!(node, ctx))
387
+ const material = await safe(plugin, 'key', () => plugin.key!(task, ctx))
316
388
  if (material === undefined) continue
317
- // `Object.entries` over a string yields its characters as string
318
- // values, so a plugin returning `'v22'` used to fold parts named
319
- // '0', '1', '2' into every key — silently, and permanently. A Map or a
320
- // class instance keeps its data off own enumerable keys, so
321
- // `Object.entries` read it as no material at all: a silent miskey.
322
- if (!isRecord(material)) {
323
- throw new UserError(
324
- `plugin '${plugin.name}' failed in key: returned ${describeValue(material)}, not a record of string values`,
325
- )
326
- }
327
- for (const [name, value] of Object.entries(material)) {
389
+ const entries = reading(plugin, 'key', () => {
390
+ // `Object.entries` over a string yields its characters as string
391
+ // values, so a plugin returning `'v22'` used to fold parts named
392
+ // '0', '1', '2' into every key — silently, and permanently. A Map or a
393
+ // class instance keeps its data off own enumerable keys, so
394
+ // `Object.entries` read it as no material at all: a silent miskey.
395
+ if (!isRecord(material)) {
396
+ throw new UserError(
397
+ `plugin '${plugin.name}' failed in key: returned ${describeValue(material)}, not a record of string values`,
398
+ )
399
+ }
400
+ return Object.entries(material)
401
+ })
402
+ for (const [name, value] of entries) {
328
403
  if (typeof value !== 'string') {
329
404
  throw new UserError(
330
405
  `plugin '${plugin.name}' failed in key: value for '${name}' on ${node.id} is not a string`,
@@ -421,19 +496,30 @@ export async function claimedDigests(
421
496
  digests.call(plugin.fingerprint, file, ctx),
422
497
  )
423
498
  if (answer === undefined) return undefined
424
- const { hash, projects, extras } = (answer ?? {}) as Partial<FingerprintDigests>
425
- if (
426
- typeof hash !== 'string' ||
427
- typeof projects !== 'object' ||
428
- projects === null ||
429
- Object.values(projects).some((d) => typeof d !== 'string') ||
430
- (extras !== undefined &&
431
- (!Array.isArray(extras) || extras.some((rel) => typeof rel !== 'string')))
432
- ) {
433
- throw new UserError(
434
- `plugin '${plugin.name}' failed in fingerprint: digests returned ${describeValue(answer)}, not { hash, projects: { name: digest }, extras?: [path] }`,
435
- )
436
- }
499
+ const { hash, projects, extras } = reading(plugin, 'fingerprint', () => {
500
+ const read = (answer ?? {}) as Partial<Record<keyof FingerprintDigests, unknown>>
501
+ const hash = read.hash
502
+ const projects = read.projects
503
+ const digests =
504
+ typeof projects === 'object' && projects !== null ? Object.entries(projects) : undefined
505
+ const extras = Array.isArray(read.extras) ? [...(read.extras as unknown[])] : read.extras
506
+ if (
507
+ typeof hash !== 'string' ||
508
+ digests === undefined ||
509
+ digests.some(([, d]) => typeof d !== 'string') ||
510
+ (extras !== undefined &&
511
+ (!Array.isArray(extras) || extras.some((rel) => typeof rel !== 'string')))
512
+ ) {
513
+ throw new UserError(
514
+ `plugin '${plugin.name}' failed in fingerprint: digests returned ${describeValue(answer)}, not { hash, projects: { name: digest }, extras?: [path] }`,
515
+ )
516
+ }
517
+ return {
518
+ hash,
519
+ projects: Object.fromEntries(digests) as FingerprintDigests['projects'],
520
+ extras: extras as string[] | undefined,
521
+ }
522
+ })
437
523
  const known = new Set(ctx.projects.map((p) => p.name))
438
524
  const stray = Object.keys(projects).find((n) => !known.has(n))
439
525
  if (stray !== undefined) {
@@ -454,24 +540,26 @@ async function projectNames(
454
540
  ctx: FingerprintContext,
455
541
  ): Promise<Set<string> | undefined> {
456
542
  if (answer === undefined) return undefined
457
- // A plain object is no list: `for…of` threw "is not iterable", naming
458
- // neither the plugin nor the hook.
459
- if (
460
- typeof answer === 'string' ||
461
- typeof answer !== 'object' ||
462
- answer === null ||
463
- typeof (answer as Partial<Iterable<unknown>>)[Symbol.iterator] !== 'function'
464
- ) {
465
- throw new UserError(
466
- `plugin '${plugin.name}' failed in fingerprint: returned ${describeValue(answer)}, not a list of project names`,
467
- )
468
- }
543
+ // A lazy list (a generator) runs the plugin's code as it is read.
544
+ const read = reading(plugin, 'fingerprint', () => {
545
+ // A plain object is no list: `for…of` threw "is not iterable", naming
546
+ // neither the plugin nor the hook.
547
+ if (
548
+ typeof answer === 'string' ||
549
+ typeof answer !== 'object' ||
550
+ answer === null ||
551
+ typeof (answer as Partial<Iterable<unknown>>)[Symbol.iterator] !== 'function'
552
+ ) {
553
+ throw new UserError(
554
+ `plugin '${plugin.name}' failed in fingerprint: returned ${describeValue(answer)}, not a list of project names`,
555
+ )
556
+ }
557
+ return [...(answer as Iterable<unknown>)]
558
+ })
469
559
  const names = new Set<string>()
470
560
  // A name no project carries selected nothing, and the tasks it meant to
471
561
  // select did not run, with no word said.
472
562
  const known = new Set(ctx.projects.map((p) => p.name))
473
- // A lazy list (a generator) runs the plugin's code as it is read.
474
- const read = await safe(plugin, 'fingerprint', () => [...(answer as Iterable<unknown>)])
475
563
  for (const name of read) {
476
564
  if (typeof name !== 'string') {
477
565
  throw new UserError(
@@ -521,6 +609,8 @@ export async function claimedExtraFiles(
521
609
  export interface ScheduleRun {
522
610
  readonly step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
523
611
  readonly refined?: (merged: ReadonlyMap<string, number>) => void
612
+ /** Where `ctx.flaky` names go; none in a plan. */
613
+ readonly flaky?: (taskIds: Iterable<string>) => void
524
614
  }
525
615
 
526
616
  /**
@@ -533,7 +623,7 @@ export interface ScheduleRun {
533
623
  export async function applyScheduleHooks(
534
624
  plugins: readonly VxPlugin[],
535
625
  nodes: ReadonlyMap<string, TaskNode>,
536
- ctx: Omit<ScheduleHookContext, 'cache' | 'refine' | 'onRefine' | 'refining'>,
626
+ ctx: Omit<ScheduleHookContext, 'cache' | 'refine' | 'onRefine' | 'refining' | 'flaky'>,
537
627
  cacheFor: (plugin: string) => PluginCache = () => absentPluginCache,
538
628
  run: ScheduleRun = {},
539
629
  ): Promise<ReadonlyMap<string, number>> {
@@ -543,6 +633,9 @@ export async function applyScheduleHooks(
543
633
  const refining = async (): Promise<void> => {
544
634
  while (promised.size > 0) await Promise.all(promised)
545
635
  }
636
+ // The keys are derived by now: a node a hook edited ran under the key of
637
+ // the one declared.
638
+ const viewed = readOnlyNodes(nodes)
546
639
  const merged = (): ReadonlyMap<string, number> => {
547
640
  const out = new Map<string, number>()
548
641
  for (const set of sets) if (set !== undefined) for (const [id, w] of set) out.set(id, w)
@@ -586,14 +679,16 @@ export async function applyScheduleHooks(
586
679
  const onRefine = (listener: () => void): void => {
587
680
  if (run.refined !== undefined) listeners.push({ plugin, i, listener })
588
681
  }
589
- const own = { ...ctx, cache: cacheFor(plugin.name), refine, onRefine, refining }
682
+ const flaky = (taskIds: Iterable<string>): void => run.flaky?.(taskIds)
683
+ const own = { ...ctx, cache: cacheFor(plugin.name), refine, onRefine, refining, flaky }
590
684
  const call = (): Promise<ReadonlyMap<string, number> | undefined> =>
591
- safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
685
+ safe(plugin, 'schedule', () => plugin.schedule!(viewed, own))
592
686
  const weights = await (run.step === undefined
593
687
  ? call()
594
688
  : run.step(`${plugin.name}:schedule`, call))
595
689
  // A refine that landed while the call ran is the newer set.
596
- if (weights !== undefined) sets[i] ??= scheduleWeights(plugin, weights, nodes)
690
+ if (weights !== undefined)
691
+ sets[i] ??= reading(plugin, 'schedule', () => scheduleWeights(plugin, weights, nodes))
597
692
  }
598
693
  return merged()
599
694
  }
@@ -650,9 +745,13 @@ export function buildAdmission(
650
745
  // The scheduler asks only about ids in this same map, so the lookup
651
746
  // always lands: an unknown-id arm here was deleted with the whole core
652
747
  // suite green (item 651).
653
- const task = nodes.get(id)!
748
+ // Read-only: the key is derived, and a hook's edit ran under it.
749
+ const task = readOnly(nodes.get(id)!)
654
750
  const ctx = {
655
- running: [...running].flatMap((r) => nodes.get(r) ?? []),
751
+ running: [...running].flatMap((r) => {
752
+ const node = nodes.get(r)
753
+ return node === undefined ? [] : [readOnly(node)]
754
+ }),
656
755
  concurrency,
657
756
  }
658
757
  for (const plugin of answering) {
@@ -723,18 +822,20 @@ export async function resolveCache(
723
822
  lentBy === undefined ? base : { ...base, lent: () => lentBy()?.[plugin.name] }
724
823
  const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
725
824
  if (remote === undefined) continue
726
- assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
727
- // `hasMany: true` failed every batch probe, and `missing: true` every
728
- // upload, each said per call as the remote's own fault.
729
- const odd = OPTIONAL_REMOTE_METHODS.filter(
730
- (m) => remote[m] !== undefined && typeof remote[m] !== 'function',
731
- )
732
- if (odd.length > 0) {
733
- throw new UserError(
734
- `plugin '${plugin.name}' returned from cache something that is not a remote cache layer: ` +
735
- `${odd.join(', ')} ${odd.length === 1 ? 'not a function' : 'not functions'}`,
825
+ reading(plugin, 'cache', () => {
826
+ assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
827
+ // `hasMany: true` failed every batch probe, and `missing: true` every
828
+ // upload, each said per call as the remote's own fault.
829
+ const odd = OPTIONAL_REMOTE_METHODS.filter(
830
+ (m) => remote[m] !== undefined && typeof remote[m] !== 'function',
736
831
  )
737
- }
832
+ if (odd.length > 0) {
833
+ throw new UserError(
834
+ `plugin '${plugin.name}' returned from cache something that is not a remote cache layer: ` +
835
+ `${odd.join(', ')} ${odd.length === 1 ? 'not a function' : 'not functions'}`,
836
+ )
837
+ }
838
+ })
738
839
  const entry: ResolvedRemote = {
739
840
  remote,
740
841
  plugin: plugin.name,
@@ -888,8 +989,10 @@ const EXECUTOR_HOOKS = [
888
989
  'footer',
889
990
  'machines',
890
991
  'idealMs',
992
+ 'advice',
891
993
  'profile',
892
994
  'running',
995
+ 'unneeded',
893
996
  ] as const
894
997
 
895
998
  /**
@@ -907,46 +1010,71 @@ export async function resolveExecutors(
907
1010
  if (plugin.executor === undefined) continue
908
1011
  const executor = await safe(plugin, 'executor', () => plugin.executor!(ctx))
909
1012
  if (executor === undefined) continue
910
- assertShape(plugin, 'executor', executor, ['execute'], 'an executor')
911
- if (typeof executor.name !== 'string' || executor.name.length === 0) {
912
- throw new UserError(`plugin '${plugin.name}' returned an executor with no name`)
913
- }
914
- // The pool's room is `active < capacity`: 0 or NaN parks its tasks for good.
915
- const capacity: unknown = executor.capacity
916
- if (capacity !== undefined && !(Number.isInteger(capacity) && (capacity as number) > 0)) {
917
- throw new UserError(
918
- `plugin '${plugin.name}' returned executor '${executor.name}' with capacity ${typeof capacity === 'number' ? String(capacity) : JSON.stringify(capacity)}: it must be a positive integer`,
919
- )
920
- }
921
- // Read as `=== true`: `remote: 'yes'` ran a remote executor as a local one.
922
- for (const flag of EXECUTOR_FLAGS) {
923
- const v: unknown = executor[flag]
924
- if (v !== undefined && typeof v !== 'boolean') {
1013
+ reading(plugin, 'executor', () => {
1014
+ assertShape(plugin, 'executor', executor, ['execute'], 'an executor')
1015
+ if (typeof executor.name !== 'string' || executor.name.length === 0) {
1016
+ throw new UserError(`plugin '${plugin.name}' returned an executor with no name`)
1017
+ }
1018
+ // The pool's room is `active < capacity`: 0 or NaN parks its tasks for good.
1019
+ const capacity: unknown = executor.capacity
1020
+ if (capacity !== undefined && !(Number.isInteger(capacity) && (capacity as number) > 0)) {
925
1021
  throw new UserError(
926
- `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`,
1022
+ `plugin '${plugin.name}' returned executor '${executor.name}' with capacity ${typeof capacity === 'number' ? String(capacity) : JSON.stringify(capacity)}: it must be a positive integer`,
927
1023
  )
928
1024
  }
929
- }
930
- const platforms: unknown = executor.platforms
931
- if (
932
- platforms !== undefined &&
933
- !(
934
- Array.isArray(platforms) &&
935
- platforms.length > 0 &&
936
- platforms.every((p) => (PLATFORMS as readonly unknown[]).includes(p))
937
- )
938
- ) {
939
- throw new UserError(
940
- `plugin '${plugin.name}' returned executor '${executor.name}' with platforms ${JSON.stringify(platforms)}: it must be a non-empty array of ${PLATFORMS.join(', ')}`,
941
- )
942
- }
943
- for (const hook of EXECUTOR_HOOKS) {
944
- const v: unknown = executor[hook]
945
- if (v !== undefined && typeof v !== 'function')
1025
+ // Read as `=== true`: `remote: 'yes'` ran a remote executor as a local one.
1026
+ for (const flag of EXECUTOR_FLAGS) {
1027
+ const v: unknown = executor[flag]
1028
+ if (v !== undefined && typeof v !== 'boolean') {
1029
+ throw new UserError(
1030
+ `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`,
1031
+ )
1032
+ }
1033
+ }
1034
+ const platforms: unknown = executor.platforms
1035
+ if (
1036
+ platforms !== undefined &&
1037
+ !(
1038
+ Array.isArray(platforms) &&
1039
+ platforms.length > 0 &&
1040
+ platforms.every((p) => (PLATFORMS as readonly unknown[]).includes(p))
1041
+ )
1042
+ ) {
946
1043
  throw new UserError(
947
- `plugin '${plugin.name}' returned executor '${executor.name}' with ${hook} that is not a function`,
1044
+ `plugin '${plugin.name}' returned executor '${executor.name}' with platforms ${JSON.stringify(platforms)}: it must be a non-empty array of ${PLATFORMS.join(', ')}`,
948
1045
  )
949
- }
1046
+ }
1047
+ const workers: unknown = executor.workers
1048
+ if (workers !== undefined) {
1049
+ const offered = (platforms ?? []) as readonly unknown[]
1050
+ const names = new Set<unknown>()
1051
+ const ok =
1052
+ Array.isArray(workers) &&
1053
+ workers.length > 0 &&
1054
+ workers.every(
1055
+ (w: unknown) =>
1056
+ Array.isArray(w) &&
1057
+ typeof w[0] === 'string' &&
1058
+ w[0].length > 0 &&
1059
+ !names.has(w[0]) &&
1060
+ names.add(w[0]) !== undefined &&
1061
+ Array.isArray(w[1]) &&
1062
+ w[1].length > 0 &&
1063
+ (w[1] as unknown[]).every((p) => offered.includes(p)),
1064
+ )
1065
+ if (!ok)
1066
+ throw new UserError(
1067
+ `plugin '${plugin.name}' returned executor '${executor.name}' with workers ${JSON.stringify(workers)}: it must be a non-empty array of distinct [name, platforms] pairs, each platform one of its platforms`,
1068
+ )
1069
+ }
1070
+ for (const hook of EXECUTOR_HOOKS) {
1071
+ const v: unknown = executor[hook]
1072
+ if (v !== undefined && typeof v !== 'function')
1073
+ throw new UserError(
1074
+ `plugin '${plugin.name}' returned executor '${executor.name}' with ${hook} that is not a function`,
1075
+ )
1076
+ }
1077
+ })
950
1078
  executorPlugin.set(executor, plugin.name)
951
1079
  executors.push(executor)
952
1080
  }