diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 5141b63..aec9efc 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -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", diff --git a/CHANGELOG.md b/CHANGELOG.md index e19c6cb..a9efc93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 820e0bc..9e84ad8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-plugin-codex", - "version": "1.8.0", + "version": "1.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-plugin-codex", - "version": "1.8.0", + "version": "1.8.1", "license": "Apache-2.0", "bin": { "cc-plugin-codex": "scripts/installer-cli.mjs" diff --git a/package.json b/package.json index a249ce2..e32f06f 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/lib/mcp-capabilities.mjs b/scripts/lib/mcp-capabilities.mjs index e476ad8..e170dc6 100644 --- a/scripts/lib/mcp-capabilities.mjs +++ b/scripts/lib/mcp-capabilities.mjs @@ -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" }, }, }); }); @@ -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) { diff --git a/scripts/lib/state.mjs b/scripts/lib/state.mjs index 7e118ab..56204d5 100644 --- a/scripts/lib/state.mjs +++ b/scripts/lib/state.mjs @@ -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) { @@ -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); diff --git a/stryker.shard.config.mjs b/stryker.shard.config.mjs index 199c5ed..84b81a7 100644 --- a/stryker.shard.config.mjs +++ b/stryker.shard.config.mjs @@ -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": { diff --git a/tests/advisor-controls.test.mjs b/tests/advisor-controls.test.mjs index 25aa839..9161267 100644 --- a/tests/advisor-controls.test.mjs +++ b/tests/advisor-controls.test.mjs @@ -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"); @@ -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"); @@ -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); @@ -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"); @@ -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"); }); diff --git a/tests/integration/claude-companion.test.mjs b/tests/integration/claude-companion.test.mjs index 474f2be..dbeb6bd 100644 --- a/tests/integration/claude-companion.test.mjs +++ b/tests/integration/claude-companion.test.mjs @@ -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); diff --git a/tests/mutation-config.test.mjs b/tests/mutation-config.test.mjs index 4ffc5ce..77869f3 100644 --- a/tests/mutation-config.test.mjs +++ b/tests/mutation-config.test.mjs @@ -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"]], diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 48c788a..176a150 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -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");