mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
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:
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user