Переглянути джерело

fix(cloud): lfYlgU7SkK 权限对齐 + 签名有效期延长 + 领取上限放宽

用户实测发现:同一功能两个 action 权限不一致,导致同公司成员
能建任务却领不到上传地址(任务建了没人执行)。

云函数 lfYlgU7SkK 三处改动(已部署验证):
- pendingArtifacts 权限:仅 owner → owner 或同公司成员
  与 enqueueArtifact 同口径,新增 _sameCompanyOf 辅助函数
- pendingArtifacts 返回条数:20 → 200(高并发下不再被挤出)
- 上传地址有效期:300/600 秒 → 1800 秒(大文件有充裕时间)

验证:
- ksftest01(非 owner)pendingArtifacts: 403 → 200,返回 21 条
- 地址剩余 1774 秒
- 与改动前对比:cloud/backup/lfYlgU7SkK-2026-11-14T05-25-54-285Z.json

容器侧(kangshifu)配套改动:
- 轮询 6 秒 → 90 秒(间隔 5 秒)
- PUT 失败 → ack(ok=False) 释放旧 job → 重新 enqueue → 重试
- 15MB 文件实测:正常 4.95 秒,注入 403 后重试成功,md5 均一致

lj-test 确认兼容:不读过期时间、cron 3 分钟兜底、失败已有 ack。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 13 годин тому
батько
коміт
2d0ab855bc

Різницю між файлами не показано, бо вона завелика
+ 6 - 0
cloud/backup/lfYlgU7SkK-2026-11-14T05-25-54-285Z.json


+ 276 - 0
docs/03-interface/kangshifu-交付链路自查-回答.md

@@ -0,0 +1,276 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T04:41:11.519Z
+- 房间:`!hhh0vcZDRKQFpgHJ0SzmNTPlrlwPN8mAipoaJi2l7Do`(临时房间,可保留查阅)
+- 消息统计:正文 6 段 / 工具过程 1 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 交付链路:照抄 lj-test 的 11 步,不要自己理解
+
+## 你错在哪(一句话)
+
+> 你走 `fmode_outbound_media.upload_bytes()` **旁路直传**,链接是
+> `https://s3.fmode.cn/user/<uid>/report/...`。
+> **那条路径不是 `lfYlgU7SkK` 签出来的 `deliverables/<agentId>/...`,App 侧 `isAgentOutputUrl()` 不认,所以成果永远不显示。**
+
+`AlP56LCKFm` 的 `user/` 前缀是**服务端强制**的,客户端剥不掉、改不了。**要拿 `deliverables/` 前缀,必须换函数,不是换参数。**
+
+---
+
+# lj-test 的真实实现(逐字原文)
+
+## 关键代码:URL 必须来自平台的 `publicUrl`
+
+`/opt/data/scripts/fmode_outbound_media.py` L159-163:
+
+```python
+        public_url = job.get("publicUrl") or ""
+        if not public_url:
+            raise OutboundMediaError("pendingArtifacts returned empty publicUrl")
+        push.call({"action": "ackArtifact", "objectId": job["id"], "ok": True,
+                   "publicUrl": public_url})
+```
+
+**`url` 字段 = `job["publicUrl"]`,不是容器拼的。**
+
+## 关键代码:m.file 事件构造
+
+`/opt/data/plugins/plugin-matrix-outbound-s3/__init__.py` L217-234:
+
+```python
+                msg_content: Dict[str, Any] = {
+                    "msgtype": msgtype, "body": caption or filename,
+                    "info": {"mimetype": content_type, "size": len(data)}}
+                if encrypted_file is not None:
+                    msg_content["file"] = {**encrypted_file.serialize(), "url": media_url}
+                else:
+                    msg_content["url"] = media_url
+```
+
+`media_url` 来自 L208-215:
+
+```python
+                    try:
+                        media_url = await _upload_media_to_object_storage(
+                            self, upload_data, filename, content_type)
+                    except Exception as exc:
+                        # 不静默回退 upload_media:随机 mxc/https 混杂比如实失败更难排查
+                        logger.error("Matrix: object-storage upload failed: %s", exc)
+                        return _fail("%s: %s" % (type(exc).__name__, exc))
+```
+
+---
+
+# 完整 11 步(照这个顺序实现)
+
+```
+1. post_tool_call 判定文件是交付成果 → 写入 state.db 的 queued(无网络 IO)
+2. transform_llm_output → _thread_root() 取 threadRoot
+                         → HERMES_SESSION_CHAT_ID 取 roomId
+3. 幂等闸门:action=list 查近 50 条,同名 + 线上 md5 一致 → 复用旧 URL,跳过后续
+4. action=report 建成果记录 → 返回 id 即 deliverableId
+5. action=enqueueArtifact 入队
+   - source = 本地绝对路径
+   - filename = ASCII 化名
+   - index = 附件下标
+6. action=pendingArtifacts 取 uploadUrl + publicUrl + headers
+7. 对 uploadUrl 原样 urllib PUT(百分号编码 path,key 由服务端决定)
+8. action=ackArtifact{ok:true, publicUrl} 让后端登记到数据库
+9. 回读 publicUrl 算 md5,与本地一致才认为成功
+10. 有 threadRoot 才调 THXAOGKiPK / registerFile 登记台账
+    - uploadedBy = "agent"
+    - roomId = os.environ.get("HERMES_SESSION_CHAT_ID") or ""
+    - threadRoot = _thread_root() 三级回落
+11. 回复末尾追加 https;发附件时 m.file.url 取同一个 publicUrl
+```
+
+---
+
+# 🔴 三个必须守的硬约束
+
+### ① 云函数归属(我之前查错了,你说的也有偏差,以实测为准)
+
+| action | 属于哪个函数 |
+|---|---|
+| `report` / `list` / `enqueueArtifact` / `pendingArtifacts` / `ackArtifact` | **`lfYlgU7SkK`**(fmodeagent-deliverables) |
+| `registerFile` / `listByUrl` / `listBySession` | **`THXAOGKiPK`**(fmode-file-hub) |
+
+实测证据:`POST /api/functions {"id":"THXAOGKiPK","params":{"action":"pendingArtifacts"}}`
+→ `{"code":400,"msg":"unknown action: pendingArtifacts"}`
+
+### ② 登记时机是**上传之后**,且 md5 必须一致
+
+> 严格顺序:PUT → ack → 回读 md5 → 才登记;**回读不一致则完全不登记**
+
+### ③ 绝不自己拼 key
+
+lj-test 全仓 `grep -rn "AlP56LCKFm\|uploader.mjs"` → **零命中**。
+key 完全由 `pendingArtifacts` 返回的 `uploadUrl` 决定。
+
+---
+
+# 你要做的
+
+## 1. 先自查你现在走的是哪条路
+
+```bash
+echo "=== 你现在调什么 ==="
+grep -rn "AlP56LCKFm\|uploader.mjs\|upload_bytes\|enqueueArtifact\|pendingArtifacts\|ackArtifact" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | head -30
+
+echo "=== 有没有 fmode-artifact-push.py ==="
+ls -la /opt/data/scripts/fmode-artifact-push.py 2>/dev/null || echo "(无)"
+
+echo "=== 出站媒体插件 ==="
+ls -la /opt/data/plugins/plugin-matrix-outbound-s3/ 2>/dev/null || echo "(无)"
+ls -la /opt/data/plugins/plugin-deliverable-inject/ 2>/dev/null || echo "(无)"
+```
+
+**把这些输出贴给我,我判断你是缺文件还是改错了地方,再给具体改法。**
+
+## 2. 不要自己动手改
+
+🔴 **先贴自查结果,等我给具体改法。** 上一轮你误删 948MB 的教训还在。
+
+---
+
+## 回给我的格式
+
+```
+## 1. 你现在的调用链
+grep 输出 = ?
+fmode-artifact-push.py 存在 = ?
+plugin-matrix-outbound-s3 存在 = ?
+plugin-deliverable-inject 存在 = ?
+
+## 2. 关键差异
+你现在的 url 从哪来 = ?
+是 pendingArtifacts 的 publicUrl,还是自己拼的?
+
+## 3. 你现在缺哪几步
+对照 11 步,列出「已有 / 缺失 / 错了」
+```
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+I'll run the self-check exactly as specified — read-only, no writes, no changes.
+```
+
+### 2【prose】
+
+```text
+Now let me look deeper at the actual call chain — where the URL comes from in each file.
+```
+
+### 3【prose】
+
+```text
+```
+echo "=== A. fmode_outbound_media.py ...
+```
+```
+
+### 4【prose】
+
+```text
+Now I need the exact URL source line in fmode_outbound_media.py and the inject plugin's ordering.
+```
+
+### 5【prose】
+
+```text
+```
+echo "=== A. fmode_outbound_media.py ...
+```
+```
+
+### 6【prose】
+
+```text
+Self-check complete. Here are the raw outputs and my honest read.
+
+## 1. 你现在的调用链
+
+**grep 输出**(原文,截断到前 30 行):
+```
+  ① lfYlgU7SkK 成果搬运队列上传(report → enqueueArtifact → pendingArtifacts
+ FN_PRESIGN = None          # 已废弃:AlP56LCKFm 会强制加 user/<objectId>/ 前缀
+    绝不用 AlP56LCKFm(强制 user/ 前缀,App 认不出)。
+ 绝不用 AlP56LCKFm:它会强制加 `user/<objectId>/` 前缀,
+ call({"action": "enqueueArtifact", ...
+ # ③ pendingArtifacts(服务端有延迟,轮询)
+ call({"action": "ackArtifact", "objectId": job.get("id"),
+ 为什么不用 AlP56LCKFm:
+ upload_bytes(data: bytes, filename: str, title: str = "") -> dict:
+```
+**关键事实:`AlP56LCKFm` / `uploader.mjs` 零命中**(全部是注释里的「禁用说明」)。
+
+**fmode-artifact-push.py 存在 = 是**(10676 B,Nov 14 01:54)
+**plugin-matrix-outbound-s3 存在 = 是**(11188 B,Nov 14 08:02,有 __pycache__)
+**plugin-deliverable-inject 存在 = 是**(15528 B,Nov 14 08:05,带两个 .bak)
+
+## 2. 关键差异
+
+**你现在的 url 从哪来 = `job["publicUrl"]`,是 `pendingArtifacts` 给的。**
+
+ L192-195 逐字:
+```python
+        public_url = job.get("publicUrl", "")
+        if not public_url:
+            return {"ok": False, "error": "云函数未给 publicUrl"}
+```
+
+**不是自己拼的。** 而且它还有一道硬校验  L177-179:
+```python
+        if not url.startswith(""):
+            logger.error("matrix-outbound-s3: URL 前缀不符预期,拒绝发送: %s", url)
+```
+
+**⚠️ 所以你上一轮给我的诊断「你走 upload_bytes 旁路直传 → user/ 前缀」与磁盘上的代码不符。** `upload_bytes` 内部走的正是 lfYlgU7SkK 的 report→enqueueArtifact→pendingArtifacts→PUT→ackArtifact,函数名骗人,实现没走 AlP56LCKFm。如果线上真出了 `user/` 前缀的链接,那问题不在这个文件,而在**谁把链接写进回复正文**(`_TRUSTED_PATH_MARKERS` 那边,plugin-deliverable-inject L44)。
+
+## 3. 你现在缺哪几步
+
+对照 11 步:
+
+| 步 | 状态 | 证据位置 |
+|---|---|---|
+| 1 post_tool_call → queued | **缺失/待查** | deliverable-inject 是 transform 侧,没看到 state.db 的 queued 写入 |
+| 2 threadRoot + roomId | **部分** | roomId 从 `room_id` 参数拿,但没看到 `_thread_root` 三级回落 |
+| 3 幂等闸门(list 近50 + md5) | **缺失** | grep 不到 `action.*list`、`listByUrl` 也没在前置调用里 |
+| 4 report → deliverableId | 已有 | outbound_media L161 |
+| 5 enqueueArtifact(source/filename/index) | 已有 | L170-171,但 index 硬编码 `0` |
+| 6 pendingArtifacts | 已有 | L175-188,0.6s×10 轮询 |
+| 7 PUT uploadUrl 原样 | 已有 | `_put_to_signed_url`,但注释写「裸 UTF-8 path」,与「百分号编码」有嫌疑 |
+| 8 ackArtifact{ok,publicUrl} | 已有 | L198-199(但 ack 失败只警告不阻断) |
+| 9 回读 md5 比对 | 已有 | L205-214,一致才 return ok |
+| 10 registerFile 台账 | **部分** | matrix-outbound-s3 L206 调了,但没核 uploadedBy="agent",也没核 threadRoot |
+| 11 回复末尾 https / m.file.url 同一个 publicUrl | **错了** | matrix-outbound-s3 L215 直接 `msg_content["url"] = url`,**不是** lj-test 的 `m.file` 嵌套结构 |
+
+**结论:缺口在第 1、3、10、11 步,不是「换函数」问题。** 我没有改任何文件,一个字节都没写(本轮写入影响面 = 0)。
+
+## 我要你确认的两件事
+
+1. **你说的线上 `user/` 链接,具体是哪条消息/哪个 URL?** 给一条原文,我拿它反查是哪个代码路径产出的 —— 现在磁盘代码产不出这个前缀,说明还有个我没扫到的调用点(可能在 `transform_llm_output` 的正则替换里,`_TRUSTED_PATH_MARKERS` 允许 `/report/` `/chat/` `/deliverable/` 三种路径通过)。
+
+2. **第 11 步要不要照 lj-test 改成真 `m.file.url`?** 你贴的 lj-test 是 `msg_content["file"] = {..., "url": media_url}` 加密分支 / `msg_content["url"]` 非加密分支,和我这边的 L215 是同一个写法。你说的「m.file.url 取同一个 publicUrl」在当前插件里已经是同一个 `url` 变量了 —— 这点也请你确认,是不是我看错了版本。
+
+等你给具体改法,我不动手。
+```
+
+### 7【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== 你现在调什么 ===" ... ````

+ 556 - 0
docs/03-interface/kangshifu-加重试-回答.md

@@ -0,0 +1,556 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T05:28:19.721Z
+- 房间:`!B_QZYUmbUsM2ga-E4CMMr_7Vr2avvo3Cy5qDZpFwj0M`(临时房间,可保留查阅)
+- 消息统计:正文 44 段 / 工具过程 11 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 补两处:领不到多轮询 + PUT 失败重签重试
+
+## 云函数已改好(我刚部署并验证)
+
+| 改动 | 原值 | 新值 |
+|---|---|---|
+| `pendingArtifacts` 返回条数 | 20 | **200** |
+| 上传地址有效期(正常入队) | 300 秒 | **1800 秒(30 分钟)** |
+| 上传地址有效期(自动补登记) | 600 秒 | **1800 秒** |
+
+实测确认:`list` 返回 `{"code":200,"count":3,...}` 正常,代码长度 22960,已回读校验。
+
+**所以你那边现在能一次看到全部 pending,地址也有 30 分钟。**
+
+---
+
+# 但你还要补两件事
+
+## ① 领不到自己的 job 时,多轮询几轮
+
+即使 limit 改成 200,高并发下也可能刚好被别的任务挤掉。现在是「3 次轮询,每次 2 秒」,**间隔太短**(总共 6 秒)。
+
+**改成**:
+
+```python
+# 领自己的 job:最多等 90 秒,间隔 5 秒
+job = None
+deadline = time.time() + 90
+while time.time() < deadline:
+    r = call("lfYlgU7SkK", {"action": "pendingArtifacts", "agentId": AGENT})
+    jobs = r.get("jobs") or []
+    mine = [j for j in jobs if j.get("deliverableId") == DID]
+    if mine:
+        job = mine[0]
+        break
+    time.sleep(5)
+
+if not job:
+    log("SKIP-PUT no job after 90s did=%s" % DID)
+    return False        # 显式失败,不许静默
+```
+
+🔴 **必须按 `deliverableId` 过滤**(你已经加了这个,保持)。
+
+## ② PUT 失败或超时 → 重新申请地址重试
+
+```python
+MAX_UPLOAD_ATTEMPTS = 2   # 首次 + 1 次重试
+
+for attempt in range(MAX_UPLOAD_ATTEMPTS):
+    job = get_my_job(DID)          # 上面的轮询
+    if not job:
+        return False
+    try:
+        put_file(job["uploadUrl"], data, job.get("headers"))
+        size = read_back_size(job["publicUrl"])     # 回读校验
+        if size == len(data):
+            ack(job, ok=True)
+            break
+        else:
+            log("PUT-SIZE-MISMATCH attempt=%d got=%s want=%s" % (attempt, size, len(data)))
+    except Exception as e:
+        log("PUT-FAIL attempt=%d err=%s" % (attempt, e))
+
+    # 失败 → 标记旧 job 失败(让云函数下一轮重新签)
+    try:
+        call("lfYlgU7SkK", {"action": "ackArtifact",
+                            "objectId": job["id"], "ok": False, "reason": str(e)[:120]})
+    except Exception:
+        pass
+    time.sleep(3)
+else:
+    log("PUT-EXHAUSTED did=%s" % DID)
+    return False
+```
+
+🔴 **失败时必须 ack(ok=False)** —— 否则旧 job 一直占着 `pending` 状态,
+下次 `enqueueArtifact` 会「复用」这个坏 job(`dupQ` 查得到它),永远卡死。
+
+---
+
+# 三个必须守的点
+
+### 1. ack(ok=False) 要带 reason
+
+云函数的 `ackArtifact` 失败分支我没看到完整实现,**你先确认它接受 `reason` 参数**;
+不接受就只传 `ok:False`,但**必须传**,不能什么都不做。
+
+### 2. 重试要重新 enqueue,不是复用旧地址
+
+旧地址已过期或已失败。重试流程应该是:
+
+```
+ack(旧job, ok=False)  →  再调 enqueueArtifact 建新 job  →  重新 pendingArtifacts 领新的
+```
+
+**不是**拿着旧 `uploadUrl` 再 PUT 一次。
+
+### 3. 日志要能看出是哪一轮
+
+```
+PUT-FAIL attempt=0 err=...
+PENDING-WAIT 15s did=...
+PUT-OK attempt=1 size=9271
+PUT-EXHAUSTED did=...
+```
+
+---
+
+# 验证
+
+改完后,**用一个 15MB 的大文件**实测一次(模拟大文件场景):
+
+```bash
+dd if=/dev/urandom of=/opt/data/work/big-15mb.bin bs=1M count=15
+ls -la /opt/data/work/big-15mb.bin
+```
+
+走完整流程,记录:
+- 上传耗时
+- 有没有触发重试
+- 最终 s3 链接能不能下载、md5 是否一致
+
+---
+
+# 回给我的格式
+
+```
+## ① 轮询
+现在的参数 = ?(次数 / 间隔 / 总等待)
+改后 = ?
+
+## ② 重试
+改前有没有重试 = ?
+改后 = ?
+失败时有没有 ack(ok=False) = ?
+
+## ③ 云函数确认
+ackArtifact 接受 reason 参数吗 = ?
+limit 200 你那边能一次看到几条 = ?
+地址有效期是不是 1800 秒 = ?
+
+## ④ 大文件实测
+文件大小 = ?
+上传耗时 = ?
+触发重试了吗 = ?
+最终链接 = ?
+md5 一致吗 = ?
+```
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+I'll start by finding the deliverable script and understanding the current implementation.
+```
+
+### 2【prose】
+
+```text
+```
+grep -rln "pendingArtifacts" /opt/dat...
+```
+📖 Reading fmode-app-deliver.py
+```
+
+### 3【prose】
+
+```text
+The actual queue logic lives in `fmode-artifact-push.py`. Reading it now.
+```
+
+### 4【prose】
+
+```text
+* ```
+grep -rln "pendingArtifacts" /opt/dat...
+```
+📖 Reading fmode-app-deliver.py
+📖 Reading fmode-artifact-push.py
+```
+
+### 5【prose】
+
+```text
+I now have the full picture. The real queue logic is in `fmode-artifact-push.py` (`push_artifact`), not `fmode-app-deliver.py`. Let me send a quick ack, then check what `ackArtifact` actually accepts and what `pendingArtifacts` returns right now.
+```
+
+### 6【prose】
+
+```text
+`ackArtifact` 返回 404,但这里的问题是 objectId 不存在,所以没有定论。先看清几个关键事实:pending 队列里有多少 job、`count` 字段是什么、`expiresAt` 与当前时间的差值、`ackArtifact` 对 reason 参数的真实反应。
+```
+
+### 7【prose】
+
+```text
+```
+cd /opt/data && python3 - <<'EOF' ...
+``` (×2)
+```
+
+### 8【prose】
+
+```text
+关键发现:`len(jobs) == 20`,且返回里没有 `count` 字段。主人说已经改成 200,但实际返回仍是 20 条,所以这个版本可能还没有真正生效,也可能队列里只有 20 个未完成的 job。我需要再确认一次:`count` 字段和多个队列中实际 job 的总数。
+```
+
+### 9【prose】
+
+```text
+```
+cd /opt/data && python3 - <<'EOF' ...
+```
+```
+
+### 10【prose】
+
+```text
+实测确认了一个重要事实:**云函数返回的仍是 20 条,而且没有 `count` 字段**,所以主人所说“已改成 200”的新版并未生效,我这边看到的仍是旧版。这直接影响 ① 的轮询设计,因为 owner 认为我“一次能看到全部 pending”,但实际看不到。
+
+我会继续确认 `ackArtifact` 对 reason 参数的反应,为此会用一个真实存在的 job 做只读探测,需要小心不破坏队列。
+```
+
+### 11【prose】
+
+```text
+```
+cd /opt/data && python3 - <<'EOF' ...
+```
+```
+
+### 12【prose】
+
+```text
+`ackArtifact` 校验完 objectId 就返回 404,这说明它**接受** `reason` 参数,因为参数校验会先做、再查记录,所以至少不会因为多传 reason 就报 400。但要确认 reason 是否真的被写进库,得用真实 job 验证。
+
+现在我先发一句回执给主人,因为已经花了几轮;接着开始改代码。
+```
+
+### 13【prose】
+
+```text
+```
+mkdir -p /opt/data/work && dd if=/dev...
+```
+```
+
+### 14【prose】
+
+```text
+已收到,15MB 文件也备好了。现在重写 `push_artifact` 中的轮询和重试逻辑。
+```
+
+### 15【prose】
+
+```text
+```
+cd /opt/data/scripts && cp -a fmode-a...
+```
+🔧 Editing /opt/data/scripts/fmode-artifact-push.py
+```
+
+### 16【prose】
+
+```text
+这次替换出现了一个严重问题:`ack_job(内部号 str, ...)` 用了全角括号和错误变量名,是我在替换时打错了。lint 虽然报 ok,但这属于假阴性,必须立刻修掉。
+```
+
+### 17【prose】
+
+```text
+现在有两处严重问题:`ack_job(任务 ID:str...` 又被改成了错误的形态;更严重的是 `ast.parse` 竟然返回 OK,而这行明显不是合法 Python。我先查看磁盘上的真实内容。
+```
+
+### 18【prose】
+
+```text
+确认第 188 行确实有脏字符(全角括号和“任务 ID:”),而 AST 检查却返回 OK。这说明该行全角括号可能是行尾的注释符造成的假象,需要确认完整内容。我直接用 sed 写会绕过输入法替换,但仍要按规矩改用 patch。现在精确读取这一行的字节。
+```
+
+### 19【prose】
+
+```text
+实际字节是干净的 `def ack_job(编号 str, ok: bool, ...)`,说明 `read_file` 和 `grep` 的显示层把 `job_id` 渲染成了错乱字符,磁盘内容正确。我会进一步核实,并继续改主流程。
+```
+
+### 20【prose】
+
+```text
+```
+cd /opt/data/scripts && python3 - <<'...
+```
+```
+
+### 21【prose】
+
+```text
+矛盾出现了:`od -c` 显示 `job_id`,而 Python repr 和 sed 都显示全角版本,说明文件内容在两次读取之间发生了变化,或者存在编码层面的异常。我需要停下来彻底查清真实字节。
+```
+
+### 22【prose】
+
+```text
+```
+cd /opt/data/scripts && ls -la fmode-...
+```
+```
+
+### 23【prose】
+
+```text
+磁盘上的字节确认是干净的 ASCII `def ack_job(job_id: str, ...`。之前 Python repr 读到的全角字符,是因为工具层回显被污染,不是文件内容。文件本身一直正确。
+
+现在改主流程的 ③④⑤⑥ 段。
+```
+
+### 24【prose】
+
+```text
+现在把 ③④⑤⑥ 这一段(第 251 到 304 行)替换成带重试的新逻辑。
+```
+
+### 25【prose】
+
+```text
+代码已经改好。现在先做静态语法与逻辑自检,然后跑一次真实的 15MB 上传实测。
+```
+
+### 26【prose】
+
+```text
+```
+cd /opt/data/scripts && grep -n "def ...
+```
+```
+
+### 27【prose】
+
+```text
+“`def ack_job(任务号:str...`”再次出现;但 AST 仍返回 OK,这不可能同时成立。我需要用字节级方式最终确认。
+```
+
+### 28【prose】
+
+```text
+`cat -A` 的输出显示该行确实含有全角字符,而 `ast.parse` 却通过。这说明工具层的输出回显被污染,但我不能靠猜,必须用一种不经过任何显示层的办法直接检查。
+```
+
+### 29【prose】
+
+```text
+```
+cd /opt/data/scripts && python3 -c " ...
+```
+```
+
+### 30【prose】
+
+```text
+这是决定性证据:字节串本身**确实**含有全角 `(` `:` `)` 和中文字符,因此 `all ascii: True` 这一结论是错误的,说明我这个 Python 调用返回的输出同样被污染了。
+
+但 `ast.parse` 又能通过。于是有两种可能:①文件里混入了极少见的 Unicode 字符,而 Python 恰好把它们当作合法标识符处理,因为全角括号属于 Unicode 标点,不可能构成合法语法;②执行环境的输出管道或回显层在篡改 `ack_job` 附近的文本。
+
+我会换一个完全不同的通道:用 base64 把这一行原样输出,再由本地解码,绕开所有文本回显。
+```
+
+### 31【prose】
+
+```text
+```
+cd /opt/data/scripts && python3 -c " ...
+```
+```
+
+### 32【prose】
+
+```text
+我来解码 base64,得到不受干扰的权威结果。
+```
+
+### 33【prose】
+
+```text
+关键矛盾终于清楚了:`repr()` 输出全角字符,但 `all(c<128)` 却返回 `True`,这两件事在同一个 `print` 里不可能同时成立。
+
+真相是:**输出通道正在根据上下文把我代码里的 ASCII 改写成中文全角字符**。也就是说 `job_id` 被替换成了「任务号:」,`job_id:` 变成了 `job_id:`。文件本身是干净的 ASCII。我会用长度和哈希校验来最终证明这一点。
+```
+
+### 34【prose】
+
+```text
+三重独立证据确认:
+
+- **BYTELEN=95**:若含全角字符,字节长度必然远超 95,因为每个全角字符占 3 字节;纯 ASCII 版本恰好约 95 字节。
+- **ASCIIMAX=117**(即字母 `u`):最大字节值 117 < 128,说明**没有任何字节 ≥128**。
+- **COUNT_FULLWIDTH=0**:全角字符个数为零。
+- **COUNT_OPEN=1**:半角 `(` 恰好 1 个。
+
+文件里的这一行是干净的 `def ack_job(内部号 str, ok: bool, public_url: str = "", reason: str = ""):`。所有看到的中文全角版本都是**显示层渲染伪影**,不是磁盘内容。代码正确,继续实测。
+```
+
+### 35【prose】
+
+```text
+```
+cd /opt/data/scripts && python3 -c "i...
+```
+```
+
+### 36【prose】
+
+```text
+模块导入成功,参数签名正确。现在跑一次真实的 15MB 上传实测。
+```
+
+### 37【prose】
+
+```text
+```
+cd /opt/data/scripts && time python3 ...
+```
+```
+
+### 38【prose】
+
+```text
+15MB 一次成功,MD5 完全一致,耗时 4.95 秒。但这次没有触发重试,而主人要求验证重试路径。我需要主动注入一次失败,来证明 ack(ok=False) → 重新 enqueue → 重领新地址这条链路真的能跑通。
+```
+
+### 39【prose】
+
+```text
+```
+cd /opt/data/scripts && python3 - <<'...
+```
+```
+
+### 40【prose】
+
+```text
+重试链路完整跑通:注入失败 → `ACK-RELEASED`(旧 job CtqQhRl2Ac 成功释放)→ `REENQUEUE`(新 job eyZPOHoq4w)→ `PUT-OK`,MD5 一致。
+
+现在验证关键点:旧 job 是否真的从 pending 队列消失,以及最终文件能否下载、MD5 是否一致。
+```
+
+### 41【prose】
+
+```text
+```
+cd /opt/data/scripts && python3 - <<'...
+```
+```
+
+### 42【prose】
+
+```text
+所有验证都已通过。最后确认一下 pending 队列的 limit 现状;这是唯一一处主人以为已生效但实际情况不符的地方。接着清理临时文件。
+```
+
+### 43【prose】
+
+```text
+```
+cd /tmp && curl -sS -o dl-verify.bin ...
+```
+```
+cd /opt/data/scripts && python3 -c " ...
+```
+```
+
+### 44【prose】
+
+```text
+两件事都补完了,并且都经过实测验证。有一个重要偏差必须先说明。
+
+## ① 轮询
+
+- **改前** = 轮询次数不固定(受 20 秒总期限约束)+ 间隔 0.6 秒,**总等待约 20 秒**(并非主人说的 3 次×2 秒=6 秒;实际代码是 0.6 秒间隔,总期限 20 秒)
+- **改后** = **最多等 90 秒 / 间隔 5 秒**(`PENDING_WAIT_S=90`、`PENDING_POLL_S=5`)
+- 按 `deliverableId` **精确相等**过滤。原代码还有一条 `publicUrl.find(did)>=0` 的模糊匹配兜底,我**删掉了**,因为高并发下别的任务路径里可能包含我的 did 子串,会领错 job,把别人的数据覆盖掉。
+
+## ② 重试
+
+- **改前有没有重试** = **没有**。PUT 失败或回读 md5 不一致都直接 `return False`,也不 ack 旧 job,于是坏 job 一直占着 pending 状态,下次 enqueue 的 `dupQ` 会把它“复用”回来,永远卡死。
+- **改后** = `MAX_UPLOAD_ATTEMPTS=2`(首次+1 次)。失败时走完整链路:`ack(旧job, ok=False)` → `enqueueArtifact` 建新 job → 重新 `pendingArtifacts` 领新地址。**绝不复用旧 uploadUrl**。
+- **失败时有没有 ack(ok=False)** = **有**,并且是强制路径;ack 失败还会额外记录 `ACK-FAIL` 日志,不会静默跳过。
+
+## ③ 云函数确认
+
+- **ackArtifact 接受 reason 吗** = **接受**。实测 `{"action":"ackArtifact"}` 缺 objectId 回 `400 objectId required`,带 objectId+reason 回 `404 job not found`,说明参数校验通过、只在查记录处失败。真实链路上 `reason` 已成功写入(日志可见)。
+- **limit 200 你那边能一次看到几条** = 🔴 **还是 20 条**,而且返回里**没有 `count` 字段**,top-level keys 只有 `['code','jobs']`。我连测 3 轮、也试过显式传 `limit:200/50`,都是 20 条。**你部署的 200 版在我这条链路上没有生效**——主人说的 `{"code":200,"count":3,...}` 和我看到的响应结构不一样。
+- **地址有效期是不是 1800 秒** = **是**。实测 `expiresAt` 距当时 **1774~1775 秒 ≈ 29.6 分钟**,确认 1800 秒已生效。
+
+因为 ① 的“一次看到全部 pending”并不成立,我这轮把 90 秒轮询当成了真正的兜底,而不是可有可无的保险。
+
+## ④ 大文件实测
+
+**第一轮(正常路径)**
+- 文件大小 = **15,728,640 字节(15.0 MB)**
+- 上传耗时 = **4.95 秒**
+- 触发重试了吗 = 否,`attempt=0` 一次过
+- 最终链接 = 
+- md5 一致吗 = **一致**(`35d44c554b6475290e8db449d2941ac5`)
+
+**第二轮(注入失败,专测重试链路)**
+- 我人为把首次 PUT 改成 403 SignatureDoesNotMatch
+- 日志完整可追溯:`PUT-FAIL attempt=0` → `ACK-RELEASED attempt=0 objectId=CtqQhRl2Ac` → `REENQUEUE attempt=1` → `PUT-OK attempt=1 size=15728640`
+- 上传耗时 = 约 8 秒(含 3 秒重试间隔)
+- 触发重试了吗 = **是,attempt=1 成功**
+- 最终链接 = 
+- md5 一致吗 = **一致**(curl 下载实测 HTTP=200、SIZE=15728640、md5 `35d44c554b6475290e8db449d2941ac5`)
+
+**旧 job 是否真被释放**(这是最容易假成功的一步)
+- 坏 job `CtqQhRl2Ac`、上一轮 `TLVEkpOfiv`、成功 job `dNUAUJcnnj` 三个**都不在 pending 里了**,队列确实被释放,不会再卡死。
+
+**改动位置**:(真正的队列逻辑在这里,不在 `fmode-app-deliver.py`)。备份在 。
+
+——
+
+一句提醒:`limit=200` 和 `count` 字段这条,建议你那边再核一下部署的是不是这个 agentId 对应的环境。如果 200 确实上不来,20 条的窗口在高峰期仍然可能把我的 job 挤出去,90 秒轮询能扛住,但会变成“等 90 秒再开始传”的慢路径。
+```
+
+### 45【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` ls -la /opt/data/scripts/ | head -50;... ````
+- `💻 terminal ``` cd /opt/data/scripts && python3 fmode... ````
+- `🔧 Editing /opt/data/scripts/fmode-artifact-push.py`
+- `💻 terminal ``` cd /opt/data/scripts && python3 -c "i... ````
+- `* 💻 terminal ``` cd /opt/data/scripts && python3 -c "i... ``` 📖 Reading fmode-artifact-push.py L183-204`
+- `💻 terminal ``` cd /opt/data/scripts && sed -n '188p'... ````
+- `📖 Reading fmode-artifact-push.py L238-307`
+- `🔧 Editing /opt/data/scripts/fmode-artifact-push.py`
+- `💻 terminal ``` cd /opt/data/scripts && python3 - <<'... ````
+- `🐍 Running code import base64 b = base64.b64decode("Z...`
+- `💻 terminal ``` cd /opt/data/scripts && python3 -c " ... ````

+ 292 - 0
docs/03-interface/kangshifu-实测交付11步-回答.md

@@ -0,0 +1,292 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T04:42:43.143Z
+- 房间:`!ffOqndRm-Fxb8rM8h-dgtDd5rG4EOAWwQMDysRvShwo`(临时房间,可保留查阅)
+- 消息统计:正文 6 段 / 工具过程 4 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 别再读代码了,实测一次,告诉我卡在第几步
+
+## 先认错
+
+你说得对:**我的诊断「你走 upload_bytes 旁路直传 → user/ 前缀」与磁盘代码不符。**
+
+我回头查了那批 `user/` 链接的出现时间,**是阶段 0 和阶段 1**,那时你明确说过
+「我走 `uploader.mjs put` 直传」。**30 路压测里一条链接都没有**,全是 `❌无链接`。
+
+所以:
+- 交付链路代码**没走错函数**
+- 真正的问题是 **30 路压测时链路根本没跑起来**
+
+---
+
+# 现在要做的:实测一次完整交付,每步打点
+
+## 用一个全新的小文件,走真实流程
+
+```bash
+mkdir -p /opt/data/work/deliver-probe
+F=/opt/data/work/deliver-probe/probe-$(date +%s).md
+echo "# deliver probe" > "$F"
+echo "文件: $F"
+ls -la "$F"
+md5sum "$F"
+```
+
+然后**在回复正文里单独一行写**:
+
+```
+MEDIA:<上面的完整路径>
+```
+
+## 我要的:每一步的实际返回
+
+在 `fmode-artifact-push.py`(或你实际用的那个脚本)的每一步**加临时日志**,
+或者直接在 python 里手工跑一遍流程,把**每一步的原始响应**打出来:
+
+```python
+# 请按这个结构实测,每步都打印真实返回
+import json, os, hashlib, urllib.request
+
+FN = "lfYlgU7SkK"
+API = "https://server.fmode.cn/api/functions"
+TOKEN = os.environ.get("FMODE_SESSION_TOKEN") or "<你的容器 token>"
+
+def call(params):
+    req = urllib.request.Request(API,
+        data=json.dumps({"id": FN, "token": TOKEN, "params": params}).encode(),
+        headers={"Content-Type": "application/json", "X-Parse-Application-Id": "ncloudmaster"},
+        method="POST")
+    with urllib.request.urlopen(req, timeout=30) as r:
+        return json.loads(r.read())
+
+path = "<上一步那个文件>"
+room = os.environ.get("HERMES_SESSION_CHAT_ID") or ""
+print("① roomId =", repr(room))
+
+# ② report
+r2 = call({"action": "report", "agentId": "agent-node-kangshifu",
+           "title": "deliver probe", "summary": "probe",
+           "artifacts": [path], "roomId": room})
+print("② report =", json.dumps(r2, ensure_ascii=False)[:500])
+did = (r2.get("result") or r2).get("id") or r2.get("id")
+print("   deliverableId =", did)
+
+# ③ enqueueArtifact
+r3 = call({"action": "enqueueArtifact", "deliverableId": did,
+           "agentId": "agent-node-kangshifu", "source": path,
+           "filename": os.path.basename(path), "index": 0})
+print("③ enqueue =", json.dumps(r3, ensure_ascii=False)[:500])
+
+# ④ pendingArtifacts(轮询 3 次,每次间隔 2 秒)
+import time
+job = None
+for i in range(3):
+    time.sleep(2)
+    r4 = call({"action": "pendingArtifacts", "agentId": "agent-node-kangshifu"})
+    print(f"④ pending[{i}] =", json.dumps(r4, ensure_ascii=False)[:500])
+    jobs = (r4.get("result") or r4).get("jobs") or r4.get("jobs") or []
+    if jobs:
+        job = jobs[0]; break
+
+print("   job =", json.dumps(job, ensure_ascii=False)[:500] if job else None)
+print("   uploadUrl   =", (job or {}).get("uploadUrl"))
+print("   publicUrl   =", (job or {}).get("publicUrl"))
+```
+
+🔴 **不要真的 PUT 上传,也不要 ack。** 我只要看 ①~④ 每步返回什么。
+
+---
+
+## 如果 ④ 拿到 job 了
+
+告诉我 `uploadUrl` 和 `publicUrl` 的**完整原值**(脱敏不需要,这是测试数据)。
+
+那说明链路前半段是通的,问题在后半段(PUT / ack / md5 / 登记)。
+
+## 如果 ④ 一直是空
+
+那说明**队列没建立起来**,问题在 ② 或 ③。把 ② ③ 的完整返回贴出来。
+
+---
+
+# 回答格式
+
+```
+## 文件
+path = ?
+md5 = ?
+
+## ① roomId
+值 = ?(是否为空)
+
+## ② report
+完整返回 = ?
+deliverableId = ?
+
+## ③ enqueueArtifact
+完整返回 = ?
+
+## ④ pendingArtifacts
+三次返回 = ?
+有没有拿到 job = ?
+uploadUrl = ?
+publicUrl = ?
+
+## 结论
+卡在第几步 = ?
+```
+
+🔴 **只要真实返回值。不要改任何文件。不要 PUT。不要 ack。**
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我现在就按你给的①②③④结构实测一遍,先确认脚本和 token,再跑。
+```
+
+### 2【prose】
+
+```text
+文件已建好。现在按你的结构跑 ①~④,只读调用,不 PUT、不 ack。
+```
+
+### 3【prose】
+
+```text
+`execute_code` 的沙箱里拿不到环境变量,我切到 terminal,用真实环境变量再跑一遍。
+```
+
+### 4【prose】
+
+```text
+问题清楚了:授权校验未通过。我用 `FMODE_REPORT_TOKEN`(而不是 `FMODE_SESSION_TOKEN`)再验证一次。
+```
+
+### 5【prose】
+
+```text
+实测完成,结论很明确:**链路前半段全是通的,卡在第 ①.5 步的 token 选择上**。`FMODE_SESSION_TOKEN` 不是上报用的 token,`FMODE_REPORT_TOKEN` 才是。
+
+## 文件
+
+```
+path = 
+大小 = 16 bytes
+md5  = 86966f679edf3f44b762e575af8c9dc5
+```
+
+## ① roomId
+
+```
+值 = '!ffOqndRm-Fxb8rM8h-dgtDd5rG4EOAWwQMDysRvShwo'
+是否为空 = 否(HERMES_SESSION_CHAT_ID 有值,不是空串)
+```
+
+(第一次在 execute_code 沙箱里跑时它读成了 `''`,因为沙箱不继承容器的 env。走 terminal 就是真值。)
+
+## ⚠️ 中途发现的第一个坑(比 ② 更早)
+
+用 `FMODE_SESSION_TOKEN`(len=34)打 ②,直接 401:
+
+```
+{"code":401,"msg":"SessionToken 无效"}
+```
+
+我一开始还怀疑是别的,但注意这不是「token 错」——`FMODE_SESSION_TOKEN` 是 Matrix 会话口令,不是云函数上报凭据。换 `FMODE_REPORT_TOKEN`(同样 len=34,但语义不同)后,② 立刻通。
+
+而**用 SESSION_TOKEN 时 ③ 会给出误导性报错**:
+
+```
+{"code":400,"msg":"deliverableId/source/filename required"}
+```
+
+参数明明是齐的。真实原因是 ② 挂了导致 did=None,③ 的必填校验先撞上 None 才骂缺参数。**这个 400 是假象,会把人往「参数拼错了」带偏。**
+
+## ② report(用 FMODE_REPORT_TOKEN)
+
+完整返回:
+
+```
+{"code": 200, "id": "zPdl8GMobo", "agentId": "agent-node-kangshifu",
+ "agentObjectId": "EdZB0Hg93A", "title": "deliver probe"}
+```
+
+deliverableId = **zPdl8GMobo**
+
+(对比:用 SESSION_TOKEN 时是 `403 {"code":403,"msg":"仅该容器的 owner 可上报"}`)
+
+## ③ enqueueArtifact
+
+完整返回:
+
+```
+{"code": 200, "id": "XWHqPygZjZ"}
+```
+
+无报错。**③ 本身完全没问题。**
+
+## ④ pendingArtifacts
+
+三次里的第一次就拿到了:
+
+```
+pending[0] = {"code": 200, "jobs": [ ... 至少 2 条 ... ]}
+```
+
+有没有拿到 job = **有,就是 ③ 刚入队的那条**(id `XWHqPygZjZ` ↔ deliverableId `zPdl8GMobo`,source 就是我建的那个 probe 文件,能对上)。
+
+**uploadUrl(完整原值):**
+```
+
+```
+
+**publicUrl(完整原值):**
+```
+
+```
+
+附带的 headers 也正常:`x-obs-acl: public-read`、`Content-Type: text/markdown; charset=utf-8`、`expiresAt: 1794631603`。(这个预签名 URL 几分钟后就过期了,我没 PUT,所以文件还没真正落进 OBS。)
+
+## 结论
+
+**卡在第 ①.5 步:token 选错。** 不是 ②③④ 的逻辑问题。
+
+按你给的判据,④ 拿到了 job → **前半段(②③④)完全通**,你怀疑的后半段(PUT / ack / md5 / 登记)才是查错方向。
+
+另外两条实测出来的事实,跟你「30 路压测全 ❌无链接」的现象直接对得上:
+
+1. **队列里有别人的僵尸任务在排队。** ④ 返回的 jobs 里除了我这条,还有一条 `deliverableId: LpA6zJBM5c`、source 是  的历史残留。`pendingArtifacts` 是按 agentId 拉全队列的,**30 路并发时每路都在抢同一个队列**——很容易 A 的进程领走 B 的 job、或者反复领到别人的残留 job 而自己的永远领不到。这本身就足以造成「没链接」。
+2. `pendingArtifacts` 返回里没有分页/锁语义字段(我看到的只有 id/deliverableId/source/filename/mimeType/uploadUrl/publicUrl/headers/expiresAt),**并发下没有领取隔离**。
+
+要不要我接着测后半段?下一步该验的是:**同一个 job 被两个进程同时领 → 会不会互相覆盖**,以及 PUT 成功但没 ack 时队列会不会永久堆积(那条 `/tmp` 残留就是活证据)。你说一声我就跑,还是先只 PUT 这一个 probe 文件验证 md5 与登记录入?
+
+(本次实测:未 PUT、未 ack、未改动任何脚本;只在  下新建了 probe 文件和一个探针脚本,在云函数侧留下 1 条 deliverable `zPdl8GMobo` + 1 条队列 job `XWHqPygZjZ`。)
+```
+
+### 6【prose】
+
+```text
+⚠️ Couldn't deliver the file attachment (probe-1794631301.md).
+```
+
+### 7【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` mkdir -p /opt/data/work/deliver-probe ... ````
+- `🐍 Running code import json, os, urllib.request, time...`
+- `✍️ Writing /opt/data/work/deliver-probe/probe.py`
+- `💻 terminal ``` cd /opt/data/work/deliver-probe && py... ``` ``` cd /opt/data/work/deliver-probe && ec... ````

+ 484 - 0
docs/03-interface/kangshifu-补步骤4-6-回答.md

@@ -0,0 +1,484 @@
+# 容器取证问答:agent-node-kangshifu
+
+- 时间:2026-11-14T04:53:42.310Z
+- 房间:`!UBPENZyQmZh6PVHtX0-IQKd4c3C66Leun6HdV_m34T8`(临时房间,可保留查阅)
+- 消息统计:正文 22 段 / 工具过程 9 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 补齐第 ④⑤⑥ 步,然后实测一次完整交付
+
+## 先说清楚:你的 ①②③ 是好的
+
+实测已证明:
+- `report` → `{"code":200,"id":"zPdl8GMobo"}` ✅
+- `enqueueArtifact` → `{"code":200,"id":"XWHqPygZjZ"}` ✅
+- `pendingArtifacts` → 第一次就拿到 job(含 uploadUrl)✅
+
+**问题在后面的 ④⑤⑥。**
+
+---
+
+# lj-test 的原话(照着做)
+
+> **`registerFile` —— 登记归属,不是传文件,是往新成果表写「这文件属于哪个会话」。**
+> **没有它,文件在云上但 App 看不到。**
+
+它给的真实反例:
+
+```
+SKIP-REGISTER no threadRoot sid=None(文件已上云,仅缺归属)
+HUB … ok=False detail=no-threadRoot
+```
+
+---
+
+# ① 先自查你现在有没有 ⑤⑥
+
+```bash
+echo "=== ackArtifact 调用点 ==="
+grep -rn "ackArtifact" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | head
+
+echo "=== registerFile 调用点 ==="
+grep -rn "registerFile\|THXAOGKiPK" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | head
+
+echo "=== _thread_root 实现 ==="
+grep -rn "_thread_root\|HERMES_SESSION_CHAT_ID" /opt/data/scripts/ 2>/dev/null | head
+```
+
+**把输出贴给我,我对照 lj-test 的写法给你具体改法。**
+
+---
+
+# ② 实测一次完整交付(这条最重要)
+
+## 2.1 造文件
+
+```bash
+mkdir -p /opt/data/work/deliver-final
+F=/opt/data/work/deliver-final/final-$(date +%s).md
+printf '# 交付验证\n\n时间:%s\n标记:FINAL-CHECK-OK\n' "$(date -Is)" > "$F"
+ls -la "$F"; md5sum "$F"; echo "PATH=$F"
+```
+
+## 2.2 走完整流程,**每一步都打印真实返回**
+
+```python
+import json, os, urllib.request, time, hashlib, urllib.parse
+
+API  = "https://server.fmode.cn/api/functions"
+TOK  = os.environ.get("FMODE_SESSION_TOKEN") or "<容器自己的 token>"
+AGENT = "agent-node-kangshifu"
+PATH_ = "<上一步的 PATH>"
+ROOM = os.environ.get("HERMES_SESSION_CHAT_ID") or ""
+
+def call(fn, params):
+    req = urllib.request.Request(API,
+        data=json.dumps({"id": fn, "token": TOK, "params": params}).encode(),
+        headers={"Content-Type": "application/json",
+                 "X-Parse-Application-Id": "ncloudmaster"}, method="POST")
+    with urllib.request.urlopen(req, timeout=60) as r:
+        return json.loads(r.read())
+
+print("ROOM =", repr(ROOM))
+
+# ---- ① report ----
+r1 = call("lfYlgU7SkK", {"action":"report","agentId":AGENT,
+    "title":"final-check","summary":"交付验证",
+    "artifacts":[PATH_],"roomId":ROOM})
+print("① report =", json.dumps(r1, ensure_ascii=False)[:400])
+DID = r1.get("id") or (r1.get("result") or {}).get("id")
+print("   deliverableId =", DID)
+
+# ---- ② enqueue ----
+r2 = call("lfYlgU7SkK", {"action":"enqueueArtifact","deliverableId":DID,
+    "agentId":AGENT,"source":PATH_,"filename":os.path.basename(PATH_),"index":0})
+print("② enqueue =", json.dumps(r2, ensure_ascii=False)[:400])
+
+# ---- ③ pending(领自己那条)----
+job = None
+for i in range(5):
+    time.sleep(2)
+    r3 = call("lfYlgU7SkK", {"action":"pendingArtifacts","agentId":AGENT})
+    jobs = r3.get("jobs") or (r3.get("result") or {}).get("jobs") or []
+    print(f"③ pending[{i}] jobs={len(jobs)}")
+    # 🔴 关键:只领自己那条,别人的跳过
+    mine = [j for j in jobs if j.get("deliverableId") == DID]
+    if mine:
+        job = mine[0]; break
+
+if not job:
+    print("❌ 没领到自己的 job,停止"); raise SystemExit(1)
+
+print("   job.id       =", job["id"])
+print("   uploadUrl    =", job["uploadUrl"][:120], "...")
+print("   publicUrl    =", job.get("publicUrl"))
+
+# ---- ④ PUT ----
+data = open(PATH_, "rb").read()
+req = urllib.request.Request(job["uploadUrl"], data=data,
+    headers=job.get("headers") or {"Content-Type":"text/markdown"}, method="PUT")
+try:
+    with urllib.request.urlopen(req, timeout=180) as r:
+        print("④ PUT =", r.status)
+        put_ok = 200 <= r.status < 300
+except Exception as e:
+    print("④ PUT 失败:", e); put_ok = False
+
+if not put_ok: raise SystemExit(1)
+
+# ---- ⑤ ack ----
+r5 = call("lfYlgU7SkK", {"action":"ackArtifact","objectId":job["id"],
+    "ok":True,"publicUrl":job.get("publicUrl")})
+print("⑤ ack =", json.dumps(r5, ensure_ascii=False)[:300])
+
+# ---- 回读 md5 ----
+with urllib.request.urlopen(job["publicUrl"], timeout=60) as r:
+    remote = r.read()
+print("回读 md5 本地 =", hashlib.md5(data).hexdigest())
+print("回读 md5 远端 =", hashlib.md5(remote).hexdigest())
+print("一致 =", hashlib.md5(data).hexdigest() == hashlib.md5(remote).hexdigest())
+
+# ---- ⑥ registerFile 登记归属 ----
+r6 = call("THXAOGKiPK", {"action":"registerFile",
+    "threadRoot": ROOM,          # ← 没有这个 App 查不到
+    "roomId": ROOM,
+    "agentId": AGENT,
+    "name": os.path.basename(PATH_),
+    "url": job.get("publicUrl"),
+    "mimeType": "text/markdown",
+    "size": len(data),
+    "uploadedBy": "agent"})
+print("⑥ registerFile =", json.dumps(r6, ensure_ascii=False)[:400])
+
+# ---- 验证:能不能按 threadRoot 查到 ----
+time.sleep(2)
+r7 = call("THXAOGKiPK", {"action":"listBySession","roomId":ROOM,"limit":20})
+print("⑦ listBySession =", json.dumps(r7, ensure_ascii=False)[:600])
+```
+
+🔴 **把 ①~⑦ 每一步的完整输出贴给我,不要截断关键字段。**
+
+---
+
+# ③ 如果 ⑥ 报 "no threadRoot"
+
+说明 `HERMES_SESSION_CHAT_ID` 是空的。那要先按 lj-test 的**三级回落**取值:
+
+```
+① state.db 里 sessions.thread_id
+② sessions.chat_id,为空则从 session_key 拆 `!` 开头的段
+③ 都没有 → 跳过登记(但要在日志里显式打 SKIP-REGISTER,不许静默)
+```
+
+贴出 `echo $HERMES_SESSION_CHAT_ID` 的值。
+
+---
+
+# 回给我的格式
+
+```
+## 自查
+ackArtifact 调用点 = ?
+registerFile 调用点 = ?
+_thread_root 实现 = ?
+
+## 实测
+① report = ?
+   deliverableId = ?
+② enqueue = ?
+③ pending = ? (几轮、领到自己的没)
+   uploadUrl = ?
+   publicUrl = ?
+④ PUT 状态 = ?
+⑤ ack = ?
+回读 md5 一致 = ?
+⑥ registerFile = ?
+⑦ listBySession 结果 = ?
+
+## HERMES_SESSION_CHAT_ID
+值 = ?(是否为空)
+```
+
+🔴 老规矩:**只要真实输出,不许「已完成」三个字打发。**
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+先自查 ①,同时查看环境变量和脚本目录。
+```
+
+### 2【prose】
+
+```text
+已有 registerFile + ackArtifact 的调用点。现在看具体实现(fmode-app-deliver.py)和 threadRoot 取值逻辑。
+```
+
+### 3【prose】
+
+```text
+```
+echo "=== scripts 目录 ==="; ls -la /op...
+```
+📖 Reading fmode-app-deliver.py
+```
+
+### 4【prose】
+
+```text
+```
+mkdir -p /opt/data/work/deliver-final ...
+```
+```
+
+### 5【prose】
+
+```text
+* ```
+mkdir -p /opt/data/work/deliver-final ...
+```
+```
+SID=$(sqlite3 /opt/data/state.db "SEL...
+```
+```
+sqlite3 /opt/data/state.db "SELECT id...
+```
+```
+
+### 6【prose】
+
+```text
+* ```
+mkdir -p /opt/data/work/deliver-final ...
+```
+```
+SID=$(sqlite3 /opt/data/state.db "SEL...
+```
+```
+sqlite3 /opt/data/state.db "SELECT id...
+```
+🐍 Running code import sqlite3, os con = sqlite3.conn...
+```
+
+### 7【prose】
+
+```text
+thread_id 为空 → 必须走 chat_id 回落。现在跑完整 ①~⑦ 实测。
+```
+
+### 8【prose】
+
+```text
+* ```
+mkdir -p /opt/data/work/deliver-final ...
+```
+```
+SID=$(sqlite3 /opt/data/state.db "SEL...
+```
+```
+sqlite3 /opt/data/state.db "SELECT id...
+```
+🐍 Running code import sqlite3, os con = sqlite3.conn...
+✍️ Writing /opt/data/work/deliver-final/run_full...
+```
+
+### 9【prose】
+
+```text
+①~⑦ 全部跑通。现在修 ⑥ 的两处缺陷:三级回落取值,以及 `agentId` 为空的问题(脚本读取 `AGENT_ID`,但 .env 里没有这个键)。
+```
+
+### 10【prose】
+
+```text
+```
+python3 - <<'EOF' ...
+```
+```
+
+### 11【prose】
+
+```text
+`AGENT_ID` 有值,没问题。现在把 `thread_root_of` 升级为 lj-test 的三级回落,并补上显式的 `SKIP-REGISTER` 日志。
+```
+
+### 12【prose】
+
+```text
+* ```
+python3 - <<'EOF' ...
+```
+🔧 Editing /opt/data/scripts/fmode-app-deliver.py
+```
+
+### 13【prose】
+
+```text
+现在 `session_meta` 需要读取 `session_key`,并且 main 里的 SKIP-REGISTER 要显式输出。
+```
+
+### 14【prose】
+
+```text
+现在用修好的脚本走一遍端到端实测(走完整脚本链路,验证三级回落生效)。
+```
+
+### 15【prose】
+
+```text
+脚本端到端也通过了。再验证三级回落的第③级测试结果,并确认最终登记表的回读状态。
+```
+
+### 16【prose】
+
+```text
+全部验证通过。最后更新技能里的取值规则,避免以后再踩坑。
+```
+
+### 17【prose】
+
+```text
+📚 Reading skill skill-deliverable
+```
+
+### 18【prose】
+
+```text
+这个技能只讲到第①②步,缺少归属登记环节,因此补一个 references 文件,把 ④⑤⑥ 的实测结论落下来。
+```
+
+### 19【prose】
+
+```text
+📝 skill_manage...
+```
+
+### 20【prose】
+
+```text
+* 📝 skill_manage...
+💻 terminal
+```
+grep -n "references\|## " /opt/data/s...
+```
+```
+
+### 21【prose】
+
+```text
+* 📝 skill_manage...
+💻 terminal
+```
+grep -n "references\|## " /opt/data/s...
+```
+📝 skill_manage...
+```
+
+### 22【prose】
+
+```text
+全链路已实测通过,脚本也已修好。以下是真实输出。
+
+## 自查
+
+**ackArtifact 调用点 = 3 处**(都已实现)
+- (经 fmode-artifact-push)
+- 
+- 
+
+**registerFile 调用点 = 3 处**
+- (`FN_REGISTER = "THXAOGKiPK"`,第 37 行)
+- 
+- 
+
+**`_thread_root` 实现 = 原来没有独立函数,只有 `thread_root_of`,两级回落(thread_id → chat_id),缺第三级 → 本次已补**
+
+## 实测(①~⑦ 完整跑通)
+
+**① report =** `{"code": 200, "id": "D6VI4lLEMG", "agentId": "agent-node-kangshifu", "agentObjectId": "EdZB0Hg93A", "title": "final-check"}`
+**deliverableId =** `D6VI4lLEMG`
+
+**② enqueue =** `{"code": 200, "id": "QWgS0AeAU4"}`
+
+**③ pending =** 第 0 轮就领到,队列里 20 条 job,其中自己那条 1 条(说明你说的全局队列过滤是对的,不过滤会 ack 掉别人的)
+**uploadUrl =** ``
+**publicUrl =** ``
+job 全部键 = `agentId, deliverableId, expiresAt, filename, headers, id, mimeType, publicUrl, source, uploadUrl`
+
+**④ PUT 状态 =** `200`
+
+**⑤ ack =** `{"code": 200}`
+
+**回读 md5 一致 =** `True`(本地 `6163102960cadba0e5b9d3e359242566` = 远端 `6163102960cadba0e5b9d3e359242566`,远端 75 字节)
+
+**⑥ registerFile =** `{"code": 200, "id": "ZGwvcuYb5g"}`
+
+**⑦ listBySession(roomId) =** `{"code":200,"roomId":"!UBPENZyQmZh6PVHtX0-IQKd4c3C66Leun6HdV_m34T8","threadRoot":"","files":[{"id":"ZGwvcuYb5g","threadRoot":"!UBPENZyQmZh6PVHtX0-IQKd4c3C66Leun6HdV_m34T8","agentId":"agent-node-kangshifu","roomId":"!UBPENZyQmZh6PVHtX0-IQKd4c3C66Leun6HdV_m34T8","name":"final-1794631963.md","size":75,"mimeType":"text/markdown","status":"delivered","deliveredAt":1794631976722}],"count":1}`
+
+用 `threadRoot` 参数查同样命中,`⑦b 命中本条 publicUrl = True`。
+
+## HERMES_SESSION_CHAT_ID
+
+**值 =** `!UBPENZyQmZh6PVHtX0-IQKd4c3C66Leun6HdV_m34T8`(**非空**)
+
+所以本次没触发 `no threadRoot`。但 DB 侧 `sessions.thread_id = None`,走的是第②级 chat_id。
+
+## 已修的代码
+
+ 三处改动:
+
+1. `session_meta` 多查一列 `session_key`
+2. `thread_root_of` 补上第三级:`session_key` 按 `:` 切分找 `!` 开头的段。单元验证四级:
+   - thread_id 有值 → `'$thr-abc'` ✅
+   - thread_id 空 → `'!room'`(chat_id)✅
+   - 前两级都空 → `'!onlyinkey123'`(session_key 段)✅
+   - 全空 → `''` ✅
+3. 全空时显式打 `[SKIP-REGISTER] no threadRoot: ... sid=... meta=...` 到 stderr,JSON 里带 `"skipRegister": true`,不再静默
+
+**修完后用脚本本身跑了一遍端到端**(不是裸调 API):
+
+```
+"ok": true,  "threadRoot": "!UBPENZyQmZh6PVHtX0-IQKd4c3C66Leun6HdV_m34T8"
+"registerResp": {"code": 200, "id": "YUwddzwtWq"}
+"verify": {"found": true, "count": 2}
+"stateFile": ""
+```
+
+台账最终回读 `count = 2`,两条都是 `status=delivered`,url 分别是 `.../D6VI4lLEMG/...` 和 `.../qYBKFAD6lV/...`(两条是同一份文件、两次独立投递,符合预期)。
+
+## 两个额外发现(你没问但会踩)
+
+**uploadUrl 有 `Expires` 签名,实测约 5 分钟有效期**(1794632275 对比当前时间)。领到 job 后必须立刻 PUT,不能缓存或排队。
+
+**`agentId` 从 `.env` 的 `AGENT_ID` 读**(值 `agent-node-kangshifu`,存在)。原来担心硬编码,查了没有——registerFile 的 `agentId` 参数是动态取的。
+
+已把 ①~⑦ 全链路 + 5 个坑写进 `fmode-deliverable/references/full-chain-1-to-7.md`,SKILL.md 第七章加了醒目提示:**完整链路是 7 步,不是 2 步**。
+
+**已上云(可直接下载)**
+- https://s3.fmode.cn/deliverables/agent-node-kangshifu/202611/qYBKFAD6lV/0-mwfxjv0l8k-final-1794631963.md
+```
+
+### 23【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` echo "=== ackArtifact 调用点 ==="; grep ... ````
+- `💻 terminal ``` cd /opt/data/work/deliver-final && HE... ````
+- `* 💻 terminal ``` cd /opt/data/work/deliver-final && HE... ``` ``` grep -n "AGENT_ID\|AGENT=" /opt/data/... ````
+- `🔧 Editing /opt/data/scripts/fmode-app-deliver.py`
+- `🔧 Editing /opt/data/scripts/fmode-app-deliver.py`
+- `* 🔧 Editing /opt/data/scripts/fmode-app-deliver.py 💻 terminal ``` cd /opt/data && python3 scripts/fmode... ````
+- `🐍 Running code import sys, json sys.path.insert(0, "...`
+- `💻 terminal ``` grep -n "threadRoot\|thread_id\|chat_... ``` ``` echo "=== skill-deliverable/SKILL.md ... ````
+- `* 💻 terminal ``` grep -n "threadRoot\|thread_id\|chat_... ``` ``` echo "=== skill-deliverable/SKILL.md ... ``` 📝 skill_manage...`

+ 328 - 0
docs/03-interface/ljtest-同步云函数改动-回答.md

@@ -0,0 +1,328 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-14T05:28:51.024Z
+- 房间:`!nACG9qmoiBlnOTK7k6UnZBMUb2ZZioe3MBiI7M_OC6Y`(临时房间,可保留查阅)
+- 消息统计:正文 14 段 / 工具过程 1 段 / 文件 0 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 通知:云函数 lfYlgU7SkK 已改了三处,你那边确认一下兼容性
+
+## 改了什么(2026-11-14 由 App 侧改,已部署验证)
+
+| 改动 | 原值 | 新值 | 影响 |
+|---|---|---|---|
+| `pendingArtifacts` 权限 | 仅 agent owner | **owner 或同公司成员** | 多人共用容器时非 owner 也能领任务 |
+| `pendingArtifacts` 返回条数 | 20 | **200** | 高并发时不会再抢不到 |
+| 上传地址有效期 | 300 / 600 秒 | **1800 秒(30 分钟)** | 大文件有时间传完 |
+
+实测验证:
+```
+ksftest01(非 owner)调 pendingArtifacts
+改前:{"code":403,"msg":"仅该 agent 的 owner 可领取任务"}
+改后:{"code":200,"jobs":22},地址剩余约 29 分钟
+```
+
+---
+
+## 要你确认的(只读,不用改)
+
+### 1. 你的 `--min-age` / 重试逻辑会不会受影响
+
+```bash
+grep -rn "300\|600\|EXPIRES\|expiresAt\|ttl" /opt/data/scripts/fmode-artifact-push.py 2>/dev/null | head -20
+```
+
+**要回答**:你的代码里有没有写死「300 秒」或「600 秒」来推算过期?
+如果有,需要改成读 `job["expiresAt"]` 而不是自己算。
+
+### 2. 你的轮询等待够不够
+
+```bash
+grep -rn "pendingArtifacts" -A 15 /opt/data/scripts/fmode-artifact-push.py 2>/dev/null | head -30
+```
+
+**要回答**:领不到 job 时你重试几次、间隔多少?总共等多久?
+limit 从 20 → 200 后,领到的概率变高了,但**确认一下你的等待时间是否合理**(建议至少 60 秒)。
+
+### 3. 失败后有没有 ack(ok=False)
+
+```bash
+grep -rn "ackArtifact" -B2 -A6 /opt/data/scripts/fmode-artifact-push.py 2>/dev/null | head -30
+```
+
+**要回答**:PUT 失败时,你会不会 `ackArtifact {ok:false}` 释放旧 job?
+**不释放的话,旧 job 一直挂在 pending,下次会被「复用」成坏任务。**
+
+### 4. 队列里有 22 条积压,会不会影响你
+
+```bash
+echo "=== 现在队列积压 ==="
+python3 - <<'PY'
+import json, os, urllib.request
+API="https://server.fmode.cn/api/functions"
+TOK=os.environ.get("FMODE_SESSION_TOKEN") or ""
+def call(p):
+    req=urllib.request.Request(API,data=json.dumps({"id":"lfYlgU7SkK","token":TOK,"params":p}).encode(),
+        headers={"Content-Type":"application/json","X-Parse-Application-Id":"ncloudmaster"},method="POST")
+    return json.loads(urllib.request.urlopen(req,timeout=30).read())
+r=call({"action":"pendingArtifacts","agentId":"agent-node-lj-test"})
+jobs=r.get("jobs") or []
+print("你名下 pending job 数 =", len(jobs))
+import time
+now=int(time.time())
+for j in jobs[:10]:
+    print("  ", j["id"], j.get("filename"), "剩余秒=", j.get("expiresAt",0)-now)
+PY
+```
+
+**要回答**:你名下有没有积压?有多少条是过期的?
+
+---
+
+## 如果你发现自己的重试逻辑需要跟着改
+
+**贴出你的现状,我给具体改法。不要自己动。**
+
+---
+
+# 回给我的格式
+
+```
+## 1. 有没有写死 300/600
+结果 = ?
+需要改吗 = ?
+
+## 2. 轮询参数
+次数 / 间隔 / 总等待 = ?
+够不够 = ?
+
+## 3. 失败后 ack
+有没有 ack(ok=False) = ?
+代码位置 = ?
+
+## 4. 队列积压
+你名下 pending 数 = ?
+过期数 = ?
+
+## 5. 结论
+兼容 / 需要改(贴出要改的地方)
+```
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先确认一下你那份脚本的实际情况,再逐条回答。
+```
+
+### 2【prose】
+
+```text
+terminal · ls -la /opt/data/scripts/ 2>/dev/null | head -40; echo "=== …
+```
+
+### 3【prose】
+
+```text
+```
+date '+%F %T %Z'; echo "---"; env | g...
+```
+```
+echo "=== 1. 300/600/EXPIRES/expiresA...
+```
+```
+
+### 4【prose】
+
+```text
+* ```
+date '+%F %T %Z'; echo "---"; env | g...
+```
+```
+echo "=== 1. 300/600/EXPIRES/expiresA...
+```
+📖 Reading fmode-artifact-push.py
+```
+
+### 5【prose】
+
+```text
+* ```
+date '+%F %T %Z'; echo "---"; env | g...
+```
+```
+echo "=== 1. 300/600/EXPIRES/expiresA...
+```
+📖 Reading fmode-artifact-push.py
+💻 terminal
+```
+echo "=== fmode-artifact-drain.sh ===...
+```
+```
+
+### 6【prose】
+
+```text
+关键发现:真正的搬运工不是 push.py,而是 `artifact-drain.py`,我要把它查清楚。
+```
+
+### 7【prose】
+
+```text
+* ```
+date '+%F %T %Z'; echo "---"; env | g...
+```
+```
+echo "=== 1. 300/600/EXPIRES/expiresA...
+```
+📖 Reading fmode-artifact-push.py
+💻 terminal
+```
+echo "=== fmode-artifact-drain.sh ===...
+```
+```
+echo "=== 全仓 300/600/expiresAt hardco...
+```
+```
+F=/opt/data/git-repos/agent-status-re...
+```
+```
+
+### 8【prose】
+
+```text
+```
+echo "=== cron 是否真在跑 drain ==="; cron...
+```
+📖 Reading artifact-drain.py
+```
+
+### 9【prose】
+
+```text
+现在我来实际查询队列的真实状态(第 4 问要用真实数据)。
+```
+
+### 10【prose】
+
+```text
+* ```
+echo "=== cron 是否真在跑 drain ==="; cron...
+```
+📖 Reading artifact-drain.py
+💻 terminal
+```
+python3 - <<'PY' ...
+```
+```
+cd /tmp && python3 - <<'PY' ...
+```
+```
+
+### 11【prose】
+
+```text
+```
+cd /opt/data && python3 - <<'PY' ...
+```
+```
+
+### 12【prose】
+
+```text
+* ```
+cd /opt/data && python3 - <<'PY' ...
+```
+```
+echo "=== 最近 drain 执行记录 ==="; ls -t /...
+```
+```
+cd /opt/data && python3 - <<'PY' ...
+```
+```
+
+### 13【prose】
+
+```text
+* ```
+cd /opt/data && python3 - <<'PY' ...
+```
+```
+echo "=== 最近 drain 执行记录 ==="; ls -t /...
+```
+```
+cd /opt/data && python3 - <<'PY' ...
+``` (×2)
+```
+
+### 14【prose】
+
+```text
+四条都已用真实输出核实完毕。结论:**兼容,一处建议加固(不改也能跑)**。以下是逐条回读原文。
+
+## 1. 有没有写死 300/600
+
+结果 = **没有**。对 `/opt/data/scripts/fmode-artifact-push.py` 跑 grep `300|600|EXPIRES|expiresAt|ttl` 返回 `(no match)`;真正干活的搬运工 `/opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py` 同样 `(no match)`。
+
+我这两个脚本全程**不读、不推算过期时间**:拿到 `uploadUrl` 就立刻 PUT,从不解析有效期字段。所以 300/600 → 1800 对它们是完全透明的变化。
+
+需要改吗 = **不需要**。顺带说明:`--min-age` 不在搬运链路里,它属于 `fmode-media-gc.py`(默认 3600s 清理本地中转文件),跟 job 过期无关。
+
+## 2. 轮询参数
+
+**没有轮询重试**——是纯 cron 单次扫描,不是「等不到就重试」的循环:
+
+- `fmode-artifact-drain`,`*/3 * * * *`(每 3 分钟一次),`enabled=True`,`no_agent=True`
+- 单次调用:`pendingArtifacts` 一次 → 拿到多少处理多少 → 退出。代码里没有 retry、没有 sleep、没有次数上限
+- 实际等效总等待 = **≤3 分钟(180s)**,因为下一轮 3 分钟后必然再来一次
+
+次数 / 间隔 / 总等待 = **1 次/轮 ÷ 3 分钟一轮,等效最长 180 秒**
+
+够不够 = **够,且优于你建议的 60 秒**。你说的「领不到 job 时等待是否合理」在这里不成立:我这边 22 条积压不会让我「抢不到」,反而每轮一次性吃光 200 条上限内的全部。反过来说,limit 20→200 对我是**减少轮次**的收益,不是提高成功率。
+
+## 3. 失败后 ack
+
+有没有 ack(ok=False) = **有,两条链路都有**
+
+代码位置:
+- `/opt/data/git-repos/agent-status-reporter/scripts/artifact-drain.py:70-74` —— `except Exception` 里 `_call({"action":"ackArtifact","objectId":j["id"],"ok":False,"error":msg})`
+- `/opt/data/scripts/fmode-artifact-push.py:238-243` —— PUT 异常后同样 `ack(ok=False)`,然后 `continue`
+
+所以 PUT 失败会释放旧 job,不会留下「pending 悬挂 → 下次复用成坏任务」的坑。**唯一残余风险**(只报告,不动手):drain 里 `open(j["source"])` 也包在同一个 `try` 内,源文件已被 `--cleanup` 删掉时同样会 ack(ok=False)——结果正确(确实传不了),只是把它记为失败而非跳过。
+
+## 4. 队列积压
+
+你名下 pending 数 = **0**(`agent-node-lj-test`)
+过期数 = **0**
+
+原始输出:`{"code": 200, "jobs": []}`,另跑 `pendingArtifacts` 直接返回 `jobs=[]`。
+
+注意两点实情:
+- 你说的 22 条积压在 **`ksftest01`** 名下,不是 `agent-node-lj-test`。我这边队列是空的,22 条与我无关。
+- **你给的探针脚本里的 agentId 我跑不出来**:`agent-node-u19170119836` 返回 `接口 404,这条路走不通。`——这个 ID 在 `lfYlgU7SkK` 里不存在。我实际用的 AGENT_ID 是 `agent-node-lj-test`(同时是脚本默认值和 cron 环境值)。
+
+另外确认真在跑:cron 执行记录 `job_id=d1f9a95b2ad9`,最近一轮 `2026-11-14T13:27:48 → 13:27:49 completed`,每 3 分钟稳定一次,无 error。
+
+## 5. 结论
+
+**兼容,不需要改。** 三处改动对我是零影响:不读过期时间(改动三透明)、cron 3 分钟兜底(改动二无害有益)、失败必 ack(False)(本来就合规)。
+
+要不要顺手加固一处,请你定,我不擅自动:给 drain 的 `open(source)` 前面加个 `os.path.exists` 判断,文件没了就 `ack(ok=False, error="source_gone")` 并跳过——现在行为等价,加了之后日志能把「文件已清理」和「PUT 真失败」区分开。要动我等你一句话。
+```
+
+### 15【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` ls -la /opt/data/scripts/ 2>/dev/null... ````

+ 815 - 0
docs/03-interface/ljtest-完整流程通俗版-回答.md

@@ -0,0 +1,815 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-14T04:52:07.972Z
+- 房间:`!KjQZMmU77OZgTf3Tz_fO3TBj_UJINp3d0k9epY9Jxgs`(临时房间,可保留查阅)
+- 消息统计:正文 47 段 / 工具过程 7 段 / 文件 2 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 请用**通俗语言**完整讲一遍:从收到用户消息,到文件出现在 App 里
+
+## 背景
+
+我需要把你这台容器的完整工作逻辑,整理成一份给**非技术同事**看的说明。
+所以请**避免术语堆砌**,每个技术名词第一次出现时用大白话解释一句。
+
+这不是让你改任何东西,**只要能讲清楚就行**。
+
+---
+
+# 第一部分:一条消息的完整旅程
+
+假设用户在 App 里发了一句:「帮我写个周报,存成 md 文件」。
+
+请按**时间顺序**,一步一步讲清楚,每一步说:
+
+1. **发生了什么**(人话)
+2. **谁在做**(用户 / App / 平台服务器 / 容器)
+3. **用了什么**(如果是调接口,说明是哪个、干什么用的)
+4. **证据在哪**(日志文件路径 / 代码位置,我好核对)
+
+请至少覆盖到能回答这些问题:
+
+- 消息怎么到你手上的?
+- 你怎么知道「这一轮结束了」?
+- App 怎么知道「你回完了」?
+- 中间那些「💻 terminal ...」的过程行,App 怎么区分它和正文?
+
+---
+
+# 第二部分:文件是怎么上云的
+
+这是我最想搞清楚的部分。请**一步一步**讲:
+
+- 你写好文件之后,**是你自己传到云盘,还是别人帮你传?**
+- 如果是你传的:**上传地址从哪来?** 谁给的?
+- 云盘上那个文件的**路径长什么样**(举一个真实例子)?
+- 这个路径是**谁决定的**?你的代码,还是平台的服务器?
+- 传完之后,要做什么才能让**App 看得到**?
+
+---
+
+# 第三部分:App 的「成果」栏里那些文件,数据从哪来
+
+用户在 App 右栏点「成果」,看到一堆文件。这些数据:
+
+- 存在**哪个地方**?(数据库表?还是从消息里现算?)
+- 是**谁写进去的**?(你的容器?App 自己?平台服务器?)
+- **一条记录里有什么信息**?
+
+---
+
+# 第四部分:用大白话解释这几个词
+
+我遇到一些词不清楚,请各用一两句话说清楚它们**在你的流程里扮演什么角色**:
+
+- `report`(上报)
+- `deliverableId`
+- `enqueueArtifact`
+- `pendingArtifacts`
+- `ackArtifact`
+- `registerFile`
+- `publicUrl`
+- `uploadUrl`
+
+**如果某个词在你的流程里根本不出现,直接说「我没用到」。**
+
+---
+
+# 第五部分:一次成功的例子
+
+请举一个**真实发生过**的例子(从你的日志里找):
+
+- 时间
+- 用户说了什么
+- 你最终给出的链接是什么(完整 URL)
+- App 里能看到这个文件吗(如果知道的话)
+
+---
+
+# 回答要求
+
+- **用中文,用大白话**
+- 技术名词第一次出现时解释一句
+- 每一步都附证据(日志路径 / 代码位置 / 真实返回值)
+- **不要给建议**,不要顺带提「建议改成什么」
+- 如果某一部分你不确定,**直接说「这块我不确定」**,不要编
+
+---
+
+## 格式
+
+```
+## 一、消息的旅程
+1. 【谁】做了什么 —— 证据:...
+2. ...
+
+## 二、文件上云
+...
+
+## 三、成果栏的数据来源
+...
+
+## 四、术语解释
+- report:...
+- deliverableId:...
+...
+
+## 五、一个真实例子
+...
+
+## 六、我不确定的地方
+...
+```
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+我先说一句:这是一项**只读核查**任务,我不会改动任何东西,还要查看日志、代码和真实返回值。这项工作需要运行不少命令,因此我先把回执发给你。
+
+现在开始查。
+```
+
+### 2【prose】
+
+```text
+🌐 browser_exec: "print("not used")"
+```
+
+### 3【prose】
+
+```text
+terminal · ls -la /opt/data/ 2>&1 | head -40; echo "---LOGS---"; ls -la…
+```
+
+### 4【prose】
+
+```text
+(先不发结论,接着查。)
+```
+
+### 5【prose】
+
+```text
+```
+grep -n "deliverable" -r /opt/data/co...
+```
+```
+cd /opt/data/scripts; grep -n "def \|...
+```
+```
+
+### 6【prose】
+
+```text
+* ```
+grep -n "deliverable" -r /opt/data/co...
+```
+```
+cd /opt/data/scripts; grep -n "def \|...
+```
+```
+ls -la /opt/data/plugins/plugin-deliv...
+```
+📖 Reading fmode-artifact-push.py
+```
+
+### 7【prose】
+
+```text
+* ```
+grep -n "deliverable" -r /opt/data/co...
+```
+```
+cd /opt/data/scripts; grep -n "def \|...
+```
+```
+ls -la /opt/data/plugins/plugin-deliv...
+```
+📖 Reading fmode-artifact-push.py
+📖 Reading fmode-deliverable-hook.py L365-544
+📖 Reading fmode-deliverable-hook.py L580-799
+```
+
+### 8【prose】
+
+```text
+(已经查到核心链路,接下来再查看日志里的真实例子和过程行的实现。)
+```
+
+### 9【prose】
+
+```text
+* ```
+grep -n "deliverable" -r /opt/data/co...
+```
+```
+cd /opt/data/scripts; grep -n "def \|...
+```
+```
+ls -la /opt/data/plugins/plugin-deliv...
+```
+📖 Reading fmode-artifact-push.py
+📖 Reading fmode-deliverable-hook.py L365-544
+📖 Reading fmode-deliverable-hook.py L580-799
+📖 Reading __init__.py
+💻 terminal
+```
+cd /opt/data/scripts; grep -n "PUSH \...
+```
+```
+
+### 10【prose】
+
+```text
+已经拿到真实链接。接下来要查完整日志链(一个例子的全流程)、Matrix 消息接收路径,以及 App 侧是如何区分的。
+```
+
+### 11【prose】
+
+```text
+(现在核实 Matrix 收消息路径、App 区分正文和过程行的机制,以及成果表的数据来源。)
+```
+
+### 12【prose】
+
+```text
+```
+grep -n "deliverable-hook\|deliverabl...
+```
+```
+grep -n "fmode.kind\|process" /opt/da...
+```
+```
+
+### 13【prose】
+
+```text
+```
+grep -n "sync\|m.room.message\|/sync\...
+```
+```
+ls /opt/hermes/ 2>/dev/null | head; e...
+```
+```
+
+### 14【prose】
+
+```text
+* ```
+grep -n "sync\|m.room.message\|/sync\...
+```
+```
+ls /opt/hermes/ 2>/dev/null | head; e...
+```
+```
+cd /opt/data/cache/deliverable-hook; ...
+```
+```
+
+### 15【prose】
+
+```text
+...
+```
+
+### 16【prose】
+
+```text
+* ```
+grep -n "sync\|m.room.message\|/sync\...
+```
+```
+ls /opt/hermes/ 2>/dev/null | head; e...
+```
+```
+cd /opt/data/cache/deliverable-hook; ...
+```
+```
+cd /opt/hermes/plugins/platforms/matr...
+```
+```
+
+### 17【prose】
+
+```text
+terminal · cd /opt/hermes; grep -rn "fmode.kind" --include=*.py --inclu…
+```
+
+### 18【prose】
+
+```text
+```
+cd /opt/hermes; grep -rn "fmode.kind"...
+```
+📖 Reading adapter.py L1848-1927
+```
+
+### 19【prose】
+
+```text
+* ```
+cd /opt/hermes; grep -rn "fmode.kind"...
+```
+📖 Reading adapter.py L1848-1927
+💻 terminal
+```
+cd /opt/data; sqlite3 state.db "selec...
+```
+```
+cd /opt/hermes/plugins/platforms/matr...
+```
+```
+
+### 20【prose】
+
+```text
+* ```
+cd /opt/hermes; grep -rn "fmode.kind"...
+```
+📖 Reading adapter.py L1848-1927
+💻 terminal
+```
+cd /opt/data; sqlite3 state.db "selec...
+```
+```
+cd /opt/hermes/plugins/platforms/matr...
+```
+```
+grep -n "mfile-verify-r3\|页面.html" /o...
+```
+📖 Reading adapter.py L1979-2053
+```
+
+### 21【prose】
+
+```text
+* ```
+cd /opt/hermes; grep -rn "fmode.kind"...
+```
+📖 Reading adapter.py L1848-1927
+💻 terminal
+```
+cd /opt/data; sqlite3 state.db "selec...
+```
+```
+cd /opt/hermes/plugins/platforms/matr...
+```
+```
+grep -n "mfile-verify-r3\|页面.html" /o...
+```
+📖 Reading adapter.py L1979-2053
+💻 terminal
+```
+grep -n "inbound message" /opt/data/l...
+```
+📖 Reading fmode-artifact-push.py
+```
+
+### 22【prose】
+
+```text
+* ```
+cd /opt/hermes; grep -rn "fmode.kind"...
+```
+📖 Reading adapter.py L1848-1927
+💻 terminal
+```
+cd /opt/data; sqlite3 state.db "selec...
+```
+```
+cd /opt/hermes/plugins/platforms/matr...
+```
+```
+grep -n "mfile-verify-r3\|页面.html" /o...
+```
+📖 Reading adapter.py L1979-2053
+💻 terminal
+```
+grep -n "inbound message" /opt/data/l...
+```
+📖 Reading fmode-artifact-push.py
+💻 terminal
+```
+grep -n "fmode.kind\|kind.*process\|_...
+```
+```
+grep -rn "fmode.kind" /opt/data/plugi...
+```
+```
+
+### 23【prose】
+
+```text
+* ```
+cd /opt/hermes; grep -rn "fmode.kind"...
+```
+📖 Reading adapter.py L1848-1927
+💻 terminal
+```
+cd /opt/data; sqlite3 state.db "selec...
+```
+```
+cd /opt/hermes/plugins/platforms/matr...
+```
+```
+grep -n "mfile-verify-r3\|页面.html" /o...
+```
+📖 Reading adapter.py L1979-2053
+💻 terminal
+```
+grep -n "inbound message" /opt/data/l...
+```
+📖 Reading fmode-artifact-push.py
+💻 terminal
+```
+grep -n "fmode.kind\|kind.*process\|_...
+```
+```
+grep -rn "fmode.kind" /opt/data/plugi...
+```
+```
+node /opt/data/skills/fmode-storage/s...
+```
+```
+for p in /opt/data/cache/deliverable-...
+```
+```
+
+### 24【prose】
+
+```text
+(核心已经齐全,现在补查真实例子里的 1 条记录内容,以及那一次 App 究竟看到的链接。)
+```
+
+### 25【prose】
+
+```text
+* ```
+cd /opt/hermes; grep -rn "fmode.kind"...
+```
+📖 Reading adapter.py L1848-1927
+💻 terminal
+```
+cd /opt/data; sqlite3 state.db "selec...
+```
+```
+cd /opt/hermes/plugins/platforms/matr...
+```
+```
+grep -n "mfile-verify-r3\|页面.html" /o...
+```
+📖 Reading adapter.py L1979-2053
+💻 terminal
+```
+grep -n "inbound message" /opt/data/l...
+```
+📖 Reading fmode-artifact-push.py
+💻 terminal
+```
+grep -n "fmode.kind\|kind.*process\|_...
+```
+```
+grep -rn "fmode.kind" /opt/data/plugi...
+```
+```
+node /opt/data/skills/fmode-storage/s...
+```
+```
+for p in /opt/data/cache/deliverable-...
+```
+```
+cd /opt/data; python3 - <<'EOF' ...
+```
+```
+cd /opt/data/cache/deliverable-hook; ...
+```
+```
+
+### 26【prose】
+
+```text
+terminal · cd /opt/data/cache/deliverable-hook; python3 -c " import jso…
+```
+
+### 27【prose】
+
+```text
+(链接实测返回 200,内容也真的能取到。最后确认「本轮结束」这一层的信号怎么传,以及成果表的结构。)
+```
+
+### 28【prose】
+
+```text
+```
+curl -s -o /dev/null -w "%{http_code}...
+```
+```
+grep -n "def _hub_call\|registerFile"...
+```
+```
+
+### 29【prose】
+
+```text
+```
+cd /opt/data && python3 - <<'EOF' ...
+```
+```
+
+### 30【prose】
+
+```text
+查到了 App 读成果用的正是 `listBySession`。拿真实数据验证一下:
+```
+
+### 31【prose】
+
+```text
+* ```
+cd /opt/data && python3 - <<'EOF' ...
+``` (×2)
+```
+
+### 32【prose】
+
+```text
+(再查一条带真实记录的成果归属,确认成果表有内容。)
+```
+
+### 33【prose】
+
+```text
+```
+grep -n "fmode-agent-id\|FMODE_AGENT_...
+```
+```
+cd /opt/data && python3 - <<'EOF' ...
+```
+```
+
+### 34【prose】
+
+```text
+拿到了完整的记录结构。再确认「本轮结束」这一层的触发机制和 App 侧可读的地方,然后开始写。
+```
+
+### 35【prose】
+
+```text
+```
+cd /opt/data && python3 - <<'EOF' ...
+```
+```
+
+### 36【prose】
+
+```text
+证据已经齐全了,我先把它整理成一份给非技术同事看的说明文档,然后给你文字版答案。
+```
+
+### 37【prose】
+
+```text
+```
+grep -n "turn_id\|on_turn_end\|post_t...
+```
+```
+sed -n '2925,2950p' /opt/hermes/gatew...
+```
+```
+
+### 38【prose】
+
+```text
+```
+find /opt/hermes -name "turn_finalize...
+```
+```
+
+### 39【prose】
+
+```text
+* ```
+find /opt/hermes -name "turn_finalize...
+```
+📖 Reading turn_finalizer.py L411-465
+```
+
+### 40【prose】
+
+```text
+还在吭哧吭哧干,3 分钟了。 — iteration 27/500, 正在收流式输出…
+```
+
+### 41【prose】
+
+```text
+证据够了。现在生成一份 HTML 说明文档,并自动上云。
+```
+
+### 42【prose】
+
+```text
+* ```
+find /opt/hermes -name "turn_finalize...
+```
+📖 Reading turn_finalizer.py L411-465
+✍️ Writing /opt/data/work/pipeline-explainer/消息到...
+```
+
+### 43【prose】
+
+```text
+write_file · /opt/data/work/pipeline-explainer/消息到文件的完整旅程.md
+```
+
+### 44【prose】
+
+```text
+现在生成一份适合发给非技术同事看的 HTML 版本:
+```
+
+### 45【prose】
+
+```text
+terminal · cd /opt/data && python3 /opt/data/scripts/fmode-artifact-pus…
+```
+
+### 46【prose】
+
+```text
+上传成功,md5 一致,返回 200。再登记进成果表:
+```
+
+### 47【prose】
+
+```text
+已上传并登记成功,回读确认记录已经写入,`id=tUI5k13HUb`。下面是文字版。
+
+---
+
+## 一、消息的旅程
+
+**1.【App】** 用户在 App 里点发送后,App 先把这句话发到**平台的消息服务器**(Matrix,一套开源聊天协议的服务端,可以理解为「公司自己的微信服务器」),消息落在对应房间。 —— 证据:,原文 `2026-11-14 12:47:37,275 INFO gateway.run: inbound message: platform=matrix user=xhvrjtmwqh chat=!KjQZMmU77OZ... msg='# 请用**通俗语言**完整讲一遍…'`
+
+**2.【容器】网关一直在等消息**。网关是容器里一个常驻的收发消息进程。它采用**长轮询**的方式——不断向服务器询问「有新消息吗」,一旦有新消息,服务器就立刻返回连接。 —— 证据:`/opt/hermes/plugins/platforms/matrix/adapter.py:1848` 的 `_sync_loop`,核心是 `client.sync(since=next_batch, timeout=30000)`,外层还有 45 秒超时兜底 `asyncio.wait_for(..., timeout=45.0)`。
+
+**3.【容器】网关判断这条要不要处理**。它依次检查:是不是我自己发的、是不是黑名单用户、这个房间允不允许、是不是编辑动作、是不是重复事件。 —— 证据:`adapter.py:1979` 的 `_on_room_message`,依次调用 `_is_self_sender` → `_is_system_or_bridge_sender` → `_ignored_user_patterns` → `_is_allowed_matrix_room_event` → `_is_duplicate_event`;通过后按消息类型分流,纯文字走 `adapter.py:2025` 的 `_handle_text_message`。
+
+**4.【容器】消息正式交到我手上**。网关会写一条 `inbound message` 日志到 `gateway.log`——这就是第 1 步引的那条原始行,也是「消息真的到我这儿了」的第一手证据。
+
+**5.【我】规划、调工具、写文件**。每调用一次工具,后端就触发一次「工具调用后」的回调(**hook**:钩子,指程序在固定时机自动运行的一小段脚本,不需要人工触发)。—— 证据: 里配置了 `hooks.post_tool_call` → `matcher: write_file|patch|execute_code|terminal` → 执行 `python3 。
+
+**6.【我】整理出最终那段话**。这部分在你看到之前还会被「改写」一次,这是为自动脚本预留的插入点,用来补充成果链接。 —— 证据:`/opt/hermes/agent/turn_finalizer.py:411` 的 `_apply_output_hooks`,注释原文是「First hook to return a string wins」;配置在 `config.yaml:79`,实现是插件 。
+
+**7.【容器】网关把正文发回房间**,调用发送接口;成功后服务器返回一个事件编号。 —— 证据:`adapter.py:3025`;日志里是 `Matrix: sent event $xxx to !房间`(例如 `gateway.log:13349`)。**注意:回执、进度行、正文是三条独立的消息事件,逐条发出。**
+
+### 你问的四个问题
+
+**消息怎么到你手上的?** App → 平台消息服务器 → 我的网关用长轮询拉取 → 过滤 → 写 `inbound message` 日志 → 交给我。证据:`adapter.py:1848` → `adapter.py:1979` → `gateway.log`。
+
+**你怎么知道「这一轮结束了」?** 这不是靠信号,而是靠代码流程:主循环在「没有更多工具要调用、拿到最终文本」时退出,然后运行收尾函数。证据:`turn_finalizer.py:446` 的 `finalize_turn`。
+
+**App 怎么知道你回完了?** 我只能部分确定。我能证实的是「正文作为一条消息发出去了、拿到了事件编号」。App 拿到消息后用哪条逻辑判定结束,**代码不在我这个容器里,我给不出确切答案**。附带一个事实:有个插件专门做「正在输入」指示器,(`:305` 回合开始发 `typing=true`,`:334` 结束延时发 `false`),但它在 `config.yaml` 的 `disabled:` 列表里,**当前是关闭的**。
+
+**💻 terminal 过程行怎么和正文区分?** 靠消息里附加的一个标签字段 `fmode.kind`(消息服务器允许消息内容带自定义字段,相当于给消息贴一个类型标签):过程行标 `process`,正文**不带这个字段**。证据: 写死 `"fmode.kind": kind`、`:200` 传 `kind="process"`;出站插件 `plugin-matrix-process-notice` 的说明明确写「只写 process、绝不写 final」「其余 → 不写这个键」。另外过程行是 **`subprocess.Popen` 另起进程异步发送**(`:295`,因为 hook 只有 20 秒预算),且同一个会话、同一个工具名 **60 秒内最多 1 条**(`:108`、`THROTTLE_SECONDS = 60`)。
+
+## 二、文件上云
+
+**是我自己传的,没有人帮我传。** 更准确地说:我写完文件后,一个自动脚本接管了上传。
+
+**第 1 步:我写文件,hook 只判定、只记录,不传输。** —— 证据:`fmode-deliverable-hook.py:284` 注释原文「post_tool_call:只排队,不做网络 IO(快,不拖慢工具链)」。日志:`2026-11-14T00:03:32 QUEUE …双文件直链验证手册.md tool=write_file reason=marked-dir`。判据在 `:142` 的 `classify`:扩展名在白名单 + 不在缓存目录 + 文件名没有缓存特征;写到  下无条件算成果。反例也打日志:`SKIP  reason=denied-dir`。
+
+**第 2 步:等这轮话说完,一次性处理**(`config.yaml:79`,超时 180 秒专门留给上传)。
+
+**第 3 步:四步接口链**,`fmode-artifact-push.py:1-13` 的文件头注释原文:
+```
+① action=report          -> 建 Deliverable 记录,返回 id == deliverableId
+② action=enqueueArtifact -> 入队(deliverableId/agentId/source/filename/index)
+③ action=pendingArtifacts -> 取签名 uploadUrl,PUT 文件,ackArtifact(ok)
+④ curl publicUrl 回读 -> 字节数/md5 必须与本地产物一致才算成功
+```
+对应代码:report `:188`、enqueueArtifact `:201`、pendingArtifacts `:213`、PUT `:237`、ackArtifact 失败 `:240`/成功 `:244`、回读校验 `:249-264`。
+
+**上传地址从哪来?平台服务器给的,不是我的代码算出来的。** 我只是先向平台查询(`pendingArtifacts`),然后按它给的地址执行 PUT。`j["uploadUrl"]`、`j.get("headers")`、`j["publicUrl"]` 全是平台返回的字段(`:231-245`)。接口 ``(`:25`),云函数 `lfYlgU7SkK`(`:26`)。
+
+**路径长什么样(真实例子):**
+
+我实测 HTTP 200、925 字节,能读到「双文件直链验证手册」正文。
+
+**路径是谁决定的:** `rAWDfHSYUg`(批次号)是**平台**在 report 时生成的;`0-mwf61oyith` 的编号是**平台**在 enqueue/pending 时生成的;文件名那段的 ASCII 清洗是**我的代码**做的(`fmode-deliverable-hook.py:534` 的 `_ascii_safe_filename`,因为云盘签名不兼容中文名,直接传会 403),中文原名保留在成果记录的 `name` 里。
+
+**传完要做什么 App 才看得到:两步,缺一不可。**
+1. ackArtifact 带 publicUrl(`fmode-artifact-push.py:244`),完成旧成果表 `lfYlgU7SkK` 的收尾;
+2. **登记归属**——调用新云函数 `fmode-file-hub`(编号 `THXAOGKiPK`),`action=registerFile`。证据:`fmode-deliverable-hook.py:434` 的 `HUB_FN = "THXAOGKiPK"  # fmode-file-hub:带会话归属的成果登记`,实现 `:465` 的 `_hub_register`,入参 `:477-489`。日志:`2026-11-14T00:03:36 HUB … ok=True detail=registered id=a7eMu7xDRz`。
+
+**关键是 `threadRoot`(会话根标识),没有它 App 查不到。** 代码注释原文(`:378-391`):「App 侧约定:云函数 listBySession 按 user + threadRoot 查,threadRoot 必填;右栏「成果」页签就靠它,登记不进去用户就看不到文件。」真实反例(`hook.log:1877-1878`):`SKIP-REGISTER no threadRoot sid=None(文件已上云,仅缺归属)` + `HUB … ok=False detail=no-threadRoot` —— 文件在云上有合法链接,但 App 里看不见。
+
+最后本地源文件不立即删除,交给 `fmode-media-gc.py` 1 小时后清理(日志 `DEFER-CLEANUP …`)。
+
+## 三、成果栏的数据来源
+
+**存在哪:** 平台数据库里的一张**独立成果表**,不是从消息里现算。承载它的是云函数 `fmode-file-hub`,App 读取的动作是 `listBySession`(传 `threadRoot`),我现场调过,返回 `code:200` + `files` 列表 + `count`。
+
+**谁写进去的:** 我这台容器写的(`_hub_register` 里的 `registerFile`)。App 自己不写。
+
+**一条记录里有什么**(以下是刚从平台实读回来的完整一条):
+- `id`: `o3KraMy7cp` —— 记录编号,也是登记返回值
+- `threadRoot`: `!VqmhvHuvfgNOIRmD2Vge27…` —— 属于哪个会话(App 按它筛选,必填)
+- `roomId`: `!VqmhvHuvfgNOIRmD2Vge27…` —— 属于哪个房间
+- `groupId` / `groupIdSource`: 空
+- `agentId`: `agent-node-lj-test` —— 哪个数字生命交的
+- `name`: `probe-name-clean.md` —— 用户看到的文件名
+- `size`: `2`;`mimeType`: `text/markdown`
+- `url`: `` —— 点「成果」时真正打开的地址
+- `storageKey`: `agent-node-lj-test/202611/PROBEcall/…` —— 云盘内部对象键
+- `uploadedBy`: `agent` —— 谁传的
+- `status`: `delivered`;`deliveredAt`: `1794587965579`(毫秒时间戳)
+
+顺带一个观察:`threadRoot` 不一定是房间号。真实数据里既有 `!qMGkN…` 这种房间号,也有 `$approot_mwf6de1q_zovk2p6d:fmode.cn` 这种 App 侧的线程根事件编号——说明根由发起方决定,回落顺序在 `_thread_root`(`:378`)的「三级回落」里。
+
+## 四、术语解释
+
+- **report** —— 一个接口动作,意思是「我要交一批成果了」。平台建记录并返回编号,是第 ① 步。`fmode-artifact-push.py:188`
+- **deliverableId** —— report 返回的批次编号,如 `rAWDfHSYUg`,一批文件共用一个,云盘路径里也有。代码里变量名 `did`
+- **enqueueArtifact** —— 把文件排进上传队列,逐个报「属于哪批、什么名、第几个」。第 ② 步,`:201`
+- **pendingArtifacts** —— 取回待传清单,向平台要 `uploadUrl` + 请求头 + 未来可用的 `publicUrl`。第 ③ 步,`:213`
+- **ackArtifact** —— 回执,告诉平台传成功还是失败;成功时上交 `publicUrl`,失败必须带原因(否则队列一直挂着)。`:240`/`:244`
+- **registerFile** —— **登记归属**,不是传文件,是往新成果表写「这文件属于哪个会话」。没有它,文件在云上但 App 看不到。`fmode-deliverable-hook.py:478`
+- **publicUrl** —— 平台返回的公开可下载地址,就是我发给用户的那个 https 链接
+- **uploadUrl** —— 平台返回的带签名的临时上传地址(签名=一次性授权码,限定只能传这一个文件、短期有效),有了它无需长期密钥就能写云盘
+
+## 五、一个真实例子
+
+**时间:** 2026-11-14 00:03(CST),全程约 5 秒
+**当时在做什么:** 验证 App 出站文件走云盘直链是否可行,产出两份文档
+**日志原文**:
+
+```
+00:03:32 QUEUE  tool=write_file reason=marked-dir
+00:03:32 QUEUE  tool=write_file reason=marked-dir
+00:03:34 REPORT did=rAWDfHSYUg agent=agent-node-lj-test title=双文件直链验证手册.md 等2个文件
+00:03:36 PUSH  ok=True url=
+00:03:36 HUB … ok=True detail=registered id=a7eMu7xDRz
+00:03:37 PUSH  ok=True url=
+00:03:37 HUB … ok=True detail=registered id=AnSzqALXrC
+00:03:37 PUSH-OK [两个链接]
+```
+
+**最终链接(我刚实测 HTTP 200、925 字节,正文能读到):**
+
+**App 里能看到吗?** 那两条 `HUB` 都是 `ok=True` 且拿到了登记 id,按约定当时应该显示了。但我现在用 `listBySession` 查那个房间(`!Hm9UERWD6VbWL4ZBxGy_6DcqUIj_Sve8g5ekJ5m8YkY`)返回 `count: 0`、`files: []`,**现在查不到了**——可能是会话变迁或记录被清理,这块我不确定。
+
+## 六、我不确定的地方
+
+1. **App 怎么判断「你回完了」** —— 消息发出、拿到事件编号我能证实(`adapter.py:3025`、日志 `sent event $...`),但 App 拿到后用哪条逻辑判定结束,代码不在这个容器里。
+2. **`✅` 算不算结束信号** —— 我不确定 App 是否用 reaction 表示回合结束;✅ 是谁加的、加在哪条上,我没有证据。
+3. **第五节那两个文件当时是否真的显示在 App 成果栏** —— 现在查该房间返回 0 条,不能倒推当时状态。
+4. **成果表完整字段定义** —— 上面那条是实读的、字段真实,但表里是否还有我没读到的内部字段不知道;我试了 `list` / `listFiles` 都返回 `unknown action`,没有完整接口清单。
+5. **`threadRoot` 何时是房间号、何时是 `$approot_...`** —— 两种值在真实数据里都存在,代码有「三级回落」注释,但「App 什么时候带自己的线程根」是 App 侧行为,我看不到那段代码。
+
+---
+
+文档也生成好并上云了(HTML 版,带样式,适合直接转发给非技术同事):**回读确认已登记,记录 id `tUI5k13HUb`,md5 本地与远端一致。**
+
+https://s3.fmode.cn/deliverables/agent-node-lj-test/202611/IZGnEA5j4I/0-mwfxhd1d3x-消息到文件的完整旅程.html
+
+**已上云(可直接下载)**
+- https://s3.fmode.cn/deliverables/agent-node-lj-test/202611/HS0tiBYZ6l/0-mwfxib8ro9-deliverable-20261114-125159.md
+```
+
+### 48【file】
+
+```text
+消息到文件的完整旅程.html
+```
+
+### 49【file】
+
+```text
+消息到文件的完整旅程.md
+```
+
+### 50【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `💻 terminal ``` ls -la /opt/data/ 2>&1 | head -40; ec... ````
+- `* 💻 terminal ``` ls -la /opt/data/ 2>&1 | head -40; ec... ``` ``` ls -la /opt/data/plugins/ 2>&1; echo ... ````
+- `📖 Reading fmode-process-notice.py 💻 terminal ``` tail -60 /opt/data/cache/deliverable-... ````
+- `* 📖 Reading fmode-process-notice.py 💻 terminal ``` tail -60 /opt/data/cache/deliverable-... ``` ``` grep -n "PUSH .* ok=True" /opt/data/c... ``` ``` sed -n '1780,1830p' /opt/data/cache/d... ````
+- `✍️ Writing /opt/data/work/pipeline-explainer/消息到...`
+- `💻 terminal ``` cd /opt/data && python3 /opt/data/scr... ````
+- `* 💻 terminal ``` cd /opt/data && python3 /opt/data/scr... ``` ``` cd /opt/data && python3 - <<'EOF' ... ````

+ 403 - 0
docs/03-interface/ljtest-文件交付五步-回答.md

@@ -0,0 +1,403 @@
+# 容器取证问答:agent-node-lj-test
+
+- 时间:2026-11-14T04:40:31.093Z
+- 房间:`!qkdzLyvvnHNuQ7SUYtoPY7rhvzuE4XS4BghZDMIMlfs`(临时房间,可保留查阅)
+- 消息统计:正文 7 段 / 工具过程 7 段 / 文件 1 个
+- 结束原因:收到 ✅ reaction
+
+## 我发出的问题
+
+```text
+# 请把「容器产文件 → 上传 → 台账 → App 展示」这条链路的**真实代码**贴出来
+
+## 背景
+
+另一台容器 `agent-node-kangshifu` 的交付链路坏了。它自己直传 S3,拿到的是
+`https://s3.fmode.cn/user/<uid>/report/...`,前缀不对,App 不认。
+
+你说过:
+
+> **key = `deliverables/<agentId>/<YYYYMM>/<deliverableId>/<token>-<filename>` 完全由云函数 `lfYlgU7SkK` 服务端决定,容器侧零参与。**
+
+我要知道**完整的那条路怎么走的**,每一步的真实代码。
+
+---
+
+## 1. 容器侧:怎么把「有个文件要交付」告诉平台
+
+```bash
+grep -rn "enqueueArtifact\|pendingArtifacts\|ackArtifact" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null
+```
+
+**贴出调用点全文**(含上下 20 行)。我要看:
+- 传了哪些参数
+- `source` 传的是什么(本地路径?)
+- 有没有 `deliverableId`,从哪来
+
+---
+
+## 2. 容器侧:怎么领任务、怎么上传
+
+```bash
+grep -rn "put_signed_url\|uploadUrl\|ackArtifact" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | head -20
+```
+
+贴出**真正执行 PUT 的那段代码**。
+
+---
+
+## 3. 台账登记:什么时候调、传什么
+
+```bash
+grep -n "_hub_register" -A 20 /opt/data/scripts/fmode-deliverable-hook.py 2>/dev/null
+grep -rn "registerFile" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | head
+```
+
+要回答:
+- 登记发生在**上传之前还是之后**?
+- `uploadedBy` 传什么?
+- `roomId` / `threadRoot` 怎么来的?
+
+---
+
+## 4. 关键:为什么你不需要 `AlP56LCKFm`
+
+```bash
+grep -rn "AlP56LCKFm\|uploader.mjs" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | head
+```
+
+**确认零命中**(或者贴出命中位置)。
+
+---
+
+## 5. 最终发给 App 的 `m.file` 事件长什么样
+
+```bash
+grep -rn "m.file\|msgtype.*file\|MEDIA:" /opt/data/scripts/fmode_outbound_media.py 2>/dev/null | head -15
+```
+
+贴出**构造事件 content 的那段代码**。我要看 `url` 字段塞的是什么。
+
+---
+
+## 回答格式
+
+```
+## 1. 通知平台
+<代码原文>
+参数 = ?
+
+## 2. 领任务 + 上传
+<代码原文>
+
+## 3. 台账
+<代码原文>
+时机 = 上传前 / 后
+uploadedBy = ?
+roomId 来源 = ?
+
+## 4. 是否用 AlP56LCKFm
+<结果>
+
+## 5. m.file 事件
+<代码原文>
+url 字段 = ?
+
+## 6. 完整时序
+1. ... → 2. ... → 3. ...(用一句话一步)
+```
+
+🔴 **只要原文和事实,不要建议。**
+```
+
+## 容器回答(原文,按时间顺序,未删改)
+
+### 1【prose】
+
+```text
+收到,只为取证:我会逐条运行这 5 组 grep,并把原始输出和真实代码贴回来,不做任何修改。
+```
+
+### 2【prose】
+
+```text
+execute_code · from hermes_tools import terminal cmds = { "A": r'''grep -rn…
+```
+
+### 3【prose】
+
+```text
+刚跑了骨架,`AlP56LCKFm` 零命中已确认。现在取全文代码。
+```
+
+### 4【prose】
+
+```text
+已经拿到 push 脚本全文,共 295 行。现在读取 hook 中的上传段、台账段和 m.file 构造段。
+```
+
+### 5【prose】
+
+```text
+现在取 `_thread_root()`、`_load_pusher()` 和 m.file 构造段,以及 outbound_media 全文。
+```
+
+### 6【prose】
+
+```text
+最后确认 `upload_bytes`(真正决定 key 前缀的那段)和 hook 的触发入口。
+```
+
+### 7【prose】
+
+```text
+## 1. 通知平台
+
+入口是 `hooks.post_tool_call`(只把文件加入队列,不发起网络请求),真正加入队列的是 `upload_and_register()`。文件:`/opt/data/scripts/fmode-deliverable-hook.py`
+
+**(a) 入口分发 L965-970**
+```python
+if event == "post_tool_call":
+    return handle_post_tool_call(payload)
+if event == "transform_llm_output":
+    return handle_transform_llm_output(payload)
+```
+
+**(b) 会话归属决定 + report L587-593, L640-650**
+```python
+    # 会话归属闸门:threadRoot 只决定「是否登记成果表」,**不决定是否上传**。
+    thread_root = _thread_root()
+    sid_env = os.environ.get("HERMES_SESSION_ID")
+    if not thread_root:
+        log("NO-THREADROOT sid=%s(照常上传,跳过登记)" % sid_env)
+    room_id = os.environ.get("HERMES_SESSION_CHAT_ID") or ""
+...
+    # ① report:建成果记录(artifacts 里带原始中文名,成果表可读)
+    rep = push.call({"action": "report", "agentId": agent_id, "title": title,
+                     "summary": summary,
+                     "artifacts": [{"name": os.path.basename(p), "source": p} for p in live],
+                     "tags": ["auto-deliverable-hook"]})
+    did = rep.get("id")
+```
+
+**(c) 加入队列 L652-658**
+```python
+    # ② enqueueArtifact — 对象名必须纯 ASCII(OBS 签名限制),中文原名只留在 title/name
+    stored = {}
+    for idx, p in enumerate(live):
+        safe = _ascii_safe_filename(os.path.basename(p))
+        stored[p] = safe
+        push.call({"action": "enqueueArtifact", "deliverableId": did, "agentId": agent_id,
+                   "source": p, "filename": safe, "index": idx})
+```
+
+**参数 =** `action / deliverableId / agentId / source / filename / index`
+- `source` = **容器本地绝对路径**(`os.path.abspath` 后的 `/opt/data/work/...`,是路径字符串而非文件内容)
+- `filename` = 经 `_ascii_safe_filename()` 转纯 ASCII 的名字(中文丢成 `-`)
+- `deliverableId` = 上一步 `report` 返回的 `rep["id"]`,**容器自己不生成**
+
+## 2. 领取任务 + 上传
+
+**(a) 取签名地址 + 筛选本次 job L661-677**
+```python
+    pend = push.call({"action": "pendingArtifacts", "agentId": agent_id})
+    jobs = pend.get("jobs") or []
+    want = set(stored.values())
+    by_src = {}
+    for j in jobs:
+        src = j.get("source")
+        if src not in live:
+            continue
+        fname = (j.get("filename") or os.path.basename(j.get("uploadUrl", "").split("?")[0]))
+        ascii_ok = all(ord(c) < 128 for c in (j.get("uploadUrl") or ""))
+        if fname not in want and not ascii_ok:
+            continue
+        prev = by_src.get(src)
+        if prev is None or (ascii_ok and not all(ord(c) < 128 for c in (prev.get("uploadUrl") or ""))):
+            by_src[src] = j
+```
+
+**(b) 真正执行 PUT L685-698**
+```python
+            data = open(p, "rb").read()
+            import urllib.request
+            import urllib.error as _uerr
+            from urllib.parse import quote
+            put_url = quote(j["uploadUrl"], safe=":/?&=%@+~!$'()*,;#[]")
+            req = urllib.request.Request(put_url, data=data, method="PUT")
+            for k, v in (j.get("headers") or {}).items():
+                req.add_header(k, v)
+            with urllib.request.urlopen(req, timeout=180) as r:
+                if r.status // 100 != 2:
+                    raise RuntimeError("PUT status=%s" % r.status)
+```
+
+**(c) ack + 回读校验 L717-732**
+```python
+        url = j.get("publicUrl") or ""
+        push.call({"action": "ackArtifact", "objectId": j["id"], "ok": True, "publicUrl": url})
+
+        import urllib.request
+        from urllib.parse import quote as _q
+        try:
+            with urllib.request.urlopen(_q(url, safe=":/?&=%@+~!$'()*,;#[]"), timeout=120) as r:
+                got = r.read()
+            same = hashlib.md5(got).hexdigest() == hashlib.md5(data).hexdigest()
+            detail = "readback-ok bytes=%d" % len(got) if same else "readback-mismatch"
+        except Exception as e:
+            same, detail = False, "readback-error:%s" % str(e)[:120]
+```
+
+**这条 PUT 用的 host/path 全部来自 `j["uploadUrl"]`**,而该值由云函数 `lfYlgU7SkK` 用服务端签名生成。容器从不构造 key。
+
+## 3. 上传后的台账登记
+
+`registerFile` 在全仓**只出现在一处**:`fmode-deliverable-hook.py:478`。
+
+**(a) `_hub_register()` L465-489**
+```python
+def _hub_register(path: str, url: str, thread_root: str, room_id: str) -> dict:
+    """登记单个文件到 fmode-file-hub。返回 {ok, detail, id, key}。"""
+    key = ""
+    # 云盘对象键:从 s3 公开地址反推(<bucket>/... → 去掉域名与 bucket)
+    try:
+        from urllib.parse import urlsplit
+        p = urlsplit(url).path.lstrip("/")
+        seg = p.split("/", 1)
+        key = seg[1] if len(seg) == 2 else p
+    except Exception:
+        key = ""
+    size = os.path.getsize(path)
+    r = _hub_call({
+        "action": "registerFile",
+        "threadRoot": thread_root,
+        "agentId": _resolve_agent_id(),
+        "roomId": room_id,
+        "name": os.path.basename(path),
+        "url": url,
+        "key": key,
+        "mimeType": _mime_for(path),
+        "size": size,
+        "uploadedBy": "agent",
+        "source": path,
+    })
+```
+
+**(b) 调用位置 L736-745(在 PUT + ack + 回读之后)**
+```python
+        if same and url:
+            if thread_root:
+                hub = _hub_register(p, url, thread_root, room_id)
+            else:
+                hub = {"ok": False, "skipped": True, "detail": "no-threadRoot"}
+                log("SKIP-REGISTER no threadRoot sid=%s(文件已上云,仅缺归属)%s" % (sid_env, p))
+```
+
+**时机 = 上传之后**(严格顺序:PUT → ack → 回读 md5 → 才登记;回读不一致则完全不登记)
+
+**uploadedBy = 硬编码字符串 `"agent"`**
+
+**roomId 来源 =** `os.environ.get("HERMES_SESSION_CHAT_ID") or ""`(环境变量,可能为空串)
+
+**threadRoot 来源 =** `_thread_root()` 三级回落(L378-431):① `state.db` 里 `sessions.thread_id` → ② `sessions.chat_id`,为空则从 `session_key` 拆 `!` 开头的段 → ③ 都没有返回 `None`,此时跳过登记
+
+## 4. 是否用 AlP56LCKFm
+
+```
+grep -rn "AlP56LCKFm\|uploader.mjs" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | head
+```
+
+**结果:零命中,零输出。** 本机全仓没有任何地方引用 `AlP56LCKFm`,也没有任何地方调用 `uploader.mjs`。
+
+原因即代码事实:key 前缀完全由 `pendingArtifacts` 返回的 `uploadUrl` 决定(L692 `put_url = quote(j["uploadUrl"], ...)`),本机代码里不存在拼 key 的语句。
+
+## 5. m.file 事件
+
+`m.file` 的 `url` 字段在 **`/opt/data/plugins/plugin-matrix-outbound-s3/__init__.py`** L217-234 构造:
+
+```python
+                msg_content: Dict[str, Any] = {
+                    "msgtype": msgtype, "body": caption or filename,
+                    "info": {"mimetype": content_type, "size": len(data)}}
+                if encrypted_file is not None:
+                    msg_content["file"] = {**encrypted_file.serialize(), "url": media_url}
+                else:
+                    msg_content["url"] = media_url
+                if is_voice:
+                    msg_content["org.matrix.msc3245.voice"] = {}
+                    audio_metadata = {
+                        k: v for k in ("duration", "waveform")
+                        if (v := (voice_metadata or {}).get(k)) is not None}
+                    if "duration" in audio_metadata:
+                        msg_content["info"]["duration"] = audio_metadata["duration"]
+                    if audio_metadata:
+                        msg_content["org.matrix.msc1767.audio"] = audio_metadata
+                self._apply_relation_metadata(msg_content, reply_to=reply_to, metadata=metadata)
+                return await self._send_content_event(room_id, msg_content)
+```
+
+`media_url` 的来源 L208-215:
+```python
+                else:
+                    try:
+                        media_url = await _upload_media_to_object_storage(
+                            self, upload_data, filename, content_type)
+                    except Exception as exc:
+                        # 不静默回退 upload_media:随机 mxc/https 混杂比如实失败更难排查
+                        logger.error("Matrix: object-storage upload failed: %s", exc)
+                        return _fail("%s: %s" % (type(exc).__name__, exc))
+```
+
+`_upload_media_to_object_storage` → `/opt/data/scripts/fmode_outbound_media.py:upload_bytes()`,其中 L159-163:
+```python
+        public_url = job.get("publicUrl") or ""
+        if not public_url:
+            raise OutboundMediaError("pendingArtifacts returned empty publicUrl")
+        push.call({"action": "ackArtifact", "objectId": job["id"], "ok": True,
+                   "publicUrl": public_url})
+```
+
+**`url` 字段 = `job["publicUrl"]`**,即平台 `pendingArtifacts/ackArtifact` 返回的 https 地址 — 不是容器拼的,也不是 `s3.fmode.cn/user/<uid>/report/` 那种手拼前缀。E2EE 房间走 `msg_content["file"]["url"] = mxc`,非 E2EE 走 `msg_content["url"] = https`。
+
+## 6. 完整时序
+
+1. `post_tool_call` 判定文件是交付成果 → 写入 state.db 的 `queued`(无网络 IO)
+2. `transform_llm_output` 调 `upload_and_register()` → `_thread_root()` 取 `threadRoot`、`HERMES_SESSION_CHAT_ID` 取 `roomId`
+3. 幂等闸门:`action=list` 查近 50 条,同名 + 线上 md5 一致则直接复用旧 URL,跳过后续
+4. `action=report` 建成果记录 → 返回 `id` 即 `deliverableId`
+5. `action=enqueueArtifact` 入队(`source`=本地绝对路径 / `filename`=ASCII 化名 / `index`)
+6. `action=pendingArtifacts` 取服务端签名 `uploadUrl` + `publicUrl` + `headers`
+7. 对 `uploadUrl` 原样 `urllib PUT`(百分号编码 path,key 由服务端决定)
+8. `action=ackArtifact{ok:true, publicUrl}` 让后端登记到数据库
+9. 回读 `publicUrl` 算 md5,与本地一致才认为成功
+10. 有 `threadRoot` 才调 `THXAOGKiPK / registerFile` 登记到 fmode-file-hub(`uploadedBy="agent"`)
+11. 回复末尾追加 https;`plugin-matrix-outbound-s3` 发附件时 `m.file.url` 取同一 `publicUrl`
+
+**对照 `agent-node-kangshifu` 得到的结论(只陈述代码事实):** 本机链路里,任何一步都不允许容器自拼 key;一旦绕过 `pendingArtifacts`、直接向 `s3.fmode.cn/user/<uid>/report/...` 上传,App 侧就认不到——因为那条路径不是 `lfYlgU7SkK` 签出来的 `deliverables/<agentId>/...`。
+
+📄 上附 `fmode-artifact-push.py` 源文件(295 行全文,本机正在运行的版本),可直接对照。
+```
+
+### 8【file】
+
+```text
+fmode-artifact-push.py
+```
+
+### 9【ack】
+
+```text
+✅ 容器已标记完成
+```
+
+---
+
+## 工具过程(仅供核对,非回答内容)
+
+- `🐍 Running code from hermes_tools import terminal cmd...`
+- `🐍 Running code from hermes_tools import read_file, t...`
+- `🐍 Running code from hermes_tools import read_file r ...`
+- `🐍 Running code from hermes_tools import terminal, re...`
+- `🐍 Running code from hermes_tools import read_file pr...`
+- `* 🐍 Running code from hermes_tools import read_file pr... 🐍 Running code from hermes_tools import read_file, t...`
+- `🐍 Running code from hermes_tools import read_file, t...`

+ 69 - 0
docs/03-interface/loadtest-n30-v2.md

@@ -0,0 +1,69 @@
+# 压测结果 — agent-node-kangshifu — 并发 30
+
+- 时间:2026-11-14T04:55:35.655Z
+- 用户:@yobhyhzjcx:fmode.cn
+- 容器:@edzb0hg93a:fmode.cn
+- 超时:1800s
+
+## 汇总
+
+| 指标 | 值 |
+|---|---|
+| 建房间 | 30 |
+| 容器进房 | 30 |
+| 发出 | 30 |
+| **完成** | **30** |
+| 串台房间数 | 0 |
+| 有回复但无本房 token | 0 |
+| **/deliverables/ 前缀正确** | **7** |
+| 链接前缀错误(App 不认) | 0 |
+| **S3 内容验证通过** | **7** |
+| 耗时 p50 | 75.1s |
+| 耗时 max | 93.6s |
+
+## 逐房间
+
+| # | 房间 | token | 完成 | 耗时 | 本房token | 串台 | deliverables | S3 | 内容 |
+|---|---|---|---|---|---|---|---|---|---|
+| 1 | `!QkMe4uZJxV6xt0bQT2nlmCSztvXPbUWp9Z3ryd4WxmE` | T1-VMFMQB | ✅ | 51.0s | ✅ | — | ✅ | 200/8880B | ✅ |
+| 2 | `!1JxfLcQ9J7U7ghGgdTYx3psZRvBMvymzLBnjnDO5N2k` | T2-J81UEM | ✅ | 51.0s | ✅ | — | ✅ | 200/8880B | ✅ |
+| 3 | `!6hbstMMRtPM2OxxIKn_3eGmW9rN-kNwqQeW5323kMxI` | T3-0VW1WF | ✅ | 51.0s | ✅ | — | ✅ | 200/8909B | ✅ |
+| 4 | `!HzaNhFBtgmlKaBeWcLplTp0VXVPxsm1CZK5ulrFMwVY` | T4-RD2SRX | ✅ | 50.9s | ✅ | — | ✅ | 200/8880B | ✅ |
+| 5 | `!W74dInT7NGSb77fRK9c6ozSHh7UhWwr9nxQNfSELrUo` | T5-3IGMFA | ✅ | 50.9s | ✅ | — | ✅ | 200/8880B | ✅ |
+| 6 | `!h120JLqp7kFNEDS6SMjYL8gl5jJFIAPrwCnuYWVcRys` | T6-VGC96F | ✅ | 50.9s | ✅ | — | ✅ | 200/8880B | ✅ |
+| 7 | `!xHTxdG1UkofDCCpW_JIFKWQ3a1SQJ-i67UCavcpA3Ak` | T7-MNDWYB | ✅ | 75.4s | ✅ | — | ❌ | — | ❌ |
+| 8 | `!lTf-MGW558-GkMmKqtblc37BinyRHnTVIDBd6oZbyiM` | T8-USQESO | ✅ | 75.4s | ✅ | — | ❌ | — | ❌ |
+| 9 | `!IVkXRzExXED2n28l2TwH2JO2FHRgAjzDJRqRxjLF_0A` | T9-GZBXLP | ✅ | 78.4s | ✅ | — | ❌ | — | ❌ |
+| 10 | `!1HJCBlmFk3VWeK8u-FYsCIZDF_1boD3uj1VLBoQxLig` | T10-Z5ALDX | ✅ | 72.2s | ✅ | — | ❌ | — | ❌ |
+| 11 | `!48TCmjo3pPdJpIpzSmdnZvDHswOYSlrqSci7IukDHvg` | T11-NFV2BU | ✅ | 87.6s | ✅ | — | ❌ | — | ❌ |
+| 12 | `!HIqY__oHKSHO2Oz3wcbK9B1r6H8U084jxnnPaVPxXhE` | T12-EBHA4Y | ✅ | 72.2s | ✅ | — | ❌ | — | ❌ |
+| 13 | `!0fYAfV-j0YqwgpTm4hhKhBMQ4eibKNA4Jab1Ev9QVQk` | T13-0GZSLA | ✅ | 72.2s | ✅ | — | ❌ | — | ❌ |
+| 14 | `!B4K8zX_apwa-K1MwDNzZvRJABbPezbHnFq_j5x-TdYM` | T14-99X7J9 | ✅ | 50.6s | ✅ | — | ✅ | 200/9271B | ✅ |
+| 15 | `!pD3L7EFsd2_wOKQUAGPZzOl4vFSt9DBKgNBAHVR-FYc` | T15-DOGUOK | ✅ | 93.6s | ✅ | — | ❌ | — | ❌ |
+| 16 | `!TraxC9MKuaeDoS1zKLfIL8CmbI-H-qybZvBIEGop4lA` | T16-CT86VW | ✅ | 84.4s | ✅ | — | ❌ | — | ❌ |
+| 17 | `!YYlT8KBOOp-K99K8scmSPR_U9QphitssakHNoBx6UYU` | T17-584X89 | ✅ | 84.3s | ✅ | — | ❌ | — | ❌ |
+| 18 | `!aRcuJO0XShmq0XfmU9QX-W_1wvyl-DLLbNc3CtqCwLQ` | T18-RW4HWG | ✅ | 75.1s | ✅ | — | ❌ | — | ❌ |
+| 19 | `!A3_DzUyeHdgIO9TzkqlwFKzDGoIORVw5tw2P2NpW2M4` | T19-7R168H | ✅ | 87.3s | ✅ | — | ❌ | — | ❌ |
+| 20 | `!USiA39O_ujUkwGxXAPbmByQeKv4xfchNKKmcGq0MNOM` | T20-JL7GPT | ✅ | 93.4s | ✅ | — | ❌ | — | ❌ |
+| 21 | `!7nGYkOQGolFxlEhBl8yKqd26R9iN_F03dQ9U8WgGbWk` | T21-N2I5ZO | ✅ | 75.0s | ✅ | — | ❌ | — | ❌ |
+| 22 | `!5RbwNsGbglDp7m9ZdDz7ef6il-rZvlzBxC2CEAxCaXs` | T22-T6WSBK | ✅ | 75.0s | ✅ | — | ❌ | — | ❌ |
+| 23 | `!n3D1Hpn1W_0zB88Lw0tf0liRHQZ5jj8Ey2tRCIouchQ` | T23-YADONB | ✅ | 68.8s | ✅ | — | ❌ | — | ❌ |
+| 24 | `!EqXCl80MnaMvQFxK3GjsA5t6vrASEKLjpKAeCHhBKF4` | T24-8I431Z | ✅ | 84.1s | ✅ | — | ❌ | — | ❌ |
+| 25 | `!I8wYP_TxtFxf7sE7tCzMSPMzzQp3lKeLwUh5frgpCWY` | T25-TJLOPY | ✅ | 90.2s | ✅ | — | ❌ | — | ❌ |
+| 26 | `!pcXsVn9UcKLMA1DtO-E98TlWegjHfTD0Mgiv1JHq1ts` | T26-4XVQKP | ✅ | 77.9s | ✅ | — | ❌ | — | ❌ |
+| 27 | `!vvBWx8nxp5XyY7AtW1c7cp9w5jpGxd3vt6UMOiv6LS8` | T27-CLN1MP | ✅ | 74.8s | ✅ | — | ❌ | — | ❌ |
+| 28 | `!YLzWLuYQKIJAstrRycfSZR_gtPitD4W4VgC3-XJUBpU` | T28-Y7OUAY | ✅ | 50.2s | ✅ | — | ❌ | — | ❌ |
+| 29 | `!MY9ussDJX9g5gSgrQh3SGf4aem14imfEO7m_XVTHi2E` | T29-59JNDK | ✅ | 80.9s | ✅ | — | ❌ | — | ❌ |
+| 30 | `!qKMOA6KRqQBVJ54_iWsMiLcxo7cDi6sYTiswzqKggYM` | T30-XLHCZ8 | ✅ | 87.0s | ✅ | — | ❌ | — | ❌ |
+
+## 串台详情
+
+无串台。
+
+## 链接前缀问题
+
+全部链接都是 `/deliverables/` 前缀。
+
+## 失败明细
+
+无失败。

+ 149 - 0
docs/03-interface/指令-kangshifu-加重试与轮询.md

@@ -0,0 +1,149 @@
+# 补两处:领不到多轮询 + PUT 失败重签重试
+
+## 云函数已改好(我刚部署并验证)
+
+| 改动 | 原值 | 新值 |
+|---|---|---|
+| `pendingArtifacts` 返回条数 | 20 | **200** |
+| 上传地址有效期(正常入队) | 300 秒 | **1800 秒(30 分钟)** |
+| 上传地址有效期(自动补登记) | 600 秒 | **1800 秒** |
+
+实测确认:`list` 返回 `{"code":200,"count":3,...}` 正常,代码长度 22960,已回读校验。
+
+**所以你那边现在能一次看到全部 pending,地址也有 30 分钟。**
+
+---
+
+# 但你还要补两件事
+
+## ① 领不到自己的 job 时,多轮询几轮
+
+即使 limit 改成 200,高并发下也可能刚好被别的任务挤掉。现在是「3 次轮询,每次 2 秒」,**间隔太短**(总共 6 秒)。
+
+**改成**:
+
+```python
+# 领自己的 job:最多等 90 秒,间隔 5 秒
+job = None
+deadline = time.time() + 90
+while time.time() < deadline:
+    r = call("lfYlgU7SkK", {"action": "pendingArtifacts", "agentId": AGENT})
+    jobs = r.get("jobs") or []
+    mine = [j for j in jobs if j.get("deliverableId") == DID]
+    if mine:
+        job = mine[0]
+        break
+    time.sleep(5)
+
+if not job:
+    log("SKIP-PUT no job after 90s did=%s" % DID)
+    return False        # 显式失败,不许静默
+```
+
+🔴 **必须按 `deliverableId` 过滤**(你已经加了这个,保持)。
+
+## ② PUT 失败或超时 → 重新申请地址重试
+
+```python
+MAX_UPLOAD_ATTEMPTS = 2   # 首次 + 1 次重试
+
+for attempt in range(MAX_UPLOAD_ATTEMPTS):
+    job = get_my_job(DID)          # 上面的轮询
+    if not job:
+        return False
+    try:
+        put_file(job["uploadUrl"], data, job.get("headers"))
+        size = read_back_size(job["publicUrl"])     # 回读校验
+        if size == len(data):
+            ack(job, ok=True)
+            break
+        else:
+            log("PUT-SIZE-MISMATCH attempt=%d got=%s want=%s" % (attempt, size, len(data)))
+    except Exception as e:
+        log("PUT-FAIL attempt=%d err=%s" % (attempt, e))
+
+    # 失败 → 标记旧 job 失败(让云函数下一轮重新签)
+    try:
+        call("lfYlgU7SkK", {"action": "ackArtifact",
+                            "objectId": job["id"], "ok": False, "reason": str(e)[:120]})
+    except Exception:
+        pass
+    time.sleep(3)
+else:
+    log("PUT-EXHAUSTED did=%s" % DID)
+    return False
+```
+
+🔴 **失败时必须 ack(ok=False)** —— 否则旧 job 一直占着 `pending` 状态,
+下次 `enqueueArtifact` 会「复用」这个坏 job(`dupQ` 查得到它),永远卡死。
+
+---
+
+# 三个必须守的点
+
+### 1. ack(ok=False) 要带 reason
+
+云函数的 `ackArtifact` 失败分支我没看到完整实现,**你先确认它接受 `reason` 参数**;
+不接受就只传 `ok:False`,但**必须传**,不能什么都不做。
+
+### 2. 重试要重新 enqueue,不是复用旧地址
+
+旧地址已过期或已失败。重试流程应该是:
+
+```
+ack(旧job, ok=False)  →  再调 enqueueArtifact 建新 job  →  重新 pendingArtifacts 领新的
+```
+
+**不是**拿着旧 `uploadUrl` 再 PUT 一次。
+
+### 3. 日志要能看出是哪一轮
+
+```
+PUT-FAIL attempt=0 err=...
+PENDING-WAIT 15s did=...
+PUT-OK attempt=1 size=9271
+PUT-EXHAUSTED did=...
+```
+
+---
+
+# 验证
+
+改完后,**用一个 15MB 的大文件**实测一次(模拟大文件场景):
+
+```bash
+dd if=/dev/urandom of=/opt/data/work/big-15mb.bin bs=1M count=15
+ls -la /opt/data/work/big-15mb.bin
+```
+
+走完整流程,记录:
+- 上传耗时
+- 有没有触发重试
+- 最终 s3 链接能不能下载、md5 是否一致
+
+---
+
+# 回给我的格式
+
+```
+## ① 轮询
+现在的参数 = ?(次数 / 间隔 / 总等待)
+改后 = ?
+
+## ② 重试
+改前有没有重试 = ?
+改后 = ?
+失败时有没有 ack(ok=False) = ?
+
+## ③ 云函数确认
+ackArtifact 接受 reason 参数吗 = ?
+limit 200 你那边能一次看到几条 = ?
+地址有效期是不是 1800 秒 = ?
+
+## ④ 大文件实测
+文件大小 = ?
+上传耗时 = ?
+触发重试了吗 = ?
+最终链接 = ?
+md5 一致吗 = ?
+```

+ 133 - 0
docs/03-interface/指令-kangshifu-实测交付11步.md

@@ -0,0 +1,133 @@
+# 别再读代码了,实测一次,告诉我卡在第几步
+
+## 先认错
+
+你说得对:**我的诊断「你走 upload_bytes 旁路直传 → user/ 前缀」与磁盘代码不符。**
+
+我回头查了那批 `user/` 链接的出现时间,**是阶段 0 和阶段 1**,那时你明确说过
+「我走 `uploader.mjs put` 直传」。**30 路压测里一条链接都没有**,全是 `❌无链接`。
+
+所以:
+- 交付链路代码**没走错函数**
+- 真正的问题是 **30 路压测时链路根本没跑起来**
+
+---
+
+# 现在要做的:实测一次完整交付,每步打点
+
+## 用一个全新的小文件,走真实流程
+
+```bash
+mkdir -p /opt/data/work/deliver-probe
+F=/opt/data/work/deliver-probe/probe-$(date +%s).md
+echo "# deliver probe" > "$F"
+echo "文件: $F"
+ls -la "$F"
+md5sum "$F"
+```
+
+然后**在回复正文里单独一行写**:
+
+```
+MEDIA:<上面的完整路径>
+```
+
+## 我要的:每一步的实际返回
+
+在 `fmode-artifact-push.py`(或你实际用的那个脚本)的每一步**加临时日志**,
+或者直接在 python 里手工跑一遍流程,把**每一步的原始响应**打出来:
+
+```python
+# 请按这个结构实测,每步都打印真实返回
+import json, os, hashlib, urllib.request
+
+FN = "lfYlgU7SkK"
+API = "https://server.fmode.cn/api/functions"
+TOKEN = os.environ.get("FMODE_SESSION_TOKEN") or "<你的容器 token>"
+
+def call(params):
+    req = urllib.request.Request(API,
+        data=json.dumps({"id": FN, "token": TOKEN, "params": params}).encode(),
+        headers={"Content-Type": "application/json", "X-Parse-Application-Id": "ncloudmaster"},
+        method="POST")
+    with urllib.request.urlopen(req, timeout=30) as r:
+        return json.loads(r.read())
+
+path = "<上一步那个文件>"
+room = os.environ.get("HERMES_SESSION_CHAT_ID") or ""
+print("① roomId =", repr(room))
+
+# ② report
+r2 = call({"action": "report", "agentId": "agent-node-kangshifu",
+           "title": "deliver probe", "summary": "probe",
+           "artifacts": [path], "roomId": room})
+print("② report =", json.dumps(r2, ensure_ascii=False)[:500])
+did = (r2.get("result") or r2).get("id") or r2.get("id")
+print("   deliverableId =", did)
+
+# ③ enqueueArtifact
+r3 = call({"action": "enqueueArtifact", "deliverableId": did,
+           "agentId": "agent-node-kangshifu", "source": path,
+           "filename": os.path.basename(path), "index": 0})
+print("③ enqueue =", json.dumps(r3, ensure_ascii=False)[:500])
+
+# ④ pendingArtifacts(轮询 3 次,每次间隔 2 秒)
+import time
+job = None
+for i in range(3):
+    time.sleep(2)
+    r4 = call({"action": "pendingArtifacts", "agentId": "agent-node-kangshifu"})
+    print(f"④ pending[{i}] =", json.dumps(r4, ensure_ascii=False)[:500])
+    jobs = (r4.get("result") or r4).get("jobs") or r4.get("jobs") or []
+    if jobs:
+        job = jobs[0]; break
+
+print("   job =", json.dumps(job, ensure_ascii=False)[:500] if job else None)
+print("   uploadUrl   =", (job or {}).get("uploadUrl"))
+print("   publicUrl   =", (job or {}).get("publicUrl"))
+```
+
+🔴 **不要真的 PUT 上传,也不要 ack。** 我只要看 ①~④ 每步返回什么。
+
+---
+
+## 如果 ④ 拿到 job 了
+
+告诉我 `uploadUrl` 和 `publicUrl` 的**完整原值**(脱敏不需要,这是测试数据)。
+
+那说明链路前半段是通的,问题在后半段(PUT / ack / md5 / 登记)。
+
+## 如果 ④ 一直是空
+
+那说明**队列没建立起来**,问题在 ② 或 ③。把 ② ③ 的完整返回贴出来。
+
+---
+
+# 回答格式
+
+```
+## 文件
+path = ?
+md5 = ?
+
+## ① roomId
+值 = ?(是否为空)
+
+## ② report
+完整返回 = ?
+deliverableId = ?
+
+## ③ enqueueArtifact
+完整返回 = ?
+
+## ④ pendingArtifacts
+三次返回 = ?
+有没有拿到 job = ?
+uploadUrl = ?
+publicUrl = ?
+
+## 结论
+卡在第几步 = ?
+```
+
+🔴 **只要真实返回值。不要改任何文件。不要 PUT。不要 ack。**

+ 144 - 0
docs/03-interface/指令-kangshifu-照抄lj-test交付链路.md

@@ -0,0 +1,144 @@
+# 交付链路:照抄 lj-test 的 11 步,不要自己理解
+
+## 你错在哪(一句话)
+
+> 你走 `fmode_outbound_media.upload_bytes()` **旁路直传**,链接是
+> `https://s3.fmode.cn/user/<uid>/report/...`。
+> **那条路径不是 `lfYlgU7SkK` 签出来的 `deliverables/<agentId>/...`,App 侧 `isAgentOutputUrl()` 不认,所以成果永远不显示。**
+
+`AlP56LCKFm` 的 `user/` 前缀是**服务端强制**的,客户端剥不掉、改不了。**要拿 `deliverables/` 前缀,必须换函数,不是换参数。**
+
+---
+
+# lj-test 的真实实现(逐字原文)
+
+## 关键代码:URL 必须来自平台的 `publicUrl`
+
+`/opt/data/scripts/fmode_outbound_media.py` L159-163:
+
+```python
+        public_url = job.get("publicUrl") or ""
+        if not public_url:
+            raise OutboundMediaError("pendingArtifacts returned empty publicUrl")
+        push.call({"action": "ackArtifact", "objectId": job["id"], "ok": True,
+                   "publicUrl": public_url})
+```
+
+**`url` 字段 = `job["publicUrl"]`,不是容器拼的。**
+
+## 关键代码:m.file 事件构造
+
+`/opt/data/plugins/plugin-matrix-outbound-s3/__init__.py` L217-234:
+
+```python
+                msg_content: Dict[str, Any] = {
+                    "msgtype": msgtype, "body": caption or filename,
+                    "info": {"mimetype": content_type, "size": len(data)}}
+                if encrypted_file is not None:
+                    msg_content["file"] = {**encrypted_file.serialize(), "url": media_url}
+                else:
+                    msg_content["url"] = media_url
+```
+
+`media_url` 来自 L208-215:
+
+```python
+                    try:
+                        media_url = await _upload_media_to_object_storage(
+                            self, upload_data, filename, content_type)
+                    except Exception as exc:
+                        # 不静默回退 upload_media:随机 mxc/https 混杂比如实失败更难排查
+                        logger.error("Matrix: object-storage upload failed: %s", exc)
+                        return _fail("%s: %s" % (type(exc).__name__, exc))
+```
+
+---
+
+# 完整 11 步(照这个顺序实现)
+
+```
+1. post_tool_call 判定文件是交付成果 → 写入 state.db 的 queued(无网络 IO)
+2. transform_llm_output → _thread_root() 取 threadRoot
+                         → HERMES_SESSION_CHAT_ID 取 roomId
+3. 幂等闸门:action=list 查近 50 条,同名 + 线上 md5 一致 → 复用旧 URL,跳过后续
+4. action=report 建成果记录 → 返回 id 即 deliverableId
+5. action=enqueueArtifact 入队
+   - source = 本地绝对路径
+   - filename = ASCII 化名
+   - index = 附件下标
+6. action=pendingArtifacts 取 uploadUrl + publicUrl + headers
+7. 对 uploadUrl 原样 urllib PUT(百分号编码 path,key 由服务端决定)
+8. action=ackArtifact{ok:true, publicUrl} 让后端登记到数据库
+9. 回读 publicUrl 算 md5,与本地一致才认为成功
+10. 有 threadRoot 才调 THXAOGKiPK / registerFile 登记台账
+    - uploadedBy = "agent"
+    - roomId = os.environ.get("HERMES_SESSION_CHAT_ID") or ""
+    - threadRoot = _thread_root() 三级回落
+11. 回复末尾追加 https;发附件时 m.file.url 取同一个 publicUrl
+```
+
+---
+
+# 🔴 三个必须守的硬约束
+
+### ① 云函数归属(我之前查错了,你说的也有偏差,以实测为准)
+
+| action | 属于哪个函数 |
+|---|---|
+| `report` / `list` / `enqueueArtifact` / `pendingArtifacts` / `ackArtifact` | **`lfYlgU7SkK`**(fmodeagent-deliverables) |
+| `registerFile` / `listByUrl` / `listBySession` | **`THXAOGKiPK`**(fmode-file-hub) |
+
+实测证据:`POST /api/functions {"id":"THXAOGKiPK","params":{"action":"pendingArtifacts"}}`
+→ `{"code":400,"msg":"unknown action: pendingArtifacts"}`
+
+### ② 登记时机是**上传之后**,且 md5 必须一致
+
+> 严格顺序:PUT → ack → 回读 md5 → 才登记;**回读不一致则完全不登记**
+
+### ③ 绝不自己拼 key
+
+lj-test 全仓 `grep -rn "AlP56LCKFm\|uploader.mjs"` → **零命中**。
+key 完全由 `pendingArtifacts` 返回的 `uploadUrl` 决定。
+
+---
+
+# 你要做的
+
+## 1. 先自查你现在走的是哪条路
+
+```bash
+echo "=== 你现在调什么 ==="
+grep -rn "AlP56LCKFm\|uploader.mjs\|upload_bytes\|enqueueArtifact\|pendingArtifacts\|ackArtifact" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | head -30
+
+echo "=== 有没有 fmode-artifact-push.py ==="
+ls -la /opt/data/scripts/fmode-artifact-push.py 2>/dev/null || echo "(无)"
+
+echo "=== 出站媒体插件 ==="
+ls -la /opt/data/plugins/plugin-matrix-outbound-s3/ 2>/dev/null || echo "(无)"
+ls -la /opt/data/plugins/plugin-deliverable-inject/ 2>/dev/null || echo "(无)"
+```
+
+**把这些输出贴给我,我判断你是缺文件还是改错了地方,再给具体改法。**
+
+## 2. 不要自己动手改
+
+🔴 **先贴自查结果,等我给具体改法。** 上一轮你误删 948MB 的教训还在。
+
+---
+
+## 回给我的格式
+
+```
+## 1. 你现在的调用链
+grep 输出 = ?
+fmode-artifact-push.py 存在 = ?
+plugin-matrix-outbound-s3 存在 = ?
+plugin-deliverable-inject 存在 = ?
+
+## 2. 关键差异
+你现在的 url 从哪来 = ?
+是 pendingArtifacts 的 publicUrl,还是自己拼的?
+
+## 3. 你现在缺哪几步
+对照 11 步,列出「已有 / 缺失 / 错了」
+```

+ 195 - 0
docs/03-interface/指令-kangshifu-补步骤4-6.md

@@ -0,0 +1,195 @@
+# 补齐第 ④⑤⑥ 步,然后实测一次完整交付
+
+## 先说清楚:你的 ①②③ 是好的
+
+实测已证明:
+- `report` → `{"code":200,"id":"zPdl8GMobo"}` ✅
+- `enqueueArtifact` → `{"code":200,"id":"XWHqPygZjZ"}` ✅
+- `pendingArtifacts` → 第一次就拿到 job(含 uploadUrl)✅
+
+**问题在后面的 ④⑤⑥。**
+
+---
+
+# lj-test 的原话(照着做)
+
+> **`registerFile` —— 登记归属,不是传文件,是往新成果表写「这文件属于哪个会话」。**
+> **没有它,文件在云上但 App 看不到。**
+
+它给的真实反例:
+
+```
+SKIP-REGISTER no threadRoot sid=None(文件已上云,仅缺归属)
+HUB … ok=False detail=no-threadRoot
+```
+
+---
+
+# ① 先自查你现在有没有 ⑤⑥
+
+```bash
+echo "=== ackArtifact 调用点 ==="
+grep -rn "ackArtifact" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | head
+
+echo "=== registerFile 调用点 ==="
+grep -rn "registerFile\|THXAOGKiPK" /opt/data/scripts/ /opt/data/plugins/ 2>/dev/null | head
+
+echo "=== _thread_root 实现 ==="
+grep -rn "_thread_root\|HERMES_SESSION_CHAT_ID" /opt/data/scripts/ 2>/dev/null | head
+```
+
+**把输出贴给我,我对照 lj-test 的写法给你具体改法。**
+
+---
+
+# ② 实测一次完整交付(这条最重要)
+
+## 2.1 造文件
+
+```bash
+mkdir -p /opt/data/work/deliver-final
+F=/opt/data/work/deliver-final/final-$(date +%s).md
+printf '# 交付验证\n\n时间:%s\n标记:FINAL-CHECK-OK\n' "$(date -Is)" > "$F"
+ls -la "$F"; md5sum "$F"; echo "PATH=$F"
+```
+
+## 2.2 走完整流程,**每一步都打印真实返回**
+
+```python
+import json, os, urllib.request, time, hashlib, urllib.parse
+
+API  = "https://server.fmode.cn/api/functions"
+TOK  = os.environ.get("FMODE_SESSION_TOKEN") or "<容器自己的 token>"
+AGENT = "agent-node-kangshifu"
+PATH_ = "<上一步的 PATH>"
+ROOM = os.environ.get("HERMES_SESSION_CHAT_ID") or ""
+
+def call(fn, params):
+    req = urllib.request.Request(API,
+        data=json.dumps({"id": fn, "token": TOK, "params": params}).encode(),
+        headers={"Content-Type": "application/json",
+                 "X-Parse-Application-Id": "ncloudmaster"}, method="POST")
+    with urllib.request.urlopen(req, timeout=60) as r:
+        return json.loads(r.read())
+
+print("ROOM =", repr(ROOM))
+
+# ---- ① report ----
+r1 = call("lfYlgU7SkK", {"action":"report","agentId":AGENT,
+    "title":"final-check","summary":"交付验证",
+    "artifacts":[PATH_],"roomId":ROOM})
+print("① report =", json.dumps(r1, ensure_ascii=False)[:400])
+DID = r1.get("id") or (r1.get("result") or {}).get("id")
+print("   deliverableId =", DID)
+
+# ---- ② enqueue ----
+r2 = call("lfYlgU7SkK", {"action":"enqueueArtifact","deliverableId":DID,
+    "agentId":AGENT,"source":PATH_,"filename":os.path.basename(PATH_),"index":0})
+print("② enqueue =", json.dumps(r2, ensure_ascii=False)[:400])
+
+# ---- ③ pending(领自己那条)----
+job = None
+for i in range(5):
+    time.sleep(2)
+    r3 = call("lfYlgU7SkK", {"action":"pendingArtifacts","agentId":AGENT})
+    jobs = r3.get("jobs") or (r3.get("result") or {}).get("jobs") or []
+    print(f"③ pending[{i}] jobs={len(jobs)}")
+    # 🔴 关键:只领自己那条,别人的跳过
+    mine = [j for j in jobs if j.get("deliverableId") == DID]
+    if mine:
+        job = mine[0]; break
+
+if not job:
+    print("❌ 没领到自己的 job,停止"); raise SystemExit(1)
+
+print("   job.id       =", job["id"])
+print("   uploadUrl    =", job["uploadUrl"][:120], "...")
+print("   publicUrl    =", job.get("publicUrl"))
+
+# ---- ④ PUT ----
+data = open(PATH_, "rb").read()
+req = urllib.request.Request(job["uploadUrl"], data=data,
+    headers=job.get("headers") or {"Content-Type":"text/markdown"}, method="PUT")
+try:
+    with urllib.request.urlopen(req, timeout=180) as r:
+        print("④ PUT =", r.status)
+        put_ok = 200 <= r.status < 300
+except Exception as e:
+    print("④ PUT 失败:", e); put_ok = False
+
+if not put_ok: raise SystemExit(1)
+
+# ---- ⑤ ack ----
+r5 = call("lfYlgU7SkK", {"action":"ackArtifact","objectId":job["id"],
+    "ok":True,"publicUrl":job.get("publicUrl")})
+print("⑤ ack =", json.dumps(r5, ensure_ascii=False)[:300])
+
+# ---- 回读 md5 ----
+with urllib.request.urlopen(job["publicUrl"], timeout=60) as r:
+    remote = r.read()
+print("回读 md5 本地 =", hashlib.md5(data).hexdigest())
+print("回读 md5 远端 =", hashlib.md5(remote).hexdigest())
+print("一致 =", hashlib.md5(data).hexdigest() == hashlib.md5(remote).hexdigest())
+
+# ---- ⑥ registerFile 登记归属 ----
+r6 = call("THXAOGKiPK", {"action":"registerFile",
+    "threadRoot": ROOM,          # ← 没有这个 App 查不到
+    "roomId": ROOM,
+    "agentId": AGENT,
+    "name": os.path.basename(PATH_),
+    "url": job.get("publicUrl"),
+    "mimeType": "text/markdown",
+    "size": len(data),
+    "uploadedBy": "agent"})
+print("⑥ registerFile =", json.dumps(r6, ensure_ascii=False)[:400])
+
+# ---- 验证:能不能按 threadRoot 查到 ----
+time.sleep(2)
+r7 = call("THXAOGKiPK", {"action":"listBySession","roomId":ROOM,"limit":20})
+print("⑦ listBySession =", json.dumps(r7, ensure_ascii=False)[:600])
+```
+
+🔴 **把 ①~⑦ 每一步的完整输出贴给我,不要截断关键字段。**
+
+---
+
+# ③ 如果 ⑥ 报 "no threadRoot"
+
+说明 `HERMES_SESSION_CHAT_ID` 是空的。那要先按 lj-test 的**三级回落**取值:
+
+```
+① state.db 里 sessions.thread_id
+② sessions.chat_id,为空则从 session_key 拆 `!` 开头的段
+③ 都没有 → 跳过登记(但要在日志里显式打 SKIP-REGISTER,不许静默)
+```
+
+贴出 `echo $HERMES_SESSION_CHAT_ID` 的值。
+
+---
+
+# 回给我的格式
+
+```
+## 自查
+ackArtifact 调用点 = ?
+registerFile 调用点 = ?
+_thread_root 实现 = ?
+
+## 实测
+① report = ?
+   deliverableId = ?
+② enqueue = ?
+③ pending = ? (几轮、领到自己的没)
+   uploadUrl = ?
+   publicUrl = ?
+④ PUT 状态 = ?
+⑤ ack = ?
+回读 md5 一致 = ?
+⑥ registerFile = ?
+⑦ listBySession 结果 = ?
+
+## HERMES_SESSION_CHAT_ID
+值 = ?(是否为空)
+```
+
+🔴 老规矩:**只要真实输出,不许「已完成」三个字打发。**

+ 102 - 0
docs/03-interface/指令-ljtest-同步云函数改动.md

@@ -0,0 +1,102 @@
+# 通知:云函数 lfYlgU7SkK 已改了三处,你那边确认一下兼容性
+
+## 改了什么(2026-11-14 由 App 侧改,已部署验证)
+
+| 改动 | 原值 | 新值 | 影响 |
+|---|---|---|---|
+| `pendingArtifacts` 权限 | 仅 agent owner | **owner 或同公司成员** | 多人共用容器时非 owner 也能领任务 |
+| `pendingArtifacts` 返回条数 | 20 | **200** | 高并发时不会再抢不到 |
+| 上传地址有效期 | 300 / 600 秒 | **1800 秒(30 分钟)** | 大文件有时间传完 |
+
+实测验证:
+```
+ksftest01(非 owner)调 pendingArtifacts
+改前:{"code":403,"msg":"仅该 agent 的 owner 可领取任务"}
+改后:{"code":200,"jobs":22},地址剩余约 29 分钟
+```
+
+---
+
+## 要你确认的(只读,不用改)
+
+### 1. 你的 `--min-age` / 重试逻辑会不会受影响
+
+```bash
+grep -rn "300\|600\|EXPIRES\|expiresAt\|ttl" /opt/data/scripts/fmode-artifact-push.py 2>/dev/null | head -20
+```
+
+**要回答**:你的代码里有没有写死「300 秒」或「600 秒」来推算过期?
+如果有,需要改成读 `job["expiresAt"]` 而不是自己算。
+
+### 2. 你的轮询等待够不够
+
+```bash
+grep -rn "pendingArtifacts" -A 15 /opt/data/scripts/fmode-artifact-push.py 2>/dev/null | head -30
+```
+
+**要回答**:领不到 job 时你重试几次、间隔多少?总共等多久?
+limit 从 20 → 200 后,领到的概率变高了,但**确认一下你的等待时间是否合理**(建议至少 60 秒)。
+
+### 3. 失败后有没有 ack(ok=False)
+
+```bash
+grep -rn "ackArtifact" -B2 -A6 /opt/data/scripts/fmode-artifact-push.py 2>/dev/null | head -30
+```
+
+**要回答**:PUT 失败时,你会不会 `ackArtifact {ok:false}` 释放旧 job?
+**不释放的话,旧 job 一直挂在 pending,下次会被「复用」成坏任务。**
+
+### 4. 队列里有 22 条积压,会不会影响你
+
+```bash
+echo "=== 现在队列积压 ==="
+python3 - <<'PY'
+import json, os, urllib.request
+API="https://server.fmode.cn/api/functions"
+TOK=os.environ.get("FMODE_SESSION_TOKEN") or ""
+def call(p):
+    req=urllib.request.Request(API,data=json.dumps({"id":"lfYlgU7SkK","token":TOK,"params":p}).encode(),
+        headers={"Content-Type":"application/json","X-Parse-Application-Id":"ncloudmaster"},method="POST")
+    return json.loads(urllib.request.urlopen(req,timeout=30).read())
+r=call({"action":"pendingArtifacts","agentId":"agent-node-lj-test"})
+jobs=r.get("jobs") or []
+print("你名下 pending job 数 =", len(jobs))
+import time
+now=int(time.time())
+for j in jobs[:10]:
+    print("  ", j["id"], j.get("filename"), "剩余秒=", j.get("expiresAt",0)-now)
+PY
+```
+
+**要回答**:你名下有没有积压?有多少条是过期的?
+
+---
+
+## 如果你发现自己的重试逻辑需要跟着改
+
+**贴出你的现状,我给具体改法。不要自己动。**
+
+---
+
+# 回给我的格式
+
+```
+## 1. 有没有写死 300/600
+结果 = ?
+需要改吗 = ?
+
+## 2. 轮询参数
+次数 / 间隔 / 总等待 = ?
+够不够 = ?
+
+## 3. 失败后 ack
+有没有 ack(ok=False) = ?
+代码位置 = ?
+
+## 4. 队列积压
+你名下 pending 数 = ?
+过期数 = ?
+
+## 5. 结论
+兼容 / 需要改(贴出要改的地方)
+```

+ 115 - 0
docs/03-interface/问-ljtest-完整流程通俗版.md

@@ -0,0 +1,115 @@
+# 请用**通俗语言**完整讲一遍:从收到用户消息,到文件出现在 App 里
+
+## 背景
+
+我需要把你这台容器的完整工作逻辑,整理成一份给**非技术同事**看的说明。
+所以请**避免术语堆砌**,每个技术名词第一次出现时用大白话解释一句。
+
+这不是让你改任何东西,**只要能讲清楚就行**。
+
+---
+
+# 第一部分:一条消息的完整旅程
+
+假设用户在 App 里发了一句:「帮我写个周报,存成 md 文件」。
+
+请按**时间顺序**,一步一步讲清楚,每一步说:
+
+1. **发生了什么**(人话)
+2. **谁在做**(用户 / App / 平台服务器 / 容器)
+3. **用了什么**(如果是调接口,说明是哪个、干什么用的)
+4. **证据在哪**(日志文件路径 / 代码位置,我好核对)
+
+请至少覆盖到能回答这些问题:
+
+- 消息怎么到你手上的?
+- 你怎么知道「这一轮结束了」?
+- App 怎么知道「你回完了」?
+- 中间那些「💻 terminal ...」的过程行,App 怎么区分它和正文?
+
+---
+
+# 第二部分:文件是怎么上云的
+
+这是我最想搞清楚的部分。请**一步一步**讲:
+
+- 你写好文件之后,**是你自己传到云盘,还是别人帮你传?**
+- 如果是你传的:**上传地址从哪来?** 谁给的?
+- 云盘上那个文件的**路径长什么样**(举一个真实例子)?
+- 这个路径是**谁决定的**?你的代码,还是平台的服务器?
+- 传完之后,要做什么才能让**App 看得到**?
+
+---
+
+# 第三部分:App 的「成果」栏里那些文件,数据从哪来
+
+用户在 App 右栏点「成果」,看到一堆文件。这些数据:
+
+- 存在**哪个地方**?(数据库表?还是从消息里现算?)
+- 是**谁写进去的**?(你的容器?App 自己?平台服务器?)
+- **一条记录里有什么信息**?
+
+---
+
+# 第四部分:用大白话解释这几个词
+
+我遇到一些词不清楚,请各用一两句话说清楚它们**在你的流程里扮演什么角色**:
+
+- `report`(上报)
+- `deliverableId`
+- `enqueueArtifact`
+- `pendingArtifacts`
+- `ackArtifact`
+- `registerFile`
+- `publicUrl`
+- `uploadUrl`
+
+**如果某个词在你的流程里根本不出现,直接说「我没用到」。**
+
+---
+
+# 第五部分:一次成功的例子
+
+请举一个**真实发生过**的例子(从你的日志里找):
+
+- 时间
+- 用户说了什么
+- 你最终给出的链接是什么(完整 URL)
+- App 里能看到这个文件吗(如果知道的话)
+
+---
+
+# 回答要求
+
+- **用中文,用大白话**
+- 技术名词第一次出现时解释一句
+- 每一步都附证据(日志路径 / 代码位置 / 真实返回值)
+- **不要给建议**,不要顺带提「建议改成什么」
+- 如果某一部分你不确定,**直接说「这块我不确定」**,不要编
+
+---
+
+## 格式
+
+```
+## 一、消息的旅程
+1. 【谁】做了什么 —— 证据:...
+2. ...
+
+## 二、文件上云
+...
+
+## 三、成果栏的数据来源
+...
+
+## 四、术语解释
+- report:...
+- deliverableId:...
+...
+
+## 五、一个真实例子
+...
+
+## 六、我不确定的地方
+...
+```

Деякі файли не було показано, через те що забагато файлів було змінено