Repository navigation
Conversation
gilgtm
force-pushed
the
gil/nexus-4156-workspace-lead
branch
from
November 6, 2026 16:02
417648c to
0c986b0
Compare
baxen
pushed a commit
that referenced
this pull request
Nov 8, 2026
…te (#2819) Deleting a Solo workspace (**Actions → Delete workspace**) removed it from the groups record but left its settings record live on the relay. That record holds the workspace's instructions, folders and agents. Once the workspace was gone, nothing could reach the record, but nothing deleted it either. This PR tombstones the record after the groups write. **What you see in the app:** nothing changes. The sidebar, the delete flow and its timing are the same as on `main`. Only the relay record changes: `me-section-<sha256(workspace id)>` becomes a tombstone (`deleted: true`). It has no instructions, folders, agents or teams, and its name is the neutral "Deleted workspace". ## Before / after Left: `main` at `067db608f`. Right: this change, recorded at `d61d1dbb7`, a pre-squash commit made before the review fixes. The recorded flow is a delete whose groups write confirms, with no other save in flight, and it takes the same path on this head. The review fixes only change a retried delete and overlapping kit saves (see below). The recording uses Chromium at 1280 px and fixture data only. No app surface shows a deleted workspace's settings record. So the dashed magenta **TEST HARNESS · NOT PART OF BUZZ** panel in the bottom right reads the record from the fixture relay and decrypts it. That panel is a throwaway hook in the recording worktree and is not part of this PR. **What to notice:** the app looks the same on both sides, so watch the harness panel. - At step 5, after Delete workspace, `main` still reads Launch's record as **LIVE**. It is still named "Launch chats" and still has the instructions and `~/Development/launch-site`. - With the fix, the same record is a **DELETED** tombstone named "Deleted workspace", with instructions `""` and folders `[]`. - Step 6 creates Launch again. Its settings open blank on both sides, because a new workspace gets a new id. https://github.com/user-attachments/assets/22b860fb-a0cf-462e-b2b9-52ccfa89066d  *Before, step 5 (`main`): the sidebar no longer lists Launch. The harness panel shows its record still **LIVE**, still named "Launch chats", with the instructions and folder.* ![After, step 5 with this change: the same sidebar, but the record is now a DELETED tombstone named "Deleted workspace", with instructions "" and folders [].](https://github.com/user-attachments/assets/7a6f4925-45d5-4310-b65a-b0a430f56df6) *After, step 5 (this change): the sidebar is the same. The same record is now **DELETED (tombstone)**. Its name no longer mentions Launch, and its instructions and folders are empty.* ## What changes and why The fix is in `removeSection` in `src/features/relay/solo-preferences.ts` (+38/−14). The channel kit's save guard in `src/features/channel-templates/capability.ts` (+8/−5) changes too, so the tombstone doesn't block other settings saves. - **When the groups write is confirmed,** `removeSection` queues a second write through the store's existing `serialize` queue. That write looks up the workspace's template with `templateEntry(…, await projectTemplateId(id))`. If a live template exists, it saves it back with the kit's existing `save(value, expected, deleted = true)`. - **The tombstone is emptied.** It empties the lineup with `emptyLineup()` (instructions, folders, agents and teams), so it doesn't republish the encrypted instructions. - **The tombstone is renamed.** It replaces the name `"<workspace> chats"` with the constant "Deleted workspace", so the user's workspace name isn't kept either. The TS parser and native `kit.rs` admission both accept any non-empty name of up to 120 characters. Every reader skips a deleted template, so nothing displays it. - **The delete doesn't wait for it.** `removeSection` still resolves once the groups write lands, so the sidebar and the focus move don't wait for the second write. Mutations queued after that point wait behind the tombstone. - **It's best effort.** A failed tombstone only logs `console.warn`. It never rejects `removeSection` or undoes the groups write, and the record stays live but unreachable, as on `main`. A failed groups write queues no tombstone. There's no write when no live template exists. - **A retried delete still cleans up.** A groups write can land and still reject: exact relay confirmation fails and the outbox delivers later. The sidebar keeps the workspace, so the user retries, and the retry finds it already gone from the groups record. `removeSection` used to return early there, so the record stayed live. It now queues the same cleanup whenever the workspace is absent. The cleanup writes nothing without a live template. - **The cleanup signal is bound first.** The queued op's first statement is `AbortSignal.any([lifetime, generation.signal])`, ahead of the id-hash `await`. That ties the tombstone to the generation `serialize` admitted, so a cache clear (`clearCache()` → `soloPreferences.clear()`) during the hash still aborts it. Bound after the `await`, the tombstone would take the new generation's signal and write after the clear. An account switch isn't the case this guards: it ends the session, and `lifetime` already aborts the tombstone. - **No import cycle.** `relay/solo-preferences.ts` now imports `solo/workspace.ts`, but `workspace.ts` never reaches `solo-preferences` or `session` at runtime. `docs/solo/README.md` now notes the best-effort tombstone, what a failed tombstone leaves behind and the retried delete. `docs/buzz-help.md` doesn't describe what a delete keeps, so it's unchanged. ## Overlap with #2788 #2788 (draft) changes the same `removeSection` hunk. It makes the same split, `const groups = …; const saved = await save(groups, entry, active); … return saved;`. Then it queues its own best-effort cleanup of the workspace's Lead with `void serialize(…)`. This PR uses the same shape on purpose. Whichever PR lands second gets only a small conflict: - **Imports:** keep both. - This PR adds `emptyLineup` to the `../channel-templates/model` import, and imports `projectTemplateId` and `templateEntry` from `../solo/workspace`. - #2788 adds its Leads names and `isTransientFailure`. - **`removeSection`:** keep this PR's removal of the early return and its conditional `const saved = value.groups.some(…) ? await save(groups, entry, active) : project(value);`. Keep both `void serialize(…)` blocks between `const saved = …` and `return saved;`, each with its own `.catch` and warning. Their order doesn't matter: they write different records, and each binds its own cleanup signal first. Without the early return, #2788's Lead cleanup also runs for a workspace that is already absent. Check that it writes nothing when there's no Lead. - **Tests:** keep both sets in `solo-preferences.test.ts`. ## The channel kit's save guard is per record The kit used to have one `saving` flag for the whole session (`src/features/channel-templates/capability.ts`). Workspace settings (`WorkspaceSettings.tsx`) and New workspace (`NewWorkspace.tsx`) save defaults through the kit directly, outside the Solo queue. So saving another workspace's defaults while the tombstone was pending failed with "Recipe saving is unavailable or already in progress". kalvinnchau reproduced this in Chromium and WebKit. - **One save per record.** The guard is now a set keyed by the record's coordinate (its `d` tag). Saves of other records proceed. A second save of the same record still fails fast with the same message. Every check a save makes is already per record: the head read, the unresolved-save guard and the `created_at` check. - **The trailing refresh doesn't join an older read.** Once saves overlap, one save's trailing refresh could join a catalog read that another save began before this one was confirmed. The snapshot would then miss the save until the next refresh, and Solo drafts compare their defaults revision against that snapshot. `kit.save` now ends with `refresh({ after: true })`. That only adds a read when another catalog read is already in flight. - **What can still collide:** two saves of the same record. On this device, a deleted workspace's settings can't be opened once it's gone from the sidebar. - **Queue order.** A mutation queued *during* the groups write runs before the tombstone. "Later mutations wait behind it" holds for mutations queued after `removeSection` resolves. `void serialize` stays, for three reasons: - Awaiting `kit.save` inside the delete would double the delete's latency, and a slow signer or confirm would hold the delete open. - `await serialize(…)` from inside the op would deadlock on its own queue. - `void serialize` matches #2788's Leads cleanup. ## Follow-up (pre-existing, not in this PR) Take a second device that hasn't received the groups update. Its sidebar still lists the deleted workspace. If someone opens that workspace's Workspace settings there and saves, a live template is published over the tombstone. The problem predates this change: on `main`, that save overwrites a record that was never deleted. A fence would belong in the project-defaults save path, which is outside this change. ## Verification Checked on this head (`86feaaa30`): - **Vitest, the two touched files only:** all 19 tests in `src/features/relay/solo-preferences.test.ts` and all 35 in `src/features/channel-templates/capability.test.ts` pass. - The tombstone tests cover: - the write order: groups, then template; - no write when no live template exists; - an already-deleted template left alone; - a failed groups write, which queues nothing; - a failed tombstone, after which `removeSection` still resolves; - the emptied, renamed payload. - **Held-prepare test:** `removeSection` resolves while the tombstone is held, and a later `createSection` stays unsettled behind it. The writes land groups → template → groups. The test fails in two wrong versions: - with the tombstone unqueued, as `void (async () => …)()`: `createSection` settles while the tombstone is held; - with the tombstone awaited inside the mutation: `removeSection` never resolves. - **Fence test:** it holds `crypto.subtle.digest`, clears the preferences, then releases. It fails if the cleanup signal is bound after the hash `await`. - **Settings save during cleanup (review finding 1):** the tombstone's preparation is held. Meanwhile another workspace's settings are saved through `session.channelKit.save`, as Workspace settings does. That save succeeds and the snapshot shows the edit. On release, the writes land groups → settings → tombstone. With the session-wide flag restored, the save fails with "Recipe saving is unavailable or already in progress". - **Retried delete (review finding 2):** the fixture hides the groups write from the strong by-id confirmation reads. So the first `removeSection` rejects with "awaiting exact relay confirmation" after the write landed, and only the groups record is written. Then the retry writes the tombstone. Without the fix, the retry writes nothing. - **Kit tests:** - With one record's save held, another record saves, and a second save of the held record is refused. This fails with the session-wide flag, and also with no guard at all. - A save reads itself back after a catalog read that began before its confirmation. It fails with a plain `refresh()`. - **Mutation checks:** each of these wrong versions fails at least one test: - unqueued; - awaited inside the mutation; - the cleanup signal bound late; - the session-wide flag; - no guard; - a plain trailing `refresh()`; - the early return for an absent workspace. - **Earlier mutation checks:** these wrong versions failed on earlier heads. They weren't rerun here because their code is unchanged: - queued before the groups save; - `deleted: false`; - the name kept; - `.catch` removed. - **Static checks:** `tsc --noEmit` passes. Biome passes on the four changed source files, and `git diff --check` is clean. The pre-commit hooks also passed: literal-names, icons, biome, sadscan and git-lfs. Checked on earlier heads only: - The 16 affected test files (521 tests), `pnpm lint` and `pnpm design:check` passed on the pre-squash branch, before the review fixes. - **Browser flow:** this ran in the throwaway recording harness, which isn't in this PR. The flow creates Launch, saves instructions and a folder, deletes the workspace, reads the record, then creates Launch again. Against `d61d1dbb7`, it passed in Chromium (25.2 s) and WebKit (27.0 s). - **Red checks for the flow:** - against `main`, it fails at the tombstone assertion, because no tombstone is written; - against the first draft of the fix, which kept the workspace name, it fails at the name assertion. - **No browser journeys added or removed.** The ordering, guard and failure contracts belong in the Vitest tests. The browser flow above only produced the recording. - **Agent review:** two agent reviews ran before kalvinnchau's review. Their findings were applied, with two exceptions described above: `void serialize` was kept, and the second-device fence is left as a follow-up. No new agent review ran on the review fixes. Not checked: - **Other kit consumers' tests:** `WorkspaceSettings`, `NewWorkspace` and session tests weren't run locally for the kit change. CI covers them. - **kalvinnchau's app repro:** it holds tombstone preparation while saving another workspace's defaults. It wasn't rerun in a browser on this head. - **The desktop app, a real relay and the native signer.** - **Human test:** not done yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
gilgtm
force-pushed
the
gil/nexus-4156-workspace-lead
branch
from
November 10, 2026 03:00
0c986b0 to
391ac3f
Compare
gilgtm
force-pushed
the
gil/nexus-4156-workspace-lead
branch
from
November 10, 2026 05:31
391ac3f to
6d47451
Compare
Workspaces select from their title or blank row space and open their optional Lead task, or a task composer when they have no Lead. Task cards share workspace chat styling, task creation is exposed on hover, and a workspace header too narrow for both of its controls keeps only its actions menu, which also offers New task at every width. Leads live in their own private record, load with Groups and are pruned against fresh Groups. A failed read affects only Lead selection; nothing writes Leads after a failed read. Native recipes admit optional Leads. A region's lead draft opens in its workspace with `lead`. Its start records the lead with how the chat started, only while the draft still targets that workspace, and the source saves it. Any running registered region source can save its own Solo task as a workspace Lead. The Lead must still be the owner's own Solo channel in that source's region. Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Gil Birman <gil@squareup.com>
In New chat, `#` lists the workspaces the composer's workspace control offers above channels. Choosing one removes the `#query`, sets the control and leaves no text or chip; Send files the chat as before. Only host code can make the workspace edit, so a plugin's look-alike row is dropped. Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Gil Birman <gil@squareup.com>
A Solo region can offer an optional `workspace` adapter for its chats. The composer's workspace control then shows the region's filing and files through it instead of hiding. Until the chat has a message (its verified, complete history holds only membership changes), `#` offers the same workspaces, and choosing one files the chat at once. While a choice saves, the control stays focusable and ignores activation (aria-disabled), so choosing by pointer or keyboard keeps focus on it. Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Gil Birman <gil@squareup.com>
gilgtm
force-pushed
the
gil/nexus-4156-workspace-lead
branch
from
November 10, 2026 06:17
6d47451 to
fa06b6d
Compare
gilgtm
marked this pull request as ready for review
November 10, 2026 06:37
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Restores workspace selection and adds optional workspace Leads (
bc4996eb3). It also lets you choose and show a chat's workspace (96a096a50,fa06b6d8f).openDraft(..., { lead: true }). The draft start records the Lead only while the draft still targets that workspace; the source saves it withsetWorkspaceLead.Compared with the previous version of this PR
Rebased onto main.
bc4996eb3also restores the workspace header's blank-space target and its narrow-width rule. On the shared section heading, the strips above and below the label and the trailing space did not select the workspace, and a narrow header kept New task.tests/browser/solo-rename.spec.mjscaught it on hosted CI.Choose and show a chat's workspace (
96a096a50,fa06b6d8f)#in New chat:#lists the workspaces the composer's workspace control offers, above channels. Choosing one removes the#queryand sets the control, leaving no text or chip; Send files the chat as before. Only host code can make the workspace edit, so a plugin row that looks like one is dropped.workspaceadapter for its chats. The composer's workspace control then shows the region's filing and files through it, instead of hiding. Until the chat has a message,#offers the same workspaces and files the chat at once.aria-disabled), so a pointer or keyboard choice keeps focus on it.Test changes for these two commits
Removed by this PR's diff (0)
Added by this PR's diff (10)
src/bundled/channels/ChannelCompletion.test.tsx: lets workspace rows take their place within the row limitsrc/bundled/channels/ChannelCompletion.test.tsx: lists a new chat's workspaces first, by the channel name rulesrc/bundled/channels/ChannelCompletion.test.tsx: offers no workspace rows outside a new chatsrc/features/conversation/completion.test.ts: keeps a host workspace edit and drops a provider's look-alikesrc/features/solo/SoloDraft.test.tsx:#chooses New chat's workspace without leaving text, and Send files the chat theresrc/features/solo/WorkspaceControl.test.tsx: keeps focus on the control while a keyboard choice saves, and ignores it until thensrc/features/solo/WorkspaceControl.test.tsx: keeps hiding a region chat whose region offers no workspacesrc/features/solo/WorkspaceControl.test.tsx: shows a region chat's workspace and files it through its regionsrc/features/solo/chat-workspace.test.ts: calls a chat new only while its verified, complete history has no messagesrc/features/solo/chat-workspace.test.ts: never calls a chat new before its history is knownbuzz-review-completed
Testing
Local runs on macOS, on the stack rebased onto main
9596cd3db:adbdf424echain only intests/browser/solo-rename.spec.mjs.02f9c0277makes the new plugin-owned lifecycle test wait for the chat's row before filing it, after the hosted flake check caught it racing the roster load. With that fix, the fullsolo-rename.spec.mjspassed 180/180 at02f9c0277(10 repeats, Chromium and WebKit) and 120/120 atc99d0ce51(5 repeats).6d474514c:pnpm check(Biome, TypeScript and the design checks) and the icon check passed. Vitest for the files the flake check selects for this PR: 817 passed.6d474514c:cargo fmt --checkandcargo clippy -p Buzz --all-targets -- -D warningspassed.relay::kittests at the stack tip: 9 passed.cargo-deny checkpassed at the stack tip.adbdf424e: full Vitest, 20556 passed and 23 skipped (includesbrowser-host/channel-kit.test.mjs).tests/browser/solo-rename.spec.mjs, repeated 5 times, passed 120/120. The 34 other specs that the flake check relates to the stack, run once in each engine: 343 passed, 3 skipped by their own conditions. The full browser suite was not run locally.049a9b57e(now1fbd49c49): 53 passed. Targeted New task, workspace New task and Solo Workspaces tests at pin797040559, before its header fix: 4 passed.Native testing: Gil ran the native desktop app on this stack at
adbdf424e, with the matching Nexus plugin build. Gil confirmed that these native-only paths passed:!folder listing through the Taurirepositories_folder_listcommand (NEXUS-4029: Offer folder chips in the composer #2789);/SKILL.md listing throughlocal_skills_list(NEXUS-4033: Offer session skills in the core task composer #2790);--add-dirfolder grant (NEXUS-4029: Offer folder chips in the composer #2789);Agent testing did not reach these paths: the agent ran no native app, no real relay and no real agent.
Known gaps
Hosted or local failures that this stack does not cause:
tests/browser/typing.spec.mjs:290("a growing channel/thread composer lifts history with it at the bottom") intermittently fails with React error Add complete reaction toggles to the message menu #185 on main too. Locally it failed 12 of 12 runs at9596cd3dband 9 of 12 at a later main commit.cargo test -p Buzzcrashes the library test binary (SIGSEGV), and somehost_commanddeadline tests fail. Both happen the same way at base9596cd3db, before this stack. Hosted Linux CI is the reference for these.Test changes
Titles are compared only in the test files each range changes. Tests that moved or were renamed are matched by title.
Removed by this PR's diff (2)
src/app/shell/PrimarySidebar.test.tsx: does not navigate to a replaced session after project creation from %s completessrc/features/channel-navigation/SoloInventory.test.tsx: shows and selects the ordinary New chat draft row for %sAdded by this PR's diff (23)
browser-host/channel-kit.test.mjs: workspace Leads admission: $namesrc/app/shell/PrimarySidebar.test.tsx: keeps an ordinary chat editor and pending save through task-source recoverysrc/app/shell/PrimarySidebar.test.tsx: keeps ordinary Buzz creation controls until a workspace source opts in and restores them when it is removedsrc/app/shell/PrimarySidebar.test.tsx: opens an ordinary workspace draft from %s and ignores later completion after session replacementsrc/app/shell/PrimarySidebar.test.tsx: selects the created workspace through its source from %s and retains selection failures for retrysrc/bundled/channels/SoloWorkspaces.test.tsx: selects a Lead workspace only once its separate Leads record is read, showing the owner's failure with Retrysrc/bundled/channels/SoloWorkspaces.test.tsx: shares a workspace frame while keeping lead selection, disclosure and chat creation separatesrc/features/channel-navigation/SoloInventory.test.tsx: selects ordinary drafts without marking a region draft as New chat (%s)src/features/channel-navigation/routes.test.ts: keeps a region lead draft only with its region and workspacesrc/features/channel-navigation/solo-navigation-draft.test.ts: opens a workspace's lead draft only in that workspace and returns the lead with its startsrc/features/channel-navigation/solo-workspaces.test.ts: keeps a Lead to the owner's own Solo channel in the source's regionsrc/features/channel-navigation/solo-workspaces.test.ts: lets a running registered source without a task contract set its own Solo task as Leadsrc/features/channel-navigation/solo-workspaces.test.ts: refuses a Lead from a registered source that is not running or is unregisteredsrc/features/channel-navigation/solo-workspaces.test.ts: resolves archived lead history and replaces only an authoritatively absent lead, fencing source ownership and bindingsrc/features/relay/solo-preferences.test.ts: isolates an unreadable Leads record from workspaces and the catalog, and never writes over itsrc/features/relay/solo-preferences.test.ts: keeps Leads in their own record, fencing conflicting replacements and leaving Groups at main's fieldssrc/features/relay/solo-preferences.test.ts: keeps unknown Leads fields, hides and prunes orphans, and best-effort removes a deleted workspace's Leadsrc/features/relay/solo-preferences.test.ts: loads Leads beside Groups, and session recovery retries a transient failuresrc/features/relay/solo-preferences.test.ts: never lets a refresh begun before a confirmed Lead write show the older Leadssrc/features/relay/solo-preferences.test.ts: prunes Leads against Groups read after them, so another device's Lead survivessrc/features/solo/SoloDraft.test.tsx: a region lead draft starts its workspace's lead only while it still targets that workspacetests/browser/solo-rename.spec.mjs: task-source recovery retains an ordinary chat editor, caret and unsaved nametests/browser/solo-rename.spec.mjs: workspace blank space selects independently of its controls ${variant.name}In the previous version of this PR, not carried over (1)
src/features/channel-navigation/SoloInventory.test.tsx: selects ordinary drafts without marking a task draft as New chat (%s)