fix: prevent OOM, orphaned agents, and unbounded growth during overnight builds (#1813)

* fix(stability): prevent OOM, orphaned agents, and unbounded growth during overnight builds

Address multiple crash/stability issues observed during long-running autonomous builds:

Backend:
- Skip stuck subtasks in get_next_subtask() using attempt_history.json
- Add retry with exponential backoff + jitter for LadybugDB lock contention
- Time-window filter attempt counts (2h window) to prevent unbounded accumulation
- Trim attempt history per subtask (cap at 50) to bound file size
- Use timezone-aware UTC datetimes throughout recovery manager

Frontend:
- Kill all agents on window close to prevent orphaned processes
- Circuit breaker: kill agents after 10 consecutive renderer disposal errors
- Cap batch queue logs at 100 entries (OOM prevention in IPC batching)
- Cap task log entries at 5000 per task (OOM prevention in store)

Tests:
- Add lock retry logic tests (lock detection, backoff, retry exhaustion)
- Add stuck subtask skipping tests (skip, corrupt JSON, all-stuck)
- Add time-window filtering and attempt trimming tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(stability): address PR review findings for crash stability

- Add .catch() to async killAll() calls to prevent unhandled promise rejections
  (index.ts on window close, utils.ts circuit breaker)
- Reset circuitBreakerTriggered on successful send so it can re-trigger after
  renderer recovery followed by a second crash
- Fix agentManagerRef type to reflect async killAll() signature
- Switch %-format logging to f-strings for consistency with codebase convention

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-02-14 11:23:17 +01:00
committed by GitHub
parent f40f79a2db
commit 4091d1d4b5
11 changed files with 896 additions and 44 deletions
+31 -3
View File
@@ -9,8 +9,11 @@ Enhanced with colored output, icons, and better visual formatting.
"""
import json
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
from core.plan_normalization import normalize_subtask_aliases
from ui import (
Icons,
@@ -230,8 +233,8 @@ def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None:
f" {icon(Icons.ARROW_RIGHT)} Next: {highlight(next_id)} - {next_desc}"
)
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
pass # Ignore corrupted/unreadable progress files
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e:
logger.debug(f"Failed to load plan file for phase summary: {e}")
else:
print()
print_status("No implementation subtasks yet - planner needs to run", "pending")
@@ -404,6 +407,8 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
"""
Find the next subtask to work on, respecting phase dependencies.
Skips subtasks that are marked as stuck in the recovery manager's attempt history.
Args:
spec_dir: Directory containing implementation_plan.json
@@ -415,6 +420,23 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
if not plan_file.exists():
return None
# Load stuck subtasks from recovery manager's attempt history
stuck_subtask_ids = set()
attempt_history_file = spec_dir / "memory" / "attempt_history.json"
if attempt_history_file.exists():
try:
with open(attempt_history_file, encoding="utf-8") as f:
attempt_history = json.load(f)
# Collect IDs of subtasks marked as stuck
stuck_subtask_ids = {
entry["subtask_id"]
for entry in attempt_history.get("stuck_subtasks", [])
if "subtask_id" in entry
}
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
# If we can't read the file, continue without stuck checking
pass
try:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
@@ -455,9 +477,15 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
if not deps_satisfied:
continue
# Find first pending subtask in this phase
# Find first pending subtask in this phase (skip stuck subtasks)
for subtask in phase.get("subtasks", phase.get("chunks", [])):
status = subtask.get("status", "pending")
subtask_id = subtask.get("id")
# Skip stuck subtasks
if subtask_id in stuck_subtask_ids:
continue
if status in {"pending", "not_started", "not started"}:
subtask_out, _changed = normalize_subtask_aliases(subtask)
subtask_out["status"] = "pending"
@@ -5,7 +5,9 @@ Handles database connection, initialization, and lifecycle management.
Uses LadybugDB as the embedded graph database (no Docker required, Python 3.12+).
"""
import asyncio
import logging
import random
import sys
from datetime import datetime, timezone
@@ -14,6 +16,27 @@ from graphiti_config import GraphitiConfig, GraphitiState
logger = logging.getLogger(__name__)
# Retry configuration for LadybugDB lock contention
MAX_LOCK_RETRIES = 5
INITIAL_BACKOFF_SECONDS = 0.5
MAX_BACKOFF_SECONDS = 8.0
JITTER_PERCENT = 0.2
def _is_lock_error(error: Exception) -> bool:
"""Check if an error indicates database lock contention."""
error_msg = str(error).lower()
return "could not set lock" in error_msg or (
"lock" in error_msg and ("file" in error_msg or "database" in error_msg)
)
def _backoff_with_jitter(attempt: int) -> float:
"""Calculate exponential backoff with jitter for retry delays."""
backoff = min(INITIAL_BACKOFF_SECONDS * (2**attempt), MAX_BACKOFF_SECONDS)
jitter = backoff * JITTER_PERCENT * (2 * random.random() - 1)
return max(0.01, backoff + jitter)
def _apply_ladybug_monkeypatch() -> bool:
"""
@@ -196,32 +219,36 @@ class GraphitiClient:
)
db_path = self.config.get_db_path()
try:
self._driver = create_patched_kuzu_driver(db=str(db_path))
except (OSError, PermissionError) as e:
logger.warning(
f"Failed to initialize LadybugDB driver at {db_path}: {e}"
)
capture_exception(
e,
error_type=type(e).__name__,
db_path=str(db_path),
llm_provider=self.config.llm_provider,
embedder_provider=self.config.embedder_provider,
)
return False
except Exception as e:
logger.warning(
f"Unexpected error initializing LadybugDB driver at {db_path}: {e}"
)
capture_exception(
e,
error_type=type(e).__name__,
db_path=str(db_path),
llm_provider=self.config.llm_provider,
embedder_provider=self.config.embedder_provider,
)
return False
# Retry with exponential backoff for lock contention
for attempt in range(MAX_LOCK_RETRIES + 1):
try:
self._driver = create_patched_kuzu_driver(db=str(db_path))
if attempt > 0:
logger.info(
f"LadybugDB lock acquired after {attempt} retries"
)
break # Success
except Exception as e:
if _is_lock_error(e) and attempt < MAX_LOCK_RETRIES:
wait_time = _backoff_with_jitter(attempt)
logger.debug(
f"LadybugDB lock contention (attempt {attempt + 1}/{MAX_LOCK_RETRIES}), retrying in {wait_time:.2f}s"
)
await asyncio.sleep(wait_time)
continue
logger.warning(
f"Failed to initialize LadybugDB driver at {db_path}: {e}"
)
capture_exception(
e,
error_type=type(e).__name__,
db_path=str(db_path),
llm_provider=self.config.llm_provider,
embedder_provider=self.config.embedder_provider,
)
return False
logger.info(f"Initialized LadybugDB driver (patched) at: {db_path}")
except ImportError as e:
logger.warning(f"KuzuDriver not available: {e}")
+60 -13
View File
@@ -14,12 +14,19 @@ Key Features:
"""
import json
import logging
import subprocess
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
# Recovery manager configuration
ATTEMPT_WINDOW_SECONDS = 7200 # Only count attempts within last 2 hours
MAX_ATTEMPT_HISTORY_PER_SUBTASK = 50 # Cap stored attempts per subtask
logger = logging.getLogger(__name__)
class FailureType(Enum):
"""Types of failures that can occur during autonomous builds."""
@@ -82,8 +89,8 @@ class RecoveryManager:
"subtasks": {},
"stuck_subtasks": [],
"metadata": {
"created_at": datetime.now().isoformat(),
"last_updated": datetime.now().isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
"last_updated": datetime.now(timezone.utc).isoformat(),
},
}
with open(self.attempt_history_file, "w", encoding="utf-8") as f:
@@ -95,8 +102,8 @@ class RecoveryManager:
"commits": [],
"last_good_commit": None,
"metadata": {
"created_at": datetime.now().isoformat(),
"last_updated": datetime.now().isoformat(),
"created_at": datetime.now(timezone.utc).isoformat(),
"last_updated": datetime.now(timezone.utc).isoformat(),
},
}
with open(self.build_commits_file, "w", encoding="utf-8") as f:
@@ -114,7 +121,7 @@ class RecoveryManager:
def _save_attempt_history(self, data: dict) -> None:
"""Save attempt history to JSON file."""
data["metadata"]["last_updated"] = datetime.now().isoformat()
data["metadata"]["last_updated"] = datetime.now(timezone.utc).isoformat()
with open(self.attempt_history_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
@@ -130,7 +137,7 @@ class RecoveryManager:
def _save_build_commits(self, data: dict) -> None:
"""Save build commits to JSON file."""
data["metadata"]["last_updated"] = datetime.now().isoformat()
data["metadata"]["last_updated"] = datetime.now(timezone.utc).isoformat()
with open(self.build_commits_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
@@ -185,17 +192,44 @@ class RecoveryManager:
def get_attempt_count(self, subtask_id: str) -> int:
"""
Get how many times this subtask has been attempted.
Get how many times this subtask has been attempted within the time window.
Only counts attempts within ATTEMPT_WINDOW_SECONDS (default: 2 hours).
This prevents unbounded accumulation across crash/restart cycles.
Args:
subtask_id: ID of the subtask
Returns:
Number of attempts
Number of attempts within the time window
"""
history = self._load_attempt_history()
subtask_data = history["subtasks"].get(subtask_id, {})
return len(subtask_data.get("attempts", []))
attempts = subtask_data.get("attempts", [])
# Calculate cutoff time for the window
cutoff_time = datetime.now(timezone.utc) - timedelta(
seconds=ATTEMPT_WINDOW_SECONDS
)
# For backward compatibility with naive timestamps, also create naive cutoff
cutoff_time_naive = datetime.now() - timedelta(seconds=ATTEMPT_WINDOW_SECONDS)
# Count only attempts within the time window
recent_count = 0
for attempt in attempts:
try:
attempt_time = datetime.fromisoformat(attempt["timestamp"])
# Use appropriate cutoff based on whether timestamp is naive or aware
cutoff = (
cutoff_time_naive if attempt_time.tzinfo is None else cutoff_time
)
if attempt_time >= cutoff:
recent_count += 1
except (KeyError, ValueError):
# If timestamp is missing or invalid, count it (backward compatibility)
recent_count += 1
return recent_count
def record_attempt(
self,
@@ -208,6 +242,8 @@ class RecoveryManager:
"""
Record an attempt at a subtask.
Automatically trims old attempts if the history exceeds MAX_ATTEMPT_HISTORY_PER_SUBTASK.
Args:
subtask_id: ID of the subtask
session: Session number
@@ -224,13 +260,24 @@ class RecoveryManager:
# Add the attempt
attempt = {
"session": session,
"timestamp": datetime.now().isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
"approach": approach,
"success": success,
"error": error,
}
history["subtasks"][subtask_id]["attempts"].append(attempt)
# Hard cap: trim oldest attempts if we exceed the maximum
attempts = history["subtasks"][subtask_id]["attempts"]
if len(attempts) > MAX_ATTEMPT_HISTORY_PER_SUBTASK:
trimmed_count = len(attempts) - MAX_ATTEMPT_HISTORY_PER_SUBTASK
history["subtasks"][subtask_id]["attempts"] = attempts[
-MAX_ATTEMPT_HISTORY_PER_SUBTASK:
]
logger.debug(
f"Trimmed {trimmed_count} old attempts for subtask {subtask_id} (cap: {MAX_ATTEMPT_HISTORY_PER_SUBTASK})"
)
# Update status
if success:
history["subtasks"][subtask_id]["status"] = "completed"
@@ -405,7 +452,7 @@ class RecoveryManager:
commit_record = {
"hash": commit_hash,
"subtask_id": subtask_id,
"timestamp": datetime.now().isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
}
commits["commits"].append(commit_record)
@@ -450,7 +497,7 @@ class RecoveryManager:
stuck_entry = {
"subtask_id": subtask_id,
"reason": reason,
"escalated_at": datetime.now().isoformat(),
"escalated_at": datetime.now(timezone.utc).isoformat(),
"attempt_count": self.get_attempt_count(subtask_id),
}
+4
View File
@@ -330,6 +330,10 @@ function createWindow(): void {
// Clean up on close
mainWindow.on('closed', () => {
// Kill all agents when window closes (prevents orphaned processes)
agentManager?.killAll?.()?.catch((err: unknown) => {
console.warn('[main] Error killing agents on window close:', err);
});
mainWindow = null;
});
}
@@ -35,6 +35,7 @@ import { registerProfileHandlers } from './profile-handlers';
import { registerScreenshotHandlers } from './screenshot-handlers';
import { registerTerminalWorktreeIpcHandlers } from './terminal';
import { notificationService } from '../notification-service';
import { setAgentManagerRef } from './utils';
/**
* Setup all IPC handlers across all domains
@@ -53,6 +54,9 @@ export function setupIpcHandlers(
// Initialize notification service
notificationService.initialize(getMainWindow);
// Wire up agent manager for circuit breaker cleanup
setAgentManagerRef(agentManager);
// Project handlers (including Python environment setup)
registerProjectHandlers(pythonEnvManager, agentManager, getMainWindow);
@@ -12,6 +12,17 @@ import type { BrowserWindow } from "electron";
const warnTimestamps = new Map<string, number>();
const WARN_COOLDOWN_MS = 5000; // 5 seconds between warnings per channel
/** Circuit breaker: kill agents after consecutive renderer disposal errors */
const MAX_CONSECUTIVE_DISPOSAL_ERRORS = 10;
let consecutiveDisposalErrors = 0;
let agentManagerRef: { killAll: () => void | Promise<void> } | null = null;
let circuitBreakerTriggered = false;
/** Set agent manager reference for circuit breaker cleanup */
export function setAgentManagerRef(manager: { killAll: () => void | Promise<void> }): void {
agentManagerRef = manager;
}
/**
* Check if a channel is within the warning cooldown period.
* @returns true if within cooldown (should skip warning), false if cooldown expired
@@ -108,6 +119,9 @@ export function safeSendToRenderer(
// All checks passed - safe to send
mainWindow.webContents.send(channel, ...args);
// On successful send, reset circuit breaker state (allow re-trigger after recovery)
consecutiveDisposalErrors = 0;
circuitBreakerTriggered = false;
return true;
} catch (error) {
// Catch any disposal errors that might occur between our checks and the actual send
@@ -115,6 +129,16 @@ export function safeSendToRenderer(
// Only log disposal errors once per channel to avoid log spam
if (errorMessage.includes("disposed") || errorMessage.includes("destroyed")) {
// Circuit breaker: track consecutive disposal errors
consecutiveDisposalErrors++;
if (consecutiveDisposalErrors >= MAX_CONSECUTIVE_DISPOSAL_ERRORS && !circuitBreakerTriggered && agentManagerRef) {
circuitBreakerTriggered = true;
console.error('[safeSendToRenderer] Circuit breaker triggered: killing all agents after renderer death');
Promise.resolve(agentManagerRef.killAll()).catch((err) => {
console.error('[safeSendToRenderer] Error killing agents:', err);
});
}
if (!isWithinCooldown(channel)) {
console.warn(`[safeSendToRenderer] Frame disposed, skipping send: ${channel}`);
recordWarning(channel);
@@ -7,6 +7,9 @@ import { useAuthFailureStore } from '../stores/auth-failure-store';
import { useProjectStore } from '../stores/project-store';
import type { ImplementationPlan, TaskStatus, RoadmapGenerationStatus, Roadmap, ExecutionProgress, RateLimitInfo, SDKRateLimitInfo, AuthFailureInfo } from '../../shared/types';
/** Maximum log entries to buffer in the batch queue between flushes (OOM prevention) */
const MAX_BATCH_QUEUE_LOGS = 100;
/**
* Batched update queue for IPC events.
* Collects updates within a 16ms window (one frame) and flushes them together.
@@ -124,6 +127,10 @@ function queueUpdate(taskId: string, update: BatchedUpdate): void {
let mergedLogs = existing.logs;
if (update.logs) {
mergedLogs = [...(existing.logs || []), ...update.logs];
// Cap batch queue logs to prevent OOM when logs arrive faster than flush interval
if (mergedLogs.length > MAX_BATCH_QUEUE_LOGS) {
mergedLogs = mergedLogs.slice(-MAX_BATCH_QUEUE_LOGS);
}
}
batchQueue.set(taskId, {
@@ -7,6 +7,9 @@ import { useProjectStore } from './project-store';
/** Default max parallel tasks when no project setting is configured */
export const DEFAULT_MAX_PARALLEL_TASKS = 3;
/** Maximum log entries stored per task to prevent renderer OOM */
export const MAX_LOG_ENTRIES = 5000;
interface TaskState {
tasks: Task[];
selectedTaskId: string | null;
@@ -475,7 +478,7 @@ export const useTaskStore = create<TaskState>((set, get) => ({
return {
tasks: updateTaskAtIndex(state.tasks, index, (t) => ({
...t,
logs: [...(t.logs || []), log]
logs: [...(t.logs || []), log].slice(-MAX_LOG_ENTRIES)
}))
};
}),
@@ -508,7 +511,7 @@ export const useTaskStore = create<TaskState>((set, get) => ({
return {
tasks: updateTaskAtIndex(state.tasks, index, (t) => ({
...t,
logs: [...(t.logs || []), ...logs]
logs: [...(t.logs || []), ...logs].slice(-MAX_LOG_ENTRIES)
}))
};
});
+250
View File
@@ -1,4 +1,5 @@
"""Tests for Graphiti memory integration."""
import asyncio
import os
import pytest
from pathlib import Path
@@ -529,3 +530,252 @@ class TestGraphitiState:
assert len(state.error_log) == 10
assert "Error 5" in state.error_log[0]["error"]
assert "Error 14" in state.error_log[-1]["error"]
# =============================================================================
# LADYBUGDB LOCK RETRY LOGIC TESTS
# =============================================================================
class TestIsLockError:
"""Tests for _is_lock_error lock detection function."""
def test_lock_file_error_detected(self):
"""Detects lock + file pattern in error messages."""
from integrations.graphiti.queries_pkg.client import _is_lock_error
assert _is_lock_error(Exception("Could not set lock on file")) is True
def test_lock_database_error_detected(self):
"""Detects lock + database pattern in error messages."""
from integrations.graphiti.queries_pkg.client import _is_lock_error
assert _is_lock_error(Exception("Database lock contention detected")) is True
def test_could_not_set_lock_detected(self):
"""Detects 'could not set lock' pattern."""
from integrations.graphiti.queries_pkg.client import _is_lock_error
assert _is_lock_error(Exception("could not set lock")) is True
def test_non_lock_error_not_detected(self):
"""Non-lock errors are not detected as lock errors."""
from integrations.graphiti.queries_pkg.client import _is_lock_error
assert _is_lock_error(Exception("Connection refused")) is False
assert _is_lock_error(Exception("Timeout error")) is False
assert _is_lock_error(Exception("Permission denied")) is False
def test_lock_without_file_or_database_not_detected(self):
"""'lock' alone without 'file' or 'database' is not detected."""
from integrations.graphiti.queries_pkg.client import _is_lock_error
# 'lock' without 'file' or 'database' and no 'could not set lock'
assert _is_lock_error(Exception("Object is locked by user")) is False
class TestBackoffWithJitter:
"""Tests for _backoff_with_jitter calculation."""
def test_backoff_increases_with_attempt(self):
"""Backoff time increases with attempt number."""
from integrations.graphiti.queries_pkg.client import _backoff_with_jitter
# Run multiple times to account for jitter
attempt_0_values = [_backoff_with_jitter(0) for _ in range(20)]
attempt_3_values = [_backoff_with_jitter(3) for _ in range(20)]
avg_0 = sum(attempt_0_values) / len(attempt_0_values)
avg_3 = sum(attempt_3_values) / len(attempt_3_values)
assert avg_3 > avg_0, "Higher attempts should have higher average backoff"
def test_backoff_is_positive(self):
"""Backoff is always positive."""
from integrations.graphiti.queries_pkg.client import _backoff_with_jitter
for attempt in range(10):
for _ in range(10):
assert _backoff_with_jitter(attempt) > 0
def test_backoff_capped_at_max(self):
"""Backoff should not exceed MAX_BACKOFF_SECONDS + jitter."""
from integrations.graphiti.queries_pkg.client import (
JITTER_PERCENT,
MAX_BACKOFF_SECONDS,
_backoff_with_jitter,
)
max_possible = MAX_BACKOFF_SECONDS * (1 + JITTER_PERCENT)
for _ in range(50):
val = _backoff_with_jitter(100) # Very high attempt
assert val <= max_possible + 0.01, f"Backoff {val} exceeded max {max_possible}"
class TestGraphitiClientRetryLogic:
"""Tests for LadybugDB lock retry logic in GraphitiClient.initialize().
These tests exercise the retry loop behavior by mocking the modules
that are imported locally inside initialize(). We patch at the source
module level since the imports are local to the method.
"""
def _make_config(self):
"""Create a mock GraphitiConfig for testing."""
config = MagicMock()
config.llm_provider = "openai"
config.embedder_provider = "openai"
config.get_db_path.return_value = Path("/tmp/test-db")
config.get_provider_summary.return_value = "openai/openai"
return config
def _make_mock_providers(self):
"""Create mock graphiti_providers module."""
mock_providers = MagicMock()
mock_providers.create_llm_client = MagicMock(return_value=MagicMock())
mock_providers.create_embedder = MagicMock(return_value=MagicMock())
mock_providers.ProviderError = type("ProviderError", (Exception,), {})
mock_providers.ProviderNotInstalled = type(
"ProviderNotInstalled", (mock_providers.ProviderError,), {}
)
return mock_providers
def _make_noop_sleep(self):
"""Create an async no-op replacement for asyncio.sleep."""
async def _noop_sleep(_delay):
return
return _noop_sleep
@pytest.mark.asyncio
async def test_successful_retry_after_lock_error(self):
"""Client retries and succeeds after transient lock error."""
from integrations.graphiti.queries_pkg.client import GraphitiClient
config = self._make_config()
client = GraphitiClient(config)
call_count = 0
def mock_create_driver(db=""):
nonlocal call_count
call_count += 1
if call_count == 1:
raise OSError("Could not set lock on file /tmp/test-db")
return MagicMock()
mock_graphiti_instance = MagicMock()
async def mock_build_indices():
pass
mock_graphiti_instance.build_indices_and_constraints = mock_build_indices
mock_graphiti_cls = MagicMock(return_value=mock_graphiti_instance)
mock_graphiti_core = MagicMock()
mock_graphiti_core.Graphiti = mock_graphiti_cls
mock_kuzu_driver = MagicMock()
mock_kuzu_driver.create_patched_kuzu_driver = mock_create_driver
with (
patch.dict(sys.modules, {
"graphiti_core": mock_graphiti_core,
"graphiti_providers": self._make_mock_providers(),
"integrations.graphiti.queries_pkg.kuzu_driver_patched": mock_kuzu_driver,
}),
patch(
"integrations.graphiti.queries_pkg.client._apply_ladybug_monkeypatch",
return_value=True,
),
patch(
"integrations.graphiti.queries_pkg.client.asyncio.sleep",
side_effect=self._make_noop_sleep(),
),
):
result = await client.initialize()
assert call_count == 2, "Should have retried once after lock error"
assert result is True, "Should succeed after retry"
@pytest.mark.asyncio
async def test_exhausted_retries_returns_false(self):
"""Client returns False after exhausting all retries on lock errors."""
from integrations.graphiti.queries_pkg.client import (
MAX_LOCK_RETRIES,
GraphitiClient,
)
config = self._make_config()
client = GraphitiClient(config)
call_count = 0
def always_lock_error(db=""):
nonlocal call_count
call_count += 1
raise OSError("Could not set lock on database file")
mock_kuzu_driver = MagicMock()
mock_kuzu_driver.create_patched_kuzu_driver = always_lock_error
with (
patch.dict(sys.modules, {
"graphiti_core": MagicMock(),
"graphiti_providers": self._make_mock_providers(),
"integrations.graphiti.queries_pkg.kuzu_driver_patched": mock_kuzu_driver,
}),
patch(
"integrations.graphiti.queries_pkg.client._apply_ladybug_monkeypatch",
return_value=True,
),
patch(
"integrations.graphiti.queries_pkg.client.capture_exception",
),
patch(
"integrations.graphiti.queries_pkg.client.asyncio.sleep",
side_effect=self._make_noop_sleep(),
),
):
result = await client.initialize()
assert result is False, "Should return False after exhausting retries"
# Should attempt MAX_LOCK_RETRIES + 1 times (initial + retries)
assert call_count == MAX_LOCK_RETRIES + 1
@pytest.mark.asyncio
async def test_non_lock_error_fails_immediately(self):
"""Non-lock errors cause immediate failure without retry."""
from integrations.graphiti.queries_pkg.client import GraphitiClient
config = self._make_config()
client = GraphitiClient(config)
call_count = 0
def connection_error(db=""):
nonlocal call_count
call_count += 1
raise RuntimeError("Connection refused - server not running")
mock_kuzu_driver = MagicMock()
mock_kuzu_driver.create_patched_kuzu_driver = connection_error
with (
patch.dict(sys.modules, {
"graphiti_core": MagicMock(),
"graphiti_providers": self._make_mock_providers(),
"integrations.graphiti.queries_pkg.kuzu_driver_patched": mock_kuzu_driver,
}),
patch(
"integrations.graphiti.queries_pkg.client._apply_ladybug_monkeypatch",
return_value=True,
),
patch(
"integrations.graphiti.queries_pkg.client.capture_exception",
),
):
result = await client.initialize()
assert call_count == 1, "Non-lock errors should not trigger retries"
assert result is False
+141
View File
@@ -1630,3 +1630,144 @@ class TestEdgeCaseStateTransitions:
# All subtasks blocked = effectively pending state = backlog
assert plan.status == "backlog"
assert plan.planStatus == "pending"
# =============================================================================
# STUCK SUBTASK SKIPPING TESTS (progress.py get_next_subtask)
# =============================================================================
class TestStuckSubtaskSkipping:
"""Tests for stuck subtask skipping in progress.get_next_subtask()."""
def _make_plan(self, subtasks):
"""Helper to create a minimal implementation_plan.json dict."""
return {
"feature": "Test",
"workflow_type": "feature",
"phases": [
{
"phase": 1,
"name": "Phase 1",
"depends_on": [],
"subtasks": subtasks,
}
],
}
def _make_attempt_history(self, stuck_ids):
"""Helper to create attempt_history.json with stuck subtasks."""
return {
"subtasks": {},
"stuck_subtasks": [
{"subtask_id": sid, "reason": "stuck", "escalated_at": "2024-01-01T00:00:00"}
for sid in stuck_ids
],
"metadata": {"created_at": "2024-01-01T00:00:00", "last_updated": "2024-01-01T00:00:00"},
}
def test_stuck_subtask_is_skipped(self, temp_dir):
"""Stuck subtasks are skipped when selecting the next subtask."""
from progress import get_next_subtask
spec_dir = temp_dir / "spec"
spec_dir.mkdir(parents=True)
# Create plan with two pending subtasks
plan = self._make_plan([
{"id": "stuck-1", "description": "Stuck task", "status": "pending"},
{"id": "good-1", "description": "Normal task", "status": "pending"},
])
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
# Mark stuck-1 as stuck
memory_dir = spec_dir / "memory"
memory_dir.mkdir(parents=True)
history = self._make_attempt_history(["stuck-1"])
(memory_dir / "attempt_history.json").write_text(json.dumps(history))
result = get_next_subtask(spec_dir)
assert result is not None
assert result["id"] == "good-1", "Should skip stuck-1 and select good-1"
def test_normal_subtask_selected_when_stuck_exist(self, temp_dir):
"""Normal pending subtasks are selected even when stuck ones exist."""
from progress import get_next_subtask
spec_dir = temp_dir / "spec"
spec_dir.mkdir(parents=True)
plan = self._make_plan([
{"id": "stuck-a", "description": "Stuck A", "status": "pending"},
{"id": "stuck-b", "description": "Stuck B", "status": "pending"},
{"id": "normal-c", "description": "Normal C", "status": "pending"},
])
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
memory_dir = spec_dir / "memory"
memory_dir.mkdir(parents=True)
history = self._make_attempt_history(["stuck-a", "stuck-b"])
(memory_dir / "attempt_history.json").write_text(json.dumps(history))
result = get_next_subtask(spec_dir)
assert result is not None
assert result["id"] == "normal-c"
def test_no_attempt_history_file(self, temp_dir):
"""When attempt_history.json doesn't exist, normal selection proceeds."""
from progress import get_next_subtask
spec_dir = temp_dir / "spec"
spec_dir.mkdir(parents=True)
plan = self._make_plan([
{"id": "task-1", "description": "Task 1", "status": "pending"},
])
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
# No memory directory or attempt_history.json
result = get_next_subtask(spec_dir)
assert result is not None
assert result["id"] == "task-1"
def test_corrupted_attempt_history_json(self, temp_dir):
"""When attempt_history.json is corrupted, normal selection proceeds."""
from progress import get_next_subtask
spec_dir = temp_dir / "spec"
spec_dir.mkdir(parents=True)
plan = self._make_plan([
{"id": "task-1", "description": "Task 1", "status": "pending"},
])
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
memory_dir = spec_dir / "memory"
memory_dir.mkdir(parents=True)
(memory_dir / "attempt_history.json").write_text("{invalid json!!!")
result = get_next_subtask(spec_dir)
assert result is not None
assert result["id"] == "task-1", "Should still select task when JSON is corrupted"
def test_all_pending_subtasks_stuck_returns_none(self, temp_dir):
"""When ALL pending subtasks are stuck, returns None."""
from progress import get_next_subtask
spec_dir = temp_dir / "spec"
spec_dir.mkdir(parents=True)
plan = self._make_plan([
{"id": "stuck-1", "description": "Stuck 1", "status": "pending"},
{"id": "stuck-2", "description": "Stuck 2", "status": "pending"},
{"id": "done-1", "description": "Done 1", "status": "completed"},
])
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
memory_dir = spec_dir / "memory"
memory_dir.mkdir(parents=True)
history = self._make_attempt_history(["stuck-1", "stuck-2"])
(memory_dir / "attempt_history.json").write_text(json.dumps(history))
result = get_next_subtask(spec_dir)
assert result is None, "Should return None when all pending subtasks are stuck"
+317
View File
@@ -13,6 +13,7 @@ Tests the recovery system functionality including:
import json
import subprocess
import sys
from datetime import datetime
from pathlib import Path
import pytest
@@ -533,6 +534,322 @@ def test_checkpoint_clear_and_reset(test_env):
assert manager2.get_attempt_count("subtask-1") == 2, "subtask-1 history lost"
# =============================================================================
# TIME-WINDOW FILTERING TESTS (get_attempt_count)
# =============================================================================
def test_get_attempt_count_time_window_filtering(test_env):
"""Test that get_attempt_count only counts attempts within the 2-hour window."""
from datetime import timedelta
temp_dir, spec_dir, project_dir = test_env
manager = RecoveryManager(spec_dir, project_dir)
old_time = (datetime.now() - timedelta(hours=3)).isoformat()
recent_time = (datetime.now() - timedelta(minutes=30)).isoformat()
history = manager._load_attempt_history()
history["subtasks"]["test-1"] = {
"attempts": [
{"timestamp": old_time, "approach": "old approach", "success": False},
{"timestamp": recent_time, "approach": "recent approach", "success": False},
],
"status": "failed",
}
manager._save_attempt_history(history)
count = manager.get_attempt_count("test-1")
assert count == 1, "Should only count the recent attempt within 2-hour window"
def test_get_attempt_count_boundary_just_inside_and_outside(test_env):
"""Test attempts just inside and outside the 2-hour cutoff boundary."""
from datetime import timedelta
temp_dir, spec_dir, project_dir = test_env
manager = RecoveryManager(spec_dir, project_dir)
# 1 second inside the window (1h 59m 59s ago) - should be included
inside_time = (datetime.now() - timedelta(seconds=7199)).isoformat()
# 10 seconds outside the window (2h 10s ago) - should be excluded
outside_time = (datetime.now() - timedelta(seconds=7210)).isoformat()
history = manager._load_attempt_history()
history["subtasks"]["test-boundary"] = {
"attempts": [
{"timestamp": inside_time, "approach": "inside window", "success": False},
{"timestamp": outside_time, "approach": "outside window", "success": False},
],
"status": "failed",
}
manager._save_attempt_history(history)
count = manager.get_attempt_count("test-boundary")
assert count == 1, "Attempt inside window should be counted, outside should not"
def test_get_attempt_count_all_outside_window(test_env):
"""Test that all attempts outside the time window returns 0."""
from datetime import timedelta
temp_dir, spec_dir, project_dir = test_env
manager = RecoveryManager(spec_dir, project_dir)
old_time_1 = (datetime.now() - timedelta(hours=5)).isoformat()
old_time_2 = (datetime.now() - timedelta(hours=4)).isoformat()
old_time_3 = (datetime.now() - timedelta(hours=3)).isoformat()
history = manager._load_attempt_history()
history["subtasks"]["test-old"] = {
"attempts": [
{"timestamp": old_time_1, "approach": "old 1", "success": False},
{"timestamp": old_time_2, "approach": "old 2", "success": False},
{"timestamp": old_time_3, "approach": "old 3", "success": False},
],
"status": "failed",
}
manager._save_attempt_history(history)
count = manager.get_attempt_count("test-old")
assert count == 0, "All attempts outside window should result in count of 0"
def test_get_attempt_count_all_recent(test_env):
"""Test that all recent attempts are counted."""
from datetime import timedelta
temp_dir, spec_dir, project_dir = test_env
manager = RecoveryManager(spec_dir, project_dir)
times = [
(datetime.now() - timedelta(minutes=10)).isoformat(),
(datetime.now() - timedelta(minutes=30)).isoformat(),
(datetime.now() - timedelta(minutes=90)).isoformat(),
]
history = manager._load_attempt_history()
history["subtasks"]["test-recent"] = {
"attempts": [
{"timestamp": times[0], "approach": "a1", "success": False},
{"timestamp": times[1], "approach": "a2", "success": False},
{"timestamp": times[2], "approach": "a3", "success": False},
],
"status": "failed",
}
manager._save_attempt_history(history)
count = manager.get_attempt_count("test-recent")
assert count == 3, "All recent attempts should be counted"
def test_get_attempt_count_missing_timestamp_backward_compat(test_env):
"""Test backward compatibility: attempts without timestamps are counted as recent."""
temp_dir, spec_dir, project_dir = test_env
manager = RecoveryManager(spec_dir, project_dir)
history = manager._load_attempt_history()
history["subtasks"]["test-no-ts"] = {
"attempts": [
{"approach": "no timestamp", "success": False},
{"approach": "also no timestamp", "success": False},
],
"status": "failed",
}
manager._save_attempt_history(history)
count = manager.get_attempt_count("test-no-ts")
assert count == 2, "Attempts without timestamps should be counted (backward compat)"
def test_get_attempt_count_invalid_timestamp_backward_compat(test_env):
"""Test backward compatibility: attempts with invalid timestamps are counted as recent."""
temp_dir, spec_dir, project_dir = test_env
manager = RecoveryManager(spec_dir, project_dir)
history = manager._load_attempt_history()
history["subtasks"]["test-bad-ts"] = {
"attempts": [
{"timestamp": "not-a-date", "approach": "bad ts", "success": False},
{"timestamp": "2024-13-99T99:99:99", "approach": "invalid ts", "success": False},
],
"status": "failed",
}
manager._save_attempt_history(history)
count = manager.get_attempt_count("test-bad-ts")
assert count == 2, "Attempts with invalid timestamps should be counted (backward compat)"
def test_get_attempt_count_mixed_timestamps(test_env):
"""Test mixed scenario: some attempts with timestamps, some without."""
from datetime import timedelta
temp_dir, spec_dir, project_dir = test_env
manager = RecoveryManager(spec_dir, project_dir)
old_time = (datetime.now() - timedelta(hours=5)).isoformat()
recent_time = (datetime.now() - timedelta(minutes=10)).isoformat()
history = manager._load_attempt_history()
history["subtasks"]["test-mixed"] = {
"attempts": [
{"timestamp": old_time, "approach": "old", "success": False},
{"timestamp": recent_time, "approach": "recent", "success": False},
{"approach": "no timestamp", "success": False},
{"timestamp": "garbage", "approach": "bad timestamp", "success": False},
],
"status": "failed",
}
manager._save_attempt_history(history)
# old_time: excluded (outside window)
# recent_time: included (within window)
# no timestamp: included (backward compat)
# bad timestamp: included (backward compat)
count = manager.get_attempt_count("test-mixed")
assert count == 3, "Should count recent + missing/invalid timestamps, exclude old"
# =============================================================================
# ATTEMPT HISTORY TRIMMING TESTS (record_attempt)
# =============================================================================
def test_record_attempt_trimming_at_51(test_env):
"""Test that recording the 51st attempt triggers trimming to 50."""
temp_dir, spec_dir, project_dir = test_env
manager = RecoveryManager(spec_dir, project_dir)
# Manually inject 50 attempts
history = manager._load_attempt_history()
history["subtasks"]["trim-test"] = {
"attempts": [
{
"session": i,
"timestamp": datetime.now().isoformat(),
"approach": f"approach-{i}",
"success": False,
"error": None,
}
for i in range(50)
],
"status": "failed",
}
manager._save_attempt_history(history)
# Record the 51st attempt
manager.record_attempt("trim-test", 51, False, "approach-50", "error")
history = manager._load_attempt_history()
attempts = history["subtasks"]["trim-test"]["attempts"]
assert len(attempts) == 50, "Should trim to 50 after exceeding cap"
def test_record_attempt_trimming_keeps_newest(test_env):
"""Test that trimming keeps the newest 50 attempts, not the oldest."""
temp_dir, spec_dir, project_dir = test_env
manager = RecoveryManager(spec_dir, project_dir)
# Inject 50 attempts with identifiable approaches
history = manager._load_attempt_history()
history["subtasks"]["trim-order"] = {
"attempts": [
{
"session": i,
"timestamp": datetime.now().isoformat(),
"approach": f"old-approach-{i}",
"success": False,
"error": None,
}
for i in range(50)
],
"status": "failed",
}
manager._save_attempt_history(history)
# Record new attempt (triggers trim)
manager.record_attempt("trim-order", 99, False, "newest-approach", "error")
history = manager._load_attempt_history()
attempts = history["subtasks"]["trim-order"]["attempts"]
assert len(attempts) == 50
# The oldest attempt (old-approach-0) should be gone
approaches = [a["approach"] for a in attempts]
assert "old-approach-0" not in approaches, "Oldest attempt should be trimmed"
# The newest attempt should be present
assert "newest-approach" in approaches, "Newest attempt should be kept"
# old-approach-1 should be the oldest remaining
assert "old-approach-1" in approaches, "Second oldest should now be first"
def test_record_attempt_no_trimming_at_exactly_50(test_env):
"""Test that exactly 50 attempts does not trigger trimming."""
temp_dir, spec_dir, project_dir = test_env
manager = RecoveryManager(spec_dir, project_dir)
# Inject 49 attempts
history = manager._load_attempt_history()
history["subtasks"]["no-trim"] = {
"attempts": [
{
"session": i,
"timestamp": datetime.now().isoformat(),
"approach": f"approach-{i}",
"success": False,
"error": None,
}
for i in range(49)
],
"status": "failed",
}
manager._save_attempt_history(history)
# Record the 50th attempt (should NOT trigger trimming)
manager.record_attempt("no-trim", 50, False, "approach-49", "error")
history = manager._load_attempt_history()
attempts = history["subtasks"]["no-trim"]["attempts"]
assert len(attempts) == 50, "Exactly 50 should not trigger trimming"
# First attempt should still be present
assert attempts[0]["approach"] == "approach-0", "No attempts should be removed"
def test_record_attempt_trimming_from_100(test_env):
"""Test trimming from 100 attempts keeps exactly 50."""
temp_dir, spec_dir, project_dir = test_env
manager = RecoveryManager(spec_dir, project_dir)
# Inject 100 attempts
history = manager._load_attempt_history()
history["subtasks"]["big-trim"] = {
"attempts": [
{
"session": i,
"timestamp": datetime.now().isoformat(),
"approach": f"approach-{i}",
"success": False,
"error": None,
}
for i in range(100)
],
"status": "failed",
}
manager._save_attempt_history(history)
# Record attempt 101 (triggers trim from 101 -> 50)
manager.record_attempt("big-trim", 101, False, "approach-100", "error")
history = manager._load_attempt_history()
attempts = history["subtasks"]["big-trim"]["attempts"]
assert len(attempts) == 50, "Should trim to exactly 50"
# Verify newest are kept
approaches = [a["approach"] for a in attempts]
assert "approach-100" in approaches, "Newest attempt should be kept"
assert "approach-0" not in approaches, "Oldest attempts should be trimmed"
assert "approach-50" not in approaches, "Mid-range old attempts should be trimmed"
def run_all_tests():
"""Run all tests."""
print("=" * 70)