@vzn/vx 0.0.27 → 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.27",
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.27",
21
- "@vzn/vx-linux-arm64": "0.0.27",
22
- "@vzn/vx-darwin-x64": "0.0.27",
23
- "@vzn/vx-darwin-arm64": "0.0.27"
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
@@ -40,7 +40,7 @@ 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
42
  import { installPlugins, PluginSetupError } from './plugin.js'
43
- import { buildAdmission, resolveExecutors, teardownPlugins } from './plugin-host.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'
@@ -364,16 +364,26 @@ async function runOnBus(
364
364
  remaining.add(id)
365
365
  }
366
366
  // The SAME set is handed back every time, narrowed in place: a consumer
367
- // reading it later sees the truth rather than a stale snapshot.
368
- await abandoning(() => {
369
- for (const [executor, remaining] of demandOf) executor.demand!(remaining)
370
- })
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)
371
381
  const narrowDemand =
372
382
  demandOf.size === 0
373
383
  ? (): void => {}
374
384
  : (id: string): void => {
375
385
  for (const [executor, remaining] of demandOf) {
376
- if (remaining.delete(id)) executor.demand!(remaining)
386
+ if (remaining.delete(id)) tellDemand(executor, remaining)
377
387
  }
378
388
  }
379
389