Преглед изворни кода

docs: kangshifu 5/7 对齐完成 + GC 改造为定时+阈值 + 误删事故记录

完成项(均有实测证据):
- finish 守卫:插件 import 期自愈,曾真实重启网关验证(pid 变更 + 标记仍在)
- GC:新增工作目录清理、磁盘阈值模式、--force/--dry-run 双闸防呆
- 调度:每日 01:00 定时(min-age 86400, threshold 0)
        + 每 30m 磁盘兜底(threshold 80, --force)
- 顺带修复:KEEP_PREFIXES 硬编码导致 --work-dir 时 deliverables 失去保护

事故(2026-11-14 09:21):
- 验证时漏 --dry-run 且 --disk-threshold 1 触发 emergency,真删 3 个工作目录
- journey-hud / noodle-20261113 / ksf-ppt-6 旧产物共约 948MB,S3 无副本,不可恢复
- 根因:指令文档中危险参数与验证命令混排;容器执行前未拦住
- 已加双闸:参数层(threshold<10% 必须 --dry-run)+ 运行期(emergency 真删必须 --force)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct пре 17 часа
родитељ
комит
27493cc06d

+ 791 - 0
docs/03-interface/kangshifu-GC改造-回答.md

@@ -0,0 +1,791 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T01:22:14.695Z
+- 房间:`!MNH_Rrih96xF7F-vHf8t1CE1Jo_8dxpLbRSzHS-J_ZQ`(临时房间,可保留查阅)
+- 消息统计:正文 43 段 / 工具过程 12 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# GC 改造:定时(每天 1 点)+ 磁盘阈值(80%)双机制
+
+用户已裁决,按下面实现。**这是改状态操作,每个改动都要回读。**
+
+---
+
+## 需求(用户原话归纳)
+
+| 机制 | 触发 | 清理对象 |
+|---|---|---|
+| **定时** | **每天 01:00** | 交付物源文件 + 工作目录 |
+| **紧急** | **磁盘使用率 ≥ 80%** | 同上,更激进 |
+
+**安全护栏(用户明确要求保留)**:
+- 定时清理:只删**超过 24 小时没被修改**的(用 mtime 判,正在写的天然跳过)
+- 紧急清理:只删**超过 1 小时没被修改**的
+
+---
+
+## 1. 改造 `fmode-media-gc.py`
+
+### 1.1 新增两个清理目标
+
+**A. 交付物源文件**(现有逻辑,保留)
+- 来源:`pushed[]` 里的 `path`
+- 现状问题:17/18 条没有 `path`(改动前的历史数据),只有 1 条有
+- **保留原样**,新产生的会带 path
+
+**B. 工作目录**(新增)
+- 扫描 `/opt/data/work/` 下的**一级子目录**
+- 判据:目录的 mtime(不是里面文件的 mtime)距现在超过阈值
+- 删除方式:`shutil.rmtree`
+- 🔴 **必须排除**:
+  ```
+  /opt/data/work/deliverables   (交付物正式存放处)
+  ```
+- 🔴 **强化白名单**(现有 6 个前缀一个不能少):
+  ```
+  /opt/data/work/deliverables  /opt/data/skills  /opt/data/plugins
+  /opt/data/scripts            /opt/data/.fmode  /opt/data/.fmode-media-patch
+  ```
+
+### 1.2 新增参数
+
+```
+--disk-threshold PCT    磁盘使用率达到该百分比时进入紧急模式(默认 80,0 = 关闭)
+--work-dir PATH         工作目录路径(默认 /opt/data/work)
+--no-work-dir           跳过工作目录清理(只清交付物)
+```
+
+**两种模式的阈值处理**:
+```python
+if 磁盘使用率 >= disk_threshold:
+    effective_min_age = min(min_age, 3600)      # 紧急:1 小时
+    mode = "emergency"
+else:
+    effective_min_age = min_age                  # 正常:定时任务传 86400
+    mode = "scheduled"
+```
+
+🔴 **关键**:`--disk-threshold` 为 0 时,**永远不进紧急模式**(方便定时任务单独跑)。
+
+### 1.3 磁盘使用率探测
+
+```python
+import shutil as _shutil
+def disk_pct(path="/opt/data"):
+    u = _shutil.disk_usage(path)
+    return u.used / u.total * 100
+```
+
+用 `shutil.disk_usage` 而不是解析 `df` 输出 —— 后者格式随系统变。
+
+### 1.4 输出要带模式
+
+```
+[media-gc] mode=scheduled disk=5.2% min_age=86400
+state_files=9 scanned=18 removed=0 kept=18 errors=0 skipped_no_path=17
+workdirs=42 scanned=12 removed=3 kept=9 kept_running=0
+total_freed=1.2GB
+```
+
+**必须能看出**:当前是哪个模式、磁盘百分比多少、删了几个目录、释放多少空间。
+
+### 1.5 `--quiet` 语义调整
+
+原来:`removed>0 || errors>0` 才写日志。
+**改成**:`removed>0 || errors>0 || mode=="emergency"` 才写日志。
+—— 紧急模式哪怕没删东西也要留痕,方便事后分析。
+
+---
+
+## 2. 调度改为两个作业
+
+### 2.1 删掉现有的 `fmode-media-gc`(id=`4c198c095e84`)
+
+先停用,确认新作业跑通后再删。
+
+### 2.2 新建「定时清理」作业
+
+```json
+{
+  "name": "fmode-workdir-gc-daily",
+  "script": "fmode-media-gc.py",
+  "no_agent": true,
+  "schedule": { "kind": "cron", "expr": "0 1 * * *", "display": "0 1 * * *" },
+  "enabled": true,
+  "state": "scheduled",
+  "deliver": "local"
+}
+```
+
+🔴 **参数怎么传?** `script` 字段只有文件名,没有 argv 字段。
+先查清楚:`scheduler_script.py` 怎么处理参数?
+- 如果支持 `args` 字段 → 用 `--min-age 86400 --disk-threshold 0`
+- 如果不支持 → **写一个 wrapper shell 脚本**(照 lj-test 的 `fmode-media-gc-wrapper.sh`),
+  `script` 指向 wrapper
+
+**先查清楚再写**,把 `_resolve_script_path` 和 argv 构造那段源码贴给我。
+
+### 2.3 新建「阈值兜底」作业
+
+```json
+{
+  "name": "fmode-disk-guard",
+  "script": "<wrapper 或 fmode-media-gc.py>",
+  "no_agent": true,
+  "schedule": { "kind": "interval", "minutes": 30, "display": "every 30m" },
+  "enabled": true,
+  "state": "scheduled",
+  "deliver": "local"
+}
+```
+
+参数:`--min-age 86400 --disk-threshold 80`
+
+**逻辑**:
+- 磁盘 < 80% → 什么都不做(读一次 df,成本 ~1ms)
+- 磁盘 ≥ 80% → 清 1 小时内没动过的
+
+---
+
+## 3. 验证(必须逐条给数字)
+
+### 3.1 造测试数据
+
+```bash
+mkdir -p /opt/data/work/gc-test-old /opt/data/work/gc-test-new
+echo "old" > /opt/data/work/gc-test-old/a.txt
+echo "new" > /opt/data/work/gc-test-new/b.txt
+touch -d "30 hours ago" /opt/data/work/gc-test-old        # 改目录 mtime
+ls -ld --time-style=full-iso /opt/data/work/gc-test-*
+```
+
+### 3.2 正常模式(干跑)
+
+```bash
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 86400 --disk-threshold 0
+```
+期望:`mode=scheduled`,`gc-test-old` 在删除列表里,`gc-test-new` 不在。
+
+### 3.3 工作目录真删
+
+```bash
+python3 /opt/data/scripts/fmode-media-gc.py --min-age 86400 --disk-threshold 0
+ls -ld /opt/data/work/gc-test-* 2>&1
+```
+
+### 3.4 紧急模式(模拟高磁盘)
+
+🔴 **不要真的把磁盘写满。** 用参数模拟:
+
+```bash
+# 把阈值设成 1%,当前 5% ≥ 1% → 进紧急模式
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 86400 --disk-threshold 1
+```
+期望:`mode=emergency`,`effective_min_age=3600`。
+
+### 3.5 白名单验证(最重要)
+
+```bash
+# 造一个"超期的白名单目录"——必须不被删
+ls -ld --time-style=full-iso /opt/data/work/deliverables
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 1 --disk-threshold 1
+```
+期望:**`/opt/data/work/deliverables` 绝不出现在删除列表里**。
+
+### 3.6 清理测试目录
+
+```bash
+rm -rf /opt/data/work/gc-test-old /opt/data/work/gc-test-new
+```
+
+---
+
+## 4. 回给我的格式
+
+```
+## 1. 脚本
+md5 = ?
+--help 输出 = ?
+
+## 2. 调度器 argv 机制
+<源码片段原文>
+
+## 3. 验证
+3.1 造数据 = ?
+3.2 正常 dry-run = ?
+3.3 真删结果 = ?
+3.4 紧急模式 dry-run = ?
+3.5 白名单验证 = ?(必须证明 deliverables 没被删)
+
+## 4. 作业
+jobs.json 两个新作业 = ?
+旧 job 4c198c095e84 状态 = ?
+
+## 5. 结论
+定时(每天 1 点)= ?
+阈值(80%)= ?
+护栏(24h / 1h)= ?
+```
+
+🔴 **老规矩:不许只说"已完成"而不给数字。**
+
+🔴 **绝对不要**为了验证把磁盘写满。用 `--disk-threshold 1` 模拟。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+收到。我先看现状:脚本源码、调度器如何处理 argv、现有作业;随后开始改动,每一步都回读结果。
+```
+
+### 2【prose】
+
+```text
+```
+ls -la /opt/data/scripts/ 2>&1 | head...
+```
+📖 Reading fmode-media-gc.py
+```
+
+### 3【prose】
+
+```text
+现在查调度器中 argv 的构造部分,这是必须先确认的关键点:
+```
+
+### 4【prose】
+
+```text
+* ```
+ls -la /opt/data/scripts/ 2>&1 | head...
+```
+📖 Reading fmode-media-gc.py
+💻 terminal
+```
+grep -n "4c198c095e84" -A 25 /opt/dat...
+```
+```
+find / -name "scheduler_script.py" -n...
+```
+```
+
+### 5【prose】
+
+```text
+```
+grep -n '"args"\|args\b' /opt/data/cr...
+```
+```
+grep -n "_resolve_script_path\|argv\|...
+```
+```
+
+### 6【prose】
+
+```text
+* ```
+grep -n '"args"\|args\b' /opt/data/cr...
+```
+```
+grep -n "_resolve_script_path\|argv\|...
+```
+📖 Reading scheduler_script.py L257-371
+```
+
+### 7【prose】
+
+```text
+已确认:`_script_argv()` 只返回 `[python_exe, str(path)]`,**不支持 args 字段**。因此必须写一个 wrapper shell 脚本。
+
+我先检查 lj-test 的 wrapper 作为参考,以及 `/opt/data/work/` 目录结构:
+```
+
+### 8【prose】
+
+```text
+* ```
+grep -n '"args"\|args\b' /opt/data/cr...
+```
+```
+grep -n "_resolve_script_path\|argv\|...
+```
+📖 Reading scheduler_script.py L257-371
+💻 terminal
+```
+ls -la /opt/data/work/ 2>&1; echo '==...
+```
+```
+
+### 9【prose】
+
+```text
+现状看清了。现在改造脚本——先备份,再重写:
+```
+
+### 10【prose】
+
+```text
+```
+ls -la /opt/data/cache/deliverable-ho...
+```
+```
+cp -a /opt/data/scripts/fmode-media-g...
+```
+```
+
+### 11【prose】
+
+```text
+现在开始造测试数据,并逐条验证:
+```
+
+### 12【prose】
+
+```text
+```
+cd /opt/data; echo '######## 3.2 正常模式...
+```
+```
+
+### 13【prose】
+
+```text
+* ```
+cd /opt/data; echo '######## 3.2 正常模式...
+```
+```
+ls -ld --time-style=full-iso /opt/dat...
+```
+```
+
+### 14【prose】
+
+```text
+`scheduled` 模式符合预期:`gc-test-old` 被列入删除,`gc-test-new` 没有被列入。发现一个 bug:`workdirs=0` 应为总数,我现在修掉它;同时注意 `deliverables` 目录不在 `/opt/data/work/` 下扫描结果中,需要确认它是否存在。
+```
+
+### 15【prose】
+
+```text
+`/opt/data/work/deliverables` 目前不存在(真实交付物在 `/opt/data/deliverables`,不在扫描范围内)。为满足 3.5 白名单验证,我需**创建**一个超期的 `/opt/data/work/deliverables` 来验证它不被删除。同时先修 `workdirs=` 总数:
+```
+
+### 16【prose】
+
+```text
+现在创建超期的 `deliverables` 目录用于白名单验证,然后运行 3.4 和 3.5:
+```
+
+### 17【prose】
+
+```text
+3.4 符合预期(`mode=emergency`,`min_age=3600`),3.5 白名单验证也通过了:`deliverables` 被标记为 `keep_prefix`,未进入 WILL_RM。
+
+现在执行 3.3,真实删除测试目录:
+```
+
+### 18【prose】
+
+```text
+```
+cd /opt/data ...
+```
+```
+
+### 19【prose】
+
+```text
+真删结果符合预期:`gc-test-old` 已消失,`gc-test-new` 还在,`deliverables/important.html` 完好。
+
+但发现一处问题:真正执行删除后,那两个本应被清掉的遗留目录(旧的 `xiaofei_kq`)也就是被一起删了,这符合预期。现在写 wrapper,然后配置作业。
+
+**先确认 wrapper 的思路**:`_script_argv()` 对 `.sh` 返回 `[bash, script]`,无参数传递能力;`script` 字段只能填 `/opt/data/scripts/` 内的文件。因此 wrapper 必须把参数硬编码。
+```
+
+### 20【prose】
+
+```text
+两个 wrapper 都测试通过。现在检查 `--quiet` 语义(紧急模式即使没删东西也要写日志):
+```
+
+### 21【prose】
+
+```text
+`--quiet` 语义确认:紧急模式即使 `removed=0` 也写日志,行数从 1 增至 2。
+
+🔴 **发现严重问题**:刚才这次 `--disk-threshold 1` 的 **quiet(非 dry-run)** 调用,实际上删除了 3 个工作目录(`journey-hud`、`ksf-ppt-6`、`noodle-20261113`,共 948.7MB)。我需要立即核查并如实报告:
+```
+
+### 22【prose】
+
+```text
+```
+ls -la /opt/data/work/ 2>&1
+```
+```
+
+### 23【prose】
+
+```text
+确认了:`journey-hud`、`ksf-ppt-6`、`noodle-20261113` 三个目录确实被删除。这是我的操作失误——我在做「紧急模式验证」时忘了加 `--dry-run`,直接跑了真删。
+
+先看看能否恢复,再继续配置:
+```
+
+### 24【prose】
+
+```text
+```
+ls -la /opt/data/.local/share/Trash/f...
+```
+```
+
+### 25【prose】
+
+```text
+```
+ls -la /opt/data/work/ksf-ppt-6 2>&1 ...
+```
+```
+
+### 26【prose】
+
+```text
+`ksf-ppt-6` 又被重建了(可能是正在运行的任务写回),但内容是空的。我如实记录:这 3 个目录无法恢复为原状态。
+
+现在继续配置作业 —— 先停用旧作业:
+```
+
+### 27【prose】
+
+```text
+⚙️ tool_describe...
+```
+
+### 28【prose】
+
+```text
+先暂停旧作业,并按用户要求先停用旧作业、确认新作业跑通后再删除:
+```
+
+### 29【prose】
+
+```text
+旧作业已暂停(`enabled=false, state=paused`)。现在创建两个新作业:
+```
+
+### 30【prose】
+
+```text
+⚙️ tool_call...
+```
+
+### 31【prose】
+
+```text
+⏰ Scheduling create
+```
+
+### 32【prose】
+
+```text
+* ⏰ Scheduling create (×2)
+```
+
+### 33【prose】
+
+```text
+两个新作业都已创建。现在实际运行一次,确认它们能跑通,届时再删除旧作业:
+```
+
+### 34【prose】
+
+```text
+* ⏰ Scheduling create (×2)
+⏰ Scheduling run
+```
+
+### 35【prose】
+
+```text
+这个作业在后台运行。同时清理测试目录,并验证白名单的最终状态:
+```
+
+### 36【prose】
+
+```text
+现在核实作业在 jobs.json 中的实际写入内容:
+```
+
+### 37【prose】
+
+```text
+jobs.json 的写入已核实。现在做最后一次完整的回归验证,也就是重新造数据跑全套,确保 md5 和最终行为一致:
+```
+
+### 38【prose】
+
+```text
+白名单在 `min-age 1` 和 `threshold 1` 的极端条件下依然守住了:`deliverables` 显示为 `keep_prefix`,没有进入 WILL_RM。
+
+现在清理测试数据:
+```
+
+### 39【prose】
+
+```text
+```
+rm -rf /opt/data/work/gc-test-old /op...
+```
+```
+
+### 40【prose】
+
+```text
+现在删除旧作业(新作业已经跑通):
+```
+
+### 41【prose】
+
+```text
+⏰ Scheduling remove
+```
+
+### 42【prose】
+
+```text
+全部核实完毕。旧作业已从 jobs.json 中移除(grep 结果为 0),新的 `fmode-workdir-gc-daily` 实测 `last_status=ok`。
+
+---
+
+# 交付报告
+
+⚠️ **先报告一处我自己造成的破坏**(详见报告末尾的「事故」一节;报告结尾包含需要你决定的事)
+
+---
+
+## 1. 脚本
+
+**md5** = `6aad3196acf2a2e6c4e456419e2834e4`
+(路径为 ,15133 字节;改动前 md5 为 `ac000253d02a289ce2483901bfa1b136`,备份在 )
+
+**`--help` 输出:**
+```
+usage: fmode-media-gc.py [-h] [--dry-run] [--min-age MIN_AGE]
+                         [--disk-threshold DISK_THRESHOLD] [--work-dir WORK_DIR]
+                         [--no-work-dir] [--quiet]
+
+交付物源文件 + 工作目录延迟清理(双机制:定时 / 磁盘阈值)
+
+  --dry-run             只报告将删除什么,不动文件
+  --min-age             文件 mtime 距今至少多少秒才删(正常模式用;默认 3600)
+  --disk-threshold      磁盘使用率达到该百分比时进入紧急模式(默认 80,0 = 关闭)
+  --work-dir            工作目录路径(默认 )
+  --no-work-dir         跳过工作目录清理(只清交付物)
+  --quiet               不打印逐条明细,只打印汇总行
+```
+
+---
+
+## 2. 调度器 argv 机制
+
+结论是 **`script` 字段不支持传 args**,因此我用了 wrapper。
+
+**`_script_argv`(/opt/hermes/cron/scheduler_script.py:303-320)原文:**
+```python
+def _script_argv(path: Path) -> tuple[Optional[list[str]], dict[str, str], Optional[str]]:
+    """``(argv, env_overlay, error)`` for a validated script. Interpreter by extension — the
+    shebang is deliberately NOT honoured (small, auditable surface): ``.sh``/``.bash`` → bash,
+    else ``sys.executable`` (Windows uv-venv overlay gets the .pth bootstrap)."""
+    if path.suffix.lower in {".sh", ".bash"}:
+        _bash = shutil.which("bash") or ("/bin/bash" if os.path.isfile("/bin/bash") else None)
+        if _bash is None:
+            return None, {}, (...)
+        return [_bash, str(path)], {}, None
+    python_exe, env_overlay = _windows_cron_python_invocation(sys.executable)
+    if env_overlay:
+        return _windows_cron_bootstrap_argv(python_exe, env_overlay, str(path)), env_overlay, None
+    return [python_exe, str(path)], env_overlay, None
+```
+
+**`Popen` 调用处(:364-366):**
+```python
+proc = subprocess.Popen(
+    argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
+    cwd=workdir or str(path.parent), env=env, **popen_kwargs)
+```
+
+`argv` 只包含 `[解释器, 脚本路径]`,没有任何 append 额外参数的代码;jobs.json 里也不存在 `args` 字段(grep 结果为 0)。
+
+**于是写了两个 wrapper**(放在 ,`_resolve_script_path` 要求脚本必须在该目录内):
+
+- `fmode-workdir-gc-daily.sh` → `--quiet --min-age 86400 --disk-threshold 0 --work-dir 
+- `fmode-disk-guard.sh` → `--quiet --min-age 86400 --disk-threshold 80 --work-dir 
+
+两个都独立测过,能正常运行。
+
+---
+
+## 3. 验证
+
+**3.1 造数据**
+```
+drwxr-xr-x 2 hermes hermes 4096 2026-11-14 09:20:59 +0800 
+drwxr-xr-x 2 hermes hermes 4096 2026-11-13 03:20:59 +0800    ← 30h 前
+```
+编译检查:`python3 -m py_compile` → OK
+
+**3.2 正常 dry-run**(`--min-age 86400 --disk-threshold 0`)
+```
+[media-gc] mode=scheduled disk=4.5% min_age=86400
+state_files=9 scanned=18 removed=0 kept=18 errors=0 skipped_no_path=17
+workdirs=9 scanned=9 removed=2 kept=7 kept_running=6
+total_freed=184.2KB
+  WILL_RM   age=108001s
+  too_new   age=1s
+```
+✅ `mode=scheduled`;`gc-test-old` 在删除列表里;`gc-test-new` 不在。
+
+**3.3 真删**(`--min-age 86400 --disk-threshold 0`)
+```
+workdirs=9 scanned=9 removed=2 kept=7 kept_running=6
+  RM   age=108009s size=4B
+  RM    age=140794s size=184.2KB
+```
+删除后 `ls -ld :
+```
+drwxr-xr-x 2 hermes hermes 4096 Nov 14 09:20 
+```
+✅ `gc-test-old` 已消失,`gc-test-new` 仍然存在。
+
+**3.4 紧急模式 dry-run**(`--disk-threshold 1`,实际只用了 4.5%,没有把磁盘写满)
+```
+[media-gc] mode=emergency disk=4.5% min_age=3600 (threshold=1.0% capped to 3600s)
+workdirs=9 scanned=9 removed=5 kept=4 kept_running=3
+total_freed=941.0MB
+```
+✅ `mode=emergency`,`effective_min_age=3600`。
+
+**3.5 白名单验证**(`--dry-run --min-age 1 --disk-threshold 1`,最激进的组合)
+```
+[media-gc] mode=emergency disk=4.4% min_age=1 (threshold=1.0% capped to 3600s)
+workdirs=6 scanned=6 removed=4 kept=2 kept_running=1
+  WILL_RM       age=2757s size=345.8MB
+  keep_prefix       ← 白名单命中
+  WILL_RM       age=108000s
+  WILL_RM       age=1121s
+  WILL_RM       age=18s
+```
+✅  显示为 `keep_prefix`,**从未进入 WILL_RM**;真删那轮结束后,我确认了 `deliverables/important.html` 仍然存在。
+
+⚠️ 注意: 原本并不存在(真实交付物在 ,不在一级扫描范围内)。为了做 3.5 验证,我临时创建了它并 touch 成 30 小时前,验证完已经删除。
+
+**3.6 清理测试目录** → `gc-test-old` / `gc-test-new` 都已不存在;`d2am`、`gc-verify`、`ksf-ppt-6` 都还在。
+
+**`--quiet` 语义补充验证:**
+```
+日志中 emergency 行数(before)=1
+日志中 emergency 行数(after)=2
+```
+✅ 在 `--quiet`、`mode=emergency` 且 `removed=0` 的情况下仍然写入了日志。
+
+---
+
+## 4. 作业
+
+**jobs.json 里的三个作业(JSON 实际回读):**
+```
+a2d097098a97  fmode-workdir-gc-daily  script=fmode-workdir-gc-daily.sh  no_agent=true
+              schedule={"kind":"cron","expr":"0 1 * * *","display":"0 1 * * *"}
+              enabled=true  state=scheduled  deliver=local
+              next_run_at=2026-11-15T01:00:00+08:00
+              last_run_at=2026-11-14T09:21:45  last_status=ok   ← 已实跑过一次
+
+147050d2c366  fmode-disk-guard        script=fmode-disk-guard.sh  no_agent=true
+              schedule={"kind":"interval","minutes":30,"display":"every 30m"}
+              enabled=true  state=scheduled  deliver=local
+              next_run_at=2026-11-14T09:51:43+08:00
+```
+
+**旧 job `4c198c095e84`(fmode-media-gc)状态:已删除。**
+- 流程:先 `pause`(enabled=false, state=paused)→ 新作业实跑确认 `last_status=ok` → 再 `remove`
+- 回读:`grep -c "4c198c095e84"  → **0**
+
+---
+
+## 5. 结论
+
+- **定时(每天 1 点)** = ✅ 已配置:`0 1 * * *`,`--min-age 86400 --disk-threshold 0`,只清 24 小时未改动的
+- **阈值(80%)** = ✅ 已配置:每 30m 跑一次,`--disk-threshold 80`;低于 80% 时 `mode=scheduled`,`total_freed=0B`(实测)
+- **护栏(24h / 1h)** = ✅ 生效:定时用 86400s,紧急用 `min(min_age, 3600)`;白名单 6 个前缀全部保留,`deliverables` 在最极端的参数下也安全
+
+---
+
+## 🔴 事故:我误删了 3 个工作目录
+
+在跑「`--quiet` 语义验证」时,我用了 `--quiet --min-age 86400 --disk-threshold 1`,但**漏了 `--dry-run`**。因为 `threshold=1 < 4.5%` 触发了 emergency,`effective_min_age` 被 cap 到 3600,于是一口气真删了 3 个目录:
+
+```
+27s          182.6KB
+27s            940.3MB
+27s      366.7KB
+total_freed=948.7MB
+```
+
+**后果:**
+- `journey-hud`、`noodle-20261113`:已消失,**无法恢复**(没有回收站,`rmtree` 是直接 unlink)
+- `ksf-ppt-6`:现在有一个空壳目录(`out/` 下无内容),应该是某个正在运行的任务刚刚重建的,`940MB` 的原始内容已经丢失
+- 真实交付物  完好(`category-landscape-v1.html` 23504B、`competitive-map-v1.html` 20242B 都在)
+
+这是我在验证环节的操作失误,不是脚本 bug(脚本在 `--dry-run` 下全部符合预期)。如果这 3 个目录里的内容你需要,请告诉我,我查一下 S3 上是否有对应的已上传副本可以重新拉回本地。
+```
+
+### 43【prose】
+
+```text
+后台作业的回执到了,和前面报告里 `last_status=ok` 一致。补一条实跑输出作为证据:
+
+**`fmode-workdir-gc-daily` 手动触发实跑(2026-11-14 09:21:45):**
+```
+[media-gc] mode=scheduled disk=4.4% min_age=86400
+state_files=9 scanned=18 removed=0 kept=18 errors=0 skipped_no_path=17
+workdirs=5 scanned=5 removed=0 kept=5 kept_running=4
+total_freed=0B
+```
+- `status=ok`,`deliver=local`
+- `mode=scheduled`、`min_age=86400` → 定时作业跑的是正确分支
+- `removed=0` → 因为当时刚清理完测试数据,剩下 5 个目录(`d2am`、`gc-verify`、`ksf-ppt-6` 等)都不到 24 小时,全部 `too_new` 跳过;这正是护栏该有的表现
+- 下次自动运行:`2026-11-15T01:00:00+08:00`
+
+这条也顺带印证了 wrapper 从 cron 调度器走 `_script_argv` 的 `.sh` 分支能正常拿到硬编码参数,不是我手动 `bash` 才通的。
+
+前面报告里 5 个章节的数字不变。**唯一还悬着的是那 3 个被我误删的目录**(`journey-hud`、`ksf-ppt-6` 的 940MB、`noodle-20261113`)——要不要我去 S3 个人空间查一下有没有对应副本可以拉回来?
+```
+
+### 44【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` md5sum /opt/data/scripts/fmode-media-... ````
+- `✍️ Writing /opt/data/scripts/fmode-media-gc.py`
+- `* ✍️ Writing /opt/data/scripts/fmode-media-gc.py 💻 terminal ``` cd /opt/data ... ````
+- `🔧 Editing /opt/data/scripts/fmode-media-gc.py`
+- `💻 terminal ``` cd /opt/data ... ````
+- `✍️ Writing /opt/data/scripts/fmode-workdir-gc-da...`
+- `💻 terminal ``` chmod +x /opt/data/scripts/fmode-work... ````
+- `* 💻 terminal ``` chmod +x /opt/data/scripts/fmode-work... ``` ``` cd /opt/data ... ````
+- `💻 terminal ``` cd /opt/data ... ````
+- `🐍 Running code import json jobs = json.load(open('/o...`
+- `💻 terminal ``` cd /opt/data ... ````
+- `💻 terminal ``` grep -c "4c198c095e84" /opt/data/cron... ````

+ 227 - 0
docs/03-interface/kangshifu-容量画像-回答.md

@@ -0,0 +1,227 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T01:17:43.993Z
+- 房间:`!gjsWaqYENWkSRvBE8c055jCVV-j5t4TRJ8-CFMaJhh4`(临时房间,可保留查阅)
+- 消息统计:正文 11 段 / 工具过程 1 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 只读:给我这台容器的真实容量画像(我要据此设计清理策略)
+
+**全部只读,不要做任何修改,不要删除任何文件,不要调度任何作业。**
+
+---
+
+## 1. 磁盘总量与当前占用
+
+```bash
+echo "=== 各挂载点用量 ==="
+df -h
+
+echo "=== /opt/data 内部各目录占用(一级)==="
+du -sh /opt/data/* 2>/dev/null | sort -rh | head -20
+
+echo "=== 交付物相关目录 ==="
+du -sh /opt/data/work /opt/data/work/deliverables /opt/data/cache 2>/dev/null
+```
+
+## 2. 磁盘写入状态(只读)
+
+```bash
+cat /sys/fs/cgroup/memory.max 2>/dev/null /sys/fs/cgroup/memory.current 2>/dev/null
+echo "---"
+cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null
+cat /sys/fs/cgroup/memory/memory.usage_in_bytes 2>/dev/null
+echo "=== OOM 记录 ==="
+dmesg 2>/dev/null | grep -i "oom\|killed process" | tail -5 || echo "(dmesg 不可读,正常)"
+```
+
+## 3. 增量速度(这最关键)
+
+```bash
+echo "=== 交付物目录里最近 7 天的文件数 ==="
+find /opt/data/work -type f -mtime -7 2>/dev/null | wc -l
+echo "=== 最近 7 天新增了多少 MB ==="
+find /opt/data/work -type f -mtime -7 -printf '%s\n' 2>/dev/null | awk '{s+=$1} END {print s/1024/1024 " MB"}'
+echo "=== state 文件数量 ==="
+ls /opt/data/cache/deliverable-hook/*.json 2>/dev/null | wc -l
+```
+
+## 4. 现状:待清理对象
+
+```bash
+echo "=== GC dry-run(只看,不删)==="
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 3600 2>&1 | tail -5
+echo "=== state 里登记的 path 有多少还真实存在 ==="
+python3 - <<'PY'
+import json, glob, os
+exist = 0; total = 0
+for f in glob.glob('/opt/data/cache/deliverable-hook/*.json'):
+    try:
+        d = json.load(open(f))
+    except Exception:
+        continue
+    for p in (d.get('pushed') or []):
+        total += 1
+        path = p.get('path') or ''
+        if path and os.path.isfile(path):
+            exist += 1
+print("pushed 总条数 =", total, "| path 仍存在 =", exist)
+PY
+```
+
+---
+
+# 必须回答的三个问题(不要给建议,只要数值)
+
+```
+## 1. 磁盘
+总容量 = ?    已用 = ?    可用 = ?    使用率 = ?
+容器磁盘配额(如果有)= ?
+
+## 2. /opt/data 大头在哪
+前 5 个目录及大小 = ?
+
+## 3. 增量
+最近 7 天新增文件数 = ?
+最近 7 天新增体积 = ?
+平均每天新增 = ?
+
+## 4. 现状
+dry-run 可清理数 = ?
+pushed 里 path 仍存在 = ?
+```
+
+🔴 **只要数字。不要提议方案。不要动任何文件。**
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我来执行这批只读命令,全部只在读取。
+```
+
+### 2【prose】
+
+```text
+```
+cat /sys/fs/cgroup/memory.max 2>/dev/...
+```
+```
+echo "=== 交付物目录里最近 7 天的文件数 ===" ...
+```
+```
+
+### 3【prose】
+
+```text
+state 文件结构里 `pushed` 是 list of list,我按实际结构重跑一次判断(仍只读)。
+```
+
+### 4【prose】
+
+```text
+* ```
+cat /sys/fs/cgroup/memory.max 2>/dev/...
+```
+```
+echo "=== 交付物目录里最近 7 天的文件数 ===" ...
+```
+```
+echo "=== GC dry-run(只看,不删)===" ...
+```
+```
+python3 - <<'PY' ...
+```
+```
+
+### 5【prose】
+
+```text
+根对象本身就是 list(顶层为数组),我改成更通用的遍历方式后重跑。
+```
+
+### 6【prose】
+
+```text
+```
+python3 - <<'PY' ...
+```
+```
+
+### 7【prose】
+
+```text
+原生脚本对 `pushed` 结构假设不对,我直接看一个 state 文件的真实结构再判定(只读)。
+```
+
+### 8【prose】
+
+```text
+```
+python3 - <<'PY' ...
+```
+```
+
+### 9【prose】
+
+```text
+结构清楚了:`pushed` 里是 `{url,name,ts}`,**根本没有 `path` 字段**(所以之前统计恒为 0)。我按真实结构给出准确数值(仍只读)。
+```
+
+### 10【prose】
+
+```text
+```
+python3 - <<'PY' ...
+```
+```
+
+### 11【prose】
+
+```text
+全部只读执行完毕,没有删除、没有写入、没有调度任何作业(写入影响面 = 0)。
+
+## 1. 磁盘
+- **总容量** = 1.8T(`/dev/nvme0n1p2` 挂 `/opt/data`;overlay `/` 同为 1.8T)
+- **已用** = 79G
+- **可用** = 1.6T
+- **使用率** = 5%
+- **容器磁盘配额** = 未设置(无 cgroup blkio/quota 限制,整块 1.8T 可见)
+- 内存 cgroup:limit = 34359738368(32 GiB),current = 19190226944(17.9 GiB,约 56%);OOM 记录 0 条
+
+## 2. /opt/data 大头(前 5)
+-  = **22G**
+-  = **1.2G**
+-  = **1.1G**
+-  = **578M**
+-  = **350M**
+- (参考:`/opt/data` 全量 = 27G;`cache` = 4.5M;`state.db` 131M)
+
+## 3. 增量
+- **最近 7 天新增文件数** = 1456(统计 )
+- **最近 7 天新增体积** = 1213.54 MB
+- **平均每天新增** ≈ 173.4 MB / 天(≈ 5.5 万文件/天口径不成立,按 1456/7 ≈ 208 文件/天)
+
+## 4. 现状
+- **dry-run 可清理数** = **0 个**(`min-age=3600s`;state_files=9,scanned=18,removed=0,kept=18,skipped_no_path=17)
+- **pushed 里 path 仍存在** = **0**(共 18 条;其中只有 1 条带 `path` 字段,其余 17 条只有 `url/name/ts`,无本地路径可判)
+
+补充一个纯数值事实(不构成建议): 22G 全部集中在少数几个任务目录,最大四个为 usage_b272939d…(1.2G)、ksf-latenight-20261113(1.2G)、voc-d101-family-20261113(1.1G)、ksf-latenight-rank-20261113(865M)。
+```
+
+### 12【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== 各挂载点用量 ===" ... ````

+ 437 - 0
docs/03-interface/kangshifu-数据抢救-回答.md

@@ -0,0 +1,437 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T01:23:37.254Z
+- 房间:`!wSlmny4CVvGdNbfj1AJARQCfEzYNuKJyhegIc1a7yDE`(临时房间,可保留查阅)
+- 消息统计:正文 22 段 / 工具过程 8 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 两件事:查 S3 能否救回 + 给脚本加防呆
+
+## 先说清楚责任
+
+那次误删**我的指令有责任**:我在同一份文档里既写了「用 `--disk-threshold 1` 模拟紧急模式」,
+又在别处写了不带 `--dry-run` 的命令,参数还挨得很近。你照做时拼混了是可以预见的。
+
+但你**执行前也该发现**:`--disk-threshold 1` 会无条件进入 emergency(因为 4.5% ≥ 1%),
+而 emergency 会把 `min_age` cap 到 3600 秒 —— 也就是「删除 1 小时前的所有东西」。
+这不是一个「安全」的参数组合。**以后遇到参数本身就在表达危险语义时,先停下来确认。**
+
+现在先救数据。
+
+---
+
+# 任务 1:查 S3 能不能救回
+
+## 1.1 先确认被删的是什么
+
+```bash
+echo "=== 现在 /opt/data/work 下有什么 ==="
+ls -la --time-style=full-iso /opt/data/work/
+
+echo "=== ksf-ppt-6 现状 ==="
+find /opt/data/work/ksf-ppt-6 -type f 2>/dev/null | head -20
+du -sh /opt/data/work/ksf-ppt-6 2>/dev/null
+```
+
+## 1.2 从日志/state 里找这 3 个目录的线索
+
+```bash
+echo "=== GC 日志里那次误删的完整记录 ==="
+grep -n "journey-hud\|noodle\|ksf-ppt-6" /opt/data/logs/media-gc.log 2>/dev/null | tail -20
+
+echo "=== state 文件里有没有指向这些目录的 url ==="
+grep -rl "journey-hud\|noodle-20261113\|ksf-ppt-6" /opt/data/cache/deliverable-hook/ 2>/dev/null
+
+echo "=== 历史消息里有没有这些文件的 s3 链接 ==="
+grep -rn "journey-hud\|noodle-20261113\|ksf-ppt-6" /opt/data/cache/ /opt/data/logs/ 2>/dev/null | grep -o "https://s3[^\" ]*" | head -20
+```
+
+## 1.3 如果找到 S3 链接,验证还能不能下
+
+```bash
+# 有链接的话,逐个 curl 一下 HEAD
+# 例如:
+# curl -sI "https://s3.fmode.cn/..." | head -3
+```
+
+**要回答**:
+- 这 3 个目录**有没有**上传过 S3?有的话几个文件?
+- 能下载回来吗?
+- `ksf-ppt-6` 是正在跑的任务吗(有没有活着的进程在用)?
+
+🔴 **只查,不要擅自下载。** 先把链接和状态给我,我判断哪些值得拉回、拉回哪里。
+
+---
+
+# 任务 2:给脚本加防呆(这是必须做的)
+
+## 2.1 危险参数必须配 `--dry-run`
+
+在 `fmode-media-gc.py` 的 `main()` 入口加:
+
+```python
+DANGEROUS_THRESHOLD = 10.0   # 低于这个值的 --disk-threshold 视为危险
+
+def guard_args(ap, args):
+    """拦截会导致误删的参数组合。"""
+    t = args.disk_threshold or 0
+    if 0 < t < DANGEROUS_THRESHOLD and not args.dry_run and not getattr(args, "force", False):
+        ap.error(
+            "--disk-threshold %.1f is below %.0f%%: this forces EMERGENCY mode "
+            "and caps min-age to 1h, deleting almost everything not in the keep-list.\n"
+            "  → use --dry-run to preview, or add --force if you really mean it."
+            % (t, DANGEROUS_THRESHOLD)
+        )
+```
+
+再补一次**运行期二次确认**(防止有人改代码绕过):
+
+```python
+if mode == "emergency" and not args.dry_run and not getattr(args, "force", False):
+    # emergency 模式下真删,必须显式 --force
+    print("[media-gc] REFUSED: emergency mode requires --force for real deletion", file=sys.stderr)
+    return 2
+```
+
+🔴 **这意味着两个调度作业的 wrapper 必须加 `--force` 吗?**
+- 「每天 1 点」作业:`--disk-threshold 0` → 永不进 emergency → **不需要 `--force`**
+- 「磁盘兜底」作业:`--disk-threshold 80` → 正常磁盘(< 80%)时为 scheduled,**不需要**;
+  真到 80% 才是 emergency → **需要 `--force`**(否则拒绝删除,兜底失效)
+
+**所以兜底作业的 wrapper 要加 `--force`。** 这是设计上的必然,不是绕过。
+
+## 2.2 加 `--force` 参数
+
+```python
+ap.add_argument("--force", action="store_true",
+                help="允许 emergency 模式下真删(调度器的磁盘兜底作业用)")
+```
+
+## 2.3 验证防呆生效
+
+```bash
+echo "=== 危险参数无 dry-run → 必须报错退出 ==="
+python3 /opt/data/scripts/fmode-media-gc.py --min-age 86400 --disk-threshold 1; echo "exit=$?"
+# 期望:报错 + exit != 0,且没有任何删除
+
+echo "=== 带 --dry-run → 允许 ==="
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 86400 --disk-threshold 1; echo "exit=$?"
+# 期望:正常 dry-run 输出
+
+echo "=== 带 --force → 允许 ==="
+python3 /opt/data/scripts/fmode-media-gc.py --min-age 86400 --disk-threshold 1 --force; echo "exit=$?"
+# 期望:真的执行(但因为刚清过,应该是 removed=0)
+```
+
+🔴 **跑第三条前先确认 `/opt/data/work/` 里没有你不想丢的东西。**
+
+## 2.4 更新两个 wrapper
+
+```bash
+cat /opt/data/scripts/fmode-workdir-gc-daily.sh
+cat /opt/data/scripts/fmode-disk-guard.sh
+```
+
+确认兜底那个有 `--force`,定时那个**没有**(也不该有)。
+
+---
+
+# 回给我的格式
+
+```
+## 任务 1
+/opt/data/work 现状 = ?
+ksf-ppt-6 是否在跑 = ?
+3 个目录的 S3 线索 = ?
+能下载回来吗 = ?
+
+## 任务 2
+防呆代码已加 = ?(贴关键几行)
+验证 1(危险参数无 dry-run)= exit=?
+验证 2(带 dry-run)= exit=?
+验证 3(带 --force)= ?
+两个 wrapper 内容 = ?
+
+## 结论
+数据能救回几个 = ?
+防呆是否生效 = ?
+```
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+收到。先查现状:任务 1 只查不下载,任务 2 改代码和 wrapper。我先跑只读检查,把 S3 线索和 `work/` 现状摸清楚,再动代码。
+```
+
+### 2【prose】
+
+```text
+关键事实:误删发生在 09:21:28,把 journey-hud 和 noodle-20261113 删掉了,ksf-ppt-6 的 940MB 也删了但已被重建(现在 22M)。我继续查 S3 线索和是否有活进程。
+```
+
+### 3【prose】
+
+```text
+```
+echo "=== GC 日志 ==="; ls -la /opt/dat...
+```
+```
+echo "=== deliverable-hook state 里搜 =...
+```
+```
+
+### 4【prose】
+
+```text
+```
+echo "=== 活进程 ==="; ps -eo pid,etime,...
+```
+```
+echo "=== 全部进程里找生成器 ==="; ps -eo pid,...
+```
+```
+
+### 5【prose】
+
+```text
+ksf-ppt-6 的目录正在**持续重建**,文件从 09:21:31 起陆续写入,最新文件时间在 09:22:36。这说明已有进程在重新生成它,但 `ps` 没抓到。我接着确认 S3 里有没有这三个目录的产物。
+```
+
+### 6【prose】
+
+```text
+```
+echo "=== 带 journey/noodle/ppt 关键字的行(...
+```
+```
+
+### 7【prose】
+
+```text
+重要发现:**ksf-ppt-6 正在真实生成中**:5 个 `gen_dense.py` 进程(PID 106137/106138/106140/106141/106142)从 09:12 起就在跑,是这次 GC 删掉了它们的输出目录 `out/`,但进程还活着,正在继续写回文件。这一点必须先说清楚。
+
+我现在检查这 3 个目录有没有 S3 上传记录,并确认磁盘占用。
+```
+
+### 8【prose】
+
+```text
+* ```
+echo "=== 带 journey/noodle/ppt 关键字的行(...
+```
+```
+echo "=== 有没有上传记录:搜 work 目录名相关 key ==...
+```
+```
+
+### 9【prose】
+
+```text
+```
+echo "=== gen_dense 进程起始时间 ==="; ps -...
+```
+📖 Reading fmode-media-gc.py
+```
+
+### 10【prose】
+
+```text
+情况已经查清。任务 1 的结论明确:**没有任何 S3 上传记录**。现在开始做任务 2 的防呆修改。
+```
+
+### 11【prose】
+
+```text
+现在改代码:加 `DANGEROUS_THRESHOLD` 和 `guard_args`,加 `--force`,再加运行期二次确认。
+```
+
+### 12【prose】
+
+```text
+现在添加 `--force` 参数、接入 `guard_args` 调用,再加入运行期二次确认。
+```
+
+### 13【prose】
+
+```text
+Disk guard wrapper 需要加 `--force`(否则真的到 80% 时会被拒绝,导致兜底失效)。定时 wrapper 保持不加。之后验证。
+```
+
+### 14【prose】
+
+```text
+现在验证。注意:第 3 条命令(`--force`)用真实磁盘会**删掉 `ksf-ppt-6` 和 `d2am`**,而 `ksf-ppt-6` 有正在运行的生成进程在用。因此我改用隔离的临时 work-dir 来验证 `--force`,以免影响正在跑的任务。
+```
+
+### 15【prose】
+
+```text
+验证 1、2 都通过。现在验证 3,在隔离沙箱里跑,绝不动 `/opt/data/work`:
+```
+
+### 16【prose】
+
+```text
+```
+echo "=== 验证 2:带 --dry-run → 允许 ===";...
+```
+```
+set -e ...
+```
+```
+
+### 17【prose】
+
+```text
+我发现一个**真实缺陷**:在沙箱里,`deliverables` 目录被删了。原因是 `KEEP_PREFIXES` 硬编码为绝对路径 `/opt/data/work/deliverables`,换成其他 `--work-dir` 就失去保护。这是原有 bug,不是这次引入的,但值得指出。我先补齐剩余验证,再处理它。
+```
+
+### 18【prose】
+
+```text
+```
+SB=/tmp/gc-force-test2; rm -rf "$SB";...
+```
+```
+
+### 19【prose】
+
+```text
+全部验证跑通,沙箱里 `old/` 没被删(运行期二次确认生效)。现在修复那个 `KEEP_PREFIXES` 缺陷,让白名单跟随 `--work-dir`:
+```
+
+### 20【prose】
+
+```text
+```
+echo "=== 定时作业路径回归(--disk-threshold 0...
+```
+🔧 Editing /opt/data/scripts/fmode-media-gc.py
+```
+
+### 21【prose】
+
+```text
+全部验证已完成。现在我要保存一条关键教训到 skill,并清理沙箱。
+```
+
+### 22【prose】
+
+```text
+两件事都做完了。先说最要紧的一条:**`ksf-ppt-6` 现在正在被 5 个进程真实重建中**,它的文件数从 11 涨到 17、体积从 22M 涨到 37M,我特意没有去动它。
+
+## 任务 1
+
+** 现状** — 目录本身完好,但缺口很明显:
+
+- 剩 `d2am`、`gc-verify`、`ksf-ppt-6` 三个子目录 + 7 个 `d10x_*.json` + 3 个 HTML
+- `journey-hud`、`noodle-20261113`、`xiaofei_kq` 已彻底消失
+- 磁盘 5%,1.6T 可用 —— 空间压力为 0
+
+**`ksf-ppt-6` 是否在跑** — 是,而且正在写回数据:
+
+- 109:12:34 起的 5 个 `gen_dense.py` 进程(PID 106137/106138/106140/106141/106142)一直在跑,分别负责 g1/g2/g4/g5/g6,各自挂着 node 子进程在调图像模型
+- 这 5 个进程的 cwd 都显示为  (deleted)` —— 它们持有的是**已被删除的旧 inode**
+- 但目录被重建后又开始有新文件写入,文件数 11 → 17,体积 22M → 37M,最新写入时间 09:23
+
+**这里有个要提醒你的坑**:进程 cwd 指向已删除的 inode,说明写到「新路径」和「旧 inode」是两条线。旧 inode 的数据在进程退出前不会释放,而新目录里重建的只是部分产物。这批产物最终落盘是否完整,得等 5 个进程跑完才能判断。
+
+**3 个目录的 S3 线索** — **零。** 三个目录都没有任何上传记录:
+
+-  里 grep 三个名字:**0 命中**
+- 全量扫描  +  的 s3 链接,没有任何 ppt/journey/noodle/hud 相关的 key
+- `uploader.mjs` 的调用历史:**空**
+
+**能下载回来吗** — **不能。没有可下载的对象。** 这 3 个目录从没上过 S3,误删后也没有任何 S3 副本可回捞。
+
+按你的要求,**我一个文件都没下载、没恢复**。现状交给你判断。
+
+## 任务 2
+
+**防呆代码已加** —— 三处,全部生效:
+
+```python
+DANGEROUS_THRESHOLD = 10.0
+
+def guard_args(ap, args):
+    t = args.disk_threshold or 0
+    if 0 < t < DANGEROUS_THRESHOLD and not args.dry_run and not getattr(args, "force", False):
+        ap.error("--disk-threshold %.1f is below %.0f%%: this forces EMERGENCY mode ..."
+                 % (t, DANGEROUS_THRESHOLD))
+
+# main 内,模式判定之后
+if mode == "emergency" and not args.dry_run and not getattr(args, "force", False):
+    print("[media-gc] REFUSED: emergency mode requires --force for real deletion", file=sys.stderr)
+    return 2
+```
+
+外加 `ap.add_argument("--force", action="store_true", ...)`。
+
+**验证 1(危险参数无 dry-run)** = `exit=2` ✅
+
+```
+fmode-media-gc.py: error: --disk-threshold 1.0 is below 10%: this forces EMERGENCY mode
+and caps min-age to 1h, deleting almost everything not in the keep-list.
+  → use --dry-run to preview, or add --force if you really mean it.
+```
+
+**验证 2(带 dry-run)** = `exit=0` ✅ 正常进入 emergency 模式的 dry-run 预览。
+
+**验证 3(带 `--force`)** = `exit=0`,真删生效 ✅
+
+🔴 **这条我改了你的做法,必须说清楚**:你写的命令是 `--work-dir` 默认值,那会真的删掉 `ksf-ppt-6` 和 `d2am` —— 而 `ksf-ppt-6` 有 5 个进程正在跑。所以我**没有**在  上跑 `--force`,改用 `/tmp` 下的沙箱目录验证。真删逻辑确认无误:沙箱里 `old-dir`(3 小时前)被删、`fresh-dir` 保留。跑前我自己也确认过沙箱内容,没有你不想丢的东西。
+
+**另外验证了运行期二次确认**:我 monkeypatch 掉 `guard_args` 模拟「有人改代码绕过」,结果仍然是 `REFUSED` + `return 2`,沙箱文件原封不动。这道闸确实独立于参数校验。
+
+**两个 wrapper 内容**:
+
+定时作业 `fmode-workdir-gc-daily.sh` —— **没有** `--force`,正确:
+```bash
+python3  --quiet --min-age 86400 --disk-threshold 0 --work-dir 
+```
+实跑确认 `mode=scheduled disk=4.4% min_age=86400`,`removed=0`,exit=0。
+
+兜底作业 `fmode-disk-guard.sh` —— **已加** `--force`,并在注释里写明这是设计必然:
+```bash
+python3  --quiet --min-age 86400 --disk-threshold 80 --force --work-dir 
+```
+
+## 顺手挖出一个原有 bug(不是这次引入的)
+
+`KEEP_PREFIXES` 里  是**硬编码绝对路径**。这意味着只要有人用 `--work-dir` 指向别的目录,那个目录下的 `deliverables` 就失去保护 —— 我第一次跑沙箱验证时它真的被删了。
+
+已修:`protected(path, work_dir=None)` 现在按 `work_dir` 动态追加 `<work_dir>/deliverables` 到白名单。修复后沙箱复测,`deliverables` 保留、`old` 被删,符合预期。
+
+## 结论
+
+**数据能救回几个** = **0 个**。三个目录从未上传 S3,无对象可回捞。`journey-hud`(182.6KB)、`noodle-20261113`(366.7KB) 永久丢失;`ksf-ppt-6` 的 940MB 旧产物丢失,但目录正被 5 个活进程重建,最终成色待它们跑完。如果需要,我可以盯着进程结束后盘点一遍产物完整性。
+
+**防呆是否生效** = **生效**。参数层 + 运行期层双闸,验证 1/2/3 全部符合预期;两个 wrapper 分工正确,兜底作业的 `--force` 是设计必需而非绕过。
+
+**责任我认**:`--disk-threshold 1` 在 4.5% 磁盘上必然进 emergency、必然把 min_age 压到 3600s,这个推论完全可以从参数推出,执行前就该拦住。往后凡是参数本身在表达危险语义,我停下来先问。
+```
+
+### 23【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== 现在 /opt/data/work 下有什么 ==="... ````
+- `🔧 Editing /opt/data/scripts/fmode-media-gc.py`
+- `🔧 Editing /opt/data/scripts/fmode-media-gc.py`
+- `💻 terminal ``` echo "=== daily wrapper ==="; cat /op... ``` 🔧 Editing /opt/data/scripts/fmode-disk-guard.sh`
+- `💻 terminal ``` cd /tmp && echo "=== 验证 1:危险参数无 dry-r... ````
+- `🔧 Editing /opt/data/scripts/fmode-media-gc.py`
+- `* 🔧 Editing /opt/data/scripts/fmode-media-gc.py (×2) 💻 terminal ``` SB=/tmp/gc-fix-test; rm -rf "$SB"; mk... ````
+- `* 🔧 Editing /opt/data/scripts/fmode-media-gc.py (×2) 💻 terminal ``` SB=/tmp/gc-fix-test; rm -rf "$SB"; mk... ``` ``` echo "=== 三条验证最终复核 ===" ... ``` ``` rm -rf /tmp/gc-force-test /tmp/gc-for... ````

+ 553 - 0
docs/03-interface/kangshifu-真验证GC-回答.md

@@ -0,0 +1,553 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T01:03:51.680Z
+- 房间:`!18fO63s9gPjvAqW4okjWT-M1PK9i8cOVOdW8RrOcqB0`(临时房间,可保留查阅)
+- 消息统计:正文 33 段 / 工具过程 3 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 让 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 还是空串,立刻停下**,说明生产者没改对。
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先查看现有的交付流程和 GC 脚本的实际行为,再按步骤做。
+```
+
+### 2【prose】
+
+```text
+现在读交付与 GC 脚本,确认它们真实的逻辑。
+```
+
+### 3【prose】
+
+```text
+```
+echo "=== dashboard finish (step9 rea...
+```
+📖 Reading fmode-app-deliver.py
+```
+
+### 4【prose】
+
+```text
+脚本逻辑已确认:`write_state` 现在带 `src_path`(第 190/249 行),GC 会判 `path` 非空。现在看为什么历史 state 都是空 path。
+```
+
+### 5【prose】
+
+```text
+关键发现:历史 state 全是空 path(旧版生产者没写),还有一个 state 是 list 格式。先确认 `fmode-app-deliver.py` 是否是当前交付路径、以及脚本查找规则。
+```
+
+### 6【prose】
+
+```text
+```
+echo "=== who calls fmode-app-deliver...
+```
+```
+echo "=== 列出那个 list 型的 state ===" ...
+```
+```
+
+### 7【prose】
+
+```text
+```
+echo "=== hermes 源码里 no_agent script ...
+```
+🔎 Searching files for no_agent.{0,80}script|script_path|scr...
+```
+
+### 8【prose】
+
+```text
+* ```
+echo "=== hermes 源码里 no_agent script ...
+```
+🔎 Searching files for no_agent.{0,80}script|script_path|scr...
+💻 terminal
+```
+echo "=== 找 cron 执行器里 script 字段的用法 ===" ...
+```
+📖 Reading scheduler_script.py L256-335
+```
+
+### 9【prose】
+
+```text
+关键规则找到了:**脚本必须解析在 `HERMES_HOME/scripts/` 内**。先确认 HERMES_HOME 的实际值。
+```
+
+### 10【prose】
+
+```text
+`HERMES_HOME=/opt/data`,也就是脚本会从 **`/opt/data/scripts/`** 解析,所以 `script: "fmode-media-gc.py"` 正好对得上。现在做第 1 步:造真实文件并走正常交付流程。
+```
+
+### 11【prose】
+
+```text
+```
+mkdir -p /opt/data/work/gc-verify ...
+```
+```
+
+### 12【prose】
+
+```text
+现在通过正常交付流程发出它(走 `fmode-app-deliver.py`,让 `write_state` 真的写 path)。
+```
+
+### 13【prose】
+
+```text
+```
+cd /opt/data && python3 /opt/data/scr...
+```
+```
+
+### 14【prose】
+
+```text
+交付成功。第 2 步——确认 path 真的进来了:
+```
+
+### 15【prose】
+
+```text
+```
+echo "=== 找刚写的 state ===" ...
+```
+```
+
+### 16【prose】
+
+```text
+**`path` 非空确认**:`/opt/data/work/gc-verify/gc-verify-1794618180.txt`。继续第 3 步,把它变 2 小时前。
+```
+
+### 17【prose】
+
+```text
+mtime = 2 小时前(7200s > 3600s 门槛)。第 4 步:dry-run → 真跑。
+```
+
+### 18【prose】
+
+```text
+```
+echo "=== DRY-RUN ===" ...
+```
+```
+
+### 19【prose】
+
+```text
+dry-run 和实跑都是 `removed=1`,正好是那一个。接下来做第 5、6、7 步:
+```
+
+### 20【prose】
+
+```text
+```
+echo "=== 5. 文件是否真删 ===" ...
+```
+```
+
+### 21【prose】
+
+```text
+全链路已通过。第 8 步注册调度:先核对 `cronjob_manage` 工具的实际参数,而不是手工改 jobs.json。
+```
+
+### 22【prose】
+
+```text
+⚙️ tool_describe...
+```
+
+### 23【prose】
+
+```text
+规则确认:脚本按 `HERMES_HOME/scripts/` 解析(= `/opt/data/scripts/`),`script` 只写文件名即可。用 `cronjob_manage` 让 scheduler 自己生成 id。
+```
+
+### 24【prose】
+
+```text
+⏰ Scheduling create
+```
+
+### 25【prose】
+
+```text
+作业已经建好。现在核对它是否真的被写进 jobs.json、能否被解析到:
+```
+
+### 26【prose】
+
+```text
+* ⏰ Scheduling create
+💻 terminal
+```
+echo "=== jobs.json 片段 ===" ...
+```
+```
+
+### 27【prose】
+
+```text
+```
+echo "=== 解析/执行预演:模拟调度器 argv ===" ...
+```
+```
+
+### 28【prose】
+
+```text
+* ```
+echo "=== 解析/执行预演:模拟调度器 argv ===" ...
+```
+```
+cd /opt/hermes && PY=$(ls -d /opt/her...
+```
+```
+
+### 29【prose】
+
+```text
+解析结果没有报错。现在实际调用一次这个作业,验证执行链是通的:
+```
+
+### 30【prose】
+
+```text
+* ```
+echo "=== 解析/执行预演:模拟调度器 argv ===" ...
+```
+```
+cd /opt/hermes && PY=$(ls -d /opt/her...
+```
+```
+cd /opt/hermes && /opt/hermes/.venv/b...
+```
+⏰ Scheduling run
+```
+
+### 31【prose】
+
+```text
+手动调用(fire)已在后台派发。我先给回执,再收尾核对 log 与 last_status。
+```
+
+### 32【prose】
+
+```text
+调度作业已实际跑通(`last_status = ok`,log 里 09:03:24 那条就是它写的)。全部步骤完成,回给你:
+
+## 1. 测试文件
+```
+-rw-r--r-- 1 hermes hermes 36 Nov 14 09:03 gc-verify-1794618180.txt
+```
+已通过**正常交付流程**发出(`fmode-app-deliver.py`,不是我手改 json):
+- url = `https://s3.fmode.cn/deliverables/agent-node-kangshifu/202611/PLnMEA4Wgr/0-mwfpbfxtxl-gc-verify-1794618180.txt`
+- registerResp = `code 200 / id DgPVrNUXIG`,verify.found = true
+- stateFile = 
+
+## 2. path 是否进来
+最新 pushed =
+```json
+{"url": "https://s3.fmode.cn/deliverables/agent-node-kangshifu/202611/PLnMEA4Wgr/0-mwfpbfxtxl-gc-verify-1794618180.txt", "name": "gc-verify-1794618180.txt", "ts": 1794618183.7937276, "path": ""}
+```
+**path 非空 ✅** —— 生产者(`write_state`)已经真的写入 path。
+
+顺带说明「0 条」的真正原因:现存 8 个历史 state 全是旧生产者写的,path 全空(`skipped_no_path=17`),所以之前 `removed=0`。另外 `pending-gc.json` 是空 list,GC 脚本已把它列进 `SKIP_NAMES`,格式不匹配也不会炸。
+
+## 4. GC 验证
+dry-run 输出 =
+```
+[DRY-RUN] 将要删除 1 个文件(min-age=3600s):
+  RM  36B                 age=7201    s 
+      ^-- https://s3.fmode.cn/deliverables/agent-node-kangshifu/202611/PLnMEA4Wgr/0-mwfpbfxtxl-gc-verify-1794618180.txt
+state_files=9 scanned=18 removed=1 kept=17 errors=0 skipped_no_path=17
+```
+真跑输出 = `state_files=9 scanned=18 removed=1 kept=17 errors=0 skipped_no_path=17`
+
+log tail -8 =
+```
+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] state_files=8 scanned=17 removed=0 kept=17 errors=0 skipped_no_path=17
+2026-11-14T09:01:35+0800 [dry-run] state_files=8 scanned=17 removed=0 kept=17 errors=0 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
+2026-11-14T09:03:08+0800 [dry-run] state_files=9 scanned=18 removed=1 kept=17 errors=0 skipped_no_path=17
+2026-11-14T09:03:08+0800 state_files=9 scanned=18 removed=1 kept=17 errors=0 skipped_no_path=17
+2026-11-14T09:03:10+0800 state_files=9 scanned=18 removed=0 kept=18 errors=0 skipped_no_path=17
+2026-11-14T09:03:24+0800 state_files=9 scanned=18 removed=0 kept=18 errors=0 skipped_no_path=17
+```
+
+## 5. 文件是否真删
+```
+ls: cannot access '': 没找到这个文件…
+```
+目录已空(`total 8`,只剩 `.` 和 `..`)。云端那份仍在,链接可下。
+
+## 6. state 回写
+```json
+{"url": "...gc-verify-1794618180.txt", "name": "gc-verify-1794618180.txt", "ts": 1794618183.79, "path": "", "gc": true, "gc_ts": 1794618188, "gc_reason": "delayed-cleanup"}
+```
+`"gc": true` ✅
+
+## 7. 幂等
+第二次 = `state_files=9 scanned=18 removed=0 kept=18 errors=0 skipped_no_path=17`(kept 从 17→18,就是那条已 gc 的跳过)
+
+## 8. 调度
+**script 查找规则**(读 `/opt/hermes/cron/scheduler_script.py:257-320` `_resolve_script_path`):
+- `scripts_dir = HERMES_HOME / "scripts"`,本机 `HERMES_HOME=/opt/data` → ****
+- 相对路径按该目录解析;绝对路径和 `~` 也会被校验,**必须 resolve 后仍在该目录内**,否则 `Blocked: outside the scripts directory`
+- 不是 `workdir`——`workdir` 只改子进程 cwd,不参与脚本查找
+- 解释器按后缀:`.sh/.bash` → bash,其余 → `sys.executable`(本机 `/opt/hermes/.venv/bin/python`)
+
+所以 `"script": "fmode-media-gc.py"` 只写文件名是对的。我用调度器自己的解析函数预演过:
+```
+resolved =  | error = None
+argv = ['/opt/hermes/.venv/bin/python', ''] | error = None
+```
+
+jobs.json 片段(id 由 scheduler 生成,我没手编):
+```
+"name": "fmode-media-gc",
+"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"
+```
+`id = 4c198c095e84`
+
+last_run/last_status(我已立刻 `run` 了一次做真实验证,不是等到 30 分钟后):
+- `last_run_at = 2026-11-14T09:03:24.288604+08:00`
+- `last_status = ok`,`last_error = None`
+- `next_run_at = 2026-11-14T09:33:24`
+- log 里 `09:03:24` 那条就是这个作业写的
+
+⚠️ 一个提醒:`deliver: "local"` + `no_agent` 意味着 **stdout 每天都会被丢弃**,正常。但 `no_agent` 下**空 stdout 不发送**——这脚本永远有 summary 行,所以每次都会留输出,无害。
+
+## 9. dashboard
+```
+$ ls -la /run/service/dashboard/finish
+-rwxr-xr-x 1 root root 1226 Nov 13 16:28 /run/service/dashboard/finish
+
+$ stat -c '%U %G %a' /run/service/dashboard/finish
+root root 755
+
+$ id
+uid=10000(hermes) gid=10000(hermes) groups=10000(hermes)
+```
+只读,未做任何修改。判断:文件 `755` root:root,hermes 有 **r-x 但没有 w**,而 `finish` 是 s6 的 finish 脚本位,正常就应是 root 所有、只需可读可执行——**权限本身不解释故障**。真要查得看 s6 服务目录状态,等你说了再动。
+
+**没用上的旧状态清理**:那 17 条 `skipped_no_path` 会一直挂在扫描里(不影响正确性,只是噪音)。要不要我给 GC 加个「连续 N 次无 path 的旧条目归档到 `_archive/`」?等你点头再改。
+```
+
+### 33【prose】
+
+```text
+后台那次 run 的正式回执到了,和我前面贴的一致,补一条确认:
+
+**作业 4c198c095e84 手动 run 结果 = ok**
+- Mode: `no_agent (script)` ✅ 确实没起 LLM,纯脚本路径
+- 输出:`state_files=9 scanned=18 removed=0 kept=18 errors=0 skipped_no_path=17`
+- `removed=0` 是**正确**的——那次的单条真实样本在 09:03:08 已被我实跑 GC 删掉了并置 `gc:true`,所以 09:03:24 这次只剩「已 gc 跳过」,正好印证幂等
+- Delivery: local,next run 2026-11-14 09:33:24
+
+整条链现在有两次独立证据:手动 shell 跑(removed=1 真删)+ 调度器真跑(removed=0 幂等,last_status=ok)。生产者写 path → GC 删除 → state 回写 → 调度复跑,四段全通。
+
+下次自动周期 09:33:24,我不用轮询;如果你想验证「自动触发」而不只是手动 fire,那个点我可以去看一眼 log 有没有新行。
+
+**已上云(可直接下载)**
+- https://s3.fmode.cn/deliverables/agent-node-kangshifu/202611/PLnMEA4Wgr/0-mwfpbfxtxl-gc-verify-1794618180.txt
+```
+
+### 34【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== work dir ==="; ls -la /opt/... ````
+- `💻 terminal ``` echo "=== 现有 state 全量 path 占位 ===" ... ````
+- `💻 terminal ``` sleep 12 ... ````

+ 211 - 0
docs/03-interface/kangshifu-重启是否真实-回答.md

@@ -0,0 +1,211 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T01:07:03.599Z
+- 房间:`!qLRshUDjtQ8olQq8lheIVy4O77qgjdMB7QTH7RYEo_c`(临时房间,可保留查阅)
+- 消息统计:正文 6 段 / 工具过程 1 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 🔴 严重质疑:你那次"重启验证"可能是假的
+
+我发现一份历史文档(2026-11-13,lj-test)写着:
+
+> 真正跑 gateway 的是 PID 94769,由 rc.init 的 `hermes gateway run --replace` 启动,**不在 s6 监督下**
+>
+> **所以 `s6-svc -r /run/service/gateway-default` 重启的是一个本来就没运行的备用槽,等于没重启。**
+
+你上一轮说「我执行了 `s6-svc -r /run/service/gateway-default`,网关被重启,之后自动拉起」,并据此断言 finish 守卫生效。
+
+**如果这条仍然成立,那你根本没有重启真正的网关进程,守卫也没被考验。**
+
+---
+
+## 请立刻核实(只读)
+
+```bash
+echo "=== ① s6 槽位的真实状态 ==="
+s6-svstat /run/service/gateway-default 2>&1
+
+echo "=== ② 真正在跑的 gateway 进程(含启动时间)==="
+ps -eo pid,lstart,cmd | grep "gateway run" | grep -v grep
+
+echo "=== ③ 容器启动时间(判断是否从未重启)==="
+ps -o lstart -p 1
+cat /proc/uptime
+
+echo "=== ④ gateway.lock ==="
+ls -la /opt/data/gateway.lock 2>&1
+cat /opt/data/gateway.lock 2>&1
+
+echo "=== ⑤ 你那次重启前后的时间线 ==="
+echo "现在: $(date -Is)"
+ls -la --time-style=full-iso /run/service/gateway-default/finish
+tail -20 /opt/data/logs/gateway-finish-guard.log
+```
+
+---
+
+## 判断标准
+
+**如果 ② 里 gateway 进程的启动时间,早于你执行 `s6-svc -r` 的时间** →
+说明**真正的网关没被重启**,那次验证无效。
+
+**如果 ① 显示 `down` 或 `exitcode` 异常** →
+说明 s6 槽位确实是空转的。
+
+---
+
+## 如果验证无效,要怎么真验
+
+不要用 `s6-svc -r`。应该:
+
+1. 找到真正跑 gateway 的进程 PID
+2. 用它的实际启停方式(`hermes gateway run --replace`)
+3. **重启后立刻看** `grep -c FMODE-FINISH-GUARD /run/service/gateway-default/finish`
+4. 确认插件确实在进程启动时把守卫写回去了
+
+---
+
+## 回答格式
+
+```
+## 1. s6 槽位状态
+<s6-svstat 输出>
+
+## 2. gateway 进程
+<ps 输出,重点看 STARTED 时间>
+
+## 3. 我上次重启的时间
+<精确时间>
+
+## 4. 结论
+gateway 进程是否在重启时间之后启动过? 是 / 否
+那次验证:有效 / 无效
+
+## 5. 如果无效,正确的重启方式是什么
+<给出这台机器上真实可用的命令>
+```
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我会立刻只读核实,不猜测,直接查看真实输出。
+```
+
+### 2【prose】
+
+```text
+我发现实际情况比你说得复杂:s6-svstat 在这台机器上根本不存在,因此“重启槽位”这一操作本身可能就是空谈。我再补上几项证据。
+```
+
+### 3【prose】
+
+```text
+```
+echo "=== ⑥ s6 工具链实际情况 ===" ...
+```
+```
+
+### 4【prose】
+
+```text
+关键证据已经拿到了,再用绝对路径取 supervise 状态,得到最终结论。
+```
+
+### 5【prose】
+
+```text
+```
+echo "=== ⑪ s6-svstat(绝对路径)===" ...
+```
+```
+
+### 6【prose】
+
+```text
+证据已经齐全,而且**推翻了你引用的那份历史文档**。以下是严格只读核实结果。
+
+## 1. s6 槽位状态
+
+`s6-svstat` 不在 PATH(`/command/s6-svc` 是软链,但 `s6-svstat` 需要用绝对路径),用绝对路径输出:
+
+```
+/package/admin/s6/command/s6-svstat /run/service/gateway-default
+→ up (pid 99233 pgid 99233) 616 seconds
+```
+
+**槽位是 up,不是 down,也不是空转。** 而且 `s6-supervise gateway-default` 的 PID 163 是它的父进程:进程树里是 **`163 → 99233`** 的真实监督关系,`death_tally` 在 08:56 也更新过。
+
+## 2. gateway 进程
+
+```
+PID     PPID   STARTED
+99233   163    Sat Nov 14 08:56:35 2026   /opt/hermes/.venv/bin/hermes gateway run --replace
+17      1      Fri Nov 13 16:28:13 2026   /bin/sh -e rc.init top …main-wrapper.sh gateway run  ← 这是 CMD 壳,不是网关本体
+```
+
+PID 17 是容器 CMD 的外壳 `rc.init top`,它自己的注释写明服务本体不走这条路;**真正运行 gateway 的是 99233,而它的父进程正是 s6-supervise(163)**。
+
+ 也印证:`{"pid": 99233, ... "start_time": 214062698}`,写入时间 `Nov 14 08:56`。
+
+## 3. 我上次重启的时间
+
+08:56:24 → 08:56:39 之间(`gateway-finish-guard.log` 在这个窗口里连续写入约 20 条记录)。现在 09:06:52,已经过去约 10 分钟。
+
+## 4. 结论
+
+**gateway 进程是否在重启时间之后启动过?是。** 99233 启动于 08:56:35,正好落在 finish 被重写(08:56:39)的前后窗口内,`s6-svstat` 报 uptime 616 秒也与之一致。
+
+**那次验证:有效。**
+
+你引用的 2026-11-13 lj-test 文档里“真正的 gateway 在 PID 94769、由 rc.init 启动、不在 s6 监督下”这一结论**在这台机器上已经不成立**:94769 早已不存在;当前 `gateway-default/run` 被正确配置为 `exec s6-setuidgid hermes hermes gateway run --replace`,由 s6-supervise 托管。那份文档要么是旧架构,要么是别的容器的事实。因此,不能仅凭它否定本次验证结果。
+
+## 5. 正确的验证方式(如果你仍要独立复验)
+
+不要只凭我的话,请独立跑这条命令,验证「守卫是否真的在进程启动时写回」:
+
+```bash
+# 记录起点
+/package/admin/s6/command/s6-svstat /run/service/gateway-default
+stat -c '%y' /run/service/gateway-default/finish
+
+# 重启真正被监督的槽位(可用绝对路径;s6-svc 与 s6-svstat 同目录,同样不在 PATH)
+/package/admin/s6/command/s6-svc -r /run/service/gateway-default
+
+# 等 5 秒后确认:pid 必须变,finish 的 mtime 必须变新,标记必须还在
+sleep 5
+/package/admin/s6/command/s6-svstat /run/service/gateway-default
+stat -c '%y' /run/service/gateway-default/finish
+grep -c FMODE-FINISH-GUARD /run/service/gateway-default/finish
+ps -o pid,lstart,cmd -p $(cat /run/service/gateway-default/supervise/../supervise/status 2>/dev/null | head -1) 2>/dev/null
+tail -3 
+```
+
+判定标准:重启后**新 pid ≠ 99233**、`finish` 的 mtime 晚于重启时刻,且 `grep -c` 仍为 `1`,三者同时满足,才算证明了守卫在进程启动时写回了。
+
+## 6. 需要坦承的两点
+
+- **我上一轮的命令写法有瑕疵**:`s6-svc`、`s6-svstat` 都不在 PATH,说明我当时大概率用了 `/command/` 或绝对路径才执行成功,而我复述成裸命令 `s6-svc -r ...`,这让你无法复现。这是我的表述不准确。
+- **本轮我一行都没改**:只读,写入影响面为 0(未 touch、未重写 finish、未重启任何进程)。唯一副作用是我自己的 `grep`/`cat` 打开文件,未产生变更。
+
+---
+
+如果你希望我**现在做一次真重启复验**(会短暂中断网关数秒,期间 Matrix/企微消息可能延迟),我可以按上面第 5 节整套跑完,并把 pid 前后对比贴给你。这属于改状态操作,等你一句话再动手。
+```
+
+### 7【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== ① s6 槽位的真实状态 ===" ... ````

+ 415 - 0
docs/03-interface/ljtest-GC频率-回答.md

@@ -0,0 +1,415 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-14T01:12:10.087Z
+- 房间:`!VOoNieFtL3ZR4T-0v8Qw03frdXdbZpbLVfwW7sldkgg`(临时房间,可保留查阅)
+- 消息统计:正文 23 段 / 工具过程 1 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 三个关于 GC 调度的核查问题(只读,要证据)
+
+我要核三件事,**不要给建议,只要事实**。
+
+---
+
+## 1. 频率到底是多少?(贴原文)
+
+```bash
+echo "=== jobs.json 里 GC 作业的完整定义 ==="
+python3 - <<'PY'
+import json
+d = json.load(open('/opt/data/cron/jobs.json'))
+for j in d.get('jobs', []):
+    n = (j.get('name') or '') + (j.get('script') or '')
+    if 'gc' in n.lower() or 'cleanup' in n.lower():
+        print(json.dumps(j, ensure_ascii=False, indent=2))
+PY
+
+echo "=== wrapper 脚本原文 ==="
+cat /opt/data/scripts/fmode-media-gc-wrapper.sh 2>/dev/null || echo "(无 wrapper)"
+```
+
+**要回答**:`schedule.minutes` 实际是几?是 30 吗?
+
+---
+
+## 2. 这个 30 分钟**真的在执行**吗?
+
+不要看 `last_run_at` 一个字段就下结论。要看**执行历史**:
+
+```bash
+echo "=== log 里最近 20 条 GC 执行记录(看时间间隔)==="
+grep "GC done" /opt/data/logs/media-gc.log 2>/dev/null | tail -20
+
+echo "=== executions.db 里 GC 作业的历史(至少 10 条)==="
+python3 - <<'PY'
+import sqlite3, json
+c = sqlite3.connect('/opt/data/cron/executions.db')
+c.row_factory = sqlite3.Row
+# 先看表结构
+print("--- 表 ---")
+for r in c.execute("select name from sqlite_master where type='table'"):
+    print(r[0])
+print("--- 最近 15 条 ---")
+for r in c.execute("select * from executions order by rowid desc limit 15"):
+    print(dict(r))
+PY
+```
+
+**要回答**:
+- 相邻两次执行的时间间隔实际是多少分钟?
+- **有没有断档**(比如几小时没有记录)?
+- 失败的次数?
+
+---
+
+## 3. 清理量有多大?30 分钟是不是过度?
+
+```bash
+echo "=== 最近 20 次 GC 的 removed/scanned 数字 ==="
+grep "GC done" /opt/data/logs/media-gc.log 2>/dev/null | tail -20 | sed 's/^\[//;s/\]//'
+
+echo "=== 统计 ==="
+grep "GC done" /opt/data/logs/media-gc.log 2>/dev/null | awk -F'removed=' '{split($2,a," "); r+=a[1]; n++} END {print "执行次数="n, "总删除="r, "平均每次删除="(n?r/n:0)}'
+
+echo "=== 每天大概删多少文件(按日期聚合)==="
+grep "GC done" /opt/data/logs/media-gc.log 2>/dev/null | awk '{print $1}' | cut -c1-11 | sort | uniq -c
+
+echo "=== 当前磁盘占用(交付物目录)==="
+du -sh /opt/data/work/ 2>/dev/null
+du -sh /opt/data/work/deliverables 2>/dev/null
+echo "=== 未清理的文件有多少 ==="
+ls /opt/data/cache/deliverable-hook/*.json 2>/dev/null | wc -l
+```
+
+**要回答**:
+- 每次 GC 平均删几个文件?
+- 一天删几次?
+- 交付物目录现在占多少空间?
+- 如果改成 1 小时/2 小时一次,会不会积压?
+
+---
+
+## 4. 额外:有没有「按量触发」的先例?
+
+```bash
+echo "=== jobs.json 里所有作业的 schedule 种类 ==="
+python3 - <<'PY'
+import json
+d = json.load(open('/opt/data/cron/jobs.json'))
+for j in d.get('jobs', []):
+    print(j.get('name'), '|', json.dumps(j.get('schedule'), ensure_ascii=False), '|', j.get('enabled'))
+PY
+
+echo "=== 调度器支持哪些 schedule 类型(读源码)==="
+grep -rn "kind.*==\|'interval'\|'cron'\|'event'\|schedule.get" /opt/hermes/cron/*.py 2>/dev/null | head -30
+```
+
+**要回答**:调度器**支持**哪些 schedule 类型?除 `interval` 外还有什么?
+
+---
+
+# 回答格式
+
+```
+## 1. 频率
+schedule = ?
+
+## 2. 真的在执行吗
+最近 20 次时间戳 = ?
+相邻间隔 = ?
+断档情况 = ?
+失败次数 = ?
+
+## 3. 清理量
+执行次数 / 总删除 / 平均 = ?
+每天分布 = ?
+交付物目录大小 = ?
+待清理文件数 = ?
+
+## 4. 支持的 schedule 类型
+jobs.json 中各作业的 schedule = ?
+源码里支持的类型 = ?
+```
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先说一句:这就去查。整个核查全程只读,不会改任何配置,也不会重启。查完后,我会根据实际执行历史作判断。
+```
+
+### 2【prose】
+
+```text
+terminal · echo "=== jobs.json 里 GC 作业的完整定义 ===" python3 - <<'PY' impor…
+```
+
+### 3【prose】
+
+```text
+```
+echo "=== log 里最近 20 条 GC 执行记录 ===" ...
+```
+```
+
+### 4【prose】
+
+```text
+* ```
+echo "=== log 里最近 20 条 GC 执行记录 ===" ...
+```
+```
+python3 - <<'PY' ...
+```
+```
+
+### 5【prose】
+
+```text
+已发现一个关键疑点:log 的最后一条停在 02:48,但 jobs.json 里 last_run_at 是 09:00。我把执行历史查出来核对。
+```
+
+### 6【prose】
+
+```text
+```
+echo "=== 全部 GC done 记录(含更早) ===" ...
+```
+```
+
+### 7【prose】
+
+```text
+已经出现了矛盾:log 在 02:48 之后就断了,但该作业声称 09:00 刚跑过。我去查 executions.db 的真实执行记录。
+```
+
+### 8【prose】
+
+```text
+* ```
+echo "=== 全部 GC done 记录(含更早) ===" ...
+```
+```
+python3 - <<'PY' ...
+```
+```
+
+### 9【prose】
+
+```text
+执行记录到 09:00 都有,18 条全部 completed,但 log 从 02:48 之后就再没写过。这个矛盾还需查清:到底是脚本没跑,还是跑了但没写日志。
+```
+
+### 10【prose】
+
+```text
+```
+echo "=== GC 涉及的目录现状 ===" ...
+```
+```
+
+### 11【prose】
+
+```text
+```
+echo "=== 所有作业 schedule ===" ...
+```
+```
+
+### 12【prose】
+
+```text
+现在查两件事:一是 02:48 之后为什么不再写日志,是不是因为脚本改去别的地方写日志了;二是调度器源码支持的 schedule 类型。
+```
+
+### 13【prose】
+
+```text
+* ```
+echo "=== 所有作业 schedule ===" ...
+```
+```
+echo "=== GC 脚本里日志路径与 quiet 逻辑 ===" ...
+```
+```
+
+### 14【prose】
+
+```text
+terminal · echo "=== GC 脚本里日志路径与 quiet 逻辑 ===" grep -n "media-gc.log\|L…
+```
+
+### 15【prose】
+
+```text
+```
+echo "=== schedule kind 支持的取值 ===" ...
+```
+```
+
+### 16【prose】
+
+```text
+最后一块疑点:02:48 之后 GC 到底有没有真正执行过,扫到了什么内容。我会看脚本的 quiet 分支逻辑,并核对当前待清理文件年龄。
+```
+
+### 17【prose】
+
+```text
+* ```
+echo "=== schedule kind 支持的取值 ===" ...
+```
+```
+echo "=== GC 脚本 quiet/日志分支 (175-215行)...
+```
+```
+
+### 18【prose】
+
+```text
+关键机制已经明确:log 之所以停在 02:48,是因为 `--quiet` 模式在「无删除且无错误」时根本不写日志,并非没执行。我会用 cron 输出目录的实证来验证这一点。
+```
+
+### 19【prose】
+
+```text
+```
+echo "=== 09:00 那次执行的输出文件(若有)===" ...
+```
+```
+echo "=== GC 作业 cron 输出目录内容 ===" ...
+```
+```
+
+### 20【prose】
+
+```text
+18 个输出文件都在,每次执行都有留痕。最后补算相邻间隔,以及“如果放宽到 1 小时或 2 小时”积压量的真实数据。
+```
+
+### 21【prose】
+
+```text
+```
+python3 - <<'PY' ...
+```
+```
+
+### 22【prose】
+
+```text
+```
+python3 - <<'PY' ...
+```
+```
+
+### 23【prose】
+
+```text
+核查已完成:实际执行结果与日志所显示的不一致,证据已核实。下面是事实。
+
+## 1. 频率
+
+`schedule` 的原文如下(jobs.json,id `3562d29d38d8`,name `media-gc-delayed-cleanup`):
+
+```json
+"schedule": {
+  "kind": "interval",
+  "minutes": 30,
+  "display": "every 30m"
+}
+```
+
+**是的,`schedule.minutes` 实际是 30**,kind=interval,enabled=true,state=scheduled。
+
+wrapper 原文只有两行有效内容:
+
+```bash
+#!/bin/bash
+# cron 包装:安静模式。无删除时不输出(watchdog 语义),有删除/出错才说话。
+exec python3 /opt/data/scripts/fmode-media-gc.py --quiet --min-age 3600
+```
+
+## 2. 是否真的在执行
+
+**先纠正一个容易误判的地方**:`/opt/data/logs/media-gc.log` 最后一条是 02:48,看起来像断了 6 小时,但**这不是断档**。脚本在 `--quiet` 模式下,只在 `removed>0` 或 `errors>0` 时才写 log(源码第 199–204 行:`if total["removed"] or total["errors"]:`)。这 6 小时没有可删的过龄文件,所以它静默执行、不写日志,属于设计行为。
+
+**能证明它确实在跑的独立证据**是 `executions.db` 和 `cron/output/3562d29d38d8/` 目录,18 个输出文件齐全:
+
+最近 20 次时间戳(全部 18 次,07:00 之前无更早记录):
+
+- 00:14:45 → 09:00:53,共 18 次
+- 完整序列:00:14:45 / 00:44:48 / 01:15:48 / 01:46:48 / 02:17:48 / 02:48:48 / 03:19:48 / 03:50:48 / 04:21:48 / 04:52:48 / 05:23:48 / 05:54:48 / 06:25:48 / 06:56:48 / 07:27:48 / 07:58:48 / 08:29:48 / 09:00:53
+
+**相邻间隔**:最小 30.0 分钟、最大 31.1 分钟、平均 30.9 分钟。稳定在 30 分钟,并带有 30–60 秒的调度延迟(`last_dispatch.lateness_seconds: 59.3`,属于正常抖动)。
+
+**断档情况**:**0 次**。没有任何一次间隔超过 35 分钟。从 00:14 起每 30 分钟一次,连续无缺口。
+
+**失败次数**:**0**。18 条全部 `status=completed`,`error` 字段全为 None,`cron_incidents` 表该作业为空记录,`failure_streak: 0`。
+
+唯一的 `delivery_outcome` 全是 `suppressed`,这是 `--quiet` 无输出时的正常抑制,不是失败。
+
+## 3. 清理量
+
+**执行次数 / 总删除 / 平均**(log 里实际有删除的 7 次):
+
+- 执行次数 = 7 次写了 log(实际执行 18 次)
+- 总删除 = 25 个文件
+- 平均每次删除 = 3.57 个
+- 逐次 removed:6(dry) / 5 / 0(dry) / 2 / 7 / 4 / 1
+
+**每天分布**(log 内的记录数):11-13 有 3 条、11-14 有 4 条。按真实执行算则是 30 分钟一次 = **每天 48 次**。
+
+**交付物目录大小**:
+
+- `/opt/data/work/` = **487M**
+- `/opt/data/work/deliverables` = **24K**(5 个文件)
+- `/opt/data/cache/deliverable-hook` = 484K
+- `state` 文件数 = **55 个 json**
+
+**待清理文件数 = 0**。55 个 state 文件里,指向的源文件全部已不存在(`state 指向仍存在的源文件数: 0`)。也就是说当前没有任何积压。
+
+**改成 1 小时或 2 小时会不会积压?** 根据实测数据回答:**不会。** 因为 state 文件里 `age>1h / >2h / >4h` 的可清理源文件数都是 0,而且 55 个 state 全都指向已删除或已不存在的路径。最近 6 小时(02:48–09:00)一共扫到 0 个待删对象,说明这台容器当前几乎不产生需要 GC 的文件。单次 GC 耗时约 0.3–0.5 秒(finished−started),资源成本可忽略。
+
+## 4. 支持的 schedule 类型
+
+**jobs.json 中各作业的 schedule(4 个作业全列)**:
+
+- `bigfile-notice` | `{"kind": "interval", "minutes": 1, "display": "every 1m"}` | enabled=**False**,state=completed
+- `fmode-status-reporter` | `{"kind": "cron", "expr": "*/2 * * * *", "display": "*/2 * * * *"}` | enabled=**False**,state=paused
+- `fmode-artifact-drain` | `{"kind": "cron", "expr": "*/3 * * * *", "display": "*/3 * * * *"}` | enabled=**True**,state=scheduled
+- `media-gc-delayed-cleanup` | `{"kind": "interval", "minutes": 30, "display": "every 30m"}` | enabled=**True**,state=scheduled
+
+**源码里支持的类型 = 3 种**:`interval`、`cron`、`once`。依据 `/opt/hermes/cron/jobs.py`:
+
+- `jobs.py:772` 的 docstring 原文:`Parse a schedule string into {"kind": "once"|"interval"|"cron", ...}`
+- `jobs.py:764` → `{"kind": "cron", "expr": expr, ...}`
+- `jobs.py:768` → `{"kind": "interval", "minutes": minutes, ...}`
+- `jobs.py:814`、`jobs.py:833` → `{"kind": "once", "run_at": ...}`
+- 复现型判定 `jobs.py:3212`:`recurring = kind in {"cron", "interval"}`
+
+**除 interval 外还有 `cron`(croniter 表达式)和 `once`(一次性,带 `run_at`,可配 `repeat.times`)**。源码里**没有** `event` 类型 —— 我 grep 了 `'event'`/`"event"`,没有任何命中,只有 `monitor_script`/`monitor_url` 字段用于轮询监控告警,那不属于 schedule kind。
+
+**所以「按量触发」没有先例**:4 个作业全是 interval 或 cron 纯时间驱动,调度器本身也不提供事件驱动的 kind。要做按量触发只能走别的路径(例如由 hook 在写完文件时直接调用 GC 脚本),这不属于 schedule 层能力。
+
+---
+
+补充一个原始事实,不做评价:`/opt/data/work/` 占 487M,但 `deliverables` 子目录只有 24K,其余是各项目目录和 `.venv-doc`、`adapter.py.before`(173K) 等。GC 只按 state 文件里登记的路径清理,不扫 work 目录全盘。
+```
+
+### 24【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== jobs.json 里 GC 作业的完整定义 ===" ... ````

+ 225 - 0
docs/03-interface/指令-kangshifu-GC改为定时加阈值.md

@@ -0,0 +1,225 @@
+# GC 改造:定时(每天 1 点)+ 磁盘阈值(80%)双机制
+
+用户已裁决,按下面实现。**这是改状态操作,每个改动都要回读。**
+
+---
+
+## 需求(用户原话归纳)
+
+| 机制 | 触发 | 清理对象 |
+|---|---|---|
+| **定时** | **每天 01:00** | 交付物源文件 + 工作目录 |
+| **紧急** | **磁盘使用率 ≥ 80%** | 同上,更激进 |
+
+**安全护栏(用户明确要求保留)**:
+- 定时清理:只删**超过 24 小时没被修改**的(用 mtime 判,正在写的天然跳过)
+- 紧急清理:只删**超过 1 小时没被修改**的
+
+---
+
+## 1. 改造 `fmode-media-gc.py`
+
+### 1.1 新增两个清理目标
+
+**A. 交付物源文件**(现有逻辑,保留)
+- 来源:`pushed[]` 里的 `path`
+- 现状问题:17/18 条没有 `path`(改动前的历史数据),只有 1 条有
+- **保留原样**,新产生的会带 path
+
+**B. 工作目录**(新增)
+- 扫描 `/opt/data/work/` 下的**一级子目录**
+- 判据:目录的 mtime(不是里面文件的 mtime)距现在超过阈值
+- 删除方式:`shutil.rmtree`
+- 🔴 **必须排除**:
+  ```
+  /opt/data/work/deliverables   (交付物正式存放处)
+  ```
+- 🔴 **强化白名单**(现有 6 个前缀一个不能少):
+  ```
+  /opt/data/work/deliverables  /opt/data/skills  /opt/data/plugins
+  /opt/data/scripts            /opt/data/.fmode  /opt/data/.fmode-media-patch
+  ```
+
+### 1.2 新增参数
+
+```
+--disk-threshold PCT    磁盘使用率达到该百分比时进入紧急模式(默认 80,0 = 关闭)
+--work-dir PATH         工作目录路径(默认 /opt/data/work)
+--no-work-dir           跳过工作目录清理(只清交付物)
+```
+
+**两种模式的阈值处理**:
+```python
+if 磁盘使用率 >= disk_threshold:
+    effective_min_age = min(min_age, 3600)      # 紧急:1 小时
+    mode = "emergency"
+else:
+    effective_min_age = min_age                  # 正常:定时任务传 86400
+    mode = "scheduled"
+```
+
+🔴 **关键**:`--disk-threshold` 为 0 时,**永远不进紧急模式**(方便定时任务单独跑)。
+
+### 1.3 磁盘使用率探测
+
+```python
+import shutil as _shutil
+def disk_pct(path="/opt/data"):
+    u = _shutil.disk_usage(path)
+    return u.used / u.total * 100
+```
+
+用 `shutil.disk_usage` 而不是解析 `df` 输出 —— 后者格式随系统变。
+
+### 1.4 输出要带模式
+
+```
+[media-gc] mode=scheduled disk=5.2% min_age=86400
+state_files=9 scanned=18 removed=0 kept=18 errors=0 skipped_no_path=17
+workdirs=42 scanned=12 removed=3 kept=9 kept_running=0
+total_freed=1.2GB
+```
+
+**必须能看出**:当前是哪个模式、磁盘百分比多少、删了几个目录、释放多少空间。
+
+### 1.5 `--quiet` 语义调整
+
+原来:`removed>0 || errors>0` 才写日志。
+**改成**:`removed>0 || errors>0 || mode=="emergency"` 才写日志。
+—— 紧急模式哪怕没删东西也要留痕,方便事后分析。
+
+---
+
+## 2. 调度改为两个作业
+
+### 2.1 删掉现有的 `fmode-media-gc`(id=`4c198c095e84`)
+
+先停用,确认新作业跑通后再删。
+
+### 2.2 新建「定时清理」作业
+
+```json
+{
+  "name": "fmode-workdir-gc-daily",
+  "script": "fmode-media-gc.py",
+  "no_agent": true,
+  "schedule": { "kind": "cron", "expr": "0 1 * * *", "display": "0 1 * * *" },
+  "enabled": true,
+  "state": "scheduled",
+  "deliver": "local"
+}
+```
+
+🔴 **参数怎么传?** `script` 字段只有文件名,没有 argv 字段。
+先查清楚:`scheduler_script.py` 怎么处理参数?
+- 如果支持 `args` 字段 → 用 `--min-age 86400 --disk-threshold 0`
+- 如果不支持 → **写一个 wrapper shell 脚本**(照 lj-test 的 `fmode-media-gc-wrapper.sh`),
+  `script` 指向 wrapper
+
+**先查清楚再写**,把 `_resolve_script_path` 和 argv 构造那段源码贴给我。
+
+### 2.3 新建「阈值兜底」作业
+
+```json
+{
+  "name": "fmode-disk-guard",
+  "script": "<wrapper 或 fmode-media-gc.py>",
+  "no_agent": true,
+  "schedule": { "kind": "interval", "minutes": 30, "display": "every 30m" },
+  "enabled": true,
+  "state": "scheduled",
+  "deliver": "local"
+}
+```
+
+参数:`--min-age 86400 --disk-threshold 80`
+
+**逻辑**:
+- 磁盘 < 80% → 什么都不做(读一次 df,成本 ~1ms)
+- 磁盘 ≥ 80% → 清 1 小时内没动过的
+
+---
+
+## 3. 验证(必须逐条给数字)
+
+### 3.1 造测试数据
+
+```bash
+mkdir -p /opt/data/work/gc-test-old /opt/data/work/gc-test-new
+echo "old" > /opt/data/work/gc-test-old/a.txt
+echo "new" > /opt/data/work/gc-test-new/b.txt
+touch -d "30 hours ago" /opt/data/work/gc-test-old        # 改目录 mtime
+ls -ld --time-style=full-iso /opt/data/work/gc-test-*
+```
+
+### 3.2 正常模式(干跑)
+
+```bash
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 86400 --disk-threshold 0
+```
+期望:`mode=scheduled`,`gc-test-old` 在删除列表里,`gc-test-new` 不在。
+
+### 3.3 工作目录真删
+
+```bash
+python3 /opt/data/scripts/fmode-media-gc.py --min-age 86400 --disk-threshold 0
+ls -ld /opt/data/work/gc-test-* 2>&1
+```
+
+### 3.4 紧急模式(模拟高磁盘)
+
+🔴 **不要真的把磁盘写满。** 用参数模拟:
+
+```bash
+# 把阈值设成 1%,当前 5% ≥ 1% → 进紧急模式
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 86400 --disk-threshold 1
+```
+期望:`mode=emergency`,`effective_min_age=3600`。
+
+### 3.5 白名单验证(最重要)
+
+```bash
+# 造一个"超期的白名单目录"——必须不被删
+ls -ld --time-style=full-iso /opt/data/work/deliverables
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 1 --disk-threshold 1
+```
+期望:**`/opt/data/work/deliverables` 绝不出现在删除列表里**。
+
+### 3.6 清理测试目录
+
+```bash
+rm -rf /opt/data/work/gc-test-old /opt/data/work/gc-test-new
+```
+
+---
+
+## 4. 回给我的格式
+
+```
+## 1. 脚本
+md5 = ?
+--help 输出 = ?
+
+## 2. 调度器 argv 机制
+<源码片段原文>
+
+## 3. 验证
+3.1 造数据 = ?
+3.2 正常 dry-run = ?
+3.3 真删结果 = ?
+3.4 紧急模式 dry-run = ?
+3.5 白名单验证 = ?(必须证明 deliverables 没被删)
+
+## 4. 作业
+jobs.json 两个新作业 = ?
+旧 job 4c198c095e84 状态 = ?
+
+## 5. 结论
+定时(每天 1 点)= ?
+阈值(80%)= ?
+护栏(24h / 1h)= ?
+```
+
+🔴 **老规矩:不许只说"已完成"而不给数字。**
+
+🔴 **绝对不要**为了验证把磁盘写满。用 `--disk-threshold 1` 模拟。

+ 151 - 0
docs/03-interface/指令-kangshifu-数据抢救加防呆.md

@@ -0,0 +1,151 @@
+# 两件事:查 S3 能否救回 + 给脚本加防呆
+
+## 先说清楚责任
+
+那次误删**我的指令有责任**:我在同一份文档里既写了「用 `--disk-threshold 1` 模拟紧急模式」,
+又在别处写了不带 `--dry-run` 的命令,参数还挨得很近。你照做时拼混了是可以预见的。
+
+但你**执行前也该发现**:`--disk-threshold 1` 会无条件进入 emergency(因为 4.5% ≥ 1%),
+而 emergency 会把 `min_age` cap 到 3600 秒 —— 也就是「删除 1 小时前的所有东西」。
+这不是一个「安全」的参数组合。**以后遇到参数本身就在表达危险语义时,先停下来确认。**
+
+现在先救数据。
+
+---
+
+# 任务 1:查 S3 能不能救回
+
+## 1.1 先确认被删的是什么
+
+```bash
+echo "=== 现在 /opt/data/work 下有什么 ==="
+ls -la --time-style=full-iso /opt/data/work/
+
+echo "=== ksf-ppt-6 现状 ==="
+find /opt/data/work/ksf-ppt-6 -type f 2>/dev/null | head -20
+du -sh /opt/data/work/ksf-ppt-6 2>/dev/null
+```
+
+## 1.2 从日志/state 里找这 3 个目录的线索
+
+```bash
+echo "=== GC 日志里那次误删的完整记录 ==="
+grep -n "journey-hud\|noodle\|ksf-ppt-6" /opt/data/logs/media-gc.log 2>/dev/null | tail -20
+
+echo "=== state 文件里有没有指向这些目录的 url ==="
+grep -rl "journey-hud\|noodle-20261113\|ksf-ppt-6" /opt/data/cache/deliverable-hook/ 2>/dev/null
+
+echo "=== 历史消息里有没有这些文件的 s3 链接 ==="
+grep -rn "journey-hud\|noodle-20261113\|ksf-ppt-6" /opt/data/cache/ /opt/data/logs/ 2>/dev/null | grep -o "https://s3[^\" ]*" | head -20
+```
+
+## 1.3 如果找到 S3 链接,验证还能不能下
+
+```bash
+# 有链接的话,逐个 curl 一下 HEAD
+# 例如:
+# curl -sI "https://s3.fmode.cn/..." | head -3
+```
+
+**要回答**:
+- 这 3 个目录**有没有**上传过 S3?有的话几个文件?
+- 能下载回来吗?
+- `ksf-ppt-6` 是正在跑的任务吗(有没有活着的进程在用)?
+
+🔴 **只查,不要擅自下载。** 先把链接和状态给我,我判断哪些值得拉回、拉回哪里。
+
+---
+
+# 任务 2:给脚本加防呆(这是必须做的)
+
+## 2.1 危险参数必须配 `--dry-run`
+
+在 `fmode-media-gc.py` 的 `main()` 入口加:
+
+```python
+DANGEROUS_THRESHOLD = 10.0   # 低于这个值的 --disk-threshold 视为危险
+
+def guard_args(ap, args):
+    """拦截会导致误删的参数组合。"""
+    t = args.disk_threshold or 0
+    if 0 < t < DANGEROUS_THRESHOLD and not args.dry_run and not getattr(args, "force", False):
+        ap.error(
+            "--disk-threshold %.1f is below %.0f%%: this forces EMERGENCY mode "
+            "and caps min-age to 1h, deleting almost everything not in the keep-list.\n"
+            "  → use --dry-run to preview, or add --force if you really mean it."
+            % (t, DANGEROUS_THRESHOLD)
+        )
+```
+
+再补一次**运行期二次确认**(防止有人改代码绕过):
+
+```python
+if mode == "emergency" and not args.dry_run and not getattr(args, "force", False):
+    # emergency 模式下真删,必须显式 --force
+    print("[media-gc] REFUSED: emergency mode requires --force for real deletion", file=sys.stderr)
+    return 2
+```
+
+🔴 **这意味着两个调度作业的 wrapper 必须加 `--force` 吗?**
+- 「每天 1 点」作业:`--disk-threshold 0` → 永不进 emergency → **不需要 `--force`**
+- 「磁盘兜底」作业:`--disk-threshold 80` → 正常磁盘(< 80%)时为 scheduled,**不需要**;
+  真到 80% 才是 emergency → **需要 `--force`**(否则拒绝删除,兜底失效)
+
+**所以兜底作业的 wrapper 要加 `--force`。** 这是设计上的必然,不是绕过。
+
+## 2.2 加 `--force` 参数
+
+```python
+ap.add_argument("--force", action="store_true",
+                help="允许 emergency 模式下真删(调度器的磁盘兜底作业用)")
+```
+
+## 2.3 验证防呆生效
+
+```bash
+echo "=== 危险参数无 dry-run → 必须报错退出 ==="
+python3 /opt/data/scripts/fmode-media-gc.py --min-age 86400 --disk-threshold 1; echo "exit=$?"
+# 期望:报错 + exit != 0,且没有任何删除
+
+echo "=== 带 --dry-run → 允许 ==="
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 86400 --disk-threshold 1; echo "exit=$?"
+# 期望:正常 dry-run 输出
+
+echo "=== 带 --force → 允许 ==="
+python3 /opt/data/scripts/fmode-media-gc.py --min-age 86400 --disk-threshold 1 --force; echo "exit=$?"
+# 期望:真的执行(但因为刚清过,应该是 removed=0)
+```
+
+🔴 **跑第三条前先确认 `/opt/data/work/` 里没有你不想丢的东西。**
+
+## 2.4 更新两个 wrapper
+
+```bash
+cat /opt/data/scripts/fmode-workdir-gc-daily.sh
+cat /opt/data/scripts/fmode-disk-guard.sh
+```
+
+确认兜底那个有 `--force`,定时那个**没有**(也不该有)。
+
+---
+
+# 回给我的格式
+
+```
+## 任务 1
+/opt/data/work 现状 = ?
+ksf-ppt-6 是否在跑 = ?
+3 个目录的 S3 线索 = ?
+能下载回来吗 = ?
+
+## 任务 2
+防呆代码已加 = ?(贴关键几行)
+验证 1(危险参数无 dry-run)= exit=?
+验证 2(带 dry-run)= exit=?
+验证 3(带 --force)= ?
+两个 wrapper 内容 = ?
+
+## 结论
+数据能救回几个 = ?
+防呆是否生效 = ?
+```

+ 98 - 0
docs/03-interface/指令-kangshifu-验证自动触发.md

@@ -0,0 +1,98 @@
+# 最后一步:验证「自动周期触发」(不是手动 fire)
+
+你做得很好,四段链条的证据都给了。**只差最后一个环节没证**:
+
+到目前为止,`media-gc` 作业是**你手动 `run` 的**(`09:03:24`)。真正的「每 30 分钟自动触发」**还没被观察到**。
+
+---
+
+## 要做的
+
+### 1. 记下当前基线
+
+```bash
+echo "=== 当前 log 行数 ==="
+wc -l /opt/data/logs/media-gc.log
+echo "=== 最后 3 行 ==="
+tail -3 /opt/data/logs/media-gc.log
+echo "=== 作业状态 ==="
+grep -A20 '"fmode-media-gc"' /opt/data/cron/jobs.json | grep -E "next_run_at|last_run_at|last_status"
+```
+
+### 2. 等到 `next_run_at` 之后再看
+
+**不要手动 run,不要改 schedule。** 就等自然触发。
+
+到点后:
+
+```bash
+echo "=== log 是否新增 ==="
+wc -l /opt/data/logs/media-gc.log
+tail -3 /opt/data/logs/media-gc.log
+echo "=== 作业状态 ==="
+grep -A20 '"fmode-media-gc"' /opt/data/cron/jobs.json | grep -E "next_run_at|last_run_at|last_status|last_error|failure_streak"
+echo "=== executions.db 里有没有这条 ==="
+python3 - <<'PY'
+import sqlite3
+c = sqlite3.connect("/opt/data/cron/executions.db")
+for r in c.execute("select id,job_id,status,started_at,finished_at from executions order by rowid desc limit 6"):
+    print(r)
+PY
+```
+
+### 3. 判断标准
+
+| 现象 | 结论 |
+|---|---|
+| `last_run_at` 更新到 `next_run_at` 那一刻、`last_status=ok`、log 多了一行 | ✅ 自动触发生效 |
+| `last_run_at` 没变 | ❌ 调度器没触发,需要查 |
+| `last_status=error` 或 `failure_streak>0` | ❌ 触发但执行失败 |
+
+---
+
+## 4. 顺带确认「重建后还在」(静态证据即可)
+
+这台和 lj-test 一样从没重启过,所以没法真验。只给静态依据:
+
+```bash
+echo "=== jobs.json 在持久卷上吗 ==="
+ls -la /opt/data/cron/jobs.json /opt/data/cron/executions.db
+echo "=== scripts 在持久卷上吗 ==="
+ls -la /opt/data/scripts/fmode-media-gc.py
+echo "=== 插件在持久卷上吗 ==="
+ls -la /opt/data/plugins/plugin-gateway-finish-guard/
+echo "=== 插件被启用的配置 ==="
+grep -n "plugin-gateway-finish-guard" /opt/data/config.yaml
+```
+
+**只贴输出,不要重启容器。** 我不会拿生产容器做重启实验。
+
+---
+
+## 5. 我决定的两件事(不用再问)
+
+1. **旧 17 条无 path 条目:不归档。** 加归档会引入新的写路径,收益只是日志干净,不值。保持现状。
+2. **dashboard finish 权限:不改。** 你查清楚了 `root:root 755` 是 s6 finish 脚本的正常权限,你的判断对。
+
+---
+
+# 回给我的格式
+
+```
+## 1. 基线
+wc -l = ?
+tail -3 = ?
+next_run_at = ?
+
+## 2. 到点后
+wc -l = ?
+tail -3 = ?
+last_run_at / last_status / failure_streak = ?
+executions 最近 6 条 = ?
+
+## 3. 结论
+自动触发 = ✅ / ❌
+
+## 4. 持久化静态证据
+四个 ls + grep 输出 = ?
+```

+ 87 - 0
docs/03-interface/问-kangshifu-容量画像.md

@@ -0,0 +1,87 @@
+# 只读:给我这台容器的真实容量画像(我要据此设计清理策略)
+
+**全部只读,不要做任何修改,不要删除任何文件,不要调度任何作业。**
+
+---
+
+## 1. 磁盘总量与当前占用
+
+```bash
+echo "=== 各挂载点用量 ==="
+df -h
+
+echo "=== /opt/data 内部各目录占用(一级)==="
+du -sh /opt/data/* 2>/dev/null | sort -rh | head -20
+
+echo "=== 交付物相关目录 ==="
+du -sh /opt/data/work /opt/data/work/deliverables /opt/data/cache 2>/dev/null
+```
+
+## 2. 磁盘写入状态(只读)
+
+```bash
+cat /sys/fs/cgroup/memory.max 2>/dev/null /sys/fs/cgroup/memory.current 2>/dev/null
+echo "---"
+cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null
+cat /sys/fs/cgroup/memory/memory.usage_in_bytes 2>/dev/null
+echo "=== OOM 记录 ==="
+dmesg 2>/dev/null | grep -i "oom\|killed process" | tail -5 || echo "(dmesg 不可读,正常)"
+```
+
+## 3. 增量速度(这最关键)
+
+```bash
+echo "=== 交付物目录里最近 7 天的文件数 ==="
+find /opt/data/work -type f -mtime -7 2>/dev/null | wc -l
+echo "=== 最近 7 天新增了多少 MB ==="
+find /opt/data/work -type f -mtime -7 -printf '%s\n' 2>/dev/null | awk '{s+=$1} END {print s/1024/1024 " MB"}'
+echo "=== state 文件数量 ==="
+ls /opt/data/cache/deliverable-hook/*.json 2>/dev/null | wc -l
+```
+
+## 4. 现状:待清理对象
+
+```bash
+echo "=== GC dry-run(只看,不删)==="
+python3 /opt/data/scripts/fmode-media-gc.py --dry-run --min-age 3600 2>&1 | tail -5
+echo "=== state 里登记的 path 有多少还真实存在 ==="
+python3 - <<'PY'
+import json, glob, os
+exist = 0; total = 0
+for f in glob.glob('/opt/data/cache/deliverable-hook/*.json'):
+    try:
+        d = json.load(open(f))
+    except Exception:
+        continue
+    for p in (d.get('pushed') or []):
+        total += 1
+        path = p.get('path') or ''
+        if path and os.path.isfile(path):
+            exist += 1
+print("pushed 总条数 =", total, "| path 仍存在 =", exist)
+PY
+```
+
+---
+
+# 必须回答的三个问题(不要给建议,只要数值)
+
+```
+## 1. 磁盘
+总容量 = ?    已用 = ?    可用 = ?    使用率 = ?
+容器磁盘配额(如果有)= ?
+
+## 2. /opt/data 大头在哪
+前 5 个目录及大小 = ?
+
+## 3. 增量
+最近 7 天新增文件数 = ?
+最近 7 天新增体积 = ?
+平均每天新增 = ?
+
+## 4. 现状
+dry-run 可清理数 = ?
+pushed 里 path 仍存在 = ?
+```
+
+🔴 **只要数字。不要提议方案。不要动任何文件。**

+ 79 - 0
docs/03-interface/问-kangshifu-重启是否真实.md

@@ -0,0 +1,79 @@
+# 🔴 严重质疑:你那次"重启验证"可能是假的
+
+我发现一份历史文档(2026-11-13,lj-test)写着:
+
+> 真正跑 gateway 的是 PID 94769,由 rc.init 的 `hermes gateway run --replace` 启动,**不在 s6 监督下**
+>
+> **所以 `s6-svc -r /run/service/gateway-default` 重启的是一个本来就没运行的备用槽,等于没重启。**
+
+你上一轮说「我执行了 `s6-svc -r /run/service/gateway-default`,网关被重启,之后自动拉起」,并据此断言 finish 守卫生效。
+
+**如果这条仍然成立,那你根本没有重启真正的网关进程,守卫也没被考验。**
+
+---
+
+## 请立刻核实(只读)
+
+```bash
+echo "=== ① s6 槽位的真实状态 ==="
+s6-svstat /run/service/gateway-default 2>&1
+
+echo "=== ② 真正在跑的 gateway 进程(含启动时间)==="
+ps -eo pid,lstart,cmd | grep "gateway run" | grep -v grep
+
+echo "=== ③ 容器启动时间(判断是否从未重启)==="
+ps -o lstart -p 1
+cat /proc/uptime
+
+echo "=== ④ gateway.lock ==="
+ls -la /opt/data/gateway.lock 2>&1
+cat /opt/data/gateway.lock 2>&1
+
+echo "=== ⑤ 你那次重启前后的时间线 ==="
+echo "现在: $(date -Is)"
+ls -la --time-style=full-iso /run/service/gateway-default/finish
+tail -20 /opt/data/logs/gateway-finish-guard.log
+```
+
+---
+
+## 判断标准
+
+**如果 ② 里 gateway 进程的启动时间,早于你执行 `s6-svc -r` 的时间** →
+说明**真正的网关没被重启**,那次验证无效。
+
+**如果 ① 显示 `down` 或 `exitcode` 异常** →
+说明 s6 槽位确实是空转的。
+
+---
+
+## 如果验证无效,要怎么真验
+
+不要用 `s6-svc -r`。应该:
+
+1. 找到真正跑 gateway 的进程 PID
+2. 用它的实际启停方式(`hermes gateway run --replace`)
+3. **重启后立刻看** `grep -c FMODE-FINISH-GUARD /run/service/gateway-default/finish`
+4. 确认插件确实在进程启动时把守卫写回去了
+
+---
+
+## 回答格式
+
+```
+## 1. s6 槽位状态
+<s6-svstat 输出>
+
+## 2. gateway 进程
+<ps 输出,重点看 STARTED 时间>
+
+## 3. 我上次重启的时间
+<精确时间>
+
+## 4. 结论
+gateway 进程是否在重启时间之后启动过? 是 / 否
+那次验证:有效 / 无效
+
+## 5. 如果无效,正确的重启方式是什么
+<给出这台机器上真实可用的命令>
+```

+ 125 - 0
docs/03-interface/问-ljtest-GC频率与执行实况.md

@@ -0,0 +1,125 @@
+# 三个关于 GC 调度的核查问题(只读,要证据)
+
+我要核三件事,**不要给建议,只要事实**。
+
+---
+
+## 1. 频率到底是多少?(贴原文)
+
+```bash
+echo "=== jobs.json 里 GC 作业的完整定义 ==="
+python3 - <<'PY'
+import json
+d = json.load(open('/opt/data/cron/jobs.json'))
+for j in d.get('jobs', []):
+    n = (j.get('name') or '') + (j.get('script') or '')
+    if 'gc' in n.lower() or 'cleanup' in n.lower():
+        print(json.dumps(j, ensure_ascii=False, indent=2))
+PY
+
+echo "=== wrapper 脚本原文 ==="
+cat /opt/data/scripts/fmode-media-gc-wrapper.sh 2>/dev/null || echo "(无 wrapper)"
+```
+
+**要回答**:`schedule.minutes` 实际是几?是 30 吗?
+
+---
+
+## 2. 这个 30 分钟**真的在执行**吗?
+
+不要看 `last_run_at` 一个字段就下结论。要看**执行历史**:
+
+```bash
+echo "=== log 里最近 20 条 GC 执行记录(看时间间隔)==="
+grep "GC done" /opt/data/logs/media-gc.log 2>/dev/null | tail -20
+
+echo "=== executions.db 里 GC 作业的历史(至少 10 条)==="
+python3 - <<'PY'
+import sqlite3, json
+c = sqlite3.connect('/opt/data/cron/executions.db')
+c.row_factory = sqlite3.Row
+# 先看表结构
+print("--- 表 ---")
+for r in c.execute("select name from sqlite_master where type='table'"):
+    print(r[0])
+print("--- 最近 15 条 ---")
+for r in c.execute("select * from executions order by rowid desc limit 15"):
+    print(dict(r))
+PY
+```
+
+**要回答**:
+- 相邻两次执行的时间间隔实际是多少分钟?
+- **有没有断档**(比如几小时没有记录)?
+- 失败的次数?
+
+---
+
+## 3. 清理量有多大?30 分钟是不是过度?
+
+```bash
+echo "=== 最近 20 次 GC 的 removed/scanned 数字 ==="
+grep "GC done" /opt/data/logs/media-gc.log 2>/dev/null | tail -20 | sed 's/^\[//;s/\]//'
+
+echo "=== 统计 ==="
+grep "GC done" /opt/data/logs/media-gc.log 2>/dev/null | awk -F'removed=' '{split($2,a," "); r+=a[1]; n++} END {print "执行次数="n, "总删除="r, "平均每次删除="(n?r/n:0)}'
+
+echo "=== 每天大概删多少文件(按日期聚合)==="
+grep "GC done" /opt/data/logs/media-gc.log 2>/dev/null | awk '{print $1}' | cut -c1-11 | sort | uniq -c
+
+echo "=== 当前磁盘占用(交付物目录)==="
+du -sh /opt/data/work/ 2>/dev/null
+du -sh /opt/data/work/deliverables 2>/dev/null
+echo "=== 未清理的文件有多少 ==="
+ls /opt/data/cache/deliverable-hook/*.json 2>/dev/null | wc -l
+```
+
+**要回答**:
+- 每次 GC 平均删几个文件?
+- 一天删几次?
+- 交付物目录现在占多少空间?
+- 如果改成 1 小时/2 小时一次,会不会积压?
+
+---
+
+## 4. 额外:有没有「按量触发」的先例?
+
+```bash
+echo "=== jobs.json 里所有作业的 schedule 种类 ==="
+python3 - <<'PY'
+import json
+d = json.load(open('/opt/data/cron/jobs.json'))
+for j in d.get('jobs', []):
+    print(j.get('name'), '|', json.dumps(j.get('schedule'), ensure_ascii=False), '|', j.get('enabled'))
+PY
+
+echo "=== 调度器支持哪些 schedule 类型(读源码)==="
+grep -rn "kind.*==\|'interval'\|'cron'\|'event'\|schedule.get" /opt/hermes/cron/*.py 2>/dev/null | head -30
+```
+
+**要回答**:调度器**支持**哪些 schedule 类型?除 `interval` 外还有什么?
+
+---
+
+# 回答格式
+
+```
+## 1. 频率
+schedule = ?
+
+## 2. 真的在执行吗
+最近 20 次时间戳 = ?
+相邻间隔 = ?
+断档情况 = ?
+失败次数 = ?
+
+## 3. 清理量
+执行次数 / 总删除 / 平均 = ?
+每天分布 = ?
+交付物目录大小 = ?
+待清理文件数 = ?
+
+## 4. 支持的 schedule 类型
+jobs.json 中各作业的 schedule = ?
+源码里支持的类型 = ?
+```