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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -385,3 +385,16 @@ CANARY_ENABLED=0
# The URL is the credential — anyone with it can post to that one channel.
# Unset = canary cycles run silently (violations still persisted to DB).
CANARY_SLACK_WEBHOOK_URL=

# ===========================================
# FIRE-AND-FORGET DISPATCH (Optional, #1083)
# ===========================================

# When true, eligible autonomous turns ({schedule, webhook}) are dispatched to
# the agent with a 202 accept and finalized via the result-callback endpoint, so
# a wedged turn holds zero backend coroutine/slot beyond its lease. Default
# false. Safe to flip early: a non-202 agent response (old image / non-Claude
# runtime) falls back to today's synchronous handling. Backend-only (the
# scheduler doesn't dispatch to agents). Requires a base-image rebuild + rolled
# agent containers for the 202 path to engage.
DISPATCH_ASYNC=false
7 changes: 7 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ services:
- DATABASE_URL=${DATABASE_URL:-}
- DB_POOL_SIZE=${DB_POOL_SIZE:-10}
- DB_MAX_OVERFLOW=${DB_MAX_OVERFLOW:-20}
# Fire-and-forget dispatch (#1083). When true, eligible autonomous turns
# ({schedule, webhook}) are dispatched 202 + finalized via callback so a
# wedged turn holds zero backend coroutine/slot beyond its lease. Mirrors
# docker-compose.yml; without this line the .env lever is inert in prod
# (the #1039 packaging-gap class). Default false; safe to flip early
# (non-202 agent responses fall back to sync). Backend-only.
- DISPATCH_ASYNC=${DISPATCH_ASYNC:-false}
# Host telemetry (/api/telemetry) — container-stats cache freshness (s) and
# max concurrent Docker stat fetches per refresh. Mirrors docker-compose.yml;
# without these lines the .env knobs are inert in prod (the #1039
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ services:
# forever — accepts deadlock risk for zero false 503s).
- BACKEND_AGENT_CALL_LIMIT=${BACKEND_AGENT_CALL_LIMIT:-8}
- BACKEND_AGENT_CALL_QUEUE_TIMEOUT_S=${BACKEND_AGENT_CALL_QUEUE_TIMEOUT_S:-3600}
# Fire-and-forget dispatch (#1083). When true, eligible autonomous turns
# ({schedule, webhook}) are dispatched 202 + finalized via callback so a
# wedged turn holds zero backend coroutine/slot beyond its lease. Default
# false; safe to flip early (non-202 agent responses fall back to sync).
# Backend-only — the scheduler doesn't dispatch to agents.
- DISPATCH_ASYNC=${DISPATCH_ASYNC:-false}
# Issue #874: uvicorn --reload writes __pycache__/ next to source files
# in the ./src/backend bind mount. On Linux dev hosts the host dir is
# owned by the developer UID, not container UID 1000, so the writes
Expand Down
6 changes: 6 additions & 0 deletions docker/base-image/agent_server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from .services.trinity_mcp import inject_trinity_mcp_if_configured
from .auto_sync import schedule_auto_sync_if_enabled
from .heartbeat import schedule_heartbeat
from .services.result_callback import schedule_pending_result_resend
from .services.orphan_sweeper import schedule_orphan_sweeper

# Configure logging
Expand Down Expand Up @@ -70,6 +71,11 @@
# + TRINITY_MCP_API_KEY both present, so old-image agents simply never beat.
schedule_heartbeat(app)

# #1083 fire-and-forget: on startup re-send any result-callback envelope left on
# disk by a crash/restart mid-callback, so completed work isn't lost to a phantom
# LEASE_EXPIRED. Gated on the same callback creds as the heartbeat.
schedule_pending_result_resend(app)

# #817 follow-up: periodic cgroup orphan sweep. Catches orphans that
# escape the per-task cleanup path — specifically Eugene's production
# scenario where Trinity-side CB termination skips drain_reader_threads
Expand Down
5 changes: 5 additions & 0 deletions docker/base-image/agent_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,11 @@ class ParallelTaskRequest(BaseModel):
resume_session_id: Optional[str] = None # Claude Code session ID for --resume (EXEC-023)
persist_session: Optional[bool] = False # Session tab: write the JSONL so future --resume works
images: Optional[List[Dict[str, str]]] = None # Vision images: [{"media_type": "image/jpeg", "data": "<base64>"}]
# #1083 fire-and-forget: when true AND this agent runs the Claude runtime,
# accept the turn with 202 and report the terminal via the backend's
# result-callback endpoint. Ignored by non-Claude runtimes / old images
# (they run synchronously and return 200 — the backend's non-202 fallback).
async_result: Optional[bool] = False


class ParallelTaskResponse(BaseModel):
Expand Down
15 changes: 14 additions & 1 deletion docker/base-image/agent_server/routers/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
from datetime import datetime

from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.responses import StreamingResponse, JSONResponse

from ..models import ChatRequest, ModelRequest, ParallelTaskRequest
from ..state import agent_state
from ..services.claude_code import get_execution_lock
from ..services.runtime_adapter import get_runtime
from ..services.process_registry import get_process_registry
from ..services import result_callback

logger = logging.getLogger(__name__)
router = APIRouter()
Expand Down Expand Up @@ -126,6 +127,18 @@ async def execute_task(request: ParallelTaskRequest):
else:
logger.info(f"[Task] Executing parallel task: {request.message[:50]}...")

# #1083 fire-and-forget: when the backend requests async AND this is the
# Claude runtime, accept with 202 and run the turn in a detached task that
# reports the terminal to the backend's result-callback endpoint. The detached
# task owns its own record_task_start/finish. try_spawn_async returns False
# (→ synchronous handling below) for non-Claude runtimes, a missing
# execution_id, or absent callback creds — the non-202 fallback.
if result_callback.try_spawn_async(request):
return JSONResponse(
status_code=202,
content={"execution_id": request.execution_id, "status": "accepted"},
)

# Execute via runtime adapter in headless mode (no lock, no --continue)
runtime = get_runtime()
# #1020: feed the richer /health signal — count this execution and record
Expand Down
Loading
Loading