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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +155 -0
- data/app/assets/belay/application.css +1 -0
- data/app/controllers/belay/application_controller.rb +48 -0
- data/app/controllers/belay/runs_controller.rb +166 -0
- data/app/controllers/belay/stats_controller.rb +14 -0
- data/app/helpers/belay/application_helper.rb +76 -0
- data/app/views/belay/runs/_header.html.erb +20 -0
- data/app/views/belay/runs/_pending_gate.html.erb +25 -0
- data/app/views/belay/runs/_pending_needs_review.html.erb +30 -0
- data/app/views/belay/runs/_replayed_from.html.erb +7 -0
- data/app/views/belay/runs/_replays.html.erb +14 -0
- data/app/views/belay/runs/_step.html.erb +52 -0
- data/app/views/belay/runs/diff.html.erb +47 -0
- data/app/views/belay/runs/index.html.erb +60 -0
- data/app/views/belay/runs/show.html.erb +34 -0
- data/app/views/belay/shared/_flash.html.erb +11 -0
- data/app/views/belay/shared/_sidebar.html.erb +22 -0
- data/app/views/belay/stats/_chart.html.erb +56 -0
- data/app/views/belay/stats/_table.html.erb +22 -0
- data/app/views/belay/stats/index.html.erb +16 -0
- data/app/views/layouts/belay/application.html.erb +19 -0
- data/config/routes.rb +13 -0
- data/lib/belay/anthropic_provider.rb +166 -0
- data/lib/belay/application_record.rb +7 -0
- data/lib/belay/approval.rb +35 -0
- data/lib/belay/cache_entry.rb +65 -0
- data/lib/belay/configuration.rb +60 -0
- data/lib/belay/context.rb +241 -0
- data/lib/belay/daily_stats.rb +60 -0
- data/lib/belay/engine.rb +40 -0
- data/lib/belay/executor.rb +411 -0
- data/lib/belay/fake_provider.rb +41 -0
- data/lib/belay/janitor_job.rb +48 -0
- data/lib/belay/provider.rb +15 -0
- data/lib/belay/provider_response.rb +19 -0
- data/lib/belay/resume_run_job.rb +42 -0
- data/lib/belay/rule.rb +113 -0
- data/lib/belay/run.rb +629 -0
- data/lib/belay/step.rb +57 -0
- data/lib/belay/version.rb +5 -0
- data/lib/belay/workflow.rb +199 -0
- data/lib/belay.rb +49 -0
- data/lib/generators/belay/install/install_generator.rb +48 -0
- data/lib/generators/belay/install/templates/create_belay_approvals.rb.tt +43 -0
- data/lib/generators/belay/install/templates/create_belay_cache_entries.rb.tt +27 -0
- data/lib/generators/belay/install/templates/create_belay_rules.rb.tt +46 -0
- data/lib/generators/belay/install/templates/create_belay_runs.rb.tt +37 -0
- data/lib/generators/belay/install/templates/create_belay_steps.rb.tt +58 -0
- 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
|