Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/memory/feature-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

| Date | ID | Feature | Flow |
|------|-----|---------|------|
| 2026-04-26 | #498 | Sync `/task` long-poll on backlog — sync parallel calls at capacity now spill to BACKLOG-001 (same backlog as async) and long-poll the open HTTP connection until terminal status (cap `2 × effective_timeout`); new `services/sync_waiter.py` owns the in-process registry + event/poll-fallback wait helper | [persistent-task-backlog.md](feature-flows/persistent-task-backlog.md), [parallel-headless-execution.md](feature-flows/parallel-headless-execution.md) |
| 2026-04-24 | WEBHOOK-001 (#291) | Webhook triggers — token-authenticated public URL fires schedule executions | [webhook-triggers.md](feature-flows/webhook-triggers.md) |
| 2026-04-25 | #487 | Telegram file upload Phase 2 — workspace delivery hardening: NFKC sanitizer with collision dedup, spec injection format `[File uploaded by {uploader}]: {name} ({size}) saved to {path}`, all-writes-failed channel error + abort. Same code path benefits Slack inbound. | [telegram-integration.md](feature-flows/telegram-integration.md), [slack-file-sharing.md](feature-flows/slack-file-sharing.md) |
| 2026-04-23 | #476 | SQLite lexicographic cutoff bug fix — new `iso_cutoff(hours)` helper replaces `datetime('now', ...)` in 15 sites across rate-limit / dashboard / schedules; `max_retries` default flipped `1 → 0`; `cleanup_old_rate_limit_events` wired into `CleanupService` (phase 6, hourly) | [subscription-auto-switch.md](feature-flows/subscription-auto-switch.md), [cleanup-service.md](feature-flows/cleanup-service.md), [scheduler-service.md](feature-flows/scheduler-service.md) |
Expand Down
10 changes: 6 additions & 4 deletions docs/memory/feature-flows/parallel-headless-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
> **Requirement**: 12.1 - Parallel Headless Execution
> **Status**: Implemented
> **Created**: 2025-12-22
> **Updated**: 2026-04-20 (Issue #418 inter-agent timeout fix)
> **Updated**: 2026-04-26 (Issue #498 sync long-poll on backlog)
> **Verified**: 2026-02-05

## Revision History

| Date | Changes |
|------|---------|
| 2026-04-26 | **Issue #498 - Sync long-poll on backlog**: Sync `/task` calls (`async_mode=false`) at capacity used to fail terminally with HTTP 429. They now spill to the same persistent backlog (BACKLOG-001) the async path uses and long-poll on the open HTTP connection until the queued execution reaches a terminal status. Total connection hold capped at `2 × effective_timeout` (queue wait + execution). Router (`chat.py:1007-1144`) pre-acquires the slot mirroring the async path, then on at-capacity calls `backlog.enqueue()` followed by `wait_for_sync_terminal()`. New module `services/sync_waiter.py` owns the in-process registry, `signal_sync_waiter()`, and `wait_for_sync_terminal()` (event + 5s DB-poll fallback). The drain reuses `_run_async_task_with_persistence` unchanged — it now calls `signal_sync_waiter` from its `finally` block to wake any sync waiter. See [persistent-task-backlog.md](persistent-task-backlog.md) for the full backlog flow. |
| 2026-04-20 | **Issue #418 - Inter-agent timeout ceiling fix**: Removed hardcoded 600s timeout assumption from the MCP parallel/task path so per-agent `execution_timeout_seconds` (TIMEOUT-001, default 900s, max 7200s) is honored end-to-end. `src/mcp-server/src/tools/chat.ts` — `chat_with_agent` Zod schema no longer applies `.default(600)` to `timeout_seconds`; when callers omit it, `undefined` now flows through to the backend, which resolves the target agent's configured timeout. `src/mcp-server/src/client.ts:563-565` — `client.task()` HTTP fetch ceiling changed from `(timeout_seconds \|\| 600) + 10` to `(timeout_seconds ?? 7200) + 60`, so the fetch client doesn't abort before a long-running agent-configured task finishes. Async mode still uses a fixed 30s HTTP ceiling (unchanged). |
| 2026-04-17 | **Issue #361 - Max-turns error fix**: Fixed max_turns termination being misclassified as authentication failure. Added detection for `terminal_reason="max_turns"` and `subtype="error_max_turns"` in result messages (`claude_code.py:329-336`). Max-turns errors now return HTTP 422 with clear "Task exceeded turn limit" message instead of HTTP 503 "Authentication failure". Also raised `max_turns_task` default from 20 to 50 in both `claude_code.py:52` and `guardrails-baseline.json:65`. |
| 2026-03-26 | **Line number refresh**: Updated all file/line references to match current codebase after upstream shifts (~92 lines in backend `chat.py`, model extraction in `models.py`, agent server reorganisation). |
Expand Down Expand Up @@ -221,13 +222,14 @@ As of EXEC-024, the sync and async execution paths diverge:

| Aspect | Sync (`async_mode=false`) | Async (`async_mode=true`) |
|--------|---------------------------|---------------------------|
| Execution logic | `TaskExecutionService.execute_task()` in `services/task_execution_service.py` | `_run_async_task_with_persistence()` inline in `routers/chat.py:438-650` |
| Slot management | Service acquires/releases slots internally | Router acquires slot before spawning; background task releases in `finally` |
| Execution logic | `TaskExecutionService.execute_task()` in `services/task_execution_service.py` | `_run_async_task_with_persistence()` inline in `routers/chat.py` |
| Slot management | Router pre-acquires (issue #498); service releases in `finally` (`slot_already_held=True`) | Router pre-acquires; background task releases in `finally` |
| At-capacity behavior | Spills to backlog (BACKLOG-001), long-polls on the open HTTP connection until terminal status (issue #498). Total hold ≤ `2 × effective_timeout`. | Spills to backlog, returns HTTP 202 with `execution_id`, caller polls. |
| Activity tracking | Service tracks start/completion internally | Router tracks start; background task completes activities |
| Result handling | Returns `TaskExecutionResult`; router translates to HTTP | Background task updates DB directly |
| HTTP helper | `agent_post_with_retry()` defined in service, called internally | Same function imported from service into `chat.py` |

The router (`chat.py:652-917`) still handles: container validation, execution record creation (early), collaboration tracking (WebSocket events), async mode branching, session persistence (`save_to_session`), and translating `TaskExecutionResult.status == "failed"` to HTTP error codes (429/504/503).
The router (`chat.py`) still handles: container validation, execution record creation (early), collaboration tracking (WebSocket events), async mode branching, session persistence (`save_to_session`), and translating `TaskExecutionResult.status == "failed"` to HTTP error codes (429/504/503). For sync at-capacity, the router additionally calls `backlog.enqueue()` and `wait_for_sync_terminal()` (services/sync_waiter.py); on wake it either returns the inline result (drain happy path) or reconstructs a minimal `TaskExecutionResult` from the DB row (poll-fallback for non-drain terminal flips).

## API Specifications

Expand Down
106 changes: 94 additions & 12 deletions docs/memory/feature-flows/persistent-task-backlog.md
Original file line number Diff line number Diff line change
@@ -1,41 +1,58 @@
# Feature Flow: Persistent Task Backlog

> **Requirement**: BACKLOG-001 — Persistent async task backlog for over-capacity requests
> **Requirement**: BACKLOG-001 — Persistent task backlog for over-capacity requests
> **Status**: Implemented
> **Created**: 2026-04-13
> **GitHub Issue**: [#260](https://github.com/abilityai/trinity/issues/260)
> **GitHub Issue**: [#260](https://github.com/abilityai/trinity/issues/260), extended by [#498](https://github.com/abilityai/trinity/issues/498) (sync long-poll)
> **Priority**: P1
> **Related**: [parallel-capacity.md](parallel-capacity.md), [task-execution-service.md](task-execution-service.md), [parallel-headless-execution.md](parallel-headless-execution.md)

## Overview

When `async_mode=true` arrives at `POST /api/agents/{name}/task` 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.
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 with `execution_id`
immediately 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 by `2 × 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).

## Problem Statement

Before this change, `async_mode=true` requests at capacity were dropped on
the floor with a 429 response. Bursty MCP fan-out scenarios (agents
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(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 async mode — no lost requests below the
backlog depth cap
- **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}`
returns `status=queued` while the row waits to drain
- **Transparent to sync callers** — same response shape as immediate-slot path,
just with extra wait time

## Architecture Diagram

Expand Down Expand Up @@ -114,6 +131,46 @@ Parallel path (safety net):
```

### Sync long-poll path (issue #498)

```
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.

## Database Schema

Migration `backlog_support` (append-only, reuses existing table):
Expand Down Expand Up @@ -296,6 +353,31 @@ 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.

### Sync Waiter — `src/backend/services/sync_waiter.py` (NEW, #498)

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_persistence` finally block. Looks up the registered
future and completes it with the rich `TaskExecutionResult`. 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, then `asyncio.wait(FIRST_COMPLETED)`s
on either signal. Returns the rich payload on signal, returns `None` on
poll-fallback hit (caller reconstructs from DB row), raises `TimeoutError`
if 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).

## Configuration

Per-agent backlog depth is stored in `agent_ownership.max_backlog_depth`:
Expand Down
Loading
Loading