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,17 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
module ToolMiddlewares
|
|
3
|
+
class AgentInitialization
|
|
4
|
+
def initialize(app)
|
|
5
|
+
@app = app
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(env)
|
|
9
|
+
env.agent = env.loop.agent
|
|
10
|
+
|
|
11
|
+
SolidLoop::McpSessionInitializer.call(env.loop, env.agent, execution_token: env.execution_token)
|
|
12
|
+
|
|
13
|
+
@app.call(env)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
module ToolMiddlewares
|
|
3
|
+
class ErrorHandling
|
|
4
|
+
def initialize(app)
|
|
5
|
+
@app = app
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Errors the tool path terminalizes in-line (loop → failed) and then
|
|
9
|
+
# SWALLOWS (never re-raised), so ActiveJob does not retry a durable,
|
|
10
|
+
# already-recorded failure. Everything else re-raises to the job-level
|
|
11
|
+
# reconciliation wrapper (findings 4/9), which fails the loop under the
|
|
12
|
+
# BOTH-token fence — closing the "stranded running" gap for a renamed
|
|
13
|
+
# agent method or a middleware bug. A CancellationError is fenceless: it
|
|
14
|
+
# fires precisely when this generation already lost the token (pause/stop).
|
|
15
|
+
GRACEFUL_ERRORS = [
|
|
16
|
+
SolidLoop::CancellationError,
|
|
17
|
+
SolidLoop::McpError
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def call(env)
|
|
21
|
+
begin
|
|
22
|
+
@app.call(env)
|
|
23
|
+
# Re-raise if exception was caught by inner layer but left in env
|
|
24
|
+
raise env.exception if env.exception
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
raise e unless graceful_error?(e)
|
|
27
|
+
|
|
28
|
+
handle_exception(env, e)
|
|
29
|
+
# Swallowed: expose it for outer inspection (tests) but do NOT re-raise
|
|
30
|
+
# — the failure is already durable in the DB.
|
|
31
|
+
env.exception = e
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def graceful_error?(e)
|
|
38
|
+
GRACEFUL_ERRORS.any? { |klass| e.is_a?(klass) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def handle_exception(env, e)
|
|
42
|
+
# A cancellation fires when this generation lost the token (pause/stop):
|
|
43
|
+
# the reconciler short-circuits it (the new generation owns both loop and
|
|
44
|
+
# call). Everything else terminalizes through the ONE shared helper so this
|
|
45
|
+
# graceful path and the job-level reconciler share EXACTLY the semantics.
|
|
46
|
+
# Finding 9, failure path: the helper does the BOTH-token gate ITSELF under
|
|
47
|
+
# the loop→tool_call row locks and only fails the loop when both tokens
|
|
48
|
+
# validate, so a stale worker whose per-tool lease was rotated away (its
|
|
49
|
+
# lease expired while alive, reaper case 2 reclaimed the ToolCall) never
|
|
50
|
+
# terminalizes the loop the CURRENT owner is running. `transition_status`'s
|
|
51
|
+
# block CANNOT do this — it performs the loop update after yielding
|
|
52
|
+
# regardless, so a `next` on lease mismatch skipped only the ToolCall write.
|
|
53
|
+
return if e.is_a?(SolidLoop::CancellationError)
|
|
54
|
+
return unless env.loop
|
|
55
|
+
|
|
56
|
+
SolidLoop::ToolFailureReconciler.reconcile!(
|
|
57
|
+
loop_model: env.loop,
|
|
58
|
+
tool_call: env.tool_call,
|
|
59
|
+
execution_token: env.execution_token,
|
|
60
|
+
lease_token: env.lease_token,
|
|
61
|
+
error: e
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
module ToolMiddlewares
|
|
3
|
+
class EventLogging
|
|
4
|
+
def initialize(app)
|
|
5
|
+
@app = app
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(env)
|
|
9
|
+
@app.call(env)
|
|
10
|
+
|
|
11
|
+
if env.metadata[:mcp_log].present? || env.metadata[:mcp_request_data].present?
|
|
12
|
+
SolidLoop::Event.create!(
|
|
13
|
+
loop_id: env.loop.id,
|
|
14
|
+
eventable: env.tool_call,
|
|
15
|
+
name: "mcp_tool_call",
|
|
16
|
+
http_method: "POST", # Simplified
|
|
17
|
+
url: env.metadata[:mcp_url],
|
|
18
|
+
request_data: env.metadata[:mcp_request_data],
|
|
19
|
+
response_data: env.metadata[:mcp_response_data],
|
|
20
|
+
duration: env.duration,
|
|
21
|
+
log: env.metadata[:mcp_log]
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
module ToolMiddlewares
|
|
3
|
+
class ResponseCreation
|
|
4
|
+
def initialize(app)
|
|
5
|
+
@app = app
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(env)
|
|
9
|
+
created_message = nil
|
|
10
|
+
|
|
11
|
+
# LOCK ORDER (the tool canonical-write fence): loop row → tool_call → message,
|
|
12
|
+
# continuing the order ToolExecution began. The LOOP row lock is held
|
|
13
|
+
# across BOTH the both-token validation AND the canonical response +
|
|
14
|
+
# continuation, so a pause/resume/stop (which only takes the loop lock)
|
|
15
|
+
# cannot slip between validation and the write: with the loop FOR
|
|
16
|
+
# UPDATE-locked, `loop.execution_token` and `tool_call.lease_token` are
|
|
17
|
+
# frozen for the whole write. A stale/revoked worker writes NOTHING —
|
|
18
|
+
# no tool response message, no completion transition, no successor job.
|
|
19
|
+
#
|
|
20
|
+
# Response persistence, state advancement and successor-job dispatch
|
|
21
|
+
# commit atomically inside this transaction (SolidLoop requires a
|
|
22
|
+
# same-database transactional job backend): a crash or rollback leaves
|
|
23
|
+
# neither the state change nor a dangling job.
|
|
24
|
+
env.loop.with_lock do
|
|
25
|
+
env.tool_call.with_lock do
|
|
26
|
+
env.message.with_lock do
|
|
27
|
+
# Finding 9 — the canonical tool RESPONSE message + continuation is the
|
|
28
|
+
# second half of the tool completion write; fence it with BOTH tokens
|
|
29
|
+
# too. A worker whose generation was rotated or whose per-tool lease was
|
|
30
|
+
# revoked between ToolExecution and here must write no canonical
|
|
31
|
+
# response and dispatch no successor. The `executed_at` checkpoint
|
|
32
|
+
# (written under the tool_call lock in ToolExecution, itself fenced)
|
|
33
|
+
# remains the terminal exactly-once guard; this guards the message +
|
|
34
|
+
# continuation half against the same stale-worker race.
|
|
35
|
+
return unless canonical_continuation_owned?(env)
|
|
36
|
+
|
|
37
|
+
unless env.tool_call.response_message
|
|
38
|
+
created_message = env.loop.messages.create!(
|
|
39
|
+
role: "tool",
|
|
40
|
+
content: env.result,
|
|
41
|
+
tool_call_id: env.tool_call.tool_call_id,
|
|
42
|
+
status: "success"
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Explicit ordered scope — never rely on association default order.
|
|
47
|
+
tool_calls = env.message.tool_calls.ordered.to_a
|
|
48
|
+
response_ids = env.loop.messages
|
|
49
|
+
.where(role: :tool, tool_call_id: tool_calls.map(&:tool_call_id))
|
|
50
|
+
.where("id > ?", env.message.id)
|
|
51
|
+
.distinct
|
|
52
|
+
.pluck(:tool_call_id)
|
|
53
|
+
|
|
54
|
+
next_tool_call = tool_calls.find { |tool_call| !response_ids.include?(tool_call.tool_call_id) }
|
|
55
|
+
all_resolved = next_tool_call.nil?
|
|
56
|
+
|
|
57
|
+
# Sequential agents chain the next unresolved call here; parallel
|
|
58
|
+
# agents already enqueued every call up front, so each job only
|
|
59
|
+
# advances the loop once *all* results have landed. The completion
|
|
60
|
+
# gate is identical in both modes and fenced by the execution token,
|
|
61
|
+
# so whichever job commits the final result queues the next LLM turn.
|
|
62
|
+
sequential = (env.agent || env.loop.agent)&.sequential_tool_calls?
|
|
63
|
+
|
|
64
|
+
if sequential && next_tool_call
|
|
65
|
+
SolidLoop.enqueue!(SolidLoop::ToolExecutionJob, next_tool_call.id, env.execution_token)
|
|
66
|
+
elsif all_resolved && env.loop.transition_status(
|
|
67
|
+
from: :running,
|
|
68
|
+
to: :queued,
|
|
69
|
+
expected_execution_token: env.execution_token,
|
|
70
|
+
execution_token: nil,
|
|
71
|
+
lease_expires_at: nil
|
|
72
|
+
)
|
|
73
|
+
SolidLoop.enqueue!(SolidLoop::LlmCompletionJob, env.loop.id)
|
|
74
|
+
end
|
|
75
|
+
end # env.message.with_lock
|
|
76
|
+
end # env.tool_call.with_lock
|
|
77
|
+
end # env.loop.with_lock
|
|
78
|
+
|
|
79
|
+
if created_message && env.loop.observe_enabled?
|
|
80
|
+
SolidLoop::ObserveBroadcastJob.perform_later(created_message.id)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@app.call(env)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# BOTH-token ownership check for the canonical response + continuation.
|
|
89
|
+
# The loop row and tool_call row are already FOR
|
|
90
|
+
# UPDATE-locked by the enclosing `with_lock`s, so their columns are fresh
|
|
91
|
+
# AND frozen for the whole write — no `reload` (a reload is not a fence).
|
|
92
|
+
# Ownership requires the loop still `running` on THIS generation. The tool
|
|
93
|
+
# lease is validated as terminal-OR-mine: by the time control reaches here
|
|
94
|
+
# ToolExecution has cleared the lease on the exactly-once path (executed_at
|
|
95
|
+
# set, lease NULL) — that is a legitimately owned completion. A NON-terminal
|
|
96
|
+
# call whose lease is neither NULL nor ours means a competing worker holds
|
|
97
|
+
# it: bail.
|
|
98
|
+
def canonical_continuation_owned?(env)
|
|
99
|
+
return false if env.execution_token.blank?
|
|
100
|
+
|
|
101
|
+
loop_record = env.loop
|
|
102
|
+
return false unless loop_record.running? && loop_record.execution_token == env.execution_token
|
|
103
|
+
|
|
104
|
+
return true if env.tool_call.executed_at? && env.tool_call.lease_token.nil?
|
|
105
|
+
|
|
106
|
+
env.tool_call.lease_held_by?(env.lease_token)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
module ToolMiddlewares
|
|
3
|
+
class ToolExecution
|
|
4
|
+
def initialize(app)
|
|
5
|
+
@app = app
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# LOCK ORDER (the tool canonical-write fence): loop row → tool_call → message,
|
|
9
|
+
# deterministic ascending id where several rows of one kind are locked.
|
|
10
|
+
# ToolExecution takes the first two rungs (loop, tool_call); ResponseCreation
|
|
11
|
+
# continues onto the message rung under the SAME loop lock. The both-token
|
|
12
|
+
# validation AND every canonical write are held under the LOOP row lock, so a
|
|
13
|
+
# pause/resume/stop (which only takes the loop lock) cannot slip between
|
|
14
|
+
# validation and the write: with the loop FOR UPDATE-locked,
|
|
15
|
+
# `loop.execution_token` and `tool_call.lease_token` cannot change between
|
|
16
|
+
# validation and mutation, so a stale/revoked worker writes NOTHING.
|
|
17
|
+
#
|
|
18
|
+
# CRITICAL: the tool BODY runs OUTSIDE the loop lock. A long-running tool
|
|
19
|
+
# holding the loop lock would serialize pause/stop/resume behind it — the
|
|
20
|
+
# very thing the fence exists to make safe. The side effect (at-least-once)
|
|
21
|
+
# may run even for a worker that ultimately loses the fence — that discarded
|
|
22
|
+
# effect is the documented at-least-once boundary (the stable ToolCall key
|
|
23
|
+
# dedupes). Only the validate-then-write step is locked.
|
|
24
|
+
def call(env)
|
|
25
|
+
# Terminal (executed_at) OR already has a canonical response (a crash
|
|
26
|
+
# between the response commit and the executed_at checkpoint): the tool
|
|
27
|
+
# BODY must NOT re-run. Take the fenced backfill+restore path only.
|
|
28
|
+
if env.tool_call.executed_at? || env.tool_call.response_message
|
|
29
|
+
resolve_from_checkpoint(env)
|
|
30
|
+
return @app.call(env)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Run the tool body WITHOUT any loop lock held (a long tool must never
|
|
34
|
+
# serialize pause/stop/resume behind the loop lock). The side effect is
|
|
35
|
+
# at-least-once; the canonical write below is what the fence guards.
|
|
36
|
+
execute_once(env)
|
|
37
|
+
|
|
38
|
+
# Test-only failpoint (no-op in production): runs AFTER the tool body and
|
|
39
|
+
# BEFORE the loop lock is acquired, so a concurrency spec can commit a
|
|
40
|
+
# racing pause/stop in exactly this window and prove the subsequent
|
|
41
|
+
# under-lock validation fences the now-stale worker out.
|
|
42
|
+
env.metadata[:before_lock_acquire]&.call
|
|
43
|
+
|
|
44
|
+
env.loop.with_lock do
|
|
45
|
+
env.tool_call.with_lock do
|
|
46
|
+
# Re-check under the fence: a concurrent worker may have written the
|
|
47
|
+
# response/checkpoint while our body ran. If so, restore instead of
|
|
48
|
+
# writing a second canonical result.
|
|
49
|
+
backfill_checkpoint_from_response(env)
|
|
50
|
+
|
|
51
|
+
if env.tool_call.executed_at?
|
|
52
|
+
restore_checkpoint(env)
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The canonical `executed_at` + result write
|
|
57
|
+
# is fenced with BOTH tokens while the LOOP row is still locked, so a
|
|
58
|
+
# worker whose per-tool lease was revoked (pause/reclaim) or whose
|
|
59
|
+
# generation was rotated away writes NOTHING canonical. `executed_at`
|
|
60
|
+
# stays the terminal exactly-once guard.
|
|
61
|
+
unless canonical_write_owned?(env)
|
|
62
|
+
env.metadata[:tool_write_fenced_out] = true
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Test-only failpoint (no-op in production): a callable placed here
|
|
67
|
+
# runs INSIDE the loop lock, AFTER the both-token validation and
|
|
68
|
+
# BEFORE the canonical write, so a concurrency spec can attempt a
|
|
69
|
+
# racing pause/stop at exactly that interleaving and prove the loop
|
|
70
|
+
# row lock serializes it. Harmless when absent.
|
|
71
|
+
env.metadata[:after_validate_before_write]&.call
|
|
72
|
+
|
|
73
|
+
env.tool_call.update!({
|
|
74
|
+
is_success: env.is_success,
|
|
75
|
+
result: env.result,
|
|
76
|
+
duration: env.duration || 0.0,
|
|
77
|
+
executed_at: Time.current,
|
|
78
|
+
lease_token: nil,
|
|
79
|
+
leased_until: nil
|
|
80
|
+
}.merge(env.metadata.key?(:mcp_session_id) ? { mcp_session_id: env.metadata[:mcp_session_id] } : {}))
|
|
81
|
+
env.loop.increment!(:duration_tools, env.duration) if env.duration.to_f.nonzero?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
@app.call(env)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Terminal / already-responded path: no tool body ran. Backfill the
|
|
91
|
+
# executed_at checkpoint (fenced) if a response exists without one, then
|
|
92
|
+
# restore env.result/duration/is_success from the checkpoint. Under the same
|
|
93
|
+
# loop→tool_call lock order as the write path.
|
|
94
|
+
def resolve_from_checkpoint(env)
|
|
95
|
+
env.loop.with_lock do
|
|
96
|
+
env.tool_call.with_lock do
|
|
97
|
+
backfill_checkpoint_from_response(env)
|
|
98
|
+
restore_checkpoint(env)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# BOTH-token ownership check for the canonical tool write.
|
|
104
|
+
# Both the loop row and the tool_call row are FOR UPDATE-locked
|
|
105
|
+
# by the enclosing `with_lock`s, so `loop.execution_token` and the tool
|
|
106
|
+
# lease columns are fresh AND frozen for the duration of the write. Ownership
|
|
107
|
+
# requires: the loop is still `running` on THIS generation AND this worker
|
|
108
|
+
# still holds a live per-tool lease. No `reload` needed — the row is locked,
|
|
109
|
+
# not merely re-read (a reload is not a fence).
|
|
110
|
+
def canonical_write_owned?(env)
|
|
111
|
+
return false if env.execution_token.blank? || env.lease_token.blank?
|
|
112
|
+
|
|
113
|
+
loop_record = env.loop
|
|
114
|
+
return false unless loop_record.running? && loop_record.execution_token == env.execution_token
|
|
115
|
+
|
|
116
|
+
env.tool_call.lease_held_by?(env.lease_token)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Cleanup 3 — the executed_at backfill is a canonical ToolCall mutation, so
|
|
120
|
+
# it runs under the SAME loop+tool_call locks and only when this generation
|
|
121
|
+
# still owns the loop. A stale worker must not backfill a checkpoint either.
|
|
122
|
+
# (It is a genuine no-op recovery — re-deriving the checkpoint from an
|
|
123
|
+
# already-committed response — but keeping it fenced means a revoked worker
|
|
124
|
+
# writes NOTHING to the ToolCall.)
|
|
125
|
+
def backfill_checkpoint_from_response(env)
|
|
126
|
+
return if env.tool_call.executed_at?
|
|
127
|
+
# Generation fence only — NO per-tool lease check here (both-token exempt):
|
|
128
|
+
# this backfill derives the checkpoint from an ALREADY-CANONICAL response
|
|
129
|
+
# message and invokes NO tool, so there is no at-least-once side effect to
|
|
130
|
+
# fence and nothing a stale lease could double-write. Requiring the loop to
|
|
131
|
+
# still own THIS generation is sufficient to keep a revoked worker from
|
|
132
|
+
# writing to the ToolCall; a live-lease requirement would only wrongly block
|
|
133
|
+
# the legitimate recovery of a response committed under an earlier lease.
|
|
134
|
+
return unless env.execution_token.present? &&
|
|
135
|
+
env.loop.running? && env.loop.execution_token == env.execution_token
|
|
136
|
+
|
|
137
|
+
response = env.tool_call.response_message
|
|
138
|
+
return unless response
|
|
139
|
+
|
|
140
|
+
env.tool_call.update!(
|
|
141
|
+
executed_at: response.created_at,
|
|
142
|
+
result: env.tool_call.result.presence || response.content,
|
|
143
|
+
is_success: env.tool_call.is_success.nil? ? true : env.tool_call.is_success
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def execute_once(env)
|
|
148
|
+
return if env.skip_execution?
|
|
149
|
+
|
|
150
|
+
mcp_tool = env.loop.mcp_tools.find_by(name: env.tool_call.function_name)
|
|
151
|
+
if mcp_tool
|
|
152
|
+
execute_mcp_tool(env, mcp_tool)
|
|
153
|
+
else
|
|
154
|
+
env.result = build_unknown_tool_message(env)
|
|
155
|
+
env.is_success = false
|
|
156
|
+
env.duration = 0.0
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def restore_checkpoint(env)
|
|
161
|
+
env.result = env.tool_call.result
|
|
162
|
+
env.duration = env.tool_call.duration
|
|
163
|
+
env.is_success = env.tool_call.is_success
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def execute_mcp_tool(env, mcp_tool)
|
|
167
|
+
result_hash = mcp_tool.call(
|
|
168
|
+
agent: env.agent,
|
|
169
|
+
arguments: env.tool_call.arguments,
|
|
170
|
+
tool_call: env.tool_call
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
env.result = result_hash[:result]
|
|
174
|
+
env.duration = result_hash[:duration]
|
|
175
|
+
env.is_success = result_hash[:success]
|
|
176
|
+
|
|
177
|
+
# Cleanup 3 — mcp_session_id is telemetry (which session served the call),
|
|
178
|
+
# not a canonical checkpoint. Do NOT write it here (unfenced): a stale
|
|
179
|
+
# worker whose lease was revoked would otherwise overwrite it. Stash it
|
|
180
|
+
# and let the FENCED canonical update below persist it, so only the owning
|
|
181
|
+
# worker records it.
|
|
182
|
+
env.metadata[:mcp_session_id] = mcp_tool.mcp_session_id
|
|
183
|
+
|
|
184
|
+
env.metadata[:mcp_request_data] = result_hash[:request_data]
|
|
185
|
+
env.metadata[:mcp_response_data] = result_hash[:response_data]
|
|
186
|
+
env.metadata[:mcp_log] = result_hash[:log]
|
|
187
|
+
env.metadata[:mcp_url] = "mcp://#{mcp_tool.mcp_session.mcp_name}/#{mcp_tool.name}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def build_unknown_tool_message(env)
|
|
191
|
+
available_tools = env.loop.mcp_tools.pluck(:name).uniq.sort
|
|
192
|
+
tools_list = available_tools.any? ? available_tools.map { |name| "- #{name}" }.join("\n") : "- none"
|
|
193
|
+
|
|
194
|
+
<<~MSG.strip
|
|
195
|
+
Tool call rejected: `#{env.tool_call.function_name}`
|
|
196
|
+
|
|
197
|
+
I could not find a tool with that name in this loop.
|
|
198
|
+
Please pick one of the available tools below and try again.
|
|
199
|
+
|
|
200
|
+
Available tools:
|
|
201
|
+
#{tools_list}
|
|
202
|
+
MSG
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|