BigPizzaV3/CodexPlusPlus · error · Error

历史消息所属轮次不匹配

Error message

历史消息所属轮次不匹配

What it means

Every item row returned for a turn is expected to belong to the requested turn; when a row carries a turnId that is non-null and different from turn.id, the script throws because mixing items across turns would corrupt the reconstructed history order.

Solutions

  1. Update canvas.user.js to match the installed Codex version.
  2. Stop other sessions writing to the thread, reload, and retry history import.
  3. Clear the partial cache (`${key}:partial`) and re-run pagination from scratch.
  4. Soften the check: skip mismatched rows with a warning instead of aborting the whole import.

Example fix

// before
if(row.turnId!=null&&row.turnId!==turn.id)throw Error('历史消息所属轮次不匹配');
// after
if(row.turnId!=null&&row.turnId!==turn.id){console.warn('skip foreign row',row.turnId,turn.id);continue;}
Defensive patterns

Strategy: validation

Validate before calling

const foreign=page.data.filter(r=>r?.turnId!=null&&r.turnId!==turn.id);
if(foreign.length)console.warn('rows from other turns will be skipped',foreign.length);

Type guard

const rowBelongsToTurn=(row,turnId)=>row?.turnId==null||row.turnId===turnId;

Try / catch

try{await importTurnItems(turn);}catch(e){if(e.message==='历史消息所属轮次不匹配'){return importTurnItems(turn,{skipForeignRows:true});}throw e;}

Prevention

When it happens

Trigger: 'thread/items/list' with threadId+turnId returns rows tagged with another turn's id — server-side filter regression, cursor bleed-over across turns, or a schema change where row.turnId semantics changed.

Common situations: Codex backend update altering items-list filtering; paginated projection inconsistency when the thread is being written concurrently; reusing stale cached pages across turns.

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/9ab71f32b230064a. Report an issue: GitHub.

Appendix: source

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

    if(cached&&turn.status==='completed')parsed=cached;
    else{
      const partial=turn.status==='completed'?await cache.get(`${key}:partial`):null;
      parsed=partial?.messages?.slice()||[];let itemCursor=partial?.cursor??null,limit=32;const cursors=new Set();
      do{
        signal?.throwIfAborted();
        if(cursors.has(itemCursor))throw Error('历史消息分页游标重复');
        let page;
        for(;;){
          try{page=await send('thread/items/list',{threadId,turnId:turn.id,cursor:itemCursor,limit,sortDirection:'asc'});break;}
          catch(error){
            if(limit>1&&/decoded message length too large/i.test(error.message)){limit=Math.max(1,Math.floor(limit/2));continue;}
            throw error;
          }
        }
        if(!Array.isArray(page?.data))throw Error('历史消息接口返回格式无效');
        cursors.add(itemCursor);
        for(const row of page.data){
          if(row.turnId!=null&&row.turnId!==turn.id)throw Error('历史消息所属轮次不匹配');
          const item=row.item??row;
          if(!['userMessage','agentMessage'].includes(item.type))continue;
          if(typeof item.id!=='string')throw Error('历史消息缺少稳定 ID');
          const role=item.type==='userMessage'?'user':'assistant';
          const content=role==='user'?(item.content??[]):[{text:item.text??''}];
          const message=parseMessage({type:'response_item',payload:{type:'message',id:item.id,role,phase:role==='assistant'?(item.phase??'final_answer'):'',content}},item.id);
          if(message)parsed.push({...message,turnId:turn.id,turnStatus:turn.status});
        }
        itemCursor=page.nextCursor??null;
        onProgress(`正在读取历史 ${index+1}/${turns.length} · 已读 ${parsed.length} 条消息…`);
        if(turn.status==='completed'&&itemCursor!==null)await cache.set(`${key}:partial`,{messages:parsed,cursor:itemCursor});
      }while(itemCursor!==null);
      if(turn.status==='completed'){await cache.set(key,parsed);await cache.delete(`${key}:partial`);}
    }
    // Older completed-turn caches contain text and IDs but no turn metadata.
    // The current directory and cache key provide the authoritative owner.
    for(const message of parsed)if(!seen.has(message.id)){seen.add(message.id);messages.push({...message,turnId:turn.id,turnStatus:turn.status});}
  }

View on GitHub (pinned to b1ed92e5e4)