ZhuLinsen/daily_stock_analysis · error · HTTPException

request_conflict

request_conflict

Error message

This Agent request is already running

What it means

Starting a Codex app-server agent chat stream returns HTTP 409 code=request_conflict when the same request_id already has an active SSE stream. The module keeps an in-process dict _ACTIVE_CODEX_STREAMS (api/v1/endpoints/agent.py:47) guarded by a lock; the guard at line 514-523 registers request_id -> cancel_event on stream start and removes it at stream end (line 648-650). It is a single-flight guard: one live stream per request_id per server process.

Source

Thrown at api/v1/endpoints/agent.py:516

    session_id = request.session_id or str(uuid.uuid4())
    loop = asyncio.get_running_loop()
    queue: asyncio.Queue = asyncio.Queue()
    cancel_event = threading.Event()
    request_id = request.request_id or str(uuid.uuid4())
    skill_selection = session_service.resolve_skill_selection(
        config,
        session_id,
        request.effective_skills,
    )
    skills = skill_selection.effective_skill_ids
    selected_skill_ids = skill_selection.selected_skill_ids_update
    stream_ctx = _build_agent_chat_context(request, config, skills)

    if backend_id == "codex_app_server":
        with _ACTIVE_CODEX_STREAMS_LOCK:
            if request_id in _ACTIVE_CODEX_STREAMS:
                raise HTTPException(
                    status_code=409,
                    detail={
                        "error": "request_conflict",
                        "message": "This Agent request is already running",
                    },
                )
            _ACTIVE_CODEX_STREAMS[request_id] = cancel_event

    def progress_callback(event: dict):
        if backend_id == "codex_app_server" and cancel_event.is_set():
            return
        # Enrich tool events with display names
        if event.get("type") in ("tool_start", "tool_done"):
            tool = event.get("tool", "")
            event["display_name"] = TOOL_DISPLAY_NAMES.get(tool, tool)
        asyncio.run_coroutine_threadsafe(queue.put(event), loop)

    def run_sync(executor, turn):

View on GitHub (pinned to 5159bd72e8)

Solutions

  1. Generate a fresh unique request_id (uuid) for each new stream instead of reusing one
  2. Wait for the previous stream's completion (or its server-sent close event) before re-issuing the same request_id
  3. If the previous stream is stuck, call POST /agent/chat/stream/{request_id}/cancel to end it, which lets the generator clean up and free the slot, then start a new stream
  4. Check server logs/stream state if 409 persists with no visible open stream — the entry may be orphaned until the connection actually closes

Example fix

// before
const requestId = `chat-${stockCode}`; // stable id -> second open triggers 409
openStream(requestId);

// after
const requestId = crypto.randomUUID(); // unique per stream
openStream(requestId);
// on retry after disconnect, also fire cancel for the old id first
await fetch(`/api/v1/agent/chat/stream/${oldRequestId}/cancel`, {method:'POST'});
Defensive patterns

Strategy: validation

Validate before calling

// Browser client: fresh id per stream + explicit cancel of the old one
function newRequestId() { return crypto.randomUUID(); }
// before reconnecting with the same logical conversation:
async function reopen(oldId) {
  await fetch(`/api/v1/agent/chat/stream/${oldId}/cancel`, { method: 'POST' }).catch(() => {});
  return newRequestId();
}

Try / catch

try:
    stream = post_chat_stream(request_id=fresh_uuid)
except HTTPError as e:
    if e.response.status_code == 409 and e.response.json()['detail']['error'] == 'request_conflict':
        cancel_stream(request_id)  # clear the live stream, then retry once with a NEW id
        stream = post_chat_stream(request_id=new_uuid())
    else:
        raise

Prevention

When it happens

Trigger: POSTing /agent/chat/stream (backend_id='codex_app_server') with a request_id that is still streaming — e.g. double-clicking send in the web UI, retrying a 'timed out' request while the server keeps the original SSE open, or reusing a client-generated request_id for parallel tabs. Only applies to the codex_app_server backend; other backends never register the entry and never 409 here.

Common situations: Frontend retry logic that regenerates the same request_id after a network hiccup while the original stream is alive; multiple Electron/Web clients sharing a deterministic request_id; streams that finished client-side but are still open server-side (slow downstream consumer) so the registry entry was never popped; also single-worker only — the dict is per-process, so multi-worker deployments will not detect cross-worker conflicts.

Related errors


AI-assisted analysis of ZhuLinsen/daily_stock_analysis@5159bd72e8 (2026-08-15). Data as JSON: /api/errors/8d83a0f4907691aa. Report an issue: GitHub.