@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -0,0 +1,198 @@
1
+ // Core's local floor for a plugin that runs tasks itself: `@vzn/vx-dte`'s
2
+ // workers, which run tasks another machine placed. A run arms the sandbox
3
+ // runtime before it builds a sandboxed request; a worker is no run, so it
4
+ // arms here. It starts a request's servers (`ExecuteRequest.servers`) once
5
+ // each, before the task, each inside its own sandbox, and stops them when
6
+ // it closes.
7
+
8
+ import type { ExecConfig } from '../config.js'
9
+ import {
10
+ localExecutor,
11
+ PersistentReadyError,
12
+ releaseBridges,
13
+ resetSandbox,
14
+ runPersistent,
15
+ signalExitCode,
16
+ type ExecuteRequest,
17
+ type ExecuteResult,
18
+ type ServerSpec,
19
+ wrapSandboxedCommand,
20
+ } from '../exec/index.js'
21
+ import { placeholderSweeper, prepareSandbox, sandboxRequestFor } from './sandbox-request.js'
22
+ import { forwardedSignal, terminateChildren } from './signals.js'
23
+
24
+ /** How much of a server's output before ready a failure to start quotes. */
25
+ const HEARD_BYTES = 8192
26
+
27
+ export interface LocalFloor {
28
+ execute(req: ExecuteRequest): Promise<ExecuteResult>
29
+ /** Arms the sandbox runtime for `sandboxes` from the next sandboxed task on; its servers keep running. */
30
+ rearm(sandboxes: readonly NonNullable<ExecConfig['sandbox']>[]): Promise<void>
31
+ /** Stops the servers this started and the sandbox runtime this armed. */
32
+ close(): Promise<void>
33
+ }
34
+
35
+ /**
36
+ * `sandboxes`, in a process that is no run, are the `exec.sandbox` configs
37
+ * of every sandboxed task the run holds: the runtime's proxy and its
38
+ * unix-socket filter are one per process, set from their union before the
39
+ * first sandboxed task (`prepareSandbox`). A request that carries a
40
+ * `signal` but no `liveChildren` has its children stopped here when it
41
+ * aborts, the way a run's stop ends its own.
42
+ */
43
+ export function localFloor(
44
+ sandboxes: readonly NonNullable<ExecConfig['sandbox']>[] = [],
45
+ ): LocalFloor {
46
+ const local = localExecutor()
47
+ const prepare = (list: typeof sandboxes) =>
48
+ prepareSandbox(list.map((sandbox) => ({ config: { exec: { sandbox } } })))
49
+ let armer = prepare(sandboxes)
50
+ // Each server, by task id: shared by every task here that depends on it.
51
+ const servers = new Map<string, Promise<ReturnType<typeof Bun.spawn>>>()
52
+ const children: ReturnType<typeof Bun.spawn>[] = []
53
+ // A sandboxed one starts inside its sandbox, built here as a one-shot
54
+ // task's; its port bridges and untouched placeholders go when it exits.
55
+ const start = async (spec: ServerSpec, root: string): Promise<ReturnType<typeof Bun.spawn>> => {
56
+ let command = spec.command
57
+ let signalChannel = false
58
+ let gone = async (): Promise<unknown> => undefined
59
+ if (spec.sandbox !== undefined) {
60
+ await armer?.arm()
61
+ const sb = await sandboxRequestFor(
62
+ { projectDir: spec.cwd },
63
+ spec.sandbox.config,
64
+ root,
65
+ undefined,
66
+ spec.sandbox.nested,
67
+ )
68
+ const swept = placeholderSweeper(sb.placeholders)
69
+ const wrapped = await wrapSandboxedCommand({
70
+ command,
71
+ cwd: spec.cwd,
72
+ env: spec.env,
73
+ ...sb.sandbox,
74
+ server: true,
75
+ }).catch(async (err: unknown) => {
76
+ await swept()
77
+ throw err
78
+ })
79
+ command = wrapped.wrapped
80
+ signalChannel = wrapped.forwardsSignals
81
+ gone = async () => {
82
+ if (wrapped.tag !== undefined) releaseBridges(wrapped.tag)
83
+ return swept()
84
+ }
85
+ }
86
+ // Its output until ready, the tail kept: a server that dies first says
87
+ // why (`docker run` exiting 125 said nothing on CI, 2026-10-26).
88
+ let heard: string | undefined = ''
89
+ const hear = (t: string): void => {
90
+ if (heard !== undefined) heard = (heard + t).slice(-HEARD_BYTES)
91
+ }
92
+ const spawn = runPersistent({
93
+ command,
94
+ cwd: spec.cwd,
95
+ env: spec.env,
96
+ onStdout: hear,
97
+ onStderr: hear,
98
+ ...(spec.readyWhen === undefined ? {} : { readyWhen: spec.readyWhen }),
99
+ ...(spec.timeoutMs === undefined ? {} : { timeoutMs: spec.timeoutMs }),
100
+ ...(signalChannel ? { signalChannel } : {}),
101
+ })
102
+ // A spawn that threw hands back no child, only a rejected `ready`.
103
+ if (spawn.child === undefined) void gone()
104
+ else {
105
+ children.push(spawn.child)
106
+ void spawn.child.exited.then(gone, gone)
107
+ }
108
+ try {
109
+ await spawn.ready
110
+ } catch (err) {
111
+ // A shell that exited before ready can leave its group running
112
+ // (`server & …; exit`), holding the port of the one the next task starts.
113
+ if (
114
+ err instanceof PersistentReadyError &&
115
+ err.reason === 'exited' &&
116
+ spawn.child !== undefined
117
+ )
118
+ await terminateChildren(() => [spawn.child!])
119
+ const why = err instanceof Error ? err.message : String(err)
120
+ const said = (heard ?? '').trimEnd()
121
+ throw new Error(said === '' ? why : `${why}; its output:\n${said}`)
122
+ }
123
+ heard = undefined
124
+ // Ready, so it spawned: only a failed spawn has no child.
125
+ return spawn.child!
126
+ }
127
+ const serve = (spec: ServerSpec, root: string): Promise<ReturnType<typeof Bun.spawn>> => {
128
+ let up = servers.get(spec.taskId)
129
+ if (up === undefined) {
130
+ up = start(spec, root)
131
+ servers.set(spec.taskId, up)
132
+ // One that never became ready is asked again by the next task.
133
+ up.catch(() => servers.delete(spec.taskId))
134
+ }
135
+ return up
136
+ }
137
+ return {
138
+ async execute(req) {
139
+ // A stop while a server is not yet ready ends the wait: one that never
140
+ // prints its `readyWhen` line held the task, and a worker's shutdown
141
+ // waits on its tasks before `close()` stops the servers. The aborted
142
+ // request then reads as one stopped before its spawn.
143
+ const signal = req.signal
144
+ const stopped =
145
+ signal === undefined
146
+ ? undefined
147
+ : new Promise<void>((resolve) => {
148
+ if (signal.aborted) resolve()
149
+ else signal.addEventListener('abort', () => resolve(), { once: true })
150
+ })
151
+ for (const spec of req.servers ?? []) {
152
+ if (signal?.aborted === true) break
153
+ try {
154
+ const up = serve(spec, req.workspaceRoot)
155
+ const child = await (stopped === undefined ? up : Promise.race([up, stopped]))
156
+ // One that crashed after it was ready serves nobody: the task ran
157
+ // against nothing and failed on its own "connection refused". A
158
+ // clean exit is a setup step that did its work (runPersistent).
159
+ const died = child === undefined ? null : (child.exitCode ?? child.signalCode)
160
+ if (died !== null && died !== 0) {
161
+ const code = typeof died === 'number' ? died : signalExitCode(died)
162
+ const text = `${spec.taskId}, which ${req.taskId} depends on, exited with code ${code} after it was ready\n`
163
+ req.onStderr(text)
164
+ return { exitCode: 1, durationMs: 0, stdout: '', stderr: text, violations: [] }
165
+ }
166
+ } catch (err) {
167
+ const why = err instanceof Error ? err.message : String(err)
168
+ const text = `${spec.taskId}, which ${req.taskId} depends on, did not start: ${why}\n`
169
+ // Through `onStderr` too, as a spawn failure's text goes: a worker
170
+ // ships only what its callbacks heard, and a dependant failed
171
+ // "exit 1, 0ms" with no word of why (CI, 2026-10-25).
172
+ req.onStderr(text)
173
+ return { exitCode: 1, durationMs: 0, stdout: '', stderr: text, violations: [] }
174
+ }
175
+ }
176
+ if (req.sandbox !== undefined) await armer?.arm()
177
+ const stop = req.signal
178
+ if (stop === undefined || req.liveChildren !== undefined) return local.execute(req)
179
+ // No run holds this task's children (a worker's): its stop is ours to carry out.
180
+ const live = new Set<ReturnType<typeof Bun.spawn>>()
181
+ const end = (): void => void terminateChildren(() => [...live], forwardedSignal(stop.reason))
182
+ stop.addEventListener('abort', end, { once: true })
183
+ try {
184
+ return await local.execute({ ...req, liveChildren: live })
185
+ } finally {
186
+ stop.removeEventListener('abort', end)
187
+ }
188
+ },
189
+ async rearm(list) {
190
+ if (armer?.armed === true) await resetSandbox()
191
+ armer = prepare(list)
192
+ },
193
+ async close() {
194
+ if (children.length > 0) await terminateChildren(() => children)
195
+ if (armer?.armed === true) await resetSandbox()
196
+ },
197
+ }
198
+ }
@@ -44,7 +44,8 @@
44
44
  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
- import { deriveStableKeys, workspaceInputsReach } from './stable-keys.js'
47
+ import { deriveStableKeys, rederiveKeys, workspaceInputsReach } from './stable-keys.js'
48
+ import { readRunDelta, readRunKeys, runKeysToken, writeRunDelta, 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,46 @@ 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
+ // An edit moved some files tokens: the last derivation under the same
130
+ // graph keys again only the tasks the edit reaches.
131
+ const delta = at === undefined ? undefined : readRunDelta(args, at)
132
+ let derivation
133
+ if (delta !== undefined) {
134
+ derivation = delta.derivation
135
+ await rederiveKeys(args, derivation.keys, delta.dirty)
136
+ const keys = derivation.keys
137
+ stableKeys = derivation.stable.map((id) => ({
138
+ hash: keys.get(id)!,
139
+ node: args.nodes.get(id)!,
140
+ }))
141
+ for (const id of derivation.uncached) uncachedKeys.set(id, keys.get(id)!)
142
+ } else {
143
+ const keys = new Map<string, string>()
144
+ stableKeys = await deriveStableKeys({ ...args, uncachedKeys, groupKeys, keys })
145
+ derivation = {
146
+ keys,
147
+ stable: stableKeys.map((k) => k.node.id),
148
+ uncached: [...uncachedKeys.keys()],
149
+ }
150
+ }
151
+ if (at !== undefined) {
152
+ writeRunKeys(args, at, { stableKeys, uncachedKeys })
153
+ writeRunDelta(args, at, derivation)
154
+ }
155
+ }
121
156
  } catch {
122
157
  return {
123
158
  preProbed: new Map(),
124
159
  restoreTier: new Set(),
125
160
  uncachedKeys: new Map(),
126
161
  groupKeys: new Map(),
162
+ remoteAsk: new Map(),
127
163
  }
128
164
  } finally {
129
165
  endKeys()
@@ -134,15 +170,16 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
134
170
  const candidates = stableKeys
135
171
  const preProbed = new Map<string, ProbedEntry>()
136
172
  const restoreTier = new Set<string>()
173
+ const remoteAsk = new Map<string, string>()
137
174
  if (candidates.length === 0 && uncachedKeys.size === 0) {
138
- return { preProbed, restoreTier, uncachedKeys, groupKeys }
175
+ return { preProbed, restoreTier, uncachedKeys, groupKeys, remoteAsk }
139
176
  }
140
177
 
141
178
  const keptOut = restoreTierExclusions(args.nodes, args.workspaceRoot)
142
179
  // A root-anchored output may land in a project no edge leads from, so an
143
180
  // uncached key there is derived again once the writer may have run.
144
181
  for (const id of keptOut) uncachedKeys.delete(id)
145
- if (candidates.length === 0) return { preProbed, restoreTier, uncachedKeys, groupKeys }
182
+ if (candidates.length === 0) return { preProbed, restoreTier, uncachedKeys, groupKeys, remoteAsk }
146
183
 
147
184
  // Bounded pool over the stable candidates: probe local ONCE each. A
148
185
  // confirmed hit becomes restore-tier (unless workspace outputs disable
@@ -165,11 +202,14 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
165
202
  endProbe()
166
203
  for (const { hash, node } of candidates) {
167
204
  const hit = hits.get(hash) ?? null
168
- if (hit === null && args.localOnly === true) continue
205
+ if (hit === null && args.localOnly === true) {
206
+ if (!keptOut.has(node.id)) remoteAsk.set(node.id, hash)
207
+ continue
208
+ }
169
209
  preProbed.set(node.id, { hash, hit })
170
210
  if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
171
211
  }
172
- return { preProbed, restoreTier, uncachedKeys, groupKeys }
212
+ return { preProbed, restoreTier, uncachedKeys, groupKeys, remoteAsk }
173
213
  } catch {
174
214
  // Fall through to the per-hash pool, which isolates a failing probe
175
215
  // to its own task.
@@ -182,7 +222,10 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
182
222
  const { hash, node } = candidates[next++]!
183
223
  try {
184
224
  const hit = await args.cache.get(hash, getContext(node, node.config.exec?.command ?? ''))
185
- if (hit === null && args.localOnly === true) continue
225
+ if (hit === null && args.localOnly === true) {
226
+ if (!keptOut.has(node.id)) remoteAsk.set(node.id, hash)
227
+ continue
228
+ }
186
229
  preProbed.set(node.id, { hash, hit })
187
230
  if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
188
231
  } catch {
@@ -193,7 +236,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
193
236
  }
194
237
  await Promise.all(Array.from({ length: workers }, () => pump()))
195
238
 
196
- return { preProbed, restoreTier, uncachedKeys, groupKeys }
239
+ return { preProbed, restoreTier, uncachedKeys, groupKeys, remoteAsk }
197
240
  }
198
241
 
199
242
  /**
@@ -15,7 +15,7 @@
15
15
 
16
16
  import path from 'node:path'
17
17
  import { rename, stat } from 'node:fs/promises'
18
- import { relPosix, xxh3, xxh3hex } from '../util/index.js'
18
+ import { relPosix, UserError, xxh3, xxh3hex } from '../util/index.js'
19
19
  import type { TaskNode } from '../graph/index.js'
20
20
  import type {
21
21
  FingerprintChange,
@@ -37,7 +37,7 @@ export interface LockfileClaimOptions {
37
37
  /**
38
38
  * Root-relative files the lockfile names whose BYTES the install uses but
39
39
  * the lockfile does not pin (bun.lock's `patchedDependencies` names a patch
40
- * file and records no hash of it). Their content hashes ('' when absent)
40
+ * file and records no hash of it). Their content hashes ('' when absent; unreadable refuses)
41
41
  * reach `digest` as `files` and join the memo's identity, so an edited
42
42
  * patch moves the key (item 1014). A directory (a `file:` dependency the
43
43
  * install copies) hashes every file under it but `node_modules` and
@@ -108,18 +108,30 @@ interface Memo {
108
108
  edges?: Record<string, LockfileImporter>
109
109
  }
110
110
 
111
- /** A root-relative extra file's content hash, '' when absent; a path leaving the root is never read. */
111
+ /**
112
+ * A root-relative extra file's content hash, '' when absent; a path leaving
113
+ * the root is never read. One there but unreadable (EACCES, a symlink loop)
114
+ * refuses, as a settings file does: folded as '', it keyed as no file at all.
115
+ */
112
116
  async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
113
117
  if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return 'outside-root'
114
118
  const abs = path.join(workspaceRoot, rel)
115
119
  try {
116
120
  return xxh3hex(await Bun.file(abs).bytes())
117
121
  } catch (err) {
118
- if ((err as NodeJS.ErrnoException).code === 'EISDIR') return hashDir(abs)
119
- return ''
122
+ const code = (err as NodeJS.ErrnoException).code
123
+ if (code === 'EISDIR') return hashDir(abs)
124
+ if (code === 'ENOENT' || code === 'ENOTDIR') return ''
125
+ throw unreadable(rel, err)
120
126
  }
121
127
  }
122
128
 
129
+ function unreadable(rel: string, err: unknown): UserError {
130
+ return new UserError(
131
+ `${rel}: cannot be read (${err instanceof Error ? err.message : String(err)})`,
132
+ )
133
+ }
134
+
123
135
  /**
124
136
  * Every file under `dir` but its `node_modules` and `.git`, as `rel\0hash`
125
137
  * lines in sorted order. Links are followed: a linked file is what a task
@@ -322,7 +334,18 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
322
334
  return names
323
335
  },
324
336
  ...(options.extraFiles !== undefined
325
- ? { extraFiles: (_file: string, bytes: Uint8Array) => options.extraFiles!(decode(bytes)) }
337
+ ? {
338
+ // The memo records which files these exact bytes name, for this
339
+ // parser: `--affected` asks on every diff, and a cold parse of
340
+ // a large lockfile was most of its selection's cost.
341
+ async extraFiles(_file: string, bytes: Uint8Array, ctx: FingerprintContext) {
342
+ const memoFile = path.join(ctx.cacheDir, 'lockfile-claims', `${file}.json`)
343
+ const memo = await readMemo(memoFile, version, claimant, xxh3hex(bytes))
344
+ return memo !== undefined
345
+ ? Object.keys(memo.extras)
346
+ : options.extraFiles!(decode(bytes))
347
+ },
348
+ }
326
349
  : {}),
327
350
  },
328
351
  async key(task, ctx) {
@@ -347,15 +370,22 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
347
370
  * Node's resolution walking up to the root `node_modules` (`@types/*`,
348
371
  * a config's plugins) — so a root devDependency bump that moved only the
349
372
  * root's digest replayed the old tool's output (nx#36415 class,
350
- * 2026-09-24). A project the lockfile has no importer for (outside the
351
- * workspace's `packages`) folds the root's alone; a file with neither
352
- * folds a constant.
373
+ * 2026-09-24). A project the lockfile has no importer for folds its
374
+ * nearest listed ancestor's, as Node resolves from that ancestor's
375
+ * `node_modules` (`packages/a/e2e` from `packages/a`'s): the root's alone
376
+ * keyed it on no bump only `a` reached. Outside every importer it folds
377
+ * the root's alone; a file with neither folds a constant.
353
378
  */
354
379
  function digestFor(importers: ReadonlyMap<string, string>, importer: string): string {
355
- const own = importers.get(importer)
380
+ let dir = importer
381
+ while (dir !== '.' && !importers.has(dir)) {
382
+ const slash = dir.lastIndexOf('/')
383
+ dir = slash === -1 ? '.' : dir.slice(0, slash)
384
+ }
385
+ const own = importers.get(dir)
356
386
  const root = importers.get('.')
357
387
  if (own === undefined) return root ?? 'no-importer'
358
- if (root === undefined || importer === '.') return own
388
+ if (root === undefined || dir === '.') return own
359
389
  return xxh3hex(`${own}\0${root}`)
360
390
  }
361
391
 
@@ -423,6 +453,49 @@ function decode(bytes: Uint8Array): string {
423
453
 
424
454
  // --- Reach digests -----------------------------------------------------------
425
455
 
456
+ /**
457
+ * Each member of a cycle as what it is and where its edges land, by
458
+ * colour refinement: a member starts as its material and the child
459
+ * digests its edges leave the cycle for, then folds its in-cycle
460
+ * targets' labels until no two labels part further. npm and bun key two
461
+ * copies of one version alike (`a/node_modules/foo` → bar@1,
462
+ * `node_modules/foo` → bar@2), so by material alone two members swapping
463
+ * copies, in the cycle or out of it, moved nothing. Two members left with
464
+ * one label have the same material, reach the same children, and their
465
+ * in-cycle edges land on labels alike to every depth. Telling such a pair
466
+ * apart takes an isomorphism test; it needs a cycle where copies of one
467
+ * version reach alike at every step, which no swap of what one reaches
468
+ * produces.
469
+ */
470
+ function cycleLabels(
471
+ g: ReachGraph,
472
+ members: readonly number[],
473
+ childOf: (w: number) => string | undefined,
474
+ ): string[] {
475
+ let label = new Map<number, string>()
476
+ for (const m of members) {
477
+ const out = new Set<string>()
478
+ for (const w of g.edges[m]!) {
479
+ const child = w === m ? undefined : childOf(w)
480
+ if (child !== undefined) out.add(child)
481
+ }
482
+ label.set(m, xxh3hex(`${g.material[m]!}\0${[...out].sort().join('\0')}`))
483
+ }
484
+ let parts = new Set(label.values()).size
485
+ for (;;) {
486
+ const next = new Map<number, string>()
487
+ for (const m of members) {
488
+ const inner = new Set<string>()
489
+ for (const w of g.edges[m]!) if (w !== m && label.has(w)) inner.add(label.get(w)!)
490
+ next.set(m, xxh3hex(`${label.get(m)!}\0${[...inner].sort().join('\0')}`))
491
+ }
492
+ label = next
493
+ const now = new Set(label.values()).size
494
+ if (now === parts) return members.map((m) => label.get(m)!)
495
+ parts = now
496
+ }
497
+ }
498
+
426
499
  /** A dependency graph: one material string per node and its out-edges by index. */
427
500
  export interface ReachGraph {
428
501
  readonly material: readonly string[]
@@ -493,11 +566,18 @@ export function reachDigests(g: ReachGraph): string[] {
493
566
  // share one digest, so retargeting an edge between two of them (an
494
567
  // importer's `y@1.0.0` → `y@1.1.0` inside one cycle) moved nothing
495
568
  // and replayed a stale output (item 1013). A self-loop reaches
496
- // nothing new and is left out.
497
- const lines = members.map((m) => {
498
- const targets = [...new Set(g.edges[m]!.filter((w) => w !== m).map((w) => g.material[w]!))]
499
- return `${g.material[m]!}\0${targets.sort().join('\0')}`
500
- })
569
+ // nothing new and is left out. A singleton's children already say
570
+ // where its edges land, and its line stays as it was, so an acyclic
571
+ // digest keeps its value.
572
+ const lines =
573
+ members.length === 1
574
+ ? members.map((m) => {
575
+ const targets = [
576
+ ...new Set(g.edges[m]!.filter((w) => w !== m).map((w) => g.material[w]!)),
577
+ ]
578
+ return `${g.material[m]!}\0${targets.sort().join('\0')}`
579
+ })
580
+ : cycleLabels(g, members, (w) => (comp[w] === id ? undefined : compHash[comp[w]!]!))
501
581
  let h = xxh3(`members:${members.length}`)
502
582
  for (const line of lines.sort()) h = xxh3(`${line}\n`, h)
503
583
  h = xxh3(`children:${children.size}`, h)
@@ -7,24 +7,27 @@
7
7
  // a later run replays. Moved out of execute-task.ts 2026-09-09 as pure
8
8
  // code motion.
9
9
 
10
- import { lstatSync, realpathSync, statSync } from 'node:fs'
10
+ import { existsSync, lstatSync, realpathSync, statSync } from 'node:fs'
11
11
  import path from 'node:path'
12
12
  import {
13
13
  type CacheLayer,
14
14
  type GitFilesCache,
15
15
  resolveOutputTree,
16
+ outputWalkTakes,
16
17
  resolveOutputs,
17
18
  resolveWorkspaceOutputs,
19
+ resolveWorkspaceOutputTree,
18
20
  WORKSPACE_OUTPUT_PREFIX,
19
21
  } from '../cache/index.js'
20
22
  import type { TaskNode } from '../graph/index.js'
21
23
  import {
22
24
  asTrees,
25
+ outputDirPrefixes,
23
26
  relPosix,
24
27
  span,
28
+ splitNegations,
25
29
  staticPrefix,
26
30
  taskGlob,
27
- wholeSubtreePrefixes,
28
31
  } from '../util/index.js'
29
32
  import { type Logger, warnOf } from '../report/index.js'
30
33
  import type { TaskInputComponent } from '../cache/index.js'
@@ -49,8 +52,12 @@ export interface OutputDirSnapshot {
49
52
  */
50
53
  export function entryHolds(
51
54
  node: TaskNode,
55
+ takes: (rel: string) => boolean,
52
56
  ): (files: readonly string[], rows: ReadonlyArray<{ path: string }>) => boolean {
53
- return (files, rows) => {
57
+ return (walked, rows) => {
58
+ // A root-anchored glob's snapshot walks the whole project: only what
59
+ // the glob takes is the entry's.
60
+ const files = walked.filter(takes)
54
61
  const expected = new Set(
55
62
  rows
56
63
  .map((r) => r.path)
@@ -160,12 +167,11 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
160
167
  nestedProjectDirs: a.nestedProjectDirs,
161
168
  })
162
169
  const outputFiles = tree.files
163
- const wsOutputFiles =
164
- a.ownWsOutputFiles ??
165
- (await resolveWorkspaceOutputs({
166
- workspaceRoot: a.workspaceRoot,
167
- outputs: a.wsOutputs,
168
- }))
170
+ const wsTree =
171
+ a.ownWsOutputFiles !== undefined
172
+ ? { files: a.ownWsOutputFiles, emptyDirs: [] }
173
+ : await resolveWorkspaceOutputTree({ workspaceRoot: a.workspaceRoot, outputs: a.wsOutputs })
174
+ const wsOutputFiles = wsTree.files
169
175
  endResolve()
170
176
  // The mirror of the input warning: declared outputs that resolve to
171
177
  // nothing save an empty artifact, and the next hit "restores" it —
@@ -196,7 +202,11 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
196
202
  ? ` — ${linkedOut.dir} is a symlink to ${linkedOut.target}, outside ${linkedOut.base}, and vx keeps only outputs inside it: make ${linkedOut.dir} a directory`
197
203
  : sandboxed && !grantsWrite
198
204
  ? ` — the task is sandboxed and declares no exec.sandbox.allow.write, so its writes never reached disk`
199
- : ''),
205
+ : '') +
206
+ bracketLiteralHint([
207
+ ...a.outputs.map((g) => [node.projectDir, g] as const),
208
+ ...a.wsOutputs.map((g) => [a.workspaceRoot, g] as const),
209
+ ]),
200
210
  )
201
211
  }
202
212
  const facts: SaveFacts = {}
@@ -214,6 +224,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
214
224
  // The root an output symlink's target must stay inside, outputs.workspaceFiles or not.
215
225
  workspaceRoot: a.workspaceRoot,
216
226
  ...(wsOutputFiles.length > 0 ? { workspaceOutputFiles: wsOutputFiles } : {}),
227
+ ...(wsTree.emptyDirs.length > 0 ? { workspaceOutputDirs: wsTree.emptyDirs } : {}),
217
228
  inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
218
229
  // No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
219
230
  // the contract no longer accepts one — so the invariant is enforced by
@@ -250,13 +261,13 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
250
261
  // list gets no snapshot: recording here was that same refusal (item
251
262
  // 637 deleted the fallback and nothing reddened), and the next hit
252
263
  // walks, as it did.
253
- const savedDirPrefixes = wholeSubtreePrefixes(a.outputs)
264
+ const savedDirPrefixes = outputDirPrefixes(splitNegations(a.outputs).positive)
254
265
  if (savedDirPrefixes !== null && a.outputDirSnapshots !== undefined) {
255
266
  a.outputDirSnapshots.push({
256
267
  hash: a.hash,
257
268
  projectDir: node.projectDir,
258
269
  prefixes: savedDirPrefixes,
259
- holds: entryHolds(node),
270
+ holds: entryHolds(node, outputWalkTakes(node.projectDir, a.outputs, a.nestedProjectDirs)),
260
271
  })
261
272
  }
262
273
  }
@@ -380,3 +391,32 @@ function realpathOrNull(p: string): string | null {
380
391
  return null
381
392
  }
382
393
  }
394
+
395
+ /**
396
+ * The clause a "matched no files" warning gains when a glob holds an
397
+ * unescaped `[` and, read as a literal path, names something on disk:
398
+ * `app/[slug]/page.tsx` is a character class (caching.md) that matches
399
+ * no route, so the key stops following the file and an edit replays the
400
+ * old output. Only on the already-empty path; one stat per such glob, on
401
+ * its literal prefix up to the first segment holding another glob token.
402
+ */
403
+ export function bracketLiteralHint(
404
+ globs: readonly (readonly [base: string, glob: string])[],
405
+ ): string {
406
+ let hint = ''
407
+ for (const [base, glob] of globs) {
408
+ if (glob.startsWith('!') || !/(^|[^\\])\[/.test(glob)) continue
409
+ const segments: string[] = []
410
+ for (const seg of glob.split('/')) {
411
+ if (/(^|[^\\])[*?{}]/.test(seg)) break
412
+ segments.push(seg)
413
+ }
414
+ const prefix = segments.join('/')
415
+ if (!/(^|[^\\])\[/.test(prefix)) continue
416
+ const literal = prefix.replace(/\\(.)/g, '$1')
417
+ if (!existsSync(path.join(base, literal))) continue
418
+ const escaped = glob.replace(/(^|[^\\])(?=[[\]])/g, '$1\\')
419
+ hint += ` — [ starts a character class and ${literal} exists: write ${escaped} to name it`
420
+ }
421
+ return hint
422
+ }
@@ -84,12 +84,12 @@ export interface RunOptions {
84
84
  */
85
85
  cacheFlags?: CacheFlags
86
86
  /**
87
- * The caller asked for a remote axis by name (`--cache-policy=remote:rw`). With
88
- * no cache plugin supplying a remote layer the axes are inert, and a CI
89
- * job that believes it is filling a shared cache deserves one line
90
- * saying it is not.
87
+ * The setting that asked for a remote axis by name (`'--cache-policy'` for
88
+ * `--cache-policy=remote:rw`), named in the warning. With no cache plugin
89
+ * supplying a remote layer the axes are inert, and a CI job that believes
90
+ * it is filling a shared cache deserves one line saying it is not.
91
91
  */
92
- remoteRequested?: boolean
92
+ remoteRequested?: string
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
95
  /**
@@ -205,7 +205,9 @@ export interface RunOptions {
205
205
  * never-started ones `aborted`. The seam for an embedder that owns its
206
206
  * process's lifetime instead of `handleSignals` — the watch loop aborts
207
207
  * its in-flight cycle on SIGINT/SIGTERM this way, so a cancellation
208
- * never orphans a cycle's children.
208
+ * never orphans a cycle's children. A reason naming a signal
209
+ * (`'SIGINT'`, `'SIGTERM'`, `'SIGHUP'`) is a stop as vx's own handler
210
+ * makes one: the run records no history, only what finished.
209
211
  */
210
212
  signal?: AbortSignal
211
213
  /**