@vzn/vx 0.0.65 → 0.0.67

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.65",
3
+ "version": "0.0.67",
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.65",
21
- "@vzn/vx-linux-arm64": "0.0.65",
22
- "@vzn/vx-darwin-x64": "0.0.65",
23
- "@vzn/vx-darwin-arm64": "0.0.65"
20
+ "@vzn/vx-linux-x64": "0.0.67",
21
+ "@vzn/vx-linux-arm64": "0.0.67",
22
+ "@vzn/vx-darwin-x64": "0.0.67",
23
+ "@vzn/vx-darwin-arm64": "0.0.67"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/watch.ts CHANGED
@@ -292,7 +292,13 @@ export function pollWatcher(
292
292
  }
293
293
  if (!e.isFile()) continue
294
294
  try {
295
- current.set(childRel, fs.statSync(path.join(abs, e.name)).mtimeMs)
295
+ // The later of the two clocks, as `modifiedBefore` reads them: a
296
+ // replacement that carries the old file's mtime (`cp -p`, `rsync
297
+ // -a`, `mv` of a file stamped the same) moved nothing under mtime
298
+ // alone, and the poller never ran it where the native watcher did.
299
+ // A rename or a write moves ctime, and no process can set it.
300
+ const st = fs.statSync(path.join(abs, e.name))
301
+ current.set(childRel, Math.max(st.mtimeMs, st.ctimeMs))
296
302
  } catch {
297
303
  // raced with a delete; the next scan settles it
298
304
  }
@@ -76,6 +76,13 @@ export interface TaskOutcome {
76
76
  * outcomes only; never folded into any key.
77
77
  */
78
78
  groupUpstream?: readonly TaskOutcome[]
79
+ /**
80
+ * This task RAN over inputs its key no longer describes: one moved while
81
+ * it ran, the workspace fingerprint moved, or an upstream carried this
82
+ * mark. Its save was withheld, and its outputs are not what `hash` names,
83
+ * so a dependant that folds `hash` saves nothing either (A-12).
84
+ */
85
+ unkeyed?: true
79
86
  /**
80
87
  * On a `skipped` outcome: the task at the ROOT of what blocked it — the
81
88
  * failed (or aborted) upstream, followed through any chain of skips
@@ -68,7 +68,10 @@ export function createEventBus(): EventBus {
68
68
  // as wrong output, not a silent hang.
69
69
  for (const subscriber of subscribers) {
70
70
  try {
71
- subscriber(event)
71
+ // A subscriber typed `void` may still be async: its rejection is
72
+ // isolated as its throw is.
73
+ const ret: unknown = subscriber(event)
74
+ if (ret instanceof Promise) ret.catch(() => {})
72
75
  } catch {
73
76
  // isolate: a surface fault can't propagate into the orchestrator
74
77
  }
@@ -62,6 +62,7 @@ import {
62
62
  type TaskInputComponent,
63
63
  } from './task-hash.js'
64
64
  import { getContext } from './remote-prefetch.js'
65
+ import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
65
66
 
66
67
  export interface ExecuteArgs {
67
68
  node: TaskNode
@@ -412,8 +413,27 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
412
413
  const willRead = !remoteOnly && cfgCacheable && (policy.localRead || policy.remoteRead)
413
414
  const willWrite = !remoteOnly && cfgCacheable && (policy.localWrite || policy.remoteWrite)
414
415
  // `willWrite` governs output hygiene (clean before exec); the save itself
415
- // is also withheld from a task downstream of a failure (see `taintedUpstream`).
416
- const willSave = willWrite && args.taintedUpstream !== true
416
+ // is also withheld from a task downstream of a failure (see `taintedUpstream`),
417
+ // and from one downstream of a task that ran over inputs its key no longer
418
+ // describes: this key folds that upstream's key, while the bytes on disk
419
+ // are not the ones that key names. With `a.txt` edited while `gen` ran,
420
+ // `gen` withheld its save, `use` saved over the edit under a key folding
421
+ // `gen`'s, and once `a.txt` was put back `use` hit the edit's output (A-12).
422
+ // Only an upstream whose key this one folds: one read by content
423
+ // (`tasks: []` and a file input) is judged by its bytes. `upstream` holds
424
+ // a hole where a dependency did not run (a skipped one, a restore-tier
425
+ // task's unfinished deps).
426
+ const present = upstream.filter((u): u is TaskOutcome => u !== undefined)
427
+ const folded = new Set(
428
+ filterUpstreamHashes(present, cacheCfg?.inputs?.tasks, node.projectName, node.id).map(
429
+ ([id]) => id,
430
+ ),
431
+ )
432
+ const unkeyedUpstream = expandGroupUpstream(present.filter((u) => folded.has(u.node.id))).find(
433
+ (u) => u.unkeyed === true,
434
+ )
435
+ let unkeyed = unkeyedUpstream !== undefined
436
+ const willSave = willWrite && args.taintedUpstream !== true && !unkeyed
417
437
 
418
438
  // Retain only what is read back. `cache.save` below is the single consumer
419
439
  // of `result.stdout`, and it runs only when this task will WRITE an entry;
@@ -968,7 +988,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
968
988
  * withholds it too: the key folded the old one.
969
989
  */
970
990
  async function keyStillTrue(): Promise<boolean> {
971
- if (fingerprintMoved()) return false
991
+ if (fingerprintMoved()) {
992
+ unkeyed = true
993
+ return false
994
+ }
972
995
  const endCheck = span('miss: recheck inputs')
973
996
  const moved = await movedSinceKey()
974
997
  endCheck()
@@ -979,9 +1002,17 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
979
1002
  `but is not saved under a key that no longer describes it`,
980
1003
  )
981
1004
  forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
1005
+ unkeyed = true
982
1006
  return false
983
1007
  }
984
1008
 
1009
+ if (unkeyedUpstream !== undefined && willWrite && effectiveExitCode === 0) {
1010
+ log.status(
1011
+ `[vx] ${node.id}: ran over ${unkeyedUpstream.node.id}'s outputs, which its key no longer ` +
1012
+ `describes — the result stands, but is not saved`,
1013
+ )
1014
+ }
1015
+
985
1016
  const finalViolations = violations
986
1017
 
987
1018
  return {
@@ -991,6 +1022,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
991
1022
  durationMs: spentMs,
992
1023
  hash,
993
1024
  ...(attempt > 1 ? { attempts: attempt } : {}),
1025
+ ...(unkeyed ? { unkeyed: true as const } : {}),
994
1026
  ...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
995
1027
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
996
1028
  ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
@@ -507,47 +507,53 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
507
507
  },
508
508
  },
509
509
  on(hook, handler) {
510
+ const fail = (err: unknown): void => {
511
+ if (disabled.has(plugin.name)) return
512
+ disabled.add(plugin.name)
513
+ warn(
514
+ `[vx] plugin '${plugin.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
515
+ )
516
+ }
510
517
  const dispose = bus.subscribe((event) => {
511
518
  if (disabled.has(plugin.name)) return
512
- // void each handler call: hooks may return Promise; we
513
- // intentionally don't await (the bus is synchronous;
514
- // long-running plugin work happens off the critical path).
519
+ // Not awaited: the bus is synchronous and plugin work runs off the
520
+ // critical path. A handler's rejection is caught like its throw.
521
+ let ret: unknown
515
522
  try {
516
523
  switch (hook) {
517
524
  case 'onRunStart':
518
525
  if (event.kind === 'run:start')
519
- void (handler as PluginHookHandlers['onRunStart'])(event.info)
526
+ ret = (handler as PluginHookHandlers['onRunStart'])(event.info)
520
527
  break
521
528
  case 'onTaskStart':
522
529
  if (event.kind === 'task:start')
523
- void (handler as PluginHookHandlers['onTaskStart'])(event.node)
530
+ ret = (handler as PluginHookHandlers['onTaskStart'])(event.node)
524
531
  break
525
532
  case 'onTaskStdout':
526
533
  if (event.kind === 'task:stdout')
527
- void (handler as PluginHookHandlers['onTaskStdout'])(event.node, event.chunk)
534
+ ret = (handler as PluginHookHandlers['onTaskStdout'])(event.node, event.chunk)
528
535
  break
529
536
  case 'onTaskStderr':
530
537
  if (event.kind === 'task:stderr')
531
- void (handler as PluginHookHandlers['onTaskStderr'])(event.node, event.chunk)
538
+ ret = (handler as PluginHookHandlers['onTaskStderr'])(event.node, event.chunk)
532
539
  break
533
540
  case 'onTaskComplete':
534
541
  if (event.kind === 'task:complete')
535
- void (handler as PluginHookHandlers['onTaskComplete'])(event.node, event.outcome)
542
+ ret = (handler as PluginHookHandlers['onTaskComplete'])(event.node, event.outcome)
536
543
  break
537
544
  case 'onRunStatus':
538
545
  if (event.kind === 'run:status')
539
- void (handler as PluginHookHandlers['onRunStatus'])(event.line)
546
+ ret = (handler as PluginHookHandlers['onRunStatus'])(event.line)
540
547
  break
541
548
  case 'onRunEnd':
542
- if (event.kind === 'run:end') void (handler as PluginHookHandlers['onRunEnd'])()
549
+ if (event.kind === 'run:end') ret = (handler as PluginHookHandlers['onRunEnd'])()
543
550
  break
544
551
  }
545
552
  } catch (err) {
546
- disabled.add(plugin.name)
547
- warn(
548
- `[vx] plugin '${plugin.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
549
- )
553
+ fail(err)
554
+ return
550
555
  }
556
+ if (ret instanceof Promise) ret.catch(fail)
551
557
  })
552
558
  disposers.push(dispose)
553
559
  },