Applies ai-worker's scheduler discipline. Use for any task touching app/jobs/workflow_scheduler_job.rb, app/services/scheduling/**, config/sidekiq.yml, config/schedule.yml, or the scheduler concurrency specs (spec/jobs/workflow_scheduler_job_spec.rb, spec/services/scheduling/**).
Install
npx skillscat add hoang-phan/ai-worker/ai-worker-scheduling Install via the SkillsCat registry.
ai-worker-scheduling
Scope: app/jobs/workflow_scheduler_job.rb, app/services/scheduling/planner.rb (pure decisions: eligibility, clone_key), app/services/scheduling/capacity.rb (slot/clone bookkeeping), app/services/scheduling/workflow_queries.rb (the AR selection the job uses: status lists, ordering, due-UAT relation, free slots), config/sidekiq.yml, config/schedule.yml. What a started stage does is StageExecutor (ai-worker-orchestration, ai-worker-stage-types). The code wins over docs/RUNBOOK.md.
Tick
WorkflowSchedulerJob#perform(run_workflow_id = nil) is cron-driven on the stage_execution queue (schedule.yml: every 3 minutes, in two entries for working hours 05–14 and off-hours, Asia/Ho_Chi_Minh). It walks every in-flight workflow in position order and never blocks on an agent. Three phases, in order:
- Active workflows —
implementing/reviewing/local_testing, plusmerged/uat_testingones that areprocessing. Running ones are executed (polled) first so finished runs free their slot and clone; then eligible waiting ones are started whileCapacity#can_start?holds, callingstarted!only if the workflow isprocessingafterexecute. - Post-merge — skipped while any workflow is
implementing/local_testingor any isprocessing. Otherwiseconfirm_mergesrunsStageExecutor#check_mergedfordonedevelopment workflows with a PR URL thattracks_jira_after_merge?, then one non-processingmergedworkflow (lowest position) is executed. - UAT — free slots =
max_concurrent_agentsminus allprocessingworkflows (no idle wait, no clone check). Due =uat_waitingwithuat_test_atpassed (moved touat_testingfirst) oruat_testingand not processing (a failed attempt retried); ordered byuat_test_at, limited to the free slots.
execute rescues StandardError per workflow and logs [scheduler], so one failure never stops the tick.
Eligibility (Planner.eligible_waiting)
- Heads = the first (by position) development workflow in
active_workflowsper clone; a head waiting on review keeps its slot, so later development workflows on that clone don't start.active_workflowsmust be inpositionorder. - PR-review workflows are always eligible (they neither hold nor wait on the head); only a running agent on the same clone blocks them, via
Capacity. - Runbooks are eligible only if
auto_run?orid == run_workflow_id(the "Run stage" button); they never become heads.
Capacity and clone key
Capacity.new(busy_clones:, max:): can start iffactive_count < maxand the workflow'sclone_keyisn't busy.busy_clonesis built from allprocessingworkflows (all statuses, so a runningmerged/UAT run counts) and the initial list is copied, not mutated.maxisWorkflowSchedulerJob.max_concurrent_agents(MAX_CONCURRENT_AGENTS, default 3).Planner.clone_key(project)=realpathof the expandedlocal_directoryif it exists, else the expanded path. Always go through it; never compare rawlocal_directorystrings.
Concurrency-1 contract
Capacity and head-of-line checks aren't atomic, so ticks must serialize: config/sidekiq.yml keeps :concurrency: 1 (production runs a dedicated -c 1 -q stage_execution process, see docs/RUNBOOK.md). Parallelism comes from detached agent processes. Don't relax one rule (per-clone, global max, development one-at-a-time) without revisiting the others, and don't move the scheduler off the stage_execution queue.
Specs
| Rule | Spec |
|---|---|
| Poll running before starting waiting | workflow_scheduler_job_spec.rb "polls processing workflows before starting…" |
| One start per shared clone | workflow_scheduler_job_spec.rb "starts only one waiting workflow for a shared clone" |
| Global max | workflow_scheduler_job_spec.rb "with one agent slot" |
| Per-workflow failure isolation | workflow_scheduler_job_spec.rb "logs one executor failure and continues…" |
| Post-merge idle gate, confirm + start one | workflow_scheduler_job_spec.rb "the post-merge phase" |
| UAT due/retry and slot limit | workflow_scheduler_job_spec.rb "the UAT phase" |
| Named non-auto-run runbook | workflow_scheduler_job_spec.rb "starts a named non-auto-run runbook" |
| Head-of-line, PR-review exemption, runbook rules | spec/services/scheduling/planner_spec.rb .eligible_waiting |
| Clone-key normalisation | spec/services/scheduling/planner_spec.rb .clone_key |
| Slot/clone bookkeeping | spec/services/scheduling/capacity_spec.rb |
Run with RAILS_ENV=test bundle exec rspec spec/jobs/workflow_scheduler_job_spec.rb spec/services/scheduling. A new rule goes in the pure Planner/Capacity first (with a spec there) and gets a job-level spec only for phase wiring.