Playbook: How Projects Enqueue Tasks via the Queue API
Service: p24-meta-dispatcher Cloudflare Worker
Related docs: docs/queue-api-operations.md, docs/meta-dispatcher-operations.md, docs/queue-dispatcher-operations.md
Dispatch Protocol Summary
| Project | repo field | job_profile (auto-derived) | Keys location |
|---|---|---|---|
| et-operational-platform | radieu/et-operational-platform | nextjs-supabase-vercel | Vercel env vars |
| brandpilot | radieu/brandpilot | nextjs-supabase-vercel | secrets/brandpilot.env.sops (synced to Vercel) |
| Art-Agency | radieu/Art-Agency | nextjs-supabase-vercel | secrets/art-agency.env.sops |
| radekkonarski-personal-brand | radieu/radekkonarski-personal-brand | content-automation | secrets/radekkonarski-brand.env.sops (pending C1) |
| amazon-kdp-tango | radieu/amazon-kdp-tango | kdp-python | secrets/kdp-tango.env.sops (pending) |
| p24-infra | radieu/p24-infra | infra-ops | secrets/monitoring.env.sops |
1. Overview
The p24-meta-dispatcher Cloudflare Worker exposes an HTTP API that any sibling project can call to dispatch a GitHub issue into the p24-infra worker queue. Within ~2 minutes, the queue-dispatcher on bms-4 picks up the job and spawns a Claude worker via spawn-worker.sh.
Your project → POST /queue-issue → CF Worker → dev_r_worker_queue
↓ (within 2 min)
queue-dispatcher-loop.py (bms-4)
↓
spawn-worker.sh → systemd-run → Claude worker
bms4 proxy (planned — ADR-005 §9, #6112). A centralization component on bms-4 is planned to sit between remote/ephemeral workers and Anthropic: a single static egress IP, centralized account/task routing (reusing the dispatcher’s existing
_account_orderheadroom ranker), and OAuth session custody for ephemeral workers (windows-dev Docker containers, #6110). It does not change how sibling projects enqueue — thePOST /queue-issuepath below is unchanged. Design-of-record:docs/bms4-proxy-operations.md.
Use this API when: a sibling project (BrandPilot, et-operational-platform, Art-Agency, radekkonarski-personal-brand) needs to trigger a p24-infra worker to implement a GitHub issue automatically.
Do NOT use this API for:
- Dispatching issues that belong to the calling project’s own repo without moving them to
radieu/p24-infrafirst (or supply therepofield to target the right repo) - Bypassing the issue pipeline — the issue must exist on GitHub first
2. Endpoint Reference
Base URL
https://p24-meta-dispatcher.radieu.workers.dev
Authentication
All endpoints require:
Authorization: Bearer <QUEUE_API_KEY>
Missing or invalid token returns 401 {"error":"Unauthorized"}. The key is validated with a constant-time comparison to prevent timing attacks.
POST /queue-issue — Dispatch a single issue
Enqueues one GitHub issue for processing by a Claude worker.
Request:
POST https://p24-meta-dispatcher.radieu.workers.dev/queue-issue
Authorization: Bearer <QUEUE_API_KEY>
Content-Type: application/json
{
"issue_number": 1234,
"repo": "radieu/et-operational-platform",
"job_type": "dev-issue",
"weight": "light",
"priority": 10
}| Field | Required | Default | Description |
|---|---|---|---|
issue_number | Yes | — | GitHub issue number |
repo | No | radieu/p24-infra | Target repo (e.g. radieu/et-operational-platform) |
job_type | No | dev-issue | dev-issue, infra-task, or infra-alert |
weight | No | auto-derived | light (3 GB) or heavy (8 GB) — omit to let the Worker derive from issue labels/complexity |
priority | No | auto-derived | Lower = processed first (1–20). Omit to let Worker compute |
job_profile | No | auto-derived | Tech-stack profile. Auto-derived from repo name if omitted. Values: nextjs-supabase-vercel | python-fastapi | infra-ops | content-automation | kdp-python | generic |
When weight/priority are omitted, the Worker fetches the issue from GitHub and auto-derives weight from acceptance-criteria count and labels.
Successful response — 201:
{
"id": 42,
"status": "queued",
"job_type": "dev-issue",
"weight": "light",
"priority": 10,
"github_issue_number": 1234,
"repo": "radieu/et-operational-platform",
"queued_at": "2026-06-25T10:00:00Z",
"metadata": { "source": "queue-api" }
}The returned id is the dev_r_worker_queue row ID. Use it to poll job status.
Error responses:
| Status | Body | Cause |
|---|---|---|
400 | {"error":"issue_number required"} | Missing issue_number in body |
401 | {"error":"Unauthorized"} | Invalid or missing Bearer token |
GET /queue-issue?job_id=N — Poll job status
Check the status of a previously enqueued job.
GET https://p24-meta-dispatcher.radieu.workers.dev/queue-issue?job_id=42
Authorization: Bearer <QUEUE_API_KEY>job_id is the id returned by POST /queue-issue — not the GitHub issue number.
Successful response — 200:
{
"id": 42,
"status": "running",
"job_type": "dev-issue",
"weight": "light",
"priority": 10,
"github_issue_number": 1234,
"repo": "radieu/et-operational-platform",
"queued_at": "2026-06-25T10:00:00Z",
"claimed_at": "2026-06-25T10:02:15Z",
"started_at": "2026-06-25T10:02:16Z",
"metadata": { "source": "queue-api" }
}Status lifecycle:
queued → claimed → running → done
→ failed
→ cancelled
→ oom_killed
Error responses:
| Status | Body | Cause |
|---|---|---|
400 | {"error":"job_id required"} | Missing or non-numeric job_id |
401 | {"error":"Unauthorized"} | Invalid or missing Bearer token |
404 | {"error":"not found"} | No row with that id |
POST /trigger — Manual meta-dispatcher run
Triggers the wave-aware batch queueing logic immediately (normally runs every 4 hours via cron). Returns 202 immediately; dispatch runs in the background.
POST https://p24-meta-dispatcher.radieu.workers.dev/trigger
Authorization: Bearer <QUEUE_API_KEY>
Content-Type: application/json
{ "all_waves": false }Response: 202 {"ok":true}
3. Per-Project Setup — Where to Get the Keys
3.1 BrandPilot
QUEUE_API_KEY and QUEUE_API_URL are already present.
SOPS location: p24-infra/secrets/brandpilot.env.sops
# Decrypt to verify (Windows dev workstation)
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
sops --decrypt --input-type dotenv --output-type dotenv secrets\brandpilot.env.sops | Select-String "QUEUE_"Vercel env vars: synced automatically by secrets-sync.yml on merge to dev or main. Available as:
QUEUE_API_KEYQUEUE_API_URL→https://p24-meta-dispatcher.radieu.workers.dev
No manual setup needed — keys are live in both SOPS and Vercel.
3.2 et-operational-platform
QUEUE_API_KEY and QUEUE_API_URL are in secrets/monitoring.env.sops (they cover vps-i1/bms-4 env as well).
SOPS location: p24-infra/secrets/monitoring.env.sops
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
sops --decrypt --input-type dotenv --output-type dotenv secrets\monitoring.env.sops | Select-String "QUEUE_"To add to et-operational-platform Vercel project:
Open a p24-infra session and run:
vercel env add QUEUE_API_KEY production preview
vercel env add QUEUE_API_URL production previewValues come from secrets/monitoring.env.sops.
Or file a radieu/p24-infra issue with label infra-task-request — the n8n pipeline will handle it.
3.3 Art-Agency
QUEUE_API_KEY and QUEUE_API_URL are now configured in secrets/art-agency.env.sops (added in PR #1849).
SOPS location: p24-infra/secrets/art-agency.env.sops
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
sops --decrypt --input-type dotenv --output-type dotenv secrets\art-agency.env.sops | Select-String "QUEUE_"To sync to the Art-Agency Vercel project, open a p24-infra session and run:
vercel env add QUEUE_API_KEY production preview
vercel env add QUEUE_API_URL production previewOr use /request-infra from any Art-Agency session — see docs/playbooks/cross-project-infra-requests.md.
SOPS target: p24-infra/secrets/art-agency.env.sops
3.4 radekkonarski-personal-brand
Keys are not yet in this project’s SOPS file. To add them:
- Open a p24-infra session.
- Decrypt
radekkonarski-personal-brand/secrets/radekkonarski-brand.env.sops, append the keys, re-encrypt, push. - Reference in n8n workflows on bms-4 via the n8n credential or env injection.
SOPS target: radekkonarski-personal-brand/secrets/radekkonarski-brand.env.sops
4. Integration Examples
4.1 curl — manual dispatch
curl -s -X POST "https://p24-meta-dispatcher.radieu.workers.dev/queue-issue" \
-H "Authorization: Bearer $QUEUE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"issue_number": 1234, "repo": "radieu/et-operational-platform", "job_type": "dev-issue"}' \
| jq .Poll status:
JOB_ID=42
curl -s "https://p24-meta-dispatcher.radieu.workers.dev/queue-issue?job_id=$JOB_ID" \
-H "Authorization: Bearer $QUEUE_API_KEY" | jq .status4.2 n8n HTTP Request node (bms-4)
Node settings:
| Setting | Value |
|---|---|
| Method | POST |
| URL | {{ $env.QUEUE_API_URL }}/queue-issue |
| Authentication | Predefined Credential Type → Header Auth |
| Credential | p24-infra-queue-api (or create Header Auth with name Authorization, value Bearer {{ $env.QUEUE_API_KEY }}) |
| Specify Body | JSON (not keypairs — see note below) |
| JSON | ={{ JSON.stringify({ issue_number: $json.issue_number, repo: "radieu/et-operational-platform", job_type: "dev-issue" }) }} |
n8n 2.26.x bug:
specifyBody=keypairssilently sends an empty body. Always usespecifyBody=jsonwith aJSON.stringify(...)expression. See memory filefeedback_n8n_2_26_specifyBody_bug.md.
Complete HTTP Request node JSON (copy into Code node or import):
{
"parameters": {
"method": "POST",
"url": "={{ $env.QUEUE_API_URL }}/queue-issue",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpHeaderAuth",
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ issue_number: $json.issue_number, repo: \"radieu/et-operational-platform\", job_type: \"dev-issue\" }) }}",
"options": {}
},
"credentials": {
"httpHeaderAuth": { "id": "<credential-id>", "name": "p24-infra-queue-api" }
}
}Credential setup (once per n8n instance):
Settings → Credentials → New → Header Auth:
- Name:
p24-infra-queue-api - Name field:
Authorization - Value field:
Bearer <QUEUE_API_KEY>
Never hardcode the key value in node parameters — always use the credential reference.
4.3 TypeScript / Next.js (BrandPilot or et-operational-platform)
// lib/queue.ts
export interface QueueJob {
id: number;
status: "queued" | "claimed" | "running" | "done" | "failed" | "cancelled" | "oom_killed";
job_type: string;
weight: "light" | "heavy";
priority: number;
github_issue_number: number;
repo: string;
queued_at: string;
claimed_at?: string;
started_at?: string;
}
export async function dispatchIssue(
issueNumber: number,
repo = "radieu/p24-infra",
jobType = "dev-issue",
): Promise<QueueJob> {
const url = process.env.QUEUE_API_URL;
const key = process.env.QUEUE_API_KEY;
if (!url || !key) throw new Error("QUEUE_API_URL or QUEUE_API_KEY not set");
const resp = await fetch(`${url}/queue-issue`, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ issue_number: issueNumber, repo, job_type: jobType }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(`Queue API ${resp.status}: ${JSON.stringify(err)}`);
}
return resp.json() as Promise<QueueJob>;
}
export async function pollJobStatus(jobId: number): Promise<QueueJob> {
const url = process.env.QUEUE_API_URL;
const key = process.env.QUEUE_API_KEY;
if (!url || !key) throw new Error("QUEUE_API_URL or QUEUE_API_KEY not set");
const resp = await fetch(`${url}/queue-issue?job_id=${jobId}`, {
headers: { Authorization: `Bearer ${key}` },
});
if (resp.status === 404) throw new Error(`Job ${jobId} not found`);
if (!resp.ok) throw new Error(`Poll failed: ${resp.status}`);
return resp.json() as Promise<QueueJob>;
}
// Usage in a Server Action or API Route:
//
// const job = await dispatchIssue(1234, "radieu/et-operational-platform");
// console.log(`Queued as job #${job.id}, weight=${job.weight}`);
//
// // Poll until done (simple loop — use exponential backoff in production):
// while (true) {
// const s = await pollJobStatus(job.id);
// if (["done","failed","cancelled","oom_killed"].includes(s.status)) break;
// await new Promise(r => setTimeout(r, 10_000)); // wait 10s
// }5. What Happens After Dispatch
POST /queue-issue
→ CF Worker inserts row into dev_r_worker_queue (status=queued)
→ returns { id, status:"queued", ... }
↓ within ~2 min
queue-dispatcher-loop.py on bms-4 (runs every 2 min via p24-queue-dispatcher.timer):
1. Claims dispatcher lease via claim_dispatcher_lease() RPC
2. Checks available RAM via available_ram_gb('bms-4')
3. If RAM available: PATCHes row to status=claimed, SSH-executes spawn-worker.sh
4. spawn-worker.sh allocates a systemd cgroup, runs claude -p with the worker prompt
5. Row status → running; PID recorded
6. Worker completes: row status → done (or failed / oom_killed)
Exit-code escalation (if worker can't fit in current weight tier):
light → heavy → playwright → orchestrator → failed
Stuck jobs auto-recovered: reset_stale_workers() re-queues running rows after 2 hours.
Monitoring:
- Mezmo: filter
app:p24-queuefor dispatch events (dispatch_cycle,spawn_ok,spawn_oom) - Grafana:
worker-queue-v1dashboard — queue depth, running count, RAM headroom - Discord:
#infra-errorschannel forspawn_oomanddispatch_failedevents
Typical end-to-end time:
- Queued → claimed: ≤ 2 min (next dispatcher tick)
- Claimed → running: < 30 seconds (SSH + systemd-run startup)
- Running → done: depends on issue complexity (light: 5–30 min, heavy: 15–60 min)
6. Troubleshooting
Job stays queued for > 5 minutes:
-- Check dispatcher lease
SELECT holder, expires_at FROM dev_r_dispatcher_lease;
-- Check available RAM
SELECT available_ram_gb('bms-4');
-- Check queue depth
SELECT status, COUNT(*) FROM dev_r_worker_queue GROUP BY status;If dispatcher is dead, see docs/playbooks/worker-queue-operations.md § Fix — Queue not draining.
Job status is failed immediately:
The Worker could not insert the row (Supabase auth issue) or the GitHub issue was not found. Check:
QUEUE_API_KEYmatches the CF Worker secretissue_numberexists inrepo- Supabase service key is valid (
SUPABASE_SERVICE_ROLE_KEYin CF Worker)
401 Unauthorized from the API:
- Verify
QUEUE_API_KEYwas copied fromsecrets/monitoring.env.sops(orbrandpilot.env.sops) - Check that the Authorization header is exactly
Bearer <key>with a space after Bearer - The key is validated with constant-time comparison — even a trailing newline causes failure
OOM (oom_killed):
Dispatcher auto-re-queues as heavier weight (light → heavy). If it OOM-kills again at heavy, the issue needs splitting. See docs/playbooks/worker-queue-operations.md § Fix — OOM worker.
7. Related
docs/queue-api-operations.md— full API reference (all endpoints, CORS, rate limits)docs/meta-dispatcher-operations.md— CF Worker deployment, cron scheduling, wave logicdocs/queue-dispatcher-operations.md— bms-4 dispatcher internals, RAM budget, exit codesdocs/playbooks/worker-queue-operations.md— stuck jobs, OOM recovery, failoverdocs/playbooks/cross-project-infra-requests.md— how to request infra changes from non-p24-infra sessionsinfra-src/meta-dispatcher/src/index.ts— Worker sourcescripts/spawn-worker.sh— worker spawn logic and exit code reference