@vzn/vx 0.0.652 → 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 +2 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- 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 +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- 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 +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- 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 +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- 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 +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- 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 +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- 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-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- 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 +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- 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 +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- 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 +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -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/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- 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 +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- 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 +17 -3
- 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 +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- 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 +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- 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 +46 -25
- 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
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,
|
|
@@ -94,6 +116,7 @@ import {
|
|
|
94
116
|
type HashCache,
|
|
95
117
|
type InputFact,
|
|
96
118
|
isKeyless,
|
|
119
|
+
keyOf,
|
|
97
120
|
movedInput,
|
|
98
121
|
type UpfrontGroupKey,
|
|
99
122
|
} from './task-hash.js'
|
|
@@ -128,6 +151,12 @@ export interface ExecuteArgs {
|
|
|
128
151
|
* machine. The hash is still computed — dependents fold it.
|
|
129
152
|
*/
|
|
130
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
|
|
131
160
|
/**
|
|
132
161
|
* `exec.remote: 'only'` placed on a remote executor: execute remotely,
|
|
133
162
|
* but this machine's disk is untouched — no output clean, no vx cache
|
|
@@ -147,6 +176,11 @@ export interface ExecuteArgs {
|
|
|
147
176
|
* where a locally-placed consumer fetches its producers from. */
|
|
148
177
|
deferred?: DeferredOutputs
|
|
149
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[]
|
|
150
184
|
/** The run's cache directory: walled off from a sandboxed task's grants. */
|
|
151
185
|
cacheDir?: string
|
|
152
186
|
/** Anchor for hrtime spans across all tasks in this run. */
|
|
@@ -248,6 +282,12 @@ export interface ExecuteArgs {
|
|
|
248
282
|
* (keyed-projects.ts `readableDeps`). Absent: none.
|
|
249
283
|
*/
|
|
250
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[]
|
|
251
291
|
/**
|
|
252
292
|
* Run-scoped list the miss path appends its output-directory snapshot
|
|
253
293
|
* request to, taken at run end (run.ts) instead of right after the save:
|
|
@@ -256,7 +296,7 @@ export interface ExecuteArgs {
|
|
|
256
296
|
*/
|
|
257
297
|
outputDirSnapshots?: OutputDirSnapshot[]
|
|
258
298
|
/** The run's save lane: a miss's cache save runs off the execution slot (save-lane.ts). */
|
|
259
|
-
deferSave?: (save: () => Promise<void
|
|
299
|
+
deferSave?: (save: () => Promise<void>, taskId: string) => Promise<boolean>
|
|
260
300
|
/**
|
|
261
301
|
* Where a deferred save's `landed` promise is parked by task id, for the
|
|
262
302
|
* in-flight join (admission.ts): a duplicate of this task in another run
|
|
@@ -302,6 +342,26 @@ export interface ExecuteArgs {
|
|
|
302
342
|
serversNested?: readonly (readonly string[])[]
|
|
303
343
|
/** When each command of the run ran and where it may write (command-windows.ts). */
|
|
304
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
|
|
305
365
|
}
|
|
306
366
|
|
|
307
367
|
/**
|
|
@@ -389,7 +449,8 @@ async function restoreProbed(
|
|
|
389
449
|
* A hit restored, or null when its artifact was removed after the probe (a
|
|
390
450
|
* `vx cache clean`, here or for a workspace sharing the cache directory): a
|
|
391
451
|
* miss. The restore has wiped the declared outputs by then; the run path
|
|
392
|
-
* 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.
|
|
393
454
|
*/
|
|
394
455
|
async function restoreOrMiss(
|
|
395
456
|
args: ExecuteArgs,
|
|
@@ -401,7 +462,7 @@ async function restoreOrMiss(
|
|
|
401
462
|
try {
|
|
402
463
|
return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
|
|
403
464
|
} catch (err) {
|
|
404
|
-
if (!(err instanceof ArtifactVanishedError)) throw err
|
|
465
|
+
if (!(err instanceof ArtifactVanishedError)) throw new RestoreFailed(err)
|
|
405
466
|
warnOf(args.log)(`[vx] ${args.node.id}: ${err.message} — running it`)
|
|
406
467
|
return null
|
|
407
468
|
}
|
|
@@ -456,16 +517,18 @@ async function executeGroupTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
456
517
|
)
|
|
457
518
|
}
|
|
458
519
|
const wallclockNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
459
|
-
const hash = await
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
+
)
|
|
469
532
|
return groupOutcome(args.node, args.upstream, hash, wallclockNs)
|
|
470
533
|
}
|
|
471
534
|
|
|
@@ -547,7 +610,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
547
610
|
// nothing to read under it, and nothing a later run could find.
|
|
548
611
|
const present = upstream.filter((u): u is TaskOutcome => u !== undefined)
|
|
549
612
|
const folded = new Set(
|
|
550
|
-
filterUpstreamHashes(
|
|
613
|
+
filterUpstreamHashes(
|
|
614
|
+
present,
|
|
615
|
+
cfg.inputs?.tasks,
|
|
616
|
+
node.projectName,
|
|
617
|
+
node.id,
|
|
618
|
+
standInsOf(node),
|
|
619
|
+
).map(([id]) => id),
|
|
551
620
|
)
|
|
552
621
|
const foldedTasks = expandGroupUpstream(present.filter((u) => folded.has(u.node.id)))
|
|
553
622
|
const keylessBy = isKeyless(cfg)
|
|
@@ -572,7 +641,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
572
641
|
// task's unfinished deps).
|
|
573
642
|
const unkeyedUpstream = foldedTasks.find((u) => u.unkeyed === true)
|
|
574
643
|
let unkeyed = unkeyedUpstream !== undefined
|
|
575
|
-
|
|
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
|
|
576
648
|
|
|
577
649
|
// The executor retains nothing: the entry's output is the attempt's
|
|
578
650
|
// `output` log, both streams in the order they reached the logger, kept
|
|
@@ -599,6 +671,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
599
671
|
// does an executor that declares `timeouts`.
|
|
600
672
|
let timeoutTimer: ReturnType<typeof setTimeout> | undefined
|
|
601
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
|
|
602
678
|
// The attempt's listener on the run's stop signal, taken off once the
|
|
603
679
|
// attempt settles. Left on, every task of the run added one: the list
|
|
604
680
|
// grew to the task count, and each add scans it for a duplicate, ~100 ms
|
|
@@ -609,12 +685,32 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
609
685
|
const abort = (): void => stop.abort(args.stopSignal?.reason)
|
|
610
686
|
unlistenStop?.()
|
|
611
687
|
unlistenStop = undefined
|
|
688
|
+
unbegun = false
|
|
689
|
+
withdrawUnbegun = undefined
|
|
612
690
|
if (args.stopSignal?.aborted === true) abort()
|
|
613
691
|
else if (args.stopSignal !== undefined) {
|
|
614
692
|
const run = args.stopSignal
|
|
615
693
|
run.addEventListener('abort', abort, { once: true })
|
|
616
694
|
unlistenStop = () => run.removeEventListener('abort', abort)
|
|
617
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
|
+
}
|
|
618
714
|
clearTimeout(timeoutTimer)
|
|
619
715
|
timeoutFired = false
|
|
620
716
|
if (effectiveTimeout !== undefined && !local && args.executor.timeouts !== true) {
|
|
@@ -635,7 +731,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
635
731
|
signal: AbortSignal | undefined,
|
|
636
732
|
): Promise<unknown> {
|
|
637
733
|
if (signal === undefined || isLocalExecutor(args.executor)) return running
|
|
638
|
-
const started =
|
|
734
|
+
const started = performance.now()
|
|
639
735
|
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
640
736
|
let onAbort: (() => void) | undefined
|
|
641
737
|
const abandoned = new Promise<ExecuteResult>((resolve) => {
|
|
@@ -650,10 +746,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
650
746
|
const reason: unknown = signal.reason
|
|
651
747
|
resolve({
|
|
652
748
|
exitCode: signalExitCode(reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM'),
|
|
653
|
-
durationMs:
|
|
749
|
+
durationMs: Math.round(performance.now() - started),
|
|
654
750
|
stdout: '',
|
|
655
751
|
stderr: '',
|
|
656
752
|
violations: [],
|
|
753
|
+
// What it started may still run and write the retry's outputs.
|
|
754
|
+
leftRunning: true,
|
|
657
755
|
...(timeoutFired ? { timedOut: true } : {}),
|
|
658
756
|
})
|
|
659
757
|
}, graceMs)
|
|
@@ -697,19 +795,21 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
697
795
|
const hash =
|
|
698
796
|
preProbed?.hash ??
|
|
699
797
|
args.upfrontKey ??
|
|
700
|
-
(await
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
+
))
|
|
713
813
|
|
|
714
814
|
// The local no-op half of `exec.remote: 'only'`: no remote executor took
|
|
715
815
|
// the task, so it succeeds without running. The hash was still computed —
|
|
@@ -727,26 +827,42 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
727
827
|
}
|
|
728
828
|
}
|
|
729
829
|
|
|
830
|
+
const ownInputs = {
|
|
831
|
+
projectDir: node.projectDir,
|
|
832
|
+
workspaceRoot: args.workspaceRoot,
|
|
833
|
+
declared: node.config.inputs,
|
|
834
|
+
}
|
|
730
835
|
const cleanArgs = {
|
|
731
836
|
projectDir: node.projectDir,
|
|
732
837
|
outputs,
|
|
733
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
838
|
+
nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
|
|
839
|
+
inputs: ownInputs,
|
|
734
840
|
committed: committedFiles(args.gitFilesCache, node.projectDir),
|
|
735
841
|
}
|
|
736
842
|
const wsCleanArgs = {
|
|
737
843
|
workspaceRoot: args.workspaceRoot,
|
|
738
844
|
outputs: wsOutputs,
|
|
845
|
+
inputs: ownInputs,
|
|
739
846
|
committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
|
|
740
847
|
}
|
|
741
848
|
// An ADDITIVE task (its outputs overlap an upstream's, with the edge that
|
|
742
849
|
// orders them; item 588) does not clean by glob — the glob selects the
|
|
743
850
|
// upstream's files it adds beside. Its outputs are stamped once before
|
|
744
851
|
// the first attempt, and after a 0 exit its own set is what the run
|
|
745
|
-
// 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
|
|
746
854
|
// earlier run are its command's to clean, as they are under Turbo.
|
|
747
855
|
const additive = (node.addsToOutputsOf?.length ?? 0) > 0
|
|
748
856
|
let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
|
|
749
857
|
let wsStampedBefore: ReadonlyMap<string, OutputStamp> | undefined
|
|
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
|
|
750
866
|
// `outputs.clean: false` leaves the outputs to the first attempt's
|
|
751
867
|
// command, and the save stores whatever they hold after it, a leftover
|
|
752
868
|
// included: the user opted out of the clean-tree guarantee
|
|
@@ -802,6 +918,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
802
918
|
: {}),
|
|
803
919
|
})
|
|
804
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
|
|
805
929
|
/** The other command that may have put a moved input back (`ownRewrite`). */
|
|
806
930
|
let rewroteBy: string | undefined
|
|
807
931
|
/**
|
|
@@ -846,7 +970,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
846
970
|
c.hash = MASKED + c.hash
|
|
847
971
|
// The values of secret-named variables, masked in what the task prints
|
|
848
972
|
// and what the cache keeps of it (L-11); null when there are none.
|
|
849
|
-
|
|
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()
|
|
850
978
|
// A runtime probe's row is named by its command, which a TS config may
|
|
851
979
|
// build from `process.env`: the run history stored it and `vx why`
|
|
852
980
|
// printed it (L-11).
|
|
@@ -868,16 +996,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
868
996
|
const inputChanges = described !== undefined ? args.explainMiss?.(node.id, captured) : undefined
|
|
869
997
|
// A plugin may keep the request past the run (the cache closed): it gets
|
|
870
998
|
// the upstream outputs read now. Only the local floor leaves them unread.
|
|
871
|
-
if (inputs !== undefined && !isLocalExecutor(args.executor))
|
|
999
|
+
if (inputs !== undefined && !isLocalExecutor(args.executor)) await described!.readUpstream()
|
|
872
1000
|
// A declared input set that resolves to NOTHING is the quiet stale hit:
|
|
873
1001
|
// the key stops moving with this project's source and every later run
|
|
874
1002
|
// is a hit. Almost always a glob written against the wrong directory
|
|
875
1003
|
// (`lib/**` in a package that builds `src/`). Said once per miss, on the
|
|
876
1004
|
// run's status line — the task itself succeeds.
|
|
877
1005
|
const declaredInputs = [...(cfg.inputs?.files ?? []), ...(cfg.inputs?.workspaceFiles ?? [])]
|
|
878
|
-
|
|
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) {
|
|
879
1013
|
warnOf(log)(
|
|
880
|
-
`[vx] ${node.id}: inputs matched no files (${
|
|
1014
|
+
`[vx] ${node.id}: inputs matched no files (${unmatched.join(', ')}) — ` +
|
|
881
1015
|
`the key will not change when this project's source does` +
|
|
882
1016
|
bracketLiteralHint([
|
|
883
1017
|
...(cfg.inputs?.files ?? []).map((g) => [node.projectDir, g] as const),
|
|
@@ -910,6 +1044,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
910
1044
|
// the last attempt alone read `max 407ms` beside a footer of 837 (item
|
|
911
1045
|
// 1101). A saved entry keeps the attempt that produced it, what a hit saves.
|
|
912
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
|
+
}
|
|
913
1077
|
|
|
914
1078
|
// One task attempt: clean the declared outputs (before EVERY attempt — so a
|
|
915
1079
|
// stale prior-build artifact can't survive into a fresh run, and a failed
|
|
@@ -922,6 +1086,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
922
1086
|
result: ExecuteResult
|
|
923
1087
|
exitCode: number
|
|
924
1088
|
withdrawn?: true
|
|
1089
|
+
unbegun?: true
|
|
925
1090
|
}> {
|
|
926
1091
|
// The first attempt under `clean: false` finds its last outputs (tsc's `.tsbuildinfo`).
|
|
927
1092
|
const clean = willWrite && !deferralRequested && !(keepOutputs && attempt === 1)
|
|
@@ -929,33 +1094,92 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
929
1094
|
// tree would replace a stale build with nothing at all. The eligibility
|
|
930
1095
|
// gate guarantees no key in this run can see what stays behind, and the
|
|
931
1096
|
// summary names every deferred task so `dist/` is not silently stale.
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
+
})
|
|
959
1183
|
// What the attempt before this one left, put back if this one is
|
|
960
1184
|
// withdrawn: it stands, with its output and its refusals.
|
|
961
1185
|
const standing = { output, violations, flushMasked }
|
|
@@ -997,6 +1221,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
997
1221
|
} catch (thrown) {
|
|
998
1222
|
running = Promise.reject(thrown)
|
|
999
1223
|
}
|
|
1224
|
+
// A failure that landed while the request was built: the executor holds it now.
|
|
1225
|
+
if (args.failFast?.aborted === true) withdrawUnbegun?.()
|
|
1000
1226
|
let res = await boundAfterAbort(running, req.signal)
|
|
1001
1227
|
.then((r: unknown) => {
|
|
1002
1228
|
assertExecuteResult(args.executor.name, node.id, r)
|
|
@@ -1049,6 +1275,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1049
1275
|
throw thrown
|
|
1050
1276
|
})
|
|
1051
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
|
+
}
|
|
1052
1287
|
if (secrets !== null)
|
|
1053
1288
|
res = {
|
|
1054
1289
|
...res,
|
|
@@ -1100,12 +1335,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1100
1335
|
// A child we SIGTERMed for exceeding the timeout is a genuine failure —
|
|
1101
1336
|
// stream a clear line so the 143 exit reads as a timeout. One that outlived
|
|
1102
1337
|
// the grace died of the SIGKILL, and the line said SIGTERM over an exit
|
|
1103
|
-
// 137 (item 1063)
|
|
1338
|
+
// 137 (item 1063); so did a shell dead of the SIGTERM whose leftover the
|
|
1339
|
+
// SIGKILL took.
|
|
1104
1340
|
if (res.timedOut) {
|
|
1105
|
-
|
|
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)'
|
|
1106
1347
|
log.taskStderr(
|
|
1107
1348
|
node,
|
|
1108
|
-
`${midLine ? '\n' : ''}[vx] timed out after ${effectiveTimeout}ms —
|
|
1349
|
+
`${midLine ? '\n' : ''}[vx] timed out after ${effectiveTimeout}ms — ${how}\n`,
|
|
1109
1350
|
)
|
|
1110
1351
|
// Force a non-zero classification even if the child TRAPPED SIGTERM and
|
|
1111
1352
|
// still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
|
|
@@ -1138,6 +1379,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1138
1379
|
bins: taskBinDirs(node, args.workspaceRoot),
|
|
1139
1380
|
signal: res.signal,
|
|
1140
1381
|
hidden: req.sandbox && ((f: string) => !sandboxReads(req.sandbox!, f)),
|
|
1382
|
+
stderr: errTail,
|
|
1141
1383
|
})
|
|
1142
1384
|
// The line quotes the command's first word, which a config may have
|
|
1143
1385
|
// built from a secret: masked as the task's own output is (L-37).
|
|
@@ -1179,15 +1421,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1179
1421
|
// printed a blank line under the OUTPUT rule or the task's last line. Glued, a frame read
|
|
1180
1422
|
// `partialvx: retrying …`.
|
|
1181
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 = ''
|
|
1182
1426
|
// The attempt's output as the logger got it (masked): the entry's when
|
|
1183
1427
|
// the task saves, else only what a failure reports (`failedOutput`).
|
|
1184
1428
|
let output: BoundedCapture | undefined
|
|
1185
1429
|
// Masked again whole: the live stream lets a held piece go after a pause.
|
|
1186
|
-
const
|
|
1187
|
-
const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [], storeMask)
|
|
1430
|
+
const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [], secrets ?? undefined)
|
|
1188
1431
|
const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
|
|
1189
1432
|
// Each failed attempt's output, beside `failedAttempts`: kept for `vx last --log`.
|
|
1190
1433
|
const attemptOutputs: string[] = []
|
|
1434
|
+
// Any attempt's shell left a process running, not only the last one's.
|
|
1435
|
+
let leftRunning = false
|
|
1191
1436
|
// The sampling of the attempt's process tree, stopped once it settles.
|
|
1192
1437
|
let untrack: (() => TreeLoad | undefined) | undefined
|
|
1193
1438
|
// What the last attempt's samples saw, for the run history.
|
|
@@ -1221,6 +1466,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1221
1466
|
for (;;) {
|
|
1222
1467
|
attempt++
|
|
1223
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
|
+
}
|
|
1224
1481
|
result = a.result
|
|
1225
1482
|
effectiveExitCode = a.exitCode
|
|
1226
1483
|
if (a.withdrawn) {
|
|
@@ -1229,6 +1486,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1229
1486
|
attemptOutputs.pop()
|
|
1230
1487
|
break
|
|
1231
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
|
+
}
|
|
1232
1496
|
forgetUndeclaredWrites(args, writeReach)
|
|
1233
1497
|
if (writesFingerprint) args.fingerprintWatch?.wrote()
|
|
1234
1498
|
spentMs += result.durationMs
|
|
@@ -1288,20 +1552,57 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1288
1552
|
exitCode: effectiveExitCode,
|
|
1289
1553
|
...(result.timedOut === true ? { timedOut: true as const } : {}),
|
|
1290
1554
|
})
|
|
1291
|
-
|
|
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}`
|
|
1292
1582
|
log.taskStderr(
|
|
1293
1583
|
node,
|
|
1294
|
-
`${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`,
|
|
1295
1585
|
)
|
|
1296
1586
|
}
|
|
1297
1587
|
|
|
1298
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
|
+
}
|
|
1299
1599
|
const kept =
|
|
1300
1600
|
willSave || args.keepRun !== undefined
|
|
1301
1601
|
? new BoundedCapture()
|
|
1302
1602
|
: new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
|
|
1303
1603
|
output = kept
|
|
1304
1604
|
midLine = false
|
|
1605
|
+
errTail = ''
|
|
1305
1606
|
const toOut = (t: string): void => {
|
|
1306
1607
|
kept.push(t)
|
|
1307
1608
|
if (t.length > 0) midLine = !t.endsWith('\n')
|
|
@@ -1310,19 +1611,26 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1310
1611
|
const toErr = (t: string): void => {
|
|
1311
1612
|
kept.push(t, true)
|
|
1312
1613
|
if (t.length > 0) midLine = !t.endsWith('\n')
|
|
1614
|
+
errTail = (errTail + t).slice(-2048)
|
|
1313
1615
|
log.taskStderr(node, t)
|
|
1314
1616
|
}
|
|
1315
1617
|
const masked = secrets && maskedStreams(secrets, toOut, toErr)
|
|
1316
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 })
|
|
1317
1625
|
const base: ExecuteRequest = {
|
|
1318
1626
|
taskId: node.id,
|
|
1319
1627
|
...(attempt > 1 ? { attempt } : {}),
|
|
1320
1628
|
workspaceRoot: args.workspaceRoot,
|
|
1321
1629
|
command: step.command,
|
|
1322
|
-
forwardArgs: effectiveForwardArgs,
|
|
1630
|
+
forwardArgs: own(effectiveForwardArgs),
|
|
1323
1631
|
cwd: node.projectDir,
|
|
1324
1632
|
env,
|
|
1325
|
-
envDefine: step.env?.define ?? {},
|
|
1633
|
+
envDefine: own(step.env?.define ?? {}),
|
|
1326
1634
|
capture,
|
|
1327
1635
|
onStdout: masked ? masked.out : toOut,
|
|
1328
1636
|
onStderr: masked ? masked.err : toErr,
|
|
@@ -1341,11 +1649,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1341
1649
|
...(cfgCacheable && !remoteOnly && !willSave ? { unsaved: true } : {}),
|
|
1342
1650
|
...(remoteOnly ? { remoteOnly: true } : {}),
|
|
1343
1651
|
...(node.platform !== undefined ? { platform: node.platform } : {}),
|
|
1652
|
+
...(node.worker !== undefined ? { worker: node.worker } : {}),
|
|
1344
1653
|
...(deferralRequested ? { download: 'deferred' as const } : {}),
|
|
1345
1654
|
// `--force`/`--no-cache` reach a remote executor's private record
|
|
1346
1655
|
// through this flag — the policy gates above only cover vx's OWN cache.
|
|
1347
1656
|
...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
|
|
1348
|
-
outputs: { files: outputs, workspaceFiles: wsOutputs },
|
|
1657
|
+
outputs: { files: own(outputs), workspaceFiles: own(wsOutputs) },
|
|
1349
1658
|
...(args.terminal === true ? { terminal: true as const } : {}),
|
|
1350
1659
|
...(args.servers === undefined
|
|
1351
1660
|
? {}
|
|
@@ -1356,11 +1665,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1356
1665
|
}),
|
|
1357
1666
|
}
|
|
1358
1667
|
// The signal last: it arms a plugin's `exec.timeout`, and the sandbox's arming
|
|
1359
|
-
//
|
|
1668
|
+
// above is vx's work, not the task's. Armed first, a 60 ms timeout
|
|
1360
1669
|
// expired before the spawn and failed `echo` as timed out, unrun.
|
|
1361
1670
|
if (!userSandbox) return { ...base, signal: requestSignal(local) }
|
|
1362
|
-
|
|
1363
|
-
if (args.executor.remote !== true) await args.armSandbox?.()
|
|
1671
|
+
const deps = await dependencyReads(args.readableDeps?.(node) ?? [], args.workspaceRoot)
|
|
1364
1672
|
const sb = await sandboxRequestFor(
|
|
1365
1673
|
node,
|
|
1366
1674
|
step.sandbox!,
|
|
@@ -1368,7 +1676,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1368
1676
|
cfgCacheable ? args.keyedProjects(node) : undefined,
|
|
1369
1677
|
args.nestedProjectDirs,
|
|
1370
1678
|
args.cacheDir,
|
|
1371
|
-
|
|
1679
|
+
deps.reads,
|
|
1680
|
+
deps.reported,
|
|
1681
|
+
args.nestedDirsOf,
|
|
1372
1682
|
)
|
|
1373
1683
|
placeholders = sb.placeholders
|
|
1374
1684
|
withheld = sb.withheld
|
|
@@ -1397,7 +1707,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1397
1707
|
// edit's bytes under the old key (X-123).
|
|
1398
1708
|
const materialize = result.outputs?.kind === 'deferred' ? result.outputs.materialize : undefined
|
|
1399
1709
|
if (materialize !== undefined) {
|
|
1400
|
-
const keyed = await keyStillTrue()
|
|
1710
|
+
const keyed = (await keyStillTrue()) && (await args.serverDiedUnder?.(node)) !== true
|
|
1401
1711
|
args.deferred?.register(node.id, {
|
|
1402
1712
|
materialize,
|
|
1403
1713
|
...(keyed ? { hash } : {}),
|
|
@@ -1416,50 +1726,53 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1416
1726
|
willSave &&
|
|
1417
1727
|
nothingLeftRunning() &&
|
|
1418
1728
|
(await keyStillTrue()) &&
|
|
1729
|
+
(await args.serverDiedUnder?.(node)) !== true &&
|
|
1419
1730
|
(own = await ownOutputs()) !== undefined
|
|
1420
1731
|
) {
|
|
1421
|
-
const { landed } = await
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
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
|
+
)
|
|
1443
1756
|
// A run that settles the outcome on `landed` learns there whether the
|
|
1444
|
-
// 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).
|
|
1445
1758
|
if (args.deferredSaves !== undefined) args.deferredSaves.set(node.id, landed)
|
|
1446
|
-
else saved = true
|
|
1759
|
+
else saved = (await landed).saved === true
|
|
1447
1760
|
} else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants === true) {
|
|
1448
1761
|
// Nothing reads after it, so nothing to mark: only its row's `outputs`.
|
|
1449
1762
|
if (effectiveExitCode === 0)
|
|
1450
1763
|
unsavedBytes = await unsavedOutputBytes({
|
|
1451
1764
|
node,
|
|
1452
1765
|
workspaceRoot: args.workspaceRoot,
|
|
1453
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
1766
|
+
nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
|
|
1454
1767
|
outputs,
|
|
1455
1768
|
wsOutputs,
|
|
1456
1769
|
})
|
|
1457
1770
|
} else if (cfgCacheable && !remoteOnly && !deferralRequested) {
|
|
1458
1771
|
// Ran here and saves nothing — failed, a read-only policy, a tainted
|
|
1459
|
-
// upstream, a
|
|
1460
|
-
// have marked, and may have rewritten an
|
|
1461
|
-
// save catches. A same-run reader keyed
|
|
1462
|
-
// 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
|
|
1463
1776
|
// withheld save already dropped the project; this finds the same move
|
|
1464
1777
|
// again and drops it again.
|
|
1465
1778
|
const endCheck = span('miss: recheck inputs')
|
|
@@ -1471,7 +1784,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1471
1784
|
unsavedBytes = await markUnsaved({
|
|
1472
1785
|
node,
|
|
1473
1786
|
workspaceRoot: args.workspaceRoot,
|
|
1474
|
-
nestedProjectDirs: args.nestedProjectDirs,
|
|
1787
|
+
nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
|
|
1475
1788
|
gitFilesCache: args.gitFilesCache,
|
|
1476
1789
|
outputs,
|
|
1477
1790
|
wsOutputs,
|
|
@@ -1482,11 +1795,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1482
1795
|
/**
|
|
1483
1796
|
* What an additive run saves as its own, or undefined when it removed a
|
|
1484
1797
|
* file it found: no entry replays a removal (`ownOutputsSince`), so the
|
|
1485
|
-
* 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`).
|
|
1486
1800
|
*/
|
|
1487
1801
|
async function ownOutputs(): Promise<
|
|
1488
1802
|
{ ownOutputFiles?: string[]; ownWsOutputFiles?: string[] } | undefined
|
|
1489
1803
|
> {
|
|
1804
|
+
if (failedTouchedFound) return undefined
|
|
1490
1805
|
const mine: { ownOutputFiles?: string[]; ownWsOutputFiles?: string[] } = {}
|
|
1491
1806
|
if (stampedBefore !== undefined) {
|
|
1492
1807
|
const files = await ownOutputsSince(cleanArgs, stampedBefore)
|
|
@@ -1501,13 +1816,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1501
1816
|
return mine
|
|
1502
1817
|
}
|
|
1503
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
|
+
|
|
1504
1842
|
/**
|
|
1505
1843
|
* A process the shell left behind still held its output when the drain
|
|
1506
1844
|
* bound cut it, so it may still write outputs: a save now would file a
|
|
1507
1845
|
* tree the run does not leave, and a hit elsewhere would replay it.
|
|
1508
1846
|
*/
|
|
1509
1847
|
function nothingLeftRunning(): boolean {
|
|
1510
|
-
if (
|
|
1848
|
+
if (!leftRunning) return true
|
|
1511
1849
|
warnOf(log)(
|
|
1512
1850
|
`[vx] ${node.id}: a process it left running may still write its outputs — ` +
|
|
1513
1851
|
`the result stands, but is not saved; wait for it in the command (\`wait\`)`,
|
|
@@ -1528,12 +1866,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1528
1866
|
const changed = new Set(result.changed.map((f) => path.join(args.workspaceRoot, f)))
|
|
1529
1867
|
const there = keyedInputs(described!.facts).find((f) => changed.has(f.path))
|
|
1530
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
|
+
}
|
|
1531
1879
|
}
|
|
1532
1880
|
return (
|
|
1533
1881
|
(await movedInput(
|
|
1534
1882
|
keyedInputs(described!.facts),
|
|
1535
1883
|
cache,
|
|
1536
|
-
|
|
1884
|
+
commandFrom,
|
|
1537
1885
|
stability.strategy === 'mutable' ? ownRewrite : undefined,
|
|
1538
1886
|
)) ?? described!.added()
|
|
1539
1887
|
)
|
|
@@ -1558,22 +1906,59 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1558
1906
|
// `'unchecked'` skips the check of the command's own window; a key the
|
|
1559
1907
|
// re-derive before the command already disagreed with still withholds,
|
|
1560
1908
|
// since it never described the bytes the command read.
|
|
1561
|
-
|
|
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
|
+
}
|
|
1562
1920
|
const endCheck = span('miss: recheck inputs')
|
|
1563
1921
|
const moved = await movedSinceKey()
|
|
1564
1922
|
endCheck()
|
|
1565
1923
|
if (moved === undefined) return true
|
|
1566
|
-
const
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
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`)
|
|
1570
1939
|
// A task that regenerates its own input (TanStack Router's committed
|
|
1571
1940
|
// `routeTree.gen.ts`, rewritten to the same bytes by every build) is
|
|
1572
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.
|
|
1573
1952
|
const hint =
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
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
|
+
: ''))
|
|
1577
1962
|
if (stability.severity === 'error') {
|
|
1578
1963
|
warnOf(log)(
|
|
1579
1964
|
`[vx] ${node.id}: ${what} — the result is not saved, ` +
|
|
@@ -1582,12 +1967,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1582
1967
|
movedFails = moved === null ? '' : printable(relPosix(args.workspaceRoot, moved))
|
|
1583
1968
|
} else {
|
|
1584
1969
|
warnOf(log)(
|
|
1585
|
-
`[vx] ${node.id}: ${what} —
|
|
1586
|
-
|
|
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,
|
|
1587
1975
|
)
|
|
1588
1976
|
}
|
|
1589
1977
|
forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
|
|
1590
1978
|
unkeyed = true
|
|
1979
|
+
driftedInput = moved
|
|
1591
1980
|
return false
|
|
1592
1981
|
}
|
|
1593
1982
|
|
|
@@ -1616,11 +2005,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1616
2005
|
: {}),
|
|
1617
2006
|
...(inputChanges !== undefined ? { inputChanges } : {}),
|
|
1618
2007
|
...(unkeyed ? { unkeyed: true as const } : {}),
|
|
2008
|
+
...(driftedInput !== undefined ? { driftedInput } : {}),
|
|
1619
2009
|
...(keylessBy !== undefined ? { keylessBy } : {}),
|
|
1620
2010
|
...(saved ? { saved: true as const } : {}),
|
|
1621
2011
|
...(unsavedBytes !== undefined ? { outputBytes: unsavedBytes } : {}),
|
|
1622
2012
|
...(effectiveExitCode !== 0 && output !== undefined
|
|
1623
|
-
? {
|
|
2013
|
+
? {
|
|
2014
|
+
failedOutput: failedOutputLog(
|
|
2015
|
+
output,
|
|
2016
|
+
willSave || args.keepRun !== undefined,
|
|
2017
|
+
secrets ?? undefined,
|
|
2018
|
+
),
|
|
2019
|
+
}
|
|
1624
2020
|
: {}),
|
|
1625
2021
|
...(args.keepRun !== undefined &&
|
|
1626
2022
|
(effectiveExitCode !== 0 ? args.keepRun.failures : !willSave) &&
|
|
@@ -1661,3 +2057,48 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1661
2057
|
: {}),
|
|
1662
2058
|
}
|
|
1663
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
|
+
}
|