coatepec 0.5.1 → 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +33 -0
- data/lib/coatepec/mcp/tools.rb +66 -0
- data/lib/coatepec/mcp.rb +4 -4
- data/lib/coatepec/spec/flaky_checker.rb +87 -0
- 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/server.rb +5 -0
- data/lib/coatepec/worker_manager.rb +48 -8
- data/lib/coatepec.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ddcc044bcc53590ba19d988b0db8ea0aea4efd7db102bd6718196e61ab9ab820
|
|
4
|
+
data.tar.gz: 1b8b779b5bb59068a7fdd5fc90e7d44b900d50f1918e1d68877a9ccad24bf1e7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56fce0755730ab151c81fb43f88a28d6e081b242c3e9bb31fac71578c290e864efbc5e5313702a4c015b95a661cdd601f1701979c98dbbdae1a0cfa06e0be2fa
|
|
7
|
+
data.tar.gz: 4101b3595de972478ac414f3c019b413ec991cfe1d8ea0fbb251010fad6d699eaeb849818a1c4dbcfe813b47b41af220b68a7f971efe287dbbb8c598d8ff9763
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
- Add `rails_spec_flaky_check`: runs a spec selection multiple times with
|
|
6
|
+
independently random seeds (RSpec's `--seed` is equivalent to `--order
|
|
7
|
+
rand:SEED`) and reports which examples' pass/fail status was
|
|
8
|
+
inconsistent across runs -- separately from examples that failed every
|
|
9
|
+
run (`consistently_failing`, not flaky) and examples that passed every
|
|
10
|
+
run (omitted). Each round's seed is included in the response so a
|
|
11
|
+
specific divergence can be reproduced with a plain `rails_spec_run`
|
|
12
|
+
call. `rails_spec_run` itself is unchanged by this release.
|
|
13
|
+
|
|
14
|
+
## 0.5.2
|
|
15
|
+
|
|
16
|
+
- Fix `WorkerManager` getting permanently stuck treating a dead worker as
|
|
17
|
+
alive: `Worker::Client#alive?` used `Process.kill(0, pid)`, which returns
|
|
18
|
+
true for an unreaped zombie, and the one existing auto-restart-and-retry
|
|
19
|
+
path only caught `Worker::Client::DisconnectedError`, not a raw
|
|
20
|
+
`Errno::EPIPE`/`SystemCallError` from writing to a dead worker's pipe.
|
|
21
|
+
Every call after a worker died this way failed identically, forever --
|
|
22
|
+
since stdio MCP servers have no lighter reconnect in Claude Code, this
|
|
23
|
+
previously required a full session restart to recover from.
|
|
24
|
+
- Add `rails_runtime_restart`: unconditionally tears down and respawns the
|
|
25
|
+
test worker, for whatever the automatic detection above doesn't catch.
|
|
26
|
+
- Fix RSpec's own "(files took N seconds to load)" reporting growing
|
|
27
|
+
across every `rails_spec_run` call made through the same warm,
|
|
28
|
+
forked-per-run worker -- it was measuring time since the worker first
|
|
29
|
+
booted, not time this run's files took to load, because `rspec-core`
|
|
30
|
+
freezes that timestamp once per process and Coatepec never reset it on
|
|
31
|
+
reuse.
|
|
32
|
+
|
|
3
33
|
## 0.5.1
|
|
4
34
|
|
|
5
35
|
- Fix `rails_model` crashing outright for a model with a `has_one`/
|
data/README.md
CHANGED
|
@@ -127,8 +127,10 @@ 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 |
|
|
133
|
+
| `rails_spec_flaky_check` | `paths`, `example?`, `timeout_seconds?` (per round, 1..900), `runs?` (2..20, default 5) | Runs the selection `runs` times with a fresh random seed each round; reports examples whose status was inconsistent across runs |
|
|
132
134
|
|
|
133
135
|
### Example queries
|
|
134
136
|
|
|
@@ -155,6 +157,30 @@ a no-op.
|
|
|
155
157
|
rather than introspecting it (a nonexistent constant raises `model_not_found`
|
|
156
158
|
instead) -- the tool only ever reflects on `ActiveRecord::Base` descendants.
|
|
157
159
|
|
|
160
|
+
`rails_spec_flaky_check`:
|
|
161
|
+
|
|
162
|
+
- "Is this spec flaky?" -- `rails_spec_flaky_check(paths: ["spec/models/widget_spec.rb"])`
|
|
163
|
+
runs it 5 times (default), each with an independently random seed
|
|
164
|
+
(equivalent to RSpec's `--order rand:SEED`), and reports any example
|
|
165
|
+
whose pass/fail status wasn't the same every time under `flaky_examples`
|
|
166
|
+
-- distinct from `consistently_failing` (fails every run: broken, not
|
|
167
|
+
flaky) and examples that passed every run (omitted -- nothing to report).
|
|
168
|
+
- Each round's seed is included in the response (`rounds[].seed`), so a
|
|
169
|
+
specific divergence can be reproduced afterward with a plain
|
|
170
|
+
`rails_spec_run(seed: <that seed>)`.
|
|
171
|
+
- `timeout_seconds` is a **per-round** budget, not a total; `runs *
|
|
172
|
+
timeout_seconds` is capped at 1800s combined (`flaky_check_budget_exceeded`
|
|
173
|
+
if exceeded) since this tool can run for a while.
|
|
174
|
+
- Selections over 500 examples are capped per round (the same limit
|
|
175
|
+
`rails_spec_run` already has), and each round samples a different subset,
|
|
176
|
+
since execution order varies by design -- for suites this large, narrow
|
|
177
|
+
`paths`/`example` rather than passing a very broad directory selection.
|
|
178
|
+
- `statuses[]` only aligns positionally with `rounds[]` when no round
|
|
179
|
+
crashed -- a round whose process itself failed contributes no entry to
|
|
180
|
+
`statuses[]` (though it still appears in `rounds[]`), so treat positional
|
|
181
|
+
correspondence as best-effort, not guaranteed, when a round's `status` in
|
|
182
|
+
`rounds[]` looks like an outright crash rather than a normal pass/fail.
|
|
183
|
+
|
|
158
184
|
The warm test worker forces Rails' reload-checking on for its own boot,
|
|
159
185
|
regardless of the target app's own `test.rb` setting (which disables it by
|
|
160
186
|
default) -- so editing a model file takes effect on the next tool call
|
|
@@ -178,6 +204,13 @@ across a commit that touches the Gemfile, since the sidecar is managed by
|
|
|
178
204
|
your MCP client rather than by Coatepec itself: restart your MCP client (or
|
|
179
205
|
however it manages the Coatepec process) to pick up the change.
|
|
180
206
|
|
|
207
|
+
A dead *worker* (as opposed to a dead sidecar) recovers on its own: the
|
|
208
|
+
next tool call detects it and transparently boots a fresh one before
|
|
209
|
+
retrying, whether the worker exited outright or a request to it failed
|
|
210
|
+
with a broken pipe. If you want a fresh worker without waiting for a
|
|
211
|
+
failure -- or one keeps recurring -- call `rails_runtime_restart` directly;
|
|
212
|
+
it always respawns, even if the current worker looks healthy.
|
|
213
|
+
|
|
181
214
|
## Security boundary
|
|
182
215
|
|
|
183
216
|
No eval, console, SQL/record access, shell, Rake, or file-write tool. Spec
|
data/lib/coatepec/mcp/tools.rb
CHANGED
|
@@ -69,6 +69,72 @@ 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
|
+
|
|
97
|
+
# The `rails_spec_flaky_check` MCP tool: runs targeted RSpec examples
|
|
98
|
+
# multiple times with independently random seeds and reports which
|
|
99
|
+
# examples' pass/fail status was inconsistent across rounds. Separate
|
|
100
|
+
# tool from rails_spec_run for the same reason rails_spec_profile is --
|
|
101
|
+
# see docs/superpowers/specs/2026-08-13-flaky-spec-detection-design.md.
|
|
102
|
+
class FlakyCheckTool < ::MCP::Tool
|
|
103
|
+
tool_name "rails_spec_flaky_check"
|
|
104
|
+
description "Run targeted RSpec examples multiple times with random seeds to detect order-dependent or " \
|
|
105
|
+
"intermittent flakiness, reporting which examples' status was inconsistent across runs"
|
|
106
|
+
annotations(read_only_hint: false, destructive_hint: true, idempotent_hint: false, open_world_hint: true)
|
|
107
|
+
input_schema(
|
|
108
|
+
properties: {
|
|
109
|
+
paths: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 100 },
|
|
110
|
+
example: { type: %w[string null] },
|
|
111
|
+
timeout_seconds: { type: "integer", minimum: 1, maximum: 900 },
|
|
112
|
+
runs: { type: "integer", minimum: 2, maximum: 20 }
|
|
113
|
+
},
|
|
114
|
+
required: ["paths"],
|
|
115
|
+
additionalProperties: false
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
class << self
|
|
119
|
+
def call(paths:, server_context:, example: nil, timeout_seconds: 120, runs: 5)
|
|
120
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
121
|
+
data = server_context[:worker_manager].check_flaky(
|
|
122
|
+
paths: paths, example: example, timeout_seconds: timeout_seconds, runs: runs
|
|
123
|
+
)
|
|
124
|
+
Response.ok(data: data, meta: meta_for(server_context, started_at))
|
|
125
|
+
rescue Coatepec::Error => e
|
|
126
|
+
Response.error(e)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def meta_for(server_context, started_at)
|
|
132
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
|
|
133
|
+
{ project_root: server_context[:project_root], environment: "test", duration_ms: duration_ms }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
72
138
|
# The `rails_routes` MCP tool: lists/filters/paginates the target
|
|
73
139
|
# Rails app's routes.
|
|
74
140
|
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
|
|
17
|
-
# project's worker manager.
|
|
15
|
+
# Wires the `rails_spec_run`, `rails_runtime_status`, `rails_runtime_restart`,
|
|
16
|
+
# `rails_spec_flaky_check`, `rails_routes`, and `rails_model` tools into an
|
|
17
|
+
# `::MCP::Server` instance 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, FlakyCheckTool, RoutesTool, ModelTool],
|
|
24
24
|
server_context: { worker_manager: worker_manager, project_root: project.root }
|
|
25
25
|
)
|
|
26
26
|
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Coatepec
|
|
6
|
+
module Spec
|
|
7
|
+
# Runs a spec selection multiple times, each round with an
|
|
8
|
+
# independently random seed (RSpec's --seed N is equivalent to --order
|
|
9
|
+
# rand:N, confirmed against rspec-core's own option_parser.rb), and
|
|
10
|
+
# groups per-example pass/fail status across rounds by the example's
|
|
11
|
+
# own id -- stable regardless of execution order, since it's derived
|
|
12
|
+
# from declaration position/nesting, not from --order. Composes Runner
|
|
13
|
+
# unchanged, exactly like FactoryProfRunner: every round is a plain,
|
|
14
|
+
# ordinary Runner#run call with a different seed, no strategy override
|
|
15
|
+
# needed (unlike FactoryProfRunner, this feature has no load-time
|
|
16
|
+
# activation trick to fight -- see
|
|
17
|
+
# docs/superpowers/specs/2026-08-13-flaky-spec-detection-design.md).
|
|
18
|
+
class FlakyChecker
|
|
19
|
+
DEFAULT_RUNS = 5
|
|
20
|
+
MAX_TOTAL_SECONDS = 1800 # timeout_seconds * runs must not exceed this
|
|
21
|
+
MAX_ITEMS = 200 # same bound rails_model/FactoryProfRunner use elsewhere
|
|
22
|
+
|
|
23
|
+
def initialize(project_root, rails_runtime: nil)
|
|
24
|
+
@runner = Runner.new(project_root, rails_runtime: rails_runtime)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(paths:, example: nil, timeout_seconds: Runner::DEFAULT_TIMEOUT, runs: DEFAULT_RUNS)
|
|
28
|
+
validate_budget!(timeout_seconds, runs)
|
|
29
|
+
rounds = Array.new(runs) { run_one_round(paths, example, timeout_seconds) }
|
|
30
|
+
build_report(rounds)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def validate_budget!(timeout_seconds, runs)
|
|
36
|
+
return if timeout_seconds * runs <= MAX_TOTAL_SECONDS
|
|
37
|
+
|
|
38
|
+
raise Coatepec::Error.new(
|
|
39
|
+
:flaky_check_budget_exceeded,
|
|
40
|
+
"timeout_seconds (#{timeout_seconds}) * runs (#{runs}) exceeds the " \
|
|
41
|
+
"#{MAX_TOTAL_SECONDS}s combined budget -- lower one or both"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run_one_round(paths, example, timeout_seconds)
|
|
46
|
+
seed = SecureRandom.random_number(65_536)
|
|
47
|
+
result = @runner.run(
|
|
48
|
+
paths: paths, example: example, seed: seed, fail_fast: false,
|
|
49
|
+
timeout_seconds: timeout_seconds
|
|
50
|
+
)
|
|
51
|
+
{ seed: seed, status: result[:status], examples: result[:examples] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_report(rounds)
|
|
55
|
+
by_id = group_examples_by_id(rounds)
|
|
56
|
+
{
|
|
57
|
+
runs: rounds.size,
|
|
58
|
+
rounds: rounds.map { |r| { seed: r[:seed], status: r[:status] } },
|
|
59
|
+
flaky_examples: classify(by_id) { |statuses| statuses.uniq.size > 1 },
|
|
60
|
+
consistently_failing: classify(by_id) { |statuses| statuses.uniq == ["failed"] }
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Rounds whose RSpec process itself crashed/timed out (not a specific
|
|
65
|
+
# example failing -- the whole run killed before its JSON summary was
|
|
66
|
+
# ever written) contribute no per-example data here. Excluded rather
|
|
67
|
+
# than counted as "every example failed", which would misrepresent a
|
|
68
|
+
# dead process as evidence against examples that never actually ran;
|
|
69
|
+
# the round itself is still visible in the `rounds:` summary.
|
|
70
|
+
def group_examples_by_id(rounds)
|
|
71
|
+
rounds.each_with_object({}) do |round, acc|
|
|
72
|
+
round[:examples].each { |ex| (acc[ex[:id]] ||= { meta: ex, statuses: [] })[:statuses] << ex[:status] }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def classify(by_id)
|
|
77
|
+
by_id.values.select { |v| yield(v[:statuses]) }.first(MAX_ITEMS).map do |v|
|
|
78
|
+
v[:meta].slice(:id, :description, :file_path, :line_number).merge(
|
|
79
|
+
statuses: v[:statuses],
|
|
80
|
+
pass_count: v[:statuses].count("passed"),
|
|
81
|
+
failure_count: v[:statuses].count("failed")
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
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
|
|
|
@@ -42,6 +42,7 @@ module Coatepec
|
|
|
42
42
|
case command
|
|
43
43
|
when "status" then @runtime.status
|
|
44
44
|
when "spec_run" then handle_spec_run(args)
|
|
45
|
+
when "flaky_check" then handle_flaky_check(args)
|
|
45
46
|
when "routes" then handle_routes(args)
|
|
46
47
|
when "model" then handle_model(args)
|
|
47
48
|
else
|
|
@@ -53,6 +54,10 @@ module Coatepec
|
|
|
53
54
|
Spec::Runner.new(@project_root, rails_runtime: @runtime).run(**args.transform_keys(&:to_sym))
|
|
54
55
|
end
|
|
55
56
|
|
|
57
|
+
def handle_flaky_check(args)
|
|
58
|
+
Spec::FlakyChecker.new(@project_root, rails_runtime: @runtime).call(**args.transform_keys(&:to_sym))
|
|
59
|
+
end
|
|
60
|
+
|
|
56
61
|
def handle_routes(args)
|
|
57
62
|
Introspection::Routes.new(**args.transform_keys(&:to_sym)).call
|
|
58
63
|
end
|
|
@@ -28,6 +28,18 @@ module Coatepec
|
|
|
28
28
|
)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
def check_flaky(paths:, example:, timeout_seconds:, runs:)
|
|
32
|
+
dispatch(
|
|
33
|
+
"flaky_check",
|
|
34
|
+
{ paths: paths, example: example, timeout_seconds: timeout_seconds, runs: runs },
|
|
35
|
+
# Each round costs timeout_seconds *plus* real per-round overhead (process
|
|
36
|
+
# termination/spawn/result-parsing -- roughly 0.6s+ per round from
|
|
37
|
+
# ProcessStrategy#terminate's own escalation sleeps alone), so the flat
|
|
38
|
+
# base slack below is scaled by an extra ~2s per round on top of it.
|
|
39
|
+
timeout: (timeout_seconds * runs) + (2 * runs) + 10
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
31
43
|
def routes(query: nil, limit: 50, offset: 0)
|
|
32
44
|
dispatch("routes", { query: query, limit: limit, offset: offset }, timeout: 30)
|
|
33
45
|
end
|
|
@@ -40,6 +52,27 @@ module Coatepec
|
|
|
40
52
|
@lock.synchronize { @client&.stop }
|
|
41
53
|
end
|
|
42
54
|
|
|
55
|
+
# Unconditionally tears down and respawns the worker, bypassing the usual
|
|
56
|
+
# "only restart if #ensure_worker! thinks it's needed" check. Manual escape
|
|
57
|
+
# hatch for rails_runtime_restart, independent of whatever #perform's
|
|
58
|
+
# rescue below already recovers from automatically.
|
|
59
|
+
#
|
|
60
|
+
# Still honors the sidecar-restart invariant: if the Gemfile changed since
|
|
61
|
+
# boot, a worker-only respawn would silently adopt the new bundle in the
|
|
62
|
+
# worker while the parent MCP process stays on the old one, so that case
|
|
63
|
+
# raises :sidecar_restart_required instead of restarting. The check runs
|
|
64
|
+
# against the *pre-existing* snapshot, before restart_worker! re-baselines
|
|
65
|
+
# it -- same ordering #dispatch uses. Nothing to compare against on the
|
|
66
|
+
# very first call.
|
|
67
|
+
def restart!
|
|
68
|
+
@lock.synchronize do
|
|
69
|
+
raise_sidecar_restart_required! if sidecar_restart_required?
|
|
70
|
+
|
|
71
|
+
restart_worker!
|
|
72
|
+
status
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
43
76
|
private
|
|
44
77
|
|
|
45
78
|
def dispatch(command, args, timeout:, retried: false)
|
|
@@ -57,26 +90,33 @@ module Coatepec
|
|
|
57
90
|
|
|
58
91
|
def perform(command, args, timeout:, retried:)
|
|
59
92
|
@client.request(command, args, timeout: timeout)
|
|
60
|
-
rescue Worker::Client::DisconnectedError
|
|
61
|
-
raise Coatepec::Error.new(:worker_disconnected, "Worker disconnected") if retried
|
|
93
|
+
rescue Worker::Client::DisconnectedError, SystemCallError, IOError => e
|
|
94
|
+
raise Coatepec::Error.new(:worker_disconnected, "Worker disconnected: #{e.message}") if retried
|
|
62
95
|
|
|
63
96
|
restart_worker!
|
|
64
97
|
dispatch(command, args, timeout: timeout, retried: true)
|
|
65
98
|
end
|
|
66
99
|
|
|
67
100
|
def check_for_restart!
|
|
68
|
-
|
|
69
|
-
case reason
|
|
101
|
+
case @change_detector.restart_reason(@snapshot)
|
|
70
102
|
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
|
-
)
|
|
103
|
+
raise_sidecar_restart_required!
|
|
75
104
|
when :worker_restart_required
|
|
76
105
|
restart_worker!
|
|
77
106
|
end
|
|
78
107
|
end
|
|
79
108
|
|
|
109
|
+
def sidecar_restart_required?
|
|
110
|
+
@snapshot && @change_detector.restart_reason(@snapshot) == :sidecar_restart_required
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def raise_sidecar_restart_required!
|
|
114
|
+
raise Coatepec::Error.new(
|
|
115
|
+
:sidecar_restart_required,
|
|
116
|
+
"Gemfile changed; restart your MCP client to restart Coatepec and pick up the change"
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
80
120
|
def ensure_worker!
|
|
81
121
|
restart_worker! unless @client&.alive?
|
|
82
122
|
end
|
data/lib/coatepec.rb
CHANGED
|
@@ -18,6 +18,7 @@ require_relative "coatepec/spec/fork_strategy"
|
|
|
18
18
|
require_relative "coatepec/spec/spawn_strategy"
|
|
19
19
|
require_relative "coatepec/spec/guarded_fork_strategy"
|
|
20
20
|
require_relative "coatepec/spec/runner"
|
|
21
|
+
require_relative "coatepec/spec/flaky_checker"
|
|
21
22
|
require_relative "coatepec/worker/server"
|
|
22
23
|
require_relative "coatepec/worker_manager"
|
|
23
24
|
|
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.
|
|
4
|
+
version: 0.6.0
|
|
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-20 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: railties
|
|
@@ -74,6 +74,7 @@ files:
|
|
|
74
74
|
- lib/coatepec/project.rb
|
|
75
75
|
- lib/coatepec/project_config.rb
|
|
76
76
|
- lib/coatepec/protocol.rb
|
|
77
|
+
- lib/coatepec/spec/flaky_checker.rb
|
|
77
78
|
- lib/coatepec/spec/fork_strategy.rb
|
|
78
79
|
- lib/coatepec/spec/guarded_fork_strategy.rb
|
|
79
80
|
- lib/coatepec/spec/path_policy.rb
|