feat(devtools): 假后端加上行挂起开关 --hold-replies,让「忙时被拒 / 排队 / 队列满」可稳定复现
为什么
客户端手里还挂着一条没发出去的乐观气泡时,收到「队列满 / 忙时被拒」会怎样,判据是
ChatViewModel.queueFullOutcome(本会话待发 0 条 ⇒ 刻意什么都不说、1 条 ⇒ 回滚 + 横幅、
≥2 条 ⇒ 一个气泡都不动、只提示「可能没发出去」)。但假后端收到上行 chat 或
POST /api/v1/chat 时会立刻把那条用户消息落库并广播 message_persisted,客户端拿
clientMsgId 一比就把乐观气泡收走(consumePendingFor)—— 一个往返之后待发列表就空了,
那三档里只剩「0 条」能凑出来。真后端忙时是先排队或先拒绝、不会立刻落库,所以这是假后端的
建模缺陷,本次修它。
加了什么(只动 devtools/fake-backend/,不碰 app/ devdocs/ 构建文件)
- 启动开关 --hold-replies(默认关):收到上行消息只记录、不落库、不广播 message_persisted。
上行帧照常进 out/upstream.jsonl、state.wsUpstreamByType 照常计数;回复路径
(--stream-chunk / --auto-reply / /__control/reply 自己的 persist)一概不受影响 ——
关的只是「上行那条的落库」这一件事。
- 运行期开关 POST /__control/hold-replies?on=1|0,便于在同一个进程里做「先挂着 → 再落库」两段。
- 显式补落库 POST /__control/persist?session=&id=<holdId|messageId|clientMsgId>&index=N:
不给选择器就按 FIFO 落最旧那条;落库时才盖 createdAt、才认领幂等键(与「队列满的判定发生在
认领之前」一致),广播的 message_persisted 与默认路径同形状。挂起列表在 GET /__control/state
的 heldMessages 里。
- 挂起的消息在挂起时先占号(msg-xxxxxx),所以 REST 回执里的 messageId 与之后真正落库的是同一个。
三处 message_persisted 广播点各自改成了什么
- 原 :657(WS 上行 chat 之后那条)与 原 :1235(REST POST /api/v1/chat 之后那条):受本开关控制,
挂起时整条跳过(仍记录上行帧)。
- 原 :773(send_reply_round 里助手消息的落库)刻意不动:那是回复路径,本来就有自己的 persist
开关,本开关不去关它 —— 任务里也明说不要把回复路径一并关掉。已实跑验证:开关打开时
reply?persist=0 不落库、reply?persist=1 照旧广播 message_persisted。
拒绝场景(任务第 2 项):不需要新造
/__control/pending-full(载荷 {sessionId,mode,max,clientMsgId?,messageId?},与客户端
PendingFullData 逐字对应)、/__control/error?code=chat_busy(带 clientMsgId/messageId)与能发
任意帧的 /__control/frame 早就在了,形状也不缺东西。本次只实跑复核(三帧都在探针那边看到原样帧体、
回执 delivered=1)并写进 README,没有改它们的形状。
默认行为未变
--hold-replies 默认关,state.holdReplies=false、heldMessages=[];关着时三处广播、add_message 的
id 分配(新增的 message_id 参数只在挂起补落库时给值)、REST 回执形状逐字与改动前一致。
验证(本机实跑,端口 18099/18098,绝不用真后端的 10942)
挂起档:ws_probe 发 chat 后 2 秒里零回帧、out/downstream.jsonl 里 message_persisted 计数 0,
而 upstream.jsonl 与 wsUpstreamByType.chat 都记到了;POST /__control/persist?id=local-1 之后才在
活着的连接上收到 message_persisted(delivered=1,id 正是挂起时先占的 msg-000001)。REST 路径同理:
/api/v1/chat 的 202 回执时刻下游流水没有那一帧,2 秒后 persist 才有(时间戳可对)。pending_full /
error{chat_busy} / __control/frame 三帧都投递成功。运行期 on=0 → 立刻落库、on=1 → 挂住,双向有效。
默认档回归:立即落库、灌消息分页、复位都照旧。
README
新增第 6 节「场景四:忙时被拒 —— 让『待发气泡还挂着』可复现(--hold-replies)」:写清乐观气泡被
一个往返收走这个机制、开关怎么用、一条可复制的命令序列(起服务 → 发 chat 挂住 → 注入 pending_full →
期望的横幅文案),以及帧上带不带 clientMsgId/messageId 时客户端四种不同的处置。原第 6-10 节顺延为
7-11 节,并在开关表、控制端点表、记录文件节、已知不确定节各补了对应内容。
This commit is contained in:
1 parent
1f6c16c590
commit
c73dd532ff
2 files changed
+291
-8
No files matched your search
@@ -38,6 +38,7 @@ python fake_backend.py --port 18080
|
||||
| `--stream-chunk` | 关 | 回复走旧形状 `stream_chunk` + `stream_end`(验客户端的回退分支) |
|
||||
| `--auto-reply` | 关 | 收到上行 `chat` 帧后自动回一轮(默认关,保持确定性) |
|
||||
| `--interrupt-ack` | 关 | 收到上行 `interrupt` 帧后回 `session_interrupted{mode:"idle",dropped:0}` |
|
||||
| `--hold-replies` | 关 | 上行消息**只记录、不落库**:收到 `chat` / `POST /api/v1/chat` 后不广播 `message_persisted`,由 `POST /__control/persist` 显式补落库(见第 6 节) |
|
||||
| `--out-dir DIR` | `<脚本目录>/out` | 记录目录 |
|
||||
|
||||
## 2. 客户端怎么指过来
|
||||
@@ -133,7 +134,88 @@ python ws_probe.py --port 18080 --listen 4
|
||||
|
||||
想临时切回结构化形状,不改启动参数也行:`…/__control/reply?session=web&text=hi&frame=structured`。
|
||||
|
||||
## 6. 其它可编排场景(都试过)
|
||||
## 6. 场景四:忙时被拒 —— 让「待发气泡还挂着」可复现(`--hold-replies`)
|
||||
|
||||
**要验什么**:客户端手里**还挂着**一条没发出去的气泡时,收到「队列满 / 忙时被拒」会怎样。
|
||||
判据是客户端的纯函数 `queueFullOutcome`(`ChatViewModel.kt:240`),三档:
|
||||
|
||||
- 本会话待发 **0** 条 ⇒ 刻意**什么都不说**(没有气泡会挂住,不能凭空弹提示);
|
||||
- 恰好 **1** 条 ⇒ **回滚**:气泡摘掉、正文 / 图片 / 附件退回输入栏,另加一条横幅;
|
||||
- **≥2** 条 ⇒ **不猜**:一个气泡都不动,横幅只说「可能没发出去」(`QUEUE_FULL_UNKNOWN_MESSAGE`)。
|
||||
|
||||
**为什么需要这个开关**:默认行为下,客户端一发上行 `chat`(或 `POST /api/v1/chat`),假后端**立刻**
|
||||
把那条用户消息落库并广播 `message_persisted`;客户端拿 `clientMsgId` 一比就把乐观气泡收走
|
||||
(`consumePendingFor`,`ChatViewModel.kt:116`)。**一个往返之后待发列表就空了** —— 于是「服务端把这条
|
||||
拒掉」这类场景永远凑不出来:手里根本没有气泡可回滚,判定只会落到「0 条 ⇒ 静默」那一档。
|
||||
真后端忙时不会立刻落库(先排队或先拒绝),所以这是假后端的**建模缺陷**,这个开关就是修它。
|
||||
|
||||
**它做什么**:`--hold-replies` 打开后,收到上行消息**只记录、不落库、不广播** `message_persisted`:
|
||||
|
||||
- 上行帧照常进 `out/upstream.jsonl`,`/__control/state` 的 `wsUpstreamByType` 照常计数(判据来源不变);
|
||||
- **回复路径不受影响**:`--stream-chunk` / `--auto-reply` / `/__control/reply` 自己的 `persist=`
|
||||
该怎么走还怎么走 —— 那是另一个开关,关的是「上行那条的落库」这一件事;
|
||||
- 那条消息挂进 `state.heldMessages`(带 `holdId` / 先占的 `messageId` / `clientMsgId` / `heldAt`),
|
||||
由 `POST /__control/persist` **显式**补上落库那一步。
|
||||
|
||||
于是场景可以分两段做:「先把气泡挂住」→「再落库,或者干脆发一帧拒绝」。运行期也能开关:
|
||||
`POST /__control/hold-replies?on=1|0`(等价于启动参数,改完立刻生效)。
|
||||
|
||||
```bash
|
||||
B=http://127.0.0.1:18099
|
||||
|
||||
# 1) 起服务(换个端口,方便和默认那一档并排)
|
||||
python fake_backend.py --port 18099 --hold-replies
|
||||
|
||||
# 2) 发一条 chat:它会被挂住 —— 「挂住」的判据是 2 秒里一条回帧都没有
|
||||
python ws_probe.py --port 18099 --listen 2 \
|
||||
--send '{"type":"chat","data":{"sessionId":"web","clientMsgId":"local-1","text":"这条先挂着"}}'
|
||||
curl -s "$B/__control/state" # heldMessages 里有它;wsUpstreamByType.chat=1
|
||||
grep -c message_persisted out/downstream.jsonl # 0:一条都没广播
|
||||
|
||||
# 3) 显式补落库 —— 这时才广播 message_persisted
|
||||
# 先在一个终端起探针(它监听 5 秒):
|
||||
python ws_probe.py --port 18099 --ping --listen 5
|
||||
# 再在另一个终端补落库(回执里的 delivered 应当 >= 1):
|
||||
curl -s -X POST "$B/__control/persist?id=local-1"
|
||||
|
||||
# 4) 忙时被拒:再发一条(local-2)挂着,然后注入 pending_full —— 这是「1 条待发」那一档
|
||||
# 还是分两个终端:探针先发 chat 再监听 6 秒,把那段时间留给另一个终端注入
|
||||
python ws_probe.py --port 18099 --listen 6 \
|
||||
--send '{"type":"chat","data":{"sessionId":"web","clientMsgId":"local-2","text":"这条会被拒"}}'
|
||||
# 另一个终端注入(探针还在监听时执行,回执里的 delivered 应当是 1):
|
||||
curl -s -X POST "$B/__control/pending-full?session=web&mode=thinking&max=5&clientMsgId=local-2"
|
||||
# 探针那边就能看到那帧原样到达:
|
||||
# <<< frame 1 [after-send] {"type":"pending_full", …
|
||||
|
||||
# 客户端期望:气泡回滚、正文回到输入栏、横幅是 QUEUE_FULL_MESSAGE
|
||||
# 「她正忙且队列已满,这条没发出去,请稍后手动重试」(ChatViewModel.kt:138)
|
||||
```
|
||||
|
||||
**在 App 上跑同一条路**:带 `--hold-replies` 起服务、客户端指过来,在聊天页发一条,然后
|
||||
`curl -s -X POST "$B/__control/pending-full?session=web&mode=thinking&max=5"`(**不带** `clientMsgId`,
|
||||
走的就是「1 条待发」那一档:本会话只有这一条在飞,判定无歧义)。想让帧**精确**指向那条气泡,就把键
|
||||
填成客户端铸的 `localId` —— 它就在 `out/upstream.jsonl` 那条 chat 帧上(`data.clientMsgId`)。
|
||||
|
||||
帧上带不带那两个可选键,客户端的处置完全不同(与 `queueFullOutcome` 的判定顺序一一对应):
|
||||
|
||||
| 帧上的键 | 客户端看到的效果 |
|
||||
|---|---|
|
||||
| `&clientMsgId=<那条 localId>`,**不带** `messageId` | 命中那条气泡 ⇒ **回滚 + 横幅**(本场景要验的就是它) |
|
||||
| `&clientMsgId=<那条 localId>&messageId=m-1` | **收编**:只摘掉气泡,正文**不**退回输入栏(「其实早就进去了」) |
|
||||
| 不带 `clientMsgId`,本会话 1 条待发 | 就是它 ⇒ 回滚 + 横幅 |
|
||||
| 不带 `clientMsgId`,本会话 ≥2 条待发 | **不猜**:一个气泡都不动,横幅只说「可能没发出去」 |
|
||||
| 不带 `clientMsgId`,本会话 0 条待发 | 什么都不说(刻意静默) |
|
||||
|
||||
定向那一帧同理,与广播帧是**同一个 code、同一句文案**:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$B/__control/error?session=web&code=chat_busy&clientMsgId=local-2"
|
||||
```
|
||||
|
||||
同一件事被两条帧各报一次(广播 `pending_full` + 定向 `error{chat_busy}`)时,第二次到达气泡早已摘走,
|
||||
客户端不会重复归还(`QueueFullOutcomeTest` 钉住了这条)。
|
||||
|
||||
## 7. 其它可编排场景(都试过)
|
||||
|
||||
```bash
|
||||
B=http://127.0.0.1:18080
|
||||
@@ -143,11 +225,17 @@ curl -s -X POST "$B/__control/forget?session=web:5eed000000000000000000000000000
|
||||
curl -s "$B/api/v1/sessions/web:5eed0000000000000000000000000001/messages?limit=1"
|
||||
|
||||
# 队列满:广播 pending_full(可选带 clientMsgId / messageId,两个键都能缺席)
|
||||
# 忙时被拒的完整打法(先让气泡挂着再拒)见第 6 节
|
||||
curl -s -X POST "$B/__control/pending-full?session=web&mode=thinking&max=5&clientMsgId=local-zz"
|
||||
|
||||
# 定向错误帧(与 REST 的 409 chat_busy 同码)
|
||||
curl -s -X POST "$B/__control/error?session=web&code=chat_busy&clientMsgId=local-zz"
|
||||
|
||||
# 上行落库挂起 / 恢复,以及显式补落库(第 6 节那两个端点)
|
||||
curl -s -X POST "$B/__control/hold-replies?on=1"
|
||||
curl -s -X POST "$B/__control/hold-replies?on=0"
|
||||
curl -s -X POST "$B/__control/persist?session=web&id=local-zz"
|
||||
|
||||
# 忙碌 / 空闲 / 已打断
|
||||
curl -s -X POST "$B/__control/busy?session=web&mode=executing"
|
||||
curl -s -X POST "$B/__control/idle?session=web&mode=idle"
|
||||
@@ -183,7 +271,7 @@ curl -s "$B/__control/requests?limit=20"
|
||||
> Git Bash 的坑:命令行里的中文会被转成本地代码页(GBK)再交给子进程,服务端按 UTF-8 解 ⇒ 400 `invalid json body`。
|
||||
> 所以**带中文的请求体写进文件再用 `--data-binary @file`**,控制端点的参数用 URL 编码(`%E4%BD%A0%E5%A5%BD`)。
|
||||
|
||||
## 7. 端点速查
|
||||
## 8. 端点速查
|
||||
|
||||
`/api/v1/**`(一律包信封:成功 `{data}` / 失败 `{error:{code,message}}`,`ApiEnvelope.kt:8`):
|
||||
|
||||
@@ -211,11 +299,13 @@ curl -s "$B/__control/requests?limit=20"
|
||||
| `POST /__control/busy` / `idle` / `interrupted` | 下发 `session_busy` / `session_idle` / `session_interrupted` |
|
||||
| `POST /__control/reply?session=&text=&chunks=&frame=&persist=` | 下发一轮助手回复(`frame=default\|chunk\|structured`) |
|
||||
| `POST /__control/pending-full` / `error` / `frame` | 队列满、定向错误帧、任意帧 |
|
||||
| `POST /__control/hold-replies?on=` | 运行期开关:上行消息要不要挂起(等价于 `--hold-replies`) |
|
||||
| `POST /__control/persist?session=&id=&index=` | 把挂起的上行消息**显式**落库并广播 `message_persisted`;不给选择器就按 FIFO 落最旧那条 |
|
||||
| `POST /__control/ws-close?code=&reason=` | 踢掉所有客户端 WS(用来触发客户端的重连 / 补洞路径) |
|
||||
| `POST /__control/forget` / `create` / `clear` / `reset` | 会话级造场景 |
|
||||
| `GET /__control/state` / `upstream` / `requests` | 当前状态、上行帧、HTTP 请求流水 |
|
||||
|
||||
## 8. 记录文件(`out/`,每次启动追加,删掉即清空)
|
||||
## 9. 记录文件(`out/`,每次启动追加,删掉即清空)
|
||||
|
||||
- `out/upstream.jsonl` —— **每条上行帧一行**:`{ts, epochMs, client, type, sessionId, raw, frame}`。
|
||||
`raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。
|
||||
@@ -223,7 +313,10 @@ curl -s "$B/__control/requests?limit=20"
|
||||
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers}`。
|
||||
`Authorization` 只留 sha256 指纹、WS 的 `?token=` 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。
|
||||
|
||||
## 9. 形状出处(它照谁回话)
|
||||
挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的
|
||||
`heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。
|
||||
|
||||
## 10. 形状出处(它照谁回话)
|
||||
|
||||
| 实现 | 依据 |
|
||||
|---|---|
|
||||
@@ -246,7 +339,7 @@ curl -s "$B/__control/requests?limit=20"
|
||||
| `interrupt{scope}` 取值与「只取消在跑回合」 | `WsOutbound.kt:59-78` + `InterruptScope.kt:27` |
|
||||
| `stream_chunk` + `stream_end` 回退形状 | `WsEvent.kt:205-228` + `ChatViewModel.kt:1451-1472` |
|
||||
|
||||
## 10. 已知不确定 / 没做
|
||||
## 11. 已知不确定 / 没做
|
||||
|
||||
- **`history` 帧壳**:我发了 `{type, sessionId:"web", seq, data:{messages}}`。契约说 v0.36 起每个结构化流帧的帧壳
|
||||
都带 `sessionId`(`WsEvent.kt:21-27`),但客户端**对 `history` 不读帧壳**(`ChatViewModel.kt:1410-1412`
|
||||
@@ -258,5 +351,16 @@ curl -s "$B/__control/requests?limit=20"
|
||||
`observability_*` 推送、`subagent_ask` / `task_output_query`。上行收到这些帧**只记录、不处置**。因此 App 里
|
||||
管理员页面、附件上传这类路径在这个假后端上跑不通。
|
||||
- **`message_persisted` 与流帧的先后顺序**是我按「正文流完再落库」安排的,没有对着真后端核过。
|
||||
- **`--hold-replies` 的三个代价**(都刻意保留,因为改掉就不再是「只关那一件事」了):
|
||||
1. `POST /api/v1/chat` 的回执形状**逐字不变**(仍 `{stream:true,queued:false,queueDepth:0,messageId,sessionId}`),
|
||||
而那条消息此刻**并没有进库** —— 这个回执是「假后端在说已受理」的近似;想如实区分,看
|
||||
`/__control/state` 的 `heldMessages`。
|
||||
2. 挂起期间**不认领**幂等键(与「队列满的判定发生在认领之前」一致)⇒ 同一 `clientMsgId` 重发会得到
|
||||
**第二条**挂起记录,而不是 `chat_duplicate`。
|
||||
3. `createdAt` 在**补落库那一刻**才盖 ⇒ 若期间库里已经有更晚的消息,这条会以「晚到」的方式插进去,
|
||||
而 WS `history` 用的是**列表序**(不做排序)⇒ 这时的 `history` 顺序会与 REST 按 `createdAt` 的
|
||||
排序不同(默认模式下两者本来就一致,所以只有挂起场景看得到这个差)。
|
||||
- 挂起的消息**只活在内存里**(进程一停就没了);`/__control/reset` 会清空挂起列表,但**不动**模式开关
|
||||
(`holdReplies` 是模式,不是库里的状态)。
|
||||
- **不做 TLS**(只有 `http://`),也不支持 WebSocket 扩展 / permessage-deflate。
|
||||
- 控制端点的响应形状(`{"data":{...}}` 里的键)**是自造的**,不要照它写客户端代码。
|
||||
Reference in new issue
Block a user