fix(agents): bound compaction wake retry timeouts

This commit is contained in:
Peter Steinberger
2026-05-27 21:57:37 +01:00
parent ea2e9ce8bd
commit db549137d3
2 changed files with 106 additions and 55 deletions
+67 -11
View File
@@ -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.
+39 -44
View File
@@ -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;