Skip to content

Commit 2f89de8

Browse files
committed
fix(codex): preserve yielded native subagent delivery
1 parent 70add7d commit 2f89de8

4 files changed

Lines changed: 245 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
2222

2323
### Fixes
2424

25+
- **Codex yielded native subagents:** keep the parent app-server subscription and shared client alive until yielded native subagent completion delivery settles, preventing lost wakeups and leaked one-shot cleanup.
2526
- **Discord streamed finals:** send completion replies as fresh messages so inactive channels become unread, while preserving targeted mentions without escalating `@everyone` or `@here`. (#99711, #99662) Thanks @davelutztx.
2627
- **Cron edit delivery:** preserve each job's implicit delivery mode when applying partial delivery updates, so disabling best-effort delivery no longer turns detached job announcements off. (#100846) Thanks @machine3at.
2728
- **Control UI session creation:** keep newly created sessions at the front of the stable sidebar order after selecting another session. Thanks @shakkernerd.

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

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

796+
it("runs deferred parent cleanup after native subagent delivery settles", async () => {
797+
const client = createClient();
798+
const runtime = createRuntime();
799+
const monitor = new CodexNativeSubagentMonitor(client, runtime);
800+
const cleanup = vi.fn();
801+
monitor.registerParent({
802+
parentThreadId: "parent-thread",
803+
requesterSessionKey: "agent:main:main",
804+
taskRuntimeScope: createTaskScope("agent:main:main"),
805+
agentId: "main",
806+
});
807+
808+
await notifyChildStarted(client);
809+
monitor.deferUntilParentSettles("parent-thread", cleanup);
810+
expect(cleanup).not.toHaveBeenCalled();
811+
812+
await client.notify(
813+
nativeCompletionNotification({
814+
agentPath: "child-thread",
815+
statusLabel: "completed",
816+
result: "child final result",
817+
}),
818+
);
819+
820+
expect(cleanup).toHaveBeenCalledOnce();
821+
});
822+
823+
it("leaves immediate parent cleanup with the caller", () => {
824+
const client = createClient();
825+
const monitor = new CodexNativeSubagentMonitor(client, createRuntime());
826+
const cleanup = vi.fn();
827+
monitor.registerParent({
828+
parentThreadId: "parent-thread",
829+
requesterSessionKey: "agent:main:main",
830+
taskRuntimeScope: createTaskScope("agent:main:main"),
831+
agentId: "main",
832+
});
833+
834+
expect(monitor.deferUntilParentSettles("parent-thread", cleanup)).toBe(false);
835+
expect(cleanup).not.toHaveBeenCalled();
836+
});
837+
838+
it("runs deferred parent cleanup when an interrupted child has no completion to deliver", async () => {
839+
const client = createClient();
840+
const runtime = createRuntime();
841+
const monitor = new CodexNativeSubagentMonitor(client, runtime);
842+
const cleanup = vi.fn();
843+
monitor.registerParent({
844+
parentThreadId: "parent-thread",
845+
requesterSessionKey: "agent:main:main",
846+
taskRuntimeScope: createTaskScope("agent:main:main"),
847+
agentId: "main",
848+
});
849+
850+
await notifyChildStarted(client);
851+
monitor.deferUntilParentSettles("parent-thread", cleanup);
852+
await client.notify(childTurnCompletedNotification({ status: "interrupted" }));
853+
854+
expect(cleanup).toHaveBeenCalledOnce();
855+
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
856+
});
857+
858+
it("runs deferred parent cleanup when a child ends in a system error", async () => {
859+
const client = createClient();
860+
const runtime = createRuntime();
861+
const monitor = new CodexNativeSubagentMonitor(client, runtime);
862+
const cleanup = vi.fn();
863+
monitor.registerParent({
864+
parentThreadId: "parent-thread",
865+
requesterSessionKey: "agent:main:main",
866+
taskRuntimeScope: createTaskScope("agent:main:main"),
867+
agentId: "main",
868+
});
869+
870+
await notifyChildStarted(client);
871+
monitor.deferUntilParentSettles("parent-thread", cleanup);
872+
await client.notify({
873+
method: "thread/status/changed",
874+
params: {
875+
threadId: "child-thread",
876+
status: { type: "systemError" },
877+
},
878+
});
879+
880+
expect(cleanup).toHaveBeenCalledOnce();
881+
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
882+
});
883+
884+
it("waits again when an interrupted child starts another turn before cleanup is deferred", async () => {
885+
const client = createClient();
886+
const runtime = createRuntime();
887+
const monitor = new CodexNativeSubagentMonitor(client, runtime);
888+
const cleanup = vi.fn();
889+
monitor.registerParent({
890+
parentThreadId: "parent-thread",
891+
requesterSessionKey: "agent:main:main",
892+
taskRuntimeScope: createTaskScope("agent:main:main"),
893+
agentId: "main",
894+
});
895+
896+
await notifyChildStarted(client);
897+
await client.notify(childTurnCompletedNotification({ status: "interrupted" }));
898+
await client.notify({
899+
method: "turn/started",
900+
params: {
901+
threadId: "child-thread",
902+
turn: {
903+
id: "resumed-child-turn",
904+
status: "inProgress",
905+
items: [],
906+
error: null,
907+
},
908+
},
909+
});
910+
monitor.deferUntilParentSettles("parent-thread", cleanup);
911+
expect(cleanup).not.toHaveBeenCalled();
912+
913+
await client.notify(
914+
nativeCompletionNotification({
915+
agentPath: "child-thread",
916+
statusLabel: "completed",
917+
result: "resumed child final result",
918+
}),
919+
);
920+
921+
expect(cleanup).toHaveBeenCalledOnce();
922+
});
923+
796924
it("reconciles transcript final text before delivering empty Codex completion notifications", async () => {
797925
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-subagent-"));
798926
const codexHome = path.join(tempDir, "codex-home");

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

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type ParentState = {
4545
agentId?: string;
4646
taskRuntime?: AgentHarnessTaskRuntime;
4747
mirror?: CodexNativeSubagentTaskMirror;
48+
deferredSettlement?: () => Promise<void> | void;
4849
deliveredCompletionKeys: Set<string>;
4950
};
5051

@@ -62,6 +63,7 @@ type ChildState = {
6263
completionDeliveryTimer?: ReturnType<typeof setTimeout>;
6364
deliveringCompletionKey?: string;
6465
noFinalCompletionFallbackTimer?: ReturnType<typeof setTimeout>;
66+
settledWithoutCompletion: boolean;
6567
};
6668

6769
type ChildAssistantMessages = {
@@ -111,7 +113,7 @@ export function registerCodexNativeSubagentMonitor(params: {
111113
agentId?: string;
112114
codexHome?: string;
113115
runtime?: NativeSubagentMonitorRuntime;
114-
}): void {
116+
}): CodexNativeSubagentMonitor {
115117
let monitor = monitors.get(params.client);
116118
if (!monitor) {
117119
monitor = new CodexNativeSubagentMonitor(params.client, params.runtime ?? defaultRuntime, {
@@ -127,6 +129,7 @@ export function registerCodexNativeSubagentMonitor(params: {
127129
taskRuntimeScope: params.taskRuntimeScope,
128130
agentId: params.agentId,
129131
});
132+
return monitor;
130133
}
131134

132135
/** Tracks native subagent thread notifications, transcript completions, and task delivery. */
@@ -168,6 +171,18 @@ export class CodexNativeSubagentMonitor {
168171
this.transcriptPathsByChildThreadId.clear();
169172
}
170173

174+
deferUntilParentSettles(parentThreadId: string, callback: () => Promise<void> | void): boolean {
175+
const normalizedParentThreadId = parentThreadId.trim();
176+
const state = this.parentStates.get(normalizedParentThreadId);
177+
if (!state || !this.hasUnsettledChildren(normalizedParentThreadId)) {
178+
return false;
179+
}
180+
// A yielded one-shot turn must keep this monitor alive until its child
181+
// result reaches the parent; cleanup ownership transfers back afterward.
182+
state.deferredSettlement = callback;
183+
return true;
184+
}
185+
171186
configure(options: MonitorOptions): void {
172187
const codexHome = normalizeOptionalString(options.codexHome);
173188
if (codexHome) {
@@ -222,11 +237,42 @@ export class CodexNativeSubagentMonitor {
222237
});
223238
}
224239
}
240+
this.markChildTurnStarted(notification);
241+
await this.handleChildSystemError(notification);
225242
this.captureChildAssistantMessage(notification);
226243
await this.handleChildTurnCompletion(notification);
227244
await this.handleCompletionNotification(notification);
228245
}
229246

247+
private markChildTurnStarted(notification: CodexServerNotification): void {
248+
if (notification.method !== "turn/started") {
249+
return;
250+
}
251+
const params = isJsonObject(notification.params) ? notification.params : undefined;
252+
const childThreadId = readString(params, "threadId")?.trim();
253+
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
254+
if (childState) {
255+
childState.settledWithoutCompletion = false;
256+
}
257+
}
258+
259+
private async handleChildSystemError(notification: CodexServerNotification): Promise<void> {
260+
if (notification.method !== "thread/status/changed") {
261+
return;
262+
}
263+
const params = isJsonObject(notification.params) ? notification.params : undefined;
264+
const status = isJsonObject(params?.status) ? params.status : undefined;
265+
if (readString(status, "type") !== "systemError") {
266+
return;
267+
}
268+
const childThreadId = readString(params, "threadId")?.trim();
269+
const childState = childThreadId ? this.childStates.get(childThreadId) : undefined;
270+
if (childState) {
271+
childState.settledWithoutCompletion = true;
272+
await this.flushDeferredParentSettlements(childState.parentThreadId);
273+
}
274+
}
275+
230276
private ensureParentTaskRuntime(state: ParentState): void {
231277
if (state.taskRuntime || !state.requesterSessionKey || !state.taskRuntimeScope) {
232278
return;
@@ -434,6 +480,10 @@ export class CodexNativeSubagentMonitor {
434480
if (turnId) {
435481
childState.assistantMessagesByTurn.delete(turnId);
436482
}
483+
// Codex keeps interrupted agents resumable but intentionally sends no
484+
// parent completion, so one-shot cleanup may settle until another turn starts.
485+
childState.settledWithoutCompletion = true;
486+
await this.flushDeferredParentSettlements(childState.parentThreadId);
437487
return;
438488
}
439489
if (childState && turn) {
@@ -516,6 +566,7 @@ export class CodexNativeSubagentMonitor {
516566
}
517567
}
518568
if (!state.requesterSessionKey) {
569+
await this.flushDeferredParentSettlements(state.parentThreadId);
519570
return;
520571
}
521572
const completionKey = buildCompletionDedupeKey(state.parentThreadId, completion);
@@ -585,6 +636,7 @@ export class CodexNativeSubagentMonitor {
585636
});
586637
} finally {
587638
childState.deliveringCompletionKey = undefined;
639+
await this.flushDeferredParentSettlements(state.parentThreadId);
588640
}
589641
}
590642

@@ -635,6 +687,47 @@ export class CodexNativeSubagentMonitor {
635687
unrefTimer(childState.completionDeliveryTimer);
636688
}
637689

690+
private hasUnsettledChildren(parentThreadId: string): boolean {
691+
for (const childState of this.childStates.values()) {
692+
if (
693+
childState.parentThreadId === parentThreadId &&
694+
(childState.pendingCompletion !== undefined ||
695+
childState.deliveringCompletionKey !== undefined ||
696+
(!childState.transcriptTerminal && !childState.settledWithoutCompletion))
697+
) {
698+
return true;
699+
}
700+
}
701+
return false;
702+
}
703+
704+
private async flushDeferredParentSettlements(parentThreadId: string): Promise<void> {
705+
if (this.hasUnsettledChildren(parentThreadId)) {
706+
return;
707+
}
708+
const state = this.parentStates.get(parentThreadId);
709+
const callback = state?.deferredSettlement;
710+
if (!state || !callback) {
711+
return;
712+
}
713+
state.deferredSettlement = undefined;
714+
await this.runDeferredParentSettlement(parentThreadId, callback);
715+
}
716+
717+
private async runDeferredParentSettlement(
718+
parentThreadId: string,
719+
callback: () => Promise<void> | void,
720+
): Promise<void> {
721+
try {
722+
await callback();
723+
} catch (error) {
724+
embeddedAgentLog.warn("Failed to finish deferred Codex app-server cleanup", {
725+
parentThreadId,
726+
error: formatErrorMessage(error),
727+
});
728+
}
729+
}
730+
638731
private finalizeCompletionTask(completion: CodexNativeSubagentCompletion, eventAt: number): void {
639732
const taskRuntime = this.getTaskRuntimeForChild(completion.childThreadId);
640733
if (!taskRuntime) {
@@ -701,6 +794,7 @@ export class CodexNativeSubagentMonitor {
701794
transcriptPollAttempt: 0,
702795
transcriptTerminal: false,
703796
completionDeliveryAttempt: 0,
797+
settledWithoutCompletion: false,
704798
};
705799
this.childStates.set(normalizedChildThreadId, childState);
706800
}

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1432,6 +1432,9 @@ export async function runCodexAppServerAttempt(
14321432
trajectoryEndRecorded = true;
14331433
};
14341434
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
1435+
const nativeSubagentMonitorRef: {
1436+
current?: ReturnType<typeof registerCodexNativeSubagentMonitor>;
1437+
} = {};
14351438
const pendingNativePreToolUseFailures: CodexNativePreToolUseFailure[] = [];
14361439
const projectorRef: { current?: CodexAppServerEventProjector } = {};
14371440
let nativePreToolUseFailureFallbackActive = false;
@@ -2360,7 +2363,7 @@ export async function runCodexAppServerAttempt(
23602363
appServer.start.transport === "stdio"
23612364
? (appServer.start.env?.CODEX_HOME ?? resolveCodexAppServerHomeDir(agentDir))
23622365
: undefined;
2363-
registerCodexNativeSubagentMonitor({
2366+
nativeSubagentMonitorRef.current = registerCodexNativeSubagentMonitor({
23642367
client,
23652368
parentThreadId: thread.threadId,
23662369
requesterSessionKey: params.sessionKey,
@@ -3596,7 +3599,20 @@ export async function runCodexAppServerAttempt(
35963599
if (!timedOut && !runAbortController.signal.aborted) {
35973600
await steeringQueueRef.current?.flushPending();
35983601
}
3599-
if (!timedOut) {
3602+
const yieldedOneShotCleanupDeferred =
3603+
!timedOut &&
3604+
params.cleanupBundleMcpOnRunEnd === true &&
3605+
yieldDetected &&
3606+
nativeSubagentMonitorRef.current?.deferUntilParentSettles(thread.threadId, async () => {
3607+
// Keep the parent subscription alive until native child delivery;
3608+
// unsubscribing first drops the completion signal that settles cleanup.
3609+
await unsubscribeCodexThreadBestEffort(client, {
3610+
threadId: thread.threadId,
3611+
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
3612+
});
3613+
await releaseSharedClientLeaseAndRetireOneShotClient();
3614+
});
3615+
if (!timedOut && !yieldedOneShotCleanupDeferred) {
36003616
await unsubscribeCodexThreadBestEffort(client, {
36013617
threadId: thread.threadId,
36023618
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
@@ -3607,7 +3623,9 @@ export async function runCodexAppServerAttempt(
36073623
notificationCleanup();
36083624
requestCleanup();
36093625
closeCleanup?.();
3610-
await releaseSharedClientLeaseAndRetireOneShotClient();
3626+
if (!yieldedOneShotCleanupDeferred) {
3627+
await releaseSharedClientLeaseAndRetireOneShotClient();
3628+
}
36113629
if (nativeHookRelay) {
36123630
if (shouldDelayNativeHookRelayUnregister) {
36133631
// Codex hook subprocesses can outlive a completed app-server turn by a

0 commit comments

Comments
 (0)