Skip to content

fix(desktop): resolve repos_dir symlink at boot before agent restore#1231

Merged
wpfleger96 merged 6 commits into
mainfrom
duncan/repos-dir-boot-persist
Jun 24, 2026
Merged

fix(desktop): resolve repos_dir symlink at boot before agent restore#1231
wpfleger96 merged 6 commits into
mainfrom
duncan/repos-dir-boot-persist

Conversation

@wpfleger96

Copy link
Copy Markdown
Collaborator

Follow-up to #1194, which added the configurable repos_dir feature. That PR left a startup-ordering race: an agent could clone into ~/.buzz/REPOS before it was symlinked to the user's external repos directory, permanently blocking the symlink.

The bug

repos_dir lived only in frontend localStorage. The Rust backend learned it solely through apply_workspace, which runs after React mounts. But restore_managed_agents_on_launch is spawned earlier in the setup hook:

  1. ensure_nest() provisions REPOS as an empty real directory at boot.
  2. restore_managed_agents_on_launch spawns and an agent clones into that empty real REPOS.
  3. The frontend mounts and applies the workspace.
  4. ensure_repos_symlink now sees a non-empty real directory and refuses — so the symlink is never created, permanently.

The fix

Make the backend authoritative for repos_dir:

  • Persist repos_dir to a ~/.buzz/.repos-dir nest dotfile (mirrors the existing .nest-agents-version dotfile precedent). apply_workspace writes it.
  • Resolve the symlink at boot immediately after ensure_nest(), before the agent-restore spawn — so REPOS is a symlink before any agent can clone.

Fail-closed gate

When a configured repos_dir is present and the boot symlink resolve fails — e.g. a transiently unavailable external target (unmounted volume, NAS lag, FileVault unlock delay) — agent restore is skipped for that launch rather than letting an agent clone into the wrong REPOS. A skipped restore is recoverable on the next launch; a wrong-place clone is permanent corruption. When no repos_dir is configured, the real-directory default is correct and restore proceeds normally.

Known boundaries

  • Multi-workspace stale-dotfile window: the dotfile holds the last-applied workspace's value. If the app is killed between switching workspaces and the frontend apply_workspace completing, boot uses the prior workspace's value. Self-corrects on the first frontend apply.
  • repos-dir-error toast gap: the fail-closed skip-restore branch produces a real boot-time error with no UI signal, because the toast listener registers asynchronously after mount. This applies to the skip-restore branch, not just the symlink Err. Separable and tracked, not addressed here.

Related: #1194

npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 6 commits June 24, 2026 11:39
repos_dir lived only in frontend localStorage; the backend learned it
solely via apply_workspace, which runs after React mounts — later than
the async restore_managed_agents_on_launch. An agent could clone into
the empty real REPOS dir before the symlink was ever applied, and once
REPOS is non-empty ensure_repos_symlink correctly refuses forever,
silently breaking a configured repos_dir on every boot.

Persist repos_dir to a ~/.buzz/.repos-dir dotfile on apply_workspace and
read it in the setup hook to resolve the REPOS symlink before agents are
restored, making the backend authoritative at boot. The frontend path
stays as the live-edit bridge.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The boot symlink resolve logged-and-continued on Err. If a configured
repos_dir's external target was transiently unavailable at launch (NAS
not mounted, FileVault unlock lag), REPOS stayed the empty real dir
ensure_nest provisioned and the spawned agent restore could clone into
it — permanently re-triggering the race the persisted dotfile exists to
close.

Gate the restore spawn on the symlink outcome: resolve_repos_at_boot
fails closed, skipping restore for the launch only when a repos_dir was
configured AND its symlink could not be resolved. The no-repos_dir path
(real in-nest default) and the happy path are unchanged. A skipped
restore recovers on the next reachable boot; a misplaced clone does not.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A bad persisted repos_dir bricked the app: apply_workspace hard-rejected
on boot, parking the FE forever on the loading gate with no way to edit the
value. The reject only ever fired on boot (apply_workspace's sole caller is
useWorkspaceInit), so it guarded a dialog-apply path that never existed.

Make a bad repos_dir non-fatal on apply, mirroring the existing symlink-
failure branch: relay/keys still apply (the relay is the active workspace's
own choice, orthogonal to the filesystem repos dir), the bad value is NOT
persisted (clearing .repos-dir so the next boot resolves cleanly and agent
restore proceeds — persisting it would silently skip restore every boot), the
REPOS symlink is skipped, and a repos-dir-error toast surfaces the reason. The
app boots into a working state where the user fixes the value in settings.

Add a validate_repos_dir command so Add/Edit dialogs block Save on a bad path
(the front door); the backend fallback is the safety net for a path that goes
bad after save. Add a positive boot log line so a healthy resolve is
observable, not just the failure branches.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The bad-repos_dir recovery fix hinged on a persisted-state guarantee
that no test asserted: a bad path must CLEAR .repos-dir so the next
boot's should_restore_agents(false, _) restores agents. apply_workspace
takes State/AppHandle and has no unit-test seam, so extract the persist
decision into a pure effective_repos_dir(nest, candidate) helper and
assert all three cases drive the correct dotfile state (bad -> cleared,
valid -> raw trimmed path, empty/none -> cleared). Resolving the nest is
now soft so relay/keys apply unconditionally even when $HOME is unset.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…est helper

The conn_manager.register() signature has required a grace_limit: u8
argument since #1226. #1227's register_presence_sub helper was added
with only 5 args, breaking the buzz-relay test build (E0061). Matches
the sibling register_conn helper's value of 3.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ir binding

The validateReposDir binding pushed tauri.ts one line past its existing
1198 override. No clean no-op trim exists — the file is uniformly
formatted with no redundant blank lines — so the override moves to 1199
with the reason updated to cover the new binding.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 force-pushed the duncan/repos-dir-boot-persist branch from ac4ac2f to cdcd393 Compare June 24, 2026 15:48
@wpfleger96 wpfleger96 merged commit 86d7748 into main Jun 24, 2026
28 checks passed
@wpfleger96 wpfleger96 deleted the duncan/repos-dir-boot-persist branch June 24, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant