dsh-vibe-math 2.0.15 → 2.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-vibe-math",
3
3
  "description": "Multi-agent mathematical problem-solving & verification frameworks for DeepSeek Harness — THREE agent presets in one install: vibe-math-v2 (probability-driven: qs.json + Propos knowledge base + explorer→solver→review/debate verdict), vibe-math-v3 (THIRD-generation, recommended: paper-style Markdown knowledge base with Problems/Progress/Propos/Methods/Verified + planner-agent scheduling that decides the next N actions + universal theory/method invention library + agents write their own Markdown directly via a per-file write lock), and vibe-math-v4 (FOURTH-generation: persistent self-organizing resident subagents that message & meet to decide all tasks, verify only by unanimous consensus, /compact at a context threshold, and stop only when all agree the problem is solved). Installing this bundle auto-installs all three presets (v1 was removed at v2.0.0).",
4
- "version": "2.0.15",
4
+ "version": "2.0.17",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -48,6 +48,8 @@ export function apply(ctx) {
48
48
  let problemText = '', problemId = 'problem', runId = 'run-' + shortId()
49
49
  let meetingState = null, verifyState = null, pendingVerify = null, pendingMeeting = null
50
50
  let busy = new Set(), wakeKind = new Map(), currentResident = ''
51
+ let finalizeLock = null // 'meeting'|'verify' while a consensus finalize is running (reentry guard)
52
+ const verifiedRecently = new Map() // targetId -> timestamp when it was closed as Verified (dedup re-propose)
51
53
  let lastActivityAt = now(), lastProgressAt = now(), artifactCount = 0, lastSyncMeetingAt = 0, persistedEpoch = '', heartbeatDisposer = null
52
54
  const activityLogCap = 200
53
55
 
@@ -403,6 +405,9 @@ export function apply(ctx) {
403
405
  const ids=Array.from(residents.keys()); const allSpoke=ids.every(id=>st.inputs[id]!==undefined)
404
406
  if(allSpoke){ await finalizeMeeting(); return }
405
407
  // only wake IDLE un-spoken residents (rotated order); in-flight ones re-trigger this on end.
408
+ // NOTE: we deliberately do NOT flush mailboxes here — drafting an un-spoken resident into a normal
409
+ // mail round would delay the meeting and can starve the consensus past its watchdog if the mail
410
+ // backlog is large. Mail is delivered on scheduleNext passes when no consensus is in progress.
406
411
  const order=st.order||ids
407
412
  const id=order.find(x=>st.inputs[x]===undefined && !busy.has(x))
408
413
  if(!id){ armHeartbeat(); return } // no idle un-spoken resident (a busy/hung one): re-check later
@@ -411,29 +416,48 @@ export function apply(ctx) {
411
416
  if(!ok) armHeartbeat() // a failed meeting wake must NOT silently hang the meeting
412
417
  }
413
418
  async function finalizeMeeting(){
414
- const st=meetingState
415
- const ids=Array.from(residents.keys()); const allSpoke=ids.length>0 && ids.every(id=>st.inputs[id]!==undefined)
416
- const lines=['# 会议 '+st.id+'|'+fmtTime(),'','**议程**:'+st.agenda,'']
417
- for(const [id,iv] of Object.entries(st.inputs)){ lines.push('### '+id); lines.push(iv.input||''); lines.push('') }
418
- await writeText('Shared/meetings/'+st.id+'.md', lines.join('\n'))
419
- meetings.push({id:st.id,agenda:st.agenda,at:now(),inputs:st.inputs})
420
- logDecision('meeting',st.agenda)
421
- // handle what the meeting produced: task proposals/claims, verify targets, stop vote
422
- for(const [id,iv] of Object.entries(st.inputs)){
423
- if(iv.propose_task) await proposeTask(iv.propose_task, iv.task_desc||'', id)
424
- if(iv.claim_task) await claimTask(iv.claim_task, id)
425
- if(iv.propose_verify) pendingVerify={targetId:iv.propose_verify,targetType:guessTargetType(iv.propose_verify),proposer:id,at:now()}
426
- }
427
- const votes=Object.values(st.inputs).map(x=>x.voteSolved).filter(v=>typeof v==='boolean')
428
- const allSolved = allSpoke && votes.length>0 && votes.every(v=>v===true)
429
- logActivity('meeting', 'concluded'+(allSolved?' → ALL agree solved':' (no unanimous solved vote)'))
430
- if(allSolved){ running=false; autoDone=true; phase='done'; clearHeartbeat(); logActivity('stop','all residents agree: problem solved'); await saveAll(); return }
431
- meetingState=null; wakeKind.clear(); await saveAll(); await scheduleNext()
419
+ if(finalizeLock) return // reentry guard: two onResidentEnd may both see allSpoke → only finalize once
420
+ finalizeLock='meeting'
421
+ let doSchedule=false
422
+ try {
423
+ const st=meetingState
424
+ const ids=Array.from(residents.keys()); const allSpoke=ids.length>0 && ids.every(id=>st.inputs[id]!==undefined)
425
+ const lines=['# 会议 '+st.id+'|'+fmtTime(),'','**议程**:'+st.agenda,'']
426
+ for(const [id,iv] of Object.entries(st.inputs)){ lines.push('### '+id); lines.push(iv.input||''); lines.push('') }
427
+ await writeText('Shared/meetings/'+st.id+'.md', lines.join('\n'))
428
+ meetings.push({id:st.id,agenda:st.agenda,at:now(),inputs:st.inputs})
429
+ logDecision('meeting',st.agenda)
430
+ // handle what the meeting produced: task proposals/claims, verify targets, stop vote
431
+ for(const [id,iv] of Object.entries(st.inputs)){
432
+ if(iv.propose_task) await proposeTask(iv.propose_task, iv.task_desc||'', id)
433
+ if(iv.claim_task) await claimTask(iv.claim_task, id)
434
+ if(iv.propose_verify) maybeQueueVerify(iv.propose_verify, id)
435
+ }
436
+ const votes=Object.values(st.inputs).map(x=>x.voteSolved).filter(v=>typeof v==='boolean')
437
+ const allSolved = allSpoke && votes.length>0 && votes.every(v=>v===true)
438
+ logActivity('meeting', 'concluded'+(allSolved?' → ALL agree solved':' (no unanimous solved vote)'))
439
+ if(allSolved){ running=false; autoDone=true; phase='done'; clearHeartbeat(); logActivity('stop','all residents agree: problem solved'); await saveAll(); return }
440
+ meetingState=null; wakeKind.clear(); await saveAll()
441
+ doSchedule=true
442
+ } finally { finalizeLock=null } // release BEFORE scheduling so a chained verify/meeting is not swallowed
443
+ if(doSchedule) await scheduleNext()
432
444
  }
433
445
 
434
446
  // ---- verification (unanimous) ----
435
447
  async function beginVerify(pv){
436
448
  clearHeartbeat()
449
+ // Re-check dedup at ACTUAL start, not just at propose time: a resident may propose object X while
450
+ // X is already being verified (it does not know). That proposal sits in pendingVerify; when the
451
+ // current X verify closes, beginVerify would run X end-to-end a SECOND time (test9: p-r3-04 was
452
+ // verified twice back-to-back). Drop it if X was closed within the dedup window.
453
+ const tgt=pv&&pv.targetId?String(pv.targetId):''
454
+ if(tgt){
455
+ const last=verifiedRecently.get(tgt)
456
+ if(last!==undefined && (now()-last) < recoverStallMs()){
457
+ logActivity('verify',tgt+' queued verify dropped at start (just verified at '+fmtTime(last)+')')
458
+ pendingVerify=null; await saveAll(); await scheduleNext(); return
459
+ }
460
+ }
437
461
  pendingVerify=null
438
462
  verifyState={targetId:pv.targetId,targetType:pv.targetType,targetOwner:pv.proposer||'',stage:'independent',round:0,asked:[],verdicts:{},history:{},transcript:[],at:now(),lastVerdictAt:now()}
439
463
  markProgress();
@@ -452,6 +476,9 @@ export function apply(ctx) {
452
476
  }
453
477
  const ids=Array.from(residents.keys()); const allVoted=ids.every(id=>vs.verdicts[id]!==undefined)
454
478
  if(allVoted){ await finalizeVerify(); return }
479
+ // NOTE: we deliberately do NOT flush mailboxes here — drafting an un-voted resident into a normal
480
+ // mail round would delay its verdict and can starve the verify past its watchdog when the backlog
481
+ // is large. Mail is delivered on scheduleNext passes when no consensus is in progress.
455
482
  const id=ids.find(x=>vs.verdicts[x]===undefined && !busy.has(x))
456
483
  if(!id){ armHeartbeat(); return } // no idle un-voted resident (a busy/hung one): re-check later
457
484
  const r=residents.get(id)
@@ -459,33 +486,60 @@ export function apply(ctx) {
459
486
  if(!ok) armHeartbeat() // a failed verify wake must NOT silently hang the verification
460
487
  }
461
488
  async function finalizeVerify(){
462
- const vs=verifyState; const expected=Array.from(residents.keys()).length
463
- const allVoted = expected>0 && Object.keys(vs.verdicts).length>=expected
464
- const vals=Object.values(vs.verdicts)
465
- // verdict is a PURE 0-1 probability; only ALL=1 (true) or ALL=0 (false) is a binary verdict.
466
- const allTrue = allVoted && vals.every(x=>Number(x.prob)===1)
467
- const allFalse = allVoted && vals.every(x=>Number(x.prob)===0)
468
- if(allTrue||allFalse){ await closeVerify(vs,allTrue); return }
469
- if(vs.round+1<params.verdictMaxRounds){
470
- // Move to a REAL debate round: snapshot the current votes into history (so the next round's
471
- // prompt can show others' previous stances), then CLEAR verdicts so every resident is asked to
472
- // give a fresh independent judgement after seeing the debate. Without the clear, allVoted stays
473
- // true and the debate rounds burn through with NOBODY being re-asked (a silent no-op).
474
- vs.history=Object.assign({}, vs.verdicts); vs.verdicts={}
475
- vs.lastVerdictAt=now() // fresh deadlock window for the re-vote round
476
- vs.stage='debate'; vs.round+=1; vs.asked=[]; logActivity('verify',vs.targetId+' round '+vs.round+' → debate (re-vote after seeing others)'); await saveAll(); await scheduleNext(); return
477
- }
478
- const avg=vals.length? vals.reduce((a,x)=>a+(x.prob!=null?x.prob:0.5),0)/vals.length : 0.5
479
- await writeDebateDoc(vs,false,avg); await rewriteSourceProb(vs.targetId, avg, vs.targetOwner); logActivity('verify',vs.targetId+' NOT unanimous kept unverified (avg '+avg.toFixed(2)+')')
480
- verifyState=null; wakeKind.clear(); await saveAll(); await scheduleNext()
489
+ if(finalizeLock) return // reentry guard (two onResidentEnd may both see allVoted)
490
+ finalizeLock='verify'
491
+ let doSchedule=false
492
+ try {
493
+ const vs=verifyState; const expected=Array.from(residents.keys()).length
494
+ const allVoted = expected>0 && Object.keys(vs.verdicts).length>=expected
495
+ const vals=Object.values(vs.verdicts)
496
+ // verdict is a PURE 0-1 probability; only ALL=1 (true) or ALL=0 (false) is a binary verdict.
497
+ const allTrue = allVoted && vals.every(x=>Number(x.prob)===1)
498
+ const allFalse = allVoted && vals.every(x=>Number(x.prob)===0)
499
+ if(allTrue||allFalse){ await closeVerify(vs,allTrue); doSchedule=true }
500
+ else if(vs.round+1<params.verdictMaxRounds){
501
+ // Move to a REAL debate round: snapshot the current votes into history (so the next round's
502
+ // prompt can show others' previous stances), then CLEAR verdicts so every resident is asked to
503
+ // give a fresh independent judgement after seeing the debate. Without the clear, allVoted stays
504
+ // true and the debate rounds burn through with NOBODY being re-asked (a silent no-op).
505
+ vs.history=Object.assign({}, vs.verdicts); vs.verdicts={}
506
+ vs.lastVerdictAt=now() // fresh deadlock window for the re-vote round
507
+ vs.stage='debate'; vs.round+=1; vs.asked=[]; logActivity('verify',vs.targetId+' round '+vs.round+' → debate (re-vote after seeing others)'); await saveAll(); doSchedule=true
508
+ }
509
+ else {
510
+ const avg=vals.length? vals.reduce((a,x)=>a+(x.prob!=null?x.prob:0.5),0)/vals.length : 0.5
511
+ await writeDebateDoc(vs,false,avg); await rewriteSourceProb(vs.targetId, avg, vs.targetOwner); logActivity('verify',vs.targetId+' NOT unanimous → kept unverified (avg '+avg.toFixed(2)+')')
512
+ verifyState=null; wakeKind.clear(); await saveAll(); doSchedule=true
513
+ }
514
+ } finally { finalizeLock=null } // release BEFORE scheduling (chained verifies must not be swallowed)
515
+ if(doSchedule) await scheduleNext()
481
516
  }
482
517
  async function closeVerify(vs,isTrue){
483
518
  await writeDebateDoc(vs,true,isTrue?1:0)
484
519
  const target=vs.targetId
485
520
  await writeVerifiedCard(vs,isTrue)
486
521
  await rewriteSource(target,isTrue,vs.targetOwner)
522
+ verifiedRecently.set(target, now()) // dedup: block an immediate re-proposal of the same object
487
523
  logActivity('verify',target+' → Verified ('+(isTrue?'真':'假')+') by unanimous consensus')
488
- verifyState=null; wakeKind.clear(); await saveAll(); await scheduleNext()
524
+ verifyState=null; wakeKind.clear(); await saveAll()
525
+ // scheduling is done by finalizeVerify AFTER it releases finalizeLock (so a chained verify is
526
+ // never swallowed by the still-held reentry lock)
527
+ }
528
+ // Queue a verify proposal UNLESS the same object was just verified (closed as 真/假). In parallel
529
+ // self-organization several residents may independently propose the same target while a verify is
530
+ // already settling; without the guard the object gets re-verified end-to-end a second time (test9:
531
+ // p-r3-04 was Verified twice back-to-back). A resident who genuinely extends the object later can
532
+ // still re-propose after the dedup window (recoverStallMs) has passed.
533
+ function maybeQueueVerify(target, proposer){
534
+ const t=String(target||'').trim()
535
+ if(!t) return false
536
+ const last=verifiedRecently.get(t)
537
+ if(last!==undefined && (now()-last) < recoverStallMs()){
538
+ logActivity('verify',t+' re-propose ignored (just verified at '+fmtTime(last)+')')
539
+ return false
540
+ }
541
+ pendingVerify={targetId:t,targetType:guessTargetType(t),proposer:proposer||'',at:now()}
542
+ return true
489
543
  }
490
544
  async function writeDebateDoc(vs,done,val){
491
545
  const lines=['# 验证辩论|'+vs.targetId+'('+vs.targetType+')|'+fmtTime(),'',(done?('**结论**:'+(val===1?'全体一致为真':'全体一致为假')):('**未达成全体一致**,平均概率 '+val.toFixed(2))),'','## 各常驻意见']
@@ -499,31 +553,71 @@ export function apply(ctx) {
499
553
  const text='# 已验证|'+vs.targetId+'\n- ID: '+vs.targetId+'\n- 类型: '+type+'\n- 结论: '+(isTrue?'真':'假')+'\n- 概率: '+(isTrue?1:0)+'\n- 来源: 全体常驻一致\n## 陈述\n参见来源卡。\n'
500
554
  await writeText('Verified/'+dir+'/'+vs.targetId+'.md', text)
501
555
  }
556
+ // Does `content` declare the target as its card ID? Accept both the exact `- ID: <id>` and the
557
+ // compact single-line form (`- ID: <id>; - 状态: ...`). Residents write cards by hand via fs with
558
+ // varying formats and (crucially) sometimes put a DIFFERENT file name than the declared ID (e.g.
559
+ // Propos/r-3/p-01.md declares "- ID: p-r3-01"). Matching only on the file name then silently loses
560
+ // the verified-status write-back, so we scan candidates' declared ID too.
561
+ function cardDeclaresId(content, target){
562
+ if(!content || !target) return false
563
+ const m=/-\s*ID:\s*([^;\n]+)/.exec(content)
564
+ return !!(m && String(m[1]).trim()===String(target).trim())
565
+ }
502
566
  async function findSourceRel(target, owner){
503
- // Prefer the OWNER's library (avoids id collisions across residents), then others.
567
+ // 1) exact file name in the owner's library (fast path), then every resident's library
504
568
  const order = owner ? [owner, ...Array.from(residents.keys()).filter(k=>k!==owner)] : Array.from(residents.keys())
505
569
  for(const rid of order){
506
570
  for(const base of ['Propos','Methods','Subproblems']){
507
- const cand=base+'/'+rid+'/'+target+'.md'; if((await readText(cand))!==undefined){ return cand }
571
+ const cand=base+'/'+rid+'/'+target+'.md'
572
+ const t0=await readText(cand); if(t0!==undefined) return cand
508
573
  }
509
574
  }
510
- return 'Propos/'+target+'.md'
575
+ // 2) declared-ID scan: residents sometimes name the file differently from the declared card ID
576
+ // (e.g. p-01.md declares ID p-r3-01). Look inside every card of every library for the target ID.
577
+ try {
578
+ for(const rid of order){
579
+ for(const base of ['Propos','Methods','Subproblems']){
580
+ const dirT=await fs.resolve(base+'/'+rid, {cwd: frameworkRoot()})
581
+ if(await fs.stat(dirT)===undefined) continue
582
+ const entries=await fs.listDir(dirT)
583
+ for(const e of entries||[]){
584
+ if(!e || e.type!=='file' || !/\.md$/.test(String(e.name))) continue
585
+ const c=await readText(base+'/'+rid+'/'+e.name)
586
+ if(c!==undefined && cardDeclaresId(c,target)) return base+'/'+rid+'/'+e.name
587
+ }
588
+ }
589
+ }
590
+ } catch(e){ /* scanning is best-effort */ }
591
+ return null // NOT 'Propos/'+target+'.md': writing there would create a stray empty card
592
+ }
593
+ // Update the `- 状态:` / `- 概率:` fields of a source card. Residents hand-write cards in two
594
+ // shapes: one field per line, or one line with `; `-separated fields. Accept both by allowing the
595
+ // anchor anywhere on a line and consuming up to the next `;` when fields share the line.
596
+ function rewriteCardField(text, field, newValue){
597
+ if(!text) return text
598
+ const esc=field.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')
599
+ // one-per-line: `- 状态: ...\n` OR inline: `; - 状态: ...;` / `- 状态: ...; - 概率:`
600
+ const re=new RegExp('(^|\\n|;\\s*)-\\s*'+esc+':[^;\\n]*','gm')
601
+ const replaced=text.replace(re,'$1- '+field+': '+newValue)
602
+ return replaced===text ? text : replaced
511
603
  }
512
604
  // non-unanimous verification: keep the object in its library but write back the
513
605
  // average probability (design §8: "留库附概率"), so the card reflects the consensus estimate.
514
606
  async function rewriteSourceProb(target,prob,owner){
515
607
  const rel=await findSourceRel(target,owner)
608
+ if(!rel){ logActivity('verify',target+' source card NOT found; avg prob '+Number(prob).toFixed(2)+' not written back'); return }
516
609
  let text=(await readText(rel))||''
517
- text=text.replace(/(^|\n)- 概率:.*/m,'$1- 概率: '+Number(prob).toFixed(2))
518
- await writeText(rel,text)
610
+ const next=rewriteCardField(text,'概率',Number(prob).toFixed(2))
611
+ await writeText(rel,next||text)
519
612
  }
520
613
  async function rewriteSource(target,isTrue,owner){
521
614
  // find & update the source card status/prob; best effort across per-resident libs
522
615
  const rel=await findSourceRel(target,owner)
616
+ if(!rel){ logActivity('verify',target+' source card NOT found; verified status not written back'); return }
523
617
  let text=(await readText(rel))||''
524
- text=text.replace(/(^|\n)- 状态:.*/m,'$1'+(isTrue?'- 状态: 已验证·真':'- 状态: 已验证·假'))
525
- .replace(/(^|\n)- 概率:.*/m,'$1'+(isTrue?'- 概率: 1':'- 概率: 0'))
526
- await writeText(rel,text)
618
+ let next=rewriteCardField(text,'状态',isTrue?'已验证·真':'已验证·假')
619
+ next=rewriteCardField(next,'概率',isTrue?'1':'0')
620
+ await writeText(rel,next||text)
527
621
  }
528
622
  function guessTargetType(id){ if(/^p-/.test(id)) return 'proposition'; if(/^m-/.test(id)) return 'method'; if(/^s-/.test(id)) return 'subproblem'; return 'proposition' }
529
623
 
@@ -674,7 +768,7 @@ export function apply(ctx) {
674
768
  if(kind==='meeting' && meetingState){
675
769
  meetingState.inputs[r.rId]={input:parsed.input||parsed.summary||'',voteSolved:typeof parsed.voteSolved==='boolean'?parsed.voteSolved:null,propose_verify:parsed.propose_verify||null,propose_task:parsed.propose_task||null,task_desc:parsed.task_desc||'',claim_task:parsed.claim_task||null}
676
770
  meetingState.lastInputAt=now()
677
- if(parsed.propose_verify) pendingVerify={targetId:parsed.propose_verify,targetType:guessTargetType(parsed.propose_verify),proposer:r.rId,at:now()}
771
+ if(parsed.propose_verify) maybeQueueVerify(parsed.propose_verify, r.rId)
678
772
  await saveAll(); await continueMeetingRound(); return
679
773
  }
680
774
  if((kind==='verif-ind'||kind==='verif-deb') && verifyState){
@@ -694,7 +788,7 @@ export function apply(ctx) {
694
788
  // normal turn
695
789
  if(r.status==='brainstorm'){ r.insight=parsed.summary||output; r.status='active' }
696
790
  if(typeof parsed.solved==='boolean') reports.push({rId:r.rId,solved:parsed.solved,summary:parsed.summary||'',at:now()})
697
- if(parsed.propose_verify) pendingVerify={targetId:parsed.propose_verify,targetType:guessTargetType(parsed.propose_verify),proposer:r.rId,at:now()}
791
+ if(parsed.propose_verify) maybeQueueVerify(parsed.propose_verify, r.rId)
698
792
  // group-conversation relay: the resident may choose to speak to the whole team (input) —
699
793
  // forward it to the others so this is a real discussion group, not private monologues.
700
794
  if(typeof parsed.input==='string' && parsed.input.trim()) await relayToGroup(r.rId, parsed.input.trim())
@@ -724,7 +818,7 @@ export function apply(ctx) {
724
818
  running=true; autoDone=false; phase='brainstorm'
725
819
  await writeText('Problems/'+problemId+'.md','# 问题|'+problemId+'\n- ID: '+problemId+'\n- 类型: 问题\n- 状态: 求解中\n- 优先级: 1\n- 依赖: []\n\n## 陈述\n'+problemText+'\n')
726
820
  residents=new Map(); mailboxes=new Map(); taskboard=[]; decisions=[]; meetings=[]; reports=[]; verifyState=null; meetingState=null; pendingVerify=null; residentSeq=0; artifactCount=0; clearHeartbeat()
727
- busy=new Set(); wakeKind=new Map(); currentResident=''; pendingMeeting=null; lastSyncMeetingAt=0 // fresh run must NOT inherit stale concurrency/coordination state (busy/wakeKind/currentResident/pendingMeeting) from a previous run on the same reused session
821
+ busy=new Set(); wakeKind=new Map(); currentResident=''; pendingMeeting=null; lastSyncMeetingAt=0; finalizeLock=null; verifiedRecently.clear() // fresh run must NOT inherit stale concurrency/coordination state (busy/wakeKind/currentResident/pendingMeeting) from a previous run on the same reused session
728
822
  lastActivityAt=now(); lastProgressAt=now() // fresh stall/activity clock for the new run (else B could fire immediately on a reused session)
729
823
  const dirs=Array.isArray(seedDirections)?seedDirections.slice(0,params.residentCount):[]
730
824
  for(let i=0;i<params.residentCount;i++){ const r=newResident(dirs[i]||''); await spawnResident(r) }
@@ -750,7 +844,7 @@ export function apply(ctx) {
750
844
  const needRespawn = Array.from(residents.values()).some(r=>!r.childId)
751
845
  if(needRespawn){
752
846
  for(const [,r] of residents){ r.childId=''; r.status='brainstorm'; r.insight=''; r.roundsSinceCompact=0 }
753
- busy=new Set(); wakeKind=new Map(); currentResident=''; pendingMeeting=null; pendingVerify=null; verifyState=null; meetingState=null
847
+ busy=new Set(); wakeKind=new Map(); currentResident=''; pendingMeeting=null; pendingVerify=null; verifyState=null; meetingState=null; finalizeLock=null; verifiedRecently.clear()
754
848
  }
755
849
  for(const [,r] of residents){ if(!r.childId){ await spawnResident(r) } }
756
850
  if(!running){ running=true; autoDone=false; if(phase==='idle') phase='active' }
@@ -767,7 +861,14 @@ export function apply(ctx) {
767
861
  verify: verifyState?{target:verifyState.targetId,stage:verifyState.stage, voted:Object.keys(verifyState.verdicts).length+'/'+residents.size}:null,
768
862
  pendingVerify: pendingVerify?pendingVerify.targetId:null,
769
863
  meetings:meetings.length, recentActivity: activityLog.slice(-8) } }
770
- async function addMember(direction){ const r=newResident(direction||''); await spawnResident(r); return {ok:true,id:r.rId,direction:r.direction} }
864
+ async function addMember(direction){ const r=newResident(direction||''); await spawnResident(r)
865
+ // Mid-meeting additions must join the meeting's speaking order; otherwise allSpoke (over CURRENT
866
+ // residents) can never be true for the new member (not in the snapshot order) and the meeting is
867
+ // only ever released by the stuck watchdog instead of finalizing with everyone's input.
868
+ if(meetingState){ if(!Array.isArray(meetingState.order)) meetingState.order=Array.from(residents.keys()); if(!meetingState.order.includes(r.rId)) meetingState.order.push(r.rId) }
869
+ // Mid-verify additions are automatically asked to vote (continueVerifyRound recomputes ids from
870
+ // the live residents map), so no extra handling is needed there.
871
+ return {ok:true,id:r.rId,direction:r.direction} }
771
872
  async function removeMember(id){ const r=residents.get(id); if(!r) return {ok:false}; if(r.childId){ try{ subagents.interrupt(r.childId,{kind:'ancestor',agent:rootAgent}) }catch(e){} } residents.delete(id); busy.delete(id); mailboxes.delete(id); wakeKind.delete(id)
772
873
  // Reconcile in-progress coordination so a removed member cannot hang consensus or crash a round:
773
874
  // drop its meeting speech / verify verdict / deferred-meeting / pending-verify if it owned them, and
@@ -559,11 +559,18 @@ VibeMath/Projects/<project>/
559
559
  - 并行填充常驻后,一个常驻可能"提议验证(propose_verify)"而另一个同时"提议开会(propose_meeting)"。**会议不得抢占验证**:`startMeeting` 在 `verifyState || pendingVerify` 时改为把会议请求**暂存**(`pendingMeeting`,仅记录 agenda/type/target)并返回 `{deferred:true}`;`scheduleNext` 在所有 meeting/verify/pending 都清空后才恢复该暂存会议。这样统一共识验证(全真/全假)作为"求真"环节不会被会议的协调讨论打断,验证做完后再开会开会协调下一步——两者互斥但都不丢失(暂存会议之后补开)。**暂存只保留第一条**(`!pendingMeeting` 才写入),避免后到的请求覆盖先到的。
560
560
  - **进行中增删常驻的一致性**:在会议/验证进行中 `removeMember` 会**同步清理**该常驻的会议发言(`delete meetingState.inputs[id]`)、验证投票(`delete verifyState.verdicts[id]`)、若它是待验证的提出者则清 `pendingVerify`,并从会议发言顺序 `st.order` 里剔除它;`wakeResident` 对已经不存在的常驻**防御性返回 false**(不再因 `r.childId` 抛 TypeError)。这样移除一个常驻不会让共识**卡在幽灵身上**,也不会让会议/验证循环去唤醒一个已删除的常驻而导致崩溃。
561
561
 
562
+ ### 实战审计修复(test9)
563
+ - **源卡回写定位与格式兼容**:常驻用 fs 直写卡片时,文件名可能与卡内声明的 `- ID:` 不一致(如 `Propos/r-3/p-01.md` 声明 `ID: p-r3-01`),且元数据可能是单行 `; ` 分隔(`- ID: p-105; - 状态: 未定论; - 概率: 0.95; …`)。旧 `findSourceRel` 只按文件名匹配 → 找不到 → 回退到顶层 `Propos/<target>.md` 并对空串执行写回,产生 **0 字节垃圾文件**、真实源卡状态不更新;旧回写正则只匹配"每行一个字段"的格式,对单行 `; ` 分隔卡失效。修复:① 文件名快查后增加**按卡内 `- ID:` 扫描**各常驻库;② 找不到时**跳过写回并记日志**(不再造空文件);③ 回写正则兼容"行首字段"与"行内 `; ` 分隔字段"两种格式。
564
+ - **会议/验证终结幂等(并发重入)**:两个常驻的 `onResidentEnd` 同时看到 `allSpoke/allVoted` 时可能**并发执行两次 finalizeMeeting/finalizeVerify**,重复 `meetings.push`/`logDecision`/`propose_task`/`stop`(test9:同一次会议在 decisions/session 里出现两次、生成两条相同任务、重复 stop)。修复:会话级 `finalizeLock` 重入锁(第一次执行期间再次进入直接返回),且**锁在调用 `scheduleNext()` 之前释放**——否则锁会跨 await 误吞紧随其后的链式验证/会议终结(牵连修正)。
565
+ - **重复验证同一对象去重**:并行自组织下多个常驻可能各自对同一对象 `propose_verify`,导致对象被端到端验证两次(test9:p-r3-04 被 Verified 两次)。修复:`verifiedRecently` 记录刚定论对象;在去重窗口(`recoverStallMs()`)内忽略对同一对象的再次提议(`maybeQueueVerify`);`beginVerify` 启动时**再查一次**去重窗口(丢弃"验证进行中排进 pendingVerify、前一个刚关闭"的重复请求);扩展后仍可在窗口外重新提议。
566
+ - **会议进行中加入成员**:新成员不在会议发言快照 `order` 中会导致 `allSpoke`(按当前成员集判定)永不成立,会议只能靠看门狗超时放弃。修复:`addMember` 在会议进行中把新成员**追加进 `order`**(验证进行中则自动被要求投票,无需处理)。
567
+ - **邮箱被长会议/长验证链饿死(记录为设计边界,未在会议轮内打断)**:`scheduleNext` 只在"无 meeting/verify/pending"时投递邮箱;verify→meeting→verify 长链可能让邮箱消息长时间未送达(test9 收尾期有积压)。曾尝试在会议/验证轮间投递,但会把未投票/未发言的常驻拉去做普通轮,令验证在看门狗窗口内等不到票而**误放弃**——故回退,改为**仅在无进行中共识时投递**(消息持久化不丢失,下次非共识调度/恢复时送达)。
568
+
562
569
  > 效果:一个卡死/坏掉的会议或验证最多阻塞 `activityTimeoutMs`×2 后自动释放,团队重新回到 A/B 分级保活,**不会永久停死**。仍符合"框架只促成、从不指派任务"(放弃只是终止一个无法推进的会议,把控制权交还团队的自组织循环)。
563
570
  >
564
571
  > 说明:无法对"某常驻真的拒绝/掉线"的情况达成全体一致时,看门狗会把该对象保留为"未定论/带概率",这是哲学上期望的诚实结果。`session.json` 仍不持久化 `meetingState`(进行中的会议不跨进程恢复),B 的停滞看门狗在重启后仍会触发,因此重启也能自愈。
565
572
  >
566
- > 全套测试仍全绿:`selfdrive-v4` 21/21、`e2e-v4-fixes` 39/39、v3 100/100、v2 business 18/regression 14、multisession 25/25。
573
+ > 全套测试仍全绿:`selfdrive-v4` 21/21、`e2e-v4-fixes` 61/61(含 T20 源卡回写 / T21 终结重入 / T22 验证去重)、v3 100/100、v2 business 18/regression 14、multisession 25/25。
567
574
 
568
575
 
569
576