Handbook
Managed compose services
Purpose: register and operate long-lived Docker Compose stacks from Fleet using persisted service records under $FLEET_DATA_DIR/etc/services/.
Updated
| Audience | Granite operators and integrators hosting Forge LLM or Market Studio |
| Prerequisites | Fleet running with bearer token; Docker Compose v2 on the host |
| Success | POST /v1/container-services registers a stack; …/start runs docker compose up -d; status appears on GET /v1/container-services |
Container types
Managed compose types inherit the service category capability api_manage_services: true.
type_id |
Compose root (typical) | Notes |
|---|---|---|
forge_llm |
deploy/forge-llm-control-plane or external forge-llm checkout |
Gateway control plane; legacy /v1/services/forge-llm/* endpoints |
forge_market_studio |
deploy/forge-market-studio |
Postgres + market-app; rollout via admin API |
Add types in etc/containers/types.json or GET /v1/container-types. Only types with api_manage_services accept POST /v1/container-services and per-service start/stop.
Service record shape
Each etc/services/<id>.json file:
{
"version": 1,
"id": "market-studio",
"type_id": "forge_market_studio",
"label": "Granite Market Studio",
"compose_root": "/path/to/deploy/forge-market-studio",
"compose_files": ["compose.granite.yaml"]
}
compose_files lists overlay filenames (not compose.yaml). Allowed overlays are validated in fleet_server/managed_compose_service.py (includes compose.granite.yaml, compose.market.yaml, and forge-llm overlays).
HTTP API
| Method | Path | Behavior |
|---|---|---|
| GET | /v1/container-services |
All service records; compose status when type is API-manageable |
| GET | /v1/container-services/{id} |
One record + status |
| POST | /v1/container-services |
Register or replace (body: type_id, compose_root, compose_files, label) |
| POST | /v1/container-services/{id}/start |
docker compose up -d |
| POST | /v1/container-services/{id}/stop |
docker compose down |
| DELETE | /v1/container-services/{id} |
Remove record (409 if stack still running) |
Legacy forge_llm-only:
| Method | Path |
|---|---|
| GET | /v1/services/forge-llm |
| POST | /v1/services/forge-llm/start |
| POST | /v1/services/forge-llm/stop |
Rollout helpers
| Stack | Admin route | Host script |
|---|---|---|
| Forge LLM gateway | POST /v1/admin/forge-llm-control-plane-rollout |
scripts/rollout-forge-llm-control-plane.sh |
| Market Studio | POST /v1/admin/forge-market-studio-rollout (409 when same env busy) |
scripts/rollout-forge-market-studio.sh |
| Pattern cell rollups backfill | POST /v1/admin/forge-market-pattern-rollups-backfill (409 when same env busy) |
scripts/backfill-forge-market-pattern-rollups.sh |
Infra flows via POST /v1/flows/submit (e.g. market-studio-rollout) use the same per-service_id slot; parallel rollouts across different envs (prod vs dev) are allowed.
Body {"sync": true} runs synchronously; default schedules background rollout and returns log_path.
Market Studio rollout does not use SSH — it builds from a local forge-market checkout, starts compose, registers the managed service, and curls /health.
Use rollout, not start, after a gateway code change
The gateway image bakes apps/forge-gateway in at build time and compose.yaml mounts no source volume. POST /v1/container-services/<id>/start runs docker compose up -d without --build, so it recreates the container from the existing forge-gateway:control-plane image and silently keeps the old code. After changing gateway source, use POST /v1/admin/forge-llm-control-plane-rollout, which runs compose build forge-gateway before up -d.
LLM request queue (bounce vs hold)
control_plane/queue_manager.py serialises inference behind one concurrent slot. DEFAULT_WAIT_BY_MODE in control_plane/modes.py decides how a caller waits when that slot is taken:
| Wait mode | Modes | Behaviour when the slot is busy |
|---|---|---|
bounce |
interactive, struct_json, reason_short, embed |
Immediate 503 with reason: busy and Retry-After; the caller does not join the queue |
hold |
task_code, long_ctx, codegen_loop |
Joins the queue and waits up to FORGE_LLM_HOLD_TIMEOUT_SEC (default 300s) |
Queue depth is capped by FORGE_LLM_MAX_QUEUE_DEPTH (default 64). Only hold callers occupy a slot — a bounced caller stops waiting the moment it receives its 503, so enqueuing one would leak its slot forever.
Diagnosing a wedged queue
A saturated queue and a wedged queue look identical from the client (503 / queue_busy / code: queue_full). Tell them apart by whether queue_position moves:
- Real load —
queue_positionfluctuates between calls. - Wedged —
queue_positionis pinned at exactlyFORGE_LLM_MAX_QUEUE_DEPTHacross repeated calls and never drains, because every slot is held by a ticket whose waiter is gone.
Confirm against the gateway rather than the client: GET /v1/active reports queue_depth, and 0 there while clients still see queue_full means the queue is fine and the problem is elsewhere. Recover with POST /v1/admin/forge-llm-control-plane-rollout — queue state is in-process, so a restart clears it.
Two accounting rules keep the queue from wedging, both covered by apps/forge-gateway/tests/test_queue_manager.py:
- A
bouncecaller is never enqueued. - When
release()hands the active slot to a ticket whose waiter has already timed out, the timed-out caller returns that slot instead of stranding it.
Tickets older than twice the hold timeout are reaped on the next acquire, so a missed path degrades to a self-healing delay rather than an outage that needs a restart.
Future migration jobs may use template build_market_image (see forge-migrator recipe) to build on Granite without manual SSH.
Example — register Market Studio manually
export FLEET=http://127.0.0.1:18766
export TOKEN=…
curl -fsS -X POST "$FLEET/v1/container-services" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "market-studio",
"type_id": "forge_market_studio",
"compose_root": "/home/you/forge-fleet/deploy/forge-market-studio",
"compose_files": ["compose.granite.yaml"],
"label": "Market Studio"
}'
curl -fsS -X POST "$FLEET/v1/container-services/market-studio/start" \
-H "Authorization: Bearer $TOKEN"
Implementation map
| Module | Role |
|---|---|
fleet_server/managed_compose_service.py |
Generic compose ps/start/stop/status |
fleet_server/forge_llm_service.py |
LLM gateway enrichment on status |
fleet_server/container_layout.py |
Types catalog + etc/services persistence |
Before: Container templates · After: Operate architecture · Lookup: HTTP API