@vzn/vx 0.0.21 → 0.0.22

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 +611 -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
@@ -37,6 +37,13 @@ export interface SaveMissArgs {
37
37
  /** Declared `cache.outputs.files` / `.workspaceFiles`. */
38
38
  outputs: string[]
39
39
  wsOutputs: string[]
40
+ /**
41
+ * An ADDITIVE task's own set (item 588): the files its run added or
42
+ * changed under its declared outputs, resolved by execute-task against
43
+ * the stamp it took before the run. Replaces the glob walk below, which
44
+ * would also take the upstream's files the task adds beside.
45
+ */
46
+ ownOutputFiles?: string[] | undefined
40
47
  /** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
41
48
  captured: readonly TaskInputComponent[]
42
49
  command: string
@@ -60,11 +67,13 @@ export interface SaveMissArgs {
60
67
  export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<void> }> {
61
68
  const { node, cache, log } = a
62
69
  const endResolve = span('miss: resolve outputs')
63
- const outputFiles = await resolveOutputs({
64
- projectDir: node.projectDir,
65
- outputs: a.outputs,
66
- nestedProjectDirs: a.nestedProjectDirs,
67
- })
70
+ const outputFiles =
71
+ a.ownOutputFiles ??
72
+ (await resolveOutputs({
73
+ projectDir: node.projectDir,
74
+ outputs: a.outputs,
75
+ nestedProjectDirs: a.nestedProjectDirs,
76
+ }))
68
77
  const wsOutputFiles = await resolveWorkspaceOutputs({
69
78
  workspaceRoot: a.workspaceRoot,
70
79
  outputs: a.wsOutputs,
@@ -79,9 +88,22 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<void>
79
88
  a.outputs.length + a.wsOutputs.length > 0 &&
80
89
  outputFiles.length + wsOutputFiles.length === 0
81
90
  ) {
91
+ // One cause is common enough to name, because the task shows no other
92
+ // symptom: a sandboxed task with no write grant. Its writes land in the
93
+ // sandbox's own scratch and never reach disk, and whether the shell
94
+ // even notices depends on the layout — in a single-package workspace
95
+ // (project dir === workspace root) the write SUCCEEDS into that
96
+ // scratch and the task exits 0, so this warning is the only thing that
97
+ // says the build produced nothing (item 444). A declared
98
+ // `cache.outputs` is not a write grant; `exec.sandbox.allow.write` is.
99
+ const sandboxed = node.config.exec?.sandbox !== undefined
100
+ const grantsWrite = (node.config.exec?.sandbox?.allow?.write?.length ?? 0) > 0
82
101
  log.status(
83
102
  `[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
84
- `an empty artifact is saved; a later hit restores nothing`,
103
+ `an empty artifact is saved; a later hit restores nothing` +
104
+ (sandboxed && !grantsWrite
105
+ ? ` — the task is sandboxed and declares no exec.sandbox.allow.write, so its writes never reached disk`
106
+ : ''),
85
107
  )
86
108
  }
87
109
  const save = async (): Promise<void> => {
@@ -110,60 +132,101 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<void>
110
132
  },
111
133
  })
112
134
  endSave()
113
- // The directory snapshot behind the next hit's skip-restore. Taken at
114
- // run end when the run keeps a list: the task wrote these directories
135
+ // The files the task just wrote are the entry's bytes: stamp them,
136
+ // so the next hit can tell them from another entry's with the same
137
+ // size and mtime (item 886).
138
+ cache.recordOutputStamps?.(a.hash, node.projectDir, a.workspaceRoot)
139
+ // The directory snapshot behind the next hit's skip-restore, taken at
140
+ // run end from the run's list: the task wrote these directories
115
141
  // milliseconds ago, inside the snapshot's racy window, so a snapshot
116
142
  // taken here was refused and the next hit walked every output tree —
117
143
  // 1,000 walks, 296 ms accumulated, on the first warm run after a cold
118
- // build of the 1,000-project bench (2026-09-10).
144
+ // build of the 1,000-project bench (2026-09-10). A caller with no
145
+ // list gets no snapshot: recording here was that same refusal (item
146
+ // 637 deleted the fallback and nothing reddened), and the next hit
147
+ // walks, as it did.
119
148
  const savedDirPrefixes = wholeSubtreePrefixes(a.outputs)
120
- if (savedDirPrefixes !== null) {
121
- if (a.outputDirSnapshots !== undefined) {
122
- a.outputDirSnapshots.push({
123
- hash: a.hash,
124
- projectDir: node.projectDir,
125
- prefixes: savedDirPrefixes,
126
- })
127
- } else {
128
- await cache.recordOutputDirs?.(a.hash, node.projectDir, savedDirPrefixes)
129
- }
149
+ if (savedDirPrefixes !== null && a.outputDirSnapshots !== undefined) {
150
+ a.outputDirSnapshots.push({
151
+ hash: a.hash,
152
+ projectDir: node.projectDir,
153
+ prefixes: savedDirPrefixes,
154
+ })
130
155
  }
131
156
  }
132
- // This task just wrote outputs to the project's tree. Record the
133
- // exact declared-output paths as changed (same as the cache-hit
134
- // restore path) instead of dropping the whole snapshot: a downstream
135
- // same-project task then re-spawns git ONLY when its input globs can
136
- // actually see one of these paths. On a 1000-package cold run this
137
- // removes ~one synchronous `git ls-files` spawn per project (the
138
- // single largest cold-run cost — 22% of CPU in profiling). Contract:
139
- // outputs must be declared — an executed task that writes files
140
- // outside `cache.outputs.files` which a same-project downstream task
141
- // reads is undeclared behavior (the restore path already assumes it).
157
+ markWritten(a, outputFiles, wsOutputFiles)
158
+ // Off the slot when the run keeps a lane (the save lane bounds and
159
+ // drains it); in the slot otherwise — an embedder without a lane gets
160
+ // the entry before the outcome.
161
+ if (a.deferSave !== undefined) return { landed: a.deferSave(save) }
162
+ await save()
163
+ return { landed: Promise.resolve() }
164
+ }
165
+
166
+ /** What `markUnsaved` reads of a miss: where its declared outputs are. */
167
+ export type UnsavedArgs = Pick<
168
+ SaveMissArgs,
169
+ 'node' | 'workspaceRoot' | 'nestedProjectDirs' | 'gitFilesCache' | 'outputs' | 'wsOutputs'
170
+ >
171
+
172
+ /**
173
+ * A miss that ran here and saves nothing (it failed, the policy writes
174
+ * nothing, an upstream failed, its key no longer held) still wrote its
175
+ * outputs, and a same-project reader after it must see them as it sees a
176
+ * save's: resolved and marked the same way.
177
+ */
178
+ export async function markUnsaved(a: UnsavedArgs): Promise<void> {
179
+ const endResolve = span('miss: resolve outputs')
180
+ const outputFiles = await resolveOutputs({
181
+ projectDir: a.node.projectDir,
182
+ outputs: a.outputs,
183
+ nestedProjectDirs: a.nestedProjectDirs,
184
+ })
185
+ const wsOutputFiles = await resolveWorkspaceOutputs({
186
+ workspaceRoot: a.workspaceRoot,
187
+ outputs: a.wsOutputs,
188
+ })
189
+ endResolve()
190
+ markWritten(a, outputFiles, wsOutputFiles)
191
+ }
192
+
193
+ /**
194
+ * The task just wrote outputs to the project's tree. Record the exact
195
+ * declared-output paths as changed (same as the cache-hit restore path)
196
+ * instead of dropping the whole snapshot: a downstream same-project task
197
+ * then re-spawns git ONLY when its input globs can actually see one of
198
+ * these paths. On a 1000-package cold run this removes ~one synchronous
199
+ * `git ls-files` spawn per project (the single largest cold-run cost —
200
+ * 22% of CPU in profiling). Contract: outputs must be declared — an
201
+ * executed task that writes files outside `cache.outputs.files` which a
202
+ * same-project downstream task reads is undeclared behavior (the restore
203
+ * path already assumes it).
204
+ */
205
+ function markWritten(
206
+ a: Pick<SaveMissArgs, 'node' | 'workspaceRoot' | 'gitFilesCache'>,
207
+ outputFiles: readonly string[],
208
+ wsOutputFiles: readonly string[],
209
+ ): void {
210
+ const { projectDir } = a.node
142
211
  if (outputFiles.length > 0) {
143
212
  a.gitFilesCache?.markOutputsChanged(
144
- node.projectDir,
145
- outputFiles.map((p) => path.relative(node.projectDir, p).split(path.sep).join('/')),
213
+ projectDir,
214
+ outputFiles.map((p) => path.relative(projectDir, p).split(path.sep).join('/')),
146
215
  )
147
216
  }
148
- // Declared workspace outputs may have landed inside OTHER
149
- // projects' dirs (no-boundary escape hatch) — mark the exact
150
- // paths against every partition that can see them.
217
+ // Declared workspace outputs may have landed inside OTHER projects' dirs
218
+ // (no-boundary escape hatch) — mark the exact paths against every
219
+ // partition that can see them.
151
220
  if (wsOutputFiles.length > 0) {
152
221
  a.gitFilesCache?.markWorkspaceOutputsChanged(
153
222
  a.workspaceRoot,
154
223
  wsOutputFiles.map((f) => path.relative(a.workspaceRoot, f).split(path.sep).join('/')),
155
224
  )
156
225
  }
157
- // The workspace-wide partition (when one exists) spans this
158
- // project's subtree, so it inherits the same "undeclared writes
159
- // are only visible to git" rule as the project drop above.
226
+ // The workspace-wide partition (when one exists) spans this project's
227
+ // subtree, so it inherits the same "undeclared writes are only visible
228
+ // to git" rule as the project drop above.
160
229
  if (outputFiles.length + wsOutputFiles.length > 0) {
161
230
  a.gitFilesCache?.invalidateWorkspacePartition()
162
231
  }
163
- // Off the slot when the run keeps a lane (the save lane bounds and
164
- // drains it); in the slot otherwise — an embedder without a lane gets
165
- // the entry before the outcome.
166
- if (a.deferSave !== undefined) return { landed: a.deferSave(save) }
167
- await save()
168
- return { landed: Promise.resolve() }
169
232
  }
@@ -139,16 +139,18 @@ export interface RunOptions {
139
139
  */
140
140
  profile?: string
141
141
  /**
142
- * Install SIGINT/SIGTERM handlers for the run's duration: forward
143
- * SIGTERM to every live child, close the cache, exit 128+signo
144
- * (130/143). Default true. The watch loop disables this — it owns
142
+ * Install SIGINT/SIGTERM handlers for the run's duration: forward the
143
+ * signal received to every live child's group (a SIGHUP as SIGTERM),
144
+ * close the cache, exit 128+signo (130/143). Default true. The watch loop disables this — it owns
145
145
  * signal disposition for its whole lifetime and a cycle must never
146
146
  * exit the process out from under it.
147
147
  */
148
148
  handleSignals?: boolean
149
149
  /**
150
150
  * Abort the run from outside. On `abort` every live and persistent
151
- * child is SIGTERMed, given the kill grace, then SIGKILLed; nothing
151
+ * child is sent SIGINT when the abort reason is `'SIGINT'` (a Ctrl-C
152
+ * the embedder received), SIGTERM otherwise, given the kill grace,
153
+ * then SIGKILLed; nothing
152
154
  * further is dispatched; run() returns with the killed tasks and the
153
155
  * never-started ones `aborted`. The seam for an embedder that owns its
154
156
  * process's lifetime instead of `handleSignals` — the watch loop aborts
@@ -156,6 +158,14 @@ export interface RunOptions {
156
158
  * never orphans a cycle's children.
157
159
  */
158
160
  signal?: AbortSignal
161
+ /**
162
+ * Hand the requested persistent tasks still running back on
163
+ * `RunSummary.persistent` instead of stopping them when the graph ends.
164
+ * The caller owns them from then on and stops them with its `stop()`.
165
+ * The watch loop sets this: a dev server stays up while watch idles and
166
+ * is replaced only when the next cycle starts.
167
+ */
168
+ holdPersistent?: boolean
159
169
  log?: Logger
160
170
  /**
161
171
  * Inject the run's event bus. When provided, the orchestrator emits
@@ -210,9 +220,25 @@ export interface RunOptions {
210
220
  * declares a cache plugin). Undefined → identical to before.
211
221
  */
212
222
  remoteCache?: RemoteCacheLayer
223
+ /**
224
+ * The largest artifact, decoded, the local cache saves, ingests or
225
+ * restores; default 2 GiB. A seam for a test, which cannot produce 2 GiB
226
+ * of output to prove the refusal: the CLI never sets it, and no config
227
+ * or variable reaches it.
228
+ */
229
+ artifactCeiling?: number
213
230
  }
214
231
 
215
232
  export interface RunSummary {
216
233
  ok: boolean
217
234
  outcomes: TaskOutcome[]
235
+ /** The persistent tasks `RunOptions.holdPersistent` handed back; absent when none is running. */
236
+ persistent?: HeldPersistent
237
+ }
238
+
239
+ export interface HeldPersistent {
240
+ /** The held tasks' ids, `pkg#task`. */
241
+ ids: readonly string[]
242
+ /** Send every held task `signal` (default SIGTERM), then SIGKILL after the kill grace; resolves once each is gone. */
243
+ stop(signal?: 'SIGINT' | 'SIGTERM'): Promise<void>
218
244
  }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { TaskNode } from '../graph/index.js'
7
7
  import { killGraceMs } from '../util/index.js'
8
+ import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
8
9
 
9
10
  type Child = ReturnType<typeof Bun.spawn>
10
11
 
@@ -14,7 +15,7 @@ type Child = ReturnType<typeof Bun.spawn>
14
15
  * hang a normal run at completion. Well-behaved servers exit far under
15
16
  * this, so the happy path never waits it out.
16
17
  */
17
- export const PERSISTENT_SHUTDOWN_GRACE_MS = 2000
18
+ const PERSISTENT_SHUTDOWN_GRACE_MS = 2000
18
19
 
19
20
  export interface KeepAlive {
20
21
  nodes: TaskNode[]
@@ -48,32 +49,51 @@ export function selectKeepAlive(
48
49
  /**
49
50
  * SIGTERM every persistent child not kept alive and wait for them, bounded:
50
51
  * SIGTERM gives well-behaved servers (vite, next, esbuild --watch) a moment
51
- * to clean up; past the grace the stragglers are SIGKILLed so the run's
52
- * normal completion cannot hang on one that traps the signal. Bun's
53
- * `kill` is idempotent on an exited child; the timer is cleared and
54
- * unref'd so a fast shutdown never delays CLI exit.
52
+ * to clean up; past the grace every group with a member left is SIGKILLed,
53
+ * so the run's normal completion cannot hang on one that traps the signal.
54
+ * The GROUP, not the leader: a `server & wait` shell dies on the SIGTERM
55
+ * while a server that ignores it lives on, holding the task's pipe, and
56
+ * vx printed its summary and never exited (nx#8286 reproduced on vx,
57
+ * 2026-09-24).
55
58
  */
56
59
  export async function shutdownPersistent(
57
60
  registry: ReadonlyMap<string, Child>,
58
61
  keepAlive: readonly Child[],
59
62
  graceMs: number = killGraceMs(PERSISTENT_SHUTDOWN_GRACE_MS),
60
- ): Promise<void> {
63
+ ): Promise<CrashedPersistent[]> {
61
64
  const kept = new Set(keepAlive)
65
+ const crashed: CrashedPersistent[] = []
66
+ for (const [id, child] of registry) {
67
+ if (kept.has(child) || !hasEnded(child) || child.exitCode === 0) continue
68
+ crashed.push({ id, code: child.exitCode ?? child.signalCode ?? 'unknown' })
69
+ }
62
70
  const dying = [...registry.values()].filter((c) => !kept.has(c))
63
- if (dying.length === 0) return
64
- for (const child of dying) child.kill('SIGTERM')
65
- const allExited = Promise.allSettled(dying.map((c) => c.exited))
66
- let graceTimer: ReturnType<typeof setTimeout> | undefined
67
- const winner = await Promise.race([
68
- allExited.then(() => 'exited' as const),
69
- new Promise<'grace'>((resolve) => {
70
- graceTimer = setTimeout(() => resolve('grace'), graceMs)
71
- graceTimer.unref?.()
72
- }),
73
- ])
74
- if (graceTimer !== undefined) clearTimeout(graceTimer)
75
- if (winner === 'grace') {
76
- for (const child of dying) child.kill('SIGKILL')
77
- await allExited
71
+ if (dying.length === 0) return crashed
72
+ const letGo = holdGroups(dying)
73
+ try {
74
+ for (const child of dying) killTree(child, 'SIGTERM')
75
+ for (const child of await untilGroupsGone(dying, graceMs)) killTree(child, 'SIGKILL')
76
+ await Promise.allSettled(dying.map((c) => c.exited))
77
+ } finally {
78
+ letGo()
78
79
  }
80
+ return crashed
81
+ }
82
+
83
+ /**
84
+ * A dependency-only persistent task that ended on its own, not by the stop
85
+ * at the end of the graph, and not cleanly. Its dependants may still have
86
+ * passed, and until item 892 the run did too: the server was reported
87
+ * `success` and its crash was said nowhere, while a requested server's
88
+ * crash failed the run.
89
+ */
90
+ export interface CrashedPersistent {
91
+ id: string
92
+ /** The exit code, or the signal that killed it. */
93
+ code: number | string
94
+ }
95
+
96
+ /** The child has exited or been killed; Bun sets these once it reaps it. */
97
+ export function hasEnded(child: Child): boolean {
98
+ return child.exitCode !== null || child.signalCode !== null
79
99
  }
@@ -1,9 +1,9 @@
1
1
  // Where each task runs: the placement of a graph over the resolved
2
2
  // executors, and the plan-mode view of it. Split from run.ts on 2026-09-10
3
3
  // (pure motion). A task is pinned to this machine when it is persistent,
4
- // depends on a persistent task, or says `exec.remote: false`; everything
5
- // else asks the executors in declaration order, and the local floor takes
6
- // what nothing claimed.
4
+ // depends on a persistent task, is sandboxed, or says `exec.remote: false`;
5
+ // everything else asks the executors in declaration order, and the local
6
+ // floor takes what nothing claimed.
7
7
 
8
8
  import { machineParallelism } from '../util/index.js'
9
9
  import { selectExecutor, type TaskExecutor } from '../exec/index.js'
@@ -16,26 +16,42 @@ import type { prepareRun } from './prepare.js'
16
16
  /**
17
17
  * A task is pinned to this machine when it is persistent, transitively
18
18
  * depends on a persistent task (a worker cannot reach a port on the
19
- * submitter), or declares `exec.remote: false`.
19
+ * submitter), declares `exec.sandbox` (the sandbox is this machine's
20
+ * machinery — a worker has none of it, and a boundary "verified" where it
21
+ * is not enforced passes vacuously), or declares `exec.remote: false`. A
22
+ * dependant of a pinned task is pinned with it.
20
23
  */
21
24
  export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
22
25
  const pinned = new Set<string>()
23
- const memo = new Map<string, boolean>()
24
- const visit = (id: string): boolean => {
25
- const known = memo.get(id)
26
- if (known !== undefined) return known
27
- const node = nodes.get(id)
28
- if (node === undefined) return false
29
- memo.set(id, false) // cycle guard; the graph builder already rejects cycles
30
- const result =
26
+ for (const node of nodes.values()) {
27
+ if (
31
28
  node.config.exec?.persistent !== undefined ||
32
- node.config.exec?.remote === false ||
33
- node.deps.some((d) => visit(d))
34
- memo.set(id, result)
35
- if (result) pinned.add(id)
36
- return result
29
+ node.config.exec?.sandbox !== undefined ||
30
+ node.config.exec?.remote === false
31
+ ) {
32
+ pinned.add(node.id)
33
+ }
34
+ }
35
+ if (pinned.size === 0) return pinned
36
+ // Pinning flows up the dependant edges from what pins itself, one walk on
37
+ // an explicit stack: a chain is as deep as the graph, and a recursion
38
+ // per edge threw `RangeError` on the 50,000 the builder takes (item 737).
39
+ const dependants = new Map<string, string[]>()
40
+ for (const node of nodes.values()) {
41
+ for (const dep of node.deps) {
42
+ const list = dependants.get(dep)
43
+ if (list === undefined) dependants.set(dep, [node.id])
44
+ else list.push(node.id)
45
+ }
46
+ }
47
+ const stack = [...pinned]
48
+ while (stack.length > 0) {
49
+ for (const up of dependants.get(stack.pop()!) ?? []) {
50
+ if (pinned.has(up)) continue
51
+ pinned.add(up)
52
+ stack.push(up)
53
+ }
37
54
  }
38
- for (const id of nodes.keys()) visit(id)
39
55
  return pinned
40
56
  }
41
57
 
@@ -53,10 +69,28 @@ export interface Placements {
53
69
  remoteOnly: Set<string>
54
70
  }
55
71
 
72
+ /**
73
+ * The tasks that write IN PLACE — everything not placed on a remote
74
+ * executor. `resolveDownloadModes` reads it as "the outputs are already
75
+ * here", so a task wrongly left out is one vx thinks it must fetch from a
76
+ * CAS that never held it.
77
+ *
78
+ * `remote` is a THREE-state field: `true` (remote), `false` (an explicit
79
+ * local plugin) and undefined — which is core's own local floor, since
80
+ * `localExecutor()` declares no `remote` at all. So the question is "not
81
+ * remote", never "declared local"; asking the latter drops every task on
82
+ * the floor executor, i.e. every task in a workspace with no executor
83
+ * plugin. It lives here, shared, because `run()` and `--dry` each built
84
+ * this set inline from the same predicate and a second copy is how the
85
+ * plan and the run would disagree about what comes home (441, 442, 445).
86
+ */
87
+ export function locallyPlaced(placements: Placements, ids: Iterable<string>): Set<string> {
88
+ return new Set([...ids].filter((id) => placements.executors.get(id)?.remote !== true))
89
+ }
90
+
56
91
  export function placeTasks(
57
92
  nodes: Map<string, TaskNode>,
58
93
  executors: readonly TaskExecutor[],
59
- pinAllLocal = false,
60
94
  ): Placements {
61
95
  const pinned = pinnedLocalSet(nodes)
62
96
  const placements: Placements = {
@@ -71,7 +105,7 @@ export function placeTasks(
71
105
  projectName: node.projectName,
72
106
  projectDir: node.projectDir,
73
107
  command: node.config.exec!.command,
74
- pinnedLocal: pinAllLocal || pinned.has(node.id),
108
+ pinnedLocal: pinned.has(node.id),
75
109
  cacheable: node.config.cache !== undefined,
76
110
  })
77
111
  placements.executors.set(node.id, executor)
@@ -126,11 +160,7 @@ export async function planExecutorOf(
126
160
  : resolveDownloadModes({
127
161
  nodes: prepared.nodes,
128
162
  policy,
129
- localPlaced: new Set(
130
- [...prepared.nodes.keys()].filter(
131
- (id) => placements.executors.get(id)?.remote !== true,
132
- ),
133
- ),
163
+ localPlaced: locallyPlaced(placements, prepared.nodes.keys()),
134
164
  remoteOnly: placements.remoteOnly,
135
165
  })
136
166
  return {
@@ -16,6 +16,7 @@ import { FULL_CACHE_POLICY } from '../cache/index.js'
16
16
  import { isGroupTask, runGraph, type TaskNode, type TaskOutcome } from '../graph/index.js'
17
17
  import type { HistoryProvider } from './history.js'
18
18
  import { computeGroupHash, computeTaskHash } from './task-hash.js'
19
+ import { keyUpstream } from './upstream.js'
19
20
 
20
21
  export type CacheStatus =
21
22
  | 'hit-local' // entry exists in local cache
@@ -124,11 +125,20 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
124
125
  const outcomes = await runGraph({
125
126
  nodes: args.nodes,
126
127
  concurrency: 1,
127
- execute: async (node, upstream) => {
128
+ execute: async (node, live) => {
129
+ const upstream = keyUpstream(node, live)
128
130
  if (isGroupTask(node)) {
129
131
  cacheStatusById.set(node.id, 'group')
130
132
  return planOutcome(node, computeGroupHash(upstream))
131
133
  }
134
+ // A persistent task has no key on the live path (its outcome carries
135
+ // none, `stable-keys.ts`), so a dependant folds nothing for it. The
136
+ // plan keyed it and folded that, and `--dry` then called a dependant
137
+ // a miss under a key the run never looks up (item 766).
138
+ if (node.config.exec?.persistent !== undefined) {
139
+ cacheStatusById.set(node.id, 'no-cache')
140
+ return planOutcome(node, undefined)
141
+ }
132
142
 
133
143
  const hash = await computeTaskHash({
134
144
  node,
@@ -142,17 +152,20 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
142
152
  ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
143
153
  })
144
154
 
145
- // Prediction keys off READS: a no-read policy (--no-cache /
146
- // --force / --cache=local:,remote:) would re-execute every task,
147
- // so we predict misses for it. The probe below routes through the
148
- // policy-aware cache layer, which itself respects local/remote
149
- // read gating.
155
+ // Prediction keys off READS: a no-read policy re-executes every task,
156
+ // so nothing is probed. Whether that run is a miss or no cache at all
157
+ // is the WRITE axes': `--force` and `--cache=local:w` save what they
158
+ // run, and the plan called them `no-cache`, the label a task with no
159
+ // `cache` block gets (item 991). The probe below routes through the
160
+ // policy-aware cache layer, which itself respects read gating.
150
161
  const policy = args.cachePolicy ?? FULL_CACHE_POLICY
151
- const cacheEnabled =
152
- node.config.cache !== undefined && (policy.localRead || policy.remoteRead)
162
+ const reads = policy.localRead || policy.remoteRead
163
+ const writes = policy.localWrite || policy.remoteWrite
153
164
  let status: CacheStatus
154
- if (!cacheEnabled) {
165
+ if (node.config.cache === undefined || (!reads && !writes)) {
155
166
  status = 'no-cache'
167
+ } else if (!reads) {
168
+ status = 'miss'
156
169
  } else {
157
170
  const where = await args.cache.has(hash)
158
171
  status = where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local'
@@ -261,12 +274,12 @@ function predictPlan(tasks: PlannedTask[]): PlanPrediction {
261
274
  return { wallMs, workMs, unknownCount }
262
275
  }
263
276
 
264
- function planOutcome(node: TaskNode, hash: string): TaskOutcome {
277
+ function planOutcome(node: TaskNode, hash: string | undefined): TaskOutcome {
265
278
  return {
266
279
  node,
267
280
  status: 'success',
268
281
  exitCode: 0,
269
282
  durationMs: 0,
270
- hash,
283
+ ...(hash !== undefined ? { hash } : {}),
271
284
  }
272
285
  }
@@ -11,7 +11,7 @@ import { ChainedCache, type CacheLayer } from '../cache/index.js'
11
11
  import { localExecutor, type TaskExecutor } from '../exec/index.js'
12
12
  import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
13
13
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
14
- import { detectCycle, type TaskNode } from '../graph/index.js'
14
+ import { checkGraph, type TaskNode } from '../graph/index.js'
15
15
  import type {
16
16
  CacheContext,
17
17
  ExecutorContext,
@@ -25,13 +25,6 @@ import type {
25
25
  WorkspaceHookContext,
26
26
  } from './plugin.js'
27
27
 
28
- /**
29
- * Run a capability factory with crash isolation. A throw becomes a clean
30
- * `UserError` naming the plugin + hook: every stage and capability resolved
31
- * here is load-bearing, so a broken one must abort with a clear message,
32
- * never silently degrade. (Telemetry sinks are the observe-only exception,
33
- * and telemetry-host.ts logs-and-skips them instead.)
34
- */
35
28
  /** `a string`, `an array`, `null`, `a number` — for a refusal that names what came back. */
36
29
  function describeValue(v: unknown): string {
37
30
  if (v === null) return 'null'
@@ -40,7 +33,6 @@ function describeValue(v: unknown): string {
40
33
  return t === 'object' ? 'an object' : `a ${t}`
41
34
  }
42
35
 
43
- /** What a capability hook handed back must be what the seam runs. */
44
36
  /** Every method `CacheLayer` requires (the optional ones are probed with `?.`). */
45
37
  export const CACHE_LAYER_METHODS: readonly string[] = [
46
38
  'key',
@@ -60,6 +52,7 @@ export const CACHE_LAYER_METHODS: readonly string[] = [
60
52
  'close',
61
53
  ]
62
54
 
55
+ /** What a capability hook handed back must be what the seam runs. */
63
56
  function assertShape(
64
57
  plugin: VxPlugin,
65
58
  hook: string,
@@ -78,6 +71,13 @@ function assertShape(
78
71
  )
79
72
  }
80
73
 
74
+ /**
75
+ * Run a capability factory with crash isolation. A throw becomes a clean
76
+ * `UserError` naming the plugin + hook: every stage and capability resolved
77
+ * here is load-bearing, so a broken one must abort with a clear message,
78
+ * never silently degrade. (Telemetry sinks are the observe-only exception,
79
+ * and telemetry-host.ts logs-and-skips them instead.)
80
+ */
81
81
  async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>): Promise<T> {
82
82
  try {
83
83
  return await fn()
@@ -132,8 +132,9 @@ export async function applyProjectHooks(
132
132
 
133
133
  /**
134
134
  * `graph` stage: every plugin edits the task graph in place, then the graph
135
- * is checked ONCE the way the builder checks its own output — every dep
136
- * names a node in the graph, and there is no cycle. A violation is reported
135
+ * is checked ONCE the way the builder checks its own output (`checkGraph`:
136
+ * each node under its own id, every dep a node, no cycle, no two tasks
137
+ * deleting each other's outputs). A violation is reported
137
138
  * against the LAST plugin that ran: usually the one whose edit made it so,
138
139
  * but an earlier plugin's edit that a later one left in place is blamed on
139
140
  * the later one, since nothing is checked between plugins.
@@ -150,16 +151,7 @@ export async function applyGraphHooks(
150
151
  last = plugin
151
152
  }
152
153
  if (last === undefined) return
153
- await safe(last, 'graph', () => {
154
- for (const node of nodes.values()) {
155
- for (const dep of node.deps) {
156
- if (!nodes.has(dep)) {
157
- throw new Error(`${node.id} depends on '${dep}', which is not a task in this run's graph`)
158
- }
159
- }
160
- }
161
- detectCycle(nodes)
162
- })
154
+ await safe(last, 'graph', () => checkGraph(nodes))
163
155
  }
164
156
 
165
157
  /**
@@ -299,8 +291,10 @@ export function buildAdmission(
299
291
  if (answering.length === 0) return undefined
300
292
  const broken = new Set<VxPlugin>()
301
293
  return (id, running) => {
302
- const task = nodes.get(id)
303
- if (task === undefined) return true
294
+ // The scheduler asks only about ids in this same map, so the lookup
295
+ // always lands: an unknown-id arm here was deleted with the whole core
296
+ // suite green (item 651).
297
+ const task = nodes.get(id)!
304
298
  const ctx = {
305
299
  running: [...running].flatMap((r) => nodes.get(r) ?? []),
306
300
  concurrency,