diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab2a5bb..ba1478ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Upgrading: migration 029 adds the scoring lease columns to +`evaluation_scenario_results`. A host runs +`bin/rails generate action_agent:install --skip` then `bin/rails db:migrate`, +after 1.9.3's 028. Until it does, fanned-out evaluation runs keep scoring +with the claim 1.9.3 uses. + +### Fixed + +- **An evaluation judge job delivered twice scores its result once** + (`actionagent`). The claim on a pending result was a conditional write on + its timestamp, which turned away a stale reader but not a duplicate + delivered after the claim: that one read the new timestamp and asked the + judge to score the result again. A claim now takes a 30-minute lease + (`scoring_token`, `scoring_expires_at`) that a fresh reader cannot take + while it is live, and a worker that finishes after its lease was + reclaimed cannot overwrite its successor's result. A delivery that finds + a live lease, such as the queue's redelivery after a worker died + mid-judge, enqueues the job again for when the lease expires, so the run + still completes. Without migration 029 the judge job keeps the timestamp + claim instead of failing on the missing columns. + ## [1.9.3] - 2026-10-21 Releases `activeagent` and `actionagent` 1.9.3, with the matching browser diff --git a/actionagent/app/jobs/action_agent/evaluation_judge_job.rb b/actionagent/app/jobs/action_agent/evaluation_judge_job.rb index fd2cbfca..81796fd5 100644 --- a/actionagent/app/jobs/action_agent/evaluation_judge_job.rb +++ b/actionagent/app/jobs/action_agent/evaluation_judge_job.rb @@ -17,11 +17,12 @@ def perform(result_id, selection = {}, run_id = nil) return finish_without_result(run_id, selection) unless row run = row.evaluation_run + runner = ScenarioEvaluationRunner.new(run.evaluation, selection: selection, run: run) begin - ScenarioEvaluationRunner.new(run.evaluation, selection: selection, run: run).judge_result!(row) + runner.judge_result!(row) rescue StandardError => e Rails.logger.warn("[ActionAgent] evaluation run #{run.id}: scoring result #{result_id} failed: #{e.class}: #{e.message}") - ScenarioEvaluationRunner.new(run.evaluation, selection: selection, run: run).fail_judging!(row.reload, e) + runner.fail_judging!(row.reload, e) end end diff --git a/actionagent/app/services/action_agent/scenario_evaluation_runner.rb b/actionagent/app/services/action_agent/scenario_evaluation_runner.rb index 32664713..bedbd3a3 100644 --- a/actionagent/app/services/action_agent/scenario_evaluation_runner.rb +++ b/actionagent/app/services/action_agent/scenario_evaluation_runner.rb @@ -41,6 +41,7 @@ module ActionAgent # any replay, and one started here is stopped once the run ends. class ScenarioEvaluationRunner < EvaluationRunnerService Evals = ActiveAgent::Evals + JUDGE_LEASE_DURATION = 30.minutes # The providers a candidate model name resolves against. `mock` is the # framework's test double, accepted so the test suite can compare cohorts @@ -187,17 +188,20 @@ def replay_scenario!(run, scenario_id, spec_value) # A job delivered again for a result that is already scored, or that a # concurrent delivery has claimed, scores nothing. It still checks # whether the run is complete: the earlier delivery may have stored the - # last result and stopped before completing the run. + # last result and stopped before completing the run. A result another + # delivery holds a live lease on is tried again when the lease expires + # (see #wait_for_claim!). def judge_result!(row) run = row.evaluation_run return unless run.running? - return finish_fan_out!(run) unless row.pending? && claim_result!(row) + return finish_fan_out!(run) unless row.pending? + return wait_for_claim!(row, run) unless claim_result!(row) spec = fan_out_specs(run).fetch([ row.provider.to_s, row.model ]) do Evals::ModelSpec.new(label: row.model, provider: row.provider.to_s, model: row.model) end result = fan_out_scorer(run).evaluate(Evals::Scenario.from_hash(row.evaluated_scenario), spec, replay_from(row)) - row.update!( + store_claimed_result!(row, status: result.status, score: result.score, scores: result.scores, @@ -227,7 +231,15 @@ def fail_result!(run, scenario_id, spec_value, error) row = stored_result(run, scenario, spec) task = Evals::Scenario.from_hash(scenario.as_json_summary) row ||= store_replay(run, scenario, task, spec, Evals::Replay.failed(error)) - row.update!(status: :errored, error_message: row.error_message.presence || error_text(error)) if row.pending? + attributes = { status: :errored, error_message: row.error_message.presence || error_text(error) } + if scoring_leases? + # Another replay delivery may fail while this result is being + # scored. Only an unclaimed replay belongs to this failure path. + EvaluationScenarioResult.where(id: row.id, status: :pending, scoring_token: nil) + .update_all(attributes.merge(updated_at: Time.current)) + elsif row.pending? + row.update!(attributes) + end end end finish_fan_out!(run) @@ -237,7 +249,7 @@ def fail_result!(run, scenario_id, spec_value, error) # still completes. def fail_judging!(row, error) run = row.evaluation_run - row.update!(status: :errored, error_message: [ row.error_message.presence, "Scoring failed: #{error_text(error)}" ].compact.join(" ")) if row.pending? + store_claimed_result!(row, status: :errored, error_message: [ row.error_message.presence, "Scoring failed: #{error_text(error)}" ].compact.join(" ")) if row.pending? finish_fan_out!(run) end @@ -331,15 +343,59 @@ def expected_results(run, specs) @evaluation.scenarios.where(id: ids).count * specs.size end - # Claims a pending result for this job before it is scored. Of two judge - # jobs delivered for one result (a replay job delivered again after it - # enqueued the first), only the one whose claim lands scores it, so the - # judge is asked once and the run's meter counts each call once. The - # claim is a conditional write on the row's timestamp: it holds no lock - # while the judge is asked, and a row whose claimant then stops is still - # pending for the next delivery. + # A timestamp comparison alone only rejects a stale reader. A duplicate + # delivered after that write reads the new timestamp and could score too. + # The lease excludes fresh readers as well, without holding a database + # lock across a provider call. A redelivery can reclaim an expired lease. + # + # Without the lease columns (migration 029 not yet run) the claim is the + # timestamp comparison alone, as before the lease. def claim_result!(row) - EvaluationScenarioResult.where(id: row.id, status: :pending, updated_at: row.updated_at).update_all(updated_at: Time.current) == 1 + now = Time.current + claim = EvaluationScenarioResult.where(id: row.id, status: :pending, updated_at: row.updated_at) + return claim.update_all(updated_at: now) == 1 unless scoring_leases? + + token = SecureRandom.uuid + claimed = claim.where("scoring_token IS NULL OR scoring_expires_at <= ?", now) + .update_all(scoring_token: token, scoring_expires_at: now + JUDGE_LEASE_DURATION, updated_at: now) + @scoring_token = token if claimed == 1 + end + + # A claim lost to another delivery leaves the result pending, and that + # delivery may be a worker that stopped: the queue's redelivery of its + # job lands here, inside the lease the stopped worker still holds. So the + # job is enqueued again for when that lease expires. If the holder stores + # the result first, the later delivery finds it scored and only checks + # whether the run is complete. A result left pending with no live lease + # (the claim raced another write to the row) is tried again at once. + # Without the lease columns a lost claim is a concurrent delivery that is + # scoring the result now, as before. + def wait_for_claim!(row, run) + current = EvaluationScenarioResult.find_by(id: row.id) if scoring_leases? + if current&.pending? + retry_at = [ current.scoring_expires_at, Time.current ].compact.max + EvaluationJudgeJob.set(wait_until: retry_at).perform_later(row.id, @selection.to_h, run.id) + end + finish_fan_out!(run) + end + + # Fence late successes and failures: after a lease is reclaimed, the old + # worker cannot overwrite its successor. Only the current owner releases + # the lease. A failure before claiming may mark an unclaimed row errored. + def store_claimed_result!(row, **attributes) + return row.update!(attributes) unless scoring_leases? + + row.assign_attributes(attributes) + row.validate! + EvaluationScenarioResult.where(id: row.id, status: :pending, scoring_token: @scoring_token) + .update_all(attributes.merge(scoring_token: nil, scoring_expires_at: nil, updated_at: Time.current)) + end + + # Whether migration 029's lease columns are installed. A host that + # upgraded the gem without running the generator keeps scoring with the + # timestamp claim instead of failing every judge job on a missing column. + def scoring_leases? + (%w[scoring_token scoring_expires_at] - EvaluationScenarioResult.column_names).empty? end # What Evals::Runner does with a replay that raises: an errored replay, diff --git a/actionagent/lib/generators/action_agent/templates/migrations/029_add_evaluation_scoring_leases.rb.erb b/actionagent/lib/generators/action_agent/templates/migrations/029_add_evaluation_scoring_leases.rb.erb new file mode 100644 index 00000000..966644b7 --- /dev/null +++ b/actionagent/lib/generators/action_agent/templates/migrations/029_add_evaluation_scoring_leases.rb.erb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddEvaluationScoringLeases < ActiveRecord::Migration<%= migration_version %> + def change + add_column "#{ActionAgent.table_name_prefix}evaluation_scenario_results", :scoring_token, :string + add_column "#{ActionAgent.table_name_prefix}evaluation_scenario_results", :scoring_expires_at, :datetime + end +end diff --git a/actionagent/test/install_generator_test.rb b/actionagent/test/install_generator_test.rb index 89ae9f84..fec5c089 100644 --- a/actionagent/test/install_generator_test.rb +++ b/actionagent/test/install_generator_test.rb @@ -271,6 +271,28 @@ class ActionAgentInstallGeneratorTest < Rails::Generators::TestCase connection&.drop_table("#{prefix}sandbox_sessions", if_exists: true) end + test "scoring lease columns install under a custom prefix and reverse" do + run_generator [ "--skip-routes" ] + connection = ActiveRecord::Base.connection + prefix = "scoring_probe_" + table = "#{prefix}evaluation_scenario_results" + connection.create_table(table, force: true) + ActionAgent.table_name_prefix = prefix + namespace = Module.new + namespace.module_eval(File.read(migration_file_name("db/migrate/add_evaluation_scoring_leases.rb"))) + migration = namespace::AddEvaluationScoringLeases.new + + ActiveRecord::Migration.suppress_messages { migration.migrate(:up) } + assert connection.column_exists?(table, :scoring_token, :string) + assert connection.column_exists?(table, :scoring_expires_at, :datetime) + ActiveRecord::Migration.suppress_messages { migration.migrate(:down) } + assert_not connection.column_exists?(table, :scoring_token) + assert_not connection.column_exists?(table, :scoring_expires_at) + ensure + ActionAgent.table_name_prefix = "active_agent_" + connection&.drop_table(table, if_exists: true) if table + end + test "files in the numbered template directory that break the naming convention are not emitted" do with_numbered_templates( "001_create_widgets.rb.erb" => numbered_template("CreateWidgets"), diff --git a/actionagent/test/scenario_evaluation_fan_out_test.rb b/actionagent/test/scenario_evaluation_fan_out_test.rb index b017c9ae..33c50d6a 100644 --- a/actionagent/test/scenario_evaluation_fan_out_test.rb +++ b/actionagent/test/scenario_evaluation_fan_out_test.rb @@ -434,4 +434,272 @@ def with_method(klass, method_name, body) ensure ActionAgent::ProviderKey.delete_all end + + test "a fresh overlapping judge delivery does not score twice" do + evaluation = build_suite + evaluation.update!(judge_kind: "llm", judge_model: "claude-opus-5", + criteria: [ { "key" => "quality", "type" => "llm_judge", "config" => { "prompt" => "Is the answer useful?" } } ]) + run = evaluation.run_later!(keys: [ "tickets_1" ]) + drain(only: [ ActionAgent::EvaluationRunJob, ActionAgent::EvaluationReplayJob ]) + row = run.scenario_results.sole + clear_enqueued_jobs # the replay's judge job; this test delivers it by hand + prompts = [] + redelivered = false + judge = fake_judge(prompts, on_call: ->(kind) { + # First job has claimed the row and is awaiting the judge. Deliver + # the duplicate now, loading the CURRENT database row as the job does. + if kind == :score && !redelivered + redelivered = true + ActionAgent::EvaluationJudgeJob.perform_now(row.id, {}, run.id) + end + }) + with_judge(judge) { ActionAgent::EvaluationJudgeJob.perform_now(row.id, {}, run.id) } + assert_equal 1, prompts.count(:score), "an overlapping delivery must not score the same result again" + assert_equal 1, run.reload.scores.dig("_judge_usage", "by_kind", "score") + assert_nil row.reload.scoring_token + assert_nil row.scoring_expires_at + + # The duplicate came back for when the first delivery's lease would + # expire, and finds the result already scored. + assert_equal 1, enqueued_jobs.count { |job| job["job_class"] == "ActionAgent::EvaluationJudgeJob" } + travel ActionAgent::ScenarioEvaluationRunner::JUDGE_LEASE_DURATION + 1.second do + with_judge(judge) { perform_enqueued_jobs(only: ActionAgent::EvaluationJudgeJob) } + end + assert_equal 1, prompts.count(:score) + assert_equal "complete", run.reload.status + end + + test "an expired scoring lease is reclaimed on redelivery" do + run = build_suite.run_later!(keys: [ "tickets_1" ]) + drain(only: [ ActionAgent::EvaluationRunJob, ActionAgent::EvaluationReplayJob ]) + row = run.scenario_results.sole + row.update!(scoring_token: "stopped-worker", scoring_expires_at: 1.second.ago) + + ActionAgent::EvaluationJudgeJob.perform_now(row.id, {}, run.id) + + assert_equal "passed", row.reload.status + assert_nil row.scoring_token + assert_nil row.scoring_expires_at + assert_equal "complete", run.reload.status + end + + test "a redelivery inside a stopped worker's live lease scores the result once the lease expires" do + evaluation = build_suite + run = evaluation.run_later!(keys: [ "tickets_1" ]) + drain(only: [ ActionAgent::EvaluationRunJob, ActionAgent::EvaluationReplayJob ]) + row = run.scenario_results.sole + clear_enqueued_jobs # the replay's judge job; this test delivers it by hand + # The first delivery claims the result, then its worker dies mid-judge. + assert ActionAgent::ScenarioEvaluationRunner.new(evaluation, run: run).send(:claim_result!, row) + expires_at = row.reload.scoring_expires_at + + # The queue redelivers the job while the dead worker's lease is live. + ActionAgent::EvaluationJudgeJob.perform_now(row.id, {}, run.id) + + assert_equal "pending", row.reload.status, "a live lease is not scored over" + assert_equal "running", run.reload.status + retry_job = enqueued_jobs.sole + assert_equal "ActionAgent::EvaluationJudgeJob", retry_job["job_class"] + assert_equal row.id, retry_job["arguments"].first + assert_in_delta expires_at.to_f, Time.iso8601(retry_job["scheduled_at"]).to_f, 0.001, + "the redelivery comes back when the stopped worker's lease expires" + + travel_to(expires_at + 1.second) { perform_enqueued_jobs(only: ActionAgent::EvaluationJudgeJob) } + + assert_equal "passed", row.reload.status + assert_nil row.scoring_token + assert_equal "complete", run.reload.status + assert_empty enqueued_jobs + end + + test "a result scored before a deferred redelivery runs is not scored again" do + evaluation = build_suite + run = evaluation.run_later!(keys: [ "tickets_1" ]) + drain(only: [ ActionAgent::EvaluationRunJob, ActionAgent::EvaluationReplayJob ]) + row = run.scenario_results.sole + clear_enqueued_jobs # the replay's judge job; this test delivers it by hand + holder = ActionAgent::ScenarioEvaluationRunner.new(evaluation, run: run) + assert holder.send(:claim_result!, row) + ActionAgent::EvaluationJudgeJob.perform_now(row.id, {}, run.id) + assert_equal 1, enqueued_jobs.size + + holder.send(:store_claimed_result!, row.reload, status: :passed, score: 1.0) + stored = row.reload.attributes + travel ActionAgent::ScenarioEvaluationRunner::JUDGE_LEASE_DURATION + 1.second do + perform_enqueued_jobs(only: ActionAgent::EvaluationJudgeJob) + end + + assert_equal stored, row.reload.attributes + assert_equal "complete", run.reload.status + assert_empty enqueued_jobs + end + + test "a late scoring success or failure cannot overwrite the new lease owner's result" do + [ false, true ].each do |fail_late| + prompts = [] + evaluation = build_suite + evaluation.update!(judge_kind: "llm", judge_model: "claude-opus-5", + criteria: [ { "key" => "quality", "type" => "llm_judge", "config" => { "prompt" => "Useful?" } } ]) + run = evaluation.run_later!(keys: [ "tickets_1" ]) + drain(only: [ ActionAgent::EvaluationRunJob, ActionAgent::EvaluationReplayJob ]) + row = run.scenario_results.sole + redelivered = false + stored = nil + judge = fake_judge(prompts, on_call: ->(kind) { + if kind == :score && !redelivered + redelivered = true + travel ActionAgent::ScenarioEvaluationRunner::JUDGE_LEASE_DURATION + 1.second do + ActionAgent::EvaluationJudgeJob.perform_now(row.id, {}, run.id) + stored = row.reload.attributes + end + raise "old worker failed" if fail_late + end + }) + + with_judge(judge) { ActionAgent::EvaluationJudgeJob.perform_now(row.id, {}, run.id) } + + assert_equal 2, prompts.count(:score), "a call beyond lease expiry may be repeated" + assert_equal stored, row.reload.attributes, "the old owner cannot write after its lease was reclaimed" + assert_equal "passed", row.status + assert_equal "complete", run.reload.status + end + end + + test "a scoring exception releases its lease and stores the error" do + evaluation = build_suite + run = evaluation.run_later!(keys: [ "tickets_1" ]) + drain(only: [ ActionAgent::EvaluationRunJob, ActionAgent::EvaluationReplayJob ]) + row = run.scenario_results.sole + with_method(ActionAgent::ScenarioEvaluationRunner, :fan_out_scorer, proc { |_run| raise "score exploded" }) do + ActionAgent::EvaluationJudgeJob.perform_now(row.id, {}, run.id) + end + + assert_equal "errored", row.reload.status + assert_match(/score exploded/, row.error_message) + assert_nil row.scoring_token + assert_nil row.scoring_expires_at + assert_equal "complete", run.reload.status + end + + test "an old owner cannot write while its successor is still scoring" do + evaluation = build_suite + run = evaluation.run_later!(keys: [ "tickets_1" ]) + drain(only: [ ActionAgent::EvaluationRunJob, ActionAgent::EvaluationReplayJob ]) + row = run.scenario_results.sole + old_runner = ActionAgent::ScenarioEvaluationRunner.new(evaluation, run: run) + assert old_runner.send(:claim_result!, row) + + travel ActionAgent::ScenarioEvaluationRunner::JUDGE_LEASE_DURATION + 1.second do + successor = ActionAgent::ScenarioEvaluationRunner.new(evaluation, run: run) + assert successor.send(:claim_result!, row.reload) + token = row.reload.scoring_token + + old_runner.fail_judging!(row, RuntimeError.new("old call timed out")) + assert_equal "pending", row.reload.status + assert_equal token, row.scoring_token + assert_equal 0, old_runner.send(:store_claimed_result!, row, status: :passed, score: 1.0) + assert_equal "pending", row.reload.status + assert_equal token, row.scoring_token + end + end + + test "a duplicate replay failure cannot interrupt an active scoring owner" do + evaluation = build_suite + evaluation.update!(judge_kind: "llm", judge_model: "claude-opus-5", + criteria: [ { "key" => "quality", "type" => "llm_judge", "config" => { "prompt" => "Useful?" } } ]) + run = evaluation.run_later!(keys: [ "tickets_1" ]) + drain(only: [ ActionAgent::EvaluationRunJob, ActionAgent::EvaluationReplayJob ]) + row = run.scenario_results.sole + failed = false + judge = fake_judge([], on_call: ->(kind) { + if kind == :score && !failed + failed = true + ActionAgent::ScenarioEvaluationRunner.new(evaluation, run: run).fail_result!( + run.reload, row.evaluation_scenario_id, run.selection["models"].first, RuntimeError.new("duplicate replay failed")) + end + }) + + with_judge(judge) { ActionAgent::EvaluationJudgeJob.perform_now(row.id, {}, run.id) } + + assert_equal "passed", row.reload.status + assert_nil row.error_message + assert_nil row.scoring_token + assert_equal "complete", run.reload.status + end + + # A host that upgraded the gem but has not run migration 029 yet. + def without_scoring_lease_columns + klass = ActionAgent::EvaluationScenarioResult + columns = klass.column_names - %w[scoring_token scoring_expires_at] + queries = [] + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") { |*, payload| queries << payload[:sql] } + klass.stub(:column_names, columns) { yield } + queries.grep(/scoring_(token|expires_at)/) + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + + test "without the scoring lease columns a fanned-out run still scores and completes" do + run = build_suite.run_later! + + lease_queries = without_scoring_lease_columns { drain } + + assert_empty lease_queries, "no query may name a column the host has not installed" + assert_equal "complete", run.reload.status + assert_equal 3, run.scenario_results.count + assert run.scenario_results.none?(&:pending?) + end + + test "without the scoring lease columns a scoring failure is still stored" do + run = build_suite.run_later!(keys: [ "tickets_1" ]) + drain(only: [ ActionAgent::EvaluationRunJob, ActionAgent::EvaluationReplayJob ]) + row = run.scenario_results.sole + + lease_queries = without_scoring_lease_columns do + with_method(ActionAgent::ScenarioEvaluationRunner, :fan_out_scorer, proc { |_run| raise "score exploded" }) do + ActionAgent::EvaluationJudgeJob.perform_now(row.id, {}, run.id) + end + end + + assert_empty lease_queries + assert_equal "errored", row.reload.status + assert_match(/score exploded/, row.error_message) + assert_equal "complete", run.reload.status + end + + test "without the scoring lease columns a replay job failure is still stored" do + evaluation = build_suite + run = evaluation.run_later!(keys: [ "tickets_1" ]) + perform_enqueued_jobs(only: ActionAgent::EvaluationRunJob) + scenario = evaluation.scenarios.find_by!(key: "tickets_1") + + lease_queries = without_scoring_lease_columns do + ActionAgent::ScenarioEvaluationRunner.new(evaluation, run: run.reload).fail_result!( + run, scenario.id, run.selection["models"].first, RuntimeError.new("replay job failed")) + end + + assert_empty lease_queries + assert_equal "errored", run.scenario_results.sole.status + assert_equal "complete", run.reload.status + end + + test "without the scoring lease columns a duplicate delivery is turned away by the timestamp claim" do + evaluation = build_suite + run = evaluation.run_later!(keys: [ "tickets_1" ]) + drain(only: [ ActionAgent::EvaluationRunJob, ActionAgent::EvaluationReplayJob ]) + row = run.scenario_results.sole + clear_enqueued_jobs # the replay's judge job; this test delivers it by hand + stale = ActionAgent::EvaluationScenarioResult.find(row.id) + + lease_queries = without_scoring_lease_columns do + travel 1.second do + assert ActionAgent::ScenarioEvaluationRunner.new(evaluation, run: run).send(:claim_result!, row) + ActionAgent::ScenarioEvaluationRunner.new(evaluation, run: run).judge_result!(stale) + end + end + + assert_empty lease_queries + assert_equal "pending", row.reload.status, "the stale reader loses the claim and scores nothing" + assert_empty enqueued_jobs, "without a lease there is nothing to come back for" + end end diff --git a/docs/framework/evaluations.md b/docs/framework/evaluations.md index 99e7e9a9..911e18a3 100644 --- a/docs/framework/evaluations.md +++ b/docs/framework/evaluations.md @@ -533,9 +533,22 @@ thread count. The agent's tool roster is discovered once, before the replays, and every scoring job reads it from the run, so a diagnosis names the same tools whichever job wrote it. A replay job delivered again does not replay a pair -that already has a stored result, a judge job delivered again does not score -a result twice, a job that fails with an exception outside the replay stores -an errored result, and a failure while completing the run marks it failed. A +that already has a stored result. A judge job delivered again does not score +a result twice: the job that claims a pending result holds a 30-minute +scoring lease on it, and a delivery that finds the lease live enqueues itself +again for when it expires. When the queue redelivers the job of a worker +that died mid-judge, the redelivery scores the result once that worker's +lease runs out; a duplicate whose first delivery finishes in time finds the +result scored. A worker that finishes after another reclaimed its lease +cannot overwrite that worker's result. A provider call that outlasts the +lease may still be repeated, so set provider timeouts below that interval. +The lease columns come from migration 029, installed with +`bin/rails generate action_agent:install --skip` and `bin/rails db:migrate`; +until it runs, the judge job claims a result by its timestamp alone, which +turns away a stale duplicate but not one delivered after the claim. + +A job that fails with an exception outside the replay stores an errored +result, and a failure while completing the run marks it failed. A scenario removed from the suite while a run is in flight is left out of that run's results. What no job can recover from is a job lost outright: a worker killed without a graceful shutdown drops the job it was running diff --git a/test/dummy/db/migrate/029_add_evaluation_scoring_leases.rb b/test/dummy/db/migrate/029_add_evaluation_scoring_leases.rb new file mode 100644 index 00000000..6cff707a --- /dev/null +++ b/test/dummy/db/migrate/029_add_evaluation_scoring_leases.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddEvaluationScoringLeases < ActiveRecord::Migration[7.2] + def change + add_column "#{ActionAgent.table_name_prefix}evaluation_scenario_results", :scoring_token, :string + add_column "#{ActionAgent.table_name_prefix}evaluation_scenario_results", :scoring_expires_at, :datetime + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index acb2e472..01835695 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 28) do +ActiveRecord::Schema[8.0].define(version: 29) do create_table "active_agent_agent_contexts", force: :cascade do |t| t.string "action_name", null: false t.string "agent_name", null: false @@ -323,6 +323,8 @@ t.text "recommendation" t.float "score" t.json "scores" + t.string "scoring_token" + t.datetime "scoring_expires_at" t.integer "status", default: 0, null: false t.json "tool_calls" t.datetime "updated_at", null: false