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.
- checksums.yaml +7 -0
- data/CLA.md +91 -0
- data/CLAUDE.md +2226 -0
- data/CONTRIBUTING.md +104 -0
- data/LICENSE +108 -0
- data/LICENSE-COMMERCIAL.md +39 -0
- data/PLAN.md +161 -0
- data/README.md +1155 -0
- data/exe/superkick +6 -0
- data/lib/superkick/agent/runtime.rb +82 -0
- data/lib/superkick/agent/runtimes/local.rb +74 -0
- data/lib/superkick/agent/runtimes.rb +4 -0
- data/lib/superkick/agent.rb +209 -0
- data/lib/superkick/agent_store.rb +85 -0
- data/lib/superkick/attach/client.rb +245 -0
- data/lib/superkick/attach/protocol.rb +71 -0
- data/lib/superkick/attach/server.rb +371 -0
- data/lib/superkick/budget_checker.rb +120 -0
- data/lib/superkick/buffer/client.rb +91 -0
- data/lib/superkick/buffer/server.rb +127 -0
- data/lib/superkick/cli/agent.rb +524 -0
- data/lib/superkick/cli/completion.rb +591 -0
- data/lib/superkick/cli/goal.rb +71 -0
- data/lib/superkick/cli/mcp.rb +34 -0
- data/lib/superkick/cli/monitor.rb +47 -0
- data/lib/superkick/cli/notifier.rb +39 -0
- data/lib/superkick/cli/repository.rb +46 -0
- data/lib/superkick/cli/server.rb +106 -0
- data/lib/superkick/cli/setup.rb +166 -0
- data/lib/superkick/cli/spawner.rb +85 -0
- data/lib/superkick/cli/team.rb +407 -0
- data/lib/superkick/cli.rb +175 -0
- data/lib/superkick/client_registry.rb +30 -0
- data/lib/superkick/configuration.rb +178 -0
- data/lib/superkick/connection.rb +56 -0
- data/lib/superkick/control/client.rb +78 -0
- data/lib/superkick/control/reply.rb +43 -0
- data/lib/superkick/control/server.rb +1271 -0
- data/lib/superkick/cost_accumulator.rb +53 -0
- data/lib/superkick/cost_extractor.rb +65 -0
- data/lib/superkick/cost_poller.rb +70 -0
- data/lib/superkick/driver/profile_source.rb +134 -0
- data/lib/superkick/driver.rb +179 -0
- data/lib/superkick/drivers/claude_code.rb +110 -0
- data/lib/superkick/drivers/codex.rb +57 -0
- data/lib/superkick/drivers/copilot.rb +75 -0
- data/lib/superkick/drivers/gemini.rb +86 -0
- data/lib/superkick/drivers/goose.rb +74 -0
- data/lib/superkick/drivers.rb +16 -0
- data/lib/superkick/drop.rb +80 -0
- data/lib/superkick/drops.rb +76 -0
- data/lib/superkick/environment_executor.rb +90 -0
- data/lib/superkick/goal.rb +95 -0
- data/lib/superkick/goals/agent_exit.rb +41 -0
- data/lib/superkick/goals/agent_signal.rb +42 -0
- data/lib/superkick/goals/command.rb +103 -0
- data/lib/superkick/history_buffer.rb +38 -0
- data/lib/superkick/hosted/attach/bridge.rb +52 -0
- data/lib/superkick/hosted/attach/client.rb +208 -0
- data/lib/superkick/hosted/attach/relay.rb +313 -0
- data/lib/superkick/hosted/attach/relay_store.rb +48 -0
- data/lib/superkick/hosted/bridge.rb +263 -0
- data/lib/superkick/hosted/buffer/bridge.rb +42 -0
- data/lib/superkick/hosted/buffer/client.rb +63 -0
- data/lib/superkick/hosted/buffer/relay.rb +126 -0
- data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
- data/lib/superkick/hosted/control/client.rb +84 -0
- data/lib/superkick/hosted/mcp_proxy.rb +144 -0
- data/lib/superkick/inject_handler.rb +24 -0
- data/lib/superkick/injection_guard.rb +26 -0
- data/lib/superkick/injection_queue.rb +177 -0
- data/lib/superkick/injector.rb +65 -0
- data/lib/superkick/input_buffer.rb +171 -0
- data/lib/superkick/integrations/bugsnag/README.md +98 -0
- data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
- data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/bugsnag.rb +7 -0
- data/lib/superkick/integrations/circleci/README.md +75 -0
- data/lib/superkick/integrations/circleci/monitor.rb +185 -0
- data/lib/superkick/integrations/circleci/probe.rb +36 -0
- data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
- data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/circleci.rb +8 -0
- data/lib/superkick/integrations/datadog/README.md +253 -0
- data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
- data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
- data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
- data/lib/superkick/integrations/datadog/notifier.rb +294 -0
- data/lib/superkick/integrations/datadog/spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
- data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
- data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
- data/lib/superkick/integrations/datadog.rb +14 -0
- data/lib/superkick/integrations/docker/README.md +256 -0
- data/lib/superkick/integrations/docker/client.rb +295 -0
- data/lib/superkick/integrations/docker/runtime.rb +218 -0
- data/lib/superkick/integrations/docker.rb +4 -0
- data/lib/superkick/integrations/git/repository_source.rb +66 -0
- data/lib/superkick/integrations/git/version_control.rb +119 -0
- data/lib/superkick/integrations/git.rb +8 -0
- data/lib/superkick/integrations/github/README.md +300 -0
- data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
- data/lib/superkick/integrations/github/drops.rb +114 -0
- data/lib/superkick/integrations/github/goal.rb +135 -0
- data/lib/superkick/integrations/github/issue_goal.rb +104 -0
- data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
- data/lib/superkick/integrations/github/monitor.rb +251 -0
- data/lib/superkick/integrations/github/probe.rb +30 -0
- data/lib/superkick/integrations/github/repository_source.rb +228 -0
- data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
- data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
- data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
- data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
- data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
- data/lib/superkick/integrations/github.rb +16 -0
- data/lib/superkick/integrations/honeybadger/README.md +97 -0
- data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
- data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
- data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
- data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/honeybadger.rb +9 -0
- data/lib/superkick/integrations/shell/README.md +83 -0
- data/lib/superkick/integrations/shell/monitor.rb +87 -0
- data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
- data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
- data/lib/superkick/integrations/shell.rb +7 -0
- data/lib/superkick/integrations/shortcut/README.md +193 -0
- data/lib/superkick/integrations/shortcut/drops.rb +91 -0
- data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
- data/lib/superkick/integrations/shortcut/probe.rb +34 -0
- data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
- data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
- data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
- data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
- data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
- data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
- data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
- data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
- data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
- data/lib/superkick/integrations/shortcut.rb +11 -0
- data/lib/superkick/integrations/slack/README.md +297 -0
- data/lib/superkick/integrations/slack/drops.rb +70 -0
- data/lib/superkick/integrations/slack/notifier.rb +426 -0
- data/lib/superkick/integrations/slack/spawner.rb +251 -0
- data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
- data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
- data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
- data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
- data/lib/superkick/integrations/slack.rb +12 -0
- data/lib/superkick/liquid.rb +129 -0
- data/lib/superkick/local/repository_source.rb +148 -0
- data/lib/superkick/mcp_server.rb +596 -0
- data/lib/superkick/monitor.rb +215 -0
- data/lib/superkick/notification_dispatcher.rb +280 -0
- data/lib/superkick/notifier.rb +173 -0
- data/lib/superkick/notifier_state_store.rb +55 -0
- data/lib/superkick/notifier_template.rb +121 -0
- data/lib/superkick/notifiers/command.rb +124 -0
- data/lib/superkick/notifiers/terminal_bell.rb +41 -0
- data/lib/superkick/output_logger.rb +54 -0
- data/lib/superkick/poller.rb +126 -0
- data/lib/superkick/process_runner.rb +87 -0
- data/lib/superkick/pty_proxy.rb +403 -0
- data/lib/superkick/registry.rb +75 -0
- data/lib/superkick/repository_source.rb +195 -0
- data/lib/superkick/server.rb +211 -0
- data/lib/superkick/session_recorder.rb +154 -0
- data/lib/superkick/setup.rb +160 -0
- data/lib/superkick/spawn/agent_spawner.rb +311 -0
- data/lib/superkick/spawn/approval_store.rb +113 -0
- data/lib/superkick/spawn/handler.rb +144 -0
- data/lib/superkick/spawn/injector.rb +119 -0
- data/lib/superkick/spawn/workflow_executor.rb +196 -0
- data/lib/superkick/spawn/workflow_validator.rb +77 -0
- data/lib/superkick/spawner.rb +67 -0
- data/lib/superkick/supervisor.rb +516 -0
- data/lib/superkick/team/artifact_store.rb +92 -0
- data/lib/superkick/team/log.rb +140 -0
- data/lib/superkick/team/log_entry_drop.rb +34 -0
- data/lib/superkick/team/log_monitor.rb +84 -0
- data/lib/superkick/team/log_notifier.rb +96 -0
- data/lib/superkick/team/log_store.rb +40 -0
- data/lib/superkick/template_filters.rb +24 -0
- data/lib/superkick/template_renderer.rb +223 -0
- data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
- data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
- data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
- data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
- data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
- data/lib/superkick/version.rb +5 -0
- data/lib/superkick/version_control.rb +135 -0
- data/lib/superkick/yaml_config.rb +302 -0
- data/lib/superkick.rb +198 -0
- data/plan.md +267 -0
- 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
|