coatepec 0.5.0 → 0.5.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: 7cbcec579a7904ef50c8d8227036bbb0dd45f05bd664ffa2fceb2d210f5171be
4
- data.tar.gz: 672092f99f2bfab2de7ad832572e6f16b5aeea8613860f0456fc81f7abaf25f4
3
+ metadata.gz: be24375e5f36c63e0511dc268bacecd57a54a33a94f5d44540925927c49f6655
4
+ data.tar.gz: 0e80f68351e4e814e9e4486f7e02d8d3f70512edaa7e9806c6baa3091111e779
5
5
  SHA512:
6
- metadata.gz: f065ef0f83fbf5796d8e333326d9cf852a7322de12a12f77ece713df6cf00d29f167ea3ca2001830cb70e1e770a4533e3b111956390da1d0968c682cdeb8335a
7
- data.tar.gz: 62d2d70afe6f5554c4e40fd83572384161f1819b228e65baa5c3a9c048716632e47c126356810e0c5aeb1e2ca1e9a79ed30fe0d10e97e773d6f1b6527a373d38
6
+ metadata.gz: 297b7eb82d12ece3991893ed1052650648d37fb86b438356257e67e90f69260297b5c3f4b390a2ce25860e2e06a8487dc1597bb39219c3f13ac550901cc92e94
7
+ data.tar.gz: dbe1f843c872f42c20d7df9ee4226af83200b9c9457bb2a9b3aba185d22b7c5c8878fa3237a57dc4dfb4526c499e4c75cb1cb5c3135262aa24dd71fc4ea8805b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.2
4
+
5
+ - Fix `WorkerManager` getting permanently stuck treating a dead worker as
6
+ alive: `Worker::Client#alive?` used `Process.kill(0, pid)`, which returns
7
+ true for an unreaped zombie, and the one existing auto-restart-and-retry
8
+ path only caught `Worker::Client::DisconnectedError`, not a raw
9
+ `Errno::EPIPE`/`SystemCallError` from writing to a dead worker's pipe.
10
+ Every call after a worker died this way failed identically, forever --
11
+ since stdio MCP servers have no lighter reconnect in Claude Code, this
12
+ previously required a full session restart to recover from.
13
+ - Add `rails_runtime_restart`: unconditionally tears down and respawns the
14
+ test worker, for whatever the automatic detection above doesn't catch.
15
+ - Fix RSpec's own "(files took N seconds to load)" reporting growing
16
+ across every `rails_spec_run` call made through the same warm,
17
+ forked-per-run worker -- it was measuring time since the worker first
18
+ booted, not time this run's files took to load, because `rspec-core`
19
+ freezes that timestamp once per process and Coatepec never reset it on
20
+ reuse.
21
+
22
+ ## 0.5.1
23
+
24
+ - Fix `rails_model` crashing outright for a model with a `has_one`/
25
+ `has_many :through` association that goes through a polymorphic
26
+ `belongs_to` (e.g. `has_one :x, through: :notable, source: :y` where
27
+ `belongs_to :notable, polymorphic: true`). `foreign_key` on that
28
+ reflection needs a single fixed class to resolve, which a polymorphic
29
+ association can't provide, and that was previously an unrescued
30
+ `ArgumentError` that took down the whole response. That specific case now
31
+ reports `foreign_key: nil`/`class_name: nil` for the affected association,
32
+ the same way an already-handled plain polymorphic `belongs_to` does.
33
+ - Add a general safety net around each association's metadata: if a single
34
+ association still fails for some other, not-yet-anticipated
35
+ `ActiveRecord` reflection quirk, only that association's entry degrades
36
+ (gaining an `error` field describing what went wrong) instead of the
37
+ entire `rails_model` call crashing for the whole model.
38
+
3
39
  ## 0.5.0
4
40
 
5
41
  - **Breaking:** `mcp` is no longer a runtime dependency of the `coatepec`
data/README.md CHANGED
@@ -127,9 +127,35 @@ a no-op.
127
127
  |---|---|---|
128
128
  | `rails_spec_run` | `paths: string[1..100]`, `example?`, `seed?`, `fail_fast?`, `timeout_seconds?` (1..900, default 120) | Isolated per run; output capped at 256 KiB per stream |
129
129
  | `rails_runtime_status` | `{}` | Reports Ruby/Rails versions, worker PID, boot_id, lifecycle state |
130
+ | `rails_runtime_restart` | `{}` | Unconditionally respawns the worker, discarding its warm boot |
130
131
  | `rails_routes` | `query?`, `limit?` (1..200, default 50), `offset?` | Case-insensitive filter across name/verb/path/controller/action |
131
132
  | `rails_model` | `name` (constant path, e.g. `Widget` or `Admin::Widget`) | ActiveRecord models only; columns, associations, validators, enums -- no row data |
132
133
 
134
+ ### Example queries
135
+
136
+ `rails_routes`:
137
+
138
+ - "What are all the routes in this app?" -- `rails_routes()`
139
+ - "What's the URL for widgets?" -- `rails_routes(query: "widget")`. `query` is a
140
+ case-insensitive substring match across name, verb, path, controller, *and*
141
+ action -- not just the path -- so a resource name alone typically returns
142
+ every route for that resource (index/create/new/...); narrow further with
143
+ something like `query: "new_widget"` to hit one route by name.
144
+ - "Which routes accept POST?" -- `rails_routes(query: "POST")`, the same
145
+ substring match applied to the verb column.
146
+
147
+ `rails_model`:
148
+
149
+ - "What columns does Widget have, and which are nullable?" --
150
+ `rails_model(name: "Widget")` -- see `columns[].null`, `columns[].sql_type`,
151
+ `columns[].default`.
152
+ - "What validations and associations does Widget enforce?" -- same call --
153
+ see `validators` and `associations`.
154
+ - "What happens if I ask about a non-model class, like a controller?" --
155
+ `rails_model(name: "ApplicationController")` raises `not_active_record_model`
156
+ rather than introspecting it (a nonexistent constant raises `model_not_found`
157
+ instead) -- the tool only ever reflects on `ActiveRecord::Base` descendants.
158
+
133
159
  The warm test worker forces Rails' reload-checking on for its own boot,
134
160
  regardless of the target app's own `test.rb` setting (which disables it by
135
161
  default) -- so editing a model file takes effect on the next tool call
@@ -153,6 +179,13 @@ across a commit that touches the Gemfile, since the sidecar is managed by
153
179
  your MCP client rather than by Coatepec itself: restart your MCP client (or
154
180
  however it manages the Coatepec process) to pick up the change.
155
181
 
182
+ A dead *worker* (as opposed to a dead sidecar) recovers on its own: the
183
+ next tool call detects it and transparently boots a fresh one before
184
+ retrying, whether the worker exited outright or a request to it failed
185
+ with a broken pipe. If you want a fresh worker without waiting for a
186
+ failure -- or one keeps recurring -- call `rails_runtime_restart` directly;
187
+ it always respawns, even if the current worker looks healthy.
188
+
156
189
  ## Security boundary
157
190
 
158
191
  No eval, console, SQL/record access, shell, Rake, or file-write tool. Spec
@@ -93,7 +93,32 @@ module Coatepec
93
93
  end
94
94
 
95
95
  def associations_for(klass)
96
- klass.reflect_on_all_associations.first(MAX_ITEMS).map { |assoc| build_association_data(assoc) }
96
+ klass.reflect_on_all_associations.first(MAX_ITEMS).map { |assoc| safe_association_data(assoc) }
97
+ end
98
+
99
+ # build_association_data's own field-level rescues (association_class_name,
100
+ # association_foreign_key) cover every failure mode seen in practice so
101
+ # far, but ActiveRecord's reflection internals are large enough that
102
+ # betting the whole rails_model call on having anticipated all of them
103
+ # is optimistic -- a has_one/has_many :through a polymorphic belongs_to
104
+ # is exactly the kind of case that wasn't anticipated until it crashed
105
+ # this method outright (see association_foreign_key). This is the
106
+ # boundary of last resort: one association's introspection failing
107
+ # degrades just that entry instead of the whole model. It deliberately
108
+ # does not rescue StandardError -- a NoMethodError here is a genuine
109
+ # Coatepec bug (e.g. a typo), and letting that crash loudly beats
110
+ # silently reporting it as "this association is fine, no data".
111
+ def safe_association_data(assoc)
112
+ build_association_data(assoc)
113
+ rescue NameError, ArgumentError, ::ActiveRecord::ActiveRecordError => e
114
+ degraded_association_data(assoc, e)
115
+ end
116
+
117
+ def degraded_association_data(assoc, error)
118
+ {
119
+ name: assoc.name.to_s, macro: assoc.macro.to_s, class_name: nil, foreign_key: nil, through: nil,
120
+ polymorphic: nil, error: "#{error.class}: #{error.message}"
121
+ }
97
122
  end
98
123
 
99
124
  def build_association_data(assoc)
@@ -101,7 +126,7 @@ module Coatepec
101
126
  name: assoc.name.to_s,
102
127
  macro: assoc.macro.to_s,
103
128
  class_name: association_class_name(assoc),
104
- foreign_key: assoc.foreign_key.to_s,
129
+ foreign_key: association_foreign_key(assoc),
105
130
  through: assoc.through_reflection&.name&.to_s,
106
131
  polymorphic: assoc.polymorphic? || false
107
132
  }
@@ -120,6 +145,20 @@ module Coatepec
120
145
  nil
121
146
  end
122
147
 
148
+ # A has_one/has_many :through reflection whose `through:` target is
149
+ # itself a polymorphic belongs_to has no single fixed class either --
150
+ # ThroughReflection#foreign_key needs through_reflection.klass to find
151
+ # the source reflection, and .klass on a polymorphic reflection always
152
+ # raises ArgumentError (see association_class_name above). A dangling
153
+ # class_name on the reflection itself still raises NameError the same
154
+ # way. Report foreign_key: nil rather than letting either crash the
155
+ # call.
156
+ def association_foreign_key(assoc)
157
+ assoc.foreign_key.to_s
158
+ rescue NameError, ArgumentError
159
+ nil
160
+ end
161
+
123
162
  def validators_for(klass)
124
163
  klass.validators.first(MAX_ITEMS).map do |validator|
125
164
  {
@@ -69,6 +69,31 @@ module Coatepec
69
69
  end
70
70
  end
71
71
 
72
+ # The `rails_runtime_restart` MCP tool: unconditionally tears down and
73
+ # respawns the test worker, discarding its warm Rails boot. A manual
74
+ # escape hatch alongside WorkerManager's own automatic dead-worker
75
+ # detection, for whatever failure mode that detection doesn't catch.
76
+ class RuntimeRestartTool < ::MCP::Tool
77
+ tool_name "rails_runtime_restart"
78
+ description "Tear down and respawn the Coatepec test worker, discarding its warm Rails boot " \
79
+ "(use if rails_spec_run/rails_runtime_status keep failing and a fresh worker is needed)"
80
+ annotations(read_only_hint: false, destructive_hint: true, idempotent_hint: false, open_world_hint: false)
81
+ input_schema(properties: {}, required: [], additionalProperties: false)
82
+
83
+ class << self
84
+ def call(server_context:)
85
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
+ data = server_context[:worker_manager].restart!
87
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
88
+ Response.ok(data: data,
89
+ meta: { project_root: server_context[:project_root], environment: "test",
90
+ duration_ms: duration_ms })
91
+ rescue Coatepec::Error => e
92
+ Response.error(e)
93
+ end
94
+ end
95
+ end
96
+
72
97
  # The `rails_routes` MCP tool: lists/filters/paginates the target
73
98
  # Rails app's routes.
74
99
  class RoutesTool < ::MCP::Tool
data/lib/coatepec/mcp.rb CHANGED
@@ -12,15 +12,15 @@ require_relative "mcp/response"
12
12
  require_relative "mcp/tools"
13
13
 
14
14
  module Coatepec
15
- # Wires the `rails_spec_run`, `rails_runtime_status`, `rails_routes`, and
16
- # `rails_model` tools into an `::MCP::Server` instance backed by the given
17
- # project's worker manager.
15
+ # Wires the `rails_spec_run`, `rails_runtime_status`, `rails_runtime_restart`,
16
+ # `rails_routes`, and `rails_model` tools into an `::MCP::Server` instance
17
+ # backed by the given project's worker manager.
18
18
  module MCP
19
19
  def self.build_server(project:, worker_manager:)
20
20
  ::MCP::Server.new(
21
21
  name: "coatepec",
22
22
  version: Coatepec::VERSION,
23
- tools: [SpecRunTool, RuntimeStatusTool, RoutesTool, ModelTool],
23
+ tools: [SpecRunTool, RuntimeStatusTool, RuntimeRestartTool, RoutesTool, ModelTool],
24
24
  server_context: { worker_manager: worker_manager, project_root: project.root }
25
25
  )
26
26
  end
@@ -14,6 +14,15 @@ module Coatepec
14
14
  redirect_output(out_w, err_w)
15
15
  # Forked children must not share the parent's live DB sockets.
16
16
  ActiveRecord::Base.connection_handler.clear_all_connections! if defined?(ActiveRecord::Base)
17
+ # RSpec freezes its own "load started at" timestamp once, at the moment
18
+ # rspec/core.rb is first required -- in this architecture, that's when
19
+ # the long-lived warm worker booted, not when THIS run started. Every
20
+ # forked child inherits that frozen timestamp via copy-on-write, so
21
+ # RSpec's own "(files took N seconds to load)" reporting would
22
+ # otherwise measure "time since the worker booted" and grow across
23
+ # every run for as long as the worker stays warm. Reset it fresh
24
+ # before each run.
25
+ RSpec.configuration.start_time = RSpec::Core::Time.now
17
26
 
18
27
  status = RSpec::Core::Runner.run(full_args, $stderr, $stdout)
19
28
  $stdout.flush
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coatepec
4
- VERSION = "0.5.0"
4
+ VERSION = "0.5.2"
5
5
  end
@@ -32,9 +32,20 @@ module Coatepec
32
32
  def alive?
33
33
  return false unless @pid
34
34
 
35
+ # A dead-but-unreaped worker (crash, the OS reclaiming a long-idle
36
+ # process, anything) is a zombie: Process.kill(0, pid) below would still
37
+ # succeed against it, since it still holds a process-table entry. Reap it
38
+ # here so staleness is detected instead of reported as "alive" forever --
39
+ # nothing else calls Process.wait on this pid except #stop, which only
40
+ # WorkerManager#restart_worker! reaches, and only once #alive? itself
41
+ # already says false.
42
+ _pid, status = Process.waitpid2(@pid, Process::WNOHANG)
43
+ @pid = nil if status
44
+ return false unless @pid
45
+
35
46
  Process.kill(0, @pid)
36
47
  true
37
- rescue Errno::ESRCH
48
+ rescue Errno::ESRCH, Errno::ECHILD
38
49
  false
39
50
  end
40
51
 
@@ -40,6 +40,27 @@ module Coatepec
40
40
  @lock.synchronize { @client&.stop }
41
41
  end
42
42
 
43
+ # Unconditionally tears down and respawns the worker, bypassing the usual
44
+ # "only restart if #ensure_worker! thinks it's needed" check. Manual escape
45
+ # hatch for rails_runtime_restart, independent of whatever #perform's
46
+ # rescue below already recovers from automatically.
47
+ #
48
+ # Still honors the sidecar-restart invariant: if the Gemfile changed since
49
+ # boot, a worker-only respawn would silently adopt the new bundle in the
50
+ # worker while the parent MCP process stays on the old one, so that case
51
+ # raises :sidecar_restart_required instead of restarting. The check runs
52
+ # against the *pre-existing* snapshot, before restart_worker! re-baselines
53
+ # it -- same ordering #dispatch uses. Nothing to compare against on the
54
+ # very first call.
55
+ def restart!
56
+ @lock.synchronize do
57
+ raise_sidecar_restart_required! if sidecar_restart_required?
58
+
59
+ restart_worker!
60
+ status
61
+ end
62
+ end
63
+
43
64
  private
44
65
 
45
66
  def dispatch(command, args, timeout:, retried: false)
@@ -57,26 +78,33 @@ module Coatepec
57
78
 
58
79
  def perform(command, args, timeout:, retried:)
59
80
  @client.request(command, args, timeout: timeout)
60
- rescue Worker::Client::DisconnectedError
61
- raise Coatepec::Error.new(:worker_disconnected, "Worker disconnected") if retried
81
+ rescue Worker::Client::DisconnectedError, SystemCallError, IOError => e
82
+ raise Coatepec::Error.new(:worker_disconnected, "Worker disconnected: #{e.message}") if retried
62
83
 
63
84
  restart_worker!
64
85
  dispatch(command, args, timeout: timeout, retried: true)
65
86
  end
66
87
 
67
88
  def check_for_restart!
68
- reason = @change_detector.restart_reason(@snapshot)
69
- case reason
89
+ case @change_detector.restart_reason(@snapshot)
70
90
  when :sidecar_restart_required
71
- raise Coatepec::Error.new(
72
- :sidecar_restart_required,
73
- "Gemfile changed; restart your MCP client to restart Coatepec and pick up the change"
74
- )
91
+ raise_sidecar_restart_required!
75
92
  when :worker_restart_required
76
93
  restart_worker!
77
94
  end
78
95
  end
79
96
 
97
+ def sidecar_restart_required?
98
+ @snapshot && @change_detector.restart_reason(@snapshot) == :sidecar_restart_required
99
+ end
100
+
101
+ def raise_sidecar_restart_required!
102
+ raise Coatepec::Error.new(
103
+ :sidecar_restart_required,
104
+ "Gemfile changed; restart your MCP client to restart Coatepec and pick up the change"
105
+ )
106
+ end
107
+
80
108
  def ensure_worker!
81
109
  restart_worker! unless @client&.alive?
82
110
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coatepec
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Enrique Mogollan
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-05 00:00:00.000000000 Z
10
+ date: 2026-08-10 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties