claude-evolve 1.8.11 → 1.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -362,6 +362,12 @@ print(max_id + 1)
362
362
  "
363
363
  }
364
364
 
365
+ # AIDEV-NOTE: This function had a critical race condition bug that caused wrong rows to be updated
366
+ # The bug occurred when parallel processes modified the main CSV between temp CSV creation and append.
367
+ # FIX: Now requires original_main_csv_lines parameter (6th arg) to track the exact line count at copy time.
368
+ # This ensures we always append the correct new rows from temp CSV, regardless of concurrent modifications.
369
+ # Without this fix, the system would update wrong IDs (e.g., claim to add gen81 but update gen80 instead).
370
+ #
365
371
  # Validate that AI directly modified the CSV file
366
372
  validate_direct_csv_modification() {
367
373
  local temp_csv="$1"
@@ -369,6 +375,7 @@ validate_direct_csv_modification() {
369
375
  local idea_type="$3"
370
376
  local ai_model="${4:-}" # AI model that generated the ideas
371
377
  local expected_ids="${5:-}" # Optional: comma or space separated list of expected IDs
378
+ local original_main_csv_lines="${6:-}" # CRITICAL: Line count of main CSV when temp CSV was created
372
379
 
373
380
  # Check if the file was actually modified
374
381
  if [[ ! -f "$temp_csv" ]]; then
@@ -376,32 +383,37 @@ validate_direct_csv_modification() {
376
383
  return 1
377
384
  fi
378
385
 
379
- # Get the count before modification from the temp CSV (which was copied from original before AI ran)
380
- # We need to track this before the AI runs by reading from the beginning state
381
- # First, get a fresh count from the current main CSV (which reflects any previous operations in this session)
382
- local current_original_count
383
- current_original_count=$(grep -v '^[[:space:]]*$' "$FULL_CSV_PATH" | tail -n +2 | wc -l | tr -d '[:space:]')
384
-
385
386
  # Count data rows in the modified temp CSV
386
387
  local new_count
387
388
  new_count=$(grep -v '^[[:space:]]*$' "$temp_csv" | tail -n +2 | wc -l | tr -d '[:space:]')
388
389
 
390
+ # If original line count wasn't provided, fall back to current main CSV count (old behavior)
391
+ # This preserves backward compatibility but may have race conditions
392
+ if [[ -z "$original_main_csv_lines" ]]; then
393
+ echo "[WARN] No original line count provided - using current main CSV count (may cause race conditions)" >&2
394
+ original_main_csv_lines=$(wc -l < "$FULL_CSV_PATH" | tr -d '[:space:]')
395
+ fi
396
+
397
+ # Calculate how many data rows the temp CSV started with (before stubs were added)
398
+ # This should match the original main CSV line count (including header)
399
+ local original_data_rows=$((original_main_csv_lines - 1)) # Subtract header
400
+
401
+ # Calculate how many rows were actually added to temp CSV
402
+ local added_count=$((new_count - original_data_rows))
389
403
 
390
404
  # Check if AI overwrote the file instead of appending
391
- if [[ $new_count -lt $current_original_count ]]; then
392
- echo "[ERROR] AI overwrote the CSV file instead of appending ($new_count < $current_original_count)" >&2
405
+ if [[ $new_count -lt $original_data_rows ]]; then
406
+ echo "[ERROR] AI overwrote the CSV file instead of appending ($new_count < $original_data_rows)" >&2
393
407
  head -10 "$temp_csv" >&2
394
408
  return 1
395
409
  fi
396
410
 
397
411
  # Check if no changes were made
398
- if [[ $new_count -eq $current_original_count ]]; then
399
- echo "[ERROR] CSV file wasn't modified - same number of data rows ($new_count = $current_original_count)" >&2
412
+ if [[ $new_count -eq $original_data_rows ]]; then
413
+ echo "[ERROR] CSV file wasn't modified - same number of data rows ($new_count = $original_data_rows)" >&2
400
414
  head -10 "$temp_csv" >&2
401
415
  return 1
402
416
  fi
403
-
404
- local added_count=$((new_count - current_original_count))
405
417
  if [[ $added_count -ne $expected_count ]]; then
406
418
  echo "[ERROR] Expected to add $expected_count ideas but only added $added_count" >&2
407
419
  echo "[ERROR] Ideation failed - rejecting partial results to prevent endless loops" >&2
@@ -434,43 +446,47 @@ validate_direct_csv_modification() {
434
446
 
435
447
  # Use proper locking to safely update the CSV
436
448
  echo "[INFO] Acquiring CSV lock to apply changes..."
437
-
449
+
438
450
  # Set the lockfile path
439
451
  CSV_LOCKFILE="$FULL_EVOLUTION_DIR/.evolution.csv.lock"
440
-
452
+
441
453
  if ! acquire_csv_lock; then
442
454
  echo "[ERROR] Failed to acquire CSV lock for update" >&2
443
455
  rm -f "$temp_csv"
444
456
  return 1
445
457
  fi
446
-
447
- # Get just the new entries (skip header and existing entries)
448
- local original_line_count=$(wc -l < "$FULL_CSV_PATH" | tr -d '[:space:]')
449
-
450
- # Append only the new lines from temp CSV to the main CSV
451
- tail -n +$((original_line_count + 1)) "$temp_csv" >> "$FULL_CSV_PATH"
452
-
458
+
459
+ # CRITICAL FIX: Use the original line count (when temp CSV was created) to determine which lines to append
460
+ # This prevents race conditions where other processes modify the main CSV between temp CSV creation and append
461
+ # Append only the NEW lines from temp CSV (those added after the original content)
462
+ echo "[DEBUG] Appending last $added_count rows from temp CSV (from line $((original_main_csv_lines + 1)) onwards)" >&2
463
+ tail -n +$((original_main_csv_lines + 1)) "$temp_csv" >> "$FULL_CSV_PATH"
464
+
465
+ # Get the IDs that were actually added by reading them from temp CSV (not main CSV)
466
+ # This avoids race conditions where other processes add rows to main CSV
467
+ local new_ids
468
+ new_ids=$(tail -n $added_count "$temp_csv" | grep -v "^id," | cut -d',' -f1 | tr -d '"')
469
+ echo "[DEBUG] IDs being added: $new_ids" >&2
470
+
453
471
  # Clean up temp file
454
472
  rm -f "$temp_csv"
455
-
473
+
456
474
  # Update idea-LLM field for newly added rows if model is known
457
475
  if [[ -n "$ai_model" ]]; then
458
476
  echo "[INFO] Recording that $ai_model generated the ideas" >&2
459
- # Get the IDs of the newly added rows (skip header line and strip quotes)
460
- local new_ids
461
- new_ids=$(tail -n $added_count "$FULL_CSV_PATH" | grep -v "^id," | cut -d',' -f1 | tr -d '"')
462
-
477
+
463
478
  # Update each new row with the model that generated it
464
479
  for id in $new_ids; do
465
480
  if [[ -n "$id" && "$id" != "id" ]]; then
481
+ echo "[DEBUG] Updating field for $id" >&2
466
482
  "$PYTHON_CMD" "$SCRIPT_DIR/../lib/evolution_csv.py" "$FULL_CSV_PATH" field "$id" "idea-LLM" "$ai_model" || echo "[WARN] Failed to update $id" >&2
467
483
  fi
468
484
  done
469
485
  fi
470
-
486
+
471
487
  # Release the lock
472
488
  release_csv_lock
473
-
489
+
474
490
  echo "[INFO] Successfully added $added_count $idea_type ideas to CSV"
475
491
  return 0
476
492
  }
@@ -1001,6 +1017,12 @@ generate_novel_ideas_direct() {
1001
1017
  local temp_csv="$FULL_EVOLUTION_DIR/temp-csv-$$.csv"
1002
1018
  cp "$FULL_CSV_PATH" "$temp_csv"
1003
1019
 
1020
+ # CRITICAL: Capture the original line count immediately after copying
1021
+ # This is needed to correctly append rows later, preventing race conditions
1022
+ local original_csv_lines
1023
+ original_csv_lines=$(wc -l < "$temp_csv" | tr -d '[:space:]')
1024
+ echo "[DEBUG] Original CSV has $original_csv_lines lines (including header)" >&2
1025
+
1004
1026
  # Pre-populate the CSV with stub rows containing the correct IDs
1005
1027
  # This ensures the AI can't possibly use wrong IDs - it just fills in descriptions
1006
1028
  echo "[INFO] Pre-populating CSV with stub rows: $required_ids_str"
@@ -1098,10 +1120,10 @@ CRITICAL: Do NOT use any git commands (git add, git commit, git reset, etc.). On
1098
1120
 
1099
1121
  # Restore working directory
1100
1122
  cd "$original_pwd"
1101
-
1102
1123
 
1103
1124
  # Validate that the CSV file was actually modified with correct IDs
1104
- if ! validate_direct_csv_modification "$temp_csv" "$count" "novel" "$ai_response" "$required_ids_str"; then
1125
+ # Pass original_csv_lines to prevent race conditions
1126
+ if ! validate_direct_csv_modification "$temp_csv" "$count" "novel" "$ai_response" "$required_ids_str" "$original_csv_lines"; then
1105
1127
  rm -f "$temp_csv"
1106
1128
  return 1
1107
1129
  fi
@@ -1135,6 +1157,11 @@ generate_hill_climbing_direct() {
1135
1157
  local temp_csv="$FULL_EVOLUTION_DIR/temp-csv-$$.csv"
1136
1158
  cp "$FULL_CSV_PATH" "$temp_csv"
1137
1159
 
1160
+ # CRITICAL: Capture the original line count immediately after copying
1161
+ local original_csv_lines
1162
+ original_csv_lines=$(wc -l < "$temp_csv" | tr -d '[:space:]')
1163
+ echo "[DEBUG] Original CSV has $original_csv_lines lines (including header)" >&2
1164
+
1138
1165
  # Extract just the IDs from top performers for clarity (needed before pre-populating)
1139
1166
  local valid_parent_ids
1140
1167
  valid_parent_ids=$(echo "$top_performers" | cut -d',' -f1 | paste -sd ',' -)
@@ -1230,10 +1257,10 @@ CRITICAL INSTRUCTIONS:
1230
1257
 
1231
1258
  # Restore working directory
1232
1259
  cd "$original_pwd"
1233
-
1234
1260
 
1235
1261
  # Validate that the CSV file was actually modified with correct IDs
1236
- if ! validate_direct_csv_modification "$temp_csv" "$count" "hill-climbing" "$ai_response" "$required_ids_str"; then
1262
+ # Pass original_csv_lines to prevent race conditions
1263
+ if ! validate_direct_csv_modification "$temp_csv" "$count" "hill-climbing" "$ai_response" "$required_ids_str" "$original_csv_lines"; then
1237
1264
  rm -f "$temp_csv"
1238
1265
  return 1
1239
1266
  fi
@@ -1267,6 +1294,11 @@ generate_structural_mutation_direct() {
1267
1294
  local temp_csv="$FULL_EVOLUTION_DIR/temp-csv-$$.csv"
1268
1295
  cp "$FULL_CSV_PATH" "$temp_csv"
1269
1296
 
1297
+ # CRITICAL: Capture the original line count immediately after copying
1298
+ local original_csv_lines
1299
+ original_csv_lines=$(wc -l < "$temp_csv" | tr -d '[:space:]')
1300
+ echo "[DEBUG] Original CSV has $original_csv_lines lines (including header)" >&2
1301
+
1270
1302
  # Extract just the IDs from top performers for clarity (needed before pre-populating)
1271
1303
  local valid_parent_ids
1272
1304
  valid_parent_ids=$(echo "$top_performers" | cut -d',' -f1 | paste -sd ',' -)
@@ -1352,10 +1384,10 @@ CRITICAL INSTRUCTIONS:
1352
1384
 
1353
1385
  # Restore working directory
1354
1386
  cd "$original_pwd"
1355
-
1356
1387
 
1357
1388
  # Validate that the CSV file was actually modified with correct IDs
1358
- if ! validate_direct_csv_modification "$temp_csv" "$count" "structural" "$ai_response" "$required_ids_str"; then
1389
+ # Pass original_csv_lines to prevent race conditions
1390
+ if ! validate_direct_csv_modification "$temp_csv" "$count" "structural" "$ai_response" "$required_ids_str" "$original_csv_lines"; then
1359
1391
  rm -f "$temp_csv"
1360
1392
  return 1
1361
1393
  fi
@@ -1389,6 +1421,11 @@ generate_crossover_direct() {
1389
1421
  local temp_csv="$FULL_EVOLUTION_DIR/temp-csv-$$.csv"
1390
1422
  cp "$FULL_CSV_PATH" "$temp_csv"
1391
1423
 
1424
+ # CRITICAL: Capture the original line count immediately after copying
1425
+ local original_csv_lines
1426
+ original_csv_lines=$(wc -l < "$temp_csv" | tr -d '[:space:]')
1427
+ echo "[DEBUG] Original CSV has $original_csv_lines lines (including header)" >&2
1428
+
1392
1429
  # Extract just the IDs from top performers for clarity (needed before pre-populating)
1393
1430
  local valid_parent_ids
1394
1431
  valid_parent_ids=$(echo "$top_performers" | cut -d',' -f1 | paste -sd ',' -)
@@ -1474,10 +1511,10 @@ CRITICAL INSTRUCTIONS:
1474
1511
 
1475
1512
  # Restore working directory
1476
1513
  cd "$original_pwd"
1477
-
1478
1514
 
1479
1515
  # Validate that the CSV file was actually modified with correct IDs
1480
- if ! validate_direct_csv_modification "$temp_csv" "$count" "crossover" "$ai_response" "$required_ids_str"; then
1516
+ # Pass original_csv_lines to prevent race conditions
1517
+ if ! validate_direct_csv_modification "$temp_csv" "$count" "crossover" "$ai_response" "$required_ids_str" "$original_csv_lines"; then
1481
1518
  rm -f "$temp_csv"
1482
1519
  return 1
1483
1520
  fi
@@ -1496,7 +1533,12 @@ ideate_ai_legacy() {
1496
1533
  # Create temporary CSV copy in evolution directory (so AI can access it)
1497
1534
  local temp_csv="$FULL_EVOLUTION_DIR/temp-csv-$$.csv"
1498
1535
  cp "$FULL_CSV_PATH" "$temp_csv"
1499
-
1536
+
1537
+ # CRITICAL: Capture the original line count immediately after copying
1538
+ local original_csv_lines
1539
+ original_csv_lines=$(wc -l < "$temp_csv" | tr -d '[:space:]')
1540
+ echo "[DEBUG] Original CSV has $original_csv_lines lines (including header)" >&2
1541
+
1500
1542
  echo "[INFO] Generating $TOTAL_IDEAS ideas (legacy mode)..."
1501
1543
 
1502
1544
  # Get top performers for context
@@ -1580,14 +1622,14 @@ CRITICAL: Do NOT use any git commands (git add, git commit, git reset, etc.). On
1580
1622
 
1581
1623
  # Restore working directory
1582
1624
  cd "$original_pwd"
1583
-
1584
-
1625
+
1585
1626
  # Validate that the CSV file was actually modified
1586
- if ! validate_direct_csv_modification "$temp_csv" "$TOTAL_IDEAS" "mixed" "$ai_response"; then
1627
+ # Pass original_csv_lines to prevent race conditions
1628
+ if ! validate_direct_csv_modification "$temp_csv" "$TOTAL_IDEAS" "mixed" "$ai_response" "" "$original_csv_lines"; then
1587
1629
  rm -f "$temp_csv"
1588
1630
  return 1
1589
1631
  fi
1590
-
1632
+
1591
1633
  echo "[INFO] Legacy ideas generated"
1592
1634
  return 0
1593
1635
  }
@@ -407,19 +407,41 @@ with EvolutionCSV('$FULL_CSV_PATH') as csv:
407
407
  eval_arg=""
408
408
  fi
409
409
  local eval_cmd=("$PYTHON_CMD" "$FULL_EVALUATOR_PATH" "$eval_arg")
410
-
410
+
411
411
  # Add memory limiting if configured
412
+ # CRITICAL: Use multiple layers of protection (ulimit + Python wrapper + monitoring)
413
+ local memory_protection=""
412
414
  if [[ -n "$MEMORY_LIMIT_MB" ]] && [[ "$MEMORY_LIMIT_MB" -gt 0 ]]; then
415
+ # Layer 1: ulimit for hard memory limit (kernel-enforced)
416
+ # IMPORTANT: Use -m (RSS) not -v (virtual memory) because:
417
+ # - Neural networks use mmap() which bypasses RLIMIT_AS (-v)
418
+ # - RSS limit is more reliable for actual memory consumption
419
+ # Convert MB to KB for ulimit
420
+ local memory_limit_kb=$((MEMORY_LIMIT_MB * 1024))
421
+
422
+ # Try -m first (RSS limit), fall back to -v if not supported
423
+ if ulimit -m $memory_limit_kb 2>/dev/null; then
424
+ memory_protection="ulimit -m $memory_limit_kb 2>/dev/null; "
425
+ echo "[MEMORY] Layer 1: ulimit -m ${memory_limit_kb}KB (RSS limit - catches neural networks)" >&2
426
+ else
427
+ memory_protection="ulimit -v $memory_limit_kb 2>/dev/null; "
428
+ echo "[MEMORY] Layer 1: ulimit -v ${memory_limit_kb}KB (fallback - may not catch neural networks)" >&2
429
+ fi
430
+
431
+ # Layer 2: Python wrapper with PROCESS TREE monitoring (backup protection)
413
432
  eval_cmd=("$PYTHON_CMD" "$SCRIPT_DIR/../lib/memory_limit_wrapper.py" "$MEMORY_LIMIT_MB" "${eval_cmd[@]}")
433
+
434
+ echo "[MEMORY] Layer 2: Python process tree monitoring (kills entire subprocess tree)" >&2
414
435
  fi
415
-
436
+
416
437
  # Add timeout if configured
417
438
  [[ -n "$timeout_seconds" ]] && eval_cmd=(timeout "$timeout_seconds" "${eval_cmd[@]}")
418
-
419
- # Run evaluation with tee to both display and capture output
439
+
440
+ # Run evaluation with memory protection, tee to both display and capture output
420
441
  # Use stdbuf to disable buffering for real-time output
421
442
  # IMPORTANT: Use PIPESTATUS to get the exit code of the evaluation command, not tee
422
- stdbuf -o0 -e0 "${eval_cmd[@]}" 2>&1 | tee "$eval_output_file" >&2
443
+ # The subshell ensures ulimit is applied before the command runs
444
+ stdbuf -o0 -e0 bash -c "${memory_protection}$(printf '%q ' "${eval_cmd[@]}")" 2>&1 | tee "$eval_output_file" >&2
423
445
  local eval_exit_code=${PIPESTATUS[0]} # Get exit code of first command in pipe
424
446
 
425
447
  if [[ $eval_exit_code -eq 0 ]]; then
@@ -4,6 +4,16 @@ Memory-limited execution wrapper for claude-evolve evaluations.
4
4
 
5
5
  This script runs a command with memory limits to prevent runaway algorithms
6
6
  from consuming all system memory and crashing the machine.
7
+
8
+ CRITICAL: Multi-layer protection approach (both must work together):
9
+ 1. ulimit -m (RSS limit) set by calling shell script - kernel-enforced, catches neural networks
10
+ 2. This Python wrapper monitors ENTIRE PROCESS TREE every 0.1s and kills if limit exceeded
11
+
12
+ AIDEV-NOTE: Previous bugs fixed:
13
+ - ulimit -v (virtual memory) doesn't catch neural networks that use mmap()
14
+ - Was only monitoring direct child, not entire process tree (missed grandchildren)
15
+ - Monitoring interval was 0.5s - too slow for fast memory allocations
16
+ - Resource limit failures were silently ignored instead of failing fast
7
17
  """
8
18
  import sys
9
19
  import os
@@ -11,119 +21,197 @@ import subprocess
11
21
  import signal
12
22
  import time
13
23
  import resource
14
- from typing import Optional
24
+ from typing import Optional, Tuple
15
25
 
16
- def set_memory_limit(limit_mb: int) -> None:
17
- """Set memory limit in MB using resource module."""
26
+ def verify_memory_limit_set(limit_mb: int) -> Tuple[bool, str]:
27
+ """Verify that memory limits are actually enforced."""
28
+ try:
29
+ limit_bytes = limit_mb * 1024 * 1024
30
+
31
+ # Check RLIMIT_AS (virtual memory)
32
+ soft_as, hard_as = resource.getrlimit(resource.RLIMIT_AS)
33
+ if soft_as != resource.RLIM_INFINITY and soft_as <= limit_bytes * 1.1:
34
+ return True, f"RLIMIT_AS set to {soft_as / (1024*1024):.0f}MB"
35
+
36
+ # Check RLIMIT_DATA (data segment)
37
+ try:
38
+ soft_data, hard_data = resource.getrlimit(resource.RLIMIT_DATA)
39
+ if soft_data != resource.RLIM_INFINITY and soft_data <= limit_bytes * 1.1:
40
+ return True, f"RLIMIT_DATA set to {soft_data / (1024*1024):.0f}MB"
41
+ except (OSError, ValueError):
42
+ pass
43
+
44
+ return False, "No hard memory limits detected"
45
+ except Exception as e:
46
+ return False, f"Error checking limits: {e}"
47
+
48
+ def set_memory_limit(limit_mb: int) -> bool:
49
+ """
50
+ Set memory limit in MB using resource module.
51
+ Returns True if successful, False otherwise.
52
+ """
18
53
  try:
19
54
  # Convert MB to bytes
20
55
  limit_bytes = limit_mb * 1024 * 1024
21
-
56
+
22
57
  # Set virtual memory limit (address space)
23
58
  # On macOS this is the most reliable way to limit memory
24
59
  resource.setrlimit(resource.RLIMIT_AS, (limit_bytes, limit_bytes))
25
-
60
+
26
61
  # Also try to set data segment limit if available
27
62
  try:
28
63
  resource.setrlimit(resource.RLIMIT_DATA, (limit_bytes, limit_bytes))
29
64
  except (OSError, ValueError):
30
65
  # Not available on all systems
31
66
  pass
32
-
33
- print(f"[MEMORY] Set memory limit to {limit_mb}MB", file=sys.stderr)
34
-
67
+
68
+ # Verify it was actually set
69
+ is_set, msg = verify_memory_limit_set(limit_mb)
70
+ if is_set:
71
+ print(f"[MEMORY] ✓ Hard limit enforced: {msg}", file=sys.stderr)
72
+ return True
73
+ else:
74
+ print(f"[MEMORY] ✗ Hard limit NOT enforced: {msg}", file=sys.stderr)
75
+ return False
76
+
35
77
  except (OSError, ValueError) as e:
36
- print(f"[MEMORY] Warning: Could not set memory limit: {e}", file=sys.stderr)
78
+ print(f"[MEMORY] Could not set memory limit: {e}", file=sys.stderr)
79
+ return False
80
+
81
+ def get_process_tree_memory_native(pid: int) -> float:
82
+ """Get total memory usage of entire process group using native ps command."""
83
+ try:
84
+ # Get the process group ID
85
+ pgid = os.getpgid(pid)
86
+
87
+ # Get all processes in the process group
88
+ ps_result = subprocess.run(
89
+ ["ps", "-o", "rss=", "-g", str(pgid)],
90
+ capture_output=True,
91
+ text=True,
92
+ timeout=1
93
+ )
94
+
95
+ if ps_result.returncode != 0:
96
+ return 0.0
97
+
98
+ # Sum all RSS values from the process group
99
+ total_rss_kb = 0
100
+ for line in ps_result.stdout.strip().split('\n'):
101
+ line = line.strip()
102
+ if line:
103
+ try:
104
+ total_rss_kb += int(line)
105
+ except ValueError:
106
+ continue
107
+
108
+ # Convert KB to MB
109
+ return total_rss_kb / 1024.0
110
+ except Exception:
111
+ return 0.0
37
112
 
38
113
  def monitor_memory_usage_native(process: subprocess.Popen, limit_mb: int) -> Optional[str]:
39
- """Monitor process memory usage using native tools and kill if it exceeds limits."""
40
- # print(f"[MEMORY] Starting native monitoring for PID {process.pid} with limit {limit_mb}MB", file=sys.stderr)
41
-
114
+ """Monitor ENTIRE PROCESS TREE memory usage using native tools and kill if it exceeds limits."""
115
+ print(f"[MEMORY] Monitoring process tree from root PID {process.pid} (limit: {limit_mb}MB)", file=sys.stderr)
116
+
42
117
  while process.poll() is None:
43
118
  try:
44
- # Use ps command to get memory usage
45
- ps_result = subprocess.run(
46
- ["ps", "-o", "rss=", "-p", str(process.pid)],
47
- capture_output=True,
48
- text=True,
49
- timeout=1
50
- )
51
-
52
- if ps_result.returncode == 0 and ps_result.stdout.strip():
53
- # ps returns RSS in KB, convert to MB
54
- memory_kb = int(ps_result.stdout.strip())
55
- memory_mb = memory_kb / 1024
56
-
57
- # print(f"[MEMORY] PID {process.pid} using {memory_mb:.1f}MB (limit: {limit_mb}MB)", file=sys.stderr)
58
-
59
- if memory_mb > limit_mb:
60
- print(f"[MEMORY] Process exceeded {limit_mb}MB limit (using {memory_mb:.1f}MB), terminating", file=sys.stderr)
61
- # Kill the entire process group - fix race condition
62
- try:
63
- pgid = os.getpgid(process.pid)
64
- os.killpg(pgid, signal.SIGTERM)
65
- except ProcessLookupError:
66
- return f"Memory limit exceeded: {memory_mb:.1f}MB > {limit_mb}MB"
67
-
68
- time.sleep(2) # Give it time to cleanup
69
-
70
- try:
71
- if process.poll() is None:
72
- pgid = os.getpgid(process.pid)
73
- os.killpg(pgid, signal.SIGKILL)
74
- except ProcessLookupError:
75
- pass
119
+ # Get total memory for entire process tree
120
+ memory_mb = get_process_tree_memory_native(process.pid)
121
+
122
+ if memory_mb > limit_mb:
123
+ print(f"[MEMORY] Process tree exceeded {limit_mb}MB limit (using {memory_mb:.1f}MB), terminating entire tree", file=sys.stderr)
124
+ # Kill the entire process group
125
+ try:
126
+ pgid = os.getpgid(process.pid)
127
+ os.killpg(pgid, signal.SIGTERM)
128
+ except ProcessLookupError:
76
129
  return f"Memory limit exceeded: {memory_mb:.1f}MB > {limit_mb}MB"
77
-
78
- time.sleep(0.5) # Check every 500ms
79
-
130
+
131
+ time.sleep(2) # Give it time to cleanup
132
+
133
+ try:
134
+ if process.poll() is None:
135
+ pgid = os.getpgid(process.pid)
136
+ os.killpg(pgid, signal.SIGKILL)
137
+ except ProcessLookupError:
138
+ pass
139
+ return f"Memory limit exceeded: {memory_mb:.1f}MB > {limit_mb}MB"
140
+
141
+ time.sleep(0.1) # Check every 100ms for faster response
142
+
80
143
  except (subprocess.TimeoutExpired, ValueError, ProcessLookupError):
81
144
  # Process might have terminated or ps command failed
82
- time.sleep(0.5)
145
+ time.sleep(0.1)
83
146
  continue
84
-
85
- # print(f"[MEMORY] Monitoring stopped for PID {process.pid}", file=sys.stderr)
147
+
86
148
  return None
87
149
 
150
+ def get_process_tree_memory_psutil(ps_process) -> float:
151
+ """Get total memory usage of entire process tree using psutil."""
152
+ try:
153
+ import psutil
154
+ total_mb = 0.0
155
+
156
+ # Get memory of root process
157
+ try:
158
+ total_mb += ps_process.memory_info().rss / (1024 * 1024)
159
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
160
+ return 0.0
161
+
162
+ # Get memory of all children (recursive)
163
+ try:
164
+ for child in ps_process.children(recursive=True):
165
+ try:
166
+ total_mb += child.memory_info().rss / (1024 * 1024)
167
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
168
+ continue
169
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
170
+ pass
171
+
172
+ return total_mb
173
+ except ImportError:
174
+ return 0.0
175
+
88
176
  def monitor_memory_usage(process: subprocess.Popen, limit_mb: int) -> Optional[str]:
89
- """Monitor process memory usage and kill if it exceeds limits."""
177
+ """Monitor ENTIRE PROCESS TREE memory usage and kill if it exceeds limits."""
90
178
  try:
91
179
  import psutil
92
180
  ps_process = psutil.Process(process.pid)
93
-
181
+ print(f"[MEMORY] Monitoring process tree from root PID {process.pid} (limit: {limit_mb}MB, using psutil)", file=sys.stderr)
182
+
94
183
  while process.poll() is None:
95
184
  try:
96
- # Get memory usage in MB
97
- memory_info = ps_process.memory_info()
98
- memory_mb = memory_info.rss / (1024 * 1024)
99
-
185
+ # Get total memory for entire process tree
186
+ memory_mb = get_process_tree_memory_psutil(ps_process)
187
+
100
188
  if memory_mb > limit_mb:
101
- print(f"[MEMORY] Process exceeded {limit_mb}MB limit (using {memory_mb:.1f}MB), terminating", file=sys.stderr)
102
- # Kill the entire process group - fix race condition
189
+ print(f"[MEMORY] Process tree exceeded {limit_mb}MB limit (using {memory_mb:.1f}MB), terminating entire tree", file=sys.stderr)
190
+ # Kill the entire process group
103
191
  try:
104
192
  pgid = os.getpgid(process.pid)
105
193
  os.killpg(pgid, signal.SIGTERM)
106
194
  except ProcessLookupError:
107
195
  return f"Memory limit exceeded: {memory_mb:.1f}MB > {limit_mb}MB"
108
-
196
+
109
197
  time.sleep(2) # Give it time to cleanup
110
-
198
+
111
199
  try:
112
200
  if process.poll() is None:
113
- pgid = os.getpgid(process.pid)
201
+ pgid = os.getpgid(process.pid)
114
202
  os.killpg(pgid, signal.SIGKILL)
115
203
  except ProcessLookupError:
116
204
  pass
117
205
  return f"Memory limit exceeded: {memory_mb:.1f}MB > {limit_mb}MB"
118
-
119
- time.sleep(0.5) # Check every 500ms
206
+
207
+ time.sleep(0.1) # Check every 100ms for faster response
120
208
  except (psutil.NoSuchProcess, psutil.AccessDenied):
121
209
  # Process already terminated
122
210
  break
123
211
  except ImportError:
124
212
  # psutil not available, use native monitoring
125
213
  return monitor_memory_usage_native(process, limit_mb)
126
-
214
+
127
215
  return None
128
216
 
129
217
  def validate_memory_limit(limit_mb: int) -> bool:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-evolve",
3
- "version": "1.8.11",
3
+ "version": "1.8.13",
4
4
  "bin": {
5
5
  "claude-evolve": "./bin/claude-evolve",
6
6
  "claude-evolve-main": "./bin/claude-evolve-main",