@vzn/vx 0.0.652 → 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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  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 +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. 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)
@@ -59,6 +61,23 @@ export function shellVerdict(args: {
59
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`
60
62
  }
61
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
+
62
81
  /** Why the shell could not run a file, read from the file. */
63
82
  function fileVerdict(file: string): string {
64
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'
@@ -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
  */
@@ -577,7 +578,9 @@ 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)
@@ -588,8 +591,11 @@ export function probesAfterWrites(
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)
@@ -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)