Przeglądaj źródła

docs: lj-test 5/7 真实机制 + kangshifu 对齐过程归档

lj-test 实测结论(原文代码 + 实跑日志):
- finish 守卫靠插件 import 期自愈,副本在 plugins/plugin-gateway-finish-guard
  (不是 /opt/data/finish-guard);上游 service_manager.py 会 rmtree 服务目录
- 延迟清理:1 小时 TTL,Hermes 调度器每 30 分钟跑 fmode-media-gc.py,
  只删 pushed 登记过的路径,KEEP_PREFIXES 白名单保护
- 无系统 cron;定时任务走 /opt/data/cron/jobs.json

kangshifu 对齐进展:
- 第 1 项完成:插件自愈 + 真实网关重启验证(grep -c=1,幂等跳过)
- 第 2 项:GC 脚本落地,pushed 新增 path 字段;真删验证进行中

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 18 godzin temu
rodzic
commit
c7b9ea1a1e

+ 392 - 0
docs/03-interface/kangshifu-dryrun放行-回答.md

@@ -0,0 +1,392 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T01:01:56.102Z
+- 房间:`!ejjnL-pS3Cil8DH3PUWQMpFNMC9OP9M33OLejXdb460`(临时房间,可保留查阅)
+- 消息统计:正文 21 段 / 工具过程 5 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# dry-run 做完直接真跑,不用等确认
+
+我上一条说的「等确认」是指我这边看,**不是要你去某个界面操作**。现在明确放行:
+
+## 2.3 的流程改成
+
+```bash
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 3600
+python3 /opt/data/scripts/fmode-media-gc.py --min-age 3600
+tail -5 /opt/data/logs/media-gc.log
+```
+
+**dry-run 和真跑连着做完**,两个输出都贴给我。
+
+---
+
+## 但下面这几条仍然必须守
+
+1. **`KEEP_PREFIXES` 白名单一个不能少**:
+   ```
+   /opt/data/work/deliverables  /opt/data/skills  /opt/data/plugins
+   /opt/data/scripts            /opt/data/.fmode  /opt/data/.fmode-media-patch
+   ```
+   漏一个就可能删掉插件或技能。
+
+2. **只删 `pushed[]` 里登记过的 path**,不许全盘 `find`。
+
+3. **`--min-age 3600`(1 小时)**,不许改小来「方便测试」。要测就造 mtime 很久的假文件。
+
+4. **删不掉要记 `kept_detail`,不许中断**。
+
+5. 真跑前**先备份 `pushed` 状态目录**:
+   ```bash
+   cp -r /opt/data/cache/deliverable-hook /opt/data/cache/deliverable-hook.bak-$(date +%Y%m%d%H%M%S)
+   ls -d /opt/data/cache/deliverable-hook.bak-*
+   ```
+
+---
+
+## 顺序(不要跳)
+
+1. 补 1.4 的数字(`grep -c` / `head -8` / `log tail -5` / 两个退出码)
+2. 旧方案改名 `/opt/data/finish-guard.disabled-20261114`
+3. `pushed` 加 `path` 字段
+4. 写 `fmode-media-gc.py` + `py_compile`
+5. **dry-run → 真跑 → tail 日志**(连着做)
+6. 贴 `jobs.json` 全文给我,我确认格式后再加作业
+
+**第 6 步仍然要先贴给我** —— 那是改调度配置,字段名写错会让整台容器的定时任务失效,和删文件不是一回事。
+
+---
+
+## 回给我的格式
+
+```
+## 0. 1.4 数字
+grep -c = ?
+head -8 = ?
+log tail -5 = ?
+code0 → exit=?
+code78 → exit=?
+
+## 1. 旧方案
+ls 输出 = ?
+
+## 2.1 pushed
+写入点原文 = ?
+样本 = ?
+
+## 2.2 GC
+md5 = ?
+
+## 2.3 备份 + dry-run + 真跑
+备份目录 = ?
+dry-run 输出 = ?
+真跑输出 = ?
+log tail -5 = ?
+
+## 2.4 jobs.json(等我确认格式)
+<全文>
+
+## 3. 事实确认
+<三个命令输出>
+```
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+收到。按你的顺序一步步来,先查现状再动手。
+
+我先看一下相关文件的实际状态(1.4 数字、旧方案、pushed 写入点、脚本现状)。
+```
+
+### 2【prose】
+
+```text
+当前状态:`fmode-media-gc.py` 已存在、`finish-guard.disabled-20261114` 已改名、备份还没有。我先把各项现状读出来核对。
+```
+
+### 3【prose】
+
+```text
+发现关键缺口:`pushed[]` 条目里**只有 url/name/ts,没有 path 字段**。而 GC 只删 `pushed[]` 登记过的 path —— 没有 path 就等于没东西可删。我先看脚本全文,确认它怎么取 path。
+```
+
+### 4【prose】
+
+```text
+```
+cd /opt/data && echo "=== finish-guar...
+```
+📖 Reading fmode-media-gc.py
+```
+
+### 5【prose】
+
+```text
+脚本看起来是对的。现在看 `pushed` 的写入点到底在哪儿——刚搜到的 `fmode-app-deliver.py:186` 是哪一条?
+```
+
+### 6【prose】
+
+```text
+好——**第 3 步「pushed 加 path 字段」已经落到代码里了**(`fmode-app-deliver.py:190` 有 `"path": os.path.abspath(src_path) if src_path else ""`)。但还需确认调用方真的把 src_path 传进来,否则写进去永远是空串。同时跑 1.4 的数字采集。
+```
+
+### 7【prose】
+
+```text
+```
+cd /opt/data && echo "=== write_state...
+```
+```
+
+### 8【prose】
+
+```text
+第 3 步确认到位:`write_state(..., src_path=path)` 真实传参,`path` 变量来自上传前的本地文件路径。现在补齐 1.4 的两个退出码并做备份。
+```
+
+### 9【prose】
+
+```text
+```
+cd /opt/data && echo "=== line 240-26...
+```
+```
+
+### 10【prose】
+
+```text
+备份已完成,9 个文件全部都在。接下来我要确认 1.4 所需的两个退出码语义是否真实存在:现有 `finish` 只写了 exit 0,`code=78` 在日志里出现,我会去查它到底从哪里来。
+```
+
+### 11【prose】
+
+```text
+```
+cd /opt/data && cp -r cache/deliverab...
+```
+```
+cd /opt/data && echo "=== finish 全文 =...
+```
+```
+
+### 12【prose】
+
+```text
+语义已清楚:旧方案里 78 是 EX_CONFIG(不重启,exit 125 让它彻底停),现行版把 78 和 0 都改成重启(exit 0)。现在按顺序执行:1.4 两个退出码实测、dry-run、真跑、tail 日志。
+```
+
+### 13【prose】
+
+```text
+```
+cd /opt/data && echo "=== code0 实测 ==...
+```
+```
+
+### 14【prose】
+
+```text
+1.4 的数字都实测拿到了。现在接着把 dry-run 和真跑连着做完。
+```
+
+### 15【prose】
+
+```text
+```
+cd /opt/data && echo "########## DRY-...
+```
+```
+
+### 16【prose】
+
+```text
+dry-run 结果:`skipped_no_path=17`,这 17 条全部被跳过,因为历史遗留条目里没有 `path` 字段。也就是说,当前**真跑同样会删 0 个文件**,因为现有台账里没有任何一条带 `path`。我还是会按你的要求连着真跑一遍,并把真实结果贴给你。
+```
+
+### 17【prose】
+
+```text
+```
+cd /opt/data && echo "########## REAL...
+```
+```
+
+### 18【prose】
+
+```text
+真跑完成,删除了 0 个文件,退出码为 0。现在读取 `jobs.json` 的现状,为第 6 步做准备。
+```
+
+### 19【prose】
+
+```text
+```
+cd /opt/data && echo "=== jobs.json 位...
+```
+```
+
+### 20【prose】
+
+```text
+...
+```
+
+### 21【prose】
+
+```text
+全部按顺序做完了。有一点必须先讲清楚:**这次真跑删除了 0 个文件,这属于正常结果**。其中 `skipped_no_path=17` 说明现有全部 17 条历史台账都没有 `path` 字段(加上 `path` 字段只对今后新产生的条目生效)。脚本按规则会跳过这些条目,不会猜测路径,因此没有删除任何东西。
+
+## 0. 1.4 数字
+- `grep -c ""`(脚本 fmode-app-deliver.py 行数)= **264**
+- `head -8` = shebang + 文档串前 7 行,链路说明到「① lfYlgU7SkK 成果搬运队列上传…」为止
+- `log tail -5`(finish-guard.log)=
+  ```
+  08:45:27 code=78 anomaly → restart
+  08:45:27 code=0 operator-stop → 125
+  08:45:29 code=0 operator-stop → 125
+  09:01:34 code=0 → restart
+  09:01:34 code=78 anomaly → restart
+  ```
+- `code=0` → **exit=0**(现行为:一律重启)
+- `code=78` → **exit=0**(现行为:异常也重启;旧版 `finish.orig.sh` 里 78=EX_CONFIG 是不重启)
+
+## 1. 旧方案
+```
+drwxr-xr-x 2 hermes hermes 4096 Nov 14 08:45 finish-guard.disabled-20261114
+finish  finish.orig.sh  install.sh
+```
+改名已完成,原文件全部保留在该目录。
+
+## 2.1 pushed
+**写入点原文**(`fmode-app-deliver.py:186-192`):
+```python
+pushed.append({
+    "url": up["url"],
+    "name": up["name"],
+    "ts": time.time,
+    "path": os.path.abspath(src_path) if src_path else "",
+})
+state["pushed"] = pushed[-20:]
+```
+调用点第 249 行确实传参:`write_state(meta.get("id") or sid, up, src_path=path)` —— `path` 是上传前的本地文件路径,非空。
+
+**样本**(现存台账,加字段前生成的):
+```json
+{"url": "",
+ "name": "verify-artifact-1794592504.txt", "ts": 1794592515.2136326}
+```
+→ 没有 `path` 键。新条目才会带。
+
+## 2.2 GC
+- md5 = `ac000253d02a289ce2483901bfa1b136`
+- `py_compile` = **COMPILE_OK**
+- `KEEP_PREFIXES` 6 个全在(grep 计数 = 6):deliverables / skills / plugins / scripts / .fmode / .fmode-media-patch
+- 没有 `os.walk` / `glob` / `find`,**不存在全盘遍历**,只读 `deliverable-hook/*.json` 的 `pushed[]`
+- 默认 `DEFAULT_MIN_AGE = 3600`,未改小
+- 删失败走 `kept_detail`(`rm_failed`),`continue` 不中断;state 写入用 `os.replace` 原子替换
+
+## 2.3 备份 + dry-run + 真跑
+**备份目录** = (8 个 session state + pending-gc.json 全在,已核对)
+
+**dry-run 输出**:
+```
+[DRY-RUN] 将要删除 0 个文件(min-age=3600s):
+state_files=8 scanned=17 removed=0 kept=17 errors=0 skipped_no_path=17
+```
+exit=0
+
+**真跑输出**:
+```
+state_files=8 scanned=17 removed=0 kept=17 errors=0 skipped_no_path=17
+```
+exit=0
+
+**log tail -5**:
+```
+2026-11-14T09:01:01+0800 [dry-run] state_files=8 scanned=17 removed=0 kept=17 errors=0 skipped_no_path=17
+2026-11-14T09:01:08+0800 [dry-run] ... skipped_no_path=17
+2026-11-14T09:01:35+0800 [dry-run] ... skipped_no_path=17
+2026-11-14T09:01:37+0800 state_files=8 scanned=17 removed=0 kept=17 errors=0 skipped_no_path=17
+```
+
+## 2.4 jobs.json(等我确认格式)
+现有 `jobs.json` 是**完整对象数组**,每条作业含 30+ 个字段(`id / name / schedule{kind,minutes,display} / repeat{times,completed} / enabled / state / deliver / script / no_agent / last_dispatch / fire_claim` 等),顶层 `{jobs: [...], updated_at: ...}`。
+
+我不新增作业,只贴要追加的那条给你确认字段形状:
+```json
+{
+  "id": "<待生成12位hex>",
+  "name": "fmode-media-gc",
+  "prompt": "",
+  "skills": [],
+  "skill": null,
+  "model": null,
+  "provider": null,
+  "provider_snapshot": null,
+  "model_snapshot": null,
+  "base_url": null,
+  "script": "fmode-media-gc.py",
+  "no_agent": true,
+  "monitor_script": null,
+  "monitor_url": null,
+  "monitor_state": null,
+  "context_from": null,
+  "schedule": { "kind": "interval", "minutes": 30, "display": "every 30m" },
+  "schedule_display": "every 30m",
+  "repeat": { "times": null, "completed": 0 },
+  "enabled": true,
+  "state": "scheduled",
+  "paused_at": null,
+  "paused_reason": null,
+  "created_at": "<ISO8601+08:00>",
+  "next_run_at": "<ISO8601+08:00>",
+  "last_run_at": null,
+  "last_status": null,
+  "last_error": null,
+  "last_delivery_error": null,
+  "last_delivery_unverified": null,
+  "failure_streak": 0,
+  "deliver": "local",
+  "origin": null,
+  "enabled_toolsets": null,
+  "workdir": null,
+  "last_dispatch": null,
+  "fire_claim": null
+}
+```
+两个待你定的点:**频率(我暂填 30 分钟)**、**`id` 由 scheduler 还是由我生成**。
+
+## 3. 事实确认
+- **a) 白名单条数** = `6`(一个不少)
+- **b) 全盘遍历检查** = `os.walk|glob|find` 匹配为空 → 没有全盘扫描
+- **c) 默认 min-age** = `DEFAULT_MIN_AGE = 3600`,`--min-age` 只是可选覆盖
+
+**写入影响面**:
+- 新建了 1 个备份目录(`deliverable-hook.bak-20261114090129`)
+- 追加了 4 行 `logs/media-gc.log`
+- **`jobs.json` 未动**(等你确认格式)
+- **无文件被删除**,无 state 被改写(removed=0 所以 `dirty` 未置位)
+```
+
+### 22【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` cd /opt/data && echo "=== scripts ===... ````
+- `* 💻 terminal ``` cd /opt/data && echo "=== scripts ===... ``` ``` cd /opt/data && echo "=== logs ===" &... ``` ``` cd /opt/data && echo "=== md5 media-g... ````
+- `📖 Reading fmode-app-deliver.py L160-209`
+- `📖 Reading jobs.json`
+- `* 📖 Reading jobs.json 💻 terminal ``` cd /opt/data && echo "=== md5 ===" &&... ````

+ 467 - 0
docs/03-interface/kangshifu-按ljtest对齐-回答.md

@@ -0,0 +1,467 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T01:00:00.682Z
+- 房间:`!KBll8UbO-DIrn2jZEAjDSzuDibvwEf65PM4J9U4c6HY`(临时房间,可保留查阅)
+- 消息统计:正文 25 段 / 工具过程 10 段 / 文件 0 个
+- 结束原因:静默超时
+
+## 我发出的问题
+
+```text
+# 按 lj-test 的真实机制对齐(你之前的假设和我的假设都有错,以下是 lj-test 实测结论)
+
+我已经拿到了 lj-test 的**原文代码和实跑日志**。你上一轮的三条判断我核对了,**两条对、一条不对**:
+
+| 你的判断 | 核实结果 |
+|---|---|
+| 「finish 已是 v2,78→125,其余→0」 | ✅ 正确 |
+| 「插件链里没有立刻删源文件」 | ✅ 正确,但**不等于没有延迟清理** —— lj-test 有,你没有 |
+| 「没有 crontab」 | ✅ 正确,但**不等于没有定时任务** —— lj-test 用 Hermes 调度器 |
+
+所以这一轮三件事,**全部照 lj-test 的机制抄**,不要自创。
+
+---
+
+# 任务 1:`finish` 守卫改成插件自愈(替换你的 `install.sh` 方案)
+
+## lj-test 的做法(原文)
+
+`/opt/data/plugins/plugin-gateway-finish-guard/__init__.py`:
+
+```python
+# ── Hermes 插件入口(import 即执行,这是关键的「每次启动自愈」时机)──
+def register(ctx=None):
+    install_guard()
+    return None
+
+# import 期立即执行:gateway 每次启动都会走到这里
+try:
+    _installed = install_guard()
+    if _installed:
+        log.info("[finish-guard] 启动期自愈完成")
+except Exception as _exc:
+    log.warning("[finish-guard] 启动期自愈异常(已忽略): %s", _exc)
+```
+
+`install_guard()` 逻辑(第 78-103 行):
+1. 读 `/run/service/gateway-default/finish`
+2. 已含 `GUARD_TAG` → 跳过(幂等)
+3. 没有 → **先备份上游原件** → 重写 → `chmod 755`
+
+## 为什么这样能活过重启
+
+- `/opt/data` 是持久卷 → 插件本身不丢
+- `config.yaml` 的 `plugins.enabled` 里有它 → Hermes 启动必 import
+- 上游 `service_manager.py:636/657` 会 `shutil.rmtree(svc_dir)` 重建服务目录 → 所以**只能靠插件在 import 时抢回来**
+
+## 你要做的
+
+### 1.1 检查你已有的是什么
+
+```bash
+echo "=== 你的 install.sh 方案 ==="
+ls -la /opt/data/finish-guard/ 2>/dev/null
+echo "=== 是否已有插件 ==="
+ls -la /opt/data/plugins/plugin-gateway-finish-guard/ 2>/dev/null || echo "(无)"
+echo "=== config.yaml 启用列表 ==="
+grep -n -A20 "plugins:" /opt/data/config.yaml | head -30
+```
+
+🔴 **如果插件已存在**(你上一轮说发现了它),先 `cat` 全文贴给我,**不要直接覆盖** —— 要先判断它写的是什么。
+
+### 1.2 如果插件不存在或不完整 → 按 lj-test 写一个
+
+要求:
+- `plugin.yaml` 里 `hooks: [post_llm_call]`(或任一个真实 hook —— loader 会跳过 `hooks: []` 的插件)
+- `__init__.py` 在 **import 阶段**调用 `install_guard()`
+- `install_guard()` 用 `GUARD_TAG` 做幂等判据
+- 备份上游原件到 `/opt/data/patches/finish-backup/`
+- 保留 v2 的 `78 → 125`?**不**:按现行 v2 即可(lj-test 也是 78→125),**这一轮不改这条**
+
+### 1.3 清理你的旧方案
+
+确认插件生效后,删掉 `/opt/data/finish-guard/`(避免双写竞争)。
+
+### 1.4 验证
+
+```bash
+# ① 从持久卷恢复一份"干净" finish(模拟上游覆盖)
+cp /opt/data/patches/finish-backup/* /run/service/gateway-default/finish 2>/dev/null
+head -3 /run/service/gateway-default/finish   # 应该是原版,无 GUARD_TAG
+
+# ② 重启网关,让插件 import
+s6-svc -r /run/service/main-hermes 2>/dev/null || s6-svc -r /run/service/gateway-default
+
+# ③ 回读:插件是否抢回来了
+sleep 8
+grep -c "GUARD_TAG 的实际标记串" /run/service/gateway-default/finish
+ls -la /opt/data/logs/gateway-finish-guard.log && tail -5 /opt/data/logs/gateway-finish-guard.log
+
+# ④ 退出码
+PATH="/command:$PATH" finish 0;  echo "code0 → exit=$?"
+PATH="/command:$PATH" finish 78; echo "code78 → exit=$?"
+```
+
+---
+
+# 任务 2:建立 GC(照 lj-test 的 `fmode-media-gc.py`)
+
+## lj-test 的关键设计(必须照抄)
+
+| 设计点 | 为什么 |
+|---|---|
+| **只删 `pushed` 状态里登记过的路径** | 不做全盘 `find`,绝不误删 |
+| `KEEP_PREFIXES` 白名单 | `work/deliverables`/`skills`/`plugins`/`scripts`/`.fmode`/`.fmode-media-patch` **永不删** |
+| `--min-age` 默认 **3600 秒(1 小时)** | 不是 24 小时 |
+| 要求「已 pushed 且有 url」 | 没上云成功的不删 |
+| 删除后回写状态 `entry["gc"]=True` | 可追溯 |
+
+lj-test 实际日志(证明它真在跑):
+```
+[2026-11-14 01:15:49] GC done dry_run=False state_files=55 scanned=21 removed=7 kept=14 errors=0
+[2026-11-14 02:48:49] GC done dry_run=False state_files=55 scanned=10 removed=1 kept=8 errors=0
+```
+
+## 你要做的
+
+### 2.1 删掉你上一轮写的 `fmode-deliverable-gc.py`
+
+它用的是 `pending-gc.json`,而 lj-test 用 `pushed` 数组。**口径必须统一**,否则两套并存会互相打架。
+
+### 2.2 照 lj-test 写 `/opt/data/scripts/fmode-media-gc.py`
+
+先把它读一遍作为参照(lj-test 那边路径相同):
+
+```bash
+ls -la /opt/data/scripts/fmode-media-gc.py 2>/dev/null || echo "(你这里没有,需要新写)"
+```
+
+**没有就新写**,要点:
+- 读 `/opt/data/cache/deliverable-hook/*.json` 状态文件
+- 遍历每个 state 的 `pushed[]`
+- 条件:`entry.url` 非空 + 文件 mtime 距今 > `--min-age` + 路径不在 `KEEP_PREFIXES` 下
+- 满足 → `os.remove` → 回写 `gc/gc_ts/gc_reason`
+- 失败 → `kept_detail` 记录原因,**不中断**
+- 支持 `--dry-run`、`--min-age`、`--quiet`
+- 输出汇总:`state_files=N scanned=N removed=N kept=N errors=N`
+
+### 2.3 加生产者:hook 上传成功后写 `pushed` 登记
+
+`/opt/data/scripts/fmode-deliverable-hook.py`,在上传成功且 md5 一致的那些行**之后**:
+
+```python
+        # C-2 清理:**延迟清理**。
+        # 已上云且回读 md5 一致 → 不立即删;源文件必须活到「发送时刻」,
+        # 否则回复里的 MEDIA: /media-to-send 路径在 adapter 发送前就已消失。
+        # 真正的删除交给 fmode-media-gc.py(每 30 分钟跑一次,只清 mtime 超过 1 小时的)。
+        if same:
+            log("DEFER-CLEANUP keep local %s" % p)
+```
+
+🔴 **前提**:你现在的 hook 如果本来就写 `pushed` 数组,那 2.3 只需确认(`grep -n "pushed"`);如果不写,才需要加。
+
+### 2.4 注册到 Hermes 调度器(**不是 crontab**)
+
+看 lj-test 的结构:
+
+```bash
+cat /opt/data/cron/jobs.json 2>/dev/null | head -60
+```
+
+你的 `jobs.json` 里加一个作业,对齐 lj-test 的 `media-gc-delayed-cleanup`:
+
+```json
+{
+  "name": "media-gc-delayed-cleanup",
+  "enabled": true,
+  "schedule": "every 30m",
+  "command": "python3 /opt/data/scripts/fmode-media-gc.py --quiet --min-age 3600"
+}
+```
+
+🔴 **先把你现有的 `jobs.json` 全文贴给我,我再确认格式**——不要照我这个 JSON 直接写,字段名可能不同。
+
+### 2.5 验证
+
+```bash
+python3 -m py_compile /opt/data/scripts/fmode-media-gc.py && echo "syntax ok"
+
+# dry-run 先看会删什么
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 3600
+
+# 真跑
+python3 /opt/data/scripts/fmode-media-gc.py --min-age 3600
+tail -5 /opt/data/logs/media-gc.log
+
+# 作业是否注册
+grep -A5 "media-gc-delayed-cleanup" /opt/data/cron/jobs.json
+```
+
+---
+
+# 任务 3:回答两个悬而未决的问题
+
+1. `/opt/data/plugins/plugin-gateway-finish-guard/` 到底写的是什么?**贴全文**
+2. 你的 hook 现在写不写 `pushed`?`grep -n "pushed" /opt/data/scripts/fmode-deliverable-hook.py`
+
+---
+
+# 回给我的格式
+
+```
+## 1.1 现状
+<三个命令的输出>
+
+## 1.2-1.3 插件
+<cat 全文 / 或说明"已存在,未改动" + 原文>
+
+## 1.4 验证
+清空后 grep 计数 = ?
+gateway-finish-guard.log 最后 5 行 = ?
+code0 → exit=?
+code78 → exit=?
+
+## 2.2 GC 脚本
+<你们环境是复用还是新写?新写的话贴 md5 与 --help 输出>
+
+## 2.3 pushed
+grep 结果 = ?
+
+## 2.4 jobs.json
+<全文>
+
+## 2.5 验证
+dry-run 输出 = ?
+真跑 tail -5 = ?
+
+## 结论
+1 项:完成 / 未完成(原因)
+2 项:完成 / 未完成(原因)
+3 项:完成 / 未完成(原因)
+```
+
+🔴 老规矩:**不许只说"已完成"而不给回读**。lj-test 能给出 `removed=7` 这种实跑数字,你也要给得出来。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+收到。我先做一遍只读排查,三个任务的现状摸清后再动手改动。给我几分钟,我先跑 1.1 里的三条命令,再加上 hook 里 pushed 的 grep。
+```
+
+### 2【prose】
+
+```text
+插件已存在,而且已经在 config.yaml 的 enabled 里了。按主人的指示,我先全文读取,不覆盖。
+```
+
+### 3【prose】
+
+```text
+```
+echo "=== GC 脚本现状 ===" ...
+```
+```
+echo "=== plugin.yaml ===" ...
+```
+```
+
+### 4【prose】
+
+```text
+已找到关键事实:现有插件用 GUARD_MARK(不是 GUARD_TAG),**plugin.yaml 的 `hooks: []` 为空,loader 会跳过这个插件**,因此它实际上没有生效;而且它写的 78→125 是正确的。当前 finish 是旧方案写入的,78→重启,和 v2 不一致。我继续查看 hook 与 jobs.json。
+```
+
+### 5【prose】
+
+```text
+```
+echo "=== 现行 finish 全文 ===" ...
+```
+```
+echo "=== hook 全文 ===" ...
+```
+```
+
+### 6【prose】
+
+```text
+先插播一条进度:现状已经确认,有三处与你的假设不同,需要你先知悉,我会按 lj-test 机制对齐;但有一处不能照抄脚本名:`/opt/data/scripts/fmode-deliverable-hook.py` **在这台机器上不存在**(deliverable 逻辑在插件 `plugin-deliverable-inject` 里),所以 2.3 的改法要按实际代码定。我继续读实际的生产者代码和状态文件格式。
+```
+
+### 7【prose】
+
+```text
+```
+echo "=== jobs.json 全文 ===" ...
+```
+```
+echo "=== deliverable-inject 插件目录 ===" ...
+```
+```
+
+### 8【prose】
+
+```text
+现状已经查清。再补两个关键细节:现有 finish 脚本是不是由旧 install.sh 安装的(没有 GUARD_MARK),以及真正的生产者 `fmode-app-deliver.py` 如何写入 pushed、本地路径保存在哪里。
+```
+
+### 9【prose】
+
+```text
+```
+echo "=== 备份的原版 finish ===" ...
+```
+```
+echo "=== 现行 finish 的 md5 对比 ===" ...
+```
+```
+
+### 10【prose】
+
+```text
+现状检查完成,结论已经很明确。我先报现状(三处差异都影响我的实现方式),然后动手,不会覆盖你的代码。
+
+**现状关键 4 条**
+1. 插件已存在(4779B),**但 `plugin.yaml` 里 `hooks: []` 且 `provides_hooks: []`**,按你说的 loader 规则,这插件实际没被加载,所以“自愈”是假的。另外它的标记串叫 `GUARD_MARK`(内容 `FMODE-FINISH-GUARD v2`),不是 `GUARD_TAG`,逻辑本身符合 v2(78→125、operator flag→125、其余→0)。
+2. 现行 `/run/service/gateway-default/finish` 的 md5 = 旧方案 `/opt/data/finish-guard/finish`,**不含 GUARD 标记**,而且它的 78 分支是 → **exit 0(重启)**,和 v2 相反。也就是说,现在跑的是旧 install.sh 写入的版本,不是插件版本。
+3. `/opt/data/scripts/fmode-deliverable-hook.py` **不存在**;真正的生产者是 `/opt/data/scripts/fmode-app-deliver.py`,它**已经在写 `pushed[]`**(url/name/ts),但**不记录本地 path**,所以 GC 现在拿不到“删哪个文件”。
+4. `jobs.json` 的字段结构是 `{"jobs":[{...}]}`,其中 schedule 是对象(`{"kind":"interval","minutes":N}`),没有 `command` 字段,脚本靠 `script` 字段加 `no_agent:true` 运行,所以我没照抄你的 JSON 片段。
+
+开始动手,先备份再改。
+```
+
+### 11【prose】
+
+```text
+```
+set -e ...
+```
+```
+
+### 12【prose】
+
+```text
+备份完成。现在改插件:只补上有效的 hook(`post_llm_call`),让 loader 真正加载它;`install_guard` 逻辑不动,因为它本来就符合 v2。
+```
+
+### 13【prose】
+
+```text
+插件 `__init__.py` 已经符合 v2,只需补上 hook 回调函数和文件日志,因为在 import 阶段 logging 还没有配置好,`logger.info` 的输出会丢失。我先读全文。
+```
+
+### 14【prose】
+
+```text
+我发现两个真实缺陷:①`logger` 在 import 阶段没有 handler,日志会丢,所以必须同时写入文件;②`register(ctx)` 要求必填位置参数,如果 loader 以 `register()` 无参调用,就会抛 TypeError;③声明了 `post_llm_call` 却没有实现回调,也会报错。现在一次性修掉这三处。
+```
+
+### 15【prose】
+
+```text
+现在把内部 logger 调用改成 `_log`,并修好 register 签名和 hook 回调。
+```
+
+### 16【prose】
+
+```text
+...
+```
+
+### 17【prose】
+
+```text
+先备份上游原版到持久卷,然后手工执行一次插件,让它自愈并覆盖现正在运行的旧 finish 脚本;接下来验证幂等性和第二次运行是否跳过。
+```
+
+### 18【prose】
+
+```text
+```
+echo "=== 现行 finish 头部 ===" ...
+```
+```
+
+### 19【prose】
+
+```text
+`install_guard() -> 1` 表示 gateway 的 finish 已被自愈,dashboard 是无权改动的 root 目录;它原本就有正确的 HERMES_DASHBOARD 逻辑,不该改动。现在验证幂等和结果。
+```
+
+### 20【prose】
+
+```text
+幂等验证成立:第二次运行时,gateway-default 被跳过,GUARD_MARK 计数仍为 1(没有双写)。现在执行 1.4 的验证:用备份恢复干净原版,重启网关,看插件是否抢回来。
+```
+
+### 21【prose】
+
+```text
+```
+echo "① 恢复干净原版(模拟上游覆盖)" ...
+```
+```
+
+### 22【prose】
+
+```text
+干净原版已经就位(GUARD 标记计数为 0)。现在重启网关,让插件真正通过 Hermes loader 加载,这才是检验插件配置是否生效的关键一步;之前手工 import 只证明函数可用。
+```
+
+### 23【prose】
+
+```text
+```
+echo "=== 重启前进程 ===" ...
+```
+```
+
+### 24【prose】
+
+```text
+⚠️ 我要下线一会儿——手上这个任务会中断。等我回来,你随便说句话,我就接着往下做。
+```
+
+### 25【prose】
+
+```text
+会话已恢复——我刚执行了 `s6-svc -r /run/service/gateway-default`,网关被重启,之后自动拉起,现在在线。这也正好完成了一次真实的 p kill 检验:**finish 守卫确实把网关拉回来了**(如果是原版 exit 0 → 125,这次重启后网关就会永久宕掉,不会有这条消息)。
+
+现在的进度快照:
+
+**已完成**
+- 插件 `plugin.yaml` 已从 `hooks: []`(会被 loader 跳过)改为 `hooks: [post_llm_call]`;`__init__.py` 修了三处实际缺陷:import 期日志写入文件、`register(ctx=None)`、补 `post_llm_call` 回调
+- 备份齐全:(插件、finish、两个脚本、jobs.json 全备)
+- 手工 import 验证 `install_guard -> 1`,幂等验证第二次跳过,`GUARD_MARK` 计数恒为 1
+
+**未完成(需要继续)**
+- 任务 1.4 的回读:重启后 `grep -c` 计数、`gateway-finish-guard.log` 尾 5 行、`finish 0` / `finish 78` 退出码
+- 任务 2:删旧 GC 脚本、写 `fmode-media-gc.py`、给 `fmode-app-deliver.py` 的 `pushed[]` 补本地 path(否则 GC 无文件可删)、注册调度作业
+- 任务 3:给出根本原因——你的 hook 文件路径不存在,真正生产者是 `fmode-app-deliver.py`
+
+接下来你希望我按哪个方向继续?
+
+1. **继续把三件事做完**(推荐)——重启后回读数据、写 GC、改 producer、注册作业;GC 那部分我会先 dry-run 给你看会删什么,再真跑
+2. **只做回读,不写任何新东西**——把 1.4 的验证数据补全,GC 与调度留着等你确认
+3. **先只给我现状报告**——我把已核实的事实和风险点整理成一份,改动暂停
+
+另外提醒一处:`pushed[]` 现在只有 `url/name/ts`,没有本地路径,所以 GC 照 lj-test 的逻辑写出来会 `scanned>0 但 removed=0`。需要同时给生产者加 `path` 字段才真正可删——这一步要不要做,请你决定。
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== 你的 install.sh 方案 ===" ... ````
+- `✍️ Writing /opt/data/plugins/plugin-gateway-fini...`
+- `📖 Reading plugin.yaml ✍️ Writing /opt/data/plugins/plugin-gateway-fini...`
+- `* 📖 Reading plugin.yaml ✍️ Writing /opt/data/plugins/plugin-gateway-fini... 📖 Reading __init__.py`
+- `🔧 Editing /opt/data/plugins/plugin-gateway-fini...`
+- `🔧 Editing /opt/data/plugins/plugin-gateway-fini...`
+- `🔧 Editing /opt/data/plugins/plugin-gateway-fini...`
+- `* 🔧 Editing /opt/data/plugins/plugin-gateway-fini... (×2)`
+- `* 🔧 Editing /opt/data/plugins/plugin-gateway-fini... (×3)`
+- `💻 terminal ``` cd /opt/data ... ````

+ 417 - 0
docs/03-interface/kangshifu-继续做完-回答.md

@@ -0,0 +1,417 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T01:01:22.810Z
+- 房间:`!AaWEioI2mJ3rNmBDJpGZ9KSyrbfaSIsHJRZRwTffg9U`(临时房间,可保留查阅)
+- 消息统计:正文 18 段 / 工具过程 9 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 选 1:继续做完。但先把 1.4 的数字补上
+
+先回答你的问题:**选 1,继续做完三件事。**
+
+你这次做得好,尤其「这条消息本身证明守卫把网关拉回来了」这个推理是对的,你自己修的三个缺陷也是真的。所以继续。
+
+---
+
+# 第 0 步(先做这个):补齐 1.4 的**数字**
+
+你现在只说了"网关回来了",没有回读数据。补这三条:
+
+```bash
+echo "=== ① GUARD 标记计数(必须是 1)==="
+grep -c "FMODE-FINISH-GUARD" /run/service/gateway-default/finish
+
+echo "=== ② 当前 finish 全文头部 ==="
+head -8 /run/service/gateway-default/finish
+
+echo "=== ③ 日志尾 5 行 ==="
+tail -5 /opt/data/logs/gateway-finish-guard.log
+
+echo "=== ④ 退出码 ==="
+PATH="/command:$PATH" finish 0;  echo "code0 → exit=$?"
+PATH="/command:$PATH" finish 78; echo "code78 → exit=$?"
+```
+
+🔴 如果 `grep -c` 不是 1(是 0 或 2),**停下来告诉我**,说明插件没抢回来或有双写。
+
+---
+
+# 任务 1 收尾:清理旧方案
+
+确认上面的数字都对之后:
+
+```bash
+# 旧方案下线(避免双写竞争)
+mv /opt/data/finish-guard /opt/data/finish-guard.disabled-20261114 2>/dev/null
+ls -la /opt/data/finish-guard* 2>/dev/null
+```
+
+**不要直接 rm**——先改名留着,万一插件出问题还能回退。
+
+---
+
+# 任务 2:GC(按下面的顺序,不要跳步)
+
+## 2.1 先给生产者加 `path` 字段
+
+你说 `pushed[]` 只有 `url/name/ts`。先**只读**确认:
+
+```bash
+echo "=== pushed 写入点原文 ==="
+grep -n "pushed" -A 12 /opt/data/scripts/fmode-app-deliver.py | head -50
+echo "=== 一个真实 state 文件的 pushed 内容 ==="
+ls /opt/data/cache/deliverable-hook/*.json 2>/dev/null | head -1 | xargs -r python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(json.dumps(d.get('pushed',[])[:2], ensure_ascii=False, indent=2))"
+```
+
+贴出这两个输出**再动手**。
+
+改法:在写 `pushed` 的那一处,**追加**一个 `path` 字段(保留原有 `url/name/ts` 不动):
+
+```python
+st.setdefault("pushed", []).append({
+    "url": url,
+    "name": name,
+    "ts": int(time.time()),
+    "path": p,          # ← 新增:本地源文件绝对路径,供 GC 定位
+})
+```
+
+## 2.2 写 `fmode-media-gc.py`
+
+照 lj-test 的口径(我已经给你要点):
+
+- 只读 `/opt/data/cache/deliverable-hook/*.json` 的 `pushed[]`
+- 条件:`url` 非空 **且** `path` 非空 **且** 文件存在 **且** mtime 距今 > `--min-age`
+- `KEEP_PREFIXES` 白名单:
+  ```
+  /opt/data/work/deliverables  /opt/data/skills  /opt/data/plugins
+  /opt/data/scripts            /opt/data/.fmode  /opt/data/.fmode-media-patch
+  ```
+- 删除后回写 `gc=True / gc_ts / gc_reason="delayed-cleanup"`
+- 删不掉 → `kept_detail` 记原因,**不中断**
+- 支持 `--dry-run` / `--min-age`(默认 3600)/ `--quiet`
+- 汇总一行:`state_files=N scanned=N removed=N kept=N errors=N`
+
+🔴 **没有 `path` 的旧条目要跳过并计数**(`skipped_no_path=N`),不能当错误。
+
+## 2.3 验证(**必须先 dry-run**)
+
+```bash
+python3 -m py_compile /opt/data/scripts/fmode-media-gc.py && echo "syntax ok"
+echo "=== dry-run ==="
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 3600
+```
+
+**把 dry-run 的输出全部贴给我,停在这里等我确认,不要自己真跑。**
+
+原因:这一步会真删文件,我要先看到它要删什么。
+
+## 2.4 注册调度作业
+
+```bash
+cat /opt/data/cron/jobs.json
+```
+
+贴全文给我,我确认字段名后再加。**不要照我上次给的 JSON 直接写**。
+
+---
+
+# 任务 3:确认几个事实
+
+```bash
+# ① 真正的生产者是谁
+ls -la /opt/data/scripts/fmode-app-deliver.py /opt/data/scripts/fmode-deliverable-hook.py 2>&1
+
+# ② 你的旧 GC 脚本(要删的)
+ls -la /opt/data/scripts/fmode-deliverable-gc.py /opt/data/cache/deliverable-hook/pending-gc.json 2>&1
+
+# ③ 现在有没有在跑 gc
+grep -n "gc" /opt/data/cron/jobs.json
+```
+
+---
+
+# 回给我的格式
+
+```
+## 0. 1.4 数字
+grep -c = ?
+head -8 = ?
+log tail -5 = ?
+code0 → exit=?
+code78 → exit=?
+
+## 1. 旧方案已下线
+ls 输出 = ?
+
+## 2.1 pushed 现状
+写入点原文 = ?
+真实 state 样本 = ?
+
+## 2.2 GC 脚本
+md5 = ?
+--help 输出 = ?
+
+## 2.3 dry-run(等待确认,不要真跑)
+<完整输出>
+
+## 2.4 jobs.json
+<全文>
+
+## 3. 事实确认
+<三个命令输出>
+```
+
+🔴 **到 2.3 就停**,等我说「可以真跑」再继续。
+
+老规矩:不许只说"已完成"而不给数字。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+收到。我按你的顺序执行,先做第 0 步的实测。
+```
+
+### 2【prose】
+
+```text
+`finish` 不在 PATH 里,我改用绝对路径直接调用 `/run/service/gateway-default/finish` 来测试退出码:
+```
+
+### 3【prose】
+
+```text
+```
+echo "=== which finish ==="; PATH="/c...
+```
+```
+
+### 4【prose】
+
+```text
+退出码测不出来,原因是 finish 脚本依赖 s6 的 execline 环境,脱离现场时 `ifelse` 找不到。我改成离线静态验证:用一个假的 `ifelse` shim,只观察它是否会被调用。
+```
+
+### 5【prose】
+
+```text
+```
+echo "=== finish 全文 ==="; cat -A /run...
+```
+```
+
+### 6【prose】
+
+```text
+finish 是个纯 sh 脚本,只用到 `[ ]` 和 `exit`,因此可以脱离 s6 直接跑。我重新测退出码:
+```
+
+### 7【prose】
+
+```text
+```
+F=/run/service/gateway-default/finish ...
+```
+```
+
+### 8【prose】
+
+```text
+第 0 步的数字都拿到了,全部符合预期。接着做任务 1、2.1 和任务 3 的只读确认(这几项互不依赖,我一起执行):
+```
+
+### 9【prose】
+
+```text
+```
+echo "########## 任务1:旧方案下线 ##########" ...
+```
+```
+
+### 10【prose】
+
+```text
+第 0 步和 1、3 都已确认完毕。现在开始改生产者代码,追加 `path` 字段:
+```
+
+### 11【prose】
+
+```text
+我需要先看清楚 `write_state` 的调用方,才能确定本地源文件路径是哪个变量:
+```
+
+### 12【prose】
+
+```text
+`path` 在 main 循环里就是循环变量 `path`,所以改动需要把 `path` 传进 `write_state`。我改两处:
+```
+
+### 13【prose】
+
+```text
+现在写 GC 脚本。我先看一下旧 GC 脚本,了解 lj-test 的口径:
+```
+
+### 14【prose】
+
+```text
+现在写新的 GC 脚本:
+```
+
+### 15【prose】
+
+```text
+dry-run 结果:`removed=0`,全部 17 条都是 `skipped_no_path=17`,原因是历史 state 里的条目都没有 `path` 字段。我另外造一个临时 state 做逻辑自测(不碰真实台账),验证删除条件是否真的生效:
+```
+
+### 16【prose】
+
+```text
+自测已经跑通(那个 bash 语法错误只是我清理目录时的那行命令写错了,目录本身已经先被删掉)。接下来补做清理并验证:
+```
+
+### 17【prose】
+
+```text
+```
+rm -rf /tmp/gc-selftest ...
+```
+```
+
+### 18【prose】
+
+```text
+全部做完,停在 2.3 等你确认。
+
+## 0. 1.4 数字(全部通过,没有停下来)
+
+- **① grep -c FMODE-FINISH-GUARD** = `1` ✅ 插件抢回来了,无双写
+- **② head -8** = 脚本头三行是 `#!/command/with-contenv sh`、`# shellcheck shell=sh`、`# FMODE-FINISH-GUARD v2`,注释块说明 78=配置错误不重启、operator 标记=人为停机不重启、其余一律重启
+- **③ log tail -5** = 08:56:39 有两次 `已重写 /run/service/gateway-default/finish (FMODE-FINISH-GUARD v2)`,最后一行 `已是 v2,跳过`(幂等生效)
+- **④ 退出码** = 你给的 `PATH="/command:$PATH" finish` 在这台机上不存在(`command not found`,exit=127),`/command/finish` 也没有。finish 是纯 sh 脚本,我改用 `sh <file>` 直接调用:
+
+```
+code0  → exit=0     (重启网关)
+code78 → exit=125   (不重启)
+code1  → exit=0     (重启)
+有 operator marker 时 code0 → exit=125,marker 已移除
+```
+逻辑完全符合设计。
+
+⚠️ 一个附带发现:日志里 `改写 /run/service/dashboard/finish 失败: [Errno 13]`。dashboard 那个 finish 没改成功(权限锁),所以 dashboard 服务**目前没有守卫保护**。不影响网关,但要不要处理,请另外决定。
+
+## 1. 旧方案已下线
+
+```
+-rwxr-xr-x 951 Nov 14 08:45 finish
+-rwxr-xr-x 626 Nov 14 08:45 finish.orig.sh
+-rwxr-xr-x 177 Nov 14 08:45 install.sh
+```
+`ls  只列出了 `.disabled-20261114` 目录内部的内容,说明改名成功,旧目录已变成 `finish-guard.disabled-20261114`。没有 rm,随时可回退。
+
+## 2.1 pushed 现状
+
+**写入点原文**(`fmode-app-deliver.py:185-187`):
+```python
+pushed = [p for p in (state.get("pushed") or []) if isinstance(p, dict)]
+pushed.append({"url": up["url"], "name": up["name"], "ts": time.time})
+state["pushed"] = pushed[-20:]
+```
+
+**真实 state 样本**(`20261112_173217_b286d4d6.json`):
+```json
+[{"url":"https://s3.fmode.cn/user/o8k5DewYqC/chat/202611/o8k5dewyqc-1794482790750-814741-file",
+  "name":"report.html","ts":1794482811.86241}]
+```
+确认只有 `url/name/ts`,没有 `path`。
+
+**已改**(两处,语法检查通过):
+- `write_state(session_id, up, src_path="")` 签名加参数
+- `pushed.append({... "path": os.path.abspath(src_path) if src_path else ""})`
+- 调用点 `write_state(meta.get("id") or sid, up, src_path=path)`
+
+`url/name/ts` 三个老字段原样保留。
+
+## 2.2 GC 脚本
+
+- **md5** = `ac000253d02a289ce2483901bfa1b136`
+- **--help**:
+```
+usage: fmode-media-gc.py [-h] [--dry-run] [--min-age MIN_AGE] [--quiet]
+  --dry-run         只报告将删除什么,不动文件
+  --min-age MIN_AGE 文件 mtime 距今至少多少秒才删(默认 3600)
+  --quiet           不打印逐条明细,只打印汇总行
+```
+- 白名单 6 个前缀按你给的原文,另加了一道「已经是子目录前缀」的匹配(不是裸 `startswith`,避免  被误判为命中 )
+- 已 GC 条目(`gc: true`)跳过,保证幂等
+- state 回写用 `.tmp-media-gc` + `os.replace` 原子替换,避免中途被杀写空
+
+**我另做了一次隔离自测**(/tmp 假台账,已删干净),验证到:老文件删、新文件留、缺失文件记 `already_gone`、无 path 记 `skipped_no_path`、回写带上 `gc/gc_ts/gc_reason`、二次跑 `removed=0`。
+
+## 2.3 dry-run(真实台账,等待确认,未真跑)
+
+```
+[DRY-RUN] 将要删除 0 个文件(min-age=3600s):
+state_files=8 scanned=17 removed=0 kept=17 errors=0 skipped_no_path=17
+```
+
+**为什么是 0**:现有 8 个 state、共 17 条 pushed,`pushed_with_path=0` —— 历史条目全是改动前写入的,没有 `path`,按规则全部计入 `skipped_no_path`,不算错误。也就是说 **代码路径正确,但目前没有可删对象**。要等改动后的新交付物产生带 `path` 的条目,GC 才会真正有活干。
+
+## 2.4 jobs.json
+
+字段名是 `id / name / prompt / script / no_agent / schedule{kind,minutes,display} / repeat{times,completed} / enabled / state / deliver`。现有 7 个 job,全部 `enabled: false`,`grep -n "gc"` **无匹配**(没有任何 GC 作业在跑)。全文太长,只贴结构骨架,`schedule` 只有 `kind: interval|...` + `minutes` + `display` 三个键:
+
+```json
+{
+  "jobs": [
+    {"id":"fd3a5d64cd47","name":"ksf-env-verify-readonly","script":"ksf-env-verify.sh","no_agent":true,
+     "schedule":{"kind":"interval","minutes":1,"display":"every 1m"},"repeat":{"times":1,"completed":1},"enabled":false},
+    {"id":"8b117b86b90d","name":"ksf-voc-toolcheck","no_agent":false,...,"enabled":false},
+    {"id":"35cc597890bd","name":"d-verify-load","script":"d-verify-load.sh","enabled":false},
+    {"id":"de2e9e203d2d","name":"d-verify-load","script":"d-verify-load.sh","enabled":false},
+    {"id":"595231eba4ef","name":"d-verify-load2","script":"d-verify-load.sh","enabled":false},
+    {"id":"b363db43dfce","name":"d-model-verify","enabled":false},
+    {"id":"323e16fbefd1","name":"fmode-status-report","script":"fmode-report.py","no_agent":true,
+     "schedule":{"kind":"interval","minutes":2,"display":"every 2m"},"repeat":{"times":null,"completed":321},"enabled":false}
+  ],
+  "updated_at": "2026-11-12T16:52:22.837397+08:00"
+}
+```
+要建 GC 作业的话,`no_agent: true` + `script: "fmode-media-gc.py"` 这条路和 `fmode-status-report` 一致 —— 等你确认字段名我再写。
+
+## 3. 事实确认
+
+- **① 真正的生产者**:`fmode-app-deliver.py` 存在(9967B,Nov 14 01:55)。**`fmode-deliverable-hook.py` 不存在** —— 它是脚本(无扩展名)不是 .py,所以「hook 是生产者」这个说法要么路径记错了,要么真生产者就是 app-deliver。
+- **② 旧 GC 脚本**:`fmode-deliverable-gc.py` 存在(1857B),`pending-gc.json` 存在但只有 **3 字节**(内容基本为空)。已在 3-① 确认它当前没有任何调度。
+- **③ cron 里的 gc**:`grep -n "gc"  输出为空 —— **现在没有任何 GC 在跑**。
+
+---
+
+🔴 **停在这里,等你说「可以真跑」。** 补充一句判断:2.3 真跑一次也不会有任何删除(0 个候选),真正的验证要等下次带 `path` 的交付物产生后才有意义。
+```
+
+### 19【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== ① GUARD 标记计数 ==="; grep -c ... ````
+- `📖 Reading fmode-app-deliver.py L150-199`
+- `💻 terminal ``` echo "=== write_state 调用点 ==="; grep ... ````
+- `🔧 Editing /opt/data/scripts/fmode-app-deliver.py`
+- `🔧 Editing /opt/data/scripts/fmode-app-deliver.py`
+- `* 🔧 Editing /opt/data/scripts/fmode-app-deliver.py 📖 Reading fmode-deliverable-gc.py`
+- `✍️ Writing /opt/data/scripts/fmode-media-gc.py`
+- `💻 terminal ``` echo "=== 语法检查 ==="; python3 -m py_co... ````
+- `* 💻 terminal ``` echo "=== 语法检查 ==="; python3 -m py_co... ``` ``` set -e ... ````

+ 492 - 0
docs/03-interface/ljtest-5和7真实逻辑-回答.md

@@ -0,0 +1,492 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-14T00:55:16.810Z
+- 房间:`!P1ia9jpwaflGsY49h7ilan3U8KD8RAStB7dAATGUW_E`(临时房间,可保留查阅)
+- 消息统计:正文 25 段 / 工具过程 3 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 请如实说明你当前的三处实现(只读,贴原始代码)
+
+我要把 kangshifu 对齐到你现在的状态,**不能基于假设**。上一轮我按二手信息写的指令有两处前提是错的,被你纠正了。这次先问清楚再动手。
+
+---
+
+## 1. `finish` 守卫的**持久化机制**
+
+```bash
+echo "=== /run/service/gateway-default/finish 当前内容 ==="
+cat /run/service/gateway-default/finish
+echo "=== 是否有持久化副本 ==="
+ls -la /opt/data/finish-guard/ 2>/dev/null || echo "(无)"
+echo "=== 谁在往 /run/service 写它 ==="
+grep -rln "gateway-default/finish\|service/gateway-default" /opt/data/plugins/ /opt/data/scripts/ /etc/s6-overlay/ 2>/dev/null
+```
+
+要回答:
+- guard 的**持久化副本放在哪**?
+- **谁在什么时机**把它装回 `/run/service`(tmpfs)?
+- 开机/容器重建后,这个机制还能生效吗?**有实测证据吗**(比如重启后 `cat` 回读)?
+
+---
+
+## 2. 上传成功后源的清理逻辑
+
+```bash
+echo "=== 找删除调用 ==="
+grep -rn "os.remove\|os.unlink\|shutil.rmtree\|\.unlink(\|\.remove(" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null
+echo "=== 找延迟/队列/GC ==="
+grep -rn "gc\|cleanup\|pending-gc\|retention\|ttl\|TTL" /opt/data/scripts/ /opt/data/plugins/ /opt/data/cache/ 2>/dev/null | head -30
+```
+
+要回答:
+- 上传成功且 md5 校验通过之后,**源文件删不删**?
+- 如果删,**立刻删还是延迟**?延迟多久?
+- 如果是延迟,**谁负责清**(定时任务 / 下次上传时顺带 / 永不删)?
+- **请贴出那段代码原文**(不要总结)。
+
+---
+
+## 3. 定时/周期性任务的挂载方式
+
+```bash
+echo "=== cron ==="; crontab -l 2>/dev/null; ls /etc/cron.d/ /etc/cron.daily/ 2>/dev/null
+echo "=== s6 oneshot ==="; ls /etc/s6-overlay/s6-rc.d/ 2>/dev/null
+echo "=== 各 oneshot 的 up 脚本 ==="; for d in /etc/s6-overlay/s6-rc.d/*/; do echo "--- $d"; ls "$d"; done 2>/dev/null | head -60
+echo "=== hermes 调度器痕迹 ==="; ls -la /opt/data/scheduler* /opt/data/state/scheduler* 2>/dev/null
+```
+
+要回答:
+- 你这台有没有 cron?(如果没有,别写"有")
+- 周期性任务(如果有)挂在哪?
+- **容器重启后它们还在吗?怎么保证的?**
+
+---
+
+## 格式
+
+每一项都用:
+
+```
+## N. 结论
+<一两句话>
+
+## 证据
+<命令输出原文 / 代码原文>
+
+## 重建后是否还在
+<是/否 + 依据>
+```
+
+🔴 只要事实和证据,不要建议,不要替我设计。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先跑只读检查,拿到原始输出,再逐项回答。
+```
+
+### 2【prose】
+
+```text
+terminal · echo "=== 1. finish 文件 ===" ls -la /run/service/gateway-defa…
+```
+
+### 3【prose】
+
+```text
+```
+echo "=== 2. 删除调用 ===" ...
+```
+```
+
+### 4【prose】
+
+```text
+已拿到第一手输出。现在读三段关键源码原文。
+```
+
+### 5【prose】
+
+```text
+* ```
+echo "=== 2. 删除调用 ===" ...
+```
+```
+echo "=== 3. cron ===" ...
+```
+```
+echo "=== plugin-gateway-finish-guard...
+```
+```
+
+### 6【prose】
+
+```text
+```
+echo "=== fmode-deliverable-hook.py 7...
+```
+```
+echo "=== fmode-media-gc.py 全文行数 ===" ...
+```
+```
+
+### 7【prose】
+
+```text
+现在读 finish-guard 插件的原文(谁、什么时机装回),以及 media-gc 的默认 min_age 和它到底怎么被调用。
+```
+
+### 8【prose】
+
+```text
+关键点:media-gc 的注释说「cron 每 30 分钟跑一次」,但本机 `crontab` 不存在,所以要查清它到底挂在哪里、有没有真的在跑,下面取证。
+```
+
+### 9【prose】
+
+```text
+```
+echo "=== cron 实际任务清单(用 hermes 自己的数据源...
+```
+```
+
+### 10【prose】
+
+```text
+* ```
+echo "=== cron 实际任务清单(用 hermes 自己的数据源...
+```
+```
+echo "=== media-gc job 完整定义 ===" ...
+```
+```
+
+### 11【prose】
+
+```text
+* ```
+echo "=== cron 实际任务清单(用 hermes 自己的数据源...
+```
+```
+echo "=== media-gc job 完整定义 ===" ...
+```
+```
+echo "=== finish 备份目录(上游原始版本留存证据)===" ...
+```
+```
+python3 -c " ...
+```
+```
+
+### 12【prose】
+
+```text
+现在补两处关键缺口:media-gc 这条定时任务的完整定义与最近一次执行证据;以及 finish 守卫有没有「重启后回读」的实测记录。
+```
+
+### 13【prose】
+
+```text
+```
+echo "=== jobs.json 结构(前 60 行原文)===" ...
+```
+```
+python3 -c " ...
+```
+```
+
+### 14【prose】
+
+```text
+* ```
+echo "=== jobs.json 结构(前 60 行原文)===" ...
+```
+```
+python3 -c " ...
+``` (×2)
+```
+
+### 15【prose】
+
+```text
+现在查最关键的一点:finish 守卫在**本容器生命周期内**有没有经历过一次「容器重启后读取 finish」——需要看 supervise 启动时间与 finish 写入时间的先后关系。
+```
+
+### 16【prose】
+
+```text
+* ```
+echo "=== jobs.json 结构(前 60 行原文)===" ...
+```
+```
+python3 -c " ...
+``` (×2)
+```
+echo "=== ticker 心跳 & 最后成功 ===" ...
+```
+```
+echo "=== 容器 PID 1 启动时间 ===" ...
+```
+```
+
+### 17【prose】
+
+```text
+```
+echo "=== 是否有重启后回读的记录(日志里)===" ...
+```
+```
+echo "=== 用 hermes 的 venv 取上游 render_...
+```
+```
+
+### 18【prose】
+
+```text
+两项要补核实:一是持久化保存的 .sh 与当前 finish 内容不同(md5 不一致),二是 finish 的 mtime(22:20:16)早于 gateway 进程启动时刻(00:41),也就是在同一个容器生命周期内,gateway 重启过一次,之后 finish 仍是 v2。这两点都需要核实。
+```
+
+### 19【prose】
+
+```text
+* ```
+echo "=== 是否有重启后回读的记录(日志里)===" ...
+```
+```
+echo "=== 用 hermes 的 venv 取上游 render_...
+```
+```
+echo "=== 备份的上游原始 finish 内容(改前版本)===" ...
+```
+```
+echo "=== 持久化源 .sh 内容 ===" ...
+```
+```
+
+### 20【prose】
+
+```text
+三处检查都已拿到第一手证据。最后再确认一下上游代码里 rmtree 的真实位置,因为前面的路径没找到。
+```
+
+### 21【prose】
+
+```text
+```
+echo "=== 结论校验:finish mtime vs gatewa...
+```
+```
+P=/opt/hermes/.venv/lib/python3.13/si...
+```
+```
+
+### 22【prose】
+
+```text
+```
+P=/opt/hermes/hermes_cli ...
+```
+```
+
+### 23【prose】
+
+```text
+...
+```
+
+### 24【prose】
+
+```text
+* ```
+P=/opt/hermes/hermes_cli ...
+```
+```
+echo "=== 上游 _render_finish_script 原文...
+```
+```
+
+### 25【prose】
+
+```text
+证据已经收齐,下面逐项回答。
+
+## 1. finish 守卫的持久化机制
+
+## 结论
+持久化副本有**两份**:`/opt/data/plugins/plugin-gateway-finish-guard/__init__.py`(真正决定能否自动修复的载体,内容是内嵌的 `NEW_FINISH` 字符串)和 `/opt/data/patches/fmode-gateway-finish-guard.sh`(人工执行用的脚本,只管当场修复)。插件在 **import 阶段就立即执行** `install_guard()`,借此在 gateway 每次启动时重新写回 finish。
+
+## 证据
+
+当前 `/run/service/gateway-default/finish` 为 1058 字节、mtime `2026-11-13 22:20:16`、含 `FMODE-FINISH-GUARD v2`,判据为:`78 → 125`;`operator 标记存在 → 125`;**其余一切(含 0)→ 0 重启**。
+
+`/opt/data/finish-guard/` **不存在**(你猜测的路径是错的)。实际存在的两份文件是:
+- `/opt/data/plugins/plugin-gateway-finish-guard/__init__.py`(5306B)与 `plugin.yaml`
+- `/opt/data/patches/fmode-gateway-finish-guard.sh`(8215B)+ `finish-backup/`(上游原件两份)
+
+装载时机在插件源码里写得很明确:import 阶段就执行(`__init__.py` 第 129-141 行原文):
+
+```python
+# ── Hermes 插件入口(import 即执行,这是关键的「每次启动自愈」时机)──
+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)
+```
+
+写回逻辑是幂等的:检测到 `GUARD_TAG` 就跳过,否则先备份上游版本,再重写并 chmod 755(`install_guard()` 第 78-103 行,已在上方完整贴出)。
+
+覆盖它的原因在上游代码里可以确认:`/opt/hermes/hermes_cli/service_manager.py` 的 `_render_finish_script()`(第 476 行)会把 `exit 0` 映射成 `125`,并且同一文件第 636、657 行有 `shutil.rmtree(svc_dir, ignore_errors=True)` 重建服务目录,其中 `svc_dir` 就是 gateway-default。所以只改 finish 确实无法在重启后保留。
+
+## 重建后是否还在
+
+**这是否成立取决于两件事,我只能给出部分实证,不会替你判定为已验证:**
+
+- **配置层:是。** `config.yaml` 第 45 行 `plugins.enabled` 列表含 `- plugin-gateway-finish-guard`,且插件与脚本都在持久卷 `/opt/data` 上,重启不会丢。
+- **实测层:无「容器重启后 cat 回读」的记录。** `ps -o lstart -p 1` 显示 PID 1(`s6-svscan`)启动于 `Mon Nov 9 13:04:56 2026`,`/proc/uptime` = 4447099 秒(约 51.5 天),即**当前容器自 11-09 起从未重启**。守卫是 11-13 22:18 才装上的,装完后本机没有发生过容器级重启,所以「重启后是否自愈」这一类事件**根本还没发生过,没有实测证据**。
+- 间接证据只有一条,不构成重启验证:gateway 进程 `134600` 是在 `Sat Nov 14 00:41:32 2026` 重启的(晚于 finish 的 mtime 22:20:16),重启后 `grep -c "FMODE-FINISH-GUARD v2"` 仍返回 1。这说明**网关进程重启时插件 import 生效、finish 未被上游覆盖**,但**不等于**容器重建(tmpfs 清空 + `container_boot` rmtree)后仍成立。
+- `gateway-finish-guard.log` 共 16 行,含「apply 开始 / 已备份 / 已替换」,无任何 boot/重启字样记录。
+
+## 2. 上传成功后源的清理逻辑
+
+## 结论
+**上传并回读 md5 一致后不立即删除**,改为延迟清理,默认 `--min-age 3600`(1 小时);由 Hermes 内置 cron 作业 `media-gc-delayed-cleanup`(每 30 分钟)调用 `fmode-media-gc.py --quiet --min-age 3600` 执行,**不是**系统 crontab。有「永久保留」白名单(含 `/opt/data/work/deliverables`)。
+
+## 证据
+
+`fmode-deliverable-hook.py` 第 77 行:`CLEANUP_LOCAL = True`
+同文件第 750-755 行原文:
+
+```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)
+```
+
+删除动作本身(`fmode-media-gc.py` 第 143-159 行):
+
+```python
+        try:
+            os.remove(path)
+        except OSError as e:
+            out["errors"] += 1
+            out["kept_detail"].append({"path": path, "why": "unlink-failed:%s" % str(e)[:80]})
+            log("GC-FAIL %s %s" % (path, e))
+            continue
+
+        entry["gc"] = True
+        entry["gc_ts"] = int(now)
+        entry["gc_reason"] = "delayed-cleanup"
+        changed = True
+        out["removed"] += 1
+        out["removed_paths"].append(path)
+        log("GC removed %s (age=%.0fs url=%s)" % (path, age, url))
+```
+
+默认值与保留白名单(同文件第 177-178 行、第 36-44 行):
+
+```python
+    ap.add_argument("--min-age", type=float, default=3600.0,
+                    help="源文件至少存活多少秒才清理(默认 3600)")
+```
+```python
+KEEP_PREFIXES = (
+    "/opt/data/work/deliverables",
+    "/opt/data/skills",
+    "/opt/data/plugins",
+    "/opt/data/scripts",
+    "/opt/data/.fmode",
+    "/opt/data/.fmode-media-patch",
+)
+```
+
+包装脚本 `fmode-media-gc-wrapper.sh` 全文只有一句执行:
+```bash
+exec python3 /opt/data/scripts/fmode-media-gc.py --quiet --min-age 3600
+```
+
+它还要求「已登记为 pushed 且带 url」,不做全盘 find(只删状态文件里登记过的路径,状态目录 `/opt/data/cache/deliverable-hook/*.json`)。
+
+**实跑证据**(`/opt/data/logs/media-gc.log`):
+```
+[2026-11-14 01:15:49] GC done dry_run=False state_files=55 scanned=21 removed=7 kept=14 errors=0
+[2026-11-14 02:48:49] GC done dry_run=False state_files=55 scanned=10 removed=1 kept=8 errors=0
+```
+作业元数据:`media-gc-delayed-cleanup`,`enabled: True`,`schedule: every 30m`,`last_run_at: 2026-11-14T08:29:49`,`last_status: ok`,`next_run_at: 2026-11-14T08:59:49`。
+
+## 重建后是否还在
+
+**是。** 依据:它不是系统 cron,而是 Hermes 自己的调度器读持久卷 `/opt/data/cron/jobs.json`(该文件在 `/opt/data` 上,持久)。证据是该调度器至今仍在写 `ticker_heartbeat`/`ticker_last_success`,且 `executions.db` 有连续 3 分钟粒度的 completed 记录(08:30 / 08:33 / 08:36 / 08:39 / 08:42 / 08:45 / 08:48 / 08:51),因此重启后随 Hermes 一起恢复,不需要额外注册。
+
+## 3. 定时/周期性任务的挂载方式
+
+## 结论
+**没有系统 cron**(`crontab` 命令根本不存在,`/etc/cron.d/` 不存在)。周期性任务全部挂在 **Hermes 内置调度器**:`/opt/data/cron/jobs.json` + `executions.db`,当前 4 个作业、2 个启用。s6 下只有 `dashboard`、`main-hermes`、`user`、`user2` 四个 rc 单元,`main-hermes/run` 是 `exec sleep infinity` 的空壳,不承担调度。
+
+## 证据
+
+```
+$ crontab -l
+/usr/bin/bash: line 17: crontab: command not found
+$ ls /etc/cron.d/
+ls: cannot access '/etc/cron.d/': 这儿什么都没有
+$ ls /etc/cron.daily/
+apt-compat
+dpkg
+$ ls /etc/s6-overlay/s6-rc.d/
+dashboard  main-hermes  user  user2
+```
+
+`/etc/s6-overlay/s6-rc.d/user/contents.d/` 只含 `dashboard`、`main-hermes`;`main-hermes/run` 原文末尾是 `exec sleep infinity`,注释自述「this service is a no-op: it sleeps forever」。
+
+`jobs.json` 里 4 个作业:
+
+- `bigfile-notice` — every 1m — **enabled: False**,state=completed
+- `fmode-status-reporter` — `*/2 * * * *` — **enabled: False**,state=paused
+- `fmode-artifact-drain` — `*/3 * * * *` — **enabled: True**,last_run `2026-11-14T08:51:49`、`ok`
+- `media-gc-delayed-cleanup` — every 30m — **enabled: True**,last_run `2026-11-14T08:29:49`、`ok`
+
+心跳与执行记录(`/opt/data/cron/ticker_heartbeat` = `1794617628.410431`,`ticker_last_success` = `1794617628.4571285`;`executions.db` 有 `executions`、`cron_incidents` 两张表,最近 8 条全部 `status=completed`、`error=None`)。
+
+另外补充一条:`ps aux | grep cron|ticker` **无输出**,即没有独立 ticker 进程;ticker 跑在 gateway 进程(`134600`)的线程里,所以 `ps` 抓不到。
+
+## 重建后是否还在
+
+**是,但依据只有配置层和运行状态层,没有容器重建的实测。** `jobs.json`、`executions.db` 都在持久卷 `/opt/data/cron/`,Hermes 启动时读取它并恢复调度;`executions.db` 里从 08:30 到 08:51 每 3 分钟一条的记录是该调度器在跑的直接证据。和第一项一样:**自 11-09 起容器没重启过**,所以「重建后作业仍在」这件事在本机同样没有过实测。
+
+## 三个可能影响你对齐的差异点(事实陈述)
+
+1. 持久化路径不是 `/opt/data/finish-guard/`,而是 `plugins/plugin-gateway-finish-guard/` + `patches/fmode-gateway-finish-guard.sh`。
+2. 持久化的 .sh 与 `/run/service` 里的 finish **md5 不一致**(`4612d678…` vs `4f7e9713…`),因为 .sh 是「脚本 + heredoc」,不是纯 finish 副本,直接拿它 diff finish 会误判。
+3. `fmode-media-gc.py` 的注释写「cron 每 30 分钟跑一次」,但本机**没有 cron**,是 Hermes 调度器;照字面按 crontab 去对齐会挂空。
+```
+
+### 26【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== 1. finish 文件 ===" ... ````
+- `💻 terminal ``` echo "=== media-gc 1-60 ===" ... ````
+- `* 💻 terminal ``` echo "=== media-gc 1-60 ===" ... ``` ``` echo "=== jobs.json 里有没有 media-gc ===" ... ````

+ 84 - 0
docs/03-interface/指令-kangshifu-dryrun直接真跑.md

@@ -0,0 +1,84 @@
+# dry-run 做完直接真跑,不用等确认
+
+我上一条说的「等确认」是指我这边看,**不是要你去某个界面操作**。现在明确放行:
+
+## 2.3 的流程改成
+
+```bash
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 3600
+python3 /opt/data/scripts/fmode-media-gc.py --min-age 3600
+tail -5 /opt/data/logs/media-gc.log
+```
+
+**dry-run 和真跑连着做完**,两个输出都贴给我。
+
+---
+
+## 但下面这几条仍然必须守
+
+1. **`KEEP_PREFIXES` 白名单一个不能少**:
+   ```
+   /opt/data/work/deliverables  /opt/data/skills  /opt/data/plugins
+   /opt/data/scripts            /opt/data/.fmode  /opt/data/.fmode-media-patch
+   ```
+   漏一个就可能删掉插件或技能。
+
+2. **只删 `pushed[]` 里登记过的 path**,不许全盘 `find`。
+
+3. **`--min-age 3600`(1 小时)**,不许改小来「方便测试」。要测就造 mtime 很久的假文件。
+
+4. **删不掉要记 `kept_detail`,不许中断**。
+
+5. 真跑前**先备份 `pushed` 状态目录**:
+   ```bash
+   cp -r /opt/data/cache/deliverable-hook /opt/data/cache/deliverable-hook.bak-$(date +%Y%m%d%H%M%S)
+   ls -d /opt/data/cache/deliverable-hook.bak-*
+   ```
+
+---
+
+## 顺序(不要跳)
+
+1. 补 1.4 的数字(`grep -c` / `head -8` / `log tail -5` / 两个退出码)
+2. 旧方案改名 `/opt/data/finish-guard.disabled-20261114`
+3. `pushed` 加 `path` 字段
+4. 写 `fmode-media-gc.py` + `py_compile`
+5. **dry-run → 真跑 → tail 日志**(连着做)
+6. 贴 `jobs.json` 全文给我,我确认格式后再加作业
+
+**第 6 步仍然要先贴给我** —— 那是改调度配置,字段名写错会让整台容器的定时任务失效,和删文件不是一回事。
+
+---
+
+## 回给我的格式
+
+```
+## 0. 1.4 数字
+grep -c = ?
+head -8 = ?
+log tail -5 = ?
+code0 → exit=?
+code78 → exit=?
+
+## 1. 旧方案
+ls 输出 = ?
+
+## 2.1 pushed
+写入点原文 = ?
+样本 = ?
+
+## 2.2 GC
+md5 = ?
+
+## 2.3 备份 + dry-run + 真跑
+备份目录 = ?
+dry-run 输出 = ?
+真跑输出 = ?
+log tail -5 = ?
+
+## 2.4 jobs.json(等我确认格式)
+<全文>
+
+## 3. 事实确认
+<三个命令输出>
+```

+ 233 - 0
docs/03-interface/指令-kangshifu-按ljtest对齐5和7.md

@@ -0,0 +1,233 @@
+# 按 lj-test 的真实机制对齐(你之前的假设和我的假设都有错,以下是 lj-test 实测结论)
+
+我已经拿到了 lj-test 的**原文代码和实跑日志**。你上一轮的三条判断我核对了,**两条对、一条不对**:
+
+| 你的判断 | 核实结果 |
+|---|---|
+| 「finish 已是 v2,78→125,其余→0」 | ✅ 正确 |
+| 「插件链里没有立刻删源文件」 | ✅ 正确,但**不等于没有延迟清理** —— lj-test 有,你没有 |
+| 「没有 crontab」 | ✅ 正确,但**不等于没有定时任务** —— lj-test 用 Hermes 调度器 |
+
+所以这一轮三件事,**全部照 lj-test 的机制抄**,不要自创。
+
+---
+
+# 任务 1:`finish` 守卫改成插件自愈(替换你的 `install.sh` 方案)
+
+## lj-test 的做法(原文)
+
+`/opt/data/plugins/plugin-gateway-finish-guard/__init__.py`:
+
+```python
+# ── Hermes 插件入口(import 即执行,这是关键的「每次启动自愈」时机)──
+def register(ctx=None):
+    install_guard()
+    return None
+
+# import 期立即执行:gateway 每次启动都会走到这里
+try:
+    _installed = install_guard()
+    if _installed:
+        log.info("[finish-guard] 启动期自愈完成")
+except Exception as _exc:
+    log.warning("[finish-guard] 启动期自愈异常(已忽略): %s", _exc)
+```
+
+`install_guard()` 逻辑(第 78-103 行):
+1. 读 `/run/service/gateway-default/finish`
+2. 已含 `GUARD_TAG` → 跳过(幂等)
+3. 没有 → **先备份上游原件** → 重写 → `chmod 755`
+
+## 为什么这样能活过重启
+
+- `/opt/data` 是持久卷 → 插件本身不丢
+- `config.yaml` 的 `plugins.enabled` 里有它 → Hermes 启动必 import
+- 上游 `service_manager.py:636/657` 会 `shutil.rmtree(svc_dir)` 重建服务目录 → 所以**只能靠插件在 import 时抢回来**
+
+## 你要做的
+
+### 1.1 检查你已有的是什么
+
+```bash
+echo "=== 你的 install.sh 方案 ==="
+ls -la /opt/data/finish-guard/ 2>/dev/null
+echo "=== 是否已有插件 ==="
+ls -la /opt/data/plugins/plugin-gateway-finish-guard/ 2>/dev/null || echo "(无)"
+echo "=== config.yaml 启用列表 ==="
+grep -n -A20 "plugins:" /opt/data/config.yaml | head -30
+```
+
+🔴 **如果插件已存在**(你上一轮说发现了它),先 `cat` 全文贴给我,**不要直接覆盖** —— 要先判断它写的是什么。
+
+### 1.2 如果插件不存在或不完整 → 按 lj-test 写一个
+
+要求:
+- `plugin.yaml` 里 `hooks: [post_llm_call]`(或任一个真实 hook —— loader 会跳过 `hooks: []` 的插件)
+- `__init__.py` 在 **import 阶段**调用 `install_guard()`
+- `install_guard()` 用 `GUARD_TAG` 做幂等判据
+- 备份上游原件到 `/opt/data/patches/finish-backup/`
+- 保留 v2 的 `78 → 125`?**不**:按现行 v2 即可(lj-test 也是 78→125),**这一轮不改这条**
+
+### 1.3 清理你的旧方案
+
+确认插件生效后,删掉 `/opt/data/finish-guard/`(避免双写竞争)。
+
+### 1.4 验证
+
+```bash
+# ① 从持久卷恢复一份"干净" finish(模拟上游覆盖)
+cp /opt/data/patches/finish-backup/* /run/service/gateway-default/finish 2>/dev/null
+head -3 /run/service/gateway-default/finish   # 应该是原版,无 GUARD_TAG
+
+# ② 重启网关,让插件 import
+s6-svc -r /run/service/main-hermes 2>/dev/null || s6-svc -r /run/service/gateway-default
+
+# ③ 回读:插件是否抢回来了
+sleep 8
+grep -c "GUARD_TAG 的实际标记串" /run/service/gateway-default/finish
+ls -la /opt/data/logs/gateway-finish-guard.log && tail -5 /opt/data/logs/gateway-finish-guard.log
+
+# ④ 退出码
+PATH="/command:$PATH" finish 0;  echo "code0 → exit=$?"
+PATH="/command:$PATH" finish 78; echo "code78 → exit=$?"
+```
+
+---
+
+# 任务 2:建立 GC(照 lj-test 的 `fmode-media-gc.py`)
+
+## lj-test 的关键设计(必须照抄)
+
+| 设计点 | 为什么 |
+|---|---|
+| **只删 `pushed` 状态里登记过的路径** | 不做全盘 `find`,绝不误删 |
+| `KEEP_PREFIXES` 白名单 | `work/deliverables`/`skills`/`plugins`/`scripts`/`.fmode`/`.fmode-media-patch` **永不删** |
+| `--min-age` 默认 **3600 秒(1 小时)** | 不是 24 小时 |
+| 要求「已 pushed 且有 url」 | 没上云成功的不删 |
+| 删除后回写状态 `entry["gc"]=True` | 可追溯 |
+
+lj-test 实际日志(证明它真在跑):
+```
+[2026-11-14 01:15:49] GC done dry_run=False state_files=55 scanned=21 removed=7 kept=14 errors=0
+[2026-11-14 02:48:49] GC done dry_run=False state_files=55 scanned=10 removed=1 kept=8 errors=0
+```
+
+## 你要做的
+
+### 2.1 删掉你上一轮写的 `fmode-deliverable-gc.py`
+
+它用的是 `pending-gc.json`,而 lj-test 用 `pushed` 数组。**口径必须统一**,否则两套并存会互相打架。
+
+### 2.2 照 lj-test 写 `/opt/data/scripts/fmode-media-gc.py`
+
+先把它读一遍作为参照(lj-test 那边路径相同):
+
+```bash
+ls -la /opt/data/scripts/fmode-media-gc.py 2>/dev/null || echo "(你这里没有,需要新写)"
+```
+
+**没有就新写**,要点:
+- 读 `/opt/data/cache/deliverable-hook/*.json` 状态文件
+- 遍历每个 state 的 `pushed[]`
+- 条件:`entry.url` 非空 + 文件 mtime 距今 > `--min-age` + 路径不在 `KEEP_PREFIXES` 下
+- 满足 → `os.remove` → 回写 `gc/gc_ts/gc_reason`
+- 失败 → `kept_detail` 记录原因,**不中断**
+- 支持 `--dry-run`、`--min-age`、`--quiet`
+- 输出汇总:`state_files=N scanned=N removed=N kept=N errors=N`
+
+### 2.3 加生产者:hook 上传成功后写 `pushed` 登记
+
+`/opt/data/scripts/fmode-deliverable-hook.py`,在上传成功且 md5 一致的那些行**之后**:
+
+```python
+        # C-2 清理:**延迟清理**。
+        # 已上云且回读 md5 一致 → 不立即删;源文件必须活到「发送时刻」,
+        # 否则回复里的 MEDIA: /media-to-send 路径在 adapter 发送前就已消失。
+        # 真正的删除交给 fmode-media-gc.py(每 30 分钟跑一次,只清 mtime 超过 1 小时的)。
+        if same:
+            log("DEFER-CLEANUP keep local %s" % p)
+```
+
+🔴 **前提**:你现在的 hook 如果本来就写 `pushed` 数组,那 2.3 只需确认(`grep -n "pushed"`);如果不写,才需要加。
+
+### 2.4 注册到 Hermes 调度器(**不是 crontab**)
+
+看 lj-test 的结构:
+
+```bash
+cat /opt/data/cron/jobs.json 2>/dev/null | head -60
+```
+
+你的 `jobs.json` 里加一个作业,对齐 lj-test 的 `media-gc-delayed-cleanup`:
+
+```json
+{
+  "name": "media-gc-delayed-cleanup",
+  "enabled": true,
+  "schedule": "every 30m",
+  "command": "python3 /opt/data/scripts/fmode-media-gc.py --quiet --min-age 3600"
+}
+```
+
+🔴 **先把你现有的 `jobs.json` 全文贴给我,我再确认格式**——不要照我这个 JSON 直接写,字段名可能不同。
+
+### 2.5 验证
+
+```bash
+python3 -m py_compile /opt/data/scripts/fmode-media-gc.py && echo "syntax ok"
+
+# dry-run 先看会删什么
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 3600
+
+# 真跑
+python3 /opt/data/scripts/fmode-media-gc.py --min-age 3600
+tail -5 /opt/data/logs/media-gc.log
+
+# 作业是否注册
+grep -A5 "media-gc-delayed-cleanup" /opt/data/cron/jobs.json
+```
+
+---
+
+# 任务 3:回答两个悬而未决的问题
+
+1. `/opt/data/plugins/plugin-gateway-finish-guard/` 到底写的是什么?**贴全文**
+2. 你的 hook 现在写不写 `pushed`?`grep -n "pushed" /opt/data/scripts/fmode-deliverable-hook.py`
+
+---
+
+# 回给我的格式
+
+```
+## 1.1 现状
+<三个命令的输出>
+
+## 1.2-1.3 插件
+<cat 全文 / 或说明"已存在,未改动" + 原文>
+
+## 1.4 验证
+清空后 grep 计数 = ?
+gateway-finish-guard.log 最后 5 行 = ?
+code0 → exit=?
+code78 → exit=?
+
+## 2.2 GC 脚本
+<你们环境是复用还是新写?新写的话贴 md5 与 --help 输出>
+
+## 2.3 pushed
+grep 结果 = ?
+
+## 2.4 jobs.json
+<全文>
+
+## 2.5 验证
+dry-run 输出 = ?
+真跑 tail -5 = ?
+
+## 结论
+1 项:完成 / 未完成(原因)
+2 项:完成 / 未完成(原因)
+3 项:完成 / 未完成(原因)
+```
+
+🔴 老规矩:**不许只说"已完成"而不给回读**。lj-test 能给出 `removed=7` 这种实跑数字,你也要给得出来。

+ 164 - 0
docs/03-interface/指令-kangshifu-真验证GC.md

@@ -0,0 +1,164 @@
+# 让 GC 真正删一次(现在 0 条是因为没有 path,要用新交付物验证)
+
+## 我拍两个板,你不用再问
+
+1. **调度频率 = 30 分钟**(lj-test 同款;`--min-age 3600` 要求至少每小时跑一次,30m 有 2 倍余量)
+2. **`id` 由 scheduler 生成**,你不要手编
+
+---
+
+# 步骤(按顺序)
+
+## 1. 产出一个真实的带 path 交付物
+
+在你的工作目录写一个测试文件,然后**通过正常的交付流程**发出去(让 `fmode-app-deliver.py` 的 `write_state` 走到,而不是手工改 json):
+
+```bash
+mkdir -p /opt/data/work/gc-verify
+echo "gc verify $(date -Is)" > /opt/data/work/gc-verify/gc-verify-$(date +%s).txt
+ls -la /opt/data/work/gc-verify/
+```
+
+然后把该文件作为交付物发出(按你平时的方式,例如在回复里用 MEDIA 行,或调你自己的上传流程)。
+
+🔴 **不要手工往 json 里塞一条假记录** —— 那样验证不了生产者是否真的写了 path。
+
+## 2. 确认 path 真的进来了
+
+```bash
+echo "=== 找刚写的 state ==="
+ls -t /opt/data/cache/deliverable-hook/*.json | head -3
+echo "=== 最新那条 pushed 的内容 ==="
+ls -t /opt/data/cache/deliverable-hook/*.json | head -1 | xargs -r python3 -c "
+import json,sys
+d=json.load(open(sys.argv[1]))
+for p in (d.get('pushed') or [])[-3:]:
+    print(json.dumps(p, ensure_ascii=False))
+"
+```
+
+**必须看到 `"path": "/opt/data/work/..."` 非空**。如果还是空串,停下告诉我。
+
+## 3. 把它变成「1 小时前」
+
+```bash
+# 用具体路径,不要通配到别的文件
+touch -d "2 hours ago" <上一步确认的那个 path>
+ls -la --time-style=full-iso <那个 path>
+```
+
+## 4. dry-run → 真跑
+
+```bash
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 3600
+python3 /opt/data/scripts/fmode-media-gc.py --min-age 3600
+tail -8 /opt/data/logs/media-gc.log
+```
+
+**期望**:
+- dry-run:`removed=1`(其中含你要删的那个),并列出路径
+- 真跑:`removed=1`
+
+## 5. 确认文件真的没了
+
+```bash
+ls -la <那个 path> 2>&1
+# 期望:No such file or directory
+```
+
+## 6. 确认 state 回写了 gc 标记
+
+```bash
+<那个 state 文件> 里找 "gc": true
+```
+
+## 7. 幂等:再跑一次
+
+```bash
+python3 /opt/data/scripts/fmode-media-gc.py --min-age 3600
+# 期望:removed=0(已 gc 的跳过)
+```
+
+---
+
+# 8. 注册调度作业(做完上面再动这个)
+
+按你探查到的真实格式,`no_agent: true` + `script`,和 `fmode-status-report` 同款:
+
+```json
+{
+  "id": "<由 scheduler 生成>",
+  "name": "fmode-media-gc",
+  "prompt": "",
+  "script": "fmode-media-gc.py",
+  "no_agent": true,
+  "schedule": { "kind": "interval", "minutes": 30, "display": "every 30m" },
+  "repeat": { "times": null, "completed": 0 },
+  "enabled": true,
+  "state": "scheduled",
+  "deliver": "local"
+}
+```
+
+🔴 但 `script` 字段只写文件名 —— 先确认 Hermes 从哪里找脚本(`workdir`?某个固定 scripts 目录?),
+**如果它要求相对路径或绝对路径,以它的规矩为准**。查清楚再写。
+
+验证:
+
+```bash
+grep -A12 "fmode-media-gc" /opt/data/cron/jobs.json
+# 等一个调度周期(30 分钟)后看:
+tail -3 /opt/data/logs/media-gc.log
+grep -A3 "fmode-media-gc" /opt/data/cron/jobs.json | grep -E "last_run_at|last_status"
+```
+
+---
+
+# 9. 附带问题(先只看,不要改)
+
+你说的 dashboard finish 权限问题:
+
+```bash
+ls -la /run/service/dashboard/finish
+stat -c '%U %G %a' /run/service/dashboard/finish
+id
+```
+
+只贴输出,**不要改**。这台机器上 dashboard 掉了我不会自愈,但目前不影响网关,等你决定。
+
+---
+
+# 回给我的格式
+
+```
+## 1. 测试文件
+ls 输出 = ?
+
+## 2. path 是否进来
+最新 pushed = ?
+(必须看到非空 path)
+
+## 4. GC 验证
+dry-run 输出 = ?
+真跑输出 = ?
+log tail -8 = ?
+
+## 5. 文件是否真删
+ls 结果 = ?
+
+## 6. state 回写
+gc 字段 = ?
+
+## 7. 幂等
+第二次 = ?
+
+## 8. 调度
+script 查找规则 = ?
+jobs.json 片段 = ?
+last_run/last_status = ?
+
+## 9. dashboard
+三个命令输出 = ?
+```
+
+🔴 **第 2 步如果 path 还是空串,立刻停下**,说明生产者没改对。

+ 160 - 0
docs/03-interface/指令-kangshifu-继续做完.md

@@ -0,0 +1,160 @@
+# 选 1:继续做完。但先把 1.4 的数字补上
+
+先回答你的问题:**选 1,继续做完三件事。**
+
+你这次做得好,尤其「这条消息本身证明守卫把网关拉回来了」这个推理是对的,你自己修的三个缺陷也是真的。所以继续。
+
+---
+
+# 第 0 步(先做这个):补齐 1.4 的**数字**
+
+你现在只说了"网关回来了",没有回读数据。补这三条:
+
+```bash
+echo "=== ① GUARD 标记计数(必须是 1)==="
+grep -c "FMODE-FINISH-GUARD" /run/service/gateway-default/finish
+
+echo "=== ② 当前 finish 全文头部 ==="
+head -8 /run/service/gateway-default/finish
+
+echo "=== ③ 日志尾 5 行 ==="
+tail -5 /opt/data/logs/gateway-finish-guard.log
+
+echo "=== ④ 退出码 ==="
+PATH="/command:$PATH" finish 0;  echo "code0 → exit=$?"
+PATH="/command:$PATH" finish 78; echo "code78 → exit=$?"
+```
+
+🔴 如果 `grep -c` 不是 1(是 0 或 2),**停下来告诉我**,说明插件没抢回来或有双写。
+
+---
+
+# 任务 1 收尾:清理旧方案
+
+确认上面的数字都对之后:
+
+```bash
+# 旧方案下线(避免双写竞争)
+mv /opt/data/finish-guard /opt/data/finish-guard.disabled-20261114 2>/dev/null
+ls -la /opt/data/finish-guard* 2>/dev/null
+```
+
+**不要直接 rm**——先改名留着,万一插件出问题还能回退。
+
+---
+
+# 任务 2:GC(按下面的顺序,不要跳步)
+
+## 2.1 先给生产者加 `path` 字段
+
+你说 `pushed[]` 只有 `url/name/ts`。先**只读**确认:
+
+```bash
+echo "=== pushed 写入点原文 ==="
+grep -n "pushed" -A 12 /opt/data/scripts/fmode-app-deliver.py | head -50
+echo "=== 一个真实 state 文件的 pushed 内容 ==="
+ls /opt/data/cache/deliverable-hook/*.json 2>/dev/null | head -1 | xargs -r python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(json.dumps(d.get('pushed',[])[:2], ensure_ascii=False, indent=2))"
+```
+
+贴出这两个输出**再动手**。
+
+改法:在写 `pushed` 的那一处,**追加**一个 `path` 字段(保留原有 `url/name/ts` 不动):
+
+```python
+st.setdefault("pushed", []).append({
+    "url": url,
+    "name": name,
+    "ts": int(time.time()),
+    "path": p,          # ← 新增:本地源文件绝对路径,供 GC 定位
+})
+```
+
+## 2.2 写 `fmode-media-gc.py`
+
+照 lj-test 的口径(我已经给你要点):
+
+- 只读 `/opt/data/cache/deliverable-hook/*.json` 的 `pushed[]`
+- 条件:`url` 非空 **且** `path` 非空 **且** 文件存在 **且** mtime 距今 > `--min-age`
+- `KEEP_PREFIXES` 白名单:
+  ```
+  /opt/data/work/deliverables  /opt/data/skills  /opt/data/plugins
+  /opt/data/scripts            /opt/data/.fmode  /opt/data/.fmode-media-patch
+  ```
+- 删除后回写 `gc=True / gc_ts / gc_reason="delayed-cleanup"`
+- 删不掉 → `kept_detail` 记原因,**不中断**
+- 支持 `--dry-run` / `--min-age`(默认 3600)/ `--quiet`
+- 汇总一行:`state_files=N scanned=N removed=N kept=N errors=N`
+
+🔴 **没有 `path` 的旧条目要跳过并计数**(`skipped_no_path=N`),不能当错误。
+
+## 2.3 验证(**必须先 dry-run**)
+
+```bash
+python3 -m py_compile /opt/data/scripts/fmode-media-gc.py && echo "syntax ok"
+echo "=== dry-run ==="
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 3600
+```
+
+**把 dry-run 的输出全部贴给我,停在这里等我确认,不要自己真跑。**
+
+原因:这一步会真删文件,我要先看到它要删什么。
+
+## 2.4 注册调度作业
+
+```bash
+cat /opt/data/cron/jobs.json
+```
+
+贴全文给我,我确认字段名后再加。**不要照我上次给的 JSON 直接写**。
+
+---
+
+# 任务 3:确认几个事实
+
+```bash
+# ① 真正的生产者是谁
+ls -la /opt/data/scripts/fmode-app-deliver.py /opt/data/scripts/fmode-deliverable-hook.py 2>&1
+
+# ② 你的旧 GC 脚本(要删的)
+ls -la /opt/data/scripts/fmode-deliverable-gc.py /opt/data/cache/deliverable-hook/pending-gc.json 2>&1
+
+# ③ 现在有没有在跑 gc
+grep -n "gc" /opt/data/cron/jobs.json
+```
+
+---
+
+# 回给我的格式
+
+```
+## 0. 1.4 数字
+grep -c = ?
+head -8 = ?
+log tail -5 = ?
+code0 → exit=?
+code78 → exit=?
+
+## 1. 旧方案已下线
+ls 输出 = ?
+
+## 2.1 pushed 现状
+写入点原文 = ?
+真实 state 样本 = ?
+
+## 2.2 GC 脚本
+md5 = ?
+--help 输出 = ?
+
+## 2.3 dry-run(等待确认,不要真跑)
+<完整输出>
+
+## 2.4 jobs.json
+<全文>
+
+## 3. 事实确认
+<三个命令输出>
+```
+
+🔴 **到 2.3 就停**,等我说「可以真跑」再继续。
+
+老规矩:不许只说"已完成"而不给数字。

+ 73 - 0
docs/03-interface/问-ljtest-5和7的真实逻辑.md

@@ -0,0 +1,73 @@
+# 请如实说明你当前的三处实现(只读,贴原始代码)
+
+我要把 kangshifu 对齐到你现在的状态,**不能基于假设**。上一轮我按二手信息写的指令有两处前提是错的,被你纠正了。这次先问清楚再动手。
+
+---
+
+## 1. `finish` 守卫的**持久化机制**
+
+```bash
+echo "=== /run/service/gateway-default/finish 当前内容 ==="
+cat /run/service/gateway-default/finish
+echo "=== 是否有持久化副本 ==="
+ls -la /opt/data/finish-guard/ 2>/dev/null || echo "(无)"
+echo "=== 谁在往 /run/service 写它 ==="
+grep -rln "gateway-default/finish\|service/gateway-default" /opt/data/plugins/ /opt/data/scripts/ /etc/s6-overlay/ 2>/dev/null
+```
+
+要回答:
+- guard 的**持久化副本放在哪**?
+- **谁在什么时机**把它装回 `/run/service`(tmpfs)?
+- 开机/容器重建后,这个机制还能生效吗?**有实测证据吗**(比如重启后 `cat` 回读)?
+
+---
+
+## 2. 上传成功后源的清理逻辑
+
+```bash
+echo "=== 找删除调用 ==="
+grep -rn "os.remove\|os.unlink\|shutil.rmtree\|\.unlink(\|\.remove(" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null
+echo "=== 找延迟/队列/GC ==="
+grep -rn "gc\|cleanup\|pending-gc\|retention\|ttl\|TTL" /opt/data/scripts/ /opt/data/plugins/ /opt/data/cache/ 2>/dev/null | head -30
+```
+
+要回答:
+- 上传成功且 md5 校验通过之后,**源文件删不删**?
+- 如果删,**立刻删还是延迟**?延迟多久?
+- 如果是延迟,**谁负责清**(定时任务 / 下次上传时顺带 / 永不删)?
+- **请贴出那段代码原文**(不要总结)。
+
+---
+
+## 3. 定时/周期性任务的挂载方式
+
+```bash
+echo "=== cron ==="; crontab -l 2>/dev/null; ls /etc/cron.d/ /etc/cron.daily/ 2>/dev/null
+echo "=== s6 oneshot ==="; ls /etc/s6-overlay/s6-rc.d/ 2>/dev/null
+echo "=== 各 oneshot 的 up 脚本 ==="; for d in /etc/s6-overlay/s6-rc.d/*/; do echo "--- $d"; ls "$d"; done 2>/dev/null | head -60
+echo "=== hermes 调度器痕迹 ==="; ls -la /opt/data/scheduler* /opt/data/state/scheduler* 2>/dev/null
+```
+
+要回答:
+- 你这台有没有 cron?(如果没有,别写"有")
+- 周期性任务(如果有)挂在哪?
+- **容器重启后它们还在吗?怎么保证的?**
+
+---
+
+## 格式
+
+每一项都用:
+
+```
+## N. 结论
+<一两句话>
+
+## 证据
+<命令输出原文 / 代码原文>
+
+## 重建后是否还在
+<是/否 + 依据>
+```
+
+🔴 只要事实和证据,不要建议,不要替我设计。