From 126f83da31c1cb806d7e30276faa59f6358fff14 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Tue, 9 Jun 2026 14:51:09 -0400 Subject: [PATCH 1/2] Add coplan:summaries:backfill rake task (COPLAN-31) Backfills AI summaries for plans created before the summary infra (COPLAN-24, #118), which only fires on new PlanVersion creates. Enqueues SummarizePlanJob for every plan with a NULL summary and a current_plan_version; plans with no current version (no content) are skipped. Throttled with a sleep between batches (BATCH_SIZE/INTERVAL env-tunable) to avoid spiking OpenAI cost. The job's sha-claim debounce makes the task safe to re-run. Amp-Thread-ID: https://ampcode.com/threads/T-019eada7-584c-773f-9cbf-88571aa42acb Co-authored-by: Amp --- engine/lib/tasks/coplan_summaries.rake | 39 ++++++++++++++++ spec/lib/tasks/coplan_summaries_spec.rb | 62 +++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 engine/lib/tasks/coplan_summaries.rake create mode 100644 spec/lib/tasks/coplan_summaries_spec.rb diff --git a/engine/lib/tasks/coplan_summaries.rake b/engine/lib/tasks/coplan_summaries.rake new file mode 100644 index 00000000..2e3a5734 --- /dev/null +++ b/engine/lib/tasks/coplan_summaries.rake @@ -0,0 +1,39 @@ +namespace :coplan do + namespace :summaries do + # One-time backfill of AI summaries for plans created before the + # summary infra (COPLAN-24, #118), which only fires on new + # PlanVersion creates. Enqueues SummarizePlanJob for every plan + # missing a summary; the job's sha-claim debounce makes this safe + # to re-run. + # + # Throttled with a sleep between batches to avoid spiking OpenAI + # cost/rate limits. Tune via env: + # + # BATCH_SIZE=25 INTERVAL=5 bin/rails coplan:summaries:backfill + # + # Plans that already have a summary are excluded by the query; + # plans with no current_plan_version (no content) are skipped. + desc "Backfill AI summaries for plans missing one (COPLAN-31)" + task backfill: :environment do + batch_size = Integer(ENV["BATCH_SIZE"].presence || 25) + interval = Float(ENV["INTERVAL"].presence || 5) + + enqueued = 0 + skipped = 0 + + CoPlan::Plan.where(summary: nil).find_each(batch_size: batch_size) do |plan| + if plan.current_plan_version_id.nil? + skipped += 1 + next + end + + CoPlan::SummarizePlanJob.perform_later(plan_id: plan.id) + enqueued += 1 + + sleep(interval) if interval.positive? && (enqueued % batch_size).zero? + end + + puts "coplan:summaries:backfill — enqueued=#{enqueued} skipped=#{skipped} (skipped = no current_plan_version; already-summarized excluded by query)" + end + end +end diff --git a/spec/lib/tasks/coplan_summaries_spec.rb b/spec/lib/tasks/coplan_summaries_spec.rb new file mode 100644 index 00000000..a1790973 --- /dev/null +++ b/spec/lib/tasks/coplan_summaries_spec.rb @@ -0,0 +1,62 @@ +require "rails_helper" +require "rake" + +RSpec.describe "coplan:summaries:backfill", type: :task do + include ActiveJob::TestHelper + + subject(:run_task) { Rake::Task["coplan:summaries:backfill"].tap(&:reenable).invoke } + + around do |example| + previous_application = Rake.application + Rake.application = Rake::Application.new + Rake::Task.define_task(:environment) + load CoPlan::Engine.root.join("lib/tasks/coplan_summaries.rake").to_s + example.run + ensure + Rake.application = previous_application + end + + before { clear_enqueued_jobs } + + it "enqueues SummarizePlanJob for summary-less plans with a current version" do + plan = create(:plan) + + expect { run_task }.to have_enqueued_job(CoPlan::SummarizePlanJob).with(plan_id: plan.id) + end + + it "skips plans that already have a summary" do + create(:plan).update_columns(summary: "Already summarized.") + + expect { run_task }.not_to have_enqueued_job(CoPlan::SummarizePlanJob) + end + + it "skips plans with no current_plan_version" do + create(:plan).update_columns(current_plan_version_id: nil) + + expect { run_task }.not_to have_enqueued_job(CoPlan::SummarizePlanJob) + end + + it "enqueues only the eligible plans when mixed" do + eligible = create(:plan) + create(:plan).update_columns(summary: "Has one.") + create(:plan).update_columns(current_plan_version_id: nil) + + # Plan creation enqueues its own SummarizePlanJob (PlanVersion + # after_create_commit); clear those so we observe only the task's. + clear_enqueued_jobs + run_task + + expect(enqueued_jobs.map { |job| job[:args].first["plan_id"] }).to eq([eligible.id]) + end + + it "falls back to defaults when BATCH_SIZE/INTERVAL are blank" do + plan = create(:plan) + ENV["BATCH_SIZE"] = "" + ENV["INTERVAL"] = "" + + expect { run_task }.to have_enqueued_job(CoPlan::SummarizePlanJob).with(plan_id: plan.id) + ensure + ENV.delete("BATCH_SIZE") + ENV.delete("INTERVAL") + end +end From 5a033bd64545370427ca1da3d02947e8acc0a886 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Wed, 10 Jun 2026 09:27:51 -0400 Subject: [PATCH 2/2] Clear stale sha claims before backfilling (COPLAN-31 review) A prior SummarizePlanJob can claim summary_content_sha256 and then fail (CoPlan::Ai::Error) or return blank, leaving the plan summary-less with the sha already claimed. Re-enqueuing alone would no-op forever at the job's claim_sha guard, so the plan could never be backfilled. Reset the claim for summary-less plans before enqueueing so the jobs can re-claim and retry; already-summarized plans keep their claim. Report reset_claims in the task output. Amp-Thread-ID: https://ampcode.com/threads/T-019eada7-584c-773f-9cbf-88571aa42acb Co-authored-by: Amp --- engine/lib/tasks/coplan_summaries.rake | 14 +++++++++++++- spec/lib/tasks/coplan_summaries_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/engine/lib/tasks/coplan_summaries.rake b/engine/lib/tasks/coplan_summaries.rake index 2e3a5734..c22ac864 100644 --- a/engine/lib/tasks/coplan_summaries.rake +++ b/engine/lib/tasks/coplan_summaries.rake @@ -18,6 +18,18 @@ namespace :coplan do batch_size = Integer(ENV["BATCH_SIZE"].presence || 25) interval = Float(ENV["INTERVAL"].presence || 5) + # A prior SummarizePlanJob may have claimed the current content + # sha (summary_content_sha256) but then hit CoPlan::Ai::Error or + # got a blank response — leaving the plan summary-less with the + # sha already claimed. Re-enqueuing alone would no-op forever at + # the job's claim_sha guard, so those plans could never be + # backfilled. Clear the stale claims (only for summary-less + # plans; already-summarized plans keep theirs) so the enqueued + # jobs can re-claim and retry. + reset = CoPlan::Plan.where(summary: nil) + .where.not(summary_content_sha256: nil) + .update_all(summary_content_sha256: nil) + enqueued = 0 skipped = 0 @@ -33,7 +45,7 @@ namespace :coplan do sleep(interval) if interval.positive? && (enqueued % batch_size).zero? end - puts "coplan:summaries:backfill — enqueued=#{enqueued} skipped=#{skipped} (skipped = no current_plan_version; already-summarized excluded by query)" + puts "coplan:summaries:backfill — enqueued=#{enqueued} skipped=#{skipped} reset_claims=#{reset} (skipped = no current_plan_version; already-summarized excluded by query)" end end end diff --git a/spec/lib/tasks/coplan_summaries_spec.rb b/spec/lib/tasks/coplan_summaries_spec.rb index a1790973..1e6d5ade 100644 --- a/spec/lib/tasks/coplan_summaries_spec.rb +++ b/spec/lib/tasks/coplan_summaries_spec.rb @@ -49,6 +49,26 @@ expect(enqueued_jobs.map { |job| job[:args].first["plan_id"] }).to eq([eligible.id]) end + it "clears a stale sha claim on a summary-less plan so the retried job isn't debounced away" do + plan = create(:plan) + # Simulate a prior job that claimed the sha but failed/blanked: + # summary stays nil while the sha is already claimed. + plan.update_columns(summary_content_sha256: plan.current_plan_version.content_sha256) + + expect { run_task }.to have_enqueued_job(CoPlan::SummarizePlanJob).with(plan_id: plan.id) + expect(plan.reload.summary_content_sha256).to be_nil + end + + it "leaves an already-summarized plan's sha claim intact" do + plan = create(:plan) + sha = plan.current_plan_version.content_sha256 + plan.update_columns(summary: "Done.", summary_content_sha256: sha) + + run_task + + expect(plan.reload.summary_content_sha256).to eq(sha) + end + it "falls back to defaults when BATCH_SIZE/INTERVAL are blank" do plan = create(:plan) ENV["BATCH_SIZE"] = ""