docs(dsh-server-docs): mksess 口径校正(DB 直插→PG 直插)+ 技能 dsh-auto-handoff-chain 三处同步建立(序⑰ S1/S2)

- 02-运维手册.md:mksess 命中处补「PG 直插 + 连接串来源」+ D1 勘误指针两行(档案 77 的旧形态说明,正文不改)
- skills/dsh-change-workflow/SKILL.md(146/395/495 三行)、skills/dsh-env-bootstrap/references/常驻规则-快照.md(55 行):同口径校正
- skills/dsh-auto-handoff-chain/:新建文档库副本(SKILL.md v1.3.2 + scripts/chain_report.py),此前该技能三处同步从未建立
- README.md / INDEX.md:登记该技能(技能表 + 「什么时候查什么」+ 技能清单表)

判据:E1 = 0 行、E2 两副本 md5 全同、E3 diff -r = 0 行、E4 登记命中 README 1 / INDEX 2、镜像 47 同值
This commit is contained in:
admin committed 2026-09-17 17:03:34 +08:00
1 parent 146c3d25ef
commit bc0dd2c96d
10 files changed
+551 -9

No files matched your search

@@ -0,0 +1,237 @@
---
name: dsh-auto-handoff-chain
description: 长任务「多棒自动接力」编排法 —— 把一个跨越多个上下文窗口的大任务,拆成「规划棒 ↔ 执行棒」交替的一次性自动化链条,每棒做完自动开新会话接下一棒,**全程零人工点击**。当用户说「自动新建会话接续处理」「接力跑下去」「多步骤任务自动推进」「跑完一棒自动接下一棒」「无人值守推进」,或一个任务预计要跨 ≥3 个会话 / 超过一个上下文窗口时触发。核心 = 六件套 prompt 骨架(状态单点 → 唯一执行依据指针 → 全局锁 → 单一动作 → 成本纪律 → 收尾四件套)+ **登记门禁(★要拍板的,等拍了再登记 —— 用户 2026-09-17 明令,当天已有实测事故 §3.3.1)** + 五条实测防护(断链 / 双开 / once 不转完成态 / 跨过拍板点 / **下一棒定太晚**,间隔纪律见 §3.1.1)+ 实测成本基线 + 复跑脚本 `scripts/chain_report.py`。⛔ 两条关键判据:**prompt 里绝不抄任务细节**(细节只有一个漂移源 = 入口文件的「本轮动作」块);**先判「是不是要拍板」,未命中才轮到「候选排不排得出优劣」—— 顺序颠倒就会自我扩权**。
version: 1.3.2
updated_at: 2026-09-17
created_from: 覆盖网络线 19 个会话(2026-09-16 ~ 09-17)的实测复盘 —— 09-17 链条连续 8 棒零断链,规划棒成本降至旧形态的 1/8–1/12
agent_created: true
---
# dsh-auto-handoff-chain — 多棒自动接力编排法
> **一句话**:不要用「一条 prompt 跑完整条工作线」,而是让**每一棒只做一件事**,做完**自己把下一棒排上**。
用户原话(2026-09-17 立项):
> 「每次执行完毕后 自动根据方法决策 自动新建会话接续处理,**在处理多步骤任务的时候非常好**」
---
## 0. 与其他技能 / 文档的分工
| 谁 | 管什么 |
|---|---|
| **本技能** | **怎么把长任务排成一条自动接力的链条**(编排层) |
| `dsh-change-workflow` | 单次改造怎么落地(六阶段) |
| `dsh-decision-method` | 单次取舍怎么定得对(**规划棒应加载**) |
| 工作区 `会话接续规范_20260916.md` | 上下文超限时的**接续包 v2 模板 / 开机四步 / 多线并行**(本技能是其"多棒编排"的补充,**不重复**它已写的接续包字段与成本公式) |
| 工作区 `接续入口_<线名>_<日期>.md` | **每一条工作线的唯一执行依据**(本技能的核心依赖) |
---
## 1. 形态:规划棒 ↔ 执行棒 交替
```text
规划棒①(出交接单)→ 执行棒①(照单落地)→ 规划棒②(出下一单)→ 执行棒② → …
↑ 每棒 = 一个全新会话 + 一条一次性 automation,做完自己登记下一棒
```
**为什么拆成两种棒(不是随便切段)**
| 棒 | 只做什么 | 成本量级(实测) |
|---|---|---|
| **规划棒** | **只出交接单**:不改服务器、不改代码、不部署 | 23–56 次调用 / 5.7–9.4 积分 / 3–10 min |
| **执行棒** | **照单落地**:按单子的 S0–Sn 逐条执行、逐条验收 | 93–215 次调用 / 23–29 积分 / 21–41 min |
⇒ 这是「**规划与执行分离**」从纪律变成**机制**:规划棒物理上碰不到生产,执行棒物理上不必做取舍。
**⛔ 什么时候不要用这套**
- 一次性小任务(直接做,别排链条)
- 步骤可并行(用并行会话 + 全局锁,不需要串成链)
- **任务形态本身就贵**(例:批量改写 N 份文档)—— 那只该**先写脚本一次跑完**;接力**救不了**贵的活,只换场地
- 单棒做不完(会超出上下文预算)⇒ 说明**棒还要再切细**,或改用「接续包 v2 + 开机四步」
- 🔴 **链条上存在「需要用户拍板」的决策点** ⇒ 可自决的段落照常接力,但**必须在拍板点前停下**,不许自动跨过去(**见 §3.3 登记门禁**)
---
## 2. 六件套 prompt 骨架(照抄填空)
> **位置**:每棒的 prompt 写在 `automation_update` 的 `prompt` 字段里。
> **要点**:整段 400–1,300 字符。**越长越糟** —— 每多抄一个技术细节,就多一个漂移源。
```text
<线名> · <第 N 棒:规划棒 / 执行棒>(本轮只做这一件事,做完即停)。
第 0 步:跑 `<绝对路径>/state.py` 看状态(只读、免抢锁、1 次调用拿到锁/git/入口/收口)。
第 1 步:抢全局执行锁 `bash "<绝对路径>/handoff-guard.sh" --claim-exec "<线名>-<第N棒>"`;**抢不到 = 有会话在跑 ⇒ 只报告并立刻停**。
第 2 步:读**唯一执行依据** = `<入口文件绝对路径>` 的 **§2「🎯 本轮动作」块**,按它点名的那份交接单开工。
(规划棒专属)本轮任务:出可执行交接单,落盘 `<路径>`,按 8 段模板:目标 / 只读前置 / 范围 / 决策点 / 步骤 S0–Sn / 逐条验收判据 / 回滚 / §8 回报格式。
(规划棒专属)⛔ 开工前先加载技能 `dsh-decision-method`(取舍判据以它为准,不凭记忆)。
约束:⛔ 不 commit / push;⛔ 不做 <明确点名的排除项>;⛔ 不重做 <已收官的序号>;⛔ 不扩大单子范围(单外发现的缺陷先报告、不动手)。
成本纪律:批量活先写脚本再让脚本跑;取证最多 3 条命令;⛔ 不要 Glob/Grep 全库摸底;一轮内工具调用次数尽量压低。
纪律:技术实现项**自决不上抛**;只有「没有客观优劣」的取舍才列候选,且每个候选必须写「优点 / 缺点」、**候选竖排成段**(不横排);判据必须可被第三方复现。
收尾(缺一即算未完成):① 释放锁 `--release-exec`;② **先过登记门禁(见 §3.3)**——只有「下一棒可自决」才登记,**`scheduledAt` = 此刻 + 2~5 分钟(见 §3.1.1,⛔ 不许留长等待窗口)**;用**陈述句**告知「已登记自动接续、约 2~5 分钟后自动开新会话、接续点 = X」;③ 把入口 §2「🎯 本轮动作」推进到再下一棒;④ 写工作区日志。
```
### 2.1 三条骨架为什么长这样(都有实测出处)
| 骨架 | 治什么 | 实测依据 |
|---|---|---|
| **第 2 步 = 指向入口,不抄细节** | **细节漂移**(细节有两个来源 ⇒ 必然打架) | 09-16 那版 prompt 重述了整段技术细节 ≈1.2 KB,反而**挤掉了"开机四步"那一行** ⇒ 40 次调用 / 9.37 积分(立项口径 ≤10 次 / ≈1 分) |
| **「本轮只做这一件事,做完即停」** | 无人值守时的**自我扩权** | `b08b1c35`:用户只说「先确认待办」,第 28 次调用**已在写代码** |
| **「取证最多 3 条命令」** | 防御性过度取证 | 同一会话第 7–24 次**连续 17 次取证**,reasoning 里三连自我加码「取证非常完整了」 |
| **纪律块(自决不上下抛 + 候选写优缺点)** | **把决策方法内联**(新会话读不到旧上下文,方法论不会自己进来) | 09-17 实测:9 棒里 `Skill` 调用 **0 次** ⇒ 判据全靠这段内联文字撑住 |
### 2.2 ★ 已知缺口:Skill 调用 = 0(本技能给出的修法)
**实测**(09-17 全部 9 棒):`function_call.name == "Skill"` **一次都没有**。判据是 prompt 里的**内联摘要**在起作用,完整方法论从未进上下文。
**修法**(分棒区别对待,别一刀切):
- **规划棒必须加**:`⛔ 开工前先加载技能 dsh-decision-method`(规划棒基数只有 23–56 次调用,+1 次可接受,且它确实要做取舍、要出单)
- **执行棒可以不加**:单子已经把判断写死了,再加载方法论是纯开销(执行棒基数已 93–215 次)
---
## 3. 收尾四件套(缺一即算未完成)
> 这四件里**第 ② 件是唯一会"断链"的地方**,也是钩子**做不到**的地方(钩子不能创建会话、不能创建自动化)。
| # | 动作 | 判据 |
|---|---|---|
| ① | 释放锁 `--release-exec` | 跑一次信息模式确认已释放 |
| ② | **先过 §3.3 登记门禁** → 登记下一棒的一次性 automation(**`scheduledAt` = 此刻 + 2~5 分钟**,见 §3.1.1)+ **在给用户的回复里用陈述句告知** | 门禁不过 ⇒ **不登记,改为告知"链条已暂停待拍板"**;陈述句三要素:约 2~5 分钟后自动开新会话 / **不用你操作** / 接续点 = X |
| ③ | 把入口文件 §2「🎯 本轮动作」**推进到再下一棒** | 入口 = 下一棒的**第一信息源**;不推进 ⇒ 下一棒照旧口径做,做重工 |
| ④ | 写工作区日志(当日 `memory/YYYY-MM-DD.md` 追加自己的小节) | 只追加自己的小节,⛔ 不重写别人的段落 |
### 3.1 收尾陈述句模板(实测原文,照抄)
```text
已登记自动接续:一次性 automation `<id>`,约 5 分钟后(08:37)自动开新会话,不用你操作;
接续点 = 序 ④「443/TCP 兜底」的规划棒(出 `交接单_443兜底_20260917.md`,只出单、不改服务器)。
收口:锁 抢 ✓ → 释放 ✓(08:32)|未 commit / 未 push|入口 §0/§2 已刷到「③ 已完成 → 下一棒 ④」|产出物已交付。
```
**⛔ 只登记不告知 = 缺陷**:新建会话是**用户可感知的状态变更**。实测:`408636f2` 登记了自动化却一字未提,用户两小时后自己发现才追问。
#### 3.1.1 🔴 间隔纪律:`scheduledAt` = 收口时刻 **+ 2~5 分钟**(2026-09-17 用户追问后定)
- **实测反例**:序⑦ 执行棒 12:2x 收口,把下一棒定在 **12:50**(留 ~25 min)⇒ 用户 12:26 直接追问「**为什么要等20多分钟才执行接续会话**」。当时的自我理由是「给用户留一个在本会话追改的窗口」+「让旧锁自然陈旧」——**两条都站不住**:锁在收尾 ① 里**已经释放**(不存在"等锁陈旧"),而"追改窗口"等价于**主动制造 20 分钟空转**。
- **判据**:收口那一刻,链条上**没有任何"要等的对象"** ⇒ **等待时间越短越好**(2~5 min 只用于避开调度器的最小提前量与文件落盘竞态)。
- **唯一允许拉长的情形**:下一棒**明确要等一个外部窗口**(对方服务重启完 / 另一条棒在跑 / 用户拍板)⇒ 可以拉长,但**必须在陈述句里写明在等什么**。
- ⛔ **不许把"给用户留追改窗口"当理由** —— 用户要的是**尽快推进**,追改可以在任何一轮直接打断。
#### 3.1.2 🔴 下一棒的 **automation id 只能来自工具返回值**(2026-09-17 实测踩坑)
- **实测反例**:序⑧ 执行棒收口时,我先把「下一棒 id = `ad8d1c4f-…`」写进了自动化记忆文件,**之后**才调 `automation_update` 建单 —— 真实 id 是 **`0c3feee5-…`**(工具生成,无法预知)⇒ 记忆文件里留下一条**指向不存在的自动化**的假 id,必须回头再改一次。
- **正确顺序**(不可颠倒):① 先 `automation_update(mode=list)` 查重 ⇒ ② `mode=create` 建单 ⇒ ③ **从返回值取真 id** ⇒ ④ 再写记忆文件 / 入口文件 / 日志。
- ⛔ **不许"先占位再补"**:id 是**外部生成的事实**,编造 = 判据级污染(下一位读者会拿它去 `mode=view`,查不到就会误判"链条断了")。
- ⚠️ 同理适用于:`scheduledAt` 的实际生效值(以返回的 `nextRunAt` 为准)、被工具规范化过的 name。
### 3.2 ★ 登记前必须先看有没有在跑的(防双开)
实测(序③规划棒,09-17 07:56):
> 「执行棒会话**已经起来了**(07:53 触发,现在 07:56),所以我**不再新建接续 automation** —— 链条已经接上,多建一条只会重复开工。」
**判据**:登记前先确认下一棒是否**已经存在**(会话列表 / `automation_runtime_state.running`)。已存在 ⇒ **不登记,只报告**。
### 3.3 🔴 登记门禁:要拍板的,等拍了再登记(用户 2026-09-17 明令)
用户原话:
> 「**需要我拍板时,等拍了再新建接续会话就行**」
**动作只有两句**:
- 下一棒若含**需要用户拍板**的事 ⇒ ⛔ **不登记**;本棒正常收口,停在「待你拍板」节,并告知「**链条已暂停,等你拍板**」
- **拍板到手后** ⇒ 再由当时的会话登记下一棒
**什么算「需要拍板」** = 边界外六类 + 不可逆操作:
业务目标 / 优先级 · 花钱或承诺资源 · 对外承诺与合规 · 需要用户提供凭据或审批 · 无客观优劣的偏好 · 影响面超出本平台 · 不可逆操作(删数据 / 迁库 / 清目录)。
⚠️ **判据顺序**:先判「是不是要拍板」;**未命中**才轮到「候选排不排得出优劣」。顺序反了就会自我扩权 —— 实测事故见 §3.3.1。
**唯一例外**:条件式非阻塞项(不做决定也能先开工)⇒ 可以登记,但要把「遇到即停」写进下一棒 prompt。
> 实测正例(序④规划棒):「只有 S3 实测证明『地址覆盖 + 指定 SNI』不可行时,这一条才成为唯一缺口」⇒ 不阻塞开工 ⇒ 正常登记。
### 3.3.1 实测事故:序⑥ → 序⑦「拍板未定,接续已跑」(2026-09-17 用户报障)
用户原话:
> 「**最新会话遇到需要我拍板 还是自动创建接续会话的情况,导致拍板还没定,接续会话已经开始执行**」
**经过**:09:52 序⑥**规划棒**把「真机来源」(要用户出设备 = 边界外)自己拍了 ⇒ 登记执行棒 → 10:58 序⑦规划棒按链条自动开跑 → **11:00 用户才给拍板** → 11:09 序⑦执行棒又自动开起来 → 11:11 用户手动喊停。**多烧 ≥2 轮,且要用户自己介入收拾。**
**三条留档(别再犯)**
1. **边界外的事,候选再明显更优也不许自己拍** —— 这次就是「A 零成本零等待」看着更优,但选项本身越界。
2. **拍板项一旦上抛,本棒就不许再登记下一棒** —— 机器定时与人的拍板节奏**没有同步点**,不等就必然错位。
3. **判「某棒是否在跑」看全局锁的时间戳**,⛔ 不是 `automation.status` —— 12 条已跑完的一次性任务,`status` 全是 `ACTIVE`。
---
## 4. 五条实测防护(都是真踩过的)
| # | 现象 | 后果 | 处置 |
|---|---|---|---|
| ① | **漏登记下一棒**(链条断在收工处) | 无人接着做,**要等用户发现** | 实测:07:2x 那棒漏登记 ⇒ 用户 07:39 主动追问「为什么还不创建新会话接续任务」。⇒ 修法 = 把「收尾四件套缺一即算未完成」**写进每棒 prompt** |
| ② | **双开 / 重复登记** | 同一份活被跑两遍,白烧一轮 | 见 §3.2(登记前先看有没有在跑的) |
| ③ | **once 型 automation 跑完不自动转完成态** | 列表里堆积"已过期但 ACTIVE"的一次性任务;调度器补跑窗口 **12 小时** ⇒ 理论上可能被扫到、**多开一个会话** | 实测 09-17:列表里躺着 4 条过期仍 ACTIVE 的 once 型任务。处置:**保持原样**(不动既有配置)或**设为暂停**;⛔ 不要用 shell 去改库 |
| ④ | **自动跨过"需要拍板"的点** | 拍板未定就跑,**要等用户自己发现**(已有实测事故 §3.3.1) | **要拍板的,等拍了再登记**(§3.3 门禁) |
| ⑤ | **下一棒 `scheduledAt` 拉太长**(实测 ~25 min) | 链条白白空转,**用户当场追问** | 实测:序⑦执行棒 12:2x 收口、下一棒排 12:50 ⇒ 用户 12:26 追问「**为什么要等20多分钟才执行接续会话**」。⇒ 修法 = §3.1.1:**= 收口时刻 + 2~5 分钟** |
---
## 5. 实测成本基线(2026-09-17 链条,连续 8 棒零断链)
| 棒 | 工具调用 | 积分 | 时长 | 用户轮数 |
|---|---|---|---|---|
| 序③ 规划棒 | 50 | 6.66 | 10 min | 1 |
| 序③ 执行棒 | 215 | 24.65 | 41 min | 4 |
| 序④ 规划棒 | 56 | 5.73 | 6 min | 1 |
| 序④ 执行棒 | 123 | 24.90 | 23 min | 3 |
| 序⑤ 规划棒 | 25 | 7.66 | 3 min | 1 |
| 序⑤ 执行棒 | 121 | 28.76 | 21 min | 1 |
| 序⑥ 规划棒 | 23 | 9.35 | 7 min | 1 |
| 序⑥ 执行棒 | 93(进行中) | 22.99 | — | 9 |
**对比旧形态**(同一条线、09-16「一条 prompt 跑完整条线」):
| 会话 | 工具调用 | 积分 | 时长 |
|---|---|---|---|
| 规划覆盖网络任务落地步骤 | 207 | 77.99 | 195 min |
| R3 接续(relay 常驻 + via 切换) | 641 | 72.22 | 166 min |
⇒ **规划棒成本降到旧形态的 1/8–1/12**;且**每棒用户轮数 ≈ 1**(只有 automation prompt 本身,**零人工介入**)。
**取数方法**(已固化为脚本,一条命令出全套):
```bash
"E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" \
"E:/ProgramData/.workbuddy/skills/dsh-auto-handoff-chain/scripts/chain_report.py" "<线名关键词>"
```
它一次输出四段:① 逐会话(工具调用 / 积分 / 积分每次 / **Skill 调用数** / top tools)② 合计 ③ **自动化运行链 + 断链检测**(相邻两棒间隔 > 90 min 报 ⚠️)④ **过期但仍 ACTIVE 的一次性 automation 清单**。
> **实测判读**:09-17 那 8 棒间隔全部 < 90 min(**零断链**);09-16 旧形态连续 6 次 > 90 min(人工介入、无接力)。
底层口径(要自己写脚本时看这三行):
- 会话/自动化元数据 → `<配置目录>/workbuddy.db`(`sessions` / `automations` / `automation_runs`)
- 逐次积分 → 转录 `projects/<工作区目录名>/<sid>.jsonl`,**递归**收集 `rawUsage` 节点求和(⛔ 顶层 `d.get("rawUsage")` 抓不到)
- 工具调用次数 → 同一份 jsonl 里数 `type == "function_call"` 的行;文本元素类型是 `input_text` / `output_text`(⛔ 不是 `"text"`,写错会静默抽空)
---
## 6. 落地清单(新开一条接力链时)
1. **建入口文件** `接续入口_<线名>_<日期>.md`,把状态、定序、`§2「🎯 本轮动作」块` 写进去
2. **让 `state.py` 能读到它**(状态脚本的 `[入口]` 段只展开入口 §2 ⇒ 钉在 §2 顶部才有用)
3. **定序**:把整条线切成「规划棒①→执行棒①→规划棒②→…」,并**写进入口 §0**
4. **起第一棒**:`automation_update` 建一次性 automation(`+2~15 分钟`),prompt 照 §2 骨架填
5. **每棒收尾走完 §3 四件套**(其中第 ② 件**前置 §3.3 登记门禁**)—— 推进 §2 + 登记下一棒
6. **收口**:链条跑完时,最后一棒不登记下一棒,改为**明确告知用户"链条已完结"**
7. **遇到拍板点**:停在「待你拍板」节(候选竖排 + 优缺点 + 我的倾向),**并明确告知链条已暂停**;等用户拍板后再由当时会话登记下一棒
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""接力链体检 —— 一条命令出「某条接力链跑了哪些棒 / 每棒成本 / 有没有断链」。
用法:
python chain_report.py # 默认看全部会话
python chain_report.py "覆盖网络" # 只看标题含该关键词的会话
python chain_report.py "覆盖网络" --json # 追加输出机器可读 JSON
数据源:
<HOME>/workbuddy.db → sessions / automations / automation_runs
<HOME>/projects/*/<sid>.jsonl → 逐次 rawUsage(含 credit)+ function_call 计数
坑(踩过的,别再踩):
* rawUsage 不在顶层,必须【递归】收集;顶层 d.get("rawUsage") 抓不到。
* 文本元素类型是 input_text / output_text,不是 "text"。
* 工作区目录名会随工作区搬迁改变(e-... / d-...),两个目录都要找。
* 本机没有 sqlite3 CLI ⇒ 必须用 python 的 sqlite3,以 mode=ro 只读打开。
* 读文件必须显式 UTF-8(newline=""),否则 CP936 静默乱码。
"""
import sys, os, io, json, glob, sqlite3, datetime, argparse
sys.stdout.reconfigure(encoding="utf-8")
HOME = os.environ.get("WORKBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy")
DB = os.path.join(HOME, "workbuddy.db")
def ts(v):
if not v:
return "-"
try:
return datetime.datetime.fromtimestamp(int(v) / 1000).strftime("%m-%d %H:%M")
except Exception:
return str(v)
def dur(a, b):
if not a or not b:
return "-"
try:
return "%dmin" % round((int(b) - int(a)) / 60000.0)
except Exception:
return "-"
def walk_usage(o, hits):
"""递归收集 rawUsage 节点(顶层抓不到)。"""
if isinstance(o, dict):
for k, v in o.items():
if k == "rawUsage" and isinstance(v, dict):
hits.append(v)
else:
walk_usage(v, hits)
elif isinstance(o, list):
for x in o:
walk_usage(x, hits)
def get_text(d):
"""文本元素类型是 input_text / output_text。"""
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)
def find_jsonl(sid, projdirs):
for d in projdirs:
p = os.path.join(d, sid + ".jsonl")
if os.path.exists(p):
return p
return None
def scan_session(path):
ncall = nuser = nasst = 0
credit = 0.0
tools, skills_missing = {}, 0
with io.open(path, "r", encoding="utf-8", errors="replace", newline="") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
d = json.loads(line)
except Exception:
continue
t = d.get("type")
if t == "function_call":
ncall += 1
nm = d.get("name") or "?"
tools[nm] = tools.get(nm, 0) + 1
elif t == "message":
if d.get("role") == "user":
nuser += 1
elif d.get("role") == "assistant":
nasst += 1
hits = []
walk_usage(d, hits)
for x in hits:
try:
credit += float(x.get("credit") or 0)
except Exception:
pass
return dict(calls=ncall, credit=credit, user=nuser, asst=nasst,
tools=tools, skill=tools.get("Skill", 0))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("keyword", nargs="?", default="")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
if not os.path.exists(DB):
print("找不到 workbuddy.db: %s" % DB)
return 1
con = sqlite3.connect("file:%s?mode=ro" % DB.replace("?", "%3f"), uri=True)
cur = con.cursor()
kw = args.keyword
sql = "SELECT id,title,created_at,last_activity_at,is_background_automation FROM sessions"
sess = []
for r in cur.execute(sql):
if kw and kw not in (r[1] or ""):
continue
sess.append(r)
sess.sort(key=lambda r: r[2] or 0)
projdirs = glob.glob(os.path.join(HOME, "projects", "*"))
print("=" * 96)
print("接力链体检 · 关键词 = %s · 会话 %d 个 · %s" % (kw or "(全部)", len(sess),
datetime.datetime.now().strftime("%Y-%m-%d %H:%M")))
print("=" * 96)
print("%-10s %-12s %-7s %-5s %-8s %-8s %-7s %-6s %s" % (
"sid", "创建", "时长", "auto", "工具调用", "积分", "积分/次", "Skill", "标题"))
rows = []
T = dict(calls=0, credit=0.0, user=0)
for r in sess:
sid, title = r[0], r[1]
p = find_jsonl(sid, projdirs)
if not p:
print("%-10s %-12s %-7s %-5s %-8s %-8s %-7s %-6s %s" % (
sid[:8], ts(r[2]), dur(r[2], r[3]), r[4], "无转录", "-", "-", "-", title))
continue
s = scan_session(p)
per = (s["credit"] / s["calls"]) if s["calls"] else 0
T["calls"] += s["calls"]
T["credit"] += s["credit"]
T["user"] += s["user"]
print("%-10s %-12s %-7s %-5s %-8d %-8.2f %-7.3f %-6d %s" % (
sid[:8], ts(r[2]), dur(r[2], r[3]), r[4], s["calls"], s["credit"], per,
s["skill"], title))
top = sorted(s["tools"].items(), key=lambda kv: -kv[1])[:5]
if top:
print(" top: " + ", ".join("%s×%d" % (a, b) for a, b in top))
rows.append(dict(sid=sid, title=title, created_at=r[2], **{
k: s[k] for k in ("calls", "credit", "user", "asst", "skill")}))
print("-" * 96)
print("合计: 工具调用 %d | 积分 %.2f | 用户轮 %d" % (T["calls"], T["credit"], T["user"]))
# ---- 自动化运行链(断链检测) ----
print()
print("=" * 96)
print("自动化运行链(每行 = 一次触发 = 一个新会话)")
print("=" * 96)
anames = {r[0]: r[1] for r in cur.execute("SELECT id,name FROM automations")}
recs = []
for r in cur.execute("SELECT automation_id, runs_json FROM automation_runs"):
try:
runs = json.loads(r[1] or "[]")
except Exception:
runs = []
for x in runs:
recs.append((x.get("startedAt") or 0, anames.get(r[0], r[0]), x))
recs.sort()
prev = None
gaps = []
for st, name, x in recs:
if kw and kw not in (name or ""):
continue
if prev:
gap = (int(st) - int(prev)) / 60000.0
if gap > 90:
gaps.append((ts(prev), ts(st), gap))
prev = st
print("%-19s ok=%-5s %-6s %-14s %s" % (
ts(st), x.get("success"),
dur(st, x.get("finishedAt")), str(x.get("conversationId"))[:12], (name or "")[:40]))
if gaps:
print()
print("⚠️ 疑似断链(相邻两棒间隔 > 90 分钟):")
for a, b, g in gaps:
print(" %s → %s (间隔 %.0f 分钟)" % (a, b, g))
# ---- 完成态核查 ----
print()
print("=" * 96)
print("过期但仍 ACTIVE 的一次性 automation(once 型跑完不自动转完成态 ⇒ 有补跑窗口)")
print("=" * 96)
n = 0
for r in cur.execute("SELECT id,name,status,scheduled_at,deleted_at FROM automations ORDER BY created_at DESC"):
if r[2] != "ACTIVE" or r[4] or not r[3]:
continue
try:
when = datetime.datetime.fromisoformat(r[3])
except Exception:
continue
if when < datetime.datetime.now():
n += 1
print(" %s | %-40s | 触发时刻 %s | deleted_at=%s" % (r[0][:8], (r[1] or "")[:40], r[3], r[4]))
if not n:
print(" (无)")
if args.json:
print()
print(json.dumps(rows, ensure_ascii=False, indent=2))
con.close()
return 0
if __name__ == "__main__":
sys.exit(main())