solid_loop 0.0.4

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