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

@@ -8,7 +8,7 @@ Client cordis boot + core services: SlotsService (Service wrapper over SlotCore
## Plan-mode projection
Each opened `Session` queries the optional plan capability independently of paginated history and exposes `planMode: null | { active, pending? }` in its `ConversationSnapshot`. `null` hides consumers that require the capability. A successful selection replaces the snapshot with the host-confirmed committed and pending state; failures retain the previous state. Logged live `plan/mode` events commit `active` and clear `pending`, while reconnect re-queries the full state. A failed capability query never makes an otherwise usable conversation fail to open.
Each opened `Session` queries the optional plan capability independently of paginated history and exposes `planMode: null | { active, pending? }` in its `ConversationSnapshot`. `null` hides consumers that require the capability; a present `pending` differs from `active`. A successful selection replaces the snapshot with the host-confirmed committed and pending state; failures retain the previous state. A shared request fence drops stale plan query and selection responses, while an event-version fence preserves a commit that overtakes a current unary request. Logged live `plan/mode` events commit `active` and clear `pending`; replacement history windows also fold their latest plan event so gap repair cannot miss a recovered commit. Reconnect re-queries the full state, and a failed capability query never makes an otherwise usable conversation fail to open.
## Model Experience

View File

@@ -59,6 +59,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private planCapabilityKnown = false
/** Monotonic local fence for committed plan events observed on the mux stream. */
private planEventVersion = 0
/** Highest plan event seq observed through an append or replacement window. */
private latestPlanEventSeq: number | null = null
/** Monotonic fence shared by plan queries and selections; only the latest response may land. */
private planRequestVersion = 0
/** Latest valid commit, held until the initial capability query resolves. */
private latestLivePlanMode: PlanModeState | null = null
// Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2,
@@ -145,6 +149,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
*/
async setPlanMode(active: boolean): Promise<RpcResult<PlanModeState | null>> {
const planEventVersion = this.planEventVersion
const planRequestVersion = ++this.planRequestVersion
let result: RpcResult<PlanModeState | null>
try {
result = (await this.api.sessions.setPlanMode({ sessionId: this.sessionId, active })).result
@@ -152,8 +157,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
result = transportError(error)
}
if (result.ok) {
this.applyPlanResponse(result.value, planEventVersion)
this.notifier.notifyNow()
if (this.applyPlanResponse(result.value, planEventVersion, planRequestVersion)) {
this.notifier.notifyNow()
}
}
return result
}
@@ -387,6 +393,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.hasMore = hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
this.rebuildDerivedFromWindow()
this.applyLatestWindowPlanMode()
const buffered = this.liveBuffer
this.liveBuffer = []
for (const item of buffered) this.appendLive(item.event, item.view)
@@ -411,10 +418,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
*/
private async refreshPlanMode(generation: number): Promise<void> {
const planEventVersion = this.planEventVersion
const planRequestVersion = ++this.planRequestVersion
try {
const { result } = await this.api.sessions.planMode({ sessionId: this.sessionId })
if (generation !== this.openGeneration) return
if (result.ok) this.applyPlanResponse(result.value, planEventVersion)
if (result.ok) this.applyPlanResponse(result.value, planEventVersion, planRequestVersion)
else console.error('[web-runtime] plan-mode query failed:', result.error)
} catch (error) {
if (generation !== this.openGeneration) return
@@ -429,6 +437,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (typeof candidate.data !== 'object' || candidate.data === null) return
const data = candidate.data as { active?: unknown }
if (typeof data.active !== 'boolean') return
if (this.latestPlanEventSeq !== null && event.seq <= this.latestPlanEventSeq) return
this.latestPlanEventSeq = event.seq
this.planEventVersion++
this.latestLivePlanMode = { active: data.active }
if (this.planCapabilityKnown && this.planMode !== null) {
@@ -436,20 +446,38 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
}
}
/** Replacement windows bypass appendLive, so fold their newest valid plan commit explicitly. */
private applyLatestWindowPlanMode(): void {
for (let i = this.events.length - 1; i >= 0; i--) {
const before = this.latestPlanEventSeq
this.applyLivePlanMode(this.events[i] as SessionEvent)
if (this.latestPlanEventSeq !== before) return
}
}
/**
* Apply a unary plan snapshot unless a newer mux commit crossed the request.
* Apply the latest unary plan snapshot unless a newer request or mux commit
* crossed it.
* A successful null response establishes absence and never promotes a raw
* event into a capability.
*
* @returns Whether this response was current and applied.
*/
private applyPlanResponse(value: PlanModeState | null, requestVersion: number): void {
private applyPlanResponse(
value: PlanModeState | null,
requestEventVersion: number,
requestVersion: number,
): boolean {
if (requestVersion !== this.planRequestVersion) return false
this.planCapabilityKnown = true
if (value === null) {
this.planMode = null
return
return true
}
this.planMode = requestVersion === this.planEventVersion
this.planMode = requestEventVersion === this.planEventVersion
? value
: this.latestLivePlanMode ?? value
return true
}
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;

View File

@@ -96,6 +96,29 @@ describe('plan mode projection', () => {
expect(result).toEqual({ ok: true, value: { active: false, pending: true } })
expect(api.callsOf('session.setPlanMode')).toEqual([{ sessionId: SID, active: true }])
expect(session.getSnapshot().planMode).toEqual({ active: false, pending: true })
api.onSetPlanMode = () => Promise.resolve(ok({ active: false }))
await session.setPlanMode(false)
expect(session.getSnapshot().planMode).toEqual({ active: false })
})
it('drops an older overlapping selection response after the newer selection lands', async () => {
const { api, session } = makeSession()
api.onPlanMode = () => Promise.resolve(ok({ active: false }))
await session.open()
const older = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
const newer = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
let call = 0
api.onSetPlanMode = () => ++call === 1 ? older.promise : newer.promise
const selectPlan = session.setPlanMode(true)
const selectDefault = session.setPlanMode(false)
newer.resolve(ok({ active: false }))
await selectDefault
older.resolve(ok({ active: false, pending: true }))
await selectPlan
expect(session.getSnapshot().planMode).toEqual({ active: false })
})
it('retains the prior state when a selection fails at the business or transport layer', async () => {
@@ -174,6 +197,26 @@ describe('plan mode projection', () => {
expect(selection.session.getSnapshot().planMode).toEqual({ active: true })
})
it('applies a plan commit recovered through a gap-repair replacement window', async () => {
const { api, session } = makeSession()
const initial = plainTurn(0, 0, 'a', 'b')
api.onHistory = () => histResponse(initial)
api.onPlanMode = () => Promise.resolve(ok({ active: false, pending: true }))
await session.open()
const planCommit = at(6, { type: 'plan/mode', data: { active: true } })
const later = ev.turnStart(7, 1)
api.onHistory = () => histResponse([...initial, planCommit, later])
session.handleMuxEnvelope('rp-gap' as never, {
type: 'session/event', sessionId: SID, event: later,
})
await vi.waitFor(() => {
expect(api.callsOf('session.history')).toHaveLength(2)
expect(session.getSnapshot().planMode).toEqual({ active: true })
})
})
it('keeps history usable when the independent capability query fails', async () => {
const business = makeSession()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})