Checklist for adding or changing an agent-backed fetch/sync/triage service and its job — Jira::AgentFetcher, Slack::MentionFetcher, Bugsnag::Triager on AiCli::DetachedRun. Use when a task adds a new detached-agent service, changes its result file, prompt, tool allowlist, blockers, import, sync job or schedule.yml entry, or touches AiCli::DetachedRun.
Install
npx skillscat add hoang-phan/ai-worker/ai-worker-detached-agent-runs Install via the SkillsCat registry.
SKILL.md
ai-worker-detached-agent-runs
Scope: the services that start the agent CLI detached, read a JSON array back from a result file, and import it into records. General job/service rules are in ai-worker-orchestration, spec rules in ai-worker-testing, model/migration rules in ai-worker-domain — not repeated here.
Moving parts
app/services/ai_cli/detached_run.rb— the shared state machine:DetachedRun.new(dir:, result_name:, stale_after:, error_class:)withstart(cwd:, prompt:, allowed_tools:, model:, effort:),running?,poll { |rows| ... },last_started_at,read_rows,log_path. State dir holdspid,started_at,agent.logand the result file.startis a no-op (false) while the process is alive and deletes any old result;pollreturns nil while running, raiseserror_classonce running paststale_after, and after the process ends clears the pid and returns the (block-filtered) rows.- Result file contract: a JSON array and nothing else.
[]means "ran, nothing found". Missing file or invalid JSON / non-array raises the module'sFetchError, so the prompt must say "if the lookup fails, do NOT create the file". - Fetcher (
Jira::AgentFetcher,Slack::MentionFetcher,Bugsnag::Triager) — wraps oneDetachedRun(memoisedrun), ownsROOT = Rails.root.join("tmp", ...),STALE_AFTER,ALLOWED_TOOLS(where the run needs tools), the prompt, andfilter(rows)(drops malformed rows; free-text from the agent is validated with a regex such asKEY/ID/ReviewSync::PR_URL). Public surface:running?,last_started_at,start,poll. FetchError— oneclass FetchError < StandardErrorper module (Jira::,Slack::,Bugsnag::,Brainstorm::), passed aserror_class.- Sync wrapper (
Jira::TicketSync,Slack::ReviewSync;Bugsnag::Triagerplays both roles) —blockers(array of human-readable reasons it can't run),fetcher, and an idempotentimport(rows)returning aResultstruct (created/skipped/failed) or, for Bugsnag, merging intosync.ledger. - Job (
app/jobs/jira_sync_job.rb,slack_review_job.rb,bugsnag_sync_job.rb) and itsconfig/schedule.ymlentries (*/5in working hours plus an hourly off-hours twin; queuedefault). The job's own throttle (FETCH_EVERY,RUN_HOUR) is separate from the cron cadence. - Last-result recording:
last_result+last_synced_atonJiraSync/BugsnagSync,slack_last_result+slack_last_synced_atonProject(db/schema.rb). Errors are recorded as"failed: #{e.message}".truncate(250). Brainstorm::Planneralready builds itsrunonDetachedRunbut still has its ownpid_file/pollandResult; treatDetachedRunas the target shape and finish that migration rather than copying the planner's local state handling.
Checklist for a new one
- Domain first (
ai-worker-domain): migration + model fields for the trigger/last-result columns (and a ledger if you need cross-run dedupe); add any newStageRunaction toStageRun::ACTIONSbefore anything logs it. - Fetcher:
module X; class FetchError,ROOTundertmp/<name>,STALE_AFTER(15 min for lookups, 45 for runs that read code),run=AiCli::DetachedRun.new(...),start/poll/running?/last_started_atdelegating to it,filterfor the row shape. - Least privilege: pass an
ALLOWED_TOOLSarray with only the read/search tools the run needs plusWritefor the result file. Nothing that posts, reacts, transitions or edits unless the feature is exactly that (Bugsnag'screateJiraIssue/editJiraIssueare the only writes). Access goes through the agent's MCP connectors — never add tokens to the app. - Prompt: say what is read-only, spell out the absolute result path (
dir.join("<name>.json")), give an example row, say[]for none and "do NOT create the file if the lookup fails", ask for one short reply line. User free text (assignee, mention, project) is passed as data for the agent to resolve; never interpolate it into a shell string, and never put secrets in it. - Sync wrapper:
blockerslisting every missing precondition,fetcher, idempotentimport(skip what already exists, one failed row must not stop the rest). - Job, in this order: poll →
import+ record if rows came back; then skip iffetcher.running?or your trigger rule says not yet; thenblockers(log a warning, don't start) elsefetcher.start. RescueModule::FetchError, AiCli::CommandErrorper record and write the failure to last-result. Never wait on the agent. config/schedule.yml: working-hours entry + off-hours entry onqueue: default; keepconfig/sidekiq.ymlconcurrency untouched.- Add the service to the "Detached-agent sync services" map in
ai-worker-orchestrationand, if the UI shows last-result, handle it viaai-worker-ui.
Spec recipe
spec/services/ai_cli/detached_run_spec.rbis the model:Dir.mktmpdirstate dir,allow(AiCli::Runner).to receive(:start).and_return(4242),allow(AiCli::Runner).to receive(:poll).and_return(:running)or[ :completed, nil ], write a fakepidand result file by hand, then callpoll.- Fetcher specs (
spec/services/{jira,slack,bugsnag}/*_spec.rb):stub_const("X::ROOT", Pathname(tmp)), write a result file with good and junk rows, assertpollreturns only the filtered rows; also cover missing file, bad JSON, non-array, and staleness (pid alive andstarted_atolder thanSTALE_AFTERraisesFetchErrorand clears the pid). - Sync specs: each
blockersreason,importcreating records, idempotent re-import, a row that fails validation landing infailed. - Job specs (
spec/jobs/*_sync_job_spec.rb): stub the fetcher; assert import-before-start, no start while running or blocked, FetchError recorded as"failed: ...". - Run with
RAILS_ENV=test bundle exec rspec.
Safety
- No secrets in prompts, logs or result files; the app holds no Jira/Slack/Bugsnag tokens.
- Never start a real agent (or call
fetcher.start) against a real project to try it — stubAiCli::Runner. No scratch scripts orrails runneragainst the development DB; every command setsRAILS_ENV=test. - Don't delete or hand-edit real
tmp/<name>/<id>state dirs; a stale pid is cleared bypoll.