From cf9454d274ebd1c0dfea7e1d98cd4a5edbdbc990 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 17:21:15 +0000 Subject: [PATCH] fix: preserve follower timing at state write --- scripts/native/cicd/read-follower-state.mjs | 16 +++++++ scripts/src/cicd.ts | 47 ++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 scripts/native/cicd/read-follower-state.mjs diff --git a/scripts/native/cicd/read-follower-state.mjs b/scripts/native/cicd/read-follower-state.mjs new file mode 100644 index 00000000..6928d65d --- /dev/null +++ b/scripts/native/cicd/read-follower-state.mjs @@ -0,0 +1,16 @@ +import { execFileSync } from "node:child_process"; + +const namespace = process.env.NAMESPACE || ""; +const configMap = process.env.CONFIGMAP || ""; +const followerId = process.env.FOLLOWER_ID || ""; + +try { + const raw = execFileSync("kubectl", ["-n", namespace, "get", "configmap", configMap, "-o", "json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const data = JSON.parse(raw).data || {}; + process.stdout.write(data[followerId] || "{}"); +} catch { + process.stdout.write("{}"); +} diff --git a/scripts/src/cicd.ts b/scripts/src/cicd.ts index 33d5f873..0b83c8cf 100644 --- a/scripts/src/cicd.ts +++ b/scripts/src/cicd.ts @@ -2399,7 +2399,8 @@ function roundSeconds(value: number): number { } function writeFollowerState(registry: BranchFollowerRegistry, state: FollowerState, options: ParsedOptions): CommandResult { - const json = JSON.stringify(compactFollowerStateForConfigMap(state)); + const stateForWrite = preserveWriteTimeTotalTiming(registry, state, options); + const json = JSON.stringify(compactFollowerStateForConfigMap(stateForWrite)); const dataPatch = JSON.stringify({ data: { [state.id]: json, _updatedAt: new Date().toISOString(), _specRef: SPEC_REF } }); if (options.inCluster) { const patchBase64 = Buffer.from(dataPatch, "utf8").toString("base64"); @@ -2464,6 +2465,50 @@ function writeFollowerState(registry: BranchFollowerRegistry, state: FollowerSta return runKubeScript(registry, options, script, "", 10_000); } +function preserveWriteTimeTotalTiming(registry: BranchFollowerRegistry, state: FollowerState, options: ParsedOptions): FollowerState { + if (state.timings.totalSeconds !== null) return state; + const existing = readExistingFollowerStateForWrite(registry, state.id, options); + const existingTimings = asOptionalRecord(existing?.timings); + if (existingTimings === null) return state; + const sourceCommit = stringOrNull(existingTimings.sourceCommit); + if (sourceCommit === null || sourceCommit !== state.source.observedSha) return state; + const startedAt = stringOrNull(existingTimings.startedAt); + const existingFinishedAt = stringOrNull(existingTimings.finishedAt); + const finishedAt = existingFinishedAt ?? (terminalPhase(state.phase) ? new Date().toISOString() : null); + const seconds = totalSecondsFromRange(startedAt, finishedAt) ?? numberOrNull(existingTimings.totalSeconds); + if (seconds === null) return state; + return { + ...state, + timings: { + ...state.timings, + totalSeconds: seconds, + totalStatus: terminalPhase(state.phase) ? "completed" : state.phase.toLowerCase(), + totalSource: stringOrNull(existingTimings.totalSource) ?? "stored-state", + sourceCommit, + startedAt, + finishedAt, + overBudget: seconds > state.timings.budgetSeconds, + }, + }; +} + +function readExistingFollowerStateForWrite(registry: BranchFollowerRegistry, followerId: string, options: ParsedOptions): Record | null { + const script = [ + "set -eu", + `NAMESPACE=${shQuote(registry.controller.namespace)}`, + `CONFIGMAP=${shQuote(registry.controller.stateConfigMapName)}`, + `FOLLOWER_ID=${shQuote(followerId)}`, + "export NAMESPACE CONFIGMAP FOLLOWER_ID", + "tmpdir=$(mktemp -d)", + "cleanup() { rm -rf \"$tmpdir\"; }", + "trap cleanup EXIT INT TERM", + nativeCicdScriptLoadShell(["read-follower-state.mjs"]), + "node \"$tmpdir/read-follower-state.mjs\"", + ].join("\n"); + const result = runKubeScript(registry, options, script, "", 10_000); + return result.exitCode === 0 ? parseJsonObject(result.stdout) : null; +} + function runControllerReconcileJob(registry: BranchFollowerRegistry, options: ParsedOptions, mode: { dryRun: boolean; wait: boolean; recordState: boolean }): Record { const timeoutSeconds = options.timeoutSeconds ?? registry.controller.budgets.runOnceSeconds; const jobName = `${registry.controller.deploymentName}-once-${Date.now().toString(36)}`.slice(0, 63);