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

Projectrepo fieldjob_profile (auto-derived)Keys location
et-operational-platformradieu/et-operational-platformnextjs-supabase-vercelVercel env vars
brandpilotradieu/brandpilotnextjs-supabase-vercelsecrets/brandpilot.env.sops (synced to Vercel)
Art-Agencyradieu/Art-Agencynextjs-supabase-vercelsecrets/art-agency.env.sops
radekkonarski-personal-brandradieu/radekkonarski-personal-brandcontent-automationsecrets/radekkonarski-brand.env.sops (pending C1)
amazon-kdp-tangoradieu/amazon-kdp-tangokdp-pythonsecrets/kdp-tango.env.sops (pending)
p24-infraradieu/p24-infrainfra-opssecrets/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_order headroom ranker), and OAuth session custody for ephemeral workers (windows-dev Docker containers, #6110). It does not change how sibling projects enqueue — the POST /queue-issue path 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-infra first (or supply the repo field 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
}
FieldRequiredDefaultDescription
issue_numberYesGitHub issue number
repoNoradieu/p24-infraTarget repo (e.g. radieu/et-operational-platform)
job_typeNodev-issuedev-issue, infra-task, or infra-alert
weightNoauto-derivedlight (3 GB) or heavy (8 GB) — omit to let the Worker derive from issue labels/complexity
priorityNoauto-derivedLower = processed first (1–20). Omit to let Worker compute
job_profileNoauto-derivedTech-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:

StatusBodyCause
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-issuenot 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:

StatusBodyCause
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_KEY
  • QUEUE_API_URLhttps://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 preview

Values 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 preview

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

  1. Open a p24-infra session.
  2. Decrypt radekkonarski-personal-brand/secrets/radekkonarski-brand.env.sops, append the keys, re-encrypt, push.
  3. 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 .status

4.2 n8n HTTP Request node (bms-4)

Node settings:

SettingValue
MethodPOST
URL{{ $env.QUEUE_API_URL }}/queue-issue
AuthenticationPredefined Credential Type → Header Auth
Credentialp24-infra-queue-api (or create Header Auth with name Authorization, value Bearer {{ $env.QUEUE_API_KEY }})
Specify BodyJSON (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=keypairs silently sends an empty body. Always use specifyBody=json with a JSON.stringify(...) expression. See memory file feedback_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-queue for dispatch events (dispatch_cycle, spawn_ok, spawn_oom)
  • Grafana: worker-queue-v1 dashboard — queue depth, running count, RAM headroom
  • Discord: #infra-errors channel for spawn_oom and dispatch_failed events

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_KEY matches the CF Worker secret
  • issue_number exists in repo
  • Supabase service key is valid (SUPABASE_SERVICE_ROLE_KEY in CF Worker)

401 Unauthorized from the API:

  • Verify QUEUE_API_KEY was copied from secrets/monitoring.env.sops (or brandpilot.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.


  • docs/queue-api-operations.md — full API reference (all endpoints, CORS, rate limits)
  • docs/meta-dispatcher-operations.md — CF Worker deployment, cron scheduling, wave logic
  • docs/queue-dispatcher-operations.md — bms-4 dispatcher internals, RAM budget, exit codes
  • docs/playbooks/worker-queue-operations.md — stuck jobs, OOM recovery, failover
  • docs/playbooks/cross-project-infra-requests.md — how to request infra changes from non-p24-infra sessions
  • infra-src/meta-dispatcher/src/index.ts — Worker source
  • scripts/spawn-worker.sh — worker spawn logic and exit code reference