@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -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
+ }
@@ -1,6 +1,9 @@
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
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
4
7
  // programmatic signal to the vx process alone (CI cancellation,
5
8
  // `kill <pid>`) orphans every running child — terminal Ctrl-C only worked
6
9
  // via process-group propagation. Without the escalation (added
@@ -11,7 +14,8 @@
11
14
  // them to the runner around every spawn.
12
15
 
13
16
  import { signalExitCode } from '../exec/index.js'
14
- import { killGraceMs } from '../util/index.js'
17
+ import { killGraceMs, settleWithin } from '../util/index.js'
18
+ import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
15
19
  import type { Logger } from './logger.js'
16
20
 
17
21
  /**
@@ -25,33 +29,60 @@ export const SIGNAL_SHUTDOWN_GRACE_MS = 2000
25
29
  type Child = ReturnType<typeof Bun.spawn>
26
30
 
27
31
  /**
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.
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.
34
66
  */
35
67
  export async function terminateChildren(
36
68
  live: () => Child[],
69
+ signal: ForwardedSignal = 'SIGTERM',
37
70
  graceMs: number = killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS),
38
71
  ): Promise<void> {
39
72
  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))
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
+ }
55
86
  }
56
87
 
57
88
  export interface SignalForwarding {
@@ -64,14 +95,28 @@ export function forwardSignals(args: {
64
95
  enabled: boolean
65
96
  log: Logger
66
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
67
112
  /** In-flight children; the runner adds and removes each around its spawn. */
68
113
  liveChildren: ReadonlySet<Child>
69
114
  /** Ready persistent tasks the orchestrator owns until the graph finishes. */
70
115
  persistentRegistry: ReadonlyMap<string, Child>
71
116
  }): SignalForwarding {
72
117
  const everyChild = (): Child[] => [...args.liveChildren, ...args.persistentRegistry.values()]
73
- const exit = (signal: 'SIGINT' | 'SIGTERM'): never => {
74
- for (const child of everyChild()) child.kill('SIGKILL')
118
+ const exit = (signal: StopSignal): never => {
119
+ for (const child of everyChild()) killTree(child, 'SIGKILL')
75
120
  try {
76
121
  args.cache.close()
77
122
  } catch {
@@ -79,8 +124,8 @@ export function forwardSignals(args: {
79
124
  }
80
125
  process.exit(signalExitCode(signal))
81
126
  }
82
- let stopping: 'SIGINT' | 'SIGTERM' | undefined
83
- const onSignal = (signal: 'SIGINT' | 'SIGTERM'): void => {
127
+ let stopping: StopSignal | undefined
128
+ const onSignal = (signal: StopSignal): void => {
84
129
  // A second signal during the grace is the user saying "now": SIGKILL
85
130
  // and go. The exit code stays the first signal's — that is the one
86
131
  // that ended the run.
@@ -94,18 +139,29 @@ export function forwardSignals(args: {
94
139
  } catch {
95
140
  // teardown must not throw on the way out
96
141
  }
97
- void terminateChildren(everyChild).then(() => exit(signal))
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))
98
151
  }
99
152
  const onSigint = (): void => onSignal('SIGINT')
100
153
  const onSigterm = (): void => onSignal('SIGTERM')
154
+ const onSighup = (): void => onSignal('SIGHUP')
101
155
  if (args.enabled) {
102
156
  process.on('SIGINT', onSigint)
103
157
  process.on('SIGTERM', onSigterm)
158
+ process.on('SIGHUP', onSighup)
104
159
  }
105
160
  return {
106
161
  remove: () => {
107
162
  process.off('SIGINT', onSigint)
108
163
  process.off('SIGTERM', onSigterm)
164
+ process.off('SIGHUP', onSighup)
109
165
  },
110
166
  }
111
167
  }
@@ -13,8 +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 { normalizeGlob, relPosix } from '../util/index.js'
16
+ import { isLiteralPattern, normalizeGlob, relPosix } from '../util/index.js'
17
+ import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
17
18
  import { computeGroupHash, computeTaskHash, type HashCache } from './task-hash.js'
19
+ import { filterUpstreamHashes, keyUpstream } from './upstream.js'
18
20
 
19
21
  export interface DeriveStableKeysArgs {
20
22
  nodes: Map<string, TaskNode>
@@ -55,10 +57,47 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
55
57
  // dep's accumulated producers fold forward.
56
58
  // - outputProjects: the project names of every upstream task declaring
57
59
  // cache.outputs.files (project-relative outputs land in the producer's
58
- // 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).
59
65
  // - wsOutputUpstream: any upstream declares cache.outputs.workspaceFiles
60
- // (root-anchored, boundary-ignoring outputs).
61
- 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
+ }
62
101
  const wsOutputUpstreamById = new Map<string, boolean>()
63
102
  const stableKeys: StableKey[] = []
64
103
  // Workspace-relative project dirs, for the reach test of a
@@ -76,19 +115,68 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
76
115
 
77
116
  // Fold every dep's accumulated producers + the dep's own declared
78
117
  // outputs into this node's transitive-upstream producer sets.
79
- 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
80
124
  let wsOutputUpstream = false
81
125
  for (const dep of node.deps) {
82
126
  const depNode = args.nodes.get(dep)
83
127
  if (!depNode) continue
84
- for (const p of outputProjectsById.get(dep) ?? []) outputProjects.add(p)
128
+ const upstreamOfDep = outputProjectsById.get(dep)
129
+ if (upstreamOfDep !== undefined) outputProjects.addAll(upstreamOfDep)
85
130
  if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
86
131
  const depOut = depNode.config.cache?.outputs
87
132
  if ((depOut?.files?.length ?? 0) > 0) outputProjects.add(depNode.projectName)
88
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
89
174
  }
90
175
  outputProjectsById.set(id, outputProjects)
91
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)
92
180
 
93
181
  if (isGroupTask(node)) {
94
182
  // Groups have no exec/cache; they only fold upstream keys so
@@ -100,6 +188,12 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
100
188
  continue
101
189
  }
102
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
+
103
197
  const hash = await computeTaskHash({
104
198
  node,
105
199
  upstream,
@@ -115,7 +209,12 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
115
209
 
116
210
  const unstable =
117
211
  node.deps.some((d) => unstableById.has(d)) ||
118
- dependsOnSiblingOutputs(node, outputProjects, wsOutputUpstream, dirByProject)
212
+ dependsOnSiblingOutputs(
213
+ node,
214
+ unfolded === undefined ? outputProjects : unfolded.or(outputProjects),
215
+ wsOutputUpstream || wsUnfoldedHere,
216
+ dirByProject,
217
+ )
119
218
  if (unstable) unstableById.add(id)
120
219
 
121
220
  const cacheEnabled = node.config.cache !== undefined
@@ -124,6 +223,28 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
124
223
  return stableKeys
125
224
  }
126
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
+
127
248
  /**
128
249
  * Synthetic upstream outcomes for key derivation. Only `node` + `hash`
129
250
  * are read by filterUpstreamHashes / computeGroupHash; the rest of
@@ -146,7 +267,7 @@ function synthUpstream(
146
267
  hash: keyById.get(dep),
147
268
  } as TaskOutcome)
148
269
  }
149
- return out
270
+ return keyUpstream(node, out)
150
271
  }
151
272
 
152
273
  /**
@@ -176,7 +297,7 @@ function synthUpstream(
176
297
  */
177
298
  export function dependsOnSiblingOutputs(
178
299
  node: TaskNode,
179
- upstreamOutputProjects: ReadonlySet<string>,
300
+ upstreamOutputProjects: ProjectNames,
180
301
  hasWsOutputUpstream: boolean,
181
302
  dirByProject?: ReadonlyMap<string, string>,
182
303
  ): boolean {
@@ -232,7 +353,7 @@ export function workspaceInputsReach(
232
353
  const segments = entry.split('/')
233
354
  const literal: string[] = []
234
355
  for (const seg of segments) {
235
- if (/[*?[\]{}]/.test(seg)) break
356
+ if (!isLiteralPattern(seg)) break
236
357
  literal.push(seg)
237
358
  }
238
359
  if (literal.length === 0) return true
@@ -246,6 +367,77 @@ export function workspaceInputsReach(
246
367
  return false
247
368
  }
248
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
+
249
441
  function topoOrder(nodes: Map<string, TaskNode>): string[] {
250
442
  const indegree = new Map<string, number>()
251
443
  const dependents = new Map<string, string[]>()
@@ -42,6 +42,8 @@ export interface OutputWriter {
42
42
  * `setStatus`; the two share one display slot.
43
43
  */
44
44
  setRegion(lines: readonly string[], opts?: { force?: boolean }): void
45
+ /** Hand over what `coalesce` holds and write straight through from now on. */
46
+ settle(): void
45
47
  /** Permanently remove the status display; later set* is a no-op. */
46
48
  clearStatus(): void
47
49
  }
@@ -61,6 +63,13 @@ export interface OutputWriterOptions {
61
63
  forceFloorMs?: number
62
64
  /** Injectable clock for tests. */
63
65
  now?: () => number
66
+ /**
67
+ * Off a TTY, hold writes and hand them to the stream once per turn of
68
+ * the event loop until `settle()`. A warm 476-package run wrote each of
69
+ * its 952 task lines as its own syscall (item 753). Off by default: a
70
+ * caller reading its stream right after a write sees it.
71
+ */
72
+ coalesce?: boolean
64
73
  }
65
74
 
66
75
  export function createOutputWriter(
@@ -71,6 +80,17 @@ export function createOutputWriter(
71
80
  const minRedrawMs = opts.minRedrawMs ?? 100
72
81
  const forceFloorMs = opts.forceFloorMs ?? 30
73
82
  const now = opts.now ?? Date.now
83
+ // The coalesced writes not yet handed over; null once settled or when
84
+ // not coalescing.
85
+ let held: string[] | null = !enabled && opts.coalesce === true ? [] : null
86
+ let flushQueued = false
87
+ const flushHeld = (): void => {
88
+ flushQueued = false
89
+ if (held === null || held.length === 0) return
90
+ const text = held.join('')
91
+ held.length = 0
92
+ stream.write(text)
93
+ }
74
94
 
75
95
  let current: readonly string[] | null = null
76
96
  let shown = false
@@ -169,7 +189,15 @@ export function createOutputWriter(
169
189
  enabled,
170
190
  write(chunk) {
171
191
  if (!enabled) {
172
- stream.write(chunk)
192
+ if (held === null) {
193
+ stream.write(chunk)
194
+ return
195
+ }
196
+ held.push(chunk)
197
+ if (!flushQueued) {
198
+ flushQueued = true
199
+ setImmediate(flushHeld)
200
+ }
173
201
  return
174
202
  }
175
203
  if (shown) {
@@ -187,6 +215,10 @@ export function createOutputWriter(
187
215
  setRegion(lines, o = {}) {
188
216
  set(lines, o)
189
217
  },
218
+ settle() {
219
+ flushHeld()
220
+ held = null
221
+ },
190
222
  clearStatus() {
191
223
  if (!enabled || dead) return
192
224
  dead = true