@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.
Files changed (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /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
- const dependentsOf = new Map<string, string[]>()
33
- for (const n of nodes) {
34
- for (const upstreamId of n.deps) {
35
- const list = dependentsOf.get(upstreamId)
36
- if (list) list.push(n.id)
37
- else dependentsOf.set(upstreamId, [n.id])
32
+ // Integer ids and flat arrays: Maps keyed by id strings and an array per
33
+ // node were 53 ms of a 9,603-task run's schedule stage (2026-11-01).
34
+ const count = nodes.length
35
+ const index = new Map<string, number>()
36
+ for (let i = 0; i < count; i++) index.set(nodes[i]!.id, i)
37
+ // Each node's dependents, as one flat list sliced by `first`.
38
+ const ups: number[] = []
39
+ const first = new Int32Array(count + 1)
40
+ for (let i = 0; i < count; i++) {
41
+ for (const up of nodes[i]!.deps) {
42
+ const u = index.get(up)
43
+ if (u === undefined) continue
44
+ first[u + 1]!++
45
+ ups.push(u, i)
38
46
  }
39
47
  }
48
+ for (let i = 0; i < count; i++) first[i + 1]! += first[i]!
49
+ const next = first.slice(0, count)
50
+ const dependents = new Int32Array(ups.length / 2)
51
+ for (let e = 0; e < ups.length; e += 2) dependents[next[ups[e]!]!++] = ups[e + 1]!
40
52
  // A group runs nothing, so it has no history; the median would charge it
41
53
  // a whole task and let a gate outrank the longest chain (PR #3286).
42
54
  const ownDuration = (n: TaskNode): number =>
@@ -47,33 +59,33 @@ export function criticalPath(
47
59
  // Reverse-topological pass: a node's value is final once every dependent's
48
60
  // is, so start from the sinks (no dependents) and release each upstream
49
61
  // when its last dependent has been scored.
50
- const memo = new Map<string, number>()
51
- const nodeById = new Map<string, TaskNode>(nodes.map((n) => [n.id, n]))
52
- const pending = new Map<string, number>()
53
- const queue: TaskNode[] = []
54
- for (const n of nodes) {
55
- const count = dependentsOf.get(n.id)?.length ?? 0
56
- pending.set(n.id, count)
57
- if (count === 0) queue.push(n)
62
+ const score = new Float64Array(count)
63
+ const scored = new Uint8Array(count)
64
+ const pending = new Int32Array(count)
65
+ const queue = new Int32Array(count)
66
+ let tail = 0
67
+ for (let i = 0; i < count; i++) {
68
+ pending[i] = first[i + 1]! - first[i]!
69
+ if (pending[i] === 0) queue[tail++] = i
58
70
  }
59
- let head = 0
60
- while (head < queue.length) {
61
- const n = queue[head++]!
71
+ for (let head = 0; head < tail; head++) {
72
+ const i = queue[head]!
62
73
  let downstream = 0
63
- for (const dep of dependentsOf.get(n.id) ?? []) {
64
- const d = memo.get(dep) ?? 0
74
+ for (let e = first[i]!; e < first[i + 1]!; e++) {
75
+ const d = score[dependents[e]!]!
65
76
  if (d > downstream) downstream = d
66
77
  }
67
- memo.set(n.id, ownDuration(n) + downstream)
68
- for (const up of n.deps) {
69
- const left = (pending.get(up) ?? 0) - 1
70
- pending.set(up, left)
71
- if (left === 0) {
72
- const upNode = nodeById.get(up)
73
- if (upNode) queue.push(upNode)
74
- }
78
+ score[i] = ownDuration(nodes[i]!) + downstream
79
+ scored[i] = 1
80
+ for (const up of nodes[i]!.deps) {
81
+ const u = index.get(up)
82
+ if (u !== undefined && --pending[u]! === 0) queue[tail++] = u
75
83
  }
76
84
  }
77
- for (const n of nodes) if (!memo.has(n.id)) memo.set(n.id, ownDuration(n))
85
+ const memo = new Map<string, number>()
86
+ for (let head = 0; head < tail; head++) memo.set(nodes[queue[head]!]!.id, score[queue[head]!]!)
87
+ // A node on a cycle is never released: its own duration.
88
+ for (let i = 0; i < count; i++)
89
+ if (scored[i] === 0) memo.set(nodes[i]!.id, ownDuration(nodes[i]!))
78
90
  return memo
79
91
  }
@@ -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${JSON.stringify(
23
- Object.entries(exec.env?.define ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
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>()