fix(gateway,history): REST 流式不再静默失败(G2)+ clientMsgId 幂等(G4)(协同 §G,均标「中高/中」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- G2:`stream:true` 原经**有损总线**且**先回 200** ⇒ 总线饱和时客户端拿到 200 却什么都没发生。
  改为**直达编排器**(与 ws chat 帧既有先例同一原则):新文件 chat_stream_dispatch.go;200 变成有意义的
  承诺(同步穿过「编排器接收 + 用户消息落库」才回);顺带对齐 `202 {queued,queueDepth}` / `409 chat_busy`;
  兜底路径(未接编排器才走总线)现在**返回错误**、ctx 与请求脱钩(WithoutCancel + 30min 上界)→ `503 chat_dispatch_failed`;
  未接 loop 由 200 改为 `500 chat_unavailable`(与同步路径同口径,已同步改既有用例并新增钉住用例)
- G4:`chat`(WS 帧 + REST 体)新增**可选** `clientMsgId` ⇒ 幂等。
  原子性两层,均非「先查再插」:① 同进程在 `o.mu` 同一临界区内完成「查认领表→查存储→预分配 id→认领」;
  ② 跨重启靠 `(session_id, client_msg_id)` **部分**唯一索引 + `INSERT … ON CONFLICT DO NOTHING`(命中则读回既有行)。
  重复语义:REST `200 {duplicate:true, messageId}`;WS 新帧 `chat_duplicate`(owner/admin 档);重复**不落库、不起回合**。
  `clientMsgId` 回填进 DTO ⇒ `message_persisted` 与读接口都可见(客户端可据此对齐本地气泡,不再靠文本相等)
- 迁移 `0013_chat_client_msg_id`(up:加列 + 部分唯一索引 `idx_chat_messages_client_msg_id`;down:先删索引再删列),
  已在 `data/cyrene.db` **副本**上验 up → 判重 → down → 再 up
- 拆分:store.go 647→498 行(`log_reads.go` 抽走四个读路径);七个 SELECT 清单抽成 `messageColumns` 一处
- 复核:**全树 `go test ./... -count=1` 0 FAIL** + build/vet 绿 + linux/darwin 交叉编译绿;
  四条真断言(总线饱和仍落库出帧且 `ws.chat` 零发布 / 同键两次仅一回合+并发两条也仅一回合 / 省略键逐字不变 /
  迁移副本 up-down-up);**3 条变异反证**均已还原
- 越界说明(均已报备):`history.go` DTO 字段(否则「读接口可见」不可能)、`policy_store_test.go`
  (0013 使「回滚最后一步 == 0012」前提失效,改为按 schema_migrations 现算)、`extra_test.go`(补 runner)
- 顺带补交 P0-A 遗漏的新测试文件 `loop/memory_writeback_test.go`
This commit is contained in:
AskaEth committed 2026-10-04 14:37:37 +08:00
1 parent da3a386daf
commit 7512d593c2
18 files changed
+1573 -216

No files matched your search

@@ -104,9 +104,12 @@ func TestToolPolicyMigration_UpDownUpOnDataDirCopy(t *testing.T) {
t.Fatalf("insert into copy: %v", err)
}
// down:只回滚最后一步(= 0012)。
if err := db.MigrateDown(ctx, conn, 1, testStoreLogger()); err != nil {
t.Fatalf("MigrateDown(copy, 1): %v", err)
// down:回滚到 0012 之前 —— 按已应用的迁移现算步数,而不是写死「最后一步」。
// 写死会让后续任何新迁移(0013 起)悄悄变成「回滚那个新迁移」,于是本测试再也
// 测不到 0012 的 down(0013 落地时正是这样暴露的)。
steps := migrationStepsPast(t, ctx, conn, "0012_tool_policy")
if err := db.MigrateDown(ctx, conn, steps, testStoreLogger()); err != nil {
t.Fatalf("MigrateDown(copy, %d): %v", steps, err)
}
if toolPoliciesTableExists(t, ctx, conn) {
t.Fatal("tool_policies 应在 MigrateDown 后消失")
@@ -138,14 +141,44 @@ func TestToolPolicyMigration_UpDownOnMemory(t *testing.T) {
if !toolPoliciesTableExists(t, ctx, conn) {
t.Fatal("tool_policies 应在 MigrateUp 后存在")
}
if err := db.MigrateDown(ctx, conn, 1, testStoreLogger()); err != nil {
t.Fatalf("MigrateDown: %v", err)
steps := migrationStepsPast(t, ctx, conn, "0012_tool_policy")
if err := db.MigrateDown(ctx, conn, steps, testStoreLogger()); err != nil {
t.Fatalf("MigrateDown(%d): %v", steps, err)
}
if toolPoliciesTableExists(t, ctx, conn) {
t.Fatal("tool_policies 应在 MigrateDown 后消失")
}
}
// migrationStepsPast 计算「回滚到 id 之前」需要走几步:MigrateDown 从最新往回
// 走,因此 id 自己以及比它新的每一个各算一步。按 schema_migrations 现算,本测试
// 因此只锁定 0012 的 down,不会被之后新增的迁移(0013 及以后)带偏。
func migrationStepsPast(t *testing.T, ctx context.Context, conn *sql.DB, id string) int {
t.Helper()
rows, err := conn.QueryContext(ctx, `SELECT id FROM schema_migrations`)
if err != nil {
t.Fatalf("列出已应用迁移:%v", err)
}
defer func() { _ = rows.Close() }()
steps := 0
for rows.Next() {
var got string
if err := rows.Scan(&got); err != nil {
t.Fatalf("读取迁移 id:%v", err)
}
if got >= id {
steps++
}
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历迁移:%v", err)
}
if steps == 0 {
t.Fatalf("已应用迁移里没有 %s,本测试的前提不成立", id)
}
return steps
}
// TestRegistry_PolicyStoreSurvivesRestart 锁定「重启后策略仍在」:
// 第一次运行 Save,第二次运行(新连接 + 新注册表 + 新 store)Load 回来。
func TestRegistry_PolicyStoreSurvivesRestart(t *testing.T) {