@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -6,8 +6,8 @@
6
6
  // A bounded number run at once (memory: each pack holds an artifact's
7
7
  // bytes); the scheduler finishes a task only once its save has settled
8
8
  // (`settledOf`), so a run ends with every save in the cache; and a save
9
- // that fails degrades to a miss next time, said once — the task's work
10
- // ran.
9
+ // that fails degrades to a miss next time, reported with its task — the
10
+ // task's work ran.
11
11
 
12
12
  export interface SaveLane {
13
13
  /**
@@ -17,34 +17,45 @@ export interface SaveLane {
17
17
  * task's outcome, and a duplicate of the same task in another run
18
18
  * (admission's in-flight join).
19
19
  */
20
- defer(save: () => Promise<void>): Promise<boolean>
20
+ defer(save: () => Promise<void>, taskId: string): Promise<boolean>
21
21
  }
22
22
 
23
- export function createSaveLane(cap: number, onError: (err: unknown) => void): SaveLane {
24
- const queue: Array<{ save: () => Promise<void>; settle: (stored: boolean) => void }> = []
23
+ interface Queued {
24
+ save: () => Promise<void>
25
+ taskId: string
26
+ settle: (stored: boolean) => void
27
+ }
28
+
29
+ export function createSaveLane(
30
+ cap: number,
31
+ onError: (err: unknown, taskId: string) => void,
32
+ ): SaveLane {
33
+ const queue: Queued[] = []
25
34
  let running = 0
26
- const start = (save: () => Promise<void>, settle: (stored: boolean) => void): void => {
27
- void save()
35
+ const start = (q: Queued): void => {
36
+ void q
37
+ .save()
28
38
  .then(
29
39
  () => true,
30
40
  (err: unknown) => {
31
- onError(err)
41
+ onError(err, q.taskId)
32
42
  return false
33
43
  },
34
44
  )
35
45
  .then((stored) => {
36
46
  running--
37
- settle(stored)
47
+ q.settle(stored)
38
48
  const next = queue.shift()
39
- if (next !== undefined) start(next.save, next.settle)
49
+ if (next !== undefined) start(next)
40
50
  })
41
51
  running++
42
52
  }
43
53
  return {
44
- defer(save) {
54
+ defer(save, taskId) {
45
55
  return new Promise<boolean>((settle) => {
46
- if (running < cap) start(save, settle)
47
- else queue.push({ save, settle })
56
+ const q = { save, taskId, settle }
57
+ if (running < cap) start(q)
58
+ else queue.push(q)
48
59
  })
49
60
  },
50
61
  }
@@ -25,9 +25,11 @@ export function shellVerdict(args: {
25
25
  signal?: string | number | undefined
26
26
  /** A sandboxed task's view: whether a file is hidden from it (no grant reads it). */
27
27
  hidden?: ((file: string) => boolean) | undefined
28
+ /** What the step wrote to stderr: the shell's own "not found" line names the word a compound command missed. */
29
+ stderr?: string | undefined
28
30
  }): string | undefined {
29
31
  if (args.code !== 127 && args.code !== 126) return signalVerdict(args.code, args.signal)
30
- const word = execWord(args.command)
32
+ const word = execWord(args.command) ?? (args.code === 127 ? notFoundWord(args.stderr) : undefined)
31
33
  const what = word ?? 'a command in this task'
32
34
  if (word !== undefined && word.includes('/')) {
33
35
  const file = path.resolve(args.cwd, word)
@@ -43,6 +45,15 @@ export function shellVerdict(args: {
43
45
  if (args.code === 126) {
44
46
  return `[vx] exit 126 is the shell's "found but cannot execute": ${what} is not executable or is a directory — chmod +x it`
45
47
  }
48
+ // A program the host's PATH has may be one the sandbox hides (a
49
+ // hermetic task sees no /usr/bin): the reason is a grant, not an install.
50
+ const found =
51
+ word === undefined || args.hidden === undefined
52
+ ? null
53
+ : Bun.which(word, { PATH: [...args.bins, process.env['PATH'] ?? ''].join(path.delimiter) })
54
+ if (found !== null && args.hidden!(found)) {
55
+ return `[vx] exit 127 is the shell's "command not found": ${word} is ${found} on the host, but no sandbox grant reads it, so inside the sandbox it is not there — add it (or its directory) to exec.sandbox.allow.read`
56
+ }
46
57
  const unnamed = args.bins.find((dir) => dir.includes(path.delimiter))
47
58
  if (unnamed !== undefined) {
48
59
  return `[vx] exit 127 is the shell's "command not found": ${what} is not on this task's PATH — ${unnamed} holds "${path.delimiter}", which PATH reads as a separator, so vx cannot put it there; move the workspace to a path without "${path.delimiter}"`
@@ -50,6 +61,23 @@ export function shellVerdict(args: {
50
61
  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`
51
62
  }
52
63
 
64
+ /**
65
+ * The word the shell's last "not found" line names: dash's `sh: 1: cat:
66
+ * not found`, bash's `bash: line 1: cat: command not found`. A command with
67
+ * a redirect or a `&&` has no one word to read from the command itself, and
68
+ * a hermetic task's hidden `cat` was told to be installed (almost every
69
+ * real command has one).
70
+ */
71
+ function notFoundWord(stderr: string | undefined): string | undefined {
72
+ if (stderr === undefined) return undefined
73
+ const lines = stderr.trimEnd().split('\n')
74
+ for (let i = lines.length - 1; i >= 0; i--) {
75
+ const m = /: ([^:]+): (?:command )?not found$/.exec(lines[i]!.trimEnd())
76
+ if (m !== null) return m[1]
77
+ }
78
+ return undefined
79
+ }
80
+
53
81
  /** Why the shell could not run a file, read from the file. */
54
82
  function fileVerdict(file: string): string {
55
83
  if (!existsSync(file)) {
@@ -99,6 +99,103 @@ export async function terminateChildren(
99
99
  }
100
100
  }
101
101
 
102
+ /**
103
+ * The signals a run hears from the moment it goes for its run lock until
104
+ * `forwardSignals` takes over. A plugin's setup runs in between, so a
105
+ * slow one held that window open for seconds, and a Ctrl-C in it killed
106
+ * vx by the default action: no plugin's teardown, no sink's flush, the
107
+ * lock's entry left in the temp dir. The first signal is noted and aborts
108
+ * `signal` (the lock's wait reads it); `forwardSignals` replays it once
109
+ * installed, or `run()` exits of it on a way out before that. A second
110
+ * exits at once, as a second does later.
111
+ */
112
+ export interface HeldSignals {
113
+ readonly signal: AbortSignal
114
+ /** Removes the listeners, once; the signal heard so far, if any. */
115
+ takeOver(): StopSignal | undefined
116
+ /**
117
+ * From here a stop is held for the run's own end (plugin setup has
118
+ * begun, so teardown is owed); before it, a stop leaves at once, its
119
+ * exit hooks releasing the run lock: nothing earlier (a git listing, a
120
+ * remote's open) waits on the stop, so holding it held the run.
121
+ */
122
+ arm(): void
123
+ }
124
+
125
+ export function holdSignals(): HeldSignals {
126
+ const controller = new AbortController()
127
+ let heard: StopSignal | undefined
128
+ let armed = false
129
+ const onSignal = (signal: StopSignal): void => {
130
+ if (heard !== undefined) exitOf(heard, false)
131
+ if (!armed) {
132
+ claimExitForSignal()
133
+ exitOf(signal, false)
134
+ }
135
+ heard = signal
136
+ claimExitForSignal()
137
+ controller.abort(signal)
138
+ }
139
+ const onSigint = (): void => onSignal('SIGINT')
140
+ const onSigterm = (): void => onSignal('SIGTERM')
141
+ const onSighup = (): void => onSignal('SIGHUP')
142
+ process.on('SIGINT', onSigint)
143
+ process.on('SIGTERM', onSigterm)
144
+ if (!hangupIgnored()) process.on('SIGHUP', onSighup)
145
+ let taken = false
146
+ return {
147
+ signal: controller.signal,
148
+ arm: () => {
149
+ armed = true
150
+ },
151
+ takeOver: () => {
152
+ if (taken) return undefined
153
+ taken = true
154
+ process.off('SIGINT', onSigint)
155
+ process.off('SIGTERM', onSigterm)
156
+ process.off('SIGHUP', onSighup)
157
+ return heard
158
+ },
159
+ }
160
+ }
161
+
162
+ /**
163
+ * `work`, or a rejection once `signal` aborts. A plugin's setup or
164
+ * executor factory that never settles held a held signal with it: the
165
+ * first was noted and waited, and only a second, which tears nothing
166
+ * down, ended vx.
167
+ */
168
+ export function unlessStopped<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
169
+ if (signal === undefined) return work
170
+ if (signal.aborted) return Promise.reject(new Error(`stopped by ${String(signal.reason)}`))
171
+ const { promise, resolve, reject } = Promise.withResolvers<T>()
172
+ const onAbort = (): void => reject(new Error(`stopped by ${String(signal.reason)}`))
173
+ signal.addEventListener('abort', onAbort, { once: true })
174
+ work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort))
175
+ return promise
176
+ }
177
+
178
+ /**
179
+ * A held signal no `forwardSignals` took over (the run returned or threw
180
+ * before its tasks) leaves here, once the run's own end is done: stdout
181
+ * drained, bounded, then `exitOf`.
182
+ */
183
+ export async function exitOfSignal(signal: StopSignal): Promise<never> {
184
+ await settleWithin(new Promise<void>((r) => process.stdout.end(() => r())), 2_000)
185
+ return exitOf(signal, false)
186
+ }
187
+
188
+ /**
189
+ * Die of the signal once the exit hooks have run: a shell running vx in
190
+ * a script stops it on Ctrl-C only when its child died of SIGINT, and a
191
+ * 130 exit ran the script's next line. The code stays the fallback
192
+ * should the signal not land.
193
+ */
194
+ function exitOf(signal: StopSignal, handsTerminal: boolean): never {
195
+ if (!handsTerminal) process.once('exit', () => dieOf(signal))
196
+ process.exit(signalExitCode(signal))
197
+ }
198
+
102
199
  export interface SignalForwarding {
103
200
  /** Detach the handlers — in `run()`'s finally, so repeated runs never stack listeners. */
104
201
  remove(): void
@@ -130,6 +227,8 @@ export function forwardSignals(args: {
130
227
  * the signal's code rather than dying of the signal.
131
228
  */
132
229
  handsTerminal?: boolean
230
+ /** The run's held signals: taken over here, and one already heard replayed. */
231
+ held?: HeldSignals | undefined
133
232
  /** In-flight children; the runner adds and removes each around its spawn. */
134
233
  liveChildren: ReadonlySet<Child>
135
234
  /** Ready persistent tasks the orchestrator owns until the graph finishes. */
@@ -155,14 +254,7 @@ export function forwardSignals(args: {
155
254
  } catch {
156
255
  // double-close race with the normal path; we're exiting anyway
157
256
  }
158
- // Die of the signal once the exit hooks have run: a shell running vx
159
- // in a script stops it on Ctrl-C only when its child died of SIGINT,
160
- // and a 130 exit ran the script's next line. The code stays the
161
- // fallback should the signal not land.
162
- if (args.handsTerminal !== true) {
163
- process.once('exit', () => dieOf(signal))
164
- }
165
- process.exit(signalExitCode(signal))
257
+ return exitOf(signal, args.handsTerminal === true)
166
258
  }
167
259
  let stopping: StopSignal | undefined
168
260
  const onSignal = (signal: StopSignal): void => {
@@ -209,6 +301,9 @@ export function forwardSignals(args: {
209
301
  process.on('SIGTSTP', onSigtstp)
210
302
  process.on('SIGCONT', onSigcont)
211
303
  }
304
+ // After this module's own listeners are on, so no signal finds none.
305
+ const heard = args.held?.takeOver()
306
+ if (heard !== undefined) onSignal(heard)
212
307
  return {
213
308
  remove: () => {
214
309
  process.off('SIGINT', onSigint)
@@ -15,6 +15,7 @@ import {
15
15
  foldedDeps,
16
16
  isGroupTask,
17
17
  outputTakenBack,
18
+ standInsOf,
18
19
  type TaskNode,
19
20
  type TaskOutcome,
20
21
  } from '../graph/index.js'
@@ -89,14 +90,14 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
89
90
  // reached through a no-output intermediate still poisons the key, so each
90
91
  // dep's accumulated producers fold forward.
91
92
  // - outputProjects: the project names of every upstream task declaring
92
- // cache.outputs.files (project-relative outputs land in the producer's
93
- // own dir), and of every upstream with no cache block that may write
93
+ // outputs.files (project-relative outputs land in the producer's
94
+ // own dir), and of every upstream with no `cache: true` that may write
94
95
  // in its own project (`undeclaredWriteReach`, item 743). A bitset over
95
96
  // project indexes: a union per dep was a string Set copied tasks ×
96
97
  // deps × projects times, 101 ms of a 513 ms warm run at 476 packages
97
98
  // (item 744).
98
- // - wsOutputUpstream: any upstream declares cache.outputs.workspaceFiles
99
- // (root-anchored, boundary-ignoring outputs), or has no cache block
99
+ // - wsOutputUpstream: any upstream declares outputs.workspaceFiles
100
+ // (root-anchored, boundary-ignoring outputs), or has no `cache: true`
100
101
  // and a sandbox write grant elsewhere in the workspace, or may rewrite
101
102
  // a root file a plugin claims (a root `pnpm install`): every key folds
102
103
  // what the claimant took from it, once per run.
@@ -125,7 +126,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
125
126
  // of what any one reader's upstream producers declare.
126
127
  const outputsOf = new Map<string, string[]>()
127
128
  for (const n of args.nodes.values()) {
128
- const files = n.config.cache?.outputs.files ?? []
129
+ const files = n.config.outputs?.files ?? []
129
130
  if (files.length === 0) continue
130
131
  const list = outputsOf.get(n.projectName)
131
132
  if (list === undefined) outputsOf.set(n.projectName, [...files])
@@ -185,10 +186,10 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
185
186
  const wideOfDep = wideById.get(dep)
186
187
  if (wideOfDep !== undefined) wideUnion.addAll(wideOfDep)
187
188
  if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
188
- const depOut = depNode.config.cache?.outputs
189
+ const depOut = depNode.config.outputs
189
190
  if ((depOut?.files?.length ?? 0) > 0) outputs.add(depNode.projectName)
190
191
  if ((depOut?.workspaceFiles?.length ?? 0) > 0) wsOutputUpstream = true
191
- // An uncached producer declares no outputs, yet a same-project reader
192
+ // An uncached producer is held to no outputs, yet a same-project reader
192
193
  // after it (`tasks: []` decouples the key, not the order) read the
193
194
  // bytes it wrote: its key taken up front was the pre-producer one, and
194
195
  // the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
@@ -198,7 +199,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
198
199
  wideUnion.add(depNode.projectName)
199
200
  } else if (reach === 'workspace') wsOutputUpstream = true
200
201
  if (
201
- depNode.config.cache === undefined &&
202
+ depNode.config.cache !== true &&
202
203
  mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles)
203
204
  )
204
205
  wsOutputUpstream = true
@@ -219,7 +220,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
219
220
  for (const dep of anyUnfolded ? node.deps : []) {
220
221
  const depNode = args.nodes.get(dep)
221
222
  if (!depNode) continue
222
- const cached = depNode.config.cache !== undefined
223
+ const cached = depNode.config.cache === true
223
224
  // The cached twin: a formatter rewriting its own input ahead of a
224
225
  // reader that folds no key of it replayed seed B's build under seed A
225
226
  // on the fourth run, the same way (item 750).
@@ -323,7 +324,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
323
324
  )
324
325
  if (unstable) unstableById.add(id)
325
326
 
326
- const cacheEnabled = node.config.cache !== undefined
327
+ const cacheEnabled = node.config.cache === true
327
328
  if (cacheEnabled && !unstable) stableKeys.push({ hash, node })
328
329
  // An uncached key stays preliminary behind any upstream that may write
329
330
  // into this project, or anywhere: none of its writes is declared.
@@ -438,7 +439,7 @@ export function gatedOutputs(
438
439
  // 1,000 projects' tasks spent 5-8 ms here re-splitting the same globs.
439
440
  const outputsOf = new Map<string, ProjectOutputs>()
440
441
  for (const n of nodes.values()) {
441
- const files = n.config.cache?.outputs.files
442
+ const files = n.config.outputs?.files
442
443
  if (files === undefined || files.length === 0) continue
443
444
  let po = outputsOf.get(n.projectName)
444
445
  if (po === undefined) outputsOf.set(n.projectName, (po = new ProjectOutputs()))
@@ -518,13 +519,13 @@ const probesAfterWritesOf = new WeakMap<ReadonlyMap<string, TaskNode>, ReadonlyS
518
519
  * its upstream ran (`ComputeHashArgs.probesAfterWrites`).
519
520
  *
520
521
  * A writer is any upstream whose command may write (`commandWriteReach`)
521
- * or that declares outputs. A key that folds the writer's covers what it
522
- * writes, as for a rewriter in `deriveStableKeys`: an early answer then
522
+ * or that declares outputs. A key that folds a cached writer's covers what
523
+ * it writes, as for a rewriter in `deriveStableKeys`: an early answer then
523
524
  * costs a spurious miss on the run after the bytes change, never a stale
524
525
  * hit, and such a probe keeps the run's shared answer — one spawn per run,
525
526
  * not per task. So only a writer the key does not fold, transitively
526
- * (`tasks: []`, a filter that leaves it out, an order-only edge), makes
527
- * the probe wait.
527
+ * (`tasks: []`, a filter that leaves it out, an order-only edge), or an
528
+ * uncached one, makes the probe wait.
528
529
  *
529
530
  * Once per graph: run, admission and both up-front passes ask the same map.
530
531
  */
@@ -554,8 +555,8 @@ export function probesAfterWrites(
554
555
  for (const n of nodes.values()) {
555
556
  if (
556
557
  commandWriteReach(n, workspaceRoot) !== 'none' ||
557
- (n.config.cache?.outputs.files.length ?? 0) > 0 ||
558
- (n.config.cache?.outputs.workspaceFiles?.length ?? 0) > 0
558
+ (n.config.outputs?.files.length ?? 0) > 0 ||
559
+ (n.config.outputs?.workspaceFiles?.length ?? 0) > 0
559
560
  )
560
561
  writers.add(n.id)
561
562
  }
@@ -577,19 +578,24 @@ export function probesAfterWrites(
577
578
  if (unfolded || !depWrites) continue
578
579
  const folded =
579
580
  foldsAll || (folds ??= new Set(foldedDeps(node, nodeOf).map((c) => c.node.id))).has(dep)
580
- unfolded = folded ? unfoldedBelow.has(dep) : true
581
+ // An uncached writer's writes are not a function of its key.
582
+ const covers = folded && (!writers.has(dep) || nodeOf(dep).config.cache === true)
583
+ unfolded = covers ? unfoldedBelow.has(dep) : true
581
584
  }
582
585
  if (writer) writerBelow.add(id)
583
586
  if (unfolded) unfoldedBelow.add(id)
584
- if (unfolded && node.config.cache !== undefined && probes(node)) late.add(id)
587
+ if (unfolded && node.config.cache === true && probes(node)) late.add(id)
585
588
  }
586
589
  return late
587
590
  }
588
591
 
589
592
  /**
590
593
  * Does this task's key fold the dependency's? What `computeTaskHash` folds
591
- * (`filterUpstreamHashes` over the same synthetic outcomes); a group folds
592
- * every keyed member.
594
+ * (`filterUpstreamHashes` over the same synthetic outcomes); an unkeyed
595
+ * group, which has no filter, folds every keyed member. A keyed group is
596
+ * keyed by `computeTaskHash`, filter and all: read as folding everything, its
597
+ * `tasks: []` hid a cached rewriter below it, and its reader was restored
598
+ * under the key taken before the rewrite.
593
599
  */
594
600
  function foldedBy(
595
601
  node: TaskNode,
@@ -597,9 +603,11 @@ function foldedBy(
597
603
  keyById: Map<string, string>,
598
604
  ): (dep: string) => boolean {
599
605
  const filter = node.config.inputs?.tasks
600
- if (isGroupTask(node) || filter === undefined) return (dep) => keyById.has(dep)
606
+ if (filter === undefined) return (dep) => keyById.has(dep)
601
607
  const hashes = new Set(
602
- filterUpstreamHashes(upstream, filter, node.projectName, node.id).map(([, h]) => h),
608
+ filterUpstreamHashes(upstream, filter, node.projectName, node.id, standInsOf(node)).map(
609
+ ([, h]) => h,
610
+ ),
603
611
  )
604
612
  return (dep) => {
605
613
  const h = keyById.get(dep)
@@ -628,8 +636,8 @@ function synthUpstream(
628
636
  * Conservative stability check: does any TRANSITIVE-upstream task whose
629
637
  * outputs could land where this task reads its inputs make this task's
630
638
  * key preliminary? The producers are pre-folded by `deriveStableKeys`
631
- * (`upstreamOutputProjects` = projects declaring `cache.outputs.files`
632
- * upstream; `hasWsOutputUpstream` = any `cache.outputs.workspaceFiles`
639
+ * (`upstreamOutputProjects` = projects declaring `outputs.files`
640
+ * upstream; `hasWsOutputUpstream` = any `outputs.workspaceFiles`
633
641
  * upstream), so a producer reached through a no-output intermediate is
634
642
  * caught too.
635
643
  *
@@ -663,7 +671,6 @@ export function dependsOnSiblingOutputs(
663
671
  readableOf?: ReadonlyMap<string, readonly string[]>
664
672
  },
665
673
  ): boolean {
666
- const cache = node.config.cache
667
674
  const declared = node.config.inputs
668
675
  // A task with no inputs has no key to take up front.
669
676
  if (declared === undefined) return false
@@ -697,7 +704,7 @@ export function dependsOnSiblingOutputs(
697
704
  // that producer, its restore raced the producer's clean and extract
698
705
  // (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
699
706
  // `outputsOf` holds this task's own globs too; set them aside once.
700
- const own = cache?.outputs.files ?? []
707
+ const own = node.config.outputs?.files ?? []
701
708
  if (own.length > 0) {
702
709
  const others = [...outputs]
703
710
  for (const g of own) {
@@ -22,7 +22,7 @@ export interface ArtifactTask {
22
22
  /** Absolute. */
23
23
  readonly projectDir: string
24
24
  readonly command: string
25
- /** Its `cache.outputs`: project-relative `files`, root-relative `workspaceFiles`. */
25
+ /** Its `outputs`: project-relative `files`, root-relative `workspaceFiles`. */
26
26
  readonly outputs: {
27
27
  readonly files: readonly string[]
28
28
  readonly workspaceFiles: readonly string[]
@@ -69,7 +69,10 @@ export interface TaskArtifacts {
69
69
  restore(key: string, task: ArtifactTask): Promise<CapturedChunk[] | null>
70
70
  /**
71
71
  * Entry `key`'s output alone, laying no file; null when no cache holds
72
- * it. An `exec.remote: 'only'` task's files stay off this disk.
72
+ * it. An `exec.remote: 'only'` task's files stay off this disk. An entry
73
+ * read off a remote is kept in the local store, so a later `restore`
74
+ * downloads nothing; naming no task, its row reads `<key>#unknown` with
75
+ * no command, and its file names are not checked against any outputs.
73
76
  */
74
77
  output(key: string): Promise<CapturedChunk[] | null>
75
78
  /**
@@ -129,7 +132,17 @@ export function taskArtifacts(cache: CacheLayer, workspaceRoot: string): TaskArt
129
132
  },
130
133
 
131
134
  async restore(key, task) {
132
- const entry = await (cache.getSaved ? cache.getSaved(key) : cache.get(key))
135
+ // The task names the row a remote read writes, and the files its
136
+ // artifact may carry (`CacheGetContext.outputs`). Its command is
137
+ // masked as a save's is, read only by that write.
138
+ const ctx = {
139
+ taskId: task.id,
140
+ get command() {
141
+ return secretMask([process.env])?.mask(task.command) ?? task.command
142
+ },
143
+ outputs: task.outputs,
144
+ }
145
+ const entry = await (cache.getSaved ? cache.getSaved(key, ctx) : cache.get(key, ctx))
133
146
  if (entry === null) return null
134
147
  await cache.restoreOutputs(key, task.projectDir, workspaceRoot, task.outputs)
135
148
  return decodeOutputLog(entry.stdout)