@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -2,9 +2,10 @@
2
2
  // uses for predictive priority and `vx info --history` surfaces.
3
3
  //
4
4
  // The data has been in cache.db.runs since schema v11; what's new is
5
- // surfacing it. A single SQL CTE per run pulls the last N rows per
6
- // (project, task) pair; the result becomes a read-only snapshot for
7
- // the run's lifetime. Loaded once at prepareRun; never mutated mid-run.
5
+ // surfacing it. One SQL statement per call pulls the last N executed rows
6
+ // per (project, task) pair out of a rowid-bounded slice of the table; the
7
+ // result becomes a read-only snapshot for the run's lifetime. Loaded once
8
+ // by the `schedule` stage (or `--dry`); never mutated mid-run.
8
9
  //
9
10
  // Two providers:
10
11
  // LocalHistoryProvider — reads cache.db directly (zero-config).
@@ -12,6 +13,9 @@
12
13
  // when such an RPC actually exists.
13
14
 
14
15
  import type { Database } from 'bun:sqlite'
16
+ import { EXECUTED_RUNS_SQL } from '../cache/index.js'
17
+ import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
18
+ import type { FailureMode } from './failure-mode.js'
15
19
 
16
20
  const DEFAULT_RECENT = 50
17
21
 
@@ -27,8 +31,22 @@ export interface TaskHistory {
27
31
  successRate: number
28
32
  /** Cache hit rate over the recent window ([0, 1]). */
29
33
  hitRate: number
30
- /** Failure mode classification. */
31
- failureMode: 'stable' | 'flaky-recoverable' | 'flaky-fatal'
34
+ /** Failure mode classification over the same window (`failureModeOf`,
35
+ * the one rule `vx why`'s all-time query also applies). */
36
+ failureMode: FailureMode
37
+ /**
38
+ * Largest peak RSS (bytes) any successful execution in the window
39
+ * reported; undefined when none did (hits report nothing, and a
40
+ * platform may not expose rusage). The floor a memory reservation
41
+ * learned from history packs on.
42
+ */
43
+ maxPeakRssBytes?: number
44
+ /**
45
+ * The most CPU parallelism any successful execution in the window
46
+ * showed: cpu time over wall time, so 2.0 means two cores busy for the
47
+ * whole task. Undefined when no row carried both numbers.
48
+ */
49
+ maxCpuParallelism?: number
32
50
  }
33
51
 
34
52
  /** Map keyed by `project#task`. */
@@ -52,92 +70,141 @@ export class LocalHistoryProvider implements HistoryProvider {
52
70
  private readonly recent: number = DEFAULT_RECENT,
53
71
  ) {}
54
72
 
73
+ /**
74
+ * The window is the last `recent` INVOCATIONS, read as one rowid slice.
75
+ *
76
+ * `runs` has no (project, task) index: it cost every run a scattered
77
+ * B-tree write per task (2026-09-09, 1,000 warm hits: the record stage
78
+ * 57–79 ms with it, 14–19 ms without) and bought this reader nothing —
79
+ * ranking every pair's rows newest-first (`ROW_NUMBER` over a partition)
80
+ * sorted the whole table either way, 230 ms at 116k rows. Rows are
81
+ * appended in time order, one contiguous block per invocation, so the
82
+ * rows of the newest `recent` invocations are exactly `id >= MIN(id)` of
83
+ * the `recent`-th newest header — a primary-key range, no sort, plain
84
+ * aggregates, and at most one row per pair per invocation, so "the last
85
+ * `recent` invocations" IS the last `recent` runs for a pair that runs
86
+ * every time and whatever fewer it has for one that runs less often. A
87
+ * hint for ordering and prediction, not a ledger; `vx why` / `vx last`
88
+ * read by `run_id` and stay exact.
89
+ */
55
90
  async loadFor(taskIds: readonly string[]): Promise<HistoryTable> {
56
91
  const out = new Map<string, TaskHistory>()
57
92
  if (taskIds.length === 0) return out
58
93
 
59
- // One CTE per call: rank rows per (project, task) descending by
60
- // started_at, keep the top `recent`, aggregate. Cache-hit rows
61
- // (cache_hit = 1) are excluded from the duration percentiles so
62
- // p50/p99 reflect work the runner actually did. successRate +
63
- // hitRate are computed over ALL recent rows.
64
- const sql = `
65
- WITH recent AS (
66
- SELECT
67
- project,
68
- task,
69
- status,
70
- duration_ms,
71
- cache_hit,
72
- ROW_NUMBER() OVER (PARTITION BY project, task ORDER BY started_at DESC) AS rn
73
- FROM runs
74
- WHERE (project || '#' || task) IN (${taskIds.map(() => '?').join(',')})
94
+ const floorRow = this.db
95
+ .query(
96
+ `SELECT MIN(id) AS id FROM runs WHERE run_id =
97
+ (SELECT run_id FROM invocations ORDER BY started_at DESC LIMIT 1 OFFSET ?)`,
75
98
  )
99
+ .get(this.recent - 1) as { id: number | null }
100
+ // Fewer invocations than the window (or none): the whole table is the window.
101
+ const floor = floorRow.id ?? 0
102
+
103
+ // `skipped` rows are excluded: a skip is a task the run never executed,
104
+ // so it belongs in no success/hit RATE and no duration. Percentiles are
105
+ // over the executed-success rows of the slice — work the runner actually
106
+ // did — while the rates count every executed row.
107
+ const sql = `
76
108
  SELECT
77
- project,
78
- task,
109
+ r.project,
110
+ r.task,
79
111
  COUNT(*) AS total,
80
- SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS successes,
81
- SUM(CASE WHEN cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
82
- SUM(CASE WHEN cache_hit IS NULL OR cache_hit = 0 THEN 1 ELSE 0 END) AS executed,
83
- SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures
84
- FROM recent
85
- WHERE rn <= ${this.recent}
86
- GROUP BY project, task
112
+ SUM(CASE WHEN r.status = 'success' THEN 1 ELSE 0 END) AS successes,
113
+ SUM(CASE WHEN r.cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
114
+ SUM(CASE WHEN r.status = 'failed' THEN 1 ELSE 0 END) AS failures,
115
+ SUM(CASE WHEN r.attempts > 1 THEN 1 ELSE 0 END) AS retried,
116
+ GROUP_CONCAT(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
117
+ THEN r.duration_ms END) AS ds,
118
+ MAX(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
119
+ THEN r.peak_rss_bytes
120
+ WHEN r.cache_hit = 1 THEN e.peak_rss_bytes END) AS rss,
121
+ MAX(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
122
+ AND r.cpu_ms IS NOT NULL AND r.duration_ms > 0
123
+ THEN r.cpu_ms * 1.0 / r.duration_ms
124
+ WHEN r.cache_hit = 1 AND e.cpu_ms IS NOT NULL AND e.duration_ms > 0
125
+ THEN e.cpu_ms * 1.0 / e.duration_ms END) AS cpu
126
+ FROM runs r
127
+ -- A hit executed nothing here, but its entry carries what the
128
+ -- PRODUCING execution used (the artifact's sidecar, indexed at save
129
+ -- and ingest alike), so a task this machine has only ever restored
130
+ -- — a fresh runner behind a remote cache — still has a number. The
131
+ -- join is by primary key and only for hit rows; an entry pruned
132
+ -- since contributes nothing.
133
+ LEFT JOIN entries e ON r.cache_hit = 1 AND e.hash = r.hash
134
+ WHERE r.id >= ? AND ${EXECUTED_RUNS_SQL}
135
+ GROUP BY r.project, r.task
87
136
  `
88
-
89
137
  type Row = {
90
138
  project: string
91
139
  task: string
92
140
  total: number
93
141
  successes: number
94
142
  hits: number
95
- executed: number
96
143
  failures: number
144
+ retried: number
145
+ ds: string | null
146
+ rss: number | null
147
+ cpu: number | null
97
148
  }
98
- const rows = this.db.query(sql).all(...(taskIds as string[])) as Row[]
149
+ // Every pair in the slice is grouped (the slice is bounded, so this is
150
+ // cheap) and the asked-for ones are picked out here — a per-row IN-list
151
+ // probe in SQL cost more than the grouping it would have saved.
152
+ const wanted = new Set(taskIds)
153
+ const rows = (this.db.query(sql).all(floor) as Row[]).filter((r) =>
154
+ wanted.has(`${r.project}#${r.task}`),
155
+ )
156
+
157
+ // The flakiness signal needs per-key outcomes, which the aggregate above
158
+ // folded away — one more pass over the slice, only for the pairs whose
159
+ // verdict actually depends on it (failed, and no retry already proving
160
+ // nondeterminism); a green history never pays for it.
161
+ const mixed = this.mixedOutcomeKeys(
162
+ floor,
163
+ rows.filter((r) => (r.failures || 0) > 0 && (r.retried || 0) === 0),
164
+ )
99
165
 
100
166
  for (const row of rows) {
101
167
  const key = `${row.project}#${row.task}`
102
168
  const total = row.total || 0
103
- const failures = row.failures || 0
104
- const failureMode: TaskHistory['failureMode'] =
105
- failures === 0 ? 'stable' : failures < total / 5 ? 'flaky-recoverable' : 'flaky-fatal'
106
- const { p50, p99 } = this.percentilesFor(row.project, row.task)
169
+ const counts = { total, failures: row.failures || 0, retried: row.retried || 0 }
170
+ const durations = row.ds === null ? undefined : row.ds.split(',').map(Number)
171
+ if (durations !== undefined) durations.sort((a, b) => a - b)
107
172
  out.set(key, {
108
173
  runs: total,
109
- p50DurationMs: p50,
110
- p99DurationMs: p99,
174
+ p50DurationMs: durations ? pickPercentile(durations, 0.5) : undefined,
175
+ p99DurationMs: durations ? pickPercentile(durations, 0.99) : undefined,
111
176
  successRate: total > 0 ? (row.successes || 0) / total : 0,
112
177
  hitRate: total > 0 ? (row.hits || 0) / total : 0,
113
- failureMode,
178
+ failureMode: failureModeOf(counts, () => mixed.get(key) ?? 0),
179
+ ...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
180
+ ...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
114
181
  })
115
182
  }
116
183
  return out
117
184
  }
118
185
 
119
- // Percentiles need a row-wise read; do it in JS rather than try to
120
- // express the percentile_cont equivalent in SQLite. Tiny rows.
121
- private percentilesFor(
122
- project: string,
123
- task: string,
124
- ): { p50: number | undefined; p99: number | undefined } {
186
+ /** Mixed-outcome key count per `project#task`, over the slice, for `pairs` only. */
187
+ private mixedOutcomeKeys(
188
+ floor: number,
189
+ pairs: readonly { project: string; task: string }[],
190
+ ): Map<string, number> {
191
+ const out = new Map<string, number>()
192
+ if (pairs.length === 0) return out
193
+ const placeholders = pairs.map(() => '(?,?)').join(',')
125
194
  const rows = this.db
126
195
  .query(
127
- `SELECT duration_ms FROM runs
128
- WHERE project = ? AND task = ?
129
- AND (cache_hit IS NULL OR cache_hit = 0)
130
- AND status = 'success'
131
- ORDER BY started_at DESC
132
- LIMIT ?`,
196
+ `SELECT project, task, COUNT(*) AS n FROM (${mixedOutcomeKeysSql(
197
+ 'runs',
198
+ ` AND id >= ? AND (project, task) IN (VALUES ${placeholders})`,
199
+ )}) GROUP BY project, task`,
133
200
  )
134
- .all(project, task, this.recent) as { duration_ms: number }[]
135
- if (rows.length === 0) return { p50: undefined, p99: undefined }
136
- const durations = rows.map((r) => r.duration_ms).sort((a, b) => a - b)
137
- return {
138
- p50: pickPercentile(durations, 0.5),
139
- p99: pickPercentile(durations, 0.99),
140
- }
201
+ .all(floor, ...pairs.flatMap((p) => [p.project, p.task])) as {
202
+ project: string
203
+ task: string
204
+ n: number
205
+ }[]
206
+ for (const r of rows) out.set(`${r.project}#${r.task}`, r.n)
207
+ return out
141
208
  }
142
209
  }
143
210
 
@@ -0,0 +1,221 @@
1
+ // What a HIT leaves behind: the outputs of a confirmed cache entry
2
+ // materialised in the project — or proved already current and left alone —
3
+ // the exact changed paths marked so a same-project consumer need not
4
+ // re-spawn git, the stored stdout replayed, and the cache-hit outcome
5
+ // built. There is ONE restore path: `executeCachedTask` and the local
6
+ // short-circuit (a stable hit restored ahead of its deps) both come here,
7
+ // so the two cannot drift. Stale-hit-critical: a line moved here changes
8
+ // which bytes a green run replays. Moved out of execute-task.ts
9
+ // 2026-09-10 as pure code motion, the mirror of miss-save.ts.
10
+
11
+ import path from 'node:path'
12
+ import type { CacheConfig } from '../config.js'
13
+ import {
14
+ type CacheEntry,
15
+ cleanOutputs,
16
+ cleanWorkspaceOutputs,
17
+ resolveOutputs,
18
+ resolveWorkspaceOutputs,
19
+ WORKSPACE_OUTPUT_PREFIX,
20
+ } from '../cache/index.js'
21
+ import type { TaskOutcome } from '../graph/index.js'
22
+ import { span, wholeSubtreePrefixes } from '../util/index.js'
23
+ import type { ExecuteArgs } from './execute-task.js'
24
+
25
+ export interface RestoreHitArgs {
26
+ args: ExecuteArgs
27
+ hash: string
28
+ hit: CacheEntry
29
+ /** `performance.now()` when the cache op (probe) started — the
30
+ * user-perceived restore duration is measured from here. */
31
+ cacheOpStart: number
32
+ /** ns offset from run start for the outcome's wallclock window. */
33
+ taskStartNs: bigint
34
+ }
35
+
36
+ /**
37
+ * Materialize a confirmed cache hit: decide skip-restore, clean +
38
+ * restore declared outputs, mark the exact changed paths so downstream
39
+ * same-project tasks needn't re-spawn git, replay stored stdout, and
40
+ * build the cache-hit `TaskOutcome`. Extracted from `executeCachedTask`
41
+ * so the local short-circuit can restore a stable-key hit ahead of the
42
+ * schedule using the IDENTICAL logic — there is one restore path, not
43
+ * two that could drift.
44
+ */
45
+ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome> {
46
+ const { args, hash, hit, cacheOpStart, taskStartNs } = restore
47
+ const { node, log } = args
48
+ const cacheCfg: CacheConfig | undefined = node.config.cache
49
+ const outputs = cacheCfg?.outputs.files ?? []
50
+ const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
51
+ const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
52
+ const dirPrefixes = wholeSubtreePrefixes(outputs)
53
+ const cleanArgs = {
54
+ projectDir: node.projectDir,
55
+ outputs,
56
+ nestedProjectDirs: args.nestedProjectDirs,
57
+ }
58
+ const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
59
+
60
+ // "Tree is already current" short-circuit — skip cleanOutputs
61
+ // + restoreOutputs when the on-disk state matches what this
62
+ // entry recorded at save time. Integrity-preserving: we
63
+ // require BOTH that the output-glob walk yields exactly the
64
+ // expected paths (no strays, no missing) AND that every file's
65
+ // (size, mode, mtime) matches the stored fingerprint. Any
66
+ // divergence falls through to a real clean + restore.
67
+ //
68
+ // The output-file fingerprints can't be batch-loaded at
69
+ // prepareRun time — non-leaf task hashes depend on upstream
70
+ // outputs that haven't been written yet, so hashes are
71
+ // necessarily computed mid-run. The short-circuit's batched probe
72
+ // loads the rows with the entry (`hit.outputRows`); only the lazy
73
+ // path pays one SELECT here. Either beats reading the manifest from
74
+ // the tar (decompress + parse) at the same point.
75
+ // A task with no declared outputs has nothing to clean or restore: its
76
+ // stdout replays from the row (`hit.stdout`), and its artifact holds
77
+ // only logs. Extracting it anyway cost an `exists` + a tar read per hit
78
+ // — 1.7 ms each on a four-task real repo whose `link` and `element`
79
+ // tasks declare `outputs: []` (2026-09-10, VX_TIMING).
80
+ let skipRestore = !anyOutputs
81
+ if (anyOutputs) {
82
+ const endRows = span('output rows')
83
+ const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
84
+ endRows()
85
+ if (expected.length > 0) {
86
+ // Two namespaces in the rows: bare rels are project outputs,
87
+ // `workspace-outputs/<rel>` rows anchor at the workspace root.
88
+ const projExpected = expected.filter((e) => !e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
89
+ const wsExpected = expected
90
+ .filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
91
+ .map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
92
+ // The directory short-circuit: for whole-subtree globs, unchanged
93
+ // mtimes on every directory recorded at the last save/restore prove
94
+ // the output SET is unchanged (a file added or removed anywhere the
95
+ // glob could see bumps a recorded directory), so the walk that cost
96
+ // 0.36 ms per warm hit is replaced by a few stats. The per-file
97
+ // fingerprint check below still runs; only the enumeration is skipped.
98
+ let setKnown = false
99
+ if (dirPrefixes !== null && wsOutputs.length === 0 && args.cache.outputDirsCurrent) {
100
+ const endDirs = span('output dirs')
101
+ // Loaded with the entry (batched by getMany, or with the lazy get).
102
+ const dirRows = hit.outputDirRows ?? []
103
+ const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
104
+ setKnown = covers && (await args.cache.outputDirsCurrent!(node.projectDir, dirRows))
105
+ endDirs()
106
+ }
107
+ let actualAbs: string[]
108
+ let actualWsAbs: string[]
109
+ if (setKnown) {
110
+ actualAbs = projExpected.map((e) => path.join(node.projectDir, e.path))
111
+ actualWsAbs = []
112
+ } else {
113
+ const endGlob = span('output glob')
114
+ actualAbs = await resolveOutputs({
115
+ projectDir: node.projectDir,
116
+ outputs,
117
+ nestedProjectDirs: args.nestedProjectDirs,
118
+ })
119
+ actualWsAbs = await resolveWorkspaceOutputs({
120
+ workspaceRoot: args.workspaceRoot,
121
+ outputs: wsOutputs,
122
+ })
123
+ endGlob()
124
+ }
125
+ const setsMatch = (
126
+ actual: readonly string[],
127
+ exp: ReadonlyArray<{ path: string }>,
128
+ ): boolean => {
129
+ const expSet = new Set(exp.map((e) => e.path))
130
+ return actual.length === expSet.size && actual.every((r) => expSet.has(r))
131
+ }
132
+ const actualRels = actualAbs.map((p) =>
133
+ path.relative(node.projectDir, p).split(path.sep).join('/'),
134
+ )
135
+ const actualWsRels = actualWsAbs.map((p) =>
136
+ path.relative(args.workspaceRoot, p).split(path.sep).join('/'),
137
+ )
138
+ if (setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)) {
139
+ const endStat = span('output stat')
140
+ skipRestore =
141
+ (await args.cache.isOutputsCurrent(node.projectDir, projExpected)) &&
142
+ (await args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected))
143
+ endStat()
144
+ // The walk ran and proved the tree current (rows absent, or a
145
+ // directory had moved — a benign touch): snapshot the directories
146
+ // so the next hit skips the walk.
147
+ if (skipRestore && !setKnown && dirPrefixes !== null) {
148
+ await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
149
+ }
150
+ }
151
+ }
152
+ }
153
+ if (!skipRestore) {
154
+ let cleanedRels: string[] = []
155
+ let cleanedWsRels: string[] = []
156
+ if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
157
+ if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
158
+ await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
159
+ // The directory snapshot behind the NEXT hit's skip-restore, taken at
160
+ // run end when the run keeps a list (as the miss path does, see
161
+ // miss-save.ts): the restore renamed into these directories
162
+ // microseconds ago, inside the snapshot's racy window, so a snapshot
163
+ // taken here was refused and the first warm run after every restore
164
+ // walked its output trees — 41 of payload's 45 tasks, 14,430 files,
165
+ // ~75 ms of the no-op's CPU (2026-09-11).
166
+ if (dirPrefixes !== null) {
167
+ if (args.outputDirSnapshots !== undefined) {
168
+ args.outputDirSnapshots.push({ hash, projectDir: node.projectDir, prefixes: dirPrefixes })
169
+ } else {
170
+ await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
171
+ }
172
+ }
173
+ // Restored outputs changed the project's tree — but on this
174
+ // path we know the EXACT changed paths (wiped declared
175
+ // outputs + the artifact's files). Record them instead of
176
+ // dropping the snapshot; downstream same-project tasks only
177
+ // re-spawn git when their input globs can actually see one of
178
+ // these paths. The cache-miss save path keeps the
179
+ // unconditional drop (an executed task may write undeclared
180
+ // files only git can see).
181
+ if (outputs.length > 0) {
182
+ args.gitFilesCache?.markOutputsChanged(node.projectDir, [
183
+ ...cleanedRels,
184
+ ...hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
185
+ ])
186
+ }
187
+ if (wsOutputs.length > 0) {
188
+ args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
189
+ ...cleanedWsRels,
190
+ ...hit.outputFiles
191
+ .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
192
+ .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length)),
193
+ ])
194
+ }
195
+ }
196
+ if (hit.stdout) log.taskStdout(node, hit.stdout)
197
+ const status =
198
+ hit.exitCode !== 0 ? 'failed' : hit.source === 'remote' ? 'cache-hit-remote' : 'cache-hit'
199
+ // `restored` distinguishes "we just wrote files to disk" from
200
+ // "disk already matched the cached snapshot". Drives the
201
+ // "up-to-date" vs "local-cache" / "remote-cache" label in the
202
+ // framed block. Only meaningful when at least one output was
203
+ // declared — no-outputs tasks never materialize anything, so
204
+ // they're vacuously up-to-date.
205
+ const restored = !skipRestore && anyOutputs
206
+ return {
207
+ node,
208
+ status,
209
+ exitCode: hit.exitCode,
210
+ // What THIS run spent: probe + restore. NOT the stored exec time — see
211
+ // `storedDurationMs`, which is what the hit skipped.
212
+ durationMs: Math.round(performance.now() - cacheOpStart),
213
+ storedDurationMs: hit.durationMs,
214
+ ...(hit.cpuMs !== undefined ? { storedCpuMs: hit.cpuMs } : {}),
215
+ ...(hit.peakRssBytes !== undefined ? { storedPeakRssBytes: hit.peakRssBytes } : {}),
216
+ hash,
217
+ restored,
218
+ wallclockStartNs: taskStartNs,
219
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
220
+ }
221
+ }