Applies ai-worker's job/service discipline. Use for any task touching app/jobs/**, app/services/**, config/sidekiq.yml, or config/schedule.yml — the scheduler and sync jobs, StageExecutor, and the Git/Github/AiCli/Prompts/Jira/Slack/Bugsnag/Brainstorm service objects.
Install
npx skillscat add hoang-phan/ai-worker/ai-worker-orchestration Install via the SkillsCat registry.
ai-worker-orchestration
Scope: app/jobs/**, app/services/**, config/sidekiq.yml, config/schedule.yml, config/initializers/sidekiq.rb (scheduler specifics: ai-worker-scheduling). Read app/models/workflow.rb, stage.rb and project.rb only for the public methods and enums this layer calls (current_stage, effective_template, skill_list, advance!, status/kind enums, project settings) — don't otherwise touch the model layer (ai-worker-domain) or the UI (ai-worker-ui). The overall design is in docs/ARCHITECTURE.md and docs/RUNBOOK.md; where they and the code disagree, the code wins.
File map (the whole scope)
Scheduling
config/schedule.yml,config/sidekiq.yml,app/jobs/workflow_scheduler_job.rb,app/services/scheduling/**— the scheduler tick, its eligibility/capacity rules and the concurrency-1 contract: see theai-worker-schedulingskill. The Jira, Slack-review and Bugsnag sync jobs and the hourly brainstorm job (also inschedule.yml) run ondefault.app/services/stage_executor.rb— runs one workflow's current stage and never waits on the agent:callpolls a running agent whenworkflow.processing?, else hands off toStages::StartHandler(per-stage start paths andstart_ai_run, which detaches the agent and records run state on the workflow),poll_ai_runhands the run toAiCli::RunPoller, andcallrescues everyGit::CommandError/Github::CommandError/AiCli::CommandError/ArgumentErrorintofail_stage, which logs afailedStageRun— never let one bubble up and crash the Sidekiq job. Everything after the run lives inapp/services/stages/(below). Per-stage start/finish touch points: seeai-worker-stage-types.app/services/stages/start_handler.rb— start path per stage type (branch/checkout setup, PR-check decision,start_ai_run).app/services/stages/completion_handler.rb— success path per stage type (finish_*): decides the next workflow status, logs theStageRun, best-effort follow-ups.stages/failure_handler.rb— failed-stage logging, theMAX_CONSECUTIVE_FAILUREScap that parks a workflow aserrored.stages/pr_check_decision.rb(pure PR-check decision),stages/local_test_policy.rb(pure local-test retry/outcome rules),stages/review_handoff.rb(best-effort reviewer Slack ping and auto-merge),stages/workflow_run_gate.rb(runbookwait_for_workflowgate).app/services/ai_cli/run_poller.rb— non-blocking finished/running/stale decision for a detached run (kills stale ones);ai_cli/run_logs.rb— per-workflow agent log naming (log/ai_runs/workflow-<id>-<unix>.log).
Agent CLI and prompts
app/services/ai_cli/runner.rb— the single place that launches the agent CLI (claude, cursoragent, or codex; the app-wide agent is read from Rails.cache, falling back to theAGENTenv var). It is not blocking:startdetaches the process, redirects output to a log file and returns its pid;pollis a non-blocking wait that returns:runningor the exit status;tail_new_lineslogs new log output across ticks. It also handles per-run options (allowed/disallowed tools, extra MCP config, env vars, model/effort).app/services/ai_cli/browser_test_run.rb— per-run setup for the browser-testing stages (uat_test, local_test): credential checks, Playwright MCP config and output dir, credential env vars, disallowed tools.app/services/ai_cli/cursor_mcp_file.rb— writes the run's MCP servers to<cwd>/.cursor/mcp.jsonfor the cursor agent and restores the original file when the run ends.app/services/ai_cli/detached_run.rb— state machine (pid/start/log/result file in atmp/dir) shared by the detached-agent sync services below.app/services/ai_cli/output.rb— parses the agent's final output for the marker lines stages rely on (VAR:,JIRA_RESULT,UAT_RESULT,LOCAL_TEST_RESULT, retest/feedback blocks,POSTED, fixed summary).ai_cli/command_error.rb,git/command_error.rb,github/command_error.rb— one error class per service.app/services/prompts/stage_prompt.rb— builds the final prompt text for a stage (stage prompt, else assigned/default template, else a built-in default; plus conflict prompt, Slack-message wrapper and review-fix additions).app/services/prompts/renderer.rb— the only place{TOKEN}placeholders and workflow variables are substituted.
Git / GitHub
app/services/git/branch_service.rb— all git operations in the project's clone: create/resume a workflow's branch from freshmain, mergemaininto it (reporting conflicts), check out a remote branch, detect/abort an in-progress merge.app/services/github/client.rb— facade (Github::Client.new(project)) delegating togithub/reader.rb(query-only: finding a PR, PR info, merged/review-decision/checks state, Actions run state) andgithub/writer.rb(requesting review, enabling auto-merge, checking out fork PRs).github/runner.rbbuilds theghargv in the project's clone and raisesGithub::CommandError; Reader and Writer share it. PR creation is never done here — the agent does it during the implementation run, per its prompt.
Detached-agent sync services (same shape: a state directory under tmp/ holding pid, start time, result file and log; start spawns an agent via AiCli::Runner; running?/poll read back the result file once the process ends and raise the module's own FetchError on a bad or missing result; import creates records idempotently; blockers lists why it can't run, on the fetcher or on its sync wrapper)
app/services/jira/agent_fetcher.rb+jira/ticket_sync.rb— fetch a project's assigned Jira tickets through the agent's Atlassian MCP and create workflows for ones not yet imported.app/jobs/jira_sync_job.rbdrives it.app/services/slack/mention_fetcher.rb+slack/review_sync.rb— fetch Slack mentions containing PR URLs (agent with a Slack-read-only tool allowlist) and create PR-review workflows (skipping closed, own or already-imported PRs).app/jobs/slack_review_job.rbdrives it and enqueues the scheduler right after creating workflows.app/services/agent_runs/sync_cycle.rb— one poll → import → start pass for a record, shared by the sync jobs; records expected errors so later records still run.app/services/slack/review_notifier.rb— pings reviewers by starting a short detached agent run (haiku, only the Slack MCP send-message tool allowed) that posts the message and prints its permalink;slack/ping_collector.rbreads the permalink back intoWorkflow#slack_review_thread_ts. State undertmp/slack_pings/. Optional and best-effort (Slack::NotifyError, rescued byStages::ReviewHandoff).app/services/slack/web_client.rb— minimal bot-tokenchat.postMessageclient; currently has no caller inapp/(only its spec), so don't route new Slack work through it.app/services/slack/review_candidate.rb— pure "should this PR from a mention become a review workflow" rules used byslack/review_sync.rb.app/services/bugsnag/triager.rb— agent run that files Jira Bugs for new Bugsnag errors and records them in the sync's ledger.app/jobs/bugsnag_sync_job.rbdrives it (weekday, once per day for auto-sync ones).app/services/brainstorm/planner.rb+app/jobs/brainstorm_job.rb— goal-driven, read-only agent that proposes workflows for personal projects, only while the project has no pending workflow.brainstorm/prompt.rbbuilds its prompt;brainstorm/proposal_normalizer.rbcleans its result rows.- Adding or changing one of these (fetcher on
AiCli::DetachedRun, sync wrapper, job, schedule entry, specs): see theai-worker-detached-agent-runsskill. - Each sync job imports a finished result first, then starts a new run only if none is running and its own trigger rule holds (last fetch old enough for Jira/Slack, once per weekday after a set hour for Bugsnag, no pending workflow for brainstorm); fetch errors are logged and recorded on the sync/project as its "last result".
app/jobs/application_job.rb— Rails default base class.
Conventions
- Never build a shell command as a
cd X && ...string. Subprocesses useOpen3.capture3(*command, chdir: dir)(Git/Github services) orProcess.spawn(..., chdir:)with an argument array (AiCli::Runner). Never interpolate untrusted text (ticket text, Slack text, PR titles) into a shell string — pass it as an argument or in the prompt/env. - Each service raises its own
CommandErrorsubclass on a non-zero exit instead of returning a boolean.StageExecutoris the one place that turns these into afailedStageRun; the sync jobs rescue their ownFetchError/AiCli::CommandError. Don't rescue inside the Git/Github/Runner objects. - Agent runs are detached and polled across ticks; a job must never block on one. State that lets a later tick find the run lives on the workflow (
processing, pid, log path, start time, log offset) or in the sync'stmp/directory. Everything that starts a run must record that state; everything that finishes one must clear it. - Scheduler concurrency rules (per-clone,
MAX_CONCURRENT_AGENTS, head-of-line, Sidekiq concurrency 1): seeai-worker-scheduling. - Every code path in
StageExecutorthat attempts work or reaches an outcome logs aStageRunwhoseactionis inStageRun::ACTIONS. Add the new action to that constant (domain skill) before logging it — an unlisted action fails the model validation and turns into a crash or a bogus failure. - A failed stage stays in its current workflow status so the next tick retries it; after
MAX_CONSECUTIVE_FAILURESthe workflow becomeserrored, which the scheduler ignores until a person resumes it. - Agents are given the least tool access that works (tool allowlists for the Slack, Bugsnag and brainstorm runs; denied
gh workflow run/gh apifor UAT/local tests). Secrets (UAT/local logins) reach the agent only as environment variables, never in prompts or logs; the review ping's agent gets only the Slack send-message tool. Jira, Slack and Bugsnag access goes through the agent's own MCP connectors, so the app holds no tokens for them. - Test with RSpec, not scratch runners. Put specs under
spec/services/**orspec/jobs/**(stubOpen3,AiCli::RunnerandGithub::Client, or use temp directories and temp git repos) and runRAILS_ENV=test bundle exec rspec. Never run scratch scripts,bin/rails runneror console code against the development DB, and never start a real agent against a real project to "try it".