Skip to content

test(webview): ClineProvider parallelMode suite with viewStates pruning edges - #1921

Open
easonLiangWorldedtech wants to merge 54 commits into
Zoo-Code-Org:mainfrom
easonLiangWorldedtech:vps2-f2c
Open

easonLiangWorldedtech wants to merge 54 commits into
Zoo-Code-Org:mainfrom
easonLiangWorldedtech:vps2-f2c

Conversation

@easonLiangWorldedtech

@easonLiangWorldedtech easonLiangWorldedtech commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Part of the vps2 durable per-view state series — tracked in easonLiangWorldedtech#41.

Unit F2c of the F2 split; supersedes #1555. Stacked on F2b. Content source of record: kind: commit, base 8554307ec -> head bff2a5ca8 (PR #1555).

Budget: 739 a+d standalone — over the soft 400 cap, under the hard 1000 cap. Rationale: one cohesive regression suite whose ~588 lines are shared mock setup for three describes; splitting the file would duplicate the setup rather than reduce what a reviewer reads.

Related GitHub Issue

Closes: easonLiangWorldedtech#41

Description

How this unit implements durable per-view state, and the choices reviewers should check:

  • Per-view identity. Each ClineProvider starts with a host-generated temporary id (sidebar-3) and, on webviewDidLaunch, adopts the stable id the webview persists through acquireVsCodeApi().setState() (webview-ui/src/utils/vscode.ts). The id is sanitized to [A-Za-z0-9_-] and __proto__ is rejected so it stays a safe object key in the shared map.
  • Durable viewStates map. mode and currentApiConfigName are stored per view id under the new viewStates global-state key (packages/types/src/global-settings.ts). Every write goes through one serialized write queue that re-reads the map fresh and merges into the existing entry, so concurrent views cannot clobber each other; entries with nothing persistable are deleted and stale entries are pruned.
  • Re-keying. Writes captured while the provider still holds its temporary id persist under that id and are re-keyed to the stable id when the webview registers one, so a change made before the launch message stays durable. A failed registration restores the previous id so a later launch retries.
  • Overlay composition in getState(). The view-local buffer is layered over the shared ContextProxy values; fields mutated while the async profile lookup is in flight are re-applied field by field, so a slow load cannot clobber a newer user selection.
  • Cache/storage atomicity. ContextProxy#updateGlobalState fills the in-memory cache before awaiting globalState.update, so the three viewStates writes now go through writePersistedViewStates, which restores the previously cached map when the storage write rejects instead of leaving other views reading an uncommitted map.
  • viewStates is excluded from settings import/export so a per-window view map is never transferred between machines.
  • Title-bar command routing (src/activate/registerCommands.ts): title-bar commands target the click-origin instance, and overlapping openClineInNewTab calls are serialized.

Test Procedure

pnpm --dir src vitest run core/webview core/config core/task activate
pnpm --dir webview-ui vitest run src/utils/__tests__/vscode.spec.ts src/context/__tests__/ExtensionStateContext.spec.tsx
  • ClineProvider.parallelMode.spec.ts — multi-tab isolation: two views with different ids keep separate mode, profile pin and apiConfiguration.
  • ClineProvider.spec.ts → setViewStateId, persisted view state pruning, view state persistence edge cases — merge/clear/prune semantics, corrupted storage values treated as an empty map, re-keying of pre-launch writes, and the cache rollback when the storage write rejects.
  • ClineProvider.sticky-mode.spec.ts / sticky-profile.spec.ts — a view's pinned profile survives a mode switch and a sibling view's change.
  • vscode.spec.ts / ExtensionStateContext.spec.tsx — the webview reuses its persisted id or mints a new one, and posts it in webviewDidLaunch.
  • Each fix in this branch was verified with a negative control: the production change was reverted in isolation and the corresponding test confirmed to fail.

Pre-Submission Checklist

  • Issue Linked: linked to the approved tracking issue above.
  • Scope: per-view durable state only (no settings-UI or task-lifecycle changes).
  • Self-Review: performed.
  • Testing: new suites plus per-fix regression tests.
  • Visual Snapshot (UI changes only): N/A — no user-visible rendered state changes; the webview diff is the launch handshake only.
  • Documentation Impact: considered; no user-facing documentation change is required (the setting is internal state, not a user control).
  • Contribution Guidelines: read and agreed.

Documentation Updates

  • No documentation updates are required.

Additional Notes

mutation-diff reports more than the 500 changed-executable-line cap for this series; the remedy is tracked on easonLiangWorldedtech#41 (maintainer-side cap decision), not another split.

easonliang28 and others added 25 commits October 5, 2026 21:34
…en view-identity tests

Track the in-flight tab panel creation with a module-level promise so concurrent openClineInNewTab calls reuse one panel and provider (adds a Promise.all regression test). ClineProvider.spec sets the private view via the public resolveWebviewView() instead of a ts-ignore assignment. registerCommands.spec types evictCurrentTask/refreshWorkspace on the fixture and drops the as any attachment. eslint-suppressions: prune the registerCommands.spec.ts entry (two as any suppressions removed).
…-bar posts

- openClineInNewTab: extract the unserialized creation body into
  createTabPanelUnlocked and guard the in-flight slot clear so a settled
  creation cannot clobber a replacement already stored in the slot.
- onDidDispose: clear the tracked tab ref only when the disposing panel is
  still the tracked one, so a late disposal of a replaced panel cannot
  clobber the replacement's ref.
- MDM lookup failure: log the fallback to the output channel instead of
  swallowing it silently.
- Route the six title-bar button handlers through a shared postActions
  helper that posts each action in order and logs failures with the
  handler-specific prefix.
- package.json: add the four InTab commands to the command palette, scoped
  to the active tab panel.
- Tests: handler-level regression for openInNewTab + popoutButtonClicked
  started before the first creation resolves; fresh-creation test for a
  settled in-flight promise; stale-panel disposal regression; retained
  panel assertion for disposed tab instances; rightmost-editor column
  placement assertion; MDM fallback output assertion; %s placeholders for
  primitive it.each titles.
- Stryker directives for the two equivalent setPanel type-literal mutants
  (setPanel branches only on type === sidebar).
Replace the weak toBeDefined() assertion in the dispose spec with an
identity check against the panel returned during creation, per the
CodeRabbit actionable comment on this PR (review run 7c4cfeb3-6dd9-4615-
9a58-70cfc705eca2). The tracked tab is now pinned with toBe(panel)
before the dispose assertions, so a wrong or duplicated tracked panel
fails the suite instead of passing a defined-only check.

Upstream: Zoo-Code-Org#1528 (vps2 F0)
Retain the tracked tab panel in the InTab handler cases and assert that
getInstanceForView was called with that exact panel, per the CodeRabbit
actionable comment on this PR (review run 4afe1273-8739-4235-90d3-311db5f6ccb9,
inline comment 3952466254 on the tabHandlerCases spec). A handler resolving
any other view now fails instead of passing on the stubbed provider result
alone; the same identity pin is applied to plusButtonClickedInTab.

Upstream: Zoo-Code-Org#1528 (vps2 F0)
…States

Each ClineProvider instance now owns a unique viewId (renderContext plus a
monotonic counter) and registers a stable viewStateId for durable persistence.

- Per-view state buffer (viewLocalState) holds mode / currentApiConfigName /
  apiConfiguration overrides in memory; saveViewState persists the non-secret
  subset durably under the active view id, rekeyed to the stable id on
  registration.
- viewStates is stored as a map pruned to the newest 50 entries; writes go
  through a serialized queue so concurrent provider instances merge without
  lost updates.
- setViewStateId sanitizes ids and rejects "__proto__" so a per-view entry can
  never be keyed through the Object.prototype setter.
- postMessageToWebview no longer awaits the webview ack: a remounted or
  disposed page never acknowledges, and awaiting would wedge task-critical
  callers.
- History restore falls back to the default mode view-locally instead of
  writing the shared global mode.
- GlobalState gains the "viewStates" key and GLOBAL_STATE_KEYS tracks it.

Adds F1a coverage in ClineProvider.spec.ts (viewId uniqueness, saveViewState
persistence semantics, loadViewState fallback and failure, pruning, the
__proto__ guard) and adapts the two history-restore tests in
ClineProvider.sticky-mode.spec.ts to the view-local restore. getState()
merging of hydrated per-view values and the remaining view-state suites land
in the follow-up (F1b).
…nd target tab-instance commands

Reapply in-flight view-local fields with Object.is identity so a field cleared during the load window stays cleared; route mode switches through setValue so the in-memory buffer and durable write agree, with rollback on failure; refresh cross-instance view-local state on profile upsert, activate and delete and re-pin the buffer after a delete; point focusInput and active-panel re-registration at the tracked tab provider and panel; log dropped webview postMessage failures with the message type; pin tab-instance, focusInput and active-panel identity in the registerCommands tests and type the mdm double in the provider spec.
…apture view pin on delete

Address CodeRabbit walkthrough findings on the F1a unit:

- handleModeSwitchUnlocked now bails before the task-level writes when the abort signal has fired, closing the partial-apply window where a cancelled switch could still rewrite the persisted task mode; the existing pre-write guard still covers in-flight aborts.
- Replace bracket access to sibling-instance private members with a typed pinnedProfileName getter and direct private member access (compile-time safe across instances).
- deleteProviderProfile now captures this view's pin before the currentApiConfigName rewrite so a view pinned to the deleted profile while the global selection points elsewhere is still reconfigured with the surviving profile's settings.

Tests: focusInput asserts the tab panel by identity and that no error was logged on the success path; the stalled getProfile double fails loudly on a second lookup (only one lookup is resolvable).
…-local profile pins

handleModeSwitchUnlocked: an abort landing while updateTaskHistory is in flight previously left the new mode persisted in task history and assigned to task._taskMode before the pre-write signal check bailed; the landed write is now rolled back to the pre-switch item and the method returns before the TaskModeSwitched emit and the durable mode write. TaskModeSwitched now only fires for a completed transition. deleteProviderProfile: the unconditional setValue('currentApiConfigName', ...) overwrote a view's pin when an unrelated profile was deleted; the pin is now re-pointed only when it names the deleted profile, and a deleted-was-global deletion updates the shared store only. The nested apiConfiguration overlay is replaced with the surviving profile's settings only for a view pinned to the deleted profile.
…tore

deleteProviderProfile pruned the UI-facing listApiConfigMeta entry but never removed the profile's settings from the ProviderSettingsManager store (context.secrets), so a later listApiConfigMeta sync could resurrect the deleted profile and a dangling per-mode mapping could re-activate it.

The purge now calls providerSettingsManager.deleteConfig and branches on the typed ProviderSettingsNotFoundError (introduced here alongside) so an already-gone secret is an idempotent success -- the stale list entry is still pruned -- while any other failure (e.g. the store refusing to delete the last remaining configuration) propagates. Matching message text instead would let a profile whose name contains 'not found' swallow an unrelated failure.

Tests: the dangling-mode-mapping resurrection scenario, the already-gone secret, the store-level last-profile refusal, the typed-signal contract in the manager spec, and provider-level not-found/propagation pins.
A failed task-history rollback during an aborted mode switch previously propagated into the outer persistence-error handler and surfaced as the switch's own persistence failure. Guard the rollback with its own try/catch so the rollback error is logged with task context and the cancellation return is preserved (CodeRabbit finding on this PR).
…d switch

The in-flight abort rollback rewrote the whole task-history item with the pre-switch snapshot, clobbering any fields the running task persisted during the pending window (tokens, cost, status, apiConfigName). Re-read the item and restore only the mode this switch changed (CodeRabbit data-integrity finding on this PR).
Rebasing onto main tip merged main's createTabPanelUnlocked body with the PR's
serialized creation. Main no longer resolves CodeIndexManager in this function,
so the leftover line referenced an import the PR never added and the suite
failed with ReferenceError. Removing it restores the PR's own delta: 48
registerCommands tests and 459 F1a tests pass.
…overrides

Fold ClineProvider viewLocalState on top of ContextProxy values in getState() (mode, apiConfiguration, and all per-view fields) so each webview reports its own selections while falling back to shared global state for everything else. Ports the getState-merging and local-state-isolation spec coverage from the superseded vps2 source.

Also pins the full default surface of the merged read path, including the apiConfiguration provider fill-in when provider settings sanitize the raw value away (mutation-diff gate).
Rebuilding the F1b unit as main tip + its own delta cleared the conflict with
main. The patch was authored against an older main, so applying it dropped the
alwaysDenyUnapprovedCommands entry from getState(); restoring it with the shared
default constant keeps the settings round trip complete. 410 tests pass.
Applying the F1c delta (ad94239...8554307) onto the rebuilt F1b head cleared the conflict with main tip without changing content. 338 src tests and 39 webview-ui tests pass.
Part of the vps2 durable per-view state series, tracked in #41.
Stacked on F2b. Content ported from the pinned source (kind: commit, base 8554307 -> head
bff2a5c, PR Zoo-Code-Org#1555). Over the soft 400 a+d cap (739): one cohesive regression suite whose
~588 lines are shared mock setup for three describes.
@github-actions github-actions Bot added the awaiting-coderabbit Waiting for CodeRabbit to approve the latest commit label Oct 8, 2026
…ompensating deletion

setViewStateId took the identifier straight off the webview channel and used it as a
key in the shared viewStates map. It now treats it as untrusted input:

- a non-string value is logged and ignored (a crafted postMessage is JSON, so the
  declared message type is not enforced at the boundary);
- the key is length-bounded by MAX_VIEW_STATE_ID_LENGTH (the webview generates a UUID,
  so a far longer value is not a view id, and an unbounded key is unbounded growth in
  the shared map);
- an id another live ClineProvider already registered is refused: adopting it would
  load that view's pinned profile, nested overlay, and mode into this provider, and
  this webview could then overwrite that view's persisted entry. The view keeps its
  temporary id and still works against the shared state.

Generating the id webview-side is unchanged and deliberate: it has to survive a
webview reload, which recreates the view on both sides, so the host cannot mint it.
What the host owns is which view may hold a given key. A webview can already request
any profile through the activateProviderProfile message path, so the id was never an
authentication boundary; the boundary violation is one live view taking over another
live view's persisted entry, and that is what the claim check blocks.

Zoo-Code-Org#1921 is not cumulative with Zoo-Code-Org#1919, so the profile-deletion work is ported here too:
the compensating deleteProviderProfile (pre-delete settings read, per-store
snapshots, per-store rollback, inconsistent-state error) plus the typed
ProviderSettingsNotFoundError out of getProfile that the compensation branches on.

Tests: three new in ClineProvider.spec (foreign claim refused, oversized id refused,
non-string id ignored) and the three delete-profile regression tests; one in
ProviderSettingsManager.spec. Three existing reload tests now dispose the writing
provider before the new one re-registers the id, which is what a reload does.

Negative controls: neutralising each gate (claim predicate -> false, length cap ->
false, typeof gate -> false) fails its test (foreign id adopted, oversized key
adopted, TypeError on trim); each was reverted and re-passed.

Local: core/webview + core/config 972 passed; tsc 100 errors (= local baseline);
eslint --prune-suppressions --max-warnings=0 clean on all four files.
@github-actions github-actions Bot removed coderabbit-review-active Required CI passed; CodeRabbit review is active awaiting-coderabbit Waiting for CodeRabbit to approve the latest commit labels Oct 8, 2026
@easonLiangWorldedtech

Copy link
Copy Markdown
Contributor Author

Pushed 2c7eb07 — Security Boundaries + Persistence Integrity

Security Boundaries (the ❌ row on c6833ee)

setViewStateId took the identifier straight off the webview channel and used it as a key in the shared viewStates map. It is now validated as untrusted input:

gate behaviour
type a non-string value is logged and ignored — a crafted postMessage is JSON, so the declared viewStateId?: string is not enforced at the boundary
length bounded by MAX_VIEW_STATE_ID_LENGTH (64) — the webview generates a UUID, so a far longer value is not a view id, and an unbounded key is unbounded growth in the shared map
ownership an id another live ClineProvider already registered is refused; the view keeps its temporary id

Why ownership rather than host-side generation: the id has to survive a webview reload, which recreates the view on both sides, so the host cannot mint an id that persists across recreation — that is the F1c design in #41. A webview can already request any profile through the activateProviderProfile message path, so the id was never an authentication boundary. The boundary that is real is one live view adopting another live view's persisted key: it would load that view's pinned profile, nested overlay and mode into this provider, and this webview could then overwrite that view's entry. getAllInstances() claim check closes exactly that, and the view still works against the shared state on its temporary id.

Persistence Integrity (the second ❌ row)

#1921 is not cumulative with #1919, so the compensating deleteProviderProfile is ported here: pre-delete settings read (abort before the destructive commit unless it is the typed not-found), snapshots of profile list / shared selection / view pin / nested overlay, per-store rollback each awaited on its own, and a distinct Profile deletion left persisted state inconsistent: … error when the rollback itself fails. rePinViewLocalStateForDeletedProfile restores each sibling's own pin before rethrowing. The typed ProviderSettingsNotFoundError out of getProfile (the dependency the compensation branches on) is ported with it.

Tests + negative controls

New: refuses a viewStateId that another live view already owns, refuses an oversized viewStateId instead of using it as a persisted key, ignores a non-string viewStateId arriving from a crafted message, the three delete-profile compensation tests, and rejects a missing profile lookup with the typed not-found error. Three existing reload tests now dispose the writing provider before the new one re-registers the id — that is what a reload actually does, and it is the case the claim check must keep allowing.

mutation result
claim predicate → false foreign-claim test FAILS (viewStateId becomes owned-by-first-view)
length cap → false oversized test FAILS (200-char key adopted)
typeof viewStateId !== "string" → false non-string test FAILS (TypeError: viewStateId.trim is not a function)
manager typed-throw reverted typed not-found test FAILS

Local: core/webview + core/config 972 passed; tsc --noEmit 100 errors (= local baseline, stale built @roo-code/types dist — CI compile is green); eslint --prune-suppressions --max-warnings=0 clean on all four files, no suppression delta.

No @coderabbitai full review requested — the content push triggers the at-head review.

@coderabbitai

coderabbitai Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@easonLiangWorldedtech Thanks for the detailed update and guidance.

You report that 2c7eb07 adds:

  • Runtime type validation, a 64-character limit, and live-view ownership checks for setViewStateId.
  • Compensating rollback for deleteProviderProfile, with a distinct error when rollback fails.
  • Regression tests and negative controls for these changes.

The ownership check addresses live-view state isolation, not profile authorization. Reload tests must release the previous provider’s claim before registering the replacement provider.

I have not independently verified this head or the reported test results. I will not trigger a separate full review, as requested.


✏️ Learnings added
Learnt from: easonLiangWorldedtech
URL: https://github.com/Zoo-Code-Org/Zoo-Code/pull/1921

Timestamp: 2026-10-08T17:15:18.881Z
Learning: In Zoo-Code-Org/Zoo-Code, ClineProvider viewStateId ownership is a live-view state-isolation boundary, not a profile-authorization boundary: webviews can already request profiles through activateProviderProfile. The intended setViewStateId contract rejects non-string inputs, identifiers longer than MAX_VIEW_STATE_ID_LENGTH (64), and identifiers claimed by another live ClineProvider. Reload lifecycle tests dispose the previous provider before a replacement provider registers the same persisted identifier.

Learnt from: easonLiangWorldedtech
URL: https://github.com/Zoo-Code-Org/Zoo-Code/pull/1921

Timestamp: 2026-10-08T17:15:18.881Z
Learning: Zoo-Code-Org/Zoo-Code PR #1921 is not cumulative with PR #1919. Profile-deletion compensation implemented in #1919 must also be present in #1921; do not assume that #1921 inherits those changes.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: easonLiangWorldedtech
Repo: Zoo-Code-Org/Zoo-Code

Timestamp: 2026-10-08T11:15:24.502Z
Learning: In src/core/webview/ClineProvider.ts, viewStates rollback must preserve an absent storage key as undefined. Read the rollback target directly with context.globalState.get<GlobalState["viewStates"]>("viewStates"); getPersistedViewStates({ fresh: true }) normalizes an absent key to {}, which violates the persistence-failure contract. ContextProxy.setValue updates the cache before awaiting storage, so rollback must use the storage value rather than a potentially stale cached value.

If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

@github-actions github-actions Bot added coderabbit-review-active Required CI passed; CodeRabbit review is active awaiting-coderabbit Waiting for CodeRabbit to approve the latest commit labels Oct 8, 2026
…n rollback

Port of the two cross-view tests added on Zoo-Code-Org#1919 (the units are not cumulative):

- refreshes another live view's buffered settings when the profile it pins is
  upserted: a second live ClineProvider pinned to the profile must receive the fresh
  apiConfiguration through its own view-local buffer and a state post.
- restores an affected sibling view's own pin when the deletion re-pin write fails:
  the sibling gets its own context (same backing store, failing viewStates update), so
  the re-pin write is the one that rejects; the test asserts the cached map, the durable
  map and the buffer all still name the pre-deletion profile, and that the deleting view
  rolled its own stores back.

Negative controls on THIS branch:
- emptying refreshViewLocalStateForUpdatedProfile's affected list -> the first test
  fails (sibling keeps the openrouter/stale-key overlay).
- replacing the deletion's list restore with `void previousEntries` -> the second test
  fails (list no longer matches [doomed, keeper]).
The rePin-level pin rollback itself is not separately observable here: this branch's
writePersistedViewStates already repairs storage AND the ContextProxy cache when its
setValue rejects, so the phantom map never survives. Both reverted byte-for-byte and
re-passed.

Test-only change; no production lines touched.

Local: core/webview + core/config = 974 passed; tsc 100 errors (= baseline);
eslint --prune-suppressions --max-warnings=0 clean on both files.
@github-actions github-actions Bot added coderabbit-review-active Required CI passed; CodeRabbit review is active awaiting-coderabbit Waiting for CodeRabbit to approve the latest commit and removed coderabbit-review-active Required CI passed; CodeRabbit review is active awaiting-coderabbit Waiting for CodeRabbit to approve the latest commit labels Oct 8, 2026
Port of the Zoo-Code-Org#1919 fix (the units are not cumulative): createTabPanelUnlocked built the
tab in eight awaited steps after constructing the ClineProvider, with no cleanup on
failure. A rejected step left the provider registered in ClineProvider.activeInstances
with its listeners, and setPanel may already have tracked a panel nobody owns - so a
later "Open in editor" inherits a dead tab or races a second provider for it.

The post-construction body now runs in a try and disposeFailedTabCreation disposes the
provider, clears the tracked ref only while it still names this panel, and disposes the
panel - each step in its own try/catch, with an incomplete cleanup logged as its own
line while the original error still reaches the caller.

Tests ported as well: 'disposes the provider and the panel when a creation step
rejects' and 'reports incomplete cleanup when the tab-creation rollback itself fails'.
Negative controls on THIS branch: removing the disposeFailedTabCreation call fails both
tests; removing only the tracked-ref clear fails the first on getPanel(). Reverted
byte-for-byte and re-passed.

src/activate/registerCommands.ts is now byte-identical to the Zoo-Code-Org#1919 branch version.
git diff -w shows +51/-1; the rest is the one-tab re-indentation the try requires.

Local: core/webview + core/config + activate = 1084 passed; tsc 100 errors (=
baseline); eslint --prune-suppressions --max-warnings=0 clean on both files.
@github-actions github-actions Bot removed coderabbit-review-active Required CI passed; CodeRabbit review is active awaiting-coderabbit Waiting for CodeRabbit to approve the latest commit labels Oct 8, 2026
@easonLiangWorldedtech

Copy link
Copy Markdown
Contributor Author

Pushed 657c109 (non-cumulative port of #1919 420a831) — ⚠️ Lifecycle Resource Cleanup (createTabPanelUnlocked)

Confirmed: the body constructs ClineProvider and then runs eight more steps (newGroupRight, createWebviewPanel, setPanel, iconPath, resolveWebviewView, the two panel listeners, delay(100) + lockEditorGroup) with no cleanup. Any of those awaits can reject, and the retry path added earlier makes the failure reachable repeatedly. What survived:

  • the constructed provider stays in ClineProvider.activeInstances with its listeners attached (duplicate provider work, and it counts as a visible instance for getVisibleInstance heuristics);
  • setPanel(newPanel, "tab") may already have run, so tabPanel points at a panel whose provider never resolved — the next "Open in editor" finds no live provider for it, drops the ref and rebuilds, i.e. the leak is paid again on every retry.

Fix as requested: the post-construction body now runs inside a try, and disposeFailedTabCreation disposes the provider, clears the tracked ref only while it still names this panel, and disposes the panel. Each step is awaited in its own try/catch so one failing cleanup cannot strand the other, and an incomplete cleanup is logged as its own line instead of hiding behind the original error, which is still what the caller sees.

Tests: "disposes the provider and the panel when a creation step rejects" (the last step, lockEditorGroup, rejects — provider constructed, panel created and tracked, view resolved) and "reports incomplete cleanup when the tab-creation rollback itself fails" (provider dispose rejects and panel dispose throws -> the incomplete-cleanup line, both steps still attempted).

Negative control: removing the disposeFailedTabCreation call fails both tests (dispose never called); removing only the tracked-ref clear fails the first on getPanel() still returning the orphan panel. Reverted byte-for-byte and re-passed.

git diff -w shows the real delta (+52/-1 in registerCommands.ts); the rest of the diff is the one-tab re-indentation the try requires.

Local: core/webview + core/config + activate = 1084 passed; tsc --noEmit 100 errors (= local baseline); eslint --prune-suppressions --max-warnings=0 clean on both files. src/activate/registerCommands.ts is byte-identical to the #1919 branch version; the hunk review against c4e38b9 shows only the helper, the try/catch and the re-indentation — nothing foreign.

@github-actions github-actions Bot added coderabbit-review-active Required CI passed; CodeRabbit review is active awaiting-coderabbit Waiting for CodeRabbit to approve the latest commit labels Oct 8, 2026
…m writing

Port of Zoo-Code-Org#1919 027c502. The units are not cumulative, and this branch was further
behind than expected: deleteProviderProfile here still ran unserialized against
upsert/activation/mode-switch, so the queue wrapper, the two queue-contract abort
checks, and the new cancellation guards all land here.

A timed-out deletion must not keep writing durable state after it has yielded its
place in the queue: the shared selection, the shared provider settings, and other
views' pins are now behind one more AbortSignal check, and the catch skips
compensation entirely once the signal is aborted, because the next queued mutation
may already own those stores and replaying this operation's rollback would overwrite
IT. Both paths log what state was left behind.

The queue still advances on the timeout-bounded caller result on purpose: advancing
on the unbounded run promise lets one hung durable write block every later profile
mutation for the lifetime of the window.

Test: 'stops writing durable state once the deletion's abort signal fires' - the
cancellation lands on the survivor lookup, after the settings commit and the profile-
list write; asserts the list write landed, the shared selection and provider settings
were not rewritten, and no compensation write happened.

Negative controls: removing the pre-rewrite guard lets the deletion run to completion
(rejection assertion fails); removing the compensation skip replays the profile list
(list no longer equals [keeper]). Both reverted byte-for-byte and re-passed.

Port review per the hunk-by-hunk rule: git diff shows 5 hunks, +51 lines, all
additions (wrapper + signature + three abort checks + compensation skip); nothing
foreign was dragged in.

Local: core/webview + core/config + activate = 1085 passed; tsc 100 errors (=
baseline); eslint --prune-suppressions --max-warnings=0 clean on both files.
@easonLiangWorldedtech

Copy link
Copy Markdown
Contributor Author

Pushed c788f52 (port of #1919 027c502) — ❌ Persistence Integrity (cancelled profile deletion vs. the next queued mutation)

Half confirmed, half argued.

Confirmed: the overlap is real, but the fix is not where the check put it. deleteProviderProfileUnlocked now checks the AbortSignal once more, immediately before the selection/settings rewrite (the shared selection, the shared provider settings, and other views' pins), and the catch skips compensation entirely once the signal is aborted. A timed-out operation has already handed the queue turn to the next mutation, so replaying its rollback would overwrite that mutation's writes - which is the corruption the row describes, just reached through the rollback rather than through the forward writes. Both paths log what state was left behind, so a partial deletion is reported instead of silently repaired.

Argued - do not advance the queue from the unbounded run promise. this.providerProfileMutationQueue = run.then(...) makes one hung durable write (deleteConfig never settling) block every later profile mutation for the lifetime of the extension host: no caller can delete, upsert, activate, or switch mode, and no caller is waiting for the hung one. Advancing on the timeout-bounded callerResult is deliberate; the invariant that makes it safe is that the cancelled operation stops touching durable state at the cancellation point, which is exactly what the added guards enforce. The row's own resolution keeps the caller timeout; the difference is only who owns the stores afterwards.

Test: "stops writing durable state once the deletion's abort signal fires" - the cancellation lands on the survivor lookup, i.e. after the settings commit and the profile-list write. Asserts the list write landed, the shared selection and provider settings were not rewritten, and no compensation write happened (saveConfig never called).

Negative controls: removing the pre-rewrite guard lets the deletion run to completion (the rejection assertion fails); removing the compensation skip replays the profile list (the list no longer equals [keeper]). Both reverted byte-for-byte and re-passed.

This branch was further behind than expected: deleteProviderProfile here still ran unserialized against upsert/activation/mode-switch, so the port also brings the queue wrapper and the two queue-contract abort checks. git diff is 5 hunks / +51 lines, all additions - reviewed hunk-by-hunk, nothing foreign. Local: 1085 passed; tsc --noEmit 100 errors (= baseline); eslint clean.

@github-actions github-actions Bot removed coderabbit-review-active Required CI passed; CodeRabbit review is active awaiting-coderabbit Waiting for CodeRabbit to approve the latest commit labels Oct 8, 2026
…loop

Port of Zoo-Code-Org#1919 59f4492 (the units are not cumulative). The generic settings loop
iterates Object.entries(message.updatedSettings) and calls provider.setValue for every
key; with the per-view buffer in place that write also updates this view's local
overlay, which getState() prefers over the shared values. Nothing validated the runtime
shape, so a crafted payload could carry apiConfiguration straight into both sinks.

HOST_OWNED_SETTINGS (apiConfiguration, listApiConfigMeta, viewStates) is rejected at the
top of the loop with its own log line: those keys come from validated profile-management
paths, and ClineProvider owns the per-view durable map.

Test and negative control are the same as on Zoo-Code-Org#1919: removing the guard makes the
apiConfiguration assertion fail; reverted byte-for-byte and re-passed here.

Port diff is identical to Zoo-Code-Org#1919 (+62 across the two files), reviewed hunk-by-hunk:
one Set at module scope, one guard at the top of the loop, one new describe in the spec.

Local: core/webview + core/config + activate = 1086 passed; tsc 100 errors (=
baseline); eslint --prune-suppressions --max-warnings=0 clean on both files.
Port of Zoo-Code-Org#1919 9e47e3e (the units are not cumulative). dispose() set _disposed, ran a
chain of awaited teardown steps, and only removed the provider from activeInstances on its
last line: a rejecting mcpHub.unregisterClient() / skillsManager.dispose() /
marketplaceManager.cleanup() left the provider discoverable through getVisibleInstance,
getAllInstances and getInstanceForView with its view and listeners attached, and _disposed
meant the cleanup would never run again.

Every teardown step now runs through attemptCleanup, which records its own failure and
lets the rest proceed; the unregistration is unconditional afterwards and an incomplete
teardown gets its own log line.

Test and negative control are the same as on Zoo-Code-Org#1919: making attemptCleanup propagate makes
dispose() throw at the first failing step and the provider stays registered; reverted
byte-for-byte and re-passed here.

Port diff is identical to Zoo-Code-Org#1919 (+67/-8 across the two files), reviewed hunk-by-hunk: one
block inside dispose() plus the new test. Nothing foreign.

Local: core/webview + core/config + activate = 1087 passed; tsc 100 errors (= baseline);
eslint --prune-suppressions --max-warnings=0 clean on both files.
@easonLiangWorldedtech

Copy link
Copy Markdown
Contributor Author

Pushed 55aba85 (Security Boundaries) and 60546d0 (Lifecycle) — non-cumulative ports of #1919 59f4492 / 9e47e3e — ❌ Security Boundaries + ⚠️ Lifecycle Resource Cleanup

❌ Security Boundaries — confirmed, fixed

The generic settings loop is the problem, not the profile paths: it iterates Object.entries(message.updatedSettings) and calls provider.setValue(key, value) for every key, and since the per-view buffer landed that write also updates this view's local overlay - which getState() prefers over the shared values. Nothing validated the runtime shape, so the compile-time type was the only thing standing between a webview payload and apiConfiguration. (Worth noting for the record: apiConfiguration is not even a RooCodeSettings key, so the crafted payload the row describes is exactly the case the type cannot express.)

HOST_OWNED_SETTINGS = apiConfiguration, listApiConfigMeta, viewStates is now rejected at the top of the loop with its own log line. Those keys are produced by validated profile-management paths - ProviderSettingsManager resolves and validates a configuration before it can become active - and ClineProvider owns the per-view durable map, so the generic settings route must not carry them into either sink.

Test: "refuses to apply profile- and provider-owned keys supplied by the webview" - one payload with a legitimate setting plus all three host-owned keys; the ordinary setting still goes through provider.setValue, none of the three reach setValue or contextProxy.setValue, and the refusal is logged. Negative control: removing the guard fails the apiConfiguration assertion (the injected configuration is applied). Reverted byte-for-byte and re-passed.

⚠️ Lifecycle Resource Cleanup — confirmed, fixed

Exactly as described: dispose() set _disposed, ran a chain of awaited teardown steps, and removed the provider from activeInstances only on its last line. A rejecting mcpHub.unregisterClient() / skillsManager.dispose() / marketplaceManager.cleanup() skipped the unregistration, removeAllListeners() and McpServerManager.unregisterProvider(this), leaving a provider discoverable through getVisibleInstance / getAllInstances / getInstanceForView with its view and listeners attached - and _disposed meant nothing would ever retry. The tab-creation rollback added earlier disposes providers, so this is on a reachable path, not only on shutdown.

Each teardown step now runs through attemptCleanup, which records its own failure and lets the rest proceed; the unregistration is unconditional afterwards, and an incomplete teardown is reported as its own line (Disposal was incomplete (mcpHub: ...; marketplace manager: ...); the provider was unregistered anyway.).

Test: "unregisters a provider whose teardown step rejects" - mcpHub.unregisterClient rejects and marketplaceManager.cleanup throws; asserts dispose() does not crash, the provider is gone from getAllInstances() and no longer resolvable for its own view, and the incomplete-teardown line names both failures. Negative control: making attemptCleanup propagate makes dispose() throw at the first failing step and the provider stays registered. Reverted byte-for-byte and re-passed.

On the helper half of the resolution: disposeFailedTabCreation already clears the tracked ref between two independent try/catch blocks, so the ref clear is unconditional and the original creation error is what the caller sees; the incomplete-cleanup line is separate. No change needed there.

Local: core/webview + core/config + activate = 1087 passed; tsc --noEmit 100 errors (= baseline); eslint clean. Port diffs are identical to #1919 (+62 and +67/-8), reviewed hunk-by-hunk.

…eview nits

Port of the same defect found on the sibling unit (Zoo-Code-Org#1919): the branches are not
cumulative, so this unit needs its own copy.

deleteProviderProfileUnlocked rewrites five durable stores but compensated only four.
When the deleted profile was the global or view-local selection,
contextProxy.setProviderSettings(survivingSettings) replaces the shared provider keys; if a
later step failed (e.g. a sibling re-pin), the rollback restored the settings, the profile
list, the shared selection and this view's pin - but left the survivor's provider keys in
place, so the restored profile's name was served with another profile's provider and
credentials through getState(), while the success log still claimed a complete restore.
The snapshot + providerSettingsWriteLanded flag now restore those keys like the other four.

Test: 'restores the shared provider settings when a step after the rewrite fails' - the
failure is injected in rePinViewLocalStateForDeletedProfile, after the keys landed.
Negative control: removing the compensation block leaves apiProvider 'anthropic' where
'openrouter' must be; reverted byte-for-byte and re-passed.

Nits: mockRestore() on a bare vi.fn() leaves executeCommand with no implementation at all,
so restore the saved previousExecute in a finally instead; and the closing
`): Promise<void> {` of disposeFailedTabCreation / deleteProviderProfileUnlocked was one
tab deeper than its declaration.

Local: core/webview + core/config + activate = 1088 passed; tsc 100 errors (= baseline);
eslint --prune-suppressions --max-warnings=0 clean on all four files.
Review nit: the deletion test asserted that the ask-mode mapping still points at the
deleted id and that the id is absent from the store, but never drove the path the comment
described, so a regression in the mode-switch fallback would have passed. The test now
calls the public handleModeSwitch("ask") after the deletion and asserts the view still
selects the surviving profile.

Discrimination check: skipping the deleteProviderProfile call flips the scenario (the
mapping assertion and the post-switch selection both go red), so the assertions track the
deletion rather than the seed. Reverted byte-for-byte; 18/18 in this file, eslint clean.
Port of the same regression-evidence fix on the sibling unit (Zoo-Code-Org#1919 34d0907); the branches
are not cumulative.

getProfile now raises and preserves ProviderSettingsNotFoundError for id lookups too, but the
focused test only covered the name branch. The id branch is the one the mode mapping and
deleteProviderProfile hit - a stale mode -> id mapping has to surface as a prunable
not-found, not as a generic failure that gets re-wrapped.

Test seeds two profiles with known ids, calls getProfile({ id: 'missing-id' }) and asserts the
error type plus the id-specific message. Negative control: swapping the id branch to a plain
Error turns the typed assertion red; reverted byte-for-byte and re-passed.

Local: core/config suite 254 passed, eslint --prune-suppressions --max-warnings=0 clean.
…fails

Port of the same fix on the sibling unit (Zoo-Code-Org#1919 fe87309); the branches are not cumulative.

rePinViewLocalStateForDeletedProfile wrote all affected views through Promise.all and only ever
undid the FAILING sibling, so a rejection in one view left the views whose writes had landed
pinned to the surviving profile while the deletion itself was rolled back.

Every affected view is now snapshotted before any write starts, the writes are awaited with
Promise.allSettled, and on any rejection every affected view is restored (each restore awaited
on its own). The restore is unconditional because _saveViewLocalStateFromMutation fills the
in-memory buffer before the durable write settles, so even a rejected write leaves the view
re-pointed. The first rejection is rethrown so the caller's shared-store compensation still runs.

Test: 'rolls back the sibling views whose re-pin write succeeded when another sibling fails'.
Negative control: restoring only the last snapshot leaves the healthy sibling on 'keeper-profile'
(new test red, pre-existing single-sibling test green); reverted byte-for-byte.

Local: core/webview + core/config + activate = 1090 passed; tsc 100 (= baseline); eslint clean.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-coderabbit Waiting for CodeRabbit to approve the latest commit coderabbit-review-active Required CI passed; CodeRabbit review is active

Projects

None yet

Development

Successfully merging this pull request may close these issues.

VPS2 durable per-view state - independent-fix series (supersedes #34 + the 21-item upstream draft series)

2 participants