superkick 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. checksums.yaml +7 -0
  2. data/CLA.md +91 -0
  3. data/CLAUDE.md +2226 -0
  4. data/CONTRIBUTING.md +104 -0
  5. data/LICENSE +108 -0
  6. data/LICENSE-COMMERCIAL.md +39 -0
  7. data/PLAN.md +161 -0
  8. data/README.md +1155 -0
  9. data/exe/superkick +6 -0
  10. data/lib/superkick/agent/runtime.rb +82 -0
  11. data/lib/superkick/agent/runtimes/local.rb +74 -0
  12. data/lib/superkick/agent/runtimes.rb +4 -0
  13. data/lib/superkick/agent.rb +209 -0
  14. data/lib/superkick/agent_store.rb +85 -0
  15. data/lib/superkick/attach/client.rb +245 -0
  16. data/lib/superkick/attach/protocol.rb +71 -0
  17. data/lib/superkick/attach/server.rb +371 -0
  18. data/lib/superkick/budget_checker.rb +120 -0
  19. data/lib/superkick/buffer/client.rb +91 -0
  20. data/lib/superkick/buffer/server.rb +127 -0
  21. data/lib/superkick/cli/agent.rb +524 -0
  22. data/lib/superkick/cli/completion.rb +591 -0
  23. data/lib/superkick/cli/goal.rb +71 -0
  24. data/lib/superkick/cli/mcp.rb +34 -0
  25. data/lib/superkick/cli/monitor.rb +47 -0
  26. data/lib/superkick/cli/notifier.rb +39 -0
  27. data/lib/superkick/cli/repository.rb +46 -0
  28. data/lib/superkick/cli/server.rb +106 -0
  29. data/lib/superkick/cli/setup.rb +166 -0
  30. data/lib/superkick/cli/spawner.rb +85 -0
  31. data/lib/superkick/cli/team.rb +407 -0
  32. data/lib/superkick/cli.rb +175 -0
  33. data/lib/superkick/client_registry.rb +30 -0
  34. data/lib/superkick/configuration.rb +178 -0
  35. data/lib/superkick/connection.rb +56 -0
  36. data/lib/superkick/control/client.rb +78 -0
  37. data/lib/superkick/control/reply.rb +43 -0
  38. data/lib/superkick/control/server.rb +1271 -0
  39. data/lib/superkick/cost_accumulator.rb +53 -0
  40. data/lib/superkick/cost_extractor.rb +65 -0
  41. data/lib/superkick/cost_poller.rb +70 -0
  42. data/lib/superkick/driver/profile_source.rb +134 -0
  43. data/lib/superkick/driver.rb +179 -0
  44. data/lib/superkick/drivers/claude_code.rb +110 -0
  45. data/lib/superkick/drivers/codex.rb +57 -0
  46. data/lib/superkick/drivers/copilot.rb +75 -0
  47. data/lib/superkick/drivers/gemini.rb +86 -0
  48. data/lib/superkick/drivers/goose.rb +74 -0
  49. data/lib/superkick/drivers.rb +16 -0
  50. data/lib/superkick/drop.rb +80 -0
  51. data/lib/superkick/drops.rb +76 -0
  52. data/lib/superkick/environment_executor.rb +90 -0
  53. data/lib/superkick/goal.rb +95 -0
  54. data/lib/superkick/goals/agent_exit.rb +41 -0
  55. data/lib/superkick/goals/agent_signal.rb +42 -0
  56. data/lib/superkick/goals/command.rb +103 -0
  57. data/lib/superkick/history_buffer.rb +38 -0
  58. data/lib/superkick/hosted/attach/bridge.rb +52 -0
  59. data/lib/superkick/hosted/attach/client.rb +208 -0
  60. data/lib/superkick/hosted/attach/relay.rb +313 -0
  61. data/lib/superkick/hosted/attach/relay_store.rb +48 -0
  62. data/lib/superkick/hosted/bridge.rb +263 -0
  63. data/lib/superkick/hosted/buffer/bridge.rb +42 -0
  64. data/lib/superkick/hosted/buffer/client.rb +63 -0
  65. data/lib/superkick/hosted/buffer/relay.rb +126 -0
  66. data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
  67. data/lib/superkick/hosted/control/client.rb +84 -0
  68. data/lib/superkick/hosted/mcp_proxy.rb +144 -0
  69. data/lib/superkick/inject_handler.rb +24 -0
  70. data/lib/superkick/injection_guard.rb +26 -0
  71. data/lib/superkick/injection_queue.rb +177 -0
  72. data/lib/superkick/injector.rb +65 -0
  73. data/lib/superkick/input_buffer.rb +171 -0
  74. data/lib/superkick/integrations/bugsnag/README.md +98 -0
  75. data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
  76. data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
  77. data/lib/superkick/integrations/bugsnag.rb +7 -0
  78. data/lib/superkick/integrations/circleci/README.md +75 -0
  79. data/lib/superkick/integrations/circleci/monitor.rb +185 -0
  80. data/lib/superkick/integrations/circleci/probe.rb +36 -0
  81. data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
  82. data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
  83. data/lib/superkick/integrations/circleci.rb +8 -0
  84. data/lib/superkick/integrations/datadog/README.md +253 -0
  85. data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
  86. data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
  87. data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
  88. data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
  89. data/lib/superkick/integrations/datadog/notifier.rb +294 -0
  90. data/lib/superkick/integrations/datadog/spawner.rb +201 -0
  91. data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
  92. data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
  93. data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
  94. data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
  95. data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
  96. data/lib/superkick/integrations/datadog.rb +14 -0
  97. data/lib/superkick/integrations/docker/README.md +256 -0
  98. data/lib/superkick/integrations/docker/client.rb +295 -0
  99. data/lib/superkick/integrations/docker/runtime.rb +218 -0
  100. data/lib/superkick/integrations/docker.rb +4 -0
  101. data/lib/superkick/integrations/git/repository_source.rb +66 -0
  102. data/lib/superkick/integrations/git/version_control.rb +119 -0
  103. data/lib/superkick/integrations/git.rb +8 -0
  104. data/lib/superkick/integrations/github/README.md +300 -0
  105. data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
  106. data/lib/superkick/integrations/github/drops.rb +114 -0
  107. data/lib/superkick/integrations/github/goal.rb +135 -0
  108. data/lib/superkick/integrations/github/issue_goal.rb +104 -0
  109. data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
  110. data/lib/superkick/integrations/github/monitor.rb +251 -0
  111. data/lib/superkick/integrations/github/probe.rb +30 -0
  112. data/lib/superkick/integrations/github/repository_source.rb +228 -0
  113. data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
  114. data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
  115. data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
  116. data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
  117. data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
  118. data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
  119. data/lib/superkick/integrations/github.rb +16 -0
  120. data/lib/superkick/integrations/honeybadger/README.md +97 -0
  121. data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
  122. data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
  123. data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
  124. data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
  125. data/lib/superkick/integrations/honeybadger.rb +9 -0
  126. data/lib/superkick/integrations/shell/README.md +83 -0
  127. data/lib/superkick/integrations/shell/monitor.rb +87 -0
  128. data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
  129. data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
  130. data/lib/superkick/integrations/shell.rb +7 -0
  131. data/lib/superkick/integrations/shortcut/README.md +193 -0
  132. data/lib/superkick/integrations/shortcut/drops.rb +91 -0
  133. data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
  134. data/lib/superkick/integrations/shortcut/probe.rb +34 -0
  135. data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
  136. data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
  137. data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
  138. data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
  139. data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
  140. data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
  141. data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
  142. data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
  143. data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
  144. data/lib/superkick/integrations/shortcut.rb +11 -0
  145. data/lib/superkick/integrations/slack/README.md +297 -0
  146. data/lib/superkick/integrations/slack/drops.rb +70 -0
  147. data/lib/superkick/integrations/slack/notifier.rb +426 -0
  148. data/lib/superkick/integrations/slack/spawner.rb +251 -0
  149. data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
  150. data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
  151. data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
  152. data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
  153. data/lib/superkick/integrations/slack.rb +12 -0
  154. data/lib/superkick/liquid.rb +129 -0
  155. data/lib/superkick/local/repository_source.rb +148 -0
  156. data/lib/superkick/mcp_server.rb +596 -0
  157. data/lib/superkick/monitor.rb +215 -0
  158. data/lib/superkick/notification_dispatcher.rb +280 -0
  159. data/lib/superkick/notifier.rb +173 -0
  160. data/lib/superkick/notifier_state_store.rb +55 -0
  161. data/lib/superkick/notifier_template.rb +121 -0
  162. data/lib/superkick/notifiers/command.rb +124 -0
  163. data/lib/superkick/notifiers/terminal_bell.rb +41 -0
  164. data/lib/superkick/output_logger.rb +54 -0
  165. data/lib/superkick/poller.rb +126 -0
  166. data/lib/superkick/process_runner.rb +87 -0
  167. data/lib/superkick/pty_proxy.rb +403 -0
  168. data/lib/superkick/registry.rb +75 -0
  169. data/lib/superkick/repository_source.rb +195 -0
  170. data/lib/superkick/server.rb +211 -0
  171. data/lib/superkick/session_recorder.rb +154 -0
  172. data/lib/superkick/setup.rb +160 -0
  173. data/lib/superkick/spawn/agent_spawner.rb +311 -0
  174. data/lib/superkick/spawn/approval_store.rb +113 -0
  175. data/lib/superkick/spawn/handler.rb +144 -0
  176. data/lib/superkick/spawn/injector.rb +119 -0
  177. data/lib/superkick/spawn/workflow_executor.rb +196 -0
  178. data/lib/superkick/spawn/workflow_validator.rb +77 -0
  179. data/lib/superkick/spawner.rb +67 -0
  180. data/lib/superkick/supervisor.rb +516 -0
  181. data/lib/superkick/team/artifact_store.rb +92 -0
  182. data/lib/superkick/team/log.rb +140 -0
  183. data/lib/superkick/team/log_entry_drop.rb +34 -0
  184. data/lib/superkick/team/log_monitor.rb +84 -0
  185. data/lib/superkick/team/log_notifier.rb +96 -0
  186. data/lib/superkick/team/log_store.rb +40 -0
  187. data/lib/superkick/template_filters.rb +24 -0
  188. data/lib/superkick/template_renderer.rb +223 -0
  189. data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
  190. data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
  191. data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
  192. data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
  193. data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
  194. data/lib/superkick/version.rb +5 -0
  195. data/lib/superkick/version_control.rb +135 -0
  196. data/lib/superkick/yaml_config.rb +302 -0
  197. data/lib/superkick.rb +198 -0
  198. data/plan.md +267 -0
  199. metadata +404 -0
@@ -0,0 +1,516 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ # Supervises all poller lifecycles — monitor threads (per-agent) and spawner
5
+ # threads (server-level) — in response to commands from Control::Server.
6
+ #
7
+ # Commands processed from the queue:
8
+ # :register → start all monitors for a newly registered agent
9
+ # :add_monitor → start one monitor for an existing agent
10
+ # :remove_monitor → stop one monitor
11
+ # :restart_monitors → stop then restart all monitors for an agent
12
+ # :start_spawner → start a spawner via IPC
13
+ #
14
+ # At server startup, call restore_from_registry to resume monitors for all
15
+ # agents already persisted (e.g. after a server restart).
16
+ class Supervisor
17
+ attr_reader :agent_threads, :spawner_threads, :worker, :goal_threads, :cost_poller_threads
18
+
19
+ def initialize(store:, injector:, notification_dispatcher:, buffer_client: nil, config: Superkick.config, agent_spawner: nil, approval_store: nil, team_log_store: nil)
20
+ @store = store
21
+ @injector = injector
22
+ @config = config
23
+ @approval_store = approval_store
24
+ @notification_dispatcher = notification_dispatcher
25
+ @team_log_store = team_log_store
26
+ @buffer_client = buffer_client || Buffer.client_from(store:, config:)
27
+ self.agent_spawner = agent_spawner
28
+ @command_queue = Queue.new
29
+ @worker = nil
30
+ @agent_threads = {}
31
+ @agent_threads_mutex = Mutex.new
32
+ @spawner_threads = {}
33
+ @spawners = {}
34
+ @goal_threads = {}
35
+ @goals = {}
36
+ @goals_mutex = Mutex.new
37
+ @goal_paused = {}
38
+ @goal_elapsed = {}
39
+ @goal_started_at = {}
40
+ @cost_poller_threads = {}
41
+ end
42
+
43
+ def start
44
+ @worker = Thread.new { work_loop }
45
+ self
46
+ end
47
+
48
+ def agent_spawner=(spawner)
49
+ @agent_spawner = spawner
50
+ @workflow_executor = spawner ? Spawn::WorkflowExecutor.new(
51
+ store: @store, agent_spawner: spawner, notification_dispatcher: @notification_dispatcher,
52
+ config: @config
53
+ ) : nil
54
+ end
55
+
56
+ def stop
57
+ @command_queue << {action: :shutdown}
58
+ @worker&.join(5)
59
+
60
+ # Stop all agent monitor threads
61
+ @agent_threads_mutex.synchronize do
62
+ @agent_threads.each_value { it.each_value(&:kill) }
63
+ @agent_threads.clear
64
+ end
65
+
66
+ # Stop all goal checker threads
67
+ @goals_mutex.synchronize do
68
+ @goal_threads.each_value(&:kill)
69
+ @goal_threads.clear
70
+ @goals.each_value(&:teardown)
71
+ @goals.clear
72
+ end
73
+
74
+ # Stop all cost poller threads
75
+ @cost_poller_threads.each_value(&:kill)
76
+ @cost_poller_threads.clear
77
+
78
+ # Stop all spawner threads
79
+ @spawner_threads.each_value(&:kill)
80
+ @spawner_threads.clear
81
+ @spawners.clear
82
+ end
83
+
84
+ # Control::Server calls this to enqueue commands.
85
+ def enqueue(action, **params)
86
+ @command_queue << {action:}.merge(params)
87
+ end
88
+
89
+ # Call once at server boot to re-create monitor threads for persisted agents.
90
+ def restore_from_registry
91
+ @store.each do |agent|
92
+ agent.monitors.each_key { start_agent_monitor(agent.id, it) }
93
+ end
94
+ end
95
+
96
+ # Return status info for all spawners.
97
+ def spawner_status
98
+ @spawners.map do |name, spawner|
99
+ thread = @spawner_threads[name]
100
+ config = @config.spawners[name] || {}
101
+ info = {
102
+ name:,
103
+ type: spawner.class.type.to_s,
104
+ status: thread&.alive? ? "running" : "stopped"
105
+ }
106
+ info[:on_complete_target] = workflow_target_name(config[:on_complete]) if config[:on_complete]
107
+ info[:on_fail_target] = workflow_target_name(config[:on_fail]) if config[:on_fail]
108
+ info
109
+ end
110
+ end
111
+
112
+ # Stop a single spawner by name.
113
+ def stop_spawner(name)
114
+ thread = @spawner_threads.delete(name)
115
+ thread&.kill
116
+ @spawners.delete(name)
117
+ Superkick.logger.info("supervisor") { "Stopped spawner #{name}" }
118
+ end
119
+
120
+ # Start spawner threads for each configured spawner.
121
+ def start_spawners
122
+ @config.spawners.each do |name, config|
123
+ start_spawner_monitor(name, config)
124
+ end
125
+ end
126
+
127
+ # Returns the goal instance for a given agent, or nil.
128
+ def goal_for(agent_id)
129
+ @goals_mutex.synchronize { @goals[agent_id] }
130
+ end
131
+
132
+ # Called by Control::Server when a superkick_signal_goal arrives for an agent.
133
+ # Forwards the signal to the AgentSignal goal if one exists.
134
+ def signal_goal(agent_id, status)
135
+ @goals_mutex.synchronize do
136
+ goal = @goals[agent_id]
137
+ goal.signal!(status) if goal.respond_to?(:signal!)
138
+ end
139
+ end
140
+
141
+ # Pause the goal checker for a claimed agent. The thread continues to
142
+ # sleep but skips check logic. Elapsed time is snapshotted so
143
+ # max_duration doesn't tick during the pause.
144
+ def pause_goal_checker(agent_id)
145
+ @goals_mutex.synchronize do
146
+ @goal_paused[agent_id] = true
147
+ if @goal_started_at[agent_id]
148
+ @goal_elapsed[agent_id] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @goal_started_at[agent_id]
149
+ end
150
+ end
151
+ Superkick.logger.info("supervisor") { "Goal checker paused for #{agent_id}" }
152
+ end
153
+
154
+ # Resume the goal checker for an unclaimed agent. Restores started_at
155
+ # so the remaining max_duration budget is preserved.
156
+ def resume_goal_checker(agent_id)
157
+ @goals_mutex.synchronize do
158
+ @goal_paused.delete(agent_id)
159
+ if @goal_elapsed[agent_id]
160
+ @goal_started_at[agent_id] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @goal_elapsed[agent_id]
161
+ @goal_elapsed.delete(agent_id)
162
+ end
163
+ end
164
+ Superkick.logger.info("supervisor") { "Goal checker resumed for #{agent_id}" }
165
+ end
166
+
167
+ # Returns whether the goal checker for an agent is paused.
168
+ def goal_paused?(agent_id)
169
+ @goals_mutex.synchronize { @goal_paused[agent_id] == true }
170
+ end
171
+
172
+ # Start a goal checker for a spawned agent.
173
+ def start_goal_checker(agent_id:, goal_config:, spawner_config:)
174
+ @goals_mutex.synchronize do
175
+ return if @goal_threads[agent_id]&.alive?
176
+ end
177
+
178
+ goal = Goal.build(goal_config, agent_id:)
179
+ @goals_mutex.synchronize { @goals[agent_id] = goal }
180
+
181
+ interval = goal_config[:check_interval] || @config.poll_interval
182
+ max_duration = spawner_config[:max_duration]
183
+
184
+ stall_threshold = spawner_config[:stall_threshold]
185
+
186
+ @goals_mutex.synchronize do
187
+ @goal_threads[agent_id] = Thread.new do
188
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
189
+ @goals_mutex.synchronize { @goal_started_at[agent_id] = started_at }
190
+ stalled_notified = false
191
+ Superkick.logger.info("supervisor") { "Goal checker started for #{agent_id} (type=#{goal_config[:type]})" }
192
+
193
+ loop do
194
+ sleep(interval)
195
+
196
+ # Skip checks when paused (claimed agent)
197
+ if @goals_mutex.synchronize { @goal_paused[agent_id] }
198
+ next
199
+ end
200
+
201
+ # Check max_duration timeout using tracked started_at
202
+ if max_duration
203
+ current_started_at = @goals_mutex.synchronize { @goal_started_at[agent_id] }
204
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - current_started_at
205
+ if elapsed >= max_duration
206
+ Superkick.logger.warn("supervisor") { "Agent #{agent_id} exceeded max_duration (#{max_duration}s) — terminating" }
207
+ agent = @store.get(agent_id)
208
+ agent&.set_goal_status(:timed_out)
209
+ notify_lifecycle(:agent_timed_out, agent_id:,
210
+ spawner_name: spawner_config[:name],
211
+ message: "Agent #{agent_id} timed out after #{max_duration}s")
212
+
213
+ # If this is a team lead, terminate all team members
214
+ if agent&.team_id && agent&.team_role == :lead
215
+ terminate_team(agent.team_id, reason: :timed_out)
216
+ else
217
+ @agent_spawner&.terminate(agent_id)
218
+ end
219
+ break
220
+ end
221
+ end
222
+
223
+ # Check goal status
224
+ status = goal.check
225
+ next if status == :pending
226
+
227
+ agent = @store.get(agent_id)
228
+ agent&.set_goal_status(status)
229
+
230
+ if Goal::TERMINAL_STATUSES.include?(status)
231
+ Superkick.logger.info("supervisor") { "Agent #{agent_id} goal terminal: #{status}" }
232
+ notify_lifecycle_for_goal(status, agent_id:, spawner_name: spawner_config[:name])
233
+ @workflow_executor&.fire(agent_id:, goal_status: status, spawner_config:)
234
+
235
+ # If this is a team lead, terminate all team members
236
+ agent = @store.get(agent_id)
237
+ if agent&.team_id && agent&.team_role == :lead
238
+ terminate_team(agent.team_id, reason: status)
239
+ else
240
+ @agent_spawner&.terminate(agent_id)
241
+ end
242
+ break
243
+ elsif status == :errored
244
+ Superkick.logger.info("supervisor") { "Agent #{agent_id} goal status: #{status}" }
245
+ notify_lifecycle(:agent_blocked, agent_id:,
246
+ spawner_name: spawner_config[:name],
247
+ message: "Agent #{agent_id} needs help (goal status: errored)")
248
+ else
249
+ Superkick.logger.info("supervisor") { "Agent #{agent_id} goal status: #{status}" }
250
+ end
251
+
252
+ # Stall detection
253
+ if stall_threshold
254
+ state = query_idle_state(agent_id)
255
+ if state && state[:seconds_idle] && state[:seconds_idle] >= stall_threshold
256
+ unless stalled_notified
257
+ notify_lifecycle(:agent_stalled, agent_id:,
258
+ spawner_name: spawner_config[:name],
259
+ message: "Agent #{agent_id} idle for #{state[:seconds_idle].round}s")
260
+ stalled_notified = true
261
+ end
262
+ else
263
+ stalled_notified = false
264
+ end
265
+ end
266
+ rescue => e
267
+ Superkick.logger.error("supervisor") { "Goal check error for #{agent_id}: #{e.message}" }
268
+ end
269
+ ensure
270
+ @goals_mutex.synchronize do
271
+ @goals.delete(agent_id)&.teardown
272
+ @goal_threads.delete(agent_id)
273
+ @goal_paused.delete(agent_id)
274
+ @goal_elapsed.delete(agent_id)
275
+ @goal_started_at.delete(agent_id)
276
+ end
277
+ end
278
+ end
279
+ end
280
+
281
+ # Stop a goal checker for a given agent.
282
+ def stop_goal_checker(agent_id)
283
+ thread = @goals_mutex.synchronize { @goal_threads.delete(agent_id) }
284
+ thread&.kill
285
+ @goals_mutex.synchronize { @goals.delete(agent_id)&.teardown }
286
+ stop_cost_poller(agent_id)
287
+ end
288
+
289
+ # Start a cost poller for a spawned agent.
290
+ def start_cost_poller(agent_id:, spawner_config: nil)
291
+ return if @cost_poller_threads[agent_id]&.alive?
292
+
293
+ poller = CostPoller.new(agent_id:, store: @store)
294
+ @cost_poller_threads[agent_id] = Thread.new do
295
+ Superkick.logger.info("supervisor") { "Cost poller started for #{agent_id}" }
296
+ poller.run
297
+ rescue => e
298
+ Superkick.logger.error("supervisor") { "Cost poller error (#{agent_id}): #{e.message}" }
299
+ end
300
+ end
301
+
302
+ # Stop a cost poller for a given agent.
303
+ def stop_cost_poller(agent_id)
304
+ thread = @cost_poller_threads.delete(agent_id)
305
+ thread&.kill
306
+ end
307
+
308
+ # Terminate all agents in a team. Called when the team lead times out
309
+ # or the team goal reaches a terminal status.
310
+ def terminate_team(team_id, reason: :timed_out)
311
+ teammates = @store.team(team_id)
312
+ Superkick.logger.info("supervisor") { "Terminating team #{team_id} (#{teammates.size} agents, reason: #{reason})" }
313
+
314
+ teammates.each do |agent|
315
+ agent.set_goal_status(reason)
316
+ @agent_spawner&.terminate(agent.id)
317
+ end
318
+
319
+ event_type = case reason
320
+ when :completed then :team_completed
321
+ when :failed then :team_failed
322
+ when :timed_out then :team_timed_out
323
+ else :team_failed
324
+ end
325
+
326
+ event = {
327
+ event_type:,
328
+ monitor_type: :spawner,
329
+ monitor_name: :system,
330
+ team_members: teammates.map(&:id)
331
+ }
332
+ dispatch_notification(
333
+ event:,
334
+ agent_id: team_id,
335
+ message: "Team #{team_id} #{reason} (#{teammates.size} agents)"
336
+ )
337
+ end
338
+
339
+ private
340
+
341
+ # Extract a human-readable workflow target name from a workflow config.
342
+ def workflow_target_name(workflow_config)
343
+ workflow_config&.dig(:spawner)&.to_s || "inline"
344
+ end
345
+
346
+ # Dispatch a lifecycle notification event.
347
+ # The Team::LogNotifier handles writing to the team log.
348
+ def notify_lifecycle(event_type, agent_id:, spawner_name: nil, message: nil)
349
+ event = {
350
+ event_type:,
351
+ monitor_type: :spawner,
352
+ monitor_name: spawner_name || :system
353
+ }
354
+ msg = message || "Superkick: #{event_type} for #{agent_id}"
355
+ dispatch_notification(event:, agent_id:, message: msg)
356
+ end
357
+
358
+ # Map a terminal goal status to the appropriate lifecycle event.
359
+ def notify_lifecycle_for_goal(status, agent_id:, spawner_name: nil)
360
+ event_type = case status
361
+ when :completed then :agent_completed
362
+ when :failed then :agent_failed
363
+ when :timed_out then :agent_timed_out
364
+ else return # non-terminal statuses don't get lifecycle notifications here
365
+ end
366
+ notify_lifecycle(event_type, agent_id:, spawner_name:,
367
+ message: "Agent #{agent_id} goal #{status}")
368
+ end
369
+
370
+ # Query idle state from the agent via the buffer client.
371
+ def query_idle_state(agent_id)
372
+ return nil unless @buffer_client.reachable?(agent_id)
373
+
374
+ response = @buffer_client.request(agent_id, "idle_state")
375
+ response&.fetch(:ok, false) ? response : nil
376
+ rescue Buffer::Client::AgentUnreachable, SystemCallError, IOError, JSON::ParserError => e
377
+ Superkick.logger.debug("supervisor:#{agent_id}") { "Idle state query error: #{e.message}" }
378
+ nil
379
+ end
380
+
381
+ def work_loop
382
+ loop do
383
+ command = @command_queue.pop
384
+ process(command)
385
+ break if command[:action] == :shutdown
386
+ rescue => e
387
+ Superkick.logger.error("supervisor") { "Supervisor error: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
388
+ end
389
+ end
390
+
391
+ def process(command)
392
+ agent_id = command[:agent_id]
393
+ name = command[:monitor_name]
394
+
395
+ case command[:action]
396
+ when :register then start_all_agent_monitors(agent_id)
397
+ when :add_monitor then start_agent_monitor(agent_id, name)
398
+ when :remove_monitor then stop_agent_monitor(agent_id, name)
399
+ when :restart_monitors then restart_agent_monitors(agent_id)
400
+ when :unregister
401
+ stop_all_agent_monitors(agent_id)
402
+ stop_goal_checker(agent_id)
403
+ when :start_spawner
404
+ config = command[:config] || @config.spawners[name]
405
+ start_spawner_monitor(name, config) if config
406
+ when :shutdown then nil
407
+ end
408
+ end
409
+
410
+ def start_agent_monitor(agent_id, name)
411
+ name_sym = name.to_sym
412
+ @agent_threads_mutex.synchronize do
413
+ t = @agent_threads.dig(agent_id, name_sym)
414
+ return if t&.alive?
415
+ end
416
+
417
+ agent = @store.get(agent_id)
418
+ return unless agent
419
+
420
+ config = agent.monitor_config(name) || {}
421
+ type_key = config[:type]&.to_sym || name_sym
422
+
423
+ klass = Monitor.registered[type_key]
424
+ unless klass
425
+ Superkick.logger.warn("supervisor") { "Unknown monitor type '#{type_key}' — skipping" }
426
+ return
427
+ end
428
+
429
+ # Resolve missing config values from the agent's environment snapshot.
430
+ config = klass.resolve_config(config, environment: agent.environment) if agent.environment
431
+
432
+ handler = InjectHandler.new(injector: @injector, agent:)
433
+ server_context = {team_log_store: @team_log_store}
434
+ monitor = klass.new(name: name_sym, agent:, config:, handler:, server_context:)
435
+
436
+ thread = Thread.new do
437
+ Superkick.logger.info("supervisor") { "Starting #{name_sym} monitor (type=#{type_key}) for #{agent_id}" }
438
+ monitor.run
439
+ rescue => e
440
+ Superkick.logger.error("supervisor") { "Monitor thread error (#{name_sym}/#{agent_id}): #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
441
+ ensure
442
+ @agent_threads_mutex.synchronize do
443
+ @agent_threads[agent_id]&.delete(name_sym)
444
+ threads = @agent_threads[agent_id]
445
+ @agent_threads.delete(agent_id) if threads && threads.empty?
446
+ end
447
+ end
448
+
449
+ @agent_threads_mutex.synchronize do
450
+ (@agent_threads[agent_id] ||= {})[name_sym] = thread
451
+ end
452
+ end
453
+
454
+ def stop_agent_monitor(agent_id, name)
455
+ name_sym = name.to_sym
456
+ thread = @agent_threads_mutex.synchronize { @agent_threads.dig(agent_id, name_sym) }
457
+ return unless thread
458
+
459
+ @agent_threads_mutex.synchronize { @agent_threads[agent_id]&.delete(name_sym) }
460
+ thread.kill
461
+ Superkick.logger.info("supervisor") { "Stopped #{name_sym} monitor for #{agent_id}" }
462
+ end
463
+
464
+ def start_all_agent_monitors(agent_id)
465
+ agent = @store.get(agent_id)
466
+ return unless agent
467
+
468
+ agent.monitors.each_key { start_agent_monitor(agent_id, it) }
469
+ end
470
+
471
+ def stop_all_agent_monitors(agent_id)
472
+ threads = @agent_threads_mutex.synchronize { @agent_threads.delete(agent_id) }
473
+ return unless threads
474
+
475
+ threads.each_value(&:kill)
476
+ end
477
+
478
+ def restart_agent_monitors(agent_id)
479
+ stop_all_agent_monitors(agent_id)
480
+ start_all_agent_monitors(agent_id)
481
+ end
482
+
483
+ def start_spawner_monitor(name, config)
484
+ return if @spawner_threads[name]&.alive?
485
+
486
+ type_key = config[:type]&.to_sym || name.to_sym
487
+ klass = Spawner.registered[type_key]
488
+ unless klass
489
+ Superkick.logger.warn("supervisor") { "Unknown spawner type '#{type_key}' — skipping" }
490
+ return
491
+ end
492
+
493
+ handler = Spawn::Handler.new(
494
+ spawner: @agent_spawner,
495
+ store: @store,
496
+ spawner_config: config,
497
+ approval_store: @approval_store,
498
+ notification_dispatcher: @notification_dispatcher,
499
+ repository_source: Superkick.config.repository_source
500
+ )
501
+ spawner = klass.new(name:, config:, handler:)
502
+ @spawners[name] = spawner
503
+
504
+ @spawner_threads[name] = Thread.new do
505
+ Superkick.logger.info("supervisor") { "Starting spawner #{name} (type=#{type_key})" }
506
+ spawner.run
507
+ rescue => e
508
+ Superkick.logger.error("supervisor") { "Spawner thread error (#{name}): #{e.message}" }
509
+ end
510
+ end
511
+
512
+ def dispatch_notification(...)
513
+ @notification_dispatcher.dispatch(...)
514
+ end
515
+ end
516
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Team
5
+ # ArtifactStore — per-team artifact storage for sharing data between agents.
6
+ #
7
+ # Artifacts are persisted to disk at ~/.superkick/teams/<team_id>/artifacts/<author>--<name>.json.
8
+ # Each artifact is a JSON file with name, author, content, and timestamps.
9
+ # Thread-safe via Mutex.
10
+ class ArtifactStore
11
+ Artifact = Data.define(:name, :author, :content, :created_at, :updated_at)
12
+
13
+ def initialize
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ # Publish (create or update) an artifact.
18
+ def publish(team_id:, author:, name:, content:)
19
+ @mutex.synchronize do
20
+ path = artifact_path(team_id, author, name)
21
+ FileUtils.mkdir_p(File.dirname(path))
22
+
23
+ now = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ")
24
+ existing = load_artifact(path)
25
+ created_at = existing ? existing[:created_at] : now
26
+
27
+ data = {name:, author:, content:, created_at:, updated_at: now}
28
+ File.write(path, JSON.pretty_generate(data))
29
+
30
+ Artifact.new(**data)
31
+ end
32
+ end
33
+
34
+ # Read an artifact by author and name.
35
+ def get(team_id:, author:, name:)
36
+ @mutex.synchronize do
37
+ data = load_artifact(artifact_path(team_id, author, name))
38
+ data ? Artifact.new(**data) : nil
39
+ end
40
+ end
41
+
42
+ # List artifacts for a team, optionally filtered by author.
43
+ # Returns metadata only (no content).
44
+ def list(team_id:, author: nil)
45
+ @mutex.synchronize do
46
+ dir = artifacts_dir(team_id)
47
+ return [] unless Dir.exist?(dir)
48
+
49
+ pattern = author ? "#{sanitize(author)}--*.json" : "*.json"
50
+ Dir.glob(File.join(dir, pattern)).filter_map do |path|
51
+ data = load_artifact(path)
52
+ next unless data
53
+ {name: data[:name], author: data[:author], created_at: data[:created_at], updated_at: data[:updated_at]}
54
+ end
55
+ end
56
+ end
57
+
58
+ # Delete an artifact.
59
+ def delete(team_id:, author:, name:)
60
+ @mutex.synchronize do
61
+ path = artifact_path(team_id, author, name)
62
+ return false unless File.exist?(path)
63
+ FileUtils.rm_f(path)
64
+ true
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def artifacts_dir(team_id)
71
+ File.join(Superkick.config.teams_dir, team_id.to_s, "artifacts")
72
+ end
73
+
74
+ def artifact_path(team_id, author, name)
75
+ File.join(artifacts_dir(team_id), "#{sanitize(author)}--#{sanitize(name)}.json")
76
+ end
77
+
78
+ def sanitize(str)
79
+ str.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
80
+ end
81
+
82
+ def load_artifact(path)
83
+ return nil unless File.exist?(path)
84
+ JSON.parse(File.read(path), symbolize_names: true)
85
+ rescue JSON::ParserError
86
+ nil
87
+ end
88
+ end
89
+ end
90
+
91
+ # Backwards-compatible alias
92
+ end