fix(client): restore host-confirmed workspace order

This commit is contained in:
_Kerman
2026-08-11 17:51:31 +08:00
parent 7222a99e33
commit 6898b06160
8 changed files with 60 additions and 21 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: 5ac081d6f257bc1c3a7d8a2dd75234f72f209da7
README.zh.md: 15a1a4602fbb4ae6bd56d728b17782da8cfa4693
README.md: ef07addd9955ee6158013f3932ea407969ddf314
README.zh.md: 1d154d1470a7864a4bdbb4b36c5143fa23a9dc68

View File

@@ -13,7 +13,7 @@ The callback returns one synchronous disposer or an iterable of disposers. A gen
## Workspace and Session lists
Workspace and Session lists have independent monotone `pending``ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal/order frames and unary mutation echoes arriving during a list request replay over its response. Every successful Workspace baseline re-establishes Host-durable Workspace order so reconnects adopt changes committed while this client was offline. `WorkspacesService.insertBefore` installs an optimistic order immediately; only the latest unary echo may replace it, a newer Host order frame outranks an older echo, and a latest rejected request rolls back. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.
Workspace and Session lists have independent monotone `pending``ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal/order frames and unary mutation echoes arriving during a list request replay over its response. Every successful Workspace baseline re-establishes Host-durable Workspace order so reconnects adopt changes committed while this client was offline. `WorkspacesService.insertBefore` installs an optimistic order immediately; only the latest unary echo may replace it, a newer Host order frame outranks an older echo, and a latest rejected request restores the last Host-confirmed order rather than an earlier uncommitted drag. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them. 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: disconnect clears it, and mux-open replay restores only requests that remain pending.

View File

@@ -13,7 +13,7 @@
## Workspace 与 Session 列表
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除/顺序帧与一元变更回显会在其响应之上回放。每次成功的 Workspace 基线都会重新建立 Host 持久 Workspace 顺序,因此重连会接纳该客户端离线期间提交的变更。`WorkspacesService.insertBefore` 会立即安装乐观顺序;只有最新一元回声可以替换它,更新的 Host 顺序帧优先于旧回声,而最新请求被拒时会回滚。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。
Workspace 和 Session 列表各自具有单调的 `pending``ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量插入或更新/移除/顺序帧与一元变更回显会在其响应之上回放。每次成功的 Workspace 基线都会重新建立 Host 持久 Workspace 顺序,因此重连会接纳该客户端离线期间提交的变更。`WorkspacesService.insertBefore` 会立即安装乐观顺序;只有最新一元回声可以替换它,更新的 Host 顺序帧优先于旧回声,而最新请求被拒时会恢复最近一次由 Host 确认的顺序,不会恢复更早且尚未提交的拖拽。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活。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 的请求。

View File

@@ -55,6 +55,8 @@ export class WorkspaceManager {
private orderRequestGeneration = 0
/** Increments on order frames so a later remote commit outranks an older unary echo. */
private orderFrameGeneration = 0
/** Last complete order accepted from a Host baseline, frame, or current unary echo. */
private committedOrder: WorkspaceId[] = []
/**
* Ids this process has seen removed, kept for the connection's lifetime so
* a late changed frame or a stale baseline row cannot resurrect a deleted
@@ -171,8 +173,8 @@ export class WorkspaceManager {
): Promise<RpcResult<{ workspaceIds: WorkspaceId[] }>> {
const requestGeneration = ++this.orderRequestGeneration
const frameGeneration = this.orderFrameGeneration
const previousOrder = this.itemViews().map(workspace => workspace.workspaceId)
this.installOrder(insertIdBefore(previousOrder, workspaceId, beforeWorkspaceId))
const localOrder = this.itemViews().map(workspace => workspace.workspaceId)
this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId))
let result: RpcResult<{ workspaceIds: WorkspaceId[] }>
try {
;({ result } = await this.api.workspace.insertBefore({
@@ -182,16 +184,16 @@ export class WorkspaceManager {
} catch (error) {
if (requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(previousOrder)
this.installOrder(this.committedOrder)
}
throw error
}
if (result.ok && requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(result.value.workspaceIds)
this.installOrder(result.value.workspaceIds, true)
} else if (!result.ok && requestGeneration === this.orderRequestGeneration
&& frameGeneration === this.orderFrameGeneration) {
this.installOrder(previousOrder)
this.installOrder(this.committedOrder)
}
return result
}
@@ -239,7 +241,7 @@ export class WorkspaceManager {
else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
else if (envelope.payload.type === 'host/workspace-order-changed') {
this.orderFrameGeneration++
this.installOrder(envelope.payload.workspaceIds)
this.installOrder(envelope.payload.workspaceIds, true)
}
else if (envelope.payload.type === 'host/archived-sessions-changed') {
this.installArchived(envelope.payload.archivedSessionIds)
@@ -292,9 +294,12 @@ export class WorkspaceManager {
this.notifier.markDirty()
}
/** Reorder known Workspace objects by a complete Host id sequence. */
private installOrder(workspaceIds: readonly WorkspaceId[]): void {
this.refreshFrames?.push({ type: 'order', workspaceIds })
/** Reorder known Workspace objects, optionally recording a Host-committed sequence. */
private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void {
if (committed) {
this.refreshFrames?.push({ type: 'order', workspaceIds })
this.committedOrder = [...workspaceIds]
}
const rank = new Map(workspaceIds.map((id, index) => [id, index]))
const items = [...this.items].sort((left, right) => {
const leftId = left.getSnapshot().view?.workspaceId
@@ -317,6 +322,9 @@ export class WorkspaceManager {
// late unary response cannot roll back a newer frame.
const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view
if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return
if (!this.committedOrder.includes(view.workspaceId)) {
this.committedOrder = [view.workspaceId, ...this.committedOrder]
}
if (identity !== undefined) {
this.items = index === -1
? [identity, ...this.items]
@@ -334,6 +342,7 @@ export class WorkspaceManager {
private remove(workspaceId: WorkspaceId, direct = false): void {
this.refreshFrames?.push({ type: 'remove', workspaceId })
this.removedIds.add(workspaceId)
this.committedOrder = this.committedOrder.filter(id => id !== workspaceId)
const items = this.items.filter(item =>
item.getSnapshot().view?.workspaceId !== workspaceId)
if (items.length === this.items.length) {
@@ -367,6 +376,7 @@ export class WorkspaceManager {
installed.set(view.workspaceId, workspace)
}
this.items = [...installed.values()]
this.committedOrder = views.map(view => view.workspaceId)
}
private itemViews(): readonly WorkspaceView[] {

View File

@@ -115,6 +115,35 @@ describe('WorkspaceManager', () => {
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two'])
})
it('rolls overlapping rejected reorders back to the last Host-confirmed order', async () => {
const api = new FakeApiClient()
api.onWorkspaceList = () => Promise.resolve(ok({
items: [workspace('one'), workspace('two'), workspace('three')] as never[],
}))
const manager = new WorkspaceManager(api)
await manager.refresh()
const firstGate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
const secondGate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceInsertBefore']>>>()
let request = 0
api.onWorkspaceInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise
const first = manager.insertBefore(wid('three'), wid('one'))
const second = manager.insertBefore(wid('two'), wid('three'))
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
firstGate.resolve(err({
code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: 'three' },
}))
await expect(first).resolves.toMatchObject({ ok: false })
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one'])
secondGate.resolve(err({
code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: 'two' },
}))
await expect(second).resolves.toMatchObject({ ok: false })
expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three'])
})
it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => {
const api = new FakeApiClient()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onWorkspaceList']>>>()