meta-dispatcher — Operations
Purpose
Cloudflare Worker (p24-meta-dispatcher) that replaces the local /issues-queue PowerShell skill.
Combines two functions in one Worker:
- HTTP Queue API — single ad-hoc dispatch:
POST /queue-issue - Cron meta-dispatcher — wave-aware: reads GitHub issues, builds dependency waves, inserts
dev_r_worker_queuerows every 4 hours
The existing queue-dispatcher-loop.py on bms-4 is unchanged — it remains the consumer.
Deployment
Automated (CI/CD — preferred)
infra-src/meta-dispatcher/ci/deploy-meta-dispatcher.yml (install to .github/workflows/) deploys the Worker and binds all 5
secrets automatically. It runs on:
- push to
maintouchinginfra-src/meta-dispatcher/**(or the workflow itself) - manual:
gh workflow run deploy-meta-dispatcher.yml --repo radieu/p24-infra
The workflow installs deps, runs npm test, deploys via wrangler deploy, then binds
the secrets — each value decrypted from secrets/monitoring.env.sops with the CI age key
(AGE_KEY_GHA) and piped to wrangler secret put (never echoed). It uses the GH secret
CF_API_TOKEN (needs Workers Scripts:Edit scope) and account ID
acc5b2c956dd488265003364e2c7f84a. A smoke test confirms GET /queue-issue returns 404
(not 500) after deploy. On failure it sends a Discord alert and opens a GH issue.
Manual (fallback)
cd infra-src/meta-dispatcher
npm install
wrangler deployAfter first deploy, set all 5 secrets (see below).
Rollback:
wrangler rollbackRestores the previous CF Worker version instantly. DB migration rollback (if needed):
DROP TABLE dev_r_orchestrator_runs;
-- Also remove dev_r_services rows if rolling back the registrationSecrets
All 5 secrets are set via wrangler secret put from infra-src/meta-dispatcher/:
| Secret name | Source | SOPS file |
|---|---|---|
QUEUE_API_KEY | Generated: python3 -c "import secrets; print(secrets.token_hex(32))" | secrets/monitoring.env.sops + secrets/brandpilot.env.sops |
SUPABASE_SERVICE_KEY | Supabase dashboard → API keys → Secret | secrets/monitoring.env.sops |
GH_TOKEN | GitHub PAT with repo scope (issues + comments read on private repo) | secrets/monitoring.env.sops |
DISCORD_WEBHOOK_URL | P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL | secrets/monitoring.env.sops |
MEZMO_SERVICE_KEY | P24_INFRA_MEZMO_SERVICE_KEY | secrets/monitoring.env.sops |
wrangler secret put QUEUE_API_KEY
wrangler secret put SUPABASE_SERVICE_KEY
wrangler secret put GH_TOKEN
wrangler secret put DISCORD_WEBHOOK_URL
wrangler secret put MEZMO_SERVICE_KEYQUEUE_API_KEY Rotation
- Generate a new key:
python3 -c "import secrets; print(secrets.token_hex(32))" - Set on Worker:
wrangler secret put QUEUE_API_KEY(ininfra-src/meta-dispatcher/) - Update
secrets/monitoring.env.sops— follow Windows SOPS procedure indocs/playbooks/sops-windows-crlf.md - Update
secrets/brandpilot.env.sops— same procedure - Sync to Vercel:
vercel env add QUEUE_API_KEY(for BrandPilot integration) - Append rotation entry to
docs/secrets-rotation-log.md
The old key stops working immediately after step 2 — coordinate with any callers (BrandPilot, n8n) before rotating.
Cron Schedule
Runs every 4 hours: 0 */4 * * * (00:00, 04:00, 08:00, 12:00, 16:00, 20:00 UTC).
Verify in Cloudflare dashboard → Workers → p24-meta-dispatcher → Triggers tab.
Use wrangler tail to stream live logs during a cron invocation or manual trigger.
Monitoring
Grafana panel — recent orchestrator runs
Add a Table panel to the p24-infra Grafana dashboard using the grafana_readonly PostgreSQL datasource:
SELECT id, triggered_by, status, issues_analyzed, waves_planned, issues_queued,
issues_skipped, started_at, completed_at
FROM dev_r_orchestrator_runs
ORDER BY started_at DESC
LIMIT 10;Mezmo log search
Search app:meta-dispatcher in the Mezmo UI for structured logs from the Worker.
Key log lines: "run started", "wave N queued", "run complete", "wave in progress — skipped".
Distributed Lock
The Worker uses a unique partial index on dev_r_orchestrator_runs to prevent overlapping runs:
CREATE UNIQUE INDEX dev_r_orchestrator_runs_one_running
ON dev_r_orchestrator_runs (status)
WHERE status = 'running';A second concurrent trigger attempt gets a 23505 unique violation and aborts cleanly.
Stale locks (CF Worker hit the 30s wall-clock limit) are auto-cleaned on the next run start
(any running row older than 10 minutes is patched to failed before inserting the new run row).
Wave Logic
- Fetches all open issues with milestones:
Triage,Design,In Progress - Enriches
In Progressissues only (reads comments to find## Code-change-designblock) - Builds a dependency graph from: explicit
depends on #Nreferences in body + file-overlap edges - Topological sort (Kahn’s algorithm), max 6 issues per wave
- On the cron trigger: queues first incomplete wave only (default)
- On
POST /triggerwithall_waves: true: queues all incomplete waves
Priority formula: (wave_number × 10) + labelAdj + sizeAdj
labelAdj:bug=-5,patch=-2, else=0sizeAdj: S=-1, M=0, L=+1, XL=+2- Size from AC count in design comment: 1-2→S, 3-4→M, 5+→L; XL if arch-gate keyword in title/body
- Weight: S→
light, M/L/XL→heavy
Role Classification (#1706)
Both singleDispatch() (queue-api) and runMetaDispatcher() (wave builder) call
classifyRole() (src/classify.ts) at enqueue time and write role,
repo_context, server_preference to each queue row. The worker reads role to
select its agent prompt; the dispatcher uses server_preference to route rows to
the right server. Full rule table: see worker-queue-operations.md §Role-Based Routing.
Troubleshooting
All endpoints return HTTP 500 / CF error 1101 (issues #1507, #1550)
Symptom: every request to https://p24-meta-dispatcher.radieu.workers.dev/*
returns an opaque 500 with a Cloudflare 1101 (“Worker threw exception”) page.
Root cause (2026-06-27): the Worker source was complete but had never been
deployed with its secrets bound — there was no deploy workflow, so the Worker
either did not exist or ran without QUEUE_API_KEY/SUPABASE_SERVICE_KEY bound.
Fix: run the deploy workflow (gh workflow run deploy-meta-dispatcher.yml),
which deploys the code and binds all 5 secrets. Confirm with:
# 404 (not 500) means the handler is reachable and secrets are bound
curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $QUEUE_API_KEY" \
"https://p24-meta-dispatcher.radieu.workers.dev/queue-issue?job_id=999999999"Then verify QUEUE_API_URL in secrets/monitoring.env.sops points to the live
host p24-meta-dispatcher.radieu.workers.dev (not a placeholder). Note: the routing
handler already wraps all routes in a try/catch that converts downstream throws into a
diagnosable JSON 500 with a detail field instead of a bare 1101 (see src/index.ts).
POST/GET /queue-issue returns 401 or 403 (issue #2222)
First, distinguish the two — they have different causes:
- 401
{"error":"Unauthorized"}comes from the worker itself (verifyApiKey). It means theAuthorization: Bearer <key>header was missing, malformed, or did not match the boundQUEUE_API_KEY. The worker has no code path that returns 403. - 403
Forbiddenonhttps://p24-meta-dispatcher.radieu.workers.dev/*is a Cloudflare edge response (Bot Fight Mode / WAF custom rule / rate-limit / Access policy) — it never reaches the worker. A 403 is therefore not a key mismatch; check the Cloudflare dashboard (Security → Events) for the blocking rule and the source IP.
Verify the key against the live worker (status code only — never print the key):
QK=$(sops -d --input-type dotenv --output-type dotenv secrets/monitoring.env.sops \
| grep '^QUEUE_API_KEY=' | cut -d= -f2- | tr -d '"')
# 404 = handler reached + key accepted; 401 = bad key
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $QK" \
"https://p24-meta-dispatcher.radieu.workers.dev/queue-issue?job_id=999999999"
unset QKQUEUE_API_URL must be the BASE host — https://p24-meta-dispatcher.radieu.workers.dev,
without a /queue-issue suffix. Callers append the path themselves
(${QUEUE_API_URL}/queue-issue, ${QUEUE_API_URL}/trigger); a suffixed value produces a
doubled path. (The worker routes on path.endsWith(...) so a doubled /queue-issue is
currently tolerated, but /trigger and any future route are not — keep the env var as the
base host.)
Misconfiguration is fail-closed (#2222): if QUEUE_API_KEY is unbound or empty, the
worker returns a clean 401 rather than crashing to a CF 1101/500 or (for an empty key)
accepting an empty Bearer token. If you see 401 on every request, confirm the secret is
bound: wrangler secret list for p24-meta-dispatcher.
Endpoint returns HTTP 500 Supabase … Invalid API key (issue #4621)
Symptom: a specific data-path endpoint (e.g. POST /admin/cleanup-failed, seen when
nightly-queue-cleanup.yml fails) returns:
HTTP 500: {"error":"internal error","detail":"Error: Supabase UPDATE cleanupFailedJobs: undefined Invalid API key"}
The worker is reachable (routes resolve, try/catch produces a JSON 500 with detail), so this
is not the “all endpoints 1101” case above — it is Supabase rejecting the bound
SUPABASE_SERVICE_KEY.
Root cause (2026-07-30): the Wrangler SUPABASE_SERVICE_KEY secret on the deployed worker
was a stale legacy JWT (eyJ…). Supabase disabled legacy API keys, so every call is rejected
with Invalid API key. The current new-format sb_secret_* value already lives in
secrets/monitoring.env.sops — only the worker’s bound copy was stale.
Fix: re-bind the worker secrets from SOPS via the deploy workflow (never edit SOPS for this — the value there is already correct):
gh workflow run deploy-meta-dispatcher.yml --repo radieu/p24-infra # re-binds all 5 secrets
# verify the originally-failing path (dry run — no rows mutated):
gh workflow run nightly-queue-cleanup.yml --repo radieu/p24-infra -f dry_run=true
# expect: HTTP 200: {"ok":true,...,"dry_run":true}Confirm which SOPS keys are new-format without printing values:
… | grep '^SUPABASE_SERVICE_KEY=' | cut -d= -f2- should start with sb_secret_, not eyJ.
Related
docs/queue-api-operations.md— HTTP API endpoint referenceinfra-src/meta-dispatcher/ci/deploy-meta-dispatcher.yml(install to.github/workflows/) — CI/CD deploy + secret bindinginfra-src/meta-dispatcher/— source codesupabase/migrations/20260625_queue_api.sql— DB schemaqueue-dispatcher-loop.py(bms-4) — unchanged consumer