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,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
|
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
|