feat(api): documents[].reason + messages 对不存在会话回 404(安卓回执九两条;契约 v0.63)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- ① documents[] 元素增可选 reason(omitempty):装配期在给出 status 的**同一处**填充,取的**就是注入模型清单里那份原因文案**(同一字符串两处用,服务端不为客户端另编一句),只在 unreadable 时下发;随 S3.5 的回合后回写落进 document 列 ⇒ 历史接口 / 握手回放都有。文案只含原因、不含落盘路径/存储名以外内容/凭据(有独特目录名反证断言)。

- ② GET /sessions/{id}/messages:不存在/已删除 ⇒ 404 session_not_found;**存在但无消息仍 200 {data:[]}**(空会话绝不能被答成 404);参数形状校验仍先于 404;存在性判定复用既有窄视图(history.Store.SessionPersona,未知 id 报 ErrSessionNotFound),**未新增 SQL/存储方法**;判不出来(未接该视图)保持旧语义(判不出 ≠ 不存在),判定本身失败回 500 不静默。

- 契约 v0.63(三档:reason 纯增量 / messages 404 行为变更 / 无破坏性):api-client §1 行 6 + §1.2④ 的 10 条逐字文案 + §4.4 错误码 + changelog + banner;api-reference §1.2 行 6 + §3.4 + §3.8 DTO + §8.1 错误码表。

- 主会话独立核对的副作用(隔离会话 sess-* 现在也 404):查活库 chat_sessions 里**根本没有 sess-* 行**(只有 web 与 web:<uuid>),sess-backthink 消息数 0,前端唯一提到 sess-backthink 的是 graph_update 的内存桶测试 ⇒ **没有客户端按 id 读隔离会话的 messages**;且这与 POST /sessions/{id}/read(v0.37 起即 404)一致 ⇒ 接受不加 sess- 例外,并在回执里问安卓确认。

- 主会话独立验收:隔离 worktree(S9 的 13 文件)build/vet 绿、整树连跑 2 次 59 ok / 0 FAIL、reason/404 用例 8 PASS、双平台交叉编译绿;**变异亲自复现**(在调用点短路 404 守卫 ⇒ UnknownSessionIs404 与 DeletedSessionIs404 双红:status = 200, want 404)。

- 如实登记:验收过程中出现过**一次不可复现的子集 FAIL**(restore 之后那次 Session|Reason 子集跑;未记到用例名)—— 随后同一子集、那三条用例 ×5、以及整树 ×2 全绿,判为瞬时;已记入账本,后续留意。
This commit is contained in:
AskaEth committed 2026-10-04 21:50:08 +08:00
1 parent c1fa1e9b9a
commit 902b6eecfd
13 files changed
+779 -21

No files matched your search

@@ -16,7 +16,7 @@ import (
// 列 —— 两段式是刻意的,也是客户端能看到「昔涟读不到这个文件」的唯一路径。
//
// 三条纪律:
// - 只补结论(status/chars/truncated),身份与大小逐字保留;
// - 只补结论(status/chars/truncated 与 unreadable 的 reason),身份与大小逐字保留;
// - 按**服务端存储名**匹配,匹配不到就原样留着(凭空给它一个结论就是编造);
// - 找不到行、存储未实现该视图、写库失败一律带来源 WARN,绝不静默。
//
@@ -103,6 +103,9 @@ func (o *Orchestrator) takeTurnDocuments(sessionID string, gen uint64) ([]histor
// 消息的 document 列(design/24 §4.4/§6.1)。它由 completeTurn 在助手回复落库
// 之后、状态机迁移之前调用,因此结论与该回合的对话内容一起同批入账。
//
// 回写的是 status/chars/truncated 与 unreadable 的原因文案(reason,契约
// v0.63)—— 之后所有读取面(历史接口 / 握手回放)拿到的是同一份结论。
//
// 找不到对应行(ErrMessageNotFound)、存储未接该视图、列被写坏、写库失败时都
// **不静默**:打一条带来源的 WARN 说明「状态未回写」。日志只带会话、消息 id、
// 条数与错误,**绝不带正文**,也不带列内容。
@@ -151,10 +154,13 @@ func (o *Orchestrator) persistDocumentStatus(sessionID string, gen uint64, res *
}
// mergeDocumentStatus 把本回合解析到的结论按存储名并进已落库的引用数组:匹配到
// 的补上 status/chars/truncated,没匹配到的原样保留(那意味着本回合没有解析到
// 它,凭空给它一个结论就是编造)。第二个返回值报告是否真的补进了新结论 ——
// 的补上 status/chars/truncated/reason,没匹配到的原样保留(那意味着本回合没有
// 解析到它,凭空给它一个结论就是编造)。第二个返回值报告是否真的补进了新结论 ——
// 没变化就不必写库、也不必刷一条「什么都没变」的日志。
//
// reason 与 status 同批搬运(契约 v0.63):抽取结论的这三项 + 原因文案是同一个
// 回合的同一份事实,分两次写会让客户端看到「unreadable 但没有理由」的中间态。
//
// 列被写坏时返回包装了 history 哨兵的错误,由调用方按「不静默」处理。
func mergeDocumentStatus(raw string, resolved []history.DocumentRef) (string, bool, error) {
refs, err := history.DecodeDocuments(raw)
@@ -173,10 +179,12 @@ func mergeDocumentStatus(raw string, resolved []history.DocumentRef) (string, bo
if !ok {
continue
}
if refs[i].Status == got.Status && refs[i].Chars == got.Chars && refs[i].Truncated == got.Truncated {
if refs[i].Status == got.Status && refs[i].Chars == got.Chars &&
refs[i].Truncated == got.Truncated && refs[i].Reason == got.Reason {
continue
}
refs[i].Status, refs[i].Chars, refs[i].Truncated = got.Status, got.Chars, got.Truncated
refs[i].Status, refs[i].Chars, refs[i].Truncated, refs[i].Reason =
got.Status, got.Chars, got.Truncated, got.Reason
changed = true
}
if !changed {