pi-multi-viewers 0.1.0

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.
Files changed (35) hide show
  1. package/AGENTS.md +192 -0
  2. package/README.md +153 -0
  3. package/docs/design.md +288 -0
  4. package/docs/examples/first-experiment/README.md +42 -0
  5. package/docs/examples/first-experiment/work-a/AGENTS.md +10 -0
  6. package/docs/examples/first-experiment/work-a/a/0001.md +65 -0
  7. package/docs/examples/first-experiment/work-a/a/0002.md +70 -0
  8. package/docs/examples/first-experiment/work-a/perspective.md +5 -0
  9. package/docs/examples/first-experiment/work-b/AGENTS.md +10 -0
  10. package/docs/examples/first-experiment/work-b/b/0001.md +100 -0
  11. package/docs/examples/first-experiment/work-b/perspective.md +6 -0
  12. package/docs/reviews/2026-09-10-e2e10-fork-source-modes-review.md +366 -0
  13. package/docs/reviews/2026-09-10-e2e11-forkmode-guards-review.md +229 -0
  14. package/docs/reviews/2026-09-10-e2e12-code-review.md +346 -0
  15. package/docs/reviews/2026-09-11-e2e13-code-review.md +284 -0
  16. package/docs/reviews/2026-09-11-e2e14-observability-review.md +216 -0
  17. package/docs/reviews/README.md +36 -0
  18. package/docs/test-methodology.md +258 -0
  19. package/extensions/multi-viewers-say/index.ts +156 -0
  20. package/fake_agent.py +120 -0
  21. package/human_sayer.py +144 -0
  22. package/human_viewer.py +215 -0
  23. package/meeting_core.py +255 -0
  24. package/meeting_engine.py +733 -0
  25. package/meeting_fs.py +1066 -0
  26. package/meeting_loop.py +606 -0
  27. package/package.json +41 -0
  28. package/prompts/multi-viewers.md +94 -0
  29. package/scripts/check-residue.sh +190 -0
  30. package/scripts/mv.sh +325 -0
  31. package/start_discussion.py +1485 -0
  32. package/templates/AGENTS.md.tpl +100 -0
  33. package/templates/agent.md.tpl +9 -0
  34. package/templates/gitignore.tpl +7 -0
  35. package/templates/spec-readme.md.tpl +87 -0
@@ -0,0 +1,733 @@
1
+ #!/usr/bin/env python3
2
+ """meeting_engine.py —— Meeting 模式唯一状态机引擎(v2 重构)。
3
+
4
+ 设计原则(用户确认 2026-08-09:合理是第一原则):
5
+ - 状态机只此一份——fake_agent 与 meeting_loop 通过注入 responder 复用
6
+ - 协议信号(freezing/af/pass/concluded)由引擎确定性写,不经过 responder
7
+ - responder 只负责"响应一轮"(LLM 唤醒或随机决策),返回是否产出
8
+ - 判定函数(all_last_types/aggregate_mode/rr_next_speaker 等)集中于此,不重复实现
9
+ - 写 af 判定统一在循环顶部(消掉 mode 分派中的重复)
10
+ - max_rr 兜底收尾由 resultWriter 写(与 RR 经验一致,非 starter)
11
+
12
+ responder 接口:
13
+ responder(workdir, agent, head, meta, is_first, rr_turn, retry,
14
+ finalizing=False, finalize_reason=None) -> bool
15
+ - rr_turn=True → RR 轮次(pass,无异议——单向流)
16
+ - rr_turn=False → meeting(message / freezing)
17
+ - retry=True → 上一轮无产出,强制表态
18
+ - finalizing=True → 收尾:写 result.md(收尾指令,双面约束)
19
+ - finalize_reason → 收尾原因(consensus/quota/stall)
20
+ - 返回值被忽略(审核#12 死契约):产出判定靠 _produced(消息文件数)
21
+ ——responder 不需要返回 bool,返回 True/False 皆可
22
+ """
23
+
24
+ import json
25
+ import os
26
+ import random
27
+ import re
28
+ import time
29
+
30
+ from meeting_fs import (
31
+ git_head, git_pull, git_commit, git_push,
32
+ list_my_messages, new_messages_with_meta, next_msg_id,
33
+ read_point, read_message, write_message, commit_message, setup_commit,
34
+ run_git, parse_frontmatter, serialize_message,
35
+ git_show, is_message_file, parse_log_nameonly,
36
+ read_protocol, cat_batch, remove_message, write_text, file_size,
37
+ bare_of_base, bare_of_workdir, log,
38
+ )
39
+ from meeting_core import (
40
+ meeting_speak_count as core_meeting_speak_count,
41
+ next_in_order as core_next,
42
+ should_write_af, can_start_rr, validate_and_fix, is_all_last_in,
43
+ aggregate_mode as core_aggregate_mode,
44
+ has_new_messages_for_me,
45
+ )
46
+ POLL_INTERVAL = 2.0
47
+ JITTER = 0.3
48
+ MAX_RETRY = 3
49
+
50
+
51
+ # ---------------------------------------------------------------
52
+ # 判定辅助(集中于此,fake/loop 不重复实现)
53
+ # ---------------------------------------------------------------
54
+
55
+ def participants(bare):
56
+ """参与者列表(order)——单一来源 = bare HEAD 的 protocol.json。
57
+
58
+ **收 bare 而非 workdir**(调用方用 `meeting_fs.bare_of_workdir` 推):
59
+ 统一契约,避免"函数体内自推路径"成为第三种约定(另两种:调用方推、
60
+ 按值形状猜)。读 bare 而非本地副本的理由见 meeting_fs.read_protocol
61
+ docstring(LLM 有 bash,可改本地副本)。
62
+ """
63
+ return list(read_protocol(bare).get("participants", []))
64
+
65
+
66
+ def result_writer(bare):
67
+ """resultWriter(收尾写者);未配置 → 参与者末位。
68
+
69
+ 注意默认值必须**惰性求值**:`dict.get(k, participants(bare)[-1])`
70
+ 的第二参数总会被求值——既多读一次协议,又会在 participants 为空时
71
+ 抛 IndexError(即使 resultWriter 已配置)。
72
+ """
73
+ proto = read_protocol(bare)
74
+ rw = proto.get("resultWriter")
75
+ if rw:
76
+ return rw
77
+ parts = list(proto.get("participants", []))
78
+ return parts[-1] if parts else ""
79
+
80
+
81
+ def each_agent_messages(bare, agents):
82
+ """bare 树中每个 agent 的完整消息列表(frontmatter dict,按序号升序)。
83
+
84
+ 所有 bare 读取的单一入口(审核 D:不搞两套读取——_each_agent_last
85
+ 由本函数派生)。供 core 判定使用(设计 10.2 状态机只此一份:
86
+ 引擎只做 bare 组装,判定归 core)。
87
+ 批量读(review5 M3):git cat-file --batch 一次进程读全部,
88
+ 不用每消息一次 git_show(O(n) subprocess)。
89
+ """
90
+ r = run_git(bare, "ls-tree", "-r", "-z", "--name-only", "HEAD", check=False)
91
+ files = [f for f in r.stdout.rstrip("\0").split("\0") if f]
92
+ msg_files = [f for f in files if is_message_file(f)]
93
+ if not msg_files:
94
+ return {a: [] for a in agents}
95
+ # 批量读内容(git cat-file --batch:一次进程读全部消息文件)
96
+ contents = cat_batch(bare, msg_files)
97
+ by_agent = {}
98
+ for f in msg_files:
99
+ ag, name = f.split("/")
100
+ by_agent.setdefault(ag, []).append((name, f))
101
+ result = {}
102
+ for a in agents:
103
+ entries = sorted(by_agent.get(a, []))
104
+ msgs = []
105
+ for name, path in entries:
106
+ c = contents.get(path)
107
+ if c is None:
108
+ continue
109
+ fm = parse_frontmatter(c)
110
+ if fm:
111
+ msgs.append(fm)
112
+ result[a] = msgs
113
+ return result
114
+
115
+
116
+ def each_agent_last(bare, agents):
117
+ """bare 树中每个 agent 最后一条消息的 {type, mode, next}。
118
+
119
+ 由 _each_agent_messages(唯一 bare 读取)派生:取每 agent 列表末尾。
120
+ 这是状态判定的数据源(设计 11.8)。
121
+ """
122
+ messages = each_agent_messages(bare, agents)
123
+ result = {}
124
+ for a in agents:
125
+ msgs = messages.get(a, [])
126
+ if not msgs:
127
+ result[a] = None
128
+ continue
129
+ fm = msgs[-1]
130
+ result[a] = {
131
+ "type": fm.get("type"),
132
+ "mode": fm.get("mode"),
133
+ "next": fm.get("next"),
134
+ }
135
+ return result
136
+
137
+
138
+ def aggregate_mode(bare, agents):
139
+ """全局模式(审核#6:判定下沉 core,引擎只传数据)。
140
+
141
+ 仅 stall 分支用(pull 后需要新鲜数据重检,L-M2);循环内模式判定
142
+ 用循环顶组装的 messages 派生。
143
+ """
144
+ return core_aggregate_mode(each_agent_last(bare, agents))
145
+
146
+
147
+ def rr_next_speaker(bare, agents):
148
+ """RR 阶段轮到谁 = 最近的参与者消息的 next。
149
+
150
+ RR 串行不变量(设计 11.8):RR 阶段同一时刻只有一个 agent 写 +
151
+ push(next 链唯一确定下一位)→ 最后发言者 = 最近的一条参与者消息
152
+ (helper 设计 3.4:human 插话不算发言者,不参与轮转)。
153
+ 故"最近的参与者消息"的 next 就是下一位发言人。
154
+
155
+ human 插话(HEAD = human 消息,无 next)会打断轮转链——逐 commit
156
+ 往前找最后一条参与者消息(helper 设计 3.4;否则 next 缺失 →
157
+ 所有 agent 的 nxt != agent → RR 死锁,只能等 stall 兑底)。
158
+
159
+ **等价性约束**(用户 2026-08-30 严格核对):非 human 场景必须与
160
+ 原实现完全等价(git log -1 + 文件列表最后一个消息文件 msg_files[-1]
161
+ ——原语义),仅 human 场景走逐 commit 回退路径。
162
+ """
163
+ # ① HEAD 是参与者消息 → 原实现语义不变(git log -1,取最后一个消息文件)
164
+ # --no-quotepath:git log --name-only 默认转义中文路径(quotepath,
165
+ # 带引号路径 is_message_file 不匹配 → msg_files 空 → next 解析失败
166
+ # → RR 死锁;2026-09-09 中文视角名实测)
167
+ r = run_git(bare, "log", "-1", "--name-only", "--format=%H",
168
+ check=False)
169
+ lines = r.stdout.strip().splitlines()
170
+ files = [l.strip() for l in lines[1:] if l.strip()]
171
+ msg_files = [f for f in files if is_message_file(f)]
172
+ if msg_files and msg_files[-1].split("/")[0] in agents:
173
+ c = git_show(bare, "HEAD", msg_files[-1])
174
+ if c is None:
175
+ return None
176
+ fm = parse_frontmatter(c)
177
+ if not fm:
178
+ return None
179
+ return fm.get("next")
180
+
181
+ # ② HEAD 非参与者消息(human 插话)→ 逐 commit 往前找最近的参与者
182
+ # 消息(--skip=1 跳过 HEAD;每组文件仍取最后一个消息文件,对齐①语义)
183
+ r = run_git(bare, "log", "--name-only", "--format=%H", "--skip=1",
184
+ check=False)
185
+ for commit, fls in parse_log_nameonly(r.stdout):
186
+ msg_files = [f for f in fls if is_message_file(f)]
187
+ if not msg_files:
188
+ continue
189
+ last = msg_files[-1]
190
+ if last.split("/")[0] not in agents:
191
+ continue # 非参与者(human 插话)→ 继续往前找
192
+ c = git_show(bare, commit, last)
193
+ if c is None:
194
+ return None
195
+ fm = parse_frontmatter(c)
196
+ if not fm:
197
+ return None
198
+ return fm.get("next")
199
+ return None
200
+
201
+
202
+ def rr_active_count(messages, agents):
203
+ """RR 阶段轮数:starter(order[0])的 mode==round-robin 消息数。
204
+
205
+ 设计 3:max_rr_rounds = RR 轮数,数 starter 的 mode==round-robin
206
+ 消息(第一条 pass 本身 + 后续),不从讨论开始数(避免
207
+ meeting 阶段消息混入)。
208
+ messages: _each_agent_messages 的产物(L-M2:循环顶组装一次,派生计数)
209
+ """
210
+ starter = agents[0]
211
+ return sum(1 for fm in messages.get(starter, [])
212
+ if fm.get("mode") == "round-robin")
213
+
214
+
215
+ def human_msg_count(bare):
216
+ """bare 中 human 插话数(配额增量,helper 设计 §4)。
217
+
218
+ human 消息 = human/ 目录下的消息文件(human 不在 participants)。
219
+ 从共享事实(bare 树)推导——无状态传递、崩溃安全。
220
+ 用途:meeting 配额上限 = max_meeting + human_msg_count(human 每
221
+ 发言一次所有 agent 配额 +1,抵消响应消耗——helper 设计 4.1)。
222
+ """
223
+ r = run_git(bare, "ls-tree", "-r", "-z", "--name-only", "HEAD", check=False)
224
+ files = [f for f in r.stdout.rstrip("\0").split("\0") if f]
225
+ return sum(1 for f in files
226
+ if f.startswith("human/") and is_message_file(f))
227
+
228
+
229
+ def _produced(workdir, agent, before):
230
+ return len(list_my_messages(workdir, agent)) > before
231
+
232
+
233
+ # ---------------------------------------------------------------
234
+ # 协议信号(引擎确定性写,不经过 responder)
235
+ # ---------------------------------------------------------------
236
+
237
+ def write_af_if_no_rr(bare, workdir, agent, agents):
238
+ """写 all-freezing,但 pull 后重检:bare 已有 RR(任一 agent 最后一条
239
+ mode==round-robin)则放弃。
240
+
241
+ 设计(并发竞态修复):should_write_af 检查在 pull 前做(基于旧视图),
242
+ pull 后可能发现别人已启动 RR——此时补写 af 会让最新 commit 变回
243
+ all-freezing → 全局 mode 判定回退 → 卡死(实测 5-agent)。
244
+ 决定必须基于最新共享事实:pull 后重检,过期决定不执行。
245
+
246
+ 判定用聚合(_each_agent_last,设计 11.8),不用 git grep 全文:
247
+ grep 不区分 frontmatter/正文且是正则('-' 为范围符),LLM 讨论
248
+ 正文引用该字符串会误匹配 → 死锁(code-review 实测)。
249
+ 注意:head 不传(P1)——pull 后内部重取(pull 可能带回新 commit,
250
+ seen_at 必须是最新共享事实)。
251
+ """
252
+ # 写消息前先 pull(同步最新)
253
+ git_pull(workdir)
254
+ # pull 后重取 head(pull 可能带回新 commit,seen_at 必须是最新共享事实)
255
+ head = git_head(workdir)
256
+ # 重检:bare 是否有 RR 已启动(任一 agent 最后一条 mode==round-robin)
257
+ lasts = each_agent_last(bare, agents)
258
+ if any(v is not None and v.get("mode") == "round-robin" for v in lasts.values()):
259
+ log(agent, "bare 已有 RR 启动——放弃补写 af")
260
+ return
261
+ # 无 RR → 正常写 af
262
+ write_protocol_signal(workdir, agent, "all-freezing", "all-freezing")
263
+
264
+
265
+ def write_protocol_signal(workdir, agent, type_, mode, next_=None):
266
+ """写流程控制消息(freezing/af/pass/concluded)并 commit+push。
267
+
268
+ pass:RR 轮次的 loop 代写表态(responder 无法产出时),带 next 轮转链。
269
+ 写前同步(review5 F1/L1):pull 后自取 head——调用方 head 可能是
270
+ pull 前的旧值(F1 双路径不一致);统一"写前同步"(pull + 重取 head)。
271
+
272
+ seen_at 沿用上一个(用户 10030 定案):loop 自己生成的新消息不能更新
273
+ seen_at(seen_at = LLM 处理过的最新消息位置);loop 消息的 seen_at
274
+ = 当前读取点(LLM 最后处理点),不推进。
275
+ """
276
+ # 写前同步:pull 带回并发新 commit(F1)
277
+ git_pull(workdir)
278
+ # seen_at = 当前读取点(LLM 最后处理点,loop 消息不推进)——
279
+ # 无历史(agent 从未 responder 成功)→ 兜底到讨论起点(仓库根 commit
280
+ # = setup 提交,大家读到 question.md 的时刻)。不用 head:head 随并发
281
+ # push 漂移,会把 agent 从未读过的消息虚假标记已读(实测 2026-09-03
282
+ # crash_recovery flaky 根因);起点固定,从起点读一条不漏。
283
+ seen_at = read_point(workdir, agent) or setup_commit(workdir)
284
+ head = git_head(workdir) # validate_and_fix 用(协议信号正文不含 head 语义)
285
+ msg_id = next_msg_id(workdir, agent)
286
+ fm = {
287
+ "from": agent,
288
+ "type": type_,
289
+ "mode": mode,
290
+ "seen_at": seen_at,
291
+ "summary": f"[loop] {type_}",
292
+ }
293
+ if type_ == "pass":
294
+ # pass:RR 轮次表态(含 starter 启动 RR 的第一条),带 next 轮转链
295
+ fm["mode"] = "round-robin"
296
+ fm["next"] = next_
297
+ # 协议信号路径:放行引擎专用 type(all-freezing/concluded),M1;
298
+ # seen_at 保留(loop 消息沿用上一个,不强制为 head,用户 10030)
299
+ fixed, _ = validate_and_fix(fm, agent, mode, head,
300
+ allow_protocol_types=True,
301
+ loop_message=True)
302
+ path = f"{agent}/{msg_id}.md"
303
+ write_message(workdir, path, fixed, f"loop protocol signal: {type_}")
304
+ git_commit(workdir, [path], commit_message(agent, msg_id))
305
+ git_push(workdir)
306
+ log(agent, f"[loop] 写流程控制 {type_} ({path})")
307
+
308
+
309
+ def respond_with_fallback(workdir, agent, responder, head, meta, is_first,
310
+ rr_turn, before, agents):
311
+ """一次响应轮:responder 写内容文件 → 引擎补全提交 → 查产出。
312
+
313
+ 职责分离:responder(LLM)只写内容文件;补全字段/commit/push
314
+ 由本函数(引擎)调用 commit_new_files 接管。无产出则重试,
315
+ 重试仍失败 → loop 代写流程控制(无静默铁律的完整性)。
316
+ """
317
+ mode = "round-robin" if rr_turn else "meeting"
318
+ # ① responder 写内容文件
319
+ responder(workdir, agent, head, meta, is_first, rr_turn, False)
320
+ # ② 引擎补全提交(responder 只写,流程归引擎)
321
+ commit_new_files(workdir, agent, head, mode)
322
+ if _produced(workdir, agent, before):
323
+ return True
324
+ for _ in range(MAX_RETRY):
325
+ log(agent, f"无产出(无静默铁律)——重试")
326
+ responder(workdir, agent, head, meta, is_first, rr_turn, True)
327
+ commit_new_files(workdir, agent, head, mode)
328
+ if _produced(workdir, agent, before):
329
+ return True
330
+ # ③ responder 始终无法产出 → loop 代写(保证流程完整)
331
+ if rr_turn:
332
+ nxt_a = core_next(agents, agent)
333
+ log(agent, f"responder 无法产出——loop 代写 pass(无静默铁律最后执行)")
334
+ write_protocol_signal(workdir, agent, "pass", "round-robin",
335
+ nxt_a)
336
+ else:
337
+ log(agent, f"responder 无法产出——loop 代写 freezing")
338
+ write_protocol_signal(workdir, agent, "freezing", "meeting")
339
+ # 代写 = 无产出(设计 11.1:只数 responder 成功产出的轮,
340
+ # 代写不耗配额)——返回 False(审核#1:原先 return True 让 C' 修复失效)
341
+ return False
342
+
343
+
344
+ # ---------------------------------------------------------------
345
+ # 唯一状态机
346
+ # ---------------------------------------------------------------
347
+
348
+ def commit_new_files(workdir, agent, head, mode):
349
+ """loop 接管流程:校验/补全 agent 写的内容文件 + commit + push。
350
+
351
+ LLM(responder)只写内容文件(最小 frontmatter,可能漏字段),
352
+ loop 在此确定性补全协议字段并提交:
353
+ - from/seen_at/mode:validate_and_fix 修复(mode 无条件覆盖为当前阶段)
354
+ - next:RR 阶段(round-robin)的 pass/message 补轮转链(LLM 不知道顺序)
355
+
356
+ mode: 当前阶段(meeting/round-robin)。
357
+ 返回值被忽略(调用点用 _produced 判定产出,review4 L1)——
358
+ new_files 非空即 True,即使全部被校验拦截删除。
359
+ """
360
+ agent_dir = os.path.join(workdir, agent)
361
+ if not os.path.isdir(agent_dir):
362
+ return False
363
+ files = sorted(os.listdir(agent_dir))
364
+ new_files = [f for f in files
365
+ if re.match(r"^\d{4}\.md$", f)
366
+ and not _is_committed(workdir, f"{agent}/{f}")]
367
+ if not new_files:
368
+ return False
369
+ for f in new_files:
370
+ path = os.path.join(agent_dir, f)
371
+ fm, content = read_message(workdir, f"{agent}/{f}") # IO 归 fs(L16)
372
+ if content is None:
373
+ continue
374
+ if not fm:
375
+ log(agent, f"[fix] {f} 无 frontmatter 或块不完整(parse 返回 None,A1),删除等待重写")
376
+ remove_message(workdir, f"{agent}/{f}")
377
+ continue
378
+ fixed, errors = validate_and_fix(fm, agent, mode, head)
379
+ if errors:
380
+ # 语义字段(type)非法——拦截,不提交(坏消息不进 bare,
381
+ # 设计 11.4)+ 删除滞留文件(审核#14:否则孤儿未跟踪文件每轮
382
+ # 重扫白耗重试;删除后序号复用,responder 重写自然接续)
383
+ log(agent, f"[fix] {f} 校验失败: {errors}——删除,等待重写")
384
+ remove_message(workdir, f"{agent}/{f}")
385
+ continue
386
+ # RR 阶段(round-robin)的消息必须带 next(轮转链)——
387
+ # next 是轮转顺序(协议状态),LLM 不知道,loop 确定性补写。
388
+ # pass/message 都补(设计 11.6):单向流下 LLM 正常只写 pass,
389
+ # 但违规写 message 时若不补 next → rr_next_speaker 返回 None
390
+ # → 轮转链断(防御性兜底)
391
+ # review5 A6:next 无条件覆盖为顺序下一位(与 mode 无条件覆盖对称)。
392
+ # 只补缺省会让 LLM 写错的 next 保留 → 轮转顺序混乱。单向流下
393
+ # RR 只有 pass(无异议),next 是轮转顺序(协议状态),LLM 不知道。
394
+ if mode == "round-robin" and fixed.get("type") in ("pass", "message"):
395
+ fixed["next"] = core_next(
396
+ participants(bare_of_workdir(workdir)), agent)
397
+ new_content = serialize_message(fixed, content)
398
+ if new_content and new_content != content:
399
+ write_text(workdir, f"{agent}/{f}", new_content)
400
+ git_commit(workdir, [f"{agent}/{f}"],
401
+ commit_message(agent, f.split(".")[0]))
402
+ git_push(workdir)
403
+ log(agent, f"提交并推送 {len(new_files)} 条新消息")
404
+ return True
405
+
406
+
407
+ def _is_committed(workdir, path):
408
+ """文件是否已提交(tracked)。
409
+
410
+ 用 git ls-files(tracked = 已提交过),不用 git status --porcelain:
411
+ status 会把"已提交但被 LLM 覆盖修改"的文件显示为 modified → 被当新
412
+ 消息重新提交 → 消息不可变(设计 3.1)被破坏(审核 G3)。
413
+ tracked 文件即使 modified 也拒绝提交。
414
+ -z:中文路径不带引号转义(2026-09-09 引号 bug——git quotepath 默认
415
+ 转义非 ASCII,引号路径永远不匹配 → 已提交文件被当新文件重复提交)。
416
+ """
417
+ r = run_git(workdir, "ls-files", "-z", "--", path, check=False)
418
+ return path in r.stdout.rstrip("\0").split("\0")
419
+
420
+
421
+ def _stall_elapsed(bare, agents, last_head, last_head_time, head):
422
+ """无进展累计(秒)——review5 M1 重写,去 %ct 墙钟依赖。
423
+
424
+ 旧实现用 bare HEAD 的 %ct(commit 时间)与 time.time() 比较——四失真源:
425
+ ① 跨机时钟快 → %ct 未来值 → stall 永不触发(死锁无兜底);② 跨机时钟
426
+ 慢 → 原生 commit %ct 偏旧 → 活跃期误触发;③ 同机 pull --rebase 重放
427
+ 更新 %ct → 系统性推迟;④ setup commit 起点 → 创建与启动间隔 >600s
428
+ 即空转收尾。
429
+
430
+ 新语义:HEAD 变化是共享事实(无时钟参与),本地墙钟只测"相邻两轮
431
+ 看到的 HEAD 未变的等待累计"。仅当 bare 存在消息文件(讨论真正开始,
432
+ setup commit 不算)后累计。返回 (累计秒, 新的 last_head, 新的起始时间)。
433
+ """
434
+ r = run_git(bare, "ls-tree", "-r", "-z", "--name-only", "HEAD", check=False)
435
+ has_msg = any(is_message_file(f)
436
+ for f in r.stdout.rstrip("\0").split("\0"))
437
+ if not has_msg:
438
+ return 0.0, head, time.time() # 讨论未开始(setup 阶段)→ 不累计
439
+ if head == last_head:
440
+ return time.time() - last_head_time, last_head, last_head_time
441
+ return 0.0, head, time.time()
442
+
443
+
444
+ def _result_md_valid(workdir):
445
+ """result.md 有效性校验:存在 + 非空 + 有实质内容(>50 字节)。
446
+
447
+ LLM 可能写空文件/仅 frontmatter——存在性检查退化为空提交(审核 A2)。
448
+ 存在性与大小经由 fs.file_size 一次判定(-1 = 不存在/不可读)。
449
+ """
450
+ return file_size(workdir, "result.md") > 50
451
+
452
+
453
+ def finalize_discussion(workdir, agent, responder, head, reason="consensus"):
454
+ """收尾:唤醒 resultWriter 写 result.md → 校验 → concluded 同 commit。
455
+
456
+ 设计(继承主协议 7.4):全员 pass 判定归引擎,result.md 内容归
457
+ resultWriter 的 LLM(收尾指令)。双面约束:LLM 只在收尾指令时写。
458
+ reason: consensus(全员 pass)/ quota(max_rr 兜底,标注未完全共识)
459
+ """
460
+ log(agent, f"收尾({reason})——唤醒写 result.md")
461
+ # ① 唤醒 rw 写 result.md(收尾指令;双面约束:此时才允许写)
462
+ responder(workdir, agent, head, [], False, False, False,
463
+ finalizing=True, finalize_reason=reason)
464
+ # ② 校验 result.md 有效(存在 + 非空);无则重试(无静默铁律的扩展)
465
+ result_path = os.path.join(workdir, "result.md")
466
+ for _ in range(MAX_RETRY):
467
+ if _result_md_valid(workdir):
468
+ break
469
+ log(agent, "result.md 未生成或无效——重试")
470
+ responder(workdir, agent, head, [], False, False, True,
471
+ finalizing=True, finalize_reason=reason)
472
+ # ③ 有效则 commit(产物非消息);重试耗尽仍无效 → loop 兜底代写
473
+ # (保证收尾必有产物,不静默缺失——审核 A2)
474
+ if _result_md_valid(workdir):
475
+ _commit_result_md(workdir, agent, "discuss: result.md")
476
+ else:
477
+ log(agent, "result.md 重试后仍无效——loop 兜底代写")
478
+ write_text(workdir, "result.md",
479
+ "# 讨论结论\n\n(resultWriter 未能生成有效 result.md,"
480
+ f"由本地循环兜底代写。收尾原因:{reason})\n")
481
+ _commit_result_md(workdir, agent, "discuss: result.md (loop fallback)")
482
+ write_protocol_signal(workdir, agent, "concluded", "concluded")
483
+ return True
484
+
485
+
486
+ def _commit_result_md(workdir, agent, subject):
487
+ """幂等提交 result.md(审核#1):已提交无改动 → 跳过。
488
+
489
+ git commit 无改动退出码=1 → RuntimeError → 顶层 except → 重进
490
+ finalize → 确定性死循环。分 commit 窗口的幂等保障。
491
+ 用 git status --porcelain(不用 git diff --quiet——diff 看不到
492
+ 未跟踪新文件,会误判"已提交"导致 result.md 永不 commit)。
493
+ """
494
+ r = run_git(workdir, "status", "--porcelain", "--", "result.md",
495
+ check=False)
496
+ if r.stdout.strip():
497
+ git_commit(workdir, ["result.md"], subject)
498
+ else:
499
+ log(agent, "result.md 无改动——跳过 commit(幂等)")
500
+
501
+
502
+ def agent_loop(workdir, agent, responder, max_meeting=10, max_rr=7,
503
+ poll_interval=POLL_INTERVAL, stall_timeout=600):
504
+ """主状态机(v2)。responder 注入:响应一轮并返回是否产出。
505
+
506
+ stall_timeout: 无进展超时兜底(秒,默认 600=10 分钟)。任何 agent
507
+ 检测到 bare 无新 commit 超过此时间 → resultWriter 强制收尾
508
+ (离线参与者死锁兜底,主协议 7.6)。
509
+ """
510
+ log(agent, f"meeting engine v2 启动 (meeting配额={max_meeting}, "
511
+ f"rr配额={max_rr})")
512
+ agents = participants(bare_of_workdir(workdir))
513
+ rw = result_writer(bare_of_workdir(workdir))
514
+ # meeting 发言轮数不存 RAM——每轮从 bare 重算(_meeting_speak_count):
515
+ # loop 崩溃/重启不丢配额(审核#1,状态从共享事实推导)
516
+ # stall 无进展累计:本地墙钟 + HEAD 观测(review5 M1,无 %ct 依赖)
517
+ last_head, last_head_time = None, time.time()
518
+
519
+ while True:
520
+ # 讨论被清理(cleanup 删除目录)→ 退出。loop 生命周期跟随其服务
521
+ # 对象,不依赖 cleanup 杀进程(职责边界,用户 2026-08-31 定)。
522
+ # repo.git 是讨论存在的唯一标志(workdir 被删后进程 cwd 仍可用,
523
+ # 但 bare 消失 = 讨论不存在)。
524
+ bare = bare_of_workdir(workdir)
525
+ if not os.path.isdir(bare):
526
+ log(agent, "讨论目录已移除(cleanup)——退出")
527
+ return
528
+ try:
529
+ git_pull(workdir)
530
+ head = git_head(workdir)
531
+ bare = bare_of_workdir(workdir)
532
+ # 循环顶一次全量 bare 读取(L-M2:消除每轮 3-4 次重复 subprocess
533
+ # 重读——同一轮内无写入、数据稳定)。**消息列表类**判定由此派生
534
+ # (lasts/mode/冻结集合等);**计数与时序类**判定(human_msg_count /
535
+ # _stall_elapsed / RR 分支的 rr_next_speaker)各自单独读 bare——
536
+ # 它们无状态、按需调用,不为省 ~3ms/轮 而跨函数传快照。
537
+ messages = each_agent_messages(bare, agents)
538
+ lasts = {a: (messages[a][-1] if messages[a] else None)
539
+ for a in agents}
540
+ all_last = {a: (lasts[a].get("type") if lasts[a] else None)
541
+ for a in agents}
542
+ mode = core_aggregate_mode(lasts)
543
+
544
+ # ===== ① concluded → 退出 =====
545
+ if mode == "concluded":
546
+ log(agent, "讨论已收尾,退出")
547
+ return
548
+
549
+ # ===== ①.5 无进展超时兜底(审核 H/K:离线参与者死锁)=====
550
+ # bare 无新 commit 超过 stall_timeout → rw 强制收尾。覆盖:
551
+ # 离线者 last=None(全员冻结永不成立)、RR turn-holder 掉线、
552
+ # 全员等待无人响应。T 默认 600s(provider API 可能很慢)。
553
+ # 仅 rw 执行收尾(单一写者),其他 agent 只等待。
554
+ stall, last_head, last_head_time = _stall_elapsed(
555
+ bare, agents, last_head, last_head_time, head)
556
+ if stall > stall_timeout:
557
+ log(agent, f"无进展超过 {stall_timeout}s——超时兜底")
558
+ if agent == rw:
559
+ finalize_discussion(workdir, agent, responder, head,
560
+ reason="stall")
561
+ continue
562
+ # 非 rw:rw 可能离线(崩溃/未启动)——接管收尾(审核#2,
563
+ # 主协议 7.6"任一 agent 可接管",meeting 收紧成仅 rw 是缺口)。
564
+ # 接管仲裁(review5 A3):两个非 rw 同时接管 → 各自 finalize
565
+ # → 双写 result.md → rebase 冲突真死锁。仲裁:
566
+ # ① 先 pull 重检 bare 是否已收尾(result.md 已提交 → 让位)
567
+ # ② 写接管声明消息(推进 HEAD → 其他 agent 的 stall 判定被
568
+ # 重置 → 天然唯一接管者;push 冲突由现有容错重试仲裁,
569
+ # 先到者赢)③ finalize。
570
+ git_pull(workdir)
571
+ if aggregate_mode(bare, agents) == "concluded":
572
+ log(agent, "收尾已完成(他人接管)——让位")
573
+ continue
574
+ r = run_git(bare, "show", "HEAD:result.md", check=False)
575
+ if r.returncode == 0:
576
+ log(agent, "result.md 已提交(他人收尾)——让位")
577
+ continue
578
+ # 写接管声明(freezing——推进 HEAD + 不破坏流程)
579
+ log(agent, "rw 未收尾——声明接管(推进 HEAD 天然唯一接管者)")
580
+ write_protocol_signal(workdir, agent, "freezing",
581
+ aggregate_mode(bare, agents) or "meeting")
582
+ finalize_discussion(workdir, agent, responder, head,
583
+ reason="stall")
584
+ continue
585
+
586
+ # ===== ② 全员冻结 → 写 af(统一判定,穿透 mode 分派)=====
587
+ # 宽松条件:所有最后一条 ∈ {freezing, af};我已 af 则跳过
588
+ # pull 后重检 bare(写 af 专用函数):RR 已启动则放弃(并发竞态修复)
589
+ if should_write_af(all_last) and all_last.get(agent) != "all-freezing":
590
+ write_af_if_no_rr(bare, workdir, agent, agents)
591
+ continue
592
+
593
+ # ===== ③ all-freezing 阶段:starter 启动 RR =====
594
+ if mode == "all-freezing":
595
+ # starter 严格条件:全员 af → 启动 RR(用户 10084/10089:
596
+ # 唤醒目的 = 有新的文件可提交给 LLM 处理。starter 冻结期间
597
+ # 别人可能发了 message(读取点后),必须唤醒读完再 pass;
598
+ # 无新消息才确定性写 pass)
599
+ if can_start_rr(all_last) and agent == agents[0]:
600
+ rp = read_point(workdir, agent)
601
+ meta = new_messages_with_meta(workdir, rp, agent)
602
+ if has_new_messages_for_me(meta, agent):
603
+ # 有冻结期间的新消息 → 唤醒 LLM 读 + 写 pass
604
+ log(agent, "start RR——有冻结期间新消息,唤醒 LLM 读取")
605
+ before = len(list_my_messages(workdir, agent))
606
+ respond_with_fallback(
607
+ workdir, agent, responder, head, meta,
608
+ False, True, before, agents)
609
+ else:
610
+ # 无新消息 → 确定性写 pass(启动 RR,带 next 轮转链)
611
+ nxt_a = core_next(agents, agent)
612
+ write_protocol_signal(workdir, agent, "pass",
613
+ "round-robin", nxt_a)
614
+ continue
615
+ time.sleep(poll_interval)
616
+ continue
617
+
618
+ # ===== ④ round-robin 阶段(发言锁已解)=====
619
+ if mode == "round-robin":
620
+ # 全员 pass → resultWriter 收尾(写 result.md + concluded)
621
+ # 判定收敛到 core(审核 D:判定只此一份——core 吃完整消息列表,
622
+ # 引擎只做 bare 组装)
623
+ if is_all_last_in(messages, {"pass"}):
624
+ if agent == rw:
625
+ finalize_discussion(workdir, agent, responder, head,
626
+ reason="consensus")
627
+ continue
628
+ time.sleep(poll_interval)
629
+ continue
630
+ # max_rr 兜底:RR 阶段活跃消息达上限 → resultWriter 强制收尾
631
+ # (未完全共识,result.md 标注配额耗尽)
632
+ if rr_active_count(messages, agents) >= max_rr:
633
+ if agent == rw:
634
+ log(agent, f"RR 配额耗尽({max_rr})——强制收尾")
635
+ finalize_discussion(workdir, agent, responder, head,
636
+ reason="quota")
637
+ continue
638
+ time.sleep(poll_interval)
639
+ continue
640
+ # next 驱动:轮到我才响应(惰性——只在 RR 分支才查 next)
641
+ nxt = rr_next_speaker(bare, agents)
642
+ if nxt != agent:
643
+ time.sleep(poll_interval)
644
+ continue
645
+ # 唤醒目的 = 有新的文件可提交给 LLM 处理(用户 10084):
646
+ # 轮到我但读取点后无新消息 → 无需唤醒 LLM,确定性写 pass
647
+ # (确认共识,带 next 轮转链)。有新消息 → 唤醒读完再 pass。
648
+ rp = read_point(workdir, agent)
649
+ meta = new_messages_with_meta(workdir, rp, agent)
650
+ if not has_new_messages_for_me(meta, agent):
651
+ nxt_a = core_next(agents, agent)
652
+ write_protocol_signal(workdir, agent, "pass",
653
+ "round-robin", nxt_a)
654
+ continue
655
+ before = len(list_my_messages(workdir, agent))
656
+ respond_with_fallback(workdir, agent, responder, head, meta,
657
+ False, True, before, agents)
658
+ continue
659
+
660
+ # ===== ⑤ meeting 阶段 =====
661
+ rp = read_point(workdir, agent)
662
+ # is_first = 从未写过任何消息(含 freezing)——不能 read_point=="",
663
+ # 因为 read_point 跳 freezing(冻结不推进已读,Q1 语义)——
664
+ # 首启写 freezing 的 agent 会永远 read_point=="" → 无限首启(实测漏洞)
665
+ is_first = (len(list_my_messages(workdir, agent)) == 0)
666
+ meta = new_messages_with_meta(workdir, rp, agent)
667
+
668
+ # ⑤.1 首启(全 starter 语义:question.md 出现 = 全员启动信号)
669
+ if is_first:
670
+ log(agent, "首启——参与讨论")
671
+ before = len(list_my_messages(workdir, agent))
672
+ respond_with_fallback(
673
+ workdir, agent, responder, head, meta,
674
+ True, False, before, agents)
675
+ continue
676
+
677
+ # ⑤.2 配额耗尽 → 确定性 freezing(review5 M2:提前为独立分支,
678
+ # 在触发判断之前。**为什么必须提前**:若把配额检查放在触发之后,
679
+ # "全员配额尽 + 无新消息"场景下触发恒为假 → 配额检查不可达 →
680
+ # 只能等 stall 超时兜底(脆弱且慢)。
681
+ # 配额 = 从 bare 重算(_meeting_speak_count:数 mode==meeting 且
682
+ # type==message 的消息),不存 RAM(审核#1)。
683
+ # 已冻结守卫:配额尽者写第一条 freezing 后不再重复写(否则每轮
684
+ # 重写 freezing,消息膨胀 + push 冲突)——复用 others_frozen 分支
685
+ # 的守卫模式。
686
+ # human 配额增量(helper 设计 §4):上限 = max_meeting +
687
+ # human_msg_count(human 每发言一次所有 agent 配额 +1,
688
+ # 抵消响应消耗——不加快配额耗尽)。
689
+ quota = max_meeting + human_msg_count(bare)
690
+ if (core_meeting_speak_count(messages, agent) >= quota
691
+ and all_last.get(agent) != "freezing"):
692
+ log(agent, f"meeting 配额耗尽({quota} 轮,含 human 增量"
693
+ f"{quota - max_meeting})——确定性 freezing")
694
+ write_protocol_signal(workdir, agent, "freezing", "meeting")
695
+ continue
696
+
697
+ # ⑤.3 触发判断
698
+ # 触发 = 有别人的新消息(正常响应)或 我成为唯一未冻结者(收尾表态)
699
+ # 后者:其他人都冻结了,没人会回应我——发言无意义,确定性 freezing
700
+ # (设计缺口:meeting 触发原只定义"有新消息",未覆盖"别人都冻结")
701
+ others_frozen = all(
702
+ all_last.get(o) in ("freezing", "all-freezing")
703
+ for o in agents if o != agent)
704
+ triggered = has_new_messages_for_me(meta, agent)
705
+ # 唯一未冻结者(无新消息)→ 确定性 freezing(不经 responder)
706
+ if others_frozen and not triggered and all_last.get(agent) != "freezing":
707
+ log(agent, "其他人都已冻结且无新消息——确定性 freezing")
708
+ write_protocol_signal(workdir, agent, "freezing", "meeting")
709
+ continue
710
+ # 无新消息(非唯一未冻结者)→ 等待(L2:化简,先 freezing 后 sleep)
711
+ if not triggered:
712
+ time.sleep(poll_interval + random.uniform(-JITTER, JITTER))
713
+ continue
714
+
715
+ # ⑤.4 发言锁:我最后是 freezing → 锁(不发 message,等 RR)
716
+ if all_last.get(agent) == "freezing":
717
+ time.sleep(poll_interval)
718
+ continue
719
+
720
+ # ⑤.5 配额内:responder(message/freezing 二选一,无产出重试+代写)
721
+ log(agent, f"触发——新消息 {len(meta)} 条")
722
+ before = len(list_my_messages(workdir, agent))
723
+ respond_with_fallback(
724
+ workdir, agent, responder, head, meta,
725
+ False, False, before, agents)
726
+ except Exception as e:
727
+ # 统一异常边界(审核 A3+G1/E):responder(wake_llm timeout)
728
+ # 或 git 异常(git_push 重试耗尽)穿透 while True → loop 崩溃
729
+ # → agent 永久失联 → RR next 链阻塞。单次异常不终止讨论:
730
+ # 记 log + 下一轮继续(设计 11.4 的异常路径扩展)。
731
+ log(agent, f"异常(不终止讨论): {type(e).__name__}: {e}")
732
+ time.sleep(poll_interval)
733
+