mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(agents): bound compaction wake retry timeouts
This commit is contained in:
@@ -626,6 +626,7 @@ describe("resolveSubagentCompletionOrigin", () => {
|
||||
describe("deliverSubagentAnnouncement active requester steering", () => {
|
||||
async function deliverSteeredAnnouncement(params: {
|
||||
mode?: "followup" | "collect" | "interrupt";
|
||||
announceTimeoutMs?: number;
|
||||
queueEmbeddedAgentMessageWithOutcome?: QueueEmbeddedAgentMessageWithOutcome;
|
||||
requesterOrigin?: {
|
||||
channel?: string;
|
||||
@@ -646,6 +647,17 @@ describe("deliverSubagentAnnouncement active requester steering", () => {
|
||||
params.queueEmbeddedAgentMessageWithOutcome ?? createQueueOutcomeMock(true),
|
||||
getRuntimeConfig: () =>
|
||||
({
|
||||
...(params.announceTimeoutMs !== undefined
|
||||
? {
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
announceTimeoutMs: params.announceTimeoutMs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
messages: {
|
||||
queue: {
|
||||
mode: params.mode ?? "followup",
|
||||
@@ -805,17 +817,14 @@ describe("deliverSubagentAnnouncement active requester steering", () => {
|
||||
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledTimes(2);
|
||||
expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"paperclip-session",
|
||||
"child done",
|
||||
{
|
||||
steeringMode: "all",
|
||||
debounceMs: 0,
|
||||
waitForTranscriptCommit: true,
|
||||
deliveryTimeoutMs: 120_000,
|
||||
},
|
||||
);
|
||||
const retryOptions = mockCallArg(queueEmbeddedAgentMessageWithOutcome, 1, 2);
|
||||
expectRecordFields(retryOptions, {
|
||||
steeringMode: "all",
|
||||
debounceMs: 0,
|
||||
waitForTranscriptCommit: true,
|
||||
});
|
||||
expect(retryOptions.deliveryTimeoutMs).toBeGreaterThan(0);
|
||||
expect(retryOptions.deliveryTimeoutMs).toBeLessThan(120_000);
|
||||
} finally {
|
||||
if (previousTestFast === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_FAST;
|
||||
@@ -862,6 +871,53 @@ describe("deliverSubagentAnnouncement active requester steering", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("passes the remaining delivery window into compaction retries (86566)", async () => {
|
||||
const previousTestFast = process.env.OPENCLAW_TEST_FAST;
|
||||
process.env.OPENCLAW_TEST_FAST = "1";
|
||||
try {
|
||||
const queueEmbeddedAgentMessageWithOutcome = vi
|
||||
.fn<QueueEmbeddedAgentMessageWithOutcome>()
|
||||
.mockImplementationOnce((sessionId: string) => ({
|
||||
queued: false,
|
||||
sessionId,
|
||||
reason: "compacting",
|
||||
gatewayHealth: "live",
|
||||
}))
|
||||
.mockImplementationOnce((sessionId: string) => ({
|
||||
queued: true,
|
||||
sessionId,
|
||||
target: "embedded_run",
|
||||
gatewayHealth: "live",
|
||||
}));
|
||||
const callGateway = await deliverSteeredAnnouncement({
|
||||
announceTimeoutMs: 500,
|
||||
queueEmbeddedAgentMessageWithOutcome,
|
||||
requesterOrigin: {
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
accountId: "acct-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledTimes(2);
|
||||
const retryOptions = mockCallArg(queueEmbeddedAgentMessageWithOutcome, 1, 2);
|
||||
expectRecordFields(retryOptions, {
|
||||
steeringMode: "all",
|
||||
debounceMs: 0,
|
||||
waitForTranscriptCommit: true,
|
||||
});
|
||||
expect(retryOptions.deliveryTimeoutMs).toBeGreaterThan(0);
|
||||
expect(retryOptions.deliveryTimeoutMs).toBeLessThan(500);
|
||||
} finally {
|
||||
if (previousTestFast === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_FAST;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_FAST = previousTestFast;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not retry non-compacting steer failures (86566)", async () => {
|
||||
// Only compacting is treated as transient; other wake failures keep their
|
||||
// existing single-attempt fallback behavior.
|
||||
|
||||
@@ -222,42 +222,39 @@ function resolveCompactionSteerRetryDelaysMs() {
|
||||
: ([1_000, 2_000, 4_000, 8_000] as const);
|
||||
}
|
||||
|
||||
// Wake an active requester run, retrying through two transient outcomes:
|
||||
// - transcript_commit_wait_unsupported: retry once without the transcript-commit
|
||||
// wait (best-effort steering for runtimes that cannot wait for commit).
|
||||
// - compacting: the run becomes steerable again once compaction finishes, so
|
||||
// wait through compaction and retry the same wake, bounded by cancellation and
|
||||
// a finite backoff schedule (well within the delivery timeout).
|
||||
// Both the steer path and the generated-completion active wake use this helper so
|
||||
// the retry behavior stays consistent. Other failure reasons are returned as-is
|
||||
// for the caller's existing fallback handling. The two transient retries are
|
||||
// handled in a single loop so a run that compacts and then reports
|
||||
// transcript_commit_wait_unsupported still gets the best-effort retry.
|
||||
// Wake an active requester run through transient compacting and transcript-wait
|
||||
// outcomes. Both active-wake call sites use one loop so delivery deadlines and
|
||||
// best-effort transcript retry stay consistent.
|
||||
async function resolveActiveWakeWithRetries(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
wakeOptions: EmbeddedAgentQueueMessageOptions,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EmbeddedAgentQueueMessageOutcome> {
|
||||
let currentOptions = wakeOptions;
|
||||
let outcome = await resolveQueueEmbeddedAgentMessageOutcome(
|
||||
sessionId,
|
||||
message,
|
||||
currentOptions,
|
||||
);
|
||||
const compactionRetryDelaysMs = resolveCompactionSteerRetryDelaysMs();
|
||||
let compactionRetryIndex = 0;
|
||||
// Bound compaction waiting by the delivery timeout (the window the issue asks
|
||||
// us to wait within), not just the fixed backoff schedule: a compaction that
|
||||
// finishes after the schedule is exhausted but still inside the delivery
|
||||
// timeout should keep being retried. The backoff schedule controls the gap
|
||||
// between attempts; once it is exhausted the last delay is reused until the
|
||||
// deadline. A missing/zero timeout falls back to the bounded schedule only.
|
||||
// Bound the whole active wake by the caller's delivery window. Each retry
|
||||
// passes only the remaining window into transcript-commit waiting so a
|
||||
// near-deadline retry cannot add another full timeout.
|
||||
const compactionDeadlineMs =
|
||||
typeof wakeOptions.deliveryTimeoutMs === "number" &&
|
||||
wakeOptions.deliveryTimeoutMs > 0
|
||||
typeof wakeOptions.deliveryTimeoutMs === "number" && wakeOptions.deliveryTimeoutMs > 0
|
||||
? Date.now() + wakeOptions.deliveryTimeoutMs
|
||||
: undefined;
|
||||
let currentOptions = wakeOptions;
|
||||
const resolveRetryOptions = (): EmbeddedAgentQueueMessageOptions | undefined => {
|
||||
if (compactionDeadlineMs === undefined) {
|
||||
return currentOptions;
|
||||
}
|
||||
const remainingDeliveryTimeoutMs = compactionDeadlineMs - Date.now();
|
||||
if (remainingDeliveryTimeoutMs <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...currentOptions,
|
||||
deliveryTimeoutMs: remainingDeliveryTimeoutMs,
|
||||
};
|
||||
};
|
||||
let outcome = await resolveQueueEmbeddedAgentMessageOutcome(sessionId, message, currentOptions);
|
||||
const compactionRetryDelaysMs = resolveCompactionSteerRetryDelaysMs();
|
||||
let compactionRetryIndex = 0;
|
||||
for (;;) {
|
||||
if (outcome.queued || signal?.aborted) {
|
||||
break;
|
||||
@@ -269,19 +266,17 @@ async function resolveActiveWakeWithRetries(
|
||||
const bestEffortOptions = { ...currentOptions };
|
||||
delete bestEffortOptions.waitForTranscriptCommit;
|
||||
currentOptions = bestEffortOptions;
|
||||
outcome = await resolveQueueEmbeddedAgentMessageOutcome(
|
||||
sessionId,
|
||||
message,
|
||||
currentOptions,
|
||||
);
|
||||
outcome = await resolveQueueEmbeddedAgentMessageOutcome(sessionId, message, currentOptions);
|
||||
continue;
|
||||
}
|
||||
if (outcome.reason === "compacting") {
|
||||
const withinDeadline =
|
||||
compactionDeadlineMs === undefined
|
||||
const remainingDeliveryTimeoutMs =
|
||||
compactionDeadlineMs === undefined ? undefined : compactionDeadlineMs - Date.now();
|
||||
const canRetry =
|
||||
remainingDeliveryTimeoutMs === undefined
|
||||
? compactionRetryIndex < compactionRetryDelaysMs.length
|
||||
: Date.now() < compactionDeadlineMs;
|
||||
if (!withinDeadline) {
|
||||
: remainingDeliveryTimeoutMs > 0;
|
||||
if (!canRetry) {
|
||||
break;
|
||||
}
|
||||
// Use the next scheduled backoff delay; once the schedule is exhausted,
|
||||
@@ -294,10 +289,10 @@ async function resolveActiveWakeWithRetries(
|
||||
// not sleep past the deadline (which would overrun the delivery timeout).
|
||||
// If no time remains, stop retrying and let the fallback handle it.
|
||||
const delayMs =
|
||||
compactionDeadlineMs === undefined
|
||||
remainingDeliveryTimeoutMs === undefined
|
||||
? scheduledDelayMs
|
||||
: Math.min(scheduledDelayMs, compactionDeadlineMs - Date.now());
|
||||
if (delayMs <= 0 && compactionDeadlineMs !== undefined) {
|
||||
: Math.min(scheduledDelayMs, remainingDeliveryTimeoutMs);
|
||||
if (delayMs <= 0 && remainingDeliveryTimeoutMs !== undefined) {
|
||||
break;
|
||||
}
|
||||
await waitForAnnounceRetryDelay(delayMs, signal);
|
||||
@@ -305,11 +300,11 @@ async function resolveActiveWakeWithRetries(
|
||||
break;
|
||||
}
|
||||
compactionRetryIndex += 1;
|
||||
outcome = await resolveQueueEmbeddedAgentMessageOutcome(
|
||||
sessionId,
|
||||
message,
|
||||
currentOptions,
|
||||
);
|
||||
const retryOptions = resolveRetryOptions();
|
||||
if (!retryOptions) {
|
||||
break;
|
||||
}
|
||||
outcome = await resolveQueueEmbeddedAgentMessageOutcome(sessionId, message, retryOptions);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user