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,53 @@
1
+ module SolidLoop
2
+ class Pipeline
3
+ class ToolContext
4
+ # Inputs
5
+ attr_accessor :tool_call_id
6
+
7
+ # Loaded Context
8
+ attr_accessor :tool_call
9
+ attr_accessor :message
10
+ attr_accessor :loop
11
+ attr_accessor :agent
12
+ # Working State
13
+ attr_accessor :result
14
+ attr_accessor :duration
15
+ attr_accessor :is_success
16
+ attr_accessor :exception
17
+ attr_accessor :metadata # Hash for middlewares to share custom state
18
+ attr_accessor :execution_token
19
+ # The per-tool_call lease token this worker claimed. The SECOND
20
+ # fence, alongside execution_token: the canonical completion write
21
+ # re-validates BOTH under the row lock, so a stale worker whose tool lease
22
+ # was revoked by pause/reclaim writes nothing.
23
+ attr_accessor :lease_token
24
+
25
+ def initialize(tool_call_id:)
26
+ @tool_call_id = tool_call_id
27
+ @metadata = {}
28
+ end
29
+
30
+ def loop_active?
31
+ return true unless @loop
32
+ # Fail closed: a context without a generation token never owns the loop.
33
+ @loop.running? && @execution_token.present? && @loop.execution_token == @execution_token
34
+ end
35
+
36
+ def loop_active_fresh?
37
+ @loop.reload
38
+ loop_active?
39
+ end
40
+
41
+ def skip_execution!(result:, success: false, duration: 0.0)
42
+ @skip_execution = true
43
+ @result = result
44
+ @is_success = success
45
+ @duration = duration
46
+ end
47
+
48
+ def skip_execution?
49
+ @skip_execution == true
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,40 @@
1
+ require_relative "pipeline/builder"
2
+
3
+ module SolidLoop
4
+ class Pipeline
5
+ attr_reader :middlewares
6
+
7
+ def initialize(middlewares = [])
8
+ @middlewares = middlewares
9
+ end
10
+
11
+ def call(env)
12
+ runner = build_runner(@middlewares.dup)
13
+ runner.call(env)
14
+ end
15
+
16
+ private
17
+
18
+ def build_runner(middlewares)
19
+ # Base case: the innermost block does nothing (or could raise NotImplementedError if expected to be handled)
20
+ runner = ->(env) { }
21
+
22
+ # Wrap from inside out so the first middleware wraps the second, etc.
23
+ middlewares.reverse_each do |middleware_class|
24
+ next_runner = runner
25
+ runner = ->(env) do
26
+ # Instantiate the middleware passing the next step in the chain
27
+ instance = middleware_class.new(next_runner)
28
+
29
+ # We check loop_active? at every jump to short-circuit if the UI cancelled the loop
30
+ active = env.respond_to?(:loop_active_fresh?) ? env.loop_active_fresh? : env.loop_active?
31
+ return unless active
32
+
33
+ instance.call(env)
34
+ end
35
+ end
36
+
37
+ runner
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidLoop
4
+ # Idempotent recovery service for durable LLM turns and tool calls
5
+ # (docs/decisions/durable_attempt_lease.md). Host installs the recurrence via
6
+ # SolidLoop::ReaperJob (the gem does NOT own a scheduler).
7
+ #
8
+ # Each case below is a short, row-locked transaction that RE-checks its
9
+ # triggering condition under the lock, so two racing reapers on distinct
10
+ # backends resolve to exactly one acting on any given row.
11
+ #
12
+ # Cases 1, 3, 4 cover the LLM path; case 2 (per-tool_call lease reclaim) covers
13
+ # the tool path via `reap_stalled_tool_calls`.
14
+ class Reaper
15
+ # DIAGNOSTIC return value: the counters below are a
16
+ # best-effort summary of what one `reap!` pass did, intended for logging /
17
+ # liveness metrics — NOT a stable, precise public API. In particular
18
+ # `cleaned_shell_loops` counts LOOPS whose orphaned `processing` shells were
19
+ # cleaned (incremented once per loop), not the number of shell ROWS cleaned;
20
+ # the name says so to avoid the earlier "shells" mis-read (a single loop can
21
+ # own several shells). Treat the struct as a diagnostic snapshot; the field set
22
+ # may grow across releases.
23
+ Result = Struct.new(
24
+ :requeued_dead_llm, # case 1: dead LLM turns re-enqueued
25
+ :cleaned_shell_loops, # case 1 + backstop: LOOPS whose processing shells were failed+hidden (per-loop count)
26
+ :requeued_stalled_tools, # case 2: dead tool calls re-enqueued
27
+ :repaired_gaps, # case 3: dropped-enqueue repairs
28
+ :requeued_stale_queue, # case 4: lost queue rows re-enqueued
29
+ keyword_init: true
30
+ ) do
31
+ def total
32
+ requeued_dead_llm + cleaned_shell_loops + requeued_stalled_tools + repaired_gaps + requeued_stale_queue
33
+ end
34
+ end
35
+
36
+ def initialize(now: Time.current)
37
+ @now = now
38
+ @result = Result.new(
39
+ requeued_dead_llm: 0,
40
+ cleaned_shell_loops: 0,
41
+ requeued_stalled_tools: 0,
42
+ repaired_gaps: 0,
43
+ requeued_stale_queue: 0
44
+ )
45
+ end
46
+
47
+ def call
48
+ reap_expired_llm_leases # case 1
49
+ reap_stalled_tool_calls # case 2
50
+ reap_dropped_enqueues # case 3
51
+ reap_lost_queue_rows # case 4
52
+ reap_orphaned_shells # cleanup 6 backstop (paused/terminal loops)
53
+
54
+ SolidLoop.last_reaped_at = @now
55
+ @result
56
+ end
57
+
58
+ private
59
+
60
+ # Case 1 — `running` with an EXPIRED lease: the worker that held this LLM
61
+ # turn is presumed dead (a healthy worker's HTTP client would have raised
62
+ # before the lease — derived to outlive read_timeout — expired). Requeue a
63
+ # fresh LLM turn; if the loop is no longer a live LLM turn (paused/terminal/
64
+ # frozen/token gone), just clean up its orphaned processing shell.
65
+ def reap_expired_llm_leases
66
+ candidate_ids(status: "running").each do |loop_id|
67
+ loop = SolidLoop::Loop.find(loop_id)
68
+ loop.with_lock do
69
+ # with_lock reloads the row under FOR UPDATE; re-check the expiry so a
70
+ # second reaper (or a resume that already moved the loop on) does not
71
+ # double-act.
72
+ next unless loop.running?
73
+ next unless loop.lease_expires_at.present? && loop.lease_expires_at < @now
74
+
75
+ fail_and_hide_processing_shells!(loop)
76
+
77
+ if loop.admin_frozen?
78
+ # Frozen: leave status alone (admin intervention pending); the shell
79
+ # cleanup above is all that is safe. Clearing the stale lease avoids
80
+ # re-selecting this row every sweep.
81
+ loop.update!(lease_expires_at: nil)
82
+ @result.cleaned_shell_loops += 1
83
+ else
84
+ # Rotate the generation token AWAY (fences any late commit from the
85
+ # dead turn) and hand the loop back to a fresh LLM turn. The enqueue
86
+ # commits inside this same transaction (verified perform_later).
87
+ loop.update!(status: :queued, execution_token: nil, lease_expires_at: nil, error_message: nil)
88
+ SolidLoop.enqueue!(SolidLoop::LlmCompletionJob, loop.id)
89
+ @result.requeued_dead_llm += 1
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ # Case 2 — `running`, lease_expires_at NULL (tool phase, NOT an LLM turn),
96
+ # with an unresolved tool_call (executed_at IS NULL) whose per-tool lease is
97
+ # expired or absent: the worker that held that tool is presumed dead. Re-enqueue
98
+ # a ToolExecutionJob for it. HEALTHY SIBLINGS with a live lease are left
99
+ # untouched — that is the whole reason the per-tool lease exists (parallel
100
+ # tools are the shipped default).
101
+ #
102
+ # Reaper-vs-reaper dedup: under the loop row lock, CAS-claim the dead call's
103
+ # lease and hand the token to the job. Two reapers on distinct backends
104
+ # serialize on the loop lock; the loser's CAS gets 0 rows and does NOT
105
+ # re-enqueue — exactly one acts. The job adopts the reaper's lease iff it
106
+ # still holds it, and re-invokes with the SAME stable ToolCall key.
107
+ def reap_stalled_tool_calls
108
+ candidate_tool_phase_loop_ids.each do |loop_id|
109
+ loop = SolidLoop::Loop.find(loop_id)
110
+ loop.with_lock do
111
+ # Re-check the tool-phase condition under the lock: a resume/LLM claim
112
+ # that moved the loop on (lease set again, or left `running`) is not
113
+ # case 2 territory. A frozen loop awaits admin intervention.
114
+ next unless loop.running? && loop.lease_expires_at.nil?
115
+ next if loop.admin_frozen?
116
+
117
+ # Mirror the emission policy (resume! / ResponseCreation): a SEQUENTIAL
118
+ # agent has only the HEAD call in flight — reclaiming a non-head call
119
+ # too would stamp a live lease on it, making the head's later
120
+ # ResponseCreation chain-enqueue no-op (a stall). Parallel agents run
121
+ # every call at once, so reclaim each dead one independently. A missing
122
+ # agent falls back to reclaiming all (safe: the job ordering guard
123
+ # self-heals sequential order anyway).
124
+ agent = begin
125
+ loop.agent
126
+ rescue StandardError
127
+ nil
128
+ end
129
+ sequential = begin
130
+ agent&.sequential_tool_calls?
131
+ rescue StandardError
132
+ false
133
+ end
134
+ candidates = loop.unresolved_tool_calls.to_a
135
+ candidates = candidates.first(1) if sequential
136
+
137
+ candidates.each do |tool_call|
138
+ if tool_call.executed_at?
139
+ # Executed but its CONTINUATION was dropped (crash after the
140
+ # executed_at checkpoint, before the canonical response + successor
141
+ # committed). The tool BODY must NOT re-run; re-enqueue to finish the
142
+ # continuation. No lease to claim (executed calls carry none), so the
143
+ # dedup is ResponseCreation's idempotent `response_message` check —
144
+ # a duplicate re-enqueue is harmless.
145
+ SolidLoop.enqueue!(SolidLoop::ToolExecutionJob, tool_call.id, loop.execution_token)
146
+ @result.requeued_stalled_tools += 1
147
+ else
148
+ # Unexecuted: CAS-claim ONLY a lease that is free or expired; a
149
+ # live-leased (healthy, in-flight) sibling returns 0 rows and is
150
+ # skipped. Two racing reapers ⇒ the loser's CAS gets 0 rows ⇒ exactly
151
+ # one re-enqueue. Hand the reaper's token to the job so it adopts it.
152
+ reaper_token = SecureRandom.uuid
153
+ claimed = SolidLoop::ToolCall
154
+ .where(id: tool_call.id, executed_at: nil)
155
+ .where("lease_token IS NULL OR leased_until < ?", @now)
156
+ .update_all(lease_token: reaper_token, leased_until: @now + SolidLoop.config.tool_lease_duration(agent), updated_at: @now)
157
+ next if claimed.zero?
158
+
159
+ SolidLoop.enqueue!(SolidLoop::ToolExecutionJob, tool_call.id, loop.execution_token, reaper_token)
160
+ @result.requeued_stalled_tools += 1
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+
167
+ # Loops that MIGHT hold a stalled tool: `running`, in tool phase (lease NULL),
168
+ # with at least one tool_call that is EITHER unexecuted with a dead/absent
169
+ # lease OR executed-but-not-yet-continued (executed_at set but no tool response
170
+ # message committed). Both disjuncts exclude calls that already have a response
171
+ # message, so a fully-resolved loop is NOT selected. The precise per-call
172
+ # decision (and whether the call is in the current unresolved tail) is still
173
+ # made under the loop lock in `reap_stalled_tool_calls`; this only narrows the
174
+ # scan. Explicit parens around the whole (dead-lease OR dropped-continuation)
175
+ # disjunction so operator precedence can't rot into a bug.
176
+ def candidate_tool_phase_loop_ids
177
+ no_response = <<~SQL.squish
178
+ NOT EXISTS (
179
+ SELECT 1 FROM solid_loop_messages r
180
+ WHERE r.loop_id = solid_loop_loops.id
181
+ AND r.role = 'tool'
182
+ AND r.tool_call_id = solid_loop_tool_calls.tool_call_id
183
+ AND r.id > solid_loop_tool_calls.message_id
184
+ )
185
+ SQL
186
+
187
+ SolidLoop::ToolCall
188
+ .joins(message: :loop)
189
+ .where(solid_loop_loops: { status: "running", lease_expires_at: nil })
190
+ .where(no_response)
191
+ .where(
192
+ "(solid_loop_tool_calls.executed_at IS NULL " \
193
+ " AND (solid_loop_tool_calls.lease_token IS NULL OR solid_loop_tool_calls.leased_until < :now)) " \
194
+ "OR (solid_loop_tool_calls.executed_at IS NOT NULL)",
195
+ now: @now
196
+ )
197
+ .distinct
198
+ .pluck("solid_loop_loops.id")
199
+ end
200
+
201
+ # Case 3 — `running`, lease NULL (so NOT an in-flight LLM turn), and ZERO
202
+ # unresolved tool calls: the loop committed its LLM turn (cleared the lease)
203
+ # but the successor enqueue was lost, OR it landed in tool-phase with nothing
204
+ # left to do. Repair by handing it a fresh LLM turn.
205
+ #
206
+ # GUARD: a tool-phase loop that legitimately has unresolved tool calls is
207
+ # LEFT ALONE — it is waiting on a ToolExecutionJob and there is no
208
+ # tool lease yet, so reclaiming it is case 2's job. This method must only
209
+ # ever act when there are genuinely no unresolved tools.
210
+ def reap_dropped_enqueues
211
+ SolidLoop::Loop
212
+ .where(status: "running", lease_expires_at: nil)
213
+ .pluck(:id)
214
+ .each do |loop_id|
215
+ loop = SolidLoop::Loop.find(loop_id)
216
+ loop.with_lock do
217
+ next unless loop.running? && loop.lease_expires_at.nil?
218
+ # Never resurrect a frozen loop (admin intervention pending). This
219
+ # also stops case 1's frozen-clean branch — which nulls the lease —
220
+ # from being picked up here and requeued a sweep later.
221
+ next if loop.admin_frozen?
222
+ # Under the lock, re-confirm there is nothing in flight and nothing
223
+ # to resolve. A non-empty set => tool phase => case 2's territory.
224
+ next if loop.unresolved_tool_calls.exists?
225
+
226
+ loop.update!(status: :queued, execution_token: nil, error_message: nil, lease_expires_at: nil)
227
+ SolidLoop.enqueue!(SolidLoop::LlmCompletionJob, loop.id)
228
+ @result.repaired_gaps += 1
229
+ end
230
+ end
231
+ end
232
+
233
+ # Case 4 — `queued` longer than the configured threshold: its enqueued
234
+ # LlmCompletionJob was probably lost. Re-enqueue; the claim CAS makes a
235
+ # duplicate delivery a harmless no-op.
236
+ #
237
+ # Cleanup 7 — "two racing reapers ⇒ exactly one acts" made true for case 4:
238
+ # BUMP `updated_at` to now inside the same row-locked txn BEFORE the enqueue.
239
+ # The second reaper serializes on the loop lock and then re-reads
240
+ # `updated_at < threshold`, which is now false, so it does NOT re-enqueue.
241
+ # (The claim CAS would already dedupe the LlmCompletionJob delivery, but the
242
+ # bump makes the reaper-vs-reaper resolution hold at the reaper layer too, so
243
+ # the ADR's "one winner" statement is literally true — not merely
244
+ # "duplicate-but-harmless".)
245
+ def reap_lost_queue_rows
246
+ threshold = @now - SolidLoop.config.queued_reap_threshold
247
+
248
+ SolidLoop::Loop
249
+ .where(status: "queued")
250
+ .where("updated_at < ?", threshold)
251
+ .pluck(:id)
252
+ .each do |loop_id|
253
+ loop = SolidLoop::Loop.find(loop_id)
254
+ loop.with_lock do
255
+ next unless loop.queued? && loop.updated_at < threshold
256
+
257
+ # Marker bump first: fences the losing reaper's re-check above.
258
+ loop.update_column(:updated_at, @now)
259
+ SolidLoop.enqueue!(SolidLoop::LlmCompletionJob, loop.id)
260
+ @result.requeued_stale_queue += 1
261
+ end
262
+ end
263
+ end
264
+
265
+ # Cleanup 6 backstop — pause / admin-freeze / force-stop already SYNCHRONOUSLY
266
+ # fail+hide their `processing` assistant shells, but a hard kill between the
267
+ # status transition and that cleanup (or a legacy row) could leave a paused/
268
+ # terminal loop holding a hidden `processing` shell forever. Cases 1–4 only
269
+ # scan `running` loops, so this sweep catches the paused/terminal ones. It is
270
+ # idempotent (a shell already failed is not re-selected) and row-locked so two
271
+ # reapers don't double-write.
272
+ def reap_orphaned_shells
273
+ loop_ids = SolidLoop::Message
274
+ .joins(:loop)
275
+ .where(role: "assistant", status: "processing")
276
+ .where(solid_loop_loops: { status: %w[paused completed failed] })
277
+ .distinct
278
+ .pluck("solid_loop_loops.id")
279
+
280
+ loop_ids.each do |loop_id|
281
+ loop = SolidLoop::Loop.find(loop_id)
282
+ loop.with_lock do
283
+ # Re-check under the lock: a resume could have moved the loop back to
284
+ # `running` (its shell is legitimately in flight again) between the scan
285
+ # and the lock — leave those alone.
286
+ next if loop.running? || loop.queued? || loop.init?
287
+
288
+ shells = loop.messages.where(role: "assistant", status: "processing")
289
+ next unless shells.exists?
290
+
291
+ shells.find_each { |shell| shell.update!(status: "failed", is_hidden: true) }
292
+ @result.cleaned_shell_loops += 1
293
+ end
294
+ end
295
+ end
296
+
297
+ # Fail + hide any `processing` assistant/streaming shell for the loop,
298
+ # preserving partial content for audit. Force-stop/reclaim must never mark a
299
+ # partial `success` (born-hidden shell protocol).
300
+ def fail_and_hide_processing_shells!(loop)
301
+ loop.messages.where(role: "assistant", status: "processing").find_each do |shell|
302
+ shell.update!(status: "failed", is_hidden: true)
303
+ end
304
+ end
305
+
306
+ def candidate_ids(status:)
307
+ SolidLoop::Loop
308
+ .where(status: status)
309
+ .where("lease_expires_at < ?", @now)
310
+ .pluck(:id)
311
+ end
312
+ end
313
+ end
@@ -0,0 +1,3 @@
1
+ module SolidLoop
2
+ VERSION = "0.0.4"
3
+ end
data/lib/solid_loop.rb ADDED
@@ -0,0 +1,94 @@
1
+ require "solid_loop/version"
2
+ require "solid_loop/engine"
3
+ require "solid_loop/mcp_client"
4
+ require "solid_loop/configuration"
5
+ require "solid_loop/lease_renewer"
6
+ require "solid_loop/lease_heartbeat"
7
+ require "solid_loop/reaper"
8
+
9
+ module SolidLoop
10
+ class CancellationError < StandardError; end
11
+ class LimitExceededError < StandardError; end
12
+ # Raised on the main LLM-turn path when the background lease heartbeat has
13
+ # LOST the lease (a conditional renew touched 0 rows — the generation token
14
+ # rotated away via pause/stop/reclaim, or the reaper already reclaimed the
15
+ # turn). The main path must raise this BEFORE any canonical write so a worker
16
+ # that no longer owns the loop never commits a turn. See LeaseHeartbeat.
17
+ class LostLease < StandardError; end
18
+ # Raised when a steering operation is invalid: enqueue on a terminal loop, or
19
+ # edit/cancel of an already-delivered (or non-steering) message.
20
+ class SteeringError < StandardError; end
21
+ # Raised when a successor job could not be enqueued (ActiveJob `perform_later`
22
+ # returned a non-`successfully_enqueued?` job — e.g. a `before_enqueue`
23
+ # callback did `throw :abort`). Every dispatch site runs inside a DB
24
+ # transaction, so raising here rolls back the enclosing state change instead
25
+ # of committing it with no successor job to carry the loop forward.
26
+ class EnqueueError < StandardError; end
27
+
28
+ class << self
29
+ # Enqueue a successor job, raising unless ActiveJob confirms it was actually
30
+ # enqueued. `perform_later` returns `false` when an enqueue callback aborts;
31
+ # left unchecked, callers would commit state (a queued loop, a persisted
32
+ # tool-call row) with no job to advance it. Returns the enqueued job.
33
+ def enqueue!(job_class, *args)
34
+ job = job_class.perform_later(*args)
35
+
36
+ unless job.is_a?(ActiveJob::Base) && job.successfully_enqueued?
37
+ raise EnqueueError,
38
+ "Failed to enqueue #{job_class.name}(#{args.map(&:inspect).join(', ')}): " \
39
+ "perform_later returned #{job.inspect} (enqueue aborted?)"
40
+ end
41
+
42
+ job
43
+ end
44
+
45
+ # Optional host-app hook for the admin dashboard to deep-link a loop's
46
+ # polymorphic subject (which the engine can't route to on its own). A
47
+ # callable taking the loop and returning a URL string, or a Hash
48
+ # { url:, label: }. Left nil by default — the admin degrades to plain text.
49
+ attr_accessor :subject_resolver
50
+
51
+ # Durable-lease + reaper configuration (see docs/decisions/durable_attempt_lease.md).
52
+ def config
53
+ @config ||= Configuration.new
54
+ end
55
+
56
+ # Reap dead/lost LLM turns and repair lost queue rows. Idempotent; safe to
57
+ # run concurrently (each case re-checks its condition under a row lock).
58
+ # Returns the SolidLoop::Reaper::Result. Host installs the recurrence via
59
+ # SolidLoop::ReaperJob — the gem does NOT own a scheduler.
60
+ def reap!(now: Time.current)
61
+ Reaper.new(now: now).call
62
+ end
63
+
64
+ # Timestamp of the last successful `reap!` — a liveness health signal for
65
+ # hosts to alert on ("has the reaper run recently?"). nil until the first
66
+ # reap completes. Process-local (each worker tracks its own last run).
67
+ attr_accessor :last_reaped_at
68
+
69
+ def llm_middlewares
70
+ @llm_middlewares ||= Pipeline::Builder.new([
71
+ Middlewares::ErrorHandling,
72
+ Middlewares::AgentInitialization,
73
+ Middlewares::MessageBuilding,
74
+ Middlewares::EventLogging,
75
+ Middlewares::NetworkCalling,
76
+ Middlewares::ResponseParsing
77
+ ])
78
+ end
79
+
80
+ def tool_middlewares
81
+ @tool_middlewares ||= Pipeline::Builder.new([
82
+ ToolMiddlewares::ErrorHandling,
83
+ ToolMiddlewares::AgentInitialization,
84
+ ToolMiddlewares::EventLogging,
85
+ ToolMiddlewares::ToolExecution,
86
+ ToolMiddlewares::ResponseCreation
87
+ ])
88
+ end
89
+
90
+ def configure
91
+ yield self
92
+ end
93
+ end
94
+ end