Fix cross-process race condition and remove dead code

Use a separate manual_competitors.json file that the backend agent never
overwrites, preventing data loss when users add competitors during analysis.
Remove unused removeCompetitor and updateCompetitorAnalysis store actions.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
AndyMik90
2026-02-15 14:12:13 +01:00
co-authored by Claude Opus 4.6
parent 9b46709508
commit 517892cf31
4 changed files with 76 additions and 34 deletions
@@ -31,6 +31,7 @@ class CompetitorAnalyzer:
self.refresh = refresh
self.agent_executor = agent_executor
self.analysis_file = output_dir / "competitor_analysis.json"
self.manual_competitors_file = output_dir / "manual_competitors.json"
self.discovery_file = output_dir / "roadmap_discovery.json"
self.project_index_file = output_dir / "project_index.json"
@@ -119,26 +120,47 @@ class CompetitorAnalyzer:
)
def _get_manual_competitors(self) -> list[dict]:
"""Extract manually-added competitors from the existing analysis file.
"""Extract manually-added competitors from the dedicated manual file and analysis file.
Reads from manual_competitors.json (primary, never overwritten by agent) and
falls back to competitor_analysis.json. Deduplicates by competitor ID.
Returns a list of competitor dicts where source == 'manual'.
Returns an empty list if the file doesn't exist or is malformed.
"""
if not self.analysis_file.exists():
return []
competitors_by_id: dict[str, dict] = {}
try:
with open(self.analysis_file, encoding="utf-8") as f:
data = json.load(f)
# Primary source: dedicated manual competitors file (never overwritten by agent)
if self.manual_competitors_file.exists():
try:
with open(self.manual_competitors_file, encoding="utf-8") as f:
data = json.load(f)
for c in data.get("competitors", []):
if isinstance(c, dict) and c.get("id"):
competitors_by_id[c["id"]] = c
except (json.JSONDecodeError, OSError) as e:
print_status(
f"Warning: could not read manual competitors file: {e}", "warning"
)
return [
c
for c in data.get("competitors", [])
if isinstance(c, dict) and c.get("source") == "manual"
]
except (json.JSONDecodeError, OSError) as e:
print_status(f"Warning: could not read manual competitors: {e}", "warning")
return []
# Fallback: also check analysis file for manual competitors
if self.analysis_file.exists():
try:
with open(self.analysis_file, encoding="utf-8") as f:
data = json.load(f)
for c in data.get("competitors", []):
if (
isinstance(c, dict)
and c.get("source") == "manual"
and c.get("id")
and c["id"] not in competitors_by_id
):
competitors_by_id[c["id"]] = c
except (json.JSONDecodeError, OSError) as e:
print_status(
f"Warning: could not read manual competitors from analysis: {e}",
"warning",
)
return list(competitors_by_id.values())
def _merge_manual_competitors(self, manual_competitors: list[dict]) -> None:
"""Merge manual competitors back into the newly-generated analysis file.
@@ -882,6 +882,44 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n"
);
});
// Also persist manual competitors to a separate file that the backend
// agent never overwrites, preventing data loss during concurrent analysis
const manualCompetitors = competitorAnalysis.competitors.filter(
(c) => c.source === "manual"
);
if (manualCompetitors.length > 0) {
const manualCompetitorsPath = path.join(
roadmapDir,
AUTO_BUILD_PATHS.MANUAL_COMPETITORS
);
const manualSerialized = {
competitors: manualCompetitors.map((c) => ({
id: c.id,
name: c.name,
url: c.url,
description: c.description,
relevance: c.relevance,
pain_points: c.painPoints.map((p) => ({
id: p.id,
description: p.description,
source: p.source,
severity: p.severity,
frequency: p.frequency,
opportunity: p.opportunity,
})),
strengths: c.strengths,
market_position: c.marketPosition,
source: c.source,
})),
updated_at: new Date().toISOString(),
};
await writeFileWithRetry(
manualCompetitorsPath,
JSON.stringify(manualSerialized, null, 2),
{ encoding: "utf-8" }
);
}
debugLog("[Roadmap Handler] Saved competitor analysis:", { projectId });
return { success: true };
} catch (error) {
@@ -71,8 +71,6 @@ interface RoadmapState {
updateFeaturePhase: (featureId: string, newPhaseId: string) => void;
addFeature: (feature: Omit<RoadmapFeature, 'id'>) => string;
addCompetitor: (input: ManualCompetitorInput) => string;
removeCompetitor: (competitorId: string) => void;
updateCompetitorAnalysis: (analysis: CompetitorAnalysis) => void;
}
const initialGenerationStatus: RoadmapGenerationStatus = {
@@ -322,23 +320,6 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
return newId;
},
// Remove a competitor by ID (used for rollback on save failure)
removeCompetitor: (competitorId) => {
set((state) => {
if (!state.competitorAnalysis) return state;
return {
competitorAnalysis: {
...state.competitorAnalysis,
competitors: state.competitorAnalysis.competitors.filter(
(c) => c.id !== competitorId
)
}
};
});
},
// Replace the entire competitor analysis object
updateCompetitorAnalysis: (analysis) => set({ competitorAnalysis: analysis })
}));
/**
@@ -111,6 +111,7 @@ export const AUTO_BUILD_PATHS = {
ROADMAP_FILE: 'roadmap.json',
ROADMAP_DISCOVERY: 'roadmap_discovery.json',
COMPETITOR_ANALYSIS: 'competitor_analysis.json',
MANUAL_COMPETITORS: 'manual_competitors.json',
IDEATION_FILE: 'ideation.json',
IDEATION_CONTEXT: 'ideation_context.json',
PROJECT_INDEX: '.auto-claude/project_index.json',