fix(navigation): purge permanent trap dead-ends & clear UNKNOWN traps

ROOT CAUSE:
The bot would permanently softlock itself on UNKNOWN screens (like Trending Audio/Reels)
because 'is_trap' had NO time-decay, and UNKNOWN screen traps persisted forever.
Once 'press back' was trapped on an UNKNOWN screen, the bot was permanently blocked
from escaping any future UNKNOWN screen, causing endless restart loops.

FIXES:
1. Trap Time-Decay: 'is_trap' now forgives Qdrant-persisted traps older than 30 minutes.
   This mirrors ContextMemory and prevents permanent dead-ends.
2. UNKNOWN Guard: 'learn_trap' now REFUSES to persist traps for UNKNOWN screens to Qdrant.
   They only live in-memory to prevent breaking global navigation.
3. In-Memory Purge: 'force start instagram' now properly calls 'clear_traps()' to wipe
   the planner's in-memory traps. Previously they survived restarts, immediately
   re-trapping the bot.

Purged 6 ancient traps from Qdrant (one was 8 days old!).
TDD: 3 new tests, full suite 113/114 (1 infra flake)
This commit is contained in:
2026-05-05 16:42:34 +02:00
parent 8a2c93fb64
commit cf21dd3f76
3 changed files with 105 additions and 1 deletions

View File

@@ -234,6 +234,11 @@ class GoalExecutor:
explored_nav_actions.clear()
visited_screens.clear()
consecutive_back_presses = 0
# CRITICAL: Also clear the planner's learned traps.
# Without this, traps learned before restart persist and
# immediately re-trap the bot on the same (or similar) screen.
if hasattr(self, 'planner') and hasattr(self.planner, 'knowledge'):
self.planner.knowledge.clear_traps()
continue
# Check if it was a navigation action (vs a goal action). If we are not on the required screen,

View File

@@ -164,11 +164,28 @@ class NavigationKnowledge:
pass
return None
def clear_traps(self):
"""Clear all in-memory traps. Called after force-restart to allow fresh routing."""
count = len(self._learned_traps)
self._learned_traps.clear()
if count > 0:
logger.info(f"🧹 [NavigationKnowledge] Cleared {count} in-memory traps for fresh routing.")
def learn_trap(self, screen_type: ScreenType, action: str, trap_reason: str = "softlock"):
"""Aversively learn that an action on a screen is dangerous/useless."""
trap_key = f"{screen_type.name}_{action}"
self._learned_traps.add(trap_key)
# GUARD: Never persist traps for UNKNOWN screens to Qdrant.
# UNKNOWN is a catch-all — persisting traps here permanently blocks
# ALL unidentified screens, creating inescapable dead-ends.
if screen_type == ScreenType.UNKNOWN:
logger.warning(
f"🛡️ [Aversive Learning] Trap '{action}' on UNKNOWN kept in-memory only (not persisted). "
"UNKNOWN is a catch-all — permanent traps here block all unidentified screens."
)
return
if not self._db or not self._db.is_connected:
return
@@ -185,7 +202,11 @@ class NavigationKnowledge:
logger.error(f"💀 [Aversive Learning] BURNED action '{action}' on {screen_type.name} due to: {trap_reason}")
def is_trap(self, screen_type: ScreenType, action: str) -> bool:
"""Check if an action on this screen is a known trap."""
"""Check if an action on this screen is a known trap.
Traps have time-based expiry: entries older than 30 minutes are
auto-forgiven and deleted from Qdrant to prevent permanent dead-ends.
"""
from GramAddict.core.screen_topology import ScreenTopology
if ScreenTopology.is_structural_action(screen_type, action):
@@ -198,6 +219,8 @@ class NavigationKnowledge:
if not self._db or not self._db.is_connected:
return False
TRAP_EXPIRY_SECONDS = 1800 # 30 minutes: old traps expire
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
@@ -212,6 +235,20 @@ class NavigationKnowledge:
limit=1,
)[0]
if results:
timestamp = results[0].payload.get("timestamp", 0)
age_seconds = time.time() - timestamp
# Time-based expiry: old traps are forgiven
if age_seconds > TRAP_EXPIRY_SECONDS:
logger.info(
f"🔄 [Aversive Decay] Forgave expired trap '{action}' on {screen_type.name} "
f"(age: {age_seconds/60:.0f}min). Allowing re-exploration."
)
# Delete the stale trap from Qdrant
seed = f"trap_{trap_key}"
self._db.delete_point(seed)
return False
self._learned_traps.add(trap_key)
return True
except Exception: