@vzn/vx 0.0.361 → 0.0.362

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.361",
3
+ "version": "0.0.362",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.361",
25
- "@vzn/vx-linux-arm64": "0.0.361",
26
- "@vzn/vx-darwin-x64": "0.0.361",
27
- "@vzn/vx-darwin-arm64": "0.0.361"
24
+ "@vzn/vx-linux-x64": "0.0.362",
25
+ "@vzn/vx-linux-arm64": "0.0.362",
26
+ "@vzn/vx-darwin-x64": "0.0.362",
27
+ "@vzn/vx-darwin-arm64": "0.0.362"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -1590,8 +1590,24 @@ export class Cache implements CacheLayer {
1590
1590
  // whole. A commit that fails after the rename takes the artifact back
1591
1591
  // out: the old rows then name no artifact, which a probe reads as a
1592
1592
  // miss.
1593
+ //
1594
+ // A previous artifact is moved aside first, never renamed over: ext4
1595
+ // flushes the incoming file's delayed blocks when a rename replaces a
1596
+ // file, 0.55 ms against 0.04 on the main thread with the write lock
1597
+ // held (a `--force` run, 2026-10-02). A reader probing between the two
1598
+ // renames finds no artifact, a miss (`ArtifactVanishedError`); one
1599
+ // holding the old file still reads it whole. The aside name is a temp
1600
+ // name, so a crash before its unlink leaves an orphan the sweep takes.
1601
+ const aside = this.tempPath(hash)
1602
+ let displaced = false
1593
1603
  let renamed = false
1594
1604
  const tx = this.db.transaction(() => {
1605
+ try {
1606
+ renameSync(finalPath, aside)
1607
+ displaced = true
1608
+ } catch (err) {
1609
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
1610
+ }
1595
1611
  renameSync(tmpPath, finalPath)
1596
1612
  renamed = true
1597
1613
  insertEntry.run(
@@ -1628,11 +1644,13 @@ export class Cache implements CacheLayer {
1628
1644
  try {
1629
1645
  tx.immediate()
1630
1646
  } catch (err) {
1647
+ endTx()
1631
1648
  await unlink(renamed ? finalPath : tmpPath).catch(() => undefined)
1649
+ if (displaced) await unlink(aside).catch(() => undefined)
1632
1650
  throw err
1633
- } finally {
1634
- endTx()
1635
1651
  }
1652
+ endTx()
1653
+ if (displaced) await unlink(aside).catch(() => undefined)
1636
1654
  }
1637
1655
 
1638
1656
  /** Apply the deferred accessed_at bumps in one statement. */
@@ -181,6 +181,14 @@ export interface ScheduleOptions {
181
181
  * be the next clean run's stale hit (`ExecuteArgs.taintedUpstream`).
182
182
  */
183
183
  continueMode?: ContinueMode
184
+ /**
185
+ * A failure the graph's outcomes do not hold yet: a persistent task
186
+ * that died after it became ready. Its outcome said `success` when it
187
+ * was ready, so this is how a dependant not yet started learns of it:
188
+ * under 'deps-ok' it is skipped as below a failed task, and under
189
+ * 'never' it stops dispatch.
190
+ */
191
+ serverDied?: (id: string) => boolean
184
192
  execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
185
193
  onStart?: (node: TaskNode) => void
186
194
  onFinish?: (outcome: TaskOutcome) => void
@@ -563,8 +571,19 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
563
571
  // they're dep-independent, so they typically restore before a dep
564
572
  // could fail anyway.
565
573
  const aborted = (): boolean => options.signal?.aborted === true
574
+ const serverDied = options.serverDied ?? ((): boolean => false)
575
+ const servers =
576
+ options.serverDied === undefined
577
+ ? []
578
+ : [...nodes.values()]
579
+ .filter((n) => n.config.exec?.persistent !== undefined)
580
+ .map((n) => n.id)
566
581
  const willSkip = (id: string): boolean => {
567
582
  if (failFastTripped || aborted()) return true
583
+ if (continueMode === 'never' && servers.some(serverDied)) {
584
+ failFastTripped = true
585
+ return true
586
+ }
568
587
  if (inRestoreTier(id)) return false
569
588
  if (continueMode === 'always') return false
570
589
  const node = nodes.get(id) as TaskNode
@@ -580,7 +599,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
580
599
  u?.status === 'failed' ||
581
600
  u?.status === 'skipped' ||
582
601
  u?.status === 'aborted' ||
583
- blockedRestores.has(d)
602
+ blockedRestores.has(d) ||
603
+ serverDied(d)
584
604
  )
585
605
  })
586
606
  }
@@ -651,6 +671,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
651
671
  const viaRestore = node.deps.find((d) => blockedRestores.has(d))
652
672
  const blockedBy =
653
673
  blocker?.node.id ??
674
+ node.deps.find(serverDied) ??
654
675
  viaSkip?.blockedBy ??
655
676
  (viaRestore === undefined ? undefined : blockedRestores.get(viaRestore))
656
677
  finishOne(id, {
@@ -821,7 +821,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
821
821
  })
822
822
  .catch(async (raw: unknown) => {
823
823
  const err = nameExecutorFailure(args.executor, raw)
824
- const message = err instanceof Error ? err.message : String(err)
824
+ // A remote executor's message carries the server's own text, which
825
+ // may echo the env it was sent: masked here, where it is printed,
826
+ // and on the error the scheduler prints with its cause (L-39).
827
+ if (secrets !== null) {
828
+ for (const e of [err, err instanceof Error ? err.cause : undefined])
829
+ if (e instanceof Error) e.message = secrets.mask(e.message)
830
+ }
831
+ const message =
832
+ err instanceof Error ? err.message : (secrets?.mask(String(err)) ?? String(err))
825
833
  log.taskStderr(node, `${message}\n`)
826
834
  await sweepPlaceholders(placeholders)
827
835
  throw err
@@ -922,6 +922,12 @@ async function runOnBus(
922
922
  ...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
923
923
  ...(admit !== undefined ? { admit } : {}),
924
924
  ...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
925
+ // A server that ended on its own is failed (the end of the run says
926
+ // so); what has not yet started hears it before it dispatches (C-88).
927
+ serverDied: (id) => {
928
+ const child = persistentRegistry.get(id)
929
+ return child !== undefined && hasEnded(child) && child.exitCode !== 0
930
+ },
925
931
  signal: stopRun.signal,
926
932
  onStart: (node) => {
927
933
  log.taskStart?.(node)
@@ -296,7 +296,7 @@ function servesCommand(command: string): boolean {
296
296
  /**
297
297
  * Whether script `name` never exits: by name, as a watcher, as a server, or
298
298
  * running such a script of its own package by name (docusaurus's
299
- * `start:baseUrl`: `cross-env BASE_URL=… pnpm start`).
299
+ * `start:baseUrl`: `cross-env BASE_URL=… pnpm start`) or through a runner.
300
300
  */
301
301
  function isPersistent(
302
302
  name: string,
@@ -307,8 +307,13 @@ function isPersistent(
307
307
  if (typeof own !== 'string' || seen.has(name)) return false
308
308
  seen.add(name)
309
309
  if (PERSISTENT_TASK_NAMES.has(name) || isWatcher(name, own) || servesCommand(own)) return true
310
+ // Through a runner too: `concurrently "npm:web" "npm:api"` and `run-p web
311
+ // api` over two servers mapped as one-shot tasks (D-113).
312
+ const text = Object.entries(scripts).filter(
313
+ (e): e is [string, string] => typeof e[1] === 'string',
314
+ )
310
315
  return foreground(own).some((segment) =>
311
- [...segment.matchAll(RUNS_SCRIPT)].some((m) => isPersistent(m[1]!, scripts, seen)),
316
+ scriptRefs(segment, text).some((ref) => isPersistent(ref, scripts, seen)),
312
317
  )
313
318
  }
314
319