[eric] agents: launch honors its prompt instead of silently dropping it, spawn phases log their trail (the ENG-131 ghost hang)

This commit is contained in:
ciregenz
2026-08-06 15:19:02 -07:00
parent 3ea5167778
commit 42103a4e23
6 changed files with 74 additions and 0 deletions
+3
View File
@@ -138,6 +138,7 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
# Read BEFORE build_agent_options consumes these flags: a fresh-session/fork request must force the persistent client to respawn (same branch id would otherwise fingerprint-match a client still holding the old transcript).
p_force_respawn = bool(session.needs_fresh_session or session.needs_fork or fork_session)
try:
logger.info(f"[SPAWN-PHASE] run-loop start session={session_id[:8]}")
(options, options_kwargs, prompt_content, p_stderr_buffer,
global_settings) = await self.build_agent_options(
session, session_id, prompt, prompt_content, builtin_perms,
@@ -148,7 +149,9 @@ class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionContr
thinking = ThinkingState()
# Gate the CLI turn (spawn + stream) behind the admission slot so a burst can't run every turn at once; the slot is held ONLY for run_turn_with_retry, so the context-valve retry below re-acquires cleanly instead of nesting.
logger.info(f"[SPAWN-PHASE] admission-wait session={session_id[:8]}")
async with self.turn_admission_slot(session, session_id):
logger.info(f"[SPAWN-PHASE] admitted session={session_id[:8]}")
await self.run_turn_with_retry(
session, session_id, prompt_content, options, options_kwargs,
turn, thinking, p_stderr_buffer, resolved_model, api_type, global_settings,
+3
View File
@@ -132,6 +132,9 @@ async def get_session(session_id: str):
@agents.router.post("/launch")
async def launch_agent(config: AgentConfig):
session = await agent_manager.launch_agent(config)
# A launch that carries a prompt runs it as the first turn through the same path /message uses.
if config.prompt:
asyncio.create_task(agent_manager.send_message(session.id, config.prompt))
return {"session_id": session.id, "session": session.model_dump(mode="json")}
@agents.router.post("/sessions/{session_id}/message")
+2
View File
@@ -5,6 +5,8 @@ from uuid import uuid4
class AgentConfig(BaseModel):
name: str = ""
# First-turn prompt. Launch used to silently DROP this (pydantic ignores unknown fields), leaving the session claiming "running" forever with zero messages and no error, the ENG-131 ghost hang.
prompt: Optional[str] = None
model: str = "sonnet"
mode: str = "agent"
provider: str = "anthropic"
@@ -50,6 +50,8 @@ class RunOptions(AgentManagerProtocol):
from claude_agent_sdk import ClaudeAgentOptions
from claude_agent_sdk.types import HookMatcher
logger.info(f"[SPAWN-PHASE] options-build start session={session_id[:8]}")
# Per-SESSION hook context, updated in place each turn: with a persistent client the hooks the
# CLI holds were bound at connect, so they must read this stable object, not a per-turn rebuild.
hook_ctx = self.hook_ctxs.get(session_id)
@@ -115,7 +117,9 @@ class RunOptions(AgentManagerProtocol):
set_framework_overhead(session, composed_prompt)
# Pass session.active_mcps as the activation filter. Empty list ⇒ no MCP tools shipped to the SDK; the model must MCPSearch and MCPActivate first. The product invariant lives here at the dispatch layer (see build_mcp_servers docstring).
logger.info(f"[SPAWN-PHASE] mcp-build start session={session_id[:8]}")
mcp_servers = await self.build_mcp_servers(session.allowed_tools, session.active_mcps)
logger.info(f"[SPAWN-PHASE] mcp-build done session={session_id[:8]}")
browser_delegation_tools, invoke_agent_tools = register_builtin_mcp_servers(
mcp_servers, session, builtin_perms, selected_browser_ids, selected_app_output_ids
@@ -192,9 +196,11 @@ class RunOptions(AgentManagerProtocol):
"include_partial_messages": True,
}
# cc/cx/gc/ag/gemini/openrouter prefixes force 9Router; route="api" bypasses to the provider's host directly; otherwise Pro proxy or key.
logger.info(f"[SPAWN-PHASE] provider-env start session={session_id[:8]}")
await configure_provider_env(
options_kwargs, session, resolved_model, api_type, global_settings
)
logger.info(f"[SPAWN-PHASE] provider-env done session={session_id[:8]}")
if mcp_servers:
options_kwargs["mcp_servers"] = mcp_servers
mcp_json_len = len(json.dumps({"mcpServers": mcp_servers}))
@@ -264,7 +270,9 @@ class RunOptions(AgentManagerProtocol):
# Distill the dropped span into a cached aux summary so a rebuild keeps the gist of old turns instead of hard-dropping them. Fail-open: "" -> the plain recap above, exactly today's behavior.
from backend.apps.agents.manager.session.distill_history import distilled_history_summary
from backend.apps.agents.manager.session.history_compaction import wrap_platform_note
logger.info(f"[SPAWN-PHASE] distill start session={session_id[:8]}")
distilled = await distilled_history_summary(session, global_settings)
logger.info(f"[SPAWN-PHASE] distill done session={session_id[:8]}")
if distilled:
fenced = wrap_platform_note(f"Summary of earlier conversation (older turns compacted):\n{distilled}")
history = f"{fenced}\n\n{history}" if history else fenced
@@ -275,7 +283,9 @@ class RunOptions(AgentManagerProtocol):
prompt_content.insert(0, {"type": "text", "text": history})
# Compaction trigger (Phase 2). Driven by live ctx_used ratio rather than turn count, fires when input_tokens/context_window crosses session.compact_threshold_pct (default 0.65). Cheap, programmatic summarization (no aux LLM call) so this adds zero latency on the user's turn.
logger.info(f"[SPAWN-PHASE] context-guard start session={session_id[:8]}")
await pre_send_context_guard(self, session, session_id)
logger.info(f"[SPAWN-PHASE] context-guard done session={session_id[:8]}")
logger.info(f"[MCP-DEBUG] Creating ClaudeAgentOptions short={session.model} resolved={resolved_model} api_type={api_type}")
options = ClaudeAgentOptions(**options_kwargs)
@@ -131,10 +131,13 @@ class TurnRunner(AgentManagerProtocol):
async def p_connect():
p_client = ClaudeSDKClient(options=options)
logger.info(f"[SPAWN-PHASE] cli-connect start session={session_id[:8]}")
await p_client.connect()
logger.info(f"[SPAWN-PHASE] cli-connect done session={session_id[:8]}")
return p_client
fp = boot_fingerprint(options_kwargs, session)
logger.info(f"[SPAWN-PHASE] client-acquire start session={session_id[:8]}")
handle = await acquire_client(
self.client_pool, session_id, fp, p_connect, force_respawn=force_respawn,
)
+53
View File
@@ -0,0 +1,53 @@
"""A launch that carries a prompt must RUN it.
The trap this seals (ENG-131): AgentConfig had no `prompt` field, so pydantic silently dropped it
from `POST /api/agents/launch`. The session was created, broadcast as "running", and then nothing
was ever scheduled: a permanent silent spinner with 0 messages, indistinguishable from a real hang.
Five sessions were forensically chased through pool, router, and scheduler before the launch body
turned out to be the whole story.
"""
import asyncio
from pytest import MonkeyPatch
from backend.apps.agents import agents as agents_module
from backend.apps.agents.core.models import AgentConfig, AgentSession
def p_launch(monkeypatch: MonkeyPatch, config: AgentConfig) -> list:
sent: list = []
session = AgentSession(name="probe")
async def fake_launch(cfg: AgentConfig) -> AgentSession:
return session
async def fake_send(session_id: str, prompt: str, **kwargs) -> None:
sent.append((session_id, prompt))
monkeypatch.setattr(agents_module.agent_manager, "launch_agent", fake_launch)
monkeypatch.setattr(agents_module.agent_manager, "send_message", fake_send)
async def run() -> None:
await agents_module.launch_agent(config)
# The first turn is fire-and-forget; drain it before asserting.
await asyncio.sleep(0)
await asyncio.sleep(0)
asyncio.run(run())
return sent
def test_launch_with_prompt_schedules_the_first_turn(monkeypatch: MonkeyPatch) -> None:
sent = p_launch(monkeypatch, AgentConfig(name="probe", prompt="say ready"))
assert len(sent) == 1
assert sent[0][1] == "say ready"
def test_launch_without_prompt_schedules_nothing(monkeypatch: MonkeyPatch) -> None:
assert p_launch(monkeypatch, AgentConfig(name="probe")) == []
def test_prompt_survives_the_launch_body_parse() -> None:
# The original failure: this field VANISHED in validation, so the route could never see it.
assert AgentConfig(**{"prompt": "hello", "name": "x"}).prompt == "hello"