@@ -313,14 +313,52 @@ def _format_eta(self) -> str:
313313 async def _await_idle (self ) -> None :
314314 """Block until there are no programs in QUEUED or RUNNING."""
315315 t0 = time .monotonic ()
316- while await self ._has_active_dags ():
316+ ghost_checked = False
317+ while True :
318+ has_active = await self ._has_active_dags ()
319+ if not has_active :
320+ break
321+
317322 elapsed = time .monotonic () - t0
318323 if elapsed > 30 and int (elapsed ) % 60 < self .config .loop_interval :
319324 logger .info (
320325 "[EvolutionEngine] gen={} Waiting for idle ({:.0f}s elapsed)" ,
321326 self .metrics .total_generations ,
322327 elapsed ,
323328 )
329+ # Ghost safety: after 30s, verify counts with full fetch (once)
330+ if elapsed > 30 and not ghost_checked :
331+ ghost_checked = True
332+ real_q = len (
333+ await self .storage .get_all_by_status (ProgramState .QUEUED .value )
334+ )
335+ real_r = len (
336+ await self .storage .get_all_by_status (ProgramState .RUNNING .value )
337+ )
338+ if real_q == 0 and real_r == 0 :
339+ # Clean up ghost IDs from status sets
340+ queued_ids = await self .storage .get_ids_by_status (
341+ ProgramState .QUEUED .value
342+ )
343+ running_ids = await self .storage .get_ids_by_status (
344+ ProgramState .RUNNING .value
345+ )
346+ if queued_ids :
347+ await self .storage .remove_ids_from_status_set (
348+ ProgramState .QUEUED .value , queued_ids
349+ )
350+ if running_ids :
351+ await self .storage .remove_ids_from_status_set (
352+ ProgramState .RUNNING .value , running_ids
353+ )
354+ ghost_count = len (queued_ids ) + len (running_ids )
355+ logger .warning (
356+ "[EvolutionEngine] Ghost IDs detected — SCARD says active "
357+ "but no real programs found. Cleaned {} ghost ID(s) from "
358+ "status sets. Breaking idle wait." ,
359+ ghost_count ,
360+ )
361+ break
324362 await asyncio .sleep (self .config .loop_interval )
325363
326364 async def _select_elites_for_mutation (self ) -> list [Program ]:
@@ -359,18 +397,40 @@ async def _ingest_completed_programs(self) -> None:
359397 Programs already in the archive stay DONE (they arrived from a refresh DAG).
360398 New programs are added if accepted, otherwise discarded.
361399 """
362- completed = await self .storage .get_all_by_status (ProgramState .DONE .value )
363- if not completed :
400+ # Fetch only IDs first (SMEMBERS — no deserialization), then filter
401+ # out archive programs before doing the expensive mget+deserialize.
402+ done_ids = await self .storage .get_ids_by_status (ProgramState .DONE .value )
403+ if not done_ids :
364404 logger .debug (
365405 "[EvolutionEngine] gen={} No completed programs to ingest" ,
366406 self .metrics .total_generations ,
367407 )
368408 return
369409
410+ archive_program_ids = set (await self .strategy .get_program_ids ())
411+ new_ids = [pid for pid in done_ids if pid not in archive_program_ids ]
412+
413+ if not new_ids :
414+ logger .debug (
415+ "[EvolutionEngine] gen={} {} DONE programs all in archive, skipping" ,
416+ self .metrics .total_generations ,
417+ len (done_ids ),
418+ )
419+ return
420+
421+ # Only deserialize the new (non-archive) programs
422+ completed = await self .storage .mget (new_ids )
423+ # Filter to actual DONE state (mget may return stale status)
424+ completed = [p for p in completed if p .state == ProgramState .DONE ]
425+
426+ if not completed :
427+ return
428+
370429 logger .info (
371- "[EvolutionEngine] gen={} Ingest {} program(s)" ,
430+ "[EvolutionEngine] gen={} Ingest {} program(s) ({} in archive skipped) " ,
372431 self .metrics .total_generations ,
373432 len (completed ),
433+ len (done_ids ) - len (new_ids ),
374434 )
375435 logger .debug (
376436 "[EvolutionEngine] Program IDs: {}" ,
@@ -383,14 +443,10 @@ async def _ingest_completed_programs(self) -> None:
383443 rej_strategy = 0
384444
385445 state_tasks : list [asyncio .Task ] = []
386- archive_program_ids = set (await self .strategy .get_program_ids ())
387446
388447 for prog in completed :
389448 try :
390- if prog .id in archive_program_ids :
391- # already in archive (arrived from a refresh DAG) — stays DONE
392- continue
393- elif not self .config .program_acceptor .is_accepted (prog ):
449+ if not self .config .program_acceptor .is_accepted (prog ):
394450 # rejected by basic checks
395451 rej_valid += 1
396452 logger .info (
@@ -460,18 +516,25 @@ async def _refresh_archive_programs(self) -> int:
460516 return 0
461517
462518 programs_to_refresh = await self .storage .mget (program_ids_to_refresh )
519+ done_programs = [p for p in programs_to_refresh if p .state == ProgramState .DONE ]
463520
464- tasks : list [asyncio .Task ] = []
465- for program in programs_to_refresh :
466- if program .state == ProgramState .DONE :
467- tasks .append (
468- asyncio .create_task (self ._set_state (program , ProgramState .QUEUED ))
469- )
521+ if not done_programs :
522+ return 0
470523
471- if tasks :
472- await asyncio .gather (* tasks , return_exceptions = True )
524+ try :
525+ count = await self .storage .batch_transition_state (
526+ done_programs ,
527+ ProgramState .DONE .value ,
528+ ProgramState .QUEUED .value ,
529+ )
530+ except Exception as e :
531+ logger .error (
532+ "[EvolutionEngine] gen={} batch_transition_state failed: {}" ,
533+ self .metrics .total_generations ,
534+ e ,
535+ )
536+ return 0
473537
474- count = len (tasks )
475538 if count :
476539 logger .info (
477540 "[EvolutionEngine] gen={} Submitted {} program(s) for refresh" ,
@@ -484,16 +547,14 @@ async def _refresh_archive_programs(self) -> int:
484547 async def _has_active_dags (self ) -> bool :
485548 """True if any programs are QUEUED or RUNNING (i.e., engine not idle).
486549
487- Uses get_all_by_status (SMEMBERS + MGET) rather than count_by_status
488- (raw SCARD) so that ghost IDs — set members with no backing program
489- data — are filtered out and cannot permanently stall _await_idle() .
550+ Uses count_by_status (SCARD, O(1)) for the fast path. Falls back to
551+ the expensive get_all_by_status after 30s of continuous waiting to
552+ detect ghost IDs that would otherwise stall _await_idle forever .
490553 """
491- queued_progs , running_progs = await asyncio .gather (
492- self .storage .get_all_by_status (ProgramState .QUEUED .value ),
493- self .storage .get_all_by_status (ProgramState .RUNNING .value ),
554+ queued , running = await asyncio .gather (
555+ self .storage .count_by_status (ProgramState .QUEUED .value ),
556+ self .storage .count_by_status (ProgramState .RUNNING .value ),
494557 )
495- queued = len (queued_progs )
496- running = len (running_progs )
497558
498559 if queued or running :
499560 current_counts = (queued , running )
0 commit comments