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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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