From b081d7c2bbecd141c28fc828afde22b9f61bc6af Mon Sep 17 00:00:00 2001 From: root Date: Sat, 11 Jul 2026 04:46:47 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E4=BA=8B=E4=BB=B6=E7=9A=84=E9=99=88=E6=97=A7?= =?UTF-8?q?=20runner=20=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mgr/runner-retention.ts | 17 ++++++++-- .../22-runner-retention-stale-pending.ts | 34 ++++++++++++++++--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/mgr/runner-retention.ts b/src/mgr/runner-retention.ts index 6ba47d4..411d67f 100644 --- a/src/mgr/runner-retention.ts +++ b/src/mgr/runner-retention.ts @@ -750,8 +750,21 @@ function hasNoBusinessProgress(run: RunRecord, command: CommandRecord, commands: 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 phases = events.map((event) => typeof event.payload.phase === "string" ? event.payload.phase : null); - if (events.some((event, index) => event.type !== "backend_status" || phases[index] === null || !allowedPhases.has(phases[index] as string))) return false; + 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); } diff --git a/src/selftest/cases/22-runner-retention-stale-pending.ts b/src/selftest/cases/22-runner-retention-stale-pending.ts index 87d3097..547361e 100644 --- a/src/selftest/cases/22-runner-retention-stale-pending.ts +++ b/src/selftest/cases/22-runner-retention-stale-pending.ts @@ -110,6 +110,7 @@ const selfTest: SelfTestCase = async (context) => { try { await assertTwentyStalePendingSelectsOnlyOldest({ fixturePath, statePath, kubectlCommand }); + await assertLegacyNoUserMessageCandidate({ fixturePath, statePath, kubectlCommand }); await assertEventOnlyFailedMountCandidate({ fixturePath, statePath, kubectlCommand }); await assertProtectedMatrix({ fixturePath, statePath, kubectlCommand }); await assertPendingToRunningRaceFailsClosed({ fixturePath, statePath, kubectlCommand }); @@ -128,6 +129,9 @@ const selfTest: SelfTestCase = async (context) => { name: "runner-retention-stale-pending", tests: [ "stale-pending-typed-candidate", + "formal-user-message-is-not-runner-progress", + "legacy-no-user-message-candidate", + "user-message-command-binding-protected", "incoming-selects-only-oldest", "no-incoming-does-not-delete", "fresh-pending-protected", @@ -236,6 +240,25 @@ async function assertProtectedMatrix(input: { fixturePath: string; statePath: st 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"); + const mismatchedUserMessage = pendingFact(106, oldAt); + const userMessage = mismatchedUserMessage.events.find((event) => event.type === "user_message"); + assert.ok(userMessage); + userMessage.payload.commandId = "cmd-mismatched"; + await assertProtected(input, [mismatchedUserMessage], [runnerPod(mismatchedUserMessage, 106, "failed-config")], "business-progress-observed-or-unverifiable"); +} + +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 + .filter((event) => event.type !== "user_message") + .map((event, index) => ({ ...event, id: `evt-${fact.run.id}-${index + 1}`, seq: index + 1 })); + await writeFixture(input, { + jobs: [runnerJob(fact, 89)], + pods: [runnerPod(fact, 89, "failed-config")], + }); + const summary = await enforceRunnerRetentionBeforeCreate({ store: new RetentionFactStore([fact]), options: retentionOptions(input.kubectlCommand, 1), incomingRunnerCount: 1 }); + assert.equal(summary.selectedRunnerCount, 1); + assert.equal(firstSummary(summary, "selected").candidateKind, "stale-pending"); } async function assertEventOnlyFailedMountCandidate(input: { fixturePath: string; statePath: string; kubectlCommand: string }): Promise { @@ -495,11 +518,12 @@ function pendingFact(index: number, createdAt: string, variant: "pending" | "cla }; const events: RunEvent[] = [ runEvent(runId, 1, createdAt, "backend_status", { phase: "run-created" }), - runEvent(runId, 2, createdAt, "backend_status", { phase: "command-created", commandId }), - runEvent(runId, 3, createdAt, "backend_status", { phase: "runner-job-created", commandId }), - runEvent(runId, 4, createdAt, "backend_status", { phase: "runner-dispatch-completed", commandId }), - ...(claimed ? [runEvent(runId, 5, progressedAt, "backend_status", { phase: "run-claimed", commandId })] : []), - ...(toolExecuting ? [runEvent(runId, 5, progressedAt, "tool_call", { commandId, tool: "shell", phase: "started" })] : []), + runEvent(runId, 2, createdAt, "user_message", { commandId, traceId: `trc-retention-${suffix}`, userMessageId: `msg-retention-${suffix}`, text: "retention self-test", source: "agentrun-turn-command-payload", valuesPrinted: false }), + runEvent(runId, 3, createdAt, "backend_status", { phase: "command-created", commandId }), + runEvent(runId, 4, createdAt, "backend_status", { phase: "runner-job-created", commandId }), + runEvent(runId, 5, createdAt, "backend_status", { phase: "runner-dispatch-completed", commandId }), + ...(claimed ? [runEvent(runId, 6, progressedAt, "backend_status", { phase: "run-claimed", commandId })] : []), + ...(toolExecuting ? [runEvent(runId, 6, progressedAt, "tool_call", { commandId, tool: "shell", phase: "started" })] : []), ]; return { run, command, events }; }