pi-multi-viewers 0.1.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 +192 -0
- package/README.md +153 -0
- package/docs/design.md +288 -0
- package/docs/examples/first-experiment/README.md +42 -0
- package/docs/examples/first-experiment/work-a/AGENTS.md +10 -0
- package/docs/examples/first-experiment/work-a/a/0001.md +65 -0
- package/docs/examples/first-experiment/work-a/a/0002.md +70 -0
- package/docs/examples/first-experiment/work-a/perspective.md +5 -0
- package/docs/examples/first-experiment/work-b/AGENTS.md +10 -0
- package/docs/examples/first-experiment/work-b/b/0001.md +100 -0
- package/docs/examples/first-experiment/work-b/perspective.md +6 -0
- package/docs/reviews/2026-09-10-e2e10-fork-source-modes-review.md +366 -0
- package/docs/reviews/2026-09-10-e2e11-forkmode-guards-review.md +229 -0
- package/docs/reviews/2026-09-10-e2e12-code-review.md +346 -0
- package/docs/reviews/2026-09-11-e2e13-code-review.md +284 -0
- package/docs/reviews/2026-09-11-e2e14-observability-review.md +216 -0
- package/docs/reviews/README.md +36 -0
- package/docs/test-methodology.md +258 -0
- package/extensions/multi-viewers-say/index.ts +156 -0
- package/fake_agent.py +120 -0
- package/human_sayer.py +144 -0
- package/human_viewer.py +215 -0
- package/meeting_core.py +255 -0
- package/meeting_engine.py +733 -0
- package/meeting_fs.py +1066 -0
- package/meeting_loop.py +606 -0
- package/package.json +41 -0
- package/prompts/multi-viewers.md +94 -0
- package/scripts/check-residue.sh +190 -0
- package/scripts/mv.sh +325 -0
- package/start_discussion.py +1485 -0
- package/templates/AGENTS.md.tpl +100 -0
- package/templates/agent.md.tpl +9 -0
- package/templates/gitignore.tpl +7 -0
- package/templates/spec-readme.md.tpl +87 -0
package/meeting_loop.py
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""meeting_loop.py —— 真实 LLM 薄壳(Pi 适配版)。
|
|
3
|
+
|
|
4
|
+
复用 meeting_engine 的唯一状态机,只注入"唤醒 pi"的 responder。
|
|
5
|
+
协议逻辑(锁/配额/级联/信号)全在引擎,此处只做 LLM 交互。
|
|
6
|
+
|
|
7
|
+
用法:python3 meeting_loop.py <workdir> <agent> [--pure]
|
|
8
|
+
(配额/超时从 protocol.json 读——单一事实源;无 CLI 覆盖)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shlex
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
21
|
+
|
|
22
|
+
import meeting_fs
|
|
23
|
+
from meeting_fs import next_msg_id, log
|
|
24
|
+
from meeting_engine import agent_loop
|
|
25
|
+
|
|
26
|
+
MIN_MEM_MB = 2000
|
|
27
|
+
MAX_WAKE_SEC = 900
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RecoverableWakeError(Exception):
|
|
31
|
+
"""可恢复的唤醒失败(如内存不足)——引擎应 sleep 后下轮重试,
|
|
32
|
+
不进入"无产出→代写 freezing"路径(审核#2:临时内存压力不能变
|
|
33
|
+
永久发言锁)。区别于 LLM 物理性无法产出(走代写兜底)。"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# 当前唤醒中的 pi 子进程句柄(SIGTERM 时 terminate,防孤儿残留)
|
|
37
|
+
_current_proc = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _handle_sigterm(sig, frame):
|
|
41
|
+
"""SIGTERM:terminate 唤醒中的 pi 子进程后退出(用户 2026-09-01)。
|
|
42
|
+
|
|
43
|
+
kill loop → 子进程 pi 陪葬,不残留孤儿(实测教训:kill loop 后 pi
|
|
44
|
+
变孤儿继续跑)。若唤醒未进行(_current_proc 为 None)直接退出。
|
|
45
|
+
"""
|
|
46
|
+
global _current_proc
|
|
47
|
+
if _current_proc is not None and _current_proc.poll() is None:
|
|
48
|
+
_kill_proc(_current_proc)
|
|
49
|
+
raise SystemExit(0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _kill_proc(proc):
|
|
53
|
+
"""终止子进程:先 SIGTERM,5s 未退再 SIGKILL(兜底必杀)。
|
|
54
|
+
|
|
55
|
+
实测教训(2026-09-01 e2e):communicate() 无超时等 terminate 会
|
|
56
|
+
永久卡死(pi 不响应 SIGTERM 时,wchan do_sys_poll 空转 36s+)。
|
|
57
|
+
subprocess.run(timeout) 的原语义 = 超时 kill 强杀,此处对齐。
|
|
58
|
+
"""
|
|
59
|
+
if proc.poll() is not None:
|
|
60
|
+
return
|
|
61
|
+
proc.terminate()
|
|
62
|
+
try:
|
|
63
|
+
proc.communicate(timeout=5)
|
|
64
|
+
except subprocess.TimeoutExpired:
|
|
65
|
+
proc.kill()
|
|
66
|
+
proc.communicate()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
signal.signal(signal.SIGTERM, _handle_sigterm)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def mem_available_mb():
|
|
73
|
+
try:
|
|
74
|
+
with open("/proc/meminfo") as f:
|
|
75
|
+
for line in f:
|
|
76
|
+
if line.startswith("MemAvailable:"):
|
|
77
|
+
return int(line.split()[1]) / 1024
|
|
78
|
+
except OSError:
|
|
79
|
+
return 99999
|
|
80
|
+
return 99999
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mem_peak_mb():
|
|
84
|
+
"""本进程峰值 RSS(MB)——构建观测点用(见 _prepare_fork_session)。"""
|
|
85
|
+
try:
|
|
86
|
+
import resource
|
|
87
|
+
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
|
|
88
|
+
except (ImportError, ValueError):
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_session_id(workdir, agent):
|
|
93
|
+
path = os.path.join(os.path.dirname(workdir), f"status-{agent}.json")
|
|
94
|
+
try:
|
|
95
|
+
with open(path) as f:
|
|
96
|
+
return json.load(f).get("sessionID", "")
|
|
97
|
+
except (OSError, ValueError):
|
|
98
|
+
return ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def save_session_id(workdir, agent, sid):
|
|
102
|
+
path = os.path.join(os.path.dirname(workdir), f"status-{agent}.json")
|
|
103
|
+
with open(path, "w") as f:
|
|
104
|
+
json.dump({"sessionID": sid}, f)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_session(stdout):
|
|
108
|
+
"""防御性解析 pi --mode json 输出的 session 头。
|
|
109
|
+
|
|
110
|
+
pi 在 JSON 模式的第一行输出 SessionHeader:{"type":"session","id":...}。
|
|
111
|
+
扫描所有 JSON 行,优先取 type=session 的 id;也兼容旧式 sessionID 字段。
|
|
112
|
+
"""
|
|
113
|
+
for line in stdout.splitlines():
|
|
114
|
+
line = line.strip()
|
|
115
|
+
if not line.startswith("{"):
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
ev = json.loads(line)
|
|
119
|
+
except ValueError:
|
|
120
|
+
continue
|
|
121
|
+
if isinstance(ev, dict):
|
|
122
|
+
if ev.get("type") == "session" and ev.get("id"):
|
|
123
|
+
return ev["id"]
|
|
124
|
+
if ev.get("sessionID"):
|
|
125
|
+
return ev["sessionID"]
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_agent_config(workdir, agent):
|
|
130
|
+
"""读 pi-agent.json(setup 生成,per-agent 本地配置)。
|
|
131
|
+
|
|
132
|
+
返回 dict:{model, thinking, prompt_file}。
|
|
133
|
+
文件缺失时返回空 dict——wake_llm 仍能跑(用默认模型/无附加 prompt)。
|
|
134
|
+
"""
|
|
135
|
+
path = os.path.join(workdir, "pi-agent.json")
|
|
136
|
+
try:
|
|
137
|
+
with open(path) as f:
|
|
138
|
+
return json.load(f)
|
|
139
|
+
except (OSError, ValueError):
|
|
140
|
+
return {}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_wake_prompt(agent, meta, is_first, state, retry,
|
|
144
|
+
msg_path=None, perspective_brief=None):
|
|
145
|
+
"""唤醒 prompt(动态信息;协议规则在 AGENTS.md)。
|
|
146
|
+
|
|
147
|
+
wake prompt 是**最后一条 user 消息**——与 system prompt(协议+视角
|
|
148
|
+
任务书)前后呼应的最后一道角色锚定(用户 2026-09-09:e2e 三轮实测,
|
|
149
|
+
fork 全量历史的行为先例会淹没中段注入,末位 user 必须自带身份)。
|
|
150
|
+
|
|
151
|
+
perspective_brief: 视角任务书正文(身份重申用,取自 pi-agent.json
|
|
152
|
+
指向的文件内容)——首段原文注入。
|
|
153
|
+
|
|
154
|
+
msg_path: loop 计算的下一个消息路径(如 'a/0003.md')——指定 LLM
|
|
155
|
+
应写的文件(用户 9618:文件名由 loop 决定而非 LLM 自己算,可靠性
|
|
156
|
+
更高;仍不保证 LLM 会写,但消除"算错序号撞已提交"的假无产出主因)。
|
|
157
|
+
LLM 不需要知道 git HEAD(用户 9773):seen_at 是协议字段归 loop 填,
|
|
158
|
+
传 HEAD 给 LLM 反而引入 git 概念——彻底脱离。
|
|
159
|
+
"""
|
|
160
|
+
lines = []
|
|
161
|
+
# 角色锚定(最后一条 user 的首位——紧邻生成时刻,权重最高)
|
|
162
|
+
lines.append(f"你是本次多视角分析的参与者「{agent}」,"
|
|
163
|
+
"你的视角任务书在 system prompt 中。")
|
|
164
|
+
if perspective_brief:
|
|
165
|
+
lines.append(f"你的视角:{perspective_brief}")
|
|
166
|
+
lines.append("你的当前任务只有一个:按下述要求写一条消息文件。"
|
|
167
|
+
"上下文中的历史(开发过程、对话、监控命令等)都只是背景,"
|
|
168
|
+
"与写这条消息无关。不要执行任何等待、监控或其它动作。")
|
|
169
|
+
if retry:
|
|
170
|
+
lines.append("你刚才被唤醒但没写消息。必须写一条消息文件。")
|
|
171
|
+
if is_first:
|
|
172
|
+
lines.append("(无新消息,你是讨论的第一位发言者——直接产出你的"
|
|
173
|
+
"第一条视角分析,作为消息正文)")
|
|
174
|
+
if msg_path:
|
|
175
|
+
lines.append(f"请把你的消息写到: {msg_path}(不要写别的文件名)")
|
|
176
|
+
lines.append(f"当前状态: {state}")
|
|
177
|
+
lines.append("必须写完整 frontmatter:from(你)、type(message/freezing/pass)、"
|
|
178
|
+
"summary(message 类型必填,一句话概括你说了什么)")
|
|
179
|
+
if meta:
|
|
180
|
+
lines.append("需要读取的新消息:")
|
|
181
|
+
for m in meta:
|
|
182
|
+
lines.append(f"- {m['path']}")
|
|
183
|
+
return "\n".join(lines)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _lock_git(workdir):
|
|
187
|
+
"""唤醒 LLM 前锁定本地 git:.git 改名 .git.locked(原子)。
|
|
188
|
+
|
|
189
|
+
LLM 有 bash 工具,理论上可执行 git commit/push 破坏 loop 的流程管理
|
|
190
|
+
(绕过 commit_new_files 补全)。改名方案:LLM 对话期间 .git 不存在 →
|
|
191
|
+
**从该 workdir 发起**的 git 操作失败(git 不再上溯到主项目——由
|
|
192
|
+
`_run_wake_proc` 注入的 GIT_CEILING_DIRECTORIES 提供);loop 完成后改回。
|
|
193
|
+
|
|
194
|
+
**守卫范围(重要,勿读成全称)**:只约束"从讨论 workdir 发起的 git
|
|
195
|
+
操作"。主项目仓库不在守卫范围——agent 的 cwd 就是主项目,它可以
|
|
196
|
+
直接在其中执行 git;那部分约束归指令层(工作协议"不要执行 git
|
|
197
|
+
操作")+ 主项目 .gitignore(mv-*/ 使分析内容不会被杂散
|
|
198
|
+
`git add -A` 提交进主仓库)。要真正拦住主仓库需换机制类(沙箱/钩子),
|
|
199
|
+
经评估收益不支撑扩面(评审 A1 裁决记录)。
|
|
200
|
+
|
|
201
|
+
归属说明:**刻意不搬到 meeting_fs**——_lock_git 与 finally 里的
|
|
202
|
+
restore_git_lock(解锁)在调用点就近配对("加锁必有解锁"可就地验证,
|
|
203
|
+
异常路径一目了然);搬去 fs 层会使该验证跨文件,收益为负(os.rename
|
|
204
|
+
零成本、无 I/O 封装价值)。
|
|
205
|
+
"""
|
|
206
|
+
git_dir = os.path.join(workdir, ".git")
|
|
207
|
+
locked = git_dir + ".locked"
|
|
208
|
+
if os.path.isdir(git_dir) and not os.path.exists(locked):
|
|
209
|
+
os.rename(git_dir, locked)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def restore_git_lock(workdir):
|
|
213
|
+
"""把 .git.locked 改回 .git(锁恢复的唯一实现)。返回是否确实恢复。
|
|
214
|
+
|
|
215
|
+
纯函数 + 返回值:**只在确实恢复时**需要打日志(启动路径打、finally
|
|
216
|
+
路径不打——正常每轮都恢复不是事件,日志会变噪音);调用方各自决定。
|
|
217
|
+
两个调用点:`wake_llm` 的 finally(正常路径)与启动时的
|
|
218
|
+
`recover_git_lock`(崩溃残留路径)。
|
|
219
|
+
"""
|
|
220
|
+
git_dir = os.path.join(workdir, ".git")
|
|
221
|
+
locked = git_dir + ".locked"
|
|
222
|
+
if os.path.isdir(locked) and not os.path.exists(git_dir):
|
|
223
|
+
os.rename(locked, git_dir)
|
|
224
|
+
return True
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def recover_git_lock(workdir, agent):
|
|
229
|
+
"""启动时恢复崩溃残留的 git 锁:.git.locked → .git(确实恢复才记日志)。"""
|
|
230
|
+
if restore_git_lock(workdir):
|
|
231
|
+
log(agent, "检测到 .git 残留锁(上次中断)——已恢复")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _prepare_fork_session(workdir, agent, sid, fork_source, fork_cwd,
|
|
235
|
+
session_dir, fork_mode, topic):
|
|
236
|
+
"""首唤准备:生成 fork 源 + 注入切换叙事;返回 fork 源路径。
|
|
237
|
+
|
|
238
|
+
与命令组装分开:这里是"首唤一次性准备"(文件生成/叙事注入/统计
|
|
239
|
+
日志),后者是纯命令拼装——两事混在一起曾让单函数 125 行。
|
|
240
|
+
|
|
241
|
+
构建观测点:耗时与峰值 RSS 随统计行一起打印(触发条件可核验的
|
|
242
|
+
最低手段——不建指标体系、不进 status)。
|
|
243
|
+
"""
|
|
244
|
+
fork_src = os.path.join(session_dir, f"fork-src-{sid}.jsonl")
|
|
245
|
+
t0 = time.perf_counter()
|
|
246
|
+
n, err = meeting_fs.build_fork_source(
|
|
247
|
+
fork_source, fork_src, sid, fork_cwd or workdir, mode=fork_mode)
|
|
248
|
+
if err:
|
|
249
|
+
log(agent, f"[fatal] fork 源生成失败(mode={fork_mode}): {err}")
|
|
250
|
+
raise RuntimeError(err)
|
|
251
|
+
build_ms = (time.perf_counter() - t0) * 1000
|
|
252
|
+
# 统计取自产物自描述 header(单一来源;读失败降级为只打条数)
|
|
253
|
+
stats = meeting_fs.read_fork_stats(fork_src)
|
|
254
|
+
perf = f"{build_ms:.0f}ms/{mem_peak_mb():.0f}MB"
|
|
255
|
+
if stats.get("est") is not None:
|
|
256
|
+
est = stats["est"]
|
|
257
|
+
est_txt = f"est≈{est // 1000}k" if est >= 1000 else f"est≈{est}"
|
|
258
|
+
log(agent, f"fork 源({stats.get('mode') or fork_mode},{n} 条,"
|
|
259
|
+
f"丢弃 {stats.get('dropped')} 条,{est_txt}"
|
|
260
|
+
f"(字符/3 估算),构建 {perf}): {os.path.basename(fork_src)}")
|
|
261
|
+
else:
|
|
262
|
+
log(agent, f"fork 源({fork_mode},{n} 条,构建 {perf}): "
|
|
263
|
+
f"{os.path.basename(fork_src)}")
|
|
264
|
+
# 切换叙事:源尾部注入"停止旧任务 → 新任务说明 → assistant 确认"
|
|
265
|
+
# 对话——显式切断历史叙事惯性(agent 读到的最后叙事是任务切换
|
|
266
|
+
# 共识,不再扮演主 pi)。任务说明 = 视角 brief + 主题(来自
|
|
267
|
+
# protocol.json.topic,P1:单一事实源,不再二次解析 question.md)
|
|
268
|
+
topic_txt = topic or "见 question.md"
|
|
269
|
+
turns = [
|
|
270
|
+
("user", "从现在开始,我们停止之前的任务的执行,开始新任务。"),
|
|
271
|
+
("assistant", "好的,请说明新任务的具体信息。"),
|
|
272
|
+
# 只交代"身份与任务书在哪",**不拼视角文件正文**——身份/视角措辞
|
|
273
|
+
# 的唯一来源是 system prompt 注入(gen_agent_def + --append-system-prompt);
|
|
274
|
+
# 此处再拼一遍会形成第三处措辞(与 gen_agent_def、AGENTS.md 各自生成),
|
|
275
|
+
# 且此前拼的是整个 agent 定义文件(含生成头)并带 500 字截断分支
|
|
276
|
+
("user", f"新任务:你是多视角分析中的「{agent}」视角参与者。"
|
|
277
|
+
f"你的身份与视角任务书已在 system prompt 中注入——按它行事。"
|
|
278
|
+
f"分析主题:{topic_txt}。你的唯一任务是参与这次多视角"
|
|
279
|
+
f"分析——按视角产出分析/回应其他参与者,写消息文件的路径"
|
|
280
|
+
f"由本地循环在每次唤醒时告知。上下文中的历史(之前的开发、"
|
|
281
|
+
f"监控、测试等)都与新任务无关。"),
|
|
282
|
+
("assistant", f"好的,我已理解新任务:以「{agent}」视角参与分析"
|
|
283
|
+
f"(主题:{topic_txt}),完成每次唤醒指定的消息写入,"
|
|
284
|
+
f"不做任务以外的任何事。"),
|
|
285
|
+
]
|
|
286
|
+
tn = meeting_fs.append_handoff_turns(fork_src, turns)
|
|
287
|
+
log(agent, f"切换叙事已注入({tn} 条消息/{len(turns) // 2} 对对话)")
|
|
288
|
+
return fork_src
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _build_wake_cmd(workdir, agent, sid, cfg, fork_source, fork_cwd,
|
|
292
|
+
session_dir, first_wake, pure, prompt,
|
|
293
|
+
fork_mode=meeting_fs.DEFAULT_FORK_MODE, topic=""):
|
|
294
|
+
"""组装唤醒命令(#3 拆分,e2e7 评审):返回 (cmd, spawn_cwd)。
|
|
295
|
+
|
|
296
|
+
首唤:fork 源生成(_prepare_fork_session,fork_mode=compaction|budget|
|
|
297
|
+
full)→ `--session` 直接打开;续接:`--session-id`。
|
|
298
|
+
cwd = fork_cwd(主项目)优先。协议/视角注入在此追加。
|
|
299
|
+
"""
|
|
300
|
+
base = os.path.dirname(workdir)
|
|
301
|
+
if first_wake:
|
|
302
|
+
base_name = os.path.basename(base.rstrip("/")) or "discussion"
|
|
303
|
+
display_name = f"{base_name}-{agent}"
|
|
304
|
+
fork_src = _prepare_fork_session(workdir, agent, sid, fork_source,
|
|
305
|
+
fork_cwd, session_dir, fork_mode,
|
|
306
|
+
topic)
|
|
307
|
+
cmd = ["pi", "--mode", "json", "--session", fork_src,
|
|
308
|
+
"--name", display_name, "--session-dir", session_dir]
|
|
309
|
+
else:
|
|
310
|
+
cmd = ["pi", "--mode", "json", "--session-id", sid,
|
|
311
|
+
"--session-dir", session_dir]
|
|
312
|
+
if pure:
|
|
313
|
+
# Pi 的 pure 近似:关闭外部扩展/技能/prompt-template/主题加载,
|
|
314
|
+
# 保留内置工具(read/bash/edit/write)与项目内 AGENTS.md。
|
|
315
|
+
cmd += ["--no-extensions", "--no-skills", "--no-prompt-templates",
|
|
316
|
+
"--no-themes"]
|
|
317
|
+
model = cfg.get("model") or ""
|
|
318
|
+
if model:
|
|
319
|
+
cmd += ["--model", model]
|
|
320
|
+
thinking = cfg.get("thinking") or ""
|
|
321
|
+
if thinking:
|
|
322
|
+
cmd += ["--thinking", thinking]
|
|
323
|
+
prompt_file = cfg.get("prompt_file") or ""
|
|
324
|
+
if prompt_file and os.path.isfile(os.path.join(workdir, prompt_file)):
|
|
325
|
+
cmd += ["--append-system-prompt", os.path.join(workdir, prompt_file)]
|
|
326
|
+
# 协议 AGENTS.md 注入(fork-only 缺口修复):work 不在主项目 cwd
|
|
327
|
+
# 祖先链上,pi 不会自动发现——无条件注入(文件存在才加)
|
|
328
|
+
protocol_md = os.path.join(workdir, "AGENTS.md")
|
|
329
|
+
if os.path.isfile(protocol_md):
|
|
330
|
+
cmd += ["--append-system-prompt", protocol_md]
|
|
331
|
+
# 非交互模式 + JSON 事件流;自动信任项目本地文件(AGENTS.md 等)
|
|
332
|
+
cmd += ["--approve", "--print", prompt]
|
|
333
|
+
return cmd, (fork_cwd or workdir)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _run_wake_proc(cmd, spawn_cwd, workdir, agent):
|
|
337
|
+
"""spawn + 分片等待(#3 拆分):返回 CompletedProcess。
|
|
338
|
+
|
|
339
|
+
三路径语义(2026-09-01 定,不得改变):
|
|
340
|
+
① pi 正常结束 → 返回 CompletedProcess
|
|
341
|
+
② 讨论目录被清理(cleanup)→ _kill_proc + SystemExit(0)(干净退出)
|
|
342
|
+
③ 总超时 → _kill_proc + 抛 TimeoutExpired(上层可恢复重试)
|
|
343
|
+
"""
|
|
344
|
+
global _current_proc
|
|
345
|
+
base = os.path.dirname(workdir)
|
|
346
|
+
# stdout 全量缓冲(A,e2e7 评审):唯一消费者是调用方的 parse_session
|
|
347
|
+
# ——只取 session 头的兜底路径(sid 已预生成,续接不依赖 parse
|
|
348
|
+
# 成功)。性能实测 ≈150-200 KB/唤醒、峰值亚 MB(不构成风险);
|
|
349
|
+
# 若改为流式读取,必须让"谁读 session 头"同样显式可见(可读性保留票)。
|
|
350
|
+
#
|
|
351
|
+
# GIT_CEILING_DIRECTORIES=<讨论目录>:**git 上溯防护**(实现 A1)。
|
|
352
|
+
# 本进程的 argv 就是 agent 会话里 bash 工具所继承的环境来源——
|
|
353
|
+
# 注入后,从 work-<agent> 发起的 git 不会上溯到主项目仓库。
|
|
354
|
+
# 为什么需要:_lock_git 把 work-<agent>/.git 改名后,git 的默认行为是
|
|
355
|
+
# **向上继续找仓库**——fork 模式下 cwd=主项目,实测锁态下
|
|
356
|
+
# `git rev-parse --git-dir` 从 workdir 发起会命中主项目 .git(rc=0),
|
|
357
|
+
# 守卫形同虚设(见 _lock_git docstring 的范围说明)。
|
|
358
|
+
# 注:Popen 的 env 是**整体替换**,必须合并 os.environ(否则丢 PATH)。
|
|
359
|
+
proc = subprocess.Popen(cmd, cwd=spawn_cwd, stdout=subprocess.PIPE,
|
|
360
|
+
stderr=subprocess.PIPE, text=True,
|
|
361
|
+
env={**os.environ, "GIT_CEILING_DIRECTORIES": base})
|
|
362
|
+
_current_proc = proc
|
|
363
|
+
try:
|
|
364
|
+
# 分片等待:每片检查讨论目录是否被清理(cleanup 删目录)——
|
|
365
|
+
# 唤醒阻塞不再屏蔽自退出(cleanup 是唯一清理操作,不靠手动 kill)
|
|
366
|
+
out = err = None
|
|
367
|
+
normal = False
|
|
368
|
+
deadline = time.time() + MAX_WAKE_SEC
|
|
369
|
+
while time.time() < deadline:
|
|
370
|
+
try:
|
|
371
|
+
out, err = proc.communicate(timeout=15)
|
|
372
|
+
normal = True
|
|
373
|
+
break # 正常结束
|
|
374
|
+
except subprocess.TimeoutExpired:
|
|
375
|
+
if not os.path.isdir(meeting_fs.bare_of_base(base)):
|
|
376
|
+
log(agent, "讨论目录已清理——终止唤醒中的 pi")
|
|
377
|
+
_kill_proc(proc)
|
|
378
|
+
raise SystemExit(0) # 干净退出(不被 except 捕获)
|
|
379
|
+
if not normal:
|
|
380
|
+
# 总超时:保持原语义(超时 = kill 强杀 + 抛 TimeoutExpired
|
|
381
|
+
# → 上层可恢复重试)
|
|
382
|
+
_kill_proc(proc)
|
|
383
|
+
raise subprocess.TimeoutExpired(cmd, MAX_WAKE_SEC)
|
|
384
|
+
finally:
|
|
385
|
+
_current_proc = None
|
|
386
|
+
return subprocess.CompletedProcess(cmd, proc.returncode, out or "",
|
|
387
|
+
err or "")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def wake_llm(workdir, agent, prompt, pure=False, fork_source=None, fork_cwd=None,
|
|
391
|
+
fork_mode=meeting_fs.DEFAULT_FORK_MODE, topic=""):
|
|
392
|
+
"""唤醒 pi(fork-only:首唤由本地生成 fork 源 + `--session` 打开,
|
|
393
|
+
后续 `--session-id` 续接)。返回 (sessionID, returncode)。
|
|
394
|
+
|
|
395
|
+
每次唤醒记录完整命令行 + prompt 到 wake-logs/(排错第一手段)。
|
|
396
|
+
命令组装与进程等待拆为 _build_wake_cmd / _run_wake_proc(#3 拆分,
|
|
397
|
+
e2e7 评审——可读性:单函数曾 125 行/嵌套 5 层)。
|
|
398
|
+
"""
|
|
399
|
+
if not fork_source:
|
|
400
|
+
raise RuntimeError(
|
|
401
|
+
"fork 源未配置(protocol.json 缺 forkSource)——多视角模式必须在"
|
|
402
|
+
"主 pi session 内启动(无 session 时先在项目目录跑一次 pi --print 造引导 session)")
|
|
403
|
+
cfg = read_agent_config(workdir, agent)
|
|
404
|
+
sid = load_session_id(workdir, agent)
|
|
405
|
+
base = os.path.dirname(workdir)
|
|
406
|
+
session_dir = os.path.join(base, "pi-sessions")
|
|
407
|
+
first_wake = not sid
|
|
408
|
+
if first_wake:
|
|
409
|
+
# 预生成 UUID 并显式传 --session-id:即使输出解析失败,本进程
|
|
410
|
+
# 也有确定 sid(续接不依赖 parse 成功)
|
|
411
|
+
import uuid
|
|
412
|
+
sid = str(uuid.uuid4())
|
|
413
|
+
cmd, spawn_cwd = _build_wake_cmd(workdir, agent, sid, cfg, fork_source,
|
|
414
|
+
fork_cwd, session_dir, first_wake,
|
|
415
|
+
pure, prompt, fork_mode, topic)
|
|
416
|
+
|
|
417
|
+
log_dir = os.path.join(base, "wake-logs")
|
|
418
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
419
|
+
os.makedirs(session_dir, exist_ok=True)
|
|
420
|
+
# wake-log = 命令行全文(**单一来源**):prompt 通过 argv(--append-system-prompt
|
|
421
|
+
# / positional)进入 cmd,故此处不再另写 PROMPT 段——此前两处逐字重复占
|
|
422
|
+
# 文件 40–43%(§3.5-P8)。shlex.quote 逐元素引用 → CMD 是**可真行级 grep**
|
|
423
|
+
# 的单行(含空格/换行的 prompt 值不会把记录撕成多行)。
|
|
424
|
+
with open(os.path.join(log_dir, f"{agent}-{int(time.time())}.txt"), "w") as f:
|
|
425
|
+
f.write("CMD: " + " ".join(shlex.quote(a) for a in cmd) + "\n")
|
|
426
|
+
t0 = time.monotonic()
|
|
427
|
+
log(agent, f"唤醒 pi (session={sid})")
|
|
428
|
+
_lock_git(workdir)
|
|
429
|
+
try:
|
|
430
|
+
r = _run_wake_proc(cmd, spawn_cwd, workdir, agent)
|
|
431
|
+
finally:
|
|
432
|
+
restore_git_lock(workdir)
|
|
433
|
+
|
|
434
|
+
# 进程事实登记(§3.2):elapsed_ms 与 rc **无家**(session 首个 entry
|
|
435
|
+
# 之前是黑箱、rc 只有 Popen 知道)→ 在数据已在手处就地捕获,零解析。
|
|
436
|
+
# rc 总是写(零成本、权威);elapsed_ms 用 monotonic 差值,跨度 = **pi
|
|
437
|
+
# 进程生命周期**(spawn → exit,与 wake prompt 跨度/墙钟都不同口径)。
|
|
438
|
+
_log_wake_done(agent, sid, r, int((time.monotonic() - t0) * 1000))
|
|
439
|
+
new_sid = parse_session(r.stdout) or sid
|
|
440
|
+
if new_sid:
|
|
441
|
+
save_session_id(workdir, agent, new_sid)
|
|
442
|
+
if r.returncode != 0:
|
|
443
|
+
# 常见可重试失败:session 文件损坏/不存在。pi 对 --session-id
|
|
444
|
+
# 通常自动创建;保留 stderr 日志便于诊断。明确 "No session
|
|
445
|
+
# found" 则清空 status 后下轮新建。
|
|
446
|
+
if "No session found" in (r.stderr or "") or "Session not found" in (r.stderr or ""):
|
|
447
|
+
log(agent, "唤醒失败(session 无效)——清空重试")
|
|
448
|
+
sp = os.path.join(base, f"status-{agent}.json")
|
|
449
|
+
if os.path.exists(sp):
|
|
450
|
+
os.remove(sp)
|
|
451
|
+
return new_sid, r.returncode
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _log_wake_done(agent, sid, r, elapsed_ms):
|
|
455
|
+
"""唤醒完成行(登记字段 + ISO8601 时间戳)。
|
|
456
|
+
|
|
457
|
+
§3.2 契约:`elapsed_ms` = pi 进程生命周期跨度;`rc` = 进程返回值(权威、
|
|
458
|
+
总是写)。超时/被 kill 路径走异常分支(本函数不执行)——**缺席 ≠ 0**:
|
|
459
|
+
没有值就不写字段,读侧按 n/a 处理。
|
|
460
|
+
时间戳升级为 ISO8601(含日期):秒级 `HH:MM:SS` 无法跨天 join,也无法
|
|
461
|
+
与 session/commit 时间对齐(§3.5-P10)。
|
|
462
|
+
"""
|
|
463
|
+
log(agent, f"pi 完成(session={sid} elapsed_ms={elapsed_ms} "
|
|
464
|
+
f"rc={r.returncode})")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _read_perspective_brief(workdir, agent):
|
|
468
|
+
"""读视角任务书正文(wake prompt 身份重申用)。
|
|
469
|
+
|
|
470
|
+
来源:pi-agent.json 的 prompt_file 字段(与注入同一事实源,L3 修复
|
|
471
|
+
——原硬编码 .pi/agent/<agent>.md,与注入路径两处推导,prompt_file
|
|
472
|
+
一改身份重申静默降级);缺失字段回退默认路径(前向兼容)。
|
|
473
|
+
缺失/超长(>500 字)→ 截断;文件不存在 → None(不影响唤醒)。
|
|
474
|
+
"""
|
|
475
|
+
try:
|
|
476
|
+
with open(os.path.join(workdir, "pi-agent.json")) as f:
|
|
477
|
+
pf = json.load(f).get("prompt_file") or ""
|
|
478
|
+
except (OSError, ValueError):
|
|
479
|
+
pf = ""
|
|
480
|
+
fp = (os.path.join(workdir, pf) if pf
|
|
481
|
+
else os.path.join(workdir, ".pi/agent", f"{agent}.md"))
|
|
482
|
+
try:
|
|
483
|
+
with open(fp, encoding="utf-8") as f:
|
|
484
|
+
brief = f.read().strip()
|
|
485
|
+
except OSError:
|
|
486
|
+
return None
|
|
487
|
+
if len(brief) > 500:
|
|
488
|
+
brief = brief[:500] + "…(见 system prompt 完整任务书)"
|
|
489
|
+
return brief or None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def make_responder(pure, fork_source=None, fork_cwd=None,
|
|
493
|
+
fork_mode=meeting_fs.DEFAULT_FORK_MODE, topic=""):
|
|
494
|
+
"""构造真实 LLM responder:唤醒 pi,LLM 写内容文件。
|
|
495
|
+
|
|
496
|
+
LLM 只提供内容(写消息文件),流程(补全字段/commit/push)
|
|
497
|
+
由引擎的 commit_new_files 接管。"""
|
|
498
|
+
def responder(workdir, agent, head, meta, is_first, rr_turn, retry,
|
|
499
|
+
finalizing=False, finalize_reason="consensus"):
|
|
500
|
+
if finalizing:
|
|
501
|
+
if finalize_reason == "consensus":
|
|
502
|
+
reason_txt = "所有参与者已 pass,共识达成"
|
|
503
|
+
elif finalize_reason == "quota":
|
|
504
|
+
reason_txt = "达到轮次上限,未完全共识"
|
|
505
|
+
else:
|
|
506
|
+
reason_txt = "无进展超时(stall),未完全共识"
|
|
507
|
+
# fork 模式(cwd=主项目)下“工作区根目录”有歧义——路径必须绝对
|
|
508
|
+
result_path = os.path.join(workdir, "result.md")
|
|
509
|
+
prompt = (f"讨论已收敛({reason_txt})。"
|
|
510
|
+
f"请写 result.md 到 {result_path},总结讨论结论。")
|
|
511
|
+
if retry:
|
|
512
|
+
prompt = (f"你上一次被唤醒但未生成有效的 result.md。"
|
|
513
|
+
f"请现在写 result.md(非空,总结讨论结论)到 {result_path}。")
|
|
514
|
+
if mem_available_mb() < MIN_MEM_MB:
|
|
515
|
+
log(agent, "内存不足——抛可恢复异常(不代写 freezing,下轮重试)")
|
|
516
|
+
raise RecoverableWakeError("内存不足")
|
|
517
|
+
wake_llm(workdir, agent, prompt, pure,
|
|
518
|
+
fork_source=fork_source, fork_cwd=fork_cwd)
|
|
519
|
+
return True
|
|
520
|
+
if rr_turn:
|
|
521
|
+
state = "round-robin(轮到你:写 pass 确认共识,单向流无异议)"
|
|
522
|
+
else:
|
|
523
|
+
state = "meeting(有未读新消息,可发言或 freezing)"
|
|
524
|
+
# fork 模式(cwd=主项目)下 LLM 不在 workdir——msg_path/meta 必须
|
|
525
|
+
# 绝对路径(legacy 模式下绝对路径同样有效,统一一条路径)
|
|
526
|
+
msg_path = os.path.join(workdir, agent,
|
|
527
|
+
f"{next_msg_id(workdir, agent)}.md")
|
|
528
|
+
meta_abs = [dict(m, path=os.path.join(workdir, m["path"]))
|
|
529
|
+
for m in meta]
|
|
530
|
+
prompt = build_wake_prompt(agent, meta_abs, is_first, state, retry,
|
|
531
|
+
msg_path=msg_path,
|
|
532
|
+
perspective_brief=_read_perspective_brief(
|
|
533
|
+
workdir, agent))
|
|
534
|
+
if mem_available_mb() < MIN_MEM_MB:
|
|
535
|
+
log(agent, "内存不足——抛可恢复异常(不代写 freezing,下轮重试)")
|
|
536
|
+
raise RecoverableWakeError("内存不足")
|
|
537
|
+
wake_llm(workdir, agent, prompt, pure,
|
|
538
|
+
fork_source=fork_source, fork_cwd=fork_cwd,
|
|
539
|
+
fork_mode=fork_mode, topic=topic)
|
|
540
|
+
return True
|
|
541
|
+
return responder
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _preserve_result_md(workdir):
|
|
545
|
+
"""收尾时保存 result.md(薄包装 → meeting_fs.preserve_result_md,
|
|
546
|
+
T2 合并:与 cleanup 路径共享同一实现)。"""
|
|
547
|
+
dest = meeting_fs.preserve_result_md(os.path.dirname(workdir))
|
|
548
|
+
if dest:
|
|
549
|
+
print(f"[{time.strftime('%H:%M:%S')}] 已保存 result.md → {dest}",
|
|
550
|
+
flush=True)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
if __name__ == "__main__":
|
|
554
|
+
if len(sys.argv) < 3:
|
|
555
|
+
print("用法: python3 meeting_loop.py <workdir> <agent> "
|
|
556
|
+
"[--max-meeting N] [--max-rr N] [--stall-timeout S] [--pure]")
|
|
557
|
+
sys.exit(1)
|
|
558
|
+
workdir, agent = sys.argv[1], sys.argv[2]
|
|
559
|
+
recover_git_lock(workdir, agent)
|
|
560
|
+
pure = "--pure" in sys.argv
|
|
561
|
+
mm, mr, st = 10, 7, 600
|
|
562
|
+
# 协议从 **bare HEAD** 读(单一来源 = 共享事实;本地副本 LLM 可改)——
|
|
563
|
+
# 与 engine participants()/check_status 同一原语
|
|
564
|
+
bare = meeting_fs.bare_of_workdir(workdir)
|
|
565
|
+
proto = meeting_fs.read_protocol(bare)
|
|
566
|
+
if not proto:
|
|
567
|
+
print(f"[fatal] protocol.json 读取失败(bare HEAD 无有效内容): {bare}",
|
|
568
|
+
flush=True)
|
|
569
|
+
sys.exit(1)
|
|
570
|
+
if proto.get("pure"):
|
|
571
|
+
pure = True
|
|
572
|
+
if proto.get("maxMeetingRounds"):
|
|
573
|
+
mm = proto["maxMeetingRounds"]
|
|
574
|
+
if proto.get("maxRRRounds"):
|
|
575
|
+
mr = proto["maxRRRounds"]
|
|
576
|
+
if proto.get("stallTimeoutSeconds"):
|
|
577
|
+
st = proto["stallTimeoutSeconds"]
|
|
578
|
+
# (CLI 配额覆盖通道已删——L5:协议是配额唯一事实源,生产无调用方;
|
|
579
|
+
# docstring 用法行同步删除)
|
|
580
|
+
fork_mode_cfg = proto.get("forkMode") or meeting_fs.DEFAULT_FORK_MODE
|
|
581
|
+
if fork_mode_cfg not in meeting_fs.FORK_MODES:
|
|
582
|
+
# 配置错误不是运行期故障(不进 engine 重试路径)——照 forkSource 先例
|
|
583
|
+
print(f"[fatal] protocol.json 的 forkMode 非法: {fork_mode_cfg!r}"
|
|
584
|
+
f"(合法值: {'/'.join(meeting_fs.FORK_MODES)})——若来自旧版本"
|
|
585
|
+
f"产物(rename 前的 active/curated),请清理分析目录后重跑",
|
|
586
|
+
flush=True)
|
|
587
|
+
sys.exit(1)
|
|
588
|
+
fork_source = proto.get("forkSource") or ""
|
|
589
|
+
if not fork_source:
|
|
590
|
+
print("[fatal] protocol.json 缺 forkSource——多视角模式必须在主 pi "
|
|
591
|
+
"session 内启动(wrapper 会自动解析;无 session 时先在项目目录"
|
|
592
|
+
"跑一次 pi --print 造引导 session)", flush=True)
|
|
593
|
+
sys.exit(1)
|
|
594
|
+
try:
|
|
595
|
+
agent_loop(workdir, agent,
|
|
596
|
+
make_responder(pure,
|
|
597
|
+
fork_source=fork_source,
|
|
598
|
+
fork_cwd=proto.get("forkCwd") or "",
|
|
599
|
+
fork_mode=fork_mode_cfg,
|
|
600
|
+
topic=proto.get("topic") or ""),
|
|
601
|
+
max_meeting=mm, max_rr=mr, stall_timeout=st)
|
|
602
|
+
except KeyboardInterrupt:
|
|
603
|
+
log(agent, "被中断")
|
|
604
|
+
sys.exit(130)
|
|
605
|
+
if agent == proto.get("resultWriter"):
|
|
606
|
+
_preserve_result_md(workdir)
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-multi-viewers",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Multi-perspective analysis for Pi: fork the main session into N perspective agents over the meeting protocol.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"pi": {
|
|
9
|
+
"extensions": [
|
|
10
|
+
"./extensions"
|
|
11
|
+
],
|
|
12
|
+
"prompts": [
|
|
13
|
+
"./prompts"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"extensions",
|
|
18
|
+
"scripts",
|
|
19
|
+
"*.py",
|
|
20
|
+
"templates",
|
|
21
|
+
"prompts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"AGENTS.md",
|
|
24
|
+
"docs/design.md",
|
|
25
|
+
"docs/test-methodology.md",
|
|
26
|
+
"docs/examples",
|
|
27
|
+
"docs/reviews"
|
|
28
|
+
],
|
|
29
|
+
"keywords": [
|
|
30
|
+
"pi",
|
|
31
|
+
"multi-perspective",
|
|
32
|
+
"multi-agent",
|
|
33
|
+
"session-fork",
|
|
34
|
+
"analysis"
|
|
35
|
+
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/maxdai/pi-multi-viewers.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/maxdai/pi-multi-viewers#readme"
|
|
41
|
+
}
|