pi-multi-viewers 0.2.2 → 0.4.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/observability.py CHANGED
@@ -9,6 +9,7 @@ human_viewer(observability 是"读"侧,human_viewer.incremental 是它
9
9
  的进展数据源);不准 import 主文件。
10
10
  """
11
11
 
12
+ import datetime
12
13
  import glob
13
14
  import json
14
15
  import os
@@ -326,27 +327,94 @@ def build_report(base):
326
327
  f"{_dur(d['max_ms'] // 1000)} | rc≠0 {d['fails']} 次")
327
328
 
328
329
  # ---- LLM 运行事实(session 文档化字段;流式预过滤,不整文件解析) ----
330
+ # ---- 终止原因(只报事实与计数)----
331
+ _report_termination(base, agents, out)
332
+
333
+ # ---- 每次唤醒构成表(零插桩事后推导)----
334
+ _report_wake_table(base, agents, out)
335
+
329
336
  out.append("LLM(session 文档化字段):")
330
337
  any_usage = False
331
338
  for a in agents:
332
- u = _report_session_usage(base, a)
333
- if not u:
339
+ m = _report_session_metrics(base, a)
340
+ if not m:
334
341
  out.append(f" {a}: n/a")
335
342
  continue
336
343
  any_usage = True
337
- out.append(f" {a}: input {u['input']} | cacheRead "
338
- f"{u['cache_read']} | output {u['output']} | 响应 "
339
- f"{u['responses']} | error {u['errors']} 次")
344
+ u = m["usage"]
345
+ # usage 合计:reasoning output(**不可相加**)——缺席时省略该括注
346
+ # (缺席 0),出现时即使为 0 也写出
347
+ rea = ("" if u["reasoning"] is None
348
+ else f"(reasoning {_num(u['reasoning'])})")
349
+ out.append(f" {a}: 响应 {m['responses']} 次 | input {_num(u['input'])}"
350
+ f" | cacheRead {_num(u['cacheRead'])}"
351
+ f" | output {_num(u['output'])}{rea}")
352
+ # 按 stopReason 原值分组:**键集固定,不随数据增长**——新同类数字
353
+ # 只多一个键。error 那笔账(计数 + 时长)就在这里,这是 provider
354
+ # 失败从"完全不可见"变为"一行可读"的落点。
355
+ # 键 = 原值(含 None):解释性命名会因"长消息也是 toolUse"立刻过期。
356
+ parts = []
357
+ for k in sorted(m["stop"], key=lambda x: (x is None, x or "")):
358
+ d = m["stop"][k]
359
+ parts.append(f"{k if k is not None else '(无)'} "
360
+ f"{d['n']}({_dur(d['sec'])})")
361
+ out.append(f" stopReason:{' | '.join(parts)}")
340
362
  if not any_usage:
341
363
  out.append(" n/a(session 缺失,或无本轮数据——边界条目自 2026-09-11 "
342
364
  "起写入,此前的老分析不适用)")
365
+ # ---- 档位对照:声明值(spec/pi-agent.json)vs 生效值(session) ----
366
+ out.append(_report_levels_line(base, agents))
343
367
  out.append("(口径:进程跨度=pi 进程生命周期;输出=prompt 分段合计;"
344
368
  "墙钟=commit 时间差——三者不可互替;"
369
+ "stopReason 括注 = **相邻条目 Δ 合计**(含每次唤醒首条响应;"
370
+ "跨唤醒空闲不进入 Δ——唤醒 prompt 本身是一条 user 条目);"
345
371
  "消息数含流程信号(freezing/pass/concluded)与 human,"
346
372
  "配额只计 meeting 发言)")
347
373
  return out
348
374
 
349
375
 
376
+ def _report_levels_line(base, agents):
377
+ """档位对照行:**声明值 vs 生效值**(e2e17 评审 §1)。
378
+
379
+ 为什么需要:声明值(`pi-agent.json.thinking`)从来没人跟生效值对照过——
380
+ 探测失败 → 静默取 DEFAULT_THINKING,spec 文件表面完全正常。这一行是那个
381
+ 缺口的最小可见性形态:**零新增字段**(两个值都已有家,只是从没被并列读出)、
382
+ **零新增分支**(集合比较)。
383
+
384
+ 生效值来源 = session 的 `thinking_level_change` 条目(pi 在会话缺该条目时
385
+ 写入,位于边界之后)——**本仓只读,不复刻 pi 的解析链**(那是 pi 的配置)。
386
+ fail-open:任一侧读不到 → 显式 n/a(不猜、不写 0)。
387
+ """
388
+ declared, effective = {}, {}
389
+ for a in agents:
390
+ try:
391
+ with open(os.path.join(base, f"work-{a}", "pi-agent.json")) as f:
392
+ v = json.load(f).get("thinking") or ""
393
+ if v:
394
+ declared[a] = v
395
+ except (OSError, ValueError):
396
+ continue
397
+ levels = _report_session_levels(base, a)
398
+ effective[a] = levels
399
+ if not declared and not effective:
400
+ return "档位:n/a(无 pi-agent.json 且无 session 档位条目)"
401
+ d_set = set(declared.values())
402
+ e_set = {x for v in effective.values() for x in v}
403
+ d_txt = "、".join(sorted(d_set)) if d_set else "n/a"
404
+ e_txt = "、".join(sorted(e_set)) if e_set else "n/a"
405
+ if d_set and e_set:
406
+ same = (
407
+ f"✓ 一致" if d_set == e_set
408
+ # 不一致是两个方向的异常:声明了没生效(写错/被覆盖),或生效值
409
+ # 不在声明里(外部改档/会话遗留)——两种都要人看到
410
+ else f"⚠ 不一致(声明 {d_txt} / 生效 {e_txt})")
411
+ else:
412
+ same = "(一侧 n/a,无法对照)"
413
+ detail = "、".join(f"{a} {v or 'n/a'}"
414
+ for a, v in ((a, declared.get(a, "")) for a in agents))
415
+ return f"档位:声明 {d_txt}({detail})| 生效 {e_txt} | {same}"
416
+
417
+
350
418
  def _dur(sec):
351
419
  """人类可读时长(口径由调用方在同一行标注——进程跨度/墙钟/间隔)。"""
352
420
  sec = int(sec)
@@ -361,6 +429,195 @@ def _hhmm(ts):
361
429
  return time.strftime("%H:%M:%S", time.localtime(ts))
362
430
 
363
431
 
432
+ def _parse_wake_windows(base, agent):
433
+ """从一个 agent 的 loop log 解析每次唤醒的窗口(零判定输入——只读登记行)。
434
+
435
+ 返回 [(spawn_epoch, exit_epoch, elapsed_ms, rc, retries)];日志时间戳是
436
+ **本地无时区**(`[2026-09-13T19:43:10.123]`)→ 转 epoch 再与 session 的
437
+ UTC 时间戳 / git 的 epoch 对齐。fail-open:文件缺失 → []。
438
+ """
439
+ path = os.path.join(base, f"loop-{agent}.log")
440
+ wakes, cur = [], None
441
+ try:
442
+ lines = open(path, encoding="utf-8", errors="replace").read().splitlines()
443
+ except OSError:
444
+ return []
445
+ for line in lines:
446
+ m = re.match(r"\[([^\]]+)\]\s+\S+:\s+(.*)$", line)
447
+ if not m:
448
+ continue
449
+ try:
450
+ dt = datetime.datetime.fromisoformat(m.group(1))
451
+ except ValueError:
452
+ continue
453
+ epoch = time.mktime(dt.timetuple()) + dt.microsecond / 1e6
454
+ body = m.group(2)
455
+ if body.startswith("唤醒 pi"):
456
+ cur = [epoch, None, 0, 0, 0]
457
+ elif cur is not None and body.startswith("pi 完成"):
458
+ mm = re.search(r"elapsed_ms=(\d+) rc=(-?\d+)", body)
459
+ if mm:
460
+ cur[1] = epoch
461
+ cur[2] = int(mm.group(1))
462
+ cur[3] = int(mm.group(2))
463
+ wakes.append(tuple(cur))
464
+ cur = None
465
+ elif cur is not None and "无产出" in body and "重试" in body:
466
+ cur[4] += 1
467
+ return wakes
468
+
469
+
470
+ def _agent_event_roles(base, agent):
471
+ """该 agent 本轮(边界之后)的事件流:[(epoch, role)]——顺序读取,fail-open。"""
472
+ fp = _agent_session_file(base, agent)
473
+ if not fp:
474
+ return []
475
+ out = []
476
+ for ev in meeting_fs.iter_after_boundary(fp):
477
+ m = ev.get("message") or {}
478
+ role, ts = m.get("role"), ev.get("timestamp")
479
+ if not role or not ts:
480
+ continue
481
+ try:
482
+ out.append((datetime.datetime.fromisoformat(
483
+ ts.replace("Z", "+00:00")).timestamp(), role))
484
+ except ValueError:
485
+ continue
486
+ return out
487
+
488
+
489
+ def _wake_rows(base, agent, commits):
490
+ """每次唤醒的构成记录(零插桩事后推导;精度契约见 design.md 观测面契约)。
491
+
492
+ 每条 = 四端点(spawn / 首事件 / 末事件 / exit)+ 往返数 + assistant Δ +
493
+ toolResult Δ + retry + 本唤醒内的 commit。**不承诺**单次调用内的
494
+ TTFT/生成拆分(session 只有完成时间戳)。
495
+ """
496
+ wakes = _parse_wake_windows(base, agent)
497
+ if not wakes:
498
+ return []
499
+ events = _agent_event_roles(base, agent)
500
+ rows = []
501
+ for i, (t0, t1, ms, rc, retries) in enumerate(wakes, 1):
502
+ t1 = t1 or (t0 + ms / 1000)
503
+ row = {"n": i, "spawn": t0, "exit": t1, "ms": ms, "rc": rc,
504
+ "retries": retries, "rounds": 0, "d_assist": 0.0,
505
+ "d_tool": 0.0, "first": None, "last": None, "commit": None}
506
+ prev = None
507
+ for t, role in events:
508
+ if not (t0 - 2 <= t <= t1 + 2):
509
+ continue
510
+ if role == "assistant":
511
+ row["rounds"] += 1
512
+ if prev is not None and t >= prev:
513
+ row["d_assist"] += t - prev
514
+ elif role == "toolResult" and prev is not None and t >= prev:
515
+ row["d_tool"] += t - prev
516
+ if row["first"] is None:
517
+ row["first"] = t
518
+ row["last"] = t
519
+ prev = t
520
+ for c in commits:
521
+ if t0 <= c <= t1:
522
+ row["commit"] = c
523
+ break
524
+ rows.append(row)
525
+ return rows
526
+
527
+
528
+ def _report_wake_table(base, agents, out):
529
+ """每次唤醒构成表 + 聚合(只读 loop log / session / bare 三处已有事实)。"""
530
+ r = meeting_fs.run_git(meeting_fs.bare_of_base(base), "log",
531
+ "--format=%ct", check=False)
532
+ commits = sorted(int(x) for x in r.stdout.split() if x.isdigit())
533
+ any_row = False
534
+ for agent in agents:
535
+ rows = _wake_rows(base, agent, commits)
536
+ if not rows:
537
+ continue
538
+ if not any_row:
539
+ out.append("唤醒构成(每次一行;Δ 助手/Δ 工具 = 相邻条目差,"
540
+ "不含跨唤醒空闲):")
541
+ any_row = True
542
+ start = tail = inner = 0.0
543
+ decomposed = no_ev = 0 # 无 session 事件的唤醒无法分解(不冒充 0)
544
+ for row in rows:
545
+ pre = (row["first"] - row["spawn"]) if row["first"] else 0.0
546
+ post = (row["exit"] - row["last"]) if row["last"] else 0.0
547
+ inner += (row["last"] - row["first"]) if row["first"] else 0.0
548
+ start += pre
549
+ tail += post
550
+ if row["first"]:
551
+ decomposed += row["ms"] / 1000
552
+ else:
553
+ no_ev += 1
554
+ retry = f" retry×{row['retries']}" if row["retries"] else ""
555
+ commit = _hhmm(row["commit"]) if row["commit"] else "—"
556
+ out.append(
557
+ f" {agent} #{row['n']:>2} {_hhmm(row['spawn'])}"
558
+ f" 启动{pre:4.1f}s"
559
+ f" 内{_dur(row['last'] - row['first']) if row['first'] else 'n/a':>7}"
560
+ f" 收尾{post:4.1f}s | 往返 {row['rounds']:>2}"
561
+ f" | Δ助手 {_dur(row['d_assist']):>6} Δ工具 {_dur(row['d_tool']):>6}"
562
+ f" | commit {commit}{retry}"
563
+ + (" rc≠0" if row["rc"] else ""))
564
+ total = sum(x["ms"] for x in rows) / 1000
565
+ out.append(f" {agent} 合计:{len(rows)} 唤 | 跨度 {_dur(total)} = "
566
+ f"启动前 {_dur(start)} + 事件内 {_dur(inner)} + 收尾 {_dur(tail)}"
567
+ f" | 平均 {total / len(rows):.1f}s"
568
+ f" | 往返 {sum(x['rounds'] for x in rows)}"
569
+ f" | retry {sum(x['retries'] for x in rows)}"
570
+ + (f" | 未分解 {no_ev} 次(无 session 事件)" if no_ev else ""))
571
+ # 对账恒等式(e2e23 分析产出):**仅有事件的那部分**应满足
572
+ # Σ(启动+事件内+收尾) = Σ进程跨度;差>2s 说明时间源没对齐
573
+ # (loop 日志=本地无时区 / session=UTC)。只报告事实,不进任何代码
574
+ # 分支(跨度阈值不得参与判定,观测面契约)。
575
+ diff = abs((start + inner + tail) - decomposed)
576
+ if diff > 2:
577
+ out.append(f" 恒等校验:Σ(启动+事件内+收尾) 与可分解跨度 差 "
578
+ f"{diff:.1f}s——时间源未对齐(日志=本地时区,"
579
+ f"session=UTC)")
580
+ if not any_row:
581
+ out.append("唤醒构成:n/a(无 loop log 或无登记行)")
582
+
583
+
584
+ def _report_termination(base, agents, out):
585
+ """终止原因——**只报事实与计数**(不做评分/判定;观测面契约)。"""
586
+ bare = meeting_fs.bare_of_base(base)
587
+ msgs = meeting_engine.each_agent_messages(bare, agents)
588
+ types = {}
589
+ for a in agents:
590
+ for fm in msgs.get(a, []): # each_agent_messages → frontmatter dict 列表
591
+ t = (fm or {}).get("type")
592
+ types[t] = types.get(t, 0) + 1
593
+ has_result = bool(meeting_fs.run_git(bare, "show", "HEAD:result.md",
594
+ check=False).stdout.strip())
595
+ stalls = 0
596
+ for f in glob.glob(os.path.join(base, "loop-*.log")):
597
+ try:
598
+ txt = open(f, encoding="utf-8", errors="replace").read()
599
+ except OSError:
600
+ continue
601
+ stalls += txt.count("超时兜底") + txt.count("声明接管")
602
+ if not meeting_fs.read_protocol(bare).get("participants"):
603
+ out.append("终止:n/a(协议不可读)")
604
+ return
605
+ if stalls:
606
+ why = "stall 接管"
607
+ elif types.get("pass"):
608
+ why = "共识(RR 全体 pass)"
609
+ elif types.get("freezing") or types.get("all-freezing"):
610
+ why = "配额耗尽(冻结,无 pass)"
611
+ elif has_result:
612
+ why = "已收尾(无 pass/冻结记录)"
613
+ else:
614
+ why = "未完成"
615
+ out.append(f"终止:{why} | freezing {types.get('freezing', 0)}"
616
+ f" / all-freezing {types.get('all-freezing', 0)}"
617
+ f" / pass {types.get('pass', 0)} / stall 接管行 {stalls}"
618
+ + (" | result.md 已提交" if has_result else ""))
619
+
620
+
364
621
  def _report_wake_fields(base):
365
622
  """解析 loop-*.log 的登记字段(`elapsed_ms` / `rc`)。
366
623
 
@@ -390,48 +647,113 @@ def _report_wake_fields(base):
390
647
  return out
391
648
 
392
649
 
393
- def _report_session_usage(base, agent):
394
- """从该 agent session 文件取 usage(**单一适配器** + 流式预过滤)。
650
+ def _report_session_metrics(base, agent):
651
+ """ agent **本轮**(边界之后)的 session 运行事实——**单一适配器**。
395
652
 
396
653
  字段来源 = pi 的**文档化** session schema(`docs/session-format.md`:
397
- `usage` / `stopReason`)。行级预过滤(`"usage" in line` 才 json.loads)
398
- ——避免对 MB 级文件整解析(实测 json.loads 3MB ≈27ms,预过滤可省大部分)。
654
+ `usage` / `stopReason` / `timestamp`)——**只读已有家,不新增记录**
655
+ (同一事实两处 = 双写;loop log 的不变量是零判定输入)。
656
+
657
+ 返回:{responses, usage: {input, cacheRead, output, reasoning},
658
+ stop: {stopReason 原值: {"n": 次数, "sec": 响应跨度合计}}}
659
+ `usage["reasoning"]` = None 表示**从未出现**(缺席 ≠ 0)。
660
+
661
+ 响应跨度口径:`Δt = ts(本条) − ts(紧邻前一条事件)`——单次遍历顺序读取,
662
+ 不需要随机访问。**每次唤醒的首条响应也计入**:每次唤醒都会在 session 里
663
+ 写入 user 条目(唤醒 prompt),跨唤醒空闲落在"上一唤醒末条 → 本次 user
664
+ 条目"之间,**从不进入任何 Δ**(e2e20 评审批实测:本场 7 个唤醒 user 条目
665
+ 每个后面直接跟 assistant;首响 Δ 与非首响同量级,无分钟级样本)。
666
+ **不做"首响不计时"**:那是基于错误归因的排除——当时以为 e2e19 的
667
+ "Δ 合计 33m09s > 进程跨度 13m09s"来自跨唤醒空闲,实际根因是边界子串
668
+ 误判(`iter_after_boundary` 把历史条目算进本轮;修好后 ΣΔ ≤ 墙钟成立),
669
+ 排除掉的是真数据里常最贵的那条。
670
+ error 类单列(usage 全零)——不并入也不丢弃:否则 provider 抖动会被算成
671
+ "生成变慢"(e2e14 评审)。
399
672
  fail-open:文件缺失/字段变 → 返回 {}。
400
673
  """
401
- try:
402
- with open(os.path.join(base, f"status-{agent}.json")) as f:
403
- sid = json.load(f).get("sessionID") or ""
404
- except (OSError, ValueError):
405
- sid = ""
406
- if not sid:
407
- return {}
408
- fp = os.path.join(base, "pi-sessions", f"fork-src-{sid}.jsonl")
409
- if not os.path.isfile(fp):
674
+ fp = _agent_session_file(base, agent)
675
+ if not fp:
410
676
  return {}
411
677
  # **只统计边界之后的条目**(本轮运行事实)——fork 携带的历史条目里也
412
678
  # 有大量 assistant+usage,全文件统计会把主 pi 的历史算成本次分析的
413
679
  # 消耗(2026-09-11 实测:717 条 fork 历史被算成"本轮 367 次响应 /
414
680
  # input 1.2M")。边界由 append_handoff_turns 写入(显式登记,非推断)。
415
- u = {"input": 0, "cache_read": 0, "output": 0, "responses": 0, "errors": 0}
681
+ r = {"responses": 0,
682
+ "usage": {"input": 0, "cacheRead": 0, "output": 0,
683
+ "reasoning": None},
684
+ "stop": {}}
685
+ prev_ts = None
416
686
  for ev in meeting_fs.iter_after_boundary(fp):
417
687
  m = ev.get("message") or {}
418
688
  if m.get("role") != "assistant":
689
+ prev_ts = ev.get("timestamp") or prev_ts
419
690
  continue
420
- u["responses"] += 1
421
- if m.get("stopReason") == "error":
422
- u["errors"] += 1
691
+ r["responses"] += 1
692
+ reason = m.get("stopReason")
693
+ dur = _delta_seconds(prev_ts, ev.get("timestamp"))
694
+ d = r["stop"].setdefault(reason, {"n": 0, "sec": 0})
695
+ d["n"] += 1
696
+ if dur is not None:
697
+ d["sec"] += dur
423
698
  usage = m.get("usage") or {}
424
- for k, key in (("input", "input"), ("cacheRead", "cache_read"),
699
+ for k, key in (("input", "input"), ("cacheRead", "cacheRead"),
425
700
  ("output", "output")):
426
701
  v = usage.get(k)
427
702
  if isinstance(v, int):
428
- u[key] += v
429
- if not u["responses"]:
703
+ r["usage"][key] += v
704
+ rv = usage.get("reasoning")
705
+ if isinstance(rv, int):
706
+ r["usage"]["reasoning"] = (r["usage"]["reasoning"] or 0) + rv
707
+ prev_ts = ev.get("timestamp") or prev_ts
708
+ if not r["responses"]:
430
709
  return {}
431
- # 数字格式化(人读):千分位缩写
432
- for k in ("input", "cache_read", "output"):
433
- u[k] = _num(u[k])
434
- return u
710
+ return r
711
+
712
+
713
+ def _delta_seconds(prev_iso, cur_iso):
714
+ """两个 ISO 时间戳的秒差;任一缺失/不可解析 → None(缺席 ≠ 0)。"""
715
+ if not prev_iso or not cur_iso:
716
+ return None
717
+ try:
718
+ a = datetime.datetime.fromisoformat(prev_iso.replace("Z", "+00:00"))
719
+ b = datetime.datetime.fromisoformat(cur_iso.replace("Z", "+00:00"))
720
+ except ValueError:
721
+ return None
722
+ d = (b - a).total_seconds()
723
+ return d if d >= 0 else None
724
+
725
+
726
+ def _agent_session_file(base, agent):
727
+ """该 agent 的 fork 源 session 文件路径(找不到 → "")。"""
728
+ try:
729
+ with open(os.path.join(base, f"status-{agent}.json")) as f:
730
+ sid = json.load(f).get("sessionID") or ""
731
+ except (OSError, ValueError):
732
+ sid = ""
733
+ if not sid:
734
+ return ""
735
+ fp = os.path.join(base, "pi-sessions", f"fork-src-{sid}.jsonl")
736
+ return fp if os.path.isfile(fp) else ""
737
+
738
+
739
+ def _report_session_levels(base, agent):
740
+ """该 agent 本轮**生效的 thinking 档位**(去重、保序)——session 侧的家。
741
+
742
+ pi 在会话缺 `thinking_level_change` 条目时写入它(每次打开会话至多一次),
743
+ 且位于我们的边界之后——所以边界过滤后读到的就是"本场生效值"。
744
+ **本仓只读,不复刻 pi 的解析链**(`modelThinkingLevels` →
745
+ `defaultThinkingLevel` 是 pi 的配置,复刻 = 两处实现/必漂移)。
746
+ """
747
+ fp = _agent_session_file(base, agent)
748
+ if not fp:
749
+ return []
750
+ out = []
751
+ for ev in meeting_fs.iter_after_boundary(fp):
752
+ if ev.get("type") == "thinking_level_change":
753
+ lv = ev.get("thinkingLevel")
754
+ if lv and lv not in out:
755
+ out.append(lv)
756
+ return out
435
757
 
436
758
 
437
759
  def _num(n):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multi-viewers",
3
- "version": "0.2.2",
3
+ "version": "0.4.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,
@@ -53,7 +53,7 @@ argument-hint: '"<主题>"'
53
53
  视角之间**互补或对立都可以**——对立产生的分歧正是多视角分析的价值。
54
54
  每个视角任务书必须包含:
55
55
 
56
- 1. **单一 lenses**:明确该 agent 用什么角度看(性能/简单化/安全/……),
56
+ 1. **单一 lenses**:明确该 agent 用什么角度看(效率/简单化/安全/……),
57
57
  要求"所有观点必须从该视角出发"
58
58
  2. **不越界**:"其它视角由别的参与者负责,你不要越界展开"(**不要**列举
59
59
  具体是哪几个视角——参与者会变,列举会过期)
@@ -4,6 +4,10 @@
4
4
  # 用法: ./scripts/check-residue.sh [--verbose]
5
5
  # 退出码: 0 = 干净;1 = 有残留(列出清单)
6
6
  #
7
+ # **调用纪律**(2026-09-12):看退出码 + 末行 `[residue] ...` 汇总;
8
+ # 不要只 grep 某一类残留——那会把其它类整块滤掉,把"有残留"跑成"看起来
9
+ # 干净"(实测:grep 残留-2|残留-3 滤掉了 4 个 session 残留)。
10
+ #
7
11
  # 检查三类测试残留形态(2026-09-09 建立——此前靠人工记忆逐项检查,
8
12
  # 反复漏检:afk-e2e 引导 session 漏删即反例):
9
13
  # 1. 主 pi 侧 session:~/.pi/agent/sessions/<编码目录>/*.jsonl 中
@@ -40,6 +44,9 @@ is_registered() {
40
44
  }
41
45
 
42
46
  RESIDUE=0
47
+ N_SESSION=0
48
+ N_PROC=0
49
+ N_DIR=0
43
50
 
44
51
  say() { [ "$1" = "--verbose" ] || [ -n "$VERBOSE" ] && echo "$2"; }
45
52
 
@@ -64,16 +71,25 @@ for d in "$SESS_DIR"/*/; do
64
71
  "$w"*|"$w") whitelisted=1 ;;
65
72
  esac
66
73
  done
67
- if [ "$whitelisted" = "0" ]; then
68
- # 归属判定(2026-09-09):登记日志回查——已登记=测试产物
69
- # (可删),未登记=非本流程创建(勿删,提示加白名单)。
70
- # 首条消息摘要仅作补充信息(不再作为删除判断依据)。
71
- if is_registered "$f"; then
72
- owner="[已登记-测试产物,可删]"
73
- else
74
- owner="[未登记-非本流程创建,勿删]"
75
- RESIDUE=1
76
- fi
74
+ # 归属判定(2026-09-09 起):登记日志回查——已登记=测试产物(可删),
75
+ # 未登记=非本流程创建(勿删,提示加白名单)。首条消息摘要仅作补充
76
+ # 信息(不作删除依据)。
77
+ # **登记优先于白名单**(2026-09-12 修):登记是**显式事实**(创建即
78
+ # 登记),白名单是**按 cwd 的猜测**。此前白名单在前 → 探针跑在白名单
79
+ # 项目目录(如 --print 冒烟在 /root/pi-agents-helper)时整个文件被跳过、
80
+ # 连"已登记"都不报——实测漏检(那次 4 个漏检 session 里有 1 个正是此形)。
81
+ if is_registered "$f"; then
82
+ # 已登记 = **本流程测试产物**(可删)——但"还存在"本身就是残留:
83
+ # 验收语义是"清理完毕"→ 计入 RESIDUE(归属标记只决定谁来删)
84
+ owner="[已登记-测试产物,可删]"
85
+ RESIDUE=1
86
+ elif [ "$whitelisted" = "0" ]; then
87
+ owner="[未登记-非本流程创建,勿删]"
88
+ RESIDUE=1
89
+ else
90
+ continue # 白名单 cwd 且未登记 = 用户自己的长期会话,不是残留
91
+ fi
92
+ N_SESSION=$((N_SESSION + 1))
77
93
  first_user=$(python3 -c "
78
94
  import json, sys
79
95
  try:
@@ -87,10 +103,9 @@ try:
87
103
  except Exception:
88
104
  print('')
89
105
  " 2>/dev/null)
90
- echo "[残留-1] session(24h 内 cwd=$cwd,创建 $(stat -c %y "$f" | cut -d. -f1))$owner"
91
- echo " 首条消息: ${first_user:-(无 user 消息)}"
92
- echo " 路径: $f"
93
- fi
106
+ echo "[残留-1] session(24h 内 cwd=$cwd,创建 $(stat -c %y "$f" | cut -d. -f1))$owner"
107
+ echo " 首条消息: ${first_user:-(无 user 消息)}"
108
+ echo " 路径: $f"
94
109
  done
95
110
  done
96
111
 
@@ -128,6 +143,7 @@ for d in /proc/[0-9]*; do
128
143
  */meeting_loop.py)
129
144
  echo "[残留-2] meeting_loop 进程 PID=$pid: $(ps -p "$pid" -o cmd= 2>/dev/null)"
130
145
  RESIDUE=1
146
+ N_PROC=$((N_PROC + 1))
131
147
  LOOP_PIDS="$LOOP_PIDS $pid"
132
148
  break
133
149
  ;;
@@ -143,12 +159,14 @@ for d in /proc/[0-9]*; do
143
159
  # loop 已退出、pi 被 init 收养(PPID=1)——孤儿残留
144
160
  echo "[残留-2] pi 进程 PID=$pid(孤儿:PPID=1,其 loop 已退出)"
145
161
  RESIDUE=1
162
+ N_PROC=$((N_PROC + 1))
146
163
  continue
147
164
  fi
148
165
  case " $LOOP_PIDS " in
149
166
  *" $ppid "*)
150
167
  echo "[残留-2] pi 进程 PID=$pid(父 meeting_loop PID=$ppid)"
151
168
  RESIDUE=1
169
+ N_PROC=$((N_PROC + 1))
152
170
  ;;
153
171
  # 其余 comm=pi 的进程不报:用户自己正在用的 pi 会话(PPID 是
154
172
  # pi-web/shell 等)不是残留——"名称 + PPID"两个条件同时成立才判定
@@ -165,6 +183,7 @@ for d in mv-*/; do
165
183
  esac
166
184
  echo "[残留-3] 分析目录: $PWD/$d"
167
185
  RESIDUE=1
186
+ N_DIR=$((N_DIR + 1))
168
187
  done
169
188
 
170
189
  # 3b. 结构形态:任何含 repo.git/ + pi-sessions/ 的目录 = 讨论环境
@@ -181,10 +200,13 @@ for base in "$PWD" "${TMPDIR:-/tmp}"; do
181
200
  SEEN_ENVS="$SEEN_ENVS $d"
182
201
  echo "[残留-3] 讨论环境: $d($(du -sh "$d" 2>/dev/null | cut -f1))"
183
202
  RESIDUE=1
203
+ N_DIR=$((N_DIR + 1))
184
204
  done < <(find "$base" -maxdepth 3 -type d -name repo.git 2>/dev/null)
185
205
  done
186
206
 
187
- if [ "$RESIDUE" = "0" ]; then
188
- echo "干净:无测试残留"
189
- fi
207
+ # 汇总行:**结论只在这一行**——调用方看这一行 + 退出码即可,
208
+ # 不必(也不得)只 grep 某一类(2026-09-12:调用方 grep "残留-2|残留-3"
209
+ # 把 [残留-1] 整类滤掉,把有残留跑成"看起来干净")。
210
+ echo "[residue] session=$N_SESSION 进程=$N_PROC 目录=$N_DIR →" \
211
+ "$([ "$RESIDUE" = "0" ] && echo 干净 || echo 有残留)"
190
212
  exit "$RESIDUE"