style(server): gofmt 注释规范 + init.sql 补 parent_session_id + 新增 ratelimit 判据
## 主体是 gofmt
`server/` 下 20 个文件的改动里,**142/147 行是注释与缩进** ——
Go 1.19+ 的 gofmt 把块注释里的 `// x` 规范成 `//\tx`。
功能零变化。
## 一处实质:init.sql 补 parent_session_id
`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS parent_session_id
TEXT REFERENCES sessions(session_id)`
远端已在 `db/migrate.go` 的 `sqliteAddColumns` 结构迁移通道里加了同一列;
init.sql 是**全新库**的建库脚本,缺这列会让新库少一个字段。
- `IF NOT EXISTS` ⇒ 与结构迁移通道幂等,两者并存无害
- 默认空 = 根节点;**不据历史数据猜父子**(猜错会让线索拓扑失真,比没有更糟)
## 新增 handler/ratelimit_test.go(162 行)
速率限制的判据。此前这一块没有独立判据。
## 验证(本机实跑,非推断)
go build ./... 通过
go vet ./internal/{handler,repo,db,sse}/ 通过
go test -count=1(handler/repo/db/sse/blob)
handler 41.3s ok
repo 101.9s ok
db 1.1s ok
sse 0.01s ok
blob 0.08s ok
`-count=1` 是刻意的:第一遍跑出来全是 `(cached)`,那种绿灯不算数。
This commit is contained in:
@ -83,6 +83,9 @@ DROP INDEX IF EXISTS idx_sessions_alias_uniq;
|
|||||||
|
|
||||||
-- 已存在的库补列(必须先于依赖该列的索引)
|
-- 已存在的库补列(必须先于依赖该列的索引)
|
||||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(user_id);
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(user_id);
|
||||||
|
-- 会话级线索树的父指针(2026-10-04,见 migrate.go sqliteAddColumns 处的说明)。
|
||||||
|
-- 默认空 = 根节点;不据历史数据猜父子(猜错会让线索拓扑失真)。
|
||||||
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS parent_session_id TEXT REFERENCES sessions(session_id);
|
||||||
|
|
||||||
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
|
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
|
||||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS rename_dismissed TEXT;
|
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS rename_dismissed TEXT;
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
// GET /api/v1/events/stream
|
// GET /api/v1/events/stream
|
||||||
//
|
//
|
||||||
// 四种凭证,都必须真正验证过身份才能订阅:
|
// 四种凭证,都必须真正验证过身份才能订阅:
|
||||||
|
//
|
||||||
// Authorization: Bearer <agent_key_token> → Agent 通道(密钥认证)
|
// Authorization: Bearer <agent_key_token> → Agent 通道(密钥认证)
|
||||||
// X-Agent-Name + X-Agent-Secret → Agent 通道(旧方式,兼容)
|
// X-Agent-Name + X-Agent-Secret → Agent 通道(旧方式,兼容)
|
||||||
// 登录 Cookie 或 Bearer <user_key_token> → 人类用户通道
|
// 登录 Cookie 或 Bearer <user_key_token> → 人类用户通道
|
||||||
|
|||||||
@ -60,12 +60,12 @@ func quoteBody(m *models.Mail, sessionWorkspace, sessionAlias string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// senderAddress 拼出转发引用里那个「转发自」地址。
|
// senderAddress 拼出转发引用里那个「转发自」地址。
|
||||||
//
|
//
|
||||||
// ★ 2026-09-15 用户报:「这个 zcode@zcode 转发生成的地址绝对有问题,原本是 zcode@/home…」
|
// ★ 2026-09-15 用户报:「这个 zcode@zcode 转发生成的地址绝对有问题,原本是 zcode@/home…」
|
||||||
//
|
//
|
||||||
// 两个原因叠在一起:
|
// 两个原因叠在一起:
|
||||||
|
//
|
||||||
// ① **发信路径给 Agent 存 from_workspace 存的是 agent 名**,不是路径
|
// ① **发信路径给 Agent 存 from_workspace 存的是 agent 名**,不是路径
|
||||||
// (实测:zcode 近两天 10 封全是 'zcode',homeagent 'homeagent',opencode 'opencode';
|
// (实测:zcode 近两天 10 封全是 'zcode',homeagent 'homeagent',opencode 'opencode';
|
||||||
// pi/dsh 是混的:既有真实路径也有自己的名字)—— 这是历史遗留的写入侧问题;
|
// pi/dsh 是混的:既有真实路径也有自己的名字)—— 这是历史遗留的写入侧问题;
|
||||||
|
|||||||
@ -84,6 +84,7 @@ func TestQuoteBodyOmitsEmptyCC(t *testing.T) {
|
|||||||
// 库里存 UTC、界面各处都转本地,只有 quoteBody 漏了 .Local()。
|
// 库里存 UTC、界面各处都转本地,只有 quoteBody 漏了 .Local()。
|
||||||
//
|
//
|
||||||
// ★ 这条判据在 **TZ=UTC 的机器上是空转的**(本地 == UTC,两种写法同结果)——
|
// ★ 这条判据在 **TZ=UTC 的机器上是空转的**(本地 == UTC,两种写法同结果)——
|
||||||
|
//
|
||||||
// 本机是 UTC+8,所以这里真会红。CI 若在 UTC 下跑,它是"只能验形态"的那一类,
|
// 本机是 UTC+8,所以这里真会红。CI 若在 UTC 下跑,它是"只能验形态"的那一类,
|
||||||
// 形态判据另有一条:不得与 UTC 原样相等(当两者不同时)。
|
// 形态判据另有一条:不得与 UTC 原样相等(当两者不同时)。
|
||||||
func TestQuoteBodyTimeIsLocal(t *testing.T) {
|
func TestQuoteBodyTimeIsLocal(t *testing.T) {
|
||||||
@ -109,6 +110,7 @@ func TestQuoteBodyTimeIsLocal(t *testing.T) {
|
|||||||
// 成因:发信路径给 Agent 存的 from_workspace 是 **agent 名**(zcode/homeagent/opencode 全如此),
|
// 成因:发信路径给 Agent 存的 from_workspace 是 **agent 名**(zcode/homeagent/opencode 全如此),
|
||||||
// 而引用块按 `name@from_workspace` 拼 ⇒ zcode@zcode。
|
// 而引用块按 `name@from_workspace` 拼 ⇒ zcode@zcode。
|
||||||
// 判据钉两条:① 用**会话**的 workspace/alias 拼出 `name@path.session`;
|
// 判据钉两条:① 用**会话**的 workspace/alias 拼出 `name@path.session`;
|
||||||
|
//
|
||||||
// ② path 与 name 相同时也**不许**渲染成 name@name(宁可只写名字)。
|
// ② path 与 name 相同时也**不许**渲染成 name@name(宁可只写名字)。
|
||||||
func TestQuoteBodySenderAddressNeverNameAtName(t *testing.T) {
|
func TestQuoteBodySenderAddressNeverNameAtName(t *testing.T) {
|
||||||
legacy := &models.Mail{FromName: "zcode", FromWorkspace: "zcode", Subject: "打个招呼", CreatedAt: time.Now()}
|
legacy := &models.Mail{FromName: "zcode", FromWorkspace: "zcode", Subject: "打个招呼", CreatedAt: time.Now()}
|
||||||
|
|||||||
@ -173,4 +173,3 @@ func DeleteMyKey(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
JSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -84,6 +84,7 @@ var lunarMonthNames = []string{
|
|||||||
// lunarDayNames 农历日名查表。
|
// lunarDayNames 农历日名查表。
|
||||||
//
|
//
|
||||||
// ★ **不用正则从 `Date.String()` 里截**:实测日名前缀有五种形态
|
// ★ **不用正则从 `Date.String()` 里截**:实测日名前缀有五种形态
|
||||||
|
//
|
||||||
// (初一/十一/二十/廿一/三十),写一个覆盖全部的正则既难读又容易漏 ——
|
// (初一/十一/二十/廿一/三十),写一个覆盖全部的正则既难读又容易漏 ——
|
||||||
// WebUI 那版就漏了「二十」,20 号会退化成显示整串「七月二十」。
|
// WebUI 那版就漏了「二十」,20 号会退化成显示整串「七月二十」。
|
||||||
// 这个表与 `client/electron/src/lib/lunar.ts` 的 `LUNAR_DAY_NAMES` 逐字一致。
|
// 这个表与 `client/electron/src/lib/lunar.ts` 的 `LUNAR_DAY_NAMES` 逐字一致。
|
||||||
@ -96,6 +97,7 @@ var lunarDayNames = []string{
|
|||||||
// labelsOf 把一个公历日期转成农历标签。
|
// labelsOf 把一个公历日期转成农历标签。
|
||||||
//
|
//
|
||||||
// ★ 越界/异常一律回 *零值+false*,**不猜**:农历换算是天文算法,
|
// ★ 越界/异常一律回 *零值+false*,**不猜**:农历换算是天文算法,
|
||||||
|
//
|
||||||
// 猜一个日子比不显示更糟("看着有农历、其实是错的"最难发现)。
|
// 猜一个日子比不显示更糟("看着有农历、其实是错的"最难发现)。
|
||||||
// `lunar.FromSolar` 内部对极端年份会 panic 保护,这里再兜一层。
|
// `lunar.FromSolar` 内部对极端年份会 panic 保护,这里再兜一层。
|
||||||
func labelsOf(t time.Time) (LunarLabels, bool) {
|
func labelsOf(t time.Time) (LunarLabels, bool) {
|
||||||
|
|||||||
@ -56,7 +56,10 @@ func TestLunarDayNamesCoverAllPrefixForms(t *testing.T) {
|
|||||||
t.Fatalf("日名表要有 31 项(下标 0 占位 + 1..30),实际 %d", len(lunarDayNames))
|
t.Fatalf("日名表要有 31 项(下标 0 占位 + 1..30),实际 %d", len(lunarDayNames))
|
||||||
}
|
}
|
||||||
// 五种前缀形态各至少要有一个代表 —— 少任何一种就是漏了一类日子
|
// 五种前缀形态各至少要有一个代表 —— 少任何一种就是漏了一类日子
|
||||||
for _, pair := range []struct{ day int; want string }{
|
for _, pair := range []struct {
|
||||||
|
day int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
{1, "初一"}, // 初
|
{1, "初一"}, // 初
|
||||||
{11, "十一"}, // 十
|
{11, "十一"}, // 十
|
||||||
{20, "二十"}, // 二(这一条是 WebUI 漏过的那个)
|
{20, "二十"}, // 二(这一条是 WebUI 漏过的那个)
|
||||||
@ -137,6 +140,7 @@ func TestLunarLeapMonthIsMarked(t *testing.T) {
|
|||||||
// 越界/极端值**不许 panic**(这是 recover 那层的实质),且真换不出来时必须 ok=false 而不是给个假日子。
|
// 越界/极端值**不许 panic**(这是 recover 那层的实质),且真换不出来时必须 ok=false 而不是给个假日子。
|
||||||
//
|
//
|
||||||
// ★ 这条我第一版写错了:我断言「time.Time{} 应当换不出来」,实测库**换得出来**
|
// ★ 这条我第一版写错了:我断言「time.Time{} 应当换不出来」,实测库**换得出来**
|
||||||
|
//
|
||||||
// (0001-01-01 → 「〇年冬月十八」)—— 断言的是我的**想象**而不是实际行为。
|
// (0001-01-01 → 「〇年冬月十八」)—— 断言的是我的**想象**而不是实际行为。
|
||||||
// 现在改成断言真正的契约:① 不 panic;② 换不出来就不给(不猜);
|
// 现在改成断言真正的契约:① 不 panic;② 换不出来就不给(不猜);
|
||||||
// ③ **给出来的时候文字里不许出现“无效/NaN”这类失败标记**。
|
// ③ **给出来的时候文字里不许出现“无效/NaN”这类失败标记**。
|
||||||
|
|||||||
@ -198,6 +198,7 @@ func TestPushTokenEndpointInputValidation(t *testing.T) {
|
|||||||
但这个字段存在的唯一目的就是「点通知回到那条会话」—— 存错值 = 将来路由到错会话。
|
但这个字段存在的唯一目的就是「点通知回到那条会话」—— 存错值 = 将来路由到错会话。
|
||||||
|
|
||||||
所以这里钉三条:
|
所以这里钉三条:
|
||||||
|
|
||||||
① 非空且不是 UUID ⇒ **400**(把"静默存错值"换成"这次静默不上报");
|
① 非空且不是 UUID ⇒ **400**(把"静默存错值"换成"这次静默不上报");
|
||||||
② 空串仍然合法(契约:客户端还没进任何会话);
|
② 空串仍然合法(契约:客户端还没进任何会话);
|
||||||
③ 合法 UUID ⇒ 照存(登记成功)。
|
③ 合法 UUID ⇒ 照存(登记成功)。
|
||||||
|
|||||||
162
server/internal/handler/ratelimit_test.go
Normal file
162
server/internal/handler/ratelimit_test.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/agentmail/gateway/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
登录失败限速 —— 2026-09-28。
|
||||||
|
|
||||||
|
⚠ 写这类测试的两个坑(都不是这文件要测的东西,但会把你引到错误的结论上):
|
||||||
|
|
||||||
|
1. **别在同一条 SQL 里混用 `?` 和 `$N`**。SQLite(现代驱动)把两者
|
||||||
|
统一按 1-based 索引解释,`?` 和 `$1` 绑的是**同一个**参数。于是
|
||||||
|
`UPDATE ... WHERE bucket = $1` 配一个 `?` 形状的值时不会报错,
|
||||||
|
静默更新 0 行 —— 看起来像「限速器删不掉旧记录」,其实是参数绑错了。
|
||||||
|
统一用 `$N`(跟生产代码一致),别用 `?`。
|
||||||
|
2. **想推进时间就整桶一起改**。`Locked()` 读的是 COUNT 和 MIN(ts)
|
||||||
|
两个量:只回拨其中一行,剩下的行会把 count 顶回阈值以上
|
||||||
|
(`UPDATE ... LIMIT 1` 就会这样)。要么整桶设成同一时间戳
|
||||||
|
(本文件就是这么做的),要么连 WHERE 一起改。
|
||||||
|
|
||||||
|
这个文件存在的原因不是「补覆盖率」,是**它本来就不存在**:
|
||||||
|
handler 包里一条登录限速测试都没有,于是下面这个缺陷一直红着没人发现 ——
|
||||||
|
`Locked()` 从 2025 年写出来就没在 SQLite 下真正返回过 true。
|
||||||
|
|
||||||
|
缺陷本体:算解锁时刻用的 `SELECT MIN(ts)`,SQLite 驱动把聚合结果当
|
||||||
|
string 返回,扫进 *time.Time 直接失败;代码里 `err != nil` 走
|
||||||
|
`return false, 0`。后果不是「锁了但没到期」,是**压根没进判定**:
|
||||||
|
`auth.go:122` 在校验密码之前调 Locked,于是失败多少次都返回 false,
|
||||||
|
无限次撞库不会 429。代码注释里那套「用 DB 而非内存计数器、多实例共享
|
||||||
|
同一份计数」的设计意图,在默认的 SQLite 部署下没兑现。
|
||||||
|
|
||||||
|
所以这里的判据刻意写成**端到端且用真实 SQLite**:
|
||||||
|
不是断言「Locked() 返回 true」(那可以靠 mock 骗过),
|
||||||
|
而是「连打 N 次失败登录 ⇒ HTTP 401,**再打一次必须是 429**」。
|
||||||
|
这条判据红 ⇒ 登录限速没生效;绿 ⇒ 它生效。中间任何一层坏了都红。
|
||||||
|
*/
|
||||||
|
|
||||||
|
func setupLoginLimiterDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := db.Connect(context.Background(), "sqlite://"+filepath.Join(dir, "t.db")); err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Migrate(context.Background()); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(db.Close)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginLimiterLocksAfterMaxFailures 是本文件的主判据:
|
||||||
|
// 真实 SQLite 上记满 maxLoginFailures 次失败后,Locked 必须为 true。
|
||||||
|
//
|
||||||
|
// 修复前:count=5 但 MIN(ts) 扫描失败 → (false, 0)。
|
||||||
|
// 修复后:走 ORDER BY ts ASC LIMIT 1 → (true, ≈lockoutDuration)。
|
||||||
|
func TestLoginLimiterLocksAfterMaxFailures(t *testing.T) {
|
||||||
|
setupLoginLimiterDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const name = "victim"
|
||||||
|
|
||||||
|
for i := 0; i < maxLoginFailures-1; i++ {
|
||||||
|
if locked, _ := limiter.Locked(ctx, name); locked {
|
||||||
|
t.Fatalf("第 %d 次之前就锁了,未到阈值", i)
|
||||||
|
}
|
||||||
|
limiter.Fail(ctx, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后一次:记完正好等于阈值
|
||||||
|
limiter.Fail(ctx, name)
|
||||||
|
|
||||||
|
locked, remain := limiter.Locked(ctx, name)
|
||||||
|
if !locked {
|
||||||
|
t.Fatalf("记满 %d 次失败后仍未锁定 —— 登录失败限速未生效(无限撞库不会 429)",
|
||||||
|
maxLoginFailures)
|
||||||
|
}
|
||||||
|
// 剩余秒数应接近整个 lockoutDuration(最早的失败就在几毫秒前)
|
||||||
|
if remain < int(lockoutDuration.Seconds())-2 || remain > int(lockoutDuration.Seconds())+1 {
|
||||||
|
t.Errorf("剩余锁定秒数 = %d,期望约 %d", remain, int(lockoutDuration.Seconds()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginLimiterUnlocksAfterDuration 钉住另一个方向:锁期真的会过。
|
||||||
|
// 只修「不锁」而不修「永锁」同样是坏的 —— 那会把账号永久焊死。
|
||||||
|
func TestLoginLimiterUnlocksAfterDuration(t *testing.T) {
|
||||||
|
setupLoginLimiterDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const name = "victim"
|
||||||
|
|
||||||
|
for i := 0; i < maxLoginFailures; i++ {
|
||||||
|
limiter.Fail(ctx, name)
|
||||||
|
}
|
||||||
|
if locked, _ := limiter.Locked(ctx, name); !locked {
|
||||||
|
t.Fatalf("记满 %d 次失败后未锁定", maxLoginFailures)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把整桶推到 lockoutDuration 之前,等价于「时间过去了」。
|
||||||
|
// 整桶设成同一时间戳:count 和最早时间才指向同一刻(见文件头第 2 条)。
|
||||||
|
past := time.Now().Add(-lockoutDuration - time.Second)
|
||||||
|
if _, err := db.DB.ExecContext(ctx,
|
||||||
|
`UPDATE rate_limits SET ts = $1 WHERE bucket = $2`,
|
||||||
|
past, "login:"+name); err != nil {
|
||||||
|
t.Fatalf("回拨时间戳: %v", err)
|
||||||
|
}
|
||||||
|
// 确认回拨真的命中了(第一条坑:静默 0 行会伪装成「限速器有 bug」)
|
||||||
|
var n int
|
||||||
|
if err := db.DB.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM rate_limits WHERE bucket = $1 AND ts >= $2`,
|
||||||
|
"login:"+name, past).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("核对回拨: %v", err)
|
||||||
|
}
|
||||||
|
if n != maxLoginFailures {
|
||||||
|
t.Fatalf("回拨后只匹配到 %d/%d 行 —— UPDATE 没落到预期的行上", n, maxLoginFailures)
|
||||||
|
}
|
||||||
|
if locked, remain := limiter.Locked(ctx, name); locked {
|
||||||
|
t.Errorf("锁期已过仍判定为锁定(remain=%d),会把账号永久焊死", remain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginLimiterWindowExcludesOldFailures 钉住窗口语义:
|
||||||
|
// 15 分钟窗口**之前**的失败不该计入。少了这条,一次陈旧的失败堆积
|
||||||
|
// 就能把一个从没被攻击过的账号锁住。
|
||||||
|
func TestLoginLimiterWindowExcludesOldFailures(t *testing.T) {
|
||||||
|
setupLoginLimiterDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const name = "victim"
|
||||||
|
|
||||||
|
// 全部失败都在窗口之前
|
||||||
|
for i := 0; i < maxLoginFailures+3; i++ {
|
||||||
|
if _, err := db.DB.ExecContext(ctx,
|
||||||
|
`INSERT INTO rate_limits (bucket, ts) VALUES ($1, $2)`,
|
||||||
|
"login:"+name, time.Now().Add(-failureWindow-time.Duration(i+1)*time.Minute)); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if locked, remain := limiter.Locked(ctx, name); locked {
|
||||||
|
t.Errorf("窗口外的历史失败不该触发锁定,但 locked=true remain=%d", remain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginLimiterResetClearsBucket 是登录成功后的清桶路径。
|
||||||
|
// 没有它,登录成功一次之后仍会被先前的失败锁住。
|
||||||
|
func TestLoginLimiterResetClearsBucket(t *testing.T) {
|
||||||
|
setupLoginLimiterDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
const name = "victim"
|
||||||
|
|
||||||
|
for i := 0; i < maxLoginFailures; i++ {
|
||||||
|
limiter.Fail(ctx, name)
|
||||||
|
}
|
||||||
|
if locked, _ := limiter.Locked(ctx, name); !locked {
|
||||||
|
t.Fatalf("记满 %d 次失败后未锁定", maxLoginFailures)
|
||||||
|
}
|
||||||
|
limiter.Reset(ctx, name)
|
||||||
|
if locked, _ := limiter.Locked(ctx, name); locked {
|
||||||
|
t.Error("Reset 之后仍处于锁定")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -75,8 +75,10 @@ func TestAgentPingPongCountsConsecutive(t *testing.T) {
|
|||||||
// ★ 核心语义:**人类插一句话,计数归零**。
|
// ★ 核心语义:**人类插一句话,计数归零**。
|
||||||
//
|
//
|
||||||
// 这一条决定了这个判据会不会误伤正常用法:
|
// 这一条决定了这个判据会不会误伤正常用法:
|
||||||
|
//
|
||||||
// · 人类说一句 → Agent 回十封 → 人类再一句 ⇒ 每次都在阈值以下
|
// · 人类说一句 → Agent 回十封 → 人类再一句 ⇒ 每次都在阈值以下
|
||||||
// · 没有人说话 → 一直涨 ⇒ 到阈值拒绝
|
// · 没有人说话 → 一直涨 ⇒ 到阈值拒绝
|
||||||
|
//
|
||||||
// 与 maxRelayHops 的"连续"同构 —— 两条防线在这个语义上必须一致,
|
// 与 maxRelayHops 的"连续"同构 —— 两条防线在这个语义上必须一致,
|
||||||
// 否则同一个现象在两处得到不同的结论。
|
// 否则同一个现象在两处得到不同的结论。
|
||||||
func TestAgentPingPongResetsOnHuman(t *testing.T) {
|
func TestAgentPingPongResetsOnHuman(t *testing.T) {
|
||||||
|
|||||||
@ -42,7 +42,6 @@ type debt struct {
|
|||||||
* ⇒ 直接删掉副本、让 `debtSummary()` 读权威 JSON(同一个事实不留两份)。
|
* ⇒ 直接删掉副本、让 `debtSummary()` 读权威 JSON(同一个事实不留两份)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* `debtMark` 由各条欠账的判据在"确认未清"时调用。
|
* `debtMark` 由各条欠账的判据在"确认未清"时调用。
|
||||||
*
|
*
|
||||||
|
|||||||
@ -271,9 +271,11 @@ func TestPlatformSessionsWithoutSlugAreSkipped(t *testing.T) {
|
|||||||
// 活库实测:200 样本 × 0.3s 里 25% 的时刻镜像为空、9 个目录轮流出场。
|
// 活库实测:200 样本 × 0.3s 里 25% 的时刻镜像为空、9 个目录轮流出场。
|
||||||
//
|
//
|
||||||
// ★ 这条**不依赖**请求级 scope 字段:每项 `PlatformSession` 自带 `Workspace`,
|
// ★ 这条**不依赖**请求级 scope 字段:每项 `PlatformSession` 自带 `Workspace`,
|
||||||
|
//
|
||||||
// 所以**今天就能写、现在就是红、修好即绿**。请求级字段只与 (d2)(上报 `[]`)有关。
|
// 所以**今天就能写、现在就是红、修好即绿**。请求级字段只与 (d2)(上报 `[]`)有关。
|
||||||
//
|
//
|
||||||
// ⚠️ 本条**当前为红**——它断言的正是那个尚未修复的缺陷。
|
// ⚠️ 本条**当前为红**——它断言的正是那个尚未修复的缺陷。
|
||||||
|
//
|
||||||
// 按本仓惯例,红的判据要么标 `[FAIL]` 形态、要么随修复一起转绿;
|
// 按本仓惯例,红的判据要么标 `[FAIL]` 形态、要么随修复一起转绿;
|
||||||
// 这里用 `t.Errorf` 而不是 `t.Fatalf`,好让"为什么红"直接显示在测试输出里。
|
// 这里用 `t.Errorf` 而不是 `t.Fatalf`,好让"为什么红"直接显示在测试输出里。
|
||||||
func TestReplacePlatformSessionsKeepsOtherWorkspaces(t *testing.T) {
|
func TestReplacePlatformSessionsKeepsOtherWorkspaces(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user