@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.
Files changed (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. 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. They inherit instability from any unstable member.
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)) || dependsOnSiblingOutputs(node, args.nodes)
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
- export function synthUpstream(
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 UPSTREAM task whose outputs
115
- * could land where this task reads its inputs make this task's key
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
- * - Same-project upstream with declared `cache.outputs.files`: its
119
- * outputs land inside this project's dir, which this task's input
120
- * globs (default `**` / anything project-relative) can match —
121
- * the upstream must run before this key is final. Project
122
- * boundaries are hard, so only SAME-project outputs can reach a
123
- * project-relative input.
124
- * - Any upstream with declared `cache.outputs.workspaceFiles` when
125
- * this task reads `cache.inputs.workspaceFiles`: root-anchored
126
- * outputs ignore boundaries and can land anywhere the root-anchored
127
- * inputs read.
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 (lazy read-through in
130
- * execute-task stays correct). When in doubt, unstable.
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(node: TaskNode, nodes: Map<string, TaskNode>): boolean {
133
- const inputs = node.config.cache?.inputs
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 (node.config.cache === undefined) return false
137
- const readsWorkspaceFiles = (inputs?.workspaceFiles?.length ?? 0) > 0
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
- for (const dep of node.deps) {
140
- const depNode = nodes.get(dep)
141
- if (!depNode) continue
142
- const depOutputs = depNode.config.cache?.outputs
143
- if (depOutputs === undefined) continue
144
- const sameProject = depNode.projectName === node.projectName
145
- if (sameProject && (depOutputs.files?.length ?? 0) > 0) return true
146
- if (readsWorkspaceFiles && (depOutputs.workspaceFiles?.length ?? 0) > 0) return true
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
- export function topoOrder(nodes: Map<string, TaskNode>): string[] {
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()) {