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 +4 -2
- package/README.md +13 -6
- package/docs/design.md +42 -1
- package/fake_agent.py +16 -5
- package/human_viewer.py +85 -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 +392 -0
- package/package.json +1 -1
- package/spec_gen.py +518 -0
- package/start_discussion.py +47 -828
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
|
|
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.
|
|
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
|
|
136
|
-
meeting_fs.py
|
|
137
|
-
meeting_engine.py
|
|
138
|
-
meeting_loop.py
|
|
139
|
-
start_discussion.py
|
|
140
|
-
|
|
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. **
|
|
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,
|
|
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 =
|
|
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 =
|
|
89
|
+
decision = meeting_core.T_PASS
|
|
88
90
|
else:
|
|
89
91
|
# meeting 阶段:有内容 → message;无话可说 → freezing
|
|
90
|
-
decision =
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
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",
|
|
101
|
-
return bool(content) and len(content) >
|
|
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: 分析是否已收尾(
|
|
121
|
+
- done: 分析是否已收尾(concluded 且 result.md 有效)
|
|
122
|
+
- progress: 进度行文本(三样观测面,见 progress_text)
|
|
123
|
+
|
|
124
|
+
**单次读取**:一次 each_agent_messages → 派生 mode 与全部三样观测面
|
|
125
|
+
(mode 判定、freezing 集合、meeting 配额),不重复读 bare。
|
|
112
126
|
"""
|
|
113
|
-
|
|
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,
|
|
154
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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") ==
|
|
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 ==
|
|
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
|
|
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 ==
|
|
250
|
-
return
|
|
251
|
-
if any(m ==
|
|
252
|
-
return
|
|
253
|
-
if all(t in (
|
|
254
|
-
return
|
|
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") ==
|
|
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
|
|