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,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Attach
|
|
5
|
+
# Binary wire protocol for the attach system. Shared by Attach::Server and
|
|
6
|
+
# Attach::Client. Uses length-prefixed frames — 1 byte type + 4 byte big-endian
|
|
7
|
+
# length + payload. JSON is only used for infrequent control messages (meta,
|
|
8
|
+
# resize, hello); output and input frames carry raw bytes with zero overhead.
|
|
9
|
+
module Protocol
|
|
10
|
+
OUTPUT = 0x01
|
|
11
|
+
INPUT = 0x02
|
|
12
|
+
META = 0x03
|
|
13
|
+
RESIZE = 0x04
|
|
14
|
+
HISTORY = 0x05
|
|
15
|
+
ERROR = 0x06
|
|
16
|
+
HELLO = 0x07
|
|
17
|
+
COMMAND = 0x08
|
|
18
|
+
NOTIFY = 0x09
|
|
19
|
+
|
|
20
|
+
# Write a single frame to an IO.
|
|
21
|
+
# @param io [IO] writable socket
|
|
22
|
+
# @param type [Integer] message type constant
|
|
23
|
+
# @param data [String] payload bytes
|
|
24
|
+
def self.write_frame(io, type, data)
|
|
25
|
+
data = data.b if data.encoding != Encoding::BINARY
|
|
26
|
+
header = [type, data.bytesize].pack("CN")
|
|
27
|
+
io.write(header)
|
|
28
|
+
io.write(data)
|
|
29
|
+
io.flush
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Read a single frame from an IO.
|
|
33
|
+
# @return [Array(Integer, String)] [type, payload] or nil on EOF
|
|
34
|
+
def self.read_frame(io)
|
|
35
|
+
header = io.read(5)
|
|
36
|
+
return nil unless header && header.bytesize == 5
|
|
37
|
+
|
|
38
|
+
type, len = header.unpack("CN")
|
|
39
|
+
payload = if len > 0
|
|
40
|
+
io.read(len)
|
|
41
|
+
else
|
|
42
|
+
"".b
|
|
43
|
+
end
|
|
44
|
+
return nil unless payload && payload.bytesize == len
|
|
45
|
+
|
|
46
|
+
[type, payload]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Build a frame as a binary string without writing to an IO.
|
|
50
|
+
# Used by the WebSocket bridges and transports to construct frames
|
|
51
|
+
# for sending over WebSocket binary messages.
|
|
52
|
+
# @param type [Integer] message type constant
|
|
53
|
+
# @param data [String] payload bytes
|
|
54
|
+
# @return [String] binary frame bytes
|
|
55
|
+
def self.build_frame(type, data)
|
|
56
|
+
data = data.b if data.encoding != Encoding::BINARY
|
|
57
|
+
[type, data.bytesize].pack("CN") + data
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Convenience: write a JSON-encoded frame.
|
|
61
|
+
def self.write_json_frame(io, type, hash)
|
|
62
|
+
write_frame(io, type, JSON.generate(hash))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Convenience: decode a JSON payload.
|
|
66
|
+
def self.decode_json(payload)
|
|
67
|
+
JSON.parse(payload, symbolize_names: true)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Superkick
|
|
7
|
+
module Attach
|
|
8
|
+
# Per-agent attach endpoint. Runs inside the `superkick agent` process
|
|
9
|
+
# alongside Buffer::Server. Manages connected attach clients and broadcasts
|
|
10
|
+
# PTY output to all of them.
|
|
11
|
+
#
|
|
12
|
+
# Supports multiple concurrent read-only clients and at most one read-write
|
|
13
|
+
# client at a time. Read-write clients can forward keystrokes and terminal
|
|
14
|
+
# resize events to the PTY.
|
|
15
|
+
#
|
|
16
|
+
# Mode switching:
|
|
17
|
+
# - Force-takeover: HELLO with `force: true` demotes existing RW holder
|
|
18
|
+
# - In-session: COMMAND frames with `promote_rw`, `force_promote_rw`,
|
|
19
|
+
# or `demote_ro` actions to switch modes without reconnecting
|
|
20
|
+
# - Auto-promote: claiming an agent auto-promotes to RW when slot is vacant
|
|
21
|
+
# - Idle timeout: RW clients auto-demoted after `attach_rw_idle_timeout`
|
|
22
|
+
# seconds of no INPUT frames
|
|
23
|
+
class Server
|
|
24
|
+
attr_reader :socket_path, :history
|
|
25
|
+
|
|
26
|
+
def initialize(agent_id:, pty_proxy:, control_client: nil, config: Superkick.config)
|
|
27
|
+
@agent_id = agent_id
|
|
28
|
+
@pty_proxy = pty_proxy
|
|
29
|
+
@control_client = control_client
|
|
30
|
+
@history = HistoryBuffer.new(capacity: config.attach_history_size)
|
|
31
|
+
@socket_path = config.attach_socket_path(agent_id)
|
|
32
|
+
@rw_idle_timeout = config.attach_rw_idle_timeout
|
|
33
|
+
@clients = [] # Array of { socket:, mode: }
|
|
34
|
+
@clients_mutex = Mutex.new
|
|
35
|
+
@server = nil
|
|
36
|
+
@accept_thread = nil
|
|
37
|
+
@idle_check_thread = nil
|
|
38
|
+
@rw_client = nil # at most one read-write client
|
|
39
|
+
@rw_last_input_at = nil # monotonic timestamp of last INPUT frame from RW client
|
|
40
|
+
@broadcast_callbacks = []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def start
|
|
44
|
+
FileUtils.mkdir_p(File.dirname(@socket_path))
|
|
45
|
+
FileUtils.rm_f(@socket_path)
|
|
46
|
+
@server = UNIXServer.new(@socket_path)
|
|
47
|
+
@accept_thread = Thread.new { accept_loop }
|
|
48
|
+
start_idle_check if @rw_idle_timeout && @rw_idle_timeout > 0
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def stop
|
|
53
|
+
begin
|
|
54
|
+
@server&.close
|
|
55
|
+
rescue IOError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
begin
|
|
59
|
+
@accept_thread&.kill
|
|
60
|
+
rescue ThreadError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
begin
|
|
64
|
+
@idle_check_thread&.kill
|
|
65
|
+
rescue ThreadError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
@clients_mutex.synchronize do
|
|
69
|
+
@clients.each do |c|
|
|
70
|
+
c[:socket].close
|
|
71
|
+
rescue IOError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
@clients.clear
|
|
75
|
+
end
|
|
76
|
+
FileUtils.rm_f(@socket_path)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Register a callback that receives every data chunk passed to broadcast().
|
|
80
|
+
# Used by the Hosted::Attach::Bridge to forward output to the relay.
|
|
81
|
+
def on_broadcast(&block)
|
|
82
|
+
@broadcast_callbacks << block
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Called by PtyProxy#proxy_output on every chunk of PTY output.
|
|
86
|
+
# Writes to history buffer and broadcasts to all connected clients.
|
|
87
|
+
def broadcast(data)
|
|
88
|
+
@history.write(data)
|
|
89
|
+
|
|
90
|
+
@clients_mutex.synchronize do
|
|
91
|
+
@clients.reject! do |client|
|
|
92
|
+
Protocol.write_frame(client[:socket], Protocol::OUTPUT, data)
|
|
93
|
+
false
|
|
94
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
95
|
+
Superkick.logger.debug("attach:#{@agent_id}") { "Client disconnected" }
|
|
96
|
+
@rw_client = nil if @rw_client == client[:socket]
|
|
97
|
+
true # remove from list
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
@broadcast_callbacks.each { it.call(data) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Accept a raw Protocol frame from the hosted bridge (remote input).
|
|
105
|
+
# The relay handles RW exclusivity for remote users — it only forwards
|
|
106
|
+
# INPUT and RESIZE frames from the RW user.
|
|
107
|
+
def handle_remote_input(data)
|
|
108
|
+
data = data.b if data.is_a?(String) && data.encoding != Encoding::BINARY
|
|
109
|
+
return if data.bytesize < 5
|
|
110
|
+
|
|
111
|
+
type = data.getbyte(0)
|
|
112
|
+
payload_len = data[1, 4].unpack1("N")
|
|
113
|
+
payload = data[5, payload_len]
|
|
114
|
+
return unless payload
|
|
115
|
+
|
|
116
|
+
case type
|
|
117
|
+
when Protocol::INPUT
|
|
118
|
+
@pty_proxy.enqueue_inject(payload)
|
|
119
|
+
when Protocol::RESIZE
|
|
120
|
+
resize_data = Protocol.decode_json(payload)
|
|
121
|
+
@pty_proxy.resize(resize_data[:rows], resize_data[:cols])
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def accept_loop
|
|
128
|
+
loop do
|
|
129
|
+
raw = @server.accept
|
|
130
|
+
Thread.new(raw) { handle_client(it) }
|
|
131
|
+
rescue IOError, Errno::EBADF
|
|
132
|
+
break
|
|
133
|
+
rescue => e
|
|
134
|
+
Superkick.logger.error("attach:#{@agent_id}") {
|
|
135
|
+
"Accept error: #{e.message}"
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def handle_client(socket)
|
|
141
|
+
# 1. Read hello frame
|
|
142
|
+
frame = Protocol.read_frame(socket)
|
|
143
|
+
unless frame && frame[0] == Protocol::HELLO
|
|
144
|
+
begin
|
|
145
|
+
socket.close
|
|
146
|
+
rescue IOError
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
hello = Protocol.decode_json(frame[1])
|
|
153
|
+
mode = (hello[:mode] == "rw") ? :rw : :ro
|
|
154
|
+
force = hello[:force] == true
|
|
155
|
+
|
|
156
|
+
# 2. Check read-write exclusivity
|
|
157
|
+
if mode == :rw
|
|
158
|
+
@clients_mutex.synchronize do
|
|
159
|
+
if @rw_client
|
|
160
|
+
if force
|
|
161
|
+
demote_rw_client_locked("Read-write taken over by another client", event_type: :attach_force_takeover)
|
|
162
|
+
else
|
|
163
|
+
Protocol.write_json_frame(socket, Protocol::ERROR,
|
|
164
|
+
{message: "Another read-write client is already connected"})
|
|
165
|
+
begin
|
|
166
|
+
socket.close
|
|
167
|
+
rescue IOError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
@rw_client = socket
|
|
174
|
+
@rw_last_input_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# 3. Send metadata
|
|
179
|
+
meta = build_meta
|
|
180
|
+
Protocol.write_json_frame(socket, Protocol::META, meta)
|
|
181
|
+
|
|
182
|
+
# 4. Replay history
|
|
183
|
+
history_data = @history.snapshot
|
|
184
|
+
unless history_data.empty?
|
|
185
|
+
Protocol.write_frame(socket, Protocol::HISTORY, history_data)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# 5. Register client for broadcast
|
|
189
|
+
client_entry = {socket:, mode:}
|
|
190
|
+
@clients_mutex.synchronize { @clients << client_entry }
|
|
191
|
+
|
|
192
|
+
# 6. Unified frame processing loop — all clients (RW and RO) use the
|
|
193
|
+
# same loop. INPUT and RESIZE are only forwarded for the active RW
|
|
194
|
+
# client; COMMAND frames are always processed.
|
|
195
|
+
read_client_frames(socket)
|
|
196
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
197
|
+
# Client disconnected
|
|
198
|
+
ensure
|
|
199
|
+
@clients_mutex.synchronize do
|
|
200
|
+
@clients.reject! { |c| c[:socket] == socket }
|
|
201
|
+
@rw_client = nil if @rw_client == socket
|
|
202
|
+
end
|
|
203
|
+
begin
|
|
204
|
+
socket.close
|
|
205
|
+
rescue IOError
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def read_client_frames(socket)
|
|
211
|
+
loop do
|
|
212
|
+
frame = Protocol.read_frame(socket)
|
|
213
|
+
break unless frame
|
|
214
|
+
|
|
215
|
+
type, payload = frame
|
|
216
|
+
case type
|
|
217
|
+
when Protocol::INPUT
|
|
218
|
+
if @rw_client == socket
|
|
219
|
+
@rw_last_input_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
220
|
+
@pty_proxy.enqueue_inject(payload)
|
|
221
|
+
end
|
|
222
|
+
when Protocol::RESIZE
|
|
223
|
+
if @rw_client == socket
|
|
224
|
+
data = Protocol.decode_json(payload)
|
|
225
|
+
@pty_proxy.resize(data[:rows], data[:cols])
|
|
226
|
+
end
|
|
227
|
+
when Protocol::COMMAND
|
|
228
|
+
cmd = Protocol.decode_json(payload)
|
|
229
|
+
handle_attach_command(cmd, socket)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
233
|
+
# Client disconnected
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def handle_attach_command(cmd, socket)
|
|
237
|
+
case cmd[:action]
|
|
238
|
+
when "claim"
|
|
239
|
+
control = Control.client_from
|
|
240
|
+
result = control.request("claim_agent", agent_id: @agent_id)
|
|
241
|
+
Superkick.logger.info("attach:#{@agent_id}") { "Claim via attach: #{result.success? ? "ok" : result.error_message}" }
|
|
242
|
+
# Auto-promote to RW when claiming and slot is vacant
|
|
243
|
+
if result.success?
|
|
244
|
+
@clients_mutex.synchronize do
|
|
245
|
+
promote_client_locked(socket) unless @rw_client
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
when "unclaim"
|
|
249
|
+
control = Control.client_from
|
|
250
|
+
result = control.request("unclaim_agent", agent_id: @agent_id)
|
|
251
|
+
Superkick.logger.info("attach:#{@agent_id}") { "Unclaim via attach: #{result.success? ? "ok" : result.error_message}" }
|
|
252
|
+
when "promote_rw"
|
|
253
|
+
@clients_mutex.synchronize do
|
|
254
|
+
if @rw_client == socket
|
|
255
|
+
notify_client(socket, "Already in read-write mode")
|
|
256
|
+
elsif @rw_client
|
|
257
|
+
notify_client(socket, "Read-write slot is occupied — use forced promotion to take over")
|
|
258
|
+
else
|
|
259
|
+
promote_client_locked(socket)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
when "force_promote_rw"
|
|
263
|
+
@clients_mutex.synchronize do
|
|
264
|
+
if @rw_client == socket
|
|
265
|
+
notify_client(socket, "Already in read-write mode")
|
|
266
|
+
else
|
|
267
|
+
demote_rw_client_locked("Read-write taken over by another client", event_type: :attach_force_takeover) if @rw_client
|
|
268
|
+
promote_client_locked(socket)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
when "demote_ro"
|
|
272
|
+
@clients_mutex.synchronize do
|
|
273
|
+
if @rw_client == socket
|
|
274
|
+
@rw_client = nil
|
|
275
|
+
@rw_last_input_at = nil
|
|
276
|
+
entry = @clients.find { it[:socket] == socket }
|
|
277
|
+
entry[:mode] = :ro if entry
|
|
278
|
+
Superkick.logger.info("attach:#{@agent_id}") { "Client voluntarily demoted to read-only" }
|
|
279
|
+
notify_client(socket, "Switched to read-only mode")
|
|
280
|
+
else
|
|
281
|
+
notify_client(socket, "Already in read-only mode")
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
rescue Control::Client::ServerUnavailable
|
|
286
|
+
Superkick.logger.warn("attach:#{@agent_id}") { "Cannot #{cmd[:action]}: server unavailable" }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Promote a client to RW. MUST be called while holding @clients_mutex.
|
|
290
|
+
def promote_client_locked(socket)
|
|
291
|
+
@rw_client = socket
|
|
292
|
+
@rw_last_input_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
293
|
+
|
|
294
|
+
entry = @clients.find { it[:socket] == socket }
|
|
295
|
+
entry[:mode] = :rw if entry
|
|
296
|
+
|
|
297
|
+
Superkick.logger.info("attach:#{@agent_id}") { "Client promoted to read-write" }
|
|
298
|
+
notify_client(socket, "Promoted to read-write mode")
|
|
299
|
+
dispatch_attach_event(:attach_promoted, "Client promoted to read-write mode")
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Demote the current RW client to RO. Sends a NOTIFY frame to inform them.
|
|
303
|
+
# MUST be called while holding @clients_mutex.
|
|
304
|
+
def demote_rw_client_locked(reason, event_type: :attach_demoted)
|
|
305
|
+
old_socket = @rw_client
|
|
306
|
+
return unless old_socket
|
|
307
|
+
|
|
308
|
+
@rw_client = nil
|
|
309
|
+
@rw_last_input_at = nil
|
|
310
|
+
|
|
311
|
+
entry = @clients.find { it[:socket] == old_socket }
|
|
312
|
+
entry[:mode] = :ro if entry
|
|
313
|
+
|
|
314
|
+
Superkick.logger.info("attach:#{@agent_id}") { "RW client demoted: #{reason}" }
|
|
315
|
+
notify_client(old_socket, reason)
|
|
316
|
+
dispatch_attach_event(event_type, reason)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Send a NOTIFY frame to a client. Swallows IO errors.
|
|
320
|
+
def notify_client(socket, message)
|
|
321
|
+
Protocol.write_json_frame(socket, Protocol::NOTIFY, {message:})
|
|
322
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
323
|
+
nil
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Fire a notification event to the server so it can dispatch to notifiers
|
|
327
|
+
# (Slack, command, terminal bell, etc.). Best-effort — swallows errors.
|
|
328
|
+
def dispatch_attach_event(event_type, reason)
|
|
329
|
+
return unless @control_client
|
|
330
|
+
|
|
331
|
+
@control_client.request("attach_event",
|
|
332
|
+
agent_id: @agent_id,
|
|
333
|
+
event_type: event_type.to_s,
|
|
334
|
+
reason:)
|
|
335
|
+
rescue Control::Client::ServerUnavailable
|
|
336
|
+
nil
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Background thread that periodically checks if the RW client has been
|
|
340
|
+
# idle (no INPUT frames) past the configured timeout.
|
|
341
|
+
def start_idle_check
|
|
342
|
+
@idle_check_thread = Thread.new do
|
|
343
|
+
check_interval = [@rw_idle_timeout / 2.0, 30].min
|
|
344
|
+
loop do
|
|
345
|
+
sleep check_interval
|
|
346
|
+
@clients_mutex.synchronize do
|
|
347
|
+
next unless @rw_client && @rw_last_input_at
|
|
348
|
+
|
|
349
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @rw_last_input_at
|
|
350
|
+
if elapsed >= @rw_idle_timeout
|
|
351
|
+
demote_rw_client_locked(
|
|
352
|
+
"Demoted to read-only after #{@rw_idle_timeout.to_i}s of inactivity",
|
|
353
|
+
event_type: :attach_idle_timeout
|
|
354
|
+
)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
rescue => e
|
|
358
|
+
Superkick.logger.error("attach:#{@agent_id}") { "Idle check error: #{e.message}" }
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def build_meta
|
|
364
|
+
{
|
|
365
|
+
agent_id: @agent_id,
|
|
366
|
+
history_bytes: @history.size
|
|
367
|
+
}
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Centralized budget evaluation. Checks per-agent, per-spawner,
|
|
5
|
+
# and global budget limits on each cost report.
|
|
6
|
+
#
|
|
7
|
+
# Returns an array of violations, each a Hash:
|
|
8
|
+
# { level: :agent/:spawner/:global, budget:, spent:, action: :warning/:exceeded }
|
|
9
|
+
class BudgetChecker
|
|
10
|
+
def initialize(store:, config: Superkick.config)
|
|
11
|
+
@store = store
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Check all budget levels for an agent.
|
|
16
|
+
# @return [Array<Hash>] violations (empty means all budgets ok)
|
|
17
|
+
def check(agent)
|
|
18
|
+
violations = []
|
|
19
|
+
violations.concat(check_agent_budget(agent))
|
|
20
|
+
violations.concat(check_spawner_budget(agent))
|
|
21
|
+
violations.concat(check_global_budget)
|
|
22
|
+
violations
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def check_agent_budget(agent)
|
|
28
|
+
per_agent = per_agent_budget_config(agent)
|
|
29
|
+
return [] unless per_agent
|
|
30
|
+
|
|
31
|
+
max = per_agent[:max_cost_usd]
|
|
32
|
+
return [] unless max
|
|
33
|
+
|
|
34
|
+
spent = agent.cost.to_h[:total_cost_usd]
|
|
35
|
+
warn_pct = per_agent[:warning_threshold_percentage] || 80
|
|
36
|
+
|
|
37
|
+
if spent >= max
|
|
38
|
+
[{level: :agent, budget: max, spent:, action: :exceeded}]
|
|
39
|
+
elsif spent >= (max * warn_pct / 100.0)
|
|
40
|
+
[{level: :agent, budget: max, spent:, action: :warning}]
|
|
41
|
+
else
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def check_spawner_budget(agent)
|
|
47
|
+
return [] unless agent.spawn_info
|
|
48
|
+
|
|
49
|
+
spawner_name = agent.spawn_info[:spawner_name]&.to_sym
|
|
50
|
+
config = @config.spawners[spawner_name]
|
|
51
|
+
return [] unless config
|
|
52
|
+
|
|
53
|
+
max = config.dig(:budget, :max_cost_usd)
|
|
54
|
+
return [] unless max
|
|
55
|
+
|
|
56
|
+
window = (config.dig(:budget, :window_hours) || 24) * 3600
|
|
57
|
+
cutoff = Time.now - window
|
|
58
|
+
|
|
59
|
+
total = 0.0
|
|
60
|
+
@store.each do |a|
|
|
61
|
+
next unless a.spawn_info&.dig(:spawner_name)&.to_sym == spawner_name
|
|
62
|
+
spawned_at = a.spawn_info[:spawned_at]
|
|
63
|
+
next unless spawned_at
|
|
64
|
+
begin
|
|
65
|
+
next if Time.iso8601(spawned_at) < cutoff
|
|
66
|
+
rescue ArgumentError
|
|
67
|
+
next
|
|
68
|
+
end
|
|
69
|
+
total += a.cost.to_h[:total_cost_usd]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
warn_pct = config.dig(:budget, :warning_threshold_percentage) || 80
|
|
73
|
+
|
|
74
|
+
if total >= max
|
|
75
|
+
[{level: :spawner, budget: max, spent: total, action: :exceeded}]
|
|
76
|
+
elsif total >= (max * warn_pct / 100.0)
|
|
77
|
+
[{level: :spawner, budget: max, spent: total, action: :warning}]
|
|
78
|
+
else
|
|
79
|
+
[]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def check_global_budget
|
|
84
|
+
max = @config.budget[:max_cost_usd]
|
|
85
|
+
return [] unless max
|
|
86
|
+
|
|
87
|
+
window = (@config.budget[:window_hours] || 24) * 3600
|
|
88
|
+
cutoff = Time.now - window
|
|
89
|
+
|
|
90
|
+
total = 0.0
|
|
91
|
+
@store.each do |a|
|
|
92
|
+
next unless a.spawn_info
|
|
93
|
+
spawned_at = a.spawn_info[:spawned_at]
|
|
94
|
+
next unless spawned_at
|
|
95
|
+
begin
|
|
96
|
+
next if Time.iso8601(spawned_at) < cutoff
|
|
97
|
+
rescue ArgumentError
|
|
98
|
+
next
|
|
99
|
+
end
|
|
100
|
+
total += a.cost.to_h[:total_cost_usd]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
warn_pct = @config.budget[:warning_threshold_percentage] || 80
|
|
104
|
+
|
|
105
|
+
if total >= max
|
|
106
|
+
[{level: :global, budget: max, spent: total, action: :exceeded}]
|
|
107
|
+
elsif total >= (max * warn_pct / 100.0)
|
|
108
|
+
[{level: :global, budget: max, spent: total, action: :warning}]
|
|
109
|
+
else
|
|
110
|
+
[]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def per_agent_budget_config(agent)
|
|
115
|
+
return nil unless agent.spawn_info
|
|
116
|
+
spawner_name = agent.spawn_info[:spawner_name]&.to_sym
|
|
117
|
+
@config.spawners.dig(spawner_name, :budget, :per_agent)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Buffer
|
|
7
|
+
@clients = {}
|
|
8
|
+
extend ClientRegistry
|
|
9
|
+
|
|
10
|
+
# Buffer client for communicating with agents' buffer servers.
|
|
11
|
+
#
|
|
12
|
+
# The default implementation communicates over a Unix socket. In hosted
|
|
13
|
+
# mode, a subclass (Hosted::Buffer::Client) routes commands through
|
|
14
|
+
# WebSocket relays.
|
|
15
|
+
#
|
|
16
|
+
# All server-side components (Injector, SpawnInjector, CostPoller,
|
|
17
|
+
# Supervisor) use Buffer.client_from to construct the right implementation
|
|
18
|
+
# for the configured server type.
|
|
19
|
+
class Client
|
|
20
|
+
# Shared exception that callers rescue regardless of server type.
|
|
21
|
+
class AgentUnreachable < StandardError; end
|
|
22
|
+
|
|
23
|
+
def self.from(store:, **_kwargs)
|
|
24
|
+
new(store:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(store:)
|
|
28
|
+
@store = store
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Send a command to the agent's buffer server and return the parsed response.
|
|
32
|
+
# @param agent_id [String]
|
|
33
|
+
# @param command [String] buffer command name
|
|
34
|
+
# @param params [Hash] extra key/value params merged into the request
|
|
35
|
+
# @return [Hash] parsed response from the buffer server
|
|
36
|
+
def request(agent_id, command, **params)
|
|
37
|
+
socket_path = resolve_socket_path(agent_id)
|
|
38
|
+
connection = Superkick::Connection.open(socket_path)
|
|
39
|
+
begin
|
|
40
|
+
connection.send_message({command:}.merge(params))
|
|
41
|
+
response = connection.receive_message
|
|
42
|
+
raise AgentUnreachable, "Buffer server closed connection" unless response
|
|
43
|
+
response
|
|
44
|
+
ensure
|
|
45
|
+
connection.close
|
|
46
|
+
end
|
|
47
|
+
rescue Errno::ENOENT, Errno::ECONNREFUSED, Errno::ECONNRESET
|
|
48
|
+
raise AgentUnreachable, "Buffer socket not found for agent #{agent_id}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Fire-and-forget: send a command, log and swallow AgentUnreachable.
|
|
52
|
+
def send_command(agent_id, command, **params)
|
|
53
|
+
request(agent_id, command, **params)
|
|
54
|
+
rescue AgentUnreachable => e
|
|
55
|
+
Superkick.logger.debug(log_tag) { "Send failed for #{agent_id}: #{e.message}" }
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if the agent's buffer socket is reachable.
|
|
60
|
+
def reachable?(agent_id)
|
|
61
|
+
agent = @store.get(agent_id)
|
|
62
|
+
return false unless agent&.buffer_socket_path
|
|
63
|
+
|
|
64
|
+
socket_path = agent.buffer_socket_path
|
|
65
|
+
File.exist?(socket_path)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Probe the agent's buffer endpoint to verify it's alive.
|
|
69
|
+
def probe(agent_id)
|
|
70
|
+
response = request(agent_id, "ping")
|
|
71
|
+
response&.fetch(:ok, false) == true
|
|
72
|
+
rescue AgentUnreachable, SystemCallError, IOError, JSON::ParserError
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def log_tag
|
|
79
|
+
"buffer:client"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve_socket_path(agent_id)
|
|
83
|
+
agent = @store.get(agent_id)
|
|
84
|
+
socket_path = agent&.buffer_socket_path
|
|
85
|
+
raise AgentUnreachable, "No buffer socket for agent #{agent_id}" unless socket_path && File.exist?(socket_path)
|
|
86
|
+
|
|
87
|
+
socket_path
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|