@vzn/vx 0.0.645 → 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 (186) hide show
  1. package/README.md +39 -50
  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 +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  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 +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -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'
@@ -30,11 +30,24 @@ export const PASS_STATUSES = `(${TASK_STATUSES.filter(isPassStatus)
30
30
  .map((s) => `'${s}'`)
31
31
  .join(', ')})`
32
32
 
33
+ /** The share of a machine a task must use to have filled it. */
34
+ const SATURATED = 0.9
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
+
33
46
  /** Per (project#task) — last RECENT runs collapsed into a summary. */
34
47
  export interface TaskHistory {
35
48
  /** Total runs in the recent window. */
36
49
  runs: number
37
- /** 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. */
38
51
  p50DurationMs: number | undefined
39
52
  /** Wall-clock p99 (ms). Same exclusion. */
40
53
  p99DurationMs: number | undefined
@@ -53,11 +66,25 @@ export interface TaskHistory {
53
66
  */
54
67
  maxPeakRssBytes?: number
55
68
  /**
56
- * The most CPU parallelism any successful execution in the window
57
- * showed: cpu time over wall time, so 2.0 means two cores busy for the
58
- * 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.
59
75
  */
60
76
  maxCpuParallelism?: number
77
+ /**
78
+ * The most cores, and the most memory, of a machine an executor ran it
79
+ * on (`ExecuteResult.machine`) that it used all of: 90% of the cores
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
82
+ * not one: tasks beside it slow for a moment, and booking a machine for
83
+ * it left CI agents half idle (2026-10-22). A
84
+ * task that filled a machine wanted a bigger one. Set by `resourcesFor`.
85
+ */
86
+ saturatedCores?: number
87
+ saturatedMemoryBytes?: number
61
88
  }
62
89
 
63
90
  /** Map keyed by `project#task`. */
@@ -66,7 +93,11 @@ export type HistoryTable = ReadonlyMap<string, TaskHistory>
66
93
  /** What a scheduler learns from a task's history: its p50 and its resource maxima. */
67
94
  export type TaskResources = Pick<
68
95
  TaskHistory,
69
- 'p50DurationMs' | 'maxPeakRssBytes' | 'maxCpuParallelism'
96
+ | 'p50DurationMs'
97
+ | 'maxPeakRssBytes'
98
+ | 'maxCpuParallelism'
99
+ | 'saturatedCores'
100
+ | 'saturatedMemoryBytes'
70
101
  >
71
102
 
72
103
  export interface HistoryProvider {
@@ -93,8 +124,37 @@ export class LocalHistoryProvider implements HistoryProvider {
93
124
  readonly recent: number = DEFAULT_RECENT,
94
125
  /** The run's branch (`currentBranch`); undefined: a forecast reads every branch's timings. */
95
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>,
96
134
  ) {}
97
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
+
98
158
  /**
99
159
  * The window is the last `recent` INVOCATIONS, read as one rowid slice.
100
160
  *
@@ -104,8 +164,8 @@ export class LocalHistoryProvider implements HistoryProvider {
104
164
  * ranking every pair's rows newest-first (`ROW_NUMBER` over a partition)
105
165
  * sorted the whole table either way, 230 ms at 116k rows. Rows are
106
166
  * appended in time order, one contiguous block per invocation, so the
107
- * rows of the newest `recent` invocations are exactly `id >= MIN(id)` of
108
- * 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
109
169
  * aggregates, and at most one row per pair per invocation, so "the last
110
170
  * `recent` invocations" IS the last `recent` runs for a pair that runs
111
171
  * every time and whatever fewer it has for one that runs less often. A
@@ -133,22 +193,24 @@ export class LocalHistoryProvider implements HistoryProvider {
133
193
  const sql = `
134
194
  SELECT
135
195
  r.project,
136
- r.task,
196
+ r.task${this.defs === undefined ? '' : ', r.def AS def'},
137
197
  COUNT(*) AS total,
138
198
  SUM(CASE WHEN r.status IN ${PASS_STATUSES} THEN 1 ELSE 0 END) AS successes,
139
199
  SUM(CASE WHEN r.cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
140
200
  SUM(CASE WHEN r.status = 'failed' THEN 1 ELSE 0 END) AS failures,
141
- 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,
142
202
  GROUP_CONCAT(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
143
203
  THEN r.duration_ms END) AS ds,
144
204
  MAX(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
145
205
  THEN r.peak_rss_bytes
146
206
  WHEN r.cache_hit = 1 THEN e.peak_rss_bytes END) AS rss,
147
- MAX(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
148
- AND r.cpu_ms IS NOT NULL AND r.duration_ms > 0
149
- THEN r.cpu_ms * 1.0 / r.duration_ms
150
- WHEN r.cache_hit = 1 AND e.cpu_ms IS NOT NULL AND e.duration_ms > 0
151
- 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
152
214
  FROM runs r
153
215
  -- A hit executed nothing here, but its entry carries what the
154
216
  -- PRODUCING execution used (the artifact's sidecar, indexed at save
@@ -158,11 +220,12 @@ export class LocalHistoryProvider implements HistoryProvider {
158
220
  -- since contributes nothing.
159
221
  LEFT JOIN entries e ON r.cache_hit = 1 AND e.hash = r.hash
160
222
  WHERE r.id >= ? AND ${EXECUTED_RUNS_SQL}
161
- GROUP BY r.project, r.task
223
+ GROUP BY r.project, r.task${this.byDef}
162
224
  `
163
225
  type Row = {
164
226
  project: string
165
227
  task: string
228
+ def?: string | null
166
229
  total: number
167
230
  successes: number
168
231
  hits: number
@@ -175,9 +238,10 @@ export class LocalHistoryProvider implements HistoryProvider {
175
238
  // Every pair in the slice is grouped (the slice is bounded, so this is
176
239
  // cheap) and the asked-for ones are picked out here — a per-row IN-list
177
240
  // probe in SQL cost more than the grouping it would have saved.
178
- const rows = (this.db.query(sql).all(floor) as Row[]).filter(
179
- (r) => wanted === null || wanted.has(`${r.project}#${r.task}`),
180
- )
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
+ })
181
245
 
182
246
  // The flakiness signal needs per-key outcomes, which the aggregate above
183
247
  // folded away — one more pass over the slice, only for the pairs whose
@@ -238,27 +302,55 @@ export class LocalHistoryProvider implements HistoryProvider {
238
302
 
239
303
  /**
240
304
  * Keep default-branch commit `sha`'s timings (task id → ms) as forecast
241
- * rows: one per task, so a repeat import, by this worktree or another
242
- * at once, adds nothing.
305
+ * rows, each with the task's measured need from `needs` when it has one
306
+ * (what `resourcesFor` answers for a task with no run here): one row per
307
+ * task, so a repeat import, by this worktree or another at once, adds
308
+ * nothing.
243
309
  */
244
- importTimings(sha: string, timings: ReadonlyMap<string, number>): void {
310
+ importTimings(
311
+ sha: string,
312
+ timings: ReadonlyMap<string, number>,
313
+ needs: ReadonlyMap<string, TaskResources> = new Map(),
314
+ /** Each task's definition when it was timed (`taskDefinition`). */
315
+ defs: ReadonlyMap<string, string> = new Map(),
316
+ ): void {
245
317
  const insert = this.db.query(
246
- `INSERT OR IGNORE INTO task_durations(project, task, source, sha, duration_ms, recorded_at)
247
- VALUES (?, ?, 'remote', ?, ?, ?)`,
318
+ `INSERT OR IGNORE INTO task_durations(project, task, source, sha, duration_ms, recorded_at,
319
+ cpu_parallelism, peak_rss_bytes, saturated_cores, saturated_memory_bytes, def)
320
+ VALUES (?, ?, 'remote', ?, ?, ?, ?, ?, ?, ?, ?)`,
248
321
  )
249
322
  const now = Date.now()
250
323
  this.db
251
324
  .transaction(() => {
252
325
  for (const [id, ms] of timings) {
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
253
334
  const [project, task] = splitTaskId(id)
254
- insert.run(project, task, sha, Math.round(ms), now)
335
+ insert.run(
336
+ project,
337
+ task,
338
+ sha,
339
+ Math.round(ms),
340
+ now,
341
+ n?.maxCpuParallelism ?? null,
342
+ n?.maxPeakRssBytes ?? null,
343
+ n?.saturatedCores ?? null,
344
+ n?.saturatedMemoryBytes ?? null,
345
+ defs.get(id) ?? null,
346
+ )
255
347
  }
256
348
  })
257
349
  .immediate()
258
350
  }
259
351
 
260
352
  /**
261
- * 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
262
354
  * last `FORECAST_RUNS` timings on this branch, else the default branch's
263
355
  * newest imported timing (`importTimings`), else the median on any
264
356
  * branch, else none. The timings are the shared store's `task_durations`, so every
@@ -298,11 +390,11 @@ export class LocalHistoryProvider implements HistoryProvider {
298
390
  .query(
299
391
  `SELECT j.key AS i, (SELECT group_concat(ms) FROM (
300
392
  SELECT duration_ms AS ms FROM task_durations
301
- 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}
302
394
  ORDER BY id DESC LIMIT ${tier.limit})) AS ds
303
395
  FROM json_each(?) AS j`,
304
396
  )
305
- .all(...tier.params, JSON.stringify(pending.map(splitTaskId))) as {
397
+ .all(...tier.params, JSON.stringify(pending.map((id) => this.keyOf(id)))) as {
306
398
  i: number
307
399
  ds: string | null
308
400
  }[]
@@ -342,67 +434,193 @@ export class LocalHistoryProvider implements HistoryProvider {
342
434
  type Executed = {
343
435
  project: string
344
436
  task: string
437
+ def?: string | null
345
438
  rss: number | null
346
439
  cpu: number | null
347
440
  }
348
441
  const executed = this.db
349
442
  .query(
350
- `SELECT project, task, MAX(peak_rss_bytes) AS rss,
351
- MAX(CASE WHEN cpu_ms IS NOT NULL AND duration_ms > 0
352
- THEN cpu_ms * 1.0 / duration_ms END) AS cpu
353
- FROM runs
354
- WHERE id >= ? AND status = 'success' AND (cache_hit IS NULL OR cache_hit = 0)
355
- 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}`,
356
449
  )
357
450
  .all(floor) as Executed[]
358
451
  const p50s = this.forecasts(wanted)
359
452
  for (const row of executed) {
360
453
  const key = `${row.project}#${row.task}`
361
- if (!wanted.has(key)) continue
454
+ if (!wanted.has(key) || !this.current(key, row.def)) continue
362
455
  out.set(key, {
363
456
  p50DurationMs: p50s.get(key),
364
457
  ...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
365
458
  ...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
366
459
  })
367
460
  }
368
- 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
+ }
369
469
  const hits = this.db
370
470
  .query(
371
- `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,
372
472
  MAX(CASE WHEN e.cpu_ms IS NOT NULL AND e.duration_ms > 0
373
- THEN e.cpu_ms * 1.0 / e.duration_ms END) AS cpu
374
- FROM (SELECT DISTINCT project, task, hash FROM runs WHERE id >= ? AND cache_hit = 1) h
375
- JOIN entries e ON e.hash = h.hash
376
- 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}`,
377
478
  )
378
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>()
379
486
  for (const row of hits) {
380
487
  const key = `${row.project}#${row.task}`
381
- 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)
382
495
  const had = out.get(key)
383
496
  const rss = Math.max(had?.maxPeakRssBytes ?? -Infinity, row.rss ?? -Infinity)
384
- const cpu = Math.max(had?.maxCpuParallelism ?? -Infinity, row.cpu ?? -Infinity)
385
497
  out.set(key, {
386
- 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,
387
504
  ...(rss !== -Infinity ? { maxPeakRssBytes: rss } : {}),
388
- ...(cpu !== -Infinity ? { maxCpuParallelism: cpu } : {}),
505
+ })
506
+ }
507
+ // A failure counts: a task killed for memory filled its machine too.
508
+ type Saturated = {
509
+ project: string
510
+ task: string
511
+ def?: string | null
512
+ cores: number | null
513
+ memory: number | null
514
+ }
515
+ const saturated = this.db
516
+ .query(
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}`,
529
+ )
530
+ .all(floor) as Saturated[]
531
+ for (const row of saturated) {
532
+ const key = `${row.project}#${row.task}`
533
+ if (
534
+ !wanted.has(key) ||
535
+ !this.current(key, row.def) ||
536
+ (row.cores === null && row.memory === null)
537
+ )
538
+ continue
539
+ out.set(key, {
540
+ ...(out.get(key) ?? { p50DurationMs: p50s.get(key) }),
541
+ ...(row.cores !== null ? { saturatedCores: row.cores } : {}),
542
+ ...(row.memory !== null ? { saturatedMemoryBytes: row.memory } : {}),
389
543
  })
390
544
  }
391
545
  // A forecast reads past the window: a task with none in it still has one.
392
546
  for (const [key, p50] of p50s) if (!out.has(key)) out.set(key, { p50DurationMs: p50 })
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
+ }
393
552
  return out
394
553
  }
395
554
 
555
+ /**
556
+ * A task never measured here takes its need from the newest imported
557
+ * row that has one (`importTimings`): a fresh CI machine packs by what
558
+ * the default branch measured. One probe skips a store with none.
559
+ */
560
+ private remoteNeeds(wanted: ReadonlySet<string>, out: Map<string, TaskResources>): void {
561
+ const pending = [...wanted].filter((id) => out.get(id)?.maxCpuParallelism === undefined)
562
+ if (pending.length === 0) return
563
+ if (
564
+ this.db
565
+ .query(
566
+ `SELECT 1 FROM task_durations WHERE source = 'remote' AND cpu_parallelism IS NOT NULL LIMIT 1`,
567
+ )
568
+ .get() === null
569
+ )
570
+ return
571
+ const rows = this.db
572
+ .query(
573
+ `SELECT j.key AS i, d.cpu_parallelism AS cpu, d.peak_rss_bytes AS rss,
574
+ d.saturated_cores AS sc, d.saturated_memory_bytes AS sm
575
+ FROM json_each(?) AS j
576
+ JOIN task_durations d ON d.id = (
577
+ SELECT id FROM task_durations
578
+ WHERE project = j.value ->> 0 AND task = j.value ->> 1 AND source = 'remote'
579
+ AND cpu_parallelism IS NOT NULL ${this.durationDef}
580
+ ORDER BY id DESC LIMIT 1)`,
581
+ )
582
+ .all(JSON.stringify(pending.map((id) => this.keyOf(id)))) as {
583
+ i: number
584
+ cpu: number
585
+ rss: number | null
586
+ sc: number | null
587
+ sm: number | null
588
+ }[]
589
+ for (const r of rows) {
590
+ const id = pending[r.i]!
591
+ const had = out.get(id)
592
+ out.set(id, {
593
+ p50DurationMs: had?.p50DurationMs,
594
+ maxCpuParallelism: r.cpu,
595
+ ...(r.rss !== null ? { maxPeakRssBytes: r.rss } : {}),
596
+ // A saturation measured here wins.
597
+ ...(r.sc !== null ? { saturatedCores: r.sc } : {}),
598
+ ...(r.sm !== null ? { saturatedMemoryBytes: r.sm } : {}),
599
+ ...(had?.saturatedCores !== undefined ? { saturatedCores: had.saturatedCores } : {}),
600
+ ...(had?.saturatedMemoryBytes !== undefined
601
+ ? { saturatedMemoryBytes: had.saturatedMemoryBytes }
602
+ : {}),
603
+ })
604
+ }
605
+ }
606
+
396
607
  /** The first `runs` id of the newest `recent` invocations (see `loadFor`). */
397
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.
398
612
  const row = this.db
399
613
  .query(
400
- `SELECT MIN(id) AS id FROM runs WHERE run_id =
401
- (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)`,
402
618
  )
403
- .get(this.recent - 1) as { id: number | null }
619
+ .get(this.recent) as { n: number; id: number | null; past: number }
404
620
  // Fewer invocations than the window (or none): the whole table is the window.
405
- 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
406
624
  }
407
625
 
408
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 {