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.
Files changed (137) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +156 -0
  3. data/CODE_OF_CONDUCT.md +67 -0
  4. data/CONTRIBUTING.md +82 -0
  5. data/MIT-LICENSE +21 -0
  6. data/README.md +483 -0
  7. data/Rakefile +11 -0
  8. data/app/assets/javascripts/solid_loop/chart.umd.min.js +14 -0
  9. data/app/assets/javascripts/solid_loop/chartjs-adapter-date-fns.bundle.min.js +7 -0
  10. data/app/assets/javascripts/solid_loop/chartkick.min.js +2 -0
  11. data/app/assets/stylesheets/solid_loop/application.css +15 -0
  12. data/app/controllers/solid_loop/application_controller.rb +29 -0
  13. data/app/controllers/solid_loop/dashboard_controller.rb +104 -0
  14. data/app/controllers/solid_loop/events_controller.rb +12 -0
  15. data/app/controllers/solid_loop/loops_controller.rb +77 -0
  16. data/app/controllers/solid_loop/mcp_inbound_sessions_controller.rb +13 -0
  17. data/app/controllers/solid_loop/mcp_sessions_controller.rb +68 -0
  18. data/app/controllers/solid_loop/mcp_tools_controller.rb +16 -0
  19. data/app/controllers/solid_loop/messages_controller.rb +12 -0
  20. data/app/controllers/solid_loop/tool_calls_controller.rb +12 -0
  21. data/app/helpers/solid_loop/application_helper.rb +125 -0
  22. data/app/jobs/solid_loop/application_job.rb +13 -0
  23. data/app/jobs/solid_loop/llm_completion_job.rb +139 -0
  24. data/app/jobs/solid_loop/observe_broadcast_job.rb +15 -0
  25. data/app/jobs/solid_loop/reaper_job.rb +20 -0
  26. data/app/jobs/solid_loop/tool_execution_job.rb +200 -0
  27. data/app/models/solid_loop/application_record.rb +5 -0
  28. data/app/models/solid_loop/base.rb +190 -0
  29. data/app/models/solid_loop/event.rb +7 -0
  30. data/app/models/solid_loop/loop.rb +225 -0
  31. data/app/models/solid_loop/mcp_inbound_session.rb +25 -0
  32. data/app/models/solid_loop/mcp_session.rb +12 -0
  33. data/app/models/solid_loop/mcp_tool.rb +26 -0
  34. data/app/models/solid_loop/message.rb +69 -0
  35. data/app/models/solid_loop/tool_call.rb +42 -0
  36. data/app/queries/solid_loop/admin/base_query.rb +23 -0
  37. data/app/queries/solid_loop/admin/events_query.rb +31 -0
  38. data/app/queries/solid_loop/admin/loops_query.rb +38 -0
  39. data/app/queries/solid_loop/admin/mcp_inbound_sessions_query.rb +31 -0
  40. data/app/queries/solid_loop/admin/mcp_sessions_query.rb +26 -0
  41. data/app/queries/solid_loop/admin/mcp_tools_query.rb +26 -0
  42. data/app/queries/solid_loop/admin/messages_query.rb +37 -0
  43. data/app/queries/solid_loop/admin/tool_calls_query.rb +34 -0
  44. data/app/services/solid_loop/adapters/native.rb +170 -0
  45. data/app/services/solid_loop/dialects/anthropic.rb +160 -0
  46. data/app/services/solid_loop/dialects/gemini.rb +122 -0
  47. data/app/services/solid_loop/dialects/open_ai.rb +62 -0
  48. data/app/services/solid_loop/dialects/reasoning_packer.rb +37 -0
  49. data/app/services/solid_loop/llm_usage_parser/anthropic.rb +22 -0
  50. data/app/services/solid_loop/llm_usage_parser/gemini.rb +18 -0
  51. data/app/services/solid_loop/llm_usage_parser/llama.rb +15 -0
  52. data/app/services/solid_loop/llm_usage_parser/openai.rb +18 -0
  53. data/app/services/solid_loop/llm_usage_parser.rb +19 -0
  54. data/app/services/solid_loop/mcp_session_initializer.rb +205 -0
  55. data/app/services/solid_loop/mcp_tool_execution_service.rb +153 -0
  56. data/app/services/solid_loop/middlewares/agent_initialization.rb +37 -0
  57. data/app/services/solid_loop/middlewares/error_handling.rb +107 -0
  58. data/app/services/solid_loop/middlewares/event_logging.rb +100 -0
  59. data/app/services/solid_loop/middlewares/message_building.rb +92 -0
  60. data/app/services/solid_loop/middlewares/network_calling.rb +84 -0
  61. data/app/services/solid_loop/middlewares/response_parsing.rb +348 -0
  62. data/app/services/solid_loop/middlewares/tool_call_xml_parser.rb +117 -0
  63. data/app/services/solid_loop/sse_stream_aggregator.rb +105 -0
  64. data/app/services/solid_loop/tool_failure_reconciler.rb +122 -0
  65. data/app/services/solid_loop/tool_middlewares/agent_initialization.rb +17 -0
  66. data/app/services/solid_loop/tool_middlewares/error_handling.rb +66 -0
  67. data/app/services/solid_loop/tool_middlewares/event_logging.rb +27 -0
  68. data/app/services/solid_loop/tool_middlewares/response_creation.rb +110 -0
  69. data/app/services/solid_loop/tool_middlewares/tool_execution.rb +206 -0
  70. data/app/views/layouts/solid_loop/admin.html.erb +416 -0
  71. data/app/views/layouts/solid_loop/application.html.erb +17 -0
  72. data/app/views/solid_loop/dashboard/index.html.erb +184 -0
  73. data/app/views/solid_loop/events/index.html.erb +47 -0
  74. data/app/views/solid_loop/events/show.html.erb +76 -0
  75. data/app/views/solid_loop/loops/index.html.erb +83 -0
  76. data/app/views/solid_loop/loops/show.html.erb +148 -0
  77. data/app/views/solid_loop/mcp_inbound_sessions/index.html.erb +53 -0
  78. data/app/views/solid_loop/mcp_inbound_sessions/show.html.erb +78 -0
  79. data/app/views/solid_loop/mcp_sessions/_tool_output.html.erb +11 -0
  80. data/app/views/solid_loop/mcp_sessions/index.html.erb +46 -0
  81. data/app/views/solid_loop/mcp_sessions/inspector.html.erb +94 -0
  82. data/app/views/solid_loop/mcp_sessions/show.html.erb +142 -0
  83. data/app/views/solid_loop/mcp_tools/index.html.erb +44 -0
  84. data/app/views/solid_loop/mcp_tools/show.html.erb +69 -0
  85. data/app/views/solid_loop/messages/_message.html.erb +75 -0
  86. data/app/views/solid_loop/messages/index.html.erb +80 -0
  87. data/app/views/solid_loop/messages/show.html.erb +121 -0
  88. data/app/views/solid_loop/shared/_pagination.html.erb +21 -0
  89. data/app/views/solid_loop/tool_calls/index.html.erb +53 -0
  90. data/app/views/solid_loop/tool_calls/show.html.erb +59 -0
  91. data/config/routes.rb +27 -0
  92. data/db/migrate/20260408000100_solid_loop_init.rb +136 -0
  93. data/db/migrate/20260709000100_solid_loop_create_mcp_inbound_sessions.rb +16 -0
  94. data/db/migrate/20260713000100_solid_loop_add_execution_guards.rb +6 -0
  95. data/db/migrate/20260714000100_solid_loop_add_message_steering.rb +17 -0
  96. data/db/migrate/20260714000200_solid_loop_add_message_conversation_order.rb +14 -0
  97. data/db/migrate/20260715000100_solid_loop_add_mcp_inbound_session_principal_key.rb +43 -0
  98. data/db/migrate/20260715000200_solid_loop_add_llm_lease.rb +40 -0
  99. data/db/migrate/20260715000300_solid_loop_add_tool_lease.rb +33 -0
  100. data/db/migrate/20260715000400_solid_loop_add_lease_running_check.rb +32 -0
  101. data/db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb +35 -0
  102. data/docs/contributing/coverage.md +64 -0
  103. data/docs/decisions/durable_attempt_lease.md +364 -0
  104. data/docs/decisions/mcp-only-tooling.md +135 -0
  105. data/docs/decisions/mcp-server.md +223 -0
  106. data/docs/decisions/reasoning_persistence.md +51 -0
  107. data/docs/decisions/ruby_llm_rejected.md +35 -0
  108. data/docs/guides/dialects.md +55 -0
  109. data/docs/guides/llm_middlewares.md +294 -0
  110. data/docs/guides/mcp_transports.md +374 -0
  111. data/docs/guides/tool_middlewares.md +148 -0
  112. data/lib/solid_loop/configuration.rb +175 -0
  113. data/lib/solid_loop/engine.rb +14 -0
  114. data/lib/solid_loop/lease_heartbeat.rb +94 -0
  115. data/lib/solid_loop/lease_renewer.rb +347 -0
  116. data/lib/solid_loop/llm_metrics.rb +16 -0
  117. data/lib/solid_loop/mcp/call_context.rb +19 -0
  118. data/lib/solid_loop/mcp/client_factory.rb +61 -0
  119. data/lib/solid_loop/mcp/http_transport.rb +52 -0
  120. data/lib/solid_loop/mcp/principal.rb +82 -0
  121. data/lib/solid_loop/mcp/result.rb +13 -0
  122. data/lib/solid_loop/mcp/server.rb +246 -0
  123. data/lib/solid_loop/mcp/stdio_transport.rb +224 -0
  124. data/lib/solid_loop/mcp/toolset.rb +347 -0
  125. data/lib/solid_loop/mcp/transport.rb +20 -0
  126. data/lib/solid_loop/mcp.rb +25 -0
  127. data/lib/solid_loop/mcp_client.rb +176 -0
  128. data/lib/solid_loop/pipeline/builder.rb +38 -0
  129. data/lib/solid_loop/pipeline/context.rb +61 -0
  130. data/lib/solid_loop/pipeline/tool_context.rb +53 -0
  131. data/lib/solid_loop/pipeline.rb +40 -0
  132. data/lib/solid_loop/reaper.rb +313 -0
  133. data/lib/solid_loop/version.rb +3 -0
  134. data/lib/solid_loop.rb +94 -0
  135. data/lib/tasks/coverage.rake +206 -0
  136. data/lib/tasks/solid_loop_tasks.rake +4 -0
  137. 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