solid_loop 0.0.4
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 +7 -0
- data/CHANGELOG.md +156 -0
- data/CODE_OF_CONDUCT.md +67 -0
- data/CONTRIBUTING.md +82 -0
- data/MIT-LICENSE +21 -0
- data/README.md +483 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/solid_loop/chart.umd.min.js +14 -0
- data/app/assets/javascripts/solid_loop/chartjs-adapter-date-fns.bundle.min.js +7 -0
- data/app/assets/javascripts/solid_loop/chartkick.min.js +2 -0
- data/app/assets/stylesheets/solid_loop/application.css +15 -0
- data/app/controllers/solid_loop/application_controller.rb +29 -0
- data/app/controllers/solid_loop/dashboard_controller.rb +104 -0
- data/app/controllers/solid_loop/events_controller.rb +12 -0
- data/app/controllers/solid_loop/loops_controller.rb +77 -0
- data/app/controllers/solid_loop/mcp_inbound_sessions_controller.rb +13 -0
- data/app/controllers/solid_loop/mcp_sessions_controller.rb +68 -0
- data/app/controllers/solid_loop/mcp_tools_controller.rb +16 -0
- data/app/controllers/solid_loop/messages_controller.rb +12 -0
- data/app/controllers/solid_loop/tool_calls_controller.rb +12 -0
- data/app/helpers/solid_loop/application_helper.rb +125 -0
- data/app/jobs/solid_loop/application_job.rb +13 -0
- data/app/jobs/solid_loop/llm_completion_job.rb +139 -0
- data/app/jobs/solid_loop/observe_broadcast_job.rb +15 -0
- data/app/jobs/solid_loop/reaper_job.rb +20 -0
- data/app/jobs/solid_loop/tool_execution_job.rb +200 -0
- data/app/models/solid_loop/application_record.rb +5 -0
- data/app/models/solid_loop/base.rb +190 -0
- data/app/models/solid_loop/event.rb +7 -0
- data/app/models/solid_loop/loop.rb +225 -0
- data/app/models/solid_loop/mcp_inbound_session.rb +25 -0
- data/app/models/solid_loop/mcp_session.rb +12 -0
- data/app/models/solid_loop/mcp_tool.rb +26 -0
- data/app/models/solid_loop/message.rb +69 -0
- data/app/models/solid_loop/tool_call.rb +42 -0
- data/app/queries/solid_loop/admin/base_query.rb +23 -0
- data/app/queries/solid_loop/admin/events_query.rb +31 -0
- data/app/queries/solid_loop/admin/loops_query.rb +38 -0
- data/app/queries/solid_loop/admin/mcp_inbound_sessions_query.rb +31 -0
- data/app/queries/solid_loop/admin/mcp_sessions_query.rb +26 -0
- data/app/queries/solid_loop/admin/mcp_tools_query.rb +26 -0
- data/app/queries/solid_loop/admin/messages_query.rb +37 -0
- data/app/queries/solid_loop/admin/tool_calls_query.rb +34 -0
- data/app/services/solid_loop/adapters/native.rb +170 -0
- data/app/services/solid_loop/dialects/anthropic.rb +160 -0
- data/app/services/solid_loop/dialects/gemini.rb +122 -0
- data/app/services/solid_loop/dialects/open_ai.rb +62 -0
- data/app/services/solid_loop/dialects/reasoning_packer.rb +37 -0
- data/app/services/solid_loop/llm_usage_parser/anthropic.rb +22 -0
- data/app/services/solid_loop/llm_usage_parser/gemini.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser/llama.rb +15 -0
- data/app/services/solid_loop/llm_usage_parser/openai.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser.rb +19 -0
- data/app/services/solid_loop/mcp_session_initializer.rb +205 -0
- data/app/services/solid_loop/mcp_tool_execution_service.rb +153 -0
- data/app/services/solid_loop/middlewares/agent_initialization.rb +37 -0
- data/app/services/solid_loop/middlewares/error_handling.rb +107 -0
- data/app/services/solid_loop/middlewares/event_logging.rb +100 -0
- data/app/services/solid_loop/middlewares/message_building.rb +92 -0
- data/app/services/solid_loop/middlewares/network_calling.rb +84 -0
- data/app/services/solid_loop/middlewares/response_parsing.rb +348 -0
- data/app/services/solid_loop/middlewares/tool_call_xml_parser.rb +117 -0
- data/app/services/solid_loop/sse_stream_aggregator.rb +105 -0
- data/app/services/solid_loop/tool_failure_reconciler.rb +122 -0
- data/app/services/solid_loop/tool_middlewares/agent_initialization.rb +17 -0
- data/app/services/solid_loop/tool_middlewares/error_handling.rb +66 -0
- data/app/services/solid_loop/tool_middlewares/event_logging.rb +27 -0
- data/app/services/solid_loop/tool_middlewares/response_creation.rb +110 -0
- data/app/services/solid_loop/tool_middlewares/tool_execution.rb +206 -0
- data/app/views/layouts/solid_loop/admin.html.erb +416 -0
- data/app/views/layouts/solid_loop/application.html.erb +17 -0
- data/app/views/solid_loop/dashboard/index.html.erb +184 -0
- data/app/views/solid_loop/events/index.html.erb +47 -0
- data/app/views/solid_loop/events/show.html.erb +76 -0
- data/app/views/solid_loop/loops/index.html.erb +83 -0
- data/app/views/solid_loop/loops/show.html.erb +148 -0
- data/app/views/solid_loop/mcp_inbound_sessions/index.html.erb +53 -0
- data/app/views/solid_loop/mcp_inbound_sessions/show.html.erb +78 -0
- data/app/views/solid_loop/mcp_sessions/_tool_output.html.erb +11 -0
- data/app/views/solid_loop/mcp_sessions/index.html.erb +46 -0
- data/app/views/solid_loop/mcp_sessions/inspector.html.erb +94 -0
- data/app/views/solid_loop/mcp_sessions/show.html.erb +142 -0
- data/app/views/solid_loop/mcp_tools/index.html.erb +44 -0
- data/app/views/solid_loop/mcp_tools/show.html.erb +69 -0
- data/app/views/solid_loop/messages/_message.html.erb +75 -0
- data/app/views/solid_loop/messages/index.html.erb +80 -0
- data/app/views/solid_loop/messages/show.html.erb +121 -0
- data/app/views/solid_loop/shared/_pagination.html.erb +21 -0
- data/app/views/solid_loop/tool_calls/index.html.erb +53 -0
- data/app/views/solid_loop/tool_calls/show.html.erb +59 -0
- data/config/routes.rb +27 -0
- data/db/migrate/20260408000100_solid_loop_init.rb +136 -0
- data/db/migrate/20260709000100_solid_loop_create_mcp_inbound_sessions.rb +16 -0
- data/db/migrate/20260713000100_solid_loop_add_execution_guards.rb +6 -0
- data/db/migrate/20260714000100_solid_loop_add_message_steering.rb +17 -0
- data/db/migrate/20260714000200_solid_loop_add_message_conversation_order.rb +14 -0
- data/db/migrate/20260715000100_solid_loop_add_mcp_inbound_session_principal_key.rb +43 -0
- data/db/migrate/20260715000200_solid_loop_add_llm_lease.rb +40 -0
- data/db/migrate/20260715000300_solid_loop_add_tool_lease.rb +33 -0
- data/db/migrate/20260715000400_solid_loop_add_lease_running_check.rb +32 -0
- data/db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb +35 -0
- data/docs/contributing/coverage.md +64 -0
- data/docs/decisions/durable_attempt_lease.md +364 -0
- data/docs/decisions/mcp-only-tooling.md +135 -0
- data/docs/decisions/mcp-server.md +223 -0
- data/docs/decisions/reasoning_persistence.md +51 -0
- data/docs/decisions/ruby_llm_rejected.md +35 -0
- data/docs/guides/dialects.md +55 -0
- data/docs/guides/llm_middlewares.md +294 -0
- data/docs/guides/mcp_transports.md +374 -0
- data/docs/guides/tool_middlewares.md +148 -0
- data/lib/solid_loop/configuration.rb +175 -0
- data/lib/solid_loop/engine.rb +14 -0
- data/lib/solid_loop/lease_heartbeat.rb +94 -0
- data/lib/solid_loop/lease_renewer.rb +347 -0
- data/lib/solid_loop/llm_metrics.rb +16 -0
- data/lib/solid_loop/mcp/call_context.rb +19 -0
- data/lib/solid_loop/mcp/client_factory.rb +61 -0
- data/lib/solid_loop/mcp/http_transport.rb +52 -0
- data/lib/solid_loop/mcp/principal.rb +82 -0
- data/lib/solid_loop/mcp/result.rb +13 -0
- data/lib/solid_loop/mcp/server.rb +246 -0
- data/lib/solid_loop/mcp/stdio_transport.rb +224 -0
- data/lib/solid_loop/mcp/toolset.rb +347 -0
- data/lib/solid_loop/mcp/transport.rb +20 -0
- data/lib/solid_loop/mcp.rb +25 -0
- data/lib/solid_loop/mcp_client.rb +176 -0
- data/lib/solid_loop/pipeline/builder.rb +38 -0
- data/lib/solid_loop/pipeline/context.rb +61 -0
- data/lib/solid_loop/pipeline/tool_context.rb +53 -0
- data/lib/solid_loop/pipeline.rb +40 -0
- data/lib/solid_loop/reaper.rb +313 -0
- data/lib/solid_loop/version.rb +3 -0
- data/lib/solid_loop.rb +94 -0
- data/lib/tasks/coverage.rake +206 -0
- data/lib/tasks/solid_loop_tasks.rake +4 -0
- metadata +228 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidLoop
|
|
4
|
+
# Durable-lease + reaper tunables. See docs/decisions/durable_attempt_lease.md.
|
|
5
|
+
#
|
|
6
|
+
# The load-bearing invariant is: an LLM turn's lease MUST outlive the HTTP
|
|
7
|
+
# client's read timeout, so a healthy in-flight turn can never be reclaimed by
|
|
8
|
+
# the reaper (its provider client raises first). The lease duration is derived
|
|
9
|
+
# PER CLAIM from the agent's actual `read_timeout` plus `lease_margin`, so the
|
|
10
|
+
# invariant holds by construction rather than by a fragile global-TTL constant.
|
|
11
|
+
class Configuration
|
|
12
|
+
# Seconds added on top of the agent's HTTP read_timeout when deriving a
|
|
13
|
+
# per-claim LLM lease. Must be > 0 so the lease strictly outlives the client
|
|
14
|
+
# timeout. Also acts as the floor when an agent exposes no read_timeout.
|
|
15
|
+
attr_reader :lease_margin
|
|
16
|
+
|
|
17
|
+
# A loop sitting in `queued` for longer than this (seconds) is treated by the
|
|
18
|
+
# reaper (case 4) as having lost its enqueued job, and is re-enqueued. Must
|
|
19
|
+
# comfortably exceed normal queue latency so a merely-backlogged loop is not
|
|
20
|
+
# redundantly re-enqueued (the claim CAS dedupes duplicates harmlessly, so a
|
|
21
|
+
# low value is safe-but-wasteful, not incorrect).
|
|
22
|
+
attr_reader :queued_reap_threshold
|
|
23
|
+
|
|
24
|
+
# Fallback read_timeout (seconds) used to derive a lease when the agent's
|
|
25
|
+
# `llm_provider` hash carries no `:read_timeout`. Kept in sync with the
|
|
26
|
+
# adapter default (LlmCompletionJob::READ_TIMEOUT).
|
|
27
|
+
attr_reader :default_read_timeout
|
|
28
|
+
|
|
29
|
+
# Fallback tool client timeout (seconds) used to derive a per-tool_call lease.
|
|
30
|
+
# Tools do not expose a single uniform read_timeout the way the LLM provider
|
|
31
|
+
# does (a loop can mix in-process toolsets and remote MCP servers, each with
|
|
32
|
+
# its own Faraday timeout — default 60s in Mcp::Client), so the tool lease is
|
|
33
|
+
# derived from THIS configurable ceiling on the slowest tool a healthy worker
|
|
34
|
+
# could still be blocked in. The lease (this + `lease_margin`) must strictly
|
|
35
|
+
# outlive that ceiling so a live, long-running tool is never reclaimed.
|
|
36
|
+
attr_reader :default_tool_timeout
|
|
37
|
+
|
|
38
|
+
# Grace period (seconds) added on top of the OWNING AGENT'S `max_duration` to
|
|
39
|
+
# form the lease renewer's per-registration leak ceiling. Once a registration has
|
|
40
|
+
# been renewed for longer than `max_duration + lease_leak_grace` — only possible
|
|
41
|
+
# if a turn NEVER deregistered (a job that skipped its `ensure`, an unreachable
|
|
42
|
+
# death path) — the renewer DROPS it and stops renewing, so its lease lapses and
|
|
43
|
+
# the reaper's case-1 reclaim reclaims the loop; renewer liveness no longer
|
|
44
|
+
# depends on `ensure` ever running.
|
|
45
|
+
#
|
|
46
|
+
# This replaces the old `lease_renew_ceiling_multiplier` (a MULTIPLE of the lease
|
|
47
|
+
# duration), which was WRONG: a healthy turn stays registered for its WHOLE life,
|
|
48
|
+
# so any legitimate stream longer than K lease-widths (e.g. a small `read_timeout`
|
|
49
|
+
# with steady chunks) was dropped and reaper-reclaimed mid-flight, and no long
|
|
50
|
+
# turn could ever complete. Tying the ceiling to the agent's OWN legal turn bound
|
|
51
|
+
# (`max_duration`, its own contract) means a legitimate turn always finishes
|
|
52
|
+
# before the ceiling and is never dropped, while a genuine leak still self-expires
|
|
53
|
+
# shortly after `max_duration`. Must be > 0.
|
|
54
|
+
attr_reader :lease_leak_grace
|
|
55
|
+
|
|
56
|
+
# Fallback `max_duration` (seconds) used to size the leak ceiling when a
|
|
57
|
+
# registration is created without one (an out-of-band caller of
|
|
58
|
+
# `LeaseRenewer#register`). Mirrors `SolidLoop::Base#max_duration`'s default (2h).
|
|
59
|
+
attr_reader :default_max_duration
|
|
60
|
+
|
|
61
|
+
def initialize
|
|
62
|
+
@lease_margin = 60
|
|
63
|
+
@queued_reap_threshold = 300
|
|
64
|
+
@default_read_timeout = SolidLoop::LlmCompletionJob::READ_TIMEOUT
|
|
65
|
+
@default_tool_timeout = 600
|
|
66
|
+
# 5 minutes past the agent's own legal turn bound: comfortably absorbs clock
|
|
67
|
+
# skew / a straggling final chunk, yet a genuine leak self-expires soon after
|
|
68
|
+
# `max_duration` rather than never.
|
|
69
|
+
@lease_leak_grace = 300
|
|
70
|
+
@default_max_duration = 2.hours.to_i
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def lease_margin=(seconds)
|
|
74
|
+
seconds = Integer(seconds)
|
|
75
|
+
raise ArgumentError, "lease_margin must be > 0 (the lease must outlive the client timeout)" unless seconds.positive?
|
|
76
|
+
|
|
77
|
+
@lease_margin = seconds
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def queued_reap_threshold=(seconds)
|
|
81
|
+
seconds = Integer(seconds)
|
|
82
|
+
raise ArgumentError, "queued_reap_threshold must be > 0" unless seconds.positive?
|
|
83
|
+
|
|
84
|
+
@queued_reap_threshold = seconds
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def default_read_timeout=(seconds)
|
|
88
|
+
seconds = Integer(seconds)
|
|
89
|
+
raise ArgumentError, "default_read_timeout must be > 0" unless seconds.positive?
|
|
90
|
+
|
|
91
|
+
@default_read_timeout = seconds
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def default_tool_timeout=(seconds)
|
|
95
|
+
seconds = Integer(seconds)
|
|
96
|
+
raise ArgumentError, "default_tool_timeout must be > 0" unless seconds.positive?
|
|
97
|
+
|
|
98
|
+
@default_tool_timeout = seconds
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def lease_leak_grace=(seconds)
|
|
102
|
+
seconds = Integer(seconds)
|
|
103
|
+
raise ArgumentError, "lease_leak_grace must be > 0" unless seconds.positive?
|
|
104
|
+
|
|
105
|
+
@lease_leak_grace = seconds
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def default_max_duration=(seconds)
|
|
109
|
+
seconds = Integer(seconds)
|
|
110
|
+
raise ArgumentError, "default_max_duration must be > 0" unless seconds.positive?
|
|
111
|
+
|
|
112
|
+
@default_max_duration = seconds
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Derive an LLM lease duration (seconds) from an agent's configured HTTP read
|
|
116
|
+
# timeout plus the safety margin. The margin GUARANTEES lease > read_timeout,
|
|
117
|
+
# so a live worker never holds an expired lease. A missing/invalid read
|
|
118
|
+
# timeout falls back to `default_read_timeout`.
|
|
119
|
+
def llm_lease_duration(agent)
|
|
120
|
+
read_timeout =
|
|
121
|
+
begin
|
|
122
|
+
Integer(agent.llm_provider[:read_timeout])
|
|
123
|
+
rescue StandardError, TypeError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
read_timeout = default_read_timeout unless read_timeout&.positive?
|
|
127
|
+
read_timeout + lease_margin
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Derive a per-tool_call lease duration (seconds) for `agent`. Mirrors the LLM
|
|
131
|
+
# lease so the invariant holds BY CONSTRUCTION: the lease strictly outlives the
|
|
132
|
+
# SLOWEST client an in-flight tool could be blocked in, so a healthy
|
|
133
|
+
# long-running tool can never be reclaimed by the reaper (case 2).
|
|
134
|
+
#
|
|
135
|
+
# max(default_tool_timeout, slowest resolved HTTP MCP client timeout) + margin
|
|
136
|
+
#
|
|
137
|
+
# HTTP MCP tools use `mcp_config[:timeout] || 60` (Mcp::ClientFactory) — an
|
|
138
|
+
# operator can set this well above `default_tool_timeout`, so it must be folded
|
|
139
|
+
# in. In-process/custom tools expose NO client timeout to derive from, so
|
|
140
|
+
# `default_tool_timeout` is their ceiling. `lease_margin` (validated > 0)
|
|
141
|
+
# guarantees lease > the resolved timeout.
|
|
142
|
+
#
|
|
143
|
+
# Residual (documented): an in-process/custom tool that legitimately runs
|
|
144
|
+
# longer than `default_tool_timeout` MAY be reclaimed → a duplicate invocation.
|
|
145
|
+
# That is harmless — the DB fence yields exactly one canonical result and tools
|
|
146
|
+
# carry the stable `solid_loop:tool_call:#{id}` idempotency key — but operators
|
|
147
|
+
# who run a slow in-process tool must raise `default_tool_timeout` above it.
|
|
148
|
+
def tool_lease_duration(agent = nil)
|
|
149
|
+
max_http = max_http_tool_timeout(agent)
|
|
150
|
+
[ default_tool_timeout, max_http ].compact.max + lease_margin
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
# The slowest resolved HTTP MCP client timeout across the agent's `mcps`, or
|
|
156
|
+
# nil if the agent exposes none / cannot be inspected. Matches the resolution
|
|
157
|
+
# in Mcp::ClientFactory#transport_for (`mcp_config[:timeout] || 60` for a url:
|
|
158
|
+
# entry). Defensive: a raising/oddly-shaped `mcps` yields nil (fall back to
|
|
159
|
+
# `default_tool_timeout`) rather than breaking the claim.
|
|
160
|
+
def max_http_tool_timeout(agent)
|
|
161
|
+
return nil unless agent.respond_to?(:mcps)
|
|
162
|
+
|
|
163
|
+
timeouts = Array(agent.mcps).filter_map do |mcp_config|
|
|
164
|
+
next unless mcp_config.is_a?(Hash)
|
|
165
|
+
next unless mcp_config[:url] # only HTTP-sugar entries carry a client timeout
|
|
166
|
+
next if mcp_config[:toolset] || mcp_config[:transport]
|
|
167
|
+
|
|
168
|
+
Integer(mcp_config[:timeout] || 60)
|
|
169
|
+
end
|
|
170
|
+
timeouts.max
|
|
171
|
+
rescue StandardError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require_relative "pipeline/context"
|
|
2
|
+
require_relative "pipeline/tool_context"
|
|
3
|
+
require_relative "pipeline"
|
|
4
|
+
require_relative "llm_metrics"
|
|
5
|
+
|
|
6
|
+
module SolidLoop
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace SolidLoop
|
|
9
|
+
|
|
10
|
+
initializer "solid_loop.mime_types" do
|
|
11
|
+
Mime::Type.register "text/vnd.turbo-stream.html", :turbo_stream unless Mime[:turbo_stream]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidLoop
|
|
4
|
+
# A thin per-turn HANDLE over the process-wide {SolidLoop::LeaseRenewer}
|
|
5
|
+
# (docs/decisions/durable_attempt_lease.md). It preserves the
|
|
6
|
+
# exact public surface the LLM path relies on (`run`, `start`, `stop`, `lost?`,
|
|
7
|
+
# `check!`) so callers (`LlmCompletionJob`, `ResponseParsing`) are unchanged.
|
|
8
|
+
#
|
|
9
|
+
# WHY it no longer owns a thread/connection: the old design spawned ONE renewer
|
|
10
|
+
# thread per in-flight turn and each renew tick checked out its own pool
|
|
11
|
+
# connection. At full worker-pool occupancy (pool size == worker concurrency, the
|
|
12
|
+
# standard Rails shape) every connection is held by a busy worker, so every
|
|
13
|
+
# heartbeat blocked in `with_connection` and — after `checkout_timeout` — treated
|
|
14
|
+
# pool exhaustion as a lost lease, yielding healthy turns → the reaper requeued
|
|
15
|
+
# them into the same saturated pool → LIVELOCK. Renewal is now O(1) in
|
|
16
|
+
# connections: the shared `LeaseRenewer` renews ALL registered leases from ONE
|
|
17
|
+
# dedicated connection, so a per-turn renew never competes with workers again.
|
|
18
|
+
#
|
|
19
|
+
# This handle registers the turn on `start` and deregisters on `stop`. The
|
|
20
|
+
# ownership contract is UNCHANGED and now lives in the renewer: each renew is a
|
|
21
|
+
# conditional UPDATE keyed on `loop_id + execution_token`, gated by
|
|
22
|
+
# `lease_expires_at IS NOT NULL` (never resurrects a NULLed tool-phase lease); a
|
|
23
|
+
# genuine 0-row loss (rotated away / reclaimed) flips a per-registration lost
|
|
24
|
+
# flag; the main path checks `lost?`/`check!` before any canonical write; `stop`
|
|
25
|
+
# deregisters BEFORE the canonical commit and again in `ensure`.
|
|
26
|
+
class LeaseHeartbeat
|
|
27
|
+
# Kept for source/back-compat; the cadence now lives in the renewer.
|
|
28
|
+
MIN_INTERVAL = SolidLoop::LeaseRenewer::MIN_INTERVAL
|
|
29
|
+
|
|
30
|
+
def self.run(loop_id:, execution_token:, lease_duration:, max_duration: nil, logger: nil)
|
|
31
|
+
hb = new(loop_id: loop_id, execution_token: execution_token,
|
|
32
|
+
lease_duration: lease_duration, max_duration: max_duration, logger: logger)
|
|
33
|
+
hb.start
|
|
34
|
+
begin
|
|
35
|
+
yield hb
|
|
36
|
+
ensure
|
|
37
|
+
# Deregister BEFORE the caller's canonical completion so the two never race
|
|
38
|
+
# on lease_expires_at. Idempotent.
|
|
39
|
+
hb.stop
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
attr_reader :loop_id, :execution_token, :lease_duration, :max_duration
|
|
44
|
+
|
|
45
|
+
def initialize(loop_id:, execution_token:, lease_duration:, max_duration: nil, logger: nil)
|
|
46
|
+
@loop_id = loop_id
|
|
47
|
+
@execution_token = execution_token
|
|
48
|
+
@lease_duration = lease_duration.to_f
|
|
49
|
+
# The owning agent's legal turn bound, forwarded to the renewer to size the
|
|
50
|
+
# leak ceiling (max_duration + grace). A legitimate turn — capped at
|
|
51
|
+
# max_duration by the agent's own contract — is therefore never dropped.
|
|
52
|
+
@max_duration = max_duration
|
|
53
|
+
@logger = logger || (defined?(Rails) ? Rails.logger : nil)
|
|
54
|
+
@renewer = SolidLoop::LeaseRenewer.instance
|
|
55
|
+
@key = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Register this turn with the shared renewer. Idempotent.
|
|
59
|
+
def start
|
|
60
|
+
return self if @key
|
|
61
|
+
|
|
62
|
+
@key = @renewer.register(
|
|
63
|
+
loop_id: @loop_id, execution_token: @execution_token,
|
|
64
|
+
lease_duration: @lease_duration, max_duration: @max_duration
|
|
65
|
+
)
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# True once a conditional renew for THIS turn touched 0 rows (the lease is no
|
|
70
|
+
# longer ours). Thread-safe; readable from the main path between chunks.
|
|
71
|
+
def lost?
|
|
72
|
+
return false unless @key
|
|
73
|
+
|
|
74
|
+
@renewer.lost?(@key)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Raise if the heartbeat has lost the lease. Call this on the main path
|
|
78
|
+
# immediately before any canonical write/checkpoint.
|
|
79
|
+
def check!
|
|
80
|
+
raise SolidLoop::LostLease, "lease for loop #{@loop_id} lost (generation rotated away)" if lost?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Idempotent. Deregisters this turn from the shared renewer so no further renew
|
|
84
|
+
# fires for it. The renewer thread and its connection are process-scoped and
|
|
85
|
+
# outlive individual turns by design (that is the whole point of the shared
|
|
86
|
+
# renewer), so `stop` here does NOT tear down the thread — it only unregisters.
|
|
87
|
+
def stop
|
|
88
|
+
key = @key
|
|
89
|
+
@key = nil
|
|
90
|
+
@renewer.deregister(key) if key
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidLoop
|
|
4
|
+
# A SINGLE per-process background service that renews ALL in-flight LLM-turn
|
|
5
|
+
# leases from ONE dedicated DB connection
|
|
6
|
+
# (docs/decisions/durable_attempt_lease.md).
|
|
7
|
+
#
|
|
8
|
+
# WHY a shared renewer (not a heartbeat-per-turn): the previous design spawned
|
|
9
|
+
# one background thread PER in-flight turn, and each renew tick checked out its
|
|
10
|
+
# own connection via `with_connection`. At full worker-pool occupancy — the
|
|
11
|
+
# STANDARD Rails deployment shape, where the DB pool size == worker concurrency —
|
|
12
|
+
# every pooled connection is held by a busy worker, so EVERY heartbeat blocks in
|
|
13
|
+
# `with_connection` waiting for a connection that never frees. After
|
|
14
|
+
# `checkout_timeout` each heartbeat treats pool exhaustion as a lost lease → the
|
|
15
|
+
# turn yields uncommitted → the reaper requeues into the same saturated pool →
|
|
16
|
+
# livelock. No generation could complete while saturated.
|
|
17
|
+
#
|
|
18
|
+
# The fix makes renewal O(1) in connections, independent of worker concurrency:
|
|
19
|
+
# ONE renewer thread owns ONE dedicated connection (checked out for the process
|
|
20
|
+
# lifetime, NOT returned to the shared pool between ticks) and batch-renews every
|
|
21
|
+
# registered lease each tick. A `LlmCompletionJob` REGISTERS its
|
|
22
|
+
# `(loop_id, execution_token, lease_duration)` on start and DEREGISTERS in
|
|
23
|
+
# `ensure`; renewal never competes with workers for a connection again.
|
|
24
|
+
#
|
|
25
|
+
# Leak safety (does NOT depend on `ensure` running): every registration also
|
|
26
|
+
# carries a HARD CEILING derived from the OWNING AGENT'S legal turn duration —
|
|
27
|
+
# once it has been renewed for longer than `max_duration + config.lease_leak_grace`,
|
|
28
|
+
# the renewer DROPS it and stops renewing. A leaked registration (a turn that
|
|
29
|
+
# somehow skipped its `ensure`) therefore self-expires shortly after `max_duration`,
|
|
30
|
+
# its lease lapses, and the reaper reclaims the loop — so a leaked registration can
|
|
31
|
+
# never strand a loop forever. Crucially, a LEGITIMATE long turn is NEVER dropped:
|
|
32
|
+
# the agent already caps a legal turn at `max_duration` (its own contract), so any
|
|
33
|
+
# turn within that bound completes before the ceiling. (The earlier
|
|
34
|
+
# `lease_duration × K` ceiling was WRONG: a healthy turn stays registered for its
|
|
35
|
+
# whole life, so any legitimate stream longer than K lease-widths — e.g. a tiny
|
|
36
|
+
# `read_timeout` with steady chunks — was dropped and reaper-reclaimed mid-flight,
|
|
37
|
+
# and the replacement hit the same ceiling, so no long turn could ever complete.)
|
|
38
|
+
#
|
|
39
|
+
# Ownership safety is unchanged from the old per-turn heartbeat: each registered
|
|
40
|
+
# lease is renewed with the SAME conditional UPDATE keyed on
|
|
41
|
+
# `loop_id + execution_token`, gated by `lease_expires_at IS NOT NULL` so a
|
|
42
|
+
# canonical commit that NULLed the lease (tool phase) is never resurrected. A
|
|
43
|
+
# 0-row renew that is genuinely lost (rotated away / reclaimed) flips a per-
|
|
44
|
+
# registration lost flag; a 0-row renew because the lease was legitimately
|
|
45
|
+
# cleared to NULL is NOT a loss. The main path checks the per-registration flag
|
|
46
|
+
# (`check!`) before any canonical write, exactly as before.
|
|
47
|
+
#
|
|
48
|
+
# Isolation & lifecycle: the renewer connection is checked out from the SHARED
|
|
49
|
+
# pool ONCE (one extra long-lived connection for the whole process — size the
|
|
50
|
+
# pool as `worker_concurrency + 1` so it never contends with a full worker set;
|
|
51
|
+
# documented in the README) and released only when the renewer stops. Drawing
|
|
52
|
+
# from the shared pool (rather than a separate one) keeps the renewer's view of
|
|
53
|
+
# the data identical to the workers' — including under transactional test
|
|
54
|
+
# fixtures, which tie every pooled connection to one test transaction. Each renew
|
|
55
|
+
# runs inside the Rails executor so it participates in query-cache/reload/
|
|
56
|
+
# connection management. The thread is lazily started on the first registration
|
|
57
|
+
# and INTENTIONALLY runs for the whole process lifetime (there is nothing to tear
|
|
58
|
+
# down between turns — registrations come and go, the one thread + one connection
|
|
59
|
+
# persist). Its single connection is released back to the pool at process exit
|
|
60
|
+
# when the thread unwinds. `stop!`/`reset_instance!` (used by tests to prove a
|
|
61
|
+
# clean exit) are idempotent, join the thread, and let the held connection return
|
|
62
|
+
# to the pool — no thread or connection is leaked (verified by the specs'
|
|
63
|
+
# clean-exit / baseline-thread-count assertions).
|
|
64
|
+
class LeaseRenewer
|
|
65
|
+
# Never renew slower than this, even for a tiny ttl, so a pathologically small
|
|
66
|
+
# lease does not spin the DB. Mirrors the old LeaseHeartbeat::MIN_INTERVAL.
|
|
67
|
+
MIN_INTERVAL = 1.0
|
|
68
|
+
|
|
69
|
+
# A single registered in-flight lease. `lost` is the exact per-turn signal the
|
|
70
|
+
# old heartbeat exposed: set true iff a conditional renew for THIS registration
|
|
71
|
+
# touched 0 rows AND the loop is no longer ours (rotated away / reclaimed). A
|
|
72
|
+
# renew error also flips it (a dead renewer must never let a dead lease look
|
|
73
|
+
# alive). It is a `Concurrent::AtomicBoolean` so the write (on the renewer
|
|
74
|
+
# thread) and the read (on the main path) carry a proper memory barrier on
|
|
75
|
+
# every Ruby engine, not only under MRI's GVL.
|
|
76
|
+
#
|
|
77
|
+
# `registered_at` stamps when this turn registered, used to enforce a HARD
|
|
78
|
+
# CEILING (see `#ceiling_expired?`): a leaked registration (a job that never
|
|
79
|
+
# deregistered) is dropped once it exceeds the OWNING AGENT'S OWN legal turn
|
|
80
|
+
# bound — `max_duration + config.lease_leak_grace` — so its lease finally lapses
|
|
81
|
+
# and the reaper reclaims the loop, and renewer liveness no longer depends on
|
|
82
|
+
# `ensure` ever running.
|
|
83
|
+
#
|
|
84
|
+
# `max_duration` is the agent's already-enforced legal turn duration (see
|
|
85
|
+
# `SolidLoop::Base#max_duration`, default 2h): a turn longer than that is
|
|
86
|
+
# illegitimate BY THE AGENT'S OWN CONTRACT. Deriving the ceiling from it (not
|
|
87
|
+
# from `lease_duration × K`) means a LEGITIMATE turn — bounded by `max_duration`
|
|
88
|
+
# — always finishes before the ceiling and is NEVER dropped, while a genuine leak
|
|
89
|
+
# still self-expires shortly after `max_duration`. (The old `lease_duration × K`
|
|
90
|
+
# ceiling dropped healthy long turns too: an agent with a tiny `read_timeout` and
|
|
91
|
+
# steady chunks streams healthily for far longer than K lease-widths.)
|
|
92
|
+
Registration = Struct.new(:loop_id, :execution_token, :lease_duration, :max_duration, :registered_at, :lost, keyword_init: true) do
|
|
93
|
+
def lost?
|
|
94
|
+
lost.true?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def mark_lost!
|
|
98
|
+
lost.make_true
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Guards singleton creation AND teardown so two concurrent first-turns can never
|
|
103
|
+
# construct DISTINCT renewers (BLOCKER — the class-var `||=` is not atomic: two
|
|
104
|
+
# threads could both see `@instance == nil`, each `new` a renewer, each start a
|
|
105
|
+
# thread and try to check out the one process-lifetime connection; whichever lost
|
|
106
|
+
# the class-var assignment would be unreachable from `instance`/`reset_instance!`
|
|
107
|
+
# yet keep its thread — and possibly the ONLY reserved connection — running
|
|
108
|
+
# forever, reintroducing the livelock). All access to `@instance` goes through
|
|
109
|
+
# this constant mutex; the actual `stop!` runs OUTSIDE the lock so we never hold
|
|
110
|
+
# it across a thread join.
|
|
111
|
+
MUTEX = Mutex.new
|
|
112
|
+
|
|
113
|
+
class << self
|
|
114
|
+
def instance
|
|
115
|
+
MUTEX.synchronize { @instance ||= new }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Reset the process singleton (tests only): stop the running renewer and drop
|
|
119
|
+
# the memoized instance so the next registration starts a fresh one. Idempotent.
|
|
120
|
+
# The swap-to-nil is atomic under MUTEX; the (blocking, thread-joining) `stop!`
|
|
121
|
+
# runs OUTSIDE the lock so a concurrent `instance` never waits on a join.
|
|
122
|
+
def reset_instance!
|
|
123
|
+
inst = MUTEX.synchronize do
|
|
124
|
+
taken = @instance
|
|
125
|
+
@instance = nil
|
|
126
|
+
taken
|
|
127
|
+
end
|
|
128
|
+
inst&.stop!
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def initialize(logger: nil)
|
|
133
|
+
@logger = logger || (defined?(Rails) ? Rails.logger : nil)
|
|
134
|
+
@registrations = {} # key => Registration
|
|
135
|
+
@mutex = Mutex.new
|
|
136
|
+
@wake = Concurrent::Event.new
|
|
137
|
+
@stopping = Concurrent::AtomicBoolean.new(false)
|
|
138
|
+
@thread = nil
|
|
139
|
+
@tick_interval = MIN_INTERVAL
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Register an in-flight LLM turn. Returns an opaque key used to query the
|
|
143
|
+
# per-registration lost flag and to deregister. Starts the shared renewer
|
|
144
|
+
# thread on first use. The cadence is driven by the SMALLEST registered lease
|
|
145
|
+
# (~ttl/4), so a short-lived lease is still renewed in time.
|
|
146
|
+
#
|
|
147
|
+
# `max_duration` (the owning agent's already-enforced legal turn bound) sizes the
|
|
148
|
+
# per-registration leak ceiling (`max_duration + config.lease_leak_grace`). A
|
|
149
|
+
# legitimate turn — bounded by `max_duration` — always finishes before it; a
|
|
150
|
+
# genuine leak self-expires shortly after. Defaults to `config.default_max_duration`
|
|
151
|
+
# so an out-of-band caller that omits it still gets a bounded ceiling.
|
|
152
|
+
def register(loop_id:, execution_token:, lease_duration:, max_duration: nil)
|
|
153
|
+
key = [ loop_id, execution_token ]
|
|
154
|
+
reg = Registration.new(
|
|
155
|
+
loop_id: loop_id, execution_token: execution_token,
|
|
156
|
+
lease_duration: lease_duration.to_f,
|
|
157
|
+
max_duration: (max_duration || SolidLoop.config.default_max_duration).to_f,
|
|
158
|
+
registered_at: Time.current,
|
|
159
|
+
lost: Concurrent::AtomicBoolean.new(false)
|
|
160
|
+
)
|
|
161
|
+
@mutex.synchronize do
|
|
162
|
+
@registrations[key] = reg
|
|
163
|
+
recompute_interval
|
|
164
|
+
end
|
|
165
|
+
ensure_running
|
|
166
|
+
@wake.set # renew promptly so a just-registered lease is refreshed without waiting a full tick
|
|
167
|
+
key
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Deregister an in-flight turn (called from the job's `ensure`). Idempotent.
|
|
171
|
+
def deregister(key)
|
|
172
|
+
@mutex.synchronize do
|
|
173
|
+
@registrations.delete(key)
|
|
174
|
+
recompute_interval
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# True once a conditional renew for this registration touched 0 rows and the
|
|
179
|
+
# lease is no longer ours. Thread-safe; readable from the main path between
|
|
180
|
+
# chunks. Missing key (already deregistered / ceiling-dropped) reads as not-lost.
|
|
181
|
+
def lost?(key)
|
|
182
|
+
@mutex.synchronize { @registrations[key]&.lost? } || false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Whether `key` is still actively registered (renewed each tick). Drops to
|
|
186
|
+
# false on deregister OR once the hard ceiling drops a leaked registration.
|
|
187
|
+
# Thread-safe; used by specs to assert the ceiling behavior.
|
|
188
|
+
def registered?(key)
|
|
189
|
+
@mutex.synchronize { @registrations.key?(key) }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Idempotent. Stops the renewer thread, joins it, and releases the dedicated
|
|
193
|
+
# connection. Called at process exit; also used by tests to prove a clean exit.
|
|
194
|
+
def stop!
|
|
195
|
+
@stopping.make_true
|
|
196
|
+
@wake.set
|
|
197
|
+
thread = nil
|
|
198
|
+
@mutex.synchronize do
|
|
199
|
+
thread = @thread
|
|
200
|
+
@thread = nil
|
|
201
|
+
end
|
|
202
|
+
begin
|
|
203
|
+
thread&.join
|
|
204
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
205
|
+
@logger&.error("[SolidLoop] lease renewer died: #{e.class}: #{e.message}")
|
|
206
|
+
end
|
|
207
|
+
# The held connection is released back to the shared pool when the renewer
|
|
208
|
+
# thread's `with_connection` block unwinds on stop — no connection leaks.
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def ensure_running
|
|
215
|
+
return if @stopping.true?
|
|
216
|
+
|
|
217
|
+
@mutex.synchronize do
|
|
218
|
+
return if @thread&.alive?
|
|
219
|
+
|
|
220
|
+
@stopping.make_false
|
|
221
|
+
@thread = Thread.new do
|
|
222
|
+
Thread.current.name = "solid-loop-lease-renewer"
|
|
223
|
+
run_loop
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Recompute the tick cadence from the smallest registered lease (~ttl/4),
|
|
229
|
+
# floored at MIN_INTERVAL. Caller holds @mutex.
|
|
230
|
+
def recompute_interval
|
|
231
|
+
smallest = @registrations.values.map(&:lease_duration).min
|
|
232
|
+
@tick_interval = smallest ? [ smallest / 4.0, MIN_INTERVAL ].max : MIN_INTERVAL
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def run_loop
|
|
236
|
+
# ONE connection for the WHOLE renewer lifetime. Checked out ONCE here (not
|
|
237
|
+
# per tick) and held until the renewer stops. This is the BLOCKER fix:
|
|
238
|
+
# renewal's connection demand is now O(1) — a SINGLE checkout for ALL
|
|
239
|
+
# in-flight leases — instead of O(worker_concurrency) (the old design's
|
|
240
|
+
# one-thread-and-one-checkout-per-turn, which at full pool occupancy had
|
|
241
|
+
# every heartbeat blocking in `with_connection` → treating pool exhaustion as
|
|
242
|
+
# a lost lease → livelock). Because it is drawn from the SAME pool as the
|
|
243
|
+
# workers, it correctly participates in transactional test fixtures (which
|
|
244
|
+
# tie all pooled connections to one test transaction) and sees the same
|
|
245
|
+
# committed data any worker does. Operators size the pool as
|
|
246
|
+
# `worker_concurrency + 1` so this one long-lived connection never contends
|
|
247
|
+
# with a full worker set (documented in the README durability section).
|
|
248
|
+
SolidLoop::Loop.connection_pool.with_connection do
|
|
249
|
+
until @stopping.true?
|
|
250
|
+
interval = @mutex.synchronize { @tick_interval }
|
|
251
|
+
# Wait up to one tick; a register/deregister/stop wakes us immediately.
|
|
252
|
+
# Reset BEFORE the stop-check so a set that arrives during renew_all is
|
|
253
|
+
# not lost: worst case an extra (harmless, idempotent) renew pass.
|
|
254
|
+
@wake.wait(interval)
|
|
255
|
+
@wake.reset
|
|
256
|
+
break if @stopping.true?
|
|
257
|
+
|
|
258
|
+
renew_all
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
rescue StandardError => e
|
|
262
|
+
# A renewer-thread death must NEVER let dead leases look alive: mark every
|
|
263
|
+
# registered turn lost so each main path fails visibly.
|
|
264
|
+
@logger&.error("[SolidLoop] lease renewer loop error: #{e.class}: #{e.message}")
|
|
265
|
+
@mutex.synchronize { @registrations.each_value(&:mark_lost!) }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Batch-renew every currently registered lease on the renewer's held
|
|
269
|
+
# connection, inside the Rails executor. A snapshot of the registrations is
|
|
270
|
+
# taken under the mutex so a concurrent register/deregister never mutates the
|
|
271
|
+
# list mid-iteration; the lost flag is written back onto the live Registration
|
|
272
|
+
# object (safe — a deregistered key's write is simply never read).
|
|
273
|
+
def renew_all
|
|
274
|
+
executor = defined?(Rails) ? Rails.application&.executor : nil
|
|
275
|
+
if executor
|
|
276
|
+
executor.wrap { renew_each }
|
|
277
|
+
else
|
|
278
|
+
renew_each
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def renew_each
|
|
283
|
+
now = Time.current
|
|
284
|
+
|
|
285
|
+
# HARD CEILING (finding-1 at this layer): drop any registration older than the
|
|
286
|
+
# owning agent's legal turn bound (`max_duration + config.lease_leak_grace`)
|
|
287
|
+
# even if it was never deregistered. A dropped registration is no longer
|
|
288
|
+
# renewed, so its lease lapses and the reaper (case 1) reclaims the loop —
|
|
289
|
+
# renewer liveness never depends on `ensure` running. The drop happens under the
|
|
290
|
+
# mutex; the snapshot renewed below excludes the dropped entries. A LEGITIMATE
|
|
291
|
+
# turn (bounded by its agent's `max_duration`) always finishes before the
|
|
292
|
+
# ceiling, so it is never dropped.
|
|
293
|
+
snapshot = @mutex.synchronize do
|
|
294
|
+
leaked = @registrations.select { |_key, reg| ceiling_expired?(reg, now) }
|
|
295
|
+
if leaked.any?
|
|
296
|
+
leaked.each_key { |key| @registrations.delete(key) }
|
|
297
|
+
recompute_interval
|
|
298
|
+
@logger&.warn(
|
|
299
|
+
"[SolidLoop] lease renewer dropped #{leaked.size} leaked registration(s) " \
|
|
300
|
+
"past the renew ceiling (loops #{leaked.values.map(&:loop_id).inspect}); " \
|
|
301
|
+
"their leases will lapse and be reaped."
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
@registrations.values.dup
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
snapshot.each { |reg| renew_one(reg, now) }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# True once a registration has been renewed for longer than the owning agent's
|
|
311
|
+
# OWN legal turn duration allows (`max_duration + config.lease_leak_grace`). Only
|
|
312
|
+
# a LEAKED registration (never deregistered) can reach this — a legitimate turn is
|
|
313
|
+
# bounded by `max_duration` and deregisters in its `ensure` far sooner.
|
|
314
|
+
def ceiling_expired?(reg, now)
|
|
315
|
+
ceiling = reg.max_duration + SolidLoop.config.lease_leak_grace
|
|
316
|
+
(now - reg.registered_at) > ceiling
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# One conditional renew for a single registration — byte-for-byte the same
|
|
320
|
+
# ownership contract as the old per-turn heartbeat. Keyed on
|
|
321
|
+
# loop_id + execution_token, gated by `lease_expires_at IS NOT NULL` so a NULLed
|
|
322
|
+
# (tool-phase / ended) lease is never resurrected. A genuine 0-row loss flips
|
|
323
|
+
# the per-registration lost flag.
|
|
324
|
+
def renew_one(reg, now)
|
|
325
|
+
rows = SolidLoop::Loop
|
|
326
|
+
.where(id: reg.loop_id, status: "running", execution_token: reg.execution_token)
|
|
327
|
+
.where.not(lease_expires_at: nil)
|
|
328
|
+
.update_all(lease_expires_at: now + reg.lease_duration, updated_at: now)
|
|
329
|
+
|
|
330
|
+
return if rows.positive?
|
|
331
|
+
|
|
332
|
+
# 0 rows: distinguish "lost" (rotated away / reclaimed) from "already ended"
|
|
333
|
+
# (our own commit NULLed the lease). Only the former is a loss. Re-check: still
|
|
334
|
+
# running on our token ⇒ the 0-row renew was just the NULL-lease guard (a
|
|
335
|
+
# normal end-of-turn), NOT a lost lease.
|
|
336
|
+
still_ours = SolidLoop::Loop
|
|
337
|
+
.where(id: reg.loop_id, status: "running", execution_token: reg.execution_token)
|
|
338
|
+
.exists?
|
|
339
|
+
reg.mark_lost! unless still_ours
|
|
340
|
+
rescue StandardError => e
|
|
341
|
+
# A per-lease renew error is treated as a loss for THAT turn only (the others
|
|
342
|
+
# keep renewing). The main path will see the flag and fail visibly.
|
|
343
|
+
@logger&.error("[SolidLoop] lease renew error for loop #{reg.loop_id}: #{e.class}: #{e.message}")
|
|
344
|
+
reg.mark_lost!
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidLoop
|
|
4
|
+
# Value object to hold raw timing and token metrics from the network layer.
|
|
5
|
+
LlmMetrics = Struct.new(
|
|
6
|
+
:ttft, # Time to First Token
|
|
7
|
+
:duration_thinking, # Time spent in reasoning/thought
|
|
8
|
+
:duration_generation, # Total time from request start
|
|
9
|
+
:tokens_completion, # Estimated tokens during stream
|
|
10
|
+
keyword_init: true
|
|
11
|
+
) do
|
|
12
|
+
def to_h
|
|
13
|
+
super.to_h.compact
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
module Mcp
|
|
3
|
+
# Caller identity handed to Transport#deliver. HTTP transports typically
|
|
4
|
+
# ignore it (auth travels in headers); in-process transports use it to
|
|
5
|
+
# reach domain state without a network hop.
|
|
6
|
+
#
|
|
7
|
+
# Two caller shapes (see docs/decisions/mcp-server.md):
|
|
8
|
+
# in-process agent — agent/loop set, principal from Base#mcp_principal
|
|
9
|
+
# external via Server — agent/loop nil, principal = authorizer's return
|
|
10
|
+
# Handlers must not assume agent/loop are present.
|
|
11
|
+
#
|
|
12
|
+
# idempotency_key: stable per-ToolCall key for tools/call dispatches (nil
|
|
13
|
+
# for non-tool traffic such as initialize/list). It also travels on the
|
|
14
|
+
# wire as `_meta["solidloop/idempotencyKey"]`; handlers of irreversible
|
|
15
|
+
# tools should dedupe on it because a crash between a remote side effect
|
|
16
|
+
# and SolidLoop's executed_at checkpoint can re-deliver the invocation.
|
|
17
|
+
CallContext = Struct.new(:agent, :loop, :principal, :idempotency_key, keyword_init: true)
|
|
18
|
+
end
|
|
19
|
+
end
|