@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
@@ -3,18 +3,20 @@
3
3
  // request here; an executor decides WHERE/HOW the process runs. Core's own
4
4
  // `local-executor.ts` is the in-process spawn, and it sits at the TAIL of
5
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`. An `exec.interactive` task reaches the local one
6
+ // Persistent tasks (`exec.persistent`) never reach an executor as tasks: a
7
+ // worker cannot hand the submitter a listening port. A `servers` executor
8
+ // is handed a task's persistent dependencies instead (`ExecuteRequest.servers`)
9
+ // and starts them where it runs the task; the rest stay on `runPersistent`. An `exec.interactive` task reaches the local one
9
10
  // only: no plugin can hand it this terminal.
10
11
  //
11
12
  // Lives in `exec/` (not `orchestrator/`) so the contract depends only on
12
13
  // process primitives — the module-boundary matrix forbids `exec` → `cache`,
13
14
  // which is why sandbox baselines arrive pre-resolved on the request.
14
15
 
16
+ import type { SandboxConfig } from '../config.js'
15
17
  import type { CaptureConfig, RunResult } from './runner.js'
16
18
  import type { ResolvedSandboxConfig, SandboxViolation } from './sandbox-runtime.js'
17
- import { UserError } from '../util/index.js'
19
+ import { thrownMessage, UserError, type ui } from '../util/index.js'
18
20
 
19
21
  /** Sandbox baselines + the user's resolved sandbox block, when the task is sandboxed. */
20
22
  export interface ExecuteSandbox {
@@ -34,6 +36,12 @@ export interface ExecuteSandbox {
34
36
  */
35
37
  readonly reportLinked: readonly string[]
36
38
  readonly config: ResolvedSandboxConfig
39
+ /**
40
+ * A cached task's: the project directories its key answers for, which
41
+ * an executor that builds the request again where it runs, on that
42
+ * machine's paths and platform, hands `sandboxRequestFor`.
43
+ */
44
+ readonly keyed?: readonly string[]
37
45
  }
38
46
 
39
47
  /** One declared input file, as the cache key saw it. */
@@ -77,6 +85,24 @@ export interface TaskInputs {
77
85
  readonly configDigest: string
78
86
  }
79
87
 
88
+ /** A persistent dependency a `servers` executor starts where it runs the task, as `runPersistent` does. */
89
+ export interface ServerSpec {
90
+ readonly taskId: string
91
+ readonly command: string
92
+ readonly cwd: string
93
+ readonly env: NodeJS.ProcessEnv
94
+ /** `exec.persistent.readyWhen`: ready at the first output line it matches; absent, at spawn. */
95
+ readonly readyWhen?: string
96
+ /** Bounds the readiness wait. */
97
+ readonly timeoutMs?: number
98
+ /**
99
+ * Its `exec.sandbox`, and the directories of the projects nested in its
100
+ * own (a wall its grants do not reach): it starts inside that sandbox,
101
+ * built where it runs, as a one-shot task's.
102
+ */
103
+ readonly sandbox?: { readonly config: SandboxConfig; readonly nested: readonly string[] }
104
+ }
105
+
80
106
  export interface ExecuteRequest {
81
107
  /** `${project}#${task}` — for executors that route or log by task. */
82
108
  readonly taskId: string
@@ -90,6 +116,15 @@ export interface ExecuteRequest {
90
116
  * this machine — needs a stable address, and the key is that address.
91
117
  */
92
118
  readonly cacheKey?: string
119
+ /**
120
+ * Core saves no entry under `cacheKey` after this attempt: the run's
121
+ * policy writes no layer (`--no-cache`, `--cache-policy local:r`), or an
122
+ * upstream failed under `--continue=always`, ran unkeyed or is keyless.
123
+ * An executor that saves a task's result for its dependants keeps it off
124
+ * that key too: a distributed run's worker saved it, and the next run
125
+ * hit an entry the user had said not to write (2026-10-24).
126
+ */
127
+ readonly unsaved?: boolean
93
128
  /**
94
129
  * Cache READS are disabled for this run (`--force` / `--no-cache`). An
95
130
  * executor that keeps its OWN remote record of executions must not serve
@@ -104,6 +139,11 @@ export interface ExecuteRequest {
104
139
  * execution under the same key.
105
140
  */
106
141
  readonly remoteOnly?: boolean
142
+ /**
143
+ * The platform to run it on, one of the task's `exec.platforms` and the
144
+ * executor's `platforms`: its key includes it. Absent when the task names none.
145
+ */
146
+ readonly platform?: string
107
147
  /**
108
148
  * Whether this task's outputs must land on the submitter's disk now.
109
149
  * 'eager' (or absent) = materialise as today. 'deferred' = leave them
@@ -162,6 +202,12 @@ export interface ExecuteRequest {
162
202
  * core's local executor is ever handed such a task (placement.ts).
163
203
  */
164
204
  readonly terminal?: true
205
+ /**
206
+ * The persistent tasks it depends on, through groups, for a `servers`
207
+ * executor: each started where the command runs, ready before it, and
208
+ * shared by the tasks that run there. Absent for any other executor.
209
+ */
210
+ readonly servers?: readonly ServerSpec[]
165
211
  }
166
212
 
167
213
  export interface ExecuteResult extends RunResult {
@@ -173,16 +219,40 @@ export interface ExecuteResult extends RunResult {
173
219
  * existed. `deferred` means they are NOT on disk and `materialize()`
174
220
  * fetches them; core calls it lazily, at most once, and then saves an
175
221
  * ordinary cache entry, so a deferred task leaves no permanent third
176
- * state behind.
222
+ * state behind. An executor may answer `deferred` to an `eager` request
223
+ * too, when it cannot bring them here: core saves nothing from this disk.
177
224
  */
178
225
  readonly outputs?: { kind: 'disk' } | { kind: 'deferred'; materialize: () => Promise<void> }
226
+ /**
227
+ * Tracked files the command changed where it ran, workspace-relative
228
+ * POSIX: an executor that runs it elsewhere says so, as this disk shows
229
+ * none of it. One the key read withholds the save under the key, as an
230
+ * input changed here does.
231
+ */
232
+ readonly changed?: readonly string[]
179
233
  /**
180
234
  * Executor-reported placement label — which machine ran the command
181
235
  * (a REAPI worker id, a pool member). Absent = this host. Rides
182
236
  * `TaskOutcome.where` into telemetry so a task can be attributed to
183
- * its worker; never persisted to the analytics store.
237
+ * its worker, onto its row and frame (`<id>@<where>`), and into the
238
+ * run history (v44).
184
239
  */
185
240
  readonly where?: string
241
+ /**
242
+ * The cores and memory of the machine `where` names, as it reports
243
+ * them: the run history keeps them, so a task that used all of one
244
+ * reads as wanting a bigger one (`TaskResources.saturated*`).
245
+ */
246
+ readonly machine?: {
247
+ readonly cores: number
248
+ readonly memoryBytes: number
249
+ /**
250
+ * The share of a core each runnable thread got there over the task's
251
+ * run (1 when a core was free). On a crowded machine the CPU a task
252
+ * used is its share, not its need: the run history divides by this.
253
+ */
254
+ readonly cpuShare?: number
255
+ }
186
256
  }
187
257
 
188
258
  /** What an executor sees when a task is PLACED — once per task, before scheduling. */
@@ -193,9 +263,10 @@ export interface TaskPlacement {
193
263
  readonly command: string
194
264
  /**
195
265
  * Must run on this machine: the task is persistent, depends (transitively)
196
- * on a persistent or sandboxed task, is sandboxed itself, declares
266
+ * on a persistent task this executor does not carry or a sandboxed one, is sandboxed itself, declares
197
267
  * `exec.remote: false`, or folds a runtime probe into its key (this
198
- * machine's `node -v`). A `remote` executor is never offered such a task.
268
+ * machine's `node -v`) and this executor is not `runtime`. A `remote`
269
+ * executor is never offered such a task.
199
270
  */
200
271
  readonly pinnedLocal: boolean
201
272
  /** Declares `cache` — the only tasks whose input set is described, and so the only ones that can ship. */
@@ -207,6 +278,54 @@ export interface TaskExecutor {
207
278
  readonly name: string
208
279
  /** Runs the command somewhere else. Never offered a `pinnedLocal` task. */
209
280
  readonly remote?: boolean
281
+ /**
282
+ * A `remote` executor that enforces `ExecuteRequest.sandbox` where it
283
+ * runs the command, as the local one does. It is offered sandboxed tasks
284
+ * and their dependants; the request's violations come back on the result.
285
+ */
286
+ readonly sandbox?: boolean
287
+ /**
288
+ * A `remote` executor that starts a task's persistent dependencies where
289
+ * it runs the task (`ExecuteRequest.servers`). It is offered the
290
+ * dependants of a persistent task with no `exec.sandbox` and no
291
+ * `exec.remote: false`; that task then runs here only for a dependant
292
+ * placed here (2026-10-22: nothing runs on a distributed run's
293
+ * orchestrator).
294
+ */
295
+ readonly servers?: boolean
296
+ /**
297
+ * A `remote` executor that answers a task's `inputs.runtime` and
298
+ * `workspaceRuntime` probes where it runs the task (`answerRuntime`) and
299
+ * fails it when one answers other than `TaskInputs` says this machine
300
+ * did. It is offered tasks whose key folds a probe, which otherwise run
301
+ * here: their key holds this machine's answer (C-2).
302
+ */
303
+ readonly runtime?: boolean
304
+ /**
305
+ * A `remote` executor that runs every task of the run, so none runs on
306
+ * this machine; only cache hits restore here (2026-10-24: "no
307
+ * task should ever run on the orchestrator"). Nothing is pinned for it:
308
+ * it is offered `exec.remote: false` and `exec.interactive` tasks too,
309
+ * and starts every server a dependant needs. A task it cannot take (a
310
+ * platform it does not offer, one it declines, a server requested on its
311
+ * own) refuses the run, and a task it gives back fails.
312
+ */
313
+ readonly exclusive?: boolean
314
+ /**
315
+ * A `remote` executor that enforces `ExecuteRequest.timeoutMs` where it
316
+ * runs the command, timed from its start, and reports `timedOut`. Core
317
+ * then arms no clock of its own: armed at the request, it counted the
318
+ * wait for a worker still booting and killed a 1 s task with a 3 s
319
+ * timeout before it began (2026-10-24). The run's stop still aborts.
320
+ */
321
+ readonly timeouts?: boolean
322
+ /**
323
+ * The platforms this executor runs tasks on (`exec.platforms`' names). It
324
+ * is offered a task that declares `exec.platforms` only when one of them
325
+ * is listed, and runs it on `ExecuteRequest.platform`; an executor that
326
+ * declares none is never offered such a task off this machine.
327
+ */
328
+ readonly platforms?: readonly string[]
210
329
  /**
211
330
  * How many tasks this executor runs at once. Its tasks then occupy a pool
212
331
  * of this size instead of the local worker slots, so a remote pool is not
@@ -217,9 +336,10 @@ export interface TaskExecutor {
217
336
  /** Per-task opt-out at placement time. Absent = accepts every task it is offered. */
218
337
  accepts?(task: TaskPlacement): boolean
219
338
  /**
220
- * The tasks placed here that have NOT finished yet — called once after
221
- * placement with the whole set, then again after each completion (any
222
- * outcome: a cache hit and a failure both remove one).
339
+ * The tasks placed here that will run and have NOT finished yet — called
340
+ * first once the run's cache has said which placed tasks it holds (those
341
+ * restore, so they are left out), then again after each completion (any
342
+ * outcome, a failure too) and when a task it left out executes after all.
223
343
  *
224
344
  * An executor that provisions something per task — a container, an
225
345
  * allocation, a pod — otherwise has to guess. `capacity` says how much it
@@ -228,11 +348,80 @@ export interface TaskExecutor {
228
348
  * moment the run can no longer use it, rather than holding it until
229
349
  * teardown. Absent = the executor does not care and nothing is computed
230
350
  * for it.
351
+ *
352
+ * `missed`: of `remaining`, those whose keys the run's up-front probe
353
+ * found in no store (each store asked answered for every key), or, when
354
+ * the remote has no batch probe, those the index keeps no hit of: they
355
+ * will run. The rest may still restore, or were not probed (a key an upstream's outputs can
356
+ * change). The same set each call, changed in place.
231
357
  */
232
- demand?(remaining: ReadonlySet<string>): void
358
+ demand?(remaining: ReadonlySet<string>, missed: ReadonlySet<string>): void
359
+ /**
360
+ * Rows the run's footer shows for this executor, `[label, text]`, the
361
+ * text plain or a `ui` inline (a tone colors it): what only it knows, such as the machines it ran tasks on. Asked on every
362
+ * frame of the live footer and once for the final one, so it is cheap;
363
+ * a throw shows nothing.
364
+ */
365
+ footer?(): readonly (readonly [label: string, text: ui.Inline])[]
366
+ /**
367
+ * The machines it ran the run's tasks on (`small#1`, `local`), for the
368
+ * run summary telemetry sinks read; asked once at the end, only when a
369
+ * sink is on. A throw reports none.
370
+ */
371
+ machines?(): readonly MachineReport[]
372
+ /**
373
+ * The run's end had it placed its tasks perfectly, from the run's
374
+ * start: what each took, replayed on the machines it could have had, for
375
+ * the run summary beside the real time. Asked once at the end, only when a sink is on. A throw, or
376
+ * anything but a finite number, reports none.
377
+ */
378
+ idealMs?(): number | undefined
379
+ /**
380
+ * Its own Chrome-trace events for `vx run --profile` (the machines it ran
381
+ * tasks on, in time), asked once at the end, only when the flag is set.
382
+ * `ts` is in microseconds of `performance.now()`; `pid` from 1, which
383
+ * the run moves past its own process. A throw adds none.
384
+ */
385
+ profile?(): readonly Record<string, unknown>[]
386
+ /**
387
+ * Where a task it was handed runs (`small#2`), for the live row; asked
388
+ * per frame. `null` while it holds the task for a machine to begin it:
389
+ * the row says `waiting`, and its clock starts when a machine begins it.
390
+ */
391
+ running?(taskId: string): string | null | undefined
233
392
  execute(req: ExecuteRequest): Promise<ExecuteResult>
234
393
  }
235
394
 
395
+ /** One machine an executor ran tasks on, as the run summary reports it. */
396
+ export interface MachineReport {
397
+ /** As task rows name it (`small#1`). */
398
+ name: string
399
+ /** `online`, or `gone` for one that left before the run ended. */
400
+ status: string
401
+ cores: number
402
+ memoryBytes: number
403
+ done: number
404
+ failed: number
405
+ /** From when it joined to when it left or the run ended: warm-up not counted. */
406
+ onlineMs: number
407
+ /** Time with at least one task on it, from its send to its result. */
408
+ busyMs: number
409
+ /** Cores in use at once, at the most and on average over its time online. */
410
+ cpuPeak: number
411
+ cpuAvg: number
412
+ /** Bytes in use at once, at the most and on average. */
413
+ memoryPeakBytes: number
414
+ memoryAvgBytes: number
415
+ /** Core-time its tasks used over the core-time it had, 0 to 1. */
416
+ utilization: number
417
+ /** Cores the executor booked for the tasks it ran, on average over its time online. */
418
+ bookedAvg?: number
419
+ /** From when the executor asked for it to when it joined, for one it started. */
420
+ warmUpMs?: number
421
+ /** When it was asked for (else when it joined), from the executor's start. */
422
+ startMs?: number
423
+ }
424
+
236
425
  /**
237
426
  * What a remote executor rejects with when it gives a task back: core runs
238
427
  * the same request on the local floor and says `reason` once (a remote that
@@ -267,6 +456,8 @@ export function assertExecuteResult(
267
456
  `executor '${executor}' returned an invalid result for ${taskId}: ${what} — a plugin bug, not a task failure`,
268
457
  )
269
458
  }
459
+ const shown = (v: unknown): string =>
460
+ typeof v === 'object' && v !== null ? 'an object' : String(v as string)
270
461
  if (typeof res !== 'object' || res === null)
271
462
  fail(`${res === null ? 'null' : typeof res} (expected an object)`)
272
463
  const r = res as Record<string, unknown>
@@ -284,6 +475,46 @@ export function assertExecuteResult(
284
475
  }
285
476
  if (!Array.isArray(r['violations']))
286
477
  fail(`violations is ${typeof r['violations']} (expected an array)`)
478
+ // The optional fields reach the task's row, telemetry and the run
479
+ // history: a `where` object lost the whole run's history to a bind error
480
+ // naming no executor, and a `null` violation failed the task as a TypeError.
481
+ for (const [i, v] of (r['violations'] as unknown[]).entries()) {
482
+ if (typeof v !== 'object' || v === null || typeof (v as { line?: unknown }).line !== 'string')
483
+ fail(`violations[${i}] is not an object with a string line`)
484
+ }
485
+ const where = r['where']
486
+ if (where !== undefined && (typeof where !== 'string' || where === ''))
487
+ fail(`where is ${where === '' ? 'empty' : typeof where} (expected a non-empty string)`)
488
+ for (const key of ['cpuMs', 'peakRssBytes']) {
489
+ const n = r[key]
490
+ if (n !== undefined && !(typeof n === 'number' && Number.isFinite(n) && n >= 0))
491
+ fail(`${key} is ${shown(n)} (expected a non-negative number)`)
492
+ }
493
+ const signal = r['signal']
494
+ if (
495
+ signal !== undefined &&
496
+ typeof signal !== 'string' &&
497
+ !(typeof signal === 'number' && Number.isInteger(signal) && signal > 0)
498
+ )
499
+ fail(`signal is ${shown(signal)} (expected a name or a signal number)`)
500
+ if (r['timedOut'] !== undefined && typeof r['timedOut'] !== 'boolean')
501
+ fail(`timedOut is ${typeof r['timedOut']} (expected a boolean)`)
502
+ const machine = r['machine']
503
+ if (machine !== undefined) {
504
+ if (typeof machine !== 'object' || machine === null) fail('machine is not an object')
505
+ const m = machine as Record<string, unknown>
506
+ // A worker that cannot read its memory reports 0 (vx-dte's join takes it).
507
+ for (const [key, least] of [
508
+ ['cores', 'positive'],
509
+ ['memoryBytes', 'non-negative'],
510
+ ['cpuShare', 'positive'],
511
+ ] as const) {
512
+ const n = m[key]
513
+ if (key === 'cpuShare' && n === undefined) continue
514
+ if (!(typeof n === 'number' && Number.isFinite(n) && (least === 'positive' ? n > 0 : n >= 0)))
515
+ fail(`machine.${key} is ${shown(n)} (expected a ${least} number)`)
516
+ }
517
+ }
287
518
  const outputs = r['outputs'] as { kind?: unknown; materialize?: unknown } | undefined
288
519
  if (outputs !== undefined) {
289
520
  if (typeof outputs !== 'object' || outputs === null) fail('outputs is not an object')
@@ -293,6 +524,12 @@ export function assertExecuteResult(
293
524
  } else if (outputs.kind !== 'disk')
294
525
  fail(`outputs.kind is ${String(outputs.kind)} (expected disk or deferred)`)
295
526
  }
527
+ const changed = r['changed']
528
+ if (
529
+ changed !== undefined &&
530
+ (!Array.isArray(changed) || changed.some((f) => typeof f !== 'string'))
531
+ )
532
+ fail('changed is not an array of paths')
296
533
  }
297
534
 
298
535
  /**
@@ -307,13 +544,18 @@ export function selectExecutor(
307
544
  executors: readonly TaskExecutor[],
308
545
  task: TaskPlacement,
309
546
  label: (executor: TaskExecutor) => string = (e) => `executor '${e.name}'`,
547
+ /** `task.pinnedLocal` as `executor` sees it: one that enforces the sandbox or carries servers lifts some pins. */
548
+ pinnedFor: (executor: TaskExecutor) => boolean = () => task.pinnedLocal,
310
549
  ): TaskExecutor {
311
550
  for (const executor of executors) {
312
- if (task.pinnedLocal && executor.remote === true) continue
551
+ const pinned = pinnedFor(executor)
552
+ if (pinned && executor.remote === true) continue
313
553
  if (executor.accepts === undefined) return executor
314
554
  let taken: unknown
315
555
  try {
316
- taken = executor.accepts(task)
556
+ taken = executor.accepts(
557
+ pinned === task.pinnedLocal ? task : { ...task, pinnedLocal: pinned },
558
+ )
317
559
  // An `async accepts` answers a Promise: truthy, so the executor took
318
560
  // every task, and a rejection went unheard (the shape of H-16's admit).
319
561
  if (taken instanceof Promise) {
@@ -321,7 +563,7 @@ export function selectExecutor(
321
563
  throw new Error('returned a Promise; accepts is synchronous')
322
564
  }
323
565
  } catch (err) {
324
- const m = err instanceof Error ? err.message : String(err)
566
+ const m = thrownMessage(err)
325
567
  throw new UserError(`${label(executor)} failed in accepts for ${task.taskId}: ${m}`)
326
568
  }
327
569
  if (taken) return executor
@@ -330,3 +572,8 @@ export function selectExecutor(
330
572
  `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.`,
331
573
  )
332
574
  }
575
+
576
+ /** This machine as `exec.platforms` names it: `os-arch` as Bun reports them, `linux-x64`. */
577
+ export function hostPlatform(): string {
578
+ return `${process.platform}-${process.arch}`
579
+ }
package/src/exec/index.ts CHANGED
@@ -9,7 +9,9 @@ export {
9
9
  VX_RUN_WORKSPACE_ENV,
10
10
  } from './env.js'
11
11
  export {
12
+ lineEnds,
12
13
  runCommand,
14
+ restoreTerminalModes,
13
15
  runPersistent,
14
16
  PersistentReadyError,
15
17
  withForwardArgs,
@@ -19,6 +21,7 @@ export {
19
21
  type CaptureConfig,
20
22
  execWord,
21
23
  maskCaptured,
24
+ isDroppedOutputChunk,
22
25
  BoundedCapture,
23
26
  type CapturedChunk,
24
27
  } from './runner.js'
@@ -48,17 +51,20 @@ export {
48
51
  } from './sandbox-paths.js'
49
52
  export { bindableWrites, punchWalls } from './sandbox-binds.js'
50
53
  export { isLocalExecutor, localExecutor } from './local-executor.js'
51
- export { holdGroups, killTree, untilGroupsGone } from './kill-tree.js'
52
- export { sampleTrees } from './proc-sample.js'
54
+ export { holdGroups, killTree, pauseTree, untilGroupsGone } from './kill-tree.js'
55
+ export { sampleTrees, type TreeUsage } from './proc-sample.js'
53
56
  export {
54
57
  assertExecuteResult,
55
58
  executorFallback,
59
+ hostPlatform,
56
60
  isExecutorFallback,
57
61
  selectExecutor,
58
62
  type ExecuteRequest,
59
63
  type ExecuteResult,
60
64
  type ExecuteSandbox,
61
65
  type InputFile,
66
+ type MachineReport,
67
+ type ServerSpec,
62
68
  type TaskExecutor,
63
69
  type TaskInputs,
64
70
  type TaskPlacement,
@@ -328,6 +328,21 @@ export function killTree(child: Child, signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL')
328
328
  }
329
329
  }
330
330
 
331
+ /**
332
+ * Stop or continue `child`'s group with vx (Ctrl-Z, `fg`). Straight to
333
+ * the group, never down a sandbox's channel: its watcher passes one
334
+ * signal and is gone. Under a Linux sandbox that stops bwrap, and the
335
+ * command inside blocks once its output pipe fills.
336
+ */
337
+ export function pauseTree(child: Child, signal: 'SIGSTOP' | 'SIGCONT'): void {
338
+ if (!(child.pid > 0) || goneGroups.has(child)) return
339
+ try {
340
+ process.kill(-child.pid, signal)
341
+ } catch {
342
+ // The group is gone, or not ours: nothing of it to stop.
343
+ }
344
+ }
345
+
331
346
  /**
332
347
  * How often a group whose leader has exited is asked again whether any
333
348
  * member is left. Only a group that outlived its leader is polled.
@@ -2,7 +2,7 @@
2
2
  // process. Not a plugin — "run it on this machine" is not a capability
3
3
  // someone else supplies differently, it is what running MEANS when no
4
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
5
+ // (2026-09-05). An executor plugin places work ELSEWHERE; its
6
6
  // absence is "here".
7
7
  import { runCommand, signalExitCode, stopSignal } from './runner.js'
8
8
  import { runSandboxed } from './sandbox-runtime.js'
@@ -8,7 +8,7 @@
8
8
  // counted; nor is the CPU of a descendant that already exited.
9
9
 
10
10
  import { readdir, readFile } from 'node:fs/promises'
11
- import { procfsIsOwn } from '../util/index.js'
11
+ import { helperEnv, procfsIsOwn } from '../util/index.js'
12
12
 
13
13
  export interface TreeUsage {
14
14
  /** CPU time of the live tree, user + system, in ms. */
@@ -19,7 +19,10 @@ export interface TreeUsage {
19
19
 
20
20
  interface Proc {
21
21
  ppid: number
22
+ /** Its own CPU and, on Linux, what its reaped children used (`cutime`, `cstime`). */
22
23
  cpuMs: number
24
+ /** The executable's name; Linux only. */
25
+ comm?: string
23
26
  /** Resident KiB when the table carries it; Linux reads it per tree member. */
24
27
  rssKiB?: number
25
28
  }
@@ -32,7 +35,11 @@ interface Proc {
32
35
  const TICK_MS = 10
33
36
 
34
37
  /** Each root's tree usage; a root no longer alive has no entry. */
35
- export async function sampleTrees(roots: readonly number[]): Promise<Map<number, TreeUsage>> {
38
+ export async function sampleTrees(
39
+ roots: readonly number[],
40
+ /** Members not counted (their children still are): the sandbox's own tracer. */
41
+ skip?: ReadonlySet<string>,
42
+ ): Promise<Map<number, TreeUsage>> {
36
43
  const out = new Map<number, TreeUsage>()
37
44
  if (roots.length === 0) return out
38
45
  const table = process.platform === 'linux' ? await linuxTable() : await psTable()
@@ -49,7 +56,8 @@ export async function sampleTrees(roots: readonly number[]): Promise<Map<number,
49
56
  const stack = [root]
50
57
  while (stack.length > 0) {
51
58
  const pid = stack.pop()!
52
- tree.push(pid)
59
+ const comm = table.get(pid)!.comm
60
+ if (skip === undefined || comm === undefined || !skip.has(comm)) tree.push(pid)
53
61
  for (const c of children.get(pid) ?? []) stack.push(c)
54
62
  }
55
63
  let cpuMs = 0
@@ -86,12 +94,16 @@ async function linuxTable(): Promise<Map<number, Proc> | undefined> {
86
94
  }
87
95
  // `<pid> (<comm>) <state> <ppid> …` — comm may hold spaces and parens,
88
96
  // so the fields start after the LAST ')': state is [0], ppid [1],
89
- // utime [11], stime [12].
90
- const f = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
97
+ // utime [11], stime [12], cutime [13], cstime [14]. The reaped
98
+ // children's share keeps a tree's sum from dropping when a member
99
+ // exits: what a task's short-lived processes used stays counted.
100
+ const close = stat.lastIndexOf(')')
101
+ const f = stat.slice(close + 2).split(' ')
91
102
  if (f[0] === 'Z' || f[0] === 'X') return
92
103
  table.set(Number(entry), {
93
104
  ppid: Number(f[1]),
94
- cpuMs: (Number(f[11]) + Number(f[12])) * TICK_MS,
105
+ cpuMs: (Number(f[11]) + Number(f[12]) + Number(f[13]) + Number(f[14])) * TICK_MS,
106
+ comm: stat.slice(stat.indexOf('(') + 1, close),
95
107
  })
96
108
  }),
97
109
  )
@@ -113,7 +125,9 @@ async function linuxRssKiB(pid: number): Promise<number> {
113
125
  async function psTable(): Promise<Map<number, Proc> | undefined> {
114
126
  let text: string
115
127
  try {
128
+ // It runs beside the tasks, readable by them (`ps eww`): PATH alone.
116
129
  const ps = Bun.spawn(['ps', '-A', '-o', 'pid=,ppid=,rss=,time='], {
130
+ env: helperEnv(),
117
131
  stdin: 'ignore',
118
132
  stdout: 'pipe',
119
133
  stderr: 'ignore',
@@ -153,3 +167,53 @@ export function psTimeMs(s: string): number | undefined {
153
167
  for (const n of parts) seconds = seconds * 60 + n
154
168
  return Math.round((days * 86_400 + seconds) * 1000)
155
169
  }
170
+
171
+ /** How often a watched tree is looked at; one timer serves every tree. */
172
+ const WATCH_EVERY_MS = 250
173
+
174
+ const watched = new Map<number, TreeUsage & { looks: number }>()
175
+ let watchTimer: ReturnType<typeof setInterval> | undefined
176
+ let looking = false
177
+ const SKIP = new Set(['strace'])
178
+
179
+ async function look(): Promise<void> {
180
+ if (looking) return
181
+ looking = true
182
+ try {
183
+ const usage = await sampleTrees([...watched.keys()], SKIP)
184
+ for (const [pid, u] of usage) {
185
+ const w = watched.get(pid)
186
+ if (w === undefined) continue
187
+ w.cpuMs = Math.max(w.cpuMs, u.cpuMs)
188
+ w.rssBytes = Math.max(w.rssBytes, u.rssBytes)
189
+ w.looks++
190
+ }
191
+ } catch {
192
+ // A failed look is a missing point.
193
+ } finally {
194
+ looking = false
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Watch a running tree until `stop`, which answers the most CPU and the
200
+ * peak resident memory any look saw; none when no look landed (a task
201
+ * shorter than one interval). The sandbox's answer on Linux, where the
202
+ * wait never sees what bwrap's pid namespace used.
203
+ */
204
+ export function watchTree(pid: number): { stop(): TreeUsage | undefined } {
205
+ const w = { cpuMs: 0, rssBytes: 0, looks: 0 }
206
+ watched.set(pid, w)
207
+ watchTimer ??= setInterval(() => void look(), WATCH_EVERY_MS)
208
+ watchTimer.unref?.()
209
+ return {
210
+ stop() {
211
+ if (watched.get(pid) === w) watched.delete(pid)
212
+ if (watched.size === 0 && watchTimer !== undefined) {
213
+ clearInterval(watchTimer)
214
+ watchTimer = undefined
215
+ }
216
+ return w.looks === 0 ? undefined : { cpuMs: w.cpuMs, rssBytes: w.rssBytes }
217
+ },
218
+ }
219
+ }