@vzn/vx 0.0.644 → 0.0.646

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 (83) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +22 -48
  3. package/package.json +5 -5
  4. package/schemas/init.json +5 -0
  5. package/schemas/last.json +15 -0
  6. package/skills/vx/SKILL.md +1 -1
  7. package/src/bin.ts +1 -1
  8. package/src/cache/archive.ts +12 -76
  9. package/src/cache/cache.ts +72 -42
  10. package/src/cache/git-inputs.ts +55 -10
  11. package/src/cache/index.ts +5 -2
  12. package/src/cache/inputs.ts +61 -11
  13. package/src/cache/layer.ts +30 -8
  14. package/src/cache/layered-cache.ts +138 -44
  15. package/src/cache/output-index.ts +25 -11
  16. package/src/cache/plugin-cache.ts +9 -14
  17. package/src/cache/remote-lane.ts +60 -22
  18. package/src/cache/run-history.ts +13 -6
  19. package/src/cache/schema.ts +19 -2
  20. package/src/cache/scoped-cache.ts +3 -80
  21. package/src/cli/core-alias.ts +5 -2
  22. package/src/cli/help.ts +8 -10
  23. package/src/cli/index.ts +0 -12
  24. package/src/cli/init.ts +159 -25
  25. package/src/cli/last.ts +4 -2
  26. package/src/cli/plan-format.ts +6 -2
  27. package/src/cli/plugin-commands.ts +98 -6
  28. package/src/cli/plugin-templates.ts +2 -2
  29. package/src/cli/run.ts +2 -8
  30. package/src/cli/select.ts +5 -3
  31. package/src/cli/upgrade.ts +5 -6
  32. package/src/config.ts +2 -4
  33. package/src/exec/executor.ts +112 -9
  34. package/src/exec/index.ts +2 -0
  35. package/src/exec/proc-sample.ts +67 -5
  36. package/src/exec/runner.ts +4 -0
  37. package/src/exec/sandbox-runtime.ts +11 -4
  38. package/src/graph/scheduler.ts +59 -8
  39. package/src/history/history.ts +119 -7
  40. package/src/history/metrics.ts +53 -16
  41. package/src/index.ts +16 -1
  42. package/src/orchestrator/affected-tasks.ts +0 -18
  43. package/src/orchestrator/execute-args.ts +3 -0
  44. package/src/orchestrator/execute-task.ts +28 -7
  45. package/src/orchestrator/held-servers.ts +86 -0
  46. package/src/orchestrator/hit-restore.ts +11 -4
  47. package/src/orchestrator/index.ts +18 -1
  48. package/src/orchestrator/local-floor.ts +104 -0
  49. package/src/orchestrator/local-shortcircuit.ts +26 -7
  50. package/src/orchestrator/miss-save.ts +10 -4
  51. package/src/orchestrator/options.ts +0 -8
  52. package/src/orchestrator/persistent-task.ts +14 -0
  53. package/src/orchestrator/placement.ts +84 -9
  54. package/src/orchestrator/plan.ts +7 -1
  55. package/src/orchestrator/plugin-host.ts +22 -7
  56. package/src/orchestrator/plugin.ts +48 -7
  57. package/src/orchestrator/prepare.ts +14 -49
  58. package/src/orchestrator/run-context.ts +39 -1
  59. package/src/orchestrator/run-keys.ts +164 -0
  60. package/src/orchestrator/run-records.ts +6 -0
  61. package/src/orchestrator/run.ts +95 -6
  62. package/src/orchestrator/sandbox-request.ts +9 -3
  63. package/src/orchestrator/task-artifacts.ts +142 -0
  64. package/src/orchestrator/task-hash.ts +12 -4
  65. package/src/orchestrator/task-process.ts +16 -0
  66. package/src/orchestrator/telemetry-host.ts +2 -1
  67. package/src/orchestrator/telemetry.ts +60 -17
  68. package/src/report/forecast.ts +6 -27
  69. package/src/report/framed-output.ts +41 -5
  70. package/src/report/logger.ts +60 -20
  71. package/src/report/status-line.ts +38 -18
  72. package/src/report/summary.ts +75 -9
  73. package/src/settings.ts +8 -7
  74. package/src/util/colors.ts +16 -1
  75. package/src/util/index.ts +1 -0
  76. package/src/util/paths.ts +30 -0
  77. package/src/util/ui.ts +4 -1
  78. package/src/workspace/affected.ts +0 -39
  79. package/src/workspace/config-bytecode.ts +276 -0
  80. package/src/workspace/index.ts +0 -2
  81. package/src/workspace/migration.ts +4 -32
  82. package/src/workspace/project-loader.ts +92 -14
  83. package/src/cli/foreign-flags.ts +0 -727
@@ -2,7 +2,13 @@
2
2
  // through here (enforced by tests/module-boundaries.test.ts).
3
3
 
4
4
  export { run, planRun, nxProjectTarget, projectNamed } from './run.js'
5
- export { prepareRun, type PreparedRun } from './prepare.js'
5
+ export {
6
+ namesRemote,
7
+ openRemotes,
8
+ prepareRun,
9
+ workspaceCachePolicy,
10
+ type PreparedRun,
11
+ } from './prepare.js'
6
12
  export { affectedTaskProjects } from './affected-tasks.js'
7
13
  export { applyGraphStage } from './graph-stage.js'
8
14
  export type { AffectedReason } from './affected-tasks.js'
@@ -37,6 +43,14 @@ export { forwardedSignal } from './signals.js'
37
43
  // `vx cache clean` waits for a run on the workspace before it evicts.
38
44
  export { acquireRunLock } from './run-lock.js'
39
45
  export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
46
+ export {
47
+ cacheFacts,
48
+ taskArtifacts,
49
+ type ArtifactRun,
50
+ type ArtifactTask,
51
+ type CacheFacts,
52
+ type TaskArtifacts,
53
+ } from './task-artifacts.js'
40
54
  export {
41
55
  installPlugins,
42
56
  type CacheContext,
@@ -120,6 +134,7 @@ export {
120
134
  currentBranch,
121
135
  detectCi,
122
136
  localBranchScope,
137
+ cacheRefs,
123
138
  resolveCacheScope,
124
139
  } from './run-context.js'
125
140
  export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
@@ -133,3 +148,5 @@ export {
133
148
  export { type RunStep, runSteps } from './run-steps.js'
134
149
  export { resolveRunId, shortRunId } from './run-id.js'
135
150
  export { collectInfo, type CollectInfoOptions, type InfoFacts } from './doctor.js'
151
+ export { localFloor, type LocalFloor } from './local-floor.js'
152
+ export { sandboxRequestFor, type SandboxRequest } from './sandbox-request.js'
@@ -0,0 +1,104 @@
1
+ // Core's local floor for a plugin that runs tasks itself: `@vzn/vx-dte`'s
2
+ // orchestrator for its own share, and its agents, which run tasks another
3
+ // machine placed. A run arms the sandbox runtime before it builds a
4
+ // sandboxed request; an agent is no run, so it arms here. It starts a
5
+ // request's servers (`ExecuteRequest.servers`) once each, before the
6
+ // task, and stops them when it closes.
7
+
8
+ import type { ExecConfig } from '../config.js'
9
+ import {
10
+ localExecutor,
11
+ resetSandbox,
12
+ runPersistent,
13
+ type ExecuteRequest,
14
+ type ExecuteResult,
15
+ type ServerSpec,
16
+ } from '../exec/index.js'
17
+ import { prepareSandbox } from './sandbox-request.js'
18
+ import { forwardedSignal, terminateChildren } from './signals.js'
19
+
20
+ export interface LocalFloor {
21
+ execute(req: ExecuteRequest): Promise<ExecuteResult>
22
+ /** Arms the sandbox runtime for `sandboxes` from the next sandboxed task on; its servers keep running. */
23
+ rearm(sandboxes: readonly NonNullable<ExecConfig['sandbox']>[]): Promise<void>
24
+ /** Stops the servers this started and the sandbox runtime this armed. */
25
+ close(): Promise<void>
26
+ }
27
+
28
+ /**
29
+ * `sandboxes`, in a process that is no run, are the `exec.sandbox` configs
30
+ * of every sandboxed task the run holds: the runtime's proxy and its
31
+ * unix-socket filter are one per process, set from their union before the
32
+ * first sandboxed task (`prepareSandbox`). A request that carries a
33
+ * `signal` but no `liveChildren` has its children stopped here when it
34
+ * aborts, the way a run's stop ends its own.
35
+ */
36
+ export function localFloor(
37
+ sandboxes: readonly NonNullable<ExecConfig['sandbox']>[] = [],
38
+ ): LocalFloor {
39
+ const local = localExecutor()
40
+ const prepare = (list: typeof sandboxes) =>
41
+ prepareSandbox(list.map((sandbox) => ({ config: { exec: { sandbox } } })))
42
+ let armer = prepare(sandboxes)
43
+ // Each server, by task id: shared by every task here that depends on it.
44
+ const servers = new Map<string, Promise<ReturnType<typeof Bun.spawn>>>()
45
+ const children: ReturnType<typeof Bun.spawn>[] = []
46
+ const serve = (spec: ServerSpec): Promise<unknown> => {
47
+ let up = servers.get(spec.taskId)
48
+ if (up === undefined) {
49
+ const spawn = runPersistent({
50
+ command: spec.command,
51
+ cwd: spec.cwd,
52
+ env: spec.env,
53
+ onStdout: () => {},
54
+ onStderr: () => {},
55
+ ...(spec.readyWhen === undefined ? {} : { readyWhen: spec.readyWhen }),
56
+ ...(spec.timeoutMs === undefined ? {} : { timeoutMs: spec.timeoutMs }),
57
+ })
58
+ children.push(spawn.child)
59
+ up = spawn.ready.then(() => spawn.child)
60
+ servers.set(spec.taskId, up)
61
+ // One that never became ready is asked again by the next task.
62
+ up.catch(() => servers.delete(spec.taskId))
63
+ }
64
+ return up
65
+ }
66
+ return {
67
+ async execute(req) {
68
+ for (const spec of req.servers ?? []) {
69
+ try {
70
+ await serve(spec)
71
+ } catch (err) {
72
+ const why = err instanceof Error ? err.message : String(err)
73
+ return {
74
+ exitCode: 1,
75
+ durationMs: 0,
76
+ stdout: '',
77
+ stderr: `${spec.taskId}, which ${req.taskId} depends on, did not start: ${why}\n`,
78
+ violations: [],
79
+ }
80
+ }
81
+ }
82
+ if (req.sandbox !== undefined) await armer?.arm()
83
+ const stop = req.signal
84
+ if (stop === undefined || req.liveChildren !== undefined) return local.execute(req)
85
+ // No run holds this task's children (an agent's): its stop is ours to carry out.
86
+ const live = new Set<ReturnType<typeof Bun.spawn>>()
87
+ const end = (): void => void terminateChildren(() => [...live], forwardedSignal(stop.reason))
88
+ stop.addEventListener('abort', end, { once: true })
89
+ try {
90
+ return await local.execute({ ...req, liveChildren: live })
91
+ } finally {
92
+ stop.removeEventListener('abort', end)
93
+ }
94
+ },
95
+ async rearm(list) {
96
+ if (armer?.armed === true) await resetSandbox()
97
+ armer = prepare(list)
98
+ },
99
+ async close() {
100
+ if (children.length > 0) await terminateChildren(() => children)
101
+ if (armer?.armed === true) await resetSandbox()
102
+ },
103
+ }
104
+ }
@@ -45,6 +45,7 @@ import { normalizeGlob, relPosix, span, staticPrefix } from '../util/index.js'
45
45
  import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
46
46
  import type { TaskNode } from '../graph/index.js'
47
47
  import { deriveStableKeys, workspaceInputsReach } from './stable-keys.js'
48
+ import { readRunKeys, runKeysToken, writeRunKeys } from './run-keys.js'
48
49
  import type { HashCache, UpfrontGroupKey } from './task-hash.js'
49
50
  import { getContext } from './remote-prefetch.js'
50
51
 
@@ -100,6 +101,8 @@ export interface ShortCircuit {
100
101
  uncachedKeys: Map<string, string>
101
102
  /** Unkeyed group id → its up-front key (`deriveStableKeys`' `groupKeys`). */
102
103
  groupKeys: Map<string, UpfrontGroupKey>
104
+ /** Behind a remote, each stable task the local store missed → its key: the remote may hold it. */
105
+ remoteAsk: Map<string, string>
103
106
  }
104
107
 
105
108
  /**
@@ -117,13 +120,22 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
117
120
  const groupKeys = new Map<string, UpfrontGroupKey>()
118
121
  const endKeys = span('stable keys')
119
122
  try {
120
- stableKeys = await deriveStableKeys({ ...args, uncachedKeys, groupKeys })
123
+ const at = runKeysToken(args)
124
+ const remembered = at === undefined ? undefined : readRunKeys(args, at)
125
+ if (remembered !== undefined) {
126
+ stableKeys = remembered.stableKeys
127
+ for (const [id, key] of remembered.uncachedKeys) uncachedKeys.set(id, key)
128
+ } else {
129
+ stableKeys = await deriveStableKeys({ ...args, uncachedKeys, groupKeys })
130
+ if (at !== undefined) writeRunKeys(args, at, { stableKeys, uncachedKeys })
131
+ }
121
132
  } catch {
122
133
  return {
123
134
  preProbed: new Map(),
124
135
  restoreTier: new Set(),
125
136
  uncachedKeys: new Map(),
126
137
  groupKeys: new Map(),
138
+ remoteAsk: new Map(),
127
139
  }
128
140
  } finally {
129
141
  endKeys()
@@ -134,15 +146,16 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
134
146
  const candidates = stableKeys
135
147
  const preProbed = new Map<string, ProbedEntry>()
136
148
  const restoreTier = new Set<string>()
149
+ const remoteAsk = new Map<string, string>()
137
150
  if (candidates.length === 0 && uncachedKeys.size === 0) {
138
- return { preProbed, restoreTier, uncachedKeys, groupKeys }
151
+ return { preProbed, restoreTier, uncachedKeys, groupKeys, remoteAsk }
139
152
  }
140
153
 
141
154
  const keptOut = restoreTierExclusions(args.nodes, args.workspaceRoot)
142
155
  // A root-anchored output may land in a project no edge leads from, so an
143
156
  // uncached key there is derived again once the writer may have run.
144
157
  for (const id of keptOut) uncachedKeys.delete(id)
145
- if (candidates.length === 0) return { preProbed, restoreTier, uncachedKeys, groupKeys }
158
+ if (candidates.length === 0) return { preProbed, restoreTier, uncachedKeys, groupKeys, remoteAsk }
146
159
 
147
160
  // Bounded pool over the stable candidates: probe local ONCE each. A
148
161
  // confirmed hit becomes restore-tier (unless workspace outputs disable
@@ -165,11 +178,14 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
165
178
  endProbe()
166
179
  for (const { hash, node } of candidates) {
167
180
  const hit = hits.get(hash) ?? null
168
- if (hit === null && args.localOnly === true) continue
181
+ if (hit === null && args.localOnly === true) {
182
+ if (!keptOut.has(node.id)) remoteAsk.set(node.id, hash)
183
+ continue
184
+ }
169
185
  preProbed.set(node.id, { hash, hit })
170
186
  if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
171
187
  }
172
- return { preProbed, restoreTier, uncachedKeys, groupKeys }
188
+ return { preProbed, restoreTier, uncachedKeys, groupKeys, remoteAsk }
173
189
  } catch {
174
190
  // Fall through to the per-hash pool, which isolates a failing probe
175
191
  // to its own task.
@@ -182,7 +198,10 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
182
198
  const { hash, node } = candidates[next++]!
183
199
  try {
184
200
  const hit = await args.cache.get(hash, getContext(node, node.config.exec?.command ?? ''))
185
- if (hit === null && args.localOnly === true) continue
201
+ if (hit === null && args.localOnly === true) {
202
+ if (!keptOut.has(node.id)) remoteAsk.set(node.id, hash)
203
+ continue
204
+ }
186
205
  preProbed.set(node.id, { hash, hit })
187
206
  if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
188
207
  } catch {
@@ -193,7 +212,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
193
212
  }
194
213
  await Promise.all(Array.from({ length: workers }, () => pump()))
195
214
 
196
- return { preProbed, restoreTier, uncachedKeys, groupKeys }
215
+ return { preProbed, restoreTier, uncachedKeys, groupKeys, remoteAsk }
197
216
  }
198
217
 
199
218
  /**
@@ -13,6 +13,7 @@ import {
13
13
  type CacheLayer,
14
14
  type GitFilesCache,
15
15
  resolveOutputTree,
16
+ outputWalkTakes,
16
17
  resolveOutputs,
17
18
  resolveWorkspaceOutputs,
18
19
  WORKSPACE_OUTPUT_PREFIX,
@@ -20,11 +21,12 @@ import {
20
21
  import type { TaskNode } from '../graph/index.js'
21
22
  import {
22
23
  asTrees,
24
+ outputDirPrefixes,
23
25
  relPosix,
24
26
  span,
27
+ splitNegations,
25
28
  staticPrefix,
26
29
  taskGlob,
27
- wholeSubtreePrefixes,
28
30
  } from '../util/index.js'
29
31
  import { type Logger, warnOf } from '../report/index.js'
30
32
  import type { TaskInputComponent } from '../cache/index.js'
@@ -49,8 +51,12 @@ export interface OutputDirSnapshot {
49
51
  */
50
52
  export function entryHolds(
51
53
  node: TaskNode,
54
+ takes: (rel: string) => boolean,
52
55
  ): (files: readonly string[], rows: ReadonlyArray<{ path: string }>) => boolean {
53
- return (files, rows) => {
56
+ return (walked, rows) => {
57
+ // A root-anchored glob's snapshot walks the whole project: only what
58
+ // the glob takes is the entry's.
59
+ const files = walked.filter(takes)
54
60
  const expected = new Set(
55
61
  rows
56
62
  .map((r) => r.path)
@@ -250,13 +256,13 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
250
256
  // list gets no snapshot: recording here was that same refusal (item
251
257
  // 637 deleted the fallback and nothing reddened), and the next hit
252
258
  // walks, as it did.
253
- const savedDirPrefixes = wholeSubtreePrefixes(a.outputs)
259
+ const savedDirPrefixes = outputDirPrefixes(splitNegations(a.outputs).positive)
254
260
  if (savedDirPrefixes !== null && a.outputDirSnapshots !== undefined) {
255
261
  a.outputDirSnapshots.push({
256
262
  hash: a.hash,
257
263
  projectDir: node.projectDir,
258
264
  prefixes: savedDirPrefixes,
259
- holds: entryHolds(node),
265
+ holds: entryHolds(node, outputWalkTakes(node.projectDir, a.outputs, a.nestedProjectDirs)),
260
266
  })
261
267
  }
262
268
  }
@@ -92,14 +92,6 @@ export interface RunOptions {
92
92
  remoteRequested?: boolean
93
93
  /** This run's cache scope, above `VX_CACHE_SCOPE`, a plugin's and the branch's (an embedder's). */
94
94
  cacheScope?: string
95
- /**
96
- * What the branch changed, for a scoped run: the tasks whose key it can
97
- * move read the scope's key, every other task the default key (one
98
- * lookup each). Absent, the run diffs the branch from `scopeBase`.
99
- */
100
- scopeChanges?: AffectedChanges
101
- /** The commit a scoped run diffs the branch from (`--affected`'s base); absent, a plugin's, else the merge base with the default branch. */
102
- scopeBase?: string
103
95
  /**
104
96
  * The default policy's remote write is off (`remote:r`): the CLI passes it
105
97
  * off CI (`CI` unset, `0` or `false`) unless `--cache-policy` or
@@ -73,6 +73,20 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
73
73
  ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
74
74
  })))
75
75
 
76
+ // Every dependant starts it where it runs (`ServerSpec`): nothing to spawn
77
+ // here, and nothing that ran (owner, 2026-10-22: nothing runs on a
78
+ // distributed run's orchestrator).
79
+ if (args.carried === true)
80
+ return {
81
+ node,
82
+ status: 'success',
83
+ exitCode: 0,
84
+ durationMs: 0,
85
+ ...(hash === undefined ? {} : { hash }),
86
+ wallclockStartNs,
87
+ wallclockEndNs: wallclockStartNs,
88
+ }
89
+
76
90
  // The args after `--` reach a server as they reach any task. A readyWhen
77
91
  // server once got none — "so the matcher sees the unmodified output",
78
92
  // though the args change the command, not what the matcher reads — and
@@ -1,7 +1,7 @@
1
1
  // Where each task runs: the placement of a graph over the resolved
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
- // depends on a persistent task, is sandboxed, says `exec.remote: false`, or
4
+ // depends on a persistent task no `servers` executor carries, is sandboxed, says `exec.remote: false`, or
5
5
  // folds a runtime probe into its key; an `exec.interactive` task goes to the
6
6
  // local floor directly, as only it can hand over this terminal;
7
7
  // everything else asks the executors in declaration order, and the local
@@ -14,6 +14,7 @@ import { resolveDownloadModes } from './download-policy.js'
14
14
  import { type Logger, warnOf } from '../report/index.js'
15
15
  import { executorLabel, resolveExecutors } from './plugin-host.js'
16
16
  import type { prepareRun } from './prepare.js'
17
+ import { cacheFacts, taskArtifacts } from './task-artifacts.js'
17
18
 
18
19
  /**
19
20
  * A task is pinned to this machine when it is persistent, transitively
@@ -22,20 +23,29 @@ import type { prepareRun } from './prepare.js'
22
23
  * machinery — a worker has none of it, and a boundary "verified" where it
23
24
  * is not enforced passes vacuously), or declares `exec.remote: false`. A
24
25
  * dependant of a pinned task is pinned with it. A task whose key folds a
25
- * runtime probe is pinned alone (`withProbedRuntime`).
26
+ * runtime probe is pinned alone (`withProbedRuntime`). `sandbox: false`
27
+ * leaves `exec.sandbox` out: the set for an executor that enforces it where
28
+ * it runs (`TaskExecutor.sandbox`).
26
29
  */
27
- export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
30
+ export function pinnedLocalSet(
31
+ nodes: Map<string, TaskNode>,
32
+ sandbox = true,
33
+ /** For a `servers` executor: a carriable persistent task pins nothing. */
34
+ servers = false,
35
+ /** `false` for a `runtime` executor: it checks the probes where it runs the task. */
36
+ runtime = true,
37
+ ): Set<string> {
28
38
  const pinned = new Set<string>()
29
39
  for (const node of nodes.values()) {
30
40
  if (
31
- node.config.exec?.persistent !== undefined ||
32
- node.config.exec?.sandbox !== undefined ||
41
+ (node.config.exec?.persistent !== undefined && !(servers && carriable(node))) ||
42
+ (sandbox && node.config.exec?.sandbox !== undefined) ||
33
43
  node.config.exec?.remote === false
34
44
  ) {
35
45
  pinned.add(node.id)
36
46
  }
37
47
  }
38
- if (pinned.size === 0) return withProbedRuntime(nodes, pinned)
48
+ if (pinned.size === 0) return runtime ? withProbedRuntime(nodes, pinned) : pinned
39
49
  // Pinning flows up the dependant edges from what pins itself, one walk on
40
50
  // an explicit stack: a chain is as deep as the graph, and a recursion
41
51
  // per edge threw `RangeError` on the 50,000 the builder takes (item 737).
@@ -55,14 +65,50 @@ export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
55
65
  stack.push(up)
56
66
  }
57
67
  }
58
- return withProbedRuntime(nodes, pinned)
68
+ return runtime ? withProbedRuntime(nodes, pinned) : pinned
69
+ }
70
+
71
+ /**
72
+ * A persistent task a `servers` executor can start where it runs a
73
+ * dependant: one the run holds for its dependants only, with no sandbox
74
+ * (this machine's machinery) and no `exec.remote: false`.
75
+ */
76
+ function carriable(node: TaskNode): boolean {
77
+ const exec = node.config.exec
78
+ return (
79
+ exec?.persistent !== undefined &&
80
+ !node.requested &&
81
+ node.surfaced !== true &&
82
+ exec.sandbox === undefined &&
83
+ exec.remote !== false &&
84
+ exec.interactive !== true
85
+ )
86
+ }
87
+
88
+ /** The persistent tasks `node` depends on through groups and servers, each after its own. */
89
+ function serversBehind(nodes: ReadonlyMap<string, TaskNode>, node: TaskNode): TaskNode[] {
90
+ const out: TaskNode[] = []
91
+ const seen = new Set<string>()
92
+ const visit = (id: string): void => {
93
+ if (seen.has(id)) return
94
+ seen.add(id)
95
+ const dep = nodes.get(id)
96
+ if (dep === undefined) return
97
+ const server = dep.config.exec?.persistent !== undefined
98
+ if (!server && !isGroupTask(dep)) return
99
+ for (const d of dep.deps) visit(d)
100
+ if (server) out.push(dep)
101
+ }
102
+ for (const d of node.deps) visit(d)
103
+ return out
59
104
  }
60
105
 
61
106
  /**
62
107
  * A key that folds `inputs.runtime` / `workspaceRuntime` holds what
63
108
  * THIS machine answered (`node -v`); a worker runs its own runtime, which no
64
109
  * executor can prove equal, and its output saved under this key is a stale
65
- * hit here (C-2). Such a task runs here. Its dependants are not pinned with
110
+ * hit here (C-2). Such a task runs here, or on a `runtime` executor, which
111
+ * fails it where its probes answer otherwise. Its dependants are not pinned with
66
112
  * it: they fold the probe through its input key, and their own output does
67
113
  * not depend on this machine's runtime.
68
114
  */
@@ -131,6 +177,10 @@ export interface Placements {
131
177
  remoteOnlyNoop: Set<string>
132
178
  /** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
133
179
  remoteOnly: Set<string>
180
+ /** Each task placed on a `servers` executor → the persistent tasks it starts there. */
181
+ servers: Map<string, TaskNode[]>
182
+ /** Persistent tasks every dependant of which carries them: never started here. */
183
+ carried: Set<string>
134
184
  }
135
185
 
136
186
  /**
@@ -156,12 +206,27 @@ export function placeTasks(
156
206
  nodes: Map<string, TaskNode>,
157
207
  executors: readonly TaskExecutor[],
158
208
  ): Placements {
159
- const pinned = pinnedLocalSet(nodes)
209
+ // Each executor's pins, computed only when one reads them.
210
+ const pins = new Map<string, Set<string>>()
211
+ const pinsOf = (e: Pick<TaskExecutor, 'sandbox' | 'servers' | 'runtime'>): Set<string> => {
212
+ const k = `${e.sandbox === true}${e.servers === true}${e.runtime === true}`
213
+ let set = pins.get(k)
214
+ if (set === undefined) {
215
+ set = pinnedLocalSet(nodes, e.sandbox !== true, e.servers === true, e.runtime !== true)
216
+ pins.set(k, set)
217
+ }
218
+ return set
219
+ }
220
+ const pinned = pinsOf({})
160
221
  const placements: Placements = {
161
222
  executors: new Map(),
162
223
  remoteOnlyNoop: new Set(),
163
224
  remoteOnly: new Set(),
225
+ servers: new Map(),
226
+ carried: new Set(),
164
227
  }
228
+ /** Persistent tasks a dependant placed off a `servers` executor needs here. */
229
+ const heldHere = new Set<string>()
165
230
  // `resolveExecutors` puts it last in every list.
166
231
  const floor = executors.find(isLocalExecutor)!
167
232
  for (const node of nodes.values()) {
@@ -181,8 +246,14 @@ export function placeTasks(
181
246
  cacheable: node.config.cache !== undefined,
182
247
  },
183
248
  executorLabel,
249
+ (e) => pinsOf(e).has(node.id),
184
250
  )
185
251
  placements.executors.set(node.id, executor)
252
+ const servers = serversBehind(nodes, node)
253
+ if (servers.length > 0) {
254
+ if (executor.servers === true) placements.servers.set(node.id, servers)
255
+ else for (const s of servers) heldHere.add(s.id)
256
+ }
186
257
  if (node.config.exec?.remote === 'only') {
187
258
  // A pinned 'only' task (it transitively depends on a persistent one)
188
259
  // lands here too: pinning wins, so it noops rather than shipping.
@@ -190,6 +261,8 @@ export function placeTasks(
190
261
  else placements.remoteOnlyNoop.add(node.id)
191
262
  }
192
263
  }
264
+ for (const servers of placements.servers.values())
265
+ for (const s of servers) if (!heldHere.has(s.id)) placements.carried.add(s.id)
193
266
  return placements
194
267
  }
195
268
 
@@ -220,6 +293,8 @@ export async function planExecutorOf(
220
293
  cacheDir: prepared.cacheDir,
221
294
  warn: warnOf(log),
222
295
  concurrency: machineParallelism(),
296
+ artifacts: taskArtifacts(prepared.cache, prepared.workspaceRoot),
297
+ cacheFacts: () => cacheFacts(prepared.remotes, prepared.cacheRefs),
223
298
  })
224
299
  } catch (err) {
225
300
  warnOf(log)(
@@ -54,6 +54,8 @@ export interface PlannedTask {
54
54
  * eager task is the default and saying so on every line says nothing.
55
55
  */
56
56
  download?: 'deferred'
57
+ /** A remote hit's stored size, when the remote said it: what restoring it downloads. */
58
+ remoteBytes?: number
57
59
  /** Why `--affected` kept this requested task; absent without `--affected` and on a dependency it pulled in. */
58
60
  affected?: AffectedReason
59
61
  }
@@ -139,6 +141,7 @@ export interface PlanArgs {
139
141
  */
140
142
  export async function plan(args: PlanArgs): Promise<RunPlan> {
141
143
  const cacheStatusById = new Map<string, CacheStatus>()
144
+ const remoteBytesById = new Map<string, number>()
142
145
  // Keyed first, probed after: a remote that can batch (`hasMany`) is
143
146
  // asked once for every key, one that cannot is asked per key.
144
147
  const probed = new Map<string, string>()
@@ -191,13 +194,15 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
191
194
  },
192
195
  })
193
196
  if (probed.size > 0) {
194
- await args.cache.remoteHasMany?.([...new Set(probed.values())])
197
+ const infos = await args.cache.remoteHasMany?.([...new Set(probed.values())])
195
198
  for (const [id, hash] of probed) {
196
199
  const where = await args.cache.has(hash)
197
200
  cacheStatusById.set(
198
201
  id,
199
202
  where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local',
200
203
  )
204
+ const size = where === 'remote' ? infos?.get(hash)?.size : undefined
205
+ if (size !== undefined) remoteBytesById.set(id, size)
201
206
  }
202
207
  }
203
208
 
@@ -225,6 +230,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
225
230
  deps: node.deps,
226
231
  ...(executor !== undefined ? { executor } : {}),
227
232
  ...(args.downloadOf?.(id) === 'deferred' ? { download: 'deferred' as const } : {}),
233
+ ...(remoteBytesById.has(id) ? { remoteBytes: remoteBytesById.get(id)! } : {}),
228
234
  })
229
235
  }
230
236
 
@@ -9,7 +9,6 @@
9
9
 
10
10
  import path from 'node:path'
11
11
  import {
12
- type BranchScope,
13
12
  type Cache,
14
13
  type CacheLayer,
15
14
  type CachePolicy,
@@ -428,11 +427,12 @@ export interface ScheduleRun {
428
427
  export async function applyScheduleHooks(
429
428
  plugins: readonly VxPlugin[],
430
429
  nodes: ReadonlyMap<string, TaskNode>,
431
- ctx: Omit<ScheduleHookContext, 'cache' | 'refine'>,
430
+ ctx: Omit<ScheduleHookContext, 'cache' | 'refine' | 'onRefine'>,
432
431
  cacheFor: (plugin: string) => PluginCache = () => absentPluginCache,
433
432
  run: ScheduleRun = {},
434
433
  ): Promise<ReadonlyMap<string, number>> {
435
434
  const sets: (ReadonlyMap<string, number> | undefined)[] = []
435
+ const listeners: { plugin: VxPlugin; i: number; listener: () => void }[] = []
436
436
  const merged = (): ReadonlyMap<string, number> => {
437
437
  const out = new Map<string, number>()
438
438
  for (const set of sets) if (set !== undefined) for (const [id, w] of set) out.set(id, w)
@@ -449,8 +449,21 @@ export async function applyScheduleHooks(
449
449
  return
450
450
  }
451
451
  run.refined(merged())
452
+ for (const l of listeners) {
453
+ if (l.i === i) continue
454
+ try {
455
+ l.listener()
456
+ } catch (err) {
457
+ ctx.warn(
458
+ `[vx] plugin '${l.plugin.name}' failed in onRefine: ${err instanceof Error ? err.message : String(err)}`,
459
+ )
460
+ }
461
+ }
462
+ }
463
+ const onRefine = (listener: () => void): void => {
464
+ if (run.refined !== undefined) listeners.push({ plugin, i, listener })
452
465
  }
453
- const own = { ...ctx, cache: cacheFor(plugin.name), refine }
466
+ const own = { ...ctx, cache: cacheFor(plugin.name), refine, onRefine }
454
467
  const call = (): Promise<ReadonlyMap<string, number> | undefined> =>
455
468
  safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
456
469
  const weights = await (run.step === undefined
@@ -556,10 +569,10 @@ export interface CacheHost extends CacheContext {
556
569
  readonly policy: CachePolicy
557
570
  /** Filled with each remote and what its policy lets it do, for the plugins' `ctx.cache`. */
558
571
  readonly remotes?: ResolvedRemote[]
559
- /** The run's branch scope, which picks each remote key. */
560
- readonly branch?: BranchScope
561
572
  /** Uploads at once, per remote (`cache.concurrency.upload`). */
562
573
  readonly uploadConcurrency?: number
574
+ /** What another machine's run lent, by plugin: each plugin's `ctx.lent`. */
575
+ readonly lentBy?: () => Readonly<Record<string, string>> | undefined
563
576
  }
564
577
 
565
578
  /**
@@ -574,10 +587,12 @@ export async function resolveCache(
574
587
  plugins: readonly VxPlugin[],
575
588
  host: CacheHost,
576
589
  ): Promise<CacheLayer> {
577
- const { localCache, policy, remotes, branch, uploadConcurrency, ...ctx } = host
590
+ const { localCache, policy, remotes, uploadConcurrency, lentBy, ...base } = host
578
591
  const resolved: ResolvedRemote[] = []
579
592
  for (const plugin of plugins) {
580
593
  if (plugin.cache === undefined) continue
594
+ const ctx: CacheContext =
595
+ lentBy === undefined ? base : { ...base, lent: () => lentBy()?.[plugin.name] }
581
596
  const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
582
597
  if (remote === undefined) continue
583
598
  assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
@@ -593,7 +608,7 @@ export async function resolveCache(
593
608
  }
594
609
  return resolved.length === 0
595
610
  ? localCache
596
- : new LayeredCache(localCache, resolved, branch, uploadConcurrency)
611
+ : new LayeredCache(localCache, resolved, uploadConcurrency)
597
612
  }
598
613
 
599
614
  /** The plugin each resolved executor came from, so a hook of it that throws is named. */