|
|
@@ -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`(已确认) |
|
|
|
+
|
|
|
+🔴 **两台的上传生产者文件名不同。新容器接入时先确认自己用的是哪个。**
|