One Watcher, Any Pipeline: Label-Based Dispatch
Code: github.com/wanleung/ai-dev-team
In short: As the system grew, every new pipeline type — bug fixes, documentation, features — needed its own Python script and GitHub Actions workflow. This post covers how all of them were unified into a single watcher process where a GitHub label determines which pipeline runs. Adding a new pipeline now means writing one YAML file.
By the time the blocky pipeline landed, the entry point situation had quietly become a mess.
build_feature.py # GitHub Actions: feature pipeline
fix_issue.py # GitHub Actions: bug fix pipeline
BugFixOrchestrator # Separate orchestrator class (523 lines)
DocOrchestrator # Another separate orchestrator class (338 lines)
watcher.py # Hourly poller — already knew about all threeEvery new pipeline type meant writing a new script, a new orchestrator subclass, and a new .github/workflows/*.yml. The logic for “what to run” was scattered across three different layers.
The insight: the watcher already knew what to do based on a GitHub label. It was just routing that knowledge through the wrong abstraction.
The new model
Each GitHub label maps to a pipeline YAML file:
ai-feature → pipelines/ai-feature.yaml
ai-fix → pipelines/ai-fix.yaml
ai-docs → pipelines/ai-docs.yaml
my-label → pipelines/my-label.yaml (custom)The watcher dispatches by label. The orchestrator loads the corresponding YAML and runs it. Nothing else changes.
Adding a new pipeline type is now:
Create
pipelines/my-new-pipeline.yamlAdd the label to your
repos.yamlentryDone — no Python, no new workflow
Built-in pipeline files
The three pipelines that previously lived in Python now live in YAML:
pipelines/ai-feature.yaml — the standard 13-stage feature pipeline:
stages:
- pm
- pm_reviewer
- architect
- architect_reviewer
- tier_review
- junior_engineer
- senior_engineer
- reviewer
- qa_planner
- qa_engineer
- test_fix
- deploy_tester
- deploy_fixpipelines/ai-fix.yaml — diagnose first, then fix:
stages:
- diagnose
- bug_fix
- test_fix
- deploy_testerpipelines/ai-docs.yaml — documentation generation and PR:
stages:
- doc_generate
- doc_commit_prThese are starting points. You can override any of them by placing a pipeline.yaml in your project repo — the project-level file takes priority, exactly as before.
New stages in the registry
The bug-fix and documentation stages previously lived in separate orchestrator subclasses. They were absorbed into the unified Orchestrator._make_stage_registry():
"diagnose": PipelineStage(fn=lambda r: self._stage_diagnose(r), ...),
"bug_fix": PipelineStage(fn=lambda r: self._stage_bug_fix(r), ...),
"doc_generate": PipelineStage(fn=lambda r: self._stage_doc_generate(r), ...),
"doc_commit_pr": PipelineStage(fn=lambda r: self._stage_doc_commit_pr(r), ...),Same pattern as every other stage. Same PipelineResult. They’re now first-class citizens in the blocky pipeline format too — you can include diagnose inside a loop block, or mix doc_generate into a feature pipeline if you want docs auto-generated alongside code.
Watcher dispatch: before and after
Before, _dispatch() branched on a pipeline_type string:
if pipeline_type == "feature":
orch = Orchestrator(...)
elif pipeline_type == "bug":
orch = BugFixOrchestrator(...)
elif pipeline_type == "documentation":
orch = DocOrchestrator(...)After, it’s a single path:
def _dispatch(label: str, ...) -> None:
orch = Orchestrator(...)
stages = orch.load_pipeline_for_label(label, project_dir)
orch._pipeline_yaml_stages = stages
orch.run(requirement)load_pipeline_for_label() does the resolution: project pipeline.yaml → built-in pipelines/<label>.yaml → None (falls back to default mode). The orchestrator doesn’t need to know whether it’s running a feature, a bug fix, or documentation — it just runs the stages it was given.
Concurrency: two levels
Before the unification, concurrency was a single ThreadPoolExecutor:
with ThreadPoolExecutor(max_workers=max_parallel) as pool:
for issue in all_issues:
pool.submit(run_pipeline, issue)This worked but didn’t model the real constraint: some repos need more parallelism than others. A small active repo and a large quiet one shouldn’t share a fixed pool.
The new model has two levels:
Level 1 — per-repo executor
Each repo gets its own ThreadPoolExecutor with max_workers = parallel_issues:
# repos.yaml
watchers:
- tracker_repo: owner/my-busy-repo
parallel_issues: 3 # process up to 3 issues from this repo at once
- tracker_repo: owner/my-quiet-repo
parallel_issues: 1 # defaultLevel 2 — global cap
A shared threading.Semaphore(max_parallel) wraps every pipeline run. Total concurrent pipelines across all repos never exceeds settings.max_parallel:
global_sem = threading.Semaphore(max_parallel)
def _run_with_global_cap(*args, **kwargs):
global_sem.acquire()
try:
run_pipeline(*args, **kwargs)
finally:
global_sem.release()Both limits compose correctly. A repo with parallel_issues: 3 can run three at once — as long as the global cap isn’t already at capacity.
Per-backend LLM pools
The LLM pool from v0.4.0 (per-backend semaphores) is now installed at startup for both the watcher and the CLI. Configuration lives in config.yaml:
llm:
pools:
ollama: 1 # one call at a time — safe for local GPU/CPU
openai: 5 # five concurrent OpenAI calls
anthropic: 5The watcher installs the pool before dispatching any pipelines. Every agent call acquires a slot:
with get_pool().acquire(self._backend):
response = self._llm.call(messages)Pool limits are validated at startup. Zero or negative values are coerced to a safe default with a warning — a misconfigured ollama: 0 won’t silently deadlock the entire watcher.
GitHub Actions: one pattern
The feature-build.yml and bug-fix.yml workflows previously called Python scripts directly. They now both call the same entry point with different labels:
# .github/workflows/feature-build.yml
- name: Run pipeline
run: python watcher.py --once --repo ${{ github.event.inputs.tracker_repo }} --issue ${{ github.event.inputs.issue_number }} --label ai-feature
# .github/workflows/bug-fix.yml
- name: Run pipeline
run: python watcher.py --once --repo ${{ github.event.inputs.tracker_repo }} --issue ${{ github.event.inputs.issue_number }} --label ai-fixAdding a workflow for a new pipeline type is now copy-paste + change the label. No new Python entry point needed.
–once mode
The --once flag is what makes the watcher work from GitHub Actions. Instead of polling in a loop, it processes a single issue and exits:
python watcher.py --once \
--repo owner/my-repo \
--issue 42 \
--label ai-fixThe watcher poller uses the same run_pipeline function under the hood. The same code path, same logging, same error handling — just triggered once by Actions rather than on a schedule.
Discovering pipelines
python main.py --list-pipelinesAvailable pipelines:
ai-docs
ai-feature
ai-fixLists every YAML file in pipelines/. If you add pipelines/my-custom.yaml, it shows up here automatically. The --pipeline flag runs a specific one directly from the CLI:
python main.py --pipeline ai-fix --issue 99What the repo looks like now
ai-software-house/
├── main.py # CLI + --pipeline / --list-pipelines
├── watcher.py # Poller + --once for Actions
├── orchestrator.py # Unified orchestrator (all stage types)
├── llm_pool.py # Per-backend concurrency pools
├── pipelines/
│ ├── ai-feature.yaml # 13-stage feature pipeline
│ ├── ai-fix.yaml # 4-stage bug-fix pipeline
│ └── ai-docs.yaml # 2-stage docs pipeline
├── repos.yaml # Repos + labels + parallel_issues
└── config.yaml # Models, team size, llm.poolsBugFixOrchestrator, DocOrchestrator, build_feature.py, and fix_issue.py are gone. The stage registry absorbed their logic. The YAML files replaced their pipelines.
The pattern
The underlying principle: configuration should be data, not code. Changing which stages run in which order shouldn’t require editing Python. It should require editing a list.
The blocky pipeline format established this for custom per-project pipelines. The label-based dispatch extends it to the system level — the operator choosing which pipeline to run for a given kind of issue is now also just a label in repos.yaml.
The watcher doesn’t know what a “feature” or a “bug fix” is. It knows labels and YAML files. That’s enough.
Code: github.com/wanleung/ai-dev-team