@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.
- package/README.md +5 -20
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/src/bin.ts +9 -31
- package/src/cache/cache.ts +202 -25
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +3 -0
- package/src/cache/layer.ts +11 -0
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +3 -2
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +11 -1
- package/src/cache/schema.ts +11 -1
- package/src/cli/cache.ts +26 -16
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +12 -23
- package/src/cli/index.ts +4 -9
- package/src/cli/info.ts +15 -7
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +213 -203
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +8 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +38 -42
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +2 -1
- package/src/cli/watch.ts +83 -44
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/exec/executor.ts +2 -1
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +11 -2
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +14 -10
- package/src/graph/task-graph.ts +27 -1
- package/src/handoff.ts +10 -7
- package/src/history/history.ts +5 -0
- package/src/history/metrics.ts +9 -1
- package/src/index.ts +3 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-task.ts +16 -2
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +31 -3
- package/src/orchestrator/persistent-task.ts +3 -1
- package/src/orchestrator/plan.ts +5 -1
- package/src/orchestrator/plugin-host.ts +61 -0
- package/src/orchestrator/plugin.ts +47 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +1 -1
- package/src/orchestrator/run-records.ts +10 -0
- package/src/orchestrator/run.ts +53 -22
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/task-hash.ts +17 -3
- package/src/report/events.ts +3 -11
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +272 -173
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +73 -38
- package/src/report/plain-output.ts +1 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +100 -131
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +65 -10
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +20 -2
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +31 -0
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/config-schema.ts +4 -2
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +28 -0
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +4 -3
- package/src/workspace/workspace.ts +3 -0
- package/src/util/colors.ts +0 -133
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
51
|
-
const
|
|
52
|
-
const pending = new
|
|
53
|
-
const queue
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
pending
|
|
57
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
64
|
-
const d =
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
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
|
-
|
|
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
|
}
|
package/src/graph/priorities.ts
CHANGED
|
@@ -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
|
|
163
|
-
//
|
|
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
|
|
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
|
|
171
|
-
const
|
|
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 (
|
|
174
|
-
const
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
198
|
-
//
|
|
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
|
|
206
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
/**
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
972
|
+
const started = (node: TaskNode): void => {
|
|
968
973
|
try {
|
|
969
|
-
|
|
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
|
|
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
|
|
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
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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
|
-
|
|
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')
|
package/src/history/history.ts
CHANGED
|
@@ -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
|
}
|
package/src/history/metrics.ts
CHANGED
|
@@ -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 './
|
|
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'
|