@vzn/vx 0.0.638 → 0.0.640

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.
Files changed (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -1,4 +1,3 @@
1
- import path from 'node:path'
2
1
  import type { ExecConfig, TaskConfig, CacheConfig, InputStability } from '../config.js'
3
2
  import {
4
3
  ArtifactVanishedError,
@@ -16,17 +15,9 @@ import {
16
15
  FULL_CACHE_POLICY,
17
16
  type GitFilesCache,
18
17
  racyWindowMs,
18
+ type TaskInputComponent,
19
19
  } from '../cache/index.js'
20
20
  import {
21
- buildIsolatedEnv,
22
- packageManagerPath,
23
- PM_EXEC_ENV,
24
- VX_RUN_TASK_ENV,
25
- VX_RUN_WORKSPACE_ENV,
26
- runPersistent,
27
- withForwardArgs,
28
- releaseBridges,
29
- wrapSandboxedCommand,
30
21
  signalExitCode,
31
22
  isLocalExecutor,
32
23
  isExecutorFallback,
@@ -38,7 +29,6 @@ import {
38
29
  type SandboxViolation,
39
30
  type TaskExecutor,
40
31
  type TaskInputs,
41
- PersistentReadyError,
42
32
  sandboxReads,
43
33
  maskCaptured,
44
34
  BoundedCapture,
@@ -53,7 +43,6 @@ import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '..
53
43
  import {
54
44
  killGraceMs,
55
45
  MASKED,
56
- parseDuration,
57
46
  maskedEmitter,
58
47
  outputMatcher,
59
48
  printable,
@@ -63,14 +52,13 @@ import {
63
52
  span,
64
53
  UserError,
65
54
  } from '../util/index.js'
66
- import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS, terminateChildren } from './signals.js'
55
+ import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
67
56
  import { executorLabel, nameExecutorFailure } from './plugin-host.js'
68
57
  import type { CommandWindows } from './command-windows.js'
69
58
  import {
70
59
  commandWriteReach,
71
60
  mayWriteFingerprint,
72
61
  type Placeholder,
73
- placeholderSweeper,
74
62
  reachedWithheld,
75
63
  sandboxRequestFor,
76
64
  sandboxReadable,
@@ -83,25 +71,33 @@ import {
83
71
  import { markUnsaved, saveMiss, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
84
72
  import type { FingerprintWatch } from './fingerprint-watch.js'
85
73
  import { restoreHit } from './hit-restore.js'
86
- import type { MissExplainer } from './miss-reason.js'
74
+ import type { MissExplainer } from '../history/index.js'
87
75
  import { shellVerdict } from './shell-verdict.js'
88
76
  import { sandboxFix } from './sandbox-fix.js'
89
77
  // The hit path's entry stays importable from here (tests).
90
78
  export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
91
79
  import type { DeferredOutputs } from './deferred-outputs.js'
92
- import type { Logger } from './logger.js'
80
+ import { type Logger, warnOf } from '../report/index.js'
93
81
  import {
94
82
  computeGroupHash,
95
83
  computeTaskHash,
96
84
  describeTaskInputs,
97
85
  type HashCache,
98
86
  type InputFact,
87
+ isKeyless,
99
88
  movedInput,
100
- type TaskInputComponent,
101
89
  type UpfrontGroupKey,
102
90
  } from './task-hash.js'
103
91
  import { getContext } from './remote-prefetch.js'
104
92
  import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
93
+ import { executePersistentTask } from './persistent-task.js'
94
+ import {
95
+ forgetUndeclaredWrites,
96
+ isAborted,
97
+ taskBinDirs,
98
+ taskEnv,
99
+ taskTimeoutMs,
100
+ } from './task-process.js'
105
101
 
106
102
  export interface ExecuteArgs {
107
103
  node: TaskNode
@@ -181,8 +177,6 @@ export interface ExecuteArgs {
181
177
  * workspace. Per-task `exec.timeout` wins. Never hashed.
182
178
  */
183
179
  timeout?: number
184
- /** `run.readyNotice` (ms), resolved like `timeout`; unset, 10 s. */
185
- readyNotice?: number
186
180
  /**
187
181
  * The run's `inputStability` layers around the task's own: `run` from
188
182
  * the env vars (above the task), `workspace` from `.vx/workspace` (below).
@@ -259,9 +253,10 @@ export interface ExecuteArgs {
259
253
  /**
260
254
  * The run keeps its bytes in a remote (run-store.ts): an attempt captures
261
255
  * its output at the entry's bounds, and an outcome that saves no entry
262
- * (a failure, an uncached run) carries it as `runLog`.
256
+ * carries it as `runLog`; a failure's only when `failures`
257
+ * (`cache.upload.failures`).
263
258
  */
264
- keepRunLog?: boolean
259
+ keepRun?: { failures: boolean }
265
260
  /**
266
261
  * Whether a task has rewritten a root file a plugin claims (whose bytes
267
262
  * the claimant folded) since the run read it (fingerprint-watch.ts): past that, no key taken
@@ -283,8 +278,9 @@ export interface ExecuteArgs {
283
278
  /**
284
279
  * Dispatch a single task to one of three execution paths. Each path
285
280
  * owns its own outcome shape; the dispatcher just picks based on the
286
- * task's config shape (group vs persistent vs cached). Sharing
287
- * helpers (`taskEnv`, `effectiveForwardArgs`) live below.
281
+ * task's config shape (group vs persistent vs cached). The persistent
282
+ * path is persistent-task.ts; what both spawning paths share is
283
+ * task-process.ts.
288
284
  */
289
285
  export async function executeTask(args: ExecuteArgs): Promise<TaskOutcome> {
290
286
  if (isGroupTask(args.node)) return executeGroupTask(args)
@@ -355,7 +351,7 @@ async function restoreOrMiss(
355
351
  return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
356
352
  } catch (err) {
357
353
  if (!(err instanceof ArtifactVanishedError)) throw err
358
- args.log.status(`[vx] ${args.node.id}: ${err.message} — running it`)
354
+ warnOf(args.log)(`[vx] ${args.node.id}: ${err.message} — running it`)
359
355
  return null
360
356
  }
361
357
  }
@@ -400,7 +396,7 @@ function fingerprintMoved(args: ExecuteArgs): boolean {
400
396
  * through us.
401
397
  */
402
398
  async function executeGroupTask(args: ExecuteArgs): Promise<TaskOutcome> {
403
- if (args.node.config.cache === undefined) {
399
+ if (args.node.config.inputs === undefined) {
404
400
  return unkeyedGroupOutcome(
405
401
  args.node,
406
402
  args.upstream,
@@ -423,7 +419,7 @@ async function executeGroupTask(args: ExecuteArgs): Promise<TaskOutcome> {
423
419
  }
424
420
 
425
421
  /**
426
- * An unkeyed group's outcome (no `cache`, no key of its own): its key is
422
+ * An unkeyed group's outcome (no `inputs`, no key of its own): its key is
427
423
  * its upstream's alone (`computeGroupKey`), so the up-front one stands when
428
424
  * that upstream is unchanged. Synchronous: the scheduler settles such a
429
425
  * group in place (`ScheduleOptions.settleNow`), and `upstream` is the key's
@@ -474,294 +470,6 @@ function sameUpstream(live: TaskOutcome[], upfront: TaskOutcome[]): boolean {
474
470
  return true
475
471
  }
476
472
 
477
- /**
478
- * Persistent task: dev server / file watcher / daemon. Spawn, wait
479
- * for ready (regex match or immediate), stash the subprocess in the
480
- * orchestrator-owned registry, return success. The orchestrator
481
- * SIGTERMs the registry at end-of-run.
482
- *
483
- * Never reads or writes the cache — the project loader rejects
484
- * `cache + persistent` at config-load time, so by the time we get
485
- * here, `cache` is guaranteed undefined.
486
- *
487
- * Never routed through an executor: a persistent task is local by
488
- * construction (its port lives on this machine).
489
- */
490
- async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
491
- const { node, log } = args
492
- // Type narrowing: the dispatcher checks `exec.persistent` before
493
- // calling us, so `exec` and `exec.persistent` are both present.
494
- const step = node.config.exec as ExecConfig & {
495
- persistent: NonNullable<ExecConfig['persistent']>
496
- }
497
- const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
498
- const env = taskEnv(node, step, args.workspaceRoot)
499
- const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
500
- // Keyed like an uncached task (its whole project), so a cached dependant
501
- // behind a dev server re-runs when the server's sources change; with no
502
- // key the dependant folded nothing of it and replayed a stale e2e (A-17).
503
- const hash =
504
- args.noDependants === true
505
- ? undefined
506
- : (args.upfrontKey ??
507
- (await computeTaskHash({
508
- node,
509
- upstream: args.upstream,
510
- workspaceRoot: args.workspaceRoot,
511
- cache: args.cache,
512
- forwardArgs: args.forwardArgs,
513
- nestedProjectDirs: args.nestedProjectDirs,
514
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
515
- ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
516
- })))
517
-
518
- // The args after `--` reach a server as they reach any task. A readyWhen
519
- // server once got none — "so the matcher sees the unmodified output",
520
- // though the args change the command, not what the matcher reads — and
521
- // `vx run dev -- --port 4000` on the documented Vite task dropped the
522
- // port in silence (item 1060).
523
- const plainCommand = withForwardArgs(step.command, effectiveForwardArgs)
524
- // A persistent task declaring `exec.sandbox` runs inside it like any
525
- // other: the same grants, the same walls. What it cannot have is the
526
- // violation REPORT — that reads the trace after the child exits, and a
527
- // server exits when the run tears it down. Enforced, not reported.
528
- // (Until 2026-09-09 the block was accepted and silently ignored.)
529
- let command = plainCommand
530
- let bridgeTag: string | undefined
531
- let signalChannel = false
532
- // The empty files the request pre-created for a literal write grant; a
533
- // server that never writes one gets it taken back when it exits, and a
534
- // server that dies before readiness is told the directory spelling — the
535
- // same trap and the same answer as a one-shot task's (sandbox-request.ts).
536
- let placeholders: Placeholder[] = []
537
- if (step.sandbox !== undefined) {
538
- await args.armSandbox?.()
539
- // A persistent task never declares `cache` (the loader refuses it), so
540
- // it keeps the whole linked grant: no key of its can be stale.
541
- const sb = await sandboxRequestFor(
542
- node,
543
- step.sandbox,
544
- args.workspaceRoot,
545
- undefined,
546
- args.nestedProjectDirs,
547
- args.cacheDir,
548
- )
549
- placeholders = sb.placeholders
550
- // A wrap that refuses (a port the host holds) leaves the server unspawned
551
- // and the placeholders vx made for it in the project for every later run.
552
- const wrapped = await wrapSandboxedCommand({
553
- command: plainCommand,
554
- cwd: node.projectDir,
555
- env,
556
- ...sb.sandbox,
557
- server: true,
558
- }).catch(async (err: unknown) => {
559
- await sweepPlaceholders(placeholders)
560
- throw err
561
- })
562
- command = wrapped.wrapped
563
- bridgeTag = wrapped.tag
564
- signalChannel = wrapped.forwardsSignals
565
- }
566
- // A server's output masked as a one-shot task's is (L-11); `readyWhen`
567
- // is matched on the raw chunks, before this. Its held tail is flushed by
568
- // the idle timer: a server never ends on the way to a flush.
569
- const serverSecrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
570
- const serverOut = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStdout(node, t))
571
- const serverErr = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStderr(node, t))
572
- // A server writes until the run stops it: its window stays open.
573
- args.commandWindows?.open(node.id, node.projectDir, commandWriteReach(node, args.workspaceRoot))
574
- const persistentOpts: Parameters<typeof runPersistent>[0] = {
575
- command,
576
- cwd: node.projectDir,
577
- env,
578
- onStdout: serverOut ? (chunk) => serverOut.push(chunk) : (chunk) => log.taskStdout(node, chunk),
579
- onStderr: serverErr ? (chunk) => serverErr.push(chunk) : (chunk) => log.taskStderr(node, chunk),
580
- ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
581
- ...(args.track !== undefined
582
- ? { onSpawn: (pid: number) => void args.track!(node.id, pid) }
583
- : {}),
584
- ...(signalChannel ? { signalChannel } : {}),
585
- ...(args.terminal === true ? { terminal: true } : {}),
586
- }
587
- if (step.persistent.readyWhen !== undefined) {
588
- persistentOpts.readyWhen = step.persistent.readyWhen
589
- }
590
- // For a persistent task the timeout bounds the readiness wait. Per-task
591
- // `exec.timeout` wins; else the run-level default (env/workspace/--timeout).
592
- const effectiveTimeout = taskTimeoutMs(step) ?? args.timeout
593
- if (effectiveTimeout !== undefined) {
594
- persistentOpts.timeoutMs = effectiveTimeout
595
- }
596
-
597
- // A stop that landed during the awaits above (the key, the sandbox's
598
- // arming, request and wrap) leaves nothing to kill yet: spawned now, the
599
- // server came up after the teardown and held the run's exit 7 s.
600
- if (args.stopSignal?.aborted === true) {
601
- if (bridgeTag !== undefined) releaseBridges(bridgeTag)
602
- await placeholderSweeper(placeholders)()
603
- return {
604
- node,
605
- status: 'aborted',
606
- // The signal a spawned server would have been sent (a hang-up forwards SIGTERM).
607
- exitCode: signalExitCode(forwardedSignal(args.stopSignal.reason)),
608
- durationMs: 0,
609
- wallclockStartNs,
610
- wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
611
- }
612
- }
613
- const spawn = runPersistent(persistentOpts)
614
- // The host side of a port bridge lives exactly as long as the server:
615
- // released on the child's exit, whether the run tore it down or it died.
616
- // A placeholder the server never wrote goes back the same way.
617
- // ONE sweep, shared by the exit handler and the readiness failure below
618
- // — whichever asks second must get the SAME list. A second sweep cannot
619
- // produce it: the first one's `rm` already happened, so the file is gone
620
- // and `sweepPlaceholders` skips it. Collecting both lists and unioning
621
- // them does not close that, because the union only covers an exit
622
- // handler that FINISHED; one still between its `rm` and its return has
623
- // published nothing yet, and the readiness path then reports no
624
- // placeholder at all. The failure then says only "File exists", which is
625
- // the message the hint exists to explain (seen under the gate's parallel
626
- // load, and reproduced deterministically by delaying each side, item 450).
627
- const sweptUntouched = placeholderSweeper(placeholders)
628
- if (bridgeTag !== undefined || placeholders.length > 0) {
629
- const tag = bridgeTag
630
- const onExit = async (): Promise<void> => {
631
- if (tag !== undefined) releaseBridges(tag)
632
- await sweptUntouched()
633
- }
634
- // A spawn that failed has no child to wait on: release now, or the
635
- // tag would hold the sandbox's reset for the rest of the process.
636
- if (spawn.child === undefined) void onExit()
637
- else void spawn.child.exited.then(onExit, onExit)
638
- }
639
- // Said once if readiness is slow: a dependency's output is hidden unless
640
- // it fails, and with no `exec.timeout` the wait never ends, so a run
641
- // whose `readyWhen` never matched showed nothing at all.
642
- const readyWhen = step.persistent.readyWhen
643
- // How long a server may take to match `readyWhen` before vx says it waits.
644
- const noticeMs = args.readyNotice ?? 10_000
645
- const notice =
646
- readyWhen === undefined
647
- ? undefined
648
- : setTimeout(() => {
649
- if (isAborted(args.stopSignal)) return
650
- const after = noticeMs < 1000 ? `${noticeMs} ms` : `${noticeMs / 1000} s`
651
- const unbounded = effectiveTimeout === undefined ? ', with no exec.timeout' : ''
652
- log.status(
653
- `vx: ${node.id} not ready after ${after}: waiting for a line matching /${serverSecrets?.mask(readyWhen) ?? readyWhen}/ (readyWhen)${unbounded}`,
654
- )
655
- }, noticeMs)
656
- if (notice !== undefined) {
657
- const quiet = (): void => clearTimeout(notice)
658
- void spawn.ready.then(quiet, quiet)
659
- }
660
- try {
661
- await spawn.ready
662
- } catch (err) {
663
- // A shell that exited before ready can leave its group running
664
- // (`server & …; exit`): never registered, so no teardown reached it,
665
- // and it held its port against the next `vx watch` cycle's server.
666
- if (err instanceof PersistentReadyError && err.reason === 'exited')
667
- await terminateChildren(() => [spawn.child])
668
- // A server the run's stop killed while it started is aborted, as any
669
- // task the stop kills (item 962): it read `failed (never ready:
670
- // exited, exit 130)` with a recap after every Ctrl-C (C-62). The stop
671
- // aborts before it kills, so it is set by the time the child is gone.
672
- // Read through a call: the early return above narrows `aborted` to
673
- // false, but the stop can land during `spawn.ready`.
674
- if (isAborted(args.stopSignal)) {
675
- return {
676
- node,
677
- status: 'aborted',
678
- exitCode: err instanceof PersistentReadyError ? (err.exitCode ?? 1) : 1,
679
- durationMs: spawn.readyMs(),
680
- wallclockStartNs,
681
- wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
682
- }
683
- }
684
- const message = err instanceof Error ? err.message : String(err)
685
- // The task's OWN stream, not the process's: the frame is where a
686
- // reader looks for why a task failed, and a run with a custom logger
687
- // (an embedder) never saw a bare stderr write at all.
688
- log.taskStderr(node, `\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
689
- // A server is never traced, so the sandbox's refusals reach no report:
690
- // a dev server that died on a file outside its grants read only as the
691
- // tool's own "not found" (2026-10-03). Said for a sandboxed server that
692
- // exited failing, which is the shape a refusal takes.
693
- if (
694
- step.sandbox !== undefined &&
695
- err instanceof PersistentReadyError &&
696
- err.reason === 'exited' &&
697
- err.exitCode !== 0
698
- ) {
699
- log.taskStderr(
700
- node,
701
- `[vx] ${node.id} ran in the sandbox, which reports nothing for a server: a path outside ` +
702
- `its grants reads as missing (ENOENT), a refused write as read-only (EROFS). Check ` +
703
- `exec.sandbox.allow, or run the command as a one-shot sandboxed task to see what it was refused.\n`,
704
- )
705
- }
706
- // The server is dead or being torn down, so the sweep on its exit may
707
- // already be running: ask the shared one rather than starting a second.
708
- for (const p of await sweptUntouched()) {
709
- log.taskStderr(node, `${untouchedPlaceholderLine(node.projectDir, p)}\n`)
710
- }
711
- // The reason rides the outcome (every label reads it), and a child that
712
- // exited before ready keeps its own exit code rather than a made-up 1.
713
- // One the readiness timeout is killing reports the signal's, as an
714
- // ordinary timeout does (X-24).
715
- // A readiness timeout rejects once the group is gone (runner.ts), so a
716
- // server that ignored the TERM holds no port into the next `vx watch`
717
- // cycle, and a second signal here would cut a one-shot handler short.
718
- const ready = err instanceof PersistentReadyError ? err : undefined
719
- return {
720
- node,
721
- status: 'failed',
722
- // A server that traps the TERM and exits 0 still timed out, as a
723
- // one-shot task that does so reports 143 (X-116).
724
- exitCode:
725
- ready?.reason === 'timeout'
726
- ? (await spawn.child.exited) || signalExitCode('SIGTERM')
727
- : (ready?.exitCode ?? 1),
728
- durationMs: spawn.readyMs(),
729
- ...(ready !== undefined ? { notReady: ready.reason } : {}),
730
- wallclockStartNs,
731
- wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
732
- }
733
- }
734
-
735
- // Ready only once the stop had landed: a trap that prints the marker on
736
- // the way down, or a race with the signal. The stop is killing it (it
737
- // is in `liveChildren`), and it served nobody, as a one-shot that exits
738
- // 0 on the stop is aborted (item 962).
739
- if (isAborted(args.stopSignal)) {
740
- return {
741
- node,
742
- status: 'aborted',
743
- exitCode: signalExitCode(forwardedSignal(args.stopSignal!.reason)),
744
- durationMs: spawn.readyMs(),
745
- wallclockStartNs,
746
- wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
747
- }
748
- }
749
- args.persistentRegistry?.set(node.id, spawn.child)
750
- forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
751
- if (mayWriteFingerprint(node, args.workspaceRoot, claimedFiles(args))) {
752
- args.fingerprintWatch?.wrote()
753
- }
754
- return {
755
- node,
756
- status: 'success',
757
- exitCode: 0,
758
- durationMs: spawn.readyMs(),
759
- ...(hash !== undefined ? { hash } : {}),
760
- wallclockStartNs,
761
- wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
762
- }
763
- }
764
-
765
473
  /**
766
474
  * Cached task: the common case. Hash inputs, try cache.get; on hit,
767
475
  * clean+restore+replay logs; on miss (or --no-cache), clean outputs,
@@ -784,7 +492,17 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
784
492
  // exists to prevent), no output clean, no local artifact save. Its result
785
493
  // lives in the remote executor's own store.
786
494
  const remoteOnly = args.remoteOnly === true
787
- const willRead = !remoteOnly && cfgCacheable && (policy.localRead || policy.remoteRead)
495
+ // A key that folds a task with no inputs is this run's alone (`isKeyless`):
496
+ // nothing to read under it, and nothing a later run could find.
497
+ const present = upstream.filter((u): u is TaskOutcome => u !== undefined)
498
+ const folded = new Set(
499
+ filterUpstreamHashes(present, cfg.inputs?.tasks, node.projectName, node.id).map(([id]) => id),
500
+ )
501
+ const foldedTasks = expandGroupUpstream(present.filter((u) => folded.has(u.node.id)))
502
+ const keyless =
503
+ isKeyless(cfg) || foldedTasks.some((u) => u.keyless === true || isKeyless(u.node.config))
504
+ const reads = policy.localRead || policy.remoteRead
505
+ const willRead = !remoteOnly && cfgCacheable && !keyless && reads
788
506
  const willWrite = !remoteOnly && cfgCacheable && (policy.localWrite || policy.remoteWrite)
789
507
  // `willWrite` governs output hygiene (clean before exec); the save itself
790
508
  // is also withheld from a task downstream of a failure (see `taintedUpstream`),
@@ -797,17 +515,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
797
515
  // (`tasks: []` and a file input) is judged by its bytes. `upstream` holds
798
516
  // a hole where a dependency did not run (a skipped one, a restore-tier
799
517
  // task's unfinished deps).
800
- const present = upstream.filter((u): u is TaskOutcome => u !== undefined)
801
- const folded = new Set(
802
- filterUpstreamHashes(present, cacheCfg?.inputs?.tasks, node.projectName, node.id).map(
803
- ([id]) => id,
804
- ),
805
- )
806
- const unkeyedUpstream = expandGroupUpstream(present.filter((u) => folded.has(u.node.id))).find(
807
- (u) => u.unkeyed === true,
808
- )
518
+ const unkeyedUpstream = foldedTasks.find((u) => u.unkeyed === true)
809
519
  let unkeyed = unkeyedUpstream !== undefined
810
- const willSave = willWrite && args.taintedUpstream !== true && !unkeyed
520
+ const willSave = willWrite && args.taintedUpstream !== true && !unkeyed && !keyless
811
521
 
812
522
  // The executor retains nothing: the entry's output is the attempt's
813
523
  // `output` log, both streams in the order they reached the logger, kept
@@ -909,7 +619,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
909
619
  const taskStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
910
620
 
911
621
  // Hash is computed mid-run, not at prepareRun time. Tasks whose
912
- // `cache.inputs.files` matches sibling outputs (e.g. `'**/*'` after
622
+ // `inputs.files` matches sibling outputs (e.g. `'**/*'` after
913
623
  // a `codegen` step has written `generated.txt`) need the upstream
914
624
  // outputs ALREADY on disk when their hash is computed — so we
915
625
  // can't lift this into prepareRun. Same model as Turbo / Nx.
@@ -1106,13 +816,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1106
816
  // is a hit. Almost always a glob written against the wrong directory
1107
817
  // (`lib/**` in a package that builds `src/`). Said once per miss, on the
1108
818
  // run's status line — the task itself succeeds.
1109
- const declaredInputs = [
1110
- ...(cacheCfg?.inputs.files ?? []),
1111
- ...(cacheCfg?.inputs.workspaceFiles ?? []),
1112
- ]
819
+ const declaredInputs = [...(cfg.inputs?.files ?? []), ...(cfg.inputs?.workspaceFiles ?? [])]
1113
820
  if (inputs !== undefined && declaredInputs.length > 0 && inputs.files.length === 0) {
1114
- log.status(
1115
- `[vx] ${node.id}: cache.inputs matched no files (${declaredInputs.join(', ')}) — ` +
821
+ warnOf(log)(
822
+ `[vx] ${node.id}: inputs matched no files (${declaredInputs.join(', ')}) — ` +
1116
823
  `the key will not change when this project's source does`,
1117
824
  )
1118
825
  }
@@ -1235,7 +942,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1235
942
  if (remoteOnly) {
1236
943
  raw = new UserError(`${why}, and remote: 'only' keeps it off this machine`)
1237
944
  } else {
1238
- log.status(`[vx] ${node.id}: ${why} — running it here`)
945
+ warnOf(log)(`[vx] ${node.id}: ${why} — running it here`)
1239
946
  const local = await localExecutor().execute(await buildRequest(true))
1240
947
  assertExecuteResult('local', node.id, local)
1241
948
  return local
@@ -1471,7 +1178,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1471
1178
 
1472
1179
  async function buildRequest(local: boolean): Promise<ExecuteRequest> {
1473
1180
  const kept =
1474
- willSave || args.keepRunLog === true
1181
+ willSave || args.keepRun !== undefined
1475
1182
  ? new BoundedCapture()
1476
1183
  : new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
1477
1184
  output = kept
@@ -1709,13 +1416,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1709
1416
  ? ''
1710
1417
  : '; if the task writes it, declare it in cache.outputs'
1711
1418
  if (stability.severity === 'error') {
1712
- log.status(
1419
+ warnOf(log)(
1713
1420
  `[vx] ${node.id}: ${what} — the result is not saved, ` +
1714
1421
  `and inputStability.severity is 'error', so the task fails${hint}`,
1715
1422
  )
1716
1423
  movedFails = moved === null ? '' : printable(relPosix(args.workspaceRoot, moved))
1717
1424
  } else {
1718
- log.status(
1425
+ warnOf(log)(
1719
1426
  `[vx] ${node.id}: ${what} — the result stands, ` +
1720
1427
  `but is not saved under a key that no longer describes it${hint}`,
1721
1428
  )
@@ -1728,7 +1435,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1728
1435
  if (movedFails !== undefined) effectiveExitCode = 1
1729
1436
 
1730
1437
  if (unkeyedUpstream !== undefined && willWrite && effectiveExitCode === 0) {
1731
- log.status(
1438
+ warnOf(log)(
1732
1439
  `[vx] ${node.id}: ran over ${unkeyedUpstream.node.id}'s outputs, which its key no longer ` +
1733
1440
  `describes — the result stands, but is not saved`,
1734
1441
  )
@@ -1750,11 +1457,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1750
1457
  : {}),
1751
1458
  ...(inputChanges !== undefined ? { inputChanges } : {}),
1752
1459
  ...(unkeyed ? { unkeyed: true as const } : {}),
1460
+ ...(keyless ? { keyless: true as const } : {}),
1753
1461
  ...(saved ? { saved: true as const } : {}),
1754
1462
  ...(effectiveExitCode !== 0 && output !== undefined
1755
- ? { failedOutput: failedOutputLog(output, willSave || args.keepRunLog === true) }
1463
+ ? { failedOutput: failedOutputLog(output, willSave || args.keepRun !== undefined) }
1756
1464
  : {}),
1757
- ...(args.keepRunLog === true && (effectiveExitCode !== 0 || !willSave) && output !== undefined
1465
+ ...(args.keepRun !== undefined &&
1466
+ (effectiveExitCode !== 0 ? args.keepRun.failures : !willSave) &&
1467
+ output !== undefined
1758
1468
  ? { runLog: storedOutput() }
1759
1469
  : {}),
1760
1470
  ...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
@@ -1778,81 +1488,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1778
1488
  finalViolations,
1779
1489
  node.projectDir,
1780
1490
  (t) => secrets?.mask(t) ?? t,
1781
- cacheCfg?.inputs.files,
1491
+ cfg.inputs?.files,
1782
1492
  ),
1783
1493
  ),
1784
1494
  }
1785
1495
  : {}),
1786
1496
  }
1787
1497
  }
1788
-
1789
- /**
1790
- * Drop the run's facts about files a task may have written without
1791
- * declaring them (`undeclaredWriteReach`): the project's git snapshot and
1792
- * its index OIDs. The next reader
1793
- * re-enumerates the project (one `git ls-files`) and hashes its files by
1794
- * content; the per-declaration file-list memos are keyed on the snapshot
1795
- * array, so they miss with it (item 743).
1796
- */
1797
- function forgetUndeclaredWrites(args: ExecuteArgs, reach: 'none' | 'project' | 'workspace'): void {
1798
- if (reach === 'none') return
1799
- if (reach === 'workspace') {
1800
- args.gitFilesCache?.clear()
1801
- return
1802
- }
1803
- args.gitFilesCache?.delete(args.node.projectDir)
1804
- args.gitFilesCache?.invalidateWorkspacePartition()
1805
- }
1806
-
1807
- /** The two bin directories a task's PATH starts with: its own, then the root's (once when they coincide). */
1808
- function taskBinDirs(node: TaskNode, workspaceRoot: string): string[] {
1809
- const bins = [path.join(node.projectDir, 'node_modules', '.bin')]
1810
- const rootBin = path.join(workspaceRoot, 'node_modules', '.bin')
1811
- // Identical when the root is itself a project — dedupe rather than list it
1812
- // twice, so PATH reads the same either way.
1813
- if (rootBin !== bins[0]) bins.push(rootBin)
1814
- return bins
1815
- }
1816
-
1817
- /**
1818
- * Build the child-process env for one task. Same arguments at every call site
1819
- * (persistent + cached). Two `node_modules/.bin` directories are prepended to
1820
- * PATH: the project's own, then the WORKSPACE ROOT's. Never a sibling
1821
- * project's, and never an arbitrary ancestor — that is the project-isolation
1822
- * rule, and the root is not a sibling.
1823
- *
1824
- * The root entry is where a monorepo's shared tooling actually lives: declare
1825
- * `oxlint` once as a root devDependency and every member's `.bin` is empty of
1826
- * it. Without the root on PATH such a task exits 127, which is what this
1827
- * repo's own gate did the moment core stopped BEING the root and the two
1828
- * paths stopped coinciding. It also removes a divergence that mattered more:
1829
- * the REAPI executor already rebuilds both entries in the action's command,
1830
- * so a task that resolved on a worker failed on the machine that submitted
1831
- * it. npm/pnpm/yarn all put the ancestor chain on PATH for the same reason.
1832
- */
1833
- function taskEnv(node: TaskNode, step: ExecConfig, workspaceRoot: string): NodeJS.ProcessEnv {
1834
- const bins = taskBinDirs(node, workspaceRoot)
1835
- const env = buildIsolatedEnv({
1836
- passThrough: step.env?.passThrough ?? [],
1837
- define: step.env?.define ?? {},
1838
- source: process.env,
1839
- binPaths: bins,
1840
- })
1841
- env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
1842
- env[VX_RUN_TASK_ENV] = node.id
1843
- // A task's own passThrough or define wins.
1844
- if (env[PM_EXEC_ENV] === undefined) {
1845
- const manager = packageManagerPath(workspaceRoot)
1846
- if (manager !== null) env[PM_EXEC_ENV] = manager
1847
- }
1848
- return env
1849
- }
1850
-
1851
- /** A task's own `exec.timeout` in ms; the schema has checked its form. */
1852
- function taskTimeoutMs(step: ExecConfig): number | undefined {
1853
- return step.timeout === undefined ? undefined : parseDuration(step.timeout)!
1854
- }
1855
-
1856
- function isAborted(signal: AbortSignal | undefined): boolean {
1857
- return signal?.aborted === true
1858
- }
@@ -12,7 +12,7 @@
12
12
  import { readFileSync, statSync } from 'node:fs'
13
13
  import path from 'node:path'
14
14
  import { FILE_HASH_RACY_MS, racyWindowMs } from '../cache/index.js'
15
- import type { Logger } from './logger.js'
15
+ import { type Logger, warnOf } from '../report/index.js'
16
16
 
17
17
  export class FingerprintWatch {
18
18
  private pending = false
@@ -83,7 +83,7 @@ export class FingerprintWatch {
83
83
  say(log: Logger): void {
84
84
  if (this.said || this.movedFiles === undefined) return
85
85
  this.said = true
86
- log.status(
86
+ warnOf(log)(
87
87
  `[vx] a task rewrote \`${this.movedFiles.join('`, `')}\` during the run, which a plugin ` +
88
88
  `folds into every key: nothing keyed before it is restored or saved from here on`,
89
89
  )