按用户令提交:把此前未纳管的 9 个技能目录一并入库

用户令逐字:「E:/ProgramData/.workbuddy/skills  提交仓库是指的这里」——
即本目录就是仓库(2026-10-07 已在本目录建仓,见当日日志 §22),本轮把余下未纳管的 9 个技能一并提交。

本次入库(9 个技能,46 个文件):
1、`AI HOT`
2、`draw-ui`
3、`dsh-diagnose`
4、`dsh-knowledge`
5、`dsh-local-env`
6、`dsh-opensource-release`
7、`dsh-workflow`
8、`oil-motion`
9、`skills-security-check`

提交前核对:
· **凭据类扫描**(`*.env` / `*token*` / `*.key` / `*secret*` / `*.pem`)⇒ **零命中** ✓;
· 体积合计约 20 MB(`draw-ui` 12M + `oil-motion` 6.5M 是大头,形态为配图/素材 ——
  仓库 `.gitignore` 里明写「`assets/*.png` 是内容不是产物」⇒ 属刻意入库);
· 运行产物仍按既定规则排除(`__pycache__` / `logs/` / `tmp/` / `.venv/` / `*.egg-info` / `uv.lock` / `.workbuddy/`)。
This commit is contained in:
admin committed 2026-10-08 22:29:08 +08:00
1 parent d26c844f64
commit e03465c398
46 files changed
+7558

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
---
name: dsh-workflow
description: DSH 多租户平台(ai1net.com / dshs,服务器 47.77.182.89)**作业流程总入口** —— 覆盖两半:① **平台功能改造与优化的完整工作流**(用户提出平台需求 / 改造 / 优化 / 缺陷修复 / 功能调研,或要求"按既有流程执行 / 沉淀 / 整理处理机制"时;含**红线 R1–R11 原文速查**与档案模板)② **长任务「多棒自动接力」编排**(「自动新建会话接续处理」「接力跑下去」「多步骤任务自动推进」「跑完一棒自动接下一棒」「无人值守推进」,或一个任务预计要跨 ≥3 个会话 / 超过一个上下文窗口时)。核心 = **六阶段工作流**(需求识别→调研→规划→开发→验证→归档)+ **红线 R1–R11(任一条命中 ⇒ 先停手)** + 档案模板 + 并行调度协议(三把锁)+ **六件套 prompt 骨架** + **登记门禁(要拍板的先等拍板)** + **收尾四件套** + 七条实测防护 + **排期两条铁律**。判据实体在主干,**全文在 `references/`(2 档)**。⚠️ **分工**:本技能管「**怎么落地**」;「怎么想、怎么定」⇒ `session-mechanism`;「本机环境」⇒ `dsh-local-env`。
version: 1.0.0
updated_at: 2026-09-28
last_change: 【2026-09-28】**由 `dsh-change-workflow` + `dsh-auto-handoff-chain`(v1.7.5)合并而成**(两个原名退役)。按 `dsh-knowledge-upkeep §10`「主干 + 详情档」形态:判据实体留主干、全文下沉 `references/`(**内容守恒:逐行 0 丢失**,含原 frontmatter 变更历史)。⚠️ 两者**不是同一层**(一个是"一次改造的六阶段"、一个是"跨会话接力的编排机制",属**互补**)⇒ 合并只做「**单一入口 + 分诊**」,⛔ **不把两套判据混成一套**,各自全文原样保留为独立详情档。⚠️ **并按对账结论处置了版本分叉**:`change-workflow` 的**文档库版为超集且更新**(红线 **R1–R11** + 已按 2026-09-21 修订的 R8「开发环境直接做」;本机版 R1–R8 是其真子集且 R8 为旧措辞)⇒ 取**文档库版**,并并入其「🔵 作用域 + 宿主落点对照」块;`auto-handoff-chain` 本机版为**超集**(多 §3.1.3「投单 ≠ 启动」等 60 行)⇒ 取**本机版**。⛔ 未删任何判据,⛔ 未丢掉任何一条红线。
agent_created: true
---
# dsh-workflow — DSH 平台作业流程总入口
> ## 🔴 第 0 步:先分诊(两个层次别混)
>
> | 情形 | 去哪 | 一句话 |
> |---|---|---|
> | **一次**平台改造 / 优化 / 缺陷修复 / 功能调研(要落地) | **§1 六阶段** | 单次任务的完整流程 |
> | 判断某事**该不该做 / 怎么选型** | ⛔ 不属本技能 ⇒ `session-mechanism` | — |
> | 任务**要跨多个会话 / 超过一个上下文窗口**,要无人值守推进 | **§2 多棒自动接力** | 把长任务拆成棒次链条 |
> | 只是**一条**自动化接续(做完即停) | 两者都用:§1 出活、§2 排棒 | — |
---
## 1. 平台改造六阶段(一次改造怎么落地)
**六阶段**:**需求识别**(先盘点再动手,⛔ 勿跳步)→ **调研**(**源码级实证优先**,⛔ 禁止只靠文档推断)→ **规划**(文档先行,方案确认后动工)→ **开发**(小步 + 备份 + 逐条验证)→ **验证**(全链路 + 浏览器实测 + 清理)→ **归档清理**(文档同步 + 三层沉淀)。
## 🔴 红线 R1–R11(原文速查 · 任一条命中 ⇒ **先停手**;效率论证不构成豁免)
1. 禁止启动 dsh 时自动获取最新版本(升级走独立流程)。
2. **不改官方 dsh 主程序与缓存**;扩展只走 profile 层官方插件机制。
3. **client bundle 严禁 `exports.default = apply`**(或任何 default 函数导出)。
4. **R4|禁止借真实账号(admin/guest)的会话跑测试**(⚠️ 旧理由已自 2026-09-20 失效 —— 已改为多会话并存)。
5. **R5|权限可见面只准收窄,扩大必须先确认**。
6. **R7|禁止未经确认的批量 / 全仓写入**。
7. **R8|生产变更知会**(2026-09-21 修订)—— 开发环境服务器 ⇒ **直接做**,只需动手前一句说明 + 攒批;⛔ **不是"先取得确认"**。
8. **R9|⛔ 绝对禁止「人工删锁 / 接管」** —— 锁**只能由持有者自己释放**;抢不到 = 终点 ⇒ 只读、报告、结束。
9. **R10|⛔ 绝不以 root(或非该实例 uid)运行 / 触碰用户实例的东西**。
10. **R11|⛔ 只做正向迭代** —— 十维判据(目标/方向/架构/功能/性能/安全/交互/UI/便利性/扩展性)**是否让任一维净变差**?命中 ⇒ **立即停下复盘**。
> 📂 **R5/R7/R8 的由来与完整判据** ⇒ `references/dsh-change-workflow/02-红线详解-R5-R7-R8.md`(判「算不算批量写入 / 要不要先知会 / 是扩大还是收窄」时**必读**)
**另两条硬机制**:**并行调度协议**(按**冲突域**定并行度 + **三把锁**:全局执行锁 / 单级占用锁 / 服务器侧操作锁)|**档案模板**(改造档案的固定骨架)。
📂 **全文 ⇒ `references/00-平台改造六阶段.md`**(含六阶段细目、红线完整原文与解释、档案模板、并行调度详解、运维锚点与取证、插件与数据源口径、实例可见面与共享边界、浏览器验证栈)
📂 **附属档目录 ⇒ `references/dsh-change-workflow/`**(`00-平台速查` · `01-档案模板` · `02-红线详解-R5-R7-R8` · `03-沙箱与技能机制` · `04-运维锚点与取证` · `05-插件与数据源口径` · `06-实例可见面与共享边界` · `07-并行调度详解` · `08-浏览器验证栈详解`)
---
## 2. 多棒自动接力(跨会话长任务怎么编排)
**形态**:`规划棒①(出交接单)→ 执行棒①(照单落地)→ 规划棒② → …` —— 每棒 = **一个全新会话 + 一条一次性自动化**,做完**自己把下一棒排上**,**全程零人工点击**。
⇒ 这是「**规划与执行分离**」从纪律变成**机制**:规划棒**物理上碰不到生产**。
**六件套 prompt 骨架(照抄填空)**:① 状态单点 → ② 唯一执行依据指针 → ③ **全局锁** → ④ 单一动作 → ⑤ **成本纪律** → ⑥ **收尾四件套**。
**收尾四件套(缺一即算未完成)**:① 释放锁 ② **过登记门禁**后登记下一棒 + **用陈述句告知用户** ③ 把入口「本轮动作」推进到再下一棒 ④ 写工作区日志。
> ⚠️ 第 ② 件是**唯一会"断链"的地方**,也是**钩子做不到**的地方(钩子不能创建会话 / 自动化)。
🔴 **登记门禁(顺序不可颠倒)**:**先判「是不是要拍板」,未命中才轮到「候选排不排得出优劣」**。下一棒若含**边界外事项** ⇒ **不登记**,停下等拍板;拍板到手后再建。
🔴 **排期两条铁律**:**同一时刻只挂一个**;首个(唯一)接续棒 = **收口 + 3~4 分钟**(🔴 2026-10-01 用户口径:「接续会话 时间缩短 3-4 分钟即可」⇒ 原 5~8 作废)。⚠️ **改时间不会触发** ⇒ 重排**必须新建**一条。
🔴 **七条实测防护**(都是真踩过的):断链 / 双开 / once 不转完成态 / 跨过拍板点 / 下一棒定太晚 / **投单 ≠ 启动**(工单不会自己变成动作 ⇒ 光投单+加指针,automation 全表可能**无一条**)/ 工作区归属与 `cwds` 归一(⛔ 错一字面即裂组,且**裂组会自我强化**)。
📂 **全文 ⇒ `references/01-多棒自动接力.md`**(含六件套骨架原文与为什么长成这样、收尾陈述句模板、七条防护、实测成本基线(连续 8 棒零断链)、工作区归属五律、活库改动铁律、§3.1.3「投单 ≠ 启动」、域锁四个"假绿"坑、落地清单)
📂 **附属档 ⇒ `references/dsh-auto-handoff-chain/`**(`chain_report.py` · `audit-mirror.py`)
---
## 3. 详情档索引(跨档引用按此表定位)
| 档 | 覆盖的原技能 | 原章节 |
|---|---|---|
| `references/00-平台改造六阶段.md` | `dsh-change-workflow`(**文档库超集版**,全文) | 阶段 0 需求识别 / 阶段 1 调研 / 阶段 2 规划 / 阶段 3 开发 / 阶段 4 验证 / 阶段 5 归档清理 / **红线 R1–R11 原文速查** / 并行调度 / 三把锁 / 档案模板 / 变更历史 |
| `references/01-多棒自动接力.md` | `dsh-auto-handoff-chain`(**v1.7.5 本机超集版**,全文) | §0 分工 / §1 形态 / §2 六件套骨架 / §3 收尾四件套与登记门禁 / §4 防护 / §5 成本基线 / §6 落地清单(含 §3.1.3「投单 ≠ 启动」) |
| `references/dsh-change-workflow/` | 原附属档 | 9 档(平台速查 / 档案模板 / 红线详解 R5·R7·R8 / 沙箱与技能机制 / 运维锚点与取证 / 插件与数据源口径 / 实例可见面与共享边界 / 并行调度详解 / 浏览器验证栈详解) |
| `references/dsh-auto-handoff-chain/` | 原附属档 | `chain_report.py` · `audit-mirror.py` |
🔴 **两个原名已退役**(`dsh-change-workflow` / `dsh-auto-handoff-chain`)⇒ 别处见到按本技能对应档读。
⚠️ **为什么保留为两档而不是揉成一份**:两者**层次不同**(单次改造 vs 跨会话接力),属**互补**而非重复 ⇒ 唯一入口在主干,⛔ 判据不混。
⚠️ **红线以本档为准**:`references/00` 里是 **R1–R11**(含 R9/R10/R11);若在别处看到只到 **R8** 的旧清单,**以本档为准**(那是 2026-09-21 之前的版本)。
@@ -0,0 +1,412 @@
# 平台改造六阶段 + 红线 R1-R11 原文速查
> **归属**:技能 `dsh-workflow` · 详情档(主干 `../SKILL.md`)
> **本档覆盖**:原技能 `dsh-change-workflow` **全文**(正文 288 行 + frontmatter 变更历史)
> **provenance**:文档库版(**超集**:含 R9–R11 与已修订的 R8;本机版 R1–R8 是其真子集)
> **搬运方式**:**逐行未改**;内部附件链接已改写为 `dsh-change-workflow/<附件名>`(附件在本目录下)
> ⚠️ 原技能 `dsh-change-workflow` **已合并退役** ⇒ 见到该名按本档读。
---
> 🔵 **作用域(2026-09-26 从 WorkBuddy 用户级技能迁入)**:本技能描述的是 **dsh 平台项目**的作业方法,已提升为 DSH 全局技能,供任何会话发现与加载。
> ⚠️ 正文里的**宿主路径与脚本位置是迁移时的旧值**(如 `~/.workbuddy`、`CODEBUDDY.md`、`07-scripts/`、`文档库`)。~~在 DSH 上以**当前工作区的规则文件**与 `$DSH_HOME` 下的实际落点为准~~ 🔴 **2026-10-02 纠正:宿主是 WorkBuddy、不是 DSH;`$DSH_HOME` 本机已悬空** ⇒ 以**当前工作区的规则文件** + `E:\ProgramData\.workbuddy\skills\` 下的实际落点为准;⛔ 不要把正文里的旧路径当现行事实。
> ⚠️ 冲突时:**工作区规则 > 本技能**。
### 🔴 宿主落点对照 —— ⚠️ **本节方向已作废(2026-10-02 纠正)**
> 🔴 **左右方向与现状相反,⛔ 不要再照它执行。** 下表是 **2026-09-26 从 WorkBuddy 迁往 DSH 当时**
> 的形态(让人把 `~/.workbuddy/08-skills/<n>/` 换成 **`$DSH_HOME/skills/<n>/`**)。
> **现状**:宿主是 **WorkBuddy**(不是 DSH);技能真身就在 `E:\ProgramData\.workbuddy\skills\<n>\`;
> 而 `$DSH_HOME`=`E:\ProgramDSH\.dsh` 在本机**目录已不存在**(悬空老值,只剩环境变量还留着)。
> ⇒ 正确方向是 **WorkBuddy ← DSH**:正文里见到 `$DSH_HOME/skills/<n>/`,换成
> **`E:\ProgramData\.workbuddy\skills\<n>\`**。
**下表保留仅为留痕(右列=已作废的旧落点)**:
| 正文里的旧路径 | ~~DSH 侧实际落点~~(**已作废**) |
|---|---|
| `~/.workbuddy/08-skills/<n>/`、`.workbuddy/08-skills/<n>/` | ~~**`$DSH_HOME/skills/<n>/`**(技能根,被 watch,改动下一请求生效)~~ 🔴 **作废:方向反了**,现行落点=**`E:\ProgramData\.workbuddy\skills\<n>\`** |
| `.workbuddy/memory/MEMORY.md`、`~/.workbuddy/MEMORY.md` | **`$DSH_HOME/AGENTS.md`**(全局,每轮注入)/工作区 `AGENTS.md`(项目级) |
| `.workbuddy/memory/YYYY-MM-DD.md`(按日记忆) | **无对等物** ⇒ 教训写进本技能 `references/` 或项目台账「实测教训」节 |
| `CODEBUDDY.md`(项目常驻规则) | **同一份文件仍生效**(已在 preset 覆盖里加入 DSH 的指令候选),无需改名 |
| `07-scripts/<x>.py`(文档库脚本) | 同左,但**路径必须写全**:`D:\github\dsh_shenxian\dsh-server-docs\07-scripts\<x>.py` |
| `~/.workbuddy/settings.json` 的 hooks | **`$DSH_HOME/hooks/hooks.json`** + `dsh-guard.py` |
| WorkBuddy `automation_*` / 积分口径 | **无对等物**(见 `dsh-auto-handoff-chain` 的不可移植清单) |
> 🔴 **表尾收口(2026-10-02 补)**:**上表每一行的右列都属同一"迁往 DSH"的旧形态,一律作废** ——
> 尤其第 2 行(`$DSH_HOME/AGENTS.md`)与第 6 行(`$DSH_HOME/hooks/hooks.json`):本机**没有 `$DSH_HOME`**。
> 已实测的现行落点只有三条,照这三条找:
> ① **技能根** = `E:\ProgramData\.workbuddy\skills\<n>\`;
> ② **hook 注册面** = `E:\ProgramData\.workbuddy\settings.json` 的 `hooks` 段(2026-10-02 实测:本轮就是往那儿加的第 6 条);
> ③ **记忆** = 用户级 `~/.workbuddy/MEMORY.md`(跨项目)+ 工作区 `<工作区>/.workbuddy/memory/`(按日 + 长期)。
> ⛔ 上表左列里凡写 `$DSH_HOME/…` 的,**不要再当落点用**;`E:\ProgramDSH\` 整树已于 2026-09-28 删除
> (见 `dsh-local-env/references/01-环境引导与迁移.md` §"同名影子树")。
> ⚠️ 正文其余处出现的旧路径属**历史记录**(当时的事故与实测),**保持原样不改** —— 改写历史记录等于造假。
---
# dsh-change-workflow — dsh 平台改造工作流
dsh 多租户平台(服务器 47.77.182.89,dshs + dsh 0.1.2-rc.1)功能改造的标准处理方法。把平台运维历史中反复验证过的处理方式固化为可执行流程:**先识别边界 → 源码级调研 → 方案对比确认 → 小步开发 → 全链路验证 → 归档清理**。
> 📂 **平台速查表(域名 / 生效路径 / 端口 / 存量事实)已下沉** → `dsh-change-workflow/00-平台速查.md`
> ⛔ **两条留在这里的判据**:① 正域名 = `ai1net.com`(`alotbuy.com` 是已降级的 301 旧域 —— 抓它只会拿到 301 HTML,**别误判为「改动没生效」**);② 平台页由门户直出(`127.0.0.1:3080`),生效副本只有 `/opt/dshs/web/` 一份。
## 六阶段流程
### 阶段 0 · 需求识别(先盘点再动手,勿跳步)
0. **⛔ 开工前置检查(2026-09-11 用户点名要求,血的教训)**:**这是本技能最重要的一步**。
病根 = 不先查「已经有什么」就凭直觉装工具/猜机制;出错后要用户追问,才把**早就写在文档里的说明**翻出来
(最刺眼的一次:我早在技能里写了「勿走 agent-browser」,自己却忘了,绕最远的路)。
任何「**装工具 / 写自动化 / 查机制**」之前,按序做完这三条:
1. **先看可用技能列表**(会话开始就在)→ 有对应的**立刻加载技能**,禁止另起炉灶;
2. **先看本机/项目已有资产**:`$DSH_HOME\skills\`(尤其本技能,技能根被 watch、改动下一请求生效)、`MEMORY.md`;
3. **要装任何东西前自问:本机已有的能否满足?** 能→不装;不能→**先说清楚再装**。
→ **固化结论(别再重新发现)**:浏览器自动化**只允许一件** —— **`browser-harness`**(调用姿势 ⇒ 本节末指针);
⛔ **`agent-browser` 已禁用**(2026-09-25 用户明令:「**改为 browser-harness,只允许使用这个**」);
⛔ **禁止 Playwright / `playwright-core`**(2026-09-13 用户明令);
静态页与 API 取数用 `curl`/WebFetch,别动浏览器。
1. **环境盘点**:本机/服务器/工具链/权限/DB/实例状态先行(历史教训:未盘点就默认本机装 Docker 走错路线,被用户批评)。
2. **需求边界**:区分"方案请求"(只输出方案,P0 硬规则:**方案与执行分离,未经明确授权不改文件**)与"任务明确直接执行"。
3. **用户确认**:关键分叉用选项表收敛(`ask_user_question`:推荐项置首标注「(Recommended)」,2-4 选项,不设「其他」占位)。
4. **输出格式**:一句话结论先行 + 表格化细节(状态/结果列)。
### 阶段 1 · 调研(源码级实证优先,禁止只靠文档推断)
0. **★ 用户报障第一步:先拿「真实失败请求」,别猜**(2026-09-11 档案 51 实证,省掉全部试错)。
用户只会说"报错/没反应/发消息失败",而**平台 journal 里有精确的 URL + method + status**:
```bash
ssh ... 'journalctl -u dshs --since "-30min" --no-pager \
| grep -E "401|403|404|500|502|503|crash-restart|dsh-child|not_running|YAMLException|ERR_MODULE" \
| grep -v "level.:30.*200"'
# 顺带看 host / remoteAddress(= 用户真实 IP)定位是谁、连的哪个子域
```
判据速查:
- `POST /api/session/prompt → 401` = **实例侧鉴权**(档案 51 的旧 cookie 问题);
- `502` = 实例根本没起来 → 翻同时间段的 `YAMLException` / `ERR_MODULE_NOT_FOUND` /
`crash-loop-circuit-open`(档案 52 就是被这一步牵出来的);
- 401 先分辨**平台 401**(body `{"error":"unauthorized"}`)还是**实例 401**
(body `dsh web authentication required…`)——**改的地方完全不同**。
1. **只读分析官方包**(允许):`/usr/local/lib/node_modules/@deepseek-ai/dsh/...` 内 bundle 读代码定位机制(slot 声明、inject 契约、fiber 注入)。**绝不写官方主程序**。
2. **服务器实测 > 推断**:区分 fence 层 / 应用层错误;浏览器问题抓 console **完整 err 对象**(`description`/堆栈,勿只看字符串字段——`at new apply` 类堆栈是定位关键)。
3. **双端对照**:archive 源 ↔ 实例安装副本 md5 对账;官方行为对照第三方插件写法差异逐字段对比(如 register 的 `locale`/`children`/`label` 函数形式)。
4. **多模型交叉验证**:关键结论交叉核对,不轻信单点推断。
### 阶段 2 · 规划(文档先行,方案确认后动工)
1. **方案对比表**:≥2 选项 + 各自影响/风险 + 建议(用户偏好:结构表对比+理由)。
2. **红线自查**(每项改造必过):
- R1 不触发 dsh 自动获取最新版本;版本升级走独立"升级测试→评估→修复"流程(档案 07)
- R2 不改官方 dsh 主程序与缓存;扩展只走 profile 层 `dsh plugin remove/add`(tgz)官方机制
- R3 client bundle **严禁 `exports.default = apply`**——loader ESM/CJS interop 会取纯函数为插件主体 → 无 inject → `ctx.<svc>` 抛 `cannot get property ... without inject`;官方 bundle 只导出 `apply`+`inject`
- **R4 禁止借真实账号(`admin` / `guest`)的会话来跑测试** —— 🔴 **理由已于 2026-09-20 更换(序47 落地)**:
~~"`auth.ts` 登录时 `deleteUserSessions(user.id)` 是 last-wins 单活跃会话,**会当场踢掉用户正在用的浏览器会话**"~~
—— **last-wins 已被序47 取消**(现为**多会话并存 + 每用户上限 + 显式登出全部**,见
`04-调整方案/145-…md` 与 `04-调整方案/08` 头注)⇒ ⛔ **不得再拿"会踢人"当理由**(那是本技能 R4 的旧理由,
照旧用会得出相反判断)。**现在仍然禁用的三条真实理由**:
① 借来的会话**办不了该用户自己的事**(停实例/回收/重建/看 profile 文件——见下面那条"独立账号");
② 会在真实账号的会话列表与 `audit_log` 里留下**不属于该用户的一次动作**(观测面被污染);
③ 真实账号的会话被拿去做破坏性验证(`logout-all` / `revoke`)时,**真会把用户踢下线**。
要测登录态一律用**专用测试账号**(注册 → 审批 → 用完即删)或 `node /opt/dshs/mksess.cjs`(**PG 直插**)建临时 session
- **专用测试账号模板(2026-09-11 实证)**:`POST /api/auth/register` → DB
`UPDATE users SET role='active', approved_by=<真实 admin id>`。⚠️ 两个坑:① `users` 表**没有 `status` 列**,
审批就是改 `role`,CHECK 只允许 `admin/pending/active/disabled`(写 `'user'` 会 CHECK 失败);
② `approved_by` 有 **FK 指向 `users.id`**,填字符串会 `FOREIGN KEY constraint failed`。
⚡ **更省事的等效做法(2026-09-20 序47 实跑验证)**:直接 `INSERT INTO users (...) VALUES (...)`,
`pass_hash` 用产品自己的 `hashPassword`(`lib/web/auth.js`)现算(`scrypt$salt$hash`),`role='active'`,
`uid` 取 `max(uid)+1` ⇒ **绕开邮箱验证码与 Turnstile**(注册页两道门都开着,见档案 134)。
**凡要"操作该用户自己的实例"(停实例/回收/重建/看它的 profile 文件)→ 必须用独立账号**,
不能借 admin/guest 的 session(借了就没法停实例、也污染真实用户)。用完 `DELETE sessions + users` 并 `rm -rf users/<id>`
⚠️ **拿新版平台验证登录态时还有一个坑**:`sid` cookie 是 `Secure` + `Domain=.ai1net.com` ⇒
本机 `curl -c <jar>` **不会**为 `127.0.0.1` 存这条 cookie(jar 文件干脆不生成)⇒ 后续请求全是**假 401**。
正确做法 = **从 `set-cookie` 响应头里直接取 `sid` 值**,再用 `-b "sid=$VAL"` 带上。
- **R5 权限可见面只准收窄,扩大必须先确认**(2026-09-11 用户新增)——**先判方向**:这次改动是"扩大"还是"收窄"?命中「扩大」定义(新增 bwrap 挂载 / 放开遮蔽 / 暴露平台目录或 env / 放宽 `ALLOWED_ENV` / 放松 nft / 提高权限档位或放宽 approval / 新增用户可读写路径 / 让 root 执行链路的对象变成用户可控)→ **必须在方案里写出「权限影响评估」四问并等用户明确同意**,不得顺手做;改了「触发文件」清单里的文件就一律按 R5 走。收窄可直接做,但遮蔽类必须**真启动一次实例**验证(档案 42:遮蔽 `/usr` 内文件让实例起不来)
3. **影响评估**:实例重启会换端口(authority 变)→ 旧 WS 断连预期;登录 session 删除预期;数据目录权限影响。**若命中 R5,另附「权限影响评估」四问 + 改动前后 diff「实例可访问路径清单(权威版)」**。
4. **文档占位**:`04-调整方案/` 建编号档案(模板见下),先写需求与改动设计。
### 阶段 3 · 开发(小步 + 备份 + 逐条验证)
**服务器 TS/配置改码标准流程**(多文件改动):
1. `scp` 服务器源码 → 本地工作区 → 本地 Edit(**读一条→改一条→验证一条**,禁止批量读全部文件)
2. `scp` 回服务器 `/tmp/` → `cp` 覆盖前先备份 `bak-<功能>-<YYYYMMDD>/`
3. `npm run build`(tsc 零报错)→ `systemctl restart dshs`
4. 本机直连验证(绕 DNS/代理):
```bash
# SECURE_COOKIES=true → 必须走 TLS;用 --resolve 把域名指到回环
curl -sk --resolve ai1net.com:443:127.0.0.1 https://ai1net.com/api/...
# 带登录态:-H "Cookie: sid=<token>"
```
(明文 `http://127.0.0.1:3080` 在 SECURE_COOKIES 下不通)
> ⚠️ **全量覆盖 `/opt/dshs/lib/` 之前,必须先在 HEAD 上 `npm run build`**(2026-09-13 22:16 实测事故)
> 并行会话做「整包 lib/ 覆盖」时用了一份**不含他人已提交改动**的陈旧构建 ⇒ **把别人的已上线修复整体回退**(本例:`/api/dsh/status` 的 `quota` 字段凭空消失)。
> - **指纹**:`lib/**` 一整批文件 mtime 相同 + 服务重启时间对得上 ⇒ 就是整包覆盖,不是点改。
> - **判据**:部署后立刻 `md5sum` 对账本机构建,或 `grep -c <你上次加的符号>` 目标产物。
> - **铁律**:**部署完立刻复验「上一次刚验收过的东西」** —— 本例正是靠这一步才发现的。
> - 另:**「已部署」≠「已提交」**;**不能拿服务器状态当版本事实源**(服务器曾跑着未 commit 的版本)。
**客户端插件(client bundle)部署流程**:
> ⚠️ **动手改之前先做「基线校验」(2026-09-13 补,改第三方 tgz 插件时尤其必须)**
> 插件以 tgz 铺到实例,**本地那个「源码目录」不一定是线上正在跑的那份**(可能漂移 / 被别的会话改过 / 是旧快照)。
> 步骤:把**服务器上正在用的 tgz** 拉下来解包 → 与本地源码目录做**忽略行尾**的全量比对(`diff --strip-trailing-cr`)→
> 逐文件确认「**差异只有我准备改的**」。否则可能用一套漂移的代码**覆盖线上**(本仓有过同类事故)。
> - 只看文件名/版本号不算校验(`package.json` 里的版本可能与实际不符;本仓 `lib/index.js` 的 `VERSION` 常量就长期落后于 `package.json`)。
> - ⚠️ **行尾会制造假差异**:`npm pack` 不规范化行尾,手工拼装的 bundle 常是**混用行尾**(实测:线上 `client.js` CR=5222/LF=5547,本地纯 LF),去掉行尾后整文件的「差异」会瞬间收敛到真实改动。
> - 反例(我犯的):**先改后验**。顺序错 = 风险窗口白开一段;改完再验只能证明「没出事」,证明不了「不会出事」。
1. archive 源改版(`/opt/dsh/docs/04-调整方案/poc/<plugin>/lib/client.js` + package.json version/description)
2. `npm pack` → tgz 拷到用户 `ws/`(chown 用户 uid)→ `setpriv --reuid=<uid> --regid=<gid> --init-groups env HOME=... DSH_HOME=... dsh plugin --profile web remove/add <tgz>`
3. kill 实例进程 → 门户自动 respawn 新 pid+**新端口** → `journalctl -u dshs` 抓 `dsh web: http://127.0.0.1:<port>/?token=...`
4. 验证 node_modules 副本 markers(version、关键字符串计数——区分代码 vs 注释行)
### 阶段 4 · 验证(全链路 + 浏览器实测 + 清理)
1. **API 链路**:portal enter(sid)→ url token → 页面 200 → `llm/listProviders ok:true`
- **★ 验证用例必须同时覆盖「有请求体」与「无请求体」两类**(2026-09-11 档案 51 事后修正实证):
只测 `POST` 会整条漏掉 `GET`/`SSE`。本次补丁初版闸门写成 `mayHaveBody && …` →
**POST 全绿、`GET`/SSE 完全不生效**,而 SSE 恰恰是"页面已打开、实例被回收"时最早撞 401 的链路。
凡改动含"按 method / 有无 body 分支",**`GET` 那条必须单独跑一遍**。
- **模拟浏览器语义要测到底**:断言不只是状态码,还要**只带最初那一个旧 cookie、忽略任何新 cookie**
再跑一遍(这才等价于真实浏览器)。修完后新 cookie 是否**回写**(`Set-Cookie`)必须显式断言。
2. **浏览器验证栈(只走 `browser-harness`;⛔ `agent-browser` 与 Playwright 均已禁用)**:
### ⛔ 铁律 0:动手前先 `list_tabs()` 确认「你在驱动哪个浏览器」
⛔ **不要假定端口号** —— 端口绑定会变,**断言端口=把当日观察写成永久结论**:
- 2026-09-11 实测:`BU_CDP_URL=9223` **不被尊重**(daemon 常驻共享,`BH_RUNTIME_DIR_SHARED=1`),
换 `BU_NAME` + 非共享 runtime 重建 daemon 后 `list_tabs()` **仍返回用户的 16 个标签页**
(`admin.ai1net.com` / `guest.ai1net.com` / `portal.html#/plugins/manual`)。
- 2026-09-26 实测:**9222 被用户浏览器占(PID 25272)· 9223 空闲** —— 与 09-11 的归属**相反**。
⇒ **判据不是端口号,是 `list_tabs()` 返回的标签页是不是你自己开的**。端口每次现测:
`curl --noproxy '*' http://127.0.0.1:<port>/json/list`。
**所以:脚本第一步永远是 `print([t["url"] for t in list_tabs()])`。**
- 只要看到的**不是**你自己新开的空浏览器 → **立刻停手**,只做只读检查。
- **禁止**在未确认隔离前做任何有副作用的动作(登录 / 输入 / 提交 / 关闭标签)。
- 已造成的真实事故:在用户浏览器里 `fetch('/api/auth/login')` → **覆盖其 `.ai1net.com` 的 `sid`**,用户在该浏览器里变成测试账号身份。
补救 = 删掉测试账号(cookie 失效 → 用户重新登录即可)。**收尾必须 `close_tab` 关掉自己开的标签。**
> 📂 **浏览器验证栈的调用细节已下沉** → `dsh-change-workflow/08-浏览器验证栈详解.md`(正确调用 · dsh composer(Lexical)输入难点 · agent-browser 旧实测记录 · 独立 headless 定型做法 · 三个必踩坑)
> ⛔ **留在这里的判据**:只用 `browser-harness` **一件**(⛔ `agent-browser` + Playwright 全面禁止);**动手前第一步先 `list_tabs()`**;**绝不附着用户日常 Chrome**。
3. **静态文件 vs 后端改动**:改 `web/*.html` **无需重启**(静态直出,实测服务启动时间不变);改 `src/**` 才 `npm run build` + `systemctl restart`。**别为前端改动重启服务**(会打断所有用户实例)。
4. **安全面复测**:降权 uid 探测敏感目录(/root、/etc/shadow、DB)应 denied;中间目录 711(去 r 留 x)不破坏子进程访问。
5. **清理临时物**:`poc-*` session 用完即删(DB DELETE);测试 tab 关闭;临时 tgz/脚本归档或清。
6. **截图留证**:每功能验收存 PNG 到本地 tmp + present_files 展示。
7. **🔴 交付前必问一句:这份改动「出厂」了吗**(2026-09-13 实证 —— 用户第 2 次反馈「样式还是没变」的**真因之一**):
改完 `lib/*.js` **≠** 用户会看到变化。出厂链固定为
**改文件 → bump `package.json` 版本 → `npm pack` → 上传 `/opt/dsh/artifacts/<包>-<版本>.tgz` → `node /opt/dshs/07-scripts/ensure-<包>.cjs --all --restart`**。
- **一秒自检**:`ls -la` 对比「源文件 mtime」与「最新 tgz mtime」—— **源文件更新 ⇒ 还没出厂**。
- ⚠️ 打包脚本里带断言时**先把锚点核对存在再跑 `npm pack`**:断言若在写文件前抛错,版本号没改但 tgz 已被覆盖 ⇒
得到「内容是新版、版本号仍写旧版」的**假产物**(本次已踩,靠手工删该 tgz 兜住)。
- 缓存不是借口:`src/supervisor/proxy.ts` 已对 `/plugins/`、`/assets/` 下发 `Cache-Control: no-cache`,且模块 URL 自带内容 `rev=<sha1>`
⇒ **bundle 一变浏览器下次请求就拿到新的**(「看不到变化」几乎都是没出厂,不是缓存)。
8. **视觉/UI 交付必须三层验收,且要拿到「真正下发到浏览器的那份」**(2026-09-13 定型):
| 层 | 做什么 | 能发现什么 |
|---|---|---|
| ① 单测 | `07-scripts/verify-*.mjs` 断言 **class 名与结构**(不只断言文本),关键结构逐项写死(例:**5 个弹窗都必须是 `.table-wrap + table.tbl`**) | 结构走样、分支没改到 |
| ② 实装 | `md5sum` 对齐 **本地产物 / 各 profile 的 `node_modules` 实链**,并断言**旧标记为 0** | 没铺发、铺了旧版 |
| ③ 端到端 | `mksess.cjs` 临时会话 → 取实例首页 → 解出 `/plugins/??…&rev=<sha1>` → `curl` 回 bundle → grep 新/旧标记(**用完即删临时会话**) | bundle 没下发、下发的是旧内容 |
> 只做 ① ⇒「脚本全绿但用户说没变」;只做 ①② ⇒ 漏掉「用户拿到的那份」。
9. **🔴 用户说「参考门户/某个现成页面的样式」时:抄那个页面的源码,不要抄规范的抽象条目**(2026-09-13 第 3 次踩):
正确动作 = 打开 `web/portal.html` / `web/admin.html` 的 `<style>`(或它 link 的 `design.css`),
把 `.nav-card` / `table.tbl` / `.badge` / `.btn-sm` / `.card-h` / `.page-title` 的**实际取值逐条搬过来**,
并在注释里写明「本类名 ← 门户某类名」的对应表(便于复核)。
⛔ 反面:只引 `01-规范/06-工作台UI规范 §4.5/§4.7` 的抽象条目再自行演绎 —— 用户会说「还是没变」。
唯一允许的差异:**颜色走 dsh token `--dsw-*`**(嵌在 dsh 面板内须随主题);字号/间距/圆角/动效与门户**逐值一致**。
### 阶段 5 · 归档清理(文档同步 + 三层沉淀)
0. ★★ **交付门禁:本机改完 ≠ 交付**(2026-09-15 加 —— 同类已发生 **3 次**:09-13「只做到本地打包、没部署」→ 用户「点开看还是和之前一样」;09-14「应用更改是哪里…还是和之前一样」;09-15 会话原话「**平台那半我只改了本机,从没部署到服务器 —— 那你看的当然还是旧页面**」)
**收尾前逐项自问三句:① 这次改的是哪一层?② 这一层的生效链路是什么?③ 最后一步走了吗、在「用户可见面」验了吗?**
| 改动所在层 | 生效链路(**缺一步都不算交付**) | 用户可见面复验 |
|---|---|---|
| **平台静态页**(`web/*.html`、`web/i18n.js`、`portal.html`…) | `scp` 到服务器对应路径(如 `/opt/dshs/web/`)→ 注意 **CDN/CF 缓存**(2026-09-14 实测静态资源被 CF 缓存 **31 天**) | 用 `curl` 取**线上**页面(带 `?lang=` 等参数)确认新内容;必要时 cache-busting |
| **平台 TS**(`src/**`) | `npm run build` → **`systemctl restart dshs`** | 线上端点 / 页面实测(**不是**本地 build 通过) |
| **自研插件**(`poc/*`、client bundle / host 面) | 打 tgz → **admin 导入候选池 → 实例「功能管理」启用** → **重启实例**(client bundle 在**实例启动时**加载,见 `01-规范/06-工作台UI规范.md §7`) | 取实例页 HTML 里真实 bundle URL **逐串验证**(`rev` 已变) |
| **文档库 / 技能** | `docs-sync-check.sh` 对账 → `scp`(同相对目录)→ **复跑对账** | 对账「一致」计数回升、且**残留项全是别人的 lane** |
| **配置**(env / nginx / nft / 配额) | 改 → reload / restart 对应服务 | 生效值实测(`systemctl show` / `curl` / `nft list`) |
⛔ **四条"自我安慰",一条都不算交付**:**本机改完了** / **build 通过了** / **本地打包完成** / **已 commit 了**。
⛔ **也别回头问用户「要不要部署」**:不打断在线用户的上线属**我的 lane 内执行细节 ⇒ 直接做完**(`CODEBUDDY.md §3 R7-边界` + 素材库 **U20 / X9**);只有**不可逆破坏性操作**与**边界外六类**才先问。
📌 与**阶段 4** 的分工:阶段 4 验"**改对了没有**"(三层验收);本门禁验"**东西到用户那儿了没有**"(链路完整性)。
1. **文档落盘(2026-09-13 现状:本地工作树 = 源,服务器 = 只读镜像)**:
- **源** = 本地 `D:\github\dsh_shenxian\dsh-server-docs`(**目录即 git 工作树**),推 Gitea `dsh_shenxian_doc.git` 的 **main**。
- **四件套(顺序固定)**:`docs-audit.py` → `docs-index-stats.py --write` → `docs-manifest.py` → `docs-sync-check.sh`;另有 `docs-consistency.py`。
- **传服务器**:用**单个 `tar` 管道**(逐个 `scp` 会因每条一个 SSH 连接而超时):
`tar -cf - <相对路径…> | ssh bt-server 'cd /opt/dsh/docs && tar -xf -'`,随后 `chmod`(目录 700 / 文件 600)。
- **对账**:`bash 07-scripts/docs-sync-check.sh`(**服务器镜像无 `.git`**,只能靠 md5 对账;要点:`core.autocrlf=false` + `.gitattributes` 的 `* -text` 必须保持)。
- ⛔ **`docs-status-sync.sh` 已不存在**(旧流程残留,**勿再引用/调用**);状态摘要仍在 `/opt/dsh/docs-status/文档库状态备注.md`。
- ⚠️ **本库多会话共用** ⇒ 提交前先 `git status`,**只 `git add` 自己改的文件**;别人在途的改动(2026-09-13 实例:档案 76 md、`08-skills/dsh-opensource-release/SKILL.md`、`docs-manifest.json`)**一律不提交、不 scp**。
`docs-manifest.json` 是**派生文件**:它描述的是整库状态,若别人有未提交改动,**跟着一起提交会把别人的半成品「顺手上锁」**⇒ 此时宁可让它保持 dirty。
2. **档案更新**:`04-调整方案/<NN>-*.md` 写"需求→改动文件→commit→验证记录";README.md 档案清单行同步;版本号 bump。
- ⚠️ **档案号必须『原子预留』,不能靠"读一眼 ls 再写"**(2026-09-11 **两次**撞号实证):**"取号 + 写档案 + 改 `INDEX.md` + 改 `docs-status` 同一串行事务"这条规则本身不够** —— 两条并行通道同时 `ls` 时都读不到对方的文件,必然撞。**第二次撞号(37/38 各两条,4 个文件挤 2 个号)就是这么发生的**。
- **正确做法(原子锁)**:写档案前先原子占号,例如
```bash
cd /opt/dsh/docs/调整方案
n=$(ls | grep -oE '^[0-9]+' | sort -n | tail -1); n=$((n+1))
mkdir ".lock-$n" 2>/dev/null || { echo "占用,重取"; } # mkdir 是原子的
```
或等价地用 `flock /opt/dsh/docs/.numbering.lock -c '...'` 包住"取号+落文件"整段。**占号与落盘之间的窗口必须极短,且不要在中途放开去做别的任务。**
- **撞号后处置**:保留**已 commit** 的那个号不动(commit message 已引用它),把**自己未提交**的那个顺延改号(`mv` 文件 + 改 INDEX 行 + 改自己的 docs-status 变更行 + 在自己档案内修正交叉引用 + 加一条撞号说明)。**改号要在提交前做**,否则又制造一次引用错位。
- ⚠️ **`docs/README.md` 的档案清单已落后(止于 28)** → **不要只改 README**;全量清单在 `docs/INDEX.md`,以它为准。
3. **三层沉淀**(用户习惯,必做)—— 🔴 **DSH 侧落点已改,见下表**:
- 治本层:机制/流程固化进 skill(`$DSH_HOME/skills/<name>/`)或档案
- 失误层:教训 append 到**本技能的 `references/` 实测教训档**,或项目台账的「实测教训」节
(DSH 侧**没有** WorkBuddy 那种按日记忆文件);⛔ append-only,不改写历史
- 记忆层:长期约定 / 红线写进 **`$DSH_HOME/AGENTS.md`**(全局,每轮注入)或工作区 `AGENTS.md`(项目级)
4. **汇总表**:带状态/结果列交付,列明未完成项与副作用。**排版按 `session-mechanism §5.4`**(首屏给判定 / 层级≤3 / 每节≤7 行 / 表格≤5 列 / 一条信息只说一次;细节进附录)。
5. **技能自身归档(本 skill 有改动时必做)**:🔴 **DSH 侧工作副本 = `$DSH_HOME/skills/<name>/SKILL.md`**(不是 `.workbuddy/08-skills/`)。服务器归档位仍在 **`/opt/dsh/docs/08-skills/<name>/SKILL.md`**(root 600)。改完本 skill 后**单向推**:
```bash
P=/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0
export PATH="$P/usr/bin:$P/mingw64/bin:/c/Windows/System32:/c/Windows:$PATH"
S="/c/Users/Administrator/AppData/Local/Temp/wb-scratch"; mkdir -p "$S"
cp "<本机 skill>/SKILL.md" "$S/SKILL.md" # 中文路径先转纯 ASCII
scp -i ~/.ssh/id_ed25519 -P 22 "$S/SKILL.md" [email protected]:/tmp/SKILL.md
ssh -i ~/.ssh/id_ed25519 -p 22 [email protected] \
'D=/opt/dsh/docs/08-skills/<name>; mkdir -p /opt/dsh/backups/docs-skills; \
cp $D/SKILL.md /opt/dsh/backups/docs-skills/SKILL.md.bak-$(date +%Y%m%d_%H%M%S); \
cp /tmp/SKILL.md $D/SKILL.md && chmod 600 $D/SKILL.md && chown root:root $D/SKILL.md && rm -f /tmp/SKILL.md'
```
**md5 必须双端一致**再算完成;同时更新引用它的元数据:`README.md` 模块表行、`INDEX.md` 场景速查行 + 全量清单行。**方向固定「本机 → 服务器」,勿反向覆盖**(服务器副本是归档,不是工作副本)。
⚠️ **改「跨载体规则」时必做全载体扫描**(2026-09-15 实证):一条交互 / 流程规则往往**同时躺在多个载体里**,
只改一处 ⇒ 其余载体立刻自相矛盾,**比不改更糟**。动手前先 `grep -rn` 关键短语,把命中处**全部**列出:
| 载体 | 典型位置 |
|---|---|
| 技能正文 | `SKILL.md` 的 §/铁律/硬约束/反模式/自检 + `references/**` |
| 项目根常驻层 | `CODEBUDDY.md §1`(**连带同节的指针行**,如「排版按 …」) |
| 用户级记忆(跨项目) | `~/.workbuddy/MEMORY.md` |
| **钩子文案** | `07-scripts/stop-dialog-guard.py` 的 `REASON` / `CONTEXT` |
| **生成物(勿手改)** | `dsh-env-bootstrap/references/常驻规则-快照.md` ⇒ 改完源后跑 `python resident-rules.py --snapshot` **重生成**,再 `--check` 应 RC=0 |
| 登记行 | `README.md` 模块表 · `INDEX.md` |
判据:改完再 grep 一遍,命中应**只在已改处**。**钩子文案最关键** —— 它每轮都在"教训 AI 该怎么做",
与规则打架时危害最大(本轮实证:钩子写「不要在结尾甩问题」,用户要「待确认内容放到最后」,两句话直接对撞)。
收口 = 上表**逐格确认** + 两副本 md5 一致 + 镜像 md5 一致 + 七件套全绿。
> 📂 **档案模板(8 段结构)已下沉** → `dsh-change-workflow/01-档案模板.md`
## 红线 R1-R11 原文速查
> 来源 = 工作区 `CODEBUDDY.md` §3 的**可移植快照**(`dsh-env-bootstrap/references/常驻规则-快照.md`,权威单向)。
> ⛔ 编号即红线号,**任一条命中 ⇒ 先停手**;效率论证不构成豁免。
1. **禁止启动 dsh 时自动获取最新版本**;版本升级独立流程(档案 07;/usr/local/etc/npmrc `update-notifier=false` 已全局生效)。
2. **不改官方 dsh 主程序与缓存**(`/usr/local/lib/node_modules/@deepseek-ai/dsh` 及依赖);扩展只走 profile 层官方插件机制。
3. **client bundle 严禁 `exports.default = apply`**(或任何 default 函数导出)——详见上文 R3 解释(@dsh-local/portal-entry v0.4.0→v0.4.1 实证教训)。
4. **R4 禁止借真实账号(`admin`/`guest`)的会话跑测试**:⛔ **旧理由("登录 last-wins 会删该账号全部旧会话、把用户踢下线")自 2026-09-20 起已失效** —— 序47 已改为**多会话并存**(`04-调整方案/145-…md`)。**仍然禁止**的三条理由:① 借来的会话办不了该用户自己的实例操作 ② 污染真实账号的会话列表与 `audit_log` ③ 拿真实账号去验证 `logout-all`/`revoke` 会真把人踢下线。改用 `mksess.cjs` **PG 直插**临时 session 或**专用测试账号**(档案 15 实证教训 + 序47 复核)。
5. **权限可见面只准收窄,扩大必须先确认**(2026-09-11 用户新增,R5)——见下节「R5 权限扩大门禁」。
6. **R7|禁止未经确认的批量 / 全仓写入**(2026-09-12 用户新增红线)—— 见下节「R7 批量写入门禁」。
7. **R8|生产变更知会**(2026-09-12 立 · **2026-09-21 修订**)—— 开发环境服务器 ⇒ **直接做**,只需动手前一句说明 + 攒批;⛔ **不是"先取得确认"**,见下节「R8 生产变更知会」。
8. **R9|⛔ 绝对禁止「人工删锁 / 接管」** —— 不得删锁目录、不得以「持有者疑似已死 / 卡住」为由**单方面接管**;**锁只能由持有者自己释放**(抢不到 = 终点 ⇒ 只读、报告、结束)。
9. **R10|⛔ 绝不以 root(或非该实例 uid)运行 / 触碰用户实例的东西** —— ① 验证 / 冒烟 / 探针**必须以该 uid 运行**或进沙箱,⛔ **禁 root 直跑 profile**;② ⛔ 不用 root 去读 / 写 / 删用户实例的 home 与数据目录。
10. **R11|⛔ 只做正向迭代** —— 判据(十维:目标 / 方向 / 架构 / 功能 / 性能 / 安全 / 交互 / UI / 便利性 / 扩展性):是否让**任一维净变差**?**命中 ⇒ 立即停下复盘**(写清劣化在哪一维 / 代价多大 → 找保住正向收益的做法 → 拿不出就停手只报告);⛔ 三种伪装禁止:必要代价 / 后续再优化 / 藏进交付不写。
> 📂 **R7 / R8 / R5 三条红线的由来与完整判据已下沉** → `dsh-change-workflow/02-红线详解-R5-R7-R8.md`(**要判「算不算批量写入 / 要不要先知会 / 这次是扩大还是收窄」时必读**)
> 📂 **Profile·Skill 装载与管理面三节已下沉** → `dsh-change-workflow/03-沙箱与技能机制.md`(Profile 层 cordis patch 机制 · Skill 装载机制(bundledSkillDir)· Skill 管理面(编排器 API + 静态页))
> 📂 **通用运维锚点已下沉** → `dsh-change-workflow/04-运维锚点与取证.md`(重启用锚点 · 存量会话档位体检 · 功能插件启用:探活·快照回滚·逐插件隔离 · 会话记录取证)
## 本机 Git Bash 环境坑(2026-09-11 实证,几乎每次都踩)
- **PATH 常坏**:`Bash` 工具里 `dirname`/`head`/`ls` 报 `command not found`,且 `ssh` 不在 PATH 里(私钥真名是 `~/.ssh/id_ed25519`,⛔ 没有 `id_ed25519_dsh` 这把)。**每次先前置**:
```bash
P=/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0
export PATH="$P/usr/bin:$P/mingw64/bin:/c/Windows/System32:/c/Windows:$PATH"
```
- **node 是 Windows 程序,不认 `/c/...` msys 路径**(会拼成 `d:\c\Users\...`)。脚本里写路径要用 `C:/Users/...`;而 `ssh`/`scp`/`cp` 这些 msys 程序要用 `/c/...`。
- **中文路径下发文件不可靠** → 中转文件先 `cp` 到纯 ASCII 路径(`C:/Users/Administrator/AppData/Local/Temp/wb-scratch/`)再 `scp`。
- **heredoc 通道 UTF-8 安全**:`ssh HOST 'cat > /tmp/x' << 'EOF'`(引号定界符)可原样传输中文,已多次验证;但**别在 heredoc 定界符后再接 `' 2>/dev/null`** 之类的引号拼接,会 EOF 报错 —— 复杂脚本改成「本地写 → scp → 远端 node 执行」。
- **PowerShell 工具在本会话输出被吞**(命令成功但无 stdout)→ 本地操作优先用 Bash(修好 PATH 后)。
- **`.bak-*` 可能已被 git 跟踪**(历史提交过):不要批量挪动/删除它们,否则产生意外的 `D` 变更;只做定向 `git add`。
- 🔴 **沙箱 `node-safe-delete` 会拦构建工具的 `emptyDir`(2026-09-25 实证)**:`vite build` 清空 `outDir` 时会对每个被删文件走 node 的 `rmSync`,**只要总数 > 阈值 50 就整批拒绝**(报 `[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED] …`)⇒ 构建**中止**。**正解 = 构建前先把旧产物目录 `mv` 走**(`emptyDir` 对不存在的目录是空操作),⛔ 不要以为构建脚本或依赖坏了。
- 🔴 **Windows 侧直连 WSL2 靶机的回环端口(2026-09-25 实证)**:靶机实例监听 `127.0.0.1:<port>` 时,Windows 上 `curl http://127.0.0.1:<port>/` **可直接到达**(`wslrelay.exe` 在做 localhost 转发,`netstat -ano` 里看到的就是它)⇒ **⛔ 不需要再 `ssh -L` 建隧道**(建了也不报错,只是白建;旧记录里的 `tunnel.log` 全是 `Connection refused` 就是这个原因)。
> 📂 **数据源与插件口径已下沉** → `dsh-change-workflow/05-插件与数据源口径.md`(外部数据源缓存必须带结构版本守卫 · 官方白名单插件来源 · 技能 vs 插件怎么区分 · 术语与环境约定)
> 📂 **沙箱与权限预设机制已下沉** → `dsh-change-workflow/03-沙箱与技能机制.md`(**改沙箱/权限默认值前必读**)
> 📂 **实例可见面与边界整章已下沉** → `dsh-change-workflow/06-实例可见面与共享边界.md`(bwrap 挂载面铁律 · 🔒 基础运行时版本冻结 · 📦 实例共享工具(jq/ripgrep/ffmpeg)· 资源与网络边界 · 只读诊断配方 · 📋 实例可访问路径清单 · 技能上传安全)
> 📂 **技能/插件区分判据与术语约定已下沉** → `dsh-change-workflow/05-插件与数据源口径.md`
> 📂 **功能插件启用与对话取证已下沉** → `dsh-change-workflow/04-运维锚点与取证.md`
## 多任务并行调度协议(2026-09-11 用户提出,长期沿用)
**核心结论**:**可以并行,但并行度由「冲突域」决定,不由任务数量决定。** 只读/调研类可自由并行;改码 → 构建 → 重启 → 提交 → 写文档是**单通道**,必须串行。
> 📂 **并行调度详解已下沉** → `dsh-change-workflow/07-并行调度详解.md`(冲突域清单 · 批次模型与依赖处理 · 反例 · 落地机制:三把锁 `--claim-exec` / `.doing-<单号>` / 服务器侧 `/opt/dsh/state/.op-lock`)
## 详情索引(references/)
> 本技能 = **主干(本文件)+ 详情档**。主干只留判据 / 流程主干 / 命令骨架;长表、案例、实测记录、历史细节在下面各档。
>
> **跨档引用怎么查**:主干与详情档正文里出现的「§N」「见 §8 坑 N」「见下表 / 见上表」等编号,**按本表的「覆盖的原章节」列定位到对应档**(下沉后原编号不再有独立章节标题)。
| 详情档 | 覆盖的原章节 | 原行段 | 行数 |
|---|---|---|---|
| `dsh-change-workflow/00-平台速查.md` | 平台速查(硬编码事实,勿猜) | L14–L92 | 79 |
| `dsh-change-workflow/01-档案模板.md` | 档案模板(04-调整方案/<NN>-<标题>.md) | L391–L403 | 13 |
| `dsh-change-workflow/02-红线详解-R5-R7-R8.md` | R7 批量写入门禁 · R8 生产变更知会 · R5 权限扩大门禁 | L414–L480 | 67 |
| `dsh-change-workflow/03-沙箱与技能机制.md` | Profile 层 cordis patch 机制 · Skill 装载机制 · Skill 管理面 · dsh 沙箱与权限预设机制 | L481–L504 + L555–L600 | 70 |
| `dsh-change-workflow/04-运维锚点与取证.md` | 通用运维锚点(重启用) · 功能插件启用:探活 · 快照回滚 · 逐插件隔离 · 会话记录取证 | L505–L525 + L935–L998 | 85 |
| `dsh-change-workflow/05-插件与数据源口径.md` | 外部数据源缓存必须带结构版本守卫 · 官方白名单插件来源 · 技能 vs 插件:怎么区分 · 术语与环境约定 | L539–L554 + L907–L934 | 44 |
| `dsh-change-workflow/06-实例可见面与共享边界.md` | 实例可见面 · 软件共享 · 网络与安全边界 · 铁律:实例能看到什么,完全由 bwrap 挂载面决定 · 🔒 基础运行时版本冻结 · 📦 实例共享工具 · 资源与网络边界 · 只读诊断配方 · 📋 实例可访问路径清单 · 技能上传安全 | L601–L906 | 306 |
| `dsh-change-workflow/07-并行调度详解.md` | 冲突域清单 · 批次模型与依赖处理 · 反例(同域并发的具体破坏形态) · 落地机制 | L1003–L1052 | 50 |
| `dsh-change-workflow/08-浏览器验证栈详解.md` | 正确调用(Windows,2026-09-11 实测可用) · ⛔ dsh composer(Lexical)输入难点 · 三个必踩坑 | L231–L293 | 63 |
---
## 变更历史(迁移前 · 自 frontmatter 移出以保证 YAML 合法)
> 原文照抄,仅为保留历史;其中的宿主路径与脚本位置是当时的旧值。
```text
【2026-09-22 按要求统一版本号】frontmatter `version` → `1.0.0`(原 v2.9.2-playwright-banned);正文与历史中的版本号为当时记录,未改动。此前 【2026-09-12 新增「三把锁」落地机制 + 更正过期指向】把「多任务并行调度协议」从**设计**补成**可强制执行的判据** —— 全局执行锁 `05-交接单/.exec-lock`(同一时刻只允许一个执行会话)+ 单级占用锁 `.doing-<单号>` + **服务器侧操作锁 `/opt/dsh/state/.op-lock/`**(管生产态:重启 / drain / 改实例 env·quota / 批量铺插件 / 改 nginx·nft·证书,因「服务器态变更看不出来」故必须显式加锁);工装 `07-scripts/handoff-guard.sh`(新增【1d】分支)与 `07-scripts/op-lock.sh`。同时**更正产出闭环里的过期指向**:原文写 `docs-status/文档库状态备注.md` + `bash 07-scripts/docs-status-sync.sh --pull`,**该机制已不存在** → 改为 `INDEX.md §二` 登记 + 收尾四件套(audit → manifest → sync-check → `PUSH=1 handoff-guard`)。出处:当日 3 次并行事故 + `04-调整方案/69`。此前】**R7 禁止未经确认的批量 / 全仓写入**(由来:为"让 scp 出去的文件行尾干净",用脚本把 **147 个文件** CRLF→LF —— 当期只被要求改一个 UI 字符串;被用户指为「容易把服务器搞崩」,且随后 `cp -r` 确实把**未改动的** 2 个文件覆盖到了服务器)+ **R8 会中断在线用户的生产变更须先知会**(由来:档案 58/59 连续两次重启,直接引发用户"会话连接异常"报障)。【2026-09-11 晚,用户当面纠正后大修】① **阶段 0 新增第 0 条「开工前置检查」并标明"本技能最重要的一步"**(装工具/写自动化/查机制前先看:可用技能列表 → 本机/项目已有技能与记忆 → "本机已有的能否满足")+ 红线 **R6「先查已有资产,再动手」**;② **浏览器自动化定型**:用 `browser-harness`(CDP 9223,Windows 调用方式 + helper 清单),**禁再装 agent-browser / Playwright**;⛔ **铁律:browser-harness 附着的是「用户正在用的 Chrome」→ 动手前先 `list_tabs()`,不是全新空浏览器就立刻停手**(本轮真实事故:在其浏览器里登录测试号 → 覆盖用户 `.alotbuy.com` 的 `sid`);dsh composer 是 Lexical(`data-lexical-editor`),`type_text`/`fill_input` 均未生效;③ **更正「空闲回收」认知**:回收**从未生效**(TTL 默认 7 天 / cap 4 / 每请求 touch),但 **"回收没生效"≠"实例不会中断"** —— 中断真凶 = **服务重启(09-11 达 48 次)→ 旧实例变孤儿 → 另起新实例 → 旧页面 401** + **实例崩溃自动重启(近 4 天 69 次;admin 根因 `duplicate loader entry id: permission`)**;附三数核对手法;④ 阶段 1 新增「**用户报障第一步:先读 journal 拿真实失败请求**」(三分钟定位,判据速查 401/502/crash);⑤ 阶段 4 新增「**验证用例必须同时覆盖「有请求体」与「无请求体」**」(档案 51 事后修正实证:只测 POST 会整条漏掉 GET/SSE)+ 「模拟浏览器语义要测到底」(只带最初旧 cookie、显式断言 Set-Cookie 回写);⑥ R4 补**专用测试账号模板**(`role='active'`、`approved_by` 需真实 admin id;`users` 表无 `status` 列)。此前新增「**本环境 TLS 走中间人代理 → Python 脚本必须 SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt**」(两份 CA bundle 的区别 + 零平台改动正解 + 为何不走 R5 注入 env)+「职责边界:技能投放=admin 的事,平台只提供机制与把关」;此前 R5 追加「**安装类操作=扩大,必须先出安装确认清单(七项)**;技能/插件依赖体检只报告+拦截、绝不代装」;另把「复杂引号一律本地写文件→scp(禁 ssh 内 inline node -e/sed)」从建议升为**硬性要求**(本轮同类错误连犯 4 次)。此前补环境事实「正确域名 alotbuy.com(dsh.alotbuy.com 是旧域 301,抓页面只会拿到 301 HTML)」+ 档案 45 存量会话提示落点(login.html);此前新增「**基础运行时版本冻结**」节(档案 44:唯一缝隙=Python user-site 会盖平台包;修法 PYTHONNOUSERSITE + PYTHONUSERBASE 双保险;版本漂移巡检 07-scripts/runtime-baseline.cjs + cron 05:10);补「3.6 不开放」的正解(引导优先,遮蔽不可行)+ 属主自愈(ws-cleanup --reclaim-only,平台 root 跑 pnpm 污染用户 ws);**R5 已立**。此前新增红线 R5「权限可见面只准收窄,扩大必须先确认」**(含「R5 权限扩大门禁」整节:扩大/收窄方向判定表、权限影响评估四问、触发文件清单、以「实例可访问路径清单」做 diff 验收);阶段 2 红线自查与影响评估同步加入 R5 门禁;此前新增「**无法遮蔽 /usr 内文件**」(ro-bind 目录无法创建Line truncated
```
---
## 变更历史(原 frontmatter · 逐字保留)
```text
name: dsh-change-workflow
description: DSH 平台项目的功能改造与优化工作流:需求识别、调研、规划、开发、验证、归档清理的完整阶段链,含红线判据、备份先行、append-only 日志、交付门禁与「本机改完 ≠ 交付」的生效链路。触发场景:用户提出平台需求 / 改造 / 优化 / 缺陷修复 / 功能调研,问「按既有流程执行」「这件事怎么落地」「我做完了吗」,或要把一次性处理沉淀成可复用的处理机制时。
version: 1.0.0
updated_at: 2026-09-15
agent_created: true
```
---
## 变更历史(**对侧副本** frontmatter · 逐字保留 · 来自 `dsh-change-workflow` 的 本机 版)
> ⚠️ 本档正文取自**另一侧**(超集);此处补上对侧副本的版本史,⛔ 以保证不丢任何事实。
```text
name: dsh-change-workflow
description: dsh 多租户平台(ai1net.com / dshs,服务器 47.77.182.89)功能改造与优化任务的完整工作流。当用户提出平台需求、改造、优化、缺陷修复、功能调研,或要求"按既有流程执行/沉淀/整理处理机制"时触发。核心:需求识别→调研→规划→开发→验证→归档清理 六阶段 + 红线机制(不自动升级 dsh / 不改官方主程序 / client bundle 禁 exports.default / 不用真实账号测登录 / **R7 禁未经确认的批量·全仓写入** / **R8 会中断在线用户的生产变更须先知会**)+ 备份先行 + append-only 日志 + 表格式交付 + 文档「服务器唯一源 + docs-status 单目录同步」。
version: 1.0.0
updated_at: 2026-09-15
last_change: 【2026-09-22 按要求统一版本号】frontmatter `version` → `1.0.0`(原 v2.9.2-playwright-banned);正文与历史中的版本号为当时记录,未改动。此前 【2026-09-12 新增「三把锁」落地机制 + 更正过期指向】把「多任务并行调度协议」从**设计**补成**可强制执行的判据** —— 全局执行锁 `05-交接单/.exec-lock`(同一时刻只允许一个执行会话)+ 单级占用锁 `.doing-<单号>` + **服务器侧操作锁 `/opt/dsh/state/.op-lock/`**(管生产态:重启 / drain / 改实例 env·quota / 批量铺插件 / 改 nginx·nft·证书,因「服务器态变更看不出来」故必须显式加锁);工装 `07-scripts/handoff-guard.sh`(新增【1d】分支)与 `07-scripts/op-lock.sh`。同时**更正产出闭环里的过期指向**:原文写 `docs-status/文档库状态备注.md` + `bash 07-scripts/docs-status-sync.sh --pull`,**该机制已不存在** → 改为 `INDEX.md §二` 登记 + 收尾四件套(audit → manifest → sync-check → `PUSH=1 handoff-guard`)。出处:当日 3 次并行事故 + `04-调整方案/69`。此前】**R7 禁止未经确认的批量 / 全仓写入**(由来:为"让 scp 出去的文件行尾干净",用脚本把 **147 个文件** CRLF→LF —— 当期只被要求改一个 UI 字符串;被用户指为「容易把服务器搞崩」,且随后 `cp -r` 确实把**未改动的** 2 个文件覆盖到了服务器)+ **R8 会中断在线用户的生产变更须先知会**(由来:档案 58/59 连续两次重启,直接引发用户"会话连接异常"报障)。【2026-09-11 晚,用户当面纠正后大修】① **阶段 0 新增第 0 条「开工前置检查」并标明"本技能最重要的一步"**(装工具/写自动化/查机制前先看:可用技能列表 → 本机/项目已有技能与记忆 → "本机已有的能否满足")+ 红线 **R6「先查已有资产,再动手」**;② **浏览器自动化定型**:用 `browser-harness`(CDP 9223,Windows 调用方式 + helper 清单),**禁再装 agent-browser / Playwright**;⛔ **铁律:browser-harness 附着的是「用户正在用的 Chrome」→ 动手前先 `list_tabs()`,不是全新空浏览器就立刻停手**(本轮真实事故:在其浏览器里登录测试号 → 覆盖用户 `.alotbuy.com` 的 `sid`);dsh composer 是 Lexical(`data-lexical-editor`),`type_text`/`fill_input` 均未生效;③ **更正「空闲回收」认知**:回收**从未生效**(TTL 默认 7 天 / cap 4 / 每请求 touch),但 **"回收没生效"≠"实例不会中断"** —— 中断真凶 = **服务重启(09-11 达 48 次)→ 旧实例变孤儿 → 另起新实例 → 旧页面 401** + **实例崩溃自动重启(近 4 天 69 次;admin 根因 `duplicate loader entry id: permission`)**;附三数核对手法;④ 阶段 1 新增「**用户报障第一步:先读 journal 拿真实失败请求**」(三分钟定位,判据速查 401/502/crash);⑤ 阶段 4 新增「**验证用例必须同时覆盖「有请求体」与「无请求体」**」(档案 51 事后修正实证:只测 POST 会整条漏掉 GET/SSE)+ 「模拟浏览器语义要测到底」(只带最初旧 cookie、显式断言 Set-Cookie 回写);⑥ R4 补**专用测试账号模板**(`role='active'`、`approved_by` 需真实 admin id;`users` 表无 `status` 列)。此前新增「**本环境 TLS 走中间人代理 → Python 脚本必须 SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt**」(两份 CA bundle 的区别 + 零平台改动正解 + 为何不走 R5 注入 env)+「职责边界:技能投放=admin 的事,平台只提供机制与把关」;此前 R5 追加「**安装类操作=扩大,必须先出安装确认清单(七项)**;技能/插件依赖体检只报告+拦截、绝不代装」;另把「复杂引号一律本地写文件→scp(禁 ssh 内 inline node -e/sed)」从建议升为**硬性要求**(本轮同类错误连犯 4 次)。此前补环境事实「正确域名 alotbuy.com(dsh.alotbuy.com 是旧域 301,抓页面只会拿到 301 HTML)」+ 档案 45 存量会话提示落点(login.html);此前新增「**基础运行时版本冻结**」节(档案 44:唯一缝隙=Python user-site 会盖平台包;修法 PYTHONNOUSERSITE + PYTHONUSERBASE 双保险;版本漂移巡检 07-scripts/runtime-baseline.cjs + cron 05:10);补「3.6 不开放」的正解(引导优先,遮蔽不可行)+ 属主自愈(ws-cleanup --reclaim-only,平台 root 跑 pnpm 污染用户 ws);**R5 已立**。此前新增红线 R5「权限可见面只准收窄,扩大必须先确认」**(含「R5 权限扩大门禁」整节:扩大/收窄方向判定表、权限影响评估四问、触发文件清单、以「实例可访问路径清单」做 diff 验收);阶段 2 红线自查与影响评估同步加入 R5 门禁;此前新增「**无法遮蔽 /usr 内文件**」(ro-bind 目录Line truncated
agent_created: true
```
@@ -0,0 +1,479 @@
# 多棒自动接力编排法(规划棒 ↔ 执行棒)
> **归属**:技能 `dsh-workflow` · 详情档(主干 `../SKILL.md`)
> **本档覆盖**:原技能 `dsh-auto-handoff-chain` **全文**(正文 308 行 + frontmatter 变更历史)
> **provenance**:本机版(WorkBuddy 实况)
> **搬运方式**:**逐行未改**;内部附件链接已改写为 `dsh-auto-handoff-chain/<附件名>`(附件在本目录下)
> ⚠️ 原技能 `dsh-auto-handoff-chain` **已合并退役** ⇒ 见到该名按本档读。
> 🔴 **2026-10-01 用户口径(最新)**:**「接续会话 时间缩短 3-4 分钟即可」** ⇒ 本档中一切「收口 + 5~8 分钟」**一律按 3~4 分钟读**(原值作废)。
---
# dsh-auto-handoff-chain — 多棒自动接力编排法
> **一句话**:不要用「一条 prompt 跑完整条工作线」,而是让**每一棒只做一件事**,做完**自己把下一棒排上**。
用户原话(2026-09-17 立项):
> 「每次执行完毕后 自动根据方法决策 自动新建会话接续处理,**在处理多步骤任务的时候非常好**」
---
## 0. 与其他技能 / 文档的分工
| 谁 | 管什么 |
|---|---|
| **本技能** | **怎么把长任务排成一条自动接力的链条**(编排层) |
| `dsh-change-workflow` | 单次改造怎么落地(六阶段) |
| `session-mechanism` | 单次取舍怎么定得对(**规划棒应加载**) |
| 工作区 `会话接续规范_20260916.md` | 上下文超限时的**接续包 v2 模板 / 开机四步 / 多线并行**(本技能是其"多棒编排"的补充,**不重复**它已写的接续包字段与成本公式) |
| 工作区 `接续入口_<线名>_<日期>.md` | **每一条工作线的唯一执行依据**(本技能的核心依赖) |
### 0.6 🔴 工作区归属五律(2026-09-21 立①②③④ / 2026-09-22 加⑤ / **2026-09-23 修⑤ 盘符大小写** / **2026-09-24 修⑤ 判据:实测 `sessions.cwd` + 正斜杠** —— 治「参考别的工作区的规则 ⇒ 会话落错家」+「同一工作区裂成两个同名分组」)
**背景实测**:某工作区会话「参考接续会话的规则」时照抄了 `aliyun-dsh-server` 的路径 ⇒ 把**入口文件**和**接续任务**都落到了平台工作区,而它那条线自己的家是 `dsh-plugin-forge`。同一份入口出现两处、md5 相同 = **第二真相源**;平台工作区的 `state.py` 因此把**别线**报成了自己的线(实测报"2 条工作线",其中一条不是它的)。
| 律 | 内容 | 反例(都真发生过) |
|---|---|---|
| ① **入口只允许一份** | 位置 = **那条线自己的工作区根**;头部必须写 `> 🔴 **工作区**:<该工作区绝对路径>` | 两处同改(forge + aliyun 各一份,md5 相同) |
| ② **`cwds` = 本工作区** | automation 的 `cwds` 只认**那条线自己的工作区**;⛔ 不因"脚本在别的工作区"就把 `cwds` 设过去 | 照抄"第 0 步跑 `state.py`" ⇒ `cwds` 写成脚本所在的工作区 |
| ③ **参考规则 = 加载本技能** | 要副本就复制**规则文档**到本工作区 `docs/会话与接续/`;⛔ **入口文件不复制** | 直接读别的工作区的 `接续入口_*.md`,照抄其中路径 |
| ④ **`cwds` 写对 ≠ 会话落对**(2026-09-21 加) | `cwds` 只是**登记项**,管不到会话从哪个目录被拉起 ⇒ prompt 里**额外**写死「开工前先 `cd` 到 `<工作区绝对路径>`」+「收尾校验产物落点」 | **乙类(下)**:6 棒 `cwds` 全写对,会话仍全落在 `E:\ProgramData\WorkBuddy\<时戳>` |
| ⑤ **`cwds` 路径写法必须归一化**(2026-09-22 加;**09-23 修盘符方向**;**09-24 修判据**) | ⛔ **只写「与手动开会话的 cwd 逐字同形」**:**当前正解 = `E:/ProgramData/AIProject/ai1net-dsh-server`(正斜杠)**。**判据 = 实测 `sessions.cwd`**(⛔ 不凭记忆定「绝对写法」—— 09-22 / 09-23 各翻烧饼一次);⚠️ 宿主去重键 = `path.trim().toLowerCase()` ⇒ **只小写、不统一斜杠**:斜杠风格不同即裂同名分组(09-24 代码级实证);⚠️ 改路径字面必须**枚举全部复制面**(技能正文 + 本技能 `description` + `MEMORY.md` + 入口声明行 + automation prompt) | **写法一变就当两个 cwd** ⇒ UI 里**同一工作区裂成两个同名会话分组**(实测:09-22 = 斜杠方向,aliyun 151 vs 6、capability 5 vs 4;09-23 = 盘符大小写,aliyun 158 vs 2) |
**律⑤ 的实测事故·一(2026-09-22 排查,「又产生了两个同名的会话分组」|污染维度 = 斜杠方向)**
| 环节 | 事实 |
|---|---|
| 现象 | UI 里同一个工作区出现**两个同名分组**(用户原话:「又产生了 两个同名的会话分组」) |
| 宿主行为 | 会话 `cwd` **逐字抄** `automation.cwds`,**不做任何规范化**(实测 10/10 逐字一致) |
| 污染源 | **10 条 automation 的 `cwds` 写成正斜杠** `E:/ProgramData/AI技能/…`,集中在 09-21 06:48 → 09-23 09:52 的接续棒 |
| 影响面 | `aliyun-dsh-server`:反斜杠 151 会话 vs 正斜杠 6 会话|`dsh-ai1net-capability`:5 vs 4 |
| 传染路径 | 前任棒次在**日志里把正斜杠当规范记下**(`2026-09-23.md` 的 `cwds = E:/…`)⇒ 后棒照抄 ⇒ 一路正斜杠 |
**判据**:⛔ 凭记忆定「绝对写法」必错(09-22 定「大写」/ 09-23 改「小写」,各裂一次)⇒ **一律取实测 `sessions.cwd` 里条数最多的那条字面逐字复制**。会话已经裂开 ⇒ 只能**发现后清理**(旧组不会自动消失,且会自我强化);⛔ **新建 automation 一律写「与手动开会话的 cwd 逐字同形」**。
**自查一句**:我这个 `cwds` 与**手动新建会话实际落库的 cwd** 是否**逐字同形**(斜杠方向也必须同形)?(当前正解 = `E:/ProgramData/AIProject/ai1net-dsh-server`)
🔎 **不确定就现查存量**(比记忆可靠):在 `CODEBUDDY_CONFIG_DIR` 下的 `workbuddy.db` 里跑 `SELECT cwd, COUNT(*) FROM sessions WHERE cwd LIKE '%<工作区名>%' GROUP BY cwd ORDER BY 2 DESC` ⇒ 取**条数最多**的那一条**字面**逐字复制。
**律⑤ 的实测事故·二(2026-09-23 排查,用户原话「为什么又出现了多个 aliyun-dsh-server 会话分组」|污染维度 = 盘符大小写)**
| 环节 | 事实 |
|---|---|
| 现象 | 同一工作区**再次**裂成两个同名分组(距事故·一只隔一天) |
| 污染源 | **3 条接续棒(IM 线第 12 / 13 / 15 棒)的 `cwds` 写成大写盘符** `E:\ProgramData\AI技能\aliyun-dsh-server`;三条**全部建在 09-23 18:55 automation 库被 app 重建之后**(记录全丢 ⇒ 重登时按技能里残留的**错误字面**输入)。⚠️ **21:5x 复核更正:原记「第 12 / 13 / 14 棒」有误** —— 第 14 棒(`2d059ec8` · 20:15)实为小写、落 `e:\`;**真凶是第 15 棒(`4e08cc77` · 20:45),当时漏改** ⇒ 20:45 又裂 1 条 |
| 实测分布(09-23 21:5x 复核) | `sessions.cwd`:小写 `e:\…` = **160 条** | 大写 `E:\…` = **5 条**(3 条 automation 拉起 19:10 / 19:49 / 20:45 + **2 条 IDE 手动** 20:54 / 21:47)| 旧路径 `d:\AI技能\aliyun-dsh-server` = **11 条**(09-08~09-12 迁移前,末次活动 09-13,已冻结)| `workspaces.path` = 小写(**53/53 全小写**) |
| 🔴 教训一:凭记忆规定"绝对写法"必错 | 律⑤ 原文「**只写大写盘符** / **小写盘符必错**」**方向反了** —— 存量与工作区登记都是小写。⇒ 正解**不是**"与存量逐字对齐"(存量被污染成 3 个值之后,这句话无法执行),而是 **跟随宿主自己的登记值 `workspaces.path`** ⇒ 判据由"我们的约定"变成"客观事实",⛔ **不再翻烧饼** |
| 🔴 教训二:改规则必须枚举**全部复制面**并对账 | 同一路径字面同时存在于 **技能正文 + 技能 frontmatter `description` + 用户级 `MEMORY.md` + 项目 `MEMORY.md` + 4 个入口文件的「工作区」声明行 + automation 的 prompt 正文 + `USER.md` + 历史日志/交接单**。09-23 那次只改了 3 处,**漏掉 `description`(曝光度最高 —— 每次新会话的技能清单都会读到它)** ⇒ 当天 19:10 起照旧裂 3 条。⇒ **说"已同步改正"必须附一条对账查询**,否则修复质量无法自证 |
| 🔴 教训三:已裂的组会**自我强化** | 手动新建会话**继承所在分组的字面** ⇒ `E:\` 组一旦存在就自己长(21:5x 实测该组 5 条中 2 条为手动会话)。⇒ 只堵源头不够,**必须清掉已裂组**,否则残留组持续复制自己 |
| 已止损(09-23 21:5x 补全) | ① 第 14 棒(20:15)小写 ✅ ② **第 12 / 13 / 15 棒**(原为大写)一并改回小写 `e:\…`,三条已确认落库 ✅ ③ 技能 `description` 方向已补改 ✅ ④ 两处 `MEMORY.md` 判据已改为「真源 = `workspaces.path`」并**冻结** ✅ |
| 待处置 | ① 已裂的 **5 条大写 + 11 条 `d:\`** 会话字面归并 ⇒ 需**停 app + 改活库**(WAL 三件套)⇒ 属红线门禁,⛔ 未擅自动 ② 4 个入口文件第 3 行「工作区」声明行的**大写字面**(登记时的诱导源)统一为小写 ③ `USER.md` 里工作区仍写 `D:\AI技能\aliyun-dsh-server`(过时,`d:\` 组来源) |
**律④ 的实测事故(2026-09-21,6 个碳线棒次 100% 命中)**
|cwds(登记)| 会话实际 `cwd`| 对得上|
|---|---|---|
| `E:\ProgramData\AI技能\dsh-ai1net-capability` | `E:\ProgramData\WorkBuddy\2026-09-21-20-20-06` | ❌ |
| 同上(连续 5 棒) | `…-20-22-44` / `…-20-25-47` / `…-20-30-24` / `…-20-33-35` | ❌ |
| 同上(执行棒⑤) | `…-22-11-36` | ❌ |
**危害**:入口 / 记忆 / 日志 / md5 全写进错目录,主工作区看起来「什么都没发生」;**每跑一棒多一个**时间戳目录(不是"两份")。
**判据**:会话记录每条都有 `cwd` 字段 —— 开机第一件事比对它;不等 ⇒ **停手报告**,⛔ 不许就地开工。
**对策**:登记方 prompt 加行「① 先 `cd` 到 `<工作区绝对路径>`,校验 cwd 相等」;本棒收尾校验**产物落点**是否在该路径下,越界则报告并搬回。
⚠️ **别与本类混淆 —— 同名重复的六类成因(排查按此顺序)**:
| 类 | 成因 | 特征 | 处置 |
|---|---|---|---|
| **甲·盘符迁移留旧份** | 项目目录名 = 盘符+路径连字符化,路径一变就多一份 | 目录名不同(`d-AI技能-…` vs `e-ProgramData-AI技能-…`) | 发现后清理,旧份不自动消失 |
| **乙·cwd 写法变体**(律⑤) | ① **斜杠方向**(`e:\…` vs `E:/…`)② **盘符大小写**(`e:\…` vs `E:\…`)—— 两个维度都**各自实测裂过**(09-22 / 09-23) | 目录名**看着像同一个**,实为两个 cwd | 新建一律「**反斜杠 + 小写盘符**」;旧份发现后清理 |
| **丙·会话标题重名** | 不同线起了同名 automation | 标题完全相同、cwd 也相同 | 改标题加线名/序号(本项目已普遍带线名,风险低) |
| **丁·cwd 落到时戳目录**(律④) | prompt 没 `cd`,宿主按默认目录拉起 | 一堆 `E:\ProgramData\WorkBuddy\<时戳>` | 见律④ |
| **戊·同名镜像目录**(2026-09-27 加) | **磁盘上真的有两个目录**,路径段里只差一个词(`…\ProgramData\…` vs `…\ProgramDSH\…`),且镜像目录里**另有一份同名入口文件**、还有 `bundles\`/`docs\` 骨架 | 目录名**看着像同一个**、只差一段;两侧都可能被当「本工作区」;⛔ 不是写法变体(乙类)、也**不是**空壳(光看"目录里有东西"判不出来) | 定**唯一权威**(判据 = 实测 `sessions.cwd` 条数最多那条 + app 当前工作区 + `.workbuddy\memory\` 只长在哪侧)→ 镜像侧入口改**跳转 stub** → 改全部 automation 的 `cwds` 与 `--ws` → 已落错的会话搬回权威分组。**只留一处** |
| **己·入口声明的工作区与磁盘不符**(2026-09-27 加) | 搬家或改名后**只搬了目录、没回填入口头部**:入口第 3/10 行仍写着旧路径 —— 实测 `ai1net-decision-laya` 线入口写「工作区 = `E:/ProgramData/AIProject/session-mechanism-laya`」,而**该目录在两个根下都不存在**(真实目录名是 `ai1net-decision-laya`,入口内 33 处引用全是旧名) | 与本表甲/戊**正相反**:那两类是"多了一份",这一类是"**指向的那份根本不在**"。照抄登记 ⇒ 宿主**凭空新建一个幽灵分组**(`projects\e-ProgramData-AIProject-session-mechanism-laya`),比同名镜像更难发现(镜像至少目录存在、还能靠 `ls` 撞见) | 判据 = 把入口里每处「工作区 = …」逐条 `Test-Path`;**任一不存在 ⇒ 以实测 `sessions.cwd` 为准回填入口头部**,⛔ 绝不按入口里的路径去**新建**目录(那会把幽灵坐实) |
⚠️ **「停维护」≠「停用」**(2026-09-27 实测,比上表任何一类都容易漏):定完唯一权威后,只写一句「旧副本停维护」**不算收口** —— 旧侧那份入口仍是**完整正文**,两侧还会各自继续被写。实测当晚:`ai1net-dsh-anywhere` 两侧入口已分叉到 **24903 B vs 23816 B(差 1087 B / 落后 8.5 小时)**、`ai1net-dsh-desktop` 差 **121 B / 20 分钟**,而两条线**都早已拍板"只用 ProgramData 那份"**。⇒ 收口动作必须落到「**旧侧入口就地改成跳转 stub**」(明示"这不是入口"+权威路径+停用原因),否则下一棒读旧侧就会照着过期内容重做。
**律①/② 的实测事故·三(2026-09-27 排查,用户原话「又开始出现接续会话 创建重复的 会话分组了 ai1net_ui」|污染维度 = 同名镜像目录)**
| 环节 | 事实 |
|---|---|
| 现象 | `ai1net_ui` 在 UI 里裂成两个同名分组:`projects\e-ProgramData-AIProject-ai1net_ui`(3 会话)与 `projects\e-ProgramDSH-AIProject-ai1net_ui`(1 会话,建于 23:45:19) |
| 污染源 | 第 1 棒登记第 2 棒时把 `cwds` 写成镜像路径 `E:\ProgramDSH\AIProject\ai1net_ui`。它这么判是因为:入口 `来源会话` 那栏写着「工作区 `E:\ProgramDSH\AIProject\ai1net_ui`」、正文几十处**资产**路径都在 ProgramDSH 下、上一棒给它的抢锁 `--ws` 也是镜像路径 |
| 隐蔽后果一:**锁不互斥** | dsh 锁按 `--ws` 分命名空间 ⇒ 权威 `.locks\e-programdata-aiproject-ai1net_ui-2e350e23` vs 镜像 `.locks\e-programdsh-aiproject-ai1net_ui-97d0d29d`,**两处各一把锁、互不排斥**(第 2 棒持锁期间从权威侧查 `status` 报「空闲 ✅」)。本轮两棒没真并发,纯属运气 —— 这条比 UI 观感危险得多 |
| 隐蔽后果二:**入口分叉** | 第 2 棒只写了镜像那份 ⇒ 45052 B vs 40720 B,md5 `c3f0afa2…` ≠ `07ad6380…`;再走一棒就会读到过期的「本轮动作」 |
| 处置(已落地) | ① 新版入口同步到权威侧 ② 权威入口头部立「本线工作区(唯一权威)」四条约束表 ③ 镜像侧入口改**跳转 stub** ④ 第 3 棒 automation 的 `cwds` + prompt 入口路径 + `--ws` 全改权威侧(排期不动)⑤ 会话 `cc1c1205` 搬回权威分组(先等它 `status=completed`,再 SQL 单行 `UPDATE sessions SET cwd=…` + jsonl 移位 + 删空目录;`integrity_check=ok`;`group by cwd` → 1 行 3 条) |
| 🔴 教训一 | **判「本工作区」⛔ 绝不能读入口里的「来源会话」栏和资产路径** —— 那些是**历史**与**资产位置**,不是工作区。唯一判据 = ① 实测 `sessions.cwd` 条数最多那条字面 ② app 当前打开的工作区 ③ `.workbuddy\memory\` 只长在哪一侧 |
| 🔴 教训二 | **镜像目录不是空壳**(内有 `bundles\`/`docs\` 骨架,看着极像正经工作区)⇒ 光凭"目录里有东西"定不了归属;**入口里出现两个绝对根路径时,先问「磁盘上到底有几个同名目录」** |
| 自查一句 | 我这条线的工作区路径,**在磁盘上是否只有一个目录**?(同名不同根 = 戊类,必须先定权威、再开工;若已裂 ⇒ 清掉错误分组,旧组不会自愈) |
**跨工作区取脚本的正确姿势**(不违反律②)—— 本工作区没有脚本时,用**绝对路径**调、并指定 `--ws`:
python "E:/ProgramData/AIProject/ai1net-dsh-server/state.py" --ws "E:/ProgramData/AIProject/<自己的工作区>"
⛔ 把 `state.py` 复制到每个工作区 = 多一份要维护的代码,`--ws` 是正解(2026-09-21 加;`state.py` 同时新增**归属校验**:头部声明的 `工作区` ≠ 本工作区 ⇒ 标「外来线」并排除出 §2 / 口令)。
---
### 实测事故·四:全库体检(2026-09-27 23:5x · 用户原话「检查 workbuddy 下所有其他会话 看是否也会出现相同的问题」)
**范围**:`projects\` 全部 7 个分组 / `sessions` 全部 11 条 / `automations` 全部 4 条 / `.locks` 全部 12 个键 / 各工作区入口文件。复跑脚本 = `dsh-auto-handoff-chain/audit-mirror.py`(只读;读 `workbuddy.db` 走 `file:…?mode=ro`)。
**结论**:**当前只有 ai1net_ui 裂过、且已修**(分组↔cwd **7:7 一一对应,孤儿 0、缺组 0**,去重键冲突 0)。但**三条线具备同款结构条件**:
| 工作区 | 同名镜像 | 入口双份 | 入口是否分叉 | 锁命名空间 | automation `cwds` | 判定 |
|---|---|---|---|---|---|---|
| `ai1net_ui` | 有 | 有 → **已 stub** | 已修(1883 B stub) | 仅旧侧残留键 | 3 条全=权威侧 | 🟢 已收口 |
| `ai1net-dsh-desktop` | 有 | 有 | 🔴 **分叉 19039 B(23:35) vs 18918 B(23:15)** | 🔴 **两套键**(旧 15:00 `…-3fe5ab6f` / 新 23:36 `…-547001a0`) | 无 | 🔴 P1 |
| `ai1net-dsh-anywhere` | 有 | 有 | 🔴 **分叉 24903 B(23:37) vs 23816 B(15:00),差 1087 B / 8.5 h** | 🔴 **两套键**(旧 15:00 `…-5ee837d7` / 新 23:38 `…-6bce3721`) | 无 | 🔴 P1 |
| `ai1net-decision-laya` | 有 | 有(md5 一致,在同步) | 否 | 自建 `scripts/lock.py`(不涉 dsh 锁) | 无 | 🟠 P1(己类) |
| `aigc-idea-impression` | **无** | 无 | — | — | 1 条=本工作区 ✓ | 🟢 |
**三个新事实(写进判据)**:
1. **`E:\ProgramDSH\AIProject\` 是 `E:\ProgramData\AIProject\` 的整树镜像**(4 个工作区,连 `.workbuddy\memory\automations\` 的 16~24 个棒记录副本都被复制过去)⇒ 这是甲/戊/己三类的**共同温床**:只要这棵镜像树还在,任何一条线把 `cwds`/`--ws` 写到 ProgramDSH 侧就立刻复现。**⛔ 看单个目录判不出来,必须整根比对**。
- 🔴 **2026-09-28 现状**:该镜像树**已整树删除** ⇒ 本条**暂不适用**,保留作**复现判据**。⚠️ 但同一份 `cwds` 裂缝**换了个形式仍会发生**:工作区 09-28 由 `aliyun-dsh-server` 改名为 `ai1net-dsh-server` ⇒ 凡沿旧字面登记的 `cwds` 都会落到(已不存在的)旧目录。
2. **`dsh-anywhere` 的权威侧入口头部第 10 行仍写「工作区 = `E:\ProgramDSH\AIProject\ai1net-dsh-anywhere`」,而同一文件 §24 早已记「唯一权威 = 本目录」** —— 同一份文件内部自相矛盾。⇒ 戊类的"污染源"(第 1 棒据入口头部判错工作区)**在别的线上原样待触发**。
3. **「登记下一棒写错 `cwds`」这条路径全库只发生在本线**:逐条解析 `automation_update` 入参,6 条其他会话的 `mode=create/update` 计数**全为 0**(其余为 `list`/`view`)⇒ 没登记过接续棒的线不会踩。**这才是可复用的判据:先看 `automations` 表里有没有指向镜像侧的 `cwds`,而不是先看 UI。**
**一条被证伪的假设**(省下次排查):`~/.workbuddy` 与 `E:\ProgramData\.workbuddy` **不是** junction 关系,且只有**一个** `workbuddy.db`(`C:\Users\Administrator\.workbuddy\` 下**无** db)⇒ 不存在"双库"问题,分组只有一套。
🔎 **收尾自检一句**:本棒 `cwds` 是否 = 我这条线自己的工作区?入口文件是否只在我这个工作区存在一份?**本次写入的产物是否都在该工作区路径下(律④)?**
---
### 0.7 🔴🔴 活库(`workbuddy.db`)改动铁律(2026-09-23 血的教训 —— ⛔ 违反必炸库)
> 🔴 **纠偏(2026-09-23 22:0x 实证)**:「**必须停 app**」只对**文件级操作**(`cp`/`mv`/覆盖 WAL 三件套)成立,⛔ **不要套到 SQL 变更上**。
> **实证**:**在 app 运行中**用 `sqlite3.Connection.backup()` 做在线一致性备份(产出单文件自洽副本 1.83 MB)→ `UPDATE sessions SET cwd=…`(**16 行**)→ 改前/改后各跑一次 `PRAGMA integrity_check` 全 `ok` → **app 全程正常**(执行者本身就跑在 app 里)。
> ⚠️ 把"必须停 app"套到 SQL 变更会形成**死锁**:执行清理的 AI **永远跑在 app 内** ⇒ 这条规则一读死,「清理已裂组」就永远做不成(用户原话点破:「**关了你咋运行 你就是 workbuddy**」)。**正解 = ⛔ 不碰三件套,只用 SQL + 官方 `backup()` API/`VACUUM INTO`。**
**事故**:为清理「同名会话分组」,`cp` 覆盖了 `E:\ProgramData\.workbuddy\workbuddy.db` ⇒ **WorkBuddy 应用当场读不了库**(`DatabaseError: file is not a database`),且**持续不可读**、app 还在往上写。
**机理**(这才是要记的):`workbuddy.db` 是 **WAL 模式 + 多进程并发**的活库,同一逻辑库由**三个文件**共同构成:
```
workbuddy.db 主库(已 checkpoint 的基线)
workbuddy.db-wal 预写日志(增量,带 salt 世代)
workbuddy.db-shm 共享内存索引(被 app 进程持有)
```
三者必须**同一世代**才自洽。文件级 `cp` 只换其中一部分 ⇒ **salt 世代错位** ⇒ SQLite 判定「不是数据库」。
**铁律四条(⛔ 不可破)**:
| # | 律 | 理由 |
|---|---|---|
| ❶ | **先确认 app 未运行**(`Get-Process WorkBuddy`)⇒ 有进程就**停手**,⛔ 不碰文件 | app 持有 `-shm` 句柄,`cp` 覆盖必然三件套错位 |
| ❷ | **⛔ 禁止文件级 `cp`/`mv`/`rsync` 动活库** | 即使 app 已关,逐文件 `cp` 仍有中间态风险;要备份就三件套一起、要恢复就三件套一起 |
| ❸ | **改数据只走 SQL 连接**(`sqlite3.connect` + `busy_timeout`),⛔ 不碰字节 | 连接路径由 SQLite 自己维护锁与世代 |
| ❹ | **改前必备份三件套**,改后**立即 `PRAGMA integrity_check`** | 备份要含 `-wal`/`-shm`,否则备份本身不自洽 |
**判据(出事时怎么定位)**:
- `file is not a database` ≠ 文件坏了 ⇒ 先看**三件套 salt 是否同世代**:主库 `change counter`(off 24) 与 `version-valid-for`(off 92) 相等 ⇒ **主库自洽**
- 用 `?mode=ro&immutable=1` 打开可**完全绕开 WAL/SHM**:读得出 ⇒ **数据在,只是 WAL 错位**
- ⛔ **`immutable=1` 只能读,不得用于写**(会跳过锁 ⇒ 与 app 打架)
**修法(唯一)**:停 app → **删 `-wal` 与 `-shm`** → 重启 app(SQLite 从自洽主库重建)⇒ 数据零丢失。
**自检一句**:我要动的这个库,**app 现在开着吗**?备份是**三件套一起**吗?改完跑 `integrity_check` 了吗?
---
## 1. 形态:规划棒 ↔ 执行棒 交替
```text
规划棒①(出交接单)→ 执行棒①(照单落地)→ 规划棒②(出下一单)→ 执行棒② → …
↑ 每棒 = 一个全新会话 + 一条一次性 automation,做完自己登记下一棒
```
**为什么拆成两种棒(不是随便切段)**
| 棒 | 只做什么 | 成本量级(实测) |
|---|---|---|
| **规划棒** | **只出交接单**:不改服务器、不改代码、不部署 | 23–56 次调用 / 5.7–9.4 积分 / 3–10 min |
| **执行棒** | **照单落地**:按单子的 S0–Sn 逐条执行、逐条验收 | 93–215 次调用 / 23–29 积分 / 21–41 min |
⇒ 这是「**规划与执行分离**」从纪律变成**机制**:规划棒物理上碰不到生产,执行棒物理上不必做取舍。
**⛔ 什么时候不要用这套**
- 一次性小任务(直接做,别排链条)
- 步骤可并行(用并行会话 + **域锁**:域不重叠即真并行;机制层任务仍须独占,见 §3.5)
- **任务形态本身就贵**(例:批量改写 N 份文档)—— 那只该**先写脚本一次跑完**;接力**救不了**贵的活,只换场地
- 单棒做不完(会超出上下文预算)⇒ 说明**棒还要再切细**,或改用「接续包 v2 + 开机四步」
- 🔴 **链条上存在「需要用户拍板」的决策点** ⇒ 可自决的段落照常接力,但**必须在拍板点前停下**,不许自动跨过去(**见 §3.3 登记门禁**)
---
## 2. 六件套 prompt 骨架(照抄填空)
> **位置**:每棒的 prompt 写在 `automation_update` 的 `prompt` 字段里。
> **要点**:整段 400–1,300 字符。**越长越糟** —— 每多抄一个技术细节,就多一个漂移源。
```text
<线名> · <第 N 棒:规划棒 / 执行棒>(本轮只做这一件事,做完即停)。
第 0 步:跑 `<绝对路径>/state.py` 看状态(只读、免抢锁、1 次调用拿到锁/git/入口/收口)。
第 1 步(开工门禁):先跑 `bash "<绝对路径>/preflight-lock.sh" "<线名>-<第N棒>" <目标文件...>` —— **【D】未归类 或【E】机制层非空 ⇒ rc=1 ⇒ 拒开工**(先去归类/改独占)。
第 1b 步:抢锁 `bash "<绝对路径>/handoff-guard.sh" --claim-exec "<线名>-<第N棒>" --domains <域...>`;**抢不到 = 冲突域被占 ⇒ 只报告并立刻停**(⛔ 不得删锁,R9)。
⚠️ 机制层任务(`scripts`/`config`/`crypto`/`isolation`/`index`/`CODEBUDDY.md`/锁本身)**⛔ 不加 `--domains`** ⇒ 退化全局独占。
第 2 步:读**唯一执行依据** = `<入口文件绝对路径>` 的 **§2「🎯 本轮动作」块**,按它点名的那份交接单开工。
(规划棒专属)本轮任务:出可执行交接单,落盘 `<路径>`,按 8 段模板:目标 / 只读前置 / 范围 / 决策点 / 步骤 S0–Sn / 逐条验收判据 / 回滚 / §8 回报格式。
(规划棒专属)⛔ 开工前先加载技能 `session-mechanism`(取舍判据以它为准,不凭记忆)。
约束:⛔ 不 commit / push;⛔ 不做 <明确点名的排除项>;⛔ 不重做 <已收官的序号>;⛔ 不扩大单子范围(单外发现的缺陷先报告、不动手)。
成本纪律:批量活先写脚本再让脚本跑;取证最多 3 条命令;⛔ 不要 Glob/Grep 全库摸底;一轮内工具调用次数尽量压低。
纪律:技术实现项**自决不上抛**;只有「没有客观优劣」的取舍才列候选,且每个候选必须写「优点 / 缺点」、**候选竖排成段**(不横排);判据必须可被第三方复现。
收尾(缺一即算未完成):① 释放锁 `--release-exec "<会话名>"`(⛔ 不带会话名 ⇒ **拒绝释放** · 2026-09-25 修);② **先过登记门禁(见 §3.3)**——只有「下一棒可自决」才登记,**`scheduledAt` = 此刻 + 5~8 分钟(见 §3.1.1 铁律①,⛔ 不许留长等待窗口)**,且**同一时刻只挂一个接续棒**(§3.1.1 铁律②:⛔ 不预登记队列,后续项写进入口 §2 的「⏭️ 本线下一项」由下一棒自己排);用**陈述句**告知「已登记自动接续、约 5~8 分钟后自动开新会话、接续点 = 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 里的**内联摘要**在起作用,完整方法论从未进上下文。
**修法**(分棒区别对待,别一刀切):
- **规划棒必须加**:`⛔ 开工前先加载会话技能 session-mechanism`(规划棒基数只有 23–56 次调用,+1 次可接受,且它确实要做取舍、要出单)
- **执行棒可以不加**:单子已经把判断写死了,再加载方法论是纯开销(执行棒基数已 93–215 次)
---
## 3. 收尾四件套(缺一即算未完成)
> 这四件里**第 ② 件是唯一会"断链"的地方**,也是钩子**做不到**的地方(钩子不能创建会话、不能创建自动化)。
> 🔴 第 ② 件的两条硬约束(详见 §3.1.1):**只排"下一棒"这一棒**(⛔ 不预登记队列)+ **`scheduledAt` = 收口 + 5~8 分钟**。
| # | 动作 | 判据 |
|---|---|---|
| ① | 释放锁 `--release-exec "<会话名>"`(⛔ 不带名 ⇒ 拒绝释放) | 跑一次信息模式确认已释放 |
| ② | **先过 §3.3 登记门禁** → 登记下一棒的一次性 automation(**`scheduledAt` = 此刻 + 5~8 分钟**,见 §3.1.1 铁律①;**同一时刻只挂一个**,铁律②)+ **在给用户的回复里用陈述句告知** | 门禁不过 ⇒ **不登记,改为告知"链条已暂停待拍板"**;陈述句三要素:约 5~8 分钟后自动开新会话 / **不用你操作** / 接续点 = X |
| ③ | 把入口文件 §2「🎯 本轮动作」**推进到再下一棒** | 入口 = 下一棒的**第一信息源**;不推进 ⇒ 下一棒照旧口径做,做重工 |
| ④ | 写工作区日志(当日 `memory/YYYY-MM-DD.md` 追加自己的小节) | 只追加自己的小节,⛔ 不重写别人的段落 |
### 3.1 收尾陈述句模板(实测原文,照抄)
```text
已登记自动接续:一次性 automation `<id>`,约 5~8 分钟后(08:37)自动开新会话,不用你操作;
接续点 = 序 ④「443/TCP 兜底」的规划棒(出 `05-交接单/交接单_443兜底_20260917.md`,只出单、不改服务器)。
收口:锁 抢 ✓ → 释放 ✓(08:32)|未 commit / 未 push|入口 §0/§2 已刷到「③ 已完成 → 下一棒 ④」|产出物已交付。
```
**⛔ 只登记不告知 = 缺陷**:新建会话是**用户可感知的状态变更**。实测:`408636f2` 登记了自动化却一字未提,用户两小时后自己发现才追问。
#### 3.1.1 🔴 排期两条铁律:**首个(也是唯一的)接续棒** = 收口 + **3~4 分钟**;⛔ **同一时刻只挂一个**(2026-09-18 定稿;🔴 **2026-10-01 用户口径改值:5~8 ⇒ 3~4 分钟**)
> 用户 2026-09-18 原话:「**首个接续任务 5-8分钟**」+「**最好不要建立多个接续任务,一个会话结束时在排下一个**」
- **铁律①(间隔)**:`scheduledAt` = **收口时刻 + 3~4 分钟**(🔴 2026-10-01 用户口径,原 5~8 作废)。⚠️ 这 3~4 分钟是「**从本会话收口,到那"唯一一个"接续棒开跑**」的间隔 —— ⛔ **不是"棒与棒之间的间隔"**,后者根本不存在(由铁律②,任何时刻只该有一个待跑接续棒)。
- **铁律②(唯一)**:**同一时刻只挂一个接续棒**,下一棒**只能由"正在收官的那一棒"自己排**。⛔ **禁止预登记队列 / 堆叠**("我先把后面两棒都排上" = 违规)。
- **后续项不会丢**:把它写进入口文件 §2 的 **「⏭️ 本线下一项(⛔ 本棒不预登记)」** 行(含要点 / 根因 / 验收基线),**由当前那一棒收官时照此立棒**。
- **⚠️ 「重挂」的唯一正确做法 = 新建,不是改时间**(2026-09-21 实测,见 §4 第 ⑦ 条):棒因为**抢不到锁**等原因空转、你想"过 8 分钟再来一次"时,⛔ **不要** `mode="update"` 改 `scheduledAt` —— 已跑过的 once 型棒改时间**不会产生新的运行记录**(链条静默死亡)。正确做法:`mode="create"` **新建一条同内容 automation**,旧棒置 `PAUSED`(保住铁律②的"只挂一个"),并在 prompt 里写明"抢不到就照此法自助重挂"。
**边界(哪一头都不能越)**
| 方向 | 判据 |
|---|---|
| 下限为何是 5(不是 2) | 避开调度器最小提前量与文件落盘竞态 |
| 上限 8 何时可越 | ⛔ 只有「**要等一个外部窗口**」才允许拉长(对方服务重启完 / 另一条棒在跑 / 用户拍板),且**必须在陈述句里写明在等什么** |
| ⛔ 不许的两条理由 | 「**给用户留追改窗口**」(追改可在任何一轮直接打断 ⇒ 等价于主动空转)· 「**怕它跑不完**」(**排期按实测基线算,不按猜不确定性算**:规划棒实测只需 **6–13 分钟**,序⑦/⑨/⑪/⑯/⑱/㉔) |
**实测事故 2026-09-18(一棒之内两处都犯过 ⇒ 本节因此重写)**
| # | 我做了什么 | 用户原话 | 性质 |
|---|---|---|---|
| ① | ㉘ 规划棒排到 **收口 + ~2 h**,又给 ㉙ 留 **1.5 h 余量** | 「为什么时间要定在3:00」→「㉘ 规划棒 = 01:30 **也还有1个多小时呢**」 | 违反铁律①:长空转 + **用"猜不确定性"代替实测基线** |
| ② | 用户说「**5-8分钟即可**」后,我把它读成 **"棒与棒之间的间隔"**(㉘→㉙ = 8 分钟),于是**同时挂了 ㉘ + ㉙ 两个接续棒** | 「我说的是**首个**接续任务 5-8分钟,**最好不要建立多个接续任务,一个会话结束时在排下一个**」 | 违反铁律②:预登记队列。**根因 = 改规则时只在旧句子上换了个数字,没有回到本节核对规则的完整定义** |
⇒ **判据**:任何一次"排期调整"都要**回到本节逐条对照两条铁律**;⛔ 不许只换数字就交差 —— 数字与"口径落在哪个对象上"会一起漂移。
#### 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.1.3 🔴 跨线交付:**「投单 ≠ 启动」** —— 工单不会自己变成动作(2026-09-28 实测漏)
**事实**:给**另一条线**(另一工作区)投了对接单、还在对方入口加了指针之后,那条线**不会因此开工**。
依据就是你本技能的第一律 —— **「自动化 = 开新会话的唯一通道」**:没有 `cwds` 指向那个工作区的 automation,**就没有任何机制把对方拉起来**;工单只是「文件里的留言」,得等某个会话去读它。
**实测(2026-09-28)**:平台线给 `ai1net-dsh-anywhere` 投了对接单(15:10 落地),并对用户说「让相关会话都执行起来」——
但 automation 全表 6 条里**没有一条指向那个工作区**,对方**当天零活动**(根目录 mtime 停在 09-27 23:38、无当日日志)。
⇒ 用户后来直接问「**为什么另一个会话还没开发 app**」,根因就是**投单之后没排棒**。
**判据(投完单必自问一句)**:这张单**由谁、在什么时候**读?
- 有 automation ⇒ ✅ 已闭环
- 没有 ⇒ **立刻补一条 `once` automation**:`cwds` = 对方工作区,prompt 写「读 X 单并执行」(⛔ 别指望对方"自己会看到")
⚠️ **投单前还要核对对方的「开工依据」是否仍然成立**:实测那条线入口的「下一步」依赖的宿主目录**已被删除** ⇒ 就算把它拉起来,按自己的入口办也会失败。
⇒ **投单 + 排棒 + 在棒里写明「局面已变、⛔ 别按旧入口办」**,三件一起才算送到。
⚠️ **跨线排棒要防"撞车"**:若对方要动的东西正被自己这条线用着(例如今晚要端到端验证的那批文件),**把对方那一棒设成「只读核对 + 出结论」**,⛔ 不给写权限 —— 否则两条线同时改同一批文件。
### 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,甚至 ~2 h) | 链条白白空转,**用户当场追问** | 实测:序⑦执行棒 12:2x 收口、下一棒排 12:50 ⇒ 用户 12:26 追问「**为什么要等20多分钟才执行接续会话**」;09-18 又排过「收口 + ~2 h」。⇒ 修法 = §3.1.1 铁律①:**= 收口时刻 + 5~8 分钟**,且**按实测基线算**(规划棒 6–13 分钟) |
| ⑥ | **预登记多个接续棒**(队列堆叠) | 链条上挂着 ≥2 个待跑棒 ⇒ 抢锁空转;后续项要么白烧要么被撤,**用户当场纠正** | 实测 09-18:㉗ 棒同时挂了 ㉘+㉙ 两条 ⇒ 用户原话「**最好不要建立多个接续任务,一个会话结束时在排下一个**」。⇒ 修法 = §3.1.1 铁律②:**同一时刻只挂一个**;后续项写进入口 §2 的「⏭️ 本线下一项(⛔ 本棒不预登记)」,由当棒收官时立棒 |
| ⑦ | **「重挂」= 改 `scheduledAt` ⇒ 根本不触发**(棒被锁挡回、想重排一次时最容易踩) | 链条**静默死亡**:以为"5~8 分钟后会自动重试",实际调度器**从未创建运行**,要等用户追问才发现 | 实测 09-21 carbon 棒③:06:24 把 `scheduledAt` 改成 06:34 ⇒ 06:42 仍零痕迹;`daemon.log` 里**不存在** `:1789943*` 形态的运行记录,只有原 06:23 那一笔 ⇒ **已跑过的 once 型棒,改时间不会重新入队**。⇒ 修法 = **重挂必须新建 automation**(`mode="create"`),并把旧棒置 `PAUSED`(保「同一时刻只挂一个」);⛔ 别用改时间重排。取证方式 = 运行记录形如 `<automationId>:<epochMs>`,去 `E:/ProgramData/.workbuddy/logs/daemon.log` 里按 `<id>:<期望时间戳前 7 位>` 搜 |
### 3.5 🔴 域锁四个已实证的「假绿」坑(2026-09-22 落地时全踩过)
> 域锁 = 锁携带**资源声明**(`DOMAINS`),可算交集 ⇒ **域不重叠即可真并行**(取代旧的「全局只放一个」)。
> 机制层(`scripts`/`config`/`crypto`/`isolation`/`index`/`CODEBUDDY.md`/锁本身)**全平台共用 ⇒ 必须独占**。
| # | 坑 | 症状 | 修法 |
|---|---|---|---|
| ① | **域键算法 shell / hook 两侧不一致** | 一侧取"首个锚点段"、另一侧取"前两段" ⇒ 同一路径算出不同域 ⇒ **交集永远算空** ⇒ 该拦的全放行 | 锚点表**逐字一致**且**先长后短**(`dsh-server-docs` → `aliyun-dsh-server` → `src`/`web`/`test`…);改一侧必须同步另一侧 |
| ② | **非 ASCII 会话名塌成同一目录**(本平台**必现**) | `tr -c 'A-Za-z0-9._-' '_'` 把每个非 ASCII 字符压成 `_` ⇒ 「域锁-A」「域锁-B」「域锁-C」**全映射成同名目录** ⇒ 一锁被多人共用、域声明被并进同一份 ⇒ **域隔离整体失效** | 会话名含非 ASCII ⇒ 目录名**附 `cksum` 摘要**(唯一、确定、无外部依赖);ASCII 名直接留用(可读+兼容) |
| ③ | **同会话二次抢锁 `rm -rf` 重建** | 锁目录名只由会话名派生 ⇒ 第二次抢 `src/net` 把第一次的 `src/im` 声明**冲掉** ⇒ 异会话可抢进来 | 改为**合并**:`grep -qxF` 去重后追加进 `DOMAINS` ⇒ 一会话一把锁、多域累积 |
| ④ | **`trap` 单槽位互相覆盖** | bash 的 `trap` 是**单一槽位** —— 先后注册两次,后者**覆盖**前者 ⇒ 必然漏清一样(实测漏 `.gate`) | 收敛成**一个**清理函数同时清所有临时物,全流程**只注册一次**;⚠️ 且**只清自己创建的**(`.gate` 可能是别人的,无脑删 = 破坏别人的互斥) |
**⚠️ 验收 hook 的前置条件(⛔ 不满足就是在测错的东西)**:`_is_mine()` 要求**会话名与 `session_id` 同时吻合**。
若不设 `DSH_SESSION_NAME`,且多把锁写着**同一个** `CODEBUDDY_SESSION_ID`(同进程批量抢锁必然如此)⇒ 会话名为空 ⇒ 退到「只看 id」分支 ⇒ **把别人的锁认成自己的** ⇒ 假绿放行。
⇒ **验收必须带 `DSH_SESSION_NAME=<会话名>`**,并覆盖四场景:我改我域(放行)/我改他域(拦)/他改我域(拦+报精确冲突域)/他改他域(放行)。
**⚠️ 收尾顺序铁律**:`--release-exec "<会话名>"` **必须放在所有文件改完之后**(⛔ 不带会话名 ⇒ 拒绝释放)。
提前放锁后再改文件 ⇒ 被 hook 以「没有持任何锁」**硬拦**(fail-closed,按设计工作)⇒ 只能重新抢锁再改。
⇒ **改完 → 回归 → 才放锁**。
---
## 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-workflow/references/dsh-auto-handoff-chain/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(`+5~8 分钟`,见 §3.1.1 铁律①),prompt 照 §2 骨架填
5. **每棒收尾走完 §3 四件套**(其中第 ② 件**前置 §3.3 登记门禁**)—— 推进 §2 + 登记下一棒;🔴 **同一时刻只挂一个接续棒**:本棒只排"下一棒"一棒,后续项写进入口 §2 的「⏭️ 本线下一项」行,⛔ 不预登记队列
6. **收口**:链条跑完时,最后一棒不登记下一棒,改为**明确告知用户"链条已完结"**
7. **遇到拍板点**:停在「待你拍板」节(候选竖排 + 优缺点 + 我的倾向),**并明确告知链条已暂停**;等用户拍板后再由当时会话登记下一棒
---
## 变更历史(原 frontmatter · 逐字保留)
```text
name: dsh-auto-handoff-chain
description: 长任务「多棒自动接力」编排法 —— 把一个跨越多个上下文窗口的大任务,拆成「规划棒 ↔ 执行棒」交替的一次性自动化链条,每棒做完自动开新会话接下一棒,**全程零人工点击**。当用户说「自动新建会话接续处理」「接力跑下去」「多步骤任务自动推进」「跑完一棒自动接下一棒」「无人值守推进」,或一个任务预计要跨 ≥3 个会话 / 超过一个上下文窗口时触发。核心 = 六件套 prompt 骨架(状态单点 → 唯一执行依据指针 → 全局锁 → 单一动作 → 成本纪律 → 收尾四件套)+ **登记门禁(★要拍板的,等拍了再登记 —— 用户 2026-09-17 明令,当天已有实测事故 §3.3.1)** + 七条实测防护(断链 / 双开 / once 不转完成态 / 跨过拍板点 / **下一棒定太晚** / **预登记多个接续棒** / **重挂改时间不触发(必须新建)**,排期纪律见 §3.1.1)⛔ **投单 ≠ 启动(§3.1.3,09-28 实测)**:给别条线投工单+在对方入口加指针**不产生任何动作**(automation 全表无一条 `cwds` 指向那个工作区 ⇒ 对方**当天零活动**,用户直接问「为什么另一个会话还没开发 app」)⇒ 必须**补一条 `once` automation**,`cwds` = 对方工作区;⚠️ 投单前还要核对对方「开工依据」是否仍成立(实测其依赖目录已删),并在棒里写明「局面已变、⛔ 别按旧入口办」。+ 实测成本基线 + 复跑脚本 `scripts/chain_report.py`。⛔ **排期两条铁律(§3.1.1,用户 2026-09-18 明令)**:首个(唯一)接续棒 = 收口 + **5~8 分钟**;**同一时刻只挂一个接续棒**,下一棒由当棒收官时再排。⛔ 两条关键判据:**prompt 里绝不抄任务细节**(细节只有一个漂移源 = 入口文件的「本轮动作」块);**先判「是不是要拍板」,未命中才轮到「候选排不排得出优劣」—— 顺序颠倒就会自我扩权**。⛔ **工作区归属五律(§0.6,2026-09-21 立①②③④ / 09-22 加⑤)**:入口文件只允许一份(在本工作区)· automation 的 `cwds` = 本工作区 · 参考接续规则用本技能(不是去读别的工作区的文档)· 🔴 **`cwds` 只写「与手动开会话的 cwd 逐字同形」(判据 = 实测 `sessions.cwd`;**当前正解 = `E:/ProgramData/AIProject/ai1net-dsh-server` 正斜杠**)**(`E:/…` ✅/`E:\…` ❌/`e:\…` ❌ ⇒ 同一工作区在 UI 里裂成**两个同名会话分组**,实测 09-22 斜杠方向、09-23 盘符大小写**两次**;⚠️ 宿主去重键 = `path.trim().toLowerCase()` ⇒ **只小写、不统一斜杠**(09-24 代码级实证))。⛔ **同名镜像目录(§0.6 戊类 · 2026-09-27 实测)**:入口文件同时存在于**两个真目录**(如 `…\ProgramData\…` 与 `…\ProgramDSH\…`)⇒ ① 同一工作区裂成**两个同名会话分组** ② dsh 锁按 `--ws` 分命名空间 ⇒ **两处各一把锁、互不排斥(等于没上锁)** ③ 入口分叉;判据 = **磁盘上到底有几个同名目录** + 实测 `sessions.cwd` 条数最多那条;⚠️ 镜像目录**不是空壳**(可能有 `bundles\`/`docs\` 骨架),⛔ 别看入口里的「来源会话/资产路径」判工作区。⛔ **己类(2026-09-27 实测)**:入口里声明的**工作区路径在磁盘上不存在**(搬家或改名后只搬目录、没回填入口头部 —— 实测 `session-mechanism-laya` vs 真实目录 `ai1net-decision-laya`,入口内 33 处旧名)⇒ 照抄登记会**凭空新建一个幽灵分组**;判据 = 把入口里每处「工作区 = …」逐条 `Test-Path`,任一不成立即以**实测 `sessions.cwd`** 为准回填(⛔ 别按旧名去新建目录)。🔴 **「停维护」≠「停用」**:定完唯一权威后,旧侧那份入口**必须就地改成跳转 stub**,只写一句"旧副本停维护"**不算收口** —— 实测两条线因此各留了一份完整旧正文,当晚已分叉(24903 B vs 23816 B)。全库自查一条命令:`scripts/audit-mirror.py`。🔴🔴 **活库改动铁律(§0.7,09-23 血教训)**:⛔ **「必须停 app」只对文件级操作成立**(`cp`/`mv`/覆盖 WAL 三件套会 salt 错位 ⇒ `file is not a database`);**SQL 变更不需要停 app**(执行者永远跑在 app 里,要求停 = 死锁 —— 09-23 22:0x 已纠偏,见 §0.7)—— 改数据**只走 SQL 连接**、备份走官方 `Connection.backup()`/`VACUUM INTO`、改完必跑 `integrity_check`。
version: 1.7.5
updated_at: 2026-09-28
created_from: 覆盖网络线 19 个会话(2026-09-16 ~ 09-17)的实测复盘 —— 09-17 链条连续 8 棒零断链,规划棒成本降至旧形态的 1/8–1/12
last_change: 【2026-09-28 跨线交付漏排棒】新增 §3.1.3「**投单 ≠ 启动**」—— 实测:给另一条线投了对接单+在对方入口加了指针,但 automation 全表**无一条** `cwds` 指向该工作区 ⇒ 对方**当天零活动**(用户直接问「为什么另一个会话还没开发 app」)。判据 = 投完单必自问「**这张单由谁、在什么时候读**」;没有 automation 就**立刻补一条 `once`**(`cwds` = 对方工作区)。附两条:① 投单前要核对对方「开工依据」是否仍成立(实测其依赖的宿主目录**已被删除** ⇒ 拉起来也会失败,须在棒里写明「局面已变、⛔ 别按旧入口办」);② 跨线排棒若涉及**自己正在用的文件** ⇒ 设成「**只读核对 + 出结论**」防两条线撞车。此前 09-27:【2026-09-27 全库体检】加 §0.6「己·入口声明的工作区与磁盘不符」成因类(→ 六类)+「停维护 ≠ 停用」铁律(旧侧入口必须改 stub)+ 实测事故·四(全库 7 工作区扫描,附复跑脚本 `scripts/audit-mirror.py`)。
agent_created: true
```
---
## 变更历史(**对侧副本** frontmatter · 逐字保留 · 来自 `dsh-auto-handoff-chain` 的 文档库 版)
> ⚠️ 本档正文取自**另一侧**(超集);此处补上对侧副本的版本史,⛔ 以保证不丢任何事实。
```text
name: dsh-auto-handoff-chain
description: 长任务「多棒自动接力」编排法 —— 把一个跨越多个上下文窗口的大任务,拆成「规划棒 ↔ 执行棒」交替的一次性自动化链条,每棒做完自动开新会话接下一棒,**全程零人工点击**。当用户说「自动新建会话接续处理」「接力跑下去」「多步骤任务自动推进」「跑完一棒自动接下一棒」「无人值守推进」,或一个任务预计要跨 ≥3 个会话 / 超过一个上下文窗口时触发。核心 = 六件套 prompt 骨架(状态单点 → 唯一执行依据指针 → 全局锁 → 单一动作 → 成本纪律 → 收尾四件套)+ **登记门禁(★要拍板的,等拍了再登记 —— 用户 2026-09-17 明令,当天已有实测事故 §3.3.1)** + 七条实测防护(断链 / 双开 / once 不转完成态 / 跨过拍板点 / **下一棒定太晚** / **预登记多个接续棒** / **重挂改时间不触发(必须新建)**,排期纪律见 §3.1.1)+ 实测成本基线 + 复跑脚本 `scripts/chain_report.py`。⛔ **排期两条铁律(§3.1.1,用户 2026-09-18 明令)**:首个(唯一)接续棒 = 收口 + **5~8 分钟**;**同一时刻只挂一个接续棒**,下一棒由当棒收官时再排。⛔ 两条关键判据:**prompt 里绝不抄任务细节**(细节只有一个漂移源 = 入口文件的「本轮动作」块);**先判「是不是要拍板」,未命中才轮到「候选排不排得出优劣」—— 顺序颠倒就会自我扩权**。⛔ **工作区归属五律(§0.6,2026-09-21 立①②③④ / 09-22 加⑤)**:入口文件只允许一份(在本工作区)· automation 的 `cwds` = 本工作区 · 参考接续规则用本技能(不是去读别的工作区的文档)· 🔴 **`cwds` 只写「与手动开会话的 cwd 逐字同形」(判据 = 实测 `sessions.cwd`;**当前正解 = `E:/ProgramData/AIProject/aliyun-dsh-server` 正斜杠**)**(`E:/…` ✅/`E:\…` ❌/`e:\…` ❌ ⇒ 同一工作区在 UI 里裂成**两个同名会话分组**,实测 09-22 斜杠方向、09-23 盘符大小写**两次**;⚠️ 宿主去重键 = `path.trim().toLowerCase()` ⇒ **只小写、不统一斜杠**(09-24 代码级实证))。🔴🔴 **活库改动铁律(§0.7,09-23 血教训)**:动 `workbuddy.db` 前**必须先确认 app 已关**,⛔ **禁止文件级 `cp`/`mv`**(WAL 三件套会 salt 错位 ⇒ `file is not a database`),改数据**只走 SQL 连接**、备份与恢复**都三件套一起**、改完必跑 `integrity_check`。
version: 1.7.2
updated_at: 2026-09-23
created_from: 覆盖网络线 19 个会话(2026-09-16 ~ 09-17)的实测复盘 —— 09-17 链条连续 8 棒零断链,规划棒成本降至旧形态的 1/8–1/12
last_change: 【2026-09-22 按要求统一版本号】frontmatter `version` → `1.0.0`(原 v1.5.0);正文与历史中的版本号为当时记录,未改动。
agent_created: true
```
@@ -0,0 +1,363 @@
# -*- coding: utf-8 -*-
"""同名重复会话分组 · 全库只读体检
================================================
用途:一条命令扫出「同一工作区在磁盘上有几份」,以及由此派生的四类隐患:
① 会话分组重复(projects\\<cwd-slug> 与 DB 的 sessions.cwd 不再一一对应)
② 锁命名空间分裂(dsh 锁按 --ws 算键 ⇒ 两处各一把锁、互不排斥)
③ 入口文件双份 / 已分叉(接续棒会读到过期的「本轮动作」)
④ automation 的 cwds 指向非权威侧
只读。绝不写 workbuddy.db、不动任何工作区文件。
🔴 2026-09-28 现状:本脚本默认比对的「镜像侧」`E:\ProgramDSH\AIProject` **已整树删除**
⇒ 脚本**功能已失效**(两侧恒为空对 ⇒ 只走"跳过"分支)。代码保留备查;若日后重建镜像树,可原样复用。
判据与成因分类见 SKILL.md §0.6(甲~己)与「实测事故·四」。
用法:
python audit-mirror.py # 全量(默认)
python audit-mirror.py --roots E:\\ProgramData\\AIProject E:\\ProgramDSH\\AIProject
python audit-mirror.py --only ai1net_ui # 只看名字含该串的工作区
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import os
import re
import sqlite3
import sys
BS = chr(92) # 反斜杠;用 chr() 免得被各种 shell 的转义吃掉
DSH_HOME_DEFAULT = r"E:\ProgramDSH\.dsh"
WB_HOME_DEFAULT = r"E:\ProgramData\.workbuddy"
DEFAULT_ROOTS = [
r"E:\ProgramData\AIProject",
r"E:\ProgramDSH\AIProject",
r"E:\ProgramData\WorkBuddy",
r"C:\Users\Administrator\WorkBuddy",
]
ENTRY_KEYS = ("接续入口", "交接单", "接续包")
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "_venv", "dist", "build", ".next"}
# ---------------------------------------------------------------- 工具
def norm(p: str) -> str:
return p.replace("/", BS).rstrip(BS)
def slug_of(path: str) -> str:
"""复现宿主的 cwd -> 分组目录名 规则:分隔符统一成 '-'、合并连续、去首尾、盘符转小写。
实测(2026-09-27 · 7/7 命中):
'E:/ProgramData/AIProject/ai1net_ui' -> 'e-ProgramData-AIProject-ai1net_ui'
注意:**只小写盘符**,中间路径段保留原大小写。
"""
s = path.strip()
for ch in (BS, ":", "/", " "):
s = s.replace(ch, "-")
s = re.sub("-+", "-", s).strip("-")
return (s[0].lower() + s[1:]) if len(s) > 1 and s[1] == "-" else s
def ts(v) -> str:
try:
return datetime.datetime.fromtimestamp(v / 1000).strftime("%m-%d %H:%M:%S")
except Exception:
return str(v)
def manifest(root: str, cap: int = 4000) -> dict:
out = {}
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for f in filenames:
full = os.path.join(dirpath, f)
try:
st = os.stat(full)
except OSError:
continue
out[os.path.relpath(full, root)] = (st.st_size, int(st.st_mtime))
if len(out) >= cap:
out["__TRUNCATED__"] = (1, 0)
return out
return out
def compare(a: str, b: str) -> dict:
ma, mb = manifest(a), manifest(b)
ka, kb = set(ma) - {"__TRUNCATED__"}, set(mb) - {"__TRUNCATED__"}
only_a, only_b = sorted(ka - kb), sorted(kb - ka)
size_diff = sorted(k for k in ka & kb if ma[k][0] != mb[k][0])
return {
"files_a": len(ka), "files_b": len(kb),
"only_a": only_a[:6], "n_only_a": len(only_a),
"only_b": only_b[:6], "n_only_b": len(only_b),
"size_diff": size_diff[:6], "n_size_diff": len(size_diff),
"trunc": ("__TRUNCATED__" in ma or "__TRUNCATED__" in mb),
}
def md5(p: str) -> str:
with open(p, "rb") as fh:
return hashlib.md5(fh.read()).hexdigest()[:8]
def hr(title: str) -> None:
print()
print("=" * 78)
print(title)
print("=" * 78)
# ---------------------------------------------------------------- 各节
def read_db(wb_home: str):
db = os.path.join(wb_home, "workbuddy.db")
if not os.path.isfile(db):
print("⛔ 找不到 %s" % db)
sys.exit(2)
con = sqlite3.connect("file:%s?mode=ro" % db.replace(BS, "/"), uri=True)
sessions = list(con.execute(
"select id,cwd,is_background_automation,deleted_at,created_at,last_activity_at,"
"coalesce(custom_title,title) from sessions order by cwd,created_at"))
automations = list(con.execute(
"select id,name,status,schedule_type,scheduled_at,cwds,created_at from automations order by created_at"))
return con, sessions, automations
def sec1_sessions(sessions, roots, only):
hr("§1 会话 cwd 存在性 + 同名镜像检测")
bycw = {}
for r in sessions:
bycw.setdefault(norm(r[1]) if r[1] else "", []).append(r)
pairs = []
for c in sorted(bycw):
if not c:
continue
base = os.path.basename(c)
if only and only not in base:
continue
sibs = [os.path.join(rt, base) for rt in roots
if os.path.isdir(os.path.join(rt, base))
and os.path.normcase(os.path.join(rt, base)) != os.path.normcase(c)]
print("\n--- cwd = %s" % c)
print(" 目录存在: %-5s 会话数: %d" % (os.path.isdir(c), len(bycw[c])))
for r in bycw[c]:
print(" %s auto=%s del=%s created=%s last=%s %r" % (
r[0][:8], r[2], r[3], ts(r[4]), ts(r[5]), r[6][:36]))
if sibs:
for s in sibs:
pairs.append((c, s))
print(" [戊] 同名镜像: %s" % s)
else:
print(" [ok] 无同名镜像")
return pairs
def sec2_pairs(pairs):
hr("§2 镜像对内容比对(真副本 / 空骨架 / 谁更全)")
if not pairs:
print("(无镜像对)")
for a, b in pairs:
print("\n--- %s\n vs %s" % (a, b))
if not (os.path.isdir(a) and os.path.isdir(b)):
print(" ⚠️ 有一侧不存在 —— 属**己类**(入口声明与磁盘不符),不是戊类")
continue
r = compare(a, b)
print(" 文件数 A=%d B=%d%s" % (r["files_a"], r["files_b"],
" (已截断)" if r["trunc"] else ""))
print(" 仅 A 有 %d;仅 B 有 %d;同名但大小不同 %d" % (
r["n_only_a"], r["n_only_b"], r["n_size_diff"]))
for k, lbl in (("only_a", "A 独有"), ("only_b", "B 独有"), ("size_diff", "大小不同")):
if r[k]:
print(" %s: %s" % (lbl, r[k]))
if not r["trunc"] and r["n_only_a"] == r["n_only_b"] == r["n_size_diff"] == 0:
print(" => 逐字节同构(真副本,最危险)")
elif r["n_only_a"] > r["n_only_b"]:
print(" => A 更全 ⇒ **A 极可能是权威侧**")
elif r["n_only_b"] > r["n_only_a"]:
print(" => B 更全 ⇒ **B 极可能是权威侧**")
def sec3_entries(sessions, roots, only):
hr("§3 入口 / 交接类文件:双份与分叉")
seen_base = set()
for r in sessions:
if not r[1]:
continue
c = norm(r[1])
base = os.path.basename(c)
if base in seen_base or (only and only not in base):
continue
seen_base.add(base)
dirs, seen = [c], {os.path.normcase(c)}
for rt in roots:
p = os.path.join(rt, base)
if os.path.isdir(p) and os.path.normcase(p) not in seen:
dirs.append(p)
seen.add(os.path.normcase(p))
print("\n--- 工作区 %s" % base)
found = []
for d in dirs:
if not os.path.isdir(d):
continue
for f in sorted(os.listdir(d)):
if f.lower().endswith(".md") and any(k in f for k in ENTRY_KEYS):
p = os.path.join(d, f)
found.append((p, os.path.getsize(p),
datetime.datetime.fromtimestamp(os.path.getmtime(p)).strftime("%m-%d %H:%M"),
md5(p)))
if not found:
print(" (无入口类文件)")
continue
for p, sz, mt, h in found:
print(" %-62s %7dB %s md5=%s" % (p, sz, mt, h))
names = {}
for p, sz, mt, h in found:
names.setdefault(os.path.basename(p), []).append((p, sz, h))
for nm, lst in names.items():
if len(lst) > 1 and len({x[2] for x in lst}) > 1:
print(" 🔴 已分叉: %s 字节 %s" % (nm, [x[1] for x in lst]))
def sec4_automations(automations, sessions, roots):
hr("§4 automation 的 cwds 归属")
import json as _j
live = {norm(r[1]) for r in sessions if r[1]}
for aid, name, status, stype, at, cwds, created in automations:
try:
arr = _j.loads(cwds) if cwds else []
except Exception:
arr = [cwds]
print("\n--- %s %r" % (aid[:8], (name or "")[:44]))
print(" status=%s type=%s at=%s created=%s" % (status, stype, at, ts(created)))
for c in arr:
c2 = norm(c)
others = [os.path.join(rt, os.path.basename(c2)) for rt in roots
if os.path.isdir(os.path.join(rt, os.path.basename(c2)))
and os.path.normcase(os.path.join(rt, os.path.basename(c2))) != os.path.normcase(c2)]
ok_exist = os.path.isdir(c2)
authoritative = c2 in live or any(norm(o) in live for o in others)
marks = []
marks.append("存在" if ok_exist else "🔴不存在(己类:会建幽灵分组)")
if others:
marks.append("有同名镜像%d个" % len(others))
if not authoritative:
marks.append("🔴会话不在该路径(疑似写到非权威侧)")
print(" cwds=%s %s" % (c2, " · ".join(marks)))
for o in others:
print(" 镜像侧: %s" % o)
def sec5_locks(dsh_home):
hr("§5 dsh 锁命名空间残留(按改动时间倒序)")
lk = os.path.join(dsh_home, ".locks")
if not os.path.isdir(lk):
print(" (无 %s)" % lk)
return
rows = []
for d in sorted(os.listdir(lk)):
p = os.path.join(lk, d)
if not os.path.isdir(p):
continue
files = []
for rt, _, fs in os.walk(p):
files += [os.path.relpath(os.path.join(rt, f), p) for f in fs]
rows.append((os.path.getmtime(p), d, len(files)))
for mt, d, n in sorted(rows, reverse=True):
print(" %s %-52s 文件=%d" % (
datetime.datetime.fromtimestamp(mt).strftime("%Y-%m-%d %H:%M:%S"), d, n))
# 同一工作区两套键
print("\n == 同一工作区出现两套键(锁不互斥的判据) ==")
fams = {}
for _, d, _n in rows:
m = re.match(r"^(e|d|c|f)-(programdata|programdsh|github|tmp)?-?(.*?)-[0-9a-f]{8}$", d)
if m:
fams.setdefault(m.group(3), set()).add(d)
hit = {k: v for k, v in fams.items() if len(v) > 1}
if not hit:
print(" 无")
for k, v in sorted(hit.items()):
print(" 🔴 %s -> %s" % (k, sorted(v)))
def sec6_groups(wb_home, sessions):
hr("§6 projects 分组目录 <-> DB cwd 是否一一对应")
proj = os.path.join(wb_home, "projects")
if not os.path.isdir(proj):
print(" (无 %s)" % proj)
return
cwds = sorted({norm(r[1]) for r in sessions if r[1]})
groups = sorted(d for d in os.listdir(proj) if os.path.isdir(os.path.join(proj, d)))
idx = {slug_of(c).lower(): c for c in cwds}
print(" 分组数=%d 不同 cwd 数=%d" % (len(groups), len(cwds)))
orphan = []
for d in groups:
n = len([f for f in os.listdir(os.path.join(proj, d)) if f.endswith(".jsonl")])
hit = idx.get(d.lower())
print(" %-52s jsonl=%-3d %s" % (d, n, ("<= " + hit) if hit else "🔴 无对应 cwd(孤儿分组)"))
if not hit:
orphan.append(d)
miss = [c for c in cwds if slug_of(c).lower() not in [g.lower() for g in groups]]
print("\n 孤儿分组 = %d %s" % (len(orphan), orphan))
print(" 未建组的 cwd = %d %s" % (len(miss), miss))
if not orphan and not miss:
print(" ✅ 一一对应,无重复分组")
def sec7_mirror_tree(roots):
hr("§7 根目录整树比对(找「镜像树」,甲/戊/己的共同温床)")
ai = [os.path.join(r, "AIProject") for r in roots]
a, b = r"E:\ProgramData\AIProject", r"E:\ProgramDSH\AIProject"
if not (os.path.isdir(a) and os.path.isdir(b)):
print(" (无 ProgramData\\AIProject / ProgramDSH\\AIProject 对,跳过)")
return
la, lb = set(os.listdir(a)), set(os.listdir(b))
both = sorted(la & lb)
print(" A = %s 目录数=%d" % (a, len(la)))
print(" B = %s 目录数=%d" % (b, len(lb)))
print(" 同名(B 是 A 的镜像候选)= %d 个: %s" % (len(both), both))
print(" 仅 A 有 = %d 个: %s" % (len(la - lb), sorted(la - lb)))
print(" 仅 B 有 = %d 个: %s" % (len(lb - la), sorted(lb - la)))
if both:
print(" ⇒ ⚠️ 上面这些同名目录每多一个,就多一份「工作区归属判错」的机会")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--wb-home", default=WB_HOME_DEFAULT)
ap.add_argument("--dsh-home", default=DSH_HOME_DEFAULT)
ap.add_argument("--roots", nargs="*", default=None)
ap.add_argument("--only", default=None, help="只看名字含该串的工作区")
ns = ap.parse_args()
roots = ns.roots if ns.roots else DEFAULT_ROOTS
import json as _j
con, sessions, automations = read_db(ns.wb_home)
print("workbuddy home = %s" % ns.wb_home)
print("dsh home = %s" % ns.dsh_home)
print("候选根 = %s" % roots)
print("会话 %d 条 / automation %d 条" % (len(sessions), len(automations)))
pairs = sec1_sessions(sessions, roots, ns.only)
sec2_pairs(pairs)
sec3_entries(sessions, roots, ns.only)
sec4_automations(automations, sessions, roots)
sec5_locks(ns.dsh_home)
sec6_groups(ns.wb_home, sessions)
sec7_mirror_tree(roots)
hr("总判读口径")
print(" ① 同一工作区在磁盘上有几个目录?>1 ⇒ 戊类(先定权威再开工)")
print(" ② 入口声明的路径 Test-Path 是否成立?不成立 ⇒ 己类(回填入口头部,⛔ 别新建目录)")
print(" ③ automation 的 cwds 是否落在「会话实际所在的那一侧」?否 ⇒ 立刻改,否则多一个分组")
print(" ④ 旧侧入口是否已改 stub?只写「停维护」不算 ⇒ 两侧必分叉")
print(" ⑤ 锁有几套键?一个工作区两套 = 锁不互斥(比 UI 观感危险)")
if __name__ == "__main__":
main()
@@ -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())
@@ -0,0 +1,91 @@
# 平台速查(硬编码事实,勿猜)
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:平台速查(硬编码事实,勿猜)(原行 L14–L92)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L14–L92 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
## 平台速查(硬编码事实,勿猜)
> ⚠️ **正确域名 = `ai1net.com`**(2026-09-19 由 `alotbuy.com` 迁入);`alotbuy.com` 是**旧域名、已降为 nginx 301 过渡装置**
> ——对它抓页面只会拿到 301 HTML,**别误判为"改动没生效"**(2026-09-11 踩过)。
> 平台页由门户直出(`127.0.0.1:3080`),生效副本只有 `/opt/dshs/web/` 一份。
> 存量会话提示(档案 45)落在 `web/login.html`:告知「shell 频繁要求审批 → **新开一个会话**即可」。
>
> ⚠️ **`*.ai1net.com`(含每个用户子域)都由同一个 vhost 先 `proxy_pass 127.0.0.1:3080`(门户)**,
> 再由门户的 `src/supervisor/proxy.ts` 转发到实例 → **想改"子域访问行为"要改 `proxy.ts`,
> nginx 的 `error_page` / `location` 到不了实例层**(档案 49 实测)。
> 实例空闲回收后首次访问的链路(档案 49):**导航**(GET+`Accept: text/html`)→ 立刻 302 到
> `web/wake.html`(门户同源,动画 + 自动调 `/api/dsh/enter` 跳回);**XHR/API** → **等实例就绪后
> 继续转发**(不是回错误/动画 —— 让请求最终成功,dsh 自身 loading 即反馈)。
>
> ⚠️ **实例的浏览器凭证 cookie 名带随机后缀**:`dsh-auth-<随机后缀>=v1.<base64>.<sig>`(HttpOnly/Secure)。
> **实例重建后这个后缀会变**(不是"过期",是"名字都不对")→ 已打开的页面手里那份对新实例**永远无效**。
> 档案 51 的修法在 `proxy.ts`:**非导航请求遇实例侧 401 → 取当前实例 token → `GET /?token=` 换新 cookie
> → 覆盖 cookie 头透明重放同一请求 → 响应追加 `Set-Cookie` 回写浏览器**(此后含 SSE 自动重连直接成功)。
> 重放闸门是 `canReplay = mayHaveBody ? bodyBuf !== undefined : true` —— **GET/HEAD 无请求体,一样要能重放**。
>
> ⚠️ **"空闲回收"实际从未生效(2026-09-11 核实,别再拿它解释现象)**:
> ① **TTL** `instanceIdleTtlSeconds` 默认 = **7 天**(`src/config.ts:167` `60*60*24*7`),线上 env 未设 → 等于不会触发;
> ② **LRU 上限** `maxIdleInstances` 默认 = **4**,只有**第 5 个用户**拉起实例时才淘汰最久未活跃者(现有 2 用户永远达不到);
> ③ `reapOnce()` 只看 `lastActive`,而 `proxy.ts::resolveSubdomainAccess()` **每个子域请求都 `touch()`**,
> dsh 前端还有常驻 SSE `/plugins/events` → **页面开着就永远"活跃"**,TTL 规则不可能命中。
> **历史实证**:全历史 `grep -c "idle-reap"` = **1**(`09-09 12:24 stop … idle>20s`,是档案 08 拿 TTL=20s 做验证那次)。
> ### ⛔ **别把"回收没生效"外推成"实例不会中断"**(2026-09-11 我犯过这个错,被用户当面纠正)
>
> 「页面等一会就模型断开、必须刷新」的真凶是**中断**,与"空闲回收"是两码事。实测统计:
>
> | 中断来源 | 实证 | 机制 |
> |---|---|---|
> | **① 门户服务重启**(主因) | 2026-09-11 一天重启 **48 次**(全是部署/探活) | 重启后 supervisor **内存 `mains` 表清空** → 旧实例变**孤儿**(进程还在但不被跟踪)→ 再访问判 `not_running` → **重新拉起新实例(新端口/token/cookie 名)** → 旧页面带旧 cookie → **401**。**这正是档案 51 的触发源** |
> | **② 实例崩溃后自动重启** | 近 4 天 `crash-restart` **69 次**(admin 34 / guest 16 / 测试号 19) | admin 根因 = `duplicate loader entry id: permission`(`exitCode 1`)——**启用与官方 `permission` 撞 id 的候选插件**所致;guest = 信号终止(无 exitCode),紧跟服务重启 |
> | ③ 空闲回收 | 全历史 `[idle-reap]` **仅 1 次**(09-09 测试值 20s) | **从来没生效**,不构成中断来源 |
>
> **必查手法**:`journalctl -u dshs --since "-1d" | grep -c "Started DSH server login orchestrator"`(重启次数)
> + `grep -c crash-restart` + `grep -c idle-reap` —— 三个数一比就知道该往哪查。
>
> **治本方向(待办)**:① 部署**攒批重启**(别每改一处重启一次);② **supervisor 启动时收养已存在 scope**
> (本地模式目前**无**收养逻辑,`reconcile.ts` 只覆盖 k8s)→ 重启不再产生"新实例 + 401";③ 业务插件探活失败**自动摘 bundle**。
> ⚠️ **配置风险**:`maxIdleInstances=4` × `MemoryMax=1024M`(硬限上界) = 4096M **远超** 宿主总量 1870MB(**上限非预留**,但真跑满必然换页)→ **撑不住,建议调 2**。
>
> ⚠️ **dsh web 客户端的会话事件流走 SSE(`EventSource`),不是 WebSocket**(源码
> `dsh-api-session-controller/lib/types/client/sessions/session.js`)。**凡"页面内自愈"类注入脚本,
> 只 hook `fetch`/`XMLHttpRequest` 是够不到主链路的** —— EventSource 遇 401 只静默 `onerror` 重连,
> 界面什么都不显示(档案 50 因此对真实用户无效,档案 51 才在传输层根治)。
> 判断前端用什么传输**别猜**:`grep -rl "new WebSocket\|EventSource" <dsh 包>`。
>
> ⛔ **注入脚本(`src/supervisor/proxy.ts` 的 `SESSION_*_JS`)两条铁律 —— 2026-09-13 事故换来的**:
> ① **它是 TS 模板字面量,里面的 `\n` / `${` / 反引号会在模板求值时先被处理一次。**
> &nbsp;&nbsp;写 `.join('\n')`(单反斜杠)⇒ 求值后变**裸换行** ⇒ 注入的 JS 直接 **SyntaxError** ⇒
> &nbsp;&nbsp;**整段脚本静默不执行**(浮层 / 自愈 / 助手面板全废,页面只剩 dsh 自己的「连接异常」)。
> &nbsp;&nbsp;→ 用 `String.fromCharCode(10)`;**校验必须"先模板求值、再 `node --check`"**:
> &nbsp;&nbsp;`node 07-scripts/verify-inject.cjs lib/supervisor/proxy.js`(**已接入 `npm test`,勿绕过**)。
> &nbsp;&nbsp;⚠️ `new Function(原文)` 与「grep 页面 HTML 有没有标记」**都是假绿** —— 前者跳过求值,后者验不出"跑不跑得起来"。
> ② **触发面必须覆盖"页面开着不动"**:用户盯着页面时无任何事件;现已有 **可见时 25 s 心跳**
> &nbsp;&nbsp;+ `EventSource` / `WebSocket` 断流包装,且两者都**连续两次失败才恢复**(恢复=原地 replace,会丢未保存输入)。
>
> 🔴 **浏览器自动化:唯一允许 `browser-harness`**(⚠️ **2026-09-25 用户明令**:「**改为 browser-harness,只允许使用这个**」
> &nbsp;&nbsp;⇒ 此前「两件(含 `agent-browser`)」的口径**作废**):
> ① **`browser-harness`**(CDP)—— **唯一允许的一条**:全局 console script `browser-harness`(`~/.local/bin`);
> &nbsp;&nbsp;⛔ **动手前必须先 `list_tabs()`**;**默认起独立 headless 实例(`BU_CDP_URL` 指向 9223),⛔ 绝不附着用户日常 Chrome**
> &nbsp;&nbsp;—— 旧事故:在其浏览器里登录测试号 → **覆盖了用户的 `.ai1net.com` 的 `sid`**。
> &nbsp;&nbsp;📂 正确调用(含 2026-09-25 实测路径更正)⇒ `08-浏览器验证栈详解.md`。
> ⛔ **`agent-browser` 已禁用**(2026-09-13 曾定为「本项目默认」,2026-09-25 用户明令收窄 ⇒ 新增此禁);
> ⛔ **禁止 Playwright / `playwright-core`**(含「独立无头」用法)(用户 2026-09-13 明令:「**playwright 禁止使用,加到规则中**」)。
> 静态页与 API 取数优先 `curl` / WebFetch,**别动浏览器**。
| 项 | 值 |
|----|-----|
| 服务器 | 47.77.182.89(Alibaba Cloud Linux al8);SSH:**`ssh -i ~/.ssh/id_ed25519 -p 22 [email protected]`**(🔴 2026-09-22 实测更正:`~/.ssh/config` 里的别名 `bt-server` **写死 `Port 32022`,而 sshd 只监听 22 ⇒ 用它必 `Connection refused`**;私钥真名 `~/.ssh/id_ed25519`,⛔ 技能旧文里的 `id_ed25519_dsh` 不存在) |
| 门户 | dshs(systemd `dshs`,127.0.0.1:3080);源码 `/opt/dshs`(git master) |
| 数据 | `/var/lib/dshs/users/<id>/{home,ws}`;DB `/var/lib/dshs/dshs.db`(root 600) |
| 域名 | **ai1net.com**(门户,CF 代理 + 通配证书,源站 443);用户子域 **`<用户名>.ai1net.com`** 经 portal proxy 转发实例;旧 `alotbuy.com` 301 → `ai1net.com`(档案 22 / 2026-09-19 迁入) |
| 实例 | 门户 spawn `node /usr/local/bin/dsh --profile web --host 127.0.0.1 --port <动态>`;ISOLATION account(setpriv,uid∈[100000,199999];admin=114801) |
| 文档 | **本机 git 工作树 = 唯一源**:🔴 **`D:/github/dsh_shenxian/dsh-server-docs`**(仓库 `dsh_shenxian_doc`)→ 同步到服务器 `/opt/dsh/docs`(**无 .git 的部署镜像**;`08-skills/**` 与根 `README.md`/`INDEX.md` 均 **600 root:root**)。⚠️ **2026-09-22 实测更正**:旧文写的 `E://ProgramData//AI技能//aliyun-dsh-server//dsh-server-docs` **该目录不存在**(本工作区里只有 `docs/`,那是会话规则副本);⚠️ **根 `README.md`/`INDEX.md` 也在服务器上有一份** ⇒ 登记跟改后要连它们一起推。⛔ **本行 2026-09-13 更正**:原文写「服务器唯一源 / 本地不保留副本 / `dsh-server-docs/` 已废弃」——那是**更早的状态,现已作废** |
| 文档同步 | 改完跑**四件套**:`docs-audit.py` → `docs-index-stats.py --write`(INDEX 状态摘要**机器生成**)→ `docs-manifest.py` → `docs-sync-check.sh` → `docs-consistency.py`;**只 scp 自己本次改的文件**、推前复跑对账。⛔ `docs-status/` 机制**已不存在**(原文作废) |
| 硬规则 | 动**文档 / 代码 / 服务器**之前先抢**全局执行锁**(`bash dsh-server-docs/07-scripts/handoff-guard.sh --claim-exec "<会话名>"`);要动**生产**再占服务器侧 `07-scripts/op-lock.sh`;**同一时刻只放一个执行会话**。规则实体在项目根 `CODEBUDDY.md`(§3 红线 R1–R9 / §6 并发纪律)—— **本技能不复述、也不得与之冲突**。⚠️ 释放 op-lock 必须带 `ME=<原占用者>`,否则按 R9 被拒 |
| 档案号 | **复跑取号,勿写死**(`ls 04-调整方案/ | sort -n | tail -1`;并行改动会打穿)(01-52 已落;**50/51/52** = 会话过期自愈注入(已被51取代) / 实例侧401透明重放 / 新用户实例无法启动;⚠️ 无 48;**37/38 各有两条**(并行通道撞号)→ **归档前先 `mkdir <目录>/.lock-<n>` 原子占号**,光"先 ls 再写"不够) |
@@ -0,0 +1,23 @@
# 档案模板(04-调整方案/<NN>- 的 8 段结构)
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:档案模板(04-调整方案/<NN>-<标题>.md)(原行 L391–L403)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L391–L403 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
## 档案模板(04-调整方案/<NN>-<标题>.md)
```markdown
# <NN>-<标题>(<日期> 落地 / 调研)
## 背景与动机 # 需求来源、触发场景
## 用户决策 # 关键分叉 + 选择 + 理由(含日期)
## 实现 # 改动文件清单 + commit hash + 关键代码/配置片段
### A. ... # 分模块
## 验证记录 # 实测命令 + 输出 + 结论(含失败尝试)
## 事故/踩坑记录 # 坑现象→根因→规避(若适用)
## 回滚 / 注意 # 回滚步骤、副作用、后续待办
```
@@ -0,0 +1,77 @@
# 红线详解 · R7 批量写入门禁 / R8 生产变更知会 / R5 权限扩大门禁
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:R7 批量写入门禁 · R8 生产变更知会 · R5 权限扩大门禁(原行 L414–L480)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L414–L480 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
### R7 批量写入门禁(2026-09-12 用户新增红线)
> **用户原话**:「为什么会犯这种错误,容易把服务器搞崩,必须记录在红线中。」
**由来(真实事故)**:为了让 scp 出去的文件行尾干净,写脚本遍历整个代码库把 **147 个文本文件** CRLF→LF。当期被要求的只是一句「把某个 UI 字符串改个名」,**操作半径放大了两个数量级**。
**已经造成的实际损害**(不是理论风险):
- 147 个文件被标记为 M —— 若继续 scp / 提交 / 推送,会覆盖服务器上正确的版本、产生巨型 diff 掩盖真实改动、并与并行会话冲突;
- 随后用 `cp -r` 同步插件目录时,把**当期并没有改过的** `cordis.patch.yml`、`lib/index.js` 也用 CRLF 覆盖到了**服务器**(已发现并恢复)。
**规则(硬性)**:
1. **只做被明确要求的事**。执行中发现的额外问题(哪怕看起来"很小、很好修")一律**先报告、后动手**,不得顺手改。用户说"按建议处理"只授权**那条建议本身**,不是授权一切顺带优化。
2. **禁止**对仓库或生产目录做:全库遍历改写(`os.walk` / `find -exec` / `grep -rl | xargs`)、通配符重写、批量 `chmod`/`chown`、**批量换行符转换**、`cp -r` 整目录覆盖、`git add -A`。
3. **阈值**:一次操作若**可能影响 >10 个文件**,或表述里出现「所有 / 整个 / 全库 / 全部」→ **停下来先问**,先产出**受影响清单**再决定。
4. **本机不是沙箱**:本机镜像与服务器是两份独立副本;本机的批量改动即便不带任何"部署"动作,也会在下一次 scp 时传导到生产。
5. **先用单点验证**:任何批量手段先对 **1 个对象**试,确认后果(`git status`、`file`、`md5`)符合预期再考虑推广。
6. **传播前比对待传清单**:scp / 同步前必须 `git status` 确认**待传清单只含本次真实改动**,不含被工具顺手改动的文件。
7. **行尾类问题一律先报告**:仓库 blobs 是 LF 而本机工作树因 system 级 `core.autocrlf=true` 呈现 CRLF(`D:/Program Files/Git/etc/gitconfig`)—— 这是**既有环境事实**,不构成"需要当场修复"的缺陷;要动必须先与用户确认范围。
### R8 生产变更知会(2026-09-12 用户新增红线)
**由来**:档案 58(内存优化)、59(重连反馈)两次改动都需要重启 `dshs`,**连续两次把在线用户踢下线**,直接引发用户"会话连接异常"报障。
**规则**:
1. 以下动作**都会中断在线用户**,执行前必须先说明「**影响谁、断多久、为什么必须现在做**」并取得确认:
- `systemctl restart dshs`(drain 全部实例)
- `systemctl stop dsh-*.scope`(停某个用户实例)
- 批量铺插件 / 改 `MemoryMax` / 改实例 env(都要实例重启才生效)
- 重建 profile、改 profile patch
2. **能选低峰期就不要在用户活跃时做**;无法避免时明确告知"会断一次"。
3. **改完即验证**:重启后必须确认服务 active + 门户 200 + 实例能被拉起,再向用户交代。
4. 附带:本机 `D:\github` 下的镜像**任何批量改动都视为"可能影响生产"**(见 R7 第 4 条)。
### R5 权限扩大门禁(2026-09-11 用户新增红线)
**先判方向:这次改动是「扩大」还是「收窄」?**
| 方向 | 例子 | 处置 |
|---|---|---|
| **收窄** | 减少挂载、去掉白名单项、收紧 nft、收窄 env | 可直接做,但**仍须验证**(遮蔽类可能让实例起不来 → 档案 42) |
| **扩大** ⚠️ | 新增 bwrap 挂载 / `--bind`、放开被遮蔽的路径、把平台目录或文件暴露给实例、给实例注入新 env、放宽 `ALLOWED_ENV`、放松 nft(出网或宿主访问)、提高权限档位或放宽 `approval`、新增用户可读/可写路径、把 root 执行链路(解压 / chown / pnpm)的对象变成用户可控 | **一律先出「权限影响评估」并等用户明确同意**,禁止"顺手做了" |
**「权限影响评估」四问(方案里必须逐条写,档案留痕)**:
1. **扩了什么** —— 逐条列具体路径 / 端口 / env / 权限位(不要写"优化了访问"这种含糊话);
2. **谁受影响** —— 全部租户 / 单租户 / 仅 admin;
3. **有没有不扩大也能实现的方案** —— 若有,必须先提;若确实没有,说明为什么;
4. **回滚方式 + 验收方式** —— 回滚命令写清楚;验收**必须 diff 技能里的「实例可访问路径清单(权威版)」**,
逐项确认"新增项都是用户已同意的"。
**🔒 安装类操作 = 扩大,必须先出「安装确认清单」并经用户确认**(2026-09-11 用户明确要求:
「需要安装哪些依赖和工具,需要确认后才能安装,避免安装有风险的内容」)。
凡是要往**平台共享位**(`/usr/local/dsh-runtime`、`/usr/local/bin`、系统包)装东西,**一律不得自动执行**,
先列七项等用户点头:① 名称+版本 ② **为什么需要**(哪个技能/插件在用)③ **来源与校验方式**(官方 URL + sha256)
④ 体积 ⑤ 影响面(**全部租户**)⑥ 风险点 ⑦ 卸载方式。
技能/插件**导入时的依赖体检只做「报告 + 拦截」(缺失即 409),绝不代装**。
**触发文件(改这些就必须走 R5,无一例外)**:
`src/supervisor/orchestrator.ts`(bwrap args / `baseEnv`)、`src/supervisor/spawn.ts`(`ALLOWED_ENV`)、
`/etc/nftables-dsh-egress.nft`、`src/web/routes/skills.ts`、`src/web/routes/business-plugins.ts`、
`ensure-role-profile-patch.cjs`(角色 profile patch)。
> **历史依据**:档案 39(`/etc` 白名单化)、40(bundled-skills 挂载)、41(技能上传/启停)、42(Python 运行时)
> 里每一次"扩大"都是在用户明确要求下才做的;反例是档案 42 的遮蔽尝试 —— 属"收窄"但因触碰挂载结构
> 直接让实例起不来,**收窄也必须跑真启动验证**。
@@ -0,0 +1,80 @@
# 机制速查(一)· Profile 层 cordis patch / Skill 装载机制 / Skill 管理面
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:Profile 层 cordis patch 机制 · Skill 装载机制 · Skill 管理面 · dsh 沙箱与权限预设机制(原行 L481–L504 + L555–L600)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L481–L504 + L555–L600 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
### Profile 层 cordis patch 机制(角色化 UI 裁剪,档案 09)
- client 插件行 id = **短 id**(`ui-settings-models`,非包名),见 `dsh --profile web --dump-config`。
- **禁用官方 client 插件**(如设置面板「模型」分区对普通用户):profile `cordis.patch.yml` 写 `- id: <短id>\n name: "@deepseek-ai/<包名>"\n disabled: true`(dsh-app-boot applyEntryPatches:非 insert patch 按 id 合入 overrides)。`--dump-config` 验证:目标行出现 `disabled: true` + `# == ... patched by <path>` 注释。
- **生效必须重启实例**(client bundle 启动时打包);`patchReload: live` 对 client 插件增减**不生效**(实测 5 轮)。
- 当前生产 spawn 不带 `--patch` overlay(enablePatch=false),disable 只能写 profile 层 `cordis.patch.yml`。
- 幂等工具:`/opt/dshs/ensure-role-profile-patch.cjs [--restart] <username>`(全量=非 admin 用户;含管理标记头则跳过;非默认空内容不覆盖)。admin profile 不动 = admin 保留该分区。
### Skill 装载机制(全员共享只读技能 = bundledSkillDir,档案 10)
- 技能由 agent preset 注册:`@deepseek-ai/dsh-agent-presets/presets/standard/agent.cordis.yml` L83-88(`skill-filesystem` + `tool-skill`),preset 无 config 块 → provider 配置走 **env**。
- 技能分层 rank(`dsh-skill-filesystem/lib/index.js` roots()):project-dsh 100(`<项目>/.dsh/skills`)/ project-agents 200 / custom 300 / user-dsh 400(`$DSH_HOME/skills`,每用户独立)/ user-agents 500 / **bundled 600(`$DSH_BUNDLED_SKILL_DIR`,`trustedHost: true` 只读共享)**。
- 扫描粒度:`discoverRoot` **只扫 1 层**(root 下每目录须含 `SKILL.md`;或直接 .md 文件);`root/主技能/subskills/子技能/SKILL.md` **不会被发现** → subskill 要独立可调需**平铺**到技能根。
- **编排器 env 白名单(关键卡点)**:`/opt/dshs/lib/supervisor/spawn.js` `ALLOWED_ENV` 仅 PATH/HOME/USER/TMP/LANG 等基础变量;`baseEnv()` = `{...scrubEnv(process.env), HOME, DSH_HOME, DEEPSEEK_API_KEY}` → **新 env 变量必须同时加白名单 + baseEnv 显式注入**(src/*.ts 同步),否则被 scrub 丢弃。这是改编排器(自研,非红线 2 对象)。
- MCN V1.0 技能跨平台部署注意:`subskills/browser-harness/envs/` 为 **Windows venv**(`Scripts/*.exe`),Linux 服务器不可用需重建;海外节点直连抖音风控风险高,数据类优先 RedFox API。
### Skill 管理面(编排器 API + 静态页,档案 11)
- **dsh 技能名硬规则**:`/^[a-z0-9]+(?:-[a-z0-9]+)*$/`(`@deepseek-ai/dsh-skill/lib/index.js` SKILL_NAME)—— **中文名技能 dsh 静默丢弃**。MCN 等含中文名技能需先改 `SKILL.md` frontmatter `name` 为 kebab(如 `mcn-workstation`)。
- **API 形态**:`/api/skills/shared`(admin 三件套 GET/POST/DELETE)+ `/api/skills/mine`(任何登录用户);上传 base64 body `{ file, filename, force? }`;后端走系统 `tar/unzip` 解压、路径穿越校验、合法名校验。包结构:单顶层目录 + 含 SKILL.md。
- **落盘属主**:shared → root:root 0755(OS 权限兜底只读);mine → `home` 属主用户 uid/gid(确保用户可改自己的技能)。踩坑:`chownTree` 须 chown 顶层目录自身(首次实现只 chown 子项,目录保留 tar 包内 owner)。
- **watch 即时生效**:`skill-filesystem` 对共享根 watchManager 监听 addDir/unlinkDir/SKILL.md → 自动 invalidate registry(**无需重启实例**,源码已核实)。
- **编排器 env 注入 DSH_BUNDLED_SKILL_DIR**:spawn.ts ALLOWED_ENV 加变量 + orchestrator.ts baseEnv 显式注入(`config.bundledSkillDir`,默认 `<dataRoot>/bundled-skills`);这是上一节「全员共享只读技能层」的实例侧落地。
## dsh 沙箱与权限预设机制(2026-09-11 源码核实,改默认值必看)
**两层隔离,别混淆**:
| 层 | 谁提供 | 说明 |
|---|---|---|
| **内层** | dsh 自带沙箱 `read-only` / `workspace-write` / `danger-full-access` | 靠 Landlock 或 bubblewrap 后端。**本机不可用**:内核 5.10(Landlock 需 5.13+,LSM 无 landlock),bwrap 嵌套探测失败 → **fail-closed 拒绝执行任何 shell**(`@deepseek-ai/dsh-sandbox/lib/index.js:185`)|
| **外层** | 平台 `systemd-run --scope` + `bwrap` + `setpriv` | **真正的边界**:mount 路径隔离 + uid 隔离 + cgroup(**基础 448M → 上界 1024M**:`MemoryHigh=MIN` 软限 + `MemoryMax=MAX` 硬限;**与插件开关无关**(档案 96)/150%/128)。内层失效不影响它 |
**权限预设插件**(row `@deepseek-ai/dsh-permission-presets`,短 id 一般即 `permission-presets`):
- 内置预设表(**sandbox 与 approval 成对绑定**,这是关键坑):
```js
'workspace-write': { sandbox: 'workspace-write', approval: 'ask' }
'danger-full-access': { sandbox: 'danger-full-access', approval: 'never' } // ← 选它=同时摘掉审批
```
- `defaultPreset = config.defaultPreset ?? inferredDefault`;默认落 `workspace-write`(→ 本机即「shell 全废」)。
- 设置持久化命名空间 = **`permission`**(settings key `permission.defaultPreset`,值必须是 `presets` 里的键名)。
**改默认值的官方做法(不碰官方主程序,R2 合规)**:
> ⭐ **首选:`DSH_PERMISSION_MODE` 环境变量**(2026-09-11 定案,档案 33)。
> 官方 `@deepseek-ai/dsh-base/cordis.patch.yml:217/233` 直接读它:
> ```yaml
> mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
> policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'"
> ```
> 在编排器 spawn 时注入 `DSH_PERMISSION_MODE=danger-full-access` 即可 **全局默认「完全权限」+ 免审批**,且:
> ① 不必铺 profile(env 随 spawn,**新用户自动生效**);② 完全复用官方逻辑(预设表 / 设置 UI 都不动)。
> 落地两处:`spawn.ts` 的 `ALLOWED_ENV` 加该键 + `orchestrator.ts` 的 `baseEnv()` 注入(值取 `process.env.X ?? 'danger-full-access'`,运维可覆盖)。
> ⚠️ **副作用**:权限档位是**会话级播种**(会话创建时写 `permission/preset` / `sandbox/mode` / `approval/policy` 三条种子事件)→ **旧会话不跟随新默认**,必须**新建会话**才生效。给用户的话术是「新开一个会话」,不是「刷新页面」。
其余做法(需要更细粒度时才用):
1. **profile 层 `cordis.patch.yml` 改该行 `config`** —— 已核实 `applyEntryPatches` 里
`const { id, insert, name, ...overrides } = patch` → **patch 的其它键会作为 overrides 合入目标行**,所以 `config:` 可覆盖。
注意 **`config` 是整体替换不是深合并** → 必须把内置的两个预设一并写全。
2. 或改设置:`$DSH_HOME/settings.yaml` 加
```yaml
permission:
defaultPreset: <presets 里的键名>
```
3. **profile 是每用户一份** → 要像 `ensure-role-profile-patch.cjs` 那样给每个用户(含新用户)铺;改完**必须重启该用户实例**才生效。
4. 用户仍可在实例「设置 → 权限」自助切换(`ui-permission`),这是设计内的。
**推荐形态**:不要直接用 `danger-full-access`(会连审批一起摘掉)。在 patch 里**新增一个自定义预设**:
`sandbox: danger-full-access` + `approval: ask`,语义 = 「隔离由平台容器提供,高风险操作仍需确认」。
@@ -0,0 +1,95 @@
# 运维锚点 · 会话记录取证 · 功能插件启用
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:通用运维锚点(重启用) · 功能插件启用:探活 · 快照回滚 · 逐插件隔离 · 会话记录取证(原行 L505–L525 + L935–L998)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L505–L525 + L935–L998 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
## 通用运维锚点(重启用)
- 门户重启后实例需重新 launch:`curl -b "sid=..." -X POST http://127.0.0.1:3080/api/dsh/enter` → 返回实例 port + token url
- 实例检测:`pgrep -af "^node /usr/local/bin/dsh --profile web"`;监听:`ss -tlnp | grep :<port>`
- 测试 session 生成:`node /opt/dshs/mksess.cjs`(**PG 直插**,连接串取自 env / `dshs.env` / `dshs.service.d`;10 分钟;`user_agent=poc-curl2`);清理:DELETE WHERE user_agent='poc-curl2'
- **存量会话档位体检(档案 36 P0-1,只读,建议定期跑)**:权限档位**会话级播种**(建会话时写 `permission/preset` + `sandbox/mode` + `approval/policy`,**resume 不重播**)→ 改默认档位只惠及新会话。查全平台还有多少会话是旧档位:
```bash
for f in /var/lib/dshs/users/*/home/sessions/*/*/session.jsonl.zstd; do
[ -f "$f" ] || continue
printf "%-12s %s\n" \
"$(zstd -dc "$f" 2>/dev/null | head -c 4000 | grep -o '"preset":"[a-z-]*"' | head -1)" \
"$(echo $f | sed 's#.*/users/##')"
done
```
2026-09-11 实测:**总会话 10,`workspace-write` 10,`danger-full-access` 0** —— 档案 33 之后**一条都没迁移**。
- **技能投放体检**:`for u in /var/lib/dshs/users/*/; do echo "$(basename $u): $(ls -A $u/home/skills 2>/dev/null|wc -l)"; done; ls -A /var/lib/dshs/bundled-skills | wc -l` —— 2026-09-11 实测**全为 0**(机制就绪但零投放,档案 36 P0-2)。
- **文档备份目录要先建**:`mkdir -p /opt/dsh/backups/docs` 否则 `cp ... .bak-<ts>` 报 `No such file or directory`(本次踩坑)。
- 服务器 git:`git -C /opt/dshs`(user.name/email = deploy@dsh.alotbuy.com)
- **⚠️ 服务器 git 提交前先 `git status --short`**:工作树常滞留未提交文件(曾有一次 `git add -A` 把 `/logout` 路由、`ensure-role-profile-patch.cjs`、`mksess.cjs`、`*.bak` 一并卷进 `fa718d2`);应**定向 `git add` 只暂存本次改动文件**。
- **登录直达冷启动竞态(档案 13)**:`enter` 返回打开 URL 前必须等 launch token(`waitForLaunchTokenForUser`);实例 spawn 后 `status` 即 running 但 HTTP 路由未就绪,浏览器撞启动窗口会 404(`proxy.ts` 的 404 只对应 unknown_user/not_running,此 404 来自实例自身透传)。验证用「enter 并发 + `--resolve` 回源 curl」:URL 必须含 `?token=`,子域应 303→200。
## 功能插件启用:探活 · 快照回滚 · 逐插件隔离(档案 34/35,2026-09-11)
**原缺陷**:`/api/plugins/mine/apply` 在 `pnpm add → reconcile → restartMain` 之后**直接标 success,全程无探活** → 插件搞崩实例(崩溃循环)时任务仍报「已应用」。
**正确流程**:快照 → 禁用项直接 remove(不引入新代码,永远安全)→ 启用项**先整批试一次** → 探活 → 失败才回滚 + **逐插件隔离**(能起来的保留、起不来的单独摘掉并记 `plugin_incident` 审计)。
**探活的两个必踩坑(都靠实测发现)**:
1. **不能只判 `restartMain` 的返回值** —— 编排器里没有该用户实例时(portal 重启清了内存态、或实例被空闲回收)它返回 `undefined`,会把**无辜插件**误判为「不兼容」。→ 无实例时先按 `/api/dsh/enter` 同路径 `launch` 一个再探。
2. **固定短等待会把好插件判死** —— 初版「固定 2.5s 稳定窗口」实测好插件也报「未产出 launch token」。→ 改为**轮询**(500ms 间隔 / 总预算 60s):拿到 `launchToken` 且 `status==='running'` 后,再过 2.5s 确认没闪崩才算通过。
3. 探活失败要**回传 dsh 的真实错误**(如 `duplicate loader entry id` / `ERR_MODULE_NOT_FOUND`),不要泛化 —— 这是 admin 判断"这个插件为什么坏"的唯一线索。
**坑:平台 bundle 包不能被 ws 清理删掉**(档案 35 P0)。`ws-cleanup` 的 T1 会无条件删 ws 顶层 `.tgz`,而三个平台 bundle 是以 `file:<ws>/*.tgz` 安装的 → 删掉后 profile 的 `dependencies` 指向不存在文件 → **任何 pnpm 操作 ENOENT 失败**(启用功能插件必然失败)。**且极隐蔽:`node_modules` 已装好,实例照常运行,只在下次 pnpm 操作时爆发。** 修法:清理前读每个用户**所有 profile 的 `file:` 依赖**,被引用的文件一律豁免(别硬编码文件名)。平台 bundle 的可重建源码在 `/opt/dsh/docs/04-调整方案/poc/`,产物存 `/opt/dsh/artifacts/`。
**造测试 fixture 的坑**:bundle 的 `package.json.name` **必须与 `cordis.patch.yml` 里 `insert` 的 `name:` 一致**,否则 `ERR_MODULE_NOT_FOUND`;且打包要用 `package/` 前缀的 npm-pack 布局(`tar czf x.tgz package`),平铺布局 pnpm 可能不接受。
**平台 bundle 的铺装(档案 36)**:`07-scripts/ensure-biz-plugins.cjs` —— 幂等,判定**看 `dsh.profile.bundles` 不看 `dependencies`**(包内 `cordis.patch.yml` 只对 bundles 成员生效;有 dep 没 bundles = 等于没装)。流程:复制产物 tgz 到用户 ws(chown)→ `pnpm add file:` → 对齐 dsh plugin add 的 reconcile。**新用户自动铺 = 审批时 detached `spawn`(fire-and-forget)+ cron 巡检兜底。**
**坑:`pnpm-workspace.yaml` 会让 pnpm 拒绝 `add`**。dsh 会给某些 profile 生成 `pnpm-workspace.yaml`(`packages: [.]`)→ pnpm 视为 workspace root → `ERR_PNPM_ADDING_TO_ROOT`。→ **所有 `pnpm add` / `pnpm remove` 都要带 `--ignore-workspace-root-check`**(有无该文件都工作)。
**坑:判断「装没装」**永远看 `dsh.profile.bundles`,不要看 `dependencies` —— 两者可以不一致,而不一致时组件是**没生效**的。
**坑:Node 22 的 `execFile` 类型**:`execFile(file, args, { stdio:'ignore' }, cb)` 在本项目 TS 版本报「'stdio' does not exist in type ExecFileOptions」。要做 fire-and-forget 用 `spawn(..., { stdio:'ignore', detached:true }).unref()`。
## 会话记录取证(分析某工作区/用户的真实对话,2026-09-11 定型)
用户常提「看看 XX 工作空间下的对话记录,能发现哪些问题」。**这是只读任务,可与其他只读任务并行**,且**适合丢给子代理**(大文件长分析,别吃主上下文)。
**路径规律**(`<UID>` = 用户 id,admin = `cce6d1cd-b376-4304-80f0-0e1c58c9ffde`):
```
U=/var/lib/dshs/users/<UID>
$U/home/sessions/--<cwd 路径把 / 换成 ->--/session-<uuid>/session.jsonl.zstd
$U/home/storages/workspace.json # 工作区注册表:id → { path, title, sessionIds }
```
例:`--var-lib-dshs-users-<UID>-ws-mcntimo--` 对应工作区 `$U/ws/mcntimo`。
**坑:文件是「追加式多帧 zstd」,不是普通 zstd。**
- `zlib.zstdDecompressSync` / `createZstdDecompress` 只解第一帧 → 只得 240 字节的 session 头,然后报 `Unknown frame descriptor`。
- **必须用 zstd CLI**(服务器默认没装):`yum install -y zstd`(al8 官方源 `zstd-1.5.1-2.0.2.al8`,安全,已获用户授权)。然后 `zstd -dc <src> > /tmp/s.jsonl` —— 1.6 MB 明文正常解出。
- 用户点过 `/export`(`command/done` → `Session log download requested.`)拿到的是**这种多帧 zstd**,**用户根本打不开** —— 属平台缺陷(档案 36 P1-6)。
**分析脚本写法(2026-09-11 踩坑后定型)**
```bash
# ❌ 别用:ssh 'node -e "..."' —— 内层引号转义在单引号 ssh 命令里必坏
# ✅ 一律:本地写 → scp → 远端执行
P=/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0
export PATH="$P/usr/bin:$P/mingw64/bin:/c/Windows/System32:/c/Windows:$PATH"
S="C:/Users/Administrator/AppData/Local/Temp/wb-scratch"
scp -i ~/.ssh/id_ed25519 -P 22 "$S/an.cjs" root@47.77.182.89:/tmp/an.cjs
ssh -i ~/.ssh/id_ed25519 -p 22 root@47.77.182.89 'node /tmp/an.cjs /tmp/g.jsonl users'
```
- ⚠️ **`.cjs` 后缀是必须的**:服务器 `/tmp/package.json` 含 `"type":"module"` → `/tmp/*.js` 被当 ESM,`require is not defined in ES module scope`(本次踩坑)。
- 脚本按 `MODE` 分支输出(`users` / `errors` / `tools` / `assistant` / `seq`),**一次写好复用**,避免反复 scp。
- `type` 分布里 `request/header` 每条含完整 system prompt + 工具表(占明文相当大比例)→ **务必按 `type` 过滤**,别全量 dump。
**JSONL 结构**:每行一条 JSON。第 1 行 `{"type":"session", cwd, agentPreset, ...}`;其余 `type` ∈ `request/header`(含完整 system prompt + 工具表)、`assistant/chunk`、`assistant/message`(含 `reasoning` + `tool-call`)、`tool/result`、`turn/end` 等。工具结果里能直接看到真报错原文。
**分析要点**(产出「问题清单」,P0/P1/P2 + 行号 + ≤60 字原文片段):
① 工具报错/重试/超时/权限被拒(**沙箱限制是高频根因**)② 用户挫败信号(反复纠正、重复要求、放弃)③ **平台侧缺陷**(菜单无反应、报错不可读、路径写死、工作区为空、会话中断)④ 卡点与无谓的工具调用 ⑤ **安全**:凭据明文(只报「第 N 行疑似凭据,类型 X」,**绝不抄值**)、越权尝试。
**额外可用信号**:报错在会话中的**位置分布**(`grep -n`)能区分「已修」还是「一直在发生」。
**必须先查「是不是已知问题」再报(2026-09-11 定型)**:动手前先 `ls /opt/dsh/docs/04-调整方案/` + 读同类旧档案(如 21 卡顿 / 23 环境限制 / 32 上次取证),**新发现要明确标注「新增」还是「修正/补充旧档案」**,否则会把已归档的结论当新问题重复报。
**产出后的闭环(别只输出清单)**:① 落 `04-调整方案/<NN>-<主题>.md`(含「修正旧档案的哪条结论」小节);② `INDEX.md` §二 追加 `04-<NN>` 行并更新状态摘要(**README 的档案清单已定格为历史对照,别只改 README**);③ `INDEX.md §六.1` 的「下一号」+1、`01-规范/03-路线图与待办.md` 补登记;④ 收尾四件套:`python 07-scripts/docs-audit.py`(**退出码 0**)→ `python 07-scripts/docs-manifest.py`(刷新机读清单)→ `bash 07-scripts/docs-sync-check.sh`(全绿)→ `MINE="<我改的文件>" PUSH=1 bash 07-scripts/handoff-guard.sh`(幽灵文件硬判定)。
@@ -0,0 +1,54 @@
# 插件与数据源口径 · 缓存版本守卫 / 白名单来源 / 技能 vs 插件 / 术语与环境约定
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:外部数据源缓存必须带结构版本守卫 · 官方白名单插件来源 · 技能 vs 插件:怎么区分 · 术语与环境约定(原行 L539–L554 + L907–L934)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L539–L554 + L907–L934 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
## 外部数据源缓存必须带结构版本守卫(2026-09-11 实证)
换数据源或在缓存条目里增删字段时,**必须同时 bump 一个 `CACHE_VERSION`**,并在读缓存时校验版本/结构(不符即视为未命中重拉)。
踩坑实录(档案 29):白名单路由从「git clone + 解析 yml」改为「拉 `plugins.json`」后,`whitelist-cache/index.json` 仍是**旧格式**且 `fetchedAt` 在 TTL(6h)内 → 被直接复用 → 条目缺 `owner`/`importKind` 字段 → 按字段过滤时 `e.owner.toLowerCase()` 抛异常 → **接口 500**。加 `CACHE_VERSION` 守卫后自动重拉修复。
配套好习惯:① 缓存读失败/不可用时**降级用旧缓存并返回 `stale: true`**,前端提示;② 排序按「热度」字段降序,让无效条目自然沉底,默认首页就是最有价值的那些。
## 官方白名单插件来源(档案 29 定型口径)
- **数据源**:`https://awesome-dsh-plugin.com/plugins.json`(官方规范地址,3.1MB / 3408 条 / 23 分类;npm 镜像包 `dsh-plugin-catalog`)。字段:`name/owner/url/page/category/description{zh,en}/npm/version/stars/downloads/install/added`。**不要**再去 git clone 仓库解析 3431 个 yml —— 其 `tarball` 是可选字段,覆盖率仅 6%(211/3431)。
- **导入口径 = 仅预构建**(平台侧绝不执行第三方构建脚本):有 `npm` → `registry.npmjs.org/<name>` → `dist-tags.latest` → `dist.tarball`(scoped 包地址 `@scope%2Fname`,即 `encodeURIComponent(name).replace('%40','@')`);有 `tarball` → release 资产;两者皆无(`install` 为 `github:owner/repo`)→ 拒绝并回显原因。**可导入 1792/3408 = 52.6%**,官方下载量 TOP10 全覆盖。
- **npm 官方 tarball 可直接喂现有 `stageTgzArchive`**(`package/` 包装层已被 `findPackageJson` 兼容)→ 无需改 DB schema。
- **筛选排序技巧**:给列表加 `onlyImportable=1`,并在前端把「只看可导入」默认打开;按 `downloads` 降序使未发 npm 的条目自然沉底。
- **安全兜底不外包**:收录 ≠ 安全审计(上游 README 明确)。所有导入包仍走平台 `stageTgzArchive` 扫描,P0 命中即阻断,并把原因**在页面内回显**给 admin 判断(例:热门插件 `FuRongJun-1999/dsh-memory` 因 `docs/…example.yml` 被判 P0,疑似误报)。
## 技能 vs 插件:怎么区分(2026-09-11 更正,别用"有没有代码/界面"判)
**先纠正一个常见误解**:技能**可以带 `scripts/`**(实证:`短视频工作台/scripts/MCN_CYLG_API.py`、`mcp-config.json`)。所以「技能=纯内容无代码」「不带界面的插件就是技能」**都不成立**。
**正确判据 = 「代码何时、被谁执行」**:
| | 技能(skills) | dsh 插件(plugins) |
|---|---|---|
| 包形态 | 目录 + `SKILL.md`(+ `scripts/`、`references/`) | npm 包 + **`dsh.bundle.patch`**(+ 可选 `client.js`) |
| **代码执行者** | **agent 通过 bash 工具按需调用**(SKILL.md 指示 → 模型决定) | **cordis 在实例启动时加载执行**(无人决定) |
| 是否经审批/沙箱 | ✅ 走统一工具管线(approval + 沙箱 + uid/cgroup) | ❌ **不经**,它自己就是实例进程的一部分 |
| 参与框架生命周期 | 否(纯文件) | 是(注册服务/工具/UI,进 `profile.bundles`) |
| 装载时机 | 运行时按需发现,**watch 即时生效** | **启动时**,改后必须重启 |
| 失败后果 | 脚本报错 / agent 表现不佳 | **实例起不来(崩溃循环)** |
| 透明度 | 命令进会话记录,可追溯 | 静默运行 |
**可编程判据(我们代码里已在用)**:看包里有没有 **`dsh.bundle`**(`isBundle()` 判 `dsh.bundle.patch`)→ 有=插件,无=技能。**有无 client 面(UI)只是插件的可选属性**,与"是不是技能"无关。
**风险分层(理由要准)**:技能低风险靠三条——**惰性**(不调用不执行)+ **可见**(命令在 transcript)+ **失败不致命**;插件高风险的核心理由是 **开机即执行、无人介入、失败致命**。
> ⚠️ 注意:档案 33 把默认档改成 `danger-full-access` + `approval: never` 后,**技能脚本也不再弹确认**(仍受沙箱/uid/cgroup 约束),两条路线的"人工拦截面"差距因此缩小——分层的主要依据从"审批"转为"是否被框架主动加载 + 失败是否致命"。
## 术语与环境约定(2026-09-11 档案 38b)
- **术语:统一叫「功能插件」**(2026-09-11 起由「业务插件」改名)。**只改显示文案/注释,代码标识符保持不变**:表名 `business_plugins`、包名 `@dsh-local/business-plugins`、section id `business-plugins`、API 路径 `/api/plugins/{business,mine}`、文件名 `business-plugins.ts` / `ensure-biz-plugins.cjs`、目录 `poc/business-plugins/`。
- **`ensure-biz-plugins.cjs` 已是「版本感知 + 自动取最新产物」**:升级流程 = 把新包丢进 `/opt/dsh/artifacts/` → 跑一次脚本(落后会自动 `pnpm add`)。**升级时不要先删旧包**,否则依赖表里的旧 `file:` 指向失效文件会让 `pnpm` 整体 ENOENT 失败(正确顺序:先放新包 + 改依赖指向 → `pnpm add` → 再删旧包)。
- **`.gitignore` 已忽略 `*.bak-*`**:`git add <dir>` 会把同目录的未跟踪备份一并暂存(已踩一次)。提交前用 `git status --short` 核对,或定向 `git add <具体文件>`。
- **服务器 Python 是 3.6.8,不要动系统 `python3`**:`/usr/libexec/platform-python` 是 `dnf`/`yum` 的 shebang,替换/升级会直接搞坏包管理器。**运维脚本一律用 Node**(平台技术栈就是 Node);必须用 Python 时写 3.6 兼容代码(无 `subprocess.run(capture_output=)`、无 f-string `=` 调试等 3.7+ 特性)。确需现代 Python 就**并行装 `python3.11`**(仓库有),别切 alternatives。
@@ -0,0 +1,316 @@
# 实例可见面 · 软件共享 · 网络与安全边界(档案 38a/39/41/44/46)
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:实例可见面 · 软件共享 · 网络与安全边界 · 铁律:实例能看到什么,完全由 bwrap 挂载面决定 · 🔒 基础运行时版本冻结 · 📦 实例共享工具 · 资源与网络边界 · 只读诊断配方 · 📋 实例可访问路径清单 · 技能上传安全(原行 L601–L906)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L601–L906 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
## 实例可见面 · 软件共享 · 网络与安全边界(档案 38a,2026-09-11 实测)
### 铁律:实例能看到什么,完全由 bwrap 挂载面决定
`orchestrator.ts` `spawnAsUser()` 的 `bwrapArgs` **只挂这些**(照抄,勿凭记忆):
```
--ro-bind /usr /usr ← 含 /usr/local(宿主装一次 = 全员共享的现成通道)
--ro-bind /lib64 /lib64
--symlink usr/bin /bin ; usr/sbin /sbin ; usr/lib /lib ← 合成根(档案 23)
--tmpfs /etc + 14 项文件白名单 ← 见下「/etc 白名单」;**不要用 --ro-bind /etc /etc**
--dev /dev --proc /proc
--bind <userRoot>/tmp /tmp ← 每用户独立,1777
--bind <userRoot> <userRoot>
--ro-bind-try <userRoot>/home/profiles/<web|headless>/{cordis.patch.yml,package.json,pnpm-lock.yaml} ← 只读,防绕过投放管控
--unshare-pid ← 注意:**没有 --unshare-net**
--chdir <userRoot>/ws -- setpriv --reuid <uid> --regid <uid> --clear-groups <cmd>
```
> ⚠️ **最常见的漏**:**平台侧任何新增的共享目录,都必须同步补一条 `--ro-bind`,否则实例内根本不存在**。
> 实证(档案 38a P0):`bundledSkillDir` = `<dataRoot>/bundled-skills`(=`/var/lib/dshs/bundled-skills`)**没被挂载** → 以平台原样参数进实例 `ls` = `No such file or directory`;而 `dsh-skill-filesystem/lib/index.js:84,181` 是 **`resolve()` + 实例内直接读盘**(不是编排器推数据)→ **档案 10/11 的共享技能层实际失效:技能投进去也发现不了**。修法 = `'--ro-bind-try', bundledSkillDir, bundledSkillDir`(放在 `--bind root root` **之后**)。
#### `/etc` 白名单(档案 39,2026-09-11 落地)
**`--ro-bind /etc /etc` 不要用** —— 实例会读走 `/etc` 下 **576 个 others-readable 文件**,含**平台情报**:
`/etc/systemd/system/dsh-*.{service,path}`、`/etc/nftables-dsh-egress.nft`(**出网护栏规则全文**)、
`/etc/cron.d/dsh-*`、`/etc/letsencrypt/renewal/*.conf`。
正确写法 = **空 `--tmpfs /etc` + 逐文件 `--ro-bind-try`**,白名单 15 项(运行时实测必需最小集):
`ld.so.cache` `ld.so.conf` `passwd` `group` `nsswitch.conf` `hosts` `resolv.conf` `host.conf`
`services` `localtime` `os-release` `machine-id` `pki/tls/certs` `pki/ca-trust` **`alternatives`**
- ⚠️ **symlink 必须 `realpathSync()` 后绑「真实目标 → symlink 原路径」**,否则实例内是断链
(`nsswitch.conf` → `/etc/authselect/`;`localtime` → `/usr/share/zoneinfo/`)。
- 🔴 **`alternatives` 是「软链枢纽目录」,必须整体挂 —— 这是最容易漏、且事故最隐蔽的一条**
(2026-09-11 实测踩到):`/usr/bin/python3` 是**两跳软链**
`/usr/bin/python3 → /etc/alternatives/python3 → /usr/bin/python3.6`,
中间一跳在 `/etc` → 沙箱内 `python3` **静默 `command not found`**(不是报错,是"命令不存在")。
一次就打断 **21 个命令**:`python3` `python` `pip3` `pip-3` `pydoc3` `python3-config` `pyvenv-3`
`easy_install-3` `unversioned-python` `ld`(→`ld.bfd`,node-gyp 编译要用) `pax`
`print-*` `ifup` `ifdown` `lpc`。
→ **判定准则:只绑"直接指向 /etc 的软链"是不够的,必须枚举"整条链条会穿过 /etc"的条目**:
```bash
find /usr/bin /usr/sbin /usr/libexec /usr/local/bin -maxdepth 1 | while read -r f; do
[ -L "$f" ] || continue; c="$f"; n=0
while [ -L "$c" ] && [ $n -lt 10 ]; do t=$(readlink "$c")
case "$t" in /*) c="$t";; *) c="$(dirname "$c")/$t";; esac
case "$c" in /etc/*) echo "$f -> $c";; esac; n=$((n+1)); done
done | sort -u
```
安全性:`/etc/alternatives` 33 项**全部指向 `/usr` 或 `/lib64`**(已只读挂载),不含凭据/平台情报。
- ⚠️ **这类回归的唯一验收手段是"工具清单前后对比"**(`for c in …; do command -v $c; done`)或**会话取证**
—— 它不报错、不崩溃,只是能力静默消失,所以 `/etc` 白名单类改动**必须跑一次全量工具对比**。
- 实测效果:可见项 **222→13**、可读文件 **576→27**、平台情报 **4/1/2/8 → 0/0/0/0**;
同时 `node` / `os.userInfo` / DNS / node-TLS / `curl` / `dsh --dump-config`(549 行/176 插件)**全通**。
- 凭据类本来就读不到(`dshs.env` 600、`shadow`/`gshadow` 0000、`sudoers` 440)——
**别把"没泄露"当成本次收益**。
- 判据:**`/etc` 里凡是"平台的秘密"就白名单化**;`/usr` 不必动(审计证实零凭据,且是运行时宿主;
`/usr/bin` 的 1151 个 CLI 是 agent 唯一手脚,去掉=平台核心能力归零)。
#### ⛔ H5 红线:OUTPUT 链按「目的地址」封本机服务 = 自伤(档案 39 实测踩到)
实例是「先收后回」的服务端:nginx(root) → 实例端口的 SYN 合法,但**实例回的 SYN-ACK 与后续数据包
`daddr` 同样是 `127.0.0.1`**。若写 `meta skuid <uid段> ip daddr {127.0.0.0/8,…} reject` 一刀切,
**回包会被一起拒掉** → nginx 无法回源、**实例整体不可用**。
- **症状指纹**:root 连实例端口 **timeout(丢包)而不是 refused**。refused = 规则只打在客户端方向(正常);
**timeout 往往意味着双向都被打到**。
- **正确写法**:只匹配**主动发起**的连接 —— TCP 用纯 SYN、UDP 用 `ct state new`:
```
meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, <eth0>, <公网EIP> } \
meta l4proto tcp tcp flags & (fin|syn|rst|ack) == syn counter reject with tcp reset
meta skuid 100000-199999 ip daddr { …同上… } meta l4proto udp ct state new counter reject
```
- 与平台既有 `src/supervisor/firewall.ts` 的 **portGuard**(iptables `-m owner ! --uid-owner 0 -j REJECT`)
**语义一致、范围更全**,且不依赖 `config.portGuard` 开关;portGuard 的存在**反证实例进程自身不需要任何
loopback 连接**。
- **验证闭环(缺一不可)**:① root 回源必须拿到 `303`/`200`(**不能只看 `ss` 有 LISTEN**);
② 实例内自连宿主端口必须 `ECONNREFUSED`;③ `nft list table ip dsh_egress` 看 counter 是否命中。
- **改前先查有没有实例在跑**:`systemctl list-units "dsh-*scope" --no-pager --all`。
- 一键复现:`bash scripts/install-egress-guard.sh`(含 nft + service,已纳入版本控制)。
#### ⛔ 无法遮蔽 `/usr` 内的文件(档案 42 实测踩到 → 实例起不来)
想「屏蔽 `/usr` 里某个文件」(例如藏掉旧解释器 `python3.6`)时**不能用叠加挂载**:
`--ro-bind /dev/null /usr/libexec/platform-python3.6` 会让 bwrap 报
`Can't create file at …: Permission denied` → **实例直接起不来**。
根因:bwrap 需要**在 DEST 创建挂载点**,而 `/usr` 是 **ro-bind(只读)** → 创建失败。
`/etc` 之所以能自由白名单,是因为它先 `--tmpfs`(可写)再逐项 `--ro-bind-try`。
- **判据**:**只有「先 tmpfs 再白名单」的目录才可自由增删**;ro-bind 的目录只能整目录决策。
- **可行变体(未采用,收益低风险中)**:`--tmpfs /usr/libexec` + rebind 必需子项
(`git-core` `getconf` `gawk`/`awk` `coreutils` …)→ 与 /etc 同构的白名单,但要有同类的"静默回归"预案。
- ⚠️ **改 bwrap 参数后必须真启动一次实例验证** —— 本类错误会让**所有实例无法启动**(不是静默降级);
改前先确认无实例在跑:`systemctl list-units "dsh-*scope" --no-pager --all`。
#### 实例共享 Python 运行时(档案 42)
- 系统 `python3` = **3.6.8**,本体 `/usr/libexec/platform-python3.6` 是 **dnf/yum 的兄弟解释器**
(shebang 用绝对路径 `/usr/libexec/platform-python`)→ **不能移除**;而 `/usr/bin/python3`
只是两跳软链(经 `/etc/alternatives`),**动它安全**。
- 平台已装**可移植 Python 3.12**:`bash scripts/install-python-runtime.sh`(幂等 / 可离线)
→ 解压 python-build-standalone 到 **`/usr/local/dsh-runtime/python-3.12.14/`**,
`/usr/local/bin/{python3,python,pip3}` 指过去。
`/usr` 已 ro-bind + `/usr/local/bin` 在实例 PATH 首位 → **实例内 `python3` 即 3.12,零代码、无需重启**。
- **迁移打包单元 = `/usr/local/dsh-runtime/` 一个目录**(自包含,不依赖系统 rpm):
`tar czf dsh-python-runtime.tar.gz -C /usr/local dsh-runtime` → 目标机解压回原位 + 重跑脚本(只重建软链)。
- 📌 **对 AI/用户的引导(这是"3.6 不开放"的正解,不是遮蔽)**:明确写「Python 用 `python3`(= 3.12);
`python3.6` / `python2` 是系统遗留、**不受支持**」。实例内 `/usr/local/bin` 在 PATH 首位,
`python3` 已指向 3.12 —— 引导的成本为零、风险为零;而遮蔽属"改挂载结构",会让实例起不来(见上)。
### 🔒 基础运行时版本冻结(档案 44)
**要求:禁止用户以任何方式升级 Python / pip / node 等基础包**,避免版本差异导致插件功能不可用。
**已经天然成立的护栏**(都不需要额外代码):`/usr` 是 ro-bind →
实例内**改不了** `/usr/local/dsh-runtime/**`;`pip install`(默认)被 Read-only 挡;
`npm/pnpm -g` 失败(`/usr/local/lib/node_modules` 只读);`$HOME/.local/bin` **不在**实例 PATH(固定
`/usr/local/bin:/usr/bin:/bin`);dsh 主进程由 orchestrator spawn,PATH 取自 root 环境,不受用户影响。
**唯一的缝 = Python 的 user-site**(2026-09-11 实测):
`pip install --user` 一旦创建 `$HOME/.local/lib/python3.12/site-packages`,它就会进入 `sys.path`
且**排在平台 `site-packages` 之前** → 用户装的同名包**盖住平台包**(实测 `import packaging` 拿到用户版 26.3)。
**修法(两条限制性 env,官方机制、互不冲突,已注入 `baseEnv` + 放行 `ALLOWED_ENV`)**:
```
PYTHONNOUSERSITE=1 # user-site 不进 sys.path
PYTHONUSERBASE=/usr/local/dsh-runtime/.no-user-install # pip --user 写只读位 → 明确报错
```
- 实测:`sys.path` 中无 user-site;`pip --user` 报
`Can not perform a '--user' install. User site-packages are disabled for this Python.`;
历史污染失效(平台包不再被盖);**`--target` + `PYTHONPATH` 仍可用**(这是给用户的推荐路径)。
- ⚠️ 这两条虽是"注入 env"(R5 触发项),但**方向是收窄**(限制用户侧安装)→ 可直接做,档案留痕即可。
**版本漂移巡检**:`scripts/runtime-baseline.cjs`(`--accept` 写基线;默认比对,漂移则退出码 1),
cron **每天 05:10** 跑;基线存 `/opt/dsh/state/runtime-baseline.json`(当前 = python3 3.12.14 /
pip 26.2.1 / node 22.23.2 / npm 10.9.8)。**升级运行时后必须 `--accept` 更新基线**,否则会一直告警。
### 📦 实例共享工具(jq / ripgrep / ffmpeg,档案 46)
**核心答案:用户要工具/程序包时,`不需要`每人装一份** —— 宿主装一次全员共享。依据:
实例内 `/usr` 是 **ro-bind** 且 `/usr/local/bin` 在实例 PATH **首位** → 宿主安装**全部实例(含新用户)
立即可见**,零代码、无需重启;而用户侧本来就装不上(`/usr` 只读 + 无 sudo)。
- **一键安装**:`bash scripts/install-shared-tools.sh`(幂等 / 固定版本 / **官方 sha256 校验** / `--force`)
当前已装:`jq 1.8.2`、`ripgrep 15.2.0`(musl 静态)、`ffmpeg`+`ffprobe`(BtbN 静态构建,自带全部编解码库)。
- **新增共享工具的标准动作**:优先找**静态单文件**发行版 → 放 `/usr/local/dsh-runtime/bin/` +
软链到 `/usr/local/bin/` → 更新 `/usr/local/dsh-runtime/SHARED-TOOLS.md` →
跑 `node scripts/runtime-baseline.cjs --accept` 刷新版本基线。
- **迁移单元**:整个 **`/usr/local/dsh-runtime/`** 一个目录(Python + 这些工具都在里面)→
`tar czf dsh-runtime.tar.gz -C /usr/local dsh-runtime`,目标机解压回原位 + 重跑两个安装脚本。
- ⚠️ **版本号提取别用通用规则**:ffmpeg 的版本形如 `N-126492-gefb0a7e5e7`(**不含 `x.y`**),
基线脚本对它单独用 `ffmpeg version (\S+)`。
- ⚠️ **Playwright 仍未做**:它不是单文件,除浏览器二进制外缺 **11 个系统 `.so`**(`libnss3`/`libgbm`/
`libatk`/`libcups`/`libdrm`/`libxkbcommon`…)。系统库可 `dnf install`(宿主装一次同样全员可见);
浏览器二进制建议共享位 + `PLAYWRIGHT_BROWSERS_PATH`,但**注入该 env 属 R5「给实例注入新 env」= 扩大**,
须先出「权限影响评估」并取得确认。
- 🖥️ **admin 可视化**:门户 **`#/runtime`「运行环境」**页(档案 47)+ `GET /api/admin/runtime`
(admin 只读)—— 列名称/版本/**来源**/**安装脚本**/可否卸载 + **版本漂移告警**(对比基线)+
目录/体积/最近变更 + 折叠的「升级 / 卸载 / 迁移怎么做」。
**术语澄清**:「宿主」= **服务器本身**(不是某个用户);安装由**平台以 root** 执行,用户侧装不进 `/usr`。
- pip 落点:默认装 runtime 的 site-packages(**只读被挡**);**推荐 `--target <ws>/.pylibs` + `PYTHONPATH`**(实测可用)。
- ⚠️ 宿主 `/usr/local/bin/python3` 现在是 3.12 → 已核实宿主**无任何东西依赖裸 `python3`**
(dnf/yum 绝对路径;BT-Panel 用自带 pyenv 绝对路径)。
- ⚠️ **平台自身 bug(待治本)**:`business-plugins.ts` / `ensure-biz-plugins.cjs` 以 **root** 跑 `pnpm`
且 `HOME=<userRoot>/ws` → 在用户工作区留下 **root 属主的 `.local/`** → 用户 `pip install --user`
报 `Permission denied`(自己的目录里装不了包)。**不能简单改 HOME**(要与实例共用 pnpm store,
否则 `ERR_PNPM_UNEXPECTED_STORE`)→ 应在 pnpm 流程后把 `ws` 下的 root 属主项 chown 回用户,
或放进 `ws-cleanup.cjs` 做自愈。
- 🔐 **本环境出网走 TLS 中间人代理 → Python 脚本必须显式指定 CA**(2026-09-11 guest 会话实证)。
服务器上有**两份 CA bundle**:
- `/etc/pki/tls/certs/ca-bundle.crt` —— **含代理 CA**(**curl 默认读这份**,所以 curl 一直正常)
- `/etc/ssl/certs/ca-bundle.crt` —— 系统原始,**不含代理 CA**(**Python 默认读这份**)
→ 在实例里用 Python 抓 HTTPS 会报 `URLError(SSLCertVerificationError: …)` 或
`unable to get local issuer certificate`。**正解(零平台改动,就是 A 方案)**:
```bash
SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt python3 your_script.py
```
```python
# 或写进脚本(推荐,免每次手敲)
import os; os.environ.setdefault("SSL_CERT_FILE", "/etc/pki/tls/certs/ca-bundle.crt")
# requests 亦可用 REQUESTS_CA_BUNDLE 指向同一文件
```
**写技能/脚本时把这一句固化进去** —— 这是**环境特性,不是用户错误**;不固化就会反复踩
"某个工具莫名连不上网"。**不要**改成平台注入 env:那属 **R5「给实例注入新 env」= 扩大**,
而一行代码就能解决。
(相关:`/etc/ssl` 已加入实例 `/etc` 白名单,否则连系统 CA 都读不到 —— 档案 49 事后修正 2。)
**跨用户共享本地安装 → 一律走宿主**:
| 方式 | 改动 | 评价 |
|---|---|---|
| **宿主 `/usr/local` 或 `/usr/bin`**(推荐)| **零代码**,`/usr` 已 ro-bind | 装一次全员生效、新用户自动。全局单版本 |
| `<dataRoot>/shared-*` + `--ro-bind` + env 白名单 | 改 `orchestrator.ts` + `spawn.ts` ALLOWED_ENV | 与 bundled-skills 同构,**必须先修 bind** |
| 让用户自己 `pip/npm install` | — | ❌ 用户侧**装不上**(`/usr` 只读 + 无 sudo),装上了也是 N 份重复且污染工作区 |
### 资源与网络边界(改默认值/做容量规划前必看)
- **宿主 1870MB / 2 vCPU / 40G 单分区,`quotaon /` 未启用 = 无磁盘配额** → 单租户可写满打瘫全平台。
- 每实例内存 **`MemoryHigh=448M` → `MemoryMax=1024M`**(**与插件集合无关**;见档案 96)+ `CPUQuota=150%` + `TasksMax=128` → **并发实例上限约 2–3 个**(宿主 1870MB)。
- **bwrap 只 `--unshare-pid`,不 `--unshare-net`** → 实例与宿主**共享网络命名空间**。**已在档案 39 缓解**:nft **H5** 封锁实例**主动**访问 `127.0.0.0/8` + eth0 + docker0 + 公网 EIP(写法与坑见上「H5 红线」)。⚠️ 残留:实例仍可 `bind 0.0.0.0`(入站方向未限制)。
- 出网护栏 `table ip dsh_egress`(`/etc/nftables-dsh-egress.nft`,一键复现 `scripts/install-egress-guard.sh`):**H1** `reject` 云元数据 `100.100.100.200`;**H5** 封锁实例主动访问宿主自身;其余新建外联只 `log prefix "dsh-egress" level info` **不拦截**。DNS = 阿里云内网 `100.100.2.136/138`(**绝不可封 `100.100.0.0/16`**)。
- `HOME=<userRoot>/ws`(不是 `home/`)→ pip/npm 缓存全落在**用户工作区**里(`ws/.npm`、`ws/.cache/pip`),会被门户 `#/files` 展示、也可能被 ws-cleanup 触碰。
### 只读诊断配方(不改用户目录、不起实例)
```bash
B=/usr/bin/bwrap; R=/var/lib/dshs/users/<UID>
# 把 diag 脚本 ro-bind 进命名空间,避免往用户 tmp 写文件
$B --ro-bind /usr /usr --ro-bind /lib64 /lib64 \
--symlink usr/bin /bin --symlink usr/sbin /sbin --symlink usr/lib /lib \
--tmpfs /etc --dev /dev --proc /proc \
--bind $R/tmp /tmp --bind $R $R --ro-bind /tmp/diag.sh /tmp/diag.sh \
--unshare-pid --chdir $R/ws \
-- setpriv --reuid <uid> --regid <uid> --clear-groups /bin/sh /tmp/diag.sh
# 权威 bwrap 参数不要读源码猜:直接看失败/运行中的 scope
systemctl list-units "dsh-*" --no-pager --all | grep scope
```
> ⚠️ **`pgrep -f "dsh --profile web"` 会匹配到你自己的命令行**(命令串里含该模式)→ 拿到假 PID。改用 `ps -eo pid,user,cmd | grep "^ *[0-9]\+ .*node /usr/local/bin/dsh"` 或直接读 `systemctl list-units "dsh-*"`。
### 📋 实例可访问路径清单(权威版 · 2026-09-11 档案 39 收窄后)
**只有一栏是"该用户的数据",其余都是"公共软件"或"平台自己"——写文档/答用户时按此表口径。**
| 能读到 | 权限 | 说明 |
|---|---|---|
| `<dataRoot>/users/<该用户id>/` | **读写** | **唯一属于该用户的数据**:`home/`(= `DSH_HOME`:profiles/sessions/settings/storages/`skills/`(**已启用**)/ `skills-library/`(**已禁用的自建技能**,档案 41)/ `.credentials.yaml`)、`ws/`(= `HOME`,默认工作区,pip/npm 缓存也在这)、`tmp/`(该用户的 `/tmp`,1777)、`patches/*.yml`、`handoff.json` |
| `/usr` | 只读 | 公共软件层(node/dsh/npm/pnpm + `/usr/bin` 1151 个 CLI + `/usr/lib64`)。**审计证实零凭据、零用户数据** |
| `/lib64` | 只读 | 系统共享库(node 动态依赖 7 个 `.so`) |
| `/etc`(**白名单 15 项**) | 只读 | `ld.so.cache` `ld.so.conf` `passwd` `group` `nsswitch.conf` `hosts` `resolv.conf` `host.conf` `services` `localtime` `os-release` `machine-id` `pki/tls/certs` `pki/ca-trust` **`alternatives`**;**其余 `/etc` = 空 tmpfs(可写、临时、退出即消)** |
| `/dev`、`/proc` | — | 运行必需;`--unshare-pid` → `/proc` 只见本命名空间进程 |
| `/bin` `/sbin` `/lib` | 符号链接 | → `usr/bin` `usr/sbin` `usr/lib`(补齐标准布局,避免沙箱探针 execvp 失败) |
| `<dataRoot>/bundled-skills/` | **只读** | 平台共享技能层(档案 40 起已挂载)。**内容对每个登录用户可读** → 禁止放内部文档/私有提示词/凭据 |
| 读不到 | 依据 |
|---|---|
| `<dataRoot>/users/<其他用户id>/` | bwrap 未挂载 + uid 隔离(实测只列自己那 1 项) |
| `<dataRoot>/dshs.db` | 未挂载(实测 No such file) |
| `/etc` 白名单外**全部**(systemd 单元 / nft 护栏 / cron / letsencrypt / ssh / firewalld / selinux / 576 个文件) | 档案 39 `/etc` 白名单 |
| `/root`、`/home/*`、`/opt`、`/var`(自己路径除外)、宿主 `/tmp` | bwrap 未挂载 |
| 宿主 `127.0.0.1:*`、eth0、docker0、公网 EIP | nft **H5**(实测 ECONNREFUSED) |
| 云元数据 `100.100.100.200` | nft **H1** |
**仍然可达(已知残留)**:公网(pip/npm/API/LLM)、阿里云内网 DNS `100.100.2.136/138`、
`bind 0.0.0.0`(**入站方向未限制**,跨租户互通靠 H5 的发送侧拦截)、同实例命名空间内 `setpriv` 后的自有进程。
**技能的投放通道(2026-09-11 实测)**:
| 通道 | 路径 | 谁可改 | 备注 |
|---|---|---|---|
| preset 自带 | `<dsh>/node_modules/@deepseek-ai/dsh-agent-presets/presets/<preset>/skills/` | **不可** | dsh 只内置 **2 个**(`cordis-plugin-development`、`editing-cordis-compositions`,都在 `cordis` preset 里)。**改它 = 改官方包(R2 红线)** |
| bundled 共享层 | `$DSH_BUNDLED_SKILL_DIR`(rank **600**,全员只读) | 平台 | ⚠️ **当前断的**:见下 |
| 用户层 | `$DSH_HOME/skills`(rank **400**,每用户独立,watch 即时生效) | 用户 | 平台"用户级启停"应走这层 |
> ✅ **bundled 层已修复(档案 40,commit `37e016f`)**:bwrap args 在 **`--bind root root` 之后**追加
> `'--ro-bind-try', bundledSkillDir, bundledSkillDir`(仅当 `config.bundledSkillDir !== ''`)。
> 实测:实例内 `ls $DSH_BUNDLED_SKILL_DIR` 可见、`readFileSync(SKILL.md)` = OK(= 发现条件成立)、
> `mountinfo` 为精确叶子 `ro,nosuid,nodev`、`touch` = Read-only;**可见面未扩大**(`ls users/` 仍 1 项、
> DB 不可读、`/etc` 仍 13)。⚠️ 此前"只注入 env 就以为配好了"是错的 —— **skill-filesystem 在实例内读盘,
> env 只决定"去哪儿找",目录不挂载就是找不到**(通用判据:插件在实例内 `resolve()` + 读盘的东西,
> 必须真的出现在命名空间里)。
> ⚠️ **宿主 `<dataRoot>/bundled-skills` 目前仍为空** —— 机制通了,但**尚未投放任何技能**。
> 🔬 **修法安全性已实测(2026-09-11,guest uid 100002 实跑)**:加该行后 `mountinfo` = `…/bundled-skills → 同名 ro,nosuid,nodev`(**精确叶子路径 + 只读**);`ls …/users/` **只列用户自己那一个**(看不到其他用户);读 DB = **No such file**;`touch` 挂载点 = **Read-only file system**。**对照组(不加)= `/var/lib/dshs/` 里只有 `users`** → 支持面零变化,且**父目录本来就是 bwrap 为用户 root 合成的**(今天线上已可见该路径名,`users/` 是只含自己的空壳),**不是这一行新暴露的**。
> ⛔ **红线:只能挂最内层路径**。写成 `--ro-bind /var/lib/dshs /var/lib/dshs`(父目录)= **一次性把全部用户 home + DB + 凭据放进每个实例**。这是本改动唯一的真实风险,且属手滑型错误 —— 改完必须**逐字复核 bwrap args**,并跑一次上面的可见面实测。
> ⚠️ **副作用(设计意图,但要说清)**:挂上后 `bundled-skills/` 里的内容**对每个登录用户可读** → 该目录**禁止放内部文档 / 私有提示词 / 凭据**;投放清单必须按"全员可见"审一遍。
> ⚠️ **dsh 技能没有"禁用"机制**(`skill-filesystem` 无 disable/deny)→ ranked 600 的共享层**无法 per-user 关闭**,只适合"人人必须有的基线技能";要让用户可启停,必须用 rank 400 的用户层(复制进/删掉)。
**平台侧 API ↔ 技能层 的映射(2026-09-11 核实 `src/web/routes/skills.ts`;档案 41 后已扩充)**:
| API | 鉴权 | 落点 | 层 | 说明 |
|---|---|---|---|---|
| `GET/POST/DELETE /api/skills/shared`(+`/apply`) | **`requireAdmin`** | `<dataRoot>/bundled-skills` | bundled **600** 共享只读 | ✅ 档案 40 起已挂载可发现;对用户 **`locked:true`(不可禁用/删除)** |
| `GET/POST /api/skills/mine`(+`/apply`) | `requireAuth`(任意登录用户) | `<userRoot>/home/skills` | 用户 **400** 独立 | ✅ 用户自建技能;`GET` 每项带 `source`/`enabled`/`locked` |
| `POST /api/skills/mine/:name/enable` \| `/disable` | `requireAuth` | 启用 ↔ `<userRoot>/home/skills-library/` | 用户 **400** | **dsh 无 disable 机制** → 「禁用」= `rename` 出扫描根(**保留文件**,可再启用)。watch 即时生效,**无需重启** |
| `DELETE /api/skills/mine/:name` | `requireAuth` | 两处同删 | — | 彻底移除用户自建技能 |
| 同名守卫 | — | — | — | 与共享技能同名 → 上传/启用/禁用/删除**一律 409**(否则用户白传一份永远被 rank 600 压住、又关不掉的技能) |
> ⚠️ **投放前必须平铺 —— `discoverRoot` 只扫 1 层**:带 `subskills/` 的技能(实证 `短视频工作台/subskills/{mcn-data-insight,mcn-dou-analysis,…}`)**子技能不会被发现**。投放时把 `subskills/*` 提升为顶层,或每层各投一个技能。`references/`、`scripts/` 是技能内部相对路径引用,**不受影响、别动**。
> ⚠️ **`scripts/` 的可用性由"宿主基线"决定**(技能脚本经 bash 执行 → 受只读 `/usr` 约束):宿主缺 `python3.9+`/`ffmpeg`/`rg`/`jq`/浏览器时,脚本写出来也跑不动。**投放前先核对宿主基线**,别在 SKILL.md 里写"请先装 X"(用户侧装不上)。
> ⚠️ **安全:分清"谁在执行"**(档案 41 定稿)——
> · **运行**技能 `scripts/`:走 agent 的 bash 管线,困在用户沙箱里(uid+bwrap+cgroup+`/etc` 白名单+H5)→ **自伤,可接受**,**不该以"用户能跑任意代码"为由禁止**;
> · **上传/解压**技能包:**平台以 root 执行** → **这才是平台级风险点**(见下「技能上传安全」)。
> 注意档案 33 后默认 `danger-full-access` + `approval: never` → 技能脚本**不再弹确认**。
### 技能上传安全(档案 41 · 该类漏洞的通用判据)
**P0 实测复现的根因:`zip` 的成员可以是符号链接,而 `unzip` 默认原样恢复它。**
symlink 的**目标写在 zip 元数据里、不在任何文件内容中** → `scanDir`(只读文件内容,且遇 `!st.isFile()` 直接 `continue`)**天然看不到**;成员名校验(拒绝对路径 / `..`)也**放行**。
随后 `applyStaged` 以 **root** 执行 `chownTree`/`chmodTree`,二者**跟随**符号链接 → 改写**链接目标**(宿主任意文件)的属主与权限。实测 `-rw------- root:root` → **`-rw-r--r-- <租户uid>`**;指向 `/etc/shadow` = 密码哈希 chmod 644 全机可读 + chown 给该租户。
攻击面 = **传了 `owner` 的那条路由**(`mine`;`shared` 不传 owner,不受影响)。
**必须同时覆盖两个维度**:① **文件内容**(现有 `BLOCK_PATTERNS` P0 阻断 / `WARN_RULES` P1 告警 —— `scanDir` 内部**会 throw 400**,不是只告警);② **归档元数据与文件类型**(symlink / 硬链 / 设备 / FIFO + 解压规模)。
**三项加固(已落地)**:
1. `findSpecialEntry()` —— 解压后**立刻**拒绝任何非普通文件/目录条目(在任何 `chown/chmod` 之前);
2. `sumUncompressedSize()`(解析 `unzip -l`)+ `MAX_SKILL_FILES=2000` + `MAX_SKILL_UNCOMPRESSED_BYTES=200MB`
—— `UPLOAD_BODY_LIMIT=180MB` 只限**压缩体**,而宿主 `quotaon /` 未启用 = **无磁盘配额** → zip bomb 可跨租户 DoS;
3. 纵深防御:`chownTree` 用 **`lchownSync`** 且跳过 symlink;`chmodTree` 跳过 symlink(**Linux 无 `fs.lchmod`**,ENOSYS)。
> 更彻底(未做):以目标 uid **降权解压**(`setpriv --reuid <uid> ... unzip`),让整条链路都不在 root 下。
**技能的依赖:没有机制(最大缺口)**。`dsh-skill-filesystem` **不处理任何 install/deps/package.json** —— 技能包只能靠 `SKILL.md` 写"请先装 X",由 agent 运行时手敲。对比插件:`package.json.dependencies` + `pnpm add` 自动解析(store 硬链复用)。约束:实例内 `/usr` **只读** → 系统级装不上;只能 `--user`/本地;无共享、每用户各装一份。
→ **平台若要多用户投放带依赖的技能,必须在启用时"代装"**:Node 依赖装到该技能目录(或用户 ws 下统一 `node_modules` + `NODE_PATH`);Python 依赖用**用户级 venv**(`$DSH_HOME/.venvs/<skill>`)并让脚本用该解释器。**绝不让 agent 临时 pip/npm install**(只读 `/usr` + 供应链 + 不可复现)。
@@ -0,0 +1,60 @@
# 并行调度详解 · 冲突域清单 / 批次模型 / 反例 / 三把锁落地机制
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:冲突域清单 · 批次模型与依赖处理 · 反例(同域并发的具体破坏形态) · 落地机制(原行 L1003–L1052)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L1003–L1052 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
### 冲突域清单(同域必串行,跨域才可并行)
| 冲突域 | 涉及资源 | 并行性 |
|---|---|---|
| 门户后端代码 | `/opt/dshs/src/**` → `npm run build` 全量重编译 `lib/` | ❌ 独占:一批只允许 1 个改码任务 |
| 服务重启 | `systemctl restart dshs` | ❌ 全局;重启会打断所有用户实例 |
| 前端页面 | `web/*.html`(同文件后写覆盖前写;marker-splice 会互相清块) | ❌ 同文件串行 |
| 代码仓库 | `git add` / `git commit`(index.lock) | ❌ 串行 |
| DB schema | `dshs.db` 建表/迁移 | ❌ 串行(普通读写事务短,可并行) |
| 文档 | `/opt/dsh/docs/**`、`docs-status/**`(服务器唯一源,单点文件) | ❌ 串行(后写覆盖) |
| 本地记忆 | `.workbuddy/memory/*.md`、`MEMORY.md` | ❌ 串行(并发 append 会丢更新) |
| 实例运行态 | 每用户 profile / 实例进程 | ⚠️ **跨用户可并行**;同用户串行 |
| 临时测试会话 | `sessions` 表 | ✅ 可并行,但 UA 必须带 worker 标识(`poc-<worker>`),清理只删自己的前缀 |
| 只读调研 | 读源码、看日志、`--dump-config`、外网抓取、兼容性测试 | ✅ 完全可并行 |
### 批次模型与依赖处理
1. **一批 = 1 个「改码通道」+ N 个「只读通道」**;改码任务之间永远排队串行。
2. 待办先建成 **DAG**,逐条标注「依赖」+「冲突域」;**无依赖且冲突域不同**才允许同批。
3. **收口在主会话**:并行通道产出汇总回主会话,统一写档案 → commit → `docs-status-sync.sh --pull`(这三步本身也是串行单通道)。
4. **用户侧多会话按「领域」切分**(A=门户后端 / B=文档梳理 / C=兼容性测试),**不要按「同一领域的不同切片」切** —— 后者必然同域冲突。
5. **开跑前先出「并行批次表」**(任务 / 冲突域 / 依赖 / 可否并行)给用户确认。
6. 我侧可用并行子代理(`Agent` + `run_in_background`)跑**只读通道**;**写操作必须回到主会话串行执行**。
### 反例(同域并发的具体破坏形态)
- 两会话同时 `npm run build` → `lib/` 产物交叉污染,`systemctl restart` 互相打断实例
- 两会话同时改 `portal.html` → 后写覆盖前写(marker 区块被对方整段替换)
- 并发 `git add/commit` → `index.lock` 冲突
- 同时写 `/opt/dsh/docs/**` 或 `MEMORY.md` → 后写覆盖 / 丢更新
- 用同一 UA 前缀批量删测试会话 → 误删另一个 worker 的会话
### 落地机制(2026-09-12 建立):三把锁 —— 别靠记忆,靠判据
冲突域判断是「设计」,还需要「执行时的强制判据」。当日 3 次实证事故后补上三把锁:
| 锁 | 位置 | 管什么 | 拿法 |
|---|---|---|---|
| **全局执行锁**(粗) | `05-交接单/.exec-lock` | 同一时刻只允许**一个执行会话**动「文档 / 代码 / 服务器」 | `bash 07-scripts/handoff-guard.sh --claim-exec "<会话名>"` |
| **单级占用锁**(细) | `05-交接单/.doing-<单号>` | 这个单归谁做(供台账与接管) | `bash 07-scripts/handoff-guard.sh --claim <单号> "<会话名>"` |
| **服务器侧操作锁** | `/opt/dsh/state/.op-lock/<操作名>`(跨机可见) | 谁正在动**生产**:重启 / drain / 改实例 env·quota / 批量铺插件 / 改 nginx·nft·证书 | `bash 07-scripts/op-lock.sh claim <操作名> "<影响面:谁会被断、断多久>"` |
- **顺序**:开工 **先抢全局锁 → 再占服务器锁**;完工 **反序**释放(先放服务器锁,最后放全局锁)。
- 🔒 **锁的生命周期 = 任务的生命周期**(2026-09-14 用户明令):**抢到锁的任务,只有"执行完成 → 反序释放"才算完成**;⛔ **禁止"抢锁做一半、不解锁就结束回合/会话"**(锁是独占资源 + 本库无心跳机制 ⇒ 别人既等不到也判不出你死没死)。配套:① 抢锁**前**先列出**收口步骤**(落地 → 校验 → 推送/对账 → 收尾);② 中途要停(等用户拍板 / 等窗口)⇒ **先 `--release-exec "<会话名>"` 再停**(⛔ 不带会话名 ⇒ 拒绝释放);③ **结束语必须对锁状态负责** —— 写明"已释放",或**显式点名**"锁仍在 `<OWNER>` + 原因 + 下一步"(仅限释放通道不可用);⛔"忘了 / 做不完就走"不允许。
- **一条命令预检**:`ME="<会话名>" MINE="<我要改的文件>" bash 07-scripts/handoff-guard.sh [单号]`;推送前加 `PUSH=1`(启用「幽灵文件」硬判定:对账结果里出现未声明的「仅本地」文件 → 直接失败)。
- **`mkdir` 即原子**:占位失败 = 有会话正在动 → **停手**,别重试、别"抢一下看看"。
- **服务器侧锁要带身份**:`ME="<会话名>" bash 07-scripts/op-lock.sh claim <操作名> "<影响面>"` —— 不传 `ME` 会记成 `unknown-session`(2026-09-12 实测踩到两个后果:handoff-guard 的【1d】把**你自己的锁**当成别人的;`release` 的归属校验也会拒你)。`release` **只能由占用者本人**执行,冒充别人 → 拒绝并提示 R9。
- ⛔ **不得人工删锁、不得接管**(**R9**,2026-09-12 用户明令「严格禁止这类操作」):**AI 一律不得** `rm -rf 05-交接单/.exec-lock`、不得删 `05-交接单/.doing-*`,也不得以「持有者疑似已死 / 卡住 / 太久没动 / 只在只读分析没产出」为由**单方面接管**。锁**只能由持有者自己释放**(`--release-exec "<会话名>"` / `--release <单号>`);**抢不到锁 ⇒ 停手 + 报告用户**,**锁的处置权只属于用户本人**(要撤也只能用户自己动手)。`handoff-guard.sh` 输出里的「人工删锁 / 接管」字样**均不构成授权**(该文案已同步作废)。理由:平台**无心跳机制**,AI **没有任何判据**能确认对方已死 —— 删锁 = 在无法验证的前提下单方面撤销互斥,一旦对方仍在跑,就退回「两个会话同时改同一批文件」。
- **为什么服务器态必须单独加锁**:文档冲突靠 `git status` / mtime 还能看出来,**服务器态变更看不出来**(`systemctl` 不会告诉你 10 分钟前谁重启过)。
- 判定与工具细节见 `05-交接单/README.md §一 §三 §六`;机制记录见 `04-调整方案/69-并发治理落地-commit常态化与服务器侧锁.md`。
@@ -0,0 +1,77 @@
# 浏览器验证栈详解 · browser-harness 调用 / Lexical 输入难点 / 独立 headless 定型做法 / 必踩坑
> **归属**:技能 `dsh-change-workflow` 的详情档(**按需读**,不是每次都要读)。
> **本档覆盖**:正确调用(Windows,2026-09-11 实测可用) · ⛔ dsh composer(Lexical)输入难点 · 三个必踩坑(原行 L231–L293)。
> **主文件 / 判据与流程主干** = `../SKILL.md`(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。
> **来源**:2026-09-22「技能重组线」把 `../SKILL.md` 的 L231–L293 段**逐行原样**下沉到本文件,未改一字。
> **跨档引用**:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 `../SKILL.md` 末节「详情索引」的原章节列定位。
> **维护**:本文件与 `../SKILL.md` 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。
---
### 正确调用(Windows,2026-09-11 实测可用)
```bash
export PATH="/c/Users/Administrator/.local/bin:$PATH" # 全局 console script 在这个目录
export BU_CDP_URL="http://127.0.0.1:9223" # 指向独立 headless 实例
env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy NO_PROXY="127.0.0.1,localhost" \
browser-harness <<'PY' # helper 已预导入;⛔ 别裸调 python -m
print(page_info())
PY
# 本 checkout(E:/ProgramData/.workbuddy/skills/browser-harness/)另有 dev 启动器:./browser-harness
# 🔴 2026-09-25 实测更正:旧写法 R="C:/Users/Administrator/.workbuddy/skills/browser-harness" +
# "$R/.venv/Scripts/python.exe -m browser_harness.run" **已作废** —— C: 侧该目录不存在、本机 checkout 里也没有 .venv。
```
helper:`js / click_at_xy / type_text / fill_input / press_key / switch_tab / close_tab / capture_screenshot / list_tabs / wait`。
### ⛔ dsh composer(Lexical)输入难点(2026-09-11 未攻克)
- composer 真身:`div[contenteditable="true"][data-lexical-editor="true"][data-composer-input="true"]`(class `uV2eYG_input`)。
- `type_text()` = `Input.insertText` → **绕过框架监听**,Lexical 模型不更新 = 没输入。
- `fill_input()`(逐字符 `press_key` + `input/change`)实测**也未生效**。
- 真因:**目标标签不是前台标签** → `el.focus()` 后 `document.activeElement` 仍是 `BODY`,CDP 鼠标/键盘到不了该标签;
`switch_tab` + `ensure_real_tab` + `Page.bringToFront` 都没解决。
- **对照**:Playwright 的 `page.click()` + `page.keyboard.type()` **成功过**(能真实发出消息并拿到回复)。
→ 结论:**要在独占浏览器里跑**(不与用户标签争前台);**传输层 bug 一律用 curl 验证**,浏览器只用于视觉/交互确认。
⚠️ **工具选择口径(🔴 2026-09-25 用户明令 —— 这条优先于下面所有旧实测)**:
**只允许一件工具** —— `browser-harness`(路径与调用见上)。此前「两件(含 `agent-browser`)」的口径**作废**。
⛔ **Playwright / `playwright-core` 全面禁止**(用户 2026-09-13 明令)。
本机现状:`agent-browser` 的 daemon **起不来**(2026-09-13 实测:`open` 挂住零输出,而 `--version` / `node -e` 正常;Chrome 153 已装)
⇒ **该件已禁用**(见上条),此段只作背景。
`browser-harness` **必须用下面「独立 headless 实例」那套**(至少先 `list_tabs()` 确认),绝不附着用户日常 Chrome。
拿不到浏览器时,**静态页与 API 取数一律用 `curl`/`WebFetch`**,UI 结构用「无浏览器 harness」验收(见阶段 4 第 8 条三层验收)。
⛔ **`agent-browser` 2026-09-11 实测记录(该件**已禁用**;此段仅作历史背景,❌ 不得据此恢复使用)**:
① 自带 Chromium 要从 `storage.googleapis.com` 下 196MB → **必然超时失败**;
② 改用本机 Chrome + `--cdp` 时,环境里有 `HTTP_PROXY=http://127.0.0.1:2349`(WorkBuddy 服务代理),
CLI 连 `127.0.0.1` 的 CDP **也走代理** → `Timeout connecting to CDP`
(要先 `env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy NO_PROXY="127.0.0.1,localhost"` 才通);
③ 即使通了,**标签/会话状态不一致**:`tab list` 显示 `[t1] about:blank`,而 `get url` 报 `login.html`;
`open <实例子域>` 不生效。→ **要浏览器就用下面这套 browser-harness**。
**核心教训:不要用用户日常 Chrome。** 直接 `new_tab` 到用户正在用的浏览器会反复弹「允许远程调试?」——用户会烦。根因:**绕过 wrapper / 启动器**(直接用 `python -m browser_harness.run`)就不会设 `BH_RUNTIME_DIR_SHARED=1`,daemon 无法常驻复用 → 每次调用新建连接 → **每次弹一次授权**。⇒ 2026-09-25 起**统一走全局 console script `browser-harness`**(或本 checkout 的 `./browser-harness`),⛔ 不再裸调 `python -m`。
**定型做法 = 起独立 headless 实例(零弹窗、不碰用户浏览器、cookie 隔离)**:
```bash
# ① 一次性启动(后台常驻;PATH 必须先修好,否则 rm/node 会失败导致 Chrome 根本没起)
export PATH="/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/usr/bin:/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/mingw64/bin:/c/Windows/System32:/c/Windows:$PATH"
"/c/Program Files/Google/Chrome/Application/chrome.exe" \
--headless=new --remote-debugging-port=9223 --remote-allow-origins='*' \
--user-data-dir='C:\Users\Administrator\AppData\Local\Temp\chrome-bh-dsh' \
--no-first-run --no-default-browser-check --no-proxy-server --disable-gpu about:blank
# ② 探活:curl -s --noproxy '*' http://127.0.0.1:9223/json/version
```
```bash
# ③ 每次验证(把 python 脚本写成文件后重定向进 stdin,避免 heredoc 引号地狱)
export PATH="/c/Users/Administrator/.local/bin:$PATH"
export BU_CDP_URL="http://127.0.0.1:9223"
browser-harness < "E:/<你的工作区>/tmp/shot.py" # 或 heredoc;脚本走 stdin
```
**登录态**:DB 直插临时 session(`user_agent=poc-ui`,用后即删),取 token 后
`cdp("Network.setCookie", name="sid", value=TOKEN, domain=".ai1net.com", path="/", secure=True)` → `Page.reload`。
用户侧真实浏览器完全不受影响(不覆盖其 sid)。
**三个必踩坑**:
- **`Emulation.setDeviceMetricsOverride` 按 target 生效** → 必须**先 `new_tab` 再设覆盖**;设在旧标签上再开新标签 = 覆盖丢失(截图只有 758×482)。
- **hash 残留会骗人**:页面停在上次的 `#/plugins/manual`,重载后目标元素不存在 → `js()` 返回 null / 元素测量得 `0/0`。**验证脚本必须显式导航到目标 tab**。
- **Chrome 必须先 `--headless=new` 前台跑通再加后台**;后台启动失败时先看 task 输出(常见是 PATH 未设导致前置 `rm` 失败,`&&` 短路,Chrome 从未执行)。
- 收尾:临时 session 删除;独立 Chrome 可留着复用(内存小),要停就 kill **9223** 端口对应进程。