The Job Adapter
The Job Adapter: your Rails app already has a vocabulary for background work - perform_later, retry_on, discard_on - and the fastest way to adopt a new tool is to let it speak that vocabulary. This wraps a plan in an ActiveJob-shaped class: retry_on maps to the framework's retry policy, discard_on maps to the hopeless convention, and your team learns nothing new until they want to.
Testing & Verification
Round 13
Chris Oliver
exit 0
bundle exec ruby examples/job_adapter.rb
a real captured run
THE JOB ADAPTER (ActiveJob's vocabulary, Agentic underneath)
DigestJob(user: rosa) -> {:status=>:ok}
DigestJob(user: sam, flaky: 2) -> {:status=>:ok}
DigestJob(user: kim, revoked: true) -> {:status=>:discarded, :reason=>"401 key revoked"}
read the mapping, because it's the whole example: retry_on
became the orchestrator's retry_policy (attempts: 3 means two
retries - same accounting as ActiveJob), so sam's double-429
healed INSIDE the plan without ever bouncing off the queue.
discard_on became a check on the failure's type PLUS the
hopeless? convention, so kim's revoked key discards even if
nobody remembered to list AuthenticationError - the error's own
testimony backstops the macro. and the adapter is 40 lines
because both vocabularies were already talking about the same
three ideas: try again, give up, or ask a human. meet your
team where they are; the framework doesn't mind the costume.
source
# frozen_string_literal: true # The Job Adapter: your Rails app already has a vocabulary for # background work - perform_later, retry_on, discard_on - and the # fastest way to adopt a new tool is to let it speak that vocabulary. # This wraps a plan in an ActiveJob-shaped class: retry_on maps to # the framework's retry policy, discard_on maps to the hopeless # convention, and your team learns nothing new until they want to. # # bundle exec ruby examples/job_adapter.rb # # Runs offline; the queue is an array, the lessons are real. require class="s">"bundler/setup" require class="s">"agentic" Agentic.logger.level = class="y">:fatal # ActiveJob's essential shape, mapped onto Agentic underneath class PlanJob class << self attr_reader class="y">:retried, class="y">:discarded def retry_on(*errors, attempts: 3) @retried = {errors: errors, attempts: attempts} end def discard_on(*errors) @discarded = errors end def perform_later(**args) QUEUE << [self, args] end end def execute(**args) orchestrator = Agentic:class="y">:PlanOrchestrator.new( retry_policy: { max_retries: self.class.retried[class="y">:attempts] - 1, retryable_errors: self.class.retried[class="y">:errors].map(&class="y">:name) } ) build_plan(orchestrator, **args) result = orchestrator.execute_plan return {status: class="y">:ok} if result.successful? failure = result.results.values.find { |r| !r.successful? }.failure if self.class.discarded.any? { |e| failure.type == e.name } || failure.hopeless? {status: class="y">:discarded, reason: failure.message} else {status: class="y">:failed_will_requeue, reason: failure.message} end end end QUEUE = [] # --- the job your app would actually write --------------------------------------- class DigestJob < PlanJob retry_on Agentic:class="y">:Errors:class="y">:LlmRateLimitError, attempts: 3 discard_on Agentic:class="y">:Errors:class="y">:LlmAuthenticationError def build_plan(orchestrator, user:, flaky: 0, revoked: false) attempts = 0 fetch = Agentic:class="y">:Task.new(description: class="s">"fetch:#{user}", agent_spec: {class="s">"name" => class="s">"f", class="s">"instructions" => class="s">"w"}) send_task = Agentic:class="y">:Task.new(description: class="s">"send:#{user}", agent_spec: {class="s">"name" => class="s">"s", class="s">"instructions" => class="s">"w"}) orchestrator.add_task(fetch, agent: ->(_t) { raise Agentic:class="y">:Errors:class="y">:LlmAuthenticationError, class="s">"401 key revoked" if revoked attempts += 1 raise Agentic:class="y">:Errors:class="y">:LlmRateLimitError, class="s">"429" if attempts <= flaky class="s">"stories for #{user}" }) orchestrator.add_task(send_task, [fetch], agent: ->(t) { class="s">"sent: #{t.previous_output}" }) end end puts class="s">"THE JOB ADAPTER (ActiveJob's vocabulary, Agentic underneath)" puts DigestJob.perform_later(user: class="s">"rosa") DigestJob.perform_later(user: class="s">"sam", flaky: 2) # succeeds on 3rd try DigestJob.perform_later(user: class="s">"kim", revoked: true) # hopeless QUEUE.each do |job_class, args| outcome = job_class.new.execute(**args) puts format(class="s">" %-32s -> %s", class="s">"#{job_class}(#{args.map { |k, v| "#{k}: #{v}class="s">" }.join(", class="s">")})", outcome.inspect) end puts puts class="s">" read the mapping, because it's the whole example: retry_on" puts class="s">" became the orchestrator's retry_policy (attempts: 3 means two" puts class="s">" retries - same accounting as ActiveJob), so sam's double-429" puts class="s">" healed INSIDE the plan without ever bouncing off the queue." puts class="s">" discard_on became a check on the failure's type PLUS the" puts class="s">" hopeless? convention, so kim's revoked key discards even if" puts class="s">" nobody remembered to list AuthenticationError - the error's own" puts class="s">" testimony backstops the macro. and the adapter is 40 lines" puts class="s">" because both vocabularies were already talking about the same" puts class="s">" three ideas: try again, give up, or ask a human. meet your" puts class="s">" team where they are; the framework doesn't mind the costume."