@vzn/vx 0.0.650 → 0.0.652
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 +7 -23
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/schemas/show.json +14 -20
- package/src/bin.ts +21 -31
- package/src/cache/archive.ts +1 -1
- package/src/cache/cache.ts +221 -30
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/history-stream.ts +134 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +77 -14
- package/src/cache/layer.ts +17 -6
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +5 -3
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +56 -17
- package/src/cache/schema.ts +15 -2
- package/src/cli/cache.ts +28 -18
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +14 -25
- package/src/cli/index.ts +28 -9
- package/src/cli/info.ts +16 -8
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +246 -204
- 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 +36 -45
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +45 -52
- 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 +3 -2
- package/src/cli/watch-set.ts +4 -4
- package/src/cli/watch.ts +94 -52
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/config.ts +47 -30
- package/src/exec/executor.ts +42 -2
- package/src/exec/index.ts +1 -0
- 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-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +317 -23
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/definition.ts +16 -3
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +17 -11
- package/src/graph/task-graph.ts +51 -24
- package/src/handoff.ts +10 -7
- package/src/history/failure-mode.ts +24 -1
- package/src/history/history.ts +5 -0
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +14 -6
- package/src/index.ts +7 -3
- package/src/orchestrator/admission.ts +1 -1
- package/src/orchestrator/command-windows.ts +1 -1
- package/src/orchestrator/deferred-outputs.ts +1 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +5 -7
- package/src/orchestrator/excluded-keys.ts +1 -1
- package/src/orchestrator/execute-task.ts +67 -20
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/hit-restore.ts +5 -6
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/keyed-projects.ts +28 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +58 -11
- package/src/orchestrator/persistent-task.ts +5 -1
- package/src/orchestrator/placement.ts +1 -1
- package/src/orchestrator/plan.ts +7 -3
- package/src/orchestrator/plugin-host.ts +67 -2
- package/src/orchestrator/plugin.ts +53 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +62 -13
- package/src/orchestrator/run-keys.ts +1 -1
- package/src/orchestrator/run-records.ts +88 -59
- package/src/orchestrator/run-report.ts +1 -1
- package/src/orchestrator/run.ts +145 -41
- package/src/orchestrator/sandbox-request.ts +52 -9
- package/src/orchestrator/shell-verdict.ts +9 -0
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/stable-keys.ts +17 -18
- package/src/orchestrator/task-artifacts.ts +1 -1
- package/src/orchestrator/task-hash.ts +21 -7
- package/src/orchestrator/telemetry.ts +5 -1
- package/src/report/events.ts +6 -14
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +283 -175
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +74 -39
- package/src/report/plain-output.ts +3 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +105 -136
- package/src/settings.ts +8 -8
- 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 +191 -14
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +21 -3
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +33 -1
- package/src/util/tail.ts +4 -1
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-schema.ts +70 -63
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +36 -1
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +16 -9
- 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,154 @@
|
|
|
1
|
+
// A task's spawn on a thread of its own. Bun's spawn holds the calling
|
|
2
|
+
// thread until the child has exec'd (vfork), ~0.6 ms a task, and on a cold
|
|
3
|
+
// run that thread is the scheduler's: 2,000 tasks with 1.8 ms of the
|
|
4
|
+
// scheduler's own work each took 4.96 s spawned there and 3.71 s spawned
|
|
5
|
+
// here, the 3.6 s of that work the floor (2026-10-29). The child's
|
|
6
|
+
// descriptors are numbers in the one table every thread shares, so its
|
|
7
|
+
// pipes are made and read where they were; only the spawn and the wait
|
|
8
|
+
// for its exit move.
|
|
9
|
+
|
|
10
|
+
import type { ResourceUsage } from 'bun'
|
|
11
|
+
|
|
12
|
+
/** What `runCommand` reads of a child spawned here. */
|
|
13
|
+
export interface OffChild {
|
|
14
|
+
readonly pid: number
|
|
15
|
+
readonly exited: Promise<number>
|
|
16
|
+
readonly exitCode: number | null
|
|
17
|
+
readonly signalCode: NodeJS.Signals | null
|
|
18
|
+
resourceUsage(): ResourceUsage | undefined
|
|
19
|
+
kill(signal?: NodeJS.Signals | number): void
|
|
20
|
+
/** Hold the process open from now until the child exits: a spawn with `held: false` does not. */
|
|
21
|
+
hold(): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface OffSpawn {
|
|
25
|
+
cmd: string[]
|
|
26
|
+
argv0?: string
|
|
27
|
+
cwd?: string
|
|
28
|
+
env?: Record<string, string>
|
|
29
|
+
/** Each a descriptor number or `'ignore'`: a stream cannot cross threads. */
|
|
30
|
+
stdio: Array<number | 'ignore'>
|
|
31
|
+
detached?: boolean
|
|
32
|
+
/** False: the child keeps no run alive until `hold()` (a spare shell waiting for its task). */
|
|
33
|
+
held?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The worker as source, not a file: a compiled vx carries only its entry
|
|
37
|
+
// point's graph, and a worker file would need an entry point of its own.
|
|
38
|
+
const WORKER = `
|
|
39
|
+
const live = new Map()
|
|
40
|
+
self.onmessage = (e) => {
|
|
41
|
+
const m = e.data
|
|
42
|
+
if (m.kill !== undefined) { live.get(m.kill)?.kill(m.signal); return }
|
|
43
|
+
let p
|
|
44
|
+
try {
|
|
45
|
+
p = Bun.spawn(m.opts.cmd, m.opts)
|
|
46
|
+
} catch (err) {
|
|
47
|
+
postMessage({ id: m.id, error: { message: String(err?.message ?? err), code: err?.code } })
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
live.set(m.id, p)
|
|
51
|
+
postMessage({ id: m.id, pid: p.pid })
|
|
52
|
+
p.exited.then(() => {
|
|
53
|
+
live.delete(m.id)
|
|
54
|
+
const u = p.resourceUsage()
|
|
55
|
+
// Its two fields vx reads: the object itself does not cross threads.
|
|
56
|
+
const usage = u === undefined ? undefined : { cpuTime: { user: u.cpuTime.user, system: u.cpuTime.system, total: u.cpuTime.total }, maxRSS: u.maxRSS }
|
|
57
|
+
postMessage({ id: m.id, exitCode: p.exitCode, signalCode: p.signalCode, usage })
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
`
|
|
61
|
+
|
|
62
|
+
interface Pending {
|
|
63
|
+
held: () => boolean
|
|
64
|
+
spawned: (pid: number) => void
|
|
65
|
+
failed: (err: Error) => void
|
|
66
|
+
exit: (m: {
|
|
67
|
+
exitCode: number | null
|
|
68
|
+
signalCode: NodeJS.Signals | null
|
|
69
|
+
usage?: ResourceUsage
|
|
70
|
+
}) => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let worker: Worker | undefined
|
|
74
|
+
let nextId = 0
|
|
75
|
+
const pending = new Map<number, Pending>()
|
|
76
|
+
let liveCount = 0
|
|
77
|
+
|
|
78
|
+
function thread(): Worker {
|
|
79
|
+
if (worker !== undefined) return worker
|
|
80
|
+
const w = new Worker(URL.createObjectURL(new Blob([WORKER], { type: 'text/javascript' })))
|
|
81
|
+
w.onmessage = (e: MessageEvent) => {
|
|
82
|
+
const m = e.data
|
|
83
|
+
const p = pending.get(m.id)
|
|
84
|
+
if (p === undefined) return
|
|
85
|
+
if (m.error !== undefined) {
|
|
86
|
+
pending.delete(m.id)
|
|
87
|
+
const err = new Error(m.error.message) as NodeJS.ErrnoException
|
|
88
|
+
if (m.error.code !== undefined) err.code = m.error.code
|
|
89
|
+
if (p.held()) settleLive(-1)
|
|
90
|
+
p.failed(err)
|
|
91
|
+
} else if (m.pid !== undefined) p.spawned(m.pid)
|
|
92
|
+
else {
|
|
93
|
+
pending.delete(m.id)
|
|
94
|
+
if (p.held()) settleLive(-1)
|
|
95
|
+
p.exit(m)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// An idle thread keeps no run alive.
|
|
99
|
+
w.unref()
|
|
100
|
+
worker = w
|
|
101
|
+
return w
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Hold the process open while a child spawned here runs, as a spawn on the main thread does. */
|
|
105
|
+
function settleLive(delta: number): void {
|
|
106
|
+
liveCount += delta
|
|
107
|
+
if (liveCount > 0) worker?.ref()
|
|
108
|
+
else worker?.unref()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Spawn `opts` on the spawn thread; resolves once the child is running, rejects when the spawn threw. */
|
|
112
|
+
export function spawnOff(opts: OffSpawn): Promise<OffChild> {
|
|
113
|
+
const w = thread()
|
|
114
|
+
const id = nextId++
|
|
115
|
+
let held = opts.held !== false
|
|
116
|
+
if (held) settleLive(1)
|
|
117
|
+
const { held: _, ...spawn } = opts
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
let exitCode: number | null = null
|
|
120
|
+
let signalCode: NodeJS.Signals | null = null
|
|
121
|
+
let usage: ResourceUsage | undefined
|
|
122
|
+
let onExit!: (code: number) => void
|
|
123
|
+
const exited = new Promise<number>((r) => (onExit = r))
|
|
124
|
+
pending.set(id, {
|
|
125
|
+
held: () => held,
|
|
126
|
+
failed: reject,
|
|
127
|
+
spawned: (pid) =>
|
|
128
|
+
resolve({
|
|
129
|
+
pid,
|
|
130
|
+
exited,
|
|
131
|
+
get exitCode() {
|
|
132
|
+
return exitCode
|
|
133
|
+
},
|
|
134
|
+
get signalCode() {
|
|
135
|
+
return signalCode
|
|
136
|
+
},
|
|
137
|
+
resourceUsage: () => usage,
|
|
138
|
+
kill: (signal) => w.postMessage({ kill: id, signal }),
|
|
139
|
+
hold: () => {
|
|
140
|
+
if (held || exitCode !== null || signalCode !== null) return
|
|
141
|
+
held = true
|
|
142
|
+
settleLive(1)
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
exit: (m) => {
|
|
146
|
+
exitCode = m.exitCode
|
|
147
|
+
signalCode = m.signalCode
|
|
148
|
+
usage = m.usage
|
|
149
|
+
onExit(exitCode ?? 1)
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
w.postMessage({ id, opts: spawn })
|
|
153
|
+
})
|
|
154
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// One task's own proxy: the runtime's proxy is the run's and judges every
|
|
2
|
+
// connection by the union of all tasks' network lists, so a task reached a
|
|
3
|
+
// host only another task of the run declared. Each networked task gets this
|
|
4
|
+
// front on its own socket, which judges by the task's lists and hands what
|
|
5
|
+
// it allows to the run's proxy as its parent (that proxy still applies the
|
|
6
|
+
// union and its address guard). A task names itself to a proxy by a
|
|
7
|
+
// username it writes, so only the socket it was given says which task it is.
|
|
8
|
+
|
|
9
|
+
import { BlockList } from 'node:net'
|
|
10
|
+
|
|
11
|
+
/** The deep modules of the runtime the front is built from: its own proxy code, not a copy. */
|
|
12
|
+
async function loadProxyParts() {
|
|
13
|
+
// Literal specifiers: `bun build --compile` embeds only what it can see.
|
|
14
|
+
const [http, socks, mux, pattern, parent] = await Promise.all([
|
|
15
|
+
import('@anthropic-ai/sandbox-runtime/dist/sandbox/http-proxy.js'),
|
|
16
|
+
import('@anthropic-ai/sandbox-runtime/dist/sandbox/socks-proxy.js'),
|
|
17
|
+
import('@anthropic-ai/sandbox-runtime/dist/sandbox/mux-proxy.js'),
|
|
18
|
+
import('@anthropic-ai/sandbox-runtime/dist/sandbox/domain-pattern.js'),
|
|
19
|
+
import('@anthropic-ai/sandbox-runtime/dist/sandbox/parent-proxy.js'),
|
|
20
|
+
])
|
|
21
|
+
return { http, socks, mux, pattern, parent }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TaskProxy {
|
|
25
|
+
close(): Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Listen at `endpoint` (a unix socket path, or 0 for a localhost port) for
|
|
30
|
+
* one task. Returns the bound port when `endpoint` is 0.
|
|
31
|
+
*/
|
|
32
|
+
export async function startTaskProxy(opts: {
|
|
33
|
+
endpoint: string | 0
|
|
34
|
+
allowed: readonly string[]
|
|
35
|
+
denied: readonly string[]
|
|
36
|
+
/** The run's proxy: its port and the session token both legs carry. */
|
|
37
|
+
runProxyPort: number
|
|
38
|
+
token: string
|
|
39
|
+
/** The task's attribution key (`srt.<encodedCommand>`'s suffix). */
|
|
40
|
+
encodedCommand: string
|
|
41
|
+
/** A refusal this front made, in the runtime's own record form. */
|
|
42
|
+
refused(line: string): void
|
|
43
|
+
}): Promise<TaskProxy & { port: number | undefined }> {
|
|
44
|
+
const { http, socks, mux, pattern, parent } = await loadProxyParts()
|
|
45
|
+
const filter = (port: number, host: string): boolean => {
|
|
46
|
+
const deny = (reason: string) => {
|
|
47
|
+
opts.refused(`deny network-outbound ${host}:${port} (${reason})`)
|
|
48
|
+
return false
|
|
49
|
+
}
|
|
50
|
+
if (!parent.isValidHost(host)) return deny('malformed host')
|
|
51
|
+
const canonical: string = parent.canonicalizeHost(host) ?? host
|
|
52
|
+
if (opts.denied.some((d) => pattern.matchesDomainPatternWithPort(canonical, port, d)))
|
|
53
|
+
return deny('host is on the deny list')
|
|
54
|
+
if (opts.allowed.some((a) => pattern.matchesDomainPatternWithPort(canonical, port, a)))
|
|
55
|
+
return true
|
|
56
|
+
return deny('host is not on the allow list')
|
|
57
|
+
}
|
|
58
|
+
const user = encodeURIComponent(`srt.${opts.encodedCommand}`)
|
|
59
|
+
const up = new URL(
|
|
60
|
+
`http://${user}:${encodeURIComponent(opts.token)}@127.0.0.1:${opts.runProxyPort}`,
|
|
61
|
+
)
|
|
62
|
+
// Every allowed connection goes on to the run's proxy, which applies the
|
|
63
|
+
// run's lists and address guard again; only a loopback host, which the
|
|
64
|
+
// runtime never sends to a parent, is dialed from here, and only one the
|
|
65
|
+
// task named.
|
|
66
|
+
const parentProxy = {
|
|
67
|
+
httpUrl: up,
|
|
68
|
+
httpsUrl: up,
|
|
69
|
+
noProxy: { all: false, suffixes: [], cidr: new BlockList() },
|
|
70
|
+
}
|
|
71
|
+
const httpServer = http.createHttpProxyServer({
|
|
72
|
+
filter: (port: number, host: string) => filter(port, host),
|
|
73
|
+
parentProxy,
|
|
74
|
+
proxyAuthToken: opts.token,
|
|
75
|
+
})
|
|
76
|
+
const socksServer = socks.createSocksProxyServer({
|
|
77
|
+
filter: (port: number, host: string) => filter(port, host),
|
|
78
|
+
parentProxy,
|
|
79
|
+
proxyAuthToken: opts.token,
|
|
80
|
+
})
|
|
81
|
+
const front = mux.createMuxProxyServer({
|
|
82
|
+
httpServer,
|
|
83
|
+
handleSocksConnection: (s) => socksServer.handleConnection(s),
|
|
84
|
+
})
|
|
85
|
+
await front.listenHttpBackend()
|
|
86
|
+
await new Promise<void>((resolve, reject) => {
|
|
87
|
+
front.server.once('error', reject)
|
|
88
|
+
if (opts.endpoint === 0) front.server.listen(0, '127.0.0.1', () => resolve())
|
|
89
|
+
else front.server.listen(opts.endpoint, () => resolve())
|
|
90
|
+
})
|
|
91
|
+
front.unref()
|
|
92
|
+
return { port: front.getPort(), close: () => front.close() }
|
|
93
|
+
}
|
|
@@ -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/definition.ts
CHANGED
|
@@ -15,18 +15,31 @@ const memo = new WeakMap<TaskNode, string | undefined>()
|
|
|
15
15
|
export function taskDefinition(node: TaskNode): string | undefined {
|
|
16
16
|
if (memo.has(node)) return memo.get(node)
|
|
17
17
|
const exec = node.config.exec
|
|
18
|
+
const define = exec?.env?.define
|
|
18
19
|
const def =
|
|
19
20
|
exec?.command === undefined
|
|
20
21
|
? undefined
|
|
21
22
|
: xxh3hex(
|
|
22
|
-
`${node.id}\0${exec.command}\0${
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
`${node.id}\0${exec.command}\0${
|
|
24
|
+
// `[]` is what the stringify gives for no values: most tasks
|
|
25
|
+
// have none, and stringifying it for each was ~9 ms of an
|
|
26
|
+
// 8,002-hit warm run.
|
|
27
|
+
define === undefined || isEmpty(define)
|
|
28
|
+
? '[]'
|
|
29
|
+
: JSON.stringify(
|
|
30
|
+
Object.entries(define).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
31
|
+
)
|
|
32
|
+
}`,
|
|
25
33
|
)
|
|
26
34
|
memo.set(node, def)
|
|
27
35
|
return def
|
|
28
36
|
}
|
|
29
37
|
|
|
38
|
+
function isEmpty(o: object): boolean {
|
|
39
|
+
for (const _ in o) return false
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
/** Each task's definition (`taskDefinition`) by id, groups left out: what a history reader matches rows to. */
|
|
31
44
|
export function taskDefinitions(nodes: Iterable<TaskNode>): Map<string, string> {
|
|
32
45
|
const out = new Map<string, string>()
|