mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
Merge branch 'main' into 09-12-onetalk-flow-boundaries
This commit is contained in:
@@ -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` 控制 admission;pause/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 deadline,coordinator 在选择和发送前均复用该查询。
|
||||
- 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 后 publish,terminal/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": {}
|
||||
}
|
||||
@@ -29,7 +29,8 @@
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
| 64 | 2026-09-12 | OneTalk 采集与命令流程边界整理 | `15efc84` | `09-12-onetalk-flow-boundaries` |
|
||||
| 65 | 2026-09-12 | OneTalk 采集与命令流程边界整理 | `15efc84` | `09-12-onetalk-flow-boundaries` |
|
||||
| 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` |
|
||||
|
||||
@@ -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 错误'
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};`;
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user