fix: 兼容用户输入事件的陈旧 runner 判定

This commit is contained in:
root
2026-07-11 04:46:47 +02:00
parent 61ac6063b3
commit b081d7c2bb
2 changed files with 44 additions and 7 deletions
+15 -2
View File
@@ -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);
}
@@ -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<void> {
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<void> {
@@ -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 };
}