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

1""" 

2HevolveSocial - Idle Detection Service 

3 

4Detects when agents are idle (not serving active user tasks) and manages 

5opt-in preferences for contributing idle compute to distributed coding. 

6 

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 

23 

24logger = logging.getLogger('hevolve_social') 

25 

26 

27class IdleDetectionService: 

28 """Detects idle agents and manages opt-in for distributed coding.""" 

29 

30 @staticmethod 

31 def is_agent_idle(user_prompt: str) -> bool: 

32 """Check if a specific agent session is idle. 

33 

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 

52 

53 # Not in user_tasks at all = idle 

54 if user_prompt not in user_tasks: 

55 return True 

56 

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 

61 

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 

80 

81 @staticmethod 

82 def _check_user_dispatchable(user) -> bool: 

83 """Shared dispatchability check: not admin-paused AND no active sessions. 

84 

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). 

89 

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 ) 

116 

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 } 

125 

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. 

129 

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: 

134 

135 - ``coding_daemon._loop`` — peer compute sharing 

136 - ``peer_discovery._self_info`` — gossip advertising of 

137 available idle capacity 

138 

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 ] 

153 

154 @staticmethod 

155 def get_idle_agent_personas(db: Session) -> List[Dict]: 

156 """Get all idle ``user_type='agent'`` personas eligible for goal dispatch. 

157 

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. 

165 

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) 

170 

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 ] 

186 

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 

191 

192 user = db.query(User).filter_by(id=user_id).first() 

193 if not user: 

194 return {'success': False, 'error': 'User not found'} 

195 

196 user.idle_compute_opt_in = True 

197 db.flush() 

198 return {'success': True, 'user_id': user_id, 'idle_compute_opt_in': True} 

199 

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 

204 

205 user = db.query(User).filter_by(id=user_id).first() 

206 if not user: 

207 return {'success': False, 'error': 'User not found'} 

208 

209 user.idle_compute_opt_in = False 

210 db.flush() 

211 return {'success': True, 'user_id': user_id, 'idle_compute_opt_in': False} 

212 

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 

217 

218 total_opted_in = db.query(User).filter( 

219 User.idle_compute_opt_in == True, 

220 ).count() 

221 

222 idle_agents = IdleDetectionService.get_idle_opted_in_agents(db) 

223 

224 return { 

225 'total_opted_in': total_opted_in, 

226 'currently_idle': len(idle_agents), 

227 'idle_agents': idle_agents, 

228 }