fix(runtime-adapters): skip rig-role managed-block injection

The rig-role managed-block regenerator pairs (target-file x spec)
without seat correlation, so multi-seat pods collide into one
CLAUDE.md/AGENTS.md body. openrig-pm + corpus-pm were behaviorally
affected; openrig-build saw the on-disk symptom only (startup.files
insulated runtime).

SHAPE 1 surgical fix per plat-architect: in claude-code-adapter and
codex-runtime-adapter, refuse mergeManagedBlock when blockId is
`rig-role`. Per-seat role content travels through the send_text
startup path instead, which preserves seat identity. Skip is logged
(structured, never silent) so the collision can't land quietly.

Adapter-layer only (both adapters + their tests). No domain, route,
or UI changes. Narrow check on the single blockId; no broader
special-case.

Tests added (6):
- projectEntry + category=guidance + effectiveId=rig-role does not
  write CLAUDE.md / AGENTS.md (each adapter)
- projectEntry with non-rig-role guidance still merges (regression,
  each adapter)
- deliverStartup guidance_merge for path=rig-role does not write
  (each adapter)

Gates: vitest 38/39 in claude+codex adapter suites (1 preexisting
unrelated Codex thread-id env fail confirmed via stash); tsc
--noEmit clean in packages/daemon.

Shape 2 (config sweep of agent.yaml rig-role references) is a
separate follow-up PR per architect scoping.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.6 committed 2026-04-13 12:43:33 -07:00
1 parent bc2c899b45
commit 3023de8112
4 files changed
+167

No files matched your search

@@ -302,6 +302,18 @@ export class ClaudeCodeAdapter implements RuntimeAdapter {
}
private mergeGuidance(targetPath: string, blockId: string, content: string): void {
// The `rig-role` managed block is authored per seat but delivered through a
// projection path that pairs (target-file × spec) without seat correlation,
// so multiple pod-mates' role bodies collide into one CLAUDE.md. The fix
// is to route per-seat content through the `send_text` startup path
// instead, which preserves seat identity. Here we refuse the merge loudly
// so the collision can't land silently. See ADR-0006.
if (blockId === "rig-role") {
console.log(
`[openrig] skip: effectiveId is rig-role, per-seat delivery via send_text path required (target=${targetPath})`
);
return;
}
mergeManagedBlock(this.fs, targetPath, blockId, content, {
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
});
@@ -242,6 +242,17 @@ export class CodexRuntimeAdapter implements RuntimeAdapter {
}
private mergeGuidance(targetPath: string, blockId: string, content: string): void {
// Mirrors Claude Code adapter: the `rig-role` managed block collides across
// pod-mates because the regenerator pairs (target-file × spec) without
// seat correlation. Per-seat role content is delivered through `send_text`
// startup instead. Refuse the merge loudly; silent skip would mask the
// collision. See ADR-0006.
if (blockId === "rig-role") {
console.log(
`[openrig] skip: effectiveId is rig-role, per-seat delivery via send_text path required (target=${targetPath})`
);
return;
}
mergeManagedBlock(this.fs, targetPath, blockId, content, {
replaceBlockIds: blockId === "openrig-start.md" ? ["using-openrig.md"] : [],
});
@@ -333,4 +333,74 @@ describe("Claude Code runtime adapter", () => {
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain("No tmux session");
});
// --- Regenerator bug repair: rig-role managed-block skip ---
//
// The rig-role managed-block injector pairs target-file × spec independently
// of seat identity, causing CLAUDE.md to receive the wrong seat's body on
// multi-seat pods. Per architect SHAPE 1: skip mergeManagedBlock when the
// block id is `rig-role`. Per-seat delivery travels via startup.files
// send_text path instead. Skip must be logged (never silent).
it("projectEntry skips rig-role guidance managed block; CLAUDE.md is not written", async () => {
const fs = mockFs({ "/agents/impl/guidance/role.md": "# You are `impl`\nTDD discipline." });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const adapter = new ClaudeCodeAdapter({ tmux: mockTmux(), fsOps: fs });
const plan: ProjectionPlan = {
runtime: "claude-code", cwd: "/project",
entries: [makeEntry({
category: "guidance", effectiveId: "rig-role", mergeStrategy: "managed_block",
absolutePath: "/agents/impl/guidance/role.md", resourcePath: "guidance/role.md",
})],
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
};
await adapter.project(plan, makeBinding());
const store = (fs as unknown as { _store: Record<string, string> })._store;
expect(store["/project/CLAUDE.md"]).toBeUndefined();
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("skip: effectiveId is rig-role")
);
logSpy.mockRestore();
});
it("projectEntry still merges non-rig-role guidance blocks (regression)", async () => {
const fs = mockFs({ "/agents/base/guidance/using-openrig.md": "# Using OpenRig\nhub guidance" });
const adapter = new ClaudeCodeAdapter({ tmux: mockTmux(), fsOps: fs });
const plan: ProjectionPlan = {
runtime: "claude-code", cwd: "/project",
entries: [makeEntry({
category: "guidance", effectiveId: "using-openrig.md", mergeStrategy: "managed_block",
absolutePath: "/agents/base/guidance/using-openrig.md", resourcePath: "guidance/using-openrig.md",
})],
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
};
await adapter.project(plan, makeBinding());
const store = (fs as unknown as { _store: Record<string, string> })._store;
expect(store["/project/CLAUDE.md"]).toContain("BEGIN RIGGED MANAGED BLOCK: using-openrig.md");
expect(store["/project/CLAUDE.md"]).toContain("hub guidance");
});
it("deliverStartup skips rig-role guidance_merge; CLAUDE.md is not written", async () => {
const fs = mockFs({ "/rig/rig-role": "# You are `impl`\nrole body" });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const adapter = new ClaudeCodeAdapter({ tmux: mockTmux(), fsOps: fs });
const file: ResolvedStartupFile = {
path: "rig-role", absolutePath: "/rig/rig-role", ownerRoot: "/rig",
deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start", "restore"],
};
const result = await adapter.deliverStartup([file], makeBinding());
expect(result.delivered).toBe(1); // skip counts as delivered (non-failure)
const store = (fs as unknown as { _store: Record<string, string> })._store;
expect(store["/project/CLAUDE.md"]).toBeUndefined();
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("skip: effectiveId is rig-role")
);
logSpy.mockRestore();
});
});
@@ -5,6 +5,7 @@ import Database from "better-sqlite3";
import { describe, it, expect, vi } from "vitest";
import { CodexRuntimeAdapter, type CodexAdapterFsOps } from "../src/adapters/codex-runtime-adapter.js";
import type { NodeBinding, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
import type { ProjectionPlan, ProjectionEntry } from "../src/domain/projection-planner.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
function mockTmux(overrides?: Partial<TmuxAdapter>): TmuxAdapter {
@@ -374,4 +375,77 @@ describe("Codex runtime adapter", () => {
expect(content.match(/\[mcp_servers\.exa\]/g)?.length ?? 0).toBe(1);
expect(content.match(/\[mcp_servers\.context7\]/g)?.length ?? 0).toBe(1);
});
// --- Regenerator bug repair: rig-role managed-block skip ---
//
// Parallel to the Claude Code adapter fix. The same rig-role seat-collision
// symptom occurs for AGENTS.md on Codex members. Per architect SHAPE 1:
// skip mergeManagedBlock when the block id is `rig-role`; log honest skip.
it("projectEntry skips rig-role guidance managed block; AGENTS.md is not written", async () => {
const fs = mockFs({ "/agents/qa/guidance/role.md": "# You are `qa`\ngate discipline." });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const adapter = new CodexRuntimeAdapter({ tmux: mockTmux(), fsOps: fs });
const plan: ProjectionPlan = {
runtime: "codex", cwd: "/project",
entries: [{
category: "guidance", effectiveId: "rig-role", mergeStrategy: "managed_block",
sourceSpec: "base", sourcePath: "/agents/qa",
resourcePath: "guidance/role.md", absolutePath: "/agents/qa/guidance/role.md",
classification: "safe_projection",
} as ProjectionEntry],
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
};
await adapter.project(plan, makeBinding());
const store = (fs as unknown as { _store: Record<string, string> })._store;
expect(store["/project/AGENTS.md"]).toBeUndefined();
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("skip: effectiveId is rig-role")
);
logSpy.mockRestore();
});
it("projectEntry still merges non-rig-role guidance blocks (regression)", async () => {
const fs = mockFs({ "/agents/base/guidance/using-openrig.md": "# Using OpenRig\nhub guidance" });
const adapter = new CodexRuntimeAdapter({ tmux: mockTmux(), fsOps: fs });
const plan: ProjectionPlan = {
runtime: "codex", cwd: "/project",
entries: [{
category: "guidance", effectiveId: "using-openrig.md", mergeStrategy: "managed_block",
sourceSpec: "base", sourcePath: "/agents/base",
resourcePath: "guidance/using-openrig.md",
absolutePath: "/agents/base/guidance/using-openrig.md",
classification: "safe_projection",
} as ProjectionEntry],
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [],
};
await adapter.project(plan, makeBinding());
const store = (fs as unknown as { _store: Record<string, string> })._store;
expect(store["/project/AGENTS.md"]).toContain("BEGIN RIGGED MANAGED BLOCK: using-openrig.md");
expect(store["/project/AGENTS.md"]).toContain("hub guidance");
});
it("deliverStartup skips rig-role guidance_merge; AGENTS.md is not written", async () => {
const fs = mockFs({ "/rig/rig-role": "# You are `qa`\nrole body" });
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const adapter = new CodexRuntimeAdapter({ tmux: mockTmux(), fsOps: fs });
const file: ResolvedStartupFile = {
path: "rig-role", absolutePath: "/rig/rig-role", ownerRoot: "/rig",
deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start", "restore"],
};
const result = await adapter.deliverStartup([file], makeBinding());
expect(result.delivered).toBe(1);
const store = (fs as unknown as { _store: Record<string, string> })._store;
expect(store["/project/AGENTS.md"]).toBeUndefined();
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("skip: effectiveId is rig-role")
);
logSpy.mockRestore();
});
});