rubik 0.1.5 → 0.2.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +130 -0
  4. data/README.md +30 -17
  5. data/lib/rubik/ast/extraction_result.rb +37 -0
  6. data/lib/rubik/ast/extractor.rb +35 -9
  7. data/lib/rubik/ast/graph.rb +27 -1
  8. data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
  9. data/lib/rubik/ast/treesitter_extractor.rb +45 -17
  10. data/lib/rubik/buffer/apply_contract.rb +78 -0
  11. data/lib/rubik/buffer/manager.rb +61 -5
  12. data/lib/rubik/coeffects.rb +14 -15
  13. data/lib/rubik/concurrency/event_loop.rb +56 -44
  14. data/lib/rubik/concurrency/scheduler.rb +7 -24
  15. data/lib/rubik/config/defaults.rb +9 -6
  16. data/lib/rubik/config.rb +8 -0
  17. data/lib/rubik/context/git_lister.rb +6 -1
  18. data/lib/rubik/context/scanner.rb +15 -3
  19. data/lib/rubik/mcp/transport/stdio.rb +14 -11
  20. data/lib/rubik/orchestrator/step_executor.rb +9 -2
  21. data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
  22. data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
  23. data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
  24. data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
  25. data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
  26. data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
  27. data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
  28. data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
  29. data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
  30. data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
  31. data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
  32. data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
  33. data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
  34. data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
  35. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
  36. data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
  37. data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
  38. data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
  39. data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
  40. data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
  41. data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
  42. data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
  43. data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
  44. data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
  45. data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
  46. data/lib/rubik/orchestrator/tool_history.rb +8 -3
  47. data/lib/rubik/platform/process.rb +74 -2
  48. data/lib/rubik/plugin/manager.rb +148 -71
  49. data/lib/rubik/plugin/package_inventory.rb +84 -0
  50. data/lib/rubik/plugin/requires_dag.rb +40 -0
  51. data/lib/rubik/plugin/storage.rb +100 -14
  52. data/lib/rubik/prompt/doctrine.rb +5 -3
  53. data/lib/rubik/repl/commands/buffer.rb +28 -18
  54. data/lib/rubik/repl/commands/subagents.rb +2 -0
  55. data/lib/rubik/repl/input_execution.rb +42 -0
  56. data/lib/rubik/repl/input_loop.rb +57 -7
  57. data/lib/rubik/repl/prompt_grace.rb +103 -0
  58. data/lib/rubik/runtime/repair_probe.rb +98 -0
  59. data/lib/rubik/security/blocked_guidance.rb +19 -0
  60. data/lib/rubik/security/guard.rb +115 -6
  61. data/lib/rubik/security/redactor.rb +7 -7
  62. data/lib/rubik/storage/transcript_indexer.rb +21 -11
  63. data/lib/rubik/tools/builtins/exec.rb +76 -28
  64. data/lib/rubik/tools/builtins/files.rb +6 -1
  65. data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
  66. data/lib/rubik/tools/builtins/subagent.rb +39 -31
  67. data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
  68. data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
  69. data/lib/rubik/tools/truncator.rb +19 -2
  70. data/lib/rubik/tools/uri_resolver.rb +84 -20
  71. data/lib/rubik/uris.rb +11 -0
  72. data/lib/rubik/version.rb +1 -1
  73. metadata +20 -1
@@ -23,6 +23,9 @@ module Rubik
23
23
  # PERF: Pre-compiled constants to avoid re-allocation inside hot loops
24
24
  URL_PATTERN = %r{(https?://[^:]+:)([^@]+)@}
25
25
  URL_MASK = "\\1#{MASK_STRING}@".freeze
26
+ STATIC_PATTERNS = PATTERNS.reject { |pattern| pattern == URL_PATTERN }.freeze
27
+ # Skip static replacement passes for assignment-only and URL-only inputs.
28
+ STATIC_PATTERNS_COMBINED_REGEX = Regexp.union(STATIC_PATTERNS).freeze
26
29
  QUICK_CHECK_REGEX = %r{sk-|AKIA|ghp_|github_pat_|AIza|bearer|PRIVATE KEY|://[^:]+:|password|passwd|api_key|secret|token}i
27
30
 
28
31
  # Anahtar-maskeleme desenleri (Session::Recorder#mask_payload ile aynı
@@ -46,13 +49,10 @@ module Rubik
46
49
  # ve desenler zaten bu metne uygulanıyordu, dolayısıyla eşdeğerdir.
47
50
  return result unless result.match?(QUICK_CHECK_REGEX)
48
51
 
49
- PATTERNS.each do |pattern|
50
- if pattern == URL_PATTERN
51
- result.gsub!(pattern, URL_MASK)
52
- else
53
- result.gsub!(pattern, MASK_STRING)
54
- end
55
- end
52
+ # Keep replacement order: masking one credential can expose a word
53
+ # boundary needed to recognize the next (for example, a bearer token).
54
+ STATIC_PATTERNS.each { |pattern| result.gsub!(pattern, MASK_STRING) } if result.match?(STATIC_PATTERNS_COMBINED_REGEX)
55
+ result.gsub!(URL_PATTERN, URL_MASK)
56
56
 
57
57
  result.gsub!(ASSIGNMENT_KEY_REGEX) do
58
58
  "#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{MASK_STRING}"
@@ -26,23 +26,30 @@ module Rubik
26
26
  return unless store && inst
27
27
 
28
28
  file = inst.transcript_uri.to_s
29
+ result_ref = inst.respond_to?(:result_ref) ? inst.result_ref : nil
29
30
  # Gerçek upsert: alias üzerinden varsa mevcut id güncellenir — resume
30
31
  # sonrası ikinci tamamlanma yinelenen transcript varlığı yaratmaz.
31
32
  ref = Rubik::Uris.transcript_ref(inst.storage_id)
32
33
  existing = store.get(type: "transcript", id: ref)
34
+ links = {
35
+ "subagent_id" => inst.storage_id.to_s,
36
+ # The raw path is forensics-only (read_file denies it): the
37
+ # canonical read route rides in the entity itself so recall
38
+ # consumers land on show_file in one hop (issue #97 finding 5).
39
+ "canonical" => Rubik::Uris.subagent_transcript(inst.storage_id),
40
+ "transcript" => file
41
+ }
42
+ # Issue #122: the searchable summary also points at the paginated
43
+ # retained-result route. Availability is session-scoped (archive TTL),
44
+ # so this is a pointer with explicit expiration semantics, not a
45
+ # promise of unbounded retention.
46
+ links["result"] = result_ref if result_ref
33
47
  store.put(
34
48
  type: "transcript",
35
49
  title: "[#{inst.type_name}] #{inst.role} (#{inst.id}) — #{inst.state}",
36
- body: entity_body(inst, file),
50
+ body: entity_body(inst, file, result_ref: result_ref),
37
51
  tags: [inst.type_name.to_s, inst.state.to_s].uniq,
38
- links: {
39
- "subagent_id" => inst.storage_id.to_s,
40
- # The raw path is forensics-only (read_file denies it): the
41
- # canonical read route rides in the entity itself so recall
42
- # consumers land on show_file in one hop (issue #97 finding 5).
43
- "canonical" => Rubik::Uris.subagent_transcript(inst.storage_id),
44
- "transcript" => file
45
- },
52
+ links: links,
46
53
  status: inst.state.to_s,
47
54
  id: existing&.[]("id"),
48
55
  ref: ref
@@ -59,7 +66,7 @@ module Rubik
59
66
  # gelmeyen her metin (prompt fallback, inst.result) HAM memory'den
60
67
  # gelir — kalıcı+aranabilir gövdeye girmeden Redactor'dan geçer
61
68
  # (dosya tarafının maskeleme paritesi; review bulgusu).
62
- def entity_body(inst, file)
69
+ def entity_body(inst, file, result_ref: nil)
63
70
  task = first_user_task(file)
64
71
  task_part = if task
65
72
  "[task] #{clip(task)}"
@@ -71,7 +78,10 @@ module Rubik
71
78
 
72
79
  result = inst.respond_to?(:result) ? redact(inst.result.to_s) : ""
73
80
  result_part = result.empty? ? "[state] #{inst.state} — no result recorded" : "[result] #{clip(result)}"
74
- "#{task_part}\n#{result_part}"
81
+ body = "#{task_part}\n#{result_part}"
82
+ return body unless result_ref && inst.respond_to?(:state) && inst.state.to_s == "completed"
83
+
84
+ "#{body}\n[retrieve] #{result_ref} — full retained result, paginated (manage_subagents action 'result')"
75
85
  end
76
86
 
77
87
  def redact(text)
@@ -150,51 +150,99 @@ module Rubik
150
150
  def execute(args = {})
151
151
  cmd = (args["command"] || args[:command]).to_s.strip
152
152
  background = args["background"] || args[:background]
153
- if background
154
- task_mgr = @task_manager || (@runtime_context.respond_to?(:get) ? Rubik::Coeffects.get(@runtime_context, Rubik::Coeffects::TASK_MANAGER) : nil)
155
- return ToolOutput.error("TaskManager is not available to run commands in background.") unless task_mgr
153
+ return execute_background(cmd) if background
156
154
 
157
- @guard.verify_command_safety!(cmd)
158
- task = task_mgr.start(cmd, chdir: @root_dir, ctx: @runtime_context)
159
- return "Command dispatched to background task [#{task.id}] (PID #{task.pid}): #{cmd}\nUse `manage_task` with action 'status' or 'logs' to inspect."
155
+ timeout = effective_timeout(args)
156
+ @guard.verify_command_safety!(cmd)
157
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
158
+ res = @runner.run(cmd, timeout: timeout, chdir: @root_dir)
159
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1)
160
+ command_result(cmd, res, duration_ms: duration_ms)
161
+ rescue Rubik::Security::BlockedWriteError => e
162
+ # Authorization denial (issue #127): nothing executed, so no process
163
+ # exit code is fabricated — the metadata says denied / not executed.
164
+ # The prefix stays first for display consumers (StepExecutor::ERROR_PREFIXES).
165
+ Rubik::Tools::Result.new(
166
+ tool_name: "run_command", status: :error,
167
+ output: Rubik::Tools::RunCommandTool.blocked_guidance(e),
168
+ error: "command denied by security policy (not executed)",
169
+ exit_code: nil, metadata: { "executed" => false, "denied" => "authorization" }
170
+ )
171
+ rescue StandardError => e
172
+ Rubik::Tools::Result.new(
173
+ tool_name: "run_command", status: :error,
174
+ output: "Command error: #{e.message}", error: e.message,
175
+ exit_code: nil, metadata: { "executed" => false }
176
+ )
177
+ end
178
+
179
+ # Stable public seam (RunCommandTool#execute, ManageTaskTool, tests and
180
+ # dev/review_fix_check call it). The renderer lives in the security
181
+ # domain next to the error it renders: Rubik::Security::BlockedGuidance
182
+ # — also keeps this file under the builtin line budget.
183
+ def self.blocked_guidance(err)
184
+ Rubik::Security::BlockedGuidance.render(err)
185
+ end
186
+
187
+ private
188
+
189
+ def execute_background(cmd)
190
+ task_mgr = @task_manager ||
191
+ (@runtime_context.respond_to?(:get) ? Rubik::Coeffects.get(@runtime_context, Rubik::Coeffects::TASK_MANAGER) : nil)
192
+ if task_mgr.nil?
193
+ return Rubik::Tools::Result.new(tool_name: "run_command", status: :error,
194
+ output: "TaskManager is not available to run commands in background.",
195
+ error: "task manager unavailable",
196
+ metadata: { "executed" => false, "background" => true })
160
197
  end
161
198
 
199
+ @guard.verify_command_safety!(cmd)
200
+ task = task_mgr.start(cmd, chdir: @root_dir, ctx: @runtime_context)
201
+ text = "Command dispatched to background task [#{task.id}] (PID #{task.pid}): #{cmd}\n" \
202
+ "Use `manage_task` with action 'status' or 'logs' to inspect."
203
+ Rubik::Tools::Result.new(tool_name: "run_command", status: :ok, output: text, exit_code: nil,
204
+ metadata: { "executed" => true, "background" => true, "task_id" => task.id })
205
+ end
206
+
207
+ def effective_timeout(args)
162
208
  # Timeout contract: runners expose their config via a public `config`
163
209
  # reader (Sandbox::Runner does via attr_reader). Never reach into an
164
210
  # arbitrary object with instance_variable_get — that couples us to an
165
211
  # ivar name we do not own and silently falls back if it is ever renamed.
166
212
  cfg = @runner.config if @runner.respond_to?(:config)
167
213
  cfg_timeout = cfg&.fetch("tools.timeout_seconds", 30) || 30
168
- timeout = (args["timeout"] || args[:timeout] || cfg_timeout).to_i.clamp(1, 600)
214
+ (args["timeout"] || args[:timeout] || cfg_timeout).to_i.clamp(1, 600)
215
+ end
169
216
 
170
- @guard.verify_command_safety!(cmd)
171
- res = @runner.run(cmd, timeout: timeout, chdir: @root_dir)
172
- status = res[:ok] ? "OK" : "EXIT #{res[:exit_code]}"
173
- # Shell tag (Plan 55 D-5) teaches the model which interpreter ran the
174
- # command; drivers that resolve no shell simply omit it.
217
+ # The structured command outcome (issue #127): the display text keeps
218
+ # the historical [OK]/[EXIT n] prefix and diagnostics, while the machine
219
+ # fields (status / exit_code / truncated / metadata) ride the Result
220
+ # contract through BOTH dispatch paths — no display-text parsing.
221
+ def command_result(cmd, res, duration_ms:)
222
+ exit_code = res[:exit_code]
223
+ timed_out = exit_code.to_i == 124
224
+ status_text = res[:ok] ? "OK" : "EXIT #{exit_code}"
175
225
  shell_tag = res[:shell] ? " (#{res[:shell]})" : ""
176
- out = "[#{status}]#{shell_tag} #{res[:output]}"
226
+ out = "[#{status_text}]#{shell_tag} #{res[:output]}"
177
227
  out += "\n#{res[:isolation]}" if res[:isolation]
178
228
 
229
+ diagnostic = nil
179
230
  unless res[:ok]
180
231
  require_relative "../../runtime/diagnostic_engine" unless defined?(Rubik::Runtime::DiagnosticEngine)
181
- diag = Rubik::Runtime::DiagnosticEngine.analyze_process_result(res, cmd: cmd)
182
- out += "\n\n#{diag.format_for_llm}" if diag
232
+ diagnostic = Rubik::Runtime::DiagnosticEngine.analyze_process_result(res, cmd: cmd)
233
+ out += "\n\n#{diagnostic.format_for_llm}" if diagnostic
183
234
  end
184
- out
185
- rescue Rubik::Security::BlockedWriteError => e
186
- # Prefix stays first: StepExecutor::ERROR_PREFIXES classifies by start_with?.
187
- ToolOutput.error(Rubik::Tools::RunCommandTool.blocked_guidance(e))
188
- rescue StandardError => e
189
- ToolOutput.error("Command error: #{e.message}")
190
- end
191
235
 
192
- # Stable public seam (RunCommandTool#execute, ManageTaskTool, tests and
193
- # dev/review_fix_check call it). The renderer lives in the security
194
- # domain next to the error it renders: Rubik::Security::BlockedGuidance
195
- # — also keeps this file under the builtin line budget.
196
- def self.blocked_guidance(err)
197
- Rubik::Security::BlockedGuidance.render(err)
236
+ timeout_suffix = timed_out ? " (timed out)" : ""
237
+ error_message = res[:ok] ? nil : "command exited with status #{exit_code}#{timeout_suffix}"
238
+
239
+ Rubik::Tools::Result.new(
240
+ tool_name: "run_command", status: res[:ok] ? :ok : :error, output: out,
241
+ error: error_message, exit_code: exit_code, duration_ms: duration_ms,
242
+ metadata: { "executed" => true, "timed_out" => timed_out,
243
+ "diagnostic_category" => diagnostic && diagnostic.category.to_s,
244
+ "shell" => res[:shell].to_s, "driver" => res[:driver].to_s }.reject { |_k, v| v.nil? || v == "" }
245
+ )
198
246
  end
199
247
  end
200
248
 
@@ -19,7 +19,12 @@ module Rubik
19
19
  type: "object",
20
20
  properties: {
21
21
  path: { type: "string", description: "File path, or virtual rubik:// URI: 'rubik://storage/<project>/<type>/<id>' " \
22
- "(browse from 'rubik://storage/index'), 'rubik://goal/state', 'rubik://subagent/<id>/transcript'" },
22
+ "(browse from 'rubik://storage/index'), 'rubik://goal/state', " \
23
+ "'rubik://subagent/<storage-id>/transcript' (the UNIQUE storage id from " \
24
+ "manage_subagents status 'transcript_ref' or wait rows — live while running, " \
25
+ "persisted after the first save; a display id like 'subagent-2' or a disk path " \
26
+ "is NOT a transcript address), 'rubik://subagent/<storage-id>/result[/<run>]' " \
27
+ "(retained completed results)" },
23
28
  offset: { type: "integer", description: "1-based line number to start reading from (optional)" },
24
29
  limit: { type: "integer", description: "Maximum number of lines to read (optional)" },
25
30
  from_disk: { type: "boolean", description: "Read current disk content without modifying in-memory buffer" },
@@ -3,6 +3,8 @@
3
3
  require_relative "../base"
4
4
  require_relative "../output"
5
5
  require_relative "../../runtime"
6
+ require_relative "../../runtime/repair_probe"
7
+ require_relative "../../buffer/apply_contract"
6
8
 
7
9
  module Rubik
8
10
  module Tools
@@ -33,7 +35,9 @@ module Rubik
33
35
  def execute(args = {})
34
36
  return ToolOutput.error("Buffer manager is not connected.") unless @buffer_manager
35
37
 
36
- if @shell&.config && @shell.config.fetch("buffer.model_can_apply", true) == false
38
+ shell_config = @shell.respond_to?(:config) ? @shell.config : nil
39
+ manager_config = @buffer_manager.respond_to?(:config) ? @buffer_manager.config : nil
40
+ unless Rubik::Buffer::ApplyContract.model_can_apply?(shell_config: shell_config, manager_config: manager_config)
37
41
  return ToolOutput.error("Model buffer application is disabled by configuration " \
38
42
  "(buffer.model_can_apply: false). The human operator will review and apply changes.")
39
43
  end
@@ -43,7 +47,15 @@ module Rubik
43
47
  policy = force ? "overwrite" : nil
44
48
 
45
49
  if @buffer_manager.dirty?
46
- applied = @buffer_manager.apply!(policy: policy)
50
+ applied = nil
51
+ begin
52
+ applied = @buffer_manager.apply!(policy: policy)
53
+ rescue Rubik::Buffer::ApplyFailedError => e
54
+ # Partial apply: files in e.applied ARE on disk — sync the model's
55
+ # view for them and report the remainder honestly (issue #146).
56
+ lines = Rubik::Buffer::ApplyContract.failure_lines(e, sync: ->(paths) { @shell&.sync_context_and_ast(paths) })
57
+ return ToolOutput.error(lines.join("\n"))
58
+ end
47
59
  conflicts = @buffer_manager.last_conflicts || []
48
60
  @shell&.sync_context_and_ast(applied) unless applied.empty?
49
61
 
@@ -68,30 +80,16 @@ module Rubik
68
80
  messages.join("\n")
69
81
  end
70
82
  else
71
- clean_buffer_message
83
+ Rubik::Buffer::ApplyContract.clean_buffer_message(@buffer_manager.last_applied, auto_apply_mode)
72
84
  end
73
85
  end
74
86
 
75
87
  private
76
88
 
77
- # "Buffer is clean" alone reads as "nothing happened" when an earlier
78
- # apply (apply_buffer itself or the auto-apply policy) already flushed
79
- # the edits to disk. Surface the last successful apply so the model can
80
- # verify against reality instead of re-editing or force-applying
81
- # (issue #97 finding 6). When the mode cannot be read, the origin stays
82
- # deliberately two-sided rather than inventing a specific cause.
83
- def clean_buffer_message
84
- last = @buffer_manager.last_applied
85
- return "No unapplied buffer changes to write (buffer is clean)." unless last.is_a?(Hash) && Array(last[:paths]).any?
86
-
87
- mode = @shell.respond_to?(:config) && @shell.config.respond_to?(:fetch) ? @shell.config.fetch("buffer.auto_apply", "manual") : nil
88
- origin = if !mode.nil? && !mode.to_s.strip.empty? && mode.to_s.downcase != "manual"
89
- "the auto-apply policy (buffer.auto_apply: #{mode})"
90
- else
91
- "an earlier apply_buffer call or the auto-apply policy"
92
- end
93
- "No unapplied buffer changes to write (buffer is clean): #{last[:paths].size} file(s) were already written to disk " \
94
- "at #{last[:at].utc.iso8601} by #{origin}: #{last[:paths].join(', ')}."
89
+ def auto_apply_mode
90
+ return nil unless @shell.respond_to?(:config) && @shell.config.respond_to?(:fetch)
91
+
92
+ @shell.config.fetch("buffer.auto_apply", "manual")
95
93
  end
96
94
  end
97
95
 
@@ -117,6 +115,10 @@ module Rubik
117
115
  type: "string",
118
116
  description: "Replacement or custom command string (optional, probed automatically if auto_probe: true)"
119
117
  },
118
+ probe_args: {
119
+ type: "array", items: { type: "string" },
120
+ description: "Explicit non-mutating functional probe arguments appended to the exact proposed command; required for unknown executables"
121
+ },
120
122
  auto_probe: {
121
123
  type: "boolean",
122
124
  description: "Whether to automatically probe system binaries and candidate commands (default: true)"
@@ -187,7 +189,7 @@ module Rubik
187
189
  error_diagnostic = nil
188
190
 
189
191
  begin
190
- verified, error_diagnostic = verify_binary(target, lang, proposed_cmd, sub_ctx)
192
+ verified, error_diagnostic = verify_binary(target, lang, proposed_cmd, sub_ctx, probe_args: args["probe_args"] || args[:probe_args])
191
193
  rescue StandardError => e
192
194
  error_diagnostic = Rubik::Runtime::DiagnosticEngine.diagnose(
193
195
  cmd: proposed_cmd,
@@ -200,66 +202,114 @@ module Rubik
200
202
 
201
203
  return ToolOutput.error("Verification failed for #{target} (#{lang}):\n#{error_diagnostic&.format_for_llm}") unless verified
202
204
 
203
- # 3. Live Context Promotion & Subsystem Configuration (Zero-Downtime Hot Swapping)
204
- case target
205
- when "lsp"
206
- cfg_key = "lsp.servers.#{lang}"
207
- prev_cmd = @config&.fetch(cfg_key)
208
- had_client = @lsp_manager ? !@lsp_manager.client_for(lang).nil? : false
209
- @config&.set(cfg_key, proposed_cmd)
210
- if @lsp_manager
211
- @lsp_manager.stop_client(lang)
212
- start_res = @lsp_manager.start_client(lang)
213
- unless start_res.is_a?(Hash) && start_res[:ok] == true
214
- # Failure Reversibility (AGENTS.md §1.A): roll back the in-memory
215
- # state; disk stays clean because persist happens only after a
216
- # verified start (atomic promotion).
217
- @lsp_manager.stop_client(lang)
218
- @config&.set(cfg_key, prev_cmd)
219
- @lsp_manager.start_client(lang) if had_client
220
- cause = start_res.is_a?(Hash) ? start_res[:error] : "unexpected start_client response"
221
- return ToolOutput.error("Failed to start LSP server for #{lang} ('#{proposed_cmd}'); " \
222
- "previous configuration restored. Cause: #{cause}")
205
+ # Live changes belong to a disposable transaction. Successful project
206
+ # persistence is an operator decision and outlives lifecycle cleanup.
207
+ promotion_ctx = root_ctx.extend
208
+ released = false
209
+ @repair_scopes ||= {}
210
+ cfg_key = { "lsp" => "lsp.servers", "test_runner" => "test.runners",
211
+ "linter" => "linter.commands" }[target]
212
+ cfg_key = "#{cfg_key}.#{lang}" if cfg_key
213
+ prev_cmd = @config&.fetch(cfg_key) if cfg_key
214
+ service_key = "#{{ 'test_runner' => 'runner' }.fetch(target, target)}:#{lang}"
215
+ previous_services = root_ctx.services
216
+ phase = "Promotion"
217
+ begin
218
+ promotion_ctx.on_dispose do
219
+ next if released
220
+
221
+ @repair_scopes.delete([root_ctx, service_key]) if @repair_scopes[[root_ctx, service_key]].equal?(promotion_ctx)
222
+ unless root_ctx.disposed?
223
+ if previous_services.key?(service_key)
224
+ restored = target == "lsp" ? @lsp_manager&.clients&.[](lang) : nil
225
+ root_ctx.provide(service_key, restored || previous_services[service_key])
226
+ else
227
+ root_ctx.unprovide(service_key)
228
+ end
223
229
  end
224
- active_client = @lsp_manager.client_for(lang)
225
- root_ctx.provide("lsp:#{lang}", active_client || proposed_cmd)
226
- else
227
- root_ctx.provide("lsp:#{lang}", proposed_cmd)
228
230
  end
229
- # Persist to project config only after the live promotion succeeded.
230
- persist_res = @config&.set(cfg_key, proposed_cmd, persist: :project)
231
- when "test_runner"
232
- persist_res = @config&.set("test.runners.#{lang}", proposed_cmd, persist: :project)
233
- if @adapter_registry
234
- adapter = @adapter_registry.get(lang)
235
- if adapter
236
- adapter.config ||= @config
237
- adapter.custom_test_cmd = proposed_cmd
238
- else
239
- require_relative "../../adapter/generic" unless defined?(Rubik::Adapter::GenericAdapter)
240
- new_adapter = Rubik::Adapter::GenericAdapter.new(lang, test_cmd: proposed_cmd, guard: @guard)
241
- new_adapter.config = @config
242
- @adapter_registry.register(lang, new_adapter, ctx: root_ctx)
231
+ if cfg_key && @config
232
+ promotion_ctx.on_dispose do
233
+ next if released
234
+
235
+ prev_cmd.nil? ? @config.delete(cfg_key) : @config.set(cfg_key, prev_cmd)
243
236
  end
237
+ @config.set(cfg_key, proposed_cmd)
244
238
  end
245
- root_ctx.provide("runner:#{lang}", proposed_cmd)
246
- when "linter"
247
- persist_res = @config&.set("linter.commands.#{lang}", proposed_cmd, persist: :project)
248
- if @adapter_registry
249
- adapter = @adapter_registry.get(lang)
250
- if adapter
251
- adapter.config ||= @config
252
- adapter.custom_format_cmd = proposed_cmd
253
- else
254
- require_relative "../../adapter/generic" unless defined?(Rubik::Adapter::GenericAdapter)
255
- new_adapter = Rubik::Adapter::GenericAdapter.new(lang, format_cmd: proposed_cmd, guard: @guard)
256
- new_adapter.config = @config
257
- @adapter_registry.register(lang, new_adapter, ctx: root_ctx)
239
+
240
+ case target
241
+ when "lsp"
242
+ raise "LSP manager and config are required for live activation" unless @lsp_manager && @config
243
+
244
+ had_client = !@lsp_manager.clients[lang].nil?
245
+ # The manager owns its client table. Restore the old configured
246
+ # client on rollback; probe clients never enter that table.
247
+ promotion_ctx.on_dispose do
248
+ next if released
249
+
250
+ @lsp_manager.stop_client(lang)
251
+ prev_cmd.nil? ? @config.delete(cfg_key) : @config.set(cfg_key, prev_cmd)
252
+ if had_client && !root_ctx.disposed?
253
+ restored = @lsp_manager.start_client(lang)
254
+ raise "Previous LSP client could not be restarted: #{restored}" unless restored[:ok]
255
+ end
256
+ end
257
+ @lsp_manager.stop_client(lang)
258
+ start_res = @lsp_manager.start_client(lang)
259
+ raise "Failed to start LSP server: #{start_res}" unless start_res.is_a?(Hash) && start_res[:ok]
260
+
261
+ service = @lsp_manager.clients[lang]
262
+ raise "LSP manager reported success without an active client" unless service
263
+ when "test_runner", "linter"
264
+ if @adapter_registry
265
+ adapter = @adapter_registry.get(lang)
266
+ if adapter
267
+ field = target == "test_runner" ? :custom_test_cmd : :custom_format_cmd
268
+ old_value = adapter.public_send(field)
269
+ old_config = adapter.config
270
+ promotion_ctx.on_dispose do
271
+ next if released
272
+
273
+ adapter.public_send("#{field}=", old_value)
274
+ adapter.config = old_config
275
+ end
276
+ adapter.config ||= @config
277
+ adapter.public_send("#{field}=", proposed_cmd)
278
+ else
279
+ require_relative "../../adapter/generic" unless defined?(Rubik::Adapter::GenericAdapter)
280
+ options = target == "test_runner" ? { test_cmd: proposed_cmd } : { format_cmd: proposed_cmd }
281
+ adapter = Rubik::Adapter::GenericAdapter.new(lang, **options, guard: @guard)
282
+ adapter.config = @config
283
+ promotion_ctx.on_dispose do
284
+ @adapter_registry.unregister(lang) if !released && @adapter_registry.get(lang).equal?(adapter)
285
+ end
286
+ @adapter_registry.register(lang, adapter)
287
+ end
258
288
  end
289
+ service = proposed_cmd
290
+ when "command"
291
+ service = proposed_cmd
259
292
  end
260
- root_ctx.provide("linter:#{lang}", proposed_cmd)
261
- when "command"
262
- root_ctx.provide("command:#{lang}", proposed_cmd)
293
+
294
+ root_ctx.provide(service_key, service)
295
+ phase = "Persistence"
296
+ persist_res = @config.set(cfg_key, proposed_cmd, persist: :project) if cfg_key && @config
297
+ raise persist_res[:error].to_s if persist_res.is_a?(Hash) && !persist_res[:ok]
298
+ rescue StandardError => e
299
+ promotion_ctx.dispose
300
+ rollback_errors = promotion_ctx.last_dispose_errors
301
+ detail = rollback_errors.empty? ? "previous session state restored" : "rollback incomplete: #{rollback_errors}"
302
+ return ToolOutput.error("#{phase} failed for #{target} (#{lang}): #{e.message}; #{detail}")
303
+ end
304
+
305
+ # Keep one lifetime inverse per repaired key, preserving the original
306
+ # baseline across repeated repairs without retaining every transaction.
307
+ scope_key = [root_ctx, service_key]
308
+ if @repair_scopes.key?(scope_key)
309
+ released = true
310
+ promotion_ctx.dispose
311
+ else
312
+ @repair_scopes[scope_key] = promotion_ctx
263
313
  end
264
314
 
265
315
  if @config.nil? && ["lsp", "test_runner", "linter"].include?(target)
@@ -267,71 +317,14 @@ module Rubik
267
317
  "so the command is active for this session only and was not saved."
268
318
  end
269
319
 
270
- if persist_res.is_a?(Hash) && !persist_res[:ok]
271
- return "⚠️ Promoted '#{proposed_cmd}' for #{target} (#{lang}) — active in this session, " \
272
- "but saving to the project config failed: #{persist_res[:error]}"
273
- end
274
-
275
320
  "✨ Successfully verified and promoted runtime service for #{target} (#{lang}): '#{proposed_cmd}'. " \
276
- "Service is now active and hot-swapped in the live context with zero downtime."
321
+ "Service is now active in the live context."
277
322
  end
278
323
 
279
324
  private
280
325
 
281
- def verify_binary(target, lang, proposed_cmd, sub_ctx)
282
- case target
283
- when "lsp"
284
- client = Rubik::LSP::Client.new(lang, root_dir: @root_dir, server_cmd: proposed_cmd)
285
- if client.available?
286
- sub_ctx.provide("lsp_probe:#{lang}", client)
287
- [true, nil]
288
- else
289
- [false, Rubik::Runtime::DiagnosticEngine.diagnose(
290
- cmd: proposed_cmd,
291
- exit_code: 127,
292
- stderr: "Pre-flight check failed: LSP binary '#{client.binary_name}' not available on system"
293
- )]
294
- end
295
- when "test_runner"
296
- if Rubik::Runtime::Discovery.binary_available?(proposed_cmd)
297
- sub_ctx.provide("runner_probe:#{lang}", proposed_cmd)
298
- [true, nil]
299
- else
300
- [false, Rubik::Runtime::DiagnosticEngine.diagnose(
301
- cmd: proposed_cmd,
302
- exit_code: 127,
303
- stderr: "Pre-flight check failed: Test runner executable not found in PATH"
304
- )]
305
- end
306
- when "linter"
307
- if Rubik::Runtime::Discovery.binary_available?(proposed_cmd)
308
- sub_ctx.provide("linter_probe:#{lang}", proposed_cmd)
309
- [true, nil]
310
- else
311
- [false, Rubik::Runtime::DiagnosticEngine.diagnose(
312
- cmd: proposed_cmd,
313
- exit_code: 127,
314
- stderr: "Pre-flight check failed: Linter/formatter executable not found in PATH"
315
- )]
316
- end
317
- when "command"
318
- if Rubik::Runtime::Discovery.binary_available?(proposed_cmd)
319
- sub_ctx.provide("command_probe:#{lang}", proposed_cmd)
320
- [true, nil]
321
- else
322
- [false, Rubik::Runtime::DiagnosticEngine.diagnose(
323
- cmd: proposed_cmd,
324
- exit_code: 127,
325
- stderr: "Pre-flight check failed: Command executable not found in PATH"
326
- )]
327
- end
328
- else
329
- [false, Rubik::Runtime::DiagnosticEngine.diagnose(
330
- cmd: proposed_cmd,
331
- exit_code: 1,
332
- stderr: "Unknown target component type: #{target}"
333
- )]
334
- end
326
+ def verify_binary(target, lang, proposed_cmd, sub_ctx, probe_args: nil)
327
+ Rubik::Runtime::RepairProbe.new(root_dir: @root_dir).verify(target, lang, proposed_cmd, sub_ctx, probe_args: probe_args)
335
328
  end
336
329
  end
337
330