mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 15:43:57 -06:00
45dd558d92
* 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
731 lines
31 KiB
TypeScript
731 lines
31 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { CodexAppServerClient } from "./client.js";
|
|
import { createClientHarness } from "./test-support.js";
|
|
|
|
const EXPECTED_LIVE_THREAD_IDLE_TIMEOUT_MS = 30 * 60_000;
|
|
const EXPECTED_MAX_IDLE_LIVE_THREADS = 64;
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
refreshAuth: vi.fn(async () => ({ accessToken: "refreshed", chatgptAccountId: "account" })),
|
|
mergeRateLimitUpdate: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./auth-bridge.js", () => ({
|
|
refreshCodexAppServerAuthTokens: mocks.refreshAuth,
|
|
}));
|
|
|
|
vi.mock("./rate-limit-cache.js", () => ({
|
|
mergeCodexRateLimitsUpdate: mocks.mergeRateLimitUpdate,
|
|
}));
|
|
|
|
const {
|
|
claimCodexAppServerLiveThread,
|
|
consumeCodexAppServerLiveThread,
|
|
ensureCodexAppServerClientRuntime,
|
|
isCodexAppServerLiveThreadClaimed,
|
|
protectCodexAppServerLiveThread,
|
|
releaseCodexAppServerLiveThread,
|
|
retainCodexAppServerLiveThread,
|
|
unsubscribeCodexAppServerLiveThread,
|
|
} = await import("./client-runtime.js");
|
|
|
|
describe("Codex app-server client runtime", () => {
|
|
const clients: CodexAppServerClient[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const client of clients) {
|
|
client.close();
|
|
}
|
|
clients.length = 0;
|
|
vi.useRealTimers();
|
|
mocks.refreshAuth.mockClear();
|
|
mocks.mergeRateLimitUpdate.mockClear();
|
|
});
|
|
|
|
it("installs shared handlers once per physical client", async () => {
|
|
const harness = createClientHarness();
|
|
clients.push(harness.client);
|
|
const context = {
|
|
agentDir: "/tmp/agent",
|
|
authProfileId: "openai:default",
|
|
config: {},
|
|
};
|
|
const updatedContext = {
|
|
...context,
|
|
authProfileStore: { version: 1 as const, profiles: {} },
|
|
config: { models: { mode: "merge" as const } },
|
|
};
|
|
const addNotificationHandler = vi.spyOn(harness.client, "addNotificationHandler");
|
|
const addRequestHandler = vi.spyOn(harness.client, "addRequestHandler");
|
|
const addCloseHandler = vi.spyOn(harness.client, "addCloseHandler");
|
|
|
|
ensureCodexAppServerClientRuntime(harness.client, context);
|
|
ensureCodexAppServerClientRuntime(harness.client, updatedContext);
|
|
|
|
expect(addNotificationHandler).toHaveBeenCalledTimes(1);
|
|
expect(addRequestHandler).toHaveBeenCalledTimes(1);
|
|
expect(addCloseHandler).toHaveBeenCalledTimes(1);
|
|
harness.send({
|
|
method: "account/rateLimits/updated",
|
|
params: { rateLimits: { primary: { usedPercent: 12 } } },
|
|
});
|
|
harness.send({
|
|
id: "refresh-1",
|
|
method: "account/chatgptAuthTokens/refresh",
|
|
params: { reason: "expired" },
|
|
});
|
|
|
|
await vi.waitFor(() => expect(mocks.mergeRateLimitUpdate).toHaveBeenCalledTimes(1));
|
|
await vi.waitFor(() => expect(mocks.refreshAuth).toHaveBeenCalledTimes(1));
|
|
expect(mocks.refreshAuth).toHaveBeenCalledWith(updatedContext);
|
|
expect(mocks.mergeRateLimitUpdate).toHaveBeenCalledWith(harness.client, {
|
|
rateLimits: { primary: { usedPercent: 12 } },
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(harness.writes.map((line) => JSON.parse(line) as unknown)).toContainEqual({
|
|
id: "refresh-1",
|
|
result: { accessToken: "refreshed", chatgptAccountId: "account" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects ChatGPT refresh on a prepared API-key client", async () => {
|
|
const harness = createClientHarness();
|
|
clients.push(harness.client);
|
|
ensureCodexAppServerClientRuntime(harness.client, {
|
|
agentDir: "/tmp/agent",
|
|
authMode: "prepared-api-key",
|
|
});
|
|
|
|
harness.send({
|
|
id: "refresh-api-key",
|
|
method: "account/chatgptAuthTokens/refresh",
|
|
params: { reason: "expired" },
|
|
});
|
|
|
|
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThan(0));
|
|
expect(mocks.refreshAuth).not.toHaveBeenCalled();
|
|
expect(JSON.parse(harness.writes.at(-1) ?? "{}")).toMatchObject({
|
|
id: "refresh-api-key",
|
|
error: {
|
|
message: "ChatGPT token refresh is unavailable for prepared Codex API-key auth.",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("retains independently subscribed conversations on the same physical client", async () => {
|
|
const harness = createClientHarness();
|
|
clients.push(harness.client);
|
|
|
|
await expect(
|
|
retainCodexAppServerLiveThread(harness.client, "thread-before-runtime"),
|
|
).resolves.toBe(false);
|
|
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
|
|
|
await expect(retainCodexAppServerLiveThread(harness.client, "thread-a")).resolves.toBe(true);
|
|
await expect(retainCodexAppServerLiveThread(harness.client, "thread-b")).resolves.toBe(true);
|
|
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) }),
|
|
);
|
|
await expect(
|
|
consumeCodexAppServerLiveThread(harness.client, "thread-a"),
|
|
).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);
|
|
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
|
|
|
let finishRelease: (() => void) | undefined;
|
|
const pendingRelease = new Promise<void>((resolve) => {
|
|
finishRelease = resolve;
|
|
});
|
|
await retainCodexAppServerLiveThread(harness.client, "thread-a", async () => pendingRelease);
|
|
await retainCodexAppServerLiveThread(harness.client, "thread-b");
|
|
const release = releaseCodexAppServerLiveThread(harness.client, "thread-a");
|
|
const sameThreadAcquisition = consumeCodexAppServerLiveThread(harness.client, "thread-a");
|
|
|
|
await expect(consumeCodexAppServerLiveThread(harness.client, "thread-b")).resolves.toEqual(
|
|
expect.objectContaining({ release: expect.any(Function) }),
|
|
);
|
|
finishRelease?.();
|
|
await expect(release).resolves.toBe(true);
|
|
await expect(sameThreadAcquisition).resolves.toBeUndefined();
|
|
});
|
|
|
|
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" });
|
|
await retainCodexAppServerLiveThread(harness.client, "thread-a", async () => {
|
|
throw new Error("unsubscribe unavailable");
|
|
});
|
|
await retainCodexAppServerLiveThread(harness.client, "thread-b");
|
|
|
|
await expect(releaseCodexAppServerLiveThread(harness.client, "thread-a")).rejects.toThrow(
|
|
"unsubscribe unavailable",
|
|
);
|
|
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) }),
|
|
);
|
|
});
|
|
|
|
it("transfers ownership only for the exact immutable thread fingerprint", async () => {
|
|
const harness = createClientHarness();
|
|
clients.push(harness.client);
|
|
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
|
|
|
await expect(
|
|
retainCodexAppServerLiveThread(harness.client, "thread-1", undefined, "config-before"),
|
|
).resolves.toBe(true);
|
|
await expect(
|
|
consumeCodexAppServerLiveThread(harness.client, "thread-1", "config-after"),
|
|
).resolves.toBeUndefined();
|
|
await expect(
|
|
consumeCodexAppServerLiveThread(harness.client, "thread-1", "config-before"),
|
|
).resolves.toEqual(
|
|
expect.objectContaining({
|
|
configFingerprint: "config-before",
|
|
release: expect.any(Function),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("evicts only the oldest idle subscription at the per-client capacity", async () => {
|
|
const harness = createClientHarness();
|
|
clients.push(harness.client);
|
|
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
|
const release = vi.fn(async (_threadId: string) => undefined);
|
|
|
|
for (let index = 0; index <= EXPECTED_MAX_IDLE_LIVE_THREADS; index += 1) {
|
|
await retainCodexAppServerLiveThread(harness.client, `thread-${index}`, release);
|
|
}
|
|
|
|
expect(release).toHaveBeenCalledExactlyOnceWith("thread-0");
|
|
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 () => {
|
|
vi.useFakeTimers();
|
|
const harness = createClientHarness();
|
|
clients.push(harness.client);
|
|
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
|
const release = vi.fn(async (_threadId: string) => undefined);
|
|
await retainCodexAppServerLiveThread(harness.client, "thread-expired", release);
|
|
|
|
await vi.advanceTimersByTimeAsync(EXPECTED_LIVE_THREAD_IDLE_TIMEOUT_MS - 1);
|
|
expect(release).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
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();
|
|
clients.push(harness.client);
|
|
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
|
const release = vi.fn(async (_threadId: string) => undefined);
|
|
const unprotect = protectCodexAppServerLiveThread(harness.client, "thread-parent");
|
|
await retainCodexAppServerLiveThread(harness.client, "thread-parent", release);
|
|
|
|
await vi.advanceTimersByTimeAsync(EXPECTED_LIVE_THREAD_IDLE_TIMEOUT_MS * 2);
|
|
expect(release).not.toHaveBeenCalled();
|
|
unprotect();
|
|
await vi.advanceTimersByTimeAsync(EXPECTED_LIVE_THREAD_IDLE_TIMEOUT_MS - 1);
|
|
expect(release).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
expect(release).toHaveBeenCalledExactlyOnceWith("thread-parent");
|
|
});
|
|
|
|
it("keeps protected parents outside the independent idle-conversation limit", async () => {
|
|
const harness = createClientHarness();
|
|
clients.push(harness.client);
|
|
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
|
const release = vi.fn(async (_threadId: string) => undefined);
|
|
const unprotect: Array<() => void> = [];
|
|
for (let index = 0; index < EXPECTED_MAX_IDLE_LIVE_THREADS; index += 1) {
|
|
const threadId = `parent-${index}`;
|
|
unprotect.push(protectCodexAppServerLiveThread(harness.client, threadId));
|
|
await retainCodexAppServerLiveThread(harness.client, threadId, release);
|
|
}
|
|
await retainCodexAppServerLiveThread(harness.client, "conversation-a", release);
|
|
await retainCodexAppServerLiveThread(harness.client, "conversation-b", release);
|
|
|
|
expect(release).not.toHaveBeenCalled();
|
|
for (const releaseProtection of unprotect) {
|
|
releaseProtection();
|
|
}
|
|
await vi.waitFor(() => expect(release).toHaveBeenCalledTimes(2));
|
|
expect(release).toHaveBeenNthCalledWith(1, "conversation-a");
|
|
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) => {
|
|
const harness = createClientHarness();
|
|
clients.push(harness.client);
|
|
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
|
await retainCodexAppServerLiveThread(harness.client, "thread-a");
|
|
await retainCodexAppServerLiveThread(harness.client, "thread-b");
|
|
const notificationObserved = new Promise<void>((resolve) => {
|
|
harness.client.addNotificationHandler((notification) => {
|
|
if (notification.method === method) {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
|
|
harness.send({ method, params: { threadId: "thread-a" } });
|
|
await notificationObserved;
|
|
await expect(
|
|
consumeCodexAppServerLiveThread(harness.client, "thread-a"),
|
|
).resolves.toBeUndefined();
|
|
await expect(consumeCodexAppServerLiveThread(harness.client, "thread-b")).resolves.toEqual(
|
|
expect.objectContaining({ release: expect.any(Function) }),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("clears idle ownership and its timer when the physical client closes", async () => {
|
|
vi.useFakeTimers();
|
|
const harness = createClientHarness();
|
|
clients.push(harness.client);
|
|
ensureCodexAppServerClientRuntime(harness.client, { agentDir: "/tmp/agent" });
|
|
const release = vi.fn(async (_threadId: string) => undefined);
|
|
await retainCodexAppServerLiveThread(harness.client, "thread-closed", release);
|
|
|
|
harness.client.close();
|
|
await vi.advanceTimersByTimeAsync(EXPECTED_LIVE_THREAD_IDLE_TIMEOUT_MS);
|
|
|
|
expect(release).not.toHaveBeenCalled();
|
|
await expect(
|
|
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();
|
|
});
|
|
});
|