From f9cbbd7308c66646be31befbbc4516c5010b178d Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 1 Aug 2026 13:40:40 -0500 Subject: [PATCH 1/5] feat(provider): add initializing/failed states to provider init lifecycle Provider init previously only tracked a persisted Initialized bool, so the UI/CLI/MCP tool couldn't distinguish "in progress" or "crashed/failed" from "never started" while a provider's init command ran. Adds a cross-process tri-state (not_initialized/initializing/initialized/failed) derived from an advisory file lock held for the duration of Exec.Init, so a crashed process self-heals to "failed" instead of silently reverting. --- cmd/mcp/tools_provider.go | 2 + cmd/provider/configure_shared.go | 65 +++++++++++--- cmd/provider/list.go | 4 +- desktop/src/main/watcher.ts | 2 + .../components/provider/ProviderCard.svelte | 14 +-- .../components/provider/ProviderCard.test.ts | 36 +++++--- .../components/provider/ProviderSheet.svelte | 16 ++-- .../components/provider/ProviderSheet.test.ts | 4 +- .../components/provider/ProviderWizard.svelte | 9 +- .../src/renderer/src/lib/stores/providers.ts | 17 ---- desktop/src/renderer/src/lib/types/index.ts | 3 + pkg/config/config.go | 13 +++ pkg/provider/initlock.go | 68 +++++++++++++++ pkg/provider/initlock_test.go | 86 +++++++++++++++++++ pkg/workspace/provider.go | 19 ++++ 15 files changed, 290 insertions(+), 68 deletions(-) create mode 100644 pkg/provider/initlock.go create mode 100644 pkg/provider/initlock_test.go diff --git a/cmd/mcp/tools_provider.go b/cmd/mcp/tools_provider.go index d68c56dd2..363b533f2 100644 --- a/cmd/mcp/tools_provider.go +++ b/cmd/mcp/tools_provider.go @@ -16,6 +16,7 @@ type providerSummary struct { Name string `json:"name"` Version string `json:"version,omitempty"` Default bool `json:"default,omitempty"` + Status string `json:"status,omitempty"` } type providerListOutput struct { @@ -108,6 +109,7 @@ func handleProviderList(_ context.Context, g *flags.GlobalFlags) (providerListOu Name: entry.Config.Name, Version: entry.Config.Version, Default: entry.Config.Name == defaultProvider, + Status: string(entry.Status), }) } sort.Slice(summaries, func(i, j int) bool { diff --git a/cmd/provider/configure_shared.go b/cmd/provider/configure_shared.go index 14539f7c1..e90d943d9 100644 --- a/cmd/provider/configure_shared.go +++ b/cmd/provider/configure_shared.go @@ -164,7 +164,37 @@ func initProvider( provider *provider2.ProviderConfig, io2 initIO, ) error { - err := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ + lock, err := provider2.GetProviderInitLock(devsyConfig.DefaultContext, provider.Name) + if err != nil { + return fmt.Errorf("get init lock: %w", err) + } + locked, err := lock.TryLock() + if err != nil { + return fmt.Errorf("lock provider init: %w", err) + } + if !locked { + return fmt.Errorf("provider %q is already being initialized", provider.Name) + } + defer func() { _ = lock.Unlock() }() + + if devsyConfig.Current().Providers == nil { + devsyConfig.Current().Providers = map[string]*config.ProviderConfig{} + } + if devsyConfig.Current().Providers[provider.Name] == nil { + devsyConfig.Current().Providers[provider.Name] = &config.ProviderConfig{} + } + entry := devsyConfig.Current().Providers[provider.Name] + + // Persist immediately so a concurrent `provider list` sees "initializing" + // for the duration of Exec.Init rather than waiting for the caller's final + // save at the end of ConfigureProvider. + entry.InitAttempted = true + entry.InitError = "" + if err := config.SaveConfig(devsyConfig); err != nil { + return fmt.Errorf("save init state: %w", err) + } + + runErr := clientimplementation.RunCommandWithBinaries(clientimplementation.CommandOptions{ Ctx: ctx, Command: provider.Exec.Init, Context: devsyConfig.DefaultContext, @@ -173,15 +203,30 @@ func initProvider( Stdout: io2.stdout, Stderr: io2.stderr, }) - if err != nil { - return fmt.Errorf("init: %w", err) - } - if devsyConfig.Current().Providers == nil { - devsyConfig.Current().Providers = map[string]*config.ProviderConfig{} - } - if devsyConfig.Current().Providers[provider.Name] == nil { - devsyConfig.Current().Providers[provider.Name] = &config.ProviderConfig{} + if runErr != nil { + entry.InitError = truncateInitError(runErr.Error()) + // Best-effort: InitAttempted is already durably true from the save + // above, so ResolveInitState still reports "failed" once the deferred + // unlock releases the init lock even if this save fails; only the + // diagnostic InitError message would be lost. + if saveErr := config.SaveConfig(devsyConfig); saveErr != nil { + log.Warnf("save init failure state for provider %s: %v", provider.Name, saveErr) + } + return fmt.Errorf("init: %w", runErr) } - devsyConfig.Current().Providers[provider.Name].Initialized = true + + entry.Initialized = true return nil } + +// maxInitErrorLen bounds how much of a failed init command's error we persist +// to config.json, which (unlike interactive CLI/log output) may be synced, +// backed up, or included in support bundles. +const maxInitErrorLen = 500 + +func truncateInitError(msg string) string { + if len(msg) <= maxInitErrorLen { + return msg + } + return msg[:maxInitErrorLen] + "…" +} diff --git a/cmd/provider/list.go b/cmd/provider/list.go index 0ef443321..6635b303a 100644 --- a/cmd/provider/list.go +++ b/cmd/provider/list.go @@ -115,7 +115,7 @@ func (cmd *ListCmd) renderInstalledPlain( entry.Config.Name, entry.Config.Version, strconv.FormatBool(devsyConfig.Current().DefaultProvider == entry.Config.Name), - strconv.FormatBool(entry.State != nil && entry.State.Initialized), + string(entry.Status), entry.Config.Description, }) } @@ -127,7 +127,7 @@ func (cmd *ListCmd) renderInstalledPlain( "Name", "Version", "Default", - "Initialized", + "Status", "Description", }, tableEntries) diff --git a/desktop/src/main/watcher.ts b/desktop/src/main/watcher.ts index b698f9db7..107378492 100644 --- a/desktop/src/main/watcher.ts +++ b/desktop/src/main/watcher.ts @@ -30,6 +30,7 @@ export interface ProviderEntry { optionGroups?: unknown[] } state?: { initialized?: boolean; singleMachine?: boolean } + status?: string default?: boolean } @@ -43,6 +44,7 @@ export function parseProviderEntries(raw: Record) { options: entry.config.options ?? {}, optionGroups: entry.config.optionGroups ?? [], isDefault: entry.default ?? false, + status: entry.status ?? "not_initialized", state: { initialized: entry.state?.initialized ?? false, singleMachine: entry.state?.singleMachine ?? false, diff --git a/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte b/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte index aa7a06572..a67a448da 100644 --- a/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte +++ b/desktop/src/renderer/src/lib/components/provider/ProviderCard.svelte @@ -1,15 +1,12 @@