@vzn/vx 0.0.651 → 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 +2 -3
- package/package.json +5 -5
- package/schemas/show.json +14 -20
- package/src/bin.ts +12 -0
- package/src/cache/archive.ts +1 -1
- package/src/cache/cache.ts +19 -5
- package/src/cache/history-stream.ts +134 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/inputs.ts +74 -14
- package/src/cache/layer.ts +6 -6
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/run-history.ts +46 -17
- package/src/cache/schema.ts +4 -1
- package/src/cli/cache.ts +2 -2
- package/src/cli/help.ts +2 -2
- package/src/cli/index.ts +24 -0
- package/src/cli/info.ts +1 -1
- package/src/cli/last.ts +34 -2
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/show.ts +7 -10
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch-set.ts +4 -4
- package/src/cli/watch.ts +11 -8
- package/src/config.ts +47 -30
- package/src/exec/executor.ts +40 -1
- package/src/exec/index.ts +1 -0
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +314 -29
- package/src/exec/task-proxy.ts +93 -0
- package/src/graph/definition.ts +16 -3
- package/src/graph/scheduler.ts +3 -1
- package/src/graph/task-graph.ts +24 -23
- package/src/history/failure-mode.ts +24 -1
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +5 -5
- package/src/index.ts +4 -2
- 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/download-policy.ts +5 -7
- package/src/orchestrator/excluded-keys.ts +1 -1
- package/src/orchestrator/execute-task.ts +51 -18
- package/src/orchestrator/hit-restore.ts +5 -6
- package/src/orchestrator/keyed-projects.ts +28 -0
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/miss-save.ts +27 -8
- package/src/orchestrator/persistent-task.ts +2 -0
- package/src/orchestrator/placement.ts +1 -1
- package/src/orchestrator/plan.ts +2 -2
- package/src/orchestrator/plugin-host.ts +6 -2
- package/src/orchestrator/plugin.ts +6 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-failures.ts +61 -12
- package/src/orchestrator/run-keys.ts +1 -1
- package/src/orchestrator/run-records.ts +86 -67
- package/src/orchestrator/run-report.ts +1 -1
- package/src/orchestrator/run.ts +92 -19
- package/src/orchestrator/sandbox-request.ts +50 -7
- package/src/orchestrator/shell-verdict.ts +9 -0
- package/src/orchestrator/stable-keys.ts +17 -18
- package/src/orchestrator/task-artifacts.ts +1 -1
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/orchestrator/telemetry.ts +5 -1
- package/src/report/events.ts +3 -3
- package/src/report/framed-output.ts +16 -7
- package/src/report/logger.ts +1 -1
- package/src/report/plain-output.ts +2 -0
- package/src/report/summary.ts +5 -5
- package/src/settings.ts +8 -8
- package/src/util/config-frame.ts +128 -6
- package/src/util/paths.ts +1 -1
- package/src/util/settle.ts +2 -1
- package/src/util/tail.ts +4 -1
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-schema.ts +66 -61
- package/src/workspace/filter.ts +8 -1
- package/src/workspace/migration.ts +12 -6
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,
|
|
@@ -65,7 +68,7 @@ function printTimings(): void {
|
|
|
65
68
|
if (lines !== undefined) ui.print('err', ui.trace(lines))
|
|
66
69
|
}
|
|
67
70
|
import { detectColors, type ColorSupport } from '../ui/index.js'
|
|
68
|
-
import { keyedProjects } from './keyed-projects.js'
|
|
71
|
+
import { keyedProjects, readableDeps } from './keyed-projects.js'
|
|
69
72
|
import { isDefaultBuild } from '../graph/index.js'
|
|
70
73
|
import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
|
|
71
74
|
import { recordOutputDirSnapshots, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
|
|
@@ -138,7 +141,12 @@ import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
|
|
|
138
141
|
import { askRemote, serversToHold } from './held-servers.js'
|
|
139
142
|
|
|
140
143
|
import { writeRunFailures } from './run-failures.js'
|
|
141
|
-
import {
|
|
144
|
+
import {
|
|
145
|
+
assembleRunRecords,
|
|
146
|
+
recordLateRemoteErrors,
|
|
147
|
+
type RecordedOutcome,
|
|
148
|
+
runRecordOf,
|
|
149
|
+
} from './run-records.js'
|
|
142
150
|
import {
|
|
143
151
|
hasEnded,
|
|
144
152
|
disposeServers,
|
|
@@ -155,6 +163,7 @@ import {
|
|
|
155
163
|
createMissExplainer,
|
|
156
164
|
detectFlaky,
|
|
157
165
|
type FlakyCandidate,
|
|
166
|
+
knownFlaky,
|
|
158
167
|
LocalHistoryProvider,
|
|
159
168
|
} from '../history/index.js'
|
|
160
169
|
import type { RunOptions, RunSummary } from './options.js'
|
|
@@ -452,11 +461,11 @@ async function runOnBus(
|
|
|
452
461
|
options = withRunSettings(options, prepared.workspaceConfig)
|
|
453
462
|
inputStability = {
|
|
454
463
|
run: inputStabilityEnv(),
|
|
455
|
-
...(prepared.workspaceConfig?.
|
|
456
|
-
? { workspace: prepared.workspaceConfig.
|
|
464
|
+
...(prepared.workspaceConfig?.inputs?.stability !== undefined
|
|
465
|
+
? { workspace: prepared.workspaceConfig.inputs.stability }
|
|
457
466
|
: {}),
|
|
458
467
|
}
|
|
459
|
-
cleanOutputs = resolveSetting('
|
|
468
|
+
cleanOutputs = resolveSetting('outputs.clean', prepared.workspaceConfig).value as
|
|
460
469
|
| boolean
|
|
461
470
|
| undefined
|
|
462
471
|
} catch (err) {
|
|
@@ -639,15 +648,15 @@ async function runOnBus(
|
|
|
639
648
|
// deliberate — on a machine with no remote pool the ambient state already
|
|
640
649
|
// is what the task would have produced — but it must not be SILENT: a task
|
|
641
650
|
// reporting success while doing nothing is indistinguishable from one that
|
|
642
|
-
// worked, and the usual cause (no `cache
|
|
651
|
+
// worked, and the usual cause (no `cache: true`, so no executor can
|
|
643
652
|
// describe its inputs to a worker) cannot be read off the outcome.
|
|
644
653
|
if (placements.remoteOnlyNoop.size > 0) {
|
|
645
654
|
const anyRemote = executors.some((e) => e.remote === true)
|
|
646
655
|
for (const id of placements.remoteOnlyNoop) {
|
|
647
656
|
const why = !anyRemote
|
|
648
657
|
? 'no remote executor is declared'
|
|
649
|
-
: nodes.get(id)?.config.cache
|
|
650
|
-
? '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'
|
|
651
660
|
: 'no remote executor accepted it'
|
|
652
661
|
warnOf(log)(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
|
|
653
662
|
}
|
|
@@ -853,7 +862,7 @@ async function runOnBus(
|
|
|
853
862
|
// SRT's proxies holding the process open.
|
|
854
863
|
const surelyRuns = (n: TaskNode): boolean =>
|
|
855
864
|
n.config.exec?.persistent !== undefined ||
|
|
856
|
-
n.config.cache
|
|
865
|
+
n.config.cache !== true ||
|
|
857
866
|
!(policy.localRead || policy.remoteRead)
|
|
858
867
|
// Only what runs here arms this machine's sandbox: a `sandbox` executor
|
|
859
868
|
// enforces it where it runs the task, so a distributed run's
|
|
@@ -874,6 +883,7 @@ async function runOnBus(
|
|
|
874
883
|
}
|
|
875
884
|
if (sandboxed.some(surelyRuns)) prewarm()
|
|
876
885
|
const keyed = keyedProjects(nodes, prepared.keyOnly)
|
|
886
|
+
const readable = readableDeps(nodes, prepared.keyOnly)
|
|
877
887
|
const outputDirSnapshots: OutputDirSnapshot[] = []
|
|
878
888
|
// Saves run off the execution slot, twice the cap at once (memory:
|
|
879
889
|
// each pack holds an artifact's bytes); a failed save is a miss next
|
|
@@ -1114,7 +1124,7 @@ async function runOnBus(
|
|
|
1114
1124
|
(probe.hit !== null ? hits : missed).push(id)
|
|
1115
1125
|
// An empty store was not asked: it holds none of them.
|
|
1116
1126
|
if (coldLocal && !remote)
|
|
1117
|
-
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)
|
|
1118
1128
|
demand.settle(hits, missed)
|
|
1119
1129
|
}
|
|
1120
1130
|
}
|
|
@@ -1153,6 +1163,8 @@ async function runOnBus(
|
|
|
1153
1163
|
// The scheduler's fail-fast stop, as the retry loop of a task already
|
|
1154
1164
|
// in flight sees it.
|
|
1155
1165
|
const failFast = new AbortController()
|
|
1166
|
+
// Tasks a plugin's shared record names flaky (`ctx.flaky`).
|
|
1167
|
+
const sharedFlaky = new Set<string>()
|
|
1156
1168
|
const scope: RunScope = {
|
|
1157
1169
|
workspaceRoot,
|
|
1158
1170
|
cache,
|
|
@@ -1168,6 +1180,7 @@ async function runOnBus(
|
|
|
1168
1180
|
probesAfterWrites: lateProbes,
|
|
1169
1181
|
fingerprintWatch,
|
|
1170
1182
|
keyedProjects: keyed,
|
|
1183
|
+
readableDeps: readable,
|
|
1171
1184
|
outputDirSnapshots,
|
|
1172
1185
|
deferSave: saveLane.defer,
|
|
1173
1186
|
deferredSaves,
|
|
@@ -1177,6 +1190,15 @@ async function runOnBus(
|
|
|
1177
1190
|
inputStability,
|
|
1178
1191
|
}
|
|
1179
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
|
+
}
|
|
1180
1202
|
if (taskTimeoutDefault !== undefined) scope.timeout = taskTimeoutDefault
|
|
1181
1203
|
if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
|
|
1182
1204
|
const sinkTrack = telemetry?.track
|
|
@@ -1253,6 +1275,9 @@ async function runOnBus(
|
|
|
1253
1275
|
if (reprioritize !== undefined) reprioritize(merged)
|
|
1254
1276
|
else if (!graphOver) refinedEarly = merged
|
|
1255
1277
|
},
|
|
1278
|
+
flaky: (ids) => {
|
|
1279
|
+
for (const id of ids) if (nodes.has(id)) sharedFlaky.add(id)
|
|
1280
|
+
},
|
|
1256
1281
|
})
|
|
1257
1282
|
mark('schedule')
|
|
1258
1283
|
// Judged as each task finishes, against the history BEFORE this run's
|
|
@@ -1294,6 +1319,29 @@ async function runOnBus(
|
|
|
1294
1319
|
}
|
|
1295
1320
|
let lockBroken: boolean | undefined
|
|
1296
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
|
+
}
|
|
1297
1345
|
const outcomes = await runGraph({
|
|
1298
1346
|
nodes,
|
|
1299
1347
|
concurrency,
|
|
@@ -1316,6 +1364,7 @@ async function runOnBus(
|
|
|
1316
1364
|
judgeFlaky(o)
|
|
1317
1365
|
keeper?.taskDone(o)
|
|
1318
1366
|
log.taskComplete(o.node, o)
|
|
1367
|
+
streamHit(o)
|
|
1319
1368
|
demand.finished(o.node.id)
|
|
1320
1369
|
// A server that dies while the graph runs is no longer running:
|
|
1321
1370
|
// its pin stayed `running` under the line naming its exit.
|
|
@@ -1420,6 +1469,9 @@ async function runOnBus(
|
|
|
1420
1469
|
endedBeforeStop: stop.endedBeforeStop,
|
|
1421
1470
|
})
|
|
1422
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))
|
|
1423
1475
|
mark('run graph')
|
|
1424
1476
|
|
|
1425
1477
|
// Clear the status line for good before the summary prints.
|
|
@@ -1520,6 +1572,7 @@ async function runOnBus(
|
|
|
1520
1572
|
withTelemetry: telemetry !== undefined,
|
|
1521
1573
|
blocks: blocksNow(),
|
|
1522
1574
|
remoteTraffic: prepared.localCache.traffic.keys,
|
|
1575
|
+
landed,
|
|
1523
1576
|
})
|
|
1524
1577
|
const records = recordsOf(list, ok)
|
|
1525
1578
|
const recordHistory = (write: () => void): void => {
|
|
@@ -1603,7 +1656,13 @@ async function runOnBus(
|
|
|
1603
1656
|
}
|
|
1604
1657
|
// An upload that failed after the rows went in: its row says so too.
|
|
1605
1658
|
if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
|
|
1606
|
-
recordHistory(() =>
|
|
1659
|
+
recordHistory(() =>
|
|
1660
|
+
recordLateRemoteErrors(
|
|
1661
|
+
prepared.localCache,
|
|
1662
|
+
[...records.runs, ...(streamed?.landed.map((s) => s.record) ?? [])],
|
|
1663
|
+
runId,
|
|
1664
|
+
),
|
|
1665
|
+
)
|
|
1607
1666
|
const report = reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper)
|
|
1608
1667
|
// Hand the per-run summary to the telemetry sinks + drain them. Only
|
|
1609
1668
|
// when a sink is active (telemetry !== undefined) — otherwise this
|
|
@@ -1623,6 +1682,7 @@ async function runOnBus(
|
|
|
1623
1682
|
uploads: prepared.localCache.uploads,
|
|
1624
1683
|
machines: executors.flatMap((e) => executorMachines(e, fault)),
|
|
1625
1684
|
idealMs: executors.map((e) => executorIdeal(e, fault)).find((ms) => ms !== undefined),
|
|
1685
|
+
advice: executors.flatMap((e) => executorAdvice(e, fault)),
|
|
1626
1686
|
})
|
|
1627
1687
|
telemetry.emitSummary(summary)
|
|
1628
1688
|
const sinks = telemetry
|
|
@@ -1981,12 +2041,15 @@ function didYouMean(
|
|
|
1981
2041
|
elsewhere: readonly string[],
|
|
1982
2042
|
taskless: (name: string) => boolean,
|
|
1983
2043
|
): string {
|
|
1984
|
-
|
|
1985
|
-
//
|
|
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)
|
|
1986
2051
|
const allTasks = new Set<string>()
|
|
1987
|
-
for (const p of projects.values())
|
|
1988
|
-
for (const t of tasksOf(p)) if (!isDefaultBuild(p.config.tasks![t])) allTasks.add(t)
|
|
1989
|
-
}
|
|
2052
|
+
for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
|
|
1990
2053
|
// A Set: two typos of the same task hint it once, not once per typo.
|
|
1991
2054
|
const hints = new Set<string>()
|
|
1992
2055
|
// A typo past two edits named nothing to pick from (M-56): with no near
|
|
@@ -2096,10 +2159,10 @@ function flakyCandidates(
|
|
|
2096
2159
|
for (const o of outcomes) {
|
|
2097
2160
|
if (o.status !== 'success' && o.status !== 'failed') continue
|
|
2098
2161
|
// "Same inputs, failed after passing" is a claim only a task with declared
|
|
2099
|
-
// inputs can make: a task
|
|
2162
|
+
// inputs can make: a task without `cache: true` keys on its config alone
|
|
2100
2163
|
// and runs every time, so one bad network day would read as a flake for
|
|
2101
2164
|
// thirty days. Groups do no work.
|
|
2102
|
-
if (o.hash === undefined || o.node.config.cache
|
|
2165
|
+
if (o.hash === undefined || o.node.config.cache !== true || isGroupTask(o.node)) continue
|
|
2103
2166
|
// Behind a failed dependency the outcome is not the key's: a failure is
|
|
2104
2167
|
// the dependency's, and a pass built on bytes the key never named, so a
|
|
2105
2168
|
// later failure relapsed from it and read as a flake for thirty days.
|
|
@@ -2184,6 +2247,16 @@ function executorMachines(e: TaskExecutor, fault: ExecutorFault): readonly Machi
|
|
|
2184
2247
|
}
|
|
2185
2248
|
}
|
|
2186
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
|
+
|
|
2187
2260
|
function executorIdeal(e: TaskExecutor, fault: ExecutorFault): number | undefined {
|
|
2188
2261
|
try {
|
|
2189
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
|
|
@@ -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}"`
|
|
@@ -89,14 +89,14 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
89
89
|
// reached through a no-output intermediate still poisons the key, so each
|
|
90
90
|
// dep's accumulated producers fold forward.
|
|
91
91
|
// - outputProjects: the project names of every upstream task declaring
|
|
92
|
-
//
|
|
93
|
-
// own dir), and of every upstream with no cache
|
|
92
|
+
// outputs.files (project-relative outputs land in the producer's
|
|
93
|
+
// own dir), and of every upstream with no `cache: true` that may write
|
|
94
94
|
// in its own project (`undeclaredWriteReach`, item 743). A bitset over
|
|
95
95
|
// project indexes: a union per dep was a string Set copied tasks ×
|
|
96
96
|
// deps × projects times, 101 ms of a 513 ms warm run at 476 packages
|
|
97
97
|
// (item 744).
|
|
98
|
-
// - wsOutputUpstream: any upstream declares
|
|
99
|
-
// (root-anchored, boundary-ignoring outputs), or has no cache
|
|
98
|
+
// - wsOutputUpstream: any upstream declares outputs.workspaceFiles
|
|
99
|
+
// (root-anchored, boundary-ignoring outputs), or has no `cache: true`
|
|
100
100
|
// and a sandbox write grant elsewhere in the workspace, or may rewrite
|
|
101
101
|
// a root file a plugin claims (a root `pnpm install`): every key folds
|
|
102
102
|
// what the claimant took from it, once per run.
|
|
@@ -125,7 +125,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
125
125
|
// of what any one reader's upstream producers declare.
|
|
126
126
|
const outputsOf = new Map<string, string[]>()
|
|
127
127
|
for (const n of args.nodes.values()) {
|
|
128
|
-
const files = n.config.
|
|
128
|
+
const files = n.config.outputs?.files ?? []
|
|
129
129
|
if (files.length === 0) continue
|
|
130
130
|
const list = outputsOf.get(n.projectName)
|
|
131
131
|
if (list === undefined) outputsOf.set(n.projectName, [...files])
|
|
@@ -185,10 +185,10 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
185
185
|
const wideOfDep = wideById.get(dep)
|
|
186
186
|
if (wideOfDep !== undefined) wideUnion.addAll(wideOfDep)
|
|
187
187
|
if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
|
|
188
|
-
const depOut = depNode.config.
|
|
188
|
+
const depOut = depNode.config.outputs
|
|
189
189
|
if ((depOut?.files?.length ?? 0) > 0) outputs.add(depNode.projectName)
|
|
190
190
|
if ((depOut?.workspaceFiles?.length ?? 0) > 0) wsOutputUpstream = true
|
|
191
|
-
// An uncached producer
|
|
191
|
+
// An uncached producer is held to no outputs, yet a same-project reader
|
|
192
192
|
// after it (`tasks: []` decouples the key, not the order) read the
|
|
193
193
|
// bytes it wrote: its key taken up front was the pre-producer one, and
|
|
194
194
|
// the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
|
|
@@ -198,7 +198,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
198
198
|
wideUnion.add(depNode.projectName)
|
|
199
199
|
} else if (reach === 'workspace') wsOutputUpstream = true
|
|
200
200
|
if (
|
|
201
|
-
depNode.config.cache
|
|
201
|
+
depNode.config.cache !== true &&
|
|
202
202
|
mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles)
|
|
203
203
|
)
|
|
204
204
|
wsOutputUpstream = true
|
|
@@ -219,7 +219,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
219
219
|
for (const dep of anyUnfolded ? node.deps : []) {
|
|
220
220
|
const depNode = args.nodes.get(dep)
|
|
221
221
|
if (!depNode) continue
|
|
222
|
-
const cached = depNode.config.cache
|
|
222
|
+
const cached = depNode.config.cache === true
|
|
223
223
|
// The cached twin: a formatter rewriting its own input ahead of a
|
|
224
224
|
// reader that folds no key of it replayed seed B's build under seed A
|
|
225
225
|
// on the fourth run, the same way (item 750).
|
|
@@ -323,7 +323,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
323
323
|
)
|
|
324
324
|
if (unstable) unstableById.add(id)
|
|
325
325
|
|
|
326
|
-
const cacheEnabled = node.config.cache
|
|
326
|
+
const cacheEnabled = node.config.cache === true
|
|
327
327
|
if (cacheEnabled && !unstable) stableKeys.push({ hash, node })
|
|
328
328
|
// An uncached key stays preliminary behind any upstream that may write
|
|
329
329
|
// into this project, or anywhere: none of its writes is declared.
|
|
@@ -438,7 +438,7 @@ export function gatedOutputs(
|
|
|
438
438
|
// 1,000 projects' tasks spent 5-8 ms here re-splitting the same globs.
|
|
439
439
|
const outputsOf = new Map<string, ProjectOutputs>()
|
|
440
440
|
for (const n of nodes.values()) {
|
|
441
|
-
const files = n.config.
|
|
441
|
+
const files = n.config.outputs?.files
|
|
442
442
|
if (files === undefined || files.length === 0) continue
|
|
443
443
|
let po = outputsOf.get(n.projectName)
|
|
444
444
|
if (po === undefined) outputsOf.set(n.projectName, (po = new ProjectOutputs()))
|
|
@@ -554,8 +554,8 @@ export function probesAfterWrites(
|
|
|
554
554
|
for (const n of nodes.values()) {
|
|
555
555
|
if (
|
|
556
556
|
commandWriteReach(n, workspaceRoot) !== 'none' ||
|
|
557
|
-
(n.config.
|
|
558
|
-
(n.config.
|
|
557
|
+
(n.config.outputs?.files.length ?? 0) > 0 ||
|
|
558
|
+
(n.config.outputs?.workspaceFiles?.length ?? 0) > 0
|
|
559
559
|
)
|
|
560
560
|
writers.add(n.id)
|
|
561
561
|
}
|
|
@@ -581,7 +581,7 @@ export function probesAfterWrites(
|
|
|
581
581
|
}
|
|
582
582
|
if (writer) writerBelow.add(id)
|
|
583
583
|
if (unfolded) unfoldedBelow.add(id)
|
|
584
|
-
if (unfolded && node.config.cache
|
|
584
|
+
if (unfolded && node.config.cache === true && probes(node)) late.add(id)
|
|
585
585
|
}
|
|
586
586
|
return late
|
|
587
587
|
}
|
|
@@ -628,8 +628,8 @@ function synthUpstream(
|
|
|
628
628
|
* Conservative stability check: does any TRANSITIVE-upstream task whose
|
|
629
629
|
* outputs could land where this task reads its inputs make this task's
|
|
630
630
|
* key preliminary? The producers are pre-folded by `deriveStableKeys`
|
|
631
|
-
* (`upstreamOutputProjects` = projects declaring `
|
|
632
|
-
* upstream; `hasWsOutputUpstream` = any `
|
|
631
|
+
* (`upstreamOutputProjects` = projects declaring `outputs.files`
|
|
632
|
+
* upstream; `hasWsOutputUpstream` = any `outputs.workspaceFiles`
|
|
633
633
|
* upstream), so a producer reached through a no-output intermediate is
|
|
634
634
|
* caught too.
|
|
635
635
|
*
|
|
@@ -663,7 +663,6 @@ export function dependsOnSiblingOutputs(
|
|
|
663
663
|
readableOf?: ReadonlyMap<string, readonly string[]>
|
|
664
664
|
},
|
|
665
665
|
): boolean {
|
|
666
|
-
const cache = node.config.cache
|
|
667
666
|
const declared = node.config.inputs
|
|
668
667
|
// A task with no inputs has no key to take up front.
|
|
669
668
|
if (declared === undefined) return false
|
|
@@ -697,7 +696,7 @@ export function dependsOnSiblingOutputs(
|
|
|
697
696
|
// that producer, its restore raced the producer's clean and extract
|
|
698
697
|
// (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
|
|
699
698
|
// `outputsOf` holds this task's own globs too; set them aside once.
|
|
700
|
-
const own =
|
|
699
|
+
const own = node.config.outputs?.files ?? []
|
|
701
700
|
if (own.length > 0) {
|
|
702
701
|
const others = [...outputs]
|
|
703
702
|
for (const g of own) {
|
|
@@ -22,7 +22,7 @@ export interface ArtifactTask {
|
|
|
22
22
|
/** Absolute. */
|
|
23
23
|
readonly projectDir: string
|
|
24
24
|
readonly command: string
|
|
25
|
-
/** Its `
|
|
25
|
+
/** Its `outputs`: project-relative `files`, root-relative `workspaceFiles`. */
|
|
26
26
|
readonly outputs: {
|
|
27
27
|
readonly files: readonly string[]
|
|
28
28
|
readonly workspaceFiles: readonly string[]
|
|
@@ -292,7 +292,7 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
|
|
|
292
292
|
* input changed and changed BACK while the command ran matched its digest
|
|
293
293
|
* again, and the entry saved the other content's output under the key (item
|
|
294
294
|
* 1015). A write before it — an upstream's — is judged by content, as before.
|
|
295
|
-
* `byBytes` (`
|
|
295
|
+
* `byBytes` (`inputs.stability.strategy: 'mutable'`) judges a write during
|
|
296
296
|
* the command by content too, when it answers true for that file and its
|
|
297
297
|
* ctime: the task says the same bytes are its own identical rewrite
|
|
298
298
|
* (tsdown's package.json), not an edit undone, unless another command that
|
|
@@ -353,14 +353,14 @@ export function taskFilesToken(
|
|
|
353
353
|
/** What `resolveInputs` is asked for a task's key. */
|
|
354
354
|
function inputsArgs(args: ComputeHashArgs): ResolveInputsArgs {
|
|
355
355
|
const cfg = args.node.config
|
|
356
|
-
const
|
|
356
|
+
const cfgOut = cfg.outputs
|
|
357
357
|
return {
|
|
358
358
|
projectDir: args.node.projectDir,
|
|
359
359
|
workspaceRoot: args.workspaceRoot,
|
|
360
360
|
envSource: process.env,
|
|
361
361
|
inputs: cfg.inputs,
|
|
362
|
-
ownOutputs:
|
|
363
|
-
ownWorkspaceOutputs:
|
|
362
|
+
ownOutputs: cfgOut?.files ?? [],
|
|
363
|
+
ownWorkspaceOutputs: cfgOut?.workspaceFiles ?? [],
|
|
364
364
|
nestedProjectDirs: args.nestedProjectDirs,
|
|
365
365
|
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
366
366
|
...(args.probesAfterWrites?.has(args.node.id) === true ? { runtimeScope: args.node.id } : {}),
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// what or how tasks run. Contrast `cache`/`executor`, which return objects
|
|
17
17
|
// core calls INTO; those are the behavior capabilities, kept separate.
|
|
18
18
|
|
|
19
|
-
import { sampleTrees, type MachineReport } from '../exec/index.js'
|
|
19
|
+
import { sampleTrees, type MachineReport, type WorkerAdvice } from '../exec/index.js'
|
|
20
20
|
import {
|
|
21
21
|
type CacheSource,
|
|
22
22
|
deriveCacheSource,
|
|
@@ -301,6 +301,8 @@ export interface RunSummaryRecord {
|
|
|
301
301
|
* (`TaskExecutor.idealMs`), beside `totalDurationMs`. Additive.
|
|
302
302
|
*/
|
|
303
303
|
idealMs?: number
|
|
304
|
+
/** The workers that would have run it best (`TaskExecutor.advice`). Additive. */
|
|
305
|
+
advice?: readonly WorkerAdvice[]
|
|
304
306
|
}
|
|
305
307
|
|
|
306
308
|
/** One stage of a run, a wall window in epoch ms. */
|
|
@@ -334,6 +336,7 @@ export function assembleRunSummary(
|
|
|
334
336
|
uploads?: { count: number; bytes: number; ms: number; failed: number }
|
|
335
337
|
machines?: readonly MachineReport[]
|
|
336
338
|
idealMs?: number | undefined
|
|
339
|
+
advice?: readonly WorkerAdvice[]
|
|
337
340
|
},
|
|
338
341
|
): RunSummaryRecord {
|
|
339
342
|
let failedCount = 0
|
|
@@ -376,6 +379,7 @@ export function assembleRunSummary(
|
|
|
376
379
|
? { machines: timing.machines }
|
|
377
380
|
: {}),
|
|
378
381
|
...(timing.idealMs !== undefined ? { idealMs: timing.idealMs } : {}),
|
|
382
|
+
...(timing.advice !== undefined && timing.advice.length > 0 ? { advice: timing.advice } : {}),
|
|
379
383
|
}
|
|
380
384
|
}
|
|
381
385
|
|
package/src/report/events.ts
CHANGED
|
@@ -14,12 +14,12 @@ import type { Logger } from './logger.js'
|
|
|
14
14
|
import type { RunContext } from './summary.js'
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* A task no cache answered for: it
|
|
17
|
+
* A task no cache answered for: it has no `cache: true`, or the run's policy
|
|
18
18
|
* read and wrote nothing. Its run is `no-cache`, never a miss, on every
|
|
19
19
|
* surface — `--dry`'s plan, the frame, the footer, the run history.
|
|
20
20
|
*/
|
|
21
21
|
export function ranNoCache(o: { node: TaskNode; cacheOff?: boolean }): boolean {
|
|
22
|
-
return o.node.config.cache
|
|
22
|
+
return o.node.config.cache !== true || o.cacheOff === true
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** Payload of the `run:start` event — mirrors the Logger.runStart hook. */
|
|
@@ -281,7 +281,7 @@ export interface OutcomeView {
|
|
|
281
281
|
* and counting groups as executed tasks drifts from the terminal.
|
|
282
282
|
*/
|
|
283
283
|
isGroup?: boolean
|
|
284
|
-
/** Executed
|
|
284
|
+
/** Executed without `cache: true`: never consulted the cache, not a miss. */
|
|
285
285
|
noCache?: boolean
|
|
286
286
|
/** Cache hits only: the exec time the entry was stored with — the work
|
|
287
287
|
* this hit skipped, as opposed to `durationMs`, the restore it cost. */
|