--- name: workbuddy-session-forensics description: 在 WorkBuddy 桌面版上定位「某个历史会话」并复盘它 —— 从会话标题反查会话 id、创建/改名/最后活动时间、工作目录;**抽取该会话的完整对话原文(用户原话 / AI 正文 / reasoning 思考 / 全部工具调用)**;并据此分析「AI 当时为什么这么做 / 为什么停下来问用户 / 为什么没按用户点名的方法走」。当用户说「继续 XX 会话的任务」「接着上次那个会话做」「改了工作区路径继续之前的会话」,或说「看下 XX 会话为什么…」「复盘某个会话 AI 为什么…」时使用。**也用于「会话卡住了 / 卡死 / 锁死了 / 没反应 / 一直转圈 / 发消息都不恢复」的排查**(★ **四型分开办**,判据见 §2f–§2j:②f「那一轮中途断了」· ②g「agent 在跑但工具循环不收敛、不给回话」· ②h「活干完了但结果推不出去」· **②i「投递悬挂:消息进了队列、没人取出来执行」**;**也用于问「某个进程是不是某个会话的后台任务 / 能不能一直开着」**(§2j:端口 → pid → 父链,看有没有 `sandbox-cli.exe ← WorkBuddy.exe`)。 agent_created: true --- # WorkBuddy 历史会话定位(桌面版) ## 0. 为什么需要这套流程 用户常以**会话标题**引用一段工作(「继续『参考决策方法逐条判断处理』会话的任务」)。 桌面版**没有**给出「按标题查会话」的接口,`conversation_search` 对**本机新建的近期会话可能返回 0 命中**。 于是必须自己从本地落盘数据里反查。 **✅ 结论(2026-09-16 复测):近期会话的原文就在本机、完全可读** —— 路径见 §1。 ⚠️ 旧版(2026-09-13)曾断言"近期会话 jsonl 不存在、只能靠间接源复原",**该结论已被推翻**(当时多半是踩了 §1 的「目录名的坑」:搬迁后新会话进了新目录名,只找旧目录自然找不到)。**先按 §1 定位,别急着走 §3 的降级路径。** ⛔ **但转录有「保留窗口」—— 2026-09-28 实测:本机只留近期会话**(`projects/*/*.jsonl` 共 14 个、时间跨度仅 09-26 ~ 09-28;`sessions` 表 15 行、最早 `created_at ≈ 09-26`)。⇒ **用户问「上个月/更早的某个会话」时,转录与元数据大概率都已经不在了**,此时**别硬找 sid**,直接走 §3 降级路径 + §2e 反查法,并如实告知「对话原文已不在本机,但工作区日志完整」。判断"是不是落在窗口内":先看 `find -name "*.jsonl"` 的最早 mtime,早于它的会话一律按不可读处理。 ## 1. 三个数据源(各管一段,缺一不可) | 数据源 | 路径 | 有什么 | 坑 | |---|---|---|---| | **① 会话元数据库(★首选)** | `~/.workbuddy/workbuddy.db` → `sessions` 表 | `id / cwd / title / custom_title / status / created_at / updated_at / last_activity_at / model / permission_mode` | ✅ **2026-09-16 实测已可用且最新**(67 行,含当天建的会话)—— 旧版"可能已静态化、最新行停在 09-11"的结论**已作废**。用 python `sqlite3` 以 `file:...?mode=ro` 打开(本机无 sqlite3 CLI) | | **② 转录本体(★原文在这里)** | `~/.workbuddy/projects/<工作区目录名>/.jsonl` | **完整对话**:用户原话 + assistant 正文 + **reasoning 思考** + 全部 `function_call` / 结果 | ⛔ 旧版断言"近期会话根本没有 jsonl / 只能靠间接源复原" —— **2026-09-16 实测已作废:近期会话 jsonl 就在本机且是最新的**(含当天 06:5x 的写入)。见下方「目录名的坑」 | | **③ 会话日志(辅助)** | `~/.workbuddy/logs//sdk/conversations/.log`(+ `.log.1` 轮转) | 每次 LLM 往返的原始记录(单文件可达 5–10 MB) | ⚠️ 旧版指路的 `logs/<日期>/edge-sync.log` **在本机已不存在** —— `logs/` 现在是 `main.log` / `renderer.log` / `sdk/` … 的结构,别再按老路径找 | ## 1a. 「新会话 / 空工作区,用户只说『继续』」怎么反查(2026-09-21 实测定型) 场景:会话是新开的(工作区里除了 `.workbuddy/*.log` 什么都没有),用户开口就是「继续」。此时没有上下文可继承,**必须自己找出「继续的是哪条线」**。按序三步: 1. **先借 bash-guard / stop-dialog-guard 日志定位 transcript 路径**:每个工作区的 `<工作区>/.workbuddy/stop-dialog-guard.log` 每行都带 `cwd=…|tp=<转录路径>|…session_id…` ⇒ **即使 `workbuddy.db` 里查不到今晚的会话**(实测本机 `sessions` 表最后一行停在 09-12,当天会话一行没有),也能直接拿到 transcript 的真实落盘路径。 2. **`tp=` 里的路径是相对/截断了前缀的**(实测写成 `ramData-WorkBuddy-2026-09-21-22-11-36\16df8c5f-….jsonl`,像是被切掉了盘符段)⇒ 别照抄,**用 python 按 basename 反查**: 遍历真正的 projects 根去找同名 `.jsonl`。 3. **projects 根跟着活动配置目录走**:本机 `CODEBUDDY_CONFIG_DIR=E:\ProgramData\.workbuddy` ⇒ 转录在 `E:\ProgramData\.workbuddy\projects\<工作区目录名>\.jsonl`(**不是** `~/.workbuddy/projects`,按 `~` 找会 0 命中)。 拿到自己的 transcript 后,抽 **`type=message & role=user` 的第一条**(用 §2b 的 `get_text`,注意元素是 `input_text` 而非 `text`)—— 那就是本会话真正的起点,也就是用户说「继续」时指的线。 ⚠️ **同一时刻用户可能对多个会话都发了「继续」**(实测 22:12 前后三个会话的 guard log 都出现同样的 `stdin_len=408`)⇒ 别挑"最后活动时间最近的那个会话",**只认本会话 transcript 的第一条用户消息**。 ### 环境坑(同一晚实测) - bash 的 coreutils 会被宿主 shim 打断(`ls`/`find`/`tail` 直接 not found)⇒ 每条命令先 `export PATH="/usr/bin:/bin:$PATH"`。 - **PowerShell 工具 stdout 全空**(连 `Write-Output` 都拿不到回显)⇒ 列目录、查文件一律用 Bash + Python(`os.listdir` / `os.stat`),别在 PowerShell 上耗轮次。 --- > ⚠️ **目录名的坑(2026-09-16 实测,本次"以为原文不可读"的真正原因)**: > 工作区目录名 = 工作区**当前**绝对路径的「去盘符、盘符后加 `-`、分隔符换 `-`」形式 —— > - `E:\ProgramData\AI技能\aliyun-dsh-server` → `e-ProgramData-AI技能-aliyun-dsh-server` > - `D:\AI技能\aliyun-dsh-server`(搬迁前)→ `d-AI技能-aliyun-dsh-server` > > **搬迁工作区会换目录名 ⇒ 老会话留在旧目录、新会话进新目录,两个目录会同时存在。** > ⇒ 找某个 sid 时**两个目录都要 `find`**(`find ~/.workbuddy/projects -name "*"`),只看一个会误判"原文不存在"。 > > ⛔ **`~/.workbuddy/sessions/*.json` 不是会话** —— 它们按 **host 进程 pid** 命名(如 `35764.json`),内容是 `{pid, sessionId:"interactive-35764", cwd, url…}` 的**进程心跳**,没有任何对话。别被目录名骗了。 ### 1b. 辅助索引(**补充取证:这个会话动过哪些文件**) 五个目录都**按会话 id 命名**,与转录同一套 sid: | 目录 | 文件 | 内容 | 价值 | |---|---|---|---| | `~/.workbuddy/changes-index/` | `.json` | 该会话每次文件写入的 `summary`(如「7 个文件变更,+340 −0」)+ 逐文件 `filePath / action / additions / deletions` | ★★ **能精确复原「这个会话动过哪些文件」** | | `~/.workbuddy/changes-detail/` | `/` | 每次改动的明细 | ★★ | | `~/.workbuddy/artifact-index/` | `.json` | 产出物索引 | ★ | | `~/.workbuddy/file-history/` | `/` | 文件历史快照 | ★ | | `~/.workbuddy/file-tree-manifests/` | `.json` | 工作区文件树快照(可达数 MB) | ★ | ### 1c. 成本 / 积分取数(**2026-09-16 加,实测;只回答"这个会话花了多少"**) ✅ **纠正(2026-09-16 晚二次实证,两处都要改口径)**:`rawUsage` **有值**,且带**逐次 `credit`** —— 把每个 JSON 行**递归**收集所有 `rawUsage` 节点再求和,得到的**会话级积分与 `session_usage.credit_json` 逐位吻合**(本次 5 个会话全部对上:9.37 / 8.93 / 33.71 …)。 > ⛔ 之前两次记成"恒为空"的原因:**`d.get("rawUsage")` 抓不到**(它不在顶层,藏在嵌套结构里)。⇒ **必须递归遍历**,判据是"和 `session_usage` 对上"。 ```python def walk(o, hits): # 递归收集 rawUsage 节点 if isinstance(o, dict): for k, v in o.items(): (hits.append(v) if k == "rawUsage" and isinstance(v, dict) else walk(v, hits)) elif isinstance(o, list): for x in o: walk(x, hits) # 每行 json.loads(line) 后 walk(d, hits);积分 = sum(r.get("credit") for r in hits) ``` 现成脚本:`<工作区>/.workbuddy/tools/credit_by_sess.py`。 | 想查 | 表 / 字段 | 说明 | |---|---|---| | **逐轮积分** | `session_usage` → `credit_json` | JSON `{<轮标识>: <积分>}`,各值即**该轮消耗**;`used`/`size` = 当前水位 / 上下文窗口。⚠️ 它**只覆盖部分会话**(本机实测 11 行 / 会话数更多)⇒ 全覆盖口径用上面的转录 `rawUsage` | | **逐请求用量** | `automation_runs` → `runs_json` | ⚠️ **2026-09-16 晚实测:本库的 `runs_json` 里只有** `cwd / success / startedAt / finishedAt / conversationId / output`,**没有 usage**(旧笔记说"内有完整 usage"与本库不符,别再照抄)⇒ 要逐请求明细请用转录 `rawUsage` | | **该运行开在哪个会话(★ 找"自动任务新建的会话"就靠它)** | `automation_runs` → `metadata_json` → `sessionId` | 证明「每次自动化运行 = 新会话」;配 `automations` 表拿 name/prompt | | **自动化周期 / 状态** | `automations` → `rrule` / `status` / `next_run_at` / **`deleted_at`** | `FREQ=HOURLY;INTERVAL=n` ⇒ 每天 `24/n` 次;⚠️ **`status` 仍是 ACTIVE 也可能是软删除**(看 `deleted_at`),且 `next_run_at` 会停在停摆那天 | **实测成本模型(可直接引用)** - **积分 ≈ 单价 × 一轮内的工具调用次数**(线性,样本 8 次运行)。 - 单价随水位上升:水位 <10 万 ≈ **0.10** 积分/次工具调用;15 万 ≈ **0.41** ⇒ **同会话内约 4 倍**。 - **固定注入 = 35,192 token/请求**(tools 20,734 + systemPrompt 10,395 + skills 3,919 + mcp 144);**但缓存命中 99.5%** ⇒ 很轻,**不是主因**。 - ⛔ **「开新会话省积分」只对单价有效**:三个自动化**全新会话的首轮**分别烧 8.93 / 10.05 / 13.82 积分(首轮 31–54 次工具调用)⇒ **压不掉"次数"的钱**。 - **真杠杆排序**:① 一轮内工具调用次数(线性、主导)→ ② 水位 → ③ 固定注入(很轻)。 **现成脚本**:`<工作区>/.workbuddy/tools/cost_model.py`(聚合全部自动化运行的 usage / 缓存命中 / 分类占比 / 周期清单)。 ### 1c-1. 逐次 `credit` 的正确读法 +「钱花在**新增**上」(2026-09-24 实测 · 跨工作区复用) ⛔ **`rawUsage` 节点的字段名与 `message.usage` 不同**:有 `prompt_tokens` / `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` / `completion_tokens` / **`credit`**;**没有 `input_tokens`** ⇒ 取那个会**全得到 0**(本次踩过)。 一次请求 = 一个 `rawUsage` 节点,**每个节点自带自己的 credit** ⇒ 会话总积分 = `Σ credit`。 **实测结论(推翻"重发历史很贵"的直觉)**: - 缓存命中率 **98.4% ~ 99.3%** ⇒ **缓存部分近乎不计费**。 - ⇒ **积分 ≈ Σ(本次新增内容 × 全价)**;"新增" = 本次工具**入参** + 本次工具**结果** + reasoning + 回复。 - 固定注入(systemPrompt / tools / skills / 工作区规则)**第 2 次起全部命中缓存** ⇒ 确实"很轻"(与本文件第 96 行互证)。 - 但**单价仍随水位抬升**:实测 30 万+ 水位段稳定 **0.50 积分/次**,低水位 0.25~0.36 ⇒ **约 1.5~2 倍**。 **实物量级(2026-09-24 · dsh-decision-laya 两会话)**:105 次请求 = **27.2 积分**;380 次请求 = **159.1 积分**。 ⇒ 用户口中的「**一轮会话 20–30 积分**」= **一个会话跑了约 105 次请求**,不是被某份大文件注入吃掉的。 ### 1c-2. 「这个工作区到底受不受省积分钩子保护」判据(2026-09-24 定型) 省积分钩子(限流提示 / 拦大输出 / 技能守卫)的**作用域 = 硬编码的目录名白名单**: `stop-dialog-guard.py` 与 `skill-load-guard.py` → `_SCOPES_DEFAULT = ('aliyun-dsh-server','dsh-ai1net-desktop')`; `bash-output-guard.py` → `SCOPE = 'aliyun-dsh-server'`(**单值**)。 ⛔ **新建工作区不在白名单 ⇒ 三条保护全部静默失效**(脚本照跑、日志照写、但一步都不做)。 **一步判据**(只读):`grep -c 'in_scope=True' <工作区>/.workbuddy/stop-dialog-guard.log` —— 得 `0` ⇒ **该工作区从未被保护过** (⚠️ `in_scope=False` 也会写日志,**别被"有日志"骗了**)。对照:aliyun 同时刻 `in_scope=True` 且 `预算告警=True`。 **脚本**:`/.workbuddy/tools/credit_curve.py <工作区> [sid前8位]`(单价/水位分桶 + 缓存命中率 + 分段积分占比)。 - ⛔ **旧版判据已作废**:曾写「这五类目录与 `.jsonl` 同一时刻集体停更 ⇒ 有文件=转录在本机、无=已纯云端」—— **2026-09-16 实测:它们全都在正常更新**(`changes-index/` 有当天 07:00 的文件),**"有没有这批文件"不再能推断转录可读性**。判断转录是否可读,直接 `find ~/.workbuddy/projects -name "*"`。 - ⛔ **不要翻 `~/.workbuddy/cache/conversation-product-spill/`** —— 名字像「会话产物」,实为 **UI 配置产物(`acc-product-config-*.json`)**,不含任何对话内容。 - 次要源:`~/.workbuddy/workspace/sessions/`(工作区侧会话态)、`~/.workbuddy/logs/<日期>/sdk/conversations/.log`。 ### ★ 标题的两个坑(2026-09-13 实测补充) 1. **自动改名会把标题截断到约 20 个字符**(`RENAME … isUserDefined=false`): 例:用户看到的「检测用户回到dsh页面并恢复进」实为「检测用户回到dsh页面并恢复**进程状态**」被切掉。 ⇒ **用户口中的"会话标题"常常只是前缀**,反查时务必用**中段关键词**(如「回到dsh」)模糊匹配,别拿整句去 grep。 2. **同一会话会连续被改名多次**(创建后几秒内就可能改两次):`EB_SYNC_ADDED` 里的 `title=` 是**首条消息原文**,随后 `§3.4 RENAME` 才是 UI 上显示的名字 —— **判断"哪个 sid 是它"要看 RENAME 的末次值,不要只看 ADDED**。 3. `isUserDefined=true` = 用户手动命名(不会截断);`false` = 系统自动生成。 ## 2. 标准动作(按序,全是只读) ```bash # ① 按标题反查会话 id —— 直接查元数据库(最快,2026-09-16 实测可用) # python -c "..." 里:sqlite3.connect('file:/workbuddy.db?mode=ro', uri=True) # SELECT id,title,custom_title,created_at,updated_at,last_activity_at # FROM sessions WHERE title LIKE '%关键词%' ORDER BY updated_at DESC; # (本机没有 sqlite3 CLI;中文标题记得 ensure_ascii=False 输出、或写成 JSON 再 Read) # ② 判断原文能不能读 —— 注意两个工作区目录名都要找(搬迁会换目录名) find ~/.workbuddy/projects -name "*" # ③ 会话日志(辅助,含每次 LLM 往返) ls -l ~/.workbuddy/logs/*/sdk/conversations/.log* ``` - 时间戳是**毫秒 epoch**(`1789484860643`)。⛔ **别用 awk 做 `ms/1000`(会打印成 `1.78917e+09` 丢精度)**,**日期换算交给 python**。 - 本机 `bash` 的 PATH 常被 shim 重置(`find`/`grep`/`head`/`tail` 全 not found)⇒ 每条命令先 `export PATH="/d/Program Files/Git/usr/bin:/d/Program Files/Git/bin:/c/Windows/System32:/c/Windows:$PATH"`。 - ⚠️ 复杂的中文正则**优先用内建 Grep 工具**,别在 bash 里拼(编码 + 转义双重坑)。 ### 2b. 从 jsonl 抽取对话(★字段名是坑,2026-09-16 实测) **每行一个 JSON 对象**,用 `type` 区分六种记录: | `type` | 关键字段 | 装什么 | |---|---|---| | `message` | `role` = `user` / `assistant`;`content[]` | 用户输入与 AI 正文 | | ⚠️ **用户输入的 `content[].type` 是 `input_text`(⛔ 不是 `text`)** | `message.role == "user"` + `content[]` 里 **`type:"input_text"`** | 🔴 **2026-09-30 实测踩到**:按 `type=="text"` 抽 user 消息 ⇒ **抽出 0 条**,于是误以为"这条会话没有用户消息/证据是假的"。
正确姿势:**`{"role":"user","content":[{"type":"input_text","text":"…"}]}`** ⇒ 抽 `(b.get("type") or "") in ("text","input_text")`,或直接对文件 **`grep` 唯一串**(见下)。 | | `reasoning` | `rawContent[].text`(该元素 `type == "reasoning_text"`) | **AI 的思考过程** | | `function_call` | `name` / `arguments` / `callId` | 工具调用 | | `function_call_result` | `callId` / `output` | 工具结果 | | `file-history-snapshot` / `ai-title` | — | 快照 / 标题生成,分析时忽略 | ⛔⛔ **最容易踩的坑:文本元素类型是 `input_text` / `output_text`,不是 `text`。** 按 `x.get("type") == "text"` 抽取 ⇒ **每一段都抽成空字符串,且不报错**(本次实测:14 条 user + 160 条 assistant 全抽空,白跑一轮)。 ```python def get_text(d): c = d.get("content") if isinstance(c, str): return c out = [] if isinstance(c, list): for x in c: if isinstance(x, dict) and x.get("type") in ("input_text", "output_text", "text"): out.append(x.get("text", "")) return "\n".join(out) ``` - **过滤"真实用户输入"**:`…` 包着(新版本会把 `user_query` 放在 system-reminder 尾部)⇒ 用正则 **先抠 ``,抠不到再看原文**。 - **assistant 正文可能极长**(3.5 MB jsonl ⇒ 60 万字符)⇒ **先出"前 400 + 尾 1200 字符"的摘要版扫脉络**,再对命中项拉全文,别一上来全文 dump。 - 本机 python 读写**必须显式 UTF-8**(`io.open(..., encoding="utf-8", newline="")`);否则 CP936 静默乱码。 #### 2b-bis 用户说「看最后 N 轮对话」时的两段式(★ 2026-09-18 实测定型,4 次调用出全文) ⛔ **别直接全文 dump**(一个 3.5 MB 的 jsonl 会撑爆上下文)。两段式: 1. **出索引**:按 §2b 抽全部记录时**给每条打上"记录序号 `i`"**,先只打印 `U / UQ` 的 `i | 行号 | 前 90 字符` ⇒ 一眼看出"最后 N 轮"落在哪几个 `i`。 2. **取区间**:对 `i ≥ <倒数第 N 轮的 i>` 再跑一次,**整段落盘到 `tmp/<任务名>-<日期>/detail.txt`**(含 `REASONING` / `CALL <工具名>` / `ASSISTANT` 正文),再用 Read 分段读。 **要点**:① 记录序号 `i` 是"人可读的轮次锚点"(⛔ 别用行号 —— 一行可能含上万字符);② `detail` 模式**必须把 `reasoning` 一起打印** —— "AI 当时怎么想的"就在这里,是复盘的关键证据;③ 工具调用只打 `name`、不打完整参数(参数体积不可控,需要时再单查)。 **实测**(2026-09-18 复盘 `dd6abea4`「覆盖网络线-序27执行棒-E3候选数可查」):索引 1 次 + 落盘 1 次 + Read 2 次 = **4 次调用**拿到最后 3 轮全文(含 reasoning 与工具序列),足够定位"AI 为什么连续两次把用户口径读错"。 ### 2c. 分析「AI 为何停下来问 / 为何没按方法做」(2026-09-16 定型) 同一套数据能直接回答"AI 的决策错在哪",动作是**三段取证**: | 步 | 动作 | 能证明什么 | |---|---|---| | ① | 统计 `function_call.name` 的分布 | **`Skill` 计数 = 0 ⇒ 用户点名的方法论(如"参考决策方法")从未进入上下文**;`AskUserQuestion` 计数 = 0 ⇒ 提问全发生在**正文**里(hook 拦不到) | | ② | 抠出全部 `message/user` 正文 | 用户真实诉求与**被驳回的上抛**("按你的规划执行"、"XXX 不就行了")。⚠️ 注意用户回复里的**"还是…"**= 对 AI 上抛的二次纠正 | | ③ | 全文搜 `reasoning` 里的判断语 | **`决策方法 / 自决 / 上抛 / 门禁 / 拍板 / 边界 / R7 / R8`** —— 本次正是靠这一步证明"AI **想过**判据,但**用错了判据**(把 `systemd 单元 ⇒ 只报告不动手` 当成硬红线,于是把"该自己删的旧控制面"拿去问用户) | **产出形态**:`用户真实意图 → AI 实际动作(含 reasoning 原话)→ 判据对照 → 根因分层 → 我改了什么`。 ⚠️ 复盘结论**必须落到规则文件或技能**(否则下次照犯);本次落在 `CODEBUDDY.md §1`(规则冲突裁决顺序)+ 技能 `dsh-decision-method §4.5`。 ### 2d. 「看自动任务新建的会话」怎么一次找齐(2026-09-16 定型) 用户说「**自动任务新建的会话**」「自动化开的会话」时,**不要凭标题猜**,按链走: ```sql -- ① 元数据 → 拿到全部「自动化运行 = 新会话」 SELECT automation_id, status, metadata_json FROM automation_runs; -- metadata_json.sessionId / conversationId 就是那个新会话的 sid -- ② 会话名与时间 SELECT id,title,created_at,last_activity_at FROM sessions WHERE id IN (...); -- ③ 自动化本身(名字 / prompt 原文 / 是否软删除) SELECT id,name,status,scheduled_at,rrule,created_at,updated_at,deleted_at FROM automations ORDER BY created_at DESC; ``` ⚠️ **两个作用域陷阱**:① `automation_update list` **看不到库里的全部自动化**(实测只见少数几条),**要全量必须查库**;② 自动化运行**必带新 `sessionId`** ⇒ "开了几个新会话" = `automation_runs` 的行数。 **验收式复盘**(判"这套机制到底有没有用"):把"**该轮的预期口径**"与**实测**并排 —— 预期通常写在**上一个会话的 prompt / 立项记录**里(如"≤10 次调用 / ≈1 分"),实测从 `jsonl` 数 `function_call`、从 `rawUsage` 求和。**两列一对,结论立刻有据**。 ### 2e. 「转录已被清理,只记得做过某件事」怎么反查(★ 2026-09-28 定型) 场景:用户开口是**行为的描述**而非标题 —— 「之前有个会话抓取过线上视频、用 ffmpeg 提取视频关键帧,是哪个会话?文件在哪?」。转录窗口已过(见 §1 保留窗口),**靠 sid 反查这条路是死的**。改按「行为 → 工作区 → 日期 → 残留物」四条腿找: | 步 | 动作 | 判据 / 命令要点 | |---|---|---| | ① **全工作区 memory 日志批量 grep**(★主路径) | 用 Grep 工具搜**所有** `.workbuddy/memory/*.md`,pattern 取该行为的**独有名词**(工具名 `ffmpeg` / `yt-dlp` / `scene_0`、产物名 `frames_combined`、作品名、账号名) | 一次命中即给出「工作区 + 日期」,比逐会话读 jsonl 快一个量级。⚠️ Grep 的 `path` 要给**项目根**(如 `E:\ProgramData`),不是单个工作区 | | ② **读命中的那天日志** | 日志本身就写了工具链、命令要点、产出**绝对路径** | 这是"SOP 从哪次实操沉淀而来"的唯一直接证据 | | ③ **按独有产物名跨盘搜文件系统** | `os.walk` + 关键词(**带剪枝**:SKIP `node_modules/.git/Windows/Program Files/AppData`,`depth>=5` 停止下钻) | 验证产物**是否还在**。⚠️ 别用全盘 `glob("C:/**/...", recursive=True)`(实测直接超时被杀) | | ④ **交叉验证规范化文档** | 工作区 `references/操作规范/*.md`、`经验方法总结.md`、`SKILL.md` 的强制检查节 | 文档里常写明「**基于 <日期> 实测(<具体作品>)沉淀**」⇒ 与 ① 的日期互证,能定位到比日志更早的首次实操 | **本次实测产出(可直接作范例)**:命中 `aigc-idea-impression/.workbuddy/memory/2026-08-30.md` → 该会话 = 抖音「王墨绫子」3 视频 + B站 `BV1vcG36sEEr` 的 `yt-dlp` 下载 + `ffmpeg` scene/I 帧抽帧;SOP 里"SOP 基于 2026-08-30 实测沉淀"与日志日期互证。**残留物核验**:产出目录 `D:\MCNSkill项目\作品数据\` 与 ffmpeg 工具目录**均已不存在**,只有数据侧 JSON 迁到了 `mcn-data-skills\RedFox数据\作品数据\<日期>\`。⇒ 交付时必须**同时说明「原产出目录已失效」与「现存可核验的替代物」**,别让用户去翻一个不存在的路径。 ⚠️ `conversation_search`(云端)**也受窗口限制**:实测默认只覆盖近一周;显式传更早的 `start_date`(如 2026-08-25)直接返回 **HTTP 400**。⇒ 别把它当"历史全量检索",只当近期补充。 ### 2f. 🔴「这个会话卡住了 / 锁死了」怎么判(★ 2026-09-28 实测定型) ⚠️ **先分清两件不同的事,别一口咬定"锁"**:用户说「卡住 / 锁死」时,**多数是会话那一轮中途断了**,不是锁。按下面四步分开取证: | 步 | 查什么 | 判据 | |---|---|---| | ① **读转录尾部找中断点** | 该会话 `.jsonl` 的**最后若干条**,看有没有 `role=user` 且正文含 **`error-recovery`**(如 `## Model error retry: Tool Not Found`) | 🔴 **这条 = 中断点**:host 注入错误 → 该轮**异常中断**(既没继续也没收尾)。它**后面若长时间没有 `reasoning`/`function_call` 记录** ⇒ 就是"看着卡死"的那段时间 | | ② **看会话状态字段** | `sessions.status`:正常收尾 = `completed`;卡着 = **`working`** | `working` 只说明"有轮次未闭合",⚠️ **当前正在跑时它也是 `working`** ⇒ 必须结合 ① 的**时间间隔**判,不能只看字段 | | ③ **查锁残留(逐个点名,别只查一个)** | `.exec-lock` | `.me-lock` | `.doing-*` | `.locks/.gate` | **全都没有 = 锁没坏**。⚠️ 只查 `.exec-lock` 会漏掉 `.gate` 泄漏(会让后续抢锁卡满超时) | | ④ **复测那个"失败的工具/命令"** | 重新调用一次 | 现在成功 ⇒ **瞬时故障**,⛔ 不是工具被移除、⛔ 不是会话坏了 | **实测案例(2026-09-28)**:某会话 21:22:07 调一个当时不可用的工具 ⇒ host 注入 `Tool Not Found` ⇒ 转录 **80 分钟零记录**、`status` 停在 `working`、界面看着"锁死"。**同时**另一条线的自动化因**抢不到全局锁而未开工**(两件事互相独立,别混为一因)。⇒ 结论:**① 工具瞬时不可用 ≠ 会话来死;② 真·抢不到锁 = 那一棒「未开工」**(守规矩停手,但**必须重排**,因为一次性自动化**已消耗、不会自动重跑**)。 ⚠️ **别做**:⛔ 别因为 `status=working` 就断定卡死(当前在跑的会话也是它);⛔ 别因为"抢不到锁"就去删锁/接管(红线,只能持有者释放);⛔ 别把"工具瞬时不可用"记成"工具不存在"。 ### 2g. 🔴 第二种"卡住":**agent 在跑,但在工具循环里不收敛**(★ 2026-09-28 22:5x–23:1x 实测定型) > ⚠️ **本节初版曾判成「消息从不派发」,那是错的**(当时只看了 `sendPrompt` 与 node 进程,漏掉工作区日志里的状态机)。**订正于 2026-09-28 23:16** —— 真相是**消息派发了、agent 真的在跑**,只是**每轮只返回 `tool_calls`、永不收尾**,所以用户永远等不到回复。 **与 §2f 的区别(一眼分)**:②f 有**中断证据**(`error-recovery` 注入后长时间零记录);本型**通篇零错误**,但**工具有在被执行**(只是不给用户回话)。 🌟 **唯一权威判据:工作区日志的状态机**(`~/.workbuddy/logs/<日期>/<工作区名>__*.log`) ``` grep -E "SessionRunStateMachine" <该日志> | grep ``` 看这几项: - `busy=` / `queueBusy=` ⇒ **恒 `true`** 表示会话从未进入空闲 - `MODEL_REQUEST_STARTED → TOOL_STARTED → TOOL_ENDED → MODEL_REQUEST_STARTED …` **反复循环** - 🔴 **决定性**:模型侧 `finish_reason=` **反复是 `tool_calls`、从不出现 `stop`** ⇒ agent **永远不产出面向用户的回复** = 用户眼中的"界面一直转、发了消息没反应" **三条辅助(按证据强度排序)**: | 步 | 查什么 | 判据 | |---|---|---| | ① **后台任务** | 工作区日志搜 `executeInBackground` / `background task created` | 🔴 若该会话起了 **`--interval N --max-hours M` 型常驻任务** ⇒ 它每 N 秒产一次输出、**每次输出都当通知灌回会话** ⇒ 会话被反复唤醒,**永远回不到 idle**。这是最常见的放大器 | | ② **会话类型** | `sessions.session_settings` 里若有 `"automation":{"hostComposesUserContext":true,…}` | 🔴 **该会话是自动化会话** ⇒ 宿主会**持续构造上下文并驱动它继续**。**在自动化会话里手动续聊 = 借用宿主驱动的地盘 ⇒ 它按"持续干活"语义跑,不会按"问答"语义回话** | | ③ **上下文与压缩** | `session_usage.used/size` + 日志里 `setSessionConfigOption: preMessageCompactPct=?` | `preMessageCompactPct=0`(**关闭发消息前压缩**)+ 长上下文(实测 ≈180K tokens、转录 5 MB)⇒ 单次模型响应可达 **1.9 MB / 20 s**,越跑越慢、越慢越像卡死 | **实测案例(2026-09-28 · 会话「手机↔WorkBuddy 通道 · 夜间就绪检查」/ sid `3a46cebb`)**:该会话由**一次性自动化 `bc4eed39`** 于 19:30 创建(prompt 原文:「**只读就绪检查**…**⛔ 不要启动任何常驻服务**…**本轮只做这一件事,做完即停**」)。22:58 正常收尾后,用户在 23:00:40 手动发消息 → agent **起跑并持续工作**(改 `wb-supervisor-watch.py`、23:02:46 **起常驻后台任务 `--interval 20 --max-hours 6`**、23:03:28 查 hook.log…),**每轮 `finish_reason=tool_calls`,全程零回复**;用户三次点停止(`CANCEL_REQUESTED → FORCE_IDLE`)都只能把那一轮掐掉,**消息本身已作废**。 **当时误判的两条**:🔴 「没有新 node 进程 ⇒ 从未启动」**不成立** —— agent 是**宿主进程(同一 pid)内的会话级运行**,不另起 node;🔴 「转录 mtime 冻结 ⇒ 没在跑」**也不成立** —— agent 在跑但**只在内存/沙箱里动作**,转录正文可以滞后甚至不落盘。 ⚠️ **别做**:⛔ 别"再发一条看看"(新消息会重开一轮同样的循环);⛔ 别只凭 `sendPrompt` 返回快、或没看到新 node 进程,就断定"没派发"(**必须看状态机**);⛔ 别把 `daemon.log` 里 `[conversations] diagnostic log write failed EPERM`(当日 08:35 起就在刷)当成当次事故的因;⛔ 别为"恢复它"随手重启应用(会打断其他线正在跑的棒)。 ✅ **正确处置**:① 用户消息**已作废**(不是"没收到"),**要重新给一次**;② **换新会话**接着做(新会话不带那些坏条件,实测秒起);③ 若必须救该会话:**先让它真 idle**(点停止 → 等 `FORCE_IDLE`),**再确认没有常驻后台任务在喂料**(`executeInBackground` 起的进程要确认已结束),然后**发一条极简的、一句话能答完的消息**试探是否收敛;④ 把「**会话内禁起常驻后台长跑任务**」与「**自动化会话不要手动续聊**」两条写进作业规则(`agent-operating-rules`)。 ### 2h. 🔴 第三种形态:**界面显示"一直运行",但看不到任何执行**(★ 2026-09-29 实测定型) **关键**:这种症状**必须拆成两问**,否则一定误判 —— ① 它到底**跑没跑**?② 结果**有没有送出去**? | 问 | 查什么 | 本机实测(2026-09-29 · sid `3a46cebb`) | |---|---|---| | ① 跑没跑 | 工作区会话日志 `<日志根>/<上一天日期>/<工作区名>__*.log` 里的 `[SessionRunStateMachine]` + `[ToolManager] execute \| tool=` | **跑了**:07:06:08–07:06:28 连做 3 次工具(`Edit` 真写了 19.9 KB 文件、`present_files`),最后 `AGENT_ENDED → idle busy=false`(模型侧 `finish_reason="stop"`,本轮输出 **341 KB**) | | ② 送没送出去 | 同日志里数 **`[ACP StreamManager] sendToClient: Standalone SSE is closed`** | **没送出去**:该文件累计 **80,504 条**(06 时单小时 56,154;峰值 06:31–06:37 六分钟 34k)。⇒ 宿主仍在往一个**已关闭的客户端流**推,会话的真实产出到不了界面 | | ③ 转录佐证 | 该会话 `*.jsonl` 里 `type=message` 的最后一条 `assistant` 时间 | 停在 **06:37:09**;之后只有 `file-history-snapshot`。⚠️ 转录 mtime 仍在动 ⇒ **别拿 mtime 当"有产出"**(同 §2g) | | ④ 谁在驱动 | `automations` + `automation_runtime_state` | 该会话是**后台自动化会话**(`is_background_automation=1`、`session_settings.automation.hostComposesUserContext=true`);其 `schedule_type=once`、`next_run_at=None`、`running=0` ⇒ **驱动器已耗尽,没人再驱动它** | **为什么"显示运行中"**:客户端拿不到回合结束事件(流已关)⇒ UI 永远停在 running。**与 §2f/§2g 的区别**:§2f 是那轮断了、§2g 是工具循环不收敛、**§2h 是活干完了但结果推不出去**。 **⚠️ 放大器(本机实测)**:同一秒段里 `[ACP Agent] setSessionConfigOption: preMessageCompactPct=0` 每隔 ~15 s 被重推一次 ⇒ **发消息前压缩被关掉**,在一个十几 MB / 十几万 token 的会话上 ⇒ 单轮上下文越滚越大、响应动辄几百 KB。 **✅ 处置**:① 先按 ① 判定"真跑 / 没跑",**别一上来就说它卡死**;② 结果是送不出去 ⇒ 修**投递侧**(客户端流),不是修会话;③ 查驱动:一次性自动化(`once`)跑完就**不会再来**,要续做得**新建**棒;④ ⛔ 别在 `hostComposesUserContext=true` 的自动化会话里手动续聊当正式驱动。 **📂 旁证取证入口**:`logs/sandbox/<日期>/`、`logs/<日期>/sdk/conversations/.log`、以及 `wmic process` 查网关端口归属。 #### 2h-1. 🔴 真因与修法:**会话日志撞上 10 MiB 上限、轮转失败 ⇒ 整批丢写**(★ 2026-09-29 07:2x 实测定型,已实修) **判据(三条同时成立即可确诊)**: 1. `daemon.log` 里反复出现 `[conversations] diagnostic log write failed {"code":"EPERM","droppedLines":…,"droppedBytes":…}`,且 `droppedLines` **持续增长**(实测 12 秒涨 ~250 行、峰值丢 4 MB+); 2. `logs/<日期>/sdk/conversations/.log` **大小卡在 ~10,485,656 B(10 MiB 上限)且 mtime 冻结**,而**同期其他会话的同名日志正常在长**(对照即可排除"全盘坏了"); 3. 同目录已存在同样满的 `.log.1` ⇒ **轮转无空位**(`.log` 要滚成 `.log.1`,而 `.log.1` 也是满的)。 **修法(非破坏、可逆、实测有效)**——把卡死的那两个文件**改名挪开**(⛔ 不要删): ```bash D="E:/ProgramData/.workbuddy/logs/<日期>/sdk/conversations" TS=$(date +%Y%m%d-%H%M%S) # 用 python os.rename(MSYS 的 mv 在含中文/长路径时更易踩坑) "$PY" -c "import os;d=r'<上面D>';[os.rename(os.path.join(d,n),os.path.join(d,n+'.stuck-'+r'$TS')) for n in ('.log','.log.1')]" ``` **验收(必须做)**:① 数十秒内 `.log` **被宿主自动重建**(会先只有几百字节);② 它**再次长大**(实测 231 B → 334 KB / 70 秒);③ `daemon.log` 里该告警**不再新增**(实测改名后 90 秒零新增)。 **⚠️ 关键认知**:这两个文件**并没有被锁**(实测 append/rw 打开都成功)⇒ ⛔ 别再往"查谁占用文件"上耗时间;**纯粹是"满了 + 没轮转空位 ⇒ 写不进去"**。 **⚠️ 代价**:挪开的那两段是**会话诊断日志**(非数据原始记录,`.jsonl` 转录不动)⇒ 挪走只丢"日志",不丢会话内容。**但已被丢写的内容无法恢复**(转写里根本没写入)。 **🔴 它会反复复发(2026-09-29 07:2x 实测)**:**每个会话**的日志涨到 10 MiB 都会重演 —— 实测在我修好 `3a46cebb` 四分钟后,`16a5c457` 与 `2a0e12a8` 又先后撞上限,丢写告警 103 → 153 条持续增长。⇒ **修一次不够,要么持续兜底,要么改宿主上限**。 **✅ 已落地的兜底(本工作区)**: | 件 | 作用 | |---|---| | `$WS/.workbuddy/tools/wb-logcap-sweep.py` | 扫描器:找出**当天**目录里`≥9.9 MiB 且 mtime 冻结 >10 s` 的 `.log`,**改名挪开**(⛔ 不删)。`--dry-run` 只看;`--min-mb` 调阈值;`--quiet` 静默。🔴 **只认 `logs/YYYY-MM-DD/` 目录名** —— 曾因 `sorted()[-1]` 取到 `weixinpay` 而**静默失效**(永远报"无需处理")⇒ 判据与修法见 **§2i-1** | | `$WS/.workbuddy/tools/logcap-sweep.cmd` | 双击即用(先 dry-run,再问 Y/N 才动手) | | `$WS/.workbuddy/tools/wb-result-hook.py` → `maybe_sweep_logcap()` | **钩子兜底**:域内会话 `SessionEnd` 时自动扫一轮(限流 120 s、硬超时 8 s、错误全吞)。钩子是宿主起的短命子进程 ⇒ **零 token、不占会话** | **⚠️ 扫描器必须只认「当天」目录**(实测踩过):一开始扫了所有日期,把**昨天已写满、早已停用**的日志也误判成"卡住"白改了名。⇒ 两道护栏:① 只取 `logs/` 下**最新一天**的目录;② `mtime` 超过 **6 小时**的一律不认。 **⚠️ 改名用 `os.rename`(Python),不是 `mv`** —— MSYS 的 `mv` 在长路径/中文名下更易踩坑。 **⚠️ 本机 `curl http://127.0.0.1:18899` 的返回可能是假的**:本机装了 **Proxifier**(`Proxifier.exe`),它会把回环请求也代理掉 ⇒ 端口**没在监听**时也会返回 `502 upstream connect failed … (os error 10061)`。⇒ 判"某端口有没有服务"**必须看 `netstat -ano | grep LISTENING`**,⛔ 不要凭 curl 的返回下结论。 ### 2i. 🔴 第四种"卡住":**投递悬挂 —— 消息进了队列、没人取出来执行**(★ 2026-09-29 22:5x–23:0x 实测定型) **症状**:用户在界面上发了消息(或某个程序把消息投了进去),**界面一直转、永远没有回复**;同时该会话**没有任何执行迹象**(转录不写、状态机不动)。 **与前三型的区别(一眼分)**:②f 有中断证据;②g 工具在循环执行;②h 干完了推不出去;**②i 是"消息收到了、也入队了,但没有任何东西去排空队列"** —— **执行这一步从未发生**。 🌟 **唯一判据:投递那一行是 `resolveWaiter` 还是 `parkInQueue`**(工作区日志 `<配置根>/logs/<日期>/<工作区名>__*.log`) ``` grep -F "" <该日志> | grep -E "PromptIterator|prompt\(\) called" ``` - `route=resolveWaiter` + `hasWaiter=true` ⇒ 投递方**阻塞等在那里** ⇒ 消息会被立即执行(正常形态) - 🔴 `route=**parkInQueue**` + `hasWaiter=**false**` ⇒ 投递方**没等** ⇒ 消息只是"停进队列",**需要另一次触发去 drain**;若该会话没有驱动源 ⇒ **消息永久躺在队列里** **四条佐证(缺一不可,避免误判)**: | # | 查什么 | 判据 | |---|---|---| | ① | 状态机最后一次转换 | `AGENT_ENDED … to=idle busy=false` ⇒ **上一次是正常收尾的**,不是卡在循环里 | | ② | 该会话**全天**投递路径分布 | 若**只有最后一条**是 `parkInQueue`(其余全 `resolveWaiter`)⇒ 这是**孤例故障**,不是配置问题 | | ③ | 投递后有无执行 | 无 `RUN_PREPARING`、转录 mtime 与最后一条正文都**早于**投递时间 ⇒ 确定没被消费 | | ④ | 有无驱动源 | 查 `sessions.session_settings.automation` + `automation_runs`:`once` 型 run 一旦 `resultState=delivered` 且 `next_run_at=None` ⇒ **驱动器已耗尽,不会有人来 drain** | #### 2i-1. 🔴 **定量指纹 + 恢复判据(★ 2026-09-30 复测复现 · 用户给了窗口 22:55–23:20)** **这一步能把"症状"变成"确诊"** —— 数全天两个计数,异常会自己跳出来: ```bash # ① hasWaiter 分布:正常会话几乎全是 true grep -a -c "hasWaiter=false" <该日志> # ② 客户端状态丢失:只会在出事那个小时出现 grep -a -c "No state found for connectionId" <该日志> ``` **本次实测(主会话 `fe146dd9`,2026-09-29)** | 计数 | 读数 | |---|---| | `hasWaiter=false` | **全天仅 3 次**,且**全部**落在 22:53:00 / 23:05:01 / 23:05:37(`queueLen` 0→1→2 逐条堆积) | | `hasWaiter=true` | 86 次(正常形态) | | `No state found for connectionId` | **22 点这一小时 303 次,其余小时 0 次** ⇒ 客户端连接状态在该小时反复丢失 | | **恢复点** | **23:12:16 `route=resolveWaiter … hasWaiter=true`** ⇒ 队列被排空;**同一刻宿主 pid 由 `32356` 换到新实例** ⇒ 客户端重新挂上 | ⇒ **确诊口径**:`parkInQueue` + `queueLen` 递增 + `hasWaiter=false` + 同小时 `No state found` 飙升 ⇒ **客户端没有 waiter ⇒ 没人 drain**; **处置=让客户端重新挂上该会话**(切走再切回/重开该会话窗口);⛔ **别反复发消息试探**(只往队尾再堆一条)。 ⚠️ **两层别混(同窗口常常同时出现)**:本次 22:58:40–22:59:50 还叠着 `daemon.log` 的 `diagnostic log write failed`(`droppedLines` 496→2580) —— **那是"会话日志层"(§2h-1),不解释"消息卡住"**。⇒ 一次事故里看到两个异常时,**分别归因、分别取证**,⛔ 不要合成一个因 (教训:`multi-session-collab/references/pitfalls.md` **P0-2 / P0-5**)。 **⚠️ 与"诊断日志满额"(§2h-1)会互相放大**:日志满额后,投递与执行过程在诊断日志里全空白 ⇒ 观感更像"死住"。排查 ②i 时**顺手看一眼该 `.log` 是否已撞 10 MiB**。 **✅ 处置**:① 那条消息**已作废、不会自愈** —— 要重新给一次;② 别"再发一条试试"救它(若该会话是 `hostComposesUserContext=true` 的自动化会话,手工续聊不算正式驱动,见 §2g);③ 要续做该线 ⇒ **新建一棒**(一次性自动化);④ ⛔ 不要删锁 / 接管(无锁残留)。 **实测样本(2026-09-29 · 会话「接续 · 机制线(钩子锚点真实投递取证)」/ sid `fe146dd9`)**:22:06:05 正常收尾回 idle;此后 **22:53:00 / 23:05:01 / 23:05:37 连续三次**投递全部 `route=parkInQueue / hasWaiter=false`,且第二次起 `queueLen=1`(**队列里压着前一条**);宿主机零 `RUN_PREPARING`、转录最后写入 22:05:35 ⇒ **一条都没执行**。而 22:53 之前的 13 次(21:11–21:57)**全是 `resolveWaiter` 且正常执行** ⇒ 🔴 **分界点在 22:53**,不是"偶发一次",而是"从这里起变成常态"。 🔴 **必须两侧日志对读**(本次的关键):`hasWaiter` 是**双方认知不一致**的地方 —— | 看哪边 | 文件 | 同一次投递记成什么 | |---|---|---| | 宿主侧 | `logs/<日期>/<工作区名>__*.log` | `[AcpView][PromptIterator] … route=parkInQueue hasWaiter=false` ⇒ **不执行,只入队** | | 客户端侧 | `logs/<日期>/sdk/conversations/.log` | `method:sendPrompt` → `state-machine:transition PROMPT_SENT from idle to valid to working` → `runtime-status:persisted {"status":"working"}` ⇒ **自认"已发送、正在工作"** | ⇒ 所以**别拿会话状态字段当判据**:客户端会把 `working` 写进库(不是"残留",是它**基于错误假设主动写的**),而宿主那边根本 idle。 🔴 **客户端侧的三个特征行(一搜即中,比状态机更省事)**: 1. `handlePost: Received cancel notification for session ` —— **用户在界面上点了「停止」** 2. 紧跟 `cancel: received cancel request` + `Ignoring cancel for idle session ` ⇒ **停止无效**(宿主认为闲着,可用户明明看到它在转) 3. `cancelAllInFlightPrompts: Scheduling sweep for 1 in-flight prompt(s)` ⇒ 那条"在飞"的 prompt **正是被 parkInQueue 挂住的那条**(它被计入 in-flight,所以界面永远在转) ⇒ **「用户反复发消息 + 反复点停止都无效」是 ②i 的典型用户侧症状**,可直接作为问诊入口。 ⚠️ **四条踩过的坑(2026-09-29 23:0x 实测,血泪)**: 1. **`queueLen` 会累积、且不会自动重试**:实测 `queueLen=0`(22:53)→ `1`(23:05:01)→ `2`(23:05:37)⇒ **每再投一次就再压一条**,队列只进不出。所以"用户又发了一条"要读成"又压了一条",⛔ 不是"重试成功"。 2. ⛔ **别拿 `ending POST /api/v1/acp [Nms]` 的耗时当判据** —— 实测全天**没有任何** ≥ 800 ms 的 POST(全是 1–5 ms),**正常与异常完全一样**。我一开始看 `[2ms]` 就推断"客户端发完即断 ⇒ 没人等",是**错的**(幸好回查证伪)。`hasWaiter` 不是从 HTTP 连接时长推出来的。 3. 🔴 **最有价值的线索是"变点",不是那一条异常**:把该会话**全天**的 `PromptIterator` 行排成队看 —— 本次 13:56–22:03 共 **30+ 次全是** `resolveWaiter / hasWaiter=true`(全部正常执行),**22:53 起**才变 `parkInQueue`。同入口、同前置动作(投递前那串 `set_mode`/`set_model`/`set_config_option` 前后**完全同构**),**只有"等待者"这一项变了** ⇒ 说明投递方/入口在某一刻换了。**别只看那一条就下结论"偶发"。** 4. **前置动作同构性要自己验**:本次用"每次投递前 ±30 s 的 `handlePost` 序列"做对照,确认异常前的那串配置请求与正常时**逐条同构** ⇒ 才能把变量锁定在 `hasWaiter` 上,而不是瞎猜"是不是客户端版本变了"。 ⚠️ **要查实现时的路径坑**:`parkInQueue` / `hasWaiter` 在 `.../Programs/WorkBuddy/resources/app.asar`(本机 09-21 版)里**零命中**(`resolveWaiter` 命中的是无关的 MCP waiters)⇒ **运行中的宿主比那份 asar 新**,字符串检索要基于**实际运行的版本**,别拿旧 asar 下结论。 > ⚠️ **`sessions.status` 与 `last_activity_at` 都不可单独作判据**:本次二者分别显示 `working` 与 `22:53:00`,而 `last_activity_at` 的刷新其实是**客户端的配置同步动作**(`set_session_config_option` / `set_mode` / `set_model`,即"UI 打开了这个会话")造成的,**不代表执行**。⇒ 一律回到状态机 + `PromptIterator` 那两行。 #### 2i-1. 🔴 兜底扫描器 `wb-logcap-sweep.py` 曾**静默失效**(★ 2026-09-29 23:0x 实测,已修) **判据**:`--dry-run` 打印 **「无需处理:没有 >= 9.9 MiB 的会话日志」**,而**明明存在满额文件**(本次实测满额 10.0 MiB、已冻结 2.9 小时)⇒ **不要相信这句"无需处理",去查它挑中的是哪个目录**。 **真因**:`logs/` 下同时住着 `weixinpay` / `update` / `startup` / `sites` / `sandbox` / `perf` / `migration` / `editor_sdk` / `Diagnostics` / `Crash-Log` 等**非日期目录**;裸 `sorted(it.iterdir())[-1]` 取到的是 `weixinpay` ⇒ 该目录无 `sdk/conversations` ⇒ `continue` ⇒ **一个候选都扫不到**。(讽刺的是:它"永远正常退出",所以钩子兜底也一直以为没事。) **修法**:目录名必须匹配 `^\d{4}-\d{2}-\d{2}$`。修复后首次运行即扫出 **3 个**满额并挪开(`fe146dd9` 冻结 10,438 s / `f0fb0dbc` 冻结 139 s / `2d81f349` 冻结 1,618 s),40 s 内前两个已被宿主重建并恢复写入。 **教训**:这类"扫描-跳过"型兜底**必须有自证**(列出它扫的是哪个目录/扫到几个候选),否则"没动作"与"坏了"长得一模一样。 ### 2j. 🔴 某个进程「是不是某个会话的后台任务」「能不能一直开着」(★ 2026-09-30 实测定型) **触发**:用户问「XX 工作区那个会话开的服务/工作台,是不是后台任务?能不能一直开着?」「那个端口还活着吗?」 **三步取证(全只读,⛔ 不碰那个进程)** ```bash # ① 端口 ⇒ pid(只认 LISTENING 那行) netstat -ano | grep ":<端口>" # 例:TCP 127.0.0.1:8900 ... LISTENING 27112 # ② pid ⇒ 父链(技能自带,纯 ctypes,⛔ 不依赖 psutil) python /scripts/proc-parent.py # ③ 定性:宿主日志里搜「它是怎么起来的」 # 日志:<配置根>/logs/<日期>/<工作区名>__*.log grep -a "executeInBackground\|background task created\|processExit" <该日志> ``` **怎么读** | 读数 | 结论 | |---|---| | 父链里出现 `… ← sandbox-cli.exe ← WorkBuddy.exe` | ✅ **是会话/宿主托管的后台任务**(挂在 WorkBuddy 进程树下) | | 父链里是 `services.exe` / `svchost.exe` / `explorer.exe` | ⛔ 不是后台任务 ⇒ 独立进程(计划任务 / 启动文件夹 / 手工) | | 日志有 `[BashTool] executeInBackground … \| no timeout (background)` + `[BashTool] background task created \| taskId=XXXX \| mode=pipe` | 确认是后台任务,**且无超时**(不会到点被杀) | | 日志有 `[SandboxPipeHandle] processExit \| processId=pipe-N \| exitCode=… \| killed=…` | 它**什么时候死过**(`killed=false` ⇒ 自己退的,⛔ 不是被回收) | ⚠️ 日志中文是**「UTF-8 字节被按 GBK 解」的乱码**,还原一行: `fixed = s.encode("gbk", "replace").decode("utf-8", "replace")` **存活边界(2026-09-30 实测,别凭直觉答)** | 事件 | 后台任务会掉吗 | |---|---| | 创建它的会话 `status=completed` | ❌ **不掉**(实测:会话 00:18 已完成,`:8900` 仍在 `LISTENING`、HTTP 200) | | 单次工具调用结束 | ❌ **不掉**(⚠️ 与"前台子进程随调用结束被回收"**正好相反**) | | 后台任务超时 | ❌ 不会(`no timeout (background)`) | | **该工作区窗口 / 那个 WorkBuddy 实例关闭** | ✅ **掉** | | **WorkBuddy 整体退出 / 重启** | ✅ **掉,且不会自动回来** | | 进程自己崩 / 改完代码要重启 | ⚠️ 会(实测 00:17:00 `exitCode=127 | killed=false`,7 分钟后才被重新拉起) | ⇒ **一句话口径**:**「后台任务」跟着 WorkBuddy 活,不跟着会话活。** 要「关了 WorkBuddy 也还在」必须做成**独立进程**(计划任务 / 启动文件夹)—— 后台任务做不到,⛔ 别承诺"一直开着"。 **三个环境坑(本机实测,省 20 分钟)** 1. ⚠️ **PowerShell 工具在某次会话里可能"零输出"**(同一条命令在别的会话正常)⇒ 别在上面耗;用 bash 的 `netstat` + 本技能的 `proc-parent.py`。 2. ⚠️ `export MSYS_NO_PATHCONV=1` 之后 **⛔ 别把 `/e/...` 交给 `python.exe`** —— 原生程序会解释成 `E:\e\...`(实测报 `can't open file 'e:\e\...'`)⇒ 一律传 `E:/...`。 3. ⚠️ Git Bash 里 `tasklist /FI "…"` 会被**路径转换**吃掉(`/FI` 变成 `E:/…/FI`)⇒ 需要 `MSYS2_ARG_CONV_EXCL='*'`,或干脆不用它。 --- ## 3. 原文读不到时怎么复原任务(三步 · **降级路径,先按 §1 实测能否读到**) 1. **工作区共享日志**:`.workbuddy/memory/YYYY-MM-DD.md`(多会话**共用同一份**,按小节标题 + 时间 + 里面的锁名 `ME=xxx-HHMM` 归属到具体会话);配 `MEMORY.md` 状态层。 2. **项目文档库**的「待办 / 交接单 / BRIEF」单一来源。 3. **服务端 / 生产态只读实测**(`git log`、`ls`、`cat package.json`、cgroup 读数)—— 这是唯一能把"文档说"与"实际是"分开的证据层。 > **别做**:不要凭 `MEMORY.md` 一句话就下结论;文档常滞后于事实(本次实测就发现 3 处账实不符)。 ## 4. 附带:工作区搬迁后必查的连环故障(2026-09-13 实证) 改了工作区路径后,**第一件事是修「活动配置」里 hooks 的脚本绝对路径**: - ⛔ **先确认真·活动配置在哪**(2026-09-13 血证,本技能旧版写错过):先跑 `env | grep CODEBUDDY_CONFIG_DIR`(本机 = `E:\ProgramData\.workbuddy`)—— **要改的是该目录下的 `settings.json`**。 `C:\Users\Administrator\.workbuddy\settings.json` 是**搬迁前的遗留副本,改了完全不生效**:09-13 同机两个会话各改一次(06:55 / 07:11),**白改**,还被误读成「hook 是启动时快照、新开会话也没用」—— 真实原因是**改错文件**。 - 症状:**所有 Write/Edit 被拒**,报 `can't open file '<旧盘符>\...\xxx-hook.py'`。 - 修法:只替换路径串(`sed -i`),**绝不整段覆盖 `hooks`** —— 它是**多会话共享配置**,顶层键覆盖会静默抹掉别人的钩子。 - 校验 A(配置本身):`python -c "import json;d=json.load(open('settings.json',encoding='utf-8'));print(list(d.keys()), list(d['hooks'].keys()))"`,确认顶层键与 hooks 事件都没少。 - ★**校验 B(宿主到底执行了哪条命令 —— 最硬的证据,不用猜)**:看宿主日志 `~/.workbuddy/logs/<日期>/<工作区名>__*.log` - `[HookExecutor] spawn … timeout=… cmd=<命令逐字>` ← 宿主**实际执行**的命令串(可直接和配置文件比对) - `[HookExecutor] abnormal exit … code=N` ← 钩子崩了(脚本路径/解释器不对)⇒ **宿主 fail-closed:该机所有 Write/Edit 被拒** - 有 spawn、无 abnormal exit、却**没拦住** ⇒ 钩子跑通了但走了「放行」分支 = **fail-open**(脚本对读不懂的载荷放行是常见设计面)⇒ 去给脚本加一行「原样落盘 stdin」的诊断再复现。 - ⚠️ 钩子脚本的**改动即时生效**(脚本内容每次调用现读),只有 `settings.json` 的 hooks 条目是**应用启动时快照**。 - ★**hook 命令是「应用启动时快照」**:改完 `settings.json` 的 hooks 条目**必须完全重启 WorkBuddy(关窗 ≠ 退出)才加载**。 **判据(2026-09-13 07:2x 实测,用的是活动配置、未被"改错文件"污染)**:07:25 改了活动配置 → 07:26:27 宿主**仍执行旧命令** → 07:30:01 完全重启后 **07:30:48 才执行新命令**。 ⚠️ 注意:同一件事当天曾被写成"两次实测定论",但那两次改的都是**非活动文件**,**不构成证据**(见本节第一条)。 自证看宿主日志的 `[HookExecutor] spawn`,或钩子自己写的低频日志(`.workbuddy/lock-hook.log` 的 `SessionStart` 行)。 ⚠️ **一个会误导人的假象**:若中途存在指向新路径的**目录联接**(如 `D://旧路径 → E://新路径`),「改完就能写」会被误读成「配置是现读的」—— 当天同机两个会话**独立踩了同一坑**。判据:拆掉联接后再试一次,仍报旧路径 ⇒ 快照无疑。 🧯 **应急兜底**:此类钩子常**有意不拦 Bash**(避免把自己锁死)⇒ 路径失配期间可用 shell 写文件过渡。 ## 5. 同名多会话:用户说「找最新的那个」时怎么办 **判据(两个都算,取更大者)**: - `createdAtMs`(创建时间)—— 在 `edge-sync.log` 的 `§2 CREATE` body 里 - `lastActivityAtMs`(最后活动)—— 在 `§3.4.1 ACTIVITY` 行里 **实例(2026-09-13)**:`方案规划` 有两个 —— `ca58e09f`(09-11 23:52 建 / 最后活动 09-12 16:54)与 `ba6c2de4`(09-12 17:32 建 / 最后活动 09-12 19:17)。两指标**一致**指向后者 ⇒ 判定安全;**若两指标打架,两条都列出来问用户,别猜**。 **交付姿势**(用户说「同步 X 到这个会话」时): - 本机**拿不到云端原文**(见 §1 / §3),**别承诺「我把原文搬过来了」**。 - 正确交付 = **一份「会话同步包」**:① 身份卡(id / 标题 / 改名史 / 生命周期 / 活动点)② 三条「原文不可读」的硬证据 ③ **该会话工作线的内容复原**(用活动点与共享日志段落**逐一对齐时间戳**做归属)④ 它对当前状态的影响 ⑤ 「若确要原文」的可行路径。 - 落位:`<工作区>/.workbuddy/session-sync/YYYYMMDD-同步包-<标题>-.md`(放 `.workbuddy/` 下,不会被当临时产物清掉)。 - ⚠️ **归属必须标置信度**:共享日志是多会话混写的,同窗并行会话的记录**单列一节**,别混进「这个会话做了什么」。