diff --git a/GramAddict/core/bot_flow.py b/GramAddict/core/bot_flow.py
index 5cea769..3296921 100644
--- a/GramAddict/core/bot_flow.py
+++ b/GramAddict/core/bot_flow.py
@@ -1,6 +1,7 @@
import logging
import os
import random
+import re
try:
import psutil
@@ -857,6 +858,20 @@ def _run_zero_latency_stories_loop(device, configs, session_state, cognitive_sta
logger.warning("Failed to dump UI hierarchy in StoriesFeed.")
return "CONTEXT_LOST"
+ # ── Perimeter Guard: Verify we're still inside Instagram ──
+ # Production bug 2026-05-03: A story's swipe-up link opened the Play Store,
+ # and the loop kept tapping blindly on com.android.vending for 5+ iterations.
+ packages = set(re.findall(r'package="([^"]+)"', xml_dump))
+ app_id = getattr(device, "app_id", "com.instagram.android")
+ if packages and app_id not in packages:
+ logger.error(
+ f"🚨 [StoriesFeed] FOREIGN APP DETECTED! Packages: {packages}. "
+ f"A story link likely opened an external app. Aborting loop."
+ )
+ device.press("back")
+ sleep(1.5)
+ return "CONTEXT_LOST"
+
if getattr(configs.args, "ignore_close_friends", False):
if "enge freunde" in xml_dump.lower() or "close friend" in xml_dump.lower():
logger.info(
@@ -970,6 +985,17 @@ def _run_zero_latency_feed_loop(
if cognitive_stack.get("radome"):
context_xml = cognitive_stack.get("radome").sanitize_xml(context_xml)
+ # ── Perimeter Guard: Verify we're still inside Instagram ──
+ # Parity with story loop guard (production bug 2026-05-03).
+ if context_xml:
+ feed_packages = set(re.findall(r'package="([^"]+)"', context_xml))
+ feed_app_id = getattr(device, "app_id", "com.instagram.android")
+ if feed_packages and feed_app_id not in feed_packages:
+ logger.error(f"🚨 [FeedLoop] FOREIGN APP DETECTED! Packages: {feed_packages}. Aborting loop.")
+ device.press("back")
+ sleep(1.5)
+ return "CONTEXT_LOST"
+
# ── Execute Plugin Registry Behaviors (Feed Level) ──
from GramAddict.core.behaviors import BehaviorContext, PluginRegistry
diff --git a/GramAddict/core/goap.py b/GramAddict/core/goap.py
index 66bdab0..ebce795 100644
--- a/GramAddict/core/goap.py
+++ b/GramAddict/core/goap.py
@@ -247,34 +247,37 @@ class GoalExecutor:
for k in keys_to_clear:
del self.action_failures[k]
- # ── Back-Press Circuit Breaker ──
+ # ── Back-Press Circuit Breaker → Escalation ──
if action == "press back":
consecutive_back_presses += 1
if consecutive_back_presses >= MAX_CONSECUTIVE_BACK:
- logger.error(
+ logger.warning(
f"🛑 [GOAP] Back-pressed {MAX_CONSECUTIVE_BACK} times with no screen transition. "
- f"Aborting goal '{goal}' to prevent app exit."
+ f"Escalating to force restart."
)
- self.path_memory.learn_path(goal, start_screen, steps_taken, False)
- # Phase 3 GREEN: Unlearn the trap path
+ # Unlearn the trap path
from GramAddict.core.qdrant_memory import NavigationMemoryDB
- # We don't know the exact action that got us here easily without analyzing steps_taken,
- # but we can grab the first action taken from start_screen in this chain if available.
if len(steps_taken) > consecutive_back_presses:
last_real_action = steps_taken[-consecutive_back_presses - 1]["action"]
logger.debug(
f"[GOAP Unlearn] last_real_action={last_real_action}, " f"start_screen={start_screen}"
)
NavigationMemoryDB().unlearn_transition(start_screen, last_real_action)
- else:
- logger.debug(
- f"[GOAP Unlearn] No real action to unlearn. "
- f"steps={len(steps_taken)}, back_presses={consecutive_back_presses}"
- )
- return False
+ # ── ESCALATION: Force restart instead of aborting ──
+ app_id = getattr(self.device, "app_id", "com.instagram.android")
+ self.device.app_start(app_id, use_monkey=True)
+ random_sleep(2.0, 3.5)
+ steps_taken.append({"action": "force start instagram"})
+
+ logger.info("🔄 [GOAP Escalation] App restarted. Purging all failure state for fresh attempt.")
+ self.action_failures.clear()
+ explored_nav_actions.clear()
+ visited_screens.clear()
+ consecutive_back_presses = 0
+ continue
else:
consecutive_back_presses = 0
else:
@@ -388,6 +391,7 @@ class GoalExecutor:
# Determine if this was a navigation or an interaction
from GramAddict.core.screen_topology import ScreenTopology
+
is_navigation = any(k in action.lower() for k in ["tab", "open", "go to", "navigate", "following list"])
if not is_navigation:
is_navigation = ScreenTopology.is_structural_action(pre_action_screen_type, action)
diff --git a/GramAddict/core/situational_awareness.py b/GramAddict/core/situational_awareness.py
index b96b982..851094b 100644
--- a/GramAddict/core/situational_awareness.py
+++ b/GramAddict/core/situational_awareness.py
@@ -350,8 +350,31 @@ class SituationalAwarenessEngine:
is_foreign = True
if is_foreign:
- # We explicitly ask the TelepathicEngine to classify this to avoid writing brittle substring hacks
- # for Android System UI variations across different device manufacturers.
+ # ── Tier 1: Known Foreign Packages (O(1) — ZERO LLM) ──
+ # Production bug 2026-05-03: Play Store was detected via slow LLM path.
+ # For these well-known packages, a set lookup is instant and infallible.
+ KNOWN_FOREIGN_PACKAGES = {
+ "com.android.vending", # Play Store
+ "com.android.chrome", # Chrome
+ "com.google.android.chrome", # Chrome (Google build)
+ "com.google.android.youtube", # YouTube
+ "org.mozilla.firefox", # Firefox
+ "com.opera.browser", # Opera
+ "com.brave.browser", # Brave
+ "com.microsoft.emmx", # Edge
+ "com.sec.android.app.sbrowser", # Samsung Browser
+ }
+ dominant_pkgs = packages - {"com.android.systemui"}
+ fast_match = dominant_pkgs & KNOWN_FOREIGN_PACKAGES
+ if fast_match:
+ logger.info(
+ f"🚨 [SAE Perceive] Known foreign package: {fast_match} → "
+ f"OBSTACLE_FOREIGN_APP (O(1) fast-path, no LLM needed)"
+ )
+ return SituationType.OBSTACLE_FOREIGN_APP
+
+ # ── Tier 2: Unknown/Ambiguous Packages → LLM Classification ──
+ # Only SystemUI-only or rare custom packages reach this path.
try:
from GramAddict.core.config import Config
from GramAddict.core.llm_provider import query_telepathic_llm
diff --git a/tests/e2e/device_emulator.py b/tests/e2e/device_emulator.py
index 63bc519..f868b17 100644
--- a/tests/e2e/device_emulator.py
+++ b/tests/e2e/device_emulator.py
@@ -247,7 +247,8 @@ class InstagramEmulator:
# Could implement swipe transitions here (e.g. scroll down loads new feed)
def app_start(self, pkg, use_monkey=False):
- pass
+ self.app_starts.append(pkg)
+ logger.info(f"[Emulator] app_start({pkg})")
def app_stop(self, pkg):
pass
diff --git a/tests/e2e/test_goap_trap_recovery.py b/tests/e2e/test_goap_trap_recovery.py
index 64af014..d223873 100644
--- a/tests/e2e/test_goap_trap_recovery.py
+++ b/tests/e2e/test_goap_trap_recovery.py
@@ -34,7 +34,7 @@ def test_goap_recovers_from_trapped_state_with_restart(monkeypatch):
# We just want to see that AFTER the restart, it tries 'tap reels tab' again,
# meaning it cleared the state.
- goap.achieve("open reels", max_steps=6)
+ goap.achieve("open reels", max_steps=10)
# It should have tried 'tap reels tab' twice, failed both times,
# then triggered 'force start instagram'.
diff --git a/tests/e2e/test_production_bug_play_store_trap_20260503.py b/tests/e2e/test_production_bug_play_store_trap_20260503.py
new file mode 100644
index 0000000..f549e6e
--- /dev/null
+++ b/tests/e2e/test_production_bug_play_store_trap_20260503.py
@@ -0,0 +1,52 @@
+"""
+Production Bug Regression: Play Store Trap (2026-05-03)
+========================================================
+Session: 2026-05-03_18-03-15
+Trace: Frames 14-40 (Instagram Stories) → Frame 41 (com.android.vending)
+
+Root Cause: Story loop had no perimeter guard. A story's swipe-up link
+opened the Play Store, and the bot continued tapping at (w*0.85, h*0.5)
+on Play Store UI for 5+ iterations until user killed the process.
+
+This test uses the EXACT production XML dumps to validate:
+1. ScreenIdentity correctly classifies Play Store as FOREIGN_APP
+2. SAE correctly classifies Play Store as OBSTACLE_FOREIGN_APP
+3. Story frame before the linkout is correctly classified as STORY_VIEW
+"""
+
+from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
+from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
+from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
+
+
+class TestPlayStoreTrapRegression:
+ def test_screen_identity_classifies_play_store_as_foreign(self):
+ """ScreenIdentity must classify com.android.vending as FOREIGN_APP."""
+ xml = load_fixture_xml("play_store_from_story_link.xml")
+ sid = ScreenIdentity("testuser")
+ result = sid.identify(xml)
+ assert result["screen_type"] == ScreenType.FOREIGN_APP, (
+ f"ScreenIdentity classified Play Store as {result['screen_type'].value}! "
+ f"This is the exact production bug from 2026-05-03."
+ )
+
+ def test_sae_classifies_play_store_as_obstacle_foreign_app(self):
+ """SAE must classify com.android.vending as OBSTACLE_FOREIGN_APP."""
+ xml = load_fixture_xml("play_store_from_story_link.xml")
+ device = E2EDeviceStub([xml])
+ SituationalAwarenessEngine.reset()
+ sae = SituationalAwarenessEngine(device)
+ result = sae.perceive(xml)
+ assert result == SituationType.OBSTACLE_FOREIGN_APP, (
+ f"SAE classified Play Store as {result.value}! " f"Exact production bug regression from 2026-05-03."
+ )
+
+ def test_story_view_before_linkout_is_story(self):
+ """The last story frame before the Play Store must be STORY_VIEW."""
+ xml = load_fixture_xml("story_view_before_linkout.xml")
+ sid = ScreenIdentity("testuser")
+ result = sid.identify(xml)
+ assert result["screen_type"] == ScreenType.STORY_VIEW, (
+ f"Frame before Play Store was classified as {result['screen_type'].value}, "
+ f"not STORY_VIEW. This could cause false positives."
+ )
diff --git a/tests/e2e/test_sae_foreign_app_fastpath.py b/tests/e2e/test_sae_foreign_app_fastpath.py
new file mode 100644
index 0000000..951e4fa
--- /dev/null
+++ b/tests/e2e/test_sae_foreign_app_fastpath.py
@@ -0,0 +1,56 @@
+"""
+E2E: SAE Foreign App Fast-Path
+================================
+Validates that the SAE classifies known foreign packages (Play Store,
+Chrome, Settings) as OBSTACLE_FOREIGN_APP using O(1) structural detection
+WITHOUT falling through to LLM classification.
+
+This eliminates 2-5 seconds of LLM inference for detections that
+should be instantaneous set lookups.
+"""
+
+from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType
+from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
+
+
+def test_sae_classifies_play_store_without_llm():
+ """Play Store XML must be classified as FOREIGN_APP via fast-path, not LLM."""
+ xml = load_fixture_xml("play_store_from_story_link.xml")
+ device = E2EDeviceStub([xml])
+ SituationalAwarenessEngine.reset()
+ sae = SituationalAwarenessEngine(device)
+
+ result = sae.perceive(xml)
+
+ assert result == SituationType.OBSTACLE_FOREIGN_APP, (
+ f"SAE classified Play Store as {result.value} instead of OBSTACLE_FOREIGN_APP! "
+ f"The bot cannot detect it left Instagram."
+ )
+
+
+def test_sae_fast_path_handles_known_foreign_packages():
+ """
+ Verify the fast-path handles com.android.vending, com.android.chrome,
+ com.google.android.youtube etc. without LLM calls.
+ com.android.settings may classify as OBSTACLE_SYSTEM which is also valid.
+ """
+ known_foreign_pkgs = {
+ "com.android.vending": SituationType.OBSTACLE_FOREIGN_APP,
+ "com.android.chrome": SituationType.OBSTACLE_FOREIGN_APP,
+ "com.google.android.youtube": SituationType.OBSTACLE_FOREIGN_APP,
+ }
+ for pkg, expected in known_foreign_pkgs.items():
+ xml = f"""
+
+
+
+
+ """
+ device = E2EDeviceStub([xml])
+ SituationalAwarenessEngine.reset()
+ sae = SituationalAwarenessEngine(device)
+
+ result = sae.perceive(xml)
+ assert result == expected, f"Package {pkg} was classified as {result.value}, expected {expected.value}!"
diff --git a/tests/e2e/test_system_sae.py b/tests/e2e/test_system_sae.py
index f5d0f71..bb4dccf 100644
--- a/tests/e2e/test_system_sae.py
+++ b/tests/e2e/test_system_sae.py
@@ -154,6 +154,10 @@ class TestSAELoop:
from GramAddict.core.perception.screen_identity import ScreenIdentity
from GramAddict.core.qdrant_memory import ScreenMemoryDB
+ db = ScreenMemoryDB()
+ if not db.is_connected:
+ pytest.skip("Qdrant is not running — this test requires vector store for NORMAL override storage")
+
# Load real home feed XML
xml_dump = Path("tests/e2e/fixtures/home_feed_real.xml").read_text()
@@ -168,7 +172,7 @@ class TestSAELoop:
# Simulate LLM unlearning by storing this exact state as NORMAL
compressed = sae._compress_xml(xml_dump)
- ScreenMemoryDB().store_screen(compressed, "NORMAL")
+ db.store_screen(compressed, "NORMAL")
identity = ScreenIdentity("testuser")
# Ensure we inject the device so get_screenshot_b64 doesn't crash if it falls back
diff --git a/tests/e2e/test_workflow_feed_foreign_app_escape.py b/tests/e2e/test_workflow_feed_foreign_app_escape.py
new file mode 100644
index 0000000..6361c0e
--- /dev/null
+++ b/tests/e2e/test_workflow_feed_foreign_app_escape.py
@@ -0,0 +1,67 @@
+"""
+E2E: Feed Loop Foreign App Escape
+====================================
+Validates that the feed loop detects a foreign app takeover
+(e.g. Play Store opened) and immediately aborts with CONTEXT_LOST.
+Parity with story loop guard.
+"""
+
+import datetime
+
+from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
+
+
+def test_feed_loop_escapes_foreign_app(e2e_configs):
+ """
+ Simulates: Feed → Play Store.
+ The loop must detect com.android.vending and abort immediately.
+ """
+ from GramAddict.core.bot_flow import _run_zero_latency_feed_loop
+ from GramAddict.core.dopamine_engine import DopamineEngine
+
+ feed_xml = load_fixture_xml("home_feed_with_ad.xml")
+ play_store_xml = load_fixture_xml("play_store_from_story_link.xml")
+
+ # Feed -> Play Store
+ device = E2EDeviceStub([feed_xml, play_store_xml])
+
+ dopamine = DopamineEngine()
+ dopamine.session_limit_seconds = 999
+
+ # Needs a dummy zero_engine and nav_graph
+ class DummyZeroEngine:
+ pass
+
+ class DummyNavGraph:
+ def do(self, action):
+ pass
+
+ def navigate_to(self, state, engine):
+ pass
+
+ cognitive_stack = {
+ "dopamine": dopamine,
+ "zero_engine": DummyZeroEngine(),
+ "nav_graph": DummyNavGraph(),
+ "radome": None,
+ "active_inference": None,
+ }
+
+ session_state = type(
+ "S", (), {"startTime": datetime.datetime.now(), "check_limit": lambda *args, **kwargs: (False, False)}
+ )()
+
+ # Override speed_multiplier to avoid multi-minute sleeps in CI
+ e2e_configs.args.speed_multiplier = 0.01
+
+ result = _run_zero_latency_feed_loop(
+ device, DummyZeroEngine(), DummyNavGraph(), e2e_configs, session_state, "HomeFeed", cognitive_stack
+ )
+
+ # Must return CONTEXT_LOST
+ assert (
+ result == "CONTEXT_LOST"
+ ), f"Feed loop returned '{result}' instead of 'CONTEXT_LOST' when Play Store was in foreground."
+
+ # Must have pressed back to attempt recovery
+ assert "back" in device.pressed_keys, "Feed loop detected foreign app but never pressed BACK to recover!"
diff --git a/tests/e2e/test_workflow_stories_foreign_app_escape.py b/tests/e2e/test_workflow_stories_foreign_app_escape.py
new file mode 100644
index 0000000..ce79278
--- /dev/null
+++ b/tests/e2e/test_workflow_stories_foreign_app_escape.py
@@ -0,0 +1,54 @@
+"""
+E2E: Story Loop Foreign App Escape
+====================================
+Validates that the story binging loop detects a foreign app takeover
+(e.g. Play Store opened via story link) and immediately aborts with
+CONTEXT_LOST instead of continuing to tap blindly.
+
+Production Bug Reproduction:
+ Session: 2026-05-03_18-03-15
+ Trace: Frames 14-40 (Instagram Stories) → Frame 41 (com.android.vending)
+ Root Cause: _run_zero_latency_stories_loop had no perimeter guard.
+
+Uses the REAL production XML dumps from the 2026-05-03 session trace.
+"""
+
+import datetime
+
+from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml
+
+
+def test_story_loop_escapes_foreign_app(e2e_configs):
+ """
+ Simulates: Story → Story → Story → Play Store (via swipe-up link).
+ The loop must detect com.android.vending and abort immediately.
+ """
+ from GramAddict.core.bot_flow import _run_zero_latency_stories_loop
+ from GramAddict.core.dopamine_engine import DopamineEngine
+
+ story_xml = load_fixture_xml("story_view_before_linkout.xml")
+ play_store_xml = load_fixture_xml("play_store_from_story_link.xml")
+
+ # 3 story iterations → then Play Store appears
+ device = E2EDeviceStub([story_xml, story_xml, story_xml, play_store_xml])
+
+ dopamine = DopamineEngine()
+ dopamine.session_limit_seconds = 999
+ cognitive_stack = {"dopamine": dopamine}
+
+ session_state = type("S", (), {"startTime": datetime.datetime.now()})()
+
+ # Override speed_multiplier to avoid multi-minute sleeps in CI
+ e2e_configs.args.speed_multiplier = 0.01
+
+ result = _run_zero_latency_stories_loop(device, e2e_configs, session_state, cognitive_stack)
+
+ # Must return CONTEXT_LOST, NOT FEED_EXHAUSTED
+ assert result == "CONTEXT_LOST", (
+ f"Story loop returned '{result}' instead of 'CONTEXT_LOST' when Play Store was in foreground. "
+ f"The bot would have tapped blindly on the Play Store! "
+ f"This is the exact production bug from 2026-05-03."
+ )
+
+ # Must have pressed back to attempt recovery
+ assert "back" in device.pressed_keys, "Story loop detected foreign app but never pressed BACK to recover!"
diff --git a/tests/fixtures/play_store_from_story_link.xml b/tests/fixtures/play_store_from_story_link.xml
new file mode 100644
index 0000000..642e546
--- /dev/null
+++ b/tests/fixtures/play_store_from_story_link.xml
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/fixtures/story_view_before_linkout.xml b/tests/fixtures/story_view_before_linkout.xml
new file mode 100644
index 0000000..0ccec3d
--- /dev/null
+++ b/tests/fixtures/story_view_before_linkout.xml
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+