会话生命周期
昨天(Day 06)拿到了 app_server 的整体地图,今天走进第一个、也是最核心的部门——会话(conversation)。从数据模型到启动状态机,逐层读"一个会话如何被创建和启动",并见识"生成器即进度流"这个贯穿始终的巧妙设计。读懂了会话的诞生,明天(Day 08)的事件系统就是它运转时产生的血液。
三个会话数据模型
app_conversation_models.py 里有三个关键模型,分工明确:
AppConversationInfo(:110):要落库的会话元数据(id、sandbox_id、repo、分支、llm_model、tags…),不含实时状态。AppConversation(:195):= Info + 实时字段(sandbox_status、execution_status、conversation_url、session_api_key)。运行时拼装,不落库。AppConversationStartRequest(:221):启动参数(repo、branch、initial_message、llm_model、agent_type…)。
AppConversationInfo 落库);而"他现在是在工位、在开会还是请假了"(沙箱运行/暂停、Agent 跑到哪)是时刻在变的,你要看就去实时查,没人会把它写进档案——写进去下一秒就过期了。会话有些信息是静态、需要永久保存的(它绑了哪个仓库、用哪个模型)——存数据库。有些是动态、时刻在变的(沙箱现在是运行还是暂停、Agent 现在跑到哪了)——这些去实时查沙箱,没必要也不应该存库(存了立刻就过期)。
Info(存档)+ 实时字段拼成 AppConversation(完整视图)。把"持久事实"和"瞬时状态"分开,是数据建模的基本卫生。启动任务状态机
启动会话很慢(拉容器、clone、装环境要几十秒),所以用一个"启动任务"对象 AppConversationStartTask 承载进度,它是一个状态机(app_conversation_models.py:288):
"生成器即进度流"设计
yield 吐出当前 task 的最新状态,前端一路看着进度走,而不是干等到最后。启动方法的签名很特别(app_conversation_service.py:81)——它返回一个异步生成器,反复 yield 同一个 task 的最新状态:
async def start_app_conversation(
self, start_request: AppConversationStartRequest
) -> AsyncGenerator[AppConversationStartTask, None]:
# 每完成一步,就 yield 一次更新后的 task
yield task # WORKING
# ...拉沙箱...
yield task # WAITING_FOR_SANDBOX
# ...clone...
yield task # PREPARING_REPOSITORY
# ...
yield task # READY
for 循环消费,一路看到进度,直到 READY 或 ERROR。return 一次就结束。生成器用 yield 能"吐一个值、暂停、下次继续吐",像挤牙膏。这里完美契合"慢启动的多阶段进度"——每完成一个阶段 yield 一次,消费者立刻能拿到并显示,不用干等到最后。同一个生成器,既能被后台任务慢慢消费,也能被 StreamingResponse 包成流式 HTTP 响应直接吐给前端(/stream-start 端点就这么干)。一份逻辑两种消费方式——优雅。路由:先返回、再后台继续
启动端点 start_app_conversation(app_conversation_router.py:364)有个巧妙处理:只取生成器的第一个值就返回,剩下的进度放后台消费:
set_db_session_keep_open(request.state, True) # 请求返回后别关连接
set_httpx_client_keep_open(request.state, True)
async_iter = app_conversation_service.start_app_conversation(start_request)
result = await anext(async_iter) # 只取第一个(初始 task)立即返回
# ...
asyncio.create_task(_consume_remaining(async_iter, db_session, httpx_client)) # 剩余进度后台跑
anext(async_iter) 取生成器第一个产出(初始 task)就 return 给前端——用户马上看到"会话已创建,正在启动",不用等几十秒的完整启动。后面的阶段用 asyncio.create_task 丢到后台继续跑(进度通过别的通道给前端)。keep_open 连接? 正常情况下,HTTP 请求返回后 FastAPI 会关闭这个请求的 db/http 连接。但这里后台任务还要继续用它们跑剩余启动流程,所以显式标记"别关",由后台任务用完再关。这是"HTTP 请求已响应、但工作在后台继续"这类场景的经典处理。👶 小白:既然启动会经历那么多阶段,为什么只取第一个 anext 就 return,不干脆等全部 yield 完再回?
👨🏫 老师:因为等全部跑完要几十秒,用户会以为卡死了。就像去餐厅点单——服务员不会让你站在门口等菜全炒好才给你桌位,而是先领你入座(返回"会话已创建"),厨房继续做菜(后台跑剩余阶段),你在座位上看上菜进度。先给用户一个"已受理"的确定反馈,慢活丢后台,是所有"长耗时操作"的通用体验设计。
启动核心全流程 _start_app_conversation
真正的启动逻辑在 live_status_app_conversation_service.py:361 的 _start_app_conversation——会话逻辑的心脏。串起来就是 Day 05 那条旅程:
# 概念流程(每步 yield 一个状态)
# 1. 取 user_id;有 parent_conversation_id 则继承父会话配置(复用同一沙箱/repo/model)
# 2. _wait_for_sandbox_start:找一个在跑的沙箱,或 start_sandbox 新建,
# 轮询 wait_for_sandbox_running 直到容器 RUNNING 且 agent-server /alive
# 3. _seed_sandbox_profiles:把用户的 LLM profiles 同步进沙箱
# 4. 算 working_dir → 建 AsyncRemoteWorkspace
# 5. run_setup_scripts:clone 仓库、跑 setup.sh、装 git hooks、加载 skills
# 6. 构建 Agent(见 L06)
# 7. POST {agent_server_url}/api/conversations 在沙箱里真正创建会话
# 8. 落库 AppConversationInfo
# 9. 注册事件回调(总是确保有 SetTitleCallbackProcessor 自动生成标题)
# 10. 状态置 READY;处理排队的 pending 消息
# 异常 → 状态置 ERROR,detail 经 redact_text_secrets 脱敏
/alive(避免"容器起了但服务没就绪"的竞态);⑨ 总是挂一个自动生成标题的回调;异常时错误信息脱敏(redact_text_secrets,防止密钥泄漏到日志)。构建 Agent(第6步细看)
_build_start_conversation_request_for_user(:1610)组装出要交给 agent-server 的 Agent 配置:
if agent_type == AgentType.PLAN:
tools = get_planning_tools(plan_path=plan_path) # 规划型 Agent
else:
register_builtins_agents(enable_browser=True)
tools = get_default_tools(enable_browser=True, enable_sub_agents=...) # 默认工具集
configured_agent_settings = user.agent_settings.model_copy(update={
'llm': llm, 'tools': tools, 'mcp_config': mcp_config or {},
'agent_context': AgentContext(system_message_suffix=..., secrets=secrets)})
agent = configured_agent_settings.create_agent()
create_agent()。这个 agent 配置会随"创建会话"请求送进沙箱里的 agent-server,由它真正实例化并运行。可选附加 SwitchLLMTool(多模型时让 Agent 自己切换模型)。_configure_llm(:1215),强制 stream=True(流式,Day 03 的打字机效果靠它)、usage_id='agent'(用量归属)。注意:app_server 只是"组装并转交"Agent 配置,真正的 Agent 循环在沙箱里跑——再次印证管家/工人分工。薄代理与删除
会话建好后,很多操作(发消息、看 diff、下载文件)前端无法直连沙箱,就由 app_server 代理转发。这些端点刻意做成"薄代理"——只转发、不加逻辑:
# send-message 端点(router:441)文档原话:convenience wrapper,不做额外处理
# 把消息直接转发到沙箱 agent-server 的 /api/conversations/{id}/events
DELETE /{id},router:928):删会话记录;如果这个沙箱没有别的会话在用了,后台任务 _finalize_sandbox_delete 先归档 workspace(保留你的工作成果)再删容器。所有代理端点都靠 _get_agent_server_context(:134)解析出"会话→沙箱→暴露URL→agent_server_url"这条链路,再转发。今日小结 + 动手
🧠 今天你应该能回答
- 三个会话模型的分工?为什么区分落库/实时?
- 启动状态机有哪些阶段?为什么需要它?
- "生成器即进度流"是什么?好在哪?
- 路由为什么"先返回、后台继续"?keep_open 为什么?
- 薄代理原则是什么?为什么删会话要先归档 workspace?
✋ 动手
grep -n 'class AppConversationInfo\|class AppConversation\|StartTaskStatus' openhands/app_server/app_conversation/app_conversation_models.py
grep -n 'def start_app_conversation\|anext\|create_task' openhands/app_server/app_conversation/app_conversation_router.py
grep -n 'def _start_app_conversation\|_wait_for_sandbox_start\|redact_text_secrets' openhands/app_server/app_conversation/live_status_app_conversation_service.py