Skip to content

Fix Orca duplicate agent role warnings after bootstrap #149

Description

@jaegon1015-bot

Problem Situation

Orca mirrors LazyCodex agent registrations into its runtime CODEX_HOME with absolute config_file paths. LazyCodex bootstrap also materializes the same roles under the runtime agents directory, so Codex reports 12 malformed same-layer duplicate role definitions on every launch.

Reproduction Logs

CODEX_HOME=<isolated-orca-home> codex exec --ephemeral --skip-git-repo-check "Reply exactly PROBE_OK" exited 0 but emitted 12 distinct duplicate agent role name ... declared in the same config layer warnings.

The failing regression command was:

node --test --test-name-pattern='Orca mirror' test/bootstrap-setup.test.mjs

Before the fix it failed with AssertionError [ERR_ASSERTION]: Missing expected rejection. because agents/explorer.toml was still materialized beside Orca's explicit explorer registration.

Root Cause

linkBundledAgentsStep staged and linked every bundled agent before updateConfigStep filtered foreign registrations. The config filter prevented a second TOML block, but it did not prevent Codex directory discovery from loading the runtime-local file with the same embedded role name.

The warning list exactly matched the intersection between Orca's explicit agent registrations and LazyCodex-managed runtime TOMLs. A configured-only role without a runtime-local copy did not warn.

Verified Fix

Filter foreign explicit registrations before staging, remove stale LazyCodex-managed runtime copies for those roles, and let the existing explicit config remain authoritative. The installed-agent manifest now contains only roles that were actually materialized. This is idempotent and preserves the normal no-foreign-registration path.

diff --git a/plugins/omo/components/bootstrap/dist/cli.js b/plugins/omo/components/bootstrap/dist/cli.js
index 8a81451..1d1bb2c 100755
--- a/plugins/omo/components/bootstrap/dist/cli.js
+++ b/plugins/omo/components/bootstrap/dist/cli.js
@@ -3543,7 +3543,10 @@ async function linkBundledAgentsStep(options) {
   const agentsTarget = join21(options.codexHome, "agents");
   try {
     const stageRoot = join21(options.pluginData, "bootstrap", "agents-stage");
-    await stageBundledAgents(options.pluginRoot, stageRoot);
+    const existingConfig = await readConfigIfPresent(join21(options.codexHome, "config.toml"));
+    const foreignAgentFiles = await stageBundledAgents(options.pluginRoot, stageRoot, existingConfig);
+    for (const agentFile of foreignAgentFiles)
+      await rm10(join21(agentsTarget, agentFile), { force: true });
     const preservedReasoning = await capturePreservedAgentReasoning({ codexHome: options.codexHome });
     const preservedServiceTier = await capturePreservedAgentServiceTier({ codexHome: options.codexHome });
     const linked = await linkCachedPluginAgents({
@@ -3567,9 +3570,10 @@ async function linkBundledAgentsStep(options) {
     };
   }
 }
-async function stageBundledAgents(pluginRoot, stageRoot) {
+async function stageBundledAgents(pluginRoot, stageRoot, existingConfig) {
   await rm10(stageRoot, { force: true, recursive: true });
   await mkdir7(stageRoot, { recursive: true });
+  const foreignAgentFiles = [];
   const componentsRoot = join21(pluginRoot, "components");
   for (const componentName of await directoryNames(componentsRoot)) {
     const agentsDir = join21(componentsRoot, componentName, "agents");
@@ -3579,9 +3583,15 @@ async function stageBundledAgents(pluginRoot, stageRoot) {
     const stagedAgentsDir = join21(stageRoot, "components", componentName, "agents");
     await mkdir7(stagedAgentsDir, { recursive: true });
     for (const agentFile of agentFiles) {
+      const agentConfig = { configFile: `./agents/${agentFile}`, name: agentNameFromToml3(agentFile) };
+      if (hasForeignAgentRegistration(existingConfig, agentConfig)) {
+        foreignAgentFiles.push(agentFile);
+        continue;
+      }
       await copyFile2(join21(agentsDir, agentFile), join21(stagedAgentsDir, agentFile));
     }
   }
+  return foreignAgentFiles;
 }
 async function updateConfigStep(options, inputs, degraded) {
   const configPath = join21(options.codexHome, "config.toml");
diff --git a/plugins/omo/components/bootstrap/src/agent-staging.ts b/plugins/omo/components/bootstrap/src/agent-staging.ts
new file mode 100644
index 0000000..b97b616
--- /dev/null
+++ b/plugins/omo/components/bootstrap/src/agent-staging.ts
@@ -0,0 +1,56 @@
+import { copyFile, mkdir, readdir, rm } from "node:fs/promises";
+import { join } from "node:path";
+
+import { hasForeignAgentRegistration } from "../../../../src/install/codex-config-agents.ts";
+
+export async function stageBundledAgents(
+	pluginRoot: string,
+	stageRoot: string,
+	existingConfig: string,
+): Promise<readonly string[]> {
+	await rm(stageRoot, { force: true, recursive: true });
+	await mkdir(stageRoot, { recursive: true });
+	const foreignAgentFiles: string[] = [];
+	const componentsRoot = join(pluginRoot, "components");
+	for (const componentName of await directoryNames(componentsRoot)) {
+		const agentsDir = join(componentsRoot, componentName, "agents");
+		const agentFiles = (await fileNames(agentsDir)).filter((name) => name.endsWith(".toml"));
+		if (agentFiles.length === 0) continue;
+		const stagedAgentsDir = join(stageRoot, "components", componentName, "agents");
+		await mkdir(stagedAgentsDir, { recursive: true });
+		for (const agentFile of agentFiles) {
+			const agentConfig = { configFile: `./agents/${agentFile}`, name: agentNameFromToml(agentFile) };
+			if (hasForeignAgentRegistration(existingConfig, agentConfig)) {
+				foreignAgentFiles.push(agentFile);
+				continue;
+			}
+			await copyFile(join(agentsDir, agentFile), join(stagedAgentsDir, agentFile));
+		}
+	}
+	return foreignAgentFiles;
+}
+
+export function agentNameFromToml(fileName: string): string {
+	return fileName.endsWith(".toml") ? fileName.slice(0, -".toml".length) : fileName;
+}
+
+async function directoryNames(root: string): Promise<string[]> {
+	return entryNames(root, (entry) => entry.isDirectory());
+}
+
+async function fileNames(root: string): Promise<string[]> {
+	return entryNames(root, (entry) => entry.isFile());
+}
+
+async function entryNames(root: string, keep: (entry: { isDirectory(): boolean; isFile(): boolean }) => boolean): Promise<string[]> {
+	try {
+		const entries = await readdir(root, { withFileTypes: true });
+		return entries
+			.filter((entry) => keep(entry))
+			.map((entry) => entry.name)
+			.sort();
+	} catch (error) {
+		if (error instanceof Error && "code" in error && error.code === "ENOENT") return [];
+		throw error;
+	}
+}
diff --git a/plugins/omo/components/bootstrap/src/setup.ts b/plugins/omo/components/bootstrap/src/setup.ts
index 1a03421..a343824 100644
--- a/plugins/omo/components/bootstrap/src/setup.ts
+++ b/plugins/omo/components/bootstrap/src/setup.ts
@@ -1,4 +1,4 @@
-import { copyFile, mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
+import { readFile, rm, stat } from "node:fs/promises";
 import { join } from "node:path";
 
 // These relative imports resolve at BUILD time in the monorepo; esbuild
@@ -17,6 +17,7 @@ import { trustedHookStatesForPlugin } from "../../../../src/install/codex-hook-t
 import { resolveCodexInstallerBinDir } from "../../../../src/install/codex-installer-bin-dir.ts";
 import { prepareGitBashForInstall } from "../../../../src/install/git-bash.ts";
 import type { CodexAgentConfig, GitBashResolution } from "../../../../src/install/types.ts";
+import { agentNameFromToml, stageBundledAgents } from "./agent-staging.ts";
 import { appendBootstrapLog, BOOTSTRAP_DOCTOR_HINT } from "./worker.ts";
 import type { BootstrapDegradedEntry, BootstrapStepOutcome } from "./worker.ts";
 
@@ -91,7 +92,9 @@ async function linkBundledAgentsStep(options: WorkerSetupOptions): Promise<Agent
 		// first: bootstrap must never persist anything under PLUGIN_ROOT (the
 		// Codex-managed marketplace cache).
 		const stageRoot = join(options.pluginData, "bootstrap", "agents-stage");
-		await stageBundledAgents(options.pluginRoot, stageRoot);
+		const existingConfig = await readConfigIfPresent(join(options.codexHome, "config.toml"));
+		const foreignAgentFiles = await stageBundledAgents(options.pluginRoot, stageRoot, existingConfig);
+		for (const agentFile of foreignAgentFiles) await rm(join(agentsTarget, agentFile), { force: true });
 		const preservedReasoning = await capturePreservedAgentReasoning({ codexHome: options.codexHome });
 		const preservedServiceTier = await capturePreservedAgentServiceTier({ codexHome: options.codexHome });
 		const linked = await linkCachedPluginAgents({
@@ -118,22 +121,6 @@ async function linkBundledAgentsStep(options: WorkerSetupOptions): Promise<Agent
 	}
 }
 
-async function stageBundledAgents(pluginRoot: string, stageRoot: string): Promise<void> {
-	await rm(stageRoot, { force: true, recursive: true });
-	await mkdir(stageRoot, { recursive: true });
-	const componentsRoot = join(pluginRoot, "components");
-	for (const componentName of await directoryNames(componentsRoot)) {
-		const agentsDir = join(componentsRoot, componentName, "agents");
-		const agentFiles = (await fileNames(agentsDir)).filter((name) => name.endsWith(".toml"));
-		if (agentFiles.length === 0) continue;
-		const stagedAgentsDir = join(stageRoot, "components", componentName, "agents");
-		await mkdir(stagedAgentsDir, { recursive: true });
-		for (const agentFile of agentFiles) {
-			await copyFile(join(agentsDir, agentFile), join(stagedAgentsDir, agentFile));
-		}
-	}
-}
-
 async function updateConfigStep(
 	options: WorkerSetupOptions,
 	inputs: { agentConfigs: readonly CodexAgentConfig[]; gitBashEnabled: boolean },
@@ -147,7 +134,7 @@ async function updateConfigStep(
 		// for such a role would collide with the mirrored entry once Codex
 		// discovers <codexHome>/agents/<name>.toml (two different file paths for
 		// one role name -> upstream warning), so foreign pre-existing blocks are
-		// left untouched; directory discovery still loads the linked toml.
+		// left untouched and their runtime-local copies are excluded from staging.
 		const existingConfig = await readConfigIfPresent(configPath);
 		const agentConfigs = inputs.agentConfigs.filter(
 			(agentConfig) => !hasForeignAgentRegistration(existingConfig, agentConfig),
@@ -268,31 +255,6 @@ async function stampGitBashEnvStep(options: WorkerSetupOptions, degraded: Bootst
 	}
 }
 
-async function directoryNames(root: string): Promise<string[]> {
-	return entryNames(root, (entry) => entry.isDirectory());
-}
-
-async function fileNames(root: string): Promise<string[]> {
-	return entryNames(root, (entry) => entry.isFile());
-}
-
-async function entryNames(root: string, keep: (entry: { isDirectory(): boolean; isFile(): boolean }) => boolean): Promise<string[]> {
-	try {
-		const entries = await readdir(root, { withFileTypes: true });
-		return entries
-			.filter((entry) => keep(entry))
-			.map((entry) => entry.name)
-			.sort();
-	} catch (error) {
-		if (error instanceof Error && "code" in error && error.code === "ENOENT") return [];
-		throw error;
-	}
-}
-
-function agentNameFromToml(fileName: string): string {
-	return fileName.endsWith(".toml") ? fileName.slice(0, -".toml".length) : fileName;
-}
-
 function errorMessage(error: unknown): string {
 	return error instanceof Error ? error.message : String(error);
 }
diff --git a/plugins/omo/test/bootstrap-setup.test.mjs b/plugins/omo/test/bootstrap-setup.test.mjs
index 4ef3311..3d8b298 100644
--- a/plugins/omo/test/bootstrap-setup.test.mjs
+++ b/plugins/omo/test/bootstrap-setup.test.mjs
@@ -127,7 +127,7 @@ test("#given a completed first run #when the worker setup runs again #then confi
 	});
 });
 
-test("#given a config.toml that already declares [agents.explorer] at a different path (Orca mirror) #when the worker setup runs #then no second colliding registration is added for that role", async () => {
+test("#given a config.toml that already declares [agents.explorer] at a different path (Orca mirror) #when the worker setup runs #then the mirrored role is not also linked into the runtime agents directory", async () => {
 	await withSetupFixture(async (fixture) => {
 		const orcaMirrorPath = "/orca-mirrored-home/.codex/agents/explorer.toml";
 		await writeFile(
@@ -149,11 +149,15 @@ test("#given a config.toml that already declares [agents.explorer] at a differen
 			"no colliding ./agents registration for the mirrored role",
 		);
 		assert.match(config, /\[agents\.metis\]\nconfig_file = "\.\/agents\/metis\.toml"/);
-		assert.equal(
-			await readFile(join(fixture.codexHome, "agents", "explorer.toml"), "utf8"),
-			BUNDLED_EXPLORER_TOML,
-			"the linked toml is still staged for Codex directory discovery",
+		await assert.rejects(() => stat(join(fixture.codexHome, "agents", "explorer.toml")), { code: "ENOENT" });
+		assert.equal(await readFile(join(fixture.codexHome, "agents", "metis.toml"), "utf8"), BUNDLED_METIS_TOML);
+		const manifest = JSON.parse(
+			await readFile(join(fixture.pluginData, "bootstrap", "agents-stage", ".installed-agents.json"), "utf8"),
 		);
+		assert.deepEqual(manifest.agents, [join(fixture.codexHome, "agents", "metis.toml")]);
+		await runWorkerSetup(setupOptions(fixture));
+		assert.equal(await readConfig(fixture), config);
+		await assert.rejects(() => stat(join(fixture.codexHome, "agents", "explorer.toml")), { code: "ENOENT" });
 	});
 });
 

Verification

  • RED: Orca mirror regression failed before the implementation because the duplicate runtime TOML still existed.
  • GREEN: node --test --test-name-pattern='Orca mirror' test/bootstrap-setup.test.mjs -> 1 passed, 0 failed.
  • Regression: node --test test/bootstrap-setup.test.mjs -> 13 passed, 0 failed.
  • Component build: node components/bootstrap/scripts/build.mjs -> exit 0 using the committed standalone bootstrap dist.
  • Manual QA: patched bootstrap against an isolated Orca-shaped CODEX_HOME changed runtime agent TOMLs from 12 to 0; real codex exec --ephemeral exited 0 with 0 duplicate-role warnings and returned the expected response.
  • Live QA: the patched bootstrap was applied to the affected Orca account; a fresh Codex process returned the expected response with 0 duplicate-role warnings.

This fix was debugged, implemented, and verified with LazyCodex.
Tag: lazycodex-generated

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions