@vzn/vx 0.0.651 → 0.0.653
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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +142 -6
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +58 -31
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
|
@@ -1,22 +1,32 @@
|
|
|
1
|
+
import { existsSync, lstatSync } from 'node:fs'
|
|
1
2
|
import path from 'node:path'
|
|
2
|
-
import type { ExecConfig, TaskConfig,
|
|
3
|
+
import type { ExecConfig, TaskConfig, InputsConfig, InputStability } from '../config.js'
|
|
3
4
|
import {
|
|
5
|
+
ABSENT_INPUT,
|
|
4
6
|
ArtifactVanishedError,
|
|
5
7
|
type CacheEntry,
|
|
6
8
|
type CacheLayer,
|
|
7
9
|
type CachePolicy,
|
|
8
10
|
cleanOutputs,
|
|
11
|
+
cleanOutputPaths,
|
|
9
12
|
committedFiles,
|
|
10
13
|
cleanWorkspaceOutputs,
|
|
11
14
|
type OutputStamp,
|
|
12
15
|
ownOutputsSince,
|
|
13
16
|
ownWorkspaceOutputsSince,
|
|
17
|
+
resolveOutputs,
|
|
18
|
+
resolveWorkspaceOutputs,
|
|
14
19
|
stampOutputs,
|
|
15
20
|
stampWorkspaceOutputs,
|
|
21
|
+
stampCommitted,
|
|
22
|
+
committedMoved,
|
|
16
23
|
FULL_CACHE_POLICY,
|
|
17
24
|
type GitFilesCache,
|
|
18
25
|
racyWindowMs,
|
|
26
|
+
stampGrainMs,
|
|
19
27
|
type TaskInputComponent,
|
|
28
|
+
declaresInput,
|
|
29
|
+
fsClockNow,
|
|
20
30
|
} from '../cache/index.js'
|
|
21
31
|
import {
|
|
22
32
|
signalExitCode,
|
|
@@ -40,8 +50,19 @@ import {
|
|
|
40
50
|
FAILED_OUTPUT_TAIL_CHARS,
|
|
41
51
|
failedOutputLog,
|
|
42
52
|
} from './output-log.js'
|
|
43
|
-
import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
44
53
|
import {
|
|
54
|
+
isGroupTask,
|
|
55
|
+
OutputsFailed,
|
|
56
|
+
RestoreDemoted,
|
|
57
|
+
RestoreFailed,
|
|
58
|
+
standInsOf,
|
|
59
|
+
type TaskNode,
|
|
60
|
+
type TaskOutcome,
|
|
61
|
+
} from '../graph/index.js'
|
|
62
|
+
import {
|
|
63
|
+
anyTaskGlob,
|
|
64
|
+
heldSecrets,
|
|
65
|
+
isLiteralPattern,
|
|
45
66
|
killGraceMs,
|
|
46
67
|
MASKED,
|
|
47
68
|
maskedStreams,
|
|
@@ -49,6 +70,7 @@ import {
|
|
|
49
70
|
printable,
|
|
50
71
|
relPosix,
|
|
51
72
|
secretMask,
|
|
73
|
+
type SecretMask,
|
|
52
74
|
secretNamed,
|
|
53
75
|
span,
|
|
54
76
|
UserError,
|
|
@@ -58,6 +80,7 @@ import { executorLabel, nameExecutorFailure } from './plugin-host.js'
|
|
|
58
80
|
import type { CommandWindows } from './command-windows.js'
|
|
59
81
|
import {
|
|
60
82
|
commandWriteReach,
|
|
83
|
+
dependencyReads,
|
|
61
84
|
mayWriteFingerprint,
|
|
62
85
|
type Placeholder,
|
|
63
86
|
reachedWithheld,
|
|
@@ -93,6 +116,7 @@ import {
|
|
|
93
116
|
type HashCache,
|
|
94
117
|
type InputFact,
|
|
95
118
|
isKeyless,
|
|
119
|
+
keyOf,
|
|
96
120
|
movedInput,
|
|
97
121
|
type UpfrontGroupKey,
|
|
98
122
|
} from './task-hash.js'
|
|
@@ -127,6 +151,12 @@ export interface ExecuteArgs {
|
|
|
127
151
|
* machine. The hash is still computed — dependents fold it.
|
|
128
152
|
*/
|
|
129
153
|
remoteOnlyNoop?: boolean
|
|
154
|
+
/**
|
|
155
|
+
* The task depends on a `remoteOnlyNoop` one, directly or through others
|
|
156
|
+
* (`Placements.belowNoop`): it saves nothing, since its key names the
|
|
157
|
+
* no-op's outputs, which never landed.
|
|
158
|
+
*/
|
|
159
|
+
belowNoop?: boolean
|
|
130
160
|
/**
|
|
131
161
|
* `exec.remote: 'only'` placed on a remote executor: execute remotely,
|
|
132
162
|
* but this machine's disk is untouched — no output clean, no vx cache
|
|
@@ -146,6 +176,11 @@ export interface ExecuteArgs {
|
|
|
146
176
|
* where a locally-placed consumer fetches its producers from. */
|
|
147
177
|
deferred?: DeferredOutputs
|
|
148
178
|
nestedProjectDirs: string[]
|
|
179
|
+
/**
|
|
180
|
+
* Where its outputs stop when that is more than `nestedProjectDirs`: the
|
|
181
|
+
* nested workspaces under it too (prepare.ts `outputWallsOf`).
|
|
182
|
+
*/
|
|
183
|
+
outputNestedDirs?: string[]
|
|
149
184
|
/** The run's cache directory: walled off from a sandboxed task's grants. */
|
|
150
185
|
cacheDir?: string
|
|
151
186
|
/** Anchor for hrtime spans across all tasks in this run. */
|
|
@@ -183,17 +218,23 @@ export interface ExecuteArgs {
|
|
|
183
218
|
* byte-identical with and without it.
|
|
184
219
|
*/
|
|
185
220
|
retries?: number
|
|
221
|
+
/**
|
|
222
|
+
* Whether the run history knows the task as flaky (`knownFlaky`): a keyed
|
|
223
|
+
* task with no `retries` of its own or of the run that fails gets ONE more
|
|
224
|
+
* attempt when it does. Decided at run time, so never in the key.
|
|
225
|
+
*/
|
|
226
|
+
knownFlaky?: (node: TaskNode, hash: string) => boolean
|
|
186
227
|
/**
|
|
187
228
|
* `run.timeout` (ms), already resolved from flag, variable and
|
|
188
229
|
* workspace. Per-task `exec.timeout` wins. Never hashed.
|
|
189
230
|
*/
|
|
190
231
|
timeout?: number
|
|
191
232
|
/**
|
|
192
|
-
* The run's `
|
|
233
|
+
* The run's `inputs.stability` layers around the task's own: `run` from
|
|
193
234
|
* the env vars (above the task), `workspace` from `.vx/workspace` (below).
|
|
194
235
|
*/
|
|
195
236
|
inputStability?: { run: InputStability; workspace?: InputStability }
|
|
196
|
-
/** `
|
|
237
|
+
/** `outputs.clean` for tasks without their own: `.vx/workspace`. */
|
|
197
238
|
cleanOutputs?: boolean
|
|
198
239
|
/** Per-run memo for `git ls-files` (one entry per project dir). */
|
|
199
240
|
gitFilesCache?: GitFilesCache
|
|
@@ -236,6 +277,17 @@ export interface ExecuteArgs {
|
|
|
236
277
|
* granted a linked workspace package only when it is in this set.
|
|
237
278
|
*/
|
|
238
279
|
keyedProjects: (node: TaskNode) => ReadonlySet<string>
|
|
280
|
+
/**
|
|
281
|
+
* The run's lookup of the tasks whose outputs a sandboxed task may read
|
|
282
|
+
* (keyed-projects.ts `readableDeps`). Absent: none.
|
|
283
|
+
*/
|
|
284
|
+
readableDeps?: (node: TaskNode) => readonly TaskNode[]
|
|
285
|
+
/**
|
|
286
|
+
* The projects nested in a keyed one, by its dir (keyed-projects.ts
|
|
287
|
+
* `nestedDirsOf`): walled from a task granted that linked package.
|
|
288
|
+
* Absent: none.
|
|
289
|
+
*/
|
|
290
|
+
nestedDirsOf?: (projectDir: string) => readonly string[]
|
|
239
291
|
/**
|
|
240
292
|
* Run-scoped list the miss path appends its output-directory snapshot
|
|
241
293
|
* request to, taken at run end (run.ts) instead of right after the save:
|
|
@@ -244,7 +296,7 @@ export interface ExecuteArgs {
|
|
|
244
296
|
*/
|
|
245
297
|
outputDirSnapshots?: OutputDirSnapshot[]
|
|
246
298
|
/** The run's save lane: a miss's cache save runs off the execution slot (save-lane.ts). */
|
|
247
|
-
deferSave?: (save: () => Promise<void
|
|
299
|
+
deferSave?: (save: () => Promise<void>, taskId: string) => Promise<boolean>
|
|
248
300
|
/**
|
|
249
301
|
* Where a deferred save's `landed` promise is parked by task id, for the
|
|
250
302
|
* in-flight join (admission.ts): a duplicate of this task in another run
|
|
@@ -290,6 +342,26 @@ export interface ExecuteArgs {
|
|
|
290
342
|
serversNested?: readonly (readonly string[])[]
|
|
291
343
|
/** When each command of the run ran and where it may write (command-windows.ts). */
|
|
292
344
|
commandWindows?: CommandWindows
|
|
345
|
+
/**
|
|
346
|
+
* A persistent task matched `readyWhen` (or spawned, with none): what it
|
|
347
|
+
* writes from here on is its since-ready output (run.ts holds it until
|
|
348
|
+
* the task's outcome has been logged).
|
|
349
|
+
*/
|
|
350
|
+
serverReady?: (id: string) => void
|
|
351
|
+
/**
|
|
352
|
+
* Asked just before a miss is saved: whether a server it depends on
|
|
353
|
+
* (through groups too) died while it ran, which then holds the save. Its
|
|
354
|
+
* key is a healthy run's; its bytes were built against a dead server.
|
|
355
|
+
*/
|
|
356
|
+
serverDiedUnder?: (node: TaskNode) => Promise<boolean>
|
|
357
|
+
/**
|
|
358
|
+
* Told once when an attempt's shell left a process running: it may still
|
|
359
|
+
* write the outputs a dependent reads, so run.ts seeds the run's taint
|
|
360
|
+
* with this task and nothing built on it is saved.
|
|
361
|
+
*/
|
|
362
|
+
leftRunning?: (node: TaskNode) => void
|
|
363
|
+
/** A `vx watch` cycle (`RunOptions.watch`): an input edited mid-run is the next cycle's. */
|
|
364
|
+
watch?: true
|
|
293
365
|
}
|
|
294
366
|
|
|
295
367
|
/**
|
|
@@ -345,7 +417,7 @@ function readsCache(args: ExecuteArgs): boolean {
|
|
|
345
417
|
const policy = args.cachePolicy ?? FULL_CACHE_POLICY
|
|
346
418
|
return (
|
|
347
419
|
args.remoteOnly !== true &&
|
|
348
|
-
args.node.config.cache
|
|
420
|
+
args.node.config.cache === true &&
|
|
349
421
|
(policy.localRead || policy.remoteRead)
|
|
350
422
|
)
|
|
351
423
|
}
|
|
@@ -377,7 +449,8 @@ async function restoreProbed(
|
|
|
377
449
|
* A hit restored, or null when its artifact was removed after the probe (a
|
|
378
450
|
* `vx cache clean`, here or for a workspace sharing the cache directory): a
|
|
379
451
|
* miss. The restore has wiped the declared outputs by then; the run path
|
|
380
|
-
* cleans them again and the task writes them.
|
|
452
|
+
* cleans them again and the task writes them. Any other throw fails the
|
|
453
|
+
* task as a failed restore (`RestoreFailed`), never as a miss.
|
|
381
454
|
*/
|
|
382
455
|
async function restoreOrMiss(
|
|
383
456
|
args: ExecuteArgs,
|
|
@@ -389,7 +462,7 @@ async function restoreOrMiss(
|
|
|
389
462
|
try {
|
|
390
463
|
return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
|
|
391
464
|
} catch (err) {
|
|
392
|
-
if (!(err instanceof ArtifactVanishedError)) throw err
|
|
465
|
+
if (!(err instanceof ArtifactVanishedError)) throw new RestoreFailed(err)
|
|
393
466
|
warnOf(args.log)(`[vx] ${args.node.id}: ${err.message} — running it`)
|
|
394
467
|
return null
|
|
395
468
|
}
|
|
@@ -397,16 +470,16 @@ async function restoreOrMiss(
|
|
|
397
470
|
|
|
398
471
|
/**
|
|
399
472
|
* What a write to an input during the command means, each field highest
|
|
400
|
-
* first: the env var (`layers.run`), the task's `
|
|
473
|
+
* first: the env var (`layers.run`), the task's `inputs.stability`,
|
|
401
474
|
* the workspace's, then `'immutable'` and `'warn'` (2026-10-12), so
|
|
402
475
|
* by default any such write withholds the save, on CI too, since a
|
|
403
476
|
* concurrent task can change an input and put it back.
|
|
404
477
|
*/
|
|
405
478
|
function inputStabilityOf(
|
|
406
|
-
|
|
479
|
+
inputs: InputsConfig | undefined,
|
|
407
480
|
layers: ExecuteArgs['inputStability'],
|
|
408
481
|
): Required<InputStability> {
|
|
409
|
-
const task =
|
|
482
|
+
const task = inputs?.stability
|
|
410
483
|
return {
|
|
411
484
|
strategy: layers?.run.strategy ?? task?.strategy ?? layers?.workspace?.strategy ?? 'immutable',
|
|
412
485
|
severity: layers?.run.severity ?? task?.severity ?? layers?.workspace?.severity ?? 'warn',
|
|
@@ -444,16 +517,18 @@ async function executeGroupTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
444
517
|
)
|
|
445
518
|
}
|
|
446
519
|
const wallclockNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
447
|
-
const hash = await
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
520
|
+
const hash = await keyOf(
|
|
521
|
+
computeTaskHash({
|
|
522
|
+
node: args.node,
|
|
523
|
+
upstream: args.upstream,
|
|
524
|
+
workspaceRoot: args.workspaceRoot,
|
|
525
|
+
cache: args.cache,
|
|
526
|
+
forwardArgs: args.forwardArgs,
|
|
527
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
528
|
+
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
529
|
+
...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
|
|
530
|
+
}),
|
|
531
|
+
)
|
|
457
532
|
return groupOutcome(args.node, args.upstream, hash, wallclockNs)
|
|
458
533
|
}
|
|
459
534
|
|
|
@@ -518,9 +593,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
518
593
|
const { node, upstream, cache, log } = args
|
|
519
594
|
const cfg: TaskConfig = node.config
|
|
520
595
|
const step = cfg.exec as ExecConfig // dispatcher guarantees exec is present
|
|
521
|
-
const
|
|
596
|
+
const cfgOut = cfg.outputs
|
|
522
597
|
const policy = args.cachePolicy ?? FULL_CACHE_POLICY
|
|
523
|
-
const cfgCacheable =
|
|
598
|
+
const cfgCacheable = cfg.cache === true
|
|
524
599
|
// We may READ when at least one read axis is on (the cache layer
|
|
525
600
|
// refines local vs remote), and WRITE when at least one write axis is
|
|
526
601
|
// on. Output management (clean before exec) keys off writes — so
|
|
@@ -535,7 +610,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
535
610
|
// nothing to read under it, and nothing a later run could find.
|
|
536
611
|
const present = upstream.filter((u): u is TaskOutcome => u !== undefined)
|
|
537
612
|
const folded = new Set(
|
|
538
|
-
filterUpstreamHashes(
|
|
613
|
+
filterUpstreamHashes(
|
|
614
|
+
present,
|
|
615
|
+
cfg.inputs?.tasks,
|
|
616
|
+
node.projectName,
|
|
617
|
+
node.id,
|
|
618
|
+
standInsOf(node),
|
|
619
|
+
).map(([id]) => id),
|
|
539
620
|
)
|
|
540
621
|
const foldedTasks = expandGroupUpstream(present.filter((u) => folded.has(u.node.id)))
|
|
541
622
|
const keylessBy = isKeyless(cfg)
|
|
@@ -560,7 +641,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
560
641
|
// task's unfinished deps).
|
|
561
642
|
const unkeyedUpstream = foldedTasks.find((u) => u.unkeyed === true)
|
|
562
643
|
let unkeyed = unkeyedUpstream !== undefined
|
|
563
|
-
|
|
644
|
+
/** The input `keyStillTrue` found moved under the command (`TaskOutcome.driftedInput`). */
|
|
645
|
+
let driftedInput: string | null | undefined
|
|
646
|
+
const willSave =
|
|
647
|
+
willWrite && args.taintedUpstream !== true && !unkeyed && !keyless && args.belowNoop !== true
|
|
564
648
|
|
|
565
649
|
// The executor retains nothing: the entry's output is the attempt's
|
|
566
650
|
// `output` log, both streams in the order they reached the logger, kept
|
|
@@ -571,8 +655,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
571
655
|
const deferralRequested = args.download === 'deferred'
|
|
572
656
|
const capture: CaptureConfig = { stdout: false, stderr: false }
|
|
573
657
|
|
|
574
|
-
const outputs =
|
|
575
|
-
const wsOutputs =
|
|
658
|
+
const outputs = cfgOut?.files ?? []
|
|
659
|
+
const wsOutputs = cfgOut?.workspaceFiles ?? []
|
|
576
660
|
const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
|
|
577
661
|
// Per-task `exec.timeout` beats `run.timeout` (flag, variable,
|
|
578
662
|
// workspace; resolved into `args.timeout` by run.ts).
|
|
@@ -587,6 +671,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
587
671
|
// does an executor that declares `timeouts`.
|
|
588
672
|
let timeoutTimer: ReturnType<typeof setTimeout> | undefined
|
|
589
673
|
let timeoutFired = false
|
|
674
|
+
// Fail-fast withdrew the attempt while its executor still held it for a
|
|
675
|
+
// machine to begin (`running` answered `null`): it never started.
|
|
676
|
+
let unbegun = false
|
|
677
|
+
let withdrawUnbegun: (() => void) | undefined
|
|
590
678
|
// The attempt's listener on the run's stop signal, taken off once the
|
|
591
679
|
// attempt settles. Left on, every task of the run added one: the list
|
|
592
680
|
// grew to the task count, and each add scans it for a duplicate, ~100 ms
|
|
@@ -597,12 +685,32 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
597
685
|
const abort = (): void => stop.abort(args.stopSignal?.reason)
|
|
598
686
|
unlistenStop?.()
|
|
599
687
|
unlistenStop = undefined
|
|
688
|
+
unbegun = false
|
|
689
|
+
withdrawUnbegun = undefined
|
|
600
690
|
if (args.stopSignal?.aborted === true) abort()
|
|
601
691
|
else if (args.stopSignal !== undefined) {
|
|
602
692
|
const run = args.stopSignal
|
|
603
693
|
run.addEventListener('abort', abort, { once: true })
|
|
604
694
|
unlistenStop = () => run.removeEventListener('abort', abort)
|
|
605
695
|
}
|
|
696
|
+
// `--continue=never` starts nothing after a failure: a task a plugin
|
|
697
|
+
// executor still holds for a machine is not yet started, and was sent
|
|
698
|
+
// to one after the failure (2026-11-06).
|
|
699
|
+
const failFast = args.failFast
|
|
700
|
+
if (!local && failFast !== undefined && args.executor.running !== undefined) {
|
|
701
|
+
const withdraw = (): void => {
|
|
702
|
+
if (stop.signal.aborted || args.executor.running?.(node.id) !== null) return
|
|
703
|
+
unbegun = true
|
|
704
|
+
stop.abort(new Error('fail-fast'))
|
|
705
|
+
}
|
|
706
|
+
withdrawUnbegun = withdraw
|
|
707
|
+
failFast.addEventListener('abort', withdraw, { once: true })
|
|
708
|
+
const unlisten = unlistenStop
|
|
709
|
+
unlistenStop = () => {
|
|
710
|
+
unlisten?.()
|
|
711
|
+
failFast.removeEventListener('abort', withdraw)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
606
714
|
clearTimeout(timeoutTimer)
|
|
607
715
|
timeoutFired = false
|
|
608
716
|
if (effectiveTimeout !== undefined && !local && args.executor.timeouts !== true) {
|
|
@@ -623,7 +731,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
623
731
|
signal: AbortSignal | undefined,
|
|
624
732
|
): Promise<unknown> {
|
|
625
733
|
if (signal === undefined || isLocalExecutor(args.executor)) return running
|
|
626
|
-
const started =
|
|
734
|
+
const started = performance.now()
|
|
627
735
|
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
628
736
|
let onAbort: (() => void) | undefined
|
|
629
737
|
const abandoned = new Promise<ExecuteResult>((resolve) => {
|
|
@@ -638,10 +746,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
638
746
|
const reason: unknown = signal.reason
|
|
639
747
|
resolve({
|
|
640
748
|
exitCode: signalExitCode(reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM'),
|
|
641
|
-
durationMs:
|
|
749
|
+
durationMs: Math.round(performance.now() - started),
|
|
642
750
|
stdout: '',
|
|
643
751
|
stderr: '',
|
|
644
752
|
violations: [],
|
|
753
|
+
// What it started may still run and write the retry's outputs.
|
|
754
|
+
leftRunning: true,
|
|
645
755
|
...(timeoutFired ? { timedOut: true } : {}),
|
|
646
756
|
})
|
|
647
757
|
}, graceMs)
|
|
@@ -685,19 +795,21 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
685
795
|
const hash =
|
|
686
796
|
preProbed?.hash ??
|
|
687
797
|
args.upfrontKey ??
|
|
688
|
-
(await
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
798
|
+
(await keyOf(
|
|
799
|
+
computeTaskHash({
|
|
800
|
+
node,
|
|
801
|
+
upstream,
|
|
802
|
+
workspaceRoot: args.workspaceRoot,
|
|
803
|
+
cache,
|
|
804
|
+
forwardArgs: args.forwardArgs,
|
|
805
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
806
|
+
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
807
|
+
...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
|
|
808
|
+
...(args.probesAfterWrites !== undefined
|
|
809
|
+
? { probesAfterWrites: args.probesAfterWrites }
|
|
810
|
+
: {}),
|
|
811
|
+
}),
|
|
812
|
+
))
|
|
701
813
|
|
|
702
814
|
// The local no-op half of `exec.remote: 'only'`: no remote executor took
|
|
703
815
|
// the task, so it succeeds without running. The hash was still computed —
|
|
@@ -715,32 +827,48 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
715
827
|
}
|
|
716
828
|
}
|
|
717
829
|
|
|
830
|
+
const ownInputs = {
|
|
831
|
+
projectDir: node.projectDir,
|
|
832
|
+
workspaceRoot: args.workspaceRoot,
|
|
833
|
+
declared: node.config.inputs,
|
|
834
|
+
}
|
|
718
835
|
const cleanArgs = {
|
|
719
836
|
projectDir: node.projectDir,
|
|
720
837
|
outputs,
|
|
721
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
838
|
+
nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
|
|
839
|
+
inputs: ownInputs,
|
|
722
840
|
committed: committedFiles(args.gitFilesCache, node.projectDir),
|
|
723
841
|
}
|
|
724
842
|
const wsCleanArgs = {
|
|
725
843
|
workspaceRoot: args.workspaceRoot,
|
|
726
844
|
outputs: wsOutputs,
|
|
845
|
+
inputs: ownInputs,
|
|
727
846
|
committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
|
|
728
847
|
}
|
|
729
848
|
// An ADDITIVE task (its outputs overlap an upstream's, with the edge that
|
|
730
849
|
// orders them; item 588) does not clean by glob — the glob selects the
|
|
731
850
|
// upstream's files it adds beside. Its outputs are stamped once before
|
|
732
851
|
// the first attempt, and after a 0 exit its own set is what the run
|
|
733
|
-
// added or changed against that stamp
|
|
852
|
+
// added or changed against that stamp; a retry first removes what the
|
|
853
|
+
// failed attempt added (`dropFailedAdditions`). Stale files of its own from an
|
|
734
854
|
// earlier run are its command's to clean, as they are under Turbo.
|
|
735
855
|
const additive = (node.addsToOutputsOf?.length ?? 0) > 0
|
|
736
856
|
let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
|
|
737
857
|
let wsStampedBefore: ReadonlyMap<string, OutputStamp> | undefined
|
|
738
|
-
//
|
|
858
|
+
// A failed attempt changed or removed a file the stamp found, or a
|
|
859
|
+
// committed file under the outputs (no clean deletes one): no retry can
|
|
860
|
+
// put it back, so no entry holds only the passing attempt's work.
|
|
861
|
+
let failedTouchedFound = false
|
|
862
|
+
// The committed files under the outputs as the first attempt found them,
|
|
863
|
+
// stamped only when a retry can follow.
|
|
864
|
+
let committedBefore: ReadonlyMap<string, OutputStamp> | undefined
|
|
865
|
+
let wsCommittedBefore: ReadonlyMap<string, OutputStamp> | undefined
|
|
866
|
+
// `outputs.clean: false` leaves the outputs to the first attempt's
|
|
739
867
|
// command, and the save stores whatever they hold after it, a leftover
|
|
740
868
|
// included: the user opted out of the clean-tree guarantee
|
|
741
869
|
// (2026-10-15). A retry still starts clean. An additive task never cleans
|
|
742
870
|
// by glob anyway.
|
|
743
|
-
const keepOutputs = !additive && (
|
|
871
|
+
const keepOutputs = !additive && (cfgOut?.clean ?? args.cleanOutputs ?? true) === false
|
|
744
872
|
|
|
745
873
|
// Cache lookup. On hit, time the user-perceived restore op
|
|
746
874
|
// (clean+restore+log-replay) — that's what the framed-block footer
|
|
@@ -790,6 +918,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
790
918
|
: {}),
|
|
791
919
|
})
|
|
792
920
|
: undefined
|
|
921
|
+
// The command's start for the re-check (`movedInput`), on the clock that
|
|
922
|
+
// stamps its writes: off `Date.now()`, a coarse kernel tick dated an edit
|
|
923
|
+
// in the command's first tick before it, and an edit undone by the end
|
|
924
|
+
// read as an upstream's write of the key's bytes. After the describe, so
|
|
925
|
+
// the stats that tell an upstream's write in its tick apart come first.
|
|
926
|
+
const commandFrom = cfgCacheable
|
|
927
|
+
? fsClockNow(args.cacheDir ?? path.join(args.workspaceRoot, '.vx'))
|
|
928
|
+
: 0
|
|
793
929
|
/** The other command that may have put a moved input back (`ownRewrite`). */
|
|
794
930
|
let rewroteBy: string | undefined
|
|
795
931
|
/**
|
|
@@ -834,7 +970,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
834
970
|
c.hash = MASKED + c.hash
|
|
835
971
|
// The values of secret-named variables, masked in what the task prints
|
|
836
972
|
// and what the cache keeps of it (L-11); null when there are none.
|
|
837
|
-
|
|
973
|
+
// Built again once the sandbox starts: its proxy token joins them.
|
|
974
|
+
const taskMask = (): SecretMask | null =>
|
|
975
|
+
secretMask([process.env, env, step.env?.define], step.env?.secret)
|
|
976
|
+
let secrets = taskMask()
|
|
977
|
+
let maskHeld = heldSecrets()
|
|
838
978
|
// A runtime probe's row is named by its command, which a TS config may
|
|
839
979
|
// build from `process.env`: the run history stored it and `vx why`
|
|
840
980
|
// printed it (L-11).
|
|
@@ -856,16 +996,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
856
996
|
const inputChanges = described !== undefined ? args.explainMiss?.(node.id, captured) : undefined
|
|
857
997
|
// A plugin may keep the request past the run (the cache closed): it gets
|
|
858
998
|
// the upstream outputs read now. Only the local floor leaves them unread.
|
|
859
|
-
if (inputs !== undefined && !isLocalExecutor(args.executor))
|
|
999
|
+
if (inputs !== undefined && !isLocalExecutor(args.executor)) await described!.readUpstream()
|
|
860
1000
|
// A declared input set that resolves to NOTHING is the quiet stale hit:
|
|
861
1001
|
// the key stops moving with this project's source and every later run
|
|
862
1002
|
// is a hit. Almost always a glob written against the wrong directory
|
|
863
1003
|
// (`lib/**` in a package that builds `src/`). Said once per miss, on the
|
|
864
1004
|
// run's status line — the task itself succeeds.
|
|
865
1005
|
const declaredInputs = [...(cfg.inputs?.files ?? []), ...(cfg.inputs?.workspaceFiles ?? [])]
|
|
866
|
-
|
|
1006
|
+
const unmatched =
|
|
1007
|
+
inputs === undefined || declaredInputs.length === 0
|
|
1008
|
+
? []
|
|
1009
|
+
: inputs.files.length === 0
|
|
1010
|
+
? declaredInputs
|
|
1011
|
+
: deadSourceGlobs(cfg.inputs, inputs.files, relPosix(args.workspaceRoot, node.projectDir))
|
|
1012
|
+
if (unmatched.length > 0) {
|
|
867
1013
|
warnOf(log)(
|
|
868
|
-
`[vx] ${node.id}: inputs matched no files (${
|
|
1014
|
+
`[vx] ${node.id}: inputs matched no files (${unmatched.join(', ')}) — ` +
|
|
869
1015
|
`the key will not change when this project's source does` +
|
|
870
1016
|
bracketLiteralHint([
|
|
871
1017
|
...(cfg.inputs?.files ?? []).map((g) => [node.projectDir, g] as const),
|
|
@@ -889,7 +1035,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
889
1035
|
// Cache miss path (or caching disabled), up to `1 + retries` attempts.
|
|
890
1036
|
// Explicit config wins over the run-level `--retries` default, including
|
|
891
1037
|
// an explicit `retries: 0`.
|
|
892
|
-
const
|
|
1038
|
+
const retriesSet = step.retries !== undefined || args.retries !== undefined
|
|
1039
|
+
let maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
|
|
893
1040
|
let attempt = 0
|
|
894
1041
|
let result: ExecuteResult
|
|
895
1042
|
let effectiveExitCode: number
|
|
@@ -897,6 +1044,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
897
1044
|
// the last attempt alone read `max 407ms` beside a footer of 837 (item
|
|
898
1045
|
// 1101). A saved entry keeps the attempt that produced it, what a hit saves.
|
|
899
1046
|
let spentMs = 0
|
|
1047
|
+
// A known flake gets a retry its config did not ask for (below).
|
|
1048
|
+
const retryCanFollow =
|
|
1049
|
+
maxAttempts > 1 || (!retriesSet && cfgCacheable && !keyless && args.knownFlaky !== undefined)
|
|
1050
|
+
// Stamps the committed files a clean found before the first attempt, and
|
|
1051
|
+
// before a retry taints the save if a failed attempt moved one.
|
|
1052
|
+
const checkCommitted = (
|
|
1053
|
+
root: string,
|
|
1054
|
+
rels: readonly string[],
|
|
1055
|
+
committed: (abs: string) => boolean,
|
|
1056
|
+
before: ReadonlyMap<string, OutputStamp> | undefined,
|
|
1057
|
+
): ReadonlyMap<string, OutputStamp> | undefined => {
|
|
1058
|
+
if (!retryCanFollow) return undefined
|
|
1059
|
+
const files = rels.map((r) => path.resolve(root, r))
|
|
1060
|
+
if (before === undefined) return stampCommitted(files, committed)
|
|
1061
|
+
if (committedMoved(files, committed, before)) failedTouchedFound = true
|
|
1062
|
+
return before
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// vx's own work on the outputs, around a command that exited `exitCode`.
|
|
1066
|
+
async function outputsStep<T>(
|
|
1067
|
+
step: 'clean' | 'save',
|
|
1068
|
+
exitCode: number,
|
|
1069
|
+
work: () => Promise<T>,
|
|
1070
|
+
): Promise<T> {
|
|
1071
|
+
try {
|
|
1072
|
+
return await work()
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
throw new OutputsFailed(err, step, exitCode, spentMs)
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
900
1077
|
|
|
901
1078
|
// One task attempt: clean the declared outputs (before EVERY attempt — so a
|
|
902
1079
|
// stale prior-build artifact can't survive into a fresh run, and a failed
|
|
@@ -909,6 +1086,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
909
1086
|
result: ExecuteResult
|
|
910
1087
|
exitCode: number
|
|
911
1088
|
withdrawn?: true
|
|
1089
|
+
unbegun?: true
|
|
912
1090
|
}> {
|
|
913
1091
|
// The first attempt under `clean: false` finds its last outputs (tsc's `.tsbuildinfo`).
|
|
914
1092
|
const clean = willWrite && !deferralRequested && !(keepOutputs && attempt === 1)
|
|
@@ -916,33 +1094,92 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
916
1094
|
// tree would replace a stale build with nothing at all. The eligibility
|
|
917
1095
|
// gate guarantees no key in this run can see what stays behind, and the
|
|
918
1096
|
// summary names every deferred task so `dist/` is not silently stale.
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1097
|
+
// Its throw (a name vx cannot clear) fails the task before this
|
|
1098
|
+
// attempt's command: no exit of its own to name.
|
|
1099
|
+
await outputsStep('clean', attempt > 1 ? effectiveExitCode : 1, async () => {
|
|
1100
|
+
if (additive && willWrite && !deferralRequested && outputs.length > 0) {
|
|
1101
|
+
const endStamp = span('miss: stamp outputs')
|
|
1102
|
+
if (stampedBefore === undefined) stampedBefore = await stampOutputs(cleanArgs)
|
|
1103
|
+
else {
|
|
1104
|
+
const rels = await dropFailedAdditions(
|
|
1105
|
+
node.projectDir,
|
|
1106
|
+
outputs,
|
|
1107
|
+
stampedBefore,
|
|
1108
|
+
cleanArgs.committed,
|
|
1109
|
+
await ownOutputsSince(cleanArgs, stampedBefore),
|
|
1110
|
+
() => resolveOutputs(cleanArgs),
|
|
1111
|
+
)
|
|
1112
|
+
args.gitFilesCache?.markOutputsChanged(node.projectDir, rels)
|
|
1113
|
+
}
|
|
1114
|
+
endStamp()
|
|
1115
|
+
} else if (clean && outputs.length > 0) {
|
|
1116
|
+
// Mark the wiped paths, exactly as the workspace twin below does. The
|
|
1117
|
+
// git snapshot still lists them with their committed index OIDs, and
|
|
1118
|
+
// resolveFiles SKIPS its existence probe for any path carrying a
|
|
1119
|
+
// trusted OID — so a declared output the producer deletes and does not
|
|
1120
|
+
// re-create would stay in a same-project consumer's input set, keeping
|
|
1121
|
+
// that consumer's key unchanged while the file is gone from disk.
|
|
1122
|
+
const endClean = span('miss: clean outputs')
|
|
1123
|
+
const cleanedRels = await cleanOutputs(cleanArgs)
|
|
1124
|
+
endClean()
|
|
1125
|
+
args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
|
|
1126
|
+
committedBefore = checkCommitted(
|
|
1127
|
+
node.projectDir,
|
|
1128
|
+
cleanedRels,
|
|
1129
|
+
cleanArgs.committed,
|
|
1130
|
+
committedBefore,
|
|
1131
|
+
)
|
|
1132
|
+
} else if (keepOutputs && willWrite && !deferralRequested && outputs.length > 0) {
|
|
1133
|
+
committedBefore = checkCommitted(
|
|
1134
|
+
node.projectDir,
|
|
1135
|
+
retryCanFollow
|
|
1136
|
+
? (await resolveOutputs(cleanArgs)).map((f) => relPosix(node.projectDir, f))
|
|
1137
|
+
: [],
|
|
1138
|
+
cleanArgs.committed,
|
|
1139
|
+
committedBefore,
|
|
1140
|
+
)
|
|
1141
|
+
}
|
|
1142
|
+
if (additive && willWrite && !deferralRequested && wsOutputs.length > 0) {
|
|
1143
|
+
// A root-anchored addition is stamped as the project one above: a
|
|
1144
|
+
// glob clean deleted the upstream's files before this task, which
|
|
1145
|
+
// reads them, ran (A-43).
|
|
1146
|
+
if (wsStampedBefore === undefined)
|
|
1147
|
+
wsStampedBefore = await stampWorkspaceOutputs(wsCleanArgs)
|
|
1148
|
+
else {
|
|
1149
|
+
const rels = await dropFailedAdditions(
|
|
1150
|
+
args.workspaceRoot,
|
|
1151
|
+
wsOutputs,
|
|
1152
|
+
wsStampedBefore,
|
|
1153
|
+
wsCleanArgs.committed,
|
|
1154
|
+
await ownWorkspaceOutputsSince(wsCleanArgs, wsStampedBefore),
|
|
1155
|
+
() => resolveWorkspaceOutputs(wsCleanArgs),
|
|
1156
|
+
)
|
|
1157
|
+
args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, rels)
|
|
1158
|
+
}
|
|
1159
|
+
} else if (clean && wsOutputs.length > 0) {
|
|
1160
|
+
// Root-anchored deletions can land in other projects' dirs; mark
|
|
1161
|
+
// them so stale per-project git snapshots can't survive the wipe.
|
|
1162
|
+
const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
|
|
1163
|
+
args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, cleanedWsRels)
|
|
1164
|
+
wsCommittedBefore = checkCommitted(
|
|
1165
|
+
args.workspaceRoot,
|
|
1166
|
+
cleanedWsRels,
|
|
1167
|
+
wsCleanArgs.committed,
|
|
1168
|
+
wsCommittedBefore,
|
|
1169
|
+
)
|
|
1170
|
+
} else if (keepOutputs && willWrite && !deferralRequested && wsOutputs.length > 0) {
|
|
1171
|
+
wsCommittedBefore = checkCommitted(
|
|
1172
|
+
args.workspaceRoot,
|
|
1173
|
+
retryCanFollow
|
|
1174
|
+
? (await resolveWorkspaceOutputs(wsCleanArgs)).map((f) =>
|
|
1175
|
+
relPosix(args.workspaceRoot, f),
|
|
1176
|
+
)
|
|
1177
|
+
: [],
|
|
1178
|
+
wsCleanArgs.committed,
|
|
1179
|
+
wsCommittedBefore,
|
|
1180
|
+
)
|
|
1181
|
+
}
|
|
1182
|
+
})
|
|
946
1183
|
// What the attempt before this one left, put back if this one is
|
|
947
1184
|
// withdrawn: it stands, with its output and its refusals.
|
|
948
1185
|
const standing = { output, violations, flushMasked }
|
|
@@ -984,6 +1221,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
984
1221
|
} catch (thrown) {
|
|
985
1222
|
running = Promise.reject(thrown)
|
|
986
1223
|
}
|
|
1224
|
+
// A failure that landed while the request was built: the executor holds it now.
|
|
1225
|
+
if (args.failFast?.aborted === true) withdrawUnbegun?.()
|
|
987
1226
|
let res = await boundAfterAbort(running, req.signal)
|
|
988
1227
|
.then((r: unknown) => {
|
|
989
1228
|
assertExecuteResult(args.executor.name, node.id, r)
|
|
@@ -1036,6 +1275,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1036
1275
|
throw thrown
|
|
1037
1276
|
})
|
|
1038
1277
|
endExec()
|
|
1278
|
+
if (unbegun) {
|
|
1279
|
+
await sweepPlaceholders(placeholders)
|
|
1280
|
+
if (attempt === 1) return { result: res, exitCode: res.exitCode, unbegun: true }
|
|
1281
|
+
// A retry withdrawn: the attempt before it stands, as above.
|
|
1282
|
+
output = standing.output
|
|
1283
|
+
violations = standing.violations
|
|
1284
|
+
flushMasked = standing.flushMasked
|
|
1285
|
+
return { result, exitCode: effectiveExitCode, withdrawn: true }
|
|
1286
|
+
}
|
|
1039
1287
|
if (secrets !== null)
|
|
1040
1288
|
res = {
|
|
1041
1289
|
...res,
|
|
@@ -1087,12 +1335,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1087
1335
|
// A child we SIGTERMed for exceeding the timeout is a genuine failure —
|
|
1088
1336
|
// stream a clear line so the 143 exit reads as a timeout. One that outlived
|
|
1089
1337
|
// the grace died of the SIGKILL, and the line said SIGTERM over an exit
|
|
1090
|
-
// 137 (item 1063)
|
|
1338
|
+
// 137 (item 1063); so did a shell dead of the SIGTERM whose leftover the
|
|
1339
|
+
// SIGKILL took.
|
|
1091
1340
|
if (res.timedOut) {
|
|
1092
|
-
|
|
1341
|
+
// A plugin executor's child is its own: core only aborted its request.
|
|
1342
|
+
const how = !isLocalExecutor(args.executor)
|
|
1343
|
+
? 'its executor was aborted'
|
|
1344
|
+
: res.signal === 'SIGKILL' || res.killedAfterGrace === true
|
|
1345
|
+
? 'killed (SIGKILL after the SIGTERM grace)'
|
|
1346
|
+
: 'killed (SIGTERM)'
|
|
1093
1347
|
log.taskStderr(
|
|
1094
1348
|
node,
|
|
1095
|
-
`${midLine ? '\n' : ''}[vx] timed out after ${effectiveTimeout}ms —
|
|
1349
|
+
`${midLine ? '\n' : ''}[vx] timed out after ${effectiveTimeout}ms — ${how}\n`,
|
|
1096
1350
|
)
|
|
1097
1351
|
// Force a non-zero classification even if the child TRAPPED SIGTERM and
|
|
1098
1352
|
// still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
|
|
@@ -1125,6 +1379,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1125
1379
|
bins: taskBinDirs(node, args.workspaceRoot),
|
|
1126
1380
|
signal: res.signal,
|
|
1127
1381
|
hidden: req.sandbox && ((f: string) => !sandboxReads(req.sandbox!, f)),
|
|
1382
|
+
stderr: errTail,
|
|
1128
1383
|
})
|
|
1129
1384
|
// The line quotes the command's first word, which a config may have
|
|
1130
1385
|
// built from a secret: masked as the task's own output is (L-37).
|
|
@@ -1166,13 +1421,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1166
1421
|
// printed a blank line under the OUTPUT rule or the task's last line. Glued, a frame read
|
|
1167
1422
|
// `partialvx: retrying …`.
|
|
1168
1423
|
let midLine = false
|
|
1424
|
+
/** The attempt's last stderr: the shell's "not found" line names the word a compound command missed (shell-verdict.ts). */
|
|
1425
|
+
let errTail = ''
|
|
1169
1426
|
// The attempt's output as the logger got it (masked): the entry's when
|
|
1170
1427
|
// the task saves, else only what a failure reports (`failedOutput`).
|
|
1171
1428
|
let output: BoundedCapture | undefined
|
|
1172
1429
|
// Masked again whole: the live stream lets a held piece go after a pause.
|
|
1173
|
-
const
|
|
1174
|
-
const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [], storeMask)
|
|
1430
|
+
const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [], secrets ?? undefined)
|
|
1175
1431
|
const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
|
|
1432
|
+
// Each failed attempt's output, beside `failedAttempts`: kept for `vx last --log`.
|
|
1433
|
+
const attemptOutputs: string[] = []
|
|
1434
|
+
// Any attempt's shell left a process running, not only the last one's.
|
|
1435
|
+
let leftRunning = false
|
|
1176
1436
|
// The sampling of the attempt's process tree, stopped once it settles.
|
|
1177
1437
|
let untrack: (() => TreeLoad | undefined) | undefined
|
|
1178
1438
|
// What the last attempt's samples saw, for the run history.
|
|
@@ -1206,13 +1466,33 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1206
1466
|
for (;;) {
|
|
1207
1467
|
attempt++
|
|
1208
1468
|
const a = await runAttempt()
|
|
1469
|
+
// Never started: skipped, as fail-fast skips a task not yet dispatched.
|
|
1470
|
+
if (a.unbegun) {
|
|
1471
|
+
return {
|
|
1472
|
+
node,
|
|
1473
|
+
status: 'skipped',
|
|
1474
|
+
exitCode: 1,
|
|
1475
|
+
durationMs: 0,
|
|
1476
|
+
hash,
|
|
1477
|
+
wallclockStartNs,
|
|
1478
|
+
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1209
1481
|
result = a.result
|
|
1210
1482
|
effectiveExitCode = a.exitCode
|
|
1211
1483
|
if (a.withdrawn) {
|
|
1212
1484
|
attempt--
|
|
1213
1485
|
failedAttempts.pop()
|
|
1486
|
+
attemptOutputs.pop()
|
|
1214
1487
|
break
|
|
1215
1488
|
}
|
|
1489
|
+
// A failed attempt's leftover may write into the retry's outputs too.
|
|
1490
|
+
// It is not killed, as a passing attempt's is not: only the save is
|
|
1491
|
+
// withheld (`nothingLeftRunning`), and its dependents' (`args.leftRunning`).
|
|
1492
|
+
if (result.leftRunning === true && !leftRunning) {
|
|
1493
|
+
leftRunning = true
|
|
1494
|
+
args.leftRunning?.(node)
|
|
1495
|
+
}
|
|
1216
1496
|
forgetUndeclaredWrites(args, writeReach)
|
|
1217
1497
|
if (writesFingerprint) args.fingerprintWatch?.wrote()
|
|
1218
1498
|
spentMs += result.durationMs
|
|
@@ -1252,25 +1532,77 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1252
1532
|
}
|
|
1253
1533
|
}
|
|
1254
1534
|
|
|
1535
|
+
// A known flake gets the one retry its config did not ask for; a
|
|
1536
|
+
// timeout always fails, and a task whose key names nothing (no cache
|
|
1537
|
+
// block, or keyed on a keyless upstream) has no record to know it by.
|
|
1538
|
+
if (
|
|
1539
|
+
effectiveExitCode !== 0 &&
|
|
1540
|
+
attempt === 1 &&
|
|
1541
|
+
!retriesSet &&
|
|
1542
|
+
result.timedOut !== true &&
|
|
1543
|
+
cfgCacheable &&
|
|
1544
|
+
!keyless &&
|
|
1545
|
+
args.failFast?.aborted !== true &&
|
|
1546
|
+
args.knownFlaky?.(node, hash) === true
|
|
1547
|
+
)
|
|
1548
|
+
maxAttempts = 2
|
|
1255
1549
|
if (effectiveExitCode === 0 || attempt >= maxAttempts || args.failFast?.aborted === true) break
|
|
1256
1550
|
failedAttempts.push({
|
|
1257
1551
|
endedAt: Date.now(),
|
|
1258
1552
|
exitCode: effectiveExitCode,
|
|
1259
1553
|
...(result.timedOut === true ? { timedOut: true as const } : {}),
|
|
1260
1554
|
})
|
|
1555
|
+
// The outcome's SANDBOX VIOLATIONS section is the last attempt's: a
|
|
1556
|
+
// retried attempt's refusals go into its own output, kept for
|
|
1557
|
+
// `vx last --log`, as the section prints them.
|
|
1558
|
+
const refused = new Set<string>()
|
|
1559
|
+
let denials = 0
|
|
1560
|
+
for (const v of violations) {
|
|
1561
|
+
const line = printable(secrets?.mask(v.line) ?? v.line)
|
|
1562
|
+
if (!refused.has(line) && v.hint !== true) denials++
|
|
1563
|
+
refused.add(line)
|
|
1564
|
+
}
|
|
1565
|
+
if (refused.size > 0 && output !== undefined) {
|
|
1566
|
+
const section = `${midLine ? '\n' : ''}${denials > 0 ? `SANDBOX VIOLATIONS (${denials})` : 'SANDBOX'}\n${[...refused].join('\n')}\n`
|
|
1567
|
+
output.push(section, true)
|
|
1568
|
+
log.taskStderr(node, section)
|
|
1569
|
+
midLine = false
|
|
1570
|
+
}
|
|
1571
|
+
attemptOutputs.push(
|
|
1572
|
+
output === undefined ? '' : failedOutputLog(output, true, secrets ?? undefined),
|
|
1573
|
+
)
|
|
1574
|
+
// The shell's own exit 0 failed only on the refusal: `exit 1` named
|
|
1575
|
+
// nothing that happened.
|
|
1576
|
+
const why =
|
|
1577
|
+
result.timedOut === true
|
|
1578
|
+
? 'a timeout'
|
|
1579
|
+
: result.exitCode === 0 && denials > 0
|
|
1580
|
+
? `${denials} sandbox violation${denials === 1 ? '' : 's'}`
|
|
1581
|
+
: `exit ${effectiveExitCode}`
|
|
1261
1582
|
log.taskStderr(
|
|
1262
1583
|
node,
|
|
1263
|
-
`${midLine ? '\n' : ''}vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${
|
|
1584
|
+
`${midLine ? '\n' : ''}vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${why}\n`,
|
|
1264
1585
|
)
|
|
1265
1586
|
}
|
|
1266
1587
|
|
|
1267
1588
|
async function buildRequest(local: boolean): Promise<ExecuteRequest> {
|
|
1589
|
+
// A remote executor enforces it where it runs the task. Armed before
|
|
1590
|
+
// the streams' mask: its start holds the proxy's token, which the
|
|
1591
|
+
// task's env carries (`initSandbox`).
|
|
1592
|
+
if (userSandbox && args.executor.remote !== true) {
|
|
1593
|
+
await args.armSandbox?.()
|
|
1594
|
+
if (maskHeld !== heldSecrets()) {
|
|
1595
|
+
maskHeld = heldSecrets()
|
|
1596
|
+
secrets = taskMask()
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1268
1599
|
const kept =
|
|
1269
1600
|
willSave || args.keepRun !== undefined
|
|
1270
1601
|
? new BoundedCapture()
|
|
1271
1602
|
: new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
|
|
1272
1603
|
output = kept
|
|
1273
1604
|
midLine = false
|
|
1605
|
+
errTail = ''
|
|
1274
1606
|
const toOut = (t: string): void => {
|
|
1275
1607
|
kept.push(t)
|
|
1276
1608
|
if (t.length > 0) midLine = !t.endsWith('\n')
|
|
@@ -1279,18 +1611,26 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1279
1611
|
const toErr = (t: string): void => {
|
|
1280
1612
|
kept.push(t, true)
|
|
1281
1613
|
if (t.length > 0) midLine = !t.endsWith('\n')
|
|
1614
|
+
errTail = (errTail + t).slice(-2048)
|
|
1282
1615
|
log.taskStderr(node, t)
|
|
1283
1616
|
}
|
|
1284
1617
|
const masked = secrets && maskedStreams(secrets, toOut, toErr)
|
|
1285
1618
|
flushMasked = () => masked?.end()
|
|
1619
|
+
// A plugin executor gets frozen copies of what core goes on reading:
|
|
1620
|
+
// the outputs it saves, the config two tasks declared alike share, the
|
|
1621
|
+
// run's args. One that emptied its outputs saved an empty artifact a
|
|
1622
|
+
// later hit restored; one that set its define ran the next task with it.
|
|
1623
|
+
const own = <T extends object>(v: T): T =>
|
|
1624
|
+
local ? v : Object.freeze(Array.isArray(v) ? ([...v] as T) : { ...v })
|
|
1286
1625
|
const base: ExecuteRequest = {
|
|
1287
1626
|
taskId: node.id,
|
|
1627
|
+
...(attempt > 1 ? { attempt } : {}),
|
|
1288
1628
|
workspaceRoot: args.workspaceRoot,
|
|
1289
1629
|
command: step.command,
|
|
1290
|
-
forwardArgs: effectiveForwardArgs,
|
|
1630
|
+
forwardArgs: own(effectiveForwardArgs),
|
|
1291
1631
|
cwd: node.projectDir,
|
|
1292
1632
|
env,
|
|
1293
|
-
envDefine: step.env?.define ?? {},
|
|
1633
|
+
envDefine: own(step.env?.define ?? {}),
|
|
1294
1634
|
capture,
|
|
1295
1635
|
onStdout: masked ? masked.out : toOut,
|
|
1296
1636
|
onStderr: masked ? masked.err : toErr,
|
|
@@ -1309,11 +1649,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1309
1649
|
...(cfgCacheable && !remoteOnly && !willSave ? { unsaved: true } : {}),
|
|
1310
1650
|
...(remoteOnly ? { remoteOnly: true } : {}),
|
|
1311
1651
|
...(node.platform !== undefined ? { platform: node.platform } : {}),
|
|
1652
|
+
...(node.worker !== undefined ? { worker: node.worker } : {}),
|
|
1312
1653
|
...(deferralRequested ? { download: 'deferred' as const } : {}),
|
|
1313
1654
|
// `--force`/`--no-cache` reach a remote executor's private record
|
|
1314
1655
|
// through this flag — the policy gates above only cover vx's OWN cache.
|
|
1315
1656
|
...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
|
|
1316
|
-
outputs: { files: outputs, workspaceFiles: wsOutputs },
|
|
1657
|
+
outputs: { files: own(outputs), workspaceFiles: own(wsOutputs) },
|
|
1317
1658
|
...(args.terminal === true ? { terminal: true as const } : {}),
|
|
1318
1659
|
...(args.servers === undefined
|
|
1319
1660
|
? {}
|
|
@@ -1324,11 +1665,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1324
1665
|
}),
|
|
1325
1666
|
}
|
|
1326
1667
|
// The signal last: it arms a plugin's `exec.timeout`, and the sandbox's arming
|
|
1327
|
-
//
|
|
1668
|
+
// above is vx's work, not the task's. Armed first, a 60 ms timeout
|
|
1328
1669
|
// expired before the spawn and failed `echo` as timed out, unrun.
|
|
1329
1670
|
if (!userSandbox) return { ...base, signal: requestSignal(local) }
|
|
1330
|
-
|
|
1331
|
-
if (args.executor.remote !== true) await args.armSandbox?.()
|
|
1671
|
+
const deps = await dependencyReads(args.readableDeps?.(node) ?? [], args.workspaceRoot)
|
|
1332
1672
|
const sb = await sandboxRequestFor(
|
|
1333
1673
|
node,
|
|
1334
1674
|
step.sandbox!,
|
|
@@ -1336,6 +1676,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1336
1676
|
cfgCacheable ? args.keyedProjects(node) : undefined,
|
|
1337
1677
|
args.nestedProjectDirs,
|
|
1338
1678
|
args.cacheDir,
|
|
1679
|
+
deps.reads,
|
|
1680
|
+
deps.reported,
|
|
1681
|
+
args.nestedDirsOf,
|
|
1339
1682
|
)
|
|
1340
1683
|
placeholders = sb.placeholders
|
|
1341
1684
|
withheld = sb.withheld
|
|
@@ -1344,7 +1687,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1344
1687
|
|
|
1345
1688
|
const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
1346
1689
|
|
|
1347
|
-
const stability = inputStabilityOf(args.node.config.
|
|
1690
|
+
const stability = inputStabilityOf(args.node.config.inputs, args.inputStability)
|
|
1348
1691
|
let movedFails: string | undefined
|
|
1349
1692
|
let own: Awaited<ReturnType<typeof ownOutputs>>
|
|
1350
1693
|
let saved = false
|
|
@@ -1364,7 +1707,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1364
1707
|
// edit's bytes under the old key (X-123).
|
|
1365
1708
|
const materialize = result.outputs?.kind === 'deferred' ? result.outputs.materialize : undefined
|
|
1366
1709
|
if (materialize !== undefined) {
|
|
1367
|
-
const keyed = await keyStillTrue()
|
|
1710
|
+
const keyed = (await keyStillTrue()) && (await args.serverDiedUnder?.(node)) !== true
|
|
1368
1711
|
args.deferred?.register(node.id, {
|
|
1369
1712
|
materialize,
|
|
1370
1713
|
...(keyed ? { hash } : {}),
|
|
@@ -1383,50 +1726,53 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1383
1726
|
willSave &&
|
|
1384
1727
|
nothingLeftRunning() &&
|
|
1385
1728
|
(await keyStillTrue()) &&
|
|
1729
|
+
(await args.serverDiedUnder?.(node)) !== true &&
|
|
1386
1730
|
(own = await ownOutputs()) !== undefined
|
|
1387
1731
|
) {
|
|
1388
|
-
const { landed } = await
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1732
|
+
const { landed } = await outputsStep('save', effectiveExitCode, () =>
|
|
1733
|
+
saveMiss({
|
|
1734
|
+
...own,
|
|
1735
|
+
node,
|
|
1736
|
+
hash,
|
|
1737
|
+
cache,
|
|
1738
|
+
log,
|
|
1739
|
+
workspaceRoot: args.workspaceRoot,
|
|
1740
|
+
nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
|
|
1741
|
+
gitFilesCache: args.gitFilesCache,
|
|
1742
|
+
outputs,
|
|
1743
|
+
wsOutputs,
|
|
1744
|
+
captured,
|
|
1745
|
+
command: storedCommand,
|
|
1746
|
+
durationMs: result.durationMs,
|
|
1747
|
+
stdout: storedOutput(),
|
|
1748
|
+
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
1749
|
+
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
1750
|
+
outputDirSnapshots: args.outputDirSnapshots,
|
|
1751
|
+
deferSave: args.deferSave,
|
|
1752
|
+
// A sink listens: say what the save cost.
|
|
1753
|
+
measure: args.explainMiss !== undefined,
|
|
1754
|
+
}),
|
|
1755
|
+
)
|
|
1410
1756
|
// A run that settles the outcome on `landed` learns there whether the
|
|
1411
|
-
// entry is in; without one, the save is
|
|
1757
|
+
// entry is in; without one, the save is done (a `--force` save may have kept the entry it found).
|
|
1412
1758
|
if (args.deferredSaves !== undefined) args.deferredSaves.set(node.id, landed)
|
|
1413
|
-
else saved = true
|
|
1759
|
+
else saved = (await landed).saved === true
|
|
1414
1760
|
} else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants === true) {
|
|
1415
1761
|
// Nothing reads after it, so nothing to mark: only its row's `outputs`.
|
|
1416
1762
|
if (effectiveExitCode === 0)
|
|
1417
1763
|
unsavedBytes = await unsavedOutputBytes({
|
|
1418
1764
|
node,
|
|
1419
1765
|
workspaceRoot: args.workspaceRoot,
|
|
1420
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
1766
|
+
nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
|
|
1421
1767
|
outputs,
|
|
1422
1768
|
wsOutputs,
|
|
1423
1769
|
})
|
|
1424
1770
|
} else if (cfgCacheable && !remoteOnly && !deferralRequested) {
|
|
1425
1771
|
// Ran here and saves nothing — failed, a read-only policy, a tainted
|
|
1426
|
-
// upstream, a
|
|
1427
|
-
// have marked, and may have rewritten an
|
|
1428
|
-
// save catches. A same-run reader keyed
|
|
1429
|
-
// either replayed the bytes from before the command (item 750). A
|
|
1772
|
+
// upstream, a server that died under it, a key that no longer held —
|
|
1773
|
+
// yet it wrote what a save would have marked, and may have rewritten an
|
|
1774
|
+
// input the re-check before a save catches. A same-run reader keyed
|
|
1775
|
+
// from the snapshot's OIDs for either replayed the bytes from before the command (item 750). A
|
|
1430
1776
|
// withheld save already dropped the project; this finds the same move
|
|
1431
1777
|
// again and drops it again.
|
|
1432
1778
|
const endCheck = span('miss: recheck inputs')
|
|
@@ -1438,7 +1784,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1438
1784
|
unsavedBytes = await markUnsaved({
|
|
1439
1785
|
node,
|
|
1440
1786
|
workspaceRoot: args.workspaceRoot,
|
|
1441
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
1787
|
+
nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
|
|
1442
1788
|
gitFilesCache: args.gitFilesCache,
|
|
1443
1789
|
outputs,
|
|
1444
1790
|
wsOutputs,
|
|
@@ -1449,11 +1795,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1449
1795
|
/**
|
|
1450
1796
|
* What an additive run saves as its own, or undefined when it removed a
|
|
1451
1797
|
* file it found: no entry replays a removal (`ownOutputsSince`), so the
|
|
1452
|
-
* task saves nothing and runs again.
|
|
1798
|
+
* task saves nothing and runs again. Undefined too for any task when a
|
|
1799
|
+
* failed attempt moved a file no clean removed (`failedTouchedFound`).
|
|
1453
1800
|
*/
|
|
1454
1801
|
async function ownOutputs(): Promise<
|
|
1455
1802
|
{ ownOutputFiles?: string[]; ownWsOutputFiles?: string[] } | undefined
|
|
1456
1803
|
> {
|
|
1804
|
+
if (failedTouchedFound) return undefined
|
|
1457
1805
|
const mine: { ownOutputFiles?: string[]; ownWsOutputFiles?: string[] } = {}
|
|
1458
1806
|
if (stampedBefore !== undefined) {
|
|
1459
1807
|
const files = await ownOutputsSince(cleanArgs, stampedBefore)
|
|
@@ -1468,13 +1816,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1468
1816
|
return mine
|
|
1469
1817
|
}
|
|
1470
1818
|
|
|
1819
|
+
/**
|
|
1820
|
+
* Before an additive task's retry: remove what the failed attempt added
|
|
1821
|
+
* against the first stamp, as an exclusive task's clean removes it, so
|
|
1822
|
+
* the stamp still tells the retry's work from what the task found. A
|
|
1823
|
+
* found file it changed or removed stays as it is and taints the save.
|
|
1824
|
+
* Returns the removed paths, `root`-relative.
|
|
1825
|
+
*/
|
|
1826
|
+
async function dropFailedAdditions(
|
|
1827
|
+
root: string,
|
|
1828
|
+
outs: readonly string[],
|
|
1829
|
+
before: ReadonlyMap<string, OutputStamp>,
|
|
1830
|
+
committed: (abs: string) => boolean,
|
|
1831
|
+
changed: string[] | undefined,
|
|
1832
|
+
walk: () => Promise<string[]>,
|
|
1833
|
+
): Promise<string[]> {
|
|
1834
|
+
const added = (changed ?? (await walk())).filter((f) => !before.has(f))
|
|
1835
|
+
if (changed === undefined || added.length < changed.length) failedTouchedFound = true
|
|
1836
|
+
const rels = added.map((f) => relPosix(root, f))
|
|
1837
|
+
if (rels.length > 0)
|
|
1838
|
+
await cleanOutputPaths({ projectDir: root, rels, outputs: outs, committed })
|
|
1839
|
+
return rels
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1471
1842
|
/**
|
|
1472
1843
|
* A process the shell left behind still held its output when the drain
|
|
1473
1844
|
* bound cut it, so it may still write outputs: a save now would file a
|
|
1474
1845
|
* tree the run does not leave, and a hit elsewhere would replay it.
|
|
1475
1846
|
*/
|
|
1476
1847
|
function nothingLeftRunning(): boolean {
|
|
1477
|
-
if (
|
|
1848
|
+
if (!leftRunning) return true
|
|
1478
1849
|
warnOf(log)(
|
|
1479
1850
|
`[vx] ${node.id}: a process it left running may still write its outputs — ` +
|
|
1480
1851
|
`the result stands, but is not saved; wait for it in the command (\`wait\`)`,
|
|
@@ -1495,12 +1866,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1495
1866
|
const changed = new Set(result.changed.map((f) => path.join(args.workspaceRoot, f)))
|
|
1496
1867
|
const there = keyedInputs(described!.facts).find((f) => changed.has(f.path))
|
|
1497
1868
|
if (there !== undefined) return there.path
|
|
1869
|
+
// One the key never folded, as this disk lacks it (untracked there),
|
|
1870
|
+
// that the declaration matches: the command read it, and its entry
|
|
1871
|
+
// here was every later run's hit (2026-11-07).
|
|
1872
|
+
const dir = relPosix(args.workspaceRoot, node.projectDir)
|
|
1873
|
+
for (const f of result.changed) {
|
|
1874
|
+
const own = relPosix(node.projectDir, path.join(args.workspaceRoot, f))
|
|
1875
|
+
const projectRel = own.startsWith('../') ? null : own
|
|
1876
|
+
if (declaresInput(cfg, projectRel, f, false, true, dir))
|
|
1877
|
+
return path.join(args.workspaceRoot, f)
|
|
1878
|
+
}
|
|
1498
1879
|
}
|
|
1499
1880
|
return (
|
|
1500
1881
|
(await movedInput(
|
|
1501
1882
|
keyedInputs(described!.facts),
|
|
1502
1883
|
cache,
|
|
1503
|
-
|
|
1884
|
+
commandFrom,
|
|
1504
1885
|
stability.strategy === 'mutable' ? ownRewrite : undefined,
|
|
1505
1886
|
)) ?? described!.added()
|
|
1506
1887
|
)
|
|
@@ -1525,36 +1906,77 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1525
1906
|
// `'unchecked'` skips the check of the command's own window; a key the
|
|
1526
1907
|
// re-derive before the command already disagreed with still withholds,
|
|
1527
1908
|
// since it never described the bytes the command read.
|
|
1528
|
-
|
|
1909
|
+
// Unchecked, the command may still have rewritten an input in place: a
|
|
1910
|
+
// later reader keyed after it (stable-keys.ts) must not read the
|
|
1911
|
+
// project's run-start facts.
|
|
1912
|
+
if (stability.strategy === 'unchecked' && described!.hash === hash) {
|
|
1913
|
+
if (args.executor.remote !== true)
|
|
1914
|
+
forgetUndeclaredWrites(
|
|
1915
|
+
args,
|
|
1916
|
+
wsOutputs.length > 0 ? 'workspace' : commandWriteReach(node, args.workspaceRoot),
|
|
1917
|
+
)
|
|
1918
|
+
return true
|
|
1919
|
+
}
|
|
1529
1920
|
const endCheck = span('miss: recheck inputs')
|
|
1530
1921
|
const moved = await movedSinceKey()
|
|
1531
1922
|
endCheck()
|
|
1532
1923
|
if (moved === undefined) return true
|
|
1533
|
-
const
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1924
|
+
const name =
|
|
1925
|
+
moved === null ? 'its inputs' : `\`${printable(relPosix(args.workspaceRoot, moved))}\``
|
|
1926
|
+
// Only its ctime moved: a hard link (`cp -al src dist`), chmod or chown.
|
|
1927
|
+
// The line called that a change and offered a rewrite's way out. It is
|
|
1928
|
+
// still withheld: an edit undone with its mtime put back reads the same.
|
|
1929
|
+
// Not said of a change an executor reported from where it ran.
|
|
1930
|
+
const fact =
|
|
1931
|
+
moved === null || rewroteBy !== undefined || (result.changed?.length ?? 0) > 0
|
|
1932
|
+
? undefined
|
|
1933
|
+
: keyedInputs(described!.facts).find((f) => f.path === moved)
|
|
1934
|
+
const ctimeOnly = fact !== undefined && (await onlyCtimeMoved(fact, commandFrom, cache))
|
|
1935
|
+
const what = ctimeOnly
|
|
1936
|
+
? `${name}'s ctime moved while the task ran, but not its bytes or mtime (a hard link, chmod or chown)`
|
|
1937
|
+
: `${name} changed after its key was taken` +
|
|
1938
|
+
(rewroteBy === undefined ? '' : ` while ${rewroteBy} ran`)
|
|
1537
1939
|
// A task that regenerates its own input (TanStack Router's committed
|
|
1538
1940
|
// `routeTree.gen.ts`, rewritten to the same bytes by every build) is
|
|
1539
1941
|
// never saved (item 1015), and the line said so without the way out.
|
|
1942
|
+
// A file the key read may instead be one the task rewrites in place (a
|
|
1943
|
+
// formatter's temp + rename), whose sources cannot be its outputs: vx
|
|
1944
|
+
// deletes outputs before the command. Its way out is judging the write
|
|
1945
|
+
// by its bytes. Neither is said of a file this disk lacks: one the task
|
|
1946
|
+
// deleted, or one only where an executor ran it.
|
|
1947
|
+
const read =
|
|
1948
|
+
moved !== null &&
|
|
1949
|
+
keyedInputs(described!.facts).some((f) => f.path === moved && f.digest !== ABSENT_INPUT)
|
|
1950
|
+
// Under watch the edit starts the next cycle, which runs on it: said
|
|
1951
|
+
// first, or the way out read as the fix for the user's own edit.
|
|
1540
1952
|
const hint =
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1953
|
+
(args.watch === true && rewroteBy === undefined ? '; the next cycle runs on it' : '') +
|
|
1954
|
+
(ctimeOnly
|
|
1955
|
+
? "; if the task hard-links it, copy it instead, or set inputs.stability: { strategy: 'mutable' }"
|
|
1956
|
+
: moved === null || rewroteBy !== undefined || !existsSync(moved)
|
|
1957
|
+
? ''
|
|
1958
|
+
: '; if the task generates it, declare it in outputs' +
|
|
1959
|
+
(read && stability.strategy !== 'mutable'
|
|
1960
|
+
? "; if it rewrites it in place, set inputs.stability: { strategy: 'mutable' }"
|
|
1961
|
+
: ''))
|
|
1544
1962
|
if (stability.severity === 'error') {
|
|
1545
1963
|
warnOf(log)(
|
|
1546
1964
|
`[vx] ${node.id}: ${what} — the result is not saved, ` +
|
|
1547
|
-
`and
|
|
1965
|
+
`and inputs.stability.severity is 'error', so the task fails${hint}`,
|
|
1548
1966
|
)
|
|
1549
1967
|
movedFails = moved === null ? '' : printable(relPosix(args.workspaceRoot, moved))
|
|
1550
1968
|
} else {
|
|
1551
1969
|
warnOf(log)(
|
|
1552
|
-
`[vx] ${node.id}: ${what} —
|
|
1553
|
-
|
|
1970
|
+
`[vx] ${node.id}: ${what} — ` +
|
|
1971
|
+
(ctimeOnly
|
|
1972
|
+
? 'vx cannot tell that from an edit undone, so the result stands but is not saved'
|
|
1973
|
+
: 'the result stands, but is not saved under a key that no longer describes it') +
|
|
1974
|
+
hint,
|
|
1554
1975
|
)
|
|
1555
1976
|
}
|
|
1556
1977
|
forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
|
|
1557
1978
|
unkeyed = true
|
|
1979
|
+
driftedInput = moved
|
|
1558
1980
|
return false
|
|
1559
1981
|
}
|
|
1560
1982
|
|
|
@@ -1577,17 +1999,24 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1577
1999
|
durationMs: spentMs,
|
|
1578
2000
|
hash,
|
|
1579
2001
|
...(attempt > 1 ? { attempts: attempt } : {}),
|
|
1580
|
-
...(failedAttempts.length > 0 ? { failedAttempts } : {}),
|
|
2002
|
+
...(failedAttempts.length > 0 ? { failedAttempts, attemptOutputs } : {}),
|
|
1581
2003
|
...(described !== undefined
|
|
1582
2004
|
? { inputFiles: captured.reduce((n, c) => (c.kind === 'file' ? n + 1 : n), 0) }
|
|
1583
2005
|
: {}),
|
|
1584
2006
|
...(inputChanges !== undefined ? { inputChanges } : {}),
|
|
1585
2007
|
...(unkeyed ? { unkeyed: true as const } : {}),
|
|
2008
|
+
...(driftedInput !== undefined ? { driftedInput } : {}),
|
|
1586
2009
|
...(keylessBy !== undefined ? { keylessBy } : {}),
|
|
1587
2010
|
...(saved ? { saved: true as const } : {}),
|
|
1588
2011
|
...(unsavedBytes !== undefined ? { outputBytes: unsavedBytes } : {}),
|
|
1589
2012
|
...(effectiveExitCode !== 0 && output !== undefined
|
|
1590
|
-
? {
|
|
2013
|
+
? {
|
|
2014
|
+
failedOutput: failedOutputLog(
|
|
2015
|
+
output,
|
|
2016
|
+
willSave || args.keepRun !== undefined,
|
|
2017
|
+
secrets ?? undefined,
|
|
2018
|
+
),
|
|
2019
|
+
}
|
|
1591
2020
|
: {}),
|
|
1592
2021
|
...(args.keepRun !== undefined &&
|
|
1593
2022
|
(effectiveExitCode !== 0 ? args.keepRun.failures : !willSave) &&
|
|
@@ -1628,3 +2057,48 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1628
2057
|
: {}),
|
|
1629
2058
|
}
|
|
1630
2059
|
}
|
|
2060
|
+
|
|
2061
|
+
/**
|
|
2062
|
+
* The wildcard input globs, when not one of them matched a file: the
|
|
2063
|
+
* literal files beside them did. A lockfile plugin appends `package.json`
|
|
2064
|
+
* to every task's inputs, so `scr/**` for `src/**` kept a non-empty set
|
|
2065
|
+
* and its warning went unsaid: an edit to `src/` then replayed a stale hit.
|
|
2066
|
+
* On the miss path only; stops at the first matched file.
|
|
2067
|
+
*/
|
|
2068
|
+
function deadSourceGlobs(
|
|
2069
|
+
declared: InputsConfig | undefined,
|
|
2070
|
+
files: readonly { readonly path: string }[],
|
|
2071
|
+
projectRel: string,
|
|
2072
|
+
): string[] {
|
|
2073
|
+
const wild = (globs: readonly string[] | undefined): string[] =>
|
|
2074
|
+
(globs ?? []).filter((g) => !g.startsWith('!') && !isLiteralPattern(g))
|
|
2075
|
+
const own = wild(declared?.files)
|
|
2076
|
+
const ws = wild(declared?.workspaceFiles)
|
|
2077
|
+
if (own.length === 0 && ws.length === 0) return []
|
|
2078
|
+
const ownMatch = anyTaskGlob(own)
|
|
2079
|
+
const wsMatch = anyTaskGlob(ws)
|
|
2080
|
+
const prefix = projectRel === '' ? '' : `${projectRel}/`
|
|
2081
|
+
for (const { path: p } of files) {
|
|
2082
|
+
if (wsMatch(p)) return []
|
|
2083
|
+
if (p.startsWith(prefix) && ownMatch(p.slice(prefix.length))) return []
|
|
2084
|
+
}
|
|
2085
|
+
return [...own, ...ws]
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/**
|
|
2089
|
+
* Whether a moved input kept the bytes and the mtime its key folded, so only
|
|
2090
|
+
* its ctime moved. Asked once, of the file a re-check named: the common
|
|
2091
|
+
* path pays nothing.
|
|
2092
|
+
*/
|
|
2093
|
+
async function onlyCtimeMoved(fact: InputFact, from: number, cache: CacheLayer): Promise<boolean> {
|
|
2094
|
+
if (fact.digest === ABSENT_INPUT || fact.behind !== undefined) return false
|
|
2095
|
+
let st
|
|
2096
|
+
try {
|
|
2097
|
+
st = lstatSync(fact.path)
|
|
2098
|
+
} catch {
|
|
2099
|
+
return false
|
|
2100
|
+
}
|
|
2101
|
+
if (st.ctimeMs < from - stampGrainMs(st.ctimeMs)) return false
|
|
2102
|
+
if (st.mtimeMs >= from - stampGrainMs(st.mtimeMs)) return false
|
|
2103
|
+
return (await cache.hashFile(fact.path).catch(() => undefined)) === fact.digest
|
|
2104
|
+
}
|