fix(web): fence plan mode projection races

This commit is contained in:
fz
2026-07-24 13:16:26 +08:00
parent bc63b5fe00
commit ced6ab9d14
13 changed files with 119 additions and 32 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-24-web-plan-mode-projection.md: bff1e6924e514cda9b8d8f00e8bfa2d1fb110437
2026-07-24-web-plan-mode-projection.zh.md: 039e0e10c580c4128e95b0db5b0bdc0ba3617459
2026-07-24-web-plan-mode-projection.md: 9c894a30a0c456d2d6216baa23220db8d68fdda3
2026-07-24-web-plan-mode-projection.zh.md: 9f8876c4f222059066ff1bff42aebc37acb5683c

View File

@@ -14,11 +14,11 @@ The host does not mount plan mode for every product composition. The wire must d
The session RPC domain exposes `session.planMode({ sessionId })` and `session.setPlanMode({ sessionId, active })`. Their shared value is `null | { active: boolean, pending?: boolean }`. `null` means the optional `ctx.planMode` service is absent; `{ active: false }` means the service is available and inactive. Both methods resume a cold session through the same host-owned path as history and prompt before reading or changing state.
The host adapter delegates selection and folding to `ctx.planMode`; it does not append events or duplicate boundary logic. `active` is the last committed logged value. When present, `pending` is the selected target value awaiting a model-request boundary; its presence, rather than its boolean value, identifies pending intent. Re-selecting the committed value can therefore return `pending: false` while cancelling a pending entry. The boundary then removes that intent without logging a redundant state event. The RPC does not cancel a running request, so a selection made during generation leaves that request unchanged and shapes the next one.
The host adapter delegates selection and folding to `ctx.planMode`; it does not append events or duplicate boundary logic. `active` is the last committed logged value. When present, `pending` is the selected target value awaiting a model-request boundary and differs from `active`; its presence, rather than its boolean value, identifies a user-visible pending transition. Re-selecting the committed value can leave an internal cleanup intent in the service, but the adapter canonicalizes that net-zero state to `{ active }`. The boundary then removes the intent without logging a redundant state event. The wire schema rejects equal `active` and `pending` values. The RPC does not cancel a running request, so a selection made during generation leaves that request unchanged and shapes the next one.
The browser session object queries the complete state after history opens and on reconnect. A failed plan query is fail-soft: history remains usable and the last known capability state is retained. A reconnect generation fence prevents a superseded query from overwriting the newer result. A separate local event-version fence prevents a query or selection response from overwriting a `plan/mode` commit that overtook it on the mux stream; an early commit remains private until a successful query confirms capability presence. Successful selections otherwise update the snapshot only from the host-confirmed response, while business and transport failures leave the prior state intact.
The browser session object queries the complete state after history opens and on reconnect. A failed plan query is fail-soft: history remains usable and the last known capability state is retained. A reconnect generation fence prevents a superseded open from overwriting the newer result. One monotonic plan-request fence covers both queries and selections, so an older unary response cannot replace the result of a newer request. A separate local event-version fence prevents a current query or selection response from overwriting a `plan/mode` commit that overtook it on the mux stream; an early commit remains private until a successful query confirms capability presence. Successful selections otherwise update the snapshot only from the host-confirmed response, while business and transport failures leave the prior state intact.
Committed `plan/mode` session events remain the live notification. When the host advertised the capability, a valid event replaces `active` and clears `pending`. The object layer ignores malformed events and does not infer capability from a raw event alone. This keeps full-state reads authoritative while preserving the existing logged event stream as the commit signal.
Committed `plan/mode` session events remain the live notification. When the host advertised the capability, a valid event replaces `active` and clears `pending`. Both the append path and a history replacement window observe the newest valid plan event by sequence, so gap repair applies a recovered commit even when the buffered triggering frame becomes replay overlap. The object layer ignores malformed events and does not infer capability from a raw event alone. This keeps full-state reads authoritative while preserving the existing logged event stream as the commit signal.
## State and timing
@@ -26,7 +26,7 @@ Committed `plan/mode` session events remain the live notification. When the host
|---|---|---|---|
| Inactive | Plan | `{ active: false, pending: true }` | Logs `plan/mode: true`; snapshot becomes active |
| Active | Default | `{ active: true, pending: false }` | Logs `plan/mode: false`; snapshot becomes inactive |
| Inactive with pending Plan | Default | `{ active: false, pending: false }` | No state event is needed |
| Inactive with pending Plan | Default | `{ active: false }` | No state event is needed |
| Capability absent | Either | `null` | No plan behavior is introduced |
Stopping generation remains a separate session operation. A pending selection survives cancellation and applies when the next prompt or continuation reaches the service boundary.
@@ -43,9 +43,9 @@ Stopping generation remains a separate session operation. A pending selection su
## Verification
- API schemas reject invalid request and state shapes, and both fetch directions dispatch the two methods.
- Host runtime tests cover capability absence, real-service pending and cancellation state, cold-session errors, and shared RPC semantics.
- Client object tests cover open, selection success, business and transport failure, committed live events, malformed and unavailable events, fail-soft queries, reconnect refresh, superseded-query fencing, and mux commits overtaking unary responses.
- API schemas reject invalid request and state shapes, including equal committed and pending values, and both fetch directions dispatch the two methods.
- Host runtime tests cover capability absence, real-service pending state, canonical net-zero cancellation, cold-session errors, and shared RPC semantics.
- Client object tests cover open, selection success, overlapping selection response order, business and transport failure, committed live events, gap-repaired commits, malformed and unavailable events, fail-soft queries, reconnect refresh, superseded-query fencing, and mux commits overtaking unary responses.
## Consequences

View File

@@ -14,11 +14,11 @@ plan 服务拥有持久状态和边界时序,但 Web 宿主契约无法发现
会话 RPC 域公开 `session.planMode({ sessionId })``session.setPlanMode({ sessionId, active })`。两者返回相同的值类型:`null | { active: boolean, pending?: boolean }``null` 表示可选的 `ctx.planMode` 服务不存在;`{ active: false }` 表示该服务可用,但当前未激活。读取或修改状态前,这两个方法都会通过宿主用于历史记录与提示词请求的同一路径恢复冷会话。
宿主适配器把选择与折叠工作交给 `ctx.planMode`,不会自行追加事件或重复实现边界逻辑。`active` 是最近一次已提交并记录到日志的值。`pending` 存在时,其值是等待模型请求边界生效的所选目标;表示存在待生效意图的是该字段是否存在,而不是其布尔值。因此,重新选择已提交值时,可以在取消待生效的进入选择的同时返回 `pending: false`。随后,边界会移除该意图,且不会记录多余的状态事件。该 RPC 不会取消正在执行的请求,因此生成期间作出的选择不会改变本次请求,只会影响下一次请求。
宿主适配器把选择与折叠工作交给 `ctx.planMode`,不会自行追加事件或重复实现边界逻辑。`active` 是最近一次已提交并记录到日志的值。`pending` 存在时,其值是等待模型请求边界生效的所选目标,且必定不同于 `active`;表示用户可见的待生效转换的是该字段是否存在,而不是其布尔值。重新选择已提交值时,服务内部可能仍留有一项清理意图,但适配器会把这一无净变化状态规范化为 `{ active }`。随后,边界会移除该意图,且不会记录多余的状态事件。协议 schema 会拒绝 `active``pending` 相等的值。该 RPC 不会取消正在执行的请求,因此生成期间作出的选择不会改变本次请求,只会影响下一次请求。
浏览器会话对象在历史记录加载完成后以及重连时查询完整状态。plan 查询失败不会阻断其他功能:历史记录仍可使用,并保留最近一次已知的功能状态。重连使用代际围栏,避免已被取代的查询覆盖较新的结果。另一道独立的本地事件版本围栏会阻止查询或选择响应覆盖 mux 流中已抢先到达的 `plan/mode` 提交;提前到达的提交会保持为内部状态,直到查询成功并确认该功能存在。除上述情况外,选择成功后,只有宿主确认的响应才会更新快照;业务错误和传输失败都会保留先前状态。
浏览器会话对象在历史记录加载完成后以及重连时查询完整状态。plan 查询失败不会阻断其他功能:历史记录仍可使用,并保留最近一次已知的功能状态。重连使用代际围栏,避免已被取代的打开流程覆盖较新的结果。一个单调递增的 plan 请求围栏同时覆盖查询和选择,因此较早的 unary 响应无法替换较新请求的结果。另一道独立的本地事件版本围栏会阻止当前查询或选择响应覆盖 mux 流中已抢先到达的 `plan/mode` 提交;提前到达的提交会保持为内部状态,直到查询成功并确认该功能存在。除上述情况外,选择成功后,只有宿主确认的响应才会更新快照;业务错误和传输失败都会保留先前状态。
已提交的 `plan/mode` 会话事件仍作为实时通知。当宿主已公布该功能时,有效事件会替换 `active` 并清除 `pending`。对象层会忽略格式错误的事件,也不会仅凭一条原始事件推断功能是否可用。这样既以完整状态读取为真源,又保留现有的日志事件流作为提交信号。
已提交的 `plan/mode` 会话事件仍作为实时通知。当宿主已公布该功能时,有效事件会替换 `active` 并清除 `pending`追加路径和历史替换窗口都会按序号采用最新的有效 plan 事件,因此即使缓冲的触发帧在回放时已与窗口重叠,缺口回补仍会应用恢复出的提交。对象层会忽略格式错误的事件,也不会仅凭一条原始事件推断功能是否可用。这样既以完整状态读取为真源,又保留现有的日志事件流作为提交信号。
## 状态与时序
@@ -26,7 +26,7 @@ plan 服务拥有持久状态和边界时序,但 Web 宿主契约无法发现
|---|---|---|---|
| 未激活 | Plan | `{ active: false, pending: true }` | 记录 `plan/mode: true`;快照变为已激活 |
| 已激活 | Default | `{ active: true, pending: false }` | 记录 `plan/mode: false`;快照变为未激活 |
| 未激活Plan 待生效 | Default | `{ active: false, pending: false }` | 无需记录状态事件 |
| 未激活Plan 待生效 | Default | `{ active: false }` | 无需记录状态事件 |
| 功能不存在 | 任一选择 | `null` | 不会引入 plan 行为 |
停止生成仍是单独的会话操作。待生效的选择会在取消后保留,并在下一条提示词或 continuation 到达服务边界时应用。
@@ -43,9 +43,9 @@ plan 服务拥有持久状态和边界时序,但 Web 宿主契约无法发现
## 验证
- API schema 拒绝无效的请求与状态结构,两个 fetch 方向均可分派这两个方法。
- 宿主运行时测试覆盖功能不存在、真实服务的待生效与取消状态、冷会话错误,以及共享 RPC 语义。
- 客户端对象测试覆盖打开、选择成功、业务错误与传输失败、已提交的实时事件、格式错误及功能不可用时的事件、查询失败时的容错、重连刷新、针对已被取代查询的围栏保护,以及 mux 提交抢先于 unary 响应到达的情况。
- API schema 拒绝无效的请求与状态结构,包括 `active``pending` 相等的情况;两个 fetch 方向均可分派这两个方法。
- 宿主运行时测试覆盖功能不存在、真实服务的待生效状态、取消时无净变化状态的规范化、冷会话错误,以及共享 RPC 语义。
- 客户端对象测试覆盖打开、选择成功、重叠选择请求的响应顺序、业务错误与传输失败、已提交的实时事件、经缺口回补的提交、格式错误及功能不可用时的事件、查询失败时的容错、重连刷新、针对已被取代查询的围栏保护,以及 mux 提交抢先于 unary 响应到达的情况。
## 后果