@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
@@ -41,13 +41,27 @@ export function selectKeepAlive(
41
41
  ): KeepAlive {
42
42
  const out: KeepAlive = { nodes: [], children: [] }
43
43
  if (!foreground || registry.size === 0) return out
44
+ const reached = keptServers(nodes, (id) => registry.has(id))
45
+ for (const [id, child] of registry) {
46
+ if (!reached.has(id)) continue
47
+ out.nodes.push(nodes.get(id)!)
48
+ out.children.push(child)
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** The servers and groups `selectKeepAlive` keeps in the foreground: the requested ones and what they stand on. */
54
+ function keptServers(
55
+ nodes: ReadonlyMap<string, TaskNode>,
56
+ isServer: (id: string) => boolean,
57
+ ): Set<string> {
44
58
  // A requested GROUP is a requested server's stand-in: `vx run app#dev`
45
59
  // over `dev: { dependsOn: ['^dev'] }` started every server and exited 0
46
60
  // at the end of the graph, stopping them all (C-52).
47
61
  const stack: string[] = []
48
62
  for (const [id, n] of nodes) {
49
63
  if (!n.requested && n.surfaced !== true) continue
50
- if (registry.has(id) || isGroupTask(n)) stack.push(id)
64
+ if (isServer(id) || isGroupTask(n)) stack.push(id)
51
65
  }
52
66
  const reached = new Set<string>()
53
67
  while (stack.length > 0) {
@@ -56,15 +70,120 @@ export function selectKeepAlive(
56
70
  reached.add(id)
57
71
  for (const dep of nodes.get(id)?.deps ?? []) {
58
72
  const d = nodes.get(dep)
59
- if (d !== undefined && (registry.has(dep) || isGroupTask(d))) stack.push(dep)
73
+ if (d !== undefined && (isServer(dep) || isGroupTask(d))) stack.push(dep)
60
74
  }
61
75
  }
62
- for (const [id, child] of registry) {
63
- if (!reached.has(id)) continue
64
- out.nodes.push(nodes.get(id)!)
65
- out.children.push(child)
76
+ return reached
77
+ }
78
+
79
+ /**
80
+ * The servers no task left needs, as tasks finish: a dependency-only
81
+ * server stops once every task that uses it, directly or through groups,
82
+ * has finished, not at the end of the graph. A server that uses another
83
+ * finishes only when it is stopped itself. `finished(id)` answers the
84
+ * servers to stop now; a server kept in the foreground never is.
85
+ */
86
+ export function unneededServers(
87
+ nodes: ReadonlyMap<string, TaskNode>,
88
+ foreground: boolean,
89
+ ): (id: string) => string[] {
90
+ const isServer = (n: TaskNode): boolean => n.config.exec?.persistent !== undefined
91
+ const kept = foreground ? keptServers(nodes, (id) => isServer(nodes.get(id)!)) : new Set<string>()
92
+ const dependents = new Map<string, string[]>()
93
+ for (const [id, n] of nodes)
94
+ for (const d of n.deps) {
95
+ const list = dependents.get(d)
96
+ if (list === undefined) dependents.set(d, [id])
97
+ else list.push(id)
98
+ }
99
+ // Each server's users: its dependents, a group read through to its own.
100
+ const users = new Map<string, Set<string>>()
101
+ const usedBy = new Map<string, string[]>()
102
+ for (const [id, n] of nodes) {
103
+ if (!isServer(n) || kept.has(id)) continue
104
+ const set = new Set<string>()
105
+ const stack = [...(dependents.get(id) ?? [])]
106
+ const seen = new Set<string>()
107
+ while (stack.length > 0) {
108
+ const d = stack.pop()!
109
+ if (seen.has(d)) continue
110
+ seen.add(d)
111
+ if (isGroupTask(nodes.get(d)!)) stack.push(...(dependents.get(d) ?? []))
112
+ else set.add(d)
113
+ }
114
+ if (set.size === 0) continue
115
+ users.set(id, set)
116
+ for (const u of set) {
117
+ const list = usedBy.get(u)
118
+ if (list === undefined) usedBy.set(u, [id])
119
+ else list.push(id)
120
+ }
121
+ }
122
+ const done = (id: string, out: string[]): void => {
123
+ for (const s of usedBy.get(id) ?? []) {
124
+ const left = users.get(s)
125
+ if (left === undefined || !left.delete(id) || left.size > 0) continue
126
+ users.delete(s)
127
+ out.push(s)
128
+ done(s, out)
129
+ }
130
+ }
131
+ return (id) => {
132
+ if (usedBy.size === 0) return []
133
+ const n = nodes.get(id)
134
+ // A server finishes when it is ready, still serving its users.
135
+ if (n === undefined || isServer(n)) return []
136
+ const out: string[] = []
137
+ done(id, out)
138
+ return out
139
+ }
140
+ }
141
+
142
+ /**
143
+ * The servers that use each server, directly or through groups. A server
144
+ * stops only once these have: one may still talk to it while it shuts
145
+ * down (a flush to its database), and both stopped at once took the
146
+ * database away under that handler.
147
+ */
148
+ export function serverUsers(
149
+ nodes: ReadonlyMap<string, TaskNode>,
150
+ ): ReadonlyMap<string, readonly string[]> {
151
+ const isServer = (n: TaskNode | undefined): boolean => n?.config.exec?.persistent !== undefined
152
+ const users = new Map<string, string[]>()
153
+ for (const [id, n] of nodes) {
154
+ if (!isServer(n)) continue
155
+ const stack = [...n.deps]
156
+ const seen = new Set<string>()
157
+ while (stack.length > 0) {
158
+ const d = stack.pop()!
159
+ if (seen.has(d)) continue
160
+ seen.add(d)
161
+ const dn = nodes.get(d)
162
+ if (dn !== undefined && isGroupTask(dn)) stack.push(...dn.deps)
163
+ else if (isServer(dn)) {
164
+ const list = users.get(d)
165
+ if (list === undefined) users.set(d, [id])
166
+ else list.push(id)
167
+ }
168
+ }
169
+ }
170
+ return users
171
+ }
172
+
173
+ /** Stop one server as `shutdownPersistent` stops the rest: SIGTERM, then SIGKILL past the grace. */
174
+ export async function stopServer(
175
+ child: Child,
176
+ graceMs: number = killGraceMs(PERSISTENT_SHUTDOWN_GRACE_MS),
177
+ ): Promise<void> {
178
+ if (hasEnded(child)) return
179
+ const letGo = holdGroups([child])
180
+ try {
181
+ killTree(child, 'SIGTERM')
182
+ for (const c of await untilGroupsGone([child], graceMs)) killTree(c, 'SIGKILL')
183
+ await child.exited
184
+ } finally {
185
+ letGo()
66
186
  }
67
- return out
68
187
  }
69
188
 
70
189
  /**
@@ -81,6 +200,7 @@ export async function shutdownPersistent(
81
200
  registry: ReadonlyMap<string, Child>,
82
201
  keepAlive: readonly Child[],
83
202
  graceMs: number = killGraceMs(PERSISTENT_SHUTDOWN_GRACE_MS),
203
+ usersOf: ReadonlyMap<string, readonly string[]> = new Map(),
84
204
  ): Promise<CrashedPersistent[]> {
85
205
  const kept = new Set(keepAlive)
86
206
  const crashed: CrashedPersistent[] = []
@@ -88,13 +208,30 @@ export async function shutdownPersistent(
88
208
  if (kept.has(child) || !hasEnded(child) || child.exitCode === 0) continue
89
209
  crashed.push({ id, code: child.exitCode ?? child.signalCode ?? 'unknown' })
90
210
  }
91
- const dying = [...registry.values()].filter((c) => !kept.has(c))
211
+ const dying = [...registry].filter(([, c]) => !kept.has(c))
92
212
  if (dying.length === 0) return crashed
93
- const letGo = holdGroups(dying)
213
+ const letGo = holdGroups(dying.map(([, c]) => c))
214
+ // Each after the servers using it (`serverUsers`); the rest at once.
215
+ const stops = new Map<string, Promise<void>>()
216
+ const stop = (id: string, child: Child): Promise<void> => {
217
+ const known = stops.get(id)
218
+ if (known !== undefined) return known
219
+ const users: Promise<void>[] = []
220
+ for (const u of usersOf.get(id) ?? []) {
221
+ const c = registry.get(u)
222
+ if (c !== undefined && !kept.has(c)) users.push(stop(u, c))
223
+ }
224
+ const own = async (): Promise<void> => {
225
+ killTree(child, 'SIGTERM')
226
+ for (const c of await untilGroupsGone([child], graceMs)) killTree(c, 'SIGKILL')
227
+ await child.exited
228
+ }
229
+ const p = users.length === 0 ? own() : Promise.allSettled(users).then(own)
230
+ stops.set(id, p)
231
+ return p
232
+ }
94
233
  try {
95
- for (const child of dying) killTree(child, 'SIGTERM')
96
- for (const child of await untilGroupsGone(dying, graceMs)) killTree(child, 'SIGKILL')
97
- await Promise.allSettled(dying.map((c) => c.exited))
234
+ await Promise.allSettled(dying.map(([id, c]) => stop(id, c)))
98
235
  } finally {
99
236
  letGo()
100
237
  }
@@ -279,9 +416,9 @@ export async function disposeServers(args: {
279
416
  (c) => hasEnded(c) && c.exitCode !== 0 && !keepAlive.children.includes(c),
280
417
  )
281
418
  if (failedElsewhere && !args.hold && !args.continueAlways) keepAlive = { nodes: [], children: [] }
282
- const crashed = (await shutdownPersistent(registry, keepAlive.children)).filter(
283
- (c) => endedBeforeStop?.has(registry.get(c.id)!) ?? true,
284
- )
419
+ const crashed = (
420
+ await shutdownPersistent(registry, keepAlive.children, undefined, serverUsers(args.nodes))
421
+ ).filter((c) => endedBeforeStop?.has(registry.get(c.id)!) ?? true)
285
422
  for (const c of crashed) failServer(outcomes, c.id, c.code)
286
423
  keepAlive.nodes.forEach((n, i) => {
287
424
  const child = keepAlive.children[i]!
@@ -2,8 +2,9 @@
2
2
  // executors, and the plan-mode view of it. Split from run.ts on 2026-09-10
3
3
  // (pure motion). A task is pinned to this machine when it is persistent,
4
4
  // depends on a persistent task no `servers` executor carries, is sandboxed, says `exec.remote: false`, or
5
- // folds a runtime probe into its key; an `exec.interactive` task goes to the
6
- // local floor directly, as only it can hand over this terminal;
5
+ // folds a runtime probe into its key; a sandboxed task goes to no executor
6
+ // that does not declare `sandbox`, here or remote; an `exec.interactive`
7
+ // task goes to the local floor directly, as only it can hand over this terminal;
7
8
  // everything else asks the executors in declaration order, and the local
8
9
  // floor takes what nothing claimed. An `exclusive` executor lifts every
9
10
  // pin and leaves the floor nothing: a task that would land here refuses.
@@ -204,6 +205,13 @@ export interface Placements {
204
205
  * did before the field existed.
205
206
  */
206
207
  remoteOnlyNoop: Set<string>
208
+ /**
209
+ * Tasks that depend on a `remoteOnlyNoop` one, directly or through
210
+ * others: each saves no entry. `remote` is not in the key, so the no-op's
211
+ * key is the one a real run of it has, and a dependant's key folding it
212
+ * would file output built without its outputs where a run over them looks.
213
+ */
214
+ belowNoop: Set<string>
207
215
  /** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
208
216
  remoteOnly: Set<string>
209
217
  /** Each task placed on a `servers` executor → the persistent tasks it starts there. */
@@ -223,8 +231,9 @@ export interface Placements {
223
231
  }
224
232
 
225
233
  /**
226
- * The tasks that write IN PLACE — everything not placed on a remote
227
- * executor. `resolveDownloadModes` reads it as "the outputs are already
234
+ * Whether a task writes IN PLACE — everything not placed on a remote
235
+ * executor. A predicate, not a set: a set held an entry per task of the
236
+ * run for its whole length. `resolveDownloadModes` reads it as "the outputs are already
228
237
  * here", so a task wrongly left out is one vx thinks it must fetch from a
229
238
  * CAS that never held it.
230
239
  *
@@ -234,11 +243,27 @@ export interface Placements {
234
243
  * remote", never "declared local"; asking the latter drops every task on
235
244
  * the floor executor, i.e. every task in a workspace with no executor
236
245
  * plugin. It lives here, shared, because `run()` and `--dry` each built
237
- * this set inline from the same predicate and a second copy is how the
246
+ * this inline from the same predicate and a second copy is how the
238
247
  * plan and the run would disagree about what comes home (441, 442, 445).
239
248
  */
240
- export function locallyPlaced(placements: Placements, ids: Iterable<string>): Set<string> {
241
- return new Set([...ids].filter((id) => placements.executors.get(id)?.remote !== true))
249
+ export function locallyPlaced(placements: Placements): (id: string) => boolean {
250
+ return (id) => placements.executors.get(id)?.remote !== true
251
+ }
252
+
253
+ /** Why `e`, which runs every task, takes none of this one's: no platform or worker type of it fits. */
254
+ function unrouted(
255
+ e: TaskExecutor,
256
+ named: string | undefined,
257
+ wants: readonly string[] | undefined,
258
+ ): string {
259
+ const types = e.workers
260
+ if (types !== undefined && named !== undefined) {
261
+ const type = types.find(([n]) => n === named)
262
+ if (type === undefined)
263
+ return `it has no worker type '${named}' that exec.worker names (types: ${types.map(([n]) => n).join(', ')})`
264
+ return `its worker type '${named}' runs ${type[1].join(', ')}, and exec.platforms asks for ${wants!.join(', ')}`
265
+ }
266
+ return `it offers ${e.platforms?.join(', ') ?? 'no platform'}, and exec.platforms asks for ${wants!.join(', ')}`
242
267
  }
243
268
 
244
269
  export function placeTasks(
@@ -267,6 +292,7 @@ export function placeTasks(
267
292
  const placements: Placements = {
268
293
  executors: new Map(),
269
294
  remoteOnlyNoop: new Set(),
295
+ belowNoop: new Set(),
270
296
  remoteOnly: new Set(),
271
297
  servers: new Map(),
272
298
  carried: new Set(),
@@ -290,12 +316,14 @@ export function placeTasks(
290
316
  if (executors.length === 1 && executors[0] === floor) {
291
317
  for (const node of nodes.values()) {
292
318
  delete node.platform
319
+ delete node.worker
293
320
  if (isGroupTask(node)) continue
294
321
  here(node)
295
322
  if (node.config.exec?.persistent !== undefined) continue
296
323
  placements.executors.set(node.id, floor)
297
324
  if (node.config.exec?.remote === 'only') placements.remoteOnlyNoop.add(node.id)
298
325
  }
326
+ markBelowNoop(nodes, placements)
299
327
  return placements
300
328
  }
301
329
  const pinned = pinsOf({})
@@ -318,6 +346,7 @@ export function placeTasks(
318
346
  refuse(node.id, 'it starts a server only beside a task that needs it')
319
347
  for (const node of nodes.values()) {
320
348
  delete node.platform
349
+ delete node.worker
321
350
  if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
322
351
  if (node.config.exec?.interactive === true && sole === undefined) {
323
352
  placements.executors.set(node.id, floor)
@@ -327,8 +356,18 @@ export function placeTasks(
327
356
  continue
328
357
  }
329
358
  const wants: readonly string[] | undefined = node.config.exec?.platforms
359
+ const sandboxed = node.config.exec?.sandbox !== undefined
360
+ const named = node.config.exec?.worker
361
+ const fits = (ps: readonly string[]): boolean =>
362
+ wants === undefined || ps.some((p) => wants.includes(p))
363
+ /** Its worker type on `e`, which names types: `exec.worker`'s, else the first that fits. */
364
+ const typeOn = (e: TaskExecutor): readonly [string, readonly string[]] | undefined =>
365
+ e.workers?.find(([n, ps]) => (named === undefined || n === named) && fits(ps))
366
+ // An executor that names no types ignores `exec.worker`, as this machine does.
330
367
  const routes = (e: TaskExecutor): boolean =>
331
- wants === undefined || e.platforms?.some((p) => wants.includes(p)) === true
368
+ e.workers !== undefined
369
+ ? typeOn(e) !== undefined
370
+ : wants === undefined || e.platforms?.some((p) => wants.includes(p)) === true
332
371
  const executor = selectExecutor(
333
372
  executors,
334
373
  {
@@ -341,20 +380,25 @@ export function placeTasks(
341
380
  },
342
381
  executorLabel,
343
382
  (e) => pinsOf(e).has(node.id) || !routes(e),
383
+ (e) => sandboxed && e.sandbox !== true && e !== floor,
344
384
  )
345
385
  if (sole !== undefined && executor === floor) {
346
386
  refuse(
347
387
  node.id,
348
- pinsOf(sole).has(node.id)
349
- ? 'its key includes a runtime probe, which it does not check'
350
- : routes(sole)
351
- ? 'it declined the task'
352
- : `it offers ${sole.platforms?.join(', ') ?? 'no platform'}, and exec.platforms asks for ${wants!.join(', ')}`,
388
+ sandboxed && sole.sandbox !== true
389
+ ? 'it does not enforce exec.sandbox'
390
+ : pinsOf(sole).has(node.id)
391
+ ? 'its key includes a runtime probe, which it does not check'
392
+ : routes(sole)
393
+ ? 'it declined the task'
394
+ : unrouted(sole, named, wants),
353
395
  )
354
396
  }
355
397
  placements.executors.set(node.id, executor)
398
+ const type = routes(executor) ? typeOn(executor) : undefined
399
+ if (type !== undefined) node.worker = type[0]
356
400
  if (wants !== undefined) {
357
- const offered = executor.platforms
401
+ const offered = type?.[1] ?? executor.platforms
358
402
  if (offered !== undefined && routes(executor)) {
359
403
  node.platform =
360
404
  wants.includes(host) && offered.includes(host)
@@ -386,9 +430,31 @@ export function placeTasks(
386
430
  if (sole !== undefined) refuse(node.id, 'it starts a server only beside a task that needs it')
387
431
  here(node)
388
432
  }
433
+ markBelowNoop(nodes, placements)
389
434
  return placements
390
435
  }
391
436
 
437
+ /** Fills `belowNoop`: every task that reaches a `remoteOnlyNoop` one. */
438
+ function markBelowNoop(nodes: Map<string, TaskNode>, placements: Placements): void {
439
+ if (placements.remoteOnlyNoop.size === 0) return
440
+ const dependants = new Map<string, string[]>()
441
+ for (const node of nodes.values())
442
+ for (const dep of node.deps) {
443
+ const list = dependants.get(dep)
444
+ if (list === undefined) dependants.set(dep, [node.id])
445
+ else list.push(node.id)
446
+ }
447
+ // An explicit stack: a recursion per edge throws on a deep chain.
448
+ const stack = [...placements.remoteOnlyNoop]
449
+ while (stack.length > 0) {
450
+ for (const id of dependants.get(stack.pop()!) ?? []) {
451
+ if (placements.belowNoop.has(id)) continue
452
+ placements.belowNoop.add(id)
453
+ stack.push(id)
454
+ }
455
+ }
456
+ }
457
+
392
458
  /**
393
459
  * `executorOf` for `planRun`, or nothing. A remote-only task no remote
394
460
  * executor takes is labelled `noop` whatever the executor count: the run
@@ -442,7 +508,7 @@ export async function planExecutorOf(
442
508
  : resolveDownloadModes({
443
509
  nodes: prepared.nodes,
444
510
  policy,
445
- localPlaced: locallyPlaced(placements, prepared.nodes.keys()),
511
+ localPlaced: locallyPlaced(placements),
446
512
  remoteOnly: placements.remoteOnly,
447
513
  })
448
514
  return {
@@ -12,13 +12,20 @@
12
12
  // side-effect-free pure probes.
13
13
 
14
14
  import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
15
- import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
15
+ import { cachesNothing, FULL_CACHE_POLICY, networkConcurrency } from '../cache/index.js'
16
16
  import { ranNoCache } from '../report/index.js'
17
- import { idleServers, isGroupTask, runGraph, type TaskNode } from '../graph/index.js'
17
+ import {
18
+ idleServers,
19
+ isGroupTask,
20
+ isKeyFailed,
21
+ markKeyFailed,
22
+ runGraph,
23
+ type TaskNode,
24
+ } from '../graph/index.js'
18
25
  import type { AffectedReason } from './affected-tasks.js'
19
26
  import type { HistoryProvider } from '../history/index.js'
20
27
  import { hostPlatform } from '../exec/index.js'
21
- import { computeGroupKey, computeTaskHash } from './task-hash.js'
28
+ import { computeGroupKey, computeTaskHash, keyOf } from './task-hash.js'
22
29
  import { dependedOn, keyedOutcome, keylessServer, keyUpstream } from './upstream.js'
23
30
 
24
31
  export type CacheStatus =
@@ -177,10 +184,12 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
177
184
  }
178
185
  if (isGroupTask(node)) {
179
186
  cacheStatusById.set(node.id, 'group')
180
- return keyedOutcome(node, await computeGroupKey(hashArgs))
187
+ return keyedOutcome(node, await keyOf(computeGroupKey(hashArgs)))
181
188
  }
182
189
 
183
- const hash = keylessServer(node, depended) ? undefined : await computeTaskHash(hashArgs)
190
+ const hash = keylessServer(node, depended)
191
+ ? undefined
192
+ : await keyOf(computeTaskHash(hashArgs))
184
193
 
185
194
  // Prediction keys off READS: a no-read policy re-executes every task,
186
195
  // so nothing is probed. Whether that run is a miss or no cache at all
@@ -205,15 +214,23 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
205
214
  })
206
215
  if (probed.size > 0) {
207
216
  const infos = await args.cache.remoteHasMany?.([...new Set(probed.values())])
208
- for (const [id, hash] of probed) {
209
- const where = await args.cache.has(hash)
210
- cacheStatusById.set(
211
- id,
212
- where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local',
213
- )
214
- const size = where === 'remote' ? infos?.get(hash)?.size : undefined
215
- if (size !== undefined) remoteBytesById.set(id, size)
217
+ // Asked at once, as a run pulls: in series, a remote that cannot batch
218
+ // cost its round-trip, or its timeout, once per key.
219
+ const pending = [...probed]
220
+ let next = 0
221
+ const one = async (): Promise<void> => {
222
+ while (next < pending.length) {
223
+ const [id, hash] = pending[next++]!
224
+ const where = await args.cache.has(hash)
225
+ cacheStatusById.set(
226
+ id,
227
+ where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local',
228
+ )
229
+ const size = where === 'remote' ? infos?.get(hash)?.size : undefined
230
+ if (size !== undefined) remoteBytesById.set(id, size)
231
+ }
216
232
  }
233
+ await Promise.all(Array.from({ length: Math.min(pending.length, networkConcurrency()) }, one))
217
234
  }
218
235
 
219
236
  if (args.restorable !== undefined) {
@@ -234,6 +251,11 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
234
251
  cacheStatusById.set(id, 'not-started')
235
252
  }
236
253
 
254
+ // A task whose key could not be derived fails the run, so the plan is
255
+ // marked (`isKeyFailed`) and `--dry` exits 1; the scheduler printed the
256
+ // run's own line for each.
257
+ const failed = [...outcomes.values()].some(isKeyFailed)
258
+ const mark = (p: RunPlan): RunPlan => (failed ? markKeyFailed(p) : p)
237
259
  const skips = platformSkips(args.nodes, args.otherPlatform)
238
260
  const tasks: PlannedTask[] = []
239
261
  for (const id of args.nodes.keys()) {
@@ -268,13 +290,13 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
268
290
  const p50 = p50s.get(t.node.id)
269
291
  if (p50 !== undefined) t.p50Ms = p50
270
292
  }
271
- return { tasks, predicted: predictPlan(tasks), ...downgradeField(args) }
293
+ return mark({ tasks, predicted: predictPlan(tasks), ...downgradeField(args) })
272
294
  } catch {
273
295
  // Failing open: prediction is a nicety — a broken history read must
274
296
  // never break `--dry`.
275
297
  }
276
298
  }
277
- return { tasks, ...downgradeField(args) }
299
+ return mark({ tasks, ...downgradeField(args) })
278
300
  }
279
301
 
280
302
  /**