|
@@ -0,0 +1,791 @@
|
|
|
|
|
+# 容器取证问答:agent-node-lj-test
|
|
|
|
|
+
|
|
|
|
|
+- 时间:2026-11-14T00:08:09.183Z
|
|
|
|
|
+- 房间:`!cHe84pQ1DVG_ZXNlmkLOnaUg3YKRWvrkX6GJLafW3F0`(临时房间,可保留查阅)
|
|
|
|
|
+- 消息统计:正文 6 段 / 工具过程 5 段 / 文件 0 个
|
|
|
|
|
+- 结束原因:收到 ✅ reaction
|
|
|
|
|
+
|
|
|
|
|
+## 我发出的问题
|
|
|
|
|
+
|
|
|
|
|
+```text
|
|
|
|
|
+## 请说明 5-7 三项的实现细节(我要用来对照另一台容器)
|
|
|
|
|
+
|
|
|
|
|
+**只读,贴原始代码和配置,不要总结。**
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+# 5. 延迟清理 + GC
|
|
|
|
|
+
|
|
|
|
|
+### 5.1 上传成功后不删源文件
|
|
|
|
|
+```bash
|
|
|
|
|
+grep -n "os.remove\|CLEANUP_LOCAL\|DEFER-CLEANUP\|delayed\|delay" /opt/data/scripts/fmode-deliverable-hook.py | head -20
|
|
|
|
|
+```
|
|
|
|
|
+请贴:
|
|
|
|
|
+- 上传成功后对源文件做了什么(删/留)
|
|
|
|
|
+- 如果留,是怎么标记的(state 字段?日志?)
|
|
|
|
|
+
|
|
|
|
|
+### 5.2 GC 脚本
|
|
|
|
|
+```bash
|
|
|
|
|
+ls -la /opt/data/scripts/ | grep -iE "gc|cleanup"
|
|
|
|
|
+cat /opt/data/scripts/fmode-media-gc.py 2>/dev/null | head -60
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 5.3 cron 配置
|
|
|
|
|
+```bash
|
|
|
|
|
+cat /opt/data/cron/jobs.json 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); [print(j.get('name'), '|', j.get('enabled'), '|', j.get('schedule') or j.get('intervalMs') or j.get('cron')) for j in (d.get('jobs') or d)]" 2>/dev/null || cat /opt/data/cron/jobs.json | head -40
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+# 6. `fmode.kind='process'`
|
|
|
|
|
+
|
|
|
|
|
+### 6.1 发送脚本
|
|
|
|
|
+```bash
|
|
|
|
|
+ls -la /opt/data/scripts/ | grep -i "process"
|
|
|
|
|
+cat /opt/data/scripts/fmode-process-notice.py 2>/dev/null | head -50
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 6.2 谁调用它
|
|
|
|
|
+```bash
|
|
|
|
|
+grep -n "fmode-process-notice\|process-notice" /opt/data/scripts/*.py /opt/data/config.yaml 2>/dev/null | head -10
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 6.3 插件层面
|
|
|
|
|
+```bash
|
|
|
|
|
+ls -la /opt/data/plugins/ | grep -i "process"
|
|
|
|
|
+cat /opt/data/plugins/plugin-matrix-process-notice/plugin.yaml 2>/dev/null
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**请明确回答**:`fmode.kind='process'` 是**脚本发**的还是**插件 patch 发的**?两者都在吗?
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+# 7. 网关防复发
|
|
|
|
|
+
|
|
|
|
|
+### 7.1 当前的 finish 脚本
|
|
|
|
|
+```bash
|
|
|
|
|
+cat /run/service/gateway-default/finish
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 7.2 持久化机制
|
|
|
|
|
+```bash
|
|
|
|
|
+ls -la /opt/data/patches/ | grep -i finish
|
|
|
|
|
+cat /opt/data/patches/fmode-gateway-finish-guard.sh 2>/dev/null | head -40
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 7.3 插件
|
|
|
|
|
+```bash
|
|
|
|
|
+ls -la /opt/data/plugins/ | grep -i "finish\|gateway"
|
|
|
|
|
+cat /opt/data/plugins/plugin-gateway-finish-guard/plugin.yaml 2>/dev/null
|
|
|
|
|
+grep -n "def register\|finish" /opt/data/plugins/plugin-gateway-finish-guard/__init__.py 2>/dev/null | head -15
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**请明确回答**:finish 被改写的**触发点**是什么?插件 import 期?还是有别的机制?
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 另外,请给一份「最小可复制清单」
|
|
|
|
|
+
|
|
|
|
|
+对 5-7 每一项,告诉我:
|
|
|
|
|
+1. **必须的最少文件**(哪些文件是必不可少的)
|
|
|
|
|
+2. **必须的配置**(config.yaml / cron 里要加什么)
|
|
|
|
|
+3. **一句话原理**(为什么这么做)
|
|
|
|
|
+
|
|
|
|
|
+我要把这些原样转给另一台容器,让它快速对齐。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 纪律
|
|
|
|
|
+
|
|
|
|
|
+- 只读,贴原始内容
|
|
|
|
|
+- 找不到的就说没有
|
|
|
|
|
+- 不要推断
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 容器回答(原文,按时间顺序,未删改)
|
|
|
|
|
+
|
|
|
|
|
+### 1【prose】
|
|
|
|
|
+
|
|
|
|
|
+```text
|
|
|
|
|
+我先把第 5~7 项的真实文件内容全部读出来,再按你的格式逐条贴原始输出。
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 2【prose】
|
|
|
|
|
+
|
|
|
|
|
+```text
|
|
|
|
|
+execute_code · from hermes_tools import terminal cmds = [ ("5.1_grep_hook",…
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 3【prose】
|
|
|
|
|
+
|
|
|
|
|
+```text
|
|
|
|
|
+我把缺的几块继续读全(hook 的 291 行附近、GC 后半段、cron、process-notice 脚本与插件、finish-guard 持久化脚本和插件)。
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 4【prose】
|
|
|
|
|
+
|
|
|
|
|
+```text
|
|
|
|
|
+第 7 部分还缺持久化脚本和插件主体,我一次性读全。
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 5【prose】
|
|
|
|
|
+
|
|
|
|
|
+```text
|
|
|
|
|
+**收到,5~7 三项已全部只读回读完成,以下是原始输出,末尾附最小可复制清单。**
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+# 5. 延迟清理 + GC
|
|
|
|
|
+
|
|
|
|
|
+## 5.1 上传成功后不删源文件
|
|
|
|
|
+
|
|
|
|
|
+`grep -n "os.remove\|CLEANUP_LOCAL\|DEFER-CLEANUP\|delayed\|delay" /opt/data/scripts/fmode-deliverable-hook.py` 原始输出:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+77:CLEANUP_LOCAL = True
|
|
|
|
|
+753: log("DEFER-CLEANUP keep local %s(交给 fmode-media-gc.py,1h 后清理)" % p)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**注意**:本文件里 **没有 `os.remove` 调用**(grep 零命中)。`CLEANUP_LOCAL = True` 只是一个常量,实际分支并不用它删文件。
|
|
|
|
|
+
|
|
|
|
|
+hook 第 747~754 行原文:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ # C-2 清理:**延迟清理**。
|
|
|
|
|
+ # 已上云且回读 md5 一致 → 不立即删;源文件必须活到「发送时刻」,
|
|
|
|
|
+ # 否则回复里的 MEDIA: /media-to-send 路径在 adapter 发送前就已消失。
|
|
|
|
|
+ # 真正的删除交给 /opt/data/scripts/fmode-media-gc.py(cron 每 30 分钟跑一次,
|
|
|
|
|
+ # 只清 pushed 登记里 mtime 超过 1 小时的源文件)。
|
|
|
|
|
+ if same:
|
|
|
|
|
+ log("DEFER-CLEANUP keep local %s(交给 fmode-media-gc.py,1h 后清理)" % p)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**它做了哪些操作**:什么都不做(仅写一行日志 `DEFER-CLEANUP keep local ...`)。文件留着。
|
|
|
|
|
+
|
|
|
|
|
+**怎么标记的**:靠 **state JSON 的 `pushed` 数组字段**,不是日志。第 903~908 行原文:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ st.setdefault("pushed", []).append({
|
|
|
|
|
+ "path": ..., "url": ..., "did": ...,
|
|
|
|
|
+ "src_mtime": int(_mtime_or_0(r["path"])), "gc": False,
|
|
|
|
|
+ })
|
|
|
|
|
+ save_state(sid, st)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+状态文件位置(第 30、262~264 行):
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+CACHE_DIR = HOME / "cache" / "deliverable-hook"
|
|
|
|
|
+def state_path(session_id: str) -> Path:
|
|
|
|
|
+ return CACHE_DIR / ("%s.json" % sid)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+状态结构注释(第 14 行):
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+{"queued": [{path, tool, ts, title?, reason?}], "pushed": [{path,url,did,ts}], "seen": [...]}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+GC 实际依赖的字段是 `path / url / src_mtime / gc / gc_ts / gc_reason`。
|
|
|
|
|
+
|
|
|
|
|
+## 5.2 GC 脚本
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+-rwxr-xr-x 1 hermes hermes 189 Nov 13 23:44 fmode-media-gc-wrapper.sh
|
|
|
|
|
+-rwxr-xr-x 1 hermes hermes 7592 Nov 13 23:44 fmode-media-gc.py
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`fmode-media-gc.py` 头部原文(1~60 行):
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+"""fmode-media-gc.py — 延迟清理已上云源文件(配合 fmode-deliverable-hook.py 的延迟清理策略)。
|
|
|
|
|
+
|
|
|
|
|
+背景
|
|
|
|
|
+----
|
|
|
|
|
+hook 上传成功并回读 md5 一致后**不再立即删除**源文件:回复里的 行
|
|
|
|
|
+和 adapter 的 media-to-send 都要在「发送时刻」还有文件可读。删早了会得到
|
|
|
|
|
+「文件不存在」的空附件或多发一条本地兜底。
|
|
|
|
|
+
|
|
|
|
|
+这个脚本负责真正的删除:扫描 hook 的状态目录(CACHE_DIR/*.json,字段
|
|
|
|
|
+`pushed: [{path,url,did,ts,src_mtime,gc}]`),对满足全部条件的源文件才删:
|
|
|
|
|
+
|
|
|
|
|
+ 1. 已登记为 pushed 且带 url(= 确实上云了,本地副本不再唯一)
|
|
|
|
|
+ 2. 源文件 mtime 距今 >= --min-age(默认 3600s = 1 小时)
|
|
|
|
|
+ 3. 路径不在排除目录里(/opt/data/work/deliverables、/opt/data/skills 等永久保留)
|
|
|
|
|
+
|
|
|
|
|
+删完在状态里把该条 gc 置 True 并记 gc_ts,重复运行不会重复计数。
|
|
|
|
|
+只删登记在案的路径 —— 不做全盘 find,避免误伤。
|
|
|
|
|
+
|
|
|
|
|
+用法
|
|
|
|
|
+----
|
|
|
|
|
+ python3 fmode-media-gc.py # 真删
|
|
|
|
|
+ python3 fmode-media-gc.py --dry-run # 只报不动
|
|
|
|
|
+ python3 fmode-media-gc.py --min-age 120 # 改成 2 分钟(测试用)
|
|
|
|
|
+ python3 fmode-media-gc.py --json # 输出机器可读汇总
|
|
|
|
|
+"""
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import argparse
|
|
|
|
|
+import json
|
|
|
|
|
+import os
|
|
|
|
|
+import sys
|
|
|
|
|
+import time
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+
|
|
|
|
|
+HOME = Path("/opt/data")
|
|
|
|
|
+CACHE_DIR = HOME / "cache" / "deliverable-hook"
|
|
|
|
|
+LOG = HOME / "logs" / "media-gc.log"
|
|
|
|
|
+
|
|
|
|
|
+# 永久保留(交付目录、技能、工作区源码),GC 绝不碰
|
|
|
|
|
+KEEP_PREFIXES = (
|
|
|
|
|
+ "/opt/data/work/deliverables",
|
|
|
|
|
+ "/opt/data/skills",
|
|
|
|
|
+ "/opt/data/plugins",
|
|
|
|
|
+ "/opt/data/scripts",
|
|
|
|
|
+ "/opt/data/.fmode",
|
|
|
|
|
+ "/opt/data/.fmode-media-patch",
|
|
|
|
|
+)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+核心删除逻辑(`gc_state` 原文节选):
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ if entry.get("gc") is True:
|
|
|
|
|
+ continue
|
|
|
|
|
+ out["scanned"] += 1
|
|
|
|
|
+
|
|
|
|
|
+ url = entry.get("url") or ""
|
|
|
|
|
+ if not url:
|
|
|
|
|
+ out["kept"] += 1
|
|
|
|
|
+ out["kept_detail"].append({"path": path, "why": "no-url(未确认上云)"})
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ why = keep_by_policy(path)
|
|
|
|
|
+ if why:
|
|
|
|
|
+ out["kept"] += 1
|
|
|
|
|
+ out["kept_detail"].append({"path": path, "why": why})
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ if not os.path.exists(path):
|
|
|
|
|
+ if not dry_run:
|
|
|
|
|
+ entry["gc"] = True
|
|
|
|
|
+ entry["gc_ts"] = int(now)
|
|
|
|
|
+ entry["gc_reason"] = "already-gone"
|
|
|
|
|
+ changed = True
|
|
|
|
|
+ continue
|
|
|
|
|
+
|
|
|
|
|
+ mt = stat_mtime(path)
|
|
|
|
|
+ # 取 mtime 与「上云时刻」中较晚者,防止 clock 抖动把新文件当老文件
|
|
|
|
|
+ basis = max(mt, float(entry.get("src_mtime") or 0))
|
|
|
|
|
+ age = now - basis if basis else now - float(entry.get("ts") or now)
|
|
|
|
|
+ if age < min_age:
|
|
|
|
|
+ out["kept"] += 1
|
|
|
|
|
+ out["kept_detail"].append({"path": path, "why": "age=%.0fs<%.0fs" % (age, min_age)})
|
|
|
|
|
+ continue
|
|
|
|
|
+ ...
|
|
|
|
|
+ try:
|
|
|
|
|
+ os.remove(path)
|
|
|
|
|
+ except OSError as e:
|
|
|
|
|
+ ...
|
|
|
|
|
+ entry["gc"] = True
|
|
|
|
|
+ entry["gc_ts"] = int(now)
|
|
|
|
|
+ entry["gc_reason"] = "delayed-cleanup"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**唯一的 `os.remove` 在这个 GC 脚本第 ~134 行,不在 hook 里。**
|
|
|
|
|
+
|
|
|
|
|
+参数默认值原文:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ ap.add_argument("--min-age", type=float, default=3600.0,
|
|
|
|
|
+ help="源文件至少存活多少秒才清理(默认 3600)")
|
|
|
|
|
+ ap.add_argument("--dry-run", action="store_true", help="只报告不删除")
|
|
|
|
|
+ ap.add_argument("--json", action="store_true", help="输出 JSON 汇总")
|
|
|
|
|
+ ap.add_argument("--quiet", action="store_true",
|
|
|
|
|
+ help="无删除时完全不输出(cron watchdog 模式用;有删除才说话)")
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+wrapper 原文(全文,共 3 行有效):
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+#!/bin/bash
|
|
|
|
|
+# cron 包装:安静模式。无删除时不输出(watchdog 语义),有删除/出错才说话。
|
|
|
|
|
+exec python3 /opt/data/scripts/fmode-media-gc.py --quiet --min-age 3600
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 5.3 cron 配置
|
|
|
|
|
+
|
|
|
|
|
+`/opt/data/cron/jobs.json` 里 4 个 job,逐条打印(name | enabled | script | schedule | state | last_status):
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+bigfile-notice | False | bigfile-notice.sh | {"kind": "interval", "minutes": 1, "display": "every 1m"} | completed | ok
|
|
|
|
|
+fmode-status-reporter | False | fmode-status-report.sh | {"kind": "cron", "expr": "*/2 * * * *", "display": "*/2 * * * *"} | paused | ok
|
|
|
|
|
+fmode-artifact-drain | True | fmode-artifact-drain.sh | {"kind": "cron", "expr": "*/3 * * * *", "display": "*/3 * * * *"} | scheduled | ok
|
|
|
|
|
+media-gc-delayed-cleanup | True | fmode-media-gc-wrapper.sh | {"kind": "interval", "minutes": 30, "display": "every 30m"} | scheduled | ok
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**GC 对应的 job**:`media-gc-delayed-cleanup`,enabled=True,interval 30 分钟,script=`fmode-media-gc-wrapper.sh`。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+# 6. `fmode.kind='process'`
|
|
|
|
|
+
|
|
|
|
|
+## 6.1 发送脚本
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+-rw-r--r-- 1 hermes hermes 6837 Nov 13 21:42 fmode-process-notice.py
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+注意权限是 **644**(不是 755),靠 `python3 <path>` 调用,所以能跑。
|
|
|
|
|
+
|
|
|
|
|
+头部原文(1~26 行):
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+"""fmode-process-notice.py — post_tool_call 时往 Matrix 发一条「过程消息」
|
|
|
|
|
+
|
|
|
|
|
+被 /opt/data/scripts/fmode-deliverable-hook.py 的 handle_post_tool_call 通过
|
|
|
|
|
+subprocess 调用(fire-and-forget),也可以单独手动跑做自检:
|
|
|
|
|
+
|
|
|
|
|
+ # 自检:真发一条到指定房间
|
|
|
|
|
+ python3 /opt/data/scripts/fmode-process-notice.py --room '!xxx:fmode.cn' --text '测试'
|
|
|
|
|
+
|
|
|
|
|
+设计要点(都是踩过的坑):
|
|
|
|
|
+ 1) 必须用 subprocess 从 hook 里异步拉起 —— hook 本身是同步的,网络 IO 塞进去会拖慢工具链;
|
|
|
|
|
+ Hermes 对 post_tool_call 只给 20s 超时,不能堵在这里。
|
|
|
|
|
+ 2) 房间号优先取 HERMES_SESSION_CHAT_ID(gateway 注入),否则按 session_id 查 state.db。
|
|
|
|
|
+ 3) 发出去的消息 content 里带 fmode.kind = "process",App 右栏靠它做过程/终态分流。
|
|
|
|
|
+ 4) 去重:同一 session 同一工具名 60s 内最多 1 条,避免连打 20 个命令刷屏。
|
|
|
|
|
+ 5) 失败一律静默(fail-open),写日志到 /opt/data/logs/process-notice.log,绝不因为
|
|
|
|
|
+ 通知失败影响工具链或本轮回复。
|
|
|
|
|
+"""
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+脚本内 `send()` 实际写入的字段(原文):
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ body = {
|
|
|
|
|
+ "msgtype": "m.text",
|
|
|
|
|
+ "body": text,
|
|
|
|
|
+ "fmode.kind": kind,
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+默认 `kind="process"`(`def send(env: dict, room: str, text: str, kind: str = "process")`)。
|
|
|
|
|
+
|
|
|
|
|
+节流:`THROTTLE_SECONDS = 60`,`TEXT_LIMIT = 60`,节流目录 `cache/process-notice`。
|
|
|
|
|
+
|
|
|
|
|
+## 6.2 谁调用它
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+/opt/data/scripts/fmode-deliverable-hook.py:291:NOTICE_SCRIPT = HOME / "scripts" / "fmode-process-notice.py"
|
|
|
|
|
+/opt/data/scripts/fmode-process-notice.py:2:...(自身 docstring)
|
|
|
|
|
+/opt/data/scripts/fmode-process-notice.py:8: python3 /opt/data/scripts/fmode-process-notice.py --room ...
|
|
|
|
|
+/opt/data/config.yaml:47: - plugin-matrix-process-notice
|
|
|
|
|
+/opt/data/config.yaml:64: plugin-matrix-process-notice:
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+调用点(hook 第 291~321 行原文):
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+NOTICE_SCRIPT = HOME / "scripts" / "fmode-process-notice.py"
|
|
|
|
|
+NOTICE_TOOLS = {"terminal", "write_file", "patch", "execute_code", "browser_exec"}
|
|
|
|
|
+
|
|
|
|
|
+def _spawn_process_notice(payload: dict) -> None:
|
|
|
|
|
+ """fire-and-forget 派生过程消息发送进程,失败绝不影响工具链。"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ tool = payload.get("tool_name") or ""
|
|
|
|
|
+ if tool not in NOTICE_TOOLS:
|
|
|
|
|
+ return
|
|
|
|
|
+ if not NOTICE_SCRIPT.is_file():
|
|
|
|
|
+ return
|
|
|
|
|
+ env = dict(os.environ)
|
|
|
|
|
+ for k in ("HERMES_SESSION_CHAT_ID", "HERMES_SESSION_ID",
|
|
|
|
|
+ "HERMES_SESSION_PLATFORM", "HERMES_SESSION_KEY",
|
|
|
|
|
+ "MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID"):
|
|
|
|
|
+ v = payload.get(k) or os.environ.get(k)
|
|
|
|
|
+ if v:
|
|
|
|
|
+ env[k] = str(v)
|
|
|
|
|
+ subprocess.Popen(
|
|
|
|
|
+ ["python3", str(NOTICE_SCRIPT)],
|
|
|
|
|
+ stdin=subprocess.PIPE,
|
|
|
|
|
+ stdout=subprocess.DEVNULL,
|
|
|
|
|
+ stderr=subprocess.DEVNULL,
|
|
|
|
|
+ env=env,
|
|
|
|
|
+ start_new_session=True,
|
|
|
|
|
+ ).communicate(input=json.dumps(payload, ensure_ascii=False).encode("utf-8"))
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ log("NOTICE-SPAWN-FAIL %s" % e)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 6.3 插件层面
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+drwxr-xr-x 2 hermes hermes 4096 Nov 13 21:58 plugin-matrix-process-notice
|
|
|
|
|
+-rw-r--r-- 1 hermes hermes 16656 Nov 13 21:58 __init__.py
|
|
|
|
|
+-rw-r--r-- 1 hermes hermes 1060 Nov 13 21:58 plugin.yaml
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`plugin.yaml` 全文:
|
|
|
|
|
+
|
|
|
|
|
+```yaml
|
|
|
|
|
+name: plugin-matrix-process-notice
|
|
|
|
|
+version: 1.3.0
|
|
|
|
|
+description: "给 Matrix 出站消息补 fmode.kind,只写 process、绝不写 final:patch MatrixAdapter._apply_relation_metadata(每条出站消息必经、且在 send_message_event 之前),工具进度行写 content['fmode.kind']='process',其它消息不写这个键(并清掉上游残留的 'final')。撤掉 final 的原因:_progress_metadata 对非 Discord 是恒等返回、最终回复走 _thread_metadata_for_source 字段集与 progress 同构,出口层无法区分,写错的 final 会让 App 把工具输出当正文直出。真类必须从 platform_registry 的 adapter_factory 取——按模块名 import 会拿到 plugins.* 影子实例,patch 打在空气里(v1.0 失败根因)。另挂 pre_gateway_dispatch 兜住适配器迟加载。不改 body/format/m.relates_to,异常全部吞掉不影响主流程。PATCH 落在 /opt/data 持久卷,容器重建不丢。"
|
|
|
|
|
+author: fmode
|
|
|
|
|
+kind: backend
|
|
|
|
|
+hooks:
|
|
|
|
|
+ - pre_gateway_dispatch
|
|
|
|
|
+provides_hooks:
|
|
|
|
|
+ - pre_gateway_dispatch
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+插件注入点(`__init__.py` docstring 原文):
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+ * monkey-patch MatrixAdapter._apply_relation_metadata
|
|
|
|
|
+ 它在 send() 和 _upload_and_send() 里**每一条**出站消息都会经过,且就在构造 msg_content 之后、
|
|
|
|
|
+ 真正 send_message_event 之前 —— 最合适的注入点。原函数先执行(线程/回复关系不变),
|
|
|
|
|
+ 然后我们只往 msg_content 里塞一个 content["fmode.kind"],不动 body/format/m.relates_to。
|
|
|
|
|
+
|
|
|
|
|
+ 分类规则(只写 process,绝不写 final):
|
|
|
|
|
+ - metadata["_fmode_kind"] 显式给了 process → 用
|
|
|
|
|
+ - metadata["_interim_send"] 为真 → "process" (网关自己标的「非最终」)
|
|
|
|
|
+ - body 命中工具进度行特征(💻/🔧 之类 + 工具名)→ "process"
|
|
|
|
|
+ - 其余 → **不写 fmode.kind 这个键**
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+config.yaml 里的启用状态(原文第 43~47、64~65 行):
|
|
|
|
|
+
|
|
|
|
|
+```yaml
|
|
|
|
|
+plugins:
|
|
|
|
|
+ enabled:
|
|
|
|
|
+ ...
|
|
|
|
|
+ - plugin-matrix-process-notice
|
|
|
|
|
+ ...
|
|
|
|
|
+ entries:
|
|
|
|
|
+ plugin-matrix-process-notice:
|
|
|
|
|
+ allow_tool_override: false
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 明确回答
|
|
|
|
|
+
|
|
|
|
|
+**两者都在,不是二选一,但写 `fmode.kind='process'` 的是插件 patch,不是脚本。**
|
|
|
|
|
+
|
|
|
|
|
+- **脚本** `fmode-process-notice.py`:自带 `send()` 里也写 `"fmode.kind": kind`,但它发的是**一条独立的新 m.room.message 事件**(独立 event_id),文字是 `tool · <摘要>`,属于「额外再造一条过程消息」的通道。它由 hook 的 `_spawn_process_notice` 在 `post_tool_call` 里用 `subprocess.Popen` 拉起。
|
|
|
|
|
+- **插件** `plugin-matrix-process-notice`:patch `MatrixAdapter._apply_relation_metadata`,在网关**原生出站消息**(含 progress 气泡)上补 `content['fmode.kind']='process'`,不造新事件。
|
|
|
|
|
+
|
|
|
|
|
+两者并存 = 同一次工具调用可能既有一条「原生 progress 气泡被打上 kind」又有一条「脚本额外发的摘要行」。插件 docstring 明确解释了为什么不写 `final`(出口层无法区分终态)。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+# 7. 网关防复发
|
|
|
|
|
+
|
|
|
|
|
+## 7.1 当前 finish 脚本
|
|
|
|
|
+
|
|
|
|
|
+`cat /run/service/gateway-default/finish` 全文:
|
|
|
|
|
+
|
|
|
|
|
+```sh
|
|
|
|
|
+#!/command/with-contenv sh
|
|
|
|
|
+# shellcheck shell=sh
|
|
|
|
|
+# FMODE-FINISH-GUARD v2 — 网关防静默瘫痪守卫
|
|
|
|
|
+# 持久化源: /opt/data/patches/fmode-gateway-finish-guard.sh
|
|
|
|
|
+# 自愈入口: /opt/data/plugins/plugin-gateway-finish-guard/
|
|
|
|
|
+#
|
|
|
|
|
+# 判据优先级:
|
|
|
|
|
+# 1. exit 78 (EX_CONFIG 配置错误) → 125 永久停(重启无用)
|
|
|
|
|
+# 2. 存在运维显式停止标记文件 → 125 永久停(唯一人为停止通道)
|
|
|
|
|
+# 3. 其他一切(含 exit 0) → 0 重启
|
|
|
|
|
+# 注意:绝不能把 exit 0 一律当「干净停止」,2026-11-13 的 12 分半静默瘫痪就是它造成的。
|
|
|
|
|
+MARKER=/opt/data/state/gateway-stopped-by-operator
|
|
|
|
|
+LOG=/opt/data/logs/gateway-finish-guard.log
|
|
|
|
|
+
|
|
|
|
|
+log_decision() {
|
|
|
|
|
+ printf '%s exit=%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "${1:-?}" "$2" >> "$LOG" 2>/dev/null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+if [ "$1" = "78" ]; then
|
|
|
|
|
+ log_decision "$1" "EX_CONFIG(78) -> 125 不重启"
|
|
|
|
|
+ exit 125
|
|
|
|
|
+fi
|
|
|
|
|
+
|
|
|
|
|
+if [ -f "$MARKER" ]; then
|
|
|
|
|
+ log_decision "$1" "operator-stop-marker 存在 -> 125 不重启"
|
|
|
|
|
+ exit 125
|
|
|
|
|
+fi
|
|
|
|
|
+
|
|
|
|
|
+log_decision "$1" "-> 0 重启"
|
|
|
|
|
+exit 0
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+活文件校验:`md5sum` = `4f7e9713e8b98ef79b9a71f19eacdae4`,`grep -c FMODE-FINISH-GUARD` = **1**(守卫标记在)。
|
|
|
|
|
+
|
|
|
|
|
+日志尾部真实决策记录:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+2026-11-13 22:21:34 exit=78 EX_CONFIG(78) -> 125 不重启
|
|
|
|
|
+2026-11-13 22:21:34 exit=1 -> 0 重启
|
|
|
|
|
+2026-11-13 23:34:02 exit=0 -> 0 重启
|
|
|
|
|
+2026-11-13 23:48:27 exit=1 -> 0 重启
|
|
|
|
|
+2026-11-14 00:41:32 exit=1 -> 0 重启
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 7.2 持久化机制
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+drwxr-xr-x 2 hermes hermes 4096 Nov 13 22:20 finish-backup
|
|
|
|
|
+-rwxr-xr-x 1 hermes hermes 8215 Nov 13 22:19 fmode-gateway-finish-guard.sh
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`finish-backup/` 内容:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+-rwxr-xr-x 1 hermes hermes 293 Nov 9 13:05 finish.orig-20261113-221825
|
|
|
|
|
+-rw-r--r-- 1 hermes hermes 293 Nov 13 22:20 finish.upstream-20261113-222016
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`fmode-gateway-finish-guard.sh` 头部原文(1~50 行):
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+#!/bin/bash
|
|
|
|
|
+# fmode-gateway-finish-guard.sh — 网关「崩了必须自启」持久化守卫
|
|
|
|
|
+#
|
|
|
|
|
+# ── 事故背景(2026-11-13 21:58)────────────────────────────────────
|
|
|
|
|
+# gateway 收到 SIGTERM → 当时有 1 个活跃 turn → drain timed out → 被中断退出
|
|
|
|
|
+# → 退出码 0 → 原 finish 把「0」当干净停止 → 返回 125(s6 永久放弃服务)
|
|
|
|
|
+# → 容器 running 但网关死了 12 分半,无人知晓。
|
|
|
|
|
+#
|
|
|
|
|
+# 上游 service_manager.py::_render_finish_script() 的注释写明这是**有意设计**
|
|
|
|
|
+# (防 #76435 的重连风暴)。但对本容器是致命错误:我们宁愿多重启几次,
|
|
|
|
|
+# 也不能出现「表面正常、实际瘫痪」的静默故障。
|
|
|
|
|
+#
|
|
|
|
|
+# ── 修复策略 ────────────────────────────────────────────────────
|
|
|
|
|
+# 新判据(优先级从上到下):
|
|
|
|
|
+# 1. exit 78 (EX_CONFIG 配置错误) → 125 不重启(重启也没用,保留上游语义)
|
|
|
|
|
+# 2. 存在「运维显式停止」标记文件 → 125 不重启(唯一的人为停止通道)
|
|
|
|
|
+# 3. 其他一切情况(含 exit 0) → 0 重启
|
|
|
|
|
+#
|
|
|
|
|
+# ⚠ 环境变量通道不可用:finish 走 `#!/command/with-contenv sh`,
|
|
|
|
|
+# with-contenv 会**清空并重建**环境,外部传入的变量在脚本内一律 UNSET
|
|
|
|
|
+# (已实测)。所以人为停止只能靠标记文件,不能用 env var。
|
|
|
|
|
+#
|
|
|
|
|
+# ⚠ 关键实测教训:`with-contenv` 是 `#!/command/execlineb -S0`,
|
|
|
|
|
+# 它继承**调用者的 PATH**。只有在 PATH 含 /command 时(= s6-supervise 的真实
|
|
|
|
|
+# 环境)`if [` 才被正确解析;在我方 shell 里手测会误报
|
|
|
|
|
+# `execlineb: fatal: unable to exec ifelse` 并返回 127。
|
|
|
|
|
+# 验证时必须先 `export PATH="/command:$PATH"`,否则看到的是假阴性。
|
|
|
|
|
+#
|
|
|
|
|
+# ── 持久化 ──────────────────────────────────────────────────────
|
|
|
|
|
+# /run/service/gateway-default/ 是 tmpfs,重建即丢;且上游
|
|
|
|
|
+# container_boot._register_service() 会 rmtree 整个服务目录后重写 finish
|
|
|
|
|
+# (每次容器启动 reconcile 都覆盖)。因此单改 finish 活不过一次重启。
|
|
|
|
|
+# 真正的持久化载体是 /opt/data/plugins/plugin-gateway-finish-guard/
|
|
|
|
|
+# —— 持久卷上的 Hermes 插件,gateway 每次启动(import 时)重新打补丁。
|
|
|
|
|
+# 本脚本只负责「当场修复 + 验收回读」,可重复运行。
|
|
|
|
|
+#
|
|
|
|
|
+# 用法:
|
|
|
|
|
+# bash /opt/data/patches/fmode-gateway-finish-guard.sh --apply # 打补丁
|
|
|
|
|
+# bash /opt/data/patches/fmode-gateway-finish-guard.sh --check # 只读自检
|
|
|
|
|
+# bash /opt/data/patches/fmode-gateway-finish-guard.sh --revert # 还原上游行为
|
|
|
|
|
+set -uo pipefail
|
|
|
|
|
+
|
|
|
|
|
+SVC_DIR=/run/service/gateway-default
|
|
|
|
|
+FINISH="$SVC_DIR/finish"
|
|
|
|
|
+MARKER=/opt/data/state/gateway-stopped-by-operator
|
|
|
|
|
+BKDIR=/opt/data/patches/finish-backup
|
|
|
|
|
+LOGDIR=/opt/data/logs
|
|
|
|
|
+GUARD_TAG="FMODE-FINISH-GUARD"
|
|
|
|
|
+rc=0
|
|
|
|
|
+ts() { date '+%Y-%m-%d %H:%M:%S'; }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+脚本自带三种模式(末尾原文):
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+case "${1:---check}" in
|
|
|
|
|
+ --apply) apply_patch ;;
|
|
|
|
|
+ --check) check_patch ;;
|
|
|
|
|
+ --revert) revert_patch ;;
|
|
|
|
|
+ *) echo "用法: $0 [--apply|--check|--revert]"; exit 2 ;;
|
|
|
|
|
+esac
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+其 `--check` 的实测判据(关键,直接抄):
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+ # 实测判据:必须带上 /command 到 PATH,模拟 s6-supervise 真实环境
|
|
|
|
|
+ got=$(PATH="/command:$PATH" "$FINISH" 0 2>/dev/null; echo $?)
|
|
|
|
|
+ if [ "$got" = "0" ]; then
|
|
|
|
|
+ echo "OK exit=0 -> 0(会重启)"
|
|
|
|
|
+...
|
|
|
|
|
+ got=$(PATH="/command:$PATH" "$FINISH" 78 2>/dev/null; echo $?)
|
|
|
|
|
+ if [ "$got" = "125" ]; then
|
|
|
|
|
+ echo "OK exit=78 -> 125(EX_CONFIG 不重启,保留上游语义)"
|
|
|
|
|
+...
|
|
|
|
|
+ got=$(PATH="/command:$PATH" "$FINISH" 1 2>/dev/null; echo $?)
|
|
|
|
|
+ if [ "$got" = "0" ]; then
|
|
|
|
|
+ echo "OK exit=1 -> 0(会重启)"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+## 7.3 插件
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+drwxr-xr-x 2 hermes hermes 4096 Nov 13 22:20 plugin-gateway-finish-guard
|
|
|
|
|
+-rw-r--r-- 1 hermes hermes 5306 Nov 13 22:20 __init__.py
|
|
|
|
|
+-rw-r--r-- 1 hermes hermes 285 Nov 13 22:20 plugin.yaml
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`plugin.yaml` 全文:
|
|
|
|
|
+
|
|
|
|
|
+```yaml
|
|
|
|
|
+name: plugin-gateway-finish-guard
|
|
|
|
|
+version: 1.0.0
|
|
|
|
|
+description: "网关 finish 判据自愈:把「exit 0 = 干净停止 → 永不自启」改成「除 EX_CONFIG 和人为停止标记外一律重启」。持久卷载体,防容器重建后补丁丢失。"
|
|
|
|
|
+author: fmode
|
|
|
|
|
+kind: backend
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+注意:这个 `plugin.yaml` **没有 `hooks:` 段**,也没有 `provides_hooks` —— 它不靠 hook 触发。
|
|
|
|
|
+
|
|
|
|
|
+`__init__.py` 关键部分原文:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+def register(ctx=None): # pragma: no cover - Hermes 可能用不同签名调用
|
|
|
|
|
+ install_guard()
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+# import 期立即执行:gateway 每次启动都会走到这里
|
|
|
|
|
+try:
|
|
|
|
|
+ _installed = install_guard()
|
|
|
|
|
+ if _installed:
|
|
|
|
|
+ log.info("[finish-guard] 启动期自愈完成")
|
|
|
|
|
+except Exception as _exc: # noqa: BLE001
|
|
|
|
|
+ log.warning("[finish-guard] 启动期自愈异常(已忽略): %s", _exc)
|
|
|
|
|
+```
|
|
|
|
|
+ (1/2)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 6【prose】
|
|
|
|
|
+
|
|
|
|
|
+```text
|
|
|
|
|
+```python
|
|
|
|
|
+FINISH_PATH = Path("/run/service/gateway-default/finish")
|
|
|
|
|
+GUARD_LOG = Path("/opt/data/logs/gateway-finish-guard.log")
|
|
|
|
|
+
|
|
|
|
|
+def install_guard(force: bool = False) -> bool:
|
|
|
|
|
+ """把 v2 判据写入 finish。幂等:已是 v2 则跳过。返回是否处于守卫状态。"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ if not FINISH_PATH.parent.is_dir():
|
|
|
|
|
+ log.debug("[finish-guard] 服务目录不存在,跳过: %s", FINISH_PATH.parent)
|
|
|
|
|
+ return False
|
|
|
|
|
+ current = ""
|
|
|
|
|
+ try:
|
|
|
|
|
+ current = FINISH_PATH.read_text(encoding="utf-8", errors="replace")
|
|
|
|
|
+ except OSError:
|
|
|
|
|
+ pass
|
|
|
|
|
+ if GUARD_TAG in current and not force:
|
|
|
|
|
+ log.debug("[finish-guard] finish 已是 v2,无需重写")
|
|
|
|
|
+ return True
|
|
|
|
|
+ if current and GUARD_TAG not in current:
|
|
|
|
|
+ bdir = Path("/opt/data/patches/finish-backup")
|
|
|
|
|
+ bdir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ import time
|
|
|
|
|
+ bak = bdir / f"finish.upstream-{time.strftime('%Y%m%d-%H%M%S')}"
|
|
|
|
|
+ ...
|
|
|
|
|
+ FINISH_PATH.write_text(NEW_FINISH, encoding="utf-8")
|
|
|
|
|
+ os.chmod(FINISH_PATH, 0o755)
|
|
|
|
|
+ log.info("[finish-guard] finish 已修回 v2 判据(exit 0 也会重启)")
|
|
|
|
|
+ return True
|
|
|
|
|
+ except Exception as exc: # 绝不因自愈失败阻断网关启动
|
|
|
|
|
+ log.warning("[finish-guard] 打补丁失败,网关继续启动: %s", exc)
|
|
|
|
|
+ return False
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+模块 docstring 第 10~12 行原文:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+`/opt/data` 是持久卷,Hermes 在 gateway 启动时 import 本插件。
|
|
|
|
|
+所以把「重新打补丁」的动作放在 import 期执行:只要插件还在、还在 config.yaml
|
|
|
|
|
+的 plugins.enabled 里,每次网关启动都会自动把 finish 修回 v2 判据。
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 明确回答
|
|
|
|
|
+
|
|
|
|
|
+**触发点是「插件 import 期」+ `register()`,不是 hook,也没有别的机制。**
|
|
|
|
|
+
|
|
|
|
|
+具体两处,都在 `__init__.py`:
|
|
|
|
|
+1. **模块顶层(import 期立即执行)**:`try: _installed = install_guard()` —— 这是真正起作用的时机,gateway 每次启动 import 插件就修一次。
|
|
|
|
|
+2. **`register(ctx=None)`**:同样调 `install_guard()`,作为插件系统正常加载路径的兜底。
|
|
|
|
|
+
|
|
|
|
|
+没有 `pre_gateway_dispatch` 之类的 hook,`plugin.yaml` 里无 `hooks:` 键。另外 `patches/fmode-gateway-finish-guard.sh` 是**手动/当场修复工具**(`--apply/--check/--revert`),不是自动化触发点。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+# 最小可复制清单
|
|
|
|
|
+
|
|
|
|
|
+## 5. 延迟清理 + GC
|
|
|
|
|
+
|
|
|
|
|
+**1. 必须的最少文件**
|
|
|
|
|
+- `/opt/data/scripts/fmode-deliverable-hook.py` —— 其中只需含「上传成功后不删、只 log `DEFER-CLEANUP`」那段 + `pushed` 登记里写 `src_mtime` 和 `gc: False`
|
|
|
|
|
+- `/opt/data/scripts/fmode-media-gc.py`(唯一真正 `os.remove` 的地方)
|
|
|
|
|
+- `/opt/data/scripts/fmode-media-gc-wrapper.sh`(3 行,`--quiet --min-age 3600`)
|
|
|
|
|
+
|
|
|
|
|
+**2. 必须的配置**
|
|
|
|
|
+- `config.yaml` 的 `hooks.post_tool_call`(matcher `write_file|patch|execute_code|terminal`,`python3 /opt/data/scripts/fmode-deliverable-hook.py`,timeout 20)与 `hooks.transform_llm_output`(同脚本,timeout 180)
|
|
|
|
|
+- cron 一个 job:`script: fmode-media-gc-wrapper.sh`,`schedule: {"kind":"interval","minutes":30}`,`enabled: true`
|
|
|
|
|
+- 状态目录 `/opt/data/cache/deliverable-hook/*.json` 需可写(GC 读它,GC 自己 `mkdir`)
|
|
|
|
|
+
|
|
|
|
|
+**3. 一句话原理**
|
|
|
|
|
+回复里的 `` 行和 adapter 的 media-to-send 在「发送时刻」还要读文件,所以上传成功后不能立即删;改为把 `path/url/src_mtime/gc` 登记进 state JSON,由 cron 每 30 分钟扫一遍,只删「有 url + 不在 KEEP_PREFIXES + mtime 超过 1 小时 + 未 gc」的文件,删后置 `gc:True` 保证幂等。
|
|
|
|
|
+
|
|
|
|
|
+## 6. `fmode.kind='process'`
|
|
|
|
|
+
|
|
|
|
|
+**1. 必须的最少文件**
|
|
|
|
|
+- `/opt/data/plugins/plugin-matrix-process-notice/`(`__init__.py` + `plugin.yaml`)——**这是真正给原生出站消息打 `fmode.kind=process` 的**
|
|
|
|
|
+- `/opt/data/scripts/fmode-process-notice.py` —— 可选,脚本通道(额外发独立摘要 event)
|
|
|
|
|
+- `/opt/data/scripts/fmode-deliverable-hook.py` 里 `_spawn_process_notice()`(若保留脚本通道)
|
|
|
|
|
+
|
|
|
|
|
+**2. 必须的配置**
|
|
|
|
|
+- `config.yaml`:`plugins.enabled` 加 `plugin-matrix-process-notice`;`plugins.entries.plugin-matrix-process-notice.allow_tool_override: false`
|
|
|
|
|
+- 脚本通道需要 `/opt/data/.env` 里 `MATRIX_HOMESERVER / MATRIX_ACCESS_TOKEN / MATRIX_USER_ID`(脚本 `read_env()` 读它,进程环境优先)
|
|
|
|
|
+
|
|
|
|
|
+**3. 一句话原理**
|
|
|
|
|
+patch `MatrixAdapter._apply_relation_metadata`(每条出站消息必经、且在 `send_message_event` 之前),只对工具进度行写 `content['fmode.kind']='process'`、其余不写这个键并清掉上游残留的 `final`——因为出口层无法可靠区分终态,写错的 `final` 会让 App 把工具输出当正文。**必须从 `platform_registry` 的 `adapter_factory` 取真类**,按模块名 import 会拿到影子实例、patch 打在空气里。
|
|
|
|
|
+
|
|
|
|
|
+## 7. 网关防复发
|
|
|
|
|
+
|
|
|
|
|
+**1. 必须的最少文件**
|
|
|
|
|
+- `/opt/data/plugins/plugin-gateway-finish-guard/`(`__init__.py` 内嵌 `NEW_FINISH` 全文 + `install_guard()` + import 期执行;`plugin.yaml`)
|
|
|
|
|
+- `/opt/data/patches/fmode-gateway-finish-guard.sh`(手动 `--apply/--check/--revert` 工具,建议一起带上)
|
|
|
|
|
+- `/opt/data/patches/finish-backup/`(备份目录,插件会自动写 `finish.upstream-*`)
|
|
|
|
|
+- 写权限前提:`/opt/data/state/`(放 operator stop 标记)、`/opt/data/logs/`
|
|
|
|
|
+
|
|
|
|
|
+**2. 必须的配置**
|
|
|
|
|
+- `config.yaml`:`plugins.enabled` 加 `plugin-gateway-finish-guard`;`plugins.entries.plugin-gateway-finish-guard.allow_tool_override: false`
|
|
|
|
|
+- `plugin.yaml` **不需要** `hooks:` 段(触发靠 import 期)
|
|
|
|
|
+- 停机唯一合法方式:`touch /opt/data/state/gateway-stopped-by-operator`
|
|
|
|
|
+
|
|
|
|
|
+**3. 一句话原理**
|
|
|
|
|
+`/run/service/*/finish` 是 tmpfs,且上游 `container_boot._register_service()` 每次启动 rmtree 重写,单改活不过重启;所以把补丁正文内嵌进 `/opt/data` 持久卷上的插件,在 **import 期**重写 finish 为「78 → 125、有 operator 标记 → 125、其余(含 exit 0)→ 0 重启」,杜绝「exit 0 当干净停止 → s6 永久放弃服务」的静默瘫痪。验收必须 `PATH="/command:$PATH" finish <code>`,否则 `with-contenv` 会假阴性。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+**找不到/没有的**:`fmode-deliverable-hook.py` 里没有任何 `os.remove`;`plugin-gateway-finish-guard/plugin.yaml` 里没有 `hooks` 段。 (2/2)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 7【ack】
|
|
|
|
|
+
|
|
|
|
|
+```text
|
|
|
|
|
+✅ 容器已标记完成
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 工具过程(仅供核对,非回答内容)
|
|
|
|
|
+
|
|
|
|
|
+- `🐍 Running code from hermes_tools import terminal cmd...`
|
|
|
|
|
+- `🐍 Running code from hermes_tools import terminal cmd...`
|
|
|
|
|
+- `🐍 Running code from hermes_tools import terminal cmd...`
|
|
|
|
|
+- `* 🐍 Running code from hermes_tools import terminal cmd... (×2)`
|
|
|
|
|
+- `🐍 Running code from hermes_tools import terminal cmd...`
|