跳转至

轨迹存储模型 — 完整架构设计

版本:v0.5.0  |  最后更新:2026-05-30


文档地图

你想了解…
Capture 代理的整体架构(概念、Story 边界、采集管线) Capture 架构设计 §4–§6
TLV Markdown 的块格式规范 轨迹 Markdown 格式
persisting traj 命令用法 Traj 命令 · Capture 子命令

1. 概述

Agent 轨迹在 Persisting 中同时服务两类读者:机器(回放、统计、检索)与(阅读、git diff、code review)。为此采用两层存储:Lance 为 canonical raw event log,TLV Markdown 为按需物化的人读视图。

与 Story 的关系:Capture 主路径为 任意协议 → Story(Call + Event)→ 事件记录 → Lance/Markdown。协议差异在 Story 边界前消化;事件记录是存储层统一中间表示。详见 Capture §4.2 三层词汇表

graph TD
    subgraph "数据源"
        S1["Capture Proxy<br/>(实时代理流量)"]:::accent1
        S2["CLI import<br/>(IDE 日志 / 历史导出)"]:::accent1
    end

    subgraph "归一化层"
        N["CaptureRecord<br/>(统一中间表示)"]:::accent0
    end

    subgraph "引擎层"
        CE["CaptureEngine<br/>(事件驱动采集核心)"]:::accent0
        AQ["ApplyDispatcher<br/>(per-story_id 有序 apply)"]:::accent0
        MP["MarkdownPipeline<br/>(过滤 + live/batch/reconcile)"]:::accent3
        E1["Lance Append<br/>(canonical)"]:::accent2
        E2["Materialize<br/>(全量 Lance → Markdown)"]:::accent3
        E3["Live Upsert<br/>(call_id+role 流式 md)"]:::accent3
        E3b["Frontmatter Refresh<br/>(会话 YAML 摘要)"]:::accent3
        E4["Stream Append<br/>(import 批量 append md)"]:::accent3
        E5["Compact<br/>(Markdown → Lance)"]:::accent3
    end

    subgraph "存储层"
        L1["Lance Event Log<br/>列存 · 无损 · {run}/events.lance/"]:::accent4
        L2["TLV Markdown<br/>人读 · 对话块 · 可选物化"]:::accent5
        L3["sessions.json<br/>内存索引 · 快速 list"]:::accent5
    end

    S1 --> CE
    S2 --> N
    N --> E1
    CE --> AQ
    AQ --> N
    CE --> E3
    E3 --> MP
    MP --> E3
    E1 --> L1
    E3 --> L2
    E3 --> E3b
    E3b --> L2
    E1 --> E4
    E4 --> L2
    L1 --> E2
    E2 --> MP
    MP --> L2
    L2 --> E5
    E5 --> L1
    CE --> L3

    classDef accent0 fill:#e1bee7,stroke:#7b1fa2,color:#000
    classDef accent1 fill:#bbdefb,stroke:#1565c0,color:#000
    classDef accent2 fill:#c8e6c9,stroke:#2e7d32,color:#000
    classDef accent3 fill:#fff9c4,stroke:#f9a825,color:#000
    classDef accent4 fill:#ffccbc,stroke:#d84315,color:#000
    classDef accent5 fill:#d7ccc8,stroke:#4e342e,color:#000

核心 invariantLance row_count ≥ Markdown block_count(materialize 是有损的)。


2. 两层结构对比

维度 Lance (canonical) TLV Markdown (materialized view)
角色 全量 event log,系统的 single source of truth 人类可读对话视图
完整性 无损,所有事件全部保留 有损,过滤内部流量与 lifecycle
格式 列存(EventRow),capture run 使用 run 级 events.lance/(Lance dataset 目录 TLV 块序列(MarkdownBlock),纯文本文件
写入方式 -f lance / traj add --storage-format lance 时 append;import 批量 append Proxy -f mdCaptureEngine live upsert;均可 materialize 全量重建
典型操作 replaystats、Search import、FTS 直接打开、git diff、code review
按 session 分片 events.lance/ 内按 session_id 列过滤;同一 run dataset 可含多个 distinct session_id(见 §7.1.1 {run_dir}/{storage_session_id}.md

3. 数据流全景

3.1 Capture Proxy(-f md)— 事件驱动 + live Markdown

Proxy 侧采集经 CaptureEngine 统一处理;Proxy 不 await 完整 applyspawn_apply → Event WAL → ApplyDispatcher),Markdown 写入在后台完成。-f md 不写 Lance

sequenceDiagram
    participant Agent
    participant Proxy as Capture Proxy
    participant Queue as ApplyDispatcher
    participant Engine as CaptureEngine
    participant MD as TLV Markdown

    Agent->>Proxy: POST /v1/messages

    Proxy->>Queue: spawn_apply Event::Request(先写 Event WAL,不阻塞 upstream)
    Queue->>Engine: 按 story_id 有序 apply
    Engine->>Engine: prepare + StoryActor ask
    Engine->>MD: upsert user block + refresh frontmatter

    loop SSE stream (150ms 节流)
        Proxy->>Queue: spawn_apply Event::ResponseDraft
        Queue->>Engine: 有序 apply
        Engine->>MD: upsert assistant block (draft: true)
    end

    alt 客户端断开
        Proxy->>Queue: spawn_apply Event::Cancelled
        Queue->>Engine: 有序 apply
        Note over Engine,MD: cancelled 可不进 Markdown
    else 正常结束
        Proxy->>Queue: spawn_apply Event::ResponseComplete
        Queue->>Engine: 有序 apply
        Engine->>MD: upsert assistant (final) + refresh frontmatter
    end

关键设计决策:

  1. -f md 只写 Markdown。需要 canonical 列存时改用 -f lance,或事后 traj compact / import。
  2. Live Markdown 用 upsertupsert_block_by_call_idcall_id + role 匹配;块区间替换使用 rewrite_block_range
  3. ApplyDispatcher 保证有序。同一 story_id 内 Request / Draft / Complete 按序 apply。
  4. StoryActor ask。生产环境等待 story actor 处理结果;失败写入 dead_letter.jsonl
  5. Run 结束对账。写 .capture/reconcile.json(md call_id vs Markdown replay);不涉及 Lance。
  6. Frontmatter 摘要。每次 dialogue 写入后刷新 YAML;traj capture 结束打印 stderr 一行汇总。

3.1b Capture Proxy(-f lance)— 仅 canonical dataset

-f lance(别名 bin)只 append 到 {run}/events.lance/(Lance dataset 目录), live 写 Markdown、写 reconcile。人读视图用 traj materialize

sequenceDiagram
    participant Agent
    participant Proxy as Capture Proxy
    participant Queue as ApplyDispatcher
    participant Engine as CaptureEngine
    participant Sink as CallbackSink / Worker
    participant Lance as events.lance/

    Agent->>Proxy: POST /v1/messages
    Proxy->>Queue: spawn_apply Event::Request
    Queue->>Engine: 有序 apply
    Engine->>Sink: append llm.request
    Sink->>Lance: Lance Append flush
    Note over Engine,Lance: ResponseDraft 不落盘
    Proxy->>Queue: spawn_apply Event::ResponseComplete
    Queue->>Engine: 有序 apply
    Engine->>Sink: append llm.response.stream
    Sink->>Lance: flush

3.2 CLI Import — 按 --storage-format 只写一层

IDE 日志 / gateway 导入不经 Proxy,走 CaptureRecord → 选定层 append。默认目标多为 Lance;-f md / --storage-format markdown 时对 engine lines 调用 stream_engine_lines_to_markdown() 末尾追加(无 upsert)。

sequenceDiagram
    participant Import as traj import
    participant Worker as TrajectoryAppendWorker
    participant Lance as events.lance/
    participant MD as TLV Markdown

    Import->>Worker: CaptureRecord (RON line)
    Worker->>Worker: buffer (≤32 或关键事件 flush)
    alt storage-format lance
        Worker->>Lance: TrajectoryAppend
    else storage-format markdown
        Worker->>MD: stream_engine_lines_to_markdown (append)
    end

4. 核心数据结构

4.1 CaptureRecord(统一中间表示)

pub struct CaptureRecord {
    pub seq: u64,                    // session 内单调序号
    pub source: String,              // "persisting-proxy" | "persisting-capture"
    pub kind: String,                // "llm.request" | "llm.response" | "session.started" | ...
    pub timestamp: Option<String>,   // RFC3339
    pub session_id: Option<String>,
    pub agent_id: Option<String>,
    pub trace_id: Option<String>,
    pub call_id: Option<String>,
    pub subagent_id: Option<String>,
    pub parent_agent_id: Option<String>,
    pub parent_call_id: Option<String>,
    pub payload: serde_json::Value,  // 自由格式,按 kind 不同
}

CaptureRecord 是系统中唯一的内部数据交换格式。数据源(proxy / CLI import)产出 CaptureRecord,引擎(Lance / Markdown / Index)消费它。

4.2 EventRow(列存行)

pub struct EventRow {
    pub seq: i64,                     // 会话内序号
    pub timestamp: Option<String>,
    pub kind: String,                 // 索引列
    pub source: String,
    pub agent_id: Option<String>,     // 过滤/路由
    pub session_id: Option<String>,   // 过滤/路由
    pub call_id: Option<String>,      // 调用链
    pub trace_id: Option<String>,
    pub parent_call_id: Option<String>,
    pub model: Option<String>,        // 从 payload 反规范化
    pub payload_json: String,         // 完整 CaptureRecord JSON(canonical 载荷)
}

反规范化设计kindsession_idmodel 等高频查询字段提升为独立列,payload_json 存储完整 JSON。这样可以在不解析 JSON 的情况下做过滤和聚合。

4.3 MarkdownBlock(人读块)

pub struct MarkdownBlock {
    pub header: BlockHeader,  // 元数据(type, length, role, kind, turn, ...)
    pub body: Vec<u8>,        // 正文内容(纯文本 / JSON)
}

pub struct BlockHeader {
    pub type_name: String,           // "llm.request" | "llm.response" 等
    pub length: usize,               // body 字节长度
    pub fields: BTreeMap<String, Value>,  // role, kind, turn, model, seq, tokens...
}

详见 轨迹 Markdown 格式


5. 转换管线

5.1 四条转换路径

graph LR
    A["CaptureRecord[]"] -->|"1. capture_records_to_markdown_blocks"| B["(BlockHeader, Vec&lt;u8&gt;)[]"]
    B -->|"2. write_markdown_document"| C["TLV Markdown 文件"]

    A -->|"3. capture_record_to_event_row"| D["EventRow"]
    D -->|"4. Lance append"| E["events.lance/"]

    C -->|"5. markdown_document_to_capture_records"| A
    E -->|"6. event_row_to_capture_record"| A

    style A fill:#e1bee7,stroke:#7b1fa2,color:#000
    style C fill:#c8e6c9,stroke:#2e7d32,color:#000
    style E fill:#ffccbc,stroke:#d84315,color:#000
方向 API 模式 触发场景
CaptureRecord → Markdown capture_records_to_markdown_blocks() 过滤 + 转换 traj materialize
Block → .md 文件 write_markdown_document() 全量重写 materialize 全量
Block → .md 文件(增量 append) stream_engine_lines_to_markdown() 末尾追加 import -f md(非 Proxy live 路径)
Block → .md 文件(live upsert) upsert_block_by_call_id() 按 call_id+role 替换或 append Proxy -f md 流式采集
.md 文件 → CaptureRecord markdown_document_to_capture_records() 解析 + 重建 compact 导入
CaptureRecord → Lance capture_record_to_event_row() 一行一条 Lance append
Lance → CaptureRecord event_row_to_capture_record() 反序列化 replay / stats

5.2 Materialize(Lance → Markdown 全量)

pub struct MaterializeStats {
    pub source_events: usize,    // Lance 行数
    pub markdown_blocks: usize,  // Markdown 块数
    pub skipped_events: usize,   // 被过滤的事件数
}

流程:

Lance event log (全量扫描)
    │  event_row_to_capture_record()
CaptureRecord[]
    │  MarkdownPipeline::blocks_from_records()  ← 静态过滤 + history replay dedup
(BlockHeader, Vec<u8>)[]
    │  format_document_preamble() + encode_block_with_header()
TLV Markdown 文件 (全量重写)

5.3 Live Upsert(Proxy -f md 流式)

Proxy 流式场景下,Markdown 通过 upsert_block_by_call_id() 增量更新,而非每批 append:

Event::Request
    │  MarkdownPipeline::try_block() → user block
upsert_block_by_call_id(call_id, role=user)   ← 不存在则 append

Event::ResponseDraft (≤150ms)
    │  draft_stream_assistant_block() → header.draft=true
upsert_block_by_call_id(call_id, role=assistant)   ← 流式原地更新

Event::ResponseComplete
    │  enrich + append Lance llm.response.stream
    │  MarkdownPipeline::try_block() → final assistant
upsert_block_by_call_id(call_id, role=assistant)   ← 覆盖 draft,移除 draft 标记

匹配键call_id + role(user / assistant 各一块,互不覆盖)。同一 call 的 user 与 assistant 可并存。

seq 预览:draft 块通过 sink.peek_next_seq() 写入 header,与实际 Lance append 序号对齐;finalize 后以 stamped record 的 seq 覆盖。

5.4 Stream Append(Import -f md 增量)

pub struct StreamMaterializeStats {
    pub events_seen: usize,      // 本批 lines 数
    pub blocks_appended: usize,  // 实际写入文件的块数
    pub skipped_events: usize,   // 被过滤的事件数
}

与 live upsert 的区别:仅 append 到文件末尾,无 call_id 级 rewrite;适用于离线 import,不适用于 Proxy 流式 draft。

新批次的 engine lines (RON)
    │  engine_line_to_record() + MarkdownPipeline::try_block()
筛选后的 (BlockHeader, Vec<u8>)[]
    │  append_engine_lines_to_markdown()
已有 .md 文件 (末尾追加)

5.5 Compact(Markdown → Lance)

pub struct CompactStats {
    pub source_blocks: usize,   // Markdown 块数
    pub event_rows: usize,      // 生成的 Lance 行数
}

流程:

TLV Markdown 文件
    │  parse_document()
MarkdownBlock[]
    │  block_to_capture_record() + enrich: payload["_tlv"] = {...}
CaptureRecord[]
    │  capture_record_to_event_row()
Lance event log (`events.lance`)

_tlv 字段:compact 时为每个 CaptureRecord 注入 payload._tlv,包含 role 和原始 block_fields。这保留了 TLV 元数据,使 compact 操作是信息增量的 —— 但 materialize 时丢弃的内部事件(如 lifecycle)无法通过 compact 恢复。


6. 对话过滤规则

live / materialize / reconcile 统一storage/markdown_pipeline.rsMarkdownPipeline

API 用途
MarkdownPipeline::should_skip() 有状态过滤(含 history replay dedupuser_message_count 未增则跳过)
MarkdownPipeline::static_skip() / skip_markdown_block() 无状态静态过滤(dialogue.rs re-export 后者供测试/CLI)
MarkdownPipeline::try_block() 过滤 + capture_record_to_block()
跳过项 检测条件 原因
history replay user_message_count 未增 Claude Code 重发全量 history,无新 user turn
内部探测请求 pathcount_tokenscount-tokens 非对话流量
无可见文本的空白请求 visible_user_text() 为 None 无信息量
无可见文本的空白响应 visible_assistant_text() 为 None 无信息量
流式 partial(仅 md) payload stream_partial: true 不落 Markdown
流式 draft(仅 md) header draft: true 不落 Lance;finalize 后 draft 被覆盖
生命周期事件 kindsession. 开头 不在对话中展示
客户端取消 llm.call.cancelled 仅 Lance;不进 Markdown
主 session flash/haiku 影子请求 无 subagent_id + model 含 flash/haiku + 非 subagent shape payload Claude Code 的预热探测,与 pro 内容重复
llm.spawn_link 不跳过,始终保留 主/子代理关联是重要轨迹信息
// 静态过滤(无 history dedup)
pub fn skip_markdown_block(rec: &CaptureRecord) -> bool {
    MarkdownPipeline::static_skip(rec)
}

// live 路径:有状态 dedup
impl MarkdownPipeline {
    pub fn should_skip(&mut self, rec: &CaptureRecord) -> bool {
        if self.skip_history_replay(rec) { return true; }
        Self::static_skip(rec)
    }
}

invariant:materialize 之后 Lance row_count ≥ Markdown block_count

turn 字段turn = seq / 2 + 1 为全局 seq 的派生编号,保证同一 call 的 user/assistant 共享语义轮次;并发 in-flight 时相邻块可能属于不同 call 却共享 turn 值。分析对话轮次时应以 call_id 为准。

7. 存储布局

7.1 Capture run 布局(推荐)

{store}/
├── .capture/
│   ├── sessions.json                   ← 全局会话索引
│   ├── dead_letter.jsonl               ← Event apply 失败(可 replay)
│   ├── trajectory_dead_letter.jsonl    ← Lance worker flush 失败
│   ├── reconcile.json                  ← run 结束 md↔Lance 对账
│   ├── run_session                     ← 当前 run_id(纯文本一行)
│   ├── run_child.yaml                  ← 子进程信息 (PID + 命令行)
│   └── daemon.env.json                 ← daemon 环境快照 (API keys)
├── {agent_id}/
│   └── run-{timestamp}-{nanos}/        ← 一次 traj capture 的根目录
│       ├── run-{timestamp}-{nanos}.md   ← 主 agent Markdown (run bucket)
│       ├── agent-{claude_agent_id}.md   ← 子 agent Markdown (sibling)
│       ├── agent-{...}.md               ← 更多子 agent
│       └── events.lance/                ← run 级 Lance dataset(目录;仅 `-f lance`)

关键隔离 invariant

  • 子 agent 的 user/assistant/tool 块写入对应的 agent-{id}.md
  • 主 agent 对话与 llm.spawn_link写入 run-{run_id}.md{header_session_id}.md
  • 主 md 不内联子 agent 的完整轨迹,而是通过 llm.spawn_link 块引用 sibling 文件
  • traj capture 模式下不写 session-meta.yaml;客户端元信息进入 Markdown YAML frontmatter 的 client:

7.1.0 Judge sidecar(可选)

LLM-as-judge 结果写在 {run}/layers/judge_{rubric}.lance/,并由同目录 manifest.json(本地 JSON,非外部 layout API)登记 join 键 [session_id, call_id]

7.1.1 Run bucket 内多 session_id 分区

Claude Code 等客户端会在 HTTP header 注入 header session UUID,与 capture run 目录名 run-* 不同。同一 events.lance/ dataset 内因此常见:

session_id = run-20260530-120000-123   ← session.started / 部分元数据
session_id = 58867536-…                ← 实际 LLM 对话行

读取规则

操作 行为
traj stats <agent_dir>(未指定 --session-id 列出 run bucket → 展开 Lance 内 distinct session_id → 逐分区统计
traj stats --session-id <uuid> 只过滤该 session_id 的行
traj replay / materialize 按 Story 坐标中的 session_id 过滤

实现:expand_story_locationspersisting-engine)。不要假设「一个 run 目录 = 一个 session_id」。

7.2 扁平 session 布局(serve 模式)

{store}/
├── {agent_id}/
│   └── {session_id}/
│       ├── {session_id}.md             ← Markdown(可选)
│       ├── session-meta.yaml           ← 客户端进程元信息(serve 模式)
│       └── events.lance/            ← Lance dataset(可选;`-f lance`)

serve 模式下无 run_session,每个逻辑 session 独立一个目录。session-meta.yaml 记录发起连接的客户端进程信息(peer addr / PID / 命令行)。

7.3 Markdown 路径解析算法

locate_session_markdown_for_key() 在给定 run 目录内按以下优先级解析目标 Markdown 文件:

输入: run_dir, storage_session_id

1.  {run_dir}/{storage_session_id}.md 已存在 → 使用该文件
2.  storage_session_id 为 "agent-*" → 仅匹配 {run_dir}/agent-{id}.md;
    不 fallback 到 run 主文件(防止主 session 误写入 subagent 文件)
3.  run_dir 名为 "run-*" → 优先 {run_dir}/run-{run_dir名}.md (locate_run_bucket_markdown)
4.  扫描时排除 agent-*.md,避免主 session 误走 subagent 路径
5.  均不存在 → 新建 {run_dir}/{storage_session_id}.md
    (subagent 则为 agent-{id}.md)

该算法同时服务于 capture CLI worker 和 traj materialize


8. 存储策略

Capture(-f

策略 -f 行为
Markdown only md CaptureEngine live upsert 到 {session}.md不写 Lance
Lance only lance 仅 append events.lance;人读视图用 traj materialize 按需生成

Trajectory CLI(--storage-format

两种物理层:Lance(canonical)、Markdown(物化视图)。traj add 每次只写一层(由 --storage-format 决定);materialize 为 Lance → Markdown 全量导出(不随 add 自动触发)。

行为
auto :无层→Lance,仅 md→Markdown,仅 Lance→Lance,两层都有→Lance; replay:有 Lance 读 Lance,否则 Markdown;stats:两层都有时同时摘要
lance / markdown 强制读/写/统计指定层
both 遗留别名(append→Lance only;read/stats 同 auto),不推荐

读取优先级replay 在两层并存时默认 Lance;纯 Markdown session 从块序列还原。-f md run 结束后优先查看 reconcile.json;不一致且存在 Lance 层时执行 traj materialize 全量对齐。


8.2 Markdown Frontmatter 摘要

每个 live md 文件头部 YAML 含会话 rollup(实现:storage/frontmatter.rs):

字段 含义
format 固定 persisting:1.0
block TLV 块布局说明(模板字符串)
session / agent 逻辑 session 与租户
model / provider 来自 sessions.json
started / duration 首末请求时间差
turns user 块数量
total_tokens / estimated_cost_usd 累计用量与成本
subagents 同 run 下 agent-*.md stem 列表
client 子进程 / peer 元信息

刷新:dialogue 块写入后自动更新;traj capture 结束全量 refresh + stderr 摘要行。


8.3 Run 结束 Reconcile

traj capture -f md 在 worker shutdown 之后

  1. 扫描 run 目录下所有 *.md
  2. 对 Markdown 层做 replay / 解析,提取 call_id 集合
  3. 与 live md 结构对照(及 excessive blank lines 等)
  4. 写入 {storage}/.capture/reconcile.json

-f lance 写 reconcile。若之后又有 Lance 层且需对齐人读视图,用 traj materialize


9. 双向转换的保真度

graph LR
    V["Lance<br/>(无损全量)"] -->|"materialize<br/>(有损: 过滤内部事件)"| M["Markdown<br/>(人读视图)"]
    M -->|"compact<br/>(信息增量: + _tlv)"| V2["Lance<br/>(含 _tlv)"]

    style V fill:#ffccbc,stroke:#d84315,color:#000
    style M fill:#c8e6c9,stroke:#2e7d32,color:#000
    style V2 fill:#ffccbc,stroke:#d84315,color:#000
操作 方向 信息变化
materialize Lance → Markdown 有损:丢弃 count_tokens / lifecycle / flash影子请求 / 空 turn;图像仅保留 dialogue 占位符,不嵌 <img>
compact Markdown → Lance 信息增量:注入 payload._tlv(TLV 元数据);但无法恢复已丢弃的内部事件与 sidecar 未写入的像素

结论:Lance 是 canonical。Markdown 可以从 Lance 重建,但反过来不能。永远不要把 Markdown 当作唯一存储。


10. 与 Capture 代理的集成

10.1 CaptureEngine 事件模型

Proxy 通过 CaptureEngine::spawn_apply(ctx, event)llm_capture / streaming 直接调用)→ ApplyDispatcherCaptureEngine::apply 处理采集;prepare / StoryActor 失败写入 .capture/dead_letter.jsonl;Lance worker flush 失败写入 .capture/trajectory_dead_letter.jsonl

事件命名属于 Story 区engine/story/event.rs),与 wire 协议无关:

Event 变体 Lance Markdown Proxy 调度 说明
Event::Request llm.request ✅ user upsert + frontmatter spawn_apply(非阻塞) 转发 upstream 前触发
Event::ResponseDraft ✅ assistant upsert (draft: true) spawn_apply SSE 150ms 节流
Event::ResponseComplete llm.response / .stream ✅ assistant upsert (final) + frontmatter spawn_apply 流结束或非流式
Event::Cancelled llm.call.cancelled spawn_apply 客户端提前断开 SSE
Proxy (spawn_apply)           ApplyDispatcher              CaptureEngine
  │                                │                                │
  ├─ Event::Request                ├─ 按 story_id 有序 ───────────→ prepare + StoryActor ask
  │                                │                                ├─ md upsert (MarkdownPipeline)
  ├─ Event::ResponseDraft          ├─ 有序 apply ─────────────────→ md only
  ├─ Event::ResponseComplete       ├─ 有序 apply ─────────────────→ ask → append Lance
  │                                │                                ├─ md final + frontmatter
  └─ Event::Cancelled              └─ 有序 apply ─────────────────→ ask → append (cancelled)

StoryActor 生产环境使用 ask(可观测 + 失败 dead letter);shutdown 前先通过 ApplyDispatcher barrier drain 已接收事件,再对各 story 发送 Flush drain mailbox。spawn_apply 在入队前写 .capture/events.wal.jsonl,clean shutdown 后 truncate;非 clean shutdown 下次启动 replay 未 ack event。CallbackSinkTrajectoryAppendWorker 负责 Lance 批量 flush。

10.2 已知限制

现象 原因 缓解
md 与 Lance 短暂不一致 后台 apply 尚未 drain shutdown Flush + reconcile.json
turn 编号跨 call 重复 turn = seq/2+1 非 per-call 消费方用 call_id 分组
apply 队列满 / 采集失败 背压 dead letter + replay-dead-letter
Event WAL 多轮 crash replay entry 暂未 ack 原 seq;next_seq 未从旧 WAL 恢复 PendingEntry.seq + open 扫描 max(seq)+1
Lance worker flush 失败 磁盘 / IO 异常 trajectory_dead_letter.jsonl 保留 RON batch
Lance dataset 过大 长 run / 高频事件 按 run 或 agent 拆分;Overwrite 路径会重写整个 dataset(规划中)

详见 Capture 架构设计 §10


11. 相关文档