@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -0,0 +1,224 @@
1
+ // The per-task execution contract. `execute-task.ts` decides WHAT to run
2
+ // (command, env, sandbox baselines, capture) and hands a fully-resolved
3
+ // request here; an executor decides WHERE/HOW the process runs. Core's own
4
+ // `local-executor.ts` is the in-process spawn, and it sits at the TAIL of
5
+ // every executor list — what a plugin executor declines runs here.
6
+ // Persistent tasks (`exec.persistent`) never reach an executor: they are local by
7
+ // construction (a worker cannot hand the submitter a listening port) and
8
+ // stay on `runPersistent`.
9
+ //
10
+ // Lives in `exec/` (not `orchestrator/`) so the contract depends only on
11
+ // process primitives — the module-boundary matrix forbids `exec` → `cache`,
12
+ // which is why sandbox baselines arrive pre-resolved on the request.
13
+
14
+ import type { CaptureConfig, RunResult } from './runner.js'
15
+ import type { ResolvedSandboxConfig, SandboxViolation } from './sandbox-runtime.js'
16
+
17
+ /** Sandbox baselines + the user's resolved sandbox block, when the task is sandboxed. */
18
+ export interface ExecuteSandbox {
19
+ readonly baseAllowRead: readonly string[]
20
+ readonly baseAllowWrite: readonly string[]
21
+ readonly baseDenyRead: readonly string[]
22
+ /**
23
+ * Only denials on a path under this directory are reported. A denial
24
+ * outside it is still ENFORCED — the task cannot leave its project — but
25
+ * that is the wall doing its job, not a finding to fail a run over.
26
+ */
27
+ readonly reportWithin: string
28
+ readonly config: ResolvedSandboxConfig
29
+ }
30
+
31
+ /** One declared input file, as the cache key saw it. */
32
+ export interface InputFile {
33
+ /** Workspace-relative POSIX path. */
34
+ readonly path: string
35
+ /** Git blob OID of the WORKTREE bytes — for a symlink, of its target string — the same digest the key folds. */
36
+ readonly digest: string
37
+ }
38
+
39
+ /**
40
+ * Everything the cache key folds for one task, with values — the set a
41
+ * remote executor needs to reproduce the task somewhere else. Built on the
42
+ * miss path only, from the same resolution that produced the key, so it can
43
+ * never drift from what a hit would have matched. Held in memory for the
44
+ * attempt and never persisted: `env` values and `runtime` output may be
45
+ * secrets (cache.db stores only their digests).
46
+ */
47
+ export interface TaskInputs {
48
+ /** Declared `cache.inputs.files` + `workspaceFiles` (own outputs excluded), sorted by path. */
49
+ readonly files: readonly InputFile[]
50
+ /** Declared `cache.inputs.env` names with their resolved values. */
51
+ readonly env: ReadonlyArray<{ readonly name: string; readonly value: string }>
52
+ /** `cache.inputs.runtime` commands and the output that was folded — a toolchain expectation. */
53
+ readonly runtime: ReadonlyArray<{ readonly command: string; readonly output: string }>
54
+ /** `cache.inputs.workspaceRuntime`, same shape. */
55
+ readonly workspaceRuntime: ReadonlyArray<{ readonly command: string; readonly output: string }>
56
+ /**
57
+ * Dependencies whose cache keys are folded. `outputs` lists each one's
58
+ * declared output files (workspace-relative POSIX, as stored with its cache
59
+ * entry) — restored on disk before this task runs, so an input-shipping
60
+ * executor can include them in the input root. Empty when the upstream's
61
+ * entry is not in the local index (a non-cacheable dependency).
62
+ */
63
+ readonly upstream: ReadonlyArray<{
64
+ readonly taskId: string
65
+ readonly hash: string
66
+ readonly outputs: readonly string[]
67
+ }>
68
+ /** Digest of the project's own `package.json` (folded even when no glob lists it). */
69
+ readonly packageJsonDigest: string
70
+ /** Digest of the resolved task config. */
71
+ readonly configDigest: string
72
+ /** The workspace fingerprint (root manifests + lockfile). */
73
+ readonly workspaceFingerprint: string
74
+ }
75
+
76
+ export interface ExecuteRequest {
77
+ /** `${project}#${task}` — for executors that route or log by task. */
78
+ readonly taskId: string
79
+ readonly workspaceRoot: string
80
+ /** Present for cacheable tasks (the miss path); absent when the task declares no `cache`. */
81
+ readonly inputs?: TaskInputs
82
+ /**
83
+ * The task's cache key (present when the task is cacheable). An executor
84
+ * that keeps its own remote record of executions — so a dependent's input
85
+ * tree can reference this task's outputs without the bytes ever landing on
86
+ * this machine — needs a stable address, and the key is that address.
87
+ */
88
+ readonly cacheKey?: string
89
+ /**
90
+ * Cache READS are disabled for this run (`--force` / `--no-cache`). An
91
+ * executor that keeps its OWN remote record of executions must not serve
92
+ * this request from it — the user asked for re-execution, and a private
93
+ * cache that ignores the flag is still a cache.
94
+ */
95
+ readonly refresh?: boolean
96
+ /**
97
+ * `exec.remote: 'only'`: the task produces a REMOTE input tree. The
98
+ * executor should not materialise outputs onto this machine's disk, and
99
+ * may satisfy the request from its own remote record of a previous
100
+ * execution under the same key.
101
+ */
102
+ readonly remoteOnly?: boolean
103
+ /**
104
+ * Whether this task's outputs must land on the submitter's disk now.
105
+ * 'eager' (or absent) = materialise as today. 'deferred' = leave them
106
+ * in the remote store and return `outputs: {kind:'deferred'}` with a
107
+ * closure core can call if a local consumer turns out to need them.
108
+ * A local executor ignores this — it writes in place by construction.
109
+ */
110
+ readonly download?: 'eager' | 'deferred'
111
+ /** The declared output globs — project-relative `files`, root-relative `workspaceFiles`. */
112
+ readonly outputs: {
113
+ readonly files: readonly string[]
114
+ readonly workspaceFiles: readonly string[]
115
+ }
116
+ readonly command: string
117
+ /** Appended to `command`, shell-quoted, by the executor (forwarded CLI args). */
118
+ readonly forwardArgs: readonly string[]
119
+ readonly cwd: string
120
+ readonly env: NodeJS.ProcessEnv
121
+ /**
122
+ * `exec.env.define` verbatim — literal name=value pairs from the task
123
+ * config. `env` above is the fully RESOLVED child environment and is
124
+ * host-specific (it carries this machine's PATH, HOME, TMPDIR…), so an
125
+ * executor that ships the environment somewhere else cannot use it: those
126
+ * values would enter the action identity and split every machine from
127
+ * every other. These are declared in the config, so they are the same
128
+ * everywhere and safe to forward.
129
+ */
130
+ readonly envDefine: Readonly<Record<string, string>>
131
+ readonly capture: CaptureConfig
132
+ readonly timeoutMs?: number
133
+ readonly onStdout: (chunk: string) => void
134
+ readonly onStderr: (chunk: string) => void
135
+ /** See `RunOptions.liveChildren`: the run's SIGINT/SIGTERM registry. */
136
+ readonly liveChildren?: Set<ReturnType<typeof Bun.spawn>>
137
+ readonly sandbox?: ExecuteSandbox
138
+ }
139
+
140
+ export interface ExecuteResult extends RunResult {
141
+ /** Sandbox violations (empty when unsandboxed). */
142
+ readonly violations: readonly SandboxViolation[]
143
+ /**
144
+ * Where this task's outputs are. Absent = `{kind:'disk'}` — in place
145
+ * under `cwd`, which is what every executor did before deferral
146
+ * existed. `deferred` means they are NOT on disk and `materialize()`
147
+ * fetches them; core calls it lazily, at most once, and then saves an
148
+ * ordinary cache entry, so a deferred task leaves no permanent third
149
+ * state behind.
150
+ */
151
+ readonly outputs?: { kind: 'disk' } | { kind: 'deferred'; materialize: () => Promise<void> }
152
+ /**
153
+ * Executor-reported placement label — which machine ran the command
154
+ * (a REAPI worker id, a pool member). Absent = this host. Rides
155
+ * `TaskOutcome.where` into telemetry so a task can be attributed to
156
+ * its worker; never persisted to the analytics store.
157
+ */
158
+ readonly where?: string
159
+ }
160
+
161
+ /** What an executor sees when a task is PLACED — once per task, before scheduling. */
162
+ export interface TaskPlacement {
163
+ readonly taskId: string
164
+ readonly projectName: string
165
+ readonly projectDir: string
166
+ readonly command: string
167
+ /**
168
+ * Must run on this machine: the task is persistent, depends (transitively)
169
+ * on a persistent task, or declares `exec.remote: false`. A `remote`
170
+ * executor is never offered such a task.
171
+ */
172
+ readonly pinnedLocal: boolean
173
+ /** Declares `cache` — the only tasks whose input set is described, and so the only ones that can ship. */
174
+ readonly cacheable: boolean
175
+ }
176
+
177
+ export interface TaskExecutor {
178
+ /** Shown in errors; `'local'` for core's own. */
179
+ readonly name: string
180
+ /** Runs the command somewhere else. Never offered a `pinnedLocal` task. */
181
+ readonly remote?: boolean
182
+ /**
183
+ * How many tasks this executor runs at once. Its tasks then occupy a pool
184
+ * of this size instead of the local worker slots, so a remote pool is not
185
+ * throttled by the local CPU count. Absent = the local pool.
186
+ */
187
+ readonly capacity?: number
188
+ /** Per-task opt-out at placement time. Absent = accepts every task it is offered. */
189
+ accepts?(task: TaskPlacement): boolean
190
+ /**
191
+ * The tasks placed here that have NOT finished yet — called once after
192
+ * placement with the whole set, then again after each completion (any
193
+ * outcome: a cache hit and a failure both remove one).
194
+ *
195
+ * An executor that provisions something per task — a container, an
196
+ * allocation, a pod — otherwise has to guess. `capacity` says how much it
197
+ * MAY run at once; this says how much is actually left, so it can size
198
+ * what it creates to the work that remains and give capacity back the
199
+ * moment the run can no longer use it, rather than holding it until
200
+ * teardown. Absent = the executor does not care and nothing is computed
201
+ * for it.
202
+ */
203
+ demand?(remaining: ReadonlySet<string>): void
204
+ execute(req: ExecuteRequest): Promise<ExecuteResult>
205
+ }
206
+
207
+ /**
208
+ * Place one task: the first executor, in declaration order, that may take
209
+ * it — a `remote` executor is skipped for a `pinnedLocal` task, then
210
+ * `accepts()` decides. Decided once per task before scheduling. The local
211
+ * executor accepts everything, so with it declared this cannot throw.
212
+ */
213
+ export function selectExecutor(
214
+ executors: readonly TaskExecutor[],
215
+ task: TaskPlacement,
216
+ ): TaskExecutor {
217
+ for (const executor of executors) {
218
+ if (task.pinnedLocal && executor.remote === true) continue
219
+ if (executor.accepts === undefined || executor.accepts(task)) return executor
220
+ }
221
+ throw new Error(
222
+ `no executor accepted ${task.taskId} (declared: ${executors.map((e) => e.name).join(', ')}). Core's local executor accepts every task, so this means it is missing from the list.`,
223
+ )
224
+ }
package/src/exec/index.ts CHANGED
@@ -1,13 +1,35 @@
1
1
  // Module contract. Cross-module imports must come through here; see
2
2
  // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
3
 
4
- export { buildIsolatedEnv } from './env.js'
5
- export { runCommand, runPersistent, signalExitCode } from './runner.js'
4
+ export { buildIsolatedEnv, VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from './env.js'
5
+ export {
6
+ runCommand,
7
+ runPersistent,
8
+ shellQuote,
9
+ signalExitCode,
10
+ type CaptureConfig,
11
+ } from './runner.js'
6
12
  export {
7
13
  initSandbox,
8
14
  probeSandbox,
15
+ unavailableReason,
9
16
  resetSandbox,
10
17
  resolveSandboxConfig,
11
18
  runSandboxed,
19
+ releaseBridges,
20
+ wrapSandboxedCommand,
21
+ type ResolvedSandboxConfig,
12
22
  type SandboxViolation,
13
23
  } from './sandbox-runtime.js'
24
+ export { type DeniedCall } from './sandbox-violations.js'
25
+ export { localExecutor } from './local-executor.js'
26
+ export {
27
+ selectExecutor,
28
+ type ExecuteRequest,
29
+ type ExecuteResult,
30
+ type ExecuteSandbox,
31
+ type InputFile,
32
+ type TaskExecutor,
33
+ type TaskInputs,
34
+ type TaskPlacement,
35
+ } from './executor.js'
@@ -0,0 +1,41 @@
1
+ // Core's own local executor: spawn the task's command here, in this
2
+ // process. Not a plugin — "run it on this machine" is not a capability
3
+ // someone else supplies differently, it is what running MEANS when no
4
+ // plugin claims the task, so it sits at the TAIL of every executor list
5
+ // (owner, 2026-09-05). An executor plugin places work ELSEWHERE; its
6
+ // absence is "here".
7
+ import { runCommand } from './runner.js'
8
+ import { runSandboxed } from './sandbox-runtime.js'
9
+ import type { TaskExecutor } from './executor.js'
10
+
11
+ /** The local executor. Accepts every task. */
12
+ export function localExecutor(): TaskExecutor {
13
+ return {
14
+ name: 'local',
15
+ async execute(req) {
16
+ const common = {
17
+ command: req.command,
18
+ cwd: req.cwd,
19
+ env: req.env,
20
+ forwardArgs: req.forwardArgs,
21
+ onStdout: req.onStdout,
22
+ onStderr: req.onStderr,
23
+ capture: req.capture,
24
+ ...(req.liveChildren !== undefined ? { liveChildren: req.liveChildren } : {}),
25
+ ...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
26
+ }
27
+ if (req.sandbox === undefined) {
28
+ const res = await runCommand(common)
29
+ return { ...res, violations: [] }
30
+ }
31
+ return runSandboxed({
32
+ ...common,
33
+ baseAllowRead: req.sandbox.baseAllowRead,
34
+ baseAllowWrite: req.sandbox.baseAllowWrite,
35
+ baseDenyRead: req.sandbox.baseDenyRead,
36
+ reportWithin: req.sandbox.reportWithin,
37
+ config: req.sandbox.config,
38
+ })
39
+ },
40
+ }
41
+ }
@@ -4,7 +4,9 @@
4
4
  // process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
5
5
  // folded into the v11 `runs` table by the orchestrator.
6
6
 
7
+ import { readFileSync } from 'node:fs'
7
8
  import { constants as osConstants } from 'node:os'
9
+ import { killGraceMs } from '../util/index.js'
8
10
 
9
11
  export interface RunResult {
10
12
  exitCode: number
@@ -25,6 +27,20 @@ export interface RunResult {
25
27
  peakRssBytes?: number
26
28
  }
27
29
 
30
+ /**
31
+ * Which streams `runCommand` retains into `RunResult.stdout` / `.stderr`.
32
+ * Both default to true — the primitive's contract is that those fields hold
33
+ * what the child wrote, and a field that silently lies is worse than one
34
+ * that costs. A caller that will not READ a stream opts down here: the live
35
+ * `onStdout` / `onStderr` callbacks still fire for every chunk, so only the
36
+ * retained string is dropped. Retaining a stream nothing reads costs its
37
+ * full byte size in heap, for the whole task.
38
+ */
39
+ export interface CaptureConfig {
40
+ stdout?: boolean
41
+ stderr?: boolean
42
+ }
43
+
28
44
  export interface RunOptions {
29
45
  command: string
30
46
  cwd: string
@@ -34,6 +50,8 @@ export interface RunOptions {
34
50
  /** Called for each chunk of stdout/stderr as it arrives, for live output. */
35
51
  onStdout?: (chunk: string) => void
36
52
  onStderr?: (chunk: string) => void
53
+ /** See `CaptureConfig`. Omitted → both streams retained (today's behaviour). */
54
+ capture?: CaptureConfig
37
55
  /**
38
56
  * Upper bound (ms) on the child's run time. When it elapses the
39
57
  * child is SIGTERMed and the result is flagged `timedOut`. Undefined
@@ -136,6 +154,12 @@ export function signalExitCode(signal: string): number {
136
154
  return num === undefined ? 130 : 128 + num
137
155
  }
138
156
 
157
+ /**
158
+ * Grace after a timeout SIGTERM before escalating to SIGKILL — a child that
159
+ * ignores SIGTERM must still be bounded. Matches the persistent-shutdown grace.
160
+ */
161
+ const TIMEOUT_SIGKILL_GRACE_MS = 2000
162
+
139
163
  /**
140
164
  * Arm a SIGTERM timeout on a spawned child. Returns a handle whose
141
165
  * `timedOut()` reports whether the timer fired — so the caller can
@@ -150,13 +174,67 @@ export function armTimeout(
150
174
  ): { timedOut: () => boolean; clear: () => void } {
151
175
  if (timeoutMs === undefined) return { timedOut: () => false, clear: () => {} }
152
176
  let fired = false
177
+ let killTimer: ReturnType<typeof setTimeout> | undefined
153
178
  const timer = setTimeout(() => {
154
179
  fired = true
155
180
  proc.kill('SIGTERM')
181
+ // Escalate to SIGKILL after a grace: a child that TRAPS+IGNORES SIGTERM
182
+ // (`trap '' TERM`) would otherwise defeat the timeout entirely and hang
183
+ // `await proc.exited` until its natural exit — there is no run-level
184
+ // timeout, so a wedged child hangs the whole run forever. Mirrors the
185
+ // end-of-run persistent-shutdown escalation. Unref'd so it never keeps
186
+ // the CLI alive.
187
+ killTimer = setTimeout(() => proc.kill('SIGKILL'), killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
188
+ killTimer.unref?.()
156
189
  }, timeoutMs)
157
- return { timedOut: () => fired, clear: () => clearTimeout(timer) }
190
+ return {
191
+ timedOut: () => fired,
192
+ clear: () => {
193
+ clearTimeout(timer)
194
+ if (killTimer !== undefined) clearTimeout(killTimer)
195
+ },
196
+ }
158
197
  }
159
198
 
199
+ /**
200
+ * Grace after the direct child exits before we abort the stdout/stderr readers.
201
+ * The pipe reaches EOF only when EVERY write-end fd is closed, so a task that
202
+ * backgrounds a process inheriting fd 1/2 (`server & echo up` — `execWrap`
203
+ * leaves compound commands as `sh -c`, so `sh` exits while the grandchild holds
204
+ * the pipe) never EOFs. On a clean exit with no lingering writer the pipe EOFs
205
+ * at once, so the readers win this race immediately and normal tasks pay
206
+ * nothing; only a stuck reader waits out the grace. Residual buffered output is
207
+ * bounded by the OS pipe buffer (streamToString drains continuously DURING the
208
+ * run), so this is far longer than any real drain needs.
209
+ */
210
+ const POST_EXIT_DRAIN_MS = 250
211
+
212
+ /**
213
+ * After the direct child has exited, wait for the stdout/stderr readers to
214
+ * reach EOF — but bound it: an orphaned grandchild holding the pipe open would
215
+ * hang the run forever (there is no default task timeout). If the grace expires
216
+ * first, abort the reader signal so `streamToString` cancels its read and
217
+ * returns whatever it captured. The timer is cleared AND unref'd so it can
218
+ * never keep the CLI alive after the readers settle (the plugin-flush lesson).
219
+ */
220
+ export async function drainOrAbort(streams: Promise<unknown>, ac: AbortController): Promise<void> {
221
+ let timer: ReturnType<typeof setTimeout> | undefined
222
+ const deadline = new Promise<'timeout'>((resolve) => {
223
+ timer = setTimeout(() => resolve('timeout'), POST_EXIT_DRAIN_MS)
224
+ timer.unref?.()
225
+ })
226
+ const winner = await Promise.race([streams.then(() => 'drained' as const), deadline])
227
+ if (timer !== undefined) clearTimeout(timer)
228
+ if (winner === 'timeout') ac.abort()
229
+ }
230
+
231
+ /**
232
+ * Upper bound on the text a `readyWhen` regex is tested against. The matcher
233
+ * normally keeps only the trailing partial line, so this bites only when the
234
+ * stream has no line breaks at all.
235
+ */
236
+ const READY_MATCH_WINDOW_CHARS = 64 * 1024
237
+
160
238
  export interface PersistentSpawn {
161
239
  /** Underlying Bun subprocess so the orchestrator can SIGTERM it later. */
162
240
  child: ReturnType<typeof Bun.spawn>
@@ -167,14 +245,15 @@ export interface PersistentSpawn {
167
245
  * Rejects with the spawn error if the child fails to start.
168
246
  */
169
247
  ready: Promise<void>
170
- /** Captured stdout/stderr up to the moment ready resolved. */
171
- bufferedStdout: () => string
172
- bufferedStderr: () => string
173
248
  /** ms elapsed from spawn to ready (or to current time if not yet ready). */
174
249
  readyMs: () => number
175
250
  }
176
251
 
177
- export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs'> {
252
+ // `capture` is a `runCommand` concept: a persistent task never returns a
253
+ // RunResult (it is handed back at READY, still running), so there is no
254
+ // retained string to opt out of. Its output reaches the caller only through
255
+ // the live callbacks.
256
+ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs' | 'capture'> {
178
257
  /**
179
258
  * String regex. The first stdout/stderr line that matches signals
180
259
  * "ready". Undefined → ready immediately on spawn.
@@ -198,8 +277,6 @@ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs'> {
198
277
  */
199
278
  export function runPersistent(opts: PersistentOptions): PersistentSpawn {
200
279
  const start = Date.now()
201
- let bufferedStdout = ''
202
- let bufferedStderr = ''
203
280
  let readyAt: number | undefined
204
281
 
205
282
  // Pattern compiled once; thrown errors surface synchronously so the
@@ -220,8 +297,6 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
220
297
  return {
221
298
  child: undefined as unknown as ReturnType<typeof Bun.spawn>,
222
299
  ready: Promise.reject(new Error(`failed to spawn persistent task: ${message}`)),
223
- bufferedStdout: () => '',
224
- bufferedStderr: () => `\n[vx] failed to spawn: ${message}\n`,
225
300
  readyMs: () => Date.now() - start,
226
301
  }
227
302
  }
@@ -255,16 +330,13 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
255
330
  let fragment = ''
256
331
  const handleChunk = (chunk: string): void => {
257
332
  if (chunk.length === 0) return
258
- // Buffers capture "up to the moment ready resolved" (their
259
- // documented contract) — a dev server kept alive for hours must
260
- // not accrete its whole log history into vx's heap.
261
- if (isStderr) {
262
- if (readyAt === undefined) bufferedStderr += chunk
263
- opts.onStderr?.(chunk)
264
- } else {
265
- if (readyAt === undefined) bufferedStdout += chunk
266
- opts.onStdout?.(chunk)
267
- }
333
+ // Chunks are forwarded, never retained. The one consumer of a
334
+ // persistent task's text is the logger, which keeps its OWN bounded
335
+ // tail fed from these same callbacks (registered at taskStart, so it
336
+ // covers the pre-ready window too) — a second copy here was read by
337
+ // nobody.
338
+ if (isStderr) opts.onStderr?.(chunk)
339
+ else opts.onStdout?.(chunk)
268
340
  if (readyRe && readyAt === undefined) {
269
341
  fragment += chunk
270
342
  if (readyRe.test(fragment)) {
@@ -273,6 +345,17 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
273
345
  } else {
274
346
  const lastNl = fragment.lastIndexOf('\n')
275
347
  if (lastNl >= 0) fragment = fragment.slice(lastNl + 1)
348
+ // Dropping complete lines assumes the stream HAS line breaks.
349
+ // `\r`-only output — a progress bar, a spinner — is one endless
350
+ // line, so the trim above never fires and the fragment becomes a
351
+ // third unbounded accumulator (measured 464 MiB in 6 s, and the
352
+ // per-chunk `test` over an ever-growing string makes it quadratic
353
+ // in CPU too). Keep the most RECENT window: a match may span the
354
+ // chunk boundary, and no readiness marker is anywhere near this
355
+ // long.
356
+ if (fragment.length > READY_MATCH_WINDOW_CHARS) {
357
+ fragment = fragment.slice(fragment.length - READY_MATCH_WINDOW_CHARS)
358
+ }
276
359
  }
277
360
  }
278
361
  }
@@ -314,6 +397,14 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
314
397
  ),
315
398
  )
316
399
  child.kill('SIGTERM')
400
+ // Same escalation as `armTimeout`: a server that traps TERM and
401
+ // never became ready is not in the persistent registry, so nothing
402
+ // else would ever kill it — it outlived the run under init.
403
+ const killTimer = setTimeout(
404
+ () => child.kill('SIGKILL'),
405
+ killGraceMs(TIMEOUT_SIGKILL_GRACE_MS),
406
+ )
407
+ killTimer.unref?.()
317
408
  }
318
409
  }, opts.timeoutMs)
319
410
  }
@@ -340,8 +431,6 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
340
431
  return {
341
432
  child,
342
433
  ready,
343
- bufferedStdout: () => bufferedStdout,
344
- bufferedStderr: () => bufferedStderr,
345
434
  readyMs: () => (readyAt ?? Date.now()) - start,
346
435
  }
347
436
  }
@@ -376,15 +465,19 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
376
465
  const timeout = armTimeout(proc, opts.timeoutMs)
377
466
  const ac = new AbortController()
378
467
  const streams = Promise.all([
379
- streamToString(proc.stdout, opts.onStdout, ac.signal),
380
- streamToString(proc.stderr, opts.onStderr, ac.signal),
468
+ streamToString(proc.stdout, opts.onStdout, ac.signal, opts.capture?.stdout ?? true),
469
+ streamToString(proc.stderr, opts.onStderr, ac.signal, opts.capture?.stderr ?? true),
381
470
  ])
382
- // Gate on the child's own exit, not on stream EOF: a timeout SIGTERM
383
- // kills `sh` but an orphaned grandchild can keep the pipe open, so we
384
- // abort the readers once the child is gone rather than wait forever.
471
+ // Gate on the child's own exit, not on stream EOF: an orphaned grandchild
472
+ // can keep the pipe open past the child's exit (a timeout SIGTERM leaves the
473
+ // grandchild alive; a NORMAL exit of `server & echo up` does too), so EOF may
474
+ // never arrive. A timeout aborts the readers at once; otherwise drainOrAbort
475
+ // lets a clean exit EOF immediately and only cuts off a stuck reader after a
476
+ // brief grace — without this the run hangs forever.
385
477
  await proc.exited
386
478
  timeout.clear()
387
479
  if (timeout.timedOut()) ac.abort()
480
+ else await drainOrAbort(streams, ac)
388
481
  const [stdout, stderr] = await streams
389
482
  opts.liveChildren?.delete(proc)
390
483
  const exitCode = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
@@ -395,18 +488,46 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
395
488
  stderr,
396
489
  ...(proc.signalCode ? { signal: proc.signalCode } : {}),
397
490
  ...(timeout.timedOut() ? { timedOut: true } : {}),
398
- ...resourceUsageToCpuRss(proc.resourceUsage()),
491
+ ...resourceUsageToCpuRss(proc.resourceUsage(), ownRssHighWater()),
492
+ }
493
+ }
494
+
495
+ /**
496
+ * This process's own RSS high-water mark, in bytes: the floor under which
497
+ * a child's `ru_maxrss` says nothing about the child. Linux folds the
498
+ * forking parent's high-water mark into the child's figure at exec (a
499
+ * forked child starts with its parent's pages; `exec_mmap` keeps the old
500
+ * mm's peak), so a task lighter than vx itself reads vx's footprint —
501
+ * `true` read 44 MB through vx while its shell's `VmHWM` was 1.9 MB, and
502
+ * 300 MB allocated in the parent made `true` read 328 MB (2026-09-12).
503
+ * Read after the child exits so it covers the whole task's span (the mark
504
+ * is monotonic). Linux reads `VmHWM`; elsewhere the current RSS is the
505
+ * bound in hand.
506
+ */
507
+ export function ownRssHighWater(): number {
508
+ if (process.platform === 'linux') {
509
+ try {
510
+ const m = /VmHWM:\s+(\d+) kB/.exec(readFileSync('/proc/self/status', 'utf8'))
511
+ if (m !== null) return Number(m[1]) * 1024
512
+ } catch {
513
+ // /proc unreadable: fall through to the current RSS.
514
+ }
399
515
  }
516
+ return process.memoryUsage.rss()
400
517
  }
401
518
 
402
519
  /**
403
520
  * Drain a `Bun.spawn` stdout/stderr stream while invoking the live
404
- * callback per UTF-8 chunk. Returns the full accumulated string.
521
+ * callback per UTF-8 chunk. Returns the accumulated string, or `''` when
522
+ * `retain` is false — the stream is still fully drained and every chunk
523
+ * still reaches `onChunk`; only the retained copy is dropped, so a caller
524
+ * that will not read it does not pay its byte size in heap.
405
525
  */
406
526
  export async function streamToString(
407
527
  stream: ReadableStream<Uint8Array> | number | undefined,
408
528
  onChunk?: (s: string) => void,
409
529
  signal?: AbortSignal,
530
+ retain = true,
410
531
  ): Promise<string> {
411
532
  // Bun.spawn types stdout/stderr as `ReadableStream | number | undefined`
412
533
  // — the `number` is for inheritance modes, only present when the caller
@@ -430,12 +551,12 @@ export async function streamToString(
430
551
  const { value, done } = await reader.read()
431
552
  if (done) break
432
553
  const chunk = decoder.decode(value, { stream: true })
433
- full += chunk
554
+ if (retain) full += chunk
434
555
  onChunk?.(chunk)
435
556
  }
436
557
  const tail = decoder.decode()
437
558
  if (tail.length > 0) {
438
- full += tail
559
+ if (retain) full += tail
439
560
  onChunk?.(tail)
440
561
  }
441
562
  } finally {
@@ -453,18 +574,23 @@ export async function streamToString(
453
574
  */
454
575
  export function resourceUsageToCpuRss(
455
576
  usage: ReturnType<ReturnType<typeof Bun.spawn>['resourceUsage']>,
456
- platform: NodeJS.Platform = process.platform,
577
+ /** The parent's own high-water mark (`ownRssHighWater`); a peak at or under it is inherited, not the child's, and is not reported. */
578
+ floorBytes = 0,
457
579
  ): { cpuMs?: number; peakRssBytes?: number } {
458
580
  if (!usage) return {}
459
581
  // cpuTime.total is microseconds as a bigint → ms.
460
582
  const cpuMs = Number(usage.cpuTime.total) / 1000
461
- // maxRSS is the raw kernel `ru_maxrss`, whose UNIT IS PLATFORM-SPECIFIC and
462
- // Bun does NOT normalize it:
463
- // • Linux → kilobytes (multiply by 1024 for bytes)
464
- // • macOS / BSD → bytes (already bytes; do NOT multiply)
465
- // • Windows → bytes (PeakWorkingSetSize)
466
- // Treating macOS's byte value as KB inflates peak RSS by 1024× — e.g. a
467
- // real 460 MB showed up as 460 GB. Convert per platform.
468
- const peakRssBytes = platform === 'linux' ? usage.maxRSS * 1024 : usage.maxRSS
469
- return { cpuMs, peakRssBytes }
583
+ // `maxRSS` is BYTES on every platform: Bun normalizes the kernel's
584
+ // `ru_maxrss` (kilobytes on Linux, bytes on macOS) before handing it
585
+ // over, as its typing says. This used to multiply by 1024 on Linux on
586
+ // the belief that Bun passed the raw value through, and every Linux
587
+ // peak was 1024× too big — a 64 MB `bun test` recorded as 64 GB in
588
+ // telemetry, `--summarize` and the run history, and once reservations
589
+ // were learned from that history (2026-09-12) every task with a
590
+ // recorded peak was over any budget and ran alone. Measured, not
591
+ // assumed: `tests/runner.test.ts` allocates a known number of bytes
592
+ // and reads the peak back within a bounded factor of it.
593
+ // A reading at or under the parent's own mark is the parent's (see
594
+ // `ownRssHighWater`): the child's peak is unknown, bounded by it.
595
+ return usage.maxRSS > floorBytes ? { cpuMs, peakRssBytes: usage.maxRSS } : { cpuMs }
470
596
  }