Skip to content

Commit d31cb1b

Browse files
author
NianJiuZst
committed
fix(codex): bound native subagent completion retries
1 parent 1cd6f81 commit d31cb1b

2 files changed

Lines changed: 158 additions & 4 deletions

File tree

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

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,6 +1286,128 @@ describe("CodexNativeSubagentMonitor", () => {
12861286
}
12871287
});
12881288

1289+
it("uses every configured completion retry delay before a durable parent handoff", async () => {
1290+
vi.useFakeTimers();
1291+
try {
1292+
const client = createClient();
1293+
const runtime = createRuntime();
1294+
runtime.deliverAgentHarnessTaskCompletion
1295+
.mockResolvedValueOnce({
1296+
delivered: false,
1297+
path: "direct" as const,
1298+
error: "completion handoff is still pending",
1299+
})
1300+
.mockResolvedValueOnce({
1301+
delivered: false,
1302+
path: "direct" as const,
1303+
error: "completion handoff is still pending",
1304+
})
1305+
.mockResolvedValueOnce({
1306+
delivered: true,
1307+
path: "direct" as const,
1308+
phases: [{ phase: "direct-primary" as const, delivered: true, path: "direct" as const }],
1309+
});
1310+
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
1311+
completionDeliveryRetryDelaysMs: [10, 20],
1312+
});
1313+
monitor.registerParent({
1314+
parentThreadId: "parent-thread",
1315+
requesterSessionKey: "agent:main:discord:channel:C123",
1316+
taskRuntimeScope: createTaskScope(),
1317+
agentId: "main",
1318+
});
1319+
1320+
await notifyChildStarted(client);
1321+
await client.notify(
1322+
nativeCompletionNotification({
1323+
agentPath: "child-thread",
1324+
statusLabel: "completed",
1325+
result: "child final result",
1326+
}),
1327+
);
1328+
1329+
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(1);
1330+
1331+
await vi.advanceTimersByTimeAsync(10);
1332+
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(2);
1333+
1334+
await vi.advanceTimersByTimeAsync(20);
1335+
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(3);
1336+
expect(runtime.setDetachedTaskDeliveryStatusByRunId).toHaveBeenCalledWith(
1337+
expect.objectContaining({
1338+
runId: "codex-thread:child-thread",
1339+
deliveryStatus: "delivered",
1340+
}),
1341+
);
1342+
expect(runtime.setDetachedTaskDeliveryStatusByRunId).not.toHaveBeenCalledWith(
1343+
expect.objectContaining({
1344+
runId: "codex-thread:child-thread",
1345+
deliveryStatus: "failed",
1346+
}),
1347+
);
1348+
1349+
await vi.advanceTimersByTimeAsync(1_000);
1350+
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(3);
1351+
1352+
client.close();
1353+
} finally {
1354+
vi.useRealTimers();
1355+
}
1356+
});
1357+
1358+
it("fails completion delivery after exhausting retries on permanently non-durable handoff", async () => {
1359+
vi.useFakeTimers();
1360+
try {
1361+
const client = createClient();
1362+
const runtime = createRuntime();
1363+
runtime.deliverAgentHarnessTaskCompletion.mockResolvedValue({
1364+
delivered: false,
1365+
path: "direct" as const,
1366+
error: "completion delivery did not produce a parent response",
1367+
});
1368+
const monitor = new CodexNativeSubagentMonitor(client, runtime, {
1369+
completionDeliveryRetryDelaysMs: [10, 20],
1370+
});
1371+
monitor.registerParent({
1372+
parentThreadId: "parent-thread",
1373+
requesterSessionKey: "agent:main:discord:channel:C123",
1374+
taskRuntimeScope: createTaskScope(),
1375+
agentId: "main",
1376+
});
1377+
1378+
await notifyChildStarted(client);
1379+
await client.notify(
1380+
nativeCompletionNotification({
1381+
agentPath: "child-thread",
1382+
statusLabel: "completed",
1383+
result: "child final result",
1384+
}),
1385+
);
1386+
1387+
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(1);
1388+
1389+
await vi.advanceTimersByTimeAsync(10);
1390+
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(2);
1391+
1392+
await vi.advanceTimersByTimeAsync(20);
1393+
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(3);
1394+
expect(runtime.setDetachedTaskDeliveryStatusByRunId).toHaveBeenCalledWith(
1395+
expect.objectContaining({
1396+
runId: "codex-thread:child-thread",
1397+
deliveryStatus: "failed",
1398+
error: expect.stringContaining("retry schedule exhausted"),
1399+
}),
1400+
);
1401+
1402+
await vi.advanceTimersByTimeAsync(1_000);
1403+
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(3);
1404+
1405+
client.close();
1406+
} finally {
1407+
vi.useRealTimers();
1408+
}
1409+
});
1410+
12891411
it("reconciles completed native subagents from child rollout transcripts", async () => {
12901412
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-subagent-"));
12911413
const codexHome = path.join(tempDir, "codex-home");

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

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -614,15 +614,47 @@ export class CodexNativeSubagentMonitor {
614614
});
615615
}
616616

617+
private markCompletionDeliveryFailed(
618+
completion: CodexNativeSubagentCompletion,
619+
error: string,
620+
): void {
621+
const taskRuntime = this.getTaskRuntimeForChild(completion.childThreadId);
622+
if (!taskRuntime) {
623+
return;
624+
}
625+
taskRuntime.setDetachedTaskDeliveryStatusByRunId({
626+
runId: codexNativeSubagentRunId(completion.childThreadId),
627+
deliveryStatus: "failed",
628+
error,
629+
});
630+
}
631+
617632
private scheduleCompletionDeliveryRetry(childState: ChildState): void {
618633
if (!childState.pendingCompletion || childState.completionDeliveryTimer) {
619634
return;
620635
}
621636
const attempt = childState.completionDeliveryAttempt;
622-
const delayMs =
623-
this.completionDeliveryRetryDelaysMs[
624-
Math.min(attempt, this.completionDeliveryRetryDelaysMs.length - 1)
625-
];
637+
const delayMs = this.completionDeliveryRetryDelaysMs[attempt];
638+
// completionDeliveryAttempt tracks scheduled retries. Once every configured
639+
// delay has been consumed, settle the handoff instead of clamping forever.
640+
if (delayMs === undefined) {
641+
const completion = childState.pendingCompletion;
642+
const deliveryAttempts = childState.completionDeliveryAttempt + 1;
643+
const error =
644+
`completion delivery abandoned after ${deliveryAttempts} failed attempts; ` +
645+
"retry schedule exhausted";
646+
childState.pendingCompletion = undefined;
647+
childState.pendingCompletionEventAt = undefined;
648+
childState.completionDeliveryAttempt = 0;
649+
this.markCompletionDeliveryFailed(completion, error);
650+
embeddedAgentLog.warn("Codex native subagent completion delivery abandoned", {
651+
parentThreadId: childState.parentThreadId,
652+
childThreadId: completion.childThreadId,
653+
attempts: deliveryAttempts,
654+
error,
655+
});
656+
return;
657+
}
626658
childState.completionDeliveryAttempt += 1;
627659
childState.completionDeliveryTimer = setTimeout(() => {
628660
childState.completionDeliveryTimer = undefined;

0 commit comments

Comments
 (0)