BigPizzaV3/CodexPlusPlus · error · Error

本批改变了共同目标根节点

Error message

本批改变了共同目标根节点

What it means

mergeBatch enforces a single shared root: the existing root node (parent===null in state.nodes) must remain the only root after merging. This throw fires when the batch introduces a different node with parent===null or removes/re-parents the existing root so the root id changes.

Solutions

  1. Prompt the model to always keep the existing root node untouched and parent all new nodes under it
  2. Pre-sanitize output: force parent=null only on the known root id (find it in state.nodes before merging)
  3. Use the repair retry path so the batch is re-emitted with the root preserved
  4. If a full restructure is genuinely needed, plan it as an explicit re-organization flow rather than a batch edit

Example fix

// before
{n.id:'n_new_root', parent:null, ...}
// after
const rootId=state.nodes.find(n=>n.parent===null)?.id;
for(const n of value.upserts){
  if(n.parent===null && n.id!==rootId) n.parent=rootId??null;
}
Defensive patterns

Strategy: validation

Validate before calling

const rootId=state.nodes.find(n=>n.parent===null)?.id;
for(const n of value.upserts)
  if(n.parent===null&&n.id!==rootId) n.parent=rootId??null;

Type guard

function preservesRoot(upserts, rootId){ return upserts.every(n=>n.parent!==null||n.id===rootId); }

Try / catch

try { mergeBatch(value,...) } catch(e){ if(e.message==='本批改变了共同目标根节点'){ forceRootPreservation(value.upserts); retryMerge(); } else throw e; }

Prevention

When it happens

Trigger: Model output sets parent:null on a new node (creating a second root), or re-parents the existing root node to a non-null parent, so nodes.find(n=>n.parent===null).id !== oldRoot.id.

Common situations: Models that restructure the tree from scratch each batch instead of incrementally editing under the established root — common with compact-mode prompts or after conversation continuation.

Understand the failure class

Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.

Related errors


AI-assisted analysis of BigPizzaV3/CodexPlusPlus@b1ed92e5e4 (2026-09-19). Data as JSON: /api/errors/ba0a975e000ef064. Report an issue: GitHub.

Appendix: source

Thrown at tools/conversation-canvas/public/canvas.user.js:251

  for(const n of value.upserts){
    if(!n||typeof n.id!=='string'||patch.has(n.id)||(!allowed.has(n.id)&&(!n.id.startsWith(prefix)||old.has(n.id))))throw Error('本批节点 ID 无效或覆盖了未提供的节点');
    if(!Array.isArray(n.sources)||!n.sources.length||!n.sources.every(id=>parts.has(id)))throw Error('本批引用了不存在的消息片段');
    for(const field of ['title','summary','description','status'])if(typeof n[field]!=='string'||n[field].length>3000)throw Error('本批节点描述过长或无效');
    const previous=old.get(n.id),evidence=[...(previous?.evidence||[])];
    for(const id of new Set(n.sources)){covered.add(id);const {messageId,start,end,digest}=parts.get(id);evidence.push({messageId,start,end,digest});}
    const revisions=[...(previous?.revisions||[])];
    if(previous&&['summary','description','status'].some(k=>previous[k]!==n[k]))revisions.push({batch:state.batchCount,summary:previous.summary,description:previous.description,status:previous.status});
    patch.set(n.id,{...n,sources:[...new Set([...(previous?.sources||[]),...n.sources.map(id=>parts.get(id).messageId)])],evidence,revisions});
  }
  if(covered.size!==batch.length){
    const error=new Error('本批仍有未归入任务树的消息片段,进度未提交');
    error.missingPartIds=batch.filter(part=>!covered.has(part.partId)).map(part=>part.partId);
    throw error;
  }
  for(const n of patch.values())if(n.parent!==null&&!allowed.has(n.parent)&&!patch.has(n.parent)&&old.get(n.id)?.parent!==n.parent)throw Error('本批父节点未提供或不存在');
  const nodes=state.nodes.map(n=>patch.get(n.id)||n);for(const [id,n] of patch)if(!old.has(id))nodes.push(n);
  const oldRoot=state.nodes.find(n=>n.parent===null);
  if(oldRoot&&nodes.find(n=>n.parent===null)?.id!==oldRoot.id)throw Error('本批改变了共同目标根节点');
  const currentNodeId=value.currentNodeId??state.currentNodeId??null;
  validateOrganization({requestId,nodes,currentNodeId},[...new Set(nodes.flatMap(n=>n.sources))].map(id=>({id})),requestId);
  return {...state,schemaVersion:2,nodes,currentNodeId,batchCount:state.batchCount+1,done:{...state.done,...Object.fromEntries(batch.map(p=>[p.partId,true]))}};
}

// Save is an atomic durable checkpoint. A failed model call never commits its
// coverage; a restart can resume its pending side-chat turn before resubmitting.
async function organizeLong({messages,record,save,run,signal,progress=()=>{},onGraph=()=>{},compact=false}){
  const history=await prepareHistory(messages,signal);
  const partIndex=new Map(history.parts.map(part=>[part.partId,part]));
  const previous=record?.published;
  let job=record?.job;
  if(!job||!compatible(job.state.manifest,history.manifest)){
    const incremental=previous?.done&&compatible(previous.manifest,history.manifest);
    const state=incremental?{...previous,manifest:history.manifest}:{schemaVersion:2,nodes:[],currentNodeId:null,batchCount:0,done:{},manifest:history.manifest};
    job={kind:incremental||!previous?.nodes?.length?'incremental':'rebuild',state,session:{},pending:null};
  }else job={...job,state:{...job.state,manifest:history.manifest}};
  let document={version:3,published:previous??null,job};

View on GitHub (pinned to b1ed92e5e4)