数据截至 (上游 commit e55b2a12c9a5)
一次 session 的一生:从任一入口到沙箱就绪
30 秒导读: Kortix 让 agent 干活的最小单位叫 session——一个隔离沙箱 + 一条 git 分支 + 一个 OpenCode 会话。你在网页点"新会话"、Slack 里 @ 一句、cron 到点、webhook 打进来,走的都是同一条创建路径。本章讲这条主线:入口怎么汇流、重复投递怎么被压成一次、算力不够时怎么排队、以及一条 session 从建号、就绪、投第一条 prompt 到被回收的完整状态流。
上一章讲了 kortix.toml 怎么把一家公司写成一份声明;声明里那些 agent、trigger 要真跑起来,就得先有一条 session。本章讲的就是这一步。
1. 先讲清楚:一条 session 到底是什么
一句话定义: session 是"给 agent 干一件事的一次性工位"——一台沙箱、一条专属 git 分支、一段可回放的对话。
Kortix 在这里立了一条贯穿全系统的不变量:
session_id == sandbox_id == git 分支名依据:
apps/api/src/projects/routes/project-sessions.ts:68(session 路由区的开篇注释)
这条不变量的分量比它看起来重。因为三者同名,任何一处拿到 session_id 就能直接寻址另外两处:要找沙箱不用查表,要找分支不用映射。建号时那行 insert 就是这么写死的——branchName: sessionId、sandboxId: sessionId(apps/api/src/projects/lib/sessions.ts:1367-1378,createProjectSession)。
谁会造 session? 六个入口,一个都不能少:
| 入口 | 谁在用 | 代码位置 |
|---|---|---|
HTTP POST /:projectId/sessions | 网页 / CLI / 移动端 | apps/api/src/projects/routes/project-sessions.ts:72-84 |
| cron 定时 | 调度器从 trigger 运行时目录(由 manifest 同步落库)领到期时隙 | apps/api/src/projects/lib/triggers.ts:1039 |
| webhook | 外部系统签名投递 | apps/api/src/projects/routes/r1.ts:153 |
| Slack | 频道里 @ 机器人 | apps/api/src/channels/slack/session.ts:166 |
| 收到邮件 | apps/api/src/channels/email/session.ts:223 | |
| Telegram | 收到消息 | apps/api/src/channels/telegram-webhook.ts:124 |
还有一个只续话不建号的入口:语音会话(LiveKit voice worker)经 MCP ask_kortix 只调 continueSession(apps/api/src/channels/voice/runtime.ts:334)。
2. 顶层全景:六个入口,一个引擎
怎么读这张图: 从左到右是一次创建的时间顺序;虚线框是"异步继续跑"的部分,HTTP 响应不等它。
入口层 汇流点 引擎 供给
┌──────────┐
│ HTTP 面 │──┐
├──────────┤ │
│ cron │──┤
├──────────┤ │ ┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ webhook │──┼───▶│CreateSessionCmd │──▶│ createSession│──▶│createProjectSes-│
├──────────┤ │ │ (一个命令对象) │ │ (引擎) │ │sion:插一行 →返回│
│ Slack │──┤ └─────────────────┘ └──────┬───────┘ └────────┬────────┘
├──────────┤ │ │ ┆
│ Email │──┤ ┌──────┴───────┐ ┆(异步)
├──────────┤ │ │命令表 claim │ ▼
│ Telegram │──┘ │幂等键/排队 │ ┌─────────────────┐
└──────────┘ └──────┬───────┘ │provisionSession-│
│ │Sandbox:开箱 │
┌──────┴───────┐ └────────┬────────┘
│post-create: │ │
│绑线程/投prompt│◀───────────┘
└──────────────┘
各部件一句话职责:
| 部件 | 干什么 | 文件 |
|---|---|---|
CreateSessionCommand | 入口唯一要填的东西:一个纯数据对象 | session-lifecycle/types.ts:56 |
createSession | 引擎门面:决定走快路径、排队还是幂等 claim | session-lifecycle/engine.ts:96 |
| 命令表 store | 把命令落库、claim、标记成败,兼作幂等锁 | session-lifecycle/store.ts |
backpressure | 算"现在该不该排队" | session-lifecycle/backpressure.ts:10 |
createProjectSession | 真正建号:前置闸门 + 插行 + 异步开箱 | projects/lib/sessions.ts:687 |
provisionSessionSandbox | 向 provider 要一台机器 | platform/services/session-sandbox.ts:298 |
openSession | 一次调用回一个就绪 stage,可反复调 | projects/routes/shared.ts:788 |
continueSession | 往已有 session 投一条新 prompt | session-lifecycle/engine.ts:367 |
主线走一遍(不进代码): 入口填好命令 → 引擎判断要不要排队 / 要不要走幂等表 → 建号(数据库里立刻多一行 provisioning 的 session)→ HTTP 立刻返回 → 后台异步开沙箱 → 客户端(或引擎自己)反复问 openSession 直到 ready → 首条 prompt 投进 OpenCode → agent 开干 → 空闲够久被 reaper 停机 → 下次打开原地复活。
3. 汇流的形状:入口只负责"填表"
这套设计最值钱的地方在于入口不写业务。Slack 的代码不知道什么叫并发上限,cron 的代码不知道沙箱怎么开——它们只负责把自己那点上下文塞进同一个结构体。
CreateSessionCommand 的关键字段(apps/api/src/projects/session-lifecycle/types.ts:56-80):
| 字段 | 干什么 |
|---|---|
source | 我是谁。17 种取值的联合类型(types.ts:6-22),从 'ui' 到 'trigger:cron' 到 'system:connector-connected' |
idempotencyKey | 我这次投递的身份证。填了才进命令表,不填走快路径 |
queuePolicy | 忙的时候怎么办:never / on_backpressure / always(types.ts:24) |
postCreate | 建完号还要做什么:绑聊天线程、投首条 prompt、应用触发器访问策略(types.ts:26-46) |
visibility | 这条 session 归谁看:private / project / restricted |
enforceAccountCap | 要不要吃并发上限这一刀 |
extraEnvVars | 只有这个入口知道的环境变量(如 Slack 的 thread_ts) |
同一个结构,六个入口填出六种性格:
| 入口 | source | idempotencyKey | queuePolicy | postCreate | visibility | 吃并发上限 |
|---|---|---|---|---|---|---|
HTTP 面(project-sessions.ts:72-84) | ui | 请求头 idempotency-key,可为 null | 缺省 never | 无 | 缺省 private | 是 |
cron(lib/triggers.ts:1039) | trigger:cron | trigger:cron:{proj}:{slug}:{scheduleRevision}:{scheduledFor} | on_backpressure | 应用触发器访问策略 | 先 private,postCreate 再按策略放宽 | 否 |
webhook(r1.ts:130) | trigger:webhook | trigger:webhook:{proj}:{slug}:{投递ID或体哈希} | on_backpressure | 应用触发器访问策略 | 先 private,postCreate 再按策略放宽 | 否 |
Slack(slack/session.ts:166) | slack | slack:threadcreate:{team}:{thread} | on_backpressure | 绑线程 | 看频道策略 | 否 |
Email(email/session.ts:223) | email | email:threadcreate:{inbox}:{thread} | on_backpressure | 绑线程 + 投 prompt | project | 否 |
Telegram(telegram-webhook.ts:124) | telegram | telegram:{proj}:{update_id} | on_backpressure | 无 | project | 否 |
读这张表能直接读出三条设计意图:
- 人点的按钮吃上限,机器触发的不吃。 所有自动化入口都传
enforceAccountCap: false——因为它们已经被queuePolicy: 'on_backpressure'管住了,再叠一层 429 只会让 cron 无声掉帧。 - 触发器会话的可见性"先锁后放"。 cron/webhook 建号时显式
visibility: 'private',再由 postCreate 动作apply_trigger_session_access按触发器当前的账户级访问策略(private / members / project)放宽——排队中的建号因此永远不会捕获过期策略(apps/api/src/projects/lib/triggers.ts:1041-1076、策略解析在apps/api/src/projects/trigger-session-access-policy.ts:24-40)。 - 自动化 session 挂在兜底身份名下。 它以"账户 owner 兜底身份"归档(
resolveProjectAutomationActor,session-lifecycle/actor.ts:7),如果一直按private存,全团队只有第一个 owner 能看见它——注释里写得很直白(projects/lib/sessions.ts:702-707)。
4. 核心机制一:幂等键 + 命令表 = 重复投递收敛成一次创建
它要解决的小问题
用户手抖点两下"新建会话";Slack 把同一条 @ 消息用两个 event 投给你;GitHub webhook 因为你响应慢了 10 秒,原样重投一遍。每一次重复,如果都老老实实建号,就是多开一台真机、多烧一笔钱、多一个跟自己抢答的 agent。
思路
不要在每个入口各写一套去重。把"这次投递"抽象成一条命令行,给它一个字符串身份证(幂等键),数据库上对这一列建唯一索引——于是"谁先谁后"这个分布式难题,退化成一次 INSERT … ON CONFLICT DO NOTHING 的胜负。
依据:packages/db/src/schema/kortix.ts 中 session_lifecycle_commands 表的 uniqueIndex('idx_session_lifecycle_commands_idempotency')。
三条路径
createSession(cmd)
│
├─ 没填 idempotencyKey 且不需排队 ──▶ 直接建号(不落命令表) ← 快路径
│
└─ 填了 key(或需排队)
│
└─ INSERT ... ON CONFLICT DO NOTHING
│
├─ 插进去了(existing=false) ──▶ 我是唯一执行者,建号
│
└─ 冲突了(existing=true) ──▶ 读出已有行,把它的 状态
翻译成本次的返回值,不建号
原理演示
# 示意,非源码 —— 幂等 claim 的骨架
def claim(key, payload):
row = db.insert("commands", key=key, payload=payload, on_conflict="do_nothing")
if row:
return row, False # 我抢到了,归我执行
return db.select_by_key(key), True # 别人先到,我复用他的结果
重点看:抢不到不是错误,而是"复用"。
真实实现
claimCreateSessionCommand(apps/api/src/projects/session-lifecycle/store.ts:337)先按有无 key 分岔:无 key 直接 insert 返回;有 key 则 onConflictDoNothing({ target: sessionLifecycleCommands.idempotencyKey }),插空了就回查那一行并标 existing: true(store.ts:361-378)。
抢输的一方交给 resultFromExistingCommand(store.ts:381)把已有行的状态翻译成本次结果:
| 已有命令的状态 | 翻译成的 SessionLifecycleStatus | 语义 |
|---|---|---|
succeeded | deduped | 已经建好了,把 session_id 给你 |
queued | queued | 排着呢,可重试 |
running | pending | 正在建,可重试 |
| 其它(失败/死信) | failed | 不可重试 |
引擎侧还多做一步贴心事:如果已有命令带 session_id,顺手把 project_sessions 那一行也捞出来塞进返回值,调用方无需二次查询(engine.ts:223-227);那一小段里还顺带处理了"软删除的 session 不能当建号成功回给幂等键"的坑(engine.ts:228-235)。
各入口的幂等键怎么造(这里最见功力)
cron 用"排程时隙"而不是"这一 tick":
键 =
trigger:cron:{projectId}:{slug}:{scheduleRevision}:{scheduledFor},其中scheduledFor是数据库里领到的那个到期时隙,scheduleRevision是排程配置的修订号——cron 表达式被改过,键就换新。依据:
apps/api/src/projects/lib/triggers.ts:1173;时隙由claimDueScheduleSlots从project_trigger_runtime表按nextFireAt领取(apps/api/src/projects/trigger-execution-store.ts:59-95),错过的多个时隙会被合并成一次补跑,防止重启后触发风暴(trigger-execution-store.ts:94-97)
差别在哪?如果用"这一 tick 的时间戳"当键,上一轮超时但其实慢慢跑成功了的那次触发,下一轮会拿到一个新键、再建一台机器。用排程时隙当键,两轮算同一个时隙、同一个键,第二次直接 deduped。
webhook 双策略:优先用投递方给的 ID(x-kortix-delivery-id / x-github-delivery / x-request-id);一个都没有,就退化成对 原始报文 + 签名头 + 静态令牌指纹 求 SHA-256 当键(apps/api/src/projects/routes/r1.ts:121-138)。也就是说内容相同的重投必然同键。
Slack 把"聊天线程创建权"直接当成幂等键。它先用一张共享去重表抢线程(claimThreadCreate,channels/slack/session.ts:267),抢到的那个 key 原样传给 idempotencyKey(session.ts:183)。抢输的一方不建号,而是轮询等赢家把 chat_threads 映射写出来,然后把自己这条消息当跟进消息投进同一个 session(session.ts:86-95、waitForThreadSession 在 :284)。
Slack 侧其实还有一道更靠前的闸:同一条用户消息可能以 app_mention 和 message 两个不同 event_id 到达,所以真正的"恰好一次"锚点是消息坐标 (team, channel, ts)——inboundMessageKey / claimInboundMessage(channels/slack/dedup.ts:38、:52),在 dispatchSlackEvent 里前置拦截(channels/slack/dispatch.ts:646-647)。
一个诚实的坑
HTTP 面支持 idempotency-key 请求头(project-sessions.ts:151-156,含格式校验),但在这份克隆里 grep 不到任何自带客户端(web / cli / mobile / desktop)发送它。也就是说:网页上的重复点击目前走的是快路径,不经过命令表,没有服务端去重。 幂等表实际在保护的是自动化入口。