@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/graph/definition.ts
CHANGED
|
@@ -15,18 +15,31 @@ const memo = new WeakMap<TaskNode, string | undefined>()
|
|
|
15
15
|
export function taskDefinition(node: TaskNode): string | undefined {
|
|
16
16
|
if (memo.has(node)) return memo.get(node)
|
|
17
17
|
const exec = node.config.exec
|
|
18
|
+
const define = exec?.env?.define
|
|
18
19
|
const def =
|
|
19
20
|
exec?.command === undefined
|
|
20
21
|
? undefined
|
|
21
22
|
: xxh3hex(
|
|
22
|
-
`${node.id}\0${exec.command}\0${
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
`${node.id}\0${exec.command}\0${
|
|
24
|
+
// `[]` is what the stringify gives for no values: most tasks
|
|
25
|
+
// have none, and stringifying it for each was ~9 ms of an
|
|
26
|
+
// 8,002-hit warm run.
|
|
27
|
+
define === undefined || isEmpty(define)
|
|
28
|
+
? '[]'
|
|
29
|
+
: JSON.stringify(
|
|
30
|
+
Object.entries(define).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
31
|
+
)
|
|
32
|
+
}`,
|
|
25
33
|
)
|
|
26
34
|
memo.set(node, def)
|
|
27
35
|
return def
|
|
28
36
|
}
|
|
29
37
|
|
|
38
|
+
function isEmpty(o: object): boolean {
|
|
39
|
+
for (const _ in o) return false
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
/** Each task's definition (`taskDefinition`) by id, groups left out: what a history reader matches rows to. */
|
|
31
44
|
export function taskDefinitions(nodes: Iterable<TaskNode>): Map<string, string> {
|
|
32
45
|
const out = new Map<string, string>()
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -186,7 +186,7 @@ export interface TaskOutcome {
|
|
|
186
186
|
serverExit?: number
|
|
187
187
|
/**
|
|
188
188
|
* The input that changed while the command ran, failing the task under
|
|
189
|
-
* `
|
|
189
|
+
* `inputs.stability.severity: 'error'` (workspace-relative; '' when vx
|
|
190
190
|
* could not name it). The exit is vx's 1; the label reads this instead.
|
|
191
191
|
*/
|
|
192
192
|
inputMoved?: string
|
|
@@ -231,6 +231,8 @@ export interface TaskOutcome {
|
|
|
231
231
|
attempts?: number
|
|
232
232
|
/** The attempts that failed and were run again, in order: when each ended (epoch ms) and how. */
|
|
233
233
|
failedAttempts?: readonly { endedAt: number; exitCode: number; timedOut?: true }[]
|
|
234
|
+
/** Each of `failedAttempts`' output, as `failedOutput` encodes it: what `vx last --log` shows. */
|
|
235
|
+
attemptOutputs?: readonly string[]
|
|
234
236
|
/**
|
|
235
237
|
* Set when this run proved the task flaky: its key failed after it had
|
|
236
238
|
* passed (this run's included) or it passed only on a retry. Its row carries it.
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -294,7 +294,7 @@ export interface BuildGraphOptions {
|
|
|
294
294
|
*/
|
|
295
295
|
undeclaredDeps?: (taskId: string, name: string) => void
|
|
296
296
|
/**
|
|
297
|
-
* The workspace root, where `
|
|
297
|
+
* The workspace root, where `outputs.workspaceFiles` is anchored.
|
|
298
298
|
* Given, a root-anchored output is also compared with every project's
|
|
299
299
|
* `files` outputs (item 1088); absent (a graph built for a unit row), only
|
|
300
300
|
* with other root-anchored ones.
|
|
@@ -1274,7 +1274,7 @@ function detectOutputCollisions(
|
|
|
1274
1274
|
const byProject = new Map<string, TaskNode[]>()
|
|
1275
1275
|
const wsDeclarers: TaskNode[] = []
|
|
1276
1276
|
for (const n of nodes.values()) {
|
|
1277
|
-
const outs = n.config.
|
|
1277
|
+
const outs = n.config.outputs
|
|
1278
1278
|
if ((outs?.files?.length ?? 0) > 0) {
|
|
1279
1279
|
const bucket = byProject.get(n.projectName)
|
|
1280
1280
|
if (bucket) bucket.push(n)
|
|
@@ -1290,9 +1290,9 @@ function detectOutputCollisions(
|
|
|
1290
1290
|
const positiveOf = (globs: readonly string[] | undefined): readonly string[] | undefined =>
|
|
1291
1291
|
globs === undefined ? undefined : splitNegations(globs).positive
|
|
1292
1292
|
const filesOf = (n: TaskNode): readonly string[] | undefined =>
|
|
1293
|
-
positiveOf(n.config.
|
|
1293
|
+
positiveOf(n.config.outputs?.files)
|
|
1294
1294
|
const wsFilesOf = (n: TaskNode): readonly string[] | undefined =>
|
|
1295
|
-
positiveOf(n.config.
|
|
1295
|
+
positiveOf(n.config.outputs?.workspaceFiles)
|
|
1296
1296
|
for (const bucket of byProject.values()) {
|
|
1297
1297
|
// Most projects hold one task with outputs, and a call per project
|
|
1298
1298
|
// cost the cold build a measured 0.4 ms at 1,000 projects.
|
|
@@ -1346,7 +1346,7 @@ function detectOutputCollisions(
|
|
|
1346
1346
|
* captures, so the pair cannot collide there.
|
|
1347
1347
|
*/
|
|
1348
1348
|
function takenBackOf(n: TaskNode, field: 'files' | 'workspaceFiles'): string[] {
|
|
1349
|
-
return splitNegations(n.config.
|
|
1349
|
+
return splitNegations(n.config.outputs?.[field] ?? []).negative
|
|
1350
1350
|
}
|
|
1351
1351
|
|
|
1352
1352
|
/**
|
|
@@ -1374,9 +1374,9 @@ function collideAcross(
|
|
|
1374
1374
|
if (!outputsOverlap(ga, gb) || outputTakenBack(gb, wsBack) || outputTakenBack(ga, filesBack))
|
|
1375
1375
|
continue
|
|
1376
1376
|
const head =
|
|
1377
|
-
`${ws.id} declares the output ${JSON.stringify(ga)} in
|
|
1377
|
+
`${ws.id} declares the output ${JSON.stringify(ga)} in outputs.workspaceFiles, ` +
|
|
1378
1378
|
`inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
|
|
1379
|
-
`
|
|
1379
|
+
`outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
|
|
1380
1380
|
`cache-hit restore, so whichever of these runs second DELETES the other's output`
|
|
1381
1381
|
if (exclusive && (reaches(files.id, ws.id) || reaches(ws.id, files.id))) {
|
|
1382
1382
|
throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`, 'VX_E_OUTPUT_OVERLAP')
|
|
@@ -1400,7 +1400,7 @@ function collideAcross(
|
|
|
1400
1400
|
const outside = wsGlobs[own.indexOf(null)]!
|
|
1401
1401
|
throw new UserError(
|
|
1402
1402
|
`${head}. ${ws.id} depends on ${files.id}, but adding to its output needs every ` +
|
|
1403
|
-
`
|
|
1403
|
+
`outputs.workspaceFiles entry inside ${rel === '' ? 'the workspace root' : rel}, ` +
|
|
1404
1404
|
`and ${JSON.stringify(outside)} is not. Give each task its own output path.`,
|
|
1405
1405
|
'VX_E_OUTPUT_OVERLAP',
|
|
1406
1406
|
)
|
|
@@ -1640,7 +1640,7 @@ function collide(
|
|
|
1640
1640
|
const head =
|
|
1641
1641
|
`${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
|
|
1642
1642
|
(ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
|
|
1643
|
-
` in
|
|
1643
|
+
` in outputs.${field} — vx cleans a task's declared outputs before it runs and ` +
|
|
1644
1644
|
`before a cache-hit restore, so whichever of these runs second DELETES the other's ` +
|
|
1645
1645
|
`output`
|
|
1646
1646
|
// An overlap WITH an edge is the addition shape (item 588): the
|
|
@@ -1652,7 +1652,7 @@ function collide(
|
|
|
1652
1652
|
const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
|
|
1653
1653
|
const parts = {
|
|
1654
1654
|
headline: `${a.id} and ${b.id} both write ${ga === gb ? ga : `${ga} / ${gb}`}`,
|
|
1655
|
-
...(field === 'files' ? {} : { what: ['in
|
|
1655
|
+
...(field === 'files' ? {} : { what: ['in outputs.workspaceFiles'] }),
|
|
1656
1656
|
why: "vx clears a task's outputs before it runs, so one deletes the other's",
|
|
1657
1657
|
}
|
|
1658
1658
|
if (up !== undefined && exclusive)
|
|
@@ -1665,9 +1665,8 @@ function collide(
|
|
|
1665
1665
|
)
|
|
1666
1666
|
if (up !== undefined && down !== undefined) {
|
|
1667
1667
|
const downGlobs = splitNegations(
|
|
1668
|
-
(field === 'files'
|
|
1669
|
-
|
|
1670
|
-
: down.config.cache?.outputs.workspaceFiles) ?? [],
|
|
1668
|
+
(field === 'files' ? down.config.outputs?.files : down.config.outputs?.workspaceFiles) ??
|
|
1669
|
+
[],
|
|
1671
1670
|
).positive
|
|
1672
1671
|
;(down.addsToOutputsOf ??= []).push(up.id)
|
|
1673
1672
|
;(up.outputsAddedToBy ??= []).push(...(downGlobs ?? []))
|
|
@@ -1706,13 +1705,13 @@ export function outputTakenBack(output: string, negatives: readonly string[]): b
|
|
|
1706
1705
|
* and its key read nothing (hunt 8). Always refused, whatever the rules.
|
|
1707
1706
|
*/
|
|
1708
1707
|
function refuseSelfClean(node: TaskNode): void {
|
|
1709
|
-
const
|
|
1708
|
+
const declaredOutputs = node.config.outputs
|
|
1710
1709
|
const declared = node.config.inputs
|
|
1711
|
-
if (
|
|
1710
|
+
if (declaredOutputs === undefined || declared === undefined) return
|
|
1712
1711
|
for (const field of ['files', 'workspaceFiles'] as const) {
|
|
1713
|
-
if (!coversAny(declared[field],
|
|
1712
|
+
if (!coversAny(declared[field], declaredOutputs[field])) continue
|
|
1714
1713
|
const inputs = splitNegations(declared[field] ?? [])
|
|
1715
|
-
const out = splitNegations(
|
|
1714
|
+
const out = splitNegations(declaredOutputs[field] ?? [])
|
|
1716
1715
|
const outputs = out.positive
|
|
1717
1716
|
if (outputs.length === 0) continue
|
|
1718
1717
|
const roots = outputs.map((g) => rootless(staticPrefix(g)))
|
|
@@ -1823,11 +1822,11 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1823
1822
|
return list
|
|
1824
1823
|
}
|
|
1825
1824
|
for (const n of nodes.values()) {
|
|
1826
|
-
const
|
|
1825
|
+
const declaredOutputs = n.config.outputs
|
|
1827
1826
|
const inputs = n.config.inputs
|
|
1828
1827
|
if (inputs === undefined) continue
|
|
1829
|
-
const outFiles = positives(
|
|
1830
|
-
const outWs = positives(
|
|
1828
|
+
const outFiles = positives(declaredOutputs?.files ?? [])
|
|
1829
|
+
const outWs = positives(declaredOutputs?.workspaceFiles ?? [])
|
|
1831
1830
|
if (outFiles.length > 0) {
|
|
1832
1831
|
const anchor = workspaceRoot === undefined ? undefined : relPosix(workspaceRoot, n.projectDir)
|
|
1833
1832
|
bucket(n.projectName).push({ node: n, globs: outFiles, reads: false, anchor })
|
|
@@ -1895,7 +1894,7 @@ function readsRootedOutputs(
|
|
|
1895
1894
|
if (workspaceRoot !== undefined) {
|
|
1896
1895
|
for (const node of filesWriters) {
|
|
1897
1896
|
const rel = relPosix(workspaceRoot, node.projectDir)
|
|
1898
|
-
const globs = positives(node.config.
|
|
1897
|
+
const globs = positives(node.config.outputs!.files).map((g) =>
|
|
1899
1898
|
rel === '' ? g : `${rel}/${g}`,
|
|
1900
1899
|
)
|
|
1901
1900
|
rooted.push({ node, globs, reads: false, rel })
|
|
@@ -2025,14 +2024,16 @@ function takenBack(
|
|
|
2025
2024
|
let tb = memo.bySide.get(reader)
|
|
2026
2025
|
if (tb === undefined) {
|
|
2027
2026
|
const inputs = reader.node.config.inputs!
|
|
2028
|
-
const
|
|
2027
|
+
const declaredOutputs = reader.node.config.outputs
|
|
2029
2028
|
// A reader rebased to the root (X-135) speaks in its project's terms.
|
|
2030
2029
|
const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
|
|
2031
2030
|
// What the reader's key never reads: its `!` entries and its own outputs.
|
|
2032
2031
|
const list = [
|
|
2033
2032
|
...splitNegations(field === 'files' ? inputs.files : (inputs.workspaceFiles ?? [])).negative,
|
|
2034
2033
|
...splitNegations(
|
|
2035
|
-
field === 'files'
|
|
2034
|
+
field === 'files'
|
|
2035
|
+
? (declaredOutputs?.files ?? [])
|
|
2036
|
+
: (declaredOutputs?.workspaceFiles ?? []),
|
|
2036
2037
|
).positive,
|
|
2037
2038
|
].map((g) => at + g)
|
|
2038
2039
|
const key = list.join('\0')
|
|
@@ -30,7 +30,7 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
|
|
|
30
30
|
* `status`, `cache_hit`, `cached` columns) with how often it failed, how often it
|
|
31
31
|
* passed — an executed success or a cache hit, which replays a success —
|
|
32
32
|
* and how often it failed AFTER its first pass (`relapses`). A task with
|
|
33
|
-
* no cache
|
|
33
|
+
* no `cache: true` keys on its config alone and runs every time, so its key
|
|
34
34
|
* says nothing about its inputs: no reader counts it, as no run's row
|
|
35
35
|
* calls it flaky. Nor is a row that names a `blocked_by`: it ran behind a
|
|
36
36
|
* failure (`--continue=always`), on bytes its key never named, so its
|
|
@@ -198,6 +198,29 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
|
|
|
198
198
|
return out
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Whether a task that just failed on `hash` is known to be flaky: this key
|
|
203
|
+
* passed before (the failure itself is the flake), or the retained history
|
|
204
|
+
* holds a key of the task that failed after it passed, or a pass that took
|
|
205
|
+
* a retry. Asked only after a failure, so a green run never reads it.
|
|
206
|
+
*/
|
|
207
|
+
export function knownFlaky(db: Database, project: string, task: string, hash: string): boolean {
|
|
208
|
+
const passed = db
|
|
209
|
+
.query(
|
|
210
|
+
`SELECT 1 FROM ${OUTCOMES} WHERE hash = ? AND project = ? AND task = ?
|
|
211
|
+
AND (status = 'success' OR status IN ${HIT_STATUSES}) LIMIT 1`,
|
|
212
|
+
)
|
|
213
|
+
.get(hash, project, task)
|
|
214
|
+
if (passed !== null) return true
|
|
215
|
+
const flaked = db
|
|
216
|
+
.query(
|
|
217
|
+
`SELECT 1 FROM (${keyOutcomesSql(OUTCOMES, ' AND project = ? AND task = ?')})
|
|
218
|
+
WHERE relapses > 0 OR retried_failures > 0 LIMIT 1`,
|
|
219
|
+
)
|
|
220
|
+
.get(project, task)
|
|
221
|
+
return flaked !== null
|
|
222
|
+
}
|
|
223
|
+
|
|
201
224
|
/** One task the recorded history shows failing on a key that had passed. */
|
|
202
225
|
export interface FlakyTask {
|
|
203
226
|
/** `project#task`. */
|
package/src/history/index.ts
CHANGED
package/src/history/metrics.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface RunSummaryRow {
|
|
|
39
39
|
cacheHit: boolean | null
|
|
40
40
|
/** On a hit: true when outputs were restored, false when up-to-date (v31); null otherwise. */
|
|
41
41
|
restored: boolean | null
|
|
42
|
-
/** Whether the task declared
|
|
42
|
+
/** Whether the task declared `cache: true`; null on rows older than the column. */
|
|
43
43
|
cached: boolean | null
|
|
44
44
|
hash: string
|
|
45
45
|
cpuMs: number | null
|
|
@@ -382,7 +382,7 @@ export interface WhyDidThisRerun {
|
|
|
382
382
|
cacheHit: boolean | null
|
|
383
383
|
/** On a hit: outputs restored (true) or up-to-date (false); null otherwise. */
|
|
384
384
|
restored: boolean | null
|
|
385
|
-
/** Whether the task declared
|
|
385
|
+
/** Whether the task declared `cache: true`; null on rows older than the column. */
|
|
386
386
|
cached: boolean | null
|
|
387
387
|
startedAt: number
|
|
388
388
|
}
|
|
@@ -614,9 +614,9 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
614
614
|
// one's is new each run, so every dependent misses: "folded by
|
|
615
615
|
// dependents" read as if they could hit.
|
|
616
616
|
this_.keylessBy === null
|
|
617
|
-
? 'this task declares no `cache`
|
|
617
|
+
? 'this task declares no `cache: true` — it runs on every invocation; its key is folded by dependents only'
|
|
618
618
|
: this_.keylessBy === taskId
|
|
619
|
-
? 'this task declares no `cache`
|
|
619
|
+
? 'this task declares no `cache: true` — it runs on every invocation; it declares no `inputs` either, so each run derives a new key and every task that depends on it misses; declare its `inputs`'
|
|
620
620
|
: `this task declares no \`cache\` block — it runs on every invocation; its key includes ${this_.keylessBy}, which declares no \`inputs\`, so each run derives a new key and every task that depends on it misses; declare inputs on ${this_.keylessBy}`
|
|
621
621
|
: this_.keylessBy !== null
|
|
622
622
|
? keylessNote(taskId, this_.keylessBy)
|
|
@@ -889,7 +889,7 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
889
889
|
this_.cached === 0
|
|
890
890
|
? // The verdict already says the task is uncached; this line says
|
|
891
891
|
// why no input can be named.
|
|
892
|
-
'
|
|
892
|
+
'without `cache: true` it saves no entry, so no input fingerprints are kept to say which of its `inputs` moved its key; with no `inputs`, every run gives it a new key'
|
|
893
893
|
: unsaved !== null
|
|
894
894
|
? `cache key changed but input fingerprints are unavailable — ${unsaved} and saved no entry; only the key change is known`
|
|
895
895
|
: 'cache key changed but input fingerprints are unavailable — the entry was pruned (or the row predates the `cached` column); only the key change is known',
|
package/src/index.ts
CHANGED
|
@@ -41,9 +41,10 @@ export type {
|
|
|
41
41
|
TaskConfig,
|
|
42
42
|
ExecConfig,
|
|
43
43
|
ExecEnv,
|
|
44
|
-
CacheConfig,
|
|
45
44
|
InputsConfig,
|
|
46
|
-
|
|
45
|
+
TaskOutputs,
|
|
46
|
+
WorkspaceInputs,
|
|
47
|
+
WorkspaceOutputs,
|
|
47
48
|
InputStability,
|
|
48
49
|
SandboxConfig,
|
|
49
50
|
SandboxGrants,
|
|
@@ -245,6 +246,7 @@ export type {
|
|
|
245
246
|
ServerSpec,
|
|
246
247
|
TaskExecutor,
|
|
247
248
|
TaskPlacement,
|
|
249
|
+
WorkerAdvice,
|
|
248
250
|
} from './exec/index.js'
|
|
249
251
|
|
|
250
252
|
// Telemetry — THE canonical, versioned data-export contract every exporter
|
|
@@ -186,7 +186,7 @@ export function admitTasks(
|
|
|
186
186
|
const cacheable =
|
|
187
187
|
!isGroupTask(node) &&
|
|
188
188
|
node.config.exec?.persistent === undefined &&
|
|
189
|
-
node.config.cache
|
|
189
|
+
node.config.cache === true &&
|
|
190
190
|
canRead &&
|
|
191
191
|
canWrite
|
|
192
192
|
// A restore-tier task (confirmed local hit, may run before its deps)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// When each command of the run ran and where it may write, so
|
|
2
|
-
// `
|
|
2
|
+
// `inputs.stability.strategy: 'mutable'` can tell a task's own identical
|
|
3
3
|
// rewrite from another task's write and restore (STATUS item 28): a file
|
|
4
4
|
// whose bytes match the key after a write is the task's own only if no
|
|
5
5
|
// other command that may write it ran between the describe and that write.
|
|
@@ -115,7 +115,7 @@ export class DeferredOutputs {
|
|
|
115
115
|
// Project outputs only: a task declaring `outputs.workspaceFiles` is
|
|
116
116
|
// never deferred (`deferralEligibility` forces it eager), so nothing
|
|
117
117
|
// workspace-anchored can reach this path.
|
|
118
|
-
const outputs = producer.config.
|
|
118
|
+
const outputs = producer.config.outputs?.files ?? []
|
|
119
119
|
const nestedProjectDirs = this.args.nestedDirsByProject.get(producer.projectName) ?? []
|
|
120
120
|
const cleanArgs = {
|
|
121
121
|
projectDir: producer.projectDir,
|
|
@@ -44,14 +44,12 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
|
|
|
44
44
|
if (list) list.push({ taskId: n.id, prefixes, via })
|
|
45
45
|
else readersByProject.set(n.projectName, [{ taskId: n.id, prefixes, via }])
|
|
46
46
|
}
|
|
47
|
-
// A task
|
|
47
|
+
// A task without `cache: true` keys on its inputs, and a cached task that
|
|
48
48
|
// depends on it folds that key: deferring a producer there moved both
|
|
49
49
|
// keys with the transfer flag. Only such a task is a reader — one nothing
|
|
50
50
|
// cached folds keys nothing that lasts.
|
|
51
51
|
const folded = new Set<string>()
|
|
52
|
-
const stack = [...nodes.values()]
|
|
53
|
-
.filter((n) => n.config.cache !== undefined)
|
|
54
|
-
.flatMap((n) => n.deps)
|
|
52
|
+
const stack = [...nodes.values()].filter((n) => n.config.cache === true).flatMap((n) => n.deps)
|
|
55
53
|
for (let id = stack.pop(); id !== undefined; id = stack.pop()) {
|
|
56
54
|
if (folded.has(id)) continue
|
|
57
55
|
folded.add(id)
|
|
@@ -63,7 +61,7 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
|
|
|
63
61
|
const inputs = n.config.inputs
|
|
64
62
|
// A task with no inputs has no key to read; an uncached one with inputs
|
|
65
63
|
// is read only for the cached tasks that fold its key.
|
|
66
|
-
if (inputs === undefined || (n.config.cache
|
|
64
|
+
if (inputs === undefined || (n.config.cache !== true && !folded.has(n.id))) continue
|
|
67
65
|
if ((inputs?.workspaceFiles?.length ?? 0) > 0) workspaceReader ??= n.id
|
|
68
66
|
// A `runtime` input is a SHELL COMMAND whose reads are unknowable —
|
|
69
67
|
// the same reason vx refuses to infer inputs by tracing. It can `cat` a
|
|
@@ -86,11 +84,11 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
|
|
|
86
84
|
}
|
|
87
85
|
|
|
88
86
|
for (const n of nodes.values()) {
|
|
89
|
-
const outputs = n.config.
|
|
87
|
+
const outputs = n.config.outputs
|
|
90
88
|
if ((outputs?.workspaceFiles?.length ?? 0) > 0) {
|
|
91
89
|
ineligible.set(
|
|
92
90
|
n.id,
|
|
93
|
-
'declares
|
|
91
|
+
'declares outputs.workspaceFiles — root-anchored outputs can land where any project-relative input reads',
|
|
94
92
|
)
|
|
95
93
|
continue
|
|
96
94
|
}
|
|
@@ -117,7 +117,7 @@ export function excludedTaint(nodes: ReadonlyMap<string, TaskNode>): {
|
|
|
117
117
|
let unsaved = 0
|
|
118
118
|
for (const id of reached) {
|
|
119
119
|
const cfg = nodes.get(id)!.config
|
|
120
|
-
if (cfg.cache
|
|
120
|
+
if (cfg.cache === true && cfg.exec?.persistent === undefined) unsaved++
|
|
121
121
|
}
|
|
122
122
|
return { seeds, unsaved }
|
|
123
123
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import type { ExecConfig, TaskConfig,
|
|
2
|
+
import type { ExecConfig, TaskConfig, InputsConfig, InputStability } from '../config.js'
|
|
3
3
|
import {
|
|
4
4
|
ArtifactVanishedError,
|
|
5
5
|
type CacheEntry,
|
|
@@ -58,6 +58,7 @@ import { executorLabel, nameExecutorFailure } from './plugin-host.js'
|
|
|
58
58
|
import type { CommandWindows } from './command-windows.js'
|
|
59
59
|
import {
|
|
60
60
|
commandWriteReach,
|
|
61
|
+
dependencyReads,
|
|
61
62
|
mayWriteFingerprint,
|
|
62
63
|
type Placeholder,
|
|
63
64
|
reachedWithheld,
|
|
@@ -183,17 +184,23 @@ export interface ExecuteArgs {
|
|
|
183
184
|
* byte-identical with and without it.
|
|
184
185
|
*/
|
|
185
186
|
retries?: number
|
|
187
|
+
/**
|
|
188
|
+
* Whether the run history knows the task as flaky (`knownFlaky`): a keyed
|
|
189
|
+
* task with no `retries` of its own or of the run that fails gets ONE more
|
|
190
|
+
* attempt when it does. Decided at run time, so never in the key.
|
|
191
|
+
*/
|
|
192
|
+
knownFlaky?: (node: TaskNode, hash: string) => boolean
|
|
186
193
|
/**
|
|
187
194
|
* `run.timeout` (ms), already resolved from flag, variable and
|
|
188
195
|
* workspace. Per-task `exec.timeout` wins. Never hashed.
|
|
189
196
|
*/
|
|
190
197
|
timeout?: number
|
|
191
198
|
/**
|
|
192
|
-
* The run's `
|
|
199
|
+
* The run's `inputs.stability` layers around the task's own: `run` from
|
|
193
200
|
* the env vars (above the task), `workspace` from `.vx/workspace` (below).
|
|
194
201
|
*/
|
|
195
202
|
inputStability?: { run: InputStability; workspace?: InputStability }
|
|
196
|
-
/** `
|
|
203
|
+
/** `outputs.clean` for tasks without their own: `.vx/workspace`. */
|
|
197
204
|
cleanOutputs?: boolean
|
|
198
205
|
/** Per-run memo for `git ls-files` (one entry per project dir). */
|
|
199
206
|
gitFilesCache?: GitFilesCache
|
|
@@ -236,6 +243,11 @@ export interface ExecuteArgs {
|
|
|
236
243
|
* granted a linked workspace package only when it is in this set.
|
|
237
244
|
*/
|
|
238
245
|
keyedProjects: (node: TaskNode) => ReadonlySet<string>
|
|
246
|
+
/**
|
|
247
|
+
* The run's lookup of the tasks whose outputs a sandboxed task may read
|
|
248
|
+
* (keyed-projects.ts `readableDeps`). Absent: none.
|
|
249
|
+
*/
|
|
250
|
+
readableDeps?: (node: TaskNode) => readonly TaskNode[]
|
|
239
251
|
/**
|
|
240
252
|
* Run-scoped list the miss path appends its output-directory snapshot
|
|
241
253
|
* request to, taken at run end (run.ts) instead of right after the save:
|
|
@@ -345,7 +357,7 @@ function readsCache(args: ExecuteArgs): boolean {
|
|
|
345
357
|
const policy = args.cachePolicy ?? FULL_CACHE_POLICY
|
|
346
358
|
return (
|
|
347
359
|
args.remoteOnly !== true &&
|
|
348
|
-
args.node.config.cache
|
|
360
|
+
args.node.config.cache === true &&
|
|
349
361
|
(policy.localRead || policy.remoteRead)
|
|
350
362
|
)
|
|
351
363
|
}
|
|
@@ -397,16 +409,16 @@ async function restoreOrMiss(
|
|
|
397
409
|
|
|
398
410
|
/**
|
|
399
411
|
* What a write to an input during the command means, each field highest
|
|
400
|
-
* first: the env var (`layers.run`), the task's `
|
|
412
|
+
* first: the env var (`layers.run`), the task's `inputs.stability`,
|
|
401
413
|
* the workspace's, then `'immutable'` and `'warn'` (2026-10-12), so
|
|
402
414
|
* by default any such write withholds the save, on CI too, since a
|
|
403
415
|
* concurrent task can change an input and put it back.
|
|
404
416
|
*/
|
|
405
417
|
function inputStabilityOf(
|
|
406
|
-
|
|
418
|
+
inputs: InputsConfig | undefined,
|
|
407
419
|
layers: ExecuteArgs['inputStability'],
|
|
408
420
|
): Required<InputStability> {
|
|
409
|
-
const task =
|
|
421
|
+
const task = inputs?.stability
|
|
410
422
|
return {
|
|
411
423
|
strategy: layers?.run.strategy ?? task?.strategy ?? layers?.workspace?.strategy ?? 'immutable',
|
|
412
424
|
severity: layers?.run.severity ?? task?.severity ?? layers?.workspace?.severity ?? 'warn',
|
|
@@ -518,9 +530,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
518
530
|
const { node, upstream, cache, log } = args
|
|
519
531
|
const cfg: TaskConfig = node.config
|
|
520
532
|
const step = cfg.exec as ExecConfig // dispatcher guarantees exec is present
|
|
521
|
-
const
|
|
533
|
+
const cfgOut = cfg.outputs
|
|
522
534
|
const policy = args.cachePolicy ?? FULL_CACHE_POLICY
|
|
523
|
-
const cfgCacheable =
|
|
535
|
+
const cfgCacheable = cfg.cache === true
|
|
524
536
|
// We may READ when at least one read axis is on (the cache layer
|
|
525
537
|
// refines local vs remote), and WRITE when at least one write axis is
|
|
526
538
|
// on. Output management (clean before exec) keys off writes — so
|
|
@@ -571,8 +583,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
571
583
|
const deferralRequested = args.download === 'deferred'
|
|
572
584
|
const capture: CaptureConfig = { stdout: false, stderr: false }
|
|
573
585
|
|
|
574
|
-
const outputs =
|
|
575
|
-
const wsOutputs =
|
|
586
|
+
const outputs = cfgOut?.files ?? []
|
|
587
|
+
const wsOutputs = cfgOut?.workspaceFiles ?? []
|
|
576
588
|
const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
|
|
577
589
|
// Per-task `exec.timeout` beats `run.timeout` (flag, variable,
|
|
578
590
|
// workspace; resolved into `args.timeout` by run.ts).
|
|
@@ -735,12 +747,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
735
747
|
const additive = (node.addsToOutputsOf?.length ?? 0) > 0
|
|
736
748
|
let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
|
|
737
749
|
let wsStampedBefore: ReadonlyMap<string, OutputStamp> | undefined
|
|
738
|
-
// `
|
|
750
|
+
// `outputs.clean: false` leaves the outputs to the first attempt's
|
|
739
751
|
// command, and the save stores whatever they hold after it, a leftover
|
|
740
752
|
// included: the user opted out of the clean-tree guarantee
|
|
741
753
|
// (2026-10-15). A retry still starts clean. An additive task never cleans
|
|
742
754
|
// by glob anyway.
|
|
743
|
-
const keepOutputs = !additive && (
|
|
755
|
+
const keepOutputs = !additive && (cfgOut?.clean ?? args.cleanOutputs ?? true) === false
|
|
744
756
|
|
|
745
757
|
// Cache lookup. On hit, time the user-perceived restore op
|
|
746
758
|
// (clean+restore+log-replay) — that's what the framed-block footer
|
|
@@ -889,7 +901,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
889
901
|
// Cache miss path (or caching disabled), up to `1 + retries` attempts.
|
|
890
902
|
// Explicit config wins over the run-level `--retries` default, including
|
|
891
903
|
// an explicit `retries: 0`.
|
|
892
|
-
const
|
|
904
|
+
const retriesSet = step.retries !== undefined || args.retries !== undefined
|
|
905
|
+
let maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
|
|
893
906
|
let attempt = 0
|
|
894
907
|
let result: ExecuteResult
|
|
895
908
|
let effectiveExitCode: number
|
|
@@ -1173,6 +1186,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1173
1186
|
const storeMask = secrets ?? undefined
|
|
1174
1187
|
const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [], storeMask)
|
|
1175
1188
|
const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
|
|
1189
|
+
// Each failed attempt's output, beside `failedAttempts`: kept for `vx last --log`.
|
|
1190
|
+
const attemptOutputs: string[] = []
|
|
1176
1191
|
// The sampling of the attempt's process tree, stopped once it settles.
|
|
1177
1192
|
let untrack: (() => TreeLoad | undefined) | undefined
|
|
1178
1193
|
// What the last attempt's samples saw, for the run history.
|
|
@@ -1211,6 +1226,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1211
1226
|
if (a.withdrawn) {
|
|
1212
1227
|
attempt--
|
|
1213
1228
|
failedAttempts.pop()
|
|
1229
|
+
attemptOutputs.pop()
|
|
1214
1230
|
break
|
|
1215
1231
|
}
|
|
1216
1232
|
forgetUndeclaredWrites(args, writeReach)
|
|
@@ -1252,12 +1268,27 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1252
1268
|
}
|
|
1253
1269
|
}
|
|
1254
1270
|
|
|
1271
|
+
// A known flake gets the one retry its config did not ask for; a
|
|
1272
|
+
// timeout always fails, and a task whose key names nothing (no cache
|
|
1273
|
+
// block, or keyed on a keyless upstream) has no record to know it by.
|
|
1274
|
+
if (
|
|
1275
|
+
effectiveExitCode !== 0 &&
|
|
1276
|
+
attempt === 1 &&
|
|
1277
|
+
!retriesSet &&
|
|
1278
|
+
result.timedOut !== true &&
|
|
1279
|
+
cfgCacheable &&
|
|
1280
|
+
!keyless &&
|
|
1281
|
+
args.failFast?.aborted !== true &&
|
|
1282
|
+
args.knownFlaky?.(node, hash) === true
|
|
1283
|
+
)
|
|
1284
|
+
maxAttempts = 2
|
|
1255
1285
|
if (effectiveExitCode === 0 || attempt >= maxAttempts || args.failFast?.aborted === true) break
|
|
1256
1286
|
failedAttempts.push({
|
|
1257
1287
|
endedAt: Date.now(),
|
|
1258
1288
|
exitCode: effectiveExitCode,
|
|
1259
1289
|
...(result.timedOut === true ? { timedOut: true as const } : {}),
|
|
1260
1290
|
})
|
|
1291
|
+
attemptOutputs.push(output === undefined ? '' : failedOutputLog(output, true, storeMask))
|
|
1261
1292
|
log.taskStderr(
|
|
1262
1293
|
node,
|
|
1263
1294
|
`${midLine ? '\n' : ''}vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${result.timedOut === true ? 'a timeout' : `exit ${effectiveExitCode}`}\n`,
|
|
@@ -1285,6 +1316,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1285
1316
|
flushMasked = () => masked?.end()
|
|
1286
1317
|
const base: ExecuteRequest = {
|
|
1287
1318
|
taskId: node.id,
|
|
1319
|
+
...(attempt > 1 ? { attempt } : {}),
|
|
1288
1320
|
workspaceRoot: args.workspaceRoot,
|
|
1289
1321
|
command: step.command,
|
|
1290
1322
|
forwardArgs: effectiveForwardArgs,
|
|
@@ -1336,6 +1368,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1336
1368
|
cfgCacheable ? args.keyedProjects(node) : undefined,
|
|
1337
1369
|
args.nestedProjectDirs,
|
|
1338
1370
|
args.cacheDir,
|
|
1371
|
+
await dependencyReads(args.readableDeps?.(node) ?? [], args.workspaceRoot),
|
|
1339
1372
|
)
|
|
1340
1373
|
placeholders = sb.placeholders
|
|
1341
1374
|
withheld = sb.withheld
|
|
@@ -1344,7 +1377,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1344
1377
|
|
|
1345
1378
|
const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
1346
1379
|
|
|
1347
|
-
const stability = inputStabilityOf(args.node.config.
|
|
1380
|
+
const stability = inputStabilityOf(args.node.config.inputs, args.inputStability)
|
|
1348
1381
|
let movedFails: string | undefined
|
|
1349
1382
|
let own: Awaited<ReturnType<typeof ownOutputs>>
|
|
1350
1383
|
let saved = false
|
|
@@ -1540,11 +1573,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1540
1573
|
const hint =
|
|
1541
1574
|
moved === null || rewroteBy !== undefined
|
|
1542
1575
|
? ''
|
|
1543
|
-
: '; if the task writes it, declare it in
|
|
1576
|
+
: '; if the task writes it, declare it in outputs'
|
|
1544
1577
|
if (stability.severity === 'error') {
|
|
1545
1578
|
warnOf(log)(
|
|
1546
1579
|
`[vx] ${node.id}: ${what} — the result is not saved, ` +
|
|
1547
|
-
`and
|
|
1580
|
+
`and inputs.stability.severity is 'error', so the task fails${hint}`,
|
|
1548
1581
|
)
|
|
1549
1582
|
movedFails = moved === null ? '' : printable(relPosix(args.workspaceRoot, moved))
|
|
1550
1583
|
} else {
|
|
@@ -1577,7 +1610,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1577
1610
|
durationMs: spentMs,
|
|
1578
1611
|
hash,
|
|
1579
1612
|
...(attempt > 1 ? { attempts: attempt } : {}),
|
|
1580
|
-
...(failedAttempts.length > 0 ? { failedAttempts } : {}),
|
|
1613
|
+
...(failedAttempts.length > 0 ? { failedAttempts, attemptOutputs } : {}),
|
|
1581
1614
|
...(described !== undefined
|
|
1582
1615
|
? { inputFiles: captured.reduce((n, c) => (c.kind === 'file' ? n + 1 : n), 0) }
|
|
1583
1616
|
: {}),
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
// 2026-09-10 as pure code motion, the mirror of miss-save.ts.
|
|
10
10
|
|
|
11
11
|
import path from 'node:path'
|
|
12
|
-
import type { CacheConfig } from '../config.js'
|
|
13
12
|
import {
|
|
14
13
|
type CacheEntry,
|
|
15
14
|
cleanOutputPaths,
|
|
@@ -58,9 +57,9 @@ export interface RestoreHitArgs {
|
|
|
58
57
|
export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
|
|
59
58
|
const { args, hash } = given
|
|
60
59
|
const { node } = args
|
|
61
|
-
const
|
|
62
|
-
const outputs =
|
|
63
|
-
const wsOutputs =
|
|
60
|
+
const cfgOut = node.config.outputs
|
|
61
|
+
const outputs = cfgOut?.files ?? []
|
|
62
|
+
const wsOutputs = cfgOut?.workspaceFiles ?? []
|
|
64
63
|
const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
|
|
65
64
|
// A layer that handed no rows: one SELECT, read by both paths below.
|
|
66
65
|
const restore =
|
|
@@ -399,8 +398,8 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
|
|
|
399
398
|
export function currentHit(restore: RestoreHitArgs): (() => TaskOutcome) | undefined {
|
|
400
399
|
const { args, hash, hit } = restore
|
|
401
400
|
const { node } = args
|
|
402
|
-
const outputs = node.config.
|
|
403
|
-
if ((node.config.
|
|
401
|
+
const outputs = node.config.outputs?.files ?? []
|
|
402
|
+
if ((node.config.outputs?.workspaceFiles?.length ?? 0) > 0) return undefined
|
|
404
403
|
if (outputs.length > 0) {
|
|
405
404
|
const dirPrefixes = dirPrefixesOf(outputs)
|
|
406
405
|
if (dirPrefixes === null || args.cache.outputDirsCurrent === undefined) return undefined
|