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,125 @@
1
+ module SolidLoop
2
+ module ApplicationHelper
3
+ # ---- Money -------------------------------------------------------------
4
+ # One money formatter used everywhere in the admin so the same cost never
5
+ # reads as "$0.09" in one place, "$0.0248" in another and "$0.00029" in a
6
+ # third. Shows 2 dp for "normal" amounts but keeps enough significant
7
+ # figures that a sub-cent run never collapses to "$0.00".
8
+ #
9
+ # 1.2345 -> "$1.23"
10
+ # 0.0248 -> "$0.025"
11
+ # 0.00029 -> "$0.00029"
12
+ # 0.0 -> "$0.00"
13
+ # -0.005 -> "-$0.0050"
14
+ def sl_money(amount)
15
+ value = amount.to_f
16
+ return "$0.00" if value.zero?
17
+
18
+ abs = value.abs
19
+ # Default to 2 dp so "normal" money reads $0.09 / $1.23. But when 2 dp
20
+ # would lose precision (0.0248 -> 0.02) OR round a real amount away to
21
+ # zero (0.00029 -> 0.00), extend precision to ~2 significant figures so
22
+ # sub-cent runs stay legible instead of collapsing.
23
+ precision = 2
24
+ if abs < 0.1 && (abs - abs.round(2)).abs > Float::EPSILON
25
+ precision = [(-Math.log10(abs)).floor + 2, 8].min
26
+ end
27
+
28
+ # Sign goes before the "$" (never "$-0.0050").
29
+ "#{'-' if value.negative?}$#{number_with_precision(abs, precision: precision)}"
30
+ end
31
+
32
+ # ---- Failure classification -------------------------------------------
33
+ # A loop's terminal error is captured only as a free-text `error_message`
34
+ # (e.g. "HTTP 403: ...", "Faraday::ConnectionFailed: ...", or an agent-side
35
+ # exception). Classify without a schema change by pattern-matching that
36
+ # string: provider/transport problems (HTTP status, Faraday, JSON parse of
37
+ # the wire response, PII-redaction rejection) are INFRA failures — not agent
38
+ # bugs. Everything else that reached `failed` is an AGENT failure.
39
+ #
40
+ # Returns :infra, :agent, or nil (loop is not failed / has no message).
41
+ def sl_failure_kind(loop)
42
+ return nil unless loop.status == "failed"
43
+
44
+ SolidLoop::ApplicationHelper.classify_failure(loop.error_message)
45
+ end
46
+
47
+ # Module-level so read models / KPIs can reuse the same rules as the view.
48
+ INFRA_ERROR_PATTERNS = [
49
+ /\AHTTP \d{3}\b/, # provider returned a non-2xx envelope
50
+ /\bFaraday::/, # transport / connection error class
51
+ /\bJSON::ParserError\b/, # unparseable provider response body
52
+ /invalid_json_after_redaction/, # OpenRouter PII-redaction mangled the body
53
+ /PII detected/i,
54
+ /\b(?:timed out|timeout|connection (?:refused|reset))\b/i
55
+ ].freeze
56
+
57
+ # Agent-side code bugs — even when the text happens to mention "timeout" or
58
+ # "connection" — must NOT be mislabelled infra (e.g. a NoMethodError whose
59
+ # message references a `timeout` method). Checked before the infra patterns.
60
+ CODE_ERROR_PREFIX = /\A(?:NoMethodError|NameError|ArgumentError|TypeError|KeyError|IndexError|RuntimeError|NotImplementedError|FrozenError|ZeroDivisionError|NoMatchingPatternError)\b/
61
+
62
+ def self.classify_failure(error_message)
63
+ msg = error_message.to_s.strip
64
+ return :agent if msg.blank?
65
+ return :agent if msg.match?(CODE_ERROR_PREFIX)
66
+
67
+ INFRA_ERROR_PATTERNS.any? { |re| msg.match?(re) } ? :infra : :agent
68
+ end
69
+
70
+ # A short, human failure reason for inline display / tooltips. Trims the
71
+ # noisy body so a table cell stays readable; full text is on the loop page.
72
+ def sl_failure_reason(loop, limit: 140)
73
+ msg = loop.error_message.to_s.strip
74
+ return nil if msg.empty?
75
+
76
+ msg = msg.gsub(/\s+/, " ")
77
+ msg.length > limit ? "#{msg[0, limit - 1]}…" : msg
78
+ end
79
+
80
+ # ---- Subject linking ---------------------------------------------------
81
+ # A loop's polymorphic subject ("User #2") is an app record the engine
82
+ # can't route to. Host apps register a resolver returning a URL (and,
83
+ # optionally, a richer label) so the admin can deep-link to it. When no
84
+ # resolver is configured we degrade to the plain "Type #id" text.
85
+ #
86
+ # SolidLoop.subject_resolver = ->(loop) {
87
+ # { url: main_app.user_path(loop.subject_id), label: loop.subject&.name }
88
+ # }
89
+ def sl_subject_link(loop)
90
+ return nil if loop.subject_type.blank? && loop.subject_id.blank?
91
+
92
+ resolved = sl_resolve_subject(loop)
93
+ label = resolved[:label].presence || "#{loop.subject_type} ##{loop.subject_id}"
94
+ url = sl_safe_subject_url(resolved[:url])
95
+
96
+ url ? link_to(label, url) : label
97
+ end
98
+
99
+ private
100
+
101
+ # The subject_resolver is a host-app hook — treat its URL as untrusted.
102
+ # Allow only relative paths or http(s); drop javascript:/data:/other schemes
103
+ # and protocol-relative "//host" so a resolver can't inject a clickable XSS
104
+ # link into the admin. Unsafe -> nil (renders the label as plain text).
105
+ def sl_safe_subject_url(url)
106
+ url = url.to_s.strip
107
+ return nil if url.empty?
108
+ return url if url.start_with?("/") && !url.start_with?("//")
109
+ return url if url.match?(%r{\Ahttps?://}i)
110
+
111
+ nil
112
+ end
113
+
114
+ def sl_resolve_subject(loop)
115
+ resolver = SolidLoop.respond_to?(:subject_resolver) ? SolidLoop.subject_resolver : nil
116
+ return {} unless resolver
117
+
118
+ result = resolver.call(loop)
119
+ result.is_a?(Hash) ? result : { url: result }
120
+ rescue StandardError => e
121
+ Rails.logger.warn("SolidLoop subject_resolver raised: #{e.class}: #{e.message}")
122
+ {}
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,13 @@
1
+ module SolidLoop
2
+ class ApplicationJob < ActiveJob::Base
3
+ # SolidLoop requires a same-database transactional job backend (Solid
4
+ # Queue / GoodJob in the app database). Successor jobs are enqueued INSIDE
5
+ # the transaction that advances loop state, so state + dispatch commit
6
+ # atomically — a crash or rollback leaves neither. The enqueue must
7
+ # therefore not be deferred to after-commit. (Rails 7.1 has no such
8
+ # deferral; the attribute exists from 7.2.)
9
+ if respond_to?(:enqueue_after_transaction_commit=)
10
+ self.enqueue_after_transaction_commit = false
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,139 @@
1
+ module SolidLoop
2
+ class LlmCompletionJob < ApplicationJob
3
+ queue_as :solid_loop_llm
4
+
5
+ READ_TIMEOUT = 600 # 10 minutes
6
+ EVENTS_SAVE_BEFORE_REQUEST = true # two phase event save, good for slow models debugging
7
+ HTTP_STREAMING_UPDATE_MODEL_INTERVAL = 2 # seconds - update model content every N seconds
8
+
9
+ def perform(loop_id)
10
+ execution_token = SecureRandom.uuid
11
+
12
+ # Derive the lease from the agent's own read_timeout (+ margin) so it is
13
+ # guaranteed to outlive the HTTP client — a healthy in-flight turn can then
14
+ # never be reclaimed by the reaper. Resolved before the CAS so the same
15
+ # atomic transition to `running` also stamps `lease_expires_at`.
16
+ loop_for_lease = SolidLoop::Loop.find(loop_id)
17
+ begin
18
+ agent_for_lease = loop_for_lease.agent
19
+ rescue NameError => e
20
+ # A renamed/missing agent class: fail the still-`queued` loop
21
+ # VISIBLY instead of raising forever on every retry. No token/lease is
22
+ # held yet, so this is a plain queued→failed transition.
23
+ Rails.logger.error "LlmCompletionJob: cannot resolve agent for loop #{loop_id}: #{e.class}: #{e.message}"
24
+ # from: :queued only — a missing agent fails BEFORE any CAS claim, so no
25
+ # worker can ever hold this loop in `running`; fencing to :queued keeps
26
+ # this consistent with every other transition and can't stomp a sibling.
27
+ loop_for_lease.transition_status(
28
+ from: :queued,
29
+ to: :failed,
30
+ error_message: "#{e.class}: #{e.message}",
31
+ execution_token: nil,
32
+ lease_expires_at: nil
33
+ )
34
+ return
35
+ end
36
+ return unless agent_for_lease # nil agent_class_name: nothing to run
37
+
38
+ lease_duration = SolidLoop.config.llm_lease_duration(agent_for_lease)
39
+ lease_expires_at = Time.current + lease_duration
40
+
41
+ claimed = SolidLoop::Loop
42
+ .where(id: loop_id, status: :queued)
43
+ .update_all(
44
+ status: :running,
45
+ execution_token: execution_token,
46
+ lease_expires_at: lease_expires_at,
47
+ updated_at: Time.current
48
+ )
49
+ return if claimed == 0
50
+
51
+ loop_record = nil
52
+
53
+ # Reconciliation wrapper: wrap the ENTIRE post-claim job body —
54
+ # the Context build, the second `agent` resolution, middleware config, and
55
+ # the whole pipeline — so a non-graceful error (a NoMethodError from a
56
+ # renamed agent, a bug in a middleware) fails the loop VISIBLY instead of
57
+ # stranding it `running`. Hard kills still fall through to the reaper. The
58
+ # inner ErrorHandling middleware still terminalizes its graceful allowlist
59
+ # first; this only catches what escapes it.
60
+ begin
61
+ # Heartbeat the lease for the ENTIRE claimed interval.
62
+ # The heartbeat wrapper opens IMMEDIATELY after the successful claim, so
63
+ # it covers the CAS→`Context.new`→second-`agent`→config window too — making
64
+ # "the entire claimed interval is heartbeated" literally true (previously
65
+ # Context.new/agent/config ran BEFORE the heartbeat started, an unheartbeated
66
+ # gap). It covers MCP init (AgentInitialization), the whole stream incl.
67
+ # silent prefill (NetworkCalling), and finalization. `read_timeout` is an
68
+ # inactivity timeout, not a total-duration bound, so the static claim-time
69
+ # lease can expire on a HEALTHY worker; the heartbeat renews it at ~ttl/4
70
+ # keyed on loop_id + execution_token, and flips a lost-lease flag if the
71
+ # generation rotated away. The heartbeat still stops in `run`'s ensure
72
+ # BEFORE any canonical completion.
73
+ SolidLoop::LeaseHeartbeat.run(
74
+ loop_id: loop_id,
75
+ execution_token: execution_token,
76
+ lease_duration: lease_duration,
77
+ # The agent's own legal turn bound sizes the renewer's leak ceiling
78
+ # (`max_duration + lease_leak_grace`), so a legitimate turn — capped at
79
+ # max_duration by the agent's contract — is never dropped, while a genuine
80
+ # leak self-expires shortly after max_duration.
81
+ max_duration: agent_for_lease.max_duration,
82
+ logger: Rails.logger
83
+ ) do |heartbeat|
84
+ context = SolidLoop::Pipeline::Context.new(loop_id: loop_id, execution_token: execution_token)
85
+ loop_record = context.loop
86
+ agent = loop_record.agent
87
+ context.heartbeat = heartbeat
88
+
89
+ builder = SolidLoop::Pipeline::Builder.new(SolidLoop.llm_middlewares.middlewares)
90
+ agent.configure_llm_middlewares(builder)
91
+
92
+ pipeline = SolidLoop::Pipeline.new(builder.middlewares)
93
+ pipeline.call(context)
94
+ end
95
+ rescue SolidLoop::LostLease => e
96
+ # The heartbeat lost the lease mid-turn (generation rotated away or the
97
+ # reaper reclaimed us). We DON'T own the loop, so we write nothing and do
98
+ # NOT fail it — recovery belongs to whoever now owns the generation (the
99
+ # resume, or the reaper's re-enqueue). Swallow so ActiveJob doesn't retry.
100
+ Rails.logger.info "LlmCompletionJob: lease lost for loop #{loop_id}, yielding turn: #{e.message}"
101
+ rescue StandardError => e
102
+ # If we owned the loop we reconciled it to `failed` (durable) — swallow so
103
+ # ActiveJob does not retry endlessly (the CAS would no-op anyway). If we
104
+ # did NOT own it (a concurrent pause/stop/reclaim already rotated the
105
+ # token), re-raise so the genuinely-unhandled error stays visible.
106
+ raise unless reconcile_failure!(loop_record, execution_token, e)
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ # Fence-checked reconciliation: only mutate if THIS generation still owns the
113
+ # loop (running + our token) under the row lock. A stale worker (pause/stop/
114
+ # reclaim already rotated the token) writes nothing. Marks any processing
115
+ # shell failed+hidden, fails the loop, and clears the token + lease atomically.
116
+ def reconcile_failure!(loop_record, execution_token, error)
117
+ # Context build itself failed before we could load the loop: nothing to
118
+ # reconcile against here. Re-raise so the error stays visible (a missing
119
+ # loop row is not a stale-worker no-op).
120
+ return false unless loop_record
121
+
122
+ error_msg = "#{error.class}: #{error.message}"
123
+ Rails.logger.error "LlmCompletionJob reconciling uncaught error: #{error_msg}\n#{error.backtrace&.take(10)&.join("\n")}"
124
+
125
+ loop_record.transition_status(
126
+ from: :running,
127
+ to: :failed,
128
+ expected_execution_token: execution_token,
129
+ error_message: error_msg,
130
+ execution_token: nil,
131
+ lease_expires_at: nil
132
+ ) do
133
+ loop_record.messages.where(role: "assistant", status: "processing").find_each do |shell|
134
+ shell.update!(status: "failed", is_hidden: true)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,15 @@
1
+ module SolidLoop
2
+ class ObserveBroadcastJob < ApplicationJob
3
+ def perform(message_id)
4
+ message = SolidLoop::Message.find_by(id: message_id)
5
+ return unless message
6
+
7
+ Turbo::StreamsChannel.broadcast_append_to(
8
+ "solid_loop:loop:#{message.loop_id}",
9
+ target: "sl-messages",
10
+ partial: "solid_loop/messages/message",
11
+ locals: { message: message }
12
+ )
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ module SolidLoop
2
+ # A plain entry point for `SolidLoop.reap!`, so a host can install the
3
+ # recurrence with whatever scheduler it already runs (good_job cron,
4
+ # solid_queue recurring, sidekiq-scheduler, a clock process, …). The gem does
5
+ # NOT hardcode a scheduler — see docs/decisions/durable_attempt_lease.md.
6
+ #
7
+ # # good_job (config/initializers or good_job.rb):
8
+ # config.good_job.cron = {
9
+ # solid_loop_reaper: { cron: "* * * * *", class: "SolidLoop::ReaperJob" }
10
+ # }
11
+ #
12
+ # Idempotent and safe to run concurrently.
13
+ class ReaperJob < ApplicationJob
14
+ queue_as :solid_loop_maintenance
15
+
16
+ def perform
17
+ SolidLoop.reap!
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,200 @@
1
+ module SolidLoop
2
+ class ToolExecutionJob < ApplicationJob
3
+ queue_as :solid_loop_tool
4
+
5
+ # `reaper_lease_token` (optional): when the reaper's case 2 re-enqueues a
6
+ # stalled tool call it FIRST CAS-claims the per-tool lease under the loop
7
+ # lock and hands its token here, so two racing reapers resolve to exactly one
8
+ # re-enqueue (the loser's CAS gets 0 rows). A normal dispatch omits it and the
9
+ # job self-claims a fresh lease below.
10
+ def perform(tool_call_id, execution_token = nil, reaper_lease_token = nil)
11
+ # A tool job must carry the generation token it was dispatched with.
12
+ # A job without one (legacy/host-enqueued) is not part of any current
13
+ # generation: fail closed instead of adopting the loop's token.
14
+ return if execution_token.nil?
15
+
16
+ # Pre-fetch basic objects to determine the agent class. The token fence
17
+ # here is a plain generation check (no lease acquired yet); a stale job
18
+ # no-ops silently — there is nothing to reconcile.
19
+ tool_call = SolidLoop::ToolCall.find(tool_call_id)
20
+ message = tool_call.message
21
+ loop_model = message.loop
22
+ loop_model.reload
23
+ return unless loop_model.running? && loop_model.execution_token == execution_token
24
+
25
+ # Reconciliation wrapper (findings 4/5/9): begin reconciliation immediately
26
+ # after loading the loop/tool IDs and INCLUDE everything that used to sit
27
+ # OUTSIDE the begin — agent resolution (a renamed/missing agent method),
28
+ # sequential-mode evaluation, the lease claim aftermath, builder creation,
29
+ # and `configure_tool_middlewares`. A non-graceful error at ANY of these
30
+ # steps fails the loop VISIBLY under the fence instead of stranding it
31
+ # `running` / reaping-and-retrying it forever. The context is built up
32
+ # front (with lease_token nil until claimed) so a PRE-LEASE error still has
33
+ # a generation-fenced failure path: `reconcile_failure!` transitions the
34
+ # loop to failed under the execution_token even when no tool lease was held.
35
+ # Hard kills (SIGKILL / an Exception outside StandardError) still fall
36
+ # through to the reaper's case 2, which re-invokes with the SAME stable key.
37
+ context = SolidLoop::Pipeline::ToolContext.new(tool_call_id: tool_call_id)
38
+ context.tool_call = tool_call
39
+ context.message = message
40
+ context.loop = loop_model
41
+ context.execution_token = execution_token
42
+ context.lease_token = nil
43
+
44
+ begin
45
+ agent = loop_model.agent
46
+
47
+ # Token fencing + the executed_at checkpoint keep every mode safe against
48
+ # stale/duplicate jobs. The first-unresolved boundary is the *ordering*
49
+ # invariant: it only applies when the agent opts into sequential
50
+ # execution (parallel mode deliberately runs the turn's jobs in any
51
+ # order). The check runs under the parent assistant message lock — the
52
+ # same lock ResponseCreation resolves calls under — so it cannot race a
53
+ # result landing.
54
+ if agent.sequential_tool_calls?
55
+ first_unresolved_id = nil
56
+ message.with_lock do
57
+ first_unresolved_id = loop_model.unresolved_tool_calls.first&.id
58
+ end
59
+
60
+ unless first_unresolved_id == tool_call.id
61
+ # A stray/duplicate/out-of-order job must not stall the turn:
62
+ # no-op, but re-dispatch the actual head so ordering self-heals. Route
63
+ # through enqueue! so a silently-aborted re-dispatch surfaces (job
64
+ # retries) instead of leaving the head-of-line call unrun.
65
+ SolidLoop.enqueue!(self.class, first_unresolved_id, execution_token) if first_unresolved_id
66
+ return
67
+ end
68
+ end
69
+
70
+ # The per-tool_call lease (the SECOND fence token).
71
+ # `execution_token` above is the generation fence (shared by every tool of
72
+ # the turn); the `lease_token` here is per-tool, so the reaper can reclaim
73
+ # ONE dead parallel tool without fencing its healthy siblings. The lease is
74
+ # derived to strictly outlive the tool client timeout, so a healthy
75
+ # long-running tool can never lose its lease to a reaper.
76
+ lease_token =
77
+ if tool_call.executed_at?
78
+ # Already checkpointed but re-dispatched: the tool BODY must not run
79
+ # again (executed_at is the terminal exactly-once guard). This path
80
+ # exists so a call whose CONTINUATION (canonical response + successor)
81
+ # was dropped — a crash after executed_at, reclaimed by reaper case 2 —
82
+ # is completed: ToolExecution restores the checkpoint and ResponseCreation
83
+ # commits the continuation (its own idempotent `response_message` check
84
+ # makes a duplicate delivery harmless). No lease is needed or held.
85
+ nil
86
+ elsif reaper_lease_token
87
+ # Reaper path: the lease was already CAS-claimed under the loop lock and
88
+ # handed here. ADOPT it only if we STILL hold it (a worker that took over
89
+ # after the reaper enqueued would have rotated it). No-op otherwise.
90
+ return unless tool_call.lease_held_by?(reaper_lease_token)
91
+
92
+ reaper_lease_token
93
+ else
94
+ # Normal dispatch: self-claim UNDER THE LOOP LOCK.
95
+ #
96
+ # SECONDARY race (clean-resume violation): the generation was validated
97
+ # by the `reload` at the top of `perform`, but claiming the ToolCall
98
+ # lease independently leaves a window in which a pause/resume can rotate
99
+ # G1→G2 and revoke old leases BETWEEN the two. A stale worker would then
100
+ # install a FRESH lease on the ToolCall without re-checking the loop,
101
+ # and the legitimate G2 job could not claim until that lease expired (a
102
+ # full tool-TTL delay before reaper case 2 repairs it). Fix: re-validate
103
+ # `running + execution_token` under the loop row lock and claim the lease
104
+ # in the SAME locked scope (loop → tool_call order). A rotation that
105
+ # commits before we take the lock is now seen here → no-op; one that
106
+ # arrives after blocks on the loop lock and rotates cleanly afterward.
107
+ minted = claim_lease_under_loop_lock(loop_model, tool_call, execution_token, agent)
108
+ if minted.nil?
109
+ # Observability: the CAS minted nothing — either the
110
+ # generation rotated away under us (stale worker no-op) or a DIFFERENT
111
+ # live per-tool lease still holds this call (e.g. a host-side
112
+ # paused→running resume that re-enqueued this job WITHOUT revoking the
113
+ # gated tool_call's unexecuted lease — see SolidLoop::Base#resume!,
114
+ # which NULLs lease_token/leased_until before re-enqueue). Without this
115
+ # log the strand is invisible: no error, no event, the loop just waits
116
+ # out the lease TTL / reaper. Reload so the logged lease reflects the
117
+ # holder we lost to, not our stale in-memory copy — guarded so a
118
+ # concurrent teardown of the row can't turn this pure-observability
119
+ # path into a raise (a log must never change control flow).
120
+ begin
121
+ tool_call.reload
122
+ rescue ActiveRecord::RecordNotFound
123
+ nil
124
+ end
125
+ Rails.logger.warn(
126
+ "ToolExecutionJob: tool lease claim lost — another live lease holds " \
127
+ "this tool_call; job no-op (loop may strand until lease TTL/reaper). " \
128
+ "tool_call_id=#{tool_call.id} loop_id=#{loop_model.id} " \
129
+ "lease_token=#{tool_call.lease_token.inspect} " \
130
+ "leased_until=#{tool_call.leased_until.inspect} " \
131
+ "executed_at=#{tool_call.executed_at.inspect}"
132
+ )
133
+ return
134
+ end
135
+
136
+ minted
137
+ end
138
+
139
+ tool_call.reload
140
+ context.tool_call = tool_call
141
+ context.lease_token = lease_token
142
+
143
+ builder = SolidLoop::Pipeline::Builder.new(SolidLoop.tool_middlewares.middlewares)
144
+ agent.configure_tool_middlewares(builder)
145
+ pipeline = SolidLoop::Pipeline.new(builder.middlewares)
146
+
147
+ pipeline.call(context)
148
+ rescue StandardError => e
149
+ # If this generation (+ lease, when one was held) still owns the call under
150
+ # the row lock we reconciled it to `failed` (durable) — swallow so ActiveJob
151
+ # does not retry endlessly. If ownership was already rotated away
152
+ # (pause/stop/reclaim), re-raise so a genuinely-unhandled error stays
153
+ # visible. A pre-lease error takes the no-lease, generation-fenced path.
154
+ raise unless reconcile_failure!(loop_model, tool_call, context, e)
155
+ end
156
+ end
157
+
158
+ private
159
+
160
+ # Claim the per-tool lease UNDER the loop row lock, after re-validating that
161
+ # THIS generation still owns the loop (SECONDARY claim-under-lock race). Lock
162
+ # order loop → tool_call. Returns the minted lease token, or nil when either
163
+ # the generation rotated away (stale worker — no-op) or the CAS claimed 0 rows
164
+ # (a live sibling lease, or the row became executed under us). No lease is
165
+ # installed on a loop we no longer own, so the legitimate next-generation job
166
+ # never waits out a stale lease's TTL.
167
+ def claim_lease_under_loop_lock(loop_model, tool_call, execution_token, agent)
168
+ minted = nil
169
+ loop_model.with_lock do
170
+ next unless loop_model.running? && loop_model.execution_token == execution_token
171
+
172
+ candidate = SecureRandom.uuid
173
+ now = Time.current
174
+ claimed = SolidLoop::ToolCall
175
+ .where(id: tool_call.id, executed_at: nil)
176
+ .where("lease_token IS NULL OR leased_until < ?", now)
177
+ .update_all(lease_token: candidate, leased_until: now + SolidLoop.config.tool_lease_duration(agent), updated_at: now)
178
+ minted = candidate if claimed.positive?
179
+ end
180
+ minted
181
+ end
182
+
183
+ # Fence-checked reconciliation for the tool job (findings 4/9). Delegates to
184
+ # the ONE shared helper so the job's and the middleware's semantics cannot
185
+ # drift. The helper does the BOTH-token ownership gate ITSELF under the
186
+ # loop→tool_call row locks (NOT via `transition_status`'s block, which cannot
187
+ # abort the loop write) and only terminalizes when both tokens validate — so a
188
+ # stale worker whose per-tool lease was rotated away never fails the loop the
189
+ # current owner is running. Returns true ⇒ handled (swallow); false ⇒ re-raise.
190
+ def reconcile_failure!(loop_model, tool_call, context, error)
191
+ SolidLoop::ToolFailureReconciler.reconcile!(
192
+ loop_model: loop_model,
193
+ tool_call: tool_call,
194
+ execution_token: context.execution_token,
195
+ lease_token: context.lease_token,
196
+ error: error
197
+ )
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,5 @@
1
+ module SolidLoop
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end