@vzn/vx 0.0.18 → 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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -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 {
@@ -60,6 +61,14 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
60
61
  const outputProjectsById = new Map<string, ReadonlySet<string>>()
61
62
  const wsOutputUpstreamById = new Map<string, boolean>()
62
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
+ }
63
72
 
64
73
  for (const id of order) {
65
74
  const node = args.nodes.get(id)!
@@ -106,7 +115,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
106
115
 
107
116
  const unstable =
108
117
  node.deps.some((d) => unstableById.has(d)) ||
109
- dependsOnSiblingOutputs(node, outputProjects, wsOutputUpstream)
118
+ dependsOnSiblingOutputs(node, outputProjects, wsOutputUpstream, dirByProject)
110
119
  if (unstable) unstableById.add(id)
111
120
 
112
121
  const cacheEnabled = node.config.cache !== undefined
@@ -169,6 +178,7 @@ export function dependsOnSiblingOutputs(
169
178
  node: TaskNode,
170
179
  upstreamOutputProjects: ReadonlySet<string>,
171
180
  hasWsOutputUpstream: boolean,
181
+ dirByProject?: ReadonlyMap<string, string>,
172
182
  ): boolean {
173
183
  const cache = node.config.cache
174
184
  // A cache-disabled task has no key to prefetch anyway; treat as
@@ -184,8 +194,55 @@ export function dependsOnSiblingOutputs(
184
194
  // `app` read `gen/**` project-relative), so the mere presence of a
185
195
  // workspace-output producer upstream makes the key preliminary.
186
196
  if (hasWsOutputUpstream) return true
187
- const readsWorkspaceFiles = (cache.inputs?.workspaceFiles?.length ?? 0) > 0
188
- if (readsWorkspaceFiles && upstreamOutputProjects.size > 0) 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
+ }
216
+
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
+ }
245
+ }
189
246
  return false
190
247
  }
191
248
 
@@ -248,13 +248,14 @@ export function formatFailureLine(
248
248
  id: string,
249
249
  durationMs: number | null,
250
250
  colors: ColorSupport = NO_COLOR,
251
+ cacheWord: 'miss' | 'no-cache' = 'miss',
251
252
  ): string {
252
253
  return formatTaskRow(
253
254
  paint(ERROR, '◼\uFE0E', colors),
254
255
  durationMs,
255
256
  'failed',
256
257
  ERROR,
257
- 'miss',
258
+ cacheWord,
258
259
  '',
259
260
  paintPinnedId(id, colors),
260
261
  colors,