solid_loop 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +156 -0
- data/CODE_OF_CONDUCT.md +67 -0
- data/CONTRIBUTING.md +82 -0
- data/MIT-LICENSE +21 -0
- data/README.md +483 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/solid_loop/chart.umd.min.js +14 -0
- data/app/assets/javascripts/solid_loop/chartjs-adapter-date-fns.bundle.min.js +7 -0
- data/app/assets/javascripts/solid_loop/chartkick.min.js +2 -0
- data/app/assets/stylesheets/solid_loop/application.css +15 -0
- data/app/controllers/solid_loop/application_controller.rb +29 -0
- data/app/controllers/solid_loop/dashboard_controller.rb +104 -0
- data/app/controllers/solid_loop/events_controller.rb +12 -0
- data/app/controllers/solid_loop/loops_controller.rb +77 -0
- data/app/controllers/solid_loop/mcp_inbound_sessions_controller.rb +13 -0
- data/app/controllers/solid_loop/mcp_sessions_controller.rb +68 -0
- data/app/controllers/solid_loop/mcp_tools_controller.rb +16 -0
- data/app/controllers/solid_loop/messages_controller.rb +12 -0
- data/app/controllers/solid_loop/tool_calls_controller.rb +12 -0
- data/app/helpers/solid_loop/application_helper.rb +125 -0
- data/app/jobs/solid_loop/application_job.rb +13 -0
- data/app/jobs/solid_loop/llm_completion_job.rb +139 -0
- data/app/jobs/solid_loop/observe_broadcast_job.rb +15 -0
- data/app/jobs/solid_loop/reaper_job.rb +20 -0
- data/app/jobs/solid_loop/tool_execution_job.rb +200 -0
- data/app/models/solid_loop/application_record.rb +5 -0
- data/app/models/solid_loop/base.rb +190 -0
- data/app/models/solid_loop/event.rb +7 -0
- data/app/models/solid_loop/loop.rb +225 -0
- data/app/models/solid_loop/mcp_inbound_session.rb +25 -0
- data/app/models/solid_loop/mcp_session.rb +12 -0
- data/app/models/solid_loop/mcp_tool.rb +26 -0
- data/app/models/solid_loop/message.rb +69 -0
- data/app/models/solid_loop/tool_call.rb +42 -0
- data/app/queries/solid_loop/admin/base_query.rb +23 -0
- data/app/queries/solid_loop/admin/events_query.rb +31 -0
- data/app/queries/solid_loop/admin/loops_query.rb +38 -0
- data/app/queries/solid_loop/admin/mcp_inbound_sessions_query.rb +31 -0
- data/app/queries/solid_loop/admin/mcp_sessions_query.rb +26 -0
- data/app/queries/solid_loop/admin/mcp_tools_query.rb +26 -0
- data/app/queries/solid_loop/admin/messages_query.rb +37 -0
- data/app/queries/solid_loop/admin/tool_calls_query.rb +34 -0
- data/app/services/solid_loop/adapters/native.rb +170 -0
- data/app/services/solid_loop/dialects/anthropic.rb +160 -0
- data/app/services/solid_loop/dialects/gemini.rb +122 -0
- data/app/services/solid_loop/dialects/open_ai.rb +62 -0
- data/app/services/solid_loop/dialects/reasoning_packer.rb +37 -0
- data/app/services/solid_loop/llm_usage_parser/anthropic.rb +22 -0
- data/app/services/solid_loop/llm_usage_parser/gemini.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser/llama.rb +15 -0
- data/app/services/solid_loop/llm_usage_parser/openai.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser.rb +19 -0
- data/app/services/solid_loop/mcp_session_initializer.rb +205 -0
- data/app/services/solid_loop/mcp_tool_execution_service.rb +153 -0
- data/app/services/solid_loop/middlewares/agent_initialization.rb +37 -0
- data/app/services/solid_loop/middlewares/error_handling.rb +107 -0
- data/app/services/solid_loop/middlewares/event_logging.rb +100 -0
- data/app/services/solid_loop/middlewares/message_building.rb +92 -0
- data/app/services/solid_loop/middlewares/network_calling.rb +84 -0
- data/app/services/solid_loop/middlewares/response_parsing.rb +348 -0
- data/app/services/solid_loop/middlewares/tool_call_xml_parser.rb +117 -0
- data/app/services/solid_loop/sse_stream_aggregator.rb +105 -0
- data/app/services/solid_loop/tool_failure_reconciler.rb +122 -0
- data/app/services/solid_loop/tool_middlewares/agent_initialization.rb +17 -0
- data/app/services/solid_loop/tool_middlewares/error_handling.rb +66 -0
- data/app/services/solid_loop/tool_middlewares/event_logging.rb +27 -0
- data/app/services/solid_loop/tool_middlewares/response_creation.rb +110 -0
- data/app/services/solid_loop/tool_middlewares/tool_execution.rb +206 -0
- data/app/views/layouts/solid_loop/admin.html.erb +416 -0
- data/app/views/layouts/solid_loop/application.html.erb +17 -0
- data/app/views/solid_loop/dashboard/index.html.erb +184 -0
- data/app/views/solid_loop/events/index.html.erb +47 -0
- data/app/views/solid_loop/events/show.html.erb +76 -0
- data/app/views/solid_loop/loops/index.html.erb +83 -0
- data/app/views/solid_loop/loops/show.html.erb +148 -0
- data/app/views/solid_loop/mcp_inbound_sessions/index.html.erb +53 -0
- data/app/views/solid_loop/mcp_inbound_sessions/show.html.erb +78 -0
- data/app/views/solid_loop/mcp_sessions/_tool_output.html.erb +11 -0
- data/app/views/solid_loop/mcp_sessions/index.html.erb +46 -0
- data/app/views/solid_loop/mcp_sessions/inspector.html.erb +94 -0
- data/app/views/solid_loop/mcp_sessions/show.html.erb +142 -0
- data/app/views/solid_loop/mcp_tools/index.html.erb +44 -0
- data/app/views/solid_loop/mcp_tools/show.html.erb +69 -0
- data/app/views/solid_loop/messages/_message.html.erb +75 -0
- data/app/views/solid_loop/messages/index.html.erb +80 -0
- data/app/views/solid_loop/messages/show.html.erb +121 -0
- data/app/views/solid_loop/shared/_pagination.html.erb +21 -0
- data/app/views/solid_loop/tool_calls/index.html.erb +53 -0
- data/app/views/solid_loop/tool_calls/show.html.erb +59 -0
- data/config/routes.rb +27 -0
- data/db/migrate/20260408000100_solid_loop_init.rb +136 -0
- data/db/migrate/20260709000100_solid_loop_create_mcp_inbound_sessions.rb +16 -0
- data/db/migrate/20260713000100_solid_loop_add_execution_guards.rb +6 -0
- data/db/migrate/20260714000100_solid_loop_add_message_steering.rb +17 -0
- data/db/migrate/20260714000200_solid_loop_add_message_conversation_order.rb +14 -0
- data/db/migrate/20260715000100_solid_loop_add_mcp_inbound_session_principal_key.rb +43 -0
- data/db/migrate/20260715000200_solid_loop_add_llm_lease.rb +40 -0
- data/db/migrate/20260715000300_solid_loop_add_tool_lease.rb +33 -0
- data/db/migrate/20260715000400_solid_loop_add_lease_running_check.rb +32 -0
- data/db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb +35 -0
- data/docs/contributing/coverage.md +64 -0
- data/docs/decisions/durable_attempt_lease.md +364 -0
- data/docs/decisions/mcp-only-tooling.md +135 -0
- data/docs/decisions/mcp-server.md +223 -0
- data/docs/decisions/reasoning_persistence.md +51 -0
- data/docs/decisions/ruby_llm_rejected.md +35 -0
- data/docs/guides/dialects.md +55 -0
- data/docs/guides/llm_middlewares.md +294 -0
- data/docs/guides/mcp_transports.md +374 -0
- data/docs/guides/tool_middlewares.md +148 -0
- data/lib/solid_loop/configuration.rb +175 -0
- data/lib/solid_loop/engine.rb +14 -0
- data/lib/solid_loop/lease_heartbeat.rb +94 -0
- data/lib/solid_loop/lease_renewer.rb +347 -0
- data/lib/solid_loop/llm_metrics.rb +16 -0
- data/lib/solid_loop/mcp/call_context.rb +19 -0
- data/lib/solid_loop/mcp/client_factory.rb +61 -0
- data/lib/solid_loop/mcp/http_transport.rb +52 -0
- data/lib/solid_loop/mcp/principal.rb +82 -0
- data/lib/solid_loop/mcp/result.rb +13 -0
- data/lib/solid_loop/mcp/server.rb +246 -0
- data/lib/solid_loop/mcp/stdio_transport.rb +224 -0
- data/lib/solid_loop/mcp/toolset.rb +347 -0
- data/lib/solid_loop/mcp/transport.rb +20 -0
- data/lib/solid_loop/mcp.rb +25 -0
- data/lib/solid_loop/mcp_client.rb +176 -0
- data/lib/solid_loop/pipeline/builder.rb +38 -0
- data/lib/solid_loop/pipeline/context.rb +61 -0
- data/lib/solid_loop/pipeline/tool_context.rb +53 -0
- data/lib/solid_loop/pipeline.rb +40 -0
- data/lib/solid_loop/reaper.rb +313 -0
- data/lib/solid_loop/version.rb +3 -0
- data/lib/solid_loop.rb +94 -0
- data/lib/tasks/coverage.rake +206 -0
- data/lib/tasks/solid_loop_tasks.rake +4 -0
- metadata +228 -0
|
@@ -0,0 +1,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
|