@vzn/vx 0.0.26 → 0.0.28

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.26",
3
+ "version": "0.0.28",
4
4
  "description": "An open, extensible monorepo task runner.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.26",
21
- "@vzn/vx-linux-arm64": "0.0.26",
22
- "@vzn/vx-darwin-x64": "0.0.26",
23
- "@vzn/vx-darwin-arm64": "0.0.26"
20
+ "@vzn/vx-linux-x64": "0.0.28",
21
+ "@vzn/vx-linux-arm64": "0.0.28",
22
+ "@vzn/vx-darwin-x64": "0.0.28",
23
+ "@vzn/vx-darwin-arm64": "0.0.28"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -254,15 +254,26 @@ export function assertExecuteResult(
254
254
  * Place one task: the first executor, in declaration order, that may take
255
255
  * it — a `remote` executor is skipped for a `pinnedLocal` task, then
256
256
  * `accepts()` decides. Decided once per task before scheduling. The local
257
- * executor accepts everything, so with it declared this cannot throw.
257
+ * executor accepts everything, so with it declared the only throw is an
258
+ * `accepts()` that threw: refused as `label` names that executor, where it
259
+ * surfaced as a bare stack naming no plugin (item 1022).
258
260
  */
259
261
  export function selectExecutor(
260
262
  executors: readonly TaskExecutor[],
261
263
  task: TaskPlacement,
264
+ label: (executor: TaskExecutor) => string = (e) => `executor '${e.name}'`,
262
265
  ): TaskExecutor {
263
266
  for (const executor of executors) {
264
267
  if (task.pinnedLocal && executor.remote === true) continue
265
- if (executor.accepts === undefined || executor.accepts(task)) return executor
268
+ if (executor.accepts === undefined) return executor
269
+ let taken: boolean
270
+ try {
271
+ taken = executor.accepts(task)
272
+ } catch (err) {
273
+ const m = err instanceof Error ? err.message : String(err)
274
+ throw new UserError(`${label(executor)} failed in accepts for ${task.taskId}: ${m}`)
275
+ }
276
+ if (taken) return executor
266
277
  }
267
278
  throw new Error(
268
279
  `no executor accepted ${task.taskId} (declared: ${executors.map((e) => e.name).join(', ')}). Core's local executor accepts every task, so this means it is missing from the list.`,
@@ -10,7 +10,7 @@ import { selectExecutor, type TaskExecutor } from '../exec/index.js'
10
10
  import { isGroupTask, type TaskNode } from '../graph/index.js'
11
11
  import { resolveDownloadModes } from './download-policy.js'
12
12
  import type { Logger } from './logger.js'
13
- import { resolveExecutors } from './plugin-host.js'
13
+ import { executorLabel, resolveExecutors } from './plugin-host.js'
14
14
  import type { prepareRun } from './prepare.js'
15
15
 
16
16
  /**
@@ -100,14 +100,18 @@ export function placeTasks(
100
100
  }
101
101
  for (const node of nodes.values()) {
102
102
  if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
103
- const executor = selectExecutor(executors, {
104
- taskId: node.id,
105
- projectName: node.projectName,
106
- projectDir: node.projectDir,
107
- command: node.config.exec!.command,
108
- pinnedLocal: pinned.has(node.id),
109
- cacheable: node.config.cache !== undefined,
110
- })
103
+ const executor = selectExecutor(
104
+ executors,
105
+ {
106
+ taskId: node.id,
107
+ projectName: node.projectName,
108
+ projectDir: node.projectDir,
109
+ command: node.config.exec!.command,
110
+ pinnedLocal: pinned.has(node.id),
111
+ cacheable: node.config.cache !== undefined,
112
+ },
113
+ executorLabel,
114
+ )
111
115
  placements.executors.set(node.id, executor)
112
116
  if (node.config.exec?.remote === 'only') {
113
117
  // A pinned 'only' task (it transitively depends on a persistent one)
@@ -364,6 +364,17 @@ export async function resolveCache(
364
364
  })
365
365
  }
366
366
 
367
+ /** The plugin each resolved executor came from, so a hook of it that throws is named. */
368
+ const executorPlugin = new WeakMap<TaskExecutor, string>()
369
+
370
+ /** How a message names `executor`: its plugin and its own name, or its name alone (the floor). */
371
+ export function executorLabel(executor: TaskExecutor): string {
372
+ const plugin = executorPlugin.get(executor)
373
+ return plugin === undefined
374
+ ? `executor '${executor.name}'`
375
+ : `plugin '${plugin}' (executor '${executor.name}')`
376
+ }
377
+
367
378
  /**
368
379
  * Collect every plugin's `executor`, in declaration order, with core's
369
380
  * own appended at the tail: per task, `selectExecutor` takes the first
@@ -383,6 +394,7 @@ export async function resolveExecutors(
383
394
  if (typeof executor.name !== 'string' || executor.name.length === 0) {
384
395
  throw new UserError(`plugin '${plugin.name}' returned an executor with no name`)
385
396
  }
397
+ executorPlugin.set(executor, plugin.name)
386
398
  executors.push(executor)
387
399
  }
388
400
  // Core's own executor is the TAIL of every list, so a plugin executor
@@ -396,9 +408,10 @@ export async function resolveExecutors(
396
408
  * End-of-run plugin lifecycle: each plugin's optional `teardown()`, in
397
409
  * declaration order. Crash-isolated — a throwing teardown is logged and
398
410
  * skipped, never propagated — and each call is time-bounded by
399
- * {@link teardownTimeoutMs}. Runs on the normal completion path only; the
400
- * finally-path disposers just unsubscribe. (Telemetry sinks flush before
401
- * this, in telemetry-host.ts, with the same reporting rule.)
411
+ * {@link teardownTimeoutMs}. Runs on every exit of a run once its plugins'
412
+ * factories ran, and of a plan (item 1021); the finally-path disposers
413
+ * just unsubscribe. (Telemetry sinks flush before this, in
414
+ * telemetry-host.ts, with the same reporting rule.)
402
415
  *
403
416
  * The bound is PER CALL and the calls are sequential, so the worst case
404
417
  * composes: measured at the 3s default, 1/2/3 simultaneously-hung plugins
@@ -442,6 +442,20 @@ export interface InstallPluginsArgs {
442
442
  warn?: (message: string) => void
443
443
  }
444
444
 
445
+ /**
446
+ * A plugin's `setup` threw. It names the plugin so the run can tear down
447
+ * every other one: their capability factories and setups ran, and the
448
+ * one that failed set up nothing to release.
449
+ */
450
+ export class PluginSetupError extends UserError {
451
+ constructor(
452
+ readonly plugin: Plugin,
453
+ message: string,
454
+ ) {
455
+ super(message)
456
+ }
457
+ }
458
+
445
459
  /**
446
460
  * Install every plugin's `setup` hook against a shared bus + context.
447
461
  * Synchronous loop; setup() promises are awaited in order so a plugin's
@@ -534,7 +548,8 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
534
548
  try {
535
549
  await plugin.setup(ctx)
536
550
  } catch (err) {
537
- throw new UserError(
551
+ throw new PluginSetupError(
552
+ plugin,
538
553
  `plugin '${plugin.name}' failed to load: ${err instanceof Error ? err.message : String(err)}`,
539
554
  )
540
555
  }
@@ -39,8 +39,8 @@ import { admitTasks, taintTracker } from './admission.js'
39
39
  import { excludedTaint } from './excluded-keys.js'
40
40
  import { keyUpstream } from './upstream.js'
41
41
  import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js'
42
- import { installPlugins } from './plugin.js'
43
- import { buildAdmission, resolveExecutors, teardownPlugins } from './plugin-host.js'
42
+ import { installPlugins, PluginSetupError } from './plugin.js'
43
+ import { buildAdmission, executorLabel, resolveExecutors, teardownPlugins } from './plugin-host.js'
44
44
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
45
45
  import { assembleRunSummary, isPassStatus } from './telemetry.js'
46
46
  import type { RunContextRecord } from './telemetry.js'
@@ -217,6 +217,19 @@ async function runOnBus(
217
217
 
218
218
  const prepared = await prepareRun(options, log)
219
219
  mark('plugin stages')
220
+ // Every exit from here tears the plugins down: their capability
221
+ // factories ran in prepareRun, and teardown is what releases what those
222
+ // opened (a client, a channel). Until item 1021 only the normal end did,
223
+ // so an early return, a refused setup or a throw left them held — once
224
+ // per cycle under `vx watch`. A plugin whose own setup threw set up
225
+ // nothing, and is the one left out.
226
+ let tornDown = false
227
+ const teardown = async (except?: unknown): Promise<void> => {
228
+ if (tornDown) return
229
+ tornDown = true
230
+ const plugins = prepared.plugins.filter((p) => p !== except)
231
+ await teardownPlugins(plugins, (m) => log.status(m))
232
+ }
220
233
  // A task whose command re-enters `vx run` in the workspace running it
221
234
  // is refused. When the inner run reaches this task again it forks a run
222
235
  // per run until the machine gives out; when it does not (`ci` shelling
@@ -226,6 +239,7 @@ async function runOnBus(
226
239
  // name the task; the check is on the root so both shapes are caught.
227
240
  const outerRoot = process.env[VX_RUN_WORKSPACE_ENV]
228
241
  if (outerRoot !== undefined && path.resolve(outerRoot) === path.resolve(prepared.workspaceRoot)) {
242
+ await teardown()
229
243
  prepared.cache.close()
230
244
  throw new UserError(
231
245
  `task ${process.env[VX_RUN_TASK_ENV] ?? '<unknown>'} runs \`vx run\` inside its own workspace: a nested run is invisible to the outer graph (its tasks escape the schedule, the concurrency budget and the cache key) and a loop back to this task forks without bound. Declare what it needs with dependsOn instead.`,
@@ -241,6 +255,7 @@ async function runOnBus(
241
255
  log.status(
242
256
  `No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${didYouMean(prepared.unresolvedTasks, prepared.projects)}${await initHint(prepared)}`,
243
257
  )
258
+ await teardown()
244
259
  prepared.cache.close()
245
260
  return { ok: false, outcomes: [] }
246
261
  }
@@ -254,6 +269,7 @@ async function runOnBus(
254
269
  ? `No projects declare task(s): ${options.tasks.join(', ')}.${await initHint(prepared)}`
255
270
  : 'No tasks to run.'
256
271
  log.status(msg)
272
+ await teardown()
257
273
  prepared.cache.close()
258
274
  return { ok: false, outcomes: [] }
259
275
  }
@@ -273,9 +289,21 @@ async function runOnBus(
273
289
  })
274
290
  } catch (err) {
275
291
  disposePlugins?.()
292
+ await teardown(err instanceof PluginSetupError ? err.plugin : undefined)
276
293
  prepared.cache.close()
277
294
  throw err
278
295
  }
296
+ // Until the run's own try below, a throw leaves through here.
297
+ const abandoning = async <T>(step: () => T | Promise<T>): Promise<T> => {
298
+ try {
299
+ return await step()
300
+ } catch (err) {
301
+ disposePlugins?.()
302
+ await teardown()
303
+ prepared.cache.close()
304
+ throw err
305
+ }
306
+ }
279
307
  const {
280
308
  workspaceRoot,
281
309
  workspaceConfig,
@@ -293,24 +321,19 @@ async function runOnBus(
293
321
 
294
322
  // Resolved ONCE per run, in declaration order, the local executor last.
295
323
  // A broken factory aborts here, before any task starts.
296
- let executors: readonly TaskExecutor[]
297
- try {
298
- executors = await resolveExecutors(prepared.plugins, {
324
+ const executors: readonly TaskExecutor[] = await abandoning(() =>
325
+ resolveExecutors(prepared.plugins, {
299
326
  workspaceRoot: prepared.workspaceRoot,
300
327
  cacheDir: prepared.cacheDir,
301
328
  warn: (m: string) => log.status(m),
302
329
  concurrency,
303
- })
304
- } catch (err) {
305
- disposePlugins?.()
306
- prepared.cache.close()
307
- throw err
308
- }
330
+ }),
331
+ )
309
332
  // Placement: decided ONCE per task, before scheduling, so the scheduler
310
333
  // can admit a remote-pooled task against its pool instead of a local
311
334
  // worker slot. Group tasks run nothing; persistent tasks never reach an
312
335
  // executor (local by construction) — both stay off the map.
313
- const placements = placeTasks(nodes, executors)
336
+ const placements = await abandoning(() => placeTasks(nodes, executors))
314
337
  // A `remote: 'only'` task nobody takes succeeds WITHOUT running. That is
315
338
  // deliberate — on a machine with no remote pool the ambient state already
316
339
  // is what the task would have produced — but it must not be SILENT: a task
@@ -341,14 +364,26 @@ async function runOnBus(
341
364
  remaining.add(id)
342
365
  }
343
366
  // The SAME set is handed back every time, narrowed in place: a consumer
344
- // reading it later sees the truth rather than a stale snapshot.
345
- for (const [executor, remaining] of demandOf) executor.demand!(remaining)
367
+ // reading it later sees the truth rather than a stale snapshot. A hint,
368
+ // as `admit` is: one that throws is named once and asked no more, where
369
+ // it surfaced as a bare stack, or mid-run from the completion path
370
+ // (item 1022).
371
+ const tellDemand = (executor: TaskExecutor, remaining: ReadonlySet<string>): void => {
372
+ try {
373
+ executor.demand!(remaining)
374
+ } catch (err) {
375
+ demandOf.delete(executor)
376
+ const m = err instanceof Error ? err.message : String(err)
377
+ log.status(`[vx] ${executorLabel(executor)} failed in demand: ${m}; not asked again this run`)
378
+ }
379
+ }
380
+ for (const [executor, remaining] of demandOf) tellDemand(executor, remaining)
346
381
  const narrowDemand =
347
382
  demandOf.size === 0
348
383
  ? (): void => {}
349
384
  : (id: string): void => {
350
385
  for (const [executor, remaining] of demandOf) {
351
- if (remaining.delete(id)) executor.demand!(remaining)
386
+ if (remaining.delete(id)) tellDemand(executor, remaining)
352
387
  }
353
388
  }
354
389
 
@@ -457,10 +492,12 @@ async function runOnBus(
457
492
  }
458
493
  // Taken after the early exits above (nothing they do touches a tree) and
459
494
  // before the schedule: from here on tasks clean, restore and write.
460
- const releaseRunLock = await acquireRunLock(prepared.workspaceRoot, {
461
- log: (m) => log.status(m),
462
- signal: stopRun.signal,
463
- })
495
+ const releaseRunLock = await abandoning(() =>
496
+ acquireRunLock(prepared.workspaceRoot, {
497
+ log: (m) => log.status(m),
498
+ signal: stopRun.signal,
499
+ }),
500
+ )
464
501
  try {
465
502
  // One run-id per `vx run` invocation. Every task in the resulting
466
503
  // graph carries it so analytics queries can group by invocation.
@@ -900,8 +937,8 @@ async function runOnBus(
900
937
  }
901
938
  // End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
902
939
  // + time-bounded inside teardownPlugins, so a faulty plugin can neither
903
- // fail nor hang the run. Normal completion path only — the finally
904
- // below just unsubscribes.
940
+ // fail nor hang the run. Here on the normal path, after the drains
941
+ // below; every other exit takes it through `teardown` too.
905
942
  // Drain any still-in-flight background prefetches before closing the
906
943
  // cache handle — a prefetch ingesting into a closed SQLite DB would
907
944
  // throw. Tasks that resolved as local hits never awaited their
@@ -939,7 +976,7 @@ async function runOnBus(
939
976
  // Not on a stopped run: one stopped while it waited on another run's
940
977
  // lock never held it, and its prune evicted under that run (item 858).
941
978
  if (!stopRun.signal.aborted) await applyCacheRetention(prepared, log)
942
- await teardownPlugins(prepared.plugins, (m) => log.status(m))
979
+ await teardown()
943
980
  await closeCache()
944
981
  mark('close')
945
982
  printTimings()
@@ -1019,6 +1056,8 @@ async function runOnBus(
1019
1056
  // released here. Idempotent; safe even if installPlugins threw.
1020
1057
  disposePlugins?.()
1021
1058
  telemetry?.dispose()
1059
+ // No-op after the normal path's teardown; on a throw, the only one.
1060
+ await teardown()
1022
1061
  // No-op after the normal path's close. On a throw this is the only
1023
1062
  // close there is, and it must not itself throw — that would replace
1024
1063
  // the run's real error with a teardown one. Background uploads are
@@ -1123,6 +1162,9 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1123
1162
  mark('plan')
1124
1163
  return planned
1125
1164
  } finally {
1165
+ // The plan called the cache and executor factories; teardown releases
1166
+ // what they opened, as a run's end does (item 1021).
1167
+ await teardownPlugins(prepared.plugins, (m) => log.status(m))
1126
1168
  prepared.cache.close()
1127
1169
  // The same table a run prints: a dry run is how the prepare stages
1128
1170
  // (discovery, config load, the git enumeration) get profiled on a