⚠️ INTERNAL AS OF 2026-04-26 (#428):BacklogServiceis no longer a public API. It is the persistent overflow store insideCapacityManager, reached viaacquire(..., overflow_policy="queue_persistent", overflow_payload=PersistentTaskPayload(...)). The SQL columns (schedule_executions.queued_at,backlog_metadata, status'queued'), drain-on-release behaviour, 24h expiry, and partial index are unchanged. New callers should reach forcapacity-management.mdinstead of importingBacklogServicedirectly.Requirement: BACKLOG-001 — Persistent task backlog for over-capacity requests Status: Implemented Created: 2026-04-13 Updated: 2026-04-26 (#428: BacklogService internalized behind CapacityManager) GitHub Issue: #260, extended by #498 (sync long-poll), internalized by #428 Priority: P1 Related: capacity-management.md, parallel-capacity.md, task-execution-service.md, parallel-headless-execution.md
When a POST /api/agents/{name}/task request arrives and all of the agent's
parallel execution slots (CAPACITY-001) are occupied, the request is spilled
into a durable SQLite-backed FIFO backlog instead of returning HTTP 429. When
a slot frees, the oldest queued item for that agent is drained automatically
via a SlotService release callback. True HTTP 429 is only returned when the
backlog itself is also at its configured depth.
Both modes share the same backlog (issue #498):
- Async (
async_mode=true): Caller gets HTTP 202 withexecution_idimmediately and polls for the result. The backlog drains in the background. - Sync (
async_mode=false): Caller's HTTP connection is held open and long-polls until the queued execution reaches a terminal status, then the result is returned inline on the same connection. Total connection hold is bounded by2 × effective_timeout(queue wait + execution).
Queued rows survive backend restarts. A 60-second maintenance task in the backend process expires rows older than 24 hours and drains orphans left behind when a release callback couldn't fire (e.g. process crash).
Before BACKLOG-001 (#260), async_mode=true requests at capacity were dropped
on the floor with a 429 response. Bursty MCP fan-out scenarios (agents
orchestrating other agents via chat_with_agent(parallel=true, async=true)) routinely hit
the 3-slot default cap and lost work. Clients had to implement their own
retry-with-backoff logic, and there was no first-class backpressure signal.
Before #498, sync calls (async_mode=false) bypassed the backlog entirely —
hitting capacity returned a terminal 429 even though the backlog could have
absorbed the overflow. Observed in production: ~40% terminal-failure rate
from one MCP fan-out caller (214 capacity rejections / 24h, 0 enqueues from
the same caller across 541 dispatches). #498 closed that gap by spilling
sync calls to the same backlog and long-polling on the open HTTP connection.
The backlog gives Trinity:
- Spill-over by default for both sync and async — no lost requests below the backlog depth cap
- Restart durability — queued items survive backend restarts via SQLite
- Bounded resource envelope — per-agent
max_backlog_depth(default 50, hard cap 200) + 24h stale expiry - Transparent to pollers — existing
GET /api/agents/{name}/executions/{id}returnsstatus=queuedwhile the row waits to drain - Transparent to sync callers — same response shape as immediate-slot path, just with extra wait time
POST /api/agents/{name}/task
async_mode=true
│
┌────────────▼─────────────┐
│ routers/chat.py │
│ (submit_parallel_task) │
└────────────┬─────────────┘
│
create_task_execution (status=running)
│
slot_service.acquire_slot()
│
┌──────────────────┴───────────────────┐
│ │
slot acquired slot full
│ │
▼ ▼
_run_async_task_with_persistence() backlog.enqueue()
│ │
▼ ┌───────────┴───────────┐
finally: release_slot │ │
│ depth < cap depth >= cap
▼ │ │
release callbacks fire ▼ ▼
│ HTTP 202 queued HTTP 429
▼
backlog.on_slot_released(name)
│
▼
backlog.drain_next(name)
│
┌──────┴──────┐
│ │
sentinel no queued
acquire items → exit
│
▼
claim_next_queued ◄── atomic UPDATE ... RETURNING
│
▼
swap sentinel → real execution_id slot
│
▼
reconstruct ParallelTaskRequest from backlog_metadata
│
▼
asyncio.create_task(
_run_async_task_with_persistence(
identity from backlog_metadata,
# is_self_task / self_task_activity_id / inject_result
# threaded through so SELF-EXEC-001 (#264) survives queueing
)
)
Parallel path (safety net):
backend startup
│
▼
main.py lifespan
│
├── register_on_release(backlog.on_slot_released)
│
└── asyncio.create_task(_backlog_maintenance_loop)
│
│ every 60s:
│ - expire_stale(max_age_hours=24)
│ - drain_orphans_all() → picks up rows that missed
│ their callback after a restart or crash
▼
POST /api/agents/{name}/task
async_mode=false
│
router pre-acquires slot
│
┌──────────────────┴───────────────────┐
│ │
slot acquired slot full
│ │
▼ ▼
execute_task(slot_already_held=True) backlog.enqueue()
→ return inline result │
┌───────────┴───────────┐
│ │
depth < cap depth >= cap
│ │
▼ ▼
wait_for_sync_terminal HTTP 429
(event + 5s DB-poll fallback)
│
┌─────────────┼─────────────┐
│ │ │
signaled by poll detects timeout
drain finally terminal flip (2 × effective_timeout)
│ │ │
▼ ▼ ▼
return inline result reconstruct HTTP 504
(full TaskExecResult) from DB row (execution may
→ return still complete
in background)
The drain machinery is shared with the async path — _run_async_task_with_persistence
runs the queued task identically, then signals _sync_waiters from its finally
block. Sync waiters wake on the same event the async chat-session-persistence
broadcast fires on.
Migration backlog_support (append-only, reuses existing table):
ALTER TABLE schedule_executions ADD COLUMN queued_at TEXT;
ALTER TABLE schedule_executions ADD COLUMN backlog_metadata TEXT;
ALTER TABLE agent_ownership ADD COLUMN max_backlog_depth INTEGER DEFAULT 50;
CREATE INDEX idx_executions_queued
ON schedule_executions(agent_name, queued_at)
WHERE status = 'queued';The partial index is the key to cheap FIFO claim — only queued rows are
indexed. claim_next_queued reads at most one row via the subquery,
making the operation O(log n) on backlog size.
Pull claim order (#2842, #2843). The backend drain above claims oldest
first. A pull pilot's worker claims through the same method with two extra
rules: rows whose triggered_by is in pull_pilot.INTERACTIVE_TRIGGERS go
first (strict precedence, no anti-starvation rule), and a row whose
conversation_key already has a running row is skipped. The partial unique
index below stops two workers running one conversation at once:
CREATE UNIQUE INDEX idx_executions_one_running_turn
ON schedule_executions(agent_name, conversation_key)
WHERE status = 'running' AND conversation_key IS NOT NULL;backlog_service.enqueue stamps conversation_key on pilot agents only: the
explicit key execute_task was given (session:<key>, public:<id>,
channel:<id>, room:<id>, paid:<id>, #3114; session:chat:<id> for
POST /chat, #3127), else chat_session_id, else
resume_session_id. The ordering expression cannot use
idx_executions_queued; per-agent queues are small.
Interactive turns on a pilot (#3114, #3127). pull_pilot.pull_owns_dispatch
covers every interactive trigger plus validation. POST /chat skips the
admission acquire on a pilot and runs chat_execution_service.run_pulled_chat_turn
through the resumable-turn engine, one Claude conversation per chat session. Their sync callers go through
task_execution_service.dispatch_and_await_terminal: phase 1 polls the row
while it is queued, for at most one agent execution timeout, and on expiry
cancels it (cancel_queued_execution) and returns FAILED/CAPACITY; a cancel
that loses to a claim falls through to phase 2, the terminal wait (agent
timeout + 120s from the claim). The pull sink signals the in-process waiter
when its CAS write wins.
Post-turn delivery on pull (#2329). A queued /task row's delivery
settings (save_to_session, chat_session_id, user_message,
create_new_session, inject_result, collaboration / self-task activity ids)
are applied by both paths through one helper,
chat_execution_service.run_post_turn_delivery, with the request rebuilt by
backlog_service.request_from_metadata. The push drain calls it from
run_async_task; the pull sink spawns it on its CAS-won branch when the
metadata asks for delivery, then signals the waiter with the chat session id
(signalled at once when there is nothing to deliver). A token-gated SUCCESS
write refuses a row already SUCCESS or SKIPPED, so a worker's retried result
POST cannot run delivery twice. The live-stream proxies (routers/chat.py,
routers/public.py, client_portal/router.py) hold the SSE connection with
: queued comments while the row is queued (sync_waiter.wait_while_queued),
then retry an agent 404 briefly while the claimed row is running.
Captured at enqueue time, replayed on drain. No credential values, only identity and request parameters:
{
"message": "...",
"model": "sonnet",
"allowed_tools": ["Read", "Bash"],
"system_prompt": "...",
"timeout_seconds": 900,
"max_turns": null,
"save_to_session": false,
"user_message": "...",
"create_new_session": false,
"chat_session_id": null,
"resume_session_id": null,
"inject_result": false,
"user_id": 42,
"user_email": "user@example.com",
"subscription_id": "sub-xyz",
"x_source_agent": null,
"x_mcp_key_id": "mcp-key-abc",
"x_mcp_key_name": "my-key",
"triggered_by": "manual",
"collaboration_activity_id": null,
"is_self_task": false,
"self_task_activity_id": null,
"persist_session": false,
"schedule_context": null,
"attempt": null,
"images": null
}persist_session, schedule_context, attempt and images (#3114) are read
by the pull claim only: the envelope hands persist_session and images
(base64 vision input) to the worker, and the claim-time prompt uses
schedule_context and attempt. Canary G-04 skips images when scanning for
credentials.
queued ──(drain)──▶ running ──(success)──▶ success
│ │
│ └──(agent error)──▶ failed
│
├──(user terminate)──▶ cancelled
│
├──(agent delete)──▶ cancelled (reason=agent_deleted)
│
└──(stale >24h)──▶ failed (reason=backlog expired)
Enqueue (async path, replaces old 429 block):
if not slot_acquired:
backlog = BacklogService() # live: CapacityManager owns its instance (capacity_manager.py)
enqueued = await backlog.enqueue(
agent_name=name,
execution_id=execution_id,
request=request,
effective_timeout=effective_timeout,
user_id=current_user.id,
user_email=current_user.email or current_user.username,
subscription_id=_task_subscription_id,
x_source_agent=x_source_agent,
x_mcp_key_id=x_mcp_key_id,
x_mcp_key_name=x_mcp_key_name,
triggered_by=triggered_by,
collaboration_activity_id=collaboration_activity_id,
is_self_task=is_self_task,
self_task_activity_id=self_task_activity_id,
)
if enqueued:
return {"status": "queued", "execution_id": execution_id, ...}
# else: backlog full → real 429Terminate (short-circuit for queued rows):
_exec_row = db.get_execution(task_execution_id)
if _exec_row and _exec_row.status == TaskExecutionStatus.QUEUED:
cancelled = db.cancel_queued_execution(
task_execution_id, reason="Cancelled by user while queued"
)
if cancelled:
return {"status": "cancelled_while_queued", ...}| Method | Purpose |
|---|---|
enqueue(...) |
Check depth, persist backlog_metadata, flip row to QUEUED. Returns False if at cap. |
drain_next(agent_name) |
Acquire sentinel slot → atomically claim row → swap to real execution_id slot → reconstruct ParallelTaskRequest via request_from_metadata (including inject_result) → spawn _run_async_task_with_persistence (#496: was _execute_task_background, deleted by #95). |
on_slot_released(agent_name) |
Callback registered with SlotService. Tries drain_next once per release. |
expire_stale(max_age_hours=24) |
Maintenance: mark old queued rows as FAILED. |
drain_orphans_all() |
Maintenance: iterate agents with queued work, drain one item each. |
cancel_all_backlog(agent_name, reason) |
Called on agent delete. |
Design invariants:
- Slot acquired before row is claimed — prevents RUNNING-without-slot orphans.
- Single-statement
UPDATE ... WHERE id=(SELECT ... LIMIT 1) RETURNING— atomic claim. _run_async_task_with_persistenceis late-imported inside_spawn_drainto avoid arouters.chat↔services.backlog_servicecycle. #496 regression guard:tests/unit/test_backlog.py::TestLazyImportTargetparsesrouters/chat.pyvia AST and asserts the import target exists; a paired test asserts the lazy-import string inservices/backlog_service.pymatches the validated allow-list. Catches both directions of drift without booting the backend (the symptom that allowed #496 to ship: aSimpleNamespacemock injected the missing symbol back in, masking the productionImportError).- Drain spawn failures emit a stable log token
backlog_drain_spawn_failedso log-based detection (Vector / dashboards) can catch import drift or similar spawn-time regressions at fleet scale rather than per-row. - Self-task fields (
is_self_task,self_task_activity_id) are captured at enqueue and threaded through drain so SELF-EXEC-001 (#264)inject_resultsemantics survive backlog overflow. - Identity replayed from
backlog_metadata; no re-auth at drain time.
Added release callback hook:
def register_on_release(self, callback: Callable[[str], Awaitable[None]]) -> None: ...
async def release_slot(self, agent_name, execution_id):
... # existing Redis ZSET + metadata cleanup
for cb in self._on_release_callbacks:
asyncio.create_task(self._safe_invoke(cb, agent_name))Callback invocation is isolated (_safe_invoke catches exceptions per
callback), fire-and-forget via create_task, and gracefully handles "no
running loop" (returns silently and lets the 60s maintenance task drain the
work on the next tick).
from services.slot_service import get_slot_service
from services.backlog_service import BacklogService
_backlog = BacklogService() # live: CapacityManager constructs and wires it
get_slot_service().register_on_release(_backlog.on_slot_released)
async def _backlog_maintenance_loop():
await asyncio.sleep(15) # small delay to not slow boot
while True:
try:
await _backlog.expire_stale(max_age_hours=24)
await _backlog.drain_orphans_all()
except Exception as exc:
logger.warning(f"[Backlog] maintenance tick failed: {exc}")
await asyncio.sleep(60)
asyncio.create_task(_backlog_maintenance_loop())After stopping the container and deleting schedules, the delete path calls
backlog.cancel_all_backlog(agent_name, reason="agent_deleted") so orphan
queued rows don't linger in the database.
In-process registry that lets sync HTTP callers long-poll a queued execution on the same connection. Two primitives:
signal_sync_waiter(execution_id, result, chat_session_id)— called from_run_async_task_with_persistencefinally block. Looks up the registered future and completes it with the richTaskExecutionResult. No-op when no waiter is registered (the normal async fire-and-forget path) or when the caller already cancelled.wait_for_sync_terminal(execution_id, timeout)— registers a future, starts a 5s DB-poll fallback task, thenasyncio.wait(FIRST_COMPLETED)s on either signal. Returns the rich payload on signal, returnsNoneon poll-fallback hit (caller reconstructs from DB row), raisesTimeoutErrorif neither fires.
The registry is in-process — multi-worker deployments would need pubsub to fan signals across processes; that's not the current backend shape (single worker).
The poll fallback covers terminal-flip sites that don't go through the drain:
corrupt-metadata in _spawn_drain, expire_stale_queued, cancel_all_backlog,
and cleanup_service recovery. Latency cost is bounded at one poll interval
(default 5s).
Per-agent backlog depth is stored in agent_ownership.max_backlog_depth:
db.get_max_backlog_depth(agent_name) # default 50
db.set_max_backlog_depth(agent_name, n) # validates 1-200No REST endpoint for this setting yet — the default (50) is sensible for most workloads and the issue explicitly deferred UI configuration. Custom values can be set directly via the internal DB method or a future Settings page if demand emerges.
- Every enqueue and drain logs agent_name, execution_id, and current
depth at
INFOlevel. - Drain failures (corrupt metadata, lost slot race, background spawn
failure) log at
ERRORwith the execution_id and cause. - Queued rows are visible in existing tools:
GET /api/agents/{name}/executions/{id}returnsstatus=queuedGET /api/agents/{name}/executionslists queued rows alongside other statuses- Frontend:
ExecutionDetail.vueandTasksPanel.vuerender a dedicated amber badge forstatus=queued.
- MCP
get_execution_resultpolling transparently surfaces thequeuedstatus without tool changes.
| Failure | Behaviour |
|---|---|
Corrupt backlog_metadata JSON |
Row marked FAILED with reason, slot released, drain continues with next item. |
| Slot acquisition fails after claim | Row released back to QUEUED via release_claim_to_queued; next callback retries. |
| Backend crash mid-drain | Row stays RUNNING with no Claude session ID — existing cleanup service recovers it within the timeout window. New queued rows are drained by the 60s maintenance loop on restart. |
| Agent container gone when drain fires | _run_async_task_with_persistence surfaces an HTTP error via TaskExecutionService, row marked FAILED. |
| Concurrent drains on same agent | Atomic UPDATE guarantees only one callback wins the row; others get None and release their sentinel slots. |
| Cancel-while-queued | Terminate endpoint short-circuits, row moves to CANCELLED. The claim SQL's WHERE status='queued' filter naturally skips cancelled rows, so the drain path is race-safe. |
Unit tests: tests/unit/test_backlog.py (33 tests, no backend/Docker required).
Coverage:
- TaskExecutionStatus.QUEUED enum value
- Migration (columns + partial index)
ResourcesMixin.get/set_max_backlog_depth(including range validation 1-200)- ScheduleOperations backlog queries: transition to queued, atomic FIFO claim, agent isolation, release-claim-back, single cancel, bulk cancel for agent, stale expiry (normal + tiny-threshold), list agents with queued
BacklogService.enqueue: under cap succeeds, at cap rejected, self-task fields captured for SELF-EXEC-001 round-trip (#496)BacklogService.drain_next: empty noop, failed-claim releases slot, corrupt metadata marks failed, slot-acquire-failure noop, happy path spawns background task with reconstructed request, self-task fields threaded through to_run_async_task_with_persistence(#496)SlotService.register_on_release+release_slotfan-out, per-callback exception isolation- #496 regression guards: AST-based check that
_run_async_task_with_persistenceis defined inrouters/chat.py, and inverse check that the lazy-import string inservices/backlog_service.pymatches the validated allow-list
- Backend running at http://localhost:8000
- Redis running (SlotService)
- Test agent created with known
max_parallel_tasks
Action:
- Set
max_parallel_tasks=1on a test agent (viaPUT /api/agents/{name}/capacity). - Send 3 consecutive
POST /api/agents/{name}/task?async_mode=truerequests. Expected: - Request 1:
status=accepted(running). - Request 2 & 3:
status=queuedwith HTTP 200 body. - Poll
GET /api/agents/{name}/executions/{id}: status transitionsqueued → running → successin FIFO order.
Action:
- Temporarily call
db.set_max_backlog_depth(name, 1)via a Python shell. - With
max_parallel_tasks=1, send 3 async tasks rapidly. Expected: request 3 receivesHTTP 429with "backlog is full" detail.
Action:
- Queue 2 tasks (as in scenario 1, second task in queued state).
docker compose restart backend. Expected: within ~75 seconds (15s startup delay + 60s tick), the second task drains and completes. No manual intervention.
Action:
- Queue a task via scenario 1.
- Call
POST /api/agents/{name}/executions/{id}/terminate. Expected: response{"status":"cancelled_while_queued"}; DB row in CANCELLED state; task never runs.
Action:
- Queue 3 tasks for an agent.
DELETE /api/agents/{name}. Expected: all queued rows transition to CANCELLED witherror="agent_deleted"; no orphan rows remain.
- Queued task for deleted agent — should fail cleanly during drain
- Multiple agents with queued work — fair per-agent drain (each agent gets one drain per maintenance tick)
- Backlog entries older than 24h — expired to FAILED on next tick
Last Tested: 2026-04-25 (unit tests; manual scenarios pending) Status: ✅ 33 unit tests passing; integration testing recommended post-merge
| Date | Change |
|---|---|
| 2026-04-13 | Initial implementation (PR #316). |
| 2026-04-25 | #496 fix: lazy-import target updated from _execute_task_background (deleted by #95) to _run_async_task_with_persistence. Drain had been silently failing with ImportError since #95 because the existing happy-path test injected a SimpleNamespace stub into sys.modules["routers.chat"] with whatever attribute name it expected. AST-based regression guards added. Self-task fields (is_self_task, self_task_activity_id, inject_result) now captured at enqueue and rehydrated on drain so SELF-EXEC-001 (#264) survives backlog overflow. Drain spawn failures emit stable log token backlog_drain_spawn_failed. Stale task_activity_id field dropped from metadata (the post-#95 service tracks CHAT_START itself). |
All nine acceptance criteria from issue #260 are met:
- Async task at capacity returns HTTP 202 with
status: "queued"instead of 429 - Queued tasks auto-execute when slots free up (FIFO order)
-
GET /api/agents/{name}/executions/{id}showsqueued → running → successlifecycle - Backlog persists across backend restarts (60s maintenance tick drains orphans)
- Configurable
max_backlog_depthper agent (default 50) - True 429 only when backlog is also full
- Queued items can be cancelled
- Stale queued items expire after 24h
- MCP
get_execution_resultworks for queued/backlog items without changes
- MCP tool signatures —
chat_with_agent(parallel=true, async=true)automatically gains backlog behaviour. - Frontend routing — the Tasks tab and execution detail views render
queued rows via the existing list endpoints; only the status badge
mapping needed to learn about
queued. - Sync mode —
async_mode=falseremains blocking and is unaffected. - Fan-out —
FANOUT-001usesTaskExecutionServicedirectly in parallel mode and bypasses the backlog entirely. - Scheduler — scheduled executions are created in RUNNING state via the scheduler container and never touch the backlog.
| File | Change |
|---|---|
src/backend/models.py |
+1 line: TaskExecutionStatus.QUEUED = "queued" |
src/backend/db_models.py |
+3 lines: queued_at, backlog_metadata on ScheduleExecution |
src/backend/db/migrations.py |
+37 lines: _migrate_backlog_support + registry entry |
src/backend/db/schedules.py |
+231 lines: 10 backlog query methods + row mapping |
src/backend/db/agent_settings/resources.py |
+45 lines: get/set_max_backlog_depth (1-200 validation) |
src/backend/database.py |
+38 lines: delegate methods |
src/backend/services/slot_service.py |
+49 lines: release callback hook + safe invocation |
src/backend/services/backlog_service.py |
NEW 320 lines — core service |
src/backend/routers/chat.py |
+69 lines: enqueue-on-capacity, terminate-while-queued |
src/backend/routers/agents.py |
+10 lines: delete cascade |
src/backend/main.py |
+27 lines: callback registration + 60s maintenance loop |
| File | Change |
|---|---|
src/frontend/src/views/ExecutionDetail.vue |
+1 line: amber queued badge |
src/frontend/src/components/TasksPanel.vue |
+2 lines: amber queued badge (text + dot) |
| File | Change |
|---|---|
tests/unit/test_backlog.py |
NEW 29 tests — enum, migration, query methods, service |
tests/unit/conftest.py |
+42 lines: backend import bootstrap (evicts shadow utils) |
tests/registry.json |
+7 lines: register new test file |