This commit is contained in:
AndyMik90
2026-03-12 21:46:11 +01:00
parent 2a6610a019
commit b9b6e54149
12 changed files with 156 additions and 37 deletions
@@ -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');
+32 -24
View File
@@ -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}
+3
View File
@@ -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)