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:

View File

@@ -0,0 +1,62 @@
"""
TDD: Navigation Trap Recovery & UNKNOWN Screen Handling
1. After force-restart, all in-memory traps must be cleared to allow fresh routing.
2. Traps on UNKNOWN screens must NEVER persist — UNKNOWN is a catch-all,
permanent traps here block ALL unidentified screens.
3. Trap system must have time-decay — old traps expire after a threshold.
"""
import pytest
class TestNavigationTrapRecovery:
"""Trap system must not create permanent dead-ends."""
def test_app_restart_clears_in_memory_traps(self):
"""
META-TEST: When 'force start instagram' succeeds in GOAP,
the planner's learned traps MUST be cleared.
"""
import inspect
from GramAddict.core.goap import GoalExecutor
source = inspect.getsource(GoalExecutor.achieve)
# Find the force start instagram block
assert "_learned_traps" in source or "clear_traps" in source or "wipe_traps" in source, (
"GOAP.navigate_to must clear the planner's _learned_traps after a force restart. "
"Currently only action_failures and explored_nav_actions are cleared, "
"leaving traps active — which causes immediate re-trapping on restart."
)
def test_unknown_screen_traps_never_persist_to_qdrant(self):
"""
META-TEST: Traps learned on UNKNOWN screens must NOT be persisted to Qdrant.
UNKNOWN is a catch-all — persisting traps here blocks ALL unidentified screens forever.
"""
import inspect
from GramAddict.core.navigation.knowledge import NavigationKnowledge
source = inspect.getsource(NavigationKnowledge.learn_trap)
source_lower = source.lower()
# Must have a guard against persisting UNKNOWN traps
assert "unknown" in source_lower, (
"learn_trap must guard against persisting traps on UNKNOWN screens. "
"UNKNOWN is a catch-all — permanent traps here block ALL unidentified screens."
)
def test_trap_system_has_time_decay(self):
"""
META-TEST: is_trap must consider the age of the trap entry.
Old traps (persisted in Qdrant) must expire to prevent permanent dead-ends.
"""
import inspect
from GramAddict.core.navigation.knowledge import NavigationKnowledge
source = inspect.getsource(NavigationKnowledge.is_trap)
# Must reference time-based expiry or timestamp comparison
assert "timestamp" in source or "time" in source or "age" in source or "expire" in source, (
"is_trap must implement time-based expiry for Qdrant-persisted traps. "
"Permanent traps cause permanent dead-ends."
)