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/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,
|
|
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:
|
package/observability.py
ADDED
|
@@ -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