@vzn/vx 0.0.18 → 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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -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/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -1,42 +1,56 @@
|
|
|
1
|
-
import { mkdir } from 'node:fs/promises'
|
|
2
1
|
import path from 'node:path'
|
|
3
2
|
import type { ExecConfig, TaskConfig, CacheConfig } from '../config.js'
|
|
4
3
|
import {
|
|
4
|
+
ArtifactVanishedError,
|
|
5
5
|
type CacheEntry,
|
|
6
6
|
type CacheLayer,
|
|
7
7
|
type CachePolicy,
|
|
8
8
|
cleanOutputs,
|
|
9
9
|
cleanWorkspaceOutputs,
|
|
10
|
+
type OutputStamp,
|
|
11
|
+
ownOutputsSince,
|
|
12
|
+
stampOutputs,
|
|
10
13
|
FULL_CACHE_POLICY,
|
|
11
14
|
type GitFilesCache,
|
|
12
|
-
resolveInputs,
|
|
13
|
-
resolveOutputs,
|
|
14
|
-
resolveWorkspaceOutputs,
|
|
15
|
-
WORKSPACE_OUTPUT_PREFIX,
|
|
16
15
|
} from '../cache/index.js'
|
|
17
16
|
import {
|
|
18
17
|
buildIsolatedEnv,
|
|
18
|
+
VX_RUN_TASK_ENV,
|
|
19
|
+
VX_RUN_WORKSPACE_ENV,
|
|
19
20
|
runPersistent,
|
|
20
|
-
resolveSandboxConfig,
|
|
21
21
|
shellQuote,
|
|
22
|
+
releaseBridges,
|
|
23
|
+
wrapSandboxedCommand,
|
|
22
24
|
signalExitCode,
|
|
23
25
|
type CaptureConfig,
|
|
24
26
|
type ExecuteRequest,
|
|
27
|
+
assertExecuteResult,
|
|
25
28
|
type ExecuteResult,
|
|
26
29
|
type SandboxViolation,
|
|
27
30
|
type TaskExecutor,
|
|
28
31
|
type TaskInputs,
|
|
32
|
+
PersistentReadyError,
|
|
29
33
|
} from '../exec/index.js'
|
|
30
|
-
import { isGroupTask, type TaskNode, type TaskOutcome
|
|
34
|
+
import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
35
|
+
import { relPosix, span } from '../util/index.js'
|
|
31
36
|
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
mayWriteFingerprint,
|
|
38
|
+
type Placeholder,
|
|
39
|
+
placeholderSweeper,
|
|
40
|
+
reachedWithheld,
|
|
41
|
+
sandboxRequestFor,
|
|
42
|
+
sweepPlaceholders,
|
|
43
|
+
undeclaredWriteReach,
|
|
44
|
+
untouchedPlaceholderLine,
|
|
45
|
+
type WithheldLink,
|
|
46
|
+
withheldLinkLine,
|
|
47
|
+
} from './sandbox-request.js'
|
|
48
|
+
import { markUnsaved, saveMiss, type OutputDirSnapshot } from './miss-save.js'
|
|
49
|
+
import type { FingerprintWatch } from './fingerprint-watch.js'
|
|
50
|
+
import { restoreHit } from './hit-restore.js'
|
|
51
|
+
import { shellVerdict } from './shell-verdict.js'
|
|
52
|
+
// The hit path's entry stays importable from here (tests).
|
|
53
|
+
export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
|
|
40
54
|
import type { DeferredOutputs } from './deferred-outputs.js'
|
|
41
55
|
import type { Logger } from './logger.js'
|
|
42
56
|
import {
|
|
@@ -44,8 +58,10 @@ import {
|
|
|
44
58
|
computeTaskHash,
|
|
45
59
|
describeTaskInputs,
|
|
46
60
|
type HashCache,
|
|
61
|
+
movedInput,
|
|
47
62
|
type TaskInputComponent,
|
|
48
63
|
} from './task-hash.js'
|
|
64
|
+
import { getContext } from './remote-prefetch.js'
|
|
49
65
|
|
|
50
66
|
export interface ExecuteArgs {
|
|
51
67
|
node: TaskNode
|
|
@@ -86,6 +102,13 @@ export interface ExecuteArgs {
|
|
|
86
102
|
nestedProjectDirs: string[]
|
|
87
103
|
/** Anchor for hrtime spans across all tasks in this run. */
|
|
88
104
|
runStartHrTimeNs: bigint
|
|
105
|
+
/**
|
|
106
|
+
* The run's stop (a process signal or the embedder's abort). An attempt
|
|
107
|
+
* that ends while it is aborted is `aborted`, whatever its exit: a task
|
|
108
|
+
* that traps the forwarded SIGINT and exits 0 did not finish on its own
|
|
109
|
+
* terms (item 962).
|
|
110
|
+
*/
|
|
111
|
+
stopSignal?: AbortSignal
|
|
89
112
|
/**
|
|
90
113
|
* Registry the orchestrator owns. For each persistent task we
|
|
91
114
|
* spawn, we stash the subprocess handle here so the orchestrator
|
|
@@ -112,25 +135,6 @@ export interface ExecuteArgs {
|
|
|
112
135
|
* `exec.timeout` wins. Threaded as an option only — never hashed.
|
|
113
136
|
*/
|
|
114
137
|
timeout?: number
|
|
115
|
-
/**
|
|
116
|
-
* Cache-correctness verification (`vx run --verify`). When
|
|
117
|
-
* `determinism` is set, an executed + cacheable task is re-run after its
|
|
118
|
-
* save and its outputs are content-compared; a divergence flags the task
|
|
119
|
-
* non-hermetic. When `inputs` is set, an executed + cacheable task is
|
|
120
|
-
* forced through the declared-input baseline sandbox and any read outside
|
|
121
|
-
* those inputs flags the declared `cache.inputs` as incomplete. When
|
|
122
|
-
* `fingerprint` is set, the executed task's output tree is fingerprinted
|
|
123
|
-
* and attached to the outcome (no re-run — the cross-machine diff feed).
|
|
124
|
-
* `allow` exempts known-nondeterministic task ids from failing the run.
|
|
125
|
-
* Pure side-channel — the re-run never saves, nothing is hashed.
|
|
126
|
-
* Undefined = off.
|
|
127
|
-
*/
|
|
128
|
-
verify?: {
|
|
129
|
-
determinism: boolean
|
|
130
|
-
inputs: boolean
|
|
131
|
-
fingerprint: boolean
|
|
132
|
-
allow: ReadonlySet<string>
|
|
133
|
-
}
|
|
134
138
|
/** Per-run memo for `git ls-files` (one entry per project dir). */
|
|
135
139
|
gitFilesCache?: GitFilesCache
|
|
136
140
|
/** Per-run memo for derived hashes (package.json bytes + task config). */
|
|
@@ -147,6 +151,55 @@ export interface ExecuteArgs {
|
|
|
147
151
|
* Absent → probe lazily, exactly as today (unstable / unclassified).
|
|
148
152
|
*/
|
|
149
153
|
preProbed?: { hash: string; hit: CacheEntry | null }
|
|
154
|
+
/**
|
|
155
|
+
* Start the sandbox runtime, on the first task that executes inside one
|
|
156
|
+
* (run.ts, `prepareSandbox`). Absent when no task in the run declares a
|
|
157
|
+
* sandbox. A cache hit never calls it: a hit needs no sandbox.
|
|
158
|
+
*/
|
|
159
|
+
armSandbox?: () => Promise<void>
|
|
160
|
+
/**
|
|
161
|
+
* The run's keyed-set lookup (keyed-projects.ts): the project directories
|
|
162
|
+
* a task's key answers for. A sandboxed task that declares `cache` is
|
|
163
|
+
* granted a linked workspace package only when it is in this set.
|
|
164
|
+
*/
|
|
165
|
+
keyedProjects: (node: TaskNode) => ReadonlySet<string>
|
|
166
|
+
/**
|
|
167
|
+
* Run-scoped list the miss path appends its output-directory snapshot
|
|
168
|
+
* request to, taken at run end (run.ts) instead of right after the save:
|
|
169
|
+
* a directory written milliseconds ago is inside the snapshot's racy
|
|
170
|
+
* window and would be refused, and the next hit would walk the tree.
|
|
171
|
+
*/
|
|
172
|
+
outputDirSnapshots?: OutputDirSnapshot[]
|
|
173
|
+
/** The run's save lane: a miss's cache save runs off the execution slot (save-lane.ts). */
|
|
174
|
+
deferSave?: (save: () => Promise<void>) => Promise<void>
|
|
175
|
+
/**
|
|
176
|
+
* Where a deferred save's `landed` promise is parked by task id, for the
|
|
177
|
+
* in-flight join (admission.ts): a duplicate of this task in another run
|
|
178
|
+
* must not probe the cache before the entry is there.
|
|
179
|
+
*/
|
|
180
|
+
deferredSaves?: Map<string, Promise<void>>
|
|
181
|
+
/**
|
|
182
|
+
* `continueMode: 'always'` let this task run although an upstream —
|
|
183
|
+
* directly or through a chain of successes — failed or aborted. It still
|
|
184
|
+
* cleans its outputs and runs, and a cache HIT still restores (a hit is a
|
|
185
|
+
* healthy run's bytes), but its own result is never saved: the key it
|
|
186
|
+
* derives is the one a healthy run derives (pure-input hashing), while
|
|
187
|
+
* the bytes were built on a partial tree, so a save here is the next
|
|
188
|
+
* clean run's stale hit.
|
|
189
|
+
*/
|
|
190
|
+
taintedUpstream?: boolean
|
|
191
|
+
/**
|
|
192
|
+
* Whether a task has rewritten a file the workspace fingerprint folded
|
|
193
|
+
* since the run read it (fingerprint-watch.ts): past that, no key taken
|
|
194
|
+
* on the old digest is probed or saved.
|
|
195
|
+
*/
|
|
196
|
+
fingerprintWatch?: FingerprintWatch
|
|
197
|
+
/**
|
|
198
|
+
* No task in the run depends on this one, so none is ordered after it to
|
|
199
|
+
* read what it wrote: a miss that saves nothing marks nothing (the output
|
|
200
|
+
* walk cost 1,000 read-only misses 1.62 → 1.78 s, item 750).
|
|
201
|
+
*/
|
|
202
|
+
noDependants?: true
|
|
150
203
|
}
|
|
151
204
|
|
|
152
205
|
/**
|
|
@@ -211,18 +264,56 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
211
264
|
// regex matcher sees the unmodified output. When it's absent the
|
|
212
265
|
// task is "ready on spawn" — we can safely append forwardArgs in
|
|
213
266
|
// the same way runCommand does.
|
|
267
|
+
const plainCommand =
|
|
268
|
+
step.persistent.readyWhen !== undefined
|
|
269
|
+
? step.command
|
|
270
|
+
: effectiveForwardArgs.length > 0
|
|
271
|
+
? step.command + ' ' + effectiveForwardArgs.map(shellQuote).join(' ')
|
|
272
|
+
: step.command
|
|
273
|
+
// A persistent task declaring `exec.sandbox` runs inside it like any
|
|
274
|
+
// other: the same grants, the same walls. What it cannot have is the
|
|
275
|
+
// violation REPORT — that reads the trace after the child exits, and a
|
|
276
|
+
// server exits when the run tears it down. Enforced, not reported.
|
|
277
|
+
// (Until 2026-09-09 the block was accepted and silently ignored.)
|
|
278
|
+
let command = plainCommand
|
|
279
|
+
let bridgeTag: string | undefined
|
|
280
|
+
let signalChannel = false
|
|
281
|
+
// The empty files the request pre-created for a literal write grant; a
|
|
282
|
+
// server that never writes one gets it taken back when it exits, and a
|
|
283
|
+
// server that dies before readiness is told the directory spelling — the
|
|
284
|
+
// same trap and the same answer as a one-shot task's (sandbox-request.ts).
|
|
285
|
+
let placeholders: Placeholder[] = []
|
|
286
|
+
if (step.sandbox !== undefined) {
|
|
287
|
+
await args.armSandbox?.()
|
|
288
|
+
// A persistent task never declares `cache` (the loader refuses it), so
|
|
289
|
+
// it keeps the whole linked grant: no key of its can be stale.
|
|
290
|
+
const sb = await sandboxRequestFor(
|
|
291
|
+
node,
|
|
292
|
+
step.sandbox,
|
|
293
|
+
args.workspaceRoot,
|
|
294
|
+
undefined,
|
|
295
|
+
args.nestedProjectDirs,
|
|
296
|
+
)
|
|
297
|
+
placeholders = sb.placeholders
|
|
298
|
+
const wrapped = await wrapSandboxedCommand({
|
|
299
|
+
command: plainCommand,
|
|
300
|
+
cwd: node.projectDir,
|
|
301
|
+
env,
|
|
302
|
+
...sb.sandbox,
|
|
303
|
+
server: true,
|
|
304
|
+
})
|
|
305
|
+
command = wrapped.wrapped
|
|
306
|
+
bridgeTag = wrapped.tag
|
|
307
|
+
signalChannel = wrapped.forwardsSignals
|
|
308
|
+
}
|
|
214
309
|
const persistentOpts: Parameters<typeof runPersistent>[0] = {
|
|
215
|
-
command
|
|
216
|
-
step.persistent.readyWhen !== undefined
|
|
217
|
-
? step.command
|
|
218
|
-
: effectiveForwardArgs.length > 0
|
|
219
|
-
? step.command + ' ' + effectiveForwardArgs.map(shellQuote).join(' ')
|
|
220
|
-
: step.command,
|
|
310
|
+
command,
|
|
221
311
|
cwd: node.projectDir,
|
|
222
312
|
env,
|
|
223
313
|
onStdout: (chunk) => log.taskStdout(node, chunk),
|
|
224
314
|
onStderr: (chunk) => log.taskStderr(node, chunk),
|
|
225
315
|
...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
|
|
316
|
+
...(signalChannel ? { signalChannel } : {}),
|
|
226
317
|
}
|
|
227
318
|
if (step.persistent.readyWhen !== undefined) {
|
|
228
319
|
persistentOpts.readyWhen = step.persistent.readyWhen
|
|
@@ -235,25 +326,61 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
235
326
|
}
|
|
236
327
|
|
|
237
328
|
const spawn = runPersistent(persistentOpts)
|
|
329
|
+
// The host side of a port bridge lives exactly as long as the server:
|
|
330
|
+
// released on the child's exit, whether the run tore it down or it died.
|
|
331
|
+
// A placeholder the server never wrote goes back the same way.
|
|
332
|
+
// ONE sweep, shared by the exit handler and the readiness failure below
|
|
333
|
+
// — whichever asks second must get the SAME list. A second sweep cannot
|
|
334
|
+
// produce it: the first one's `rm` already happened, so the file is gone
|
|
335
|
+
// and `sweepPlaceholders` skips it. Collecting both lists and unioning
|
|
336
|
+
// them does not close that, because the union only covers an exit
|
|
337
|
+
// handler that FINISHED; one still between its `rm` and its return has
|
|
338
|
+
// published nothing yet, and the readiness path then reports no
|
|
339
|
+
// placeholder at all. The failure then says only "File exists", which is
|
|
340
|
+
// the message the hint exists to explain (seen under the gate's parallel
|
|
341
|
+
// load, and reproduced deterministically by delaying each side, item 450).
|
|
342
|
+
const sweptUntouched = placeholderSweeper(placeholders)
|
|
343
|
+
if (bridgeTag !== undefined || placeholders.length > 0) {
|
|
344
|
+
const tag = bridgeTag
|
|
345
|
+
const onExit = async (): Promise<void> => {
|
|
346
|
+
if (tag !== undefined) releaseBridges(tag)
|
|
347
|
+
await sweptUntouched()
|
|
348
|
+
}
|
|
349
|
+
// A spawn that failed has no child to wait on: release now, or the
|
|
350
|
+
// tag would hold the sandbox's reset for the rest of the process.
|
|
351
|
+
if (spawn.child === undefined) void onExit()
|
|
352
|
+
else void spawn.child.exited.then(onExit, onExit)
|
|
353
|
+
}
|
|
238
354
|
try {
|
|
239
355
|
await spawn.ready
|
|
240
356
|
} catch (err) {
|
|
241
357
|
const message = err instanceof Error ? err.message : String(err)
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
358
|
+
// The task's OWN stream, not the process's: the frame is where a
|
|
359
|
+
// reader looks for why a task failed, and a run with a custom logger
|
|
360
|
+
// (an embedder, the MCP server) never saw a bare stderr write at all.
|
|
361
|
+
log.taskStderr(node, `\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
|
|
362
|
+
// The server is dead or being torn down, so the sweep on its exit may
|
|
363
|
+
// already be running: ask the shared one rather than starting a second.
|
|
364
|
+
for (const p of await sweptUntouched()) {
|
|
365
|
+
log.taskStderr(node, `${untouchedPlaceholderLine(node.projectDir, p)}\n`)
|
|
366
|
+
}
|
|
367
|
+
// The reason rides the outcome (every label reads it), and a child that
|
|
368
|
+
// exited before ready keeps its own exit code rather than a made-up 1.
|
|
369
|
+
const ready = err instanceof PersistentReadyError ? err : undefined
|
|
246
370
|
return {
|
|
247
371
|
node,
|
|
248
372
|
status: 'failed',
|
|
249
|
-
exitCode: 1,
|
|
373
|
+
exitCode: ready?.exitCode ?? 1,
|
|
250
374
|
durationMs: spawn.readyMs(),
|
|
375
|
+
...(ready !== undefined ? { notReady: ready.reason } : {}),
|
|
251
376
|
wallclockStartNs,
|
|
252
377
|
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
253
378
|
}
|
|
254
379
|
}
|
|
255
380
|
|
|
256
381
|
args.persistentRegistry?.set(node.id, spawn.child)
|
|
382
|
+
forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
|
|
383
|
+
if (mayWriteFingerprint(node, args.workspaceRoot)) args.fingerprintWatch?.wrote()
|
|
257
384
|
return {
|
|
258
385
|
node,
|
|
259
386
|
status: 'success',
|
|
@@ -288,6 +415,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
288
415
|
const remoteOnly = args.remoteOnly === true
|
|
289
416
|
const willRead = !remoteOnly && cfgCacheable && (policy.localRead || policy.remoteRead)
|
|
290
417
|
const willWrite = !remoteOnly && cfgCacheable && (policy.localWrite || policy.remoteWrite)
|
|
418
|
+
// `willWrite` governs output hygiene (clean before exec); the save itself
|
|
419
|
+
// is also withheld from a task downstream of a failure (see `taintedUpstream`).
|
|
420
|
+
const willSave = willWrite && args.taintedUpstream !== true
|
|
291
421
|
|
|
292
422
|
// Retain only what is read back. `cache.save` below is the single consumer
|
|
293
423
|
// of `result.stdout`, and it runs only when this task will WRITE an entry;
|
|
@@ -299,7 +429,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
299
429
|
// Deferral is decided at plan time and only ever set for a remote-placed,
|
|
300
430
|
// eligibility-cleared task; it suppresses this machine's clean AND save.
|
|
301
431
|
const deferralRequested = args.download === 'deferred'
|
|
302
|
-
const capture: CaptureConfig = { stdout:
|
|
432
|
+
const capture: CaptureConfig = { stdout: willSave, stderr: false }
|
|
303
433
|
|
|
304
434
|
const outputs = cacheCfg?.outputs.files ?? []
|
|
305
435
|
const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
|
|
@@ -357,20 +487,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
357
487
|
// dependents fold it — and the machine's ambient state (node_modules as
|
|
358
488
|
// installed by the dev) serves dependents exactly as before the field.
|
|
359
489
|
if (args.remoteOnlyNoop === true) {
|
|
360
|
-
// Under a `--verify` proof the noop must be REPORTED, not silent: there
|
|
361
|
-
// is no execution to sandbox or re-run, so the proof cannot cover this
|
|
362
|
-
// task (the eleventh-wave residual — a silently unverified task reads
|
|
363
|
-
// as a green proof over ground the proof never touched).
|
|
364
|
-
const wantsProof =
|
|
365
|
-
args.verify !== undefined &&
|
|
366
|
-
(args.verify.inputs || args.verify.determinism || args.verify.fingerprint)
|
|
367
490
|
return {
|
|
368
491
|
node,
|
|
369
492
|
status: 'success',
|
|
370
493
|
exitCode: 0,
|
|
371
494
|
durationMs: 0,
|
|
372
495
|
hash,
|
|
373
|
-
...(wantsProof ? { verify: { kind: 'unverifiable-remote-only' as const } } : {}),
|
|
374
496
|
wallclockStartNs: taskStartNs,
|
|
375
497
|
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
376
498
|
}
|
|
@@ -382,6 +504,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
382
504
|
nestedProjectDirs: args.nestedProjectDirs,
|
|
383
505
|
}
|
|
384
506
|
const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
|
|
507
|
+
// An ADDITIVE task (its outputs overlap an upstream's, with the edge that
|
|
508
|
+
// orders them; item 588) does not clean by glob — the glob selects the
|
|
509
|
+
// upstream's files it adds beside. Its outputs are stamped once before
|
|
510
|
+
// the first attempt, and after a 0 exit its own set is what the run
|
|
511
|
+
// added or changed against that stamp. Stale files of its own from an
|
|
512
|
+
// earlier run are its command's to clean, as they are under Turbo.
|
|
513
|
+
const additive = (node.addsToOutputsOf?.length ?? 0) > 0
|
|
514
|
+
let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
|
|
385
515
|
|
|
386
516
|
// Cache lookup. On hit, time the user-perceived restore op
|
|
387
517
|
// (clean+restore+log-replay) — that's what the framed-block footer
|
|
@@ -391,17 +521,37 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
391
521
|
// to the run path as a known stable miss. No second cache.get.
|
|
392
522
|
if (willRead) {
|
|
393
523
|
const cacheOpStart = performance.now()
|
|
524
|
+
// A hit whose artifact was removed after the probe (a prune, another
|
|
525
|
+
// workspace's retention on a shared cache directory) is a miss. The
|
|
526
|
+
// restore has wiped the declared outputs by then; the run path cleans
|
|
527
|
+
// them again and the task writes them.
|
|
528
|
+
const restoreOrMiss = async (hit: CacheEntry): Promise<TaskOutcome | null> => {
|
|
529
|
+
try {
|
|
530
|
+
return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
|
|
531
|
+
} catch (err) {
|
|
532
|
+
if (!(err instanceof ArtifactVanishedError)) throw err
|
|
533
|
+
log.status(`[vx] ${node.id}: ${err.message} — running it`)
|
|
534
|
+
return null
|
|
535
|
+
}
|
|
536
|
+
}
|
|
394
537
|
if (preProbed !== undefined) {
|
|
395
538
|
if (preProbed.hit !== null) {
|
|
396
|
-
|
|
539
|
+
const restored = await restoreOrMiss(preProbed.hit)
|
|
540
|
+
if (restored !== null) return restored
|
|
541
|
+
// The up-front probe's hit may be restoring AHEAD of this task's
|
|
542
|
+
// deps, and a command run now would build from outputs they have
|
|
543
|
+
// not written yet and save that under the good key. The scheduler
|
|
544
|
+
// runs it again once they are done (admission drops the probe).
|
|
545
|
+
throw new RestoreDemoted(node.id)
|
|
397
546
|
}
|
|
398
547
|
// Confirmed stable miss — skip the probe, fall through to run.
|
|
399
|
-
} else {
|
|
548
|
+
} else if (!fingerprintMoved()) {
|
|
400
549
|
const endProbe = span('cache.get')
|
|
401
|
-
const hit = await cache.get(hash,
|
|
550
|
+
const hit = await cache.get(hash, getContext(node, step.command))
|
|
402
551
|
endProbe()
|
|
403
552
|
if (hit) {
|
|
404
|
-
|
|
553
|
+
const restored = await restoreOrMiss(hit)
|
|
554
|
+
if (restored !== null) return restored
|
|
405
555
|
}
|
|
406
556
|
}
|
|
407
557
|
}
|
|
@@ -431,19 +581,33 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
431
581
|
})
|
|
432
582
|
: undefined
|
|
433
583
|
const inputs: TaskInputs | undefined = described?.inputs
|
|
584
|
+
// A declared input set that resolves to NOTHING is the quiet stale hit:
|
|
585
|
+
// the key stops moving with this project's source and every later run
|
|
586
|
+
// is a hit. Almost always a glob written against the wrong directory
|
|
587
|
+
// (`lib/**` in a package that builds `src/`). Said once per miss, on the
|
|
588
|
+
// run's status line — the task itself succeeds.
|
|
589
|
+
const declaredInputs = [
|
|
590
|
+
...(cacheCfg?.inputs.files ?? []),
|
|
591
|
+
...(cacheCfg?.inputs.workspaceFiles ?? []),
|
|
592
|
+
]
|
|
593
|
+
if (inputs !== undefined && declaredInputs.length > 0 && inputs.files.length === 0) {
|
|
594
|
+
log.status(
|
|
595
|
+
`[vx] ${node.id}: cache.inputs matched no files (${declaredInputs.join(', ')}) — ` +
|
|
596
|
+
`the key will not change when this project's source does`,
|
|
597
|
+
)
|
|
598
|
+
}
|
|
434
599
|
|
|
435
|
-
// Sandbox is opt-in per task via `sandbox: {}` (or `
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
// task to prove input-completeness (a read outside the declared inputs is a
|
|
440
|
-
// proof failure, not a task failure — so it flags the RUN via the verdict,
|
|
441
|
-
// it does not flip the task's own exit code the way a user-declared sandbox
|
|
442
|
-
// violation does).
|
|
443
|
-
const userSandbox = cfg.sandbox !== undefined
|
|
444
|
-
const verifyInputs = args.verify?.inputs === true && willWrite
|
|
445
|
-
const useSandbox = userSandbox || verifyInputs
|
|
600
|
+
// Sandbox is opt-in per task via `exec.sandbox: {}` (or `{...}`) in the
|
|
601
|
+
// task config. No CLI flag, no workspace inheritance — the task config is
|
|
602
|
+
// the single source of truth, and a violation fails the task (below).
|
|
603
|
+
const userSandbox = cfg.exec?.sandbox !== undefined
|
|
446
604
|
let violations: SandboxViolation[] = []
|
|
605
|
+
// The empty files the sandbox request created for a literal write grant;
|
|
606
|
+
// swept after the attempt (`sweepPlaceholders`).
|
|
607
|
+
let placeholders: Placeholder[] = []
|
|
608
|
+
// The linked workspace packages the request withheld: the key does not
|
|
609
|
+
// answer for them.
|
|
610
|
+
let withheld: WithheldLink[] = []
|
|
447
611
|
|
|
448
612
|
// Cache miss path (or caching disabled), up to `1 + retries` attempts.
|
|
449
613
|
// Explicit config wins over the run-level `--retry` default, including
|
|
@@ -458,8 +622,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
458
622
|
// attempt's partial outputs can't leak into the next; gated on WRITES so a
|
|
459
623
|
// `--no-cache` run leaves the user's tree alone), spawn, and classify the
|
|
460
624
|
// exit (a sandbox violation on a 0 exit → fail; a timeout SIGTERM → the
|
|
461
|
-
// streamed notice). Shared by
|
|
462
|
-
//
|
|
625
|
+
// streamed notice). Shared by every attempt of the retry loop, so they
|
|
626
|
+
// can never drift on the spawn/clean/classify path.
|
|
463
627
|
async function runAttempt(): Promise<{
|
|
464
628
|
result: ExecuteResult
|
|
465
629
|
exitCode: number
|
|
@@ -468,7 +632,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
468
632
|
// tree would replace a stale build with nothing at all. The eligibility
|
|
469
633
|
// gate guarantees no key in this run can see what stays behind, and the
|
|
470
634
|
// summary names every deferred task so `dist/` is not silently stale.
|
|
471
|
-
if (willWrite && !deferralRequested && outputs.length > 0) {
|
|
635
|
+
if (additive && willWrite && !deferralRequested && outputs.length > 0) {
|
|
636
|
+
const endStamp = span('miss: stamp outputs')
|
|
637
|
+
stampedBefore ??= await stampOutputs(cleanArgs)
|
|
638
|
+
endStamp()
|
|
639
|
+
} else if (willWrite && !deferralRequested && outputs.length > 0) {
|
|
472
640
|
// Mark the wiped paths, exactly as the workspace twin below does. The
|
|
473
641
|
// git snapshot still lists them with their committed index OIDs, and
|
|
474
642
|
// resolveFiles SKIPS its existence probe for any path carrying a
|
|
@@ -498,13 +666,38 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
498
666
|
// no other trace. Rethrown unchanged — the scheduler still classifies it,
|
|
499
667
|
// and still prints it plainly for a UserError.
|
|
500
668
|
const endExec = span('miss: execute')
|
|
501
|
-
const res = await args.executor
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
669
|
+
const res = await args.executor
|
|
670
|
+
.execute(req)
|
|
671
|
+
.then((r: unknown) => {
|
|
672
|
+
assertExecuteResult(args.executor.name, node.id, r)
|
|
673
|
+
return r
|
|
674
|
+
})
|
|
675
|
+
.catch(async (err: unknown) => {
|
|
676
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
677
|
+
log.taskStderr(node, `${message}\n`)
|
|
678
|
+
await sweepPlaceholders(placeholders)
|
|
679
|
+
throw err
|
|
680
|
+
})
|
|
506
681
|
endExec()
|
|
507
682
|
violations = [...res.violations]
|
|
683
|
+
// A denial under a dependency the key does not answer for says only
|
|
684
|
+
// ENOENT; the line beside it names the package and how to key it. Only
|
|
685
|
+
// with a denial there, so it never reddens a pass.
|
|
686
|
+
for (const w of reachedWithheld(withheld, violations)) {
|
|
687
|
+
violations.push({ timestamp: new Date(), line: withheldLinkLine(node.id, w) })
|
|
688
|
+
}
|
|
689
|
+
// A placeholder the task never wrote is not its output: take it back
|
|
690
|
+
// before the outputs are collected. On a failure it is also the one
|
|
691
|
+
// clue to a grant that meant a directory — say so beside the failure.
|
|
692
|
+
const untouched = await sweepPlaceholders(placeholders)
|
|
693
|
+
if (res.exitCode !== 0 && violations.length === 0) {
|
|
694
|
+
for (const p of untouched) {
|
|
695
|
+
violations.push({
|
|
696
|
+
timestamp: new Date(),
|
|
697
|
+
line: untouchedPlaceholderLine(node.projectDir, p),
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
}
|
|
508
701
|
// Fail-on-violation, on BOTH platforms: macOS reads SRT's structured
|
|
509
702
|
// violation store, Linux parses the strace log the sandboxed spawn
|
|
510
703
|
// writes. (This comment used to claim Linux violations are "always 0"
|
|
@@ -512,12 +705,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
512
705
|
// before the strace pass shipped, false since, and the branch below is
|
|
513
706
|
// live on Linux.) Violations surface via `TaskOutcome.sandboxViolationLines`.
|
|
514
707
|
let code = res.exitCode
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
708
|
+
// ANY violation fails the task (owner, 2026-09-05). A sandboxed task
|
|
709
|
+
// declares what it touches; a denial means it touched something else,
|
|
710
|
+
// and an artifact built while reading — or failing to read — something
|
|
711
|
+
// the cache key never folded is not a safe thing to replay. A task that
|
|
712
|
+
// survives the denial is the dangerous case, not the harmless one:
|
|
713
|
+
// that is precisely the run that succeeds and caches a wrong result.
|
|
520
714
|
if (userSandbox && violations.length > 0 && code === 0) code = 1
|
|
715
|
+
// A declared sandbox fails the task on any violation — that is its
|
|
716
|
+
// whole contract. `userSandbox` is the only way a task is sandboxed,
|
|
717
|
+
// so this reads as "sandboxed and it tripped".
|
|
521
718
|
// A child we SIGTERMed for exceeding the timeout is a genuine failure —
|
|
522
719
|
// stream a clear line so the 143 exit reads as a timeout.
|
|
523
720
|
if (res.timedOut) {
|
|
@@ -526,11 +723,24 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
526
723
|
// still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
|
|
527
724
|
// pattern). Without this a timed-out task is classified `success` and its
|
|
528
725
|
// PARTIAL outputs are cached + replayed forever — the run even reports
|
|
529
|
-
// green. The timeout is a real, retryable failure
|
|
530
|
-
//
|
|
531
|
-
// reports 143, so this only rewrites the trap-exit-0 case.
|
|
726
|
+
// green. The timeout is a real, retryable failure; a genuinely-killed
|
|
727
|
+
// child already reports 143, so this only rewrites the trap-exit-0 case.
|
|
532
728
|
if (code === 0) code = signalExitCode('SIGTERM')
|
|
533
729
|
}
|
|
730
|
+
// The shell's 127 and 126 name the word and nothing about why — the
|
|
731
|
+
// PATH vx built, or a `#!` line the file itself carries (items 257,
|
|
732
|
+
// 258) — and an exit above 128 is a signal's number and nothing
|
|
733
|
+
// about what sent it (259). One frame line names the rule.
|
|
734
|
+
if (!res.timedOut) {
|
|
735
|
+
const verdict = shellVerdict({
|
|
736
|
+
code,
|
|
737
|
+
command: step.command,
|
|
738
|
+
cwd: node.projectDir,
|
|
739
|
+
bins: taskBinDirs(node, args.workspaceRoot),
|
|
740
|
+
signal: res.signal,
|
|
741
|
+
})
|
|
742
|
+
if (verdict !== undefined) log.taskStderr(node, `\n${verdict}\n`)
|
|
743
|
+
}
|
|
534
744
|
return { result: res, exitCode: code }
|
|
535
745
|
}
|
|
536
746
|
|
|
@@ -558,19 +768,33 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
558
768
|
}
|
|
559
769
|
}
|
|
560
770
|
|
|
771
|
+
// Pass or fail, the command may have written where its project's
|
|
772
|
+
// run-start facts describe; a remote executor wrote on its own disk.
|
|
773
|
+
const writeReach =
|
|
774
|
+
args.executor.remote === true ? 'none' : undeclaredWriteReach(node, args.workspaceRoot)
|
|
775
|
+
const writesFingerprint =
|
|
776
|
+
args.executor.remote !== true && mayWriteFingerprint(node, args.workspaceRoot)
|
|
561
777
|
for (;;) {
|
|
562
778
|
attempt++
|
|
563
779
|
const a = await runAttempt()
|
|
780
|
+
forgetUndeclaredWrites(args, writeReach)
|
|
781
|
+
if (writesFingerprint) args.fingerprintWatch?.wrote()
|
|
564
782
|
result = a.result
|
|
565
783
|
effectiveExitCode = a.exitCode
|
|
566
784
|
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
|
|
785
|
+
// An attempt that ended in a shutdown never finished on its own terms —
|
|
786
|
+
// it is aborted, so it is neither cached, counted, shown, nor RETRIED.
|
|
787
|
+
// Two ways to know: the child died of SIGINT / SIGTERM (Ctrl-C, or a
|
|
788
|
+
// `kill` vx never saw; a timeout also SIGTERMs, but `timedOut` marks it
|
|
789
|
+
// as our own deadline, a real and retryable failure), or the RUN is
|
|
790
|
+
// stopping. The child alone was not enough: one that traps the
|
|
791
|
+
// forwarded SIGINT and exits 0 was cached as a success with its partial
|
|
792
|
+
// outputs, one that exits 1 was retried after Ctrl-C, and one SIGKILLed
|
|
793
|
+
// at the end of the grace read as an OOM failure (item 962).
|
|
794
|
+
const shutdown =
|
|
795
|
+
args.stopSignal?.aborted === true ||
|
|
796
|
+
((result.signal === 'SIGINT' || result.signal === 'SIGTERM') && !result.timedOut)
|
|
797
|
+
if (shutdown) {
|
|
574
798
|
return {
|
|
575
799
|
node,
|
|
576
800
|
status: 'aborted',
|
|
@@ -585,7 +809,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
585
809
|
if (effectiveExitCode === 0 || attempt >= maxAttempts) break
|
|
586
810
|
log.taskStderr(
|
|
587
811
|
node,
|
|
588
|
-
`vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after exit ${effectiveExitCode}\n`,
|
|
812
|
+
`vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${result.timedOut === true ? 'a timeout' : `exit ${effectiveExitCode}`}\n`,
|
|
589
813
|
)
|
|
590
814
|
}
|
|
591
815
|
|
|
@@ -612,63 +836,23 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
612
836
|
...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
|
|
613
837
|
outputs: { files: outputs, workspaceFiles: wsOutputs },
|
|
614
838
|
}
|
|
615
|
-
if (!
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
ownWorkspaceOutputs: wsOutputs,
|
|
628
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
629
|
-
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
630
|
-
...(args.hashCache !== undefined
|
|
631
|
-
? {
|
|
632
|
-
runtimeCache: args.hashCache.runtime,
|
|
633
|
-
workspaceRuntimeCache: args.hashCache.workspaceRuntime,
|
|
634
|
-
}
|
|
635
|
-
: {}),
|
|
636
|
-
})
|
|
637
|
-
const baseAllowWrite = [
|
|
638
|
-
...outputs.map((g) => path.join(node.projectDir, staticPrefix(g))),
|
|
639
|
-
// Workspace outputs anchor their write prefixes at the root.
|
|
640
|
-
...wsOutputs.map((g) => path.join(args.workspaceRoot, staticPrefix(g))),
|
|
641
|
-
]
|
|
642
|
-
// bwrap can't --bind a non-existent host path; the bind silently
|
|
643
|
-
// becomes a no-op (or a tmpfs that evaporates on exit), and writes
|
|
644
|
-
// to the path appear to succeed inside the sandbox but never land
|
|
645
|
-
// on the host. Pre-create every output path so the binds resolve
|
|
646
|
-
// to real fs entries: globbed outputs (`dist/**`) become empty
|
|
647
|
-
// dirs; literal outputs (`out.txt`) become empty files.
|
|
648
|
-
await prepareOutputsForBind(node.projectDir, outputs)
|
|
649
|
-
await prepareOutputsForBind(args.workspaceRoot, wsOutputs)
|
|
650
|
-
// Output paths are read+write — a task that declares `dist/**` as
|
|
651
|
-
// output expects to read what it just wrote (e.g. `touch dist/x`
|
|
652
|
-
// stats the file; `tsc --incremental` re-reads .tsbuildinfo).
|
|
653
|
-
return {
|
|
654
|
-
...base,
|
|
655
|
-
sandbox: {
|
|
656
|
-
baseAllowRead: [...resolved.files, ...baseAllowWrite],
|
|
657
|
-
baseAllowWrite,
|
|
658
|
-
baseDenyRead: [args.workspaceRoot],
|
|
659
|
-
config: resolveSandboxConfig(cfg.sandbox ?? {}, node.projectDir),
|
|
660
|
-
settleOnCleanExit: verifyInputs,
|
|
661
|
-
},
|
|
662
|
-
}
|
|
839
|
+
if (!userSandbox) return base
|
|
840
|
+
await args.armSandbox?.()
|
|
841
|
+
const sb = await sandboxRequestFor(
|
|
842
|
+
node,
|
|
843
|
+
step.sandbox!,
|
|
844
|
+
args.workspaceRoot,
|
|
845
|
+
cfgCacheable ? args.keyedProjects(node) : undefined,
|
|
846
|
+
args.nestedProjectDirs,
|
|
847
|
+
)
|
|
848
|
+
placeholders = sb.placeholders
|
|
849
|
+
withheld = sb.withheld
|
|
850
|
+
return { ...base, sandbox: sb.sandbox }
|
|
663
851
|
}
|
|
664
852
|
|
|
665
853
|
const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
666
854
|
|
|
667
|
-
|
|
668
|
-
// captured inside the save block while the tree is attempt-1's.
|
|
669
|
-
let verifyFp1: Map<string, string> | undefined
|
|
670
|
-
|
|
671
|
-
if (effectiveExitCode === 0 && willWrite && deferralRequested) {
|
|
855
|
+
if (effectiveExitCode === 0 && willSave && deferralRequested) {
|
|
672
856
|
// The outputs never landed here: no artifact, no rows. A partial local
|
|
673
857
|
// record (a row with no artifact) is exactly the corrupt-entry shape
|
|
674
858
|
// `restoreOutputs` refuses, so writing none is the only clean answer.
|
|
@@ -684,216 +868,110 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
684
868
|
command: step.command,
|
|
685
869
|
durationMs: result.durationMs,
|
|
686
870
|
stdout: result.stdout,
|
|
871
|
+
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
872
|
+
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
687
873
|
},
|
|
688
874
|
})
|
|
689
875
|
}
|
|
690
|
-
} else if (effectiveExitCode === 0 &&
|
|
691
|
-
const
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
workspaceRoot: args.workspaceRoot,
|
|
699
|
-
outputs: wsOutputs,
|
|
700
|
-
})
|
|
701
|
-
endResolve()
|
|
702
|
-
const endSave = span('miss: save')
|
|
703
|
-
// Tier-3 input fingerprint: the digest rows captured by the pre-exec
|
|
704
|
-
// describe above, persisted with the entry inside `cache.save`'s
|
|
705
|
-
// transaction. Miss path only — the warm/hit path never reaches here.
|
|
706
|
-
await cache.save({
|
|
876
|
+
} else if (effectiveExitCode === 0 && willSave && (await keyStillTrue())) {
|
|
877
|
+
const ownOutputFiles =
|
|
878
|
+
additive && stampedBefore !== undefined
|
|
879
|
+
? await ownOutputsSince(cleanArgs, stampedBefore)
|
|
880
|
+
: undefined
|
|
881
|
+
const { landed } = await saveMiss({
|
|
882
|
+
...(ownOutputFiles !== undefined ? { ownOutputFiles } : {}),
|
|
883
|
+
node,
|
|
707
884
|
hash,
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
885
|
+
cache,
|
|
886
|
+
log,
|
|
887
|
+
workspaceRoot: args.workspaceRoot,
|
|
888
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
889
|
+
gitFilesCache: args.gitFilesCache,
|
|
890
|
+
outputs,
|
|
891
|
+
wsOutputs,
|
|
892
|
+
captured,
|
|
893
|
+
command: step.command,
|
|
894
|
+
durationMs: result.durationMs,
|
|
895
|
+
stdout: result.stdout,
|
|
896
|
+
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
897
|
+
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
898
|
+
outputDirSnapshots: args.outputDirSnapshots,
|
|
899
|
+
deferSave: args.deferSave,
|
|
723
900
|
})
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
verifyFp1 = await hashOutputTree(
|
|
748
|
-
outputRefs(node.projectDir, outputFiles, args.workspaceRoot, wsOutputFiles),
|
|
749
|
-
)
|
|
750
|
-
}
|
|
751
|
-
// This task just wrote outputs to the project's tree. Record the
|
|
752
|
-
// exact declared-output paths as changed (same as the cache-hit
|
|
753
|
-
// restore path) instead of dropping the whole snapshot: a downstream
|
|
754
|
-
// same-project task then re-spawns git ONLY when its input globs can
|
|
755
|
-
// actually see one of these paths. On a 1000-package cold run this
|
|
756
|
-
// removes ~one synchronous `git ls-files` spawn per project (the
|
|
757
|
-
// single largest cold-run cost — 22% of CPU in profiling). Contract:
|
|
758
|
-
// outputs must be declared — an executed task that writes files
|
|
759
|
-
// outside `cache.outputs.files` which a same-project downstream task
|
|
760
|
-
// reads is undeclared behavior (the restore path already assumes it).
|
|
761
|
-
if (outputFiles.length > 0) {
|
|
762
|
-
args.gitFilesCache?.markOutputsChanged(
|
|
763
|
-
node.projectDir,
|
|
764
|
-
outputFiles.map((p) => path.relative(node.projectDir, p).split(path.sep).join('/')),
|
|
765
|
-
)
|
|
766
|
-
}
|
|
767
|
-
// Declared workspace outputs may have landed inside OTHER
|
|
768
|
-
// projects' dirs (no-boundary escape hatch) — mark the exact
|
|
769
|
-
// paths against every partition that can see them.
|
|
770
|
-
if (wsOutputFiles.length > 0) {
|
|
771
|
-
args.gitFilesCache?.markWorkspaceOutputsChanged(
|
|
772
|
-
args.workspaceRoot,
|
|
773
|
-
wsOutputFiles.map((f) => path.relative(args.workspaceRoot, f).split(path.sep).join('/')),
|
|
774
|
-
)
|
|
775
|
-
}
|
|
776
|
-
// The workspace-wide partition (when one exists) spans this
|
|
777
|
-
// project's subtree, so it inherits the same "undeclared writes
|
|
778
|
-
// are only visible to git" rule as the project drop above.
|
|
779
|
-
if (outputFiles.length + wsOutputFiles.length > 0) {
|
|
780
|
-
args.gitFilesCache?.invalidateWorkspacePartition()
|
|
901
|
+
args.deferredSaves?.set(node.id, landed)
|
|
902
|
+
} else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants !== true) {
|
|
903
|
+
// Ran here and saves nothing — failed, a read-only policy, a tainted
|
|
904
|
+
// upstream, a key that no longer held — yet it wrote what a save would
|
|
905
|
+
// have marked, and may have rewritten an input the re-check before a
|
|
906
|
+
// save catches. A same-run reader keyed from the snapshot's OIDs for
|
|
907
|
+
// either replayed the bytes from before the command (item 750). A
|
|
908
|
+
// withheld save already dropped the project; this finds the same move
|
|
909
|
+
// again and drops it again.
|
|
910
|
+
const endCheck = span('miss: recheck inputs')
|
|
911
|
+
const moved = await movedSinceKey()
|
|
912
|
+
endCheck()
|
|
913
|
+
if (moved !== undefined) {
|
|
914
|
+
forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
|
|
915
|
+
} else {
|
|
916
|
+
await markUnsaved({
|
|
917
|
+
node,
|
|
918
|
+
workspaceRoot: args.workspaceRoot,
|
|
919
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
920
|
+
gitFilesCache: args.gitFilesCache,
|
|
921
|
+
outputs,
|
|
922
|
+
wsOutputs,
|
|
923
|
+
})
|
|
781
924
|
}
|
|
782
925
|
}
|
|
783
926
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
927
|
+
/**
|
|
928
|
+
* The workspace fingerprint every key here folded has moved (a task
|
|
929
|
+
* rewrote the lockfile), said once per run.
|
|
930
|
+
*/
|
|
931
|
+
function fingerprintMoved(): boolean {
|
|
932
|
+
if (args.fingerprintWatch?.moved() === undefined) return false
|
|
933
|
+
args.fingerprintWatch.say(log)
|
|
934
|
+
return true
|
|
935
|
+
}
|
|
790
936
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
verify = {
|
|
801
|
-
kind: 'undeclared-inputs',
|
|
802
|
-
paths: undeclaredInputPaths(finalViolations, args.workspaceRoot),
|
|
803
|
-
}
|
|
804
|
-
} else if (effectiveExitCode === 0 && !args.verify?.determinism) {
|
|
805
|
-
// Inputs-only run, inputs complete. (`=all` falls through to the stronger
|
|
806
|
-
// determinism verdict below.)
|
|
807
|
-
verify = { kind: 'proven-complete' }
|
|
808
|
-
}
|
|
937
|
+
/**
|
|
938
|
+
* The input that no longer holds what the key folded: `null` when the
|
|
939
|
+
* key re-derived just before the command already differed (the file
|
|
940
|
+
* unnamed), undefined when none moved.
|
|
941
|
+
*/
|
|
942
|
+
async function movedSinceKey(): Promise<string | null | undefined> {
|
|
943
|
+
return described!.hash !== hash
|
|
944
|
+
? null
|
|
945
|
+
: await movedInput(described!.facts, cache, described!.describedAt)
|
|
809
946
|
}
|
|
810
947
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
) {
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
verify = { kind: 'rerun-failed', exitCode: rerun.exitCode }
|
|
836
|
-
} else {
|
|
837
|
-
const fp2 = await hashOutputTree(
|
|
838
|
-
outputRefs(
|
|
839
|
-
node.projectDir,
|
|
840
|
-
await resolveOutputs({
|
|
841
|
-
projectDir: node.projectDir,
|
|
842
|
-
outputs,
|
|
843
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
844
|
-
}),
|
|
845
|
-
args.workspaceRoot,
|
|
846
|
-
await resolveWorkspaceOutputs({
|
|
847
|
-
workspaceRoot: args.workspaceRoot,
|
|
848
|
-
outputs: wsOutputs,
|
|
849
|
-
}),
|
|
850
|
-
),
|
|
851
|
-
)
|
|
852
|
-
verify = classifyDeterminism(
|
|
853
|
-
diffOutputTrees(verifyFp1, fp2),
|
|
854
|
-
args.verify.allow.has(node.id),
|
|
855
|
-
)
|
|
856
|
-
}
|
|
857
|
-
// Put attempt 1's saved bytes back so disk == the cached artifact
|
|
858
|
-
// REGARDLESS of the verdict. Mirror the restoreHit sequence exactly:
|
|
859
|
-
// clean the declared globs FIRST (a nondeterministic re-run may have
|
|
860
|
-
// written a stray filename the restore would never overwrite), then
|
|
861
|
-
// restore, then record the exact changed paths so a downstream
|
|
862
|
-
// same-project task's git snapshot can't go stale. fp1's keys ARE
|
|
863
|
-
// attempt 1's saved rels (bare project rels + workspace-outputs/-
|
|
864
|
-
// prefixed ws rels — the artifact namespace).
|
|
865
|
-
let cleanedRels: string[] = []
|
|
866
|
-
let cleanedWsRels: string[] = []
|
|
867
|
-
if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
|
|
868
|
-
if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
|
|
869
|
-
await cache.restoreOutputs(
|
|
870
|
-
hash,
|
|
871
|
-
node.projectDir,
|
|
872
|
-
wsOutputs.length > 0 ? args.workspaceRoot : undefined,
|
|
873
|
-
)
|
|
874
|
-
const savedKeys = [...verifyFp1.keys()]
|
|
875
|
-
if (outputs.length > 0) {
|
|
876
|
-
args.gitFilesCache?.markOutputsChanged(node.projectDir, [
|
|
877
|
-
...cleanedRels,
|
|
878
|
-
...savedKeys.filter((k) => !k.startsWith(WORKSPACE_OUTPUT_PREFIX)),
|
|
879
|
-
])
|
|
880
|
-
}
|
|
881
|
-
if (wsOutputs.length > 0) {
|
|
882
|
-
args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
|
|
883
|
-
...cleanedWsRels,
|
|
884
|
-
...savedKeys
|
|
885
|
-
.filter((k) => k.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
886
|
-
.map((k) => k.slice(WORKSPACE_OUTPUT_PREFIX.length)),
|
|
887
|
-
])
|
|
888
|
-
}
|
|
889
|
-
}
|
|
948
|
+
/**
|
|
949
|
+
* Does the key still describe the inputs the command ran over? It was
|
|
950
|
+
* taken before the command — up front, or before `describeTaskInputs` —
|
|
951
|
+
* and a save files the outputs under it. A user's edit mid-run, or a
|
|
952
|
+
* task rewriting its own input (a formatter), saved bytes built from one
|
|
953
|
+
* state under the key of another: restore the old state and the next run
|
|
954
|
+
* replayed them as up-to-date (turborepo#10111, #1146, item 743). The
|
|
955
|
+
* result stands; only the entry is withheld, and the facts about the
|
|
956
|
+
* project go, since something wrote there. A lockfile a task rewrote
|
|
957
|
+
* withholds it too: the key folded the old one.
|
|
958
|
+
*/
|
|
959
|
+
async function keyStillTrue(): Promise<boolean> {
|
|
960
|
+
if (fingerprintMoved()) return false
|
|
961
|
+
const endCheck = span('miss: recheck inputs')
|
|
962
|
+
const moved = await movedSinceKey()
|
|
963
|
+
endCheck()
|
|
964
|
+
if (moved === undefined) return true
|
|
965
|
+
const what = moved === null ? 'its inputs' : `\`${relPosix(args.workspaceRoot, moved)}\``
|
|
966
|
+
log.status(
|
|
967
|
+
`[vx] ${node.id}: ${what} changed after its key was taken — the result stands, ` +
|
|
968
|
+
`but is not saved under a key that no longer describes it`,
|
|
969
|
+
)
|
|
970
|
+
forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
|
|
971
|
+
return false
|
|
890
972
|
}
|
|
891
973
|
|
|
892
|
-
|
|
893
|
-
// the determinism re-run above never clobbers verifyFp1) so a connected
|
|
894
|
-
// serve can pair it with other platforms' reports for the same cache key.
|
|
895
|
-
const outputFp =
|
|
896
|
-
args.verify?.fingerprint && verifyFp1 !== undefined ? foldFingerprint(verifyFp1) : undefined
|
|
974
|
+
const finalViolations = violations
|
|
897
975
|
|
|
898
976
|
return {
|
|
899
977
|
node,
|
|
@@ -902,6 +980,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
902
980
|
durationMs: result.durationMs,
|
|
903
981
|
hash,
|
|
904
982
|
...(attempt > 1 ? { attempts: attempt } : {}),
|
|
983
|
+
...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
|
|
905
984
|
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
906
985
|
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
907
986
|
...(result.where !== undefined ? { where: result.where } : {}),
|
|
@@ -910,8 +989,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
910
989
|
: {}),
|
|
911
990
|
wallclockStartNs,
|
|
912
991
|
wallclockEndNs,
|
|
913
|
-
...(verify !== undefined ? { verify } : {}),
|
|
914
|
-
...(outputFp !== undefined ? { outputFp } : {}),
|
|
915
992
|
...(finalViolations.length > 0
|
|
916
993
|
? {
|
|
917
994
|
sandboxViolations: finalViolations.length,
|
|
@@ -921,227 +998,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
921
998
|
}
|
|
922
999
|
}
|
|
923
1000
|
|
|
924
|
-
export interface RestoreHitArgs {
|
|
925
|
-
args: ExecuteArgs
|
|
926
|
-
hash: string
|
|
927
|
-
hit: CacheEntry
|
|
928
|
-
/** `performance.now()` when the cache op (probe) started — the
|
|
929
|
-
* user-perceived restore duration is measured from here. */
|
|
930
|
-
cacheOpStart: number
|
|
931
|
-
/** ns offset from run start for the outcome's wallclock window. */
|
|
932
|
-
taskStartNs: bigint
|
|
933
|
-
}
|
|
934
|
-
|
|
935
1001
|
/**
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
* two that could drift.
|
|
1002
|
+
* Drop the run's facts about files a task may have written without
|
|
1003
|
+
* declaring them (`undeclaredWriteReach`): the project's git snapshot and
|
|
1004
|
+
* its index OIDs, and its `package.json` digest. The next reader
|
|
1005
|
+
* re-enumerates the project (one `git ls-files`) and hashes its files by
|
|
1006
|
+
* content; the per-declaration file-list memos are keyed on the snapshot
|
|
1007
|
+
* array, so they miss with it (item 743).
|
|
943
1008
|
*/
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
|
|
951
|
-
const dirPrefixes = wholeSubtreePrefixes(outputs)
|
|
952
|
-
const cleanArgs = {
|
|
953
|
-
projectDir: node.projectDir,
|
|
954
|
-
outputs,
|
|
955
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
956
|
-
}
|
|
957
|
-
const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
|
|
958
|
-
|
|
959
|
-
// "Tree is already current" short-circuit — skip cleanOutputs
|
|
960
|
-
// + restoreOutputs when the on-disk state matches what this
|
|
961
|
-
// entry recorded at save time. Integrity-preserving: we
|
|
962
|
-
// require BOTH that the output-glob walk yields exactly the
|
|
963
|
-
// expected paths (no strays, no missing) AND that every file's
|
|
964
|
-
// (size, mode, mtime) matches the stored fingerprint. Any
|
|
965
|
-
// divergence falls through to a real clean + restore.
|
|
966
|
-
//
|
|
967
|
-
// The output-file fingerprints can't be batch-loaded at
|
|
968
|
-
// prepareRun time — non-leaf task hashes depend on upstream
|
|
969
|
-
// outputs that haven't been written yet, so hashes are
|
|
970
|
-
// necessarily computed mid-run. We do one extra SELECT per
|
|
971
|
-
// cache hit here. Still beats reading the manifest from the
|
|
972
|
-
// tar (decompress + parse) at the same point.
|
|
973
|
-
let skipRestore = false
|
|
974
|
-
if (anyOutputs) {
|
|
975
|
-
const endRows = span('output rows')
|
|
976
|
-
const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
|
|
977
|
-
endRows()
|
|
978
|
-
if (expected.length > 0) {
|
|
979
|
-
// Two namespaces in the rows: bare rels are project outputs,
|
|
980
|
-
// `workspace-outputs/<rel>` rows anchor at the workspace root.
|
|
981
|
-
const projExpected = expected.filter((e) => !e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
982
|
-
const wsExpected = expected
|
|
983
|
-
.filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
984
|
-
.map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
|
|
985
|
-
// The directory short-circuit: for whole-subtree globs, unchanged
|
|
986
|
-
// mtimes on every directory recorded at the last save/restore prove
|
|
987
|
-
// the output SET is unchanged (a file added or removed anywhere the
|
|
988
|
-
// glob could see bumps a recorded directory), so the walk that cost
|
|
989
|
-
// 0.36 ms per warm hit is replaced by a few stats. The per-file
|
|
990
|
-
// fingerprint check below still runs; only the enumeration is skipped.
|
|
991
|
-
let setKnown = false
|
|
992
|
-
if (dirPrefixes !== null && wsOutputs.length === 0 && args.cache.outputDirsCurrent) {
|
|
993
|
-
const endDirs = span('output dirs')
|
|
994
|
-
// Loaded with the entry (batched by getMany, or with the lazy get).
|
|
995
|
-
const dirRows = hit.outputDirRows ?? []
|
|
996
|
-
const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
|
|
997
|
-
setKnown = covers && (await args.cache.outputDirsCurrent!(node.projectDir, dirRows))
|
|
998
|
-
endDirs()
|
|
999
|
-
}
|
|
1000
|
-
let actualAbs: string[]
|
|
1001
|
-
let actualWsAbs: string[]
|
|
1002
|
-
if (setKnown) {
|
|
1003
|
-
actualAbs = projExpected.map((e) => path.join(node.projectDir, e.path))
|
|
1004
|
-
actualWsAbs = []
|
|
1005
|
-
} else {
|
|
1006
|
-
const endGlob = span('output glob')
|
|
1007
|
-
actualAbs = await resolveOutputs({
|
|
1008
|
-
projectDir: node.projectDir,
|
|
1009
|
-
outputs,
|
|
1010
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
1011
|
-
})
|
|
1012
|
-
actualWsAbs = await resolveWorkspaceOutputs({
|
|
1013
|
-
workspaceRoot: args.workspaceRoot,
|
|
1014
|
-
outputs: wsOutputs,
|
|
1015
|
-
})
|
|
1016
|
-
endGlob()
|
|
1017
|
-
}
|
|
1018
|
-
const setsMatch = (
|
|
1019
|
-
actual: readonly string[],
|
|
1020
|
-
exp: ReadonlyArray<{ path: string }>,
|
|
1021
|
-
): boolean => {
|
|
1022
|
-
const expSet = new Set(exp.map((e) => e.path))
|
|
1023
|
-
return actual.length === expSet.size && actual.every((r) => expSet.has(r))
|
|
1024
|
-
}
|
|
1025
|
-
const actualRels = actualAbs.map((p) =>
|
|
1026
|
-
path.relative(node.projectDir, p).split(path.sep).join('/'),
|
|
1027
|
-
)
|
|
1028
|
-
const actualWsRels = actualWsAbs.map((p) =>
|
|
1029
|
-
path.relative(args.workspaceRoot, p).split(path.sep).join('/'),
|
|
1030
|
-
)
|
|
1031
|
-
if (setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)) {
|
|
1032
|
-
const endStat = span('output stat')
|
|
1033
|
-
skipRestore =
|
|
1034
|
-
(await args.cache.isOutputsCurrent(node.projectDir, projExpected)) &&
|
|
1035
|
-
(await args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected))
|
|
1036
|
-
endStat()
|
|
1037
|
-
// The walk ran and proved the tree current (rows absent, or a
|
|
1038
|
-
// directory had moved — a benign touch): snapshot the directories
|
|
1039
|
-
// so the next hit skips the walk.
|
|
1040
|
-
if (skipRestore && !setKnown && dirPrefixes !== null) {
|
|
1041
|
-
await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
if (!skipRestore) {
|
|
1047
|
-
let cleanedRels: string[] = []
|
|
1048
|
-
let cleanedWsRels: string[] = []
|
|
1049
|
-
if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
|
|
1050
|
-
if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
|
|
1051
|
-
await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
|
|
1052
|
-
if (dirPrefixes !== null) {
|
|
1053
|
-
await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
|
|
1054
|
-
}
|
|
1055
|
-
// Restored outputs changed the project's tree — but on this
|
|
1056
|
-
// path we know the EXACT changed paths (wiped declared
|
|
1057
|
-
// outputs + the artifact's files). Record them instead of
|
|
1058
|
-
// dropping the snapshot; downstream same-project tasks only
|
|
1059
|
-
// re-spawn git when their input globs can actually see one of
|
|
1060
|
-
// these paths. The cache-miss save path keeps the
|
|
1061
|
-
// unconditional drop (an executed task may write undeclared
|
|
1062
|
-
// files only git can see).
|
|
1063
|
-
if (outputs.length > 0) {
|
|
1064
|
-
args.gitFilesCache?.markOutputsChanged(node.projectDir, [
|
|
1065
|
-
...cleanedRels,
|
|
1066
|
-
...hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
|
|
1067
|
-
])
|
|
1068
|
-
}
|
|
1069
|
-
if (wsOutputs.length > 0) {
|
|
1070
|
-
args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
|
|
1071
|
-
...cleanedWsRels,
|
|
1072
|
-
...hit.outputFiles
|
|
1073
|
-
.filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
1074
|
-
.map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length)),
|
|
1075
|
-
])
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
if (hit.stdout) log.taskStdout(node, hit.stdout)
|
|
1079
|
-
const status =
|
|
1080
|
-
hit.exitCode !== 0 ? 'failed' : hit.source === 'remote' ? 'cache-hit-remote' : 'cache-hit'
|
|
1081
|
-
// `restored` distinguishes "we just wrote files to disk" from
|
|
1082
|
-
// "disk already matched the cached snapshot". Drives the
|
|
1083
|
-
// "up-to-date" vs "local-cache" / "remote-cache" label in the
|
|
1084
|
-
// framed block. Only meaningful when at least one output was
|
|
1085
|
-
// declared — no-outputs tasks never materialize anything, so
|
|
1086
|
-
// they're vacuously up-to-date.
|
|
1087
|
-
const restored = !skipRestore && anyOutputs
|
|
1088
|
-
return {
|
|
1089
|
-
node,
|
|
1090
|
-
status,
|
|
1091
|
-
exitCode: hit.exitCode,
|
|
1092
|
-
// What THIS run spent: probe + restore. NOT the stored exec time — see
|
|
1093
|
-
// `storedDurationMs`, which is what the hit skipped.
|
|
1094
|
-
durationMs: Math.round(performance.now() - cacheOpStart),
|
|
1095
|
-
storedDurationMs: hit.durationMs,
|
|
1096
|
-
hash,
|
|
1097
|
-
restored,
|
|
1098
|
-
// Under `--verify`, a cache hit didn't execute this run — so it wasn't
|
|
1099
|
-
// proven. Flag it `not-verified` (use `--force` to re-execute + verify).
|
|
1100
|
-
...(args.verify?.determinism || args.verify?.inputs
|
|
1101
|
-
? { verify: { kind: 'not-verified' as const } }
|
|
1102
|
-
: {}),
|
|
1103
|
-
wallclockStartNs: taskStartNs,
|
|
1104
|
-
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
1009
|
+
function forgetUndeclaredWrites(args: ExecuteArgs, reach: 'none' | 'project' | 'workspace'): void {
|
|
1010
|
+
if (reach === 'none') return
|
|
1011
|
+
if (reach === 'workspace') {
|
|
1012
|
+
args.gitFilesCache?.clear()
|
|
1013
|
+
args.hashCache?.packageJson.clear()
|
|
1014
|
+
return
|
|
1105
1015
|
}
|
|
1016
|
+
args.gitFilesCache?.delete(args.node.projectDir)
|
|
1017
|
+
args.gitFilesCache?.invalidateWorkspacePartition()
|
|
1018
|
+
args.hashCache?.packageJson.delete(args.node.projectDir)
|
|
1106
1019
|
}
|
|
1107
1020
|
|
|
1108
|
-
/**
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
* we know any stale content was wiped; what's left is to materialize
|
|
1117
|
-
* the empty skeleton.
|
|
1118
|
-
*/
|
|
1119
|
-
async function prepareOutputsForBind(
|
|
1120
|
-
projectDir: string,
|
|
1121
|
-
outputs: readonly string[],
|
|
1122
|
-
): Promise<void> {
|
|
1123
|
-
for (const g of outputs) {
|
|
1124
|
-
const hasWildcard = /[*?[\]]/.test(g)
|
|
1125
|
-
if (hasWildcard) {
|
|
1126
|
-
const abs = path.join(projectDir, staticPrefix(g))
|
|
1127
|
-
await mkdir(abs, { recursive: true })
|
|
1128
|
-
} else {
|
|
1129
|
-
const abs = path.join(projectDir, g)
|
|
1130
|
-
await mkdir(path.dirname(abs), { recursive: true })
|
|
1131
|
-
const f = Bun.file(abs)
|
|
1132
|
-
if (!(await f.exists())) await Bun.write(abs, '')
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1021
|
+
/** The two bin directories a task's PATH starts with: its own, then the root's (once when they coincide). */
|
|
1022
|
+
function taskBinDirs(node: TaskNode, workspaceRoot: string): string[] {
|
|
1023
|
+
const bins = [path.join(node.projectDir, 'node_modules', '.bin')]
|
|
1024
|
+
const rootBin = path.join(workspaceRoot, 'node_modules', '.bin')
|
|
1025
|
+
// Identical when the root is itself a project — dedupe rather than list it
|
|
1026
|
+
// twice, so PATH reads the same either way.
|
|
1027
|
+
if (rootBin !== bins[0]) bins.push(rootBin)
|
|
1028
|
+
return bins
|
|
1135
1029
|
}
|
|
1136
1030
|
|
|
1137
|
-
/**
|
|
1138
|
-
* Return the longest prefix of a glob that contains no wildcards. Used
|
|
1139
|
-
* to derive an allowWrite path from each `cache.outputs.files` entry:
|
|
1140
|
-
* `dist/**` → `dist`, `build/output.js` → `build/output.js`, `**` →
|
|
1141
|
-
* `.` (the project dir itself). bwrap binds at the directory level so
|
|
1142
|
-
* a file path covers writes to that file; a dir path covers writes
|
|
1143
|
-
* anywhere underneath.
|
|
1144
|
-
*/
|
|
1145
1031
|
/**
|
|
1146
1032
|
* Build the child-process env for one task. Same arguments at every call site
|
|
1147
1033
|
* (persistent + cached). Two `node_modules/.bin` directories are prepended to
|
|
@@ -1159,15 +1045,14 @@ async function prepareOutputsForBind(
|
|
|
1159
1045
|
* it. npm/pnpm/yarn all put the ancestor chain on PATH for the same reason.
|
|
1160
1046
|
*/
|
|
1161
1047
|
function taskEnv(node: TaskNode, step: ExecConfig, workspaceRoot: string): NodeJS.ProcessEnv {
|
|
1162
|
-
const bins =
|
|
1163
|
-
const
|
|
1164
|
-
// Identical when the root is itself a project — dedupe rather than list it
|
|
1165
|
-
// twice, so PATH reads the same either way.
|
|
1166
|
-
if (rootBin !== bins[0]) bins.push(rootBin)
|
|
1167
|
-
return buildIsolatedEnv({
|
|
1048
|
+
const bins = taskBinDirs(node, workspaceRoot)
|
|
1049
|
+
const env = buildIsolatedEnv({
|
|
1168
1050
|
passThrough: step.env?.passThrough ?? [],
|
|
1169
1051
|
define: step.env?.define ?? {},
|
|
1170
1052
|
source: process.env,
|
|
1171
1053
|
binPaths: bins,
|
|
1172
1054
|
})
|
|
1055
|
+
env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
|
|
1056
|
+
env[VX_RUN_TASK_ENV] = node.id
|
|
1057
|
+
return env
|
|
1173
1058
|
}
|