跳到主要内容

数据截至 (上游 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):

字段装什么一句话
clientsprovider → AgentClient五家 CLI 的适配器,按 provider 查
agentsagentId → LiveManagedAgent活状态,只装活着的 agent
timelineStoreInMemoryAgentTimelineStore时间线,唯一编号权
runsAgentRunState回合归属:这轮是谁发起的、settled 没
subscribersSet<SubscriptionRecord>谁在听,听全局还是听某个 agent
agentStreamCoalescerAgentStreamCoalescer流式碎片合流,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):

分支sessionactiveForegroundTurnIdlastError
ManagedAgentInitializing必须 null可选
ManagedAgentIdle必须 null可选
ManagedAgentRunningstring | 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)新开一个 agentdeleteAgentState 清残留(1073),再 client.createSession
resumeAgentFromPersistence(1110)daemon 重启后按磁盘记录恢复AgentPersistenceHandleclient.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)——归档,连孩子一起。 顺序是:落一次快照 → 读回记录 → markRecordArchivedarchivedAt 并尽力归档 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):

PendingForegroundRunAutonomousAgentRun
谁发起用户从客户端发 prompt,走 streamAgentprovider 自己开的(如定时任务、hook 触发)
startedfalse,拿到 turnId 才 truetrue
stagedEvents有,用来暂存
结算条件turnId 必须精确匹配(agent-run-state.ts:88-90)turnIdundefined 也认(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 的流"的:

  1. runs.createTurnStream(turnId) 造一个 ForegroundTurnStream,内含队列 + 一个 waiter(agent-run-state.ts:208)。
  2. waiter 注册进 agent.foregroundTurnWaiters
  3. 每条事件流过 dispatchSessionEvent 时,按 turnId 挑出匹配的 waiter,调它的 callback 入队(agent-manager.ts:3170)。
  4. 生成器从队列里 yield,遇到终结事件(turn_completed / turn_failed / turn_canceled)收摊。
  5. 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_messagereasoningtool_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 认识的那个 idrewind(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一次取数的回执

epochinitialize 时铸的一个 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.tsprojectTimelineRows(:254):把 tool_call 生命周期折叠成一条、把连续 assistant 片段合并,同时保留 sourceSeqRanges客户端渲染
状态投影agent-projections.tstoAgentPayload(:100)给线协议,toStoredAgentRecord(:63)给磁盘,buildStoredAgentPayload(:194)给"只有磁盘记录、进程里没有"的 agent客户端列表 / 持久化
活动摘要activity-curator.tscurateAgentActivity(: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 里的旧状态和新状态:

迁移结论
runningidlefinished——它干完了
任意 → errorerror——它挂了
待批权限从 0 变 1permission(在 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.tsAGENT_LIFECYCLE_STATUSES
判别联合packages/server/src/server/agent/agent-manager.tsManagedAgentManagedAgentBaseActiveManagedAgent
管理器主体同上AgentManager(字段布局在类头)
入场四路同上createAgentresumeAgentFromPersistenceimportProviderSessionreloadAgentSession
入场公共尾巴同上registerSessionbuildManagedAgentForRegister
离场三路同上closeAgentarchiveAgentcascadeArchiveChildrendetachAgentprepareAgentForClosure
跑一轮同上streamAgentrunAgentreplaceAgentRuntryRunOutOfBand
回合收尾同上finalizeForegroundTurnapplyActiveTurnTerminal
取消同上cancelAgentRuncancelAgentRunBeforeAgentRunCancellationError
事件入口同上enqueueSessionEventdrainSessionEvents、dispatchSessionEvent
事件判定同上handleStreamEventdispatchStreamEventByTypeStreamEventFlags
回声消解同上recordSubmittedPromptreconcileSubmittedPromptEcho
订阅扇出同上subscribeSubscribeOptionsAgentManagerEventdispatchdispatchStream
attention同上checkAndSetAttentionbroadcastAgentAttention
回合归属.../agent/agent-run-state.tsAgentRunStatePendingForegroundRunAutonomousAgentRunForegroundTurnWaiterForegroundTurnStream
turnId 读取.../agent/agent-sdk-types.tsgetAgentStreamEventTurnId
流合并.../agent/agent-stream-coalescer.tsAgentStreamCoalescercollapseEntries
时间线类型.../agent/agent-timeline-store-types.tsAgentTimelineRowAgentTimelineCursorAgentTimelineFetchResultAgentTimelineStore
时间线实现.../agent/agent-timeline-store.tsInMemoryAgentTimelineStoreappendfetchgetEpochenrichSubmittedUserMessage
外部追加.../agent/timeline-append.tsappendTimelineItemIfAgentKnownemitLiveTimelineItemIfAgentKnown
落盘.../agent/agent-storage.tsAgentStorageapplySnapshotprojectDirNameFromCwd
投影.../agent/timeline-projection.ts.../agent/agent-projections.tsprojectTimelineRowstoAgentPayloadtoStoredAgentRecord
摘要.../agent/activity-curator.tscurateAgentActivitybuildAgentForkContextAttachment
通知策略.../server/agent-attention-policy.tscomputeNotificationPlanPRESENCE_THRESHOLD_MS
权限回执.../agent/permission-response.tsrespondToAgentPermission
懒加载.../agent/agent-loading.tsensureAgentLoadedensureUnarchivedAgentLoaded