From b9b6e5414978b801706223dc97ec3368400842bf Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Thu, 12 Mar 2026 21:46:11 +0100 Subject: [PATCH] autoswap --- .../src/main/claude-profile/usage-monitor.ts | 69 +++++++++++++++++-- .../main/ipc-handlers/github/spec-utils.ts | 1 + .../main/ipc-handlers/gitlab/spec-utils.ts | 5 +- .../ipc-handlers/ideation/task-converter.ts | 1 + .../src/main/ipc-handlers/linear-handlers.ts | 3 +- .../src/main/ipc-handlers/roadmap-handlers.ts | 1 + .../main/ipc-handlers/settings-handlers.ts | 10 +++ .../main/ipc-handlers/task/crud-handlers.ts | 10 ++- apps/desktop/src/main/project-store.ts | 56 ++++++++------- .../components/ProactiveSwapListener.tsx | 10 +++ .../renderer/components/UsageIndicator.tsx | 24 +++++-- apps/desktop/src/shared/types/task.ts | 3 + 12 files changed, 156 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/main/claude-profile/usage-monitor.ts b/apps/desktop/src/main/claude-profile/usage-monitor.ts index c54e085c..943ef20c 100644 --- a/apps/desktop/src/main/claude-profile/usage-monitor.ts +++ b/apps/desktop/src/main/claude-profile/usage-monitor.ts @@ -214,6 +214,7 @@ export class UsageMonitor extends EventEmitter { private currentUsage: ClaudeUsageSnapshot | null = null; private currentUsageProfileId: string | null = null; // Track which profile's usage is in currentUsage private isChecking = false; + private pendingCheckAfterCurrent = false; // Per-profile API failure tracking with cooldown-based retry // Map - stores when API last failed for this profile @@ -310,7 +311,12 @@ export class UsageMonitor extends EventEmitter { this.debugLog('[UsageMonitor] Starting with interval: ' + interval + ' ms (5-minute updates for active profile usage stats)'); - // Check immediately + // Fetch all profiles usage immediately on startup so the UI shows real data. + // This runs independently of checkUsageAndSwap — even if the active profile's + // fetch fails (e.g., 429), inactive profiles still get fetched and displayed. + this.fetchAllProfilesOnStartup(); + + // Check immediately (handles proactive swap for active profile) this.checkUsageAndSwap(); // Then check periodically @@ -330,6 +336,43 @@ export class UsageMonitor extends EventEmitter { } } + /** + * Fetch all profiles' usage on startup so the UI shows real data immediately. + * Runs asynchronously — doesn't block app startup. Errors are non-fatal. + * + * We wait briefly for checkUsageAndSwap() to populate currentUsage (active profile), + * then call getAllProfilesUsage() which fetches inactive profiles and combines everything. + * If the active profile fetch fails (e.g., 429), we still call _doGetAllProfilesUsage + * directly to populate inactive profile data for the UI. + */ + private async fetchAllProfilesOnStartup(): Promise { + try { + // Wait for checkUsageAndSwap() to have a chance to populate currentUsage. + // 5s is enough for most API responses; if it fails, we proceed with partial data. + await new Promise(resolve => setTimeout(resolve, 5000)); + + console.log('[UsageMonitor] Startup: fetching all profiles usage for UI...'); + + let allProfilesUsage: AllProfilesUsage | null; + if (this.currentUsage) { + // Active profile data is available — use the normal path + allProfilesUsage = await this.getAllProfilesUsage(true); + } else { + // Active profile fetch failed (429, etc.) — fetch inactive profiles directly + console.log('[UsageMonitor] Startup: active profile usage unavailable, fetching inactive profiles directly'); + allProfilesUsage = await this._doGetAllProfilesUsage(true); + } + + if (allProfilesUsage) { + this.emit('all-profiles-usage-updated', allProfilesUsage); + console.log('[UsageMonitor] Startup: all profiles usage emitted to UI (' + allProfilesUsage.allProfiles.length + ' profiles)'); + } + } catch (error) { + // Non-fatal — the regular polling will pick it up + console.log('[UsageMonitor] Startup: failed to fetch all profiles usage (non-fatal):', error instanceof Error ? error.message : String(error)); + } + } + /** * Get current usage snapshot (for UI indicator) */ @@ -447,6 +490,7 @@ export class UsageMonitor extends EventEmitter { await this.appendZAIAccounts(allProfiles); // Return minimal data with auth status - don't return null! + console.log('[UsageMonitor] getAllProfilesUsage fast-path (no currentUsage):', allProfiles.map(p => ({ id: p.profileId, name: p.profileName, email: p.profileEmail, active: p.isActive }))); return { activeProfile: { profileId: activeProfileId || '', @@ -760,9 +804,18 @@ export class UsageMonitor extends EventEmitter { // Sort by availability score (highest first = most available) allProfiles.sort((a, b) => b.availabilityScore - a.availabilityScore); + // Build active profile data — may be null on startup if the active profile's fetch failed + const activeProfile = this.currentUsage ?? { + profileId: settings.activeProfileId || '', + profileName: settings.profiles.find(p => p.id === settings.activeProfileId)?.name || '', + sessionPercent: 0, + weeklyPercent: 0, + fetchedAt: new Date(), + }; + + console.log('[UsageMonitor] _doGetAllProfilesUsage complete:', allProfiles.map(p => ({ id: p.profileId, name: p.profileName, session: p.sessionPercent, weekly: p.weeklyPercent, active: p.isActive }))); return { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - activeProfile: this.currentUsage!, // Non-null: _doGetAllProfilesUsage is only called when currentUsage is set + activeProfile, allProfiles, fetchedAt: new Date() }; @@ -1316,7 +1369,8 @@ export class UsageMonitor extends EventEmitter { */ private async checkUsageAndSwap(): Promise { if (this.isChecking) { - return; // Prevent concurrent checks + this.pendingCheckAfterCurrent = true; // Re-run after current check completes + return; } this.isChecking = true; @@ -1436,6 +1490,13 @@ export class UsageMonitor extends EventEmitter { console.error('[UsageMonitor] Check failed:', error); } finally { this.isChecking = false; + // If a check was requested while we were busy, run it now + if (this.pendingCheckAfterCurrent) { + this.pendingCheckAfterCurrent = false; + this.checkUsageAndSwap().catch(error => { + console.error('[UsageMonitor] Pending check failed:', error); + }); + } } } diff --git a/apps/desktop/src/main/ipc-handlers/github/spec-utils.ts b/apps/desktop/src/main/ipc-handlers/github/spec-utils.ts index 46bbb368..f19649e7 100644 --- a/apps/desktop/src/main/ipc-handlers/github/spec-utils.ts +++ b/apps/desktop/src/main/ipc-handlers/github/spec-utils.ts @@ -165,6 +165,7 @@ export async function createSpecForIssue( githubIssueNumber: issueNumber, githubUrl: safeGithubUrl, category, + userDescription: safeDescription, // Store baseBranch for worktree creation and QA comparison // This comes from project.settings.mainBranch or task-level override ...(baseBranch && { baseBranch }) diff --git a/apps/desktop/src/main/ipc-handlers/gitlab/spec-utils.ts b/apps/desktop/src/main/ipc-handlers/gitlab/spec-utils.ts index f501e476..4637466c 100644 --- a/apps/desktop/src/main/ipc-handlers/gitlab/spec-utils.ts +++ b/apps/desktop/src/main/ipc-handlers/gitlab/spec-utils.ts @@ -5,7 +5,7 @@ import { mkdir, writeFile, readFile, stat } from 'fs/promises'; import path from 'path'; -import type { Project } from '../../../shared/types'; +import type { Project, TaskMetadata } from '../../../shared/types'; import type { GitLabAPIIssue, GitLabAPINoteBasic, GitLabConfig } from './types'; import { labelMatchesWholeWord } from '../shared/label-utils'; import { sanitizeText, sanitizeStringArray } from '../shared/sanitize'; @@ -460,11 +460,12 @@ export async function createSpecForIssue( await writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'); // Create task_metadata.json (consistent with GitHub format for backend compatibility) - const taskMetadata = { + const taskMetadata: TaskMetadata = { sourceType: 'gitlab' as const, gitlabIssueIid: safeIssue.iid, gitlabUrl: safeIssue.web_url, category: determineCategoryFromLabels(safeIssue.labels || []), + userDescription: safeIssue.description || '', // Store baseBranch for worktree creation and QA comparison ...(baseBranch && { baseBranch }) }; diff --git a/apps/desktop/src/main/ipc-handlers/ideation/task-converter.ts b/apps/desktop/src/main/ipc-handlers/ideation/task-converter.ts index 075a1e48..ba946c88 100644 --- a/apps/desktop/src/main/ipc-handlers/ideation/task-converter.ts +++ b/apps/desktop/src/main/ipc-handlers/ideation/task-converter.ts @@ -244,6 +244,7 @@ export async function convertIdeaToTask( // Build task description and metadata const taskDescription = buildTaskDescription(idea); const metadata = buildTaskMetadata(idea); + metadata.userDescription = taskDescription; // Create spec files (inside lock to ensure atomicity) createSpecFiles(specDir, idea, taskDescription); diff --git a/apps/desktop/src/main/ipc-handlers/linear-handlers.ts b/apps/desktop/src/main/ipc-handlers/linear-handlers.ts index 3e2061d7..2f97a890 100644 --- a/apps/desktop/src/main/ipc-handlers/linear-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/linear-handlers.ts @@ -524,7 +524,8 @@ ${safeDescription || 'No description provided.'} linearIssueId: sanitizeText(issue.id, 100), linearIdentifier: safeIdentifier, linearUrl: safeUrl, - category: 'feature' + category: 'feature', + userDescription: description, }; // lgtm[js/http-to-file-access] - specDir is controlled, Linear data sanitized writeFileSync(path.join(specDir, 'task_metadata.json'), JSON.stringify(metadata, null, 2), 'utf-8'); diff --git a/apps/desktop/src/main/ipc-handlers/roadmap-handlers.ts b/apps/desktop/src/main/ipc-handlers/roadmap-handlers.ts index 3c17026a..a7bf8612 100644 --- a/apps/desktop/src/main/ipc-handlers/roadmap-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/roadmap-handlers.ts @@ -585,6 +585,7 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" sourceType: "roadmap", featureId: feature.id, category: "feature", + userDescription: taskDescription, }; await writeFileWithRetry(path.join(specDir, "task_metadata.json"), JSON.stringify(metadata, null, 2), { encoding: 'utf-8' }); diff --git a/apps/desktop/src/main/ipc-handlers/settings-handlers.ts b/apps/desktop/src/main/ipc-handlers/settings-handlers.ts index 07c82837..274edfcd 100644 --- a/apps/desktop/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/settings-handlers.ts @@ -1039,6 +1039,16 @@ export function registerSettingsHandlers( // Non-fatal: usage-monitor may use stale order until next app restart } + // Trigger immediate usage check so the new active profile's data is fetched + // Without this, the UI shows "Usage data unavailable" until the next polling cycle + try { + const { getUsageMonitor } = await import('../claude-profile/usage-monitor'); + const monitor = getUsageMonitor(); + monitor.checkNow(); + } catch { + // Non-fatal: usage will be fetched on next poll + } + console.warn('[PROVIDER_ACCOUNTS_SET_QUEUE_ORDER] Queue order updated:', order.length, 'accounts'); return { success: true }; } catch (error) { diff --git a/apps/desktop/src/main/ipc-handlers/task/crud-handlers.ts b/apps/desktop/src/main/ipc-handlers/task/crud-handlers.ts index 76561d2b..84a6c9b6 100644 --- a/apps/desktop/src/main/ipc-handlers/task/crud-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers/task/crud-handlers.ts @@ -207,10 +207,11 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void { const specDir = path.join(specsDir, specId); mkdirSync(specDir, { recursive: true }); - // Build metadata with source type + // Build metadata with source type and preserve original user description const taskMetadata: TaskMetadata = { sourceType: 'manual', - ...metadata + ...metadata, + userDescription: description, }; // Process and save attached images @@ -592,6 +593,11 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void { updatedMetadata.attachedImages = savedImages; } + // Keep userDescription in sync when description is edited + if (updates.description !== undefined) { + updatedMetadata.userDescription = updates.description; + } + // Sanitize thinking levels and update task_metadata.json sanitizeThinkingLevels(updatedMetadata); const metadataPath = path.join(specDir, 'task_metadata.json'); diff --git a/apps/desktop/src/main/project-store.ts b/apps/desktop/src/main/project-store.ts index a2e42b34..42fcd3c1 100644 --- a/apps/desktop/src/main/project-store.ts +++ b/apps/desktop/src/main/project-store.ts @@ -447,28 +447,48 @@ export class ProjectStore { } } - let description = ''; - const requirementsPath = path.join(specPath, AUTO_BUILD_PATHS.REQUIREMENTS); - // PRIORITY 1: Read original user task description from requirements.json - if (existsSync(requirementsPath)) { + // Try to read task metadata (read early — userDescription is the preferred source) + const metadataPath = path.join(specPath, 'task_metadata.json'); + let metadata: TaskMetadata | undefined; + if (existsSync(metadataPath)) { try { - const reqContent = readFileSync(requirementsPath, 'utf-8'); - const requirements = JSON.parse(reqContent); - if (typeof requirements.task_description === 'string' && requirements.task_description.trim()) { - // Use the full task description that the user entered - description = requirements.task_description.trim(); - } + const content = readFileSync(metadataPath, 'utf-8'); + metadata = JSON.parse(content); } catch { // Ignore parse errors } } - // PRIORITY 2: Fallback to plan description if user requirement text is missing + // Resolve task description with priority fallback: + // PRIORITY 0: task_metadata.json userDescription (immutable — spec pipeline can't overwrite) + // PRIORITY 1: requirements.json task_description (original format, before spec pipeline rewrites it) + // PRIORITY 2: implementation_plan.json description (AI-generated summary) + // PRIORITY 3: spec.md Overview section (AI-synthesized content) + let description = ''; + + if (typeof metadata?.userDescription === 'string' && metadata.userDescription.trim()) { + description = metadata.userDescription.trim(); + } + + if (!description) { + const requirementsPath = path.join(specPath, AUTO_BUILD_PATHS.REQUIREMENTS); + if (existsSync(requirementsPath)) { + try { + const reqContent = readFileSync(requirementsPath, 'utf-8'); + const requirements = JSON.parse(reqContent); + if (typeof requirements.task_description === 'string' && requirements.task_description.trim()) { + description = requirements.task_description.trim(); + } + } catch { + // Ignore parse errors + } + } + } + if (!description && plan?.description) { description = plan.description; } - // PRIORITY 3: Final fallback to spec.md Overview (AI-synthesized content) if (!description && existsSync(specFilePath)) { try { const content = readFileSync(specFilePath, 'utf-8'); @@ -484,18 +504,6 @@ export class ProjectStore { } } - // Try to read task metadata - const metadataPath = path.join(specPath, 'task_metadata.json'); - let metadata: TaskMetadata | undefined; - if (existsSync(metadataPath)) { - try { - const content = readFileSync(metadataPath, 'utf-8'); - metadata = JSON.parse(content); - } catch { - // Ignore parse errors - } - } - // Determine task status and review reason from plan // For JSON errors, store just the raw error - renderer will use i18n to format const finalDescription = hasJsonError diff --git a/apps/desktop/src/renderer/components/ProactiveSwapListener.tsx b/apps/desktop/src/renderer/components/ProactiveSwapListener.tsx index 7119c23b..9ccd736e 100644 --- a/apps/desktop/src/renderer/components/ProactiveSwapListener.tsx +++ b/apps/desktop/src/renderer/components/ProactiveSwapListener.tsx @@ -9,6 +9,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { RefreshCw, X } from 'lucide-react'; import { Button } from './ui/button'; +import { loadSettings } from '../stores/settings-store'; interface SwapNotification { fromProfile: string; @@ -34,6 +35,15 @@ export function ProactiveSwapListener() { setNotification(notif); setIsVisible(true); + // Reload settings so the UI reflects the new active account + // (proactive swap updates globalPriorityOrder in the settings file) + loadSettings(); + + // Request fresh usage data for the new active profile + // Without this, UsageIndicator shows "Usage data unavailable" until next poll + window.electronAPI.requestUsageUpdate(); + window.electronAPI.requestAllProfilesUsage?.(); + // Auto-hide after 5 seconds setTimeout(() => { setIsVisible(false); diff --git a/apps/desktop/src/renderer/components/UsageIndicator.tsx b/apps/desktop/src/renderer/components/UsageIndicator.tsx index f7270260..4a1d49ff 100644 --- a/apps/desktop/src/renderer/components/UsageIndicator.tsx +++ b/apps/desktop/src/renderer/components/UsageIndicator.tsx @@ -440,14 +440,16 @@ export function UsageIndicator() { ); } } else { - // No cached data for target — clear stale usage so it shows loading - setUsage(null); + // No cached data for target — keep old usage while waiting for fresh data + // (setting null causes "Usage data unavailable" flash; the backend checkNow() + // triggered by setQueueOrder will push fresh data via onUsageUpdated event) } await setQueueOrder(newOrder); - // Fetch fresh data from backend - window.electronAPI.requestUsageUpdate(); + // The queue order handler triggers checkNow() on the UsageMonitor, + // which will emit 'usage-updated' with fresh data for the new active profile. + // Also request all profiles to refresh the sidebar. window.electronAPI.requestAllProfilesUsage?.(); }, [settings.globalPriorityOrder, providerAccounts, setQueueOrder, otherProfiles, usage, isCrossProviderMode]); @@ -757,6 +759,7 @@ export function UsageIndicator() { const unsubscribeAllProfiles = window.electronAPI.onAllProfilesUsageUpdated?.((allProfilesUsage) => { // Filter out the active profile - we only want to show "other" profiles const nonActiveProfiles = allProfilesUsage.allProfiles.filter(p => !p.isActive); + console.log('[UsageIndicator] Event: all profiles updated:', nonActiveProfiles.map(p => ({ id: p.profileId, name: p.profileName, session: p.sessionPercent, weekly: p.weeklyPercent }))); setOtherProfiles(nonActiveProfiles); // Track if active profile needs re-auth const activeProfile = allProfilesUsage.allProfiles.find(p => p.isActive); @@ -781,6 +784,8 @@ export function UsageIndicator() { window.electronAPI.requestAllProfilesUsage?.().then((result) => { if (result.success && result.data) { const nonActiveProfiles = result.data.allProfiles.filter(p => !p.isActive); + console.log('[UsageIndicator] Initial allProfiles received:', result.data.allProfiles.map(p => ({ id: p.profileId, name: p.profileName, active: p.isActive, session: p.sessionPercent, weekly: p.weeklyPercent }))); + console.log('[UsageIndicator] Non-active profiles for matching:', nonActiveProfiles.map(p => ({ id: p.profileId, name: p.profileName }))); setOtherProfiles(nonActiveProfiles); // Track if active profile needs re-auth (even if main usage is unavailable) const activeProfile = result.data.allProfiles.find(p => p.isActive); @@ -865,6 +870,17 @@ export function UsageIndicator() { ? otherProfiles.find(p => p.profileName === account.name || p.profileEmail === account.name) : undefined); + if (!profileData && hasOAuthMonitoring) { + console.log('[UsageIndicator] No profileData match for account:', { + accountId: account.id, + accountName: account.name, + claudeProfileId: account.claudeProfileId, + provider: account.provider, + authType: account.authType, + otherProfileIds: otherProfiles.map(p => ({ id: p.profileId, name: p.profileName })) + }); + } + return (