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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +8 -0
- data/lib/coatepec/mcp/tools.rb +25 -0
- data/lib/coatepec/mcp.rb +4 -4
- data/lib/coatepec/spec/fork_strategy.rb +9 -0
- data/lib/coatepec/version.rb +1 -1
- data/lib/coatepec/worker/client.rb +12 -1
- data/lib/coatepec/worker_manager.rb +36 -8
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be24375e5f36c63e0511dc268bacecd57a54a33a94f5d44540925927c49f6655
|
|
4
|
+
data.tar.gz: 0e80f68351e4e814e9e4486f7e02d8d3f70512edaa7e9806c6baa3091111e779
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/coatepec/mcp/tools.rb
CHANGED
|
@@ -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`, `
|
|
16
|
-
# `rails_model` tools into an `::MCP::Server` instance
|
|
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
|
data/lib/coatepec/version.rb
CHANGED
|
@@ -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
|
-
|
|
69
|
-
case reason
|
|
89
|
+
case @change_detector.restart_reason(@snapshot)
|
|
70
90
|
when :sidecar_restart_required
|
|
71
|
-
|
|
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.
|
|
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-
|
|
10
|
+
date: 2026-08-10 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|