pi-multi-viewers 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,397 @@
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
+ # ---- 一次读取(消息文件 = 权威口径),供流程/配额/冻结/RR 四段共用 ----
198
+ msgs = meeting_engine.each_agent_messages(bare, agents)
199
+ per_agent = {a: len(msgs.get(a, [])) for a in agents}
200
+ # human 消息不在 participants 里(视而不见原则)——单独数 human/ 目录的
201
+ # 消息文件。**不用 commit subject 统计**:那是自由文本(`discuss: X/NNNN`),
202
+ # 格式一改/手写就静默归零(2026-09-11 实测:构造环境 subject 不同 → "提交 0"
203
+ # 而实际有 3 条消息);消息文件是判定域的事实,格式由本仓控制。
204
+ r_h = meeting_fs.run_git(bare, "ls-tree", "-r", "-z", "--name-only",
205
+ "HEAD", check=False)
206
+ human_n = sum(1 for f in r_h.stdout.rstrip("\0").split("\0")
207
+ if f and f.startswith("human/")
208
+ and meeting_fs.is_message_file(f))
209
+
210
+ # ---- 流程时间线(时间戳来自 commit——消息文件不带墙钟) ----
211
+ r = meeting_fs.run_git(bare, "log", "--reverse", "--format=%ct%x09%s",
212
+ "HEAD", check=False)
213
+ rows = []
214
+ for line in r.stdout.splitlines():
215
+ if "\t" not in line:
216
+ continue
217
+ ts, subj = line.split("\t", 1)
218
+ rows.append((int(ts), subj))
219
+ if rows:
220
+ span = rows[-1][0] - rows[0][0]
221
+ detail = " / ".join(f"{a} {n}" for a, n in per_agent.items())
222
+ if human_n: # human 单列明细(它不是参与者),但计入合计
223
+ detail += f" / human {human_n}"
224
+ out.append(f"流程:{len(agents)} agents | 消息 "
225
+ f"{sum(per_agent.values()) + human_n}"
226
+ f"(含流程信号;{detail})| 墙钟跨度 {_dur(span)}"
227
+ f"(首末 commit 差)")
228
+ # 最长无进展间隔(相邻 commit 间隔的最大值)
229
+ gaps = [(rows[i + 1][0] - rows[i][0], rows[i][0], rows[i + 1][0])
230
+ for i in range(len(rows) - 1)]
231
+ if gaps:
232
+ g, t1, t2 = max(gaps)
233
+ out.append(f"节奏:最长无进展 interval {_dur(g)}"
234
+ f"({_hhmm(t1)} → {_hhmm(t2)},commit 间隔)")
235
+
236
+ # ---- 配额与 human 插话(bare 派生,无状态) ----
237
+ # **配额消耗从 frontmatter 统计**(mode==meeting 且 type==message),
238
+ # 不是"该 agent 的消息总数"——上限约束的是 meeting 发言轮次,而一个
239
+ # agent 的消息里还有 freezing/all-freezing/pass/concluded 等流程信号。
240
+ # 两者混算会出现"meeting 6/2"这种超限假象(口径错误,2026-09-11 实测)。
241
+ # msgs 由上方流程段一次读取提供(同一读取派生四段)。
242
+ lasts = {a: (msgs[a][-1] if msgs[a] else None) for a in agents}
243
+ types = {a: (lasts[a].get("type") if lasts[a] else None) for a in agents}
244
+ quota_meeting = proto.get("maxMeetingRounds", 10)
245
+ quota_rr = proto.get("maxRRRounds", 7)
246
+ out.append("配额:meeting " + "、".join(
247
+ f"{a} {meeting_core.meeting_speak_count(msgs, a)}/{quota_meeting}"
248
+ for a in agents)
249
+ + f"(消耗/上限,口径 = mode:meeting 且 type:message)"
250
+ f"| RR 上限 {quota_rr}/agent | human 插话 {human_n} 条"
251
+ "(不占配额;各 agent 上限 +human 条数)")
252
+ frozen = meeting_core.frozen_agents(agents, types)
253
+ not_frozen = [a for a in agents if a not in frozen]
254
+ out.append(f"冻结:{len(frozen)}/{len(agents)} 已冻结"
255
+ + (f"({'、'.join(frozen)})" if frozen else "")
256
+ + (f";未冻结 {'、'.join(not_frozen)}" if not_frozen else ""))
257
+ # aggregate_mode 期望 {agent: {type, mode}}(core 判定入口形态)——
258
+ # 用 `.get` 规范化:消息缺字段(老产物/手工 fixture)时按 None 处理,
259
+ # 不得 KeyError(报告契约:读不出 → 降级,不崩)
260
+ mode_now = meeting_core.aggregate_mode(
261
+ {a: ({"type": fm.get("type"), "mode": fm.get("mode")} if fm else None)
262
+ for a, fm in lasts.items()})
263
+ if mode_now == meeting_core.M_ROUND_ROBIN:
264
+ out.append(f"RR:轮到 "
265
+ f"{meeting_engine.rr_next_speaker(bare, agents) or '(未定)'}")
266
+ else:
267
+ out.append(f"阶段:{mode_now}")
268
+ # 标题与口径:一次读取派生的三样观测面(配额进度 / 冻结集合 / RR 位置)
269
+
270
+ # ---- 进程事实(登记字段;日志的唯一机器消费点) ----
271
+ proc = _report_wake_fields(base)
272
+ out.append("进程(loop log 登记字段):")
273
+ if not proc:
274
+ out.append(" n/a(无完成行——尚未唤醒或日志缺失)")
275
+ for a in agents:
276
+ d = proc.get(a)
277
+ if not d:
278
+ out.append(f" {a}: n/a")
279
+ continue
280
+ out.append(f" {a}: 唤醒 {d['wakes']} 次 | 进程跨度 总 "
281
+ f"{_dur(d['total_ms'] // 1000)} / 最大 "
282
+ f"{_dur(d['max_ms'] // 1000)} | rc≠0 {d['fails']} 次")
283
+
284
+ # ---- LLM 运行事实(session 文档化字段;流式预过滤,不整文件解析) ----
285
+ out.append("LLM(session 文档化字段):")
286
+ any_usage = False
287
+ for a in agents:
288
+ u = _report_session_usage(base, a)
289
+ if not u:
290
+ out.append(f" {a}: n/a")
291
+ continue
292
+ any_usage = True
293
+ out.append(f" {a}: input {u['input']} | cacheRead "
294
+ f"{u['cache_read']} | output {u['output']} | 响应 "
295
+ f"{u['responses']} 次 | error {u['errors']} 次")
296
+ if not any_usage:
297
+ out.append(" n/a(session 缺失,或无本轮数据——边界条目自 2026-09-11 "
298
+ "起写入,此前的老分析不适用)")
299
+ out.append("(口径:进程跨度=pi 进程生命周期;输出=prompt 分段合计;"
300
+ "墙钟=commit 时间差——三者不可互替)")
301
+ return out
302
+
303
+
304
+ def _dur(sec):
305
+ """人类可读时长(口径由调用方在同一行标注——进程跨度/墙钟/间隔)。"""
306
+ sec = int(sec)
307
+ if sec < 60:
308
+ return f"{sec}s"
309
+ if sec < 3600:
310
+ return f"{sec // 60}m{sec % 60:02d}s"
311
+ return f"{sec // 3600}h{(sec % 3600) // 60:02d}m"
312
+
313
+
314
+ def _hhmm(ts):
315
+ return time.strftime("%H:%M:%S", time.localtime(ts))
316
+
317
+
318
+ def _report_wake_fields(base):
319
+ """解析 loop-*.log 的登记字段(`elapsed_ms` / `rc`)。
320
+
321
+ **日志的唯一机器消费点**(观测面契约:日志零判定输入,报告只读已登记
322
+ 字段——不解析自由文本、不做启发式猜测)。fail-open:读不到 → 跳过。
323
+ """
324
+ out = {}
325
+ for f in sorted(glob.glob(os.path.join(base, "loop-*.log"))):
326
+ agent = os.path.basename(f)[len("loop-"):-len(".log")]
327
+ d = {"wakes": 0, "total_ms": 0, "max_ms": 0, "fails": 0}
328
+ try:
329
+ with open(f, encoding="utf-8", errors="replace") as fh:
330
+ for line in fh:
331
+ m = re.search(r"elapsed_ms=(\d+) rc=(-?\d+)", line)
332
+ if not m:
333
+ continue
334
+ d["wakes"] += 1
335
+ ms, rc = int(m.group(1)), int(m.group(2))
336
+ d["total_ms"] += ms
337
+ d["max_ms"] = max(d["max_ms"], ms)
338
+ if rc != 0:
339
+ d["fails"] += 1
340
+ except OSError:
341
+ continue
342
+ if d["wakes"]:
343
+ out[agent] = d
344
+ return out
345
+
346
+
347
+ def _report_session_usage(base, agent):
348
+ """从该 agent 的 session 文件取 usage(**单一适配器** + 流式预过滤)。
349
+
350
+ 字段来源 = pi 的**文档化** session schema(`docs/session-format.md`:
351
+ `usage` / `stopReason`)。行级预过滤(`"usage" in line` 才 json.loads)
352
+ ——避免对 MB 级文件整解析(实测 json.loads 3MB ≈27ms,预过滤可省大部分)。
353
+ fail-open:文件缺失/字段变 → 返回 {}。
354
+ """
355
+ try:
356
+ with open(os.path.join(base, f"status-{agent}.json")) as f:
357
+ sid = json.load(f).get("sessionID") or ""
358
+ except (OSError, ValueError):
359
+ sid = ""
360
+ if not sid:
361
+ return {}
362
+ fp = os.path.join(base, "pi-sessions", f"fork-src-{sid}.jsonl")
363
+ if not os.path.isfile(fp):
364
+ return {}
365
+ # **只统计边界之后的条目**(本轮运行事实)——fork 携带的历史条目里也
366
+ # 有大量 assistant+usage,全文件统计会把主 pi 的历史算成本次分析的
367
+ # 消耗(2026-09-11 实测:717 条 fork 历史被算成"本轮 367 次响应 /
368
+ # input 1.2M")。边界由 append_handoff_turns 写入(显式登记,非推断)。
369
+ u = {"input": 0, "cache_read": 0, "output": 0, "responses": 0, "errors": 0}
370
+ for ev in meeting_fs.iter_after_boundary(fp):
371
+ m = ev.get("message") or {}
372
+ if m.get("role") != "assistant":
373
+ continue
374
+ u["responses"] += 1
375
+ if m.get("stopReason") == "error":
376
+ u["errors"] += 1
377
+ usage = m.get("usage") or {}
378
+ for k, key in (("input", "input"), ("cacheRead", "cache_read"),
379
+ ("output", "output")):
380
+ v = usage.get(k)
381
+ if isinstance(v, int):
382
+ u[key] += v
383
+ if not u["responses"]:
384
+ return {}
385
+ # 数字格式化(人读):千分位缩写
386
+ for k in ("input", "cache_read", "output"):
387
+ u[k] = _num(u[k])
388
+ return u
389
+
390
+
391
+ def _num(n):
392
+ """人可读数字(k/M 缩写;原值精度对人读报告无意义)。"""
393
+ if n >= 1_000_000:
394
+ return f"{n / 1_000_000:.1f}M"
395
+ if n >= 1_000:
396
+ return f"{n / 1_000:.1f}k"
397
+ 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.1",
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,
@@ -73,7 +73,9 @@ argument-hint: '"<主题>"'
73
73
  ### 4. 结束回合
74
74
 
75
75
  告知用户:
76
- - 观看:复制上一步的 `!!` 命令执行(实时流式,结束时自动退出)
76
+ - 观看:复制上一步的 `!!` 命令执行(实时流式,结束时自动退出并**附本次
77
+ 分析报告**——消息数/墙钟/配额/冻结/进程跨度/LLM 用量;用户无需任何
78
+ 额外操作即可看到)
77
79
  - 插话:随时 `/multi-viewers-say <文本>`(自动定位当前分析;也可用 wrapper `--say <目录> "<文本>"`)
78
80
  - 完成时告诉主 pi,主 pi 会收尾
79
81
 
@@ -87,8 +89,7 @@ mv.sh --status <分析目录绝对路径> # done/stopped/running
87
89
 
88
90
  - `done`:读 `<分析目录>-result.md`(与目录同级的固定位)→ 向用户给
89
91
  **摘要** → `mv.sh --cleanup <分析目录绝对路径>`
90
- - cleanup 会打印**本次分析报告**(提交数 / 墙钟 / 进程跨度 / 配额 / LLM
91
- 用量 / rc≠0)——这是删目录前最后一次可读,**把其中的关键数字一并
92
- 转述给用户**(运行成本与健康度的一手信息)
92
+ - cleanup 会再打印一次**分析报告**(删目录前最后一次可读)。报告在
93
+ 观看输出末尾已自动出现过,**不必重复转述**——用户问起数字时按需引用
93
94
  - `stopped`:报告"分析已结束但未生成结果",不要继续等待
94
95
  - `running`:告知还在进行,继续等用户通知