@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.
- package/README.md +39 -50
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +326 -65
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +65 -4
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +282 -0
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- 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
|
-
|
|
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 `
|
|
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[] {
|
package/src/history/history.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// HistoryTable + HistoryProvider — historical run data the `schedule`
|
|
2
|
-
// plugin (`@vzn/vx-
|
|
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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
|
|
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
|
|
108
|
-
* the `recent
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
242
|
-
*
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
352
|
-
THEN
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
401
|
-
(SELECT
|
|
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
|
|
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
|
-
|
|
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. */
|
package/src/history/index.ts
CHANGED