@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.
Files changed (78) hide show
  1. package/README.md +2 -3
  2. package/package.json +5 -5
  3. package/schemas/show.json +14 -20
  4. package/src/bin.ts +12 -0
  5. package/src/cache/archive.ts +1 -1
  6. package/src/cache/cache.ts +19 -5
  7. package/src/cache/history-stream.ts +134 -0
  8. package/src/cache/history-writer.ts +76 -0
  9. package/src/cache/index.ts +2 -0
  10. package/src/cache/inputs.ts +74 -14
  11. package/src/cache/layer.ts +6 -6
  12. package/src/cache/lockfile-memo.ts +2 -1
  13. package/src/cache/run-history.ts +46 -17
  14. package/src/cache/schema.ts +4 -1
  15. package/src/cli/cache.ts +2 -2
  16. package/src/cli/help.ts +2 -2
  17. package/src/cli/index.ts +24 -0
  18. package/src/cli/info.ts +1 -1
  19. package/src/cli/last.ts +34 -2
  20. package/src/cli/plugin-templates.ts +36 -45
  21. package/src/cli/show.ts +7 -10
  22. package/src/cli/watch-judge.ts +1 -1
  23. package/src/cli/watch-set.ts +4 -4
  24. package/src/cli/watch.ts +11 -8
  25. package/src/config.ts +47 -30
  26. package/src/exec/executor.ts +40 -1
  27. package/src/exec/index.ts +1 -0
  28. package/src/exec/sandbox-paths.ts +3 -1
  29. package/src/exec/sandbox-runtime.ts +314 -29
  30. package/src/exec/task-proxy.ts +93 -0
  31. package/src/graph/definition.ts +16 -3
  32. package/src/graph/scheduler.ts +3 -1
  33. package/src/graph/task-graph.ts +24 -23
  34. package/src/history/failure-mode.ts +24 -1
  35. package/src/history/index.ts +1 -0
  36. package/src/history/metrics.ts +5 -5
  37. package/src/index.ts +4 -2
  38. package/src/orchestrator/admission.ts +1 -1
  39. package/src/orchestrator/command-windows.ts +1 -1
  40. package/src/orchestrator/deferred-outputs.ts +1 -1
  41. package/src/orchestrator/download-policy.ts +5 -7
  42. package/src/orchestrator/excluded-keys.ts +1 -1
  43. package/src/orchestrator/execute-task.ts +51 -18
  44. package/src/orchestrator/hit-restore.ts +5 -6
  45. package/src/orchestrator/keyed-projects.ts +28 -0
  46. package/src/orchestrator/local-shortcircuit.ts +2 -2
  47. package/src/orchestrator/miss-save.ts +27 -8
  48. package/src/orchestrator/persistent-task.ts +2 -0
  49. package/src/orchestrator/placement.ts +1 -1
  50. package/src/orchestrator/plan.ts +2 -2
  51. package/src/orchestrator/plugin-host.ts +6 -2
  52. package/src/orchestrator/plugin.ts +6 -0
  53. package/src/orchestrator/remote-prefetch.ts +1 -1
  54. package/src/orchestrator/run-failures.ts +61 -12
  55. package/src/orchestrator/run-keys.ts +1 -1
  56. package/src/orchestrator/run-records.ts +86 -67
  57. package/src/orchestrator/run-report.ts +1 -1
  58. package/src/orchestrator/run.ts +92 -19
  59. package/src/orchestrator/sandbox-request.ts +50 -7
  60. package/src/orchestrator/shell-verdict.ts +9 -0
  61. package/src/orchestrator/stable-keys.ts +17 -18
  62. package/src/orchestrator/task-artifacts.ts +1 -1
  63. package/src/orchestrator/task-hash.ts +4 -4
  64. package/src/orchestrator/telemetry.ts +5 -1
  65. package/src/report/events.ts +3 -3
  66. package/src/report/framed-output.ts +16 -7
  67. package/src/report/logger.ts +1 -1
  68. package/src/report/plain-output.ts +2 -0
  69. package/src/report/summary.ts +5 -5
  70. package/src/settings.ts +8 -8
  71. package/src/util/config-frame.ts +128 -6
  72. package/src/util/paths.ts +1 -1
  73. package/src/util/settle.ts +2 -1
  74. package/src/util/tail.ts +4 -1
  75. package/src/workspace/bracket-literal.ts +2 -2
  76. package/src/workspace/config-schema.ts +66 -61
  77. package/src/workspace/filter.ts +8 -1
  78. package/src/workspace/migration.ts +12 -6
@@ -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${JSON.stringify(
23
- Object.entries(exec.env?.define ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
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>()
@@ -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
- * `inputStability.severity: 'error'` (workspace-relative; '' when vx
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.
@@ -294,7 +294,7 @@ export interface BuildGraphOptions {
294
294
  */
295
295
  undeclaredDeps?: (taskId: string, name: string) => void
296
296
  /**
297
- * The workspace root, where `cache.outputs.workspaceFiles` is anchored.
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.cache?.outputs
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.cache?.outputs.files)
1293
+ positiveOf(n.config.outputs?.files)
1294
1294
  const wsFilesOf = (n: TaskNode): readonly string[] | undefined =>
1295
- positiveOf(n.config.cache?.outputs.workspaceFiles)
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.cache?.outputs[field] ?? []).negative
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 cache.outputs.workspaceFiles, ` +
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
- `cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
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
- `cache.outputs.workspaceFiles entry inside ${rel === '' ? 'the workspace root' : rel}, ` +
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 cache.outputs.${field} — vx cleans a task's declared outputs before it runs and ` +
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 cache.outputs.workspaceFiles'] }),
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
- ? down.config.cache?.outputs.files
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 cache = node.config.cache
1708
+ const declaredOutputs = node.config.outputs
1710
1709
  const declared = node.config.inputs
1711
- if (cache === undefined || declared === undefined) return
1710
+ if (declaredOutputs === undefined || declared === undefined) return
1712
1711
  for (const field of ['files', 'workspaceFiles'] as const) {
1713
- if (!coversAny(declared[field], cache.outputs[field])) continue
1712
+ if (!coversAny(declared[field], declaredOutputs[field])) continue
1714
1713
  const inputs = splitNegations(declared[field] ?? [])
1715
- const out = splitNegations(cache.outputs[field] ?? [])
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 cache = n.config.cache
1825
+ const declaredOutputs = n.config.outputs
1827
1826
  const inputs = n.config.inputs
1828
1827
  if (inputs === undefined) continue
1829
- const outFiles = positives(cache?.outputs.files ?? [])
1830
- const outWs = positives(cache?.outputs.workspaceFiles ?? [])
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.cache!.outputs.files).map((g) =>
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 cache = reader.node.config.cache
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' ? (cache?.outputs.files ?? []) : (cache?.outputs.workspaceFiles ?? []),
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 block keys on its config alone and runs every time, so its key
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`. */
@@ -16,6 +16,7 @@ export {
16
16
  } from './history.js'
17
17
  export {
18
18
  detectFlaky,
19
+ knownFlaky,
19
20
  type FailureMode,
20
21
  type FlakyCandidate,
21
22
  type FlakyFinding,
@@ -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 a `cache` block; null on rows older than the column. */
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 a `cache` block; null on rows older than the column. */
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` block — it runs on every invocation; its key is folded by dependents only'
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` block — 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`'
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
- 'with no `cache` block 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'
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
- CacheOutputs,
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 !== undefined &&
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
- // `inputStability.strategy: 'mutable'` can tell a task's own identical
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.cache?.outputs.files ?? []
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 with no `cache` block keys on its inputs, and a cached task that
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 === undefined && !folded.has(n.id))) continue
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.cache?.outputs
87
+ const outputs = n.config.outputs
90
88
  if ((outputs?.workspaceFiles?.length ?? 0) > 0) {
91
89
  ineligible.set(
92
90
  n.id,
93
- 'declares cache.outputs.workspaceFiles — root-anchored outputs can land where any project-relative input reads',
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 !== undefined && cfg.exec?.persistent === undefined) unsaved++
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, CacheConfig, InputStability } from '../config.js'
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 `inputStability` layers around the task's own: `run` from
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
- /** `cache.outputs.clean` for tasks without their own: `.vx/workspace`. */
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 !== undefined &&
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 `cache.inputStability`,
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
- cache: CacheConfig | undefined,
418
+ inputs: InputsConfig | undefined,
407
419
  layers: ExecuteArgs['inputStability'],
408
420
  ): Required<InputStability> {
409
- const task = cache?.inputStability
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 cacheCfg: CacheConfig | undefined = cfg.cache
533
+ const cfgOut = cfg.outputs
522
534
  const policy = args.cachePolicy ?? FULL_CACHE_POLICY
523
- const cfgCacheable = cacheCfg !== undefined
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 = cacheCfg?.outputs.files ?? []
575
- const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
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
- // `cache.outputs.clean: false` leaves the outputs to the first attempt's
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 && (cacheCfg?.outputs.clean ?? args.cleanOutputs ?? true) === false
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 maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
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.cache, args.inputStability)
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 cache.outputs'
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 inputStability.severity is 'error', so the task fails${hint}`,
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 cacheCfg: CacheConfig | undefined = node.config.cache
62
- const outputs = cacheCfg?.outputs.files ?? []
63
- const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
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.cache?.outputs.files ?? []
403
- if ((node.config.cache?.outputs.workspaceFiles?.length ?? 0) > 0) return undefined
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