@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  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 +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  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 +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -4,10 +4,12 @@
4
4
 
5
5
  import { terminalSubscriber, type RunEventSubscriber } from '../report/index.js'
6
6
  import type { Logger } from '../report/index.js'
7
+ import { settleWithin, teardownTimeoutMs, thrownMessage } from '../util/index.js'
7
8
  import type { OutputContext, VxPlugin } from './plugin.js'
8
9
 
9
10
  const REQUIRED = ['status', 'taskStdout', 'taskStderr', 'taskComplete'] as const
10
11
  const OPTIONAL = [
12
+ 'warn',
11
13
  'runStart',
12
14
  'taskStart',
13
15
  'runEnd',
@@ -26,14 +28,17 @@ function checkLogger(value: unknown): Logger {
26
28
  const missing = REQUIRED.filter((k) => typeof o[k] !== 'function')
27
29
  if (missing.length > 0) throw new Error(`output Logger has no ${missing.join(', ')}`)
28
30
  const wrong = OPTIONAL.filter((k) => o[k] !== undefined && typeof o[k] !== 'function')
29
- if (wrong.length > 0) throw new Error(`output Logger's ${wrong.join(', ')} must be functions`)
31
+ if (wrong.length > 0)
32
+ throw new Error(
33
+ `output Logger's ${wrong.join(', ')} must be ${wrong.length === 1 ? 'a function' : 'functions'}`,
34
+ )
30
35
  return value as Logger
31
36
  }
32
37
 
33
38
  /**
34
39
  * The first plugin, in declaration order, whose `output` returns a Logger.
35
- * A hook that throws or returns something else is warned about and passed
36
- * over: a renderer must never fail a run.
40
+ * A hook that throws, returns something else or does not answer in time
41
+ * is warned about and passed over: a renderer must never fail a run.
37
42
  */
38
43
  export async function claimOutput(
39
44
  plugins: readonly VxPlugin[],
@@ -42,12 +47,20 @@ export async function claimOutput(
42
47
  for (const plugin of plugins) {
43
48
  if (plugin.output === undefined) continue
44
49
  try {
45
- const logger = await plugin.output(ctx)
50
+ // Bounded as telemetry's hook is: one that never settled held the
51
+ // run before its first task until the loop drained, exit 1 (item 921).
52
+ let logger: Logger | undefined
53
+ const ms = teardownTimeoutMs()
54
+ const arrived = await settleWithin(
55
+ Promise.resolve(plugin.output(ctx)).then((l) => {
56
+ logger = l
57
+ }),
58
+ ms,
59
+ )
60
+ if (!arrived) throw new Error(`did not answer within ${ms} ms`)
46
61
  if (logger !== undefined) return { logger: checkLogger(logger), owner: plugin.name }
47
62
  } catch (err) {
48
- ctx.warn(
49
- `[vx] plugin '${plugin.name}' output: ${err instanceof Error ? err.message : String(err)}; vx renders the run`,
50
- )
63
+ ctx.warn(`[vx] plugin '${plugin.name}' output: ${thrownMessage(err)}; vx renders the run`)
51
64
  }
52
65
  }
53
66
  return undefined
@@ -62,19 +75,35 @@ export function guardedOutput(
62
75
  claimed: { logger: Logger; owner: string },
63
76
  fallback: RunEventSubscriber,
64
77
  ): RunEventSubscriber {
65
- const render = terminalSubscriber(claimed.logger)
66
78
  let failed = false
79
+ const fail = (err: unknown): void => {
80
+ if (failed) return
81
+ failed = true
82
+ fallback({
83
+ kind: 'run:status',
84
+ warn: true,
85
+ line: `[vx] plugin '${claimed.owner}' output failed: ${thrownMessage(err)}; vx renders the rest of the run`,
86
+ })
87
+ }
88
+ // An `async` method's rejection reached no one: an unhandled rejection
89
+ // ended the run with a stack, exit 1, after its tasks had passed. It
90
+ // fails the Logger as a throw does; the event it was handed stays its.
91
+ const logger = Object.create(claimed.logger) as Logger
92
+ for (const k of [...REQUIRED, ...OPTIONAL, 'warn'] as const) {
93
+ const method = claimed.logger[k] as ((...args: unknown[]) => unknown) | undefined
94
+ if (typeof method !== 'function') continue
95
+ ;(logger as unknown as Record<string, unknown>)[k] = (...args: unknown[]): void => {
96
+ const ret = method.apply(claimed.logger, args)
97
+ if (ret instanceof Promise) ret.catch(fail)
98
+ }
99
+ }
100
+ const render = terminalSubscriber(logger)
67
101
  return (event) => {
68
102
  if (failed) return fallback(event)
69
103
  try {
70
104
  render(event)
71
105
  } catch (err) {
72
- failed = true
73
- fallback({
74
- kind: 'run:status',
75
- warn: true,
76
- line: `[vx] plugin '${claimed.owner}' output failed: ${err instanceof Error ? err.message : String(err)}; vx renders the rest of the run`,
77
- })
106
+ fail(err)
78
107
  fallback(event)
79
108
  }
80
109
  }
@@ -4,16 +4,19 @@
4
4
  // RS (U+001E) + `e` / `o` where the stream switches to stderr / stdout, and
5
5
  // a literal RS doubled. Output that never touched stderr is its own text.
6
6
 
7
- import { BoundedCapture, type CapturedChunk } from '../exec/index.js'
7
+ import { type BoundedCapture, type CapturedChunk, isDroppedOutputChunk } from '../exec/index.js'
8
+ import { MASKED, type SecretMask } from '../util/index.js'
8
9
 
9
10
  const RS = '\x1e'
10
11
 
11
- /** `chunks` as one stored string; it starts on stdout. */
12
- export function encodeOutputLog(chunks: readonly CapturedChunk[]): string {
12
+ /**
13
+ * `chunks` as one stored string; it starts on stdout. `mask` (the task's
14
+ * secrets) is applied to each stream's whole text (`maskStreams`).
15
+ */
16
+ export function encodeOutputLog(chunks: readonly CapturedChunk[], mask?: SecretMask): string {
13
17
  let out = ''
14
18
  let err = false
15
- for (const c of chunks) {
16
- if (c.text.length === 0) continue
19
+ for (const c of maskStreams(chunks, mask)) {
17
20
  if (c.err !== err) {
18
21
  out += RS + (c.err ? 'e' : 'o')
19
22
  err = c.err
@@ -23,6 +26,76 @@ export function encodeOutputLog(chunks: readonly CapturedChunk[]): string {
23
26
  return out
24
27
  }
25
28
 
29
+ /**
30
+ * `encodeOutputLog` with `mask` applied, for a capture of raw output: a
31
+ * value the capture's dropped middle cut left a piece either side, which
32
+ * only `maskCut` sees, as `maskCaptured` does for core's own capture.
33
+ */
34
+ export function maskedOutputLog(chunks: readonly CapturedChunk[], mask: SecretMask): string {
35
+ const at = chunks.findIndex(isDroppedOutputChunk)
36
+ if (at === -1) return encodeOutputLog(chunks, mask)
37
+ const before = chunks.slice(0, at)
38
+ const tail = encodeOutputLog(chunks.slice(at + 1))
39
+ // The dropped line is on stdout, where the tail's own log starts.
40
+ const lead = tail.startsWith(RS + 'e') ? RS + 'e' : ''
41
+ const [h, t] = mask.maskCut(encodeOutputLog(before), tail.slice(lead.length))
42
+ const back = before.findLast((c) => c.text.length > 0)?.err === true ? RS + 'o' : ''
43
+ return h + back + chunks[at]!.text + lead + t
44
+ }
45
+
46
+ /**
47
+ * Each stream's run of adjacent non-empty chunks joined, with `mask`
48
+ * applied to each stream's text as a whole: a value is masked where it
49
+ * starts and its rest dropped from the runs it reaches. The live stream
50
+ * masks chunk by chunk and lets a held piece go after a pause, so a value
51
+ * written in two pieces reached the capture in two chunks, and a write to
52
+ * the other stream between them made them two runs. A hit replays each
53
+ * stream to its own descriptor, so `vx run > log` joined them again.
54
+ */
55
+ export function maskStreams(
56
+ chunks: readonly CapturedChunk[],
57
+ mask: SecretMask | undefined,
58
+ ): CapturedChunk[] {
59
+ if (mask === undefined) return chunks.filter((c) => c.text.length > 0)
60
+ const runs: CapturedChunk[] = []
61
+ for (const c of chunks) {
62
+ if (c.text.length === 0) continue
63
+ const last = runs.at(-1)
64
+ if (last !== undefined && last.err === c.err) last.text += c.text
65
+ else runs.push({ text: c.text, err: c.err })
66
+ }
67
+ for (const err of [false, true]) {
68
+ const own = runs.filter((r) => r.err === err)
69
+ const spans = mask.spans(own.map((r) => r.text).join(''))
70
+ if (spans.length === 0) continue
71
+ let at = 0
72
+ let i = 0
73
+ for (const r of own) {
74
+ const end = at + r.text.length
75
+ let text = ''
76
+ let from = at
77
+ for (; i < spans.length && spans[i]![0] < end; i++) {
78
+ const [s, e] = spans[i]!
79
+ if (s >= from) text += r.text.slice(from - at, s - at) + MASKED
80
+ from = Math.max(from, Math.min(e, end))
81
+ // A value that runs past this run is cut from the next one too.
82
+ if (e > end) break
83
+ }
84
+ r.text = text + r.text.slice(from - at)
85
+ at = end
86
+ }
87
+ }
88
+ // A run emptied whole leaves its neighbours, the other stream, adjacent.
89
+ const kept: CapturedChunk[] = []
90
+ for (const r of runs) {
91
+ if (r.text.length === 0) continue
92
+ const last = kept.at(-1)
93
+ if (last !== undefined && last.err === r.err) last.text += r.text
94
+ else kept.push(r)
95
+ }
96
+ return kept
97
+ }
98
+
26
99
  /** The chunks `encodeOutputLog` wrote, adjacent same-stream text joined. */
27
100
  export function decodeOutputLog(log: string): CapturedChunk[] {
28
101
  if (!log.includes(RS)) return log.length === 0 ? [] : [{ text: log, err: false }]
@@ -58,9 +131,16 @@ export const FAILED_OUTPUT_HEAD_CHARS = 8 * 1024
58
131
  export const FAILED_OUTPUT_TAIL_CHARS = 56 * 1024
59
132
 
60
133
  /** A failed attempt's capture, re-bounded to the failure's limits when it is the entry's. */
61
- export function failedOutputLog(capture: BoundedCapture, entrySized: boolean): string {
62
- if (!entrySized) return encodeOutputLog(capture.chunks())
63
- const small = new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
64
- for (const c of capture.chunks()) small.push(c.text, c.err)
65
- return encodeOutputLog(small.chunks())
134
+ export function failedOutputLog(
135
+ capture: BoundedCapture,
136
+ entrySized: boolean,
137
+ mask?: SecretMask,
138
+ ): string {
139
+ if (!entrySized) return encodeOutputLog(capture.chunks(), mask)
140
+ // Masked before the re-bound: its cut must not split a value first.
141
+ return encodeOutputLog(
142
+ capture
143
+ .rebound(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS, (c) => maskStreams(c, mask))
144
+ .chunks(),
145
+ )
66
146
  }
@@ -4,15 +4,17 @@
4
4
 
5
5
  import type { ExecConfig } from '../config.js'
6
6
  import {
7
+ lineEnds,
7
8
  PersistentReadyError,
8
9
  releaseBridges,
9
10
  runPersistent,
11
+ sandboxReads,
10
12
  signalExitCode,
11
13
  withForwardArgs,
12
14
  wrapSandboxedCommand,
13
15
  } from '../exec/index.js'
14
16
  import type { TaskOutcome } from '../graph/index.js'
15
- import { maskedEmitter, secretMask, testTimerMs } from '../util/index.js'
17
+ import { maskedStreams, secretMask, testTimerMs } from '../util/index.js'
16
18
  import { warnOf } from '../report/index.js'
17
19
  import type { ExecuteArgs } from './execute-task.js'
18
20
  import {
@@ -25,13 +27,43 @@ import {
25
27
  undeclaredWriteReach,
26
28
  untouchedPlaceholderLine,
27
29
  } from './sandbox-request.js'
30
+ import { shellVerdict } from './shell-verdict.js'
28
31
  import { forwardedSignal, terminateChildren } from './signals.js'
29
32
  import { computeTaskHash } from './task-hash.js'
30
- import { forgetUndeclaredWrites, isAborted, taskEnv, taskTimeoutMs } from './task-process.js'
33
+ import {
34
+ forgetUndeclaredWrites,
35
+ isAborted,
36
+ taskBinDirs,
37
+ taskEnv,
38
+ taskTimeoutMs,
39
+ } from './task-process.js'
31
40
 
32
41
  /** How long a server may take to match `readyWhen` before vx says it waits. */
33
42
  const READY_NOTICE_MS = 10_000
34
43
 
44
+ /**
45
+ * A server is keyed on its inputs like any task, so a cached dependant
46
+ * behind a dev server re-runs when the server's sources change; with none
47
+ * it has no key and the dependant always runs (A-17). A server the watch
48
+ * loop keeps up is keyed too: its dependant folds the key either way.
49
+ */
50
+ export function serverKey(args: ExecuteArgs): Promise<string | undefined> | string | undefined {
51
+ if (args.noDependants === true) return undefined
52
+ return (
53
+ args.upfrontKey ??
54
+ computeTaskHash({
55
+ node: args.node,
56
+ upstream: args.upstream,
57
+ workspaceRoot: args.workspaceRoot,
58
+ cache: args.cache,
59
+ forwardArgs: args.forwardArgs,
60
+ nestedProjectDirs: args.nestedProjectDirs,
61
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
62
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
63
+ })
64
+ )
65
+ }
66
+
35
67
  /**
36
68
  * Persistent task: dev server / file watcher / daemon. Spawn, wait
37
69
  * for ready (regex match or immediate), stash the subprocess in the
@@ -55,23 +87,22 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
55
87
  const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
56
88
  const env = taskEnv(node, step, args.workspaceRoot)
57
89
  const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
58
- // Keyed on its inputs like any task, so a cached dependant behind a dev
59
- // server re-runs when the server's sources change; with none it has no
60
- // key and the dependant always runs (A-17).
61
- const hash =
62
- args.noDependants === true
63
- ? undefined
64
- : (args.upfrontKey ??
65
- (await computeTaskHash({
66
- node,
67
- upstream: args.upstream,
68
- workspaceRoot: args.workspaceRoot,
69
- cache: args.cache,
70
- forwardArgs: args.forwardArgs,
71
- nestedProjectDirs: args.nestedProjectDirs,
72
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
73
- ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
74
- })))
90
+ const hash = await serverKey(args)
91
+
92
+ // Every dependant starts it where it runs (`ServerSpec`): nothing to spawn
93
+ // here, and nothing that ran (2026-10-22: nothing runs on a
94
+ // distributed run's orchestrator).
95
+ if (args.carried === true)
96
+ return {
97
+ node,
98
+ status: 'success',
99
+ exitCode: 0,
100
+ durationMs: 0,
101
+ carried: true,
102
+ ...(hash === undefined ? {} : { hash }),
103
+ wallclockStartNs,
104
+ wallclockEndNs: wallclockStartNs,
105
+ }
75
106
 
76
107
  // The args after `--` reach a server as they reach any task. A readyWhen
77
108
  // server once got none — "so the matcher sees the unmodified output",
@@ -92,6 +123,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
92
123
  // server that dies before readiness is told the directory spelling — the
93
124
  // same trap and the same answer as a one-shot task's (sandbox-request.ts).
94
125
  let placeholders: Placeholder[] = []
126
+ let serverSandbox: Awaited<ReturnType<typeof sandboxRequestFor>>['sandbox'] | undefined
95
127
  if (step.sandbox !== undefined) {
96
128
  await args.armSandbox?.()
97
129
  // A persistent task never declares `cache` (the loader refuses it), so
@@ -105,6 +137,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
105
137
  args.cacheDir,
106
138
  )
107
139
  placeholders = sb.placeholders
140
+ serverSandbox = sb.sandbox
108
141
  // A wrap that refuses (a port the host holds) leaves the server unspawned
109
142
  // and the placeholders vx made for it in the project for every later run.
110
143
  const wrapped = await wrapSandboxedCommand({
@@ -125,16 +158,21 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
125
158
  // is matched on the raw chunks, before this. Its held tail is flushed by
126
159
  // the idle timer: a server never ends on the way to a flush.
127
160
  const serverSecrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
128
- const serverOut = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStdout(node, t))
129
- const serverErr = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStderr(node, t))
130
161
  // A server writes until the run stops it: its window stays open.
131
162
  args.commandWindows?.open(node.id, node.projectDir, commandWriteReach(node, args.workspaceRoot))
163
+ // Whether the server's output ends mid-line, for vx's never-ready note.
164
+ const ends = lineEnds(
165
+ (t) => log.taskStdout(node, t),
166
+ (t) => log.taskStderr(node, t),
167
+ )
168
+ // Masked as one: a stream's held tail goes out before the other's next chunk.
169
+ const serverMasked = serverSecrets && maskedStreams(serverSecrets, ends.onStdout!, ends.onStderr!)
132
170
  const persistentOpts: Parameters<typeof runPersistent>[0] = {
133
171
  command,
134
172
  cwd: node.projectDir,
135
173
  env,
136
- onStdout: serverOut ? (chunk) => serverOut.push(chunk) : (chunk) => log.taskStdout(node, chunk),
137
- onStderr: serverErr ? (chunk) => serverErr.push(chunk) : (chunk) => log.taskStderr(node, chunk),
174
+ onStdout: serverMasked ? serverMasked.out : ends.onStdout!,
175
+ onStderr: serverMasked ? serverMasked.err : ends.onStderr!,
138
176
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
139
177
  ...(args.track !== undefined
140
178
  ? { onSpawn: (pid: number) => void args.track!(node.id, pid) }
@@ -221,8 +259,11 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
221
259
  // A shell that exited before ready can leave its group running
222
260
  // (`server & …; exit`): never registered, so no teardown reached it,
223
261
  // and it held its port against the next `vx watch` cycle's server.
224
- if (err instanceof PersistentReadyError && err.reason === 'exited')
225
- await terminateChildren(() => [spawn.child])
262
+ if (err instanceof PersistentReadyError && err.reason === 'exited' && spawn.child !== undefined)
263
+ await terminateChildren(() => [spawn.child!])
264
+ // What the masked streams hold back (a tail that could begin a secret)
265
+ // belongs above vx's own line: left to the idle flush, it came after it.
266
+ serverMasked?.end()
226
267
  // A server the run's stop killed while it started is aborted, as any
227
268
  // task the stop kills (item 962): it read `failed (never ready:
228
269
  // exited, exit 130)` with a recap after every Ctrl-C (C-62). The stop
@@ -243,7 +284,30 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
243
284
  // The task's OWN stream, not the process's: the frame is where a
244
285
  // reader looks for why a task failed, and a run with a custom logger
245
286
  // (an embedder) never saw a bare stderr write at all.
246
- log.taskStderr(node, `\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
287
+ log.taskStderr(
288
+ node,
289
+ ends.own(`[vx] ${node.id}: persistent task failed to become ready: ${message}\n`),
290
+ )
291
+ // A server's 127, 126 or signal death needs the line a one-shot task's
292
+ // gets (execute-task.ts): a dev server whose tool sits in a sibling's
293
+ // bin said only "exit 127".
294
+ if (
295
+ err instanceof PersistentReadyError &&
296
+ err.reason === 'exited' &&
297
+ err.exitCode !== undefined
298
+ ) {
299
+ const signal = spawn.child?.signalCode
300
+ const verdict = shellVerdict({
301
+ code: err.exitCode,
302
+ command: step.command,
303
+ cwd: node.projectDir,
304
+ bins: taskBinDirs(node, args.workspaceRoot),
305
+ signal: signal ?? undefined,
306
+ hidden: serverSandbox && ((f: string) => !sandboxReads(serverSandbox, f)),
307
+ })
308
+ if (verdict !== undefined)
309
+ log.taskStderr(node, `${serverSecrets?.mask(verdict) ?? verdict}\n`)
310
+ }
247
311
  // A server is never traced, so the sandbox's refusals reach no report:
248
312
  // a dev server that died on a file outside its grants read only as the
249
313
  // tool's own "not found" (2026-10-03). Said for a sandboxed server that
@@ -280,7 +344,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
280
344
  // A server that traps the TERM and exits 0 still timed out, as a
281
345
  // one-shot task that does so reports 143 (X-116).
282
346
  exitCode:
283
- ready?.reason === 'timeout'
347
+ ready?.reason === 'timeout' && spawn.child !== undefined
284
348
  ? (await spawn.child.exited) || signalExitCode('SIGTERM')
285
349
  : (ready?.exitCode ?? 1),
286
350
  durationMs: spawn.readyMs(),
@@ -304,16 +368,21 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
304
368
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
305
369
  }
306
370
  }
307
- args.persistentRegistry?.set(node.id, spawn.child)
371
+ // Ready, so it spawned: only a failed spawn has no child.
372
+ const child = spawn.child!
373
+ args.persistentRegistry?.set(node.id, child)
308
374
  forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
309
375
  if (mayWriteFingerprint(node, args.workspaceRoot, args.fingerprintWatch?.names ?? [])) {
310
376
  args.fingerprintWatch?.wrote()
311
377
  }
378
+ // Ready by its clean exit (a setup step): nothing is running any more.
379
+ const exited = child.exitCode
312
380
  return {
313
381
  node,
314
382
  status: 'success',
315
383
  exitCode: 0,
316
384
  durationMs: spawn.readyMs(),
385
+ ...(exited === null ? {} : { serverExit: exited }),
317
386
  ...(hash !== undefined ? { hash } : {}),
318
387
  wallclockStartNs,
319
388
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
@@ -176,9 +176,8 @@ export function sessionEnd(keepAlive: KeepAlive): Promise<{ code: number; i: num
176
176
 
177
177
  export interface HoldArgs {
178
178
  forwardArgs: readonly string[] | undefined
179
- /** Where a held server's output goes. */
179
+ /** Where a held server's output, and the notice of its death, go. */
180
180
  outOf: (id: string) => HeldServer['out']
181
- warn: (message: string) => void
182
181
  }
183
182
 
184
183
  /** The kept servers handed to the caller (`RunOptions.hold`) instead of waited on. */
@@ -193,17 +192,24 @@ export function holdServers(keepAlive: KeepAlive, args: HoldArgs): HeldPersisten
193
192
  const servers = new Map<string, HeldServer>()
194
193
  keepAlive.nodes.forEach((n, i) => {
195
194
  const child = held[i]!
195
+ const out = args.outOf(n.id)
196
196
  servers.set(n.id, {
197
197
  child,
198
198
  config: serverConfig(n, args.forwardArgs),
199
- out: args.outOf(n.id),
199
+ out,
200
200
  handOver: () => void handed.add(child),
201
201
  })
202
202
  // One that died during the graph was said then.
203
203
  if (hasEnded(child)) return
204
+ // Said where its output goes: a later run that keeps it owns the
205
+ // terminal from its start, and this run's renderer wrote over its rows.
204
206
  void child.exited.then((code) => {
205
207
  if (!stopping && !handed.has(child) && code !== 0)
206
- args.warn(`vx: ${n.id} exited with code ${code}`)
208
+ out.bus.emit({
209
+ kind: 'run:status',
210
+ line: `vx: ${n.id} exited with code ${code}`,
211
+ warn: true,
212
+ })
207
213
  })
208
214
  })
209
215
  return {
@@ -260,6 +266,12 @@ export async function disposeServers(args: {
260
266
  endedBeforeStop: ReadonlySet<Child> | undefined
261
267
  }): Promise<{ keepAlive: KeepAlive; crashed: CrashedPersistent[] }> {
262
268
  const { registry, outcomes, endedBeforeStop } = args
269
+ // One turn of the event loop first, so Bun reaps what has already died:
270
+ // `exitCode` stays null until it does. A server dead at once, with
271
+ // nothing between its spawn and here to yield, was judged still up: the
272
+ // footer counted it a success, pinned it `running`, and a teardown read
273
+ // the run ok over vx's exit 1.
274
+ if (registry.size > 0) await new Promise((resolve) => setTimeout(resolve, 0))
263
275
  let keepAlive = selectKeepAlive(registry, args.nodes, args.keep)
264
276
  const failedElsewhere =
265
277
  ![...outcomes.values()].every((o) => isPassStatus(o.status)) ||