coatepec 0.5.1 → 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: 161aafede82c7279701f75a51cc7976a6ef7bbbadff7bc22a7bc04dc050e1780
4
- data.tar.gz: 2b867df0d495bb573e1e00ad9a94f9aca800fa7045bbf6965fb0825acfc2683f
3
+ metadata.gz: be24375e5f36c63e0511dc268bacecd57a54a33a94f5d44540925927c49f6655
4
+ data.tar.gz: 0e80f68351e4e814e9e4486f7e02d8d3f70512edaa7e9806c6baa3091111e779
5
5
  SHA512:
6
- metadata.gz: d93777d12dac89c55450377259fc7d206bd6ef077766395f1397045372a58a7203bf986bbb9a525fc89b8510159a7fd9d0c3a057c321219edd95d6f23caa7acf
7
- data.tar.gz: 3e580288a7c4ef9b0e707909967c28293ce8bc8b749e8c11d03903faef69ef80587c4a8307e13e769633d521b6245a05f806859d124a40346a9a0120b56e3e9b
6
+ metadata.gz: 297b7eb82d12ece3991893ed1052650648d37fb86b438356257e67e90f69260297b5c3f4b390a2ce25860e2e06a8487dc1597bb39219c3f13ac550901cc92e94
7
+ data.tar.gz: dbe1f843c872f42c20d7df9ee4226af83200b9c9457bb2a9b3aba185d22b7c5c8878fa3237a57dc4dfb4526c499e4c75cb1cb5c3135262aa24dd71fc4ea8805b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
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
+
3
22
  ## 0.5.1
4
23
 
5
24
  - Fix `rails_model` crashing outright for a model with a `has_one`/
data/README.md CHANGED
@@ -127,6 +127,7 @@ 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
 
@@ -178,6 +179,13 @@ across a commit that touches the Gemfile, since the sidecar is managed by
178
179
  your MCP client rather than by Coatepec itself: restart your MCP client (or
179
180
  however it manages the Coatepec process) to pick up the change.
180
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
+
181
189
  ## Security boundary
182
190
 
183
191
  No eval, console, SQL/record access, shell, Rake, or file-write tool. Spec
@@ -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.1"
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.1
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-07 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