Five resources shipped with no documentation and one documented lie: docs/inference-tasks.md still told people `mcpctl get tasks` lists the inference queue. Work items own that name now. Adds docs/epics.md (the Epic -> EpicTask -> Task hierarchy, the state machine, risk-scaled review gates, the accept-merges gate, and reconciliation), docs/gitproviders.md (forge registration, discovery/import, merge-on-accept, post-merge reopen) and docs/agent-authority.md (the three run modes, and plainly which two are not implemented). Two things found while writing them, both fixed rather than filed: - `webhookSecretKey` was read by the webhook route but writable from NOWHERE -- absent from the create schema, update schema, service, CLI and apply spec. Since the endpoint fails closed on a null key, post-merge CI failures could never reopen their task; the board would keep claiming `merged` for work that broke the build. Adds --webhook-secret-key, the schema fields and the apply key, with tests covering persist / omit / explicit-null / reject-empty. Omission deliberately does not clear the column, so an unrelated edit cannot silently disarm the webhook. - src/cli/src/commands/create.ts contained a literal NUL byte inside a string literal (a binary-file check written with the raw character rather than an escape). Identical behaviour, but it made the whole file read as binary, so `grep -I` skipped it entirely and 1120 lines of CLI were invisible to every text search. Replaced with the escape. The Epic write path (criteria/status/tradeoffs) is API-only -- documented as such rather than papered over, and filed as task 180. Full suite 2612 green; lint at baseline 794/77. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
8.9 KiB
Inference Tasks (v5)
A durable inference task queue. Every inference call lands as a row in the database; mcpd's previous in-memory request map is gone. mcpd restart no longer drops in-flight work, worker disconnects no longer fail callers, and pools that have no live workers can still accept work that drains when one shows up.
The queue is the same persistence layer underneath two API surfaces:
- Sync (existing):
POST /api/v1/llms/<name>/inferandPOST /api/v1/agents/<name>/chat. Caller's HTTP request stays open until the task finishes, exactly like pre-v5. Internally it's now a DB row with a 10-min wait. Used bymcpctl chat/mcpctl chat-llm. - Async (new — this doc):
POST /api/v1/inference-tasks. Caller gets a task id immediately and polls / streams / cancels separately. Useful for fire-and-forget scripts, queued work for offline pools, and operator visibility into in-flight jobs.
Lifecycle
┌──────► claimed ──first chunk──► running ──┐
pending ──┐ │ │ │
├──┘ on dispatch │ on revert (worker disconnect)│
│ └─────────► pending │
▼ ▼
cancelled ◄───────────── DELETE ──────────────► completed | error
| Status | Meaning |
|---|---|
pending |
In queue, no worker has it yet (or claim was reverted) |
claimed |
A worker has it; dispatch frame went down the SSE channel |
running |
First chunk came back; streaming is in flight |
completed |
Worker POSTed terminal result |
error |
Permanent failure (auth, bad request, queue timeout) |
cancelled |
Caller cancelled via DELETE |
A worker disconnecting mid-task reverts claimed/running rows back
to pending (the row's claimedBy is cleared); a sibling worker on
the same pool drains it. Tasks survive mcpd restart — the row is the
source of truth, so a fresh mcpd reads the queue and either matches a
worker or waits.
Async API
POST /api/v1/inference-tasks → 201 + { id, status, poolName, llmName, streaming, createdAt }
GET /api/v1/inference-tasks?... → list (owner-scoped by default; ?status, ?poolName, ?agentId, ?limit)
GET /api/v1/inference-tasks/<id> → poll one row
GET /api/v1/inference-tasks/<id>/stream → SSE: `event: chunk` + `event: terminal`
DELETE /api/v1/inference-tasks/<id> → cancel pending/claimed/running; no-op on terminal
Foreign-owner ids return 404 (not 403) on every endpoint — keeps id-enumeration from leaking task existence across users.
Enqueue
curl -X POST https://mcpd/api/v1/inference-tasks \
-H 'Authorization: Bearer ...' \
-H 'Content-Type: application/json' \
-d '{
"llmName": "vllm-local-qwen3",
"request": {
"messages": [{"role":"user","content":"summarize the changelog"}],
"max_tokens": 500
},
"streaming": false
}'
# 201 Created
# {
# "id": "cmoip...",
# "status": "pending",
# "poolName": "vllm-local-qwen3",
# "llmName": "vllm-local-qwen3",
# "streaming": false,
# "createdAt": "2026-04-28T..."
# }
Rejects with 400 for public Llms — the existing
POST /api/v1/llms/<name>/infer is the right tool there. Rejects
with 404 for missing Llm names.
Poll
curl https://mcpd/api/v1/inference-tasks/cmoip... \
-H 'Authorization: Bearer ...'
# 200
# {
# "id": "cmoip...",
# "status": "completed",
# "responseBody": { "choices": [{"message":{"role":"assistant","content":"..."}}] },
# "claimedBy": "<providerSessionId>",
# "createdAt": "...", "claimedAt": "...", "completedAt": "..."
# }
Stream live updates
curl -N https://mcpd/api/v1/inference-tasks/cmoip.../stream \
-H 'Authorization: Bearer ...'
# event: chunk
# data: {"data":"hello "}
#
# event: chunk
# data: {"data":"world","done":true}
#
# event: terminal
# data: { ...full row... }
Already-terminal tasks emit one terminal event and close. Caller
disconnect cleans up server-side.
Cancel
curl -X DELETE https://mcpd/api/v1/inference-tasks/cmoip... \
-H 'Authorization: Bearer ...'
Returns 200 + the cancelled row. Cancelling a completed /
error / already-cancelled task is a no-op (returns the current
row). The worker that picked the task up may still execute the
underlying inference call (no remote-cancel protocol yet); the
result is just discarded server-side.
CLI
Renamed. This queue used to answer to
mcpctl get tasks. Agentic-teams work items own that name now, so the queue keeps only its explicit spellings:inference-tasks,inference-task,itasks,itask. Baretasks/tasklist work items — see epics.md.The same split applies to RBAC: this queue is authorized by
view:inference-tasks, work items byview:tasks. Bindings written before the split are rewritten once at startup, so an existingview:tasksgrant keeps working here and does not silently become a grant over work items.
mcpctl get inference-tasks # table view: ID, STATUS, POOL, LLM, MODEL, STREAM, AGE, WORKER
mcpctl get itasks # short alias
mcpctl get inference-task <id> # describe one
mcpctl get inference-task <id> -o yaml # full row, yaml format
# Enqueue async via chat-llm:
mcpctl chat-llm vllm-local-qwen3 --async -m "summarize the changelog"
# stdout: cmoip... (just the id, pipeable)
# stderr: (task cmoip... enqueued for pool 'vllm-local-qwen3', status=pending)
# Wire to other commands:
ID=$(mcpctl chat-llm vllm-local-qwen3 --async -m "long task")
sleep 30
mcpctl get task $ID
RBAC
The inference-tasks resource. Default action mapping:
| Method | Action |
|---|---|
GET |
view |
POST |
create |
DELETE |
delete |
Aliases: inference-task and inference-tasks both normalize to
inference-tasks, so mcpctl create rbac-binding --resource inference-task --role view ... works either way.
Renamed. v5 shipped this queue under the RBAC resource
tasks, on the reasoning that inference was the only kind of task we had. Agentic teams introduces work-item tasks, which taketasks(and thetaskalias) over. Bindings written before the split literally stored"tasks", so mcpd rewrites them toinference-tasksonce at startup — seesrc/mcpd/src/bootstrap/rbac-tasks-backfill.ts. Nothing to do by hand; the rewrite logs what it changed.view:tasksnow grants work items, not inference.
Owner scoping is enforced at the route layer ON TOP of the RBAC hook:
- A caller with only the implicit role sees and can cancel their own tasks.
view:inference-tasksresource-wide is required to see / list / cancel another user's tasks.
GC
mcpd runs a 5-min ticker that:
- Flips pending tasks older than the queue timeout (default 1 h)
to
errorwith a clear message ("Task expired in pending state after ...s"). Any caller still polling sees a clean failure. - Deletes terminal tasks (
completed/error/cancelled) past the retention window (default 7 d). Indexes back theWHERE status IN (...) AND completedAt < cutoffpredicate so the sweep is cheap even with millions of rows.
Both timeouts are constants in main.ts; making them per-deployment
configurable is a v6 concern.
Limitations / Out-of-scope for v5
- Streaming chunks are not persisted. The DB stores only the
final assembled body (or null for streaming-only consumers). A
caller who connects to
/streamAFTER the task completed gets oneterminalevent with no chunk replay. Persisting deltas would be a per-chunk INSERT — too expensive at SSE granularity. - Public-Llm queueing is not exposed. Public Llms are HTTP
adapters with no SSE worker, so the queue concept doesn't apply.
Direct
/llms/<name>/inferagainst a public Llm stays the right call. - Multi-instance mcpd: the in-process
EventEmitterthat wakes blocked HTTP handlers fires only on the mcpd that holds the row's blocked handler. With multiple mcpd replicas behind a load balancer the wakeup wouldn't cross instances. v6 swap: pgLISTEN/NOTIFY, no schema change. - Worker capacity is one task per session at a time (today). A per-session capacity hint + scheduler-level concurrency control is a v6+ concern.
- Remote cancel protocol: cancelling a
runningtask marks the row as cancelled but the worker keeps executing. The result POST is discarded server-side. Real cancellation (an SSE message that tells the worker to abort its in-flight call) is a v6 concern.
See also
- virtual-llms.md — the Llm-side primitives (kind, lifecycle, pools) that the queue routes around.
- agents.md — how the chat path uses the same queue underneath.