diff --git a/scripts/src/cicd-branch-follower.ts b/scripts/src/cicd-branch-follower.ts index 7e8114a2..d63bd737 100644 --- a/scripts/src/cicd-branch-follower.ts +++ b/scripts/src/cicd-branch-follower.ts @@ -35,6 +35,7 @@ import { runBranchFollowerTaskRunDrillDown } from "./cicd-taskrun-drilldown"; import { runBranchFollowerJobDrillDown, runBranchFollowerRuntimeDrillDown } from "./cicd-job-runtime-drilldown"; import { runBranchFollowerGate } from "./cicd-gates"; import { attachReconcileTimeline, compactReconcileTimeline, finishReconcileStep, finishReconcileTimeline, startReconcileStep, startReconcileTimeline } from "./cicd-reconcile-timeline"; +import { orderFollowersForControllerCloseout, shouldYieldAfterAutomaticTrigger } from "./cicd-reconcile-scheduler"; import type { AdapterSummary, BranchFollowerAction, BranchFollowerDebugStep, BranchFollowerGate, BranchFollowerPhase, BranchFollowerRegistry, ControllerSpec, FollowerSpec, FollowerState, K8sFollowerStateRead, K8sStateRead, NativeCloseoutWaitResult, NativeK8sJobResult, NativeStatusSpec, NativeWorkloadSpec, OutputMode, ParsedOptions, StageTiming, TriggerResult } from "./cicd-types"; import { arrayField, @@ -707,10 +708,13 @@ async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions) const stateReadStep = startReconcileStep(reconcileTimeline, "*", "state-read"); const previous = readK8sState(registry, options); finishReconcileStep(stateReadStep, { status: previous.ok ? "ok" : "degraded", object: registry.controller.stateConfigMapName, reason: previous.errors.join("; ") }); + const scheduled = orderFollowersForControllerCloseout(selected, previous.stateByFollower); + reconcileTimeline.followerCount = scheduled.length; + reconcileTimeline.followers = scheduled.map((follower) => follower.id).slice(0, 8); const results: FollowerState[] = []; const stateWriteWarnings: string[] = []; const stateWrites: Record[] = []; - for (const follower of selected) { + for (const follower of scheduled) { const oldState = previous.stateByFollower[follower.id] ?? {}; const statusReadStep = startReconcileStep(reconcileTimeline, follower.id, "status-read"); const live = await readAdapterStatus(registry, follower, options); @@ -732,6 +736,10 @@ async function runOnce(registry: BranchFollowerRegistry, options: ParsedOptions) } } results.push(state); + if (shouldYieldAfterAutomaticTrigger(options, state)) { + stateWriteWarnings.push(`controller yielded after triggering ${follower.id}; remaining followers will be reconciled by the next loop`); + break; + } } finishReconcileTimeline(reconcileTimeline); return { diff --git a/scripts/src/cicd-reconcile-scheduler.ts b/scripts/src/cicd-reconcile-scheduler.ts new file mode 100644 index 00000000..d8b0d50f --- /dev/null +++ b/scripts/src/cicd-reconcile-scheduler.ts @@ -0,0 +1,30 @@ +// SPEC: PJ2026-01060703 CI/CD branch follower controller scheduling. +// Responsibility: keep automatic closeout observations ahead of unrelated followers. + +import type { FollowerSpec, FollowerState, ParsedOptions } from "./cicd-types"; + +const CLOSEOUT_PHASES = new Set(["Triggering", "ClosingOut"]); + +export function orderFollowersForControllerCloseout( + followers: FollowerSpec[], + stateByFollower: Record>, +): FollowerSpec[] { + return followers + .map((follower, index) => ({ follower, index, priority: followerPriority(stateByFollower[follower.id]) })) + .sort((left, right) => left.priority - right.priority || left.index - right.index) + .map((item) => item.follower); +} + +export function shouldYieldAfterAutomaticTrigger(options: ParsedOptions, state: FollowerState): boolean { + if (!options.inCluster || !options.confirm || options.wait || options.dryRun) return false; + return state.phase === "Triggering" && state.inFlightJob !== null; +} + +function followerPriority(state: Record | undefined): number { + if (state === undefined) return 2; + const phase = typeof state.phase === "string" ? state.phase : null; + if (phase !== null && CLOSEOUT_PHASES.has(phase)) return 0; + if (typeof state.inFlightJob === "string" && state.inFlightJob.trim() !== "") return 0; + if (phase === "PendingTrigger" || phase === "Superseded") return 1; + return 2; +}