@vzn/vx 0.0.650 → 0.0.651

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 (115) hide show
  1. package/README.md +5 -20
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/src/bin.ts +9 -31
  5. package/src/cache/cache.ts +202 -25
  6. package/src/cache/file-hashes.ts +3 -0
  7. package/src/cache/git-inputs.ts +188 -39
  8. package/src/cache/index.ts +1 -0
  9. package/src/cache/inputs.ts +3 -0
  10. package/src/cache/layer.ts +11 -0
  11. package/src/cache/layered-cache.ts +4 -0
  12. package/src/cache/linked-inputs.ts +3 -0
  13. package/src/cache/lockfile-memo.ts +3 -2
  14. package/src/cache/output-index.ts +5 -2
  15. package/src/cache/remote-lane.ts +2 -1
  16. package/src/cache/run-history.ts +11 -1
  17. package/src/cache/schema.ts +11 -1
  18. package/src/cli/cache.ts +26 -16
  19. package/src/cli/completions.ts +4 -2
  20. package/src/cli/docs.ts +10 -11
  21. package/src/cli/help.ts +12 -23
  22. package/src/cli/index.ts +4 -9
  23. package/src/cli/info.ts +15 -7
  24. package/src/cli/init.ts +23 -15
  25. package/src/cli/last.ts +213 -203
  26. package/src/cli/lock.ts +55 -5
  27. package/src/cli/plan-format.ts +100 -118
  28. package/src/cli/plugin-commands.ts +8 -2
  29. package/src/cli/plugin-templates.ts +2 -2
  30. package/src/cli/run.ts +46 -27
  31. package/src/cli/select.ts +43 -5
  32. package/src/cli/show.ts +38 -42
  33. package/src/cli/task-verb.ts +22 -0
  34. package/src/cli/upgrade.ts +3 -1
  35. package/src/cli/watch-fs.ts +6 -1
  36. package/src/cli/watch-judge.ts +2 -1
  37. package/src/cli/watch.ts +83 -44
  38. package/src/cli/why.ts +123 -89
  39. package/src/cli/workspace-config.ts +2 -1
  40. package/src/exec/executor.ts +2 -1
  41. package/src/exec/kill-tree.ts +58 -5
  42. package/src/exec/local-executor.ts +1 -1
  43. package/src/exec/pipes.ts +152 -0
  44. package/src/exec/runner.ts +199 -182
  45. package/src/exec/sandbox-binds.ts +1 -1
  46. package/src/exec/sandbox-runtime.ts +11 -2
  47. package/src/exec/spawn-off.ts +154 -0
  48. package/src/exec/warm-shell.ts +146 -0
  49. package/src/graph/critical-path.ts +40 -28
  50. package/src/graph/priorities.ts +98 -44
  51. package/src/graph/scheduler.ts +14 -10
  52. package/src/graph/task-graph.ts +27 -1
  53. package/src/handoff.ts +10 -7
  54. package/src/history/history.ts +5 -0
  55. package/src/history/metrics.ts +9 -1
  56. package/src/index.ts +3 -1
  57. package/src/orchestrator/doctor.ts +1 -1
  58. package/src/orchestrator/execute-task.ts +16 -2
  59. package/src/orchestrator/graph-memo.ts +50 -1
  60. package/src/orchestrator/graph-stage.ts +6 -0
  61. package/src/orchestrator/index.ts +3 -0
  62. package/src/orchestrator/live-usage.ts +16 -11
  63. package/src/orchestrator/locked-digests.ts +63 -0
  64. package/src/orchestrator/lockfile-claim.ts +108 -29
  65. package/src/orchestrator/miss-save.ts +31 -3
  66. package/src/orchestrator/persistent-task.ts +3 -1
  67. package/src/orchestrator/plan.ts +5 -1
  68. package/src/orchestrator/plugin-host.ts +61 -0
  69. package/src/orchestrator/plugin.ts +47 -2
  70. package/src/orchestrator/prepare.ts +16 -1
  71. package/src/orchestrator/projects.ts +4 -1
  72. package/src/orchestrator/run-context.ts +29 -10
  73. package/src/orchestrator/run-failures.ts +1 -1
  74. package/src/orchestrator/run-records.ts +10 -0
  75. package/src/orchestrator/run.ts +53 -22
  76. package/src/orchestrator/sandbox-request.ts +2 -2
  77. package/src/orchestrator/signals.ts +8 -5
  78. package/src/orchestrator/task-hash.ts +17 -3
  79. package/src/report/events.ts +3 -11
  80. package/src/report/forecast.ts +10 -32
  81. package/src/report/framed-output.ts +272 -173
  82. package/src/report/index.ts +8 -3
  83. package/src/report/logger.ts +73 -38
  84. package/src/report/plain-output.ts +1 -1
  85. package/src/report/status-line.ts +49 -357
  86. package/src/report/summary.ts +100 -131
  87. package/src/ui/ansi.ts +48 -0
  88. package/src/ui/components.ts +506 -0
  89. package/src/ui/index.ts +79 -0
  90. package/src/ui/kit.ts +37 -0
  91. package/src/ui/live-region.ts +363 -0
  92. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  93. package/src/ui/terminal.ts +51 -0
  94. package/src/ui/theme.ts +224 -0
  95. package/src/ui/version-line.ts +28 -0
  96. package/src/util/config-frame.ts +65 -10
  97. package/src/util/edit-distance.ts +6 -1
  98. package/src/util/index.ts +2 -15
  99. package/src/util/paths.ts +20 -2
  100. package/src/util/secret-mask.ts +39 -3
  101. package/src/util/settle.ts +31 -0
  102. package/src/util/timing.ts +4 -16
  103. package/src/workspace/affected.ts +89 -30
  104. package/src/workspace/config-schema.ts +4 -2
  105. package/src/workspace/dependencies-lock.ts +191 -0
  106. package/src/workspace/filter.ts +28 -0
  107. package/src/workspace/index.ts +9 -0
  108. package/src/workspace/lockfile.ts +159 -6
  109. package/src/workspace/migration.ts +4 -3
  110. package/src/workspace/workspace.ts +3 -0
  111. package/src/util/colors.ts +0 -133
  112. package/src/util/report.ts +0 -44
  113. package/src/util/ui.ts +0 -258
  114. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  115. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -0,0 +1,146 @@
1
+ // A task's shell, started before the task is asked for. A slot that frees
2
+ // on a cold run starts its next task at once, and sh's own exec was part of
3
+ // that turn: 200 `sleep 0.02` took 4.4 ms each past their sleep spawned
4
+ // fresh, 2.7 ms in a shell already waiting (2026-11-01). The spare holds
5
+ // no task's directory or env: it waits on a pipe, and the task's script,
6
+ // written when it is taken, moves it to the task's directory and exports
7
+ // the task's env before the command runs, so each task still has a shell
8
+ // of its own (its CPU and memory are the shell's) and vx keeps no process
9
+ // beyond a run. Linux only, where a task's pipes are vx's own descriptors.
10
+
11
+ import { closeSync, writeSync } from 'node:fs'
12
+ import { taskShell } from '../util/index.js'
13
+ import { flushGuard, listGroup } from './kill-tree.js'
14
+ import { heldPipe, TaskPipes } from './pipes.js'
15
+ import { type OffChild, spawnOff } from './spawn-off.js'
16
+
17
+ /** A shell waiting for its task's script on descriptor 3. */
18
+ export interface Spare {
19
+ readonly child: Promise<OffChild>
20
+ readonly pipes: TaskPipes
21
+ /** The write end of the script's pipe; closed once the script is in. */
22
+ readonly script: number
23
+ }
24
+
25
+ /**
26
+ * Spares kept waiting. Two: a cold run's slots free one at a time, and a
27
+ * burst (the run's first tasks) spawns fresh while the pool fills.
28
+ */
29
+ const SPARES = 2
30
+
31
+ const spares: Spare[] = []
32
+ /** A spare that could not start (no libc pipes, a refused spawn): no more are tried. */
33
+ let off = false
34
+
35
+ // `exec 3<&-` first: dash reads the script through its own descriptor,
36
+ // opened on /dev/fd/3 and closed on exec, so the task inherits no 3.
37
+ const WAIT = '. /dev/fd/3'
38
+
39
+ function start(): Spare | undefined {
40
+ const fds = heldPipe()
41
+ if (fds === undefined) return undefined
42
+ let sh: string
43
+ try {
44
+ sh = taskShell()
45
+ } catch {
46
+ // No shell on vx's PATH: the task's own spawn says so.
47
+ closeSync(fds[0]!)
48
+ closeSync(fds[1]!)
49
+ return undefined
50
+ }
51
+ const pipes = new TaskPipes()
52
+ // vx lists the spare's group itself, with the turn's other lines: the
53
+ // spare runs nothing before `runOnSpare` flushes them.
54
+ const child = spawnOff({
55
+ cmd: [sh, '-c', WAIT],
56
+ argv0: 'sh',
57
+ cwd: '/',
58
+ env: {},
59
+ detached: true,
60
+ held: false,
61
+ stdio: ['ignore', ...(pipes.stdio as [number, number]), fds[0]!],
62
+ }).then((c) => {
63
+ listGroup(c.pid)
64
+ return c
65
+ })
66
+ const settled = child.finally(() => {
67
+ pipes.spawned()
68
+ closeSync(fds[0]!)
69
+ })
70
+ settled.catch(() => {
71
+ off = true
72
+ pipes.close()
73
+ closeSync(fds[1]!)
74
+ })
75
+ return { child: settled, pipes, script: fds[1]! }
76
+ }
77
+
78
+ /** A spare for the next task, or undefined; starts another in its place. */
79
+ export function takeSpare(): Spare | undefined {
80
+ const taken = spares.shift()
81
+ while (!off && spares.length < SPARES) {
82
+ const s = start()
83
+ if (s === undefined) {
84
+ off = true
85
+ break
86
+ }
87
+ spares.push(s)
88
+ }
89
+ return taken
90
+ }
91
+
92
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
93
+ const quote = (v: string): string => `'${v.replaceAll("'", "'\\''")}'`
94
+
95
+ /**
96
+ * The script a spare runs as the task: its directory (127 when it is gone,
97
+ * as a spawn into it fails; the OLDPWD `cd` sets is not the task's), its
98
+ * env, then `command`. Undefined when an env
99
+ * name is one sh cannot export: that task spawns fresh.
100
+ */
101
+ export function spareScript(
102
+ cwd: string,
103
+ env: Readonly<Record<string, string | undefined>>,
104
+ command: string,
105
+ ): string | undefined {
106
+ // A script past what one pipe write lands whole would hold vx's thread.
107
+ if (command.length > SCRIPT_BYTES) return undefined
108
+ let out = `exec 3<&-\ncd -- ${quote(cwd)} 2>/dev/null || exit 127\nunset OLDPWD\n`
109
+ for (const k in env) {
110
+ const v = env[k]
111
+ // Undefined unsets the name: a spawn drops it.
112
+ if (v === undefined) continue
113
+ if (!NAME.test(k)) return undefined
114
+ out += `export ${k}=${quote(v)}\n`
115
+ }
116
+ out += `${command}\n`
117
+ return Buffer.byteLength(out) > SCRIPT_BYTES ? undefined : out
118
+ }
119
+
120
+ /** A pipe holds 64 KiB: a script under it is written without waiting on the shell's read. */
121
+ const SCRIPT_BYTES = 60 * 1024
122
+
123
+ /**
124
+ * Hand `spare` its script. False when the spare is gone (killed, or its
125
+ * spawn refused): the task then spawns fresh.
126
+ */
127
+ export async function runOnSpare(spare: Spare, script: string): Promise<OffChild | undefined> {
128
+ let child: OffChild
129
+ try {
130
+ child = await spare.child
131
+ } catch {
132
+ return undefined
133
+ }
134
+ try {
135
+ // Its group listed before it runs a line of the task.
136
+ flushGuard()
137
+ writeSync(spare.script, script)
138
+ } catch {
139
+ child.kill('SIGKILL')
140
+ return undefined
141
+ } finally {
142
+ closeSync(spare.script)
143
+ }
144
+ child.hold()
145
+ return child
146
+ }
@@ -29,14 +29,26 @@ export function criticalPath(
29
29
  ? (p50s[Math.floor(p50s.length / 2)] ?? DEFAULT_DURATION_MS)
30
30
  : DEFAULT_DURATION_MS
31
31
 
32
- const dependentsOf = new Map<string, string[]>()
33
- for (const n of nodes) {
34
- for (const upstreamId of n.deps) {
35
- const list = dependentsOf.get(upstreamId)
36
- if (list) list.push(n.id)
37
- else dependentsOf.set(upstreamId, [n.id])
32
+ // Integer ids and flat arrays: Maps keyed by id strings and an array per
33
+ // node were 53 ms of a 9,603-task run's schedule stage (2026-11-01).
34
+ const count = nodes.length
35
+ const index = new Map<string, number>()
36
+ for (let i = 0; i < count; i++) index.set(nodes[i]!.id, i)
37
+ // Each node's dependents, as one flat list sliced by `first`.
38
+ const ups: number[] = []
39
+ const first = new Int32Array(count + 1)
40
+ for (let i = 0; i < count; i++) {
41
+ for (const up of nodes[i]!.deps) {
42
+ const u = index.get(up)
43
+ if (u === undefined) continue
44
+ first[u + 1]!++
45
+ ups.push(u, i)
38
46
  }
39
47
  }
48
+ for (let i = 0; i < count; i++) first[i + 1]! += first[i]!
49
+ const next = first.slice(0, count)
50
+ const dependents = new Int32Array(ups.length / 2)
51
+ for (let e = 0; e < ups.length; e += 2) dependents[next[ups[e]!]!++] = ups[e + 1]!
40
52
  // A group runs nothing, so it has no history; the median would charge it
41
53
  // a whole task and let a gate outrank the longest chain (PR #3286).
42
54
  const ownDuration = (n: TaskNode): number =>
@@ -47,33 +59,33 @@ export function criticalPath(
47
59
  // Reverse-topological pass: a node's value is final once every dependent's
48
60
  // is, so start from the sinks (no dependents) and release each upstream
49
61
  // when its last dependent has been scored.
50
- const memo = new Map<string, number>()
51
- const nodeById = new Map<string, TaskNode>(nodes.map((n) => [n.id, n]))
52
- const pending = new Map<string, number>()
53
- const queue: TaskNode[] = []
54
- for (const n of nodes) {
55
- const count = dependentsOf.get(n.id)?.length ?? 0
56
- pending.set(n.id, count)
57
- if (count === 0) queue.push(n)
62
+ const score = new Float64Array(count)
63
+ const scored = new Uint8Array(count)
64
+ const pending = new Int32Array(count)
65
+ const queue = new Int32Array(count)
66
+ let tail = 0
67
+ for (let i = 0; i < count; i++) {
68
+ pending[i] = first[i + 1]! - first[i]!
69
+ if (pending[i] === 0) queue[tail++] = i
58
70
  }
59
- let head = 0
60
- while (head < queue.length) {
61
- const n = queue[head++]!
71
+ for (let head = 0; head < tail; head++) {
72
+ const i = queue[head]!
62
73
  let downstream = 0
63
- for (const dep of dependentsOf.get(n.id) ?? []) {
64
- const d = memo.get(dep) ?? 0
74
+ for (let e = first[i]!; e < first[i + 1]!; e++) {
75
+ const d = score[dependents[e]!]!
65
76
  if (d > downstream) downstream = d
66
77
  }
67
- memo.set(n.id, ownDuration(n) + downstream)
68
- for (const up of n.deps) {
69
- const left = (pending.get(up) ?? 0) - 1
70
- pending.set(up, left)
71
- if (left === 0) {
72
- const upNode = nodeById.get(up)
73
- if (upNode) queue.push(upNode)
74
- }
78
+ score[i] = ownDuration(nodes[i]!) + downstream
79
+ scored[i] = 1
80
+ for (const up of nodes[i]!.deps) {
81
+ const u = index.get(up)
82
+ if (u !== undefined && --pending[u]! === 0) queue[tail++] = u
75
83
  }
76
84
  }
77
- for (const n of nodes) if (!memo.has(n.id)) memo.set(n.id, ownDuration(n))
85
+ const memo = new Map<string, number>()
86
+ for (let head = 0; head < tail; head++) memo.set(nodes[queue[head]!]!.id, score[queue[head]!]!)
87
+ // A node on a cycle is never released: its own duration.
88
+ for (let i = 0; i < count; i++)
89
+ if (scored[i] === 0) memo.set(nodes[i]!.id, ownDuration(nodes[i]!))
78
90
  return memo
79
91
  }
@@ -156,29 +156,47 @@ export function computeReverseDepCount(
156
156
  /** Count only these dependents (the exec tier); every one when absent. */
157
157
  counted?: ReadonlyMap<string, unknown>,
158
158
  ): Map<string, number> {
159
+ return reverseDepCountsOver(nodes, [counted])[0]!
160
+ }
161
+
162
+ /**
163
+ * `computeReverseDepCount` once per entry of `counted`, from ONE closure:
164
+ * the closure is the cost (~100 ms at 11k nodes, 2026-10-29), each count
165
+ * one popcount pass over it.
166
+ */
167
+ function reverseDepCountsOver(
168
+ nodes: ReadonlyMap<string, TaskNode>,
169
+ counted: ReadonlyArray<ReadonlyMap<string, unknown> | undefined>,
170
+ ): Map<string, number>[] {
159
171
  // Exact transitive-dependent COUNTS need closure SETS (diamonds
160
172
  // double-count under naive summing). Set-of-strings closures are
161
173
  // O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
162
- // bitsets make the same closure O(E·N/32) time and N²/8 bits of
163
- // memory (3270 tasks ≈ 1.3 MB) — single-digit ms at that scale.
174
+ // bitsets make the same closure O(E·N/32) time over N²/8 bits
175
+ // (3270 tasks ≈ 1.3 MB) — single-digit ms at that scale.
164
176
  const ids = [...nodes.keys()]
165
- const index = new Map<string, number>()
166
- for (let i = 0; i < ids.length; i++) index.set(ids[i]!, i)
167
177
  const n = ids.length
168
- const words = (n + 31) >>> 5
178
+ const index = new Map<string, number>()
179
+ for (let i = 0; i < n; i++) index.set(ids[i]!, i)
169
180
 
170
- // Direct dependents as index lists + in-degree for the topo pass.
171
- const directReverse: number[][] = Array.from({ length: n }, () => [])
181
+ // Direct dependents as one flat list (offsets per node) + in-degree.
182
+ const from: number[] = []
183
+ const to: number[] = []
184
+ const offset = new Int32Array(n + 1)
172
185
  const indegree = new Uint32Array(n)
173
- for (const node of nodes.values()) {
174
- const ni = index.get(node.id)!
175
- for (const dep of node.deps) {
186
+ for (let ni = 0; ni < n; ni++) {
187
+ for (const dep of nodes.get(ids[ni]!)!.deps) {
176
188
  const di = index.get(dep)
177
189
  if (di === undefined) continue
178
- directReverse[di]!.push(ni)
190
+ from.push(di)
191
+ to.push(ni)
192
+ offset[di + 1]!++
179
193
  indegree[ni]!++
180
194
  }
181
195
  }
196
+ for (let i = 0; i < n; i++) offset[i + 1]! += offset[i]!
197
+ const next = offset.slice(0, n)
198
+ const dependents = new Int32Array(from.length)
199
+ for (let e = 0; e < from.length; e++) dependents[next[from[e]!]!++] = to[e]!
182
200
 
183
201
  // Kahn topo order over dependency edges (deps before dependents).
184
202
  // Insertion order is topo today, but the closure's correctness
@@ -189,48 +207,84 @@ export function computeReverseDepCount(
189
207
  for (let i = 0; i < n; i++) if (indegree[i] === 0) topo[tail++] = i
190
208
  while (head < tail) {
191
209
  const v = topo[head++]!
192
- for (const r of directReverse[v]!) {
210
+ for (let k = offset[v]!; k < offset[v + 1]!; k++) {
211
+ const r = dependents[k]!
193
212
  if (--indegree[r]! === 0) topo[tail++] = r
194
213
  }
195
214
  }
196
215
 
197
- // Reverse-topo sweep: every direct dependent's closure is final
198
- // before its dependency folds it in. closure[t] = bitset over topo
199
- // POSITIONS of the node at position t's transitive dependents. A
200
- // dependent sits after its dependency, so a closure holds only bits
201
- // past its own position, and each OR starts at the dependent's word:
202
- // half the words of indexing by insertion order on a layered graph.
216
+ // The same lists by topo POSITION. A dependent sits after its
217
+ // dependency, so a closure holds only bits past its own position.
203
218
  const pos = new Int32Array(n)
204
219
  for (let t = 0; t < tail; t++) pos[topo[t]!] = t
205
- const mask = new Uint32Array(words)
206
- if (counted === undefined) mask.fill(0xffffffff)
207
- else
208
- for (let t = 0; t < tail; t++) if (counted.has(ids[topo[t]!]!)) mask[t >>> 5]! |= 1 << (t & 31)
209
- const closure = new Uint32Array(n * words)
210
- const counts = new Map<string, number>()
211
- for (let t = tail - 1; t >= 0; t--) {
220
+ const start = new Int32Array(tail + 1)
221
+ for (let t = 0; t < tail; t++) {
212
222
  const i = topo[t]!
213
- const base = t * words
214
- for (const ri of directReverse[i]!) {
215
- const r = pos[ri]!
216
- closure[base + (r >>> 5)]! |= 1 << (r & 31)
217
- const rbase = r * words
218
- for (let w = r >>> 5; w < words; w++) closure[base + w]! |= closure[rbase + w]!
219
- }
220
- let count = 0
221
- for (let w = t >>> 5; w < words; w++) {
222
- let v = closure[base + w]! & mask[w]!
223
- v = v - ((v >>> 1) & 0x55555555)
224
- v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
225
- count += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24
223
+ start[t + 1] = start[t]! + offset[i + 1]! - offset[i]!
224
+ }
225
+ const rev = new Int32Array(start[tail]!)
226
+ for (let t = 0; t < tail; t++) {
227
+ const i = topo[t]!
228
+ let k = start[t]!
229
+ for (let j = offset[i]!; j < offset[i + 1]!; j++) rev[k++] = pos[dependents[j]!]!
230
+ }
231
+ const words = (tail + 31) >>> 5
232
+ const masks = counted.map((only) => {
233
+ const mask = new Uint32Array(words)
234
+ if (only === undefined) mask.fill(0xffffffff)
235
+ else
236
+ for (let t = 0; t < tail; t++) if (only.has(ids[topo[t]!]!)) mask[t >>> 5]! |= 1 << (t & 31)
237
+ return mask
238
+ })
239
+
240
+ // Reverse-topo sweeps, one per block of `span` words: every node's
241
+ // closure restricted to the block's positions, its count summed over
242
+ // the blocks. All N rows of all words (9,603 tasks: 11.5 MB) missed
243
+ // the cache on nearly every OR, and the first call, the only one a run
244
+ // makes, took 77 ms; a block of ~256 KB took 37 (2026-10-29). A
245
+ // dependent past the block holds no bit in it, so its edge is skipped.
246
+ const span = Math.max(1, Math.min(words, Math.floor(65536 / Math.max(1, tail))))
247
+ const totals = masks.map(() => new Int32Array(tail))
248
+ const closure = new Uint32Array(tail * span)
249
+ for (let w0 = 0; w0 < words; w0 += span) {
250
+ const width = Math.min(words, w0 + span) - w0
251
+ const end = Math.min(tail, (w0 + width) << 5)
252
+ if (w0 > 0) closure.fill(0, 0, end * span)
253
+ for (let t = end - 1; t >= 0; t--) {
254
+ const base = t * span
255
+ for (let k = start[t]!; k < start[t + 1]!; k++) {
256
+ const r = rev[k]!
257
+ if (r >= end) continue
258
+ const rw = r >>> 5
259
+ if (rw >= w0) closure[base + rw - w0]! |= 1 << (r & 31)
260
+ const rbase = r * span
261
+ for (let w = rw > w0 ? rw - w0 : 0; w < width; w++)
262
+ closure[base + w]! |= closure[rbase + w]!
263
+ }
264
+ for (let m = 0; m < masks.length; m++) {
265
+ const mask = masks[m]!
266
+ let count = 0
267
+ for (let w = t >>> 5 > w0 ? (t >>> 5) - w0 : 0; w < width; w++) {
268
+ let v = closure[base + w]! & mask[w0 + w]!
269
+ if (v === 0) continue
270
+ v = v - ((v >>> 1) & 0x55555555)
271
+ v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
272
+ count += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24
273
+ }
274
+ totals[m]![t]! += count
275
+ }
226
276
  }
227
- counts.set(ids[i]!, count)
228
277
  }
229
- // Cycle-stranded nodes (never topo-visited) can't occur — the graph
230
- // builder rejects cycles — but a missing Map entry would silently
231
- // sort as undefined, so default them defensively to 0.
232
- for (const id of ids) if (!counts.has(id)) counts.set(id, 0)
233
- return counts
278
+ const all = totals.map((total) => {
279
+ const counts = new Map<string, number>()
280
+ for (let t = 0; t < tail; t++) counts.set(ids[topo[t]!]!, total[t]!)
281
+ // Cycle-stranded nodes (never topo-visited) can't occur — the graph
282
+ // builder rejects cycles — but a missing Map entry would silently
283
+ // sort as undefined, so default them defensively to 0.
284
+ for (const id of ids) if (!counts.has(id)) counts.set(id, 0)
285
+ return counts
286
+ })
287
+ return all
234
288
  }
235
289
 
236
290
  /**
@@ -5,8 +5,8 @@ import {
5
5
  isUserError,
6
6
  OUT_OF_FDS_HINT,
7
7
  thrownMessage,
8
- ui,
9
8
  } from '../util/index.js'
9
+ import * as ui from '../ui/index.js'
10
10
  import {
11
11
  computeReverseDepCount,
12
12
  mergePriorities,
@@ -89,7 +89,8 @@ export interface TaskOutcome {
89
89
  */
90
90
  artifactBytes?: number
91
91
  /**
92
- * The raw bytes of a hit's outputs: the entry's files as restored.
92
+ * The raw bytes of the task's outputs: on a hit, the entry's files as
93
+ * restored; on a miss, the files its save stored (the row waits for it).
93
94
  */
94
95
  outputBytes?: number
95
96
  /** On a remote hit this run pulled: the download and its ingest. */
@@ -308,8 +309,7 @@ export interface ScheduleOptions {
308
309
  */
309
310
  onFailFast?: () => void
310
311
  execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
311
- /** `queuedMs`: how long the task waited ready for a slot, when it did. */
312
- onStart?: (node: TaskNode, queuedMs?: number) => void
312
+ onStart?: (node: TaskNode) => void
313
313
  onFinish?: (outcome: TaskOutcome) => void
314
314
  /**
315
315
  * Where the line naming a rejected `execute` goes, before its failed
@@ -355,6 +355,11 @@ export interface ScheduleOptions {
355
355
  * Undefined or `most-work` is the reverse-deps count above.
356
356
  */
357
357
  strategy?: ScheduleStrategy
358
+ /**
359
+ * The `most-work` baseline already counted (`computeReverseDepCount`, read
360
+ * back with the graph): asked only when that baseline is the one used.
361
+ */
362
+ reverseDeps?: () => ReadonlyMap<string, number>
358
363
  /**
359
364
  * Restore-tier task ids: confirmed stable-key LOCAL cache hits (the
360
365
  * local short-circuit). A restore-tier task:
@@ -598,7 +603,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
598
603
  ? strategyPriorities(nodes, options.strategy)
599
604
  : options.restoreTier !== undefined && options.restoreTier.size > 0
600
605
  ? tieredReverseDepCount(nodes, options.restoreTier)
601
- : computeReverseDepCount(nodes)
606
+ : (options.reverseDeps?.() ?? computeReverseDepCount(nodes))
602
607
  const priority: ReadonlyMap<string, number> = options.priorities
603
608
  ? mergePriorities(baseline, options.priorities)
604
609
  : baseline
@@ -964,10 +969,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
964
969
  return undefined
965
970
  }
966
971
  }
967
- const started = (node: TaskNode, queuedMs = 0): void => {
972
+ const started = (node: TaskNode): void => {
968
973
  try {
969
- if (queuedMs > 0) onStart?.(node, queuedMs)
970
- else onStart?.(node)
974
+ onStart?.(node)
971
975
  } catch (err) {
972
976
  const m = thrownMessage(err)
973
977
  ui.say('warn', `[vx] onStart observer threw for ${node.id}: ${m}\n`)
@@ -1106,7 +1110,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1106
1110
  const settle = settleInPlace(node, upstream)
1107
1111
  let now: TaskOutcome | undefined
1108
1112
  if (settle !== undefined) {
1109
- if (!demoted.has(id)) started(node, queuedMs)
1113
+ if (!demoted.has(id)) started(node)
1110
1114
  try {
1111
1115
  now = settle()
1112
1116
  } catch {
@@ -1136,7 +1140,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1136
1140
  // the dispatch loop (it would strand the tick with the slot held).
1137
1141
  // A demoted task's second dispatch is the same task, already started,
1138
1142
  // as is one whose settle threw.
1139
- if (!demoted.has(id) && settle === undefined) started(node, queuedMs)
1143
+ if (!demoted.has(id) && settle === undefined) started(node)
1140
1144
 
1141
1145
  // `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
1142
1146
  // rejection arm handles ONLY `execute()` rejecting; a throw from
@@ -335,6 +335,31 @@ export function undeclaredDepsError(
335
335
  )
336
336
  }
337
337
 
338
+ /**
339
+ * The `pkg#task` to offer for a cross-project miss: the task nearest
340
+ * `task` in the project named, or in the one nearest `project` — or the
341
+ * one whose path ends in it, since a project is named by its path unless
342
+ * named, and `a#build` is how a `packages/a` is first typed.
343
+ */
344
+ function nearestTarget(
345
+ projects: ReadonlyMap<string, ProjectEntry>,
346
+ project: string,
347
+ task: string,
348
+ ): string | undefined {
349
+ const holder =
350
+ projects.get(project) ??
351
+ projects.get(nearest(project, projects.keys()) ?? '') ??
352
+ onlyOne([...projects.values()].filter((p) => p.name.endsWith(`/${project}`)))
353
+ if (holder === undefined) return undefined
354
+ const tasks = Object.keys(holder.config.tasks ?? {})
355
+ const name = tasks.includes(task) ? task : nearest(task, tasks)
356
+ return name === undefined ? undefined : taskId(holder.name, name)
357
+ }
358
+
359
+ function onlyOne<T>(list: readonly T[]): T | undefined {
360
+ return list.length === 1 ? list[0] : undefined
361
+ }
362
+
338
363
  /** One task mid-expansion in `buildTaskGraph`'s walk. */
339
364
  interface Frame {
340
365
  node: TaskNode
@@ -656,8 +681,9 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
656
681
  `${id}: dependsOn //#${spec.task}, the workspace root's task, but the root declares no such task: add a vx.config at the root declaring it and run \`vx lock\``,
657
682
  'VX_E_DEPENDS_ON',
658
683
  )
684
+ const meant = nearestTarget(projects, spec.project, spec.task)
659
685
  throw new UserError(
660
- `${id}: dependsOn ${taskId(spec.project, spec.task)} but no such project or task is declared`,
686
+ `${id}: dependsOn ${taskId(spec.project, spec.task)} but no such project or task is declared${meant === undefined ? '' : `. Did you mean ${meant}?`}`,
661
687
  'VX_E_DEPENDS_ON',
662
688
  )
663
689
  }
package/src/handoff.ts CHANGED
@@ -223,15 +223,19 @@ export async function handOff(bin: string, args: readonly string[]): Promise<nev
223
223
  // hides the env on every verb, is never reached here. Imported here:
224
224
  // a start that hands off nothing pays nothing.
225
225
  await (await import('./util/index.js')).hideEnvFromTasks()
226
- const child = Bun.spawn([bin, ...args], {
227
- stdio: ['inherit', 'inherit', 'inherit'],
228
- env: { ...process.env, VX_HANDED_OFF: process.execPath },
229
- })
226
+ // The handlers come first: a signal landing between the spawn and them
227
+ // killed this vx and left the local one running, orphaned (main
228
+ // dc9d49c). A handler runs on the event loop, after the spawn returned.
229
+ let child: Bun.Subprocess | undefined
230
230
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
231
231
  process.on(sig, () => {
232
- if (!inForeground()) child.kill(sig)
232
+ if (!inForeground()) child?.kill(sig)
233
233
  })
234
234
  }
235
+ child = Bun.spawn([bin, ...args], {
236
+ stdio: ['inherit', 'inherit', 'inherit'],
237
+ env: { ...process.env, VX_HANDED_OFF: process.execPath },
238
+ })
235
239
  const code = await child.exited
236
240
  const signal = child.signalCode
237
241
  if (signal === null) process.exit(code)
@@ -240,8 +244,7 @@ export async function handOff(bin: string, args: readonly string[]): Promise<nev
240
244
  // SIGINT, and an exit 130 ran the script's next line. The code below is
241
245
  // the rest, and the fallback should the signal not land.
242
246
  if (signal === 'SIGINT' || signal === 'SIGTERM' || signal === 'SIGHUP') {
243
- process.removeAllListeners(signal)
244
- process.kill(process.pid, signal)
247
+ ;(await import('./util/index.js')).dieOf(signal)
245
248
  }
246
249
  if (typeof signal === 'number') process.exit(128 + signal)
247
250
  const { constants } = await import('node:os')
@@ -311,6 +311,11 @@ export class LocalHistoryProvider implements LocalHistory {
311
311
  * against 4 for the executed successes' durations alone.
312
312
  */
313
313
  async p50sFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, number>> {
314
+ return this.p50sNow(taskIds)
315
+ }
316
+
317
+ /** `p50sFor`, at once: a row drawn now reads it (the rows' `usual`). */
318
+ p50sNow(taskIds: readonly string[]): ReadonlyMap<string, number> {
314
319
  if (taskIds.length === 0) return new Map()
315
320
  return this.forecasts(new Set(taskIds))
316
321
  }
@@ -50,6 +50,10 @@ export interface RunSummaryRow {
50
50
  avgRssBytes: number | null
51
51
  /** Where an executor ran it (`small#1`, v44); null = here. */
52
52
  placement: string | null
53
+ /** The bytes of its outputs: what its save stored, or its hit restored (v51). */
54
+ outputBytes: number | null
55
+ /** How many tasks were downstream of it (v51). */
56
+ blocks: number | null
53
57
  // High-precision spans relative to run start. Decimal strings on the wire
54
58
  // (bigints aren't JSON-safe); use BigInt or Number on the client.
55
59
  wallclockStartNs: string | null
@@ -65,6 +69,8 @@ export interface RunSummaryRow {
65
69
  remoteError: string | null
66
70
  /** The attempts a retried task took (>1, v23); null when it ran once. */
67
71
  attempts: number | null
72
+ /** Milliseconds it waited, ready, for a slot (v51); null when it never queued. */
73
+ queuedMs: number | null
68
74
  }
69
75
 
70
76
  export interface ListRunsArgs {
@@ -123,10 +129,12 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
123
129
  cache_hit AS cacheHit, restored, cached, hash,
124
130
  cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
125
131
  peak_cores AS peakCores, avg_rss_bytes AS avgRssBytes, placement,
132
+ output_bytes AS outputBytes, blocks,
126
133
  wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs,
127
134
  blocked_by AS blockedBy, timed_out AS timedOut,
128
135
  sandbox_violations AS sandboxViolations, not_ready AS notReady,
129
- forward_args IS NOT NULL AS forwarded, remote_error AS remoteError, attempts
136
+ forward_args IS NOT NULL AS forwarded, remote_error AS remoteError, attempts,
137
+ queued_ms AS queuedMs
130
138
  FROM runs ${clause} ORDER BY id DESC LIMIT ?`,
131
139
  )
132
140
  .all(...params, limit) as RawRow[]
package/src/index.ts CHANGED
@@ -145,7 +145,7 @@ export {
145
145
  taskNameProblem,
146
146
  } from './workspace/index.js'
147
147
  // Output components: a plugin draws its lines with core's, so they look alike.
148
- export { ui } from './util/index.js'
148
+ export { ui } from './ui/index.js'
149
149
  export type {
150
150
  ApplyMigrationArgs,
151
151
  MigrationFormat,
@@ -186,10 +186,12 @@ export type {
186
186
  CacheFacts,
187
187
  TaskArtifacts,
188
188
  FingerprintChange,
189
+ FingerprintDigests,
189
190
  FingerprintClaim,
190
191
  FingerprintContext,
191
192
  GraphHookContext,
192
193
  KeyHookContext,
194
+ LockedFile,
193
195
  PluginCommand,
194
196
  ProjectHookContext,
195
197
  ScheduleHookContext,
@@ -19,8 +19,8 @@ import {
19
19
  machineMemoryBytes,
20
20
  machineParallelism,
21
21
  treeBytes,
22
- ui,
23
22
  } from '../util/index.js'
23
+ import * as ui from '../ui/index.js'
24
24
  import { vxPath } from '../handoff.js'
25
25
  import { VERSION } from '../version.js'
26
26
  import { probeSandbox, resetSandbox, untracedReason } from '../exec/index.js'