belay 0.1.0

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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +155 -0
  4. data/app/assets/belay/application.css +1 -0
  5. data/app/controllers/belay/application_controller.rb +48 -0
  6. data/app/controllers/belay/runs_controller.rb +166 -0
  7. data/app/controllers/belay/stats_controller.rb +14 -0
  8. data/app/helpers/belay/application_helper.rb +76 -0
  9. data/app/views/belay/runs/_header.html.erb +20 -0
  10. data/app/views/belay/runs/_pending_gate.html.erb +25 -0
  11. data/app/views/belay/runs/_pending_needs_review.html.erb +30 -0
  12. data/app/views/belay/runs/_replayed_from.html.erb +7 -0
  13. data/app/views/belay/runs/_replays.html.erb +14 -0
  14. data/app/views/belay/runs/_step.html.erb +52 -0
  15. data/app/views/belay/runs/diff.html.erb +47 -0
  16. data/app/views/belay/runs/index.html.erb +60 -0
  17. data/app/views/belay/runs/show.html.erb +34 -0
  18. data/app/views/belay/shared/_flash.html.erb +11 -0
  19. data/app/views/belay/shared/_sidebar.html.erb +22 -0
  20. data/app/views/belay/stats/_chart.html.erb +56 -0
  21. data/app/views/belay/stats/_table.html.erb +22 -0
  22. data/app/views/belay/stats/index.html.erb +16 -0
  23. data/app/views/layouts/belay/application.html.erb +19 -0
  24. data/config/routes.rb +13 -0
  25. data/lib/belay/anthropic_provider.rb +166 -0
  26. data/lib/belay/application_record.rb +7 -0
  27. data/lib/belay/approval.rb +35 -0
  28. data/lib/belay/cache_entry.rb +65 -0
  29. data/lib/belay/configuration.rb +60 -0
  30. data/lib/belay/context.rb +241 -0
  31. data/lib/belay/daily_stats.rb +60 -0
  32. data/lib/belay/engine.rb +40 -0
  33. data/lib/belay/executor.rb +411 -0
  34. data/lib/belay/fake_provider.rb +41 -0
  35. data/lib/belay/janitor_job.rb +48 -0
  36. data/lib/belay/provider.rb +15 -0
  37. data/lib/belay/provider_response.rb +19 -0
  38. data/lib/belay/resume_run_job.rb +42 -0
  39. data/lib/belay/rule.rb +113 -0
  40. data/lib/belay/run.rb +629 -0
  41. data/lib/belay/step.rb +57 -0
  42. data/lib/belay/version.rb +5 -0
  43. data/lib/belay/workflow.rb +199 -0
  44. data/lib/belay.rb +49 -0
  45. data/lib/generators/belay/install/install_generator.rb +48 -0
  46. data/lib/generators/belay/install/templates/create_belay_approvals.rb.tt +43 -0
  47. data/lib/generators/belay/install/templates/create_belay_cache_entries.rb.tt +27 -0
  48. data/lib/generators/belay/install/templates/create_belay_rules.rb.tt +46 -0
  49. data/lib/generators/belay/install/templates/create_belay_runs.rb.tt +37 -0
  50. data/lib/generators/belay/install/templates/create_belay_steps.rb.tt +58 -0
  51. metadata +226 -0
data/lib/belay/run.rb ADDED
@@ -0,0 +1,629 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belay
4
+ class Run < ApplicationRecord # rubocop:disable Metrics/ClassLength
5
+ self.table_name = "belay_runs"
6
+
7
+ has_many :steps, class_name: "Belay::Step"
8
+ has_many :approvals, class_name: "Belay::Approval"
9
+
10
+ # Explicit class_name/foreign_key/optional: for the same reason every other
11
+ # association on this model states them explicitly - no Zeitwerk/app-
12
+ # autoloading to infer the namespace from, and most runs are not a replay
13
+ # of anything (optional: false would be wrong here, unlike Step's belongs_to
14
+ # :run above).
15
+ belongs_to :replay_of, class_name: "Belay::Run", optional: true
16
+ has_many :replays, class_name: "Belay::Run", foreign_key: :replay_of_id, inverse_of: :replay_of
17
+
18
+ # The two outcomes resolve_needs_review! accepts - see its own comment below.
19
+ RESOLUTIONS = %w[succeeded failed].freeze
20
+
21
+ # validate: false (the default) raises ArgumentError immediately on an invalid value,
22
+ # rather than deferring to a validation error at save time. Right for internal state
23
+ # management, which should fail loudly - but if status is ever set from unsanitized
24
+ # external input (a form, an API param), that ArgumentError is unhandled, not a
25
+ # friendly 422. Nothing does that today.
26
+ enum :status, {
27
+ pending: "pending",
28
+ running: "running",
29
+ paused: "paused",
30
+ completed: "completed",
31
+ failed: "failed",
32
+ cancelled: "cancelled"
33
+ }
34
+
35
+ validates :workflow, presence: true
36
+ validates :workflow_version, presence: true
37
+
38
+ def start!
39
+ transition!(from: "pending", to: "running", started_at: Time.current)
40
+ end
41
+
42
+ def pause!
43
+ transition!(from: "running", to: "paused")
44
+ end
45
+
46
+ def resume!
47
+ transition!(from: "paused", to: "running")
48
+ end
49
+
50
+ # None of complete!/fail!/cancel! below clear lease_owner/lease_expires_at - a
51
+ # terminal run can be left holding a lease forever. Harmless today only because
52
+ # every caller of acquire_lease! (JanitorJob, Phase 1f) scopes to status: running
53
+ # first, so a stale lease on a terminal run is never actually read. Would need
54
+ # fixing if anything ever queries leases without that status filter.
55
+ def complete!
56
+ transition!(from: "running", to: "completed", finished_at: Time.current)
57
+ end
58
+
59
+ def fail!(error: nil)
60
+ transition!(from: %w[pending running paused], to: "failed", finished_at: Time.current, error: error)
61
+ end
62
+
63
+ def cancel!
64
+ transition!(from: %w[pending running paused], to: "cancelled", finished_at: Time.current)
65
+ end
66
+
67
+ # At most one, by construction: Executor stops at the FIRST gate whose when:
68
+ # evaluates true (throw :gate_paused unwinds immediately, no further steps
69
+ # run), so a run can never have more than one step awaiting_approval at once.
70
+ # nil, not a raise, when there isn't one - that's the common case (most runs
71
+ # never pause on a gate at all), not an error condition.
72
+ def pending_gate
73
+ steps.awaiting_approval.first
74
+ end
75
+
76
+ # At most one, for the same reason pending_gate above is: flag_needs_review!
77
+ # (executor.rb) raises immediately after flagging a step, stopping dispatch
78
+ # right there - nothing in this codebase flags a second one before the first
79
+ # is resolved.
80
+ def pending_needs_review
81
+ steps.needs_review.first
82
+ end
83
+
84
+ # DESIGN_DECISIONS #5: "a worker claims a run with a conditional UPDATE on
85
+ # lease_owner/lease_expires_at." The condition - no owner yet, or the existing
86
+ # lease has expired - is evaluated by Postgres in the same statement that claims
87
+ # it, not read-then-written from Ruby: two workers racing here can't both see
88
+ # "unclaimed" and both proceed, the way the transition! comment above says two
89
+ # workers racing on status can. update_all's return value (rows actually
90
+ # matched) is the only thing this trusts; the in-memory object is reloaded only
91
+ # after a successful claim, never used to decide one.
92
+ #
93
+ # Only claims the lease - callers (ResumeRunJob et al, not built yet) are
94
+ # responsible for actually using it to serialize execution, and for releasing or
95
+ # heartbeating it (Phase 1f's next commits). Nothing here calls this yet either.
96
+ # Named with ! like start!/pause!/transition! above, not acquire_lease? - it's a
97
+ # mutator that also reports success, which is the pattern this class already
98
+ # uses for state changes. Diverges from those methods in one way: they raise on
99
+ # an invalid transition, this returns false on lost contention - contention is
100
+ # an expected, normal outcome here, not a bug to raise about.
101
+ def acquire_lease!(owner:, duration: Belay.config.lease_duration) # rubocop:disable Naming/PredicateMethod
102
+ expires_at = Time.current + duration
103
+ claimed = self.class
104
+ .where(id: id)
105
+ .where("lease_owner IS NULL OR lease_expires_at < ?", Time.current)
106
+ .update_all(lease_owner: owner, lease_expires_at: expires_at)
107
+
108
+ return false unless claimed == 1
109
+
110
+ reload
111
+ true
112
+ end
113
+
114
+ # DESIGN_DECISIONS #5: "...and heartbeats while a step is in flight." Extends
115
+ # lease_expires_at by a fresh full duration, not by heartbeat_interval -
116
+ # how often a caller heartbeats and how long each renewal buys are different
117
+ # numbers (Belay.config has both; this method only uses lease_duration, same
118
+ # default as acquire_lease!).
119
+ #
120
+ # Only renews a lease that's both owned by this caller AND not yet expired - if
121
+ # it already expired, another worker may have already reclaimed it via
122
+ # acquire_lease!, and blindly extending would fight that worker rather than
123
+ # detect the conflict. A caller that discovers its own heartbeat failing should
124
+ # treat that as "I may have lost this run," not retry the heartbeat.
125
+ def heartbeat_lease!(owner:, duration: Belay.config.lease_duration) # rubocop:disable Naming/PredicateMethod
126
+ expires_at = Time.current + duration
127
+ renewed = self.class
128
+ .where(id: id, lease_owner: owner)
129
+ .where("lease_expires_at >= ?", Time.current)
130
+ .update_all(lease_expires_at: expires_at)
131
+
132
+ return false unless renewed == 1
133
+
134
+ reload
135
+ true
136
+ end
137
+
138
+ # Heartbeats this run's lease, in a background thread, for as long as the
139
+ # given block runs - closes the gap named in JanitorJob's and
140
+ # ResumeRunJob's own comments and DESIGN_DECISIONS #12/#13. Step execution
141
+ # is synchronous Ruby, not async - the only way to renew a lease WHILE a
142
+ # single slow step (a model call that outruns lease_duration) is still in
143
+ # flight is a second thread doing it concurrently with the first.
144
+ #
145
+ # No-ops (just yields, no thread spun up) when this run holds no lease at
146
+ # all: lease_owner is nil both for a run that's never been through
147
+ # acquire_lease! (Workflow.start's inline path - today's realistic case,
148
+ # per JanitorJob's own comment) and for one whose lease was already
149
+ # released. heartbeat_lease!(owner: nil) would also just safely return
150
+ # false every time on its own (nil never matches a real owner, and a NULL
151
+ # lease_expires_at never satisfies >= Time.current either) - skipping the
152
+ # thread entirely is purely to avoid spinning one up for nothing on the
153
+ # common no-lease path, not a correctness requirement.
154
+ #
155
+ # Only ever renews - never aborts the block early on a lease it discovers
156
+ # is lost. There's no safe way to interrupt a step already mid-flight from
157
+ # outside it (Thread#raise into an arbitrary point in someone else's code
158
+ # is exactly the leaves-things-half-done unsafety this gem exists to avoid
159
+ # elsewhere). Losing that race is still handled the same way it already
160
+ # was: another worker's acquire_lease! simply won't match a lease that's
161
+ # genuinely still live, and JanitorJob/lease contention at the DB layer
162
+ # remains the real backstop - heartbeating just makes reaching it, while a
163
+ # worker is still legitimately running, far less likely in practice.
164
+ def heartbeat_while
165
+ return yield if lease_owner.nil?
166
+
167
+ owner = lease_owner
168
+ stop = false
169
+ thread = Thread.new { heartbeat_until_stopped(owner) { stop } }
170
+
171
+ yield
172
+ ensure
173
+ stop = true
174
+ stop_heartbeat_thread(thread)
175
+ end
176
+
177
+ # Unconditional, unlike acquire_lease!/heartbeat_lease! - both take an owner:
178
+ # and defend against racing another worker; this doesn't check who (if
179
+ # anyone) currently holds the lease. Still an honest limitation of what's
180
+ # wired up today, not a deliberate safety choice - but no longer "no owner
181
+ # identity to check against": heartbeat_while (above) shows a caller CAN
182
+ # read this run's own lease_owner column instead of needing one threaded
183
+ # through from whichever job constructed the Executor. pause_on_gate!, the
184
+ # only caller of this method, just hasn't been changed to use that same
185
+ # trick and check it before releasing - revisit if that ever matters (a
186
+ # gate pausing a run some OTHER worker's lease is still actively
187
+ # heartbeating against would be the failure mode).
188
+ def release_lease! # rubocop:disable Naming/PredicateMethod
189
+ update!(lease_owner: nil, lease_expires_at: nil)
190
+ true
191
+ end
192
+
193
+ # Approves this run's pending gate: records an approved Belay::Approval, settles
194
+ # the gate step "succeeded" (not "skipped" - that's reserved for when: false;
195
+ # this row's when: was true and a human signed off, a meaningfully different
196
+ # outcome worth distinguishing on the dashboard/audit trail), resumes the run,
197
+ # and enqueues ResumeRunJob to pick back up at the step right after the gate.
198
+ #
199
+ # by: is written as-is to decided_by (a plain string column) - ActiveRecord
200
+ # coerces it via #to_s, so passing a full User object stores its #to_s, not a
201
+ # useful identifier. Callers should pass something already meant to be read
202
+ # back later (an email, a username, an id) - this method doesn't guess which.
203
+ #
204
+ # All three writes - the Approval, the gate's own status, and resume! - are
205
+ # wrapped in one transaction, same reasoning as evaluate_gate!'s own fix: a
206
+ # crash between them must not leave a gate no longer awaiting_approval (so a
207
+ # second approve!/reject! would be guessing what already happened) while the
208
+ # run is still paused, or vice versa. If the process dies after this
209
+ # transaction commits but before ResumeRunJob.perform_later below enqueues,
210
+ # the run is left "running" with no lease - exactly what JanitorJob's
211
+ # stale-run query already reclaims (lib/belay/janitor_job.rb), so it isn't
212
+ # actually orphaned, just picked up a cycle late.
213
+ #
214
+ # pending_gate is checked once, in memory, before the transaction - not a
215
+ # row lock. Two callers racing to approve!/reject! the same gate (a double
216
+ # click, two reviewers) can both pass that check; the DB's unique index on
217
+ # belay_approvals.step_id is what actually decides it, and the loser's
218
+ # approvals.create! raises ActiveRecord::RecordNotUnique, rolling back its
219
+ # own transaction cleanly (the gate/run writes never ran) - rescued below
220
+ # into the same kind of error a late pending_gate.nil? check would have
221
+ # given a strictly-sequential second call, rather than a raw DB exception.
222
+ #
223
+ # edited_payload:, if given, becomes the gate step's own output - a gate
224
+ # settled via evaluate_gate! never has one (only its status changes), so
225
+ # this is the first thing that gives one a real output. A step after the
226
+ # gate can read it back the same way it reads any other step's output:
227
+ # ctx.<gate name>. This is a deliberate, narrower choice than "edit the
228
+ # step the gate's when: actually depended on" - the DSL has no declared
229
+ # link between a gate and which step(s) fed its condition, so there's no
230
+ # single other row this could unambiguously override instead. Recorded on
231
+ # the Approval too (same value, different column) so a dashboard can show
232
+ # what was edited without reaching through the gate's own output.
233
+ def approve!(by:, edited_payload: nil)
234
+ gate = require_pending_gate!("approve")
235
+
236
+ begin
237
+ settle_gate_approved!(gate, by: by, edited_payload: edited_payload)
238
+ rescue ActiveRecord::RecordNotUnique
239
+ raise Error, "belay_runs##{id}'s gate :#{gate.name} was already decided by another approve!/reject! call"
240
+ end
241
+
242
+ # Enqueued before rule bookkeeping below, deliberately: the gate is already
243
+ # settled and the run already resumed at this point, so nothing past here
244
+ # should be able to leave the run stuck without a job to actually continue
245
+ # it - not even an unexpected error from rule-learning, which this method's
246
+ # own rescue above must never be allowed to catch (that rescue is scoped to
247
+ # settle_gate_approved! alone for exactly this reason).
248
+ ResumeRunJob.perform_later(id)
249
+ # Only an UNEDITED approval counts toward learning a rule (DESIGN_DECISIONS) -
250
+ # a human who edited the payload before approving is saying the gate's own
251
+ # answer wasn't quite right, which is the opposite of "safe to auto-settle
252
+ # identically next time."
253
+ record_rule_decision!(gate, approved: true) if edited_payload.nil?
254
+ end
255
+
256
+ # Rejects this run's pending gate: records a rejected Belay::Approval, settles
257
+ # the gate step "failed" (there's no dedicated "rejected" Step status - a
258
+ # rejected gate IS a failed step, the same terminal shape any other step's
259
+ # unrecoverable failure takes), and fails the run outright. Unlike approve!,
260
+ # nothing is enqueued: a rejection has no step to resume into, so there's
261
+ # nothing for ResumeRunJob to do - re-entering the executor on a failed run
262
+ # would just hit the gate's own "failed" row and raise (dispatch's
263
+ # retry_or_raise, with max_attempts already exhausted), uncaught, inside a job
264
+ # with no rescue. Failing the run directly, synchronously, avoids ever taking
265
+ # that path.
266
+ def reject!(by:)
267
+ gate = require_pending_gate!("reject")
268
+
269
+ begin
270
+ settle_gate_rejected!(gate, by: by)
271
+ rescue ActiveRecord::RecordNotUnique
272
+ raise Error, "belay_runs##{id}'s gate :#{gate.name} was already decided by another approve!/reject! call"
273
+ end
274
+
275
+ record_rule_decision!(gate, approved: false)
276
+ end
277
+
278
+ # The two outcomes a human can give a needs_review step (flag_needs_review!,
279
+ # executor.rb): "succeeded" means they confirmed the step's real-world side
280
+ # effect actually happened (even though the process crashed before Belay
281
+ # itself ever saw that), settling the row with whatever output: they supply
282
+ # (there's no real one to recover - the block never returned) so a later
283
+ # step reading ctx.<name> sees something rather than nil. "failed" means
284
+ # they confirmed it did NOT happen, settling the row failed and - like
285
+ # reject! above - failing the run directly rather than resuming into
286
+ # dispatch's retry_or_raise at all: for a step with no retries: declared,
287
+ # resuming would hit max_attempts immediately and raise uncaught inside a
288
+ # job with no rescue, the same shape of bug the pause-on-gate commit fixed
289
+ # for gates - but for a step declared retries: N where the crash happened
290
+ # before attempt N, resuming would be worse, not better: it would silently
291
+ # RE-EXECUTE the block for a real retry, exactly the side effect a human
292
+ # just said did NOT happen. Failing directly avoids both failure modes at
293
+ # once, regardless of this step's own attempt/max_attempts.
294
+ #
295
+ # No Belay::Approval row here, unlike approve!/reject! - that model is
296
+ # built and documented specifically around gates (including its
297
+ # unique-per-gate-step index); a needs_review resolution isn't a gate
298
+ # decision, so it gets its own resolved_by/resolved_at columns on
299
+ # belay_steps instead (see that migration's own comment).
300
+ #
301
+ # Claimed with a conditional UPDATE (status: "needs_review" in the WHERE),
302
+ # not a plain update! on the in-memory row - pending_needs_review is read
303
+ # once, in memory, same as pending_gate is for approve!/reject!, so two
304
+ # callers racing to resolve the same step could both pass that check.
305
+ # Without a unique index to catch the loser (there's no Approval row to
306
+ # collide on here), a plain update! would let the second call silently
307
+ # overwrite whatever the first one just wrote - this update_all's return
308
+ # value (rows actually matched) is what's actually trusted, same
309
+ # trust-the-UPDATE's-own-result principle acquire_lease! above uses for
310
+ # exactly the same reason.
311
+ def resolve_needs_review!(by:, outcome:, output: nil)
312
+ unless RESOLUTIONS.include?(outcome)
313
+ raise ArgumentError, "outcome must be one of #{RESOLUTIONS.inspect}, got #{outcome.inspect}"
314
+ end
315
+
316
+ step = pending_needs_review
317
+ raise Error, "belay_runs##{id} has no needs_review step to resolve" if step.nil?
318
+
319
+ outcome == "succeeded" ? resolve_succeeded!(step, by: by, output: output) : resolve_failed!(step, by: by)
320
+ end
321
+
322
+ # Creates a shadow run: a fresh Run with the same workflow/workflow_version/
323
+ # input as this one, re-executing every llm_call step for real (with
324
+ # model_override: if given - see Context#llm) while serving every other
325
+ # step (decision, tool_call, and gate alike - no special-casing needed, a
326
+ # gate is just kind: "gate") from THIS run's own recorded output, never
327
+ # re-running its block. DESIGN_DECISIONS #8: replay compares extractions,
328
+ # it doesn't book the invoice a second time.
329
+ #
330
+ # Runs the shadow synchronously, inline, the same way Workflow.start does -
331
+ # no job enqueued, no lease taken. Executor.new(shadow).call is never
332
+ # rescued here: seed_recorded_steps! below has already written whatever
333
+ # rows it's going to write by the time the executor runs, so a caller
334
+ # gets the same raised error a fresh run's own failure would give, with
335
+ # the shadow run's state still fully inspectable afterward either way.
336
+ def replay(model: nil)
337
+ refuse_unless_replayable!
338
+
339
+ shadow = self.class.create!(workflow: workflow, workflow_version: workflow_version, input: input,
340
+ replay_of: self, replay_model: model)
341
+ seed_recorded_steps!(shadow)
342
+ Executor.new(shadow).call
343
+ shadow
344
+ end
345
+
346
+ # Every step in this run whose kind isn't "llm_call" - decision, tool_call,
347
+ # gate alike - compared against the same position in replay_of's run.
348
+ # changed: is a plain != on the two outputs, not a deep diff of exactly
349
+ # what moved inside them - good enough to answer "did this step's result
350
+ # change," the question replay exists to answer; rendering exactly what's
351
+ # different inside a Hash is a dashboard-view concern, not this method's.
352
+ def replay_diff
353
+ raise Error, "belay_runs##{id} is not a replay - it has no replay_of" if replay_of.nil?
354
+
355
+ by_position = replay_of.steps.group_by(&:position)
356
+ by_position.keys.sort.map { |position| diff_entry(position, by_position.fetch(position)) }
357
+ end
358
+
359
+ private
360
+
361
+ # The thread body heartbeat_while spawns - a separate method, not inlined
362
+ # into Thread.new's block, specifically so it can wrap itself in
363
+ # connection_pool.with_connection: a thread ActiveRecord doesn't already
364
+ # manage the lifecycle of (unlike a request or an ActiveJob perform) has
365
+ # to explicitly check its DB connection back into the pool when it's done,
366
+ # or it leaks a pooled connection every time this runs. stopped? is a
367
+ # block, not a plain boolean, because by the time this thread actually
368
+ # checks it, heartbeat_while's own local stop may have changed - a Proc
369
+ # re-reads the enclosing variable's current value each call, a plain
370
+ # argument would have captured it once, stale, at Thread.new time.
371
+ # wakeup is only to make the thread notice stop promptly instead of
372
+ # finishing out its current sleep(heartbeat_interval) first - join is what
373
+ # actually matters, to guarantee the thread (and its own checked-out DB
374
+ # connection) is gone before heartbeat_while returns. wakeup raises
375
+ # ThreadError on a thread that's already terminated (its own
376
+ # heartbeat_lease! already returned false and it exited the loop on its
377
+ # own) - an expected, not exceptional, outcome here.
378
+ #
379
+ # join's own error is swallowed too (ignoring_errors covers both calls),
380
+ # and that one matters far more than it looks: if this thread died from
381
+ # an unhandled exception (a transient ActiveRecord::StatementInvalid from
382
+ # heartbeat_lease! itself, say), Thread#join re-raises that exception IN
383
+ # THE CALLING THREAD - here, that's heartbeat_while's own ensure, which
384
+ # runs regardless of whether the real step block above it just succeeded.
385
+ # Letting that through would mean a purely advisory background thread
386
+ # hiccuping could silently discard a step that actually completed
387
+ # successfully, turning a harmless heartbeat blip into a failed (and
388
+ # possibly retried) step. This thread only ever renews a lease on a
389
+ # best-effort basis; nothing it does should ever override the real
390
+ # block's own outcome.
391
+ def stop_heartbeat_thread(thread)
392
+ return unless thread
393
+
394
+ ignoring_errors { thread.wakeup }
395
+ ignoring_errors { thread.join }
396
+ end
397
+
398
+ def ignoring_errors
399
+ yield
400
+ rescue StandardError
401
+ nil
402
+ end
403
+
404
+ def heartbeat_until_stopped(owner)
405
+ ActiveRecord::Base.connection_pool.with_connection do
406
+ until yield
407
+ sleep(Belay.config.heartbeat_interval)
408
+ break if yield || !heartbeat_lease!(owner: owner)
409
+ end
410
+ end
411
+ end
412
+
413
+ def resolve_succeeded!(step, by:, output:)
414
+ ActiveRecord::Base.transaction do
415
+ claim_needs_review!(step, by: by, status: "succeeded", output: output)
416
+ resume!
417
+ end
418
+
419
+ ResumeRunJob.perform_later(id)
420
+ end
421
+
422
+ def resolve_failed!(step, by:)
423
+ reason = "belay_steps##{step.id} (:#{step.name}) resolved as failed by #{by}"
424
+ ActiveRecord::Base.transaction do
425
+ claim_needs_review!(step, by: by, status: "failed", error: { "reason" => reason })
426
+ fail!(error: { "reason" => reason })
427
+ end
428
+ end
429
+
430
+ def claim_needs_review!(step, by:, **attrs)
431
+ claimed = Step.where(id: step.id, status: "needs_review")
432
+ .update_all(attrs.merge(resolved_by: by, resolved_at: Time.current))
433
+ return if claimed == 1
434
+
435
+ raise Error, "belay_steps##{step.id} was already resolved by someone else"
436
+ end
437
+
438
+ # Shared by approve!/reject! - same "no pending gate" guard, worded for whichever
439
+ # verb the caller used, so a human reading the raised error sees the action they
440
+ # actually called, not a generic one.
441
+ def require_pending_gate!(action)
442
+ gate = pending_gate
443
+ raise Error, "belay_runs##{id} has no pending gate to #{action}" if gate.nil?
444
+
445
+ gate
446
+ end
447
+
448
+ # A no-op for a gate decided before this feature existed (or any other reason
449
+ # gate.input never got a context_key stashed on it by Executor#evaluate_gate!)
450
+ # - there's nothing to learn or invalidate without the exact snapshot the gate
451
+ # was evaluated under, and that's not an error worth raising approve!/reject!
452
+ # over.
453
+ def settle_gate_rejected!(gate, by:)
454
+ reason = "gate :#{gate.name} rejected by #{by}"
455
+ ActiveRecord::Base.transaction do
456
+ approvals.create!(step: gate, status: "rejected", decided_by: by, decided_at: Time.current)
457
+ gate.update!(status: "failed", error: { "reason" => reason })
458
+ fail!(error: { "reason" => reason })
459
+ end
460
+ end
461
+
462
+ def settle_gate_approved!(gate, by:, edited_payload:)
463
+ ActiveRecord::Base.transaction do
464
+ approvals.create!(step: gate, status: "approved", decided_by: by, decided_at: Time.current,
465
+ edited_payload: edited_payload)
466
+ gate.update!(status: "succeeded", output: edited_payload)
467
+ resume!
468
+ end
469
+ end
470
+
471
+ def record_rule_decision!(gate, approved:)
472
+ context_key = gate.input.is_a?(Hash) ? gate.input["context_key"] : nil
473
+ return if context_key.nil?
474
+
475
+ if approved
476
+ Rule.record_approval!(workflow: workflow, workflow_version: workflow_version, gate_name: gate.name,
477
+ context_key: context_key)
478
+ else
479
+ Rule.record_rejection!(workflow: workflow, workflow_version: workflow_version, gate_name: gate.name,
480
+ context_key: context_key)
481
+ end
482
+ end
483
+
484
+ # replayed: false (and changed: false, not true) when the shadow never
485
+ # reached this position at all - distinct from "it ran and produced the
486
+ # same output" and from "it ran and produced a different one." Found by
487
+ # review: comparing a present original.output against a nil
488
+ # replay_output (because the shadow raised before reaching this position)
489
+ # looked identical to a genuine change otherwise.
490
+ #
491
+ # Known, accepted gap: replayed: can read true for a non-llm position the
492
+ # shadow's Executor never actually dispatched. seed_recorded_steps! writes
493
+ # every non-llm position's row up front, before Executor.new(shadow).call
494
+ # ever starts, independent of where live execution (an llm_call position)
495
+ # later halts - so a non-llm position physically AFTER that halt still has
496
+ # a row, and still reports replayed: true. changed: is still correct
497
+ # either way (a pre-seeded row is always a verbatim copy, never actually
498
+ # different), so nothing is ever reported as "unchanged" that genuinely
499
+ # differs - but replayed: alone can't be trusted to mean "live execution
500
+ # actually reached this far" for a replay that failed partway through.
501
+ # Telling the two apart for real would mean seeding non-llm rows lazily,
502
+ # interleaved with execution, instead of all at once - a real redesign,
503
+ # not a small fix, for a narrow case (reading this on a replay that
504
+ # didn't complete).
505
+ def diff_entry(position, original_attempts)
506
+ original = original_attempts.max_by(&:attempt)
507
+ mine = steps.find_by(position: position)
508
+ replayed = !mine.nil?
509
+ { position: position, name: original.name, kind: original.kind, original_output: original.output,
510
+ replay_output: mine&.output, replayed: replayed, changed: replayed && original.output != mine.output }
511
+ end
512
+
513
+ def refuse_unless_replayable!
514
+ current_version = workflow_class_for_replay.workflow_version
515
+ return if current_version == workflow_version
516
+
517
+ raise Error, "belay_runs##{id} ran against workflow_version #{workflow_version.inspect}, but " \
518
+ "#{workflow} is now at #{current_version.inspect} - the workflow definition has " \
519
+ "changed since this run, so replaying it would compare against a step list that " \
520
+ "no longer matches"
521
+ end
522
+
523
+ # Same clear-error-on-a-bad-constant shape as Executor's own private
524
+ # workflow_class (executor.rb) - not reused directly since that method
525
+ # reads @run, an Executor ivar this is a different class entirely, and
526
+ # duplicating three lines here is simpler than extracting a shared helper
527
+ # for the only two call sites that need it.
528
+ def workflow_class_for_replay
529
+ workflow.constantize
530
+ rescue NameError => e
531
+ raise Error, "belay_runs##{id} names workflow #{workflow.inspect}, which isn't a loadable constant " \
532
+ "(#{e.message})"
533
+ end
534
+
535
+ # Whether `position` should be re-executed fresh (true, an llm_call step)
536
+ # rather than served from the recorded output (false). The CURRENT
537
+ # workflow class's own declared kind: wins when the author stated one
538
+ # explicitly - :gate always counts, nil never does, same "explicit kind:
539
+ # is never overridden" rule the executor itself already applies to a
540
+ # live run - falling back to what actually happened when this position
541
+ # last ran only when the author declared no kind: at all. refuse_unless_
542
+ # replayable! having already passed guarantees that wherever the CURRENT
543
+ # class DOES declare a kind: explicitly, it's identical to whatever ran
544
+ # originally (workflow_version hashes exactly that pair, per .step's own
545
+ # comment) - so the only real ambiguity left is a step with no explicit
546
+ # kind: declared on either version, where runtime behavior (whether the
547
+ # block calls ctx.llm) silently changed. That's a real, accepted
548
+ # limitation, not solved here: workflow_version hashes declared kind, not
549
+ # runtime behavior, by design, so it can't catch this either. Authors who
550
+ # want a step's llm/non-llm nature to stay replay-safe across such a
551
+ # change should declare kind: explicitly.
552
+ def llm_position?(position, original)
553
+ declared = workflow_class_for_replay.steps[position]&.kind
554
+ return declared.to_s == "llm_call" if declared
555
+
556
+ original.kind == "llm_call"
557
+ end
558
+
559
+ # Copies every non-llm_call step's FINAL attempt (not every attempt - a
560
+ # shadow run doesn't need to replay retries that happened, just the
561
+ # outcome that mattered) into the shadow run, before Executor ever runs
562
+ # it. The executor's own dispatch - completely unmodified - already
563
+ # reuses a succeeded/skipped row's output without re-running its block.
564
+ # That's the entire mechanism behind "serve tool/non-LLM steps from
565
+ # recorded outputs" and "reuse recorded gate decisions" - a gate needs no
566
+ # special-casing because it's just another kind: that isn't "llm_call".
567
+ #
568
+ # A non-llm step whose recorded status ISN'T succeeded/skipped (an
569
+ # ordinary step genuinely failed and never resolved, or a gate that was
570
+ # rejected) is seeded as needs_review regardless of what it actually
571
+ # was - NOT "failed". Caught by review: a pre-seeded "failed" row's fate
572
+ # under dispatch's retry_or_raise depends on step_run.max_attempts,
573
+ # computed from the CURRENT workflow's retries: option - which
574
+ # workflow_version does NOT hash (deliberately - see .step's own
575
+ # comment), so a step innocently gaining retries: between the original
576
+ # run and replay time could make dispatch silently RE-EXECUTE the block
577
+ # for real, repeating a side effect. needs_review's raise
578
+ # (unsafe_to_rerun_error) has no such dependency: it always raises,
579
+ # unconditionally, regardless of attempt/max_attempts - giving replay a
580
+ # deterministic "stop here" with no retries:-shaped escape hatch, for a
581
+ # rejected gate exactly the same as for an ordinary step's unresolved
582
+ # failure.
583
+ #
584
+ # idempotency_key's format ("#{run.id}:#{position}") duplicates
585
+ # Executor::StepRunner's own private method of the same shape - a
586
+ # different class, and copying a one-line string format is simpler than
587
+ # plumbing a shared helper for it.
588
+ def seed_recorded_steps!(shadow)
589
+ steps.group_by(&:position).each do |position, attempts|
590
+ original = attempts.max_by(&:attempt)
591
+ next if llm_position?(position, original)
592
+
593
+ seed_one_recorded_step!(shadow, position, original)
594
+ end
595
+ end
596
+
597
+ def seed_one_recorded_step!(shadow, position, original)
598
+ attrs = if %w[succeeded skipped].include?(original.status)
599
+ { status: original.status, output: original.output }
600
+ else
601
+ reason = "belay_steps##{original.id} in the original run was #{original.status} - " \
602
+ "replay stops at the same point rather than guessing past it"
603
+ { status: "needs_review", error: { "reason" => reason } }
604
+ end
605
+
606
+ shadow.steps.create!(attrs.merge(position: position, name: original.name, kind: original.kind,
607
+ idempotency_key: "#{shadow.id}:#{position}"))
608
+ end
609
+
610
+ # Each bang method declares its own required source status(es), rather than checking
611
+ # a target-reachability graph: "running" is a legal target from both "pending" (start!)
612
+ # and "paused" (resume!), and those two have different meanings that a graph keyed only
613
+ # by target can't distinguish - it would let resume! fire on a never-started run.
614
+ #
615
+ # No concurrency guard: this reads status in memory, then does a plain update!. Two
616
+ # processes racing on the same run (stale in-memory copies) can both pass the check.
617
+ # Deferred to the leases work (Phase 1f) rather than solved here.
618
+ def transition!(from:, to:, **attrs)
619
+ allowed_from = Array(from)
620
+ unless allowed_from.include?(status)
621
+ raise InvalidTransitionError,
622
+ "cannot transition belay_runs##{id} from #{status.inspect} to #{to.inspect} " \
623
+ "(expected current status to be #{allowed_from.inspect})"
624
+ end
625
+
626
+ update!(**attrs, status: to)
627
+ end
628
+ end
629
+ end