OpenProgram Docs

操作流程#

每个操作从触发到磁盘到前端,完整写一遍。


启动#

进程启动时 SessionStore 做一次初始化:

  1. 读取 index.json 到内存 _index。
  2. 文件不存在或 JSON parse 失败时,扫描默认布局、嵌套项目布局和持久 location,依据 meta.json 重建 _index,再写回 index.json。
  3. 把所有 status=running 重置为 idle;这是修复死亡 worker 留下的 session 列表状态。执行恢复是另一条路径:执行子系统可以通过自己的恢复流程 继续持久化的 checkpoint 操作。
  4. 清理未归档的旧空壳:session 超过 1 小时且没有 history/ 目录时删除。 已归档、路径暂时不可达或被迁移日志覆盖的 session 保留。

启动时不会按时间删除归档 session,也没有 session 数量容量上限。显式归档的 session 会一直保留,直到所有者删除。

半残 session 的处理:

  • 有 meta.json 没 history/ → 旧的未归档空壳可删除;最近创建、已归档、 不可达或迁移中的 session 保留。
  • 有 history/ 没 meta.json → 无法重建注册表,不对外暴露为 session。

创建 session#

3 个入口可以触发创建:

入口 场景
dispatcher.process_user_turn 用户发消息时,session 不存在则创建
channel handler 渠道消息到达时创建
session_context CLI / research harness 进入上下文时,session 不存在则创建

其他地方不创建 session。

完整流程#

调用方调 create_session(session_id, agent_id, source=..., ...)
  → 解析位置:默认项目使用 <state>/sessions/<session_id>,绑定项目使用
    <state>/sessions/projects/<project_id>/<session_id>
  → 写 meta.json(id, agent_id, title, created_at, updated_at, source, status="idle", ...)
  → 写注册表:_index[session_id] = 摘要条目
  → 注册表原子写磁盘(临时文件 → os.rename)
  → 不广播(前端通过 list_sessions 发现新 session)

原子性#

dispatcher 和 channel handler 的创建与写入第一条消息是原子的——创建后立即 append_message,不会产生空壳。

session_context 在 __enter__ 时创建(因为后续 ContextVar 装载需要有效的 session),如果后续没写消息就异常退出,会产生空壳,由启动时清理处理。


主项目绑定#

会话的主工作目录就是它所绑定项目的路径。草稿阶段可以自由选择,第一条真实消息提交时定格。此后不能把 session 绑定到另一个项目。会话仓库已经位于应用状态目录下,并按稳定 project id 分组;项目路径变化不会改变 session→project 绑定或仓库 id。

追加工作目录是相反的设计,也应该如此:会话生命周期内随时增删。见 additional-working-directories.zh.md。

选择发生在哪里#

草稿会话,在 composer chip 里选了项目
  → pendingProjectsByChat[chatKey](前端 store,尚未发送)
  → 第一条 chat 帧带上 project_id
  → handle_chat 用该项目建会话 → 仓库落在应用状态目录的
    projects/<project_id>/<session_id>
  → chat_ack 再补一次幂等的 set_session_project(标签 + 反向索引)

set_session_project 在任何时候都接受"绑到会话已有的那个项目"——上面那条 ack 后的幂等 bind 走的就是这条路,它到达时第一轮已经提交。而在已有轮次的会话上绑另一个项目会被拒,理由为 project is frozen after the first turn(ws_actions/project.py:FROZEN_ERROR)。composer chip 与之对齐:已有 session id 的会话只读展示已绑项目,不再给选择器。

附件与恢复#

浏览器上传的文件在 dispatch 前解码并保存到 <session repository>/workdir/attachments/。保存后的路径写入 user message 标记,并在 turn 结束时随会话仓库提交。浏览器 IndexedDB 只保存尚未发送的 composer 草稿;发布后会清理,因此不是持久的 session 附件存储。渠道附件仍 位于 <state>/channels/*/accounts/*/attachments 的渠道根目录。

旧项目迁移时,迁移日志会清点外部恢复目录,将会话和恢复数据一起复制到 <state>/sessions/.migration/staging/ 并校验,再把会话发布到 projects/<project_id>/<session_id>/,把恢复数据发布到旁边的 .file-recovery/<session_id>/<turn>/,随后才更新 location 权威记录。发布缺失或 失败时,项目保持不可用状态,新的项目绑定任务会被阻止。

历史标记兼容性遵循 storage.zh.md 的存储规则:只有精确的旧 session 附件路径形式,才能按同一个 session id 解析到唯一的当前仓库,并执行 附件根目录包含检查以及符号链接和路径穿越检查。系统不会任意重映射旧绝对路径。

修复缺失的目录#

定格后的主目录仍可重定位,且只为修复:目录在磁盘上被移动或改名,项目指向了不存在的位置。这种状态下 project_workdir_for 返回 None,绝不悄悄替换成默认项目家目录。项目缺失、被替换、pending、迁移中或其他不可用状态时,新的项目绑定任务会被阻止;界面收到 location state 和缺失路径后提供修复入口。修复期间会话仓库仍可读取。

修复动作是 relocate_project ws action:

relocate_project {session_id, project_id, path}
  → project_store.relocate_project:校验新目录、改写项目 path、**保留 id**
    (会话反向索引、项目设置、定格的绑定全挂在 id 上)
  → record_relocate:追加下面的记录节点
  → project_relocated {ok, old_path, path, node_id} + 一份新的 projects_list

它改的是项目的路径,而非会话→项目的绑定,所以定格之后依然合法。绑定到该项目的 session 保持同一个稳定 project id 和应用状态目录下的会话仓库。旧工作目录副本通过迁移日志和持久 location index 处理。默认项目拒绝重定位:它的显示路径是家目录,每次 get_default_project 读取都会恢复;它没有独立的项目 Git 仓库。每轮文件撤销恢复仍位于目标仓库旁的 .file-recovery/<session_id>/<turn>/,在会话 Git 仓库之外。

relocate 记录节点#

这次移动改变了此后每一轮的运行位置,所以记进会话图,而不是无声改注册表(openprogram/store/project/relocate_node.py):

字段 值
role code
name project/relocate
caller ROOT
predecessor None
output <旧路径> → <新路径>
metadata {display: "runtime", project_id, old_path, new_path}

形状照搬 context/system_prompt(dag/overview.md §3、§7):写入不变式只约束对话节点;caller 已设,store 便不推进 head——记录重定位不动分支尖端。名字刻意不放在 context/ 下:那是隐藏于对话流的管线机器,而重定位是值得看见的用户动作。


写消息#

调用方调 append_message(session_id, msg)
  → 把 JSON 节点追加到 history/ 中的对话 DAG
  → 写入 meta.json;对话链增长时同时写入 DAG head
  → 如果 msg.role == "user":
      → preview = 截取 msg.content 前 80 字符
      → _index[session_id]["preview"] = preview
      → _index[session_id]["updated_at"] = time.time()
      → 标记注册表脏(5 秒 debounce 写磁盘)
  → 不广播 session 列表(消息内容通过独立的 streaming 通道推送)

原始节点和 meta.json 会在 turn 结束的 Git 提交之前写入。turn 成功后, GitSession.commit_all 提交会话仓库;session_commits() 读取这些 Git 提交, 形成独立的每轮时间轴。因此 Git HEAD 与对话 DAG 的 head 不是同一个指针。

preview 截取#

def _truncate(text: str | None, max_len: int = 80) -> str | None:
    if not text:
        return None
    t = text.strip().replace("\n", " ")
    return t[:77] + "…" if len(t) > max_len else t

注册表写磁盘节流#

append_message 更新注册表时,内存立即更新,磁盘写入 debounce(5 秒内最多写一次)。进程退出时 flush。如果进程被 SIGKILL 导致 flush 失败,启动时从 meta.json 重建即可恢复,最多丢失 5 秒的 preview 更新。

其他操作(create、update、delete)立即原子写磁盘。


更新字段#

标题、状态、置顶、归档、未读等字段的更新都走同一条路径:

调用方调 update_session(session_id, title="新标题", pinned=True, ...)
  → 写 meta.json(只更新传入的字段)
  → 更新 _index[session_id] 中对应字段(不改变 updated_at)
  → 注册表原子写磁盘

updated_at 只在追加消息路径推进;rename、pin、archive 和 read/unread 等元数据更新保留原有 recency。

广播由 WebSocket handler 层在调用 update_session 之后通过 _broadcast 发起,rename 与 flags 均走广播:

→ 广播 session_updated:
  {"type": "session_updated", "data": {"id": "<session_id>", "title": "新标题", "pinned": true}}
→ 前端 handleSessionUpdated 收到后 patch 对应 session 并重渲染

data 只包含变更的字段,前端做增量 patch。

status 的写入时机#

dispatcher 在 turn 生命周期中写 status:

时机 写入值
turn 开始 update_session(session_id, status="running")
turn 正常结束(前台) update_session(session_id, status="idle")
turn 正常结束(后台) update_session(session_id, status="done", unread=True)
turn 失败 update_session(session_id, status="failed")
等待用户输入 update_session(session_id, status="needs_input")

命名#

命名只有一套权威实现:openprogram/agent/dispatcher/titles.py。所有入口(WS / fn-form / channel / CLI / spawn)的命名都汇到 finalize_turn 末尾 → _maybe_auto_title,没有第二套截断/锁死逻辑。标题写入都通过 update_session(session_id, title=...),走上面"更新字段"的完整流程。

锁标记(权威,仅两把 + 一个内部计数)#

标记 类型 含义 谁来设
_user_titled bool 用户手动改名 → 永久锁,自动命名永不再跑 只有 rename 操作在用户输入了名字时设
_auto_titled bool 自动命名已产出过至少一个标题(首轮截断或任意 LLM 写入)→ "别重复截断"去重位 只有 _maybe_auto_title 设
_title_gen_count int 渐进式重命名内部计数(命中到 _RETITLE_AT_TURNS 第几个) _maybe_auto_title 内部,非入口锁

不存在既表示"已截断"又表示"永久锁"的单一标记:一个标记兼做两件事会与两阶段流程冲突——阶段 1 的截断本就应当被 LLM 标题取代。因此各入口不自己做截断、也不设自己的锁,统一由 _maybe_auto_title 完成阶段 1(截断)和阶段 2(LLM)。入口唯一可设的锁是 _user_titled,由 rename 操作设置。

自动命名(渐进式,两阶段)#

自动命名在对话演进过程中多次触发,随着上下文增多生成更精确的标题。 触发阈值:第 1、6、16、40 轮 assistant 回复时(_RETITLE_AT_TURNS)。

finalize_turn 末尾 → _maybe_auto_title:
  1. 检查 _user_titled → 用户手动改过名则永不自动重命名
  2. 统计当前 assistant 消息数 → 未命中阈值则跳过
  3. 首次(turn 1,阶段 1 立即截断):
     a. title = _title_from_text(用户首条消息)
        (剥 [attachment:]/<attachment-preview>/<file> 标记 → 取首行 → 截 50 字,超出加 …)
        → update_session(session_id, title=截取值, _auto_titled=True, _title_gen_count=1)
     b. 启动后台 daemon 线程调 LLM(阶段 2)
  4. 后续阈值(turn 6/16/40):
     a. 直接启动后台 daemon 线程
     b. LLM 输入取最近 20 条消息(而非仅首轮)
  5. 后台线程(阶段 2):
     → 竞态检查:_user_titled 则放弃
     → 首次还检查 title 是否仍为阶段 1 截取值(被改过就放弃写入)
     → 写入 update_session(session_id, title=LLM结果, _auto_titled=True, _title_gen_count=N+1)
     → 广播 session_updated

channel(微信 / Discord 等)#

channel 会话命名与普通会话完全一样,走同一套两阶段 LLM 命名,channel 端不对标题内容做任何额外操作 / 锁定 / 干预(不设 _user_titled、不设 _auto_titled、不预截断)。来源标识只在前端显示层加方括号品牌前缀(如 [WeChat] 周末计划讨论),不进 title 本体。

空会话#

创建即写第一条消息是原子的,正常不产生空会话;万一产生由启动清理或手动删除处理。命名层不对空会话做特殊过滤。

用户主动重命名#

  • 手动输入新名字 → update_session(session_id, title=新名字, _user_titled=True) 设 _user_titled 后自动命名永久停止。
  • 让 LLM 重新生成(点按钮,title 为空)→ _llm_rename() → update_session(session_id, title=LLM结果) 不设 _user_titled,自动命名继续。

LLM 标题生成的细节(prompt、参数、后处理)见 name.md。


列举#

前端发送 WebSocket 消息 {"action": "list_sessions"}
  → handle_list_sessions:
      → session_store.list_sessions():
          → 遍历内存 _index.values()
          → 除非 include_archived=True,否则丢掉已归档的行
          → 按 filters 过滤
          → 按 updated_at 降序排序
          → 返回 rows[offset:offset+limit]
      → 补充 project 字段(从项目目录映射)
      → 发送 {"type": "sessions_list", "data": rows}
  → 前端渲染侧边栏和 Chats 页面

纯内存操作,不碰磁盘。

已归档的行#

list_sessions 默认隐藏已归档会话:只有默认列举遵守归档,归档才真的能约束列表 长度。两种方式看到它们:

  • include_archived=True 同时返回已归档和活跃的行
  • archived=True 只返回已归档的

那些必须遍历每个会话、不管标志的维护流程(记忆扫描、运行态修复、"清空全部"、 渠道绑定查找、agent 寻址)显式传 include_archived=True。侧边栏 payload 也发送 全部行,前端在活跃/已归档/全部三个视图之间切换无需再请求一次。

每条 session 返回的字段#

注册表中的 15 个字段 + preview + project(列举时补充),共 17 个。完整列表见 storage.md。


删除#

调用方调 delete_session(session_id)
  → 删除 <state>/sessions/<session_id>/ 整个目录
  → 删除 _index[session_id]
  → 注册表原子写磁盘
  → 广播 session_deleted:
    {"type": "session_deleted", "session_id": "<session_id>"}
  → 前端收到后从列表中移除

注册表操作在 delete_session 内部完成。广播由 WebSocket handler 层通过 _broadcast 发起。


归档#

归档让会话列表不再无限增长。它只是 session meta 上的一个布尔标志:不删数据、 不搬目录,随时可逆。

调用方调 set_archived(session_id, True)
  → update_session(session_id, archived=True)
  → 走"更新字段"的完整流程
  → 前端收到广播后过滤显示

set_archived 对不存在的 session id 返回 False,CLI 和 REST 端点据此报告 会话不存在,而不是静默成功。

归档不动的东西#

updated_at 记录的是最后一次追加消息的时间,归档不追加任何消息,因此不碰这个 时间戳。这是索引一致性契约:取消归档后会话回到列表 里原来的位置,不会被顶到最上面。消息、分支、history 文件都不动,归档期间 get_messages 照常可读。

入口#

界面 操作
WebSocket {"action": "update_session_flags", "session_id": ..., "archived": true}
REST POST /api/sessions/archive / POST /api/sessions/unarchive,body {"session_id": ...}
CLI openprogram sessions archive <id> / openprogram sessions unarchive <id>

三个入口都经 set_archived 写同一个标志并广播 session_updated,所以每个 打开的标签页看到的状态一致。

已归档的 session 会被启动维护保留。它们默认不出现在列表中,直到显式删除前仍可访问。


注册表写磁盘(通用)#

所有注册表写磁盘操作都用原子写:

写入临时文件 index.json.tmp
  → os.rename(index.json.tmp, index.json)

防止崩溃导致文件损坏。

Last updated · 2026-10-10