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/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, "result.md")
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:
@@ -0,0 +1,392 @@
1
+ """观测层——状态判定 / --report / --wait(从 start_discussion 拆出,S2)。
2
+
3
+ 职责:**运行期只读观测**——check_status 状态机、--report 观测面聚合、
4
+ --wait 阻塞观察、loop 进程存活检测。不写任何产物(报告不落盘——
5
+ 观测面契约:冷路径一次性,不持久化)。
6
+
7
+ 依赖方向:只准 import meeting_core / meeting_fs / meeting_engine /
8
+ human_viewer(observability 是"读"侧,human_viewer.incremental 是它
9
+ 的进展数据源);不准 import 主文件。
10
+ """
11
+
12
+ import glob
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ import time
18
+
19
+ import human_viewer
20
+ import meeting_core
21
+ import meeting_engine
22
+ import meeting_fs
23
+
24
+
25
+ def _loop_pids(base):
26
+ """本讨论存活的 loop PID 列表。
27
+
28
+ **判据 = argv 精确相等,不是命令行文本正则**(2026-09-10 评审 A2):
29
+ `pgrep -f <正则>` 会匹配到**任何**命令行里含该文本的进程——从 shell
30
+ 包装调用时(`bash -c "...pgrep -f 'meeting_loop.py.*<base>'..."`)会命中
31
+ 调用者自身,误判"有 loop 存活"。项目已固化该教训(docs/test-methodology.md
32
+ 方法 2:方括号技巧或精确 PID),此处用 /proc 的 argv 逐项比较根治:
33
+ 只看 argv 里是否有**恰好等于** `os.path.join(base, "meeting_loop.py")`
34
+ 的元素——与启动方(Popen cmd 的第一个参数)同一构造。
35
+ 附带:/proc 扫描 ≈1.1ms vs pgrep ≈5.9ms(不构成选型理由,理由是判据精度)。
36
+
37
+ 读不到 /proc(非 Linux/权限)→ 返回空列表(fail-open:与"无 loop"同义,
38
+ 只影响状态显示,不影响流程——loop 自身不依赖此函数)。
39
+ """
40
+ target = os.path.join(base, "meeting_loop.py")
41
+ pids = []
42
+ for entry in glob.glob("/proc/[0-9]*/cmdline"):
43
+ try:
44
+ with open(entry, "rb") as f:
45
+ argv = f.read().decode("utf-8", "replace").split("\0")
46
+ except OSError:
47
+ continue
48
+ if target in argv:
49
+ pids.append(entry.split("/")[2])
50
+ return pids
51
+
52
+
53
+ def _loops_alive(base):
54
+ """讨论的 loop 进程是否存活(argv 精确匹配,见 _loop_pids)。"""
55
+ return bool(_loop_pids(base))
56
+
57
+
58
+ def check_status(base):
59
+ """讨论状态(单值;状态全集显式于此,T3/#7 修复 e2e7 评审):
60
+
61
+ not-exists 无 bare(目录不存在/未创建)
62
+ done result.md + concluded(权威收尾完成)
63
+ running 有 loop 存活(讨论中 / 收尾中——收尾中细分见下)
64
+ stalled 有 result.md 无 concluded 且 **loop 均不存活**
65
+ (收尾中断:rw 崩溃在 result.md 之后、concluded 之前)
66
+ stopped 无 result.md 且 loop 不存活(未启动/中断)
67
+
68
+ 修复动因(e2e7 评审 T3):原实现"有 result.md 无 concluded"恒返回
69
+ running 且不看存活 → "收尾进行中"与"收尾间隙崩溃"不可区分,--wait
70
+ 无限轮询(无终止上界)。现 stalled 使 --wait 有界退出。
71
+ 移除恒 None 第二返回值(#7 装饰性契约)——信息由状态本身表达。
72
+ """
73
+ bare = meeting_fs.bare_of_base(base)
74
+ if not os.path.isdir(bare):
75
+ return "not-exists"
76
+ # 读路径统一走 fs.run_git(quotepath 加固单点;run_cmd 只做一次性
77
+ # 环境命令——init/clone/config/push)
78
+ # 读路径统一走 fs.run_git(quotepath 加固单点;run_cmd 只做一次性
79
+ # 环境命令——init/clone/config/push)
80
+ agents = meeting_fs.read_protocol(bare).get("participants", [])
81
+ # "收尾完成"判据**单源** = human_viewer.is_finished(concluded 且
82
+ # HEAD:result.md 有效)——与 viewer 的 done 同一判据(§3.5-P5:此前
83
+ # viewer 认 concluded、这里还额外认 result.md 存在,分叉会让 viewer
84
+ # 在产物落盘前先报"已结束"并打印尚不存在的路径)。
85
+ # 不用 git grep 全文:行文本匹配会被 result.md/消息正文里的
86
+ # `type: concluded` 误触发(实测);human 消息天然排除(aggregate_mode
87
+ # 只按 participants 取末条)。
88
+ if human_viewer.is_finished(bare, agents):
89
+ return "done"
90
+ # 未完成:有 result.md 但未收尾 → 看 loop 存活区分收尾中/收尾中断
91
+ r = meeting_fs.run_git(bare, "log", "--all", "--format=%H", "--",
92
+ meeting_fs.RESULT_MD, check=False)
93
+ if r.stdout.strip():
94
+ return "running" if _loops_alive(base) else "stalled"
95
+ return "running" if _loops_alive(base) else "stopped"
96
+
97
+
98
+ def wait_for_completion(base):
99
+ """`--wait`:阻塞展示进展直到收尾或终态。返回退出码(0 完成 / 1 终止)。
100
+
101
+ 从 main 内联抽出(main 里最大单块;项目方法论把 main/CLI 分发
102
+ 列为独立测试盲区)。**纯结构变换**:调用序列与 sleep 序列逐字
103
+ 不变——helper 只做机械动作(状态判定 → 打印 → 增量展示 → sleep),
104
+ 不吸收"何时进入分支"的阶段判断。
105
+
106
+ 终止语义(四种终态,各有明确文案):stalled / not-exists /
107
+ stopped(按 loop-*.log 分叉成因)/ done(含固定位 result.md 提示)。
108
+ """
109
+ # T1 收归(e2e7 评审):进展展示复用 human_viewer.incremental
110
+ # (原内联 65 行自行 git log 全量 + 手工解析 frontmatter——与
111
+ # viewer 两套输出格式、非增量、概念丢失)。incremental 走
112
+ # since..HEAD 增量 + 统一 format_message。
113
+ import human_viewer
114
+ sys.stdout.reconfigure(line_buffering=True)
115
+ print(f"[wait] 等待讨论完成: {base}")
116
+ bare = meeting_fs.bare_of_base(base)
117
+ agents = human_viewer.participants_from_bare(bare) or []
118
+ since = "" # 首次全量(--wait 一次性观察,无游标持久需求)
119
+ first = True
120
+ while True:
121
+ state = check_status(base)
122
+ if state == "stalled":
123
+ print("[wait] 收尾中断(result.md 已提交、concluded 缺失、"
124
+ "无 loop 存活)——停止等待;可读 result.md 或 --cleanup")
125
+ return 1
126
+ if state == "not-exists":
127
+ print(f"[wait] 讨论不存在: {base}")
128
+ return 1
129
+ if state == "stopped":
130
+ # 终态:无 result.md 且无 loop 存活。
131
+ #
132
+ # **不用 loop-*.log 存在性分叉成因**(§3.4-P4):那是拿日志当
133
+ # 判定输入(唯一实例,与"日志零判定输入"不变量冲突),且两个
134
+ # 分支给出的动作此前都不可执行(`--start` 对已存在目录报"请先
135
+ # --cleanup";裸 `--start <base>` 又因缺 question.md 失败)。
136
+ # 合并为一条动作完整的提示——真正可执行的是 `--skip-setup
137
+ # --start`(环境不完整则先 --cleanup 重建)。
138
+ print(f"[wait] 未在运行且未收尾({base}:无 loop 存活、无 "
139
+ "result.md)——查 loop-*.log / status-*.json 判断原因;"
140
+ "重跑:--skip-setup --start(protocol 缺失则先 --cleanup "
141
+ "后重建)")
142
+ return 1
143
+ _mode, lines, head, done, _progress = human_viewer.incremental(
144
+ bare, agents, since,
145
+ meeting_fs.read_protocol(bare).get("maxMeetingRounds"))
146
+ if done:
147
+ for line in lines:
148
+ print(line)
149
+ print()
150
+ print("[wait] 讨论完成 ✅")
151
+ # 固定位(与 prompt 收尾指引一致):resultWriter 的 loop
152
+ # 退出时保存、cleanup 兜底再存一次——调用方无需推 rw 是谁
153
+ print(f"[wait] result.md: {base}-result.md")
154
+ return 0
155
+ for line in lines:
156
+ print(f"[wait] {time.strftime('%H:%M:%S')} 新进展:")
157
+ print(line)
158
+ print()
159
+ since = head or since
160
+ if first:
161
+ first = False
162
+ # 观察刷新节奏(消费端常量——与 loop 的空闲重试节奏有意独立,
163
+ # 见 human_viewer.OBSERVER_POLL_INTERVAL 注释)。原硬编码 10s
164
+ # 会让结束观察延迟最长 10s(e2e13 时间流分析:唯一 >10s 的
165
+ # 非必要等待点)。
166
+ time.sleep(human_viewer.OBSERVER_POLL_INTERVAL)
167
+
168
+
169
+ def build_report(base):
170
+ """只读报告(`--report`)——观测面的**唯一机器消费出口**。
171
+
172
+ 契约(design.md 观测面契约节):
173
+ - **冷路径一次性**:不常驻、不被轮询;调用方(人/主 pi)按需触发。
174
+ - **不持久化**:视图不占"数字的家"——数字的家是 bare(判定域)、
175
+ loop log 的登记字段、pi session 的文档化字段;报告只是它们的一次投影。
176
+ - **fail-open**:任何一段读不出(缺目录/缺文件/格式变)→ 该段显示 n/a,
177
+ 不报错、不改判定、不阻塞。
178
+ - **跨度分标**:进程跨度(elapsed_ms)≠ per-response 跨度(session
179
+ 时间戳差)≠ 墙钟跨度(commit 时间差)——各自标名,不混算。
180
+ - **不得升级为验收 gate**:有效期判断留给人 + result.md(本轮 §4 明确
181
+ 不做运行期评分)。
182
+
183
+ 返回输出行列表(调用方 print)。
184
+ """
185
+ out = []
186
+ out.append(f"[报告] {base}")
187
+ bare = meeting_fs.bare_of_base(base)
188
+ if not os.path.isdir(bare):
189
+ out.append(" 分析目录不存在(已 cleanup?)——n/a")
190
+ return out
191
+ agents = meeting_fs.read_protocol(bare).get("participants", [])
192
+ if not agents:
193
+ out.append(" 协议不可读(participants 空)——n/a")
194
+ return out
195
+ proto = meeting_fs.read_protocol(bare)
196
+
197
+ # ---- 流程时间线(bare = 判定域,现场派生) ----
198
+ r = meeting_fs.run_git(bare, "log", "--reverse", "--format=%ct%x09%s",
199
+ "HEAD", check=False)
200
+ rows = []
201
+ for line in r.stdout.splitlines():
202
+ if "\t" not in line:
203
+ continue
204
+ ts, subj = line.split("\t", 1)
205
+ rows.append((int(ts), subj))
206
+ per_agent = {a: 0 for a in agents}
207
+ human_n = 0
208
+ for _, subj in rows:
209
+ m = re.match(r"discuss:\s*(.+?)/(\d+)$", subj)
210
+ if not m:
211
+ continue
212
+ who = m.group(1)
213
+ if who == "human" or who not in per_agent:
214
+ human_n += 1
215
+ else:
216
+ per_agent[who] += 1
217
+ if rows:
218
+ span = rows[-1][0] - rows[0][0]
219
+ out.append(f"流程:{len(agents)} agents | 提交 "
220
+ f"{sum(per_agent.values())}(含流程信号;"
221
+ + " / ".join(f"{a} {n}" for a, n in per_agent.items())
222
+ + f")| 墙钟跨度 {_dur(span)}(首末 commit 差)")
223
+ # 最长无进展间隔(相邻 commit 间隔的最大值)
224
+ gaps = [(rows[i + 1][0] - rows[i][0], rows[i][0], rows[i + 1][0])
225
+ for i in range(len(rows) - 1)]
226
+ if gaps:
227
+ g, t1, t2 = max(gaps)
228
+ out.append(f"节奏:最长无进展 interval {_dur(g)}"
229
+ f"({_hhmm(t1)} → {_hhmm(t2)},commit 间隔)")
230
+
231
+ # ---- 配额与 human 插话(bare 派生,无状态) ----
232
+ # **配额消耗从 frontmatter 统计**(mode==meeting 且 type==message),
233
+ # 不是"该 agent 的消息总数"——上限约束的是 meeting 发言轮次,而一个
234
+ # agent 的消息里还有 freezing/all-freezing/pass/concluded 等流程信号。
235
+ # 两者混算会出现"meeting 6/2"这种超限假象(口径错误,2026-09-11 实测)。
236
+ msgs = meeting_engine.each_agent_messages(bare, agents)
237
+ lasts = {a: (msgs[a][-1] if msgs[a] else None) for a in agents}
238
+ types = {a: (lasts[a].get("type") if lasts[a] else None) for a in agents}
239
+ quota_meeting = proto.get("maxMeetingRounds", 10)
240
+ quota_rr = proto.get("maxRRRounds", 7)
241
+ out.append("配额:meeting " + "、".join(
242
+ f"{a} {meeting_core.meeting_speak_count(msgs, a)}/{quota_meeting}"
243
+ for a in agents)
244
+ + f"(消耗/上限,口径 = mode:meeting 且 type:message)"
245
+ f"| RR 上限 {quota_rr}/agent | human 插话 {human_n} 条"
246
+ "(不占配额;各 agent 上限 +human 条数)")
247
+ frozen = meeting_core.frozen_agents(agents, types)
248
+ not_frozen = [a for a in agents if a not in frozen]
249
+ out.append(f"冻结:{len(frozen)}/{len(agents)} 已冻结"
250
+ + (f"({'、'.join(frozen)})" if frozen else "")
251
+ + (f";未冻结 {'、'.join(not_frozen)}" if not_frozen else ""))
252
+ # aggregate_mode 期望 {agent: {type, mode}}(core 判定入口形态)——
253
+ # 用 `.get` 规范化:消息缺字段(老产物/手工 fixture)时按 None 处理,
254
+ # 不得 KeyError(报告契约:读不出 → 降级,不崩)
255
+ mode_now = meeting_core.aggregate_mode(
256
+ {a: ({"type": fm.get("type"), "mode": fm.get("mode")} if fm else None)
257
+ for a, fm in lasts.items()})
258
+ if mode_now == meeting_core.M_ROUND_ROBIN:
259
+ out.append(f"RR:轮到 "
260
+ f"{meeting_engine.rr_next_speaker(bare, agents) or '(未定)'}")
261
+ else:
262
+ out.append(f"阶段:{mode_now}")
263
+ # 标题与口径:一次读取派生的三样观测面(配额进度 / 冻结集合 / RR 位置)
264
+
265
+ # ---- 进程事实(登记字段;日志的唯一机器消费点) ----
266
+ proc = _report_wake_fields(base)
267
+ out.append("进程(loop log 登记字段):")
268
+ if not proc:
269
+ out.append(" n/a(无完成行——尚未唤醒或日志缺失)")
270
+ for a in agents:
271
+ d = proc.get(a)
272
+ if not d:
273
+ out.append(f" {a}: n/a")
274
+ continue
275
+ out.append(f" {a}: 唤醒 {d['wakes']} 次 | 进程跨度 总 "
276
+ f"{_dur(d['total_ms'] // 1000)} / 最大 "
277
+ f"{_dur(d['max_ms'] // 1000)} | rc≠0 {d['fails']} 次")
278
+
279
+ # ---- LLM 运行事实(session 文档化字段;流式预过滤,不整文件解析) ----
280
+ out.append("LLM(session 文档化字段):")
281
+ any_usage = False
282
+ for a in agents:
283
+ u = _report_session_usage(base, a)
284
+ if not u:
285
+ out.append(f" {a}: n/a")
286
+ continue
287
+ any_usage = True
288
+ out.append(f" {a}: input {u['input']} | cacheRead "
289
+ f"{u['cache_read']} | output {u['output']} | 响应 "
290
+ f"{u['responses']} 次 | error {u['errors']} 次")
291
+ if not any_usage:
292
+ out.append(" n/a(session 缺失,或无本轮数据——边界条目自 2026-09-11 "
293
+ "起写入,此前的老分析不适用)」")
294
+ out.append("(口径:进程跨度=pi 进程生命周期;输出=prompt 分段合计;"
295
+ "墙钟=commit 时间差——三者不可互替)")
296
+ return out
297
+
298
+
299
+ def _dur(sec):
300
+ """人类可读时长(口径由调用方在同一行标注——进程跨度/墙钟/间隔)。"""
301
+ sec = int(sec)
302
+ if sec < 60:
303
+ return f"{sec}s"
304
+ if sec < 3600:
305
+ return f"{sec // 60}m{sec % 60:02d}s"
306
+ return f"{sec // 3600}h{(sec % 3600) // 60:02d}m"
307
+
308
+
309
+ def _hhmm(ts):
310
+ return time.strftime("%H:%M:%S", time.localtime(ts))
311
+
312
+
313
+ def _report_wake_fields(base):
314
+ """解析 loop-*.log 的登记字段(`elapsed_ms` / `rc`)。
315
+
316
+ **日志的唯一机器消费点**(观测面契约:日志零判定输入,报告只读已登记
317
+ 字段——不解析自由文本、不做启发式猜测)。fail-open:读不到 → 跳过。
318
+ """
319
+ out = {}
320
+ for f in sorted(glob.glob(os.path.join(base, "loop-*.log"))):
321
+ agent = os.path.basename(f)[len("loop-"):-len(".log")]
322
+ d = {"wakes": 0, "total_ms": 0, "max_ms": 0, "fails": 0}
323
+ try:
324
+ with open(f, encoding="utf-8", errors="replace") as fh:
325
+ for line in fh:
326
+ m = re.search(r"elapsed_ms=(\d+) rc=(-?\d+)", line)
327
+ if not m:
328
+ continue
329
+ d["wakes"] += 1
330
+ ms, rc = int(m.group(1)), int(m.group(2))
331
+ d["total_ms"] += ms
332
+ d["max_ms"] = max(d["max_ms"], ms)
333
+ if rc != 0:
334
+ d["fails"] += 1
335
+ except OSError:
336
+ continue
337
+ if d["wakes"]:
338
+ out[agent] = d
339
+ return out
340
+
341
+
342
+ def _report_session_usage(base, agent):
343
+ """从该 agent 的 session 文件取 usage(**单一适配器** + 流式预过滤)。
344
+
345
+ 字段来源 = pi 的**文档化** session schema(`docs/session-format.md`:
346
+ `usage` / `stopReason`)。行级预过滤(`"usage" in line` 才 json.loads)
347
+ ——避免对 MB 级文件整解析(实测 json.loads 3MB ≈27ms,预过滤可省大部分)。
348
+ fail-open:文件缺失/字段变 → 返回 {}。
349
+ """
350
+ try:
351
+ with open(os.path.join(base, f"status-{agent}.json")) as f:
352
+ sid = json.load(f).get("sessionID") or ""
353
+ except (OSError, ValueError):
354
+ sid = ""
355
+ if not sid:
356
+ return {}
357
+ fp = os.path.join(base, "pi-sessions", f"fork-src-{sid}.jsonl")
358
+ if not os.path.isfile(fp):
359
+ return {}
360
+ # **只统计边界之后的条目**(本轮运行事实)——fork 携带的历史条目里也
361
+ # 有大量 assistant+usage,全文件统计会把主 pi 的历史算成本次分析的
362
+ # 消耗(2026-09-11 实测:717 条 fork 历史被算成"本轮 367 次响应 /
363
+ # input 1.2M")。边界由 append_handoff_turns 写入(显式登记,非推断)。
364
+ u = {"input": 0, "cache_read": 0, "output": 0, "responses": 0, "errors": 0}
365
+ for ev in meeting_fs.iter_after_boundary(fp):
366
+ m = ev.get("message") or {}
367
+ if m.get("role") != "assistant":
368
+ continue
369
+ u["responses"] += 1
370
+ if m.get("stopReason") == "error":
371
+ u["errors"] += 1
372
+ usage = m.get("usage") or {}
373
+ for k, key in (("input", "input"), ("cacheRead", "cache_read"),
374
+ ("output", "output")):
375
+ v = usage.get(k)
376
+ if isinstance(v, int):
377
+ u[key] += v
378
+ if not u["responses"]:
379
+ return {}
380
+ # 数字格式化(人读):千分位缩写
381
+ for k in ("input", "cache_read", "output"):
382
+ u[k] = _num(u[k])
383
+ return u
384
+
385
+
386
+ def _num(n):
387
+ """人可读数字(k/M 缩写;原值精度对人读报告无意义)。"""
388
+ if n >= 1_000_000:
389
+ return f"{n / 1_000_000:.1f}M"
390
+ if n >= 1_000:
391
+ return f"{n / 1_000:.1f}k"
392
+ return str(n)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multi-viewers",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Multi-perspective analysis for Pi: fork the main session into N perspective agents over the meeting protocol.",
5
5
  "type": "module",
6
6
  "private": false,