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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
62
tests/tdd/test_navigation_trap_recovery.py
Normal file
62
tests/tdd/test_navigation_trap_recovery.py
Normal 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."
|
||||
)
|
||||
Reference in New Issue
Block a user