pi-multi-viewers 0.1.0 → 0.2.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.
package/AGENTS.md CHANGED
@@ -18,7 +18,9 @@ meeting_fs.py git/文件层
18
18
  meeting_engine.py 【唯一状态机】+ 协议信号 + responder 注入
19
19
  meeting_loop.py Pi 薄壳:首唤生成 fork 源 + `--session` 打开 → 存 sid → `--session-id` 续接
20
20
  fake_agent.py 测试薄壳:responder = 随机决策
21
- start_discussion.py 环境生成/启动/清理(viewers 发现 / spec 解析 / fork 源)
21
+ start_discussion.py 组合层:CLI 分发 + 环境创建/启动/清理(_resolve_spec / setup_environment)
22
+ spec_gen.py spec 生成层(question/骨架/viewers 校验与快照/agent 定义 + pi 环境探测)
23
+ observability.py 观测层(check_status / --report / --wait / loop 存活检测)
22
24
  human_viewer.py 【human 通道】只读展示(增量/--follow/游标)
23
25
  human_sayer.py 【human 通道】插话命令(单次/stdin/交互 -i)
24
26
  scripts/mv.sh wrapper(prepare/start/status/wait/cleanup/view/say)
@@ -174,7 +176,7 @@ loop、状态从 git 共享事实推导、单一事实源 = protocol.json、无
174
176
  extension × 1(multi-viewers-say 插话:零 LLM,直接 spawn human_sayer.py;
175
177
  目录发现 = `<cwd>/mv-<sessionId>-*` 最新,兜底 `mv-*`(排除
176
178
  `mv-spec-*`)并警告)
177
- + wrapper。**npm 已发布 0.1.0(2026-09-11)**。
179
+ + wrapper。**npm 已发布 0.2.0(2026-09-11)**。
178
180
  - **开发机安装(两步,缺一不可;2026-09-10 实测)**:
179
181
  ① `pi install /root/pi-multi-viewers`——**注册包**(写
180
182
  `~/.pi/agent/settings.json` 的 `packages` 数组);pi 不是"扫 node_modules
package/README.md CHANGED
@@ -89,6 +89,8 @@ scripts/mv.sh --start <spec目录> # 启动(自动挂载主 sessi
89
89
 
90
90
  # 观看:--start 会输出可直接执行的 !! 流式观看命令(复制执行)
91
91
  scripts/mv.sh --view <dir> # 或一次性增量查看(主 pi 记录 HEAD 作下轮 --since)
92
+ # --follow 会打印【状态】(meeting/all-freezing/round-robin/concluded)
93
+ # 与【进度】(meeting 消耗/上限 | freezing 集合 | rr → 下一位)
92
94
 
93
95
  # 插话 / 状态 / 收尾
94
96
  scripts/mv.sh --say <dir> "<文本>" # 插话(命令行形态;pi 内用 /multi-viewers-say)
@@ -132,14 +134,19 @@ frontmatter 字段、写文件路径、独立参与者纪律。
132
134
  ## 架构
133
135
 
134
136
  ```
135
- meeting_core.py 纯判定(冻结级联/RR/聚合)
136
- meeting_fs.py git 层
137
- meeting_engine.py 唯一状态机(六分支)
138
- meeting_loop.py Pi 薄壳(fork 首唤 + --session-id 续接 + 视角注入)
139
- start_discussion.py 环境生成/启动/状态/清理
140
- human_viewer/sayer human 插话通道
137
+ meeting_core.py 纯判定(冻结级联/RR/聚合 + 状态机词汇常量)
138
+ meeting_fs.py git 层 + fork 源生成/裁剪 + 协议与产物常量
139
+ meeting_engine.py 唯一状态机(六分支)
140
+ meeting_loop.py Pi 薄壳(fork 首唤 + --session-id 续接 + 视角注入)
141
+ start_discussion.py 组合层:CLI 分发 + 环境创建/启动/清理
142
+ spec_gen.py spec 生成(question/骨架/viewers 快照/agent 定义 + pi 环境探测)
143
+ observability.py 观测(check_status / --report / --wait / loop 存活)
144
+ human_viewer/sayer human 插话通道
141
145
  ```
142
146
 
147
+ 依赖方向单向:`core ← fs ← engine ← loop`;`start_discussion` 组合
148
+ `spec_gen` / `observability`(两者只依赖底层,互不依赖、不反向依赖主文件)。
149
+
143
150
  ## 开发
144
151
 
145
152
  ```bash
package/docs/design.md CHANGED
@@ -112,6 +112,15 @@ BOUNDARY_TYPE`)——**显式登记"历史(fork 携带)/ 本轮"的分界*
112
112
  推断都会漂移(切换叙事改措辞、时钟精度)。2026-09-11 实测:不带边界时报告
113
113
  把 717 条 fork 历史算成"本轮 367 次响应 / input 1.2M / cacheRead 136.6M"。
114
114
 
115
+ **viewer 进度行(三样观测面)**:`--follow` 每轮在【状态】之外打印
116
+ 【进度】行——`meeting <消耗>/<上限> · … | freezing <已冻结>/<总数>(名单)
117
+ | rr → <下一位>`。**状态名一律用协议术语**(`meeting`/`freezing`/`rr`;
118
+ 译成中文会引入第二套命名,"冻结"到底指 freezing 还是 all-freezing 说不清)。
119
+ 判定与 `--report` 共用 core/engine 单一实现(`meeting_speak_count` /
120
+ `frozen_agents` / `rr_next_speaker`),且共用调用方已读的 `msgs`——
121
+ **零新增 bare 读取**(实测改动前后同为 6 次 run 调用 + 1 次 cat-file 批读;
122
+ `rr` 位置仅在 RR 阶段按需调权威实现)。进度行**只在变化时打印**(避免刷屏)。
123
+
115
124
  **报告的打印位置**:`--report`(手动,任意时刻)+ `--cleanup` 前(自动,
116
125
  删目录前最后一次可读——目录删后 `--report` 不可用)。cleanup 层对报告
117
126
  fail-open(报告失败不阻断清理,且**打印**失败原因不静默)。
@@ -224,7 +233,39 @@ commit 是溯源记录、本节是长期引用点——不并存两份权威值
224
233
  `mv-spec-*`)并**警告降级**
225
234
  (宁可提示也不静默插错分析)。观看仍用 `!!` 流式(命令 API 无原生流式
226
235
  通道,bash 流式是平台原生能力)。
227
- 10. **git 守卫范围 = 从讨论 workdir 发起的操作**(`GIT_CEILING_DIRECTORIES`
236
+ 10. **question.md 的主题行措辞 = `# 分析主题:`(唯一)**:生成端
237
+ (`gen_question` / `gen_spec_skeleton`)、模板(`spec-readme`)、prompt、
238
+ 消费端(`setup_environment` 提取 `protocol.topic`)四处同一措辞;
239
+ 消费端**只认它**,旧措辞 spec → fail-fast(明确报错,不静默退化)。
240
+ 曾出现双轨(生成产 `# 讨论主题:`、消费端写兼容循环兜两种)——那
241
+ 正是"补丁掩盖设计缺陷"的形态(不改生产、只兜消费端)。
242
+ 11. **stall 接管 = 心跳式软仲裁(非互斥)**:非 rw 在无进展超时时接管收尾,
243
+ 先 pull 重检共享事实(concluded / result.md)→ 写接管声明 commit
244
+ (**只为推进 HEAD**,使对方 `_stall_elapsed` 归零而退出该分支)→
245
+ finalize。毫秒级同轮窗口存在(双方都可能 finalize),但**不是死锁**:
246
+ 靠 push 容错 + 下轮 concluded 退出兜底,产物始终唯一。声明只降低并发
247
+ 概率,**不构成互斥保证**(注释勿写"天然唯一")。实测(2026-09-11,
248
+ 381 测试中的 `TestStallTakeover`):a 进入接管 0.11s 内完成声明→收尾→
249
+ concluded,b 晚 1.4s 只见 concluded 即退出——正常时序下软仲裁生效。
250
+ 12. **`result.md` 的文件名与有效性阈值 = `meeting_fs.RESULT_MD` /
251
+ `RESULT_MD_MIN_BYTES`**:产品级核心产物,此前 8 处字面量分散在
252
+ engine/loop/viewer/start_discussion/fake_agent(`repo.git` 早已收归
253
+ fs 层,产物名却没有家)。文件叫什么、多大算有效——同一概念的两个
254
+ 数字住在一起。
255
+ 13. **分层 = core ← fs ← engine ← loop,`start_discussion` 是组合层**
256
+ (S2 拆分后):三项职责按"谁消费"拆到两个同层模块——
257
+ `spec_gen.py`(分析前的静态产物生成 + 为其服务的 pi 环境探测)、
258
+ `observability.py`(运行期只读观测);主文件保留 CLI 分发、环境创建、
259
+ 启动、清理与编排。**判据是职责而非行数**:spec 生成与观测各自 350–520
260
+ 行、概念内聚,再拆会制造碎片(import 网变复杂而收益递减)。
261
+ 两个子模块只依赖底层(core/fs/engine),互不依赖、不反向依赖主文件;
262
+ 主文件 re-export 子模块公开符号,保证 `from start_discussion import X`
263
+ 的既有调用方(tests/wrapper)零改动。
264
+ **拆分暴露的纪律**:`mock.patch` 必须打在**符号定义处**(拆前
265
+ `start_discussion.check_status` 与定义处同址,拆后 mock re-export
266
+ 不生效——本轮 4 处测试因此假绿/失败,已改到 `spec_gen` /
267
+ `observability`)。
268
+ 14. **git 守卫范围 = 从讨论 workdir 发起的操作**(`GIT_CEILING_DIRECTORIES`
228
269
  注入于 spawn);主项目仓库不在守卫范围(agent 的 cwd 就是主项目,其
229
270
  约束归指令层 + 主项目 `.gitignore`)。要拦主仓库需换机制类(沙箱/钩子),
230
271
  经评估收益不支撑扩面。
package/fake_agent.py CHANGED
@@ -21,6 +21,8 @@ import time
21
21
 
22
22
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
23
 
24
+ import meeting_core
25
+ import meeting_fs
24
26
  from meeting_fs import next_msg_id, write_message
25
27
  from meeting_engine import agent_loop
26
28
 
@@ -65,7 +67,7 @@ def make_responder(min_sleep, max_sleep, crash_rate):
65
67
  # --- 内容决定(LLM 的判断)---
66
68
  if finalizing:
67
69
  # 收尾指令:resultWriter 写 result.md(双面约束:此时才允许写)
68
- result_path = os.path.join(workdir, "result.md")
70
+ result_path = os.path.join(workdir, meeting_fs.RESULT_MD)
69
71
  # 内容 >50 字节(_result_md_valid 阈值,审核#3)——否则恒走
70
72
  # loop 兜底代写,正常收尾路径零验证(fake 与真实 responder 语义不一致)
71
73
  # 文案三分支(review4 L12):stall 不误标"配额耗尽"
@@ -81,13 +83,14 @@ def make_responder(min_sleep, max_sleep, crash_rate):
81
83
  return True
82
84
  if retry:
83
85
  # 被重试(上次没产出):无静默铁律,强制表态
84
- decision = "freezing" if not rr_turn else "pass"
86
+ decision = meeting_core.T_FREEZING if not rr_turn else meeting_core.T_PASS
85
87
  elif rr_turn:
86
88
  # RR 阶段:单向流,只写 pass(无异议回退,留待以后)
87
- decision = "pass"
89
+ decision = meeting_core.T_PASS
88
90
  else:
89
91
  # meeting 阶段:有内容 → message;无话可说 → freezing
90
- decision = "message" if random.random() < 0.65 else "freezing"
92
+ decision = meeting_core.T_MESSAGE if random.random() < 0.65 \
93
+ else meeting_core.T_FREEZING
91
94
 
92
95
  time.sleep(random.uniform(min_sleep, max_sleep))
93
96
  if random.random() < crash_rate:
@@ -116,5 +119,13 @@ if __name__ == "__main__":
116
119
  workdir, agent = sys.argv[1], sys.argv[2]
117
120
  responder = make_responder(float(sys.argv[3]), float(sys.argv[4]),
118
121
  float(sys.argv[5]))
122
+ # stall 超时:与生产(meeting_loop.__main__)同款——protocol 优先
123
+ # (装置对齐生产:此前 fake_agent 恒用默认 600s,stall 路径在测试中
124
+ # 永不可达——P2 的接管分支零覆盖正是这个原因)
125
+ stall_timeout = meeting_fs.DEFAULT_STALL_TIMEOUT
126
+ proto = meeting_fs.read_protocol(meeting_fs.bare_of_workdir(workdir))
127
+ if proto.get("stallTimeoutSeconds"):
128
+ stall_timeout = proto["stallTimeoutSeconds"]
119
129
  agent_loop(workdir, agent, responder,
120
- max_meeting=int(sys.argv[6]), max_rr=int(sys.argv[7]))
130
+ max_meeting=int(sys.argv[6]), max_rr=int(sys.argv[7]),
131
+ stall_timeout=stall_timeout)
package/human_viewer.py CHANGED
@@ -28,10 +28,17 @@ import os
28
28
  import sys
29
29
  import time
30
30
 
31
+ import meeting_core
31
32
  import meeting_fs
32
33
  from meeting_fs import (run_git, git_show, git_head, is_message_file,
33
34
  parse_log_nameonly, extract_body, parse_frontmatter)
34
- from meeting_engine import aggregate_mode
35
+ from meeting_core import (
36
+ aggregate_mode as core_aggregate_mode,
37
+ frozen_agents,
38
+ meeting_speak_count,
39
+ )
40
+ from meeting_engine import (each_agent_last, each_agent_messages,
41
+ rr_next_speaker)
35
42
 
36
43
 
37
44
  def participants_from_bare(bare):
@@ -94,23 +101,32 @@ def is_finished(bare, agents, mode=None):
94
101
  为什么落 viewer:它是唯一增量实现的持有者,也是观察端判据的家。
95
102
  """
96
103
  if mode is None:
97
- mode = aggregate_mode(bare, agents)
98
- if mode != "concluded":
104
+ # engine.each_agent_last(它把最后一条规范化成 {type, mode, next}
105
+ # ——缺字段补 None);直接传原始 frontmatter 会在 core 里 KeyError
106
+ # (core 用 v["mode"] 按契约取值,不猜缺字段)
107
+ mode = core_aggregate_mode(each_agent_last(bare, agents))
108
+ if mode != meeting_core.M_CONCLUDED:
99
109
  return False
100
- content = git_show(bare, "HEAD", "result.md")
101
- return bool(content) and len(content) > 50
110
+ content = git_show(bare, "HEAD", meeting_fs.RESULT_MD)
111
+ return bool(content) and len(content) > meeting_fs.RESULT_MD_MIN_BYTES
102
112
 
103
113
 
104
- def incremental(bare, agents, since):
114
+ def incremental(bare, agents, since, max_meeting=None):
105
115
  """单次增量读取。
106
116
 
107
- 返回: (mode, lines: list[str], head, done)
117
+ 返回: (mode, lines: list[str], head, done, progress)
108
118
  - mode: 当前聚合 mode(meeting/all-freezing/round-robin/concluded)
109
119
  - lines: 新消息展示行(旧→新)
110
120
  - head: 当前 HEAD
111
- - done: 分析是否已收尾(mode == concluded)
121
+ - done: 分析是否已收尾(concluded result.md 有效)
122
+ - progress: 进度行文本(三样观测面,见 progress_text)
123
+
124
+ **单次读取**:一次 each_agent_messages → 派生 mode 与全部三样观测面
125
+ (mode 判定、freezing 集合、meeting 配额),不重复读 bare。
112
126
  """
113
- mode = aggregate_mode(bare, agents)
127
+ msgs = each_agent_messages(bare, agents)
128
+ lasts = {a: (msgs[a][-1] if msgs[a] else None) for a in agents}
129
+ mode = core_aggregate_mode(lasts)
114
130
  lines = []
115
131
  for commit, path in new_messages(bare, since):
116
132
  content = git_show(bare, commit, path)
@@ -120,7 +136,43 @@ def incremental(bare, agents, since):
120
136
  if s:
121
137
  lines.append(s)
122
138
  head = git_head(bare)
123
- return mode, lines, head, is_finished(bare, agents, mode)
139
+ return (mode, lines, head, is_finished(bare, agents, mode),
140
+ progress_text(bare, agents, msgs, lasts, mode, max_meeting))
141
+
142
+
143
+ def progress_text(bare, agents, msgs, lasts, mode, max_meeting=None):
144
+ """进度行:三样观测面(**状态名一律用协议术语**,不翻译)。
145
+
146
+ 形态:`meeting 7/10 · 6/10 | freezing 2/3(性能、简单)| rr → 简单`
147
+
148
+ - `meeting`:配额消耗/上限(`meeting_core.meeting_speak_count` 口径
149
+ ——"mode:meeting 且 type:message"的发言轮;上限来自 protocol,
150
+ 调用方传入;未传则只显示消耗数)。
151
+ - `freezing`:已冻结数/总数(`meeting_core.frozen_agents`)。
152
+ - `rr`:RR 阶段轮到谁(**权威实现** `rr_next_speaker`;仅
153
+ round-robin 阶段出现)。
154
+
155
+ **为什么用协议术语而非中文**(用户 2026-09-11):状态名是协议的
156
+ 一等概念(`meeting`/`freezing`/`round-robin`),翻译成中文会引入
157
+ 第二套命名("冻结"到底指 freezing 还是 all-freezing?)——协议名
158
+ 在代码、消息 frontmatter、`--report`、viewer 里只有一个说法。
159
+
160
+ 判定全部复用 core/engine 的单一实现(与 `--report` 同一份),
161
+ 且共用调用方已读的 msgs——**零新增 bare 读取**;仅 rr 位置在
162
+ RR 阶段调权威实现(需要读 HEAD 的文件列表,属必要成本)。
163
+ """
164
+ types = {a: (lasts[a].get("type") if lasts[a] else None) for a in agents}
165
+ parts = []
166
+ counts = " · ".join(f"{meeting_speak_count(msgs, a)}"
167
+ + (f"/{max_meeting}" if max_meeting else "")
168
+ for a in agents)
169
+ parts.append(f"meeting {counts}")
170
+ frozen = frozen_agents(agents, types)
171
+ parts.append(f"freezing {len(frozen)}/{len(agents)}"
172
+ + (f"({'、'.join(frozen)})" if frozen else ""))
173
+ if mode == meeting_core.M_ROUND_ROBIN:
174
+ parts.append(f"rr → {rr_next_speaker(bare, agents) or '(未定)'}")
175
+ return " | ".join(parts)
124
176
 
125
177
 
126
178
  def _cursor_path(base):
@@ -150,15 +202,27 @@ def _write_cursor(base, ref):
150
202
  OBSERVER_POLL_INTERVAL = 2.0
151
203
 
152
204
 
153
- def follow(base, bare, agents, poll_interval=OBSERVER_POLL_INTERVAL):
154
- """--follow:循环展示(tail -f 式)直到分析结束。"""
205
+ def follow(base, bare, agents, max_meeting=None,
206
+ poll_interval=OBSERVER_POLL_INTERVAL):
207
+ """--follow:循环展示(tail -f 式)直到分析结束。
208
+
209
+ 状态名用协议术语(`【状态】meeting` / `all-freezing` / `round-robin`
210
+ / `concluded`);进度行见 progress_text。
211
+ """
155
212
  since = _read_cursor(base)
156
213
  last_mode = None
214
+ last_progress = None
157
215
  while True:
158
- mode, lines, head, done = incremental(bare, agents, since)
216
+ mode, lines, head, done, progress = incremental(
217
+ bare, agents, since, max_meeting)
159
218
  if mode != last_mode:
160
219
  print(f"【状态】{mode}", flush=True)
161
220
  last_mode = mode
221
+ # 进度行只在**变化时**打印(每轮都打会刷屏;冻结/配额/RR 位
222
+ # 置在两次消息之间本就不变)
223
+ if progress != last_progress:
224
+ print(f"【进度】{progress}", flush=True)
225
+ last_progress = progress
162
226
  for s in lines:
163
227
  print(s, flush=True)
164
228
  if head != since:
@@ -184,17 +248,22 @@ def main():
184
248
  print(f"错误: 分析不存在: {base}", file=sys.stderr)
185
249
  return 1
186
250
 
187
- agents = participants_from_bare(bare)
251
+ proto = meeting_fs.read_protocol(bare) # 一次读取:参与者 + 配额上限
252
+ agents = proto.get("participants") or None
188
253
  if not agents:
189
254
  print(f"错误: 无法读取 protocol.json(分析未初始化?): {base}",
190
255
  file=sys.stderr)
191
256
  return 1
257
+ max_meeting = proto.get("maxMeetingRounds")
192
258
 
193
259
  sys.stdout.reconfigure(line_buffering=True)
194
260
  if args.follow:
195
- follow(base, bare, agents)
261
+ follow(base, bare, agents, max_meeting=max_meeting)
196
262
  else:
197
- mode, lines, _, done = incremental(bare, agents, args.since)
263
+ mode, lines, _, done, progress = incremental(
264
+ bare, agents, args.since, max_meeting)
265
+ print(f"【状态】{mode}", flush=True)
266
+ print(f"【进度】{progress}", flush=True)
198
267
  print(f"【状态】{mode}", flush=True)
199
268
  for s in lines:
200
269
  print(s, flush=True)
package/meeting_core.py CHANGED
@@ -16,6 +16,25 @@
16
16
  MEETING_TYPES = {"message", "freezing", "all-freezing", "pass", "concluded"}
17
17
  """meeting 模式合法 type 集合(校验器白名单)"""
18
18
 
19
+ # ---------------------------------------------------------------
20
+ # 状态机词汇(**字面量的家**)——消息 type / 全局 mode 的合法值。
21
+ # 谁消费:core(全部判定)、engine(信号写点 + 分支)、loop(fatal 文案)、
22
+ # viewer(展示)、fake_agent(responder 决策池)。
23
+ # 为什么建家:这些字符串是**状态机的词表**,任何一处拼写漂移 = 判定静默
24
+ # 失效。曾是 46 处字面量散在 6 个文件(e2e15 自审 S3)。
25
+ # 注意:**不要**为了用常量而替换注释/日志文案里的词(那是给人读的)——
26
+ # 只替换"参与判定的值"。
27
+ # ---------------------------------------------------------------
28
+ T_MESSAGE = "message"
29
+ T_FREEZING = "freezing"
30
+ T_ALL_FREEZING = "all-freezing"
31
+ T_PASS = "pass"
32
+ T_CONCLUDED = "concluded"
33
+ M_MEETING = "meeting"
34
+ M_ROUND_ROBIN = "round-robin"
35
+ M_ALL_FREEZING = T_ALL_FREEZING # mode 值与 type 值同形(af 复用)
36
+ M_CONCLUDED = T_CONCLUDED
37
+
19
38
  # ---------------------------------------------------------------
20
39
  # 1. 冻结判定(阶段 1.1)
21
40
  # ---------------------------------------------------------------
@@ -187,7 +206,7 @@ def meeting_speak_count(messages, agent):
187
206
  "meeting 6/2" 的超限假象——6 是消息总数、2 是配额上限)。
188
207
  """
189
208
  return sum(1 for fm in messages.get(agent, [])
190
- if fm.get("mode") == "meeting" and fm.get("type") == "message")
209
+ if fm.get("mode") == M_MEETING and fm.get("type") == T_MESSAGE)
191
210
 
192
211
 
193
212
  def frozen_agents(agents, all_last_types):
@@ -229,7 +248,7 @@ def can_start_rr(all_last_types):
229
248
  return False
230
249
  if any(t is None for t in all_last_types.values()):
231
250
  return False
232
- return all(t == "all-freezing" for t in all_last_types.values())
251
+ return all(t == T_ALL_FREEZING for t in all_last_types.values())
233
252
 
234
253
 
235
254
  def aggregate_mode(all_last):
@@ -243,13 +262,13 @@ def aggregate_mode(all_last):
243
262
  4. 否则 → meeting
244
263
  """
245
264
  if not all_last:
246
- return "meeting" # 空 → 无任何 agent → 无冻结无 RR(review5 F3)
265
+ return M_MEETING # 空 → 无任何 agent → 无冻结无 RR(review5 F3)
247
266
  types = {a: (v["type"] if v else None) for a, v in all_last.items()}
248
267
  modes = {a: (v["mode"] if v else None) for a, v in all_last.items()}
249
- if any(t == "concluded" for t in types.values()):
250
- return "concluded"
251
- if any(m == "round-robin" for m in modes.values()):
252
- return "round-robin"
253
- if all(t in ("freezing", "all-freezing") for t in types.values()):
254
- return "all-freezing"
268
+ if any(t == T_CONCLUDED for t in types.values()):
269
+ return T_CONCLUDED
270
+ if any(m == M_ROUND_ROBIN for m in modes.values()):
271
+ return M_ROUND_ROBIN
272
+ if all(t in (T_FREEZING, T_ALL_FREEZING) for t in types.values()):
273
+ return T_ALL_FREEZING
255
274
  return "meeting"
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") == "round-robin")
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") == "round-robin" for v in lasts.values()):
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, "all-freezing", "all-freezing")
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, "pass", "round-robin",
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, "freezing", "meeting")
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 == "round-robin" and fixed.get("type") in ("pass", "message"):
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, "result.md") > 50
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, "result.md")
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, "result.md",
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, "concluded", "concluded")
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", "--", "result.md",
498
+ r = run_git(workdir, "status", "--porcelain", "--", RESULT_MD,
495
499
  check=False)
496
500
  if r.stdout.strip():
497
- git_commit(workdir, ["result.md"], subject)
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, stall_timeout=600):
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 == "concluded":
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 可能离线(崩溃/未启动)——接管收尾(审核#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。
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) == "concluded":
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, "freezing",
581
- aggregate_mode(bare, agents) or "meeting")
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 == "all-freezing":
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, "pass",
613
- "round-robin", nxt_a)
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 == "round-robin":
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, "pass",
653
- "round-robin", nxt_a)
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, "freezing", "meeting")
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, "freezing", "meeting")
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) == "freezing":
735
+ if all_last.get(agent) == T_FREEZING:
717
736
  time.sleep(poll_interval)
718
737
  continue
719
738