rubik 0.4.1 → 0.4.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15cadac070995d7b8d1ac155c6ddfe14c4e4418e596efe6b734af6cb9240f6b4
4
- data.tar.gz: 21b3376be313a5133ca84180a36770b0c344495b12ed3c00390715d8dd1658eb
3
+ metadata.gz: 8907beb9d8441f6f497af9bd1b65a998c5dfa1d2857552943809ace7270e3759
4
+ data.tar.gz: a4b938aa55ff08037d63fc00d545a5039c06319c2ecee309e85d369d5d752621
5
5
  SHA512:
6
- metadata.gz: d9edcdfa2830b03dc8d9a5345d8215a5204ba96d4aa5ef93ca0742633a02ff089f2dda9fab638d4a42a489f1b166f74c9a11ecc5f64b3f602b7cb1cbbdd09a5a
7
- data.tar.gz: 2b478a0aa15376eaac5636c9d6e7f8d5dfac5534394153958d6417ecf3547dbb9ae654d7c59e5d92df6a84e2038d869145a3ba2b7c8dbdc62472106c7727c33d
6
+ metadata.gz: a034a02d012190d0f6fe02c1b7977628d60617fb0bca562c3b4799dd6512e5cb77809540d57ebe4eede099a0c8e724a679579cc75f4db85f9a8e59bd535d59ea
7
+ data.tar.gz: bc8162afcace2ab99fca16b749a5291abeac662e2255f5d798277931a7295a447bdc981bb5d9bc897afa80a15db8fe836771536c3441c110208546442b280622
data/CHANGELOG.md CHANGED
@@ -6,6 +6,75 @@ commits, 2026-08-23 → 2026-10-09, see `git log`).
6
6
 
7
7
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
8
8
 
9
+ ## [0.4.2] - 2026-10-29
10
+
11
+ A reliability patch: plugin file loads become transactional with
12
+ timing-safe integrity checks, MCP transport and handshake lifecycles
13
+ shed their remaining deadlocks and wedged-boot races, buffer apply
14
+ failures report faithfully, the repair loop can no longer return
15
+ unverified code as success, and vakanüvis slugs stop colliding across
16
+ scripts and long topics.
17
+
18
+ ### Fixed
19
+
20
+ - **MCP transport lifecycle deadlocks closed (#308):** server stderr is
21
+ drained on a dedicated thread with a bounded diagnostic tail (also
22
+ attached to `TimeoutError` messages), a dead transport incarnation is
23
+ reaped on re-open instead of leaking its file descriptors and waiter,
24
+ and a fast async boot can no longer park a dead thread handle that
25
+ permanently wedged the sync-boot gate after `stop_all`.
26
+ - **AST persistent cache no longer pins stale symbols (#308):** a failed
27
+ re-index is excluded from the cache snapshot so its protected stale
28
+ symbols are never validated under the current file's fresh digest;
29
+ per-entry extraction status is persisted and validated on load
30
+ (cache schema version 2, backward compatible with existing caches).
31
+ - **Plugin file loads are transactional (#334):** `load_plugin_file`
32
+ prepares the replacement before touching the running plugin so a
33
+ failed re-add no longer bricks the live plugin; reloading a previously
34
+ `:active` plugin that lands in `:pending` rolls back to the last
35
+ working version; the manifest TOCTOU window is closed by evaluating
36
+ the in-memory verified content via `TOPLEVEL_BINDING.eval`; manifest
37
+ SHA-256 checks use timing-safe `secure_compare`; and directory
38
+ creation failures are always reported with `Rubik::Log.warn`.
39
+ - **MCP handshake and boot races closed (#336):** the manager's
40
+ handshake lock is split so a slow connect no longer serializes
41
+ unrelated state reads; async boots carry epoch-tagged guards that
42
+ discard a superseded connection instead of adopting it; the stdio
43
+ transport gains a synchronized reaper and a bounded `stderr_tail`
44
+ diagnostic; and rollback paths log the client they stopped.
45
+ - **Repair loop no longer reports unverified code as success (#335):**
46
+ a missing diagnostics adapter, an empty LLM proposal, or an exception
47
+ mid-repair now undoes the buffer snapshot and returns `ok: false`
48
+ (flagging `unverified:` when syntax could not be checked) instead of
49
+ writing broken code; LSP rollback only restores clients that are
50
+ alive and running.
51
+ - **Buffer apply failures report faithfully (#333):** `apply!` commits
52
+ `last_applied` and `last_conflicts` in an `ensure` block so files
53
+ already written to disk before a failure are remembered and
54
+ `clean_buffer_message` no longer hides them; garbage-skipped edits
55
+ are tracked in `last_skipped`/`ApplyFailedError.skipped` and surfaced
56
+ in `ApplyContract.failure_lines` and tool outputs; and conflict event
57
+ payloads are frozen copies, closing the live-array aliasing mutation.
58
+ - **Vakanüvis slugs no longer collide across scripts and topics
59
+ (#332):** slug generation Unicode-folds and transliterates Turkish
60
+ characters (ç, ğ, ı, ö, ş, ü) and appends a deterministic 6-character
61
+ SHA-256 suffix when normalization is lossy, when the topic exceeds the
62
+ 40-char limit, or when punctuation-only input yields the untitled
63
+ fallback — distinct topics can no longer resolve to the same storage
64
+ key.
65
+ - **UI capability detection unwraps capture streams (#331):**
66
+ `Capability.resolve_mode`, `color?`, and `spinner?` unwrap `TeeIO`
67
+ targets so a live terminal wrapped in a capture stream renders styled
68
+ dialogs and notices again; non-empty truthy `CLICOLOR_FORCE` values
69
+ (e.g. `true`, `yes`) now force ANSI color while `NO_COLOR` keeps
70
+ precedence.
71
+
72
+ ### Performance
73
+
74
+ - **Prism `CallNode` filter lists hoisted to frozen Sets (#307):** the
75
+ Ruby extractor's hot walk no longer allocates two temporary Arrays per
76
+ call node (~140k fewer heap objects per 10k file extractions).
77
+
9
78
  ## [0.4.1] - 2026-10-29
10
79
 
11
80
  A hardening patch: CodeQL-driven fixes remove ReDoS-prone regular
@@ -892,6 +961,7 @@ installation section.
892
961
  - Context: serialized index mutations, snapshot-outside-lock reconcile,
893
962
  streaming-first truncation.
894
963
 
895
- [Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.4.1...HEAD
964
+ [Unreleased]: https://github.com/ozgurulukir/rubik/compare/v0.4.2...HEAD
965
+ [0.4.2]: https://github.com/ozgurulukir/rubik/compare/v0.4.1...v0.4.2
896
966
  [0.4.1]: https://github.com/ozgurulukir/rubik/compare/v0.4.0...v0.4.1
897
967
  [0.1.0]: https://github.com/ozgurulukir/rubik/releases/tag/v0.1.0
@@ -4,6 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require "digest"
6
6
  require_relative "symbol"
7
+ require_relative "extraction_result"
7
8
  require_relative "../platform"
8
9
 
9
10
  module Rubik
@@ -44,9 +45,11 @@ module Rubik
44
45
  File.join(@home_dir, ".rubik", "cache", project_hash, "ast_index.json")
45
46
  end
46
47
 
47
- def save(file_symbols_hash)
48
+ def save(file_symbols_hash, statuses: {})
48
49
  return false unless file_symbols_hash.is_a?(Hash)
49
50
 
51
+ statuses = {} unless statuses.is_a?(Hash)
52
+
50
53
  data = {
51
54
  "version" => SCHEMA_VERSION,
52
55
  "last_scan" => Time.now.to_i,
@@ -84,12 +87,17 @@ module Rubik
84
87
 
85
88
  # Bodies are intentionally NOT persisted: they dominate the cache
86
89
  # size and are re-extracted lazily from source via Symbol#body.
90
+ # `status` carries the issue-#147 extraction status so the
91
+ # loaded graph can keep telling "parsed clean" from "partial" —
92
+ # entries predating the field read as :ok, the only status they
93
+ # could ever have been saved under.
87
94
  data["files"][rel_path] = {
88
95
  "language" => lang,
89
96
  "mtime" => stat.mtime.to_i,
90
97
  "mtime_f" => stat.mtime.to_f,
91
98
  "size" => stat.size,
92
99
  "digest" => digest,
100
+ "status" => (statuses[file_path] || :ok).to_s,
93
101
  "symbols" => syms.map { |sym| sym.to_h(include_body: false) }
94
102
  }
95
103
  end
@@ -103,14 +111,22 @@ module Rubik
103
111
  end
104
112
 
105
113
  def load
114
+ load_with_statuses[0]
115
+ end
116
+
117
+ # Same entries as #load, paired with the persisted per-file extraction
118
+ # status keyed by the same expanded paths (issue #147 across the cache
119
+ # boundary: a cache-loaded file must not report "never attempted").
120
+ def load_with_statuses
106
121
  target_path = cache_path
107
- return {} unless File.exist?(target_path)
122
+ return [{}, {}] unless File.exist?(target_path)
108
123
 
109
124
  content = File.read(target_path)
110
125
  data = JSON.parse(content)
111
- return {} unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION && data["files"].is_a?(Hash)
126
+ return [{}, {}] unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION && data["files"].is_a?(Hash)
112
127
 
113
128
  result = {}
129
+ statuses = {}
114
130
  data["files"].each do |rel_path, cached_info|
115
131
  full_path = File.expand_path(rel_path, @root_dir)
116
132
  next unless valid_entry?(full_path, cached_info)
@@ -132,14 +148,29 @@ module Rubik
132
148
  )
133
149
  end
134
150
  result[full_path] = symbols
151
+ raw_status = cached_info["status"]
152
+ # Entries predating the field had exactly one possible status;
153
+ # anything else unreadable is corrupt data, not a status — warn
154
+ # and fall back instead of materializing an arbitrary symbol that
155
+ # the symbols tool would report and save_to_cache would honor.
156
+ statuses[full_path] = if raw_status.nil?
157
+ :ok
158
+ else
159
+ status = raw_status.is_a?(String) ? raw_status.to_sym : nil
160
+ unless ExtractionResult::STATUSES.include?(status)
161
+ Rubik::Log.warn(:ast, "unknown cached extraction status #{raw_status.inspect} for #{rel_path}; treating as ok")
162
+ status = :ok
163
+ end
164
+ status
165
+ end
135
166
  end
136
167
 
137
- result
168
+ [result, statuses]
138
169
  rescue JSON::ParserError, Errno::ENOENT, Errno::EACCES, TypeError, NoMethodError
139
170
  # Corrupt-but-valid JSON shapes raise TypeError/NoMethodError inside
140
171
  # the entry loop; EACCES covers an unreadable cache file. All mean
141
172
  # "cache unusable" → rebuild from scratch.
142
- {}
173
+ [{}, {}]
143
174
  end
144
175
 
145
176
  def valid_entry?(file_path, cached_info)
@@ -33,5 +33,11 @@ module Rubik
33
33
  [:ok, :partial].include?(status)
34
34
  end
35
35
  end
36
+
37
+ class ExtractionResult
38
+ # The closed status vocabulary, shared with persistence seams: the AST
39
+ # cache persists it per entry and validates against it on load.
40
+ STATUSES = [:ok, :partial, :failed, :unavailable].freeze
41
+ end
36
42
  end
37
43
  end
@@ -89,7 +89,7 @@ module Rubik
89
89
  def load_from_cache(cache_store)
90
90
  return 0 unless cache_store
91
91
 
92
- loaded_files = cache_store.load
92
+ loaded_files, statuses = cache_store.load_with_statuses
93
93
  return 0 if loaded_files.nil? || loaded_files.empty?
94
94
 
95
95
  @mutex.synchronize do
@@ -99,6 +99,7 @@ module Rubik
99
99
  lang = CacheStore::LANGUAGE_MAP[ext] || "code"
100
100
 
101
101
  @file_symbols[file_path] = syms.dup
102
+ @extraction_status[file_path] = statuses[file_path] || :ok
102
103
  syms.each do |sym|
103
104
  @symbols[sym.name] = sym
104
105
  q_key = "#{lang}:#{file_path}##{sym.name}"
@@ -113,12 +114,32 @@ module Rubik
113
114
  def save_to_cache(cache_store)
114
115
  return false unless cache_store
115
116
 
116
- snapshot = @mutex.synchronize do
117
- @file_symbols.each_with_object({}) do |(file_path, symbols), copy|
117
+ snapshot = nil
118
+ statuses = nil
119
+ skipped_failed = 0
120
+ @mutex.synchronize do
121
+ snapshot = @file_symbols.each_with_object({}) do |(file_path, symbols), copy|
122
+ # Failed-extraction honesty across the cache boundary: a :failed
123
+ # file's in-memory symbols are protected stale data or
124
+ # untrustworthy best-effort fallback. Persisting them would let
125
+ # CacheStore re-stat the CURRENT file and pin them under a fresh
126
+ # digest, making the staleness permanent — skip them and let the
127
+ # next boot re-extract.
128
+ if _get_extraction_status_unlocked(file_path) == :failed
129
+ skipped_failed += 1
130
+ next
131
+ end
132
+
118
133
  copy[file_path] = symbols.dup
119
134
  end
135
+ statuses = {}
136
+ snapshot.each_key do |file_path|
137
+ statuses[file_path] = _get_extraction_status_unlocked(file_path) || :ok
138
+ end
120
139
  end
121
- cache_store.save(snapshot)
140
+ skipped = "cache snapshot excluded #{skipped_failed} file(s) with failed extractions; they re-extract on next boot"
141
+ Rubik::Log.warn(:ast, skipped) if skipped_failed.positive?
142
+ cache_store.save(snapshot, statuses: statuses)
122
143
  end
123
144
 
124
145
  def smart_context_for(symbol_name)
@@ -179,7 +200,6 @@ module Rubik
179
200
  Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); keeping previously indexed symbols")
180
201
  return existing
181
202
  end
182
- Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); no symbols indexed")
183
203
  end
184
204
 
185
205
  _remove_file_unlocked(file_path)
@@ -188,6 +208,12 @@ module Rubik
188
208
  lang = CacheStore::LANGUAGE_MAP[ext] || "code"
189
209
 
190
210
  extracted = result.symbols
211
+ if result.status == :failed
212
+ # Truthful accounting: the fallback symbols ARE indexed when
213
+ # nothing pre-existing needed protection — even when there are
214
+ # none, the log must not claim otherwise.
215
+ Rubik::Log.warn(:ast, "extraction failed for #{file_path} (#{result.detail}); indexing #{extracted.length} best-effort fallback symbols")
216
+ end
191
217
  extracted.each do |sym|
192
218
  @symbols[sym.name] = sym
193
219
  q_key = "#{lang}:#{file_path}##{sym.name}"
@@ -8,6 +8,10 @@ module Rubik
8
8
  module AST
9
9
  class RubyExtractor
10
10
  EXCLUDED_CALLS = Set.new(["def", "if", "unless", "case", "return", "raise", "puts", "p", "print", "require"]).freeze
11
+ # PERF: Hoist require and excluded call lists to frozen Sets to avoid per-node Array allocations in hot AST walks.
12
+ REQUIRE_CALLS = Set.new(["require", "require_relative"]).freeze
13
+ EXCLUDED_PRISM_CALLS = Set.new(["require", "require_relative", "raise", "puts", "p", "print"]).freeze
14
+
11
15
  begin
12
16
  require "prism"
13
17
  PRISM_AVAILABLE = true
@@ -119,7 +123,8 @@ module Rubik
119
123
 
120
124
  when Prism::CallNode
121
125
  name = node.name.to_s
122
- if ["require", "require_relative"].include?(name)
126
+ # PERF: Fast Set lookup avoids Array allocation on every CallNode
127
+ if REQUIRE_CALLS.include?(name)
123
128
  line_num = node.location.start_line
124
129
  args_node = node.arguments&.arguments
125
130
  arg = args_node&.first&.slice.to_s
@@ -153,7 +158,8 @@ module Rubik
153
158
  operator = curr.call_operator || "."
154
159
  cname = "#{receiver}#{operator}#{cname}"
155
160
  end
156
- calls << cname unless ["require", "require_relative", "raise", "puts", "p", "print"].include?(cname)
161
+ # PERF: Fast Set lookup avoids Array allocation per call site in method body
162
+ calls << cname unless EXCLUDED_PRISM_CALLS.include?(cname)
157
163
  end
158
164
  queue.concat(curr.compact_child_nodes) if curr.respond_to?(:compact_child_nodes)
159
165
  end
@@ -38,6 +38,12 @@ module Rubik
38
38
  lines << " • Failed: #{failure.failed}#{cause ? " (#{cause.class}: #{cause.message})" : " (#{failure.kind})"}"
39
39
  lines << " • Still dirty — a retry writes only these: #{failure.pending.join(', ')}" unless failure.pending.empty?
40
40
  lines << " • Skipped conflicts (disk changed externally): #{failure.conflicts.join(', ')}" unless failure.conflicts.empty?
41
+ if failure.respond_to?(:skipped) && !failure.skipped.empty?
42
+ skipped_desc = failure.skipped.map do |s|
43
+ s.is_a?(Hash) ? "#{s[:path]} (#{s[:reason]})" : s.to_s
44
+ end.join(", ")
45
+ lines << " • Skipped invalid paths (anti-injection/garbage): #{skipped_desc}"
46
+ end
41
47
  if sync
42
48
  annotation = sync_annotation(failure, sync)
43
49
  lines << annotation if annotation
@@ -21,20 +21,22 @@ module Rubik
21
21
  # the underlying error. `pending` lists this run's entries that are
22
22
  # still dirty (a retry writes only those).
23
23
  class ApplyFailedError < StandardError
24
- attr_reader :kind, :applied, :failed, :pending, :conflicts
24
+ attr_reader :kind, :applied, :failed, :pending, :conflicts, :skipped
25
25
 
26
- def initialize(message, kind:, applied: [], failed: nil, pending: [], conflicts: [])
26
+ def initialize(message, kind:, applied: [], failed: nil, pending: [], conflicts: [], skipped: [])
27
27
  super(message)
28
28
  @kind = kind
29
29
  @applied = applied
30
30
  @failed = failed
31
31
  @pending = pending
32
32
  @conflicts = conflicts
33
+ @skipped = skipped
33
34
  end
34
35
  end
35
36
 
36
37
  class Manager
37
- attr_reader :root_dir, :dirty_files, :additional_roots, :rw_roots, :allowed_external_dirs, :rw_allowed_dirs, :guard, :last_conflicts
38
+ attr_reader :root_dir, :dirty_files, :additional_roots, :rw_roots, :allowed_external_dirs, :rw_allowed_dirs,
39
+ :guard, :last_conflicts, :last_skipped
38
40
  attr_accessor :allow_external_writes, :config
39
41
 
40
42
  def initialize(root_dir: Dir.pwd, additional_roots: [], allowed_external_dirs: [], allow_external_writes: false,
@@ -52,7 +54,8 @@ module Rubik
52
54
  @buffer = {}
53
55
  @dirty_files = {}
54
56
  @original_hashes = {}
55
- @last_conflicts = []
57
+ @last_conflicts = [].freeze
58
+ @last_skipped = [].freeze
56
59
  @last_applied = nil
57
60
  end
58
61
 
@@ -317,25 +320,35 @@ module Rubik
317
320
  @monitor.synchronize do
318
321
  applied_paths = []
319
322
  conflicts = []
323
+ skipped = []
320
324
  keys_to_apply = determine_keys_to_apply(target_paths)
321
325
  effective_policy = (policy || external_modification_policy).to_s.downcase
322
326
 
323
- keys_to_apply.each_with_index do |rel_path, index|
324
- next unless @dirty_files.key?(rel_path)
325
- next unless validate_and_clean_garbage(rel_path)
327
+ begin
328
+ keys_to_apply.each_with_index do |rel_path, index|
329
+ next unless @dirty_files.key?(rel_path)
326
330
 
327
- if external_conflict?(rel_path) && effective_policy != "overwrite"
328
- conflicts << rel_path
329
- @event_bus&.emit(:buffer_apply_conflict, paths: conflicts, path: rel_path)
330
- next
331
- end
331
+ unless validate_and_clean_garbage(rel_path)
332
+ skipped << { path: rel_path, reason: :path_validator_rejected }
333
+ next
334
+ end
332
335
 
333
- apply_single_entry(rel_path, index: index, keys_to_apply: keys_to_apply, applied_paths: applied_paths, conflicts: conflicts)
336
+ if external_conflict?(rel_path) && effective_policy != "overwrite"
337
+ conflicts << rel_path
338
+ @event_bus&.emit(:buffer_apply_conflict, paths: conflicts.dup.freeze, path: rel_path)
339
+ next
340
+ end
341
+
342
+ apply_single_entry(rel_path, index: index, keys_to_apply: keys_to_apply,
343
+ applied_paths: applied_paths, conflicts: conflicts, skipped: skipped)
344
+ end
345
+ ensure
346
+ @last_conflicts = conflicts.dup.freeze
347
+ @last_skipped = skipped.dup.freeze
348
+ @last_applied = { at: Time.now.utc, paths: applied_paths.dup.freeze } unless applied_paths.empty?
334
349
  end
335
350
 
336
- @last_conflicts = conflicts
337
- @last_applied = { at: Time.now.utc, paths: applied_paths.dup.freeze } unless applied_paths.empty?
338
- @event_bus&.emit(:buffer_applied, paths: applied_paths) unless applied_paths.empty?
351
+ @event_bus&.emit(:buffer_applied, paths: applied_paths.dup.freeze) unless applied_paths.empty?
339
352
  applied_paths
340
353
  end
341
354
  end
@@ -353,7 +366,8 @@ module Rubik
353
366
  @buffer.clear
354
367
  @dirty_files.clear
355
368
  @original_hashes.clear
356
- @last_conflicts.clear
369
+ @last_conflicts = [].freeze
370
+ @last_skipped = [].freeze
357
371
  @last_applied = nil
358
372
  end
359
373
  end
@@ -410,7 +424,7 @@ module Rubik
410
424
  end
411
425
  end
412
426
 
413
- def apply_single_entry(rel_path, index:, keys_to_apply:, applied_paths:, conflicts:)
427
+ def apply_single_entry(rel_path, index:, keys_to_apply:, applied_paths:, conflicts:, skipped:)
414
428
  full_path = File.expand_path(rel_path, @root_dir)
415
429
 
416
430
  # TOCTOU close: re-verify containment immediately before the
@@ -422,15 +436,11 @@ module Rubik
422
436
  containment_kind, = containment_root(full_path)
423
437
  unless containment_kind
424
438
  @event_bus&.emit(:buffer_containment_blocked, path: rel_path)
425
- # Commit what this run has seen so "apply! rewrites
426
- # last_conflicts on every run" holds for readers (AutoApply's
427
- # report_conflicts) even on the failure path.
428
- @last_conflicts = conflicts
429
439
  raise apply_failure(
430
440
  "Write blocked: '#{rel_path}' resolves outside all allowed roots at apply time " \
431
441
  "(possible symlink swap since buffering). Re-verify the workspace and retry.",
432
442
  kind: :containment_blocked, applied: applied_paths, failed: rel_path,
433
- pending: pending_after(keys_to_apply, index), conflicts: conflicts
443
+ pending: pending_after(keys_to_apply, index), conflicts: conflicts, skipped: skipped
434
444
  )
435
445
  end
436
446
 
@@ -439,13 +449,12 @@ module Rubik
439
449
  begin
440
450
  Rubik::Platform.atomic_write(full_path, content)
441
451
  rescue StandardError => e
442
- @last_conflicts = conflicts
443
452
  raise apply_failure(
444
453
  "Apply failed for '#{rel_path}': #{e.class}: #{e.message}; " \
445
454
  "#{applied_paths.empty? ? 'no file was written' : "already written: #{applied_paths.join(', ')}"}; " \
446
455
  "#{pending_after(keys_to_apply, index).size} file(s) still dirty",
447
456
  kind: :write_failed, applied: applied_paths, failed: rel_path,
448
- pending: pending_after(keys_to_apply, index), conflicts: conflicts
457
+ pending: pending_after(keys_to_apply, index), conflicts: conflicts, skipped: skipped
449
458
  ), cause: e
450
459
  end
451
460
 
@@ -494,11 +503,12 @@ module Rubik
494
503
 
495
504
  # Build the structured partial-apply error with frozen report copies —
496
505
  # a reader must see the state at raise time, not later mutations.
497
- def apply_failure(message, kind:, applied:, failed:, pending:, conflicts:)
506
+ def apply_failure(message, kind:, applied:, failed:, pending:, conflicts:, skipped: [])
498
507
  ApplyFailedError.new(
499
508
  message, kind: kind,
500
509
  applied: applied.dup.freeze, failed: failed,
501
- pending: pending.dup.freeze, conflicts: conflicts.dup.freeze
510
+ pending: pending.dup.freeze, conflicts: conflicts.dup.freeze,
511
+ skipped: skipped.dup.freeze
502
512
  )
503
513
  end
504
514
 
@@ -147,6 +147,10 @@ module Rubik
147
147
  @transport&.running? || false
148
148
  end
149
149
 
150
+ def stderr_tail
151
+ @transport.respond_to?(:stderr_tail) ? @transport.stderr_tail : []
152
+ end
153
+
150
154
  private
151
155
 
152
156
  def ensure_running!
@@ -51,6 +51,7 @@ module Rubik
51
51
  # boot epoch that owns them (@boot_marks).
52
52
  @stopped = false
53
53
  @boot_thread = nil
54
+ @boot_slot = nil
54
55
  @boot_marks = {}
55
56
  @monitor = Monitor.new
56
57
  end
@@ -68,18 +69,13 @@ module Rubik
68
69
  name_str = name.to_s
69
70
  parsed_defn = defn.is_a?(Hash) ? defn.transform_keys(&:to_s) : {}
70
71
  persist_res = { ok: true }
71
- tools_count = 0
72
72
 
73
73
  @monitor.synchronize do
74
74
  @servers[name_str] = parsed_defn
75
75
  @statuses[name_str] ||= :disabled
76
76
 
77
77
  if @config && persist && @config.persisting?(persist)
78
- server_hash = {
79
- "command" => parsed_defn["command"],
80
- "args" => Array(parsed_defn["args"]),
81
- "env" => parsed_defn["env"] || {}
82
- }
78
+ server_hash = { "command" => parsed_defn["command"], "args" => Array(parsed_defn["args"]), "env" => parsed_defn["env"] || {} }
83
79
  # Per-server handshake override round-trips only when present,
84
80
  # so `!mcp add` output stays minimal (the knob is hand-written
85
81
  # config, not a CLI flag).
@@ -87,14 +83,12 @@ module Rubik
87
83
  persist_res = @config.set("mcp.servers.#{name_str}", server_hash, persist: persist)
88
84
  end
89
85
 
90
- if auto_start && enabled?
91
- enable_server(name_str)
92
- else
93
- transition_server!(name_str, :disabled)
94
- end
95
- tools_count = (@tools[name_str] || []).size
86
+ transition_server!(name_str, :disabled) unless auto_start && enabled?
96
87
  end
97
88
 
89
+ enable_server(name_str) if auto_start && enabled?
90
+
91
+ tools_count = @monitor.synchronize { (@tools[name_str] || []).size }
98
92
  persist_res.merge(tools: tools_count)
99
93
  end
100
94
 
@@ -219,30 +213,42 @@ module Rubik
219
213
  # Post-wiring failures stopped the client via the ctx drain above;
220
214
  # pre-wiring failures (stale-context dispose, ctx.extend) reach
221
215
  # only this line — idempotent Client#stop makes it exactly-once.
222
- begin
223
- client.stop
224
- rescue StandardError
225
- nil
226
- end
216
+ safely_stop_rolled_back_client(client, name_str)
227
217
  raise e
228
218
  end
229
219
  end
230
220
 
231
221
  def enable_server(name)
232
222
  name_str = name.to_s
223
+ server_defn = nil
224
+ epoch = nil
233
225
  @monitor.synchronize do
234
226
  server_defn = @servers[name_str]
235
227
  raise Rubik::MCP::Error, "MCP server '#{name_str}' not found" unless server_defn
236
228
 
237
229
  return @clients[name_str] if @statuses[name_str] == :active && @clients[name_str]&.running?
238
230
 
239
- connected = begin
240
- connect_server(name_str, server_defn)
241
- rescue StandardError
242
- transition_server!(name_str, :failed)
243
- raise
231
+ epoch = Object.new
232
+ transition_server!(name_str, :connecting)
233
+ @boot_marks[name_str] = epoch
234
+ end
235
+
236
+ connected = begin
237
+ connect_server(name_str, server_defn)
238
+ rescue StandardError => e
239
+ @monitor.synchronize { transition_server!(name_str, :failed, epoch: epoch) }
240
+ raise e
241
+ end
242
+
243
+ @monitor.synchronize do
244
+ if @stopped || @statuses[name_str] != :connecting || !@boot_marks[name_str].equal?(epoch)
245
+ safely_stop_rolled_back_client(connected[:client], name_str)
246
+ return @clients[name_str]
244
247
  end
248
+
245
249
  apply_connected_server(name_str, server_defn, **connected)
250
+ transition_server!(name_str, :active, epoch: epoch)
251
+ @clients[name_str]
246
252
  end
247
253
  end
248
254
 
@@ -301,18 +307,29 @@ module Rubik
301
307
  end
302
308
 
303
309
  join_boot_safely(2.0)
310
+ epoch = Object.new
304
311
  @monitor.synchronize { @stopped = false }
305
312
  thread = Thread.new do
306
313
  perform_load_from_config(cfg)
307
314
  rescue StandardError, Interrupt => e
308
315
  Rubik::Log.warn(:mcp, "background MCP boot failed (#{e.class}: #{e.message})")
309
316
  ensure
310
- # Only clear OUR reference: a wedged boot A expiring past the drain
311
- # bound must not clobber the handle to a newer boot B's thread.
312
- @monitor.synchronize { @boot_thread = nil if @boot_thread.equal?(Thread.current) }
317
+ # Epoch-tagged clear: only clear OUR reference so a wedged boot A
318
+ # expiring past the drain bound cannot clobber a newer boot B's slot.
319
+ @monitor.synchronize do
320
+ @boot_slot = @boot_thread = nil if @boot_slot && @boot_slot[:epoch].equal?(epoch)
321
+ end
313
322
  end
314
323
  thread.name = "rubik-mcp-boot" if thread.respond_to?(:name=)
315
- @boot_thread = thread
324
+ @monitor.synchronize do
325
+ if thread.alive?
326
+ @boot_slot = { thread: thread, epoch: epoch }
327
+ @boot_thread = thread
328
+ elsif @boot_slot && @boot_slot[:epoch].equal?(epoch)
329
+ @boot_slot = @boot_thread = nil
330
+ end
331
+ end
332
+ thread
316
333
  end
317
334
 
318
335
  private
@@ -509,7 +526,6 @@ module Rubik
509
526
  disable_server(name)
510
527
  rescue StandardError => e
511
528
  Rubik::Log.warn(:mcp, "failed to stop MCP server '#{name}' during teardown (#{e.class}: #{e.message})")
512
- nil
513
529
  end
514
530
  end
515
531
  end
@@ -517,11 +533,7 @@ module Rubik
517
533
  alias dispose stop_all
518
534
 
519
535
  def status_summary
520
- @monitor.synchronize do
521
- @servers.map do |name, _defn|
522
- server_status(name)
523
- end.compact
524
- end
536
+ @monitor.synchronize { @servers.keys.map { |name| server_status(name) }.compact }
525
537
  end
526
538
 
527
539
  def server_status(name)
@@ -602,9 +614,7 @@ module Rubik
602
614
  # falls through to the next source — a miswritten knob must never fail
603
615
  # the boot at the config boundary.
604
616
  def resolve_connect_timeout(server_defn, explicit)
605
- sources = [explicit, server_defn["timeout"] || server_defn[:timeout]]
606
- cfg_timeout = @config&.fetch("mcp.timeout", nil)
607
- sources << cfg_timeout unless cfg_timeout.nil?
617
+ sources = [explicit, server_defn["timeout"] || server_defn[:timeout], @config&.fetch("mcp.timeout", nil)]
608
618
 
609
619
  sources.each do |candidate|
610
620
  return candidate if candidate.is_a?(Numeric) && candidate.positive?
@@ -614,6 +624,12 @@ module Rubik
614
624
  end
615
625
  Client::DEFAULT_TIMEOUT
616
626
  end
627
+
628
+ def safely_stop_rolled_back_client(client, name)
629
+ client&.stop
630
+ rescue StandardError => e
631
+ Rubik::Log.warn(:mcp, "failed to stop rolled-back MCP client '#{name}' (#{e.class}: #{e.message})")
632
+ end
617
633
  end
618
634
  end
619
635
  end