@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.
- package/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +611 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- 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 =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
114
|
-
//
|
|
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
|
-
|
|
122
|
-
a.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
outputFiles.map((p) => path.relative(
|
|
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
|
-
//
|
|
150
|
-
//
|
|
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
|
-
//
|
|
159
|
-
//
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
52
|
-
* normal completion cannot hang on one that traps the signal.
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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<
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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`;
|
|
5
|
-
// else asks the executors in declaration order, and the local
|
|
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),
|
|
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
|
|
24
|
-
|
|
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?.
|
|
33
|
-
node.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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:
|
|
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:
|
|
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 {
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -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,
|
|
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
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
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
|
|
152
|
-
|
|
162
|
+
const reads = policy.localRead || policy.remoteRead
|
|
163
|
+
const writes = policy.localWrite || policy.remoteWrite
|
|
153
164
|
let status: CacheStatus
|
|
154
|
-
if (!
|
|
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 {
|
|
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
|
|
136
|
-
*
|
|
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
|
-
|
|
303
|
-
|
|
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,
|