pi-multi-viewers 0.1.0 → 0.2.1
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/AGENTS.md +4 -2
- package/README.md +13 -6
- package/docs/design.md +60 -2
- package/docs/test-methodology.md +23 -0
- package/fake_agent.py +16 -5
- package/human_viewer.py +112 -16
- package/meeting_core.py +28 -9
- package/meeting_engine.py +53 -34
- package/meeting_fs.py +20 -0
- package/meeting_loop.py +1 -1
- package/observability.py +397 -0
- package/package.json +1 -1
- package/prompts/multi-viewers.md +5 -4
- package/spec_gen.py +518 -0
- package/start_discussion.py +47 -828
package/meeting_engine.py
CHANGED
|
@@ -35,6 +35,7 @@ from meeting_fs import (
|
|
|
35
35
|
git_show, is_message_file, parse_log_nameonly,
|
|
36
36
|
read_protocol, cat_batch, remove_message, write_text, file_size,
|
|
37
37
|
bare_of_base, bare_of_workdir, log,
|
|
38
|
+
RESULT_MD, RESULT_MD_MIN_BYTES, DEFAULT_STALL_TIMEOUT,
|
|
38
39
|
)
|
|
39
40
|
from meeting_core import (
|
|
40
41
|
meeting_speak_count as core_meeting_speak_count,
|
|
@@ -42,6 +43,8 @@ from meeting_core import (
|
|
|
42
43
|
should_write_af, can_start_rr, validate_and_fix, is_all_last_in,
|
|
43
44
|
aggregate_mode as core_aggregate_mode,
|
|
44
45
|
has_new_messages_for_me,
|
|
46
|
+
T_MESSAGE, T_FREEZING, T_ALL_FREEZING, T_PASS, T_CONCLUDED,
|
|
47
|
+
M_MEETING, M_ROUND_ROBIN, M_ALL_FREEZING, M_CONCLUDED,
|
|
45
48
|
)
|
|
46
49
|
POLL_INTERVAL = 2.0
|
|
47
50
|
JITTER = 0.3
|
|
@@ -209,7 +212,7 @@ def rr_active_count(messages, agents):
|
|
|
209
212
|
"""
|
|
210
213
|
starter = agents[0]
|
|
211
214
|
return sum(1 for fm in messages.get(starter, [])
|
|
212
|
-
if fm.get("mode") ==
|
|
215
|
+
if fm.get("mode") == M_ROUND_ROBIN)
|
|
213
216
|
|
|
214
217
|
|
|
215
218
|
def human_msg_count(bare):
|
|
@@ -255,11 +258,12 @@ def write_af_if_no_rr(bare, workdir, agent, agents):
|
|
|
255
258
|
head = git_head(workdir)
|
|
256
259
|
# 重检:bare 是否有 RR 已启动(任一 agent 最后一条 mode==round-robin)
|
|
257
260
|
lasts = each_agent_last(bare, agents)
|
|
258
|
-
if any(v is not None and v.get("mode") ==
|
|
261
|
+
if any(v is not None and v.get("mode") == M_ROUND_ROBIN
|
|
262
|
+
for v in lasts.values()):
|
|
259
263
|
log(agent, "bare 已有 RR 启动——放弃补写 af")
|
|
260
264
|
return
|
|
261
265
|
# 无 RR → 正常写 af
|
|
262
|
-
write_protocol_signal(workdir, agent,
|
|
266
|
+
write_protocol_signal(workdir, agent, T_ALL_FREEZING, M_ALL_FREEZING)
|
|
263
267
|
|
|
264
268
|
|
|
265
269
|
def write_protocol_signal(workdir, agent, type_, mode, next_=None):
|
|
@@ -331,11 +335,11 @@ def respond_with_fallback(workdir, agent, responder, head, meta, is_first,
|
|
|
331
335
|
if rr_turn:
|
|
332
336
|
nxt_a = core_next(agents, agent)
|
|
333
337
|
log(agent, f"responder 无法产出——loop 代写 pass(无静默铁律最后执行)")
|
|
334
|
-
write_protocol_signal(workdir, agent,
|
|
338
|
+
write_protocol_signal(workdir, agent, T_PASS, M_ROUND_ROBIN,
|
|
335
339
|
nxt_a)
|
|
336
340
|
else:
|
|
337
341
|
log(agent, f"responder 无法产出——loop 代写 freezing")
|
|
338
|
-
write_protocol_signal(workdir, agent,
|
|
342
|
+
write_protocol_signal(workdir, agent, T_FREEZING, M_MEETING)
|
|
339
343
|
# 代写 = 无产出(设计 11.1:只数 responder 成功产出的轮,
|
|
340
344
|
# 代写不耗配额)——返回 False(审核#1:原先 return True 让 C' 修复失效)
|
|
341
345
|
return False
|
|
@@ -391,7 +395,7 @@ def commit_new_files(workdir, agent, head, mode):
|
|
|
391
395
|
# review5 A6:next 无条件覆盖为顺序下一位(与 mode 无条件覆盖对称)。
|
|
392
396
|
# 只补缺省会让 LLM 写错的 next 保留 → 轮转顺序混乱。单向流下
|
|
393
397
|
# RR 只有 pass(无异议),next 是轮转顺序(协议状态),LLM 不知道。
|
|
394
|
-
if mode ==
|
|
398
|
+
if mode == M_ROUND_ROBIN and fixed.get("type") in (T_PASS, T_MESSAGE):
|
|
395
399
|
fixed["next"] = core_next(
|
|
396
400
|
participants(bare_of_workdir(workdir)), agent)
|
|
397
401
|
new_content = serialize_message(fixed, content)
|
|
@@ -447,7 +451,7 @@ def _result_md_valid(workdir):
|
|
|
447
451
|
LLM 可能写空文件/仅 frontmatter——存在性检查退化为空提交(审核 A2)。
|
|
448
452
|
存在性与大小经由 fs.file_size 一次判定(-1 = 不存在/不可读)。
|
|
449
453
|
"""
|
|
450
|
-
return file_size(workdir,
|
|
454
|
+
return file_size(workdir, RESULT_MD) > RESULT_MD_MIN_BYTES
|
|
451
455
|
|
|
452
456
|
|
|
453
457
|
def finalize_discussion(workdir, agent, responder, head, reason="consensus"):
|
|
@@ -462,7 +466,7 @@ def finalize_discussion(workdir, agent, responder, head, reason="consensus"):
|
|
|
462
466
|
responder(workdir, agent, head, [], False, False, False,
|
|
463
467
|
finalizing=True, finalize_reason=reason)
|
|
464
468
|
# ② 校验 result.md 有效(存在 + 非空);无则重试(无静默铁律的扩展)
|
|
465
|
-
result_path = os.path.join(workdir,
|
|
469
|
+
result_path = os.path.join(workdir, RESULT_MD)
|
|
466
470
|
for _ in range(MAX_RETRY):
|
|
467
471
|
if _result_md_valid(workdir):
|
|
468
472
|
break
|
|
@@ -475,11 +479,11 @@ def finalize_discussion(workdir, agent, responder, head, reason="consensus"):
|
|
|
475
479
|
_commit_result_md(workdir, agent, "discuss: result.md")
|
|
476
480
|
else:
|
|
477
481
|
log(agent, "result.md 重试后仍无效——loop 兜底代写")
|
|
478
|
-
write_text(workdir,
|
|
482
|
+
write_text(workdir, RESULT_MD,
|
|
479
483
|
"# 讨论结论\n\n(resultWriter 未能生成有效 result.md,"
|
|
480
484
|
f"由本地循环兜底代写。收尾原因:{reason})\n")
|
|
481
485
|
_commit_result_md(workdir, agent, "discuss: result.md (loop fallback)")
|
|
482
|
-
write_protocol_signal(workdir, agent,
|
|
486
|
+
write_protocol_signal(workdir, agent, T_CONCLUDED, M_CONCLUDED)
|
|
483
487
|
return True
|
|
484
488
|
|
|
485
489
|
|
|
@@ -491,16 +495,17 @@ def _commit_result_md(workdir, agent, subject):
|
|
|
491
495
|
用 git status --porcelain(不用 git diff --quiet——diff 看不到
|
|
492
496
|
未跟踪新文件,会误判"已提交"导致 result.md 永不 commit)。
|
|
493
497
|
"""
|
|
494
|
-
r = run_git(workdir, "status", "--porcelain", "--",
|
|
498
|
+
r = run_git(workdir, "status", "--porcelain", "--", RESULT_MD,
|
|
495
499
|
check=False)
|
|
496
500
|
if r.stdout.strip():
|
|
497
|
-
git_commit(workdir, [
|
|
501
|
+
git_commit(workdir, [RESULT_MD], subject)
|
|
498
502
|
else:
|
|
499
503
|
log(agent, "result.md 无改动——跳过 commit(幂等)")
|
|
500
504
|
|
|
501
505
|
|
|
502
506
|
def agent_loop(workdir, agent, responder, max_meeting=10, max_rr=7,
|
|
503
|
-
poll_interval=POLL_INTERVAL,
|
|
507
|
+
poll_interval=POLL_INTERVAL,
|
|
508
|
+
stall_timeout=DEFAULT_STALL_TIMEOUT):
|
|
504
509
|
"""主状态机(v2)。responder 注入:响应一轮并返回是否产出。
|
|
505
510
|
|
|
506
511
|
stall_timeout: 无进展超时兜底(秒,默认 600=10 分钟)。任何 agent
|
|
@@ -542,7 +547,7 @@ def agent_loop(workdir, agent, responder, max_meeting=10, max_rr=7,
|
|
|
542
547
|
mode = core_aggregate_mode(lasts)
|
|
543
548
|
|
|
544
549
|
# ===== ① concluded → 退出 =====
|
|
545
|
-
if mode ==
|
|
550
|
+
if mode == M_CONCLUDED:
|
|
546
551
|
log(agent, "讨论已收尾,退出")
|
|
547
552
|
return
|
|
548
553
|
|
|
@@ -559,16 +564,29 @@ def agent_loop(workdir, agent, responder, max_meeting=10, max_rr=7,
|
|
|
559
564
|
finalize_discussion(workdir, agent, responder, head,
|
|
560
565
|
reason="stall")
|
|
561
566
|
continue
|
|
562
|
-
# 非 rw:rw
|
|
563
|
-
#
|
|
564
|
-
#
|
|
565
|
-
#
|
|
566
|
-
# ① 先 pull
|
|
567
|
-
#
|
|
568
|
-
#
|
|
569
|
-
#
|
|
567
|
+
# 非 rw:rw 可能离线(崩溃/未启动)——接管收尾(主协议
|
|
568
|
+
# 7.6"任一 agent 可接管";meeting 收紧成仅 rw 是缺口)。
|
|
569
|
+
#
|
|
570
|
+
# 仲裁 = **心跳式软仲裁**(不是互斥锁):
|
|
571
|
+
# ① 先 pull + 重检两个"是否已收尾"的共享事实(concluded /
|
|
572
|
+
# result.md 已提交)——已收尾则让位;
|
|
573
|
+
# ② 写一条接管声明 commit,作用是**推进 HEAD**:其他 agent
|
|
574
|
+
# 下一轮 `_stall_elapsed` 看到 HEAD 变化 → 累计归零 →
|
|
575
|
+
# 退出接管分支(先到者成为事实上的唯一收尾者);
|
|
576
|
+
# ③ finalize。
|
|
577
|
+
#
|
|
578
|
+
# **残余窗口(诚实边界)**:声明落地前,另一方若已走完本轮
|
|
579
|
+
# 的 git_head + stall 判定,则同样进入本分支(窗口≈毫秒级,
|
|
580
|
+
# 同一轮竞态)。此时双方各自唤醒 LLM 写 result.md——结果
|
|
581
|
+
# **不是死锁**:先 push 者成功;后者 push 走 git_push 的
|
|
582
|
+
# pull --rebase 重试,内容冲突则耗尽重试抛错 → loop 顶层
|
|
583
|
+
# 异常边界接住 → 下一轮看到 concluded 退出。
|
|
584
|
+
# 代价 = 偶尔多一次 LLM 调用(约 30–60s);产物始终唯一
|
|
585
|
+
# (bare 只有一个 result.md 版本)。
|
|
586
|
+
# **真正的兜底是 push 容错 + 下一轮 concluded 退出这两层**;
|
|
587
|
+
# 声明只降低并发概率,不构成互斥保证(勿读成"天然唯一")。
|
|
570
588
|
git_pull(workdir)
|
|
571
|
-
if aggregate_mode(bare, agents) ==
|
|
589
|
+
if aggregate_mode(bare, agents) == M_CONCLUDED:
|
|
572
590
|
log(agent, "收尾已完成(他人接管)——让位")
|
|
573
591
|
continue
|
|
574
592
|
r = run_git(bare, "show", "HEAD:result.md", check=False)
|
|
@@ -577,8 +595,9 @@ def agent_loop(workdir, agent, responder, max_meeting=10, max_rr=7,
|
|
|
577
595
|
continue
|
|
578
596
|
# 写接管声明(freezing——推进 HEAD + 不破坏流程)
|
|
579
597
|
log(agent, "rw 未收尾——声明接管(推进 HEAD 天然唯一接管者)")
|
|
580
|
-
write_protocol_signal(workdir, agent,
|
|
581
|
-
aggregate_mode(bare, agents)
|
|
598
|
+
write_protocol_signal(workdir, agent, T_FREEZING,
|
|
599
|
+
aggregate_mode(bare, agents)
|
|
600
|
+
or M_MEETING)
|
|
582
601
|
finalize_discussion(workdir, agent, responder, head,
|
|
583
602
|
reason="stall")
|
|
584
603
|
continue
|
|
@@ -591,7 +610,7 @@ def agent_loop(workdir, agent, responder, max_meeting=10, max_rr=7,
|
|
|
591
610
|
continue
|
|
592
611
|
|
|
593
612
|
# ===== ③ all-freezing 阶段:starter 启动 RR =====
|
|
594
|
-
if mode ==
|
|
613
|
+
if mode == M_ALL_FREEZING:
|
|
595
614
|
# starter 严格条件:全员 af → 启动 RR(用户 10084/10089:
|
|
596
615
|
# 唤醒目的 = 有新的文件可提交给 LLM 处理。starter 冻结期间
|
|
597
616
|
# 别人可能发了 message(读取点后),必须唤醒读完再 pass;
|
|
@@ -609,14 +628,14 @@ def agent_loop(workdir, agent, responder, max_meeting=10, max_rr=7,
|
|
|
609
628
|
else:
|
|
610
629
|
# 无新消息 → 确定性写 pass(启动 RR,带 next 轮转链)
|
|
611
630
|
nxt_a = core_next(agents, agent)
|
|
612
|
-
write_protocol_signal(workdir, agent,
|
|
613
|
-
|
|
631
|
+
write_protocol_signal(workdir, agent, T_PASS,
|
|
632
|
+
M_ROUND_ROBIN, nxt_a)
|
|
614
633
|
continue
|
|
615
634
|
time.sleep(poll_interval)
|
|
616
635
|
continue
|
|
617
636
|
|
|
618
637
|
# ===== ④ round-robin 阶段(发言锁已解)=====
|
|
619
|
-
if mode ==
|
|
638
|
+
if mode == M_ROUND_ROBIN:
|
|
620
639
|
# 全员 pass → resultWriter 收尾(写 result.md + concluded)
|
|
621
640
|
# 判定收敛到 core(审核 D:判定只此一份——core 吃完整消息列表,
|
|
622
641
|
# 引擎只做 bare 组装)
|
|
@@ -649,8 +668,8 @@ def agent_loop(workdir, agent, responder, max_meeting=10, max_rr=7,
|
|
|
649
668
|
meta = new_messages_with_meta(workdir, rp, agent)
|
|
650
669
|
if not has_new_messages_for_me(meta, agent):
|
|
651
670
|
nxt_a = core_next(agents, agent)
|
|
652
|
-
write_protocol_signal(workdir, agent,
|
|
653
|
-
|
|
671
|
+
write_protocol_signal(workdir, agent, T_PASS,
|
|
672
|
+
M_ROUND_ROBIN, nxt_a)
|
|
654
673
|
continue
|
|
655
674
|
before = len(list_my_messages(workdir, agent))
|
|
656
675
|
respond_with_fallback(workdir, agent, responder, head, meta,
|
|
@@ -691,7 +710,7 @@ def agent_loop(workdir, agent, responder, max_meeting=10, max_rr=7,
|
|
|
691
710
|
and all_last.get(agent) != "freezing"):
|
|
692
711
|
log(agent, f"meeting 配额耗尽({quota} 轮,含 human 增量"
|
|
693
712
|
f"{quota - max_meeting})——确定性 freezing")
|
|
694
|
-
write_protocol_signal(workdir, agent,
|
|
713
|
+
write_protocol_signal(workdir, agent, T_FREEZING, M_MEETING)
|
|
695
714
|
continue
|
|
696
715
|
|
|
697
716
|
# ⑤.3 触发判断
|
|
@@ -705,7 +724,7 @@ def agent_loop(workdir, agent, responder, max_meeting=10, max_rr=7,
|
|
|
705
724
|
# 唯一未冻结者(无新消息)→ 确定性 freezing(不经 responder)
|
|
706
725
|
if others_frozen and not triggered and all_last.get(agent) != "freezing":
|
|
707
726
|
log(agent, "其他人都已冻结且无新消息——确定性 freezing")
|
|
708
|
-
write_protocol_signal(workdir, agent,
|
|
727
|
+
write_protocol_signal(workdir, agent, T_FREEZING, M_MEETING)
|
|
709
728
|
continue
|
|
710
729
|
# 无新消息(非唯一未冻结者)→ 等待(L2:化简,先 freezing 后 sleep)
|
|
711
730
|
if not triggered:
|
|
@@ -713,7 +732,7 @@ def agent_loop(workdir, agent, responder, max_meeting=10, max_rr=7,
|
|
|
713
732
|
continue
|
|
714
733
|
|
|
715
734
|
# ⑤.4 发言锁:我最后是 freezing → 锁(不发 message,等 RR)
|
|
716
|
-
if all_last.get(agent) ==
|
|
735
|
+
if all_last.get(agent) == T_FREEZING:
|
|
717
736
|
time.sleep(poll_interval)
|
|
718
737
|
continue
|
|
719
738
|
|
package/meeting_fs.py
CHANGED
|
@@ -18,6 +18,23 @@ import subprocess
|
|
|
18
18
|
import time
|
|
19
19
|
from datetime import datetime, timezone
|
|
20
20
|
|
|
21
|
+
# ---------------------------------------------------------------
|
|
22
|
+
# 产品级常量(跨模块的单一约定)
|
|
23
|
+
# ---------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
# 收尾产物的文件名。**单一来源**:engine(写/校验/提交)、loop(保存副本)、
|
|
26
|
+
# viewer(done 判据)、start_discussion(状态检查)、--report(提示)都引用它
|
|
27
|
+
# ——曾是 8 处字面量,产品级核心约定却没有家(e2e15 自审 S1)。
|
|
28
|
+
RESULT_MD = "result.md"
|
|
29
|
+
|
|
30
|
+
# result.md 的**有效性阈值**(字节):存在但小于它 → 视为未生成(LLM 可能
|
|
31
|
+
# 写空文件/仅 frontmatter——只查存在性会退化为空提交,审核 A2)。
|
|
32
|
+
RESULT_MD_MIN_BYTES = 50
|
|
33
|
+
|
|
34
|
+
# 无进展超时兜底(秒)——协议参数的默认值(gen_protocol 固化进
|
|
35
|
+
# protocol.json;engine/fake_agent 的签名默认与 CLI default 同源于此)。
|
|
36
|
+
DEFAULT_STALL_TIMEOUT = 600
|
|
37
|
+
|
|
21
38
|
# ---------------------------------------------------------------
|
|
22
39
|
# git 基础操作
|
|
23
40
|
# ---------------------------------------------------------------
|
|
@@ -314,6 +331,9 @@ def cat_batch(bare, paths):
|
|
|
314
331
|
finally:
|
|
315
332
|
proc.stdin.close()
|
|
316
333
|
proc.wait()
|
|
334
|
+
# 关输出管道:Popen 的 pipe 是文件对象,不关会留 ResourceWarning
|
|
335
|
+
# (wait() 之后 close 安全——进程已退出,无未读数据风险)
|
|
336
|
+
proc.stdout.close()
|
|
317
337
|
return result
|
|
318
338
|
|
|
319
339
|
|
package/meeting_loop.py
CHANGED
|
@@ -505,7 +505,7 @@ def make_responder(pure, fork_source=None, fork_cwd=None,
|
|
|
505
505
|
else:
|
|
506
506
|
reason_txt = "无进展超时(stall),未完全共识"
|
|
507
507
|
# fork 模式(cwd=主项目)下“工作区根目录”有歧义——路径必须绝对
|
|
508
|
-
result_path = os.path.join(workdir,
|
|
508
|
+
result_path = os.path.join(workdir, meeting_fs.RESULT_MD)
|
|
509
509
|
prompt = (f"讨论已收敛({reason_txt})。"
|
|
510
510
|
f"请写 result.md 到 {result_path},总结讨论结论。")
|
|
511
511
|
if retry:
|