Skip to content

fix(v1.13.0): 以 1.13.0 发行版为基线的全量问题修复 - #2

Open
Huiaei wants to merge 57 commits into
base/v1.13.0from
release/v1.13.0-fixed
Open

Huiaei wants to merge 57 commits into
base/v1.13.0from
release/v1.13.0-fixed

Conversation

@Huiaei

@Huiaei Huiaei commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

fix(v1.13.0): 以 1.13.0 发行版为基线的全量问题修复

基线纯净性(可复核)

git merge-base --is-ancestor d66384d HEAD          # true  ← 精确的 v1.13.0 tag
# 上游 v1.13.0 之后的 9 个提交,全部 not-ancestor(没有任何一个原样进来):
#   20254d8  0ab6690  535e19e  8391a05  7595a0a
#   1e80c7f  4d00623  8461419  6b31db6   ← 后 4 个是 merge commit

其中 4 个提交的具体改动被按需手工移植(都是修复类,且与本分支的修复互补):

上游提交 移植的改动 为什么需要
20254d8 internal/server/handler.go:无 conversation_id 时 X-Conversation-ID 回退到会话键 不回退则该值每次请求都不同 → 上游 prompt_cache_key 退化成「每账号一个常量」,前缀缓存永不命中(费用问题)
535e19e internal/upstream/tool_pairing.go:剔除 function.name 为空的 tool_call 线上 11133 的直接成因
8391a05 internal/panel/app.js:到期提醒与积分构成改为按需拉取 打开账号池就打上游,是 linguo2625469#145/linguo2625469#137 的成因
0ab6690 internal/panel/app.js + index.html:到期提醒卡片默认可见 同上;expBox 硬编码 hidden 而解隐藏语句在隐藏卡片内的按钮里 = 永远看不到

不携带的是上游 7595a0a / 8461419 引入的 DSML 原生工具调用标记还原层(internal/upstream/dsml.go 不存在,WithMarkupRepair / NewMarkupRepair / RepairAggregatedResponse 均 0 命中)。它是上游 PR linguo2625469#123 的功能增强(非修复),上游 140 条 issue 中 0 条与之相关,故按「只修问题、不带功能」的原则排除。排除后该现象退化为「流式原文透传 + 面板挂『停摆』角标 / 非流式换号重试后原样返回」——保守且不罚号,不会造成错误结果。

上游 PR linguo2625469#183 的 context_length 硬编码 1M(把 65536 上下文模型谎报成 1M)在本分支 internal/server/handler.go 中出现 0 次。

一、问题分组与修复对照

组 上游 issue 根因 修复
A. 账号被误禁 / 渠道校验 linguo2625469#197 linguo2625469#187 linguo2625469#188 linguo2625469#231 linguo2625469#198 linguo2625469#184 ① 上游 400 11-128「request illegal」是请求级拦截(渠道校验未过),却被当成账号封禁 → Pool.Disable() 永久禁用好号;② 账号间风控判定不一致(同一时刻 A 号 400 / B 号 200);③ X-Conversation-ID 缺失导致上游判定非官方客户端;④ 6021 queue.full 被当 429 限流 → 冷却账号并放大重试 ① 按业务码分流:11128/"11-128" → ErrChannelRefused(不罚号、继续轮转),11140+request illegal → ErrAccountFault(禁用 + 申诉文案);② 恒定发送会话头三件套,缺 conversation_id 时回退到会话键(不伪造随机值);③ 队列满 → 503 upstream_busy,零账号惩罚 + 透传上游 Retry-After
B. 登录态 / 账号池 linguo2625469#215 linguo2625469#222 linguo2625469#229 linguo2625469#227 linguo2625469#160 linguo2625469#173 ① 并发刷新把已消费的 refresh token 再用一次 → 账号被踢下线(12153);② 「无限积分」哨兵值 1<<40 撑大分母 → 无限账号垄断选号;③ 只有余额下限、没有消费速率闸;④ 冷却账号不按到期紧急度选 ① 按账号 singleflight 刷新 + 12153 自动重登重试;② capUnlimitedCredits 改用第二大真实余额作分母;③ 新增账号级 burn guard(窗口内消费超限 → 冷却),所有选号路径统一生效;④ 补到期梯度与区域调度
C. 面板 / 配置 / 额度 linguo2625469#226 linguo2625469#134 linguo2625469#171 linguo2625469#170 linguo2625469#216 linguo2625469#189 linguo2625469#174 linguo2625469#163 linguo2625469#179 linguo2625469#155 linguo2625469#135 linguo2625469#81 linguo2625469#26 linguo2625469#181 ① config.json.tmp 不可写 → 面板保存配置直接失败(Docker 场景高发);② 面板保存后静默吞掉 401,旧 key 一直生效;③ global 目录「重新获取」打不破 1h 缓存;④ 缺 messages[0].role=system 校验 → 全量 11-128;⑤ 工具调用 id 重复未改名 → 上游 400 ① 新增 internal/atomicfile:原子落盘 + 多目录回退 + Docker PUID 提示;② 保存后显式报错、重新取 key;③ 目录探测可失效 + 记录来源(desktop/union/enterprise);④ 出站路径 EnsureSystemFirst 保证首条 system(所有 prompt 模式生效);⑤ 重复 call id 自动改名
D. 额度口径 / agentic 停摆 linguo2625469#222 linguo2625469#149 ① 企业口径 used 四舍五入 → 多报剩余额度,调度器以为还有额度继续派发(实际打过去就是 402);② 上游偶发 data:null / 缺字段 → 「额度 0 但账号健康」,与「接口失败」无法区分;③ agentic 请求声明了 tools 却返回 finish_reason=stop + 零 tool_calls → 工具永远不执行,客户端当成正常完成 ① 改为 ceil(向上取整是保守方向);② 判空口径升级为「额度字段一个都不存在」,个人口径同源现象一并修掉,面板优先显示「余额刷新失败」;③ 新增零 tool_calls 停摆检测:非流式换号重试(永不罚号),流式只观测不重试(帧已写出),并保证停摆那一跳照常计费(否则余额虚高、燃尽熔断漏跳)

二、关键设计取舍

1. 11-128 与 11140 必须按业务码分流,不能按文本匹配。
上游同一个 msg:"request illegal" 出现在两个完全不同的语义里:

上游响应 语义 正确处理
{"code":11128,"msg":"request illegal"} / {"code":"11-128",...} 渠道校验未过(账号是好的) 不罚号,继续轮转
{"code":11140,"msg":"request illegal"} 账号被限制(需在 WorkBuddy 申诉) 禁用 + 申诉文案

上游 PR linguo2625469#233 是文本 + 数字码双条件,既漏掉 #188 的 {"code":11128,"msg":"conversation id missing"},也漏掉 issue linguo2625469#197 抓包里的字符串码 "11-128",两者都退化成 ErrClient → 轮转耗尽后仍返回 503 no_healthy_account(正是 linguo2625469#188 的症状)。本分支改为业务码优先,文本只作兜底。

2. 通道校验失败必须继续轮转,不能 fail-fast。
初版实现曾对 ErrChannelRefused 直接返回 400(理由:指纹 = 出口 IP + 请求头 + 请求体,换号无用)。这是错的,实测证据:issue linguo2625469#197 中同一时刻一个账号稳定 400 11-128、另一个健康账号稳定 200 —— 上游风控是按账号判定的;且出站请求头因账号而异(企业账号带 X-Userinfo,个人账号不带)。改为继续轮转后:3 个健康账号、只有第 1 个命中 11-128 时,返回 200(2 次上游调用),而 fail-fast 返回 400(1 次调用)。

3. 11-128 不得归入内容审核域。
11-128 Illegal API invocation from an unapproved channel 若被分类为 ErrContentBlocked,会触发 h.degrade.Trigger() → 整个网关降级到次日零点(对所有账号、所有模型)。实测确认后已把内容审核的排除项收窄到唯一真实标记 blocked by security policy。

4. 11140 信封里的模型级限流文案不得当成封禁。
上游把 msg:"request illegal" 和 displayMsg:"The model provider is rate-limiting requests." 装在同一信封里。只按 code+text 判定会把健康账号永久禁用。现在只排除三条明确的限流文案,其余封禁措辞(Too many login attempts、Your usage limit has been reached、Rate limit abuse detected 等)仍然禁用 —— 漏禁比误禁更糟(账号会一直显示可用,用户永远不知道要去申诉)。

5. 只认证据。 全分支不新增任何凭据处理路径;linguo2625469#217 只采用 AccessToken/RefreshToken/ExpiresAt,不碰 Domain,不打印 token。

6. 「判空」与「取值」必须同一套口径。
#222 的判空最初用 map 精确键匹配(credit / limitNum / …),取值用结构体 + encoding/json(字段名大小写不敏感)。两处口径不同会同时出两种错:上游把 credit 改成 Credit → 判空说「没额度字段」→ 正常响应被误报成故障;反向则会出现「判定说有额度、数值却读成 0」→ 又回到「额度 0 但账号健康」。现在改为一处解析(parseEnterpriseQuota,按去下划线 + 小写的归一化键读值并同时给出 hasQuotaField),判空与取值天然同口径。

7. 停摆是上游行为缺陷,不是账号问题。
零 tool_calls 停摆(#149)时绝不能罚号:不冷却、不计失败、不禁用——上游在这个模型/这个请求上就是会停摆,罚号只会把好号赶出池子。重试是换号(同一个号大概率再次停摆)。同时停摆那一跳必须照常计费并计入燃尽熔断,否则账号余额虚高、调度器继续往正在烧钱的号上派单。

三、附带交付:原生协议入站(Codex / Claude Code / 官方检索)

这三条入站路径是上游 issue linguo2625469#89 / linguo2625469#195 / linguo2625469#9 里被反复追问的形态,本次一并落地(PR #1 同样包含):

端点 协议 用途
POST /v1/responses OpenAI Responses 原生 Codex 直连;GET /v1/responses 明确返回 426(不支持 WebSocket 升级)
POST /v1/messages(及别名 POST /messages) Anthropic Messages 原生 Claude Code 直连;x-api-key 与 Authorization: Bearer 都接受
POST /v1/search 官方检索透传 字段与上游一致,不加工结果;deep:true 走 agentic 综述(上限 150s)
  • 三条路径与 /v1/chat/completions 共用同一套内核:同样的选号、同样的轮转、同样的错误策略、同样进请求流水与计费。
  • 旧有的 POST /v1/search/messages(Anthropic 形态的搜索结果壳)保留不变,只是搬进了 internal/server/search_messages.go。
  • 在途租约成对归还(本次一并修掉的上游缺陷):检索路径此前完全不取在途租约 → 检索流量绕过 MaxInFlight 限流,一次并发检索能把同一账号堆到超出上游并发上限。现在每次尝试都用 defer Release 圈住全部出口(上游 500 / ctx 取消 / 超时 / panic)。实测 4 条入站路径 × 3 种故障 = 12 个组合的 InFlight 全部归 0(去掉 defer 即失败)。
  • 兼容写法提醒:Handler.Config.ToolStallRetry 默认是 0,只有 cmd/server/config.go 的缺省才是 1 —— 写单测时必须显式传 ToolStallRetry: 1。

四、验证

每个修复都配有会真正执行的回归测试(不是空断言):

  • go build ./... / go vet ./... / go test -count=1 ./... 全绿(19 个包,零测试被删除)。
  • 端到端三态实测(同一套 fake upstream,分别在裸 v1.13.0 与修复后运行):
上游响应 裸 v1.13.0 修复后
11128 200,但 disabled=true(好号被禁) 200,upstream_calls=2,不禁用 / 不冷却 / err_total=0
11140 503,禁用 503,禁用 + 申诉文案
6021 429 rate_limit_exceeded,cooling=true 503 upstream_busy,不冷却,只打 1 次上游,Retry-After 原样透传
声明 tools 但零 tool_calls 200(工具没执行,客户端当成正常完成) 200(换号重试后正常返回 tool_call),不冷却 / 不禁用
企业额度 credit=812.45 1188(多报剩余额度) 1187(ceil,保守方向)
  • 裸 v1.13.0 树上 ErrChannelRefused / IsChannelRefused / upstream_busy 出现 0 次 → 修复确实长在 v1.13.0 的代码上,不依赖 v1.13.0 之后的提交。
  • 缓存键链路实测:同会话两次请求的 X-Conversation-ID、prompt_cache_key、body 级 conversation_id 三者完全一致;不带该补丁时则每次随机、cache key 退化成「每账号一个常量」、body 不补齐。
  • 主线分支与本分支的行为对照(同一套临时端到端测试两边各跑一遍):11128 / 11140 / 6021+Retry-After / 原子配置写 / 燃尽熔断 五项数字逐项一致。
  • 源码级反向清单:216 个 .go 文件逐个比对,主线相对本分支只多出 65 个符号,全部是 DSML 相关;本分支没有任何主线缺失的符号、方法或类型。25 个 WB2A_* 环境变量、两个 CI workflow、Dockerfile / docker-compose.yml / config.example.json 均与主线逐字节相同。
  • gofmt -l . 只剩 v1.13.0 基线自带的 7 个 offender,无新增。

五、配置与状态兼容性

  • 新增 13 个配置键(全部新增,零删除、零改名):cn.chat_base、dns_servers、models.hidden、pool.account_order.domestic/international、pool.burn_guard_limit/window/cooldown/cooldown_max、pool.cross_domain_fallback、pool.strategy.domestic/international、upstream.tool_stall_retry。
  • 加载逻辑是 Default() 先填默认值再 json.Unmarshal 覆盖,老 config.json 不改一行也能直接升级(新键取默认值,tool_stall_retry 还会被 clamp 到 [0,3])。
  • state.json / usage.json 格式未变,老文件可直接读;新增的 creditsRemainder / Scheduling / CreditVerified 等字段缺失时走自动迁移。credit-history.json 是新增文件(积分历史),首次启动自动创建,用户无需操作。

六、已知未覆盖(明确不在这版里)

以下 issue 没有在本次交付中实现,列出来以免被误认为已修:
#28(abort 端点)、#201(昵称→UID 映射,仅显示层)、#153(每账号 socks5 —— 目前只有环境变量代理,见 #144)、#175 #176 #141 #84 #25 #147 #209 #211 #182 #39 #56 #95 #23 #13 #19 #139 #142 #150。

另有若干 issue 判定为非代码缺陷(产品需求 / 账号本身被上游封禁 / 已在 v1.13.0 覆盖),同样不在本次范围。

七、附录:Windows 专项 CI(本次新增)与它当场抓出的真 bug

上游的两个 workflow(go-binaries.yml / docker-ghcr.yml)此前只在 ubuntu-latest 上跑测试,
而仓库同时发布 Windows 二进制(矩阵含 windows/amd64)→ Windows 专属的代码分支
永远不会被测试执行到。

本次给两个 workflow 各加了一个 test-windows job(runs-on: windows-latest,跑
go test ./... + go vet ./...),并把下游 job 的 needs 改成 [test, test-windows]。
这个 job 第一次运行就抓到了一个此前从未被任何测试覆盖的真 bug:

现场 症状 根因 修法
并发写同一目标 replace ...config.json: The system cannot find the file specified. 暂存名是固定的 path+".tmp" → 两个写者共用;前写者 rename 成功后暂存文件消失,后写者 rename 报「源文件不存在」。Windows 上更糟:文件正被另一个进程打开时 MoveFileEx 因共享冲突失败 → 降级的原地覆盖又因「目标尚不存在」报 ERROR_FILE_NOT_FOUND 暂存名改唯一(<base>.tmp-<16位hex>,同目录 → rename 仍原子);原地覆盖补 O_CREATE;错误信息里补上 rename 的原始原因(此前真实原因被次生错误盖住,排查时完全是误导)
依赖 POSIX 目录权限的用例 Windows 上 chmod 改的是「只读属性」,没有目录写保护语义 → 三个用例退化成「正常写入」,假通过 用例没区分平台 Windows 上显式 t.Skip 并写明原因

⚠️ 并发写的最坏形态是静默数据错乱(不是报错):后写者截断重写前写者已备好的内容,
前写者随后 rename 落盘的是后写者的内容。Linux 上竞态窗口极窄(4 写者 × 20 轮 × 200KB
负载实测都撞不出来),靠概率的回归测试等于没有测试 —— 因此这里用 stagingName 钩子
把「每次写都用自己的暂存名」这条不变量确定性地钉住(把实现退回固定名时用例必失败)。

同批还修了一个停摆跳的熔断口径不对称:流式路径的 NoteBurn 曾无条件执行,
而非流式路径有 !stall.Suspect 守卫 → 同一个停摆跳,流式会把它喂进积分燃尽熔断
(把好号熔断掉),非流式不会。现在两边完全一致:停摆跳照常计费(余额要准),
但不参与熔断计账(熔断针对的是「正常消耗过快」,不是「上游行为异常」)。

八、交叉审计(第二轮)修掉的问题

第一轮修复上线后,我又跑了一轮六组交叉审计(错误分类×账号惩罚×轮转 / 停摆×轮转×计费×熔断 /
熔断×冷却×选号 / 原子落盘×面板保存 / DSML×停摆检测 / 额度解析×面板×调度器),
把审计出的真缺陷补齐:

严重度 问题 根因 修法
BLOCKER 并发刷新账号文件会静默丢内容 SaveAtomic 固定用 FilePath+".tmp" 作暂存名;两个不同的 *Auth 实例(各有自己的 mu)指向同一文件时互相踩:实测 300 轮 × 2 写者,282 次「返回成功但内容没落盘」、266 次 ENOENT 假失败 改用 internal/atomicfile(唯一暂存名 + 原子替换 + 跨平台降级原地覆盖),两处不再各写一份
MAJOR 企业额度每次解析出不同余额 上游同时给 "credit" 与 "Credit" 时「谁后覆盖谁」依赖 Go 的 map 遍历顺序(实测 200 次解析 ⇒ {0:166, 812.45:34})⇒ 面板显示 2000/2000(实际 1187)⇒ 派活后 402 按归一化键分组,组内优先取非零值 ⇒ 与顺序无关
MAJOR 额度字段类型不匹配静默当 0 上游偶尔把数值写成字符串 "812.45" ⇒ json.Unmarshal 失败就跳过 ⇒ 额度变 0,且「空 payload」守卫拦不住 quotaNumber 接受数字字符串;实在解析不了就置 badType 并报错,不装作 0
MAJOR 健康企业账号在面板上是红色报错 企业版无签到体系(上游 10001),/checkin 把解释性文案也塞进 checkin_error,而 checkin_done 恒为 false ⇒ 前端判成「没签到 + 有错误」 后端区分「跳过」与「失败」(新增 checkin_skipped,跳过态不给 checkin_error);前端 checkin_skipped/企业号+仅文案 ⇒ severity 非 err;balance_error 仍优先,真 checkin_error 仍必须是 err
MINOR 12153 两套策略 chat 路径 1 次即 Disable(终态、不自愈),scheduler 路径连续 3 次才禁用;12153 在上游是偶发的(token 恰好被别的客户端用掉、并发刷新撞车) 统一为「连续 3 次才禁用」,任意一次成功即清零 —— 与 scheduler 口径完全一致
MINOR 402 硬冷却跟部署机器的本地时区走 上游额度重置墙钟是 Asia/Shanghai,部署在 UTC+13 的机器会多冷近 5 小时 改用固定 upstreamCSTZone(UTC+8,免 tzdata 依赖);同口径先例:travel.go 的 cstZone、client.go 的 promoZone
MINOR 停摆判定缺自防御 DetectToolStall 不看正文里有没有模型原生工具调用标记(DSML 块)⇒ 上游把工具调用吐成正文标记时,工具其实调了却判成停摆 ⇒ 白白换号重试 + 污染停摆率统计 正文含标记 ⇒ 不判停摆;标记常量提到 tool_stall.go(不引用 dsml.go —— v1.13.0 基线分支没有它),dsml.go 反过来由它派生,两边永不漂移
MINOR WithFrameObserver 是单槽位 后注册的观察者静默覆盖先注册的 ⇒ 现象是「判定偶尔不生效」且毫无报错 改成观察者列表,多观察者共存,调用顺序 = 注册顺序

新增测试:internal/auth/auth_concurrent_test.go(60 轮 × 2 写者并发,断言不虚假失败、落盘是完整内容、
不留暂存文件)、internal/upstream/tool_stall_marker_test.go(含「普通正文仍然判停摆」的对照组)、
internal/upstream/sse_frame_observer_test.go(多观察者都被调用 + 透传字节逐字节不变)。
上述用例均做了反向验证:把实现退回旧写法必失败。

补充:CI 又抓出两个真 bug(第二轮)

修完上表后,Windows 专项 CI 又抓出两个「本地过、CI 挂」的真 bug——它们
恰好证明了这个 job 的价值:

现象 根因 修法
四个 402 冷却用例在 CI 全挂(本地全绿) 断言看的是 st.Until 的本地小时数,而实现已改成上游时区(CST=UTC+8);WSL 本地正好 +8 ⇒ 恰好相等,CI 跑 UTC ⇒ 差 8 小时 断言改成 st.Until.In(pool.UpstreamCSTZone()).Hour();已用 TZ=UTC / TZ=America/New_York / TZ=Asia/Tokyo 三时区回归验证
并发写账号文件在 Windows 上第 5 轮就挂 Windows 的 MoveFileEx(..., MOVEFILE_REPLACE_EXISTING) 在目标文件正被另一个句柄打开时返回 ERROR_ACCESS_DENIED(5) / ERROR_SHARING_VIOLATION(32);两个写者并发替换同一目标时这必然会撞上(不是罕见故障)⇒ rename 失败 → 原地覆盖也失败(目标正被独占)⇒ 一次本该成功的写变成失败 rename 遇到「目标侧临时占用」类错误时短暂重试(40 次 × 5ms)让另一个写者先完成;重试仍失败才降级原地覆盖。非 Windows 平台行为完全不变(POSIX rename(2) 在目标被打开时不会失败,EXDEV/EBUSY 是结构性问题、重试无意义 ⇒ isTargetBusyErr 恒 false)

这两个 bug 在此前的所有 CI(只跑 ubuntu-latest)里永远不会出现。

九、交叉审计(第三轮)修掉的问题

前两轮修的是已知 issue;这一轮是交叉审计(让审计子代理在两个分支上实跑、
构造对抗用例,把每一条修复路径单独拎出来看「同一个上游错误在两条路径上是否同口径」)。
它抓到的问题不来自任何 issue,全是「修 A 引入/遗留 B」的结构性缺陷 —— 这类
问题在 issue 列表里永远不会出现,只能靠交叉审计。

9.1 审计抓到的问题与修法

级别 现象 根因 修法
BLOCKER 流式路径把 11-128 当成 ErrClient ⇒ 喂连败计数 ⇒ 5 次就把健康号降权出池 10 分钟 上游用「HTTP 200 + SSE error 帧」回报错误({"error":{"code":11128,"message":"request illegal"}}),而 FrameKind 只把 error.message 交给 Classify ⇒ 业务码丢失。11-128 与 11140 的判据都在业务码上,文案「request illegal」是两者共用的 FrameKind 把整个 error 对象搬成业务信封(code 提到顶层、message 映射成 msg、displayMsg 等原样保留)再交给 Classify ⇒ 流式与非流式口径完全一致
MAJOR 11-128 + displayMsg 里有审核词 ⇒ 整个网关对所有账号、所有模型降级到次日 00:00 CST 上游把通道校验与内容审核塞进同一个业务码,只靠文案区分;旧实现按整 body 扫文案,命中 blocked by security policy 就归 ErrContentBlocked ⇒ handler 执行 h.degrade.Trigger() 只看主文案字段(body.msg / error.message),不扫 displayMsg/displayTips;判不准时偏向通道校验(代价不对称:误判通道校验 = 有界重试零惩罚;误判内容审核 = 可能整网关整夜降级)
MAJOR 11140 的模型侧限流否决只认三条逐字句 ⇒ 上游改一个字就把可自愈的限流翻转成永久禁用 判据是逐字句而非语义家族 判据改为语义家族(modelRateLimitFamily,覆盖中英文与常见变体)
MAJOR 11140 但文案不含英文原句 ⇒ 真封禁既不禁用、又喂连败 文案判据只认 "request illegal" 一句 改为 accountRestrictedMsgFamily(明确说「账号被限制/封禁」的措辞);刻意不收「限流/用量」类宽词(那些词真封禁文案里也有)
MAJOR HTTP 429 带 11140 ⇒ 一次限流高峰就永久禁用一个健康号 11140 的封禁分支排在所有 429 判定之前;分类口径不变但惩罚强度没有按状态码分级 分类口径不动(保持与上游既有契约一致),改在惩罚侧分级:429 只软冷却(可自愈),其它状态码才硬禁用
MAJOR 非流式路径把「工具其实调了」的回合误判成停摆 ⇒ 白烧一次上游调用 + 白解绑一次粘性会话 同一个事实在两层用了两套判读方式,而动态类型不同:Aggregate 产出的 message.tool_calls 是 []map[string]any,JSON 反序列化的 body 里是 []any 新增 internal/upstream/toolcalls.go 把判读收成一个函数(HasStructuredToolCalls / MessageHasToolCalls / ToolCallList),停摆检测与 DSML 守卫都改调它。刻意不依赖 dsml.go —— v1.13.0 基线分支没有它
MAJOR DSML 修复器用「正文标记还原出的调用」覆盖真实 tool_calls ⇒ 调用参数被改写(实测 Hangzhou → Beijing) 同上(守卫只认 []any) 同上
MINOR 熔断号在面板两处被计为「健康 / 可服务」 熔断是选号层面的排除,但聚合口径没跟上(statusOf 本来就显示 CoolKind="burn") countsDetailedForRealm 把 burnBlocked 归入 cooling 桶;modelview.go 的 Servable 计数排除熔断号
MINOR 面板「解冻」/「删号重加」都不清熔断 ⇒ 运维点了恢复,号依然选不出来,且没有任何提示 Revive / Remove 不清 burnGuard 状态 一并清掉。面板「恢复账号」的语义本就是「运维口径无条件恢复」
MINOR ErrAccountFault 软冷却的 reason 写死 "account fault (14017)" ⇒ 运维按错误方向排查 实际业务码不一定是 14017 reason 带实际业务码(新增 upstream.BusinessCodeOf)
INFO 裸 "request illegal"(无业务码)落 ErrClient ⇒ 5 次连败就把健康号降权出池 ErrClient 是唯一会喂连败计数的分支 归 ErrChannelRefused(不罚号、继续轮转)。三个可选口径里最轻且最可自愈;且上游后来把同一句文案升级成带 11-128 业务码的形态 ⇒ 无码形态极可能是 11-128 的旧版
INFO 上游一旦把业务码换到别的键名(errCode/errorCode/bizCode)⇒ 11-128 与 11140 同时失明 hasBusinessCode 只认字面键名 code 认一组常见别名

9.2 两条贯穿全局的原则

这轮的 12 个缺陷收敛成两条原则,也是本 fork 反复被审计验证的设计基调:

  1. 同一个上游错误,在任何一条路径上必须同一个分类。
    流式与非流式、HTTP 状态码与 SSE 帧、chat 路径与 scheduler 路径 —— 口径
    不一致的后果是「同一个错误,一个路径罚号、另一个路径不罚」,运维完全无法
    从面板反推发生了什么。

  2. 请求级问题不得升级成账号级 / 全局级处置。
    通道校验失败(请求形状)不罚号;模型侧限流(几分钟自愈)不禁用;一次请求级
    的形状问题不得触发整个网关整夜的降级。代价不对称永远朝「轻罚」倾斜。

9.3 验证

  • 新增 internal/server/audit_wave3_test.go(15 个用例)、internal/upstream/toolcalls_unify_test.go(7 个)、
    internal/pool/burnguard_visibility_test.go(4 个)。
  • 每一组都做了反向验证:把实现退回旧写法,对应用例必须失败 —— 逐项实测过
    D2 / D3 / D4 / D5 / D6 / D7 / D10 / D11 / D13 / D14 共 10 组,全部非空洞。
  • go build ./... / go vet ./... / go test ./... 全绿(18 个包)。

这条分支与 PR #1 的区别只有基线。

用户实际运行的是 1.13.0 发行版,所以本分支才是「以这个版本的代码为准去修」的产物。修复集与 PR #1 相同(同一套补丁重放到 v1.13.0 树上)。

为什么 base 不直接用 main:main = v1.13.0 之后的 9 个提交,而本分支故意不含那 9 个里的 DSML 功能层,两边天然冲突,PR 会显示 mergeable: false。改用 base/v1.13.0 后本 PR 干净可合并(mergeable_state: clean)。

十、交叉审计(第四轮)修掉的问题

第四轮审计把范围从「多组修复之间的相互干扰」扩到运行时契约与启动级数据风险,
抓出 3 个此前几轮没覆盖到的缺陷。它们都不是「某个功能写错了」,而是跨层契约被
悄悄破坏
:上游信封的字段口径、观察者拿到的对象身份、以及进程被强杀时磁盘上的
字节状态。

严重级别 缺陷 后果 修法
MAJOR Q6.1 「额度未知」被当成「额度为 0」:pool 里「从没读到过上游余额」与「读到 0」是同一个值(credits=0) 配了 credit_floor>0 的部署会静默挡掉所有新账号的收费模型:号在池里、面板显示「余额 0」、但一个请求都接不到,没有任何日志解释为什么 新增 entry.creditsKnown:只有成功读到过权威余额(SetCredits / SetCreditsSnapshot / ReenableIfCredits / reviveCoolingLocked)才置 true;floorBlockedForRealmModel 在 creditsKnown=false 时不套用 credit_floor;状态文件持久化 credits_known,旧状态文件用「正余额证明读到过」推断、credits=0 保守视为未知(方向是「不静默挡号」);面板显示「未知」而不是「0」,积分汇总只累加额度已知的账号
MAJOR Q5.3 「观察者只读不改对象」只对同步读取成立:逐帧观察者拿到的就是同一个 map 引用,修复/规范化阶段会就地改写 delta.content 与 finish_reason 任何把帧对象存下来的观察者事后读到的都是被改写后的状态 ⇒ 停摆判定读到的是修复后的内容 交给观察者的是深拷贝(cloneFrame),观察者拿到的永远是「上游原样」;frameObserver 单槽位 → frameObservers 列表(多观察者共存);error 帧同样走深拷
MAJOR Q4.5 半截配置拖垮启动:降级路径(rename 不可用时的原地覆盖)是 O_TRUNC 覆写,非原子 —— 进程在写一半时被杀 ⇒ 磁盘上留下半截 config.json Load 解析失败 → main.go log.Fatalf ⇒ 服务起不来;且旧值已被覆盖、无法找回 三层兜底:① 覆盖前先留一份 .bak 备份(上一份完整配置);② 启动时主配置解析失败 → 自动回退到 .bak 并打 WARN,服务照常起来;③ .bak 也没有 → 明确报错并给出恢复路径(而不是裸 JSON 错误)。绝不拿半截配置启动

10.1 这一轮补上的两处「断言写错方向」

审计过程中还发现两个测试本身是空洞的(断言方向写反,永远通过):

  • TestCloneFrameHandlesNonChoiceFrames 原来改的是副本自己(改不到源,永远通过),
    现在改源并断言副本不变 —— 浅拷贝会当场失败。
  • 断言必须显式钉时区:402 冷却到期时刻的用例在本地(+8)恒过、在 CI(UTC)恒挂。
    已用 TZ=UTC / TZ=America/New_York / TZ=Asia/Tokyo 三时区回归验证全绿。

10.2 关于 v1.13.0 基线的一个说明

Q5.3 的断言在本分支上不构造「会被 DSML 修复层改写」的夹具 —— 因为 v1.13.0 基线
没有 DSML 层(internal/upstream/dsml.go 不存在)。本分支改为直接钉住契约本身:
cloneFrame 必须是深拷贝(delta / finish_reason / error 三处都隔离),
观察者拿到的帧必须与上游原始帧一致。这比「用某个具体修复层触发改写」更贴近契约本身,
也不依赖基线里没有的那一层。

10.3 验证

  • 新增 internal/pool/credits_known_test.go(未知不被挡 / 读到 0 仍被挡 / 三个权威读取
    入口都置位 / 状态往返持久化 / 旧状态文件按未知处理)、
    internal/upstream/frame_observer_clone_test.go(4 个)、
    cmd/server/config_backup_test.go(4 个)。
  • 反向验证:撤掉 creditsKnown 判断 ⇒ 用例失败;撤掉 cloneFrame 的 error 深拷 ⇒
    cloneFrame 未深拷 error 对象: map[error:map[code:99999]];撤掉 .bak 回退 ⇒
    Load 解析失败。
  • go build ./... / go vet ./... / go test ./... 全绿(18 个包),CI 的 test 与
    test-windows 两个 job 均通过。

十一、致谢

DSH Agent and others added 28 commits October 20, 2026 13:32
本分支基线严格为上游发布标签 v1.13.0(d66384d),内容为 37 项修复
(issue linguo2625469#163 linguo2625469#160 linguo2625469#179 linguo2625469#222 linguo2625469#155 linguo2625469#169 linguo2625469#229 linguo2625469#227 linguo2625469#81 linguo2625469#197 linguo2625469#188 linguo2625469#231 linguo2625469#145
linguo2625469#171 linguo2625469#137 等)的重放,不含 v1.13.0..6b31db6 的 9 个上游后续提交
(DSML 原生 tool_call 修复、prompt_cache_key 会话回退、空 tool_call
name 剥离、到期卡片面板改版)。

冲突解决(5 个文件):
- internal/upstream/tool_pairing.go:取本次修复的 import 块(fmt),
  移除上游 535e19e 的 nameBadIDs / toolCallNameEmpty 机制(上游后续
  功能,非本次修复);linguo2625469#81 重复 tool_call id 改名逻辑完整保留。
- internal/panel/app.js:把 76c3bdb 的到期快照语义移植到 v1.13.0 的
  app.js(renderExpirySnapshot / snapshotNoteText / expiryRowsHtml
  共用渲染 / loadOverview 刷新钩子);丢弃上游 8391a05 的
  loadPackages(force) 改动,保留 v1.13.0 的 loadPackages()。
- internal/panel/frontend_test.go:保留上游 0ab6690 的两个到期快照用例
  与本次修复的「快照与检查逐字节一致」「节奏标注」用例。
- internal/upstream/sse_test.go:保留本次修复的
  TestUserResourceSnapshotDeductionEndTime,丢弃上游 7595a0a 的 DSML
  用例块(v1.13.0 无 internal/upstream/dsml.go,无法编译)。
- internal/server/handler_test.go:保留本次修复的
  TestChatHiddenModelRejected / TestModelsHiddenFiltered,丢弃上游
  7595a0a 的 DSML 用例块(v1.13.0 无 dsml.go)。
…25469#184)

源分支 fix/all-known-issues-v1.13.0 在本分支构建期间新增了 311c617
(issue linguo2625469#184:6021 快速失败时把上游的 Retry-After 写回响应头)。该提交与本
分支无冲突,直接移植过来,使 v1.13.0 基线分支与源分支保持同一修复集。

- internal/server/handler.go:ErrUpstreamBusy 分支回 503 upstream_busy 时,
  若上游给了 Retry-After / retry-after-ms / x-ratelimit-reset,按秒向上取整
  后写回响应头;上游没给就不写(宁缺勿滥)。
- internal/server/upstream_busy_retry_after_test.go:钉住「给了就透传」与
  「没给就不编造」两个用例。
调整官方的go下载源为国内的,避免因为使用官方源导致无法下载的问题。

(cherry picked from commit 33f2a33)
没有 /etc/resolv.conf 的环境(Android、精简容器)里 Go 解析器会退回 [::1]:53,查任何域名都变成连本机 53 端口。新增 dns_servers 配置与 WB2A_DNS 环境变量,只换两个 http.Client 的 Transport.DialContext,空值维持系统解析器。

(cherry picked from commit 92c16fd)
在 upstream 客户端的余额查询出口挂观察者,每次真实查到余额与上次比对留痕;
新增 internal/credithist 账本(原子落盘、重启续接、上限 2000 条),面板新增
GET /panel/api/credit_history,前端在运行日志视图下展示。

Closes linguo2625469#137

(cherry picked from commit 37f5939)
≤760px 生效,实现全部在自有文件 mobile.css / mobile.js 里,index.html 只多一行 link、一行 script;>760px 逐像素不变。

(cherry picked from commit 25378f5)
cherry-pick of upstream PR linguo2625469#104 (e761ce5), adapted to this branch.

Upstream change: persistent background task job manager (internal/panel/taskjobs.go),
task ring buffer, rewritten auto-task state machine (executeAutoTask/claimAutoTask),
credit sub-cent remainder accounting (internal/pool), diff-based restart-required
field detection, serialized config saves.

Branch-specific adaptations (conflicts resolved keeping both intents):
- internal/panel/autotask.go: kept branch's SupportsBehaviorEvents() 422 gate
  alongside upstream's IsGlobal() 501 gate; kept branch's progress-only
  taskProgressReached criterion (Current>=Target) so upstream's broader
  accept_status=="completed" check cannot mis-claim rewards that were not
  actually credited (branch issue linguo2625469#174).
- internal/panel/taskcenter.go: moved branch's SupportsPersonalGrowth() gate
  into scanAccountGrowth; kept branch's enterprise-switch session warning.
- cmd/server/main.go: saveConfig keeps branch's atomicfile.Write fallback
  dirs and upstream's serialized-save mutex + running-config baseline;
  restartRequiredFields switched to upstream's diff-based form and extended
  with branch's cn.chat_base / server.read_timeout.
- internal/pool: kept branch's Status/paused fields alongside upstream's
  credits_remainder accounting; upstream's stale SetCreditsDetailed copy
  replaced by branch's SetCreditsSnapshot with creditsRemainder reset.
- internal/panel/app.js: noGrowthUI hides the check-in button for global
  accounts; branch's credit-history rendering kept.
- internal/scheduler: kept IncludeDisabledInTasks.
- Tests updated: branch's autotask_claim_test.go now expects upstream's
  done/claimed state machine; frontend_test.go stub gains cfgKey node for
  upstream's env-managed API key field; strategy_config_test.go and
  cn_chat_base_test.go adapted to the new saveConfig/restartRequiredFields
  signatures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 9676e44)
linguo2625469#104 与分支在两处语义冲突,本次合并把两边意图都保住:

1) internal/panel/tasks.go 读路径去掉分支的 422 全局拒绝(issue linguo2625469#163),
   改用上游 linguo2625469#104 的只读语义:照常返回列表,但 capabilities 的
   accept/claim/automate 全置 false 并附 globalTaskWriteMessage。
   linguo2625469#104 同时把国际版任务编号归一(task_code / code 双写 + task_id),
   正好修掉 linguo2625469#163「只认 task_code 导致编号为空、按钮点了没反应」;
   写路径(accept / accept_all / claim)仍是 501 + globalTaskWriteMessage。

2) internal/server/handler.go 的 usageCreditTotal 保持合并后的分列判定:
   credit 必须显式/有限/非负,total_tokens 必须为有限正整数,但两者
   **各自独立**判定——「有 credit 无 total_tokens」仍记 credit(token 口径记 0),
   这是分支 issue linguo2625469#179 / linguo2625469#181 的修复(上游 linguo2625469#104 单独实现会把这类响应整条丢掉,
   导致「积分扣除历史」系统性少记)。
   上游 linguo2625469#104 自带的测试表按上游口径断言 invalid total ⇒ ok=false,
   随之改写为合并口径(新增 wantTotal 字段)并加注释说明两套判据的来源。

(cherry picked from commit c5db9c4)
- Search/SearchHeaders/doSearch/ClampSearchMaxResults 四个入口
- 请求体对齐官方 buildRequestBody(query/type/max_results,字段顺序逐字节一致)
- 请求头复用 ChatHeaders 后覆盖四项:Accept、Content-Type(含 charset)、
  X-Agent-Intent=craft、X-Agent-Runtime=other
- 独立响应解析:上游搜索返回扁平结构,不能套 {code,msg,data} 信封
- 7 个单测覆盖请求形状、扁平响应、条数收敛、短 query 拒绝、业务码与 HTTP 错误分类

(cherry picked from commit 58caff1)
(cherry picked from commit e32b43f)
- Anthropic Messages 兼容形态:DSH 等客户端的搜索 provider 按 ${baseURL}/messages
  发请求,网关把 baseURL 指到 <网关>/v1/search 即可零代码接入
- 刻意不占用 /v1/messages:那是 Anthropic 模型对话标准路径,留给未来的 chat 转发
- 只服务搜索:未声明 web_search 工具的请求直接 400,不把用户消息当搜索词
- 搜索词从 user 消息提取并剥离官方固定前缀 Perform a web search for the query:
- 响应按官方契约组装:web_search_tool_result 承载 url/title,摘要走 text block 的
  citations[].cited_text(Anthropic 的 web_search_result 本身不带摘要)
- 轮转内核 runSearch 复用账号池选号/在途租约/token 预刷新/applyErrorPolicy/退避,
  与 chat 共享同一套账号健康语义
- 7 个单测:响应契约、字符串型 content、未声明工具拒绝、入参校验、鉴权、
  上游错误形态、路由不遮蔽 /v1/messages

(cherry picked from commit 4f31005)
(cherry picked from commit 4369082)
此前 reqlog 只挂了一个硬编码路径 /v1/chat/completions,搜索端点完全绕过记账——
搜索同样消耗账号额度、同样会失败,却在面板「请求记录」里不可见。

- handler.go:新增 tracedRequest() 把搜索端点纳入记账;新增 isSearchPath() 判定
- logging.go:requestTrace 增加 path/model/account 字段,event() 的 Path 改用真实
  请求路径(原先硬编码成 chat 路径);新增 requestTraceFromContext()(runSearch
  只持有 ctx,没有 *http.Request)
- search.go:选中账号后写入 trace.account;搜索流量的「模型」列标注为
  WorkBuddy搜索工具,避免该列留空无法区分流量类型

验证:data/request-logs/*.jsonl 中搜索请求落账,含账号/状态/耗时/模型标识

(cherry picked from commit 0d32090)
(cherry picked from commit c2b9801)
- 核心能力表新增「联网搜索」一行
- 验证章节补充 /v1/search/messages 的 curl 示例
- API 端点章节补充完整契约:请求/响应结构、web_search 工具声明要求、
  摘要走 citations 的说明,以及上游三个静默失效参数不要暴露给用户的警告

(cherry picked from commit 1ea6969)
(cherry picked from commit 1a77a2b)
- queryID 由 query 哈希派生,导致同一 query 的重复搜索返回相同 msg id 与
  tool_use_id:违反 Anthropic 协议「id 标识一次工具调用」的语义,且 32 位哈希
  可能碰撞。改为每次随机 16 hex(熵源故障时回落 时间纳秒 XOR 自增计数器)
- extractMessagesQuery 拼接整段 messages 历史:客户端带多轮上下文时,历史问句
  会连同固定前缀一起被当搜索词送上游(前缀因不在开头而剥不掉)。改为只取最后一条
  带文本的消息;同一条消息内的多个 text block 仍拼接(该形态合法)
- searchFailure.code 在移除 /v1/search 后无任何读取点(死字段),一并删除
- runSearch 内两段注释错位(meta 复用说明挂在记账代码块上),修正归属
- 新增 2 个回归测试锁住上述行为

(cherry picked from commit ee9db99)
(cherry picked from commit b1afd25)
两处收尾:

1) 移植上游 20254d8 的 convID = sessKey 回退(上游 v1.13.0 之后的 9 个提交之一,
   但这一条是被显式豁免的例外):不带 conversation_id 的客户端(Anthropic /
   Claude Code 入站流量)回退到已解析的会话键 sessKey,否则上游 prompt_cache_key
   退化成「每账号一个常量」,同账号下所有客户端共用一个前缀缓存槽,互相覆盖,
   前缀命中不稳定、常整段 miss(issue linguo2625469#169 / linguo2625469#229)。只在 conversationID 为空且
   sessKey 非空时回退——sessKey 为空时保持空串,与 v1.13.0 原行为一致。

2) gofmt -w internal/server/logging.go(上游 linguo2625469#104 带入的 struct tag 对齐偏差)与
   internal/server/messages.go(linguo2625469#213 带入的字段对齐偏差)。两处均为纯空白改动,
   不改任何行为。gofmt -l 现回到 7 个白名单内的既有欠账文件。
 建议 1/3)

建议 1(取整方向):企业 credit 是浮点已用量,池内取整数改用 **ceil** 而非
四舍五入。语义是「还剩多少额度」,向上取整是保守方向——宁可少报不可多报:
多报会让调度器以为账号还有额度而继续派发,实际打过去就是 402;少报只会让
调度器提前换号,不造成 402。副作用:used 恒 ≥ 真实已用量,remain 恒 ≤ 真实
剩余,浮点误差不会让剩余虚高。

建议 3(data 判空):上游偶发 `data: null`(个人接口 Accounts: null 同源)。
json.Unmarshal(null, &resp) 不报错且 resp 全零 → remain=0/total=0,表现为
「额度 0,但账号健康」,与「接口偶发失败」无法区分,排障只能靠猜。现显式判空
并返回 error,让上层显示「查询失败」。

测试:TestEnterpriseResourceUsedRoundsUp(7 子用例,含 0.1/0.5/0.9 均向上、
整数不加一、超额钳零)、TestEnterpriseResourceNullData(data:null 与缺 data
两种形态都必须返回 error)。原有 TestEnterpriseResourceMapsAllocatedQuota 的
期望值随取整方向更新(1188 → 1187)。
三处测试钉的是旧的四舍五入口径(round(812.45)=812 → remain=1188)。
企业 credit 是浮点已用量,改用 ceil(issue linguo2625469#222 建议 1)后 used=813、
remain=1187。断言值与注释同步更新,并写明取整方向的理由。
受影响:internal/panel/apikey_test.go、internal/scheduler/apikey_gate_test.go、
internal/scheduler/enterprise_gate_test.go。
cherry-pick 自 654b55d(fix/all-known-issues-v1.13.0),按 1.13.0 基线适配。

适配说明(1.13.0 基线**没有**标记修复层 internal/upstream/dsml.go):
- internal/server/handler.go:只引入停摆判定用的 DeclaredToolNames(不含
  合并名单 declaredTools —— 那是标记修复用的,1.13.0 上不存在)。
- internal/upstream/sse.go:只引入 frameObserver 旁路观察者 + WithFrameObserver
  选项;不引入 markup / WithMarkupRepair。
- internal/upstream/tool_stall_test.go:原用例比对「合并名单 vs 停摆名单」,
  1.13.0 上没有合并名单 → 改写为直接钉住「历史 tool_calls 不算本轮声明 +
  本请求声明的才算」这条语义,覆盖面等价。

停摆语义与主分支完全一致:请求声明了 tools、模型只回正文并以 stop 收尾
(工具一次没跑,客户端按「正常完成」收尾 → 任务停在原地)。判定只按
「零 tool_calls」,绝不按正文长度。处置**不罚账号**(上游行为缺陷,不是账号
问题),只换号再问一次;账本记账放在重试之前(停摆那一跳真的花了钱)。
…建议 3)

复审发现上一版判空不完整:只挡了 `data: null`,`data:{}`、缺额度字段
仍然是「额度 0 但账号健康」——正是这次要消灭的症状。

判空口径从「data 是不是 null」升级为「**额度字段一个都不存在**」:
- 企业口径:credit / limitNum / limit_num / used_num 四个字段一个都没出现
  → 判空。
- 关键:按**字段存在性**判定,不是按数值。企业号额度真的用光时上游返回
  credit=0 / limitNum=0,那是**合法态**(余额 0 是真的),不该报错;上游没给
  额度信息是**故障态**。两者混同正是旧症状的根源。

个人口径同源现象一并修掉:`data:{"Response":{"Data":{"Accounts":null}}}`、
`data:null`、缺 Accounts 字段都判空报错。空数组 `[]` 仍是合法态(这个号没有
资源包 → 余额 0 是真的)。旧口径下这些形态会把账号余额抹成 0——在配了
credit_floor 的部署里,该号所有收费模型全部选不到(一次空响应就静默出局)。

面板可观测性:checkinResultNote 把 balance_error 的判断**提到** checkin_done
之前。企业账号没有签到体系(checkin_done 恒为 false、checkin_error 是固定的
解释性文案),原先的顺序让用户永远只看到「签到未完成:企业账号无签到体系…」,
看不到余额查询真正失败的原因——判空引入后这个原因变得重要。

测试:internal/upstream/enterprise_resource_test.go(空 payload 判空 + 额度
为 0 属合法态)、internal/upstream/personal_resource_test.go(个人口径 5 种
空形态 + 空数组合法态)、internal/panel/task_frontend_test.go(balance_error
优先展示)。已验证回退即 FAIL。

(cherry picked from commit 9d93125)
issue linguo2625469#192 复审发现的附带缺陷:添加静态 API Key 时,为探测额度构造的 probe
只带 AccessToken、不带 UID:

    probe := &auth.Auth{AccessToken: key}

而余额观察者(credithist.Ledger.Observe)以 uid 建基线,**uid 为空的观测会被
直接丢弃**("纯空白 uid 与空 uid 一样无效:放进去只会留下一个面板永远筛不到
的幽灵基线")。于是静态 Key 账号的额度变化永远进不了积分历史——面板「积分历史」
对它是一片空白,看起来像"额度从没变化过"。

注意额度数据本身是对的(静态 Key 无个人账号身份,探测走企业口径端点,
UserResourceSnapshot 已按 auth.UsesEnterpriseBilling 分流);缺的只是 uid。

修法:probe 带上已解析好的 uid(uid 在调用点上方就已算好)。

(cherry picked from commit 3927cbc)
issue linguo2625469#222 建议 3 的判空实现里有一处自相矛盾:判空用的是 map 精确键匹配
(credit / limitNum / limit_num / used_num),取值用的是结构体 +
encoding/json(字段名**大小写不敏感**)。两处口径不同会同时出两种错:

1. 上游把 credit 改成 Credit → 判空说「没额度字段」→ 一个**正常响应**被误报
   成「上游没给数据」,全线企业号额度查询失败(面板显示查询失败,账号看着健康);
2. 反过来,判空若放宽成大小写不敏感而取值仍走旧结构体,「usedNum」这种
   非标准键会判定说有额度、数值却读成 0 → remain=0,表现为「额度 0 但账号健康」
   ——正是 linguo2625469#222 要消灭的那个症状。

改成**一处解析、一处判空**:新增 parseEnterpriseQuota,按归一化键
(去下划线 + 小写)读值并同时给出 hasQuotaField,判空与取值天然同口径。
credit / Credit / CREDIT、limitNum / limit_num / LIMITNUM、usedNum / used_num
全部等价。

配套:
- enterpriseResource 改用 parseEnterpriseQuota,周期时间也从同一份解析结果取
  (cycleEndTime / cycleResetTime 同样归一化)。
- 删掉旧的 enterpriseQuotaFieldsPresent(口径不一致的根因)。
- 新增测试 TestEnterpriseResourceFieldNameCaseInsensitive /
  TestEnterpriseResourceSnakeCaseUnderMixedCase /
  TestEnterpriseResourceMixedCaseUsedNum;回退实现即 FAIL(非空验证)。
上游流式响应在工具调用 name 分片到达前异常结束(200 空流也算)时,
聚合器按占位符产出 name:"" 的条目,客户端把它写进会话历史。下一轮
原样转发,cleanupOrphanToolCalls 现有的按 id 对称裁剪对它不设防——
带配对结果的脏条目命中 keepCalls 原样出站,被上游直接判

  400 {"code":11133,"extError":{"code":"model_param_invalid"}}
  tool_calls[i].function.name must be a non-empty string

整条会话从此每轮 503 no_healthy_account,只能手动清空历史。

修复保持最小干预:收集阶段照常登记 callIDs(保住 hasTraffic 语义,
否则"空 name 且无结果"这种本可被孤儿规则删掉的形态会漏出站),
只在 keepCalls 一处否决空 name 条目,删除动作完全复用既有的两侧
对称裁剪;assistant 消息本身保留,与 mergeAdjacentToolCalls 的
合并前提不冲突。

判定精确收窄为「function 对象存在、name 字段存在且为空白字符串」:
缺 function / 缺 name 字段的占位条目不判脏(上游不因此拒参),
与既有「空 id 双侧不识别」用例同口径,不扩大打击面。

新增 5 个钉桩用例(带结果成对剔除 / 无结果仍走孤儿路径 / 纯空白判脏 /
批内混合只剔脏项 / 缺字段不判脏),既有全部测试零改动通过。

(cherry picked from commit 535e19e)
…25469#137)

v1.13.0 的 go('accounts') 会直接 loadExpiry() → 逐账号实时查上游。账号多时
这个请求会把面板卡住(上游 issue linguo2625469#145 报的就是「打开账号池没反应」)。

两处改动(与上游 8391a05 同思路):
- go() 不再在切到账号池时自动 loadExpiry(),卡片用首页统计里的过期快照渲染
  (零上游请求)。完整的逐包明细由卡片上的「检查」按钮显式触发。
- loadPackages(force) 改为有缓存先直接渲染,不再每次切进「积分构成」就打一次
  上游;「刷新」按钮始终实时查(显式 force=true,不依赖事件对象真值)。

内部/panel/app.js 现在与主分支逐字节一致。
停摆检测(声明了 tools 却零 tool_calls 且 stop 收尾)本身的口径没问题,但
「换号重试」这条主线在默认部署下是空转的,而且会把本可返回的正文变成 503。
本提交修掉敌对复审实测出的 6 项:

A(BLOCKER)换号是空转:停摆分支只 releaseHeld()、不解绑粘性 → 下一轮
  PickByUIDForModel(stickyUID) 无视 tried 又选中同一个停摆号(实测 u1=2
  次、u2=0 次;关掉粘性才是 u1=1/u2=1)。改用 fail():它只做「释放租约 +
  解绑粘性」,不含任何惩罚语义,与「绝不罚号」不冲突。
B(MAJOR)正文被丢弃 → 503 no_healthy_account:单账号池必现。停摆那一跳
  的响应现在存为 fallback,重试预算用尽或池里选不出第二个号时**原样返回**
  (200),不再让客户端把「模型给了答案」误读成「服务端没号可用」。
C(MAJOR)正常收尾轮被误判:真实 agentic 客户端每轮都重发同一份 tools
  声明,于是「最终答案 + stop + 零 tool_calls」也满足表面条件。新增判定
  条件「会话历史里还没有工具结果」——历史里有结果说明工具跑过了,本轮
  就是最终答案。另加「tool_choice 点名要调某个函数」→ 确定停摆,不看历史。
D(MAJOR)流式与非流式判定相反:非流式先跑标记修复再判定(不判停摆),
  流式观测原始帧(判停摆)。流式侧新增 MarkRepaired:修复器还原出过调用
  说明上游确实调了工具 → 不判停摆。两侧口径对齐。
E(MINOR)停摆跳喂了积分燃尽熔断,把「模型这轮没调工具」记成「这个号在
  烧积分」→ 熔断器误伤健康号。停摆跳仍然记账(余额要准),但不参与燃尽
  熔断计账。
F(MINOR)停摆重试吃掉了 MaxRotate 的换号预算(实测跳 1、2 停摆,跳 3 真
  429 → 客户端直接吃 429,没人再救)。停摆重试改用独立配额,不消耗错误
  恢复预算。
I(INFO)截断被当成停摆:上游没发 [DONE] 就 EOF 收尾时,工具调用帧可能
  还没到就被切断。Aggregate 现在标 _truncated,流式侧新增
  WithStreamEndObserver,两侧都排除截断。

新增回归测试(覆盖首轮实现的四处盲区):粘性下真换号、单账号仍返回正文、
多轮正常收尾轮不判停摆、截断不判停摆、修复过的不判停摆、停摆不吃错误恢复
预算(回退即失败,已逐项验证非空转)。

(cherry picked from commit 21d8114)
零 tool_calls 停摆是**上游行为质量**指标,不是账号问题(那一跳既不冷却也不
禁用),但它意味着「那一轮任务没推进」——工具一次没跑,客户端却按「正常完成」
收尾。复审指出:reqlog 里已经有 tool_stall / tool_stall_text 字段,但面板
**完全不展示**,停摆率只能从日志/接口里捞,运维看不到。

- internal/panel/app.js:请求表的结果列在 outcome 标签旁挂一个「停摆」角标
  (reqStallTag,命中才有,未命中的行输出空串——不能给所有行都挂);行 tooltip
  (requestLogText)里补一行「零 tool_calls 停摆(正文 N 字符)」;统计条
  (renderRequestMetrics)在有停摆时显示「· 停摆 N」。
- internal/panel/frontend_test.go:新增 TestAppJSRequestStallTag(node 执行
  app.js 切片,断言命中行带「停摆」与正文字数、未命中的三种形态
  (tool_stall=false / 字段缺失 / null)都输出空串)。

不改任何判定逻辑:停摆的检测口径、零罚号纪律、换号重试语义都在 21d8114 里。

(cherry picked from commit 4934e6e)
问题:burn guard(issue linguo2625469#229)与零 tool_calls 停摆重试(issue linguo2625469#149)都是**新增
的配置项**,但 config.example.json 与 README 的字段速查表里都没有 —— 用户
从 1.13.0 升级后看到的是「代码里支持、文档里没有」的配置,只能去翻源码。

- config.example.json:pool 段补 burn_guard_limit / burn_guard_window /
  burn_guard_cooldown / burn_guard_cooldown_max(含一行 _comment_burn_guard
  说明语义与「0 = 关闭」)。upstream 段的 tool_stall_retry 之前已加。
- README.md 字段速查表补两行:
  - pool.burn_guard_limit:窗口内累计烧分超限 → 该号熔断冷却;并写明
    **停摆那一跳不参与熔断计账**(上游行为缺陷 ≠ 账号在烧钱)。
  - upstream.tool_stall_retry:停摆时换号重试,永不罚号,预算用尽原样返回
    上游那一跳的响应;0 = 只观测不重试,上限 3。

不改任何行为,只补文档与示例。
问题:README 的「快速开始」只指向上游 GHCR 镜像
(ghcr.io/linguo2625469/workbuddy2api-panel:latest)—— 那个镜像**没有**本分支
的修复(11-128 误禁号、6021 被当限流冷却等都在里面)。照着 README 部署等于
拿到未修复的版本,而用户根本不会知道有两条修复版分支。

- 新增「方式〇之二:本地构建」:clone fork → checkout 修复分支 → docker build。
- 写明两条分支的区别(fix/all-known-issues-v1.13.0 = 上游 main + 修复;
  release/v1.13.0-fixed = 精确 1.13.0 发行版 + 同一套修复),并点明上游镜像
  仍是未修复版本。
从 reporter 分支(misakano7545/workbuddy2api-panel)移植三条协议/检索能力,
并按本仓语义逐条改造:

1) POST /v1/responses(Codex Responses 协议)
   新增 internal/server/responses.go:入站 Responses 请求 → 内部 chat 请求,
   回程 chat 响应 → Responses 事件流(response.created / output_item.delta /
   response.completed),含 custom(freeform) 工具、namespace 工具组、
   托管 web_search。与 /v1/chat/completions 共用同一 chatCompletions 内核,
   故错误策略(11-128 轮转 / 11140 封禁 / 6021 503)完全一致。
   GET /v1/responses 回 426,让 Codex 客户端立刻回落 HTTPS。

2) POST /v1/messages(Anthropic Messages 原生入站)
   新增 internal/server/messages.go:Anthropic 消息/块结构 ↔ chat 互转,
   官方 SDK 的 x-api-key 头提升为 Bearer(withAnthropicAuth)。
   声明了 web_search 服务端工具时,网关侧执行(探测 → 检索 → 注入结果)。
   旧的 /v1/search/messages 搜索壳改名 searchMessages(文件改名
   search_messages.go),两条路由并存、互不遮蔽。

3) POST /v1/search(官方 agenttool 检索透传)
   新增 internal/server/search_agenttool.go:回程原样透出上游 JSON,
   deep=true 走综述式 agentic_search(新增 internal/upstream/agentic.go)。
   upstream 侧新增 SearchRaw/doSearchRaw:与既有 Search 共用请求构造与错误
   分类,但不做 min-length 校验、不加工结果(透传端点要「上游给什么回什么」),
   且保留 ErrNone 以便把上游业务错误(如 15003)原文透给调用方。

按本仓语义修掉 reporter 实现的四处问题:
- 在途租约:reporter 的 search / searchtool 两条路径都不占 MaxInFlight 名额,
  检索流量会绕过限流。现已在 /v1/search、probeOnce、searchText 三处 Acquire/Release。
- 综述式检索原本无独立时限(走 c.HTTP 的 120s),现给 150s 预算,
  避免一次综述长期扣住账号名额。
- 请求级错误的判据:ErrNone(判不出类别)也按请求级处理并原文透传,
  不再把 15003 这类错误改写成含糊文案后继续换号。
- 请求日志记账只挂了两条路径,Codex 走 /v1/responses 时整类请求无来源不入归档;
  现按 isChatEntry 一视同仁覆盖四条入站协议路径。

不改动本仓既有的错误分流(11-128 / 11140 / 6021)、计费与账号池语义。

(cherry picked from commit be56d82)
(cherry picked from commit 6665701050281f11d68a23da0586af7f41359598)
上游 fork 的 searchtool.go 与 /v1/search 都不取在途租约,检索流量绕过
MaxInFlight 限流;上一版补了 Acquire/Release,但归还点散落在各出口,
新出口容易再漏。改为「单次尝试 = 一个闭包,defer 归还」,租约作用域与
一次上游调用严格同生命周期。

新增两个断言在途计数归零的回归测试(上游 500 路径、客户端 ctx 取消路径);
已用变异测试验证:去掉 Release 后两条测试即失败(非空断言)。

(cherry picked from commit 4ebfa3d)
(cherry picked from commit 9cd19dbb1641def95642ea5c8892b29f0a26d308)
@Huiaei
Huiaei changed the base branch from main to base/v1.13.0 October 20, 2026 09:00
DSH Agent added 27 commits October 20, 2026 18:16
问题:auth.SaveAtomic 的 rename 降级判据是 atomicfile.IsReplaceableErr,它只看
POSIX 的 EXDEV / EBUSY。而 Windows 上 MoveFileEx(..., MOVEFILE_REPLACE_EXISTING)
的失败原因是:
  - ERROR_NOT_SAME_DEVICE(17):跨卷 rename;
  - ERROR_ACCESS_DENIED(5):目标文件被占用 / 只读。
Windows 的 syscall.EXDEV 是「臆造错误」列表里的占位值(0x20000000),永远
不等于任何真实 errno → errors.Is(err, syscall.EXDEV) 在 Windows 上恒为 false
→ IsReplaceableErr 认不出这两种情况 → SaveAtomic 直接报错 → **刷新后的
accessToken 不落盘**(磁盘上仍是旧 token)→ 下次启动继续用旧 token。

实测(Windows 10/11 + Go 1.22+):目标被本进程打开 → "Access is denied.";
跨卷 → "The system cannot move the file to a different disk drive.";两种情况
errors.Is(err, syscall.EXDEV) 与 EBUSY 均为 false。

改动:
- 新增 atomicfile.CanFallbackToInPlace(按平台分文件实现):
  - atomicfile_windows.go:额外识别 ERROR_NOT_SAME_DEVICE /
    ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION / ERROR_ACCESS_DENIED
    与 fs.ErrPermission;
  - atomicfile_other.go:与旧的 IsReplaceableErr 等价(POSIX 语义不变)。
- internal/auth/auth.go 的 SaveAtomic 改用 CanFallbackToInPlace。
- IsReplaceableErr 补上 ⚠️ 注释:它是 POSIX 口径,跨平台调用方请用
  CanFallbackToInPlace。

安全边界:rename 失败只可能来自目标侧(源 tmp 刚写成功),所以「权限类错误
→ 降级原地覆盖」是安全的——目标可写就写进去,不可写时 writeInPlace 返回
真实错误,不会静默丢数据。

测试(internal/atomicfile/atomicfile_test.go):
- TestCanFallbackToInPlaceRecognisesWindowsErrno(Windows 专属,POSIX 上跳过)
- TestCanFallbackToInPlaceRejectsUnrelatedErrors(全平台:nil/未知错误/ENOENT
  不得被判为可降级)
- TestCanFallbackToInPlaceMatchesIsReplaceableErrOnPosix(POSIX 上两者必须等价)
- TestWriteConcurrentSamePath(并发写同一路径不会互相污染内容)

CI 注意:现有 workflow 只在 ubuntu 上跑 go test,Windows 专属路径永远不会被
执行到——本次已用 GOOS=windows go build/vet 验证可编译。

(cherry picked from commit cd2684c)
问题:issue linguo2625469#149 的「零 tool_calls 停摆」是**上游行为缺陷**,不是账号问题,
所以命中后绝不罚账号。这个「不罚」在池层面包含两件事:
  1. 不冷却 / 不禁用 / 不 NoteFailures / 不 NoteError;
  2. **不喂燃尽熔断(issue linguo2625469#229)的计账**——把「模型这一轮没调工具」记成
     「这个号在烧积分」会让熔断器误伤健康号。

第 2 条在非流式路径有守卫(handler.go 的 `if !stall.Suspect`),但流式路径
是**无条件** NoteBurn → 同一形态(声明了 tools、零 tool_calls、stop 收尾)
在流式下会被熔断、在非流式下不会 → 熔断结果随请求模式漂移,统计与排障
都失去意义。

改动:流式路径把 NoteBurn 收进 `if !streamStall.Suspect` 守卫,与非流式
完全一致(streamStall 变量提出来复用,避免二次调用探测器)。

测试(internal/server/tool_stall_burn_test.go):
- TestStallHopDoesNotTripBurnGuardInStream(流式:声明 tools + 零 tool_calls
  + stop + usage.credit=1,阈值 0.5 → 不得熔断)
- TestStallHopDoesNotTripBurnGuardInNonStream(非流式对称)
- TestNormalHopStillTripsBurnGuard(反向对照:真调了工具的一跳必须照常熔断,
  否则上面两个用例会因「熔断根本没生效」而假绿)
- TestStallHopStillBilledButNotBurned(停摆跳照样记账——余额要准;只是不参与
  燃尽熔断计账)

已验证非空洞:把流式的守卫去掉(还原成无条件 NoteBurn)后
TestStallHopDoesNotTripBurnGuardInStream FAIL(账号进入冷却,reason=
「积分燃尽熔断:窗口内消耗 1 积分超过阈值 0.5」),恢复后 PASS。

(cherry picked from commit bcd8ca4)
问题:两个 workflow 都只在 ubuntu-latest 上跑 go test / go vet,而仓库**同时
发布 Windows 二进制**(go-binaries.yml 的 matrix 含 windows/amd64)→ Windows
专属的代码分支永远不会被测试执行到。

实测教训(本次修复的那个 bug):internal/auth.SaveAtomic 的 rename 降级判据
在 Windows 上恒为 false(Windows 的 syscall.EXDEV 是臆造占位值 0x20000000)
→ 刷新后的 accessToken 不落盘。这个缺陷在 ubuntu CI 上**完全不可见**,
因为 POSIX 上 EXDEV / EBUSY 的语义是成立的。

改动:两个 workflow 各加一个 test-windows job(runs-on: windows-latest,跑
go test ./... + go vet ./...),并把下游 job 的 needs 改成 [test, test-windows]。

为什么不用矩阵复用 ubuntu 的 test:Windows 上的错误码语义、文件占用语义、
路径大小写语义都与 POSIX 不同,必须在真正的 Windows runner 上跑;
而 `GOOS=windows go test` 只能编译不能执行(本仓库已验证:交叉编译出的
测试二进制需要在 Windows 上运行才有意义)。

(cherry picked from commit 4f079fb)
Windows 的 os.Chmod 只改「只读属性」,没有目录写保护语义 → chmod(0o555) 之后
目录照样可写 → 三个依赖「目录不可写」的用例在 Windows 上会退化成「正常写入」,
断言仍成立但覆盖不到 fallback 路径(假通过,且看不出在测什么)。

改法:在 Windows 上显式 t.Skip 并写明原因,与仓库既有的
internal/credithist/fault_test.go、internal/auth/permhint_test.go 保持同一口径。

配套:上一条 CI 提交(4f079fb)加了 test-windows job,这些用例在
windows-latest 上现在会被真实执行。

(cherry picked from commit bb60e85)
发现方式:本次新增的 test-windows CI job(windows-latest)**首次运行**就抓到了一个
此前从未被任何测试覆盖的真 bug。

现场(CI 日志原文):
  --- FAIL: TestWriteConcurrentSamePath (0.00s)
  atomicfile_test.go:252: 并发 Write: replace C:\...\config.json:
  open C:\...\config.json: The system cannot find the file specified.
  (完整新内容保留在 C:\...\config.json.tmp)

根因(两层):
1. 暂存文件用的是**固定**名 path+".tmp" → 两个并发写者共用同一个暂存名:
   - 后写者的 os.WriteFile 会把前写者**已备好的内容截断重写**,
     前写者随后 rename 落盘的是后写者的内容 → **静默数据错乱**(不报错,最坏);
   - 前写者 rename 成功后暂存文件消失 → 后写者 rename 报「源文件不存在」;
   - Windows 上更糟:文件正被另一个进程打开时 MoveFileEx 因共享冲突失败
     (ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED),
     随后的原地覆盖又因「目标文件尚不存在」报 ERROR_FILE_NOT_FOUND
     → 一次本该成功的并发写变成失败。
2. 降级路径 writeInPlace 用 O_WRONLY|O_TRUNC 打开目标,**目标不存在时**报
   ENOENT / ERROR_FILE_NOT_FOUND(Windows 上正是上面那条错误)。
   目标不存在是常态(首次写入、并发写者已把目标 rename 走)。

修法:
- 暂存名改为**唯一**:<base>.tmp-<16位十六进制>(同目录,保证 rename 同文件系统
  仍是原子替换)。每个写者只操作自己的暂存文件,互不干扰。
  代价:进程被强杀可能留下形如 config.json.tmp-<hex> 的残留
  (取舍:宁可留下可识别的残留,也不写坏主文件)。
- writeInPlace 补 O_CREATE:目标不存在时**新建**。
- 错误信息里补上 rename 的**原始原因**:此前 rename 的真实失败原因会被原地覆盖
  的次生错误盖住(实测踩过:rename 因共享冲突失败 → 原地覆盖因目标尚不存在报
  ERROR_FILE_NOT_FOUND → 排查时看到的是一个完全误导的错误)。

测试(全部已验证非空洞):
- TestWriteUsesUniqueStagingNamePerWrite:用 stagingName 钩子断言「每次写都走
  暂存名生成」——把实现退回固定 path+".tmp" 时该用例 FAIL
  (「stagingName 应被调用一次,实际 0 次」)。
- TestWriteUniqueTmpName:命名规则(与目标同目录、config.json.tmp- 前缀、两次不同名)。
- TestWriteInPlaceCreatesMissingTarget:原地覆盖能新建目标。
- TestWriteConcurrentSamePathNoLeftoverTmp:正常完成后不留暂存残留。
- TestWriteAtomicReplace / TestWriteNoFallbackReturnsPermissionError 随之调整
  (暂存名不再固定,改为校验目录内无残留 / 错误信息含前缀)。

为什么用钩子而不是靠并发压测撞概率:竞态窗口极窄——4 写者 × 20 轮 × 200KB
负载在 Linux 上都撞不出来(实测),靠概率的回归测试等于没有测试;
而 Windows 的 MoveFileEx 共享冲突把窗口放大了,才在 CI 上暴露。

(cherry picked from commit fb7b179)
SaveAtomic 之前固定用 FilePath+".tmp" 作暂存名。两个**不同**的 *Auth 实例
(各自有自己的 mu)指向同一 FilePath 时并发保存:

  - A 写完 tmp → B 也写同一个 tmp(覆盖)→ A rename 走 → B rename 报 ENOENT;
  - 或 A rename 走 → B rename 时目标已被占(Windows 报占用),内容是 A 的。

实测 300 轮 × 2 写者:SaveAtomic 返回 nil 但内容未落盘 **282 次**(被对方
rename 走),另有 **266 次**报「源文件不存在」的假失败。

a.mu 只保护**同一个对象**;scheduler 的 keepalive 刷新与 handler 的按需刷新
持有的是**不同**对象 ⇒ 互不排斥 ⇒ 现实里必然撞上。

修法:改用 internal/atomicfile.Write(唯一暂存名 + 原子替换 + 跨平台降级
原地覆盖),两处不再各写一份、日后各改各的。

新增 internal/auth/auth_concurrent_test.go:
  - TestSaveAtomicConcurrentDistinctInstances:60 轮 × 2 写者并发,两次保存都
    不得虚假失败,落盘内容必须是某一个写者的**完整**内容(不能是半截文件);
  - TestSaveAtomicLeavesNoStagingFiles:保存后不得残留暂存文件(.tmp 里有
    明文 token)。
两个用例都做了反向验证:还原成旧的固定暂存名实现后必失败。

(cherry picked from commit 4c633a3)
此前 chat 路径遇到**一次** 12153 就 Pool.Disable,而 scheduler 的 keepalive
路径是「连续 3 次才禁用」——两套策略并存。

问题:
  - 12153 会在**上游偶发**地出现(token 恰好被别的客户端用掉、并发刷新撞车、
    上游瞬时风控),单次就杀号是历史误判的主要来源;
  - 禁用是**终态**(不自动恢复,只能在面板手动解冻),误杀代价很高;
  - 单账号池里 1 次 12153 = 账号直接死掉(且 NoteSuccess 不会自愈),
    而 keepalive 里要撞三次才死 —— 同一账号在两条路径上的命运不同。

改成统一走 Pool.NoteSessionDead(连续 3 次才禁用,任意一次成功即清零),
与 scheduler 口径完全一致。日志里打出「已禁用」的那一次,方便运维定位。

同步更新 internal/server/handler_test.go 的 TestChatSessionDeadDisables:
第一次 12153 不断言禁用,累计到阈值才断言禁用。

(cherry picked from commit e786ef2)
企业版没有签到体系(上游 400 code 10001「企业账号不支持该操作」),/checkin
端点会退化为「只刷新额度」。但此前这条**解释性**文案同时被塞进了
checkin_error,而企业账号的 checkin_done 恒为 false ⇒ 前端
checkinResultNote 判定「没签到 + 有错误」⇒ 渲染成 severity=err 的红色
「签到未完成:企业账号无签到体系…」。

现场:面板上每一个**健康**的企业账号都显示成红色报错,运维误以为账号坏了
(外部脚本与旧缓存前端同样中招)。

修法:
  - 后端:区分「跳过」与「失败」——企业账号走 checkinSkipped 分支,
    只给 checkin_message(说明性),**不给** checkin_error,并新增显式的
    checkin_skipped 标记;
  - 前端:checkin_skipped / 企业号+仅解释性文案 → severity 非 err;
    balance_error 仍然优先(余额查询真失败时要能看见原因);
    有真 checkin_error 时仍然必须是 err。

新增断言(internal/panel/task_frontend_test.go):跳过态 severity≠err、
说明文案要透出、旧后端(无 checkin_skipped 字段)的企业号也不误报、
真失败仍是 err。反向验证:去掉前端这两行即失败。

(cherry picked from commit 1feb87d)
402(余额耗尽)的硬冷却要等上游把额度重置了才放号,而**重置墙钟由上游决定**
(Asia/Shanghai,UTC+8 无夏令时)。此前 nextDay4AM 用的是部署机器的本地时区:

  - 部署在 UTC 的机器:上游已重置后仍在冷却(早放,白冷一部分);
  - 部署在 UTC+13 的机器:多冷近 5 小时(晚放,账号多闲置 5 小时,
    调度器少一个可用号)。

改成固定 upstreamCSTZone(UTC+8)。同口径先例:internal/scheduler/travel.go
的自然日切分已固定用 cstZone,internal/upstream/client.go 的 promoZone 同样
是 FixedZone("CST", 8*3600)。固定 +8 还免掉 tzdata 依赖(精简容器镜像常缺
/usr/share/zoneinfo),也不受夏令时影响。

internal/pool/pool_test.go 补了三条跨时区用例:UTC 机器的傍晚/深夜、UTC-5 机器
的正午 —— 断言到期时刻是**上游时区的 04:00**。

(cherry picked from commit 098b80d)
两处来自交叉审计:

1) **停摆判定缺少自防御**:DetectToolStall 只看「零 tool_calls + stop」,
   不看正文里有没有模型原生工具调用标记(DSML 块)。上游把工具调用吐成
   正文里的文本标记时,工具**其实调了**,判成停摆会:
     - 白白换号重试一次(多花一次上游调用,客户端多等一轮);
     - 在**没有标记修复层的分支**(v1.13.0 基线)上把「工具其实调了」的回合
       误报成停摆,污染停摆率统计(issue linguo2625469#149 的可观测性失真)。

   修法:正文含标记开始标记 ⇒ 不判停摆。两条路径口径一致:
     - 修复层处理过 → 标记被移走、变成结构化 tool_calls → 不是停摆;
     - 修复层没处理 → 正文里还留着标记 → 也不是停摆。
   标记常量提到 tool_stall.go 里的 markupCallOpenTag(**不引用** dsml.go 的
   markupOpen —— v1.13.0 基线分支没有 dsml.go,引用它会编译不过);dsml.go 的
   markupOpen 改为由该常量派生,两边永不漂移。

2) **WithFrameObserver 是单槽位**:后注册的观察者会**静默覆盖**先注册的
   (option 按顺序应用),现象是「判定偶尔不生效」且**没有任何报错**,极难
   排查。改成 frameObservers 列表:多个观察者共存,调用顺序 = 注册顺序。

新增测试:internal/upstream/tool_stall_marker_test.go(含「普通正文仍然判停摆」
的对照组,防止自防御把真停摆也放过)、internal/upstream/sse_frame_observer_test.go
(多观察者都被调用 + 透传字节逐字节不变)。

(cherry picked from commit 8332173)

# Conflicts:
#	internal/upstream/dsml.go
#	internal/upstream/sse.go
从 main 回合到 v1.13.0 基线分支时,internal/upstream/sse.go 的冲突解析把
normalizeFrame(白名单重建)那一步连同 error 帧的观察者回调一起丢了:

  - 丢 normalizeFrame ⇒ 帧不再按白名单规范化 ⇒ 7 个 SSE 回归测试失败
    (TestStreamToolCallNameOnce / TestStreamToolCallParallelFragments /
    TestStreamToolCallNoiseEmptyName / TestStreamNormalizesFrames /
    TestStreamFirstIdPassthrough / TestStreamNoIdFallsBackToSentinel /
    TestStreamNormalPassthroughRegression);
  - 丢 error 帧观察者 ⇒ 停摆检测看不到上游错误帧。

已还原两处:normalizeFrame 重新接回 writeFrame;error 帧也交给逐帧观察者
(error 帧也是合法 JSON,一并观察)。
两处交叉审计发现(issue linguo2625469#222 建议 3 的延伸):

1) **同族多键取值随机**:上游同时给出 "credit" 与 "Credit"(一个真实值、一个
   占位 0)时,旧的「谁后覆盖谁」写法依赖 Go 的 map 遍历顺序 ⇒ **同一份响应
   每次解析出不同的余额**。实测 200 次解析 {"credit":812.45,"Credit":0,
   "limitNum":2000} ⇒ credit 分布 {0:166, 812.45:34}。取到 0 ⇒ 面板显示
   剩余 2000/2000(实际 1187)⇒ 派活后 402。

   改成:先按归一化键(去下划线 + 小写)分组,组内**优先取非零值**、全零时
   取零 ⇒ 与顺序无关。归一化后等价族:credit/Credit/CREDIT、limitNum/
   limit_num/LIMITNUM、usedNum/used_num。

2) **类型不匹配静默当 0**:上游偶尔把数值序列化成字符串("812.45")。旧的
   json.Unmarshal 到 float64 失败就跳过 ⇒ credit=0,而 hasQuotaField 仍为
   true ⇒ 「空 payload」守卫拦不住 ⇒ 面板显示 2000/2000(真实 1187)、
   或账号被误判耗尽而挡掉收费模型。

   改成:quotaNumber 接受 JSON number **与**数字字符串;实在解析不了就置
   badType,调用方据此**报错**而不是把读不出来的额度当成 0。

(cherry picked from commit d652498)
上一条提交把 402 硬冷却的到期时刻改到**上游时区**(CST=UTC+8)后,四个
断言仍在看 st.Until 的**本地**小时数 —— 在 WSL(本地 +8)上恰好相等,所以
本地全绿;但 CI 跑在 UTC(差 8 小时)⇒ 四个用例全挂:

  - internal/pool: TestCooldownUntilTomorrow4AM / ...Persists
  - internal/server: TestChatHardCreditCooldownUntilNextDay4AM /
    TestChat429Code14018UsesHardCreditCooldown

这正是「本地过、CI 挂」的典型:断言没跟实现的口径对齐。

修法:断言改成 st.Until.In(pool.UpstreamCSTZone()).Hour()(新增导出
UpstreamCSTZone(),让调用方与测试用同一口径)。

已用三个时区回归验证:TZ=UTC / TZ=America/New_York / TZ=Asia/Tokyo 下
internal/pool 与 internal/server 全绿。

(cherry picked from commit f0487f0)
Windows CI 抓出来的第二个真 bug(本轮并发写用例在 Windows 上第 5 轮就挂):

现象:
  replace ...workbuddy-concurrent.json: rename 失败(... Access is denied.),
  原地覆盖也失败: open ...: The process cannot access the file because it is
  being used by another process.

根因:Windows 的 MoveFileEx(..., MOVEFILE_REPLACE_EXISTING) 在**目标文件正被
另一个句柄打开**时返回 ERROR_ACCESS_DENIED(5) / ERROR_SHARING_VIOLATION(32)。
两个写者并发替换同一目标时这**必然会撞上**(另一个写者的 rename 窗口只有几
微秒,但足以让本写者失败)—— 不是罕见故障,是并发写的常态。

此时直接降级原地覆盖也没用:目标正被另一个句柄独占,writeInPlace 同样失败
⇒ 一次本该成功的并发写变成失败。

修法:rename 遇到「目标侧临时占用」类错误时**短暂重试**(40 次 × 5ms),
让另一个写者先完成;重试仍失败才降级原地覆盖。

平台差异(important):
  - Windows:isTargetBusyErr 命中 ACCESS_DENIED / SHARING_VIOLATION /
    LOCK_VIOLATION / fs.ErrPermission;
  - 非 Windows:POSIX rename(2) 在目标被打开时**不会**失败,失败只来自 EXDEV
    (跨设备)与 EBUSY(单文件挂载点)—— 这两个是结构性问题,重试无意义 ⇒
    非 Windows 上 isTargetBusyErr 恒 false,**行为与从前完全一致**(不多睡、
    不改变任何错误语义)。

新增 internal/atomicfile/atomicfile_retry_test.go:40 轮 × 2 写者并发替换同一
目标,断言每次写都成功、落盘内容是某个写者的完整内容、结束后不残留暂存文件。

(cherry picked from commit 10ca586)
交叉审计第二轮 D3/D4:同一个事实在两层用了两套判读方式,而两层的动态类型
不同 —— Aggregate 产出的 message.tool_calls 是 []map[string]any,JSON 反序列
化出来的 body 里是 []any。

后果:
  - 停摆检测的 hasToolCalls 只认 []any ⇒ 非流式路径把「工具其实调了」的回
    合误判成停摆(D3)⇒ 白烧一次上游调用 + 白解绑一次粘性会话;
  - DSML 修复器的守卫(msg["tool_calls"].([]any))同样认不出 ⇒ 修复器会用
    「正文标记还原出的调用」覆盖真实 tool_calls(D4)⇒ 调用参数被改写(实测
    Hangzhou → Beijing)。

修法:新增 internal/upstream/toolcalls.go,把判读收成**一个**函数:
  - HasStructuredToolCalls(v any) bool —— 认 []any / []map[string]any /
    map[string]any(单调用对象或 index→call 映射);
  - MessageHasToolCalls(msg) bool —— 认 tool_calls 与老形态 function_call;
  - ToolCallList(v any) []map[string]any —— 归一化成同一份列表。
停摆检测与 DSML 守卫都改调它。

刻意放在独立文件里:v1.13.0 基线分支(release/v1.13.0-fixed)不含 dsml.go
(上游 v1.13.0 之后的提交引入),本文件不能依赖 dsml.go 的任何符号,两条分
支才能共用同一份实现。

回归:internal/upstream/toolcalls_unify_test.go(7 个用例);已验证非空洞
(回退 hasToolCalls 后 TestHasToolCallsSeesAggregateShape 失败)。

(cherry picked from commit 8003365)

# Conflicts:
#	internal/upstream/dsml.go
交叉审计第二轮 D2(BLOCKER):上游用「HTTP 200 + SSE error 帧」回报错误时,
帧形如 data: {"error":{"code":11128,"message":"request illegal"}}。

11-128/11128(通道校验)与 11140(账号限制)的判据**在业务码上**,文案
"request illegal" 是两者共用的。旧实现只把 error.message 交给 Classify ⇒ 业
务码丢失 ⇒ 11-128 落通用 4xx ErrClient ⇒ handler 的 default 分支喂
NoteFailures ⇒ **5 次就把健康号降权出池 10 分钟**。

而非流式路径走 Classify(status, 完整 body),同一个上游错误在那边是
ErrChannelRefused(零动作)——于是**同一个错误两条路径处置相反**:一个罚号
一个不罚号。

修法:FrameKind 把 error.code 与 error.message 合成一个**最小业务信封**再交
给 Classify,口径与非流式完全一致。

关键细节:**整个 error 对象都要搬**(不只是 code + msg)。11140 的模型级限流
说明("The model provider is rate-limiting requests.")只出现在 displayMsg
里,msg 仍是 "request illegal";只搬 code+msg 会把那句限流说明丢掉 ⇒
IsAccountRestricted 判不出限流变体 ⇒ 把**模型侧限流**当成账号封禁 ⇒ 永久
禁用健康号。code 原样搬运(数字 11128 或字符串 "11-128" 都可能),故用
json.RawMessage 直接嵌入。

回归:internal/server/audit_wave3_test.go 的 TestFrameKindKeepsBusinessCode
(6 个用例)+ TestFrameKindMatchesNonStreamClassification(流式/非流式口径一
致性,4 组 body)。已验证非空洞(回退 FrameKind 后前者失败)。

(cherry picked from commit 7a0bdb9)
交叉审计第二轮 D5 / D6 / D7 / D13 / D14 —— 5 个缺陷同源:**判据与惩罚强度
没有对齐「上游到底说了什么」**。

D5(MAJOR,影响面最大):内容审核域的边界判错了。
上游把两种语义塞进**同一个业务码** 11-128/11128,只有「主文案字段」能区分:
主 msg = "blocked by security policy" 才是真内容审核;主 msg = "request
illegal" / "conversation id missing" / "first message is not system prompt" /
"Illegal API invocation from an unapproved channel" 都是通道校验(账号健康)。
早先的实现按**整 body** 扫文案,于是 11-128 + displayMsg 里的审核词会被归成
内容审核 ⇒ handler 执行 h.degrade.Trigger() ⇒ **整个网关对所有账号、所有模
型**降级到次日 00:00 CST(一次请求级形状问题被放大成整网关整夜的降级)。
修法:只看**主文案字段**(body.msg / error.message),不扫 displayMsg/displayTips。
判不准时**偏向通道校验**(代价不对称:误判通道校验 = 有界重试、零惩罚;误判
内容审核 = 可能整网关整夜降级)。

D6(MAJOR):11140 的模型侧限流否决只认三条逐字句,上游改一个字(「模型提供
方限流中」vs「模型提供方正在限流」)就把可自愈的限流翻转成**永久禁用**健康
号。修法:判据改为**语义家族**(modelRateLimitFamily,覆盖中英文与常见变体)。

D8(MAJOR,顺带修):11140 但文案不含英文原句时,真封禁既不禁用、又喂连败
计数(永不被检出 ⇒ 账号带病留在池里、面板显示可用、用户无从申诉 —— 比误封更
糟)。修法:文案判据改为 accountRestrictedMsgFamily(明确说「账号被限制/封禁」
的措辞),且**刻意不收**「限流/用量」类宽词。

D7(MAJOR):HTTP 429(瞬时限流)带 11140 ⇒ 永久禁用健康号。一次限流高峰就
白扔一个号(不可自愈、需人工申诉),与 issue linguo2625469#197 要避免的「好号被误禁」同类。
修法:**分类口径不动**(保持与上游既有契约一致:账号级故障码优先于 429 兜底),
改在**惩罚侧**分级 —— 429 只软冷却(可自愈),其它状态码才硬禁用。代价权衡:
若上游真用 429+11140 表示封禁,本分级会把它降级成软冷却(可自愈);反之永久
误禁不可自愈,两者不对称故取软冷却。

D13(INFO):裸 "request illegal"(**无业务码**)落通用 4xx ErrClient,而
ErrClient 是**唯一会喂连败计数**的分支 ⇒ 5 次连败就把健康号降权出池 10 分钟。
修法:归 ErrChannelRefused(**不罚号**、继续轮转)。判不准时三个口径里
(通道校验零惩罚 / 账号封禁永久禁用 / ErrClient 降权出池)最轻且最可自愈的
是通道校验;且上游后来把同一句文案升级成带 11-128 业务码的形态 ⇒ 无码形态
极可能是 11-128 的旧版。触发词刻意**只限** "request illegal" / "请求非法"
这一句真正二义的文案 —— 其它措辞("account banned" 等)只出现在封禁语义里,
无码时也应按封禁方向处理。

D14(INFO):hasBusinessCode 只认字面键名 "code",上游一旦换键名
(errCode/errorCode/error_code/bizCode)⇒ IsChannelRefused 与
IsAccountRestricted **同时失明**(11-128 与 11140 双双落通用 4xx)。修法:认一
组常见别名,并新增 BusinessCodeOf 供错误文案标注实际业务码。

顺带修 D12(MINOR):ErrAccountFault 软冷却分支把 reason 写死 "account fault
(14017)",而实际业务码不一定是 14017(例如 {"code":11140,"msg":"trial not
activated"})⇒ 运维按错误方向排查。修法:reason 带实际业务码。

回归:internal/server/audit_wave3_test.go(15 个用例)+ 更新
request_illegal_policy_test.go 的裸文案期望。全部已验证**非空洞**(逐项回退
后对应用例失败)。

(cherry picked from commit 8d749ba)
交叉审计第二轮 D10 / D11(MINOR,但都指向同一个运维黑洞:**面板说「可用」、
实际选不出来**)。

D10:熔断号在面板两处被计为「健康 / 可服务」。
熔断(burn guard)是**选号层面**的排除:burnBlocked 在各选号路径上都生效,但
账号本身 healthy(可登入、可签到)。聚合口径若把它算成 healthy,面板总览就会
显示「还有 1 个健康号」,而实际上号根本选不出来 ⇒ 排障方向完全错(去查「号为
什么不出请求」,而不是查积分燃尽熔断)。
修法:countsDetailedForRealm 把 burnBlocked 归入 cooling 桶(与 statusOf 的口
径对齐 —— statusOf 本来就把 burn 显示为 Cooling=true / CoolKind="burn",数
据在、聚合口径没跟上);modelview.go 的 Servable 计数也排除熔断号。

D11:面板「解冻」(Revive)与「删号重加」(Remove)都不清熔断状态。
面板「恢复账号」的语义是「运维口径无条件恢复」(internal/panel/panel.go 的
accountRevive 注释原文如此)。不清熔断的话,运维点了恢复,账号**依然选不出
来**,而且没有任何提示说明还差 cooldown 到期 ⇒ 运维会以为按钮坏了。删号后重
新导入同一账号同理。
修法:Revive 与 Remove 一并清掉 burnGuard 状态。

回归:internal/pool/burnguard_visibility_test.go(4 个用例)。已验证**非空洞**
(逐项回退后 TestBurnBlockedAccountIsNotCountedAsHealthy /
TestBurnBlockedAccountIsNotCountedAsServableForModel / TestReviveClearsBurnGuard
/ TestRemoveClearsBurnGuard 分别失败)。

(cherry picked from commit fd7d981)
问题:pool 里「从没读到过上游余额」与「读到 0」是同一个值(credits=0)。
于是配了 credit_floor>0 的部署会把**所有新账号**(刚导入、余额查询失败、
额度信封判空)静默挡在收费模型之外:号在池里、面板显示「余额 0」、但一个请求
都接不到,且没有任何日志解释为什么。

修法(区分「未知」与「0」):
- entry.creditsKnown:只有**成功读到过**权威余额(SetCredits / SetCreditsSnapshot /
  ReenableIfCredits / reviveCoolingLocked)才置 true。
- pick.floorBlockedForRealmModel:creditsKnown=false ⇒ **不套用** credit_floor
  (未知 ≠ 触底;真正的触底会在下一次余额刷新后被拦住)。
- 状态文件持久化 credits_known;旧状态文件用「正余额证明读到过」推断,
  credits=0 保守视为未知(方向是「不静默挡号」)。
- Status.CreditsKnown 透出给面板:app.js 显示「未知」而不是「0」,
  积分汇总只累加**额度已知**的账号,另有 N 个额度未知时显式标注。

测试:internal/pool/credits_known_test.go(未知不被挡 / 读到 0 仍被挡 /
三个权威读取入口都置位 / 状态往返持久化 / 旧状态文件按未知处理),
已反向验证:去掉 creditsKnown 判断 ⇒ 用例失败。

(cherry picked from commit 57af6b6)
问题:sse.go 里 repairFrame(DSML 标记还原)拿到的就是**同一个 map 引用**,
它会就地改写 delta.content 与 finish_reason(stop → tool_calls)。文档承诺
「观察者只读不改对象」实际上**只对同步读取成立**:

    [observer] live[0]  finish=""       contentLen=218 hasMarkup=true   ← 原始帧
    [observer] kept[0]  finish=""       contentLen=6   hasMarkup=false  ← 标记已被抹掉
    [observer] kept[1]  finish="tool_calls"                            ← stop 被改写

任何把帧对象**存下来**的观察者(日志记录、事后统计)事后读到的都是修复后的
状态 ⇒ 判定口径悄悄变了,且没有任何报错。

修法:新增 cloneFrame(深拷 choices[].delta 与 finish_reason,其余按值拷贝),
两个观察者调用点(正常帧 + error 帧)都传副本 ⇒ 观察者拿到的永远是「上游原样」,
任何时候读都对。透传字节零变化(cloneFrame 只读不改源对象)。

测试:internal/upstream/frame_observer_clone_test.go
- TestFrameObserverReceivesUnmodifiedFrame(观察者事后读到的仍是原始帧)
- TestFrameObserverDoesNotAlterPassthroughBytes(透传字节逐字节相同)
- TestCloneFrameIsDeepForDelta / TestCloneFrameHandlesNonChoiceFrames
已反向验证:改回传原引用 ⇒ 用例失败。

(cherry picked from commit c4a8063)
问题:落盘的降级路径(rename 不可用时的原地覆盖)是 O_TRUNC 覆写,**非原子**:
进程在写一半时被杀 ⇒ 磁盘上留下**半截** config.json。而半截配置解析失败 ⇒
main.go log.Fatalf("load config: …") ⇒ **服务起不来**,且旧值已被覆盖无法找回。
实测:半截配置 Load 报 "parse config: unexpected end of JSON input"。

修法(三层):
1) 落盘前留一份 .bak 备份(只备份**可解析**的当前内容——半截内容不值得备份
   成「上一份完整配置」;备份失败不阻断保存,备份是保险不是主路径);
2) 启动时主配置解析失败 ⇒ 自动回退到 .bak 并打 WARN(服务照常起来);
3) 备份也不可用 ⇒ 明确报错并给出恢复路径(从 config.example.json 复制,或
   删除该文件让程序自动生成推荐配置)——**绝不**拿半截配置启动:那比启动失败
   更难排查(面板/调度器会拿着缺字段的配置跑)。

测试:cmd/server/config_backup_test.go
- TestLoadFallsBackToBackupWhenConfigTruncated(半截 ⇒ 回退 ⇒ 用备份里的 listen)
- TestLoadWithTruncatedConfigAndNoBackup(无备份 ⇒ 报错且信息含恢复指引)
- TestLoadWithEmptyConfigFile(空文件同样回退)
- TestSaveConfigKeepsBackupBeforeOverwrite(保存前备份的是**旧**完整配置)
已反向验证:关掉回退分支 ⇒ 用例失败。

(cherry picked from commit dab5f8e)
交叉审计第二轮 Q5.3 的补充:cloneFrame 只深拷了 choices[].delta,
error 子对象仍是浅拷贝 ⇒ 错误帧的读方(补 code / 改文案)与观察者共享
同一个 map,「观察者拿到的是上游原样」这条契约在 error 帧上不成立。

非空验证:撤掉 error 深拷贝 ⇒ 该用例 FAIL。
上游 11-128 / 11140 这类错误是「按请求体判定」的,出问题时看不到
出站 body 就只能猜。开了这个开关,网关会把入站与出站请求体各打一行
到 stdout(与聊天流水同一份日志)。

用法:环境变量 WB2A_DUMP_BODY=1;不需要时不要设(长会话日志很大)。

(cherry picked from commit ece049d)
上游风控是「按头族 + 请求体」联合判定的,出问题时看不到实际出站头就
只能猜。这个开关把出站头按名排序逐条打印(Authorization / X-API-Key
只打长度不打印值)。

配合同一个开关里的 body dump,一次就能把「我以为发了什么」和「实际发了
什么」对齐。调试用,上线不要设。

(cherry picked from commit 1bd9e4f)
此前网关的运行日志只往 stderr/stdout 写。Windows 上双击或托盘启动时
根本没有控制台窗口可看,出了「上游返回 400 但客户端只看到一个错」这类
问题就只能猜。

新增 logging.log_file(空 = 不落盘,保持原行为):
- 追加写,不截断历史;父目录自动创建。
- 体积上限 + 轮转:超过 log_file_max_mb(默认 64MiB)后轮转为
  .1/.2…,最多保留 log_file_backups(默认 3)份,最老的删除。
- 打不开文件只降级为一行 WARN,不让网关起不来(stderr 仍有输出)。
- 与 stdout 共存:控制台照旧有输出,文件里是同一份。

新增 internal/logfile 包(轮转写入器 + 7 个测试)与 cmd/server 的
4 个配置测试;config.example.json 与 README 补上这三个键。

实测(本地起真实进程):启动信息与聊天流水行
'| #1 | 09:14:04 | gpt-4o | sync | 503 | … | src=127.0.0.1 ua=… |'
都完整写进 data/gateway.log,控制台输出不受影响。

(cherry picked from commit d243d03)
上一个提交(dump 开关)有两个实际问题:

1. body 可能几百 KB,整条打进日志会冲垮日志、也会把提示词原文大面积
   留在磁盘上。现在截断到 4000 字符,截断处显式标注原始长度。
2. dump 走 log.Printf → stderr。上次用户只把 stdout 重定向到文件,
   dump 全跑进了装不下的控制台窗口,日志里一条都没有。现在日志落盘
   打开后 stderr 也在文件里;下面这个测试把「dump 开关必须真的打印
   入站体、出站体、出站头、且 Authorization 不明文」钉住。

新增 4 个测试:入站/出站体都打印、出站头整体打印且 Authorization 只打
长度、没开开关时一句都不打、截断行为。

(cherry picked from commit 3d19016)
U 盘现场日志:
  panel: 保存后台任务恢复状态失败: rename data\.task-jobs-xxx.tmp data\task-jobs.json: Access is denied.

根因:Windows 上 Go 的 os.Open 不带 FILE_SHARE_DELETE ⇒ 只要有任何进程
(杀毒软件/索引器/并发写者/面板自己)持有该文件的句柄,
MoveFileEx(..., MOVEFILE_REPLACE_EXISTING) 就返回 ERROR_ACCESS_DENIED。
手写 tmp+rename 把这个错误当成致命错误直接返回 ⇒ **后台任务状态在
Windows 上永远存不下去**(重启后任务队列丢失)。

internal/panel/taskjobs.go persistLocked 与 internal/panel/ring.go
persistTasksLocked 两处都改成走 atomicfile.Write:
  · 唯一暂存名(并发写不互相踩)
  · rename 失败按 Windows 错误码降级为原地覆盖(CanFallbackToInPlace)
  · 目标被占用时的重试(isTargetBusyErr + retryRename)

新增测试:
  · internal/panel/taskjobs_atomic_test.go(跨平台:反复写不失败、
    不留暂存文件、目标被占用时降级成功)
  · internal/panel/taskjobs_windows_test.go(Windows 专项:持有目标句柄
    时仍写成功——手写 rename 在这里必失败,由 Windows CI job 实跑验证)

(cherry picked from commit 6159d03)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants