Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c30ca540a | ||
|
|
59672720bd | ||
|
|
68ba85389b | ||
|
|
0c5fa7b2ae | ||
|
|
9a3b3cff8b | ||
|
|
ae68bb0241 | ||
|
|
b4d55f412e | ||
|
|
d81635672e | ||
|
|
23b6e3fe1e | ||
|
|
d680390300 |
No files matched your search
+33
-5
@@ -1,6 +1,6 @@
|
||||
# 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 桥接等**跨平台客户端开发者**。
|
||||
> **唯一真源 = 当前代码**(`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,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
|
||||
@@ -255,8 +255,8 @@
|
||||
| C16 | POST | `/api/v1/admin/llm` | 添加/更新 LLM provider(写 `models.json` + 热重载) |
|
||||
| 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`) |
|
||||
| C19 | POST | `/api/v1/admin/channels/open` | 打开渠道(v0.66 增 `accountId`,见下注) |
|
||||
| C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道(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`,见下注;**v0.90 起 `connected` 取真实 `IsConnected()`**) |
|
||||
| C21 | POST | `/api/v1/admin/channels/test` | 渠道连通测试 |
|
||||
| C22 | GET | `/api/v1/admin/channels/{platform}/login-qr` | 取登录二维码(获取凭据,故 owner-only) |
|
||||
| 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` 一律是「否」。
|
||||
> - 响应:`{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 冻结版对照**。
|
||||
|
||||
@@ -334,6 +334,15 @@
|
||||
- 🔴 **同域但未实现的面**:`GET /api/v1/admin/channels` 的 `accounts[]` 元素**尚未**带**每账号** `authState`/`authDetail`(缺 `AdminChannelAccount` 两字段与合并器的接线)—— 见 §7.2。
|
||||
- ⚠️ **未确证(真机)**:真实 `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`
|
||||
@@ -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)。
|
||||
> 每版一句话 + 三档标注。**三档定义见 [§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`。)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"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/user"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// ownerRegistrar 实现 gateway.OwnerRegistrar:它把一个渠道账号 id 持久化进主人
|
||||
@@ -57,6 +58,30 @@ func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accou
|
||||
// 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。
|
||||
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 渠道适配器,把它们注册进能力注册表(管理
|
||||
// API 据此枚举),并接进渠道 bridge——让已配置的适配器连上,把入站消息按
|
||||
// session→actor→loop→reply 路由。缺凭据的适配器保持未连接(只记日志,绝不作
|
||||
@@ -122,6 +147,10 @@ func (a *app) wireChannels() error {
|
||||
// EngageFor 留空(P2a);按群覆盖随 P2c 到来。
|
||||
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
|
||||
Factory: factory,
|
||||
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
|
||||
// 状态、open/close 的目标解析)读的就是注册表,不同步它就只能看到旧实例,
|
||||
// 于是卡片永远「未连接」、点「连接」驱动的是旧 URL 的那把。
|
||||
Channels: channelCaps{caps: a.caps},
|
||||
// ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色
|
||||
// (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。
|
||||
ChannelPersona: factory.Persona,
|
||||
|
||||
@@ -11,17 +11,22 @@ import (
|
||||
// 本文件把记忆整理(DevDocs/design/23-记忆整理与去重 §4)接进组合根。
|
||||
//
|
||||
// 四件事只在这里定:
|
||||
// - **形态**:低峰定期后台任务(memory.TidyService 的心跳),不是会话内派
|
||||
// subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
|
||||
// - **形态**:定期后台任务(安静时段降频)的心跳(memory.TidyService),不是
|
||||
// 会话内派 subagent。它在 main.go 与 backthink 并列启动、在 close 里先于关库停止;
|
||||
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
|
||||
// 因此 design §4.1「绝不碰主会话上下文」是结构性成立的,而不是靠自觉;
|
||||
// - **审计**:接到网关的 AuditEvent(design §8 的 memory.tidy.*),出口在调用
|
||||
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 onToolAudit
|
||||
// 同一手法。未就绪时只记日志,绝不假装写过审计;
|
||||
// - **运行期调优**(2026-10-07 新增):从 `$DATA_DIR/config/memory-tidy.json`
|
||||
// 读「自动落地开关 + 每轮上限」。它是 `.env` 冻结(design/13 §2.1)之后新旋钮
|
||||
// 的唯一去处,**零新 env 键**;读不到/解不开一律回落代码默认值并记 WARN
|
||||
// (坏配置绝不能让自动档变成「无限制」)。
|
||||
// 读「自动落地开关 + 每轮上限」,以及(同日追加)**整理频率**四项
|
||||
// (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)。它是
|
||||
// `.env` 冻结(design/13 §2.1)之后新旋钮的唯一去处,**零新 env 键**;面板优先、
|
||||
// `.env` 兜底,键缺位或值不可用一律回落下一档并记 WARN(坏配置绝不能让自动档
|
||||
// 变成「无限制」,也不能让整理频率变成忙轮询);
|
||||
// - **启动播种**(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件**要做**」):
|
||||
// 那个文件不存在时由后端自己写一份「当前生效值」进去(seedMemoryTidyPanel),
|
||||
// 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着。
|
||||
//
|
||||
// 刻意**不做**的一件事(design §4.1 F):
|
||||
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
|
||||
@@ -29,6 +34,12 @@ import (
|
||||
func (a *app) wireMemoryTidy() error {
|
||||
cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy)
|
||||
a.applyMemoryTidyTuning(&cfg)
|
||||
// 启动播种(主人 2026-10-07:「后端**自动创建**独立的面板配置文件**要做**」):
|
||||
// 文件不存在/空白 ⇒ 把**此刻的生效值**写成面板的一等来源,此后 `.env` 只兜底。
|
||||
// 放在 applyMemoryTidyTuning **之后**是刻意的:那一刻的 cfg 就是
|
||||
// 「面板 > `.env` > 代码默认」的结果(面板缺失时即 `.env` + 默认),播它 ⇒ 行为
|
||||
// 零变化。文件已存在(含损坏)⇒ 一个字节都不动(幂等,见 runtimeconfig.SeedMemoryTidy)。
|
||||
a.seedMemoryTidyPanel(cfg)
|
||||
svc, err := memory.NewTidyService(cfg, memory.TidyDeps{
|
||||
Store: a.memStore,
|
||||
Embedder: a.embedder,
|
||||
@@ -42,29 +53,65 @@ func (a *app) wireMemoryTidy() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedMemoryTidyPanel 在 `$DATA_DIR/config/memory-tidy.json` 缺失/空白时播种一份
|
||||
// 「当前生效值」快照(`memory.TidyTuningSnapshot`),使面板配置从第一次启动起就自足,
|
||||
// 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` +
|
||||
// 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。
|
||||
//
|
||||
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)在 `.env` 里
|
||||
// **没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
|
||||
// wireMemoryReview 的三个 helper),而播种只发生在**面板文件不存在**时 —— 那正是
|
||||
// 「零值 + normalizeReviewConfig」这一条路径,因此这里给零值 ReviewConfig 就是
|
||||
// **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十键里,
|
||||
// 因此不必为它去读一次 env。
|
||||
func (a *app) seedMemoryTidyPanel(tidy memory.TidyConfig) {
|
||||
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
|
||||
log := logger.WithSource(a.log, "memory/tidy")
|
||||
// 播种函数自己会把「跳过 / 无法解析」记成带路径的日志(含 WARN),这里只报
|
||||
// 「该写却没写成」那一种 —— 不重复记同一条 WARN。
|
||||
if _, err := runtimeconfig.SeedMemoryTidy(path, memory.TidyTuningSnapshot(tidy, memory.ReviewConfig{}), log); err != nil {
|
||||
log.Warn("memory-tidy.json 播种失败(面板配置仍缺位,.env 继续兜底)", "path", path, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// applyMemoryTidyTuning 把 `$DATA_DIR/config/memory-tidy.json` 叠到 env 侧的
|
||||
// TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径):
|
||||
// - 文件里**没写** autoApply ⇒ 保持代码默认(开);
|
||||
// - 写 `false` ⇒ DisableAutoApply = true(一键回到「只产建议」,不必改代码);
|
||||
// - autoApplyPerRound ≤ 0 ⇒ 不覆盖,交给 normalizeTidyConfig 填默认上限。
|
||||
// - autoApplyPerRound ≤ 0 ⇒ 不覆盖,交给 normalizeTidyConfig 填默认上限;
|
||||
// - **调度四键**(intervalSec / quietIntervalSec / quietStartHour /
|
||||
// quietEndHour)⇒ 交 memory.ApplyTidyScheduleTuning 逐键解析(面板 >
|
||||
// `.env` > 代码默认),坏值回落 + WARN。
|
||||
//
|
||||
// 读不到/解不开时只记 WARN 并保持 env 侧的值:那正是「回落代码默认值」。
|
||||
// 读不到/解不开时只记 WARN 并保持 env 侧的值:那正是「回落下一档」——坏文件时
|
||||
// tuning 是零值(四个调度键都是 nil),因此 ApplyTidyScheduleTuning 一个键都不覆盖,
|
||||
// env 值原样留着(`.env` 也缺位时由 normalizeTidyConfig 落到代码默认)。
|
||||
func (a *app) applyMemoryTidyTuning(cfg *memory.TidyConfig) {
|
||||
tuning, path, err := a.readMemoryTidyTuning()
|
||||
log := logger.WithSource(a.log, "memory/tidy")
|
||||
if err != nil {
|
||||
logger.WithSource(a.log, "memory/tidy").Warn(
|
||||
"memory tidy tuning unreadable, falling back to code defaults",
|
||||
log.Warn(
|
||||
"memory tidy tuning unreadable, falling back to the env layer (.env values kept)",
|
||||
"path", path, "error", err)
|
||||
return
|
||||
} else {
|
||||
cfg.DisableAutoApply = tuning.AutoApplyOff()
|
||||
if tuning.AutoApplyPerRound > 0 {
|
||||
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound
|
||||
}
|
||||
}
|
||||
cfg.DisableAutoApply = tuning.AutoApplyOff()
|
||||
if tuning.AutoApplyPerRound > 0 {
|
||||
cfg.AutoApplyPerRound = tuning.AutoApplyPerRound
|
||||
}
|
||||
logger.WithSource(a.log, "memory/tidy").Info(
|
||||
"memory tidy tuning loaded",
|
||||
"path", path, "auto_apply", !cfg.DisableAutoApply,
|
||||
"auto_apply_per_round", cfg.AutoApplyPerRound)
|
||||
// 调度四键在两种情形下都要过一遍:成功时按面板值覆盖,失败时 tuning 是零值
|
||||
// ⇒ 原样返回(= 保持 env 值),因此不必为它再分一条路径。
|
||||
*cfg = memory.ApplyTidyScheduleTuning(*cfg, tuning, log)
|
||||
// 这里打的是**归一化之前**的值(quiet_interval_sec 可能还是 0 = 「缺省,等于
|
||||
// intervalSec」):真正生效的两档周期由 TidyService.Start 的启动日志报。
|
||||
// panel_decoded = 「文件缺失/空白/解析成功」三者之一(LoadMemoryTidyTuning 把
|
||||
// 前两者都当作「还没配过」而不是错误),为 false 即坏文件 ⇒ 四个键都回落 `.env`。
|
||||
log.Info(
|
||||
"memory tidy tuning applied (panel > .env > code defaults)",
|
||||
"path", path, "panel_decoded", err == nil, "auto_apply", !cfg.DisableAutoApply,
|
||||
"auto_apply_per_round", cfg.AutoApplyPerRound,
|
||||
"interval_sec", cfg.IntervalSec, "quiet_interval_sec", cfg.QuietIntervalSec,
|
||||
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", cfg.QuietStartHour, cfg.QuietEndHour))
|
||||
}
|
||||
|
||||
// readMemoryTidyTuning 读一次 `$DATA_DIR/config/memory-tidy.json`(**只读**)。
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件锁定**启动播种在组合根上的接线**(wire_memory_tidy.go 的 seedMemoryTidyPanel;
|
||||
// 主人 2026-10-07:「`.env` 只做兜底用 —— 后端**自动创建**独立的面板配置文件**要做**」):
|
||||
//
|
||||
// ① 文件不存在 ⇒ 后端自己写一份「当时生效值」(`.env` 给了的用 `.env`,没给的用代码
|
||||
// 默认),面板配置自此自足;
|
||||
// ② 播种之后再走一遍解析,生效配置与没播种时**逐字相同**(播种那一刻行为零变化);
|
||||
// ③ 幂等:再跑一次不改字节(文件已在 ⇒ 走「绝不覆盖」那条路)。
|
||||
//
|
||||
// 用例全部落在临时 DataDir(`t.TempDir()`):生产那份 data/config/memory-tidy.json 是
|
||||
// 主人手工建的,用例不许依赖它存在与否,也不许碰它。
|
||||
|
||||
// panelPath 返回某个 DataDir 下面板配置的落点(与组合根同一套路径推导)。
|
||||
func panelPath(dataDir string) string {
|
||||
return runtimeconfig.DefaultMemoryTidyPath(dataDir)
|
||||
}
|
||||
|
||||
// readPanel 读回面板文件原文(幂等断言要比字节,不能只看解出来的键)。
|
||||
func readPanel(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("读回 %s: %v", path, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// TestWireMemoryTidySeedsPanelOnBoot 是最接近主人场景的一条:直接调**组合根的真实
|
||||
// 装配函数** `wireMemoryTidy`(它被 wireMemory → newApp 调用,即进程启动那条路),
|
||||
// 断言这一次启动就**自己把面板配置文件建出来**了;再跑一次(= 第二次启动)字节不变。
|
||||
//
|
||||
// 为什么不用「跑一遍真二进制」来验收:那要再起一个 :8080 的同进程服务,与「后端常驻」
|
||||
// 那条红线冲突(不许为验收停/重启正在跑的后端)。因此这里走的是**同一个函数**、同一套
|
||||
// 路径推导,只把 DataDir 换成临时目录。
|
||||
func TestWireMemoryTidySeedsPanelOnBoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
store, err := memory.NewStore(ctx, conn, tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("memory store: %v", err)
|
||||
}
|
||||
|
||||
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 3600, QuietStartHour: 23, QuietEndHour: 7}
|
||||
dataDir := t.TempDir()
|
||||
a := &app{
|
||||
cfg: &config.Config{DataDir: dataDir, MemoryTidy: env},
|
||||
memStore: store,
|
||||
log: tidyTestLog(),
|
||||
}
|
||||
if err := a.wireMemoryTidy(); err != nil {
|
||||
t.Fatalf("wireMemoryTidy: %v", err)
|
||||
}
|
||||
|
||||
path := panelPath(dataDir)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("启动装配没有自动创建 %s:%v(这正是主人要求的那条「后端自动创建要做」)", path, err)
|
||||
}
|
||||
got, err := runtimeconfig.LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
t.Fatalf("回读播种结果: %v", err)
|
||||
}
|
||||
if got.IntervalSec == nil || *got.IntervalSec != 3600 ||
|
||||
got.QuietIntervalSec == nil || *got.QuietIntervalSec != 3600 {
|
||||
t.Fatalf("两档周期 = %v/%v, want 3600/3600(取 `.env`)", got.IntervalSec, got.QuietIntervalSec)
|
||||
}
|
||||
if got.QuietStartHour == nil || *got.QuietStartHour != 23 ||
|
||||
got.QuietEndHour == nil || *got.QuietEndHour != 7 {
|
||||
t.Fatalf("安静时段 = %v-%v, want 23-7", got.QuietStartHour, got.QuietEndHour)
|
||||
}
|
||||
|
||||
// 第二次启动(同一个装配函数再跑一次):文件已在 ⇒ 一个字节都不动。
|
||||
before := readPanel(t, path)
|
||||
second := &app{
|
||||
cfg: &config.Config{DataDir: dataDir, MemoryTidy: env},
|
||||
memStore: store,
|
||||
log: tidyTestLog(),
|
||||
}
|
||||
if err := second.wireMemoryTidy(); err != nil {
|
||||
t.Fatalf("第二次 wireMemoryTidy: %v", err)
|
||||
}
|
||||
if after := readPanel(t, path); after != before {
|
||||
t.Fatalf("第二次启动改动了面板文件:\n before=%s\n after =%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidyPanel_WritesEffectiveValuesOnBoot 钉住「自动创建」这件事本身:
|
||||
// 组合根启动时必须自己把面板文件建出来,内容 = 当时生效值(`.env` 档 + 代码默认)。
|
||||
func TestSeedMemoryTidyPanel_WritesEffectiveValuesOnBoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// `.env` 层:与代码默认**故意不同**,这样「播的是生效值」与「播了一份硬编码默认值」
|
||||
// 一眼可分。
|
||||
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 10800, QuietStartHour: 22, QuietEndHour: 6}
|
||||
dataDir := t.TempDir()
|
||||
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
|
||||
path := panelPath(dataDir)
|
||||
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("前置:%s 不该存在", path)
|
||||
}
|
||||
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("启动播种没有把面板文件建出来:%v", err)
|
||||
}
|
||||
|
||||
got, err := runtimeconfig.LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
t.Fatalf("回读播种结果: %v", err)
|
||||
}
|
||||
// `.env` 档:三键如实落进快照(quietIntervalSec 没有 env 键 ⇒ 缺省 = intervalSec)。
|
||||
if got.IntervalSec == nil || *got.IntervalSec != 10800 {
|
||||
t.Fatalf("intervalSec = %v, want 10800(取 `.env`)", got.IntervalSec)
|
||||
}
|
||||
if got.QuietIntervalSec == nil || *got.QuietIntervalSec != 10800 {
|
||||
t.Fatalf("quietIntervalSec = %v, want 10800(缺省 = intervalSec)", got.QuietIntervalSec)
|
||||
}
|
||||
if got.QuietStartHour == nil || *got.QuietStartHour != 22 ||
|
||||
got.QuietEndHour == nil || *got.QuietEndHour != 6 {
|
||||
t.Fatalf("安静时段 = %v-%v, want 22-6(取 `.env`)", got.QuietStartHour, got.QuietEndHour)
|
||||
}
|
||||
// 代码默认档:老六键必须齐(否则「面板能管住自动档/闸/T1」仍是空话)。
|
||||
if got.AutoApply == nil || !*got.AutoApply || got.AutoApplyPerRound <= 0 {
|
||||
t.Fatalf("自动档两键 = %v/%d, want 显式 true 且正数上限", got.AutoApply, got.AutoApplyPerRound)
|
||||
}
|
||||
if got.IngestGate == nil || !*got.IngestGate || got.T1Judge == nil || !*got.T1Judge {
|
||||
t.Fatalf("闸/T1 开关 = %v/%v, want 显式 true", got.IngestGate, got.T1Judge)
|
||||
}
|
||||
if got.T1PerWindow <= 0 || got.T1WindowSec <= 0 {
|
||||
t.Fatalf("T1 限速 = %d/%d, want 正数", got.T1PerWindow, got.T1WindowSec)
|
||||
}
|
||||
|
||||
// 幂等 + 「绝不覆盖」:再跑一次,字节一个不动。
|
||||
before := readPanel(t, path)
|
||||
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||
if after := readPanel(t, path); after != before {
|
||||
t.Fatalf("第二次播种改动了面板文件:\n before=%s\n after =%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidyPanel_KeepsRuntimeBehaviorIdentical 钉住「播种那一刻行为零变化」:
|
||||
// 播种前后各按组合根的次序解析一次,得到的生效配置必须逐字相同 —— 否则播种就成了
|
||||
// 一次没人察觉的行为变更,而不是「把现状写进文件」。
|
||||
func TestSeedMemoryTidyPanel_KeepsRuntimeBehaviorIdentical(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 7200, QuietStartHour: 1, QuietEndHour: 5}
|
||||
dataDir := t.TempDir()
|
||||
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
|
||||
|
||||
// 比对的是**归一化之后**的生效配置:TidyConfig 是归一化前的中间形态,面板写不写
|
||||
// quietIntervalSec / autoApplyPerRound 会让它在 0 与「等于生效值」之间变化,语义却
|
||||
// 相同 —— 因此必须走 normalizeTidyConfig 这一层(memory.NewTidier 的既有入口)才能
|
||||
// 说「行为逐字未变」。
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
store, err := memory.NewStore(ctx, conn, tidyTestLog())
|
||||
if err != nil {
|
||||
t.Fatalf("memory store: %v", err)
|
||||
}
|
||||
effective := func(t *testing.T, cfg memory.TidyConfig) memory.TidyConfig {
|
||||
t.Helper()
|
||||
tidier, err := memory.NewTidier(cfg, memory.TidyDeps{Store: store, Log: tidyTestLog()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTidier: %v", err)
|
||||
}
|
||||
return tidier.Config() // 已套默认值
|
||||
}
|
||||
|
||||
// ① 播种前:面板文件不存在 ⇒ 只有 `.env` + 代码默认这一档。
|
||||
before := memory.TidyConfigFromConfig(env)
|
||||
a.applyMemoryTidyTuning(&before)
|
||||
before = effective(t, before)
|
||||
// ② 启动播种。
|
||||
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||
// ③ 播种后:面板文件已在,同样的 env 再解析一次。
|
||||
after := memory.TidyConfigFromConfig(env)
|
||||
a.applyMemoryTidyTuning(&after)
|
||||
after = effective(t, after)
|
||||
|
||||
if before != after {
|
||||
t.Fatalf("播种改变了生效配置:\n before=%+v\n after =%+v", before, after)
|
||||
}
|
||||
// 两档周期(intervalAt 的真判据)也要如实等价:安静时段窗口内用 quietInterval。
|
||||
if after.QuietInterval != after.Interval || after.Interval != time.Duration(env.IntervalSec)*time.Second {
|
||||
t.Fatalf("两档周期 = %s/%s, want 都等于 `.env` 的 %s",
|
||||
after.Interval, after.QuietInterval, time.Duration(env.IntervalSec)*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidyPanel_PanelTakesOverAfterSeeding 是主人口径的终点:
|
||||
// 「后端自动创建独立的面板配置文件之后就走面板配置文件」—— 播种之后,改那个文件
|
||||
// 必须真的改变生效值(否则「自动创建」只是造了个没人读的文件)。
|
||||
func TestSeedMemoryTidyPanel_PanelTakesOverAfterSeeding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||||
dataDir := t.TempDir()
|
||||
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
|
||||
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||
|
||||
path := panelPath(dataDir)
|
||||
if err := os.WriteFile(path, []byte(`{"intervalSec": 3600}`), 0o600); err != nil {
|
||||
t.Fatalf("改写面板文件: %v", err)
|
||||
}
|
||||
cfg := memory.TidyConfigFromConfig(env)
|
||||
a.applyMemoryTidyTuning(&cfg)
|
||||
if cfg.IntervalSec != 3600 {
|
||||
t.Fatalf("intervalSec = %d, want 3600(面板优先,`.env` 只兜底)", cfg.IntervalSec)
|
||||
}
|
||||
// 面板没写的键仍由 `.env` 兜底(面板只管它写了的那几个键)。
|
||||
if cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
|
||||
t.Fatalf("安静时段 = %02d:00-%02d:00, want `.env` 的 23:00-07:00", cfg.QuietStartHour, cfg.QuietEndHour)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidyPanel_LeavesExistingFileAlone 钉住组合根这一侧的「绝不覆盖」:
|
||||
// 盘上已有一份主人手写的配置(哪怕缺键、哪怕带未知字段)⇒ 启动播种一个字节都不动。
|
||||
func TestSeedMemoryTidyPanel_LeavesExistingFileAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||||
dataDir := t.TempDir()
|
||||
dir := filepath.Join(dataDir, "config")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
body := "{\n \"autoApply\": false,\n \"手工写的未知键\": 1\n}\n"
|
||||
path := panelPath(dataDir)
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("写手写的配置: %v", err)
|
||||
}
|
||||
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
|
||||
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
|
||||
if got := readPanel(t, path); got != body {
|
||||
t.Fatalf("已存在的面板文件被改了:\n want=%s\n got =%s", body, got)
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,11 @@ func TestMemoryTidyIsWired(t *testing.T) {
|
||||
// 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句,
|
||||
// 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。
|
||||
"runtimeconfig.LoadMemoryTidyTuning",
|
||||
// 启动播种(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件
|
||||
// **要做**」):少了这两句,那个文件在生产里**根本不存在** ⇒「面板优先」
|
||||
// 永远是空话,生效的仍是 `.env` 兜底那一档 —— 而不会有任何用例变红。
|
||||
"a.seedMemoryTidyPanel(cfg)",
|
||||
"runtimeconfig.SeedMemoryTidy(",
|
||||
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
|
||||
// 的结构性保证,而不是靠自觉。
|
||||
"Audit: a.auditMemoryTidy",
|
||||
@@ -253,3 +258,70 @@ func TestApplyMemoryTidyTuning_ReadsDataDirConfig(t *testing.T) {
|
||||
t.Fatalf("坏配置时 cfg = %+v, want 回落零值(默认值由 normalize 填)", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyMemoryTidyTuning_ScheduleKnobs 钉住**整理频率**四键在组合根上的接线:
|
||||
// 面板($DATA_DIR/config/memory-tidy.json)优先、`.env` 兜底 —— 主人 2026-10-07
|
||||
// 口径「.env 只做兜底用 …… 这些键优先从面板配置上取」。
|
||||
//
|
||||
// 这里只断言「叠完之后 cfg 里是什么」,不重复 memory 包的优先级链用例:组合根这一层
|
||||
// 要证明的是**那个文件真的被读了、真的落到了 cfg 上**(少这一句,面板改了频率也无效,
|
||||
// 而不会有任何用例变红)。
|
||||
func TestApplyMemoryTidyTuning_ScheduleKnobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// env 层:与面板值故意对立。
|
||||
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
|
||||
|
||||
newApp := func(dataDir string) *app {
|
||||
return &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
}
|
||||
apply := func(dataDir string) memory.TidyConfig {
|
||||
cfg := memory.TidyConfigFromConfig(env)
|
||||
newApp(dataDir).applyMemoryTidyTuning(&cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ① 面板四键都写了 ⇒ 四个都用面板值(覆盖 `.env`)。
|
||||
dataDir := t.TempDir()
|
||||
dir := filepath.Join(dataDir, "config")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "memory-tidy.json")
|
||||
body := `{"intervalSec": 3600, "quietIntervalSec": 10800, "quietStartHour": 1, "quietEndHour": 5}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
cfg := apply(dataDir)
|
||||
if cfg.IntervalSec != 3600 {
|
||||
t.Fatalf("intervalSec = %d, want 3600(面板优先)", cfg.IntervalSec)
|
||||
}
|
||||
if cfg.QuietIntervalSec != 10800 {
|
||||
t.Fatalf("quietIntervalSec = %d, want 10800(面板优先)", cfg.QuietIntervalSec)
|
||||
}
|
||||
if cfg.QuietStartHour != 1 || cfg.QuietEndHour != 5 {
|
||||
t.Fatalf("安静时段 = %02d:00-%02d:00, want 01:00-05:00(面板优先)",
|
||||
cfg.QuietStartHour, cfg.QuietEndHour)
|
||||
}
|
||||
|
||||
// ② 文件不存在 ⇒ 四键保持 `.env` 值(面板只是「能覆盖」,不是「必须有」)。
|
||||
empty := t.TempDir()
|
||||
cfg = apply(empty)
|
||||
if cfg.IntervalSec != 7200 || cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
|
||||
t.Fatalf("没配过时应保持 env 值,得到 %+v", cfg)
|
||||
}
|
||||
|
||||
// ③ 坏文件(类型写错)⇒ 整份回落 `.env`,且**不启动失败**:组合根照旧拿到
|
||||
// 可用的 cfg(一个坏键不得让整理停摆)。
|
||||
if err := os.WriteFile(path, []byte(`{"intervalSec": "3600"}`), 0o600); err != nil {
|
||||
t.Fatalf("write broken: %v", err)
|
||||
}
|
||||
cfg = apply(dataDir)
|
||||
if cfg.IntervalSec != 7200 || cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
|
||||
t.Fatalf("坏文件时应回落 env 值,得到 %+v", cfg)
|
||||
}
|
||||
if cfg.QuietIntervalSec != 0 {
|
||||
t.Fatalf("坏文件时 quietIntervalSec 应当是零值(由 normalize 取 = intervalSec),得到 %d",
|
||||
cfg.QuietIntervalSec)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { AdminChannelAccount } from "../../../lib/types";
|
||||
import { Button, Tag } from "../../../components/primitives";
|
||||
import { authKeyOf } from "../channelFieldRules";
|
||||
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 authSet = typeof raw === "string" && raw.trim() !== "";
|
||||
const gate = accountActionGate(status, account.connected);
|
||||
const state = connectionTag(account.connected);
|
||||
const state = connectionTag(account);
|
||||
const online = accountOnlineStatus(account.accountOnline);
|
||||
const blocker = status.kind === "unknown" ? "读取不到连接状态(后端失联),已禁用" : undefined;
|
||||
|
||||
@@ -67,7 +67,7 @@ export function AccountRow({
|
||||
id {short}
|
||||
</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 的
|
||||
进程/WS 可以一直连着,而背后的 QQ 账号已掉线(消息收不到也发不出)。
|
||||
两枚标签刻意不合并:合并后「我们连着但账号掉了」这一个事故现场就无迹可寻。
|
||||
@@ -127,11 +127,23 @@ export function AccountRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** 账号级连接状态的呈现:已连接 / 未连接 / 状态未知(后端没报告该账号的活适配器)。 */
|
||||
function connectionTag(connected: boolean | undefined): string {
|
||||
if (connected === true) return "已连接";
|
||||
if (connected === false) return "未连接";
|
||||
return "状态未知";
|
||||
/** 账号级连接状态的呈现:已连接 / 连接中 / 等待接入 / 未连接 / 状态未知
|
||||
* (后端没报告该账号的活适配器)。
|
||||
*
|
||||
* 「连接中」与「等待接入」来自后端自报的连接态三态(见 ChannelConnectState)。
|
||||
* 没有它们时,一个正在后台重试的 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, "账号在平台上"), "离线标签的悬停说明点明这是账号在平台上的状态");
|
||||
}
|
||||
|
||||
// ── ⑤ 重试中 / 就绪等接入:不许画成红色的「未连接」 ────────────────────────────
|
||||
// 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`);
|
||||
@@ -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. */
|
||||
export type ChannelStatusKind =
|
||||
| "connected"
|
||||
| "connecting"
|
||||
| "ready"
|
||||
| "disconnected"
|
||||
| "unconfigured"
|
||||
| "unknown"
|
||||
@@ -29,6 +31,55 @@ export interface ChannelStatus {
|
||||
/** How much of the channel list we actually know right now. */
|
||||
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*,
|
||||
* never "empty". `data == null` with no error is the first load still running. */
|
||||
export function channelListState<T>(
|
||||
@@ -91,6 +142,24 @@ export function channelStatus(
|
||||
}
|
||||
|
||||
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 {
|
||||
kind: connected ? "connected" : "disconnected",
|
||||
label: connected ? "已连接" : "未连接",
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ChannelStatus, ChannelStatusKind } from "./channelStatus";
|
||||
|
||||
const dot: Record<ChannelStatusKind, string> = {
|
||||
connected: "bg-[var(--mu-ok)]",
|
||||
connecting: "bg-[var(--mu-warn)] mu-blink",
|
||||
ready: "bg-[var(--mu-text-lo)]",
|
||||
disconnected: "bg-[var(--mu-err)]",
|
||||
unconfigured: "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,
|
||||
* detail page and overview can never drift apart: 已连接 / 未连接 / 状态未知.
|
||||
* `showHint` off renders the compact form for dense surfaces (table row, card) —
|
||||
* the surrounding banner already carries the failure reason there. */
|
||||
* detail page and overview can never drift apart: 已连接 / 连接中 / 等待接入 /
|
||||
* 未连接 / 状态未知. `showHint` off renders the compact form for dense surfaces
|
||||
* (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({
|
||||
status,
|
||||
showHint = true,
|
||||
@@ -24,7 +27,7 @@ export function ChannelStatusTag({
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<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]}`} />
|
||||
{status.label}
|
||||
</Tag>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
adminChannelOpen,
|
||||
adminDeleteChannelAccount,
|
||||
adminDeleteEmptyChannelAccounts,
|
||||
type ChannelActionResult,
|
||||
} from "../../../lib/api";
|
||||
import { useAuditStore } from "../../audit";
|
||||
|
||||
@@ -19,6 +20,35 @@ export interface ChannelActionDeps {
|
||||
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 条目)。 */
|
||||
export interface ChannelActions {
|
||||
/** 连接/断开。`accountId` 给出时**只**作用于该账号(多账号平台逐条操作);
|
||||
@@ -42,7 +72,7 @@ export function createChannelActions(deps: ChannelActionDeps): ChannelActions {
|
||||
// 回显**实际被驱动**的适配器:多账号平台上请求里的平台名说明不了「连的是哪一条」,
|
||||
// 后端把解析结果放在 resolved 里,这里如实说出来。
|
||||
const who = res?.resolved ?? name;
|
||||
setNotice(`${connect ? "已连接" : "已断开"} ${who}`);
|
||||
setNotice(connectDisconnectNotice(connect, who, res));
|
||||
reload();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
|
||||
@@ -23,12 +23,26 @@ export interface ChannelConfigSaveResult {
|
||||
/** 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 —
|
||||
* 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 {
|
||||
name: string;
|
||||
resolved?: string;
|
||||
accountId?: string;
|
||||
connected: boolean;
|
||||
pending?: boolean;
|
||||
ready?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** open/close 的请求体。**accountId 缺省时逐字维持旧契约 `{name}`**(老后端/老客户端
|
||||
|
||||
@@ -40,6 +40,19 @@ export interface InstalledPlugin {
|
||||
export interface AdminChannelRecord {
|
||||
name: string;
|
||||
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). */
|
||||
accounts?: AdminChannelAccount[];
|
||||
}
|
||||
@@ -80,6 +93,12 @@ export interface AdminChannelAccount {
|
||||
* 缺省(undefined)= **状态未知**(该渠道没有独立账号状态面,或尚未探到结果),
|
||||
* 面板画「账号状态未知」,**绝不**画成「账号离线」。 */
|
||||
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).
|
||||
|
||||
@@ -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.
|
||||
func validKind(k Kind) bool {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -246,13 +246,17 @@ type MemoryTidyConfig struct {
|
||||
// 关闭)。整理**只产建议、绝不自动改记忆**,因此默认开启是安全的;
|
||||
// 它带来的是每轮一次的记忆向量化(召回用)与一批候选行。
|
||||
Enabled bool `env:"MEMORY_TIDY_ENABLED" default:"true"`
|
||||
// IntervalSec 是整理周期(秒),默认 86400 = 每天 1 次(design/23 §13
|
||||
// 开放点 8 的建议值)。它是下限:实际在安静时段起点跑,因此间隔取
|
||||
// 「安静时段每天一次」与它之中较长者。
|
||||
// IntervalSec 是**非安静时段**的整理周期(秒),默认 86400 = 每天 1 次
|
||||
// (design/23 §13 开放点 8 的建议值)。它是**兜底档**:`$DATA_DIR/config/
|
||||
// memory-tidy.json` 的 `intervalSec` 可覆盖它(面板优先、`.env` 兜底。
|
||||
// 2026-10-07 主人口径「`.env` 只做兜底用」),安静时段的另一档是同一文件里的
|
||||
// `quietIntervalSec`(缺省 = 等于它,见 memory 侧的 intervalAt)。
|
||||
IntervalSec int `env:"MEMORY_TIDY_INTERVAL_SEC" default:"86400"`
|
||||
// QuietStartHour / QuietEndHour 是低峰窗口(本地钟点),默认复用
|
||||
// backthink 的 23:00-07:00 语义(窗口可跨午夜;两端相等且非 0 则关闭窗口,
|
||||
// 改为纯周期)。整理只在低峰跑,且**从不推送**(design/23 §4)。
|
||||
// 改为纯周期)。2026-10-07 起窗口同时是**降频档位**(安静时段用
|
||||
// quietIntervalSec)与**补跑判据**(窗口内没跑过就补一轮),不再表示
|
||||
// 「只有低峰才跑」。整理**从不推送**(design/23 §4)。
|
||||
QuietStartHour int `env:"MEMORY_TIDY_QUIET_START_HOUR" default:"23"`
|
||||
QuietEndHour int `env:"MEMORY_TIDY_QUIET_END_HOUR" default:"7"`
|
||||
// RecallThreshold 是候选召回的余弦**召回门**,默认 0.80(design/23 §5.1
|
||||
|
||||
@@ -23,17 +23,37 @@
|
||||
// 额度出口。额度必须能按现网节奏调(且不许为它开 env 键),因此落在这里;
|
||||
// **零值不表示「无限制」**,只表示「没配、用代码默认值」。
|
||||
//
|
||||
// 2026-10-07 再追加 `intervalSec` / `quietIntervalSec` / `quietStartHour` /
|
||||
// `quietEndHour`(**整理频率**·主人两条口径:「**`.env` 只做兜底用** —— 后端自动
|
||||
// 创建独立的面板配置文件之后就走面板配置文件,这些键优先从面板配置上取」;
|
||||
// 「**频率必须调**,可以调到 1 小时或者三小时,**或者动态分配**,比如白天间隔短
|
||||
// 一点,晚上到了静默时期稍微长一点」):
|
||||
//
|
||||
// - 这四项**逐键**走「面板 > `.env` > 代码默认」三档(唯一实现点是
|
||||
// `memory.ApplyTidyScheduleTuning`):面板键在 ⇒ 用面板值;缺位 ⇒ 用 `.env`
|
||||
// 的 `MEMORY_TIDY_INTERVAL_SEC` / `MEMORY_TIDY_QUIET_*_HOUR`(quietIntervalSec
|
||||
// 没有 env 键,缺位 = 等于 `intervalSec`);两档都没有 ⇒ 代码默认
|
||||
// (86400 / 23:00-07:00,见 memory 侧的 normalizeTidyConfig)。
|
||||
// - 因此**没有新增任何 `.env` 键**(`.env` 已冻结,design/13 §2.1):上面三个
|
||||
// env 键是既有的,本切片只是让面板能覆盖它们。
|
||||
// - **坏值不得让整理停摆或忙轮询**:键写了非正数 ⇒ 回落下一档 + WARN
|
||||
// (≤0 的周期不是「没配」,而是一个会让整理变成空转的值);整个文件解不开
|
||||
// ⇒ 四个键连同其它键一起回落 `.env` 并记 WARN(既有口径,见 LoadMemoryTidyTuning)。
|
||||
//
|
||||
// 边界与 backthink.go 同源(那是同一族的手续):
|
||||
//
|
||||
// 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落
|
||||
// 代码默认值」那条裁决 —— 那是 `memory.normalizeTidyConfig` 的事。
|
||||
// 2. **只读**:面板写路径不在本切片内,因此刻意没有 `Save*`。改动仍要
|
||||
// **人手改这个文件 + 重启**(与 `backthink.json` 同口径:启动期读一次)。
|
||||
// 2. **没有「面板编辑」的写口**:改内容仍要**人手改这个文件 + 重启**(与
|
||||
// `backthink.json` 同口径:启动期读一次)。唯一的写路径是**启动播种**
|
||||
// (memorytidy_seed.go)—— 它只在文件缺失/空白时写一次,之后一个字节都不动;
|
||||
// 面板上的编辑口是另一片,不在本文件。
|
||||
// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。
|
||||
//
|
||||
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开
|
||||
// = 返回错误,调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关
|
||||
// 或上限读成「无限制」)。
|
||||
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**;启动时这种情形
|
||||
// 会被播种成一份「当时生效值」,见 memorytidy_seed.go);存在但解不开 = 返回错误,
|
||||
// 调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关或上限读成
|
||||
// 「无限制」)。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
@@ -108,6 +128,35 @@ type MemoryTidyTuning struct {
|
||||
// `3600` 比要求主人记住 Go 的时长语法更不容易写错,也与既有
|
||||
// `$DATA_DIR/config/backthink.json` 的 `*_SEC` 口径一致。
|
||||
T1WindowSec int `json:"t1WindowSec"`
|
||||
|
||||
// IntervalSec 是**非安静时段**的整理周期(秒)。nil = 没配 ⇒ 用 `.env` 的
|
||||
// `MEMORY_TIDY_INTERVAL_SEC`;两档都没有 ⇒ 代码默认 86400(每天 1 次)。
|
||||
//
|
||||
// 用**指针**的理由与 AutoApply 同源,但多一层:数值键的零值(0)在这里是
|
||||
// **坏值**而不是「一个合法的 0 秒周期」——`0` 会让整理退化成空转,因此
|
||||
// 「键没写」(静默回落)与「写了 0」(WARN + 回落)必须可区分。
|
||||
IntervalSec *int `json:"intervalSec"`
|
||||
// QuietIntervalSec 是**安静时段**的整理周期(秒)——主人要的「动态分配:
|
||||
// 白天间隔短一点,晚上到了静默时期稍微长一点」就落在这个键上。
|
||||
//
|
||||
// nil = 没配 ⇒ **等于 intervalSec**(缺省 = 不因时段换档,而不是「夜里不跑」);
|
||||
// 写非正数 ⇒ 同一档回落 + WARN(理由同 IntervalSec)。
|
||||
//
|
||||
// 它**没有 env 键**(`.env` 已冻结,design/13 §2.1):安静时段分档是本次新增
|
||||
// 的能力,`.env` 那一档只有 `MEMORY_TIDY_INTERVAL_SEC`,因此它的「下一档」就是
|
||||
// 上面那个已解析出来的 intervalSec。
|
||||
QuietIntervalSec *int `json:"quietIntervalSec"`
|
||||
// QuietStartHour / QuietEndHour 是安静时段的两端(**本地钟点**),语义与既有
|
||||
// `inQuietHours` 逐字一致:窗口可跨午夜;两端相等且非 0 = 关闭窗口。
|
||||
//
|
||||
// 用**指针**是必须的(不像 autoApplyPerRound 那样用零值当「没配」):**0 是
|
||||
// 合法钟点**(如 `0-7` 的凌晨窗口、`23-0` 到午夜的窗口),用 int 会让「显式
|
||||
// 写 0」与「没写」不可区分,于是任何只写一端的文件都会被静默改掉另一端。
|
||||
//
|
||||
// nil = 没配 ⇒ 用 `.env` 的 `MEMORY_TIDY_QUIET_START_HOUR` / `..._END_HOUR`;
|
||||
// 两端都没配 ⇒ 代码默认 23:00-07:00。
|
||||
QuietStartHour *int `json:"quietStartHour"`
|
||||
QuietEndHour *int `json:"quietEndHour"`
|
||||
}
|
||||
|
||||
// AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
|
||||
@@ -138,8 +187,12 @@ func DefaultMemoryTidyPath(dataDir string) string {
|
||||
// 文件缺失或全空白 ⇒ 零值 + nil:调用方用代码默认值(normalizeTidyConfig 会把
|
||||
// 非正的上限回落默认),因此「没配过」与「配成 0」得到同一个结果。
|
||||
//
|
||||
// 存在但解不开 ⇒ 返回错误。调用方应记一条带 source 的 WARN 并回落默认;
|
||||
// **不得覆盖**那个文件(损坏的文件可以人工救回,程序盖掉它就丢了人工写的意图)。
|
||||
// 存在但解不开 ⇒ 返回错误。调用方应记一条带 source 的 WARN 并回落**下一档**
|
||||
// (调度四键落 `.env` 值、开关类落代码默认)——注意这是**整份文件**的粒度:一个键
|
||||
// 类型写错(如 `"intervalSec": "3600"`)会让全部键一起回落,这是既有口径
|
||||
// (`TestApplyMemoryTidyTuning_ReadsDataDirConfig` 的用例③钉的就是它),而不是
|
||||
// 逐键容错。**不得覆盖**那个文件(损坏的文件可以人工救回,程序盖掉它就丢了人工
|
||||
// 写的意图)。
|
||||
//
|
||||
// 它不建目录、不读老位置:新家族没有老位置(design/13 §2.1 第 5 条)。
|
||||
func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) {
|
||||
@@ -153,6 +206,16 @@ func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) {
|
||||
if strings.TrimSpace(string(raw)) == "" {
|
||||
return MemoryTidyTuning{}, nil
|
||||
}
|
||||
return decodeMemoryTidy(raw, path)
|
||||
}
|
||||
|
||||
// decodeMemoryTidy 是 LoadMemoryTidyTuning 与**启动播种**(memorytidy_seed.go)共用的
|
||||
// 解码点:把「解不开」统一分类成 ErrInvalidMemoryTidy。两处对「坏文件」的判定必须
|
||||
// 出自同一行代码,否则会出现「读取说坏了、播种说没事」这种自相矛盾(播种侧要靠这个
|
||||
// 结论决定「绝不覆盖」,判错的代价是一份人工配置被盖掉)。
|
||||
//
|
||||
// 空内容由调用方先滤掉(空 = 还没配过,与「缺失」同义,不是解码失败)。
|
||||
func decodeMemoryTidy(raw []byte, path string) (MemoryTidyTuning, error) {
|
||||
var t MemoryTidyTuning
|
||||
if err := json.Unmarshal(raw, &t); err != nil {
|
||||
return MemoryTidyTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidMemoryTidy, path, err)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// memorytidy_seed.go 落地主人 2026-10-07 那句「**`.env` 只做兜底用** —— 后端**自动
|
||||
// 创建**独立的面板配置文件**要做**」里的「自动创建」:进程启动时若
|
||||
// `$DATA_DIR/config/memory-tidy.json` **不存在**,就按**当时生效的值**播种一份,
|
||||
// 使这个家族从此有一份自足的一等来源,而不是每次都靠 `.env` 兜。
|
||||
//
|
||||
// 与 memorytidy.go(读取)的边界同源:本文件只管**文件形状与落盘**,不认识
|
||||
// TidyConfig / ReviewConfig,也不知道任何一个默认值是多少 —— 那属于 memory 侧
|
||||
// normalize 的职责。播种内容由调用方给出(`memory.TidyTuningSnapshot` 拍出的
|
||||
// 「当前生效值」快照),与 materialize.go 拿到的是 `.env` 合成组同一手法。
|
||||
//
|
||||
// 四条纪律,前三条逐字对齐 materialize.go(那是本仓「`.env` → 面板文件」播种的既有
|
||||
// 施工口径,本文件是同一套搬到 memory-tidy 家族):
|
||||
//
|
||||
// 1. **绝不覆盖已存在的文件**:文件在(无论内容是什么、无论解不解得开)⇒ 一个字节
|
||||
// 都不动。这条是铁律 —— 主人手改过的文件被程序盖回去,就是「面板改一次、重启
|
||||
// 一次被打回」那个失败形态。
|
||||
// 2. **损坏不是要「修好」的状态**:解不开时只记 WARN、按 `.env` 跑、**不写盘**。
|
||||
// 人工写坏的配置可以人工救回,程序盖掉它就丢了那份人工意图。
|
||||
// 3. **幂等**:只有「文件不存在(或全空白)」这一次会落盘;连续启动多次,第二次起
|
||||
// 是空操作(文件已存在 ⇒ 走第 1 条)。
|
||||
// 4. **不阻断启动**:返回的 error 只在「该写却没写成」时非 nil(写盘失败 / 种子
|
||||
// 残缺);调用方记一条 WARN 即可 —— 播种失败时运行期仍按 `.env` + 代码默认跑,
|
||||
// 行为与播种前逐字相同。
|
||||
//
|
||||
// 日志**只打路径、原因与键名,不打值**:这十键今天都不是凭据,但「配置日志不打值」
|
||||
// 是本仓既有口径(materialize.go 的「绝不打 apiKey」),这里从一开始就照做,免得
|
||||
// 日后加一个敏感键时要回头改日志。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// memoryTidySeedReasonMissing / ...Blank / ...Exists 是播种日志的 reason 字段值
|
||||
// (与 materialize.go 的 reason 口径一致:让人一眼看出是「没有」「空」还是
|
||||
// 「已在、没动它」)。
|
||||
memoryTidySeedReasonMissing = "面板配置不存在"
|
||||
memoryTidySeedReasonBlank = "面板配置为空文件"
|
||||
memoryTidySeedReasonExists = "面板配置已存在,未改动"
|
||||
// memoryTidySeedFileMode 是播种文件的权限。0600 与 SaveModels / SavePlugins
|
||||
// 同口径:配置目录可能放凭据,一律不给组/其他读权限。
|
||||
memoryTidySeedFileMode = 0o600
|
||||
// memoryTidySeedDirMode 是新建 `$DATA_DIR/config/` 时的目录权限,与 SaveModels
|
||||
// 的 mkdir 口径一致。
|
||||
memoryTidySeedDirMode = 0o755
|
||||
)
|
||||
|
||||
// memoryTidySeedKeys 是播种文件应有的键名全集(顺序即 JSON 顺序)。它用于播种日志
|
||||
// 的「播了哪些键」,并与结构体的 json tag 相互钉住(见
|
||||
// TestMemoryTidySeedKeysMatchStruct):加了新键却忘了更新它,用例会红。
|
||||
var memoryTidySeedKeys = []string{
|
||||
"autoApply", "autoApplyPerRound", "ingestGate", "t1Judge",
|
||||
"t1PerWindow", "t1WindowSec", "intervalSec", "quietIntervalSec",
|
||||
"quietStartHour", "quietEndHour",
|
||||
}
|
||||
|
||||
// MemoryTidySeed 是一次播种的可诊断产物(口径对齐 materialize.go 的 Result:
|
||||
// Written / Warn 的含义逐字相同,Existed 用来区分「跳过」的起因)。
|
||||
type MemoryTidySeed struct {
|
||||
// Written 为 true 表示本次真的落盘(只在文件缺失/全空白时可能为 true)。
|
||||
Written bool
|
||||
// Existed 为 true 表示文件已存在(含解不开的),本次一个字节都没动。
|
||||
// 它与 Written 互斥。
|
||||
Existed bool
|
||||
// Warn 是非致命异常:文件存在但解不开(按 `.env` 跑,且不写盘)。
|
||||
Warn error
|
||||
}
|
||||
|
||||
// SeedMemoryTidy 在 path 处播种一份面板配置 —— **仅当它缺失或全空白**。
|
||||
//
|
||||
// 「全空白」与「缺失」同义是既有口径(LoadMemoryTidyTuning 把两者都读成「还没配过」
|
||||
// ⇒ 零值 ⇒ 走 `.env` 兜底),因此对空白文件播种,那一刻的生效值一个字都不变 ——
|
||||
// 与 materialize.go 对空文件的处置一致。
|
||||
//
|
||||
// `null` **不算空白**:它解得出零值,但它是「盘上有一个文件」这个事实本身,按铁律
|
||||
// 不动它(主人的口径是「不存在才自动创建」,不是「内容看着空就重建」)。
|
||||
//
|
||||
// log 由调用方给出(组合根一律传带 source 的日志器):本函数会直接往它上面写日志,
|
||||
// 因此**不要传 nil**。
|
||||
func SeedMemoryTidy(path string, seed MemoryTidyTuning, log *slog.Logger) (MemoryTidySeed, error) {
|
||||
log = logger.WithSource(log, "config/runtimeconfig")
|
||||
raw, readErr := os.ReadFile(path)
|
||||
if readErr == nil {
|
||||
return seedMemoryTidyExisting(log, path, raw, seed)
|
||||
}
|
||||
if !os.IsNotExist(readErr) {
|
||||
log.Warn("memory-tidy.json 不可读,本次跳过播种(按 .env 兜底运行)", "path", path, "error", readErr)
|
||||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: 读取 %s: %w", path, readErr)
|
||||
}
|
||||
return writeMemoryTidySeed(log, path, seed, memoryTidySeedReasonMissing)
|
||||
}
|
||||
|
||||
// seedMemoryTidyExisting 处理「文件已存在」的三种形态,三种处置都**不覆盖**:
|
||||
//
|
||||
// - 解不开 ⇒ WARN + 按 `.env` 跑(绝不当场「修好」它,见文件头第 2 条);
|
||||
// - 全空白 ⇒ 按「还没配过」播种(唯一会在文件已存在时写盘的形态,理由见
|
||||
// SeedMemoryTidy 的说明);
|
||||
// - 其余(含 `null`)⇒ 跳过。
|
||||
func seedMemoryTidyExisting(log *slog.Logger, path string, raw []byte, seed MemoryTidyTuning) (MemoryTidySeed, error) {
|
||||
if strings.TrimSpace(string(raw)) == "" {
|
||||
log.Info("memory-tidy.json 是空文件(按「还没配过」处理),准备播种", "path", path)
|
||||
return writeMemoryTidySeed(log, path, seed, memoryTidySeedReasonBlank)
|
||||
}
|
||||
if _, err := decodeMemoryTidy(raw, path); err != nil {
|
||||
// 解不开 ≠ 可以盖掉:只留 WARN,一个字都不写盘。这条 WARN 是主人可查的
|
||||
// 唯一线索 —— 「这份配置没生效、也没被程序改过」。
|
||||
log.Warn("memory-tidy.json 无法解析,本次不播种也不覆盖原文件(按 .env 兜底运行)",
|
||||
"path", path, "error", err)
|
||||
return MemoryTidySeed{Existed: true, Warn: err}, nil
|
||||
}
|
||||
// 跳过也留痕(Info 而不是 Debug:这是**每次启动一行**的交接回执 —— 主人能从日志
|
||||
// 确认「后端认得我手写的那份文件、没有动它」,而不是只有提升日志级别才看得见)。
|
||||
log.Info("memory-tidy.json 已存在,跳过播种(面板配置优先,绝不覆盖原文件)",
|
||||
"path", path, "reason", memoryTidySeedReasonExists)
|
||||
return MemoryTidySeed{Existed: true}, nil
|
||||
}
|
||||
|
||||
// writeMemoryTidySeed 真正落盘:先把 `$DATA_DIR/config/` 建出来(不存在时),再按
|
||||
// 「临时文件 + rename」原子写(同 SaveModels / SaveWebSearch:并发读者绝不会看到
|
||||
// 半个 JSON),权限 0600。
|
||||
//
|
||||
// 落盘前先验种子**完整**(见 memoryTidySeedComplete):一份缺键的快照写下去会变成
|
||||
// `"intervalSec": null` —— 读回来语义上等于「没配」,但主人打开文件会看到一堆 null,
|
||||
// 且「播种 = 当时的生效值」这句话就不再成立。残缺的种子是调用方的编程错误,宁可
|
||||
// WARN + 不落盘,也不留一份自相矛盾的文件。
|
||||
func writeMemoryTidySeed(log *slog.Logger, path string, seed MemoryTidyTuning, reason string) (MemoryTidySeed, error) {
|
||||
if err := memoryTidySeedComplete(seed); err != nil {
|
||||
log.Warn("memory-tidy.json 播种内容不完整,本次不落盘(.env 继续兜底)", "path", path, "error", err)
|
||||
return MemoryTidySeed{}, err
|
||||
}
|
||||
raw, err := json.MarshalIndent(seed, "", " ")
|
||||
if err != nil {
|
||||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: marshal memory tidy seed: %w", err)
|
||||
}
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, memoryTidySeedDirMode); err != nil {
|
||||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, memoryTidySeedFileMode); err != nil {
|
||||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: rename %s: %w", path, err)
|
||||
}
|
||||
log.Info("memory-tidy.json 已按当时生效值播种落盘(此后以面板配置为准,.env 只兜底)",
|
||||
"path", path, "reason", reason, "keys", memoryTidySeedKeys)
|
||||
return MemoryTidySeed{Written: true}, nil
|
||||
}
|
||||
|
||||
// memoryTidySeedComplete 报告种子是否「十键齐全且值可用」。
|
||||
//
|
||||
// 为什么要「齐全」而不是「非 nil 就放行」:MemoryTidyTuning 的指针语义是「nil = 没配」,
|
||||
// 一份缺键的种子写下去虽然与「不写」在读取端等价,但它会让文件里出现 null —— 这份文件
|
||||
// 的主人(主人 / 面板)看到的东西必须与「生效值」逐字一致。
|
||||
//
|
||||
// 数值键要求为**正**:`intervalSec: 0` 在 memory 侧是**坏值**(非正周期让心跳空转,
|
||||
// 见 ApplyTidyScheduleTuning),播种出一份自己读回都要 WARN 的配置是自相矛盾的;
|
||||
// 钟点则要求落在 0..23(越界会让 time.Date 静默滚动到别的一天)。
|
||||
func memoryTidySeedComplete(seed MemoryTidyTuning) error {
|
||||
missing := make([]string, 0, len(memoryTidySeedKeys))
|
||||
if seed.AutoApply == nil {
|
||||
missing = append(missing, "autoApply")
|
||||
}
|
||||
if seed.IngestGate == nil {
|
||||
missing = append(missing, "ingestGate")
|
||||
}
|
||||
if seed.T1Judge == nil {
|
||||
missing = append(missing, "t1Judge")
|
||||
}
|
||||
if seed.IntervalSec == nil {
|
||||
missing = append(missing, "intervalSec")
|
||||
}
|
||||
if seed.QuietIntervalSec == nil {
|
||||
missing = append(missing, "quietIntervalSec")
|
||||
}
|
||||
if seed.QuietStartHour == nil {
|
||||
missing = append(missing, "quietStartHour")
|
||||
}
|
||||
if seed.QuietEndHour == nil {
|
||||
missing = append(missing, "quietEndHour")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed missing keys: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
if seed.AutoApplyPerRound <= 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed autoApplyPerRound = %d (want > 0)", seed.AutoApplyPerRound)
|
||||
}
|
||||
if seed.T1PerWindow <= 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed t1PerWindow = %d (want > 0)", seed.T1PerWindow)
|
||||
}
|
||||
if seed.T1WindowSec <= 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed t1WindowSec = %d (want > 0)", seed.T1WindowSec)
|
||||
}
|
||||
if *seed.IntervalSec <= 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed intervalSec = %d (want > 0)", *seed.IntervalSec)
|
||||
}
|
||||
if *seed.QuietIntervalSec <= 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed quietIntervalSec = %d (want > 0)", *seed.QuietIntervalSec)
|
||||
}
|
||||
if *seed.QuietStartHour < 0 || *seed.QuietStartHour > 23 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed quietStartHour = %d (want 0..23)", *seed.QuietStartHour)
|
||||
}
|
||||
if *seed.QuietEndHour < 0 || *seed.QuietEndHour > 23 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed quietEndHour = %d (want 0..23)", *seed.QuietEndHour)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件锁定**启动播种**(memorytidy_seed.go;主人 2026-10-07:「**`.env` 只做兜底
|
||||
// 用** …… 后端**自动创建**独立的面板配置文件」)的四条纪律:
|
||||
//
|
||||
// ① 文件不存在 ⇒ 按调用方给的「当前生效值」播种(**十键齐全**,不是只播老六键);
|
||||
// ② 文件已存在 ⇒ **一个字节都不动**(也不留 `.tmp`);
|
||||
// ③ 文件损坏 ⇒ **不写盘**(WARN + 按 `.env` 跑),绝不「修好它」;
|
||||
// ④ 幂等:连跑两次只在第一次写盘。
|
||||
//
|
||||
// 用例全部落在**临时目录**(`t.TempDir()`):生产那份 `data/config/memory-tidy.json`
|
||||
// 是主人手工建的,播种逻辑只在文件缺失时触发,用例不许依赖它存在与否。
|
||||
|
||||
// completeSeedTuning 造一份「十键齐全」的播种内容。值刻意都**不是**代码默认值
|
||||
// (autoApply=false / 3600 / 10800),这样「播的是调用方给的快照」与「播了一份硬编码
|
||||
// 的默认值」在断言里一眼可分。
|
||||
func completeSeedTuning() MemoryTidyTuning {
|
||||
return MemoryTidyTuning{
|
||||
AutoApply: boolSeedPtr(false),
|
||||
AutoApplyPerRound: 7,
|
||||
IngestGate: boolSeedPtr(true),
|
||||
T1Judge: boolSeedPtr(false),
|
||||
T1PerWindow: 240,
|
||||
T1WindowSec: 1800,
|
||||
IntervalSec: intSeedPtr(3600),
|
||||
QuietIntervalSec: intSeedPtr(10800),
|
||||
QuietStartHour: intSeedPtr(23),
|
||||
QuietEndHour: intSeedPtr(7),
|
||||
}
|
||||
}
|
||||
|
||||
// boolSeedPtr / intSeedPtr 造「快照里显式写了这个键」的指针。
|
||||
func boolSeedPtr(v bool) *bool { return &v }
|
||||
|
||||
func intSeedPtr(v int) *int { return &v }
|
||||
|
||||
// seedPath 返回一个**尚不存在**的落点(含尚不存在的 `config/` 目录)。
|
||||
func seedPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
return filepath.Join(t.TempDir(), "config", memoryTidyFileName)
|
||||
}
|
||||
|
||||
// decodeSeed 把盘上的文件解成键集合,供「十键齐全」的断言(比结构体更贴近主人看到
|
||||
// 的那份文件)。
|
||||
func decodeSeed(t *testing.T, path string) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(readRaw(t, path)), &keys); err != nil {
|
||||
t.Fatalf("解 %s 的键集合: %v", path, err)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// assertNoTempLeft 断言播种没留下临时文件(原子写的中间态不该留在盘上)。
|
||||
func assertNoTempLeft(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Fatalf("留下了临时文件 %s.tmp", path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_SeedsMissingFile 是本任务的主用例:文件不存在 ⇒ 播种,且**内容
|
||||
// 就是调用方给的快照**(十键齐全、含四项频率键),目录也一并建出来。
|
||||
func TestSeedMemoryTidy_SeedsMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := seedPath(t) // 连 config/ 目录都不存在
|
||||
seed := completeSeedTuning()
|
||||
|
||||
res, err := SeedMemoryTidy(path, seed, discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("SeedMemoryTidy: %v", err)
|
||||
}
|
||||
if !res.Written || res.Existed || res.Warn != nil {
|
||||
t.Fatalf("结果 = %+v, want Written 且非 Existed 且无 Warn", res)
|
||||
}
|
||||
assertNoTempLeft(t, path)
|
||||
|
||||
// 十键齐全(含新增的四项频率键)——「别只播老六键」的断言就在这里。
|
||||
keys := decodeSeed(t, path)
|
||||
for _, key := range memoryTidySeedKeys {
|
||||
if _, ok := keys[key]; !ok {
|
||||
t.Fatalf("播种文件缺键 %q:%s", key, readRaw(t, path))
|
||||
}
|
||||
}
|
||||
if len(keys) != len(memoryTidySeedKeys) {
|
||||
t.Fatalf("播种文件的键数 = %d, want %d:%s", len(keys), len(memoryTidySeedKeys), readRaw(t, path))
|
||||
}
|
||||
|
||||
// 回读走的是**运行期同一条**读取路径(LoadMemoryTidyTuning),因此这里同时钉住
|
||||
// 「写下去的东西读回来就是那份快照」。
|
||||
got, err := LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryTidyTuning: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, seed) {
|
||||
t.Fatalf("回读 = %+v, want %+v(播种内容必须逐键等于调用方给的生效值)", got, seed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_NeverTouchesExistingFile 钉住第一条铁律:文件在 ⇒ 一个字节都不动。
|
||||
// 覆盖四种盘上形态(面板值 / 空对象 / null / 手写带未知键),四种都必须原样保留 ——
|
||||
// 尤其 `null`:它解得出零值(语义上等于没配),但它是「盘上有一个文件」这个事实本身。
|
||||
func TestSeedMemoryTidy_NeverTouchesExistingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
bodies := []string{
|
||||
`{"autoApply": false}`,
|
||||
`{}`,
|
||||
`null`,
|
||||
"{\n \"intervalSec\": 42,\n \"unknownKey\": \"keep me\"\n}\n",
|
||||
}
|
||||
for _, body := range bodies {
|
||||
path := writeMemoryTidyFile(t, body)
|
||||
before := readRaw(t, path)
|
||||
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("%q: SeedMemoryTidy: %v", body, err)
|
||||
}
|
||||
if res.Written || !res.Existed || res.Warn != nil {
|
||||
t.Fatalf("%q: 结果 = %+v, want 跳过(Existed 且非 Written 且无 Warn)", body, res)
|
||||
}
|
||||
if after := readRaw(t, path); after != before {
|
||||
t.Fatalf("%q: 已存在的文件被改了字节:\n before=%s\n after =%s", body, before, after)
|
||||
}
|
||||
assertNoTempLeft(t, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_BrokenFileIsNotRepaired 钉住第二条纪律:损坏 ⇒ WARN + 按 `.env`
|
||||
// 跑 + **一个字都不写盘**。损坏的文件可以人工救回,程序盖掉它就丢了那份人工意图。
|
||||
func TestSeedMemoryTidy_BrokenFileIsNotRepaired(t *testing.T) {
|
||||
t.Parallel()
|
||||
bodies := []string{
|
||||
`{"autoApply": "yes"}`,
|
||||
`{"intervalSec": "3600"}`,
|
||||
`not json at all`,
|
||||
`[1,2,3]`,
|
||||
}
|
||||
for _, body := range bodies {
|
||||
path := writeMemoryTidyFile(t, body)
|
||||
before := readRaw(t, path)
|
||||
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
|
||||
if err != nil {
|
||||
// 坏文件不是「该写却没写成」:它压根不该被写,因此不是 error。
|
||||
t.Fatalf("%q: 坏文件不该让播种返回错误(它只是不写盘):%v", body, err)
|
||||
}
|
||||
if res.Written || !res.Existed {
|
||||
t.Fatalf("%q: 结果 = %+v, want 既不写也不当新文件", body, res)
|
||||
}
|
||||
if !errors.Is(res.Warn, ErrInvalidMemoryTidy) {
|
||||
t.Fatalf("%q: Warn = %v, want ErrInvalidMemoryTidy(分类错误必须与读取侧同一个)", body, res.Warn)
|
||||
}
|
||||
if after := readRaw(t, path); after != before {
|
||||
t.Fatalf("%q: 损坏的文件被覆盖/「修好」了:\n before=%s\n after =%s", body, before, after)
|
||||
}
|
||||
assertNoTempLeft(t, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_IsIdempotent 钉住幂等:连跑两次,只有第一次写盘,第二次连字节
|
||||
// 都不改(这是「连续启动多次只写一次」的可验证版本)。
|
||||
func TestSeedMemoryTidy_IsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := seedPath(t)
|
||||
first, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
|
||||
if err != nil || !first.Written {
|
||||
t.Fatalf("第一次: Written=%v err=%v, want 落盘", first.Written, err)
|
||||
}
|
||||
afterFirst := readRaw(t, path)
|
||||
second, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("第二次: %v", err)
|
||||
}
|
||||
if second.Written || !second.Existed {
|
||||
t.Fatalf("第二次 = %+v, want 跳过(文件已在)", second)
|
||||
}
|
||||
if afterSecond := readRaw(t, path); afterSecond != afterFirst {
|
||||
t.Fatalf("第二次改动了文件:\n first=%s\n second=%s", afterFirst, afterSecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_SeedsBlankFile 钉住「全空白」的处置:它与「缺失」同义是既有口径
|
||||
// (LoadMemoryTidyTuning 把两者都读成「还没配过」),因此也播种 —— 那一刻的生效值
|
||||
// 一个字都不变。
|
||||
func TestSeedMemoryTidy_SeedsBlankFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := writeMemoryTidyFile(t, " \n\t")
|
||||
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("SeedMemoryTidy: %v", err)
|
||||
}
|
||||
if !res.Written || res.Existed {
|
||||
t.Fatalf("结果 = %+v, want 按「还没配过」播种", res)
|
||||
}
|
||||
if len(decodeSeed(t, path)) != len(memoryTidySeedKeys) {
|
||||
t.Fatalf("空白文件被播种后应当十键齐全:%s", readRaw(t, path))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_RefusesIncompleteSeed 钉住种子的完整性契约:残缺的快照
|
||||
// (缺指针 / 非正周期 / 钟点越界)宁可 WARN + 不落盘,也不写出一份自己读回都要
|
||||
// WARN、或满是 null 的文件。
|
||||
func TestSeedMemoryTidy_RefusesIncompleteSeed(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
seed MemoryTidyTuning
|
||||
}{
|
||||
{"缺 autoApply", func() MemoryTidyTuning { s := completeSeedTuning(); s.AutoApply = nil; return s }()},
|
||||
{"缺 intervalSec", func() MemoryTidyTuning { s := completeSeedTuning(); s.IntervalSec = nil; return s }()},
|
||||
{"缺 quietStartHour", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietStartHour = nil; return s }()},
|
||||
{"intervalSec 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.IntervalSec = intSeedPtr(0); return s }()},
|
||||
{"quietIntervalSec 为负", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietIntervalSec = intSeedPtr(-1); return s }()},
|
||||
{"autoApplyPerRound 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.AutoApplyPerRound = 0; return s }()},
|
||||
{"t1PerWindow 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.T1PerWindow = 0; return s }()},
|
||||
{"t1WindowSec 为负", func() MemoryTidyTuning { s := completeSeedTuning(); s.T1WindowSec = -60; return s }()},
|
||||
{"quietStartHour 越界", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietStartHour = intSeedPtr(24); return s }()},
|
||||
{"quietEndHour 越界", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietEndHour = intSeedPtr(-1); return s }()},
|
||||
{"完全零值", MemoryTidyTuning{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
path := seedPath(t)
|
||||
if _, err := SeedMemoryTidy(path, tc.seed, discardLog()); err == nil {
|
||||
t.Fatalf("%s: err = nil, want 拒绝残缺种子", tc.name)
|
||||
}
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("%s: 残缺种子不得落盘:%s", tc.name, readRaw(t, path))
|
||||
}
|
||||
assertNoTempLeft(t, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_LogsPathAndReasonWithoutValues 钉住「可观测」:写盘与跳过都有
|
||||
// 日志(带路径与原因、带 source),且**不打任何配置值**(本仓配置日志的既有口径)。
|
||||
func TestSeedMemoryTidy_LogsPathAndReasonWithoutValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// ① 播种:Info 日志带路径、原因、source 与键名,但没有任何值。
|
||||
// 日志级别用默认的 Info(不是 Debug):主人要在**默认日志级别**下就能看到这件事
|
||||
// 发生了 —— 可观测是这条口径的一部分,不是「把级别调高才看得见」。
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
path := seedPath(t)
|
||||
seed := completeSeedTuning()
|
||||
seed.IntervalSec = intSeedPtr(4242) // 一眼可辨的值:它**不许**出现在日志里
|
||||
if _, err := SeedMemoryTidy(path, seed, log); err != nil {
|
||||
t.Fatalf("SeedMemoryTidy: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, memoryTidyFileName) {
|
||||
t.Fatalf("播种日志没带路径:%s", out)
|
||||
}
|
||||
if !strings.Contains(out, memoryTidySeedReasonMissing) {
|
||||
t.Fatalf("播种日志没带原因:%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "source=config/runtimeconfig") {
|
||||
t.Fatalf("播种日志没标来源(AGENTS §10 禁止无来源裸日志):%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "已按当时生效值播种落盘") {
|
||||
t.Fatalf("播种日志没说明做了什么:%s", out)
|
||||
}
|
||||
for _, value := range []string{"4242", "10800", "1800"} {
|
||||
if strings.Contains(out, value) {
|
||||
t.Fatalf("日志里出现了配置值 %s(配置日志只许打键名):%s", value, out)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 再次调用(文件已在):跳过也要有日志(**默认 Info 级别**可见,含路径与原因),
|
||||
// 且同样不打值。
|
||||
buf.Reset()
|
||||
if _, err := SeedMemoryTidy(path, seed, log); err != nil {
|
||||
t.Fatalf("第二次 SeedMemoryTidy: %v", err)
|
||||
}
|
||||
skipped := buf.String()
|
||||
if !strings.Contains(skipped, "跳过播种") || !strings.Contains(skipped, memoryTidyFileName) {
|
||||
t.Fatalf("跳过播种的日志缺路径或说明:%s", skipped)
|
||||
}
|
||||
if !strings.Contains(skipped, "绝不覆盖") {
|
||||
t.Fatalf("跳过播种的日志没说明理由(绝不覆盖):%s", skipped)
|
||||
}
|
||||
if !strings.Contains(skipped, memoryTidySeedReasonExists) {
|
||||
t.Fatalf("跳过播种的日志没带 reason 字段:%s", skipped)
|
||||
}
|
||||
for _, value := range []string{"4242", "10800", "1800"} {
|
||||
if strings.Contains(skipped, value) {
|
||||
t.Fatalf("跳过日志里出现了配置值 %s:%s", value, skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 损坏:WARN 日志带路径与分类错误(主人据此知道「这份配置没生效、也没被改」)。
|
||||
buf.Reset()
|
||||
broken := writeMemoryTidyFile(t, `{"autoApply": "yes"}`)
|
||||
if _, err := SeedMemoryTidy(broken, seed, log); err != nil {
|
||||
t.Fatalf("坏文件的 SeedMemoryTidy: %v", err)
|
||||
}
|
||||
warned := buf.String()
|
||||
if !strings.Contains(warned, "level=WARN") || !strings.Contains(warned, "无法解析") {
|
||||
t.Fatalf("损坏文件必须留一条 WARN:%s", warned)
|
||||
}
|
||||
if !strings.Contains(warned, memoryTidyFileName) {
|
||||
t.Fatalf("WARN 没带路径:%s", warned)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryTidySeedKeysMatchStruct 是**防漂移**断言:播种日志里列的键名与
|
||||
// MemoryTidyTuning 的 json tag 必须一一对应。加了新键却忘了更新 memoryTidySeedKeys,
|
||||
// 用例就会红 —— 否则「播了哪些键」的日志会安静地漏报。
|
||||
func TestMemoryTidySeedKeysMatchStruct(t *testing.T) {
|
||||
t.Parallel()
|
||||
typ := reflect.TypeOf(MemoryTidyTuning{})
|
||||
var tags []string
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
tag, ok := typ.Field(i).Tag.Lookup("json")
|
||||
if !ok || tag == "" {
|
||||
t.Fatalf("字段 %s 没有 json tag", typ.Field(i).Name)
|
||||
}
|
||||
tags = append(tags, strings.Split(tag, ",")[0])
|
||||
}
|
||||
want := append([]string(nil), memoryTidySeedKeys...)
|
||||
sort.Strings(tags)
|
||||
sort.Strings(want)
|
||||
if !reflect.DeepEqual(tags, want) {
|
||||
t.Fatalf("结构体的键 %v 与 memoryTidySeedKeys %v 不一致(加了新键就要同步那张表)", tags, want)
|
||||
}
|
||||
}
|
||||
@@ -211,3 +211,70 @@ func writeMemoryTidyFile(t *testing.T, body string) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// TestLoadMemoryTidyTuning_ScheduleKeys 钉住 **整理频率**四项
|
||||
// (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)的读取口径。
|
||||
//
|
||||
// 为什么钟点必须能区分「显式 0」与「没写」:0 是**合法钟点**(0-7 的凌晨窗口、
|
||||
// 23-0 到午夜的窗口),用 int 承载会让只写一端的文件静默改掉另一端。因此这里
|
||||
// 断言的是「指针是否非 nil」,而不是「值是否非 0」。
|
||||
//
|
||||
// 值的**可用性**(非正数 ⇒ 回落)不在这里判:那是 memory 侧
|
||||
// ApplyTidyScheduleTuning 的职责,本包只管文件形状与读取。
|
||||
func TestLoadMemoryTidyTuning_ScheduleKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// ① 文件不存在 ⇒ 四个键都是 nil(= 不覆盖,由 `.env` / 代码默认兜底)。
|
||||
absent, err := LoadMemoryTidyTuning(filepath.Join(t.TempDir(), memoryTidyFileName))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryTidyTuning(absent): %v", err)
|
||||
}
|
||||
if absent.IntervalSec != nil || absent.QuietIntervalSec != nil ||
|
||||
absent.QuietStartHour != nil || absent.QuietEndHour != nil {
|
||||
t.Fatalf("没配过时四个调度键都该是 nil,得到 %+v", absent)
|
||||
}
|
||||
|
||||
// ② 四个键都写了 ⇒ 逐键读出来(与 env 无关,本层不比对)。
|
||||
got, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t,
|
||||
`{"intervalSec": 3600, "quietIntervalSec": 10800, "quietStartHour": 1, "quietEndHour": 5}`))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryTidyTuning(set): %v", err)
|
||||
}
|
||||
if got.IntervalSec == nil || *got.IntervalSec != 3600 {
|
||||
t.Fatalf("intervalSec = %v, want 3600", got.IntervalSec)
|
||||
}
|
||||
if got.QuietIntervalSec == nil || *got.QuietIntervalSec != 10800 {
|
||||
t.Fatalf("quietIntervalSec = %v, want 10800", got.QuietIntervalSec)
|
||||
}
|
||||
if got.QuietStartHour == nil || *got.QuietStartHour != 1 ||
|
||||
got.QuietEndHour == nil || *got.QuietEndHour != 5 {
|
||||
t.Fatalf("安静时段 = %v-%v, want 01:00-05:00", got.QuietStartHour, got.QuietEndHour)
|
||||
}
|
||||
|
||||
// ③ 钟点显式写 0 ⇒ **是值**(不是「没配」):0-7 的凌晨窗口是合法配置。
|
||||
zero, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"quietStartHour": 0, "quietEndHour": 7}`))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryTidyTuning(zero): %v", err)
|
||||
}
|
||||
if zero.QuietStartHour == nil || *zero.QuietStartHour != 0 {
|
||||
t.Fatalf("显式写 0 的钟点必须原样保留,得到 %v", zero.QuietStartHour)
|
||||
}
|
||||
if zero.QuietEndHour == nil || *zero.QuietEndHour != 7 {
|
||||
t.Fatalf("quietEndHour = %v, want 7", zero.QuietEndHour)
|
||||
}
|
||||
|
||||
// ④ 秒数写 0 ⇒ 仍是「有值」(0 由 memory 侧判为坏值并回落 + WARN,本层不拦)。
|
||||
explicitZero, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"intervalSec": 0}`))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryTidyTuning(explicit zero): %v", err)
|
||||
}
|
||||
if explicitZero.IntervalSec == nil || *explicitZero.IntervalSec != 0 {
|
||||
t.Fatalf("显式写 0 的 intervalSec 应当被读出来(好坏由上层判),得到 %v", explicitZero.IntervalSec)
|
||||
}
|
||||
|
||||
// ⑤ 类型写错(字符串)⇒ 整份文件解不开,分类错误 + 不启动失败。
|
||||
// 这是**既有粒度**:一个键类型写错会让全部键一起回落 `.env`。
|
||||
if _, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"intervalSec": "3600"}`)); !errors.Is(err, ErrInvalidMemoryTidy) {
|
||||
t.Fatalf("intervalSec 写成字符串应当返回 ErrInvalidMemoryTidy,得到 %v", err)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
@@ -38,23 +39,24 @@ const (
|
||||
// DefaultRequestTimeout 是**单次** /embeddings 调用的墙钟上限,独立于 HTTP 客户端
|
||||
// 那一层的 180s 总超时(httpRequestTimeout)。
|
||||
//
|
||||
// 依据(是实测数据,不是拍脑袋的调优):
|
||||
// - 上层「入库前审查」的单条总预算是 20s(memory.defaultReviewTimeout),embedding
|
||||
// 只是它内部的第一步。2026-10-07 实测(本机 llama.cpp + Qwen3-Embedding-0.6B,
|
||||
// 服务空闲):单条文本约 60ms;**64 条一批约 2.1~2.9s**;审查最坏情况
|
||||
// (ScanLimit=200 + 新条目 1)要 4 批 ⇒ 约 8~10s,已经占掉 20s 预算的一半,
|
||||
// 留给后面的 T1 灰区判定(defaultT1Timeout=8s)的余量很薄。一次卡死的 HTTP
|
||||
// 就能把这点余量连同整条审查一起吃掉(实测日志里
|
||||
// `memory/review: context deadline exceeded` 正是这个形态);
|
||||
// - 所以每次调用必须有**自己的**上限:卡住的那一批到点就失败并降级,不能把整条
|
||||
// 审查的 20s 都赌在一次 HTTP 上;
|
||||
// - 5s 的取值:相对实测的 64 条一批 2.9s 留约 1.7 倍余量,能吸收并发争用带来的
|
||||
// 抖动,同时远小于 20s 的审查预算。若日后批次变大或服务变慢,先调这里而不是
|
||||
// 调审查总预算;
|
||||
// - 真慢就**快速失败**:调用方按**既有语义**降级(审查降级到词项余弦并如实留痕
|
||||
// degraded),**绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则;
|
||||
// - 已知代价:llama.cpp 首次加载模型可能超过 5s,那一次调用会失败并降级,模型
|
||||
// 常驻后的下一次调用即恢复正常。降级本就是既有行为,因此可接受。
|
||||
// 它**不是**单独取值:批次上限(DefaultBatchLimit / DefaultBatchChars)与它由
|
||||
// budget.go 里**同一个不等式**推出 —— 单次请求的耗时上界不得超过超时的一半。
|
||||
// 改这一行就等于改了批次上限,反之亦然;两侧永远是配套的。
|
||||
//
|
||||
// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 20s
|
||||
// (memory.defaultReviewTimeout),embedding 只是它内部的第一步,后面还有 T1 灰区
|
||||
// 判定(8s)。一次卡死的 HTTP 不能把整条审查的 20s 都赌掉。真慢就**快速失败**,
|
||||
// 调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 degraded),
|
||||
// **绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则。
|
||||
//
|
||||
// 5s 保持不变的依据:本次故障(2026-10-13)的成因不是这个数太小,而是批次太大 ——
|
||||
// 64 条的实测耗时 7.5~8.5s 已经越过它,客户端放弃、服务端还在算,把整台本机
|
||||
// embedding 服务顶住,连带单条请求(写向量索引)也一起超时。所以修法是**把批次
|
||||
// 收进预算内**,而不是把预算放大到迁就批次(后者会让真挂死的请求也拖住整轮)。
|
||||
//
|
||||
// 冷启动另有 warmupTimeout(见 budget.go),但它**只放宽本进程的第一个请求**、
|
||||
// 且一次性花掉:失败不会让它续期,因此「服务一直挂死」这件事**不会**变成
|
||||
// 「每次调用都慢」—— 第二个请求起就回到本常量。
|
||||
const DefaultRequestTimeout = 5 * time.Second
|
||||
|
||||
// openAICompatibleRequest is the JSON body sent to an OpenAI-compatible
|
||||
@@ -93,6 +95,11 @@ type APIEmbedder struct {
|
||||
cfg config.EmbeddingConfig
|
||||
client *http.Client
|
||||
log *slog.Logger
|
||||
// warmupLeft 是**一次性**冷启动放宽的开关:为真时下一个请求用 warmupTimeout,
|
||||
// 取走即置假(CompareAndSwap ⇒ 并发下也只有一个请求能拿到)。
|
||||
// 它**不是**「未成功就一直放宽」—— 失败也照样花掉这一次,因此服务挂死时
|
||||
// 只会慢一次,之后每个请求都按 DefaultRequestTimeout 快速失败(见 budget.go)。
|
||||
warmupLeft atomic.Bool
|
||||
|
||||
mu sync.RWMutex
|
||||
dim int
|
||||
@@ -131,12 +138,16 @@ func WithLogger(l *slog.Logger) APIOption {
|
||||
}
|
||||
|
||||
// NewAPI builds an APIEmbedder from cfg using the default HTTP client.
|
||||
// 单次超时不可由选项更换:它是 budget.go 那条推演的输入,批次上限与它同源,
|
||||
// 因此**只允许改常量**(改了之后批次上限自动跟着改,测试的编译期/常量断言立刻复核)。
|
||||
func NewAPI(cfg config.EmbeddingConfig, opts ...APIOption) *APIEmbedder {
|
||||
e := &APIEmbedder{
|
||||
cfg: cfg,
|
||||
client: defaultHTTPClient(),
|
||||
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
// 冷启动放宽是一个**一次性**许可:见 APIEmbedder.warmupLeft。
|
||||
e.warmupLeft.Store(true)
|
||||
for _, opt := range opts {
|
||||
opt(e)
|
||||
}
|
||||
@@ -155,8 +166,10 @@ func (e *APIEmbedder) Dim() int {
|
||||
// returned vectors aligned by index. It returns ErrNoAPIURL for a blank URL
|
||||
// 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) {
|
||||
if len(texts) == 0 {
|
||||
return []Vector{}, nil
|
||||
@@ -168,7 +181,40 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro
|
||||
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()
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
@@ -222,6 +222,11 @@ const reviewBudgetRef = 20 * time.Second
|
||||
// 审查总预算:否则一次卡死的 embedding 连接就能吃光整条审查的预算,排在后面的
|
||||
// T1 灰区判定连执行机会都没有(2026-10-07 实测故障形态)。同时验证该超时确实
|
||||
// 打在了发出去的请求上。
|
||||
//
|
||||
// 🔴 冷启动放宽(warmupTimeout)必须一并守住同一条界:它只发生一次,但它那一次的
|
||||
// 等待**也**不能吃光整轮审查 —— 否则「一次挂死 = 整轮审查没有预算给 T1」这个形态
|
||||
// 会从常态超时挪到放宽预算上原样复现(reviewer round1 的 p1)。因此这里对
|
||||
// warmupTimeout 做与 DefaultRequestTimeout **同一条**断言。
|
||||
func TestDefaultRequestTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -236,6 +241,19 @@ func TestDefaultRequestTimeout(t *testing.T) {
|
||||
t.Fatalf("DefaultRequestTimeout = %s, want 5s(值变了要连带复核审查预算口径)",
|
||||
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
|
||||
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"},
|
||||
WithRoundTripper(rt),
|
||||
)
|
||||
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
|
||||
t.Fatalf("Embed() error: %v", err)
|
||||
// 第一次是冷启动调用(warmupTimeout),结果由它自己的测试断言;这里连发两次,
|
||||
// 只看稳态那一次打出去的 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 {
|
||||
t.Fatalf("请求 deadline 余量 = %s, want (0, %s]", slack, DefaultRequestTimeout)
|
||||
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,11 @@ func (s *Store) AddScoped(ctx context.Context, actorID string, item MemoryItem)
|
||||
// 作用域上也始终声明身份边界。是否允许受限上下文(群聊 / 陌生人,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) {
|
||||
if strings.TrimSpace(personaID) == "" {
|
||||
return nil, ErrMissingPersona
|
||||
@@ -80,10 +85,12 @@ func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([
|
||||
if !scope.valid() {
|
||||
return nil, ErrInvalidScope
|
||||
}
|
||||
now := timeGateNow()
|
||||
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,
|
||||
decay_weight FROM memory_items WHERE scope = ? AND status = ?
|
||||
ORDER BY created_at DESC, rowid DESC`, string(scope), string(tidyStatusActive))
|
||||
decay_weight FROM memory_items WHERE scope = ? AND status = ?`+timeGateClause+`
|
||||
ORDER BY created_at DESC, rowid DESC`,
|
||||
string(scope), string(tidyStatusActive), now, now)
|
||||
if err != nil {
|
||||
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);这是深检索入口 —— 调用方把它与
|
||||
// projectindex 存储结合,沿 项目索引 → 项目描述 → 章节内容 遍历。项目
|
||||
// 记忆所有角色可读,因此不施加角色过滤。projectID 为空
|
||||
// 返回 ErrMissingProjectID。
|
||||
// 返回 ErrMissingProjectID。与其余三条列表读路径同口径:只返回在册且过
|
||||
// **时效闸**的行(timegate.go、design/03 §9.6/§11.2)。
|
||||
func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return nil, ErrMissingProjectID
|
||||
}
|
||||
now := timeGateNow()
|
||||
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,
|
||||
decay_weight FROM memory_items WHERE project_id = ? AND status = ?
|
||||
ORDER BY created_at DESC, rowid DESC`, projectID, string(tidyStatusActive))
|
||||
decay_weight FROM memory_items WHERE project_id = ? AND status = ?`+timeGateClause+`
|
||||
ORDER BY created_at DESC, rowid DESC`, projectID, string(tidyStatusActive), now, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list project items: %w", err)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,16 @@ type MemoryItem struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
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 包各类失败的哨兵错误。每个都在
|
||||
@@ -231,6 +241,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
// 关系表里,语义增强的失败绝不使记忆写入失败,检索侧也会回退到
|
||||
// 内存余弦(见 Retriever.semanticScores)。被闸判为重复的行**不建索引**:
|
||||
// 它不进召回、也不该占掉语义检索 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) {
|
||||
if err := item.normalize(); err != nil {
|
||||
return MemoryItem{}, err
|
||||
@@ -247,12 +262,13 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
plan := s.planIngest(ctx, item)
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO memory_items
|
||||
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence,
|
||||
source_conversation, created_at, updated_at, decay_weight, status, merged_into)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
source_conversation, created_at, updated_at, decay_weight, status, merged_into,
|
||||
expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind),
|
||||
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
|
||||
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight,
|
||||
plan.insertStatus(), plan.absorbedID())
|
||||
plan.insertStatus(), plan.absorbedID(), expiryText(item.ExpiresAt))
|
||||
if err != nil {
|
||||
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 为空或
|
||||
// 不存在时返回 ErrNotFound。
|
||||
//
|
||||
// 🔴 **刻意不过任何读侧闸**(status 与时效都不过;design/03 §11.2 第 3 条):
|
||||
// 撤销软合并必须先读得到被吸收的行,人工核查 / 清算也要先读得到已过期与
|
||||
// 已失效的行 —— 加闸等于把审计与撤销的抓手一并抹掉。要「面向用户的可见集」
|
||||
// 请走 ListByPersona / ListScope / ListProject / List 或 Retriever(那些都过闸)。
|
||||
func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return MemoryItem{}, ErrNotFound
|
||||
@@ -304,14 +325,21 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
|
||||
// 只返回**在册**(status='active')条目:整理与入库前审查都只做软动作,
|
||||
// 被合并/被归档的行内容原样留在表里,因此「能看到什么记忆」的每条读路径
|
||||
// 都必须过 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) {
|
||||
if strings.TrimSpace(personaID) == "" {
|
||||
return nil, ErrMissingPersona
|
||||
}
|
||||
// 闸的左右操作数同源:两边都由 formatTime 产出定宽 UTC 文本。
|
||||
now := timeGateNow()
|
||||
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,
|
||||
decay_weight FROM memory_items WHERE persona_id = ? AND status = ?
|
||||
ORDER BY created_at DESC, rowid DESC`, personaID, string(tidyStatusActive))
|
||||
decay_weight FROM memory_items WHERE persona_id = ? AND status = ?`+timeGateClause+`
|
||||
ORDER BY created_at DESC, rowid DESC`,
|
||||
personaID, string(tidyStatusActive), now, now)
|
||||
if err != nil {
|
||||
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 的软动作口径)。要看它们走
|
||||
// 审查留痕 `GET /admin/memories/reviews` 或按 id 用 Get —— 后者刻意
|
||||
// 不过 status 闸,因为撤销软合并必须先读得到被吸收的行。
|
||||
//
|
||||
// 同样过**时效闸**:临时记忆到期(expires_at ≤ now)与事实失效
|
||||
// (valid_to ≤ now)的行一并从这里消失(timegate.go、design/03 §9.6)。
|
||||
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,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items WHERE status = ?
|
||||
ORDER BY created_at DESC, rowid DESC`, string(tidyStatusActive))
|
||||
decay_weight FROM memory_items WHERE status = ?`+timeGateClause+`
|
||||
ORDER BY created_at DESC, rowid DESC`, string(tidyStatusActive), now, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list items: %w", err)
|
||||
}
|
||||
|
||||
@@ -119,12 +119,17 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
return stats, err
|
||||
}
|
||||
stats.RunID = runID
|
||||
// ledger 是**本轮实际发生**的召回来源记账口(不是配置里那一种):生产轮可以
|
||||
// 多分区混合(有的走语义、有的降级),因此汇总行只能由实测记录推出。
|
||||
ledger := &recallLedger{}
|
||||
|
||||
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
|
||||
sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
|
||||
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 主人口径):规则层判为高置信的待定候选直接落地,
|
||||
@@ -150,7 +155,7 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
|
||||
t.logRun(stats, run)
|
||||
t.logRun(stats, run, ledger)
|
||||
t.auditAutoApply(auto)
|
||||
|
||||
switch {
|
||||
@@ -165,7 +170,14 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
|
||||
// logRun 输出本轮的可观测口径:扫描 N / 候选 M / 建议 K / 否决原因分布。
|
||||
// 它带 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{
|
||||
"run_id", run.ID,
|
||||
"rule_version", run.RuleVersion,
|
||||
@@ -181,7 +193,8 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
|
||||
"kept", stats.Kept,
|
||||
"errors", run.Errors,
|
||||
"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_applied", stats.AutoApplied,
|
||||
"auto_merge", stats.AutoMerged,
|
||||
@@ -232,11 +245,15 @@ func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition,
|
||||
// fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项
|
||||
// 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。
|
||||
// 两个来源**不可混用阈值**:阈值只有一个(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 {
|
||||
return pairs, nil
|
||||
}
|
||||
ledger.noteLexical(true)
|
||||
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
|
||||
"partition", p.label(), "error", err)
|
||||
return t.collectPairs(p, items, t.cfg.RecallThreshold,
|
||||
|
||||
@@ -4,14 +4,15 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
)
|
||||
|
||||
// 本文件收拢记忆整理(design/23-记忆整理与去重)全部的标识、默认值与旋钮,
|
||||
// 使运行路径上不出现裸魔法数字(AGENTS §10),并让「设计稿默认值」
|
||||
// 只有一处定义、可被复核。
|
||||
//
|
||||
// 形态(design/23 §4 方案 A/C):整理是一件**低峰定期后台任务** —— 跑在
|
||||
// 专属隔离会话下、零上下文启动、只产候选**不写记忆**,绝不是会话内派
|
||||
// 形态(design/23 §4 方案 A/C):整理是一件**定期后台任务**(安静时段降频)——
|
||||
// 跑在专属隔离会话下、零上下文启动、只产候选**不写记忆**,绝不是会话内派
|
||||
// subagent。
|
||||
|
||||
const (
|
||||
@@ -37,8 +38,11 @@ const (
|
||||
defaultTidyIntervalSec = 86400
|
||||
|
||||
// defaultTidyQuietStartHour / defaultTidyQuietEndHour 复用 backthink 的
|
||||
// 安静时段语义(默认 23:00-07:00,窗口跨午夜;两端相等且非 0 则关闭窗口)
|
||||
// —— 整理只在低峰跑(design/23 §4)。
|
||||
// 安静时段语义(默认 23:00-07:00,窗口跨午夜;两端相等且非 0 则关闭窗口)。
|
||||
//
|
||||
// 🔴 2026-10-07 起窗口不再等于「唯一可跑时段」:它是**降频档位**的判据
|
||||
// (安静时段用 defaultTidyIntervalSec 之外的另一档,见 intervalAt),并且
|
||||
// 仍然决定「首次/未跑过的窗口要补跑一轮」(dueNow 判据 1/3)。
|
||||
defaultTidyQuietStartHour = 23
|
||||
defaultTidyQuietEndHour = 7
|
||||
|
||||
@@ -53,7 +57,7 @@ const (
|
||||
|
||||
// maxTidyCompareItems 是每分区每轮**真正参与两两比对**的条目上限的兜底默认
|
||||
// 值(可直接从 MEMORY_TIDY_COMPARE_LIMIT 调,见 TidyConfig.CompareLimit)。
|
||||
// 两两相似度是 O(n²·dim),5000 条会到十亿级乘加,低峰任务也不该跑成那样。
|
||||
// 两两相似度是 O(n²·dim),5000 条会到十亿级乘加,后台任务也不该跑成那样。
|
||||
// 2026-10-06 由 400 抬到 2000:首轮真跑 459 条的分区被旧值截到 400,
|
||||
// 于是 compared(705) < scanned(784) —— 截断确实如实登记了,但护栏成为结
|
||||
// 果的主因本身就是缺陷。超限仍按「最新优先」截断并记一条 WARN(绝不静默),
|
||||
@@ -71,7 +75,17 @@ const (
|
||||
|
||||
// tidyEmbedBatch 是单次 Embed 调用的文本条数上限,避免把整个分区的文本
|
||||
// 一次性交给远端 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)。过短的内容
|
||||
// (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。
|
||||
@@ -123,20 +137,39 @@ const (
|
||||
maxTidyRejectBatchLimit = 10000
|
||||
)
|
||||
|
||||
// 编译期不变式(2026-10-13):批上限一旦被调回超过 embedding 推出来的值
|
||||
// (例:写回旧值 64),下面这行常量表达式会变成负数、uint 溢出 ⇒ **编译失败**。
|
||||
// 这条断言把「批上限与超时同源」从注释里的承诺变成编译器守的门。
|
||||
const _ = uint(embedding.DefaultBatchLimit - tidyEmbedBatch)
|
||||
|
||||
// TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值,
|
||||
// 因此半填的 Config 仍可用。
|
||||
type TidyConfig struct {
|
||||
// Enabled 是总开关。关闭时组合根不建服务、不跑任何整理
|
||||
// (design/23 §4 的 MEMORY_TIDY_ENABLED)。
|
||||
Enabled bool
|
||||
// Interval 是整理周期。为零时由 IntervalSec(或其默认值)推导。
|
||||
// Interval 是整理周期(**非安静时段**)。为零时由 IntervalSec(或其默认值)推导。
|
||||
Interval time.Duration
|
||||
// IntervalSec 是整理周期秒数,映射自 config.MemoryTidyConfig.IntervalSec。
|
||||
// 设置了 Interval 时忽略。它是**下限**:安静时段每天只有一次,
|
||||
// 因此实际间隔取两者中较长者。
|
||||
// IntervalSec 是整理周期秒数(**非安静时段**),映射自
|
||||
// config.MemoryTidyConfig.IntervalSec(env),并可由
|
||||
// `$DATA_DIR/config/memory-tidy.json` 的 `intervalSec` 覆盖(面板优先、`.env`
|
||||
// 兜底,唯一实现点是 ApplyTidyScheduleTuning)。设置了 Interval 时忽略。
|
||||
IntervalSec int
|
||||
// QuietIntervalSec 是**安静时段**的整理周期秒数(2026-10-07 主人「或者动态
|
||||
// 分配,比如白天间隔短一点,晚上到了静默时期稍微长一点」)。
|
||||
//
|
||||
// 非正 ⇒ 等于 IntervalSec:**缺省 = 不因时段换档**,而不是「夜里不跑」
|
||||
// (normalize 里那一条是唯一落点)。它没有 env 键(`.env` 已冻结),只由
|
||||
// `$DATA_DIR/config/memory-tidy.json` 的 `quietIntervalSec` 提供。
|
||||
QuietIntervalSec int
|
||||
// QuietInterval 是安静时段的整理周期。为零时由 QuietIntervalSec 推导。
|
||||
QuietInterval time.Duration
|
||||
// QuietStartHour / QuietEndHour 以本地钟点约束低峰窗口
|
||||
// (复用 backthink 语义:窗口可跨午夜,两端相等且非 0 则关闭窗口)。
|
||||
//
|
||||
// 🔴 2026-10-07 起窗口的**语义一字未改**(inQuietHours 逐字不动),但它的
|
||||
// **作用面**收窄了一格:从「整理唯一可跑的时段」变成「整理降频的时段」
|
||||
// (见 intervalAt)。理由是主人的频率口径要求白天也跑,且白天跑得比夜里勤。
|
||||
QuietStartHour int
|
||||
QuietEndHour int
|
||||
// RecallThreshold 是候选召回的余弦门(见 defaultTidyRecallThreshold)。
|
||||
@@ -165,6 +198,10 @@ type TidyConfig struct {
|
||||
|
||||
// TidyConfigFromConfig 把 config.MemoryTidyConfig(env 侧旋钮)映射成
|
||||
// TidyConfig,env 未暴露的项保持零值,交给 normalize 填默认值。
|
||||
//
|
||||
// 它是**兜底层**:组合根随后用 ApplyTidyScheduleTuning 把
|
||||
// `$DATA_DIR/config/memory-tidy.json` 的调度旋钮叠上来(面板优先、env 兜底);
|
||||
// env 没有的项(如 quietIntervalSec)在这里保持零值,由 normalize 取默认。
|
||||
func TidyConfigFromConfig(cfg config.MemoryTidyConfig) TidyConfig {
|
||||
return TidyConfig{
|
||||
Enabled: cfg.Enabled,
|
||||
@@ -186,6 +223,14 @@ func normalizeTidyConfig(cfg TidyConfig) TidyConfig {
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second
|
||||
}
|
||||
if cfg.QuietIntervalSec <= 0 {
|
||||
// 缺省 = 与普通间隔同档。放在 IntervalSec 归一之后,因此「面板/env 都没给
|
||||
// quietIntervalSec」时它取的是**已解析的** intervalSec,而不是魔法数字。
|
||||
cfg.QuietIntervalSec = cfg.IntervalSec
|
||||
}
|
||||
if cfg.QuietInterval <= 0 {
|
||||
cfg.QuietInterval = time.Duration(cfg.QuietIntervalSec) * time.Second
|
||||
}
|
||||
if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 {
|
||||
cfg.QuietStartHour = defaultTidyQuietStartHour
|
||||
cfg.QuietEndHour = defaultTidyQuietEndHour
|
||||
@@ -230,6 +275,32 @@ func (c TidyConfig) inQuietHours(at time.Time) bool {
|
||||
return h >= c.QuietStartHour || h < c.QuietEndHour
|
||||
}
|
||||
|
||||
// intervalAt 按 **at 所在时段** 选整理周期:安静时段用 QuietInterval,其余用
|
||||
// Interval(= design/12 §N A1 给后台思考心跳定下的「窗口内降频」口径,搬进整理)。
|
||||
//
|
||||
// 主人 2026-10-07 的原话是「**频率必须调**,可以调到 1 小时或者三小时,**或者动态
|
||||
// 分配**,比如白天间隔短一点,晚上到了静默时期稍微长一点」——这个函数就是「动态
|
||||
// 分配」的唯一判据。
|
||||
//
|
||||
// 它是**纯函数**(只读 cfg 与入参),因此心跳可以在**每一拍之前**重算它:跨过
|
||||
// 安静时段边界之后,下一拍自然换档,而不是启动时定死一次(调度侧的用法见
|
||||
// TidyService.nextDelay)。
|
||||
//
|
||||
// 两条口径:
|
||||
// - 安静判定**直接复用** inQuietHours,跨午夜口径与其余各处以字节为单位一致,
|
||||
// 这里不另写一份「越走越偏」的窗口算法;
|
||||
// - 窗口被显式关闭(两端相等且非 0)时不换档 —— 关闭窗口的意思是「没有低峰
|
||||
// 这回事」,而不是「全天都算低峰」。
|
||||
func (c TidyConfig) intervalAt(at time.Time) time.Duration {
|
||||
if c.quietWindowDisabled() {
|
||||
return c.Interval
|
||||
}
|
||||
if c.QuietInterval > 0 && c.inQuietHours(at) {
|
||||
return c.QuietInterval
|
||||
}
|
||||
return c.Interval
|
||||
}
|
||||
|
||||
// nextQuietStart 返回 at 之后(严格晚于 at)的下一个低峰窗口起点:
|
||||
// 今天/明天的 QuietStartHour 整点。窗口关闭时返回零值时间,调用方据此
|
||||
// 改走纯周期路径。
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
// 本文件是记忆整理的**试算(dry-run)**入口(契约 v0.75;切片 MT1)。
|
||||
//
|
||||
// 它与低峰心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 →
|
||||
// 它与心跳跑的是**同一条**规则路径(扫描 → 召回 → 包含判定 → 规则否决 →
|
||||
// 构造候选),只有两点刻意不同:
|
||||
//
|
||||
// 1. **零副作用**:候选不落库 —— 不写 memory_tidy_candidates、不写
|
||||
@@ -52,11 +52,15 @@ type TidyDryRun struct {
|
||||
// 「完整的建议」展示)。
|
||||
func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
|
||||
started := t.now()
|
||||
out := TidyDryRun{RecallMode: tidyRecallMode(t.deps.Embedder != nil)}
|
||||
out := TidyDryRun{}
|
||||
stats, partitions, err := t.beginRound(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
// ledger 是**本轮实际发生**的召回来源:试算会写进 RecallMode 返回给面板。
|
||||
// 旧实现按「有没有配 embedder」写(tidyRecallMode(deps.Embedder != nil)),
|
||||
// 那会让面板把「向量不可用」读成「向量正常」(M6 2026-10-13 的 medium finding)。
|
||||
ledger := &recallLedger{}
|
||||
|
||||
// 收集出口:闭包只在 scanPartitions 的**串行**循环里被调用,因此无需加锁。
|
||||
collected := make([]TidyCandidate, 0)
|
||||
@@ -64,25 +68,38 @@ func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
|
||||
sink: func(_ context.Context, cands []TidyCandidate, _ *TidyStats) {
|
||||
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)
|
||||
|
||||
out.Stats = stats
|
||||
out.Candidates = collected
|
||||
// 如实上报**实际发生**的来源:空串 = 没有任何分区真正算过相似度(「没候选」)。
|
||||
// 「尝试过但向量源不可用」是另一件事,由 recall_unavailable_partitions 单独给出
|
||||
// (见 logDryRun 的说明与 recallLedger.unavailable)。
|
||||
out.RecallMode = ledger.label()
|
||||
out.DurationMS = t.now().Sub(started).Milliseconds()
|
||||
// 汇总行**成败都记**(与 RunOnce 同一口径):失败那一路的
|
||||
// `recall_unavailable_partitions` 正是「向量不可用」与「没候选」的唯一区分点,
|
||||
// 只在成功时记就等于把这条口径丢掉。
|
||||
t.logDryRun(out, ledger)
|
||||
if len(failures) > 0 {
|
||||
return out, fmt.Errorf("memory/tidy: dry-run finished with errors: %w", errors.Join(failures...))
|
||||
}
|
||||
t.logDryRun(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
ledger.noteUnavailable()
|
||||
t.log.Warn("memory tidy dry-run recall unavailable",
|
||||
"partition", p.label(), "items", len(items), "error", 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),**不带任何记忆
|
||||
// 正文** —— 只记计数、分区数与截断标签。
|
||||
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",
|
||||
"rule_version", TidyRuleVersion,
|
||||
"partitions", out.Stats.Partitions,
|
||||
@@ -104,6 +131,7 @@ func (t *Tidier) logDryRun(out TidyDryRun) {
|
||||
"veto_reasons", tidyReasonsLabel(out.Stats.VetoReasons),
|
||||
"errors", out.Stats.Errors,
|
||||
"recall_mode", out.RecallMode,
|
||||
"recall_unavailable_partitions", ledger.unavailablePartitions(),
|
||||
"duration_ms", out.DurationMS)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件锁定**分时段的动态频率**(主人 2026-10-07:「频率必须调 …… 或者动态分配,
|
||||
// 比如白天间隔短一点,晚上到了静默时期稍微长一点」):
|
||||
//
|
||||
// ① 非安静时段用 intervalSec、安静时段用 quietIntervalSec(intervalAt);
|
||||
// ② 缺省(quietIntervalSec 未配)时两者相等 —— 也就是**行为与改动前一致**;
|
||||
// ③ 间隔在**每一轮跑完之后按当时时段重算**(不是在启动时算一次定死):
|
||||
// 否则白天/晚上的切换不会生效;
|
||||
// ④ 到点判据(dueNow)用**当时时段**的周期,与 nextDelay 同源,不会各说各话;
|
||||
// ⑤ 安静时段不再是「唯一可跑时段」:窗口外也按 intervalSec 跑(①的直接推论,
|
||||
// 由 ③ 的用例端到端证明)。
|
||||
|
||||
// quietWindowCfg 造一份分档周期配置:安静 22:00-02:00(跨午夜),
|
||||
// 白天 2h 一次、夜里 6h 一次。
|
||||
func quietWindowCfg(t *testing.T) TidyConfig {
|
||||
t.Helper()
|
||||
cfg := tidyTestCfg(0.80)
|
||||
cfg.IntervalSec = 2 * 3600
|
||||
cfg.QuietIntervalSec = 6 * 3600
|
||||
cfg.QuietStartHour, cfg.QuietEndHour = 22, 2
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestTidyConfigIntervalAt_SwitchesByPeriod 钉住 intervalAt 的取档口径与
|
||||
// inQuietHours 逐字同源(含跨午夜、边界、窗口关闭)。
|
||||
func TestTidyConfigIntervalAt_SwitchesByPeriod(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := normalizeTidyConfig(quietWindowCfg(t))
|
||||
at := func(hour, min int) time.Time {
|
||||
return time.Date(2026, 10, 7, hour, min, 0, 0, time.Local)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
at time.Time
|
||||
want time.Duration
|
||||
}{
|
||||
{"白天(14:00)用 intervalSec", at(14, 0), 2 * time.Hour},
|
||||
{"窗口前一分钟(21:59)仍用 intervalSec", at(21, 59), 2 * time.Hour},
|
||||
{"窗口起点(22:00)用 quietIntervalSec", at(22, 0), 6 * time.Hour},
|
||||
{"跨午夜的半夜(00:30)用 quietIntervalSec", at(0, 30), 6 * time.Hour},
|
||||
{"窗口终点前(01:59)用 quietIntervalSec", at(1, 59), 6 * time.Hour},
|
||||
{"窗口终点(02:00)回到 intervalSec", at(2, 0), 2 * time.Hour},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := cfg.intervalAt(tc.at)
|
||||
if got != tc.want {
|
||||
t.Fatalf("intervalAt(%s) = %s, want %s", tc.at.Format("15:04"), got, tc.want)
|
||||
}
|
||||
// 与既有安静判定**同源**:落在哪一档当且仅当 inQuietHours。
|
||||
want := cfg.Interval
|
||||
if cfg.inQuietHours(tc.at) {
|
||||
want = cfg.QuietInterval
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("intervalAt 与 inQuietHours 不同源:%s interval=%s, 期望 %s",
|
||||
tc.at.Format("15:04"), got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 缺省(quietIntervalSec 未配)⇒ 两档相等 = 行为与本次改动之前逐字一致。
|
||||
plain := normalizeTidyConfig(tidyTestCfg(0.80))
|
||||
if plain.QuietIntervalSec != plain.IntervalSec || plain.QuietInterval != plain.Interval {
|
||||
t.Fatalf("缺省时两档必须相等:interval=%s quiet=%s", plain.Interval, plain.QuietInterval)
|
||||
}
|
||||
if got := plain.intervalAt(at(23, 30)); got != plain.Interval {
|
||||
t.Fatalf("缺省时分档不得改变频率:得到 %s, want %s", got, plain.Interval)
|
||||
}
|
||||
|
||||
// 窗口被显式关闭(两端相等且非 0)⇒ 不换档:关闭窗口的意思是「没有低峰这回事」,
|
||||
// 而不是「全天都算低峰」。
|
||||
closed := normalizeTidyConfig(quietWindowCfg(t))
|
||||
closed.QuietStartHour, closed.QuietEndHour = 5, 5
|
||||
for _, h := range []int{0, 5, 12, 23} {
|
||||
if got := closed.intervalAt(at(h, 0)); got != closed.Interval {
|
||||
t.Fatalf("窗口关闭时 %02d:00 的间隔 = %s, want %s", h, got, closed.Interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceDueNowUsesPeriodInterval 钉住「到点」判据用的是**当时时段**的周期。
|
||||
//
|
||||
// 用例刻意让「按白天档会判到点、按夜里档不会」:安静 23:00-07:00,白天 1h、
|
||||
// 夜里 8h。上次跑在 23:05,此刻 01:00 ⇒ 距上次 1h55m:若拿白天的 1h 去比会判
|
||||
// 「到点」(那是错的),拿夜里的 8h 才判「不到点」。
|
||||
func TestTidyServiceDueNowUsesPeriodInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
|
||||
cfg := tidyTestCfg(0.80)
|
||||
cfg.IntervalSec = 3600
|
||||
cfg.QuietIntervalSec = 8 * 3600
|
||||
cfg.QuietStartHour, cfg.QuietEndHour = 23, 7
|
||||
svc := newTidyServiceForTest(t, store, cfg, nil, nil, nil)
|
||||
|
||||
set := func(now, last time.Time) {
|
||||
svc.now = func() time.Time { return now }
|
||||
svc.lastRun = last
|
||||
}
|
||||
last := time.Date(2026, 10, 7, 23, 5, 0, 0, time.Local)
|
||||
|
||||
set(time.Date(2026, 10, 8, 1, 0, 0, 0, time.Local), last)
|
||||
if due, reason := svc.dueNow(); due {
|
||||
t.Fatalf("夜里距上次 1h55m 不该到点(夜里的档是 8h),得到 due=true(%s)", reason)
|
||||
}
|
||||
|
||||
set(time.Date(2026, 10, 9, 1, 0, 0, 0, time.Local), last)
|
||||
if due, _ := svc.dueNow(); !due {
|
||||
t.Fatal("夜里距上次已 25h55m 必须到点(≥ 8h)")
|
||||
}
|
||||
|
||||
// 白天档:上次跑在 09:00,此刻 10:30 ⇒ 1.5h ≥ 1h ⇒ 到点。
|
||||
set(time.Date(2026, 10, 7, 10, 30, 0, 0, time.Local),
|
||||
time.Date(2026, 10, 7, 9, 0, 0, 0, time.Local))
|
||||
if due, _ := svc.dueNow(); !due {
|
||||
t.Fatal("白天距上次 1.5h 必须到点(白天的档是 1h)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceRecomputesIntervalAfterEachRound 是**动态频率**的端到端证明:
|
||||
// 安静 22:00-02:00(跨午夜),白天 2h、夜里 6h。20:00 从零启动(无历史)⇒
|
||||
// 首次落在窗口起点 22:00;此后:
|
||||
//
|
||||
// 22:00 跑完 ⇒ 按**当时**(窗口内)的档 = 6h ⇒ 下一拍 04:00(已在窗口外)
|
||||
// 04:00 跑完 ⇒ 按白天的档 = 2h ⇒ 06:00 ⇒ 08:00
|
||||
//
|
||||
// 断言的是**留痕的时刻序列**与其间隔:22:00 → 04:00 是 6h(夜里的档),
|
||||
// 04:00 → 06:00 → 08:00 都是 2h(白天的档)。若把间隔在启动时算一次定死
|
||||
// (或全程按同一个周期跑),这段序列不可能是这个形状 —— 变异用例钉住了它。
|
||||
func TestTidyServiceRecomputesIntervalAfterEachRound(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
|
||||
|
||||
clock := &schedClock{at: time.Date(2026, 10, 7, 20, 0, 0, 0, time.Local)}
|
||||
probe := newTidyWaitProbe(clock, 4) // 4 拍之后退出(心跳 #5 返回 false)
|
||||
svc := newTidyServiceForTest(t, store, quietWindowCfg(t), nil, clock.now, nil)
|
||||
svc.wait = probe.wait
|
||||
svc.Start(ctx)
|
||||
probe.awaitCalls(t, 5)
|
||||
if err := svc.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
|
||||
first := time.Date(2026, 10, 7, 22, 0, 0, 0, time.Local)
|
||||
want := []time.Time{
|
||||
first.Add(10 * time.Hour), // 08:00(次日)
|
||||
first.Add(8 * time.Hour), // 06:00
|
||||
first.Add(6 * time.Hour), // 04:00(窗口外)
|
||||
first, // 22:00(窗口起点)
|
||||
}
|
||||
gotRuns := tidyRunTimes(t, store)
|
||||
if len(gotRuns) != len(want) {
|
||||
t.Fatalf("运行留痕 = %v, want %d 轮", gotRuns, len(want))
|
||||
}
|
||||
for i, w := range want {
|
||||
if gotRuns[i] != w.UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("第 %d 轮留痕 = %s, want %s(全部:%v)",
|
||||
i+1, gotRuns[i], w.UTC().Format(time.RFC3339), gotRuns)
|
||||
}
|
||||
}
|
||||
|
||||
// 间隔必须**分档**:第一段(夜里)6h,其后(白天)2h。这一条同时证明窗口外也跑。
|
||||
// want 是「最新在前」,因此这里按时间顺序取差。
|
||||
gaps := []time.Duration{
|
||||
want[2].Sub(want[3]), // 22:00 → 04:00:跨过窗口终点,用的是夜里的档
|
||||
want[1].Sub(want[2]), // 04:00 → 06:00:窗口外,用白天的档
|
||||
want[0].Sub(want[1]), // 06:00 → 08:00
|
||||
}
|
||||
if gaps[0] != 6*time.Hour {
|
||||
t.Fatalf("夜里那一轮之后的间隔 = %s, want 6h(quietIntervalSec)", gaps[0])
|
||||
}
|
||||
if gaps[1] != 2*time.Hour || gaps[2] != 2*time.Hour {
|
||||
t.Fatalf("白天间隔 = %s / %s, want 2h(intervalSec)", gaps[1], gaps[2])
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,105 @@ const (
|
||||
tidyRecallEmbedding = "embedding"
|
||||
// tidyRecallLexical 是本轮相似度来自零依赖的词项余弦。
|
||||
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 是一条被召回的候选对。
|
||||
type tidyPair struct {
|
||||
a, b MemoryItem
|
||||
@@ -35,18 +132,23 @@ type tidyPair struct {
|
||||
mode string
|
||||
}
|
||||
|
||||
// recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错)。
|
||||
// recallPairs 在分区内召回候选对。条目少于 2 条时为空操作(无候选、不报错),
|
||||
// 且**不往 ledger 记任何一笔** —— 「没有候选可比」不是「走哪条路召回」。
|
||||
//
|
||||
// 相似度来源不可用时(embedder 报错、向量条数错位)返回错误 —— 回退决策不在
|
||||
// 这里:生产轮把它降级成词项余弦,试算轮如实上报(见 tidyRecall 策略)。
|
||||
//
|
||||
// ledger 是**实际发生**的来源记账口(见 recallLedger):它只在真正选定相似度
|
||||
// 函数的那一行记账,因此谁也无法把它报成「配置里那一种」。
|
||||
//
|
||||
// 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现
|
||||
// (同一批数据两次运行产出同一批候选 ⇒ 幂等,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 {
|
||||
return nil, nil
|
||||
}
|
||||
if t.deps.Embedder == nil {
|
||||
ledger.noteLexical(false)
|
||||
return t.collectPairs(p, items, t.cfg.RecallThreshold,
|
||||
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) {
|
||||
return nil, ErrTidyEmbedCount
|
||||
}
|
||||
ledger.noteEmbedding()
|
||||
return t.collectPairs(p, items, t.cfg.RecallThreshold,
|
||||
vectorSimFunc(vecs), tidyRecallEmbedding, stats, budget), nil
|
||||
}
|
||||
@@ -205,10 +308,6 @@ func clampUnit(v float64) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// tidyRecallMode 报告本轮相似度的来源,便于运维判断阈值是否在预期口径上。
|
||||
func tidyRecallMode(hasEmbedder bool) string {
|
||||
if hasEmbedder {
|
||||
return tidyRecallEmbedding
|
||||
}
|
||||
return tidyRecallLexical
|
||||
}
|
||||
// 旧的 tidyRecallMode(hasEmbedder bool) 已删除(M6 2026-10-13 的 medium finding):
|
||||
// 它按**配置态**报「本轮相似度来源」,把全量降级掩盖成 embedding。现在的口径是
|
||||
// recallLedger —— 只报**实际发生**的那一种,见本文件顶部的 recallLedger。
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是记忆整理的**后台任务形态**(design/23 §4 方案 A/C):低峰定期跑、
|
||||
// 独立运行、零上下文启动、只产建议、绝不推送。
|
||||
// 本文件是记忆整理的**后台任务形态**(design/23 §4 方案 A/C):定期跑(安静时段
|
||||
// 降频)、独立运行、零上下文启动、只产建议、绝不推送。
|
||||
//
|
||||
// 与主人的硬约束对齐(design/23 §4.1):
|
||||
// - 零上下文:本服务的依赖里**没有任何会话/历史读数**,输入只有记忆条目
|
||||
@@ -30,8 +30,14 @@ import (
|
||||
// 重启覆盖,于是 memory_tidy_runs 只有 1 行、77 条候选全 pending。
|
||||
// - 新口径(到点即跑 + 持久事实):启动时先读**持久事实**
|
||||
// (memory_tidy_runs 里最近一轮的开始时刻),满足任一条件就**立刻补跑一次**:
|
||||
// ① 距上次 run 已 ≥ interval;② 已在安静窗内且窗口起点晚于上次 run
|
||||
// (= 这个窗口还没跑过)。此后心跳照旧只在安静窗内跑。
|
||||
// ① 距上次 run 已 ≥ 当时间隔;② 已在安静窗内且窗口起点晚于上次 run
|
||||
// (= 这个窗口还没跑过)。此后心跳按「上一次 run + **当时时段**的周期」醒。
|
||||
//
|
||||
// 2026-10-07(主人「频率必须调……或者动态分配,白天间隔短一点,晚上到了静默时期
|
||||
// 稍微长一点」)追加**分档周期**:非安静时段用 cfg.Interval,安静时段用
|
||||
// cfg.QuietInterval(唯一判据 = TidyConfig.intervalAt)。安静时段因此从「唯一可跑
|
||||
// 时段」变成「降频时段」,心跳不再有「窗口外一律跳过」这一档;而「到点即跑 +
|
||||
// catch-up」那三条判据一条未动。
|
||||
//
|
||||
// 这样「重启不饿死」是可证的:重启只影响内存,判据只依赖持久事实与墙上时钟。
|
||||
|
||||
@@ -131,6 +137,7 @@ func (s *TidyService) Start(ctx context.Context) {
|
||||
|
||||
s.log.Info("memory tidy started",
|
||||
"interval", s.cfg.Interval.String(),
|
||||
"quiet_interval", s.cfg.QuietInterval.String(),
|
||||
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", s.cfg.QuietStartHour, s.cfg.QuietEndHour),
|
||||
"recall_threshold", s.cfg.RecallThreshold,
|
||||
"scan_limit", s.cfg.ScanLimit,
|
||||
@@ -232,7 +239,14 @@ func (s *TidyService) ManualRun(ctx context.Context) (TidyStats, error) {
|
||||
}
|
||||
|
||||
// loop 是心跳 goroutine:先做**启动补跑判定**(持久事实 + 墙上时钟),再进入
|
||||
// 「等到下一个低峰起点 → 在窗口内时跑一轮 → 重算」的循环。ctx 被取消时退出。
|
||||
// 「等到下一次到点 → 跑一轮 → 按**当时时段**重算下一次」的循环。ctx 被取消时退出。
|
||||
//
|
||||
// 🔴 2026-10-07(动态频率)**去掉了「安静窗外的唤醒一律跳过」这一档**:安静时段从
|
||||
// 「整理唯一可跑的时段」变成「整理降频的时段」(design/12 §N A1 给后台思考心跳定下
|
||||
// 的同一口径),因为主人的频率口径要求白天也跑、且白天跑得比夜里勤(「白天间隔短
|
||||
// 一点,晚上到了静默时期稍微长一点」)。窗口与补跑**都没有消失**:窗口还决定档位
|
||||
// (intervalAt)与两条补跑判据(dueNow 的 1/3),「到点即跑 + catch-up」
|
||||
// (design/23 §4.0)原样保留 —— 每次唤醒仍然要过 dueNow 才跑,不是「醒了就扫」。
|
||||
func (s *TidyService) loop(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
s.catchUp(ctx)
|
||||
@@ -243,9 +257,11 @@ func (s *TidyService) loop(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if !s.cfg.inQuietHours(s.now()) {
|
||||
// 低峰窗口外不跑:整理不是随时可做的动作(design/23 §4/§9)。
|
||||
s.log.Debug("memory tidy skipped: outside quiet hours",
|
||||
// 到点即跑:间隔用的是**此刻**的时段(nextDelay 里现算的),因此跨过
|
||||
// 安静时段边界后,下一轮自动换档。判据再次复核是因为等待期间可能有别的
|
||||
// 一轮跑过(手动触发):那时 lastRun 已经前移,这一拍就不该再跑。
|
||||
if due, reason := s.dueNow(); !due {
|
||||
s.log.Debug("memory tidy heartbeat skipped: not due", "reason", reason,
|
||||
"now_hour", s.now().Hour())
|
||||
continue
|
||||
}
|
||||
@@ -273,12 +289,16 @@ func (s *TidyService) catchUp(ctx context.Context) {
|
||||
// memory_tidy_runs 载入)与墙上时钟。满足任一条即应立刻跑:
|
||||
//
|
||||
// 1. 从未跑过 **且** 已在安静窗内 —— 「这个窗口还没跑过」的最简情形(全新库);
|
||||
// 2. 距上次 run 已 ≥ interval —— 周期到点,窗口不再是必要条件(补跑);
|
||||
// 2. 距上次 run 已 ≥ **当时时段**的周期 —— 周期到点,窗口不再是必要条件(补跑);
|
||||
// 3. 已在安静窗内 **且** 窗口起点晚于上次 run —— 这个窗口还没跑过。
|
||||
//
|
||||
// 三条都**不**看「进程活了多久」,因此重启多少次都不会饿死整理。反过来,窗口
|
||||
// 内跑过一次之后再重启,条件 2/3 都不成立(lastRun 在窗口起点之后),因此也
|
||||
// 不会每次重启都重跑一轮扫描。
|
||||
//
|
||||
// 判据 2 用 intervalAt(now) 而不是某一个固定周期:周期现在是**分档**的(安静时段
|
||||
// quietIntervalSec / 其余 intervalSec),用哪一档取决于**现在几点**——这与
|
||||
// nextDelay 用同一个函数,因此「下一拍的时间」与「到没到点」不可能各说各话。
|
||||
func (s *TidyService) dueNow() (bool, string) {
|
||||
now := s.now()
|
||||
s.mu.Lock()
|
||||
@@ -291,7 +311,7 @@ func (s *TidyService) dueNow() (bool, string) {
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
if now.Sub(last) >= s.cfg.Interval {
|
||||
if now.Sub(last) >= s.cfg.intervalAt(now) {
|
||||
return true, "interval_elapsed"
|
||||
}
|
||||
if s.cfg.inQuietHours(now) {
|
||||
@@ -304,22 +324,33 @@ func (s *TidyService) dueNow() (bool, string) {
|
||||
|
||||
// nextDelay 返回距下一次整理开跑的等待时长。
|
||||
//
|
||||
// 口径:下一次 = 下一个低峰窗口起点(窗口关闭时 = 现在 + 周期),并且不早于
|
||||
// 「上一次运行 + 周期」—— 让配置的周期成为真实下限,而不是让安静时段每天
|
||||
// 一次把「每周一次」冲掉。等待恒 ≥ minTidyTickDelay,避免配置异常时空转。
|
||||
// 口径(2026-10-07 动态频率修订):
|
||||
//
|
||||
// - **有历史**:下一次 = 「上一次 run + **当时时段**的周期」(intervalAt);
|
||||
// - **无历史**:锚到下一个安静时段起点(design/23 §4:首次整理落在低峰),窗口
|
||||
// 被关闭时退化为「现在 + 当时间隔」——与本次改动之前逐字一致;
|
||||
// - 等待恒 ≥ minTidyTickDelay,避免配置异常(周期 ≤ 1 分钟)时空转。
|
||||
//
|
||||
// 🔴 为什么在这里现算,而不是启动时算一次:
|
||||
// 周期是**分档**的(非安静时段 = intervalSec,安静时段 = quietIntervalSec),而
|
||||
// 每一轮跑完之后「现在落在哪一档」可能已经变了(例:06:50 跑完那轮落在安静档 ⇒
|
||||
// 下一拍被推到 10:50;而 10:50 之后应当换回白天的档)。把 intervalAt(now) 放在
|
||||
// **每一次「等」之前**求值,跨过时段边界后的第一拍就用新档,不必等重启。
|
||||
// 把它挪回启动时算一次 ⇒ 白天/晚上的切换不会生效(本切片有测试钉住这条)。
|
||||
func (s *TidyService) nextDelay() time.Duration {
|
||||
now := s.now()
|
||||
next := s.cfg.nextQuietStart(now)
|
||||
if s.cfg.quietWindowDisabled() {
|
||||
next = now.Add(s.cfg.Interval)
|
||||
}
|
||||
s.mu.Lock()
|
||||
last := s.lastRun
|
||||
s.mu.Unlock()
|
||||
if !last.IsZero() {
|
||||
if floor := last.Add(s.cfg.Interval); next.Before(floor) {
|
||||
next = floor
|
||||
|
||||
var next time.Time
|
||||
if last.IsZero() {
|
||||
next = s.cfg.nextQuietStart(now)
|
||||
if s.cfg.quietWindowDisabled() {
|
||||
next = now.Add(s.cfg.intervalAt(now))
|
||||
}
|
||||
} else {
|
||||
next = last.Add(s.cfg.intervalAt(now))
|
||||
}
|
||||
delay := next.Sub(now)
|
||||
if delay < minTidyTickDelay {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十键的
|
||||
// **当前生效值**拍成一份完整快照,交给 runtimeconfig.SeedMemoryTidy 落盘。
|
||||
//
|
||||
// 它回应主人 2026-10-07 的两条口径:
|
||||
//
|
||||
// - 「**`.env` 只做兜底用** —— 后端**自动创建**独立的面板配置文件之后就走面板
|
||||
// 配置文件,这些键优先从面板配置上取」⇒ 播种的时机与「此后以面板为准」;
|
||||
// - 「后端**自动创建**独立的面板配置文件**要做**」⇒ 这份快照就是「自动创建」的
|
||||
// 内容,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。
|
||||
//
|
||||
// 为什么放在 memory 而不是 runtimeconfig:runtimeconfig 只管**文件形状与读写**,
|
||||
// 「缺位/坏值怎么回落、默认值是多少」属于 TidyConfig / ReviewConfig 的归一化语义
|
||||
// (与 normalizeTidyConfig / normalizeReviewConfig 同一层)。
|
||||
//
|
||||
// 为什么快照取**归一化之后**的值:主人要的是「播种那一刻行为零变化」——把当时真正
|
||||
// 生效的值写成文件,读回来叠上去之后(面板这一档等于生效值本身)结果逐字相同。
|
||||
// 若快照取的是「未归一化的中间值」(比如四键还是 0),写下去的就是一份**比现状更空**
|
||||
// 的配置,只是侥幸等价;取归一化后的值,等价性才是结构性的(见
|
||||
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig)。
|
||||
//
|
||||
// 为什么只有一处实现:十键的默认值在 memory 侧各只有一处定义
|
||||
// (tidy_config.go / preinsert_config.go 的 default* 常量),快照直接复用那两个
|
||||
// normalize,**不另写一份默认值** —— 否则播种写下去的就是第二份「差不多」的默认值,
|
||||
// 会与运行期悄悄分叉。
|
||||
//
|
||||
// 十键与它们的生效来源:
|
||||
//
|
||||
// 键 生效来源(无面板文件时) 快照值
|
||||
// autoApply DisableAutoApply 的反向(代码默认开) true
|
||||
// autoApplyPerRound defaultTidyAutoApplyPerRound 50
|
||||
// ingestGate DisableIngestGate 的反向(默认开) true
|
||||
// t1Judge DisableT1 的反向(默认开) true
|
||||
// t1PerWindow defaultT1PerWindow 120
|
||||
// t1WindowSec defaultT1Window 3600
|
||||
// intervalSec TidyConfig.IntervalSec(env 或 86400) 归一化值
|
||||
// quietIntervalSec 缺省 = intervalSec 归一化值
|
||||
// quietStartHour TidyConfig.QuietStartHour(env 或 23) 归一化值
|
||||
// quietEndHour TidyConfig.QuietEndHour(env 或 7) 归一化值
|
||||
|
||||
// TidyTuningSnapshot 拍下十键的**当前生效值**,返回一份可直接落盘的面板快照。
|
||||
//
|
||||
// 入参是**已经叠过面板层的**两个配置(组合根的次序:TidyConfigFromConfig →
|
||||
// ApplyTidyScheduleTuning → 本函数;审查侧同理),本函数只做最后一步归一化并翻成
|
||||
// 文件形状 —— 它不读盘、不读 env、不认识面板文件:
|
||||
//
|
||||
// - tidy 侧四键(intervalSec / quietIntervalSec / quietStartHour / quietEndHour)
|
||||
// 与自动档两键(autoApply / autoApplyPerRound);
|
||||
// - review 侧四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec):
|
||||
// 这四项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件与代码默认决定,
|
||||
// 而播种只发生在**面板文件不存在**时 —— 那正是「零值 + normalizeReviewConfig」
|
||||
// 这一条路径,与运行期(wireMemoryReview 的三个 helper)逐字一致。
|
||||
//
|
||||
// 两个开关是**反向**字段(DisableX),快照要把它们翻回正向的 JSON 键:文件里的
|
||||
// `"autoApply": true` 才是「没配时到底开不开」的如实陈述。
|
||||
func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.MemoryTidyTuning {
|
||||
tidy = normalizeTidyConfig(tidy)
|
||||
review = normalizeReviewConfig(review)
|
||||
|
||||
autoApply := !tidy.DisableAutoApply
|
||||
ingestGate := !review.DisableIngestGate
|
||||
t1Judge := !review.DisableT1
|
||||
intervalSec := tidy.IntervalSec
|
||||
quietIntervalSec := tidy.QuietIntervalSec
|
||||
quietStartHour := tidy.QuietStartHour
|
||||
quietEndHour := tidy.QuietEndHour
|
||||
|
||||
return runtimeconfig.MemoryTidyTuning{
|
||||
AutoApply: &autoApply,
|
||||
AutoApplyPerRound: tidy.AutoApplyPerRound,
|
||||
IngestGate: &ingestGate,
|
||||
T1Judge: &t1Judge,
|
||||
T1PerWindow: review.T1PerWindow,
|
||||
T1WindowSec: int(review.T1Window.Seconds()),
|
||||
IntervalSec: &intervalSec,
|
||||
QuietIntervalSec: &quietIntervalSec,
|
||||
QuietStartHour: &quietStartHour,
|
||||
QuietEndHour: &quietEndHour,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件锁定**启动播种的值来源**(tidy_snapshot.go;主人 2026-10-07:「`.env` 只做
|
||||
// 兜底用 —— 后端**自动创建**独立的面板配置文件**要做**」):
|
||||
//
|
||||
// ① 快照十键齐全、值可用(否则 runtimeconfig.SeedMemoryTidy 会拒绝落盘);
|
||||
// ② 值取「**当时解析出来的生效值**」—— `.env` 给了就用 `.env`,没给就用代码默认
|
||||
// (默认值来自 normalizeTidyConfig / normalizeReviewConfig,不另写一份);
|
||||
// ③ 最关键的一条:把快照当成面板值叠回去,**归一化结果与「没有面板文件」逐字相同**
|
||||
// —— 这就是「播种那一刻行为零变化,之后改文件才生效」。
|
||||
|
||||
// snapBoolPtr / snapIntPtr 造「快照里显式写了这个键」的指针。
|
||||
// (不复用 tidy_tuning_test.go 的 intPtr:那是给**面板**用例造的,两者语义不同,
|
||||
// 分开命名便于读。)
|
||||
func snapBoolPtr(v bool) *bool { return &v }
|
||||
|
||||
func snapIntPtr(v int) *int { return &v }
|
||||
|
||||
// TestTidyTuningSnapshot_CodeDefaults 钉住「什么都没配」时的十键 = 归一化的代码默认值。
|
||||
// 期望值直接取自默认值常量(本包唯一那处定义),因此这条用例证明的是**接线**:
|
||||
// 快照确实走了 normalizeTidyConfig / normalizeReviewConfig,而不是另写一份默认值。
|
||||
func TestTidyTuningSnapshot_CodeDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
snap := TidyTuningSnapshot(TidyConfig{}, ReviewConfig{})
|
||||
|
||||
if snap.AutoApply == nil || !*snap.AutoApply {
|
||||
t.Fatalf("autoApply = %v, want 显式的 true(代码默认开)", snap.AutoApply)
|
||||
}
|
||||
if snap.IngestGate == nil || !*snap.IngestGate {
|
||||
t.Fatalf("ingestGate = %v, want 显式的 true(代码默认开)", snap.IngestGate)
|
||||
}
|
||||
if snap.T1Judge == nil || !*snap.T1Judge {
|
||||
t.Fatalf("t1Judge = %v, want 显式的 true(代码默认开)", snap.T1Judge)
|
||||
}
|
||||
if snap.AutoApplyPerRound != defaultTidyAutoApplyPerRound {
|
||||
t.Fatalf("autoApplyPerRound = %d, want %d", snap.AutoApplyPerRound, defaultTidyAutoApplyPerRound)
|
||||
}
|
||||
if snap.T1PerWindow != defaultT1PerWindow || snap.T1WindowSec != int(defaultT1Window.Seconds()) {
|
||||
t.Fatalf("T1 限速 = %d/%d, want %d/%d", snap.T1PerWindow, snap.T1WindowSec,
|
||||
defaultT1PerWindow, int(defaultT1Window.Seconds()))
|
||||
}
|
||||
if snap.IntervalSec == nil || *snap.IntervalSec != defaultTidyIntervalSec {
|
||||
t.Fatalf("intervalSec = %v, want %d", snap.IntervalSec, defaultTidyIntervalSec)
|
||||
}
|
||||
// quietIntervalSec 缺省 = intervalSec(normalizeTidyConfig 的既有口径:不因时段换档)。
|
||||
if snap.QuietIntervalSec == nil || *snap.QuietIntervalSec != defaultTidyIntervalSec {
|
||||
t.Fatalf("quietIntervalSec = %v, want = intervalSec (%d)", snap.QuietIntervalSec, defaultTidyIntervalSec)
|
||||
}
|
||||
if snap.QuietStartHour == nil || *snap.QuietStartHour != defaultTidyQuietStartHour ||
|
||||
snap.QuietEndHour == nil || *snap.QuietEndHour != defaultTidyQuietEndHour {
|
||||
t.Fatalf("安静时段 = %v-%v, want %d-%d", snap.QuietStartHour, snap.QuietEndHour,
|
||||
defaultTidyQuietStartHour, defaultTidyQuietEndHour)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyTuningSnapshot_TakesEnvLayer 钉住「`.env` 给的就用 `.env`」:四键的 env 档
|
||||
// (MEMORY_TIDY_INTERVAL_SEC / MEMORY_TIDY_QUIET_*_HOUR)必须原样进快照,否则播种写
|
||||
// 下去的就是代码默认值,等于把主人配的 `.env` 静默抹平。
|
||||
func TestTidyTuningSnapshot_TakesEnvLayer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 22, QuietEndHour: 6}
|
||||
snap := TidyTuningSnapshot(TidyConfigFromConfig(env), ReviewConfig{})
|
||||
|
||||
if snap.IntervalSec == nil || *snap.IntervalSec != 7200 {
|
||||
t.Fatalf("intervalSec = %v, want 7200(取 `.env`)", snap.IntervalSec)
|
||||
}
|
||||
// env 只给了 intervalSec,quietIntervalSec 没有 env 键 ⇒ 缺省 = intervalSec。
|
||||
if snap.QuietIntervalSec == nil || *snap.QuietIntervalSec != 7200 {
|
||||
t.Fatalf("quietIntervalSec = %v, want 7200(缺省 = intervalSec)", snap.QuietIntervalSec)
|
||||
}
|
||||
if snap.QuietStartHour == nil || *snap.QuietStartHour != 22 ||
|
||||
snap.QuietEndHour == nil || *snap.QuietEndHour != 6 {
|
||||
t.Fatalf("安静时段 = %v-%v, want 22-6(取 `.env`)", snap.QuietStartHour, snap.QuietEndHour)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyTuningSnapshot_KeepsExplicitZeroHour 钉住钟点的零值口径:**0 是合法钟点**
|
||||
// (0-7 的凌晨窗口)。若快照把「0 起」当成「没配」而写成 23,播种就篡改了主人的窗口。
|
||||
//
|
||||
// 注意「两端都是 0」是**另一回事**:那是 normalize 认的「都没配」(回落到 23-07),
|
||||
// 这与显式写 0-5 的凌晨窗口必须可区分。
|
||||
func TestTidyTuningSnapshot_KeepsExplicitZeroHour(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := config.MemoryTidyConfig{QuietStartHour: 0, QuietEndHour: 5}
|
||||
snap := TidyTuningSnapshot(TidyConfigFromConfig(env), ReviewConfig{})
|
||||
if snap.QuietStartHour == nil || *snap.QuietStartHour != 0 || *snap.QuietEndHour != 5 {
|
||||
t.Fatalf("安静时段 = %v-%v, want 0-5(显式 0 是合法钟点)", snap.QuietStartHour, snap.QuietEndHour)
|
||||
}
|
||||
|
||||
// 两端都是 0 = 「都没配」⇒ 归一成代码默认 23-07(既有口径,快照如实转述)。
|
||||
both := TidyTuningSnapshot(TidyConfigFromConfig(config.MemoryTidyConfig{}), ReviewConfig{})
|
||||
if *both.QuietStartHour != defaultTidyQuietStartHour || *both.QuietEndHour != defaultTidyQuietEndHour {
|
||||
t.Fatalf("两端 0 = 都没配 ⇒ 应归一成 %d-%d,得到 %v-%v",
|
||||
defaultTidyQuietStartHour, defaultTidyQuietEndHour, *both.QuietStartHour, *both.QuietEndHour)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig 是**播种的全部意义**所在:
|
||||
// 把快照当成面板值叠回去之后,整理与审查两侧归一化出来的配置必须与「面板文件不存在」
|
||||
// 逐字相同 —— 于是播种那一刻行为零变化,之后改文件才生效。
|
||||
//
|
||||
// 两侧各按**运行期真实的叠加路径**走:
|
||||
// - 整理侧 = 组合根那三步(TidyConfigFromConfig → ApplyTidyScheduleTuning →
|
||||
// normalizeTidyConfig,即 resolveTidySchedule);
|
||||
// - 审查侧 = wireMemoryReview 的写法(开关取 IngestGateOff/T1JudgeOff,数值取
|
||||
// t1PerWindow/t1WindowSec,再交给 normalizeReviewConfig)。
|
||||
func TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
envs := []struct {
|
||||
name string
|
||||
env config.MemoryTidyConfig
|
||||
}{
|
||||
{"`.env` 什么都没配", config.MemoryTidyConfig{}},
|
||||
{"`.env` 配了频率与窗口", config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 1, QuietEndHour: 5}},
|
||||
{"`.env` 的窗口从 0 点起(凌晨档)", config.MemoryTidyConfig{IntervalSec: 10800, QuietStartHour: 0, QuietEndHour: 6}},
|
||||
{"`.env` 把窗口关掉(两端相等且非 0)", config.MemoryTidyConfig{IntervalSec: 3600, QuietStartHour: 5, QuietEndHour: 5}},
|
||||
{"`.env` 只配了周期", config.MemoryTidyConfig{IntervalSec: 1800}},
|
||||
}
|
||||
for _, tc := range envs {
|
||||
log, _ := captureTidyLog(t)
|
||||
|
||||
// ① 没播种前:面板缺失 ⇒ 零值 tuning(= 当前生产里 data/config 下没这个文件的形态)。
|
||||
before := resolveTidySchedule(tc.env, runtimeconfig.MemoryTidyTuning{}, log)
|
||||
// ② 快照(这就是要写进面板文件的内容)。
|
||||
snap := TidyTuningSnapshot(TidyConfigFromConfig(tc.env), ReviewConfig{})
|
||||
// ③ 播种之后:面板文件就是那份快照,逐键叠回来。
|
||||
after := resolveTidySchedule(tc.env, snap, log)
|
||||
if before != after {
|
||||
t.Fatalf("%s: 播种改变了生效配置:\n before=%+v\n after =%+v", tc.name, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// 审查侧同理(闸/T1 四键在 `.env` 里没有出处,只由面板与代码默认决定)。
|
||||
reviewBefore := normalizeReviewConfig(ReviewConfig{})
|
||||
snap := TidyTuningSnapshot(TidyConfig{}, ReviewConfig{})
|
||||
reviewAfter := normalizeReviewConfig(ReviewConfig{
|
||||
DisableIngestGate: snap.IngestGateOff(),
|
||||
DisableT1: snap.T1JudgeOff(),
|
||||
T1PerWindow: snap.T1PerWindow,
|
||||
T1Window: time.Duration(snap.T1WindowSec) * time.Second,
|
||||
})
|
||||
if reviewBefore != reviewAfter {
|
||||
t.Fatalf("播种改变了审查的生效配置:\n before=%+v\n after =%+v", reviewBefore, reviewAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyTuningSnapshot_IsCompleteAndPositive 钉住快照契约:十键非 nil、数值键为正、
|
||||
// 钟点在 0..23。runtimeconfig.SeedMemoryTidy 会按这条契约拒收残缺种子,因此这里先
|
||||
// 在**产生方**把它钉住,免得「播种写不出去」要到启动日志里才发现。
|
||||
func TestTidyTuningSnapshot_IsCompleteAndPositive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
snap := TidyTuningSnapshot(TidyConfigFromConfig(config.MemoryTidyConfig{}), ReviewConfig{})
|
||||
pointers := map[string]*int{
|
||||
"intervalSec": snap.IntervalSec,
|
||||
"quietIntervalSec": snap.QuietIntervalSec,
|
||||
"quietStartHour": snap.QuietStartHour,
|
||||
"quietEndHour": snap.QuietEndHour,
|
||||
}
|
||||
for key, p := range pointers {
|
||||
if p == nil {
|
||||
t.Fatalf("%s 是 nil(缺键的种子会被拒收)", key)
|
||||
}
|
||||
}
|
||||
if snap.AutoApply == nil || snap.IngestGate == nil || snap.T1Judge == nil {
|
||||
t.Fatalf("开关指针不得为 nil:%+v", snap)
|
||||
}
|
||||
for key, v := range map[string]int{
|
||||
"autoApplyPerRound": snap.AutoApplyPerRound,
|
||||
"t1PerWindow": snap.T1PerWindow,
|
||||
"t1WindowSec": snap.T1WindowSec,
|
||||
"intervalSec": *snap.IntervalSec,
|
||||
"quietIntervalSec": *snap.QuietIntervalSec,
|
||||
} {
|
||||
if v <= 0 {
|
||||
t.Fatalf("%s = %d, want > 0(非正周期会被 runtimeconfig 拒收)", key, v)
|
||||
}
|
||||
}
|
||||
for key, v := range map[string]int{"quietStartHour": *snap.QuietStartHour, "quietEndHour": *snap.QuietEndHour} {
|
||||
if v < 0 || v > 23 {
|
||||
t.Fatalf("%s = %d, want 0..23", key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,13 @@ const tidyStatusActive = "active"
|
||||
// 最新在前;rowid 打破 created_at 并列,使同一刻写入的条目顺序稳定
|
||||
// (与 ListByPersona 同一口径)。分区过滤写在 SQL 里,而不是读全表再在内存
|
||||
// 里筛 —— 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,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// 本文件是**整理频率**的解析点:把 `$DATA_DIR/config/memory-tidy.json` 的四个调度
|
||||
// 旋钮叠到 env 侧的 TidyConfig 上,实现主人 2026-10-07 的两条口径:
|
||||
//
|
||||
// - 「**`.env` 只做兜底用** —— 后端自动创建独立的面板配置文件之后就走面板配置
|
||||
// 文件,这些键优先从面板配置上取」⇒ 逐键 **面板 > `.env` > 代码默认**;
|
||||
// - 「**频率必须调**,可以调到 1 小时或者三小时,**或者动态分配**」⇒ 周期分两档
|
||||
// (非安静时段的 intervalSec / 安静时段的 quietIntervalSec,见 intervalAt)。
|
||||
//
|
||||
// 为什么解析放在 memory 而不是 runtimeconfig:runtimeconfig 只负责**文件形状与
|
||||
// 读取**(它的头注释把这条边界写死了),而「缺位/坏值怎么回落」属于 TidyConfig 的
|
||||
// 归一化语义 —— 与 normalizeTidyConfig 同一层,因此两者可以一起被单测覆盖。
|
||||
//
|
||||
// 为什么不是「读盘时逐键容错」:整份文件解不开(含某键类型写错)时**四个键一起**
|
||||
// 回落 `.env` 并记 WARN,这是本文件家族既有的粒度(见
|
||||
// runtimeconfig.LoadMemoryTidyTuning 与它的用例③);本文件处理的是**类型正确但值
|
||||
// 不可用**(非正数)那一档。
|
||||
|
||||
// ApplyTidyScheduleTuning 把面板调度旋钮叠到 env 侧的 TidyConfig 上,返回叠加后的
|
||||
// 配置(**未归一化**:调用方随后交给 NewTidier / normalizeTidyConfig 填代码默认值,
|
||||
// 与组合根的既有顺序一致)。
|
||||
//
|
||||
// 逐键独立,三档优先级在四个键上各自成立:
|
||||
//
|
||||
// 键 面板(memory-tidy.json) 下一档(.env / 派生) 代码默认
|
||||
// intervalSec intervalSec MEMORY_TIDY_INTERVAL_SEC 86400
|
||||
// quietIntervalSec quietIntervalSec 派生:= intervalSec 同左
|
||||
// quietStartHour quietStartHour MEMORY_TIDY_QUIET_START_HOUR 23
|
||||
// quietEndHour quietEndHour MEMORY_TIDY_QUIET_END_HOUR 7
|
||||
//
|
||||
// 「面板缺位 ⇒ 用 `.env`」这一步在实现上是**不覆盖**:cfg 进函数时已经带着 env 的
|
||||
// 值(TidyConfigFromConfig),面板没写就原样留着;「`.env` 也缺位 ⇒ 代码默认」由
|
||||
// 随后的 normalizeTidyConfig 收尾(env 的 default 标签在 config.Load 那一层已经
|
||||
// 兜过一道,两道都缺时落到 defaultTidyIntervalSec / 23-07)。
|
||||
//
|
||||
// 坏值(非正数的秒数)**绝不写进 cfg**:回落下一档 + 记一条 WARN。一个 ≤0 的周期会
|
||||
// 让心跳退化成空转,因此它不是「没配」而是坏配置 —— 但也不许因此让启动失败。
|
||||
func ApplyTidyScheduleTuning(cfg TidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
|
||||
log = logger.WithSource(baseTidyLog(log), "memory/tidy")
|
||||
|
||||
if sec := panelPositiveSec(panel.IntervalSec, "intervalSec", log); sec > 0 {
|
||||
cfg.IntervalSec = sec
|
||||
// Interval 与 IntervalSec 同时归零:两者是「同一旋钮的两种写法」
|
||||
// (设置了 Interval 时忽略 IntervalSec),只改一个会让旧值报复性生效。
|
||||
cfg.Interval = 0
|
||||
}
|
||||
if sec := panelPositiveSec(panel.QuietIntervalSec, "quietIntervalSec", log); sec > 0 {
|
||||
cfg.QuietIntervalSec = sec
|
||||
cfg.QuietInterval = 0
|
||||
}
|
||||
// 钟点用「指针是否有值」而不是符号判据:**0 是合法钟点**(0-7 的凌晨窗口、
|
||||
// 23-0 到午夜的窗口),因此显式写的 0 必须原样生效,缺位才回落 env。
|
||||
if panel.QuietStartHour != nil {
|
||||
cfg.QuietStartHour = *panel.QuietStartHour
|
||||
}
|
||||
if panel.QuietEndHour != nil {
|
||||
cfg.QuietEndHour = *panel.QuietEndHour
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// panelPositiveSec 解析一个**正数秒数**旋钮,返回 0 表示「不覆盖」(调用方原样留着
|
||||
// env 值)。三种情形分得很清,因为它们该有不同的观测形状:
|
||||
//
|
||||
// - `nil`(键没写)⇒ 0,**静默**:没配不是错,正是「`.env` 兜底」这一档;
|
||||
// - 非正数(写了 0 或负数)⇒ 0 + **WARN**:它会被读成「一直跑」而不是「不跑」,
|
||||
// 属于坏配置,必须留下痕迹(否则主人以为配了 0 就等于关了,实际相反);
|
||||
// - 正数 ⇒ 原值。
|
||||
func panelPositiveSec(v *int, key string, log *slog.Logger) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
if *v <= 0 {
|
||||
log.Warn("memory-tidy.json 的调度旋钮值不可用,回落 .env(非正周期会让心跳空转)",
|
||||
"key", key, "value", *v)
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件锁定**整理频率**的解析链(主人 2026-10-07 两条口径):
|
||||
//
|
||||
// ①「`.env` 只做兜底用 …… 这些键优先从面板配置上取」⇒ 逐键 **面板 > `.env` > 代码默认**;
|
||||
// ②「频率必须调 …… 或者动态分配」⇒ 周期分两档(安静/非安静,见 tidy_config.go 的
|
||||
// intervalAt),quietIntervalSec 缺省 = intervalSec。
|
||||
//
|
||||
// 解析点只有一处:ApplyTidyScheduleTuning。用例走的是组合根的次序
|
||||
// (TidyConfigFromConfig → ApplyTidyScheduleTuning → normalizeTidyConfig),
|
||||
// 因此「环境变量缺位也缺位时落到代码默认」这一档也是真跑出来的,不是声称。
|
||||
|
||||
// resolveTidySchedule 按组合根的次序解析一次调度旋钮。
|
||||
func resolveTidySchedule(env config.MemoryTidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
|
||||
return normalizeTidyConfig(ApplyTidyScheduleTuning(TidyConfigFromConfig(env), panel, log))
|
||||
}
|
||||
|
||||
// captureTidyLog 收集一次解析期间的日志,供「坏值必须留痕」的断言使用。
|
||||
func captureTidyLog(t *testing.T) (*slog.Logger, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
return slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})), &buf
|
||||
}
|
||||
|
||||
// intPtr 造一个「面板里显式写了这个键」的指针。
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
// TestApplyTidyScheduleTuning_PriorityChain 是**优先级链**的三档用例:面板有值 ⇒
|
||||
// 用面板;面板缺位 ⇒ 用 `.env`;两档都缺位 ⇒ 用代码默认。逐键独立。
|
||||
func TestApplyTidyScheduleTuning_PriorityChain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// env 层:与面板值**故意对立**,这样「谁生效」是能一眼看出来的。
|
||||
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
|
||||
panel := runtimeconfig.MemoryTidyTuning{
|
||||
IntervalSec: intPtr(3600),
|
||||
QuietIntervalSec: intPtr(10800),
|
||||
QuietStartHour: intPtr(1),
|
||||
QuietEndHour: intPtr(5),
|
||||
}
|
||||
|
||||
t.Run("面板有值 ⇒ 四个键都用面板值", func(t *testing.T) {
|
||||
log, buf := captureTidyLog(t)
|
||||
got := resolveTidySchedule(env, panel, log)
|
||||
if got.IntervalSec != 3600 || got.Interval != time.Hour {
|
||||
t.Fatalf("intervalSec = %d / %s, want 3600 / 1h", got.IntervalSec, got.Interval)
|
||||
}
|
||||
if got.QuietIntervalSec != 10800 || got.QuietInterval != 3*time.Hour {
|
||||
t.Fatalf("quietIntervalSec = %d / %s, want 10800 / 3h", got.QuietIntervalSec, got.QuietInterval)
|
||||
}
|
||||
if got.QuietStartHour != 1 || got.QuietEndHour != 5 {
|
||||
t.Fatalf("安静时段 = %02d:00-%02d:00, want 01:00-05:00", got.QuietStartHour, got.QuietEndHour)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("正常配置不该有任何 WARN:%s", buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("面板缺位 ⇒ 用 .env", func(t *testing.T) {
|
||||
log, buf := captureTidyLog(t)
|
||||
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{}, log)
|
||||
if got.IntervalSec != 7200 || got.Interval != 2*time.Hour {
|
||||
t.Fatalf("intervalSec = %d / %s, want 7200 / 2h(env 兜底)", got.IntervalSec, got.Interval)
|
||||
}
|
||||
if got.QuietStartHour != 23 || got.QuietEndHour != 7 {
|
||||
t.Fatalf("安静时段 = %02d:00-%02d:00, want 23:00-07:00(env 兜底)",
|
||||
got.QuietStartHour, got.QuietEndHour)
|
||||
}
|
||||
// quietIntervalSec 没有 env 键 ⇒ 缺省等于 intervalSec(不是「夜里不跑」)。
|
||||
if got.QuietIntervalSec != 7200 || got.QuietInterval != 2*time.Hour {
|
||||
t.Fatalf("quietIntervalSec = %d / %s, want = intervalSec(缺省 = 不因时段换档)",
|
||||
got.QuietIntervalSec, got.QuietInterval)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("键没写不是错,不该有 WARN:%s", buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("两档都缺位 ⇒ 用代码默认", func(t *testing.T) {
|
||||
log, buf := captureTidyLog(t)
|
||||
got := resolveTidySchedule(config.MemoryTidyConfig{}, runtimeconfig.MemoryTidyTuning{}, log)
|
||||
if got.IntervalSec != defaultTidyIntervalSec || got.Interval != time.Duration(defaultTidyIntervalSec)*time.Second {
|
||||
t.Fatalf("intervalSec = %d / %s, want %d / 24h",
|
||||
got.IntervalSec, got.Interval, defaultTidyIntervalSec)
|
||||
}
|
||||
if got.QuietIntervalSec != defaultTidyIntervalSec {
|
||||
t.Fatalf("quietIntervalSec = %d, want = intervalSec 的代码默认", got.QuietIntervalSec)
|
||||
}
|
||||
if got.QuietStartHour != defaultTidyQuietStartHour || got.QuietEndHour != defaultTidyQuietEndHour {
|
||||
t.Fatalf("安静时段 = %02d:00-%02d:00, want 代码默认 %02d:00-%02d:00",
|
||||
got.QuietStartHour, got.QuietEndHour, defaultTidyQuietStartHour, defaultTidyQuietEndHour)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("没配不是错,不该有 WARN:%s", buf.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("面板只写一档 ⇒ 另一档仍走 env(逐键独立)", func(t *testing.T) {
|
||||
log, _ := captureTidyLog(t)
|
||||
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{
|
||||
QuietIntervalSec: intPtr(10800),
|
||||
QuietEndHour: intPtr(5),
|
||||
}, log)
|
||||
if got.IntervalSec != 7200 {
|
||||
t.Fatalf("intervalSec = %d, want 7200(面板没写这一项)", got.IntervalSec)
|
||||
}
|
||||
if got.QuietIntervalSec != 10800 {
|
||||
t.Fatalf("quietIntervalSec = %d, want 10800(面板写了这一项)", got.QuietIntervalSec)
|
||||
}
|
||||
if got.QuietStartHour != 23 || got.QuietEndHour != 5 {
|
||||
t.Fatalf("安静时段 = %02d:00-%02d:00, want 23:00-05:00(一端面板、一端 env)",
|
||||
got.QuietStartHour, got.QuietEndHour)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("面板写了 intervalSec 但没写 quietIntervalSec ⇒ 缺省跟随**面板**值", func(t *testing.T) {
|
||||
log, _ := captureTidyLog(t)
|
||||
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(3600)}, log)
|
||||
if got.IntervalSec != 3600 || got.QuietIntervalSec != 3600 {
|
||||
t.Fatalf("intervalSec = %d, quietIntervalSec = %d, want 两者都是 3600",
|
||||
got.IntervalSec, got.QuietIntervalSec)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("钟点显式写 0 ⇒ 生效(0 是合法钟点)", func(t *testing.T) {
|
||||
log, _ := captureTidyLog(t)
|
||||
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{QuietStartHour: intPtr(0)}, log)
|
||||
if got.QuietStartHour != 0 || got.QuietEndHour != 7 {
|
||||
t.Fatalf("安静时段 = %02d:00-%02d:00, want 00:00-07:00", got.QuietStartHour, got.QuietEndHour)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("两端相等且非 0 ⇒ 关闭窗口(语义未变)", func(t *testing.T) {
|
||||
log, _ := captureTidyLog(t)
|
||||
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{
|
||||
QuietStartHour: intPtr(5), QuietEndHour: intPtr(5),
|
||||
}, log)
|
||||
if !got.quietWindowDisabled() {
|
||||
t.Fatalf("5/5 应当被判定为「窗口已关闭」,得到 %02d:00-%02d:00",
|
||||
got.QuietStartHour, got.QuietEndHour)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestApplyTidyScheduleTuning_BadValuesFallBackWithWarn 钉住「坏配置不得导致启动
|
||||
// 失败」:非正数的秒数是**坏值**(不是「没配」),一律回落下一档 + 记一条 WARN ——
|
||||
// 它会被读成「一直跑」而不是「不跑」,静默回落会让主人以为配了 0 就等于关了。
|
||||
func TestApplyTidyScheduleTuning_BadValuesFallBackWithWarn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 23, QuietEndHour: 7}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
panel runtimeconfig.MemoryTidyTuning
|
||||
key string
|
||||
}{
|
||||
{"intervalSec 写成 0", runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(0)}, "intervalSec"},
|
||||
{"intervalSec 写成负数", runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(-60)}, "intervalSec"},
|
||||
{"quietIntervalSec 写成 0", runtimeconfig.MemoryTidyTuning{QuietIntervalSec: intPtr(0)}, "quietIntervalSec"},
|
||||
{"quietIntervalSec 写成负数", runtimeconfig.MemoryTidyTuning{QuietIntervalSec: intPtr(-1)}, "quietIntervalSec"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
log, buf := captureTidyLog(t)
|
||||
got := resolveTidySchedule(env, tc.panel, log)
|
||||
if got.IntervalSec != 7200 {
|
||||
t.Fatalf("intervalSec = %d, want 回落 env 的 7200", got.IntervalSec)
|
||||
}
|
||||
if got.QuietIntervalSec != 7200 {
|
||||
t.Fatalf("quietIntervalSec = %d, want 回落 7200", got.QuietIntervalSec)
|
||||
}
|
||||
logs := buf.String()
|
||||
if !strings.Contains(logs, tc.key) || !strings.Contains(logs, "level=WARN") {
|
||||
t.Fatalf("坏值必须留痕(含键名 %q 的 WARN),日志 = %q", tc.key, logs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 坏值**不得**让整理停摆:解析出的周期必须是正数,且归一化后一切照旧。
|
||||
log, _ := captureTidyLog(t)
|
||||
got := resolveTidySchedule(env, runtimeconfig.MemoryTidyTuning{IntervalSec: intPtr(0)}, log)
|
||||
if got.Interval <= 0 || got.QuietInterval <= 0 {
|
||||
t.Fatalf("坏值之后的周期必须仍为正:interval=%s quiet=%s", got.Interval, got.QuietInterval)
|
||||
}
|
||||
if got.IntervalSec != env.IntervalSec {
|
||||
t.Fatalf("坏值应回落 env 值 %d,得到 %d", env.IntervalSec, got.IntervalSec)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyTidyScheduleTuning_NilLoggerIsSafe:解析点可能被没有日志器的调用方
|
||||
// 直接使用(单测、将来的面板校验),nil 日志器不得 panic。
|
||||
func TestApplyTidyScheduleTuning_NilLoggerIsSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := ApplyTidyScheduleTuning(TidyConfig{}, runtimeconfig.MemoryTidyTuning{
|
||||
IntervalSec: intPtr(0), QuietIntervalSec: intPtr(-1),
|
||||
}, nil)
|
||||
if got.IntervalSec != 0 {
|
||||
t.Fatalf("坏值不该写进 cfg,得到 %d", got.IntervalSec)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 时桥接器支持热重载;
|
||||
// 为 nil 时桥接器仅支持启动期装配(Reload 返回 ErrNilFactory)。
|
||||
Factory PlatformFactory
|
||||
// Channels 是能力注册表的窄端口(见 ChannelRegistry):热重载把新适配器写回
|
||||
// 同名条目,使管理面板读到的永远是当前实例。为 nil 时热重载照常,只是面板
|
||||
// 仍读旧实例(首次同步记一次 WARN)。
|
||||
Channels ChannelRegistry
|
||||
// ChannelPersona 非 nil 时解析某个渠道账号绑定的角色
|
||||
// (channels.json 的 ChannelAccount.PersonaID,design/22 §2 方案 b):调用时传入
|
||||
// 平台与本地账号 id,账号未绑定角色时返回 ""。
|
||||
@@ -253,6 +257,10 @@ type Bridge struct {
|
||||
started bool
|
||||
closed bool
|
||||
factory PlatformFactory
|
||||
// channels 是热重载同步能力注册表的窄端口(reload.go 的 replaceChannelEntry /
|
||||
// dropChannelEntry);为 nil 时面板读到的是保存前的旧实例,首次同步记一次 WARN。
|
||||
channels ChannelRegistry
|
||||
channelsWarnOnce sync.Once
|
||||
// channelPersona 从生效配置解析账号的渠道级角色
|
||||
// (design/22 §2 方案 b);adapterPersona 按已连接的适配器缓存解析结果,
|
||||
// 使入站路径永不重读 channels.json。二者
|
||||
@@ -353,6 +361,7 @@ func New(opts Options) (*Bridge, error) {
|
||||
readCancels: make(map[string]context.CancelFunc),
|
||||
gates: make(map[string]*adapter.FlowGate),
|
||||
factory: opts.Factory,
|
||||
channels: opts.Channels,
|
||||
channelPersona: opts.ChannelPersona,
|
||||
adapterPersona: make(map[string]string),
|
||||
documentsCfg: opts.DocumentStore,
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// PlatformFactory 依据平台当前生效的账号配置
|
||||
@@ -27,6 +28,20 @@ type PlatformFactory interface {
|
||||
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 表示请求重载时未注入平台工厂
|
||||
// (没有热重载配置来源)。
|
||||
var ErrNilFactory = errors.New("channelbridge: nil platform factory")
|
||||
@@ -112,16 +127,19 @@ func (b *Bridge) Reload(platform string) error {
|
||||
}
|
||||
for _, a := range newAdapters {
|
||||
b.connectAndRun(a)
|
||||
// 紧跟上线之后同步能力注册表:新实例先连上,面板才改为读它,
|
||||
// 避免「注册表已指向新实例、它却还没连」的窗口。
|
||||
b.replaceChannelEntry(a)
|
||||
}
|
||||
for _, o := range old {
|
||||
if newNames[o.Name()] {
|
||||
// 同名账号被重建后的适配器替换:停止旧的读取
|
||||
// 循环,但保留注册表条目——它现在指向新适配器。
|
||||
// 同名账号被重建后的适配器替换:只停旧实例的读取循环,
|
||||
// 注册表条目已由 replaceChannelEntry 指向新实例。
|
||||
b.cancelAndDisconnect(o, oldCancels[o.Name()])
|
||||
continue
|
||||
}
|
||||
// 账号被彻底移除(或更名):停止它并删除其注册表
|
||||
// 条目,后续 snapshot 便不再看到它。
|
||||
// 条目,后续 snapshot 与面板便不再看到它。
|
||||
b.removeAdapter(o, oldCancels[o.Name()])
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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 停止适配器的读取循环并拆除其会话。
|
||||
// 它绝不改动注册表 map:替换同名适配器的调用方
|
||||
// 希望(新)条目保留,而移除账号的调用方使用
|
||||
// removeAdapter。
|
||||
// 它绝不改动桥接器自己的适配器表与能力注册表:替换同名适配器
|
||||
// 的调用方希望(新)条目保留,而移除账号的调用方使用
|
||||
// removeAdapter(它会连注册表条目一起清掉)。
|
||||
func (b *Bridge) cancelAndDisconnect(o adapter.PlatformAdapter, c context.CancelFunc) {
|
||||
if c != nil {
|
||||
c()
|
||||
@@ -174,5 +237,6 @@ func (b *Bridge) removeAdapter(o adapter.PlatformAdapter, c context.CancelFunc)
|
||||
delete(b.readCancels, name)
|
||||
b.mu.Unlock()
|
||||
b.forgetAdapterPersona(name)
|
||||
b.dropChannelEntry(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)
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,19 @@ type PluginSummary struct {
|
||||
// NeedsRescan/LastError(v0.39)报告适配器需要主人重新授权
|
||||
// (见 admin_channels_rescan.go 中的 rescanAware);二者仅在适配器
|
||||
// 确实要求时才出现,否则一律省略。
|
||||
//
|
||||
// Pending/Ready/Reason(v0.68)报告 `connected=false` 时**到底是哪种没连上**
|
||||
// (见 admin_channel_connect_outcome.go 中的 connectOutcomeAware):pending =
|
||||
// 已在后台重试、当前未连上(面板读作「连接中」),ready = 已就绪、等待对端接入。
|
||||
// 三者同样只在适配器确实自报时才出现 —— 没有这条状态面的平台逐字节不受影响。
|
||||
type ChannelSummary struct {
|
||||
Name string `json:"name"`
|
||||
Connected bool `json:"connected"`
|
||||
NeedsRescan bool `json:"needsRescan,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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
// 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 里出现过的
|
||||
// 账号调用 set(缺席即字段保持 nil = 未知),因此调用方不必各自重写这段三态逻辑 ——
|
||||
// 三态口径只写一遍,谁也别想在其中一处悄悄把「未知」写成 false。
|
||||
|
||||
@@ -135,11 +135,38 @@ func (s *Server) firstConfiguredChannel(name string) (sdk.Channel, bool) {
|
||||
// channelActionResult 是 open/close 的响应体:name 保持请求里的名字(老客户端
|
||||
// 契约不变),resolved 是**实际被驱动**的适配器注册名(多账号平台上它与请求的
|
||||
// 平台名不同,面板据此显示「连的是哪一条」),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}
|
||||
if 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
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
botByPlatform := s.liveBotIdentity()
|
||||
connectedByPlatform := s.liveAccountConnections()
|
||||
onlineByPlatform := s.liveAccountOnline()
|
||||
outcomeByPlatform := s.liveAccountConnectOutcomes()
|
||||
out := make([]ChannelSummary, 0)
|
||||
if s.caps != nil {
|
||||
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) {
|
||||
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):仅在适配器
|
||||
// 确实要求重新扫码时才报告,因此健康的适配器保持字段省略,
|
||||
// 而不是送出一个编造的值。
|
||||
@@ -98,9 +109,12 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 {
|
||||
sum.Accounts = enrichAccountsWithAccountOnline(
|
||||
enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]),
|
||||
onlineByPlatform[platform],
|
||||
sum.Accounts = enrichAccountsWithConnectOutcome(
|
||||
enrichAccountsWithAccountOnline(
|
||||
enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]),
|
||||
onlineByPlatform[platform],
|
||||
),
|
||||
outcomeByPlatform[platform],
|
||||
)
|
||||
}
|
||||
out = append(out, sum)
|
||||
|
||||
@@ -70,6 +70,14 @@ type AdminChannelAccount struct {
|
||||
// 或尚未探到结果),面板不得画成「离线」,也不得画成「在线」。
|
||||
// 见 design/24 §10.11 与 Docs/api-client.md §2.4(b)。
|
||||
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 列出该平台适配器暴露的账号级管理操作
|
||||
// (friends/channels/user-chats/message-test),使面板
|
||||
// 渲染正确的每账号卡片。没有专用适配器表面的平台
|
||||
|
||||
@@ -303,7 +303,12 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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
|
||||
@@ -324,7 +329,11 @@ func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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 的请求体:一个渠道
|
||||
|
||||
@@ -106,9 +106,13 @@ type Adapter struct {
|
||||
httpAPI *obHTTPClient
|
||||
started bool
|
||||
connected bool
|
||||
cancel context.CancelFunc
|
||||
lifeCtx context.Context
|
||||
newConn chan struct{}
|
||||
// lastConnectErr 是最近一次连接失败的**脱敏**原因,供 ConnectOutcome 如实回答
|
||||
// 「为什么没连上」(见 connect_outcome.go)。连上或断开即清空:一条已经过期的
|
||||
// 原因不该继续挂在当前状态旁边。由 mu 保护。
|
||||
lastConnectErr string
|
||||
cancel context.CancelFunc
|
||||
lifeCtx context.Context
|
||||
newConn chan struct{}
|
||||
|
||||
handlerMu sync.RWMutex
|
||||
handler adapter.MessageHandler
|
||||
@@ -297,6 +301,8 @@ func (a *Adapter) Disconnect(ctx context.Context) error {
|
||||
a.ln = nil
|
||||
a.srv = nil
|
||||
a.httpAPI = nil
|
||||
// 断开即清空上一次失败原因:会话已被拆掉,「为什么没连上」不再是当前事实。
|
||||
a.lastConnectErr = ""
|
||||
// 断开即丢弃账号在线状态:新会话的 known=false 由下一次探活重新确立,绝不沿用
|
||||
// 上一轮的陈旧结论(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)
|
||||
}
|
||||
}
|
||||
@@ -122,9 +122,15 @@ func (s *reconnectState) reset() {
|
||||
s.failures = 0
|
||||
}
|
||||
|
||||
// noteConnectFailure 记录一次 client 模式的连接失败:推进退避,并写一条带来源的
|
||||
// WARN(含 err 与下次重试间隔)。access_token 绝不入日志。
|
||||
// noteConnectFailure 记录一次 client 模式的连接失败:推进退避,把**脱敏后的**原因留在
|
||||
// 适配器上(供 ConnectOutcome 如实回答「为什么没连上」,见 connect_outcome.go),并写一条
|
||||
// 带来源的 WARN(含原因与下次重试间隔)。原因与日志都走同一道脱敏,因此 access_token
|
||||
// 既不落日志、也不进对外文案。
|
||||
func (a *Adapter) noteConnectFailure(err error) {
|
||||
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)
|
||||
}
|
||||
@@ -330,6 +330,9 @@ func (a *Adapter) setConn(conn *wsConn) {
|
||||
old := a.conn
|
||||
a.conn = conn
|
||||
a.connected = true
|
||||
// 会话真的活了 ⇒ 上一次失败的原因不再是当前事实,立刻清掉,免得面板把一条已经
|
||||
// 过期的原因挂在「已连接」旁边。
|
||||
a.lastConnectErr = ""
|
||||
a.mu.Unlock()
|
||||
if old != nil && old != conn {
|
||||
_ = old.Close()
|
||||
|
||||
Reference in new issue
Block a user