Fix provider-key rebuild lease handling

This commit is contained in:
SivanCola committed 2026-07-04 21:58:22 +08:00
1 parent 250b9f6f91
commit 39270df554
6 files changed
+237 -48

No files matched your search

+11 -7
View File
@@ -7916,6 +7916,8 @@ const onboardingKeyEnv = "DEEPSEEK_API_KEY"
// billing.FetchWithClient surfaces 401/403 for a bad key.
const onboardingBalanceURL = "https://api.deepseek.com/user/balance"
var connectKeyBalanceFetch = billing.FetchWithClient
// NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation
// dialog that replaces web-style confirm() for destructive or important actions.
type NativeConfirmRequest struct {
@@ -7994,20 +7996,22 @@ func (a *App) ConnectKey(apiKey string) (string, error) {
}
ctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)
defer cancel()
if _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {
if _, err := connectKeyBalanceFetch(ctx, nil, onboardingBalanceURL, apiKey); err != nil {
return "", fmt.Errorf("validate: %w", err)
}
warning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)
if err != nil {
return "", fmt.Errorf("save: %w", err)
}
if err := a.rebuild(); err != nil {
// Key is persisted; surface the failure but let the next rebuild load it.
a.mu.Lock()
if tab := a.activeTabLocked(); tab != nil {
tab.StartupErr = err.Error()
if err := a.rebuildSetting("provider key"); err != nil {
// Key is persisted. Keep the current session usable and let a later
// rebuild pick up the credential.
slog.Warn("desktop: connect key rebuild failed", "err", err)
if strings.TrimSpace(warning) == "" {
warning = err.Error()
} else {
warning = warning + "\n" + err.Error()
}
a.mu.Unlock()
}
return warning, nil
}
+149
View File
@@ -20,6 +20,7 @@ import (
"time"
"reasonix/internal/agent"
"reasonix/internal/billing"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/control"
@@ -1705,6 +1706,78 @@ api_key_env = "PROXY_DEEPSEEK_KEY"
}
}
func TestSetProviderKeyLeaseHeldKeepsCurrentController(t *testing.T) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
cfg := config.Default()
cfg.DefaultModel = "old/old-model"
cfg.Desktop.ProviderAccess = []string{"old"}
cfg.Providers = []config.ProviderEntry{
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
{Name: "longcat", Kind: "openai", BaseURL: "https://longcat.example/v1", Model: "longcat-chat", APIKeyEnv: "LONGCAT_API_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
sessionPath := filepath.Join(dir, "externally-leased-provider-key.jsonl")
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
t.Fatalf("write placeholder session: %v", err)
}
externalLease, err := agent.TryAcquireSessionLease(sessionPath)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
defer externalLease.Release()
oldSession := agent.NewSession("old system prompt")
oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
defer oldCtrl.Close()
app := NewApp()
app.ctx = context.Background()
tab := &WorkspaceTab{
ID: "tab_provider",
Scope: "global",
SessionPath: sessionPath,
Ready: true,
model: "old/old-model",
Ctrl: oldCtrl,
sink: &tabEventSink{tabID: "tab_provider", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
_, err = app.SetProviderKey("LONGCAT_API_KEY", "sk-longcat")
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
t.Fatalf("SetProviderKey err = %v, want ErrSessionLeaseHeld", err)
}
if strings.Contains(err.Error(), sessionPath) || strings.Contains(err.Error(), "held by") {
t.Fatalf("SetProviderKey surfaced raw lease details: %v", err)
}
if tab.Ctrl != oldCtrl {
t.Fatalf("tab controller changed after failed provider-key rebuild")
}
if tab.StartupErr != "" {
t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
}
if got := tab.Ctrl.History(); len(got) < 2 || got[1].Content != "hello" {
t.Fatalf("history after failed provider-key rebuild = %+v", got)
}
if access := providerAccessSet(config.LoadForEditWithoutCredentials(config.UserConfigPath()).Desktop.ProviderAccess); !access["longcat"] {
t.Fatalf("provider_access should still persist longcat after key save")
}
}
func TestAddOfficialProviderAccessUsesDesktopLanguagePricing(t *testing.T) {
isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
@@ -3059,6 +3132,82 @@ func TestConnectKeyRejectsBackgroundJobsBeforeSavingKey(t *testing.T) {
}
}
func TestConnectKeyRebuildLeaseHeldKeepsCurrentController(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv(onboardingKeyEnv, "")
os.Unsetenv(onboardingKeyEnv)
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
oldFetch := connectKeyBalanceFetch
connectKeyBalanceFetch = func(context.Context, *http.Client, string, string) (*billing.Balance, error) {
return &billing.Balance{Available: true}, nil
}
t.Cleanup(func() { connectKeyBalanceFetch = oldFetch })
cfg := config.Default()
cfg.DefaultModel = "old/old-model"
cfg.Desktop.ProviderAccess = []string{"old"}
cfg.Providers = []config.ProviderEntry{
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save config: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session dir: %v", err)
}
sessionPath := filepath.Join(dir, "externally-leased-connect-key.jsonl")
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
t.Fatalf("write placeholder session: %v", err)
}
externalLease, err := agent.TryAcquireSessionLease(sessionPath)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
defer externalLease.Release()
oldSession := agent.NewSession("old system prompt")
oldSession.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
oldExec := agent.New(nil, nil, oldSession, agent.Options{}, event.Discard)
oldCtrl := control.New(control.Options{Executor: oldExec, SessionDir: dir, SessionPath: sessionPath, Label: "old", Sink: event.Discard})
defer oldCtrl.Close()
app := NewApp()
app.ctx = context.Background()
tab := &WorkspaceTab{
ID: "tab_connect",
Scope: "global",
SessionPath: sessionPath,
Ready: true,
model: "old/old-model",
Ctrl: oldCtrl,
sink: &tabEventSink{tabID: "tab_connect", app: app},
disabledMCP: map[string]ServerView{},
}
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
app.tabOrder = []string{tab.ID}
app.activeTabID = tab.ID
warning, err := app.ConnectKey("sk-test")
if err != nil {
t.Fatalf("ConnectKey: %v", err)
}
if !strings.Contains(warning, "another Reasonix window") {
t.Fatalf("ConnectKey warning = %q, want user-facing lease warning", warning)
}
if tab.Ctrl != oldCtrl {
t.Fatalf("tab controller changed after failed connect-key rebuild")
}
if tab.StartupErr != "" {
t.Fatalf("tab startup error = %q, want unchanged current session", tab.StartupErr)
}
if !config.CredentialStored(onboardingKeyEnv) {
t.Fatal("onboarding key should be persisted even when hot rebuild is deferred")
}
}
func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {
isolateDesktopUserDirs(t)
@@ -5665,10 +5665,17 @@ function ProviderEditor({
const save = async () => {
if (extraBodyInvalid) return;
setFetchStatus(null);
setFetchFallback(null);
const ms = parseProviderListInput(models);
const vms = parseProviderListInput(visionModels).filter((model) => ms.includes(model));
const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
try {
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
} catch (e) {
setFetchFallback(String((e as Error)?.message ?? e));
return;
}
onSave({
name: name.trim(),
builtIn: initial?.builtIn ?? false,
+1 -1
View File
@@ -91,7 +91,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
app.ToolResultForTab(tabId, item.id).then((d) => {
if (!cancelled && d) setFullData(d);
}).catch(() => {}),
);
).catch(() => {});
return () => { cancelled = true; };
}, [open, item.id, item.dataArchived, fullData, tabId]);
@@ -193,7 +193,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
notifyChannels: false,
createdAt: Date.now(),
});
});
}).catch(() => {});
}
}, [open, startNew]);
@@ -210,18 +210,22 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
);
const handleAdd = useCallback(async () => {
const id = await heartbeatGenerateID();
setEditing({
id,
title: "",
prompt: "",
interval: "30m",
enabled: true,
approvalMode: "yolo",
newConversationEachRun: false,
notifyChannels: false,
createdAt: Date.now(),
});
try {
const id = await heartbeatGenerateID();
setEditing({
id,
title: "",
prompt: "",
interval: "30m",
enabled: true,
approvalMode: "yolo",
newConversationEachRun: false,
notifyChannels: false,
createdAt: Date.now(),
});
} catch {
// ignore
}
}, []);
const handleEdit = useCallback((task: HeartbeatTask) => {
@@ -238,8 +242,12 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
const handleTrigger = useCallback(
async (id: string) => {
await heartbeatTriggerNow(id);
void loadTasks();
try {
await heartbeatTriggerNow(id);
void loadTasks();
} catch {
// ignore
}
},
[loadTasks],
);
+45 -24
View File
@@ -1361,10 +1361,14 @@ func sameConfigPath(a, b string) bool {
return filepath.Clean(a) == filepath.Clean(b)
}
// rebuild tears down the controller and rebuilds it from the (just-changed)
// config, carrying the conversation forward. It keeps the active model if it
// still resolves; otherwise it falls back to the new default. Mirrors SetModel.
// rebuild builds a replacement controller from the (just-changed) config and
// swaps it in only after the target session lease is available. The old
// controller stays usable if the rebuild fails.
func (a *App) rebuild() error {
return a.rebuildSetting("settings")
}
func (a *App) rebuildSetting(setting string) error {
if a.ctx == nil {
return nil
}
@@ -1373,22 +1377,41 @@ func (a *App) rebuild() error {
return fmt.Errorf("no active tab")
}
if controllerHasActiveRuntimeWork(tab.Ctrl) {
return rebuildControllerActiveWorkError("settings")
return rebuildControllerActiveWorkError(setting)
}
if err := a.ensureTabControllerWorkspace(tab); err != nil {
return err
}
prevPath := a.reconciledSessionPathForTab(tab)
var carried []provider.Message
if tab.Ctrl != nil {
if prevPath == "" {
prevPath = strings.TrimSpace(tab.currentSessionPath())
}
if tab.Ctrl == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
prevPath = a.reconciledSessionPathForTab(tab)
if prevPath == "" {
prevPath = tab.Ctrl.SessionPath()
prevPath = strings.TrimSpace(tab.currentSessionPath())
}
}
if controllerHasActiveRuntimeWork(tab.Ctrl) {
return rebuildControllerActiveWorkError(setting)
}
if err := a.ensureTabControllerWorkspace(tab); err != nil {
return err
}
var carried []provider.Message
oldCtrl := tab.Ctrl
if oldCtrl != nil {
if prevPath == "" {
prevPath = oldCtrl.SessionPath()
}
if err := tab.ensureSessionLease(prevPath); err != nil {
return userFacingSessionLeaseError(setting, err)
}
if err := a.snapshotTabForAction(tab, "rebuilding settings"); err != nil {
return err
}
carried = tab.Ctrl.History()
tab.Ctrl.Close()
carried = oldCtrl.History()
}
model := tab.model
if cfg, err := config.LoadForRoot(tab.WorkspaceRoot); err == nil {
@@ -1413,12 +1436,13 @@ func (a *App) rebuild() error {
OnSessionRecovered: a.handleTabSessionRecovered(tab),
})
if err != nil {
tab.releaseSessionLease()
a.mu.Lock()
tab.StartupErr = err.Error()
tab.Ready = true
a.mu.Unlock()
a.emitReady(a.ctx)
if oldCtrl == nil {
a.mu.Lock()
tab.StartupErr = err.Error()
tab.Ready = true
a.mu.Unlock()
a.emitReady(a.ctx)
}
return err
}
a.bindControllerDisplayRecorder(ctrl)
@@ -1434,14 +1458,11 @@ func (a *App) rebuild() error {
path := agent.ContinueSessionPath(prevPath, ctrl.SessionDir(), ctrl.Label())
if err := tab.ensureSessionLease(path); err != nil {
ctrl.Close()
tab.releaseSessionLease()
return err
return userFacingSessionLeaseError(setting, err)
}
if len(carried) > 0 {
carried = withFreshSystemPrompt(carried, systemPromptFrom(ctrl.History()))
ctrl.Resume(&agent.Session{Messages: carried}, path)
} else if path != "" {
ctrl.SetSessionPath(path)
resumeWithFreshSystemPrompt(ctrl, carried, path)
if oldCtrl != nil {
oldCtrl.Close()
}
a.persistTabSessionPath(tab, path)
a.mu.Lock()
@@ -2230,7 +2251,7 @@ func (a *App) SetProviderKey(apiKeyEnv, value string) (string, error) {
if err := a.ensureProviderAccessForKey(apiKeyEnv); err != nil {
return "", err
}
if err := a.rebuild(); err != nil {
if err := a.rebuildSetting("provider key"); err != nil {
return "", err
}
return warning, nil
@@ -2299,7 +2320,7 @@ func (a *App) ClearProviderKey(apiKeyEnv string) error {
if err := removeDotEnv(apiKeyEnv); err != nil {
return err
}
return a.rebuild()
return a.rebuildSetting("provider key")
}
// SetPermissionMode sets the writer-fallback mode (ask|allow|deny).