mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(codex): prevent session handoffs and native tasks from losing thread ownership (#120740)
* fix(codex): preserve thread ownership across session lifecycles * test(codex): keep canonical context-engine session fixtures * fix(codex): fence replacement thread rollback by ownership * fix(codex): fence thread handoffs by physical client ownership * fix(codex): derive conversation privacy from its source session * fix(codex): claim active native subagents at their lifecycle owner * fix(codex): preserve conversation ownership when detach fails * fix(codex): fence stale native child close notifications * fix(codex): preserve active child ownership during rollback * test(codex): keep lifecycle fixtures aligned with public contracts * fix(codex): preserve rollback failure causes across ownership recovery * fix(codex): retain aggregate rollback causes through lint analysis * fix(codex): release native children when idle retention fails
This commit is contained in:
committed by
GitHub
parent
3f1f30d939
commit
45dd558d92
@@ -2,6 +2,7 @@
|
||||
* Best-effort cleanup helpers for Codex app-server startup attempts and turns.
|
||||
*/
|
||||
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { unsubscribeCodexAppServerLiveThread } from "./client-runtime.js";
|
||||
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
|
||||
import { retireSharedCodexAppServerClientIfCurrent } from "./shared-client.js";
|
||||
import { getCodexAppServerTurnRouter } from "./turn-router.js";
|
||||
@@ -146,11 +147,7 @@ export async function unsubscribeCodexThreadBestEffort(
|
||||
},
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await client.request(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: params.threadId },
|
||||
{ timeoutMs: params.timeoutMs },
|
||||
);
|
||||
await unsubscribeCodexAppServerLiveThread(client, params.threadId, params.timeoutMs);
|
||||
return true;
|
||||
} catch (error) {
|
||||
embeddedAgentLog.debug("codex app-server thread unsubscribe cleanup failed", {
|
||||
|
||||
@@ -19,11 +19,14 @@ vi.mock("./rate-limit-cache.js", () => ({
|
||||
}));
|
||||
|
||||
const {
|
||||
claimCodexAppServerLiveThread,
|
||||
consumeCodexAppServerLiveThread,
|
||||
ensureCodexAppServerClientRuntime,
|
||||
isCodexAppServerLiveThreadClaimed,
|
||||
protectCodexAppServerLiveThread,
|
||||
releaseCodexAppServerLiveThread,
|
||||
retainCodexAppServerLiveThread,
|
||||
unsubscribeCodexAppServerLiveThread,
|
||||
} = await import("./client-runtime.js");
|
||||
|
||||
describe("Codex app-server client runtime", () => {
|
||||
@@ -132,6 +135,235 @@ describe("Codex app-server client runtime", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("claims a fresh auto-subscribed child without exposing or evicting an idle owner", async () => {
|
||||
const request = vi.fn(async () => ({}));
|
||||
const client = {
|
||||
request,
|
||||
addCloseHandler: vi.fn(),
|
||||
addNotificationHandler: vi.fn(),
|
||||
addRequestHandler: vi.fn(),
|
||||
} as unknown as CodexAppServerClient;
|
||||
ensureCodexAppServerClientRuntime(client, { agentDir: "/tmp/agent" });
|
||||
const idleRelease = vi.fn(async () => undefined);
|
||||
for (let index = 0; index < EXPECTED_MAX_IDLE_LIVE_THREADS; index += 1) {
|
||||
await retainCodexAppServerLiveThread(client, `thread-idle-${index}`, idleRelease);
|
||||
}
|
||||
|
||||
const ownership = await claimCodexAppServerLiveThread(client, "thread-fresh-child");
|
||||
|
||||
expect(ownership).toEqual(expect.objectContaining({ release: expect.any(Function) }));
|
||||
expect(isCodexAppServerLiveThreadClaimed(client, "thread-fresh-child")).toBe(true);
|
||||
expect(idleRelease).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
claimCodexAppServerLiveThread(client, "thread-fresh-child"),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(retainCodexAppServerLiveThread(client, "thread-fresh-child")).resolves.toBe(false);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(client, "thread-fresh-child"),
|
||||
).resolves.toBeUndefined();
|
||||
await ownership?.release("thread-fresh-child");
|
||||
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-fresh-child" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
expect(isCodexAppServerLiveThreadClaimed(client, "thread-fresh-child")).toBe(false);
|
||||
expect(idleRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an active claim until its exact ownership is successfully released", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
||||
const release = vi
|
||||
.fn<(threadId: string) => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("unsubscribe unavailable"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-claimed", release);
|
||||
const ownership = await consumeCodexAppServerLiveThread(harness.client, "thread-claimed");
|
||||
|
||||
expect(isCodexAppServerLiveThreadClaimed(harness.client, "thread-claimed")).toBe(true);
|
||||
await expect(ownership?.release("thread-claimed")).rejects.toThrow("unsubscribe unavailable");
|
||||
expect(isCodexAppServerLiveThreadClaimed(harness.client, "thread-claimed")).toBe(true);
|
||||
await expect(ownership?.release("thread-claimed")).resolves.toBeUndefined();
|
||||
expect(isCodexAppServerLiveThreadClaimed(harness.client, "thread-claimed")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unproven or stale ownership before transferring an active claim", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-owned");
|
||||
const current = await consumeCodexAppServerLiveThread(harness.client, "thread-owned");
|
||||
|
||||
await expect(retainCodexAppServerLiveThread(harness.client, "thread-owned")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(
|
||||
retainCodexAppServerLiveThread(harness.client, "thread-owned", async () => undefined),
|
||||
).resolves.toBe(false);
|
||||
expect(isCodexAppServerLiveThreadClaimed(harness.client, "thread-owned")).toBe(true);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(harness.client, "thread-owned"),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
retainCodexAppServerLiveThread(harness.client, "thread-owned", current?.release),
|
||||
).resolves.toBe(true);
|
||||
const successor = await consumeCodexAppServerLiveThread(harness.client, "thread-owned");
|
||||
|
||||
await expect(
|
||||
retainCodexAppServerLiveThread(harness.client, "thread-owned", current?.release),
|
||||
).resolves.toBe(false);
|
||||
expect(isCodexAppServerLiveThreadClaimed(harness.client, "thread-owned")).toBe(true);
|
||||
await expect(
|
||||
retainCodexAppServerLiveThread(harness.client, "thread-owned", successor?.release),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("does not let an older release erase a newly claimed thread generation", async () => {
|
||||
const request = vi.fn(async () => ({}));
|
||||
const client = {
|
||||
request,
|
||||
addCloseHandler: vi.fn(),
|
||||
addNotificationHandler: vi.fn(),
|
||||
addRequestHandler: vi.fn(),
|
||||
} as unknown as CodexAppServerClient;
|
||||
ensureCodexAppServerClientRuntime(client, { agentDir: "/tmp/agent" });
|
||||
await retainCodexAppServerLiveThread(client, "thread-reclaimed");
|
||||
const first = await consumeCodexAppServerLiveThread(client, "thread-reclaimed");
|
||||
await retainCodexAppServerLiveThread(client, "thread-reclaimed", first?.release);
|
||||
expect(isCodexAppServerLiveThreadClaimed(client, "thread-reclaimed")).toBe(false);
|
||||
const second = await consumeCodexAppServerLiveThread(client, "thread-reclaimed");
|
||||
|
||||
await first?.release("thread-reclaimed");
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(isCodexAppServerLiveThreadClaimed(client, "thread-reclaimed")).toBe(true);
|
||||
await second?.release("thread-reclaimed");
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-reclaimed" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
expect(isCodexAppServerLiveThreadClaimed(client, "thread-reclaimed")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks same-thread replacement until its claimed unsubscribe is acknowledged", async () => {
|
||||
let acknowledgeUnsubscribe: (() => void) | undefined;
|
||||
const unsubscribeAcknowledged = new Promise<void>((resolve) => {
|
||||
acknowledgeUnsubscribe = resolve;
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/unsubscribe") {
|
||||
await unsubscribeAcknowledged;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const client = {
|
||||
request,
|
||||
addCloseHandler: vi.fn(),
|
||||
addNotificationHandler: vi.fn(),
|
||||
addRequestHandler: vi.fn(),
|
||||
} as unknown as CodexAppServerClient;
|
||||
ensureCodexAppServerClientRuntime(client, { agentDir: "/tmp/agent" });
|
||||
await retainCodexAppServerLiveThread(client, "thread-transition");
|
||||
const previous = await consumeCodexAppServerLiveThread(client, "thread-transition");
|
||||
const releasing = previous?.release("thread-transition");
|
||||
const duplicateRelease = previous?.release("thread-transition");
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledOnce());
|
||||
const overlappingPhysicalRelease = unsubscribeCodexAppServerLiveThread(
|
||||
client,
|
||||
"thread-transition",
|
||||
5_000,
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
const replacement = retainCodexAppServerLiveThread(
|
||||
client,
|
||||
"thread-transition",
|
||||
previous?.release,
|
||||
);
|
||||
const blockedClaim = consumeCodexAppServerLiveThread(client, "thread-transition");
|
||||
let replacementPublished = false;
|
||||
void replacement.then(() => {
|
||||
replacementPublished = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(replacementPublished).toBe(false);
|
||||
expect(isCodexAppServerLiveThreadClaimed(client, "thread-transition")).toBe(true);
|
||||
acknowledgeUnsubscribe?.();
|
||||
|
||||
await expect(releasing).resolves.toBeUndefined();
|
||||
await expect(duplicateRelease).resolves.toBeUndefined();
|
||||
await expect(overlappingPhysicalRelease).resolves.toBeUndefined();
|
||||
await expect(replacement).resolves.toBe(false);
|
||||
await expect(blockedClaim).resolves.toBeUndefined();
|
||||
await client.request("thread/resume", { threadId: "thread-transition" }, { timeoutMs: 5_000 });
|
||||
await expect(
|
||||
retainCodexAppServerLiveThread(client, "thread-transition", previous?.release),
|
||||
).resolves.toBe(true);
|
||||
const successor = await consumeCodexAppServerLiveThread(client, "thread-transition");
|
||||
await successor?.release("thread-transition");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/unsubscribe",
|
||||
"thread/resume",
|
||||
"thread/unsubscribe",
|
||||
]);
|
||||
});
|
||||
|
||||
it("finishes a claimed thread when its direct physical unsubscribe succeeds", async () => {
|
||||
const request = vi.fn(async () => ({}));
|
||||
request.mockRejectedValueOnce(new Error("unsubscribe unavailable"));
|
||||
const client = {
|
||||
request,
|
||||
addCloseHandler: vi.fn(),
|
||||
addNotificationHandler: vi.fn(),
|
||||
addRequestHandler: vi.fn(),
|
||||
} as unknown as CodexAppServerClient;
|
||||
ensureCodexAppServerClientRuntime(client, { agentDir: "/tmp/agent" });
|
||||
await retainCodexAppServerLiveThread(client, "thread-direct");
|
||||
await consumeCodexAppServerLiveThread(client, "thread-direct");
|
||||
|
||||
const failedRelease = unsubscribeCodexAppServerLiveThread(client, "thread-direct", 5_000);
|
||||
const failedJoin = unsubscribeCodexAppServerLiveThread(client, "thread-direct", 5_000);
|
||||
await expect(Promise.all([failedRelease, failedJoin])).rejects.toThrow(
|
||||
"unsubscribe unavailable",
|
||||
);
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
expect(isCodexAppServerLiveThreadClaimed(client, "thread-direct")).toBe(true);
|
||||
await unsubscribeCodexAppServerLiveThread(client, "thread-direct", 5_000);
|
||||
|
||||
expect(request).toHaveBeenLastCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-direct" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
expect(isCodexAppServerLiveThreadClaimed(client, "thread-direct")).toBe(false);
|
||||
});
|
||||
|
||||
it("clears claimed ownership when Codex closes the thread or its physical client", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-closed");
|
||||
await consumeCodexAppServerLiveThread(harness.client, "thread-closed");
|
||||
|
||||
harness.send({ method: "thread/closed", params: { threadId: "thread-closed" } });
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(isCodexAppServerLiveThreadClaimed(harness.client, "thread-closed")).toBe(false),
|
||||
);
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-client-closed");
|
||||
await consumeCodexAppServerLiveThread(harness.client, "thread-client-closed");
|
||||
expect(isCodexAppServerLiveThreadClaimed(harness.client, "thread-client-closed")).toBe(true);
|
||||
|
||||
harness.client.close();
|
||||
|
||||
expect(isCodexAppServerLiveThreadClaimed(harness.client, "thread-client-closed")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks only the exact thread whose subscription is being released", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
@@ -154,7 +386,7 @@ describe("Codex app-server client runtime", () => {
|
||||
await expect(sameThreadAcquisition).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not re-expose a failed release or discard an unrelated conversation", async () => {
|
||||
it("preserves a failed idle release and its unrelated conversation for retry", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
||||
@@ -166,9 +398,9 @@ describe("Codex app-server client runtime", () => {
|
||||
await expect(releaseCodexAppServerLiveThread(harness.client, "thread-a")).rejects.toThrow(
|
||||
"unsubscribe unavailable",
|
||||
);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(harness.client, "thread-a"),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(consumeCodexAppServerLiveThread(harness.client, "thread-a")).resolves.toEqual(
|
||||
expect.objectContaining({ release: expect.any(Function) }),
|
||||
);
|
||||
await expect(consumeCodexAppServerLiveThread(harness.client, "thread-b")).resolves.toEqual(
|
||||
expect.objectContaining({ release: expect.any(Function) }),
|
||||
);
|
||||
@@ -206,9 +438,66 @@ describe("Codex app-server client runtime", () => {
|
||||
}
|
||||
|
||||
expect(release).toHaveBeenCalledExactlyOnceWith("thread-0");
|
||||
await expect(consumeCodexAppServerLiveThread(harness.client, "thread-1")).resolves.toEqual(
|
||||
expect.objectContaining({ release }),
|
||||
const retained = await consumeCodexAppServerLiveThread(harness.client, "thread-1");
|
||||
expect(retained).toEqual(expect.objectContaining({ release: expect.any(Function) }));
|
||||
await retained?.release("thread-1");
|
||||
expect(release).toHaveBeenLastCalledWith("thread-1");
|
||||
});
|
||||
|
||||
it("rolls back the new owner when capacity eviction cannot release its oldest thread", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
||||
const failingRelease = vi
|
||||
.fn<(threadId: string) => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("oldest unsubscribe failed"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-oldest", failingRelease);
|
||||
for (let index = 1; index < EXPECTED_MAX_IDLE_LIVE_THREADS; index += 1) {
|
||||
await retainCodexAppServerLiveThread(harness.client, `thread-${index}`);
|
||||
}
|
||||
|
||||
await expect(retainCodexAppServerLiveThread(harness.client, "thread-overflow")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
expect(failingRelease).toHaveBeenCalledOnce();
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(harness.client, "thread-overflow"),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(consumeCodexAppServerLiveThread(harness.client, "thread-1")).resolves.toEqual(
|
||||
expect.objectContaining({ release: expect.any(Function) }),
|
||||
);
|
||||
await expect(releaseCodexAppServerLiveThread(harness.client, "thread-oldest")).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
expect(failingRelease).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("cannot resurrect an overflow owner after the physical client closes during eviction", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
||||
let finishRelease: (() => void) | undefined;
|
||||
const pendingRelease = new Promise<void>((resolve) => {
|
||||
finishRelease = resolve;
|
||||
});
|
||||
await retainCodexAppServerLiveThread(
|
||||
harness.client,
|
||||
"thread-oldest",
|
||||
async () => pendingRelease,
|
||||
);
|
||||
for (let index = 1; index < EXPECTED_MAX_IDLE_LIVE_THREADS; index += 1) {
|
||||
await retainCodexAppServerLiveThread(harness.client, `thread-${index}`);
|
||||
}
|
||||
const retain = retainCodexAppServerLiveThread(harness.client, "thread-overflow");
|
||||
|
||||
harness.client.close();
|
||||
finishRelease?.();
|
||||
|
||||
await expect(retain).resolves.toBe(false);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(harness.client, "thread-overflow"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("expires an idle subscription without keeping the process alive", async () => {
|
||||
@@ -226,6 +515,60 @@ describe("Codex app-server client runtime", () => {
|
||||
expect(release).toHaveBeenCalledExactlyOnceWith("thread-expired");
|
||||
});
|
||||
|
||||
it("renews a failed expiry instead of spinning and retries the same native owner", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
||||
const release = vi
|
||||
.fn<(threadId: string) => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("temporary unsubscribe failure"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-expiry-retry", release);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_LIVE_THREAD_IDLE_TIMEOUT_MS);
|
||||
|
||||
expect(release).toHaveBeenCalledExactlyOnceWith("thread-expiry-retry");
|
||||
await vi.advanceTimersByTimeAsync(EXPECTED_LIVE_THREAD_IDLE_TIMEOUT_MS - 1);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(release).toHaveBeenCalledTimes(2);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(harness.client, "thread-expiry-retry"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("never resurrects a natively closed thread after its in-flight unsubscribe fails", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
||||
let rejectUnsubscribe: ((error: Error) => void) | undefined;
|
||||
const failedUnsubscribe = new Promise<void>((_resolve, reject) => {
|
||||
rejectUnsubscribe = reject;
|
||||
});
|
||||
const release = vi.fn(async () => await failedUnsubscribe);
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-terminal", release);
|
||||
const notificationObserved = new Promise<void>((resolve) => {
|
||||
harness.client.addNotificationHandler((notification) => {
|
||||
if (notification.method === "thread/closed") {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
const releasing = releaseCodexAppServerLiveThread(harness.client, "thread-terminal");
|
||||
await vi.waitFor(() => expect(release).toHaveBeenCalledOnce());
|
||||
|
||||
harness.send({ method: "thread/closed", params: { threadId: "thread-terminal" } });
|
||||
await notificationObserved;
|
||||
rejectUnsubscribe?.(new Error("client closed before unsubscribe completed"));
|
||||
|
||||
await expect(releasing).rejects.toThrow("client closed before unsubscribe completed");
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(harness.client, "thread-terminal"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("protects native-child parents and renews their idle clock after the final child", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createClientHarness();
|
||||
@@ -268,6 +611,38 @@ describe("Codex app-server client runtime", () => {
|
||||
expect(release).toHaveBeenNthCalledWith(2, "conversation-b");
|
||||
});
|
||||
|
||||
it("keeps a failed unpin eviction owned until its original subscription can be retried", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
||||
const release = vi
|
||||
.fn<(threadId: string) => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("temporary unpin unsubscribe failure"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-oldest", release);
|
||||
for (let index = 1; index < EXPECTED_MAX_IDLE_LIVE_THREADS; index += 1) {
|
||||
await retainCodexAppServerLiveThread(harness.client, `thread-sibling-${index}`);
|
||||
}
|
||||
const unprotect = protectCodexAppServerLiveThread(harness.client, "thread-parent");
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-parent");
|
||||
|
||||
unprotect();
|
||||
|
||||
await vi.waitFor(() => expect(release).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(async () => {
|
||||
await expect(releaseCodexAppServerLiveThread(harness.client, "thread-oldest")).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
expect(release).toHaveBeenCalledTimes(2);
|
||||
await expect(consumeCodexAppServerLiveThread(harness.client, "thread-parent")).resolves.toEqual(
|
||||
expect.objectContaining({ release: expect.any(Function) }),
|
||||
);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(harness.client, "thread-sibling-1"),
|
||||
).resolves.toEqual(expect.objectContaining({ release: expect.any(Function) }));
|
||||
});
|
||||
|
||||
it.each(["thread/archived", "thread/deleted", "thread/closed"])(
|
||||
"discards only the exact thread after %s",
|
||||
async (method) => {
|
||||
@@ -311,4 +686,45 @@ describe("Codex app-server client runtime", () => {
|
||||
consumeCodexAppServerLiveThread(harness.client, "thread-closed"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("never publishes new live ownership after its physical client closes", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
||||
|
||||
harness.client.close();
|
||||
|
||||
await expect(retainCodexAppServerLiveThread(harness.client, "thread-stale")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(harness.client, "thread-stale"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("cannot resurrect thread ownership when its client closes during release", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
||||
let finishRelease: (() => void) | undefined;
|
||||
const pendingRelease = new Promise<void>((resolve) => {
|
||||
finishRelease = resolve;
|
||||
});
|
||||
await retainCodexAppServerLiveThread(
|
||||
harness.client,
|
||||
"thread-stale",
|
||||
async () => pendingRelease,
|
||||
);
|
||||
const release = releaseCodexAppServerLiveThread(harness.client, "thread-stale");
|
||||
const retain = retainCodexAppServerLiveThread(harness.client, "thread-stale");
|
||||
|
||||
harness.client.close();
|
||||
finishRelease?.();
|
||||
|
||||
await expect(release).resolves.toBe(true);
|
||||
await expect(retain).resolves.toBe(false);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(harness.client, "thread-stale"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Client-scoped Codex auth and account observers. */
|
||||
import { embeddedAgentLog, formatErrorMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { refreshCodexAppServerAuthTokens } from "./auth-bridge.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexServiceTier, JsonValue } from "./protocol.js";
|
||||
@@ -12,8 +13,10 @@ type ClientRuntimeContext = Omit<CodexAppServerAuthProfileLookup, "agentDir"> &
|
||||
|
||||
type ClientRuntime = {
|
||||
context: ClientRuntimeContext;
|
||||
closed: boolean;
|
||||
retainedThreads: Map<string, RetainedLiveThread>;
|
||||
releasingThreads: Map<string, Promise<void>>;
|
||||
claimedThreads: Map<string, symbol>;
|
||||
releasingThreads: Map<string, ThreadReleaseTransition>;
|
||||
protectedThreads: Map<string, number>;
|
||||
evictionTimer?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
@@ -25,6 +28,12 @@ type RetainedLiveThread = {
|
||||
release: (threadId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type ThreadReleaseTransition = {
|
||||
completion: Promise<void>;
|
||||
physicalRelease?: Promise<void>;
|
||||
invalidated?: boolean;
|
||||
};
|
||||
|
||||
export type CodexAppServerLiveThreadOwnership = {
|
||||
configFingerprint?: string;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
@@ -37,6 +46,20 @@ const CODEX_APP_SERVER_LIVE_THREAD_IDLE_TIMEOUT_MS = 30 * 60_000;
|
||||
const CODEX_APP_SERVER_LIVE_THREAD_MAX_IDLE = 64;
|
||||
|
||||
const configuredClients = new WeakMap<CodexAppServerClient, ClientRuntime>();
|
||||
const physicalThreadReleases = new WeakMap<
|
||||
CodexAppServerLiveThreadOwnership["release"],
|
||||
CodexAppServerLiveThreadOwnership["release"]
|
||||
>();
|
||||
const claimedThreadReleaseTokens = new WeakMap<
|
||||
CodexAppServerLiveThreadOwnership["release"],
|
||||
symbol
|
||||
>();
|
||||
|
||||
/** Only an initialized, still-open physical client can own retained native subscriptions. */
|
||||
export function isCodexAppServerClientRuntimeLive(client: CodexAppServerClient): boolean {
|
||||
const runtime = configuredClients.get(client);
|
||||
return runtime !== undefined && !runtime.closed;
|
||||
}
|
||||
|
||||
/** Installs one auth-refresh handler and one rate-limit observer per physical client. */
|
||||
export function ensureCodexAppServerClientRuntime(
|
||||
@@ -45,6 +68,9 @@ export function ensureCodexAppServerClientRuntime(
|
||||
): void {
|
||||
const existing = configuredClients.get(client);
|
||||
if (existing) {
|
||||
if (existing.closed) {
|
||||
return;
|
||||
}
|
||||
// Shared-client keys already isolate agent/auth identity. Keep config fresh
|
||||
// without installing another physical-client handler set.
|
||||
existing.context = context;
|
||||
@@ -52,17 +78,23 @@ export function ensureCodexAppServerClientRuntime(
|
||||
}
|
||||
const runtime: ClientRuntime = {
|
||||
context,
|
||||
closed: false,
|
||||
retainedThreads: new Map(),
|
||||
claimedThreads: new Map(),
|
||||
releasingThreads: new Map(),
|
||||
protectedThreads: new Map(),
|
||||
};
|
||||
configuredClients.set(client, runtime);
|
||||
client.addCloseHandler(() => {
|
||||
// Pending releases may settle after close; their continuations must never
|
||||
// resurrect subscriptions or eviction timers on a dead physical client.
|
||||
runtime.closed = true;
|
||||
if (runtime.evictionTimer) {
|
||||
clearTimeout(runtime.evictionTimer);
|
||||
runtime.evictionTimer = undefined;
|
||||
}
|
||||
runtime.retainedThreads.clear();
|
||||
runtime.claimedThreads.clear();
|
||||
runtime.protectedThreads.clear();
|
||||
});
|
||||
client.addRequestHandler(async (request) => {
|
||||
@@ -94,8 +126,13 @@ export function ensureCodexAppServerClientRuntime(
|
||||
const threadId = (notification.params as { threadId?: unknown } | undefined)?.threadId;
|
||||
if (typeof threadId === "string") {
|
||||
// Codex already removed server-side ownership; unsubscribing again can
|
||||
// race a replacement, so only discard this exact local idle entry.
|
||||
// race a replacement, so only discard this exact local ownership.
|
||||
const releasing = runtime.releasingThreads.get(threadId);
|
||||
if (releasing) {
|
||||
releasing.invalidated = true;
|
||||
}
|
||||
runtime.retainedThreads.delete(threadId);
|
||||
runtime.claimedThreads.delete(threadId);
|
||||
scheduleRetainedThreadEviction(client, runtime);
|
||||
}
|
||||
}
|
||||
@@ -110,6 +147,9 @@ function scheduleRetainedThreadEviction(
|
||||
clearTimeout(runtime.evictionTimer);
|
||||
runtime.evictionTimer = undefined;
|
||||
}
|
||||
if (runtime.closed) {
|
||||
return;
|
||||
}
|
||||
let expiresAt = Number.POSITIVE_INFINITY;
|
||||
for (const [threadId, thread] of runtime.retainedThreads) {
|
||||
if (!runtime.protectedThreads.has(threadId)) {
|
||||
@@ -122,7 +162,13 @@ function scheduleRetainedThreadEviction(
|
||||
runtime.evictionTimer = setTimeout(
|
||||
() => {
|
||||
runtime.evictionTimer = undefined;
|
||||
void evictExpiredRetainedThreads(client, runtime).catch(() => client.close());
|
||||
void evictExpiredRetainedThreads(client, runtime).catch((error: unknown) => {
|
||||
// The subscription owner already chose safe shared-client retirement;
|
||||
// force-closing here would abort unrelated still-leased conversations.
|
||||
embeddedAgentLog.warn("codex retained thread expiry failed", {
|
||||
reason: formatErrorMessage(error),
|
||||
});
|
||||
});
|
||||
},
|
||||
Math.max(0, expiresAt - Date.now()),
|
||||
);
|
||||
@@ -136,26 +182,59 @@ async function releaseRetainedThread(
|
||||
): Promise<boolean> {
|
||||
const pendingRelease = runtime.releasingThreads.get(threadId);
|
||||
if (pendingRelease) {
|
||||
await pendingRelease;
|
||||
await pendingRelease.completion;
|
||||
return false;
|
||||
}
|
||||
const retained = runtime.retainedThreads.get(threadId);
|
||||
if (!retained) {
|
||||
return false;
|
||||
}
|
||||
const olderThreadIds = new Set<string>();
|
||||
for (const candidateThreadId of runtime.retainedThreads.keys()) {
|
||||
if (candidateThreadId === threadId) {
|
||||
break;
|
||||
}
|
||||
olderThreadIds.add(candidateThreadId);
|
||||
}
|
||||
runtime.retainedThreads.delete(threadId);
|
||||
scheduleRetainedThreadEviction(client, runtime);
|
||||
// Keep release ownership addressable until unsubscribe settles. Unrelated
|
||||
// conversations must stay reusable while only this thread transitions.
|
||||
const release = retained.release(threadId);
|
||||
runtime.releasingThreads.set(threadId, release);
|
||||
const transition: ThreadReleaseTransition = {
|
||||
completion: Promise.resolve().then(() => retained.release(threadId)),
|
||||
};
|
||||
runtime.releasingThreads.set(threadId, transition);
|
||||
try {
|
||||
await release;
|
||||
await transition.completion;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
!runtime.closed &&
|
||||
!transition.invalidated &&
|
||||
runtime.releasingThreads.get(threadId) === transition &&
|
||||
!runtime.retainedThreads.has(threadId) &&
|
||||
!runtime.claimedThreads.has(threadId)
|
||||
) {
|
||||
// A failed unsubscribe leaves the native subscription alive. Restore its
|
||||
// exact callback and LRU position; renewing TTL prevents a zero-delay retry spin.
|
||||
retained.expiresAt = Date.now() + CODEX_APP_SERVER_LIVE_THREAD_IDLE_TIMEOUT_MS;
|
||||
const newerThreads = [...runtime.retainedThreads.entries()].filter(
|
||||
([candidateThreadId]) => !olderThreadIds.has(candidateThreadId),
|
||||
);
|
||||
for (const [candidateThreadId] of newerThreads) {
|
||||
runtime.retainedThreads.delete(candidateThreadId);
|
||||
}
|
||||
runtime.retainedThreads.set(threadId, retained);
|
||||
for (const [candidateThreadId, newerThread] of newerThreads) {
|
||||
runtime.retainedThreads.set(candidateThreadId, newerThread);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (runtime.releasingThreads.get(threadId) === release) {
|
||||
if (runtime.releasingThreads.get(threadId) === transition) {
|
||||
runtime.releasingThreads.delete(threadId);
|
||||
}
|
||||
scheduleRetainedThreadEviction(client, runtime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,32 +275,68 @@ export async function retainCodexAppServerLiveThread(
|
||||
serviceTier?: CodexServiceTier | null,
|
||||
): Promise<boolean> {
|
||||
const runtime = configuredClients.get(client);
|
||||
if (!runtime) {
|
||||
if (!runtime || runtime.closed) {
|
||||
return false;
|
||||
}
|
||||
const claimed = runtime.claimedThreads.get(threadId);
|
||||
if (
|
||||
claimed !== undefined &&
|
||||
(releaseThread === undefined || claimedThreadReleaseTokens.get(releaseThread) !== claimed)
|
||||
) {
|
||||
// Only the active generation's branded ownership handle may republish it;
|
||||
// manual resume or another turn must never steal an in-flight subscription.
|
||||
return false;
|
||||
}
|
||||
const pendingRelease = runtime.releasingThreads.get(threadId);
|
||||
if (pendingRelease) {
|
||||
await pendingRelease;
|
||||
await pendingRelease.completion;
|
||||
if (
|
||||
runtime.closed ||
|
||||
(claimed !== undefined && runtime.claimedThreads.get(threadId) !== claimed)
|
||||
) {
|
||||
// The pending operation unsubscribed this exact active generation;
|
||||
// publishing it again without thread/resume would invent ownership.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
runtime.retainedThreads.delete(threadId);
|
||||
runtime.retainedThreads.set(threadId, {
|
||||
const retained: RetainedLiveThread = {
|
||||
configFingerprint,
|
||||
serviceTier,
|
||||
expiresAt: Date.now() + CODEX_APP_SERVER_LIVE_THREAD_IDLE_TIMEOUT_MS,
|
||||
release:
|
||||
releaseThread ??
|
||||
(releaseThread ? (physicalThreadReleases.get(releaseThread) ?? releaseThread) : undefined) ??
|
||||
(async (releasedThreadId) => {
|
||||
await client.request(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: releasedThreadId },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
await unsubscribeCodexAppServerLiveThread(client, releasedThreadId, 5_000);
|
||||
}),
|
||||
});
|
||||
};
|
||||
runtime.retainedThreads.set(threadId, retained);
|
||||
|
||||
// Map insertion order is the LRU. Active turns are claimed out of this map,
|
||||
// and detached native-child parents are pinned until their final child settles.
|
||||
await evictExcessIdleThreads(client, runtime);
|
||||
try {
|
||||
await evictExcessIdleThreads(client, runtime);
|
||||
} catch (error) {
|
||||
// Capacity eviction can retire the physical client. Never leave the new
|
||||
// thread published when its caller must instead release active ownership.
|
||||
if (runtime.retainedThreads.get(threadId) === retained) {
|
||||
runtime.retainedThreads.delete(threadId);
|
||||
}
|
||||
scheduleRetainedThreadEviction(client, runtime);
|
||||
embeddedAgentLog.warn("codex retained thread capacity eviction failed", {
|
||||
threadId,
|
||||
reason: formatErrorMessage(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (runtime.closed) {
|
||||
return false;
|
||||
}
|
||||
// A turn owns its claimed subscription until its idle replacement actually
|
||||
// survives capacity eviction; a failed publish must remain fail-closed.
|
||||
if (claimed !== undefined && runtime.claimedThreads.get(threadId) === claimed) {
|
||||
runtime.claimedThreads.delete(threadId);
|
||||
}
|
||||
scheduleRetainedThreadEviction(client, runtime);
|
||||
return true;
|
||||
}
|
||||
@@ -233,12 +348,12 @@ export async function consumeCodexAppServerLiveThread(
|
||||
configFingerprint?: string,
|
||||
): Promise<CodexAppServerLiveThreadOwnership | undefined> {
|
||||
const runtime = configuredClients.get(client);
|
||||
if (!runtime) {
|
||||
if (!runtime || runtime.closed) {
|
||||
return undefined;
|
||||
}
|
||||
const pendingRelease = runtime.releasingThreads.get(threadId);
|
||||
if (pendingRelease) {
|
||||
await pendingRelease;
|
||||
await pendingRelease.completion;
|
||||
return undefined;
|
||||
}
|
||||
const retained = runtime.retainedThreads.get(threadId);
|
||||
@@ -248,15 +363,139 @@ export async function consumeCodexAppServerLiveThread(
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return claimCodexAppServerThreadOwnership(client, runtime, threadId, retained);
|
||||
}
|
||||
|
||||
/** Claims an observed Codex auto-subscription without exposing a temporarily idle owner. */
|
||||
export async function claimCodexAppServerLiveThread(
|
||||
client: CodexAppServerClient,
|
||||
threadId: string,
|
||||
): Promise<CodexAppServerLiveThreadOwnership | undefined> {
|
||||
const runtime = configuredClients.get(client);
|
||||
if (!runtime || runtime.closed || runtime.claimedThreads.has(threadId)) {
|
||||
return undefined;
|
||||
}
|
||||
const pendingRelease = runtime.releasingThreads.get(threadId);
|
||||
if (pendingRelease) {
|
||||
// A pending unsubscribe can invalidate the observed subscription; it must
|
||||
// never be resurrected after its physical connection acknowledges release.
|
||||
await pendingRelease.completion;
|
||||
return undefined;
|
||||
}
|
||||
const retained = runtime.retainedThreads.get(threadId) ?? {
|
||||
expiresAt: Date.now() + CODEX_APP_SERVER_LIVE_THREAD_IDLE_TIMEOUT_MS,
|
||||
release: async (releasedThreadId: string) => {
|
||||
await unsubscribeCodexAppServerLiveThread(client, releasedThreadId, 5_000);
|
||||
},
|
||||
};
|
||||
return claimCodexAppServerThreadOwnership(client, runtime, threadId, retained);
|
||||
}
|
||||
|
||||
function claimCodexAppServerThreadOwnership(
|
||||
client: CodexAppServerClient,
|
||||
runtime: ClientRuntime,
|
||||
threadId: string,
|
||||
retained: RetainedLiveThread,
|
||||
): CodexAppServerLiveThreadOwnership {
|
||||
runtime.retainedThreads.delete(threadId);
|
||||
const claimed = Symbol(threadId);
|
||||
runtime.claimedThreads.set(threadId, claimed);
|
||||
scheduleRetainedThreadEviction(client, runtime);
|
||||
const release = async (releasedThreadId: string): Promise<void> => {
|
||||
// Codex subscriptions have no generation identifier. An obsolete owner
|
||||
// must be rejected before it can unsubscribe a replacement's live turn.
|
||||
if (releasedThreadId !== threadId || runtime.claimedThreads.get(threadId) !== claimed) {
|
||||
return;
|
||||
}
|
||||
const pendingRelease = runtime.releasingThreads.get(threadId);
|
||||
if (pendingRelease) {
|
||||
await pendingRelease.completion;
|
||||
return;
|
||||
}
|
||||
// Publish the transition before invoking the physical callback so a new
|
||||
// retain/claim cannot slip in while Codex acknowledges unsubscribe.
|
||||
const transition: ThreadReleaseTransition = {
|
||||
completion: Promise.resolve().then(async () => {
|
||||
if (runtime.closed || runtime.claimedThreads.get(threadId) !== claimed) {
|
||||
return;
|
||||
}
|
||||
await retained.release(releasedThreadId);
|
||||
}),
|
||||
};
|
||||
runtime.releasingThreads.set(threadId, transition);
|
||||
try {
|
||||
await transition.completion;
|
||||
if (runtime.claimedThreads.get(threadId) === claimed) {
|
||||
runtime.claimedThreads.delete(threadId);
|
||||
}
|
||||
} finally {
|
||||
if (runtime.releasingThreads.get(threadId) === transition) {
|
||||
runtime.releasingThreads.delete(threadId);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Compaction and bound turns transfer this callback back into idle storage;
|
||||
// the successor must inherit its raw release, never the obsolete token guard.
|
||||
physicalThreadReleases.set(release, retained.release);
|
||||
claimedThreadReleaseTokens.set(release, claimed);
|
||||
return {
|
||||
configFingerprint: retained.configFingerprint,
|
||||
serviceTier: retained.serviceTier,
|
||||
release: retained.release,
|
||||
release,
|
||||
};
|
||||
}
|
||||
|
||||
/** Distinguish active claimed ownership from an already-evicted idle subscription. */
|
||||
export function isCodexAppServerLiveThreadClaimed(
|
||||
client: CodexAppServerClient,
|
||||
threadId: string,
|
||||
): boolean {
|
||||
const runtime = configuredClients.get(client);
|
||||
return runtime !== undefined && !runtime.closed && runtime.claimedThreads.has(threadId);
|
||||
}
|
||||
|
||||
/** Release the exact physical subscription and finish only its observed claim generation. */
|
||||
export async function unsubscribeCodexAppServerLiveThread(
|
||||
client: CodexAppServerClient,
|
||||
threadId: string,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const runtime = configuredClients.get(client);
|
||||
const claimed = runtime?.claimedThreads.get(threadId);
|
||||
let transition = runtime?.releasingThreads.get(threadId);
|
||||
if (transition?.physicalRelease) {
|
||||
await transition.physicalRelease;
|
||||
return;
|
||||
}
|
||||
const physicalRelease = Promise.resolve().then(async () => {
|
||||
if (claimed !== undefined && runtime?.claimedThreads.get(threadId) !== claimed) {
|
||||
return;
|
||||
}
|
||||
await client.request("thread/unsubscribe", { threadId }, { timeoutMs });
|
||||
});
|
||||
const ownsTransition = runtime !== undefined && transition === undefined;
|
||||
if (transition) {
|
||||
// Idle/claimed owners may invoke this helper from inside their own
|
||||
// transition; join its one RPC slot instead of joining ourselves.
|
||||
transition.physicalRelease = physicalRelease;
|
||||
} else if (runtime) {
|
||||
transition = { completion: physicalRelease, physicalRelease };
|
||||
runtime.releasingThreads.set(threadId, transition);
|
||||
}
|
||||
try {
|
||||
await physicalRelease;
|
||||
// Warm/compaction/native-child cleanup can release directly. Their exact
|
||||
// successful RPC ends only the claim generation observed before it began.
|
||||
if (claimed !== undefined && runtime?.claimedThreads.get(threadId) === claimed) {
|
||||
runtime.claimedThreads.delete(threadId);
|
||||
}
|
||||
} finally {
|
||||
if (ownsTransition && runtime.releasingThreads.get(threadId) === transition) {
|
||||
runtime.releasingThreads.delete(threadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset/end owns the exact thread; failed generation retirement must never release its successor. */
|
||||
export async function releaseCodexAppServerLiveThread(
|
||||
client: CodexAppServerClient,
|
||||
@@ -272,7 +511,7 @@ export function protectCodexAppServerLiveThread(
|
||||
threadId: string,
|
||||
): () => void {
|
||||
const runtime = configuredClients.get(client);
|
||||
if (!runtime) {
|
||||
if (!runtime || runtime.closed) {
|
||||
return () => undefined;
|
||||
}
|
||||
runtime.protectedThreads.set(threadId, (runtime.protectedThreads.get(threadId) ?? 0) + 1);
|
||||
@@ -283,6 +522,9 @@ export function protectCodexAppServerLiveThread(
|
||||
return;
|
||||
}
|
||||
protectedThread = false;
|
||||
if (runtime.closed) {
|
||||
return;
|
||||
}
|
||||
const count = runtime.protectedThreads.get(threadId) ?? 0;
|
||||
if (count <= 1) {
|
||||
runtime.protectedThreads.delete(threadId);
|
||||
@@ -300,6 +542,11 @@ export function protectCodexAppServerLiveThread(
|
||||
runtime.protectedThreads.set(threadId, count - 1);
|
||||
}
|
||||
scheduleRetainedThreadEviction(client, runtime);
|
||||
void evictExcessIdleThreads(client, runtime).catch(() => client.close());
|
||||
void evictExcessIdleThreads(client, runtime).catch((error: unknown) => {
|
||||
embeddedAgentLog.warn("codex retained thread unpin eviction failed", {
|
||||
threadId,
|
||||
reason: formatErrorMessage(error),
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -265,6 +265,37 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
).resolves.toEqual(expect.objectContaining({ configFingerprint: "config-thread-2" }));
|
||||
});
|
||||
|
||||
it("releases an obsolete physical owner when compaction migrates the same native thread", async () => {
|
||||
const fake = createFakeCodexClient({ autoCompleteCompaction: false });
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const sessionFile = await writeTestBinding({ clientId: "client-before-compaction" });
|
||||
const pending = startCompaction(sessionFile);
|
||||
await vi.waitFor(() => {
|
||||
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
|
||||
});
|
||||
|
||||
seedCodexTestBinding(sessionFile, {
|
||||
threadId: "thread-1",
|
||||
clientId: "client-after-compaction",
|
||||
cwd: tempDir,
|
||||
});
|
||||
fake.completeCompaction();
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ ok: true, compacted: true });
|
||||
expect(fake.request.mock.calls.filter(([method]) => method === "thread/unsubscribe")).toEqual([
|
||||
[
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-1" },
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number) }),
|
||||
],
|
||||
]);
|
||||
await expect(consumeCodexAppServerLiveThread(fake.client, "thread-1")).resolves.toBeUndefined();
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-1",
|
||||
clientId: "client-after-compaction",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an incognito thread's separately owned live subscription", async () => {
|
||||
const fake = createFakeCodexClient({
|
||||
retainedThreadId: null,
|
||||
@@ -538,6 +569,70 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears bootstrap projection before manual native compaction rewrites thread history", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const sessionFile = await writeTestBinding({
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint: "policy-1",
|
||||
projection: {
|
||||
schemaVersion: 1,
|
||||
mode: "thread_bootstrap",
|
||||
epoch: "epoch-1",
|
||||
fingerprint: "fingerprint-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(startCompaction(sessionFile)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
});
|
||||
expect(
|
||||
(await readCodexAppServerBinding(sessionFile))?.contextEngine?.projection,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves projected context and warm ownership when native compaction is rejected", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
fake.request.mockRejectedValueOnce(
|
||||
new CodexAppServerRpcError(
|
||||
{ code: -32_600, message: "compaction temporarily unavailable" },
|
||||
"thread/compact/start",
|
||||
),
|
||||
);
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
const projection = {
|
||||
schemaVersion: 1 as const,
|
||||
mode: "thread_bootstrap" as const,
|
||||
epoch: "epoch-1",
|
||||
fingerprint: "fingerprint-1",
|
||||
};
|
||||
const sessionFile = await writeTestBinding({
|
||||
contextEngine: {
|
||||
schemaVersion: 1,
|
||||
engineId: "lossless-claw",
|
||||
policyFingerprint: "policy-1",
|
||||
projection,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(startCompaction(sessionFile)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "compaction temporarily unavailable",
|
||||
});
|
||||
expect((await readCodexAppServerBinding(sessionFile))?.contextEngine?.projection).toEqual(
|
||||
projection,
|
||||
);
|
||||
expect(fake.request.mock.calls.map(([method]) => method)).toEqual(["thread/compact/start"]);
|
||||
await expect(consumeCodexAppServerLiveThread(fake.client, "thread-1")).resolves.toEqual(
|
||||
expect.objectContaining({ release: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves projection when aborted before guarded native compaction", async () => {
|
||||
const fake = createFakeCodexClient();
|
||||
setCodexAppServerClientFactoryForTest(async () => fake.client);
|
||||
@@ -1715,31 +1810,39 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
|
||||
async function runWithIgnoredOverrides(index: number): Promise<void> {
|
||||
const agentId = `cap-${index}`;
|
||||
await maybeCompactCodexAppServerSession({
|
||||
sessionId: `session-${index}`,
|
||||
sessionKey: `agent:${agentId}:session-${index}`,
|
||||
sessionFile: path.join(tempDir, `${agentId}.jsonl`),
|
||||
workspaceDir: tempDir,
|
||||
trigger: "budget",
|
||||
config: {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: agentId,
|
||||
compaction: {
|
||||
model: "openai/gpt-5.4-mini",
|
||||
provider: "custom-summary",
|
||||
await maybeCompactCodexAppServerSessionImpl(
|
||||
{
|
||||
sessionId: `session-${index}`,
|
||||
sessionKey: `agent:${agentId}:session-${index}`,
|
||||
sessionFile: path.join(tempDir, `${agentId}.jsonl`),
|
||||
workspaceDir: tempDir,
|
||||
trigger: "budget",
|
||||
config: {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: agentId,
|
||||
compaction: {
|
||||
model: "openai/gpt-5.4-mini",
|
||||
provider: "custom-summary",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
{ bindingStore: testCodexAppServerBindingStore },
|
||||
);
|
||||
}
|
||||
|
||||
// Fill exactly the advertised 4,096-entry cap: every distinct key warns once.
|
||||
for (let index = 0; index < 4_096; index += 1) {
|
||||
await runWithIgnoredOverrides(index);
|
||||
if ((index + 1) % 256 === 0) {
|
||||
// Thousands of resolved promises otherwise starve Vitest timeout and
|
||||
// progress callbacks without increasing real LRU-cap coverage.
|
||||
await flushAsyncTasks(1);
|
||||
}
|
||||
}
|
||||
expect(warn).toHaveBeenCalledTimes(4_096);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Native Codex app-server compaction bridge for bound OpenClaw sessions.
|
||||
*/
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
resolveCompactionTimeoutMs,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
type CodexAppServerClientFactory,
|
||||
} from "./shared-client.js";
|
||||
import { isSameCodexAppServerThreadOwner } from "./thread-ownership.js";
|
||||
import { resumeCodexAppServerThread } from "./thread-resume.js";
|
||||
|
||||
// ttlMs: 0 retains keys until the 4,096-entry LRU cap evicts them, after which a
|
||||
@@ -77,6 +79,7 @@ function watchCodexNativeCompactionCompletion(params: {
|
||||
retireUnconfirmedRequest: (reason: string) => Promise<CodexNativeCompactionCompletion>;
|
||||
cancel: () => void;
|
||||
} {
|
||||
const runOutsideBindingLease = AsyncLocalStorage.snapshot();
|
||||
let settled = false;
|
||||
let requestStarted = false;
|
||||
let abortRequested = false;
|
||||
@@ -111,8 +114,9 @@ function watchCodexNativeCompactionCompletion(params: {
|
||||
return;
|
||||
}
|
||||
retirementStarted = true;
|
||||
void params
|
||||
.retireUnconfirmed()
|
||||
// Timers started under the short-lived binding lease inherit its async
|
||||
// owner. Remote retirement must not reuse that already-released token.
|
||||
void runOutsideBindingLease(() => params.retireUnconfirmed())
|
||||
.then(() => finish({ completed: false, reason }))
|
||||
.catch((error: unknown) => {
|
||||
embeddedAgentLog.error("failed to retire unconfirmed codex app-server compaction", {
|
||||
@@ -552,6 +556,7 @@ async function compactCodexNativeThread(
|
||||
let releaseThreadSubscription: (() => Promise<void>) | undefined;
|
||||
let retainedThreadOwnership: CodexAppServerLiveThreadOwnership | undefined;
|
||||
let compactionSucceeded = false;
|
||||
let compactionRequestDefinitelyRejected = false;
|
||||
const releaseCompactionThread = async (threadId: string) => {
|
||||
if (
|
||||
await unsubscribeCodexThreadBestEffort(client, {
|
||||
@@ -651,84 +656,85 @@ async function compactCodexNativeThread(
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (options.allowNonManualNativeRequest) {
|
||||
const guardedResult = await options.bindingStore.withLease(
|
||||
bindingIdentity,
|
||||
async () => {
|
||||
const currentBinding = await options.bindingStore.read(bindingIdentity);
|
||||
if (params.abortSignal?.aborted) {
|
||||
return {
|
||||
started: false as const,
|
||||
result: skippedCodexNativeCompactionResult(params, {
|
||||
reason: "codex app-server compaction aborted before native compaction",
|
||||
code: "aborted_before_native_compaction",
|
||||
expectedThreadId: binding.threadId,
|
||||
currentThreadId: currentBinding?.threadId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (!currentBinding || !isSameNativeCompactionBinding(currentBinding, binding)) {
|
||||
embeddedAgentLog.warn(
|
||||
"skipping codex app-server compaction because the thread binding changed",
|
||||
{
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
expectedThreadId: binding.threadId,
|
||||
currentThreadId: currentBinding?.threadId,
|
||||
},
|
||||
);
|
||||
return {
|
||||
started: false as const,
|
||||
result: skippedCodexNativeCompactionResult(params, {
|
||||
const guardedResult = await options.bindingStore.withLease(bindingIdentity, async () => {
|
||||
const currentBinding = await options.bindingStore.read(bindingIdentity);
|
||||
if (params.abortSignal?.aborted) {
|
||||
if (!options.allowNonManualNativeRequest) {
|
||||
params.abortSignal.throwIfAborted();
|
||||
}
|
||||
return {
|
||||
started: false as const,
|
||||
result: skippedCodexNativeCompactionResult(params, {
|
||||
reason: "codex app-server compaction aborted before native compaction",
|
||||
code: "aborted_before_native_compaction",
|
||||
expectedThreadId: binding.threadId,
|
||||
currentThreadId: currentBinding?.threadId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (!currentBinding || !isSameNativeCompactionBinding(currentBinding, binding)) {
|
||||
embeddedAgentLog.warn(
|
||||
"skipping codex app-server compaction because the thread binding changed",
|
||||
{
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
expectedThreadId: binding.threadId,
|
||||
currentThreadId: currentBinding?.threadId,
|
||||
},
|
||||
);
|
||||
return {
|
||||
started: false as const,
|
||||
result: options.allowNonManualNativeRequest
|
||||
? skippedCodexNativeCompactionResult(params, {
|
||||
reason: "codex app-server binding changed before native compaction",
|
||||
code: "binding_changed_before_native_compaction",
|
||||
expectedThreadId: binding.threadId,
|
||||
currentThreadId: currentBinding?.threadId,
|
||||
})
|
||||
: failedCodexThreadBindingCompactionResult(params, {
|
||||
threadId: binding.threadId,
|
||||
reason: "codex app-server binding changed before native compaction",
|
||||
recovery: "stale_thread_binding",
|
||||
}),
|
||||
};
|
||||
}
|
||||
binding = currentBinding;
|
||||
const guardedRequestTimeoutMs = Math.min(
|
||||
};
|
||||
}
|
||||
binding = currentBinding;
|
||||
const guardedRequestTimeoutMs = options.allowNonManualNativeRequest
|
||||
? Math.min(
|
||||
appServer.requestTimeoutMs,
|
||||
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
await acquireThreadSubscription(guardedRequestTimeoutMs);
|
||||
await clearContextEngineProjectionBeforeNativeCompaction({
|
||||
sessionId: params.sessionId,
|
||||
bindingStore: options.bindingStore,
|
||||
identity: bindingIdentity,
|
||||
)
|
||||
: undefined;
|
||||
await acquireThreadSubscription(guardedRequestTimeoutMs);
|
||||
await clearContextEngineProjectionBeforeNativeCompaction({
|
||||
sessionId: params.sessionId,
|
||||
bindingStore: options.bindingStore,
|
||||
identity: bindingIdentity,
|
||||
binding,
|
||||
});
|
||||
try {
|
||||
await beginNativeCompactionRequest(guardedRequestTimeoutMs);
|
||||
return { started: true as const, accepted: true as const };
|
||||
} catch (error) {
|
||||
if (error instanceof CodexAppServerRpcError) {
|
||||
// A server rejection proves native history was untouched; an
|
||||
// ambiguous transport failure must never restore stale context.
|
||||
await options.bindingStore.mutate(bindingIdentity, {
|
||||
kind: "set",
|
||||
binding,
|
||||
});
|
||||
try {
|
||||
await beginNativeCompactionRequest(guardedRequestTimeoutMs);
|
||||
return { started: true as const, accepted: true as const };
|
||||
} catch (error) {
|
||||
await options.bindingStore.mutate(bindingIdentity, {
|
||||
kind: "set",
|
||||
binding,
|
||||
});
|
||||
// Retire outside the binding lock: remote detach acquires this
|
||||
// same lock and would otherwise deadlock the failure path.
|
||||
return { started: true as const, accepted: false as const, error };
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!guardedResult.started) {
|
||||
return guardedResult.result;
|
||||
}
|
||||
if (!guardedResult.accepted) {
|
||||
await settleNativeCompactionRequestError(guardedResult.error);
|
||||
throw guardedResult.error;
|
||||
}
|
||||
} else {
|
||||
params.abortSignal?.throwIfAborted();
|
||||
await acquireThreadSubscription();
|
||||
try {
|
||||
await beginNativeCompactionRequest();
|
||||
} catch (error) {
|
||||
await settleNativeCompactionRequestError(error);
|
||||
throw error;
|
||||
compactionRequestDefinitelyRejected = !isCodexThreadNotFoundError(error);
|
||||
}
|
||||
// Retirement can acquire this same generation lease.
|
||||
return { started: true as const, accepted: false as const, error };
|
||||
}
|
||||
});
|
||||
if (!guardedResult.started) {
|
||||
return guardedResult.result;
|
||||
}
|
||||
if (!guardedResult.accepted) {
|
||||
await settleNativeCompactionRequestError(guardedResult.error);
|
||||
throw guardedResult.error;
|
||||
}
|
||||
embeddedAgentLog.info("started codex app-server compaction", {
|
||||
sessionId: params.sessionId,
|
||||
@@ -765,16 +771,19 @@ async function compactCodexNativeThread(
|
||||
} finally {
|
||||
completionWatch.cancel();
|
||||
try {
|
||||
if (compactionSucceeded && retainedThreadOwnership) {
|
||||
if (
|
||||
(compactionSucceeded || compactionRequestDefinitelyRejected) &&
|
||||
retainedThreadOwnership
|
||||
) {
|
||||
const ownership = retainedThreadOwnership;
|
||||
const currentBinding = await options.bindingStore.read(bindingIdentity);
|
||||
// Reset uses this same generation lease; without it compaction
|
||||
// could return an obsolete subscription after its owner ended.
|
||||
const retained =
|
||||
currentBinding?.threadId === binding.threadId &&
|
||||
isSameCodexAppServerThreadOwner(currentBinding, binding) &&
|
||||
(await options.bindingStore.withLease(bindingIdentity, async () => {
|
||||
const leasedBinding = await options.bindingStore.read(bindingIdentity);
|
||||
if (leasedBinding?.threadId !== binding.threadId) {
|
||||
if (!isSameCodexAppServerThreadOwner(leasedBinding, binding)) {
|
||||
return false;
|
||||
}
|
||||
return await retainCodexAppServerLiveThread(
|
||||
@@ -935,7 +944,7 @@ function isSameNativeCompactionBinding(
|
||||
expected: CodexAppServerThreadBinding,
|
||||
): boolean {
|
||||
return (
|
||||
current.threadId === expected.threadId &&
|
||||
isSameCodexAppServerThreadOwner(current, expected) &&
|
||||
current.authProfileId === expected.authProfileId &&
|
||||
current.contextEngine?.engineId === expected.contextEngine?.engineId &&
|
||||
current.contextEngine?.policyFingerprint === expected.contextEngine?.policyFingerprint &&
|
||||
|
||||
@@ -5,6 +5,14 @@ import type {
|
||||
AgentHarnessTaskRuntimeScope,
|
||||
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
claimCodexAppServerLiveThread,
|
||||
consumeCodexAppServerLiveThread,
|
||||
ensureCodexAppServerClientRuntime,
|
||||
isCodexAppServerLiveThreadClaimed,
|
||||
releaseCodexAppServerLiveThread,
|
||||
retainCodexAppServerLiveThread,
|
||||
} from "./client-runtime.js";
|
||||
import { createFakeCodexAppServerClient } from "./codex-app-server.test-fixtures.js";
|
||||
import { codexNativeSubagentMonitorRuntime } from "./native-subagent-monitor.js";
|
||||
import type {
|
||||
@@ -72,6 +80,7 @@ function createClient() {
|
||||
threadTurns.set(childThreadId, response);
|
||||
},
|
||||
addNotificationHandler: fixture.client.addNotificationHandler.bind(fixture.client),
|
||||
addRequestHandler: fixture.client.addRequestHandler.bind(fixture.client),
|
||||
addCloseHandler: fixture.client.addCloseHandler.bind(fixture.client),
|
||||
notify: (notification: CodexServerNotification) => fixture.notify(notification),
|
||||
close: () => fixture.close(),
|
||||
@@ -212,6 +221,31 @@ function nativeCompletionNotification(
|
||||
};
|
||||
}
|
||||
|
||||
function closeAgentNotification(params: {
|
||||
method: "item/started" | "item/completed";
|
||||
childThreadId?: string;
|
||||
previousStatus?: "completed" | "running";
|
||||
}): CodexServerNotification {
|
||||
const childThreadId = params.childThreadId ?? "child-thread";
|
||||
return {
|
||||
method: params.method,
|
||||
params: {
|
||||
threadId: "parent-thread",
|
||||
item: {
|
||||
type: "collabAgentToolCall",
|
||||
tool: "closeAgent",
|
||||
status: params.method === "item/started" ? "inProgress" : "completed",
|
||||
senderThreadId: "parent-thread",
|
||||
receiverThreadIds: [childThreadId],
|
||||
agentsStates:
|
||||
params.method === "item/completed"
|
||||
? { [childThreadId]: { status: params.previousStatus ?? "completed" } }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function childTurnCompletedNotification(params: {
|
||||
status: "completed" | "failed" | "interrupted";
|
||||
error?: string;
|
||||
@@ -372,6 +406,118 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
expect(releaseParentThread).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retains completed-open children in the bounded owner and reclaims them for follow-up", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
const claimChildThread = vi.fn(async () => undefined);
|
||||
const retainChildThread = vi.fn(async () => true);
|
||||
const retainParentThread = vi.fn(() => vi.fn());
|
||||
const monitor = new CodexNativeSubagentMonitor(client as never, runtime, {
|
||||
claimChildThread,
|
||||
retainChildThread,
|
||||
retainParentThread,
|
||||
});
|
||||
registerParent(monitor);
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await client.notify(nativeCompletionNotification());
|
||||
|
||||
expect(claimChildThread).toHaveBeenCalledExactlyOnceWith("child-thread");
|
||||
expect(retainChildThread).toHaveBeenCalledExactlyOnceWith("child-thread");
|
||||
|
||||
await client.notify({
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: "parent-thread",
|
||||
item: {
|
||||
type: "collabAgentToolCall",
|
||||
tool: "sendInput",
|
||||
senderThreadId: "parent-thread",
|
||||
receiverThreadIds: ["child-thread"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(claimChildThread).toHaveBeenCalledTimes(2);
|
||||
expect(retainParentThread).toHaveBeenCalledTimes(2);
|
||||
monitor.dispose();
|
||||
});
|
||||
|
||||
it("does not resurrect completed children or repin parents when closeAgent runs", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
const releaseParentThread = vi.fn();
|
||||
const retainParentThread = vi.fn(() => releaseParentThread);
|
||||
const retainChildThread = vi.fn(async () => true);
|
||||
const releaseChildThread = vi.fn(async () => true);
|
||||
const monitor = new CodexNativeSubagentMonitor(client as never, runtime, {
|
||||
retainParentThread,
|
||||
retainChildThread,
|
||||
releaseChildThread,
|
||||
});
|
||||
registerParent(monitor);
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await client.notify(nativeCompletionNotification());
|
||||
expect(releaseParentThread).toHaveBeenCalledOnce();
|
||||
|
||||
await client.notify(closeAgentNotification({ method: "item/started" }));
|
||||
await client.notify(closeAgentNotification({ method: "item/completed" }));
|
||||
|
||||
expect(retainParentThread).toHaveBeenCalledExactlyOnceWith("parent-thread");
|
||||
expect(releaseParentThread).toHaveBeenCalledOnce();
|
||||
expect(retainChildThread).toHaveBeenCalledExactlyOnceWith("child-thread");
|
||||
expect(releaseChildThread).toHaveBeenCalledExactlyOnceWith("child-thread");
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledOnce();
|
||||
monitor.dispose();
|
||||
});
|
||||
|
||||
it("cancels running children and releases their parent pin when closeAgent completes", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
const releaseParentThread = vi.fn();
|
||||
const monitor = new CodexNativeSubagentMonitor(client as never, runtime, {
|
||||
retainParentThread: () => releaseParentThread,
|
||||
});
|
||||
registerParent(monitor);
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await client.notify(
|
||||
closeAgentNotification({ method: "item/completed", previousStatus: "running" }),
|
||||
);
|
||||
await client.notify(nativeCompletionNotification());
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runId: "codex-thread:child-thread", status: "cancelled" }),
|
||||
);
|
||||
expect(releaseParentThread).toHaveBeenCalledOnce();
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
|
||||
monitor.dispose();
|
||||
});
|
||||
|
||||
it("retires parent generations idempotently and fences late child completions", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
const releaseParentThread = vi.fn();
|
||||
const monitor = new CodexNativeSubagentMonitor(client as never, runtime, {
|
||||
retainParentThread: () => releaseParentThread,
|
||||
});
|
||||
const parent = registerParent(monitor);
|
||||
await notifyChildStarted(client);
|
||||
|
||||
monitor.retireParent("parent-thread");
|
||||
monitor.retireParent("parent-thread");
|
||||
parent.unregister();
|
||||
await client.notify(nativeCompletionNotification());
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ runId: "codex-thread:child-thread", status: "cancelled" }),
|
||||
);
|
||||
expect(releaseParentThread).toHaveBeenCalledOnce();
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).not.toHaveBeenCalled();
|
||||
monitor.dispose();
|
||||
});
|
||||
|
||||
it("keeps native subagent task mirroring on the shared client", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
@@ -1801,6 +1947,9 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
it("ref-counts shared parent registrations", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
const childRelease = vi.fn(async () => undefined);
|
||||
ensureCodexAppServerClientRuntime(client as never, { agentDir: "/tmp/agent" });
|
||||
await retainCodexAppServerLiveThread(client as never, "child-thread", childRelease);
|
||||
const first = registerCodexNativeSubagentMonitor({
|
||||
client: client as never,
|
||||
parentThreadId: "parent-thread",
|
||||
@@ -1817,7 +1966,17 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
});
|
||||
first.unregister();
|
||||
await notifyChildStarted(client);
|
||||
await vi.waitFor(() =>
|
||||
expect(isCodexAppServerLiveThreadClaimed(client as never, "child-thread")).toBe(true),
|
||||
);
|
||||
await client.notify(nativeCompletionNotification());
|
||||
await vi.waitFor(() =>
|
||||
expect(isCodexAppServerLiveThreadClaimed(client as never, "child-thread")).toBe(false),
|
||||
);
|
||||
const reusedChild = await consumeCodexAppServerLiveThread(client as never, "child-thread");
|
||||
expect(reusedChild).toEqual(expect.objectContaining({ release: expect.any(Function) }));
|
||||
await reusedChild?.release("child-thread");
|
||||
expect(childRelease).toHaveBeenCalledOnce();
|
||||
|
||||
expect(runtime.createRunningTaskRun).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledTimes(1);
|
||||
@@ -1827,6 +1986,209 @@ describe("CodexNativeSubagentMonitor", () => {
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("claims a fresh auto-subscribed child until completion transfers its exact owner", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
client.request.mockImplementation(async (method) => {
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {} as never;
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
ensureCodexAppServerClientRuntime(client as never, { agentDir: "/tmp/agent" });
|
||||
const parent = registerCodexNativeSubagentMonitor({
|
||||
client: client as never,
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
taskRuntimeScope: createTaskScope("agent:main:main"),
|
||||
runtime,
|
||||
});
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await vi.waitFor(() =>
|
||||
expect(isCodexAppServerLiveThreadClaimed(client as never, "child-thread")).toBe(true),
|
||||
);
|
||||
await expect(retainCodexAppServerLiveThread(client as never, "child-thread")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(client as never, "child-thread"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
|
||||
await client.notify(nativeCompletionNotification());
|
||||
await vi.waitFor(() =>
|
||||
expect(isCodexAppServerLiveThreadClaimed(client as never, "child-thread")).toBe(false),
|
||||
);
|
||||
const completed = await consumeCodexAppServerLiveThread(client as never, "child-thread");
|
||||
expect(completed).toEqual(expect.objectContaining({ release: expect.any(Function) }));
|
||||
await completed?.release("child-thread");
|
||||
|
||||
expect(client.request).toHaveBeenCalledExactlyOnceWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "child-thread" },
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
parent.unregister();
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("releases a completed native child when its full idle pool cannot evict its oldest owner", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
client.request.mockImplementation(async (method) => {
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {} as never;
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
ensureCodexAppServerClientRuntime(client as never, { agentDir: "/tmp/agent" });
|
||||
const oldestRelease = vi
|
||||
.fn<(threadId: string) => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("oldest native subscription could not be released"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
await retainCodexAppServerLiveThread(client as never, "thread-oldest", oldestRelease);
|
||||
for (let index = 1; index < 64; index += 1) {
|
||||
await retainCodexAppServerLiveThread(client as never, `thread-sibling-${index}`);
|
||||
}
|
||||
const releaseParentThread = vi.fn();
|
||||
const parent = registerCodexNativeSubagentMonitor({
|
||||
client: client as never,
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
taskRuntimeScope: createTaskScope("agent:main:main"),
|
||||
runtime,
|
||||
retainParentThread: () => releaseParentThread,
|
||||
});
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await vi.waitFor(() =>
|
||||
expect(isCodexAppServerLiveThreadClaimed(client as never, "child-thread")).toBe(true),
|
||||
);
|
||||
await client.notify(nativeCompletionNotification());
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(client.request).toHaveBeenCalledExactlyOnceWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "child-thread" },
|
||||
{ timeoutMs: 5_000 },
|
||||
),
|
||||
);
|
||||
expect(oldestRelease).toHaveBeenCalledExactlyOnceWith("thread-oldest");
|
||||
expect(isCodexAppServerLiveThreadClaimed(client as never, "child-thread")).toBe(false);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(client as never, "child-thread"),
|
||||
).resolves.toBeUndefined();
|
||||
const oldest = await consumeCodexAppServerLiveThread(client as never, "thread-oldest");
|
||||
expect(oldest).toEqual(expect.objectContaining({ release: expect.any(Function) }));
|
||||
await expect(
|
||||
retainCodexAppServerLiveThread(client as never, "thread-oldest", oldest?.release),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(client as never, "thread-sibling-1"),
|
||||
).resolves.toEqual(expect.objectContaining({ release: expect.any(Function) }));
|
||||
expect(releaseParentThread).toHaveBeenCalledOnce();
|
||||
|
||||
parent.unregister();
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("releases the exact retained completed child when its original parent closes it", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
client.request.mockImplementation(async (method) => {
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {} as never;
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
ensureCodexAppServerClientRuntime(client as never, { agentDir: "/tmp/agent" });
|
||||
const parent = registerCodexNativeSubagentMonitor({
|
||||
client: client as never,
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
taskRuntimeScope: createTaskScope("agent:main:main"),
|
||||
runtime,
|
||||
});
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await vi.waitFor(() =>
|
||||
expect(isCodexAppServerLiveThreadClaimed(client as never, "child-thread")).toBe(true),
|
||||
);
|
||||
await client.notify(nativeCompletionNotification());
|
||||
await vi.waitFor(() =>
|
||||
expect(isCodexAppServerLiveThreadClaimed(client as never, "child-thread")).toBe(false),
|
||||
);
|
||||
|
||||
await client.notify(closeAgentNotification({ method: "item/completed" }));
|
||||
await vi.waitFor(() =>
|
||||
expect(client.request).toHaveBeenCalledExactlyOnceWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "child-thread" },
|
||||
{ timeoutMs: 5_000 },
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(client as never, "child-thread"),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
parent.unregister();
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("fences a stale child close after eviction and same-client replacement ownership", async () => {
|
||||
const client = createClient();
|
||||
const runtime = createRuntime();
|
||||
client.request.mockImplementation(async (method) => {
|
||||
if (method === "thread/unsubscribe" || method === "thread/resume") {
|
||||
return {} as never;
|
||||
}
|
||||
throw new Error(`unexpected request: ${method}`);
|
||||
});
|
||||
ensureCodexAppServerClientRuntime(client as never, { agentDir: "/tmp/agent" });
|
||||
const parent = registerCodexNativeSubagentMonitor({
|
||||
client: client as never,
|
||||
parentThreadId: "parent-thread",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
taskRuntimeScope: createTaskScope("agent:main:main"),
|
||||
runtime,
|
||||
});
|
||||
|
||||
await notifyChildStarted(client);
|
||||
await vi.waitFor(() =>
|
||||
expect(isCodexAppServerLiveThreadClaimed(client as never, "child-thread")).toBe(true),
|
||||
);
|
||||
await client.notify(nativeCompletionNotification());
|
||||
await vi.waitFor(() =>
|
||||
expect(isCodexAppServerLiveThreadClaimed(client as never, "child-thread")).toBe(false),
|
||||
);
|
||||
await expect(releaseCodexAppServerLiveThread(client as never, "child-thread")).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
expect(client.request).toHaveBeenCalledOnce();
|
||||
|
||||
await client.request("thread/resume", { threadId: "child-thread" });
|
||||
const replacement = await claimCodexAppServerLiveThread(client as never, "child-thread");
|
||||
expect(replacement).toEqual(expect.objectContaining({ release: expect.any(Function) }));
|
||||
expect(isCodexAppServerLiveThreadClaimed(client as never, "child-thread")).toBe(true);
|
||||
|
||||
await client.notify(closeAgentNotification({ method: "item/completed" }));
|
||||
expect(client.request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/unsubscribe",
|
||||
"thread/resume",
|
||||
]);
|
||||
expect(isCodexAppServerLiveThreadClaimed(client as never, "child-thread")).toBe(true);
|
||||
|
||||
await replacement?.release("child-thread");
|
||||
expect(client.request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/unsubscribe",
|
||||
"thread/resume",
|
||||
"thread/unsubscribe",
|
||||
]);
|
||||
parent.unregister();
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("clears child recovery timers when the app-server client closes", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -11,7 +11,14 @@ import {
|
||||
type AgentHarnessTaskRuntime,
|
||||
type AgentHarnessTaskRuntimeScope,
|
||||
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
|
||||
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
||||
import { asFiniteNumber, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
claimCodexAppServerLiveThread,
|
||||
releaseCodexAppServerLiveThread,
|
||||
retainCodexAppServerLiveThread,
|
||||
type CodexAppServerLiveThreadOwnership,
|
||||
} from "./client-runtime.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import {
|
||||
codexNativeSubagentNotifications as nativeSubagentNotifications,
|
||||
@@ -94,6 +101,7 @@ type ThreadStatusRevision = {
|
||||
};
|
||||
|
||||
type TaskRecoveryCandidate = {
|
||||
parentState: ParentState;
|
||||
childThreadId: string;
|
||||
recoveryAttempt: number;
|
||||
requesterSessionKey: string;
|
||||
@@ -109,6 +117,9 @@ type MonitorOptions = {
|
||||
now?: () => number;
|
||||
retainClient?: () => (() => void) | undefined;
|
||||
retainParentThread?: (threadId: string) => (() => void) | undefined;
|
||||
claimChildThread?: (threadId: string) => Promise<unknown>;
|
||||
retainChildThread?: (threadId: string) => Promise<unknown>;
|
||||
releaseChildThread?: (threadId: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const DEFAULT_RECOVERY_POLL_DELAYS_MS = [
|
||||
@@ -158,9 +169,58 @@ function registerMonitor(params: {
|
||||
}): { unregister: () => void } {
|
||||
let monitor = monitors.get(params.client);
|
||||
if (!monitor) {
|
||||
// Native start/completion can race; serialize each child so only its
|
||||
// original claim handle may publish or release the same subscription.
|
||||
const childThreadOwnership = new Map<string, CodexAppServerLiveThreadOwnership>();
|
||||
const childThreadTransitions = new KeyedAsyncQueue();
|
||||
monitor = new Monitor(params.client, params.runtime ?? defaultRuntime, {
|
||||
retainClient: params.retainClient,
|
||||
retainParentThread: params.retainParentThread,
|
||||
claimChildThread: (threadId) =>
|
||||
childThreadTransitions.enqueue(threadId, async () => {
|
||||
// Codex subscribes fresh children before thread/started; they have
|
||||
// no idle entry yet but must already be fenced from manual adoption.
|
||||
const ownership = await claimCodexAppServerLiveThread(params.client, threadId);
|
||||
if (ownership) {
|
||||
childThreadOwnership.set(threadId, ownership);
|
||||
}
|
||||
return ownership;
|
||||
}),
|
||||
retainChildThread: (threadId) =>
|
||||
childThreadTransitions.enqueue(threadId, async () => {
|
||||
const ownership = childThreadOwnership.get(threadId);
|
||||
let retained = false;
|
||||
try {
|
||||
retained = await retainCodexAppServerLiveThread(
|
||||
params.client,
|
||||
threadId,
|
||||
ownership?.release,
|
||||
);
|
||||
return retained;
|
||||
} finally {
|
||||
// A full idle pool can reject terminal child ownership. Release
|
||||
// its exact branded claim before the monitor forgets that child.
|
||||
if (!retained && ownership) {
|
||||
await ownership.release(threadId);
|
||||
}
|
||||
if (childThreadOwnership.get(threadId) === ownership) {
|
||||
childThreadOwnership.delete(threadId);
|
||||
}
|
||||
}
|
||||
}),
|
||||
releaseChildThread: (threadId) =>
|
||||
childThreadTransitions.enqueue(threadId, async () => {
|
||||
const ownership = childThreadOwnership.get(threadId);
|
||||
if (ownership) {
|
||||
await ownership.release(threadId);
|
||||
if (childThreadOwnership.get(threadId) === ownership) {
|
||||
childThreadOwnership.delete(threadId);
|
||||
}
|
||||
} else {
|
||||
// A bare closeAgent thread id cannot authorize a successor's subscription.
|
||||
await releaseCodexAppServerLiveThread(params.client, threadId);
|
||||
}
|
||||
}),
|
||||
});
|
||||
monitors.set(params.client, monitor);
|
||||
}
|
||||
@@ -174,6 +234,7 @@ function registerMonitor(params: {
|
||||
|
||||
class Monitor {
|
||||
private readonly parentStates = new Map<string, ParentState>();
|
||||
private readonly retiredParentStates = new WeakSet<ParentState>();
|
||||
private readonly childStates = new Map<string, ChildState>();
|
||||
private readonly childThreadIdsByAgentPath = new Map<string, string>();
|
||||
private readonly taskReconciliations = new Map<string, Promise<void>>();
|
||||
@@ -187,6 +248,9 @@ class Monitor {
|
||||
private readonly removeCloseHandler: () => void;
|
||||
private readonly retainClient?: () => (() => void) | undefined;
|
||||
private readonly retainParentThread?: (threadId: string) => (() => void) | undefined;
|
||||
private readonly claimChildThread?: (threadId: string) => Promise<unknown>;
|
||||
private readonly retainChildThread?: (threadId: string) => Promise<unknown>;
|
||||
private readonly releaseChildThread?: (threadId: string) => Promise<unknown>;
|
||||
private readonly parentThreadRetentions = new Map<string, () => void>();
|
||||
private releaseClientRetention?: () => void;
|
||||
private disposed = false;
|
||||
@@ -204,6 +268,9 @@ class Monitor {
|
||||
this.now = options.now ?? Date.now;
|
||||
this.retainClient = options.retainClient;
|
||||
this.retainParentThread = options.retainParentThread;
|
||||
this.claimChildThread = options.claimChildThread;
|
||||
this.retainChildThread = options.retainChildThread;
|
||||
this.releaseChildThread = options.releaseChildThread;
|
||||
this.removeNotificationHandler = client.addNotificationHandler(async (notification) => {
|
||||
if (!NATIVE_SUBAGENT_NOTIFICATION_METHODS.has(notification.method)) {
|
||||
return;
|
||||
@@ -304,7 +371,7 @@ class Monitor {
|
||||
}
|
||||
registered = false;
|
||||
const current = this.parentStates.get(parentThreadId);
|
||||
if (current) {
|
||||
if (current === registeredState) {
|
||||
current.ownerCount -= 1;
|
||||
this.pruneParentIfUnused(current);
|
||||
}
|
||||
@@ -312,6 +379,26 @@ class Monitor {
|
||||
};
|
||||
}
|
||||
|
||||
retireParent(parentThreadIdInput: string): void {
|
||||
const parentThreadId = parentThreadIdInput.trim();
|
||||
const state = this.parentStates.get(parentThreadId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
// Reset invalidates this exact registration generation. Pending recovery
|
||||
// must not recreate its parent or deliver old children into a replacement.
|
||||
this.retiredParentStates.add(state);
|
||||
state.ownerCount = 0;
|
||||
for (const childState of Array.from(this.childStates.values())) {
|
||||
if (childState.parentThreadId === parentThreadId) {
|
||||
this.retireChild(state, childState, "Codex native subagent parent session ended.");
|
||||
}
|
||||
}
|
||||
if (this.parentStates.get(parentThreadId) === state) {
|
||||
this.parentStates.delete(parentThreadId);
|
||||
}
|
||||
}
|
||||
|
||||
private prepareParentTaskRuntime(state: ParentState): void {
|
||||
if (!state.requesterSessionKey || !state.taskRuntimeScope) {
|
||||
return;
|
||||
@@ -372,6 +459,9 @@ class Monitor {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (state) {
|
||||
this.handleClosedChild(notification, state);
|
||||
}
|
||||
const childState = threadId ? this.childStates.get(threadId) : undefined;
|
||||
if (notification.method === "turn/started" && childState) {
|
||||
this.resumeChild(childState);
|
||||
@@ -639,6 +729,11 @@ class Monitor {
|
||||
return state;
|
||||
}
|
||||
const isSpawnAgentTool = normalizeIdentifier(readString(item, "tool")) === "spawnagent";
|
||||
if (normalizeIdentifier(readString(item, "tool")) === "closeagent") {
|
||||
// closeAgent names an existing child before shutdown; treating its
|
||||
// receiver as discovery resurrects completed tasks and repins parents.
|
||||
return state;
|
||||
}
|
||||
const childThreadIds = isSpawnAgentTool
|
||||
? new Set([
|
||||
...readStringArray(item?.receiverThreadIds),
|
||||
@@ -658,6 +753,63 @@ class Monitor {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private handleClosedChild(notification: CodexServerNotification, state: ParentState): void {
|
||||
if (notification.method !== "item/completed") {
|
||||
return;
|
||||
}
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
const item = isJsonObject(params?.item) ? params.item : undefined;
|
||||
if (
|
||||
readString(item, "type") !== "collabAgentToolCall" ||
|
||||
normalizeIdentifier(readString(item, "tool")) !== "closeagent" ||
|
||||
normalizeIdentifier(readString(item, "status")) !== "completed"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const childThreadIds = new Set([
|
||||
...readStringArray(item?.receiverThreadIds),
|
||||
...readObjectStringKeys(item?.agentsStates),
|
||||
]);
|
||||
for (const childThreadId of childThreadIds) {
|
||||
const childState = this.childStates.get(childThreadId);
|
||||
if (childState && childState.parentThreadId !== state.parentThreadId) {
|
||||
continue;
|
||||
}
|
||||
if (childState) {
|
||||
this.retireChild(state, childState, "Codex native subagent was closed.");
|
||||
} else {
|
||||
this.updateChildThreadOwnership("release", childThreadId, this.releaseChildThread);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private retireChild(state: ParentState, childState: ChildState, summary: string): void {
|
||||
if (!childState.terminal) {
|
||||
childState.terminal = true;
|
||||
const eventAt = this.now();
|
||||
state.mirror?.markAuthoritativeCompletion(childState.childThreadId);
|
||||
state.taskRuntime?.finalizeTaskRunByRunId({
|
||||
runId: codexNativeSubagentRunId(childState.childThreadId),
|
||||
status: "cancelled",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
error: summary,
|
||||
progressSummary: summary,
|
||||
terminalSummary: summary,
|
||||
});
|
||||
}
|
||||
if (childState.pendingCompletion) {
|
||||
childState.pendingCompletion = undefined;
|
||||
state.taskRuntime?.setDetachedTaskDeliveryStatusByRunId({
|
||||
runId: codexNativeSubagentRunId(childState.childThreadId),
|
||||
deliveryStatus: "failed",
|
||||
error: summary,
|
||||
});
|
||||
}
|
||||
this.unregisterChild(childState, { retainSubscription: false });
|
||||
this.updateChildThreadOwnership("release", childState.childThreadId, this.releaseChildThread);
|
||||
}
|
||||
|
||||
private async handleCompletionNotification(notification: CodexServerNotification): Promise<void> {
|
||||
const params = isJsonObject(notification.params) ? notification.params : undefined;
|
||||
const parentThreadId = params ? readString(params, "threadId")?.trim() : undefined;
|
||||
@@ -918,6 +1070,12 @@ class Monitor {
|
||||
replyInstruction:
|
||||
"Use the Codex native subagent result to continue or wrap up the parent task. If this is a Discord/channel session, send the visible response with the message tool instead of only writing a transcript final answer. Reply in your normal assistant voice and do not expose internal notification markup.",
|
||||
});
|
||||
if (
|
||||
this.childStates.get(childState.childThreadId) !== childState ||
|
||||
this.parentStates.get(state.parentThreadId) !== state
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isDurableAgentHarnessCompletionDelivery(delivery)) {
|
||||
childState.pendingCompletion = undefined;
|
||||
childState.completionDeliveryAttempt = 0;
|
||||
@@ -936,6 +1094,12 @@ class Monitor {
|
||||
});
|
||||
this.scheduleCompletionDeliveryRetry(childState, error);
|
||||
} catch (error) {
|
||||
if (
|
||||
this.childStates.get(childState.childThreadId) !== childState ||
|
||||
this.parentStates.get(state.parentThreadId) !== state
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const message = formatErrorMessage(error);
|
||||
state.taskRuntime?.setDetachedTaskDeliveryStatusByRunId({
|
||||
runId: codexNativeSubagentRunId(completion.childThreadId),
|
||||
@@ -1008,6 +1172,7 @@ class Monitor {
|
||||
return undefined;
|
||||
}
|
||||
if (!childState) {
|
||||
this.updateChildThreadOwnership("claim", childThreadId, this.claimChildThread);
|
||||
this.releaseClientRetention ??= this.retainClient?.();
|
||||
if (!this.parentThreadRetentions.has(parentThreadId)) {
|
||||
const releaseParentThread = this.retainParentThread?.(parentThreadId);
|
||||
@@ -1062,7 +1227,15 @@ class Monitor {
|
||||
childState.agentPathKeys.add(key);
|
||||
}
|
||||
|
||||
private unregisterChild(childState: ChildState): void {
|
||||
private unregisterChild(
|
||||
childState: ChildState,
|
||||
options: { retainSubscription?: boolean } = {},
|
||||
): void {
|
||||
if (childState.terminal && options.retainSubscription !== false && !this.disposed) {
|
||||
// Completed Codex children intentionally remain reusable. Transfer their
|
||||
// auto-subscription into the shared bounded warm-thread owner, not oblivion.
|
||||
this.updateChildThreadOwnership("retain", childState.childThreadId, this.retainChildThread);
|
||||
}
|
||||
this.clearRecoveryTimers(childState);
|
||||
if (childState.completionDeliveryTimer) {
|
||||
clearTimeout(childState.completionDeliveryTimer);
|
||||
@@ -1100,6 +1273,23 @@ class Monitor {
|
||||
}
|
||||
}
|
||||
|
||||
private updateChildThreadOwnership(
|
||||
operation: "claim" | "retain" | "release",
|
||||
childThreadId: string,
|
||||
update: ((threadId: string) => Promise<unknown>) | undefined,
|
||||
): void {
|
||||
if (!update) {
|
||||
return;
|
||||
}
|
||||
void update(childThreadId).catch((error: unknown) => {
|
||||
embeddedAgentLog.warn("Failed to update Codex native subagent thread ownership", {
|
||||
operation,
|
||||
childThreadId,
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private releaseClientRetentionIfIdle(): void {
|
||||
if (
|
||||
[...this.childStates.values()].some(
|
||||
@@ -1287,6 +1477,7 @@ class Monitor {
|
||||
}
|
||||
const childThreadId = task.runId!.slice(CODEX_NATIVE_SUBAGENT_RUN_ID_PREFIX.length).trim();
|
||||
candidates.set(childThreadId, {
|
||||
parentState: state,
|
||||
requesterSessionKey: state.requesterSessionKey,
|
||||
childThreadId,
|
||||
recoveryAttempt: 0,
|
||||
@@ -1329,6 +1520,7 @@ class Monitor {
|
||||
const key = `${candidate.requesterSessionKey}\0${candidate.childThreadId}`;
|
||||
if (
|
||||
this.disposed ||
|
||||
this.retiredParentStates.has(candidate.parentState) ||
|
||||
this.recoveryPollDelaysMs.length === 0 ||
|
||||
this.taskReconciliationTimers.has(key)
|
||||
) {
|
||||
@@ -1347,6 +1539,9 @@ class Monitor {
|
||||
}
|
||||
|
||||
private async reconcileTaskCandidateOnce(candidate: TaskRecoveryCandidate): Promise<void> {
|
||||
if (this.retiredParentStates.has(candidate.parentState)) {
|
||||
return;
|
||||
}
|
||||
const runId = codexNativeSubagentRunId(candidate.childThreadId);
|
||||
const task = candidate.taskRuntime.listTaskRecords().find((record) => record.runId === runId);
|
||||
if (
|
||||
@@ -1367,6 +1562,9 @@ class Monitor {
|
||||
this.scheduleTaskCandidateReconciliation(candidate);
|
||||
return;
|
||||
}
|
||||
if (this.retiredParentStates.has(candidate.parentState)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!statusRead.isCurrent() ||
|
||||
this.childStates.get(candidate.childThreadId) !== childBeforeRead
|
||||
@@ -1457,7 +1655,13 @@ class Monitor {
|
||||
}
|
||||
}
|
||||
|
||||
export const codexNativeSubagentMonitorRuntime = { Monitor, register: registerMonitor };
|
||||
export const codexNativeSubagentMonitorRuntime = {
|
||||
Monitor,
|
||||
register: registerMonitor,
|
||||
retireParent: (client: CodexAppServerClient, parentThreadId: string): void => {
|
||||
monitors.get(client)?.retireParent(parentThreadId);
|
||||
},
|
||||
};
|
||||
|
||||
function readThreadTurnRecovery(
|
||||
thread: JsonObject,
|
||||
|
||||
@@ -406,15 +406,12 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
lastEventAt: 41_000,
|
||||
progressSummary: "Codex native subagent received more input.",
|
||||
});
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
|
||||
runId: "codex-thread:child-v2",
|
||||
status: "cancelled",
|
||||
endedAt: 41_000,
|
||||
lastEventAt: 41_000,
|
||||
error: "Codex native subagent was interrupted.",
|
||||
progressSummary: "Codex native subagent was interrupted.",
|
||||
terminalSummary: "Codex native subagent was interrupted.",
|
||||
});
|
||||
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the notification thread id when collab agent items omit sender thread id", () => {
|
||||
|
||||
@@ -358,7 +358,9 @@ export class CodexNativeSubagentTaskMirror {
|
||||
return;
|
||||
}
|
||||
const eventAt = this.now();
|
||||
if (normalizedStatus === "pendingInit" || normalizedStatus === "running") {
|
||||
if (isNonTerminalAgentStateStatus(normalizedStatus)) {
|
||||
// Codex interrupted agents remain open and can resume; finalizing here
|
||||
// makes cancellation sticky and discards their later successful result.
|
||||
this.runtime.recordTaskRunProgressByRunId({
|
||||
runId,
|
||||
lastEventAt: eventAt,
|
||||
@@ -366,7 +368,9 @@ export class CodexNativeSubagentTaskMirror {
|
||||
trimOptional(message) ??
|
||||
(normalizedStatus === "pendingInit"
|
||||
? "Codex native subagent is initializing."
|
||||
: "Codex native subagent is running."),
|
||||
: normalizedStatus === "interrupted"
|
||||
? "Codex native subagent was interrupted."
|
||||
: "Codex native subagent is running."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -409,10 +413,7 @@ export class CodexNativeSubagentTaskMirror {
|
||||
this.terminalRunIds.add(runId);
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
runId,
|
||||
status:
|
||||
normalizedStatus === "interrupted" || normalizedStatus === "shutdown"
|
||||
? "cancelled"
|
||||
: "failed",
|
||||
status: normalizedStatus === "shutdown" ? "cancelled" : "failed",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
error: trimOptional(message) ?? `Codex native subagent status: ${normalizedStatus}`,
|
||||
@@ -544,7 +545,7 @@ function isBlockedOrFailedCollabToolCallStatus(value: string | undefined): boole
|
||||
}
|
||||
|
||||
function isNonTerminalAgentStateStatus(value: string | undefined): boolean {
|
||||
return value === "pendingInit" || value === "running";
|
||||
return value === "pendingInit" || value === "running" || value === "interrupted";
|
||||
}
|
||||
|
||||
function isTerminalAgentStateStatus(value: string | undefined): boolean {
|
||||
|
||||
@@ -509,7 +509,7 @@ function emptyPluginThreadConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildDisabledAppsConfigPatch(): JsonObject {
|
||||
export function buildDisabledAppsConfigPatch(): JsonObject {
|
||||
return {
|
||||
apps: {
|
||||
_default: {
|
||||
|
||||
@@ -217,7 +217,7 @@ export async function withCodexAppServerJsonClient<T>(
|
||||
// to the conservative graceful/force-kill window used elsewhere.
|
||||
isolatedShutdown?: { exitTimeoutMs?: number; forceKillDelayMs?: number };
|
||||
},
|
||||
run: (request: CodexAppServerScopedRequest) => Promise<T>,
|
||||
run: (request: CodexAppServerScopedRequest, client: CodexAppServerClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const timeoutMs = params.timeoutMs ?? 60_000;
|
||||
const timeoutMessage = params.timeoutMessage ?? "codex app-server request timed out";
|
||||
@@ -273,7 +273,7 @@ export async function withCodexAppServerJsonClient<T>(
|
||||
signal: timeoutController.signal,
|
||||
});
|
||||
};
|
||||
return await run(scopedRequest);
|
||||
return await run(scopedRequest, client);
|
||||
} catch (error) {
|
||||
if (!isCodexAppServerStartSelectionChangedError(error) || attempt > 0) {
|
||||
throw error;
|
||||
|
||||
@@ -89,13 +89,11 @@ export async function cleanupCodexAttempt(
|
||||
terminalState.turnSucceeded &&
|
||||
!isIncognitoSessionKey(params.sessionKey) &&
|
||||
params.cleanupBundleMcpOnRunEnd !== true &&
|
||||
!connection.activeContextEngine &&
|
||||
resourceState.thread.liveThreadConfigFingerprint !== undefined &&
|
||||
resourceState.thread.clientId === resolveCodexAppServerClientInstanceId(resourceState.client) &&
|
||||
resourceState.thread.preserveNativeModel !== true &&
|
||||
resourceState.thread.connectionScope !== "supervision" &&
|
||||
!resourceState.thread.ringZeroConfigFingerprint &&
|
||||
!resourceState.thread.contextEngine
|
||||
!resourceState.thread.ringZeroConfigFingerprint
|
||||
? (await bindingStore.read(bindingIdentity))?.threadId === resourceState.thread.threadId &&
|
||||
(await bindingStore.withLease(bindingIdentity, async () => {
|
||||
// Reset/end uses this same generation lease. Never publish an old
|
||||
@@ -108,18 +106,19 @@ export async function cleanupCodexAttempt(
|
||||
return await retainCodexAppServerLiveThread(
|
||||
resourceState.client,
|
||||
resourceState.thread.threadId,
|
||||
async (threadId) => {
|
||||
const released = await unsubscribeCodexThreadBestEffort(resourceState.client, {
|
||||
threadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
});
|
||||
if (!released) {
|
||||
await closeCodexStartupClientBestEffort(resourceState.client);
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
`Codex retained thread subscription could not be released: ${threadId}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
resourceState.thread.liveThreadOwnership?.release ??
|
||||
(async (threadId) => {
|
||||
const released = await unsubscribeCodexThreadBestEffort(resourceState.client, {
|
||||
threadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
});
|
||||
if (!released) {
|
||||
await closeCodexStartupClientBestEffort(resourceState.client);
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
`Codex retained thread subscription could not be released: ${threadId}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
resourceState.thread.liveThreadConfigFingerprint,
|
||||
connection.mutable.pluginAppServer.serviceTier,
|
||||
);
|
||||
|
||||
@@ -577,20 +577,19 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
fingerprint: undefined,
|
||||
});
|
||||
|
||||
const secondHarness = createStartedThreadHarness(async (method) => {
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-1");
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const secondRun = runCodexAppServerAttempt(firstParams);
|
||||
await secondHarness.waitForMethod("turn/start");
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
firstHarness.requests.filter((request) => request.method === "turn/start"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(secondHarness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/resume",
|
||||
expect(firstHarness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/start",
|
||||
"turn/start",
|
||||
"turn/start",
|
||||
]);
|
||||
const secondInputText = getRequestInputText(secondHarness);
|
||||
const secondInputText = getRequestInputTextAt(firstHarness, 1);
|
||||
expect(secondInputText).not.toContain("OpenClaw assembled context for this turn:");
|
||||
expect(secondInputText).not.toContain("bootstrap-only context");
|
||||
expect(secondInputText).toBe("hello");
|
||||
@@ -626,7 +625,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
|
||||
],
|
||||
]);
|
||||
|
||||
await secondHarness.completeTurn();
|
||||
await firstHarness.completeTurn();
|
||||
await secondRun;
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,11 @@ import { readAttemptTerminal } from "./attempt-terminal.test-helper.js";
|
||||
import { withCodexStartupTimeout } from "./attempt-timeouts.js";
|
||||
import { prepareCodexAppServerAuthBinding } from "./auth-binding.js";
|
||||
import { resolveCodexAppServerFallbackApiKeyCacheKey } from "./auth-bridge.js";
|
||||
import { CodexAppServerRpcError } from "./client.js";
|
||||
import {
|
||||
consumeCodexAppServerLiveThread,
|
||||
retainCodexAppServerLiveThread,
|
||||
} from "./client-runtime.js";
|
||||
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
|
||||
import {
|
||||
readCodexPluginConfig,
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
@@ -834,6 +838,7 @@ async function runSharedClientRestartTest(closeCount: number) {
|
||||
const { sessionFile, workspaceDir } = createRunPaths();
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
const requests: string[][] = [];
|
||||
const clients: CodexAppServerClient[] = [];
|
||||
let starts = 0;
|
||||
const state: {
|
||||
notify: (notification: CodexServerNotification) => Promise<void>;
|
||||
@@ -842,7 +847,7 @@ async function runSharedClientRestartTest(closeCount: number) {
|
||||
const startIndex = starts++;
|
||||
const methods: string[] = [];
|
||||
requests.push(methods);
|
||||
return {
|
||||
const client = {
|
||||
...mockClientRuntimeMethods(),
|
||||
request: vi.fn(async (method: string) => {
|
||||
methods.push(method);
|
||||
@@ -862,7 +867,9 @@ async function runSharedClientRestartTest(closeCount: number) {
|
||||
return () => undefined;
|
||||
},
|
||||
addRequestHandler: () => () => undefined,
|
||||
} as never;
|
||||
} as unknown as CodexAppServerClient;
|
||||
clients.push(client);
|
||||
return client;
|
||||
});
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
await vi.waitFor(() => expect(requests[closeCount]).toContain("turn/start"), fastWait);
|
||||
@@ -874,7 +881,23 @@ async function runSharedClientRestartTest(closeCount: number) {
|
||||
turn: { id: "turn-1", status: "completed" },
|
||||
},
|
||||
});
|
||||
return { result: await run, requests };
|
||||
return { result: await run, requests, client: clients[closeCount]! };
|
||||
}
|
||||
|
||||
async function expectRetainedSuccessfulThread(client: CodexAppServerClient, threadId: string) {
|
||||
const ownership = await consumeCodexAppServerLiveThread(client, threadId);
|
||||
expect(ownership).toEqual(expect.objectContaining({ release: expect.any(Function) }));
|
||||
// Restore the exact branded owner so this assertion itself cannot orphan
|
||||
// the persistent subscription or alter later cleanup in the same test.
|
||||
await expect(
|
||||
retainCodexAppServerLiveThread(
|
||||
client,
|
||||
threadId,
|
||||
ownership?.release,
|
||||
ownership?.configFingerprint,
|
||||
ownership?.serviceTier,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
}
|
||||
|
||||
async function createSandboxReleaseFixture(
|
||||
@@ -3999,8 +4022,8 @@ describe("runCodexAppServerAttempt", () => {
|
||||
"thread/resume",
|
||||
"turn/start",
|
||||
"turn/start",
|
||||
"thread/unsubscribe",
|
||||
]);
|
||||
await expectRetainedSuccessfulThread(harness.client, "thread-existing");
|
||||
});
|
||||
|
||||
it("waits for the exact active native turn before starting a resumed thread turn", async () => {
|
||||
@@ -4060,8 +4083,8 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(harness.requests.map((request) => request.method)).toEqual([
|
||||
"thread/resume",
|
||||
"turn/start",
|
||||
"thread/unsubscribe",
|
||||
]);
|
||||
await expectRetainedSuccessfulThread(harness.client, "thread-existing");
|
||||
});
|
||||
it("does not retry turn/start for non-compact active turns", async () => {
|
||||
const { sessionFile, workspaceDir } = createRunPaths();
|
||||
@@ -5002,22 +5025,21 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(savedBinding?.threadId).toBe("thread-1");
|
||||
});
|
||||
it("restarts the app-server once when a shared client closes during startup", async () => {
|
||||
const { result, requests } = await runSharedClientRestartTest(1);
|
||||
const { result, requests, client } = await runSharedClientRestartTest(1);
|
||||
expect(readAttemptTerminal(result).aborted).toBe(false);
|
||||
expect(requests).toEqual([
|
||||
["thread/resume"],
|
||||
["thread/resume", "turn/start", "thread/unsubscribe"],
|
||||
]);
|
||||
expect(requests).toEqual([["thread/resume"], ["thread/resume", "turn/start"]]);
|
||||
await expectRetainedSuccessfulThread(client, "thread-existing");
|
||||
});
|
||||
|
||||
it("tolerates a second app-server close while retrying startup", async () => {
|
||||
const { result, requests } = await runSharedClientRestartTest(2);
|
||||
const { result, requests, client } = await runSharedClientRestartTest(2);
|
||||
expect(readAttemptTerminal(result).aborted).toBe(false);
|
||||
expect(requests).toEqual([
|
||||
["thread/resume"],
|
||||
["thread/resume"],
|
||||
["thread/resume", "turn/start", "thread/unsubscribe"],
|
||||
["thread/resume", "turn/start"],
|
||||
]);
|
||||
await expectRetainedSuccessfulThread(client, "thread-existing");
|
||||
});
|
||||
it("does not retire the shared Codex client when a spawned helper run fails with a logical thread/start error", async () => {
|
||||
const { retireSpy, state } = installFailingThreadStartClient(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
unsubscribeCodexThreadBestEffort,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
import { releaseCodexAppServerLiveThread } from "./client-runtime.js";
|
||||
import { codexNativeSubagentMonitorRuntime } from "./native-subagent-monitor.js";
|
||||
import type {
|
||||
CodexAppServerBindingIdentity,
|
||||
CodexAppServerBindingStore,
|
||||
@@ -46,6 +47,9 @@ export async function retireCodexAppServerSessionGeneration(params: {
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
// Reset retires native-child ownership before unsubscribing its parent;
|
||||
// late child completions must never reach a replacement session generation.
|
||||
codexNativeSubagentMonitorRuntime.retireParent(clientLease.client, binding.threadId);
|
||||
const released = await releaseCodexAppServerLiveThread(clientLease.client, binding.threadId);
|
||||
if (!released && isIncognitoSessionKey(params.identity.sessionKey)) {
|
||||
// Ephemeral threads have no rollout to resume, so they intentionally
|
||||
|
||||
@@ -246,6 +246,9 @@ export async function resumeExistingCodexThread(
|
||||
? params.mcpServersFingerprint
|
||||
: resumeBinding.mcpServersFingerprint;
|
||||
const resumePatch = {
|
||||
// Resume moves native subscription ownership to this physical client.
|
||||
// Keeping its previous client id disables warm reuse after every restart.
|
||||
clientId: resolveCodexAppServerClientInstanceId(params.client),
|
||||
cwd: params.cwd,
|
||||
rolloutPath: resolveCodexThreadRolloutPath(response.thread) ?? resumeBinding.rolloutPath,
|
||||
authProfileId: boundAuthProfileId,
|
||||
|
||||
@@ -449,19 +449,6 @@ export async function startOrResumeThread(
|
||||
await clearCurrentBinding("rotating a stale thread binding");
|
||||
binding = undefined;
|
||||
}
|
||||
if (
|
||||
binding?.threadId &&
|
||||
binding.environmentSelectionFingerprint !== environmentSelectionFingerprint
|
||||
) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server environment selection changed; starting a new thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
},
|
||||
);
|
||||
await clearCurrentBinding("rotating a stale thread binding");
|
||||
binding = undefined;
|
||||
}
|
||||
if (
|
||||
binding?.threadId &&
|
||||
(binding.networkProxyConfigFingerprint !== networkProxyConfigFingerprint ||
|
||||
@@ -587,8 +574,8 @@ export async function startOrResumeThread(
|
||||
binding,
|
||||
bindingIdentity,
|
||||
clientId,
|
||||
contextEngineBinding,
|
||||
dynamicToolsFingerprint,
|
||||
environmentSelectionFingerprint,
|
||||
hostSystemAgentActive,
|
||||
lifecycleTiming,
|
||||
nativeSkillIsolation,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { CodexAppServerLiveThreadOwnership } from "./client-runtime.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type { CodexAppServerRuntimeOptions } from "./config.js";
|
||||
import type { CodexPluginThreadConfig } from "./plugin-thread-config.js";
|
||||
@@ -17,6 +18,8 @@ type CodexAppServerThreadLifecycle = {
|
||||
export type CodexAppServerThreadLifecycleBinding = CodexAppServerThreadBinding & {
|
||||
lifecycle: CodexAppServerThreadLifecycle;
|
||||
liveThreadConfigFingerprint?: string;
|
||||
/** Process-local claim proof; never write this callback into durable binding state. */
|
||||
liveThreadOwnership?: CodexAppServerLiveThreadOwnership;
|
||||
clearInheritedServiceTier?: true;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import type { JsonObject } from "./protocol.js";
|
||||
import type {
|
||||
CodexAppServerBindingIdentity,
|
||||
CodexAppServerContextEngineBinding,
|
||||
CodexAppServerThreadBinding,
|
||||
} from "./session-binding.js";
|
||||
import { fingerprintCodexThreadConfig } from "./thread-fingerprints.js";
|
||||
@@ -37,8 +36,8 @@ type CodexWarmThreadReuseParams = {
|
||||
binding: CodexAppServerThreadBinding;
|
||||
bindingIdentity: CodexAppServerBindingIdentity;
|
||||
clientId?: string;
|
||||
contextEngineBinding?: CodexAppServerContextEngineBinding;
|
||||
dynamicToolsFingerprint: string;
|
||||
environmentSelectionFingerprint?: string;
|
||||
hostSystemAgentActive: boolean;
|
||||
lifecycleTiming: CodexThreadLifecycleTimingTracker;
|
||||
nativeSkillIsolation?: Parameters<typeof applyCodexNativeSkillIsolation>[1];
|
||||
@@ -121,8 +120,8 @@ export async function tryReuseCodexLiveThread(
|
||||
binding,
|
||||
bindingIdentity,
|
||||
clientId,
|
||||
contextEngineBinding,
|
||||
dynamicToolsFingerprint,
|
||||
environmentSelectionFingerprint,
|
||||
hostSystemAgentActive,
|
||||
lifecycleTiming,
|
||||
nativeSkillIsolation,
|
||||
@@ -140,12 +139,14 @@ export async function tryReuseCodexLiveThread(
|
||||
binding.clientId !== clientId ||
|
||||
binding.preserveNativeModel === true ||
|
||||
binding.connectionScope === "supervision" ||
|
||||
ringZeroActive ||
|
||||
contextEngineBinding
|
||||
ringZeroActive
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Engine identity, projection epoch, and policy were checked by the owner
|
||||
// before this call; compatible bootstrap threads must keep their session.
|
||||
|
||||
const prebuiltFinalConfigPatch = params.buildFinalConfigPatch?.({
|
||||
action: "resume",
|
||||
binding,
|
||||
@@ -202,6 +203,15 @@ export async function tryReuseCodexLiveThread(
|
||||
liveThreadConfigFingerprint,
|
||||
);
|
||||
if (!retainedThread) {
|
||||
const incompatibleOwnership = await consumeCodexAppServerLiveThread(
|
||||
params.client,
|
||||
binding.threadId,
|
||||
);
|
||||
if (incompatibleOwnership) {
|
||||
// Codex ignores resume overrides while any subscription remains. Manual
|
||||
// external adoption has no verified fingerprint, so release before resume.
|
||||
await incompatibleOwnership.release(binding.threadId);
|
||||
}
|
||||
return { prebuiltFinalConfigPatch };
|
||||
}
|
||||
|
||||
@@ -216,7 +226,14 @@ export async function tryReuseCodexLiveThread(
|
||||
params.bindingStore.mutate(bindingIdentity, {
|
||||
kind: "patch",
|
||||
threadId: binding.threadId,
|
||||
patch: { cwd: params.cwd, model, nativeHookRelayGeneration },
|
||||
// Environment selection is sticky turn/start state, like cwd/model;
|
||||
// recording its new value must not recreate the approval-bearing thread.
|
||||
patch: {
|
||||
cwd: params.cwd,
|
||||
model,
|
||||
nativeHookRelayGeneration,
|
||||
environmentSelectionFingerprint,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!committed) {
|
||||
@@ -237,7 +254,9 @@ export async function tryReuseCodexLiveThread(
|
||||
cwd: params.cwd,
|
||||
model,
|
||||
nativeHookRelayGeneration,
|
||||
environmentSelectionFingerprint,
|
||||
liveThreadConfigFingerprint,
|
||||
liveThreadOwnership: retainedThread,
|
||||
...(retainedThread.serviceTier && resumeParams.serviceTier === undefined
|
||||
? { clearInheritedServiceTier: true }
|
||||
: {}),
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
testCodexAppServerBindingStore,
|
||||
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
|
||||
} from "./session-binding.test-helpers.js";
|
||||
import { fingerprintEnvironmentSelection } from "./thread-fingerprints.js";
|
||||
import {
|
||||
buildThreadResumeParams,
|
||||
startOrResumeThread as startOrResumeThreadImpl,
|
||||
@@ -397,6 +398,174 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a warm native session across sticky environment selection changes", async () => {
|
||||
const sessionFile = path.join(tempDir, "environment-session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "environment-workspace");
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-environments");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const client = {
|
||||
getInstanceId: () => "client-environments",
|
||||
request,
|
||||
addNotificationHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as never;
|
||||
ensureCodexAppServerClientRuntime(client, { agentDir: workspaceDir });
|
||||
const common = {
|
||||
client,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
userMcpServersEnabled: false,
|
||||
};
|
||||
const firstSelection = [{ environmentId: "environment-a", cwd: workspaceDir }];
|
||||
const secondSelection = [{ environmentId: "environment-b", cwd: workspaceDir }];
|
||||
|
||||
const started = await startOrResumeThread({
|
||||
...common,
|
||||
environmentSelection: firstSelection,
|
||||
});
|
||||
await expect(
|
||||
retainCodexAppServerLiveThread(
|
||||
client,
|
||||
started.threadId,
|
||||
undefined,
|
||||
started.liveThreadConfigFingerprint,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
const switched = await startOrResumeThread({
|
||||
...common,
|
||||
environmentSelection: secondSelection,
|
||||
});
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-environments",
|
||||
environmentSelectionFingerprint: fingerprintEnvironmentSelection(secondSelection),
|
||||
});
|
||||
await expect(
|
||||
retainCodexAppServerLiveThread(
|
||||
client,
|
||||
switched.threadId,
|
||||
switched.liveThreadOwnership?.release,
|
||||
switched.liveThreadConfigFingerprint,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
const restored = await startOrResumeThread({
|
||||
...common,
|
||||
environmentSelection: firstSelection,
|
||||
});
|
||||
|
||||
expect(switched.threadId).toBe(started.threadId);
|
||||
expect(restored.threadId).toBe(started.threadId);
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
environmentSelectionFingerprint: fingerprintEnvironmentSelection(firstSelection),
|
||||
});
|
||||
});
|
||||
|
||||
it("rebinds a resumed thread to its replacement physical client before warm reuse", async () => {
|
||||
const sessionFile = path.join(tempDir, "replacement-client-session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "replacement-client-workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-reused",
|
||||
clientId: "client-before-restart",
|
||||
cwd: workspaceDir,
|
||||
dynamicToolsFingerprint: "[]",
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-reused");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const client = {
|
||||
getInstanceId: () => "client-after-restart",
|
||||
request,
|
||||
addNotificationHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as never;
|
||||
ensureCodexAppServerClientRuntime(client, { agentDir: workspaceDir });
|
||||
const common = {
|
||||
client,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
userMcpServersEnabled: false,
|
||||
};
|
||||
|
||||
const resumed = await startOrResumeThread(common);
|
||||
|
||||
expect(resumed.clientId).toBe("client-after-restart");
|
||||
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
|
||||
threadId: "thread-reused",
|
||||
clientId: "client-after-restart",
|
||||
});
|
||||
await retainCodexAppServerLiveThread(
|
||||
client,
|
||||
resumed.threadId,
|
||||
undefined,
|
||||
resumed.liveThreadConfigFingerprint,
|
||||
);
|
||||
await expect(startOrResumeThread(common)).resolves.toMatchObject({
|
||||
threadId: "thread-reused",
|
||||
clientId: "client-after-restart",
|
||||
});
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]);
|
||||
});
|
||||
|
||||
it("releases an unverified manual-resume owner before applying canonical harness overrides", async () => {
|
||||
const sessionFile = path.join(tempDir, "manual-resume-session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "manual-resume-workspace");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
threadId: "thread-manual-resume",
|
||||
clientId: "client-manual-resume",
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {};
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-manual-resume");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const client = {
|
||||
getInstanceId: () => "client-manual-resume",
|
||||
request,
|
||||
addNotificationHandler: () => () => undefined,
|
||||
addRequestHandler: () => () => undefined,
|
||||
addCloseHandler: () => () => undefined,
|
||||
} as never;
|
||||
ensureCodexAppServerClientRuntime(client, { agentDir: workspaceDir });
|
||||
await retainCodexAppServerLiveThread(client, "thread-manual-resume");
|
||||
|
||||
const resumed = await startOrResumeThread({
|
||||
client,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
userMcpServersEnabled: false,
|
||||
});
|
||||
|
||||
expect(resumed).toMatchObject({
|
||||
threadId: "thread-manual-resume",
|
||||
clientId: "client-manual-resume",
|
||||
lifecycle: { action: "resumed" },
|
||||
});
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/unsubscribe",
|
||||
"thread/resume",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reuses an isolated retained thread without dropping native skill isolation", async () => {
|
||||
vi.stubEnv("HOME", tempDir);
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(tempDir, "isolated-state"));
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
||||
import {
|
||||
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
closeCodexStartupClientBestEffort,
|
||||
CodexAppServerUnsafeSubscriptionError,
|
||||
unsubscribeCodexThreadBestEffort,
|
||||
} from "./attempt-client-cleanup.js";
|
||||
import {
|
||||
isCodexAppServerLiveThreadClaimed,
|
||||
releaseCodexAppServerLiveThread,
|
||||
retainCodexAppServerLiveThread,
|
||||
type CodexAppServerLiveThreadOwnership,
|
||||
} from "./client-runtime.js";
|
||||
import type { CodexAppServerClient } from "./client.js";
|
||||
import type {
|
||||
CodexAppServerBindingIdentity,
|
||||
CodexAppServerBindingStore,
|
||||
CodexAppServerThreadBinding,
|
||||
} from "./session-binding.js";
|
||||
import { retainSharedCodexAppServerClientByInstanceId } from "./shared-client.js";
|
||||
|
||||
const nativeThreadOwners = new KeyedAsyncQueue();
|
||||
|
||||
/** Codex subscriptions belong to a physical connection, not the native thread ID alone. */
|
||||
export function isSameCodexAppServerThreadOwner(
|
||||
current: Pick<CodexAppServerThreadBinding, "threadId" | "clientId"> | undefined,
|
||||
expected: Pick<CodexAppServerThreadBinding, "threadId" | "clientId"> | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
current !== undefined &&
|
||||
expected !== undefined &&
|
||||
current.threadId === expected.threadId &&
|
||||
current.clientId === expected.clientId
|
||||
);
|
||||
}
|
||||
|
||||
/** Fences native subscription and commit together; Codex subscriptions are not reference-counted. */
|
||||
export async function withExclusiveCodexAppServerThread<T>(params: {
|
||||
bindingStore: CodexAppServerBindingStore;
|
||||
identity: CodexAppServerBindingIdentity;
|
||||
threadId: string;
|
||||
run: () => Promise<T>;
|
||||
}): Promise<T> {
|
||||
return await nativeThreadOwners.enqueue(`thread:${params.threadId}`, async () => {
|
||||
if (await params.bindingStore.hasOtherThreadOwner(params.threadId, params.identity)) {
|
||||
throw new Error(
|
||||
`Codex thread ${params.threadId} is owned by another OpenClaw session or conversation.`,
|
||||
);
|
||||
}
|
||||
return await params.run();
|
||||
});
|
||||
}
|
||||
|
||||
/** Serializes bound turns and retirement so detach cannot unsubscribe an active turn. */
|
||||
export async function withCodexConversationThreadActivity<T>(
|
||||
bindingId: string,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return await nativeThreadOwners.enqueue(`conversation:${bindingId}`, run);
|
||||
}
|
||||
|
||||
/** Publishes one owned persistent subscription into the shared bounded idle registry. */
|
||||
export async function retainCodexAppServerBindingSubscription(
|
||||
client: CodexAppServerClient,
|
||||
threadId: string,
|
||||
ownership?: CodexAppServerLiveThreadOwnership,
|
||||
): Promise<boolean> {
|
||||
return await retainCodexAppServerLiveThread(
|
||||
client,
|
||||
threadId,
|
||||
ownership?.release ??
|
||||
(async (releasedThreadId) => {
|
||||
const unsubscribed = await unsubscribeCodexThreadBestEffort(client, {
|
||||
threadId: releasedThreadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
});
|
||||
if (!unsubscribed) {
|
||||
await closeCodexStartupClientBestEffort(client);
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
`Codex thread subscription could not be released: ${releasedThreadId}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
ownership?.configFingerprint,
|
||||
ownership?.serviceTier,
|
||||
);
|
||||
}
|
||||
|
||||
/** Rolls back the exact subscription Codex created before its binding was committed. */
|
||||
export async function rollbackCodexAppServerBindingSubscription(
|
||||
client: CodexAppServerClient,
|
||||
threadId: string,
|
||||
retained: boolean,
|
||||
): Promise<void> {
|
||||
if (retained && (await releaseCodexAppServerLiveThread(client, threadId))) {
|
||||
return;
|
||||
}
|
||||
// Failed retention can mean another generation already owns this exact
|
||||
// subscription; resume did not create a second connection-scoped listener.
|
||||
if (isCodexAppServerLiveThreadClaimed(client, threadId)) {
|
||||
return;
|
||||
}
|
||||
// Start/resume subscribes before its response; failed retention has no
|
||||
// registry owner, so the responding physical client must unsubscribe it.
|
||||
if (
|
||||
!(await unsubscribeCodexThreadBestEffort(client, {
|
||||
threadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
}))
|
||||
) {
|
||||
await closeCodexStartupClientBestEffort(client);
|
||||
}
|
||||
}
|
||||
|
||||
/** Releases only the physical client and native thread recorded by the displaced binding owner. */
|
||||
export async function releaseCodexAppServerBindingSubscription(
|
||||
binding: Pick<CodexAppServerThreadBinding, "threadId" | "clientId">,
|
||||
options: { allowUntracked?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const clientLease = retainSharedCodexAppServerClientByInstanceId(binding.clientId);
|
||||
if (!clientLease) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (await releaseCodexAppServerLiveThread(clientLease.client, binding.threadId)) {
|
||||
return;
|
||||
}
|
||||
// Evicted idle owners also disappear from the registry. Only an explicit
|
||||
// claimed generation proves an active turn still owns its subscription.
|
||||
if (isCodexAppServerLiveThreadClaimed(clientLease.client, binding.threadId)) {
|
||||
throw new Error(
|
||||
`Codex thread ${binding.threadId} has an active run; stop it before changing its owner.`,
|
||||
);
|
||||
}
|
||||
if (!options.allowUntracked) {
|
||||
return;
|
||||
}
|
||||
const unsubscribed = await unsubscribeCodexThreadBestEffort(clientLease.client, {
|
||||
threadId: binding.threadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
});
|
||||
if (!unsubscribed) {
|
||||
await closeCodexStartupClientBestEffort(clientLease.client);
|
||||
throw new CodexAppServerUnsafeSubscriptionError(
|
||||
`Codex retired thread subscription could not be released: ${binding.threadId}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
clientLease.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears and releases one exact conversation generation without touching its replacement. */
|
||||
export async function retireCodexConversationThreadBinding(params: {
|
||||
bindingStore: CodexAppServerBindingStore;
|
||||
identity: Extract<CodexAppServerBindingIdentity, { kind: "conversation" }>;
|
||||
expectedThreadId?: string;
|
||||
expectedStartId?: string;
|
||||
allowUntracked?: boolean;
|
||||
afterClear?: () => Promise<void>;
|
||||
}): Promise<boolean> {
|
||||
const expected = await params.bindingStore.read(params.identity);
|
||||
if (!expected || (params.expectedThreadId && expected.threadId !== params.expectedThreadId)) {
|
||||
return false;
|
||||
}
|
||||
return await params.bindingStore.withLease(params.identity, async () => {
|
||||
const current = await params.bindingStore.read(params.identity);
|
||||
if (
|
||||
current?.threadId !== expected.threadId ||
|
||||
(params.expectedStartId && current.conversationStartId !== params.expectedStartId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Keep the old row authoritative through unsubscribe; Codex has one
|
||||
// subscription per physical client, so clearing first races a new owner.
|
||||
await releaseCodexAppServerBindingSubscription(current, {
|
||||
allowUntracked: params.allowUntracked,
|
||||
});
|
||||
const cleared = await params.bindingStore.mutate(params.identity, {
|
||||
kind: "clear",
|
||||
threadId: current.threadId,
|
||||
});
|
||||
if (!cleared || !params.afterClear) {
|
||||
return cleared;
|
||||
}
|
||||
try {
|
||||
await params.afterClear();
|
||||
return true;
|
||||
} catch (error) {
|
||||
try {
|
||||
// Public binding storage commits separately. Restore its exact native
|
||||
// owner on failure without ever overwriting a replacement generation.
|
||||
const restored = await params.bindingStore.mutate(params.identity, {
|
||||
kind: "set",
|
||||
binding: current,
|
||||
if: { kind: "absent" },
|
||||
});
|
||||
if (!restored) {
|
||||
throw new Error("the previous Codex binding generation could not be restored", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
} catch (restorationError) {
|
||||
const recoveryError = new AggregateError(
|
||||
[error, restorationError],
|
||||
`Codex conversation detachment failed and native thread ${current.threadId} could not be restored; run /codex resume ${current.threadId} to recover it`,
|
||||
{ cause: restorationError },
|
||||
);
|
||||
throw recoveryError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { AssistantMessage, Usage } from "openclaw/plugin-sdk/llm";
|
||||
import type { SessionTranscriptMessageEntry } from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { CodexThread, JsonValue } from "./protocol.js";
|
||||
import { attachCodexMirrorIdentity } from "./upstream-prompt-provenance.js";
|
||||
@@ -267,3 +268,54 @@ export function projectBoundedCodexThreadHistory(params: {
|
||||
transcriptMessages: selected.map(({ message }) => message),
|
||||
};
|
||||
}
|
||||
|
||||
/** Projects only visible local user/assistant messages through the same bounded history policy. */
|
||||
export function projectBoundedCodexVisibleSessionHistory(
|
||||
entries: readonly SessionTranscriptMessageEntry[],
|
||||
): JsonValue[] {
|
||||
const projected: ProjectedCodexHistoryMessage[] = [];
|
||||
for (const entry of entries) {
|
||||
if ((entry.role !== "user" && entry.role !== "assistant") || !("content" in entry.message)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
entry.role === "assistant" &&
|
||||
"stopReason" in entry.message &&
|
||||
(entry.message.stopReason === "aborted" || entry.message.stopReason === "error")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const content = entry.message.content;
|
||||
const text = normalizeImportedHistoryText(
|
||||
typeof content === "string"
|
||||
? content
|
||||
: Array.isArray(content)
|
||||
? content
|
||||
.flatMap((part) =>
|
||||
part && typeof part === "object" && "text" in part && typeof part.text === "string"
|
||||
? [part.text]
|
||||
: [],
|
||||
)
|
||||
.join("\n")
|
||||
: undefined,
|
||||
);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
projected.push({
|
||||
message: entry.message,
|
||||
responseItem: {
|
||||
type: "message",
|
||||
role: entry.role,
|
||||
content: [
|
||||
{
|
||||
type: entry.role === "assistant" ? "output_text" : "input_text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
},
|
||||
textBytes: Buffer.byteLength(text, "utf8"),
|
||||
});
|
||||
}
|
||||
return selectBoundedCodexHistoryTail(projected).map(({ responseItem }) => responseItem);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { resolveCodexAppServerAuthProfileIdForAgent } from "./app-server/auth-bridge.js";
|
||||
import { consumeCodexAppServerLiveThread } from "./app-server/client-runtime.js";
|
||||
import type { CodexAppServerClient } from "./app-server/client.js";
|
||||
import { isCodexFastServiceTier } from "./app-server/config.js";
|
||||
import { assertCodexThreadResumeResponse } from "./app-server/protocol-validators.js";
|
||||
import {
|
||||
@@ -15,6 +17,15 @@ import {
|
||||
reclaimCurrentCodexSessionGeneration,
|
||||
sessionBindingIdentity,
|
||||
} from "./app-server/session-binding.js";
|
||||
import {
|
||||
isSameCodexAppServerThreadOwner,
|
||||
releaseCodexAppServerBindingSubscription,
|
||||
retainCodexAppServerBindingSubscription,
|
||||
retireCodexConversationThreadBinding,
|
||||
rollbackCodexAppServerBindingSubscription,
|
||||
withCodexConversationThreadActivity,
|
||||
withExclusiveCodexAppServerThread,
|
||||
} from "./app-server/thread-ownership.js";
|
||||
import { formatCodexDisplayText, formatThreads } from "./command-formatters.js";
|
||||
import {
|
||||
parseBindArgs,
|
||||
@@ -34,6 +45,7 @@ import {
|
||||
readCodexConversationBindingData,
|
||||
} from "./conversation-binding-data.js";
|
||||
import { formatPermissionsMode } from "./conversation-control.js";
|
||||
import { isIncognitoSessionKey } from "./incognito-session.js";
|
||||
import { formatCodexCliSessions } from "./node-cli-sessions.js";
|
||||
|
||||
export function isCurrentSessionModelSelectionLocked(ctx: PluginCommandContext): boolean {
|
||||
@@ -151,17 +163,53 @@ export async function detachConversation(
|
||||
}
|
||||
const current = await ctx.getCurrentConversationBinding();
|
||||
const data = readCodexConversationBindingData(current);
|
||||
const identity =
|
||||
data?.kind === "codex-app-server-session"
|
||||
? conversationBindingIdentity(data.bindingId)
|
||||
: undefined;
|
||||
const sourceSessionKey =
|
||||
data?.kind === "codex-app-server-session" ? data.source?.sessionKey : undefined;
|
||||
let expectedThreadId: string | undefined;
|
||||
let expectedStartId: string | undefined;
|
||||
if (data?.kind === "codex-app-server-session") {
|
||||
const binding = await deps.bindingStore.read(conversationBindingIdentity(data.bindingId));
|
||||
const binding = await deps.bindingStore.read(identity!);
|
||||
assertCodexBindingMayBeReplaced(binding, "detaching its conversation binding");
|
||||
if (deps.readCodexConversationActiveTurn(identity!)) {
|
||||
return "This Codex conversation has an active run; use /codex stop before detaching it.";
|
||||
}
|
||||
expectedThreadId = binding?.threadId;
|
||||
expectedStartId = binding?.conversationStartId;
|
||||
}
|
||||
const detached = await ctx.detachConversationBinding();
|
||||
if (data?.kind === "codex-app-server-session") {
|
||||
await deps.bindingStore.mutate(conversationBindingIdentity(data.bindingId), { kind: "clear" });
|
||||
const detachPublicConversation = async () => {
|
||||
const detached = await ctx.detachConversationBinding();
|
||||
return detached.removed
|
||||
? "Detached this conversation from Codex."
|
||||
: "No Codex conversation binding was attached.";
|
||||
};
|
||||
if (identity && expectedThreadId) {
|
||||
return await withCodexConversationThreadActivity(identity.bindingId, async () => {
|
||||
let detachedPublicConversation: string | undefined;
|
||||
const retired = await retireCodexConversationThreadBinding({
|
||||
bindingStore: deps.bindingStore,
|
||||
identity,
|
||||
expectedThreadId,
|
||||
...(expectedStartId ? { expectedStartId } : {}),
|
||||
// The source session owns ephemeral tracking; destination channel
|
||||
// session keys do not describe how this subscription was created.
|
||||
...(isIncognitoSessionKey(sourceSessionKey) ? { allowUntracked: true } : {}),
|
||||
afterClear: async () => {
|
||||
// The owner restores the exact native row if public detach fails;
|
||||
// an attached conversation then resumes its original thread.
|
||||
detachedPublicConversation = await detachPublicConversation();
|
||||
},
|
||||
});
|
||||
if (!retired) {
|
||||
return "This Codex conversation binding changed while detaching; try again.";
|
||||
}
|
||||
return detachedPublicConversation!;
|
||||
});
|
||||
}
|
||||
return detached.removed
|
||||
? "Detached this conversation from Codex."
|
||||
: "No Codex conversation binding was attached.";
|
||||
return await detachPublicConversation();
|
||||
}
|
||||
|
||||
export async function describeConversationBinding(
|
||||
@@ -266,74 +314,125 @@ export async function resumeThread(
|
||||
agentId: scope.agentId,
|
||||
config: ctx.config,
|
||||
});
|
||||
return await deps.bindingStore.withLease(identity, async () => {
|
||||
const reclaimed = await reclaimCurrentCodexSessionGeneration({
|
||||
bindingStore: deps.bindingStore,
|
||||
identity,
|
||||
config: ctx.config,
|
||||
});
|
||||
if (!reclaimed) {
|
||||
throw createCodexSessionGenerationSupersededError(identity.sessionId);
|
||||
}
|
||||
const currentBinding = await deps.bindingStore.read(identity);
|
||||
assertCodexBindingMayBeReplaced(currentBinding, "attaching a different resumed thread");
|
||||
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
|
||||
authProfileId: currentBinding?.authProfileId,
|
||||
agentDir: scope.agentDir,
|
||||
config: ctx.config,
|
||||
});
|
||||
const response = assertCodexThreadResumeResponse(
|
||||
await deps.codexControlRequest(
|
||||
pluginConfig,
|
||||
CODEX_CONTROL_METHODS.resumeThread,
|
||||
{
|
||||
threadId: normalizedThreadId,
|
||||
excludeTurns: true,
|
||||
},
|
||||
{
|
||||
return await withExclusiveCodexAppServerThread({
|
||||
bindingStore: deps.bindingStore,
|
||||
identity,
|
||||
threadId: normalizedThreadId,
|
||||
run: async () =>
|
||||
await deps.bindingStore.withLease(identity, async () => {
|
||||
const reclaimed = await reclaimCurrentCodexSessionGeneration({
|
||||
bindingStore: deps.bindingStore,
|
||||
identity,
|
||||
config: ctx.config,
|
||||
});
|
||||
if (!reclaimed) {
|
||||
throw createCodexSessionGenerationSupersededError(identity.sessionId);
|
||||
}
|
||||
const currentBinding = await deps.bindingStore.read(identity);
|
||||
assertCodexBindingMayBeReplaced(currentBinding, "attaching a different resumed thread");
|
||||
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
|
||||
authProfileId: currentBinding?.authProfileId,
|
||||
agentDir: scope.agentDir,
|
||||
authProfileId,
|
||||
sessionKey: ctx.sessionKey,
|
||||
sessionId: ctx.sessionId,
|
||||
},
|
||||
),
|
||||
);
|
||||
const effectiveThreadId = response.thread.id;
|
||||
if (effectiveThreadId !== normalizedThreadId) {
|
||||
throw new Error(
|
||||
`Codex thread/resume returned ${effectiveThreadId} for ${normalizedThreadId}`,
|
||||
);
|
||||
}
|
||||
const resumedCwd = response.thread.cwd;
|
||||
if (typeof resumedCwd !== "string") {
|
||||
throw new Error(`Codex thread/resume returned no cwd for ${normalizedThreadId}`);
|
||||
}
|
||||
const modelProvider = normalizeCodexAppServerBindingModelProvider({
|
||||
authProfileId,
|
||||
modelProvider: response.modelProvider ?? undefined,
|
||||
agentDir: scope.agentDir,
|
||||
config: ctx.config,
|
||||
});
|
||||
const bindingBeforeCommit = await deps.bindingStore.read(identity);
|
||||
assertCodexBindingMayBeReplaced(bindingBeforeCommit, "committing a different resumed thread");
|
||||
const committed = await deps.bindingStore.mutate(identity, {
|
||||
kind: "set",
|
||||
binding: {
|
||||
threadId: effectiveThreadId,
|
||||
cwd: resumedCwd,
|
||||
authProfileId,
|
||||
model: response.model,
|
||||
modelProvider,
|
||||
historyCoveredThrough: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
if (!committed) {
|
||||
throw new Error("Codex thread binding changed while attaching the resumed thread.");
|
||||
}
|
||||
return `Attached this OpenClaw session to Codex thread ${formatCodexDisplayText(
|
||||
effectiveThreadId,
|
||||
)}.`;
|
||||
config: ctx.config,
|
||||
});
|
||||
let committedResponse = false;
|
||||
const commitResumedThread = async (value: unknown, client?: CodexAppServerClient) => {
|
||||
const response = assertCodexThreadResumeResponse(value);
|
||||
const effectiveThreadId = response.thread.id;
|
||||
if (effectiveThreadId !== normalizedThreadId) {
|
||||
throw new Error(
|
||||
`Codex thread/resume returned ${effectiveThreadId} for ${normalizedThreadId}`,
|
||||
);
|
||||
}
|
||||
const resumedCwd = response.thread.cwd;
|
||||
if (typeof resumedCwd !== "string") {
|
||||
throw new Error(`Codex thread/resume returned no cwd for ${normalizedThreadId}`);
|
||||
}
|
||||
const modelProvider = normalizeCodexAppServerBindingModelProvider({
|
||||
authProfileId,
|
||||
modelProvider: response.modelProvider ?? undefined,
|
||||
agentDir: scope.agentDir,
|
||||
config: ctx.config,
|
||||
});
|
||||
const bindingBeforeCommit = await deps.bindingStore.read(identity);
|
||||
assertCodexBindingMayBeReplaced(
|
||||
bindingBeforeCommit,
|
||||
"committing a different resumed thread",
|
||||
);
|
||||
const clientId = client?.getInstanceId();
|
||||
const sameOwner = client
|
||||
? isSameCodexAppServerThreadOwner(bindingBeforeCommit, {
|
||||
threadId: effectiveThreadId,
|
||||
clientId,
|
||||
})
|
||||
: bindingBeforeCommit?.threadId === effectiveThreadId;
|
||||
let retained = false;
|
||||
try {
|
||||
if (client) {
|
||||
const knownOwnership = sameOwner
|
||||
? await consumeCodexAppServerLiveThread(client, effectiveThreadId)
|
||||
: undefined;
|
||||
retained = await retainCodexAppServerBindingSubscription(
|
||||
client,
|
||||
effectiveThreadId,
|
||||
knownOwnership,
|
||||
);
|
||||
if (!retained) {
|
||||
throw new Error("Codex resumed thread lost its native subscription owner.");
|
||||
}
|
||||
}
|
||||
if (bindingBeforeCommit && !sameOwner) {
|
||||
// The old row must remain authoritative until its subscription
|
||||
// is gone; otherwise another session can claim and lose it.
|
||||
await releaseCodexAppServerBindingSubscription(bindingBeforeCommit);
|
||||
}
|
||||
const committed = await deps.bindingStore.mutate(identity, {
|
||||
kind: "set",
|
||||
binding: {
|
||||
...(bindingBeforeCommit?.threadId === effectiveThreadId ? bindingBeforeCommit : {}),
|
||||
threadId: effectiveThreadId,
|
||||
...(clientId ? { clientId } : {}),
|
||||
cwd: resumedCwd,
|
||||
authProfileId,
|
||||
model: response.model,
|
||||
modelProvider,
|
||||
historyCoveredThrough: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
if (!committed) {
|
||||
throw new Error("Codex thread binding changed while attaching the resumed thread.");
|
||||
}
|
||||
} catch (error) {
|
||||
if (client && !sameOwner) {
|
||||
await rollbackCodexAppServerBindingSubscription(client, effectiveThreadId, retained);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
committedResponse = true;
|
||||
};
|
||||
const response = await deps.codexControlRequest(
|
||||
pluginConfig,
|
||||
CODEX_CONTROL_METHODS.resumeThread,
|
||||
{
|
||||
threadId: normalizedThreadId,
|
||||
excludeTurns: true,
|
||||
},
|
||||
{
|
||||
config: ctx.config,
|
||||
agentDir: scope.agentDir,
|
||||
authProfileId,
|
||||
sessionKey: ctx.sessionKey,
|
||||
sessionId: ctx.sessionId,
|
||||
onResponse: commitResumedThread,
|
||||
},
|
||||
);
|
||||
if (!committedResponse) {
|
||||
await commitResumedThread(response);
|
||||
}
|
||||
return `Attached this OpenClaw session to Codex thread ${formatCodexDisplayText(
|
||||
normalizedThreadId,
|
||||
)}.`;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,9 @@ export async function resolveCommandAppServerScope(
|
||||
};
|
||||
}
|
||||
|
||||
export function conversationBindingIdentity(bindingId: string): CodexAppServerBindingIdentity {
|
||||
export function conversationBindingIdentity(
|
||||
bindingId: string,
|
||||
): Extract<CodexAppServerBindingIdentity, { kind: "conversation" }> {
|
||||
return { kind: "conversation", bindingId };
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
describeControlFailure,
|
||||
type CodexControlMethod,
|
||||
} from "./app-server/capabilities.js";
|
||||
import type { CodexAppServerClient } from "./app-server/client.js";
|
||||
import {
|
||||
resolveCodexAppServerRuntimeOptions,
|
||||
resolveCodexSupervisionAppServerRuntimeOptions,
|
||||
@@ -17,7 +18,7 @@ import type {
|
||||
CodexAppServerRequestResult,
|
||||
JsonValue,
|
||||
} from "./app-server/protocol.js";
|
||||
import { requestCodexAppServerJson } from "./app-server/request.js";
|
||||
import { requestCodexAppServerJson, withCodexAppServerJsonClient } from "./app-server/request.js";
|
||||
|
||||
export type SafeValue<T> = { ok: true; value: T } | { ok: false; error: string };
|
||||
|
||||
@@ -34,6 +35,7 @@ export type CodexControlRequestOptions = {
|
||||
isolated?: boolean;
|
||||
startOptions?: CodexAppServerStartOptions;
|
||||
timeoutMs?: number;
|
||||
onResponse?: (response: unknown, client: CodexAppServerClient) => Promise<void>;
|
||||
};
|
||||
|
||||
export function requestOptions(
|
||||
@@ -76,9 +78,7 @@ export async function codexControlRequest(
|
||||
const runtime = options.startOptions
|
||||
? resolveCodexSupervisionAppServerRuntimeOptions({ pluginConfig })
|
||||
: resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
return await requestCodexAppServerJson({
|
||||
method,
|
||||
requestParams,
|
||||
const controlRequestOptions = {
|
||||
timeoutMs: options.timeoutMs ?? runtime.requestTimeoutMs,
|
||||
startOptions: options.startOptions ?? runtime.start,
|
||||
config: options.config,
|
||||
@@ -87,7 +87,17 @@ export async function codexControlRequest(
|
||||
authProfileId: options.authProfileId,
|
||||
agentDir: options.agentDir,
|
||||
isolated: options.isolated,
|
||||
});
|
||||
};
|
||||
if (options.onResponse) {
|
||||
return await withCodexAppServerJsonClient(controlRequestOptions, async (request, client) => {
|
||||
const response = await request({ method, requestParams });
|
||||
// Subscription-producing control requests must publish their exact
|
||||
// physical-client ownership before this shared lease can be released.
|
||||
await options.onResponse!(response, client);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
return await requestCodexAppServerJson({ method, requestParams, ...controlRequestOptions });
|
||||
}
|
||||
|
||||
export function safeCodexControlRequest<M extends CodexControlRequestMethod>(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolveDefaultAgentDir,
|
||||
type AuthProfileStore,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-binding-runtime";
|
||||
import { MODEL_SELECTION_LOCKED_MESSAGE } from "openclaw/plugin-sdk/model-session-runtime";
|
||||
import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
@@ -14,8 +15,16 @@ import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
|
||||
import {
|
||||
consumeCodexAppServerLiveThread,
|
||||
ensureCodexAppServerClientRuntime,
|
||||
isCodexAppServerLiveThreadClaimed,
|
||||
retainCodexAppServerLiveThread,
|
||||
} from "./app-server/client-runtime.js";
|
||||
import type { CodexAppServerClient } from "./app-server/client.js";
|
||||
import type { CodexComputerUseStatus } from "./app-server/computer-use.js";
|
||||
import type { CodexAppServerStartOptions } from "./app-server/config.js";
|
||||
import { codexNativeSubagentMonitorRuntime } from "./app-server/native-subagent-monitor.js";
|
||||
import type { JsonValue } from "./app-server/protocol.js";
|
||||
import type { CodexAppServerThreadBinding } from "./app-server/session-binding.js";
|
||||
import {
|
||||
@@ -24,6 +33,7 @@ import {
|
||||
testCodexAppServerBindingStore,
|
||||
} from "./app-server/session-binding.test-helpers.js";
|
||||
import { resetSharedCodexAppServerClientForTests } from "./app-server/shared-client.js";
|
||||
import { createClientHarness } from "./app-server/test-support.js";
|
||||
import { CODEX_APP_SERVER_VERSION } from "./app-server/version.js";
|
||||
import { codexDiagnosticsFeedbackState } from "./command-diagnostics-state.js";
|
||||
import { handleCodexCommand as dispatchCodexCommand } from "./command-dispatch.js";
|
||||
@@ -32,6 +42,7 @@ import type {
|
||||
CodexPluginsConfigBlock,
|
||||
CodexPluginsManagementIO,
|
||||
} from "./command-plugins-management.js";
|
||||
import { codexConversationBindingRuntime } from "./conversation-binding.js";
|
||||
|
||||
type CodexPluginConfigEntry = NonNullable<CodexPluginsConfigBlock["plugins"]>[string];
|
||||
|
||||
@@ -503,6 +514,376 @@ describe("codex command", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes manual resume ownership on its responding physical client before returning", async () => {
|
||||
const harness = createClientHarness();
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: tempDir });
|
||||
const response = createThreadResumeResponse({ threadId: "thread-owned-resume" });
|
||||
const codexControlRequest = vi.fn(
|
||||
async (
|
||||
_pluginConfig: unknown,
|
||||
_method: string,
|
||||
_params: unknown,
|
||||
options?: {
|
||||
onResponse?: (value: unknown, client: CodexAppServerClient) => Promise<void>;
|
||||
},
|
||||
) => {
|
||||
await options?.onResponse?.(response, harness.client);
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await runCommand("resume thread-owned-resume", { codexControlRequest });
|
||||
|
||||
expect(result.text).toContain("Attached this OpenClaw session");
|
||||
await expect(
|
||||
testCodexAppServerBindingStore.read({
|
||||
kind: "session",
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
threadId: "thread-owned-resume",
|
||||
clientId: harness.client.getInstanceId(),
|
||||
});
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(harness.client, "thread-owned-resume"),
|
||||
).resolves.toEqual(expect.objectContaining({ release: expect.any(Function) }));
|
||||
} finally {
|
||||
harness.client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("unsubscribes a manually resumed thread when its idle owner cannot be published", async () => {
|
||||
const harness = createClientHarness();
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: tempDir });
|
||||
const request = vi.spyOn(harness.client, "request").mockResolvedValue({} as never);
|
||||
const oldestRelease = vi.fn(async () => {
|
||||
throw new Error("oldest thread could not be unsubscribed");
|
||||
});
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-oldest", oldestRelease);
|
||||
for (let index = 1; index < 63; index += 1) {
|
||||
await retainCodexAppServerLiveThread(harness.client, `thread-idle-${index}`);
|
||||
}
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-existing");
|
||||
const identity = {
|
||||
kind: "session" as const,
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
};
|
||||
await writeTestBinding(identity, {
|
||||
threadId: "thread-existing",
|
||||
clientId: harness.client.getInstanceId(),
|
||||
cwd: "/repo",
|
||||
});
|
||||
const response = createThreadResumeResponse({ threadId: "thread-overflow" });
|
||||
const codexControlRequest = vi.fn(
|
||||
async (
|
||||
_pluginConfig: unknown,
|
||||
_method: string,
|
||||
_params: unknown,
|
||||
options?: {
|
||||
onResponse?: (value: unknown, client: CodexAppServerClient) => Promise<void>;
|
||||
},
|
||||
) => {
|
||||
await options?.onResponse?.(response, harness.client);
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await runCommand("resume thread-overflow", { codexControlRequest });
|
||||
|
||||
expect(result.text).toContain("lost its native subscription owner");
|
||||
expect(oldestRelease).toHaveBeenCalledOnce();
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-overflow" },
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number) }),
|
||||
);
|
||||
await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({
|
||||
threadId: "thread-existing",
|
||||
});
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(harness.client, "thread-existing"),
|
||||
).resolves.toEqual(expect.objectContaining({ release: expect.any(Function) }));
|
||||
} finally {
|
||||
harness.client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"migrates manual resume ownership across physical clients (old release fails: %s)",
|
||||
async (rejectOldRelease) => {
|
||||
const previous = createClientHarness();
|
||||
const replacement = createClientHarness();
|
||||
ensureCodexAppServerClientRuntime(previous.client, { agentDir: tempDir });
|
||||
ensureCodexAppServerClientRuntime(replacement.client, { agentDir: tempDir });
|
||||
const identity = {
|
||||
kind: "session" as const,
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
};
|
||||
await writeTestBinding(identity, {
|
||||
threadId: "thread-manual-migration",
|
||||
clientId: previous.client.getInstanceId(),
|
||||
cwd: "/repo",
|
||||
});
|
||||
const operations: string[] = [];
|
||||
const ownerDuringRelease: Array<string | undefined> = [];
|
||||
vi.spyOn(previous.client, "request").mockImplementation(async (method) => {
|
||||
operations.push(`previous:${method}`);
|
||||
ownerDuringRelease.push((await testCodexAppServerBindingStore.read(identity))?.clientId);
|
||||
if (rejectOldRelease) {
|
||||
throw new Error("previous manual owner unsubscribe failed");
|
||||
}
|
||||
return {} as never;
|
||||
});
|
||||
vi.spyOn(replacement.client, "request").mockImplementation(async (method) => {
|
||||
operations.push(`replacement:${method}`);
|
||||
return {} as never;
|
||||
});
|
||||
await expect(
|
||||
retainCodexAppServerLiveThread(previous.client, "thread-manual-migration"),
|
||||
).resolves.toBe(true);
|
||||
const sharedClientRuntime = await import("./app-server/shared-client.js");
|
||||
const retainPreviousClient = vi
|
||||
.spyOn(sharedClientRuntime, "retainSharedCodexAppServerClientByInstanceId")
|
||||
.mockImplementation((clientId) =>
|
||||
clientId === previous.client.getInstanceId()
|
||||
? { client: previous.client, release: vi.fn() }
|
||||
: undefined,
|
||||
);
|
||||
const response = createThreadResumeResponse({ threadId: "thread-manual-migration" });
|
||||
const codexControlRequest = vi.fn(
|
||||
async (
|
||||
_pluginConfig: unknown,
|
||||
_method: string,
|
||||
_params: unknown,
|
||||
options?: {
|
||||
onResponse?: (value: unknown, client: CodexAppServerClient) => Promise<void>;
|
||||
},
|
||||
) => {
|
||||
await options?.onResponse?.(response, replacement.client);
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await runCommand("resume thread-manual-migration", { codexControlRequest });
|
||||
|
||||
expect(result.text).toContain(
|
||||
rejectOldRelease
|
||||
? "previous manual owner unsubscribe failed"
|
||||
: "Attached this OpenClaw session",
|
||||
);
|
||||
expect(operations).toEqual(
|
||||
rejectOldRelease
|
||||
? ["previous:thread/unsubscribe", "replacement:thread/unsubscribe"]
|
||||
: ["previous:thread/unsubscribe"],
|
||||
);
|
||||
expect(ownerDuringRelease).toEqual([previous.client.getInstanceId()]);
|
||||
await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({
|
||||
threadId: "thread-manual-migration",
|
||||
clientId: rejectOldRelease
|
||||
? previous.client.getInstanceId()
|
||||
: replacement.client.getInstanceId(),
|
||||
});
|
||||
const survivingClient = rejectOldRelease ? previous.client : replacement.client;
|
||||
const obsoleteClient = rejectOldRelease ? replacement.client : previous.client;
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(survivingClient, "thread-manual-migration"),
|
||||
).resolves.toEqual(expect.objectContaining({ release: expect.any(Function) }));
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(obsoleteClient, "thread-manual-migration"),
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
retainPreviousClient.mockRestore();
|
||||
previous.client.close();
|
||||
replacement.client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves known native config ownership when manually resuming the same thread", async () => {
|
||||
const harness = createClientHarness();
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: tempDir });
|
||||
const identity = {
|
||||
kind: "session" as const,
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
};
|
||||
await writeTestBinding(identity, {
|
||||
threadId: "thread-known-resume",
|
||||
clientId: harness.client.getInstanceId(),
|
||||
cwd: "/repo",
|
||||
dynamicToolsFingerprint: "known-dynamic-tools",
|
||||
pluginAppsFingerprint: "known-plugin-apps",
|
||||
});
|
||||
const release = vi.fn(async () => undefined);
|
||||
await retainCodexAppServerLiveThread(
|
||||
harness.client,
|
||||
"thread-known-resume",
|
||||
release,
|
||||
"known-native-config",
|
||||
"priority",
|
||||
);
|
||||
const response = createThreadResumeResponse({ threadId: "thread-known-resume" });
|
||||
const codexControlRequest = vi.fn(
|
||||
async (
|
||||
_pluginConfig: unknown,
|
||||
_method: string,
|
||||
_params: unknown,
|
||||
options?: {
|
||||
onResponse?: (value: unknown, client: CodexAppServerClient) => Promise<void>;
|
||||
},
|
||||
) => {
|
||||
await options?.onResponse?.(response, harness.client);
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runCommand("resume thread-known-resume", { codexControlRequest }),
|
||||
).resolves.toMatchObject({
|
||||
text: "Attached this OpenClaw session to Codex thread thread-known-resume.",
|
||||
});
|
||||
await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({
|
||||
dynamicToolsFingerprint: "known-dynamic-tools",
|
||||
pluginAppsFingerprint: "known-plugin-apps",
|
||||
});
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(
|
||||
harness.client,
|
||||
"thread-known-resume",
|
||||
"known-native-config",
|
||||
),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
configFingerprint: "known-native-config",
|
||||
serviceTier: "priority",
|
||||
}),
|
||||
);
|
||||
expect(release).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
harness.client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "the same", existingThreadId: "thread-active-resume" },
|
||||
{ label: "a different", existingThreadId: "thread-existing-resume" },
|
||||
])(
|
||||
"refuses an active native child while the source session owns $label thread",
|
||||
async ({ existingThreadId }) => {
|
||||
const harness = createClientHarness();
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: tempDir });
|
||||
const response = createThreadResumeResponse({ threadId: "thread-active-resume" });
|
||||
const request = vi.spyOn(harness.client, "request").mockImplementation(async (method) => {
|
||||
if (method === "thread/resume") {
|
||||
return response as never;
|
||||
}
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {} as never;
|
||||
}
|
||||
throw new Error(`unexpected Codex method ${method}`);
|
||||
});
|
||||
const parent = codexNativeSubagentMonitorRuntime.register({
|
||||
client: harness.client,
|
||||
parentThreadId: "thread-parent",
|
||||
});
|
||||
const identity = {
|
||||
kind: "session" as const,
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
};
|
||||
await writeTestBinding(identity, {
|
||||
threadId: existingThreadId,
|
||||
clientId: harness.client.getInstanceId(),
|
||||
cwd: "/repo",
|
||||
});
|
||||
if (existingThreadId !== "thread-active-resume") {
|
||||
await retainCodexAppServerLiveThread(harness.client, existingThreadId);
|
||||
}
|
||||
harness.send({
|
||||
method: "thread/started",
|
||||
params: {
|
||||
thread: {
|
||||
id: "thread-active-resume",
|
||||
parentThreadId: "thread-parent",
|
||||
source: {
|
||||
subAgent: {
|
||||
thread_spawn: {
|
||||
parent_thread_id: "thread-parent",
|
||||
depth: 1,
|
||||
agent_path: "thread-active-resume",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(isCodexAppServerLiveThreadClaimed(harness.client, "thread-active-resume")).toBe(
|
||||
true,
|
||||
),
|
||||
);
|
||||
const codexControlRequest = vi.fn(
|
||||
async (
|
||||
_pluginConfig: unknown,
|
||||
_method: string,
|
||||
_params: unknown,
|
||||
options?: {
|
||||
onResponse?: (value: unknown, client: CodexAppServerClient) => Promise<void>;
|
||||
},
|
||||
) => {
|
||||
const resumed = await harness.client.request("thread/resume", {
|
||||
threadId: "thread-active-resume",
|
||||
excludeTurns: true,
|
||||
});
|
||||
await options?.onResponse?.(resumed, harness.client);
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await runCommand("resume thread-active-resume", { codexControlRequest });
|
||||
|
||||
expect(result.text).toContain("lost its native subscription owner");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]);
|
||||
expect(isCodexAppServerLiveThreadClaimed(harness.client, "thread-active-resume")).toBe(
|
||||
true,
|
||||
);
|
||||
await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({
|
||||
threadId: existingThreadId,
|
||||
});
|
||||
await expect(
|
||||
retainCodexAppServerLiveThread(harness.client, "thread-active-resume"),
|
||||
).resolves.toBe(false);
|
||||
if (existingThreadId !== "thread-active-resume") {
|
||||
const previousOwnership = await consumeCodexAppServerLiveThread(
|
||||
harness.client,
|
||||
existingThreadId,
|
||||
);
|
||||
expect(previousOwnership).toEqual(
|
||||
expect.objectContaining({ release: expect.any(Function) }),
|
||||
);
|
||||
await expect(
|
||||
retainCodexAppServerLiveThread(
|
||||
harness.client,
|
||||
existingThreadId,
|
||||
previousOwnership?.release,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
}
|
||||
} finally {
|
||||
parent.unregister();
|
||||
harness.client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("serializes manual resume with other session binding owners", async () => {
|
||||
const identity = {
|
||||
kind: "session" as const,
|
||||
@@ -540,6 +921,26 @@ describe("codex command", () => {
|
||||
expect(order).toEqual(["resume-start", "resume-done", "competing-owner"]);
|
||||
});
|
||||
|
||||
it("rejects manual resume of a thread owned by another OpenClaw session", async () => {
|
||||
const otherIdentity = {
|
||||
kind: "session" as const,
|
||||
agentId: "main",
|
||||
sessionId: "session-other",
|
||||
};
|
||||
await writeTestBinding(otherIdentity, { threadId: "thread-owned", cwd: "/other" });
|
||||
const codexControlRequest = vi.fn(async () =>
|
||||
createThreadResumeResponse({ threadId: "thread-owned" }),
|
||||
);
|
||||
|
||||
const result = await runCommand("resume thread-owned", { codexControlRequest });
|
||||
|
||||
expect(result.text).toContain("owned by another OpenClaw session");
|
||||
expect(codexControlRequest).not.toHaveBeenCalled();
|
||||
await expect(testCodexAppServerBindingStore.read(otherIdentity)).resolves.toMatchObject({
|
||||
threadId: "thread-owned",
|
||||
});
|
||||
});
|
||||
|
||||
it("reclaims an unloaded plugin's stale generation before attaching a thread", async () => {
|
||||
const sessionKey = "agent:main:test:chat-1";
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
@@ -4661,12 +5062,207 @@ describe("codex command", () => {
|
||||
|
||||
it("detaches the current conversation and clears the Codex app-server thread binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const clearBinding = vi.fn(async () => true);
|
||||
const detachConversationBinding = vi.fn(async () => ({ removed: true }));
|
||||
const ownershipOrder: string[] = [];
|
||||
const identity = { kind: "conversation" as const, bindingId: "binding-data-1" };
|
||||
const harness = createClientHarness();
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: tempDir });
|
||||
const releaseNativeThread = vi
|
||||
.spyOn(harness.client, "request")
|
||||
.mockImplementation(async (method) => {
|
||||
if (method !== "thread/unsubscribe") {
|
||||
throw new Error(`unexpected Codex method ${method}`);
|
||||
}
|
||||
ownershipOrder.push("native-release");
|
||||
return {} as never;
|
||||
});
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-detached");
|
||||
const clearBinding = vi.fn(
|
||||
async (...args: Parameters<typeof testCodexAppServerBindingStore.mutate>) => {
|
||||
ownershipOrder.push("native-clear");
|
||||
return await testCodexAppServerBindingStore.mutate(...args);
|
||||
},
|
||||
);
|
||||
const detachConversationBinding = vi.fn(async () => {
|
||||
ownershipOrder.push("public");
|
||||
return { removed: true };
|
||||
});
|
||||
await writeTestBinding(identity, {
|
||||
threadId: "thread-detached",
|
||||
clientId: harness.client.getInstanceId(),
|
||||
cwd: "/repo",
|
||||
});
|
||||
const sharedClientRuntime = await import("./app-server/shared-client.js");
|
||||
const retainClient = vi
|
||||
.spyOn(sharedClientRuntime, "retainSharedCodexAppServerClientByInstanceId")
|
||||
.mockReturnValue({ client: harness.client, release: vi.fn() });
|
||||
|
||||
await expect(
|
||||
handleCodexCommand(
|
||||
createContext("detach", sessionFile, {
|
||||
try {
|
||||
await expect(
|
||||
handleCodexCommand(
|
||||
createContext("detach", sessionFile, {
|
||||
detachConversationBinding,
|
||||
getCurrentConversationBinding: async () => ({
|
||||
bindingId: "binding-1",
|
||||
pluginId: "codex",
|
||||
pluginRoot: "/plugin",
|
||||
channel: "test",
|
||||
accountId: "default",
|
||||
conversationId: "conversation",
|
||||
boundAt: 1,
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 2,
|
||||
bindingId: "binding-data-1",
|
||||
workspaceDir: "/repo",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
deps: createDeps({
|
||||
bindingStore: { ...testCodexAppServerBindingStore, mutate: clearBinding },
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({
|
||||
text: "Detached this conversation from Codex.",
|
||||
});
|
||||
expect(detachConversationBinding).toHaveBeenCalled();
|
||||
expect(clearBinding).toHaveBeenCalledWith(identity, {
|
||||
kind: "clear",
|
||||
threadId: "thread-detached",
|
||||
});
|
||||
expect(releaseNativeThread).toHaveBeenCalledWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-detached" },
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number) }),
|
||||
);
|
||||
expect(ownershipOrder).toEqual(["native-release", "native-clear", "public"]);
|
||||
} finally {
|
||||
retainClient.mockRestore();
|
||||
harness.client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "an incognito source bound into an ordinary destination",
|
||||
sourceSessionKey: "agent:main:dashboard:incognito-source",
|
||||
destinationSessionKey: "agent:main:discord:ordinary-destination",
|
||||
unsubscribes: true,
|
||||
},
|
||||
{
|
||||
label: "an ordinary source bound into an incognito destination",
|
||||
sourceSessionKey: "agent:main:discord:ordinary-source",
|
||||
destinationSessionKey: "agent:main:dashboard:incognito-destination",
|
||||
unsubscribes: false,
|
||||
},
|
||||
{
|
||||
label: "a missing source bound into an incognito destination",
|
||||
sourceSessionKey: undefined,
|
||||
destinationSessionKey: "agent:main:dashboard:incognito-destination",
|
||||
unsubscribes: false,
|
||||
},
|
||||
])(
|
||||
"retires untracked detach ownership from $label using its source session",
|
||||
async ({ sourceSessionKey, destinationSessionKey, unsubscribes }) => {
|
||||
const identity = { kind: "conversation" as const, bindingId: "binding-mixed-session" };
|
||||
await writeTestBinding(identity, {
|
||||
threadId: "thread-mixed-session",
|
||||
clientId: "client-mixed-session",
|
||||
cwd: "/repo",
|
||||
});
|
||||
const request = vi.fn(async () => ({}));
|
||||
const releaseClient = vi.fn();
|
||||
const sharedClientRuntime = await import("./app-server/shared-client.js");
|
||||
const retainClient = vi
|
||||
.spyOn(sharedClientRuntime, "retainSharedCodexAppServerClientByInstanceId")
|
||||
.mockReturnValue({
|
||||
client: { request } as unknown as CodexAppServerClient,
|
||||
release: releaseClient,
|
||||
});
|
||||
const detachConversationBinding = vi.fn(async () => ({ removed: true }));
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handleCodexCommand(
|
||||
createContext("detach", undefined, {
|
||||
sessionKey: destinationSessionKey,
|
||||
detachConversationBinding,
|
||||
getCurrentConversationBinding: async () => ({
|
||||
bindingId: "binding-public",
|
||||
pluginId: "codex",
|
||||
pluginRoot: "/plugin",
|
||||
channel: "test",
|
||||
accountId: "default",
|
||||
conversationId: "conversation",
|
||||
boundAt: 1,
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 2,
|
||||
bindingId: identity.bindingId,
|
||||
workspaceDir: "/repo",
|
||||
...(sourceSessionKey
|
||||
? {
|
||||
source: {
|
||||
agentId: "main",
|
||||
sessionId: "session-source",
|
||||
sessionKey: sourceSessionKey,
|
||||
threadId: "thread-source",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{ deps: createDeps() },
|
||||
),
|
||||
).resolves.toEqual({ text: "Detached this conversation from Codex." });
|
||||
|
||||
if (unsubscribes) {
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(
|
||||
"thread/unsubscribe",
|
||||
{ threadId: "thread-mixed-session" },
|
||||
expect.objectContaining({ timeoutMs: expect.any(Number) }),
|
||||
);
|
||||
} else {
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(releaseClient).toHaveBeenCalledOnce();
|
||||
expect(detachConversationBinding).toHaveBeenCalledOnce();
|
||||
await expect(testCodexAppServerBindingStore.read(identity)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
retainClient.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the public conversation binding when native retirement fails", async () => {
|
||||
const identity = { kind: "conversation" as const, bindingId: "binding-data-1" };
|
||||
const harness = createClientHarness();
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: tempDir });
|
||||
await writeTestBinding(identity, {
|
||||
threadId: "thread-detached",
|
||||
clientId: harness.client.getInstanceId(),
|
||||
cwd: "/repo",
|
||||
});
|
||||
const detachConversationBinding = vi.fn(async () => ({ removed: true }));
|
||||
const releaseNativeThread = vi
|
||||
.spyOn(harness.client, "request")
|
||||
.mockImplementation(async (method) => {
|
||||
if (method !== "thread/unsubscribe") {
|
||||
throw new Error(`unexpected Codex method ${method}`);
|
||||
}
|
||||
throw new Error("Codex native thread subscription could not be released");
|
||||
});
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-detached");
|
||||
const sharedClientRuntime = await import("./app-server/shared-client.js");
|
||||
const retainClient = vi
|
||||
.spyOn(sharedClientRuntime, "retainSharedCodexAppServerClientByInstanceId")
|
||||
.mockReturnValue({ client: harness.client, release: vi.fn() });
|
||||
|
||||
try {
|
||||
const result = await handleCodexCommand(
|
||||
createContext("detach", undefined, {
|
||||
detachConversationBinding,
|
||||
getCurrentConversationBinding: async () => ({
|
||||
bindingId: "binding-1",
|
||||
@@ -4679,25 +5275,330 @@ describe("codex command", () => {
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 2,
|
||||
bindingId: "binding-data-1",
|
||||
bindingId: identity.bindingId,
|
||||
workspaceDir: "/repo",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{ deps: createDeps() },
|
||||
);
|
||||
|
||||
expect(result.text).toContain("native thread subscription could not be released");
|
||||
expect(releaseNativeThread).toHaveBeenCalledOnce();
|
||||
expect(detachConversationBinding).not.toHaveBeenCalled();
|
||||
await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({
|
||||
threadId: "thread-detached",
|
||||
clientId: harness.client.getInstanceId(),
|
||||
cwd: "/repo",
|
||||
});
|
||||
await expect(
|
||||
consumeCodexAppServerLiveThread(harness.client, "thread-detached"),
|
||||
).resolves.toEqual(expect.objectContaining({ release: expect.any(Function) }));
|
||||
} finally {
|
||||
retainClient.mockRestore();
|
||||
harness.client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "returns false",
|
||||
throws: false,
|
||||
message: "changed while detaching",
|
||||
},
|
||||
{
|
||||
label: "throws",
|
||||
throws: true,
|
||||
message: "native durable binding clear failed",
|
||||
},
|
||||
])(
|
||||
"preserves the public conversation when durable native clear $label",
|
||||
async ({ throws, message }) => {
|
||||
const identity = { kind: "conversation" as const, bindingId: "binding-clear-failure" };
|
||||
const harness = createClientHarness();
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: tempDir });
|
||||
const releaseNativeThread = vi
|
||||
.spyOn(harness.client, "request")
|
||||
.mockResolvedValue({} as never);
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-clear-failure");
|
||||
const originalBinding = {
|
||||
threadId: "thread-clear-failure",
|
||||
clientId: harness.client.getInstanceId(),
|
||||
cwd: tempDir,
|
||||
conversationStartId: "start-clear-failure",
|
||||
} satisfies CodexAppServerThreadBinding;
|
||||
await writeTestBinding(identity, originalBinding);
|
||||
const mutate = vi.fn(
|
||||
async (...args: Parameters<typeof testCodexAppServerBindingStore.mutate>) => {
|
||||
if (args[1].kind === "clear") {
|
||||
if (throws) {
|
||||
throw new Error("native durable binding clear failed");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return await testCodexAppServerBindingStore.mutate(...args);
|
||||
},
|
||||
);
|
||||
const detachConversationBinding = vi.fn(async () => ({ removed: true }));
|
||||
const sharedClientRuntime = await import("./app-server/shared-client.js");
|
||||
const retainClient = vi
|
||||
.spyOn(sharedClientRuntime, "retainSharedCodexAppServerClientByInstanceId")
|
||||
.mockReturnValue({ client: harness.client, release: vi.fn() });
|
||||
|
||||
try {
|
||||
const result = await handleCodexCommand(
|
||||
createContext("detach", undefined, {
|
||||
detachConversationBinding,
|
||||
getCurrentConversationBinding: async () => ({
|
||||
bindingId: "binding-public-clear-failure",
|
||||
pluginId: "codex",
|
||||
pluginRoot: tempDir,
|
||||
channel: "test",
|
||||
accountId: "default",
|
||||
conversationId: "conversation",
|
||||
boundAt: 1,
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 2,
|
||||
bindingId: identity.bindingId,
|
||||
workspaceDir: tempDir,
|
||||
start: { id: originalBinding.conversationStartId },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
deps: createDeps({
|
||||
bindingStore: { ...testCodexAppServerBindingStore, mutate },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.text).toContain(message);
|
||||
expect(releaseNativeThread).toHaveBeenCalledOnce();
|
||||
expect(mutate).toHaveBeenCalledOnce();
|
||||
expect(detachConversationBinding).not.toHaveBeenCalled();
|
||||
await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject(
|
||||
originalBinding,
|
||||
);
|
||||
} finally {
|
||||
retainClient.mockRestore();
|
||||
harness.client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("resumes the original native thread after public conversation detachment fails", async () => {
|
||||
const identity = { kind: "conversation" as const, bindingId: "binding-detach-recovery" };
|
||||
const harness = createClientHarness();
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: tempDir });
|
||||
const operations: string[] = [];
|
||||
const request = vi
|
||||
.spyOn(harness.client, "request")
|
||||
.mockImplementation(async (method, params) => {
|
||||
operations.push(method);
|
||||
if (method === "thread/unsubscribe") {
|
||||
return {} as never;
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return createThreadResumeResponse({
|
||||
threadId: "thread-original-context",
|
||||
cwd: tempDir,
|
||||
}) as never;
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
queueMicrotask(() => {
|
||||
harness.send({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-original-context",
|
||||
turn: {
|
||||
id: "turn-original-context",
|
||||
status: "completed",
|
||||
items: [{ type: "agentMessage", id: "answer", text: "Original context kept" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
return { turn: { id: "turn-original-context" } } as never;
|
||||
}
|
||||
throw new Error(`unexpected Codex method ${method}: ${JSON.stringify(params)}`);
|
||||
});
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-original-context");
|
||||
const originalBinding = {
|
||||
threadId: "thread-original-context",
|
||||
clientId: harness.client.getInstanceId(),
|
||||
cwd: tempDir,
|
||||
conversationStartId: "start-original-context",
|
||||
historyCoveredThrough: "2026-01-01T00:00:00.000Z",
|
||||
} satisfies CodexAppServerThreadBinding;
|
||||
await writeTestBinding(identity, originalBinding);
|
||||
const publicBinding = {
|
||||
bindingId: "binding-public-original-context",
|
||||
pluginId: "codex",
|
||||
pluginRoot: tempDir,
|
||||
channel: "test",
|
||||
accountId: "default",
|
||||
conversationId: "conversation",
|
||||
boundAt: 1,
|
||||
data: {
|
||||
kind: "codex-app-server-session" as const,
|
||||
version: 2 as const,
|
||||
bindingId: identity.bindingId,
|
||||
workspaceDir: tempDir,
|
||||
start: { id: originalBinding.conversationStartId },
|
||||
},
|
||||
};
|
||||
const detachConversationBinding = vi.fn(async () => {
|
||||
throw new Error("public conversation binding store write failed");
|
||||
});
|
||||
const mutate = vi.fn(
|
||||
async (...args: Parameters<typeof testCodexAppServerBindingStore.mutate>) =>
|
||||
await testCodexAppServerBindingStore.mutate(...args),
|
||||
);
|
||||
const sharedClientRuntime = await import("./app-server/shared-client.js");
|
||||
const retainClient = vi
|
||||
.spyOn(sharedClientRuntime, "retainSharedCodexAppServerClientByInstanceId")
|
||||
.mockReturnValue({ client: harness.client, release: vi.fn() });
|
||||
const acquireClient = vi
|
||||
.spyOn(sharedClientRuntime, "getLeasedSharedCodexAppServerClient")
|
||||
.mockResolvedValue(harness.client);
|
||||
const resolvePublic = vi
|
||||
.spyOn(getSessionBindingService(), "resolveByConversation")
|
||||
.mockReturnValue({ bindingId: publicBinding.bindingId } as never);
|
||||
|
||||
try {
|
||||
const result = await handleCodexCommand(
|
||||
createContext("detach", undefined, {
|
||||
detachConversationBinding,
|
||||
getCurrentConversationBinding: async () => publicBinding,
|
||||
}),
|
||||
{
|
||||
deps: createDeps({
|
||||
bindingStore: { ...testCodexAppServerBindingStore, mutate },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.text).toContain("public conversation binding store write failed");
|
||||
expect(operations).toEqual(["thread/unsubscribe"]);
|
||||
expect(mutate.mock.calls.map(([, mutation]) => mutation.kind)).toEqual(["clear", "set"]);
|
||||
expect(mutate).toHaveBeenLastCalledWith(identity, {
|
||||
kind: "set",
|
||||
binding: originalBinding,
|
||||
if: { kind: "absent" },
|
||||
});
|
||||
await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject(
|
||||
originalBinding,
|
||||
);
|
||||
|
||||
await expect(
|
||||
codexConversationBindingRuntime.handleInboundClaim(
|
||||
{
|
||||
content: "continue original task",
|
||||
bodyForAgent: "continue original task",
|
||||
channel: "test",
|
||||
isGroup: false,
|
||||
commandAuthorized: true,
|
||||
senderIsOwner: true,
|
||||
},
|
||||
{ channelId: "test", pluginBinding: publicBinding },
|
||||
{ bindingStore: testCodexAppServerBindingStore, timeoutMs: 500 },
|
||||
),
|
||||
).resolves.toEqual({
|
||||
handled: true,
|
||||
reply: { text: "Original context kept" },
|
||||
});
|
||||
expect(operations).toEqual(["thread/unsubscribe", "thread/resume", "turn/start"]);
|
||||
expect(request.mock.calls.find(([method]) => method === "thread/resume")?.[1]).toMatchObject({
|
||||
threadId: originalBinding.threadId,
|
||||
});
|
||||
expect(request.mock.calls.find(([method]) => method === "turn/start")?.[1]).toMatchObject({
|
||||
threadId: originalBinding.threadId,
|
||||
cwd: originalBinding.cwd,
|
||||
});
|
||||
await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({
|
||||
threadId: originalBinding.threadId,
|
||||
conversationStartId: originalBinding.conversationStartId,
|
||||
historyCoveredThrough: originalBinding.historyCoveredThrough,
|
||||
});
|
||||
} finally {
|
||||
resolvePublic.mockRestore();
|
||||
acquireClient.mockRestore();
|
||||
retainClient.mockRestore();
|
||||
harness.client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "returns false", throws: false },
|
||||
{ label: "throws", throws: true },
|
||||
])("shows actionable thread recovery when public detach rollback $label", async ({ throws }) => {
|
||||
const identity = { kind: "conversation" as const, bindingId: "binding-rollback-failure" };
|
||||
const harness = createClientHarness();
|
||||
ensureCodexAppServerClientRuntime(harness.client, { agentDir: tempDir });
|
||||
const releaseNativeThread = vi.spyOn(harness.client, "request").mockResolvedValue({} as never);
|
||||
await retainCodexAppServerLiveThread(harness.client, "thread-rollback-failure");
|
||||
await writeTestBinding(identity, {
|
||||
threadId: "thread-rollback-failure",
|
||||
clientId: harness.client.getInstanceId(),
|
||||
cwd: tempDir,
|
||||
});
|
||||
const mutate = vi.fn(
|
||||
async (...args: Parameters<typeof testCodexAppServerBindingStore.mutate>) => {
|
||||
if (args[1].kind === "set") {
|
||||
if (throws) {
|
||||
throw new Error("native durable binding restore failed");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return await testCodexAppServerBindingStore.mutate(...args);
|
||||
},
|
||||
);
|
||||
const detachConversationBinding = vi.fn(async () => {
|
||||
throw new Error("public conversation binding store write failed");
|
||||
});
|
||||
const sharedClientRuntime = await import("./app-server/shared-client.js");
|
||||
const retainClient = vi
|
||||
.spyOn(sharedClientRuntime, "retainSharedCodexAppServerClientByInstanceId")
|
||||
.mockReturnValue({ client: harness.client, release: vi.fn() });
|
||||
|
||||
try {
|
||||
const result = await handleCodexCommand(
|
||||
createContext("detach", undefined, {
|
||||
detachConversationBinding,
|
||||
getCurrentConversationBinding: async () => ({
|
||||
bindingId: "binding-public-rollback-failure",
|
||||
pluginId: "codex",
|
||||
pluginRoot: tempDir,
|
||||
channel: "test",
|
||||
accountId: "default",
|
||||
conversationId: "conversation",
|
||||
boundAt: 1,
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 2,
|
||||
bindingId: identity.bindingId,
|
||||
workspaceDir: tempDir,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
deps: createDeps({
|
||||
bindingStore: { ...testCodexAppServerBindingStore, mutate: clearBinding },
|
||||
bindingStore: { ...testCodexAppServerBindingStore, mutate },
|
||||
}),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({
|
||||
text: "Detached this conversation from Codex.",
|
||||
});
|
||||
expect(detachConversationBinding).toHaveBeenCalled();
|
||||
expect(clearBinding).toHaveBeenCalledWith(
|
||||
{ kind: "conversation", bindingId: "binding-data-1" },
|
||||
{ kind: "clear" },
|
||||
);
|
||||
);
|
||||
|
||||
expect(result.text).toContain("native thread thread-rollback-failure could not be restored");
|
||||
expect(result.text).toContain("/codex resume thread-rollback-failure");
|
||||
expect(releaseNativeThread).toHaveBeenCalledOnce();
|
||||
expect(detachConversationBinding).toHaveBeenCalledOnce();
|
||||
expect(mutate.mock.calls.map(([, mutation]) => mutation.kind)).toEqual(["clear", "set"]);
|
||||
await expect(testCodexAppServerBindingStore.read(identity)).resolves.toBeUndefined();
|
||||
} finally {
|
||||
retainClient.mockRestore();
|
||||
harness.client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed detach commands before clearing bindings", async () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,12 @@
|
||||
// Codex plugin module implements conversation binding behavior.
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
formatErrorMessage,
|
||||
resolveActiveEmbeddedRunSessionId,
|
||||
resolveSandboxContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveSessionAgentIds } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-binding-runtime";
|
||||
import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime";
|
||||
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
||||
import type {
|
||||
@@ -12,7 +15,12 @@ import type {
|
||||
PluginHookInboundClaimEvent,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import {
|
||||
getSessionEntry,
|
||||
resolveStorePath,
|
||||
resolveTranscriptSessionKeyBySessionId,
|
||||
} from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { readVisibleSessionTranscriptMessageEntries } from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
import { resolveCodexAppServerForModelProvider } from "./app-server/app-server-policy.js";
|
||||
import {
|
||||
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
@@ -23,6 +31,13 @@ import {
|
||||
} from "./app-server/attempt-client-cleanup.js";
|
||||
import { resolveCodexAppServerAuthProfileIdForAgent } from "./app-server/auth-bridge.js";
|
||||
import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
|
||||
import {
|
||||
consumeCodexAppServerLiveThread,
|
||||
isCodexAppServerClientRuntimeLive,
|
||||
isCodexAppServerLiveThreadClaimed,
|
||||
releaseCodexAppServerLiveThread,
|
||||
type CodexAppServerLiveThreadOwnership,
|
||||
} from "./app-server/client-runtime.js";
|
||||
import {
|
||||
isCodexAppServerIndeterminateRequestCancellationError,
|
||||
type CodexAppServerClient,
|
||||
@@ -36,6 +51,10 @@ import {
|
||||
type CodexAppServerSandboxMode,
|
||||
type OpenClawExecPolicyForCodexAppServer,
|
||||
} from "./app-server/config.js";
|
||||
import {
|
||||
buildDisabledAppsConfigPatch,
|
||||
mergeCodexThreadConfigs,
|
||||
} from "./app-server/plugin-thread-config.js";
|
||||
import { assertCodexThreadStartResponse } from "./app-server/protocol-validators.js";
|
||||
import type {
|
||||
CodexServiceTier,
|
||||
@@ -59,6 +78,7 @@ import {
|
||||
} from "./app-server/session-binding.js";
|
||||
import {
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
retainSharedCodexAppServerClientByInstanceId,
|
||||
releaseCodexAppServerClientLease,
|
||||
withLeasedCodexAppServerClientStartSelectionRetry,
|
||||
type CodexAppServerClientLease,
|
||||
@@ -68,7 +88,17 @@ import {
|
||||
CODEX_NATIVE_PERSONALITY_NONE,
|
||||
resolveCodexAppServerRequestModelSelection,
|
||||
} from "./app-server/thread-lifecycle.js";
|
||||
import {
|
||||
isSameCodexAppServerThreadOwner,
|
||||
releaseCodexAppServerBindingSubscription,
|
||||
retainCodexAppServerBindingSubscription,
|
||||
retireCodexConversationThreadBinding,
|
||||
rollbackCodexAppServerBindingSubscription,
|
||||
withCodexConversationThreadActivity,
|
||||
withExclusiveCodexAppServerThread,
|
||||
} from "./app-server/thread-ownership.js";
|
||||
import { resumeCodexAppServerThread } from "./app-server/thread-resume.js";
|
||||
import { projectBoundedCodexVisibleSessionHistory } from "./app-server/transcript-history-projection.js";
|
||||
import {
|
||||
getCodexAppServerTurnRouter,
|
||||
type CodexThreadRouteReservation,
|
||||
@@ -212,6 +242,7 @@ async function startCodexConversationThread(
|
||||
authProfileId: params.authProfileId ?? existingBinding?.authProfileId,
|
||||
...agentLookup,
|
||||
});
|
||||
const incognito = isIncognitoSessionKey(params.sessionKey);
|
||||
if (params.threadId?.trim()) {
|
||||
await attachExistingThread({
|
||||
pluginConfig: params.pluginConfig,
|
||||
@@ -228,6 +259,7 @@ async function startCodexConversationThread(
|
||||
serviceTier: params.serviceTier,
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
incognito,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
} else {
|
||||
@@ -245,6 +277,7 @@ async function startCodexConversationThread(
|
||||
serviceTier: params.serviceTier,
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
incognito,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
}
|
||||
@@ -270,8 +303,9 @@ async function handleCodexConversationInboundClaim(
|
||||
ctx: PluginHookInboundClaimContext,
|
||||
options: CodexConversationRunOptions,
|
||||
): Promise<{ handled: boolean; reply?: ReplyPayload } | undefined> {
|
||||
const data = readCodexConversationBindingData(ctx.pluginBinding);
|
||||
if (!data) {
|
||||
const publicBinding = ctx.pluginBinding;
|
||||
const data = readCodexConversationBindingData(publicBinding);
|
||||
if (!data || !publicBinding) {
|
||||
return undefined;
|
||||
}
|
||||
if (event.commandAuthorized !== true) {
|
||||
@@ -332,18 +366,55 @@ async function handleCodexConversationInboundClaim(
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await enqueueBoundTurn(data.bindingId, () =>
|
||||
runBoundTurnWithMissingThreadRecovery({
|
||||
const identity = conversationBindingIdentity(data);
|
||||
const sessionKey = event.sessionKey ?? ctx.sessionKey;
|
||||
// Native ephemeral ownership follows the persisted source, not the
|
||||
// destination channel. Shipped v1 bindings have no source to consult.
|
||||
const incognito = isIncognitoSessionKey(
|
||||
data.source?.sessionKey ?? (data.legacyBinding ? sessionKey : undefined),
|
||||
);
|
||||
// Start the snapshot before enqueueing, but do not await: yielding here
|
||||
// would let detach overtake an already-arrived bound message.
|
||||
const queuedOwner = options.bindingStore.read(identity);
|
||||
const result = await withCodexConversationThreadActivity(data.bindingId, async () => {
|
||||
const currentPublicBinding = getSessionBindingService().resolveByConversation({
|
||||
channel: publicBinding.channel,
|
||||
accountId: publicBinding.accountId,
|
||||
conversationId: publicBinding.conversationId,
|
||||
...(publicBinding.parentConversationId
|
||||
? { parentConversationId: publicBinding.parentConversationId }
|
||||
: {}),
|
||||
});
|
||||
const expected = await queuedOwner;
|
||||
const current = await options.bindingStore.read(identity);
|
||||
if (
|
||||
currentPublicBinding?.bindingId !== publicBinding.bindingId ||
|
||||
(expected &&
|
||||
(!current ||
|
||||
current.threadId !== expected.threadId ||
|
||||
current.conversationStartId !== expected.conversationStartId)) ||
|
||||
(!expected && current && data.start?.id && current.conversationStartId !== data.start.id)
|
||||
) {
|
||||
// Public hooks capture binding data before entering this owner lane;
|
||||
// a later detach must never let that stale message recreate its thread.
|
||||
return {
|
||||
reply: {
|
||||
text: "This Codex conversation was detached or changed before its message could run.",
|
||||
},
|
||||
};
|
||||
}
|
||||
return await runBoundTurnWithMissingThreadRecovery({
|
||||
bindingStore: options.bindingStore,
|
||||
data,
|
||||
prompt,
|
||||
event,
|
||||
config: options.config,
|
||||
sessionKey: event.sessionKey ?? ctx.sessionKey,
|
||||
sessionKey,
|
||||
incognito,
|
||||
pluginConfig: options.pluginConfig,
|
||||
timeoutMs: options.timeoutMs,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
return { handled: true, reply: result.reply };
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -369,8 +440,16 @@ async function handleCodexConversationBindingResolved(
|
||||
const identity = conversationBindingIdentity(data);
|
||||
const binding = await options.bindingStore.read(identity);
|
||||
assertCodexBindingMayBeReplaced(binding, "clearing a denied conversation binding");
|
||||
if (!data.start?.id || binding?.conversationStartId === data.start.id) {
|
||||
await options.bindingStore.mutate(identity, { kind: "clear" });
|
||||
if (binding && (!data.start?.id || binding.conversationStartId === data.start.id)) {
|
||||
await withCodexConversationThreadActivity(identity.bindingId, () =>
|
||||
retireCodexConversationThreadBinding({
|
||||
bindingStore: options.bindingStore,
|
||||
identity,
|
||||
expectedThreadId: binding.threadId,
|
||||
...(data.start?.id ? { expectedStartId: data.start.id } : {}),
|
||||
...(isIncognitoSessionKey(data.source?.sessionKey) ? { allowUntracked: true } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +468,7 @@ type CodexThreadBindingParams = {
|
||||
config?: CodexAppServerAuthProfileLookup["config"];
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
incognito: boolean;
|
||||
};
|
||||
|
||||
type ConversationAppServerRuntime = Awaited<ReturnType<typeof resolveConversationAppServerRuntime>>;
|
||||
@@ -505,12 +585,19 @@ function codexConversationSandboxOrPermissions(
|
||||
config?: JsonObject;
|
||||
} {
|
||||
const networkProxy = runtime.networkProxy;
|
||||
// Bound conversations have no native app approval/tool bridge. Disable
|
||||
// globally configured Codex apps even when a network profile adds config.
|
||||
// Per-app user config overrides apps._default, so the feature kill switch
|
||||
// is the only authoritative boundary for this handlerless runtime.
|
||||
const disabledApps = mergeCodexThreadConfigs(buildDisabledAppsConfigPatch(), {
|
||||
"features.apps": false,
|
||||
})!;
|
||||
if (networkProxy) {
|
||||
return {
|
||||
config: networkProxy.configPatch,
|
||||
config: mergeCodexThreadConfigs(networkProxy.configPatch, disabledApps),
|
||||
};
|
||||
}
|
||||
return { sandbox };
|
||||
return { sandbox, config: disabledApps };
|
||||
}
|
||||
|
||||
async function requestNewConversationBindingThread(
|
||||
@@ -531,7 +618,7 @@ async function requestNewConversationBindingThread(
|
||||
...buildThreadRequestRuntimeOptions(params, resolved),
|
||||
developerInstructions: CODEX_CONVERSATION_THREAD_DEVELOPER_INSTRUCTIONS,
|
||||
experimentalRawEvents: true,
|
||||
...(isIncognitoSessionKey(params.sessionKey) ? { ephemeral: true } : {}),
|
||||
...(params.incognito ? { ephemeral: true } : {}),
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
@@ -552,32 +639,62 @@ async function writeThreadBindingFromResponse(
|
||||
typeof resolved.runtime.approvalPolicy === "string"
|
||||
? resolved.runtime.approvalPolicy
|
||||
: undefined;
|
||||
const committed = await params.bindingStore.mutate(params.identity, {
|
||||
kind: "set",
|
||||
binding: {
|
||||
threadId: response.thread.id,
|
||||
clientId: resolved.client.getInstanceId(),
|
||||
cwd: response.thread.cwd ?? params.workspaceDir,
|
||||
authProfileId: params.authProfileId,
|
||||
model: response.model ?? resolved.model ?? params.model,
|
||||
modelProvider: normalizeCodexAppServerBindingModelProvider({
|
||||
authProfileId: params.authProfileId,
|
||||
modelProvider: response.modelProvider ?? resolved.modelProvider ?? params.modelProvider,
|
||||
...resolved.agentLookup,
|
||||
}),
|
||||
approvalPolicy: resolved.execPolicy?.touched
|
||||
? runtimeApprovalPolicy
|
||||
: (params.approvalPolicy ?? runtimeApprovalPolicy),
|
||||
sandbox: resolved.execPolicy?.touched
|
||||
? resolved.runtime.sandbox
|
||||
: (params.sandbox ?? resolved.runtime.sandbox),
|
||||
serviceTier: params.serviceTier ?? resolved.runtime.serviceTier ?? undefined,
|
||||
networkProxyProfileName: resolved.runtime.networkProxy?.profileName,
|
||||
networkProxyConfigFingerprint: resolved.runtime.networkProxy?.configFingerprint,
|
||||
},
|
||||
const trackSubscription = !params.incognito && isCodexAppServerClientRuntimeLive(resolved.client);
|
||||
const sameOwner = isSameCodexAppServerThreadOwner(current, {
|
||||
threadId: response.thread.id,
|
||||
clientId: resolved.client.getInstanceId(),
|
||||
});
|
||||
if (!committed) {
|
||||
throw new Error("Codex conversation binding changed while storing its thread.");
|
||||
let retained = false;
|
||||
try {
|
||||
if (trackSubscription) {
|
||||
retained = await retainCodexAppServerBindingSubscription(resolved.client, response.thread.id);
|
||||
if (!retained) {
|
||||
throw new Error("Codex conversation thread lost its native subscription owner.");
|
||||
}
|
||||
}
|
||||
if (current && !sameOwner) {
|
||||
// Keep the old identity visible until its sole native subscription is
|
||||
// released; a concurrent owner must not adopt it between clear and cleanup.
|
||||
await releaseCodexAppServerBindingSubscription(current);
|
||||
}
|
||||
const committed = await params.bindingStore.mutate(params.identity, {
|
||||
kind: "set",
|
||||
binding: {
|
||||
threadId: response.thread.id,
|
||||
clientId: resolved.client.getInstanceId(),
|
||||
cwd: params.workspaceDir,
|
||||
authProfileId: params.authProfileId,
|
||||
model: response.model ?? resolved.model ?? params.model,
|
||||
modelProvider: normalizeCodexAppServerBindingModelProvider({
|
||||
authProfileId: params.authProfileId,
|
||||
modelProvider: response.modelProvider ?? resolved.modelProvider ?? params.modelProvider,
|
||||
...resolved.agentLookup,
|
||||
}),
|
||||
approvalPolicy: resolved.execPolicy?.touched
|
||||
? runtimeApprovalPolicy
|
||||
: (params.approvalPolicy ?? runtimeApprovalPolicy),
|
||||
sandbox: resolved.execPolicy?.touched
|
||||
? resolved.runtime.sandbox
|
||||
: (params.sandbox ?? resolved.runtime.sandbox),
|
||||
serviceTier: params.serviceTier ?? resolved.runtime.serviceTier ?? undefined,
|
||||
networkProxyProfileName: resolved.runtime.networkProxy?.profileName,
|
||||
networkProxyConfigFingerprint: resolved.runtime.networkProxy?.configFingerprint,
|
||||
},
|
||||
});
|
||||
if (!committed) {
|
||||
throw new Error("Codex conversation binding changed while storing its thread.");
|
||||
}
|
||||
} catch (error) {
|
||||
// A newly started/resumed Codex thread is already subscribed before its
|
||||
// response arrives; failed ownership commits must release that exact thread.
|
||||
if (trackSubscription && !sameOwner) {
|
||||
await rollbackCodexAppServerBindingSubscription(
|
||||
resolved.client,
|
||||
response.thread.id,
|
||||
retained,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,38 +703,64 @@ async function attachExistingThread(
|
||||
threadId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const current = await params.bindingStore.read(params.identity);
|
||||
assertCodexBindingMayBeReplaced(current, "attaching a conversation-bound Codex thread");
|
||||
const resolved = await resolveThreadBindingRuntime(params);
|
||||
try {
|
||||
// Codex applies network-proxy permission profiles at thread/start. Resuming
|
||||
// an arbitrary existing thread cannot prove that profile is active.
|
||||
const response: CodexThreadResumeResponse | CodexThreadStartResponse = resolved.runtime
|
||||
.networkProxy
|
||||
? await requestNewConversationBindingThread(params, resolved)
|
||||
: await withLeasedCodexAppServerClientStartSelectionRetry({
|
||||
lease: resolved.clientLease,
|
||||
options: resolved.clientOptions,
|
||||
run: async (client, requestOptions) =>
|
||||
await client.request(
|
||||
CODEX_CONTROL_METHODS.resumeThread,
|
||||
{
|
||||
threadId: params.threadId,
|
||||
...(resolved.model ? { model: resolved.model } : {}),
|
||||
...(resolved.modelProvider ? { modelProvider: resolved.modelProvider } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...buildThreadRequestRuntimeOptions(params, resolved),
|
||||
await withExclusiveCodexAppServerThread({
|
||||
bindingStore: params.bindingStore,
|
||||
identity: params.identity,
|
||||
threadId: params.threadId,
|
||||
run: async () => {
|
||||
const current = await params.bindingStore.read(params.identity);
|
||||
assertCodexBindingMayBeReplaced(current, "attaching a conversation-bound Codex thread");
|
||||
const resolved = await resolveThreadBindingRuntime(params);
|
||||
try {
|
||||
// Codex applies network-proxy permission profiles at thread/start. Resuming
|
||||
// an arbitrary existing thread cannot prove that profile is active.
|
||||
const response: CodexThreadResumeResponse | CodexThreadStartResponse = resolved.runtime
|
||||
.networkProxy
|
||||
? await requestNewConversationBindingThread(params, resolved)
|
||||
: await withLeasedCodexAppServerClientStartSelectionRetry({
|
||||
lease: resolved.clientLease,
|
||||
options: resolved.clientOptions,
|
||||
run: async (client, requestOptions) => {
|
||||
if (isCodexAppServerLiveThreadClaimed(client, params.threadId)) {
|
||||
throw new Error(
|
||||
`Codex thread ${params.threadId} has an active run; stop it before binding its conversation.`,
|
||||
);
|
||||
}
|
||||
// Codex ignores resume config while any connection is still
|
||||
// subscribed; completed native children otherwise inherit app permissions.
|
||||
await releaseCodexAppServerLiveThread(client, params.threadId);
|
||||
if (isCodexAppServerLiveThreadClaimed(client, params.threadId)) {
|
||||
throw new Error(
|
||||
`Codex thread ${params.threadId} has an active run; stop it before binding its conversation.`,
|
||||
);
|
||||
}
|
||||
return await client.request(
|
||||
CODEX_CONTROL_METHODS.resumeThread,
|
||||
{
|
||||
threadId: params.threadId,
|
||||
...(resolved.model ? { model: resolved.model } : {}),
|
||||
...(resolved.modelProvider ? { modelProvider: resolved.modelProvider } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...buildThreadRequestRuntimeOptions(params, resolved),
|
||||
},
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
onClientChange: (client) => {
|
||||
resolved.client = client;
|
||||
},
|
||||
});
|
||||
await writeThreadBindingFromResponse(params, resolved, response);
|
||||
} finally {
|
||||
releaseCodexAppServerClientLease(resolved.clientLease);
|
||||
}
|
||||
onClientChange: (client) => {
|
||||
resolved.client = client;
|
||||
},
|
||||
});
|
||||
if (!resolved.runtime.networkProxy && response.thread.id !== params.threadId) {
|
||||
throw new Error(
|
||||
`Codex conversation resume returned ${response.thread.id} for ${params.threadId}.`,
|
||||
);
|
||||
}
|
||||
await writeThreadBindingFromResponse(params, resolved, response);
|
||||
} finally {
|
||||
releaseCodexAppServerClientLease(resolved.clientLease);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createThread(params: CodexThreadBindingParams): Promise<void> {
|
||||
@@ -640,6 +783,7 @@ async function runBoundTurn(params: {
|
||||
pluginConfig?: unknown;
|
||||
config?: CodexConversationConfig;
|
||||
sessionKey?: string;
|
||||
incognito: boolean;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BoundTurnResult> {
|
||||
const agentLookup = buildAgentLookup({ agentDir: params.data.agentDir, config: params.config });
|
||||
@@ -726,7 +870,23 @@ async function runBoundTurn(params: {
|
||||
let activeTurnCleanup: () => void = () => undefined;
|
||||
let retiredUnsafeClient: CodexAppServerClient | undefined;
|
||||
let turnRoute: CodexThreadRouteReservation | undefined;
|
||||
let liveThreadOwnership:
|
||||
| {
|
||||
client: CodexAppServerClient;
|
||||
threadId: string;
|
||||
ownership: CodexAppServerLiveThreadOwnership;
|
||||
}
|
||||
| undefined;
|
||||
let ownsNativeSubscription = false;
|
||||
let turnSucceeded = false;
|
||||
try {
|
||||
if (!params.incognito && isCodexAppServerClientRuntimeLive(client)) {
|
||||
const ownership = await consumeCodexAppServerLiveThread(client, threadId);
|
||||
if (ownership) {
|
||||
liveThreadOwnership = { client, threadId, ownership };
|
||||
ownsNativeSubscription = true;
|
||||
}
|
||||
}
|
||||
if (networkProxyBindingChanged) {
|
||||
const response = assertCodexThreadStartResponse(
|
||||
await withLeasedCodexAppServerClientStartSelectionRetry({
|
||||
@@ -744,13 +904,11 @@ async function runBoundTurn(params: {
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
approvalPolicy,
|
||||
approvalsReviewer: modelScopedRuntime.approvalsReviewer,
|
||||
...(modelScopedRuntime.networkProxy
|
||||
? { config: modelScopedRuntime.networkProxy.configPatch }
|
||||
: { sandbox }),
|
||||
...codexConversationSandboxOrPermissions(modelScopedRuntime, sandbox),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
developerInstructions: CODEX_CONVERSATION_THREAD_DEVELOPER_INSTRUCTIONS,
|
||||
experimentalRawEvents: true,
|
||||
...(isIncognitoSessionKey(params.sessionKey) ? { ephemeral: true } : {}),
|
||||
...(params.incognito ? { ephemeral: true } : {}),
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
@@ -760,6 +918,34 @@ async function runBoundTurn(params: {
|
||||
}),
|
||||
);
|
||||
threadId = response.thread.id;
|
||||
ownsNativeSubscription = true;
|
||||
if (
|
||||
liveThreadOwnership &&
|
||||
(liveThreadOwnership.threadId !== threadId || liveThreadOwnership.client !== client)
|
||||
) {
|
||||
const previousOwnership = liveThreadOwnership;
|
||||
try {
|
||||
await previousOwnership.ownership.release(previousOwnership.threadId);
|
||||
} catch (error) {
|
||||
// A failed unsubscribe leaves the old subscription alive. Restore
|
||||
// its exact branded owner before rolling back the new native thread.
|
||||
const restored =
|
||||
isCodexAppServerClientRuntimeLive(previousOwnership.client) &&
|
||||
(await retainCodexAppServerBindingSubscription(
|
||||
previousOwnership.client,
|
||||
previousOwnership.threadId,
|
||||
previousOwnership.ownership,
|
||||
).catch(() => false));
|
||||
if (!restored) {
|
||||
await closeCodexStartupClientBestEffort(previousOwnership.client);
|
||||
}
|
||||
liveThreadOwnership = undefined;
|
||||
throw error;
|
||||
}
|
||||
liveThreadOwnership = undefined;
|
||||
} else if (binding.threadId !== threadId) {
|
||||
await releaseCodexAppServerBindingSubscription(binding);
|
||||
}
|
||||
const committed = await params.bindingStore.mutate(identity, {
|
||||
kind: "set",
|
||||
binding: {
|
||||
@@ -788,7 +974,10 @@ async function runBoundTurn(params: {
|
||||
throw new Error("Codex conversation binding changed while rotating its thread.");
|
||||
}
|
||||
useStickyNetworkProfile = modelScopedRuntime.networkProxy !== undefined;
|
||||
} else if (binding.clientId !== client.getInstanceId()) {
|
||||
} else if (
|
||||
binding.clientId !== client.getInstanceId() ||
|
||||
(isCodexAppServerClientRuntimeLive(client) && !params.incognito && !liveThreadOwnership)
|
||||
) {
|
||||
const response = await withLeasedCodexAppServerClientStartSelectionRetry({
|
||||
lease: clientLease,
|
||||
options: clientOptions,
|
||||
@@ -820,6 +1009,17 @@ async function runBoundTurn(params: {
|
||||
},
|
||||
});
|
||||
threadId = response.thread.id;
|
||||
ownsNativeSubscription = true;
|
||||
if (
|
||||
!isSameCodexAppServerThreadOwner(binding, {
|
||||
threadId,
|
||||
clientId: client.getInstanceId(),
|
||||
})
|
||||
) {
|
||||
// Keep the old physical owner authoritative until unsubscribe succeeds;
|
||||
// failed migration then rolls back only the newly resumed connection.
|
||||
await releaseCodexAppServerBindingSubscription(binding);
|
||||
}
|
||||
const committed = await params.bindingStore.mutate(identity, {
|
||||
kind: "patch",
|
||||
threadId: binding.threadId,
|
||||
@@ -880,6 +1080,7 @@ async function runBoundTurn(params: {
|
||||
timeoutMs: params.timeoutMs ?? DEFAULT_BOUND_TURN_TIMEOUT_MS,
|
||||
});
|
||||
const replyText = completion.replyText.trim();
|
||||
turnSucceeded = true;
|
||||
return {
|
||||
reply: {
|
||||
text: replyText || "Codex completed without a text reply.",
|
||||
@@ -903,7 +1104,7 @@ async function runBoundTurn(params: {
|
||||
await retireUnsafeCodexTurnClientBestEffort(client, "turn interrupt");
|
||||
}
|
||||
}
|
||||
if (isIncognitoSessionKey(params.sessionKey)) {
|
||||
if (params.incognito) {
|
||||
const bindingReleased = await params.bindingStore.mutate(identity, {
|
||||
kind: "clear",
|
||||
threadId,
|
||||
@@ -922,7 +1123,56 @@ async function runBoundTurn(params: {
|
||||
} finally {
|
||||
activeTurnCleanup();
|
||||
turnRoute?.release();
|
||||
releaseCodexAppServerClientLease(clientLease);
|
||||
try {
|
||||
if (
|
||||
ownsNativeSubscription &&
|
||||
retiredUnsafeClient !== client &&
|
||||
!params.incognito &&
|
||||
isCodexAppServerClientRuntimeLive(client)
|
||||
) {
|
||||
// Ownership callbacks are branded to one physical client and native
|
||||
// thread; an old generation must never clean up its replacement.
|
||||
const currentLiveThreadOwnership =
|
||||
liveThreadOwnership?.client === client && liveThreadOwnership.threadId === threadId
|
||||
? liveThreadOwnership.ownership
|
||||
: undefined;
|
||||
let retained = false;
|
||||
if (turnSucceeded) {
|
||||
retained = await params.bindingStore.withLease(identity, async () => {
|
||||
const current = await params.bindingStore.read(identity);
|
||||
if (current?.threadId !== threadId || current.clientId !== client.getInstanceId()) {
|
||||
return false;
|
||||
}
|
||||
// Claim before turn/start and republish only its unchanged owner;
|
||||
// TTL/LRU eviction must never detach an active conversation turn.
|
||||
return await retainCodexAppServerBindingSubscription(
|
||||
client,
|
||||
threadId,
|
||||
currentLiveThreadOwnership,
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!retained) {
|
||||
const released = currentLiveThreadOwnership
|
||||
? await currentLiveThreadOwnership.release(threadId).then(() => true)
|
||||
: await unsubscribeCodexThreadBestEffort(client, {
|
||||
threadId,
|
||||
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
|
||||
});
|
||||
if (!released) {
|
||||
await closeCodexStartupClientBestEffort(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
embeddedAgentLog.warn("codex conversation subscription cleanup failed", {
|
||||
threadId,
|
||||
reason: formatErrorMessage(error),
|
||||
});
|
||||
await closeCodexStartupClientBestEffort(client);
|
||||
} finally {
|
||||
releaseCodexAppServerClientLease(clientLease);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -949,6 +1199,7 @@ async function runBoundTurnWithMissingThreadRecovery(params: {
|
||||
pluginConfig?: unknown;
|
||||
config?: CodexConversationConfig;
|
||||
sessionKey?: string;
|
||||
incognito: boolean;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BoundTurnResult> {
|
||||
await prepareConversationBinding(params);
|
||||
@@ -970,6 +1221,7 @@ async function prepareConversationBinding(
|
||||
pluginConfig?: unknown;
|
||||
config?: CodexConversationConfig;
|
||||
sessionKey?: string;
|
||||
incognito: boolean;
|
||||
},
|
||||
options: { forceNew?: boolean } = {},
|
||||
): Promise<void> {
|
||||
@@ -1022,9 +1274,12 @@ async function prepareConversationBinding(
|
||||
serviceTier: inherited?.serviceTier,
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
incognito: params.incognito,
|
||||
agentId: params.data.agentId,
|
||||
};
|
||||
const threadId = requested?.threadId ?? (!current ? params.data.source?.threadId : undefined);
|
||||
// Harness threads retain immutable tools, developer instructions, and app
|
||||
// policy. Transfer bounded visible history into a fresh bound-only thread.
|
||||
const threadId = requested?.threadId;
|
||||
if (threadId && !options.forceNew) {
|
||||
await attachExistingThread({ ...bindingParams, threadId });
|
||||
} else {
|
||||
@@ -1038,6 +1293,27 @@ async function prepareConversationBinding(
|
||||
await params.bindingStore.withLease(sourceIdentity, async () => {
|
||||
const source = await params.bindingStore.read(sourceIdentity);
|
||||
if (source && source.threadId === params.data.source?.threadId) {
|
||||
const sourceSessionKey =
|
||||
sourceIdentity.sessionKey ??
|
||||
resolveTranscriptSessionKeyBySessionId({
|
||||
agentId: sourceIdentity.agentId,
|
||||
sessionId: sourceIdentity.sessionId,
|
||||
storePath: resolveStorePath(params.config?.session?.store, {
|
||||
agentId: sourceIdentity.agentId,
|
||||
}),
|
||||
});
|
||||
if (
|
||||
sourceSessionKey &&
|
||||
resolveActiveEmbeddedRunSessionId(sourceSessionKey) === sourceIdentity.sessionId
|
||||
) {
|
||||
throw new Error(
|
||||
"Codex source session has an active run; stop it before binding this conversation.",
|
||||
);
|
||||
}
|
||||
if (source.threadId !== stored.threadId) {
|
||||
await releaseCodexAppServerBindingSubscription(source);
|
||||
await projectConversationSourceHistory(params.data.source, stored, params.config);
|
||||
}
|
||||
await params.bindingStore.mutate(sourceIdentity, {
|
||||
kind: "clear",
|
||||
threadId: source.threadId,
|
||||
@@ -1059,6 +1335,48 @@ async function prepareConversationBinding(
|
||||
});
|
||||
}
|
||||
|
||||
async function projectConversationSourceHistory(
|
||||
source: { agentId: string; sessionId: string; sessionKey?: string; threadId: string },
|
||||
target: { threadId: string; clientId?: string },
|
||||
config?: CodexConversationConfig,
|
||||
): Promise<void> {
|
||||
const storePath = resolveStorePath(config?.session?.store, { agentId: source.agentId });
|
||||
const sessionKey =
|
||||
source.sessionKey ??
|
||||
resolveTranscriptSessionKeyBySessionId({
|
||||
agentId: source.agentId,
|
||||
sessionId: source.sessionId,
|
||||
storePath,
|
||||
});
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
// Local visible transcripts remain readable for ephemeral and paginated
|
||||
// Codex threads, both of which reject native includeTurns history reads.
|
||||
const entries = await readVisibleSessionTranscriptMessageEntries({
|
||||
agentId: source.agentId,
|
||||
sessionId: source.sessionId,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
const history = projectBoundedCodexVisibleSessionHistory(entries);
|
||||
if (history.length === 0) {
|
||||
return;
|
||||
}
|
||||
const clientLease = retainSharedCodexAppServerClientByInstanceId(target.clientId);
|
||||
if (!clientLease) {
|
||||
throw new Error("Codex conversation source history lost its bound client owner.");
|
||||
}
|
||||
try {
|
||||
await clientLease.client.request("thread/inject_items", {
|
||||
threadId: target.threadId,
|
||||
items: history,
|
||||
});
|
||||
} finally {
|
||||
clientLease.release();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConversationExecPolicy(params: {
|
||||
config?: CodexConversationConfig;
|
||||
agentId?: string;
|
||||
@@ -1260,7 +1578,7 @@ function buildAgentLookup(params: {
|
||||
|
||||
function conversationBindingIdentity(
|
||||
data: Pick<CodexAppServerConversationBindingData, "bindingId">,
|
||||
): CodexAppServerBindingIdentity {
|
||||
): Extract<CodexAppServerBindingIdentity, { kind: "conversation" }> {
|
||||
return { kind: "conversation", bindingId: data.bindingId };
|
||||
}
|
||||
|
||||
|
||||
@@ -401,6 +401,48 @@ describe("codex conversation controls", () => {
|
||||
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops an incompatible pinned auth profile when selecting another provider", async () => {
|
||||
const sessionKey = "agent:main:model-provider-switch";
|
||||
const sessionId = "session-provider-switch";
|
||||
const identity = { kind: "session" as const, agentId: "main", sessionId, sessionKey };
|
||||
const storePath = resolveStorePath(undefined, { agentId: "main" });
|
||||
await upsertSessionEntry({
|
||||
agentId: "main",
|
||||
storePath,
|
||||
sessionKey,
|
||||
entry: {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
authProfileOverride: "lmstudio:work",
|
||||
authProfileOverrideSource: "user",
|
||||
},
|
||||
});
|
||||
await testCodexAppServerBindingStore.mutate(identity, {
|
||||
kind: "set",
|
||||
binding: {
|
||||
threadId: "thread-provider-switch",
|
||||
cwd: tempDir,
|
||||
model: "local-model",
|
||||
modelProvider: "lmstudio",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
setCodexConversationModelImpl({
|
||||
identity,
|
||||
bindingStore: testCodexAppServerBindingStore,
|
||||
model: "openai/gpt-5.5",
|
||||
}),
|
||||
).resolves.toBe("Codex model set to gpt-5.5.");
|
||||
|
||||
expect(getSessionEntry({ storePath, sessionKey })).toMatchObject({
|
||||
providerOverride: "openai",
|
||||
modelOverride: "gpt-5.5",
|
||||
liveModelSwitchPending: true,
|
||||
});
|
||||
expect(getSessionEntry({ storePath, sessionKey })?.authProfileOverride).toBeUndefined();
|
||||
});
|
||||
|
||||
it("escapes requested model names before chat display", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await writeCodexAppServerBinding(sessionFile, {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
|
||||
// Codex plugin module implements conversation control behavior.
|
||||
import {
|
||||
applyModelOverrideToSessionEntry,
|
||||
applyModelOverrideWithAuthProfileCompatibility,
|
||||
ModelSelectionLockedError,
|
||||
} from "openclaw/plugin-sdk/model-session-runtime";
|
||||
import {
|
||||
resolveStorePath,
|
||||
updateSessionStoreEntry,
|
||||
} from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { patchSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { resolveCodexBindingAppServerConnection } from "./app-server/binding-connection.js";
|
||||
import type { CodexAppServerClient } from "./app-server/client.js";
|
||||
import {
|
||||
@@ -230,18 +228,24 @@ export async function setCodexConversationModel(params: {
|
||||
}
|
||||
: undefined);
|
||||
if (session) {
|
||||
const updated = await updateSessionStoreEntry({
|
||||
const updated = await patchSessionEntry({
|
||||
agentId: session.agentId,
|
||||
storePath: resolveStorePath(params.config?.session?.store, { agentId: session.agentId }),
|
||||
sessionKey: session.sessionKey,
|
||||
requireWriteSuccess: true,
|
||||
// Model override helpers delete stale credentials and model metadata;
|
||||
// replacing the snapshot is required because partial patches merge fields.
|
||||
replaceEntry: true,
|
||||
update: (entry) => {
|
||||
if (entry.sessionId !== session.sessionId) {
|
||||
return null;
|
||||
throw new Error("Codex session changed while applying the model selection.");
|
||||
}
|
||||
applyModelOverrideToSessionEntry({
|
||||
applyModelOverrideWithAuthProfileCompatibility({
|
||||
cfg: params.config ?? {},
|
||||
agentDir: params.agentDir ?? resolveAgentDir(params.config ?? {}, session.agentId),
|
||||
entry,
|
||||
currentProvider: binding.modelProvider ?? "openai",
|
||||
selection: { provider: nextModelProvider ?? "openai", model: nextModel },
|
||||
preserveAuthProfileOverride: true,
|
||||
markLiveSwitchPending: true,
|
||||
});
|
||||
return entry;
|
||||
|
||||
@@ -283,6 +283,7 @@ echo "==> Docker run timeout: $CODEX_HARNESS_DOCKER_RUN_TIMEOUT"
|
||||
echo "==> Chat image probe: ${OPENCLAW_LIVE_CODEX_HARNESS_CHAT_IMAGE_PROBE:-0}"
|
||||
echo "==> Image probe: ${OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE:-1}"
|
||||
echo "==> MCP probe: ${OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE:-1}"
|
||||
echo "==> Multi-session probe: ${OPENCLAW_LIVE_CODEX_HARNESS_MULTI_SESSION_PROBE:-0}"
|
||||
echo "==> Subagent probe: ${OPENCLAW_LIVE_CODEX_HARNESS_SUBAGENT_PROBE:-1}"
|
||||
echo "==> Subagent count: ${OPENCLAW_LIVE_CODEX_HARNESS_SUBAGENT_COUNT:-1}"
|
||||
echo "==> Subagent-only fast path: ${OPENCLAW_LIVE_CODEX_HARNESS_SUBAGENT_ONLY:-auto}"
|
||||
@@ -333,6 +334,7 @@ DOCKER_RUN_ARGS+=(--rm -t \
|
||||
-e OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE="${OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE:-1}" \
|
||||
-e OPENCLAW_LIVE_CODEX_HARNESS_LARGE_OUTPUT_BYTES="${OPENCLAW_LIVE_CODEX_HARNESS_LARGE_OUTPUT_BYTES:-300000}" \
|
||||
-e OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE="${OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE:-1}" \
|
||||
-e OPENCLAW_LIVE_CODEX_HARNESS_MULTI_SESSION_PROBE="${OPENCLAW_LIVE_CODEX_HARNESS_MULTI_SESSION_PROBE:-0}" \
|
||||
-e OPENCLAW_LIVE_CODEX_HARNESS_MODEL="${OPENCLAW_LIVE_CODEX_HARNESS_MODEL:-openai/gpt-5.6-luna}" \
|
||||
-e OPENCLAW_LIVE_CODEX_HARNESS_TARGETS="${OPENCLAW_LIVE_CODEX_HARNESS_TARGETS:-}" \
|
||||
-e OPENCLAW_LIVE_CODEX_HARNESS_THINKING="${OPENCLAW_LIVE_CODEX_HARNESS_THINKING:-low}" \
|
||||
|
||||
@@ -118,6 +118,9 @@ describe("scripts/test-live-codex-harness-docker.sh", () => {
|
||||
it("forwards bounded resume stress controls into Docker", () => {
|
||||
const script = fs.readFileSync(SCRIPT_PATH, "utf8");
|
||||
|
||||
expect(script).toContain(
|
||||
'-e OPENCLAW_LIVE_CODEX_HARNESS_MULTI_SESSION_PROBE="${OPENCLAW_LIVE_CODEX_HARNESS_MULTI_SESSION_PROBE:-0}"',
|
||||
);
|
||||
expect(script).toContain(
|
||||
'-e OPENCLAW_LIVE_CODEX_HARNESS_RESUME_STRESS="${OPENCLAW_LIVE_CODEX_HARNESS_RESUME_STRESS:-0}"',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user