dsh-vibe-math 2.0.14 → 2.0.16

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.14",
4
+ "version": "2.0.16",
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
 
@@ -257,7 +259,7 @@ export function apply(ctx) {
257
259
  residents.set(r.rId,r); await saveAll(); logActivity('spawn',r.rId+' ('+(r.direction||'brainstorm')+')')
258
260
  }
259
261
  async function wakeResident(r, promptText, kind){
260
- if(!r || !r.childId) return false // a removed resident must never be woken (rune: crash on r.childId)
262
+ if(!r || !r.childId) return false // a removed resident must never be woken (else r.childId would crash)
261
263
  clearHeartbeat()
262
264
  busy.add(r.rId); wakeKind.set(r.rId,kind||'normal'); currentResident=r.rId
263
265
  r.lastActiveAt=now(); r.rounds+=1; r.roundsSinceCompact+=1
@@ -404,6 +406,16 @@ export function apply(ctx) {
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.
406
408
  const order=st.order||ids
409
+ // Bounded mailbox flush BETWEEN meeting rounds: if an idle resident has queued messages, let it
410
+ // read/answer them before being drafted into the meeting, otherwise a long chain of meetings &
411
+ // verifies can starve the mailboxes for many minutes (test9: 33 msgs sat undelivered >12 min).
412
+ // The mail recipient completes as a normal round; its onResidentEnd re-enters scheduleNext → the
413
+ // meeting continues (watchdog still bounds it). Only flush when there IS an idle un-spoken target,
414
+ // so we never burn the meeting's own turn budget on unrelated mail.
415
+ if(order.find(x=>st.inputs[x]===undefined && !busy.has(x))){
416
+ const flushed=await deliverNextMailbox()
417
+ if(flushed){ await saveAll(); armHeartbeat(); return }
418
+ }
407
419
  const id=order.find(x=>st.inputs[x]===undefined && !busy.has(x))
408
420
  if(!id){ armHeartbeat(); return } // no idle un-spoken resident (a busy/hung one): re-check later
409
421
  const r=residents.get(id)
@@ -411,24 +423,28 @@ export function apply(ctx) {
411
423
  if(!ok) armHeartbeat() // a failed meeting wake must NOT silently hang the meeting
412
424
  }
413
425
  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()
426
+ if(finalizeLock) return // reentry guard: two onResidentEnd may both see allSpoke → only finalize once
427
+ finalizeLock='meeting'
428
+ try {
429
+ const st=meetingState
430
+ const ids=Array.from(residents.keys()); const allSpoke=ids.length>0 && ids.every(id=>st.inputs[id]!==undefined)
431
+ const lines=['# 会议 '+st.id+'|'+fmtTime(),'','**议程**:'+st.agenda,'']
432
+ for(const [id,iv] of Object.entries(st.inputs)){ lines.push('### '+id); lines.push(iv.input||''); lines.push('') }
433
+ await writeText('Shared/meetings/'+st.id+'.md', lines.join('\n'))
434
+ meetings.push({id:st.id,agenda:st.agenda,at:now(),inputs:st.inputs})
435
+ logDecision('meeting',st.agenda)
436
+ // handle what the meeting produced: task proposals/claims, verify targets, stop vote
437
+ for(const [id,iv] of Object.entries(st.inputs)){
438
+ if(iv.propose_task) await proposeTask(iv.propose_task, iv.task_desc||'', id)
439
+ if(iv.claim_task) await claimTask(iv.claim_task, id)
440
+ if(iv.propose_verify) maybeQueueVerify(iv.propose_verify, id)
441
+ }
442
+ const votes=Object.values(st.inputs).map(x=>x.voteSolved).filter(v=>typeof v==='boolean')
443
+ const allSolved = allSpoke && votes.length>0 && votes.every(v=>v===true)
444
+ logActivity('meeting', 'concluded'+(allSolved?' → ALL agree solved':' (no unanimous solved vote)'))
445
+ if(allSolved){ running=false; autoDone=true; phase='done'; clearHeartbeat(); logActivity('stop','all residents agree: problem solved'); await saveAll(); return }
446
+ meetingState=null; wakeKind.clear(); await saveAll(); await scheduleNext()
447
+ } finally { finalizeLock=null }
432
448
  }
433
449
 
434
450
  // ---- verification (unanimous) ----
@@ -452,6 +468,11 @@ export function apply(ctx) {
452
468
  }
453
469
  const ids=Array.from(residents.keys()); const allVoted=ids.every(id=>vs.verdicts[id]!==undefined)
454
470
  if(allVoted){ await finalizeVerify(); return }
471
+ // Bounded mailbox flush BETWEEN verify rounds (same rationale as the meeting flush above).
472
+ if(ids.find(x=>vs.verdicts[x]===undefined && !busy.has(x))){
473
+ const flushed=await deliverNextMailbox()
474
+ if(flushed){ await saveAll(); armHeartbeat(); return }
475
+ }
455
476
  const id=ids.find(x=>vs.verdicts[x]===undefined && !busy.has(x))
456
477
  if(!id){ armHeartbeat(); return } // no idle un-voted resident (a busy/hung one): re-check later
457
478
  const r=residents.get(id)
@@ -459,34 +480,55 @@ export function apply(ctx) {
459
480
  if(!ok) armHeartbeat() // a failed verify wake must NOT silently hang the verification
460
481
  }
461
482
  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 unanimouskept unverified (avg '+avg.toFixed(2)+')')
480
- verifyState=null; wakeKind.clear(); await saveAll(); await scheduleNext()
483
+ if(finalizeLock) return // reentry guard (two onResidentEnd may both see allVoted)
484
+ finalizeLock='verify'
485
+ try {
486
+ const vs=verifyState; const expected=Array.from(residents.keys()).length
487
+ const allVoted = expected>0 && Object.keys(vs.verdicts).length>=expected
488
+ const vals=Object.values(vs.verdicts)
489
+ // verdict is a PURE 0-1 probability; only ALL=1 (true) or ALL=0 (false) is a binary verdict.
490
+ const allTrue = allVoted && vals.every(x=>Number(x.prob)===1)
491
+ const allFalse = allVoted && vals.every(x=>Number(x.prob)===0)
492
+ if(allTrue||allFalse){ await closeVerify(vs,allTrue); return }
493
+ if(vs.round+1<params.verdictMaxRounds){
494
+ // Move to a REAL debate round: snapshot the current votes into history (so the next round's
495
+ // prompt can show others' previous stances), then CLEAR verdicts so every resident is asked to
496
+ // give a fresh independent judgement after seeing the debate. Without the clear, allVoted stays
497
+ // true and the debate rounds burn through with NOBODY being re-asked (a silent no-op).
498
+ vs.history=Object.assign({}, vs.verdicts); vs.verdicts={}
499
+ vs.lastVerdictAt=now() // fresh deadlock window for the re-vote round
500
+ 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
501
+ }
502
+ const avg=vals.length? vals.reduce((a,x)=>a+(x.prob!=null?x.prob:0.5),0)/vals.length : 0.5
503
+ await writeDebateDoc(vs,false,avg); await rewriteSourceProb(vs.targetId, avg, vs.targetOwner); logActivity('verify',vs.targetId+' NOT unanimous → kept unverified (avg '+avg.toFixed(2)+')')
504
+ verifyState=null; wakeKind.clear(); await saveAll(); await scheduleNext()
505
+ } finally { finalizeLock=null }
481
506
  }
482
507
  async function closeVerify(vs,isTrue){
483
508
  await writeDebateDoc(vs,true,isTrue?1:0)
484
509
  const target=vs.targetId
485
510
  await writeVerifiedCard(vs,isTrue)
486
511
  await rewriteSource(target,isTrue,vs.targetOwner)
512
+ verifiedRecently.set(target, now()) // dedup: block an immediate re-proposal of the same object
487
513
  logActivity('verify',target+' → Verified ('+(isTrue?'真':'假')+') by unanimous consensus')
488
514
  verifyState=null; wakeKind.clear(); await saveAll(); await scheduleNext()
489
515
  }
516
+ // Queue a verify proposal UNLESS the same object was just verified (closed as 真/假). In parallel
517
+ // self-organization several residents may independently propose the same target while a verify is
518
+ // already settling; without the guard the object gets re-verified end-to-end a second time (test9:
519
+ // p-r3-04 was Verified twice back-to-back). A resident who genuinely extends the object later can
520
+ // still re-propose after the dedup window (recoverStallMs) has passed.
521
+ function maybeQueueVerify(target, proposer){
522
+ const t=String(target||'').trim()
523
+ if(!t) return false
524
+ const last=verifiedRecently.get(t)
525
+ if(last!==undefined && (now()-last) < recoverStallMs()){
526
+ logActivity('verify',t+' re-propose ignored (just verified at '+fmtTime(last)+')')
527
+ return false
528
+ }
529
+ pendingVerify={targetId:t,targetType:guessTargetType(t),proposer:proposer||'',at:now()}
530
+ return true
531
+ }
490
532
  async function writeDebateDoc(vs,done,val){
491
533
  const lines=['# 验证辩论|'+vs.targetId+'('+vs.targetType+')|'+fmtTime(),'',(done?('**结论**:'+(val===1?'全体一致为真':'全体一致为假')):('**未达成全体一致**,平均概率 '+val.toFixed(2))),'','## 各常驻意见']
492
534
  for(const [k,v] of Object.entries(vs.verdicts)){ lines.push('### '+k+'|正确概率 '+(v.prob!=null?Number(v.prob).toFixed(2):'0.50')); lines.push(v.reason||''); lines.push('') }
@@ -499,31 +541,71 @@ export function apply(ctx) {
499
541
  const text='# 已验证|'+vs.targetId+'\n- ID: '+vs.targetId+'\n- 类型: '+type+'\n- 结论: '+(isTrue?'真':'假')+'\n- 概率: '+(isTrue?1:0)+'\n- 来源: 全体常驻一致\n## 陈述\n参见来源卡。\n'
500
542
  await writeText('Verified/'+dir+'/'+vs.targetId+'.md', text)
501
543
  }
544
+ // Does `content` declare the target as its card ID? Accept both the exact `- ID: <id>` and the
545
+ // compact single-line form (`- ID: <id>; - 状态: ...`). Residents write cards by hand via fs with
546
+ // varying formats and (crucially) sometimes put a DIFFERENT file name than the declared ID (e.g.
547
+ // Propos/r-3/p-01.md declares "- ID: p-r3-01"). Matching only on the file name then silently loses
548
+ // the verified-status write-back, so we scan candidates' declared ID too.
549
+ function cardDeclaresId(content, target){
550
+ if(!content || !target) return false
551
+ const m=/-\s*ID:\s*([^;\n]+)/.exec(content)
552
+ return !!(m && String(m[1]).trim()===String(target).trim())
553
+ }
502
554
  async function findSourceRel(target, owner){
503
- // Prefer the OWNER's library (avoids id collisions across residents), then others.
555
+ // 1) exact file name in the owner's library (fast path), then every resident's library
504
556
  const order = owner ? [owner, ...Array.from(residents.keys()).filter(k=>k!==owner)] : Array.from(residents.keys())
505
557
  for(const rid of order){
506
558
  for(const base of ['Propos','Methods','Subproblems']){
507
- const cand=base+'/'+rid+'/'+target+'.md'; if((await readText(cand))!==undefined){ return cand }
559
+ const cand=base+'/'+rid+'/'+target+'.md'
560
+ const t0=await readText(cand); if(t0!==undefined) return cand
508
561
  }
509
562
  }
510
- return 'Propos/'+target+'.md'
563
+ // 2) declared-ID scan: residents sometimes name the file differently from the declared card ID
564
+ // (e.g. p-01.md declares ID p-r3-01). Look inside every card of every library for the target ID.
565
+ try {
566
+ for(const rid of order){
567
+ for(const base of ['Propos','Methods','Subproblems']){
568
+ const dirT=await fs.resolve(base+'/'+rid, {cwd: frameworkRoot()})
569
+ if(await fs.stat(dirT)===undefined) continue
570
+ const entries=await fs.listDir(dirT)
571
+ for(const e of entries||[]){
572
+ if(!e || e.type!=='file' || !/\.md$/.test(String(e.name))) continue
573
+ const c=await readText(base+'/'+rid+'/'+e.name)
574
+ if(c!==undefined && cardDeclaresId(c,target)) return base+'/'+rid+'/'+e.name
575
+ }
576
+ }
577
+ }
578
+ } catch(e){ /* scanning is best-effort */ }
579
+ return null // NOT 'Propos/'+target+'.md': writing there would create a stray empty card
580
+ }
581
+ // Update the `- 状态:` / `- 概率:` fields of a source card. Residents hand-write cards in two
582
+ // shapes: one field per line, or one line with `; `-separated fields. Accept both by allowing the
583
+ // anchor anywhere on a line and consuming up to the next `;` when fields share the line.
584
+ function rewriteCardField(text, field, newValue){
585
+ if(!text) return text
586
+ const esc=field.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')
587
+ // one-per-line: `- 状态: ...\n` OR inline: `; - 状态: ...;` / `- 状态: ...; - 概率:`
588
+ const re=new RegExp('(^|\\n|;\\s*)-\\s*'+esc+':[^;\\n]*','gm')
589
+ if(re.test(text)) return text.replace(re,'$1- '+field+': '+newValue)
590
+ return text
511
591
  }
512
592
  // non-unanimous verification: keep the object in its library but write back the
513
593
  // average probability (design §8: "留库附概率"), so the card reflects the consensus estimate.
514
594
  async function rewriteSourceProb(target,prob,owner){
515
595
  const rel=await findSourceRel(target,owner)
596
+ if(!rel){ logActivity('verify',target+' source card NOT found; avg prob '+Number(prob).toFixed(2)+' not written back'); return }
516
597
  let text=(await readText(rel))||''
517
- text=text.replace(/(^|\n)- 概率:.*/m,'$1- 概率: '+Number(prob).toFixed(2))
518
- await writeText(rel,text)
598
+ const next=rewriteCardField(text,'概率',Number(prob).toFixed(2))
599
+ await writeText(rel,next||text)
519
600
  }
520
601
  async function rewriteSource(target,isTrue,owner){
521
602
  // find & update the source card status/prob; best effort across per-resident libs
522
603
  const rel=await findSourceRel(target,owner)
604
+ if(!rel){ logActivity('verify',target+' source card NOT found; verified status not written back'); return }
523
605
  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)
606
+ let next=rewriteCardField(text,'状态',isTrue?'已验证·真':'已验证·假')
607
+ next=rewriteCardField(next,'概率',isTrue?'1':'0')
608
+ await writeText(rel,next||text)
527
609
  }
528
610
  function guessTargetType(id){ if(/^p-/.test(id)) return 'proposition'; if(/^m-/.test(id)) return 'method'; if(/^s-/.test(id)) return 'subproblem'; return 'proposition' }
529
611
 
@@ -674,7 +756,7 @@ export function apply(ctx) {
674
756
  if(kind==='meeting' && meetingState){
675
757
  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
758
  meetingState.lastInputAt=now()
677
- if(parsed.propose_verify) pendingVerify={targetId:parsed.propose_verify,targetType:guessTargetType(parsed.propose_verify),proposer:r.rId,at:now()}
759
+ if(parsed.propose_verify) maybeQueueVerify(parsed.propose_verify, r.rId)
678
760
  await saveAll(); await continueMeetingRound(); return
679
761
  }
680
762
  if((kind==='verif-ind'||kind==='verif-deb') && verifyState){
@@ -694,7 +776,7 @@ export function apply(ctx) {
694
776
  // normal turn
695
777
  if(r.status==='brainstorm'){ r.insight=parsed.summary||output; r.status='active' }
696
778
  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()}
779
+ if(parsed.propose_verify) maybeQueueVerify(parsed.propose_verify, r.rId)
698
780
  // group-conversation relay: the resident may choose to speak to the whole team (input) —
699
781
  // forward it to the others so this is a real discussion group, not private monologues.
700
782
  if(typeof parsed.input==='string' && parsed.input.trim()) await relayToGroup(r.rId, parsed.input.trim())
@@ -724,7 +806,7 @@ export function apply(ctx) {
724
806
  running=true; autoDone=false; phase='brainstorm'
725
807
  await writeText('Problems/'+problemId+'.md','# 问题|'+problemId+'\n- ID: '+problemId+'\n- 类型: 问题\n- 状态: 求解中\n- 优先级: 1\n- 依赖: []\n\n## 陈述\n'+problemText+'\n')
726
808
  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
809
+ 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
810
  lastActivityAt=now(); lastProgressAt=now() // fresh stall/activity clock for the new run (else B could fire immediately on a reused session)
729
811
  const dirs=Array.isArray(seedDirections)?seedDirections.slice(0,params.residentCount):[]
730
812
  for(let i=0;i<params.residentCount;i++){ const r=newResident(dirs[i]||''); await spawnResident(r) }
@@ -732,6 +814,10 @@ export function apply(ctx) {
732
814
  }
733
815
  async function resume(){
734
816
  currentProject=await readCurrentProject(); await ensureDirs(); await loadAll(); await loadSettings()
817
+ // After loadAll the residentSeq counter is still whatever THIS process had (0 on a fresh process),
818
+ // but persisted residents may already be r-1..r-N. Sync it to the max existing id so a later
819
+ // addMember never collides with an existing resident (it would silently overwrite it).
820
+ for(const key of residents.keys()){ const mm=/^r-(\d+)$/.exec(String(key)); if(mm) residentSeq=Math.max(residentSeq, Number(mm[1])) }
735
821
  lastActivityAt=now(); lastProgressAt=now() // pause must not count as stall time; a resumed run gets a fresh clock
736
822
  if(phase==='idle' && !running && residents.size===0) return {ok:false,message:'nothing to resume'}
737
823
  // If the persisted State came from a DIFFERENT process (crash/restart), the saved
@@ -746,7 +832,7 @@ export function apply(ctx) {
746
832
  const needRespawn = Array.from(residents.values()).some(r=>!r.childId)
747
833
  if(needRespawn){
748
834
  for(const [,r] of residents){ r.childId=''; r.status='brainstorm'; r.insight=''; r.roundsSinceCompact=0 }
749
- busy=new Set(); wakeKind=new Map(); currentResident=''; pendingMeeting=null; pendingVerify=null; verifyState=null; meetingState=null
835
+ busy=new Set(); wakeKind=new Map(); currentResident=''; pendingMeeting=null; pendingVerify=null; verifyState=null; meetingState=null; finalizeLock=null; verifiedRecently.clear()
750
836
  }
751
837
  for(const [,r] of residents){ if(!r.childId){ await spawnResident(r) } }
752
838
  if(!running){ running=true; autoDone=false; if(phase==='idle') phase='active' }
@@ -559,11 +559,17 @@ 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` 重入锁(第一次执行期间再次进入直接返回)。
565
+ - **重复验证同一对象去重**:并行自组织下多个常驻可能各自对同一对象 `propose_verify`,导致对象被端到端验证两次(test9:p-r3-04 被 Verified 两次)。修复:`verifiedRecently` 记录刚定论对象;在去重窗口(`recoverStallMs()`)内忽略对同一对象的再次提议(`maybeQueueVerify`);扩展后仍可在窗口外重新提议。
566
+ - **邮箱被长会议/长验证链饿死**:`scheduleNext` 只在"无 meeting/verify/pending"时投递邮箱;若 verify→meeting→verify 链条不断,邮箱消息可能被无限推迟(test9:11:09–11:21 收尾期 33 条群聊消息全部积压未送达)。修复:会议/验证**每轮之间**做一次有界邮箱投递(存在空闲未发言/未投票者时先投递一批,收件人完成后经 onResidentEnd 重回会议/验证;看门狗仍兜底)。
567
+
562
568
  > 效果:一个卡死/坏掉的会议或验证最多阻塞 `activityTimeoutMs`×2 后自动释放,团队重新回到 A/B 分级保活,**不会永久停死**。仍符合"框架只促成、从不指派任务"(放弃只是终止一个无法推进的会议,把控制权交还团队的自组织循环)。
563
569
  >
564
570
  > 说明:无法对"某常驻真的拒绝/掉线"的情况达成全体一致时,看门狗会把该对象保留为"未定论/带概率",这是哲学上期望的诚实结果。`session.json` 仍不持久化 `meetingState`(进行中的会议不跨进程恢复),B 的停滞看门狗在重启后仍会触发,因此重启也能自愈。
565
571
  >
566
- > 全套测试仍全绿:`selfdrive-v4` 21/21、`e2e-v4-fixes` 39/39、v3 100/100、v2 business 18/regression 14、multisession 25/25。
572
+ > 全套测试仍全绿:`selfdrive-v4` 21/21、`e2e-v4-fixes` 61/61(含 T20 源卡回写 / T21 终结重入 / T22 验证去重)、v3 100/100、v2 business 18/regression 14、multisession 25/25。
567
573
 
568
574
 
569
575