projects.py

首先来看cairn\src\cairn\server\routers\projects.py
首先开头注册了一个POST /projects 接口。创建成功时返回201。
@router.post("/projects", response_model=ProjectDetail, status_code=201)
接着传入请求体body: CreateProjectRequest

函数中,首先进行数据库连接,并进入上下文管理器。
with get_conn() as conn:
接着向 projects 表插入项目基本信息。新项目的状态固定为 active:

1
2
3
4
5
6
conn.execute(
"INSERT INTO projects "
"(id, title, status, bootstrap_enabled, created_at) "
"VALUES (?, ?, 'active', ?, ?)",
(pid, body.title, body.bootstrap_enabled, now),
)

每个项目都会自动创建两条基础事实:

1
2
3
4
5
6
7
8
9
conn.execute(
"INSERT INTO facts (id, project_id, description) VALUES (?, ?, ?)",
("origin", pid, body.origin),
)

conn.execute(
"INSERT INTO facts (id, project_id, description) VALUES (?, ?, ?)",
("goal", pid, body.goal),
)

所有数据写入完成后,接口返回一个 ProjectDetail 对象。
整体流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
接收并验证请求

生成项目 ID 和创建时间

写入项目基本信息

写入 origin 和 goal

写入可选提示

构造 ProjectDetail

返回 HTTP 201 响应

app.py 和 Router

接下来查看 cairn\src\cairn\server\app.py

app.py 是 Cairn 服务端的总入口,主要负责创建 FastAPI 应用,并把分散在各个 router 文件中的接口组装起来。创建项目的具体业务逻辑并不在 app.py 中,而是在 routers/projects.py 中。

app.py 首先导入各个接口模块:

1
from cairn.server.routers import export, hints, intents, projects, settings

然后通过 include_router() 将这些模块中的接口注册到 FastAPI 主应用:

1
2
3
4
5
app.include_router(settings.router)
app.include_router(projects.router)
app.include_router(hints.router)
app.include_router(intents.router)
app.include_router(export.router)

projects.py 为例,文件中的接口由 projects.router 收集:

1
2
3
@router.post("/projects", response_model=ProjectDetail, status_code=201)
def create_project(body: CreateProjectRequest):
...

但仅仅定义接口还不够。执行下面这行代码后,项目接口才会被加入 FastAPI 应用的路由表:

1
app.include_router(projects.router)

因此,projects.py 能被外部访问,需要经过两个步骤:

1
2
3
4
5
6
7
8
9
projects.py 定义接口

projects.router 收集接口

app.include_router(projects.router)

接口注册到 FastAPI 主应用

外部可以通过 HTTP 请求访问

include_router() 的作用是注册接口,而不是执行业务。服务器启动时,它只负责建立路由关系;只有客户端真正发送 POST /projects 请求时,FastAPI 才会执行 create_project()

Cairn 当前注册了五个 router:

Router 文件 主要职责
settings.router settings.py 配置相关接口
projects.router projects.py 项目相关接口
hints.router hints.py 提示相关接口
intents.router intents.py 意图相关接口
export.router export.py 数据导出相关接口

除此之外,app.py 还直接定义了首页接口,并通过 app.mount() 提供静态文件。这些内容不属于上面的 router 模块。

serve 和 dispatch

接下来查看 cairn\src\cairn\cli.py 中的两个命令:servedispatch

这两个命令会启动不同的程序,承担的职责也不同:

1
2
cairn serve     → 启动 API server,保存状态并提供接口
cairn dispatch → 启动 dispatcher,选择任务并调用 worker

serve:启动 API server

serve 命令先配置 SQLite 数据库路径,然后导入 FastAPI 应用,最后交给 Uvicorn 启动:

1
2
3
4
5
6
7
8
9
10
11
def serve(host: str, port: int, db_path: str, log_level: str, access_log: bool):
db.configure(Path(db_path))
from cairn.server.app import app

uvicorn.run(
app,
host=host,
port=port,
log_level=log_level.lower(),
access_log=access_log,
)

因此,cairn serve 主要负责:

  • 启动 HTTP 服务;
  • 保存和读取项目状态;
  • 提供创建项目、查询项目、更新 intent 等 API;
  • 接收网页、用户或 dispatcher 发来的请求。

它本身不会启动调度循环,也不会主动选择 AI worker。

dispatch:启动调度器

dispatch 命令要求提供 dispatcher 配置文件,然后创建并运行 DispatcherLoop

1
2
3
4
5
6
7
8
9
def dispatch(config_path: Path, once: bool, startup_healthcheck_only: bool, log_level: str):
configure_logging(log_level, bare=startup_healthcheck_only)
loop = DispatcherLoop(config_path)

if startup_healthcheck_only:
loop.run_startup_healthchecks_only()
return

loop.run(once=once)

DispatcherLoop 会读取配置中的 server 地址,并创建一个 CairnClient

1
2
self.config = DispatchConfig.load(config_path)
self.client = CairnClient(self.config.server)

CairnClient 通过 HTTP 请求访问 server,例如查询项目和读取设置:

1
2
response = self._session().get(self._url("/projects"), timeout=self._timeout)
response = self._session().get(self._url("/settings"), timeout=self._timeout)

这说明 dispatcher 不会直接读写 server 的 SQLite 数据库,而是通过 API 与 server 协作。

dispatcher 的大致工作流程如下:

1
2
3
4
5
6
7
8
9
10
11
通过 API 查询项目状态

判断哪些项目可以继续推进

选择 bootstrap、explore 或 reason 等任务

选择可用 worker

调用 worker 执行任务

通过 API 把结果写回 server

只启动 server,AI 会自动工作吗?

不会。

只启动 server 后,API 和数据库可以正常工作,用户可以创建或查询项目,但没有 dispatcher 主动扫描项目、选择任务和调用 worker,所以 AI 不会自动推进项目。

可以把 server 理解为保存状态的工作台,而 dispatcher 才是不断检查工作台并分配任务的人。

只启动 dispatcher,没有 server 会怎样?

dispatcher 会尝试访问配置中的 server 地址。没有 server 时,它无法读取设置、获取项目或写回任务结果,因此不能推进任何项目。

在普通循环模式下,请求失败会被记录,dispatcher 等待一个调度间隔后继续重试:

1
2
3
4
except requests.RequestException as exc:
LOG.warning("dispatcher server request failed ...")
time.sleep(self.config.runtime.interval)
continue

如果使用 --once 只运行一轮,请求异常会继续抛出,命令通常会直接失败退出。

因此,正常运行时应该先保证 server 可访问,再启动 dispatcher。

为什么 server 不直接调用 AI?

server 的核心职责是维护项目状态和提供稳定的 API。如果把 AI 调用也放进 server,请求处理就会和耗时较长、资源占用较高的 worker 执行耦合在一起。

将它们拆开后,可以获得几个好处:

  • API server 不会因为 AI 任务运行时间较长而被阻塞;
  • worker 的模型配置、密钥和运行环境可以由 dispatcher 单独管理;
  • 调度失败不会直接导致 API server 停止服务;
  • server 可以专注保证状态读写和接口行为的一致性。

为什么 dispatcher 要作为单独进程?

dispatcher 是一个长期运行的调度循环。它会定期查询项目、维护任务状态、使用线程池执行任务,并管理 worker 或容器。这类工作与处理 HTTP 请求的 server 有不同的运行方式和资源需求。

拆成独立进程后,server 和 dispatcher 可以分别启动、停止、重启和配置。即使 dispatcher 或某个 worker 出现故障,server 中已经保存的项目状态仍然可以继续访问。

二者的关系可以总结为:

1
2
3
4
5
6
7
用户或网页
↓ HTTP
server:保存状态、提供 API
↑ HTTP
dispatcher:读取状态、选择任务、调用 worker、写回结果

AI worker

最重要的一句话是:server 管状态,dispatcher 管推进。