@vzn/vx 0.0.50 → 0.0.51

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 (54) hide show
  1. package/README.md +38 -31
  2. package/package.json +5 -5
  3. package/src/bin.ts +1 -1
  4. package/src/cache/archive.ts +18 -6
  5. package/src/cache/cache.ts +150 -67
  6. package/src/cache/chained-cache.ts +9 -2
  7. package/src/cache/file-hashes.ts +32 -3
  8. package/src/cache/git-inputs.ts +96 -11
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/layer.ts +28 -3
  11. package/src/cache/layered-cache.ts +7 -2
  12. package/src/cache/output-index.ts +16 -7
  13. package/src/cache/run-history.ts +5 -1
  14. package/src/cache/zstd.ts +40 -8
  15. package/src/cli/cache.ts +35 -9
  16. package/src/cli/completions.ts +10 -7
  17. package/src/cli/help.ts +48 -4
  18. package/src/cli/index.ts +5 -3
  19. package/src/cli/info.ts +1 -1
  20. package/src/cli/plugin-commands.ts +10 -2
  21. package/src/cli/run.ts +1 -0
  22. package/src/cli/select.ts +29 -2
  23. package/src/cli/upgrade.ts +110 -22
  24. package/src/cli/watch.ts +35 -18
  25. package/src/config.ts +18 -5
  26. package/src/exec/executor.ts +3 -2
  27. package/src/exec/index.ts +1 -1
  28. package/src/exec/runner.ts +102 -24
  29. package/src/exec/sandbox-binds.ts +1 -1
  30. package/src/exec/sandbox-runtime.ts +149 -40
  31. package/src/exec/sandbox-violations.ts +42 -10
  32. package/src/graph/scheduler.ts +11 -5
  33. package/src/graph/task-graph.ts +96 -12
  34. package/src/orchestrator/admission.ts +72 -17
  35. package/src/orchestrator/execute-task.ts +39 -25
  36. package/src/orchestrator/hit-restore.ts +7 -1
  37. package/src/orchestrator/miss-save.ts +29 -1
  38. package/src/orchestrator/placement.ts +23 -3
  39. package/src/orchestrator/plugin-host.ts +1 -1
  40. package/src/orchestrator/plugin.ts +9 -1
  41. package/src/orchestrator/prepare.ts +1 -0
  42. package/src/orchestrator/run.ts +75 -12
  43. package/src/orchestrator/sandbox-request.ts +19 -10
  44. package/src/orchestrator/summary.ts +26 -15
  45. package/src/orchestrator/task-hash.ts +7 -2
  46. package/src/orchestrator/telemetry.ts +14 -4
  47. package/src/util/paths.ts +16 -9
  48. package/src/workspace/affected.ts +201 -38
  49. package/src/workspace/config-eval.ts +4 -1
  50. package/src/workspace/config-schema.ts +70 -10
  51. package/src/workspace/fingerprint.ts +6 -0
  52. package/src/workspace/migration.ts +1 -1
  53. package/src/workspace/project-loader.ts +53 -1
  54. package/src/workspace/workspace.ts +54 -14
@@ -33,6 +33,7 @@ import {
33
33
  drainOrAbort,
34
34
  POST_EXIT_CUT_LINE,
35
35
  shellQuote,
36
+ withForwardArgs,
36
37
  signalExitCode,
37
38
  spawnFailureText,
38
39
  streamToString,
@@ -41,6 +42,7 @@ import {
41
42
  type RunResult,
42
43
  } from './runner.js'
43
44
  import {
45
+ BUN_GLOB_WILDCARDS,
44
46
  executablePath,
45
47
  grantPrefix,
46
48
  isTmpdirRefusal,
@@ -48,7 +50,7 @@ import {
48
50
  UserError,
49
51
  xxh3hex,
50
52
  } from '../util/index.js'
51
- import { buildCustomConfig } from './sandbox-binds.js'
53
+ import { bindableWrites, buildCustomConfig } from './sandbox-binds.js'
52
54
  import {
53
55
  isMountableLiteral,
54
56
  localBindingOn,
@@ -56,7 +58,7 @@ import {
56
58
  toRealPath,
57
59
  unique,
58
60
  } from './sandbox-paths.js'
59
- import { parseStraceViolations, reportableViolations } from './sandbox-violations.js'
61
+ import { parseStraceViolations, refusedWrites, reportableViolations } from './sandbox-violations.js'
60
62
  import {
61
63
  closeSignalChannel,
62
64
  killTree,
@@ -613,6 +615,8 @@ function throughLinks(p: string, hops = 0): string {
613
615
  export function resolveSandboxConfig(
614
616
  cfg: SandboxConfig,
615
617
  projectDir: string,
618
+ /** Canonical directories a glob's hits stop at (sandbox-request.ts `wallOff`). */
619
+ walls: readonly string[] = [],
616
620
  ): ResolvedSandboxConfig {
617
621
  const resolve = (p: string): string => {
618
622
  if (p.startsWith('~')) return toRealPath(path.join(os.homedir(), p.slice(1)))
@@ -621,7 +625,7 @@ export function resolveSandboxConfig(
621
625
  }
622
626
  const a = cfg.allow ?? {}
623
627
  const r: ResolvedSandboxConfig = {
624
- allowRead: expandGrants((a.read ?? []).map(resolve), 'read'),
628
+ allowRead: expandGrants((a.read ?? []).map(resolve), 'read', walls),
625
629
  allowWrite: expandGrants(
626
630
  (a.write ?? []).map((p) => {
627
631
  const real = resolve(p)
@@ -629,6 +633,7 @@ export function resolveSandboxConfig(
629
633
  return real
630
634
  }),
631
635
  'write',
636
+ walls,
632
637
  ),
633
638
  }
634
639
  if (a.network !== undefined) r.network = a.network
@@ -644,10 +649,19 @@ export function resolveSandboxConfig(
644
649
  r.weakerNetworkIsolation = cfg.weakerNetworkIsolation
645
650
  }
646
651
  if (cfg.ignore !== undefined) {
647
- // Relative patterns anchor at the project dir; absolute and `~` ones
648
- // are taken as written. NOT realpath'd — a pattern is not a path.
649
- const anchor = (pat: string): string =>
650
- pat.startsWith('~') || path.isAbsolute(pat) ? pat : path.join(projectDir, pat)
652
+ // Relative patterns anchor at the project dir; `~` ones are taken as
653
+ // written. A pattern is not a path, but its literal head is: both
654
+ // producers record where a denial LANDS, so the head is canonicalized
655
+ // like every other side of the policy. Anchored at a project reached
656
+ // through a link (macOS's `/var`), no pattern matched and the denial it
657
+ // named failed the task (B-2).
658
+ const anchor = (pat: string): string => {
659
+ if (pat.startsWith('~')) return pat
660
+ const abs = path.isAbsolute(pat) ? pat : path.join(projectDir, pat)
661
+ const wild = abs.search(BUN_GLOB_WILDCARDS)
662
+ const head = wild === -1 ? abs : abs.slice(0, abs.lastIndexOf(path.sep, wild)) || path.sep
663
+ return toRealPath(head) + abs.slice(head.length)
664
+ }
651
665
  r.ignore = {
652
666
  ...(cfg.ignore.read ? { read: cfg.ignore.read.map(anchor) } : {}),
653
667
  ...(cfg.ignore.write ? { write: cfg.ignore.write.map(anchor) } : {}),
@@ -726,15 +740,14 @@ export async function wrapSandboxedCommand(
726
740
  wrapped: string
727
741
  tag: string
728
742
  taggedCommand: string
743
+ /** What SRT wrapped, and so what its store keys a record by (the group wrapper included on Linux). */
744
+ srtCommand: string
729
745
  baselines: CanonicalBaselines
730
746
  /** The command reads its polite signals off fd 3: spawn it with one and `signalThrough` it. */
731
747
  forwardsSignals: boolean
732
748
  }> {
733
749
  const { SandboxManager } = await loadSrt()
734
- const userCommand =
735
- args.forwardArgs && args.forwardArgs.length > 0
736
- ? args.command + ' ' + args.forwardArgs.map(shellQuote).join(' ')
737
- : args.command
750
+ const userCommand = withForwardArgs(args.command, args.forwardArgs)
738
751
 
739
752
  const tag = xxh3hex(`${args.cwd}|${userCommand}|${process.hrtime.bigint()}`).slice(0, 16)
740
753
  const tmp = taskTmpdir(tag)
@@ -775,7 +788,14 @@ export async function wrapSandboxedCommand(
775
788
  if (process.platform === 'linux' && wrapped.startsWith('bwrap ')) wrapped = `exec ${wrapped}`
776
789
  if (args.server === true) liveServers.add(tag)
777
790
  if (ports.length > 0) spawnHostBridges(ports, tag)
778
- return { wrapped, tag, taggedCommand, baselines, forwardsSignals: grouped.forwards }
791
+ return {
792
+ wrapped,
793
+ tag,
794
+ taggedCommand,
795
+ srtCommand: inner,
796
+ baselines,
797
+ forwardsSignals: grouped.forwards,
798
+ }
779
799
  }
780
800
 
781
801
  /**
@@ -962,6 +982,45 @@ export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRun
962
982
  }
963
983
  }
964
984
 
985
+ /** The records collected for the commands running, by SRT's key for each. */
986
+ const collecting = new Map<string, SandboxViolation[]>()
987
+ let collectorOn = false
988
+
989
+ /**
990
+ * Start collecting SRT's store records for `command`; the returned function
991
+ * stops and hands them over. The store is a 100-record ring the whole run
992
+ * shares, and on Linux its write observer reports every write any task
993
+ * makes, so a refused write followed by 150 declared ones was gone before
994
+ * the task's exit read it (B-7). One subscription takes each record as it
995
+ * arrives and keeps it only for a command still running. SRT keys a record
996
+ * by the base64 of the command's first 100 characters
997
+ * (`encodeSandboxedCommand`, not exported).
998
+ */
999
+ function collectRecords(
1000
+ store: ReturnType<SrtModule['SandboxManager']['getSandboxViolationStore']>,
1001
+ command: string,
1002
+ ): () => SandboxViolation[] {
1003
+ if (!collectorOn) {
1004
+ collectorOn = true
1005
+ let seen = store.getTotalCount()
1006
+ store.subscribe((all) => {
1007
+ const total = store.getTotalCount()
1008
+ const fresh = Math.min(total - seen, all.length)
1009
+ seen = total
1010
+ for (const v of fresh > 0 ? all.slice(-fresh) : []) {
1011
+ collecting.get(v.encodedCommand ?? '')?.push({ line: v.line, timestamp: v.timestamp })
1012
+ }
1013
+ })
1014
+ }
1015
+ const key = Buffer.from(command.slice(0, 100)).toString('base64')
1016
+ const list: SandboxViolation[] = []
1017
+ collecting.set(key, list)
1018
+ return () => {
1019
+ collecting.delete(key)
1020
+ return list
1021
+ }
1022
+ }
1023
+
965
1024
  /** strace's own message, as the last line a failed traced task printed. */
966
1025
  const STRACE_OWN_ERROR = /^strace: /
967
1026
 
@@ -979,12 +1038,11 @@ async function runSandboxedOnce(
979
1038
  ): Promise<SandboxedRunResult & { tracerFailed: boolean }> {
980
1039
  const start = Date.now()
981
1040
  const { SandboxManager } = await loadSrt()
982
- const { wrapped, tag, taggedCommand, baselines, forwardsSignals } =
983
- await wrapSandboxedCommand(args)
1041
+ const { wrapped, tag, srtCommand, baselines, forwardsSignals } = await wrapSandboxedCommand(args)
1042
+ const takeRecords = collectRecords(SandboxManager.getSandboxViolationStore(), srtCommand)
984
1043
 
985
- // Linux: SRT's SandboxViolationStore is macOS-only, so structured
986
- // detection on Linux requires us to wrap the spawn with strace and
987
- // parse the trace for denied syscalls. The trace is per-task (unique
1044
+ // Linux: SRT's store sees only writes (below), so read denials need
1045
+ // the spawn wrapped with strace and the trace parsed for denied syscalls. The trace is per-task (unique
988
1046
  // log path keyed by the command tag) so parallel tasks don't share
989
1047
  // a stream. Skipped when strace isn't on PATH — bwrap still enforces
990
1048
  // structurally; we just lose the structured violation list.
@@ -1044,6 +1102,7 @@ async function runSandboxedOnce(
1044
1102
  const stderr = spawnFailureText(err, args.cwd, 'sandboxed task')
1045
1103
  args.onStderr?.(stderr)
1046
1104
  releaseBridges(tag)
1105
+ takeRecords()
1047
1106
  return {
1048
1107
  exitCode: 127,
1049
1108
  durationMs: Date.now() - start,
@@ -1106,25 +1165,21 @@ async function runSandboxedOnce(
1106
1165
  // DROPPED by the unified log under pressure, not delayed. Reporting on
1107
1166
  // macOS is therefore lossy-by-OS under load; ENFORCEMENT is unaffected
1108
1167
  // (every observed loss still denied the read and failed the child).
1109
- const store = SandboxManager.getSandboxViolationStore()
1110
- // EVERY record the store holds for this command, unfiltered (owner,
1168
+ // EVERY record SRT's store saw for this command, unfiltered (owner,
1111
1169
  // 2026-09-05). A sandboxed task that fails must say what it was denied;
1112
1170
  // deciding on the user's behalf that a record was "just traversal" is how
1113
1171
  // a failure ends up with an empty violations section and no explanation.
1114
- const readMacViolations = (): SandboxViolation[] =>
1115
- store.getViolationsForCommand(taggedCommand).map((v) => ({
1116
- line: v.line,
1117
- timestamp: v.timestamp,
1118
- }))
1119
- // Darwin only. Since SRT 0.0.75 the store is fed on Linux too, by the
1120
- // seccomp helper's write observer — but SRT judges those reports against
1121
- // the GLOBAL `filesystem.allowWrite` from `initialize` (empty here; the
1122
- // per-task list travels in `customConfig`, which the monitor never sees),
1123
- // so every write a task makes to its own declared output arrived as
1124
- // `deny openat <output>` and failed the task (reproduced 2026-09-04 in a
1125
- // Linux container: exit 1, empty stderr). Linux detection is the strace
1126
- // pass below, judged against the task's own baselines.
1127
- let macViolations = process.platform === 'darwin' ? readMacViolations() : []
1172
+ const records = takeRecords()
1173
+ // Read as is on darwin only. Since SRT 0.0.75 the store is fed on Linux
1174
+ // too, by the seccomp helper's write observer — but SRT judges those
1175
+ // reports against the GLOBAL `filesystem.allowWrite` from `initialize`
1176
+ // (empty here; the per-task list travels in `customConfig`, which the
1177
+ // monitor never sees), so every write a task makes to its own declared
1178
+ // output arrived as `deny openat <output>` and, read as is, failed the
1179
+ // task (reproduced 2026-09-04 in a Linux container: exit 1, empty
1180
+ // stderr). On Linux the records are judged against the task's own binds
1181
+ // below (`refusedWrites`).
1182
+ const macViolations = process.platform === 'darwin' ? records : []
1128
1183
  // No settle window: the store is read once, right after the child exits
1129
1184
  // (owner, 2026-09-05). It cost 300ms on EVERY clean sandboxed task — the
1130
1185
  // full budget, since a task with nothing to report can only prove that by
@@ -1139,9 +1194,28 @@ async function runSandboxedOnce(
1139
1194
  // syscall on a path inside denyRead that wasn't unconditionally
1140
1195
  // allowed. Best-effort — if parsing fails we surface no Linux
1141
1196
  // violations rather than fail the whole task.
1142
- const linuxViolations: SandboxViolation[] = straceLog
1143
- ? await parseStraceViolations(straceLog, args, baselines).catch(() => [])
1144
- : []
1197
+ //
1198
+ // strace never sees a write: SRT's seccomp step hands every write-intent
1199
+ // syscall to its observer (USER_NOTIF, which takes precedence over
1200
+ // strace's TRACE), and the observer feeds the store with every ATTEMPT,
1201
+ // judged against the run-wide config, which grants no write. Judged here
1202
+ // against this task's own binds, what is left is a write bwrap refused,
1203
+ // or one into the anchor's scratch that vanished with the sandbox: a
1204
+ // task that swallowed it exited 0 with nothing reported (B-5).
1205
+ const linuxViolations: SandboxViolation[] =
1206
+ process.platform === 'linux'
1207
+ ? [
1208
+ ...(straceLog
1209
+ ? await parseStraceViolations(straceLog, args, baselines).catch(() => [])
1210
+ : []),
1211
+ ...refusedWrites(
1212
+ // Keyed by what SRT wrapped, the in-sandbox group wrapper
1213
+ // included, not by the tagged command macOS is keyed by.
1214
+ records.map((v) => v.line),
1215
+ bindableWrites(args.config.allowWrite),
1216
+ ),
1217
+ ]
1218
+ : []
1145
1219
  if (straceLog) {
1146
1220
  // Listed until the unlink lands, as the run lock's taking (item 868).
1147
1221
  await unlink(straceLog).catch(() => undefined)
@@ -1197,7 +1271,15 @@ async function runSandboxedOnce(
1197
1271
  violations,
1198
1272
  ...(proc.signalCode ? { signal: proc.signalCode } : {}),
1199
1273
  ...(timeout.timedOut() ? { timedOut: true } : {}),
1200
- ...resourceUsageToCpuRss(proc.resourceUsage()),
1274
+ // Linux: the task's CPU and peak never reach this wait. bwrap runs it
1275
+ // in a pid namespace (`--unshare-pid`), and what the namespace's
1276
+ // processes used is not folded into bwrap's usage: a 500 ms busy loop
1277
+ // read 2 ms under it and 510 without the flag, and the peak read was
1278
+ // vx's own high-water mark, inherited at exec (a sandboxed `true` from a
1279
+ // 300 MB vx: 353 MB; B-3). Nothing is reported rather than a number
1280
+ // that is someone else's. macOS's `sandbox-exec` execs the command, so
1281
+ // its usage is the task's.
1282
+ ...(process.platform === 'linux' ? {} : resourceUsageToCpuRss(proc.resourceUsage())),
1201
1283
  tracerFailed:
1202
1284
  straceLog !== undefined &&
1203
1285
  exitCode !== 0 &&
@@ -1239,7 +1321,7 @@ function sbplToken(value: string, field: string): string {
1239
1321
  * `systemInfo` has no SRT field at any level. The rest DO have fields,
1240
1322
  * but SRT reads `network.allowLocalBinding` / `allowUnixSockets` /
1241
1323
  * `allowMachLookup` off the config given to `initialize()` and never off
1242
- * the per-call one (`sandbox-manager.js` getters, 0.0.75) — so a per-task
1324
+ * the per-call one (`sandbox-manager.js` getters, 0.0.75 and 0.0.76) — so a per-task
1243
1325
  * grant passed there is silently dropped. vx is per-task by definition,
1244
1326
  * so it emits them itself. The rule text mirrors SRT's own.
1245
1327
  */
@@ -1391,7 +1473,11 @@ function injectProfileRules(wrapped: string, rules: readonly string[]): string {
1391
1473
  * NOT `isLiteralPattern`: its docblock says why the brace is not a wildcard
1392
1474
  * to a grant.
1393
1475
  */
1394
- function expandGrants(paths: readonly string[], kind: 'read' | 'write'): string[] {
1476
+ function expandGrants(
1477
+ paths: readonly string[],
1478
+ kind: 'read' | 'write',
1479
+ walls: readonly string[],
1480
+ ): string[] {
1395
1481
  // A pattern covering a directory WHOLE is that directory. `<d>/**/*` and
1396
1482
  // `<d>/**` match everything UNDER `<d>` and never `<d>` itself, so a task
1397
1483
  // granted `read: ['**/*']` still could not list its own cwd — the exact
@@ -1421,10 +1507,15 @@ function expandGrants(paths: readonly string[], kind: 'read' | 'write'): string[
1421
1507
  const head = p.slice(0, p.search(MOUNT_WILDCARDS))
1422
1508
  const home = toRealPath(head.endsWith('/') ? head.slice(0, -1) : path.dirname(head))
1423
1509
  let hits = 0
1424
- for (const hit of new Bun.Glob(pattern).scanSync({ cwd: base, onlyFiles: false, dot: true })) {
1510
+ for (const hit of scanOrNothing(pattern, base)) {
1425
1511
  const abs = path.join(base, hit)
1426
1512
  const real = toRealPath(abs)
1427
1513
  if (real !== home && !real.startsWith(home + path.sep)) continue
1514
+ // A hit that IS a wall, or lies inside one, was matched, not named:
1515
+ // `read: ['*']` in a root project bound `.git` and `.vx`, and
1516
+ // `packages/*` a nested project its key excludes (B-1). A grant that
1517
+ // names a wall literally never reaches this loop and stays.
1518
+ if (walls.some((w) => real === w || real.startsWith(w + path.sep))) continue
1428
1519
  out.push(abs)
1429
1520
  hits++
1430
1521
  }
@@ -1433,6 +1524,24 @@ function expandGrants(paths: readonly string[], kind: 'read' | 'write'): string[
1433
1524
  return out
1434
1525
  }
1435
1526
 
1527
+ /**
1528
+ * A glob's hits under `base`, or none when `base` does not exist yet:
1529
+ * `Bun.Glob` throws ENOENT there, and a cache not yet populated on a fresh
1530
+ * runner (`~/.cache/x/y/*`) failed its task with the raw errno and no word
1531
+ * of the grant (B-6). A missing base matches nothing, as an empty one does.
1532
+ */
1533
+ function scanOrNothing(pattern: string, base: string): Iterable<string> {
1534
+ const glob = new Bun.Glob(pattern)
1535
+ try {
1536
+ // Materialised here: the throw comes from the first step, not the call.
1537
+ return [...glob.scanSync({ cwd: base, onlyFiles: false, dot: true })]
1538
+ } catch (err) {
1539
+ const code = (err as NodeJS.ErrnoException).code
1540
+ if (code === 'ENOENT' || code === 'ENOTDIR') return []
1541
+ throw err
1542
+ }
1543
+ }
1544
+
1436
1545
  /** Grants already reported — once per process, not per spawn. */
1437
1546
  const warnedEmptyWriteGrant = new Set<string>()
1438
1547
 
@@ -273,16 +273,12 @@ function withinReported(
273
273
  }
274
274
 
275
275
  /**
276
- * Apply the task's user-provided `sandbox.ignore` grants on top
277
- * of whatever the macOS log monitor + Linux strace pass produced.
278
- * Mirrors SRT's own substring-match semantics:
279
- * - `'*'` entries match every command
280
- * - other keys match commands whose userCommand string CONTAINS the key
281
- * - the array of strings under each key is substring-matched against
282
- * the violation line
283
- *
284
- * The defaults installed in `initSandbox` already filter on the macOS
285
- * side; this pass catches per-task additions + Linux strace results.
276
+ * Apply the task's own `sandbox.ignore` lists on top of whatever the
277
+ * macOS log monitor and the Linux strace pass produced: a record is
278
+ * dropped when a list its operation names (`matchesIgnore`) holds a
279
+ * pattern equal to its target or matching it as a glob. Not SRT's
280
+ * per-command substring match, which is what the defaults installed in
281
+ * `initSandbox` use on the macOS side.
286
282
  */
287
283
  function filterIgnored(
288
284
  violations: SandboxViolation[],
@@ -291,3 +287,39 @@ function filterIgnored(
291
287
  if (ignore === undefined) return violations
292
288
  return violations.filter((v) => !matchesIgnore(v, ignore))
293
289
  }
290
+
291
+ /**
292
+ * The writes SRT's Linux observer saw that this task's binds do not cover.
293
+ * The observer reports every write-intent syscall as `deny <syscall>
294
+ * <path>` (it cannot see the mount table), so a record under a path bwrap
295
+ * binds writable — `bindableWrites` of the task's grants — was a write
296
+ * that landed, and anything else was refused or fell into the deny
297
+ * anchor's scratch and vanished with the sandbox. Deduplicated by syscall
298
+ * and canonical path; only a `write` ignore list can silence one.
299
+ */
300
+ export function refusedWrites(
301
+ records: readonly string[],
302
+ writable: readonly string[],
303
+ ): SandboxViolation[] {
304
+ const binds = new Set(writable.map((w) => toRealPath(absolutize(w))))
305
+ const seen = new Set<string>()
306
+ const out: SandboxViolation[] = []
307
+ for (const record of records) {
308
+ const m = /^deny (\S+) (\/.*)$/.exec(record)
309
+ if (m === null) continue
310
+ const [, syscall, raw] = m as unknown as [string, string, string]
311
+ const abs = toRealPath(raw)
312
+ if (isUnderAny(abs, binds)) continue
313
+ const key = `${syscall}|${abs}`
314
+ if (seen.has(key)) continue
315
+ seen.add(key)
316
+ out.push({
317
+ line: `${syscall}(${raw}) = a write no grant covers [${abs}]`,
318
+ timestamp: new Date(),
319
+ target: abs,
320
+ path: abs,
321
+ ignorable: ['write'],
322
+ })
323
+ }
324
+ return out
325
+ }
@@ -389,9 +389,15 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
389
389
  // the exec cap: on the 1,000-project bench with every task a restore,
390
390
  // 4 → 8 workers cut the run-graph stage 683–754 → 556–595 ms and 16 was
391
391
  // no better (2026-09-10). Exec-tier work keeps the CPU-shaped cap; the
392
- // two lanes never wait on each other. `--concurrency 1` stays serial.
392
+ // two lanes never wait on each other. `--concurrency 1` stays serial:
393
+ // the two lanes share its one slot, where a lane each let an 80 MB
394
+ // restore run inside another task's execution (item 1103).
393
395
  let activeRestore = 0
394
- const restoreConcurrency = concurrency === 1 ? 1 : 2 * concurrency
396
+ const restoreConcurrency = 2 * concurrency
397
+ const serial = concurrency === 1
398
+ const execRoom = (): boolean => (serial ? active + activeRestore < 1 : active < concurrency)
399
+ const restoreRoom = (): boolean =>
400
+ serial ? active + activeRestore < 1 : activeRestore < restoreConcurrency
395
401
  let resolved = false
396
402
 
397
403
  // Admission policy over the count limit (a plugin's `admit`). Inactive
@@ -416,7 +422,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
416
422
  const hasRoom = (id: string): boolean => {
417
423
  const pool = poolOf?.(id)
418
424
  if (pool === undefined) {
419
- return inRestoreTier(id) ? activeRestore < restoreConcurrency : active < concurrency
425
+ return inRestoreTier(id) ? restoreRoom() : execRoom()
420
426
  }
421
427
  return (poolActive.get(pool.name) ?? 0) < pool.capacity
422
428
  }
@@ -579,7 +585,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
579
585
  // 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
580
586
  // first cut let the scan run past a full exec lane, 2026-09-10).
581
587
  // This is the legacy O(1) gate, kept per lane.
582
- const execAdmissible = poolOf !== undefined || admitActive || active < concurrency
588
+ const execAdmissible = poolOf !== undefined || admitActive || execRoom()
583
589
  if (execAdmissible) {
584
590
  while (execReady.size > 0) {
585
591
  const seq = execReady.peekSeq()
@@ -594,7 +600,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
594
600
  }
595
601
  }
596
602
  // Restore-tier tasks are local disk work, on their own lane.
597
- return activeRestore < restoreConcurrency ? restoreReady.pop() : undefined
603
+ return restoreRoom() ? restoreReady.pop() : undefined
598
604
  }
599
605
 
600
606
  for (;;) {
@@ -1,3 +1,4 @@
1
+ import path from 'node:path'
1
2
  import type { ProjectConfig, TaskConfig } from '../config.js'
2
3
  import {
3
4
  asTrees,
@@ -258,6 +259,13 @@ export interface BuildGraphOptions {
258
259
  * workspace and the builder refuses it itself.
259
260
  */
260
261
  undeclaredDeps?: (taskId: string, name: string) => void
262
+ /**
263
+ * The workspace root, where `cache.outputs.workspaceFiles` is anchored.
264
+ * Given, a root-anchored output is also compared with every project's
265
+ * `files` outputs (item 1088); absent (a graph built for a unit row), only
266
+ * with other root-anchored ones.
267
+ */
268
+ workspaceRoot?: string
261
269
  }
262
270
 
263
271
  /**
@@ -505,7 +513,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
505
513
  }
506
514
  }
507
515
 
508
- checkGraph(nodes)
516
+ checkGraph(nodes, options.workspaceRoot)
509
517
  return nodes
510
518
  }
511
519
 
@@ -520,7 +528,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
520
528
  * marks are derived here, so they are cleared first and follow the edges
521
529
  * the graph has now.
522
530
  */
523
- export function checkGraph(nodes: Map<string, TaskNode>): void {
531
+ export function checkGraph(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
524
532
  for (const [key, node] of nodes) {
525
533
  if (node.id !== key) {
526
534
  throw new Error(`the task ${node.id} is stored under '${key}', not its own id`)
@@ -534,7 +542,7 @@ export function checkGraph(nodes: Map<string, TaskNode>): void {
534
542
  delete node.outputsAddedToBy
535
543
  }
536
544
  detectCycle(nodes)
537
- detectOutputCollisions(nodes)
545
+ detectOutputCollisions(nodes, workspaceRoot)
538
546
  }
539
547
 
540
548
  /**
@@ -718,7 +726,7 @@ function covers(tree: string, glob: string): boolean {
718
726
  * project boundaries by design, so ANY two tasks can. No cache key changes —
719
727
  * this only refuses a graph that was already destroying files.
720
728
  */
721
- function detectOutputCollisions(nodes: Map<string, TaskNode>): void {
729
+ function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
722
730
  // Does `from` reach `to` through deps? Asked only for a colliding pair,
723
731
  // so the walk is rare; memoised per source across the detector's calls.
724
732
  const reachMemo = new Map<string, Set<string>>()
@@ -778,11 +786,87 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>): void {
778
786
  collide(a, b, filesOf(a), filesOf(b), 'files', reaches)
779
787
  }
780
788
  }
781
- if (wsDeclarers.length < 2) return
782
- for (const [i, j] of overlapCandidates(wsDeclarers, wsFilesOf)) {
783
- const a = wsDeclarers[i]!
784
- const b = wsDeclarers[j]!
785
- collide(a, b, wsFilesOf(a), wsFilesOf(b), 'workspaceFiles', reaches)
789
+ if (wsDeclarers.length >= 2) {
790
+ for (const [i, j] of overlapCandidates(wsDeclarers, wsFilesOf)) {
791
+ const a = wsDeclarers[i]!
792
+ const b = wsDeclarers[j]!
793
+ collide(a, b, wsFilesOf(a), wsFilesOf(b), 'workspaceFiles', reaches)
794
+ }
795
+ }
796
+ // A root-anchored output that reaches into ANOTHER project's `files`
797
+ // outputs was compared with neither: `a` writing `packages/b/dist/a.txt`
798
+ // beside `b`'s `dist/**` had its file deleted by `b`'s clean and still
799
+ // replayed `up-to-date` (item 1088). Each `files` declarer joins the
800
+ // root-anchored domain with its globs rebased to the root, and only the
801
+ // mixed pairs are compared.
802
+ if (wsDeclarers.length === 0 || workspaceRoot === undefined) return
803
+ interface Entry {
804
+ node: TaskNode
805
+ globs: readonly string[]
806
+ /** The project's root-relative dir for a rebased `files` entry; undefined for a root-anchored one. */
807
+ rel?: string
808
+ }
809
+ const entries: Entry[] = wsDeclarers.map((node) => ({ node, globs: wsFilesOf(node) ?? [] }))
810
+ for (const bucket of byProject.values()) {
811
+ for (const node of bucket) {
812
+ const rel = path.relative(workspaceRoot, node.projectDir).split(path.sep).join('/')
813
+ const globs = (filesOf(node) ?? []).map((g) => (rel === '' ? g : `${rel}/${g}`))
814
+ entries.push({ node, globs, rel })
815
+ }
816
+ }
817
+ for (const [i, j] of overlapCandidates(entries, (e) => e.globs)) {
818
+ const x = entries[i]!
819
+ const y = entries[j]!
820
+ if ((x.rel === undefined) === (y.rel === undefined) || x.node === y.node) continue
821
+ const [ws, files] = x.rel === undefined ? [x, y] : [y, x]
822
+ collideAcross(ws.node, ws.globs, files.node, files.globs, files.rel!, reaches)
823
+ }
824
+ }
825
+
826
+ /**
827
+ * `collide` for a root-anchored output against another project's `files`
828
+ * output rebased to the root. With an edge the dependant adds to its
829
+ * upstream's tree, and the upstream is told the dependant's globs in its
830
+ * OWN namespace: root-relative for the root-anchored side, project-relative
831
+ * for the `files` side, where a glob that does not start in that project
832
+ * cannot be told apart and the pair is refused.
833
+ */
834
+ function collideAcross(
835
+ ws: TaskNode,
836
+ wsGlobs: readonly string[],
837
+ files: TaskNode,
838
+ rebased: readonly string[],
839
+ rel: string,
840
+ reaches: (from: string, to: string) => boolean,
841
+ ): void {
842
+ if (neverWritesLocally(ws) || neverWritesLocally(files)) return
843
+ for (const ga of wsGlobs) {
844
+ for (const gb of rebased) {
845
+ if (!outputsOverlap(ga, gb)) continue
846
+ if (reaches(files.id, ws.id)) {
847
+ ;(files.addsToOutputsOf ??= []).push(ws.id)
848
+ ;(ws.outputsAddedToBy ??= []).push(...rebased)
849
+ return
850
+ }
851
+ if (reaches(ws.id, files.id)) {
852
+ const own = wsGlobs.map((g) =>
853
+ rel === '' ? g : g.startsWith(`${rel}/`) ? g.slice(rel.length + 1) : null,
854
+ )
855
+ if (own.every((g): g is string => g !== null)) {
856
+ ;(ws.addsToOutputsOf ??= []).push(files.id)
857
+ ;(files.outputsAddedToBy ??= []).push(...own)
858
+ return
859
+ }
860
+ }
861
+ throw new UserError(
862
+ `${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
863
+ `inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
864
+ `cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
865
+ `cache-hit restore, so whichever of these runs second DELETES the other's output and ` +
866
+ `the run still reports success. Give each task its own output path, or make one ` +
867
+ `depend on the other.`,
868
+ )
869
+ }
786
870
  }
787
871
  }
788
872
 
@@ -815,9 +899,9 @@ const GLOB_HEAD_END = /[*?{}\\!]/
815
899
  * literal prefix lies at or under it, found the same way from the glob's
816
900
  * side (item 941).
817
901
  */
818
- function overlapCandidates(
819
- tasks: readonly TaskNode[],
820
- globsOf: (n: TaskNode) => readonly string[] | undefined,
902
+ function overlapCandidates<T>(
903
+ tasks: readonly T[],
904
+ globsOf: (n: T) => readonly string[] | undefined,
821
905
  ): Array<[number, number]> {
822
906
  const globs = new Map<string, number[]>()
823
907
  const globsUnder = new Map<string, number[]>()