Coverage for integrations / coding_agent / idle_detection.py: 60.7%
84 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2HevolveSocial - Idle Detection Service
4Detects when agents are idle (not serving active user tasks) and manages
5opt-in preferences for contributing idle compute to distributed coding.
7Worker-thread import-lock guard: the daemon thread that calls
8get_idle_opted_in_agents() must NEVER trigger a `from create_recipe …`
9or `from lifecycle_hooks …` import. Both modules drag the heavyweight
10langchain / autogen / transformers chain through Python's per-module
11import lock, and worker threads waiting on that lock can't release it
12when the watchdog "restarts" them — Python has no thread.kill, the
13zombie keeps holding the lock, the next attempt blocks behind it, and
14the daemon never dispatches a goal (witnessed 2026-04-29: same shape
15as the hart_intelligence import deadlock at world_model_bridge.py:299).
16We consult sys.modules instead — only consume the module when the
17main-thread bootstrap has already finished loading it.
18"""
19import logging
20import sys
21from typing import Dict, List, Optional
22from sqlalchemy.orm import Session
24logger = logging.getLogger('hevolve_social')
27class IdleDetectionService:
28 """Detects idle agents and manages opt-in for distributed coding."""
30 @staticmethod
31 def is_agent_idle(user_prompt: str) -> bool:
32 """Check if a specific agent session is idle.
34 An agent is idle when:
35 1. Its user_prompt key does NOT exist in user_tasks, OR
36 2. It exists but has no active (non-terminated) actions
37 """
38 # Worker-thread guard: never trigger `from create_recipe import …`
39 # here — it acquires the per-module import lock for the heavy
40 # langchain/autogen/transformers chain and deadlocks the daemon.
41 # Use sys.modules so we only consume the module if it's already
42 # loaded by the main-thread bootstrap. No module loaded yet ⇒
43 # treat as idle (same fall-through as the original ImportError
44 # branch — caller already handles missing user_tasks gracefully).
45 cr_mod = sys.modules.get('create_recipe')
46 if cr_mod is None:
47 return False
48 user_agents = getattr(cr_mod, 'user_agents', None)
49 user_tasks = getattr(cr_mod, 'user_tasks', None)
50 if user_tasks is None:
51 return False
53 # Not in user_tasks at all = idle
54 if user_prompt not in user_tasks:
55 return True
57 # In user_tasks but check if the action is finished
58 action = user_tasks.get(user_prompt)
59 if action is None:
60 return True
62 # Check lifecycle state. Same sys.modules guard — lifecycle_hooks
63 # is part of the same heavy import chain.
64 lh_mod = sys.modules.get('lifecycle_hooks')
65 if lh_mod is None:
66 return user_agents is None or user_prompt not in user_agents
67 action_states = getattr(lh_mod, 'action_states', None)
68 ActionState = getattr(lh_mod, 'ActionState', None)
69 if action_states is None or ActionState is None:
70 return user_agents is None or user_prompt not in user_agents
71 states = action_states.get(user_prompt, {})
72 if not states:
73 return True
74 # Idle if ALL actions are TERMINATED or COMPLETED or ERROR
75 for key, state in states.items():
76 if state not in (ActionState.TERMINATED, ActionState.COMPLETED,
77 ActionState.ERROR):
78 return False
79 return True
81 @staticmethod
82 def _check_user_dispatchable(user) -> bool:
83 """Shared dispatchability check: not admin-paused AND no active sessions.
85 Used by BOTH ``get_idle_opted_in_agents`` (distributed-compute
86 privacy contract) AND ``get_idle_agent_personas`` (local agent
87 goal dispatch). Single canonical idle-check — no parallel
88 paths (Gate 4 / CLAUDE.md).
90 Returns True if the user is eligible to receive work right now.
91 """
92 # Skip admin-paused entirely. The `settings` JSON column is
93 # the single source of truth; /api/admin/agents/<id>/pause
94 # writes it, /resume clears it.
95 user_settings = user.settings or {}
96 if user_settings.get('paused') is True:
97 return False
98 # Worker-thread guard (same rationale as ``is_agent_idle``):
99 # consult sys.modules instead of ``from create_recipe import …``.
100 # If create_recipe isn't loaded yet, treat sessions as idle —
101 # same fall-through the original ImportError branch used. Main
102 # thread loads create_recipe lazily; once that finishes,
103 # subsequent ticks see a populated ``user_tasks`` and the
104 # all_idle gate runs as designed.
105 cr_mod = sys.modules.get('create_recipe')
106 user_tasks = getattr(cr_mod, 'user_tasks', None) if cr_mod else None
107 if user_tasks is None:
108 return True
109 user_sessions = [k for k in user_tasks.keys()
110 if k.startswith(f'{user.id}_')]
111 if not user_sessions:
112 return True
113 return all(
114 IdleDetectionService.is_agent_idle(s) for s in user_sessions
115 )
117 @staticmethod
118 def _user_to_dict(user) -> Dict:
119 """Project a User row into the dict shape both consumers expect."""
120 return {
121 'user_id': user.id,
122 'username': user.username,
123 'user_type': user.user_type,
124 }
126 @staticmethod
127 def get_idle_opted_in_agents(db: Session) -> List[Dict]:
128 """Get all idle users who have OPTED IN to distributed coding.
130 **Intent (privacy contract):** humans who explicitly toggled
131 ``idle_compute_opt_in=True`` consent to having their idle
132 compute used by other Hive nodes. This is the canonical
133 eligibility filter for:
135 - ``coding_daemon._loop`` — peer compute sharing
136 - ``peer_discovery._self_info`` — gossip advertising of
137 available idle capacity
139 **Do NOT use this for local agent_daemon goal dispatch** —
140 that wants ``get_idle_agent_personas`` instead, which dispatches
141 goals to ``user_type='agent'`` rows (Echo, Quest, etc.) without
142 gating on the human-consent flag they don't apply to.
143 """
144 from integrations.social.models import User
145 opted_in = db.query(User).filter(
146 User.idle_compute_opt_in == True,
147 ).all()
148 return [
149 IdleDetectionService._user_to_dict(u)
150 for u in opted_in
151 if IdleDetectionService._check_user_dispatchable(u)
152 ]
154 @staticmethod
155 def get_idle_agent_personas(db: Session) -> List[Dict]:
156 """Get all idle ``user_type='agent'`` personas eligible for goal dispatch.
158 **Intent:** the agent_daemon dispatches goals (Echo's marketing
159 explainer, Quest's contest recap, Contest Curator's idea
160 capture, …) to autonomous agent personas. Those personas are
161 DB rows with ``user_type='agent'`` — they exist *to* do work.
162 There is no human to consent on their behalf, so the
163 ``idle_compute_opt_in`` flag (which gates distributed-compute
164 sharing for humans) does NOT apply.
166 Eligibility rules:
167 1. ``user_type == 'agent'``
168 2. NOT admin-paused (``settings.paused != True``)
169 3. No active non-terminated sessions (``is_agent_idle`` true)
171 Captured 2026-05-01 root-cause: previously the daemon called
172 ``get_idle_opted_in_agents``, which silently returned `[]` on
173 installs where no user had toggled the human consent flag —
174 the seeded marketing personas (Echo/Quest/Contest Curator)
175 never dispatched because they weren't gated by the right flag.
176 """
177 from integrations.social.models import User
178 agent_users = db.query(User).filter(
179 User.user_type == 'agent',
180 ).all()
181 return [
182 IdleDetectionService._user_to_dict(u)
183 for u in agent_users
184 if IdleDetectionService._check_user_dispatchable(u)
185 ]
187 @staticmethod
188 def opt_in(db: Session, user_id: str) -> Dict:
189 """Opt a user in to contribute idle compute for distributed coding."""
190 from integrations.social.models import User
192 user = db.query(User).filter_by(id=user_id).first()
193 if not user:
194 return {'success': False, 'error': 'User not found'}
196 user.idle_compute_opt_in = True
197 db.flush()
198 return {'success': True, 'user_id': user_id, 'idle_compute_opt_in': True}
200 @staticmethod
201 def opt_out(db: Session, user_id: str) -> Dict:
202 """Opt a user out of contributing idle compute."""
203 from integrations.social.models import User
205 user = db.query(User).filter_by(id=user_id).first()
206 if not user:
207 return {'success': False, 'error': 'User not found'}
209 user.idle_compute_opt_in = False
210 db.flush()
211 return {'success': True, 'user_id': user_id, 'idle_compute_opt_in': False}
213 @staticmethod
214 def get_idle_stats(db: Session) -> Dict:
215 """Get idle agent statistics for this node."""
216 from integrations.social.models import User
218 total_opted_in = db.query(User).filter(
219 User.idle_compute_opt_in == True,
220 ).count()
222 idle_agents = IdleDetectionService.get_idle_opted_in_agents(db)
224 return {
225 'total_opted_in': total_opted_in,
226 'currently_idle': len(idle_agents),
227 'idle_agents': idle_agents,
228 }