One Watcher, Any Pipeline: Label-Based Dispatch

📁 Computer

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 three

Every 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:

  1. Create pipelines/my-new-pipeline.yaml

  2. Add the label to your repos.yaml entry

  3. Done — 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_fix

pipelines/ai-fix.yaml — diagnose first, then fix:

stages:
  - diagnose
  - bug_fix
  - test_fix
  - deploy_tester

pipelines/ai-docs.yaml — documentation generation and PR:

stages:
  - doc_generate
  - doc_commit_pr

These 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>.yamlNone (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    # default

Level 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: 5

The 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-fix

Adding 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-fix

The 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-pipelines
Available pipelines:
  ai-docs
  ai-feature
  ai-fix

Lists 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 99

What 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.pools

BugFixOrchestrator, 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