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
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,10 @@ Un plafond ne peut que réduire l’effort. Le niveau retenu est le niveau annon
Les plafonds configurés s’appliquent aussi aux tours Chat Completions natifs admissibles sans effort épinglé pour le modèle. La conversion vers la valeur du fournisseur intervient lorsqu’un épinglage est appliqué ou qu’un plafond modifie la valeur ; sinon, la valeur de l’appelant natif conserve sa forme d’origine.

Pour une présentation destinée aux débutants des comportements v1, default et v2, consultez [Surfaces de sous-agents](/fr/guides/sub-agent-surface/).

## Mise à jour de la CLI pendant la synchronisation v1

Si la synchronisation avec `multiAgentMode: "v1"` doit désactiver la substitution v2 globale
de Codex, une mise à jour de la CLI Codex en cours peut refuser temporairement cette
synchronisation. Réessayez après la mise à jour. Un répertoire Codex utilisant déjà v1
peut toujours être synchronisé, car aucune commande native de fonctionnalité n'est nécessaire.
Original file line number Diff line number Diff line change
Expand Up @@ -393,3 +393,10 @@ after a compaction model switch or combo failover.
alongside the existing caps: omitted fields stay unchanged, `null` clears the map, and a map
entry set to `null` or `""` deletes only that key. Invalid combined updates leave both caps
and pins unchanged. Saving a pin does not alter the featured subagent roster.

## CLI updates during v1 synchronization

When an explicit `multiAgentMode: "v1"` synchronization must disable Codex's global v2
override, an ongoing Codex CLI update can temporarily refuse the synchronization.
Retry after the update finishes. A home already using v1 can still synchronize during
the update because no native feature command is needed.
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,10 @@ Yapılandırılmış sınırlar, model effort sabitlemesi olmayan uygun yerel Ch

v1, varsayılan ve v2 davranışının yeni başlayanlara yönelik açıklaması için [Alt
ajan yüzeyleri](/tr/guides/sub-agent-surface/) sayfasına bakın.

## V1 eşitlemesi sırasında CLI güncellemeleri

`multiAgentMode: "v1"` eşitlemesinin Codex'in genel v2 geçersiz kılmasını kapatması
gerekiyorsa, devam eden bir Codex CLI güncellemesi eşitlemeyi geçici olarak reddedebilir.
Güncelleme bittikten sonra yeniden deneyin. Zaten v1 kullanan bir Codex dizini, yerel
özellik komutu gerekmediği için güncelleme sırasında da eşitlenebilir.
9 changes: 8 additions & 1 deletion src/codex/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ async function injectCodexConfigImpl(
if (v1Reconcile) {
const reconciled = v1Reconcile.run(heldConfigWriteLock);
if (!reconciled.ok) {
throw new CodexInjectRefusal({ success: false, message: reconciled.message });
throw new CodexInjectRefusal({ success: false, message: reconciled.message, retryable: reconciled.retryable });
}
nativeInput = reconciled.content;
if (reconciled.content !== rawContent) {
Expand Down Expand Up @@ -548,6 +548,10 @@ async function injectCodexConfigImpl(
*/
let effectivePlan: CodexInjectionPlanOk = admittedPlan;

if (v1Reconcile && !await v1Reconcile.admit()) {
return { success: false, retryable: true, message: "Codex config injection refused: the CLI selection is busy. Retry after the CLI update finishes." };
}
try {
const configLock = await acquireConfigWriteLock(CODEX_CONFIG_PATH, {
timeoutMs: Math.min(options.lockTimeoutMs ?? CONFIG_WRITE_LOCK_WAIT_MS, CONFIG_WRITE_LOCK_WAIT_MS),
});
Expand Down Expand Up @@ -725,6 +729,9 @@ async function injectCodexConfigImpl(
releaseConfigWriteLock(configLock.handle);
heldConfigWriteLock = undefined;
}
} finally {
v1Reconcile?.release();
}
// Both write paths have committed, including the coordinator's post-callback checks.
// Compare against the admitted original, not post-v1-reconcile input: the native
// feature transition is part of this transaction too. Refusals/rollbacks return
Expand Down
34 changes: 30 additions & 4 deletions src/codex/inject/multi-agent-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import type { OcxConfig } from "../../types";
import { ConfigWriteDestinationChanged, type LockHandle } from "../config-write-lock";
import { CODEX_CONFIG_PATH } from "../paths";
import type { CodexCliSelectionAsyncParentWrite } from "../cli-update-selection-gate";

/**
* Reconcile the native `features.multi_agent_v2` override when an injection carries an
Expand All @@ -17,7 +18,7 @@ import { CODEX_CONFIG_PATH } from "../paths";
*/
export type InjectedV1SurfaceReconcile =
| { ok: true; content: string; changed: boolean }
| { ok: false; message: string };
| { ok: false; message: string; retryable?: boolean };

let toggleForTests: ((enabled: boolean, env: NodeJS.ProcessEnv, validateBeforeSpawn: () => void) => void) | undefined;

Expand All @@ -44,6 +45,9 @@ export interface PreparedV1SurfaceReconcile {
* only — `run()` re-checks the flag on the bytes present under the lock.
*/
readonly enabledAtPrepare: boolean;
/** Acquire before the config lock; an already-v1 home needs no selection lease. */
admit(): Promise<boolean>;
release(): void;
/**
* `heldConfigWriteLock` is the injector's own held write-lock handle: the
* transition would otherwise re-acquire the same file and refuse itself.
Expand Down Expand Up @@ -74,17 +78,39 @@ export async function prepareInjectedV1SurfaceReconcile(
toggle = (enabled, env, validate) => runCodexFeaturesCommand(enabled ? "enable" : "disable", "multi_agent_v2", env, validate);
}
const resolvedToggle = toggle;
const { beginCodexCliSelectionParentWriteAsync, CodexCliSelectionMutationBusyError } = await import("../cli-update-selection-gate");
let selection: CodexCliSelectionAsyncParentWrite | undefined;
const busy = (): InjectedV1SurfaceReconcile => ({
ok: false, retryable: true,
message: "Codex config injection refused: the v1 transition has no current CLI selection admission. Retry the injection after the CLI update finishes.",
});
return {
enabledAtPrepare,
async admit() {
if (!enabledAtPrepare) return true;
if (selection) return selection.entered;
selection = await beginCodexCliSelectionParentWriteAsync();
return selection.entered;
},
release() { selection?.exit(); },
run(heldConfigWriteLock) {
// Decide on the bytes present NOW, under the lock — the prepare-time
// answer is stale the moment another writer could have touched the file.
if (!isMultiAgentV2Enabled()) {
return { ok: true, content: readFileSync(CODEX_CONFIG_PATH, "utf-8"), changed: false };
}
const transition = transitionMultiAgentV2(false, resolvedToggle, {
...(heldConfigWriteLock !== undefined ? { heldConfigWriteLock } : {}),
});
// A prepare-time no-op may have become a transition. Refuse without a child;
// a fresh pass can acquire admission before taking the config lock.
if (!selection?.entered) return busy();
let transition: ReturnType<typeof transitionMultiAgentV2>;
try {
transition = selection.commit(() => transitionMultiAgentV2(false, resolvedToggle, {
...(heldConfigWriteLock !== undefined ? { heldConfigWriteLock } : {}),
}));
} catch (error) {
if (error instanceof CodexCliSelectionMutationBusyError) return busy();
throw error;
}
if (!transition.ok) {
if (transition.retryable === false) throw new ConfigWriteDestinationChanged(transition.error);
return {
Expand Down
8 changes: 7 additions & 1 deletion structure/codex-cli-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,13 @@ inherit an earlier async verdict. Ordinary atomic secret-file publication and na
retain their synchronous leaf contracts; the nonblocking guarantee covers recovery admission. CLI,
machine API, restart and Remote Workspace callers use the async
entry points. Runtime publication rechecks selection inputs, cache epoch, cancellation and caller
currentness after recovery. Codex feature-setting callers acquire admission before their synchronous
currentness after recovery. Automatic v1 injection uses the same async admission before its config
lock when the global v2 flag needs disabling. Its synchronous feature-transition commit permits
nested runtime-pin writes while excluding the update worker; already-v1 no-ops acquire no lease,
and a newly enabled flag without admission refuses retryably before spawning.
The injection releases admission after config commit or rollback, including config-lock acquisition
failure, before its history worker starts. The feature transition retains the shared artifact preimage.
Codex feature-setting callers acquire admission before their synchronous
transition; management keeps the lease across config-root hardening, then rechecks its mode plan and
commits in the existing no-await callback. Refusal precedes combined setting writes and returns a
retryable `codex_cli_selection_busy` response. Diagnostics classify a held async parent by its exact
Expand Down
2 changes: 1 addition & 1 deletion structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ Snapshot restore rechecks default ownership after hash matches; root `model_cont
An injection whose OpenCodex config explicitly selects the v1 multi-agent surface also
reconciles Codex's higher-precedence global `features.multi_agent_v2` override to disabled before
taking the journal baseline. It uses the same format-preserving feature transition as explicit
mode selection, and it runs inside the injection's coordinated write boundary: the transition and
mode selection with [CLI selection admission](codex-cli-update.md#worker-realm-and-the-selection-commit-window). Inside the coordinated write boundary, the transition and
the artifact commit share one preimage. A publication conflict after the toggle, or final
coordinator validation or commit failure after the artifact writes, restores the flag, config,
profile and journal while the native and config locks are held. No competing writer can land
Expand Down
100 changes: 100 additions & 0 deletions tests/codex-integration/codex-inject-v1-reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,79 @@ function runV1CoordinatorFailure(
return JSON.parse(child.stdout);
}

function runSelectionScenario(codexHome: string, ocxHome: string, scenario: string) {
const script = `
const fs = require("node:fs"), { join } = require("node:path");
const { spyOn } = require("bun:test");
const gate = require("./src/codex/cli-update-selection-gate");
const recovery = require("./src/codex/cli-update-recovery");
const features = require("./src/codex/features");
const locks = require("./src/codex/config-write-lock");
const runtime = require("./src/codex/runtime");
const { injectCodexConfig } = require("./src/codex/inject");
const { setCodexMultiAgentV2ToggleForTests } = require("./src/codex/inject/multi-agent-v2");
const scenario = process.env.TEST_SCENARIO;
const path = join(process.env.CODEX_HOME, "config.toml");
const original = 'model = "gpt-5.5"\\n[features]\\nmulti_agent_v2 = ' + (scenario === "noop" ? "false" : "true") + '\\n';
fs.writeFileSync(path, original);
let asyncProbes = 0, syncProbes = 0, toggles = 0, seenGate, nestedEntered, workerEntered;
spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmissionAsync").mockImplementation(async () => { asyncProbes++; return false; });
spyOn(recovery, "codexCliUpdateRecoveryBlocksAdmission").mockImplementation(() => { syncProbes++; throw new Error("unexpected sync recovery"); });
const worker = gate.createWorkerCommitWindow({ gate: gate.codexCliUpdateSelectionGate(), fence: gate.createCodexCliUpdateFence() });
if (scenario === "busy" || scenario === "noop") worker.tryEnter();
if (scenario === "aba") {
const inspect = features.isMultiAgentV2Enabled;
spyOn(features, "isMultiAgentV2Enabled").mockImplementationOnce(() => {
fs.writeFileSync(path, original.replace("= true", "= false"));
const value = inspect();
fs.writeFileSync(path, original);
return value;
});
}
if (scenario === "lock-failure") spyOn(locks, "acquireConfigWriteLock").mockResolvedValue({ ok: false, error: "locked" });
if (scenario === "lock-throw") spyOn(locks, "acquireConfigWriteLock").mockRejectedValue(new Error("fixture lock failure"));
if (scenario === "lost-owner") {
const acquire = locks.acquireConfigWriteLock;
spyOn(locks, "acquireConfigWriteLock").mockImplementation(async (...args) => {
const acquired = await acquire(...args);
const buffer = gate.codexCliUpdateSelectionGate();
gate.releaseCodexCliUpdateGateOwner(buffer, Atomics.load(new Int32Array(buffer), 0));
worker.tryEnter();
return acquired;
});
}
setCodexMultiAgentV2ToggleForTests(enabled => {
toggles++;
seenGate = gate.codexCliSelectionGateState();
const nested = gate.beginCodexCliSelectionParentWrite();
nestedEntered = nested.entered;
nested.exit();
workerEntered = worker.tryEnter();
if (workerEntered) worker.exit();
if (scenario === "persist") runtime.persistCodexRuntime({ command: "fixture-codex", source: "path", version: "1.0.0" });
if (scenario === "toggle-failure") throw new Error("fixture toggle failure");
fs.writeFileSync(path, fs.readFileSync(path, "utf8").replace("= true", "= " + enabled));
});
let result;
try { result = await injectCodexConfig(10100, { multiAgentMode: "v1" }); }
catch (error) { result = { threw: String(error) }; }
const after = fs.readFileSync(path, "utf8");
const beforeExitGate = gate.codexCliSelectionGateState();
worker.exit();
const runtimePath = join(process.env.OPENCODEX_HOME, "codex-runtime.json");
console.log(JSON.stringify({ result, toggles, asyncProbes, syncProbes, seenGate, nestedEntered, workerEntered,
unchanged: after === original, disabled: after.includes("multi_agent_v2 = false"), beforeExitGate,
finalGate: gate.codexCliSelectionGateState(), runtime: fs.existsSync(runtimePath) ? JSON.parse(fs.readFileSync(runtimePath, "utf8")) : null }));
`;
const child = spawnSync(process.execPath, ["--eval", script], {
cwd: repoRoot,
env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, TEST_SCENARIO: scenario },
encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000,
});
expect(child.status, child.stderr).toBe(0);
return JSON.parse(child.stdout);
}

describe("injectCodexConfig v1-surface reconcile", () => {
let codexHome: string;
let ocxHome: string;
Expand All @@ -104,6 +177,33 @@ describe("injectCodexConfig v1-surface reconcile", () => {
removeTreeWithRetry(ocxHome);
});

test.each(["busy", "aba", "lost-owner"])("selection admission refuses %s before the native toggle", scenario => {
const out = runSelectionScenario(codexHome, ocxHome, scenario);
expect(out.result).toMatchObject({ success: false, retryable: true });
expect(out).toMatchObject({ toggles: 0, unchanged: true, syncProbes: 0, finalGate: "free" });
});

test.each(["selected", "persist"])("selection admission owns the toggle and nested %s runtime writes", scenario => {
const out = runSelectionScenario(codexHome, ocxHome, scenario);
expect(out.result).toMatchObject({ success: true });
expect(out).toMatchObject({ toggles: 1, asyncProbes: 1, syncProbes: 0, seenGate: "parent_write",
nestedEntered: true, workerEntered: false, disabled: true, beforeExitGate: "free", finalGate: "free" });
if (scenario === "persist") expect(out.runtime).toMatchObject({ command: "fixture-codex", selectedVersion: "1.0.0" });
});

test("selection admission leaves an already-v1 injection available during worker commit", () => {
const out = runSelectionScenario(codexHome, ocxHome, "noop");
expect(out.result).toMatchObject({ success: true });
expect(out).toMatchObject({ toggles: 0, asyncProbes: 0, syncProbes: 0, disabled: true, beforeExitGate: "worker_commit", finalGate: "free" });
});

test.each(["lock-failure", "lock-throw", "toggle-failure"])("selection admission releases ownership after %s", scenario => {
const out = runSelectionScenario(codexHome, ocxHome, scenario);
if (scenario === "lock-throw") expect(out.result.threw).toContain("fixture lock failure");
else expect(out.result.success).toBe(false);
expect(out).toMatchObject({ asyncProbes: 1, unchanged: true, beforeExitGate: "free", finalGate: "free" });
});

test("inject does not turn on multi_agent_v2; fresh installs stay on Codex's default v1 surface until the user opts in", () => {
writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.5"\n', "utf8");

Expand Down
Loading
Loading