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
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belay
|
|
4
|
+
# Runs a workflow's steps in order against a Run: start/complete the run itself,
|
|
5
|
+
# iterate steps in order, hand each one to StepRunner. The per-step mechanics
|
|
6
|
+
# (checkpointing, resume, retries) are StepRunner's job, not this class's - they're
|
|
7
|
+
# genuinely separate responsibilities, which is why they're split out below rather
|
|
8
|
+
# than all flattened into one large class.
|
|
9
|
+
class Executor
|
|
10
|
+
def initialize(run)
|
|
11
|
+
@run = run
|
|
12
|
+
@step_runner = StepRunner.new(run)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# catch(:gate_paused), not a rescue: a gate whose when: evaluates true throws
|
|
16
|
+
# this tag (see StepRunner#evaluate_gate!) after already pausing the run and
|
|
17
|
+
# releasing its lease - a throw, not a raise, specifically so it unwinds past
|
|
18
|
+
# run_attempt's rescue StandardError untouched (throw/catch and raise/rescue
|
|
19
|
+
# are separate mechanisms in Ruby; no rescue clause anywhere in between ever
|
|
20
|
+
# sees it), skipping both the generic succeeded/failed checkpoint AND the
|
|
21
|
+
# complete! call below, with no further steps run.
|
|
22
|
+
def call
|
|
23
|
+
@run.start! if @run.pending?
|
|
24
|
+
outputs = {}
|
|
25
|
+
|
|
26
|
+
catch(:gate_paused) do
|
|
27
|
+
workflow_class.steps.each_with_index do |step_definition, position|
|
|
28
|
+
outputs[step_definition.name] = @step_runner.run(step_definition, position, outputs)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Guarded the same way start! is above: a second call on an already-
|
|
32
|
+
# completed run must be a clean no-op, not InvalidTransitionError on
|
|
33
|
+
# complete! -> complete!.
|
|
34
|
+
@run.complete! if @run.running?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@run
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @run.pending? above guards start! itself. complete! above assumes every call to
|
|
43
|
+
# this method runs all the way to the end successfully - calling this again on a
|
|
44
|
+
# run some OTHER code path already moved to failed/cancelled (nothing does that
|
|
45
|
+
# yet) would skip/re-run steps as normal but never re-reach complete!, since
|
|
46
|
+
# status never returns to "running". Not handled; stated here, not silently.
|
|
47
|
+
def workflow_class
|
|
48
|
+
@run.workflow.constantize
|
|
49
|
+
rescue NameError => e
|
|
50
|
+
raise Error, "belay_runs##{@run.id} names workflow #{@run.workflow.inspect}, which isn't a loadable " \
|
|
51
|
+
"constant (#{e.message})"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Runs one step to completion: checkpointing ("started" before the block, then
|
|
55
|
+
# "succeeded"/"failed" after - no transaction spans the block call itself,
|
|
56
|
+
# DESIGN_DECISIONS #4), resuming an orphaned started row in place, skipping an
|
|
57
|
+
# already-succeeded one (reusing its output, deep-symbolized so a later step's
|
|
58
|
+
# ctx.extract[:key]-style access sees the same shape a fresh run would), and
|
|
59
|
+
# retrying up to step(..., retries: N) additional attempts on a raise, each its
|
|
60
|
+
# own row sharing the position's stable idempotency_key (DESIGN_DECISIONS #3).
|
|
61
|
+
#
|
|
62
|
+
# retries: is a different, narrower kind of safety than the idempotent:/
|
|
63
|
+
# needs_review gate for an orphaned started row: that gate decides whether
|
|
64
|
+
# resuming a crash is actually safe (idempotent: true resumes it; otherwise the
|
|
65
|
+
# row is flagged needs_review and the run stops for a human), where retries:
|
|
66
|
+
# only ever answers "did retries run out" for a row the author already opted in
|
|
67
|
+
# to retrying. retries: is the step author opting a specific step into automatic
|
|
68
|
+
# retry - the same contract
|
|
69
|
+
# every job-retry system has (Sidekiq's retry:, ActiveJob's retry_on): declaring
|
|
70
|
+
# it does not make a step's side effects safe to repeat, it says the author has
|
|
71
|
+
# already made them safe (or the step has none). A step whose block charges a
|
|
72
|
+
# card and then raises will be retried exactly as declared, with no additional
|
|
73
|
+
# gate - that's what ctx.step_idempotency_key is for passing to an idempotent-
|
|
74
|
+
# aware external API, if the author chooses to; nothing here enforces that they
|
|
75
|
+
# do. The two commits are separate because the safety question is different, not
|
|
76
|
+
# because one is "done" and the other "also needed."
|
|
77
|
+
class StepRunner # rubocop:disable Metrics/ClassLength
|
|
78
|
+
# A step declared without kind: (every current README Target API example) can't
|
|
79
|
+
# be written to belay_steps as-is - kind is NOT NULL with no DB default, unlike
|
|
80
|
+
# status. "decision" is the generic catch-all of the four enum values for a step
|
|
81
|
+
# that's plain Ruby, not specifically an llm_call/tool_call/gate. Written at
|
|
82
|
+
# start_checkpoint! time, before the block has run - run_attempt below upgrades
|
|
83
|
+
# it to "llm_call" after the fact if the block actually called ctx.llm and the
|
|
84
|
+
# author never declared kind: explicitly (an explicit kind: is never overridden).
|
|
85
|
+
DEFAULT_KIND = "decision"
|
|
86
|
+
|
|
87
|
+
# Returned by evaluate_gate! only for the rule-auto-settled case (see
|
|
88
|
+
# auto_settle_gate! below) - a sentinel distinguishing "this gate step's row
|
|
89
|
+
# is already fully, correctly written" from a real output value, since a
|
|
90
|
+
# gate's own legitimate output (nil, same as approve! with no
|
|
91
|
+
# edited_payload) is otherwise indistinguishable from "nothing happened
|
|
92
|
+
# yet." run_attempt checks for this by identity, not by nil, specifically
|
|
93
|
+
# so it never re-checkpoints over what auto_settle_gate! just wrote.
|
|
94
|
+
AUTO_SETTLED = Object.new.freeze
|
|
95
|
+
private_constant :AUTO_SETTLED
|
|
96
|
+
|
|
97
|
+
# What one step's execution needs, bundled so passing it around doesn't mean a
|
|
98
|
+
# five-plus-argument parameter list on every private method below.
|
|
99
|
+
StepRun = Struct.new(:step_definition, :position, :outputs, :max_attempts, keyword_init: true)
|
|
100
|
+
|
|
101
|
+
def initialize(run)
|
|
102
|
+
@run = run
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def run(step_definition, position, outputs)
|
|
106
|
+
max_attempts = 1 + (step_definition.options[:retries] || 0)
|
|
107
|
+
step_run = StepRun.new(step_definition: step_definition, position: position, outputs: outputs,
|
|
108
|
+
max_attempts: max_attempts)
|
|
109
|
+
dispatch(step_run, latest_attempt(position))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# Step#status has six legal values now. "succeeded" and "skipped" resume the
|
|
115
|
+
# same way - reuse the stored output, never re-evaluate (DESIGN_DECISIONS #7:
|
|
116
|
+
# a gate's when: is not re-run against whatever context resume happens to
|
|
117
|
+
# have). needs_review/awaiting_approval were each left that way for a reason
|
|
118
|
+
# this can't resolve itself - re-running either would be guessing, same as a
|
|
119
|
+
# freshly-flagged needs_review row would be if dispatch were called on it
|
|
120
|
+
# again in the same process (it never is, today: flagging always raises
|
|
121
|
+
# right after, see flag_needs_review! below). An awaiting_approval row is
|
|
122
|
+
# resolved the same way: Run#approve!/#reject! move it to succeeded/failed
|
|
123
|
+
# BEFORE the executor is ever invoked again, so dispatch here never actually
|
|
124
|
+
# sees "awaiting_approval" on a row a human has already acted on - this
|
|
125
|
+
# branch only fires if something re-dispatches the run while the gate is
|
|
126
|
+
# genuinely still unresolved.
|
|
127
|
+
def dispatch(step_run, existing) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
128
|
+
case existing&.status
|
|
129
|
+
when nil
|
|
130
|
+
attempt_step(step_run, attempt: 1)
|
|
131
|
+
when "started"
|
|
132
|
+
resume_orphaned_started(step_run, existing)
|
|
133
|
+
when "succeeded", "skipped"
|
|
134
|
+
symbolize_output(existing.output)
|
|
135
|
+
when "failed"
|
|
136
|
+
retry_or_raise(step_run, existing)
|
|
137
|
+
when "needs_review"
|
|
138
|
+
raise unsafe_to_rerun_error(step_run, existing)
|
|
139
|
+
when "awaiting_approval"
|
|
140
|
+
raise Error, "belay_steps##{existing.id} at position #{step_run.position} is awaiting_approval - " \
|
|
141
|
+
"resolve it via run.approve!/reject! first"
|
|
142
|
+
else
|
|
143
|
+
raise Error, "belay_steps##{existing.id} at position #{step_run.position} has unrecognized status " \
|
|
144
|
+
"#{existing.status.inspect} - refusing to guess what resuming it should do"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# DESIGN_DECISIONS #2: a step found "started" was interrupted before anything
|
|
149
|
+
# caught an exception for it (retries: never applies) - nobody asked this
|
|
150
|
+
# executor to retry it. idempotent: true is the author's explicit sign-off that
|
|
151
|
+
# redoing it is safe; without it, retrying automatically would be guessing
|
|
152
|
+
# exactly as much as the unsafe_to_rerun_error cases below - so instead the row
|
|
153
|
+
# is flagged needs_review for a human, exactly as decision #2 says, and the run
|
|
154
|
+
# stops (flag_needs_review! raises; nothing resumes a needs_review run yet -
|
|
155
|
+
# that's dashboard/resolve-action work, not built).
|
|
156
|
+
#
|
|
157
|
+
# options[:idempotent] == true, not bare truthiness: nothing validates step
|
|
158
|
+
# option values at declare time (unlike kind:, see validate_step_kind! in
|
|
159
|
+
# Workflow), so idempotent: "false" - a string, not a boolean, from an author
|
|
160
|
+
# who doesn't think in Ruby truthiness - would otherwise be truthy and silently
|
|
161
|
+
# authorize resuming a step the author was trying to mark unsafe.
|
|
162
|
+
#
|
|
163
|
+
# Applies to an orphaned gate exactly the same way, even though re-evaluating
|
|
164
|
+
# a pure when: condition is arguably always safe regardless of idempotent: -
|
|
165
|
+
# a gate crashing mid-evaluation is rare and not this commit's problem to
|
|
166
|
+
# solve; an author who wants one resumed without flagging needs_review can
|
|
167
|
+
# already declare idempotent: true on it today, same as any step.
|
|
168
|
+
def resume_orphaned_started(step_run, existing)
|
|
169
|
+
return attempt_step(step_run, attempt: existing.attempt, reuse: existing) if idempotent?(step_run)
|
|
170
|
+
|
|
171
|
+
flag_needs_review!(step_run, existing)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def idempotent?(step_run)
|
|
175
|
+
step_run.step_definition.options[:idempotent] == true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def flag_needs_review!(step_run, existing)
|
|
179
|
+
reason = "belay_steps##{existing.id} at position #{step_run.position} was left \"started\" (by a " \
|
|
180
|
+
"crash, or by another worker concurrently running this same step - no lease mechanism " \
|
|
181
|
+
"exists yet to tell which) and step :#{step_run.step_definition.name} isn't declared " \
|
|
182
|
+
"idempotent: true"
|
|
183
|
+
existing.update!(status: "needs_review", error: { "reason" => reason })
|
|
184
|
+
# pause!, not leaving status at "running": otherwise Run.where(status: "running")
|
|
185
|
+
# can't distinguish a run legitimately executing from one silently stuck on a
|
|
186
|
+
# human. Run#pause! already exists (running -> paused) for exactly this shape
|
|
187
|
+
# of "stopped, not failed, waiting on something external."
|
|
188
|
+
@run.pause!
|
|
189
|
+
raise Error, "#{reason} - flagged needs_review; the run is paused until a human resolves it"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def unsafe_to_rerun_error(step_run, existing)
|
|
193
|
+
Error.new(
|
|
194
|
+
"belay_steps##{existing.id} at position #{step_run.position} is needs_review - resolve it via " \
|
|
195
|
+
"run.resolve_needs_review! first"
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def retry_or_raise(step_run, existing)
|
|
200
|
+
if existing.attempt < step_run.max_attempts
|
|
201
|
+
attempt_step(step_run, attempt: existing.attempt + 1)
|
|
202
|
+
else
|
|
203
|
+
raise Error, "belay_steps##{existing.id} at position #{step_run.position} failed on its final attempt " \
|
|
204
|
+
"(#{existing.attempt}/#{step_run.max_attempts}): #{existing.error}"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Loops in-process across retries (no separate Executor#call per attempt): each
|
|
209
|
+
# failure is caught and marked on its own row by run_attempt, then either a
|
|
210
|
+
# fresh attempt follows or the error is re-raised once max_attempts is reached.
|
|
211
|
+
def attempt_step(step_run, attempt:, reuse: nil)
|
|
212
|
+
loop do
|
|
213
|
+
step = reuse || start_checkpoint!(step_run, attempt)
|
|
214
|
+
output, error = run_attempt(step_run, step)
|
|
215
|
+
return output unless error
|
|
216
|
+
|
|
217
|
+
raise error if attempt >= step_run.max_attempts
|
|
218
|
+
|
|
219
|
+
attempt += 1
|
|
220
|
+
reuse = nil
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# One attempt: run the block (or, for a gate, evaluate when:), checkpoint the
|
|
225
|
+
# result. Returns [output, nil] on success or [nil, error] on failure - never
|
|
226
|
+
# raises itself, so the retry loop above decides whether to try again or
|
|
227
|
+
# propagate. context is built before the risky call so the rescue clause can
|
|
228
|
+
# still reach its llm_metadata - ctx.llm may have already made a real, billed
|
|
229
|
+
# provider call before the block went on to raise for an unrelated reason;
|
|
230
|
+
# that cost is recorded on the failed row too, not dropped.
|
|
231
|
+
#
|
|
232
|
+
# A gate reuses this same checkpoint/retry machinery rather than a separate
|
|
233
|
+
# path: start_checkpoint! already writes kind: :gate correctly (it was never
|
|
234
|
+
# nil, so DEFAULT_KIND never kicks in), and evaluate_gate! below stands in for
|
|
235
|
+
# block.call - when: false settles the row exactly like a step's output
|
|
236
|
+
# would, just under status: "skipped" instead of "succeeded". when: true
|
|
237
|
+
# takes a different exit entirely (throw, not return) - see evaluate_gate!.
|
|
238
|
+
def run_attempt(step_run, step)
|
|
239
|
+
context = build_context(step_run)
|
|
240
|
+
output = run_block(step_run, context, step)
|
|
241
|
+
return [nil, nil] if output.equal?(AUTO_SETTLED) # auto_settle_gate! already wrote this row itself
|
|
242
|
+
|
|
243
|
+
step.update!(status: settled_status(step_run), output: output, **extra_step_attrs(step_run, context))
|
|
244
|
+
[output, nil]
|
|
245
|
+
rescue StandardError => e
|
|
246
|
+
step.update!(status: "failed", error: { "class" => e.class.name, "message" => e.message },
|
|
247
|
+
**extra_step_attrs(step_run, context))
|
|
248
|
+
[nil, e]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Wrapped in @run.heartbeat_while, not called bare - this is the one call in
|
|
252
|
+
# the whole step/gate lifecycle that can plausibly run longer than
|
|
253
|
+
# lease_duration (a model call, or a gate's own when: doing something
|
|
254
|
+
# slow), so it's the only thing that needs a concurrent heartbeat. Every
|
|
255
|
+
# other method here (checkpointing, retries, dispatch) is fast,
|
|
256
|
+
# synchronous Ruby - heartbeating around those too would just spin up
|
|
257
|
+
# threads for no reason.
|
|
258
|
+
def run_block(step_run, context, step)
|
|
259
|
+
@run.heartbeat_while do
|
|
260
|
+
next evaluate_gate!(step_run, context, step) if step_run.step_definition.kind == :gate
|
|
261
|
+
|
|
262
|
+
step_run.step_definition.block.call(context)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def settled_status(step_run)
|
|
267
|
+
step_run.step_definition.kind == :gate ? "skipped" : "succeeded"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# when: false settles normally (returns nil, caller checkpoints "skipped").
|
|
271
|
+
# when: true either auto-settles (auto_settle_gate!) or pauses
|
|
272
|
+
# (pause_on_gate!) - see each for its own reasoning.
|
|
273
|
+
def evaluate_gate!(step_run, context, step)
|
|
274
|
+
condition = step_run.step_definition.options[:when]
|
|
275
|
+
return nil unless condition.call(context)
|
|
276
|
+
|
|
277
|
+
# Computed from the live Context's own symbol-keyed Hashes, before anything
|
|
278
|
+
# round-trips through jsonb - see Rule.context_key_for and belay_rules' own
|
|
279
|
+
# migration comment for why that ordering matters. Stashed on whichever of
|
|
280
|
+
# the two paths below actually settles this gate, so Run#approve!/reject!
|
|
281
|
+
# and the dashboard can both read it back later without re-deriving
|
|
282
|
+
# outputs-so-far themselves.
|
|
283
|
+
context_key = Rule.context_key_for(input: context.input, outputs: context.outputs, condition: condition)
|
|
284
|
+
rule = matching_active_rule(step_run, context_key)
|
|
285
|
+
return auto_settle_gate!(step, context_key, rule) if rule
|
|
286
|
+
|
|
287
|
+
pause_on_gate!(step, context_key)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def matching_active_rule(step_run, context_key)
|
|
291
|
+
Rule.active_for(workflow: @run.workflow, workflow_version: @run.workflow_version,
|
|
292
|
+
gate_name: step_run.step_definition.name, context_key: context_key)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Auto-approves this exact gate occurrence without ever pausing for a human,
|
|
296
|
+
# because Belay::Rule already learned it (status: "active") from N
|
|
297
|
+
# consistent, unedited human approvals of this identical (workflow,
|
|
298
|
+
# workflow_version, gate_name, context_key) - see feat(rules): learn a
|
|
299
|
+
# rule.... output: nil, same shape approve! leaves when no edited_payload
|
|
300
|
+
# was given: an auto-settled gate is indistinguishable from an unedited
|
|
301
|
+
# human approval except for rule_applied_id being set, and there's no
|
|
302
|
+
# Belay::Approval row, since no human actually decided this one.
|
|
303
|
+
#
|
|
304
|
+
# A single atomic UPDATE is enough here, unlike pause_on_gate! below's
|
|
305
|
+
# multi-write transaction: this path never touches the run's own status or
|
|
306
|
+
# its lease, so there's no multi-table invariant a mid-write crash could
|
|
307
|
+
# leave half-done - the existing orphaned-"started"-gate recovery
|
|
308
|
+
# (resume_orphaned_started's idempotent?/needs_review branch) already
|
|
309
|
+
# covers a crash before this single write commits, same as it does for the
|
|
310
|
+
# when: false "skipped" path.
|
|
311
|
+
def auto_settle_gate!(step, context_key, rule)
|
|
312
|
+
step.update!(status: "succeeded", output: nil, input: { "context_key" => context_key },
|
|
313
|
+
rule_applied_id: rule.id)
|
|
314
|
+
AUTO_SETTLED
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# when: true, no matching active rule: pauses right here instead of
|
|
318
|
+
# returning. This step's own row becomes "awaiting_approval" (not
|
|
319
|
+
# "succeeded", not "failed" - run_attempt's own checkpoint line never
|
|
320
|
+
# runs, since throw skips straight past it, same as it skips the rescue),
|
|
321
|
+
# the run is paused and its lease released, then throw :gate_paused unwinds
|
|
322
|
+
# all the way up to Executor#call's catch, skipping every remaining step
|
|
323
|
+
# and the complete! call - cleanly, not via an exception any rescue
|
|
324
|
+
# StandardError could intercept or retry. That's the actual fix for an
|
|
325
|
+
# earlier commit's documented retries: gap: there's no raise on this path
|
|
326
|
+
# for retries: to apply to at all.
|
|
327
|
+
#
|
|
328
|
+
# The three writes below are wrapped in one transaction, and the throw
|
|
329
|
+
# happens AFTER it (not inside it): a process killed mid-transaction rolls
|
|
330
|
+
# back to the gate's original "started" row, which is the already-handled
|
|
331
|
+
# crash path (idempotent?/needs_review) - not a new, worse state. Without
|
|
332
|
+
# the transaction, a crash between step.update! and @run.pause! would leave
|
|
333
|
+
# the step "awaiting_approval" while the run stays "running": JanitorJob's
|
|
334
|
+
# own query (status: running, lease expired or none - today's realistic
|
|
335
|
+
# case, since inline Workflow.start never takes a lease at all) would pick
|
|
336
|
+
# that run back up and hand it to ResumeRunJob, which has no rescue around
|
|
337
|
+
# Executor.new(run).call - dispatch's awaiting_approval branch just raises,
|
|
338
|
+
# uncaught, forever, every lease cycle, and the run never reaches "paused"
|
|
339
|
+
# at all. Caught by review before this landed; keeping the throw outside the
|
|
340
|
+
# transaction block (rather than inside it) is deliberate too - a throw is
|
|
341
|
+
# not an exception, so it wouldn't trigger Rails' own rollback-on-exception
|
|
342
|
+
# path, but there's no reason to rely on that distinction when the simpler
|
|
343
|
+
# structure (commit first, throw after) removes the question entirely.
|
|
344
|
+
def pause_on_gate!(step, context_key)
|
|
345
|
+
ActiveRecord::Base.transaction do
|
|
346
|
+
step.update!(status: "awaiting_approval", input: { "context_key" => context_key })
|
|
347
|
+
@run.pause!
|
|
348
|
+
@run.release_lease!
|
|
349
|
+
end
|
|
350
|
+
throw :gate_paused
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def extra_step_attrs(step_run, context)
|
|
354
|
+
return {} if context.nil?
|
|
355
|
+
|
|
356
|
+
attrs = context.llm_metadata
|
|
357
|
+
attrs = attrs.merge(kind: "llm_call") if step_run.step_definition.kind.nil? && !attrs.empty?
|
|
358
|
+
attrs
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def build_context(step_run)
|
|
362
|
+
Context.new(
|
|
363
|
+
input: @run.input,
|
|
364
|
+
outputs: step_run.outputs,
|
|
365
|
+
step_idempotency_key: idempotency_key(step_run.position),
|
|
366
|
+
model_override: @run.replay_model
|
|
367
|
+
)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def start_checkpoint!(step_run, attempt)
|
|
371
|
+
Step.create!(
|
|
372
|
+
run: @run,
|
|
373
|
+
position: step_run.position,
|
|
374
|
+
attempt: attempt,
|
|
375
|
+
name: step_run.step_definition.name,
|
|
376
|
+
kind: step_run.step_definition.kind || DEFAULT_KIND,
|
|
377
|
+
status: "started",
|
|
378
|
+
idempotency_key: idempotency_key(step_run.position)
|
|
379
|
+
)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# attempt is no longer always 1 (retries), so the latest attempt's row is what a
|
|
383
|
+
# resume/skip decision must look at, not an arbitrary one.
|
|
384
|
+
def latest_attempt(position)
|
|
385
|
+
Step.where(run: @run, position: position).order(attempt: :desc).first
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# A step's output read back from the DB (jsonb round-trips through JSON) has
|
|
389
|
+
# string keys, even though the very same Hash returned fresh from a block in
|
|
390
|
+
# this same process would have symbol keys. Without this, a step block written
|
|
391
|
+
# like the README's own ctx.extract[:total_cents] would get nil instead of the
|
|
392
|
+
# real value the moment the step it depends on was skipped rather than freshly
|
|
393
|
+
# run - silently, since symbol access on a string-keyed Hash just returns nil,
|
|
394
|
+
# not an error. Only one level deep matters here (only Hash has
|
|
395
|
+
# deep_symbolize_keys); scalars and bare Arrays pass through untouched.
|
|
396
|
+
def symbolize_output(output)
|
|
397
|
+
output.is_a?(Hash) ? output.deep_symbolize_keys : output
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# run_id + position only, matching DESIGN_DECISIONS #3 - stable across attempts,
|
|
401
|
+
# so a retry presents the same key to an external API. Used both for what's
|
|
402
|
+
# written to the row (idempotency_key is NOT NULL on belay_steps) and for what's
|
|
403
|
+
# exposed to the step's own block as ctx.step_idempotency_key, via build_context
|
|
404
|
+
# above - the two must be the same value, which is why both read from this one
|
|
405
|
+
# method.
|
|
406
|
+
def idempotency_key(position)
|
|
407
|
+
"#{@run.id}:#{position}"
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belay
|
|
4
|
+
# A Provider for specs: no network, a fixed script of responses, and a record of
|
|
5
|
+
# every call so a spec can assert what was actually sent. See section 9 of the
|
|
6
|
+
# architecture notes ("FakeProvider with scripted responses - no network in tests").
|
|
7
|
+
class FakeProvider < Provider
|
|
8
|
+
class ScriptExhaustedError < Error; end
|
|
9
|
+
|
|
10
|
+
# responses: each entry is either a ProviderResponse (full control over model/
|
|
11
|
+
# tokens/cost/latency) or a plain String/anything else, auto-wrapped as
|
|
12
|
+
# ProviderResponse.new(content: entry, model: model) using whatever model the
|
|
13
|
+
# matching call actually asked for - the common "just give back this content" case
|
|
14
|
+
# shouldn't need to predict which model name will be passed.
|
|
15
|
+
def initialize(responses: [])
|
|
16
|
+
super()
|
|
17
|
+
@responses = responses.dup
|
|
18
|
+
@calls = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Frozen dup, same reasoning as Workflow#steps/#inputs: a spec reaching in and
|
|
22
|
+
# doing provider.calls.clear or provider.calls << {...} would otherwise corrupt
|
|
23
|
+
# this provider's own record of what it actually received.
|
|
24
|
+
def calls
|
|
25
|
+
@calls.dup.freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(model:, prompt:, schema: nil)
|
|
29
|
+
@calls << { model: model, prompt: prompt, schema: schema }.freeze
|
|
30
|
+
|
|
31
|
+
if @responses.empty?
|
|
32
|
+
raise ScriptExhaustedError,
|
|
33
|
+
"#{self.class} received call ##{@calls.size} (model: #{model.inspect}) with no scripted " \
|
|
34
|
+
"response left - pass more entries to responses:"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
response = @responses.shift
|
|
38
|
+
response.is_a?(ProviderResponse) ? response : ProviderResponse.new(content: response, model: model)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module Belay
|
|
6
|
+
# Finds runs stuck in "running" with no live lease - either the lease has expired,
|
|
7
|
+
# or (today's realistic case, since Workflow.start still runs the executor inline,
|
|
8
|
+
# not through ResumeRunJob) no lease was ever taken out before the process
|
|
9
|
+
# crashed - and re-enqueues ResumeRunJob for each. Doesn't reclaim anything itself:
|
|
10
|
+
# acquire_lease!'s conditional UPDATE is still the only thing that actually claims
|
|
11
|
+
# a lease, so if this runs twice, or races a worker that's about to heartbeat, the
|
|
12
|
+
# side that loses that race quietly no-ops (same as any other lease contention)
|
|
13
|
+
# rather than the two stepping on each other.
|
|
14
|
+
#
|
|
15
|
+
# Deliberately leaves paused runs alone: a run paused via flag_needs_review! is
|
|
16
|
+
# waiting on a human decision, not a crashed worker - re-enqueuing it would just
|
|
17
|
+
# have ResumeRunJob acquire the lease and Executor hit the same needs_review step
|
|
18
|
+
# again, achieving nothing. Only something that actually resolves the review (not
|
|
19
|
+
# built yet) should move a paused run back to running.
|
|
20
|
+
#
|
|
21
|
+
# Nothing schedules this job on a recurring basis - that's a host app's concern
|
|
22
|
+
# (cron, sidekiq-cron, whenever, etc.), not this gem's.
|
|
23
|
+
#
|
|
24
|
+
# DESIGN_DECISIONS #12 sizes lease_duration/heartbeat_interval assuming
|
|
25
|
+
# something heartbeats roughly every heartbeat_interval while a step is in
|
|
26
|
+
# flight, so a live worker's lease never actually goes stale under this
|
|
27
|
+
# job's nose - Run#heartbeat_while (wired into Executor::StepRunner#run_block)
|
|
28
|
+
# is that something, a background thread renewing the lease for as long as
|
|
29
|
+
# a single step's own block or gate when: is running. A step genuinely
|
|
30
|
+
# slower than heartbeat_while's own thread can keep up with (the process
|
|
31
|
+
# died, or heartbeat_lease! itself started failing because another worker
|
|
32
|
+
# already won the lease) still falls through to this job reclaiming it,
|
|
33
|
+
# same as before - heartbeating narrows the race, it doesn't close it by
|
|
34
|
+
# itself for every possible failure mode.
|
|
35
|
+
class JanitorJob < ActiveJob::Base
|
|
36
|
+
def perform
|
|
37
|
+
stale_runs.find_each do |run|
|
|
38
|
+
ResumeRunJob.perform_later(run.id)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def stale_runs
|
|
45
|
+
Run.running.where("lease_owner IS NULL OR lease_expires_at < ?", Time.current)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belay
|
|
4
|
+
# Interface every provider (FakeProvider, and later AnthropicProvider) implements. Pure
|
|
5
|
+
# interface, no shared logic: #call must return a Belay::ProviderResponse or raise.
|
|
6
|
+
#
|
|
7
|
+
# schema: is for structured output (see README's Target API, llm(schema: InvoiceSchema))
|
|
8
|
+
# - not validated or interpreted here. That belongs to the llm helper (Phase 1g), once
|
|
9
|
+
# it exists to decide what "validate against a schema" even means for this gem.
|
|
10
|
+
class Provider
|
|
11
|
+
def call(model:, prompt:, schema: nil)
|
|
12
|
+
raise NotImplementedError, "#{self.class} must implement #call(model:, prompt:, schema: nil)"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belay
|
|
4
|
+
# What every Belay::Provider#call returns. content and model are required - a response
|
|
5
|
+
# without either is a bug in the provider, not a legitimate "empty" result. The token/
|
|
6
|
+
# cost/latency fields default to nil: not every provider call has them (a tool_call
|
|
7
|
+
# step has no token usage at all), and only the provider itself knows its own pricing
|
|
8
|
+
# well enough to fill in cost_cents.
|
|
9
|
+
ProviderResponse = Struct.new(:content, :model, :input_tokens, :output_tokens, :cost_cents, :latency_ms,
|
|
10
|
+
keyword_init: true) do
|
|
11
|
+
def initialize(**attrs)
|
|
12
|
+
super
|
|
13
|
+
raise ArgumentError, "ProviderResponse requires content" if content.nil?
|
|
14
|
+
raise ArgumentError, "ProviderResponse requires model" if model.nil?
|
|
15
|
+
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module Belay
|
|
6
|
+
# Drives a run forward: acquire the lease, run the executor. Enqueue with
|
|
7
|
+
# Belay::ResumeRunJob.perform_later(run.id) - nothing in this gem enqueues it yet
|
|
8
|
+
# (Workflow.start runs the executor inline); this is the piece later work would
|
|
9
|
+
# call to move that off the calling process.
|
|
10
|
+
#
|
|
11
|
+
# job_id is the lease owner: ActiveJob already gives every performed job a unique
|
|
12
|
+
# one, so there's no separate owner-identity scheme to invent.
|
|
13
|
+
#
|
|
14
|
+
# If acquire_lease! fails, another worker already holds this run (or is between
|
|
15
|
+
# attempts with time still left on its lease) - this job quietly returns rather
|
|
16
|
+
# than erroring or forcing its way in. Nothing here heartbeats the lease itself -
|
|
17
|
+
# Executor::StepRunner does, via Run#heartbeat_while, for as long as any single
|
|
18
|
+
# step/gate is actually in flight (see DESIGN_DECISIONS #12/#13) - and nothing
|
|
19
|
+
# releases it afterward either: a terminal run (completed/failed) is left holding
|
|
20
|
+
# its lease, harmless today since every caller of acquire_lease! already scopes to
|
|
21
|
+
# status: running first (see Run#release_lease!'s own comment). JanitorJob
|
|
22
|
+
# reclaiming an expired lease is what recovers from a crash, or from a step
|
|
23
|
+
# genuinely slower than heartbeat_while's own thread can keep renewing for.
|
|
24
|
+
#
|
|
25
|
+
# No retries: are configured on this class today, which matters because job_id is
|
|
26
|
+
# NOT fresh across an ActiveJob-level retry - retry_job copies the failed attempt's
|
|
27
|
+
# job_id onto the retried instance by design (only provider_job_id changes). If
|
|
28
|
+
# retry_on were ever added here, a job that acquires the lease then raises before
|
|
29
|
+
# the lease naturally expires would have its own retry see that still-valid lease
|
|
30
|
+
# as "owned by job_id X" (itself) and quietly no-op instead of resuming, since
|
|
31
|
+
# acquire_lease! has no same-owner bypass (that's heartbeat_lease!'s job). Adding
|
|
32
|
+
# retry_on to this class would need to account for that; don't add it without
|
|
33
|
+
# also handling same-owner reacquisition.
|
|
34
|
+
class ResumeRunJob < ActiveJob::Base
|
|
35
|
+
def perform(run_id)
|
|
36
|
+
run = Run.find(run_id)
|
|
37
|
+
return unless run.acquire_lease!(owner: job_id)
|
|
38
|
+
|
|
39
|
+
Executor.new(run).call
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|