@vzn/vx 0.0.639 → 0.0.640

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 (74) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +111 -83
  3. package/docs/cli.md +70 -40
  4. package/docs/execution.md +4 -4
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +235 -180
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/show.json +14 -14
  11. package/skills/vx/SKILL.md +4 -3
  12. package/src/cache/cache.ts +4 -1
  13. package/src/cache/file-hashes.ts +7 -12
  14. package/src/cache/fs-clock.ts +62 -0
  15. package/src/cache/git-inputs.ts +2 -2
  16. package/src/cache/index.ts +3 -0
  17. package/src/cache/inputs.ts +154 -83
  18. package/src/cache/key-fold.ts +23 -3
  19. package/src/cache/layer.ts +8 -7
  20. package/src/cache/linked-inputs.ts +87 -0
  21. package/src/cli/foreign-flags.ts +1 -1
  22. package/src/cli/init.ts +23 -11
  23. package/src/cli/last.ts +24 -1
  24. package/src/cli/plugin-templates.ts +2 -2
  25. package/src/cli/select.ts +16 -6
  26. package/src/cli/show.ts +9 -6
  27. package/src/cli/upgrade.ts +95 -19
  28. package/src/cli/watch-filter.ts +22 -6
  29. package/src/cli/watch-judge.ts +49 -5
  30. package/src/cli/watch-set.ts +18 -8
  31. package/src/cli/watch.ts +74 -2
  32. package/src/cli/why.ts +5 -3
  33. package/src/config.ts +19 -15
  34. package/src/exec/executor.ts +4 -4
  35. package/src/graph/dependency-spec.ts +2 -2
  36. package/src/graph/folds.ts +236 -0
  37. package/src/graph/index.ts +8 -1
  38. package/src/graph/scheduler.ts +41 -15
  39. package/src/graph/task-graph.ts +135 -73
  40. package/src/index.ts +1 -1
  41. package/src/orchestrator/affected-tasks.ts +20 -19
  42. package/src/orchestrator/download-policy.ts +16 -22
  43. package/src/orchestrator/excluded-keys.ts +4 -5
  44. package/src/orchestrator/execute-task.ts +29 -25
  45. package/src/orchestrator/index.ts +1 -0
  46. package/src/orchestrator/keyed-projects.ts +12 -51
  47. package/src/orchestrator/local-shortcircuit.ts +1 -1
  48. package/src/orchestrator/persistent-task.ts +3 -3
  49. package/src/orchestrator/placement.ts +2 -2
  50. package/src/orchestrator/plan.ts +1 -1
  51. package/src/orchestrator/plugin-host.ts +62 -22
  52. package/src/orchestrator/plugin.ts +12 -2
  53. package/src/orchestrator/prepare.ts +12 -5
  54. package/src/orchestrator/projects.ts +7 -10
  55. package/src/orchestrator/run-report.ts +124 -46
  56. package/src/orchestrator/run-steps.ts +57 -0
  57. package/src/orchestrator/run.ts +51 -21
  58. package/src/orchestrator/sandbox-fix.ts +1 -1
  59. package/src/orchestrator/sandbox-request.ts +2 -2
  60. package/src/orchestrator/stable-keys.ts +59 -26
  61. package/src/orchestrator/steps.ts +26 -6
  62. package/src/orchestrator/task-hash.ts +31 -9
  63. package/src/orchestrator/telemetry-host.ts +3 -3
  64. package/src/orchestrator/telemetry.ts +9 -4
  65. package/src/orchestrator/upstream.ts +4 -181
  66. package/src/report/framed-output.ts +1 -1
  67. package/src/report/status-line.ts +7 -3
  68. package/src/settings.ts +11 -17
  69. package/src/util/paths.ts +1 -1
  70. package/src/workspace/affected.ts +81 -2
  71. package/src/workspace/config-imports.ts +1 -1
  72. package/src/workspace/config-schema.ts +142 -143
  73. package/src/workspace/lockfile.ts +1 -1
  74. package/src/workspace/migration.ts +19 -3
@@ -1,6 +1,9 @@
1
1
  // The report every plugin's teardown reads, and the run's own bytes kept
2
2
  // in a remote that takes its writes (owner 2026-10-15): a failed task's
3
- // log and the files its outputs left, an uncached task's log.
3
+ // log and the files its outputs left (`cache.upload.failures`), and the
4
+ // log of a task that saved no entry. Each task's bytes leave when it ends,
5
+ // off its path, like its entry's upload (owner 2026-10-16); the run only
6
+ // waits for them where it waits for every upload.
4
7
 
5
8
  import {
6
9
  packRunOutputs,
@@ -14,17 +17,27 @@ import { relPosix } from '../util/index.js'
14
17
  import { decodeOutputLog } from './output-log.js'
15
18
  import type { RunReport, RunReportTask } from './plugin.js'
16
19
 
17
- export interface RunReportArgs {
20
+ /** Stores one run entry; resolves whether a remote kept it. */
21
+ export type KeepRunEntry = (name: string, bytes: Uint8Array) => Promise<boolean>
22
+
23
+ export interface RunKeeperArgs {
18
24
  readonly runId: string
19
- readonly commit: string | null
20
- readonly ciRun: CiContext
21
- readonly ok: boolean
22
25
  readonly workspaceRoot: string
23
26
  readonly nestedDirsByProject: ReadonlyMap<string, string[]>
24
- /** Stores one run entry; undefined when no remote takes the run's writes. */
25
- readonly keep: ((name: string, bytes: Uint8Array) => Promise<boolean>) | undefined
27
+ readonly keep: KeepRunEntry
28
+ /** Whether a failed task's log and outputs are kept (`cache.upload.failures`). */
29
+ readonly failures: boolean
30
+ }
31
+
32
+ /** Whether a run on CI or off it keeps failures, by `cache.upload.failures` (default `'ci'`). */
33
+ export function keepsFailures(where: unknown, ci: boolean): boolean {
34
+ const at = where ?? 'ci'
35
+ return at === 'always' || (at === 'ci') === ci
26
36
  }
27
37
 
38
+ /** Concurrent run-entry writes; more queue. */
39
+ const KEEP_CONCURRENCY = 8
40
+
28
41
  /** The files `node`'s declared outputs match on disk now, workspace-relative and sorted. */
29
42
  async function outputsLeft(
30
43
  node: TaskNode,
@@ -40,52 +53,117 @@ async function outputsLeft(
40
53
  return [...new Set([...own, ...ws].map((p) => relPosix(workspaceRoot, p)))].sort()
41
54
  }
42
55
 
43
- export async function keepRunBytes(
44
- final: readonly TaskOutcome[],
45
- args: RunReportArgs,
46
- ): Promise<RunReport> {
47
- const { keep, runId, workspaceRoot } = args
48
- const tasks: RunReportTask[] = []
49
- const work: (() => Promise<void>)[] = []
50
- for (const o of final) {
51
- if (isGroupTask(o.node)) continue
52
- const task: { -readonly [K in keyof RunReportTask]: RunReportTask[K] } = {
53
- id: o.node.id,
54
- status: o.status,
55
- durationMs: Math.round(o.durationMs),
56
- ...(o.hash !== undefined ? { key: o.hash } : {}),
56
+ /** Keeps each task's run bytes as it ends, and names them in the run's report. */
57
+ export class RunKeeper {
58
+ private readonly kept = new Map<string, { log?: string; outputs?: string }>()
59
+ private readonly queue: (() => Promise<void>)[] = []
60
+ private readonly pending = new Set<Promise<void>>()
61
+ private active = 0
62
+ private stopped = false
63
+
64
+ constructor(private readonly args: RunKeeperArgs) {}
65
+
66
+ /** A task ended: queue what of it the run keeps. */
67
+ taskDone(o: TaskOutcome): void {
68
+ if (this.stopped || isGroupTask(o.node)) return
69
+ const { runId, workspaceRoot } = this.args
70
+ const id = o.node.id
71
+ const slot = (): { log?: string; outputs?: string } => {
72
+ let s = this.kept.get(id)
73
+ if (s === undefined) this.kept.set(id, (s = {}))
74
+ return s
57
75
  }
58
- tasks.push(task)
59
- if (keep === undefined) continue
60
76
  const runLog = o.runLog
61
77
  if (runLog !== undefined) {
62
- work.push(async () => {
63
- const text = decodeOutputLog(runLog)
64
- .map((c) => c.text)
65
- .join('')
66
- if (text.length === 0) return
67
- const name = runEntryName(runId, o.node.id, 'log')
68
- if (await keep(name, new TextEncoder().encode(text))) task.log = name
78
+ const text = decodeOutputLog(runLog)
79
+ .map((c) => c.text)
80
+ .join('')
81
+ if (text.length > 0) {
82
+ const name = runEntryName(runId, id, 'log')
83
+ const bytes = new TextEncoder().encode(text)
84
+ this.enqueue(async () => {
85
+ if (await this.args.keep(name, bytes)) slot().log = name
86
+ })
87
+ }
88
+ }
89
+ if (this.args.failures && o.status === 'failed' && o.where === undefined) {
90
+ // Read now: a later task may write where this one's outputs lie.
91
+ const packed = outputsLeft(
92
+ o.node,
93
+ workspaceRoot,
94
+ this.args.nestedDirsByProject.get(o.node.projectName) ?? [],
95
+ ).then((paths) => packRunOutputs(workspaceRoot, paths))
96
+ const name = runEntryName(runId, id, 'outputs')
97
+ this.enqueue(async () => {
98
+ const bytes = await packed
99
+ if (bytes !== null && (await this.args.keep(name, bytes))) slot().outputs = name
69
100
  })
70
101
  }
71
- if (o.status === 'failed' && o.where === undefined) {
72
- work.push(async () => {
73
- const bytes = await packRunOutputs(
74
- workspaceRoot,
75
- await outputsLeft(
76
- o.node,
77
- workspaceRoot,
78
- args.nestedDirsByProject.get(o.node.projectName) ?? [],
79
- ),
80
- )
81
- if (bytes === null) return
82
- const name = runEntryName(runId, o.node.id, 'outputs')
83
- if (await keep(name, bytes)) task.outputs = name
102
+ }
103
+
104
+ /** Resolves once every queued write has settled, or the run was stopped. */
105
+ async settled(): Promise<void> {
106
+ while (!this.stopped && this.pending.size > 0) await Promise.all(this.pending)
107
+ }
108
+
109
+ /** A stopped run starts no more writes and stops waiting on those in flight. */
110
+ stop(): void {
111
+ this.stopped = true
112
+ this.queue.length = 0
113
+ }
114
+
115
+ /** What was kept of each task, by id, for `reportOf`. */
116
+ keptOf(id: string): { log?: string; outputs?: string } | undefined {
117
+ return this.kept.get(id)
118
+ }
119
+
120
+ private enqueue(job: () => Promise<void>): void {
121
+ let done!: () => void
122
+ const p = new Promise<void>((resolve) => (done = resolve))
123
+ this.pending.add(p)
124
+ this.queue.push(async () => {
125
+ try {
126
+ if (!this.stopped) await job()
127
+ } catch {
128
+ // a record, never the run's verdict
129
+ } finally {
130
+ this.pending.delete(p)
131
+ done()
132
+ }
133
+ })
134
+ this.pump()
135
+ }
136
+
137
+ private pump(): void {
138
+ while (this.active < KEEP_CONCURRENCY && this.queue.length > 0) {
139
+ const job = this.queue.shift()!
140
+ this.active++
141
+ void job().finally(() => {
142
+ this.active--
143
+ this.pump()
84
144
  })
85
145
  }
86
146
  }
87
- for (let i = 0; i < work.length; i += 8) {
88
- await Promise.all(work.slice(i, i + 8).map((w) => w()))
147
+ }
148
+
149
+ /** The report: every task, with the names of what `keeper` kept of it. */
150
+ export function reportOf(
151
+ final: readonly TaskOutcome[],
152
+ run: { runId: string; commit: string | null; ciRun: CiContext; ok: boolean },
153
+ keeper: RunKeeper | undefined,
154
+ ): RunReport {
155
+ const tasks: RunReportTask[] = []
156
+ for (const o of final) {
157
+ if (isGroupTask(o.node)) continue
158
+ const kept = keeper?.keptOf(o.node.id)
159
+ tasks.push({
160
+ id: o.node.id,
161
+ status: o.status,
162
+ durationMs: Math.round(o.durationMs),
163
+ ...(o.hash !== undefined ? { key: o.hash } : {}),
164
+ ...(kept?.log !== undefined ? { log: kept.log } : {}),
165
+ ...(kept?.outputs !== undefined ? { outputs: kept.outputs } : {}),
166
+ })
89
167
  }
90
- return { runId, commit: args.commit, ciRun: args.ciRun, ok: args.ok, tasks }
168
+ return { runId: run.runId, commit: run.commit, ciRun: run.ciRun, ok: run.ok, tasks }
91
169
  }
@@ -0,0 +1,57 @@
1
+ // The steps a run did not wait on (`vx:prefetch`), for `vx last` (M,
2
+ // 2026-10-16): they never show in a run's output. One file per run that
3
+ // had one, `<cacheDir>/steps/<runId>.json`, the newest 50 kept, as
4
+ // run-failures.ts keeps failed output: files, not cache.db, so the index
5
+ // layout stays as it was. A run with none writes nothing.
6
+
7
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
8
+ import path from 'node:path'
9
+ import { isFsRefusal } from '../util/index.js'
10
+
11
+ /** Runs whose background steps are kept; run ids are UUIDv7, so names sort by age. */
12
+ const KEPT = 50
13
+
14
+ /** One of vx's own steps that ran beside the tasks. */
15
+ export interface RunStep {
16
+ id: string
17
+ durationMs: number
18
+ ok: boolean
19
+ }
20
+
21
+ const dirOf = (cacheDir: string): string => path.join(cacheDir, 'steps')
22
+
23
+ /** Keep `steps` for `runId`; a refused write costs only them. */
24
+ export function writeRunSteps(
25
+ cacheDir: string,
26
+ runId: string,
27
+ steps: readonly RunStep[],
28
+ warn: (message: string) => void,
29
+ ): void {
30
+ if (steps.length === 0) return
31
+ try {
32
+ const dir = dirOf(cacheDir)
33
+ mkdirSync(dir, { recursive: true })
34
+ const file = path.join(dir, `${runId}.json`)
35
+ const tmp = `${file}.${process.pid}.tmp`
36
+ writeFileSync(tmp, JSON.stringify(steps))
37
+ renameSync(tmp, file)
38
+ const names = readdirSync(dir)
39
+ .filter((n) => n.endsWith('.json'))
40
+ .sort()
41
+ for (const n of names.slice(0, -KEPT)) rmSync(path.join(dir, n), { force: true })
42
+ } catch (err) {
43
+ if (!isFsRefusal(err)) throw err
44
+ warn(`[vx] background steps not kept: ${err.message} — the verdict above stands`)
45
+ }
46
+ }
47
+
48
+ /** The background steps `runId` kept; none when it had none or they are gone. */
49
+ export function runSteps(cacheDir: string, runId: string): RunStep[] {
50
+ try {
51
+ return JSON.parse(
52
+ readFileSync(path.join(dirOf(cacheDir), `${runId}.json`), 'utf8'),
53
+ ) as RunStep[]
54
+ } catch {
55
+ return []
56
+ }
57
+ }
@@ -76,9 +76,10 @@ import {
76
76
  warnOf,
77
77
  } from '../report/index.js'
78
78
  import { installPlugins, PluginSetupError, type RunReport } from './plugin.js'
79
- import { keepRunBytes } from './run-report.js'
79
+ import { keepsFailures, reportOf, RunKeeper } from './run-report.js'
80
80
  import { buildAdmission, executorDemand, resolveExecutors, teardownPlugins } from './plugin-host.js'
81
81
  import { createSteps } from './steps.js'
82
+ import { writeRunSteps } from './run-steps.js'
82
83
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
83
84
  import { claimOutput, guardedOutput } from './output-host.js'
84
85
  import { assembleRunSummary } from './telemetry.js'
@@ -358,7 +359,8 @@ async function runOnBus(
358
359
  takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
359
360
  ): Promise<RunSummary> {
360
361
  const log = busLogger(bus)
361
- const { step, stepSync } = createSteps(log, showsSteps)
362
+ const steps = createSteps(log, showsSteps)
363
+ const { step, stepSync } = steps
362
364
  // A server's output goes through a cell of its own: a later watch cycle
363
365
  // that keeps the server points it at that cycle's bus (`RunOptions.keep`).
364
366
  const serverOuts = new Map<string, { bus: EventBus }>()
@@ -450,7 +452,25 @@ async function runOnBus(
450
452
  const runId = ulid()
451
453
  const head = captureGitContext(prepared.workspaceRoot)
452
454
  const ciContext = detectCi(process.env)
453
- const keepRunLog = prepared.keepRunEntry !== undefined
455
+ const keepRun =
456
+ prepared.keepRunEntry === undefined
457
+ ? undefined
458
+ : {
459
+ failures: keepsFailures(
460
+ resolveSetting('cache.upload.failures', prepared.workspaceConfig).value,
461
+ ciContext.ci,
462
+ ),
463
+ }
464
+ const keeper =
465
+ prepared.keepRunEntry === undefined || keepRun === undefined
466
+ ? undefined
467
+ : new RunKeeper({
468
+ runId,
469
+ workspaceRoot: prepared.workspaceRoot,
470
+ nestedDirsByProject: prepared.nestedDirsByProject,
471
+ keep: prepared.keepRunEntry,
472
+ failures: keepRun.failures,
473
+ })
454
474
  try {
455
475
  disposePlugins = await installPlugins({
456
476
  plugins: prepared.plugins,
@@ -579,6 +599,7 @@ async function runOnBus(
579
599
  onStop: () => {
580
600
  stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
581
601
  cache.stopUploads?.()
602
+ keeper?.stop()
582
603
  },
583
604
  })
584
605
  const { liveChildren, persistentRegistry } = stop
@@ -823,7 +844,7 @@ async function runOnBus(
823
844
  // unchanged (no upfront key pass, no local probing).
824
845
  let prefetchDone: Promise<void> = Promise.resolve()
825
846
  if (prepared.hasRemoteLayer) {
826
- prefetchDone = step('vx:prefetch', () =>
847
+ prefetchDone = steps.background('vx:prefetch', () =>
827
848
  startRemotePrefetch({
828
849
  nodes,
829
850
  cache,
@@ -938,7 +959,7 @@ async function runOnBus(
938
959
  if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
939
960
  if (telemetry?.track !== undefined) scope.track = telemetry.track
940
961
  if (explainMiss !== undefined) scope.explainMiss = explainMiss
941
- if (keepRunLog) scope.keepRunLog = true
962
+ if (keepRun !== undefined) scope.keepRun = keepRun
942
963
  if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
943
964
  const argsOf = executeArgsOf(scope, {
944
965
  log: (node) => (node.config.exec?.persistent !== undefined ? serverLog(node.id) : log),
@@ -986,11 +1007,22 @@ async function runOnBus(
986
1007
  mark('classify + probe')
987
1008
  // A run whose every task is a confirmed local hit dispatches only
988
1009
  // restores, which no weight orders: the `schedule` stage is not asked.
1010
+ // A plugin's later weights (`ctx.refine`) re-rank the ready queues
1011
+ // while the graph runs; any that land before it starts, at its start.
1012
+ let reprioritize: ((weights: ReadonlyMap<string, number>) => void) | undefined
1013
+ let refinedEarly: ReadonlyMap<string, number> | undefined
1014
+ let graphOver = false
989
1015
  const priorities = [...nodes.values()].every(
990
1016
  (n) => n.config.exec?.command === undefined || shortCircuit.restoreTier.has(n.id),
991
1017
  )
992
1018
  ? new Map<string, number>()
993
- : await step('vx:schedule', () => prepared.priorities())
1019
+ : await prepared.priorities({
1020
+ step,
1021
+ refined: (merged) => {
1022
+ if (reprioritize !== undefined) reprioritize(merged)
1023
+ else if (!graphOver) refinedEarly = merged
1024
+ },
1025
+ })
994
1026
  mark('schedule')
995
1027
  // Judged as each task finishes, against the history BEFORE this run's
996
1028
  // rows land, so the row that prints next carries the verdict: nothing
@@ -1047,6 +1079,7 @@ async function runOnBus(
1047
1079
  if (cacheOff) o.cacheOff = true
1048
1080
  taint.settled(o)
1049
1081
  judgeFlaky(o)
1082
+ keeper?.taskDone(o)
1050
1083
  log.taskComplete(o.node, o)
1051
1084
  narrowDemand(o.node.id)
1052
1085
  },
@@ -1070,7 +1103,7 @@ async function runOnBus(
1070
1103
  ...(options.continueMode !== 'always' && taintSeeds.size === 0
1071
1104
  ? {
1072
1105
  settleNow: (node: TaskNode, upstream: TaskOutcome[]) =>
1073
- isGroupTask(node) && node.config.cache === undefined
1106
+ isGroupTask(node) && node.config.inputs === undefined
1074
1107
  ? unkeyedGroupOutcome(
1075
1108
  node,
1076
1109
  keyUpstream(node, upstream),
@@ -1083,6 +1116,10 @@ async function runOnBus(
1083
1116
  // A `schedule` plugin's weights; the scheduler keeps its structural
1084
1117
  // baseline as the tie-break. Empty map → baseline only.
1085
1118
  ...(priorities.size > 0 ? { priorities } : {}),
1119
+ reprioritize: (apply) => {
1120
+ if (refinedEarly !== undefined) apply(refinedEarly)
1121
+ reprioritize = apply
1122
+ },
1086
1123
  // The baseline under them; `most-work` when the workspace names none.
1087
1124
  ...(options.schedule !== undefined ? { strategy: options.schedule } : {}),
1088
1125
  // Local short-circuit: confirmed stable local hits the scheduler
@@ -1091,6 +1128,8 @@ async function runOnBus(
1091
1128
  restoreTier: shortCircuit.restoreTier,
1092
1129
  })
1093
1130
  stop.graphDone()
1131
+ reprioritize = undefined
1132
+ graphOver = true
1094
1133
 
1095
1134
  // Which persistent children outlive the graph, and the bounded SIGTERM
1096
1135
  // of the rest, before the summary prints. Scoped to the real CLI
@@ -1250,22 +1289,13 @@ async function runOnBus(
1250
1289
  // nothing the suite could see, because this gate is the rule.
1251
1290
  await step('vx:uploads', async () => {
1252
1291
  await prefetchDone
1253
- await cache.drainUploads?.()
1292
+ await Promise.all([cache.drainUploads?.(), keeper?.settled()])
1254
1293
  })
1294
+ writeRunSteps(prepared.cacheDir, runId, steps.ended, warnOf(log))
1255
1295
  // An upload that failed after the rows went in: its row says so too.
1256
1296
  if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
1257
1297
  recordHistory(() => recordLateRemoteErrors(prepared.localCache, records.runs, runId))
1258
- const report = await step('vx:keep-run', () =>
1259
- keepRunBytes(list, {
1260
- runId,
1261
- commit: head.commitSha,
1262
- ciRun: ciContext,
1263
- ok,
1264
- workspaceRoot: prepared.workspaceRoot,
1265
- nestedDirsByProject: prepared.nestedDirsByProject,
1266
- keep: prepared.keepRunEntry,
1267
- }),
1268
- )
1298
+ const report = reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper)
1269
1299
  // Hand the per-run summary to the telemetry sinks + drain them. Only
1270
1300
  // when a sink is active (telemetry !== undefined) — otherwise this
1271
1301
  // whole block is skipped and the run is byte-identical to before.
@@ -1285,7 +1315,7 @@ async function runOnBus(
1285
1315
  })
1286
1316
  telemetry.emitSummary(summary)
1287
1317
  const sinks = telemetry
1288
- await step('vx:telemetry', () => sinks.flush())
1318
+ await sinks.flush(step)
1289
1319
  }
1290
1320
  // End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
1291
1321
  // + time-bounded inside teardownPlugins, so a faulty plugin can neither
@@ -1477,7 +1507,7 @@ function noneAffected(prepared: PreparedRun, options: RunOptions): string {
1477
1507
  * task. Used by `--dry`.
1478
1508
  *
1479
1509
  * Side-effects are limited to opening + closing the local Cache handle
1480
- * (and running `cache.inputs.runtime` probe commands, which key
1510
+ * (and running `inputs.runtime` probe commands, which key
1481
1511
  * derivation requires). Cache probing is the byte-free `cache.has()`
1482
1512
  * existence check — no artifact download, no ingest, no accessed_at bump.
1483
1513
  */
@@ -85,7 +85,7 @@ export function sandboxFix(
85
85
  projectDir: string,
86
86
  mask: (text: string) => string = (t) => t,
87
87
  /**
88
- * The task's `cache.inputs.files`, undefined when it does not cache: a
88
+ * The task's `inputs.files`, undefined when it does not cache: a
89
89
  * write under one is granted exactly, and a read of the project no glob
90
90
  * matches is named for the key too.
91
91
  */
@@ -163,7 +163,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
163
163
  * cached path (through the executor) and the persistent path (spawned here).
164
164
  *
165
165
  * The user's grants derive NOTHING from `cache` (owner, 2026-09-05). Those
166
- * are two different questions: `cache.inputs` says what INVALIDATES the
166
+ * are two different questions: `inputs` says what INVALIDATES the
167
167
  * task, `sandbox.allow` says what it may TOUCH. Deriving one from the other
168
168
  * coupled them in both directions — a declaration added for caching
169
169
  * silently widened the sandbox, and a path the task needed had to be
@@ -476,7 +476,7 @@ export function withheldLinkLine(taskId: string, w: WithheldLink): string {
476
476
  `${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
477
477
  `reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
478
478
  `\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
479
- `\`cache.inputs.workspaceFiles\`).`
479
+ `\`inputs.workspaceFiles\`).`
480
480
  )
481
481
  }
482
482
 
@@ -5,13 +5,19 @@
5
5
  // out so the two callers can never drift on the stability gate.
6
6
  //
7
7
  // A task's key is "stable" only if its inputs can't be altered by an
8
- // upstream's outputs (a `cache.inputs.files` glob that could match a
8
+ // upstream's outputs (a `inputs.files` glob that could match a
9
9
  // sibling's declared output has a PRELIMINARY key until that upstream
10
10
  // runs — probing it would target the wrong artifact). Unstable tasks
11
11
  // fall back to lazy read-through in execute-task, which is always
12
12
  // correct. The check is conservative: when unsure, treat as unstable.
13
13
 
14
- import { isGroupTask, outputTakenBack, type TaskNode, type TaskOutcome } from '../graph/index.js'
14
+ import {
15
+ foldedDeps,
16
+ isGroupTask,
17
+ outputTakenBack,
18
+ type TaskNode,
19
+ type TaskOutcome,
20
+ } from '../graph/index.js'
15
21
  import type { CacheLayer, GitFilesCache } from '../cache/index.js'
16
22
  import {
17
23
  checkIgnore,
@@ -23,7 +29,6 @@ import {
23
29
  wholeIgnored,
24
30
  } from '../util/index.js'
25
31
  import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
26
- import { foldedDeps } from './keyed-projects.js'
27
32
  import {
28
33
  computeGroupHash,
29
34
  computeTaskHash,
@@ -129,12 +134,12 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
129
134
  const unfoldedById = new Map<string, ProjectSet>()
130
135
  const wsRewriters = new Set<string>()
131
136
  // A cached rewriter matters only to a key that leaves a dependency's key
132
- // out: a `cache.inputs.tasks` filter. With none in the graph every edge
137
+ // out: a `inputs.tasks` filter. With none in the graph every edge
133
138
  // folds and the sets above stay empty, so they are not built (about 2 ms,
134
139
  // median, of a 27 ms memoised walk over the 3,000-task bench; item 750).
135
140
  let anyUnfolded = false
136
141
  for (const n of args.nodes.values()) {
137
- if (n.config.cache?.inputs?.tasks !== undefined) {
142
+ if (n.config.inputs?.tasks !== undefined) {
138
143
  anyUnfolded = true
139
144
  break
140
145
  }
@@ -241,7 +246,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
241
246
  // cascade (and forward their producers, above). They inherit
242
247
  // instability from any unstable member.
243
248
  // `computeGroupKey`, with the unkeyed branch kept synchronous.
244
- if (node.config.cache === undefined) {
249
+ if (node.config.inputs === undefined) {
245
250
  const key = computeGroupHash(upstream)
246
251
  keyById.set(id, key)
247
252
  args.groupKeys?.set(id, { key, upstream })
@@ -293,9 +298,8 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
293
298
 
294
299
  const cacheEnabled = node.config.cache !== undefined
295
300
  if (cacheEnabled && !unstable) stableKeys.push({ hash, node })
296
- // An uncached key reads its whole project, so `dependsOnSiblingOutputs`
297
- // (which answers false for it) is not the gate: any upstream that may
298
- // write into this project, or anywhere, leaves the key preliminary.
301
+ // An uncached key stays preliminary behind any upstream that may write
302
+ // into this project, or anywhere: none of its writes is declared.
299
303
  else if (
300
304
  !cacheEnabled &&
301
305
  !unstable &&
@@ -332,7 +336,7 @@ export function gatedOutputs(
332
336
  }
333
337
  if (outputsOf.size === 0) return out
334
338
  for (const n of nodes.values()) {
335
- const inputs = n.config.cache?.inputs?.files
339
+ const inputs = n.config.inputs?.files
336
340
  const outputs = outputsOf.get(n.projectName)
337
341
  if (inputs === undefined || outputs === undefined || isGroupTask(n)) continue
338
342
  let back: string[] | undefined
@@ -396,7 +400,7 @@ function readableOutputs(
396
400
  const probesAfterWritesOf = new WeakMap<ReadonlyMap<string, TaskNode>, ReadonlySet<string>>()
397
401
 
398
402
  /**
399
- * The cached tasks whose `cache.inputs.runtime` / `workspaceRuntime` probes
403
+ * The cached tasks whose `inputs.runtime` / `workspaceRuntime` probes
400
404
  * may read what an upstream writes this run while the key folds nothing of
401
405
  * that upstream (X-34). A probe is a shell command and may read anywhere,
402
406
  * so no glob bounds it, and its answer taken before the writer ran is the
@@ -424,8 +428,8 @@ export function probesAfterWrites(
424
428
  const late = new Set<string>()
425
429
  probesAfterWritesOf.set(nodes, late)
426
430
  const probes = (n: TaskNode): boolean =>
427
- (n.config.cache?.inputs?.runtime?.length ?? 0) > 0 ||
428
- (n.config.cache?.inputs?.workspaceRuntime?.length ?? 0) > 0
431
+ (n.config.inputs?.runtime?.length ?? 0) > 0 ||
432
+ (n.config.inputs?.workspaceRuntime?.length ?? 0) > 0
429
433
  let anyProbe = false
430
434
  for (const n of nodes.values()) {
431
435
  if (probes(n)) {
@@ -455,7 +459,7 @@ export function probesAfterWrites(
455
459
  let writer = false
456
460
  let unfolded = false
457
461
  // No filter and no order-only edge: the key folds every dependency.
458
- const foldsAll = node.orderOnly === undefined && node.config.cache?.inputs?.tasks === undefined
462
+ const foldsAll = node.orderOnly === undefined && node.config.inputs?.tasks === undefined
459
463
  let folds: Set<string> | undefined
460
464
  for (const dep of node.deps) {
461
465
  if (!nodes.has(dep)) continue
@@ -483,7 +487,7 @@ function foldedBy(
483
487
  upstream: TaskOutcome[],
484
488
  keyById: Map<string, string>,
485
489
  ): (dep: string) => boolean {
486
- const filter = node.config.cache?.inputs?.tasks
490
+ const filter = node.config.inputs?.tasks
487
491
  if (isGroupTask(node) || filter === undefined) return (dep) => keyById.has(dep)
488
492
  const hashes = new Set(
489
493
  filterUpstreamHashes(upstream, filter, node.projectName, node.id).map(([, h]) => h),
@@ -525,7 +529,7 @@ function synthUpstream(
525
529
  * `outputs.files` — direct or transitive — makes the key
526
530
  * preliminary. Project boundaries are hard, so only SAME-project
527
531
  * outputs can reach a project-relative input.
528
- * - `cache.inputs.workspaceFiles` is boundary-free: it can read ANY
532
+ * - `inputs.workspaceFiles` is boundary-free: it can read ANY
529
533
  * project's dir (so any upstream `outputs.files`, in any project,
530
534
  * matters) or a root-anchored location (so any upstream
531
535
  * `outputs.workspaceFiles` matters). Either → preliminary key. This
@@ -551,9 +555,9 @@ export function dependsOnSiblingOutputs(
551
555
  },
552
556
  ): boolean {
553
557
  const cache = node.config.cache
554
- // A cache-disabled task has no key to prefetch anyway; treat as
555
- // unstable-irrelevant (caller filters on cacheEnabled).
556
- if (cache === undefined) return false
558
+ const declared = node.config.inputs
559
+ // A task with no inputs has no key to take up front.
560
+ if (declared === undefined) return false
557
561
  if (upstreamOutputProjects.has(node.projectName)) {
558
562
  // A same-project producer whose writes are all declared reaches this
559
563
  // task's inputs only where the globs can meet: a `test` reading `src/**`
@@ -562,15 +566,21 @@ export function dependsOnSiblingOutputs(
562
566
  // projects (A-20). Undeclared writes, and a rewriter this key does not
563
567
  // fold, may land anywhere: those stay project-wide.
564
568
  if (sameProject === undefined || sameProject.wide?.has(node.projectName) === true) return true
565
- const inputs = cache.inputs?.files
569
+ const inputs = declared.files
566
570
  const outputs = sameProject.outputsOf.get(node.projectName) ?? []
567
- if (inputs === undefined || outputs.length === 0) return true
571
+ if (outputs.length === 0) return true
568
572
  // An output the reader's own `!` inputs take back whole is not in its
569
573
  // key: `['**/*', '!dist/**']` beside a `dist/**` producer keys the same
570
- // before and after it, which is what `run.rules.upfrontKeys` asks a config
571
- // to write (X-54).
574
+ // before and after it, which is what `vx lock` asks a config to write
575
+ // (X-54).
572
576
  // Nor is an output git ignores whole: a key reads only what git lists.
573
- const readable = sameProject.readableOf?.get(node.projectName) ?? outputs
577
+ // Unless an entry starts inside it (`dist/**`): naming an ignored path
578
+ // makes it an input (`ignoredReach` in cache/inputs.ts).
579
+ const readable = withNamedIgnored(
580
+ inputs,
581
+ outputs,
582
+ sameProject.readableOf?.get(node.projectName) ?? outputs,
583
+ )
574
584
  const back = inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
575
585
  const reach = back.length === 0 ? readable : readable.filter((o) => !outputTakenBack(o, back))
576
586
  if (reach.length > 0 && workspaceInputsReach(inputs, reach.map(literalPrefix))) return true
@@ -578,7 +588,7 @@ export function dependsOnSiblingOutputs(
578
588
  // that producer, its restore raced the producer's clean and extract
579
589
  // (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
580
590
  // `outputsOf` holds this task's own globs too; set them aside once.
581
- const own = cache.outputs.files
591
+ const own = cache?.outputs.files ?? []
582
592
  if (own.length > 0) {
583
593
  const others = [...outputs]
584
594
  for (const g of own) {
@@ -597,7 +607,7 @@ export function dependsOnSiblingOutputs(
597
607
  // `app` read `gen/**` project-relative), so the mere presence of a
598
608
  // workspace-output producer upstream makes the key preliminary.
599
609
  if (hasWsOutputUpstream) return true
600
- const wsInputs = cache.inputs?.workspaceFiles ?? []
610
+ const wsInputs = declared.workspaceFiles ?? []
601
611
  if (wsInputs.length > 0 && upstreamOutputProjects.size > 0) {
602
612
  // Without the dirs the old answer stands: a workspace reader over any
603
613
  // producer is preliminary. With them, only a reader whose globs can
@@ -617,6 +627,29 @@ export function dependsOnSiblingOutputs(
617
627
  return false
618
628
  }
619
629
 
630
+ /**
631
+ * `readable`, plus each output git ignores whole that a positive entry of
632
+ * `inputs` starts inside: its literal prefix at or below the output's.
633
+ */
634
+ function withNamedIgnored(
635
+ inputs: readonly string[],
636
+ outputs: readonly string[],
637
+ readable: readonly string[],
638
+ ): readonly string[] {
639
+ if (readable.length === outputs.length) return readable
640
+ const named = outputs.filter((o) => {
641
+ if (readable.includes(o)) return false
642
+ const at = literalPrefix(o)
643
+ if (at === '') return false
644
+ return inputs.some((g) => {
645
+ if (g.startsWith('!')) return false
646
+ const p = literalPrefix(g)
647
+ return p === at || p.startsWith(`${at}/`)
648
+ })
649
+ })
650
+ return named.length === 0 ? readable : [...readable, ...named]
651
+ }
652
+
620
653
  /**
621
654
  * Can any positive `workspaceFiles` entry match a path inside one of
622
655
  * `dirs` (workspace-relative project dirs)? An entry's reach is its