refactor(client): keep pending interaction change UI-scoped

This commit is contained in:
Turtle
2026-08-04 20:28:23 +08:00
parent 76d43d616d
commit e2049910d5
18 changed files with 53 additions and 175 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 packages/client/runtime/README.md
README.md: 1d474c63f25f4aa25dc20c842f1968ea00ca3773
README.zh.md: 6ed5d74042901f667fc3d6bccbb2304d1aab00a8
README.md: dd780369a1d888dde2579e436afe2ce1e6dcfdd1
README.zh.md: 5574ad6452c6a2d94fb63da7e53b98e8d074f1c9

View File

@@ -8,7 +8,7 @@ Client cordis boot and React-free object services: SlotsService wraps SlotCore a
Workspace and Session lists have independent monotone `pending``ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: every generation death clears it, and mux-open replay restores only requests that remain pending.
`SessionSummary.pendingInteraction` classifies the live user action blocking a Session as `approval`, `plan-review`, or `question`. `SessionManager` tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable `PendingWait` when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct `plan-review` status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending.
`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.

View File

@@ -8,7 +8,7 @@
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval``plan-review``question``SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:每个代次结束时清除mux 打开时的回放只恢复仍处于 pending 的请求。
`SessionSummary.pendingInteraction` 将阻塞 Session 的实时用户操作分类为 `approval``plan-review``question``SessionManager` 依据稳定的请求标识跟踪可应答请求的 requested/resolved mux 帧,即使 `Session` 对象尚未实例化也不例外;实例化前的缓冲会保留每个仍有效的请求,替换回放产生的重复项,并移除已解决的请求,因此打开 Session 时,列表状态始终有一个对应的可应答 `PendingWait`。审批与问题并发时,第一个 pending 问题具有更高的呈现优先级,以匹配 composer 路由;只有满足 plan-review composer 二元呈现约束的请求才会保留独立的 `plan-review` 状态。该状态的作用域限定在连接代次内:断连时清除mux 打开时的回放只恢复仍处于 pending 的请求。
`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已纳入客户端投影的 Session 会立即投影到 Ungrouped 下。

View File

@@ -219,14 +219,17 @@ export function apply(ctx: Context): void {
console.error('[web-runtime] history reconnect failed:', error)
}
},
onDisconnected: () => {
// Reconnect replays flow from stream open, ahead of onConnected, so each
// generation death is the only safe moment to drop generation-owned state.
sessions.handleDisconnected()
try {
sessionHistory.handleDisconnected()
} catch (error) {
console.error('[web-runtime] history disconnect failed:', error)
onStateChange: (state) => {
// Generation death fires before any next-generation frame can arrive
// (reconnect replays flow from stream open, ahead of onConnected):
// the only safe moment to drop generation-scoped interaction state.
if (state === 'reconnecting') {
sessions.handleDisconnected()
try {
sessionHistory.handleDisconnected()
} catch (error) {
console.error('[web-runtime] history disconnect failed:', error)
}
}
},
})

View File

@@ -791,9 +791,8 @@ export class SessionManager {
* send no frame, so stale statuses and buffered answerable frames must not
* survive into the next generation — mux-open replay re-adds every still-pending
* request with its live rpcId.
*/
*/
handleDisconnected(): void {
for (const session of this.sessions.values()) session.handleDisconnected()
if (this.pendingInteractions.size > 0) {
this.pendingInteractions.clear()
this.notifier.markDirty()

View File

@@ -393,9 +393,8 @@ export class Session implements SessionFace {
}
/** Reconnect rebuild (manager calls this on onConnected for instances that were opened):
* reset the window and rerun open. Pending waits reset at generation death, before the next
* baseline replay can arrive, so this method preserves freshly replayed waits. Invalidates
* any in-flight open first — its history request rode the dead connection and must not settle
* reset the window and rerun open; pending waits for the baseline replay. Invalidates any
* in-flight open first — its history request rode the dead connection and must not settle
* the fresh generation into 'error' (audit S4). */
async resync(): Promise<void> {
// The queue mirror is NOT cleared here: onConnected (which drives resync)
@@ -411,6 +410,10 @@ export class Session implements SessionFace {
this.events = []
this.views = []
this.baseSeq = 0
// Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim
// (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host.
this.pending.clear()
this.pendingRev++
this.subscribedLastSeq = null
this.liveBuffer = []
this.notifier.markDirty()
@@ -439,16 +442,6 @@ export class Session implements SessionFace {
// ---- Manager-only entry points (@internal; never called by the UI) ----
/** Discard generation-scoped waits before a next-generation replay can arrive. */
handleDisconnected(): void {
if (this.pending.size === 0) return
// Superseded, not settled: a stale reference may already be responding,
// and the replay re-mints each still-pending request with the same rpcId.
this.pending.clear()
this.pendingRev++
this.notifier.markDirty()
}
/**
* Mux frame arrival (the dispatch switch).
* @param rpcId - the frame envelope id (the respond backfill key for requested frames).

View File

@@ -76,9 +76,8 @@ describe('runtime client apply', () => {
})
await Promise.resolve()
expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
// Mux and generation-lifecycle sinks route without throwing (manager semantics own the behavior).
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
bench.sinks?.onDisconnected?.()
bench.sinks?.onConnected?.()
})

View File

@@ -956,30 +956,19 @@ describe('pending-interaction list status', () => {
expect(manager.getListSnapshot().items).toHaveLength(0)
})
it('drops stale waits at generation death and preserves replay arriving before onConnected', async () => {
const api = new FakeApiClient()
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
}))
const manager = new SessionManager(api)
it('drops stale status at generation death before replay re-adds live interactions', () => {
const manager = new SessionManager(new FakeApiClient())
manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
const session = manager.get(S1)
await session.open()
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
expect(session.getSnapshot().pending).toMatchObject([{ key: 'a:ra' }])
// Generation death clears (resolved-while-disconnected questions send no frame)…
manager.handleDisconnected()
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBeUndefined()
expect(session.getSnapshot().pending).toEqual([])
// …and a replayed frame arriving before onConnected (stream open precedes
// the readiness handshake) survives the later resync untouched.
// the readiness handshake) survives the later handleConnected untouched.
manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
manager.handleConnected()
expect(manager.getListSnapshot().items[0]?.pendingInteraction).toBe('approval')
expect(session.getSnapshot().pending).toMatchObject([{ key: 'a:ra' }])
})
it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {

View File

@@ -1098,19 +1098,16 @@ describe('remaining branches', () => {
})
describe('resync', () => {
it('rebuilds the window without clearing a fresh-generation wait; cold instances no-op', async () => {
it('rebuilds the window and clears pending; cold instances no-op', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))
await session.open()
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
session.handleDisconnected()
expect(session.getSnapshot().pending).toEqual([])
session.handleMuxEnvelope('ra' as never, { type: 'approval/requested', sessionId: SID, approvalId: 'ap1' as never, toolName: 'rm' })
api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')])
await session.resync()
const snapshot = session.getSnapshot()
expect(snapshot.openState).toBe('open')
expect(snapshot.pending).toMatchObject([{ key: 'a:ra', kind: 'approval' }])
expect(snapshot.pending).toEqual([]) // baseline replay re-sends still-pending frames
expect(snapshot.nodes).toHaveLength(4)
const cold = makeSession()