@vzn/vx 0.0.644 → 0.0.646

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 (83) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +22 -48
  3. package/package.json +5 -5
  4. package/schemas/init.json +5 -0
  5. package/schemas/last.json +15 -0
  6. package/skills/vx/SKILL.md +1 -1
  7. package/src/bin.ts +1 -1
  8. package/src/cache/archive.ts +12 -76
  9. package/src/cache/cache.ts +72 -42
  10. package/src/cache/git-inputs.ts +55 -10
  11. package/src/cache/index.ts +5 -2
  12. package/src/cache/inputs.ts +61 -11
  13. package/src/cache/layer.ts +30 -8
  14. package/src/cache/layered-cache.ts +138 -44
  15. package/src/cache/output-index.ts +25 -11
  16. package/src/cache/plugin-cache.ts +9 -14
  17. package/src/cache/remote-lane.ts +60 -22
  18. package/src/cache/run-history.ts +13 -6
  19. package/src/cache/schema.ts +19 -2
  20. package/src/cache/scoped-cache.ts +3 -80
  21. package/src/cli/core-alias.ts +5 -2
  22. package/src/cli/help.ts +8 -10
  23. package/src/cli/index.ts +0 -12
  24. package/src/cli/init.ts +159 -25
  25. package/src/cli/last.ts +4 -2
  26. package/src/cli/plan-format.ts +6 -2
  27. package/src/cli/plugin-commands.ts +98 -6
  28. package/src/cli/plugin-templates.ts +2 -2
  29. package/src/cli/run.ts +2 -8
  30. package/src/cli/select.ts +5 -3
  31. package/src/cli/upgrade.ts +5 -6
  32. package/src/config.ts +2 -4
  33. package/src/exec/executor.ts +112 -9
  34. package/src/exec/index.ts +2 -0
  35. package/src/exec/proc-sample.ts +67 -5
  36. package/src/exec/runner.ts +4 -0
  37. package/src/exec/sandbox-runtime.ts +11 -4
  38. package/src/graph/scheduler.ts +59 -8
  39. package/src/history/history.ts +119 -7
  40. package/src/history/metrics.ts +53 -16
  41. package/src/index.ts +16 -1
  42. package/src/orchestrator/affected-tasks.ts +0 -18
  43. package/src/orchestrator/execute-args.ts +3 -0
  44. package/src/orchestrator/execute-task.ts +28 -7
  45. package/src/orchestrator/held-servers.ts +86 -0
  46. package/src/orchestrator/hit-restore.ts +11 -4
  47. package/src/orchestrator/index.ts +18 -1
  48. package/src/orchestrator/local-floor.ts +104 -0
  49. package/src/orchestrator/local-shortcircuit.ts +26 -7
  50. package/src/orchestrator/miss-save.ts +10 -4
  51. package/src/orchestrator/options.ts +0 -8
  52. package/src/orchestrator/persistent-task.ts +14 -0
  53. package/src/orchestrator/placement.ts +84 -9
  54. package/src/orchestrator/plan.ts +7 -1
  55. package/src/orchestrator/plugin-host.ts +22 -7
  56. package/src/orchestrator/plugin.ts +48 -7
  57. package/src/orchestrator/prepare.ts +14 -49
  58. package/src/orchestrator/run-context.ts +39 -1
  59. package/src/orchestrator/run-keys.ts +164 -0
  60. package/src/orchestrator/run-records.ts +6 -0
  61. package/src/orchestrator/run.ts +95 -6
  62. package/src/orchestrator/sandbox-request.ts +9 -3
  63. package/src/orchestrator/task-artifacts.ts +142 -0
  64. package/src/orchestrator/task-hash.ts +12 -4
  65. package/src/orchestrator/task-process.ts +16 -0
  66. package/src/orchestrator/telemetry-host.ts +2 -1
  67. package/src/orchestrator/telemetry.ts +60 -17
  68. package/src/report/forecast.ts +6 -27
  69. package/src/report/framed-output.ts +41 -5
  70. package/src/report/logger.ts +60 -20
  71. package/src/report/status-line.ts +38 -18
  72. package/src/report/summary.ts +75 -9
  73. package/src/settings.ts +8 -7
  74. package/src/util/colors.ts +16 -1
  75. package/src/util/index.ts +1 -0
  76. package/src/util/paths.ts +30 -0
  77. package/src/util/ui.ts +4 -1
  78. package/src/workspace/affected.ts +0 -39
  79. package/src/workspace/config-bytecode.ts +276 -0
  80. package/src/workspace/index.ts +0 -2
  81. package/src/workspace/migration.ts +4 -32
  82. package/src/workspace/project-loader.ts +92 -14
  83. package/src/cli/foreign-flags.ts +0 -727
@@ -3,9 +3,10 @@
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
@@ -14,7 +15,7 @@
14
15
 
15
16
  import type { CaptureConfig, RunResult } from './runner.js'
16
17
  import type { ResolvedSandboxConfig, SandboxViolation } from './sandbox-runtime.js'
17
- import { UserError } from '../util/index.js'
18
+ import { UserError, type ui } from '../util/index.js'
18
19
 
19
20
  /** Sandbox baselines + the user's resolved sandbox block, when the task is sandboxed. */
20
21
  export interface ExecuteSandbox {
@@ -34,6 +35,12 @@ export interface ExecuteSandbox {
34
35
  */
35
36
  readonly reportLinked: readonly string[]
36
37
  readonly config: ResolvedSandboxConfig
38
+ /**
39
+ * A cached task's: the project directories its key answers for, which
40
+ * an executor that builds the request again where it runs, on that
41
+ * machine's paths and platform, hands `sandboxRequestFor`.
42
+ */
43
+ readonly keyed?: readonly string[]
37
44
  }
38
45
 
39
46
  /** One declared input file, as the cache key saw it. */
@@ -77,6 +84,18 @@ export interface TaskInputs {
77
84
  readonly configDigest: string
78
85
  }
79
86
 
87
+ /** A persistent dependency a `servers` executor starts where it runs the task, as `runPersistent` does. */
88
+ export interface ServerSpec {
89
+ readonly taskId: string
90
+ readonly command: string
91
+ readonly cwd: string
92
+ readonly env: NodeJS.ProcessEnv
93
+ /** `exec.persistent.readyWhen`: ready at the first output line it matches; absent, at spawn. */
94
+ readonly readyWhen?: string
95
+ /** Bounds the readiness wait. */
96
+ readonly timeoutMs?: number
97
+ }
98
+
80
99
  export interface ExecuteRequest {
81
100
  /** `${project}#${task}` — for executors that route or log by task. */
82
101
  readonly taskId: string
@@ -162,6 +181,12 @@ export interface ExecuteRequest {
162
181
  * core's local executor is ever handed such a task (placement.ts).
163
182
  */
164
183
  readonly terminal?: true
184
+ /**
185
+ * The persistent tasks it depends on, through groups, for a `servers`
186
+ * executor: each started where the command runs, ready before it, and
187
+ * shared by the tasks that run there. Absent for any other executor.
188
+ */
189
+ readonly servers?: readonly ServerSpec[]
165
190
  }
166
191
 
167
192
  export interface ExecuteResult extends RunResult {
@@ -180,9 +205,16 @@ export interface ExecuteResult extends RunResult {
180
205
  * Executor-reported placement label — which machine ran the command
181
206
  * (a REAPI worker id, a pool member). Absent = this host. Rides
182
207
  * `TaskOutcome.where` into telemetry so a task can be attributed to
183
- * its worker; never persisted to the analytics store.
208
+ * its worker, onto its row and frame (`<id>@<where>`), and into the
209
+ * run history (v44).
184
210
  */
185
211
  readonly where?: string
212
+ /**
213
+ * The cores and memory of the machine `where` names, as it reports
214
+ * them: the run history keeps them, so a task that used all of one
215
+ * reads as wanting a bigger one (`TaskResources.saturated*`).
216
+ */
217
+ readonly machine?: { readonly cores: number; readonly memoryBytes: number }
186
218
  }
187
219
 
188
220
  /** What an executor sees when a task is PLACED — once per task, before scheduling. */
@@ -193,9 +225,10 @@ export interface TaskPlacement {
193
225
  readonly command: string
194
226
  /**
195
227
  * Must run on this machine: the task is persistent, depends (transitively)
196
- * on a persistent or sandboxed task, is sandboxed itself, declares
228
+ * on a persistent task this executor does not carry or a sandboxed one, is sandboxed itself, declares
197
229
  * `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.
230
+ * machine's `node -v`) and this executor is not `runtime`. A `remote`
231
+ * executor is never offered such a task.
199
232
  */
200
233
  readonly pinnedLocal: boolean
201
234
  /** Declares `cache` — the only tasks whose input set is described, and so the only ones that can ship. */
@@ -207,6 +240,29 @@ export interface TaskExecutor {
207
240
  readonly name: string
208
241
  /** Runs the command somewhere else. Never offered a `pinnedLocal` task. */
209
242
  readonly remote?: boolean
243
+ /**
244
+ * A `remote` executor that enforces `ExecuteRequest.sandbox` where it
245
+ * runs the command, as the local one does. It is offered sandboxed tasks
246
+ * and their dependants; the request's violations come back on the result.
247
+ */
248
+ readonly sandbox?: boolean
249
+ /**
250
+ * A `remote` executor that starts a task's persistent dependencies where
251
+ * it runs the task (`ExecuteRequest.servers`). It is offered the
252
+ * dependants of a persistent task with no `exec.sandbox` and no
253
+ * `exec.remote: false`; that task then runs here only for a dependant
254
+ * placed here (owner, 2026-10-22: nothing runs on a distributed run's
255
+ * orchestrator).
256
+ */
257
+ readonly servers?: boolean
258
+ /**
259
+ * A `remote` executor that answers a task's `inputs.runtime` and
260
+ * `workspaceRuntime` probes where it runs the task (`answerRuntime`) and
261
+ * fails it when one answers other than `TaskInputs` says this machine
262
+ * did. It is offered tasks whose key folds a probe, which otherwise run
263
+ * here: their key holds this machine's answer (C-2).
264
+ */
265
+ readonly runtime?: boolean
210
266
  /**
211
267
  * How many tasks this executor runs at once. Its tasks then occupy a pool
212
268
  * of this size instead of the local worker slots, so a remote pool is not
@@ -230,9 +286,51 @@ export interface TaskExecutor {
230
286
  * for it.
231
287
  */
232
288
  demand?(remaining: ReadonlySet<string>): void
289
+ /**
290
+ * Rows the run's footer shows for this executor, `[label, text]`, the
291
+ * 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
292
+ * frame of the live footer and once for the final one, so it is cheap;
293
+ * a throw shows nothing.
294
+ */
295
+ footer?(): readonly (readonly [label: string, text: ui.Inline])[]
296
+ /**
297
+ * The machines it ran the run's tasks on (`small#1`, `local`), for the
298
+ * run summary telemetry sinks read; asked once at the end, only when a
299
+ * sink is on. A throw reports none.
300
+ */
301
+ machines?(): readonly MachineReport[]
302
+ /**
303
+ * Where a task it was handed runs (`small#2`), for the live row; asked
304
+ * per frame. `null` while it holds the task for a machine to begin it:
305
+ * the row says `waiting`, and its clock starts when a machine begins it.
306
+ */
307
+ running?(taskId: string): string | null | undefined
233
308
  execute(req: ExecuteRequest): Promise<ExecuteResult>
234
309
  }
235
310
 
311
+ /** One machine an executor ran tasks on, as the run summary reports it. */
312
+ export interface MachineReport {
313
+ /** As task rows name it (`small#1`). */
314
+ name: string
315
+ /** `online`, or `gone` for one that left before the run ended. */
316
+ status: string
317
+ cores: number
318
+ memoryBytes: number
319
+ done: number
320
+ failed: number
321
+ onlineMs: number
322
+ /** Time with at least one task running. */
323
+ busyMs: number
324
+ /** Cores in use at once, at the most and on average over its time online. */
325
+ cpuPeak: number
326
+ cpuAvg: number
327
+ /** Bytes in use at once, at the most and on average. */
328
+ memoryPeakBytes: number
329
+ memoryAvgBytes: number
330
+ /** Core-time its tasks used over the core-time it had, 0 to 1. */
331
+ utilization: number
332
+ }
333
+
236
334
  /**
237
335
  * What a remote executor rejects with when it gives a task back: core runs
238
336
  * the same request on the local floor and says `reason` once (a remote that
@@ -307,13 +405,18 @@ export function selectExecutor(
307
405
  executors: readonly TaskExecutor[],
308
406
  task: TaskPlacement,
309
407
  label: (executor: TaskExecutor) => string = (e) => `executor '${e.name}'`,
408
+ /** `task.pinnedLocal` as `executor` sees it: one that enforces the sandbox or carries servers lifts some pins. */
409
+ pinnedFor: (executor: TaskExecutor) => boolean = () => task.pinnedLocal,
310
410
  ): TaskExecutor {
311
411
  for (const executor of executors) {
312
- if (task.pinnedLocal && executor.remote === true) continue
412
+ const pinned = pinnedFor(executor)
413
+ if (pinned && executor.remote === true) continue
313
414
  if (executor.accepts === undefined) return executor
314
415
  let taken: unknown
315
416
  try {
316
- taken = executor.accepts(task)
417
+ taken = executor.accepts(
418
+ pinned === task.pinnedLocal ? task : { ...task, pinnedLocal: pinned },
419
+ )
317
420
  // An `async accepts` answers a Promise: truthy, so the executor took
318
421
  // every task, and a rejection went unheard (the shape of H-16's admit).
319
422
  if (taken instanceof Promise) {
package/src/exec/index.ts CHANGED
@@ -59,6 +59,8 @@ export {
59
59
  type ExecuteResult,
60
60
  type ExecuteSandbox,
61
61
  type InputFile,
62
+ type MachineReport,
63
+ type ServerSpec,
62
64
  type TaskExecutor,
63
65
  type TaskInputs,
64
66
  type TaskPlacement,
@@ -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
  )
@@ -153,3 +165,53 @@ export function psTimeMs(s: string): number | undefined {
153
165
  for (const n of parts) seconds = seconds * 60 + n
154
166
  return Math.round((days * 86_400 + seconds) * 1000)
155
167
  }
168
+
169
+ /** How often a watched tree is looked at; one timer serves every tree. */
170
+ const WATCH_EVERY_MS = 250
171
+
172
+ const watched = new Map<number, TreeUsage & { looks: number }>()
173
+ let watchTimer: ReturnType<typeof setInterval> | undefined
174
+ let looking = false
175
+ const SKIP = new Set(['strace'])
176
+
177
+ async function look(): Promise<void> {
178
+ if (looking) return
179
+ looking = true
180
+ try {
181
+ const usage = await sampleTrees([...watched.keys()], SKIP)
182
+ for (const [pid, u] of usage) {
183
+ const w = watched.get(pid)
184
+ if (w === undefined) continue
185
+ w.cpuMs = Math.max(w.cpuMs, u.cpuMs)
186
+ w.rssBytes = Math.max(w.rssBytes, u.rssBytes)
187
+ w.looks++
188
+ }
189
+ } catch {
190
+ // A failed look is a missing point.
191
+ } finally {
192
+ looking = false
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Watch a running tree until `stop`, which answers the most CPU and the
198
+ * peak resident memory any look saw; none when no look landed (a task
199
+ * shorter than one interval). The sandbox's answer on Linux, where the
200
+ * wait never sees what bwrap's pid namespace used.
201
+ */
202
+ export function watchTree(pid: number): { stop(): TreeUsage | undefined } {
203
+ const w = { cpuMs: 0, rssBytes: 0, looks: 0 }
204
+ watched.set(pid, w)
205
+ watchTimer ??= setInterval(() => void look(), WATCH_EVERY_MS)
206
+ watchTimer.unref?.()
207
+ return {
208
+ stop() {
209
+ if (watched.get(pid) === w) watched.delete(pid)
210
+ if (watched.size === 0 && watchTimer !== undefined) {
211
+ clearInterval(watchTimer)
212
+ watchTimer = undefined
213
+ }
214
+ return w.looks === 0 ? undefined : { cpuMs: w.cpuMs, rssBytes: w.rssBytes }
215
+ },
216
+ }
217
+ }
@@ -932,6 +932,10 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
932
932
  new Promise((resolve) => setTimeout(resolve, POST_EXIT_DRAIN_MS).unref?.()),
933
933
  ])
934
934
  }
935
+ // A clean exit is ready: a setup step (migrate, seed, a `docker
936
+ // compose up --wait`) has done its work, and what it started, if
937
+ // anything, is torn down with the run (M 2026-10-22).
938
+ if (code === 0) markReady()
935
939
  if (readyAt === undefined && gaveUpAt === undefined) {
936
940
  rejectReady(
937
941
  new PersistentReadyError(
@@ -59,6 +59,7 @@ import {
59
59
  type CaptureConfig,
60
60
  type RunResult,
61
61
  } from './runner.js'
62
+ import { watchTree } from './proc-sample.js'
62
63
  import {
63
64
  BUN_GLOB_WILDCARDS,
64
65
  executablePath,
@@ -2074,6 +2075,7 @@ async function runSandboxedOnce(
2074
2075
 
2075
2076
  args.liveChildren?.add(proc)
2076
2077
  args.onSpawn?.(proc.pid)
2078
+ const watch = process.platform === 'linux' ? watchTree(proc.pid) : undefined
2077
2079
  const timeout = armTimeout(proc, args.timeoutMs)
2078
2080
  const armedAt = Date.now()
2079
2081
  const ac = new AbortController()
@@ -2102,6 +2104,7 @@ async function runSandboxedOnce(
2102
2104
  // aborts at once, otherwise drainOrAbort bounds the post-exit drain.
2103
2105
  await proc.exited
2104
2106
  const ranMs = Date.now() - armedAt
2107
+ const looked = watch?.stop()
2105
2108
  await timeout.settle()
2106
2109
  let cut = false
2107
2110
  if (timeout.timedOut()) ac.abort()
@@ -2276,10 +2279,14 @@ async function runSandboxedOnce(
2276
2279
  // processes used is not folded into bwrap's usage: a 500 ms busy loop
2277
2280
  // read 2 ms under it and 510 without the flag, and the peak read was
2278
2281
  // vx's own high-water mark, inherited at exec (a sandboxed `true` from a
2279
- // 300 MB vx: 353 MB; B-3). Nothing is reported rather than a number
2280
- // that is someone else's. macOS's `sandbox-exec` execs the command, so
2281
- // its usage is the task's.
2282
- ...(process.platform === 'linux' ? {} : resourceUsageToCpuRss(proc.resourceUsage())),
2282
+ // 300 MB vx: 353 MB; B-3). So the tree is looked at while it runs
2283
+ // (`watchTree`); a task over before the first look reports nothing.
2284
+ // macOS's `sandbox-exec` execs the command, so its usage is the task's.
2285
+ ...(process.platform !== 'linux'
2286
+ ? resourceUsageToCpuRss(proc.resourceUsage())
2287
+ : looked === undefined
2288
+ ? {}
2289
+ : { cpuMs: looked.cpuMs, peakRssBytes: looked.rssBytes }),
2283
2290
  ranMs,
2284
2291
  tracerFailed:
2285
2292
  straceLog !== undefined &&
@@ -98,6 +98,12 @@ export interface TaskOutcome {
98
98
  /** v11 analytics: CPU time + peak RSS for this task's child process. */
99
99
  cpuMs?: number
100
100
  peakRssBytes?: number
101
+ /**
102
+ * The most cores one sampling interval burned and the mean resident
103
+ * memory over the looks; only when a sink samples and a look landed.
104
+ */
105
+ peakCores?: number
106
+ avgRssBytes?: number
101
107
  /**
102
108
  * A GROUP task's own dependency outcomes — the tasks it stands for.
103
109
  * A group has no `exec` and therefore no outputs and no cache entry: its
@@ -116,11 +122,11 @@ export interface TaskOutcome {
116
122
  */
117
123
  unkeyed?: true
118
124
  /**
119
- * This task's key folds a task with no `inputs` (or it is one): no run
120
- * can derive that key again, so it neither read nor saved an entry, and a
121
- * dependant that folds `hash` does neither.
125
+ * The task with no `inputs` this task's key folds (this task, or one
126
+ * upstream): no run can derive that key again, so it neither read nor
127
+ * saved an entry, and a dependant that folds `hash` does neither.
122
128
  */
123
- keyless?: true
129
+ keylessBy?: string
124
130
  /**
125
131
  * The run's cache policy read and wrote nothing (`--no-cache`,
126
132
  * `--cache-policy=local:`): no cache answered for this task, so a run of it is
@@ -166,8 +172,11 @@ export interface TaskOutcome {
166
172
  */
167
173
  inputMoved?: string
168
174
  /** Executor-reported placement label (`ExecuteResult.where`) — set only
169
- * when the task ran somewhere other than this host. Telemetry-only. */
175
+ * when the task ran somewhere other than this host: telemetry and the task's row. */
170
176
  where?: string
177
+ /** The cores and memory of the machine `where` names (`ExecuteResult.machine`). */
178
+ whereCores?: number
179
+ whereMemoryBytes?: number
171
180
  /** `'deferred'` when the task's outputs were left in the remote store
172
181
  * (`--cache-download=none`) instead of landing on this machine. Absent for
173
182
  * every ordinary outcome. Telemetry-only. */
@@ -344,6 +353,14 @@ export interface ScheduleOptions {
344
353
  * second cache.get. When undefined/empty, behavior is byte-identical.
345
354
  */
346
355
  restoreTier?: ReadonlySet<string>
356
+ /**
357
+ * Servers held back until the caller knows whether their dependants are
358
+ * hits a remote holds. Each resolves to the dependants it has added to
359
+ * `restoreTier` since the run began (the caller's set, read live); a
360
+ * server whose every dependant is then in the tier is idle, as above,
361
+ * and any other is dispatched as usual.
362
+ */
363
+ heldServers?: ReadonlyMap<string, Promise<readonly string[]>>
347
364
  /**
348
365
  * Pool assignment for tasks placed on an executor with its own capacity
349
366
  * (a remote pool). Such a task occupies one of the pool's `capacity` slots
@@ -573,6 +590,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
573
590
  // without an outcome: it is not part of the run, as a pruned task is not.
574
591
  const idle = idleServers(nodes, dependents, restoreTier)
575
592
  const dormant = new Set<string>()
593
+ // Held servers not yet answered, and those of them that became ready.
594
+ const unanswered = new Set(options.heldServers?.keys() ?? [])
595
+ for (const id of idle) unanswered.delete(id)
596
+ const heldReady = new Set<string>()
576
597
  let settledIdle = 0
577
598
  const execReady = new ReadyHeap(priority)
578
599
  const restoreReady = new ReadyHeap(priority)
@@ -595,6 +616,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
595
616
  else continue
596
617
  readyAt.set(node.id, startedAt)
597
618
  }
619
+ const promoted = new Set<string>()
598
620
 
599
621
  let active = 0
600
622
  // Restore-tier work is disk I/O — a confirmed hit's tar extract is ~8
@@ -613,7 +635,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
613
635
  serial ? active + activeRestore < 1 : activeRestore < restoreConcurrency
614
636
  let resolved = false
615
637
  // A refusal's text → the first task that said it. A corrupt cache index
616
- // reaches every lookup as one UserError; a repeat names the first.
638
+ // reaches every lookup as one UserError; on bare stderr a repeat names the first.
617
639
  const refusedBy = new Map<string, string>()
618
640
 
619
641
  // Admission policy over the count limit (a plugin's `admit`). Inactive
@@ -863,6 +885,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
863
885
  while (execReady.size > 0) {
864
886
  const seq = execReady.peekSeq()
865
887
  const id = execReady.pop() as string
888
+ if (unanswered.has(id) && !willSkip(id)) {
889
+ heldReady.add(id)
890
+ continue
891
+ }
866
892
  if (willSkip(id) || idle.has(id)) return id
867
893
  if (exclusive?.has(id) === true) {
868
894
  if (!busy() && (!admitActive || admits(id))) return id
@@ -1045,8 +1071,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1045
1071
  const text = isUserError(err)
1046
1072
  ? message
1047
1073
  : `${message} — ${isOutOfFds(err) ? OUT_OF_FDS_HINT : fsRefusalHint(err)}`
1048
- const first = refusedBy.get(text)
1049
- if (first === undefined) refusedBy.set(text, id)
1074
+ // In a task's own output block, in full: that block is read
1075
+ // alone, and "as <task> above" named one printed elsewhere or
1076
+ // not at all (M, 2026-10-22). On bare stderr the first is above.
1077
+ const first = options.onError === undefined ? refusedBy.get(text) : undefined
1078
+ if (!refusedBy.has(text)) refusedBy.set(text, id)
1050
1079
  line = `[vx] ${id}: ${first === undefined ? text : `as ${first} above`}\n`
1051
1080
  } else {
1052
1081
  const named = err instanceof Error && err.name !== 'Error' ? `${err.name}: ` : ''
@@ -1089,6 +1118,28 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1089
1118
  }
1090
1119
  }
1091
1120
 
1121
+ // A held server's answer: the dependants it found go on the restore
1122
+ // lane, and the server is idle when they were all of them.
1123
+ const answer = (id: string, found: readonly string[]): void => {
1124
+ if (resolved || !unanswered.delete(id)) return
1125
+ for (const d of found) {
1126
+ if (promoted.has(d) || outcomes.has(d) || !inRestoreTier(d)) continue
1127
+ promoted.add(d)
1128
+ restoreReady.push(d)
1129
+ readyAt.set(d, Date.now())
1130
+ }
1131
+ if (dependents.get(id)?.every((d) => inRestoreTier(d)) === true) idle.add(id)
1132
+ if (heldReady.delete(id)) execReady.push(id)
1133
+ tick()
1134
+ }
1135
+ for (const [id, found] of options.heldServers ?? []) {
1136
+ if (unanswered.has(id))
1137
+ void found.then(
1138
+ (ids) => answer(id, ids),
1139
+ () => answer(id, []),
1140
+ )
1141
+ }
1142
+
1092
1143
  tick()
1093
1144
  })
1094
1145
  }