@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -3,18 +3,26 @@
3
3
  // (bwrap cannot bind a path that does not exist). Shared by the cached path
4
4
  // (through the executor) and the persistent path (spawned in execute-task).
5
5
 
6
- import { mkdir, readdir, realpath, stat } from 'node:fs/promises'
6
+ import { lstat, mkdir, readdir, realpath, rm, writeFile } from 'node:fs/promises'
7
7
  import { homedir } from 'node:os'
8
8
  import path from 'node:path'
9
9
  import type { ExecConfig } from '../config.js'
10
10
  import {
11
+ bindableWrites,
11
12
  initSandbox,
12
13
  probeSandbox,
14
+ punchWalls,
13
15
  resolveSandboxConfig,
16
+ type ResolvedSandboxConfig,
17
+ thrownReason,
18
+ toRealPath,
14
19
  type ExecuteRequest,
20
+ isMountableLiteral,
21
+ type SandboxViolation,
15
22
  } from '../exec/index.js'
16
23
  import type { TaskNode } from '../graph/index.js'
17
- import { staticPrefix, UserError } from '../util/index.js'
24
+ import { grantPrefix, UserError } from '../util/index.js'
25
+ import { WORKSPACE_FINGERPRINT_FILES } from '../workspace/index.js'
18
26
 
19
27
  /**
20
28
  * Arm the sandbox runtime for a run, lazily: only when at least one task
@@ -35,6 +43,46 @@ export interface SandboxArmer {
35
43
  readonly armed: boolean
36
44
  }
37
45
 
46
+ /**
47
+ * What SRT's run-wide `initialize()` is armed with, folded from every
48
+ * sandboxed task in the graph — or `null` when the run has none.
49
+ *
50
+ * Split out of `prepareSandbox` (pure motion) because it is the only
51
+ * part of the arming that can be READ without starting a sandbox: the
52
+ * call itself is observable solely through a live runtime, so the values
53
+ * it carries had no witness of any kind and three separate widenings of
54
+ * them survived a whole-suite mutation sweep (item 537).
55
+ */
56
+ export interface SandboxRunUnion {
57
+ /** Domains the run's filtering proxy will accept, in declaration order. */
58
+ domains: string[]
59
+ /** Whether SRT's all-or-nothing `socket(AF_UNIX)` filter is lifted for the run. */
60
+ unixSockets: boolean
61
+ /** Whether EVERY sandboxed task accepts the weaker nested profile. */
62
+ weakerNested: boolean
63
+ }
64
+
65
+ export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | null {
66
+ const sandboxed = [...nodes].filter((n) => n.config.exec?.sandbox !== undefined)
67
+ if (sandboxed.length === 0) return null
68
+ const weakerNested = sandboxed.every((n) => n.config.exec?.sandbox?.weakerWhenNested === true)
69
+ const domains = new Set<string>()
70
+ let unixSockets = false
71
+ for (const n of sandboxed) {
72
+ const allow = n.config.exec?.sandbox?.allow
73
+ const net = allow?.network
74
+ // `network: true` is deliberately absent from the union: it SKIPS the
75
+ // proxy rather than going through it, so folding it in as `*` would
76
+ // widen the allowlist every OTHER task in the run is filtered against.
77
+ if (Array.isArray(net)) for (const d of net) domains.add(d)
78
+ const sockets = allow?.unixSockets
79
+ if (sockets === true || (Array.isArray(sockets) && sockets.length > 0)) unixSockets = true
80
+ const lb = allow?.localBinding
81
+ if (Array.isArray(lb) && lb.length > 0 && process.platform === 'linux') unixSockets = true
82
+ }
83
+ return { domains: [...domains], unixSockets, weakerNested }
84
+ }
85
+
38
86
  /**
39
87
  * Prepare the sandbox for a run WITHOUT starting it. Starting is
40
88
  * `arm()`, and it happens on the first task that actually executes inside
@@ -57,20 +105,9 @@ export interface SandboxArmer {
57
105
  * it for the run. macOS keeps per-task precision through vx's own rules.
58
106
  */
59
107
  export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
60
- const sandboxed = [...nodes].filter((n) => n.config.exec?.sandbox !== undefined)
61
- if (sandboxed.length === 0) return null
62
- const weakerNested = sandboxed.every((n) => n.config.exec?.sandbox?.weakerWhenNested === true)
63
- const domains = new Set<string>()
64
- let unixSockets = false
65
- for (const n of sandboxed) {
66
- const allow = n.config.exec?.sandbox?.allow
67
- const net = allow?.network
68
- if (Array.isArray(net)) for (const d of net) domains.add(d)
69
- const sockets = allow?.unixSockets
70
- if (sockets === true || (Array.isArray(sockets) && sockets.length > 0)) unixSockets = true
71
- const lb = allow?.localBinding
72
- if (Array.isArray(lb) && lb.length > 0 && process.platform === 'linux') unixSockets = true
73
- }
108
+ const union = sandboxRunUnion(nodes)
109
+ if (union === null) return null
110
+ const { domains, unixSockets, weakerNested } = union
74
111
  let pending: Promise<void> | undefined
75
112
  let armed = false
76
113
  return {
@@ -83,7 +120,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
83
120
  const avail = await probeSandbox({ weakerNested })
84
121
  if (!avail.available) throw new UserError(`sandbox not available: ${avail.reason}`)
85
122
  await initSandbox({
86
- allowedDomains: [...domains],
123
+ allowedDomains: domains,
87
124
  ...(unixSockets ? { allowAllUnixSockets: true } : {}),
88
125
  })
89
126
  } catch (err) {
@@ -91,9 +128,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
91
128
  // the dependency check does not cover) gets the same one-line
92
129
  // verdict as a refused probe, not an internal error with a stack.
93
130
  if (err instanceof UserError) throw err
94
- throw new UserError(
95
- `sandbox not available: ${err instanceof Error ? err.message : String(err)}`,
96
- )
131
+ throw new UserError(`sandbox not available: ${thrownReason(err)}`)
97
132
  }
98
133
  armed = true
99
134
  })()
@@ -106,25 +141,31 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
106
141
  * The sandbox half of an `ExecuteRequest` for one task, shared by the
107
142
  * cached path (through the executor) and the persistent path (spawned here).
108
143
  *
109
- * The sandbox derives NOTHING from `cache` (owner, 2026-09-05). Those are
110
- * two different questions: `cache.inputs` says what INVALIDATES the task,
111
- * `sandbox.allow` says what it may TOUCH. Deriving one from the other
144
+ * The user's grants derive NOTHING from `cache` (owner, 2026-09-05). Those
145
+ * are two different questions: `cache.inputs` says what INVALIDATES the
146
+ * task, `sandbox.allow` says what it may TOUCH. Deriving one from the other
112
147
  * coupled them in both directions — a declaration added for caching
113
148
  * silently widened the sandbox, and a path the task needed had to be
114
149
  * laundered through the cache key to get it. A sandboxed task declares
115
150
  * its own reads and writes.
116
151
  *
117
- * `node_modules` is the one grant core still makes, and it is not
118
- * cache-derived: it is where the task's own PATH gets its `.bin` entries,
119
- * and denying it made the sandbox unusable for anything that imports a
120
- * dependency (`bun build --compile` died with only `error: An unknown
121
- * error occurred (Unexpected)`; owner call 2026-09-04).
152
+ * `node_modules` is the one grant core still makes: it is where the task's
153
+ * own PATH gets its `.bin` entries, and denying it made the sandbox
154
+ * unusable for anything that imports a dependency (`bun build --compile`
155
+ * died with only `error: An unknown error occurred (Unexpected)`; owner
156
+ * call 2026-09-04). Declaring `cache` may NARROW that grant, never widen
157
+ * anyone's (2026-09-24): `keyed` is the set of project directories the
158
+ * task's key answers for (keyed-projects.ts), and `undefined` for a task
159
+ * with no `cache`, which has no key to be stale.
122
160
  */
123
161
  export async function sandboxRequestFor(
124
162
  node: TaskNode,
125
163
  sandbox: NonNullable<ExecConfig['sandbox']>,
126
164
  workspaceRoot: string,
127
- ): Promise<NonNullable<ExecuteRequest['sandbox']>> {
165
+ keyed: ReadonlySet<string> | undefined,
166
+ /** The projects nested in this one (their dirs): a wall its grants do not reach. */
167
+ nested: readonly string[] = [],
168
+ ): Promise<SandboxRequest> {
128
169
  const depDirs = [
129
170
  path.join(node.projectDir, 'node_modules'),
130
171
  path.join(workspaceRoot, 'node_modules'),
@@ -133,18 +174,26 @@ export async function sandboxRequestFor(
133
174
  // sibling project, so granting `node_modules` grants a link whose
134
175
  // target is outside it. That target is a dependency, not a reach-out:
135
176
  // no project config should have to name a sibling to import what its
136
- // own `package.json` depends on (owner, 2026-09-05).
137
- depDirs.push(...(await linkedDeps(depDirs)))
177
+ // own `package.json` depends on (owner, 2026-09-05) — when the key
178
+ // moves with it.
179
+ const links = await linkedDeps(depDirs, node.projectDir, workspaceRoot, keyed)
180
+ depDirs.push(...links.granted)
138
181
  // bwrap cannot --bind a path that does not exist: the bind silently
139
182
  // becomes a no-op and writes to it appear to succeed but never land.
140
- // Pre-create what the task said it will write.
141
- await prepareOutputsForBind(node.projectDir, sandbox.allow?.write ?? [])
142
- return {
183
+ // Pre-create what the task said it will write — after the grants are
184
+ // resolved, which refuses one that leaves the project through a link
185
+ // before anything is created along it (item 1003).
186
+ const config = wallOff(resolveSandboxConfig(sandbox, node.projectDir), workspaceRoot, [
187
+ ...nested,
188
+ path.join(workspaceRoot, '.git'),
189
+ path.join(workspaceRoot, '.vx'),
190
+ ])
191
+ const placeholders = await prepareOutputsForBind(node.projectDir, sandbox.allow?.write ?? [])
192
+ const request: NonNullable<ExecuteRequest['sandbox']> = {
143
193
  // Only what the task declared, plus node_modules. Write paths are
144
194
  // readable too: a task that writes `dist/x` expects to read it back
145
195
  // (`tsc --incremental` re-reads .tsbuildinfo).
146
196
  baseAllowRead: depDirs,
147
- baseAllowWrite: [],
148
197
  // Enforcement anchors at the WORKSPACE ROOT: a task may not leave
149
198
  // its project, so every sibling and every root file is denied.
150
199
  // Reporting is a different question — see `reportWithin` below.
@@ -157,46 +206,171 @@ export async function sandboxRequestFor(
157
206
  // project's own files — that is the one that breaks the cache key,
158
207
  // because the key folds this project's inputs.
159
208
  reportWithin: node.projectDir,
160
- config: resolveSandboxConfig(sandbox, node.projectDir),
209
+ // …and of a dependency withheld above: the task reached for it
210
+ // through its own `node_modules`, and that read is the stale hit the
211
+ // withholding exists to stop, not the wall.
212
+ reportLinked: links.withheld.map((w) => w.dir),
213
+ config,
161
214
  }
215
+ return { sandbox: request, placeholders, withheld: links.withheld }
162
216
  }
163
217
 
164
218
  /**
165
- * Where the workspace links in `dirs` actually point.
219
+ * The sandbox half of the request, plus the empty files vx created so a
220
+ * literal write grant had something to bind (`placeholders`). They are
221
+ * vx's, not the task's, until the task writes them: `sweepPlaceholders`
222
+ * takes back the ones it never touched. `withheld` names the linked
223
+ * dependencies a cached task was denied, for the hint a denial under one
224
+ * earns (`withheldLinkLine`).
225
+ */
226
+ export interface SandboxRequest {
227
+ sandbox: NonNullable<ExecuteRequest['sandbox']>
228
+ placeholders: Placeholder[]
229
+ withheld: WithheldLink[]
230
+ }
231
+
232
+ /** An empty file vx created for a bind, and its mtime at creation. */
233
+ export interface Placeholder {
234
+ path: string
235
+ mtimeMs: number
236
+ }
237
+
238
+ /** A workspace link whose target the task's key does not answer for. */
239
+ export interface WithheldLink {
240
+ /** The canonical target, inside the workspace root. */
241
+ dir: string
242
+ /** The name it is installed under (`@x/ui`). */
243
+ name: string
244
+ /** The target and the link, workspace-relative POSIX, for the hint. */
245
+ target: string
246
+ link: string
247
+ }
248
+
249
+ /**
250
+ * Where the workspace links in `dirs` actually point, split into what the
251
+ * task is granted and what it is not.
166
252
  *
167
253
  * One level deep, plus one level inside a `@scope/` directory — the shape
168
254
  * a package manager writes. Anything already inside a granted directory
169
255
  * is dropped; what is left is a sibling project's real path.
256
+ *
257
+ * A link to the task's OWN project, or to a directory holding it, is
258
+ * dropped too: npm and Yarn classic link every workspace package at the
259
+ * root, the task's own included, and granting that target handed the
260
+ * project back whole whatever its `allow.read` said — an undeclared read
261
+ * of its own file ran unreported, and an edit to it was a stale hit.
262
+ *
263
+ * With `keyed` given (a cached task), a target inside the workspace root
264
+ * is granted only when it IS a keyed project's directory; every other one
265
+ * is withheld, since an edit there would not move the key. A target
266
+ * outside the root lies outside the deny anchor, so granting it is a no-op
267
+ * either way. Every side of every comparison is canonical: a target always
268
+ * is, and a directory under a root reached through a link (macOS's `/var`)
269
+ * is not.
170
270
  */
171
- async function linkedDeps(dirs: readonly string[]): Promise<string[]> {
172
- const out = new Set<string>()
173
- const scan = async (dir: string, depth: number): Promise<void> => {
271
+ async function linkedDeps(
272
+ dirs: readonly string[],
273
+ projectDir: string,
274
+ workspaceRoot: string,
275
+ keyed: ReadonlySet<string> | undefined,
276
+ ): Promise<{ granted: string[]; withheld: WithheldLink[] }> {
277
+ const [self, root] = await Promise.all([realpath(projectDir), realpath(workspaceRoot)])
278
+ const scan = async (dir: string, scope: string): Promise<Array<[string, string, string]>> => {
279
+ const found: Array<[target: string, link: string, name: string]> = []
174
280
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
175
281
  for (const e of entries) {
176
282
  const full = path.join(dir, e.name)
177
283
  if (e.isSymbolicLink()) {
178
284
  const target = await realpath(full).catch(() => undefined)
179
- if (
180
- target !== undefined &&
181
- !dirs.some((d) => target === d || target.startsWith(d + path.sep))
182
- ) {
183
- out.add(target)
285
+ if (target !== undefined && !dirs.some((d) => within(target, d)) && !within(self, target)) {
286
+ found.push([target, full, scope + e.name])
184
287
  }
185
- } else if (depth === 0 && e.isDirectory() && e.name.startsWith('@')) {
186
- await scan(full, 1)
288
+ } else if (scope === '' && e.isDirectory() && e.name.startsWith('@')) {
289
+ found.push(...(await scan(full, `${e.name}/`)))
187
290
  }
188
291
  }
292
+ return found
293
+ }
294
+ // Merged in `dirs` order, so the link a hint names is the project's own
295
+ // when both have one.
296
+ const targets = new Map<string, { link: string; name: string }>()
297
+ for (const found of await Promise.all(dirs.map((d) => scan(d, '')))) {
298
+ for (const [target, link, name] of found) {
299
+ if (!targets.has(target)) targets.set(target, { link, name })
300
+ }
301
+ }
302
+ if (keyed === undefined) return { granted: [...targets.keys()], withheld: [] }
303
+ const keyedDirs = new Set(await Promise.all([...keyed].map((d) => realpath(d))))
304
+ const granted: string[] = []
305
+ const withheld: WithheldLink[] = []
306
+ for (const [target, { link, name }] of targets) {
307
+ if (!within(target, root) || keyedDirs.has(target)) granted.push(target)
308
+ else
309
+ withheld.push({
310
+ dir: target,
311
+ name,
312
+ target: posixRel(root, target),
313
+ link: posixRel(workspaceRoot, link),
314
+ })
189
315
  }
190
- await Promise.all(dirs.map((d) => scan(d, 0)))
191
- return [...out]
316
+ return { granted, withheld }
192
317
  }
193
318
 
194
319
  /**
195
- * Ensure each declared output path exists on the host as either an
196
- * empty file (for literal output specs) or a directory (for globbed
197
- * specs) so bwrap's --bind can find a real fs entry to mount. Without
198
- * this, writes inside the sandbox to a non-existent allowWrite path
199
- * silently disappear (bwrap creates a tmpfs that evaporates on exit).
320
+ * The line a task denied a withheld dependency gets beside the denial:
321
+ * what it reached, through which link, and the two ways to make the key
322
+ * answer for it. Added only when a violation already sits under the
323
+ * target, so it never reddens a pass.
324
+ */
325
+ export function withheldLinkLine(taskId: string, w: WithheldLink): string {
326
+ return (
327
+ `vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key folds no task of ` +
328
+ `${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
329
+ `reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
330
+ `\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
331
+ `\`cache.inputs.workspaceFiles\`).`
332
+ )
333
+ }
334
+
335
+ /** The withheld dependencies a reported denial lies under: each earns its hint. */
336
+ export function reachedWithheld(
337
+ withheld: readonly WithheldLink[],
338
+ violations: readonly SandboxViolation[],
339
+ ): WithheldLink[] {
340
+ return withheld.filter((w) =>
341
+ violations.some((v) => v.path !== undefined && within(v.path, w.dir)),
342
+ )
343
+ }
344
+
345
+ /** `p` relative to `from`, with `/` separators. */
346
+ function posixRel(from: string, p: string): string {
347
+ return path.relative(from, p).split(path.sep).join('/')
348
+ }
349
+
350
+ /**
351
+ * `p` is `dir` or below it — by path, so a sibling sharing `dir`'s name
352
+ * prefix is not. `/` already ends in the separator, and a link to it
353
+ * holds every project.
354
+ */
355
+ function within(p: string, dir: string): boolean {
356
+ return p === dir || p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
357
+ }
358
+
359
+ /**
360
+ * Ensure each declared write path exists on the host as either an empty
361
+ * file (a literal path) or a directory (a glob's static prefix, or a
362
+ * literal ending in `/`) so bwrap's --bind can find a real fs entry to
363
+ * mount. Without this, writes inside the sandbox to a non-existent
364
+ * allowWrite path silently disappear (bwrap creates a tmpfs that
365
+ * evaporates on exit).
366
+ *
367
+ * A literal that names nothing yet is a FILE — `dist/vx` for `bun build
368
+ * --outfile dist/vx` — and the task that meant a directory (`write:
369
+ * ['dist']`, then `mkdir -p dist`) met "File exists" from its own tool,
370
+ * with the empty file left behind for every later run to meet again
371
+ * (its `dist/**` clean matches nothing under a file; 2026-09-16). So a
372
+ * directory is spelled `dist/`, and the files created here are returned
373
+ * so the caller can take back the ones the task never wrote.
200
374
  *
201
375
  * `cleanOutputs` ran just before this in the cache-enabled path, so
202
376
  * we know any stale content was wiped; what's left is to materialize
@@ -205,9 +379,10 @@ async function linkedDeps(dirs: readonly string[]): Promise<string[]> {
205
379
  async function prepareOutputsForBind(
206
380
  projectDir: string,
207
381
  outputs: readonly string[],
208
- ): Promise<void> {
382
+ ): Promise<Placeholder[]> {
383
+ const placeholders: Placeholder[] = []
209
384
  for (const g of outputs) {
210
- const hasWildcard = /[*?[\]]/.test(g)
385
+ const hasWildcard = !isMountableLiteral(g)
211
386
  // A grant OUTSIDE the project is the user's own path — never joined
212
387
  // onto the project dir, which would create a literal `~` there. Its
213
388
  // directory is still created when the grant is a glob, because then
@@ -218,26 +393,177 @@ async function prepareOutputsForBind(
218
393
  // reported a network error for an unwritable cache; 2026-09-05).
219
394
  if (path.isAbsolute(g) || g.startsWith('~')) {
220
395
  if (!hasWildcard) continue
221
- const abs = expandHome(staticPrefix(g))
396
+ const abs = expandHome(grantPrefix(g))
222
397
  await mkdir(abs, { recursive: true }).catch(() => undefined)
223
398
  continue
224
399
  }
225
- if (hasWildcard) {
226
- const abs = path.join(projectDir, staticPrefix(g))
400
+ if (hasWildcard || g.endsWith('/')) {
401
+ const abs = path.join(projectDir, hasWildcard ? grantPrefix(g) : g)
227
402
  await mkdir(abs, { recursive: true })
228
403
  } else {
229
404
  const abs = path.join(projectDir, g)
230
405
  // Whatever is already there is what the task meant — a grant on the
231
406
  // project dir itself is a directory, and touching it as a file is
232
- // an EISDIR, not a missing bind.
233
- if (await stat(abs).catch(() => undefined)) continue
407
+ // an EISDIR, not a missing bind. `lstat`, and an exclusive create: a
408
+ // dangling link at the grant made vx, unsandboxed, create the empty
409
+ // file at the link's target in another project (item 1003).
410
+ if (await lstat(abs).catch(() => undefined)) continue
234
411
  await mkdir(path.dirname(abs), { recursive: true })
235
- await Bun.write(abs, '')
412
+ await writeFile(abs, '', { flag: 'wx' })
413
+ placeholders.push({ path: abs, mtimeMs: (await lstat(abs)).mtimeMs })
236
414
  }
237
415
  }
416
+ return placeholders
417
+ }
418
+
419
+ /**
420
+ * The walls a project's grants stop at: its nested projects, the
421
+ * repository and vx's directory (item 1010). A read grant around one is
422
+ * punched (`punchWalls`); a write grant whose bind would hold one — a file
423
+ * grant binds its directory on Linux, so `write: ['out.txt']` in a root
424
+ * project bound the whole workspace writable, `.git` included — is
425
+ * refused, as there is no writable bind that leaves a wall out.
426
+ */
427
+ function wallOff(
428
+ config: ResolvedSandboxConfig,
429
+ workspaceRoot: string,
430
+ raw: readonly string[],
431
+ ): ResolvedSandboxConfig {
432
+ const walls = raw.map(toRealPath)
433
+ const home = toRealPath(workspaceRoot)
434
+ for (const bind of bindableWrites(config.allowWrite)) {
435
+ // A bind outside the workspace is the user's own path (`/tmp/x`,
436
+ // `~/.cache/y`), spelled there on purpose; only one inside is judged.
437
+ if (bind !== home && !bind.startsWith(home + path.sep)) continue
438
+ const wall = walls.find((w) => w === bind || w.startsWith(bind + path.sep))
439
+ if (wall === undefined) continue
440
+ throw new UserError(
441
+ `exec.sandbox.allow.write: the grant binding ${bind} would make ${wall} writable — another ` +
442
+ `project's directory, the repository or vx's own. A file grant binds its directory; ` +
443
+ `grant a directory of the task's own instead, such as "dist/".`,
444
+ )
445
+ }
446
+ return { ...config, allowRead: config.allowRead.flatMap((r) => punchWalls(r, walls)) }
447
+ }
448
+
449
+ /**
450
+ * Remove the placeholder files the task never wrote — still empty, mtime
451
+ * untouched — and return their paths. What the task wrote is its output
452
+ * and stays; what it did not is vx's own litter, and litter under a
453
+ * declared output would be archived as the task's (an empty `dist/vx`
454
+ * saved as a build) or, as a file where the task wanted a directory,
455
+ * would fail every later run the same way.
456
+ */
457
+ export async function sweepPlaceholders(placeholders: readonly Placeholder[]): Promise<string[]> {
458
+ const untouched: string[] = []
459
+ for (const p of placeholders) {
460
+ // `lstat`: a link the task put in the placeholder's place is its
461
+ // output, never vx's litter to remove (item 1003).
462
+ const st = await lstat(p.path).catch(() => undefined)
463
+ if (st === undefined || !st.isFile() || st.size !== 0 || st.mtimeMs !== p.mtimeMs) continue
464
+ await rm(p.path, { force: true })
465
+ untouched.push(p.path)
466
+ }
467
+ return untouched
468
+ }
469
+
470
+ /**
471
+ * One sweep, however many askers, each getting the SAME list.
472
+ *
473
+ * A failed persistent task has two askers — the child's exit handler and
474
+ * the readiness failure that reports the hint — and a second
475
+ * `sweepPlaceholders` cannot serve the later one: the first one's `rm`
476
+ * already happened, so it finds nothing and names nothing. Collecting
477
+ * both lists and unioning them does not close it either, because a sweep
478
+ * still between its `rm` and its return has published nothing yet, and
479
+ * the union is then empty on both sides. Sharing the one promise is what
480
+ * makes the answer independent of who asked first.
481
+ */
482
+ export function placeholderSweeper(placeholders: readonly Placeholder[]): () => Promise<string[]> {
483
+ let once: Promise<string[]> | undefined
484
+ return () => (once ??= sweepPlaceholders(placeholders))
485
+ }
486
+
487
+ /**
488
+ * The line a failed task gets for a placeholder it never wrote. Not a
489
+ * diagnosis — the task may have died before its first write — but the
490
+ * one clue to the trap: a grant that meant a directory is bound as a
491
+ * file, and the task's own `mkdir` says only "File exists". Added when
492
+ * the task already failed and the sandbox reported nothing else, so it
493
+ * never reddens a pass and never buries a real denial.
494
+ */
495
+ export function untouchedPlaceholderLine(projectDir: string, placeholder: string): string {
496
+ const rel = path.relative(projectDir, placeholder).split(path.sep).join('/')
497
+ return (
498
+ `vx: the sandbox write grant \`${rel}\` named nothing on disk, so vx bound it as an empty ` +
499
+ `file, which the task never wrote (removed again). If the task creates a directory there ` +
500
+ `("File exists" from its own mkdir), spell the grant \`${rel}/\` — a literal without the ` +
501
+ `slash is a file.`
502
+ )
238
503
  }
239
504
 
240
505
  /** `~/x` against the user's home; anything else unchanged. */
241
506
  function expandHome(p: string): string {
242
507
  return p.startsWith('~') ? path.join(homedir(), p.slice(1)) : p
243
508
  }
509
+
510
+ /** Where a command may have written: nothing a key reads, its own project, or any project. */
511
+ export type WriteReach = 'none' | 'project' | 'workspace'
512
+
513
+ /**
514
+ * Where a task that ran a command may have written files no declaration
515
+ * names — the files whose run-start facts (the git snapshot, its index
516
+ * OIDs, the `package.json` digest) a later key must not reuse (item 743).
517
+ *
518
+ * A cached task declares its outputs and is held to them. A task with no
519
+ * `cache` block can declare none, so it may have written anywhere
520
+ * `commandWriteReach` says. An unsandboxed write into another project is
521
+ * out of contract, as it is for a cached task.
522
+ */
523
+ export function undeclaredWriteReach(node: TaskNode, workspaceRoot: string): WriteReach {
524
+ if (node.config.cache !== undefined) return 'none'
525
+ return commandWriteReach(node, workspaceRoot)
526
+ }
527
+
528
+ /**
529
+ * Where a task's command may write at all: anywhere in its own project
530
+ * (`'project'`), unless a sandbox bounds it — no write grant writes nothing
531
+ * a key reads (`'none'`), and a grant that leaves the project for elsewhere
532
+ * in the workspace reaches every project (`'workspace'`). A grant outside
533
+ * the workspace reaches no input. A cached task's reach is where it may
534
+ * rewrite its own inputs in place (a formatter), which the stability gate
535
+ * reads (stable-keys.ts).
536
+ */
537
+ export function commandWriteReach(node: TaskNode, workspaceRoot: string): WriteReach {
538
+ const exec = node.config.exec
539
+ if (exec === undefined) return 'none'
540
+ if (exec.sandbox === undefined) return 'project'
541
+ let reach: 'none' | 'project' = 'none'
542
+ for (const grant of exec.sandbox.allow?.write ?? []) {
543
+ const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
544
+ if (within(abs, node.projectDir)) reach = 'project'
545
+ else if (within(abs, workspaceRoot) || within(workspaceRoot, abs)) return 'workspace'
546
+ }
547
+ return reach
548
+ }
549
+
550
+ /**
551
+ * May this task's command rewrite a file the workspace fingerprint folds
552
+ * (a lockfile, `pnpm-workspace.yaml`)? Those sit at the workspace root, so
553
+ * an unsandboxed task may only when the root is its own project (`pnpm
554
+ * install` without `--frozen-lockfile` in a root task); a sandboxed one
555
+ * when a write grant covers one. Anyone else writing there crosses a
556
+ * project boundary, out of contract.
557
+ */
558
+ export function mayWriteFingerprint(node: TaskNode, workspaceRoot: string): boolean {
559
+ const exec = node.config.exec
560
+ if (exec === undefined) return false
561
+ if (exec.sandbox === undefined) return node.projectDir === workspaceRoot
562
+ for (const grant of exec.sandbox.allow?.write ?? []) {
563
+ const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
564
+ for (const f of WORKSPACE_FINGERPRINT_FILES) {
565
+ if (within(path.join(workspaceRoot, f), abs)) return true
566
+ }
567
+ }
568
+ return false
569
+ }
@@ -4,8 +4,10 @@
4
4
  // artifact against ~5 ms of execution on the 1,000-project bench
5
5
  // (2026-09-10) — so holding the slot for it serialized I/O behind CPU.
6
6
  // A bounded number run at once (memory: each pack holds an artifact's
7
- // bytes), the run drains them before the upload drain, and a save that
8
- // fails degrades to a miss next time, said once — the task's work ran.
7
+ // bytes); the scheduler finishes a task only once its save has settled
8
+ // (`settledOf`), so a run ends with every save in the cache; and a save
9
+ // that fails degrades to a miss next time, said once — the task's work
10
+ // ran.
9
11
 
10
12
  export interface SaveLane {
11
13
  /**
@@ -15,8 +17,6 @@ export interface SaveLane {
15
17
  * same task in another run (admission's in-flight join).
16
18
  */
17
19
  defer(save: () => Promise<void>): Promise<void>
18
- /** Settle every deferred save, started or queued. */
19
- drain(): Promise<void>
20
20
  }
21
21
 
22
22
  export function createSaveLane(cap: number, onError: (err: unknown) => void): SaveLane {
@@ -40,8 +40,5 @@ export function createSaveLane(cap: number, onError: (err: unknown) => void): Sa
40
40
  else queue.push({ save, settle })
41
41
  })
42
42
  },
43
- async drain() {
44
- while (running.size > 0) await Promise.all([...running])
45
- },
46
43
  }
47
44
  }