rubyn-code 0.2.2 → 0.4.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 +4 -4
- data/README.md +151 -5
- data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +84 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +157 -0
- data/lib/rubyn_code/agent/loop.rb +182 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
- data/lib/rubyn_code/agent/tool_processor.rb +178 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/key_encryption.rb +118 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +80 -52
- data/lib/rubyn_code/autonomous/daemon.rb +146 -32
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
- data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +159 -114
- data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
- data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
- data/lib/rubyn_code/cli/commands/model.rb +105 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/commands/provider.rb +123 -0
- data/lib/rubyn_code/cli/commands/skill.rb +52 -3
- data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
- data/lib/rubyn_code/cli/first_run.rb +159 -0
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +48 -374
- data/lib/rubyn_code/cli/repl_commands.rb +177 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
- data/lib/rubyn_code/cli/repl_setup.rb +181 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +11 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/schema.json +49 -0
- data/lib/rubyn_code/config/settings.rb +103 -1
- data/lib/rubyn_code/config/validator.rb +63 -0
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +182 -0
- data/lib/rubyn_code/context/context_collapse.rb +34 -4
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +44 -8
- data/lib/rubyn_code/context/manual_compact.rb +1 -1
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/hooks/registry.rb +4 -0
- data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
- data/lib/rubyn_code/ide/client.rb +110 -0
- data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
- data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
- data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
- data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
- data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
- data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
- data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
- data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
- data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
- data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
- data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
- data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
- data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
- data/lib/rubyn_code/ide/handlers.rb +76 -0
- data/lib/rubyn_code/ide/protocol.rb +111 -0
- data/lib/rubyn_code/ide/server.rb +186 -0
- data/lib/rubyn_code/index/codebase_index.rb +311 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
- data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +75 -247
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +10 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/search.rb +1 -0
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/self_test.rb +315 -0
- data/lib/rubyn_code/skills/catalog.rb +66 -0
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/loader.rb +43 -0
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tasks/models.rb +1 -0
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +39 -32
- data/lib/rubyn_code/tools/bash.rb +7 -1
- data/lib/rubyn_code/tools/edit_file.rb +130 -17
- data/lib/rubyn_code/tools/executor.rb +130 -25
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +29 -7
- data/lib/rubyn_code/tools/grep.rb +8 -1
- data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
- data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +190 -0
- data/lib/rubyn_code/tools/read_file.rb +17 -6
- data/lib/rubyn_code/tools/registry.rb +11 -0
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +76 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +62 -1
- data/skills/rubyn_self_test.md +133 -0
- metadata +83 -1
|
@@ -7,7 +7,7 @@ require 'time'
|
|
|
7
7
|
|
|
8
8
|
module RubynCode
|
|
9
9
|
module Auth
|
|
10
|
-
module TokenStore
|
|
10
|
+
module TokenStore # rubocop:disable Metrics/ModuleLength -- single-responsibility credential store
|
|
11
11
|
EXPIRY_BUFFER_SECONDS = 300 # 5 minutes
|
|
12
12
|
KEYCHAIN_SERVICE = 'Claude Code-credentials'
|
|
13
13
|
|
|
@@ -20,82 +20,98 @@ module RubynCode
|
|
|
20
20
|
load_from_keychain || load_from_file || load_from_env
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
# Load API key for a given provider. Anthropic uses the full fallback chain.
|
|
24
|
+
# Other providers: stored key → env var.
|
|
25
|
+
def load_for_provider(provider)
|
|
26
|
+
return load if provider == 'anthropic'
|
|
27
|
+
|
|
28
|
+
stored = load_provider_key(provider)
|
|
29
|
+
return { access_token: stored, type: :api_key, source: :stored } if stored
|
|
30
|
+
|
|
31
|
+
env_key = resolve_env_key(provider)
|
|
32
|
+
api_key = ENV.fetch(env_key, nil)
|
|
33
|
+
api_key&.empty? == false ? { access_token: api_key, type: :api_key, source: :env } : nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Store an API key for a provider in tokens.yml (encrypted at rest).
|
|
37
|
+
def save_provider_key(provider, key)
|
|
24
38
|
ensure_directory!
|
|
39
|
+
data = load_tokens_file || {}
|
|
40
|
+
data['provider_keys'] ||= {}
|
|
41
|
+
data['provider_keys'][provider.to_s] = KeyEncryption.encrypt(key)
|
|
42
|
+
write_tokens_file(data)
|
|
43
|
+
end
|
|
25
44
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
# Retrieve a stored API key for a provider (decrypted transparently).
|
|
46
|
+
def load_provider_key(provider)
|
|
47
|
+
data = load_tokens_file
|
|
48
|
+
value = data&.dig('provider_keys', provider.to_s)
|
|
49
|
+
return nil unless value
|
|
31
50
|
|
|
32
|
-
|
|
33
|
-
|
|
51
|
+
migrate_plaintext_key!(data, provider, value) unless KeyEncryption.encrypted?(value)
|
|
52
|
+
KeyEncryption.decrypt(value)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def save(access_token:, refresh_token:, expires_at:)
|
|
56
|
+
ensure_directory!
|
|
57
|
+
data = load_tokens_file || {}
|
|
58
|
+
data['access_token'] = access_token
|
|
59
|
+
data['refresh_token'] = refresh_token
|
|
60
|
+
data['expires_at'] = expires_at.is_a?(Time) ? expires_at.iso8601 : expires_at.to_s
|
|
61
|
+
write_tokens_file(data)
|
|
34
62
|
data
|
|
35
63
|
end
|
|
36
64
|
|
|
37
|
-
def clear!
|
|
65
|
+
def clear! # rubocop:disable Naming/PredicateMethod -- destructive action, not a predicate
|
|
38
66
|
FileUtils.rm_f(tokens_path)
|
|
39
67
|
true
|
|
40
68
|
end
|
|
41
69
|
|
|
42
70
|
def valid?
|
|
43
71
|
tokens = self.load
|
|
44
|
-
return false unless tokens
|
|
45
|
-
return false unless tokens[:access_token]
|
|
46
|
-
|
|
47
|
-
# API keys don't expire
|
|
72
|
+
return false unless tokens&.fetch(:access_token, nil)
|
|
48
73
|
return true if tokens[:type] == :api_key
|
|
49
|
-
|
|
50
|
-
# OAuth tokens need expiry check
|
|
51
74
|
return true unless tokens[:expires_at]
|
|
52
75
|
|
|
53
76
|
tokens[:expires_at] > Time.now + EXPIRY_BUFFER_SECONDS
|
|
54
77
|
end
|
|
55
78
|
|
|
56
|
-
def exists?
|
|
57
|
-
|
|
58
|
-
end
|
|
79
|
+
def exists? = valid?
|
|
80
|
+
def access_token = self.load&.fetch(:access_token, nil)
|
|
59
81
|
|
|
60
|
-
|
|
61
|
-
tokens = self.load
|
|
62
|
-
tokens&.fetch(:access_token, nil)
|
|
63
|
-
end
|
|
82
|
+
private
|
|
64
83
|
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
|
|
84
|
+
def resolve_env_key(provider)
|
|
85
|
+
default = Config::Defaults::PROVIDER_ENV_KEYS.fetch(provider, "#{provider.upcase}_API_KEY")
|
|
86
|
+
Config::Settings.new.provider_config(provider)&.fetch('env_key', nil) || default
|
|
87
|
+
rescue StandardError
|
|
88
|
+
default
|
|
68
89
|
end
|
|
69
90
|
|
|
70
|
-
private
|
|
71
|
-
|
|
72
|
-
# Read Claude Code's OAuth token from macOS Keychain
|
|
73
91
|
def load_from_keychain
|
|
74
92
|
return nil unless RUBY_PLATFORM.include?('darwin')
|
|
75
93
|
|
|
76
94
|
output = `security find-generic-password -s "#{KEYCHAIN_SERVICE}" -w 2>/dev/null`.strip
|
|
77
95
|
return nil if output.empty?
|
|
78
96
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return nil unless oauth && oauth['accessToken']
|
|
97
|
+
oauth = JSON.parse(output)['claudeAiOauth']
|
|
98
|
+
return nil unless oauth&.dig('accessToken')
|
|
82
99
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
build_keychain_tokens(oauth)
|
|
101
|
+
rescue StandardError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
86
104
|
|
|
105
|
+
def build_keychain_tokens(oauth)
|
|
87
106
|
{
|
|
88
107
|
access_token: oauth['accessToken'],
|
|
89
108
|
refresh_token: oauth['refreshToken'],
|
|
90
|
-
expires_at:
|
|
109
|
+
expires_at: oauth['expiresAt'] ? Time.at(oauth['expiresAt'] / 1000.0) : nil,
|
|
91
110
|
type: :oauth,
|
|
92
111
|
source: :keychain
|
|
93
112
|
}
|
|
94
|
-
rescue JSON::ParserError, StandardError
|
|
95
|
-
nil
|
|
96
113
|
end
|
|
97
114
|
|
|
98
|
-
# Read from local YAML token file
|
|
99
115
|
def load_from_file
|
|
100
116
|
return nil unless File.exist?(tokens_path)
|
|
101
117
|
|
|
@@ -114,28 +130,40 @@ module RubynCode
|
|
|
114
130
|
nil
|
|
115
131
|
end
|
|
116
132
|
|
|
117
|
-
# Fall back to ANTHROPIC_API_KEY environment variable
|
|
118
133
|
def load_from_env
|
|
119
134
|
api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
|
|
120
135
|
return nil unless api_key && !api_key.empty?
|
|
121
136
|
|
|
122
|
-
{
|
|
123
|
-
access_token: api_key,
|
|
124
|
-
refresh_token: nil,
|
|
125
|
-
expires_at: nil,
|
|
126
|
-
type: :api_key,
|
|
127
|
-
source: :env
|
|
128
|
-
}
|
|
137
|
+
{ access_token: api_key, refresh_token: nil, expires_at: nil, type: :api_key, source: :env }
|
|
129
138
|
end
|
|
130
139
|
|
|
131
|
-
def
|
|
132
|
-
|
|
140
|
+
def write_tokens_file(data)
|
|
141
|
+
File.write(tokens_path, YAML.dump(data))
|
|
142
|
+
File.chmod(0o600, tokens_path)
|
|
133
143
|
end
|
|
134
144
|
|
|
145
|
+
# Auto-encrypt a plaintext key from a pre-encryption install.
|
|
146
|
+
def migrate_plaintext_key!(data, provider, plaintext)
|
|
147
|
+
data['provider_keys'][provider.to_s] = KeyEncryption.encrypt(plaintext)
|
|
148
|
+
write_tokens_file(data)
|
|
149
|
+
rescue StandardError
|
|
150
|
+
nil # don't break reads if migration fails
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def load_tokens_file
|
|
154
|
+
return nil unless File.exist?(tokens_path)
|
|
155
|
+
|
|
156
|
+
data = YAML.safe_load_file(tokens_path, permitted_classes: [Time])
|
|
157
|
+
data.is_a?(Hash) ? data : nil
|
|
158
|
+
rescue Psych::SyntaxError, Errno::EACCES
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def tokens_path = Config::Defaults::TOKENS_FILE
|
|
163
|
+
|
|
135
164
|
def ensure_directory!
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
File.chmod(0o700, dir)
|
|
165
|
+
FileUtils.mkdir_p(File.dirname(tokens_path))
|
|
166
|
+
File.chmod(0o700, File.dirname(tokens_path))
|
|
139
167
|
end
|
|
140
168
|
|
|
141
169
|
def parse_time(value)
|
|
@@ -14,8 +14,9 @@ module RubynCode
|
|
|
14
14
|
#
|
|
15
15
|
# Unlike the REPL, the daemon runs a full Agent::Loop per task — meaning
|
|
16
16
|
# it can read files, write code, run specs, and use every tool available.
|
|
17
|
-
class Daemon
|
|
17
|
+
class Daemon # rubocop:disable Metrics/ClassLength -- daemon lifecycle + retry + audit + cost
|
|
18
18
|
LIFECYCLE_STATES = %i[spawned working idle shutting_down stopped].freeze
|
|
19
|
+
MAX_TASK_RETRIES = 3
|
|
19
20
|
|
|
20
21
|
attr_reader :agent_name, :role, :state, :runs_completed, :total_cost
|
|
21
22
|
|
|
@@ -32,29 +33,17 @@ module RubynCode
|
|
|
32
33
|
# @param on_state_change [Proc, nil] callback invoked with (old_state, new_state)
|
|
33
34
|
# @param on_task_complete [Proc, nil] callback invoked with (task, result_text)
|
|
34
35
|
# @param on_task_error [Proc, nil] callback invoked with (task, error)
|
|
36
|
+
# @param session_persistence [Memory::SessionPersistence, nil] optional audit trail persistence
|
|
35
37
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
36
38
|
agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:,
|
|
37
39
|
max_runs: 100, max_cost: 10.0, poll_interval: 5, idle_timeout: 60,
|
|
38
|
-
on_state_change: nil, on_task_complete: nil, on_task_error: nil
|
|
40
|
+
on_state_change: nil, on_task_complete: nil, on_task_error: nil,
|
|
41
|
+
session_persistence: nil
|
|
39
42
|
)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@
|
|
44
|
-
@task_manager = task_manager
|
|
45
|
-
@mailbox = mailbox
|
|
46
|
-
@max_runs = max_runs
|
|
47
|
-
@max_cost = max_cost
|
|
48
|
-
@poll_interval = poll_interval
|
|
49
|
-
@idle_timeout = idle_timeout
|
|
50
|
-
@on_state_change = on_state_change
|
|
51
|
-
@on_task_complete = on_task_complete
|
|
52
|
-
@on_task_error = on_task_error
|
|
53
|
-
|
|
54
|
-
@state = :spawned
|
|
55
|
-
@runs_completed = 0
|
|
56
|
-
@total_cost = 0.0
|
|
57
|
-
@stop_requested = false
|
|
43
|
+
assign_core_attrs(agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:)
|
|
44
|
+
assign_limits(max_runs:, max_cost:, poll_interval:, idle_timeout:)
|
|
45
|
+
assign_callbacks_and_state(on_state_change, on_task_complete, on_task_error)
|
|
46
|
+
@session_persistence = session_persistence
|
|
58
47
|
end
|
|
59
48
|
|
|
60
49
|
# Enters the work-idle-work cycle. Blocks the calling thread until
|
|
@@ -121,6 +110,32 @@ module RubynCode
|
|
|
121
110
|
|
|
122
111
|
# ── Signal handling ──────────────────────────────────────────
|
|
123
112
|
|
|
113
|
+
def assign_core_attrs(agent_name:, role:, llm_client:, project_root:, task_manager:, mailbox:) # rubocop:disable Metrics/ParameterLists -- mirrors constructor keyword args
|
|
114
|
+
@agent_name = agent_name
|
|
115
|
+
@role = role
|
|
116
|
+
@llm_client = llm_client
|
|
117
|
+
@project_root = File.expand_path(project_root)
|
|
118
|
+
@task_manager = task_manager
|
|
119
|
+
@mailbox = mailbox
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def assign_limits(max_runs:, max_cost:, poll_interval:, idle_timeout:)
|
|
123
|
+
@max_runs = max_runs
|
|
124
|
+
@max_cost = max_cost
|
|
125
|
+
@poll_interval = poll_interval
|
|
126
|
+
@idle_timeout = idle_timeout
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def assign_callbacks_and_state(on_state_change, on_task_complete, on_task_error)
|
|
130
|
+
@on_state_change = on_state_change
|
|
131
|
+
@on_task_complete = on_task_complete
|
|
132
|
+
@on_task_error = on_task_error
|
|
133
|
+
@state = :spawned
|
|
134
|
+
@runs_completed = 0
|
|
135
|
+
@total_cost = 0.0
|
|
136
|
+
@stop_requested = false
|
|
137
|
+
end
|
|
138
|
+
|
|
124
139
|
def install_signal_handlers!
|
|
125
140
|
%w[INT TERM].each do |sig|
|
|
126
141
|
Signal.trap(sig) { stop! }
|
|
@@ -142,16 +157,18 @@ module RubynCode
|
|
|
142
157
|
agent_loop = build_agent_loop
|
|
143
158
|
result_text = agent_loop.send_message(build_work_prompt(task))
|
|
144
159
|
|
|
145
|
-
# Accumulate cost
|
|
146
|
-
|
|
160
|
+
# Accumulate cost via CostCalculator using actual token counts
|
|
161
|
+
track_cost_from_context_manager(agent_loop)
|
|
147
162
|
|
|
148
163
|
# Mark the task as completed with the agent's result.
|
|
149
164
|
@task_manager.complete(task.id, result: result_text)
|
|
165
|
+
|
|
166
|
+
# Persist conversation as an audit trail
|
|
167
|
+
persist_session_audit(task, agent_loop)
|
|
168
|
+
|
|
150
169
|
@on_task_complete&.call(task, result_text)
|
|
151
170
|
rescue StandardError => e
|
|
152
|
-
|
|
153
|
-
@task_manager.update(task.id, status: 'pending', owner: nil, result: "Error: #{e.message}")
|
|
154
|
-
@on_task_error&.call(task, e)
|
|
171
|
+
handle_task_error(task, e)
|
|
155
172
|
end
|
|
156
173
|
|
|
157
174
|
# Builds a fresh Agent::Loop wired with all the real tools.
|
|
@@ -181,19 +198,116 @@ module RubynCode
|
|
|
181
198
|
)
|
|
182
199
|
end
|
|
183
200
|
|
|
184
|
-
#
|
|
201
|
+
# Computes USD cost from the context manager's token counts using
|
|
202
|
+
# Observability::CostCalculator. The old approach checked for a
|
|
203
|
+
# `total_cost` method that never existed on Context::Manager, so
|
|
204
|
+
# @total_cost was always 0.0 and the max_cost safety limit never fired.
|
|
185
205
|
#
|
|
186
206
|
# @param agent_loop [Agent::Loop]
|
|
187
207
|
# @return [void]
|
|
188
|
-
def
|
|
189
|
-
# The context manager tracks token usage; we extract cost if available.
|
|
190
|
-
# This is best-effort — the daemon's own total_cost is an approximation.
|
|
208
|
+
def track_cost_from_context_manager(agent_loop)
|
|
191
209
|
cm = agent_loop.instance_variable_get(:@context_manager)
|
|
192
|
-
return unless cm
|
|
210
|
+
return unless cm
|
|
211
|
+
|
|
212
|
+
tokens = extract_token_counts(cm)
|
|
213
|
+
return if tokens.values.all?(&:zero?)
|
|
214
|
+
|
|
215
|
+
model = @llm_client.respond_to?(:model) ? @llm_client.model : 'claude-sonnet-4-6'
|
|
216
|
+
@total_cost += Observability::CostCalculator.calculate(model: model, **tokens)
|
|
217
|
+
rescue StandardError
|
|
218
|
+
# Non-critical — cost tracking is best-effort
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# @param context_mgr [Context::Manager]
|
|
222
|
+
# @return [Hash] :input_tokens, :output_tokens
|
|
223
|
+
def extract_token_counts(context_mgr)
|
|
224
|
+
{
|
|
225
|
+
input_tokens: context_mgr.respond_to?(:total_input_tokens) ? context_mgr.total_input_tokens.to_i : 0,
|
|
226
|
+
output_tokens: context_mgr.respond_to?(:total_output_tokens) ? context_mgr.total_output_tokens.to_i : 0
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Handles a task error with retry backoff. Increments the retry count
|
|
231
|
+
# in the task's metadata. After MAX_TASK_RETRIES, marks the task as
|
|
232
|
+
# failed instead of releasing it back to pending.
|
|
233
|
+
#
|
|
234
|
+
# @param task [Tasks::Task]
|
|
235
|
+
# @param error [StandardError]
|
|
236
|
+
# @return [void]
|
|
237
|
+
def handle_task_error(task, error)
|
|
238
|
+
retry_count = extract_retry_count(task) + 1
|
|
239
|
+
|
|
240
|
+
metadata = build_retry_metadata(task, retry_count)
|
|
241
|
+
if retry_count >= MAX_TASK_RETRIES
|
|
242
|
+
@task_manager.update(
|
|
243
|
+
task.id,
|
|
244
|
+
status: 'failed',
|
|
245
|
+
owner: nil,
|
|
246
|
+
result: "Failed after #{retry_count} retries. Last error: #{error.message}",
|
|
247
|
+
metadata: JSON.generate(metadata)
|
|
248
|
+
)
|
|
249
|
+
else
|
|
250
|
+
@task_manager.update(
|
|
251
|
+
task.id,
|
|
252
|
+
status: 'pending',
|
|
253
|
+
owner: nil,
|
|
254
|
+
result: "Error (retry #{retry_count}/#{MAX_TASK_RETRIES}): #{error.message}",
|
|
255
|
+
metadata: JSON.generate(metadata)
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
@on_task_error&.call(task, error)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# @param task [Tasks::Task]
|
|
262
|
+
# @return [Integer]
|
|
263
|
+
def extract_retry_count(task)
|
|
264
|
+
meta = parse_task_metadata(task)
|
|
265
|
+
(meta[:retry_count] || meta['retry_count'] || 0).to_i
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# @param task [Tasks::Task]
|
|
269
|
+
# @param retry_count [Integer]
|
|
270
|
+
# @return [Hash]
|
|
271
|
+
def build_retry_metadata(task, retry_count)
|
|
272
|
+
meta = parse_task_metadata(task)
|
|
273
|
+
meta.merge(retry_count: retry_count)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# @param task [Tasks::Task]
|
|
277
|
+
# @return [Hash]
|
|
278
|
+
def parse_task_metadata(task)
|
|
279
|
+
raw = task.metadata
|
|
280
|
+
case raw
|
|
281
|
+
when Hash then raw
|
|
282
|
+
when String then JSON.parse(raw, symbolize_names: true)
|
|
283
|
+
else {}
|
|
284
|
+
end
|
|
285
|
+
rescue JSON::ParserError
|
|
286
|
+
{}
|
|
287
|
+
end
|
|
193
288
|
|
|
194
|
-
|
|
289
|
+
# Persists the agent's conversation as a session audit trail after
|
|
290
|
+
# completing a task, so there's a record of what the daemon did.
|
|
291
|
+
#
|
|
292
|
+
# @param task [Tasks::Task]
|
|
293
|
+
# @param agent_loop [Agent::Loop]
|
|
294
|
+
# @return [void]
|
|
295
|
+
def persist_session_audit(task, agent_loop)
|
|
296
|
+
return unless @session_persistence
|
|
297
|
+
|
|
298
|
+
conversation = agent_loop.instance_variable_get(:@conversation)
|
|
299
|
+
return unless conversation.respond_to?(:messages)
|
|
300
|
+
|
|
301
|
+
session_id = "daemon-#{@agent_name}-#{task.id}"
|
|
302
|
+
@session_persistence.save_session(
|
|
303
|
+
session_id: session_id,
|
|
304
|
+
project_path: @project_root,
|
|
305
|
+
messages: conversation.messages,
|
|
306
|
+
title: "Daemon: #{task.title}",
|
|
307
|
+
metadata: { agent_name: @agent_name, task_id: task.id, task_title: task.title }
|
|
308
|
+
)
|
|
195
309
|
rescue StandardError
|
|
196
|
-
# Non-critical
|
|
310
|
+
# Non-critical — audit persistence is best-effort
|
|
197
311
|
end
|
|
198
312
|
|
|
199
313
|
# ── Idle phase ───────────────────────────────────────────────
|
|
@@ -35,9 +35,9 @@ module RubynCode
|
|
|
35
35
|
return :shutdown if monotonic_now >= deadline
|
|
36
36
|
|
|
37
37
|
# Messages always take priority over tasks.
|
|
38
|
-
return :resume if
|
|
38
|
+
return :resume if pending_messages?
|
|
39
39
|
|
|
40
|
-
return :resume if
|
|
40
|
+
return :resume if claimable_task?
|
|
41
41
|
|
|
42
42
|
remaining = deadline - monotonic_now
|
|
43
43
|
return :shutdown if remaining <= 0
|
|
@@ -53,30 +53,10 @@ module RubynCode
|
|
|
53
53
|
@interrupted = true
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
# Re-injects the agent's identity message when the conversation
|
|
57
|
-
# context has been compressed (i.e. the messages array is very short).
|
|
58
|
-
# This ensures the agent still knows who it is after compaction.
|
|
59
|
-
#
|
|
60
|
-
# @param messages [Array<Hash>] the current conversation messages
|
|
61
|
-
# @param identity [String] the identity/system prompt to re-inject
|
|
62
|
-
# @param threshold [Integer] message count below which re-injection triggers (default 3)
|
|
63
|
-
# @return [void]
|
|
64
|
-
def self.reinject_identity(messages, identity:, threshold: 3)
|
|
65
|
-
return if messages.length >= threshold
|
|
66
|
-
return if identity.nil? || identity.empty?
|
|
67
|
-
|
|
68
|
-
# Only re-inject if the identity is not already present as the
|
|
69
|
-
# first user message.
|
|
70
|
-
first_user = messages.find { |m| m[:role] == 'user' }
|
|
71
|
-
return if first_user && first_user[:content].to_s.include?(identity[0, 100])
|
|
72
|
-
|
|
73
|
-
messages.unshift({ role: 'user', content: identity })
|
|
74
|
-
end
|
|
75
|
-
|
|
76
56
|
private
|
|
77
57
|
|
|
78
58
|
# @return [Boolean]
|
|
79
|
-
def
|
|
59
|
+
def pending_messages?
|
|
80
60
|
messages = @mailbox.pending_for(@agent_name)
|
|
81
61
|
messages.is_a?(Array) ? !messages.empty? : false
|
|
82
62
|
rescue StandardError
|
|
@@ -84,7 +64,7 @@ module RubynCode
|
|
|
84
64
|
end
|
|
85
65
|
|
|
86
66
|
# @return [Boolean]
|
|
87
|
-
def
|
|
67
|
+
def claimable_task?
|
|
88
68
|
rows = @task_manager.db.query(<<~SQL).to_a
|
|
89
69
|
SELECT 1 FROM tasks
|
|
90
70
|
WHERE status = 'pending'
|
|
@@ -6,56 +6,21 @@ module RubynCode
|
|
|
6
6
|
# Uses optimistic locking to handle race conditions when multiple
|
|
7
7
|
# agents attempt to claim the same task concurrently.
|
|
8
8
|
module TaskClaimer
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
#
|
|
9
|
+
MAX_RETRIES = 3
|
|
10
|
+
|
|
11
|
+
# Finds the first ready (pending, unowned) task that hasn't exceeded
|
|
12
|
+
# max retries, claims it for the given agent, and returns the updated
|
|
13
|
+
# Task. Returns nil if no work is available.
|
|
12
14
|
#
|
|
13
15
|
# @param task_manager [#db, #update_task, #list_tasks] task persistence layer
|
|
14
16
|
# @param agent_name [String] unique identifier of the claiming agent
|
|
17
|
+
# @param max_retries [Integer] maximum retry count before skipping a task
|
|
15
18
|
# @return [Tasks::Task, nil] the claimed task, or nil if none available
|
|
16
|
-
def self.call(task_manager:, agent_name:)
|
|
19
|
+
def self.call(task_manager:, agent_name:, max_retries: MAX_RETRIES)
|
|
17
20
|
db = task_manager.db
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
# ensure that only pending tasks with no current owner are touched,
|
|
21
|
-
# avoiding race conditions with other agents.
|
|
22
|
-
db.execute(<<~SQL, [agent_name])
|
|
23
|
-
UPDATE tasks
|
|
24
|
-
SET owner = ?,
|
|
25
|
-
status = 'in_progress',
|
|
26
|
-
updated_at = datetime('now')
|
|
27
|
-
WHERE id = (
|
|
28
|
-
SELECT id FROM tasks
|
|
29
|
-
WHERE status = 'pending'
|
|
30
|
-
AND (owner IS NULL OR owner = '')
|
|
31
|
-
ORDER BY priority DESC, created_at ASC
|
|
32
|
-
LIMIT 1
|
|
33
|
-
)
|
|
34
|
-
AND status = 'pending'
|
|
35
|
-
AND (owner IS NULL OR owner = '')
|
|
36
|
-
SQL
|
|
37
|
-
|
|
38
|
-
# Fetch the task we just claimed. Using owner + status filters
|
|
39
|
-
# ensures we only retrieve a task that *this* agent successfully
|
|
40
|
-
# claimed (another agent cannot have flipped it in between).
|
|
41
|
-
rows = db.query(<<~SQL, [agent_name]).to_a
|
|
42
|
-
SELECT id, session_id, title, description, status,
|
|
43
|
-
priority, owner, result, metadata, created_at, updated_at
|
|
44
|
-
FROM tasks
|
|
45
|
-
WHERE owner = ?
|
|
46
|
-
AND status = 'in_progress'
|
|
47
|
-
ORDER BY updated_at DESC
|
|
48
|
-
LIMIT 1
|
|
49
|
-
SQL
|
|
50
|
-
|
|
51
|
-
return nil if rows.empty?
|
|
52
|
-
|
|
53
|
-
row = rows.first
|
|
54
|
-
build_task(row)
|
|
21
|
+
claim_next_pending_task(db, agent_name, max_retries)
|
|
22
|
+
fetch_claimed_task(db, agent_name)
|
|
55
23
|
rescue StandardError => e
|
|
56
|
-
# If anything goes wrong (e.g. task was already claimed between
|
|
57
|
-
# our SELECT and UPDATE, or a constraint violation) we treat it
|
|
58
|
-
# as "no work available" rather than crashing the daemon.
|
|
59
24
|
RubynCode.logger.warn("TaskClaimer: failed to claim task: #{e.message}") if RubynCode.respond_to?(:logger)
|
|
60
25
|
nil
|
|
61
26
|
end
|
|
@@ -63,6 +28,43 @@ module RubynCode
|
|
|
63
28
|
class << self
|
|
64
29
|
private
|
|
65
30
|
|
|
31
|
+
def claim_next_pending_task(db, agent_name, max_retries)
|
|
32
|
+
db.execute(<<~SQL, [agent_name, max_retries])
|
|
33
|
+
UPDATE tasks
|
|
34
|
+
SET owner = ?,
|
|
35
|
+
status = 'in_progress',
|
|
36
|
+
updated_at = datetime('now')
|
|
37
|
+
WHERE id = (
|
|
38
|
+
SELECT t.id FROM tasks t
|
|
39
|
+
WHERE t.status = 'pending'
|
|
40
|
+
AND (t.owner IS NULL OR t.owner = '')
|
|
41
|
+
AND COALESCE(
|
|
42
|
+
json_extract(t.metadata, '$.retry_count'), 0
|
|
43
|
+
) < ?
|
|
44
|
+
ORDER BY t.priority DESC, t.created_at ASC
|
|
45
|
+
LIMIT 1
|
|
46
|
+
)
|
|
47
|
+
AND status = 'pending'
|
|
48
|
+
AND (owner IS NULL OR owner = '')
|
|
49
|
+
SQL
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fetch_claimed_task(db, agent_name)
|
|
53
|
+
rows = db.query(<<~SQL, [agent_name]).to_a
|
|
54
|
+
SELECT id, session_id, title, description, status,
|
|
55
|
+
priority, owner, result, metadata, created_at, updated_at
|
|
56
|
+
FROM tasks
|
|
57
|
+
WHERE owner = ?
|
|
58
|
+
AND status = 'in_progress'
|
|
59
|
+
ORDER BY updated_at DESC
|
|
60
|
+
LIMIT 1
|
|
61
|
+
SQL
|
|
62
|
+
|
|
63
|
+
return nil if rows.empty?
|
|
64
|
+
|
|
65
|
+
build_task(rows.first)
|
|
66
|
+
end
|
|
67
|
+
|
|
66
68
|
# @param row [Hash] a database row hash
|
|
67
69
|
# @return [Tasks::Task]
|
|
68
70
|
def build_task(row)
|