docs(trellis): record mind send liveness contracts

This commit is contained in:
YBF
2026-09-12 15:35:15 +08:00
parent 6e77de7506
commit ec28fd746b
2 changed files with 75 additions and 0 deletions
@@ -61,3 +61,73 @@ installOneTalkHarnessRoute(app);
// Correct: 支架自己监听页面端口,server 只保留业务 HTTP/WS 路由。
const harnessServer = createOneTalkHarnessServer(config);
```
## Scenario: Harness send liveness and request deadlines
### 1. Scope / Trigger
- Trigger:修改联调页的 Mind WebSocket heartbeat、pending send、页面 socket 生命周期,或读取/上传 HTTP 等待行为。
- Scope`harness/runtime.ts` 拥有固定 deadline 与 `fetchWithDeadline``harness/websocket.ts` 拥有当前 socket、heartbeat ACK、local pending send 及其终态;`reading.ts``upload.ts` 只消费 shared helper。Server 仍由 `connection-store.ts``pending-send-coordinator.ts` 决定 plugin lease admission。
- Excluded:生产页面、共享 wire contract、per-send preflight heartbeat、自动重试、跨实例 presence、Harness 测试框架与 config plumbing。
### 2. Signatures
```text
heartbeatAckTimeoutMs = 75_000
requestTimeoutMs = 30_000
fetchWithDeadline(url, options, consumeResponse) -> Promise<T>
store.isPluginLeaseFresh(connection) -> boolean
```
### 3. Contracts
- 页面同一时刻至多等待一个 `heartbeat.ack`;发送 heartbeat 后启动 75 秒 deadline,匹配的 `requestId``sentAtMs` ACK 才能清除它。deadline 或当前 socket close 必须停止 interval/timeout、使当前 socket 失效并禁用发送。
- 本地连接失效时,若有 `pendingSendRequestId`,页面只结束自己的等待并显示“未确认,结果未知(不会自动重试)”。它不得伪造 `send.result`、标记 confirmed 或重发;后续旧 socket/旧 request 的 frame 与 error callback 不能修改当前页面状态。
- 读取和上传都必须经 30 秒 `AbortController` deadline。helper 在 `fetch` 与 response consumer 之后都确认 abort,避免 deadline 与 JSON 解析完成竞争时误报成功;timeout 以稳定 `request_timeout` 交给既有可见错误与 `finally` 清理。
- Server 的 lease admission 是独立权威:只有 canonical plugin 且 heartbeat 未过期可被挑选,并且在授权 await 后、`send.command` 前必须再次同步复核。Harness `plugin.status` 只作最后观测的 display/precheck,不能覆盖 Server 结果。
### 4. Validation & Error Matrix
| 条件 | 结果 |
| --- | --- |
| heartbeat ACK 缺失超过 75 秒 | 当前 socket 失效,发送禁用;pending send 仅显示 local unknown |
| ACK 的 requestId/sentAtMs 不匹配 | 忽略;不清除当前 deadline |
| 已替换 socket 的 `onmessage` / `onerror` / `onclose` | 不改变新 socket 的状态或 status text |
| 读取或上传超过 30 秒,包含 response body 解析期间的 deadline | `request_timeout` 可见;读取走既有 error path,上传释放控件 |
| Server 在选择后、授权返回前 lease 过期 | `rejected_before_send/waiting_for_page`;无 command dispatch |
| 已发送 command 后才断线或没有确认 | 保持 Server `delivery_unknown` 语义;页面不得自动重试 |
### 5. Good / Base / Bad Cases
- Good:只在 `state.socket === socket` 时处理回调;socket cleanup 集中清除 heartbeat interval、ACK timeout、request identity 和 local pending send。
- Base:手工页面以显示状态帮助诊断,但不拥有或推断 Server 对 plugin routability 的最终判断。
- Bad:用周期性 `plugin.status` 当 lease、对每次发送额外 preflight heartbeat、给 timeout 伪造 confirmed result、在旧 socket callback 中更新新连接页面,或让读取/上传无限等待。
### 6. Tests Required
- Server 的可控 clock/latch 测试应覆盖选择时过期和授权 await 后过期,并断言两者均不 dispatch `send.command`
- Harness 保持无测试文件与测试脚本;修改其 inline script 至少运行 TypeScript、生成页面后脚本语法检查、Oxfmt 与 `git diff --check`。若环境可用,再手工验证 ACK deadline、local unknown、超时 `request_timeout` 和控件恢复;该手工结果是诊断证据,不接入根质量门禁。
- 任意 Harness timer 或 callback 改动都应审查 current-socket identity、所有 timer 的对称清理,以及 deadline 与成功/解析完成竞争时的终态优先级。
### 7. Wrong vs Correct
```ts
// Wrong: a stale socket can overwrite the status of its replacement.
socket.onerror = () => setStatus(fields.connectionStatus, '连接错误', 'error');
// Correct: only the current socket owns page updates.
socket.onerror = () => {
if (state.socket !== socket) return;
setStatus(fields.connectionStatus, '连接错误', 'error');
};
```
```ts
// Wrong: response parsing can finish after the deadline and still return success.
return await consumeResponse(response);
// Correct: settle timeout before exposing a parsed response.
const result = await consumeResponse(response);
if (controller.signal.aborted) throw requestTimeoutError();
return result;
```
@@ -141,6 +141,7 @@ return router.dispatch(frame);
createOneTalkCutoverPolicy(initial?: { enabled: boolean; paused: boolean }): OneTalkCutoverPolicy
registry.requestSend({ mindSocket, frame }): Promise<OneTalkSendResultFrame["payload"]>
registry.createCommitGuard(connection, policyEpoch): OneTalkCommitGuard
store.isPluginLeaseFresh(connection): boolean
repository.guardedInsertMessage(context, source, message, guard): Promise<InsertResult>
repository.guardedUpdateSyncState(context, update, conversationId, guard): Promise<Conversation | null>
```
@@ -149,6 +150,7 @@ repository.guardedUpdateSyncState(context, update, conversationId, guard): Promi
- Bright v3 policy 只通过 `enabled/paused/epoch` 控制 admissionpause/resume 可以恢复 Bright v3,不依赖或保存 `firstBrightFactWritten`/`markBrightFactWritten`
- `OneTalkConnectionRegistry` 是 handler-facing façade;其内部 `connection-store` 是 canonical connection、generation 和 plugin presence 的唯一 owner`mind-publisher` 只做 Mind 二次授权/帧发送/失败上报,`pending-send-coordinator` 是 in-flight `sendRequestId` reserve、phase、terminal transition、timeout、disconnect、pause 和 late confirmation 的唯一 owner。它只保存进行中的 attempt;`settle` 必须清除 `pendingSends``pendingConversationSends`,不得持有终态 payload、终态 ID 去重历史或重放缓存。三个协作者必须注入同一 store,不能在 handler 或其它模块复制 socket/generation/pending map;所有 wire send 后结果不可自动重试。
- Plugin 的可路由性由 store 的 canonical lease 唯一决定:`isPluginLeaseFresh` 只接受仍注册的 plugin,且 `lastHeartbeatAtMs > now() - heartbeatTimeoutMs``requestSend` 必须在候选选择和全部异步授权后的无 await 最终 fence 都调用它;expired lease 即使 sweep 尚未运行也只能得到既有 `rejected_before_send/waiting_for_page`,不得发出 `send.command`
- HTTP 读取只依赖 `OneTalkPluginPresenceReader.isPluginOnline(scope)`,不得为读取路径注入或依赖完整 registry 的发送、连接管理或发布能力。
- 远程 observation/discovery/sync/confirmation 的 database side effect 必须带同一 canonical connection/generation/policy guard。guard 失效必须使事务回滚,不返回伪造的 accepted/duplicate。
@@ -157,6 +159,7 @@ repository.guardedUpdateSyncState(context, update, conversationId, guard): Promi
| 条件 | 结果 |
| --- | --- |
| 相同 ID 的并发 send 在 attempt 仍进行中 | 一个 attempt,另一个 `rejected_before_send/duplicate_request`;终态后复用 ID 不属于 coordinator 协议契约 |
| 候选 plugin 已过 heartbeat lease,或在授权期间 lease 过期 | `rejected_before_send/waiting_for_page`;无 `send.command` |
| wire send 前断线、替换或 pause | `rejected_before_send/waiting_for_page` |
| wire send 后断线、替换或 pause | `delivery_unknown/send_connection_lost` |
| confirmation timeout/non-success/duplicate/late | 唯一 terminal;清 timer 与全部 pending 索引;终态后的 confirmation no-op |
@@ -166,6 +169,7 @@ repository.guardedUpdateSyncState(context, update, conversationId, guard): Promi
### 5. Good / Base / Bad Cases
- Good:最后一次授权返回后只做同步 canonical/generation/epoch/open 检查,再调用 `socket.send`
- Good:store 在一个查询内同时确认 plugin 身份、canonical membership 与 heartbeat deadlinecoordinator 在选择和发送前均复用该查询。
- Base:本地 fake repository 和 fake Mind fetch 证明调用顺序与 fail-closed 逻辑;真实 PostgreSQL/Mind/浏览器仍需独立环境验收。
- Bad:把 pending 记录放在 authorization await 之后、在 confirmation 中重新授权原 Mind Session、或为 guarded write fallback 到普通 insert。
@@ -174,6 +178,7 @@ repository.guardedUpdateSyncState(context, update, conversationId, guard): Promi
- 使用可控 Promise latch 覆盖 authorization、service、transaction、publish 各 await 点的 pause/disconnect/replacement;关键竞态连续 100 次运行。
- 断言 `message.created` 只在 DB commit 后 publishterminal/guard 失败不写库、不 ACK、不 publish;独立 PostgreSQL 测试配置 `TEST_DATABASE_URL` 后再验证真实 rollback/commit 线性化。
- registry 重构测试必须锁定 façade 的 canonical cleanup/plugin replacement、精确 scope 发布与二次授权、presence 通知、pending-send timeout/connection-loss 映射和 confirmation 单次 claim;拆分后 connection/generation 与 pending/terminal 状态各自只能有一个 owner。
- send admission 测试以可控 clock 覆盖 lease 边界和授权 await 后过期;两种情况都断言无 `send.command`,非过期 lease 保持既有 dispatch/confirmation 行为。
### 7. Wrong vs Correct