@vzn/vx 0.0.66 → 0.0.68

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.66",
3
+ "version": "0.0.68",
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.66",
21
- "@vzn/vx-linux-arm64": "0.0.66",
22
- "@vzn/vx-darwin-x64": "0.0.66",
23
- "@vzn/vx-darwin-arm64": "0.0.66"
20
+ "@vzn/vx-linux-x64": "0.0.68",
21
+ "@vzn/vx-linux-arm64": "0.0.68",
22
+ "@vzn/vx-darwin-x64": "0.0.68",
23
+ "@vzn/vx-darwin-arm64": "0.0.68"
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
  }
@@ -190,7 +190,8 @@ export interface TaskExecutor {
190
190
  /**
191
191
  * How many tasks this executor runs at once. Its tasks then occupy a pool
192
192
  * of this size instead of the local worker slots, so a remote pool is not
193
- * throttled by the local CPU count. Absent = the local pool.
193
+ * throttled by the local CPU count. A positive integer; anything else
194
+ * is refused when the executor is resolved. Absent = the local pool.
194
195
  */
195
196
  readonly capacity?: number
196
197
  /** Per-task opt-out at placement time. Absent = accepts every task it is offered. */
@@ -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
@@ -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 } : {}),
@@ -419,6 +419,13 @@ export async function resolveExecutors(
419
419
  if (typeof executor.name !== 'string' || executor.name.length === 0) {
420
420
  throw new UserError(`plugin '${plugin.name}' returned an executor with no name`)
421
421
  }
422
+ // The pool's room is `active < capacity`: 0 or NaN parks its tasks for good.
423
+ const capacity: unknown = executor.capacity
424
+ if (capacity !== undefined && !(Number.isInteger(capacity) && (capacity as number) > 0)) {
425
+ throw new UserError(
426
+ `plugin '${plugin.name}' returned executor '${executor.name}' with capacity ${typeof capacity === 'number' ? String(capacity) : JSON.stringify(capacity)}: it must be a positive integer`,
427
+ )
428
+ }
422
429
  executorPlugin.set(executor, plugin.name)
423
430
  executors.push(executor)
424
431
  }