数据截至 (上游 commit 60706feb348c)
03 · AgentManager:生命周期状态机与时间线唯一真相
30 秒导读: 手机、网页、CLI 可能同时连着同一个 agent。谁说了算?daemon 进程里的 一个
AgentManager实例。它持有每个 agent 的活状态、每条消息的顺序号,以及"这条事件属于哪一轮对话"的判定。别的部件全部是它的读者。
本章讲 daemon 内部这个唯一真相源:状态怎么迁移、事件怎么入库、时间线怎么编号。 客户端拿到这些数据之后怎么消费、怎么重放,见 04-client-sync; provider 事件在进入本章之前怎么被归一化,见 02-provider-normalization。
1. 它要解决的小问题
一个 agent 会话有两种数据,寿命完全不同:
| 数据 | 例子 | 活多久 |
|---|---|---|
| 活状态 | 现在是不是在跑、有没有待批权限、当前模型是谁 | 进程内,随 session 生死 |
| 时间线 | 用户说了什么、助手回了什么、调了哪些工具 | 要能翻历史、要能跨设备对齐 |
难点不在"存下来",而在并发。同一时刻会有三股写入:provider 进程异步吐事件、用户从手机发新 prompt、另一个 agent 通过 MCP 工具往里塞东西。如果每个客户端各自拼装顺序,两台设备就会看到不一样的对话。
Paseo 的答案很直白:只留一个写入口。所有写路径都收敛到 AgentManager,由它单线程地编号、盖章、再广播。
2. 顶层全景:六个抽屉
AgentManager 的字段就是它的职责清单(packages/server/src/server/agent/agent-manager.ts:666-686):
| 字段 | 装什么 | 一句话 |
|---|---|---|
clients | provider → AgentClient | 五家 CLI 的适配器,按 provider 查 |
agents | agentId → LiveManagedAgent | 活状态,只装活着的 agent |
timelineStore | InMemoryAgentTimelineStore | 时间线,唯一编号权 |
runs | AgentRunState | 回合归属:这轮是谁发起的、settled 没 |
subscribers | Set<SubscriptionRecord> | 谁在听,听全局还是听某个 agent |
agentStreamCoalescer | AgentStreamCoalescer | 流式碎片合流,60ms 一批 |
数据流是一条单向管子。左边是五家 provider,右边是所有客户端,中间的编号和判定只发生一次:
provider session
(事件流)
│
▼
① 串行队列 enqueueSessionEvent 每个 agent 一条 Promise 链,不并发
│
▼
② 合流器 AgentStreamCoalescer 碎文本/工具状态攒 60ms
│
▼
③ 判定 handleStreamEvent 盖 turnId、改 lifecycle、决定要不要落库
│
├──► timelineStore.append ──► seq + timestamp + epoch
│
└──► dispatch ──► 所有 subscriber(带 seq/epoch/timestamp)
怎么读:从上往下是一条事件的一生。编号(
seq)只在 ③ 之后发生一次,所以任何两个订阅者收到的同一条事件,seq必然相同。
3. 生命周期:五个状态
3.1 状态本身
状态枚举住在协议包里,客户端和 daemon 共用同一份(packages/protocol/src/agent-lifecycle.ts:1-9,AGENT_LIFECYCLE_STATUSES):
| 状态 | 白话 | 谁把它设成这样 |
|---|---|---|
initializing | 会话刚建好,还在问 provider 支持什么模式/模型 | registerSession 建对象时(agent-manager.ts:2968) |
idle | 活着,等你说话 | 初始化跑完(2863)、回合正常收尾(2164) |
running | 正在跑一轮 | 前台回合被接受(2074)、或 provider 自己开了一轮(3869) |
error | 上一轮失败了,lastError 有值 | 回合失败且没有前台回合接手(3786) |
closed | 会话已关,只剩一个快照 | prepareAgentForClosure(3011) |
3.2 判别联合:让非法状态编译不过
ManagedAgent 不是"一个带 status 字段的大对象",而是判别联合(discriminated union,按 lifecycle 字段分支的类型联合)。关键在于每个分支对其他字段的约束不同(agent-manager.ts:366-398):
| 分支 | session | activeForegroundTurnId | lastError |
|---|---|---|---|
ManagedAgentInitializing | 有 | 必须 null | 可选 |
ManagedAgentIdle | 有 | 必须 null | 可选 |
ManagedAgentRunning | 有 | string | null | 可选 |
ManagedAgentError | 有 | 必须 null | 必填 |
ManagedAgentClosed | 必须 null | 必须 null | 可选 |
这样"idle 却带着一个活跃前台回合"这种自相矛盾的状态,根本写不出来。
3.3 closed 不住在 map 里
agents map 的值类型是 LiveManagedAgent,而它等于 ActiveManagedAgent——只有前四态(agent-manager.ts:411-417)。
closed 是一个一次性投影:prepareAgentForClosure 先把 agent 从 map 里删掉、退订 session、取消所有等待者,再返回一个 lifecycle: "closed" 的新对象供广播用(agent-manager.ts:3011-3044)。广播完就没了。
所以"关闭"在内存里不是一个驻留状态,而是一次告别事件。客户端要再看这个 agent,只能从磁盘记 录里重建(见 §4.3)。
4. 四条入场路,三条离场路
4.1 公共尾巴:registerSession
四个 public 入口最后都汇到同一个私有方法 registerSession(agent-manager.ts:2794)。它的顺序是刻意的:
校验 id / 查重
│
▼
初始化时间线(种子:显式行 > durable 存量 > 空) ── initializeAgentTimelineForRegister:2898
│
▼
造 ManagedAgent,lifecycle = "initializing" ── buildManagedAgentForRegister:2935
│
▼
装进 agents map ──► 落一次快照 ──► 广播 initializing
│
▼
问 provider 要 modes / 权限 / runtimeInfo ── refreshSessionState:3224
│
▼
lifecycle = "idle" ──► 再落快照 ──► 广播 ──► 订阅 session 事件流
最后一步才 subscribeToSession(2868)。在此之前 provider 吐的事件不会被处理——先有状态,才有事件,避免事件打到一个字段还没填齐的对象上。
中途每一步都插了 assertAgentRegistrationActive(2884):如果 daemon 正在关机、或这个 agent 已经被别人顶掉,立刻抛 AgentManagerShuttingDownError,并在 catch 里把还没注册的 session 关掉(2870-2874),不留孤儿进程。
4.2 四条入场路的区别
| 入口 | 场景 | 关键动作 |
|---|---|---|
createAgent(1058) | 新开一个 agent | 先 deleteAgentState 清残留(1073),再 client.createSession |
resumeAgentFromPersistence(1110) | daemon 重启后按磁盘记录恢复 | 用 AgentPersistenceHandle 走 client.resumeSession,时间线不在这里补,由调用方另外 hydrate |
importProviderSession(1180) | 把你在终端里跑的 Claude Code 会话接管过来 | client.importSession 拿回历史,buildImportedTimelineRows(560)重编 seq,historyPrimed: true |
reloadAgentSession(1259) | 改配置热重启 / 用户点"Reload" | 先取消在跑的回合,关旧 session,再用同一个 agentId 注册新 session |
reloadAgentSession 有个开关值得单说。默认它保留时间线(语音模式开关这类配置切换,不该把对话清空);只有 rehydrateFromDisk: true 时才连 durable 带内存一起删,让 registerSession 铸一个新 epoch、再从 provider 重新拉历史(1312-1321)。epoch 变了意味着客户端手上的游标全部作废——这正是"磁盘被 Paseo 之外的东西改过"时想要的效果。
4.3 三条离场路
closeAgent(1398)——关运行时,留记录。 它先做去重:同一个 agentId 的并发 close 共享同一个 Promise(inFlightAgentCloses),这样 waitForAgentClose 才能当栅栏用。真正的活在 closeAgentRuntime(1415):排空事件队列 → 把还在跑的 provider 子 agent 标成 canceled → 摘出 closed 快照 → 关 session → 落盘 → 广播。注意 close 出错也先广播再抛(1445-1460),状态不会卡在"看起来还活着"。
archiveAgent(1477)——归档,连孩子一起。 顺序是:落一次快照 → 读回记录 → markRecordArchived 写 archivedAt 并尽力归档 provider 侧原生会话 → closeAgent → 丢掉内存时间线 → cascadeArchiveChildren。
级联的判据是标签(1505-1524):凡是 labels["paseo.parent-agent-id"] 指向本 agent 的记录,一并归档;已经活着的走 archiveAgent 递归,只在磁盘上的走 archiveSnapshot。用意是"编排者被归档,它派生的小队不该继续跑"(见 06-orchestration)。
detachAgent(1734)——把孩子从家谱里摘出来。 它做的唯一一件事就是删掉那个父指针标签(PARENT_AGENT_ID_LABEL,packages/protocol/src/agent-labels.ts:10)。删掉之后,上面那条级联就永远扫不到它了——这就是"交接给人类,别再跟着父 agent 一起死"的实现。它同时处理活 agent(改内存 + 落盘 + 广播)和纯磁盘记录(只改记录)两种情况。
4.4 两个失败路径
| 错误 | 什么时候抛 | 含义 |
|---|---|---|
AgentManagerShuttingDownError(103) | 关机后还来注册 agent | 拒绝新工作,已有的交给 flushForShutdown 收尾 |
AgentRunCancellationError(110) | cancelAgentRunBefore 拿到 refused(2437-2445) | provider 不认打断,所以 reload / replace / rewind 全部放弃,而不是硬上 |
第二个是本章最重要的失败语义:不确定旧回合死透了,就不开新回合。宁可报错,也不制造两轮同时写同一条时间线。
5. 回合归属:谁在跑这一轮
5.1 两种 run
一轮对话叫一个 turn,由 turnId 标识。但发起者有两种,AgentRunState 用判别联合分开(packages/server/src/server/agent/agent-run-state.ts:13-34):
PendingForegroundRun | AutonomousAgentRun | |
|---|---|---|
| 谁发起 | 用户从客户端发 prompt,走 streamAgent | provider 自己开的(如定时任务、hook 触发) |
started | 先 false,拿到 turnId 才 true | 恒 true |
stagedEvents | 有,用来暂存 | 无 |
| 结算条件 | turnId 必须精确匹配(agent-run-state.ts:88-90) | turnId 为 undefined 也认(91-98) |
前台 run 严格、自主 run 宽松,原因在于:前台 run 是我们自己开的,turnId 一定知道;自主 run 是被动发现的,provider 未必每个事件都带 turnId。
5.2 stagedEvents:先有编号,再放行
一个时序陷阱:session.startTurn() 还没返回 turnId 时,provider 可能已经开始吐事件了。这些事件属于哪一轮?不知道。
解法是暂存。enqueueSessionEvent 开头就查:存在一个还没 started 的前台 run,就把事件塞进 stagedEvents 直接返回(agent-manager.ts:3078-3082)。
客户端 prompt
│
▼
createPendingRun ── started=false ──┐
│ │ 期间到达的 provider 事件
▼ ▼
session.startTurn() ────────► stagedEvents[] 暂存
│ 返回 turnId
▼
started=true, activeForegroundTurnId=turnId
│
▼
先广播 turn_started ──► 再落用户 prompt ──► 最后回放 stagedEvents
回放时会剔掉两样(2101-2108):provider 自己那条重复的 turn_started(因为 AgentManager 已经先发过权威版本),以及用户消息的回声(prompt 已经由 manager 落库了)。
事件的 turnId 从哪来?统一走 getAgentStreamEventTurnId(agent-sdk-types.ts:460),就是简单读 event.turnId。provider 没给的,由 attachManagedTurnIdentity(agent-manager.ts:420-444)补:turn_started 缺 id 就现铸一个 autonomous-<uuid>,终结事件缺 id 就借当前活跃回合的 id。
5.3 ForegroundTurnWaiter:把广播变成 async 迭代器
streamAgent(2006)返回一个 AsyncGenerator。它是这么把"扇出的广播"接成"一条能 for await 的流"的:
runs.createTurnStream(turnId)造一个ForegroundTurnStream,内含队列 + 一个waiter(agent-run-state.ts:208)。waiter注册进agent.foregroundTurnWaiters。- 每条事件流过
dispatchSessionEvent时,按 turnId 挑出匹配的 waiter,调它的 callback 入队(agent-manager.ts:3170)。 - 生成器从队列里 yield,遇到终结事件(
turn_completed/turn_failed/turn_canceled)收摊。 finally里摘 waiter、settle run;若已无活跃前台回合,顺手刷一次 runtimeInfo(2135-2143)。
runAgent(1889)只是 streamAgent 的"等它跑完"包装:收集 timeline、记 usage、遇 turn_failed 直接抛(1905),最后要求 persistence.sessionId 必须存在,否则报错——跑完了却没有会话 id,说明 provider 状态不对,不能假装成功。
同一个 agent 不允许两个前台回合。streamAgent 一进门就检查 activeForegroundTurnId || runs.hasRun(),命中就抛 already has an active run(2026-2038)。想抢占得走 replaceAgentRun(2211):它先把 pendingReplacement 立起来,取消旧回合,再开新的。这个标志的作用是让 finalizeForegroundTurn 在替换期间继续报 running(2157-2166),客户端不会看见一帧闪烁的 idle。
5.4 cancelAgentRun 的三态
cancelAgentRun(2384)返回 not_running / settled / refused,判定过程分两段:
有 run 吗? ──否──► not_running
│是
▼
session.interrupt() ── 超时/抛错 ─► 没被确认
│被确认 │
▼ ▼
等 run.settledPromise 等 settledPromise:
│ 成功 → settled / 超时 → refused
├─ 按时结算 ─► settled
└─ 超时 ─► 伪造一条 turn_canceled 强行收尾 ─► settled
重点在右下角:provider 连打断都没确认时,manager 不敢伪造终结事件(那会让时间线出现一条实际没发生的取消),只能返回 refused,由调用方决定放弃(§4.4)。反过来,打断被确认了但回合迟迟不结束,manager 才有底气自己灌一条 turn_canceled(2410-2427)。
6. 一条事件怎么入库
6.1 串行队列:每个 agent 一条 Promise 链
enqueueSessionEvent(3067)把每条事件挂到该 agent 的 sessionEventTails 尾巴上,前一条处理完才处理下一条。同一个 agent 的事件绝不并发处理,这是 seq 单调的前提。
配套的 drainSessionEvents(3128)是给"命令式改配置"用的:比如 setAgentMode 调完 provider,得先把 provider 同步吐出的 config 事件消化掉,再写自己的结论,否则调用顺序就不权威了(注释在 3123-3127)。
6.2 合流器:60ms 一批
AgentStreamCoalescer(agent-stream-coalescer.ts:86)只管三类 item:assistant_message、reasoning、tool_call。
| 情况 | 处理 |
|---|---|
| 空文本片段 | 直接吞掉(:103) |
| 连续同一条消息的文本 | 拼接成一条(collapseEntries:245) |
同一个 callId 的 tool_call | 后者覆盖前 者,不重复入库(:170-184) |
| tool_call 到达终态 | 立刻 flush,不等窗口(:110-113) |
| 其他 | 攒够 60ms 再 flush(AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS:3) |
一次 flush 触发 manager 构造器里注册的回调(agent-manager.ts:654-657):recordAndDispatchTimelineItem 落库并广播,然后单独通知前台等待者。这样"逐 token 到达"的碎片,在时间线里是一条完整的 assistant_message,而不是三百行。
6.3 handleStreamEvent:两个开关
handleStreamEvent(3439)是判定中心。它用一个 StreamEventFlags(304-307)携带两个决定:
| 开关 | 含义 | 典型置 false 的场景 |
|---|---|---|
shouldDispatchEvent | 要不要把原始事件广播出去 | timeline 事件已经在落库时广播过了(3717);mode_changed 只改状态,由 agent_state 代言(3595) |
shouldNotifyWaiters | 要不要喂给前台的 async 迭代器 | 被吞掉的系统注入消息、回声(3686-3698) |
进门还有两道闸:
- 重复终结事件:
finalizedForegroundTurnIds记过的 turnId,再来终结事件直接丢(3450-3456)。这个集合上限 50,超了淘汰最老的(agent-run-state.ts:186-196)。 - 历史回放:
fromHistory的事件不更新时间戳、不改 run 状态、不广播,只静静落库(3459、3701-3710)。
6.4 回声消解:唯一一处就地改写
用户从手机发 prompt,时间线上会出现两次同一句话:一次是 manager 自己记的(recordSubmittedPrompt:3955),一次是 provider 把它回显出来。
reconcileSubmittedPromptEcho(3975)负责消解:按 clientMessageId 找到已有那行,把 provider 给的 messageId 写进该行的 providerMessageId 字段,然后让回声事件静默消失。
这是整个时间线里唯一被允许的原地改写——InMemoryAgentTimelineStore.enrichSubmittedUserMessage(agent-timeline-store.ts:179-197)。它只加一个 id 字段,不动 seq、不动 item,所以已经拿到这行的客户端不会看到内容变化。
为什么非要这个字段?因为 rewind(回退到某条消息)必须用 provider 认识的那个 id。rewind(2497-2510)会检查:如果这行还没被 provider 确认过,直接拒绝回退——"provider 还不知道这句话存在,回退到它没有意义"。
7. 时间线存储:seq、epoch、cursor
7.1 三个类型
agent-timeline-store-types.ts 里三个结构决定了全系统的同步语义:
| 类型 | 字段 | 干什么 |
|---|---|---|
AgentTimelineRow(:3) | seq / timestamp / item / providerMessageId? | 一行。seq 是 agent 内单调自增的行号 |
AgentTimelineCursor(:10) | epoch + seq | 游标。光有 seq 不够——epoch 变了说明整条时间线被重铸过 |
AgentTimelineFetchResult(:34) | reset / staleCursor / gap / window / hasOlder / hasNewer / rows | 一次取数的回执 |
epoch 是 initialize 时铸的一个 UUID(agent-timeline-store.ts:152)。它只在时间线被清空重建时才换新(reload 带 rehydrate、force hydrate、rewind 之后)。
7.2 fetch 的四种回执
InMemoryAgentTimelineStore.fetch(:203)不会假装成功。它把"我没法精确满足你"的几种情况分开说清楚:
| 回执 | 触发条件 | 客户端该做什么 |
|---|---|---|
staleCursor: true + reset: true | 游标里的 epoch 和当前 epoch 不同(:233) | 丢掉本地缓存,整段重来 |
gap: true + reset: true | 向后取数,但游标比现存最小 seq 还老(:237) | 中间的行已经不在了,重来 |
hasOlder / hasNewer | 选中窗口两端还有没有数据(:53、:89) | 决定要不要继续翻页 |
window | {minSeq, maxSeq, nextSeq} | 知道现在整条线的边界在哪 |
三个方向(tail / before / after)各自一个纯函数(fetchTail:40、fetchAfter:59、fetchBefore:94),没有共享可变状态。默认一次 200 行,limit: 0 表示"整个窗口都要"(:23、:214)。
7.3 落盘的是什么(诚实说明)
这里有个容易误解的地方,得说清楚:
- 元数据落在
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json。路径由projectDirNameFromCwd(agent-storage.ts:430-441)把工作目录的分隔符换成短横线拼出来;写入走原子写writeJsonFileAtomic(:157)。记录内容由toStoredAgentRecord(agent-projections.ts:63)投影,包含状态、标签、attention、persistence handle——但不含时间线行。 applySnapshot(agent-storage.ts:240-264)有一处专门的防呆:archivedAt不在ManagedAgent里,如果不显式保留,每次普通快照都会把归档状态抹掉。- 时间线的持久化是可选注入的。
AgentTimelineStore接口(agent-timeline-store-types.ts:47)有完整的appendCommitted/fetchCommitted/bulkInsert定义,manager 也备好了enqueueDurableTimelineAppend(4462)等一整套后台写入,但在这个 commit 里,durableTimelineStore只在agent-manager.ts内被引用,bootstrap.ts:911-922构造 AgentManager 时并没有传它。 - 那重启后历史从哪来?从 provider 自己的历史文件重放。
ensureAgentLoaded(agent-loading.ts:62-134)先resumeAgentFromPersistence,再hydrateTimelineFromProvider,由session.streamHistory()把历史一条条喂回来重新编号(primeTimelineFromLegacyProviderHistory:3352)。
一句话:内存时间线是活的真相,provider 的历史文件是冷的真相,JSON 记录只是索引。
8. 对外三张脸
同一份行数据,按用途投影成三种形状:
| 投影 | 文件 | 输出 | 谁要 |
|---|---|---|---|
| 时间线投影 | timeline-projection.ts | projectTimelineRows(:254):把 tool_call 生命周期折叠成一条、把连续 assistant 片段合并,同时保留 sourceSeqRanges | 客户端渲染 |
| 状态投影 | agent-projections.ts | toAgentPayload(:100)给线协议,toStoredAgentRecord(:63)给磁盘,buildStoredAgentPayload(:194)给"只有磁盘记录、进程里没有"的 agent | 客户端列表 / 持久化 |
| 活动摘要 | activity-curator.ts | curateAgentActivity(:209)把时间线压成纯文本 | 喂给别的 agent / 通 知正文 |
投影层有两条纪律值得学:
canonical 与 projected 分离。 折叠只发生在读侧,存的永远是原始行。sourceSeqRanges(timeline-projection.ts:14-22)记录"这条折叠后的条目由哪些原始 seq 组成",所以折叠视图仍然能映射回精确游标。
投影会做卫生检查。 sanitizeUsage(agent-projections.ts:472)遇到非有限数字直接整个丢弃 usage,而不是把 NaN 发到线上;sanitizeMetadata 把 Date 转 ISO 串、剔掉 undefined。
buildAgentForkContextAttachment(activity-curator.ts:294)是同一套机器的另一个用法:按游标或 messageId 截断历史,curate 成一段 <chat-history-summary> 文本,当作附件塞给新 agent——这就是"分叉一个 agent 并带上上下文"的底层。
9. 需要人注意:边沿触发
判定在 manager 内。 checkAndSetAttention(4094)在每次 emitState 时跑,它比对 previousStatuses 里的旧状态和新状态:
| 迁移 | 结论 |
|---|---|
running → idle | finished——它干完了 |
任意 → error | error——它挂了 |
| 待批权限从 0 变 1 | permission(在 onStreamPermissionRequested:3879 里判) |
三条都是边沿触发:已经 requiresAttention 的不重复触发(4107),internal agent 跳过。注释写得很直白——attention 是"未读"标记,不是"当前处于某状态"的电平。清除靠 clearAgentAttention(1784)。
发不发通知在外层。 broadcastAgentAttention(4234)先滤掉被派生的子 agent(isDelegatedAgent,agent-labels.ts:23)——子 agent 完成不该打扰人,该由它的父 agent 处理。剩下的交给回调,由 websocket 层调 computeNotificationPlan(server/agent-attention-policy.ts:42)决定:
遍历所有客户端
│
├─ 有客户端"在场"(180s 内有活动)且正盯着这个 agent? ──► 谁都不通知
│
├─ 有在场客户端但没盯着? ──► 只挑活动最近的那一个,发应用内提示
│
└─ 一个在场的都没有? ──► 发推送(但 error 类不推,见 :78)
怎么读:从上到下,命中即停。
PRESENCE_THRESHOLD_MS = 180_000(:3)是"在场"的 定义。
这条策略是纯函数、无副作用,所以能单独测——把"要不要打扰人"这种最容易写错的逻辑从 socket 代码里择了出来。
10. 权限回执
respondToPermission(2349)转发用户的决定,并处理一个竞态:provider 可能在 RPC 返回之前就把 permission_resolved 吐回来了,客户端会看到"结果先于回执"。
做法是缓冲。事件到达时,若该 requestId 正在 inFlightPermissionResponses 里,就存进 bufferedPermissionResolutions 并暂不广播(3893-3897);等 RPC 处理完再补发(2371-2375)。finally 里两个集合都清干净,不留垃圾。
上层的 respondToAgentPermission(permission-response.ts:22-40)只多做一件事:如果 provider 在结果里给了 followUpPrompt,就以 replaceRunning: true 再开一轮——某些 provider 批准后需要一次续跑才能继续。
11. 巧妙之处
一、把"权威 turn_started"和"provider 的 turn_started"分开。 manager 在接受回合的瞬间就先广播自己那条(2076-2083,注释说明用意是让客户端能撤掉乐观 UI 而不闪 idle),provider 后来那条重复的在回放时被剔除。谁定义"这一轮开始了"是清楚的。
二、结算条件按 run 类型放宽/收紧。 前台 run 必须 turnId 精确匹配才结算,自主 run 允许 undefined(agent-run-state.ts:83-101)。同一个方法,两套严格度,恰好对应两种信息完备程度。
三、touchUpdatedAt 保证严格单调。 Date.now() 不前进时就取 旧值 + 1(771-778)。这样"按 updatedAt 排序"永远稳定,不会因为同毫秒内多次更新而抖动。
四、超时不等于失败。 waitWithTimeout(1368-1396)在超时后仍然挂着 onLateError 收晚到的异常,而不是丢掉 Promise 让它变成 unhandled rejection。
五、后台任务集合能自我排空。 flushTasks(4220-4232)用 while 循环反复 allSettled,因为等待期间还会有新任务生成——一次 Promise.all 是不够的。
12. 边界与局限
- 单进程单实例。 所有真相在一个 Node 进程的内存里,没有多 daemon 协调。daemon 挂了,活状态全丢,靠 provider 历史重建。
- 时间线的耐久层没接上。 接口和写入路径都在,
bootstrap.ts没注入(§7.3)。历史的完整性因此依赖 provider 自己的历史文件质量。 fetch的实现是数组扫描。findIndex/slice都是 O(n)(agent-timeline-store.ts:62、:97),超长会话上翻页成本随长度上升。enrichSubmittedUserMessage之外没有编辑能力。 时间线是 append-only;要改内容只能整体重铸(换 epoch)。- 归档级联是串行递归。
cascadeArchiveChildren逐 个await(1511-1523),深层 agent 树的归档不是瞬时的。 finalizedForegroundTurnIds只记 50 条。 极端情况下超老的重复终结事件会漏过去重(agent-run-state.ts:188)。
13. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 状态枚举 | packages/protocol/src/agent-lifecycle.ts | AGENT_LIFECYCLE_STATUSES |
| 判别联合 | packages/server/src/server/agent/agent-manager.ts | ManagedAgent、ManagedAgentBase、ActiveManagedAgent |
| 管理器主体 | 同上 | AgentManager(字段布局在类头) |
| 入场四路 | 同上 | createAgent、resumeAgentFromPersistence、importProviderSession、reloadAgentSession |
| 入场公共尾巴 | 同上 | registerSession、buildManagedAgentForRegister |
| 离场三路 | 同上 | closeAgent、archiveAgent、cascadeArchiveChildren、detachAgent、prepareAgentForClosure |
| 跑一轮 | 同上 | streamAgent、runAgent、replaceAgentRun、tryRunOutOfBand |
| 回合收尾 | 同上 | finalizeForegroundTurn、applyActiveTurnTerminal |
| 取消 | 同上 | cancelAgentRun、cancelAgentRunBefore、AgentRunCancellationError |
| 事件入口 | 同上 | enqueueSessionEvent、drainSessionEvents、dispatchSessionEvent |
| 事件判定 | 同上 | handleStreamEvent、dispatchStreamEventByType、StreamEventFlags |
| 回声消解 | 同上 | recordSubmittedPrompt、reconcileSubmittedPromptEcho |
| 订阅扇出 | 同上 | subscribe、SubscribeOptions、AgentManagerEvent、dispatch、dispatchStream |
| attention | 同上 | checkAndSetAttention、broadcastAgentAttention |
| 回合归属 | .../agent/agent-run-state.ts | AgentRunState、PendingForegroundRun、AutonomousAgentRun、ForegroundTurnWaiter、ForegroundTurnStream |
| turnId 读取 | .../agent/agent-sdk-types.ts | getAgentStreamEventTurnId |
| 流合并 | .../agent/agent-stream-coalescer.ts | AgentStreamCoalescer、collapseEntries |
| 时间线类型 | .../agent/agent-timeline-store-types.ts | AgentTimelineRow、AgentTimelineCursor、AgentTimelineFetchResult、AgentTimelineStore |
| 时间线实现 | .../agent/agent-timeline-store.ts | InMemoryAgentTimelineStore、append、fetch、getEpoch、enrichSubmittedUserMessage |
| 外部追加 | .../agent/timeline-append.ts | appendTimelineItemIfAgentKnown、emitLiveTimelineItemIfAgentKnown |
| 落盘 | .../agent/agent-storage.ts | AgentStorage、applySnapshot、projectDirNameFromCwd |
| 投影 | .../agent/timeline-projection.ts、.../agent/agent-projections.ts | projectTimelineRows、toAgentPayload、toStoredAgentRecord |
| 摘要 | .../agent/activity-curator.ts | curateAgentActivity、buildAgentForkContextAttachment |
| 通知策略 | .../server/agent-attention-policy.ts | computeNotificationPlan、PRESENCE_THRESHOLD_MS |
| 权限回执 | .../agent/permission-response.ts | respondToAgentPermission |
| 懒加载 | .../agent/agent-loading.ts | ensureAgentLoaded、ensureUnarchivedAgentLoaded |