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.
Files changed (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +151 -5
  3. data/db/migrations/013_add_failed_status_to_tasks.rb +51 -0
  4. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  5. data/lib/rubyn_code/agent/conversation.rb +84 -56
  6. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +152 -0
  7. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  8. data/lib/rubyn_code/agent/llm_caller.rb +157 -0
  9. data/lib/rubyn_code/agent/loop.rb +182 -683
  10. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  11. data/lib/rubyn_code/agent/prompts.rb +109 -0
  12. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  13. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  14. data/lib/rubyn_code/agent/system_prompt_builder.rb +211 -0
  15. data/lib/rubyn_code/agent/tool_processor.rb +178 -0
  16. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  17. data/lib/rubyn_code/auth/key_encryption.rb +118 -0
  18. data/lib/rubyn_code/auth/oauth.rb +80 -64
  19. data/lib/rubyn_code/auth/server.rb +21 -24
  20. data/lib/rubyn_code/auth/token_store.rb +80 -52
  21. data/lib/rubyn_code/autonomous/daemon.rb +146 -32
  22. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -24
  23. data/lib/rubyn_code/autonomous/task_claimer.rb +46 -44
  24. data/lib/rubyn_code/background/worker.rb +64 -76
  25. data/lib/rubyn_code/cli/app.rb +159 -114
  26. data/lib/rubyn_code/cli/commands/doctor.rb +73 -0
  27. data/lib/rubyn_code/cli/commands/mcp.rb +77 -0
  28. data/lib/rubyn_code/cli/commands/model.rb +105 -18
  29. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  30. data/lib/rubyn_code/cli/commands/provider.rb +123 -0
  31. data/lib/rubyn_code/cli/commands/skill.rb +52 -3
  32. data/lib/rubyn_code/cli/daemon_runner.rb +64 -11
  33. data/lib/rubyn_code/cli/first_run.rb +159 -0
  34. data/lib/rubyn_code/cli/renderer.rb +109 -60
  35. data/lib/rubyn_code/cli/repl.rb +48 -374
  36. data/lib/rubyn_code/cli/repl_commands.rb +177 -0
  37. data/lib/rubyn_code/cli/repl_lifecycle.rb +76 -0
  38. data/lib/rubyn_code/cli/repl_setup.rb +181 -0
  39. data/lib/rubyn_code/cli/setup.rb +6 -2
  40. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  41. data/lib/rubyn_code/cli/version_check.rb +28 -11
  42. data/lib/rubyn_code/config/defaults.rb +11 -0
  43. data/lib/rubyn_code/config/project_profile.rb +185 -0
  44. data/lib/rubyn_code/config/schema.json +49 -0
  45. data/lib/rubyn_code/config/settings.rb +103 -1
  46. data/lib/rubyn_code/config/validator.rb +63 -0
  47. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  48. data/lib/rubyn_code/context/context_budget.rb +182 -0
  49. data/lib/rubyn_code/context/context_collapse.rb +34 -4
  50. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  51. data/lib/rubyn_code/context/manager.rb +44 -8
  52. data/lib/rubyn_code/context/manual_compact.rb +1 -1
  53. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  54. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  55. data/lib/rubyn_code/db/connection.rb +31 -26
  56. data/lib/rubyn_code/db/migrator.rb +44 -28
  57. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  58. data/lib/rubyn_code/hooks/registry.rb +4 -0
  59. data/lib/rubyn_code/ide/adapters/tool_output.rb +330 -0
  60. data/lib/rubyn_code/ide/client.rb +110 -0
  61. data/lib/rubyn_code/ide/handlers/accept_edit_handler.rb +35 -0
  62. data/lib/rubyn_code/ide/handlers/approve_tool_use_handler.rb +34 -0
  63. data/lib/rubyn_code/ide/handlers/cancel_handler.rb +41 -0
  64. data/lib/rubyn_code/ide/handlers/config_get_handler.rb +63 -0
  65. data/lib/rubyn_code/ide/handlers/config_set_handler.rb +86 -0
  66. data/lib/rubyn_code/ide/handlers/initialize_handler.rb +79 -0
  67. data/lib/rubyn_code/ide/handlers/models_list_handler.rb +39 -0
  68. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +215 -0
  69. data/lib/rubyn_code/ide/handlers/review_handler.rb +110 -0
  70. data/lib/rubyn_code/ide/handlers/session_fork_handler.rb +49 -0
  71. data/lib/rubyn_code/ide/handlers/session_list_handler.rb +41 -0
  72. data/lib/rubyn_code/ide/handlers/session_reset_handler.rb +31 -0
  73. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +42 -0
  74. data/lib/rubyn_code/ide/handlers/shutdown_handler.rb +37 -0
  75. data/lib/rubyn_code/ide/handlers.rb +76 -0
  76. data/lib/rubyn_code/ide/protocol.rb +111 -0
  77. data/lib/rubyn_code/ide/server.rb +186 -0
  78. data/lib/rubyn_code/index/codebase_index.rb +311 -0
  79. data/lib/rubyn_code/learning/extractor.rb +65 -82
  80. data/lib/rubyn_code/learning/injector.rb +22 -23
  81. data/lib/rubyn_code/learning/instinct.rb +71 -42
  82. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  83. data/lib/rubyn_code/llm/adapters/anthropic.rb +274 -0
  84. data/lib/rubyn_code/llm/adapters/anthropic_compatible.rb +60 -0
  85. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  86. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  87. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  88. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  89. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +50 -0
  90. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  91. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  92. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  93. data/lib/rubyn_code/llm/client.rb +75 -247
  94. data/lib/rubyn_code/llm/model_router.rb +237 -0
  95. data/lib/rubyn_code/llm/streaming.rb +4 -227
  96. data/lib/rubyn_code/mcp/client.rb +1 -1
  97. data/lib/rubyn_code/mcp/config.rb +10 -12
  98. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  99. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  100. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  101. data/lib/rubyn_code/memory/search.rb +1 -0
  102. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  103. data/lib/rubyn_code/memory/store.rb +42 -55
  104. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  105. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  106. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  107. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  108. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  109. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  110. data/lib/rubyn_code/output/formatter.rb +11 -11
  111. data/lib/rubyn_code/permissions/policy.rb +11 -13
  112. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  113. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  114. data/lib/rubyn_code/self_test.rb +315 -0
  115. data/lib/rubyn_code/skills/catalog.rb +66 -0
  116. data/lib/rubyn_code/skills/document.rb +33 -29
  117. data/lib/rubyn_code/skills/loader.rb +43 -0
  118. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  119. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  120. data/lib/rubyn_code/tasks/dag.rb +25 -24
  121. data/lib/rubyn_code/tasks/models.rb +1 -0
  122. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  123. data/lib/rubyn_code/tools/background_run.rb +2 -1
  124. data/lib/rubyn_code/tools/base.rb +39 -32
  125. data/lib/rubyn_code/tools/bash.rb +7 -1
  126. data/lib/rubyn_code/tools/edit_file.rb +130 -17
  127. data/lib/rubyn_code/tools/executor.rb +130 -25
  128. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  129. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  130. data/lib/rubyn_code/tools/git_log.rb +12 -10
  131. data/lib/rubyn_code/tools/glob.rb +29 -7
  132. data/lib/rubyn_code/tools/grep.rb +8 -1
  133. data/lib/rubyn_code/tools/ide_diagnostics.rb +51 -0
  134. data/lib/rubyn_code/tools/ide_symbols.rb +53 -0
  135. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  136. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  137. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  138. data/lib/rubyn_code/tools/output_compressor.rb +190 -0
  139. data/lib/rubyn_code/tools/read_file.rb +17 -6
  140. data/lib/rubyn_code/tools/registry.rb +11 -0
  141. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  142. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  143. data/lib/rubyn_code/tools/schema.rb +4 -10
  144. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  145. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  146. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  147. data/lib/rubyn_code/tools/task.rb +17 -17
  148. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  149. data/lib/rubyn_code/tools/web_search.rb +66 -48
  150. data/lib/rubyn_code/tools/write_file.rb +76 -1
  151. data/lib/rubyn_code/version.rb +1 -1
  152. data/lib/rubyn_code.rb +62 -1
  153. data/skills/rubyn_self_test.md +133 -0
  154. 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
- def save(access_token:, refresh_token:, expires_at:)
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
- data = {
27
- 'access_token' => access_token,
28
- 'refresh_token' => refresh_token,
29
- 'expires_at' => expires_at.is_a?(Time) ? expires_at.iso8601 : expires_at.to_s
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
- File.write(tokens_path, YAML.dump(data))
33
- File.chmod(0o600, tokens_path)
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
- valid?
58
- end
79
+ def exists? = valid?
80
+ def access_token = self.load&.fetch(:access_token, nil)
59
81
 
60
- def access_token
61
- tokens = self.load
62
- tokens&.fetch(:access_token, nil)
63
- end
82
+ private
64
83
 
65
- def token_type
66
- tokens = self.load
67
- tokens&.fetch(:type, :oauth)
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
- data = JSON.parse(output)
80
- oauth = data['claudeAiOauth']
81
- return nil unless oauth && oauth['accessToken']
97
+ oauth = JSON.parse(output)['claudeAiOauth']
98
+ return nil unless oauth&.dig('accessToken')
82
99
 
83
- expires_at = if oauth['expiresAt']
84
- Time.at(oauth['expiresAt'] / 1000.0) # milliseconds to seconds
85
- end
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: 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 tokens_path
132
- Config::Defaults::TOKENS_FILE
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
- dir = File.dirname(tokens_path)
137
- FileUtils.mkdir_p(dir)
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
- @agent_name = agent_name
41
- @role = role
42
- @llm_client = llm_client
43
- @project_root = File.expand_path(project_root)
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 from the budget enforcer
146
- track_cost_from_enforcer(agent_loop)
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
- # On failure, release the task so another agent (or retry) can pick it up.
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
- # Accumulates cost tracked by the Agent::Loop's context manager.
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 track_cost_from_enforcer(agent_loop)
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.respond_to?(:total_cost)
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
- @total_cost += cm.total_cost.to_f
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 has_pending_messages?
38
+ return :resume if pending_messages?
39
39
 
40
- return :resume if has_claimable_task?
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 has_pending_messages?
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 has_claimable_task?
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
- # Finds the first ready (pending, unowned) task, claims it for the
10
- # given agent, and returns the updated Task. Returns nil if no work
11
- # is available.
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
- # Atomically claim the first eligible task. The WHERE conditions
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)