From 303d75c6ffb842fce5d8e8d5e67850e6a44d246a Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 4 Jul 2026 15:29:19 +0000 Subject: [PATCH] fix: expose branch follower timing attribution gap --- scripts/src/cicd-branch-follower.ts | 108 +++++++++++++++++++++++++++- scripts/src/cicd-render.ts | 16 +++++ 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/scripts/src/cicd-branch-follower.ts b/scripts/src/cicd-branch-follower.ts index a318ba29..4e3fbedd 100644 --- a/scripts/src/cicd-branch-follower.ts +++ b/scripts/src/cicd-branch-follower.ts @@ -2589,6 +2589,7 @@ function statusTimingContext( const fallbackPayload = asOptionalRecord(asOptionalRecord(fallbackStored.command)?.payload); const livePayload = asOptionalRecord(live?.payload); const nativePayload = firstRecord(livePayload, storedPayload, fallbackPayload); + const nativeGateTiming = nativeGateTimingSummary(nativePayload, timings); return { storedTiming: { totalSeconds: timings.totalSeconds, @@ -2606,7 +2607,8 @@ function statusTimingContext( includedInStoredTotal: false, note: liveRequested ? "live/status refresh is reported separately and must not be added to stored total" : null, }, - nativeGateTiming: nativeGateTimingSummary(nativePayload, timings), + nativeGateTiming, + timingAttribution: timingAttributionSummary(timings, nativeGateTiming), }; } @@ -2630,6 +2632,8 @@ function nativeGateTimingSummary(payload: Record | null, timing statusReadSeconds: secondsFromMsValue(numberOrNull(statusRead?.elapsedMs)), gitMirrorSyncSeconds: secondsFromMsValue(numberOrNull(sourceSync?.elapsedMs)), pipelineRunSeconds: numberOrNull(tekton?.durationSeconds), + pipelineRunStartedAt: stringOrNull(tekton?.startTime), + pipelineRunFinishedAt: stringOrNull(tekton?.completionTime), argoOperationSeconds: numberOrNull(argo?.operationDurationSeconds), argoOperationStartedAt: stringOrNull(argo?.operationStartedAt), argoOperationFinishedAt: stringOrNull(argo?.operationFinishedAt), @@ -2640,6 +2644,108 @@ function nativeGateTimingSummary(payload: Record | null, timing }; } +function timingAttributionSummary(timings: FollowerState["timings"], nativeGate: Record): Record { + 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 => 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 | 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[]): 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 { const next: Record = { status: `bun scripts/cli.ts cicd branch-follower status --follower ${follower.id}`, diff --git a/scripts/src/cicd-render.ts b/scripts/src/cicd-render.ts index 29b1d98b..3273da76 100644 --- a/scripts/src/cicd-render.ts +++ b/scripts/src/cicd-render.ts @@ -271,6 +271,7 @@ function timingContextRowsForFollower(item: Record): unknown[][ const stored = asOptionalRecord(context.storedTiming); const liveRefresh = asOptionalRecord(context.liveRefresh); const nativeGate = asOptionalRecord(context.nativeGateTiming); + const attribution = asOptionalRecord(context.timingAttribution); const rows: unknown[][] = []; if (stored !== null) { rows.push([ @@ -309,6 +310,21 @@ function timingContextRowsForFollower(item: Record): unknown[][ nativeGate.argoIncludedInStoredTotal === true ? "argo" : "no", ]); } + if (attribution !== null) { + const detail = [ + `known=${formatSeconds(numberOrNull(attribution.knownIntervalCoverageSeconds))}`, + `unknown=${formatSeconds(numberOrNull(attribution.unknownWallClockSeconds))}`, + ].join(" "); + rows.push([ + item.id, + "attribution", + stringOrNull(attribution.source) ?? "-", + detail, + stringOrNull(attribution.totalStartedAt) ?? "-", + stringOrNull(attribution.totalFinishedAt) ?? "-", + stringOrNull(attribution.status) ?? "-", + ]); + } return rows; }