@vzn/vx 0.0.646 → 0.0.649

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -9,7 +9,7 @@ import path from 'node:path'
9
9
  import type { SandboxViolation } from '../exec/index.js'
10
10
  import type { TaskOutcome } from '../graph/index.js'
11
11
  import { isLiteralPattern } from '../util/index.js'
12
- import { CREDENTIAL_STORES } from './sandbox-request.js'
12
+ import { credentialStorePaths } from './sandbox-request.js'
13
13
 
14
14
  /** Grants to add under `exec.sandbox.allow`, each list sorted and unique. */
15
15
  type SandboxFix = NonNullable<TaskOutcome['sandboxFix']>
@@ -22,9 +22,7 @@ type Kind = Exclude<keyof SandboxFix, 'withheld' | 'inputs'>
22
22
  function isCredential(abs: string): boolean {
23
23
  const base = path.basename(abs)
24
24
  if (base === '.env' || base.startsWith('.env.')) return true
25
- const rel = path.relative(os.homedir(), abs)
26
- if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false
27
- return CREDENTIAL_STORES.some((d) => rel === d || rel.startsWith(d + path.sep))
25
+ return credentialStorePaths(os.homedir()).some((d) => abs === d || abs.startsWith(d + path.sep))
28
26
  }
29
27
 
30
28
  /**
@@ -33,7 +31,8 @@ function isCredential(abs: string): boolean {
33
31
  */
34
32
  function grantPath(abs: string, projectDir: string): string {
35
33
  const rel = path.relative(projectDir, abs)
36
- if (rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel
34
+ if (rel === '') return '.'
35
+ if (!rel.startsWith('..') && !path.isAbsolute(rel)) return rel
37
36
  const home = os.homedir()
38
37
  return abs.startsWith(home + path.sep) ? `~/${abs.slice(home.length + 1)}` : abs
39
38
  }
@@ -111,6 +110,9 @@ export function sandboxFix(
111
110
  else if (kind === 'read') {
112
111
  const grant = grantPath(v.path, projectDir)
113
112
  fix.read.add(grant)
113
+ // The link the task opened lies in a directory the sandbox lacks: the
114
+ // target alone stays unreachable, and the fix named it again forever.
115
+ if (v.via !== undefined) fix.read.add(grantPath(v.via, projectDir))
114
116
  if (keyed !== undefined && inProject(grant) && !keyed(grant)) fix.inputs.add(grant)
115
117
  } else {
116
118
  const isDir =
@@ -45,7 +45,7 @@ export interface SandboxArmer {
45
45
  readonly armed: boolean
46
46
  }
47
47
 
48
- /** What the run's sandbox union reads of a task: a `TaskNode`, or an agent's order. */
48
+ /** What the run's sandbox union reads of a task: a `TaskNode`, or a worker's order. */
49
49
  export interface SandboxedNode {
50
50
  readonly config: { readonly exec?: { readonly sandbox?: ExecConfig['sandbox'] } }
51
51
  }
@@ -167,7 +167,7 @@ export function prepareSandbox(nodes: Iterable<SandboxedNode>): SandboxArmer | n
167
167
  * The sandbox half of an `ExecuteRequest` for one task, shared by the
168
168
  * cached path (through the executor) and the persistent path (spawned here).
169
169
  *
170
- * The user's grants derive NOTHING from `cache` (owner, 2026-09-05). Those
170
+ * The user's grants derive NOTHING from `cache` (2026-09-05). Those
171
171
  * are two different questions: `inputs` says what INVALIDATES the
172
172
  * task, `sandbox.allow` says what it may TOUCH. Deriving one from the other
173
173
  * coupled them in both directions — a declaration added for caching
@@ -209,6 +209,15 @@ export async function sandboxRequestFor(
209
209
  `pattern, not a name — rename the directory, or run the task without exec.sandbox`,
210
210
  )
211
211
  }
212
+ // The runtime reports a refused touch one line per record: a path holding
213
+ // a line break arrived split, and an undeclared write to the project's own
214
+ // file was judged as outside it, with the wrong hint.
215
+ if (/[\n\r]/.test(home)) {
216
+ throw new UserError(
217
+ `exec.sandbox: ${home} holds a line break, which the sandbox runtime's reports cannot ` +
218
+ `carry, so a refused touch is misreported — rename the directory, or run the task without exec.sandbox`,
219
+ )
220
+ }
212
221
  // Bun's realpath throws ENOENT on a path holding a backslash, and SRT
213
222
  // mounts no path it cannot resolve: the task saw no project and ran in
214
223
  // $HOME.
@@ -226,7 +235,7 @@ export async function sandboxRequestFor(
226
235
  // sibling project, so granting `node_modules` grants a link whose
227
236
  // target is outside it. That target is a dependency, not a reach-out:
228
237
  // no project config should have to name a sibling to import what its
229
- // own `package.json` depends on (owner, 2026-09-05) — when the key
238
+ // own `package.json` depends on (2026-09-05) — when the key
230
239
  // moves with it.
231
240
  const links = await linkedDeps(depDirs, node.projectDir, workspaceRoot, keyed)
232
241
  depDirs.push(...links.granted)
@@ -235,8 +244,10 @@ export async function sandboxRequestFor(
235
244
  // Pre-create what the task said it will write — after the grants are
236
245
  // resolved, which refuses one that leaves the project through a link
237
246
  // before anything is created along it (item 1003).
238
- const walls = [
239
- ...nested,
247
+ // The repository's and vx's own walls are sealed: no write grant may
248
+ // reach inside them either. A nested project is not: a grant into it is
249
+ // as explicit as one into a sibling.
250
+ const sealed = [
240
251
  path.join(workspaceRoot, '.git'),
241
252
  path.join(workspaceRoot, '.vx', 'cache'),
242
253
  ...(cacheDir !== undefined &&
@@ -244,6 +255,7 @@ export async function sandboxRequestFor(
244
255
  ? [cacheDir]
245
256
  : []),
246
257
  ].map(toRealPath)
258
+ const walls = [...nested.map(toRealPath), ...sealed]
247
259
  const resolved = resolveSandboxConfig(sandbox, node.projectDir, walls)
248
260
  // Made before the walls judge the binds: a directory grant naming nothing
249
261
  // yet (`dist/`, `dist/**`) was judged as a file, its bind its parent, and
@@ -251,7 +263,7 @@ export async function sandboxRequestFor(
251
263
  const placeholders = await prepareOutputsForBind(node.projectDir, sandbox.allow?.write ?? [])
252
264
  let config: ResolvedSandboxConfig
253
265
  try {
254
- config = wallOff(resolved, workspaceRoot, walls)
266
+ config = wallOff(resolved, workspaceRoot, walls, sealed)
255
267
  } catch (err) {
256
268
  await sweepPlaceholders(placeholders)
257
269
  throw err
@@ -276,6 +288,7 @@ export async function sandboxRequestFor(
276
288
  baseDenyRead: [
277
289
  ...(process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot]),
278
290
  ...(await credentialStores(workspaceRoot, config.allowWrite)),
291
+ ...(await vxStores(workspaceRoot, config.allowWrite, cacheDir)),
279
292
  ],
280
293
  // …but only denials INSIDE the project are worth reporting. A task
281
294
  // bumping into the wall is the sandbox working, not a finding: the
@@ -431,7 +444,7 @@ async function linkedDeps(
431
444
  */
432
445
  export function withheldLinkLine(taskId: string, w: WithheldLink): string {
433
446
  return (
434
- `vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key folds no task of ` +
447
+ `vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key includes no task of ` +
435
448
  `${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
436
449
  `reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
437
450
  `\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
@@ -540,20 +553,23 @@ async function fileOnPath(from: string, to: string): Promise<string> {
540
553
  * punched (`punchWalls`); a write grant whose bind would hold one — a file
541
554
  * grant binds its directory on Linux, so `write: ['out.txt']` in a root
542
555
  * project bound the whole workspace writable, `.git` included — is
543
- * refused, as there is no writable bind that leaves a wall out. A glob's
556
+ * refused, as there is no writable bind that leaves a wall out. One
557
+ * inside a sealed wall (`.git/hooks/`, vx's store) is refused too: a
558
+ * hook planted there runs unsandboxed at the next commit. A glob's
544
559
  * hits already stop at the walls (`resolveSandboxConfig` takes them).
545
560
  */
546
561
  function wallOff(
547
562
  config: ResolvedSandboxConfig,
548
563
  workspaceRoot: string,
549
564
  walls: readonly string[],
565
+ sealed: readonly string[],
550
566
  ): ResolvedSandboxConfig {
551
567
  const home = toRealPath(workspaceRoot)
552
568
  for (const bind of bindableWrites(config.allowWrite)) {
553
569
  // A bind outside the workspace is the user's own path (`/tmp/x`,
554
570
  // `~/.cache/y`), spelled there on purpose; only one inside is judged.
555
571
  if (!atOrUnder(bind, home)) continue
556
- const wall = walls.find((w) => atOrUnder(w, bind))
572
+ const wall = walls.find((w) => atOrUnder(w, bind)) ?? sealed.find((w) => atOrUnder(bind, w))
557
573
  if (wall === undefined) continue
558
574
  throw new UserError(
559
575
  `exec.sandbox.allow.write: the grant binding ${bind} would make ${wall} writable — another ` +
@@ -674,8 +690,23 @@ export const CREDENTIAL_STORES = [
674
690
  '.codex/auth.json',
675
691
  ]
676
692
 
677
- /** The stores present on this host, learned once per home. */
678
- let presentStores: { home: string; paths: string[] } | undefined
693
+ /**
694
+ * Each store's path: under `home`, and again where `$XDG_CONFIG_HOME` or
695
+ * `$XDG_DATA_HOME` moves a `.config` or `.local/share` one. A task sees
696
+ * both, so a CLI reads its login there. A relative value is no XDG path.
697
+ */
698
+ export function credentialStorePaths(home: string): string[] {
699
+ const moved = [
700
+ ['.config/', process.env['XDG_CONFIG_HOME']],
701
+ ['.local/share/', process.env['XDG_DATA_HOME']],
702
+ ] as const
703
+ const out = new Set(CREDENTIAL_STORES.map((rel) => path.join(home, rel)))
704
+ for (const rel of CREDENTIAL_STORES)
705
+ for (const [prefix, dir] of moved)
706
+ if (dir !== undefined && path.isAbsolute(dir) && rel.startsWith(prefix))
707
+ out.add(path.join(dir, rel.slice(prefix.length)))
708
+ return [...out]
709
+ }
679
710
 
680
711
  /**
681
712
  * The credential stores to deny, less any that holds the workspace, and
@@ -689,19 +720,38 @@ async function credentialStores(
689
720
  workspaceRoot: string,
690
721
  writes: readonly string[],
691
722
  ): Promise<string[]> {
692
- const home = homedir()
693
- if (presentStores?.home !== home) {
694
- presentStores = {
695
- home,
696
- paths: CREDENTIAL_STORES.map((rel) => path.join(home, rel)).filter((p) => existsSync(p)),
697
- }
698
- }
699
- const stores = presentStores.paths.filter(
700
- (p) => workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
723
+ // Asked per task, never memoized: an unsandboxed task earlier in the run
724
+ // (`npm config set` writing a token) makes a store the first sandboxed
725
+ // task did not see, and a refusal is never decided from a memo.
726
+ const stores = credentialStorePaths(homedir()).filter(
727
+ (p) => existsSync(p) && workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
701
728
  )
702
729
  return (await Promise.all(stores.map((p) => denyAround(toRealPath(p), writes)))).flat()
703
730
  }
704
731
 
732
+ /**
733
+ * vx's own stores outside the workspace: `~/.vx`, which holds every
734
+ * repository's shared store (`resolveStoreRoot`), and a cache directory
735
+ * configured outside it. The rest of home stays readable, so without this
736
+ * a task with no grant read every project's cached outputs, around the
737
+ * cross-project wall and its own key. One inside the workspace is denied
738
+ * with it already.
739
+ */
740
+ async function vxStores(
741
+ workspaceRoot: string,
742
+ writes: readonly string[],
743
+ cacheDir: string | undefined,
744
+ ): Promise<string[]> {
745
+ const root = toRealPath(workspaceRoot)
746
+ // HOME first, as `resolveStoreRoot` reads it.
747
+ const home = process.env['HOME'] || homedir()
748
+ const stores = [path.join(home, '.vx'), ...(cacheDir === undefined ? [] : [cacheDir])]
749
+ .filter((p) => existsSync(p))
750
+ .map(toRealPath)
751
+ .filter((p) => !atOrUnder(root, p) && !atOrUnder(p, root))
752
+ return (await Promise.all(stores.map((p) => denyAround(p, writes)))).flat()
753
+ }
754
+
705
755
  /** `dir` denied, or its entries no write grant lies under or at when one is inside it. */
706
756
  async function denyAround(dir: string, writes: readonly string[]): Promise<string[]> {
707
757
  const inside = writes.filter((w) => atOrUnder(w, dir))
@@ -22,7 +22,7 @@ export function shellVerdict(args: {
22
22
  cwd: string
23
23
  bins: string[]
24
24
  /** The signal that killed the child when the runner saw one (`RunResult.signal`). */
25
- signal?: string | undefined
25
+ signal?: string | number | undefined
26
26
  /** A sandboxed task's view: whether a file is hidden from it (no grant reads it). */
27
27
  hidden?: ((file: string) => boolean) | undefined
28
28
  }): string | undefined {
@@ -84,7 +84,10 @@ function fileVerdict(file: string): string {
84
84
  }
85
85
 
86
86
  /** The signal behind an exit above 128, from the runner's own report or the code alone. */
87
- function signalVerdict(code: number, signal: string | undefined): string | undefined {
87
+ function signalVerdict(code: number, signal: string | number | undefined): string | undefined {
88
+ if (typeof signal === 'number') {
89
+ return `[vx] exit ${code} is how the shell reports a death by signal ${signal}: the process was killed by that signal`
90
+ }
88
91
  const signals = osConstants.signals as Partial<Record<string, number>>
89
92
  let name: string | undefined
90
93
  let num: number | undefined
@@ -96,9 +99,6 @@ function signalVerdict(code: number, signal: string | undefined): string | undef
96
99
  num = code - 128
97
100
  }
98
101
  if (name === undefined || num === undefined) return undefined
99
- // A SIGINT/SIGTERM the runner saw is a shutdown or an outside stop, and
100
- // the orchestrator reverts the task to aborted — that path has its own line.
101
- if (signal !== undefined && (signal === 'SIGINT' || signal === 'SIGTERM')) return undefined
102
102
  const why = SIGNAL_WHY[name] ?? 'the process was killed by that signal'
103
103
  const how =
104
104
  signal !== undefined
@@ -15,7 +15,13 @@
15
15
 
16
16
  import { signalExitCode } from '../exec/index.js'
17
17
  import { claimExitForSignal, hangupIgnored, killGraceMs, settleWithin } from '../util/index.js'
18
- import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
18
+ import {
19
+ holdGroups,
20
+ killTree,
21
+ pauseTree,
22
+ restoreTerminalModes,
23
+ untilGroupsGone,
24
+ } from '../exec/index.js'
19
25
  import type { Logger } from '../report/index.js'
20
26
 
21
27
  /**
@@ -135,6 +141,9 @@ export function forwardSignals(args: {
135
141
  // teardown must not throw on the way out
136
142
  }
137
143
  for (const child of everyChild()) killTree(child, 'SIGKILL')
144
+ // The runner restores a task's terminal modes when the task exits,
145
+ // which this exit does not wait for.
146
+ restoreTerminalModes()
138
147
  try {
139
148
  args.cache.close()
140
149
  } catch {
@@ -177,17 +186,33 @@ export function forwardSignals(args: {
177
186
  const onSigint = (): void => onSignal('SIGINT')
178
187
  const onSigterm = (): void => onSignal('SIGTERM')
179
188
  const onSighup = (): void => onSignal('SIGHUP')
189
+ // Ctrl-Z stops the terminal's foreground group, which is vx's alone: a
190
+ // task runs in its own session. vx stopped and its tasks ran on, an
191
+ // interactive one still reading the terminal the shell had taken back.
192
+ // So vx stops each task's group, then itself; `fg` or `bg` continues vx,
193
+ // and vx continues them.
194
+ const onSigtstp = (): void => {
195
+ for (const child of everyChild()) pauseTree(child, 'SIGSTOP')
196
+ process.kill(process.pid, 'SIGSTOP')
197
+ }
198
+ const onSigcont = (): void => {
199
+ for (const child of everyChild()) pauseTree(child, 'SIGCONT')
200
+ }
180
201
  if (args.enabled) {
181
202
  process.on('SIGINT', onSigint)
182
203
  process.on('SIGTERM', onSigterm)
183
204
  // A hang-up vx was started deaf to (nohup) stays ignored.
184
205
  if (!hangupIgnored()) process.on('SIGHUP', onSighup)
206
+ process.on('SIGTSTP', onSigtstp)
207
+ process.on('SIGCONT', onSigcont)
185
208
  }
186
209
  return {
187
210
  remove: () => {
188
211
  process.off('SIGINT', onSigint)
189
212
  process.off('SIGTERM', onSigterm)
190
213
  process.off('SIGHUP', onSighup)
214
+ process.off('SIGTSTP', onSigtstp)
215
+ process.off('SIGCONT', onSigcont)
191
216
  },
192
217
  }
193
218
  }
@@ -57,6 +57,8 @@ export interface DeriveStableKeysArgs {
57
57
  * execute-task reuses the key when the live upstream matches.
58
58
  */
59
59
  groupKeys?: Map<string, UpfrontGroupKey>
60
+ /** Filled with every key the walk makes, in the order it makes them (`RunDerivation`). */
61
+ keys?: Map<string, string>
60
62
  }
61
63
 
62
64
  export interface StableKey {
@@ -79,7 +81,7 @@ export interface StableKey {
79
81
  export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<StableKey[]> {
80
82
  const order = topoOrder(args.nodes)
81
83
  const late = probesAfterWrites(args.nodes, args.workspaceRoot)
82
- const keyById = new Map<string, string>()
84
+ const keyById = args.keys ?? new Map<string, string>()
83
85
  const unstableById = new Set<string>()
84
86
  // Transitive-upstream output producers, accumulated in topo order — the
85
87
  // inputs to the stability gate. A task's key is preliminary if an upstream
@@ -338,6 +340,90 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
338
340
  return stableKeys
339
341
  }
340
342
 
343
+ /**
344
+ * The keys `deriveStableKeys` would make, from a derivation under the same
345
+ * graph, configs and ignored outputs (`readRunDelta`): every task's key
346
+ * is the one remembered unless it is `dirty`, and a dirty task's is derived
347
+ * as the walk derives it, over its deps' keys, in the walk's order. Which
348
+ * tasks are stable and which uncached keys are kept are the remembered
349
+ * verdicts: they read only what the global token names. `keys` is updated
350
+ * in place.
351
+ */
352
+ export async function rederiveKeys(
353
+ args: Omit<DeriveStableKeysArgs, 'keys' | 'groupKeys'>,
354
+ keys: Map<string, string>,
355
+ dirty: ReadonlySet<string>,
356
+ ): Promise<void> {
357
+ for (const [id] of keys) {
358
+ if (!dirty.has(id)) continue
359
+ const node = args.nodes.get(id)!
360
+ const upstream = synthUpstream(node, args.nodes, keys)
361
+ keys.set(
362
+ id,
363
+ isGroupTask(node) && node.config.inputs === undefined
364
+ ? computeGroupHash(upstream)
365
+ : await computeTaskHash({
366
+ node,
367
+ upstream,
368
+ workspaceRoot: args.workspaceRoot,
369
+ cache: args.cache,
370
+ forwardArgs: args.forwardArgs,
371
+ nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
372
+ gitFilesCache: args.gitFilesCache,
373
+ hashCache: args.hashCache,
374
+ }),
375
+ )
376
+ }
377
+ }
378
+
379
+ /**
380
+ * A project's positive outputs indexed by literal head, so a reader finds
381
+ * the ones its inputs reach (`workspaceInputsReach`: a head equal to, above
382
+ * or below an input's) without testing each: one project of 9000 tasks
383
+ * tested 81 million pairs, 6 s of a plan (2026-10-25).
384
+ */
385
+ class ProjectOutputs {
386
+ private readonly globs: string[] = []
387
+ private readonly byHead = new Map<string, number[]>()
388
+ private sorted: string[] | undefined
389
+
390
+ add(glob: string, head: string): void {
391
+ const key = head === '.' ? '' : head
392
+ let at = this.byHead.get(key)
393
+ if (at === undefined) this.byHead.set(key, (at = []))
394
+ at.push(this.globs.length)
395
+ this.globs.push(glob)
396
+ this.sorted = undefined
397
+ }
398
+
399
+ /** The globs `inputs` reach, in the order they were added. */
400
+ reachedBy(inputs: readonly string[]): string[] {
401
+ const hit = new Set<number>()
402
+ const take = (head: string) => {
403
+ for (const i of this.byHead.get(head) ?? []) hit.add(i)
404
+ }
405
+ for (const raw of inputs) {
406
+ if (raw.startsWith('!')) continue
407
+ take('')
408
+ const p = literalPrefix(raw)
409
+ if (p === '') return [...this.globs]
410
+ for (let at = p.indexOf('/'); at !== -1; at = p.indexOf('/', at + 1)) take(p.slice(0, at))
411
+ take(p)
412
+ const sorted = (this.sorted ??= [...this.byHead.keys()].sort())
413
+ const from = `${p}/`
414
+ let lo = 0
415
+ let hi = sorted.length
416
+ while (lo < hi) {
417
+ const mid = (lo + hi) >> 1
418
+ if (sorted[mid]! < from) lo = mid + 1
419
+ else hi = mid
420
+ }
421
+ for (let i = lo; i < sorted.length && sorted[i]!.startsWith(from); i++) take(sorted[i]!)
422
+ }
423
+ return [...hit].sort((a, b) => a - b).map((i) => this.globs[i]!)
424
+ }
425
+ }
426
+
341
427
  /**
342
428
  * The same-project outputs a reader's inputs reach, root-relative, each with
343
429
  * its project dir and project-relative glob: the outputs the gate would key
@@ -350,14 +436,13 @@ export function gatedOutputs(
350
436
  const out = new Map<string, { projectDir: string; glob: string }>()
351
437
  // Each project's positive outputs with their literal heads, read once:
352
438
  // 1,000 projects' tasks spent 5-8 ms here re-splitting the same globs.
353
- const outputsOf = new Map<string, { glob: string; head: string }[]>()
439
+ const outputsOf = new Map<string, ProjectOutputs>()
354
440
  for (const n of nodes.values()) {
355
441
  const files = n.config.cache?.outputs.files
356
442
  if (files === undefined || files.length === 0) continue
357
- let list = outputsOf.get(n.projectName)
358
- if (list === undefined) outputsOf.set(n.projectName, (list = []))
359
- for (const glob of files)
360
- if (!glob.startsWith('!')) list.push({ glob, head: literalPrefix(glob) })
443
+ let po = outputsOf.get(n.projectName)
444
+ if (po === undefined) outputsOf.set(n.projectName, (po = new ProjectOutputs()))
445
+ for (const glob of files) if (!glob.startsWith('!')) po.add(glob, literalPrefix(glob))
361
446
  }
362
447
  if (outputsOf.size === 0) return out
363
448
  for (const n of nodes.values()) {
@@ -366,8 +451,7 @@ export function gatedOutputs(
366
451
  if (inputs === undefined || outputs === undefined || isGroupTask(n)) continue
367
452
  let back: string[] | undefined
368
453
  let rel: string | undefined
369
- for (const { glob: o, head } of outputs) {
370
- if (!workspaceInputsReach(inputs, [head])) continue
454
+ for (const o of outputs.reachedBy(inputs)) {
371
455
  rel ??= relPosix(workspaceRoot, n.projectDir)
372
456
  back ??= inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
373
457
  const rooted = rel === '' ? o : `${rel}/${o}`
@@ -1,11 +1,11 @@
1
1
  // vx's own steps (reading the lock, keying, uploads, teardown) rendered as
2
- // tasks (M, 2026-10-15): `vx:<step>`, and `<plugin package>:setup` /
2
+ // tasks (2026-10-15): `vx:<step>`, and `<plugin package>:setup` /
3
3
  // `:teardown`. `:` is no task id's separator, so a step never collides with
4
4
  // a project named `vx` or a plugin package's own `#setup` task. A step is
5
5
  // not a task: no stat, history row or telemetry record counts it.
6
6
  //
7
7
  // A run shows only the steps it waits on, before its first task or its end
8
- // (M, 2026-10-16). One that runs beside the tasks (`vx:prefetch`) is a
8
+ // (2026-10-16). One that runs beside the tasks (`vx:prefetch`) is a
9
9
  // `background` step: never shown, and kept for `vx last` (run-steps.ts).
10
10
  import type { Logger } from '../report/index.js'
11
11
  import type { RunStep } from './run-steps.js'
@@ -8,10 +8,12 @@ import {
8
8
  type CacheLayer,
9
9
  type ResolvedRemote,
10
10
  resolveOutputTree,
11
- resolveWorkspaceOutputs,
11
+ resolveWorkspaceOutputTree,
12
+ sayRemoteFault,
12
13
  } from '../cache/index.js'
13
- import { secretMask } from '../util/index.js'
14
- import { decodeOutputLog, encodeOutputLog } from './output-log.js'
14
+ import type { CapturedChunk } from '../exec/index.js'
15
+ import { secretMask, thrownMessage } from '../util/index.js'
16
+ import { decodeOutputLog, encodeOutputLog, maskedOutputLog } from './output-log.js'
15
17
 
16
18
  /** The task a `TaskArtifacts` call names: where its outputs are and what ran. */
17
19
  export interface ArtifactTask {
@@ -27,17 +29,26 @@ export interface ArtifactTask {
27
29
  }
28
30
  /** The projects inside `projectDir` (absolute), whose files are not its outputs. */
29
31
  readonly nestedProjectDirs?: readonly string[]
32
+ /**
33
+ * Files under its outputs it found and left as they were (absolute): an
34
+ * upstream's, which an additive task builds on, not its entry's.
35
+ */
36
+ readonly others?: ReadonlySet<string>
30
37
  }
31
38
 
32
39
  /** What a task's run left besides its files: its output, and its time and usage. */
33
40
  export interface ArtifactRun {
34
- readonly stdout: string
35
- readonly stderr: string
41
+ /** Both streams in the order they were written, as a `BoundedCapture` keeps them. */
42
+ readonly output: readonly CapturedChunk[]
36
43
  readonly durationMs: number
37
44
  readonly cpuMs?: number
38
45
  readonly peakRssBytes?: number
39
46
  /** Its environment: a secret value in it is masked out of the stored output. */
40
47
  readonly env?: Readonly<Record<string, string | undefined>>
48
+ /** Its `exec.env.secret`: names in `env` whose values are masked whatever the name. */
49
+ readonly secret?: readonly string[]
50
+ /** No remote held the key when the run asked: its upload asks none first. */
51
+ readonly absent?: boolean
41
52
  }
42
53
 
43
54
  export interface TaskArtifacts {
@@ -50,41 +61,66 @@ export interface TaskArtifacts {
50
61
  save(key: string, task: ArtifactTask, run: ArtifactRun): Promise<void>
51
62
  /**
52
63
  * Lays entry `key`'s files into the workspace, from the local store or
53
- * else the first remote holding it, and resolves its output; null when
64
+ * else the first remote holding it, and resolves its output, both streams
65
+ * in the order written; null when
54
66
  * none holds it. A key restored here and then saved is not uploaded
55
67
  * again.
56
68
  */
57
- restore(key: string, task: ArtifactTask): Promise<{ stdout: string; stderr: string } | null>
69
+ restore(key: string, task: ArtifactTask): Promise<CapturedChunk[] | null>
70
+ /**
71
+ * Entry `key`'s output alone, laying no file; null when no cache holds
72
+ * it. An `exec.remote: 'only'` task's files stay off this disk.
73
+ */
74
+ output(key: string): Promise<CapturedChunk[] | null>
75
+ /**
76
+ * True once a remote the run reads holds entry `key`, this machine's
77
+ * entry uploaded to those it writes when none does: another machine of
78
+ * the run reads the remotes alone. False when none holds it and none took it.
79
+ */
80
+ share(key: string): Promise<boolean>
81
+ /**
82
+ * This machine's entry `key` uploaded over each written remote's copy,
83
+ * which another machine of the run found corrupt. True when one took it.
84
+ */
85
+ resend(key: string): Promise<boolean>
86
+ /**
87
+ * Whether a remote the workspace writes took entry `key` when `save`
88
+ * uploaded it; undefined when it writes none. Another machine of the
89
+ * run reads the remotes alone, so false there means only this one has it.
90
+ */
91
+ uploaded?(key: string): Promise<boolean | undefined>
58
92
  }
59
93
 
60
94
  export function taskArtifacts(cache: CacheLayer, workspaceRoot: string): TaskArtifacts {
61
95
  return {
62
96
  async save(key, task, run) {
63
- const [tree, wsFiles] = await Promise.all([
97
+ const [tree, wsTree] = await Promise.all([
64
98
  resolveOutputTree({
65
99
  projectDir: task.projectDir,
66
100
  outputs: task.outputs.files,
67
101
  nestedProjectDirs: [...(task.nestedProjectDirs ?? [])],
68
102
  }),
69
- resolveWorkspaceOutputs({ workspaceRoot, outputs: task.outputs.workspaceFiles }),
70
- ])
71
- const mask = secretMask([process.env, run.env ?? {}])
72
- const log = encodeOutputLog([
73
- { text: run.stdout, err: false },
74
- { text: run.stderr, err: true },
103
+ resolveWorkspaceOutputTree({ workspaceRoot, outputs: task.outputs.workspaceFiles }),
75
104
  ])
105
+ const mine = (f: string) => task.others?.has(f) !== true
106
+ const files = tree.files.filter(mine)
107
+ const ws = wsTree.files.filter(mine)
108
+ const mask = secretMask([process.env, run.env ?? {}], run.secret)
109
+ const log = mask === null ? encodeOutputLog(run.output) : maskedOutputLog(run.output, mask)
76
110
  await cache.save({
77
111
  hash: key,
78
112
  projectDir: task.projectDir,
79
- outputFiles: tree.files,
113
+ outputFiles: files,
80
114
  ...(tree.emptyDirs.length > 0 ? { outputDirs: tree.emptyDirs } : {}),
81
115
  workspaceRoot,
82
- ...(wsFiles.length > 0 ? { workspaceOutputFiles: wsFiles } : {}),
116
+ ...(ws.length > 0 ? { workspaceOutputFiles: ws } : {}),
117
+ ...(wsTree.emptyDirs.length > 0 ? { workspaceOutputDirs: wsTree.emptyDirs } : {}),
118
+ ...(run.absent === true ? { absent: true } : {}),
83
119
  entry: {
84
120
  taskId: task.id,
85
121
  command: mask?.mask(task.command) ?? task.command,
86
122
  durationMs: run.durationMs,
87
- stdout: mask?.mask(log) ?? log,
123
+ stdout: log,
88
124
  ...(run.cpuMs !== undefined ? { cpuMs: run.cpuMs } : {}),
89
125
  ...(run.peakRssBytes !== undefined ? { peakRssBytes: run.peakRssBytes } : {}),
90
126
  },
@@ -96,14 +132,17 @@ export function taskArtifacts(cache: CacheLayer, workspaceRoot: string): TaskArt
96
132
  const entry = await (cache.getSaved ? cache.getSaved(key) : cache.get(key))
97
133
  if (entry === null) return null
98
134
  await cache.restoreOutputs(key, task.projectDir, workspaceRoot, task.outputs)
99
- let stdout = ''
100
- let stderr = ''
101
- for (const c of decodeOutputLog(entry.stdout)) {
102
- if (c.err) stderr += c.text
103
- else stdout += c.text
104
- }
105
- return { stdout, stderr }
135
+ return decodeOutputLog(entry.stdout)
106
136
  },
137
+
138
+ async output(key) {
139
+ const entry = await (cache.getSaved ? cache.getSaved(key) : cache.get(key))
140
+ return entry === null ? null : decodeOutputLog(entry.stdout)
141
+ },
142
+
143
+ share: (key) => cache.share?.(key) ?? Promise.resolve(false),
144
+ resend: (key) => cache.resend?.(key) ?? Promise.resolve(false),
145
+ uploaded: (key) => cache.uploaded?.(key) ?? Promise.resolve(undefined),
107
146
  }
108
147
  }
109
148
 
@@ -122,7 +161,7 @@ export interface CacheFacts {
122
161
  readonly expiresAt?: number
123
162
  }
124
163
 
125
- /** The run's `CacheFacts`: each readable remote's `lend`, asked now; one that throws lends nothing. */
164
+ /** The run's `CacheFacts`: each readable remote's `lend`, asked now; one that throws lends nothing, said. */
126
165
  export async function cacheFacts(
127
166
  remotes: readonly ResolvedRemote[],
128
167
  refs: readonly string[],
@@ -132,7 +171,32 @@ export async function cacheFacts(
132
171
  await Promise.all(
133
172
  remotes.map(async (r) => {
134
173
  if (r.plugin === undefined || r.remote.lend === undefined || !r.read) return
135
- const got = await r.remote.lend().catch(() => undefined)
174
+ // Through a promise, and checked: a plugin's synchronous throw skipped
175
+ // the catch and rejected every remote's answer, and a `null` or a
176
+ // numeric token met `.token` as a TypeError.
177
+ const got = await Promise.resolve()
178
+ .then(() => r.remote.lend!())
179
+ .then((answer: unknown) => {
180
+ if (answer === undefined) return undefined
181
+ if (typeof answer !== 'object' || answer === null)
182
+ throw new Error(
183
+ `it answered ${answer === null ? 'null' : typeof answer}, not { token, expiresAt? }`,
184
+ )
185
+ const { token, expiresAt: ends } = answer as { token?: unknown; expiresAt?: unknown }
186
+ if (typeof token !== 'string') throw new Error('it answered a token that is not a string')
187
+ if (ends !== undefined && !(typeof ends === 'number' && Number.isFinite(ends)))
188
+ throw new Error('it answered an expiresAt that is not a time in ms')
189
+ return { token, ...(ends === undefined ? {} : { expiresAt: ends }) }
190
+ })
191
+ .catch((err: unknown) => {
192
+ // Said: unsaid, the run's workers went without this remote and no line said why.
193
+ const message = thrownMessage(err)
194
+ sayRemoteFault(
195
+ r,
196
+ new Error(`lend of ${r.plugin}'s access failed: ${message}`, { cause: err }),
197
+ )
198
+ return undefined
199
+ })
136
200
  if (got === undefined) return
137
201
  lent[r.plugin] = got.token
138
202
  if (got.expiresAt !== undefined) expiresAt = Math.min(expiresAt ?? Infinity, got.expiresAt)