Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cc",
"version": "1.8.0",
"version": "1.8.1",
"description": "Claude Code Plugin for Codex. Run reviews, tracked tasks, and independent Codex-Claude design or research workflows.",
"author": {
"name": "CBEPX",
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

## v1.8.1

- Give ordinary current-session marker writes a two-second lock deadline instead of failing after three collisions. Preserve live-owner checks and conditional writes. Hook callers with explicit deadlines retain their retry limit and deadline. The default deadline also bounds lock identity probes and reports `ELOCKTIMEOUT` on expiry instead of `ELOCKBUSY`.
- Wait for detached advisor task and review workers to finish before test fixture cleanup. Preserve the advisor assertions and use bounded cleanup retries for transient filesystem errors.
- Correct the Darwin integration fixture to wait for a positive child PID before it releases the parent identity lookup. A running worker with a null child PID is not a published child. Runtime behavior is unchanged.
- Publish the advisor and reliability changes from v1.8.0. The v1.8.0 tag remains for provenance, but its Release was not published.


## v1.8.0

### Added
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cc-plugin-codex",
"version": "1.8.0",
"version": "1.8.1",
"description": "Claude Code Plugin for Codex (CBEPX fork)",
"type": "module",
"author": {
Expand Down
4 changes: 2 additions & 2 deletions scripts/lib/mcp-capabilities.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ function stdioProbe(config, timeoutMs) {
params: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "cc-plugin-codex", version: "1.8.0" },
clientInfo: { name: "cc-plugin-codex", version: "1.8.1" },
},
});
});
Expand Down Expand Up @@ -394,7 +394,7 @@ async function httpProbe(config, timeoutMs) {
params: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "cc-plugin-codex", version: "1.8.0" },
clientInfo: { name: "cc-plugin-codex", version: "1.8.1" },
},
}, null, deadline);
if (initialized.statusCode === 401 || initialized.statusCode === 403) {
Expand Down
10 changes: 8 additions & 2 deletions scripts/lib/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,11 @@ export function setCurrentSession(cwd, sessionId, options = {}) {
updatedAt: nowIso(),
});
return true;
}, options.lock);
}, {
deadlineAt: performance.now() + 2_000,
retryUntilDeadline: !Number.isFinite(options.lock?.deadlineAt),
...options.lock,
});
}

function readCurrentSessionPayload(cwd) {
Expand Down Expand Up @@ -1029,7 +1033,9 @@ function acquireJobLock(lockFile, options = {}) {
attempt -= 1;
continue;
}
if (err.code === "EEXIST" && attempt < CAS_MAX_RETRIES - 1) {
// Ordinary marker writes can wait through a burst of concurrent launches.
if (err.code === "EEXIST" &&
((options.retryUntilDeadline && Number.isFinite(options.deadlineAt)) || attempt < CAS_MAX_RETRIES - 1)) {
let delay =
CAS_RETRY_DELAY_MS + Math.random() * CAS_RETRY_DELAY_MS;
const remaining = remainingLockDeadlineMs(options);
Expand Down
12 changes: 6 additions & 6 deletions stryker.shard.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ const shards = {
mutate: [
// Persistence lifecycle, session lookup, and terminal job transitions.
"scripts/lib/state.mjs:87-108",
"scripts/lib/state.mjs:177-298",
"scripts/lib/state.mjs:330-380",
"scripts/lib/state.mjs:457-843",
"scripts/lib/state.mjs:892-1065",
"scripts/lib/state.mjs:1131-1196",
"scripts/lib/state.mjs:1202-1248",
"scripts/lib/state.mjs:177-302",
"scripts/lib/state.mjs:334-384",
"scripts/lib/state.mjs:461-847",
"scripts/lib/state.mjs:896-1071",
"scripts/lib/state.mjs:1137-1202",
"scripts/lib/state.mjs:1208-1254",
],
},
"tracked-jobs": {
Expand Down
23 changes: 18 additions & 5 deletions tests/advisor-controls.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { afterEach, describe, it } from "node:test";
import { fileURLToPath } from "node:url";

import * as cli from "../scripts/lib/claude-cli.mjs";
import { isProcessAlive } from "../scripts/lib/process.mjs";

const PROJECT_ROOT = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
const COMPANION = path.join(PROJECT_ROOT, "scripts", "claude-companion.mjs");
Expand Down Expand Up @@ -60,7 +61,7 @@ function git(cwd, ...args) {

function createEnvironment() {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-advisor-controls-"));
cleanups.push(() => fs.rmSync(rootDir, { recursive: true, force: true }));
cleanups.push(() => fs.rmSync(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }));
const homeDir = path.join(rootDir, "home");
const binDir = path.join(rootDir, "bin");
const workspaceDir = path.join(rootDir, "workspace");
Expand Down Expand Up @@ -157,6 +158,20 @@ function storedJobs(testEnv) {
.sort((a, b) => String(a.job.createdAt).localeCompare(String(b.job.createdAt)));
}

function waitForDetachedJob(testEnv, launch) {
const { jobId } = JSON.parse(launch.stdout);
const workerPid = storedJobs(testEnv).find(({ job }) => job.id === jobId)?.job.workerPid;
const terminal = runCompanion(testEnv, ["status", jobId, "--wait", "--wait-timeout-ms", "30000", "--json"]);
assert.equal(terminal.status, 0, terminal.stderr);
assert.equal(JSON.parse(terminal.stdout).job.status, "completed");
// Terminal state can precede the detached worker's final log close.
const deadline = Date.now() + 30_000;
while (Number.isInteger(workerPid) && workerPid > 0 && isProcessAlive(workerPid)) {
assert.ok(Date.now() < deadline, "detached worker did not exit before cleanup");
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
}
}

describe("advisor policy primitives", () => {
it("normalizes omission, keywords, aliases, and model ids", () => {
assert.equal(cli.normalizeAdvisorPolicy(undefined), undefined);
Expand Down Expand Up @@ -334,8 +349,7 @@ describe("task advisor policy", () => {
task(testEnv, ["--fresh", "--advisor", "Fable"]);
const background = runCompanion(testEnv, [...taskArgs(testEnv, ["--fresh", "--background", "--advisor", "off"]), "--json"]);
assert.equal(background.status, 0, background.stderr);
const deadline = Date.now() + 20_000;
while (turnsOf(testEnv).length < 2 && Date.now() < deadline) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
waitForDetachedJob(testEnv, background);
const requests = storedJobs(testEnv).map(({ job }) => job.request?.advisor);
assert.deepEqual(requests, ["fable", "off"]);
assert.equal(turnsOf(testEnv)[1].disable, "1", "the detached worker must forward the stored policy");
Expand Down Expand Up @@ -477,8 +491,7 @@ describe("review advisor policy", () => {
const testEnv = createEnvironment();
const result = runCompanion(testEnv, ["adversarial-review", "--scope", "working-tree", "--background", "--advisor", "off", "--json"]);
assert.equal(result.status, 0, result.stderr);
const deadline = Date.now() + 30_000;
while (turnsOf(testEnv).length < 1 && Date.now() < deadline) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
waitForDetachedJob(testEnv, result);
assert.equal(storedJobs(testEnv)[0].job.request.advisor, "off");
assert.equal(turnsOf(testEnv)[0].disable, "1");
});
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/claude-companion.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -848,7 +848,7 @@ function createParentIdentityBarrier(testEnv) {
while (Date.now() < deadline) {
const job = fs.readdirSync(jobsDir).filter(name => name.endsWith(".json"))
.map(name => JSON.parse(fs.readFileSync(path.join(jobsDir, name), "utf8")))
.find(job => job.workerPid === workerPid && job.status === "running" && job.pid !== workerPid);
.find(job => job.workerPid === workerPid && job.status === "running" && Number.isInteger(job.pid) && job.pid > 0 && job.pid !== workerPid);
if (job) {
fs.writeFileSync(${JSON.stringify(observed)}, JSON.stringify(job));
return original.call(this, command, args, ...rest);
Expand Down
12 changes: 6 additions & 6 deletions tests/mutation-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@ const expectations = [
["scripts/lib/process.mjs:202-385", ["terminateProcessTreeIfIdentityMatches"]],
["scripts/lib/process.mjs:408-543", ["getProcessIdentity", "getSpawnedProcessIdentity", "validateProcessIdentity", "isProcessAlive", "isProcessGroupAlive"]],
["scripts/lib/state.mjs:87-108", ["resolveWorkspaceHash", "ensureStateDir"]],
["scripts/lib/state.mjs:177-298", ["setCurrentSession", "getCurrentSession", "getUsableCurrentSession", "clearCurrentSession", "markSessionCleanupPending", "listPendingSessionCleanups", "clearSessionCleanupPending"]],
["scripts/lib/state.mjs:330-380", ["writeJobFile", "normalizeStoredJob"]],
["scripts/lib/state.mjs:457-843", ["mostRecentJobTimestamp", "isWithinReapGracePeriod", "reapStaleJobs"]],
["scripts/lib/state.mjs:892-1065", ["unlinkLockIfUnchanged", "remainingLockDeadlineMs", "lockProcessTimeout", "recoverStaleLock", "acquireJobLock", "releaseJobLock"]],
["scripts/lib/state.mjs:1131-1196", ["casJobStatus", "transitionJob", "writeAtomic", "withStateFileLock"]],
["scripts/lib/state.mjs:1202-1248", ["cleanupOldJobs"]],
["scripts/lib/state.mjs:177-302", ["setCurrentSession", "getCurrentSession", "getUsableCurrentSession", "clearCurrentSession", "markSessionCleanupPending", "listPendingSessionCleanups", "clearSessionCleanupPending"]],
["scripts/lib/state.mjs:334-384", ["writeJobFile", "normalizeStoredJob"]],
["scripts/lib/state.mjs:461-847", ["mostRecentJobTimestamp", "isWithinReapGracePeriod", "reapStaleJobs"]],
["scripts/lib/state.mjs:896-1071", ["unlinkLockIfUnchanged", "remainingLockDeadlineMs", "lockProcessTimeout", "recoverStaleLock", "acquireJobLock", "releaseJobLock"]],
["scripts/lib/state.mjs:1137-1202", ["casJobStatus", "transitionJob", "writeAtomic", "withStateFileLock"]],
["scripts/lib/state.mjs:1208-1254", ["cleanupOldJobs"]],
["scripts/lib/tracked-jobs.mjs:30-78", ["transitionTrackedJob", "isStatusReaperFailure", "transitionTrackedJobTerminal"]],
["scripts/lib/tracked-jobs.mjs:310-434", ["validOwnerId", "resolveSessionOwner", "createJobRecord", "createJobProgressUpdater"]],
["scripts/lib/tracked-jobs.mjs:453-586", ["runTrackedJob"]],
Expand Down
43 changes: 43 additions & 0 deletions tests/state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1401,6 +1401,49 @@ describe("current session marker", () => {
assert.equal(getCurrentSession(repoDir), "session-a");
});

it("waits for a live marker writer beyond three collisions without stealing its lock", async () => {
setCurrentSession(repoDir, "session-a");
const holder = spawn(process.execPath, ["--input-type=module", "-e", `
import fs from "node:fs";
import { withStateFileLock } from ${JSON.stringify(STATE_MODULE_URL)};
withStateFileLock(${JSON.stringify(markerFile())}, () => {
process.send("locked");
const releaseRequest = ${JSON.stringify(path.join(repoDir, "release-holder"))};
const deadline = Date.now() + 5_000;
while (!fs.existsSync(releaseRequest)) {
if (Date.now() >= deadline) throw new Error("Missing release request");
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 800);
fs.writeFileSync(${JSON.stringify(path.join(repoDir, "holder-finished"))}, "done");
}, { skipLockOwnerIdentity: true });
`], { stdio: ["ignore", "ignore", "inherit", "ipc"] });
const exited = new Promise((resolve) => holder.once("exit", resolve));
try {
await new Promise((resolve, reject) => {
holder.once("message", resolve);
holder.once("error", reject);
holder.once("exit", () => reject(new Error("Lock holder exited before readiness")));
});
assert.throws(() => setCurrentSession(repoDir, "hook-session", {
lock: { deadlineAt: performance.now() + 2_000, skipLockOwnerIdentity: true },
}), isLockBusyError);
fs.writeFileSync(path.join(repoDir, "release-holder"), "release");
assert.equal(setCurrentSession(repoDir, "session-b", {
lock: { skipLockOwnerIdentity: true },
shouldWrite: (current) => {
assert.ok(fs.existsSync(path.join(repoDir, "holder-finished")), "must wait for the live owner");
return current?.sessionId === "session-a";
},
}), true);
assert.equal(getCurrentSession(repoDir), "session-b");
assert.equal(fs.existsSync(`${markerFile()}.lock`), false);
} finally {
holder.kill();
await exited;
}
});

it("serializes marker writes and owner-checked clears on the marker lock", () => {
setCurrentSession(repoDir, "session-b");
const before = fs.readFileSync(markerFile(), "utf8");
Expand Down
Loading