Merge pull request #47 from sinanyuntu/09-12-mind-send-liveness

收紧 Mind 发送存活性与 Harness 超时处理
This commit is contained in:
YBF
2026-09-12 15:37:20 +08:00
committed by GitHub
17 changed files with 488 additions and 36 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
@@ -0,0 +1,4 @@
{"file":".trellis/spec/project/architecture.md","reason":"Review ownership, dependency direction, and narrow module responsibilities."}
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"Review timer cleanup, terminal states, and stale callback handling."}
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"Apply Server static and compilation validation guidance without adding tests."}
{"file":".trellis/spec/mind-test-harness/development/boundary.md","reason":"Verify the change remains a development-only manual tool without test/build orchestration changes."}
@@ -0,0 +1,104 @@
# Design: Harden Mind send liveness
## Objective
Maintain one authoritative Server-side answer to “is a plugin currently
routable?”, while ensuring the local Harness page never treats the absence of a
response as continued health or an indefinitely pending operation.
## Server: the in-memory lease is the authority
`createOneTalkConnectionStore` remains the sole owner of connection membership,
generation, heartbeat timestamps, and lease duration. It will expose one narrow
freshness query for an already registered connection. The query is true only
when the supplied connection is still canonical and its `lastHeartbeatAtMs` is
strictly newer than `now() - heartbeatTimeoutMs`.
`requestSend` will use that query twice:
1. while selecting the unique candidate, before reserving a send; and
2. after its asynchronous authorization checks, immediately before dispatching
`send.command`.
This prevents a lease that expires during authorization from being dispatched.
The periodic sweep remains responsible for eventual removal, close, and status
notification; it no longer creates a window in which expired membership can be
admitted for a send.
The existing canonical pre-dispatch result remains
`rejected_before_send / waiting_for_page`. No new public result code is needed:
the contract already expresses that no exact routable page is available, and
introducing `plugin_offline` would widen the shared protocol for no behavioral
gain.
No server-initiated heartbeat is added before each send. A successful preflight
ACK would only prove a point in time before dispatch, adds latency and a second
timeout state, and does not repair a broken Server-to-Mind return path.
## Harness page: local liveness and terminal states
The Harness page does not become a second plugin-presence authority. Its
`plugin.status` remains a last-observed display/precheck. It adds a local
Mind-to-Server liveness guard:
- one heartbeat may be awaiting an ACK at a time;
- starting that heartbeat starts a fixed 75-second deadline;
- a matching ACK clears the request record and deadline;
- an expired deadline invalidates the current socket, marks the connection
unavailable, disables all send controls, and directs the operator to the
existing manual recovery/reconnect flow.
If a send is pending when local liveness ends, the page clears only its local
pending marker and reports an unconfirmed/unknown outcome. It must not emit or
pretend to have received a `send.result`, mark it confirmed, or resend it.
The same local settlement is used for an explicit socket close while a send is
pending. Late server frames are ignored because the local pending ID has ended
or the socket is no longer current.
The existing state object remains the only Harness-page state owner. Timers and
their request identity are added there and are cleared by the existing socket
cleanup path. A focused helper local to `harness/websocket.ts` owns the
"pending send became locally unknown" transition, avoiding duplicate UI updates
across ACK timeout and close handling.
## Harness HTTP deadlines
The Harness page adds a shared, page-local `fetch` wrapper in the runtime
script. Each invocation owns an `AbortController` and 30-second timer; aborts
become a stable `request_timeout` error for the existing error-text mapping.
Both read flows and the upload flow use it. Their existing `catch/finally`
paths therefore render the visible error and release upload controls. This does
not add retries, change server routes, or add a global loading coordinator.
## State transitions
```text
Server: registered plugin + fresh lease
└─ send request → recheck fresh lease → authorize → recheck fresh lease
├─ invalid → rejected_before_send / waiting_for_page
└─ valid → send.command → existing confirmation / timeout flow
Harness: accepted socket
└─ heartbeat pending → matching ACK → continue
└─ 75s deadline → local connection unavailable
├─ no pending send → disable controls; manual reconnect
└─ pending send → local unknown; disable controls; manual reconnect
Harness: HTTP request
└─ response → existing parse/result path
└─ 30s → abort → request_timeout → existing visible error/finally path
```
## Compatibility and risk
- Server behavior becomes stricter only for expired leases; valid routes retain
existing send, authorization, confirmation, and timeout semantics.
- The 75-second Harness deadline intentionally matches the current default
lease, but remains page-local. Changing Server configuration later does not
silently change the manual page; this is accepted to keep the dev tool simple.
- A local unknown outcome may coexist with a later real-world send. This is the
required safe representation: no automatic resend or synthetic terminal
Server frame is permitted.
- This task deliberately does not add tests or a fault-injection harness per
user decision and package boundary. Compiler/static validation cannot prove a
real half-open network path and will be reported as such.
@@ -0,0 +1,4 @@
{"file":".trellis/spec/project/architecture.md","reason":"Shared ownership and async-state boundary rules for the Server lease and Harness page changes."}
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"Required lifecycle guidance for timers, socket invalidation, pending requests, and late acknowledgements."}
{"file":".trellis/spec/server/backend/index.md","reason":"Server package boundary and WebSocket implementation baseline."}
{"file":".trellis/spec/mind-test-harness/development/boundary.md","reason":"Development-only Harness scope and explicit prohibition on new test infrastructure."}
@@ -0,0 +1,49 @@
# Implementation plan: Harden Mind send liveness
## Scope owners
- `apps/server/src/websocket/connection-store.ts`: canonical lease freshness
query and existing presence query semantics.
- `apps/server/src/websocket/pending-send-coordinator.ts`: use lease freshness
before reservation and immediately before `send.command`.
- `apps/mind-test-harness/src/harness/runtime.ts`: fixed page-local timeout
constants and timer state.
- `apps/mind-test-harness/src/harness/websocket.ts`: single outstanding
heartbeat, ACK deadline, local unknown send settlement, and cleanup.
- `apps/mind-test-harness/src/harness/reading.ts`, `upload.ts`, `errors.ts`:
shared deadline-aware fetch and visible timeout text.
## Ordered work
1. Run GitNexus impact analysis for every Server/Harness symbol selected for
editing; report any high/critical finding before proceeding.
2. Extend the connection-store public interface with a canonical, time-aware
freshness query. Keep lease math and membership identity in the store.
3. Apply the query at both Server send admission fences. Preserve all existing
result shapes and the current pending-send coordinator ownership.
4. Add the 75-second heartbeat ACK deadline and 30-second HTTP deadline to the
Harness page runtime. Do not add configuration plumbing.
5. Implement single-flight heartbeat acknowledgement tracking. On deadline or
socket close, settle any local pending send as unconfirmed/unknown, invalidate
the socket, disable sending, and retain manual recovery/reconnect.
6. Route reads and uploads through a 30-second `AbortController` wrapper and
map the abort to a stable visible timeout error.
7. Review the complete diff for duplicated state ownership, timeout/retry
behavior, synthetic success, and any accidental protocol expansion.
## Validation and review
- No test files or test commands are added, per user decision and Harness
package boundary.
- Run targeted TypeScript compilation for the edited Server and Harness
packages if local dependencies permit; run formatting and `git diff --check`.
- Inspect the final source paths to verify the Server checks the same owned
lease at both send fences, and the Harness clears every timer on socket
invalidation.
- Record that static/compile checks do not simulate a half-open network.
## Rollback
- The change is limited to in-memory lease admission and the development-only
Harness page. Reverting this task restores previous admission/indefinite-wait
behavior without a schema, migration, or protocol migration.
@@ -0,0 +1,107 @@
# Harden Mind send liveness
## Goal
Make Server send admission fail closed on an expired plugin lease, and make the
local Harness page end all waiting states visibly. At the instant Server receives
a Mind `send.request`, it must use its own in-memory connection lease rather
than a stale `WebSocket.OPEN` entry to decide whether a plugin is currently
routable. When the Harness page cannot prove that its WebSocket or HTTP request
is still live, it must not continue to present a healthy or indefinitely pending
state.
## Terminology
- **Harness page**: the local browser-facing manual integration page served by
`apps/mind-test-harness` (normally port 8788). It represents the Mind page
for this task and owns the WebSocket/send/read/upload UI.
- **Mind authorization mock**: the separate local service in the same package
(normally port 8787). It is not part of this liveness work.
## Confirmed Facts
- The Server owns plugin presence in process memory, not in a database or Redis:
`connections` retains `lastHeartbeatAtMs`; `activePlugins` maps the full Mind
scope to its unique plugin socket. A new plugin replaces the old one.
- Server heartbeat processing refreshes the timestamp, and a 25-second sweep
closes entries older than the configured 75-second default lease
(`apps/server/src/websocket/connection-store.ts:118-120,204-267`).
- `requestSend` currently selects a candidate from live connections by scope,
binding, permission, and `WebSocket.OPEN`, but does not synchronously require
that the recorded lease remains unexpired
(`apps/server/src/websocket/pending-send-coordinator.ts:139-154`).
- The Harness page enables sending from its last received `plugin.status=online`
and `WebSocket.OPEN`. It records heartbeat IDs, but a missing correlated ACK
has no deadline or state transition (`apps/mind-test-harness/src/harness/websocket.ts:63-87,137-163,227-235`).
- The Harness clears its pending send only after a matching `send.result` or
`onclose`; its `onerror` only changes text. A half-open Server-to-Mind path
can therefore leave the page permanently in "sending"
(`apps/mind-test-harness/src/harness/websocket.ts:250-265`).
- Harness HTTP reads and OSS uploads call `fetch` without cancellation or a
deadline, so their UI can remain loading/uploading forever
(`apps/mind-test-harness/src/harness/reading.ts:50-63`,
`apps/mind-test-harness/src/harness/upload.ts:21-63`).
## Requirements
1. Treat the Server's in-memory plugin lease as the sole send-admission fact.
At `send.request` time, a candidate must have the correct identity,
permission, live socket, and a non-expired lease; otherwise do not dispatch
`send.command`.
2. Preserve fail-closed send semantics. An unavailable plugin produces an
explicit pre-dispatch rejection; a dispatched send without a confirmed
terminal result remains `delivery_unknown`, with no automatic resend.
3. Do not use per-send Server-initiated plugin heartbeats as the normal
admission mechanism. They add a round trip without eliminating the race
after acknowledgement; continuous plugin lease renewal plus an exact
admission check is the chosen model.
4. Keep `plugin.status` as a connection snapshot and online/offline transition,
not a periodic source of truth for the Mind page.
5. Keep the same semantic rule in the Harness page: every locally initiated
wait must reach a visible terminal state. A missing heartbeat ACK disables
sending and locally reports any pending send as unconfirmed/unknown; it does
not invent a server terminal result or retry.
6. Bound Harness HTTP reads and uploads with cancellation. A timeout must be
shown as an explicit failure and release the relevant UI controls.
7. Keep implementation cost intentionally narrow: use the existing modules,
status widgets, manual recovery path, and current single-process Server
memory store. Do not add Harness test files or a browser/network
fault-injection framework.
## Acceptance Criteria
- [ ] A plugin whose `lastHeartbeatAtMs` is past the configured lease deadline
is rejected synchronously at send admission even if the sweep has not yet
run; no `send.command` reaches that plugin.
- [ ] Existing non-expired lease dispatch and confirmation behavior, including
text/media timeout semantics, is retained.
- [ ] A Harness heartbeat ACK deadline disables all send controls, changes the
WebSocket UI from healthy to failed/unavailable, and converts any local
pending send from "sending" to an explicitly unconfirmed/unknown outcome.
- [ ] A later `send.result` is ignored once the local send has ended; no local
timeout is represented as `confirmed_sent` or retried automatically.
- [ ] Timed-out Harness reads and uploads show an explicit error and restore
their controls rather than remaining loading/uploading.
- [ ] No new test files, package scripts, root quality-gate changes, or
browser-fault injection are added. Validation is limited to proportionate
static/compilation checks and manual code-path review.
## Out of Scope
- Redis/database-backed presence, multi-instance WebSocket routing, and new
cross-instance coordination.
- Periodic `plugin.status` broadcasts or a per-send plugin preflight heartbeat
protocol.
- Changes to plugin identity, authorization policy, message persistence,
delivery acknowledgement, or automatic send retries.
- New automated Harness tests, a browser/network fault-injection framework, or
changes to root test/build/typecheck orchestration.
## Decisions
- The Harness page uses a fixed 75-second missing-heartbeat-ACK deadline,
aligned with the current Server lease default.
- Harness HTTP reads and uploads use a fixed 30-second deadline.
- The user explicitly accepts no new automated tests or fault-injection work;
the task remains a narrow implementation with proportionate compile/static
validation only.
@@ -0,0 +1,26 @@
{
"id": "mind-send-liveness",
"name": "mind-send-liveness",
"title": "Harden Mind send liveness",
"description": "Fail closed on expired plugin leases and converge the Mind harness after lost acknowledgements",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-12",
"completedAt": "2026-09-12",
"branch": "09-12-mind-send-liveness",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+1
View File
@@ -29,6 +29,7 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 64 | 2026-09-12 | Harden Mind send liveness | `440df49`, `cc2ee8f` | `09-12-mind-send-liveness` |
| 63 | 2026-09-12 | OneTalk 结构化消息信息采集分类 | `f92f00d`, `31400fc`, `86447cc` | `main` |
| 62 | 2026-09-12 | 规范化 OneTalk 合约包位置 | `4d46b5b` | `09-12-normalize-onetalk-contract-package` |
| 61 | 2026-09-12 | 收敛 OneTalk WebSocket 路由分发 | `bd9cc6a` | `09-11-websocket-routing-single-source-of-truth` |
+12
View File
@@ -1353,6 +1353,16 @@ Moved Mind-only and Plugin-only WebSocket implementations to canonical endpoint
### Summary
完成并合并 OneTalk 历史 SDK 结构化业务卡采集:以完整联合条件识别名片、询盘和订单;名片落库严格保持 marker,读取时才按账号和会话组合独立客户资料;订单仅传递白名单摘要并为异常保留可观测结果。任务经 PR #42/#43 合入 main,后续结构化事实等价性修复也已在 main。当前工作树干净;收尾复验中合同运行时测试 36/36 通过、迁移 check 通过,跨包测试和 typecheck 因本地 node_modules 仍指向已迁移的旧 apps/onetalk-contract 路径且缺少 @types/node 未能重跑。
## Session 64: Harden Mind send liveness
<!-- trellis-session: v=2 fp=67f025dc90dd2a4f -->
**Date**: 2026-09-12
**Task**: Harden Mind send liveness
**Branch**: `09-12-mind-send-liveness`
### Summary
Fail-closed plugin lease admission plus Harness heartbeat and HTTP deadline terminal states; static and existing server tests passed, browser fault paths remain runtime-unverified.
### Git Commits
@@ -1361,6 +1371,8 @@ Moved Mind-only and Plugin-only WebSocket implementations to canonical endpoint
| `f92f00d` | feat: normalize OneTalk business card content |
| `31400fc` | fix: keep OneTalk business cards as markers |
| `86447cc` | docs: clarify OneTalk business card profile projection |
| `440df49` | fix(onetalk): harden mind send liveness |
| `cc2ee8f` | docs(trellis): record mind send liveness contracts |
### Status
@@ -19,6 +19,7 @@ export const harnessErrorsScript = String.raw` const errorText = (err
oss_upload_invalid_request: '上传文件或元数据无效',
oss_upload_origin_not_allowed: '当前页面 Origin 不被 Bright 允许上传',
oss_upload_unavailable: 'Bright 未配置 OSS 上传能力',
request_timeout: '请求超过 30 秒未完成',
invalid_response: 'Bright 响应不符合读取契约',
http_error: 'Bright 返回了未知 HTTP 错误'
};
+14 -13
View File
@@ -48,19 +48,20 @@ export const harnessReadingScript = String.raw` const requestedChanne
};
const readResponse = async (url, requestHeaders = {}) => {
const response = await fetch(url, { headers: requestHeaders, credentials: 'include' });
let body = null;
try { body = await response.json(); } catch (_) { body = null; }
if (!response.ok) {
const code = isReadErrorResponse(body) ? body.error.code : 'http_error';
const error = new Error(code);
error.code = code;
const retryAfter = response.headers.get('retry-after');
if (/^\d+$/.test(retryAfter || '')) error.retryAfter = Number(retryAfter);
throw error;
}
if (!isRecord(body)) throw new Error('invalid_response');
return body;
return fetchWithDeadline(url, { headers: requestHeaders, credentials: 'include' }, async (response) => {
let body = null;
try { body = await response.json(); } catch (_) { body = null; }
if (!response.ok) {
const code = isReadErrorResponse(body) ? body.error.code : 'http_error';
const error = new Error(code);
error.code = code;
const retryAfter = response.headers.get('retry-after');
if (/^\d+$/.test(retryAfter || '')) error.retryAfter = Number(retryAfter);
throw error;
}
if (!isRecord(body)) throw new Error('invalid_response');
return body;
});
};
const fillConversations = () => {
@@ -1,6 +1,8 @@
// 页面脚本分片:页面状态与控件引用(由 harness/index.ts 组装进主 IIFE
export const harnessRuntimeScript = String.raw` const heartbeatIntervalMs = 25000;
const heartbeatAckTimeoutMs = 75000;
const requestTimeoutMs = 30000;
const state = {
scope: null,
conversationId: '',
@@ -17,6 +19,7 @@ export const harnessRuntimeScript = String.raw` const heartbeatInterv
socketAccepted: false,
helloRequestId: null,
heartbeatTimer: null,
heartbeatAckTimer: null,
heartbeatRequestId: null,
heartbeatSentAtMs: null,
requestSequence: 0,
@@ -67,4 +70,28 @@ export const harnessRuntimeScript = String.raw` const heartbeatInterv
const setStatus = (target, text, stateName) => {
target.dataset.state = stateName;
target.querySelector('span').textContent = text;
};
const fetchWithDeadline = async (url, options, consumeResponse) => {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), requestTimeoutMs);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
const result = await consumeResponse(response);
if (controller.signal.aborted) {
const timeoutError = new Error('request_timeout');
timeoutError.code = 'request_timeout';
throw timeoutError;
}
return result;
} catch (error) {
if (controller.signal.aborted) {
const timeoutError = new Error('request_timeout');
timeoutError.code = 'request_timeout';
throw timeoutError;
}
throw error;
} finally {
window.clearTimeout(timeout);
}
};`;
+20 -19
View File
@@ -31,30 +31,31 @@ export const harnessUploadScript = String.raw` const setUploadStatus
setUploadStatus('上传中…', 'warn');
updateUploadAvailability();
try {
const response = await fetch(uploadUrlFor(file.name, mimeType), {
await fetchWithDeadline(uploadUrlFor(file.name, mimeType), {
method: 'POST',
headers: { 'content-type': 'application/octet-stream' },
credentials: 'include',
body: file
}, async (response) => {
let body = null;
try { body = await response.json(); } catch (_) { body = null; }
if (!response.ok) {
const code = isUploadErrorResponse(body) ? body.error.code : 'http_error';
const error = new Error(code);
error.code = code;
throw error;
}
if (!isUploadResponse(body)) throw new Error('invalid_response');
state.uploadedUrl = body.downloadUrl;
fields.uploadDownloadUrl.value = body.downloadUrl;
fields.fileSourceUrl.value = body.downloadUrl;
fields.fileName.value = body.fileName;
fields.fileMimeType.value = body.mimeType;
if (body.mimeType.startsWith('image/') && imageContentFromUrl(body.downloadUrl)) {
fields.imageSourceUrl.value = body.downloadUrl;
}
setUploadStatus('上传成功:已填入图片或附件发送字段', 'ok');
});
let body = null;
try { body = await response.json(); } catch (_) { body = null; }
if (!response.ok) {
const code = isUploadErrorResponse(body) ? body.error.code : 'http_error';
const error = new Error(code);
error.code = code;
throw error;
}
if (!isUploadResponse(body)) throw new Error('invalid_response');
state.uploadedUrl = body.downloadUrl;
fields.uploadDownloadUrl.value = body.downloadUrl;
fields.fileSourceUrl.value = body.downloadUrl;
fields.fileName.value = body.fileName;
fields.fileMimeType.value = body.mimeType;
if (body.mimeType.startsWith('image/') && imageContentFromUrl(body.downloadUrl)) {
fields.imageSourceUrl.value = body.downloadUrl;
}
setUploadStatus('上传成功:已填入图片或附件发送字段', 'ok');
} catch (error) {
setUploadStatus(errorText(error), 'error');
} finally {
@@ -99,16 +99,25 @@ export const harnessWebsocketScript = String.raw` const frameMatchesF
const stopHeartbeat = () => {
if (state.heartbeatTimer !== null) window.clearInterval(state.heartbeatTimer);
if (state.heartbeatAckTimer !== null) window.clearTimeout(state.heartbeatAckTimer);
state.heartbeatTimer = null;
state.heartbeatAckTimer = null;
state.heartbeatRequestId = null;
state.heartbeatSentAtMs = null;
};
const settlePendingSendAsUnknown = () => {
if (state.pendingSendRequestId === null) return;
state.pendingSendRequestId = null;
setStatus(fields.syncStatus, '本地连接不可用;发送未确认,结果未知(不会自动重试)', 'error');
updateSendAvailability();
};
const clearSocketState = () => {
stopHeartbeat();
state.socketAccepted = false;
state.helloRequestId = null;
state.pendingSendRequestId = null;
settlePendingSendAsUnknown();
setPluginStatus('offline');
};
@@ -123,6 +132,16 @@ export const harnessWebsocketScript = String.raw` const frameMatchesF
updateSendAvailability();
};
const invalidateSocket = (socket, status) => {
if (state.socket !== socket) return;
state.socket = null;
clearSocketState();
socket.onclose = null;
socket.close();
setStatus(fields.connectionStatus, status, 'error');
updateSendAvailability();
};
const nextRequestId = (prefix) => {
state.requestSequence += 1;
return prefix + '-' + state.requestSequence;
@@ -139,7 +158,8 @@ export const harnessWebsocketScript = String.raw` const frameMatchesF
state.socket !== socket ||
!state.socketAccepted ||
!matchesCurrentScope(scope) ||
socket.readyState !== WebSocket.OPEN
socket.readyState !== WebSocket.OPEN ||
state.heartbeatRequestId !== null
) {
return;
}
@@ -147,6 +167,10 @@ export const harnessWebsocketScript = String.raw` const frameMatchesF
const requestId = nextRequestId('heartbeat');
state.heartbeatRequestId = requestId;
state.heartbeatSentAtMs = sentAtMs;
state.heartbeatAckTimer = window.setTimeout(() => {
if (state.socket !== socket || state.heartbeatRequestId !== requestId) return;
invalidateSocket(socket, '心跳确认超时;点击“消息恢复后重连”');
}, heartbeatAckTimeoutMs);
sendWsFrame(socket, {
protocolVersion,
connectionType: 'mind_page',
@@ -229,6 +253,8 @@ export const harnessWebsocketScript = String.raw` const frameMatchesF
frame.requestId === state.heartbeatRequestId &&
frame.payload.sentAtMs === state.heartbeatSentAtMs
) {
if (state.heartbeatAckTimer !== null) window.clearTimeout(state.heartbeatAckTimer);
state.heartbeatAckTimer = null;
state.heartbeatRequestId = null;
state.heartbeatSentAtMs = null;
}
@@ -256,7 +282,10 @@ export const harnessWebsocketScript = String.raw` const frameMatchesF
setStatus(fields.syncStatus, status + ' · ' + text + (frame.payload.reason ? ' · ' + frame.payload.reason : ''), status === 'confirmed_sent' ? 'ok' : status === 'rejected_before_send' ? 'warn' : 'error');
}
};
socket.onerror = () => setStatus(fields.connectionStatus, '连接错误;消息仍可读取', 'error');
socket.onerror = () => {
if (state.socket !== socket) return;
setStatus(fields.connectionStatus, '连接错误;消息仍可读取', 'error');
};
socket.onclose = () => {
if (state.socket !== socket) return;
state.socket = null;
@@ -66,6 +66,7 @@ export type OneTalkConnectionStore = {
register: (connection: OneTalkRegisteredConnection) => () => void;
unregister: (socket: WebSocket) => void;
isPluginOnline: (scope: OneTalkMindScope) => boolean;
isPluginLeaseFresh: (connection: OneTalkRegisteredConnection) => boolean;
getConnections: () => OneTalkRegisteredConnection[];
getGeneration: (socket: WebSocket) => number | undefined;
currentEpoch: () => number;
@@ -243,6 +244,13 @@ export const createOneTalkConnectionStore = (options: {
};
const isPluginOnline = (scope: OneTalkMindScope): boolean => hasPluginForScope(scope);
const isPluginLeaseFresh = (connection: OneTalkRegisteredConnection): boolean => {
return (
connection.connectionType === "plugin" &&
connections.get(connection.socket) === connection &&
(connection.lastHeartbeatAtMs ?? 0) > now() - heartbeatTimeoutMs
);
};
const getConnections = (): OneTalkRegisteredConnection[] => [...connections.values()];
const getGeneration = (socket: WebSocket): number | undefined => generations.get(socket);
const recordHeartbeat = (socket: WebSocket): boolean => {
@@ -279,6 +287,7 @@ export const createOneTalkConnectionStore = (options: {
register,
unregister,
isPluginOnline,
isPluginLeaseFresh,
getConnections,
getGeneration,
recordHeartbeat,
@@ -145,7 +145,8 @@ export const createOneTalkPendingSendCoordinator = (options: {
isSameOneTalkScope(connection.mindScope, mind.mindScope) &&
connection.binding === mind.binding &&
connection.permissions.includes("send") &&
connection.socket.readyState === WEBSOCKET_OPEN,
connection.socket.readyState === WEBSOCKET_OPEN &&
options.store.isPluginLeaseFresh(connection),
);
if (candidates.length === 0)
return { status: "rejected_before_send", reason: "waiting_for_page" };
@@ -245,6 +246,7 @@ export const createOneTalkPendingSendCoordinator = (options: {
options.store.getGeneration(plugin.socket) !== pluginGeneration ||
mind.socket.readyState !== WEBSOCKET_OPEN ||
plugin.socket.readyState !== WEBSOCKET_OPEN ||
!options.store.isPluginLeaseFresh(plugin) ||
!options.store.epochIsCurrent(policyEpoch) ||
!options.store.policyAdmits("mind_page") ||
!options.store.policyAdmits("plugin")