fix: preserve branch follower stage intervals

This commit is contained in:
Codex
2026-07-04 16:13:12 +00:00
parent 1cd7846daf
commit d9dab2da89
7 changed files with 224 additions and 128 deletions
+6 -103
View File
@@ -37,6 +37,7 @@ import { buildCicdHelp } from "./cicd-help";
import { attachReconcileTimeline, compactReconcileTimeline, finishReconcileStep, finishReconcileTimeline, startReconcileStep, startReconcileTimeline } from "./cicd-reconcile-timeline";
import { orderFollowersForControllerCloseout, shouldYieldAfterAutomaticTrigger } from "./cicd-reconcile-scheduler";
import { buildFollowerTimings, compactListTimings, compactTimings, storedFollowerTimingsForStatus, timingPerformanceSummary } from "./cicd-timings";
import { timingAttributionSummary } from "./cicd-timing-attribution";
import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerGate, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, TriggerResult } from "./cicd-types";
import {
arrayField,
@@ -1843,6 +1844,8 @@ async function readAdapterStatus(registry: BranchFollowerRegistry, follower: Fol
const sourceSyncDetail = sourceSync === null || sourceSync.result.ok ? null : redactText(tailText(sourceSync.result.conditionMessage ?? sourceSync.result.logsTail ?? "unknown", 800));
const sourceSyncError = sourceSyncDetail === null ? null : `native source sync failed: ${sourceSyncDetail}`;
const bundle = readNativeObjectBundle(registry, follower, options, remainingSeconds(startedAt, timeoutSeconds), runKubeScript);
const bundleFinishedMs = Date.now();
const bundleStartedMs = Math.max(startedAt, bundleFinishedMs - Math.max(0, bundle.elapsedMs));
const observedSha = sourceSyncError === null ? stringOrNull(bundle.source?.commit) : null;
const runtimeTargetSha = runtimeTargetShaFromWorkloads(follower.nativeStatus.runtime, bundle.workloads);
const pipelineRunName = stringOrNull(asOptionalRecord(bundle.pipelineRun?.metadata)?.name) ?? expectedPipelineRunName(follower, observedSha);
@@ -1902,7 +1905,7 @@ async function readAdapterStatus(registry: BranchFollowerRegistry, follower: Fol
planArtifacts: bundle.planArtifacts,
argo: nativeArgoSummary(bundle.argoApplication),
runtime: nativeRuntimeSummary(follower.nativeStatus.runtime, bundle.workloads, observedSha),
timings: { statusRead: { elapsedMs: bundle.elapsedMs, budgetSeconds: timeoutSeconds } },
timings: { statusRead: { elapsedMs: bundle.elapsedMs, budgetSeconds: timeoutSeconds, startedAt: new Date(bundleStartedMs).toISOString(), finishedAt: new Date(bundleFinishedMs).toISOString() } },
errors,
statusAuthority: "k8s-native",
parsedDownstreamCliOutput: false,
@@ -2632,9 +2635,11 @@ function nativeGateTimingSummary(payload: Record<string, unknown> | null, timing
statusReadSeconds: secondsFromMsValue(numberOrNull(statusRead?.elapsedMs)),
gitMirrorSyncSeconds: secondsFromMsValue(numberOrNull(sourceSync?.elapsedMs)),
pipelineRunSeconds: numberOrNull(tekton?.durationSeconds),
pipelineRunName: stringOrNull(tekton?.name),
pipelineRunStartedAt: stringOrNull(tekton?.startTime),
pipelineRunFinishedAt: stringOrNull(tekton?.completionTime),
argoOperationSeconds: numberOrNull(argo?.operationDurationSeconds),
argoApplication: stringOrNull(argo?.name),
argoOperationStartedAt: stringOrNull(argo?.operationStartedAt),
argoOperationFinishedAt: stringOrNull(argo?.operationFinishedAt),
argoIncludedInStoredTotal: argoStage?.seconds !== null && argoStage?.source === "argocd",
@@ -2644,108 +2649,6 @@ function nativeGateTimingSummary(payload: Record<string, unknown> | null, timing
};
}
function timingAttributionSummary(timings: FollowerState["timings"], nativeGate: Record<string, unknown>): Record<string, unknown> {
const totalStartedMs = timestampMs(timings.startedAt);
const totalFinishedMs = timestampMs(timings.finishedAt);
const totalSeconds = timings.totalSeconds;
if (totalSeconds === null || totalStartedMs === null || totalFinishedMs === null || totalFinishedMs < totalStartedMs) {
return {
status: "unknown",
source: "stored-total-vs-native-intervals",
totalSeconds,
knownIntervalCoverageSeconds: null,
unknownWallClockSeconds: null,
reason: "stored total range is missing or invalid; old wall-clock attribution cannot be reconstructed",
};
}
const intervals = [
timingIntervalOverlap("pipeline", "tekton", nativeGate.pipelineRunStartedAt, nativeGate.pipelineRunFinishedAt, totalStartedMs, totalFinishedMs),
timingIntervalOverlap("argo", "argocd", nativeGate.argoOperationStartedAt, nativeGate.argoOperationFinishedAt, totalStartedMs, totalFinishedMs),
].filter((item): item is Record<string, unknown> => item !== null);
const coverageSeconds = mergedIntervalCoverageSeconds(intervals);
const unknownSeconds = roundSeconds(Math.max(0, totalSeconds - coverageSeconds));
const missingHistoricalIntervals = timings.stages.some((stage) => stage.seconds !== null);
return {
status: unknownSeconds > 0 ? "partial" : "covered",
source: "stored-total-vs-native-intervals",
totalSeconds,
totalStartedAt: timings.startedAt,
totalFinishedAt: timings.finishedAt,
knownIntervalCoverageSeconds: coverageSeconds,
unknownWallClockSeconds: unknownSeconds,
intervalCount: intervals.length,
intervals: intervals.map(({ startMs: _startMs, endMs: _endMs, overlapStartMs: _overlapStartMs, overlapEndMs: _overlapEndMs, ...item }) => item),
reason: unknownSeconds > 0
? missingHistoricalIntervals
? "stored state lacks historical per-stage intervals for the remaining wall-clock; do not infer wait/idle from current native objects"
: "no stored historical native intervals overlap the total range; old wall-clock attribution cannot be reconstructed"
: null,
};
}
function timingIntervalOverlap(stage: string, source: string, startedAtValue: unknown, finishedAtValue: unknown, totalStartedMs: number, totalFinishedMs: number): Record<string, unknown> | null {
const startedAt = stringOrNull(startedAtValue);
const finishedAt = stringOrNull(finishedAtValue);
const startMs = timestampMs(startedAt);
const endMs = timestampMs(finishedAt);
if (startMs === null || endMs === null || endMs < startMs) return null;
const overlapStartMs = Math.max(startMs, totalStartedMs);
const overlapEndMs = Math.min(endMs, totalFinishedMs);
const overlapSeconds = overlapEndMs > overlapStartMs ? roundSeconds((overlapEndMs - overlapStartMs) / 1000) : 0;
return {
stage,
source,
startedAt,
finishedAt,
overlapSeconds,
inStoredTotal: overlapSeconds > 0,
startMs,
endMs,
overlapStartMs,
overlapEndMs,
};
}
function mergedIntervalCoverageSeconds(intervals: Record<string, unknown>[]): number {
const ranges = intervals
.map((item) => ({
startMs: numberOrNull(item.overlapStartMs),
endMs: numberOrNull(item.overlapEndMs),
overlapSeconds: numberOrNull(item.overlapSeconds),
}))
.filter((item): item is { startMs: number; endMs: number; overlapSeconds: number } => item.startMs !== null && item.endMs !== null && item.overlapSeconds !== null && item.overlapSeconds > 0)
.sort((a, b) => a.startMs - b.startMs);
let coveredMs = 0;
let currentStart: number | null = null;
let currentEnd: number | null = null;
for (const range of ranges) {
if (currentStart === null || currentEnd === null) {
currentStart = range.startMs;
currentEnd = range.endMs;
continue;
}
if (range.startMs <= currentEnd) {
currentEnd = Math.max(currentEnd, range.endMs);
continue;
}
coveredMs += currentEnd - currentStart;
currentStart = range.startMs;
currentEnd = range.endMs;
}
if (currentStart !== null && currentEnd !== null) coveredMs += currentEnd - currentStart;
return roundSeconds(coveredMs / 1000);
}
function timestampMs(value: string | null): number | null {
if (value === null) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
function roundSeconds(value: number): number {
return Math.round(value * 10) / 10;
}
function followerNextCommands(follower: FollowerSpec): Record<string, string> {
const next: Record<string, string> = {
status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`,