소스 검색

docs: 数字员工状态上报字段规范(机器人侧改造依据)

逐条对照原版 status-report.py(v11) 与云函数 xlosi3m46q v0.1.2 白名单核实,
不凭印象。九章:上报协议 / 字段白名单 / 逐字段契约 / 环境变量 /
自检规范 / 排查规范 / App 兼容现状 / 验收清单。

原版口径关键发现:
- cpuPercent 非 load 换算:原版读 top 的 us+sy 真实占用率;
  cpuLoad1/核数*100 只是降级近似(与实测值有偏差)
- 上报是「逐字段」非整包:post_field 一次一字段
- 变化检测 + 心跳:只报变化字段,无变化超 30s 才单独报 heartbeatAt
- heartbeatAt 必须类型包装 {"__type":"Date","iso":...}
- currentTask 残留坑:任务结束必须显式报空串清除
- channels 点亮需真探测(假亮比不亮更糟)
- token 过期时 channels 正常、balance 降级(不应整体退出)

自检规范:只许回读平台值,不许自算自证
排查规范:查运行态(ps/proc),不查路径(find)

验证:verify:docs 47 文件

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 주 전
부모
커밋
fa5b4dc28e
2개의 변경된 파일과 403개의 추가작업 그리고 0개의 파일을 삭제
  1. 18 0
      docs/00-tracking/操作追踪.md
  2. 385 0
      docs/03-interface/reporter字段规范-20261020.md

+ 18 - 0
docs/00-tracking/操作追踪.md

@@ -794,3 +794,21 @@
 | **🆕 输出文档(更新)** | 《容器初始化流程优化建议-20261020.md》:<br>· 问题 1 **更正**为「部署在非标准路径 + 用复刻脚本」(附反证与 4 条自证线索)<br>· **新增问题 6**「验证过算法但没落库」(自证幻觉)<br>· **新增第六章**「回复容器本轮回答的提示词」(可直接发,含纠正误判 + 运行态排查法 + 回读验收口径)|
 | 验证证据 | `ng test` **499**(+15 归一用例)/ `build` 通过 / `verify:ui` **11/11** / `verify:docs` **46 文件** |
 | 新增文件 | `src/app/core/api/normalize.spec.ts`(15 用例:channels 数组/对象/空数组、cpuPercent 派生/优先/夹取/核数未知、heartbeatAt 三级回落、综合场景)|
+
+### 2026-10-20 — 输出《数字员工状态上报 · 字段规范》(机器人侧改造依据)
+
+| 项 | 内容 |
+|---|---|
+| **用户诉求** | 「给出对应的机器人的需要修改的规范逻辑,机器人需要如何修改」——上一轮给的是**临时排查提示词**,本轮要的是**标准规范** |
+| **编写方式** | 🔴 **逐条对照原版源码核实,不凭印象**:<br>· `E:\work\fmode-app\docs\03-interface\reporter-v3\status-report.py`(v11,49410 字节)<br>· 云函数 `xlosi3m46q` v0.1.2 的 `FIELDS` 白名单 + `USAGE_FIELDS` |
+| **🆕 关键发现(原版口径,此前未核实)** | 🔴 **`cpuPercent` 不是从 load 换算的**——原版读 `top -bn1` 的 us+sy 第一列真实占用率(`collect_slice`)。<br>「`cpuLoad1 / 核数 * 100`」是**近似降级**,与 `top` 实测值不同。我上一轮的 App 兼容层用的是这个近似,故文档中已标注为「止痛药不是解药」|
+| **🆕 关键发现 ②:上报是「逐字段」不是「整包」** | `post_field()` 一次请求只报一个字段;`params={agentId, field, value}`。不存在整包提交接口 |
+| **🆕 关键发现 ③:变化检测 + 心跳** | `report_slice()`:与上次快照比对,**只报变化字段**;无变化且超 `HEARTBEAT_SECS`(30s) 才单独报 `heartbeatAt`。全量上报会打垮云函数 |
+| **🆕 关键发现 ④:`heartbeatAt` 必须类型包装** | `{"__type":"Date","iso":"..."}`(`post_field` 内特判),不是裸字符串 |
+| **🆕 关键发现 ⑤:`currentTask` 残留坑(原版 v10 实证)** | 任务结束无人清 env → 值未变 → 变化检测判「无需上报」→ `FmodeAgent.currentTask` **永久残留** → 看板假亮。**任务结束必须显式报空串** |
+| **🆕 关键发现 ⑥:`channels` 点亮要真探测** | `matrix = 官方 node platforms.matrix.state=="connected" AND mxid 存在`;不能只看 env 声明(假亮比不亮更糟:用户点进去连不上)|
+| **🆕 关键发现 ⑦:token 过期时 channels 正常、balance 降级** | `channels` 走本地探测不需 token;`balance` 必须有 token。故 token 过期时不应整个上报退出 |
+| **文档结构(9 章)** | 总纲 / 上报协议 / 字段白名单 / **逐字段契约**(host·channels·balance·usage·heartbeatAt·其余)/ 环境变量 / **自检规范(只许回读)** / **排查规范(查运行态不查路径)** / App 兼容现状 / 验收清单 |
+| **修正** | 初稿引用 `scripts/check-reporter-fields.mjs` —— 该脚本**本项目不存在**(仅云函数注释里的约定),已改为准确表述「成对维护声明」 |
+| 验证证据 | `verify:docs` **47 文件**(+1)/ 表格结构全部正常 |
+| 待做 | 用户确认规范后,将 §八 验收清单并入容器部署提示词;容器按本规范改造脚本 |

+ 385 - 0
docs/03-interface/reporter字段规范-20261020.md

@@ -0,0 +1,385 @@
+# 数字员工状态上报 · 字段规范(机器人侧必读)
+
+| 字段 | 值 |
+|---|---|
+| 文档编号 | 规范-20261020-reporter-fields |
+| 版本 | v1.0 |
+| 创建日期 | 2026-10-20 |
+| 适用范围 | **所有容器(数字员工)内的状态上报脚本** |
+| 权威源 | 原版 `status-report.py`(v11)+ 云函数 `xlosi3m46q` v0.1.2 白名单 |
+| 成对维护声明 | 云函数源码注释:「白名单与容器侧 `USAGE_FIELDS` 成对维护」——改一边必须改另一边 |
+
+---
+
+## 零、一句话总纲
+
+> **机器人不"打包上报",而是"逐字段上报"**:每个字段一次请求,
+> 字段名与值的形状**必须严格等于本文档的契约**;云函数按白名单校验,白名单外直接 `400`。
+
+三类最常犯的错(实测于 `agent-node-lj-test`):
+
+| 错法 | 后果 |
+|---|---|
+| `channels` 发**数组** `["wecom"]` | App 判 `matrix` 为 `undefined` → 显示「未接入」 |
+| `host` 发**裸负载** `cpuLoad1`,无 `cpuPercent` | CPU 指标不显示 |
+| 上报走 `AlP56LCKFm`(OBS 上传) | 数据进存储桶、**没进 FmodeAgent 表** |
+
+---
+
+## 一、上报协议
+
+### 1.1 传输(唯一正确姿势)
+
+```
+POST https://server.fmode.cn/api/functions
+Content-Type: application/json
+
+{
+  "id": "xlosi3m46q",              ← 状态上报云函数(写死)
+  "token": "<FMODE_SESSION_TOKEN>", ← 主人的会话令牌,用于鉴权归属
+  "params": {
+    "agentId": "<你的 agentId>",
+    "field":   "<字段名>",           ← 白名单内的字段名
+    "value":   <字段值>              ← 形状见 §三
+  }
+}
+```
+
+**关键**:
+- 🔴 `id` 必须是 `xlosi3m46q`。用 `AlP56LCKFm` 是**上传 OBS 日志**,完全不同的事。
+- 🔴 **一次请求只报一个字段**。不存在「整包提交」的接口。
+- `params.companyId` 可选(cron 注入账套场景);无公司容器不用传。
+
+### 1.2 值序列化规则
+
+| 值类型 | 序列化 |
+|---|---|
+| 对象 / 数组型字段(`host` / `channels` / `balance`)| **紧凑 JSON 字符串**(`json.dumps(v, ensure_ascii=False, separators=(",",":"), sort_keys=True)`)|
+| `heartbeatAt` | ISO 时间戳,**必须带类型包装**:`{"__type":"Date","iso":"2026-10-20T11:05:34+08:00"}` |
+| 其余标量 | 原样(字符串 / 数字)|
+
+⚠️ `host`/`channels`/`balance` 上送的是**字符串**(服务端该列是 String 类型),
+不是 JSON 对象 —— 但字符串里的内容必须是 §三 的结构。
+
+### 1.3 上报节奏:**变化检测 + 心跳**
+
+原版逻辑(务必照抄,别改成"每次全量上报"):
+
+```
+每次采集:
+  ① 与上次快照逐字段比对
+  ② 只上报「值发生变化」的字段
+  ③ 若本次无任何变化,且距上次上报 ≥ HEARTBEAT_SECS(默认 30s)
+     → 只上报一次 heartbeatAt(证明"我还活着")
+  ④ 失败字段写入本地队列,下次先 flush 重试
+```
+
+**为什么不能全量上报**:10 容器 × 每分钟 × 20 字段的冗余写会打垮云函数(配额与延迟)。
+
+⚠️ **变化检测的经典坑**(原版踩过,见 v10 注释):
+`currentTask` 任务结束后**没人清除 env** → 值未变 → 变化检测判定"无需上报"
+→ `FmodeAgent.currentTask` **永久残留** → 看板假亮「进行中任务」。
+→ **任务结束时必须显式上报 `currentTask=""`**。
+
+---
+
+## 二、字段白名单(云函数 `FIELDS`)
+
+白名单外一律 `400 字段 X 不允许通过该接口更新`:
+
+```
+status  currentTask  currentAction  progress  name  containerRef  host  heartbeatAt
+botName  botAvatar  gender  ownerName  ownerMobile  balance  channels  extra  sessionStatus
+usage  usageSessions
+```
+
+> `usage` / `usageSessions` 为 v0.1.0 新增(只增不删,平台规则 17)。
+> 容器侧 `USAGE_FIELDS` 与云函数 `USAGE_FIELDS` **成对维护**,改一边必须改另一边。
+
+---
+
+## 三、逐字段契约(🔴 本文档核心)
+
+### 3.1 `host` —— 主机指标
+
+**必须是 JSON 对象字符串**,键名固定:
+
+```json
+{"cpuPercent": 16.4, "memPercent": 51.0, "diskPercent": 33.3}
+```
+
+| 键 | 类型 | 取值口径 | 允许缺省 |
+|---|---|---|---|
+| `cpuPercent` | number | **CPU 使用率百分比** `0~100` | ❌ 必报 |
+| `memPercent` | number | 内存使用率 `0~100` | ❌ 必报 |
+| `diskPercent` | number | 磁盘使用率 `0~100`(`df -P <工作目录>`)| ❌ 必报 |
+
+**🔴 `cpuPercent` 怎么算 —— 实测口径(原版 v11 源码 `collect_slice`)**:
+
+```python
+# 原版:读 top 的 us+sy 真实占用率,不加换算
+out = sh("top -bn1 | grep -iE '^%?Cpu' | head -1")
+parts = out.replace(",", " ").split()
+nums = [p for p in parts if p.replace(".", "").isdigit()]
+cpu = float(nums[0])          # ← 第一个数字就是 CPU%
+```
+
+内存:`/proc/meminfo` 的 `(MemTotal - MemAvailable) / MemTotal * 100`
+磁盘:`df -P <WORK> | tail -1 | awk '{print $5}'` 去掉 `%`
+
+**❌ 禁止的错法**:
+- 发 `cpuLoad1` / `cpuLoad5`(负载均值,**不是**占用率)
+- 用 `cpuLoad1 / 核数 * 100` 自己换算 —— 这是**近似**,与 `top` 实测值不同;
+  只在拿不到 `top` 时作为**降级**,且必须**显式标注来源**
+- 自己心算一遍值就算"验证通过"(**没有落库就等于没做**)
+
+**❌ 禁止**:上报`hostname` / `platform` / `uptimeSeconds` 等**非契约键**当作主键 —— 
+云函数不认。需要附加信息请放 `extra` 字段。
+
+**降级路径**(`top` 不可用时):
+```
+1. /proc/stat 两次采样差分(最准)
+2. os.getloadavg()[0] / os.cpu_count() * 100(近似,需标注)
+3. 完全拿不到 → host 里的 cpuPercent 缺省,**不要编造**
+```
+
+### 3.2 `channels` —— 通道接入
+
+**必须是 JSON 对象字符串**(🔴 **不是数组**):
+
+```json
+{
+  "homeserver": "https://matrix.fmode.cn",
+  "matrix": true,
+  "wecom": true,
+  "wecomBotId": "aibF7SNq4qV2dNwHypwp-eGUNPelYqiJ3kV",
+  "matrixConfig": {
+    "homeserver": "https://matrix.fmode.cn",
+    "userId": "@agent-node-lj-test:fmode.cn",
+    "serverName": "fmode.cn",
+    "channelState": "connected",
+    "listenerOk": true,
+    "channelDetail": {"matrixState": "connected", "gatewayState": "running", "wecomState": "connected"}
+  },
+  "wecomConfig": {"botId": "aibF7...", "connected": true}
+}
+```
+
+| 键 | 类型 | 语义 |
+|---|---|---|
+| `matrix` | boolean | **Matrix 通道是否点亮** ← App 靠它判「已接入」 |
+| `homeserver` | string | Matrix 服务器地址 |
+| `matrixConfig.userId` | string | 容器自己的 MXID(`@xxx:fmode.cn`)|
+| `matrixConfig.channelState` | string | `"connected"` = 通道在跑 |
+| `matrixConfig.listenerOk` | boolean | 旧字段名保留,语义 = 官方 channel connected |
+| `wecom` | boolean | 企微通道是否点亮(**需同时有 `wecomBotId`**)|
+| `wecomBotId` | string | 企微机器人 BotID |
+
+**🔴 点亮规则**(不能只看 env,要**真探测**):
+
+```
+matrix = (官方 node 的 platforms.matrix.state == "connected") AND (mxid 存在)
+wecom  = (WECOM_ENABLED == "true") AND (WECOM_BOT_ID 非空)
+```
+
+**❌ 禁止**:`["wecom"]` 这种数组;未探测就凭 env 声明「已接入」。
+矩阵没真的连上却报 `matrix:true` → 用户点进会话**连不上**,比报 `false` 更糟。
+
+### 3.3 `balance` —— 余额
+
+```json
+{"remainCny": 17842.31, "usedCny": 1203.5, "totalCalls": 8842, "source": "session"}
+```
+
+| `source` | 含义 |
+|---|---|
+| `"session"` | ✅ 正常:用 `FMODE_SESSION_TOKEN` 调余额接口成功 |
+| `"apikey"` | 旧 API_KEY 通道兜底 |
+| `"none"` | ⚠️ 拿不到(token 失效 / 主人无记账)→ **显式声明拿不到,不要省略字段** |
+
+**🔴 实测坑**:容器 `sessionToken` 过期(一小时前签发、次月失效)→ 接口 `401
+{"code":401,"mess":"登录已失效,请重新登录"}` → 只能降级 `source:"none"`。
+**这需要重登换新 token,不是改脚本能解决的**。
+
+> ⚠️ 注意与 §3.2 的 token 区分:`channels` 的判定**不需要** token(本地探测);
+> `balance` **必须**有有效 token。所以 token 过期时 **channels 正常、balance 降级**,
+> 这是正常的,不要因为 balance 失败就整个上报退出。
+
+### 3.4 `usage` / `usageSessions` —— Token 用量
+
+从 Hermes 的 `state.db` **只读**聚合(`mode=ro`,避免抢写锁):
+
+```python
+sqlite3.connect(f"file:{HERMES_STATE_DB}?mode=ro", uri=True, timeout=5)
+SELECT id, title, model, message_count, tool_call_count,
+       input_tokens, output_tokens, cache_read_tokens, reasoning_tokens,
+       estimated_cost_usd, cost_status, started_at
+FROM sessions ORDER BY started_at DESC LIMIT <N>
+```
+
+| 约束 | 说明 |
+|---|---|
+| 打不开 / 无 sqlite3 | **跳过该字段,不阻断其它字段上报** |
+| 时间字段 | 原样上报 epoch 数字,服务端统一转 ISO |
+| 无变化 | 不上报(避免冗余)|
+
+**🔴 实测**:容器有 `sessionmodelusage` 表(2118 万 token,42 行),
+数据**取得到**,只是**没接进上报链路** —— 这是"缺代码"不是"缺数据"。
+
+### 3.5 `heartbeatAt` —— 心跳
+
+每次上报必须刷新。**必须带类型包装**:
+
+```json
+"value": {"__type": "Date", "iso": "2026-10-20T11:05:34+08:00"}
+```
+
+**节奏**:有字段变化时随变化上报;**无变化时按 `HEARTBEAT_SECS`(默认 30s)单独上报一次**。
+
+⚠️ 只上报 `host.probedAt` 而**不报 `heartbeatAt`** 是错的 —— App 判「心跳超时」会
+把在线容器显示成离线(App 侧已加回落兼容,但**不应依赖兼容**)。
+
+### 3.6 其余字段
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `status` | string | `running` / `idle` / `error` / `offline` / `stopped` |
+| `currentTask` | string | 当前任务;**任务结束必须报空串清除**(见 §1.3 坑)|
+| `currentAction` | string | 当前动作 |
+| `progress` | number | `0~100` |
+| `name` | string | 容器名(`AGENT_NAME`,缺省 hostname)|
+| `containerRef` | string | 容器标识(`HOSTNAME`)|
+| `botName` / `botAvatar` / `gender` | string | 机器人身份 |
+| `ownerName` / `ownerMobile` | string | 主人身份 |
+| `sessionStatus` | string | Agent 自身 token 健康灯:`ok` / `expired` / `unknown` |
+| `extra` | string | 白名单外原始数据的 JSON 字符串 |
+
+---
+
+## 四、环境变量契约
+
+| env | 必填 | 说明 |
+|---|---|---|
+| `AGENT_ID` | ✅ | 容器 agentId。**缺省会退化成 hostname hash → 云函数判 `agent not found` → 数据不上报** |
+| `FMODE_SESSION_TOKEN` | ✅ | 上报鉴权 + 归属判定 + 余额采集 |
+| `AGENT_FUNCTION_ID` | ✅ | 填 `xlosi3m46q`(**填错就走到 OBS 上传**)|
+| `WECOM_BOT_ID` | 条件 | 企微 BotID,有企微通道时必填 |
+| `WECOM_ENABLED` | 条件 | `"true"` 时企微通道才可能点亮 |
+| `MATRIX_*` | 条件 | Matrix 身份信息(`MATRIX_USER_ID` 等)|
+| `AGENT_STATUS_HEARTBEAT` | 可选 | 心跳间隔秒,默认 `30` |
+| `AGENT_STATUS_ENABLED` | 可选 | `false` 则整体停用 |
+| `AGENT_STATUS_BACKEND` | 可选 | 默认 `https://server.fmode.cn/api/fmodeagent/status`(旧 REST,服务端转发到云函数)|
+
+---
+
+## 五、🔴 自检规范:**只许回读,不许自算**
+
+这是本轮实测暴露的**最严重方法论错误**:
+
+> 容器自述「cpuPercent 算法已验证 = 16.4」,
+> 但平台库里 `host` **连 `cpuPercent` 这个键都没有**。
+
+**原因**:它在调试里手算了一遍,那个值**从未进入上报体**。
+「我算过」≠「已落库」。
+
+### 正确的验收流程
+
+```bash
+# ① 上报一次
+python3 <真实路径>/status-report.py
+
+# ② 回读平台值
+curl -s 'https://server.fmode.cn/parse/classes/FmodeAgent?where={"agentId":"<你的agentId>"}' \
+  -H 'X-Parse-Application-Id: ncloudmaster' | jq '.results[0] | {host, channels, heartbeatAt, usage, balance, lastField}'
+
+# ③ 逐条断言(缺一不可)
+```
+
+| # | 断言 | 通过标准 |
+|---|---|---|
+| 1 | `host` 含 `cpuPercent` | 是数字且 `0~100` |
+| 2 | `host` 含 `memPercent` / `diskPercent` | 同上 |
+| 3 | `channels` 是**对象** | 能被 JSON 解析为 `{...}`,不是 `[...]` |
+| 4 | `channels.matrix` | 与真实通道状态一致 |
+| 5 | `channels.wecomBotId` | 等于 `WECOM_BOT_ID` |
+| 6 | `heartbeatAt` | 最近 60 秒内 |
+| 7 | `balance.source` | `"session"`(若是 `"none"` 需说明原因)|
+| 8 | `usage` | 有会话记录 |
+| 9 | `lastField` | 持续变化(证明在写)|
+
+**任一条不满足 → 报告「字段未落库:<字段名>」,
+禁止报告"已完成/已验证"。**
+
+---
+
+## 六、排查规范:**查运行态,不查路径**
+
+**本轮教训**:容器按预设路径 `find status-report.py` 找不到 → 得出「没部署」结论。
+但平台库显示它在**持续上报**(44 秒前更新)。
+
+> **「找不到文件」≠「进程没在跑」。**
+
+正确的运行态排查:
+
+```bash
+# ① 找进程(不是找文件)
+ps aux | grep -iE "status|report" | grep -v grep
+
+# ② 看进程实际跑的是哪个脚本
+ls -l /proc/<pid>/cmdline && cat /proc/<pid>/cmdline | tr '\0' ' '
+ls -l /proc/<pid>/cwd          # 实际工作目录
+
+# ③ 定位到真实路径后再改
+grep -n "cpuPercent\|channels" <真实路径>
+
+# ④ 反查:平台侧读回值里带容器自证字段(hostname / uptime)
+#    host.hostname == 本机 hostname → 确认"就是我在上报"
+```
+
+---
+
+## 七、App 侧兼容现状(供参考,**不应依赖**)
+
+App 已加兼容层(`core/api/parse-api.ts` `normalize*` 函数),能兜住:
+
+| 兼容项 | 处理 |
+|---|---|
+| `channels` 数组 | `["matrix","wecom"]` → 对象 |
+| `cpuLoad1` | 有 `cpuCores` 时派生 `cpuPercent`;**核数未知则不派生** |
+| `heartbeatAt` 缺失 | 回落 `host.probedAt` → `updatedAtClient.iso` |
+
+🔴 **但这是"止痛药"不是"解药"**:
+- 兼容层无法还原**没上报的数据**(如 `usage` 压根没发,App 也变不出来)
+- 数组形态丢失了 `matrixConfig` 的细节(`channelState` / `listenerOk`)
+- 派生 `cpuPercent` 与 `top` 实测值**有偏差**
+
+→ **容器侧改成标准格式才是根治**。
+
+---
+
+## 八、验收清单(贴进部署提示词)
+
+```markdown
+□ 进程在跑:ps aux | grep status-report 有输出
+□ 手动跑一次 → 每个字段返回 {"code":200,"field":"<字段名>",...}
+□ 回读 host.cpuPercent 是 0~100 的数字(不是 cpuLoad1)
+□ 回读 channels 能被解析为对象(不是数组)
+□ 回读 channels.matrix 与真实通道一致
+□ 回读 heartbeatAt 在 60 秒内
+□ 回读 balance.source 为 "session" 或已说明为何 "none"
+□ 回读 usage 有会话记录
+□ 上报调用的是 xlosi3m46q,不是 AlP56LCKFm
+□ 任务结束会清 currentTask(不残留)
+□ token / 密钥 未明文落盘、未入日志
+```
+
+---
+
+## 九、关联文档
+
+- `docs/03-interface/容器初始化流程优化建议-20261020.md`(初始化流程优化 + 回复容器提示词)
+- `docs/03-interface/reporter部署方案-20261020.md`(部署步骤)
+- `docs/02-design/architecture/账号与账套体系设计.md`(归属模型)
+- 云函数 `xlosi3m46q`(状态上报,`Function` 表 v0.1.2)
+- 原版脚本:`E:\work\fmode-app\docs\03-interface\reporter-v3\status-report.py`(v11,49410 字节)
+- 原版部署说明:同目录 `部署说明书.md` / `给容器Hermes的部署提示词.md`