Author SHA1 Message Date
AskaEth 6c30ca540a fix(memory): 冷启动放宽改为一次性 + 试算轮区分「没候选」与「向量不可用」
review round1 的 p1/p2 收口(commit 5967272 之后)。

p1:旧实现 `warmed` 只在 2xx 之后才翻 ⇒ 注释里写的「只在第一次成功响应之前」
实际是「服务坏着的全部时间」。reviewer 实测「接受连接但永不回包」时**每次**调用
都等满 30s(fail-fast 变 fail-slow):审查 20s 预算被一次吃光、索引写入拖成
8×30s、开机探针阻塞 30s。

- `warmupLeft` 改为**一次性许可**(CompareAndSwap 取走即失效,成败都算花掉)
  ⇒ 挂死时只有第一个请求慢一次,此后一律 DefaultRequestTimeout。
- `warmupTimeout` 30s → 8s:与常态 5s 同一量级(≤2×),且严格小于审查总预算
  20s(一次冷启动不许吃光整轮);两条界都由 TestDefaultRequestTimeout 守住。
- 放宽被花掉且没成功时留一条 WARN(事后可复核「第一个请求」这个判据)。
- 更正两处自述:api.go 的「不会被 30s 拖住」与 budget.go 的不变式说明(批次形状
  永远按稳态超时推导;冷启动只放宽第一个请求的截止时刻)。
- p2:删掉零调用者的 `WithRequestTimeout` —— 单次超时只允许改常量,批次上限与它
  同源并由编译期/常量断言复核。

p2:试算轮在向量源不可用时 `RecallMode` 与「没有候选可比」同为空串。
- `recallLedger` 增 `unavailable` 计数 + `noteUnavailable()`(strictRecall 失败
  那一笔记一笔);试算汇总行新增 `recall_unavailable_partitions`,**成败都记**。
- 不扩 `recallMode` 取值集合:失败那一路不序列化 result(响应是
  500 tidy_recall_unavailable),新取值只会出现在日志里却要动一次契约,不划算。

测试:三种失败形态 × 两次调用的耗时断言(连接被拒 / 500 / 永不回包),
试算轮的「不可用」与「没候选」两态日志断言;两处变异自证(warmupTimeout 调回
30s ⇒ TestDefaultRequestTimeout 红;去掉 noteUnavailable ⇒ 试算汇总行退化成
recall_mode="" + recall_unavailable_partitions=0)。
2026-10-13 20:51:10 +08:00
AskaEth 59672720bd fix(memory): embedding 批上限与超时同源 —— 不再「客户端先放弃、服务端继续算」
M9(EMBRECALL)。故障机制(M6 + 本次复测):客户端 5s 单次超时先放弃,服务端仍在
算那一批 ⇒ 本机 llama.cpp 被自己顶住 ⇒ 后续每个请求(含写向量索引的单条请求)
排队到自己的超时 ⇒ 召回全线降级。

- embedding/budget.go(新):把「一次请求的耗时上界 ≤ 单次超时的一半」写成不变式,
  批上限(条数 4 / 字符 480)与 5s 超时由**同一个不等式**解出;实测依据与复跑命令
  写进注释(空闲态摊销曲线 1→0.19s / 8→1.1s / 12→1.3s / 64→8.5s,约 50ms 固定 +
  2.7~3.9ms/字符,n≥32 起有数倍放大 ⇒ 余量取一半)。
- embedding/api.go:Embed 按同一对上限**自己切片**(不变式与调用方无关);冷启动
  首次调用单独给 warmupTimeout,拿到第一个成功响应后立刻回常态超时。
- memory/tidy_config.go:tidyEmbedBatch = embedding.DefaultBatchLimit(同源),
  并加编译期断言 —— 调回 64 会直接编译失败(constant -60 overflows uint)。
- memory/tidy.go | tidy_recall.go | tidy_dryrun.go:recall_mode 改报**实际发生**
  的来源(""=本轮没候选可比 / embedding / lexical / mixed),新增
  recall_degraded_partitions;试算轮 RecallMode 同口径(M6 medium finding)。
- 测试:embedding 侧用「实测曲线 + 会睡够时间的假客户端」钉住两态
  (批内 < 超时 ⇒ 不降级;旧配对 64 条 ⇒ 必然超时),并断言 abandoned == 0;
  memory 侧钉住降级/健康/没候选三态与试算轮的如实上报。

不改 preinsert*、不改 .env、不动判定规则与任何阈值。
2026-10-13 20:33:26 +08:00
AskaEth 68ba85389b Merge branch 'feat/memgate-read-side-expiry-gate-for-memory' into dev 2026-10-13 19:59:40 +08:00
AskaEth 0c5fa7b2ae docs(memory): timegate.go 登记「审查留痕读侧不过闸」—— 留痕表结构上无该三列,无可加之闸 2026-10-13 19:54:36 +08:00
AskaEth 9a3b3cff8b feat(memory): 读侧时效闸(四条列表读路径)+ Add 落 expires_at 列
主人裁定「临时记忆到期删除 / 任何调用记忆的地方都要正常调用到」的读侧
第 ① 步(隐藏):design/03 §9.6 / §11.2 的时效闸下沉到共享底层,不写在
任何调用点。

- timegate.go(新):timeGateClause = (expires_at = '' OR expires_at > ?)
  AND (valid_to = '' OR valid_to > ?);timeGateNow() 与列值同源(同一个
  formatTime 的定宽 30 字节 UTC 文本),expiryText() 负责写入侧编码。
- Store.ListByPersona / List / ListScope / ListProject 四条列表读路径
  的 WHERE 在既有 status 闸之后拼上时效闸;Retriever.visibleCandidates
  组合前两条,检索入口因此自动继承同一判据(无第二处判据)。
- Store.Get 与整理/审查的内部召回 SQL 刻意不过闸,理由写在各自注释里
  (listTidyPartitionItemsSQL 原位注释在本切片;listReviewCandidatesSQL
  的原位注释按塔裁定改派 M2,timegate.go 头注登记了这条拆分)。
- Store.Add 的 INSERT 补上 expires_at 列(MemoryItem.ExpiresAt):Add 是
  全仓唯一 INSERT 路径,时效列的真生产者只能在这里,否则闸退化成空壳。
- timegate_test.go(新):过期/失效行在四条路径与 visibleCandidates 上
  都不可见、空值与未来值可见、Get 仍能读过期行、Add 真的落库该列;
  外加时间口径同源钉(文本序==时间序 + 跨格式 'T' vs ' ' 陷阱实证)。

验收:go build ./internal/runtime/... 通过;go vet 通过;go test
./internal/runtime/memory/... 全绿;internal 全包 62 个 ok / 0 FAIL;
GOOS=darwin GOARCH=arm64 交叉构建通过。
2026-10-13 19:54:19 +08:00
AskaEth ae68bb0241 docs(api): 契约 v0.90 —— 渠道连接态三态(可选 pending/ready/reason + connected 语义收紧)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
登记 POST /admin/channels/open|close 响应与 GET /admin/channels 平台行/账号行新增的三个可选键,并写明 connected 由「动作被受理」纠正为「真实连接状态」这一行为变更(老客户端若只看 2xx 会继续骗人)。新增 §2.4(d) 字段级说明;C19/C20 两行标注;计数不变(14 + 25 + 40 = 79)。
2026-10-13 19:41:14 +08:00
AskaEth b4d55f412e fix(channel): 连接动作不再乐观 —— 适配器自报连接态三态 + 面板如实呈现
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
根因:adminChannelOpen 在 ch.Connect 返回 nil 后硬编码 connected=true。而 Connect 返回 nil 只说明「这次调用没有致命失败」—— ws+client 首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪,两种情况 IsConnected() 都是 false。前端又只按「请求成功」写死「已连接」,于是管理面把「后端受理了」说成「渠道连上了」。

改法:三态口径落在适配器自己身上(connected/pending/ready,internal/channel/adapter 的纯函数 ConnectStateOf 判定 + ConnectSignals 结构化入参,避免两个 bool 接反),qq_obv11 实现 ConnectOutcome 并记住脱敏后的失败原因;网关经类型断言探测(与 accountStatusAware/rescanAware 同套路,核心 sdk.Channel 契约刻意不扩展),只做映射不猜 transport/mode。

线上投影:open/close 响应与 GET /admin/channels 的平台行/账号行各新增 pending/ready/reason 三个可选键(零值即整键缺席,同 NeedsRescan/LastError 口径);既有四键逐字不变,未实现该状态面的平台输出逐字节不变。close 报真实 IsConnected(),断开也不乐观。

凭据边界:reason 三层脱敏(形状级 → 已知明文替换 → 剥 access_token= 查询参数),日志同样只用脱敏串。

验收:主会话亲自复跑 go build/vet 全绿、全树 go test 无 FAIL、目标三包 test 全绿、前端 tsc --noEmit 干净、esbuild 逻辑测试 34+46 checks 全过;主会话亲自变异摘掉第 3 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
2026-10-13 19:38:56 +08:00
AskaEth d81635672e fix(channel): 热重载把新适配器写回能力注册表
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
面板保存渠道配置后 Bridge.Reload 只重建了桥接器自己的适配器表,能力注册表里的条目仍指向保存前的旧实例;而管理面的渠道列表、每账号连接状态、open/close 的目标解析全部读注册表 —— 于是卡片永远「未连接」,点「连接」驱动的是旧实例(按旧 URL 拨号)。

生产日志实证:保存后 bridge 侧新实例连上 ws://localhost:3001 且心跳正常,而点连接时被驱动的适配器拨的是旧地址 ws://localhost:10301。

改法:注册表新增 upsert 语义的 ReplaceChannel(覆盖 Value、保留运行期 Policy;Register 遇重名会 ErrDuplicate,热重载必然同名);桥接器经窄端口在适配器上线后同步,账号被移除时删除条目;组合根注入 a.caps 包装。同步失败只记日志、不中断 Reload;未接线时保持旧行为并只 WARN 一次。

验收:go build ./... + go vet 全绿;registry/channelbridge/gateway 三包 test 全绿;主会话亲自变异(摘掉 Policy 沿用 ⇒ TestReplaceChannelSwapsValueAndKeepsPolicy 红)。linux/darwin 交叉编译通过。
2026-10-13 19:21:49 +08:00
44 changed files with 3559 additions and 94 deletions

No files matched your search

+33 -5
View File
@@ -1,6 +1,6 @@
# Cyrene v2 客户端 API 契约 # Cyrene v2 客户端 API 契约
> **契约版本:`v0.89`**(上一版 `v0.88`,其条目见 §6;`v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。 > **契约版本:`v0.90`**(上一版 `v0.89`,其条目见 §6;`v0.88` / `v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。 > **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。 > **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。 > **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
@@ -255,8 +255,8 @@
| C16 | POST | `/api/v1/admin/llm` | 添加/更新 LLM provider(写 `models.json` + 热重载) | | C16 | POST | `/api/v1/admin/llm` | 添加/更新 LLM provider(写 `models.json` + 热重载) |
| C17 | POST | `/api/v1/admin/llm/test` | 连通性测试 | | C17 | POST | `/api/v1/admin/llm/test` | 连通性测试 |
| C18 | POST | `/api/v1/admin/llm/reset-from-env` | 重置为 env 默认(切片 R1,v0.62;body 必须 `{"confirm":true}`,否则 `400` 且一个字节都不动;审计 `llm.resetFromEnv`) | | C18 | POST | `/api/v1/admin/llm/reset-from-env` | 重置为 env 默认(切片 R1,v0.62;body 必须 `{"confirm":true}`,否则 `400` 且一个字节都不动;审计 `llm.resetFromEnv`) |
| C19 | POST | `/api/v1/admin/channels/open` | 打开渠道(v0.66 增 `accountId`,见下注) | | C19 | POST | `/api/v1/admin/channels/open` | 打开渠道(v0.66 增 `accountId`,见下注;**v0.90 起 `connected` 为真实状态**,并按需回可选 `pending`/`ready`/`reason`) |
| C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道(v0.66 增 `accountId`,见下注) | | C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道(v0.66 增 `accountId`,见下注;**v0.90 起 `connected` 取真实 `IsConnected()`**) |
| C21 | POST | `/api/v1/admin/channels/test` | 渠道连通测试 | | C21 | POST | `/api/v1/admin/channels/test` | 渠道连通测试 |
| C22 | GET | `/api/v1/admin/channels/{platform}/login-qr` | 取登录二维码(获取凭据,故 owner-only) | | C22 | GET | `/api/v1/admin/channels/{platform}/login-qr` | 取登录二维码(获取凭据,故 owner-only) |
| C23 | GET | `/api/v1/admin/channels/{platform}/login-status` | 查扫码登录状态 | | C23 | GET | `/api/v1/admin/channels/{platform}/login-status` | 查扫码登录状态 |
@@ -291,7 +291,7 @@
> - `id` 与 `empty=1` 同时给出 ⇒ `400 bad_request`;**两者都缺 ⇒ `400 bad_request`**(绝不把「id 缺失」默认解释成删无 id 条目 —— 那是一次没有字面证据的批量删除)。`?empty=` / `?empty=0` / `?empty=false` 一律是「否」。 > - `id` 与 `empty=1` 同时给出 ⇒ `400 bad_request`;**两者都缺 ⇒ `400 bad_request`**(绝不把「id 缺失」默认解释成删无 id 条目 —— 那是一次没有字面证据的批量删除)。`?empty=` / `?empty=0` / `?empty=false` 一律是「否」。
> - 响应:`{data:{persisted, path, platform, accountId, empty, removed}}`(`empty`/`removed` 是 v0.66 新增);`removed` = 实际删除的条目数。删空后平台键整个消失(与按 id 删除的收尾口径一致)。 > - 响应:`{data:{persisted, path, platform, accountId, empty, removed}}`(`empty`/`removed` 是 v0.66 新增);`removed` = 实际删除的条目数。删空后平台键整个消失(与按 id 删除的收尾口径一致)。
### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态** ### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态**;v0.90 追加 **(d) 渠道连接态三态**
> ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数(12 + 11 + 40 = 63)原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档),下版改文档时应把它们并入 §2.1/§2.2 并更新计数。**本行留在此处仅为与 v0.67 冻结版对照**。 > ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数(12 + 11 + 40 = 63)原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档),下版改文档时应把它们并入 §2.1/§2.2 并更新计数。**本行留在此处仅为与 v0.67 冻结版对照**。
@@ -334,6 +334,15 @@
- 🔴 **同域但未实现的面**:`GET /api/v1/admin/channels` 的 `accounts[]` 元素**尚未**带**每账号** `authState`/`authDetail`(缺 `AdminChannelAccount` 两字段与合并器的接线)—— 见 §7.2。 - 🔴 **同域但未实现的面**:`GET /api/v1/admin/channels` 的 `accounts[]` 元素**尚未**带**每账号** `authState`/`authDetail`(缺 `AdminChannelAccount` 两字段与合并器的接线)—— 见 §7.2。
- ⚠️ **未确证(真机)**:真实 `errcode=-14` 至今**从未在日志里观测到** ⇒ `rejected` 分支只有单测 / httptest 证据,**没有真机证据**(见 §7.4-26)。 - ⚠️ **未确证(真机)**:真实 `errcode=-14` 至今**从未在日志里观测到** ⇒ `rejected` 分支只有单测 / httptest 证据,**没有真机证据**(见 §7.4-26)。
#### (d) 渠道连接态三态:`pending` / `ready` / `reason`(**v0.90 新增,已实现**)
- **出现位置**:`POST /api/v1/admin/channels/open` 与 `.../close` 的响应体、`GET /api/v1/admin/channels` 的**平台行**与 `accounts[]` **账号行**(三处同名同义)。
- **三个键都是可选的,取不到就整键缺席**(同 `needsRescan` / `lastError` 口径):`connected:true` 时,或适配器没有这条状态面时,`pending` / `ready` / `reason` 一律不出现。
- `reason` 只可能在 `pending:true` 时出现,是**已脱敏**的原因短句(`access_token` 等凭据绝不出现在其中)。
- **判据是结构化字段**(适配器自报的闭集状态),不是正文、不是提示词 —— 客户端据此把卡片画成「已连接 / 连接中(原因)/ 就绪待接入 / 未连接」四档。
- **口径归属**:**适配器自报**(`ConnectOutcome`,网关经类型断言探测);核心 `sdk.Channel` 契约**刻意不扩展** ⇒ 没实现该状态面的平台响应**逐字节不变**。
- ⚠️ **为什么 `ready` 不带 `reason`**:它本来就不是坏消息(监听已就绪、就等对面来连),给它编一个原因等于把「等接入」说成「出问题了」。
--- ---
## 3. WebSocket `/ws/chat` ## 3. WebSocket `/ws/chat`
@@ -1487,7 +1496,26 @@ curl -s -X POST -H "X-API-Key: $KEY" \
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。 > 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。** > 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.89(当前契约版本) ### v0.90(当前契约版本)
**「渠道连接态三态」版 —— 管理面新增三个可选键,并把 `connected` 的语义从「动作被受理」纠正为「真的连上了」**(依据:2026-10-13 主人报「改了 QQ 适配器配置后点连接,卡片仍显示未连接,却提示已连接」;实现推库 `b4d55f4`。)
- 🔴 **纠正的谎**:`POST /api/v1/admin/channels/open` 此前在 `ch.Connect` 返回 nil 后**硬编码** `connected:true`。而 `Connect` 返回 nil 只说明「这次调用没有致命失败」——**ws+client 首连失败刻意非致命**(OneBot 实现可能比 Cyrene 后启动,重试交给读循环),**ws+server 则只需监听就绪**,这两种情况下 `IsConnected()` 都是 `false`。前端又只按「请求成功」写死「已连接」⇒ 管理面把「后端受理了」说成了「渠道连上了」。
- **三态闭集(口径落在适配器自身,网关只做映射)**:
| 状态 | 含义 | 线上投影 |
|---|---|---|
| `connected` | 真连上(client 拨号成功 / HTTP 传输已装配就绪) | `connected: true`,三个可选键**全缺席** |
| `pending` | 已在后台重试、当前未连上(仅 ws+client 有重试循环) | `connected: false` + `pending: true` + `reason` |
| `ready` | 已绑定监听、就绪等对端接入(ws+server);`IsConnected()` 同样为 `false`,但**不是失败** | `connected: false` + `ready: true`(**不带** `reason`) |
| 无意见 | 未启动,或已启动但无可解释的就绪面 | 退回只看 `IsConnected()`,三个键全缺席 |
- **新增可选键(三处形状同名同义)**:① `POST /api/v1/admin/channels/open` 与 `.../close` 的响应体;② `GET /api/v1/admin/channels` 的**平台行**;③ 同端点的**账号行**(`accounts[]`)。`reason` 已**脱敏**(形状级 → 已知明文替换 → 剥 `access_token=` 查询参数),**凭据绝不出现**。
- 🔴 **行为变更(须客户端跟进)**:`open` 的 `connected` 现在是**真实连接状态**,**不再恒为 `true`**;`close` 报**真实** `IsConnected()`,不再假定「调用成功 ⇒ 一定断开了」。老客户端若只看 HTTP 2xx 就提示「已连接」,在 `pending` / `ready` 下会继续骗人 —— 请改读 `connected` 加上述两个键,把卡片画成「已连接 / 连接中(原因)/ 就绪待接入 / 未连接」四档。
- **退回行为(无破坏性)**:没实现该状态面的平台(kook / telegram / webhook / weixin-iLink)响应**逐字节不变**(既有四键 + 零变换)。
- **计数不变**:无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`。详见 **§2.4(d)**。
### v0.89
**「助手消息的行内标签白名单」版 —— 只影响渲染口径,**服务端 API 形状一字未改****(依据:主人 2026-10-07「她在聊天消息中插入了 html/md 标签,能否增加网页前端对此类标签的基本渲染支持?」;Web 端实现推库 `2dae469`。) **「助手消息的行内标签白名单」版 —— 只影响渲染口径,**服务端 API 形状一字未改****(依据:主人 2026-10-07「她在聊天消息中插入了 html/md 标签,能否增加网页前端对此类标签的基本渲染支持?」;Web 端实现推库 `2dae469`。)
+29
View File
@@ -17,6 +17,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user" "git.yeij.top/AskaEth/Cyrene_v2/internal/user"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
) )
// ownerRegistrar 实现 gateway.OwnerRegistrar:它把一个渠道账号 id 持久化进主人 // ownerRegistrar 实现 gateway.OwnerRegistrar:它把一个渠道账号 id 持久化进主人
@@ -57,6 +58,30 @@ func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accou
// 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。 // 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。
func (r *ownerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.owner } func (r *ownerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.owner }
// channelCaps 把能力注册表适配成 channelbridge.ChannelRegistry(热重载同步注册表的
// 窄端口)。替换时写入的形状与 wireChannels 启动注册**逐字相同**
// (Name/Kind/Description/Value):Description 取适配器名、Value 取适配器本身 ——
// adminListChannels 把 Value 断言成 sdk.Channel、面板按注册名精确匹配,
// 形状一旦不一致,渠道就会从面板上消失。
type channelCaps struct{ caps *registry.Registry }
func (c channelCaps) ReplaceChannel(ch sdk.Channel) error {
if ch == nil {
return fmt.Errorf("wire channel registry: %w", registry.ErrInvalidArgument)
}
if err := c.caps.ReplaceChannel(ch); err != nil {
return fmt.Errorf("wire channel registry %q: %w", ch.Name(), err)
}
return nil
}
func (c channelCaps) RemoveChannel(name string) error {
if err := c.caps.Remove(name); err != nil {
return fmt.Errorf("wire channel registry %q: %w", name, err)
}
return nil
}
// wireChannels 依环境配置构造四个 IM 渠道适配器,把它们注册进能力注册表(管理 // wireChannels 依环境配置构造四个 IM 渠道适配器,把它们注册进能力注册表(管理
// API 据此枚举),并接进渠道 bridge——让已配置的适配器连上,把入站消息按 // API 据此枚举),并接进渠道 bridge——让已配置的适配器连上,把入站消息按
// session→actor→loop→reply 路由。缺凭据的适配器保持未连接(只记日志,绝不作 // session→actor→loop→reply 路由。缺凭据的适配器保持未连接(只记日志,绝不作
@@ -122,6 +147,10 @@ func (a *app) wireChannels() error {
// EngageFor 留空(P2a);按群覆盖随 P2c 到来。 // EngageFor 留空(P2a);按群覆盖随 P2c 到来。
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。 // Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
Factory: factory, Factory: factory,
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
// 状态、open/close 的目标解析)读的就是注册表,不同步它就只能看到旧实例,
// 于是卡片永远「未连接」、点「连接」驱动的是旧 URL 的那把。
Channels: channelCaps{caps: a.caps},
// ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色 // ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色
// (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。 // (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。
ChannelPersona: factory.Persona, ChannelPersona: factory.Persona,
@@ -2,7 +2,7 @@ import type { AdminChannelAccount } from "../../../lib/types";
import { Button, Tag } from "../../../components/primitives"; import { Button, Tag } from "../../../components/primitives";
import { authKeyOf } from "../channelFieldRules"; import { authKeyOf } from "../channelFieldRules";
import { adapterNameOf } from "./channelUtils"; import { adapterNameOf } from "./channelUtils";
import { accountActionGate, accountOnlineStatus, shortAccountID, type ChannelStatus } from "./channelStatus"; import { accountActionGate, accountOnlineStatus, channelConnectStateOf, connectStateView, shortAccountID, type ChannelStatus, type ChannelStatusTone } from "./channelStatus";
/** 一条渠道账号在活动适配器列表里的行。 /** 一条渠道账号在活动适配器列表里的行。
* *
@@ -46,7 +46,7 @@ export function AccountRow({
const raw = account.config?.[authKey]; const raw = account.config?.[authKey];
const authSet = typeof raw === "string" && raw.trim() !== ""; const authSet = typeof raw === "string" && raw.trim() !== "";
const gate = accountActionGate(status, account.connected); const gate = accountActionGate(status, account.connected);
const state = connectionTag(account.connected); const state = connectionTag(account);
const online = accountOnlineStatus(account.accountOnline); const online = accountOnlineStatus(account.accountOnline);
const blocker = status.kind === "unknown" ? "读取不到连接状态(后端失联),已禁用" : undefined; const blocker = status.kind === "unknown" ? "读取不到连接状态(后端失联),已禁用" : undefined;
@@ -67,7 +67,7 @@ export function AccountRow({
id {short} id {short}
</span> </span>
)} )}
<Tag tone={account.connected === true ? "ok" : account.connected === false ? "err" : "neutral"}>{state}</Tag> <Tag tone={state.tone} title={state.hint}>{state.label}</Tag>
{/* 账号在**平台侧**的在线状态 —— 与上一枚「连接」标签说的是两件事:OneBot 的 {/* 账号在**平台侧**的在线状态 —— 与上一枚「连接」标签说的是两件事:OneBot 的
进程/WS 可以一直连着,而背后的 QQ 账号已掉线(消息收不到也发不出)。 进程/WS 可以一直连着,而背后的 QQ 账号已掉线(消息收不到也发不出)。
两枚标签刻意不合并:合并后「我们连着但账号掉了」这一个事故现场就无迹可寻。 两枚标签刻意不合并:合并后「我们连着但账号掉了」这一个事故现场就无迹可寻。
@@ -127,11 +127,23 @@ export function AccountRow({
); );
} }
/** 账号级连接状态的呈现:已连接 / 未连接 / 状态未知(后端没报告该账号的活适配器)。 */ /** 账号级连接状态的呈现:已连接 / 连接中 / 等待接入 / 未连接 / 状态未知
function connectionTag(connected: boolean | undefined): string { * (后端没报告该账号的活适配器)。
if (connected === true) return "已连接"; *
if (connected === false) return "未连接"; * 「连接中」与「等待接入」来自后端自报的连接态三态(见 ChannelConnectState)。
return "状态未知"; * 没有它们时,一个正在后台重试的 ws+client 账号会被画成红色的「未连接」,主人就会
* 以为它坏了 —— 那正是 2026-10-07 事故的另一半(一半是弹「已连接」,一半是卡片
* 永远显示「未连接」)。两态都用非错误腔:它们不是结论,是进行中。 */
function connectionTag(account: AdminChannelAccount): {
label: string;
tone: ChannelStatusTone;
hint: string | undefined;
} {
if (account.connected === true) return { label: "已连接", tone: "ok", hint: undefined };
const view = connectStateView(channelConnectStateOf(account), account.reason);
if (view !== null) return { label: view.label, tone: view.tone, hint: view.hint };
if (account.connected === false) return { label: "未连接", tone: "err", hint: undefined };
return { label: "状态未知", tone: "neutral", hint: undefined };
} }
/** 「本次会作用在哪一条账号」的小字。这是修掉「连了谁」这一困惑的关键文案: /** 「本次会作用在哪一条账号」的小字。这是修掉「连了谁」这一困惑的关键文案:
@@ -154,4 +154,24 @@ function buttonHTML(html: string, label: string): string {
ok(has(down, "账号在平台上"), "离线标签的悬停说明点明这是账号在平台上的状态"); ok(has(down, "账号在平台上"), "离线标签的悬停说明点明这是账号在平台上的状态");
} }
// ── ⑤ 重试中 / 就绪等接入:不许画成红色的「未连接」 ────────────────────────────
// 2026-10-07 事故的另一半:点了「连接」之后卡片一直显示「未连接」。ws+client 的首连
// 失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),所以那不是故障,是「正在连」;
// ws+server 则是「就绪等对端接入」。两者都必须与静态的「未连接」分开画。
{
const REASON = "qq-obv11: dial onebot ws ws://localhost:10301/: connection refused";
const retrying = renderRow({ id: "9616b27a", config: {}, connected: false, pending: true, reason: REASON });
ok(has(retrying, ">连接中</span>"), "重试中的账号画「连接中」");
ok(!has(retrying, ">未连接</span>"), "重试中不许画成「未连接」(它是进行中,不是结论)");
ok(has(retrying, REASON), "重试中的原因随标签悬停一并给出(说不出为什么就等于没说)");
const ready = renderRow({ id: "9616b27a", config: {}, connected: false, ready: true });
ok(has(ready, ">等待接入</span>"), "监听就绪的账号画「等待接入」");
ok(!has(ready, ">未连接</span>"), "就绪等接入不许画成「未连接」");
// 回归:后端没这条状态面的账号逐字不变。
const plain = renderRow({ id: "9616b27a", config: {}, connected: false });
ok(has(plain, ">未连接</span>"), "没有状态面的账号仍是「未连接」");
}
console.log(`channel account row render test: ${checks} checks passed`); console.log(`channel account row render test: ${checks} checks passed`);
@@ -0,0 +1,213 @@
// 「点了连接之后,面板到底说了什么」的真实断言(2026-10-07 UI 撒谎事故的回归测试)。
//
// 为什么必须存在:旧代码只按「请求成功」判断 —— `adminChannelOpen` 一 resolve 就弹
// 「已连接」,而 ws+client 的首连失败**刻意非致命**(OneBot 实现可能比 Cyrene 后启动),
// 于是「后端受理了」被说成了「渠道连上了」。这里不看组件、不靠记忆:既直接驱动
// `createChannelActions`(stub 掉 globalThis.fetch,读它真正写进 notice 的那句话),
// 也直接驱动卡片用的纯函数 `channelStatus`。
//
// 跑法(与 channelActions.test.ts 同款 esbuild + node,react-dom 的 CJS 需要 require):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelConnectNotice.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-channel-connect-notice.test.cjs \
// && node /tmp/cyrene-channel-connect-notice.test.cjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内
// 导入、不 import node:*。
import type { AdminChannelRecord } from "../../../lib/types";
import { channelConnectStateOf, channelStatus } from "./channelStatus";
import { connectDisconnectNotice, createChannelActions } from "./useChannelActions";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function notOk(cond: boolean, what: string): void {
ok(!cond, what);
}
function eq(got: unknown, want: unknown, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what}(got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`);
}
const REASON = "qq-obv11: dial onebot ws ws://localhost:10301/: connection refused";
// ── ① 纯函数:提示文案只看**响应**,不看请求是否成功 ─────────────────────────────
{
// 本次事故的正题:请求 200 + connected:false + pending:true ⇒ 绝不是「已连接」。
const pending = connectDisconnectNotice(true, "qq-obv11", {
name: "qq-obv11",
connected: false,
pending: true,
reason: REASON,
});
notOk(pending.includes("已连接"), `重试中不许说「已连接」,got ${JSON.stringify(pending)}`);
ok(pending.includes("正在连接"), `重试中必须说「正在连接」,got ${JSON.stringify(pending)}`);
ok(pending.includes(REASON), `重试中必须带上原因,got ${JSON.stringify(pending)}`);
// 连上了才是「已连接」。
eq(
connectDisconnectNotice(true, "qq-obv11:9616b27a", { name: "qq-obv11", connected: true }),
"已连接 qq-obv11:9616b27a",
"真连上才说「已连接」(并回显实际驱动的适配器)",
);
// server 已就绪、等待对端接入:既不是「已连接」,也不是一句静态的「未连接」。
const ready = connectDisconnectNotice(true, "qq-obv11", { name: "qq-obv11", connected: false, ready: true });
notOk(ready.includes("已连接"), `就绪等接入不许说「已连接」,got ${JSON.stringify(ready)}`);
ok(ready.includes("已就绪"), `就绪等接入必须说清楚,got ${JSON.stringify(ready)}`);
// 后端没有这条状态面(两个键都缺):如实说「尚未连上」,绝不乐观。
const bare = connectDisconnectNotice(true, "kook", { name: "kook", connected: false });
notOk(bare.includes("已连接"), `连不上不许说「已连接」,got ${JSON.stringify(bare)}`);
ok(bare.includes("尚未连上"), `必须如实说尚未连上,got ${JSON.stringify(bare)}`);
// 断开同样不乐观:后端报连接仍在,就不能说「已断开」。
eq(
connectDisconnectNotice(false, "kook", { name: "kook", connected: false }),
"已断开 kook",
"真断开才说「已断开」",
);
const stuck = connectDisconnectNotice(false, "kook", { name: "kook", connected: true });
notOk(stuck.includes("已断开"), `仍未断开不许说「已断开」,got ${JSON.stringify(stuck)}`);
}
// ── ② 卡片状态:三态在**真实渲染逻辑**里与「未连接」分开 ────────────────────────
{
const pendingRecord: AdminChannelRecord = {
name: "qq-obv11",
connected: false,
pending: true,
reason: REASON,
};
const pending = channelStatus([pendingRecord], null, pendingRecord);
eq(pending.kind, "connecting", "重试中的卡片是 connecting,不是 disconnected");
eq(pending.label, "连接中", "重试中的卡片写「连接中」");
eq(pending.tone, "warn", "重试中用警示色而不是错误色(它不是故障)");
ok(pending.hint !== null && pending.hint.includes(REASON), "重试中的卡片带上原因");
notOk(pending.label.includes("未连接"), "重试中不许画成「未连接」");
const readyRecord: AdminChannelRecord = { name: "qq-obv11", connected: false, ready: true };
const ready = channelStatus([readyRecord], null, readyRecord);
eq(ready.kind, "ready", "就绪等接入的卡片是 ready");
eq(ready.label, "等待接入", "就绪等接入的卡片写「等待接入」");
eq(ready.tone, "neutral", "就绪等接入不用错误色");
// 回归:后端没这条状态面的平台逐字不变。
const plain: AdminChannelRecord = { name: "kook", connected: false };
const disconnected = channelStatus([plain], null, plain);
eq(disconnected.kind, "disconnected", "没有状态面的平台仍是 disconnected");
eq(disconnected.label, "未连接", "没有状态面的平台仍写「未连接」");
eq(disconnected.tone, "err", "没有状态面的平台仍用错误色");
// 真连上优先:pending 与 connected 同时给出时不许被降级成「连接中」。
const both: AdminChannelRecord = { name: "qq-obv11", connected: true, pending: true };
eq(channelStatus([both], null, both).kind, "connected", "connected=true 优先于 pending");
eq(channelConnectStateOf({}), null, "两个键都缺省 = 没有这条状态面");
eq(channelConnectStateOf({ pending: true }), "pending", "pending 优先");
eq(channelConnectStateOf({ ready: true }), "ready", "ready 单独给出也认");
}
// ── ③ 真实请求:createChannelActions 写进 notice 的那句话 ───────────────────────
// 与前两段不同,这里驱动的是面板按钮真正调用的那一层,并用 stub 的 fetch 回一个
// 「请求成功但没连上」的响应 —— 旧代码在这条路径上会说「已连接」。
interface Captured {
url: string;
method: string;
}
const captured: Captured[] = [];
// 每次请求各取一个响应:三个动作在同一个 tick 里发出,若共用一个可变变量,
// 先发的两次会读到第三次的响应(那样断言的就是错的东西)。
const payloadQueue: unknown[] = [];
function installStubs(): void {
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
});
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: (url: string, init?: RequestInit) => {
captured.push({ url: String(url), method: String(init?.method ?? "GET") });
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ data: payloadQueue.shift() ?? {} }),
} as unknown as Response);
},
});
}
installStubs();
const notices: string[] = [];
const errors: string[] = [];
let reloads = 0;
const actions = createChannelActions({
log: () => {},
setBusy: () => {},
setError: (e) => {
if (e != null) errors.push(e);
},
setNotice: (n) => notices.push(n ?? ""),
reload: () => {
reloads += 1;
},
});
// 场景 A:后端回「请求成功但仍在重试」。
payloadQueue.push({
name: "qq-obv11",
resolved: "qq-obv11:9616b27a",
accountId: "9616b27a",
connected: false,
pending: true,
reason: REASON,
});
actions.toggle("qq-obv11", true, "9616b27a");
// 场景 B:后端回「真连上」。
payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: true });
actions.toggle("qq-obv11", true, "9616b27a");
// 场景 C:断开成功。
payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: false });
actions.toggle("qq-obv11", false, "9616b27a");
setTimeout(() => {
const pendingNotice = notices.find((n) => n.includes("正在连接"));
ok(pendingNotice !== undefined, `重试中必须提示「正在连接」,got ${JSON.stringify(notices)}`);
ok(
!notices[0]?.includes("已连接"),
`第一个动作(仍在重试)绝不能说「已连接」,got ${JSON.stringify(notices[0])}`,
);
ok(notices[0]?.includes(REASON) === true, `重试提示必须带上原因,got ${JSON.stringify(notices[0])}`);
ok(
notices.some((n) => n.includes("已连接 qq-obv11:9616b27a")),
`真连上时必须回显实际驱动的适配器,got ${JSON.stringify(notices)}`,
);
ok(
notices.some((n) => n.includes("已断开 qq-obv11:9616b27a")),
`断开成功要说「已断开」,got ${JSON.stringify(notices)}`,
);
eq(errors.length, 0, "这些路径都不该产生错误提示");
eq(reloads, 3, "每次动作后都刷新列表(卡片状态要靠它才不再撒谎)");
eq(captured[0].url, "/api/v1/admin/channels/open", "连接仍打到既有路径");
eq(captured[2].url, "/api/v1/admin/channels/close", "断开仍打到既有路径");
console.log(`channel connect notice test: ${checks} checks passed`);
}, 20);
@@ -7,6 +7,8 @@ import type { AdminChannelRecord } from "../../../lib/types";
* as plain node — see channelStatus.test.ts. */ * as plain node — see channelStatus.test.ts. */
export type ChannelStatusKind = export type ChannelStatusKind =
| "connected" | "connected"
| "connecting"
| "ready"
| "disconnected" | "disconnected"
| "unconfigured" | "unconfigured"
| "unknown" | "unknown"
@@ -29,6 +31,55 @@ export interface ChannelStatus {
/** How much of the channel list we actually know right now. */ /** How much of the channel list we actually know right now. */
export type ChannelListState = "ready" | "pending" | "unknown"; export type ChannelListState = "ready" | "pending" | "unknown";
/** 适配器自报的**连接态三态**(后端可选键 `pending` / `ready`,契约 v0.68;见
* internal/services/gateway/admin_channel_connect_outcome.go)。
*
* 为什么需要它:`connected:false` 至少有两种互不相同的现状 —— 「已在后台重试」
* (ws+client 的首连失败刻意非致命:OneBot 实现可能比 Cyrene 后启动)与「已就绪、
* 等待对端接入」(server 模式监听已绑定)。用一句静态的「未连接」覆盖两者,主人就会
* 把「正在连接」读成故障,或者(更糟)反方向把「没连上」读成「已连接」——
* 2026-10-07 的 UI 撒谎事故正是这么来的。
*
* `null` = 适配器没有这条状态面(后端两个键都缺省)⇒ 调用方退回只看 `connected`
* 布尔,**绝不**凭空编一个状态。 */
export type ChannelConnectState = "pending" | "ready" | null;
/** 把后端可选的 `pending` / `ready` 两键映成上面的三态。两者都缺省 ⇒ null。 */
export function channelConnectStateOf(record: {
pending?: boolean;
ready?: boolean;
}): ChannelConnectState {
if (record.pending === true) return "pending";
if (record.ready === true) return "ready";
return null;
}
/** 未连上时那句如实的话:`pending` 说「正在重试」并把原因带上(原因缺失时也不假装
* 知道为什么),`ready` 说「等待接入」。两者都不是失败腔。`null` 表示后端没有这条
* 状态面 ⇒ 调用方退回自己那套说法。 */
export function connectStateView(
state: ChannelConnectState,
reason?: string,
): { kind: ChannelStatusKind; label: string; tone: ChannelStatusTone; hint: string } | null {
if (state === "pending") {
return {
kind: "connecting",
label: "连接中",
tone: "warn",
hint: reason ? `正在后台重试连接:${reason}` : "已在后台重试连接(尚未连上)",
};
}
if (state === "ready") {
return {
kind: "ready",
label: "等待接入",
tone: "neutral",
hint: "监听已就绪,等待对端接入(尚未连上)",
};
}
return null;
}
/** List-level tri-state: a list we never read plus a recorded error is *unknown*, /** List-level tri-state: a list we never read plus a recorded error is *unknown*,
* never "empty". `data == null` with no error is the first load still running. */ * never "empty". `data == null` with no error is the first load still running. */
export function channelListState<T>( export function channelListState<T>(
@@ -91,6 +142,24 @@ export function channelStatus(
} }
const connected = record.connected === true; const connected = record.connected === true;
if (!connected) {
// 「没连上」有三种互不相同的现状,只有后端自报时才敢细说(见 ChannelConnectState):
// pending 是「正在重试」、ready 是「就绪等接入」。两者都不是「未连接」这样一个
// 静态结论,因此不给错误腔,也不把「断开」摆出来(会话本来就没建立)。
const state = channelConnectStateOf(record);
const view = connectStateView(state, record.reason);
if (view !== null) {
return {
kind: view.kind,
label: view.label,
tone: view.tone,
hint: error != null ? `${view.hint};本次刷新失败,数据可能过期:${error}` : view.hint,
stale: error != null,
canConnect: true,
canDisconnect: false,
};
}
}
return { return {
kind: connected ? "connected" : "disconnected", kind: connected ? "connected" : "disconnected",
label: connected ? "已连接" : "未连接", label: connected ? "已连接" : "未连接",
@@ -3,6 +3,8 @@ import type { ChannelStatus, ChannelStatusKind } from "./channelStatus";
const dot: Record<ChannelStatusKind, string> = { const dot: Record<ChannelStatusKind, string> = {
connected: "bg-[var(--mu-ok)]", connected: "bg-[var(--mu-ok)]",
connecting: "bg-[var(--mu-warn)] mu-blink",
ready: "bg-[var(--mu-text-lo)]",
disconnected: "bg-[var(--mu-err)]", disconnected: "bg-[var(--mu-err)]",
unconfigured: "bg-[var(--mu-text-lo)]", unconfigured: "bg-[var(--mu-text-lo)]",
unknown: "bg-[var(--mu-text-lo)]", unknown: "bg-[var(--mu-text-lo)]",
@@ -10,9 +12,10 @@ const dot: Record<ChannelStatusKind, string> = {
}; };
/** The one honest place an adapter's connection state is rendered, so list page, /** The one honest place an adapter's connection state is rendered, so list page,
* detail page and overview can never drift apart: 已连接 / 未连接 / 状态未知. * detail page and overview can never drift apart: 已连接 / 连接中 / 等待接入 /
* `showHint` off renders the compact form for dense surfaces (table row, card) — * 未连接 / 状态未知. `showHint` off renders the compact form for dense surfaces
* the surrounding banner already carries the failure reason there. */ * (table row, card) — there the hint (e.g. the retry reason behind「连接中」) rides
* along as a hover title instead of taking a line of its own. */
export function ChannelStatusTag({ export function ChannelStatusTag({
status, status,
showHint = true, showHint = true,
@@ -24,7 +27,7 @@ export function ChannelStatusTag({
}): React.ReactNode { }): React.ReactNode {
return ( return (
<span className={`inline-flex flex-wrap items-center gap-2 ${className}`}> <span className={`inline-flex flex-wrap items-center gap-2 ${className}`}>
<Tag tone={status.tone}> <Tag tone={status.tone} title={status.hint ?? undefined}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${dot[status.kind]}`} /> <span className={`inline-block h-1.5 w-1.5 rounded-full ${dot[status.kind]}`} />
{status.label} {status.label}
</Tag> </Tag>
@@ -4,6 +4,7 @@ import {
adminChannelOpen, adminChannelOpen,
adminDeleteChannelAccount, adminDeleteChannelAccount,
adminDeleteEmptyChannelAccounts, adminDeleteEmptyChannelAccounts,
type ChannelActionResult,
} from "../../../lib/api"; } from "../../../lib/api";
import { useAuditStore } from "../../audit"; import { useAuditStore } from "../../audit";
@@ -19,6 +20,35 @@ export interface ChannelActionDeps {
reload: () => void; reload: () => void;
} }
/** 连接/断开返回后要说给主人的那句话。
*
* **只看响应,不看请求是否成功**:请求 200 只说明后端受理了这次调用,不说明渠道真的
* 连上了 —— ws+client 的首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),此时
* 后端回 `connected:false` + `pending:true` + `reason`。把这句写死成「已连接」,
* 正是 2026-10-07 那次 UI 撒谎的根因。断开同理:报后端回的真实连接状态,而不是假定
* 「调用成功就一定断了」。
*
* 纯函数、与 React 解耦,因此「响应 connected:false + pending:true ⇒ 提示里没有
* 『已连接』」可以在 node 侧被直接断言(见 channelActions.test.ts)。 */
export function connectDisconnectNotice(
connect: boolean,
who: string,
res?: ChannelActionResult,
): string {
if (!connect) {
return res?.connected === true ? `${who} 仍未断开(后端报告连接仍存在)` : `已断开 ${who}`;
}
if (res?.connected === true) return `已连接 ${who}`;
if (res?.pending === true) {
return res.reason
? `${who} 正在连接(${res.reason})`
: `${who} 正在连接(尚未连上,已在后台重试)`;
}
if (res?.ready === true) return `${who} 已就绪,等待对端接入`;
// 既没连上、也没有可解释的状态面:如实说「尚未连上」——请求被受理不等于连上了。
return `${who} 尚未连上(连接请求已受理,可稍后在列表中查看状态)`;
}
/** 与 React 解耦的渠道动作集合(连接/断开/删除账号/删除无 id 条目)。 */ /** 与 React 解耦的渠道动作集合(连接/断开/删除账号/删除无 id 条目)。 */
export interface ChannelActions { export interface ChannelActions {
/** 连接/断开。`accountId` 给出时**只**作用于该账号(多账号平台逐条操作); /** 连接/断开。`accountId` 给出时**只**作用于该账号(多账号平台逐条操作);
@@ -42,7 +72,7 @@ export function createChannelActions(deps: ChannelActionDeps): ChannelActions {
// 回显**实际被驱动**的适配器:多账号平台上请求里的平台名说明不了「连的是哪一条」, // 回显**实际被驱动**的适配器:多账号平台上请求里的平台名说明不了「连的是哪一条」,
// 后端把解析结果放在 resolved 里,这里如实说出来。 // 后端把解析结果放在 resolved 里,这里如实说出来。
const who = res?.resolved ?? name; const who = res?.resolved ?? name;
setNotice(`${connect ? "已连接" : "已断开"} ${who}`); setNotice(connectDisconnectNotice(connect, who, res));
reload(); reload();
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
+15 -1
View File
@@ -23,12 +23,26 @@ export interface ChannelConfigSaveResult {
/** Result of POST /admin/channels/open|close. `resolved` is the adapter that was /** Result of POST /admin/channels/open|close. `resolved` is the adapter that was
* actually driven (`qq-obv11:9616b27a`), which is what the panel must show — * actually driven (`qq-obv11:9616b27a`), which is what the panel must show —
* the request's `name` alone never tells a multi-account platform which account * the request's `name` alone never tells a multi-account platform which account
* was touched. */ * was touched.
*
* `connected` 说的是**现在通不通**,不是「这次请求成没成功」:ws+client 的首连失败
* 刻意非致命(OneBot 实现可能比 Cyrene 后启动),因此 `Connect` 返回 nil 并不等于连上了。
* `pending`/`ready`(后端可选,契约 v0.68)进一步说明「没连上是哪种没连上」:
*
* - `pending: true` → 已在后台重试、当前未连上 ⇒ 提示须是「正在连接(reason)」,
* **绝不是**「已连接」,也不是一句静态的「未连接」;
* - `ready: true` → 已就绪、等待对端接入(server 模式)⇒ 同样不是失败;
* - `reason` → 未连上的原因(已脱敏)。
*
* 三者缺省 = 该适配器没有这条状态面 ⇒ 只看 `connected`。 */
export interface ChannelActionResult { export interface ChannelActionResult {
name: string; name: string;
resolved?: string; resolved?: string;
accountId?: string; accountId?: string;
connected: boolean; connected: boolean;
pending?: boolean;
ready?: boolean;
reason?: string;
} }
/** open/close 的请求体。**accountId 缺省时逐字维持旧契约 `{name}`**(老后端/老客户端 /** open/close 的请求体。**accountId 缺省时逐字维持旧契约 `{name}`**(老后端/老客户端
+19
View File
@@ -40,6 +40,19 @@ export interface InstalledPlugin {
export interface AdminChannelRecord { export interface AdminChannelRecord {
name: string; name: string;
connected: boolean; connected: boolean;
/** 适配器自报的**连接态三态**(后端可选键,契约 v0.68;见 internal/services/
* gateway/admin_channel_connect_outcome.go)。`connected:false` 至少有两种互不相同
* 的现状,缺了它就只能被画成一句静态的「未连接」:
*
* - `pending` → 已在后台重试、当前未连上 ⇒ 面板读作「连接中」(`reason` 说明原因);
* - `ready` → 已就绪、等待对端接入(server 模式监听已绑定)⇒ 不是失败。
*
* 两者都**缺省** = 该适配器没有这条状态面(绝大多数平台)⇒ 面板退回只看 `connected`,
* 绝不凭空编一个状态。 */
pending?: boolean;
ready?: boolean;
/** 未连上时的原因(已脱敏,绝不含凭据)。只有 `pending` 时才会出现。 */
reason?: string;
/** Configured accounts for this channel's platform (token masked). */ /** Configured accounts for this channel's platform (token masked). */
accounts?: AdminChannelAccount[]; accounts?: AdminChannelAccount[];
} }
@@ -80,6 +93,12 @@ export interface AdminChannelAccount {
* 缺省(undefined)= **状态未知**(该渠道没有独立账号状态面,或尚未探到结果), * 缺省(undefined)= **状态未知**(该渠道没有独立账号状态面,或尚未探到结果),
* 面板画「账号状态未知」,**绝不**画成「账号离线」。 */ * 面板画「账号状态未知」,**绝不**画成「账号离线」。 */
accountOnline?: boolean; accountOnline?: boolean;
/** 该账号适配器自报的连接态三态(可选,见 `AdminChannelRecord.pending`)。三者缺省 =
* 该适配器没有这条状态面 ⇒ 退回只看 `connected`。 */
pending?: boolean;
ready?: boolean;
/** 未连上时的原因(已脱敏)。只有 `pending` 时才会出现。 */
reason?: string;
} }
/** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr). /** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr).
+51
View File
@@ -301,6 +301,57 @@ func (r *Registry) RegisterChannel(c sdk.Channel) error {
}) })
} }
// ReplaceChannel 用**同名**的 c 就地更新已登记的渠道条目(热重载,design/04 §3.1)。
//
// 为什么必须是替换而不是 Register:渠道热重载**必然同名**(同名账号的账号名由
// channels.json 决定,重载前后不变),而 Register 遇重名返回 ErrDuplicate,会把
// 新适配器挡在门外 —— 注册表于是继续指向旧实例,管理面板照旧按旧配置读写它的状态
// (显示「未连接」、点「连接」拨的是旧地址)。
//
// 语义是 **upsert**,与 ReplaceTool 有意不同(工具的窄方法是「只换现有这把」):
// - 名字不存在 ⇒ 按 RegisterChannel 同口径新增(账号被新加进配置后,热重载是它
// 第一次出现在注册表里的时机,没有别处会替它注册);
// - 名字已存在且 Kind == KindChannel ⇒ 覆盖 Value(指向新适配器),**保留既有
// Policy** —— 分组/陌生人的可达标记是运行期可编辑配置(design/11 §3),不属于
// 适配器的构造产出,热重载把它清掉等于「面板保存一次,某个群忽然不能用了」;
// - 名字已存在但 Kind != KindChannel ⇒ 报错,绝不悄悄改掉那条能力的类型。
func (r *Registry) ReplaceChannel(c sdk.Channel) error {
if c == nil || c.Name() == "" {
return fmt.Errorf("registry: replace channel: %w", ErrInvalidArgument)
}
name := c.Name()
r.mu.Lock()
defer r.mu.Unlock()
cur, ok := r.caps[name]
if !ok {
// 新增分支与 Register 同口径:落盘策略覆盖初始标记(DB 是策略的权威载体)。
added := Capability{
Name: name,
Kind: KindChannel,
Description: name,
Value: c,
}
if pol, ok := r.persisted[name]; ok {
added.Policy = pol
}
r.caps[name] = added
return nil
}
if cur.Kind != KindChannel {
return fmt.Errorf("registry: replace %q: 既有能力类型是 %q,不是渠道: %w", name, cur.Kind, ErrInvalidArgument)
}
r.caps[name] = Capability{
Name: name,
Kind: KindChannel,
// Description 与 RegisterChannel 一致地取适配器名(面板按注册名精确匹配)。
Description: name,
// Policy 沿用旧条目:它是可编辑的运行期配置,不属于适配器的构造产出。
Policy: cur.Policy,
Value: c,
}
return nil
}
// validKind reports whether k is one of the known capability kinds. // validKind reports whether k is one of the known capability kinds.
func validKind(k Kind) bool { func validKind(k Kind) bool {
switch k { switch k {
@@ -0,0 +1,156 @@
package registry
import (
"context"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住渠道热重载的窄方法 `ReplaceChannel`(upsert,design/04 §3.1):
// 热重载重建出来的适配器**必然同名**,Register 会 ErrDuplicate 把新实例挡在门外,
// 注册表于是继续指向旧实例——管理面板显示「未连接」、点「连接」驱动的是旧实例。
// stubChannel 是名字可指定、连接状态可变的最小渠道(只用于注册/替换的判定)。
type stubChannel struct {
name string
up bool
}
func (s *stubChannel) Name() string { return s.name }
func (s *stubChannel) Connect(context.Context) error { s.up = true; return nil }
func (s *stubChannel) Disconnect(context.Context) error { s.up = false; return nil }
func (s *stubChannel) IsConnected() bool { return s.up }
func (s *stubChannel) HealthCheck(context.Context) error { return nil }
var _ sdk.Channel = (*stubChannel)(nil)
// TestReplaceChannelSwapsValueAndKeepsPolicy 是热重载的主判据:
// - 同名条目换成了**新实例**(调用方随后 Connect/IsConnected 驱动的就是新的那一把);
// - **Policy 原样保留** —— 分组/陌生人可达标记是运行期配置,不属于适配器实现;
// - Kind/Description 与 RegisterChannel 同口径。
func TestReplaceChannelSwapsValueAndKeepsPolicy(t *testing.T) {
const name = "qq-obv11:9616b27ae314253c54419b7541538cdc"
r := New()
old := &stubChannel{name: name, up: true}
if err := r.RegisterChannel(old); err != nil {
t.Fatalf("RegisterChannel: %v", err)
}
if err := r.SetAllowInGroup(name, true); err != nil {
t.Fatalf("SetAllowInGroup: %v", err)
}
next := &stubChannel{name: name, up: true}
// 先钉住「为什么必须是替换而不是 Register」:同名注册必被拒。
if err := r.RegisterChannel(next); !errors.Is(err, ErrDuplicate) {
t.Fatalf("同名的 RegisterChannel err = %v, want ErrDuplicate(热重载必然同名)", err)
}
if err := r.ReplaceChannel(next); err != nil {
t.Fatalf("ReplaceChannel: %v", err)
}
cap, err := r.Get(name)
if err != nil {
t.Fatalf("Get: %v", err)
}
// 判据是接口值同一性:新旧实例的 Name() 完全相同,比名字必然假绿。
if cap.Value != sdk.Channel(next) {
t.Fatalf("替换后 Value = %#v, want 新实例", cap.Value)
}
if cap.Value == sdk.Channel(old) {
t.Fatal("替换后仍指向旧实例:面板会显示未连接、点「连接」驱动旧实例")
}
if !cap.Policy.AllowInGroup {
t.Fatal("替换丢掉了 AllowInGroup:运行期策略不属于适配器构造产出,必须沿用")
}
if cap.Kind != KindChannel {
t.Fatalf("替换后 Kind = %q, want %q", cap.Kind, KindChannel)
}
if cap.Description != name {
t.Fatalf("替换后 Description = %q, want %q(与 RegisterChannel 同口径)", cap.Description, name)
}
}
// TestReplaceChannelRegistersMissingName:名字不存在时按 RegisterChannel 同口径新增
// —— 新加进配置的账号,热重载是它第一次出现在注册表里的时机,没有别处替它注册。
func TestReplaceChannelRegistersMissingName(t *testing.T) {
const name = "kook:conf0"
r := New()
ch := &stubChannel{name: name, up: true}
if err := r.ReplaceChannel(ch); err != nil {
t.Fatalf("ReplaceChannel: %v", err)
}
cap, err := r.Get(name)
if err != nil {
t.Fatalf("Get: %v", err)
}
if cap.Value != sdk.Channel(ch) {
t.Fatalf("新增后 Value = %#v, want 该适配器", cap.Value)
}
if cap.Kind != KindChannel || cap.Description != name {
t.Fatalf("新增条目形状 = %q/%q, want %q/%q", cap.Kind, cap.Description, KindChannel, name)
}
// 面板按 Kind 枚举渠道卡片,新条目必须能列出来。
listed := r.List(KindChannel)
if len(listed) != 1 || listed[0].Name != name {
t.Fatalf("List(KindChannel) = %#v, want 仅 %q", listed, name)
}
}
// TestReplaceChannelUpsertHonorsPersistedPolicy 钉住新增分支与 Register 的同口径性:
// DB 是策略的权威载体(裁决 10.4.2),已落盘的策略要覆盖新条目的零值。
func TestReplaceChannelUpsertHonorsPersistedPolicy(t *testing.T) {
r := New()
r.persisted = map[string]ToolPolicy{"kook": {AllowInGroup: true}}
if err := r.ReplaceChannel(&stubChannel{name: "kook"}); err != nil {
t.Fatalf("ReplaceChannel: %v", err)
}
cap, err := r.Get("kook")
if err != nil {
t.Fatalf("Get: %v", err)
}
if !cap.Policy.AllowInGroup {
t.Fatal("新增分支丢掉了已落盘的策略:与 Register 的口径不一致")
}
}
// TestReplaceChannelRefusesNonChannelEntry:同名条目不是渠道时拒绝,绝不悄悄改掉它的 Kind。
func TestReplaceChannelRefusesNonChannelEntry(t *testing.T) {
r := New()
if err := r.Register(Capability{Name: "kook", Kind: KindMCP, Value: struct{}{}}); err != nil {
t.Fatalf("Register: %v", err)
}
err := r.ReplaceChannel(&stubChannel{name: "kook"})
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("err = %v, want ErrInvalidArgument", err)
}
if err == nil || !strings.Contains(err.Error(), string(KindMCP)) {
t.Fatalf("错误消息 %q 里应指出既有类型 %q", err, KindMCP)
}
cap, gerr := r.Get("kook")
if gerr != nil {
t.Fatalf("Get: %v", gerr)
}
if cap.Kind != KindMCP {
t.Fatalf("被拒的替换却改了 Kind:%q", cap.Kind)
}
if _, ok := cap.Value.(*stubChannel); ok {
t.Fatal("被拒的替换却改了 Value")
}
}
// TestReplaceChannelRejectsNilAndEmptyName:两个不该被静默吞掉的调用错误。
func TestReplaceChannelRejectsNilAndEmptyName(t *testing.T) {
r := New()
if err := r.ReplaceChannel(nil); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("替换成 nil err = %v, want ErrInvalidArgument", err)
}
if err := r.ReplaceChannel(&stubChannel{}); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("替换成空名 err = %v, want ErrInvalidArgument", err)
}
if got := r.List(""); len(got) != 0 {
t.Fatalf("非法入参不得写进注册表,实际写入 %#v", got)
}
}
@@ -0,0 +1,82 @@
package adapter
// 连接**就绪三态**的取值与判定:一次 Connect 返回之后,适配器到底处于哪一种状态。
//
// 为什么必须三态而不是一个 bool:`Connect` 返回 nil 只说明「这次调用没有致命失败」,
// 它至少覆盖三件互不相同的事——
//
// ConnectStateConnected —— 真连上了(client 拨号成功 / HTTP 传输已装配就绪);
// ConnectStatePending —— 已进入后台重试、当前还没连上(OneBot 实现比 Cyrene 后
// 启动是常态,首连失败刻意非致命);
// ConnectStateReady —— 已绑定监听、就绪等待对端接入(ws+server 模式)。
//
// pending 与 ready 的 IsConnected() 都是 false,但它们**不是**「未连接」这样一个静态
// 结论:前者是「正在连接」,后者是「就绪等接入」。用一个布尔去覆盖三态,管理面板就只能
// 编一个说法——编出来的正是 2026-10-07 那个「点了连接就弹已连接,而卡片一直显示未连接」
// 的谎。口径属于适配器(它才知道自己的 transport × mode 意味着什么),网关只做映射
// (internal/services/gateway 的 connectOutcomeAware,经类型断言探测,核心契约不扩展)。
//
// 设计依据:DevDocs/design/08-渠道接入.md §1(PlatformAdapter 契约)与 §4.1(四端
// transport × mode);立场与 design/26 §2.6「状态面必须区分 WS 连着与账号在线」一致 ——
// 一个布尔说不了两件事,更说不了三件。
//
// 与 SessionState* / AccountStatus 同一口径:核心 PlatformAdapter 契约**刻意不扩展**,
// 需要这条状态面的适配器自行实现该方法,网关用类型断言探测;没实现的适配器行为逐字不变。
const (
// ConnectStateConnected 表示真连上:会话已建立(client 拨号成功),或传输已装配
// 就绪而无可观察的长连接(HTTP)。此时 IsConnected() 应为 true。
ConnectStateConnected = "connected"
// ConnectStatePending 表示已进入后台重试、当前尚未连上。它既不是配置错误,也不是
// 「未连接」,而是「连接中」,Reason 应说明原因(脱敏,绝不含凭据)。
ConnectStatePending = "pending"
// ConnectStateReady 表示已就绪、等待对端接入:server 模式已绑定监听,但还没有
// 对端连上来。IsConnected() 此时为 false,却**不是失败**,因此没有 Reason。
ConnectStateReady = "ready"
)
// ConnectSignals 是判定三态所需的**全部**输入。用结构体而不是几个相邻的布尔参数,
// 一是让调用点自解释,二是避免两个 bool 被接反(那会静默地骗过所有测试)。
type ConnectSignals struct {
// Started 报告 Connect 是否已成功启动该适配器;false 表示尚未启动,无从判断。
Started bool
// Connected 报告当前是否有一条活的会话。
Connected bool
// Retrying 报告未连上时后台是否有重试循环在跑(ws+client 有一条;HTTP 没有会话
// 可重连,不算)。
Retrying bool
// Listening 报告适配器是否已绑定监听、在等对端接入(server 模式)。
Listening bool
// Reason 是最近一次失败的原因(**已脱敏**);没有失败或已连上时为空串。
Reason string
}
// ConnectStateOf 是**纯函数**:由信号判定三态与原因。它不读时钟、不触网、不加锁,
// 因此三态可以逐条单测(见 connect_outcome_test.go);适配器侧只负责在同一把锁下把
// 信号读齐。
//
// 判定顺序(自上而下,先匹配者胜):
//
// !Started → ("", "") —— 未启动的适配器无从判断:回「无意见」而不是
// 随便认一个状态,调用方据此让可选键整键缺席。
// Connected → ConnectStateConnected —— 有活会话就是真连上,先于其它分支。
// Retrying → ConnectStatePending —— 尚未连上但重试在跑,这才是「连接中」。
// Listening → ConnectStateReady —— 监听已就绪、等对端接入,不是失败。
// 否则 → ("", "") —— 已启动、未连上、也没有可解释的就绪面:**不编**
// 状态,让调用方退回只看 IsConnected()。
//
// 只有 pending 会带 Reason:connected 没有任何坏消息要说,ready 本来就不是坏消息。
func ConnectStateOf(sig ConnectSignals) (state, reason string) {
switch {
case !sig.Started:
return "", ""
case sig.Connected:
return ConnectStateConnected, ""
case sig.Retrying:
return ConnectStatePending, sig.Reason
case sig.Listening:
return ConnectStateReady, ""
default:
return "", ""
}
}
@@ -0,0 +1,75 @@
// 本文件只测连接态三态的**纯判定**(ConnectStateOf):它不读时钟、不触网、不加锁,
// 因此三态可以逐条单测。适配器侧的落点(qq_obv11 的 ConnectOutcome 与脱敏)见
// plugins/channels/qq_obv11/connect_outcome_test.go,网关侧的映射见
// internal/services/gateway/admin_channel_connect_outcome_test.go。
package adapter
import "testing"
// TestConnectStateOfNotStartedHasNoOpinion 断言未启动的适配器**不编状态**:回空串,
// 调用方据此退回只看 IsConnected(),而不是随便认一个。
func TestConnectStateOfNotStartedHasNoOpinion(t *testing.T) {
t.Parallel()
for _, sig := range []ConnectSignals{
{},
{Connected: true},
{Retrying: true, Reason: "boom"},
{Listening: true},
} {
if state, reason := ConnectStateOf(sig); state != "" || reason != "" {
t.Errorf("ConnectStateOf(%+v) = (%q,%q), want 无意见", sig, state, reason)
}
}
}
// TestConnectStateOfConnectedWins 断言真连上优先于其它分支(监听与重试信号同时为真
// 也不该改变结论),且**不**带原因 —— 「已连接」旁边不该挂一条过期的失败原因。
func TestConnectStateOfConnectedWins(t *testing.T) {
t.Parallel()
state, reason := ConnectStateOf(ConnectSignals{
Started: true, Connected: true, Retrying: true, Listening: true, Reason: "stale failure",
})
if state != ConnectStateConnected || reason != "" {
t.Fatalf("ConnectStateOf = (%q,%q), want (%q,\"\")", state, reason, ConnectStateConnected)
}
}
// TestConnectStateOfRetryingIsPending 断言「未连上但重试在跑」是 pending 且带上原因。
// 这正是 2026-10-07 事故里被写成「已连接」(或静态「未连接」)的那一格。
func TestConnectStateOfRetryingIsPending(t *testing.T) {
t.Parallel()
state, reason := ConnectStateOf(ConnectSignals{
Started: true, Retrying: true, Reason: "dial tcp 127.0.0.1:10301: connection refused",
})
if state != ConnectStatePending {
t.Fatalf("state = %q, want %q", state, ConnectStatePending)
}
if reason != "dial tcp 127.0.0.1:10301: connection refused" {
t.Fatalf("reason = %q, want the failure reason verbatim", reason)
}
// 三个取值互不相同:把 pending 写成 connected 是这次要根除的谎。
if ConnectStatePending == ConnectStateConnected || ConnectStatePending == ConnectStateReady {
t.Fatal("pending 的取值必须与 connected / ready 都不同")
}
}
// TestConnectStateOfListeningIsReady 断言「已绑定监听、等对端接入」是 ready 而不是
// pending:它不是失败,因此**没有**原因可说(哪怕结构体里带着一条陈旧的 reason)。
func TestConnectStateOfListeningIsReady(t *testing.T) {
t.Parallel()
state, reason := ConnectStateOf(ConnectSignals{
Started: true, Listening: true, Reason: "should not be surfaced",
})
if state != ConnectStateReady || reason != "" {
t.Fatalf("ConnectStateOf = (%q,%q), want (%q,\"\")", state, reason, ConnectStateReady)
}
}
// TestConnectStateOfUnexplainedStaysSilent 断言「已启动、未连上、也没有可解释的就绪面」
// 时**不编**状态:既不说「正在连接」(不知道有没有在连),也不说「就绪」。
func TestConnectStateOfUnexplainedStaysSilent(t *testing.T) {
t.Parallel()
if state, reason := ConnectStateOf(ConnectSignals{Started: true, Reason: "orphan"}); state != "" || reason != "" {
t.Fatalf("ConnectStateOf = (%q,%q), want 无意见", state, reason)
}
}
+76 -20
View File
@@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config"
@@ -38,23 +39,24 @@ const (
// DefaultRequestTimeout 是**单次** /embeddings 调用的墙钟上限,独立于 HTTP 客户端 // DefaultRequestTimeout 是**单次** /embeddings 调用的墙钟上限,独立于 HTTP 客户端
// 那一层的 180s 总超时(httpRequestTimeout)。 // 那一层的 180s 总超时(httpRequestTimeout)。
// //
// 依据(是实测数据,不是拍脑袋的调优): // 它**不是**单独取值:批次上限(DefaultBatchLimit / DefaultBatchChars)与它由
// - 上层「入库前审查」的单条总预算是 20s(memory.defaultReviewTimeout),embedding // budget.go 里**同一个不等式**推出 —— 单次请求的耗时上界不得超过超时的一半。
// 只是它内部的第一步。2026-10-07 实测(本机 llama.cpp + Qwen3-Embedding-0.6B, // 改这一行就等于改了批次上限,反之亦然;两侧永远是配套的。
// 服务空闲):单条文本约 60ms;**64 条一批约 2.1~2.9s**;审查最坏情况 //
// (ScanLimit=200 + 新条目 1)要 4 批 ⇒ 约 8~10s,已经占掉 20s 预算的一半, // 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 20s
// 留给后面的 T1 灰区判定(defaultT1Timeout=8s)的余量很薄。一次卡死的 HTTP // (memory.defaultReviewTimeout),embedding 只是它内部的第一步,后面还有 T1 灰区
// 就能把这点余量连同整条审查一起吃掉(实测日志里 // 判定(8s)。一次卡死的 HTTP 不能把整条审查的 20s 都赌掉。真慢就**快速失败**,
// `memory/review: context deadline exceeded` 正是这个形态); // 调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 degraded),
// - 所以每次调用必须有**自己的**上限:卡住的那一批到点就失败并降级,不能把整条 // **绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则。
// 审查的 20s 都赌在一次 HTTP 上; //
// - 5s 的取值:相对实测的 64 条一批 2.9s 留约 1.7 倍余量,能吸收并发争用带来的 // 5s 保持不变的依据:本次故障(2026-10-13)的成因不是这个数太小,而是批次太大 ——
// 抖动,同时远小于 20s 的审查预算。若日后批次变大或服务变慢,先调这里而不是 // 64 条的实测耗时 7.5~8.5s 已经越过它,客户端放弃、服务端还在算,把整台本机
// 调审查总预算; // embedding 服务顶住,连带单条请求(写向量索引)也一起超时。所以修法是**把批次
// - 真慢就**快速失败**:调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 // 收进预算内**,而不是把预算放大到迁就批次(后者会让真挂死的请求也拖住整轮)。
// degraded),**绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则; //
// - 已知代价:llama.cpp 首次加载模型可能超过 5s,那一次调用会失败并降级,模型 // 冷启动另有 warmupTimeout(见 budget.go),但它**只放宽本进程的第一个请求**、
// 常驻后的下一次调用即恢复正常。降级本就是既有行为,因此可接受。 // 且一次性花掉:失败不会让它续期,因此「服务一直挂死」这件事**不会**变成
// 「每次调用都慢」—— 第二个请求起就回到本常量。
const DefaultRequestTimeout = 5 * time.Second const DefaultRequestTimeout = 5 * time.Second
// openAICompatibleRequest is the JSON body sent to an OpenAI-compatible // openAICompatibleRequest is the JSON body sent to an OpenAI-compatible
@@ -93,6 +95,11 @@ type APIEmbedder struct {
cfg config.EmbeddingConfig cfg config.EmbeddingConfig
client *http.Client client *http.Client
log *slog.Logger log *slog.Logger
// warmupLeft 是**一次性**冷启动放宽的开关:为真时下一个请求用 warmupTimeout,
// 取走即置假(CompareAndSwap ⇒ 并发下也只有一个请求能拿到)。
// 它**不是**「未成功就一直放宽」—— 失败也照样花掉这一次,因此服务挂死时
// 只会慢一次,之后每个请求都按 DefaultRequestTimeout 快速失败(见 budget.go)。
warmupLeft atomic.Bool
mu sync.RWMutex mu sync.RWMutex
dim int dim int
@@ -131,12 +138,16 @@ func WithLogger(l *slog.Logger) APIOption {
} }
// NewAPI builds an APIEmbedder from cfg using the default HTTP client. // NewAPI builds an APIEmbedder from cfg using the default HTTP client.
// 单次超时不可由选项更换:它是 budget.go 那条推演的输入,批次上限与它同源,
// 因此**只允许改常量**(改了之后批次上限自动跟着改,测试的编译期/常量断言立刻复核)。
func NewAPI(cfg config.EmbeddingConfig, opts ...APIOption) *APIEmbedder { func NewAPI(cfg config.EmbeddingConfig, opts ...APIOption) *APIEmbedder {
e := &APIEmbedder{ e := &APIEmbedder{
cfg: cfg, cfg: cfg,
client: defaultHTTPClient(), client: defaultHTTPClient(),
log: slog.New(slog.NewTextHandler(io.Discard, nil)), log: slog.New(slog.NewTextHandler(io.Discard, nil)),
} }
// 冷启动放宽是一个**一次性**许可:见 APIEmbedder.warmupLeft。
e.warmupLeft.Store(true)
for _, opt := range opts { for _, opt := range opts {
opt(e) opt(e)
} }
@@ -155,8 +166,10 @@ func (e *APIEmbedder) Dim() int {
// returned vectors aligned by index. It returns ErrNoAPIURL for a blank URL // returned vectors aligned by index. It returns ErrNoAPIURL for a blank URL
// and a wrapped error for any HTTP or decode failure. // and a wrapped error for any HTTP or decode failure.
// //
// 单次调用带独立的、比上层审查总预算(20s)短得多的超时(DefaultRequestTimeout), // texts 长度不受调用方控制:本方法按 budget.go 推出来的上限(条数 / 字符总量)
// 卡住即快速失败,把预算留给后续步骤;失败语义与降级口径不变。 // **自己切片**,分多次请求顺序发出,因此「一次请求的耗时 < 单次超时」这条不变式
// 对任何调用方都成立 —— 调用方给多大的切片都不会把服务端顶住。中途任一段失败即
// 整体失败(返回已发请求的错误),绝不半途改用混合表示。
func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, error) { func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, error) {
if len(texts) == 0 { if len(texts) == 0 {
return []Vector{}, nil return []Vector{}, nil
@@ -168,7 +181,40 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro
return nil, ErrNoAPIURL return nil, ErrNoAPIURL
} }
callCtx, cancel := context.WithTimeout(ctx, DefaultRequestTimeout) // 切片用的上限由**稳态**超时推出(budget.go 的 DefaultRequestTimeout):
// 冷启动那一次只放宽**截止时刻**,批形状不变 —— 否则首个请求会按更宽的预算
// 切出一个巨大的批,正好把冷启动的模型顶得更久。
items, chars := batchLimitsFor(DefaultRequestTimeout)
out := make([]Vector, 0, len(texts))
for _, chunk := range chunkTexts(texts, items, chars) {
vectors, err := e.embedChunk(ctx, chunk)
if err != nil {
return nil, err
}
out = append(out, vectors...)
}
return out, nil
}
// callTimeout 返回本次请求该用的单次超时,并**当场花掉**一次性冷启动放宽许可
// (成功、失败都算花掉)。因此:
// - 本进程的第一个请求最多等 warmupTimeout(冷启动),
// - 从第二个请求起一律 DefaultRequestTimeout —— 服务挂死时不会每次都慢。
//
// 返回值 relaxed 表示这一次是不是拿到了放宽,供失败时留痕。
func (e *APIEmbedder) callTimeout() (timeout time.Duration, relaxed bool) {
if e.warmupLeft.CompareAndSwap(true, false) {
return warmupTimeout, true
}
return DefaultRequestTimeout, false
}
// embedChunk 发**一次** /embeddings 请求。chunk 必须已经落在 batchLimitsFor 推出来
// 的边界内(Embed 负责切),因此它的耗时上界不超过单次超时的一半。
func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector, error) {
// 先取超时(取走即花掉那次放宽),再建 ctx:放宽许可与这次请求一一对应。
timeout, relaxed := e.callTimeout()
callCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
req, err := e.newEmbeddingRequest(callCtx, texts) req, err := e.newEmbeddingRequest(callCtx, texts)
@@ -178,6 +224,16 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro
resp, err := e.client.Do(req) resp, err := e.client.Do(req)
if err != nil { if err != nil {
// 冷启动放宽被花掉且没成功时留一条 WARN:它是「本进程第一次请求」这个
// 判断唯一能被事后复核的地方(此后一切失败都按常态超时快速失败)。
// 只记两个预算与错误(错误文本里已带端点,不另记 URL,避免重复落一条
// 可能含 userinfo 的地址)。
if relaxed {
e.log.Warn("embedding: one-shot cold-start allowance spent without a successful response",
"warmup_timeout", warmupTimeout.String(),
"steady_timeout", DefaultRequestTimeout.String(),
"error", err)
}
return nil, fmt.Errorf("embedding: request: %w", err) return nil, fmt.Errorf("embedding: request: %w", err)
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
+24 -2
View File
@@ -222,6 +222,11 @@ const reviewBudgetRef = 20 * time.Second
// 审查总预算:否则一次卡死的 embedding 连接就能吃光整条审查的预算,排在后面的 // 审查总预算:否则一次卡死的 embedding 连接就能吃光整条审查的预算,排在后面的
// T1 灰区判定连执行机会都没有(2026-10-07 实测故障形态)。同时验证该超时确实 // T1 灰区判定连执行机会都没有(2026-10-07 实测故障形态)。同时验证该超时确实
// 打在了发出去的请求上。 // 打在了发出去的请求上。
//
// 🔴 冷启动放宽(warmupTimeout)必须一并守住同一条界:它只发生一次,但它那一次的
// 等待**也**不能吃光整轮审查 —— 否则「一次挂死 = 整轮审查没有预算给 T1」这个形态
// 会从常态超时挪到放宽预算上原样复现(reviewer round1 的 p1)。因此这里对
// warmupTimeout 做与 DefaultRequestTimeout **同一条**断言。
func TestDefaultRequestTimeout(t *testing.T) { func TestDefaultRequestTimeout(t *testing.T) {
t.Parallel() t.Parallel()
@@ -236,6 +241,19 @@ func TestDefaultRequestTimeout(t *testing.T) {
t.Fatalf("DefaultRequestTimeout = %s, want 5s(值变了要连带复核审查预算口径)", t.Fatalf("DefaultRequestTimeout = %s, want 5s(值变了要连带复核审查预算口径)",
DefaultRequestTimeout) DefaultRequestTimeout)
} }
// 冷启动放宽的两条界:仍在常态超时的**同一量级**(不得成倍放大),
// 且严格小于审查总预算(一次冷启动不许吃光整轮)。
if warmupTimeout <= 0 {
t.Fatalf("warmupTimeout = %s, 必须为正", warmupTimeout)
}
if warmupTimeout > 2*DefaultRequestTimeout {
t.Fatalf("冷启动放宽 %s 超过常态超时 %s 的两倍 —— 它必须是同一量级的一次性放宽",
warmupTimeout, DefaultRequestTimeout)
}
if warmupTimeout >= reviewBudgetRef {
t.Fatalf("冷启动放宽 %s 必须严格小于审查总预算 %s(否则一次冷启动就吃光整轮审查)",
warmupTimeout, reviewBudgetRef)
}
var slack time.Duration var slack time.Duration
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -256,8 +274,12 @@ func TestDefaultRequestTimeout(t *testing.T) {
config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"}, config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt), WithRoundTripper(rt),
) )
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil { // 第一次是冷启动调用(warmupTimeout),结果由它自己的测试断言;这里连发两次,
t.Fatalf("Embed() error: %v", err) // 只看稳态那一次打出去的 deadline。
for i := 0; i < 2; i++ {
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("第 %d 次 Embed() error: %v", i+1, err)
}
} }
if slack <= 0 || slack > DefaultRequestTimeout { if slack <= 0 || slack > DefaultRequestTimeout {
t.Fatalf("请求 deadline 余量 = %s, want (0, %s]", slack, DefaultRequestTimeout) t.Fatalf("请求 deadline 余量 = %s, want (0, %s]", slack, DefaultRequestTimeout)
+145
View File
@@ -0,0 +1,145 @@
package embedding
import "time"
// 本文件是**单次 /embeddings 调用的预算推演**:批次上限与单次超时必须由**同一个
// 式子**得出。任一侧单独调整都会让「一次请求的实测耗时 < 单次超时」这条不变式
// 静默失效 —— 而失效的后果不是报错,是**服务端还在算、客户端已经放弃**:被放弃的
// 那一批仍占着本机 llama.cpp 的算力,后面每一个请求(包括写索引时的单条请求)都
// 排在它后面等到自己的超时,于是整条记忆写入路径被拖住、召回全线降级。
//
// ## 不变式
//
// requestOverhead + 一次请求的字符总量 × perCharBudget ≤ DefaultRequestTimeout / callBudgetShare
//
// 左半是**推演出来的耗时上界**,右半是该超时下允许用掉的一半(另一半是这台机器的
// 抖动余量)。批次上限由解这个不等式得到(见 DefaultBatchLimit / DefaultBatchChars)。
//
// ⚠️ 这条不等式**只按稳态超时(DefaultRequestTimeout)推导,且只约束批次形状**;
// 冷启动那次放宽(warmupTimeout)**不改批形状**,只放宽进程第一个请求的**截止
// 时刻**,而且只发生一次(见 warmupTimeout 的说明)。批形状与超时仍然同源:
// 无论第一个请求的截止多宽,它带的条数/字符数永远按稳态超时算出来。
//
// ## 实测依据(2026-10-13,本机 = AMD RX 6600 XT + llama.cpp b11371 +
// Qwen3-Embedding-0.6B Q8_0,端点 127.0.0.1:11434,**服务器空闲**,正文取自活库
// data/cyrene.db:短正文约 33 汉字、长正文约 168 汉字;每档 5~6 次取最坏)
//
// n=1 短 0.066~0.185s 长 0.740s
// n=8 短 0.86~1.11s
// n=12 短 1.27s 长 5.47s
// n=16 短 2.20~3.31s
// n=64 短 7.49~8.49s(改前的批次上限)
//
// 拟合:单次请求耗时 ≈ 固定开销 + 字符总量 × 每字符成本;固定开销实测约 50ms,
// 每字符成本实测 2.7~3.9ms。**同一批 n≥32 起出现过数倍放大**(8.97s / 26.6s),
// 因此大批次的耗时不可预测 —— 这也是「余量取一半」而不是「卡着边界」的依据。
//
// 复跑命令(读活库正文、只连本机 embedding 服务、不写任何库):
//
// python - <<'PY'
// import json,sqlite3,time,urllib.request
// URL="http://127.0.0.1:11434/v1/embeddings"; MODEL="<model id>"
// t=[r[0] for r in sqlite3.connect("data/cyrene.db").execute(
// "select content from memory_items order by id limit 64")]
// for b in (t[:1],t[:8],t[:12],t[:16],t[:64]):
// body=json.dumps({"model":MODEL,"input":b}).encode()
// r=urllib.request.Request(URL,data=body,headers={"Content-Type":"application/json"})
// s=time.perf_counter(); urllib.request.urlopen(r,timeout=300).read()
// print(len(b), round(time.perf_counter()-s,3))
// PY
const (
// requestOverhead 是单次 HTTP 调用的固定开销上界(实测 ~50ms,向上取整)。
requestOverhead = 100 * time.Millisecond
// perCharBudget 是**每字符**的墙钟预算上界(实测 2.7~3.9 ms/字符,取 5ms)。
perCharBudget = 5 * time.Millisecond
// perItemBudget 是**每条文本**的墙钟预算上界:按活库里最长的正文(168 汉字,
// 最坏摊销 0.456 s/条)向上取整到 500ms。
perItemBudget = 500 * time.Millisecond
// callBudgetShare 表示一次请求最多用掉超时的 1/2:另一半是抖动余量
// (见上面 n≥32 的数倍放大)。调大它 = 余量变小,必须连带复核实测。
callBudgetShare = 2
// DefaultBatchLimit 是单次请求的**条数**上限,由上面三个常量推出来(唯一推演):
// (5s/2 − 100ms) / 500ms = 4。
DefaultBatchLimit = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perItemBudget)
// DefaultBatchChars 是单次请求的**字符总量**上限,同一个不等式换成每字符预算:
// (5s/2 − 100ms) / 5ms = 480。它防的是「条数很少但每条很长」:
// 4 条 × 168 汉字 = 672 字符已越过它,会被再切一刀。
DefaultBatchChars = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perCharBudget)
// warmupTimeout 是**一次性的**冷启动放宽:本 embedder 生命周期里的**第一个**
// 请求可以拿到这个截止(模型加载 / GPU 缓冲分配还没发生过),此后**每一个**
// 请求(含刚失败的那个之后的下一个)一律回到 DefaultRequestTimeout。
//
// 🔴 它**不是**「只要还没成功过就一直放宽」—— 那个口径会在服务挂死时把
// fail-fast 变成 fail-slow(每次都等 warmupTimeout),并把审查的 20s 预算、
// 索引写入的 5s 预算整段吃掉。一次性 + 8s 两条一起才是有界的:
// - 一次性:放宽最多花掉**一个**请求的时长,最坏情形之后立刻回到 5s;
// - 8s:与常态 5s **同一量级**(1.6×),且严格小于审查单条总预算 20s
// (memory.defaultReviewTimeout)⇒ 一次冷启动不可能吃光整轮审查,
// 后面的 T1 灰区判定(8s)仍有预算。这条不等式由测试守住
// (api_url_test.go 的 TestDefaultRequestTimeout)。
//
// 依据:2026-10-13 部署后第一分钟的生产日志里有一条
// `vector embed for index failed ... embed 1 texts ... context deadline exceeded`
// —— 模型还没热时 5s 不够;而稳态实测单条只要 0.03~0.19s(见上面的曲线),
// 所以放宽只需要覆盖「进程的第一个请求」这一刻。
warmupTimeout = 8 * time.Second
)
// callBudget 返回在 timeout 这个单次超时下,一次请求允许用掉的墙钟预算。
func callBudget(timeout time.Duration) time.Duration {
if timeout <= 0 {
timeout = DefaultRequestTimeout
}
return timeout/callBudgetShare - requestOverhead
}
// batchLimitsFor 由**同一个超时**推出一对批次上限(条数 / 字符总量)。
// 它保证:按 (条数 ≤ items 且 字符总量 ≤ chars) 切出来的任何一段,其耗时上界
// 都不超过该超时的一半。两者都至少为 1 —— 单条文本本身越界时独占一段,
// 正文**绝不截断**(截断会让向量描述的不是这条记忆)。
func batchLimitsFor(timeout time.Duration) (items, chars int) {
budget := callBudget(timeout)
items = int(budget / perItemBudget)
if items < 1 {
items = 1
}
chars = int(budget / perCharBudget)
if chars < 1 {
chars = 1
}
return items, chars
}
// chunkTexts 按「条数 ≤ maxItems 且 字符总量 ≤ maxChars」把 texts 切成若干段。
// 单条文本的 rune 数超过 maxChars 时它独占一段(见 batchLimitsFor 的口径)。
// 空输入返回 nil。
func chunkTexts(texts []string, maxItems, maxChars int) [][]string {
if len(texts) == 0 {
return nil
}
if maxItems < 1 {
maxItems = 1
}
if maxChars < 1 {
maxChars = 1
}
var out [][]string
start := 0
chars := 0
for i, text := range texts {
n := len([]rune(text))
// 段内已有内容,再加这一条就越界 ⇒ 先收口,本段从这一条重新开始。
if i > start && (i-start >= maxItems || chars+n > maxChars) {
out = append(out, texts[start:i])
start, chars = i, 0
}
chars += n
}
return append(out, texts[start:])
}
+393
View File
@@ -0,0 +1,393 @@
package embedding
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件把 budget.go 的**不变式**钉成可执行的断言,两态各一条:
//
// - 「批内耗时 < 超时」⇒ 不降级:DerivedBatchCompletesWithinTimeout;
// - 「批内耗时 ≥ 超时」⇒ 超时(本次故障形态):LegacyBatchOf64TimesOut;
//
// 以及**变异自证**:把批上限调回旧值 64(或把推演调松)会让哪条测试红,
// 见 TestBatchBudget_InvariantHolds 与 TestAPIEmbedder_LegacyBatchOf64TimesOut。
//
// 假客户端用的是**实测曲线**(不是随口编的常数):单次请求耗时
// ≈ measuredOverhead + 字符总量 × measuredPerChar,取值见下面两个常量
// (2026-10-13 本机实测:固定开销 ~50ms、每字符 2.7~3.9ms,此处取上界)。
const (
measuredOverhead = 50 * time.Millisecond
measuredPerChar = 4 * time.Millisecond
latencyModelChars = 33 // 活库短正文的平均长度(实测 33 汉字)
)
// latencyTripper 是一个按上面那条曲线**真的睡够时间**的 RoundTripper:
// 它模拟「服务端会算完,不管你客户端还在不在等」。这正是本次故障的形态 ——
// 客户端 5s 放弃、服务端还在算,后面每个请求都排在它后面。
type latencyTripper struct {
mu sync.Mutex
requests int
maxItems int
maxChars int
// abandoned 计数「客户端先放弃、服务端还在算」的次数 —— 本次故障的形态。
// 修好之后它必须是 0(见 TestAPIEmbedder_OversizedBatchIsSplit 的断言)。
abandoned int
// firstSleep 非零时,第一次请求额外多睡这么久(模拟冷启动的模型加载)。
firstSleep time.Duration
}
func (l *latencyTripper) RoundTrip(r *http.Request) (*http.Response, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
var req openAICompatibleRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
chars := 0
for _, s := range req.Input {
chars += len([]rune(s))
}
l.mu.Lock()
l.requests++
if len(req.Input) > l.maxItems {
l.maxItems = len(req.Input)
}
if chars > l.maxChars {
l.maxChars = chars
}
first := l.requests == 1
extra := l.firstSleep
l.mu.Unlock()
wait := measuredOverhead + time.Duration(chars)*measuredPerChar
if first && extra > 0 {
wait += extra
}
select {
case <-time.After(wait):
case <-r.Context().Done():
// 客户端放弃了:服务端那一侧不会因为客户端走了就把已排队的算力还回来
// (实测的级联堆积正是从这一步开始的),因此这里明确记一笔。
l.mu.Lock()
l.abandoned++
l.mu.Unlock()
return nil, r.Context().Err()
}
resp := openAICompatibleResponse{Data: make([]openAICompatibleDatum, 0, len(req.Input))}
for i := range req.Input {
resp.Data = append(resp.Data, openAICompatibleDatum{Index: i, Embedding: []float32{1, 0}})
}
raw, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(string(raw))),
Header: make(http.Header),
}, nil
}
// shortTexts 造 n 条与活库短正文等长的文本。
func shortTexts(n int) []string {
out := make([]string, n)
line := strings.Repeat("河", latencyModelChars)
for i := range out {
out[i] = line
}
return out
}
// TestBatchBudget_InvariantHolds 是**推演本身**的断言(不连任何网络):
// 由超时推出来的一对上限,必须使「一次请求的耗时上界 ≤ 超时的一半」成立。
//
// 变异自证:把 DefaultBatchLimit 的推演改成旧口径(条数写回 64)⇒ 第一条断言红;
// 若改成「把超时放大到盖住 64 条」⇒ 最后一条断言红(旧配对必须仍是越界的)。
func TestBatchBudget_InvariantHolds(t *testing.T) {
t.Parallel()
if DefaultBatchLimit < 1 || DefaultBatchChars < 1 {
t.Fatalf("推出来的上限必须 ≥ 1:条数=%d 字符=%d", DefaultBatchLimit, DefaultBatchChars)
}
share := DefaultRequestTimeout / callBudgetShare
byItems := requestOverhead + time.Duration(DefaultBatchLimit)*perItemBudget
if byItems > share {
t.Fatalf("条数上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
DefaultBatchLimit, byItems, share)
}
byChars := requestOverhead + time.Duration(DefaultBatchChars)*perCharBudget
if byChars > share {
t.Fatalf("字符上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
DefaultBatchChars, byChars, share)
}
// 旧配对(批次 64 + 5s 超时)必须仍被判为越界 —— 那正是 2026-10-13 的故障形态:
// 实测 64 条 7.5~8.5s > 5s。这里用同一套预算常量复核它仍然出界。
legacy := requestOverhead + 64*perItemBudget
if legacy <= DefaultRequestTimeout {
t.Fatalf("旧配对(64 条)的耗时上界 %s 竟然落在单次超时 %s 之内 —— "+
"说明超时被放大到迁就批次了,本次修法不允许", legacy, DefaultRequestTimeout)
}
}
// TestChunkTexts_RespectsBothLimits 钉住切片:条数与字符总量两道界都要守,
// 单条本身越界的文本独占一段且**不被截断**。
func TestChunkTexts_RespectsBothLimits(t *testing.T) {
t.Parallel()
got := chunkTexts(shortTexts(9), 2, 100)
if len(got) != 5 {
t.Fatalf("2 条/段 切 9 条 = %d 段,want 5", len(got))
}
for i, seg := range got {
if len(seg) > 2 {
t.Fatalf("第 %d 段有 %d 条,超过条数上限 2", i, len(seg))
}
}
// 字符界绑定:4 条 33 字符 = 132 字符 ⇒ 100 字符/段时必须收口(3 条 = 99 字符满,
// 余下 1 条自成一段)。
byChars := chunkTexts(shortTexts(4), 64, 100)
if len(byChars) != 2 {
t.Fatalf("100 字符/段 切 4×33 字符 = %d 段,want 2", len(byChars))
}
for i, seg := range byChars {
total := 0
for _, s := range seg {
total += len([]rune(s))
}
if total > 100 {
t.Fatalf("第 %d 段 %d 字符,超过字符上限 100", i, total)
}
}
// 超长单条:不切碎、不截断,独占一段。
huge := strings.Repeat("长", 500)
segs := chunkTexts([]string{huge, "短"}, 64, 100)
if len(segs) != 2 || segs[0][0] != huge {
t.Fatalf("超长单条应独占一段且不改写,got %d 段", len(segs))
}
if chunkTexts(nil, 4, 100) != nil {
t.Fatal("空输入应返回 nil")
}
}
// TestAPIEmbedder_DerivedBatchCompletesWithinTimeout 钉**不降级态**:
// 按推出来的上限发一次(Embed 内部切成 1 段),在实测曲线下必须在单次超时内拿到向量。
func TestAPIEmbedder_DerivedBatchCompletesWithinTimeout(t *testing.T) {
t.Parallel()
rt := &latencyTripper{}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
texts := shortTexts(DefaultBatchLimit)
vectors, err := emb.Embed(context.Background(), texts)
if err != nil {
t.Fatalf("上限内的批不该超时:%v", err)
}
if len(vectors) != len(texts) {
t.Fatalf("got %d vectors, want %d", len(vectors), len(texts))
}
if rt.requests != 1 {
t.Fatalf("上限内的批应发 1 次请求,实际 %d 次", rt.requests)
}
}
// TestAPIEmbedder_OversizedBatchIsSplit 钉**不变式与调用方无关**:
// 调用方给 3 倍上限的切片时,Embed 自己切成多段、每段都在界内,向量顺序与入参一致。
func TestAPIEmbedder_OversizedBatchIsSplit(t *testing.T) {
t.Parallel()
rt := &latencyTripper{}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
texts := shortTexts(DefaultBatchLimit * 3)
vectors, err := emb.Embed(context.Background(), texts)
if err != nil {
t.Fatalf("超大切片应被切开而不是超时:%v", err)
}
if len(vectors) != len(texts) {
t.Fatalf("got %d vectors, want %d(切开后必须按序拼回)", len(vectors), len(texts))
}
if rt.requests < 3 {
t.Fatalf("3 倍上限应至少切成 3 段,实际 %d 段", rt.requests)
}
if rt.maxItems > DefaultBatchLimit {
t.Fatalf("有请求带了 %d 条,超过推出来的条数上限 %d", rt.maxItems, DefaultBatchLimit)
}
if rt.maxChars > DefaultBatchChars {
t.Fatalf("有请求带了 %d 字符,超过推出来的字符上限 %d", rt.maxChars, DefaultBatchChars)
}
// 🔴 本次修复的核心不变式,确定性断言(不依赖机器负载):
// 没有任何一次请求发生「客户端先放弃、服务端还在算」——那正是级联堆积的起点。
if rt.abandoned != 0 {
t.Fatalf("有 %d 次请求被客户端先放弃(服务端还在算)⇒ 不变式没守住", rt.abandoned)
}
// 服务端侧的同一条不变式:任何一次请求的建模耗时(实测曲线)都落在单次超时内。
if cost := measuredOverhead + time.Duration(rt.maxChars)*measuredPerChar; cost > DefaultRequestTimeout {
t.Fatalf("最大一次请求的实测曲线耗时 %s 越过单次超时 %s", cost, DefaultRequestTimeout)
}
}
// TestAPIEmbedder_LegacyBatchOf64TimesOut 是**变异自证**的另一半:同样是实测曲线,
// 旧配对(一次请求 64 条 + 5s 超时)必须**超时**。它证明把上限写回 64 不是「保守」,而是
// 必然把客户端与服务端撕裂(客户端走了、服务端还在算 ⇒ 后续请求全排队)。
func TestAPIEmbedder_LegacyBatchOf64TimesOut(t *testing.T) {
t.Parallel()
rt := &latencyTripper{}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
// 先把一次性冷启动放宽花掉,否则第一次调用按 warmupTimeout 算、不会超时。
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
t.Fatalf("预热调用失败:%v", err)
}
start := time.Now()
_, err := emb.embedChunk(context.Background(), shortTexts(64))
elapsed := time.Since(start)
if err == nil {
t.Fatalf("旧配对(64 条 + %s 超时)在实测曲线下不该成功", DefaultRequestTimeout)
}
if elapsed > DefaultRequestTimeout+2*time.Second {
t.Fatalf("超时保护没生效:等了 %s", elapsed)
}
}
// TestAPIEmbedder_WarmupIsOneShot 钉 p1 的核心:冷启动放宽**一次性**,三种失败形态
// 下都必须是「第一次最多等 warmupTimeout,第二次起一律 DefaultRequestTimeout」。
//
// 这是 reviewer 在 round1 打回的那条:旧实现 `warmed` 只在 2xx 之后才翻,于是
// 「服务接受连接但永不回包」时**每一次**调用都等完整放宽预算(30s),把 fail-fast
// 变成 fail-slow —— 一次挂死就能吃光审查的 20s 预算、把索引写入拖成 8×30s。
func TestAPIEmbedder_WarmupIsOneShot(t *testing.T) {
t.Parallel()
cases := []struct {
name string
rt func(t *testing.T) http.RoundTripper
}{
{
name: "连接被拒",
rt: func(t *testing.T) http.RoundTripper {
return roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("dial tcp 127.0.0.1:11434: connectex: 目标计算机积极拒绝")
})
},
},
{
name: "HTTP 500",
rt: func(t *testing.T) http.RoundTripper {
return roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader("server exploded")),
Header: make(http.Header),
}, nil
})
},
},
{
name: "接受连接但永不回包",
rt: func(t *testing.T) http.RoundTripper {
return roundTripFunc(func(r *http.Request) (*http.Response, error) {
<-r.Context().Done() // 只等服务端超时,永不回包
return nil, r.Context().Err()
})
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(tc.rt(t)))
// 第一次:放宽预算。断言上界 = warmupTimeout(不得再宽),
// 下界只对会真正等到截止的那一种(挂死)有意义。
start := time.Now()
_, err := emb.Embed(context.Background(), shortTexts(1))
first := time.Since(start)
if err == nil {
t.Fatal("这一组失败形态不该成功")
}
t.Logf("首次耗时 = %s", first.Round(time.Millisecond))
if first > warmupTimeout+time.Second {
t.Fatalf("首次耗时 %s 超过冷启动放宽 %s", first, warmupTimeout)
}
// 第二次:**必须**回到常态超时 —— 一次挂死绝不许变成每次都慢。
start = time.Now()
if _, err := emb.Embed(context.Background(), shortTexts(1)); err == nil {
t.Fatal("这一组失败形态不该成功")
}
second := time.Since(start)
t.Logf("第二次耗时 = %s(常态超时 %s)", second.Round(time.Millisecond), DefaultRequestTimeout)
if second > DefaultRequestTimeout+time.Second {
t.Fatalf("第二次耗时 %s 超过常态超时 %s ⇒ 放宽成了长期的", second, DefaultRequestTimeout)
}
// 放宽许可必须已经被花掉(它是一次性的,任何路径都不许给它续期)。
if emb.warmupLeft.Load() {
t.Fatal("第二次请求之后放宽许可仍为真 —— 一次性的约束被绕过了")
}
})
}
}
// TestAPIEmbedder_WarmupCoversColdStartOnce 钉放宽的**正面**作用:第一次请求
// (模型加载 / GPU 缓冲分配)可以比常态超时多等一点,只要它落在 warmupTimeout
// 之内;而这一刻过去之后,请求立刻回到常态超时(deadline 断言)。
func TestAPIEmbedder_WarmupCoversColdStartOnce(t *testing.T) {
t.Parallel()
rt := &latencyTripper{firstSleep: DefaultRequestTimeout + time.Second}
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt))
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
t.Fatalf("冷启动第一次调用应被 warmupTimeout(%s) 兜住:%v", warmupTimeout, err)
}
if emb.warmupLeft.Load() {
t.Fatal("放宽许可应在第一次请求时就被花掉")
}
var slack time.Duration
steady := roundTripFunc(func(r *http.Request) (*http.Response, error) {
dl, ok := r.Context().Deadline()
if !ok {
t.Error("稳态请求没有 deadline")
} else {
slack = time.Until(dl)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[1]}]}`)),
Header: make(http.Header),
}, nil
})
emb.client = &http.Client{Transport: steady}
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
t.Fatalf("稳态调用失败:%v", err)
}
if slack <= 0 || slack > DefaultRequestTimeout {
t.Fatalf("稳态单次超时余量 = %s, want (0, %s]", slack, DefaultRequestTimeout)
}
}
+14 -5
View File
@@ -73,6 +73,11 @@ func (s *Store) AddScoped(ctx context.Context, actorID string, item MemoryItem)
// 作用域上也始终声明身份边界。是否允许受限上下文(群聊 / 陌生人,design §5) // 作用域上也始终声明身份边界。是否允许受限上下文(群聊 / 陌生人,design §5)
// 接收这些内容仍由更高层策略决定;本方法只施加 // 接收这些内容仍由更高层策略决定;本方法只施加
// 基础读规则。 // 基础读规则。
//
// 与 ListByPersona/List 同口径:只返回 status='active' 且过**时效闸**
// (expires_at / valid_to 均未到期)的行 —— 闸只有这里与 ListByPersona /
// ListProject / List 四处,检索路径经 Retriever.visibleCandidates 组合调用
// 本方法,因此自动获得同一判据(timegate.go 头注、design/03 §11.2)。
func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([]MemoryItem, error) { func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([]MemoryItem, error) {
if strings.TrimSpace(personaID) == "" { if strings.TrimSpace(personaID) == "" {
return nil, ErrMissingPersona return nil, ErrMissingPersona
@@ -80,10 +85,12 @@ func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([
if !scope.valid() { if !scope.valid() {
return nil, ErrInvalidScope return nil, ErrInvalidScope
} }
now := timeGateNow()
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE scope = ? AND status = ? decay_weight FROM memory_items WHERE scope = ? AND status = ?`+timeGateClause+`
ORDER BY created_at DESC, rowid DESC`, string(scope), string(tidyStatusActive)) ORDER BY created_at DESC, rowid DESC`,
string(scope), string(tidyStatusActive), now, now)
if err != nil { if err != nil {
return nil, fmt.Errorf("memory: list scope items: %w", err) return nil, fmt.Errorf("memory: list scope items: %w", err)
} }
@@ -96,15 +103,17 @@ func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([
// 层级(design §3);这是深检索入口 —— 调用方把它与 // 层级(design §3);这是深检索入口 —— 调用方把它与
// projectindex 存储结合,沿 项目索引 → 项目描述 → 章节内容 遍历。项目 // projectindex 存储结合,沿 项目索引 → 项目描述 → 章节内容 遍历。项目
// 记忆所有角色可读,因此不施加角色过滤。projectID 为空 // 记忆所有角色可读,因此不施加角色过滤。projectID 为空
// 返回 ErrMissingProjectID。 // 返回 ErrMissingProjectID。与其余三条列表读路径同口径:只返回在册且过
// **时效闸**的行(timegate.go、design/03 §9.6/§11.2)。
func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) { func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) {
if strings.TrimSpace(projectID) == "" { if strings.TrimSpace(projectID) == "" {
return nil, ErrMissingProjectID return nil, ErrMissingProjectID
} }
now := timeGateNow()
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE project_id = ? AND status = ? decay_weight FROM memory_items WHERE project_id = ? AND status = ?`+timeGateClause+`
ORDER BY created_at DESC, rowid DESC`, projectID, string(tidyStatusActive)) ORDER BY created_at DESC, rowid DESC`, projectID, string(tidyStatusActive), now, now)
if err != nil { if err != nil {
return nil, fmt.Errorf("memory: list project items: %w", err) return nil, fmt.Errorf("memory: list project items: %w", err)
} }
+39 -7
View File
@@ -108,6 +108,16 @@ type MemoryItem struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
DecayWeight float64 DecayWeight float64
// ExpiresAt 是**临时记忆**的有效期末(design/03 §9.6、design/23 §18.1)。
// 零值 = 不设过期,落库为空串,四条列表读路径恒可见;非零值落库为定宽 UTC
// 文本,到期后该行即从读路径消失,直到后台清扫物理删除(design/23 §19)。
//
// ⚠️ **本字段目前只写不读**:Add 会把它写进 expires_at 列,但读路径一律不
// SELECT 该列(闸是纯 SQL,见 timegate.go),故 Get/List* 回来的条目
// ExpiresAt 恒为零值。要读回时效需同步 13 列 SELECT 与 scanItem,并连带
// tidy/preinsert 的内部召回 SQL —— 那是改扫描口径,不在本切片授权范围。
ExpiresAt time.Time
} }
// 区分 memory 包各类失败的哨兵错误。每个都在 // 区分 memory 包各类失败的哨兵错误。每个都在
@@ -231,6 +241,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
// 关系表里,语义增强的失败绝不使记忆写入失败,检索侧也会回退到 // 关系表里,语义增强的失败绝不使记忆写入失败,检索侧也会回退到
// 内存余弦(见 Retriever.semanticScores)。被闸判为重复的行**不建索引**: // 内存余弦(见 Retriever.semanticScores)。被闸判为重复的行**不建索引**:
// 它不进召回、也不该占掉语义检索 top-K 的一个位置。 // 它不进召回、也不该占掉语义检索 top-K 的一个位置。
//
// `item.ExpiresAt`(临时记忆的时效,design/03 §9.6)同在这一处落库为
// `expires_at` 列:Add 是全仓唯一 INSERT 路径(design/23 §15.1 D3-1),
// 因此时效列的真生产者**只能**在这里 —— 读侧时效闸(timegate.go)比对的
// 就是这一列,若写入侧不写出它,闸就退化成恒真的空壳。
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) { func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
if err := item.normalize(); err != nil { if err := item.normalize(); err != nil {
return MemoryItem{}, err return MemoryItem{}, err
@@ -247,12 +262,13 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
plan := s.planIngest(ctx, item) plan := s.planIngest(ctx, item)
_, err = s.db.ExecContext(ctx, `INSERT INTO memory_items _, err = s.db.ExecContext(ctx, `INSERT INTO memory_items
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, (id, persona_id, role_id, project_id, scope, kind, content, tags, confidence,
source_conversation, created_at, updated_at, decay_weight, status, merged_into) source_conversation, created_at, updated_at, decay_weight, status, merged_into,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind), item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind),
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation, item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight, formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight,
plan.insertStatus(), plan.absorbedID()) plan.insertStatus(), plan.absorbedID(), expiryText(item.ExpiresAt))
if err != nil { if err != nil {
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err) return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
} }
@@ -276,6 +292,11 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
// Get 返回给定 id 的条目。id 为空或 // Get 返回给定 id 的条目。id 为空或
// 不存在时返回 ErrNotFound。 // 不存在时返回 ErrNotFound。
//
// 🔴 **刻意不过任何读侧闸**(status 与时效都不过;design/03 §11.2 第 3 条):
// 撤销软合并必须先读得到被吸收的行,人工核查 / 清算也要先读得到已过期与
// 已失效的行 —— 加闸等于把审计与撤销的抓手一并抹掉。要「面向用户的可见集」
// 请走 ListByPersona / ListScope / ListProject / List 或 Retriever(那些都过闸)。
func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) { func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
if strings.TrimSpace(id) == "" { if strings.TrimSpace(id) == "" {
return MemoryItem{}, ErrNotFound return MemoryItem{}, ErrNotFound
@@ -304,14 +325,21 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
// 只返回**在册**(status='active')条目:整理与入库前审查都只做软动作, // 只返回**在册**(status='active')条目:整理与入库前审查都只做软动作,
// 被合并/被归档的行内容原样留在表里,因此「能看到什么记忆」的每条读路径 // 被合并/被归档的行内容原样留在表里,因此「能看到什么记忆」的每条读路径
// 都必须过 status 闸,否则清理过的行照样会被检索到、照样占满面板。 // 都必须过 status 闸,否则清理过的行照样会被检索到、照样占满面板。
//
// 同时过**时效闸**(timeGateClause):已过期(expires_at ≤ now)与已失效
// (valid_to ≤ now)的行一并不再返回 —— 判据细节与「为什么必须下沉到这里」
// 见 timegate.go 与 design/03 §9.6/§11.2。
func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) { func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) {
if strings.TrimSpace(personaID) == "" { if strings.TrimSpace(personaID) == "" {
return nil, ErrMissingPersona return nil, ErrMissingPersona
} }
// 闸的左右操作数同源:两边都由 formatTime 产出定宽 UTC 文本。
now := timeGateNow()
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE persona_id = ? AND status = ? decay_weight FROM memory_items WHERE persona_id = ? AND status = ?`+timeGateClause+`
ORDER BY created_at DESC, rowid DESC`, personaID, string(tidyStatusActive)) ORDER BY created_at DESC, rowid DESC`,
personaID, string(tidyStatusActive), now, now)
if err != nil { if err != nil {
return nil, fmt.Errorf("memory: list persona items: %w", err) return nil, fmt.Errorf("memory: list persona items: %w", err)
} }
@@ -329,11 +357,15 @@ func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryIt
// 管理面板的记忆列表里(design/23 §8/§10 的软动作口径)。要看它们走 // 管理面板的记忆列表里(design/23 §8/§10 的软动作口径)。要看它们走
// 审查留痕 `GET /admin/memories/reviews` 或按 id 用 Get —— 后者刻意 // 审查留痕 `GET /admin/memories/reviews` 或按 id 用 Get —— 后者刻意
// 不过 status 闸,因为撤销软合并必须先读得到被吸收的行。 // 不过 status 闸,因为撤销软合并必须先读得到被吸收的行。
//
// 同样过**时效闸**:临时记忆到期(expires_at ≤ now)与事实失效
// (valid_to ≤ now)的行一并从这里消失(timegate.go、design/03 §9.6)。
func (s *Store) List(ctx context.Context) ([]MemoryItem, error) { func (s *Store) List(ctx context.Context) ([]MemoryItem, error) {
now := timeGateNow()
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items WHERE status = ? decay_weight FROM memory_items WHERE status = ?`+timeGateClause+`
ORDER BY created_at DESC, rowid DESC`, string(tidyStatusActive)) ORDER BY created_at DESC, rowid DESC`, string(tidyStatusActive), now, now)
if err != nil { if err != nil {
return nil, fmt.Errorf("memory: list items: %w", err) return nil, fmt.Errorf("memory: list items: %w", err)
} }
+23 -6
View File
@@ -119,12 +119,17 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
return stats, err return stats, err
} }
stats.RunID = runID stats.RunID = runID
// ledger 是**本轮实际发生**的召回来源记账口(不是配置里那一种):生产轮可以
// 多分区混合(有的走语义、有的降级),因此汇总行只能由实测记录推出。
ledger := &recallLedger{}
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{ failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) { sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
t.persistCandidates(ctx, runID, cands, stats) t.persistCandidates(ctx, runID, cands, stats)
}, },
recall: t.fallbackRecall, recall: func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
return t.fallbackRecall(ctx, p, items, stats, budget, ledger)
},
}) })
// 轮末自动落地(2026-10-07 主人口径):规则层判为高置信的待定候选直接落地, // 轮末自动落地(2026-10-07 主人口径):规则层判为高置信的待定候选直接落地,
@@ -150,7 +155,7 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
failures = append(failures, err) failures = append(failures, err)
} }
t.logRun(stats, run) t.logRun(stats, run, ledger)
t.auditAutoApply(auto) t.auditAutoApply(auto)
switch { switch {
@@ -165,7 +170,14 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
// logRun 输出本轮的可观测口径:扫描 N / 候选 M / 建议 K / 否决原因分布。 // logRun 输出本轮的可观测口径:扫描 N / 候选 M / 建议 K / 否决原因分布。
// 它带 source(memory/tidy),因此这条汇总永远可回溯到整理子系统。 // 它带 source(memory/tidy),因此这条汇总永远可回溯到整理子系统。
func (t *Tidier) logRun(stats TidyStats, run TidyRun) { //
// ledger 给出的是**本轮实际发生**的召回来源(不是配置态)。这一条是 M6 2026-10-13
// 的 medium finding 的落点:旧实现按「有没有配 embedder」写 recall_mode,全量降级
// 在汇总行里被写成 embedding。现在:
// - recall_mode = "":没有任何分区真正算过相似度(没有候选可比);
// - recall_mode = lexical ∧ recall_degraded_partitions > 0:语义不可用、降级了;
// - recall_mode = mixed:多分区里一部分走了语义、一部分降级 —— 不再被抹平。
func (t *Tidier) logRun(stats TidyStats, run TidyRun, ledger *recallLedger) {
fields := []any{ fields := []any{
"run_id", run.ID, "run_id", run.ID,
"rule_version", run.RuleVersion, "rule_version", run.RuleVersion,
@@ -181,7 +193,8 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
"kept", stats.Kept, "kept", stats.Kept,
"errors", run.Errors, "errors", run.Errors,
"duration_ms", run.FinishedAt.Sub(run.StartedAt).Milliseconds(), "duration_ms", run.FinishedAt.Sub(run.StartedAt).Milliseconds(),
"recall_mode", tidyRecallMode(t.deps.Embedder != nil), "recall_mode", ledger.label(),
"recall_degraded_partitions", ledger.degradedPartitions(),
"auto_apply", t.cfg.autoApplyEnabled(), "auto_apply", t.cfg.autoApplyEnabled(),
"auto_applied", stats.AutoApplied, "auto_applied", stats.AutoApplied,
"auto_merge", stats.AutoMerged, "auto_merge", stats.AutoMerged,
@@ -232,11 +245,15 @@ func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition,
// fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项 // fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项
// 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。 // 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。
// 两个来源**不可混用阈值**:阈值只有一个(design §13 开放点 2 的 0.80)。 // 两个来源**不可混用阈值**:阈值只有一个(design §13 开放点 2 的 0.80)。
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) { //
pairs, err := t.recallPairs(ctx, p, items, stats, budget) // 降级这一笔**必须记进 ledger**:汇总行/面板上的召回来源只能由实测记录推出,
// 否则「全量降级」会被报成「向量正常」(M6 2026-10-13 的 medium finding)。
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats, budget, ledger)
if err == nil { if err == nil {
return pairs, nil return pairs, nil
} }
ledger.noteLexical(true)
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical", t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
"partition", p.label(), "error", err) "partition", p.label(), "error", err)
return t.collectPairs(p, items, t.cfg.RecallThreshold, return t.collectPairs(p, items, t.cfg.RecallThreshold,
+17 -1
View File
@@ -4,6 +4,7 @@ import (
"time" "time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
) )
// 本文件收拢记忆整理(design/23-记忆整理与去重)全部的标识、默认值与旋钮, // 本文件收拢记忆整理(design/23-记忆整理与去重)全部的标识、默认值与旋钮,
@@ -74,7 +75,17 @@ const (
// tidyEmbedBatch 是单次 Embed 调用的文本条数上限,避免把整个分区的文本 // tidyEmbedBatch 是单次 Embed 调用的文本条数上限,避免把整个分区的文本
// 一次性交给远端 embedding API。 // 一次性交给远端 embedding API。
tidyEmbedBatch = 64 //
// 🔴 它**不是**拍出来的数字,而是 internal/runtime/embedding 里**同一条推演**
// 的结果(budget.go:一次请求的耗时上界 ≤ 单次超时的一半 ⇒ 批上限 = 超时的一半
// ÷ 单条预算上界)。2026-10-13:旧值 64 的实测耗时 7.5~8.5s 已越过客户端 5s
// 单次超时 ⇒ 客户端放弃、服务端继续算 ⇒ 本机 embedding 服务被顶住,单条请求
// (写向量索引)也跟着超时。把批上限与超时绑到同一条推演上,就不会再出现
// 「只调一侧、另一侧静默失效」。
//
// 批上限在 embedder 内部**还有一道强制**(Embed 自己切片),因此这里即使被改大
// 也不会把服务端顶住;这一行只是让调用方别去构造注定被切开的超大切片。
tidyEmbedBatch = embedding.DefaultBatchLimit
// minTidyContentRunes 是参与比对的最小内容长度(按 rune)。过短的内容 // minTidyContentRunes 是参与比对的最小内容长度(按 rune)。过短的内容
// (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。 // (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。
@@ -126,6 +137,11 @@ const (
maxTidyRejectBatchLimit = 10000 maxTidyRejectBatchLimit = 10000
) )
// 编译期不变式(2026-10-13):批上限一旦被调回超过 embedding 推出来的值
// (例:写回旧值 64),下面这行常量表达式会变成负数、uint 溢出 ⇒ **编译失败**。
// 这条断言把「批上限与超时同源」从注释里的承诺变成编译器守的门。
const _ = uint(embedding.DefaultBatchLimit - tidyEmbedBatch)
// TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值, // TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值,
// 因此半填的 Config 仍可用。 // 因此半填的 Config 仍可用。
type TidyConfig struct { type TidyConfig struct {
+34 -6
View File
@@ -52,11 +52,15 @@ type TidyDryRun struct {
// 「完整的建议」展示)。 // 「完整的建议」展示)。
func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) { func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
started := t.now() started := t.now()
out := TidyDryRun{RecallMode: tidyRecallMode(t.deps.Embedder != nil)} out := TidyDryRun{}
stats, partitions, err := t.beginRound(ctx) stats, partitions, err := t.beginRound(ctx)
if err != nil { if err != nil {
return out, err return out, err
} }
// ledger 是**本轮实际发生**的召回来源:试算会写进 RecallMode 返回给面板。
// 旧实现按「有没有配 embedder」写(tidyRecallMode(deps.Embedder != nil)),
// 那会让面板把「向量不可用」读成「向量正常」(M6 2026-10-13 的 medium finding)。
ledger := &recallLedger{}
// 收集出口:闭包只在 scanPartitions 的**串行**循环里被调用,因此无需加锁。 // 收集出口:闭包只在 scanPartitions 的**串行**循环里被调用,因此无需加锁。
collected := make([]TidyCandidate, 0) collected := make([]TidyCandidate, 0)
@@ -64,25 +68,38 @@ func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
sink: func(_ context.Context, cands []TidyCandidate, _ *TidyStats) { sink: func(_ context.Context, cands []TidyCandidate, _ *TidyStats) {
collected = append(collected, cands...) collected = append(collected, cands...)
}, },
recall: t.strictRecall, recall: func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
return t.strictRecall(ctx, p, items, stats, budget, ledger)
},
} }
failures := t.scanPartitions(ctx, partitions, &stats, policy) failures := t.scanPartitions(ctx, partitions, &stats, policy)
out.Stats = stats out.Stats = stats
out.Candidates = collected out.Candidates = collected
// 如实上报**实际发生**的来源:空串 = 没有任何分区真正算过相似度(「没候选」)。
// 「尝试过但向量源不可用」是另一件事,由 recall_unavailable_partitions 单独给出
// (见 logDryRun 的说明与 recallLedger.unavailable)。
out.RecallMode = ledger.label()
out.DurationMS = t.now().Sub(started).Milliseconds() out.DurationMS = t.now().Sub(started).Milliseconds()
// 汇总行**成败都记**(与 RunOnce 同一口径):失败那一路的
// `recall_unavailable_partitions` 正是「向量不可用」与「没候选」的唯一区分点,
// 只在成功时记就等于把这条口径丢掉。
t.logDryRun(out, ledger)
if len(failures) > 0 { if len(failures) > 0 {
return out, fmt.Errorf("memory/tidy: dry-run finished with errors: %w", errors.Join(failures...)) return out, fmt.Errorf("memory/tidy: dry-run finished with errors: %w", errors.Join(failures...))
} }
t.logDryRun(out)
return out, nil return out, nil
} }
// strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。 // strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。
// 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。 // 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。
func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) { //
pairs, err := t.recallPairs(ctx, p, items, stats, budget) // 失败时要往 ledger 记一笔 unavailable:否则「尝试过但向量源不可用」与「没有候选
// 可比」在观测面上同值(reviewer round1 的 p2)。
func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats, budget, ledger)
if err != nil { if err != nil {
ledger.noteUnavailable()
t.log.Warn("memory tidy dry-run recall unavailable", t.log.Warn("memory tidy dry-run recall unavailable",
"partition", p.label(), "items", len(items), "error", err) "partition", p.label(), "items", len(items), "error", err)
return nil, fmt.Errorf("%w: %w", ErrTidyRecallUnavailable, err) return nil, fmt.Errorf("%w: %w", ErrTidyRecallUnavailable, err)
@@ -92,7 +109,17 @@ func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []Memo
// logDryRun 输出试算的观测口径。它带 source(memory/tidy),**不带任何记忆 // logDryRun 输出试算的观测口径。它带 source(memory/tidy),**不带任何记忆
// 正文** —— 只记计数、分区数与截断标签。 // 正文** —— 只记计数、分区数与截断标签。
func (t *Tidier) logDryRun(out TidyDryRun) { //
// 🔴 `recall_mode` + `recall_unavailable_partitions` 一起才把三件事分开
// (reviewer round1 的 p2:它们曾同值):
// - mode=""、unavailable=0:本轮**没有候选可比**(压根没算过相似度);
// - mode=""、unavailable>0:**尝试过召回但向量源不可用**(严格口径不降级,
// 调用方拿到的是 `ErrTidyRecallUnavailable` ⇒ HTTP 500 `tidy_recall_unavailable`);
// - mode=embedding/lexical:真的用该来源算过。
//
// 没有为此扩 `recallMode` 的取值集合:试算失败时**不序列化** result(响应是错误
// 体),一个新取值只会出现在这行日志里,却要动一次 API 契约与文档 —— 不划算。
func (t *Tidier) logDryRun(out TidyDryRun, ledger *recallLedger) {
t.log.Info("memory tidy dry-run finished", t.log.Info("memory tidy dry-run finished",
"rule_version", TidyRuleVersion, "rule_version", TidyRuleVersion,
"partitions", out.Stats.Partitions, "partitions", out.Stats.Partitions,
@@ -104,6 +131,7 @@ func (t *Tidier) logDryRun(out TidyDryRun) {
"veto_reasons", tidyReasonsLabel(out.Stats.VetoReasons), "veto_reasons", tidyReasonsLabel(out.Stats.VetoReasons),
"errors", out.Stats.Errors, "errors", out.Stats.Errors,
"recall_mode", out.RecallMode, "recall_mode", out.RecallMode,
"recall_unavailable_partitions", ledger.unavailablePartitions(),
"duration_ms", out.DurationMS) "duration_ms", out.DurationMS)
} }
+108 -9
View File
@@ -25,8 +25,105 @@ const (
tidyRecallEmbedding = "embedding" tidyRecallEmbedding = "embedding"
// tidyRecallLexical 是本轮相似度来自零依赖的词项余弦。 // tidyRecallLexical 是本轮相似度来自零依赖的词项余弦。
tidyRecallLexical = "lexical" tidyRecallLexical = "lexical"
// tidyRecallMixed 是多分区的一轮里**部分**分区走了语义、部分降级到词项余弦。
// 它取代「挑一个代表值报上去」的做法:降级一旦发生就必须看得见。
tidyRecallMixed = "mixed"
) )
// recallLedger 记录**一轮里实际发生**的召回来源(不是配置里那一种)。
//
// 为什么需要它(M6 2026-10-13 的 medium finding):整理轮与试算轮的 `recall_mode`
// 曾按「有没有配 embedder」写出去(旧实现 tidyRecallMode(hasEmbedder bool)),于是
// **全量降级**在汇总行/面板里被写成 `embedding` —— 汇总与事实相反,运维据此判读会
// 把「向量不可用」读成「向量正常工作」。本类型把口径翻过来:只在**真的用了**那条
// 路的地方记一笔,label() 完全由这些实测记录推出。
type recallLedger struct {
// embedding / lexical 是**真正用该来源算过相似度**的分区数。
embedding int
lexical int
// degraded 是**因相似度来源不可用**而降级到词项余弦的分区数(不是「没配
// embedder」那种配置形态)。它把「没候选」(label 为空串)与「向量不可用」
// (label 为 lexical 且 degraded > 0)分开 —— 这两件事在运维上不是一回事。
degraded int
// unavailable 是**尝试过召回但向量源不可用、且本轮不降级**(试算轮的严格口径)
// 的分区数。它与 degraded 的区别是「有没有改用词项余弦」:生产轮会降级
// (degraded),试算轮只报错。
//
// 🔴 它存在的理由(reviewer round1 的 p2):试算轮在向量源不可用时
// `RecallMode` 与「没有候选可比」**同为空串**,两者在那个字段上分不开。
// 现在 provenance 由 unavailablePartitions() 单独给出,日志行能区分;
// 调用方(面板)那条路本来就有明确错误码(500 tidy_recall_unavailable),
// 因此**没有**为此扩 recallMode 的取值集合(见 logDryRun 的说明)。
unavailable int
}
// noteEmbedding 记一笔「这个分区真的用了语义 embedding」。
func (l *recallLedger) noteEmbedding() {
if l != nil {
l.embedding++
}
}
// noteLexical 记一笔「这个分区用了词项余弦」;degraded 表示它是**降级**来的
// (embedder 报错 / 向量条数错位),而不是「本来就没配 embedder」。
func (l *recallLedger) noteLexical(degraded bool) {
if l == nil {
return
}
l.lexical++
if degraded {
l.degraded++
}
}
// noteUnavailable 记一笔「这个分区尝试召回但向量源不可用,且本轮没有降级」
// (试算轮的严格口径:宁可报错,也不把「没算出来」说成「算过了,没有」)。
func (l *recallLedger) noteUnavailable() {
if l != nil {
l.unavailable++
}
}
// label 返回本轮**实际**的召回来源:
// - "":没有任何分区真正算过相似度(没有候选可比)—— 「没候选」不是「降级」;
// - embedding / lexical:所有算过的分区都走同一条路;
// - mixed:两种都有(多分区里有一部分降级)。
//
// ⚠️ 「尝试过但不可用」**不改变** label:它没有产出任何相似度,报 embedding /
// lexical 都是假的;它的存在由 unavailablePartitions() 单独给出(见 recallLedger
// 的 unavailable 字段说明)。
func (l *recallLedger) label() string {
if l == nil {
return ""
}
switch {
case l.embedding == 0 && l.lexical == 0:
return ""
case l.embedding == 0:
return tidyRecallLexical
case l.lexical == 0:
return tidyRecallEmbedding
default:
return tidyRecallMixed
}
}
// degradedPartitions 返回本轮因向量不可用而降级的分区数。
func (l *recallLedger) degradedPartitions() int {
if l == nil {
return 0
}
return l.degraded
}
// unavailablePartitions 返回本轮「尝试召回但向量源不可用、且没有降级」的分区数。
func (l *recallLedger) unavailablePartitions() int {
if l == nil {
return 0
}
return l.unavailable
}
// tidyPair 是一条被召回的候选对。 // tidyPair 是一条被召回的候选对。
type tidyPair struct { type tidyPair struct {
a, b MemoryItem a, b MemoryItem
@@ -35,18 +132,23 @@ type tidyPair struct {
mode string mode string
} }
// recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错)。 // recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错),
// 且**不往 ledger 记任何一笔** —— 「没有候选可比」不是「走哪条路召回」。
// //
// 相似度来源不可用时(embedder 报错、向量条数错位)返回错误 —— 回退决策不在 // 相似度来源不可用时(embedder 报错、向量条数错位)返回错误 —— 回退决策不在
// 这里:生产轮把它降级成词项余弦,试算轮如实上报(见 tidyRecall 策略)。 // 这里:生产轮把它降级成词项余弦,试算轮如实上报(见 tidyRecall 策略)。
// //
// ledger 是**实际发生**的来源记账口(见 recallLedger):它只在真正选定相似度
// 函数的那一行记账,因此谁也无法把它报成「配置里那一种」。
//
// 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现 // 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现
// (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。 // (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) { func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
if len(items) < 2 { if len(items) < 2 {
return nil, nil return nil, nil
} }
if t.deps.Embedder == nil { if t.deps.Embedder == nil {
ledger.noteLexical(false)
return t.collectPairs(p, items, t.cfg.RecallThreshold, return t.collectPairs(p, items, t.cfg.RecallThreshold,
lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil
} }
@@ -57,6 +159,7 @@ func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []Memor
if len(vecs) != len(items) { if len(vecs) != len(items) {
return nil, ErrTidyEmbedCount return nil, ErrTidyEmbedCount
} }
ledger.noteEmbedding()
return t.collectPairs(p, items, t.cfg.RecallThreshold, return t.collectPairs(p, items, t.cfg.RecallThreshold,
vectorSimFunc(vecs), tidyRecallEmbedding, stats, budget), nil vectorSimFunc(vecs), tidyRecallEmbedding, stats, budget), nil
} }
@@ -205,10 +308,6 @@ func clampUnit(v float64) float64 {
return v return v
} }
// tidyRecallMode 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。 // 旧的 tidyRecallMode(hasEmbedder bool) 已删除(M6 2026-10-13 的 medium finding):
func tidyRecallMode(hasEmbedder bool) string { // 它按**配置态**报「本轮相似度来源」,把全量降级掩盖成 embedding。现在的口径是
if hasEmbedder { // recallLedger —— 只报**实际发生**的那一种,见本文件顶部的 recallLedger。
return tidyRecallEmbedding
}
return tidyRecallLexical
}
@@ -0,0 +1,216 @@
package memory
import (
"bytes"
"context"
"errors"
"log/slog"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件钉 M6(2026-10-13)的 medium finding:整理轮/试算轮的 `recall_mode`
// 必须是**实际发生**的那一种,不是配置态。
//
// 旧实现(tidyRecallMode(hasEmbedder bool))按「有没有配 embedder」写,于是
// 「配了但是向量不可用 ⇒ 全量降级」在汇总行里被写成 embedding —— 汇总与事实相反。
//
// 「配了但不可用」的 embedder 复用 tidy_dryrun_test.go 的 failingEmbedder
// (正是线上那 2568 次 `embedding recall unavailable` 的形态)。
// newLogCaptureTidier 构建一个把汇总行写进 buffer 的整理器(便于断言日志字段)。
func newLogCaptureTidier(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder) (*Tidier, *bytes.Buffer) {
t.Helper()
buf := &bytes.Buffer{}
tr, err := NewTidier(cfg, TidyDeps{
Store: store,
Embedder: emb,
Log: slog.New(slog.NewTextHandler(buf, nil)),
})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
return tr, buf
}
// TestRecallLedger_LabelComesFromActualUse 钉记账口本身:label 完全由「真的用了
// 哪条路」推出,混合与降级都不许被抹平。
func TestRecallLedger_LabelComesFromActualUse(t *testing.T) {
t.Parallel()
cases := []struct {
name string
notes func(l *recallLedger)
want string
degraded int
}{
{name: "没算过(没候选)", notes: func(*recallLedger) {}, want: ""},
{name: "全部语义", notes: func(l *recallLedger) { l.noteEmbedding() }, want: tidyRecallEmbedding},
{name: "全部词项", notes: func(l *recallLedger) { l.noteLexical(false) }, want: tidyRecallLexical},
{
name: "降级到词项",
notes: func(l *recallLedger) { l.noteLexical(true) },
want: tidyRecallLexical,
degraded: 1,
},
{
name: "多分区混合(部分降级)",
notes: func(l *recallLedger) { l.noteEmbedding(); l.noteLexical(true) },
want: tidyRecallMixed,
degraded: 1,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var l recallLedger
tc.notes(&l)
if got := l.label(); got != tc.want {
t.Fatalf("label = %q, want %q", got, tc.want)
}
if got := l.degradedPartitions(); got != tc.degraded {
t.Fatalf("degraded = %d, want %d", got, tc.degraded)
}
})
}
}
// TestTidyRunLog_RecallModeReportsDegradation 是 finding 的正面回归:配了 embedder
// 但它报错 ⇒ 汇总行必须写 `recall_mode=lexical` 且 `recall_degraded_partitions=1`,
// **绝不能**写成 embedding。
func TestTidyRunLog_RecallModeReportsDegradation(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce 应当降级跑完而不是失败:%v", err)
}
line := lastLogLine(t, buf, "memory tidy run finished")
if !strings.Contains(line, "recall_mode=lexical") {
t.Fatalf("降级轮的汇总行必须写 lexical,实际:%s", line)
}
if strings.Contains(line, "recall_mode=embedding") {
t.Fatalf("降级轮被报成了 embedding(正是本 finding 的形态):%s", line)
}
if !strings.Contains(line, "recall_degraded_partitions=1") {
t.Fatalf("降级分区数没写出来:%s", line)
}
}
// TestTidyRunLog_RecallModeReportsEmbeddingWhenHealthy 是反面:embedder 正常时
// 汇总行必须如实写 embedding、降级数为 0(不能因为「怕误报」而一律写降级)。
func TestTidyRunLog_RecallModeReportsEmbeddingWhenHealthy(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
line := lastLogLine(t, buf, "memory tidy run finished")
if !strings.Contains(line, "recall_mode=embedding") {
t.Fatalf("健康轮必须写 embedding,实际:%s", line)
}
if !strings.Contains(line, "recall_degraded_partitions=0") {
t.Fatalf("健康轮的降级数必须为 0:%s", line)
}
}
// TestTidyRunLog_RecallModeEmptyWhenNoPairs 钉「没候选」与「向量不可用」可区分:
// 分区里只有一条(压根没有对可比)时,来源是**空串**,而不是任何一条召回路径。
func TestTidyRunLog_RecallModeEmptyWhenNoPairs(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
line := lastLogLine(t, buf, "memory tidy run finished")
if !strings.Contains(line, `recall_mode=""`) {
t.Fatalf("没有候选可比时来源应为空串(区别于降级),实际:%s", line)
}
if !strings.Contains(line, "recall_degraded_partitions=0") {
t.Fatalf("没有候选可比不算降级:%s", line)
}
}
// TestTidyDryRun_UnavailableIsDistinctFromNoCandidates 钉 reviewer round1 p2:
// 试算轮「尝试过召回但向量源不可用」与「没有候选可比」**必须能区分**。
//
// 两者的 `RecallMode` 都是空串(都不曾产出相似度,报 embedding/lexical 都是假的),
// 区分点在 `recall_unavailable_partitions`:不可用时为 1,没候选时为 0。调用方
// (面板)另有一条更硬的区分 —— 失败那一路不序列化 result,直接是
// `500 tidy_recall_unavailable`。
func TestTidyDryRun_UnavailableIsDistinctFromNoCandidates(t *testing.T) {
t.Parallel()
t.Run("向量源不可用", func(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
out, err := tr.DryRun(context.Background())
if !errors.Is(err, ErrTidyRecallUnavailable) {
t.Fatalf("试算轮必须如实上报不可用,err = %v", err)
}
if out.RecallMode == tidyRecallEmbedding {
t.Fatalf("向量不可用却把 RecallMode 报成 embedding(配置态口径):%q", out.RecallMode)
}
line := lastLogLine(t, buf, "memory tidy dry-run finished")
if !strings.Contains(line, "recall_unavailable_partitions=1") {
t.Fatalf("汇总行必须写出「尝试过但不可用」的计数,实际:%s", line)
}
})
t.Run("没有候选可比", func(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
if _, err := tr.DryRun(context.Background()); err != nil {
t.Fatalf("没有对可比时试算不该报错:%v", err)
}
line := lastLogLine(t, buf, "memory tidy dry-run finished")
if !strings.Contains(line, "recall_unavailable_partitions=0") {
t.Fatalf("没候选不是「不可用」,计数必须为 0,实际:%s", line)
}
if !strings.Contains(line, `recall_mode=""`) {
t.Fatalf("没候选时来源为空串,实际:%s", line)
}
})
}
// lastLogLine 返回 buffer 里最后一条含 marker 的日志行。
func lastLogLine(t *testing.T, buf *bytes.Buffer, marker string) string {
t.Helper()
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if strings.Contains(lines[i], marker) {
return lines[i]
}
}
t.Fatalf("日志里没有找到 %q:\n%s", marker, buf.String())
return ""
}
+7
View File
@@ -41,6 +41,13 @@ const tidyStatusActive = "active"
// 最新在前;rowid 打破 created_at 并列,使同一刻写入的条目顺序稳定 // 最新在前;rowid 打破 created_at 并列,使同一刻写入的条目顺序稳定
// (与 ListByPersona 同一口径)。分区过滤写在 SQL 里,而不是读全表再在内存 // (与 ListByPersona 同一口径)。分区过滤写在 SQL 里,而不是读全表再在内存
// 里筛 —— design/23 §6 要求在分区切分层就隔断。 // 里筛 —— design/23 §6 要求在分区切分层就隔断。
//
// 🔴 **刻意不过时效闸**(design/03 §11.2「主会话已裁定:保持不过时效闸」,
// 同一取舍的先例见 design/23 §10.1 ①):整理比对的是**正文**,与「这条对外
// 可不可见」无关;已过期的临时记忆随后由后台清扫物理删除(design/23 §19),
// 绝不会被整理「复活」。可见性判据只有一处 —— 四条列表读路径共用 timegate.go
// 的 timeGateClause。**不要再给这条 SQL 加时效闸。** 本文件另两条分区口径
// 查询(listTidyPartitionsSQL / countTidyPartitionItemsSQL)是同一裁定的实例。
const listTidyPartitionItemsSQL = `SELECT id, persona_id, role_id, project_id, scope, kind, const listTidyPartitionItemsSQL = `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items decay_weight FROM memory_items
+73
View File
@@ -0,0 +1,73 @@
package memory
import "time"
// 本文件是**读侧时效闸**的唯一实现(design/03 §9.6 / §11.2),也是「临时记忆到期
// 即从读面消失」链路里的第 ① 步(隐藏):判据就是列值本身,零写入、即时生效。
// 第 ② 步(后台物理删除)与第 ③ 步(删除前审计)是另一条显式、有界、可停的
// 后台路径,**绝不混进读路径**(design/23 §19.1)。
//
// 🔴 为什么必须下沉到共享底层、不许写在调用点(design/03 §11.2 死角分析):
// 记忆读入口有 8 个(对话注入 owner / 群聊陌生人、`knowledge` 工具、capacity L4
// 习惯召回、子代理以 B 身份、admin 面板列表 / 搜索 / RAG),但它们**只走同一批
// 底层** —— Store.ListByPersona / ListScope / ListProject / List(面板与直接调用方)
// 以及 Retriever.visibleCandidates(它组合调用前两条,因此所有复用检索器的入口
// 自动获得同一判据)。闸一旦写在某个调用点上,立刻出现「对话看不见、`knowledge`
// 工具看得见」的死角。**因此判据只有这一处,四条列表读路径共用。**
//
// 🔴 同时登记**两处刻意不过闸**(design/03 §11.2 第 3 条;与 design/23 §10.1 ①
// 已立的先例同构):
//
// 1. 按 id 的 Store.Get(store.go)—— 撤销软合并 / 查看已软删行必须先读得到,
// 加闸等于把撤销与审计的抓手抹掉。过期行同理:清算与人工核查要读得到。
// 2. 整理与审查的**内部**召回 SQL —— listTidyPartitionItemsSQL(tidy_store.go)
// 与 listReviewCandidatesSQL(preinsert_store.go)刻意**只过 status 闸**:
// 它们比对的是正文,与「这条对外可不可见」无关;过期行随后由后台清扫删除,
// 不会复活(design/03 §11.2「主会话已裁定:保持不过时效闸」)。两处都在
// SQL 旁落了原位注释:前者在 tidy_store.go(切片 M1),后者的原位注释由
// 切片 M2 落在 preinsert_store.go —— 此处登记,免得分头实现时漏掉。
// 3. 审查留痕读侧(`GET /admin/memories/reviews`)—— 它读的是留痕表
// memory_ingest_reviews(preinsert_schema.go:29),那张表**没有**
// status / expires_at / valid_to 任何一列(结构与可见性无关,只记判定结果),
// 因此这里根本没有可加的闸;它是软删/被吸收行的**查看入口**,加闸等于审计自我抹除。
//
// **新增 / 变更任何记忆读路径时,先问这一句**:它要不要看见已过期(临时层)与
// 已软删行?答案写进注释(design/03 §11.2)。
// timeGateClause 是时效闸的 WHERE 片段,拼在四条列表读路径既有的 status 闸之后、
// ORDER BY 之前。判据与 design/03 §9.6 逐字一致:
//
// (expires_at = '' OR expires_at > ?) AND (valid_to = '' OR valid_to > ?)
//
// 空串是「从未设过期 / 从未失效」(长期记忆与日常记忆的默认值,见
// store_schema.go:27-29 的 DEFAULT 空串),因此空串**必须可见**;只有非空才比较。
// 两个 ? 都绑定 timeGateNow() 的返回值。
const timeGateClause = ` AND (expires_at = '' OR expires_at > ?) AND (valid_to = '' OR valid_to > ?)`
// timeGateNow 返回时效闸比较用的「当下」。
//
// 🔴 口径必须与**写入格式同源**(本仓历史事故,DevDocs/开发习惯与约定.md §7):
// 列里存的是 formatTime 产出的定宽 30 字节 UTC 文本(timefmt.go:17-21),所以右边
// 的「当下」也必须由同一个 formatTime 产出**同形文本**,两边才落在同一个字母表里、
// 文本序才等于时间序。反例是拿 SQLite 的 datetime('now')("YYYY-MM-DD HH:MM:SS",
// 空格分隔、无小数秒)去比这些列值:'T'(0x54) 恒大于 ' '(0x20),于是**任何当日行
// 都「大于当下」**,过期行反而被判为未过期 —— 时间判断静默错,不报错、不 panic。
// 同理不许改写成 datetime(expires_at) 之类的函数比较:那是另一套口径,
// 一旦写入格式变化就再次静默分叉。timegate_test.go 里有这条反证的实证。
func timeGateNow() string {
return formatTime(time.Now())
}
// expiryText 把 MemoryItem.ExpiresAt 编码为要落库的 expires_at 文本,是
// 「读侧口径与写入格式同源」在**写入侧**的那一半:零值 = 不设过期
// (长期 / 日常记忆),写空串(与 DDL 的默认值一致);非零值走 formatTime。
//
// 它不做任何判据(过去值不在这里被拒、也不被改写):写入侧不决定可见性,
// 一条「出生即过期」的行照常落库,四条列表读路径立刻看不见它,直到后台清扫
// 把它删掉;Get 仍读得到(见 store.go 的 Get 注释)。
func expiryText(t time.Time) string {
if t.IsZero() {
return ""
}
return formatTime(t)
}
+357
View File
@@ -0,0 +1,357 @@
package memory
import (
"context"
"sort"
"strings"
"testing"
"time"
)
// 本文件是**读侧时效闸**的回归(design/03 §9.6 / §11.2)。
//
// 判据:status='active' ∧ (expires_at = '' ∨ expires_at > now) ∧ (valid_to = '' ∨ valid_to > now),
// 它必须同时出现在**四条列表读路径**上(Store.ListByPersona / ListScope / ListProject / List),
// 因为记忆读入口有 8 个而底层只有这四个;Retriever.visibleCandidates 组合调用其中两条,
// 因此检索入口自动继承同一判据(本条亦有测试)。任何一条漏闸 = 过期记忆照样进上下文、
// 照样出现在 admin 面板(design §11.2 的死角分析)。
// setItemExpiryForTest 直接改一行的时效列,模拟整理 / D3 第五态落下的时效裁定,
// 或「事实已失效」的 valid_to 标记。走 SQL 而不是走写入路径,是为了让本条只验证
// **读侧**:写入侧(Add 真的把 ExpiresAt 落成 expires_at 列)由本文件末条测试单独钉。
func setItemExpiryForTest(t *testing.T, s *Store, id, expiresAt, validTo string) {
t.Helper()
if _, err := s.db.ExecContext(context.Background(),
`UPDATE memory_items SET expires_at = ?, valid_to = ? WHERE id = ?`,
expiresAt, validTo, id); err != nil {
t.Fatalf("set expiry (expires_at=%q valid_to=%q) on %s: %v", expiresAt, validTo, id, err)
}
}
// assertReadSet 断言读路径返回的条目集合**恰好**是 want(按 id 比对),
// 且 hidden 里的条目一条都没漏进来。按 id 而不是按正文断言:正文可能重复,
// id 才是行的身份。
func assertReadSet(t *testing.T, name string, got, want, hidden []MemoryItem) {
t.Helper()
gotIDs := idsOfItems(got)
wantIDs := idsOfItems(want)
if !sameIDs(gotIDs, wantIDs) {
t.Fatalf("%s = %v %v, want %v", name, gotIDs, contentsOf(got), wantIDs)
}
for _, h := range hidden {
for _, id := range gotIDs {
if id == h.ID {
t.Errorf("%s 返回了已过期 / 已失效行 %q(时效闸失效)", name, h.Content)
}
}
}
}
// TestStore_读路径_过滤已过期行 逐条走四条列表读路径与检索路径,证明「临时记忆到期」
// (expires_at ≤ now)与「事实已失效」(valid_to ≤ now)两种行在每一处都不可见,
// 而空 expires_at(长期 / 日常记忆的默认值)与未来的 expires_at / valid_to 照常可见。
func TestStore_读路径_过滤已过期行(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
now := time.Now().UTC()
// 可见组:长期(expires_at 空)、临时未到期(Add 直接给 ExpiresAt)、未失效(valid_to 未来)。
longTerm := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "长期-角色", Scope: ScopePersona, Kind: KindRelationship,
})
tempAlive := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-未到期-角色", Scope: ScopePersona, Kind: KindRelationship,
ExpiresAt: now.Add(time.Hour),
})
tempAliveUser := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-未到期-用户", Scope: ScopeUser, Kind: KindWorldEvent,
ExpiresAt: now.Add(time.Hour),
})
validToFuture := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "未失效-项目", Scope: ScopeProject,
Kind: KindWorldEvent, ProjectID: "proj.1",
})
// 不可见组:临时已到期(写入侧落库的过去值)、valid_to 已过、项目域的临时已到期。
tempExpired := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-已过期-角色", Scope: ScopePersona, Kind: KindRelationship,
ExpiresAt: now.Add(-time.Hour),
})
validToPast := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "已失效-用户", Scope: ScopeUser, Kind: KindWorldEvent,
})
tempExpiredProject := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-已过期-项目", Scope: ScopeProject,
Kind: KindWorldEvent, ProjectID: "proj.1",
})
setItemExpiryForTest(t, s, validToPast.ID, "", formatTime(now.Add(-time.Hour)))
setItemExpiryForTest(t, s, tempExpiredProject.ID, formatTime(now.Add(-time.Hour)), "")
setItemExpiryForTest(t, s, validToFuture.ID, "", formatTime(now.Add(time.Hour)))
ownVisible := []MemoryItem{longTerm, tempAlive, tempAliveUser, validToFuture}
hidden := []MemoryItem{tempExpired, validToPast, tempExpiredProject}
for _, tc := range []struct {
name string
read func() ([]MemoryItem, error)
want []MemoryItem
}{
{
name: "ListByPersona",
read: func() ([]MemoryItem, error) { return s.ListByPersona(ctx, "persona.a") },
want: ownVisible,
},
{
name: "List",
read: func() ([]MemoryItem, error) { return s.List(ctx) },
want: ownVisible,
},
{
name: "ListScope/user",
read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeUser, "persona.a") },
want: []MemoryItem{tempAliveUser},
},
{
name: "ListScope/project",
read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeProject, "persona.a") },
want: []MemoryItem{validToFuture},
},
{
name: "ListProject",
read: func() ([]MemoryItem, error) { return s.ListProject(ctx, "proj.1") },
want: []MemoryItem{validToFuture},
},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.read()
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
assertReadSet(t, tc.name, got, tc.want, hidden)
})
}
// 检索路径:visibleCandidates 是候选集本身(Retrieve/Inject 与所有复用 Retriever 的
// 入口都只从它取候选),闸加在它组合调用的 ListByPersona + ListScope 上,
// 因此这里必须一条过期行都没有。
r := NewRetriever(s)
cands, err := r.visibleCandidates(ctx, "persona.a")
if err != nil {
t.Fatalf("visibleCandidates: %v", err)
}
assertReadSet(t, "visibleCandidates", cands, ownVisible, hidden)
results, err := r.Retrieve(ctx, "persona.a", "", 20)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
if len(results) != len(ownVisible) {
t.Fatalf("Retrieve = %d 条 (%v), want %d", len(results), idsOf(results), len(ownVisible))
}
found := make([]MemoryItem, 0, len(results))
for _, res := range results {
found = append(found, res.Item)
}
assertReadSet(t, "Retrieve", found, ownVisible, hidden)
injected, err := r.Inject(ctx, "persona.a", "", 20)
if err != nil {
t.Fatalf("Inject: %v", err)
}
for _, h := range hidden {
if strings.Contains(injected, h.Content) {
t.Errorf("Inject 文本里出现了已过期 / 已失效行 %q(这就是「任何地方都调得到」的反面)", h.Content)
}
}
for _, v := range ownVisible {
if !strings.Contains(injected, v.Content) {
t.Errorf("Inject 文本缺少在有效期内的 %q", v.Content)
}
}
}
// TestStore_Get_不过时效闸 是反向护栏:按 id 的 Get 刻意**不过** status 闸与时效闸。
// 撤销软合并要读得到被吸收的行(design/23 §8 的「无损可逆」),清算与人工核查要读得到
// 已过期 / 已失效的行(design/03 §11.2 第 3 条)。若有人「顺手」给 Get 也加闸,本条会红。
func TestStore_Get_不过时效闸(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
now := time.Now().UTC()
tempExpired := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-已过期-角色", Scope: ScopePersona, Kind: KindRelationship,
ExpiresAt: now.Add(-time.Hour),
})
validToPast := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "已失效-用户", Scope: ScopeUser, Kind: KindWorldEvent,
})
setItemExpiryForTest(t, s, validToPast.ID, "", formatTime(now.Add(-time.Hour)))
for _, want := range []MemoryItem{tempExpired, validToPast} {
got, err := s.Get(ctx, want.ID)
if err != nil {
t.Errorf("Get(%s) err = %v, want readable(撤销 / 审计的抓手)", want.ID, err)
continue
}
if got.Content != want.Content {
t.Errorf("Get(%s).Content = %q, want %q", want.ID, got.Content, want.Content)
}
}
}
// TestStore_Add_落库expires_at列 钉住写入侧:Add 是全仓唯一 INSERT 路径,
// 时效列的**真生产者**只能是它 —— 若 Add 不写这一列,读侧时效闸就退化成恒真空壳
// (expires_at 永远为空 ⇒ 过期行永远可见)。零值必须落空串(= 不设过期),
// 非零值必须走与读侧闸同一个 formatTime。
func TestStore_Add_落库expires_at列(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
expiry := time.Date(2026, 10, 20, 3, 20, 5, 830978900, time.UTC)
withExpiry := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "临时-带时效", Scope: ScopePersona,
Kind: KindRelationship, ExpiresAt: expiry,
})
withoutExpiry := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Content: "长期-无时效", Scope: ScopePersona, Kind: KindRelationship,
})
for _, tc := range []struct {
name string
id string
want string
}{
{"非零 ExpiresAt", withExpiry.ID, formatTime(expiry)},
{"零值 ExpiresAt", withoutExpiry.ID, ""},
} {
var raw string
if err := s.db.QueryRowContext(ctx,
`SELECT expires_at FROM memory_items WHERE id = ?`, tc.id).Scan(&raw); err != nil {
t.Fatalf("%s: read raw expires_at: %v", tc.name, err)
}
if raw != tc.want {
t.Errorf("%s: expires_at = %q, want %q", tc.name, raw, tc.want)
}
}
}
// TestTimeGate_比较口径与写入格式同源 是本仓历史事故的回归(DevDocs/开发习惯与约定.md §7):
// 「SQLite 时间比较用错格式(带 T/Z 的串比 datetime('now'))会把『今天全部』当成『最近一小时』」。
//
// 闸的处理方式写死为「两边都由 formatTime 产出同形文本,文本序即时间序」,本条把这件事钉三下:
// 1. 闸的比较操作数与列值同源(同一个 formatTime,定宽 30 字节 UTC,可被 parseTime 解回);
// 2. 同源文本的**文本序 == 时间序**(这是整个闸成立的前提);
// 3. 跨格式比较会**系统性判错**(用 SQLite 字面量实证 'T' > ' ' 的陷阱),
// 所以任何把闸改成 datetime('now') 之类的第二套口径的改动,都会先在这条上露馅。
func TestTimeGate_比较口径与写入格式同源(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
gateNow := timeGateNow()
if len(gateNow) != storedTimeLen {
t.Fatalf("timeGateNow() = %q (%d 字节), want %d 字节(必须与列值同宽)",
gateNow, len(gateNow), storedTimeLen)
}
back, err := parseTime(gateNow)
if err != nil {
t.Fatalf("timeGateNow() 不能被 parseTime 解回: %v", err)
}
if reformatted := formatTime(back); reformatted != gateNow {
t.Fatalf("timeGateNow() 与列值格式不同源:再格式化得 %q,不等于 %q", reformatted, gateNow)
}
// 文本序 == 时间序(两个都由 formatTime 产出的值)。
earlier := time.Date(2026, 10, 13, 11, 0, 0, 0, time.UTC)
later := earlier.Add(time.Nanosecond)
if !(formatTime(earlier) < formatTime(later)) {
t.Fatalf("formatTime(%v) = %q 不早于 formatTime(%v) = %q —— 文本序已不等于时间序",
earlier, formatTime(earlier), later, formatTime(later))
}
// 同源比较:11:00 的列值小于 12:00 的当下 ⇒ 判为「已过期」。
var sameSource bool
if err := s.db.QueryRowContext(ctx,
`SELECT '2026-10-13T11:00:00.000000000Z' < ?`, formatTime(earlier.Add(time.Hour))).
Scan(&sameSource); err != nil {
t.Fatalf("same-source comparison: %v", err)
}
if !sameSource {
t.Fatal("同源文本比较失效:11:00 的列值未被判为早于 12:00 的当下")
}
// 跨格式比较(历史事故的机制):SQLite 的 datetime('now') 形如
// "YYYY-MM-DD HH:MM:SS",第 11 个字节是空格(0x20),而列值第 11 个字节是 'T'(0x54)。
// 于是**同一 UTC 日**内,任何列值都「大于当下」—— 早一小时的过期行反而被判为未过期。
var crossFormat bool
if err := s.db.QueryRowContext(ctx,
`SELECT '2026-10-13T11:00:00.000000000Z' > datetime('2026-10-13 12:00:00')`).
Scan(&crossFormat); err != nil {
t.Fatalf("cross-format comparison: %v", err)
}
if !crossFormat {
t.Fatalf("陷阱实证失效:跨格式比较不再系统性判错('T' vs ' ')," +
"请重新核对 timegate.go 的口径与 DevDocs/开发习惯与约定.md §7")
}
}
// TestTimeGate_闸不接受第二套时间口径 是结构性护栏:闸的 WHERE 片段里只允许出现
// 参数化比较(?),不得出现 SQLite 的时间函数 —— 一旦出现 datetime(...)/julianday(...)
// 之类,就有第二套口径,写入格式或 SQLite 行为一变就再次静默分叉(历史事故正是如此)。
func TestTimeGate_闸不接受第二套时间口径(t *testing.T) {
t.Parallel()
for _, fn := range []string{"datetime(", "julianday(", "strftime(", "unixepoch(", "date("} {
if strings.Contains(timeGateClause, fn) {
t.Errorf("timeGateClause 含 SQLite 时间函数 %q:闸必须只做「同源定宽文本」的参数化比较(%s)",
fn, timeGateClause)
}
}
if got := strings.Count(timeGateClause, "?"); got != 2 {
t.Errorf("timeGateClause 的占位符 = %d, want 2(expires_at 与 valid_to 各一个)", got)
}
}
// TestExpiryText_零值落空串 钉住写入侧编码:零值 = 不设过期,必须落空串(列默认值),
// 而不是 formatTime(零值)(那会写成 "0001-01-01T00:00:00.000000000Z",反而把长期记忆
// 变成「早已过期」—— 长期记忆会整体从读面消失)。
func TestExpiryText_零值落空串(t *testing.T) {
t.Parallel()
if got := expiryText(time.Time{}); got != "" {
t.Errorf("expiryText(零值) = %q, want 空串(不设过期)", got)
}
at := time.Date(2026, 10, 20, 3, 20, 5, 0, time.UTC)
got := expiryText(at)
if want := formatTime(at); got != want {
t.Errorf("expiryText(%v) = %q, want %q", at, got, want)
}
if len(got) != storedTimeLen {
t.Errorf("expiryText(%v) 长度 = %d, want %d", at, len(got), storedTimeLen)
}
}
// idsOfItems 取条目 id,仅用于失败信息与集合比对。
func idsOfItems(items []MemoryItem) []string {
out := make([]string, 0, len(items))
for _, it := range items {
out = append(out, it.ID)
}
sort.Strings(out)
return out
}
// sameIDs 报告两个**已排序**的 id 切片是否逐项相等。
func sameIDs(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
@@ -143,6 +143,10 @@ type Options struct {
// (channels.json → 环境变量回退)。非 nil 时桥接器支持热重载; // (channels.json → 环境变量回退)。非 nil 时桥接器支持热重载;
// 为 nil 时桥接器仅支持启动期装配(Reload 返回 ErrNilFactory)。 // 为 nil 时桥接器仅支持启动期装配(Reload 返回 ErrNilFactory)。
Factory PlatformFactory Factory PlatformFactory
// Channels 是能力注册表的窄端口(见 ChannelRegistry):热重载把新适配器写回
// 同名条目,使管理面板读到的永远是当前实例。为 nil 时热重载照常,只是面板
// 仍读旧实例(首次同步记一次 WARN)。
Channels ChannelRegistry
// ChannelPersona 非 nil 时解析某个渠道账号绑定的角色 // ChannelPersona 非 nil 时解析某个渠道账号绑定的角色
// (channels.json 的 ChannelAccount.PersonaID,design/22 §2 方案 b):调用时传入 // (channels.json 的 ChannelAccount.PersonaID,design/22 §2 方案 b):调用时传入
// 平台与本地账号 id,账号未绑定角色时返回 ""。 // 平台与本地账号 id,账号未绑定角色时返回 ""。
@@ -253,6 +257,10 @@ type Bridge struct {
started bool started bool
closed bool closed bool
factory PlatformFactory factory PlatformFactory
// channels 是热重载同步能力注册表的窄端口(reload.go 的 replaceChannelEntry /
// dropChannelEntry);为 nil 时面板读到的是保存前的旧实例,首次同步记一次 WARN。
channels ChannelRegistry
channelsWarnOnce sync.Once
// channelPersona 从生效配置解析账号的渠道级角色 // channelPersona 从生效配置解析账号的渠道级角色
// (design/22 §2 方案 b);adapterPersona 按已连接的适配器缓存解析结果, // (design/22 §2 方案 b);adapterPersona 按已连接的适配器缓存解析结果,
// 使入站路径永不重读 channels.json。二者 // 使入站路径永不重读 channels.json。二者
@@ -353,6 +361,7 @@ func New(opts Options) (*Bridge, error) {
readCancels: make(map[string]context.CancelFunc), readCancels: make(map[string]context.CancelFunc),
gates: make(map[string]*adapter.FlowGate), gates: make(map[string]*adapter.FlowGate),
factory: opts.Factory, factory: opts.Factory,
channels: opts.Channels,
channelPersona: opts.ChannelPersona, channelPersona: opts.ChannelPersona,
adapterPersona: make(map[string]string), adapterPersona: make(map[string]string),
documentsCfg: opts.DocumentStore, documentsCfg: opts.DocumentStore,
+70 -6
View File
@@ -14,6 +14,7 @@ import (
"fmt" "fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
) )
// PlatformFactory 依据平台当前生效的账号配置 // PlatformFactory 依据平台当前生效的账号配置
@@ -27,6 +28,20 @@ type PlatformFactory interface {
Build(platform string) ([]adapter.PlatformAdapter, error) Build(platform string) ([]adapter.PlatformAdapter, error)
} }
// ChannelRegistry 是能力注册表的**窄端口**,只暴露热重载需要同步的两件事:
// 把某个名字的渠道条目换成新适配器、把已消失的账号条目删掉。桥接器用它让管理面板
// (渠道列表 / 在线状态 / open·close 解析)读到的永远是**当前**适配器实例,而不必
// 依赖 capability/registry 的具体类型 —— 注册表实现与桥接器因此保持解耦。
//
// 未接线(Options.Channels 为 nil)时桥接器照常热重载,但注册表条目会停在被保存前
// 的那个实例上:面板显示「未连接」、点「连接」驱动的是旧实例。此时只记一次 WARN。
type ChannelRegistry interface {
// ReplaceChannel 用同名的新适配器覆盖(或新增)渠道条目。
ReplaceChannel(ch sdk.Channel) error
// RemoveChannel 删除该名字的渠道条目。
RemoveChannel(name string) error
}
// ErrNilFactory 表示请求重载时未注入平台工厂 // ErrNilFactory 表示请求重载时未注入平台工厂
// (没有热重载配置来源)。 // (没有热重载配置来源)。
var ErrNilFactory = errors.New("channelbridge: nil platform factory") var ErrNilFactory = errors.New("channelbridge: nil platform factory")
@@ -112,16 +127,19 @@ func (b *Bridge) Reload(platform string) error {
} }
for _, a := range newAdapters { for _, a := range newAdapters {
b.connectAndRun(a) b.connectAndRun(a)
// 紧跟上线之后同步能力注册表:新实例先连上,面板才改为读它,
// 避免「注册表已指向新实例、它却还没连」的窗口。
b.replaceChannelEntry(a)
} }
for _, o := range old { for _, o := range old {
if newNames[o.Name()] { if newNames[o.Name()] {
// 同名账号被重建后的适配器替换:停止旧的读取 // 同名账号被重建后的适配器替换:只停旧实例的读取循环,
// 循环,但保留注册表条目——它现在指向新适配器。 // 注册表条目已由 replaceChannelEntry 指向新实例。
b.cancelAndDisconnect(o, oldCancels[o.Name()]) b.cancelAndDisconnect(o, oldCancels[o.Name()])
continue continue
} }
// 账号被彻底移除(或更名):停止它并删除其注册表 // 账号被彻底移除(或更名):停止它并删除其注册表
// 条目,后续 snapshot 便不再看到它。 // 条目,后续 snapshot 与面板便不再看到它。
b.removeAdapter(o, oldCancels[o.Name()]) b.removeAdapter(o, oldCancels[o.Name()])
} }
b.log.Info("channel reloaded", "platform", platform, "adapters", len(newAdapters)) b.log.Info("channel reloaded", "platform", platform, "adapters", len(newAdapters))
@@ -148,10 +166,55 @@ func (b *Bridge) adapterSnap(platform string) ([]adapter.PlatformAdapter, map[st
return list, cancels return list, cancels
} }
// replaceChannelEntry 把刚上线的适配器写回能力注册表。注册表里的渠道条目只在启动
// 时写过一次,而热重载重建出来的是同名的新实例:不同步的话,管理面板(渠道列表、
// 账号在线状态、open/close 的目标解析)读到的仍是保存前的**旧实例** —— 卡片显示
// 「未连接」,点「连接」拨的是旧配置里的地址(design/04 §3.1)。
//
// 同步失败只记日志、绝不返回错误:面板保存不该被注册表同步拖垮。
func (b *Bridge) replaceChannelEntry(a adapter.PlatformAdapter) {
if a == nil {
return
}
name := a.Name()
if !b.IsConnected(name) {
// 没上线就不动注册表:换成连不上的实例只会让面板的在线状态更失真。
b.log.Warn("channel registry left unchanged: adapter not connected", "channel", name)
return
}
if b.channels == nil {
b.warnChannelRegistryUnwired()
return
}
if err := b.channels.ReplaceChannel(a); err != nil {
b.log.Warn("channel registry replace failed", "channel", name, "error", err)
}
}
// dropChannelEntry 在账号被移除(或更名)后删掉它的注册表条目,使管理面板不再列出
// 这条已经不存在的账号。同 replaceChannelEntry:失败只记日志。
func (b *Bridge) dropChannelEntry(name string) {
if b.channels == nil {
b.warnChannelRegistryUnwired()
return
}
if err := b.channels.RemoveChannel(name); err != nil {
b.log.Warn("channel registry remove failed", "channel", name, "error", err)
}
}
// warnChannelRegistryUnwired 只在首次同步时提醒一次:未注入 Channels 端口时热重载
// 照常进行(适配器确实换了),只是面板读到的是保存前的旧实例。
func (b *Bridge) warnChannelRegistryUnwired() {
b.channelsWarnOnce.Do(func() {
b.log.Warn("channel registry not wired: admin panel keeps the pre-reload adapter instance")
})
}
// cancelAndDisconnect 停止适配器的读取循环并拆除其会话。 // cancelAndDisconnect 停止适配器的读取循环并拆除其会话。
// 它绝不改动注册表 map:替换同名适配器的调用方 // 它绝不改动桥接器自己的适配器表与能力注册表:替换同名适配器
// 希望(新)条目保留,而移除账号的调用方使用 // 的调用方希望(新)条目保留,而移除账号的调用方使用
// removeAdapter。 // removeAdapter(它会连注册表条目一起清掉)。
func (b *Bridge) cancelAndDisconnect(o adapter.PlatformAdapter, c context.CancelFunc) { func (b *Bridge) cancelAndDisconnect(o adapter.PlatformAdapter, c context.CancelFunc) {
if c != nil { if c != nil {
c() c()
@@ -174,5 +237,6 @@ func (b *Bridge) removeAdapter(o adapter.PlatformAdapter, c context.CancelFunc)
delete(b.readCancels, name) delete(b.readCancels, name)
b.mu.Unlock() b.mu.Unlock()
b.forgetAdapterPersona(name) b.forgetAdapterPersona(name)
b.dropChannelEntry(name)
b.log.Info("channel (reload) removed", "channel", name) b.log.Info("channel (reload) removed", "channel", name)
} }
@@ -0,0 +1,245 @@
package channelbridge
import (
"context"
"errors"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住渠道热重载的**注册表同步**(design/04 §3.1):能力注册表里的渠道条目只在
// 启动时写过一次,而热重载重建出来的是同名**新实例**。不同步的话,管理面板(渠道卡片、
// 账号在线状态、open/close 目标解析)读到的仍是保存前的旧实例——卡片永远「未连接」,
// 点「连接」驱动的是旧配置里的那把(拨旧 URL)。
// errRegistryDown 表示注册表同步失败(被测代码必须吞掉它,只记日志)。
var errRegistryDown = errors.New("registry unavailable")
// errConnectDown 表示新适配器拨号失败。
var errConnectDown = errors.New("dial refused")
// recordingChannelRegistry 是 ChannelRegistry 的记录型实现:按注册名保存**接口值**,
// 使断言能按指针同一性判定新旧实例(新旧名字相同,比名字必然假绿)。
type recordingChannelRegistry struct {
mu sync.Mutex
entries map[string]sdk.Channel
removed []string
replaceErr error
removeErr error
}
func newRecordingRegistry(preset ...sdk.Channel) *recordingChannelRegistry {
reg := &recordingChannelRegistry{entries: make(map[string]sdk.Channel, len(preset))}
for _, ch := range preset {
reg.entries[ch.Name()] = ch
}
return reg
}
func (r *recordingChannelRegistry) ReplaceChannel(ch sdk.Channel) error {
if r.replaceErr != nil {
return r.replaceErr
}
r.mu.Lock()
defer r.mu.Unlock()
r.entries[ch.Name()] = ch
return nil
}
func (r *recordingChannelRegistry) RemoveChannel(name string) error {
if r.removeErr != nil {
return r.removeErr
}
r.mu.Lock()
defer r.mu.Unlock()
r.removed = append(r.removed, name)
delete(r.entries, name)
return nil
}
// entry 返回当前条目(不存在则 nil),模拟注册表的读侧。
func (r *recordingChannelRegistry) entry(name string) sdk.Channel {
r.mu.Lock()
defer r.mu.Unlock()
return r.entries[name]
}
// removedNames 返回所有被要求删除的名字。
func (r *recordingChannelRegistry) removedNames() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.removed...)
}
var _ ChannelRegistry = (*recordingChannelRegistry)(nil)
// downAdapter 是 Connect 必失败的适配器,用来钉住「新实例没上线就不动注册表」。
type downAdapter struct {
*fakeAdapter
err error
}
func (d *downAdapter) Connect(context.Context) error { return d.err }
// newReloadBridgeWithCaps 与 newReloadBridge 同形,额外注入注册表窄端口。
func newReloadBridgeWithCaps(t *testing.T, factory PlatformFactory, caps ChannelRegistry) *Bridge {
t.Helper()
b, err := New(Options{
Loop: &stubChatter{res: &loop.Result{Text: "hi"}},
Sessions: &stubSessions{sess: session.Session{SessionID: "s", Owner: true, MemWritable: true}},
Owner: &user.OwnerProfile{},
Factory: factory,
Channels: caps,
})
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { b.Stop(context.Background()) })
return b
}
// TestReloadPublishesNewAdapterInstanceToRegistry 是本次修复的主判据:重载后注册表里
// 同名条目必须指向**新实例**,否则面板显示「未连接」、点「连接」驱动旧实例。
func TestReloadPublishesNewAdapterInstanceToRegistry(t *testing.T) {
t.Parallel()
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
factory := &stubFactory{}
// 启动注册(wire_channels 的职责)写进注册表的就是这个旧实例,重载要覆盖的正是它。
reg := newRecordingRegistry(sdk.Channel(old))
b := newReloadBridgeWithCaps(t, factory, reg)
b.Add(old)
b.Start()
next := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}}
factory.adpts = []adapter.PlatformAdapter{next}
if err := b.Reload("kook"); err != nil {
t.Fatalf("Reload: %v", err)
}
got := reg.entry("kook:conf0")
if got == nil {
t.Fatal("重载后注册表里没有 kook:conf0:面板会列出「账号已配置但没有已注册的适配器」")
}
// 指针同一性判据:新旧实例的 Name() 相同,比名字必然假绿。
if got != sdk.Channel(next) {
t.Fatalf("注册表条目 = %#v, want 新实例", got)
}
if got == sdk.Channel(old) {
t.Fatal("注册表仍指向旧实例:卡片会永远显示未连接,点「连接」驱动的是旧 URL 的那把")
}
if !got.IsConnected() {
t.Fatal("注册表里的条目未连接:面板会显示未连接")
}
if !b.IsConnected("kook:conf0") || old.IsConnected() {
t.Fatalf("桥接器应当只让新实例在线:new=%v old=%v", b.IsConnected("kook:conf0"), old.IsConnected())
}
}
// TestReloadRemovesDroppedAccountFromRegistry:账号被删除(或更名)后,它的注册表条目
// 必须一并消失,否则面板会继续列出一条已经不存在的账号。同时钉住「同名重建的账号绝不
// 被按名字删除」——否则会误删刚写进去的新实例。
func TestReloadRemovesDroppedAccountFromRegistry(t *testing.T) {
t.Parallel()
dropped := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "t0"}}
kept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1"}}
factory := &stubFactory{}
reg := newRecordingRegistry(sdk.Channel(dropped), sdk.Channel(kept))
b := newReloadBridgeWithCaps(t, factory, reg)
b.Add(dropped)
b.Add(kept)
b.Start()
nextKept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1b"}}
factory.adpts = []adapter.PlatformAdapter{nextKept}
if err := b.Reload("kook"); err != nil {
t.Fatalf("Reload: %v", err)
}
if reg.entry("kook:conf0") != nil {
t.Fatal("被移除账号的注册表条目还在:面板会列出一条不存在的账号")
}
if got := reg.entry("kook:conf1"); got != sdk.Channel(nextKept) {
t.Fatalf("保留账号的条目 = %#v, want 新实例", got)
}
for _, name := range reg.removedNames() {
if name == "kook:conf1" {
t.Fatal("同名重建的账号被按名字删掉了:会误删刚写回的新实例")
}
}
}
// TestReloadWithoutRegistryPortKeepsAdapterRunning:未注入端口时保持旧行为 —— 热重载
// 照常成功、新实例照常上线,只是不同步注册表(不得因为缺端口而 panic 或中断重载)。
func TestReloadWithoutRegistryPortKeepsAdapterRunning(t *testing.T) {
t.Parallel()
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
factory := &stubFactory{}
b := newReloadBridgeWithCaps(t, factory, nil)
b.Add(old)
b.Start()
next := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}}
factory.adpts = []adapter.PlatformAdapter{next}
if err := b.Reload("kook"); err != nil {
t.Fatalf("未接线注册表不该让 Reload 失败:%v", err)
}
if !next.IsConnected() || !b.IsConnected("kook:conf0") {
t.Fatal("未接线注册表时新适配器仍应照常上线")
}
}
// TestReloadSurvivesRegistryFailures:注册表同步失败只记日志,绝不中断重载、绝不返回
// 错误——面板保存不能被注册表拖垮。
func TestReloadSurvivesRegistryFailures(t *testing.T) {
t.Parallel()
dropped := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "t0"}}
kept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1"}}
factory := &stubFactory{}
reg := newRecordingRegistry(sdk.Channel(dropped), sdk.Channel(kept))
reg.replaceErr = errRegistryDown
reg.removeErr = errRegistryDown
b := newReloadBridgeWithCaps(t, factory, reg)
b.Add(dropped)
b.Add(kept)
b.Start()
nextKept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1b"}}
factory.adpts = []adapter.PlatformAdapter{nextKept}
if err := b.Reload("kook"); err != nil {
t.Fatalf("注册表同步失败不该让 Reload 失败:%v", err)
}
if !b.IsConnected("kook:conf1") || !nextKept.IsConnected() {
t.Fatal("同步失败时新适配器仍应照常上线")
}
}
// TestReloadLeavesRegistryAloneWhenNewAccountFailsToConnect:新实例没上线(拨号失败)
// 就不写注册表——换成连不上的实例只会让面板的在线状态更失真;无关账号的条目不受影响。
func TestReloadLeavesRegistryAloneWhenNewAccountFailsToConnect(t *testing.T) {
t.Parallel()
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
down := &downAdapter{fakeAdapter: &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "new"}}, err: errConnectDown}
factory := &stubFactory{}
reg := newRecordingRegistry(sdk.Channel(old))
b := newReloadBridgeWithCaps(t, factory, reg)
b.Add(old)
b.Start()
nextOld := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old2"}}
factory.adpts = []adapter.PlatformAdapter{nextOld, down}
if err := b.Reload("kook"); err != nil {
t.Fatalf("单个适配器连不上不该让 Reload 失败:%v", err)
}
if reg.entry("kook:conf1") != nil {
t.Fatal("没连上的新实例不该写进注册表")
}
if got := reg.entry("kook:conf0"); got != sdk.Channel(nextOld) {
t.Fatalf("已上线账号的条目 = %#v, want 新实例", got)
}
}
+8
View File
@@ -23,11 +23,19 @@ type PluginSummary struct {
// NeedsRescan/LastError(v0.39)报告适配器需要主人重新授权 // NeedsRescan/LastError(v0.39)报告适配器需要主人重新授权
// (见 admin_channels_rescan.go 中的 rescanAware);二者仅在适配器 // (见 admin_channels_rescan.go 中的 rescanAware);二者仅在适配器
// 确实要求时才出现,否则一律省略。 // 确实要求时才出现,否则一律省略。
//
// Pending/Ready/Reason(v0.68)报告 `connected=false` 时**到底是哪种没连上**
// (见 admin_channel_connect_outcome.go 中的 connectOutcomeAware):pending =
// 已在后台重试、当前未连上(面板读作「连接中」),ready = 已就绪、等待对端接入。
// 三者同样只在适配器确实自报时才出现 —— 没有这条状态面的平台逐字节不受影响。
type ChannelSummary struct { type ChannelSummary struct {
Name string `json:"name"` Name string `json:"name"`
Connected bool `json:"connected"` Connected bool `json:"connected"`
NeedsRescan bool `json:"needsRescan,omitempty"` NeedsRescan bool `json:"needsRescan,omitempty"`
LastError string `json:"lastError,omitempty"` LastError string `json:"lastError,omitempty"`
Pending bool `json:"pending,omitempty"`
Ready bool `json:"ready,omitempty"`
Reason string `json:"reason,omitempty"`
Accounts []AdminChannelAccount `json:"accounts,omitempty"` Accounts []AdminChannelAccount `json:"accounts,omitempty"`
} }
@@ -0,0 +1,98 @@
package gateway
// 本文件承载管理面读取**适配器连接态三态**的那一层:把适配器的 ConnectOutcome
// 映射成面板与线上契约能用的形状,使「点了连接之后到底怎么样了」不再只有一个布尔。
//
// 为什么需要它:`Connect` 返回 nil 只说明「这次调用没有致命失败」。ws+client 的首连
// 失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),此时 IsConnected() 为 false ——
// 它与「监听已就绪等对端接入」(server 模式,同样 false)在布尔上无从区分,更与
// 「配置错、起不来」分不开。网关**绝不**按适配器名或 transport 去猜:口径属于适配器,
// 它才知道自己的 transport × mode 意味着什么;网关只做映射。
//
// 与 accountStatusAware / rescanAware / sessionStatusAware 同一契约风格(见
// admin_channel_status.go 与 admin_channels_rescan.go):核心 SDK Channel 契约被刻意
// 不扩展,因此没有这条状态面的平台不受任何改动,也不会凭空多出一个字段。
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// connectOutcomeAware 是渠道适配器暴露「Connect 之后处于哪种状态」这一可选能力的接口。
// 当前由 *qq_obv11.Adapter 实现(ConnectOutcome):它的 ws+client 首连失败非致命、
// ws+server 只需监听就绪,两种「没连上」都不是同一件事。
//
// 返回的 state 是**闭集**取值(internal/channel/adapter 的 ConnectState* 常量):
//
// "connected" —— 真连上;
// "pending" —— 已进入后台重试、当前未连上,reason 说明原因;
// "ready" —— 已绑定监听、就绪等对端接入,不是失败;
// "" —— 无意见(尚未启动),调用方退回只看 IsConnected()。
//
// 闭集而不是布尔,是因为「没连上」至少还有两种互不相同的现状,而它们对主人的意义
// 完全不同:一种是「还在连」,一种是「就等对面来连」。
type connectOutcomeAware interface {
// ConnectOutcome 报告连接态;state 取值见上,reason 在未连上时说明原因
// (如实的中文/技术短句,**绝不含凭据**)。
ConnectOutcome() (state string, reason string)
}
// asConnectOutcomeAware 报告已注册的渠道值是否暴露连接态三态。与 asRescanAware /
// asAccountStatusAware 同一套路:注册时会把适配器包进 cmd/cyrene 的 namedAdapter ——
// 一个内嵌 adapter.PlatformAdapter 的未导出结构体 —— 它只提升接口的方法,因此具体
// 适配器多出的能力只能通过拆开被内嵌字段才能抵达。直接的适配器值(测试)按原样接受。
func asConnectOutcomeAware(v any) (connectOutcomeAware, bool) {
if co, ok := v.(connectOutcomeAware); ok {
return co, true
}
inner, ok := unwrapNamedAdapter(v)
if !ok {
return nil, false
}
co, ok := inner.(connectOutcomeAware)
return co, ok
}
// channelConnectOutcome 是连接态三态在**线上**的投影:三态里只有两种「没连上」需要
// 额外说明(真连上已经由 connected 布尔说完),reason 只在 pending 时非空。
//
// 刻意用可选键而不是把三态塞进 connected:connected 的语义已被老客户端当作「连上了
// 没有」在读,改写它等于改契约;新增可选键则谁都不吃亏 —— 零值的三态投影让
// pending / ready / reason **整键缺席**,与 NeedsRescan / LastError 同一口径。
type channelConnectOutcome struct {
// Pending 表示「已在后台重试、当前未连上」——面板须把它读作「连接中」,
// 绝不是「未连接」,也绝不是「已连接」。
Pending bool
// Ready 表示「已就绪、等待对端接入」——同样不是失败。
Ready bool
// Reason 是未连上时的原因(已脱敏),仅在 Pending 时可能非空。
Reason string
}
// channelConnectStateOf 报告某渠道适配器当前的连接态:connected 布尔 + 需要额外说明的
// 三态投影。
//
// 适配器实现了 connectOutcomeAware ⇒ **以它为准**:闭集三态优先于 IsConnected() 的
// 单个布尔(pending 与 ready 的 IsConnected() 都是 false,但两者都不是「未连接」这样
// 一个静态结论)。没实现,或返回空串「无意见」⇒ 逐字退回今天的行为(只看
// IsConnected(),三态投影为零值、可选键缺席)。
func channelConnectStateOf(ch sdk.Channel) (bool, channelConnectOutcome) {
if ch == nil {
return false, channelConnectOutcome{}
}
aware, ok := asConnectOutcomeAware(ch)
if !ok {
return ch.IsConnected(), channelConnectOutcome{}
}
state, reason := aware.ConnectOutcome()
switch state {
case adapter.ConnectStateConnected:
return true, channelConnectOutcome{}
case adapter.ConnectStatePending:
return false, channelConnectOutcome{Pending: true, Reason: reason}
case adapter.ConnectStateReady:
return false, channelConnectOutcome{Ready: true}
default:
return ch.IsConnected(), channelConnectOutcome{}
}
}
@@ -0,0 +1,251 @@
package gateway
// 本文件锁定**连接态三态**在管理面上的如实转述(2026-10-07 的 UI 撒谎事故):
//
// - `POST /admin/channels/open` 之后 **不许**无条件报 connected=true;
// 适配器自报「重试中」时必须是 connected=false + pending + reason;
// - 适配器自报「就绪等接入」(server 模式)时是 connected=false + ready,
// 既不是「已连接」,也不是一句静态的「未连接」;
// - 没实现这条状态面的平台(绝大多数)响应逐字节不变 —— 可选键整键缺席。
//
// 断言打在**线上响应**上(不是内部函数),因为「面板读到什么」才是这件事的全部。
import (
"context"
"encoding/json"
"net/http"
"sync/atomic"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// outcomeChannel 是一个会自报连接态三态的假适配器:Connect 不致命(照常返回
// nil),真实状态与原因完全由测试给定,因此「网关有没有照实转述」是可断言的事实。
type outcomeChannel struct {
name string
state string
reason string
connected atomic.Bool
connects atomic.Int32
}
func (c *outcomeChannel) Name() string { return c.name }
func (c *outcomeChannel) IsConnected() bool { return c.connected.Load() }
func (c *outcomeChannel) HealthCheck(context.Context) error { return nil }
func (c *outcomeChannel) ConnectOutcome() (string, string) { return c.state, c.reason }
func (c *outcomeChannel) Connect(context.Context) error {
c.connects.Add(1)
// 「真连上」才置位;pending / ready 都必须保持未连上 —— 这正是三态要区分的。
c.connected.Store(c.state == "connected")
return nil
}
func (c *outcomeChannel) Disconnect(context.Context) error {
c.connected.Store(false)
return nil
}
// openChannelData 发起一次 open 并解出 data 对象(原始 map,好断言「键在不在」)。
func openChannelData(t *testing.T, ch *outcomeChannel) map[string]any {
t.Helper()
caps := capreg.New()
if err := caps.RegisterChannel(ch); err != nil {
t.Fatalf("register: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
map[string]any{"name": ch.name}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("open status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var out map[string]any
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode open data: %v (body=%s)", err, body)
}
return out
}
// TestChannelOpenReportsConnectedTruthfully 场景①:适配器自报真连上 ⇒ connected=true,
// 且三个可选键一律缺席(没有坏消息要额外说,老客户端形状不变)。
func TestChannelOpenReportsConnectedTruthfully(t *testing.T) {
t.Parallel()
out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "connected"})
if out["connected"] != true {
t.Fatalf("connected = %v, want true", out["connected"])
}
for _, key := range []string{"pending", "ready", "reason"} {
if _, present := out[key]; present {
t.Fatalf("%s 不该出现在「真连上」的响应里: %v", key, out)
}
}
}
// TestChannelOpenReportsPendingWithReason 场景②(本次事故的正题):适配器自报
// 「已在后台重试、当前未连上」时,响应**绝不能**是 connected=true —— 那正是面板弹出
// 「已连接」而卡片一直显示「未连接」的根源。必须如实给出 connected=false + pending
// + reason,且 resolved/name 等既有键原样保留。
func TestChannelOpenReportsPendingWithReason(t *testing.T) {
t.Parallel()
const reason = "qq-obv11: dial onebot ws ws://localhost:10301/: dial tcp: connection refused"
out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "pending", reason: reason})
if out["connected"] != false {
t.Fatalf("connected = %v, want false(重试中不许写成已连接)", out["connected"])
}
if out["pending"] != true {
t.Fatalf("pending = %v, want true(面板据此显示「正在连接」而不是「未连接」)", out["pending"])
}
if out["reason"] != reason {
t.Fatalf("reason = %v, want %q(必须把「为什么没连上」如实交出去)", out["reason"], reason)
}
if _, present := out["ready"]; present {
t.Fatalf("ready 不该与 pending 同时出现: %v", out)
}
if out["name"] != "qq-obv11" || out["resolved"] != "qq-obv11" {
t.Fatalf("既有键被破坏: %v", out)
}
}
// TestChannelOpenReportsReady 场景③:ws+server 已绑定监听、还没有对端接入。
// IsConnected() 同样是 false,但它**不是失败**,因此报 ready 而不是 pending,
// 也不带 reason(没有坏消息可说)。
func TestChannelOpenReportsReady(t *testing.T) {
t.Parallel()
out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "ready"})
if out["connected"] != false {
t.Fatalf("connected = %v, want false(监听就绪 ≠ 已连上)", out["connected"])
}
if out["ready"] != true {
t.Fatalf("ready = %v, want true(就绪等接入不是失败)", out["ready"])
}
for _, key := range []string{"pending", "reason"} {
if _, present := out[key]; present {
t.Fatalf("%s 不该出现在「就绪等接入」的响应里: %v", key, out)
}
}
}
// TestChannelOpenWithoutOutcomeKeepsLegacyShape 守住「不动别人」:没实现连接态三态
// 的适配器(绝大多数平台)响应与今天逐字节一致 —— 三个可选键整键缺席,只有既有四键。
func TestChannelOpenWithoutOutcomeKeepsLegacyShape(t *testing.T) {
t.Parallel()
caps := capreg.New()
only := &spyChannel{name: "kook:3fc8"}
if err := caps.RegisterChannel(only); err != nil {
t.Fatalf("register: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
map[string]any{"name": "kook"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var out map[string]any
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if len(out) != 3 || out["connected"] != true {
t.Fatalf("data = %v, want exactly {name,resolved,connected:true}", out)
}
for _, key := range []string{"pending", "ready", "reason"} {
if _, present := out[key]; present {
t.Fatalf("%s 不该出现(适配器没有这条状态面): %v", key, out)
}
}
}
// TestChannelCloseIsNotOptimistic 守住断开路径同样不乐观:报**真实的** IsConnected()。
// 一个「Disconnect 返回 nil 但其实没断开」的适配器必须被照实报成 connected=true,
// 而不是假定调用成功就等于断开了。
func TestChannelCloseIsNotOptimistic(t *testing.T) {
t.Parallel()
stuck := &outcomeChannel{name: "qq-obv11", state: "pending", reason: "still retrying"}
stuck.connected.Store(true) // 假装会话还活着,Disconnect 也没能拆掉它
stuckDisconnect := &stubbornChannel{outcomeChannel: stuck}
caps := capreg.New()
if err := caps.RegisterChannel(stuckDisconnect); err != nil {
t.Fatalf("register: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/close",
map[string]any{"name": "qq-obv11"}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("close status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var out map[string]any
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if out["connected"] != true {
t.Fatalf("connected = %v, want true(实测仍未断开就必须照实报)", out["connected"])
}
if _, present := out["pending"]; present {
t.Fatalf("关闭路径不该投影三态: %v", out)
}
}
// stubbornChannel 是一个 Disconnect 不真正断开连接的假适配器:用来证明关闭路径读的
// 是真实 IsConnected(),而不是「调用成功 ⇒ 一定断开了」。
type stubbornChannel struct {
*outcomeChannel
}
func (c *stubbornChannel) Disconnect(context.Context) error { return nil }
// TestChannelListExposesConnectOutcome 守住列表侧:面板卡片的状态来自
// GET /admin/channels,因此三态必须在那里也如实出现(平台行与账号行都要),
// 否则点完连接刷新一次列表,「连接中」又会变回一句静态的「未连接」。
func TestChannelListExposesConnectOutcome(t *testing.T) {
t.Parallel()
const reason = "qq-obv11: dial onebot ws ws://localhost:10301/: dial tcp: connection refused"
ch := &outcomeChannel{name: "qq-obv11:9616b27a", state: "pending", reason: reason}
store := channelsStoreOf(t, "qq-obv11",
config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}})
caps := capreg.New()
if err := caps.RegisterChannel(ch); err != nil {
t.Fatalf("register: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var rows []struct {
Name string `json:"name"`
Connected bool `json:"connected"`
Pending bool `json:"pending"`
Reason string `json:"reason"`
Accounts []struct {
ID string `json:"id"`
Connected *bool `json:"connected"`
Pending bool `json:"pending"`
Reason string `json:"reason"`
} `json:"accounts"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil {
t.Fatalf("decode list: %v (body=%s)", err, body)
}
if len(rows) != 1 {
t.Fatalf("rows = %d, want 1 (body=%s)", len(rows), body)
}
row := rows[0]
if row.Connected || !row.Pending || row.Reason != reason {
t.Fatalf("平台行 = %+v, want connected=false pending=true reason=%q", row, reason)
}
if len(row.Accounts) != 1 {
t.Fatalf("accounts = %d, want 1", len(row.Accounts))
}
acct := row.Accounts[0]
if acct.ID != "9616b27a" || acct.Connected == nil || *acct.Connected || !acct.Pending || acct.Reason != reason {
t.Fatalf("账号行 = %+v, want id=9616b27a connected=false pending=true reason=%q", acct, reason)
}
}
@@ -48,6 +48,58 @@ func enrichAccountsWithAccountOnline(accts []AdminChannelAccount, by map[string]
return enrichAccountBool(accts, by, func(a *AdminChannelAccount, v bool) { a.AccountOnline = &v }) return enrichAccountBool(accts, by, func(a *AdminChannelAccount, v bool) { a.AccountOnline = &v })
} }
// liveAccountConnectOutcomes 收集每个平台各账号的**连接态三态**(适配器自报的
// pending / ready / reason),键与 liveAccountConnections 相同(裸适配器名 ⇒ 空账号 id)。
//
// 只有确实自报了三态的账号才进 map:三态投影为零值(适配器没这条状态面,或它此刻自认
// 「真连上 / 无意见」)就缺席 —— 缺席 = 面板退回只看 connected 布尔,绝不凭空编一个
// 状态。补一个 false 顶上去,与本次要根除的「编一个状态」同属一类。
func (s *Server) liveAccountConnectOutcomes() map[string]map[string]channelConnectOutcome {
out := make(map[string]map[string]channelConnectOutcome)
if s.caps == nil {
return out
}
for _, cap := range s.caps.List(capreg.KindChannel) {
ch, ok := cap.Value.(sdk.Channel)
if !ok {
continue
}
_, outcome := channelConnectStateOf(ch)
if outcome == (channelConnectOutcome{}) {
continue
}
platform, acct := splitAdapterName(ch.Name())
if out[platform] == nil {
out[platform] = make(map[string]channelConnectOutcome)
}
out[platform][acct] = outcome
}
return out
}
// enrichAccountsWithConnectOutcome 把**连接态三态**合并进平台中匹配的账号(按账号 id
// 配对)。不在 by 里的账号保持 Pending/Ready/Reason 全空 ⇒ 整键缺席 ⇒ 面板退回只看
// connected。入参切片不被修改。
func enrichAccountsWithConnectOutcome(
accts []AdminChannelAccount,
by map[string]channelConnectOutcome,
) []AdminChannelAccount {
if len(by) == 0 {
return accts
}
out := make([]AdminChannelAccount, 0, len(accts))
for _, a := range accts {
item := a
if o, ok := by[item.ID]; ok {
item.Pending = o.Pending
item.Ready = o.Ready
item.Reason = o.Reason
}
out = append(out, item)
}
return out
}
// enrichAccountBool 是上面两个「可选布尔状态」装配器的共同实现:只对 by 里出现过的 // enrichAccountBool 是上面两个「可选布尔状态」装配器的共同实现:只对 by 里出现过的
// 账号调用 set(缺席即字段保持 nil = 未知),因此调用方不必各自重写这段三态逻辑 —— // 账号调用 set(缺席即字段保持 nil = 未知),因此调用方不必各自重写这段三态逻辑 ——
// 三态口径只写一遍,谁也别想在其中一处悄悄把「未知」写成 false。 // 三态口径只写一遍,谁也别想在其中一处悄悄把「未知」写成 false。
@@ -135,11 +135,38 @@ func (s *Server) firstConfiguredChannel(name string) (sdk.Channel, bool) {
// channelActionResult 是 open/close 的响应体:name 保持请求里的名字(老客户端 // channelActionResult 是 open/close 的响应体:name 保持请求里的名字(老客户端
// 契约不变),resolved 是**实际被驱动**的适配器注册名(多账号平台上它与请求的 // 契约不变),resolved 是**实际被驱动**的适配器注册名(多账号平台上它与请求的
// 平台名不同,面板据此显示「连的是哪一条」),accountId 在指定时回显。凭据永不出现。 // 平台名不同,面板据此显示「连的是哪一条」),accountId 在指定时回显。凭据永不出现。
func channelActionResult(target channelTarget, resolved string, connected bool) map[string]any { //
// 除既有四个键外,另按适配器自报的**连接态三态**(见 admin_channel_connect_outcome.go)
// 补三个**可选**键,且取不到就整键缺席(同 NeedsRescan / LastError 口径):
//
// pending: true —— 已在后台重试、当前未连上 ⇒ 此时 connected=false。面板须读作
// 「连接中(reason)」,**不是**「未连接」,更不是「已连接」;
// ready: true —— 已就绪、等待对端接入(server 模式)⇒ 同样 connected=false,
// 但它不是失败;
// reason: "<已脱敏>" —— 未连上时的原因,仅在 pending 时可能出现。
//
// 真连上时这三个键一律缺席:connected=true 已经把话说完了,再补一个「没有坏消息」的
// 空串只会让老客户端多出一个要处理的键。适配器没实现这条状态面时同理 —— 响应与今天
// 逐字节一致。
func channelActionResult(
target channelTarget,
resolved string,
connected bool,
outcome channelConnectOutcome,
) map[string]any {
out := map[string]any{"name": target.Name, "resolved": resolved, "connected": connected} out := map[string]any{"name": target.Name, "resolved": resolved, "connected": connected}
if target.AccountID != "" { if target.AccountID != "" {
out["accountId"] = target.AccountID out["accountId"] = target.AccountID
} }
if outcome.Pending {
out["pending"] = true
}
if outcome.Ready {
out["ready"] = true
}
if outcome.Reason != "" {
out["reason"] = outcome.Reason
}
return out return out
} }
+18 -4
View File
@@ -63,6 +63,7 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
botByPlatform := s.liveBotIdentity() botByPlatform := s.liveBotIdentity()
connectedByPlatform := s.liveAccountConnections() connectedByPlatform := s.liveAccountConnections()
onlineByPlatform := s.liveAccountOnline() onlineByPlatform := s.liveAccountOnline()
outcomeByPlatform := s.liveAccountConnectOutcomes()
out := make([]ChannelSummary, 0) out := make([]ChannelSummary, 0)
if s.caps != nil { if s.caps != nil {
for _, cap := range s.caps.List(capreg.KindChannel) { for _, cap := range s.caps.List(capreg.KindChannel) {
@@ -87,7 +88,17 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
if s.hasChannelSummary(out, platform) { if s.hasChannelSummary(out, platform) {
continue continue
} }
sum := ChannelSummary{Name: platform, Connected: ch.IsConnected()} // 连接态三态(v0.68):`connected=false` 可能只是「正在后台重试」或
// 「监听已就绪、等对端接入」,两者都不是「未连接」这样一个静态结论。
// 只有适配器确实自报了才写这三个键,否则一律省略(同 NeedsRescan 口径)。
connected, outcome := channelConnectStateOf(ch)
sum := ChannelSummary{
Name: platform,
Connected: connected,
Pending: outcome.Pending,
Ready: outcome.Ready,
Reason: outcome.Reason,
}
// 可选的 rescan 能力(weixin-iLink 会话被拒,v0.39):仅在适配器 // 可选的 rescan 能力(weixin-iLink 会话被拒,v0.39):仅在适配器
// 确实要求重新扫码时才报告,因此健康的适配器保持字段省略, // 确实要求重新扫码时才报告,因此健康的适配器保持字段省略,
// 而不是送出一个编造的值。 // 而不是送出一个编造的值。
@@ -98,9 +109,12 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
} }
} }
if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 { if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 {
sum.Accounts = enrichAccountsWithAccountOnline( sum.Accounts = enrichAccountsWithConnectOutcome(
enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]), enrichAccountsWithAccountOnline(
onlineByPlatform[platform], enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]),
onlineByPlatform[platform],
),
outcomeByPlatform[platform],
) )
} }
out = append(out, sum) out = append(out, sum)
@@ -70,6 +70,14 @@ type AdminChannelAccount struct {
// 或尚未探到结果),面板不得画成「离线」,也不得画成「在线」。 // 或尚未探到结果),面板不得画成「离线」,也不得画成「在线」。
// 见 design/24 §10.11 与 Docs/api-client.md §2.4(b)。 // 见 design/24 §10.11 与 Docs/api-client.md §2.4(b)。
AccountOnline *bool `json:"accountOnline,omitempty"` AccountOnline *bool `json:"accountOnline,omitempty"`
// Pending/Ready/Reason 是该账号适配器自报的**连接态三态**(可选能力,见
// internal/services/gateway/admin_channel_connect_outcome.go):Connected=false 时
// 面板据此把「已在后台重试(连接中)」与「已就绪、等待对端接入」从「未连接」里
// 分出来。三者缺省 = 该适配器没有这条状态面 ⇒ 面板退回只看 Connected,
// 绝不凭空编一个状态。
Pending bool `json:"pending,omitempty"`
Ready bool `json:"ready,omitempty"`
Reason string `json:"reason,omitempty"`
// Capabilities 列出该平台适配器暴露的账号级管理操作 // Capabilities 列出该平台适配器暴露的账号级管理操作
// (friends/channels/user-chats/message-test),使面板 // (friends/channels/user-chats/message-test),使面板
// 渲染正确的每账号卡片。没有专用适配器表面的平台 // 渲染正确的每账号卡片。没有专用适配器表面的平台
@@ -303,7 +303,12 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
return return
} }
s.auditClaimsAccount(r, "channel.open", "ok", target.AccountID) s.auditClaimsAccount(r, "channel.open", "ok", target.AccountID)
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), true)) // `Connect` 返回 nil 只说明「这次调用没有致命失败」——ws+client 的首连失败刻意
// 非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪。所以这里
// **绝不**硬编码 connected=true:如实取适配器自报的连接态三态,并把「重试中(含
// 原因)」与「就绪等接入」作为可选键一并交出(见 admin_channel_connect_outcome.go)。
connected, outcome := channelConnectStateOf(ch)
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), connected, outcome))
} }
// adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。accountId // adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。accountId
@@ -324,7 +329,11 @@ func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
return return
} }
s.auditClaimsAccount(r, "channel.close", "ok", target.AccountID) s.auditClaimsAccount(r, "channel.close", "ok", target.AccountID)
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), false)) // 断开不乐观:报**真实的** IsConnected(),而不是假定「调用成功 ⇒ 一定断开了」。
// 关闭路径刻意不投影三态:pending / ready 说的是「连的过程」,而这里要回答的是
// 「会话到底还在不在」——那正是 IsConnected() 的语义。
writeJSON(w, http.StatusOK,
channelActionResult(target, ch.Name(), ch.IsConnected(), channelConnectOutcome{}))
} }
// channelTestRequest 是 POST /api/v1/admin/channels/test 的请求体:一个渠道 // channelTestRequest 是 POST /api/v1/admin/channels/test 的请求体:一个渠道
+9 -3
View File
@@ -106,9 +106,13 @@ type Adapter struct {
httpAPI *obHTTPClient httpAPI *obHTTPClient
started bool started bool
connected bool connected bool
cancel context.CancelFunc // lastConnectErr 是最近一次连接失败的**脱敏**原因,供 ConnectOutcome 如实回答
lifeCtx context.Context // 「为什么没连上」(见 connect_outcome.go)。连上或断开即清空:一条已经过期的
newConn chan struct{} // 原因不该继续挂在当前状态旁边。由 mu 保护。
lastConnectErr string
cancel context.CancelFunc
lifeCtx context.Context
newConn chan struct{}
handlerMu sync.RWMutex handlerMu sync.RWMutex
handler adapter.MessageHandler handler adapter.MessageHandler
@@ -297,6 +301,8 @@ func (a *Adapter) Disconnect(ctx context.Context) error {
a.ln = nil a.ln = nil
a.srv = nil a.srv = nil
a.httpAPI = nil a.httpAPI = nil
// 断开即清空上一次失败原因:会话已被拆掉,「为什么没连上」不再是当前事实。
a.lastConnectErr = ""
// 断开即丢弃账号在线状态:新会话的 known=false 由下一次探活重新确立,绝不沿用 // 断开即丢弃账号在线状态:新会话的 known=false 由下一次探活重新确立,绝不沿用
// 上一轮的陈旧结论(AccountStatus 的诚实口径)。 // 上一轮的陈旧结论(AccountStatus 的诚实口径)。
a.account = accountStatus{} a.account = accountStatus{}
@@ -0,0 +1,121 @@
package qq_obv11
// 本文件承载 qq-obv11 的**连接态三态**:一次 Connect 返回之后,该适配器到底处于
// 「真连上 / 正在重试 / 就绪等接入」中的哪一种,以及没连上时的**脱敏原因**。
//
// 为什么需要它:`Connect` 返回 nil 只说明「这次调用没有致命失败」——ws+client 的首连
// 失败是**故意非致命**的(见 ws_client.go 与 reconnect.go:NapCat 等 OneBot 实现可能
// 比 Cyrene 后启动)。于是管理面板拿到的 `connected=false` 既可能是「正在后台重试」,
// 也可能是「监听已就绪等对端接入」,而它与「配置错、起不来」在布尔上无从区分;面板只能
// 编一个说法,编出来的正是 2026-10-07 那个「点了连接就弹已连接」的谎。三态把这三件事
// 分开,并且**由适配器自己回答**——只有它知道自己的 transport × mode 意味着什么。
//
// 设计依据:DevDocs/design/08-渠道接入.md §1(PlatformAdapter 契约)与 §4.1(四端
// transport × mode);三态取值与判定顺序定义在 internal/channel/adapter 的
// ConnectStateOf(跨平台共用一份口径,别的适配器接上时不会各自走样)。立场与
// design/26 §2.6「状态面必须区分 WS 连着与账号在线」一致:一个布尔说不了三件事。
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maskedCredential 是脱敏后的占位符(与全链路可视化的脱敏标记同形,便于一眼认出
// 「这里原本是凭据」而不是「这里本来就长这样」)。
const maskedCredential = "[redacted]"
// ConnectOutcome 报告一次 Connect 之后该适配器处于哪种就绪状态。
//
// state 是闭集取值(adapter.ConnectStateConnected / ConnectStatePending /
// ConnectStateReady),空串表示「无意见」(尚未启动,无从判断);reason 是未连上时的
// **脱敏**原因,绝不含 access_token。
//
// 它是可选的适配器能力:网关经类型断言读取(internal/services/gateway 的
// connectOutcomeAware),核心 PlatformAdapter 契约刻意不扩展。
func (a *Adapter) ConnectOutcome() (state, reason string) {
a.mu.RLock()
defer a.mu.RUnlock()
return adapter.ConnectStateOf(adapter.ConnectSignals{
Started: a.started,
Connected: a.connected,
// 只有 ws+client 有一条后台重试循环(readLoop 复用 connectClient),因此
// 「未连上 ⇒ 连接中」这条推论只在它上面成立;HTTP 没有会话可重连。
Retrying: a.cfg.Mode == modeClient && a.cfg.Transport != transportHTTP,
// 监听已绑定 ⇒ 就绪等对端接入(ws+server;http+server 也有监听器,但它由
// Connect 直接置 connected=true,走不到这里)。
Listening: a.ln != nil,
Reason: a.lastConnectErr,
})
}
// connectFailureReason 把一次连接失败整理成适配器可以如实回答的脱敏原因。
func (a *Adapter) connectFailureReason(err error) string {
if err == nil {
return ""
}
return sanitizeConnectReason(err.Error(), a.cfg.AccessToken)
}
// sanitizeConnectReason 把一次连接失败的原因整理成可以对外说的短句。三层防线缺一不可:
//
// 1. trace.RedactText 做形状级兜底与截断(与全链路可视化同一道红线):一个不设上限的
// 错误串不该被原样端给面板,而凭据形状(api_key/bearer/JWT/≥6 字符的 token 赋值)
// 在这一层就被抹掉 —— 包括 token 已被换掉、我们并不知道明文的那种情况。
// 2. 配置里的 access_token 明文整体替换 —— ws_client.go 的 appendQueryToken 会把 token
// 拼进拨号 URL,任何把该 URL 写进错误串的实现都会把它带出来;而**短 token** 逃得过
// 第 1 层的形状匹配,只有「已知明文」这一层能兜住。
// 3. 剥掉 URL 里 access_token 查询参数的值(含内嵌在引号/反括号里的那些)。
//
// 顺序是刻意的:先形状级、再逐字剥。反过来(先剥参数再交给形状级)会让第 1 层的贪婪
// 匹配把我们已经写好的 `access_token=` 参数名一起吃掉,留下更难读懂的一句话。
//
// 空原因返回空串 —— 「没有原因」与「原因被脱敏成空」是两件事,绝不互相冒充。
func sanitizeConnectReason(raw, token string) string {
if raw == "" {
return ""
}
raw = trace.RedactText(raw)
if token != "" {
raw = strings.ReplaceAll(raw, token, maskedCredential)
}
return maskAccessTokenQuery(raw)
}
// maskAccessTokenQuery 把 access_token 查询参数的值替换成占位符。大小写不敏感,循环
// 处理多次出现(同一错误串里可能带多条 URL)。
//
// 每轮把已处理的前缀搬进 builder、只对**剩余部分**再查:参数名会被原样保留,因此在同一
// 段文本上原地重查会永远命中同一个位置(占位符不改变它的存在),那是死循环而不是脱敏。
func maskAccessTokenQuery(raw string) string {
const key = "access_token="
var b strings.Builder
rest := raw
for {
i := strings.Index(strings.ToLower(rest), key)
if i < 0 {
b.WriteString(rest)
return b.String()
}
start := i + len(key)
end := start
for end < len(rest) && !isQueryValueDelimiter(rest[end]) {
end++
}
b.WriteString(rest[:start])
b.WriteString(maskedCredential)
rest = rest[end:]
}
}
// isQueryValueDelimiter 报告该字节是否终止一个查询参数值:`&`/`#` 是 URL 分隔符,
// 空白与引号、反括号则是内嵌在错误串里的 URL 的常见收尾。
func isQueryValueDelimiter(c byte) bool {
switch c {
case '&', '#', ' ', '\t', '\n', '\r', '"', '\'', ')', '}', ']':
return true
default:
return false
}
}
@@ -0,0 +1,273 @@
package qq_obv11
// 本文件测连接态三态的**适配器落点**:ConnectOutcome 如实回答「Connect 之后处于哪种
// 状态」,以及没连上时那条原因确实被脱敏(access_token 绝不出现)。
//
// 为什么必须有它:2026-10-07 管理面板「点连接就弹已连接」的根因,就是适配器只对外给
// 一个 IsConnected() 布尔 —— ws+client 的首连失败刻意非致命(NapCat 等可能比 Cyrene
// 后启动),于是「正在重试」「监听就绪」「起不来」三件事在布尔上长得一模一样。
// 纯判定部分(adapter.ConnectStateOf)见 internal/channel/adapter 的测试。
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"net"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// tokenLeakDialer 是一个**故意把 access_token 拼进错误串**的拨号替身:真实世界里任何
// 把拨号 URL 写进错误串的实现都会这样(ws_client.go 的 appendQueryToken 会把 token 拼
// 进 URL)。它使「脱敏真的接在了存储与日志前面」成为可断言的事实,而不是一句注释。
type tokenLeakDialer struct{ rawURL string }
func (d tokenLeakDialer) dial(context.Context, string, string) (net.Conn, error) {
return nil, fmt.Errorf("test: dial %s failed", d.rawURL)
}
// TestSanitizeConnectReasonStripsAccessToken 断言原因串里的 access_token 绝不出现,
// 且留下可读的占位符(面板要能看出「这里原本是凭据」,而不是一段乱码)。
func TestSanitizeConnectReasonStripsAccessToken(t *testing.T) {
t.Parallel()
const token = "s3cr3t-token-value"
raw := "websocket: dial ws://hook.example/onebot?access_token=" + token + "&self_id=1: connection refused"
got := sanitizeConnectReason(raw, token)
if strings.Contains(got, token) {
t.Fatalf("脱敏后仍含 access_token 明文: %q", got)
}
if !strings.Contains(got, maskedCredential) {
t.Errorf("脱敏后没有留下占位符: %q", got)
}
}
// TestSanitizeConnectReasonStripsShortToken 断言**短、且紧邻其它参数**的 token 也剥得掉:
// 形状级匹配要求赋值后至少 6 个非空白字符,`access_token=x&y` 这种逃得过它,只有
// 「已知明文 + 剥查询参数」这两层能兜住 —— 这正是它们存在的理由。
//
// (参数名有时会被形状级兜底连同值一起吞掉:少一点诊断信息总好过泄露凭据,
// 所以这里只断言**凭据绝不出现**,不绑定具体措辞。)
func TestSanitizeConnectReasonStripsShortToken(t *testing.T) {
t.Parallel()
const token = "ab12"
for _, raw := range []string{
"dial ws://h/p?access_token=ab12&self_id=1 failed",
"websocket: dial ws://h/p?access_token=ab12: connection refused",
} {
got := sanitizeConnectReason(raw, token)
if strings.Contains(got, token) {
t.Errorf("短 token 泄露: %q → %q", raw, got)
}
if !strings.Contains(got, maskedCredential) {
t.Errorf("脱敏后没有留下占位符: %q → %q", raw, got)
}
}
}
// TestSanitizeConnectReasonStripsUnknownShortQueryToken 钉住第 3 层(剥 `access_token=`
// 查询参数)**独有**的那一小段窗口:值**短于 6 个字符、且紧邻空白/引号等终止符**时,
// 形状级那条规则(要求赋值后至少 6 个非空白字符)匹配不上,而「已知明文替换」又因为
// 这把值**不是**我们配置的 token 而无从下手 —— 两层都落空,只剩这一层能兜住。
//
// 边界是实测出来的,不是想象的:`access_token=ab12&self_id=1` 这种写法会被形状级
// **顺带**吃掉(它的值字符类一直吃到空格为止,于是 ≥6 字符成立),所以那类串钉不住
// 第 3 层;本用例特意让短值后面紧跟一个空白,正好落在第 1、2 层都够不着的那一格。
// 去掉第 3 层实现时本条必须变红 —— 它是那一层唯一的钉子。
func TestSanitizeConnectReasonStripsUnknownShortQueryToken(t *testing.T) {
t.Parallel()
const leaked = "ab12"
raw := "websocket: dial ws://hook.example/onebot?access_token=" + leaked + " rejected during handshake"
got := sanitizeConnectReason(raw, "")
if strings.Contains(got, leaked) {
t.Fatalf("未配置 token 时仍泄露了他处的 access_token 明文: %q", got)
}
if !strings.Contains(got, maskedCredential) {
t.Errorf("脱敏后没有留下占位符: %q", got)
}
}
// TestSanitizeConnectReasonEmptyStaysEmpty 断言「没有原因」与「被脱敏成空」不互相冒充。
func TestSanitizeConnectReasonEmptyStaysEmpty(t *testing.T) {
t.Parallel()
if got := sanitizeConnectReason("", "tok"); got != "" {
t.Fatalf("空原因 = %q, want 空串", got)
}
}
// TestConnectOutcomeClientPendingCarriesSanitizedReason 场景②(事故正题):ws+client
// 首连失败 ⇒ Connect 照旧返回 nil(非致命),但 ConnectOutcome 必须如实报 pending
// 并带上**脱敏后**的原因;日志里同样不许出现 token。
func TestConnectOutcomeClientPendingCarriesSanitizedReason(t *testing.T) {
t.Parallel()
const token = "super-secret-token"
const rawURL = "ws://127.0.0.1:10301/onebot"
store := &testLogStore{}
a := NewAdapter(Config{
Mode: modeClient,
RemoteURL: rawURL,
AccessToken: token,
DialContext: tokenLeakDialer{rawURL: appendQueryToken(rawURL, token)}.dial,
Logger: slog.New(&testLogHandler{store: store}),
})
a.policy = testReconnectPolicy()
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect() error = %v, want nil(首连失败必须非致命)", err)
}
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
state, reason := a.ConnectOutcome()
if state != adapter.ConnectStatePending {
t.Fatalf("ConnectOutcome state = %q, want %q(未连上且重试在跑)", state, adapter.ConnectStatePending)
}
if state == adapter.ConnectStateConnected {
t.Fatal("首连失败被报成已连接 —— 这正是面板撒谎的根因")
}
if reason == "" {
t.Fatal("pending 必须带上原因:面板要能把「为什么没连上」如实说出来")
}
if strings.Contains(reason, token) {
t.Fatalf("原因串泄露 access_token: %q", reason)
}
assertNoTokenLeak(t, store, token)
}
// TestConnectOutcomeClientConnected 场景①:对面在时拨号成功 ⇒ connected,且没有原因
// 可挂(「已连接」旁边不该留一条过期的失败消息)。
func TestConnectOutcomeClientConnected(t *testing.T) {
t.Parallel()
srv, _ := newFakeOneBotServer(t)
defer srv.Close()
a := NewAdapter(Config{
Mode: modeClient,
RemoteURL: "ws://" + strings.TrimPrefix(srv.URL, "http://"),
DialContext: (&net.Dialer{}).DialContext,
})
a.policy = testReconnectPolicy()
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect() error = %v", err)
}
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
state, reason := a.ConnectOutcome()
if state != adapter.ConnectStateConnected || reason != "" {
t.Fatalf("ConnectOutcome = (%q,%q), want (%q,\"\")", state, reason, adapter.ConnectStateConnected)
}
if !a.IsConnected() {
t.Fatal("报 connected 却 IsConnected()=false:两个事实面必须一致")
}
}
// TestConnectOutcomeServerReadyThenConnected 场景③:ws+server 绑定监听但没有对端接入时
// 是 ready(IsConnected() 同为 false,却**不是失败**);对端反连上来之后变 connected。
func TestConnectOutcomeServerReadyThenConnected(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{Mode: modeServer, ListenAddr: "127.0.0.1:0"})
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect() error = %v", err)
}
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
if a.IsConnected() {
t.Fatal("还没有对端接入就报 IsConnected()=true")
}
if state, reason := a.ConnectOutcome(); state != adapter.ConnectStateReady || reason != "" {
t.Fatalf("ConnectOutcome = (%q,%q), want (%q,\"\")", state, reason, adapter.ConnectStateReady)
}
client, err := dialWS(context.Background(), nil, "ws://"+a.Addr()+"/", "")
if err != nil {
t.Fatalf("dial server error: %v", err)
}
defer client.Close()
if !waitUntil(3*time.Second, a.IsConnected) {
t.Fatal("对端接入后仍未报已连接")
}
if state, _ := a.ConnectOutcome(); state != adapter.ConnectStateConnected {
t.Fatalf("对端接入后 ConnectOutcome state = %q, want %q", state, adapter.ConnectStateConnected)
}
}
// TestConnectOutcomeClearsReasonOnSuccess 断言连上时清空失败原因:一条已经过期的原因
// 不该继续挂在「已连接」旁边(面板会把过期的与当前的混为一谈)。
func TestConnectOutcomeClearsReasonOnSuccess(t *testing.T) {
t.Parallel()
srv, _ := newFakeOneBotServer(t)
defer srv.Close()
gate := make(chan struct{})
dial := &countingDialer{failTimes: 1, gate: gate, delegate: (&net.Dialer{}).DialContext}
a := NewAdapter(Config{
Mode: modeClient,
RemoteURL: "ws://" + strings.TrimPrefix(srv.URL, "http://"),
DialContext: dial.dial,
})
a.policy = testReconnectPolicy()
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect() error = %v", err)
}
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
if state, reason := a.ConnectOutcome(); state != adapter.ConnectStatePending || reason == "" {
t.Fatalf("首连失败后 = (%q,%q), want pending 且带原因", state, reason)
}
close(gate) // 对面起来了
if !waitUntil(3*time.Second, a.IsConnected) {
t.Fatal("第 2 次拨号成功却没有自动连上")
}
state, reason := a.ConnectOutcome()
if state != adapter.ConnectStateConnected || reason != "" {
t.Fatalf("连上后 = (%q,%q), want connected 且原因为空(过期的原因必须清掉)", state, reason)
}
}
// TestConnectOutcomeAfterDisconnectHasNoOpinion 断言断开之后适配器「无意见」:会话已经
// 拆掉,「连的过程」不再是当前事实,网关据此退回只看 IsConnected()。
func TestConnectOutcomeAfterDisconnectHasNoOpinion(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{
Mode: modeClient,
RemoteURL: "ws://127.0.0.1:1/",
DialContext: (&countingDialer{failTimes: math.MaxInt}).dial,
})
a.policy = testReconnectPolicy()
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect() error = %v", err)
}
if err := a.Disconnect(context.Background()); err != nil {
t.Fatalf("Disconnect() error = %v", err)
}
if state, reason := a.ConnectOutcome(); state != "" || reason != "" {
t.Fatalf("Disconnect 后 = (%q,%q), want 无意见", state, reason)
}
}
// TestConnectOutcomeConfigErrorStaysFatalNotPending 守住「配置错仍然是硬失败」:外层的 Connect 报错路径没被动过,
// 因此配置错永远不会走到 ConnectOutcome 这里被说成 pending。
func TestConnectOutcomeConfigErrorStaysFatalNotPending(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{Mode: modeClient})
err := a.Connect(context.Background())
if err == nil {
t.Fatal("缺 remote_url 的 Connect() = nil, want error")
}
if !errors.Is(err, errClientConfig) {
t.Fatalf("Connect() error = %v, want errClientConfig", err)
}
if state, _ := a.ConnectOutcome(); state != "" {
t.Fatalf("未启动的适配器 ConnectOutcome = %q, want 无意见", state)
}
}
+9 -3
View File
@@ -122,9 +122,15 @@ func (s *reconnectState) reset() {
s.failures = 0 s.failures = 0
} }
// noteConnectFailure 记录一次 client 模式的连接失败:推进退避,并写一条带来源的 // noteConnectFailure 记录一次 client 模式的连接失败:推进退避,把**脱敏后的**原因留在
// WARN(含 err 与下次重试间隔)。access_token 绝不入日志。 // 适配器上(供 ConnectOutcome 如实回答「为什么没连上」,见 connect_outcome.go),并写一条
// 带来源的 WARN(含原因与下次重试间隔)。原因与日志都走同一道脱敏,因此 access_token
// 既不落日志、也不进对外文案。
func (a *Adapter) noteConnectFailure(err error) { func (a *Adapter) noteConnectFailure(err error) {
delay := a.reconnect.fail(a.policy) delay := a.reconnect.fail(a.policy)
a.log.Warn("onebot connect failed; retrying", "err", err, "retryIn", delay) reason := a.connectFailureReason(err)
a.mu.Lock()
a.lastConnectErr = reason
a.mu.Unlock()
a.log.Warn("onebot connect failed; retrying", "err", reason, "retryIn", delay)
} }
+3
View File
@@ -330,6 +330,9 @@ func (a *Adapter) setConn(conn *wsConn) {
old := a.conn old := a.conn
a.conn = conn a.conn = conn
a.connected = true a.connected = true
// 会话真的活了 ⇒ 上一次失败的原因不再是当前事实,立刻清掉,免得面板把一条已经
// 过期的原因挂在「已连接」旁边。
a.lastConnectErr = ""
a.mu.Unlock() a.mu.Unlock()
if old != nil && old != conn { if old != nil && old != conn {
_ = old.Close() _ = old.Close()