@vzn/vx 0.0.650 → 0.0.652
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 +7 -23
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/schemas/show.json +14 -20
- package/src/bin.ts +21 -31
- package/src/cache/archive.ts +1 -1
- package/src/cache/cache.ts +221 -30
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/history-stream.ts +134 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +77 -14
- package/src/cache/layer.ts +17 -6
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +5 -3
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +56 -17
- package/src/cache/schema.ts +15 -2
- package/src/cli/cache.ts +28 -18
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +14 -25
- package/src/cli/index.ts +28 -9
- package/src/cli/info.ts +16 -8
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +246 -204
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +8 -2
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +45 -52
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +3 -2
- package/src/cli/watch-set.ts +4 -4
- package/src/cli/watch.ts +94 -52
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/config.ts +47 -30
- package/src/exec/executor.ts +42 -2
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +317 -23
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/definition.ts +16 -3
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +17 -11
- package/src/graph/task-graph.ts +51 -24
- package/src/handoff.ts +10 -7
- package/src/history/failure-mode.ts +24 -1
- package/src/history/history.ts +5 -0
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +14 -6
- package/src/index.ts +7 -3
- package/src/orchestrator/admission.ts +1 -1
- package/src/orchestrator/command-windows.ts +1 -1
- package/src/orchestrator/deferred-outputs.ts +1 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +5 -7
- package/src/orchestrator/excluded-keys.ts +1 -1
- package/src/orchestrator/execute-task.ts +67 -20
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/hit-restore.ts +5 -6
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/keyed-projects.ts +28 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +58 -11
- package/src/orchestrator/persistent-task.ts +5 -1
- package/src/orchestrator/placement.ts +1 -1
- package/src/orchestrator/plan.ts +7 -3
- package/src/orchestrator/plugin-host.ts +67 -2
- package/src/orchestrator/plugin.ts +53 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +62 -13
- package/src/orchestrator/run-keys.ts +1 -1
- package/src/orchestrator/run-records.ts +88 -59
- package/src/orchestrator/run-report.ts +1 -1
- package/src/orchestrator/run.ts +145 -41
- package/src/orchestrator/sandbox-request.ts +52 -9
- package/src/orchestrator/shell-verdict.ts +9 -0
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/stable-keys.ts +17 -18
- package/src/orchestrator/task-artifacts.ts +1 -1
- package/src/orchestrator/task-hash.ts +21 -7
- package/src/orchestrator/telemetry.ts +5 -1
- package/src/report/events.ts +6 -14
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +283 -175
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +74 -39
- package/src/report/plain-output.ts +3 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +105 -136
- package/src/settings.ts +8 -8
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +191 -14
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +21 -3
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +33 -1
- package/src/util/tail.ts +4 -1
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-schema.ts +70 -63
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +36 -1
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +16 -9
- package/src/workspace/workspace.ts +3 -0
- package/src/util/colors.ts +0 -133
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /package/src/{report → ui}/terminal-signals.ts +0 -0
package/src/orchestrator/run.ts
CHANGED
|
@@ -8,11 +8,13 @@ import { EMPTY_LOCK, inputStabilityEnv, LOCK_INVALID, lockEntryBroken } from '..
|
|
|
8
8
|
import { realpathSync } from 'node:fs'
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import {
|
|
11
|
+
historyFiles,
|
|
11
12
|
indexDb,
|
|
12
13
|
Cache,
|
|
13
14
|
type CachePolicy,
|
|
14
15
|
cachesNothing,
|
|
15
16
|
formatCachePolicy,
|
|
17
|
+
HistoryStream,
|
|
16
18
|
networkConcurrency,
|
|
17
19
|
keepRunNotes,
|
|
18
20
|
stopRuntimeProbes,
|
|
@@ -28,10 +30,11 @@ import {
|
|
|
28
30
|
} from '../exec/index.js'
|
|
29
31
|
import { DeferredOutputs } from './deferred-outputs.js'
|
|
30
32
|
import { resolveDownloadModes } from './download-policy.js'
|
|
31
|
-
import type { MachineReport, TaskExecutor } from '../exec/index.js'
|
|
33
|
+
import type { MachineReport, TaskExecutor, WorkerAdvice } from '../exec/index.js'
|
|
32
34
|
import {
|
|
33
35
|
deadServerBehind,
|
|
34
36
|
declaredTask,
|
|
37
|
+
isCacheHit,
|
|
35
38
|
isGroupTask,
|
|
36
39
|
markSurfacedDeps,
|
|
37
40
|
runGraph,
|
|
@@ -39,11 +42,12 @@ import {
|
|
|
39
42
|
type TaskNode,
|
|
40
43
|
type TaskOutcome,
|
|
41
44
|
} from '../graph/index.js'
|
|
45
|
+
import { graphCounts } from './graph-memo.js'
|
|
42
46
|
import {
|
|
43
47
|
mark,
|
|
44
48
|
stageTimes,
|
|
45
49
|
MAX_TIMEOUT_MS,
|
|
46
|
-
|
|
50
|
+
timingLines,
|
|
47
51
|
ulid,
|
|
48
52
|
isLiteralPattern,
|
|
49
53
|
listed,
|
|
@@ -53,13 +57,18 @@ import {
|
|
|
53
57
|
secretMask,
|
|
54
58
|
isFsRefusal,
|
|
55
59
|
isUserError,
|
|
56
|
-
detectColors,
|
|
57
|
-
type ColorSupport,
|
|
58
60
|
SecretBroker,
|
|
59
61
|
thrownMessage,
|
|
60
|
-
type ui,
|
|
61
62
|
} from '../util/index.js'
|
|
62
|
-
import
|
|
63
|
+
import * as ui from '../ui/index.js'
|
|
64
|
+
|
|
65
|
+
/** The stage table `VX_DEBUG_TIMING=1` asks for, on stderr. */
|
|
66
|
+
function printTimings(): void {
|
|
67
|
+
const lines = timingLines()
|
|
68
|
+
if (lines !== undefined) ui.print('err', ui.trace(lines))
|
|
69
|
+
}
|
|
70
|
+
import { detectColors, type ColorSupport } from '../ui/index.js'
|
|
71
|
+
import { keyedProjects, readableDeps } from './keyed-projects.js'
|
|
63
72
|
import { isDefaultBuild } from '../graph/index.js'
|
|
64
73
|
import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
|
|
65
74
|
import { recordOutputDirSnapshots, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
|
|
@@ -70,7 +79,6 @@ import { excludedTaint } from './excluded-keys.js'
|
|
|
70
79
|
import { dependedOn, keyUpstream } from './upstream.js'
|
|
71
80
|
import { createLiveUsage } from './live-usage.js'
|
|
72
81
|
import {
|
|
73
|
-
blocksCounts,
|
|
74
82
|
busLogger,
|
|
75
83
|
createEventBus,
|
|
76
84
|
createForecast,
|
|
@@ -133,7 +141,12 @@ import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
|
|
|
133
141
|
import { askRemote, serversToHold } from './held-servers.js'
|
|
134
142
|
|
|
135
143
|
import { writeRunFailures } from './run-failures.js'
|
|
136
|
-
import {
|
|
144
|
+
import {
|
|
145
|
+
assembleRunRecords,
|
|
146
|
+
recordLateRemoteErrors,
|
|
147
|
+
type RecordedOutcome,
|
|
148
|
+
runRecordOf,
|
|
149
|
+
} from './run-records.js'
|
|
137
150
|
import {
|
|
138
151
|
hasEnded,
|
|
139
152
|
disposeServers,
|
|
@@ -150,6 +163,7 @@ import {
|
|
|
150
163
|
createMissExplainer,
|
|
151
164
|
detectFlaky,
|
|
152
165
|
type FlakyCandidate,
|
|
166
|
+
knownFlaky,
|
|
153
167
|
LocalHistoryProvider,
|
|
154
168
|
} from '../history/index.js'
|
|
155
169
|
import type { RunOptions, RunSummary } from './options.js'
|
|
@@ -356,6 +370,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
356
370
|
const detach = (): void => {
|
|
357
371
|
unsubscribeTerminal()
|
|
358
372
|
unsubscribeWarned()
|
|
373
|
+
// A run refused or thrown before its tasks (an unknown task, a plugin
|
|
374
|
+
// stage that threw) never reached the end that takes the live region
|
|
375
|
+
// down: a load slow enough to show its step left `vx:load running`
|
|
376
|
+
// on screen, and the CLI's refusal was written onto its last line.
|
|
377
|
+
terminal?.runDone?.()
|
|
359
378
|
terminal?.settle()
|
|
360
379
|
}
|
|
361
380
|
// A plugin's `output` renders the run from its start; core's renderer
|
|
@@ -442,11 +461,11 @@ async function runOnBus(
|
|
|
442
461
|
options = withRunSettings(options, prepared.workspaceConfig)
|
|
443
462
|
inputStability = {
|
|
444
463
|
run: inputStabilityEnv(),
|
|
445
|
-
...(prepared.workspaceConfig?.
|
|
446
|
-
? { workspace: prepared.workspaceConfig.
|
|
464
|
+
...(prepared.workspaceConfig?.inputs?.stability !== undefined
|
|
465
|
+
? { workspace: prepared.workspaceConfig.inputs.stability }
|
|
447
466
|
: {}),
|
|
448
467
|
}
|
|
449
|
-
cleanOutputs = resolveSetting('
|
|
468
|
+
cleanOutputs = resolveSetting('outputs.clean', prepared.workspaceConfig).value as
|
|
450
469
|
| boolean
|
|
451
470
|
| undefined
|
|
452
471
|
} catch (err) {
|
|
@@ -629,15 +648,15 @@ async function runOnBus(
|
|
|
629
648
|
// deliberate — on a machine with no remote pool the ambient state already
|
|
630
649
|
// is what the task would have produced — but it must not be SILENT: a task
|
|
631
650
|
// reporting success while doing nothing is indistinguishable from one that
|
|
632
|
-
// worked, and the usual cause (no `cache
|
|
651
|
+
// worked, and the usual cause (no `cache: true`, so no executor can
|
|
633
652
|
// describe its inputs to a worker) cannot be read off the outcome.
|
|
634
653
|
if (placements.remoteOnlyNoop.size > 0) {
|
|
635
654
|
const anyRemote = executors.some((e) => e.remote === true)
|
|
636
655
|
for (const id of placements.remoteOnlyNoop) {
|
|
637
656
|
const why = !anyRemote
|
|
638
657
|
? 'no remote executor is declared'
|
|
639
|
-
: nodes.get(id)?.config.cache
|
|
640
|
-
? 'no remote executor accepted it — the task
|
|
658
|
+
: nodes.get(id)?.config.cache !== true
|
|
659
|
+
? 'no remote executor accepted it — the task has no `cache: true`, so there is no input set to ship'
|
|
641
660
|
: 'no remote executor accepted it'
|
|
642
661
|
warnOf(log)(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
|
|
643
662
|
}
|
|
@@ -843,7 +862,7 @@ async function runOnBus(
|
|
|
843
862
|
// SRT's proxies holding the process open.
|
|
844
863
|
const surelyRuns = (n: TaskNode): boolean =>
|
|
845
864
|
n.config.exec?.persistent !== undefined ||
|
|
846
|
-
n.config.cache
|
|
865
|
+
n.config.cache !== true ||
|
|
847
866
|
!(policy.localRead || policy.remoteRead)
|
|
848
867
|
// Only what runs here arms this machine's sandbox: a `sandbox` executor
|
|
849
868
|
// enforces it where it runs the task, so a distributed run's
|
|
@@ -864,6 +883,7 @@ async function runOnBus(
|
|
|
864
883
|
}
|
|
865
884
|
if (sandboxed.some(surelyRuns)) prewarm()
|
|
866
885
|
const keyed = keyedProjects(nodes, prepared.keyOnly)
|
|
886
|
+
const readable = readableDeps(nodes, prepared.keyOnly)
|
|
867
887
|
const outputDirSnapshots: OutputDirSnapshot[] = []
|
|
868
888
|
// Saves run off the execution slot, twice the cap at once (memory:
|
|
869
889
|
// each pack holds an artifact's bytes); a failed save is a miss next
|
|
@@ -913,14 +933,39 @@ async function runOnBus(
|
|
|
913
933
|
const fault = executorFaults(warnOf(log))
|
|
914
934
|
const footers = executors.filter((e) => e.footer !== undefined)
|
|
915
935
|
const placers = executors.filter((e) => e.running !== undefined)
|
|
916
|
-
//
|
|
917
|
-
// over the graph, built on the
|
|
918
|
-
|
|
919
|
-
|
|
936
|
+
// The scheduler's dependents count, and every row's `blocks` (the live
|
|
937
|
+
// list's and the results'): one closure over the graph, built on the
|
|
938
|
+
// first that asks.
|
|
939
|
+
let counted: ReadonlyMap<string, number> | undefined
|
|
940
|
+
const blocksNow = (): ReadonlyMap<string, number> =>
|
|
941
|
+
(counted ??= graphCounts(nodes, hashCache.folds, hashCache.graph))
|
|
942
|
+
// Every row's `usual` and the forecast's timings: history read once, on
|
|
943
|
+
// the first row that asks, so a run that executes nothing never reads
|
|
944
|
+
// it. It was read only a second into a live run, so rows before that,
|
|
945
|
+
// a short run's and every log's showed none (2026-10-29).
|
|
946
|
+
let p50s: ReadonlyMap<string, number> | undefined
|
|
947
|
+
const p50sNow = (): ReadonlyMap<string, number> => {
|
|
948
|
+
if (p50s !== undefined) return p50s
|
|
949
|
+
const ids: string[] = []
|
|
950
|
+
for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
|
|
951
|
+
try {
|
|
952
|
+
p50s = new LocalHistoryProvider(
|
|
953
|
+
indexDb(prepared.localCache),
|
|
954
|
+
undefined,
|
|
955
|
+
prepared.branch(),
|
|
956
|
+
taskDefinitions(nodes.values()),
|
|
957
|
+
).p50sNow(ids)
|
|
958
|
+
} catch {
|
|
959
|
+
// History is observability: an unreadable one knows no task.
|
|
960
|
+
p50s = new Map()
|
|
961
|
+
}
|
|
962
|
+
return p50s
|
|
963
|
+
}
|
|
920
964
|
// The live rows' `cpu` and `mem`; it looks only once the live list asks.
|
|
921
965
|
const liveUsage = createLiveUsage()
|
|
922
966
|
const runContext = {
|
|
923
967
|
blocks: blocksNow,
|
|
968
|
+
usual: (id: string) => p50sNow().get(id),
|
|
924
969
|
usage: liveUsage.usage,
|
|
925
970
|
...(footers.length > 0
|
|
926
971
|
? { executorRows: () => footers.flatMap((e) => executorFooter(e, fault)) }
|
|
@@ -979,17 +1024,7 @@ async function runOnBus(
|
|
|
979
1024
|
// never reads history.
|
|
980
1025
|
terminal?.forecast(
|
|
981
1026
|
() => createForecast(nodes, new Map(), concurrency, blocksNow()),
|
|
982
|
-
async () =>
|
|
983
|
-
const ids: string[] = []
|
|
984
|
-
for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
|
|
985
|
-
const p50s = await new LocalHistoryProvider(
|
|
986
|
-
indexDb(prepared.localCache),
|
|
987
|
-
undefined,
|
|
988
|
-
prepared.branch(),
|
|
989
|
-
taskDefinitions(nodes.values()),
|
|
990
|
-
).p50sFor(ids)
|
|
991
|
-
return createForecast(nodes, p50s, concurrency, blocksNow())
|
|
992
|
-
},
|
|
1027
|
+
async () => createForecast(nodes, p50sNow(), concurrency, blocksNow()),
|
|
993
1028
|
)
|
|
994
1029
|
|
|
995
1030
|
// Remote-only: kick off background prefetches so remote-GET latency
|
|
@@ -1089,7 +1124,7 @@ async function runOnBus(
|
|
|
1089
1124
|
(probe.hit !== null ? hits : missed).push(id)
|
|
1090
1125
|
// An empty store was not asked: it holds none of them.
|
|
1091
1126
|
if (coldLocal && !remote)
|
|
1092
|
-
for (const [id, n] of nodes) if (n.config.cache
|
|
1127
|
+
for (const [id, n] of nodes) if (n.config.cache === true) missed.push(id)
|
|
1093
1128
|
demand.settle(hits, missed)
|
|
1094
1129
|
}
|
|
1095
1130
|
}
|
|
@@ -1128,6 +1163,8 @@ async function runOnBus(
|
|
|
1128
1163
|
// The scheduler's fail-fast stop, as the retry loop of a task already
|
|
1129
1164
|
// in flight sees it.
|
|
1130
1165
|
const failFast = new AbortController()
|
|
1166
|
+
// Tasks a plugin's shared record names flaky (`ctx.flaky`).
|
|
1167
|
+
const sharedFlaky = new Set<string>()
|
|
1131
1168
|
const scope: RunScope = {
|
|
1132
1169
|
workspaceRoot,
|
|
1133
1170
|
cache,
|
|
@@ -1143,6 +1180,7 @@ async function runOnBus(
|
|
|
1143
1180
|
probesAfterWrites: lateProbes,
|
|
1144
1181
|
fingerprintWatch,
|
|
1145
1182
|
keyedProjects: keyed,
|
|
1183
|
+
readableDeps: readable,
|
|
1146
1184
|
outputDirSnapshots,
|
|
1147
1185
|
deferSave: saveLane.defer,
|
|
1148
1186
|
deferredSaves,
|
|
@@ -1152,6 +1190,15 @@ async function runOnBus(
|
|
|
1152
1190
|
inputStability,
|
|
1153
1191
|
}
|
|
1154
1192
|
if (options.retries !== undefined) scope.retries = options.retries
|
|
1193
|
+
scope.knownFlaky = (node, hash) => {
|
|
1194
|
+
if (sharedFlaky.has(node.id)) return true
|
|
1195
|
+
try {
|
|
1196
|
+
return knownFlaky(indexDb(prepared.localCache), node.projectName, node.taskName, hash)
|
|
1197
|
+
} catch {
|
|
1198
|
+
// History is observability: an unreadable one retries nothing.
|
|
1199
|
+
return false
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1155
1202
|
if (taskTimeoutDefault !== undefined) scope.timeout = taskTimeoutDefault
|
|
1156
1203
|
if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
|
|
1157
1204
|
const sinkTrack = telemetry?.track
|
|
@@ -1228,6 +1275,9 @@ async function runOnBus(
|
|
|
1228
1275
|
if (reprioritize !== undefined) reprioritize(merged)
|
|
1229
1276
|
else if (!graphOver) refinedEarly = merged
|
|
1230
1277
|
},
|
|
1278
|
+
flaky: (ids) => {
|
|
1279
|
+
for (const id of ids) if (nodes.has(id)) sharedFlaky.add(id)
|
|
1280
|
+
},
|
|
1231
1281
|
})
|
|
1232
1282
|
mark('schedule')
|
|
1233
1283
|
// Judged as each task finishes, against the history BEFORE this run's
|
|
@@ -1268,10 +1318,35 @@ async function runOnBus(
|
|
|
1268
1318
|
}
|
|
1269
1319
|
}
|
|
1270
1320
|
let lockBroken: boolean | undefined
|
|
1321
|
+
prepared.localCache.storeHeldBlobSizes()
|
|
1322
|
+
// A hit's row is whole when it finishes, so it is written while the
|
|
1323
|
+
// graph runs (history-stream.ts); every other row waits for the bundle.
|
|
1324
|
+
// A row with forward args is the bundle's too: only it digests them.
|
|
1325
|
+
const historyAt = historyFiles(prepared.localCache)
|
|
1326
|
+
const stream = historyAt === undefined ? undefined : new HistoryStream<TaskOutcome>(historyAt)
|
|
1327
|
+
const forwards = (options.forwardArgs?.length ?? 0) > 0
|
|
1328
|
+
const streamHit = (o: TaskOutcome): void => {
|
|
1329
|
+
if (stream === undefined || isGroupTask(o.node)) return
|
|
1330
|
+
if (!isCacheHit(o.status)) return
|
|
1331
|
+
if (o.wallclockStartNs === undefined || o.wallclockEndNs === undefined) return
|
|
1332
|
+
if (forwards && o.node.requested) return
|
|
1333
|
+
stream.add(
|
|
1334
|
+
o,
|
|
1335
|
+
runRecordOf(o as RecordedOutcome, {
|
|
1336
|
+
tainted: taintedRan,
|
|
1337
|
+
runId,
|
|
1338
|
+
startedAtMs: endedAtMsAtStart,
|
|
1339
|
+
endedAtMs: endedAtMsAtStart,
|
|
1340
|
+
blocks: blocksNow(),
|
|
1341
|
+
remoteTraffic: prepared.localCache.traffic.keys,
|
|
1342
|
+
}),
|
|
1343
|
+
)
|
|
1344
|
+
}
|
|
1271
1345
|
const outcomes = await runGraph({
|
|
1272
1346
|
nodes,
|
|
1273
1347
|
concurrency,
|
|
1274
1348
|
restoreConcurrency: options.restoreConcurrency ?? 2 * concurrency,
|
|
1349
|
+
reverseDeps: blocksNow,
|
|
1275
1350
|
settledOf: (o) => deferredSaves.get(o.node.id),
|
|
1276
1351
|
...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
|
|
1277
1352
|
...(admit !== undefined ? { admit } : {}),
|
|
@@ -1280,8 +1355,8 @@ async function runOnBus(
|
|
|
1280
1355
|
serverDied,
|
|
1281
1356
|
onFailFast: () => failFast.abort(),
|
|
1282
1357
|
signal: stop.signal,
|
|
1283
|
-
onStart: (node
|
|
1284
|
-
log.taskStart?.(node
|
|
1358
|
+
onStart: (node) => {
|
|
1359
|
+
log.taskStart?.(node)
|
|
1285
1360
|
},
|
|
1286
1361
|
onFinish: (o) => {
|
|
1287
1362
|
if (cacheOff) o.cacheOff = true
|
|
@@ -1289,6 +1364,7 @@ async function runOnBus(
|
|
|
1289
1364
|
judgeFlaky(o)
|
|
1290
1365
|
keeper?.taskDone(o)
|
|
1291
1366
|
log.taskComplete(o.node, o)
|
|
1367
|
+
streamHit(o)
|
|
1292
1368
|
demand.finished(o.node.id)
|
|
1293
1369
|
// A server that dies while the graph runs is no longer running:
|
|
1294
1370
|
// its pin stayed `running` under the line naming its exit.
|
|
@@ -1380,6 +1456,9 @@ async function runOnBus(
|
|
|
1380
1456
|
const hold =
|
|
1381
1457
|
options.holdPersistent === true &&
|
|
1382
1458
|
(!stop.signal.aborted || stop.signal.reason === CYCLE_INTERRUPT)
|
|
1459
|
+
// A stop already signalled every server once and waits out its grace:
|
|
1460
|
+
// a SIGTERM here, while a server ran its cleanup, cut it short.
|
|
1461
|
+
if (stop.signal.aborted) await stop.aborting?.catch(() => {})
|
|
1383
1462
|
const { keepAlive, crashed: crashedPersistent } = await disposeServers({
|
|
1384
1463
|
registry: persistentRegistry,
|
|
1385
1464
|
nodes,
|
|
@@ -1390,6 +1469,9 @@ async function runOnBus(
|
|
|
1390
1469
|
endedBeforeStop: stop.endedBeforeStop,
|
|
1391
1470
|
})
|
|
1392
1471
|
|
|
1472
|
+
// Every batch the stream sent has answered: what landed is not written again.
|
|
1473
|
+
const streamed = await stream?.finish()
|
|
1474
|
+
const landed = new Set(streamed?.landed.map((s) => s.item))
|
|
1393
1475
|
mark('run graph')
|
|
1394
1476
|
|
|
1395
1477
|
// Clear the status line for good before the summary prints.
|
|
@@ -1488,7 +1570,9 @@ async function runOnBus(
|
|
|
1488
1570
|
ci: ciContext,
|
|
1489
1571
|
host: hostContext,
|
|
1490
1572
|
withTelemetry: telemetry !== undefined,
|
|
1573
|
+
blocks: blocksNow(),
|
|
1491
1574
|
remoteTraffic: prepared.localCache.traffic.keys,
|
|
1575
|
+
landed,
|
|
1492
1576
|
})
|
|
1493
1577
|
const records = recordsOf(list, ok)
|
|
1494
1578
|
const recordHistory = (write: () => void): void => {
|
|
@@ -1572,7 +1656,13 @@ async function runOnBus(
|
|
|
1572
1656
|
}
|
|
1573
1657
|
// An upload that failed after the rows went in: its row says so too.
|
|
1574
1658
|
if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
|
|
1575
|
-
recordHistory(() =>
|
|
1659
|
+
recordHistory(() =>
|
|
1660
|
+
recordLateRemoteErrors(
|
|
1661
|
+
prepared.localCache,
|
|
1662
|
+
[...records.runs, ...(streamed?.landed.map((s) => s.record) ?? [])],
|
|
1663
|
+
runId,
|
|
1664
|
+
),
|
|
1665
|
+
)
|
|
1576
1666
|
const report = reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper)
|
|
1577
1667
|
// Hand the per-run summary to the telemetry sinks + drain them. Only
|
|
1578
1668
|
// when a sink is active (telemetry !== undefined) — otherwise this
|
|
@@ -1592,6 +1682,7 @@ async function runOnBus(
|
|
|
1592
1682
|
uploads: prepared.localCache.uploads,
|
|
1593
1683
|
machines: executors.flatMap((e) => executorMachines(e, fault)),
|
|
1594
1684
|
idealMs: executors.map((e) => executorIdeal(e, fault)).find((ms) => ms !== undefined),
|
|
1685
|
+
advice: executors.flatMap((e) => executorAdvice(e, fault)),
|
|
1595
1686
|
})
|
|
1596
1687
|
telemetry.emitSummary(summary)
|
|
1597
1688
|
const sinks = telemetry
|
|
@@ -1950,12 +2041,15 @@ function didYouMean(
|
|
|
1950
2041
|
elsewhere: readonly string[],
|
|
1951
2042
|
taskless: (name: string) => boolean,
|
|
1952
2043
|
): string {
|
|
1953
|
-
|
|
1954
|
-
//
|
|
2044
|
+
// A default `build` is no declared task (X-102): neither a bare name's
|
|
2045
|
+
// match nor a menu item or hint for `p#typo`, which led to `p#build`
|
|
2046
|
+
// and its refusal ("p declares no build").
|
|
2047
|
+
const tasksOf = (p: ProjectEntry | undefined): string[] =>
|
|
2048
|
+
Object.entries(p?.config.tasks ?? {})
|
|
2049
|
+
.filter(([, c]) => !isDefaultBuild(c))
|
|
2050
|
+
.map(([t]) => t)
|
|
1955
2051
|
const allTasks = new Set<string>()
|
|
1956
|
-
for (const p of projects.values())
|
|
1957
|
-
for (const t of tasksOf(p)) if (!isDefaultBuild(p.config.tasks![t])) allTasks.add(t)
|
|
1958
|
-
}
|
|
2052
|
+
for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
|
|
1959
2053
|
// A Set: two typos of the same task hint it once, not once per typo.
|
|
1960
2054
|
const hints = new Set<string>()
|
|
1961
2055
|
// A typo past two edits named nothing to pick from (M-56): with no near
|
|
@@ -2065,10 +2159,10 @@ function flakyCandidates(
|
|
|
2065
2159
|
for (const o of outcomes) {
|
|
2066
2160
|
if (o.status !== 'success' && o.status !== 'failed') continue
|
|
2067
2161
|
// "Same inputs, failed after passing" is a claim only a task with declared
|
|
2068
|
-
// inputs can make: a task
|
|
2162
|
+
// inputs can make: a task without `cache: true` keys on its config alone
|
|
2069
2163
|
// and runs every time, so one bad network day would read as a flake for
|
|
2070
2164
|
// thirty days. Groups do no work.
|
|
2071
|
-
if (o.hash === undefined || o.node.config.cache
|
|
2165
|
+
if (o.hash === undefined || o.node.config.cache !== true || isGroupTask(o.node)) continue
|
|
2072
2166
|
// Behind a failed dependency the outcome is not the key's: a failure is
|
|
2073
2167
|
// the dependency's, and a pass built on bytes the key never named, so a
|
|
2074
2168
|
// later failure relapsed from it and read as a flake for thirty days.
|
|
@@ -2153,6 +2247,16 @@ function executorMachines(e: TaskExecutor, fault: ExecutorFault): readonly Machi
|
|
|
2153
2247
|
}
|
|
2154
2248
|
}
|
|
2155
2249
|
|
|
2250
|
+
function executorAdvice(e: TaskExecutor, fault: ExecutorFault): readonly WorkerAdvice[] {
|
|
2251
|
+
try {
|
|
2252
|
+
const a = e.advice?.()
|
|
2253
|
+
return Array.isArray(a) ? a : []
|
|
2254
|
+
} catch (err) {
|
|
2255
|
+
fault(e, 'advice', err)
|
|
2256
|
+
return []
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2156
2260
|
function executorIdeal(e: TaskExecutor, fault: ExecutorFault): number | undefined {
|
|
2157
2261
|
try {
|
|
2158
2262
|
const ms = e.idealMs?.()
|
|
@@ -183,6 +183,10 @@ export function prepareSandbox(nodes: Iterable<SandboxedNode>): SandboxArmer | n
|
|
|
183
183
|
* anyone's (2026-09-24): `keyed` is the set of project directories the
|
|
184
184
|
* task's key answers for (keyed-projects.ts), and `undefined` for a task
|
|
185
185
|
* with no `cache`, which has no key to be stale.
|
|
186
|
+
*
|
|
187
|
+
* The other is the outputs of the tasks its key folds
|
|
188
|
+
* (`dependencyOutputs`, 2026-11-06): an edge the user drew, whose files
|
|
189
|
+
* this key already answers for, so the read can replay nothing stale.
|
|
186
190
|
*/
|
|
187
191
|
export async function sandboxRequestFor(
|
|
188
192
|
node: Pick<TaskNode, 'projectDir'>,
|
|
@@ -197,6 +201,8 @@ export async function sandboxRequestFor(
|
|
|
197
201
|
* grant covered it could plant artifacts and index rows (L-24).
|
|
198
202
|
*/
|
|
199
203
|
cacheDir?: string,
|
|
204
|
+
/** The dependencies' outputs it may read (`dependencyReads`): absolute paths. */
|
|
205
|
+
dependencyOutputs: readonly string[] = [],
|
|
200
206
|
): Promise<SandboxRequest> {
|
|
201
207
|
// The runtime reads a path holding a bracket as a pattern: on Linux it
|
|
202
208
|
// mounts no such write path (B-60); seatbelt's rules compile it as a
|
|
@@ -238,7 +244,7 @@ export async function sandboxRequestFor(
|
|
|
238
244
|
// own `package.json` depends on (2026-09-05) — when the key
|
|
239
245
|
// moves with it.
|
|
240
246
|
const links = await linkedDeps(depDirs, node.projectDir, workspaceRoot, keyed)
|
|
241
|
-
depDirs.push(...links.granted)
|
|
247
|
+
depDirs.push(...links.granted, ...dependencyOutputs)
|
|
242
248
|
// bwrap cannot --bind a path that does not exist: the bind silently
|
|
243
249
|
// becomes a no-op and writes to it appear to succeed but never land.
|
|
244
250
|
// Pre-create what the task said it will write — after the grants are
|
|
@@ -269,7 +275,8 @@ export async function sandboxRequestFor(
|
|
|
269
275
|
throw err
|
|
270
276
|
}
|
|
271
277
|
const request: NonNullable<ExecuteRequest['sandbox']> = {
|
|
272
|
-
// Only what the task declared, plus node_modules
|
|
278
|
+
// Only what the task declared, plus node_modules and the outputs of
|
|
279
|
+
// the tasks its key folds (`dependencyReads`). Write paths are
|
|
273
280
|
// readable too: a task that writes `dist/x` expects to read it back
|
|
274
281
|
// (`tsc --incremental` re-reads .tsbuildinfo).
|
|
275
282
|
baseAllowRead: depDirs,
|
|
@@ -304,10 +311,46 @@ export async function sandboxRequestFor(
|
|
|
304
311
|
reportLinked: links.withheld.map((w) => w.dir),
|
|
305
312
|
config,
|
|
306
313
|
...(keyed === undefined ? {} : { keyed: [...keyed] }),
|
|
314
|
+
...(dependencyOutputs.length === 0 ? {} : { dependencyReads: [...dependencyOutputs] }),
|
|
307
315
|
}
|
|
308
316
|
return { sandbox: request, placeholders, withheld: links.withheld }
|
|
309
317
|
}
|
|
310
318
|
|
|
319
|
+
/**
|
|
320
|
+
* The paths a sandboxed task may read of its dependencies' declared
|
|
321
|
+
* outputs (`readableDeps`): each output glob's fixed head, or, for a glob
|
|
322
|
+
* with none (`*.tsbuildinfo`), the files it matches now, so a root-level
|
|
323
|
+
* output never grants the dependency's whole project. Asked when the task
|
|
324
|
+
* runs, after its dependencies ran or were restored: a head that names
|
|
325
|
+
* nothing is left out, as bwrap binds no missing path.
|
|
326
|
+
*/
|
|
327
|
+
export async function dependencyReads(
|
|
328
|
+
deps: readonly TaskNode[],
|
|
329
|
+
workspaceRoot: string,
|
|
330
|
+
): Promise<string[]> {
|
|
331
|
+
const reads = new Set<string>()
|
|
332
|
+
for (const dep of deps) {
|
|
333
|
+
const outputs = dep.config.outputs
|
|
334
|
+
if (outputs === undefined) continue
|
|
335
|
+
const globs = [
|
|
336
|
+
...outputs.files.map((g) => [dep.projectDir, g] as const),
|
|
337
|
+
...(outputs.workspaceFiles ?? []).map((g) => [workspaceRoot, g] as const),
|
|
338
|
+
]
|
|
339
|
+
for (const [base, glob] of globs) {
|
|
340
|
+
if (glob.startsWith('!')) continue
|
|
341
|
+
const head = grantPrefix(glob)
|
|
342
|
+
if (head !== '' && head !== '.') {
|
|
343
|
+
const abs = path.join(base, head)
|
|
344
|
+
if (existsSync(abs)) reads.add(abs)
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
for await (const f of new Bun.Glob(glob).scan({ cwd: base, onlyFiles: true, dot: true }))
|
|
348
|
+
reads.add(path.join(base, f))
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return [...reads]
|
|
352
|
+
}
|
|
353
|
+
|
|
311
354
|
/**
|
|
312
355
|
* The sandbox half of the request, plus the empty files vx created so a
|
|
313
356
|
* literal write grant had something to bind (`placeholders`). They are
|
|
@@ -446,8 +489,8 @@ export function withheldLinkLine(taskId: string, w: WithheldLink): string {
|
|
|
446
489
|
return (
|
|
447
490
|
`vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key includes no task of ` +
|
|
448
491
|
`${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
|
|
449
|
-
`reaches one (\`^build
|
|
450
|
-
|
|
492
|
+
`reaches one (\`^build\`, where ${w.name}#build keys its sources), or grant and key ` +
|
|
493
|
+
`the files yourself (\`allow.read\` plus ` +
|
|
451
494
|
`\`inputs.workspaceFiles\`).`
|
|
452
495
|
)
|
|
453
496
|
}
|
|
@@ -573,7 +616,7 @@ function wallOff(
|
|
|
573
616
|
if (wall === undefined) continue
|
|
574
617
|
throw new UserError(
|
|
575
618
|
`exec.sandbox.allow.write: the grant binding ${bind} would make ${wall} writable — another ` +
|
|
576
|
-
`project's directory,
|
|
619
|
+
`project's directory, .git or vx's own. A file grant binds its directory; ` +
|
|
577
620
|
`grant a directory of the task's own instead, such as "dist/".`,
|
|
578
621
|
)
|
|
579
622
|
}
|
|
@@ -774,13 +817,13 @@ export type WriteReach = 'none' | 'project' | 'workspace'
|
|
|
774
817
|
* names — the files whose run-start facts (the git snapshot, its index
|
|
775
818
|
* OIDs) a later key must not reuse (item 743).
|
|
776
819
|
*
|
|
777
|
-
* A cached task declares its outputs and is held to them
|
|
778
|
-
* `cache`
|
|
779
|
-
* `commandWriteReach` says. An unsandboxed write into another project is
|
|
820
|
+
* A cached task declares its outputs and is held to them: its save checks
|
|
821
|
+
* them. A task with no `cache: true` is held to nothing, even when it
|
|
822
|
+
* declares outputs, so it may have written anywhere `commandWriteReach` says. An unsandboxed write into another project is
|
|
780
823
|
* out of contract, as it is for a cached task.
|
|
781
824
|
*/
|
|
782
825
|
export function undeclaredWriteReach(node: TaskNode, workspaceRoot: string): WriteReach {
|
|
783
|
-
if (node.config.cache
|
|
826
|
+
if (node.config.cache === true) return 'none'
|
|
784
827
|
return commandWriteReach(node, workspaceRoot)
|
|
785
828
|
}
|
|
786
829
|
|
|
@@ -43,6 +43,15 @@ export function shellVerdict(args: {
|
|
|
43
43
|
if (args.code === 126) {
|
|
44
44
|
return `[vx] exit 126 is the shell's "found but cannot execute": ${what} is not executable or is a directory — chmod +x it`
|
|
45
45
|
}
|
|
46
|
+
// A program the host's PATH has may be one the sandbox hides (a
|
|
47
|
+
// hermetic task sees no /usr/bin): the reason is a grant, not an install.
|
|
48
|
+
const found =
|
|
49
|
+
word === undefined || args.hidden === undefined
|
|
50
|
+
? null
|
|
51
|
+
: Bun.which(word, { PATH: [...args.bins, process.env['PATH'] ?? ''].join(path.delimiter) })
|
|
52
|
+
if (found !== null && args.hidden!(found)) {
|
|
53
|
+
return `[vx] exit 127 is the shell's "command not found": ${word} is ${found} on the host, but no sandbox grant reads it, so inside the sandbox it is not there — add it (or its directory) to exec.sandbox.allow.read`
|
|
54
|
+
}
|
|
46
55
|
const unnamed = args.bins.find((dir) => dir.includes(path.delimiter))
|
|
47
56
|
if (unnamed !== undefined) {
|
|
48
57
|
return `[vx] exit 127 is the shell's "command not found": ${what} is not on this task's PATH — ${unnamed} holds "${path.delimiter}", which PATH reads as a separator, so vx cannot put it there; move the workspace to a path without "${path.delimiter}"`
|
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
// them to the runner around every spawn.
|
|
15
15
|
|
|
16
16
|
import { signalExitCode } from '../exec/index.js'
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
claimExitForSignal,
|
|
19
|
+
dieOf,
|
|
20
|
+
hangupIgnored,
|
|
21
|
+
killGraceMs,
|
|
22
|
+
settleWithin,
|
|
23
|
+
} from '../util/index.js'
|
|
18
24
|
import {
|
|
19
25
|
holdGroups,
|
|
20
26
|
killTree,
|
|
@@ -154,10 +160,7 @@ export function forwardSignals(args: {
|
|
|
154
160
|
// and a 130 exit ran the script's next line. The code stays the
|
|
155
161
|
// fallback should the signal not land.
|
|
156
162
|
if (args.handsTerminal !== true) {
|
|
157
|
-
process.once('exit', () =>
|
|
158
|
-
process.removeAllListeners(signal)
|
|
159
|
-
process.kill(process.pid, signal)
|
|
160
|
-
})
|
|
163
|
+
process.once('exit', () => dieOf(signal))
|
|
161
164
|
}
|
|
162
165
|
process.exit(signalExitCode(signal))
|
|
163
166
|
}
|