@vzn/vx 0.0.649 → 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 +225 -51
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/fold-memo.ts +8 -1
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/index.ts +12 -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 +120 -0
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-hits.ts +9 -1
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +11 -1
- package/src/cache/run-notes.ts +123 -0
- package/src/cache/schema.ts +54 -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 +224 -223
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +10 -3
- 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 +126 -92
- 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 +12 -2
- package/src/graph/task-graph.ts +27 -1
- package/src/handoff.ts +10 -7
- package/src/history/history.ts +39 -2
- package/src/history/index.ts +3 -0
- package/src/history/metrics.ts +9 -1
- package/src/index.ts +12 -3
- package/src/orchestrator/doctor.ts +3 -3
- 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/hit-restore.ts +3 -0
- package/src/orchestrator/index.ts +3 -1
- package/src/orchestrator/live-usage.ts +98 -0
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +176 -102
- 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 +49 -4
- package/src/orchestrator/prepare.ts +18 -2
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +35 -114
- package/src/orchestrator/run-records.ts +10 -0
- package/src/orchestrator/run.ts +89 -26
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-hash.ts +17 -3
- package/src/report/forecast.ts +12 -33
- package/src/report/framed-output.ts +388 -161
- package/src/report/index.ts +8 -2
- package/src/report/logger.ts +100 -36
- package/src/report/status-line.ts +81 -365
- package/src/report/summary.ts +104 -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/hidden-prompt.ts +8 -2
- 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/secrets.ts +3 -0
- 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/orchestrator/run-steps.ts +0 -93
- package/src/util/colors.ts +0 -130
- 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,
|
|
@@ -88,6 +88,11 @@ export interface TaskOutcome {
|
|
|
88
88
|
* its save wrote (measured only when a telemetry sink listens).
|
|
89
89
|
*/
|
|
90
90
|
artifactBytes?: number
|
|
91
|
+
/**
|
|
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).
|
|
94
|
+
*/
|
|
95
|
+
outputBytes?: number
|
|
91
96
|
/** On a remote hit this run pulled: the download and its ingest. */
|
|
92
97
|
fetchMs?: number
|
|
93
98
|
/** On a miss that saved, with a sink listening: the save's own time. */
|
|
@@ -350,6 +355,11 @@ export interface ScheduleOptions {
|
|
|
350
355
|
* Undefined or `most-work` is the reverse-deps count above.
|
|
351
356
|
*/
|
|
352
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>
|
|
353
363
|
/**
|
|
354
364
|
* Restore-tier task ids: confirmed stable-key LOCAL cache hits (the
|
|
355
365
|
* local short-circuit). A restore-tier task:
|
|
@@ -593,7 +603,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
593
603
|
? strategyPriorities(nodes, options.strategy)
|
|
594
604
|
: options.restoreTier !== undefined && options.restoreTier.size > 0
|
|
595
605
|
? tieredReverseDepCount(nodes, options.restoreTier)
|
|
596
|
-
: computeReverseDepCount(nodes)
|
|
606
|
+
: (options.reverseDeps?.() ?? computeReverseDepCount(nodes))
|
|
597
607
|
const priority: ReadonlyMap<string, number> = options.priorities
|
|
598
608
|
? mergePriorities(baseline, options.priorities)
|
|
599
609
|
: baseline
|
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
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// (zero-config); the interface is the seam a plugin fills for any other.
|
|
13
13
|
|
|
14
14
|
import type { Database } from 'bun:sqlite'
|
|
15
|
-
import { branchSource, EXECUTED_RUNS_SQL } from '../cache/index.js'
|
|
15
|
+
import { branchSource, type Cache, EXECUTED_RUNS_SQL, indexDb } from '../cache/index.js'
|
|
16
16
|
import { isMeasure, splitTaskId } from '../util/index.js'
|
|
17
17
|
import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
|
|
18
18
|
import type { FailureMode } from './failure-mode.js'
|
|
@@ -117,8 +117,40 @@ export class EmptyHistoryProvider implements HistoryProvider {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** One local cache's run history, read and written by core (`localHistory`): plain rows out, no handle. */
|
|
121
|
+
export interface LocalHistory extends HistoryProvider {
|
|
122
|
+
/** Each task's measured need (`p50DurationMs`, peak cores and memory). */
|
|
123
|
+
resourcesFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, TaskResources>>
|
|
124
|
+
/** Whether timings of commit `sha` were imported. */
|
|
125
|
+
hasImported(sha: string): boolean
|
|
126
|
+
/** Keep commit `sha`'s timings (task id → ms) and needs as forecast rows. */
|
|
127
|
+
importTimings(
|
|
128
|
+
sha: string,
|
|
129
|
+
timings: ReadonlyMap<string, number>,
|
|
130
|
+
needs?: ReadonlyMap<string, TaskResources>,
|
|
131
|
+
defs?: ReadonlyMap<string, string>,
|
|
132
|
+
): void
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface LocalHistoryOptions {
|
|
136
|
+
/** The newest invocations a read sees (default 50). */
|
|
137
|
+
readonly recent?: number | undefined
|
|
138
|
+
/** The run's branch (`currentBranch`); undefined: a read sees every branch's timings. */
|
|
139
|
+
readonly branch?: string | null | undefined
|
|
140
|
+
/** Each task's definition (`taskDefinitions`): given, a task reads only rows of it as it is now. */
|
|
141
|
+
readonly defs?: ReadonlyMap<string, string> | undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The run history in `cache`'s index. A plugin reads history only through
|
|
146
|
+
* this: core owns every read and write of cache.db (2026-10-29).
|
|
147
|
+
*/
|
|
148
|
+
export function localHistory(cache: Cache, options: LocalHistoryOptions = {}): LocalHistory {
|
|
149
|
+
return new LocalHistoryProvider(indexDb(cache), options.recent, options.branch, options.defs)
|
|
150
|
+
}
|
|
151
|
+
|
|
120
152
|
/** Reads from the orchestrator's local SQLite cache.db. */
|
|
121
|
-
export class LocalHistoryProvider implements
|
|
153
|
+
export class LocalHistoryProvider implements LocalHistory {
|
|
122
154
|
constructor(
|
|
123
155
|
private readonly db: Database,
|
|
124
156
|
readonly recent: number = DEFAULT_RECENT,
|
|
@@ -279,6 +311,11 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
279
311
|
* against 4 for the executed successes' durations alone.
|
|
280
312
|
*/
|
|
281
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> {
|
|
282
319
|
if (taskIds.length === 0) return new Map()
|
|
283
320
|
return this.forecasts(new Set(taskIds))
|
|
284
321
|
}
|
package/src/history/index.ts
CHANGED
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[]
|