Parcourir la source

docs: 新增《容器接入 App 完整操作手册》+ 本次操作追踪留痕

手册基于 lj-test(基准)与 kangshifu(对齐)的真实落地经验整理,
覆盖新容器接入所需的全部配置逻辑:

1. 身份与环境变量(AGENT_ID 用各自的;/opt/data 持久卷 vs /opt/hermes overlay)
2. App 与容器通信契约
   - fmode.kind 只写 process(matrix-client.service.ts:318)
   - ✅/❌ reaction 才是结束信号,不是 typing(:1413)
   - com.fmode.turn_status 可选加速(:1455)
   - s3 直链三条件:https + s3.fmode.cn + /deliverables/(s3-link.ts:290)
   - 6 个云函数清单与用途(parse-api.ts:68)
3. 必需插件清单与文件上云全链路
4. 台账 user 桶隔离的定论(容器登记对 App 无作用,App 有补偿)
5. 状态上报字段与 schema 硬约束
6. finish 守卫:插件 import 期自愈 + 三个必踩的坑
7. GC:lj-test 原始配置 + kangshifu 双机制改进 + 双闸防呆
8. 调度器:无 cron,三种 kind,wrapper 传参
9. 12 条踩坑清单
10. 五阶段接入检查表(每项要求回读证据)

同时记录 2026-11-14 09:21 误删事故:948MB 永久丢失、
根因(危险参数与验证命令混排)、已加的两道闸。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct il y a 17 heures
Parent
commit
5565e2182e

+ 17 - 0
docs/00-tracking/操作追踪.md

@@ -8161,3 +8161,20 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 | **AK/SK 口径** | `~/.obsutilconfig` 里的 43 位 AK 是 OBS 专用的,**不是** KooCLI 那对 20 位。已从 `deploy.ps1` 取回正确的一对,按黄金规则 9 集中于 `scripts/deploy.mjs` 顶部 |
 | **验证** | `npx tsc --noEmit` ✅ / `npm test` **1778 SUCCESS** / `npm run build` ✅ / 部署探活 4 项全过 / 线上 `main-2VGSQKOG.js` 与本地一致 |
 | **未验证** | 真机手感(忙时发送是否确实直接发出、无持有条)—— 需用户在 App 里实测 |
+
+## 2026-11-14(续三)· kangshifu 对齐 lj-test 完成 + 操作手册归档
+
+| 项 | 内容 |
+|---|---|
+| **背景** | 用户要求把 kangshifu 优化到与 lj-test 一致,并把过程沉淀为「新容器如何对齐」的可复用文档 |
+| **第 1 项 finish 守卫** | ✅ 插件 `plugin-gateway-finish-guard` 在 **import 期**执行 `install_guard()`,幂等(`GUARD_TAG` 判据)。实证:真实重启网关后 **pid 变为 99233**、`grep -c FMODE-FINISH-GUARD = 1`、日志「已是 v2,跳过」。判据 `78→125`(不重启)、operator 标记→125、其余含 0→重启 |
+| **第 1 项踩坑** | ① `plugin.yaml` 写 `hooks: []` 会被 loader **直接跳过** ② import 期 `logger.info` 无 handler,日志全丢 ③ `register(ctx)` 必填参数会抛 TypeError |
+| **第 2/3 项 GC** | 按用户裁决改为**双机制**:每天 01:00 `cron 0 1 * * *`(`--min-age 86400 --disk-threshold 0`)+ 每 30 分钟磁盘兜底(`--disk-threshold 80 --force`)。新增清理对象:`/opt/data/work/` 一级子目录(按目录 mtime) |
+| **顺带修复** | `KEEP_PREFIXES` 里 `deliverables` 原为**硬编码绝对路径**,换 `--work-dir` 时保护失效 → 改为按 `work_dir` 动态生成 |
+| **GC 真验证** | `removed=1` 真删 → 文件消失 → `gc:true` 回写 → 幂等 `removed=0`;白名单 `deliverables` 在最极端参数下也未被删 |
+| **🔴 事故** | 2026-11-14 09:21,验证 `--quiet` 语义时**漏 `--dry-run`**,且 `--disk-threshold 1` 触发 emergency(`min_age` 被 cap 到 3600s),**真删 3 个工作目录约 948MB**。S3 无副本,**永久丢失**:`journey-hud`(182.6KB)、`noodle-20261113`(366.7KB)、`ksf-ppt-6`(940MB 旧产物) |
+| **事故根因** | 指令文档中**危险参数与验证命令混排**;容器执行前未拦住(`--disk-threshold 1` 在 4.5% 磁盘上必然进 emergency,可从参数推出) |
+| **事故补救** | 加**双闸防呆**:① 参数层 `threshold < 10%` 且无 `--dry-run`/`--force` → `ap.error` 退出 ② 运行期 `emergency` 真删必须 `--force`,否则 `REFUSED + return 2`(monkeypatch 绕过闸 1 也拦得住,已实测) |
+| **交付文档** | 新建 `docs/03-interface/容器接入App-完整操作手册-20261114.md`:身份/Matrix 契约/插件清单/文件链路/状态上报/finish 守卫/GC/调度器/12 条踩坑/五阶段检查表/两台对照 |
+| **验证** | 手册内所有事实均核对过源码(`parse-api.ts:68`、`matrix-client.service.ts:318/1413/1455`、`s3-link.ts:290`、`jobs.py:772`、`scheduler_script.py:257`),无推测内容 |
+| **未验证** | 「容器**重建**后守卫与调度仍在」—— 两台容器自 11-09 起从未重启过,只有静态依据(东西都在 `/opt/data` 持久卷 + `config.yaml` enabled),无实测 |

+ 542 - 0
docs/03-interface/容器接入App-完整操作手册-20261114.md

@@ -0,0 +1,542 @@
+# 容器接入 App 完整操作手册
+
+> 基于 `agent-node-lj-test`(已跑通的基准)与 `agent-node-kangshifu`(照此对齐的第二台)实际落地经验整理。
+> 目的:任何新容器按此文档配置,即可达到与 App 正常协作的状态。
+>
+> 编写日期:2026-11-14 | 基准容器:`agent-node-lj-test`(`@zgdcmgnqcd:fmode.cn`)
+
+---
+
+## 目录
+
+1. [身份与环境变量](#一身份与环境变量)
+2. [App 与容器的通信契约(最关键)](#二app-与容器的通信契约)
+3. [必需插件清单](#三必需插件清单)
+4. [文件上云与台账](#四文件上云与台账)
+5. [状态上报](#五状态上报)
+6. [网关自愈(finish 守卫)](#六网关自愈finish-守卫)
+7. [磁盘清理(GC)](#七磁盘清理gc)
+8. [调度器使用](#八调度器使用)
+9. [踩坑清单](#九踩坑清单)
+10. [新容器接入检查表](#十新容器接入检查表)
+
+---
+
+## 一、身份与环境变量
+
+### 1.1 容器自身身份
+
+| 变量 | 来源 | 例 |
+|---|---|---|
+| `AGENT_ID` | 平台下发,容器自己的 id | `agent-node-lj-test` |
+| `MATRIX_USER_ID` | 平台下发 | `@zgdcmgnqcd:fmode.cn` |
+
+🔴 **每台容器必须用自己的 id,绝不能抄别的容器的** —— 台账、状态上报、Matrix 房间都按它隔离。
+
+### 1.2 s6 环境目录里实际有什么
+
+`/run/s6/container_environment/` 只有三项:
+
+```
+FMODE_API_BASE_URL
+FMODE_API_KEY
+FMODE_SESSION_TOKEN
+```
+
+🔴 **没有** `FMODE_AGENT_ID`,也**没有** `AGENT_ID`(kangshifu 实测确认)。
+所以 `_resolve_agent_id()` 必须从别处取(`agent_state.json` / 配置文件),不能指望环境变量。
+
+### 1.3 `/opt/data` vs `/opt/hermes`
+
+| 路径 | 性质 | 能否写 |
+|---|---|---|
+| `/opt/data` | **持久卷**(ext4) | ✅ 可写,**所有自定义东西放这里** |
+| `/opt/hermes` | overlay,root 属主 | ❌ `hermes` 用户写不进去 |
+
+🔴 **容器重建后 `/opt/hermes` 会丢,`/opt/data` 保留。**
+任何"要活过重启"的东西(插件、脚本、状态)必须放 `/opt/data`。
+
+---
+
+## 二、App 与容器的通信契约
+
+这是整个接入的**核心**。App 不读容器内部任何东西,全靠 Matrix 事件与云函数。
+
+### 2.1 消息类别声明:`fmode.kind`
+
+App 用它区分「过程」与「正文」(`matrix-client.service.ts:318`):
+
+```ts
+const v = content?.['fmode.kind'];
+return v === 'process' || v === 'final' ? v : undefined;
+```
+
+| 值 | App 行为 |
+|---|---|
+| `'process'` | 整条进**过程区**折叠,不逐行猜 |
+| `'final'` | 整条当**正文**直出 |
+| 缺失 | 回落到文本启发式(有误判风险) |
+
+**写入位置**:`MatrixAdapter._apply_relation_metadata`(每条出站消息必经,且在 `send_message_event` 之前)。
+
+🔴 **只写 `process`,绝不写 `final`**:
+> 出口层无法可靠区分终态,写错的 `final` 会让 App 把工具输出当正文直出。
+
+### 2.2 结束信号:`✅` / `❌` reaction
+
+唯一的「本轮真的干完了」信号(`matrix-client.service.ts:1413`)。
+
+```js
+m.reaction 事件
+  'm.relates_to': { key: '✅', event_id: '<本轮最后一条消息>' }
+```
+
+| 规则 | 原因 |
+|---|---|
+| 在**消息发送之后**打 | 由 `on_processing_complete()` 发,时序天然正确 |
+| **不要用 `m.typing=false`** | 它在正文之前 0.6 秒就发出,会「还没回就说完了」 |
+| `event_id` 指向本轮最后一条消息 | App 据此反推「哪条会话结束了」 |
+| App 侧**校验 sender** | 群聊多容器各打各的 ✅,不看 sender 会串台 |
+
+### 2.3 回合状态推送:`com.fmode.turn_status`(可选,但推荐)
+
+`matrix-client.service.ts:1455` 支持两种形态:
+
+```jsonc
+// A(首选)自定义事件类型
+{ "type": "com.fmode.turn_status", "content": { "sessionKey": "$approot_xxx | !roomId", "status": "idle|running" } }
+
+// B(兜底)m.room.message 里带字段
+{ "msgtype": "m.text", "fmode.turn_status": { ... } }
+```
+
+作用:把「停止转圈」的延迟从 15 秒轮询压到 1~2 秒。
+
+🔴 与 ✅ reaction **完全无关**:✅ 是「单条消息发完」,本事件是「整轮结束」。
+
+### 2.4 App 写入的私有字段(容器需忽略)
+
+| 字段 | 用途 |
+|---|---|
+| `com.fmode.dispatch` | 群聊派活元信息 |
+| `com.fmode.session` | 会话归属 |
+| `com.fmode.local` | 本地乐观 id,用于去重 |
+| `com.fmode.cloud_url` | 云盘地址 |
+
+容器可忽略,**但不要删改**。
+
+### 2.5 App 依赖的云函数
+
+`src/app/core/api/parse-api.ts:68`:
+
+| 变量名 | 函数 id | 用途 |
+|---|---|---|
+| `agentStatus` | `xlosi3m46q` | 容器状态上报/读取 |
+| `deliverables` | `lfYlgU7SkK` | 老成果表(字段白名单写入) |
+| `provision` | `RP5Ht55NeX` | 建号、建房、写绑定表 |
+| `uploadUrl` | `AlP56LCKFm` | OBS 签名直传 |
+| `chatThreads` | `oR5O67i1Cm` | 会话目录 |
+| **`fileHub`** | **`THXAOGKiPK`** | **文件台账(按会话隔离)** |
+
+---
+
+## 三、必需插件清单
+
+### 3.1 文件上云与注入
+
+| 插件/脚本 | 作用 |
+|---|---|
+| `plugin-deliverable-inject` | 回复正文里注入云端链接、剥离本地路径 |
+| `fmode-app-deliver.py` | **真正的上传生产者**:上传 → 回读 md5 → 写 state |
+| `plugin-gateway-finish-guard` | 网关自愈(见第六节) |
+
+🔴 **不存在 `fmode-deliverable-hook.py`** —— kangshifu 实测确认,这个文件名在多数容器上没有。
+
+### 3.2 出站媒体
+
+| 插件/脚本 | 作用 |
+|---|---|
+| `fmode_outbound_media.py` | 把 `MEDIA:/路径` 变成 `m.file` 事件 |
+
+🔴 **必须用 s3 直链,不要走 `mxc://` 上传**(用户 2026-11-14 裁决):
+- 直链格式:`https://s3.fmode.cn/deliverables/<agentId>/<年月>/<hash>/<n>-<name>`
+- `fmode_outbound_media.py:218` 的 `os.unlink` 删的是**上传临时副本 tmp**,不是源文件 —— 那是正确行为
+
+### 3.3 App 侧对链接的硬性要求
+
+`src/app/core/s3-link.ts:290`:
+
+```ts
+u.protocol === 'https:' &&
+SENDABLE_HOSTS.includes(u.host.toLowerCase()) &&   // ['s3.fmode.cn']
+u.pathname.startsWith(AGENT_PREFIX)                // '/deliverables/'
+```
+
+**不满足这三条,App 不会把它当成果。**
+
+---
+
+## 四、文件上云与台账
+
+### 4.1 完整链路(lj-test 实测格式)
+
+```
+① 容器写文件到 /opt/data/work/...
+② 上传 OBS → 得到 https://s3.fmode.cn/deliverables/...
+③ 回读 md5 校验一致
+④ 写 state 文件(/opt/data/cache/deliverable-hook/<sid>.json)
+   - queued[]  → 待上传
+   - pushed[]  → 已上传 { url, name, ts, path }
+⑤ 回复正文里出现该 url
+⑥ App 侧 agent-file-binding.service.ts 看到 sender='agent' 的 s3 直链
+   → 用**用户自己的 token** 补登记进台账(uploadedBy:'agent', roomId)
+⑦ 右栏「成果」从台账读到
+```
+
+### 4.2 🔴 台账 user 桶隔离(重要,别再排查这个)
+
+云函数 `THXAOGKiPK` 按 token 解析出的 `user` **硬过滤**:
+
+- 容器用**容器 token** 登记 → 落进容器自己的 user 桶
+- App 用**用户 token** 查 → 查不到容器那条
+- **但 App 有自己的补偿链路**(`agent-file-binding.service.ts:142`,用用户 token + roomId 补登记)
+
+**结论:容器侧登记对 App 无实际作用,不需要修。**
+详见 `docs/03-interface/台账归属定论-20261114.md`。
+
+### 4.3 state 文件是 GC 的唯一数据源
+
+`pushed[]` 必须带 `path`(绝对路径),否则 GC 无法定位源文件。
+
+**历史遗留**:2026-11-14 之前写入的条目只有 `url/name/ts`,没有 `path`,GC 会跳过并计入 `skipped_no_path`。
+
+---
+
+## 五、状态上报
+
+App 通过 `xlosi3m46q` 读取容器状态,**15 秒轮询一次**。
+
+### 5.1 关键字段
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `heartbeatAt` | ISO 时间 | 心跳时间;**超过 80 分钟判离线** |
+| `host` / `usage` / `sessionStatus` | **字符串或 null** | ⚠️ 传对象会 500 |
+| `channels.matrix` | bool | Matrix 通道是否就绪 |
+| `currentSessions[]` | 数组 | **会话级**运行态,见下 |
+
+🔴 **schema 极严格**:`host`/`usage`/`sessionStatus` 只接受 string 或 null,传 dict → 500,传未知字段 → 400。
+
+### 5.2 `currentSessions` 是「一房间一会话」的关键
+
+```jsonc
+{
+  "sessionKey": "!roomId",           // 或 "agent:main:matrix:dm:!roomId"
+  "status": "running" | "idle",
+  "turnStartAt": 1731600000000,
+  "updatedAt": 1731600060000,
+  "userId": "@user:fmode.cn"         // 多人共用容器时必需
+}
+```
+
+| App 侧规则 | 位置 |
+|---|---|
+| `running` 超过 10 分钟视为过期 | `agent-state.ts:453` `SESSION_RUNNING_STALE_MS` |
+| `idle` 记录早于本地发出时刻 → 不采信 | `agent-state.ts:479` |
+| 有 `userId` 时按用户收窄 | `agent-state.ts:385` |
+
+🔴 容器只在 **turn 首尾** 写 `currentSessions`,中间不刷新。
+
+---
+
+## 六、网关自愈(finish 守卫)
+
+### 6.1 问题
+
+s6 的 `finish` 脚本按退出码决定是否重启。**上游 `service_manager.py:476` 的 `_render_finish_script()` 会把 `exit 0` 映射成 `125`**,而同一文件 636/657 行会 `shutil.rmtree(svc_dir)` 重建服务目录。
+
+事故实例(lj-test,2026-11-13):
+```
+网关收到 SIGTERM → drain 超时 → 退出码 0
+→ finish 判为「干净停止」→ 返回 125 → 永不自启
+→ 容器 running 但网关死了 12.5 分钟
+```
+
+### 6.2 lj-test 的解法:插件 import 期自愈
+
+**持久化位置**(不是 `/opt/data/finish-guard/`,那个路径是错的):
+```
+/opt/data/plugins/plugin-gateway-finish-guard/__init__.py
+/opt/data/patches/fmode-gateway-finish-guard.sh
+```
+
+**机制**(`__init__.py:129-141`):
+
+```python
+def register(ctx=None):
+    install_guard()
+    return None
+
+try:
+    _installed = install_guard()          # import 期立即执行
+    if _installed:
+        log.info("[finish-guard] 启动期自愈完成")
+except Exception as _exc:
+    log.warning("[finish-guard] 启动期自愈异常(已忽略): %s", _exc)
+```
+
+`install_guard()` 是幂等的:检测到 `GUARD_TAG` 就跳过,否则先备份上游版本再重写 + `chmod 755`。
+
+**为什么这样能活过重启**:`/opt/data` 是持久卷 → 插件不丢 → Hermes 启动必 import → 每次启动都抢回来。
+
+### 6.3 判据
+
+```sh
+78 → exit 125    # EX_CONFIG,配置错误,重启也没用
+operator 标记存在 → exit 125    # 人为停机
+其余(含 0 / 1 / 137)→ exit 0  # 一律重启
+```
+
+### 6.4 🔴 三个必须踩过的坑
+
+| 坑 | 后果 | 解法 |
+|---|---|---|
+| `plugin.yaml` 里 `hooks: []` | **loader 直接跳过,`register()` 根本不执行** | 至少声明一个真实 hook(如 `post_llm_call`) |
+| import 期直接用 `logger.info` | logging 还没配 handler,**日志全丢** | 同时写文件日志 |
+| `register(ctx)` 参数必填 | loader 无参调用时抛 `TypeError` | 用 `register(ctx=None)` |
+
+### 6.5 验证
+
+```bash
+# s6 命令不在 PATH,必须用绝对路径
+/package/admin/s6/command/s6-svstat /run/service/gateway-default
+/package/admin/s6/command/s6-svc -r /run/service/gateway-default
+
+sleep 5
+grep -c FMODE-FINISH-GUARD /run/service/gateway-default/finish   # 必须 = 1
+ps -eo pid,lstart,cmd | grep "gateway run" | grep -v grep        # pid 必须变
+```
+
+🔴 **只看 `s6-svstat` 报 `up` 不够** —— 要确认 pid 真的变了,才算重启过。
+
+---
+
+## 七、磁盘清理(GC)
+
+### 7.1 lj-test 的原始配置
+
+| 项 | 值 |
+|---|---|
+| 脚本 | `/opt/data/scripts/fmode-media-gc.py` |
+| wrapper | `/opt/data/scripts/fmode-media-gc-wrapper.sh` |
+| 调度 | Hermes 调度器,`interval` 30 分钟 |
+| `--min-age` | **3600 秒(1 小时)** |
+| 清理对象 | `pushed[]` 登记的源文件 |
+
+实测(18 次连续执行,00:14 → 09:00):
+- 相邻间隔 30.0~31.1 分钟,**0 次断档**
+- 18 次中只有 7 次真的删了东西,共 25 个文件,**平均 3.57 个/次**
+- 单次耗时 0.3~0.5 秒
+
+### 7.2 核心安全设计(不可省)
+
+```python
+KEEP_PREFIXES = (
+    "/opt/data/work/deliverables",   # ⚠️ 必须按 work_dir 动态生成,不能硬编码
+    "/opt/data/skills",
+    "/opt/data/plugins",
+    "/opt/data/scripts",
+    "/opt/data/.fmode",
+    "/opt/data/.fmode-media-patch",
+)
+```
+
+| 规则 | 原因 |
+|---|---|
+| **只删 `pushed[]` 登记过的 path** | 不做全盘 `find`,绝不误删 |
+| 删除后回写 `gc=True / gc_ts / gc_reason` | 可追溯 + 幂等 |
+| 删不掉记 `kept_detail`,不中断 | 单个失败不影响整体 |
+| `--quiet` 时只在 `removed>0 or errors>0` 写日志 | 减少噪音 |
+
+### 7.3 kangshifu 的改进版(推荐新容器采用)
+
+用户 2026-11-14 裁决,改为**双机制**:
+
+| 机制 | 调度 | 参数 |
+|---|---|---|
+| **定时清理** | `cron 0 1 * * *`(每天凌晨 1 点) | `--min-age 86400 --disk-threshold 0` |
+| **磁盘兜底** | `interval` 30 分钟 | `--min-age 86400 --disk-threshold 80 --force` |
+
+**逻辑**:
+```python
+if 磁盘使用率 >= disk_threshold:
+    effective_min_age = min(min_age, 3600)   # 紧急:1 小时没动的就删
+    mode = "emergency"
+else:
+    effective_min_age = min_age               # 正常:24 小时
+    mode = "scheduled"
+```
+
+**新增清理对象**:`/opt/data/work/` 下的**一级子目录**,按目录 mtime 判定。
+
+### 7.4 🔴 双闸防呆(血泪教训)
+
+2026-11-14 事故:验证时漏了 `--dry-run`,`--disk-threshold 1` 触发 emergency,
+**真删 3 个工作目录,948MB,S3 无副本,永久丢失**。
+
+已加两道闸:
+
+```python
+DANGEROUS_THRESHOLD = 10.0
+
+# 闸 1:参数层
+if 0 < t < DANGEROUS_THRESHOLD and not args.dry_run and not args.force:
+    ap.error("--disk-threshold %.1f 过低,强制 EMERGENCY 模式..." % t)
+
+# 闸 2:运行期(改代码也绕不过)
+if mode == "emergency" and not args.dry_run and not args.force:
+    print("REFUSED: emergency mode requires --force", file=sys.stderr)
+    return 2
+```
+
+**新容器务必照抄这两道闸。**
+
+---
+
+## 八、调度器使用
+
+### 8.1 🔴 没有系统 cron
+
+```
+$ crontab -l        → command not found
+$ ls /etc/cron.d/   → No such file or directory
+```
+
+**周期性任务全部走 Hermes 内置调度器**:
+```
+/opt/data/cron/jobs.json      ← 持久卷
+/opt/data/cron/executions.db  ← 执行历史
+```
+
+### 8.2 支持的 schedule 类型(源码 `jobs.py:772`)
+
+只有三种:
+
+| kind | 字段 | 例 |
+|---|---|---|
+| `interval` | `minutes` | `{"kind":"interval","minutes":30,"display":"every 30m"}` |
+| `cron` | `expr` | `{"kind":"cron","expr":"0 1 * * *","display":"0 1 * * *"}` |
+| `once` | `run_at` | 一次性 |
+
+🔴 **没有 `event` 类型**。所谓「按量触发」只能由脚本自己判断,调度器不支持。
+
+### 8.3 脚本解析规则(`scheduler_script.py:257-320`)
+
+- `scripts_dir = HERMES_HOME / "scripts"` = `/opt/data/scripts/`
+- `script` 字段**只写文件名**即可
+- **绝对路径出界会被拒绝**:`Blocked: outside the scripts directory`
+- 解释器按后缀:`.sh/.bash` → bash,其余 → `sys.executable`
+
+### 8.4 带参数的作业必须用 wrapper
+
+`script` 字段**没有 argv**。要传参数就写一个 `.sh`:
+
+```bash
+#!/bin/bash
+exec python3 /opt/data/scripts/fmode-media-gc.py --quiet --min-age 86400 --disk-threshold 80 --force
+```
+
+### 8.5 完整作业示例
+
+```json
+{
+  "name": "fmode-workdir-gc-daily",
+  "script": "fmode-workdir-gc-daily.sh",
+  "no_agent": true,
+  "schedule": { "kind": "cron", "expr": "0 1 * * *", "display": "0 1 * * *" },
+  "repeat": { "times": null, "completed": 0 },
+  "enabled": true,
+  "state": "scheduled",
+  "deliver": "local"
+}
+```
+
+🔴 `id` 由 scheduler 生成(用 `cronjob_manage`),**不要手编**。
+
+---
+
+## 九、踩坑清单
+
+| # | 坑 | 表现 | 解法 |
+|---|---|---|---|
+| 1 | **真类 vs 影子类** | patch 打在空气里 | `MatrixAdapter` 必须从 `platform_registry.adapter_factory()` 取,不能 `from plugins.platforms.matrix.adapter import` |
+| 2 | **包装层放行** | wrapper 校验通过但上游仍拒绝 | 只包一层不够,要改**真正的实现** |
+| 3 | **`hooks: []` 插件被跳过** | `register()` 不执行 | 声明至少一个真实 hook |
+| 4 | **import 期 logger 无 handler** | 日志全丢,无法排障 | 同时写文件 |
+| 5 | **`/run/service` 是 tmpfs** | 重启后自定义脚本消失 | 持久化到 `/opt/data` + 插件 import 期装回 |
+| 6 | **`s6-svc` 不在 PATH** | `command not found` | 用 `/package/admin/s6/command/` 绝对路径 |
+| 7 | **`exit 0` 被判成正常停止** | 网关挂了不自动重启 | finish 守卫(第六节) |
+| 8 | **台账按 user 硬过滤** | 容器查得到、App 查不到 | 正常现象,App 有补偿登记 |
+| 9 | **`KEEP_PREFIXES` 硬编码** | 换 `--work-dir` 后白名单失效 | 按 `work_dir` 动态生成 |
+| 10 | **危险参数无防呆** | 误删数据 | 双闸(7.4) |
+| 11 | **`obsutil sync` 无 `-f` 参数** | `flag provided but not defined` | 删掉(这是 App 部署侧) |
+| 12 | **`hcloud` 是 Hetzner CLI** | 华为云命令报 `unknown flag` | 用完整路径指定 KooCLI |
+
+---
+
+## 十、新容器接入检查表
+
+按顺序逐项确认,**每项都要有回读证据,不许「应该可以」**。
+
+### 阶段一:身份与基础
+
+- [ ] `AGENT_ID` / `MATRIX_USER_ID` 是**这台自己的**,没抄别人的
+- [ ] `/opt/data` 可写,`/opt/hermes` 不需要写
+- [ ] 确认 `_resolve_agent_id()` 不依赖环境变量(s6 env 里没有)
+
+### 阶段二:Matrix 契约
+
+- [ ] 能收发 `m.text`
+- [ ] 每轮结束打 `✅` reaction,`event_id` 指向本轮最后一条消息
+- [ ] **没在用 `m.typing=false` 当结束信号**
+- [ ] `fmode.kind` 只写 `process`,且清掉上游残留的 `final`
+- [ ] `fmode.kind` patch 打在**真类**上(`platform_registry`)
+
+### 阶段三:文件链路
+
+- [ ] 出站用 **s3 直链**(`https://s3.fmode.cn/deliverables/...`),不是 `mxc://`
+- [ ] App 能把它认成成果(三条件:https + `s3.fmode.cn` + `/deliverables/`)
+- [ ] 入站能接 s3 直链并缓存
+- [ ] `pushed[]` 带 `path` 字段
+
+### 阶段四:状态上报
+
+- [ ] `heartbeatAt` 在更新
+- [ ] `host`/`usage`/`sessionStatus` 是 **string 或 null**(不是对象)
+- [ ] `currentSessions[]` 按 sessionKey 写,带 `userId`
+- [ ] turn 结束时把对应 session 改成 `idle`
+
+### 阶段五:稳定性
+
+- [ ] `plugin-gateway-finish-guard` 已装,`config.yaml` 里 enabled
+- [ ] `grep -c GUARD_TAG /run/service/gateway-default/finish` = 1
+- [ ] 真实重启过网关,**pid 变了**,守卫仍在
+- [ ] GC 脚本已装,白名单 6 项齐全
+- [ ] GC 双闸防呆生效(危险参数无 `--dry-run` 会拒绝)
+- [ ] 调度作业已注册,`last_status=ok`
+
+---
+
+## 附:两台容器对照
+
+| 项 | lj-test | kangshifu |
+|---|---|---|
+| `AGENT_ID` | `agent-node-lj-test` | `agent-node-kangshifu` |
+| Matrix user | `@zgdcmgnqcd:fmode.cn` | `@edzb0hg93a:fmode.cn` |
+| `fmode.kind` | ✅ | ✅ |
+| s3 直链 | ✅ | ✅ |
+| finish 守卫 | ✅ 插件自愈 | ✅ 照抄 |
+| GC 频率 | 30 分钟,1h TTL | **每天 1 点 + 80% 兜底** |
+| 上传生产者 | `fmode-deliverable-hook.py`(不确定) | `fmode-app-deliver.py`(已确认) |
+
+🔴 **两台的上传生产者文件名不同。新容器接入时先确认自己用的是哪个。**