autoswap
This commit is contained in:
@@ -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<profileId, lastFailureTimestamp> - 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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={account.id}
|
||||
|
||||
@@ -246,6 +246,9 @@ export interface TaskMetadata {
|
||||
useLocalBranch?: boolean; // If true, use the local branch directly instead of preferring origin/branch (preserves gitignored files)
|
||||
pushNewBranches?: boolean; // If false, keep the task branch local-only instead of auto-pushing to origin
|
||||
|
||||
// Original user description (preserved here because spec pipeline overwrites requirements.json)
|
||||
userDescription?: string;
|
||||
|
||||
// Archive status
|
||||
archivedAt?: string; // ISO date when task was archived
|
||||
archivedInVersion?: string; // Version in which task was archived (from changelog)
|
||||
|
||||
Reference in New Issue
Block a user