Skip to content

Commit f2d4336

Browse files
Merge branch 'vps2/n2-viewstates-durability' (f3ca33b) into #2235
Real merge, not a fast-forward: merge base is main e943530, with 28 commits on this side and 18 on theirs. Five shared paths had to be decided (ProviderSettingsManager.spec.ts, importExport.ts and importExport.spec.ts are byte-identical across the two heads and git resolved them without a decision); ClineProvider.spec.ts auto-merged and carries 378 declarations against the 375 floor this branch pins. Resolutions, taken from the stage-2 (ours) and stage-3 (theirs) blobs only. This chain's stage-1 recursive base is itself polluted with conflict markers, so it was never consulted as a base. - packages/types/src/vscode-extension-host.ts: both sides added the same two union members ("diagnosticsRequest" and "viewStateClaim") in opposite order. Kept this side's order. No content change; five viewStateClaim references and one diagnosticsRequest remain, one copy each. - src/core/config/ContextProxy.ts: union. resetAllState now both advances every stateWriteGenerations entry, which is what this side's rejected-write continuations gate on, and clears stateWriteOwner/secretWriteOwner, which is what their side needs for the case where the mocked Memento answers the post-reset read-back with the pre-reset value. Both guards are additive on the same reset path; neither replaces the other. - src/core/config/__tests__/ContextProxy.spec.ts: took their version of the pending-write test, which adds the mocked Memento read-back that makes the guarantee observable, plus their new does-not-restore-a-pre-reset-secret test. 79 -> 81 declarations, no test title lost from either side. - src/core/webview/ClineProvider.ts: nine regions. Their side's activation group now owns the mirror, and it writes { currentApiConfigName: name, apiConfiguration: providerSettings } into this view's buffer at ClineProvider.ts:2889-2892. Their non-activating branch refreshes the pinned self overlay and then the siblings, and refreshSiblingsAfterProfileSave is adopted whole (2879-2903): an overlay-mirror loop over ClineProvider.getAllInstances() for live siblings pinned to the profile, then the name/id strip and updateTaskApiHandlerIfNeeded(taskSettings, { forceRebuild: true }) for instances whose current task follows the profile. This side kept its own post-activation self refresh and the ordering comment it exists for - a failed activation group must not leave the overlay ahead of the restored shared state - and kept savePersistedViewState returning whether storage landed, because this side's rollback path consumes that boolean. What the merge carries and what it does not: the activation-success mirror for the saving view now names both keys; the two sibling loops mirror apiConfiguration only and nothing in them writes currentApiConfigName, so sibling-level pin mirroring still exists on no head in this series. Whether that answers the open reviewer row about the activation mirror is a question for the merged head, not for this paragraph. Two defects the merge introduced that only the compiler caught, both of the lost-chunk class: - ClineProvider.ts held two identical `type PersistedViewStateValues = Pick<...>` aliases, one from each side, which is TS2300 twice. Removed the undocumented copy and kept the documented one. - The constructor lost its `this.viewId = ${renderContext}-${ClineProvider.nextViewId++}` and `this.viewStateId = this.viewId` assignments: git matched them into a hunk whose incoming side was empty, so the merged file declared both properties and assigned neither, which is TS2564 twice. Restored from the incoming side's text. Line 941's TS18048 (Object possibly undefined) went away with them, which is how I know they were the cause and not a symptom. Gates: the compile job's thirteen commands in the job's own order, against exactly this tree. format:check 0 (prettier --check ., "All matched files use Prettier code style!"), lint 0 (12 tasks), check-types 0 (11 tasks / 13 packages), test:code-qa-ci 0, solheim-smoke:check-types 0, test:agent-evals:unit 0 (48 tests), agent-evals:check-types 0, lifecycle:model-check 0, webview-recovery:check-types + model-check 0, mcp:integration-check 0. The two solheim runtime steps fail on this machine and for platform reasons only: test:solheim-smoke-ci fails its record.test-helper.sh SIGTERM teardown cases and test:solheim-smoke:unit asserts the POSIX literal '/tmp/isolated/.config' against buildChildEnvironment's win32 output '\tmp\isolated\.config' (config.test.ts:54). All 17 files under scripts/solheim-smoke and the workflow self-test are blob-identical to main here, the merge touched nothing under scripts/, and both failures reproduce identically on the unrelated #1552 and #1557 heads, so CI on ubuntu-latest is not being asked to accept something this merge broke. Line endings were checked before reading any local format result: core.autocrlf=false, core.eol=lf, and the merged and incoming blobs of ClineProvider.ts, ContextProxy.ts, ClineProvider.spec.ts and webview-ui/tsconfig.json all contain zero CRLF pairs, so a local format red here would have been a real defect rather than a working-copy artifact - and it is green. eslint --prune-suppressions over all seven merged files: clean, and src/eslint-suppressions.json is count-neutral (3301 before, 3301 after) so it was reverted rather than committed. No .changeset files and no CHANGELOG change, per repository policy.
2 parents acf8ccb + f3ca33b commit f2d4336

7 files changed

Lines changed: 285 additions & 27 deletions

File tree

‎src/core/config/ContextProxy.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export class ContextProxy {
8282

8383
constructor(context: vscode.ExtensionContext) {
8484
this.originalContext = context
85+
8586
this.stateCache = {}
8687
this.secretCache = {}
8788
this._isInitialized = false
@@ -401,6 +402,12 @@ export class ContextProxy {
401402
*/
402403
private readonly stateWriteOwner = new Map<string, object>()
403404
private readonly secretWriteOwner = new Map<string, object>()
405+
/**
406+
* The secret writes that are still in flight, each paired with the claim it took,
407+
* so a reset can wait for them to settle before it deletes the keys. Without the
408+
* pairing a reset cannot tell a settled-but-orphaned write from a live one.
409+
*/
410+
private readonly pendingSecretWrites = new Map<string, Set<{ settle: Promise<void> }>>()
404411
/** How many writes of each secret were started. Unlike the owners, it is not cleared when a write is over. */
405412
private readonly secretWriteCount = new Map<string, number>()
406413

@@ -465,7 +472,19 @@ export class ContextProxy {
465472
// cache (it stays empty until the next write, `refreshSecrets` or a restart) rather than being served.
466473
// The entry of the key is removed when the write and its read-back are over (if it is still the latest). A store
467474
// that throws at once is turned into a rejection, so it is handled the same.
475+
const pending = this.pendingSecretWrites.get(key) ?? new Set<{ settle: Promise<void> }>()
476+
let settleWrite: () => void = () => {}
477+
const entry: { settle: Promise<void> } = {
478+
settle: new Promise<void>((resolve) => {
479+
settleWrite = resolve
480+
}),
481+
}
482+
pending.add(entry)
483+
this.pendingSecretWrites.set(key, pending)
468484
const release = () => {
485+
pending.delete(entry)
486+
if (pending.size === 0) this.pendingSecretWrites.delete(key)
487+
settleWrite()
469488
if (this.secretWriteOwner.get(key) === owner) this.secretWriteOwner.delete(key)
470489
}
471490
const stored = (async () =>
@@ -727,6 +746,13 @@ export class ContextProxy {
727746
* @returns A promise that resolves when all reset operations are complete
728747
*/
729748
public async resetAllState() {
749+
// A secret write that is in flight at this moment has already committed nothing, but it will
750+
// commit as soon as it settles, and the deletes below cannot un-commit a write that lands
751+
// after them. Wait for the pending writes first, then end the claims, then delete, so the
752+
// cleared copy is the last one to land in storage and the cache stays empty afterwards.
753+
const inFlight = [...this.pendingSecretWrites.values()].flatMap((writes) => [...writes].map((w) => w.settle))
754+
await Promise.allSettled(inFlight)
755+
730756
// Clear in-memory caches
731757
this.stateCache = {}
732758
this.secretCache = {}
@@ -735,6 +761,11 @@ export class ContextProxy {
735761
for (const key of this.stateWriteGenerations.keys()) {
736762
this.stateWriteGenerations.set(key, (this.stateWriteGenerations.get(key) ?? 0) + 1)
737763
}
764+
// A reset also ends write ownership: a write that is still in flight reads the Memento back
765+
// into the cache it just lost, so clearing the caches is not enough to keep pre-reset state
766+
// out. Ownership is what the rejection handlers gate on.
767+
this.stateWriteOwner.clear()
768+
this.secretWriteOwner.clear()
738769

739770
await Promise.all([
740771
...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)),

‎src/core/config/__tests__/ContextProxy.spec.ts‎

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,16 +150,85 @@ describe("ContextProxy", () => {
150150
rejectPending = reject
151151
}),
152152
)
153-
154153
const pending = proxy.updateGlobalState("apiProvider", "anthropic")
155154
await proxy.resetAllState()
155+
// The Memento answers the read-back with the pre-reset value: only ending write ownership
156+
// during the reset keeps the cache cleared. With the mocked store this is the difference
157+
// between the guarantee holding and the rejected write repopulating the cache.
158+
mockGlobalState.get.mockImplementation((key: string) => (key === "apiProvider" ? "anthropic" : undefined))
156159
rejectPending(new Error("disk full"))
157-
158160
await expect(pending).rejects.toThrow("disk full")
159-
// The reset cleared the cache: restoring the pre-reset value would resurrect state
160-
// that storage no longer holds.
161161
expect(proxy.getGlobalState("apiProvider")).toBeUndefined()
162162
})
163+
it("does not restore a pre-reset secret when a pending storeSecret settles after the reset", async () => {
164+
const durable = new Map<string, string | undefined>()
165+
let settle: () => void = () => {}
166+
mockSecrets.get.mockImplementation(async (key: string) => durable.get(key))
167+
mockSecrets.delete.mockImplementation(async (key: string) => {
168+
durable.delete(key)
169+
})
170+
mockSecrets.store.mockImplementationOnce(
171+
(key: string, value: string) =>
172+
new Promise<undefined>((resolve) => {
173+
settle = () => {
174+
durable.set(key, value)
175+
resolve(undefined)
176+
}
177+
}),
178+
)
179+
180+
const pending = proxy.storeSecret("apiKey", "pre-reset-secret")
181+
setTimeout(settle, 0)
182+
await proxy.resetAllState()
183+
await pending
184+
expect.soft([
185+
await mockSecrets.get("apiKey"),
186+
proxy.getSecret("apiKey"),
187+
mockSecrets.delete.mock.calls.length,
188+
])
189+
190+
// The pending store is held open on a double that only puts the value into storage
191+
// when it settles, so the ordering is observable: the reset must wait for the write to
192+
// settle and then delete the keys, so neither the cache nor the durable copy keeps the
193+
// value that was in flight when the reset started.
194+
expect(await mockSecrets.get("apiKey")).toBeUndefined()
195+
expect(proxy.getSecret("apiKey")).toBeUndefined()
196+
})
197+
198+
it("adopts a secret write issued after a reset that waited for a pending write", async () => {
199+
const durable = new Map<string, string | undefined>()
200+
let settle: () => void = () => {}
201+
mockSecrets.get.mockImplementation(async (key: string) => durable.get(key))
202+
mockSecrets.delete.mockImplementation(async (key: string) => {
203+
durable.delete(key)
204+
})
205+
mockSecrets.store.mockImplementationOnce(
206+
(key: string, value: string) =>
207+
new Promise<undefined>((resolve) => {
208+
settle = () => {
209+
durable.set(key, value)
210+
resolve(undefined)
211+
}
212+
}),
213+
)
214+
215+
const pending = proxy.storeSecret("apiKey", "pre-reset-secret")
216+
const reset = proxy.resetAllState()
217+
setTimeout(settle, 0)
218+
await pending
219+
await reset
220+
221+
// The reset released only its own claims, so the machinery stays usable afterwards:
222+
// a write issued once the reset has settled is adopted into the cache and reaches storage.
223+
// Back the post-reset write with the durable double as well, so the assertion below reads
224+
// storage rather than only the cache.
225+
mockSecrets.store.mockImplementationOnce(async (key: string, value: string) => {
226+
durable.set(key, value)
227+
})
228+
await proxy.storeSecret("apiKey", "newer-secret")
229+
expect(proxy.getSecret("apiKey")).toBe("newer-secret")
230+
expect(await mockSecrets.get("apiKey")).toBe("newer-secret")
231+
})
163232

164233
it("should bypass cache for pass-through state keys", async () => {
165234
// Setup mock return value

‎src/core/config/__tests__/ProviderSettingsManager.spec.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,17 @@ describe("ProviderSettingsManager", () => {
983983
)
984984
})
985985

986+
it('keeps a storage failure whose message mentions "not found" out of the typed not-found signal', async () => {
987+
mockSecrets.get.mockRejectedValue(new Error("vault: secret not found"))
988+
989+
// A message-based classifier would treat this as a missing profile. The typed
990+
// signal must stay reserved for a missing config entry.
991+
await expect(providerSettingsManager.deleteConfig("default")).rejects.not.toBeInstanceOf(
992+
ProviderSettingsNotFoundError,
993+
)
994+
await expect(providerSettingsManager.deleteConfig("default")).rejects.toThrow("Failed to delete config")
995+
})
996+
986997
it("should throw error when trying to delete last remaining config", async () => {
987998
mockSecrets.get.mockResolvedValue(
988999
JSON.stringify({

‎src/core/config/__tests__/importExport.spec.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,7 @@ describe("importExport", () => {
370370
})
371371

372372
expect(result.success).toBe(true)
373-
// Per-view selection state is machine-local: importing settings must not
374-
// apply another machine's view pins, while other settings round-trip.
373+
// Importing must not apply another machine's view pins.
375374
expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code" })
376375
expect(result).not.toHaveProperty("globalSettings.viewStates")
377376
})

‎src/core/config/importExport.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,7 @@ function sanitizeGlobalSettings(rawGlobalSettings: unknown): {
9999
for (const [key, rawValue] of Object.entries(rawGlobalSettings)) {
100100
const path = `globalSettings.${key}`
101101

102-
// Per-view selection state is machine-local: it round-trips through the
103-
// normal runtime and pruning paths, but importing it would pin selections
104-
// from another machine's views on this one.
102+
// Per-view selections are machine-local: they must not import across machines.
105103
if (key === "viewStates") {
106104
continue
107105
}

‎src/core/webview/ClineProvider.ts‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ import { PendingEditOperationStore, type PendingEditOperationInput } from "./Pen
164164

165165
type PersistedViewState = NonNullable<GlobalState["viewStates"]>[string]
166166
type ViewLocalStateValues = Partial<RooCodeSettings> & Partial<ExtensionState>
167+
/**
168+
* The subset of view-local values that has a durable per-view record. `saveViewState`
169+
* and the mutation path handle exactly these keys, so the generic stays narrow enough
170+
* for the compiler to reject e.g. `writeDelayMs`.
171+
*/
167172
type PersistedViewStateValues = Pick<ViewLocalStateValues, "mode" | "currentApiConfigName" | "apiConfiguration">
168173

169174
/**
@@ -434,7 +439,7 @@ export class ClineProvider
434439
public readonly viewId: string
435440

436441
/** Stable id for persisted per-view state keys, bound by the host-minted claim. */
437-
private viewStateId: string = ""
442+
private viewStateId: string
438443

439444
/**
440445
* Whether this instance wrote an entry under its own session-local temporary id.
@@ -474,15 +479,16 @@ export class ClineProvider
474479
mdmService?: MdmService,
475480
) {
476481
super()
482+
// Initialize viewId based on renderContext and monotonically increasing instance identifier for uniqueness.
483+
// activeInstances is used for visibility/iteration checks, so we keep tracking instances separately.
484+
this.viewId = `${renderContext}-${ClineProvider.nextViewId++}`
485+
this.viewStateId = this.viewId
477486
this.currentWorkspacePath = getWorkspacePath()
478487
this.pendingEditOperations = new PendingEditOperationStore(
479488
ClineProvider.PENDING_OPERATION_TIMEOUT_MS,
480489
(message) => this.log(message),
481490
)
482491

483-
// Derive the per-instance view id and bind the persisted view-state key to it.
484-
this.viewId = `${renderContext}-${ClineProvider.nextViewId++}`
485-
this.viewStateId = this.viewId
486492
ClineProvider.activeInstances.add(this)
487493
this.diagnosticsRecorder.record({ boundary: "provider", phase: "success", type: "created" })
488494

@@ -510,6 +516,8 @@ export class ClineProvider
510516
await this.postStateToWebviewWithoutClineMessages()
511517
})
512518

519+
void this.loadViewState()
520+
513521
// Initialize MCP Hub through the singleton manager
514522
McpServerManager.getInstance(this.context, this)
515523
.then((hub) => {
@@ -2874,9 +2882,18 @@ export class ClineProvider
28742882

28752883
const activationResults = await Promise.allSettled([
28762884
this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()),
2877-
this.updateGlobalState("currentApiConfigName", name),
2885+
// Shared write: adds/updates the profile list entry and the current-profile name.
2886+
this.setValue("currentApiConfigName", name),
28782887
this.providerSettingsManager.setModeConfig(mode, id),
28792888
this.contextProxy.setProviderSettings(providerSettings),
2889+
// setProviderSettings bypasses the buffer path; the mutation below refreshes this view.
2890+
// One mutation carries both the name and the settings: pinnedProfileName reads the buffer,
2891+
// so a buffer that keeps the previous name while holding the new settings pins a profile
2892+
// this view no longer runs under.
2893+
this._saveViewLocalStateFromMutation({
2894+
currentApiConfigName: name,
2895+
apiConfiguration: providerSettings,
2896+
}),
28802897
])
28812898
const failedActivation = activationResults.find(
28822899
(result): result is PromiseRejectedResult => result.status === "rejected",
@@ -2987,6 +3004,7 @@ export class ClineProvider
29873004
await this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings })
29883005

29893006
// Refresh other views pinned to this profile so their overlays cannot go stale.
3007+
await this.refreshSiblingsAfterProfileSave(name, providerSettings)
29903008

29913009
// Change the provider for the current task.
29923010
// TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`).
@@ -2996,13 +3014,53 @@ export class ClineProvider
29963014
await this.persistStickyProviderProfileToCurrentTask(name)
29973015
} else {
29983016
await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig())
3017+
3018+
// A non-activating save still changes the profile's settings: refresh the views
3019+
// that buffered a copy, or their getState() keeps the pre-save values.
3020+
if (this.pinnedProfileName === name) {
3021+
await this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings })
3022+
// Only rebuild a task that actually runs under the saved profile.
3023+
if (this.getCurrentTask()?.taskApiConfigName === name) {
3024+
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })
3025+
}
3026+
}
3027+
// A non-activating save changes the profile's settings too: refresh every sibling
3028+
// that buffered a copy (pinned siblings included), or their getState() keeps the pre-save
3029+
// values. This head had no sibling refresh at all before this change.
3030+
await this.refreshSiblingsAfterProfileSave(name, providerSettings)
29993031
}
30003032

30013033
if (signal.aborted) return id
30023034

30033035
await this.postStateToWebview()
30043036
return id
30053037
}
3038+
/**
3039+
* Refresh the buffered overlay of every live sibling pinned to a saved profile and rebuild
3040+
* any task that follows it. The handler rebuild is independent of the pin: a task restored
3041+
* from history can follow profile A while its view pins profile B.
3042+
*/
3043+
private async refreshSiblingsAfterProfileSave(name: string, providerSettings: ProviderSettings): Promise<void> {
3044+
for (const instance of ClineProvider.getAllInstances()) {
3045+
if (instance === this || instance._disposed || instance.pinnedProfileName !== name) {
3046+
continue
3047+
}
3048+
// Direct private access: compile-time safe across sibling instances.
3049+
await instance._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings })
3050+
}
3051+
3052+
const {
3053+
name: _recordName,
3054+
id: _recordId,
3055+
...taskSettings
3056+
} = providerSettings as ProviderSettings & { name?: string; id?: string }
3057+
for (const instance of ClineProvider.getAllInstances()) {
3058+
if (instance._disposed || instance.getCurrentTask()?.taskApiConfigName !== name) {
3059+
continue
3060+
}
3061+
instance.updateTaskApiHandlerIfNeeded(taskSettings, { forceRebuild: true })
3062+
}
3063+
}
30063064

30073065
async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) {
30083066
const globalSettings = this.contextProxy.getValues()

0 commit comments

Comments
 (0)