@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,117 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { constants as osConstants } from 'node:os'
3
+ import path from 'node:path'
4
+ import { execWord, exitSignal } from '../exec/index.js'
5
+
6
+ /**
7
+ * The frame line for a shell verdict the task's own output leaves a mystery.
8
+ * `sh -c 'exec <word> …'` exits 127 ("not found") and 126 ("cannot
9
+ * execute"), and its one line names the word and nothing about why: a bare
10
+ * word is a PATH lookup, and the PATH is vx's (item 257); a word with a
11
+ * slash is a file, and the file itself says why — missing, a directory, no
12
+ * execute bit, a `#!` interpreter that is not there (dash and bash 5 say
13
+ * "not found" and blame the file; bash 3.2 names it itself and exits 1),
14
+ * or no `#!` line at all (item 258). An exit above 128 is the shell's
15
+ * report of a death by signal, and the number alone says nothing about
16
+ * what sent it — the OOM killer, a crash in native code, an abort, a
17
+ * closed pipe (item 259).
18
+ */
19
+ export function shellVerdict(args: {
20
+ code: number
21
+ command: string
22
+ cwd: string
23
+ bins: string[]
24
+ /** The signal that killed the child when the runner saw one (`RunResult.signal`). */
25
+ signal?: string | undefined
26
+ }): string | undefined {
27
+ if (args.code !== 127 && args.code !== 126) return signalVerdict(args.code, args.signal)
28
+ const word = execWord(args.command)
29
+ const what = word ?? 'a command in this task'
30
+ if (word !== undefined && word.includes('/')) {
31
+ const file = path.resolve(args.cwd, word)
32
+ return `[vx] exit ${args.code} is the shell's "${args.code === 127 ? 'not found' : 'cannot execute'}": ${word} ${fileVerdict(file)}`
33
+ }
34
+ if (args.code === 126) {
35
+ return `[vx] exit 126 is the shell's "found but cannot execute": ${what} is not executable or is a directory — chmod +x it`
36
+ }
37
+ return `[vx] exit 127 is the shell's "command not found": ${what} is not on this task's PATH — vx puts ${args.bins.join(' and ')} first and never a sibling project's bin; install it in this package or at the workspace root`
38
+ }
39
+
40
+ /** Why the shell could not run a file, read from the file. */
41
+ function fileVerdict(file: string): string {
42
+ if (!existsSync(file)) {
43
+ return `does not exist — looked for ${file}, relative to the task's working directory`
44
+ }
45
+ let head: string
46
+ try {
47
+ const st = statSync(file)
48
+ if (st.isDirectory()) return 'is a directory'
49
+ if ((st.mode & 0o111) === 0) return 'is not executable — chmod +x it'
50
+ head = readFileSync(file, 'latin1').slice(0, 256)
51
+ } catch (err) {
52
+ return `could not be read (${(err as Error).message})`
53
+ }
54
+ if (!head.startsWith('#!')) {
55
+ return 'has no #! line, so it ran as a binary the loader refused — add a #! line or build it for this platform'
56
+ }
57
+ const nl = head.indexOf('\n')
58
+ const line = head.slice(2, nl === -1 ? head.length : nl)
59
+ if (line.endsWith('\r')) {
60
+ return `exists, and its #! line ends in CRLF, so the interpreter the shell looked for is "${line.trim()}\\r" — convert the file to LF line endings`
61
+ }
62
+ const interp = line.trim().split(/\s+/)[0] ?? ''
63
+ if (interp.startsWith('/') && !existsSync(interp)) {
64
+ return `exists, and its #! interpreter ${interp} does not — install it or fix the line`
65
+ }
66
+ return `exists and is executable, and the shell still could not run it — check its #! line (${line.trim()})`
67
+ }
68
+
69
+ /** The signal behind an exit above 128, from the runner's own report or the code alone. */
70
+ function signalVerdict(code: number, signal: string | undefined): string | undefined {
71
+ const signals = osConstants.signals as Partial<Record<string, number>>
72
+ let name: string | undefined
73
+ let num: number | undefined
74
+ if (signal !== undefined) {
75
+ name = signal
76
+ num = signals[signal]
77
+ } else {
78
+ name = exitSignal(code)
79
+ num = code - 128
80
+ }
81
+ if (name === undefined || num === undefined) return undefined
82
+ // A SIGINT/SIGTERM the runner saw is a shutdown or an outside stop, and
83
+ // the orchestrator reverts the task to aborted — that path has its own line.
84
+ if (signal !== undefined && (signal === 'SIGINT' || signal === 'SIGTERM')) return undefined
85
+ const why = SIGNAL_WHY[name] ?? 'the process was killed by that signal'
86
+ const how =
87
+ signal !== undefined
88
+ ? `is how the shell reports a death by ${name} (${num})`
89
+ : `is 128 + ${num}, the shell's report of a death by ${name} in the last command (or that command exited ${code} itself)`
90
+ return `[vx] exit ${code} ${how}: ${why}`
91
+ }
92
+
93
+ const SIGNAL_WHY: Record<string, string> = {
94
+ SIGKILL:
95
+ "nothing catches it — on Linux the kernel's OOM killer (dmesg, or the memory limit of the container's cgroup) or an explicit kill; vx's own timeout reports itself as a timeout",
96
+ SIGSEGV:
97
+ 'the program crashed in native code (a native module, or the runtime itself) — re-run the command by hand to reproduce',
98
+ SIGBUS:
99
+ 'the program crashed in native code (a native module, or the runtime itself) — re-run the command by hand to reproduce',
100
+ SIGILL:
101
+ 'the program crashed in native code (a native module, or the runtime itself) — re-run the command by hand to reproduce',
102
+ SIGFPE:
103
+ 'the program crashed in native code (a native module, or the runtime itself) — re-run the command by hand to reproduce',
104
+ SIGTRAP:
105
+ "the program hit a breakpoint or a trap instruction — a debugger, or a runtime's fatal check",
106
+ SIGABRT:
107
+ "the program aborted itself — an assertion, or an allocator failure (a JS runtime's heap limit reports here)",
108
+ SIGTERM:
109
+ "something outside vx asked the process to stop (a runner's cancellation, a container stop, a kill)",
110
+ SIGINT: 'something outside vx interrupted the process (a Ctrl-C reaching it, a kill -INT)',
111
+ SIGHUP: 'the process lost its controlling terminal or session (a hangup, a closed SSH session)',
112
+ SIGPIPE: 'it wrote to a pipe whose reader had gone — a reader in the command left early',
113
+ SIGXCPU: "a CPU-time limit (ulimit -t, or a sandbox's) was hit",
114
+ SIGXFSZ: 'a file-size limit (ulimit -f) was hit',
115
+ SIGSYS: 'a system call was refused — a seccomp filter, or the sandbox',
116
+ SIGQUIT: 'the process was sent a quit (Ctrl-\\, or a kill -QUIT); a core dump may be beside it',
117
+ }
@@ -0,0 +1,167 @@
1
+ // A SIGINT/SIGTERM mid-run stops the run the way `RunOptions.signal` does
2
+ // — the scheduler dispatches nothing more, and the signal is forwarded to
3
+ // everything live, SIGKILLed after a bounded grace — then waits, bounded,
4
+ // for run() to leave its own end-of-run path (telemetry flush, each
5
+ // plugin's teardown, the cache close), and exits 128+signo (130/143). A
6
+ // second signal exits at once. Without the forward, a
7
+ // programmatic signal to the vx process alone (CI cancellation,
8
+ // `kill <pid>`) orphans every running child — terminal Ctrl-C only worked
9
+ // via process-group propagation. Without the escalation (added
10
+ // 2026-09-10) a child that traps TERM — a dev server mid-cleanup, a test
11
+ // runner that ignores it — outlived the run that owned it: vx exited
12
+ // 143 and left it running under init. Split from run.ts on 2026-09-10
13
+ // (pure motion); the registries it reads stay with `run()`, which hands
14
+ // them to the runner around every spawn.
15
+
16
+ import { signalExitCode } from '../exec/index.js'
17
+ import { killGraceMs, settleWithin } from '../util/index.js'
18
+ import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
19
+ import type { Logger } from './logger.js'
20
+
21
+ /**
22
+ * How long a SIGTERMed child gets before SIGKILL on the way out. The
23
+ * end-of-run persistent shutdown grants the same two seconds; both read
24
+ * `VX_KILL_GRACE_MS` (util/settle.ts) so the suites that prove the
25
+ * escalation do not wait the full grace.
26
+ */
27
+ export const SIGNAL_SHUTDOWN_GRACE_MS = 2000
28
+
29
+ type Child = ReturnType<typeof Bun.spawn>
30
+
31
+ /**
32
+ * SIGHUP too: a task runs in its own session (kill-tree.ts), so the
33
+ * terminal closing no longer reaches it — only vx hears the hang-up,
34
+ * and vx must pass it on or the tree outlives the window.
35
+ */
36
+ export type StopSignal = 'SIGINT' | 'SIGTERM' | 'SIGHUP'
37
+
38
+ /** What a teardown sends a task's group before the SIGKILL. */
39
+ export type ForwardedSignal = 'SIGINT' | 'SIGTERM'
40
+
41
+ /**
42
+ * The signal a stop forwards to every task group: the one vx received.
43
+ * A task runs in its own session, so a terminal's Ctrl-C reaches vx
44
+ * alone; forwarding SIGTERM in its place skipped every SIGINT-only
45
+ * cleanup — a Node SIGINT listener, a shell's `trap … INT`
46
+ * (turborepo#444, #12652, #13097 and nx#23585 reproduced on vx,
47
+ * 2026-09-24). A hang-up forwards SIGTERM: to many servers SIGHUP means
48
+ * "reload", not "stop". Anything else — an embedder's abort reason that
49
+ * names no signal — is a SIGTERM.
50
+ */
51
+ export function forwardedSignal(received: unknown): ForwardedSignal {
52
+ return received === 'SIGINT' ? 'SIGINT' : 'SIGTERM'
53
+ }
54
+
55
+ /**
56
+ * Send `signal` to every child `live()` returns, wait the grace for each one's
57
+ * process GROUP to go, then SIGKILL every group with a member left and
58
+ * whatever `live()` returns NOW — re-read, because the run loop may still
59
+ * be dispatching during the grace and a child spawned after the first
60
+ * sweep must not survive the second. The group, not the leader: a shell
61
+ * that dies at once on the SIGTERM ended the wait, and its backgrounded
62
+ * server was SIGKILLed mid-cleanup or, once the runner had dropped the
63
+ * shell, not at all. Resolves once the survivors are reaped. The one
64
+ * teardown behind the process-signal handler below, `RunOptions.signal`,
65
+ * and the foreground keep-alive.
66
+ */
67
+ export async function terminateChildren(
68
+ live: () => Child[],
69
+ signal: ForwardedSignal = 'SIGTERM',
70
+ graceMs: number = killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS),
71
+ ): Promise<void> {
72
+ const children = live()
73
+ // On the group guard's list until the sweep below is done: a shell that
74
+ // dies on the signal lets its group go while what it forked runs out
75
+ // the grace (kill-tree.ts, `holdGroups`).
76
+ const letGo = holdGroups(children)
77
+ try {
78
+ for (const child of children) killTree(child, signal)
79
+ const left = await untilGroupsGone(children, graceMs)
80
+ const survivors = [...new Set([...left, ...live()])]
81
+ for (const child of survivors) killTree(child, 'SIGKILL')
82
+ await Promise.allSettled(survivors.map((c) => c.exited))
83
+ } finally {
84
+ letGo()
85
+ }
86
+ }
87
+
88
+ export interface SignalForwarding {
89
+ /** Detach the handlers — in `run()`'s finally, so repeated runs never stack listeners. */
90
+ remove(): void
91
+ }
92
+
93
+ export function forwardSignals(args: {
94
+ /** `RunOptions.handleSignals` — an embedder that owns the process's signals opts out. */
95
+ enabled: boolean
96
+ log: Logger
97
+ cache: { close(): void }
98
+ /**
99
+ * Stop the run as `RunOptions.signal` would: the scheduler stops
100
+ * dispatching and run()'s abort listener tears the children down.
101
+ */
102
+ stop: (signal: StopSignal) => void
103
+ /**
104
+ * Settles when run() has left its `finally`. A signal waits for it
105
+ * before exiting: `process.exit` runs no `finally`, and plugin.md
106
+ * promises a plugin its teardown and a sink its flush at the end of
107
+ * every run — a Ctrl-C skipped both (item 849).
108
+ */
109
+ done: Promise<void>
110
+ /** How long a signal waits for `done` before it exits anyway. */
111
+ boundMs: number
112
+ /** In-flight children; the runner adds and removes each around its spawn. */
113
+ liveChildren: ReadonlySet<Child>
114
+ /** Ready persistent tasks the orchestrator owns until the graph finishes. */
115
+ persistentRegistry: ReadonlyMap<string, Child>
116
+ }): SignalForwarding {
117
+ const everyChild = (): Child[] => [...args.liveChildren, ...args.persistentRegistry.values()]
118
+ const exit = (signal: StopSignal): never => {
119
+ for (const child of everyChild()) killTree(child, 'SIGKILL')
120
+ try {
121
+ args.cache.close()
122
+ } catch {
123
+ // double-close race with the normal path; we're exiting anyway
124
+ }
125
+ process.exit(signalExitCode(signal))
126
+ }
127
+ let stopping: StopSignal | undefined
128
+ const onSignal = (signal: StopSignal): void => {
129
+ // A second signal during the grace is the user saying "now": SIGKILL
130
+ // and go. The exit code stays the first signal's — that is the one
131
+ // that ended the run.
132
+ if (stopping !== undefined) exit(stopping)
133
+ stopping = signal
134
+ // Clear the live worker/status region BEFORE exiting so a TTY isn't
135
+ // left with a frozen region in the scrollback. runEnd is idempotent
136
+ // and a no-op for non-TTY loggers.
137
+ try {
138
+ args.log.runEnd?.()
139
+ } catch {
140
+ // teardown must not throw on the way out
141
+ }
142
+ args.stop(signal)
143
+ void settleWithin(args.done, args.boundMs)
144
+ // The run's output may still be in the pipe: `process.exit` drops
145
+ // what a reader has not taken (CLAUDE.md). An empty `write`'s
146
+ // callback fired early, and a CI-mode run lost 0.8 of 2 MiB (item
147
+ // 857); `end`'s waits for the pipe on Bun >= 1.4 (bin.ts). Bounded,
148
+ // for a reader that has gone and never takes it.
149
+ .then(() => settleWithin(new Promise<void>((r) => process.stdout.end(() => r())), 2_000))
150
+ .then(() => exit(signal))
151
+ }
152
+ const onSigint = (): void => onSignal('SIGINT')
153
+ const onSigterm = (): void => onSignal('SIGTERM')
154
+ const onSighup = (): void => onSignal('SIGHUP')
155
+ if (args.enabled) {
156
+ process.on('SIGINT', onSigint)
157
+ process.on('SIGTERM', onSigterm)
158
+ process.on('SIGHUP', onSighup)
159
+ }
160
+ return {
161
+ remove: () => {
162
+ process.off('SIGINT', onSigint)
163
+ process.off('SIGTERM', onSigterm)
164
+ process.off('SIGHUP', onSighup)
165
+ },
166
+ }
167
+ }
@@ -13,7 +13,10 @@
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 { isLiteralPattern, normalizeGlob, relPosix } from '../util/index.js'
17
+ import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
16
18
  import { computeGroupHash, computeTaskHash, type HashCache } from './task-hash.js'
19
+ import { filterUpstreamHashes, keyUpstream } from './upstream.js'
17
20
 
18
21
  export interface DeriveStableKeysArgs {
19
22
  nodes: Map<string, TaskNode>
@@ -54,12 +57,57 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
54
57
  // dep's accumulated producers fold forward.
55
58
  // - outputProjects: the project names of every upstream task declaring
56
59
  // cache.outputs.files (project-relative outputs land in the producer's
57
- // own dir).
60
+ // own dir), and of every upstream with no cache block that may write
61
+ // in its own project (`undeclaredWriteReach`, item 743). A bitset over
62
+ // project indexes: a union per dep was a string Set copied tasks ×
63
+ // deps × projects times, 101 ms of a 513 ms warm run at 476 packages
64
+ // (item 744).
58
65
  // - wsOutputUpstream: any upstream declares cache.outputs.workspaceFiles
59
- // (root-anchored, boundary-ignoring outputs).
60
- const outputProjectsById = new Map<string, ReadonlySet<string>>()
66
+ // (root-anchored, boundary-ignoring outputs), or has no cache block
67
+ // and a sandbox write grant elsewhere in the workspace, or may rewrite
68
+ // a file the workspace fingerprint folds (a root `pnpm install`): every
69
+ // key folds that digest, taken once per run.
70
+ // - rewritersById / unfoldedById: the projects of every upstream CACHED
71
+ // task that may rewrite its own inputs in place (a formatter declaring
72
+ // no outputs; `commandWriteReach`), and the subset this task's key does
73
+ // not fold. A key that folds the rewriter's covers what it writes — its
74
+ // writes are its inputs', which its key names — so only a reader
75
+ // folding no path to it (`tasks: []`) is preliminary. A rewriter an
76
+ // unfolded dependency covered is still uncovered here: its key is not
77
+ // in this one.
78
+ // - wsRewriters: the same for a cached rewriter whose reach is every
79
+ // project (a write grant elsewhere in the workspace, or a fingerprinted
80
+ // file, `mayWriteFingerprint`); one this key does not fold makes it
81
+ // preliminary. A dependency this key folds needs no pass-through here:
82
+ // a keyed task with such a rewriter unfolded is unstable itself, and
83
+ // instability is inherited below.
84
+ const projects = new ProjectIndex(args.nodes)
85
+ const outputProjectsById = new Map<string, ProjectSet>()
86
+ const rewritersById = new Map<string, ProjectSet>()
87
+ const unfoldedById = new Map<string, ProjectSet>()
88
+ const wsRewriters = new Set<string>()
89
+ // A cached rewriter matters only to a key that leaves a dependency's key
90
+ // out: a `cache.inputs.tasks` filter, or a persistent dependency, which
91
+ // has no key. With neither in the graph every edge folds and the sets
92
+ // above stay empty, so they are not built (about 2 ms, median, of a 27 ms
93
+ // memoised walk over the 3,000-task bench; item 750).
94
+ let anyUnfolded = false
95
+ for (const n of args.nodes.values()) {
96
+ if (n.config.cache?.inputs?.tasks !== undefined || n.config.exec?.persistent !== undefined) {
97
+ anyUnfolded = true
98
+ break
99
+ }
100
+ }
61
101
  const wsOutputUpstreamById = new Map<string, boolean>()
62
102
  const stableKeys: StableKey[] = []
103
+ // Workspace-relative project dirs, for the reach test of a
104
+ // `workspaceFiles` reader against its upstream producers.
105
+ const dirByProject = new Map<string, string>()
106
+ for (const node of args.nodes.values()) {
107
+ if (!dirByProject.has(node.projectName)) {
108
+ dirByProject.set(node.projectName, relPosix(args.workspaceRoot, node.projectDir))
109
+ }
110
+ }
63
111
 
64
112
  for (const id of order) {
65
113
  const node = args.nodes.get(id)!
@@ -67,19 +115,68 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
67
115
 
68
116
  // Fold every dep's accumulated producers + the dep's own declared
69
117
  // outputs into this node's transitive-upstream producer sets.
70
- const outputProjects = new Set<string>()
118
+ const outputProjects = projects.empty()
119
+ let rewriters: ProjectSet | undefined
120
+ let unfolded: ProjectSet | undefined
121
+ let folds: ((dep: string) => boolean) | undefined
122
+ let wsRewriter = false
123
+ let wsUnfoldedHere = false
71
124
  let wsOutputUpstream = false
72
125
  for (const dep of node.deps) {
73
126
  const depNode = args.nodes.get(dep)
74
127
  if (!depNode) continue
75
- for (const p of outputProjectsById.get(dep) ?? []) outputProjects.add(p)
128
+ const upstreamOfDep = outputProjectsById.get(dep)
129
+ if (upstreamOfDep !== undefined) outputProjects.addAll(upstreamOfDep)
76
130
  if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
77
131
  const depOut = depNode.config.cache?.outputs
78
132
  if ((depOut?.files?.length ?? 0) > 0) outputProjects.add(depNode.projectName)
79
133
  if ((depOut?.workspaceFiles?.length ?? 0) > 0) wsOutputUpstream = true
134
+ // An uncached producer declares no outputs, yet a same-project reader
135
+ // after it (`tasks: []` decouples the key, not the order) read the
136
+ // bytes it wrote: its key taken up front was the pre-producer one, and
137
+ // the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
138
+ const reach = undeclaredWriteReach(depNode, args.workspaceRoot)
139
+ if (reach === 'project') outputProjects.add(depNode.projectName)
140
+ else if (reach === 'workspace') wsOutputUpstream = true
141
+ const cached = depNode.config.cache !== undefined
142
+ if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot)) wsOutputUpstream = true
143
+ if (!anyUnfolded) continue
144
+ // The cached twin: a formatter rewriting its own input ahead of a
145
+ // reader that folds no key of it replayed seed B's build under seed A
146
+ // on the fourth run, the same way (item 750).
147
+ const rewrites = cached ? commandWriteReach(depNode, args.workspaceRoot) : 'none'
148
+ const rewritesWs =
149
+ cached && (rewrites === 'workspace' || mayWriteFingerprint(depNode, args.workspaceRoot))
150
+ const rewritersOfDep = rewritersById.get(dep)
151
+ const wsRewriterOfDep = wsRewriters.has(dep)
152
+ if (
153
+ rewritersOfDep === undefined &&
154
+ rewrites !== 'project' &&
155
+ !wsRewriterOfDep &&
156
+ !rewritesWs
157
+ ) {
158
+ continue
159
+ }
160
+ rewriters ??= projects.empty()
161
+ if (rewritersOfDep !== undefined) rewriters.addAll(rewritersOfDep)
162
+ if (rewrites === 'project') rewriters.add(depNode.projectName)
163
+ if (wsRewriterOfDep || rewritesWs) wsRewriter = true
164
+ folds ??= foldedBy(node, upstream, keyById)
165
+ if (folds(dep)) {
166
+ const unfoldedOfDep = unfoldedById.get(dep)
167
+ if (unfoldedOfDep !== undefined) (unfolded ??= projects.empty()).addAll(unfoldedOfDep)
168
+ continue
169
+ }
170
+ unfolded ??= projects.empty()
171
+ if (rewritersOfDep !== undefined) unfolded.addAll(rewritersOfDep)
172
+ if (rewrites === 'project') unfolded.add(depNode.projectName)
173
+ if (wsRewriterOfDep || rewritesWs) wsUnfoldedHere = true
80
174
  }
81
175
  outputProjectsById.set(id, outputProjects)
82
176
  wsOutputUpstreamById.set(id, wsOutputUpstream)
177
+ if (rewriters !== undefined) rewritersById.set(id, rewriters)
178
+ if (unfolded !== undefined) unfoldedById.set(id, unfolded)
179
+ if (wsRewriter) wsRewriters.add(id)
83
180
 
84
181
  if (isGroupTask(node)) {
85
182
  // Groups have no exec/cache; they only fold upstream keys so
@@ -91,6 +188,12 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
91
188
  continue
92
189
  }
93
190
 
191
+ // A persistent task has no key on the live path (its outcome carries no
192
+ // hash, so a dependent folds nothing of it); recording one here gave
193
+ // its dependents a second key, which a `--force` or remote run never
194
+ // saved under.
195
+ if (node.config.exec?.persistent !== undefined) continue
196
+
94
197
  const hash = await computeTaskHash({
95
198
  node,
96
199
  upstream,
@@ -106,7 +209,12 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
106
209
 
107
210
  const unstable =
108
211
  node.deps.some((d) => unstableById.has(d)) ||
109
- dependsOnSiblingOutputs(node, outputProjects, wsOutputUpstream)
212
+ dependsOnSiblingOutputs(
213
+ node,
214
+ unfolded === undefined ? outputProjects : unfolded.or(outputProjects),
215
+ wsOutputUpstream || wsUnfoldedHere,
216
+ dirByProject,
217
+ )
110
218
  if (unstable) unstableById.add(id)
111
219
 
112
220
  const cacheEnabled = node.config.cache !== undefined
@@ -115,6 +223,28 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
115
223
  return stableKeys
116
224
  }
117
225
 
226
+ /**
227
+ * Does this task's key fold the dependency's? What `computeTaskHash` folds
228
+ * (`filterUpstreamHashes` over the same synthetic outcomes); a group folds
229
+ * every keyed member. A dependency with no key (persistent) is folded by
230
+ * none.
231
+ */
232
+ function foldedBy(
233
+ node: TaskNode,
234
+ upstream: TaskOutcome[],
235
+ keyById: Map<string, string>,
236
+ ): (dep: string) => boolean {
237
+ const filter = node.config.cache?.inputs?.tasks
238
+ if (isGroupTask(node) || filter === undefined) return (dep) => keyById.has(dep)
239
+ const hashes = new Set(
240
+ filterUpstreamHashes(upstream, filter, node.projectName, node.id).map(([, h]) => h),
241
+ )
242
+ return (dep) => {
243
+ const h = keyById.get(dep)
244
+ return h !== undefined && hashes.has(h)
245
+ }
246
+ }
247
+
118
248
  /**
119
249
  * Synthetic upstream outcomes for key derivation. Only `node` + `hash`
120
250
  * are read by filterUpstreamHashes / computeGroupHash; the rest of
@@ -137,7 +267,7 @@ function synthUpstream(
137
267
  hash: keyById.get(dep),
138
268
  } as TaskOutcome)
139
269
  }
140
- return out
270
+ return keyUpstream(node, out)
141
271
  }
142
272
 
143
273
  /**
@@ -167,8 +297,9 @@ function synthUpstream(
167
297
  */
168
298
  export function dependsOnSiblingOutputs(
169
299
  node: TaskNode,
170
- upstreamOutputProjects: ReadonlySet<string>,
300
+ upstreamOutputProjects: ProjectNames,
171
301
  hasWsOutputUpstream: boolean,
302
+ dirByProject?: ReadonlyMap<string, string>,
172
303
  ): boolean {
173
304
  const cache = node.config.cache
174
305
  // A cache-disabled task has no key to prefetch anyway; treat as
@@ -184,11 +315,129 @@ export function dependsOnSiblingOutputs(
184
315
  // `app` read `gen/**` project-relative), so the mere presence of a
185
316
  // workspace-output producer upstream makes the key preliminary.
186
317
  if (hasWsOutputUpstream) return true
187
- const readsWorkspaceFiles = (cache.inputs?.workspaceFiles?.length ?? 0) > 0
188
- if (readsWorkspaceFiles && upstreamOutputProjects.size > 0) return true
318
+ const wsInputs = cache.inputs?.workspaceFiles ?? []
319
+ if (wsInputs.length > 0 && upstreamOutputProjects.size > 0) {
320
+ // Without the dirs the old answer stands: a workspace reader over any
321
+ // producer is preliminary. With them, only a reader whose globs can
322
+ // reach a producer's directory is. A Turbo-mapped workspace gives every
323
+ // task its `globalDependencies` as root literals (`turbo.json`), which
324
+ // reach no package — classing those as unstable hashed medusa's 83
325
+ // tasks twice and probed 76 of them lazily (2026-09-11).
326
+ if (dirByProject === undefined) return true
327
+ const dirs: string[] = []
328
+ for (const p of upstreamOutputProjects) {
329
+ const d = dirByProject.get(p)
330
+ if (d === undefined) return true
331
+ dirs.push(d)
332
+ }
333
+ return workspaceInputsReach(wsInputs, dirs)
334
+ }
335
+ return false
336
+ }
337
+
338
+ /**
339
+ * Can any positive `workspaceFiles` entry match a path inside one of
340
+ * `dirs` (workspace-relative project dirs)? An entry's reach is its
341
+ * literal prefix — the segments before the first one holding a glob
342
+ * metacharacter — and it reaches a dir when either is the other's
343
+ * ancestor or equal. An entry with no literal prefix (`**\/*`) reaches
344
+ * everything. Conservative on every unknown spelling.
345
+ */
346
+ export function workspaceInputsReach(
347
+ workspaceFiles: readonly string[],
348
+ dirs: readonly string[],
349
+ ): boolean {
350
+ for (const raw of workspaceFiles) {
351
+ if (raw.startsWith('!')) continue
352
+ const entry = normalizeGlob(raw)
353
+ const segments = entry.split('/')
354
+ const literal: string[] = []
355
+ for (const seg of segments) {
356
+ if (!isLiteralPattern(seg)) break
357
+ literal.push(seg)
358
+ }
359
+ if (literal.length === 0) return true
360
+ const prefix = literal.join('/')
361
+ for (const dir of dirs) {
362
+ if (dir === '' || dir === '.') return true
363
+ if (prefix === dir || prefix.startsWith(`${dir}/`) || dir.startsWith(`${prefix}/`))
364
+ return true
365
+ }
366
+ }
189
367
  return false
190
368
  }
191
369
 
370
+ /** What the stability gate reads of a set of project names. */
371
+ interface ProjectNames extends Iterable<string> {
372
+ has(name: string): boolean
373
+ readonly size: number
374
+ }
375
+
376
+ /** Every project of the graph, numbered, so a set of them is a bitset. */
377
+ class ProjectIndex {
378
+ readonly names: string[] = []
379
+ readonly indexOf = new Map<string, number>()
380
+ readonly words: number
381
+
382
+ constructor(nodes: Map<string, TaskNode>) {
383
+ for (const node of nodes.values()) {
384
+ if (this.indexOf.has(node.projectName)) continue
385
+ this.indexOf.set(node.projectName, this.names.length)
386
+ this.names.push(node.projectName)
387
+ }
388
+ this.words = (this.names.length + 31) >>> 5
389
+ }
390
+
391
+ empty(): ProjectSet {
392
+ return new ProjectSet(this, new Uint32Array(this.words))
393
+ }
394
+ }
395
+
396
+ class ProjectSet implements ProjectNames {
397
+ constructor(
398
+ private readonly index: ProjectIndex,
399
+ private readonly bits: Uint32Array,
400
+ ) {}
401
+
402
+ add(name: string): void {
403
+ const i = this.index.indexOf.get(name)!
404
+ this.bits[i >>> 5]! |= 1 << (i & 31)
405
+ }
406
+
407
+ addAll(other: ProjectSet): void {
408
+ for (let w = 0; w < this.bits.length; w++) this.bits[w]! |= other.bits[w]!
409
+ }
410
+
411
+ or(other: ProjectSet): ProjectSet {
412
+ const bits = this.bits.slice()
413
+ for (let w = 0; w < bits.length; w++) bits[w]! |= other.bits[w]!
414
+ return new ProjectSet(this.index, bits)
415
+ }
416
+
417
+ has(name: string): boolean {
418
+ const i = this.index.indexOf.get(name)
419
+ return i !== undefined && (this.bits[i >>> 5]! & (1 << (i & 31))) !== 0
420
+ }
421
+
422
+ get size(): number {
423
+ let n = 0
424
+ for (const word of this.bits) {
425
+ let v = word
426
+ while (v !== 0) {
427
+ v &= v - 1
428
+ n++
429
+ }
430
+ }
431
+ return n
432
+ }
433
+
434
+ *[Symbol.iterator](): Iterator<string> {
435
+ for (let i = 0; i < this.index.names.length; i++) {
436
+ if ((this.bits[i >>> 5]! & (1 << (i & 31))) !== 0) yield this.index.names[i]!
437
+ }
438
+ }
439
+ }
440
+
192
441
  function topoOrder(nodes: Map<string, TaskNode>): string[] {
193
442
  const indegree = new Map<string, number>()
194
443
  const dependents = new Map<string, string[]>()