{"record":{"id":"ba0a975e000ef064","repo":"BigPizzaV3/CodexPlusPlus","slug":"canvas-user-ba0a97","errorCode":null,"errorMessage":"本批改变了共同目标根节点","messagePattern":"本批改变了共同目标根节点","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"tools/conversation-canvas/public/canvas.user.js","lineNumber":251,"sourceCode":"  for(const n of value.upserts){\n    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 无效或覆盖了未提供的节点');\n    if(!Array.isArray(n.sources)||!n.sources.length||!n.sources.every(id=>parts.has(id)))throw Error('本批引用了不存在的消息片段');\n    for(const field of ['title','summary','description','status'])if(typeof n[field]!=='string'||n[field].length>3000)throw Error('本批节点描述过长或无效');\n    const previous=old.get(n.id),evidence=[...(previous?.evidence||[])];\n    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});}\n    const revisions=[...(previous?.revisions||[])];\n    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});\n    patch.set(n.id,{...n,sources:[...new Set([...(previous?.sources||[]),...n.sources.map(id=>parts.get(id).messageId)])],evidence,revisions});\n  }\n  if(covered.size!==batch.length){\n    const error=new Error('本批仍有未归入任务树的消息片段，进度未提交');\n    error.missingPartIds=batch.filter(part=>!covered.has(part.partId)).map(part=>part.partId);\n    throw error;\n  }\n  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('本批父节点未提供或不存在');\n  const nodes=state.nodes.map(n=>patch.get(n.id)||n);for(const [id,n] of patch)if(!old.has(id))nodes.push(n);\n  const oldRoot=state.nodes.find(n=>n.parent===null);\n  if(oldRoot&&nodes.find(n=>n.parent===null)?.id!==oldRoot.id)throw Error('本批改变了共同目标根节点');\n  const currentNodeId=value.currentNodeId??state.currentNodeId??null;\n  validateOrganization({requestId,nodes,currentNodeId},[...new Set(nodes.flatMap(n=>n.sources))].map(id=>({id})),requestId);\n  return {...state,schemaVersion:2,nodes,currentNodeId,batchCount:state.batchCount+1,done:{...state.done,...Object.fromEntries(batch.map(p=>[p.partId,true]))}};\n}\n\n// Save is an atomic durable checkpoint. A failed model call never commits its\n// coverage; a restart can resume its pending side-chat turn before resubmitting.\nasync function organizeLong({messages,record,save,run,signal,progress=()=>{},onGraph=()=>{},compact=false}){\n  const history=await prepareHistory(messages,signal);\n  const partIndex=new Map(history.parts.map(part=>[part.partId,part]));\n  const previous=record?.published;\n  let job=record?.job;\n  if(!job||!compatible(job.state.manifest,history.manifest)){\n    const incremental=previous?.done&&compatible(previous.manifest,history.manifest);\n    const state=incremental?{...previous,manifest:history.manifest}:{schemaVersion:2,nodes:[],currentNodeId:null,batchCount:0,done:{},manifest:history.manifest};\n    job={kind:incremental||!previous?.nodes?.length?'incremental':'rebuild',state,session:{},pending:null};\n  }else job={...job,state:{...job.state,manifest:history.manifest}};\n  let document={version:3,published:previous??null,job};","sourceCodeStart":233,"sourceCodeEnd":269,"githubUrl":"https://github.com/BigPizzaV3/CodexPlusPlus/blob/b1ed92e5e4a2d74095d4b8db5af43cef7acba9c6/tools/conversation-canvas/public/canvas.user.js#L233-L269","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Prompt the model to always keep the existing root node untouched and parent all new nodes under it","Pre-sanitize output: force parent=null only on the known root id (find it in state.nodes before merging)","Use the repair retry path so the batch is re-emitted with the root preserved","If a full restructure is genuinely needed, plan it as an explicit re-organization flow rather than a batch edit"],"exampleFix":"// before\n{n.id:'n_new_root', parent:null, ...}\n// after\nconst rootId=state.nodes.find(n=>n.parent===null)?.id;\nfor(const n of value.upserts){\n  if(n.parent===null && n.id!==rootId) n.parent=rootId??null;\n}","handlingStrategy":"validation","validationCode":"const rootId=state.nodes.find(n=>n.parent===null)?.id;\nfor(const n of value.upserts)\n  if(n.parent===null&&n.id!==rootId) n.parent=rootId??null;","typeGuard":"function preservesRoot(upserts, rootId){ return upserts.every(n=>n.parent!==null||n.id===rootId); }","tryCatchPattern":"try { mergeBatch(value,...) } catch(e){ if(e.message==='本批改变了共同目标根节点'){ forceRootPreservation(value.upserts); retryMerge(); } else throw e; }","preventionTips":["Never emit parent:null except for the established root node","Prompt the model to edit incrementally under the existing root","Before merging, assert exactly one node has parent===null and it equals the previous root id"],"tags":["validation","tree-structure","invariant"],"backgroundTag":"internal-invariant-violation","analyzedSha":"b1ed92e5e4a2d74095d4b8db5af43cef7acba9c6","analyzedAt":"2026-09-19T23:35:21.129Z","contentChangedAt":"2026-09-19T23:35:21.129Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}