跳到主要内容

数据截至 (上游 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: sessionIdsandboxId: 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
Email收到邮件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引擎门面:决定走快路径、排队还是幂等 claimsession-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 投一条新 promptsession-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)

同一个结构,六个入口填出六种性格:

入口sourceidempotencyKeyqueuePolicypostCreatevisibility吃并发上限
HTTP 面(project-sessions.ts:72-84)ui请求头 idempotency-key,可为 null缺省 never缺省 private
cron(lib/triggers.ts:1039)trigger:crontrigger:cron:{proj}:{slug}:{scheduleRevision}:{scheduledFor}on_backpressure应用触发器访问策略private,postCreate 再按策略放宽
webhook(r1.ts:130)trigger:webhooktrigger:webhook:{proj}:{slug}:{投递ID或体哈希}on_backpressure应用触发器访问策略private,postCreate 再按策略放宽
Slack(slack/session.ts:166)slackslack:threadcreate:{team}:{thread}on_backpressure绑线程看频道策略
Email(email/session.ts:223)emailemail:threadcreate:{inbox}:{thread}on_backpressure绑线程 + 投 promptproject
Telegram(telegram-webhook.ts:124)telegramtelegram:{proj}:{update_id}on_backpressureproject

读这张表能直接读出三条设计意图:

  1. 人点的按钮吃上限,机器触发的不吃。 所有自动化入口都传 enforceAccountCap: false——因为它们已经被 queuePolicy: 'on_backpressure' 管住了,再叠一层 429 只会让 cron 无声掉帧。
  2. 触发器会话的可见性"先锁后放"。 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)。
  3. 自动化 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.tssession_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语义
succeededdeduped已经建好了,把 session_id 给你
queuedqueued排着呢,可重试
runningpending正在建,可重试
其它(失败/死信)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;时隙由 claimDueScheduleSlotsproject_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-95waitForThreadSession:284)。

Slack 侧其实还有一道更靠前的闸:同一条用户消息可能以 app_mentionmessage 两个不同 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)发送它。也就是说:网页上的重复点击目前走的是快路径,不经过命令表,没有服务端去重。 幂等表实际在保护的是自动化入口。


5. 核心机制二:queuePolicy 三态与背压

它要解决的小问题

一个项目的 webhook 突然被打了 50 下。50 台沙箱同时开,provider 那边排大队,50 条 session 全卡在 provisioning,谁也不 ready。

三态怎么选

queuePolicy行为谁在用
never(缺省)从不排队,忙也硬上,忙不过就报错HTTP 面
on_backpressure先量一下水位,超了才排队全部自动化入口
always一律排队,不在请求路径上建号代码里目前无调用方

判定就一行(engine.ts:104-105):always 或者(on_backpressure 且水位超了)。

水位怎么量

sessionBackpressureState(session-lifecycle/backpressure.ts:10)在一个 Promise.all 里并发查三样,但只有前两样是阈值比较,第三样是用来算出第二条阈值线的:

查什么含义阈值 / 缺省值
项目在建数该项目处于 queued/branching/provisioning 的 session 数triggerBackpressureLimit(),缺省 3(backpressure.ts:5-8)
账户活跃数该账户处于 queued/branching/provisioning/running 的 session 数maxConcurrentSessionsForTier(tier)
账户档位 tier只作为上一行阈值的输入,自己不设线resolveAccountSessionLimit(shared/account-limits.ts:154)

前两样任一触线即排队(backpressure.ts:20)。

"活跃/在建"的状态集合被单独抽成一个零依赖模块(projects/lib/session-status.ts:10-12),注释里点明了原因:数据库有一条把这几个状态硬编码进 WHERE 的部分索引,改这里必须同步改迁移,否则并发上限的 COUNT 会悄悄退化成全表扫。

排队原因还会原样回传给用户——Slack 会据此说人话:是"这个工作区并发满了"还是"排在本项目正在启动的会话后面"(channels/slack/session.ts:255-261)。

队列谁来消费

每 60s 一 tick(仅 leader 节点)

├─▶ drainSessionLifecycleQueue({ limit: 10 })
│ └─ claimDueLifecycleCommands:选 queued 且到期且未被锁
│ └─ 逐行 UPDATE ... WHERE status='queued' ← 条件更新即抢锁
│ └─ 抢到 → status=running, lockedUntil=now+5min

└─▶ runProjectTriggerSweep() / app sweep / connector sweep
  • 消费入口:drainSessionLifecycleQueue(engine.ts:306),挂在 trigger 调度器的同一个 tick 上(triggers.ts:851),默认间隔 60 秒(triggerSchedulerIntervalMs,triggers.ts:286)。
  • 抢锁:claimDueLifecycleCommands(store.ts:635)先按 availableAt 升序选一批,再对每一行做带状态谓词的条件 UPDATE;返回空行就说明被别的 worker 抢走了。
  • 单点保证:整个调度器只在 leader 节点跑。leader 用一张 TTL 租约表选出来(apps/api/src/shared/leader-election.ts),startSingletonWorkersonAcquire 里启动调度器、onRelease 里停(apps/api/src/index.ts:1433-1458)。注释写明了不用 pg_advisory_lock 的理由:生产走 Supabase pooler 的事务模式,会话级 advisory lock 不可靠。

代价要说清楚: 排队命令的最坏延迟约等于一个调度 tick,即默认 ~60 秒。这是"排队"而不是"限流"的价格。

失败怎么退避

markCommandFailed(store.ts:540-617)的策略很朴素,但有两个细节值得抄:

  • 重试条件是 retryable && attempts < 5,不满足直接 dead_lettered——有死信,不会无限重试
  • 退避是线性的:availableAt = now + min(60s, 2s × attempts)。不是指数,而是很快撞到 60 秒天花板。

哪些错值得重试也写死了:429 / 500 / 502 / 503 / 504(isRetryableCreateError,engine.ts:1994-1996)——即"是我们忙,不是你错"。


6. 核心机制三:建号是同步的,开箱是异步的

直觉

用户按下按钮到看到界面,中间不能等一台虚拟机开机。所以这条路被切成两半:

createProjectSession
├─ 同步段(HTTP 要等) ───────────────────┐
│ 1. 选 provider(加权抽签) │
│ 2. 回调地址可达性自检 │ 几百毫秒
│ 3. 沙箱模板 slug 校验 │
│ 4. 并发上限 + 账单闸(并发跑) │
│ 5. INSERT project_sessions │
├────────────────────────────────────────┘
│ ← 到这里就 return,HTTP 201 已发出

└─ 异步段(void 一个 IIFE,没人 await)
├─ 解 git 凭据 ┐
├─ 解 base SHA ┼─ 并发
├─ 组装环境变量┘
├─ 后台推远端分支(纯发布动作,不影响就绪)
└─ provisionSessionSandbox → 沙箱 active → 会话 running

依据:同步段 apps/api/src/projects/lib/sessions.ts:861-1461;异步段是 sessions.ts:520 起那个 void (async () => { … })()

前置闸门的顺序是被算计过的

sessions.ts:441-450并发上限账单检查放进同一个 Promise.all——两者都是只读检查,串行跑白白多一次数据库往返;但注释同时强调:错误优先级保持不变,先返回 429(超并发)再返回 402(欠费)。这是"并发执行但保留串行语义"的标准手法。

另一个闸门是我很喜欢的一条自检:sandboxCallbackUnreachableReason()(sessions.ts:329)。云沙箱要回调控制平面,如果本机 KORTIX_URL 指着 localhost,沙箱一辈子也连不回来——但故障现象会推迟 60 秒才以"OpenCode runtime is not ready"的形式冒出来。所以它在建号前就把 host 判一遍回环地址,直接 503 + 一句能照做的提示(去开 dev tunnel)。把一个远端的、延迟的、症状误导的故障,前移成本地的、即时的、说人话的报错——这类改动的性价比往往被低估。

环境注入是一门"减法"

buildSessionSandboxEnvVars(sessions.ts:166)读起来像加法,实际上大半篇幅在删东西:

删掉什么为什么行号
SLACK_SIGNING_SECRET只用于验入站 webhook,沙箱里的 agent 永远用不到sessions.ts:211
SLACK_BOT_TOKENSlack 调用改走服务端 Executor,箱内不再需要原始令牌sessions.ts:217
保留名冲突的项目密钥一个叫 PATH 的密钥能悄悄搞死每一条 sessionsessions.ts:222-228
该 agent 的 env 白名单之外的密钥窄权限 agent 不该能从 $ENV 里读到别的作用域的 API keysessions.ts:233-251

反方向也有一条加法很关键:buildSessionChannelEnv(sessions.ts:145)会在每一次(重新)开箱时,从 metadata.slack 重新推导出频道绑定环境变量。因为一台被冷重建的箱子必须重新知道自己该往哪个 Slack 线程说话——session 行才是持久真相,沙箱只是它的一次投影

至于凭据闸门本身的设计,见 Executor 那一章;沙箱镜像和箱内进程见 沙箱内部

状态最终怎么翻成 running

沙箱行插入时是 provisioning;provider 创建成功后一并把 session_sandboxes.status = 'active'project_sessions.status = 'running' 写下去(platform/services/session-sandbox.ts:921-988)。注释点明了为什么能直接对齐:session_id == sandbox_id,查找是直连的。

从冬眠中被唤醒是另一条路:resumeStoppedSandbox 用一次元数据 CAS 当锁——WHERE status='stopped' 且唤醒租约不存在或已过期的条件更新,只有抢到租约的那个请求才真去启动虚拟机(行刻意保持 stopped,唤醒成功后由 finalize 事务翻成 active),并发轮询看到租约就直接退(projects/routes/shared.ts:70-146)。


7. 核心机制四:就绪判定 —— 一个可以反复问的问题

思路

不要设计"启动接口 + 状态接口"两个东西。设计一个幂等的 openSession:你随时可以问它"现在怎么样",它顺手把该做的修复做了,然后回你一个 stage。

依据:apps/api/src/projects/routes/shared.ts:788,openSession;它同时被 HTTP 的 /start(routes/r8.ts:77)和引擎内部的续话循环(engine.ts:186:242)使用。

判定流程

怎么读: 从上往下,命中即返回;左边是返回的 stage。

┌───────────────────────────────┐
│ 查 session_sandboxes 那一行 │
└───────────────┬───────────────┘

stopped 且可恢复 ──────────┤
├ 带 needsReprovision ─────▶ 退役旧箱 + 重新供给 ──▶ provisioning
└ 否 ───────────────────────▶ 原地 resume,继续往下

没有可用箱 ────────────────┤
├ session 是终态 ───────────▶ stopped / failed(retriable=false)
└ 否 ───────────────────────▶ 供给一台 ──────────▶ provisioning

箱在但 externalId 还没写 ──────────────────────────▶ provisioning

问 provider 真实状态 ──────┤
├ removed ─────────────────▶ 换一台 ────────────▶ provisioning
├ 不是 running ────────────▶ 后台叫醒 ──────────▶ starting
└ running ─────────────────▶ 解析 OpenCode 根会话
├ 还没起 ────────▶ starting
└ 拿到 pin ──────▶ ready
stage什么时候还能再问吗行号
provisioning没箱 / 箱还在建 / externalId 未落库shared.ts:500:511
startingprovider 说箱没在跑(空闲自停),或箱在跑但 OpenCode 还没服务shared.ts:570:598
ready箱在跑且 OpenCode 根会话 pin 已解析shared.ts:598
stopped / failedsession 行本身已是终态shared.ts:484

这里有一处刻意的不做:箱子状态还没确认时,openSession 不会去做 OpenCode 的重往返握手。注释解释得很清楚——那个探测在还在启动的箱子上要阻塞约 8 秒,而这个端点每秒被轮询一次(shared.ts:522-525)。等 provider 确认箱子在跑之后再做,守护进程就会快速回一个 503 not_ready,而不是耗满 8 秒(shared.ts:582-586)。便宜的检查放在热路径,昂贵的检查等条件成熟再做。

OpenCode 的"根会话"要选哪一个

ensureOpencodeSessionPin(apps/api/src/projects/opencode-mapping.ts:144)是 opencode_session_id 这一列的唯一权威写者;真正的选择逻辑是一个零依赖纯函数 pickCanonicalRoot(apps/api/src/projects/opencode-session-resolver.ts:38)。

选择规则是"最近活跃的那个根",不是"最早创建"的那个。注释里给了这个反直觉选择的理由:守护进程重启会留下两个根——一个卡在重启前那一轮、bash[running] 永远等不到完成事件的孤儿根,和一个 agent 真正续上的新根。按"最早"选,必然选中孤儿,于是 Slack 里的转圈永远不停。

而且 pin 一旦写下就粘住:resolveRootSessionId(opencode-session-resolver.ts:67)只要发现旧 pin 还存在就原样返回,不会因为 updated 时间漂移来回横跳。

长轮询:让客户端第一时间拿到 ready

客户端自己轮询有个固有浪费:哪怕服务端在第 10 毫秒就 ready 了,客户端也得等到自己下一个 ~800ms 的 tick 才知道。

解法是把等待搬到服务端:/start 接受 ?wait_ms,夹到 8 秒上限(routes/r8.ts:150-154),交给 startSession(engine.ts:157)。

# 示意,非源码 —— 有界长轮询
async def await_terminal(initial, resolve, wait_ms):
if wait_ms <= 0 or is_terminal(initial.stage): # 已经是终态,原样返回
return initial
deadline = now() + min(wait_ms, MAX_MS)
current = initial
while now() < deadline:
await sleep(POLL_MS)
nxt = await resolve() # 重新解析一次
if nxt is None: break
current = nxt
if is_terminal(current.stage): break
return current

重点看:已经终态就零成本原路返回——这保证了原来的"立刻 ready"快路径和所有不传 wait_ms 的调用方一行行为都没变

真实实现是 awaitTerminalStage(session-lifecycle/await-stage.ts:23),上限 START_AWAIT_MAX_MS = 8_000、节拍 START_AWAIT_POLL_MS = 200(await-stage.ts:8-9)。两个数字都有出处:8 秒卡在 Web 客户端 30 秒超时之内;200 毫秒之所以敢这么密,是因为每一 tick 只是一次便宜的重解析。

这个模块被刻意做成纯函数(只有 type-only import,now/sleepFn 可注入),所以它有不依赖服务端环境的单元测试(session-lifecycle/__tests__/await-terminal-stage.test.ts)。


8. 核心机制五:post-create 动作与"首条 prompt 一定要落地"

两种动作

建完号往往还欠几件事(types.ts:26-46):

动作干什么谁在用
bind_chat_threadchat_threads 写一条"这个线程 = 这个 session"的映射Slack、Email
deliver_prompt把首条 prompt 投进 OpenCodeEmail
apply_trigger_session_access建号后按触发器当前的访问策略放宽可见性cron、webhook

执行体是 applyPostCreateActions(engine.ts:1939)。绑线程那步用了 onConflictDoNothing,所以重放安全。

有意思的是 Slack 和 Email 的分歧:Slack 只绑线程,首条 prompt 走的是建号 body 里的 initial_prompt(即通过环境变量交给箱内);Email 则把首条 prompt 也做成 post-create 动作(channels/email/session.ts:243-256)。后者更慢但更可观测——投递失败是一个能被重试的命令状态,而不是一个消失的环境变量。

投递本身:两段有界等待

continueSession(engine.ts:367)是"往一条已有 session 说话"的唯一入口,它做的事按顺序是:

1. 查 session 行 ─ 没有 → 'no-session' / 已 failed → 'failed'
2. status 是 stopped/completed → 翻回 running(唤醒语义)
3. 循环 openSession 直到 ready ── 上限 300s,每 3s 一次 ← READY_DEADLINE_MS
└ 超时 → 'pending'(不是错误:等会儿它会好)
4. 拿 externalId + opencode pin,POST /session/{id}/prompt_async
└ 失败就 reopen 再试 ── 上限 45s,每 1.5s 一次 ← deliver.ts
5. 结果:'delivered' | 'pending' | 'no-session' | 'failed'

常量在 engine.ts:35-36(READY_DEADLINE_MS = 300_000POLL_INTERVAL_MS = 3_000)和 deliver.ts:22-23(45_000 / 1_500)。

deliverWithRetry(session-lifecycle/deliver.ts:38)的注释把它的存在理由写成了一个事故复盘:刚醒的沙箱有几秒钟是"抖"的——轮换过的 OpenCode 会话 404、守护进程还在绑端口时 5xx、externalId 短暂读成 null。旧代码在第一次抖动就退到 pending,Slack 于是对用户说"还在唤醒,再发一遍",把人家的消息丢了。现在的做法是:失败 → reopen() 重新解析(这一步顺带治好轮换的会话 id)→ 再投,直到 45 秒预算耗尽。

它同样是纯粹可注入的(now / sleepFn / deadlineMs 都能传),配套 __tests__/deliver.test.ts。连 postPrompt 里的连接被拒也被当成可重试的 miss 而不是抛出去(engine.ts:565-571)。

post-create 失败之后:这是幂等表最漂亮的一处收益

同样是"绑线程失败了",在两条路径上的下场完全不同:

路径结果能重放吗
快路径(无幂等键)返回 failed,retryable: false不能。session 已经建好了,但那次 post-create 永远丢了(engine.ts:70-78)
命令表路径markCommandFailed(retryable: true) 并把 sessionId 写回命令行

关键在重放时那一步检查:executeQueuedCreate 一开始就看 row.sessionId,如果上一轮已经建出了 session,就直接把它当作"已创建"返回,不再建第二个(engine.ts:362-379)。于是重放只重放欠着的 post-create。

第一次执行: 建号 ✅ ──▶ 绑线程 ❌ ──▶ 命令行记下 sessionId,status=queued

第二次(drain):executeQueuedCreate 看到 sessionId ──▶ 跳过建号
└──▶ 只重放 post-create ✅

一句话:幂等键把"创建"变成一次,同时把"创建之后的收尾"变成可以补偿的步骤。


9. 一生:完整状态流

project_sessions.status 只有 7 个取值(packages/db/src/schema/kortix.ts,projectSessionStatusEnum):

createProjectSession


┌─────────────┐
│provisioning │───── 开箱失败 ──▶ ┌────────┐
└──────┬──────┘ │ failed │(终态)
│ 沙箱 active └────────┘

┌──────────────▶┌─────────┐
│ │ running │
│ └────┬────┘
│ continueSession │
│ (唤醒) ├─ 空闲超时,reaper 停箱 ─┐
│ ├─ 卡死清理(无箱无回合) ─┤
│ └─ 用户删除 ───────────────┤
│ ▼
└──────────────────────────────────────── ┌─────────┐
│ stopped │(可复活)
└─────────┘

queuedbranching 也在枚举和"活跃"集合里(lib/session-status.ts:10),但在这条主线上不是常驻停留点。

回收:三张网

机制管什么判据代码
截止期 reaper跑着但过了死线的箱子deadline_at <= now() → 停;活跃回合续命projects/reaping/box-reaper.ts:195
卡死会话清理状态是活跃、身后却没有活箱的 session无 active 沙箱(或已打删除戳)+ 超过 TTL 未动 + 无未完成回合 + 窗口内无用量projects/reaping/stuck-sessions.ts:49
显式删除用户点删除session→stopped、箱→archived、provider 侧 removesession-lifecycle/actions.ts:40

箱子的生死现在只认一条规则:session_sandboxes.deadline_at <= now() 就停(reaping/box-reaper.ts:3)。deadline 由控制面自己记录的活跃回合续命——reaper 观察到那个确切的 OpenCode 回合在飞,才续;终端态证据只能把死线提前

这条规则是 2026-07-29 一次大扫除换来的。旧设计让被审判者自己提供证据:箱内 agent 每 60 秒续一个执行租约、一个忙探针、一个活动时钟——三个机制全由沙箱自己喂。实测生产上 187 台"真在跑"的箱子里 156 台从未产生过一条 LLM 用量、最老的 264 小时;idleObservedAt 在 100% 的活跃行上都是 null——空闲停机在生产上一次都没触发过。三个机制全删,deadline 取而代之(reaping/box-reaper.ts:9-20 的 WHAT THIS REPLACED 注释,纯决策在 reaping/policy.ts:1-31)。

被动流量永远不算活动。 开着的 tab、/v1/p 代理命中、反复的 /start 轮询都不续命——旧设计信了它们,曾让空闲箱子白活好几天(实测 2026-06-21:1,597 条幽灵活跃计费行,box-reaper.ts:21-23)。配套地,"已停的箱子保持停":/v1/p 的 heal 现在拒绝任何死线已过的行,不再需要旧的 idleQuiesced 标记——同一批行,少一份状态(reaping/sandbox-state-sync.ts:384-387)。

对"provider 说它没在跑"的箱子,判定仍是纯函数 decideReconcile(reaping/policy.ts:36),它的一条规则值得单独抄下来:

provider 状态是 unknown / 中间态 → 什么都不做。

依据:reaping/policy.ts:32-35。瞬时错误和 starting / resuming / migrating 都算 unknown,基于它去停机可能杀掉一台健康的箱子,或者跟一次正在进行的唤醒打架。(注意 terminal 不算 unknown——死透了和停了一样可处置,这是同一次扫除补上的洞:policy.ts:44-51。)

第二张网(reconcileStuckActiveSessions)是并发上限的排水口:它补的是箱子 reaper 的结构性盲区——一个 session 状态是 running/provisioning 但身后的 session_sandboxes 行已不 active,箱子 reaper 永远不会访问它,它就永久吃掉一个并发名额(单账户曾积到 200+ 把整户堵死,stuck-sessions.ts:1-13)。这网是纯数据库操作,provider 在限流也照样排水;它的 UPDATE 语句里重复了一遍状态谓词,防止和一次真实的打开操作打架(stuck-sessions.ts:87-92)。

两张网都挂在 leader 的维护循环上(projects/maintenance.ts:252:273,由 startProjectMaintenance 的定时器驱动,:533)。


10. 巧妙之处(可以直接抄走的)

  1. 把"入口"和"策略"彻底分开。 六个入口只填一个数据对象,并发上限、背压、幂等、账单闸全在引擎里。新加一个入口(比如某天的 Discord)不需要理解这些策略,只需要回答四个问题:我叫什么 source、我的幂等键怎么造、忙了怎么办、建完还欠什么。

  2. 幂等键选"业务时隙"而不是"这次调用"。 cron 用排程时隙+修订号(lib/triggers.ts:1173)、webhook 用投递 ID 或报文哈希(r1.ts:121-138)、Slack 用线程坐标(session.ts:86)。键要锚在"这件事"上,而不是"这次尝试"上,超时后的迟到成功才不会变成重复。

  3. 失败也要区分"我错了"和"我们忙"。 isRetryableCreateError 只把 429/5xx 当可重试(engine.ts:1994);markCommandFailed 有 5 次上限和死信(store.ts:550)。既不放弃太早,也不重试到天荒地老。

  4. 纯函数化关键循环。 awaitTerminalStagedeliverWithRetrydecideReconcileisSweepStalepickCanonicalRoot 都把时钟和 IO 做成可注入参数,于是这些最难复现的时序逻辑有了不吃 wall-clock 的单元测试。

  5. 把远端的、延迟的故障前移成本地的、即时的报错。 sandboxCallbackUnreachableReason()(sessions.ts:329)是这条原则的教科书例子。

  6. 调度器要能证明自己活着。 getTriggerSchedulerHealth(lib/triggers.ts:256)把最近一次扫描的开始/结束/耗时/结果暴露到 /health;isSweepStale(lib/triggers.ts:368)是一个纯函数化的停摆判据,并且特意从"开始时间"而不是"完成时间"起算——否则新 leader 那次合法的长扫描一开始就会被误判为停摆。这些都是 2026-06-21 那次"一次挂起的触发拖垮全队 18 小时"事故的直接产物(lib/triggers.ts:273-280 注释;硬上限函数紧跟在它后面,:282 起)。

  7. 超时的守卫本身也要能自愈。 扫描用了一个内存中的 in-flight 标志,但如果持有它的那一轮超过硬上限还没完成,下一 tick 会强行回收这个标志并重新开始(triggers.ts:333-343)。守卫不能变成新的单点。


11. 边界与局限(诚实清单)

  • 网页端的重复点击目前没有服务端去重。 idempotency-key 头被支持,但这份克隆里没有任何自带客户端发送它;快路径不落命令表(engine.ts:108)。
  • 排队命令的延迟是一个调度 tick。 消费只发生在 leader 的 60 秒定时里(triggers.ts:851),没有"入队即唤醒"的信号通路。
  • 退避是线性且很快封顶的(min(60s, 2s × attempts),store.ts:558),对持续性的 provider 故障不算温柔。
  • session_mode = "reuse" 的触发没有幂等保护。 复用路径直接调 continueSession(triggers.ts:633),而 continueSession 里没有任何命令行/幂等键;同时扫描把整次触发用 45 秒硬上限包住(triggerFireTimeoutMs,triggers.ts:187),continueSession 自己却可以等到 300 秒。所以一次投向冷箱的复用触发会先被记成失败、下一 tick 重来,而上一次的投递仍在后台继续——同一条 prompt 有被投两次的空间(inferred:代码没有直接声明这个结果,这是由两个超时预算的差值推出的)。
  • queuePolicy: 'always' 目前无人使用,是留给未来的口子。
  • withTimeout 不能真的取消。 注释自己讲明了:JS 没有 promise 取消,超时只是让调用方能往前走,底下的活还在跑(triggers.ts:210-214)。
  • 本章不覆盖沙箱内部的进程与镜像(见 第 3 章)、git 代理与 change request 闸门(见 第 4 章)、连接器凭据(见 第 5 章)、模型与计费(见 第 6 章)。

12. 代码地图(导航索引)

主题文件路径符号名
命令对象与状态枚举apps/api/src/projects/session-lifecycle/types.tsCreateSessionCommandSessionInvocationSourceQueuePolicySessionLifecycleStatusSessionLifecyclePostCreateAction
引擎主入口apps/api/src/projects/session-lifecycle/engine.tscreateSessionexecuteCreateSessionapplyPostCreateActionscontinueSessionstartSessiondrainSessionLifecycleQueueexecuteQueuedCreateisRetryableCreateErrorpostPrompt
引擎常量apps/api/src/projects/session-lifecycle/engine.tsWORKSPACEDAEMON_PORTREADY_DEADLINE_MSPOLL_INTERVAL_MS
命令表(幂等 + 队列)apps/api/src/projects/session-lifecycle/store.tsclaimCreateSessionCommandresultFromExistingCommandmarkCommandQueuedmarkCommandSucceededmarkCommandFailedclaimDueLifecycleCommands
背压水位apps/api/src/projects/session-lifecycle/backpressure.tssessionBackpressureStatetriggerBackpressureLimit
有界长轮询apps/api/src/projects/session-lifecycle/await-stage.tsawaitTerminalStageisTerminalStageSTART_AWAIT_MAX_MS
投递重试apps/api/src/projects/session-lifecycle/deliver.tsdeliverWithRetryDeliveryTarget
自动化身份apps/api/src/projects/session-lifecycle/actor.tsresolveProjectAutomationActor
删除与重启apps/api/src/projects/session-lifecycle/actions.tsdeleteSessionrestartSession
HTTP 建号入口apps/api/src/projects/routes/project-sessions.tsPOST /{projectId}/sessions 处理器
HTTP 就绪入口apps/api/src/projects/routes/r8.tsPOST /{projectId}/sessions/{sessionId}/start(wait_ms 夹紧)
就绪编排apps/api/src/projects/routes/shared.tsopenSessionSessionStartResultSessionStartStageresumeStoppedSandboxreplaceStaleRuntimeOnOpenretireSessionSandboxRow
建号核心apps/api/src/projects/lib/sessions.tscreateProjectSessionenforceConcurrentSessionCapcheckConcurrentSessionCapcountActiveProjectSessionsbuildSessionSandboxEnvVarsderiveKortixApiBaseproxyGitUrlsandboxCallbackUnreachableReason
状态集合(零依赖)apps/api/src/projects/lib/session-status.tsACTIVE_SESSION_STATUSESPROVISIONING_SESSION_STATUSES
触发器验签与渲染apps/api/src/projects/lib/triggers.tsverifyWebhookSignatureverifyWebhookTokenextractWebhookTokenrenderPromptTemplatenextCronRun
触发器触发与扫描apps/api/src/projects/lib/triggers.tsfireGitTriggerfindReusableTriggerSessionrunProjectTriggerSweeprunGitTriggerSweepstartProjectTriggerSchedulerwithTimeout
调度器健康与硬上限apps/api/src/projects/lib/triggers.tsgetTriggerSchedulerHealthisSweepStaleschedulerSweepIsStaletriggerBackpressureStatetriggerFireTimeoutMstriggerLoadTimeoutMstriggerSweepTimeoutMs
webhook 公开入口apps/api/src/projects/routes/r1.tsprojectWebhooksApp.post('/projects/:projectId/:slug')
leader 选举apps/api/src/shared/leader-election.tsstartLeaderElectionisLeaderrunsSingletonWorkersshouldDemoteinterpretAcquireResult
单例 worker 装配apps/api/src/index.tsstartSingletonWorkersstopSingletonWorkersbootServices
Slack 建号/复用apps/api/src/channels/slack/session.tscreateOrJoinThreadSessiondeliverSlackFollowUpToSessionclaimThreadCreatewaitForThreadSession
Slack 恰好一次apps/api/src/channels/slack/dedup.tsinboundMessageKeyclaimInboundMessagealreadyHandledclaimThreadErrorNotice
Slack 事件分发apps/api/src/channels/slack/dispatch.tsdispatchSlackEventspawnAgentTurnclassifyEvent
Email 入口apps/api/src/channels/email/session.tscreateThreadSessionspawnEmailAgentTurn
Telegram 入口apps/api/src/channels/telegram-webhook.tstelegramWebhookAppspawnAgentTurn
沙箱供给apps/api/src/platform/services/session-sandbox.tsprovisionSessionSandbox
provider 加权选择apps/api/src/platform/services/provider-balancer.tsselectProviderinvalidateProviderDistributionCache
初始化状态机apps/api/src/platform/services/sandbox-init-state.tsderiveSandboxInitStatusSANDBOX_INIT_MAX_ATTEMPTSretrySandboxProvisionCreate
供给耗时打点apps/api/src/platform/services/provision-timeline.tsProvisionTimeline
守护进程就绪探测apps/api/src/projects/lib/sandbox-daemon-ready.tswaitForDaemonOpencodeReadydaytonaPreviewHeaders
OpenCode 根会话apps/api/src/projects/opencode-mapping.ts / opencode-session-resolver.tsensureOpencodeSessionPinpickCanonicalRootresolveRootSessionId
标题同步与转录apps/api/src/projects/opencode-title-sync.ts / lib/session-transcript.tssyncOpenCodeTitlesForSessionsbuildSessionTranscriptDigest
回收apps/api/src/projects/reaping/reapAndReconcileSandboxes(box-reaper.ts)、decideReconcile(policy.ts)、reconcileStuckActiveSessions(stuck-sessions.ts);统一入口在 apps/api/src/projects/sandbox-reaper.ts
维护定时器apps/api/src/projects/maintenance.tsstartProjectMaintenancesweepExpiredSessionBranches
命令表 schemapackages/db/src/schema/kortix.tssessionLifecycleCommandssessionLifecycleCommandStatusEnumprojectSessionStatusEnum