can1357/oh-my-pi · error · ToolError

Bulk directive lists conflict #

Error message

Bulk directive lists conflict #${id} twice — each id may appear once.

What it means

Inside a `conflict://*` bulk-resolution block, each conflict id may carry exactly one `<id>: @side` directive. The parser throws when the same id appears in two directive lines, because a conflict can only be resolved to one side per pass.

Solutions

  1. Remove the duplicate directive line so each id appears once
  2. Decide one side per conflict and keep only the intended '@ours'/'@theirs'/'@base'/'@both' line

Example fix

// before
"1: @ours\n2: @theirs\n2: @ours"
// after
"1: @ours\n2: @theirs"
Defensive patterns

Strategy: validation

Validate before calling

function assertUniqueIds(block: string): void {
  const seen = new Set<number>();
  for (const line of block.split("\n")) {
    const m = line.match(/^\s*(\d+):\s*@(ours|theirs|base|both)\s*$/);
    if (!m) continue;
    const id = Number(m[1]);
    if (seen.has(id)) throw new Error(`duplicate conflict id ${id}`);
    seen.add(id);
  }
}

Try / catch

try {
  await write({ path: "conflict://*", content: block });
} catch (e) {
  if (e instanceof ToolError && e.message.includes("twice")) {
    const id = Number(e.message.match(/#(\d+)/)?.[1]);
    block = dedupeDirective(block, id);
    return write({ path: "conflict://*", content: block });
  }
  throw e;
}

Prevention

When it happens

Trigger: A per-id bulk block passed to write targeting conflict://* that contains duplicate lines such as '3: @ours' appearing twice for the same id.

Common situations: Hand-assembled or model-generated bulk resolution blocks where a line was duplicated while editing sides.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/c9f4652555102807. Report an issue: GitHub.

Appendix: source

Thrown at packages/coding-agent/src/tools/write.ts:254

 * block and still reported success. Per-id bulk is token-only; literal or
 * multi-line replacements must go through individual `conflict://<N>` writes.
 */
function parseBulkDirectives(content: string): Map<number, string> | null {
	const map = new Map<number, string>();
	const stray: string[] = [];
	let sawDirective = false;
	for (const raw of content.split("\n")) {
		const line = raw.trim();
		if (line.length === 0) continue;
		const match = line.match(BULK_DIRECTIVE_RE);
		if (!match) {
			stray.push(line);
			continue;
		}
		sawDirective = true;
		const id = Number.parseInt(match[1], 10);
		if (map.has(id)) {
			throw new ToolError(`Bulk directive lists conflict #${id} twice — each id may appear once.`);
		}
		map.set(id, match[2]);
	}
	// No directive lines at all → not a per-id block; caller uses uniform mode.
	if (!sawDirective) return null;
	if (stray.length > 0) {
		const sample = stray[0]!;
		const tokenHint = BULK_DIRECTIVE_HEAD_RE.test(sample)
			? `Per-id bulk only accepts the tokens @ours/@theirs/@base/@both — one side per id, single line. `
			: "";
		throw new ToolError(
			`Malformed \`conflict://*\` per-id block: ${stray.length} line(s) are not \`<id>: @side\` directives (first: \`${truncateDirectiveLine(sample)}\`). ` +
				tokenHint +
				`Literal or multi-line replacement content isn't supported in a per-id block — resolve those blocks with individual \`write({ path: "conflict://<N>", content })\` calls (you can issue several at once). ` +
				`For a pure pick-a-side pass, make every non-empty line \`<id>: @ours\` (or @theirs/@base/@both).`,
		);
	}
	return map;

View on GitHub (pinned to 9690622007)