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,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Superkick
6
+ module Spawn
7
+ # AgentSpawner — manages repository acquisition and agent subprocess lifecycle
8
+ # for spawned agents. Delegates process management to an Agent::Runtime.
9
+ class AgentSpawner
10
+ attr_writer :supervisor
11
+
12
+ def initialize(store:, notification_dispatcher:, config: Superkick.config, runtime: Agent::Runtime::Local.new,
13
+ spawn_injector: nil, supervisor: nil)
14
+ @store = store
15
+ @config = config
16
+ @runtime = runtime
17
+ @spawn_injector = spawn_injector || Spawn::Injector.new(store:)
18
+ @supervisor = supervisor
19
+ @notification_dispatcher = notification_dispatcher
20
+ @runtime_handles = {}
21
+ @vcs_state = {} # agent_id => { adapter:, destination: }
22
+ @cooldowns = {}
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # Spawn a new agent.
27
+ # @return [Hash] { status:, agent_id:, handle: }
28
+ def spawn(event:, spawner_config:)
29
+ max = spawner_config[:max_concurrent]
30
+ if max && active_count >= max
31
+ return {status: :at_capacity}
32
+ end
33
+
34
+ spawner_name = spawner_config[:name]
35
+ if spawner_name && cooldown_active?(spawner_name, spawner_config)
36
+ Superkick.logger.info("spawner") { "Cooldown active for #{spawner_name} — skipping" }
37
+ return {status: :cooldown}
38
+ end
39
+
40
+ agent_id = generate_agent_id(event, spawner_config)
41
+
42
+ # Workflow VCS inheritance: skip setup if inherited VCS state is present
43
+ if event[:inherited_vcs_state]
44
+ @mutex.synchronize { @vcs_state[agent_id] = event[:inherited_vcs_state] }
45
+ working_dir = event[:working_dir] || Dir.pwd
46
+ else
47
+ working_dir = run_setup(event, spawner_config)
48
+ end
49
+
50
+ handle = start_proxy(agent_id:, working_dir:,
51
+ spawner_config:)
52
+
53
+ @mutex.synchronize { @runtime_handles[agent_id] = handle }
54
+
55
+ wait_for_registration(agent_id)
56
+
57
+ # Stamp spawn_info on the agent
58
+ agent = @store.get(agent_id)
59
+ if agent
60
+ info = {
61
+ spawner_name:,
62
+ event_type: event[:event_type].to_s,
63
+ spawned_at: Time.now.iso8601
64
+ }
65
+ # Workflow metadata
66
+ info[:workflow_depth] = event[:workflow_depth] if event[:workflow_depth]
67
+ info[:workflow_iterations] = event[:workflow_iterations] if event[:workflow_iterations]
68
+ info[:parent_agent_id] = event[:parent_agent_id] if event[:parent_agent_id]
69
+ info[:workflow_source] = spawner_config[:workflow_source] if spawner_config[:workflow_source]
70
+ info[:working_dir] = working_dir
71
+ info[:context] = extract_context(event)
72
+ agent.spawn_info = info
73
+ agent.set_working_dir(working_dir)
74
+
75
+ # Agent metadata from event
76
+ agent.set_team(team_id: event[:team_id], team_role: event[:team_role]) if event[:team_id]
77
+ agent.set_role(event[:role]) if event[:role]
78
+
79
+ # Auto-attach monitors from spawner event (includes team_log when present)
80
+ attach_spawn_monitors(agent, event)
81
+
82
+ # Auto-attach per-agent notifiers from spawner event
83
+ attach_spawn_notifiers(agent, event)
84
+ end
85
+
86
+ @spawn_injector.inject(agent_id:, event:,
87
+ spawner_config:)
88
+
89
+ # Record cooldown timestamp
90
+ @mutex.synchronize { @cooldowns[spawner_name] = Process.clock_gettime(Process::CLOCK_MONOTONIC) } if spawner_name
91
+
92
+ # Start goal checker — defaults to agent_reported when no goal is configured
93
+ goal_config = spawner_config[:goal] || {type: :agent_signal}
94
+ if @supervisor
95
+ enriched = goal_config.merge(working_dir:)
96
+ # Wire team_id into goal config so the group goal can find its workers
97
+ enriched = enriched.merge(team_id: event[:team_id]) if event[:team_id]
98
+ # Inject rehydrated event context so goals can access Drops directly
99
+ # (e.g. self[:issue].number for GitHubIssueResolvedGoal)
100
+ context = extract_context(event)
101
+ rehydrated = Superkick::Drop.rehydrate(context)
102
+ rehydrated.each { |k, v| enriched[k] = v unless enriched.key?(k) }
103
+ @supervisor.start_goal_checker(agent_id:, goal_config: enriched, spawner_config:)
104
+ end
105
+
106
+ # Start cost poller if any budget is configured
107
+ if spawner_config[:budget]&.any? && @supervisor
108
+ @supervisor.start_cost_poller(agent_id:, spawner_config:)
109
+ end
110
+
111
+ dispatch_notification(
112
+ event: {event_type: :agent_spawned, monitor_type: :spawner, monitor_name: spawner_name || :system},
113
+ agent_id:,
114
+ message: "Agent #{agent_id} spawned by #{spawner_name || "system"}"
115
+ )
116
+
117
+ # Fire team_created when a team lead is spawned
118
+ if event[:team_id] && event[:team_role] == :lead
119
+ dispatch_notification(
120
+ event: {event_type: :team_created, monitor_type: :spawner, monitor_name: spawner_name || :system,
121
+ team_id: event[:team_id], team_members: [agent_id]},
122
+ agent_id:,
123
+ message: "Team #{event[:team_id]} created with lead #{agent_id}"
124
+ )
125
+ end
126
+
127
+ {status: :spawned, agent_id:, handle:}
128
+ rescue => e
129
+ Superkick.logger.error("spawner") { "Spawn failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
130
+ {status: :error, error: e.message}
131
+ end
132
+
133
+ # Clean up when a spawned agent ends.
134
+ def agent_ended(agent_id)
135
+ vcs = @mutex.synchronize { @vcs_state.delete(agent_id) }
136
+ vcs[:adapter].teardown(destination: vcs[:destination]) if vcs
137
+
138
+ @mutex.synchronize { @runtime_handles.delete(agent_id) }
139
+ rescue => e
140
+ Superkick.logger.warn("agent_spawner") { "Cleanup failed for #{agent_id}: #{e.message}" }
141
+ end
142
+
143
+ # Terminate a spawned agent gracefully.
144
+ def terminate(agent_id)
145
+ handle = @mutex.synchronize { @runtime_handles[agent_id] }
146
+ return unless handle
147
+
148
+ @runtime.terminate(handle:)
149
+ rescue Errno::ESRCH
150
+ agent_ended(agent_id)
151
+ end
152
+
153
+ def active_count
154
+ @mutex.synchronize { @runtime_handles.size }
155
+ end
156
+
157
+ def runtime_handles
158
+ @mutex.synchronize { @runtime_handles.dup }
159
+ end
160
+
161
+ def register_handle(agent_id, handle)
162
+ @mutex.synchronize { @runtime_handles[agent_id] = handle }
163
+ end
164
+
165
+ # Record a cooldown timestamp for a spawner. Used by tests to simulate
166
+ # recent spawn activity without going through the full spawn flow.
167
+ def record_cooldown(spawner_name:, at: Process.clock_gettime(Process::CLOCK_MONOTONIC))
168
+ @mutex.synchronize { @cooldowns[spawner_name] = at }
169
+ end
170
+
171
+ # Remove and return the VCS state for an agent.
172
+ # Used by Spawn::WorkflowExecutor for VCS ownership transfer.
173
+ def take_vcs_state(agent_id)
174
+ @mutex.synchronize { @vcs_state.delete(agent_id) }
175
+ end
176
+
177
+ def generate_agent_id(event, config)
178
+ if event[:agent_id]
179
+ event[:agent_id]
180
+ else
181
+ base = config[:name] || "spawned"
182
+ "#{base}-#{Process.pid}-#{SecureRandom.hex(4)}"
183
+ end
184
+ end
185
+
186
+ def run_setup(event, config)
187
+ return Dir.pwd unless config[:repository]
188
+
189
+ agent_id = generate_agent_id(event, config)
190
+ acquire_repository(event, config, agent_id)
191
+ end
192
+
193
+ private
194
+
195
+ def attach_spawn_monitors(agent, event)
196
+ monitors = event[:_spawn_monitors]
197
+ return unless monitors.is_a?(Hash) && monitors.any?
198
+
199
+ monitors.each do |name, monitor_config|
200
+ agent.set_monitor_config(name.to_sym, monitor_config)
201
+ @supervisor&.enqueue(:add_monitor, agent_id: agent.id, monitor_name: name.to_sym)
202
+ end
203
+ end
204
+
205
+ def attach_spawn_notifiers(agent, event)
206
+ notifiers = event[:_spawn_notifiers]
207
+ return unless notifiers.is_a?(Hash) && notifiers.any?
208
+
209
+ notifiers.each do |name, notifier_config|
210
+ agent.set_notifier_config(name.to_sym, notifier_config)
211
+ end
212
+ @notification_dispatcher.register_agent_notifiers(agent.id) if @notification_dispatcher.respond_to?(:register_agent_notifiers)
213
+ end
214
+
215
+ def start_proxy(agent_id:, working_dir:, spawner_config:)
216
+ driver_config = resolve_driver_config(spawner_config[:driver])
217
+ exe = File.expand_path($PROGRAM_NAME)
218
+
219
+ command = [exe, "agent", "--agent-id", agent_id, "--headless"]
220
+
221
+ if driver_config
222
+ command += ["--driver", driver_config[:type].to_s] if driver_config[:type]
223
+ command += ["--driver-config-dir", File.expand_path(driver_config[:config_dir])] if driver_config[:config_dir]
224
+ command += ["--driver-command", driver_config[:command]] if driver_config[:command]
225
+ end
226
+
227
+ command += ["--"] + driver_config[:args] if driver_config&.dig(:args)&.any?
228
+
229
+ spawn_env = driver_config&.dig(:env) || {}
230
+ spawn_env["SUPERKICK_AGENT_ID"] = agent_id
231
+
232
+ @runtime.provision(
233
+ agent_id:,
234
+ config: {env: spawn_env, command:, working_dir:}
235
+ )
236
+ end
237
+
238
+ def resolve_driver_config(driver)
239
+ Driver.normalize_driver(driver, profile_source: @config.driver_profile_source)
240
+ end
241
+
242
+ def acquire_repository(event, config, agent_id)
243
+ repository_name = config[:repository].to_sym
244
+ repository = @config.repository_source.find_by_name(repository_name)
245
+ raise Poller::FatalError, "Unknown repository: #{repository_name}" unless repository
246
+
247
+ adapter = VersionControl.for_repository(repository)
248
+ branch = interpolate_branch(config[:branch_template] || "superkick-{{ agent_id }}", event)
249
+ base_branch = config[:base_branch] || "main"
250
+ destination = File.join(@config.workspaces_dir, agent_id)
251
+
252
+ adapter.acquire(source: repository, destination:, branch:, base_branch:)
253
+ @mutex.synchronize { @vcs_state[agent_id] = {adapter:, destination:} }
254
+ destination
255
+ end
256
+
257
+ # Keys that are internal spawn/workflow metadata, not integration context.
258
+ INTERNAL_EVENT_KEYS = %i[
259
+ event_type monitor_type monitor_name agent_id
260
+ team_id team_role role
261
+ inherited_pipeline inherited_vcs_state working_dir
262
+ parent_agent_id parent_goal_status parent_goal_summary parent_spawner_name
263
+ workflow_depth workflow_iterations
264
+ _spawn_monitors _spawn_notifiers
265
+ ].freeze
266
+
267
+ def extract_context(event)
268
+ event.except(*INTERNAL_EVENT_KEYS)
269
+ .transform_values { Superkick::Drop.serialize(it) }
270
+ end
271
+
272
+ def interpolate_branch(template, event)
273
+ context = extract_context(event)
274
+ rehydrated = Superkick::Drop.rehydrate(context)
275
+ assigns = rehydrated.transform_keys(&:to_s)
276
+ assigns["agent_id"] = event[:agent_id].to_s if event[:agent_id]
277
+
278
+ liquid_template = ::Liquid::Template.parse(template)
279
+ liquid_template.render(assigns)
280
+ end
281
+
282
+ def cooldown_active?(spawner_name, config)
283
+ cooldown = config[:cooldown]
284
+ return false unless cooldown
285
+
286
+ last_spawn = @mutex.synchronize { @cooldowns[spawner_name] }
287
+ return false unless last_spawn
288
+
289
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - last_spawn
290
+ elapsed < cooldown
291
+ end
292
+
293
+ def wait_for_registration(agent_id, timeout: 30)
294
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
295
+ loop do
296
+ return if @store.has?(agent_id)
297
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
298
+ raise "Agent #{agent_id} did not register within #{timeout}s"
299
+ end
300
+ sleep 0.5
301
+ end
302
+ end
303
+
304
+ def dispatch_notification(...)
305
+ @notification_dispatcher.dispatch(...)
306
+ end
307
+ end
308
+ end
309
+
310
+ # Backwards-compatible alias
311
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Superkick
6
+ module Spawn
7
+ # Thread-safe store for pending spawn approvals and rejection records.
8
+ #
9
+ # When a spawner has `approval_required: true`, the Spawn::Handler stores
10
+ # the event here instead of spawning immediately. The user approves via
11
+ # `superkick approve <id>` (or rejects via `superkick reject <id>`), which
12
+ # triggers or discards the spawn.
13
+ #
14
+ # Rejected agent IDs are remembered so the spawner doesn't immediately
15
+ # re-dispatch the same event. Rejections can be cleared individually
16
+ # via `superkick approve --clear <id>` or all at once via
17
+ # `superkick approve --clear-all`.
18
+ class ApprovalStore
19
+ attr_reader :pending
20
+
21
+ def initialize
22
+ @pending = {}
23
+ @rejected = {}
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ # Add a pending approval. Returns the approval ID.
28
+ # Skips if the agent_id has been rejected.
29
+ def add(event:, spawner_config:)
30
+ id = generate_id(event)
31
+ @mutex.synchronize do
32
+ return nil if @rejected[id]
33
+
34
+ @pending[id] = {
35
+ id:,
36
+ event:,
37
+ spawner_config:,
38
+ created_at: Time.now.iso8601,
39
+ agent_id: event[:agent_id]
40
+ }
41
+ end
42
+ id
43
+ end
44
+
45
+ # Remove and return a pending approval by ID.
46
+ def take(id)
47
+ @mutex.synchronize { @pending.delete(id) }
48
+ end
49
+
50
+ # Get a pending approval without removing it.
51
+ def get(id)
52
+ @mutex.synchronize { @pending[id] }
53
+ end
54
+
55
+ # List all pending approvals.
56
+ def list
57
+ @mutex.synchronize { @pending.values.dup }
58
+ end
59
+
60
+ # Check if a pending or rejected entry exists for a given agent_id.
61
+ def has_agent?(agent_id)
62
+ @mutex.synchronize do
63
+ @rejected.key?(agent_id) ||
64
+ @pending.any? { |_, v| v[:agent_id] == agent_id }
65
+ end
66
+ end
67
+
68
+ # Record a rejection with an optional reason.
69
+ def reject(id, reason: nil)
70
+ entry = @mutex.synchronize { @pending.delete(id) }
71
+ return nil unless entry
72
+
73
+ @mutex.synchronize do
74
+ @rejected[id] = {
75
+ id:,
76
+ agent_id: entry[:agent_id],
77
+ rejected_at: Time.now.iso8601,
78
+ reason:
79
+ }
80
+ end
81
+ entry
82
+ end
83
+
84
+ # Check if an ID has been rejected.
85
+ def rejected?(id)
86
+ @mutex.synchronize { @rejected.key?(id) }
87
+ end
88
+
89
+ # List all rejections.
90
+ def rejections
91
+ @mutex.synchronize { @rejected.values.dup }
92
+ end
93
+
94
+ # Clear a specific rejection, allowing the event to be re-dispatched.
95
+ def clear_rejection(id)
96
+ @mutex.synchronize { @rejected.delete(id) }
97
+ end
98
+
99
+ # Clear all rejections.
100
+ def clear_all_rejections
101
+ @mutex.synchronize { @rejected.clear }
102
+ end
103
+
104
+ private
105
+
106
+ def generate_id(event)
107
+ event[:agent_id] || "approval-#{SecureRandom.hex(4)}"
108
+ end
109
+ end
110
+ end
111
+
112
+ # Backwards-compatible alias
113
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Spawn
5
+ # Handler — event handler for spawners.
6
+ #
7
+ # Receives events from spawners, checks dedup via AgentStore, and delegates
8
+ # to Spawn::AgentSpawner. This is the spawner equivalent of InjectHandler.
9
+ #
10
+ # When `approval_required: true` is set on the spawner config, events are
11
+ # held in the Spawn::ApprovalStore and a `agent_pending_approval` notification is
12
+ # fired instead of spawning immediately. The user approves via
13
+ # `superkick approve <id>`.
14
+ class Handler
15
+ attr_reader :result
16
+
17
+ def initialize(spawner:, store:, spawner_config:, notification_dispatcher:, approval_store: nil,
18
+ repository_source: nil)
19
+ @spawner = spawner
20
+ @store = store
21
+ @spawner_config = spawner_config
22
+ @approval_store = approval_store
23
+ @notification_dispatcher = notification_dispatcher
24
+ @repository_source = repository_source
25
+ @pending = []
26
+ @pending_mutex = Mutex.new
27
+ @result = nil
28
+ end
29
+
30
+ # Handle a dispatched event.
31
+ # @return [Symbol] :spawned, :duplicate, :at_capacity, :cooldown, :pending_approval, :error
32
+ def handle(event:)
33
+ agent_id = event[:agent_id]
34
+
35
+ if agent_id && @store.has?(agent_id)
36
+ Superkick.logger.info("spawn_handler") { "Agent #{agent_id} already exists — skipping" }
37
+ return :duplicate
38
+ end
39
+
40
+ # Check if approval is required
41
+ if @spawner_config[:approval_required] && @approval_store
42
+ if agent_id && @approval_store.has_agent?(agent_id)
43
+ Superkick.logger.info("spawn_handler") { "Approval already pending for #{agent_id} — skipping" }
44
+ return :pending_approval
45
+ end
46
+
47
+ approval_id = @approval_store.add(event:, spawner_config: @spawner_config)
48
+ spawner_name = @spawner_config[:name]
49
+ Superkick.logger.info("spawn_handler") { "Approval required for #{agent_id} — queued as #{approval_id}" }
50
+
51
+ dispatch_notification(
52
+ event: {event_type: :agent_pending_approval, monitor_type: :spawner, monitor_name: spawner_name || :system,
53
+ approval_id:},
54
+ agent_id: agent_id || approval_id,
55
+ message: "Agent #{agent_id || approval_id} pending approval from #{spawner_name || "spawner"}"
56
+ )
57
+ return :pending_approval
58
+ end
59
+
60
+ spawn_event(event)
61
+ rescue => e
62
+ Superkick.logger.error("spawn_handler") { "Spawn error: #{e.message}" }
63
+ :error
64
+ end
65
+
66
+ # Retry any pending events (e.g. those that were at capacity).
67
+ def flush_pending
68
+ events = @pending_mutex.synchronize do
69
+ evts = @pending.dup
70
+ @pending.clear
71
+ evts
72
+ end
73
+
74
+ events.each do |ev|
75
+ result = handle(event: ev)
76
+ @pending_mutex.synchronize { @pending << ev } if result == :at_capacity || result == :cooldown
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def spawn_event(event)
83
+ agent_id = event[:agent_id]
84
+
85
+ # Enrich event for workflow: team (team lead mode)
86
+ if @spawner_config[:workflow] == :team
87
+ event = enrich_for_team(event)
88
+ end
89
+
90
+ @result = @spawner.spawn(event:, spawner_config: effective_spawner_config)
91
+
92
+ case @result[:status]
93
+ when :spawned
94
+ Superkick.logger.info("spawn_handler") { "Spawned agent #{@result[:agent_id]} for #{agent_id}" }
95
+ when :at_capacity
96
+ Superkick.logger.warn("spawn_handler") { "At capacity — queuing #{agent_id}" }
97
+ @pending_mutex.synchronize { @pending << event }
98
+ when :cooldown
99
+ Superkick.logger.info("spawn_handler") { "Cooldown active — queuing #{agent_id}" }
100
+ @pending_mutex.synchronize { @pending << event }
101
+ end
102
+
103
+ @result[:status]
104
+ end
105
+
106
+ # Enrich a spawn event for workflow: team — add team metadata and
107
+ # repository source context so the planning agent can decompose work.
108
+ def enrich_for_team(event)
109
+ team_id = event[:agent_id]
110
+ event.merge(
111
+ team_id:,
112
+ team_role: :lead,
113
+ role: "Lead",
114
+ repository_context: @repository_source&.to_prompt_context,
115
+ prompt_template: "team/planning_agent",
116
+ _spawn_monitors: {
117
+ team_log: {type: :team_log, team_id:}
118
+ }
119
+ )
120
+ end
121
+
122
+ # Return the effective spawner config, applying driver cascade and
123
+ # team workflow defaults.
124
+ def effective_spawner_config
125
+ config = @spawner_config
126
+ if config[:workflow] == :team
127
+ config = config.merge(goal: {type: :agent_signal}) unless config[:goal]
128
+
129
+ lead_driver = config.dig(:team, :lead, :driver)
130
+ if lead_driver
131
+ config = config.merge(driver: Driver.merge_driver(config[:driver], lead_driver))
132
+ end
133
+ end
134
+ config
135
+ end
136
+
137
+ def dispatch_notification(...)
138
+ @notification_dispatcher.dispatch(...)
139
+ end
140
+ end
141
+ end
142
+
143
+ # Backwards-compatible alias
144
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superkick
4
+ module Spawn
5
+ # Injector — handles injecting a kickoff prompt into a newly spawned
6
+ # agent. Extracted from Spawn::AgentSpawner to isolate injection protocol logic
7
+ # (buffer client polling, template rendering) from workspace setup and
8
+ # process management.
9
+ #
10
+ # Uses the agent's InjectionQueue with :high priority so spawn kickoffs
11
+ # are injected before any queued monitor events.
12
+ class Injector
13
+ WORKFLOW_TEMPLATES_DIR = File.join(__dir__, "..", "templates", "workflow")
14
+
15
+ def initialize(store:, buffer_client: nil)
16
+ @store = store
17
+ @buffer_client = buffer_client || Buffer.client_from(store:)
18
+ end
19
+
20
+ # Inject a kickoff prompt into a spawned agent.
21
+ # Waits for the buffer to become reachable, renders the template,
22
+ # and sends the prompt via `enqueue_injection` with high priority.
23
+ def inject(agent_id:, event:, spawner_config:)
24
+ wait_for_buffer(agent_id)
25
+
26
+ prompt = render(event, spawner_config)
27
+ return unless prompt
28
+
29
+ enqueue_to_buffer(agent_id, prompt, event)
30
+ end
31
+
32
+ private
33
+
34
+ def wait_for_buffer(agent_id, timeout: 30)
35
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
36
+ loop do
37
+ break if @buffer_client.reachable?(agent_id)
38
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
39
+ Superkick.logger.warn("spawn_injector") { "Buffer not reachable for #{agent_id} — skipping kickoff" }
40
+ return
41
+ end
42
+ sleep 0.5
43
+ end
44
+ end
45
+
46
+ def render(event, spawner_config)
47
+ template_path = resolve_spawn_template(event, spawner_config)
48
+ return nil unless template_path
49
+
50
+ source = File.read(template_path, encoding: "utf-8")
51
+ render_event = rehydrate_context(event)
52
+ TemplateRenderer.render_source(source, render_event, monitor_type: event[:monitor_type])
53
+ end
54
+
55
+ # Rehydrate serialized Drop values in the event so templates get real
56
+ # Drop objects ({{ issue.title }}, {{ story.ref }}, etc.).
57
+ # Serialized context comes from workflow forwarding where Drops were
58
+ # serialized to hashes with _drop_type markers.
59
+ def rehydrate_context(event)
60
+ event.transform_values { Superkick::Drop.rehydrate(it) }
61
+ end
62
+
63
+ def enqueue_to_buffer(agent_id, prompt, event)
64
+ @buffer_client.send_command(agent_id, "enqueue_injection",
65
+ id: "spawn-#{agent_id}",
66
+ prompt:,
67
+ monitor_type: event[:monitor_type] || "spawner",
68
+ monitor_name: event[:monitor_name] || "system",
69
+ priority: "high",
70
+ ttl: 600)
71
+
72
+ Superkick.logger.info("spawn_injector") { "Enqueued kickoff prompt for #{agent_id}" }
73
+ end
74
+
75
+ def resolve_spawn_template(event, _spawner_config)
76
+ monitor_type = event[:monitor_type].to_s
77
+ monitor_name = event[:monitor_name]&.to_s
78
+ event_type = event[:event_type].to_s
79
+
80
+ # 1. User per-instance: ~/.superkick/templates/spawners/<name>/<event>.liquid
81
+ if monitor_name
82
+ path = File.join(Superkick.config.templates_dir, "spawners", monitor_name, "#{event_type}.liquid")
83
+ return path if File.exist?(path)
84
+ end
85
+
86
+ # 2. User per-type: ~/.superkick/templates/spawners/<type>/<event>.liquid
87
+ path = File.join(Superkick.config.templates_dir, "spawners", monitor_type, "#{event_type}.liquid")
88
+ return path if File.exist?(path)
89
+
90
+ # 2b. Workflow default: ~/.superkick/templates/spawners/workflow/<event>.liquid
91
+ if monitor_type == "workflow"
92
+ path = File.join(Superkick.config.templates_dir, "spawners", "workflow", "#{event_type}.liquid")
93
+ return path if File.exist?(path)
94
+ end
95
+
96
+ # 3. Bundled spawner templates from the spawner class
97
+ klass = Spawner.registered[monitor_type.to_sym]
98
+ if klass&.spawn_templates_dir
99
+ path = File.join(klass.spawn_templates_dir, "#{event_type}.liquid")
100
+ return path if File.exist?(path)
101
+ end
102
+
103
+ # 4. Bundled workflow default template
104
+ if monitor_type == "workflow"
105
+ path = File.join(WORKFLOW_TEMPLATES_DIR, "#{event_type}.liquid")
106
+ return path if File.exist?(path)
107
+
108
+ # Fall back to workflow_triggered.liquid for any workflow event type
109
+ path = File.join(WORKFLOW_TEMPLATES_DIR, "workflow_triggered.liquid")
110
+ return path if File.exist?(path)
111
+ end
112
+
113
+ nil
114
+ end
115
+ end
116
+ end
117
+
118
+ # Backwards-compatible alias
119
+ end