@vzn/vx 0.0.18 → 0.0.21

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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,182 @@
1
+ // Where each task runs: the placement of a graph over the resolved
2
+ // executors, and the plan-mode view of it. Split from run.ts on 2026-09-10
3
+ // (pure motion). A task is pinned to this machine when it is persistent,
4
+ // depends on a persistent task, or says `exec.remote: false`; everything
5
+ // else asks the executors in declaration order, and the local floor takes
6
+ // what nothing claimed.
7
+
8
+ import { machineParallelism } from '../util/index.js'
9
+ import { selectExecutor, type TaskExecutor } from '../exec/index.js'
10
+ import { isGroupTask, type TaskNode } from '../graph/index.js'
11
+ import { resolveDownloadModes } from './download-policy.js'
12
+ import type { Logger } from './logger.js'
13
+ import { resolveExecutors } from './plugin-host.js'
14
+ import type { prepareRun } from './prepare.js'
15
+
16
+ /**
17
+ * A task is pinned to this machine when it is persistent, transitively
18
+ * depends on a persistent task (a worker cannot reach a port on the
19
+ * submitter), or declares `exec.remote: false`.
20
+ */
21
+ export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
22
+ const pinned = new Set<string>()
23
+ const memo = new Map<string, boolean>()
24
+ const visit = (id: string): boolean => {
25
+ const known = memo.get(id)
26
+ if (known !== undefined) return known
27
+ const node = nodes.get(id)
28
+ if (node === undefined) return false
29
+ memo.set(id, false) // cycle guard; the graph builder already rejects cycles
30
+ const result =
31
+ node.config.exec?.persistent !== undefined ||
32
+ node.config.exec?.remote === false ||
33
+ node.deps.some((d) => visit(d))
34
+ memo.set(id, result)
35
+ if (result) pinned.add(id)
36
+ return result
37
+ }
38
+ for (const id of nodes.keys()) visit(id)
39
+ return pinned
40
+ }
41
+
42
+ export interface Placements {
43
+ executors: Map<string, TaskExecutor>
44
+ /**
45
+ * `exec.remote: 'only'` tasks that no REMOTE executor took — a NO-OP on
46
+ * this machine: never executed, declared outputs never cleaned or
47
+ * restored. The task exists to produce a remote input tree; without a
48
+ * remote pool, dependents use the machine's ambient state exactly as they
49
+ * did before the field existed.
50
+ */
51
+ remoteOnlyNoop: Set<string>
52
+ /** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
53
+ remoteOnly: Set<string>
54
+ }
55
+
56
+ export function placeTasks(
57
+ nodes: Map<string, TaskNode>,
58
+ executors: readonly TaskExecutor[],
59
+ pinAllLocal = false,
60
+ ): Placements {
61
+ const pinned = pinnedLocalSet(nodes)
62
+ const placements: Placements = {
63
+ executors: new Map(),
64
+ remoteOnlyNoop: new Set(),
65
+ remoteOnly: new Set(),
66
+ }
67
+ for (const node of nodes.values()) {
68
+ if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
69
+ const executor = selectExecutor(executors, {
70
+ taskId: node.id,
71
+ projectName: node.projectName,
72
+ projectDir: node.projectDir,
73
+ command: node.config.exec!.command,
74
+ pinnedLocal: pinAllLocal || pinned.has(node.id),
75
+ cacheable: node.config.cache !== undefined,
76
+ })
77
+ placements.executors.set(node.id, executor)
78
+ if (node.config.exec?.remote === 'only') {
79
+ // A pinned 'only' task (it transitively depends on a persistent one)
80
+ // lands here too: pinning wins, so it noops rather than shipping.
81
+ if (executor.remote === true) placements.remoteOnly.add(node.id)
82
+ else placements.remoteOnlyNoop.add(node.id)
83
+ }
84
+ }
85
+ return placements
86
+ }
87
+
88
+ /**
89
+ * `executorOf` for `planRun`, or nothing. Declining plugins, a single
90
+ * executor, or a resolution error all yield nothing: `--dry` is an
91
+ * inspection command and must not fail over a label. The error is still
92
+ * said, on the status line, in the plugin's name: the run this plan
93
+ * previews would refuse on it, and a plan that hid that would read as
94
+ * "everything lands locally".
95
+ */
96
+ export async function planExecutorOf(
97
+ prepared: Awaited<ReturnType<typeof prepareRun>>,
98
+ log: Logger,
99
+ policy: 'all' | 'toplevel' | 'none',
100
+ ): Promise<{
101
+ executorOf?: (id: string) => string | undefined
102
+ downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
103
+ downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
104
+ }> {
105
+ let executors: readonly TaskExecutor[]
106
+ try {
107
+ executors = await resolveExecutors(prepared.plugins, {
108
+ workspaceRoot: prepared.workspaceRoot,
109
+ cacheDir: prepared.cacheDir,
110
+ warn: (m: string) => log.status(m),
111
+ concurrency: machineParallelism(),
112
+ })
113
+ } catch (err) {
114
+ log.status(
115
+ `[vx] placement not shown — ${err instanceof Error ? err.message : String(err)} (the run would refuse on it)`,
116
+ )
117
+ return {}
118
+ }
119
+ const placements = placeTasks(prepared.nodes, executors)
120
+ // Download modes need placement regardless of how many executors there
121
+ // are (a single REMOTE one still defers); executor LABELS only earn their
122
+ // column when there is a choice to report.
123
+ const download =
124
+ policy === 'all'
125
+ ? undefined
126
+ : resolveDownloadModes({
127
+ nodes: prepared.nodes,
128
+ policy,
129
+ localPlaced: new Set(
130
+ [...prepared.nodes.keys()].filter(
131
+ (id) => placements.executors.get(id)?.remote !== true,
132
+ ),
133
+ ),
134
+ remoteOnly: placements.remoteOnly,
135
+ })
136
+ return {
137
+ ...(executors.length < 2
138
+ ? {}
139
+ : {
140
+ executorOf: (id: string) =>
141
+ placements.remoteOnlyNoop.has(id) ? 'noop' : placements.executors.get(id)?.name,
142
+ }),
143
+ ...(download === undefined
144
+ ? {}
145
+ : {
146
+ downloadOf: (id: string) => download.modeOf.get(id),
147
+ downloadDowngrades: [...download.downgrades].map(([taskId, reason]) => ({
148
+ taskId,
149
+ reason,
150
+ })),
151
+ }),
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Stands in for the executor of a task that was never placed — a group task
157
+ * (runs nothing) or a persistent one (`executePersistentTask` owns it and
158
+ * never reads this field). It THROWS rather than silently picking some
159
+ * executor from the list, so a refactor that routes such a task through the
160
+ * exec path fails loudly instead of shipping a localhost server to a worker.
161
+ */
162
+ export const UNPLACED_EXECUTOR: TaskExecutor = {
163
+ name: 'unplaced',
164
+ execute: (req) => {
165
+ throw new Error(`internal error: ${req.taskId} reached an executor without being placed`)
166
+ },
167
+ }
168
+
169
+ export function hasPooledExecutor(executors: readonly TaskExecutor[]): boolean {
170
+ return executors.some((e) => e.capacity !== undefined)
171
+ }
172
+
173
+ export function poolOfPlacement(
174
+ placements: Placements,
175
+ ): (id: string) => { name: string; capacity: number } | undefined {
176
+ return (id) => {
177
+ const executor = placements.executors.get(id)
178
+ return executor?.capacity === undefined
179
+ ? undefined
180
+ : { name: executor.name, capacity: executor.capacity }
181
+ }
182
+ }
@@ -1,20 +1,22 @@
1
1
  // Plugin consultation for the run-level extension points (cache / executor).
2
- // Each function asks the plugins in order. Nothing is applied by default:
3
- // core's own executor and cache are plugins under src/plugins/ that a
4
- // workspace declares like any other, so a list with no provider for a
5
- // load-bearing capability fails fast with MISSING_PLUGIN_HINT.
2
+ // Each function asks the plugins in order, and CORE IS THE TAIL: running
3
+ // here and caching here are what those words mean in the absence of a
4
+ // plugin placing work elsewhere or storing it somewhere else. A plugin
5
+ // goes in front — it takes what it accepts, and what it declines lands on
6
+ // the floor. No plugin is required for a plain local run.
6
7
  //
7
- // See docs/design/core-cloud-split-2026-06.md §5.1.
8
+ // See docs/design/pipeline-2026-09.md.
8
9
 
9
10
  import { ChainedCache, type CacheLayer } from '../cache/index.js'
10
- import type { TaskExecutor } from '../exec/index.js'
11
+ import { localExecutor, type TaskExecutor } from '../exec/index.js'
11
12
  import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
12
13
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
13
14
  import { detectCycle, type TaskNode } from '../graph/index.js'
14
- import { MISSING_PLUGIN_HINT } from './missing-plugin.js'
15
15
  import type {
16
16
  CacheContext,
17
17
  ExecutorContext,
18
+ FingerprintChange,
19
+ FingerprintContext,
18
20
  GraphHookContext,
19
21
  KeyHookContext,
20
22
  ProjectHookContext,
@@ -25,11 +27,57 @@ import type {
25
27
 
26
28
  /**
27
29
  * Run a capability factory with crash isolation. A throw becomes a clean
28
- * `UserError` naming the plugin + hook for the load-bearing capabilities
29
- * (`cache`/`executor`/`setup`) — a broken cache or executor must abort with
30
- * a clear message, never silently degrade. For `eventSink` the caller
31
- * logs-and-skips instead (observability must never break a run).
30
+ * `UserError` naming the plugin + hook: every stage and capability resolved
31
+ * here is load-bearing, so a broken one must abort with a clear message,
32
+ * never silently degrade. (Telemetry sinks are the observe-only exception,
33
+ * and telemetry-host.ts logs-and-skips them instead.)
32
34
  */
35
+ /** `a string`, `an array`, `null`, `a number` — for a refusal that names what came back. */
36
+ function describeValue(v: unknown): string {
37
+ if (v === null) return 'null'
38
+ if (Array.isArray(v)) return 'an array'
39
+ const t = typeof v
40
+ return t === 'object' ? 'an object' : `a ${t}`
41
+ }
42
+
43
+ /** What a capability hook handed back must be what the seam runs. */
44
+ /** Every method `CacheLayer` requires (the optional ones are probed with `?.`). */
45
+ export const CACHE_LAYER_METHODS: readonly string[] = [
46
+ 'key',
47
+ 'get',
48
+ 'has',
49
+ 'prefetch',
50
+ 'loadOutputFilesBatch',
51
+ 'isOutputsCurrent',
52
+ 'restoreOutputs',
53
+ 'save',
54
+ 'ingest',
55
+ 'recordRunBundle',
56
+ 'stats',
57
+ 'hashFile',
58
+ 'outputsPath',
59
+ 'prune',
60
+ 'close',
61
+ ]
62
+
63
+ function assertShape(
64
+ plugin: VxPlugin,
65
+ hook: string,
66
+ value: unknown,
67
+ methods: readonly string[],
68
+ what: string,
69
+ ): void {
70
+ const missing =
71
+ value === null || typeof value !== 'object'
72
+ ? methods
73
+ : methods.filter((m) => typeof (value as Record<string, unknown>)[m] !== 'function')
74
+ if (missing.length === 0) return
75
+ throw new UserError(
76
+ `plugin '${plugin.name}' returned from ${hook} something that is not ${what}: ` +
77
+ `missing ${missing.map((m) => `${m}()`).join(', ')}`,
78
+ )
79
+ }
80
+
33
81
  async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>): Promise<T> {
34
82
  try {
35
83
  return await fn()
@@ -49,7 +97,7 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
49
97
 
50
98
  export function hasHook(
51
99
  plugins: readonly VxPlugin[],
52
- hook: 'config' | 'project' | 'graph' | 'key' | 'schedule',
100
+ hook: 'config' | 'project' | 'graph' | 'key' | 'schedule' | 'admit',
53
101
  ): boolean {
54
102
  for (const p of plugins) if (p[hook] !== undefined) return true
55
103
  return false
@@ -72,18 +120,23 @@ export async function applyProjectHooks(
72
120
  plugins: readonly VxPlugin[],
73
121
  config: ProjectConfig,
74
122
  ctx: ProjectHookContext,
123
+ /** Runs after EACH plugin's edit, so a refusal can name the plugin that made it. */
124
+ afterEach?: (plugin: VxPlugin) => void,
75
125
  ): Promise<void> {
76
126
  for (const plugin of plugins) {
77
127
  if (plugin.project === undefined) continue
78
128
  await safe(plugin, 'project', () => plugin.project!(config, ctx))
129
+ afterEach?.(plugin)
79
130
  }
80
131
  }
81
132
 
82
133
  /**
83
134
  * `graph` stage: every plugin edits the task graph in place, then the graph
84
- * is checked the way the builder checks its own output — every dep names a
85
- * node in the graph, and there is no cycle. A violation is reported against
86
- * the LAST plugin that ran, which is the one whose edit made it so.
135
+ * is checked ONCE the way the builder checks its own output — every dep
136
+ * names a node in the graph, and there is no cycle. A violation is reported
137
+ * against the LAST plugin that ran: usually the one whose edit made it so,
138
+ * but an earlier plugin's edit that a later one left in place is blamed on
139
+ * the later one, since nothing is checked between plugins.
87
140
  */
88
141
  export async function applyGraphHooks(
89
142
  plugins: readonly VxPlugin[],
@@ -125,6 +178,14 @@ export async function applyKeyHooks(
125
178
  if (plugin.key === undefined) continue
126
179
  const material = await safe(plugin, 'key', () => plugin.key!(node, ctx))
127
180
  if (material === undefined) continue
181
+ // `Object.entries` over a string yields its characters as string
182
+ // values, so a plugin returning `'v22'` used to fold parts named
183
+ // '0', '1', '2' into every key — silently, and permanently.
184
+ if (typeof material !== 'object' || material === null || Array.isArray(material)) {
185
+ throw new UserError(
186
+ `plugin '${plugin.name}' failed in key: returned ${describeValue(material)}, not a record of string values`,
187
+ )
188
+ }
128
189
  for (const [name, value] of Object.entries(material)) {
129
190
  if (typeof value !== 'string') {
130
191
  throw new UserError(
@@ -141,6 +202,50 @@ export async function applyKeyHooks(
141
202
  }
142
203
  }
143
204
 
205
+ /**
206
+ * The fingerprint files the plugins claim (`VxPlugin.fingerprint`), each
207
+ * with its one claimant. The schema refused a second claimant and an
208
+ * unknown name, so this only indexes.
209
+ */
210
+ export function fingerprintClaims(plugins: readonly VxPlugin[]): ReadonlyMap<string, VxPlugin> {
211
+ const claims = new Map<string, VxPlugin>()
212
+ for (const p of plugins) {
213
+ if (p.fingerprint === undefined) continue
214
+ for (const f of p.fingerprint.files) claims.set(f, p)
215
+ }
216
+ return claims
217
+ }
218
+
219
+ /**
220
+ * Ask a claimed file's plugin which projects a change to it affects.
221
+ * `undefined` is "every project" — the claimant could not tell — and a
222
+ * non-iterable answer is refused by name, so a plugin returning `'all'`
223
+ * cannot select the projects spelled a, l, l.
224
+ */
225
+ export async function claimedAffected(
226
+ plugin: VxPlugin,
227
+ change: FingerprintChange,
228
+ ctx: FingerprintContext,
229
+ ): Promise<Set<string> | undefined> {
230
+ const answer = await safe(plugin, 'fingerprint', () => plugin.fingerprint!.affected(change, ctx))
231
+ if (answer === undefined) return undefined
232
+ if (typeof answer === 'string' || typeof answer !== 'object' || answer === null) {
233
+ throw new UserError(
234
+ `plugin '${plugin.name}' failed in fingerprint: returned ${describeValue(answer)}, not a list of project names`,
235
+ )
236
+ }
237
+ const names = new Set<string>()
238
+ for (const name of answer as Iterable<unknown>) {
239
+ if (typeof name !== 'string') {
240
+ throw new UserError(
241
+ `plugin '${plugin.name}' failed in fingerprint: affected project ${describeValue(name)} is not a name`,
242
+ )
243
+ }
244
+ names.add(name)
245
+ }
246
+ return names
247
+ }
248
+
144
249
  /**
145
250
  * `schedule` stage: the merged priorities map (a later plugin's weight for
146
251
  * a task overrides an earlier one's). Empty when no plugin answered.
@@ -155,6 +260,14 @@ export async function applyScheduleHooks(
155
260
  if (plugin.schedule === undefined) continue
156
261
  const weights = await safe(plugin, 'schedule', () => plugin.schedule!(nodes, ctx))
157
262
  if (weights === undefined) continue
263
+ // Iterating a string destructures its characters into `[id, w]` pairs
264
+ // that match no task — a plugin returning `'fast'` used to be a no-op
265
+ // with no word said.
266
+ if (!(weights instanceof Map)) {
267
+ throw new UserError(
268
+ `plugin '${plugin.name}' failed in schedule: returned ${describeValue(weights)}, not a Map of task id → weight`,
269
+ )
270
+ }
158
271
  for (const [id, w] of weights) {
159
272
  if (!nodes.has(id)) continue
160
273
  if (typeof w !== 'number' || !Number.isFinite(w)) {
@@ -168,13 +281,51 @@ export async function applyScheduleHooks(
168
281
  return merged
169
282
  }
170
283
 
284
+ /**
285
+ * `admit` stage, built once per run into the predicate the scheduler asks
286
+ * at every local dispatch: every plugin that answers must admit. A plugin
287
+ * that throws is reported once and admits from then on — a policy never
288
+ * breaks a run — so the predicate is never the reason a task hangs.
289
+ * Undefined when no plugin answers, which keeps the scheduler on its
290
+ * count-only path.
291
+ */
292
+ export function buildAdmission(
293
+ plugins: readonly VxPlugin[],
294
+ nodes: ReadonlyMap<string, TaskNode>,
295
+ concurrency: number,
296
+ warn: (message: string) => void,
297
+ ): ((id: string, running: ReadonlySet<string>) => boolean) | undefined {
298
+ const answering = plugins.filter((p) => p.admit !== undefined)
299
+ if (answering.length === 0) return undefined
300
+ const broken = new Set<VxPlugin>()
301
+ return (id, running) => {
302
+ const task = nodes.get(id)
303
+ if (task === undefined) return true
304
+ const ctx = {
305
+ running: [...running].flatMap((r) => nodes.get(r) ?? []),
306
+ concurrency,
307
+ }
308
+ for (const plugin of answering) {
309
+ if (broken.has(plugin)) continue
310
+ try {
311
+ if (plugin.admit!(task, ctx) === false) return false
312
+ } catch (err) {
313
+ broken.add(plugin)
314
+ const m = err instanceof Error ? err.message : String(err)
315
+ warn(`plugin '${plugin.name}' failed in admit: ${m}; admitting every task from here on`)
316
+ }
317
+ }
318
+ return true
319
+ }
320
+ }
321
+
171
322
  /**
172
323
  * Collect every plugin's `cache` layer in declaration order. One layer is
173
324
  * used as is; two or more are chained (lookup walks them, save reaches all;
174
325
  * see ChainedCache). A bare local layer that another declared layer already
175
326
  * wraps (`layer.local === ctx.localCache`) is dropped, so a remote plugin
176
- * that layers over the local handle composes with `localCachePlugin()`
177
- * instead of writing the local store twice. No layer at all is a named error.
327
+ * that layers over the local handle does not also write the local store
328
+ * directly. A plugin declaring nothing leaves that store, unwrapped.
178
329
  */
179
330
  export async function resolveCache(
180
331
  plugins: readonly VxPlugin[],
@@ -184,25 +335,30 @@ export async function resolveCache(
184
335
  for (const plugin of plugins) {
185
336
  if (plugin.cache === undefined) continue
186
337
  const layer = await safe(plugin, 'cache', () => plugin.cache!(ctx))
187
- if (layer !== undefined) layers.push(layer)
188
- }
189
- if (layers.length === 0) {
190
- const declined = plugins.filter((p) => p.cache !== undefined).map((p) => `${p.name} declined`)
191
- throw new UserError(
192
- `no cache plugin declared${declined.length > 0 ? ` (${declined.join(', ')})` : ''}. ${MISSING_PLUGIN_HINT}`,
193
- )
338
+ if (layer === undefined) continue
339
+ // The seam's WHOLE contract, checked once here: a layer missing a
340
+ // method otherwise failed at the first task that reached it — an
341
+ // internal TypeError deep in the chain, naming neither the plugin nor
342
+ // the hook. Five names were checked before, so a layer with those five
343
+ // passed and died at its first hit inside restoreOutputs.
344
+ assertShape(plugin, 'cache', layer, CACHE_LAYER_METHODS, 'a cache layer')
345
+ layers.push(layer)
194
346
  }
347
+ // Core's own store is the TAIL of the chain — the floor under every
348
+ // lookup, not a plugin a workspace has to declare. A layer that WRAPS
349
+ // the local handle subsumes it below, so a remote plugin does not write
350
+ // the local store twice.
351
+ if (!layers.includes(ctx.localCache)) layers.push(ctx.localCache)
195
352
  const wrapsLocal = layers.some((l) => l !== ctx.localCache && l.local === ctx.localCache)
196
353
  const distinct = wrapsLocal ? layers.filter((l) => l !== ctx.localCache) : layers
197
354
  return distinct.length === 1 ? distinct[0]! : new ChainedCache(distinct)
198
355
  }
199
356
 
200
357
  /**
201
- * Collect every plugin's `executor`, in declaration order. Unlike
202
- * `cache` this is a LIST: per task, `selectExecutor` takes the first
203
- * that accepts. An empty list is the same authoring error as a missing
204
- * cache provider and fails the same way. A broken factory aborts — an
205
- * executor is load-bearing, not observational.
358
+ * Collect every plugin's `executor`, in declaration order, with core's
359
+ * own appended at the tail: per task, `selectExecutor` takes the first
360
+ * that accepts, and what every plugin declines runs here. A broken
361
+ * factory aborts — an executor is load-bearing, not observational.
206
362
  */
207
363
  export async function resolveExecutors(
208
364
  plugins: readonly VxPlugin[],
@@ -212,16 +368,17 @@ export async function resolveExecutors(
212
368
  for (const plugin of plugins) {
213
369
  if (plugin.executor === undefined) continue
214
370
  const executor = await safe(plugin, 'executor', () => plugin.executor!(ctx))
215
- if (executor !== undefined) executors.push(executor)
216
- }
217
- if (executors.length === 0) {
218
- const declined = plugins
219
- .filter((p) => p.executor !== undefined)
220
- .map((p) => `${p.name} declined`)
221
- throw new UserError(
222
- `no executor plugin declared${declined.length > 0 ? ` (${declined.join(', ')})` : ''}. ${MISSING_PLUGIN_HINT}`,
223
- )
371
+ if (executor === undefined) continue
372
+ assertShape(plugin, 'executor', executor, ['execute'], 'an executor')
373
+ if (typeof executor.name !== 'string' || executor.name.length === 0) {
374
+ throw new UserError(`plugin '${plugin.name}' returned an executor with no name`)
375
+ }
376
+ executors.push(executor)
224
377
  }
378
+ // Core's own executor is the TAIL of every list, so a plugin executor
379
+ // that declines a task hands it back to this machine rather than
380
+ // failing the run.
381
+ executors.push(localExecutor())
225
382
  return executors
226
383
  }
227
384