Skip to content

Commit 601f4cb

Browse files
committed
fix(codex): cap native-subagent completion delivery retries
The CodexNativeSubagentMonitor's completion delivery retry loop had no maximum attempt count or deadline. A permanently non-durable delivery (e.g. silent parent reply) would reschedule every 5 minutes forever, re-invoking deliverAgentHarnessTaskCompletion on each tick. Add DEFAULT_MAX_COMPLETION_DELIVERY_ATTEMPTS (6, matching the delay array length) and a completionDeliveryMaxAttempts option. When attempts reach the limit, mark the delivery as failed and clear the pending state instead of rescheduling. Fixes #97593
1 parent c0ee7a1 commit 601f4cb

2 files changed

Lines changed: 94 additions & 0 deletions

File tree

extensions/codex/src/app-server/native-subagent-monitor.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,64 @@ describe("CodexNativeSubagentMonitor", () => {
793793
);
794794
});
795795

796+
it("abandons completion delivery after max retry attempts", async () => {
797+
vi.useFakeTimers();
798+
const client = createClient();
799+
const runtime = createRuntime();
800+
// Permanently non-durable delivery
801+
runtime.deliverAgentHarnessTaskCompletion.mockResolvedValue({
802+
delivered: false,
803+
path: "none",
804+
error: "parent response silent",
805+
});
806+
const maxAttempts = 3;
807+
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
808+
completionDeliveryRetryDelaysMs: [100, 200, 300],
809+
completionDeliveryMaxAttempts: maxAttempts,
810+
});
811+
monitor.registerParent({
812+
parentThreadId: "parent-thread",
813+
requesterSessionKey: "agent:main:discord:channel:C123",
814+
taskRuntimeScope: createTaskScope(),
815+
agentId: "main",
816+
});
817+
818+
const completion = nativeCompletionNotification({
819+
agentPath: "child-thread",
820+
statusLabel: "completed",
821+
result: "child final result",
822+
});
823+
824+
await notifyChildStarted(client);
825+
await client.notify(completion);
826+
827+
// First delivery attempt happens immediately
828+
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(1);
829+
830+
// Advance through all retry delays
831+
for (let i = 0; i < maxAttempts + 2; i++) {
832+
await vi.advanceTimersByTimeAsync(500);
833+
}
834+
835+
// Should have attempted exactly maxAttempts times (1 initial + maxAttempts-1 retries)
836+
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(maxAttempts);
837+
838+
// Should have marked delivery as failed
839+
expect(runtime.setDetachedTaskDeliveryStatusByRunId).toHaveBeenCalledWith(
840+
expect.objectContaining({
841+
runId: "codex-thread:child-thread",
842+
deliveryStatus: "failed",
843+
error: expect.stringContaining("abandoned after"),
844+
}),
845+
);
846+
847+
// No more retries after give-up
848+
await vi.advanceTimersByTimeAsync(1_000_000);
849+
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(maxAttempts);
850+
851+
vi.useRealTimers();
852+
});
853+
796854
it("reconciles transcript final text before delivering empty Codex completion notifications", async () => {
797855
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-subagent-"));
798856
const codexHome = path.join(tempDir, "codex-home");

extensions/codex/src/app-server/native-subagent-monitor.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ type MonitorOptions = {
8080
codexHome?: string;
8181
transcriptPollDelaysMs?: readonly number[];
8282
completionDeliveryRetryDelaysMs?: readonly number[];
83+
completionDeliveryMaxAttempts?: number;
8384
taskRowReconcileIntervalMs?: number;
8485
};
8586

@@ -89,6 +90,7 @@ const DEFAULT_TRANSCRIPT_POLL_DELAYS_MS = [
8990
const DEFAULT_COMPLETION_DELIVERY_RETRY_DELAYS_MS = [
9091
5_000, 15_000, 30_000, 60_000, 120_000, 300_000,
9192
];
93+
const DEFAULT_MAX_COMPLETION_DELIVERY_ATTEMPTS = DEFAULT_COMPLETION_DELIVERY_RETRY_DELAYS_MS.length;
9294
const DEFAULT_TASK_ROW_RECONCILE_INTERVAL_MS = 10_000;
9395
const RECENT_TERMINAL_TASK_RECONCILE_GRACE_MS = 60_000;
9496
// Codex's recorder uses this filename contract; non-canonical names keep the
@@ -140,6 +142,7 @@ export class CodexNativeSubagentMonitor {
140142
private codexHome?: string;
141143
private transcriptPollDelaysMs: readonly number[];
142144
private completionDeliveryRetryDelaysMs: readonly number[];
145+
private maxCompletionDeliveryAttempts: number;
143146
private taskRowReconcileTimer?: ReturnType<typeof setInterval>;
144147

145148
constructor(
@@ -152,6 +155,8 @@ export class CodexNativeSubagentMonitor {
152155
options.transcriptPollDelaysMs ?? DEFAULT_TRANSCRIPT_POLL_DELAYS_MS;
153156
this.completionDeliveryRetryDelaysMs =
154157
options.completionDeliveryRetryDelaysMs ?? DEFAULT_COMPLETION_DELIVERY_RETRY_DELAYS_MS;
158+
this.maxCompletionDeliveryAttempts =
159+
options.completionDeliveryMaxAttempts ?? DEFAULT_MAX_COMPLETION_DELIVERY_ATTEMPTS;
155160
this.startTaskRowReconciler(
156161
options.taskRowReconcileIntervalMs ?? DEFAULT_TASK_ROW_RECONCILE_INTERVAL_MS,
157162
);
@@ -614,10 +619,41 @@ export class CodexNativeSubagentMonitor {
614619
});
615620
}
616621

622+
private markCompletionDeliveryFailed(
623+
completion: CodexNativeSubagentCompletion,
624+
error: string,
625+
): void {
626+
const taskRuntime = this.getTaskRuntimeForChild(completion.childThreadId);
627+
if (!taskRuntime) {
628+
return;
629+
}
630+
taskRuntime.setDetachedTaskDeliveryStatusByRunId({
631+
runId: codexNativeSubagentRunId(completion.childThreadId),
632+
deliveryStatus: "failed",
633+
error,
634+
});
635+
}
636+
617637
private scheduleCompletionDeliveryRetry(childState: ChildState): void {
618638
if (!childState.pendingCompletion || childState.completionDeliveryTimer) {
619639
return;
620640
}
641+
if (childState.completionDeliveryAttempt >= this.maxCompletionDeliveryAttempts - 1) {
642+
const completion = childState.pendingCompletion;
643+
childState.pendingCompletion = undefined;
644+
childState.pendingCompletionEventAt = undefined;
645+
childState.completionDeliveryAttempt = 0;
646+
this.markCompletionDeliveryFailed(
647+
completion,
648+
`completion delivery abandoned after ${this.maxCompletionDeliveryAttempts} attempts`,
649+
);
650+
embeddedAgentLog.warn("Codex native subagent completion delivery abandoned", {
651+
parentThreadId: childState.parentThreadId,
652+
childThreadId: completion.childThreadId,
653+
attempts: this.maxCompletionDeliveryAttempts,
654+
});
655+
return;
656+
}
621657
const attempt = childState.completionDeliveryAttempt;
622658
const delayMs =
623659
this.completionDeliveryRetryDelaysMs[

0 commit comments

Comments
 (0)