Files
pikasTech-agentrun/src/mgr/runner-retention.ts
T
2026-07-12 18:21:48 +02:00

1224 lines
58 KiB
TypeScript

import { AgentRunError } from "../common/errors.js";
import { redactJson, redactText } from "../common/redaction.js";
import type { CommandRecord, JsonRecord, JsonValue, RunRecord } from "../common/types.js";
import { nowIso, stableHash } from "../common/validation.js";
import { isTerminalCommandState, isTerminalRunStatus } from "./store.js";
import type { AgentRunStore } from "./store.js";
import { createKubernetesClient, type KubernetesClient, type KubernetesResource, type KubernetesTransportResult } from "./kubernetes-transport.js";
export interface RunnerRetentionOptions {
namespace: string;
maxRunners: number;
cleanupOrder: "oldest-inactive-last-active-first";
activeHeartbeatMaxAgeMs: number;
stalePendingMaxAgeMs: number;
matchLabels: Record<string, string>;
jobNamePrefixes: string[];
ageBasedCleanup: {
enabled: boolean;
maxAgeHours: number | null;
};
kubectlCommand?: string;
signal?: AbortSignal;
}
export interface RunnerRetentionSummary extends JsonRecord {
enabled: boolean;
namespace: string;
maxRunners: number;
stalePendingMaxAgeMs: number;
incomingRunnerCount: number;
liveRunnerCountBefore: number;
liveRunnerCountAfter: number;
runnerJobCountBefore: number;
nonTerminalRunnerPodCountBefore: number;
orphanNonTerminalRunnerPodCountBefore: number;
inactiveCandidateCount: number;
protectedActiveRunnerCount: number;
protectedActiveRunners: JsonRecord[];
selectedRunnerCount: number;
deletedRunnerJobCount: number;
deletedRunnerPodCount: number;
deletedAssociatedResourceCount: number;
repairedTerminalClaimCount: number;
activeRunRisk: boolean;
reason: string;
valuesPrinted: false;
}
export interface RunnerStalePendingReconcileSummary extends JsonRecord {
enabled: true;
action: "runner-stale-pending-reconcile";
observedAt: string;
namespace: string;
limit: number;
maxRunners: number;
stalePendingMaxAgeMs: number;
observedRunnerCount: number;
liveRunnerCountBefore: number;
liveRunnerCountAfter: number | null;
stalePendingCandidateCount: number;
protectedActiveRunnerCount: number;
selectedRunnerCount: number;
terminalizedRunCount: number;
terminalizedCommandCount: number;
deletedRunnerJobCount: number;
deletedRunnerPodCount: number;
deletedAssociatedResourceCount: number;
failedRunnerCount: number;
reason: string;
valuesPrinted: false;
}
type CandidateKind = "idle" | "terminal" | "inactive" | "stale-pending";
type RepairKind = "none" | "release-terminal-run-claim" | "release-terminal-command-claim" | "terminalize-stale-pending";
interface RunnerPodObservation extends JsonRecord {
name: string;
uid: string | null;
resourceVersion: string | null;
phase: string | null;
createdAt: string | null;
ageMs: number | null;
stale: boolean;
podStartTime: string | null;
ready: boolean;
runnerContainerState: "waiting" | "running" | "terminated" | "unknown";
runnerContainerLastState: "running" | "terminated" | "none";
runnerContainerStarted: boolean;
runnerContainerReady: boolean;
runnerContainerRestartCount: number;
waiting: JsonRecord[];
waitingEvidenceSources: string[];
eventFactsAvailable: boolean;
factsAvailable: boolean;
valuesPrinted: false;
}
interface K8sObject {
kind?: string;
metadata?: {
name?: string;
namespace?: string;
uid?: string;
resourceVersion?: string;
creationTimestamp?: string;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: Array<{ kind?: string; name?: string }>;
};
status?: JsonRecord;
involvedObject?: { kind?: string; name?: string };
reason?: string;
message?: string;
type?: string;
count?: number;
firstTimestamp?: string;
lastTimestamp?: string;
eventTime?: string;
series?: JsonRecord;
}
interface RunnerResourceEntry {
resourceKind: "job" | "pod";
name: string;
jobName: string;
resourceUid: string | null;
resourceVersion: string | null;
runId: string | null;
commandId: string | null;
runnerId: string | null;
createdAtMs: number;
terminal: boolean;
podPhase: string | null;
podObservations: RunnerPodObservation[];
protectedActive: boolean;
protectedReason: string | null;
candidateKind: CandidateKind;
classificationReason: string;
repairKind: RepairKind;
lastActiveAtMs: number;
dbFactsFingerprint: string | null;
}
interface Snapshot {
jobs: RunnerResourceEntry[];
pods: RunnerResourceEntry[];
liveRunnerCount: number;
runnerJobCount: number;
nonTerminalRunnerPodCount: number;
orphanNonTerminalRunnerPodCount: number;
}
export async function enforceRunnerRetentionBeforeCreate(input: { store: AgentRunStore; options: RunnerRetentionOptions; incomingRunnerCount?: number }): Promise<RunnerRetentionSummary> {
const incomingRunnerCount = input.incomingRunnerCount ?? 1;
const before = await runnerSnapshot(input.store, input.options);
const targetBeforeCreate = Math.max(0, input.options.maxRunners - incomingRunnerCount);
const requiredDeleteCount = Math.max(0, before.liveRunnerCount - targetBeforeCreate);
const candidates = [...before.jobs, ...before.pods]
.filter((item) => !item.protectedActive)
.sort(compareCandidates);
const selected = candidates.slice(0, requiredDeleteCount);
const protectedActiveRunners = [...before.jobs, ...before.pods].filter((item) => item.protectedActive);
const protectedActiveRunnerCount = protectedActiveRunners.length;
const inactiveCandidates = boundedResourceSummaries(candidates);
const protectedSummaries = boundedResourceSummaries(protectedActiveRunners);
if (requiredDeleteCount > 0 && selected.length < requiredDeleteCount) {
throw new AgentRunError("infra-failed", "runner retention cannot safely make room for a new runner", {
httpStatus: 409,
details: {
reason: "runner-retention-no-safe-candidate",
namespace: input.options.namespace,
maxRunners: input.options.maxRunners,
stalePendingMaxAgeMs: input.options.stalePendingMaxAgeMs,
incomingRunnerCount,
liveRunnerCountBefore: before.liveRunnerCount,
requiredDeleteCount,
inactiveCandidateCount: candidates.length,
inactiveCandidates: inactiveCandidates.items,
inactiveCandidateOmittedCount: inactiveCandidates.omittedCount,
protectedActiveRunnerCount,
protectedActiveRunners: protectedSummaries.items,
protectedActiveRunnerOmittedCount: protectedSummaries.omittedCount,
activeRunRisk: protectedActiveRunnerCount > 0,
cleanupPolicy: cleanupPolicySummary(),
valuesPrinted: false,
},
});
}
let deletedRunnerJobCount = 0;
let deletedRunnerPodCount = 0;
let deletedAssociatedResourceCount = 0;
let repairedTerminalClaimCount = 0;
for (const item of selected) {
const retired = await retireSelectedRunner(input.store, input.options, item, `runner retention retired stale Kubernetes ${item.resourceKind}/${item.name}`);
deletedRunnerJobCount += retired.deletedRunnerJobCount;
deletedRunnerPodCount += retired.deletedRunnerPodCount;
repairedTerminalClaimCount += retired.repairedTerminalClaimCount;
if (item.resourceKind === "job") deletedAssociatedResourceCount += await deleteAssociatedResources(input.options, item);
}
const after = selected.length > 0 ? await runnerSnapshot(input.store, input.options) : before;
if (after.liveRunnerCount > targetBeforeCreate) {
throw new AgentRunError("infra-failed", "runner retention did not make enough room for a new runner", {
httpStatus: 409,
details: {
reason: "runner-retention-postcondition-failed",
namespace: input.options.namespace,
maxRunners: input.options.maxRunners,
incomingRunnerCount,
targetBeforeCreate,
liveRunnerCountBefore: before.liveRunnerCount,
liveRunnerCountAfter: after.liveRunnerCount,
selectedRunnerCount: selected.length,
deletedRunnerJobCount,
deletedRunnerPodCount,
deletedAssociatedResourceCount,
repairedTerminalClaimCount,
selected: selected.map(resourceSummary),
valuesPrinted: false,
},
});
}
return {
enabled: true,
namespace: input.options.namespace,
maxRunners: input.options.maxRunners,
stalePendingMaxAgeMs: input.options.stalePendingMaxAgeMs,
incomingRunnerCount,
liveRunnerCountBefore: before.liveRunnerCount,
liveRunnerCountAfter: after.liveRunnerCount,
runnerJobCountBefore: before.runnerJobCount,
nonTerminalRunnerPodCountBefore: before.nonTerminalRunnerPodCount,
orphanNonTerminalRunnerPodCountBefore: before.orphanNonTerminalRunnerPodCount,
inactiveCandidateCount: candidates.length,
inactiveCandidates: inactiveCandidates.items,
inactiveCandidateOmittedCount: inactiveCandidates.omittedCount,
protectedActiveRunnerCount,
protectedActiveRunners: protectedSummaries.items,
protectedActiveRunnerOmittedCount: protectedSummaries.omittedCount,
selectedRunnerCount: selected.length,
deletedRunnerJobCount,
deletedRunnerPodCount,
deletedAssociatedResourceCount,
repairedTerminalClaimCount,
activeRunRisk: protectedActiveRunnerCount > 0,
reason: requiredDeleteCount === 0 ? "within-limit" : "pre-create-retention",
cleanupPolicy: cleanupPolicySummary(),
selected: selected.map(resourceSummary),
valuesPrinted: false,
} satisfies RunnerRetentionSummary;
}
export async function reconcileStalePendingRunners(input: { store: AgentRunStore; options: RunnerRetentionOptions; limit: number }): Promise<RunnerStalePendingReconcileSummary> {
const limit = clampReconcileLimit(input.limit);
return await input.store.withRunnerCreateFence(input.options.namespace, async () => {
const observedAt = nowIso();
const before = await runnerSnapshot(input.store, input.options);
const resources = [...before.jobs, ...before.pods];
const stalePendingCandidates = resources
.filter((item) => !item.protectedActive && item.candidateKind === "stale-pending")
.sort(compareCandidates);
const protectedActiveRunners = resources.filter((item) => item.protectedActive);
const selected = stalePendingCandidates.slice(0, limit);
const outcomes: JsonRecord[] = [];
let terminalizedRunCount = 0;
let terminalizedCommandCount = 0;
let deletedRunnerJobCount = 0;
let deletedRunnerPodCount = 0;
let deletedAssociatedResourceCount = 0;
let failedRunnerCount = 0;
for (const item of selected) {
let retired: RetiredRunnerResource | null = null;
try {
retired = await retireSelectedRunner(input.store, input.options, item, `runner reconciler retired stale-pending Kubernetes ${item.resourceKind}/${item.name}`);
terminalizedRunCount += item.candidateKind === "stale-pending" ? 1 : 0;
terminalizedCommandCount += item.candidateKind === "stale-pending" ? 1 : 0;
deletedRunnerJobCount += retired.deletedRunnerJobCount;
deletedRunnerPodCount += retired.deletedRunnerPodCount;
const deletedAssociatedForRunner = item.resourceKind === "job" ? await deleteAssociatedResources(input.options, item) : 0;
deletedAssociatedResourceCount += deletedAssociatedForRunner;
outcomes.push({ ...resourceIdentitySummary(item), state: "retired", terminalized: item.candidateKind === "stale-pending", ...retired, deletedAssociatedResourceCount: deletedAssociatedForRunner, valuesPrinted: false });
} catch (error) {
failedRunnerCount++;
outcomes.push({ ...resourceIdentitySummary(item), state: retired ? "retired-associated-cleanup-failed" : "failed-closed", coreRetired: retired !== null, ...(retired ?? {}), error: retentionErrorSummary(error), valuesPrinted: false });
}
}
let after: Snapshot | null = before;
let postObservationError: JsonRecord | null = null;
if (selected.length > 0) {
try {
after = await runnerSnapshot(input.store, input.options);
} catch (error) {
after = null;
postObservationError = retentionErrorSummary(error);
}
}
const candidateSummaries = boundedResourceSummaries(stalePendingCandidates);
const protectedSummaries = boundedResourceSummaries(protectedActiveRunners);
const selectedSummaries = boundedResourceSummaries(selected);
const boundedOutcomes = boundedJsonRecords(outcomes);
return {
enabled: true,
action: "runner-stale-pending-reconcile",
observedAt,
namespace: input.options.namespace,
limit,
maxRunners: input.options.maxRunners,
stalePendingMaxAgeMs: input.options.stalePendingMaxAgeMs,
observedRunnerCount: resources.length,
liveRunnerCountBefore: before.liveRunnerCount,
liveRunnerCountAfter: after?.liveRunnerCount ?? null,
runnerJobCountBefore: before.runnerJobCount,
nonTerminalRunnerPodCountBefore: before.nonTerminalRunnerPodCount,
orphanNonTerminalRunnerPodCountBefore: before.orphanNonTerminalRunnerPodCount,
inactiveCandidateCount: resources.filter((item) => !item.protectedActive).length,
stalePendingCandidateCount: stalePendingCandidates.length,
stalePendingCandidates: candidateSummaries.items,
stalePendingCandidateOmittedCount: candidateSummaries.omittedCount,
protectedActiveRunnerCount: protectedActiveRunners.length,
protectedActiveRunners: protectedSummaries.items,
protectedActiveRunnerOmittedCount: protectedSummaries.omittedCount,
protectedReasonCounts: reasonCounts(protectedActiveRunners.map((item) => item.protectedReason ?? "unknown")),
selectedRunnerCount: selected.length,
selectedRunners: selectedSummaries.items,
selectedRunnerOmittedCount: selectedSummaries.omittedCount,
terminalizedRunCount,
terminalizedCommandCount,
deletedRunnerJobCount,
deletedRunnerPodCount,
deletedAssociatedResourceCount,
failedRunnerCount,
outcomes: boundedOutcomes.items,
outcomeOmittedCount: boundedOutcomes.omittedCount,
state: postObservationError || failedRunnerCount > 0 ? "partial" : "completed",
postObservationState: postObservationError ? "failed" : "completed",
postObservationError,
activeRunRisk: protectedActiveRunners.length > 0,
reason: postObservationError ? "stale-pending-post-observation-failed" : selected.length === 0 ? "no-stale-pending-candidate" : failedRunnerCount > 0 ? "stale-pending-reconcile-partial" : "stale-pending-reconciled",
cleanupPolicy: cleanupPolicySummary(),
valuesPrinted: false,
} satisfies RunnerStalePendingReconcileSummary;
});
}
async function runnerSnapshot(store: AgentRunStore, options: RunnerRetentionOptions): Promise<Snapshot> {
const observed = await Promise.allSettled([
kubernetesGetList(options, "jobs", labelSelector(options.matchLabels)),
kubernetesGetList(options, "pods", labelSelector(options.matchLabels)),
]);
const failure = observed.find((item): item is PromiseRejectedResult => item.status === "rejected");
if (failure) throw failure.reason;
const [jobObjects, podObjects] = observed.map((item) => (item as PromiseFulfilledResult<K8sObject[]>).value);
const matchingPods = podObjects.filter((item) => {
const name = objectName(item);
if (!name) return false;
return matchesJobPrefix(podJobName(item) ?? name, options.jobNamePrefixes);
});
const podsByJob = new Map<string, K8sObject[]>();
const eventsByPod = new Map<string, K8sObject[]>();
let eventFactsAvailable = true;
let eventObjects: K8sObject[] = [];
if (matchingPods.length > 0) {
try {
eventObjects = await kubernetesGetPodEvents(options);
} catch (error) {
if (options.signal?.aborted) throw error;
eventFactsAvailable = false;
}
}
for (const item of eventObjects) {
const podName = podEventName(item);
if (!podName) continue;
const current = eventsByPod.get(podName) ?? [];
current.push(item);
eventsByPod.set(podName, current);
}
for (const item of matchingPods) {
const name = objectName(item);
if (!name) continue;
const jobName = podJobName(item) ?? name;
const current = podsByJob.get(jobName) ?? [];
current.push(item);
podsByJob.set(jobName, current);
}
const jobs: RunnerResourceEntry[] = [];
for (const item of jobObjects) {
const name = objectName(item);
if (!name || !matchesJobPrefix(name, options.jobNamePrefixes)) continue;
jobs.push(await jobEntry(store, item, options, podsByJob.get(name) ?? [], eventsByPod, eventFactsAvailable));
}
const jobNames = new Set(jobs.map((item) => item.jobName));
const pods: RunnerResourceEntry[] = [];
let nonTerminalRunnerPodCount = 0;
let orphanNonTerminalRunnerPodCount = 0;
for (const item of matchingPods) {
const name = objectName(item);
if (!name) continue;
const jobName = podJobName(item) ?? name;
if (!matchesJobPrefix(jobName, options.jobNamePrefixes)) continue;
const terminal = podTerminal(item);
if (!terminal) nonTerminalRunnerPodCount++;
if (jobNames.has(jobName)) continue;
if (!terminal) orphanNonTerminalRunnerPodCount++;
pods.push(await podEntry(store, item, options, jobName, eventsByPod.get(name) ?? [], eventFactsAvailable));
}
return {
jobs,
pods,
liveRunnerCount: jobs.length + pods.filter((item) => !item.terminal).length,
runnerJobCount: jobs.length,
nonTerminalRunnerPodCount,
orphanNonTerminalRunnerPodCount,
};
}
async function jobEntry(store: AgentRunStore, item: K8sObject, options: RunnerRetentionOptions, pods: K8sObject[], eventsByPod: Map<string, K8sObject[]>, eventFactsAvailable: boolean): Promise<RunnerResourceEntry> {
const name = objectName(item) ?? "unknown";
const annotations = item.metadata?.annotations ?? {};
const podObservations = pods.map((pod) => observePod(pod, options.stalePendingMaxAgeMs, eventsByPod.get(objectName(pod) ?? "") ?? [], eventFactsAvailable));
const podIdentityMatch = pods.every((pod) => resourceIdentityMatches(annotations, pod.metadata?.annotations ?? {}));
const activity = await activityFor(store, {
runId: annotations["agentrun.pikastech.local/run-id"] ?? null,
commandId: annotations["agentrun.pikastech.local/command-id"] ?? null,
runnerId: annotations["agentrun.pikastech.local/runner-id"] ?? null,
terminal: jobTerminal(item),
createdAtMs: objectCreatedAtMs(item),
activeHeartbeatMaxAgeMs: options.activeHeartbeatMaxAgeMs,
stalePendingMaxAgeMs: options.stalePendingMaxAgeMs,
podObservations,
podIdentityMatch,
});
return {
resourceKind: "job",
name,
jobName: name,
resourceUid: item.metadata?.uid ?? null,
resourceVersion: item.metadata?.resourceVersion ?? null,
createdAtMs: objectCreatedAtMs(item),
terminal: jobTerminal(item),
podPhase: null,
podObservations,
...activity,
};
}
async function podEntry(store: AgentRunStore, item: K8sObject, options: RunnerRetentionOptions, jobName: string, events: K8sObject[], eventFactsAvailable: boolean): Promise<RunnerResourceEntry> {
const name = objectName(item) ?? "unknown";
const annotations = item.metadata?.annotations ?? {};
const podObservations = [observePod(item, options.stalePendingMaxAgeMs, events, eventFactsAvailable)];
const activity = await activityFor(store, {
runId: annotations["agentrun.pikastech.local/run-id"] ?? null,
commandId: annotations["agentrun.pikastech.local/command-id"] ?? null,
runnerId: annotations["agentrun.pikastech.local/runner-id"] ?? null,
terminal: podTerminal(item),
createdAtMs: objectCreatedAtMs(item),
activeHeartbeatMaxAgeMs: options.activeHeartbeatMaxAgeMs,
stalePendingMaxAgeMs: options.stalePendingMaxAgeMs,
podObservations,
podIdentityMatch: true,
});
return {
resourceKind: "pod",
name,
jobName,
resourceUid: item.metadata?.uid ?? null,
resourceVersion: item.metadata?.resourceVersion ?? null,
createdAtMs: objectCreatedAtMs(item),
terminal: podTerminal(item),
podPhase: stringPath(item, ["status", "phase"]),
podObservations,
...activity,
};
}
type ActivityFields = Pick<RunnerResourceEntry, "runId" | "commandId" | "runnerId" | "protectedActive" | "protectedReason" | "candidateKind" | "classificationReason" | "repairKind" | "lastActiveAtMs" | "dbFactsFingerprint">;
async function activityFor(store: AgentRunStore, input: {
runId: string | null;
commandId: string | null;
runnerId: string | null;
terminal: boolean;
createdAtMs: number;
activeHeartbeatMaxAgeMs: number;
stalePendingMaxAgeMs: number;
podObservations: RunnerPodObservation[];
podIdentityMatch: boolean;
}): Promise<ActivityFields> {
if (!input.runId || !input.commandId || !input.runnerId) return protectedActivity(input, "identity-facts-unavailable", input.createdAtMs, null);
if (!input.podIdentityMatch) return protectedActivity(input, "pod-identity-mismatch", input.createdAtMs, null);
let run: RunRecord;
let command: CommandRecord;
try {
[run, command] = await Promise.all([store.getRun(input.runId), store.getCommand(input.commandId)]);
} catch {
return protectedActivity(input, "db-facts-unavailable", input.createdAtMs, null);
}
const lastActiveAtMs = Math.max(input.createdAtMs, isoMs(run.updatedAt), isoMs(command.updatedAt), isoMs(run.leaseExpiresAt));
if (command.runId !== run.id) return protectedActivity(input, "db-identity-mismatch", lastActiveAtMs, null);
let commands: CommandRecord[];
let events: import("../common/types.js").RunEvent[];
try {
[commands, events] = await Promise.all([store.listCommands(run.id, 0, 100), store.listEvents(run.id, 0, 500)]);
} catch {
return protectedActivity(input, "db-progress-facts-unavailable", lastActiveAtMs, null);
}
const dbFactsFingerprint = databaseFactsFingerprint(run, command, commands, events);
const freshClaim = Boolean(run.claimedBy && heartbeatFresh(run.leaseExpiresAt, input.activeHeartbeatMaxAgeMs));
if (freshClaim) return protectedActivity(input, "fresh-active-run", lastActiveAtMs, dbFactsFingerprint);
const activePodReason = activePodProtectionReason(input.podObservations);
if (activePodReason) return protectedActivity(input, activePodReason, lastActiveAtMs, dbFactsFingerprint);
if (isTerminalRunStatus(run.status)) {
const claimDrift = run.claimedBy !== null || run.leaseExpiresAt !== null;
return candidateActivity(input, "terminal", claimDrift ? "terminal-run-stale-claim" : "terminal-run", lastActiveAtMs, dbFactsFingerprint, claimDrift ? "release-terminal-run-claim" : "none");
}
if (isTerminalCommandState(command.state)) {
const claimDrift = input.terminal && (run.claimedBy !== null || run.leaseExpiresAt !== null);
return candidateActivity(
input,
input.terminal ? "terminal" : "idle",
claimDrift ? "terminal-command-stale-claim" : input.terminal ? "terminal-runner-resource" : "terminal-command-idle-resource",
lastActiveAtMs,
dbFactsFingerprint,
claimDrift ? "release-terminal-command-claim" : "none",
);
}
if (input.terminal) return candidateActivity(input, "terminal", "terminal-runner-resource", lastActiveAtMs, dbFactsFingerprint);
const podBlockReason = stalePendingPodBlockReason(input.podObservations);
if (podBlockReason) return protectedActivity(input, podBlockReason, lastActiveAtMs, dbFactsFingerprint);
if (!hasNoBusinessProgress(run, command, commands, events, input.stalePendingMaxAgeMs)) {
return protectedActivity(input, "business-progress-observed-or-unverifiable", lastActiveAtMs, dbFactsFingerprint);
}
return candidateActivity(input, "stale-pending", "stale-pending-no-business-progress", lastActiveAtMs, dbFactsFingerprint, "terminalize-stale-pending");
}
interface RetiredRunnerResource extends JsonRecord {
deletedRunnerJobCount: number;
deletedRunnerPodCount: number;
repairedTerminalClaimCount: number;
}
async function retireSelectedRunner(store: AgentRunStore, options: RunnerRetentionOptions, item: RunnerResourceEntry, reason: string): Promise<RetiredRunnerResource> {
await revalidateSelectedRunner(store, options, item);
if (!item.runId || !item.commandId || !item.dbFactsFingerprint) {
throw new AgentRunError("infra-failed", "runner retention selected resource has no fenceable database identity", {
httpStatus: 409,
details: { reason: "runner-retention-db-fence-unavailable", namespace: options.namespace, resourceKind: item.resourceKind, name: item.name, valuesPrinted: false },
});
}
await store.withRunnerRetentionFence({
runId: item.runId,
commandId: item.commandId,
terminalizeStalePending: item.candidateKind === "stale-pending",
releaseRunnerClaim: item.repairKind === "release-terminal-run-claim" || item.repairKind === "release-terminal-command-claim",
reason,
}, async (facts) => {
const currentDbFingerprint = databaseFactsFingerprint(facts.run, facts.command, facts.commands, facts.events);
if (currentDbFingerprint !== item.dbFactsFingerprint) throw databaseFenceRejected(options, item, currentDbFingerprint, facts.run);
await deleteRunnerResourceCas(options, item);
});
if (item.resourceKind === "job") {
return { deletedRunnerJobCount: 1, deletedRunnerPodCount: 0, repairedTerminalClaimCount: isClaimRepair(item.repairKind) ? 1 : 0 };
}
return { deletedRunnerJobCount: 0, deletedRunnerPodCount: 1, repairedTerminalClaimCount: isClaimRepair(item.repairKind) ? 1 : 0 };
}
function isClaimRepair(repairKind: RepairKind): boolean {
return repairKind === "release-terminal-run-claim" || repairKind === "release-terminal-command-claim";
}
async function revalidateSelectedRunner(store: AgentRunStore, options: RunnerRetentionOptions, selected: RunnerResourceEntry): Promise<void> {
const snapshot = await runnerSnapshot(store, options);
const resources = selected.resourceKind === "job" ? snapshot.jobs : snapshot.pods;
const current = resources.find((item) => item.name === selected.name) ?? null;
const selectedFingerprint = runnerSafetyFingerprint(selected);
const currentFingerprint = current ? runnerSafetyFingerprint(current) : null;
if (current && !current.protectedActive && currentFingerprint === selectedFingerprint) return;
throw new AgentRunError("infra-failed", "runner retention selected resource changed before deletion", {
httpStatus: 409,
details: {
reason: "runner-retention-selected-runner-changed",
changeReason: current === null ? "resource-missing" : current.protectedActive ? "protected-active" : "safety-fingerprint-changed",
namespace: options.namespace,
resourceKind: selected.resourceKind,
name: selected.name,
selectedFingerprint,
currentFingerprint,
selected: resourceSummary(selected),
current: current ? resourceSummary(current) : null,
valuesPrinted: false,
},
});
}
function databaseFenceRejected(options: RunnerRetentionOptions, item: RunnerResourceEntry, currentDbFingerprint: string, run: RunRecord): AgentRunError {
return new AgentRunError("infra-failed", "runner retention database facts changed before atomic deletion", {
httpStatus: 409,
details: {
reason: "runner-retention-db-fence-rejected",
changeReason: run.claimedBy || run.status === "claimed" || run.status === "running" ? "run-became-active" : "database-fingerprint-changed",
namespace: options.namespace,
resourceKind: item.resourceKind,
name: item.name,
selectedDbFingerprint: item.dbFactsFingerprint,
currentDbFingerprint,
runStatus: run.status,
claimedBy: run.claimedBy,
leaseExpiresAt: run.leaseExpiresAt,
valuesPrinted: false,
},
});
}
async function deleteRunnerResourceCas(options: RunnerRetentionOptions, item: RunnerResourceEntry): Promise<void> {
if (item.resourceKind === "job") {
for (const pod of [...item.podObservations].sort((left, right) => left.name.localeCompare(right.name))) {
await deleteKubernetesObjectCas(options, item, "pod", pod.name, pod.uid, pod.resourceVersion);
}
const currentJob = await kubernetesGetObject(options, "job", item.name);
const expectedIdentity = {
"agentrun.pikastech.local/run-id": item.runId ?? "",
"agentrun.pikastech.local/command-id": item.commandId ?? "",
"agentrun.pikastech.local/runner-id": item.runnerId ?? "",
};
if (currentJob.metadata?.uid !== item.resourceUid || !resourceIdentityMatches(expectedIdentity, currentJob.metadata?.annotations ?? {})) {
throw new AgentRunError("infra-failed", "runner retention Job identity changed after Pod CAS deletion", {
httpStatus: 409,
details: { reason: "runner-retention-k8s-cas-rejected", changeReason: "job-identity-changed", namespace: options.namespace, resourceKind: "job", name: item.name, expectedUid: item.resourceUid, currentUid: currentJob.metadata?.uid ?? null, valuesPrinted: false },
});
}
await deleteKubernetesObjectCas(options, item, "job", item.name, currentJob.metadata?.uid ?? null, currentJob.metadata?.resourceVersion ?? null);
return;
}
await deleteKubernetesObjectCas(options, item, item.resourceKind, item.name, item.resourceUid, item.resourceVersion);
}
async function deleteKubernetesObjectCas(options: RunnerRetentionOptions, selected: RunnerResourceEntry, resourceKind: "job" | "pod", name: string, uid: string | null, resourceVersion: string | null): Promise<void> {
if (!uid || !resourceVersion) {
throw new AgentRunError("infra-failed", "runner retention cannot bind Kubernetes delete preconditions", {
httpStatus: 409,
details: { reason: "runner-retention-k8s-cas-unavailable", namespace: options.namespace, selectedResourceKind: selected.resourceKind, selectedName: selected.name, resourceKind, name, uidPresent: Boolean(uid), resourceVersionPresent: Boolean(resourceVersion), valuesPrinted: false },
});
}
const result = await retentionKubernetes(options).delete(resourceKind, name, { propagationPolicy: "Background", preconditions: { uid, resourceVersion } });
if (result.code === 0) return;
const conflict = result.kubernetesReason === "Conflict" || /\b(?:Conflict|PreconditionFailed|resourceVersion|UID)\b/iu.test(`${result.stderr}\n${result.stdout}`);
throw new AgentRunError("infra-failed", `Kubernetes CAS delete ${resourceKind} failed with code ${result.code}`, {
httpStatus: conflict ? 409 : 502,
details: redactJson({
reason: "runner-retention-k8s-cas-rejected",
changeReason: conflict ? "object-identity-or-version-changed" : "delete-request-failed",
namespace: options.namespace,
selectedResourceKind: selected.resourceKind,
selectedName: selected.name,
resourceKind,
name,
expectedUid: uid,
expectedResourceVersion: resourceVersion,
exitCode: result.code,
signal: result.signal,
transport: result.transport,
statusCode: result.statusCode,
kubernetesReason: result.kubernetesReason,
stderr: redactText(result.stderr.slice(-2000)),
stdoutHash: result.stdout ? stableHash(result.stdout) : null,
valuesPrinted: false,
}),
});
}
async function kubernetesGetList(options: RunnerRetentionOptions, resource: string, selector: string): Promise<K8sObject[]> {
const result = await retentionKubernetes(options).list(retentionResource(resource), selector ? { labelSelector: selector } : {});
if (result.code !== 0) throw new AgentRunError("infra-failed", `Kubernetes get ${resource} failed`, { httpStatus: 502, details: retentionFailureDetails(result, { reason: "runner-kubernetes-observation-failed", namespace: options.namespace, resource }) });
const parsed = parseJsonObject(result.stdout, `Kubernetes get ${resource}`, "runner-kubernetes-observation-failed");
const items = parsed.items;
return Array.isArray(items) ? items.filter((entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry)).map((entry) => entry as unknown as K8sObject) : [];
}
async function kubernetesGetObject(options: RunnerRetentionOptions, resource: "job" | "pod", name: string): Promise<K8sObject> {
const result = await retentionKubernetes(options).get(resource, name);
if (result.code !== 0) throw new AgentRunError("infra-failed", `Kubernetes get ${resource}/${name} failed`, { httpStatus: 409, details: retentionFailureDetails(result, { reason: "runner-retention-k8s-cas-rejected", changeReason: "resource-missing-before-cas", namespace: options.namespace, resourceKind: resource, name }) });
return parseJsonObject(result.stdout, `Kubernetes get ${resource}/${name}`, "runner-retention-k8s-cas-rejected") as unknown as K8sObject;
}
async function kubernetesGetPodEvents(options: RunnerRetentionOptions): Promise<K8sObject[]> {
const result = await retentionKubernetes(options).list("event", { fieldSelector: "involvedObject.kind=Pod,type=Warning" });
if (result.code !== 0) throw new AgentRunError("infra-failed", "Kubernetes get events failed", { httpStatus: 502, details: retentionFailureDetails(result, { reason: "runner-kubernetes-observation-failed", namespace: options.namespace, resource: "events" }) });
const parsed = parseJsonObject(result.stdout, "Kubernetes get events", "runner-kubernetes-observation-failed");
const items = parsed.items;
return Array.isArray(items) ? items.filter((entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry)).map((entry) => entry as unknown as K8sObject) : [];
}
async function deleteAssociatedResources(options: RunnerRetentionOptions, runner: RunnerResourceEntry): Promise<number> {
let deleted = 0;
const selector = `agentrun.pikastech.local/runner-job=${runner.jobName}`;
const secrets = await kubernetesGetAssociatedResources(options, "secret", selector, runner.jobName);
for (const secret of secrets) {
const name = objectName(secret);
if (!name || !isExclusiveRunnerSecret(secret, runner)) {
throw new AgentRunError("infra-failed", "runner retention associated Secret ownership is not exclusive", {
httpStatus: 409,
details: {
reason: "runner-associated-resource-ownership-unknown",
namespace: options.namespace,
resource: "secret",
name,
jobName: runner.jobName,
valuesPrinted: false,
},
});
}
const result = await retentionKubernetes(options).delete("secret", name, { ignoreNotFound: true, propagationPolicy: "Background" });
if (result.code !== 0) {
throw new AgentRunError("infra-failed", "Kubernetes delete associated secret failed", {
httpStatus: 502,
details: retentionFailureDetails(result, { reason: "runner-associated-resource-delete-failed", namespace: options.namespace, resource: "secret", name, jobName: runner.jobName }),
});
}
if (result.transport === "in-cluster-https" ? result.kubernetesReason !== "NotFound" : countDeletedLines(result.stdout) > 0) deleted++;
}
return deleted;
}
async function kubernetesGetAssociatedResources(options: RunnerRetentionOptions, resource: "secret", selector: string, jobName: string): Promise<K8sObject[]> {
const result = await retentionKubernetes(options).list(resource, { labelSelector: selector });
if (result.code !== 0) {
throw new AgentRunError("infra-failed", `Kubernetes get associated ${resource} failed`, {
httpStatus: 502,
details: retentionFailureDetails(result, { reason: "runner-associated-resource-observation-failed", namespace: options.namespace, resource, jobName }),
});
}
const parsed = parseJsonObject(result.stdout, `Kubernetes get associated ${resource}`, "runner-associated-resource-observation-failed");
const items = parsed.items;
return Array.isArray(items)
? items.filter((entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry)).map((entry) => entry as unknown as K8sObject)
: [];
}
function isExclusiveRunnerSecret(secret: K8sObject, runner: RunnerResourceEntry): boolean {
const labels = secret.metadata?.labels ?? {};
const annotations = secret.metadata?.annotations ?? {};
const identity = {
"agentrun.pikastech.local/run-id": runner.runId ?? "",
"agentrun.pikastech.local/command-id": runner.commandId ?? "",
"agentrun.pikastech.local/runner-id": runner.runnerId ?? "",
"agentrun.pikastech.local/runner-job": runner.jobName,
};
if (labels["app.kubernetes.io/component"] !== "runner-transient-env") return false;
if (labels["agentrun.pikastech.local/runner-job"] !== runner.jobName) return false;
if (annotations["agentrun.pikastech.local/runner-job"] !== runner.jobName) return false;
if (!resourceIdentityMatches(identity, annotations)) return false;
const owners = secret.metadata?.ownerReferences ?? [];
return owners.length === 0 || owners.every((owner) => owner.kind === "Job" && owner.name === runner.jobName);
}
function retentionKubernetes(options: RunnerRetentionOptions): KubernetesClient {
return createKubernetesClient({ namespace: options.namespace, ...(options.kubectlCommand ? { kubectlCommand: options.kubectlCommand } : {}), ...(options.signal ? { signal: options.signal } : {}) });
}
function retentionResource(resource: string): KubernetesResource {
if (resource === "jobs" || resource === "job") return "job";
if (resource === "pods" || resource === "pod") return "pod";
throw new AgentRunError("schema-invalid", `runner retention resource ${resource} is unsupported`, { httpStatus: 500, details: { reason: "runner-retention-resource-unsupported", resource, valuesPrinted: false } });
}
function retentionFailureDetails(result: KubernetesTransportResult, context: JsonRecord): JsonRecord {
return redactJson({
...context,
exitCode: result.code,
signal: result.signal,
transport: result.transport,
statusCode: result.statusCode,
kubernetesReason: result.kubernetesReason,
stderr: redactText(result.stderr.slice(-2000)),
stdoutHash: result.stdout ? stableHash(result.stdout) : null,
valuesPrinted: false,
});
}
function compareCandidates(left: RunnerResourceEntry, right: RunnerResourceEntry): number {
const priority = (kind: CandidateKind): number => kind === "idle" ? 0 : kind === "terminal" ? 1 : kind === "stale-pending" ? 2 : 3;
const byKind = priority(left.candidateKind) - priority(right.candidateKind);
if (byKind !== 0) return byKind;
const byActive = left.lastActiveAtMs - right.lastActiveAtMs;
if (byActive !== 0) return byActive;
return left.name.localeCompare(right.name);
}
function resourceSummary(item: RunnerResourceEntry): JsonRecord {
return redactJson({
resourceKind: item.resourceKind,
name: item.name,
jobName: item.jobName,
resourceUid: item.resourceUid,
resourceVersion: item.resourceVersion,
runId: item.runId,
commandId: item.commandId,
runnerId: item.runnerId,
protectedReason: item.protectedReason,
candidateKind: item.candidateKind,
classificationReason: item.classificationReason,
repairKind: item.repairKind,
terminal: item.terminal,
podPhase: item.podPhase,
podObservations: item.podObservations.slice(0, 5).map(podObservationSummary),
podObservationOmittedCount: Math.max(0, item.podObservations.length - 5),
lastActiveAt: new Date(item.lastActiveAtMs).toISOString(),
valuesPrinted: false,
}) as JsonRecord;
}
function podObservationSummary(item: RunnerPodObservation): JsonRecord {
return {
...item,
waiting: item.waiting.slice(0, 5),
waitingOmittedCount: Math.max(0, item.waiting.length - 5),
valuesPrinted: false,
};
}
function cleanupPolicySummary(): JsonRecord {
return {
mode: "db-ledger-and-k8s-observation",
forceActive: false,
activeProtection: "protect fresh claims and active Pods; terminal Pods do not remain active merely because their container once started",
terminalClaimRepair: "release stale terminal run claims under the same DB and Kubernetes CAS fence",
deletionOrder: "idle, terminal, stale-pending, inactive by lastActiveAt",
valuesPrinted: false,
};
}
function labelSelector(labels: Record<string, string>): string {
return Object.entries(labels).map(([key, value]) => `${key}=${value}`).join(",");
}
function matchesJobPrefix(name: string, prefixes: string[]): boolean {
return prefixes.length === 0 || prefixes.some((prefix) => name === prefix || name.startsWith(`${prefix}-`));
}
function podJobName(item: K8sObject): string | null {
const labels = item.metadata?.labels ?? {};
if (labels["job-name"]) return labels["job-name"];
return item.metadata?.ownerReferences?.find((owner) => owner.kind === "Job")?.name ?? null;
}
function resourceIdentityMatches(left: Record<string, string>, right: Record<string, string>): boolean {
for (const key of ["agentrun.pikastech.local/run-id", "agentrun.pikastech.local/command-id", "agentrun.pikastech.local/runner-id"] as const) {
if (!left[key] || !right[key] || left[key] !== right[key]) return false;
}
return true;
}
function jobTerminal(item: K8sObject): boolean {
const conditions = item.status?.conditions;
if (!Array.isArray(conditions)) return false;
return conditions.some((condition) => {
const record = condition as JsonRecord;
return (record.type === "Complete" || record.type === "Failed") && record.status === "True";
});
}
function podTerminal(item: K8sObject): boolean {
const phase = stringPath(item, ["status", "phase"]);
return phase === "Succeeded" || phase === "Failed";
}
function observePod(item: K8sObject, stalePendingMaxAgeMs: number, events: K8sObject[], eventFactsAvailable: boolean): RunnerPodObservation {
const createdAtMs = objectCreatedAtMs(item);
const ageMs = createdAtMs > 0 ? Math.max(0, Date.now() - createdAtMs) : null;
const phase = stringPath(item, ["status", "phase"]);
const conditions = jsonRecordArray(item.status?.conditions);
const statuses = jsonRecordArray(item.status?.containerStatuses);
const runner = statuses.find((status) => status.name === "runner") ?? null;
const runnerState = jsonRecord(runner?.state);
const runnerContainerState: RunnerPodObservation["runnerContainerState"] = runnerState?.waiting ? "waiting" : runnerState?.running ? "running" : runnerState?.terminated ? "terminated" : "unknown";
const ready = conditions.some((condition) => condition.type === "Ready" && condition.status === "True");
const waiting = [
...statuses.flatMap((status) => waitingSummary(status, "container", typeof status.name === "string" ? status.name : null)),
...jsonRecordArray(item.status?.initContainerStatuses).flatMap((status) => waitingSummary(status, "init-container", typeof status.name === "string" ? status.name : null)),
...conditions.flatMap((condition) => condition.status === "False" && typeof condition.reason === "string" && condition.reason.length > 0
? [{ source: "condition", name: typeof condition.type === "string" ? condition.type : null, reason: condition.reason, message: boundedRedactedText(condition.message), deterministic: condition.reason === "Unschedulable", valuesPrinted: false }]
: []),
...events.flatMap(eventWaitingSummary),
];
const runnerContainerRestartCount = typeof runner?.restartCount === "number" ? runner.restartCount : 0;
const runnerLastState = jsonRecord(runner?.lastState);
const runnerContainerLastState: RunnerPodObservation["runnerContainerLastState"] = runnerLastState?.running ? "running" : runnerLastState?.terminated ? "terminated" : "none";
const runnerContainerStarted = runner?.started === true || runnerContainerRestartCount > 0 || runnerContainerState === "running" || runnerContainerState === "terminated" || Boolean(runnerLastState?.running) || Boolean(runnerLastState?.terminated);
const runnerContainerReady = runner?.ready === true;
return {
name: objectName(item) ?? "unknown",
uid: item.metadata?.uid ?? null,
resourceVersion: item.metadata?.resourceVersion ?? null,
phase,
createdAt: createdAtMs > 0 ? new Date(createdAtMs).toISOString() : null,
ageMs,
stale: ageMs !== null && ageMs >= stalePendingMaxAgeMs,
podStartTime: boundedRedactedText(item.status?.startTime),
ready,
runnerContainerState,
runnerContainerLastState,
runnerContainerStarted,
runnerContainerReady,
runnerContainerRestartCount,
waiting,
waitingEvidenceSources: [...new Set(waiting.map((entry) => typeof entry.source === "string" ? entry.source : "unknown"))],
eventFactsAvailable,
factsAvailable: phase !== null && createdAtMs > 0,
valuesPrinted: false,
};
}
function waitingSummary(status: JsonRecord, source: string, name: string | null): JsonRecord[] {
const state = jsonRecord(status.state);
const waiting = jsonRecord(state?.waiting);
if (!waiting || typeof waiting.reason !== "string" || waiting.reason.length === 0) return [];
return [{ source, name, reason: waiting.reason, message: boundedRedactedText(waiting.message), deterministic: waiting.reason !== "ContainerCreating" && waiting.reason !== "PodInitializing", valuesPrinted: false }];
}
function eventWaitingSummary(event: K8sObject): JsonRecord[] {
if (event.type !== "Warning" || typeof event.reason !== "string" || event.reason.length === 0) return [];
return [{
source: "event",
name: typeof event.metadata?.name === "string" ? event.metadata.name : null,
reason: event.reason,
message: boundedRedactedText(event.message),
deterministic: true,
count: typeof event.count === "number" ? event.count : null,
lastObservedAt: boundedRedactedText(event.series?.lastObservedTime ?? event.eventTime ?? event.lastTimestamp ?? event.firstTimestamp),
valuesPrinted: false,
}];
}
function podEventName(item: K8sObject): string | null {
if (item.involvedObject?.kind !== "Pod") return null;
return typeof item.involvedObject.name === "string" && item.involvedObject.name.length > 0 ? item.involvedObject.name : null;
}
function stalePendingPodBlockReason(observations: RunnerPodObservation[]): string | null {
if (observations.length === 0) return "pod-facts-unavailable";
if (observations.some((item) => !item.factsAvailable)) return "pod-facts-unavailable";
const activeReason = activePodProtectionReason(observations);
if (activeReason) return activeReason;
if (observations.some((item) => item.phase !== "Pending")) return "runner-pod-not-pending";
if (observations.some((item) => !item.stale)) return "stale-pending-window-active";
if (observations.some((item) => !item.waiting.some((entry) => entry.deterministic === true) && !item.eventFactsAvailable)) return "pod-waiting-event-facts-unavailable";
if (observations.some((item) => item.waiting.length > 0 && !item.waiting.some((entry) => entry.deterministic === true))) return "pod-deterministic-waiting-reason-unavailable";
if (observations.some((item) => item.waiting.length === 0)) return "pod-waiting-reason-unavailable";
return null;
}
function activePodProtectionReason(observations: RunnerPodObservation[]): string | null {
if (observations.some((item) => {
const terminalPhase = item.phase === "Succeeded" || item.phase === "Failed";
return item.phase === "Running"
|| item.ready
|| item.runnerContainerReady
|| item.runnerContainerState === "running"
|| (!terminalPhase && (item.runnerContainerStarted || item.runnerContainerState === "terminated"));
})) return "runner-pod-started-running-or-ready";
return null;
}
function hasNoBusinessProgress(run: RunRecord, command: CommandRecord, commands: CommandRecord[], events: import("../common/types.js").RunEvent[], stalePendingMaxAgeMs: number): boolean {
const cutoff = Date.now() - stalePendingMaxAgeMs;
if (run.status !== "pending" || run.claimedBy !== null || run.leaseExpiresAt !== null || run.cancelRequestId !== null || run.cancelRequestedAt !== null) return false;
if (command.state !== "pending" || command.acknowledgedAt !== null || command.cancelRequestId !== null || command.cancelRequestedAt !== null) return false;
if (!sameValidTimestamp(run.createdAt, run.updatedAt) || !sameValidTimestamp(command.createdAt, command.updatedAt)) return false;
if (isoMs(run.createdAt) > cutoff || isoMs(command.createdAt) > cutoff) return false;
if (commands.length !== 1 || commands[0]?.id !== command.id) return false;
if (events.length === 0 || events.length >= 500) return false;
const allowedPhases = new Set(["run-created", "command-created", "runner-job-created", "runner-dispatch-completed"]);
const userMessages = events.filter((event) => event.type === "user_message");
if (userMessages.length > 1) return false;
const phases = events.flatMap((event) => {
if (event.type === "user_message") {
const payload = event.payload;
const valid = payload.commandId === command.id
&& typeof payload.userMessageId === "string" && payload.userMessageId.length > 0
&& typeof payload.text === "string" && payload.text.length > 0
&& typeof payload.source === "string" && payload.source.length > 0;
return valid ? [] : [null];
}
const phase = typeof event.payload.phase === "string" ? event.payload.phase : null;
return event.type === "backend_status" && phase !== null && allowedPhases.has(phase) ? [phase] : [null];
});
if (phases.includes(null)) return false;
if (!phases.includes("run-created") || !phases.includes("command-created") || !phases.includes("runner-job-created")) return false;
return events.every((event) => event.payload.commandId === undefined || event.payload.commandId === command.id);
}
function protectedActivity(input: { runId: string | null; commandId: string | null; runnerId: string | null }, reason: string, lastActiveAtMs: number, dbFactsFingerprint: string | null): ActivityFields {
return { runId: input.runId, commandId: input.commandId, runnerId: input.runnerId, protectedActive: true, protectedReason: reason, candidateKind: "inactive", classificationReason: reason, repairKind: "none", lastActiveAtMs, dbFactsFingerprint };
}
function candidateActivity(input: { runId: string | null; commandId: string | null; runnerId: string | null }, kind: CandidateKind, reason: string, lastActiveAtMs: number, dbFactsFingerprint: string, repairKind: RepairKind = "none"): ActivityFields {
return { runId: input.runId, commandId: input.commandId, runnerId: input.runnerId, protectedActive: false, protectedReason: null, candidateKind: kind, classificationReason: reason, repairKind, lastActiveAtMs, dbFactsFingerprint };
}
function databaseFactsFingerprint(run: RunRecord, command: CommandRecord, commands: CommandRecord[], events: import("../common/types.js").RunEvent[]): string {
const commandFacts = (item: CommandRecord): JsonRecord => ({
id: item.id,
runId: item.runId,
seq: item.seq,
type: item.type,
state: item.state,
payloadHash: item.payloadHash,
cancelEpoch: item.cancelEpoch,
cancelRequestId: item.cancelRequestId,
cancelRequestedAt: item.cancelRequestedAt,
cancelReason: item.cancelReason,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
acknowledgedAt: item.acknowledgedAt,
dispatchIntentHash: stableHash(item.dispatchIntent ?? null),
valuesPrinted: false,
});
return stableHash({
run: {
id: run.id,
status: run.status,
terminalStatus: run.terminalStatus,
failureKind: run.failureKind,
failureMessageHash: run.failureMessage ? stableHash(run.failureMessage) : null,
cancelEpoch: run.cancelEpoch,
cancelRequestId: run.cancelRequestId,
cancelRequestedAt: run.cancelRequestedAt,
cancelReason: run.cancelReason,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
claimedBy: run.claimedBy,
leaseExpiresAt: run.leaseExpiresAt,
valuesPrinted: false,
},
command: commandFacts(command),
commands: [...commands].sort((left, right) => left.seq - right.seq || left.id.localeCompare(right.id)).map(commandFacts),
events: [...events].sort((left, right) => left.seq - right.seq || left.id.localeCompare(right.id)).map((event) => ({
id: event.id,
runId: event.runId,
seq: event.seq,
type: event.type,
createdAt: event.createdAt,
payloadHash: stableHash(event.payload),
valuesPrinted: false,
})),
valuesPrinted: false,
});
}
function runnerSafetyFingerprint(item: RunnerResourceEntry): string {
return stableHash({
schema: "agentrun-runner-retention-safety-v1",
resourceKind: item.resourceKind,
name: item.name,
jobName: item.jobName,
resourceUid: item.resourceUid,
resourceVersion: item.resourceVersion,
runId: item.runId,
commandId: item.commandId,
runnerId: item.runnerId,
createdAtMs: item.createdAtMs,
terminal: item.terminal,
podPhase: item.podPhase,
protectedActive: item.protectedActive,
protectedReason: item.protectedReason,
candidateKind: item.candidateKind,
classificationReason: item.classificationReason,
repairKind: item.repairKind,
lastActiveAtMs: item.lastActiveAtMs,
dbFactsFingerprint: item.dbFactsFingerprint,
podObservations: [...item.podObservations].sort((left, right) => left.name.localeCompare(right.name)).map(podSafetyFacts),
valuesPrinted: false,
});
}
function podSafetyFacts(item: RunnerPodObservation): JsonRecord {
const waiting = item.waiting.map((entry) => ({
source: typeof entry.source === "string" ? entry.source : null,
name: typeof entry.name === "string" ? entry.name : null,
reason: typeof entry.reason === "string" ? entry.reason : null,
deterministic: entry.deterministic === true,
valuesPrinted: false,
})).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
return {
name: item.name,
uid: item.uid,
resourceVersion: item.resourceVersion,
phase: item.phase,
createdAt: item.createdAt,
stale: item.stale,
podStartTime: item.podStartTime,
ready: item.ready,
runnerContainerState: item.runnerContainerState,
runnerContainerLastState: item.runnerContainerLastState,
runnerContainerStarted: item.runnerContainerStarted,
runnerContainerReady: item.runnerContainerReady,
runnerContainerRestartCount: item.runnerContainerRestartCount,
waiting,
waitingEvidenceSources: [...item.waitingEvidenceSources].sort(),
eventFactsAvailable: item.eventFactsAvailable,
factsAvailable: item.factsAvailable,
valuesPrinted: false,
};
}
function boundedResourceSummaries(items: RunnerResourceEntry[]): { items: JsonRecord[]; omittedCount: number } {
const limit = 20;
return { items: items.slice(0, limit).map(resourceSummary), omittedCount: Math.max(0, items.length - limit) };
}
function boundedJsonRecords(items: JsonRecord[]): { items: JsonRecord[]; omittedCount: number } {
const limit = 20;
return { items: items.slice(0, limit), omittedCount: Math.max(0, items.length - limit) };
}
function resourceIdentitySummary(item: RunnerResourceEntry): JsonRecord {
return {
resourceKind: item.resourceKind,
name: item.name,
jobName: item.jobName,
runId: item.runId,
commandId: item.commandId,
runnerId: item.runnerId,
candidateKind: item.candidateKind,
classificationReason: item.classificationReason,
repairKind: item.repairKind,
valuesPrinted: false,
};
}
function retentionErrorSummary(error: unknown): JsonRecord {
if (error instanceof AgentRunError) {
return redactJson({
failureKind: error.failureKind,
httpStatus: error.httpStatus,
message: redactText(error.message).slice(0, 300),
details: error.details,
valuesPrinted: false,
}) as JsonRecord;
}
return {
failureKind: "infra-failed",
httpStatus: 500,
message: redactText(error instanceof Error ? error.message : String(error)).slice(0, 300),
details: null,
valuesPrinted: false,
};
}
function reasonCounts(reasons: string[]): JsonRecord {
const counts: Record<string, number> = {};
for (const reason of reasons) counts[reason] = (counts[reason] ?? 0) + 1;
return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right))) as JsonRecord;
}
function clampReconcileLimit(limit: number): number {
return Math.max(1, Math.min(Math.floor(limit), 500));
}
function jsonRecord(value: unknown): JsonRecord | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : null;
}
function jsonRecordArray(value: unknown): JsonRecord[] {
return Array.isArray(value) ? value.map(jsonRecord).filter((item): item is JsonRecord => item !== null) : [];
}
function boundedRedactedText(value: unknown): string | null {
if (typeof value !== "string" || value.length === 0) return null;
return redactText(value).slice(0, 512);
}
function sameValidTimestamp(left: string, right: string): boolean {
return isoMs(left) > 0 && isoMs(left) === isoMs(right);
}
function heartbeatFresh(leaseExpiresAt: string | null, activeHeartbeatMaxAgeMs: number): boolean {
const leaseMs = isoMs(leaseExpiresAt);
if (leaseMs <= 0) return false;
return leaseMs + activeHeartbeatMaxAgeMs >= Date.now();
}
function objectName(item: K8sObject): string | null {
return typeof item.metadata?.name === "string" && item.metadata.name.length > 0 ? item.metadata.name : null;
}
function objectCreatedAtMs(item: K8sObject): number {
return isoMs(item.metadata?.creationTimestamp) || 0;
}
function isoMs(value: string | null | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function stringPath(value: unknown, path: string[]): string | null {
let current: unknown = value;
for (const key of path) {
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
current = (current as Record<string, unknown>)[key];
}
return typeof current === "string" ? current : null;
}
function parseJsonObject(text: string, label: string, reason: string): JsonRecord {
try {
const parsed = JSON.parse(text) as JsonValue;
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
} catch (error) {
throw new AgentRunError("infra-failed", `${label} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 502, details: { reason, stdoutHash: stableHash(text), valuesPrinted: false } });
}
throw new AgentRunError("infra-failed", `${label} returned non-object JSON`, { httpStatus: 502, details: { reason, stdoutHash: stableHash(text), valuesPrinted: false } });
}
function countDeletedLines(stdout: string): number {
return stdout.split(/\r?\n/u).filter((line) => /\sdeleted$/u.test(line.trim())).length;
}