fix: harden queue retry debounce and add regression tests

This commit is contained in:
Peter Steinberger
2026-02-24 03:52:31 +00:00
parent a216f2dabe
commit 6c1ed9493c
9 changed files with 257 additions and 6 deletions

View File

@@ -1,7 +1,11 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest";
import { injectHistoryImagesIntoMessages, resolvePromptBuildHookResult } from "./attempt.js";
import {
injectHistoryImagesIntoMessages,
resolvePromptBuildHookResult,
resolvePromptModeForSession,
} from "./attempt.js";
describe("injectHistoryImagesIntoMessages", () => {
const image: ImageContent = { type: "image", data: "abc", mimeType: "image/png" };
@@ -103,3 +107,14 @@ describe("resolvePromptBuildHookResult", () => {
expect(result.prependContext).toBe("from-hook");
});
});
describe("resolvePromptModeForSession", () => {
it("uses minimal mode for subagent sessions", () => {
expect(resolvePromptModeForSession("agent:main:subagent:child")).toBe("minimal");
});
it("uses full mode for cron sessions", () => {
expect(resolvePromptModeForSession("agent:main:cron:job-1")).toBe("full");
expect(resolvePromptModeForSession("agent:main:cron:job-1:run:run-abc")).toBe("full");
});
});

View File

@@ -221,6 +221,13 @@ export async function resolvePromptBuildHookResult(params: {
};
}
export function resolvePromptModeForSession(sessionKey?: string): "minimal" | "full" {
if (!sessionKey) {
return "full";
}
return isSubagentSessionKey(sessionKey) ? "minimal" : "full";
}
function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } {
const content = (msg as { content?: unknown }).content;
if (typeof content === "string") {
@@ -494,7 +501,7 @@ export async function runEmbeddedAttempt(
},
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const promptMode = resolvePromptModeForSession(params.sessionKey);
const docsPath = await resolveOpenClawDocsPath({
workspaceDir: effectiveWorkspace,
argv1: process.argv[1],

View File

@@ -27,6 +27,7 @@ function createRetryingSend() {
describe("subagent-announce-queue", () => {
afterEach(() => {
vi.useRealTimers();
resetAnnounceQueuesForTests();
});
@@ -116,4 +117,52 @@ describe("subagent-announce-queue", () => {
expect(sender.prompts[1]).toContain("Queued #2");
expect(sender.prompts[1]).toContain("queued item two");
});
it("uses debounce floor for retries when debounce exceeds backoff", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
const previousFast = process.env.OPENCLAW_TEST_FAST;
delete process.env.OPENCLAW_TEST_FAST;
try {
const attempts: number[] = [];
const send = vi.fn(async () => {
attempts.push(Date.now());
if (attempts.length === 1) {
throw new Error("transient timeout");
}
});
enqueueAnnounce({
key: "announce:test:retry-debounce-floor",
item: {
prompt: "subagent completed",
enqueuedAt: Date.now(),
sessionKey: "agent:main:telegram:dm:u1",
},
settings: { mode: "followup", debounceMs: 5_000 },
send,
});
await vi.advanceTimersByTimeAsync(5_000);
expect(send).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(4_999);
expect(send).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(send).toHaveBeenCalledTimes(2);
const [firstAttempt, secondAttempt] = attempts;
if (firstAttempt === undefined || secondAttempt === undefined) {
throw new Error("expected two retry attempts");
}
expect(secondAttempt - firstAttempt).toBeGreaterThanOrEqual(5_000);
} finally {
if (previousFast === undefined) {
delete process.env.OPENCLAW_TEST_FAST;
} else {
process.env.OPENCLAW_TEST_FAST = previousFast;
}
}
});
});

View File

@@ -183,9 +183,10 @@ function scheduleAnnounceDrain(key: string) {
queue.consecutiveFailures++;
// Exponential backoff on consecutive failures: 2s, 4s, 8s, ... capped at 60s.
const errorBackoffMs = Math.min(1000 * Math.pow(2, queue.consecutiveFailures), 60_000);
queue.lastEnqueuedAt = Date.now() + errorBackoffMs - queue.debounceMs;
const retryDelayMs = Math.max(errorBackoffMs, queue.debounceMs);
queue.lastEnqueuedAt = Date.now() + retryDelayMs - queue.debounceMs;
defaultRuntime.error?.(
`announce queue drain failed for ${key} (attempt ${queue.consecutiveFailures}, retry in ${Math.round(errorBackoffMs / 1000)}s): ${String(err)}`,
`announce queue drain failed for ${key} (attempt ${queue.consecutiveFailures}, retry in ${Math.round(retryDelayMs / 1000)}s): ${String(err)}`,
);
} finally {
queue.draining = false;
@@ -205,7 +206,8 @@ export function enqueueAnnounce(params: {
send: (item: AnnounceQueueItem) => Promise<void>;
}): boolean {
const queue = getAnnounceQueue(params.key, params.settings, params.send);
queue.lastEnqueuedAt = Date.now();
// Preserve any retry backoff marker already encoded in lastEnqueuedAt.
queue.lastEnqueuedAt = Math.max(queue.lastEnqueuedAt, Date.now());
const shouldEnqueue = applyQueueDropPolicy({
queue,