@vzn/vx 0.0.646 → 0.0.648

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -141,7 +141,7 @@ const keyOf = (c: { project: string; task: string; hash: string }): string =>
141
141
  /**
142
142
  * Which of this run's executed tasks are flaky, judged BEFORE the run's own
143
143
  * rows are recorded: a failure on a key that has passed before, a pass on a
144
- * key that already did so, or a within-run retry. A first pass after only
144
+ * key that already did so, or a pass that took a within-run retry. A first pass after only
145
145
  * failures is a recovery, not a flake. The counts fold this run in.
146
146
  *
147
147
  * The cost follows the run's colour. No candidate (every task a hit or a
@@ -187,7 +187,10 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
187
187
  for (const c of suspects) {
188
188
  const before = counts.get(keyOf(c)) ?? { passes: 0, failures: 0, relapses: 0 }
189
189
  const relapsed = c.status === 'failed' ? before.passes > 0 : before.relapses > 0
190
- if (!relapsed && c.attempts <= 1) continue
190
+ // A retry proves nothing until one attempt passes: every attempt
191
+ // failing on a key that never passed is a break, as `flakyTasks` reads it.
192
+ const retriedPass = c.status === 'success' && c.attempts > 1
193
+ if (!relapsed && !retriedPass) continue
191
194
  const passes = before.passes + (c.status === 'success' ? 1 : 0)
192
195
  const failures = before.failures + (c.status === 'failed' ? 1 : 0)
193
196
  out.push({ ...c, taskId: `${c.project}#${c.task}`, passes, failures })
@@ -213,7 +216,7 @@ export interface FlakyTask {
213
216
  * (30 days, `RunHistory.pruneOlderThan`), most failures first: the
214
217
  * doctor's list. A pass that took a retry mixes its key on its own: it is
215
218
  * one `success` row whose failed attempts count as failures (`vx info`
216
- * said `none` over one). A full scan of `runs`, which an inspection verb
219
+ * said `none` over one). A full scan of `task_outcomes`, which an inspection verb
217
220
  * affords.
218
221
  */
219
222
  export function flakyTasks(db: Database): FlakyTask[] {
@@ -1,5 +1,5 @@
1
1
  // HistoryTable + HistoryProvider — historical run data the `schedule`
2
- // plugin (`@vzn/vx-schedule-history`, and its `vx history` verb) and
2
+ // plugin (`@vzn/vx-history`, and its `vx history` verb) and
3
3
  // `--dry`'s time prediction read.
4
4
  //
5
5
  // The data has been in cache.db.runs since schema v11; what's new is
@@ -13,7 +13,7 @@
13
13
 
14
14
  import type { Database } from 'bun:sqlite'
15
15
  import { branchSource, EXECUTED_RUNS_SQL } from '../cache/index.js'
16
- import { splitTaskId } from '../util/index.js'
16
+ import { isMeasure, splitTaskId } from '../util/index.js'
17
17
  import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
18
18
  import type { FailureMode } from './failure-mode.js'
19
19
  import { isPassStatus, TASK_STATUSES } from '../graph/index.js'
@@ -33,11 +33,21 @@ export const PASS_STATUSES = `(${TASK_STATUSES.filter(isPassStatus)
33
33
  /** The share of a machine a task must use to have filled it. */
34
34
  const SATURATED = 0.9
35
35
 
36
+ /**
37
+ * The cores a run of `runs r` kept busy, as need: its CPU over its time,
38
+ * divided by the share of a core its threads got (`cpu_share`, v49). A run
39
+ * on a crowded worker used its fair share only: nine test shards on four
40
+ * cores each used 0.36 of the 1.1 they take alone, and learning 0.36 packed
41
+ * the next run as tight (main c27c238, 2026-10-23). Never past the machine.
42
+ */
43
+ const NEED_SQL = `MIN(r.cpu_ms * 1.0 / (r.duration_ms * COALESCE(r.cpu_share, 1)),
44
+ COALESCE(r.placement_cores, r.cpu_ms * 1.0 / (r.duration_ms * COALESCE(r.cpu_share, 1))))`
45
+
36
46
  /** Per (project#task) — last RECENT runs collapsed into a summary. */
37
47
  export interface TaskHistory {
38
48
  /** Total runs in the recent window. */
39
49
  runs: number
40
- /** Wall-clock p50 (ms). Cache-hit rows excluded so this reflects work actually done. */
50
+ /** Wall-clock p50 (ms), a crowded run's as if alone. Cache-hit rows excluded so this reflects work actually done. */
41
51
  p50DurationMs: number | undefined
42
52
  /** Wall-clock p99 (ms). Same exclusion. */
43
53
  p99DurationMs: number | undefined
@@ -56,17 +66,21 @@ export interface TaskHistory {
56
66
  */
57
67
  maxPeakRssBytes?: number
58
68
  /**
59
- * The most CPU parallelism any successful execution in the window
60
- * showed: cpu time over wall time, so 2.0 means two cores busy for the
61
- * whole task. Undefined when no row carried both numbers.
69
+ * The CPU parallelism the window's successful executions needed on
70
+ * average: cpu time over wall time over the share of a core it got, so
71
+ * 2.0 means two cores busy for the whole task. An average, not the
72
+ * most: one crowded run's need read 2.84 cores for a build that uses
73
+ * 1.3 to 1.9, booked it a whole 3-core Mac, and the tasks beside it
74
+ * waited 45 s (main b5b83bb). Undefined when no row carried both numbers.
62
75
  */
63
76
  maxCpuParallelism?: number
64
77
  /**
65
78
  * The most cores, and the most memory, of a machine an executor ran it
66
79
  * on (`ExecuteResult.machine`) that it used all of: 90% of the cores
67
- * over its whole run, or 90% of the memory. A burst to every core is
80
+ * over its whole run, averaged over the window's runs on machines that
81
+ * size, or 90% of the memory in any one. A burst to every core is
68
82
  * not one: tasks beside it slow for a moment, and booking a machine for
69
- * it left CI agents half idle (M, 2026-10-22). A
83
+ * it left CI agents half idle (2026-10-22). A
70
84
  * task that filled a machine wanted a bigger one. Set by `resourcesFor`.
71
85
  */
72
86
  saturatedCores?: number
@@ -110,8 +124,37 @@ export class LocalHistoryProvider implements HistoryProvider {
110
124
  readonly recent: number = DEFAULT_RECENT,
111
125
  /** The run's branch (`currentBranch`); undefined: a forecast reads every branch's timings. */
112
126
  private readonly branch?: string | null,
127
+ /**
128
+ * Each task's definition (`taskDefinitions`): given, a task reads only
129
+ * the rows of it as it is now, and a task not in it reads none. A row
130
+ * of no definition (timings shared before definitions were kept) is
131
+ * read: dropping them left a PR's run with no task measured (#4150).
132
+ */
133
+ private readonly defs?: ReadonlyMap<string, string>,
113
134
  ) {}
114
135
 
136
+ /** `r.def` in a SELECT and GROUP BY when rows are matched to definitions. */
137
+ private get byDef(): string {
138
+ return this.defs === undefined ? '' : ', r.def'
139
+ }
140
+
141
+ /** Whether a row of `id` under `def` is the task as it is now. */
142
+ private current(id: string, def: string | null | undefined): boolean {
143
+ return this.defs === undefined || def == null || this.defs.get(id) === def
144
+ }
145
+
146
+ /** A task id's `[project, task, def?]`, the JSON a statement matches rows by. */
147
+ private keyOf(id: string): string[] {
148
+ const [project, task] = splitTaskId(id)
149
+ if (this.defs === undefined) return [project, task]
150
+ return [project, task, this.defs.get(id) ?? '']
151
+ }
152
+
153
+ /** The condition on a `task_durations` row's `def`, against `j.value ->> 2`. */
154
+ private get durationDef(): string {
155
+ return this.defs === undefined ? '' : 'AND (def IS NULL OR def = j.value ->> 2)'
156
+ }
157
+
115
158
  /**
116
159
  * The window is the last `recent` INVOCATIONS, read as one rowid slice.
117
160
  *
@@ -121,8 +164,8 @@ export class LocalHistoryProvider implements HistoryProvider {
121
164
  * ranking every pair's rows newest-first (`ROW_NUMBER` over a partition)
122
165
  * sorted the whole table either way, 230 ms at 116k rows. Rows are
123
166
  * appended in time order, one contiguous block per invocation, so the
124
- * rows of the newest `recent` invocations are exactly `id >= MIN(id)` of
125
- * the `recent`-th newest header — a primary-key range, no sort, plain
167
+ * rows of the newest `recent` invocations are exactly `id >=` the first
168
+ * row of the newest `recent` headers — a primary-key range, no sort, plain
126
169
  * aggregates, and at most one row per pair per invocation, so "the last
127
170
  * `recent` invocations" IS the last `recent` runs for a pair that runs
128
171
  * every time and whatever fewer it has for one that runs less often. A
@@ -150,22 +193,24 @@ export class LocalHistoryProvider implements HistoryProvider {
150
193
  const sql = `
151
194
  SELECT
152
195
  r.project,
153
- r.task,
196
+ r.task${this.defs === undefined ? '' : ', r.def AS def'},
154
197
  COUNT(*) AS total,
155
198
  SUM(CASE WHEN r.status IN ${PASS_STATUSES} THEN 1 ELSE 0 END) AS successes,
156
199
  SUM(CASE WHEN r.cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
157
200
  SUM(CASE WHEN r.status = 'failed' THEN 1 ELSE 0 END) AS failures,
158
- SUM(CASE WHEN r.attempts > 1 THEN 1 ELSE 0 END) AS retried,
201
+ SUM(CASE WHEN r.status = 'success' AND r.attempts > 1 THEN 1 ELSE 0 END) AS retried,
159
202
  GROUP_CONCAT(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
160
203
  THEN r.duration_ms END) AS ds,
161
204
  MAX(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
162
205
  THEN r.peak_rss_bytes
163
206
  WHEN r.cache_hit = 1 THEN e.peak_rss_bytes END) AS rss,
164
- MAX(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
165
- AND r.cpu_ms IS NOT NULL AND r.duration_ms > 0
166
- THEN r.cpu_ms * 1.0 / r.duration_ms
167
- WHEN r.cache_hit = 1 AND e.cpu_ms IS NOT NULL AND e.duration_ms > 0
168
- THEN e.cpu_ms * 1.0 / e.duration_ms END) AS cpu
207
+ -- A hit's producer use only where no run here measured it (resourcesFor).
208
+ COALESCE(
209
+ AVG(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
210
+ AND r.cpu_ms IS NOT NULL AND r.duration_ms > 0
211
+ THEN ${NEED_SQL} END),
212
+ MAX(CASE WHEN r.cache_hit = 1 AND e.cpu_ms IS NOT NULL AND e.duration_ms > 0
213
+ THEN e.cpu_ms * 1.0 / e.duration_ms END)) AS cpu
169
214
  FROM runs r
170
215
  -- A hit executed nothing here, but its entry carries what the
171
216
  -- PRODUCING execution used (the artifact's sidecar, indexed at save
@@ -175,11 +220,12 @@ export class LocalHistoryProvider implements HistoryProvider {
175
220
  -- since contributes nothing.
176
221
  LEFT JOIN entries e ON r.cache_hit = 1 AND e.hash = r.hash
177
222
  WHERE r.id >= ? AND ${EXECUTED_RUNS_SQL}
178
- GROUP BY r.project, r.task
223
+ GROUP BY r.project, r.task${this.byDef}
179
224
  `
180
225
  type Row = {
181
226
  project: string
182
227
  task: string
228
+ def?: string | null
183
229
  total: number
184
230
  successes: number
185
231
  hits: number
@@ -192,9 +238,10 @@ export class LocalHistoryProvider implements HistoryProvider {
192
238
  // Every pair in the slice is grouped (the slice is bounded, so this is
193
239
  // cheap) and the asked-for ones are picked out here — a per-row IN-list
194
240
  // probe in SQL cost more than the grouping it would have saved.
195
- const rows = (this.db.query(sql).all(floor) as Row[]).filter(
196
- (r) => wanted === null || wanted.has(`${r.project}#${r.task}`),
197
- )
241
+ const rows = (this.db.query(sql).all(floor) as Row[]).filter((r) => {
242
+ const id = `${r.project}#${r.task}`
243
+ return (wanted === null || wanted.has(id)) && this.current(id, r.def)
244
+ })
198
245
 
199
246
  // The flakiness signal needs per-key outcomes, which the aggregate above
200
247
  // folded away — one more pass over the slice, only for the pairs whose
@@ -264,18 +311,27 @@ export class LocalHistoryProvider implements HistoryProvider {
264
311
  sha: string,
265
312
  timings: ReadonlyMap<string, number>,
266
313
  needs: ReadonlyMap<string, TaskResources> = new Map(),
314
+ /** Each task's definition when it was timed (`taskDefinition`). */
315
+ defs: ReadonlyMap<string, string> = new Map(),
267
316
  ): void {
268
317
  const insert = this.db.query(
269
318
  `INSERT OR IGNORE INTO task_durations(project, task, source, sha, duration_ms, recorded_at,
270
- cpu_parallelism, peak_rss_bytes, saturated_cores, saturated_memory_bytes)
271
- VALUES (?, ?, 'remote', ?, ?, ?, ?, ?, ?, ?)`,
319
+ cpu_parallelism, peak_rss_bytes, saturated_cores, saturated_memory_bytes, def)
320
+ VALUES (?, ?, 'remote', ?, ?, ?, ?, ?, ?, ?, ?)`,
272
321
  )
273
322
  const now = Date.now()
274
323
  this.db
275
324
  .transaction(() => {
276
325
  for (const [id, ms] of timings) {
277
- const [project, task] = splitTaskId(id)
278
326
  const n = needs.get(id)
327
+ // A plugin's Infinity or negative, kept, made every later forecast
328
+ // NaN or below zero: the row is never written again to correct it.
329
+ if (
330
+ !isMeasure(ms) ||
331
+ (n !== undefined && !Object.values(n).every((v) => v === undefined || isMeasure(v)))
332
+ )
333
+ continue
334
+ const [project, task] = splitTaskId(id)
279
335
  insert.run(
280
336
  project,
281
337
  task,
@@ -286,6 +342,7 @@ export class LocalHistoryProvider implements HistoryProvider {
286
342
  n?.maxPeakRssBytes ?? null,
287
343
  n?.saturatedCores ?? null,
288
344
  n?.saturatedMemoryBytes ?? null,
345
+ defs.get(id) ?? null,
289
346
  )
290
347
  }
291
348
  })
@@ -293,7 +350,7 @@ export class LocalHistoryProvider implements HistoryProvider {
293
350
  }
294
351
 
295
352
  /**
296
- * Each task's duration forecast (owner, 2026-10-14): the median of its
353
+ * Each task's duration forecast (2026-10-14): the median of its
297
354
  * last `FORECAST_RUNS` timings on this branch, else the default branch's
298
355
  * newest imported timing (`importTimings`), else the median on any
299
356
  * branch, else none. The timings are the shared store's `task_durations`, so every
@@ -333,11 +390,11 @@ export class LocalHistoryProvider implements HistoryProvider {
333
390
  .query(
334
391
  `SELECT j.key AS i, (SELECT group_concat(ms) FROM (
335
392
  SELECT duration_ms AS ms FROM task_durations
336
- WHERE project = j.value ->> 0 AND task = j.value ->> 1 AND ${tier.where}
393
+ WHERE project = j.value ->> 0 AND task = j.value ->> 1 AND ${tier.where} ${this.durationDef}
337
394
  ORDER BY id DESC LIMIT ${tier.limit})) AS ds
338
395
  FROM json_each(?) AS j`,
339
396
  )
340
- .all(...tier.params, JSON.stringify(pending.map(splitTaskId))) as {
397
+ .all(...tier.params, JSON.stringify(pending.map((id) => this.keyOf(id)))) as {
341
398
  i: number
342
399
  ds: string | null
343
400
  }[]
@@ -377,70 +434,108 @@ export class LocalHistoryProvider implements HistoryProvider {
377
434
  type Executed = {
378
435
  project: string
379
436
  task: string
437
+ def?: string | null
380
438
  rss: number | null
381
439
  cpu: number | null
382
440
  }
383
441
  const executed = this.db
384
442
  .query(
385
- `SELECT project, task, MAX(peak_rss_bytes) AS rss,
386
- MAX(CASE WHEN cpu_ms IS NOT NULL AND duration_ms > 0
387
- THEN cpu_ms * 1.0 / duration_ms END) AS cpu
388
- FROM runs
389
- WHERE id >= ? AND status = 'success' AND (cache_hit IS NULL OR cache_hit = 0)
390
- GROUP BY project, task`,
443
+ `SELECT r.project, r.task${this.byDef}, MAX(r.peak_rss_bytes) AS rss,
444
+ AVG(CASE WHEN r.cpu_ms IS NOT NULL AND r.duration_ms > 0
445
+ THEN ${NEED_SQL} END) AS cpu
446
+ FROM runs r
447
+ WHERE r.id >= ? AND r.status = 'success' AND (r.cache_hit IS NULL OR r.cache_hit = 0)
448
+ GROUP BY r.project, r.task${this.byDef}`,
391
449
  )
392
450
  .all(floor) as Executed[]
393
451
  const p50s = this.forecasts(wanted)
394
452
  for (const row of executed) {
395
453
  const key = `${row.project}#${row.task}`
396
- if (!wanted.has(key)) continue
454
+ if (!wanted.has(key) || !this.current(key, row.def)) continue
397
455
  out.set(key, {
398
456
  p50DurationMs: p50s.get(key),
399
457
  ...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
400
458
  ...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
401
459
  })
402
460
  }
403
- type Hit = { project: string; task: string; rss: number | null; cpu: number | null }
461
+ type Hit = {
462
+ project: string
463
+ task: string
464
+ def?: string | null
465
+ rss: number | null
466
+ cpu: number | null
467
+ ms: number | null
468
+ }
404
469
  const hits = this.db
405
470
  .query(
406
- `SELECT h.project, h.task, MAX(e.peak_rss_bytes) AS rss,
471
+ `SELECT r.project, r.task${this.byDef}, MAX(e.peak_rss_bytes) AS rss,
407
472
  MAX(CASE WHEN e.cpu_ms IS NOT NULL AND e.duration_ms > 0
408
- THEN e.cpu_ms * 1.0 / e.duration_ms END) AS cpu
409
- FROM (SELECT DISTINCT project, task, hash FROM runs WHERE id >= ? AND cache_hit = 1) h
410
- JOIN entries e ON e.hash = h.hash
411
- GROUP BY h.project, h.task`,
473
+ THEN e.cpu_ms * 1.0 / e.duration_ms END) AS cpu,
474
+ MAX(CASE WHEN e.duration_ms > 0 THEN e.duration_ms END) AS ms
475
+ FROM (SELECT DISTINCT project, task, hash, def FROM runs WHERE id >= ? AND cache_hit = 1) r
476
+ JOIN entries e ON e.hash = r.hash
477
+ GROUP BY r.project, r.task${this.byDef}`,
412
478
  )
413
479
  .all(floor) as Hit[]
480
+ // A hit's CPU is its producer's use, not need: the entry keeps no
481
+ // share, so a crowded producer's reads low. Only a task with no need
482
+ // from a run here or the default branch's import takes it: main's
483
+ // blob carried each hit shard's crowded use forward, and the next run
484
+ // packed them as tight (main 357d3aa, 2026-10-23).
485
+ const hitCpu = new Map<string, number>()
414
486
  for (const row of hits) {
415
487
  const key = `${row.project}#${row.task}`
416
- if (!wanted.has(key) || (row.rss === null && row.cpu === null)) continue
488
+ if (
489
+ !wanted.has(key) ||
490
+ !this.current(key, row.def) ||
491
+ (row.rss === null && row.cpu === null && row.ms === null)
492
+ )
493
+ continue
494
+ if (row.cpu !== null) hitCpu.set(key, row.cpu)
417
495
  const had = out.get(key)
418
496
  const rss = Math.max(had?.maxPeakRssBytes ?? -Infinity, row.rss ?? -Infinity)
419
- const cpu = Math.max(had?.maxCpuParallelism ?? -Infinity, row.cpu ?? -Infinity)
420
497
  out.set(key, {
421
- p50DurationMs: p50s.get(key),
498
+ ...had,
499
+ // A task this store only restored is timed by its producer: one a
500
+ // branch's run made, main restored and never timed, and every
501
+ // later run planned it at a guess (PR #4339's docs chain: 90 s
502
+ // guessed, 45 s run, 2026-10-24).
503
+ p50DurationMs: p50s.get(key) ?? row.ms ?? undefined,
422
504
  ...(rss !== -Infinity ? { maxPeakRssBytes: rss } : {}),
423
- ...(cpu !== -Infinity ? { maxCpuParallelism: cpu } : {}),
424
505
  })
425
506
  }
426
507
  // A failure counts: a task killed for memory filled its machine too.
427
- type Saturated = { project: string; task: string; cores: number | null; memory: number | null }
508
+ type Saturated = {
509
+ project: string
510
+ task: string
511
+ def?: string | null
512
+ cores: number | null
513
+ memory: number | null
514
+ }
428
515
  const saturated = this.db
429
516
  .query(
430
- `SELECT project, task,
431
- MAX(CASE WHEN cpu_ms * 1.0 / duration_ms >= ${SATURATED} * placement_cores
432
- THEN placement_cores END) AS cores,
433
- MAX(CASE WHEN peak_rss_bytes >= ${SATURATED} * placement_memory
434
- THEN placement_memory END) AS memory
435
- FROM runs
436
- WHERE id >= ? AND placement_cores IS NOT NULL AND duration_ms > 0
437
- AND status IN ('success', 'failed') AND (cache_hit IS NULL OR cache_hit = 0)
438
- GROUP BY project, task`,
517
+ `SELECT r.project, r.task${this.byDef},
518
+ MAX(CASE WHEN r.fill >= ${SATURATED} THEN r.cores END) AS cores,
519
+ MAX(r.memory) AS memory
520
+ FROM (SELECT r.project, r.task${this.byDef}, r.placement_cores AS cores,
521
+ AVG(${NEED_SQL} / r.placement_cores) AS fill,
522
+ MAX(CASE WHEN r.peak_rss_bytes >= ${SATURATED} * r.placement_memory
523
+ THEN r.placement_memory END) AS memory
524
+ FROM runs r
525
+ WHERE r.id >= ? AND r.placement_cores IS NOT NULL AND r.duration_ms > 0
526
+ AND r.status IN ('success', 'failed') AND (r.cache_hit IS NULL OR r.cache_hit = 0)
527
+ GROUP BY r.project, r.task${this.byDef}, r.placement_cores) r
528
+ GROUP BY r.project, r.task${this.byDef}`,
439
529
  )
440
530
  .all(floor) as Saturated[]
441
531
  for (const row of saturated) {
442
532
  const key = `${row.project}#${row.task}`
443
- if (!wanted.has(key) || (row.cores === null && row.memory === null)) continue
533
+ if (
534
+ !wanted.has(key) ||
535
+ !this.current(key, row.def) ||
536
+ (row.cores === null && row.memory === null)
537
+ )
538
+ continue
444
539
  out.set(key, {
445
540
  ...(out.get(key) ?? { p50DurationMs: p50s.get(key) }),
446
541
  ...(row.cores !== null ? { saturatedCores: row.cores } : {}),
@@ -450,6 +545,10 @@ export class LocalHistoryProvider implements HistoryProvider {
450
545
  // A forecast reads past the window: a task with none in it still has one.
451
546
  for (const [key, p50] of p50s) if (!out.has(key)) out.set(key, { p50DurationMs: p50 })
452
547
  this.remoteNeeds(wanted, out)
548
+ for (const [key, cpu] of hitCpu) {
549
+ const had = out.get(key)!
550
+ if (had.maxCpuParallelism === undefined) out.set(key, { ...had, maxCpuParallelism: cpu })
551
+ }
453
552
  return out
454
553
  }
455
554
 
@@ -477,10 +576,10 @@ export class LocalHistoryProvider implements HistoryProvider {
477
576
  JOIN task_durations d ON d.id = (
478
577
  SELECT id FROM task_durations
479
578
  WHERE project = j.value ->> 0 AND task = j.value ->> 1 AND source = 'remote'
480
- AND cpu_parallelism IS NOT NULL
579
+ AND cpu_parallelism IS NOT NULL ${this.durationDef}
481
580
  ORDER BY id DESC LIMIT 1)`,
482
581
  )
483
- .all(JSON.stringify(pending.map(splitTaskId))) as {
582
+ .all(JSON.stringify(pending.map((id) => this.keyOf(id)))) as {
484
583
  i: number
485
584
  cpu: number
486
585
  rss: number | null
@@ -507,14 +606,21 @@ export class LocalHistoryProvider implements HistoryProvider {
507
606
 
508
607
  /** The first `runs` id of the newest `recent` invocations (see `loadFor`). */
509
608
  private floor(): number {
609
+ // The first row of each invocation in the window, an index probe each:
610
+ // a run of only a group records a header and no row (X-50), and at the
611
+ // oldest slot it gave no first row, so the window read the whole table.
510
612
  const row = this.db
511
613
  .query(
512
- `SELECT MIN(id) AS id FROM runs WHERE run_id =
513
- (SELECT run_id FROM invocations ORDER BY rowid DESC LIMIT 1 OFFSET ?)`,
614
+ `SELECT COUNT(*) AS n, MIN(first) AS id,
615
+ (SELECT COALESCE(MAX(id), 0) + 1 FROM runs) AS past
616
+ FROM (SELECT (SELECT MIN(id) FROM runs WHERE run_id = i.run_id) AS first
617
+ FROM (SELECT run_id FROM invocations ORDER BY rowid DESC LIMIT ?) i)`,
514
618
  )
515
- .get(this.recent - 1) as { id: number | null }
619
+ .get(this.recent) as { n: number; id: number | null; past: number }
516
620
  // Fewer invocations than the window (or none): the whole table is the window.
517
- return row.id ?? 0
621
+ if (row.n < this.recent) return 0
622
+ // A window of only empty invocations holds no row.
623
+ return row.id ?? row.past
518
624
  }
519
625
 
520
626
  /** Mixed-outcome key count per `project#task`, over the slice, for `pairs` only. */
@@ -24,11 +24,13 @@ export {
24
24
  explainCacheKey,
25
25
  getInvocation,
26
26
  getRun,
27
+ GROUP_VIA,
27
28
  inputWriters,
28
29
  latestRunId,
29
30
  listInvocations,
30
31
  listRuns,
31
32
  rootCauses,
33
+ upstreamTask,
32
34
  whyDidThisRerun,
33
35
  } from './metrics.js'
34
36
  export type {