@vzn/vx 0.0.27 → 0.0.29

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.29",
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.29",
21
+ "@vzn/vx-linux-arm64": "0.0.29",
22
+ "@vzn/vx-darwin-x64": "0.0.29",
23
+ "@vzn/vx-darwin-arm64": "0.0.29"
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)
@@ -277,7 +277,10 @@ export async function applyScheduleHooks(
277
277
  * `admit` stage, built once per run into the predicate the scheduler asks
278
278
  * at every local dispatch: every plugin that answers must admit. A plugin
279
279
  * that throws is reported once and admits from then on — a policy never
280
- * breaks a run — so the predicate is never the reason a task hangs.
280
+ * breaks a run — and one that refuses while nothing local is running is
281
+ * overridden with a word, since only a completion asks again: that
282
+ * refusal stalled the run for good (item 1023). So the predicate is never
283
+ * the reason a task hangs.
281
284
  * Undefined when no plugin answers, which keeps the scheduler on its
282
285
  * count-only path.
283
286
  */
@@ -290,6 +293,7 @@ export function buildAdmission(
290
293
  const answering = plugins.filter((p) => p.admit !== undefined)
291
294
  if (answering.length === 0) return undefined
292
295
  const broken = new Set<VxPlugin>()
296
+ const overridden = new Set<VxPlugin>()
293
297
  return (id, running) => {
294
298
  // The scheduler asks only about ids in this same map, so the lookup
295
299
  // always lands: an unknown-id arm here was deleted with the whole core
@@ -302,7 +306,14 @@ export function buildAdmission(
302
306
  for (const plugin of answering) {
303
307
  if (broken.has(plugin)) continue
304
308
  try {
305
- if (plugin.admit!(task, ctx) === false) return false
309
+ if (plugin.admit!(task, ctx) !== false) continue
310
+ if (running.size > 0) return false
311
+ if (!overridden.has(plugin)) {
312
+ overridden.add(plugin)
313
+ warn(
314
+ `plugin '${plugin.name}' refused ${id} in admit with nothing running; admitting it, since no completion would ask again`,
315
+ )
316
+ }
306
317
  } catch (err) {
307
318
  broken.add(plugin)
308
319
  const m = err instanceof Error ? err.message : String(err)
@@ -364,6 +375,17 @@ export async function resolveCache(
364
375
  })
365
376
  }
366
377
 
378
+ /** The plugin each resolved executor came from, so a hook of it that throws is named. */
379
+ const executorPlugin = new WeakMap<TaskExecutor, string>()
380
+
381
+ /** How a message names `executor`: its plugin and its own name, or its name alone (the floor). */
382
+ export function executorLabel(executor: TaskExecutor): string {
383
+ const plugin = executorPlugin.get(executor)
384
+ return plugin === undefined
385
+ ? `executor '${executor.name}'`
386
+ : `plugin '${plugin}' (executor '${executor.name}')`
387
+ }
388
+
367
389
  /**
368
390
  * Collect every plugin's `executor`, in declaration order, with core's
369
391
  * own appended at the tail: per task, `selectExecutor` takes the first
@@ -383,6 +405,7 @@ export async function resolveExecutors(
383
405
  if (typeof executor.name !== 'string' || executor.name.length === 0) {
384
406
  throw new UserError(`plugin '${plugin.name}' returned an executor with no name`)
385
407
  }
408
+ executorPlugin.set(executor, plugin.name)
386
409
  executors.push(executor)
387
410
  }
388
411
  // 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