mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
* fix(codex): keep catalog actions on the selected native session Keep stop and steer on the active client, settle native fork ownership under the binding lease, and retain canceled resume reservations until cleanup has settled. Preserve ordered inbound images and confirm native thread absence through an authoritative read. Require current source support for node catalog continuation, scope resume reservations by canonical store and thread, and preserve source identity through terminal routing. Older nodes retain readable catalogs and unqualified slash-command bindings; catalog continuation requires the upgraded capability. Production delta: +95 net lines. Focused proof: 1,200 passing cases across 27 files with one Windows-specific skip. Official P2 review 5 covers the original 67-file snapshot; review 7 covers the exact final five-file follow-up. Both are scoped-clean. The unchanged CLI test-support core-lint error is recorded in the handoff; final composed CI, live proof, and screenshot publication remain with the integration owner. * fix(codex): keep captured homes stable across alias changes Capture the physical agent directory and Codex home once per config generation, including missing home leaves, and carry that prepared path through native startup and bound CLI resume. A client restart cannot follow a retargeted alias under an older source-home identity. Preserve requested home ownership while aligning cache and fence keys, native config reads, skill isolation, and binding rotation with the captured path. Configuration reload remains the invalidation boundary. Validation: four intended original-code failures; 503 focused and sibling cases pass across ten files. Extension types, changed lint, formatting, line caps, and independent scoped P2 review pass. * test(codex): align integration fixtures with runtime contracts Load Codex metadata through the core manifest owner in the broker cancellation integration test, preserving the core/plugin boundary. Match refreshed quota-fixture lifetime to its existing fixed JWT expiry so the healthy-status assertion respects the CLI warning window. Resolve the Swift TLS retry endpoint from its updated stored pin. Preserve behavioral assertions, production thresholds, and timeouts. Validation: 11 boundary/broker cases, test-root types/lint, formatting, line caps, and scoped P2 review pass. All three quota UI replay cases pass on the verified integration runtime; native Swift CI remains the execution proof for the final candidate. * fix(plugins): preserve reactivated registries during retirement Recheck registry liveness after asynchronous command draining before marking a no-host-cleanup registry retired. A temporary registry can restore the previous one while displaced cleanup is still pending. Also collect widget dropdown rows and computed fonts in one browser evaluation so a removed loading row cannot corrupt the typography assertion. Keep the existing full-font equality assertion unchanged. Validation: scoped P2 reviews, physical core production types, targeted core test graph types, 28 catalog registry/broker cases, 37 registry owner/sibling cases, 43 original-order QA/prompt/Copilot cases, and 4 normal plus 15 bounded timing browser cases pass. * refactor(ui): deduplicate catalog key parsing Normalize catalog URL fields together and keep empty-component rejection in the decoded key validation. Preserve routing and source-home identity while reducing the startup bundle without changing performance limits.
243 lines
9 KiB
TypeScript
243 lines
9 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import {
|
|
createPluginRecord,
|
|
createPluginRegistry,
|
|
createPluginRuntimeMock,
|
|
resetPluginRuntimeStateForTest,
|
|
setActivePluginRegistry,
|
|
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import codexPlugin from "../extensions/codex/index.js";
|
|
import { invokeRegisteredNodeHostCommand } from "../src/node-host/plugin-node-host.js";
|
|
import { loadPluginManifest } from "../src/plugins/manifest.js";
|
|
import { runWithSpawnBroker } from "../src/process/spawn-broker/context.js";
|
|
import { createSpawnBrokerHost, type SpawnBrokerHost } from "../src/process/spawn-broker/host.js";
|
|
import { isPidDefinitelyDead } from "../src/shared/pid-alive.js";
|
|
import { resolveBundledPluginPublicModulePath } from "../src/test-utils/bundled-plugin-public-surface.js";
|
|
import { createDeferred, withTestTimeout } from "./helpers/promise.js";
|
|
|
|
const bufferedObservation = vi.hoisted(() => ({ returned: vi.fn() }));
|
|
vi.mock("openclaw/plugin-sdk/process-runtime", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/process-runtime")>();
|
|
return {
|
|
...actual,
|
|
async runCommandBuffered(...args: Parameters<typeof actual.runCommandBuffered>) {
|
|
const result = await actual.runCommandBuffered(...args);
|
|
bufferedObservation.returned(result);
|
|
return result;
|
|
},
|
|
};
|
|
});
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
resetPluginRuntimeStateForTest();
|
|
bufferedObservation.returned.mockReset();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
await Promise.all(
|
|
tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
const skipBrokerTests = process.platform === "win32" || Boolean(process.versions.bun);
|
|
|
|
describe.skipIf(skipBrokerTests)("registered Codex node cancellation settlement", () => {
|
|
it("keeps the session reserved until canceled late-PID work has closed", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "codex-node-cleanup-"));
|
|
tempDirs.push(root);
|
|
const codexHome = path.join(root, "codex-home");
|
|
await fs.mkdir(codexHome);
|
|
vi.stubEnv("CODEX_HOME", codexHome);
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", root);
|
|
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(root, "openclaw.json"));
|
|
const pluginConfig = { sessionCatalog: { enabled: false } };
|
|
const config = {
|
|
plugins: { entries: { codex: { enabled: true, config: pluginConfig } } },
|
|
};
|
|
const registry = createPluginRegistry({
|
|
runtime: createPluginRuntimeMock({ config: { current: () => config } }),
|
|
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
|
activateGlobalSideEffects: false,
|
|
});
|
|
const codexManifest = loadPluginManifest(
|
|
path.dirname(
|
|
resolveBundledPluginPublicModulePath({
|
|
pluginId: "codex",
|
|
artifactBasename: "openclaw.plugin.json",
|
|
}),
|
|
),
|
|
);
|
|
if (!codexManifest.ok) {
|
|
throw new Error(codexManifest.error);
|
|
}
|
|
const record = createPluginRecord({
|
|
id: "codex",
|
|
source: path.join(root, "index.js"),
|
|
nativeSessionCatalog: codexManifest.manifest.setup?.nativeSessionCatalog,
|
|
});
|
|
registry.registry.plugins.push(record);
|
|
codexPlugin.register(registry.createApi(record, { config, pluginConfig }));
|
|
setActivePluginRegistry(registry.registry);
|
|
|
|
const broker = createSpawnBrokerHost();
|
|
const controller = new AbortController();
|
|
const remoteRequested = createDeferred<ReturnType<SpawnBrokerHost["spawnExeca"]>>();
|
|
const bufferedReturned = createDeferred();
|
|
const remotes: Array<ReturnType<SpawnBrokerHost["spawnExeca"]>> = [];
|
|
const invocations: Promise<unknown>[] = [];
|
|
const failures: unknown[] = [];
|
|
let brokerStopped = false;
|
|
const resumeBroker = () => {
|
|
if (brokerStopped && broker.pid !== undefined) {
|
|
process.kill(broker.pid, "SIGCONT");
|
|
brokerStopped = false;
|
|
}
|
|
};
|
|
try {
|
|
await broker.ready();
|
|
if (broker.pid === undefined) {
|
|
throw new Error("spawn broker did not report its PID");
|
|
}
|
|
const spawnExeca = broker.spawnExeca.bind(broker);
|
|
vi.spyOn(broker, "spawnExeca").mockImplementation((argv, options) => {
|
|
const outputIndex = argv.indexOf("--output-last-message");
|
|
const outputPath = argv[outputIndex + 1];
|
|
if (outputIndex === -1 || !outputPath) {
|
|
throw new Error("fixture received an unexpected process request");
|
|
}
|
|
const source = `
|
|
const fs = require("node:fs");
|
|
let prompt = "";
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.on("data", chunk => { prompt += chunk; });
|
|
process.stdin.on("end", () => {
|
|
if (prompt === "retry") {
|
|
fs.writeFileSync(process.argv[1], "retry completed");
|
|
} else {
|
|
setInterval(() => {}, 1_000);
|
|
}
|
|
});
|
|
`;
|
|
const remote = spawnExeca([process.execPath, "-e", source, outputPath], options);
|
|
remotes.push(remote);
|
|
remoteRequested.resolve(remote);
|
|
return remote;
|
|
});
|
|
bufferedObservation.returned.mockImplementation(() => bufferedReturned.resolve());
|
|
const invoke = (prompt: string, signal?: AbortSignal) => {
|
|
const invocation = runWithSpawnBroker(broker, () =>
|
|
invokeRegisteredNodeHostCommand(
|
|
"codex.cli.session.resume",
|
|
JSON.stringify({
|
|
sessionId: "cleanup-fixture-session",
|
|
prompt,
|
|
cwd: root,
|
|
timeoutMs: 10_000,
|
|
}),
|
|
undefined,
|
|
{ signal, sendNodeEvent: async () => undefined },
|
|
),
|
|
);
|
|
invocations.push(invocation);
|
|
void invocation.catch(() => {});
|
|
return invocation;
|
|
};
|
|
process.kill(broker.pid, "SIGSTOP");
|
|
brokerStopped = true;
|
|
const first = invoke("hold", controller.signal);
|
|
const outcome = first.then(
|
|
(value) => ({ kind: "resolved" as const, value }),
|
|
(error: unknown) => ({ kind: "rejected" as const, error }),
|
|
);
|
|
const remote = await withTestTimeout(
|
|
remoteRequested.promise,
|
|
5_000,
|
|
"registered command did not reach the broker",
|
|
);
|
|
expect(remote.child.pid).toBeUndefined();
|
|
controller.abort(new Error("node invocation canceled"));
|
|
await withTestTimeout(
|
|
bufferedReturned.promise,
|
|
5_000,
|
|
"buffered execution did not expose its bounded cancellation result",
|
|
);
|
|
expect(
|
|
await Promise.race([outcome, delay(250).then(() => ({ kind: "pending" as const }))]),
|
|
).toEqual({ kind: "pending" });
|
|
await expect(invoke("retry")).rejects.toThrow("already has an active resume turn");
|
|
|
|
resumeBroker();
|
|
await withTestTimeout(remote.child.waitForClose(), 5_000, "late native child did not close");
|
|
expect(remote.child.pid).toBeDefined();
|
|
expect(isPidDefinitelyDead(remote.child.pid!)).toBe(true);
|
|
expect(
|
|
await withTestTimeout(outcome, 5_000, "registered cancellation did not settle"),
|
|
).toMatchObject({
|
|
kind: "rejected",
|
|
error: { message: "node invocation canceled" },
|
|
});
|
|
const retry = await withTestTimeout(
|
|
invoke("retry"),
|
|
5_000,
|
|
"session remained reserved after cleanup",
|
|
);
|
|
expect(JSON.parse(retry ?? "null")).toMatchObject({
|
|
ok: true,
|
|
sessionId: "cleanup-fixture-session",
|
|
text: "retry completed",
|
|
});
|
|
} catch (error) {
|
|
failures.push(error);
|
|
} finally {
|
|
controller.abort();
|
|
try {
|
|
resumeBroker();
|
|
const cleanup = await Promise.allSettled(
|
|
remotes.map(async (remote) => {
|
|
await withTestTimeout(
|
|
remote.child.ready(),
|
|
5_000,
|
|
"cleanup could not obtain late PID",
|
|
).catch(() => undefined);
|
|
remote.child.kill("SIGKILL");
|
|
await withTestTimeout(remote.result, 5_000, "fixture process did not settle").catch(
|
|
() => undefined,
|
|
);
|
|
await withTestTimeout(
|
|
remote.child.waitForClose(),
|
|
5_000,
|
|
"fixture child did not close",
|
|
);
|
|
}),
|
|
);
|
|
failures.push(
|
|
...cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])),
|
|
);
|
|
} catch (error) {
|
|
failures.push(error);
|
|
}
|
|
try {
|
|
await broker.close();
|
|
} catch (error) {
|
|
failures.push(error);
|
|
}
|
|
try {
|
|
await withTestTimeout(
|
|
Promise.allSettled(invocations),
|
|
5_000,
|
|
"registered invocations did not settle",
|
|
);
|
|
} catch (error) {
|
|
failures.push(error);
|
|
}
|
|
}
|
|
if (failures.length > 0) {
|
|
throw new AggregateError(failures, "registered cancellation fixture failed");
|
|
}
|
|
});
|
|
});
|