@vzn/vx 0.0.16 → 0.0.21
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/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// The sandbox half of an `ExecuteRequest`: what a task may read and write,
|
|
2
|
+
// where denials are reported, and the filesystem groundwork a bind needs
|
|
3
|
+
// (bwrap cannot bind a path that does not exist). Shared by the cached path
|
|
4
|
+
// (through the executor) and the persistent path (spawned in execute-task).
|
|
5
|
+
|
|
6
|
+
import { mkdir, readdir, realpath, stat } from 'node:fs/promises'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import type { ExecConfig } from '../config.js'
|
|
10
|
+
import {
|
|
11
|
+
initSandbox,
|
|
12
|
+
probeSandbox,
|
|
13
|
+
resolveSandboxConfig,
|
|
14
|
+
type ExecuteRequest,
|
|
15
|
+
} from '../exec/index.js'
|
|
16
|
+
import type { TaskNode } from '../graph/index.js'
|
|
17
|
+
import { staticPrefix, UserError } from '../util/index.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Arm the sandbox runtime for a run, lazily: only when at least one task
|
|
21
|
+
* opts in through its `sandbox: {...}` block. A task that needs sandboxing
|
|
22
|
+
* on a platform without one is a hard error, so it never silently runs
|
|
23
|
+
* unsandboxed. Returns whether the runtime was armed — the run resets it
|
|
24
|
+
* at the end (`resetSandbox`), or the next run inits on top of stale
|
|
25
|
+
* proxy state.
|
|
26
|
+
*/
|
|
27
|
+
export interface SandboxArmer {
|
|
28
|
+
/**
|
|
29
|
+
* Probe availability and start the runtime, once; every later call
|
|
30
|
+
* shares the same promise. Throws the `sandbox not available` UserError
|
|
31
|
+
* when the platform cannot host it.
|
|
32
|
+
*/
|
|
33
|
+
arm(): Promise<void>
|
|
34
|
+
/** True once `arm()` has completed — the run then owns a runtime to reset. */
|
|
35
|
+
readonly armed: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Prepare the sandbox for a run WITHOUT starting it. Starting is
|
|
40
|
+
* `arm()`, and it happens on the first task that actually executes inside
|
|
41
|
+
* a sandbox — not up front. Up front, every run of a sandboxed workspace
|
|
42
|
+
* paid the probe (a sandboxed `true` through the runtime, ~300–400 ms on
|
|
43
|
+
* Linux, the runtime module's own load included) even when every task was
|
|
44
|
+
* a cache hit and nothing executed; measured 2026-09-10 on this repo's
|
|
45
|
+
* own warm gate: `classify + probe` 288 ms of a 798 ms run. A hit needs
|
|
46
|
+
* no sandbox, so a hit pays nothing.
|
|
47
|
+
*
|
|
48
|
+
* The domain union is computed here from every sandboxed node, because
|
|
49
|
+
* SRT runs ONE filtering proxy per run and checks every request against
|
|
50
|
+
* the allowlist given to `initialize()` — never the per-call one
|
|
51
|
+
* (`sandbox-manager.js` 0.0.75). A task that declares no domains still
|
|
52
|
+
* reaches nothing: its profile is not given the proxy's port at all.
|
|
53
|
+
* The unix-socket allowance is per run the same way: SRT's Linux seccomp
|
|
54
|
+
* filter on `socket(AF_UNIX)` is all-or-nothing and read at
|
|
55
|
+
* `initialize()`, so a task declaring `unixSockets`, or a `localBinding`
|
|
56
|
+
* port list (its bridge is a unix socket the task's side creates), lifts
|
|
57
|
+
* it for the run. macOS keeps per-task precision through vx's own rules.
|
|
58
|
+
*/
|
|
59
|
+
export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
|
|
60
|
+
const sandboxed = [...nodes].filter((n) => n.config.exec?.sandbox !== undefined)
|
|
61
|
+
if (sandboxed.length === 0) return null
|
|
62
|
+
const weakerNested = sandboxed.every((n) => n.config.exec?.sandbox?.weakerWhenNested === true)
|
|
63
|
+
const domains = new Set<string>()
|
|
64
|
+
let unixSockets = false
|
|
65
|
+
for (const n of sandboxed) {
|
|
66
|
+
const allow = n.config.exec?.sandbox?.allow
|
|
67
|
+
const net = allow?.network
|
|
68
|
+
if (Array.isArray(net)) for (const d of net) domains.add(d)
|
|
69
|
+
const sockets = allow?.unixSockets
|
|
70
|
+
if (sockets === true || (Array.isArray(sockets) && sockets.length > 0)) unixSockets = true
|
|
71
|
+
const lb = allow?.localBinding
|
|
72
|
+
if (Array.isArray(lb) && lb.length > 0 && process.platform === 'linux') unixSockets = true
|
|
73
|
+
}
|
|
74
|
+
let pending: Promise<void> | undefined
|
|
75
|
+
let armed = false
|
|
76
|
+
return {
|
|
77
|
+
get armed() {
|
|
78
|
+
return armed
|
|
79
|
+
},
|
|
80
|
+
arm() {
|
|
81
|
+
pending ??= (async () => {
|
|
82
|
+
try {
|
|
83
|
+
const avail = await probeSandbox({ weakerNested })
|
|
84
|
+
if (!avail.available) throw new UserError(`sandbox not available: ${avail.reason}`)
|
|
85
|
+
await initSandbox({
|
|
86
|
+
allowedDomains: [...domains],
|
|
87
|
+
...(unixSockets ? { allowAllUnixSockets: true } : {}),
|
|
88
|
+
})
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// A throw from the runtime itself (its bridge needs socat, which
|
|
91
|
+
// the dependency check does not cover) gets the same one-line
|
|
92
|
+
// verdict as a refused probe, not an internal error with a stack.
|
|
93
|
+
if (err instanceof UserError) throw err
|
|
94
|
+
throw new UserError(
|
|
95
|
+
`sandbox not available: ${err instanceof Error ? err.message : String(err)}`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
armed = true
|
|
99
|
+
})()
|
|
100
|
+
return pending
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The sandbox half of an `ExecuteRequest` for one task, shared by the
|
|
107
|
+
* cached path (through the executor) and the persistent path (spawned here).
|
|
108
|
+
*
|
|
109
|
+
* The sandbox derives NOTHING from `cache` (owner, 2026-09-05). Those are
|
|
110
|
+
* two different questions: `cache.inputs` says what INVALIDATES the task,
|
|
111
|
+
* `sandbox.allow` says what it may TOUCH. Deriving one from the other
|
|
112
|
+
* coupled them in both directions — a declaration added for caching
|
|
113
|
+
* silently widened the sandbox, and a path the task needed had to be
|
|
114
|
+
* laundered through the cache key to get it. A sandboxed task declares
|
|
115
|
+
* its own reads and writes.
|
|
116
|
+
*
|
|
117
|
+
* `node_modules` is the one grant core still makes, and it is not
|
|
118
|
+
* cache-derived: it is where the task's own PATH gets its `.bin` entries,
|
|
119
|
+
* and denying it made the sandbox unusable for anything that imports a
|
|
120
|
+
* dependency (`bun build --compile` died with only `error: An unknown
|
|
121
|
+
* error occurred (Unexpected)`; owner call 2026-09-04).
|
|
122
|
+
*/
|
|
123
|
+
export async function sandboxRequestFor(
|
|
124
|
+
node: TaskNode,
|
|
125
|
+
sandbox: NonNullable<ExecConfig['sandbox']>,
|
|
126
|
+
workspaceRoot: string,
|
|
127
|
+
): Promise<NonNullable<ExecuteRequest['sandbox']>> {
|
|
128
|
+
const depDirs = [
|
|
129
|
+
path.join(node.projectDir, 'node_modules'),
|
|
130
|
+
path.join(workspaceRoot, 'node_modules'),
|
|
131
|
+
]
|
|
132
|
+
// A workspace dependency is a SYMLINK in `node_modules` pointing at a
|
|
133
|
+
// sibling project, so granting `node_modules` grants a link whose
|
|
134
|
+
// target is outside it. That target is a dependency, not a reach-out:
|
|
135
|
+
// no project config should have to name a sibling to import what its
|
|
136
|
+
// own `package.json` depends on (owner, 2026-09-05).
|
|
137
|
+
depDirs.push(...(await linkedDeps(depDirs)))
|
|
138
|
+
// bwrap cannot --bind a path that does not exist: the bind silently
|
|
139
|
+
// becomes a no-op and writes to it appear to succeed but never land.
|
|
140
|
+
// Pre-create what the task said it will write.
|
|
141
|
+
await prepareOutputsForBind(node.projectDir, sandbox.allow?.write ?? [])
|
|
142
|
+
return {
|
|
143
|
+
// Only what the task declared, plus node_modules. Write paths are
|
|
144
|
+
// readable too: a task that writes `dist/x` expects to read it back
|
|
145
|
+
// (`tsc --incremental` re-reads .tsbuildinfo).
|
|
146
|
+
baseAllowRead: depDirs,
|
|
147
|
+
baseAllowWrite: [],
|
|
148
|
+
// Enforcement anchors at the WORKSPACE ROOT: a task may not leave
|
|
149
|
+
// its project, so every sibling and every root file is denied.
|
|
150
|
+
// Reporting is a different question — see `reportWithin` below.
|
|
151
|
+
baseDenyRead: [workspaceRoot],
|
|
152
|
+
// …but only denials INSIDE the project are worth reporting. A task
|
|
153
|
+
// bumping into the wall is the sandbox working, not a finding: the
|
|
154
|
+
// walk `bun build --compile` makes from `/` down to its cwd lists
|
|
155
|
+
// every directory on the way (traced 2026-09-05) and no config can
|
|
156
|
+
// declare that away. What DOES matter is an undeclared touch of the
|
|
157
|
+
// project's own files — that is the one that breaks the cache key,
|
|
158
|
+
// because the key folds this project's inputs.
|
|
159
|
+
reportWithin: node.projectDir,
|
|
160
|
+
config: resolveSandboxConfig(sandbox, node.projectDir),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Where the workspace links in `dirs` actually point.
|
|
166
|
+
*
|
|
167
|
+
* One level deep, plus one level inside a `@scope/` directory — the shape
|
|
168
|
+
* a package manager writes. Anything already inside a granted directory
|
|
169
|
+
* is dropped; what is left is a sibling project's real path.
|
|
170
|
+
*/
|
|
171
|
+
async function linkedDeps(dirs: readonly string[]): Promise<string[]> {
|
|
172
|
+
const out = new Set<string>()
|
|
173
|
+
const scan = async (dir: string, depth: number): Promise<void> => {
|
|
174
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
175
|
+
for (const e of entries) {
|
|
176
|
+
const full = path.join(dir, e.name)
|
|
177
|
+
if (e.isSymbolicLink()) {
|
|
178
|
+
const target = await realpath(full).catch(() => undefined)
|
|
179
|
+
if (
|
|
180
|
+
target !== undefined &&
|
|
181
|
+
!dirs.some((d) => target === d || target.startsWith(d + path.sep))
|
|
182
|
+
) {
|
|
183
|
+
out.add(target)
|
|
184
|
+
}
|
|
185
|
+
} else if (depth === 0 && e.isDirectory() && e.name.startsWith('@')) {
|
|
186
|
+
await scan(full, 1)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
await Promise.all(dirs.map((d) => scan(d, 0)))
|
|
191
|
+
return [...out]
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Ensure each declared output path exists on the host as either an
|
|
196
|
+
* empty file (for literal output specs) or a directory (for globbed
|
|
197
|
+
* specs) so bwrap's --bind can find a real fs entry to mount. Without
|
|
198
|
+
* this, writes inside the sandbox to a non-existent allowWrite path
|
|
199
|
+
* silently disappear (bwrap creates a tmpfs that evaporates on exit).
|
|
200
|
+
*
|
|
201
|
+
* `cleanOutputs` ran just before this in the cache-enabled path, so
|
|
202
|
+
* we know any stale content was wiped; what's left is to materialize
|
|
203
|
+
* the empty skeleton.
|
|
204
|
+
*/
|
|
205
|
+
async function prepareOutputsForBind(
|
|
206
|
+
projectDir: string,
|
|
207
|
+
outputs: readonly string[],
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
for (const g of outputs) {
|
|
210
|
+
const hasWildcard = /[*?[\]]/.test(g)
|
|
211
|
+
// A grant OUTSIDE the project is the user's own path — never joined
|
|
212
|
+
// onto the project dir, which would create a literal `~` there. Its
|
|
213
|
+
// directory is still created when the grant is a glob, because then
|
|
214
|
+
// the static prefix is unambiguously a directory and bwrap cannot
|
|
215
|
+
// bind one that does not exist: `~/.bun/install/cache/**` on a runner
|
|
216
|
+
// that has never populated it silently granted nothing, and the task
|
|
217
|
+
// failed with the tool's own confusing message (`bun build --compile`
|
|
218
|
+
// reported a network error for an unwritable cache; 2026-09-05).
|
|
219
|
+
if (path.isAbsolute(g) || g.startsWith('~')) {
|
|
220
|
+
if (!hasWildcard) continue
|
|
221
|
+
const abs = expandHome(staticPrefix(g))
|
|
222
|
+
await mkdir(abs, { recursive: true }).catch(() => undefined)
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
if (hasWildcard) {
|
|
226
|
+
const abs = path.join(projectDir, staticPrefix(g))
|
|
227
|
+
await mkdir(abs, { recursive: true })
|
|
228
|
+
} else {
|
|
229
|
+
const abs = path.join(projectDir, g)
|
|
230
|
+
// Whatever is already there is what the task meant — a grant on the
|
|
231
|
+
// project dir itself is a directory, and touching it as a file is
|
|
232
|
+
// an EISDIR, not a missing bind.
|
|
233
|
+
if (await stat(abs).catch(() => undefined)) continue
|
|
234
|
+
await mkdir(path.dirname(abs), { recursive: true })
|
|
235
|
+
await Bun.write(abs, '')
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** `~/x` against the user's home; anything else unchanged. */
|
|
241
|
+
function expandHome(p: string): string {
|
|
242
|
+
return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p
|
|
243
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The save lane: a miss's cache save runs OFF the execution slot. The
|
|
2
|
+
// slot is CPU-shaped (`--concurrency`) and the save is not — pack, write,
|
|
3
|
+
// rename and one index transaction, ~2.5 ms of mostly I/O per one-file
|
|
4
|
+
// artifact against ~5 ms of execution on the 1,000-project bench
|
|
5
|
+
// (2026-09-10) — so holding the slot for it serialized I/O behind CPU.
|
|
6
|
+
// A bounded number run at once (memory: each pack holds an artifact's
|
|
7
|
+
// bytes), the run drains them before the upload drain, and a save that
|
|
8
|
+
// fails degrades to a miss next time, said once — the task's work ran.
|
|
9
|
+
|
|
10
|
+
export interface SaveLane {
|
|
11
|
+
/**
|
|
12
|
+
* Start `save` now if the lane has room, else when one finishes. Resolves
|
|
13
|
+
* once the save has settled — a failure is reported and resolves too —
|
|
14
|
+
* for the one reader that must wait for the entry: a duplicate of the
|
|
15
|
+
* same task in another run (admission's in-flight join).
|
|
16
|
+
*/
|
|
17
|
+
defer(save: () => Promise<void>): Promise<void>
|
|
18
|
+
/** Settle every deferred save, started or queued. */
|
|
19
|
+
drain(): Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createSaveLane(cap: number, onError: (err: unknown) => void): SaveLane {
|
|
23
|
+
const queue: Array<{ save: () => Promise<void>; settle: () => void }> = []
|
|
24
|
+
const running = new Set<Promise<void>>()
|
|
25
|
+
const start = (save: () => Promise<void>, settle: () => void): void => {
|
|
26
|
+
const p = save()
|
|
27
|
+
.catch(onError)
|
|
28
|
+
.then(() => {
|
|
29
|
+
running.delete(p)
|
|
30
|
+
settle()
|
|
31
|
+
const next = queue.shift()
|
|
32
|
+
if (next !== undefined) start(next.save, next.settle)
|
|
33
|
+
})
|
|
34
|
+
running.add(p)
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
defer(save) {
|
|
38
|
+
return new Promise<void>((settle) => {
|
|
39
|
+
if (running.size < cap) start(save, settle)
|
|
40
|
+
else queue.push({ save, settle })
|
|
41
|
+
})
|
|
42
|
+
},
|
|
43
|
+
async drain() {
|
|
44
|
+
while (running.size > 0) await Promise.all([...running])
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// A SIGINT/SIGTERM mid-run forwards SIGTERM to everything live, waits a
|
|
2
|
+
// bounded grace for it to go, SIGKILLs what is still there, closes the
|
|
3
|
+
// cache handle, and exits 128+signo (130/143). Without the forward, a
|
|
4
|
+
// programmatic signal to the vx process alone (CI cancellation,
|
|
5
|
+
// `kill <pid>`) orphans every running child — terminal Ctrl-C only worked
|
|
6
|
+
// via process-group propagation. Without the escalation (added
|
|
7
|
+
// 2026-09-10) a child that traps TERM — a dev server mid-cleanup, a test
|
|
8
|
+
// runner that ignores it — outlived the run that owned it: vx exited
|
|
9
|
+
// 143 and left it running under init. Split from run.ts on 2026-09-10
|
|
10
|
+
// (pure motion); the registries it reads stay with `run()`, which hands
|
|
11
|
+
// them to the runner around every spawn.
|
|
12
|
+
|
|
13
|
+
import { signalExitCode } from '../exec/index.js'
|
|
14
|
+
import { killGraceMs } from '../util/index.js'
|
|
15
|
+
import type { Logger } from './logger.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How long a SIGTERMed child gets before SIGKILL on the way out. The
|
|
19
|
+
* end-of-run persistent shutdown grants the same two seconds; both read
|
|
20
|
+
* `VX_KILL_GRACE_MS` (util/settle.ts) so the suites that prove the
|
|
21
|
+
* escalation do not wait the full grace.
|
|
22
|
+
*/
|
|
23
|
+
export const SIGNAL_SHUTDOWN_GRACE_MS = 2000
|
|
24
|
+
|
|
25
|
+
type Child = ReturnType<typeof Bun.spawn>
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* SIGTERM every child `live()` returns, wait the grace for them to go,
|
|
29
|
+
* then SIGKILL whatever `live()` returns NOW — re-read, because the run
|
|
30
|
+
* loop may still be dispatching during the grace and a child spawned
|
|
31
|
+
* after the first sweep must not survive the second. Resolves once the
|
|
32
|
+
* survivors are reaped. The one teardown behind the process-signal
|
|
33
|
+
* handler below, `RunOptions.signal`, and the foreground keep-alive.
|
|
34
|
+
*/
|
|
35
|
+
export async function terminateChildren(
|
|
36
|
+
live: () => Child[],
|
|
37
|
+
graceMs: number = killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS),
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
const children = live()
|
|
40
|
+
for (const child of children) child.kill('SIGTERM')
|
|
41
|
+
const allExited = Promise.allSettled(children.map((c) => c.exited))
|
|
42
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
43
|
+
// Not unref'd: it is what guarantees progress when every other handle
|
|
44
|
+
// has drained, and the grace is bounded either way.
|
|
45
|
+
await Promise.race([
|
|
46
|
+
allExited,
|
|
47
|
+
new Promise<void>((resolve) => {
|
|
48
|
+
graceTimer = setTimeout(resolve, graceMs)
|
|
49
|
+
}),
|
|
50
|
+
])
|
|
51
|
+
if (graceTimer !== undefined) clearTimeout(graceTimer)
|
|
52
|
+
const survivors = live()
|
|
53
|
+
for (const child of survivors) child.kill('SIGKILL')
|
|
54
|
+
await Promise.allSettled(survivors.map((c) => c.exited))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SignalForwarding {
|
|
58
|
+
/** Detach the handlers — in `run()`'s finally, so repeated runs never stack listeners. */
|
|
59
|
+
remove(): void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function forwardSignals(args: {
|
|
63
|
+
/** `RunOptions.handleSignals` — an embedder that owns the process's signals opts out. */
|
|
64
|
+
enabled: boolean
|
|
65
|
+
log: Logger
|
|
66
|
+
cache: { close(): void }
|
|
67
|
+
/** In-flight children; the runner adds and removes each around its spawn. */
|
|
68
|
+
liveChildren: ReadonlySet<Child>
|
|
69
|
+
/** Ready persistent tasks the orchestrator owns until the graph finishes. */
|
|
70
|
+
persistentRegistry: ReadonlyMap<string, Child>
|
|
71
|
+
}): SignalForwarding {
|
|
72
|
+
const everyChild = (): Child[] => [...args.liveChildren, ...args.persistentRegistry.values()]
|
|
73
|
+
const exit = (signal: 'SIGINT' | 'SIGTERM'): never => {
|
|
74
|
+
for (const child of everyChild()) child.kill('SIGKILL')
|
|
75
|
+
try {
|
|
76
|
+
args.cache.close()
|
|
77
|
+
} catch {
|
|
78
|
+
// double-close race with the normal path; we're exiting anyway
|
|
79
|
+
}
|
|
80
|
+
process.exit(signalExitCode(signal))
|
|
81
|
+
}
|
|
82
|
+
let stopping: 'SIGINT' | 'SIGTERM' | undefined
|
|
83
|
+
const onSignal = (signal: 'SIGINT' | 'SIGTERM'): void => {
|
|
84
|
+
// A second signal during the grace is the user saying "now": SIGKILL
|
|
85
|
+
// and go. The exit code stays the first signal's — that is the one
|
|
86
|
+
// that ended the run.
|
|
87
|
+
if (stopping !== undefined) exit(stopping)
|
|
88
|
+
stopping = signal
|
|
89
|
+
// Clear the live worker/status region BEFORE exiting so a TTY isn't
|
|
90
|
+
// left with a frozen region in the scrollback. runEnd is idempotent
|
|
91
|
+
// and a no-op for non-TTY loggers.
|
|
92
|
+
try {
|
|
93
|
+
args.log.runEnd?.()
|
|
94
|
+
} catch {
|
|
95
|
+
// teardown must not throw on the way out
|
|
96
|
+
}
|
|
97
|
+
void terminateChildren(everyChild).then(() => exit(signal))
|
|
98
|
+
}
|
|
99
|
+
const onSigint = (): void => onSignal('SIGINT')
|
|
100
|
+
const onSigterm = (): void => onSignal('SIGTERM')
|
|
101
|
+
if (args.enabled) {
|
|
102
|
+
process.on('SIGINT', onSigint)
|
|
103
|
+
process.on('SIGTERM', onSigterm)
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
remove: () => {
|
|
107
|
+
process.off('SIGINT', onSigint)
|
|
108
|
+
process.off('SIGTERM', onSigterm)
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
15
15
|
import type { CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
16
|
+
import { normalizeGlob, relPosix } from '../util/index.js'
|
|
16
17
|
import { computeGroupHash, computeTaskHash, type HashCache } from './task-hash.js'
|
|
17
18
|
|
|
18
19
|
export interface DeriveStableKeysArgs {
|
|
@@ -47,16 +48,53 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
47
48
|
const order = topoOrder(args.nodes)
|
|
48
49
|
const keyById = new Map<string, string>()
|
|
49
50
|
const unstableById = new Set<string>()
|
|
51
|
+
// Transitive-upstream output producers, accumulated in topo order — the
|
|
52
|
+
// inputs to the stability gate. A task's key is preliminary if an upstream
|
|
53
|
+
// writes where its inputs read, and that reach is TRANSITIVE: a producer
|
|
54
|
+
// reached through a no-output intermediate still poisons the key, so each
|
|
55
|
+
// dep's accumulated producers fold forward.
|
|
56
|
+
// - outputProjects: the project names of every upstream task declaring
|
|
57
|
+
// cache.outputs.files (project-relative outputs land in the producer's
|
|
58
|
+
// own dir).
|
|
59
|
+
// - wsOutputUpstream: any upstream declares cache.outputs.workspaceFiles
|
|
60
|
+
// (root-anchored, boundary-ignoring outputs).
|
|
61
|
+
const outputProjectsById = new Map<string, ReadonlySet<string>>()
|
|
62
|
+
const wsOutputUpstreamById = new Map<string, boolean>()
|
|
50
63
|
const stableKeys: StableKey[] = []
|
|
64
|
+
// Workspace-relative project dirs, for the reach test of a
|
|
65
|
+
// `workspaceFiles` reader against its upstream producers.
|
|
66
|
+
const dirByProject = new Map<string, string>()
|
|
67
|
+
for (const node of args.nodes.values()) {
|
|
68
|
+
if (!dirByProject.has(node.projectName)) {
|
|
69
|
+
dirByProject.set(node.projectName, relPosix(args.workspaceRoot, node.projectDir))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
51
72
|
|
|
52
73
|
for (const id of order) {
|
|
53
74
|
const node = args.nodes.get(id)!
|
|
54
75
|
const upstream = synthUpstream(node, args.nodes, keyById)
|
|
55
76
|
|
|
77
|
+
// Fold every dep's accumulated producers + the dep's own declared
|
|
78
|
+
// outputs into this node's transitive-upstream producer sets.
|
|
79
|
+
const outputProjects = new Set<string>()
|
|
80
|
+
let wsOutputUpstream = false
|
|
81
|
+
for (const dep of node.deps) {
|
|
82
|
+
const depNode = args.nodes.get(dep)
|
|
83
|
+
if (!depNode) continue
|
|
84
|
+
for (const p of outputProjectsById.get(dep) ?? []) outputProjects.add(p)
|
|
85
|
+
if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
|
|
86
|
+
const depOut = depNode.config.cache?.outputs
|
|
87
|
+
if ((depOut?.files?.length ?? 0) > 0) outputProjects.add(depNode.projectName)
|
|
88
|
+
if ((depOut?.workspaceFiles?.length ?? 0) > 0) wsOutputUpstream = true
|
|
89
|
+
}
|
|
90
|
+
outputProjectsById.set(id, outputProjects)
|
|
91
|
+
wsOutputUpstreamById.set(id, wsOutputUpstream)
|
|
92
|
+
|
|
56
93
|
if (isGroupTask(node)) {
|
|
57
94
|
// Groups have no exec/cache; they only fold upstream keys so
|
|
58
95
|
// dependents that filter inputs.tasks through the group still
|
|
59
|
-
// cascade
|
|
96
|
+
// cascade (and forward their producers, above). They inherit
|
|
97
|
+
// instability from any unstable member.
|
|
60
98
|
keyById.set(id, computeGroupHash(upstream))
|
|
61
99
|
if (node.deps.some((d) => unstableById.has(d))) unstableById.add(id)
|
|
62
100
|
continue
|
|
@@ -76,7 +114,8 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
76
114
|
keyById.set(id, hash)
|
|
77
115
|
|
|
78
116
|
const unstable =
|
|
79
|
-
node.deps.some((d) => unstableById.has(d)) ||
|
|
117
|
+
node.deps.some((d) => unstableById.has(d)) ||
|
|
118
|
+
dependsOnSiblingOutputs(node, outputProjects, wsOutputUpstream, dirByProject)
|
|
80
119
|
if (unstable) unstableById.add(id)
|
|
81
120
|
|
|
82
121
|
const cacheEnabled = node.config.cache !== undefined
|
|
@@ -90,7 +129,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
90
129
|
* are read by filterUpstreamHashes / computeGroupHash; the rest of
|
|
91
130
|
* TaskOutcome is irrelevant here, so we cast a minimal shape.
|
|
92
131
|
*/
|
|
93
|
-
|
|
132
|
+
function synthUpstream(
|
|
94
133
|
node: TaskNode,
|
|
95
134
|
nodes: Map<string, TaskNode>,
|
|
96
135
|
keyById: Map<string, string>,
|
|
@@ -111,44 +150,103 @@ export function synthUpstream(
|
|
|
111
150
|
}
|
|
112
151
|
|
|
113
152
|
/**
|
|
114
|
-
* Conservative stability check: does any
|
|
115
|
-
* could land where this task reads its inputs make this task's
|
|
116
|
-
* preliminary?
|
|
153
|
+
* Conservative stability check: does any TRANSITIVE-upstream task whose
|
|
154
|
+
* outputs could land where this task reads its inputs make this task's
|
|
155
|
+
* key preliminary? The producers are pre-folded by `deriveStableKeys`
|
|
156
|
+
* (`upstreamOutputProjects` = projects declaring `cache.outputs.files`
|
|
157
|
+
* upstream; `hasWsOutputUpstream` = any `cache.outputs.workspaceFiles`
|
|
158
|
+
* upstream), so a producer reached through a no-output intermediate is
|
|
159
|
+
* caught too.
|
|
117
160
|
*
|
|
118
|
-
* -
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* outputs
|
|
127
|
-
*
|
|
161
|
+
* - Project-relative inputs (default `**` / anything project-relative)
|
|
162
|
+
* read THIS project's dir, so a same-project upstream's
|
|
163
|
+
* `outputs.files` — direct or transitive — makes the key
|
|
164
|
+
* preliminary. Project boundaries are hard, so only SAME-project
|
|
165
|
+
* outputs can reach a project-relative input.
|
|
166
|
+
* - `cache.inputs.workspaceFiles` is boundary-free: it can read ANY
|
|
167
|
+
* project's dir (so any upstream `outputs.files`, in any project,
|
|
168
|
+
* matters) or a root-anchored location (so any upstream
|
|
169
|
+
* `outputs.workspaceFiles` matters). Either → preliminary key. This
|
|
170
|
+
* is the reach the earlier per-dep check missed — it only compared a
|
|
171
|
+
* dep's `outputs.workspaceFiles`, never a dep's `outputs.files`.
|
|
128
172
|
*
|
|
129
|
-
* Either case → unstable → skip prefetch (
|
|
130
|
-
*
|
|
173
|
+
* Either case → unstable → skip prefetch AND probe reuse (execute-task
|
|
174
|
+
* recomputes the key lazily once the upstream ran). When in doubt,
|
|
175
|
+
* unstable.
|
|
131
176
|
*/
|
|
132
|
-
export function dependsOnSiblingOutputs(
|
|
133
|
-
|
|
177
|
+
export function dependsOnSiblingOutputs(
|
|
178
|
+
node: TaskNode,
|
|
179
|
+
upstreamOutputProjects: ReadonlySet<string>,
|
|
180
|
+
hasWsOutputUpstream: boolean,
|
|
181
|
+
dirByProject?: ReadonlyMap<string, string>,
|
|
182
|
+
): boolean {
|
|
183
|
+
const cache = node.config.cache
|
|
134
184
|
// A cache-disabled task has no key to prefetch anyway; treat as
|
|
135
185
|
// unstable-irrelevant (caller filters on cacheEnabled).
|
|
136
|
-
if (
|
|
137
|
-
|
|
186
|
+
if (cache === undefined) return false
|
|
187
|
+
if (upstreamOutputProjects.has(node.projectName)) return true
|
|
188
|
+
// A root-anchored output is boundary-IGNORING by design, so it can land
|
|
189
|
+
// inside THIS task's own project dir — where an ordinary project-relative
|
|
190
|
+
// input reads it. Neither other clause sees that: `upstreamOutputProjects`
|
|
191
|
+
// holds the PRODUCER's project, not this one, and the reader clause below
|
|
192
|
+
// requires this task to read workspace-anchored inputs, which it need not.
|
|
193
|
+
// That gap was a real stale hit (a producer writing `pkgs/app/gen/**` while
|
|
194
|
+
// `app` read `gen/**` project-relative), so the mere presence of a
|
|
195
|
+
// workspace-output producer upstream makes the key preliminary.
|
|
196
|
+
if (hasWsOutputUpstream) return true
|
|
197
|
+
const wsInputs = cache.inputs?.workspaceFiles ?? []
|
|
198
|
+
if (wsInputs.length > 0 && upstreamOutputProjects.size > 0) {
|
|
199
|
+
// Without the dirs the old answer stands: a workspace reader over any
|
|
200
|
+
// producer is preliminary. With them, only a reader whose globs can
|
|
201
|
+
// reach a producer's directory is. A Turbo-mapped workspace gives every
|
|
202
|
+
// task its `globalDependencies` as root literals (`turbo.json`), which
|
|
203
|
+
// reach no package — classing those as unstable hashed medusa's 83
|
|
204
|
+
// tasks twice and probed 76 of them lazily (2026-09-11).
|
|
205
|
+
if (dirByProject === undefined) return true
|
|
206
|
+
const dirs: string[] = []
|
|
207
|
+
for (const p of upstreamOutputProjects) {
|
|
208
|
+
const d = dirByProject.get(p)
|
|
209
|
+
if (d === undefined) return true
|
|
210
|
+
dirs.push(d)
|
|
211
|
+
}
|
|
212
|
+
return workspaceInputsReach(wsInputs, dirs)
|
|
213
|
+
}
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
138
216
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Can any positive `workspaceFiles` entry match a path inside one of
|
|
219
|
+
* `dirs` (workspace-relative project dirs)? An entry's reach is its
|
|
220
|
+
* literal prefix — the segments before the first one holding a glob
|
|
221
|
+
* metacharacter — and it reaches a dir when either is the other's
|
|
222
|
+
* ancestor or equal. An entry with no literal prefix (`**\/*`) reaches
|
|
223
|
+
* everything. Conservative on every unknown spelling.
|
|
224
|
+
*/
|
|
225
|
+
export function workspaceInputsReach(
|
|
226
|
+
workspaceFiles: readonly string[],
|
|
227
|
+
dirs: readonly string[],
|
|
228
|
+
): boolean {
|
|
229
|
+
for (const raw of workspaceFiles) {
|
|
230
|
+
if (raw.startsWith('!')) continue
|
|
231
|
+
const entry = normalizeGlob(raw)
|
|
232
|
+
const segments = entry.split('/')
|
|
233
|
+
const literal: string[] = []
|
|
234
|
+
for (const seg of segments) {
|
|
235
|
+
if (/[*?[\]{}]/.test(seg)) break
|
|
236
|
+
literal.push(seg)
|
|
237
|
+
}
|
|
238
|
+
if (literal.length === 0) return true
|
|
239
|
+
const prefix = literal.join('/')
|
|
240
|
+
for (const dir of dirs) {
|
|
241
|
+
if (dir === '' || dir === '.') return true
|
|
242
|
+
if (prefix === dir || prefix.startsWith(`${dir}/`) || dir.startsWith(`${prefix}/`))
|
|
243
|
+
return true
|
|
244
|
+
}
|
|
147
245
|
}
|
|
148
246
|
return false
|
|
149
247
|
}
|
|
150
248
|
|
|
151
|
-
|
|
249
|
+
function topoOrder(nodes: Map<string, TaskNode>): string[] {
|
|
152
250
|
const indegree = new Map<string, number>()
|
|
153
251
|
const dependents = new Map<string, string[]>()
|
|
154
252
|
for (const node of nodes.values()) {
|