@vzn/vx 0.0.637 → 0.0.639

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 (126) hide show
  1. package/docs/caching.md +84 -28
  2. package/docs/cli.md +273 -408
  3. package/docs/execution.md +19 -44
  4. package/docs/features.md +13 -15
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +68 -61
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/schemas/plan.json +2 -2
  10. package/skills/vx/SKILL.md +19 -6
  11. package/src/bin.ts +13 -7
  12. package/src/cache/cache.ts +60 -11
  13. package/src/cache/git-inputs.ts +6 -0
  14. package/src/cache/index.ts +10 -2
  15. package/src/cache/inputs.ts +0 -24
  16. package/src/cache/layer.ts +32 -12
  17. package/src/cache/layered-cache.ts +112 -315
  18. package/src/cache/plugin-cache.ts +240 -0
  19. package/src/cache/policy.ts +9 -0
  20. package/src/cache/remote-lane.ts +383 -0
  21. package/src/cache/run-history.ts +92 -8
  22. package/src/cache/run-store.ts +77 -0
  23. package/src/cache/schema.ts +44 -13
  24. package/src/cache/scoped-cache.ts +22 -2
  25. package/src/cli/cache.ts +33 -16
  26. package/src/cli/completions.ts +11 -7
  27. package/src/cli/foreign-flags.ts +10 -22
  28. package/src/cli/help.ts +22 -38
  29. package/src/cli/index.ts +25 -11
  30. package/src/cli/info.ts +8 -6
  31. package/src/cli/init.ts +14 -15
  32. package/src/cli/last.ts +20 -10
  33. package/src/cli/lock.ts +39 -27
  34. package/src/cli/plan-format.ts +10 -61
  35. package/src/cli/plugin-commands.ts +77 -6
  36. package/src/cli/plugin-templates.ts +1 -1
  37. package/src/cli/run.ts +31 -191
  38. package/src/cli/select.ts +18 -9
  39. package/src/cli/show.ts +72 -40
  40. package/src/cli/task-verb.ts +4 -4
  41. package/src/cli/upgrade.ts +132 -39
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +7 -2
  44. package/src/cli/watch-judge.ts +6 -1
  45. package/src/cli/watch.ts +73 -34
  46. package/src/cli/why.ts +14 -5
  47. package/src/cli/workspace-config.ts +2 -2
  48. package/src/config.ts +0 -6
  49. package/src/exec/executor.ts +1 -1
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/index.ts +9 -1
  53. package/src/graph/priorities.ts +1 -1
  54. package/src/graph/scheduler.ts +12 -16
  55. package/src/graph/task-graph.ts +119 -21
  56. package/src/graph/task-status.ts +95 -0
  57. package/src/{orchestrator → history}/failure-mode.ts +20 -11
  58. package/src/{orchestrator → history}/history.ts +61 -5
  59. package/src/history/index.ts +48 -0
  60. package/src/{orchestrator → history}/metrics.ts +14 -23
  61. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  62. package/src/index.ts +21 -29
  63. package/src/orchestrator/affected-tasks.ts +8 -3
  64. package/src/orchestrator/doctor.ts +2 -2
  65. package/src/orchestrator/execute-args.ts +80 -0
  66. package/src/orchestrator/execute-task.ts +47 -429
  67. package/src/orchestrator/fingerprint-watch.ts +2 -2
  68. package/src/orchestrator/index.ts +3 -74
  69. package/src/orchestrator/miss-save.ts +33 -3
  70. package/src/orchestrator/options.ts +1 -36
  71. package/src/orchestrator/output-host.ts +4 -3
  72. package/src/orchestrator/persistent-task.ts +321 -0
  73. package/src/orchestrator/persistent.ts +130 -3
  74. package/src/orchestrator/placement.ts +3 -3
  75. package/src/orchestrator/plan.ts +18 -5
  76. package/src/orchestrator/plugin-host.ts +77 -47
  77. package/src/orchestrator/plugin.ts +86 -14
  78. package/src/orchestrator/prepare.ts +74 -31
  79. package/src/orchestrator/projects.ts +3 -0
  80. package/src/orchestrator/remote-prefetch.ts +23 -6
  81. package/src/orchestrator/run-artifacts.ts +5 -177
  82. package/src/orchestrator/run-context.ts +55 -0
  83. package/src/orchestrator/run-failures.ts +15 -3
  84. package/src/orchestrator/run-records.ts +36 -4
  85. package/src/orchestrator/run-report.ts +81 -179
  86. package/src/orchestrator/run-settings.ts +1 -16
  87. package/src/orchestrator/run-stop.ts +153 -0
  88. package/src/orchestrator/run.ts +313 -555
  89. package/src/orchestrator/sandbox-fix.ts +0 -16
  90. package/src/orchestrator/signals.ts +14 -6
  91. package/src/orchestrator/stable-keys.ts +107 -3
  92. package/src/orchestrator/steps.ts +50 -0
  93. package/src/orchestrator/task-hash.ts +3 -16
  94. package/src/orchestrator/task-log-buffer.ts +1 -1
  95. package/src/orchestrator/task-process.ts +94 -0
  96. package/src/orchestrator/telemetry-host.ts +1 -1
  97. package/src/orchestrator/telemetry.ts +6 -83
  98. package/src/{orchestrator → report}/events.ts +41 -8
  99. package/src/{orchestrator → report}/forecast.ts +35 -2
  100. package/src/{orchestrator → report}/framed-output.ts +39 -34
  101. package/src/report/index.ts +36 -0
  102. package/src/{orchestrator → report}/logger.ts +166 -21
  103. package/src/{orchestrator → report}/status-line.ts +84 -7
  104. package/src/{orchestrator → report}/summary.ts +179 -63
  105. package/src/report/tally.ts +83 -0
  106. package/src/settings.ts +13 -23
  107. package/src/{orchestrator → util}/colors.ts +49 -4
  108. package/src/util/config-frame.ts +31 -2
  109. package/src/util/errors.ts +32 -0
  110. package/src/util/git-ignored.ts +143 -0
  111. package/src/util/index.ts +25 -3
  112. package/src/util/refusal-notice.ts +111 -0
  113. package/src/util/report.ts +39 -0
  114. package/src/util/settle.ts +14 -39
  115. package/src/util/tail.ts +1 -1
  116. package/src/util/ui.ts +197 -0
  117. package/src/workspace/affected.ts +20 -23
  118. package/src/workspace/dependency-rules.ts +12 -4
  119. package/src/workspace/migration.ts +87 -91
  120. package/src/workspace/workspace.ts +5 -4
  121. package/schemas/summary.json +0 -133
  122. package/src/cache/chained-cache.ts +0 -276
  123. package/src/orchestrator/tally.ts +0 -112
  124. /package/src/{orchestrator → report}/path-links.ts +0 -0
  125. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  126. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -1,11 +1,9 @@
1
- import path from 'node:path'
2
1
  import type { ExecConfig, TaskConfig, CacheConfig, InputStability } from '../config.js'
3
2
  import {
4
3
  ArtifactVanishedError,
5
4
  type CacheEntry,
6
5
  type CacheLayer,
7
6
  type CachePolicy,
8
- anyUntouched,
9
7
  cleanOutputs,
10
8
  committedFiles,
11
9
  cleanWorkspaceOutputs,
@@ -17,17 +15,9 @@ import {
17
15
  FULL_CACHE_POLICY,
18
16
  type GitFilesCache,
19
17
  racyWindowMs,
18
+ type TaskInputComponent,
20
19
  } from '../cache/index.js'
21
20
  import {
22
- buildIsolatedEnv,
23
- packageManagerPath,
24
- PM_EXEC_ENV,
25
- VX_RUN_TASK_ENV,
26
- VX_RUN_WORKSPACE_ENV,
27
- runPersistent,
28
- withForwardArgs,
29
- releaseBridges,
30
- wrapSandboxedCommand,
31
21
  signalExitCode,
32
22
  isLocalExecutor,
33
23
  isExecutorFallback,
@@ -39,7 +29,6 @@ import {
39
29
  type SandboxViolation,
40
30
  type TaskExecutor,
41
31
  type TaskInputs,
42
- PersistentReadyError,
43
32
  sandboxReads,
44
33
  maskCaptured,
45
34
  BoundedCapture,
@@ -54,7 +43,6 @@ import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '..
54
43
  import {
55
44
  killGraceMs,
56
45
  MASKED,
57
- parseDuration,
58
46
  maskedEmitter,
59
47
  outputMatcher,
60
48
  printable,
@@ -64,14 +52,13 @@ import {
64
52
  span,
65
53
  UserError,
66
54
  } from '../util/index.js'
67
- import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS, terminateChildren } from './signals.js'
55
+ import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
68
56
  import { executorLabel, nameExecutorFailure } from './plugin-host.js'
69
57
  import type { CommandWindows } from './command-windows.js'
70
58
  import {
71
59
  commandWriteReach,
72
60
  mayWriteFingerprint,
73
61
  type Placeholder,
74
- placeholderSweeper,
75
62
  reachedWithheld,
76
63
  sandboxRequestFor,
77
64
  sandboxReadable,
@@ -84,13 +71,13 @@ import {
84
71
  import { markUnsaved, saveMiss, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
85
72
  import type { FingerprintWatch } from './fingerprint-watch.js'
86
73
  import { restoreHit } from './hit-restore.js'
87
- import type { MissExplainer } from './miss-reason.js'
74
+ import type { MissExplainer } from '../history/index.js'
88
75
  import { shellVerdict } from './shell-verdict.js'
89
76
  import { sandboxFix } from './sandbox-fix.js'
90
77
  // The hit path's entry stays importable from here (tests).
91
78
  export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
92
79
  import type { DeferredOutputs } from './deferred-outputs.js'
93
- import type { Logger } from './logger.js'
80
+ import { type Logger, warnOf } from '../report/index.js'
94
81
  import {
95
82
  computeGroupHash,
96
83
  computeTaskHash,
@@ -98,11 +85,18 @@ import {
98
85
  type HashCache,
99
86
  type InputFact,
100
87
  movedInput,
101
- type TaskInputComponent,
102
88
  type UpfrontGroupKey,
103
89
  } from './task-hash.js'
104
90
  import { getContext } from './remote-prefetch.js'
105
91
  import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
92
+ import { executePersistentTask } from './persistent-task.js'
93
+ import {
94
+ forgetUndeclaredWrites,
95
+ isAborted,
96
+ taskBinDirs,
97
+ taskEnv,
98
+ taskTimeoutMs,
99
+ } from './task-process.js'
106
100
 
107
101
  export interface ExecuteArgs {
108
102
  node: TaskNode
@@ -182,14 +176,12 @@ export interface ExecuteArgs {
182
176
  * workspace. Per-task `exec.timeout` wins. Never hashed.
183
177
  */
184
178
  timeout?: number
185
- /** `run.readyNotice` (ms), resolved like `timeout`; unset, 10 s. */
186
- readyNotice?: number
187
179
  /**
188
180
  * The run's `inputStability` layers around the task's own: `run` from
189
181
  * the env vars (above the task), `workspace` from `.vx/workspace` (below).
190
182
  */
191
183
  inputStability?: { run: InputStability; workspace?: InputStability }
192
- /** `cache.outputs.clean` for tasks without their own: its variable, else `.vx/workspace`. */
184
+ /** `cache.outputs.clean` for tasks without their own: `.vx/workspace`. */
193
185
  cleanOutputs?: boolean
194
186
  /** Per-run memo for `git ls-files` (one entry per project dir). */
195
187
  gitFilesCache?: GitFilesCache
@@ -257,6 +249,12 @@ export interface ExecuteArgs {
257
249
  * clean run's stale hit.
258
250
  */
259
251
  taintedUpstream?: boolean
252
+ /**
253
+ * The run keeps its bytes in a remote (run-store.ts): an attempt captures
254
+ * its output at the entry's bounds, and an outcome that saves no entry
255
+ * (a failure, an uncached run) carries it as `runLog`.
256
+ */
257
+ keepRunLog?: boolean
260
258
  /**
261
259
  * Whether a task has rewritten a root file a plugin claims (whose bytes
262
260
  * the claimant folded) since the run read it (fingerprint-watch.ts): past that, no key taken
@@ -278,8 +276,9 @@ export interface ExecuteArgs {
278
276
  /**
279
277
  * Dispatch a single task to one of three execution paths. Each path
280
278
  * owns its own outcome shape; the dispatcher just picks based on the
281
- * task's config shape (group vs persistent vs cached). Sharing
282
- * helpers (`taskEnv`, `effectiveForwardArgs`) live below.
279
+ * task's config shape (group vs persistent vs cached). The persistent
280
+ * path is persistent-task.ts; what both spawning paths share is
281
+ * task-process.ts.
283
282
  */
284
283
  export async function executeTask(args: ExecuteArgs): Promise<TaskOutcome> {
285
284
  if (isGroupTask(args.node)) return executeGroupTask(args)
@@ -350,7 +349,7 @@ async function restoreOrMiss(
350
349
  return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
351
350
  } catch (err) {
352
351
  if (!(err instanceof ArtifactVanishedError)) throw err
353
- args.log.status(`[vx] ${args.node.id}: ${err.message} — running it`)
352
+ warnOf(args.log)(`[vx] ${args.node.id}: ${err.message} — running it`)
354
353
  return null
355
354
  }
356
355
  }
@@ -469,294 +468,6 @@ function sameUpstream(live: TaskOutcome[], upfront: TaskOutcome[]): boolean {
469
468
  return true
470
469
  }
471
470
 
472
- /**
473
- * Persistent task: dev server / file watcher / daemon. Spawn, wait
474
- * for ready (regex match or immediate), stash the subprocess in the
475
- * orchestrator-owned registry, return success. The orchestrator
476
- * SIGTERMs the registry at end-of-run.
477
- *
478
- * Never reads or writes the cache — the project loader rejects
479
- * `cache + persistent` at config-load time, so by the time we get
480
- * here, `cache` is guaranteed undefined.
481
- *
482
- * Never routed through an executor: a persistent task is local by
483
- * construction (its port lives on this machine).
484
- */
485
- async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
486
- const { node, log } = args
487
- // Type narrowing: the dispatcher checks `exec.persistent` before
488
- // calling us, so `exec` and `exec.persistent` are both present.
489
- const step = node.config.exec as ExecConfig & {
490
- persistent: NonNullable<ExecConfig['persistent']>
491
- }
492
- const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
493
- const env = taskEnv(node, step, args.workspaceRoot)
494
- const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
495
- // Keyed like an uncached task (its whole project), so a cached dependant
496
- // behind a dev server re-runs when the server's sources change; with no
497
- // key the dependant folded nothing of it and replayed a stale e2e (A-17).
498
- const hash =
499
- args.noDependants === true
500
- ? undefined
501
- : (args.upfrontKey ??
502
- (await computeTaskHash({
503
- node,
504
- upstream: args.upstream,
505
- workspaceRoot: args.workspaceRoot,
506
- cache: args.cache,
507
- forwardArgs: args.forwardArgs,
508
- nestedProjectDirs: args.nestedProjectDirs,
509
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
510
- ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
511
- })))
512
-
513
- // The args after `--` reach a server as they reach any task. A readyWhen
514
- // server once got none — "so the matcher sees the unmodified output",
515
- // though the args change the command, not what the matcher reads — and
516
- // `vx run dev -- --port 4000` on the documented Vite task dropped the
517
- // port in silence (item 1060).
518
- const plainCommand = withForwardArgs(step.command, effectiveForwardArgs)
519
- // A persistent task declaring `exec.sandbox` runs inside it like any
520
- // other: the same grants, the same walls. What it cannot have is the
521
- // violation REPORT — that reads the trace after the child exits, and a
522
- // server exits when the run tears it down. Enforced, not reported.
523
- // (Until 2026-09-09 the block was accepted and silently ignored.)
524
- let command = plainCommand
525
- let bridgeTag: string | undefined
526
- let signalChannel = false
527
- // The empty files the request pre-created for a literal write grant; a
528
- // server that never writes one gets it taken back when it exits, and a
529
- // server that dies before readiness is told the directory spelling — the
530
- // same trap and the same answer as a one-shot task's (sandbox-request.ts).
531
- let placeholders: Placeholder[] = []
532
- if (step.sandbox !== undefined) {
533
- await args.armSandbox?.()
534
- // A persistent task never declares `cache` (the loader refuses it), so
535
- // it keeps the whole linked grant: no key of its can be stale.
536
- const sb = await sandboxRequestFor(
537
- node,
538
- step.sandbox,
539
- args.workspaceRoot,
540
- undefined,
541
- args.nestedProjectDirs,
542
- args.cacheDir,
543
- )
544
- placeholders = sb.placeholders
545
- // A wrap that refuses (a port the host holds) leaves the server unspawned
546
- // and the placeholders vx made for it in the project for every later run.
547
- const wrapped = await wrapSandboxedCommand({
548
- command: plainCommand,
549
- cwd: node.projectDir,
550
- env,
551
- ...sb.sandbox,
552
- server: true,
553
- }).catch(async (err: unknown) => {
554
- await sweepPlaceholders(placeholders)
555
- throw err
556
- })
557
- command = wrapped.wrapped
558
- bridgeTag = wrapped.tag
559
- signalChannel = wrapped.forwardsSignals
560
- }
561
- // A server's output masked as a one-shot task's is (L-11); `readyWhen`
562
- // is matched on the raw chunks, before this. Its held tail is flushed by
563
- // the idle timer: a server never ends on the way to a flush.
564
- const serverSecrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
565
- const serverOut = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStdout(node, t))
566
- const serverErr = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStderr(node, t))
567
- // A server writes until the run stops it: its window stays open.
568
- args.commandWindows?.open(node.id, node.projectDir, commandWriteReach(node, args.workspaceRoot))
569
- const persistentOpts: Parameters<typeof runPersistent>[0] = {
570
- command,
571
- cwd: node.projectDir,
572
- env,
573
- onStdout: serverOut ? (chunk) => serverOut.push(chunk) : (chunk) => log.taskStdout(node, chunk),
574
- onStderr: serverErr ? (chunk) => serverErr.push(chunk) : (chunk) => log.taskStderr(node, chunk),
575
- ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
576
- ...(args.track !== undefined
577
- ? { onSpawn: (pid: number) => void args.track!(node.id, pid) }
578
- : {}),
579
- ...(signalChannel ? { signalChannel } : {}),
580
- ...(args.terminal === true ? { terminal: true } : {}),
581
- }
582
- if (step.persistent.readyWhen !== undefined) {
583
- persistentOpts.readyWhen = step.persistent.readyWhen
584
- }
585
- // For a persistent task the timeout bounds the readiness wait. Per-task
586
- // `exec.timeout` wins; else the run-level default (env/workspace/--timeout).
587
- const effectiveTimeout = taskTimeoutMs(step) ?? args.timeout
588
- if (effectiveTimeout !== undefined) {
589
- persistentOpts.timeoutMs = effectiveTimeout
590
- }
591
-
592
- // A stop that landed during the awaits above (the key, the sandbox's
593
- // arming, request and wrap) leaves nothing to kill yet: spawned now, the
594
- // server came up after the teardown and held the run's exit 7 s.
595
- if (args.stopSignal?.aborted === true) {
596
- if (bridgeTag !== undefined) releaseBridges(bridgeTag)
597
- await placeholderSweeper(placeholders)()
598
- return {
599
- node,
600
- status: 'aborted',
601
- // The signal a spawned server would have been sent (a hang-up forwards SIGTERM).
602
- exitCode: signalExitCode(forwardedSignal(args.stopSignal.reason)),
603
- durationMs: 0,
604
- wallclockStartNs,
605
- wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
606
- }
607
- }
608
- const spawn = runPersistent(persistentOpts)
609
- // The host side of a port bridge lives exactly as long as the server:
610
- // released on the child's exit, whether the run tore it down or it died.
611
- // A placeholder the server never wrote goes back the same way.
612
- // ONE sweep, shared by the exit handler and the readiness failure below
613
- // — whichever asks second must get the SAME list. A second sweep cannot
614
- // produce it: the first one's `rm` already happened, so the file is gone
615
- // and `sweepPlaceholders` skips it. Collecting both lists and unioning
616
- // them does not close that, because the union only covers an exit
617
- // handler that FINISHED; one still between its `rm` and its return has
618
- // published nothing yet, and the readiness path then reports no
619
- // placeholder at all. The failure then says only "File exists", which is
620
- // the message the hint exists to explain (seen under the gate's parallel
621
- // load, and reproduced deterministically by delaying each side, item 450).
622
- const sweptUntouched = placeholderSweeper(placeholders)
623
- if (bridgeTag !== undefined || placeholders.length > 0) {
624
- const tag = bridgeTag
625
- const onExit = async (): Promise<void> => {
626
- if (tag !== undefined) releaseBridges(tag)
627
- await sweptUntouched()
628
- }
629
- // A spawn that failed has no child to wait on: release now, or the
630
- // tag would hold the sandbox's reset for the rest of the process.
631
- if (spawn.child === undefined) void onExit()
632
- else void spawn.child.exited.then(onExit, onExit)
633
- }
634
- // Said once if readiness is slow: a dependency's output is hidden unless
635
- // it fails, and with no `exec.timeout` the wait never ends, so a run
636
- // whose `readyWhen` never matched showed nothing at all.
637
- const readyWhen = step.persistent.readyWhen
638
- // How long a server may take to match `readyWhen` before vx says it waits.
639
- const noticeMs = args.readyNotice ?? 10_000
640
- const notice =
641
- readyWhen === undefined
642
- ? undefined
643
- : setTimeout(() => {
644
- if (isAborted(args.stopSignal)) return
645
- const after = noticeMs < 1000 ? `${noticeMs} ms` : `${noticeMs / 1000} s`
646
- const unbounded = effectiveTimeout === undefined ? ', with no exec.timeout' : ''
647
- log.status(
648
- `vx: ${node.id} not ready after ${after}: waiting for a line matching /${serverSecrets?.mask(readyWhen) ?? readyWhen}/ (readyWhen)${unbounded}`,
649
- )
650
- }, noticeMs)
651
- if (notice !== undefined) {
652
- const quiet = (): void => clearTimeout(notice)
653
- void spawn.ready.then(quiet, quiet)
654
- }
655
- try {
656
- await spawn.ready
657
- } catch (err) {
658
- // A shell that exited before ready can leave its group running
659
- // (`server & …; exit`): never registered, so no teardown reached it,
660
- // and it held its port against the next `vx watch` cycle's server.
661
- if (err instanceof PersistentReadyError && err.reason === 'exited')
662
- await terminateChildren(() => [spawn.child])
663
- // A server the run's stop killed while it started is aborted, as any
664
- // task the stop kills (item 962): it read `failed (never ready:
665
- // exited, exit 130)` with a recap after every Ctrl-C (C-62). The stop
666
- // aborts before it kills, so it is set by the time the child is gone.
667
- // Read through a call: the early return above narrows `aborted` to
668
- // false, but the stop can land during `spawn.ready`.
669
- if (isAborted(args.stopSignal)) {
670
- return {
671
- node,
672
- status: 'aborted',
673
- exitCode: err instanceof PersistentReadyError ? (err.exitCode ?? 1) : 1,
674
- durationMs: spawn.readyMs(),
675
- wallclockStartNs,
676
- wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
677
- }
678
- }
679
- const message = err instanceof Error ? err.message : String(err)
680
- // The task's OWN stream, not the process's: the frame is where a
681
- // reader looks for why a task failed, and a run with a custom logger
682
- // (an embedder) never saw a bare stderr write at all.
683
- log.taskStderr(node, `\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
684
- // A server is never traced, so the sandbox's refusals reach no report:
685
- // a dev server that died on a file outside its grants read only as the
686
- // tool's own "not found" (2026-10-03). Said for a sandboxed server that
687
- // exited failing, which is the shape a refusal takes.
688
- if (
689
- step.sandbox !== undefined &&
690
- err instanceof PersistentReadyError &&
691
- err.reason === 'exited' &&
692
- err.exitCode !== 0
693
- ) {
694
- log.taskStderr(
695
- node,
696
- `[vx] ${node.id} ran in the sandbox, which reports nothing for a server: a path outside ` +
697
- `its grants reads as missing (ENOENT), a refused write as read-only (EROFS). Check ` +
698
- `exec.sandbox.allow, or run the command as a one-shot sandboxed task to see what it was refused.\n`,
699
- )
700
- }
701
- // The server is dead or being torn down, so the sweep on its exit may
702
- // already be running: ask the shared one rather than starting a second.
703
- for (const p of await sweptUntouched()) {
704
- log.taskStderr(node, `${untouchedPlaceholderLine(node.projectDir, p)}\n`)
705
- }
706
- // The reason rides the outcome (every label reads it), and a child that
707
- // exited before ready keeps its own exit code rather than a made-up 1.
708
- // One the readiness timeout is killing reports the signal's, as an
709
- // ordinary timeout does (X-24).
710
- // A readiness timeout rejects once the group is gone (runner.ts), so a
711
- // server that ignored the TERM holds no port into the next `vx watch`
712
- // cycle, and a second signal here would cut a one-shot handler short.
713
- const ready = err instanceof PersistentReadyError ? err : undefined
714
- return {
715
- node,
716
- status: 'failed',
717
- // A server that traps the TERM and exits 0 still timed out, as a
718
- // one-shot task that does so reports 143 (X-116).
719
- exitCode:
720
- ready?.reason === 'timeout'
721
- ? (await spawn.child.exited) || signalExitCode('SIGTERM')
722
- : (ready?.exitCode ?? 1),
723
- durationMs: spawn.readyMs(),
724
- ...(ready !== undefined ? { notReady: ready.reason } : {}),
725
- wallclockStartNs,
726
- wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
727
- }
728
- }
729
-
730
- // Ready only once the stop had landed: a trap that prints the marker on
731
- // the way down, or a race with the signal. The stop is killing it (it
732
- // is in `liveChildren`), and it served nobody, as a one-shot that exits
733
- // 0 on the stop is aborted (item 962).
734
- if (isAborted(args.stopSignal)) {
735
- return {
736
- node,
737
- status: 'aborted',
738
- exitCode: signalExitCode(forwardedSignal(args.stopSignal!.reason)),
739
- durationMs: spawn.readyMs(),
740
- wallclockStartNs,
741
- wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
742
- }
743
- }
744
- args.persistentRegistry?.set(node.id, spawn.child)
745
- forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
746
- if (mayWriteFingerprint(node, args.workspaceRoot, claimedFiles(args))) {
747
- args.fingerprintWatch?.wrote()
748
- }
749
- return {
750
- node,
751
- status: 'success',
752
- exitCode: 0,
753
- durationMs: spawn.readyMs(),
754
- ...(hash !== undefined ? { hash } : {}),
755
- wallclockStartNs,
756
- wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
757
- }
758
- }
759
-
760
471
  /**
761
472
  * Cached task: the common case. Hash inputs, try cache.get; on hit,
762
473
  * clean+restore+replay logs; on miss (or --no-cache), clean outputs,
@@ -977,11 +688,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
977
688
  let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
978
689
  let wsStampedBefore: ReadonlyMap<string, OutputStamp> | undefined
979
690
  // `cache.outputs.clean: false` leaves the outputs to the first attempt's
980
- // command, stamped, so a save can tell what it found and never wrote.
981
- // An additive task never cleans by glob anyway.
691
+ // command, and the save stores whatever they hold after it, a leftover
692
+ // included: the user opted out of the clean-tree guarantee (M,
693
+ // 2026-10-15). A retry still starts clean. An additive task never cleans
694
+ // by glob anyway.
982
695
  const keepOutputs = !additive && (cacheCfg?.outputs.clean ?? args.cleanOutputs ?? true) === false
983
- let keptBefore: ReadonlyMap<string, OutputStamp> | undefined
984
- let wsKeptBefore: ReadonlyMap<string, OutputStamp> | undefined
985
696
 
986
697
  // Cache lookup. On hit, time the user-perceived restore op
987
698
  // (clean+restore+log-replay) — that's what the framed-block footer
@@ -1106,7 +817,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1106
817
  ...(cacheCfg?.inputs.workspaceFiles ?? []),
1107
818
  ]
1108
819
  if (inputs !== undefined && declaredInputs.length > 0 && inputs.files.length === 0) {
1109
- log.status(
820
+ warnOf(log)(
1110
821
  `[vx] ${node.id}: cache.inputs matched no files (${declaredInputs.join(', ')}) — ` +
1111
822
  `the key will not change when this project's source does`,
1112
823
  )
@@ -1148,6 +859,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1148
859
  exitCode: number
1149
860
  withdrawn?: true
1150
861
  }> {
862
+ // The first attempt under `clean: false` finds its last outputs (tsc's `.tsbuildinfo`).
863
+ const clean = willWrite && !deferralRequested && !(keepOutputs && attempt === 1)
1151
864
  // A deferred task's outputs are deliberately NOT coming, so wiping the
1152
865
  // tree would replace a stale build with nothing at all. The eligibility
1153
866
  // gate guarantees no key in this run can see what stays behind, and the
@@ -1156,16 +869,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1156
869
  const endStamp = span('miss: stamp outputs')
1157
870
  stampedBefore ??= await stampOutputs(cleanArgs)
1158
871
  endStamp()
1159
- } else if (
1160
- keepOutputs &&
1161
- attempt === 1 &&
1162
- willWrite &&
1163
- !deferralRequested &&
1164
- outputs.length > 0
1165
- ) {
1166
- keptBefore = await stampOutputs(cleanArgs)
1167
- } else if (willWrite && !deferralRequested && outputs.length > 0) {
1168
- keptBefore = undefined
872
+ } else if (clean && outputs.length > 0) {
1169
873
  // Mark the wiped paths, exactly as the workspace twin below does. The
1170
874
  // git snapshot still lists them with their committed index OIDs, and
1171
875
  // resolveFiles SKIPS its existence probe for any path carrying a
@@ -1182,16 +886,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1182
886
  // glob clean deleted the upstream's files before this task, which
1183
887
  // reads them, ran (A-43).
1184
888
  wsStampedBefore ??= await stampWorkspaceOutputs(wsCleanArgs)
1185
- } else if (
1186
- keepOutputs &&
1187
- attempt === 1 &&
1188
- willWrite &&
1189
- !deferralRequested &&
1190
- wsOutputs.length > 0
1191
- ) {
1192
- wsKeptBefore = await stampWorkspaceOutputs(wsCleanArgs)
1193
- } else if (willWrite && !deferralRequested && wsOutputs.length > 0) {
1194
- wsKeptBefore = undefined
889
+ } else if (clean && wsOutputs.length > 0) {
1195
890
  // Root-anchored deletions can land in other projects' dirs; mark
1196
891
  // them so stale per-project git snapshots can't survive the wipe.
1197
892
  const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
@@ -1246,7 +941,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1246
941
  if (remoteOnly) {
1247
942
  raw = new UserError(`${why}, and remote: 'only' keeps it off this machine`)
1248
943
  } else {
1249
- log.status(`[vx] ${node.id}: ${why} — running it here`)
944
+ warnOf(log)(`[vx] ${node.id}: ${why} — running it here`)
1250
945
  const local = await localExecutor().execute(await buildRequest(true))
1251
946
  assertExecuteResult('local', node.id, local)
1252
947
  return local
@@ -1481,9 +1176,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1481
1176
  }
1482
1177
 
1483
1178
  async function buildRequest(local: boolean): Promise<ExecuteRequest> {
1484
- const kept = willSave
1485
- ? new BoundedCapture()
1486
- : new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
1179
+ const kept =
1180
+ willSave || args.keepRunLog === true
1181
+ ? new BoundedCapture()
1182
+ : new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
1487
1183
  output = kept
1488
1184
  const toOut = (t: string): void => {
1489
1185
  kept.push(t)
@@ -1553,6 +1249,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1553
1249
  const stability = inputStabilityOf(args.node.config.cache, args.inputStability)
1554
1250
  let movedFails: string | undefined
1555
1251
  let own: Awaited<ReturnType<typeof ownOutputs>>
1252
+ let saved = false
1556
1253
  if (effectiveExitCode === 0 && willSave && deferralRequested) {
1557
1254
  // The outputs never landed here: no artifact, no rows. A partial local
1558
1255
  // record (a row with no artifact) is exactly the corrupt-entry shape
@@ -1580,7 +1277,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1580
1277
  } else if (
1581
1278
  effectiveExitCode === 0 &&
1582
1279
  willSave &&
1583
- !leftBehind() &&
1584
1280
  (await keyStillTrue()) &&
1585
1281
  (own = await ownOutputs()) !== undefined
1586
1282
  ) {
@@ -1617,6 +1313,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1617
1313
  : {}),
1618
1314
  })
1619
1315
  args.deferredSaves?.set(node.id, landed)
1316
+ saved = true
1620
1317
  } else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants !== true) {
1621
1318
  // Ran here and saves nothing — failed, a read-only policy, a tainted
1622
1319
  // upstream, a key that no longer held — yet it wrote what a save would
@@ -1642,18 +1339,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1642
1339
  }
1643
1340
  }
1644
1341
 
1645
- /**
1646
- * A file the kept outputs held that the run left as it was: saving would
1647
- * file a leftover from an older build as this run's output, so nothing is
1648
- * saved (`cache.outputs.clean: false`).
1649
- */
1650
- function leftBehind(): boolean {
1651
- return (
1652
- (keptBefore !== undefined && anyUntouched(keptBefore)) ||
1653
- (wsKeptBefore !== undefined && anyUntouched(wsKeptBefore))
1654
- )
1655
- }
1656
-
1657
1342
  /**
1658
1343
  * What an additive run saves as its own, or undefined when it removed a
1659
1344
  * file it found: no entry replays a removal (`ownOutputsSince`), so the
@@ -1730,13 +1415,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1730
1415
  ? ''
1731
1416
  : '; if the task writes it, declare it in cache.outputs'
1732
1417
  if (stability.severity === 'error') {
1733
- log.status(
1418
+ warnOf(log)(
1734
1419
  `[vx] ${node.id}: ${what} — the result is not saved, ` +
1735
1420
  `and inputStability.severity is 'error', so the task fails${hint}`,
1736
1421
  )
1737
1422
  movedFails = moved === null ? '' : printable(relPosix(args.workspaceRoot, moved))
1738
1423
  } else {
1739
- log.status(
1424
+ warnOf(log)(
1740
1425
  `[vx] ${node.id}: ${what} — the result stands, ` +
1741
1426
  `but is not saved under a key that no longer describes it${hint}`,
1742
1427
  )
@@ -1749,7 +1434,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1749
1434
  if (movedFails !== undefined) effectiveExitCode = 1
1750
1435
 
1751
1436
  if (unkeyedUpstream !== undefined && willWrite && effectiveExitCode === 0) {
1752
- log.status(
1437
+ warnOf(log)(
1753
1438
  `[vx] ${node.id}: ran over ${unkeyedUpstream.node.id}'s outputs, which its key no longer ` +
1754
1439
  `describes — the result stands, but is not saved`,
1755
1440
  )
@@ -1771,8 +1456,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1771
1456
  : {}),
1772
1457
  ...(inputChanges !== undefined ? { inputChanges } : {}),
1773
1458
  ...(unkeyed ? { unkeyed: true as const } : {}),
1459
+ ...(saved ? { saved: true as const } : {}),
1774
1460
  ...(effectiveExitCode !== 0 && output !== undefined
1775
- ? { failedOutput: failedOutputLog(output, willSave) }
1461
+ ? { failedOutput: failedOutputLog(output, willSave || args.keepRunLog === true) }
1462
+ : {}),
1463
+ ...(args.keepRunLog === true && (effectiveExitCode !== 0 || !willSave) && output !== undefined
1464
+ ? { runLog: storedOutput() }
1776
1465
  : {}),
1777
1466
  ...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
1778
1467
  ...(movedFails !== undefined ? { inputMoved: movedFails } : {}),
@@ -1802,74 +1491,3 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1802
1491
  : {}),
1803
1492
  }
1804
1493
  }
1805
-
1806
- /**
1807
- * Drop the run's facts about files a task may have written without
1808
- * declaring them (`undeclaredWriteReach`): the project's git snapshot and
1809
- * its index OIDs. The next reader
1810
- * re-enumerates the project (one `git ls-files`) and hashes its files by
1811
- * content; the per-declaration file-list memos are keyed on the snapshot
1812
- * array, so they miss with it (item 743).
1813
- */
1814
- function forgetUndeclaredWrites(args: ExecuteArgs, reach: 'none' | 'project' | 'workspace'): void {
1815
- if (reach === 'none') return
1816
- if (reach === 'workspace') {
1817
- args.gitFilesCache?.clear()
1818
- return
1819
- }
1820
- args.gitFilesCache?.delete(args.node.projectDir)
1821
- args.gitFilesCache?.invalidateWorkspacePartition()
1822
- }
1823
-
1824
- /** The two bin directories a task's PATH starts with: its own, then the root's (once when they coincide). */
1825
- function taskBinDirs(node: TaskNode, workspaceRoot: string): string[] {
1826
- const bins = [path.join(node.projectDir, 'node_modules', '.bin')]
1827
- const rootBin = path.join(workspaceRoot, 'node_modules', '.bin')
1828
- // Identical when the root is itself a project — dedupe rather than list it
1829
- // twice, so PATH reads the same either way.
1830
- if (rootBin !== bins[0]) bins.push(rootBin)
1831
- return bins
1832
- }
1833
-
1834
- /**
1835
- * Build the child-process env for one task. Same arguments at every call site
1836
- * (persistent + cached). Two `node_modules/.bin` directories are prepended to
1837
- * PATH: the project's own, then the WORKSPACE ROOT's. Never a sibling
1838
- * project's, and never an arbitrary ancestor — that is the project-isolation
1839
- * rule, and the root is not a sibling.
1840
- *
1841
- * The root entry is where a monorepo's shared tooling actually lives: declare
1842
- * `oxlint` once as a root devDependency and every member's `.bin` is empty of
1843
- * it. Without the root on PATH such a task exits 127, which is what this
1844
- * repo's own gate did the moment core stopped BEING the root and the two
1845
- * paths stopped coinciding. It also removes a divergence that mattered more:
1846
- * the REAPI executor already rebuilds both entries in the action's command,
1847
- * so a task that resolved on a worker failed on the machine that submitted
1848
- * it. npm/pnpm/yarn all put the ancestor chain on PATH for the same reason.
1849
- */
1850
- function taskEnv(node: TaskNode, step: ExecConfig, workspaceRoot: string): NodeJS.ProcessEnv {
1851
- const bins = taskBinDirs(node, workspaceRoot)
1852
- const env = buildIsolatedEnv({
1853
- passThrough: step.env?.passThrough ?? [],
1854
- define: step.env?.define ?? {},
1855
- source: process.env,
1856
- binPaths: bins,
1857
- })
1858
- env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
1859
- env[VX_RUN_TASK_ENV] = node.id
1860
- // A task's own passThrough or define wins.
1861
- if (env[PM_EXEC_ENV] === undefined) {
1862
- const manager = packageManagerPath(workspaceRoot)
1863
- if (manager !== null) env[PM_EXEC_ENV] = manager
1864
- }
1865
- return env
1866
- }
1867
-
1868
- /** A task's own `exec.timeout` in ms; the schema has checked its form. */
1869
- function taskTimeoutMs(step: ExecConfig): number | undefined {
1870
- return step.timeout === undefined ? undefined : parseDuration(step.timeout)!
1871
- }
1872
-
1873
- function isAborted(signal: AbortSignal | undefined): boolean {
1874
- return signal?.aborted === true
1875
- }