From 03491ea0680e95b12cec2dd615fbc129d7b5d195 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 12 Jul 2026 18:33:40 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=88=E6=80=81=20runner=20=E4=BC=98?= =?UTF-8?q?=E5=85=88=E8=BF=9B=E5=85=A5=E5=9B=9E=E6=94=B6=E5=80=99=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mgr/runner-retention.ts | 10 ++--- .../22-runner-retention-stale-pending.ts | 45 +++++++++++-------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/mgr/runner-retention.ts b/src/mgr/runner-retention.ts index ae3ed85..7b51016 100644 --- a/src/mgr/runner-retention.ts +++ b/src/mgr/runner-retention.ts @@ -508,16 +508,12 @@ async function activityFor(store: AgentRunStore, input: { 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); + const claimDrift = run.claimedBy !== null || run.leaseExpiresAt !== null; return candidateActivity( input, input.terminal ? "terminal" : "idle", @@ -527,6 +523,10 @@ async function activityFor(store: AgentRunStore, input: { claimDrift ? "release-terminal-command-claim" : "none", ); } + 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 (input.terminal) return candidateActivity(input, "terminal", "terminal-runner-resource", lastActiveAtMs, dbFactsFingerprint); const podBlockReason = stalePendingPodBlockReason(input.podObservations); diff --git a/src/selftest/cases/22-runner-retention-stale-pending.ts b/src/selftest/cases/22-runner-retention-stale-pending.ts index baef697..69f139b 100644 --- a/src/selftest/cases/22-runner-retention-stale-pending.ts +++ b/src/selftest/cases/22-runner-retention-stale-pending.ts @@ -123,6 +123,7 @@ const selfTest: SelfTestCase = async (context) => { await assertEventOnlyFailedMountCandidate({ fixturePath, statePath, kubectlCommand }); await assertTerminalClaimDriftCandidate({ fixturePath, statePath, kubectlCommand }); await assertTerminalCommandClaimDriftCandidate({ fixturePath, statePath, kubectlCommand }); + await assertTerminalFactsOverrideHistoricalActivity({ fixturePath, statePath, kubectlCommand }); await assertTerminalClaimRepairCasConflictFailsClosed({ fixturePath, statePath, kubectlCommand }); await assertProtectedMatrix({ fixturePath, statePath, kubectlCommand }); await assertPendingToRunningRaceFailsClosed({ fixturePath, statePath, kubectlCommand }); @@ -167,9 +168,9 @@ const selfTest: SelfTestCase = async (context) => { "container-started-pending-protected", "pod-identity-mismatch-protected", "event-facts-unavailable-protected", - "terminal-ledger-running-pod-protected", "terminal-runner-stale-claim-typed-repair", "terminal-command-runner-stale-claim-typed-repair", + "terminal-facts-override-historical-claim-heartbeat-and-running-pod", "terminal-claim-repair-cas-conflict-fails-closed", "terminal-runner-pod-completed-is-not-active", "selected-runner-race-fails-closed", @@ -268,23 +269,6 @@ async function assertProtectedMatrix(input: { fixturePath: string; statePath: st await assertProtected(input, [base], [], "pod-facts-unavailable"); await assertProtected(input, [base], [runnerPod(base, 100, "pending")], "pod-waiting-event-facts-unavailable", base, { eventsError: true }); await assertProtected(input, [base], [runnerPod(base, 100, "pending"), runnerPod(base, 101, "running", 100)], "runner-pod-started-running-or-ready"); - const terminalRunning = pendingFact(105, oldAt); - terminalRunning.run.status = "completed"; - terminalRunning.run.terminalStatus = "completed"; - terminalRunning.command.state = "completed"; - await assertProtected(input, [terminalRunning], [runnerPod(terminalRunning, 105, "running")], "runner-pod-started-running-or-ready"); - const terminalFreshClaim = pendingFact(107, oldAt, "claimed"); - terminalFreshClaim.run.status = "completed"; - terminalFreshClaim.run.terminalStatus = "completed"; - terminalFreshClaim.command.state = "completed"; - const terminalFreshClaimPod = runnerPod(terminalFreshClaim, 107, "running"); - terminalFreshClaimPod.status = { - phase: "Succeeded", - startTime: terminalFreshClaim.run.createdAt, - conditions: [{ type: "Ready", status: "False", reason: "PodCompleted" }], - containerStatuses: [{ name: "runner", ready: false, started: false, restartCount: 0, state: { terminated: { exitCode: 0, reason: "Completed" } } }], - }; - await assertProtected(input, [terminalFreshClaim], [terminalFreshClaimPod], "fresh-active-run"); const identityMismatchPod = runnerPod(base, 100, "pending"); ((identityMismatchPod.metadata as JsonRecord).annotations as JsonRecord)["agentrun.pikastech.local/command-id"] = "cmd-mismatched"; await assertProtected(input, [base], [identityMismatchPod], "pod-identity-mismatch"); @@ -295,6 +279,31 @@ async function assertProtectedMatrix(input: { fixturePath: string; statePath: st await assertProtected(input, [mismatchedUserMessage], [runnerPod(mismatchedUserMessage, 106, "failed-config")], "business-progress-observed-or-unverifiable"); } +async function assertTerminalFactsOverrideHistoricalActivity(input: { fixturePath: string; statePath: string; kubectlCommand: string }): Promise { + const oldAt = new Date(Date.now() - 60 * 60_000).toISOString(); + const terminalRun = pendingFact(105, oldAt, "claimed"); + terminalRun.run.status = "completed"; + terminalRun.run.terminalStatus = "completed"; + terminalRun.command.state = "completed"; + await writeFixture(input, { jobs: [runnerJob(terminalRun, 105)], pods: [runnerPod(terminalRun, 105, "running")], events: [] }); + const runSummary = await enforceRunnerRetentionBeforeCreate({ store: new RetentionFactStore([terminalRun]), options: retentionOptions(input.kubectlCommand, 1), incomingRunnerCount: 0 }); + const runCandidate = firstSummary(runSummary, "inactiveCandidates"); + assert.equal(runSummary.protectedActiveRunnerCount, 0); + assert.equal(runCandidate.candidateKind, "terminal"); + assert.equal(runCandidate.classificationReason, "terminal-run-stale-claim"); + assert.equal(runCandidate.repairKind, "release-terminal-run-claim"); + + const terminalCommand = pendingFact(107, oldAt, "claimed"); + terminalCommand.command.state = "completed"; + await writeFixture(input, { jobs: [runnerJob(terminalCommand, 107)], pods: [runnerPod(terminalCommand, 107, "running")], events: [] }); + const commandSummary = await enforceRunnerRetentionBeforeCreate({ store: new RetentionFactStore([terminalCommand]), options: retentionOptions(input.kubectlCommand, 1), incomingRunnerCount: 0 }); + const commandCandidate = firstSummary(commandSummary, "inactiveCandidates"); + assert.equal(commandSummary.protectedActiveRunnerCount, 0); + assert.equal(commandCandidate.candidateKind, "idle"); + assert.equal(commandCandidate.classificationReason, "terminal-command-stale-claim"); + assert.equal(commandCandidate.repairKind, "release-terminal-command-claim"); +} + async function assertLegacyNoUserMessageCandidate(input: { fixturePath: string; statePath: string; kubectlCommand: string }): Promise { const fact = pendingFact(89, new Date(Date.now() - 60 * 60_000).toISOString()); fact.events = fact.events