improve(ci): reduce main gating delays and add timing trends (#122441)

* ci: stop publishing warm dependency snapshots

Amp-Thread-ID: https://ampcode.com/threads/T-019ff3db-c467-70ad-8ed3-81f2ba94b0c0

* ci: isolate the high-variance source test shard

Amp-Thread-ID: https://ampcode.com/threads/T-019ff3db-c467-70ad-8ed3-81f2ba94b0c0

* ci: guarantee rebuilt dependency snapshot publication

Amp-Thread-ID: https://ampcode.com/threads/T-019ff3db-c467-70ad-8ed3-81f2ba94b0c0

* ci: add balanced main timing trends

Amp-Thread-ID: https://ampcode.com/threads/T-019ff3db-c467-70ad-8ed3-81f2ba94b0c0

* fix(ci): fall back when Crabbox CLI is unavailable

Amp-Thread-ID: https://ampcode.com/threads/T-019ff3db-c467-70ad-8ed3-81f2ba94b0c0

* Revert "fix(ci): fall back when Crabbox CLI is unavailable"

This reverts commit 0583ac8a9d.

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Peter Steinberger
2026-08-11 22:26:39 -07:00
committed by GitHub
parent dd57cfb6c1
commit 72e42eed48
10 changed files with 1113 additions and 37 deletions
+709 -20
View File
@@ -2,23 +2,29 @@
// Summarizes GitHub Actions run/job timings for CI analysis.
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { isDirectRunUrl } from "./lib/direct-run.mjs";
import { parsePositiveInt } from "./lib/numeric-options.mjs";
import { execPlainGh } from "./lib/plain-gh.mjs";
const DEFAULT_GITHUB_REPOSITORY = "openclaw/openclaw";
const RUN_JOBS_PAGE_SIZE = 20;
const RUN_JOBS_PAGE_SIZE = 100;
const RUN_JOBS_MAX_PAGES = 25;
const TREND_RUNS_MAX_PAGES = 100;
const DEFAULT_TREND_COMPARE_HOURS = 12;
const DEFAULT_TREND_DETAIL_RUNS = 100;
const GH_JSON_RETRY_DELAYS_MS = [1_000, 3_000, 6_000];
function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function parseJsonCommand(command, args, options = {}) {
function parseJsonCommand(command, args, onAttempt = null, options = {}) {
let lastError;
for (let attempt = 0; attempt <= GH_JSON_RETRY_DELAYS_MS.length; attempt += 1) {
try {
onAttempt?.();
const stdout =
command === "gh"
? execPlainGh(args, {
@@ -53,8 +59,12 @@ function normalizeRunJob(job) {
return {
completedAt: job.completedAt ?? job.completed_at ?? null,
conclusion: job.conclusion ?? "",
createdAt: job.createdAt ?? job.created_at ?? null,
databaseId: job.databaseId ?? job.id,
labels: Array.isArray(job.labels) ? job.labels : [],
name: job.name,
runnerGroupName: job.runnerGroupName ?? job.runner_group_name ?? null,
runnerName: job.runnerName ?? job.runner_name ?? null,
startedAt: job.startedAt ?? job.started_at ?? null,
status: job.status ?? "",
};
@@ -92,6 +102,16 @@ function percentile(values, percentileValue) {
return sorted[index];
}
function summarizeDistribution(values) {
return {
count: values.length,
max: values.length === 0 ? null : Math.max(...values),
p50: percentile(values, 0.5),
p90: percentile(values, 0.9),
p95: percentile(values, 0.95),
};
}
function parseRunList(raw) {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
@@ -295,24 +315,29 @@ function listRecentSuccessfulCiRuns(limit) {
.slice(0, limit);
}
function loadRun(runId) {
const run = parseJsonCommand("gh", [
"run",
"view",
runId,
"--json",
"status,conclusion,createdAt,updatedAt",
]);
/**
* @param {string | number} runId
* @param {number | null} [runAttempt]
*/
function loadRunJobs(runId, runAttempt = null) {
const repository = process.env.GITHUB_REPOSITORY || DEFAULT_GITHUB_REPOSITORY;
const runPath = runAttempt === null ? `runs/${runId}` : `runs/${runId}/attempts/${runAttempt}`;
const pages = [];
let totalCount = null;
let requestCount = 0;
for (let page = 1; page <= RUN_JOBS_MAX_PAGES; page += 1) {
const payload = parseJsonCommand("gh", [
"api",
"-X",
"GET",
`repos/${repository}/actions/runs/${runId}/jobs?per_page=${RUN_JOBS_PAGE_SIZE}&page=${page}`,
]);
const payload = parseJsonCommand(
"gh",
[
"api",
"-X",
"GET",
`repos/${repository}/actions/${runPath}/jobs?per_page=${RUN_JOBS_PAGE_SIZE}&page=${page}`,
],
() => {
requestCount += 1;
},
);
pages.push(payload);
const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
totalCount = typeof payload.total_count === "number" ? payload.total_count : totalCount;
@@ -323,9 +348,68 @@ function loadRun(runId) {
break;
}
}
return { jobs: collectRunJobsFromPages(pages), requestCount };
}
function loadRun(runId) {
const run = parseJsonCommand("gh", [
"run",
"view",
runId,
"--json",
"status,conclusion,createdAt,updatedAt",
]);
return {
...run,
jobs: collectRunJobsFromPages(pages),
jobs: loadRunJobs(runId).jobs,
};
}
function normalizeTrendRun(run) {
return {
conclusion: run.conclusion ?? "",
createdAt: run.createdAt ?? run.created_at ?? null,
databaseId: run.databaseId ?? run.id,
headSha: run.headSha ?? run.head_sha ?? "",
runAttempt: run.runAttempt ?? run.run_attempt ?? 1,
status: run.status ?? "",
updatedAt: run.updatedAt ?? run.updated_at ?? null,
url: run.url ?? run.html_url ?? "",
};
}
function listTrendCiRuns(cutoffMs) {
const repository = process.env.GITHUB_REPOSITORY || DEFAULT_GITHUB_REPOSITORY;
const runs = [];
let requestCount = 0;
for (let page = 1; page <= TREND_RUNS_MAX_PAGES; page += 1) {
const payload = parseJsonCommand(
"gh",
[
"api",
"-X",
"GET",
`repos/${repository}/actions/workflows/ci.yml/runs?branch=main&event=push&per_page=100&page=${page}`,
],
() => {
requestCount += 1;
},
);
const pageRuns = Array.isArray(payload.workflow_runs)
? payload.workflow_runs.map(normalizeTrendRun)
: [];
runs.push(...pageRuns);
const oldestCreatedAt = parseTime(pageRuns.at(-1)?.createdAt);
if (pageRuns.length < 100 || (oldestCreatedAt !== null && oldestCreatedAt < cutoffMs)) {
break;
}
}
return {
requestCount,
runs: runs.filter((run) => {
const createdAt = parseTime(run.createdAt);
return createdAt !== null && createdAt >= cutoffMs;
}),
};
}
@@ -365,6 +449,422 @@ function summarizeJobs(run) {
};
}
function isSyntheticTimingJob(job) {
return job.name?.startsWith("matrix.") || job.name === "ci-timings-summary";
}
function isAggregateTimingJob(job) {
return isSyntheticTimingJob(job) || job.name === "openclaw/ci-gate";
}
function summarizeTrendRun(run) {
const createdAt = parseTime(run.createdAt);
const updatedAt = parseTime(run.updatedAt);
const jobs = (run.jobs ?? []).filter((job) => !isSyntheticTimingJob(job));
const createdJobs = jobs
.map((job) => ({ job, createdAt: parseTime(job.createdAt) }))
.filter((entry) => entry.createdAt !== null);
const firstJobCreatedAt =
createdJobs.length === 0 ? null : Math.min(...createdJobs.map((entry) => entry.createdAt));
const activeJobs = jobs
.map((job) => ({
completedAt: parseTime(job.completedAt),
createdAt: parseTime(job.createdAt),
job,
startedAt: parseTime(job.startedAt),
}))
.filter(
(entry) =>
entry.job.conclusion !== "skipped" &&
entry.startedAt !== null &&
entry.completedAt !== null,
);
const jobTimings = activeJobs
.filter((entry) => !isAggregateTimingJob(entry.job))
.map((entry) => ({
dependencyGatedSeconds: secondsBetween(firstJobCreatedAt, entry.createdAt),
executionSeconds: secondsBetween(entry.startedAt, entry.completedAt),
labels: entry.job.labels,
name: entry.job.name,
runnerGroupName: entry.job.runnerGroupName,
runnerName: entry.job.runnerName,
runnerQueueSeconds: secondsBetween(entry.createdAt, entry.startedAt),
}));
const completionOrder = activeJobs.toSorted(
(left, right) =>
right.completedAt - left.completedAt ||
String(left.job.name).localeCompare(String(right.job.name)) ||
Number(left.job.databaseId ?? 0) - Number(right.job.databaseId ?? 0),
);
// The run list keeps the original workflow creation time after a rerun.
// Attempt-specific job data remains useful, but exclude cross-attempt run
// wall/admission metrics rather than mixing it with the latest attempt.
const firstAttempt = run.runAttempt === 1;
return {
admittedWallSeconds: firstAttempt ? secondsBetween(firstJobCreatedAt, updatedAt) : null,
conclusion: run.conclusion,
createdAt: run.createdAt,
databaseId: run.databaseId,
detailsLoaded: Array.isArray(run.jobs),
headSha: run.headSha,
jobTimings,
lastWorkOwner:
completionOrder.find((entry) => !isAggregateTimingJob(entry.job))?.job.name ?? null,
runAttempt: run.runAttempt,
status: run.status,
terminalOwner: completionOrder[0]?.job.name ?? null,
url: run.url,
wallSeconds: firstAttempt ? secondsBetween(createdAt, updatedAt) : null,
workflowAdmissionSeconds: firstAttempt ? secondsBetween(createdAt, firstJobCreatedAt) : null,
};
}
function summarizeOutcomes(runs) {
const counts = {
actionRequired: 0,
cancelled: 0,
failure: 0,
inProgress: 0,
neutral: 0,
other: 0,
pending: 0,
queued: 0,
skipped: 0,
stale: 0,
startupFailure: 0,
success: 0,
timedOut: 0,
total: runs.length,
};
const conclusionKeys = new Map([
["action_required", "actionRequired"],
["cancelled", "cancelled"],
["failure", "failure"],
["neutral", "neutral"],
["skipped", "skipped"],
["stale", "stale"],
["startup_failure", "startupFailure"],
["success", "success"],
["timed_out", "timedOut"],
]);
let completedNonCancelled = 0;
for (const run of runs) {
if (run.status === "completed" && run.conclusion !== "cancelled") {
completedNonCancelled += 1;
}
const key =
run.status === "completed"
? conclusionKeys.get(run.conclusion)
: run.status === "in_progress"
? "inProgress"
: run.status;
if (key && Object.hasOwn(counts, key)) {
counts[key] += 1;
} else {
counts.other += 1;
}
}
return {
...counts,
cancellationRate: counts.total === 0 ? null : counts.cancelled / counts.total,
nonCancelledPassRate:
completedNonCancelled === 0 ? null : counts.success / completedNonCancelled,
};
}
function summarizeCriticalOwners(runSummaries) {
const counts = new Map();
for (const run of runSummaries) {
if (run.lastWorkOwner) {
counts.set(run.lastWorkOwner, (counts.get(run.lastWorkOwner) ?? 0) + 1);
}
}
return [...counts.entries()]
.map(([name, runs]) => ({ name, runs }))
.toSorted((left, right) => right.runs - left.runs || left.name.localeCompare(right.name));
}
function summarizeTrendCohort(runs, runSummaries) {
const successfulRuns = runSummaries.filter(
(run) => run.status === "completed" && run.conclusion === "success",
);
const jobTimings = successfulRuns.flatMap((run) => run.jobTimings);
return {
criticalOwners: summarizeCriticalOwners(successfulRuns),
jobMetrics: {
dependencyGatedSeconds: summarizeDistribution(
jobTimings.map((job) => job.dependencyGatedSeconds).filter((value) => value !== null),
),
executionSeconds: summarizeDistribution(
jobTimings.map((job) => job.executionSeconds).filter((value) => value !== null),
),
runnerQueueSeconds: summarizeDistribution(
jobTimings.map((job) => job.runnerQueueSeconds).filter((value) => value !== null),
),
},
outcomes: summarizeOutcomes(runs),
samples: {
detailedSuccessfulRuns: successfulRuns.filter((run) => run.detailsLoaded).length,
successfulRuns: successfulRuns.length,
timedJobs: jobTimings.length,
},
runMetrics: {
admittedWallSeconds: summarizeDistribution(
successfulRuns.map((run) => run.admittedWallSeconds).filter((value) => value !== null),
),
successfulWallSeconds: summarizeDistribution(
successfulRuns.map((run) => run.wallSeconds).filter((value) => value !== null),
),
workflowAdmissionSeconds: summarizeDistribution(
successfulRuns.map((run) => run.workflowAdmissionSeconds).filter((value) => value !== null),
),
},
};
}
function summarizeJobNames(runSummaries, fromMs, toMs) {
const byName = new Map();
for (const run of runSummaries) {
const createdAt = parseTime(run.createdAt);
if (
createdAt === null ||
createdAt < fromMs ||
createdAt >= toMs ||
run.status !== "completed" ||
run.conclusion !== "success"
) {
continue;
}
for (const job of run.jobTimings) {
const timings = byName.get(job.name) ?? [];
timings.push(job);
byName.set(job.name, timings);
}
}
return byName;
}
function summarizeNamedJobComparison(runSummaries, priorWindow, comparisonWindow) {
const prior = summarizeJobNames(runSummaries, priorWindow.fromMs, priorWindow.toMs);
const comparison = summarizeJobNames(
runSummaries,
comparisonWindow.fromMs,
comparisonWindow.toMs,
);
return [...new Set([...prior.keys(), ...comparison.keys()])]
.map((name) => {
const summarize = (timings) => ({
executionSeconds: summarizeDistribution(
(timings ?? []).map((job) => job.executionSeconds).filter((value) => value !== null),
),
runnerQueueSeconds: summarizeDistribution(
(timings ?? []).map((job) => job.runnerQueueSeconds).filter((value) => value !== null),
),
});
return {
comparison: summarize(comparison.get(name)),
name,
prior: summarize(prior.get(name)),
};
})
.toSorted(
(left, right) =>
(right.comparison.executionSeconds.p90 ?? -1) -
(left.comparison.executionSeconds.p90 ?? -1) || left.name.localeCompare(right.name),
);
}
function metricDelta(comparison, prior, key) {
const comparisonValue = comparison?.[key] ?? null;
const priorValue = prior?.[key] ?? null;
return comparisonValue === null || priorValue === null ? null : comparisonValue - priorValue;
}
/**
* Aggregates main CI runs into a baseline, previous comparison window, and latest window.
*/
export function summarizeTrendTimings(runs, options) {
const { compareDurationMs, generatedAtMs, trendDurationMs } = options;
const baselineFromMs = generatedAtMs - trendDurationMs;
const comparisonFromMs = generatedAtMs - compareDurationMs;
const priorFromMs = comparisonFromMs - compareDurationMs;
const inWindow = (run, fromMs, toMs) => {
const createdAt = parseTime(run.createdAt);
return createdAt !== null && createdAt >= fromMs && createdAt < toMs;
};
const baselineRuns = runs.filter((run) => inWindow(run, baselineFromMs, generatedAtMs));
const priorRuns = runs.filter((run) => inWindow(run, priorFromMs, comparisonFromMs));
const comparisonRuns = runs.filter((run) => inWindow(run, comparisonFromMs, generatedAtMs));
const runSummaries = baselineRuns
.map(summarizeTrendRun)
.toSorted((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt));
const baselineSummaries = runSummaries.filter((run) =>
inWindow(run, baselineFromMs, generatedAtMs),
);
const priorSummaries = runSummaries.filter((run) => inWindow(run, priorFromMs, comparisonFromMs));
const comparisonSummaries = runSummaries.filter((run) =>
inWindow(run, comparisonFromMs, generatedAtMs),
);
const cohorts = {
baseline: summarizeTrendCohort(baselineRuns, baselineSummaries),
comparison: summarizeTrendCohort(comparisonRuns, comparisonSummaries),
prior: summarizeTrendCohort(priorRuns, priorSummaries),
};
return {
changes: {
executionP90Seconds: metricDelta(
cohorts.comparison.jobMetrics.executionSeconds,
cohorts.prior.jobMetrics.executionSeconds,
"p90",
),
runnerQueueP95Seconds: metricDelta(
cohorts.comparison.jobMetrics.runnerQueueSeconds,
cohorts.prior.jobMetrics.runnerQueueSeconds,
"p95",
),
successfulWallP50Seconds: metricDelta(
cohorts.comparison.runMetrics.successfulWallSeconds,
cohorts.prior.runMetrics.successfulWallSeconds,
"p50",
),
successfulWallP90Seconds: metricDelta(
cohorts.comparison.runMetrics.successfulWallSeconds,
cohorts.prior.runMetrics.successfulWallSeconds,
"p90",
),
workflowAdmissionP95Seconds: metricDelta(
cohorts.comparison.runMetrics.workflowAdmissionSeconds,
cohorts.prior.runMetrics.workflowAdmissionSeconds,
"p95",
),
},
cohorts,
jobs: summarizeNamedJobComparison(
runSummaries,
{ fromMs: priorFromMs, toMs: comparisonFromMs },
{ fromMs: comparisonFromMs, toMs: generatedAtMs },
),
runs: runSummaries,
windows: {
baseline: {
from: new Date(baselineFromMs).toISOString(),
to: new Date(generatedAtMs).toISOString(),
},
comparison: {
from: new Date(comparisonFromMs).toISOString(),
to: new Date(generatedAtMs).toISOString(),
},
prior: {
from: new Date(priorFromMs).toISOString(),
to: new Date(comparisonFromMs).toISOString(),
},
},
};
}
function formatDistribution(summary) {
return [
`n=${summary.count}`,
`p50=${formatSeconds(summary.p50)}`,
`p90=${formatSeconds(summary.p90)}`,
`p95=${formatSeconds(summary.p95)}`,
`max=${formatSeconds(summary.max)}`,
].join(" ");
}
function formatDelta(value) {
if (value === null) {
return "";
}
return `${value > 0 ? "+" : ""}${formatSeconds(value)}`;
}
function formatPercent(value) {
return value === null ? "" : `${(value * 100).toFixed(1)}%`;
}
function printTrendCohort(name, cohort) {
const outcomes = cohort.outcomes;
console.log(`\n${name}`);
console.log(
[
`runs=${outcomes.total}`,
`success=${outcomes.success}`,
`failure=${outcomes.failure}`,
`timed-out=${outcomes.timedOut}`,
`startup-failure=${outcomes.startupFailure}`,
`action-required=${outcomes.actionRequired}`,
`neutral=${outcomes.neutral}`,
`skipped=${outcomes.skipped}`,
`stale=${outcomes.stale}`,
`cancelled=${outcomes.cancelled}`,
`queued=${outcomes.queued}`,
`pending=${outcomes.pending}`,
`in-progress=${outcomes.inProgress}`,
`other=${outcomes.other}`,
`pass=${formatPercent(outcomes.nonCancelledPassRate)}`,
`cancelled-rate=${formatPercent(outcomes.cancellationRate)}`,
].join(" "),
);
console.log(
`successful wall ${formatDistribution(cohort.runMetrics.successfulWallSeconds)}`,
);
console.log(
`workflow admission ${formatDistribution(cohort.runMetrics.workflowAdmissionSeconds)}`,
);
console.log(
`dependency gating ${formatDistribution(cohort.jobMetrics.dependencyGatedSeconds)}`,
);
console.log(`runner queue ${formatDistribution(cohort.jobMetrics.runnerQueueSeconds)}`);
console.log(`job execution ${formatDistribution(cohort.jobMetrics.executionSeconds)}`);
console.log(
`detail sample ${cohort.samples.detailedSuccessfulRuns}/${cohort.samples.successfulRuns} successful runs, ${cohort.samples.timedJobs} jobs`,
);
}
function printTrendReport(report) {
const { baseline, comparison, prior } = report.cohorts;
console.log(
`CI trend: ${report.options.trendHours}h baseline; latest ${report.options.compareHours}h vs prior ${report.options.compareHours}h`,
);
console.log(
`API requests=${report.apiRequests.total} (run-list=${report.apiRequests.runList}, jobs=${report.apiRequests.jobs}); detailed=${report.sampling.detailedSuccessfulRuns}/${report.sampling.eligibleSuccessfulRuns} successful runs`,
);
printTrendCohort("Baseline", baseline);
printTrendCohort("Prior comparison window", prior);
printTrendCohort("Latest comparison window", comparison);
console.log("\nLatest minus prior");
console.log(
[
`wall-p50=${formatDelta(report.changes.successfulWallP50Seconds)}`,
`wall-p90=${formatDelta(report.changes.successfulWallP90Seconds)}`,
`admission-p95=${formatDelta(report.changes.workflowAdmissionP95Seconds)}`,
`queue-p95=${formatDelta(report.changes.runnerQueueP95Seconds)}`,
`execution-p90=${formatDelta(report.changes.executionP90Seconds)}`,
].join(" "),
);
if (comparison.criticalOwners.length > 0) {
console.log("\nLatest critical-path owners");
for (const owner of comparison.criticalOwners.slice(0, 15)) {
console.log(`${String(owner.name).padEnd(56)} ${owner.runs} run(s)`);
}
}
const timedJobs = report.jobs.filter((job) => job.comparison.executionSeconds.count > 0);
if (timedJobs.length > 0) {
console.log("\nLatest job execution p90");
for (const job of timedJobs.slice(0, 15)) {
console.log(
`${String(job.name).padEnd(56)} latest=${formatSeconds(job.comparison.executionSeconds.p90).padStart(6)} prior=${formatSeconds(job.prior.executionSeconds.p90).padStart(6)}`,
);
}
}
}
function printSection(title, jobs, metric) {
console.log(title);
for (const job of jobs) {
@@ -378,9 +878,17 @@ function printSection(title, jobs, metric) {
* Parses CI run timing CLI arguments.
*/
export function parseRunTimingArgs(args) {
let compareHours = DEFAULT_TREND_COMPARE_HOURS;
let compareHoursSpecified = false;
let detailRuns = DEFAULT_TREND_DETAIL_RUNS;
let detailRunsSpecified = false;
let explicitRunId;
let json = false;
let limit = 15;
let limitSpecified = false;
let outputPath = null;
let recentLimit = null;
let trendHours = null;
let useLatestMain = false;
for (let index = 0; index < args.length; index += 1) {
@@ -392,9 +900,14 @@ export function parseRunTimingArgs(args) {
useLatestMain = true;
continue;
}
if (arg === "--json") {
json = true;
continue;
}
const limitOption = consumePositiveIntFlag(args, index, "--limit");
if (limitOption) {
limit = limitOption.value;
limitSpecified = true;
index = limitOption.nextIndex;
continue;
}
@@ -404,6 +917,32 @@ export function parseRunTimingArgs(args) {
index = recentOption.nextIndex;
continue;
}
const trendOption = consumePositiveIntFlag(args, index, "--trend-hours");
if (trendOption) {
trendHours = trendOption.value;
index = trendOption.nextIndex;
continue;
}
const compareOption = consumePositiveIntFlag(args, index, "--compare-hours");
if (compareOption) {
compareHours = compareOption.value;
compareHoursSpecified = true;
index = compareOption.nextIndex;
continue;
}
const detailOption = consumePositiveIntFlag(args, index, "--detail-runs");
if (detailOption) {
detailRuns = detailOption.value;
detailRunsSpecified = true;
index = detailOption.nextIndex;
continue;
}
const outputOption = consumeStringFlag(args, index, "--output");
if (outputOption) {
outputPath = outputOption.value;
index = outputOption.nextIndex;
continue;
}
if (arg.startsWith("-")) {
throw new Error(`Unknown CI run timing option: ${arg}`);
}
@@ -413,10 +952,32 @@ export function parseRunTimingArgs(args) {
explicitRunId = arg;
}
if (recentLimit !== null && (explicitRunId || useLatestMain)) {
throw new Error("--recent cannot be combined with a run id or --latest-main");
}
if (explicitRunId && useLatestMain) {
throw new Error("A run id cannot be combined with --latest-main");
}
if (trendHours !== null) {
if (explicitRunId || useLatestMain || recentLimit !== null || limitSpecified) {
throw new Error("--trend-hours cannot be combined with single-run or --recent options");
}
if (trendHours < compareHours * 2) {
throw new Error("--trend-hours must cover at least two --compare-hours windows");
}
} else if (compareHoursSpecified || detailRunsSpecified || json || outputPath !== null) {
throw new Error("--compare-hours, --detail-runs, --json, and --output require --trend-hours");
}
return {
compareHours,
detailRuns,
explicitRunId,
json,
limit,
outputPath,
recentLimit,
trendHours,
useLatestMain,
};
}
@@ -443,10 +1004,138 @@ function consumePositiveIntFlag(args, index, flag) {
};
}
async function main() {
const { explicitRunId, limit, recentLimit, useLatestMain } = parseRunTimingArgs(
process.argv.slice(2),
function consumeStringFlag(args, index, flag) {
const arg = args[index];
const inlinePrefix = `${flag}=`;
if (arg.startsWith(inlinePrefix)) {
const value = arg.slice(inlinePrefix.length);
if (!value) {
throw new Error(`${flag} requires a value`);
}
return { nextIndex: index, value };
}
if (arg !== flag) {
return null;
}
const value = args[index + 1];
if (!value || value.startsWith("-")) {
throw new Error(`${flag} requires a value`);
}
return { nextIndex: index + 1, value };
}
function selectTrendDetailCandidates(runs, generatedAtMs, compareDurationMs, limit) {
const comparisonFromMs = generatedAtMs - compareDurationMs;
const priorFromMs = comparisonFromMs - compareDurationMs;
const successfulRuns = runs.filter(
(run) => run.status === "completed" && run.conclusion === "success",
);
const comparison = successfulRuns.filter(
(run) => (parseTime(run.createdAt) ?? 0) >= comparisonFromMs,
);
const prior = successfulRuns.filter((run) => {
const createdAt = parseTime(run.createdAt) ?? 0;
return createdAt >= priorFromMs && createdAt < comparisonFromMs;
});
const older = successfulRuns.filter((run) => (parseTime(run.createdAt) ?? 0) < priorFromMs);
const selected = [];
let comparisonIndex = 0;
let priorIndex = 0;
while (
selected.length < limit &&
(comparisonIndex < comparison.length || priorIndex < prior.length)
) {
if (comparisonIndex < comparison.length && selected.length < limit) {
selected.push(comparison[comparisonIndex]);
comparisonIndex += 1;
}
if (priorIndex < prior.length && selected.length < limit) {
selected.push(prior[priorIndex]);
priorIndex += 1;
}
}
return [
...selected,
...comparison.slice(comparisonIndex),
...prior.slice(priorIndex),
...older,
].slice(0, limit);
}
function runTrendReport(options) {
const generatedAtMs = Date.now();
const trendDurationMs = options.trendHours * 60 * 60 * 1000;
const compareDurationMs = options.compareHours * 60 * 60 * 1000;
const listed = listTrendCiRuns(generatedAtMs - trendDurationMs);
const eligibleRuns = listed.runs.filter(
(run) => run.status === "completed" && run.conclusion === "success",
);
const detailCandidates = selectTrendDetailCandidates(
listed.runs,
generatedAtMs,
compareDurationMs,
options.detailRuns,
);
console.error(
`[ci-timings] loading job details for ${detailCandidates.length}/${eligibleRuns.length} successful runs; expect at least ${detailCandidates.length} job API requests`,
);
const detailsByRun = new Map();
let jobsRequestCount = 0;
for (const run of detailCandidates) {
const loaded = loadRunJobs(run.databaseId, run.runAttempt);
jobsRequestCount += loaded.requestCount;
detailsByRun.set(run.databaseId, loaded.jobs);
}
const runs = listed.runs.map((run) =>
detailsByRun.has(run.databaseId) ? { ...run, jobs: detailsByRun.get(run.databaseId) } : run,
);
const summary = summarizeTrendTimings(runs, {
compareDurationMs,
generatedAtMs,
trendDurationMs,
});
const repository = process.env.GITHUB_REPOSITORY || DEFAULT_GITHUB_REPOSITORY;
return {
apiRequests: {
jobs: jobsRequestCount,
runList: listed.requestCount,
total: listed.requestCount + jobsRequestCount,
},
generatedAt: new Date(generatedAtMs).toISOString(),
options: {
compareHours: options.compareHours,
detailRuns: options.detailRuns,
trendHours: options.trendHours,
},
repository,
sampling: {
detailedSuccessfulRuns: detailCandidates.length,
eligibleSuccessfulRuns: eligibleRuns.length,
},
...summary,
};
}
async function main() {
const options = parseRunTimingArgs(process.argv.slice(2));
const { explicitRunId, limit, recentLimit, useLatestMain } = options;
if (options.trendHours !== null) {
const report = runTrendReport(options);
const reportJson = `${JSON.stringify(report, null, 2)}\n`;
if (options.outputPath) {
mkdirSync(path.dirname(options.outputPath), { recursive: true });
writeFileSync(options.outputPath, reportJson);
}
if (options.json) {
process.stdout.write(reportJson);
} else {
printTrendReport(report);
if (options.outputPath) {
console.log(`\nJSON report: ${options.outputPath}`);
}
}
return;
}
if (recentLimit !== null) {
for (const run of listRecentSuccessfulCiRuns(recentLimit)) {
const summary = summarizeJobs(loadRun(run.databaseId));
+4 -1
View File
@@ -296,7 +296,10 @@ const COMPACT_GROUP_SECONDS_HINTS = new Map<string, number>([
// Fork-per-file isolation parallelizes poorly on 4 vCPU; keep it on the
// 8 vCPU class, where it still runs a measured ~90s under fleet load.
["core-unit-fast-isolated", 90],
["core-unit-src-security", 205],
// In 35 green main runs on 2026-08-11/12, compact large jobs owned the
// critical tail 15 times and reached p90=457s. This group's former 205s
// hint repeatedly packed another 58s of serial work beside that tail.
["core-unit-src-security", 295],
["core-unit-support", 17],
]);
// Advisory per-file wall-clock hints (seconds) for stripe balancing, measured