Files
ragflow/internal/ingestion/pipeline/PROGRESS_LOG_RESUME_PLAN.md
Zhichang Yu 12787996d1 feat(agent): Go ingestion pipeline progress mirroring and DeepDOC parser hardening (#16795)
feat(ingestion): mirror Go pipeline progress into the document table;
harden resume guards
- pipeline: bind the owning document via WithDocumentID; after each
TrackProgress event aggregate ingestion_task_log progress and mirror
progress/run/progress_msg back into the document table, so GET
/api/v1/datasets/{dataset_id}/documents reflects live Go pipeline
progress without a bespoke endpoint.
- canvas: extend the S3 resume guard to reject legacy no-op nodes (e.g.
ExitLoop) so component_total equals the count of progress-reporting
components and the aggregate percent can reach 100%.
- runtime/canvas: route progress through TrackProgress; add interrupt
test coverage (r3_interrupt_test.go).
- dao/entity: add IngestionTask.DocumentID column and AggregateProgress
support used by the mirror; IngestionTaskLog keeps a Checkpoint column
alongside the progress fields.

feat(deepdoc): cache DocAnalyzer inference results in Redis (1h TTL)
- Redis-backed DocAnalyzerCache decorator over inference.Client; cache
key = "ddoc:cache:<method>:" + sha256 of the JPEG-encoded image bytes
(deterministic).
- TTL = 1h; hits skip the inner HTTP call and return cached JSON; inner
errors are not cached.

refactor(deepdoc): align figure cropping with Python cropout + bounded
page caches
- CropSectionByDLA mirrors Python cropout: best-overlap DLA
figure/equation region, fallback to section bbox per page, vertical
concat on gray background.
- sliding-window page-image cache bounds peak memory to the recent
window instead of the whole PDF.
- rename DLADebug -> DLARegions across parser/chunker/tests.

refactor(parser): drop lib_type selector; align NewXxxParser with
NewPDFParser
- remove config["lib_type"] lookup and the libType param/field/switch
from all nine constructors; surface the CGO-required error at
ParseWithResult time instead of construction time; drop resolveLibType,
its test, and the four lib_type constants.

feat(utility): add a reusable workerpool for bounded concurrent
execution
- internal/utility/workerpool.go (+ tests).

refactor: translate Chinese prose comments to English in non-harness Go
files.

chore: upgrade github.com/cloudwego/eino from v0.9.9 to v0.9.12.
2026-07-10 10:36:10 +08:00

51 KiB
Raw Blame History

Ingestion PipelineProgress / Log / Resume 机制计划书

范围:internal/ingestion/pipeline 的执行可观测性与断点续跑。 关联代码:internal/agent/canvas/*(框架闸口)、internal/agent/runtime/*progress 回调)、internal/ingestion/*ingestion 专属逻辑)。 状态:设计稿 v2v1 经评审修订,修订记录见文末 §10。结论续跑走 Beino interrupt/checkpoint观测走轻量 task_log不存全量 output


1. 背景与目标

ingestion pipeline 是把一份 DSL 编译成 eino 图后完整执行(Pipeline.Runcompiled.Workflow.Invoke)。需要三件事:

  1. Progress进度:框架层对每个 component 的发令(进入/退出/失败),供 UI/API 展示。
  2. Log日志:把 progress 落库成可回读的 ingestion_task_logAPI 侧 IngestionTaskLogDAO.ListLogsByTaskID 读取。
  3. Resume续跑:重跑同一个 tasktask_id / pipeline_id 不变)时,已成功执行过的 component 不必重做。

目标约束(来自 node_body.go:156 的项目自身原则):

cross-cutting concerns belong in the framework body (realComponentBody), not inside each component's Invoke.

即 progress / resume 不应回到 Parser 等组件内部(否则每个组件都依赖 dao,且违反单一闸口原则)。


2. 现状(已落地的部分)

区分两件事:功能是否完整(能否在进程内 interrupt→resume持久化是否到位(崩溃/重启后能否恢复)。

能力 位置 状态
框架级 progress 发令 node_body.go:187 runtime.TrackProgress → 从 ctx 取 ProgressCallback 已落地(功能完整)
pipeline 写 ingestion_task_log pipeline.go:165 taskLogProgressCallbackdao.DB==nil 时 no-op 已落地,但 progress 字段语义是状态 {0,1,-1},无序号、无进入/退出区分、无输出序列化
eino WithInterruptAfterNodes 接线 compile.go:56,78,143 CompileOptions.InterruptAftercompose.WithInterruptAfterNodes 已落地(功能完整)
eino CheckPointStoreRedis compile.go:63 + checkpoint_store.go RedisCheckPointStorekey=agent:cp:{id} 已落地(功能完整)
状态机running/succeeded/failed/cancelled run_tracker.go RunTrackerkey=agent:run:{run_id},状态 0/1/2/3 已落地(功能完整)
interrupt 捕获 / resume id 暂存 runner.go:163 interruptIDs内存 map 功能完整agent/chat 用,进程内可用);⚠️ 持久化待补跨进程重启即丢ingestion 不能依赖)
UserFillUp interrupt 发令 interrupt_resume.go UserFillUpNodeBodycompose.Interrupt / GetResumeContext 发令功能完整;⚠️ resume 侧(ResumeWithData)尚未接线(仅注释提及)
稳定 CheckPointID 选项 compile.go CompileOptions WithCheckPointID,目前 CheckPointID 每编译一次生成

关键结论:续跑要用的「执行完 interrupt → 持久化 checkpoint → 后续 resume」底座在 canvas 层已经齐全功能层面ingestion 只需把 Pipeline.Run 接上,并补齐 interrupt id 的跨进程持久化 这一缺口。


3. 路线 A自研 task_log 缓存 + 框架闸口跳过(先前提案,已放弃用于续跑)

机制

  • realComponentBodycomp.Invoke 前,从 ctx 取 ResumeProvideragent 侧为 nilcpnID 命中则直接 return cachedOutput,不调 Invoke
  • Pipeline.Run 预读 ingestion_task_log 的 exit 行 → 构建 map[cpnID]output,经 ctx 注入;并注入 cpnID→index 映射供 progress 写 0-based 序号。
  • 失效靠 input_fingerprint = hash(in)exit log 记指纹resume 时组件输入指纹不匹配则重跑。

优点

  • 不依赖 RedisDB 无关(测试友好)。
  • 对 eino resume 语义零依赖,完全自控。

缺点(这是放弃它做续跑的原因)

  • 重造 checkpointeino 已有现成的图级 checkpoint + 序列化 + 恢复A 等于自己再写一遍,且对 DAG / Loop / Parallel 的拓扑正确性要自己保证。
  • 框架闸口 skip 是 hack:在 realComponentBody 里短路 Invoke与「pipeline 完整执行 DSL」的契约微妙冲突eino 也失去了它自己对该节点执行结果的一致性保证。
  • 体积(量化)exit log 要存全量 outputlongtext。预估单文件 parser 输出通常 KB~MB长文档 / 多分片可达 十 MB 级;若按「每 component 一条 exit 行 + 全量 output」落库单 task 的 ingestion_task_log 体积 = Σ(output_size),可能达 数十 MB。这正是 B 方案把全量 output 交给 eino checkpointRedis带 TTL可过期而非 task_logDB longtext需永久保留的原因。
  • 指纹失效不彻底:仅 (cpnID, fingerprint) 仍可能在 DSL 结构变化(节点增删、边重连)时误命中。

4. 路线 Beino interrupt-after-node + Redis checkpoint推荐

思路:每个 component 执行完成后 由 eino 自动 interrupt,把整图状态(含该节点输出)序列化进 Redis checkpointorchestrator 捕获 interrupt 后 ResumeWithData 续跑下一步。进程崩溃/重启后,下一次 Pipeline.Run 检测到遗留 checkpoint 即从中恢复。

4.1 接线(编译期)

Pipeline.Run 编译时传入:

compiled, err := canvas.Compile(ctx, p.canvas,
    canvas.WithCheckPointStore(dao.NewRedisCheckPointStore(ttl)), // 复用 checkpoint_store.go
    canvas.WithStateSerializer(canvas.CanvasStateSerializer{}),  // state_serializer.goJSON
    canvas.WithInterruptAfterNonTerminalCpn(),                  // 无参canvas.Compile 内部算出非终态节点并 interrupt
    canvas.WithCheckPointID(p.taskID),                           // 稳定 id = taskID需新增选项见 §6
)
  • WithInterruptAfterNonTerminalCpn()无参,用户建议):调用方不再需要计算 / 传入 nonTerminalCpnIDscanvas.Compile 在内部直接根据 Canvas 结构算出「非终态节点 id」见 §4.1.a并对它们注册 WithInterruptAfterNodes。这样接线从调用方下沉到 Compile,避免「算错集合 / 传 allCpnIDs」类错误见 §4.1.a也让 UserFillUp 排除§4.2.b由同一处统一处理。

4.1.a 为什么不能用 allCpnIDs(用户发现 2

WithInterruptAfter(allCpnIDs) 会在每个节点(含终态/输出节点)执行后再插入一次 eino interrupt。终态节点的 Invoke 返回即代表整图完成若把它纳入eino 会在它执行后再 pause 一次,导致 orchestrator 多一轮无意义的 ResumeWithData 才真正结束,且可能让 Invoke 返回 interrupt 错误而非完成结果,打乱 §4.2 的 err==nil → break 判定。

正确做法:只对「有下游」的非终态节点 interrupt;终态节点正常退出 → err==nil → 循环 break整图一次完成。该集合由 canvas.Compile 内部计算(出度 > 0 的中间节点 = Canvas.Components 中不是 Canvas.Outputs 的节点),调用方用无参 WithInterruptAfterNonTerminalCpn() 即可,无需也无法传错集合。

  • WithCheckPointID:目前 CompileOptions 没有该字段,需新增(映射 compose.WithCheckPointID),使 checkpoint key 稳定为 agent:cp:{taskID},重跑同一个 task 才命中同一份 checkpoint。

4.2 运行期:进程内循环 + 跨进程恢复(统一在 Pipeline.Run

// Pipeline.Run 内部
for {
    // 崩溃恢复优先(用户建议):每轮首要动作先判断是否有遗留中断。
    // 上一进程在「命中 interrupt → 持久化 interrupt id」之后、消费它之前崩溃
    // 会在 Redis 留下未消费的 interrupt_id。本轮第一件事就是直接 resume 该中断,
    // 而不是从图入口重跑(那样会重复执行已完成节点)。
    if pending := runTracker.GetInterruptID(ctx, p.taskID); pending != "" {
        runCtx = compose.ResumeWithData(runCtx, pending, nil)
        runTracker.ClearInterruptID(ctx, p.taskID) // 已消费,避免下一轮重复 resume
    }
    out, err := compiled.Workflow.Invoke(runCtx, current)
    if err == nil {
        break // 整图完成
    }
    if !canvas.IsInterruptError(err) {
        return nil, err // 真错误
    }
    // 续跑型 interrupt不在本轮回内联 resume交给下一轮顶部统一 resume
    // 避免同一 ctx 被重复 ResumeWithData
    // 安全前提见 §4.2.bingestion DSL 不得含 UserFillUp 节点,
    // 因此 IsInterruptError 命中即续跑,不会误当 wait-for-user。
    ctxs := canvas.ExtractInterruptContexts(err)
    interruptID := canvas.RootInterruptID(ctxs)
    runTracker.AttachInterrupt(ctx, p.taskID, interruptID)   // 持久化到同一 hash见 §4.4),供崩溃后恢复
}

单次 Pipeline.Run 调用即可跑完整条 pipeline每节点执行后把状态落 Redis中途崩溃则下一次调用恢复。

4.2.b 安全前置:UserFillUp 在 ingest DSL 的禁止S3

ingestion 当前无交互节点,但若将来 DSL 增加 UserFillUp(如人工确认 chunking 结果),IsInterruptError 命中会被误判为续跑。前置守卫二选一并写进编译契约:

  • 方案 A推荐编译期禁止canvas.Compile 增加 InterruptAfterNonTerminalCpnUserFillUp 的互斥校验——ingestion 走该选项时,若 DSL 含 UserFillUp 节点直接编译失败,返回明确错误。
  • 方案 B运行期过滤canvas.Compile 在计算 nonTerminalCpnIDs(供 WithInterruptAfterNonTerminalCpn 使用)时显式剔除 UserFillUp 类节点,且不对其注册 after-node interrupt其余节点的 interrupt 才走续跑路径。

共存原理QuestionWithInterruptAfter 与用户导致的 interrupt 能否共存?):两者都是 eino 的同一套 interrupt 原语,仅靠 interrupt id 区分,由 IsInterruptError 统一捕获、ExtractInterruptContexts / RootInterruptID 取出对应 id。WithInterruptAfterNodes 是「编排级」pause节点跑完由框架插入resume 传 nilcompose.InterruptUserFillUp 内部调用是「节点级」pause节点自己发令、等待用户数据resume 传用户数据)。它们可以共存于同一图——只要同一个节点不被两种机制重复注册。ingestion 通过 §4.2.b 把 UserFillUp 排除出 nonTerminalCpnIDs,使其仅保留自身 compose.Interrupt 作为该节点的唯一 interrupt 源;其余节点走 after-node interrupt。该共存行为需在 eino v0.9.12 上 spike 确认(见新增 R7但原理上无冲突。

本期 ingestion DSL 保证无 UserFillUp,两条守卫任选其一落地即可消除 R6 歧义。

4.3 跨调用恢复(「重复运行一个 task」

Pipeline.Run 开头判断:

st := runTracker.Get(ctx, p.taskID) // agent:run:{taskID}status ∈ {0,1,2,3}
switch st["status"] {
case runStatusRunning:              // 0上次跑崩/被中断,保留 checkpoint + interrupt_id
    // 不删、不在此处 resume由 §4.2 循环顶部读 GetInterruptID 直接恢复
case runStatusCancelled:            // 3见 §4.3.b
    store.Delete(ctx, p.taskID)     // 清旧 checkpoint + 整跑(默认策略)
case runStatusSucceeded, runStatusFailed, "": // 1/2/"":已完成或全新 → 重跑
    store.Delete(ctx, p.taskID)     // 清旧 checkpoint连带清 interrupt_id循环中无 pending → 从入口跑)
}
  • running 的续跑入口在循环顶部:状态机只决定「保不保留 checkpoint」。running 时不删,下一行进入 §4.2 的 for 循环,循环顶部 GetInterruptID 读到遗留 interrupt_id 即 ResumeWithData 恢复——所以恢复逻辑只有一处(循环顶),不在 §4.3 重复写(避免与 §4.2 两处 resume 漂移)。

  • 其余状态 Delete 会把 checkpoint 与 interrupt_id 一同清掉,循环顶部 GetInterruptID 返回 "" → 从图入口正常跑eino checkpoint 已删,不会命中旧状态)。

  • 失效策略=状态机,而非 A 的指纹:running → 复用;其余 → 重置重跑。比指纹更可靠(天然覆盖 DSL 结构变化)。

  • 这与 run_tracker.goStart/MarkSucceeded/MarkFailed/AttachCheckpoint 完全对齐;Pipeline.Run 在开始时 Start、完成时 MarkSucceeded、失败时 MarkFailed

4.3.a 重跑 vs 续跑语义S2

ingestion 不区分「重跑」与「续跑」两种入口——它的语义统一为:

task_id 重做整图;框架用 checkpoint 跳过已执行节点。状态机只决定「是否命中遗留 checkpoint」running 命中复用,其余清掉重跑。

即:用户再次提交同一 task_id(合法重跑用例)时,行为是「先清旧 checkpoint 再整跑」——这是正确的(用户明确要重做)。如果将来需要「基于上次结果继续而不重做」,需新增独立的 resume 入口(不在本期范围),届时 succeeded 分支才需要区分「清掉重跑」与「保留续跑」。本期在 API / 用户文档中明确此语义。

4.3.b cancelled 状态恢复策略S5

RunTrackerrunStatusCancelled = "3"run_tracker.go:44)。本期策略:

  • 命中 cancelled 默认不允许自动续跑 → 清 checkpoint + 整跑(与 failed 一致),避免「用户取消后悄悄继续」的语义歧义。
  • 可选增强(不在本期):暴露显式 resume cancelled 入口,由人工/API 触发,才走 running 的复用分支。

4.4 需要新增的持久化M3 + S4

runner.go:163interruptIDs内存 map进程重启即丢。

  • M3 决策(不新增 key:复用现有 agent:run:{taskID} hash新增 interrupt_id 字段,而非引入第二种持久化约定(agent:cpint:{taskID})。给 RunTracker 加:
    func (t *RunTracker) AttachInterrupt(ctx, runID, interruptID string) error // HSet "interrupt_id"
    func (t *RunTracker) GetInterruptID(ctx, runID) (string, error)            // HGet "interrupt_id"
    func (t *RunTracker) ClearInterruptID(ctx, runID) error                    // HDel "interrupt_id"(循环顶部消费后调用)
    
    与既有的 AttachCheckpoint(写 checkpoint_id 字段,run_tracker.go:110)同 hash、同生命周期、同 TTL维护成本最低。
  • S4 边界(不动 agent/chat:本次改动仅引入 ingestion 用的持久化路径runner.go 的内存 interruptIDs 继续服务 agent/chat 路径,不做合并——不为「统一性」强行改 agent 调用方(遵循 CLAUDE.mdagent 仍在用就保留)。文档明确这一点,避免后续实现者把它当 deprecated 留着或误删。

4.5 优点

  • 复用现成底座eino 负责 checkpoint 序列化/恢复,对 DAG/Loop/Parallel 拓扑正确性是 eino 保证的,不自己再造。
  • 组件零改动、零 dao 依赖Parser 等完全不知道 resume 存在,符合 node_body.go:156 原则。
  • 用户原始意图被升级为框架级 checkpoint澄清:用户最早设想「组件读自己的 exit log 跳过」,方案 B 不是让组件去读自己的 log而是框架统一用 eino checkpoint 在闸口处跳过——组件仍然零改动,意图以更稳健的方式达成(等价于「框架替组件做了它想做的事」)。
  • 失效干净:状态机驱动,无指纹误命中问题。
  • 天然支持用户需求:「重跑同 task已完成组件不重做」= checkpoint 恢复,零额外代码。

4.6 缺点 / 风险(见 §6 风险与 spike

  • 依赖 Redisingestion 已用 Redis可接受无 Redis 时的可见降级见 R5
  • 需把 Pipeline.Run 从单次 Invoke 改为循环,且管理 interrupt id 持久化。
  • eino 序列化保真度checkpoint = CanvasState 整体 JSONstate_serializer.gojson.Marshal。native 解析器等若产出非 JSON 类型([]byte、自定义 struct会断 resume——需 spikeR1
  • 需确认 WithInterruptAfterNodes + 全图 ResumeWithData 在 Loop/Parallel 下的精确语义R3P0

5. Progress / Log 改进(路线无关,必做)

无论续跑走 A 还是 BtaskLogProgressCallback 都该升级为结构化事件,满足用户最初要求:

  • progress 字段改为 (component 序号 0-based, 相位 enter/exit/error)
  • 不携带 component 的 Outputresume 由框架 eino checkpoint 负责progress/log 仅是观测,不需要 output用户决定。这同时消解了 M5「事件携带 vs sink 不落库」的紧张——两层现在一致「都不存 output」。

5.1 共享类型保持纯净M1

runtime.ProgressEvent 携带 Index——Index 是 ingestion 专有概念,放进 agent/ingestion 共享类型属于抽象泄漏。定义:

// internal/agent/runtime共享agent 与 ingestion 共用,保持纯净)
type ProgressPhase int
const (PhaseEnter ProgressPhase = iota; PhaseExit; PhaseError)
type ProgressEvent struct {
    Phase     ProgressPhase
    Component string            // cpnID
    Err       error             // 仅 PhaseError 非空
    // 不携带 Outputresume 由 eino checkpoint 负责progress 仅是观测(用户决定)
}
type ProgressCallback func(ProgressEvent)

realComponentBodycpnID 是闭包参数(node_body.go:180),而 node_body.go:187 当前传给 TrackProgress第一参数是 componentClassclass 名),不是节点 id。改造时把该第一参数从 componentClass 改为 cpnIDruntime.TrackProgress(cpnID, ...),使 ProgressEvent.Component 填的是节点 idProgressEvent.Component 用于落库定位与排序需唯一标识节点class 名无法区分同名多实例)。采用「只改调用处第一参数」方案,不扩 TrackProgress 签名(更少改动,符合用户建议)。realComponentBody 改为发 ProgressEvent{Phase, Component: cpnID, ...}enter 时 Phase=PhaseEnterexit 时 Phase=PhaseExiterror 时 Phase=PhaseError 且填 Err不读取、不传递组件 output

5.2 ingestion 侧注入 IndexM1 落地)

pipeline 包内定义轻量包装Index 由 sink 层用 pipeline 注入 ctx 的 cpnID→index 映射算出,不污染共享类型:

// internal/ingestion/pipelineingestion 专有)
type pipelineProgressEvent struct {
    Index int                // 0-based来自 ComponentIndexMap
    Event runtime.ProgressEvent
}

taskLogProgressCallback 闭包持有 cpnID→index 映射,收到 ProgressEvent 后计算 Index = indexMap[event.Component]再写库。Index 的归属与计算完全留在 ingestion 侧。

5.3 落库策略M5已消解

ProgressEvent 不携带 Output用户决定resume 不需要它output 完整回放本就交给 eino checkpoint / Redisprogress 仅是观测)。因此 M5 原本的「事件携带 vs sink 不落库」紧张关系不再存在——两层一致事件不带、sink 也不存。

时间戳Q1 结论event 不携带时间戳)

  • ProgressEvent 不携带时间戳。回调在 realComponentBody 内同步触发、sink 立即落库emit≈落库。
  • 时间戳只由 GORM BaseModel.create_time 自动写入(秒级精度),作为行级元数据;现有 pipeline.gocreate_time 又塞进 checkpoint JSON 是冗余,应删除
  • 行内排序以自增 id 为准(LatestLogByTaskID 已依赖 id DESC 做确定性 tie-breakcreate_time 秒级会撞——event 时间戳对排序无增量价值。
  • 与移除 Output 一致,保持共享 ProgressEvent 精简M1 纯净化)。

taskLogProgressCallback 直接写独立列(component_index / phase / component / message不再写 checkpoint JSON(不含 output、不含 create_time时间戳仅来自 BaseModel.create_time。任何地方都不在 progress 通道里携带组件全量 output体积问题见 §3 量化,且 resume 不依赖它)。


6. 风险与待验证Spike附优先级

# 风险 优先级 验证方式
R1 CanvasState 经 JSON 序列化/反序列化保真native 解析器 []byte/自定义类型) P1 集成测试:跑含 Parser 的 DSL中断→恢复断言恢复后输出与一次性跑一致
R2 稳定 CheckPointID 接线 P1 CompileOptionsCheckPointID + WithCheckPointID;单测验证 key=agent:cp:{taskID}
R3 WithInterruptAfterNodes + 全图 ResumeWithData 在 Loop/Parallel/DAG 下的语义 P0 已通过2026-07-09internal/agent/workflowx/r3_interrupt_test.go 五个集成用例全绿DAG / 含 Loop 前置节点 / 含 Loop 节点本身 / 含 Parallel / 跨进程重编译同 CheckPointID 恢复。结论interrupt-after-node + ResumeWithData 在三类拓扑下语义正确——① 已完成节点interrupt 之前的resume 后不重跑counter 恒为 1无「重解析文件」风险② Loop/Parallel 正确重入或已完成则跳过counter 不增);③ 跨进程崩溃恢复(重编译图 + 同 WithCheckPointID)可续跑。无需 M2 回退§8 第 3 步可按原计划推进。注意after-node interrupt 仅在有状态图WithGenLocalState,与 canvas.BuildWorkflowCanvasState 一致)下才会被包成 *interruptError 并被 ExtractInterruptInfo 识别;无状态图返回裸 *core.InterruptSignalExtractInterruptInfo 返回 nil。ingestion 路径因 CanvasState 已注册而天然满足。
R4 interrupt id 持久化与崩溃恢复 P1 进程内循环跑完正常;模拟「跑崩在节点 N」→ 新进程 Pipeline.Run 从该断点续跑
R5 Redis 不可用降级(可见,非静默) P1 见 §6.a启动探测 Redis → 拒绝启动并明确报错,或在 task_log 写 resume_degraded warning
R6 ingestion「续跑型 interrupt」与 agent「wait-for-user interrupt」区分 P2 由 §4.2.b 的 UserFillUp 禁止/过滤守卫消除,无需靠运行期猜测
R7 WithInterruptAfterNodes 与节点内 compose.InterruptUserFillUp在同一图的共存语义 P1 eino v0.9.12 集成测试:构造含 UserFillUp + 非终态 after-node interrupt 的图,断言两者按 interrupt id 分别 resume且 UserFillUp 节点不被重复 interrupt见 §4.2.b 共存原理)

6.a R5 可见降级M4

静默降级为「整跑」会悄悄违反用户需求「重跑同 task已完成组件不重做」且无上层信号。改为可见二选一:

  • 方案 A推荐ingest job 启动时探测 Redis 不可用 → 直接拒绝启动并返回明确错误(不在无 checkpoint 能力下假装支持 resume
  • 方案 B:降级路径必须在 ingestion_task_log 写一条 resume_degraded warning含原因使 API 侧能查询到「本次为整跑、无续跑保证」。

两种都保证不静默。本期选 A强信号B 作为可加的审计记录。

6.b R3 不通过的回退M2

若 eino v0.9.12 不支持「全图 interrupt-after-node + ResumeWithData」

  • 降级为「只在 DAG 节点上 interruptLoop/Parallel 内部不 interrupt整段作为原子单元
  • 回退到路线 A 的轻量版(仅 progress/log无 resume

R3 已于 2026-07-09 通过(见 §6 R3 行 + internal/agent/workflowx/r3_interrupt_test.goM2 回退路径当前不需要§8 第 3 步的 resume 循环可按原计划安全推进。


7. 推荐方案

续跑走 B观测走轻量 task_log

  1. ProgressCallback 升级为结构化事件(序号 + 进入/退出 + 退出带输出),taskLogProgressCallback 写轻量 ingestion_task_logsink 不存全量 output见 §5.3)。
  2. Pipeline.Run 接 eino interrupt/checkpoint编译期 WithInterruptAfterNonTerminalCpn()(无参)+ WithCheckPointStore + WithCheckPointID(taskID);运行期内部 resume 循环(循环顶部先判断遗留中断并恢复);RunTracker 状态机驱动跨调用恢复与失效。
  3. 组件(Parser 等)完全不改、不依赖 dao;续跑由框架闸口 + Redis checkpoint 统一负责。

理由B 复用仓库已建好的 eino 底座(功能完整,仅缺 ingest 用持久化最省事且对图拓扑正确性最稳A 的「自研缓存 + 闸口跳过」仅在「无 Redis、必须 DB 无关」的极端约束下才值得。


8. 实施步骤(建议顺序,含评审修订)

  1. schema 评估与迁移S1新增+ 前端取数契约Q2/Q3 已实现entity/DAO/pipeline 落地dao+pipeline 包测试通过)

    • 表结构(最小迁移,沿用 ingestion_task_log,但 checkpoint JSON 拆列):原 checkpoint LONGTEXT(JSON) 按 key 拆成独立列便于直接访问与聚合(用户决定):id BIGINT 自增 PKtask_id VARCHAR(32) 索引;component_index INT0-based 序号key indexphase TINYINTkey phase,复用 ProgressPhase0=enter / 1=exit / 2=errorcomponent VARCHAR(64)cpnIDkey component,建议加索引便于聚合);message TEXTkey messageBaseModelcreate_time/update_time/deleted_at,时间戳只此一处)。不再有 checkpoint(旧 {progress,message,create_time} 整体废弃)。拆列后 AggregateProgress 可纯 SQL GROUP BY phase, component 完成,无需解析 JSON见下方端点
    • progress 字段兼容:旧编码 {progress:0/1/-1, message, create_time} 原本整体在 checkpoint JSON 列内(无独立列)。现改为拆列checkpoint 列直接删除,旧 IngestionTaskLog.Create 路径(写 JSONMap随之废弃IngestionTaskLogDAO 改用直接写列的 Create(实体字段即列),无需 CreateStructured 双写。需确认现有 API 消费者是否依赖旧 checkpoint.progress 编码(见下方端点兼容性),若有则端点层做字段映射兼容。
    • 总组件数 N算百分比必需:在 ingestion_task新增加法列 component_total INT(任务创建时由 DSL 节点数写入)。加法列迁移安全,且让"完成百分比"有服务端权威 N。
    • 前端取数端点(建议)
      • GET /api/v1/ingestion_task/{task_id}/logs → 复用 IngestionTaskLogDAO.ListLogsByTaskID改为按 id ASC(时间正序,日志流用)返回 {id, task_id, index, phase, component, message, create_time}。前端按 phase 渲染0 开始 / 1 完成 / 2 失败)。
      • GET /api/v1/ingestion_task/{task_id}/progress → 服务端聚合 {total, done, failed, running, percent, status}。因 phase/component 已是独立列,AggregateProgress 可纯 SQL 完成(无需解析 JSONcomponent 取每个组件最新 phase(按 id DESC 取每组件末行),再 CASE WHEN 末phase=1 THEN 'done' WHEN 末phase=2 THEN 'failed' ELSE 'running' END 计数;percent = done/total*100totalingestion_task.component_total。前端拉进度条不必先拉全量日志。
    • 产出migration 脚本(含 component_total 加法列)+ API 兼容性结论,进入第 1 步前完成。
    • 已落地(本次)
      • entity.IngestionTaskLogCheckpoint JSONMap → 四列 ComponentIndex(INT) / Phase(INT) / Component(VARCHAR(64),index) / Message(TEXT)entity.IngestionTask 新增 ComponentTotal(INT,default 0)。GORM AutoMigrate 自动加列(加法安全)。
      • daoIngestionTaskDAO.UpdateComponentTotal(taskID,total)IngestionTaskLogDAO.ListLogsByTaskIDid ASC;新增 TaskProgress{Total,Done,Failed,Running,Percent}AggregateProgress(taskID,total)(子查询取每 component 最新 id→按 phase 分类计数;分类向前兼容 1c renumberdone=1failed∈{-1,2})。
      • pipeline.Run:编译后 best-effort UpdateComponentTotal(taskID, len(canvas.Components))DB 为空则跳过)。
      • ⚠️ 依赖 1csink 目前仍写 Component="" / ComponentIndex=0,故 AggregateProgressGROUP BY component 在 1ccpnID 注入前会退化为单组method 与 schema 已就绪,待 1c 填列后生效。
  2. 结构化 progress 事件(独立、低风险,但先做完影响面评估) 已实现runtime/canvas/pipeline 落地 + runtime/canvas/pipeline 包测试通过agent/ingestion 整树编译通过)

    • 1a. 影响面清点(已核实)ProgressCallback|TrackProgress|WithProgressCallback|ProgressCallbackFromContext 全仓仅 helpers.go/helpers_test.go/pipeline.go/node_body.go 4 个 Go 文件有真实引用;file.go/tokenizer.go 经内容搜索 Progress/TrackProgress 引用§9 标注为旧注释,已不实)。生产可执行调用方仅 node_body.goTrackProgress 一处)+ pipeline.gosinkWithProgressCallback/ProgressCallbackFromContext 签名随类型别名自动迁移,零改动。无 SDK / 外部调用方,无需对外征询。
    • 1b. 向后兼容策略 → 改为干净硬切:影响面仅 8 处生产 + 4 个测试函数,按 AGENTS.md「优先单路径、删废弃分支」原则未保留ProgressCallback func(int,string) 适配器(渐进迁移方案被否决,避免遗留 deprecated 双轨)。一次性把 ProgressCallback 改为 func(ProgressEvent),并同步重写 helpers_test.go 4 个测试。
    • 1c. 落地(已做)
      • runtime/helpers.go:新增 ProgressPhasePhaseEnter=0/PhaseExit=1/PhaseError=2,值稳定并持久化到 phase 列,属数据契约)+ ProgressEvent{Phase, Component, Err}Err 仅 error 相位非空;不携带 Output,见 §5.3ProgressCallback 改为 func(ProgressEvent)TrackProgress(cpnID, cb, fn)ProgressEvent{PhaseEnter} / PhaseExit / {PhaseError, Err}(不再拼 compName Started/Done/:err 字符串,消息改由 sink 派生)。
      • node_body.go:187TrackProgress(componentClass, ...)TrackProgress(cpnID, ...)ProgressEvent.Component 现填节点 id(唯一标识,可区分同名多实例);componentClass 仍用于 resolveTimeoutFromContext,无冗余。
      • pipeline.gotaskLogProgressCallback 改消费 ProgressEventcomponentIndexMap()(按排序 cpnID 给确定性 0-based 序号,因 map 遍历无序)算 ComponentIndex,并写 Component/Phase(=int(Phase))/ComponentIndex/Message(消息由相位派生:cpnID Started/Done/:err,保持前端兼容)。AggregateProgressGROUP BY component 在 1c 后已生效(按 cpnID 正确分组)。
    • ⚠️ 已知不一致(非阻塞)component_total 在 step 0 取 len(canvas.Components)(含 no-op/UserFillUp 节点),而这些节点的 bodylegacyNoOpBody/UserFillUpNodeBody不发 TrackProgress,故 done 计数可能永远 < total,进度百分比上限 < 100%。属观测精度问题,留待后续按需把分母改为「实际会发事件的组件数」。
  3. CompileOptionsCheckPointIDR2 已实现canvas 包测试通过)

    • 关键 API 事实修正compose.WithCheckPointID(id) 返回的是 运行期 compose.Optioncheckpoint.go:74不是 GraphCompileOption,无法在 CompileWorkflow.Compile(...GraphCompileOption))里直接 append。因此 step 2 compose.WithCheckPointID 塞进编译选项,而是:
      • CompileOptions 新增 CheckPointID string 字段 + WithCheckPointID(id) 选项(仅把 id 记到选项里);
      • Compilecfg.CheckPointID 存进返回的 CompiledCanvas.CheckPointID(该字段早已存在却从未赋值);
      • 运行期接线(真正的 compose.WithCheckPointID)留到 step 3pipeline.gocompiled.Workflow.Invoke(runCtx, current, compose.WithCheckPointID(compiled.CheckPointID)) 时传。service 层 agent.go:1041 早已自行维护 cpID=runIDcompose.WithCheckPointID(cpID),不受本改动影响(它不读 compiled.CheckPointID)。
    • 无参 WithInterruptAfterNonTerminalCpn()建议1已实现CompileOptions 新增 InterruptAfterNonTerminal bool + 无参 WithInterruptAfterNonTerminalCpn()Compile 内部调用 computeNonTerminalCpnIDs(c) 算出「出度>0 的中间节点」(非终态),与调用方传入的 InterruptAfter 合并去重后统一 compose.WithInterruptAfterNodes(...)。选择规则:排除终态节点(无 Downstream,避免 Invoke 返回 interrupt 而非完成、并省一轮无意义 ResumeWithData排除 UserFillUp 节点§4.2.b它自带 compose.Interrupt,不与之重复注册)。CanvasOutputs 字段,故「非终态」以 len(Downstream)>0 为准(与 §4.1.a 出度>0 等价)。
    • ⚠️ 接线约束WithInterruptAfterNonTerminalCpn() 一旦被 pipeline.go 调用而尚无 step 3 的 resume 循环Invoke 会在第一个非终态节点暂停并返回 interrupt 错误,直接破坏 ingestion。故 step 2 只动 compile.go + 单测pipeline.go 的真正接线compile 选项 + Invokecompose.WithCheckPointID + resume 循环)整体归入 step 3R3 gate 之后)。
    • 单测compile_test.go 新增 TestWithCheckPointID_OptionSetsFieldTestWithInterruptAfterNonTerminalCpn_OptionSetsFieldTestComputeNonTerminalCpnIDs(断言 n1,n2 命中、n3/n4 终态排除、uf UserFillUp 排除)、TestCompile_PropagatesCheckPointID(编译成功则断言 CompiledCanvas.CheckPointID==task-9,编译失败则 skip与现有 compile_test 忽略编译错误的约定一致)。
  4. Pipeline.Run resume 循环 已实现2026-07-09R3 已于 2026-07-09 通过(见 §6 R3 行),无需 M2 回退。落地于 internal/ingestion/pipeline/pipeline.goRunresolveStore()/resolveTracker()(注入优先,否则按 redis.Get() 解析),有 store 才编译期接 WithCheckPointStore+WithCheckPointID(p.taskID)+WithInterruptAfterNonTerminalCpn() 进入 resumable 路径;运行期 for 循环(maxResumeRounds=1000 防呆)顶部先恢复中断优先级RunTracker GetInterruptID 跨进程恢复 > 进程内 localInterruptID 兜底),命中 interrupt 后仅 AttachInterrupt 持久化、下一轮顶部统一 ResumeWithData(ctx, id, nil)(不内联 resume避免同 ctx 双 resume依 §4.2 建议2RunTracker.Start/MarkSucceeded/MarkFailed 接入;cancelledcontext.Canceled/DeadlineExceeded)分支按 §4.3.b 调 cleanupCheckpoint(删 eino checkpoint + ClearInterruptID)并 MarkCancelled;无 store 时降级为单次 runPlain(不破坏无 Redis 的现有测试。interrupt 捕获用 canvas.IsInterruptError/ExtractInterruptContexts/FirstInterruptID。端到端测试 TestPipelineRunResumableAutoResumes(注入 in-memory store断言每个节点恰好执行 1 次——验证 R3「resume 不重跑」在 pipeline 层成立)。

  5. interrupt id 持久化M3 已实现2026-07-09internal/agent/canvas/run_tracker.goAttachInterrupt/GetInterruptID/ClearInterruptID,写同一 hash 的 interrupt_id 字段(不新增 keyagent/chat 路径不动S4。与 step 3 的进程内 localInterruptID 兜底共同构成跨/进程内双层恢复。

  6. 降级与守卫M4 + S3 已实现2026-07-09

    • M4§6.a 方案 A可见拒绝非静默PipelinerequireResume bool + WithRequireResume() 选项 + var ErrResumeUnavailable = errors.New(...) sentinel。RunresolveStore() 之后、Compile 之前加检查:当 requireResume && store==nil(无注入 store 且无全局 Redis时返回 fmt.Errorf("pipeline: Run: %w", ErrResumeUnavailable),由调用方 errors.Is 识别并拒绝入队(而非静默降级 runPlain)。默认(不置 requireResume)保持原有静默降级 runPlain,不破坏无 Redis 的单元/headless 测试;生产 ingestion 接线时启用 WithRequireResume() 即「Redis 不可用 → 拒绝启动」的强信号。
    • S3§4.2.b 方案 A编译期硬拒绝internal/agent/canvas/compile.goBuildWorkflow 之前加护栏——当 cfg.InterruptAfterNonTerminalingestion resume 模式)且 AutoDiscoverUserFillUpIDs(c) 非空时,Compile 直接返回错误 "...WithInterruptAfterNonTerminalCpn forbids UserFillUp nodes ... would be mis-resumed with nil data"。理由:UserFillUp 自身发 compose.Interruptwait-for-userrunResumableIsInterruptError 全捕获并以 nil 自动续跑,会静默跳过人工交互;编译期硬拒使其永不发生。先于 step 2 已有的 computeNonTerminalCpnIDs 过滤(方案 B不重复注册构成双层守卫。
    • 测试:pipeline_test.goTestPipelineRun_RequireResumeRejectsWithoutStore(无 store + WithRequireResumeErrResumeUnavailablecompile_test.goTestCompile_RejectsUserFillUpInResumeMode(含 UserFillUp + WithInterruptAfterNonTerminalCpn → 编译错误且含 "UserFillUp")。bash build.sh --test ./internal/ingestion/pipeline/... ./internal/agent/canvas/... 全绿2026-07-09
  7. Spike 验证§6 R1R4通过后默认开启 checkpoint否则回退为纯 progress/log无 resumeM2 回退路径)。


9. 涉及文件与影响面

    • internal/agent/runtime/helpers.goProgressCallback / TrackProgress / WithProgressCallback / ProgressCallbackFromContext 签名变更)。 影响面(已全仓 grep 核实):共 42 处匹配、6 个文件,分布如下:

      文件 匹配数 性质
      internal/agent/runtime/helpers.go 17 被改文件本身(定义+实现+doc
      internal/agent/runtime/helpers_test.go 14 测试回归主成本
      internal/ingestion/pipeline/pipeline.go 6 生产调用方sink + WithProgressCallback 注入)
      internal/agent/canvas/node_body.go 2 生产调用方(框架发令)
      internal/ingestion/component/file.go 2 仅 doc 注释,非调用
      internal/ingestion/component/tokenizer.go 1 仅 doc 注释,非调用
      • 生产可执行调用方只有 pipeline.go + node_body.go(共 8 处);file.go/tokenizer.go 仅为注释,不影响编译。
      • 6 个文件全部在 agent runtime / canvas / ingestion 内,无 SDK / 外部调用方 → 第 1 步无需对外征询(用户发现 2 担心的「先通知外部」仅当存在外部调用方才需,本次不存在)。
      • 真正工作量在 helpers_test.go 的 14 处测试回归 + 8 处生产调用方改造;采用 §8 第 1b 步的向后兼容适配器降低风险。
    • internal/agent/canvas/node_body.go(发结构化事件;node_body.go:187TrackProgress 第一参数由 componentClass 改为 cpnID,见 §5.1)。

    • internal/agent/canvas/compile.go(加 CheckPointID 选项 + WithCheckPointID;新增无参 WithInterruptAfterNonTerminalCpn() —— Compile 内部计算非终态节点 id出度>0§4.1.a并对它们注册 WithInterruptAfterNodes,同时按 §4.2.b 排除 UserFillUp 节点 / 或与其互斥校验)。

    • internal/agent/canvas/run_tracker.go(新增 AttachInterrupt / GetInterruptID / ClearInterruptID,同 hash 字段)。

    • internal/ingestion/pipeline/pipeline.goRun resume 循环——循环顶部先 GetInterruptID 恢复崩溃遗留中断、命中后 AttachInterrupt 持久化;升级 taskLogProgressCallback + ComponentIndexMap 注入§4.2/§5.1UserFillUp 过滤已下沉到 compile.go,此处不再计算 nonTerminalCpnIDs§4.2.b

    • internal/dao(新增 IngestionTaskLogDAO 结构化写入方法 / schema 迁移S1

  • 复用(不改)internal/agent/canvas/checkpoint_store.gointernal/agent/canvas/interrupt_resume.gointernal/agent/canvas/state_serializer.gorunner.go 的 agent/chat 内存 interruptIDs保留S4
  • 不改internal/ingestion/component/*internal/agent/component/*(组件零改动)。

10. 修订记录v1 → v2基于评审

# 优先级 修订
M1 ProgressEvent.Index 不应进共享 runtime 类型 P0 共享类型去除 Indexingestion 包内 pipelineProgressEvent{Index, Event} 包装Index 由 sink 用 cpnID→index 映射算出§5.1/§5.2
M2 R3Loop/Parallel必须前置 spike P0 R3 升为 P0§8 第 3 步加 R3 gate新增 §6.b 回退路径
M3 interrupt id 复用 RunTracker hash 字段,别加新 key P1 §4.4 定为 AttachInterrupt 写同一 hash 的 interrupt_id,不引入 agent:cpint:{taskID}
M4 Redis 不可用降级要可见 P1 §6.a 改为拒绝启动 / 写 resume_degraded warning非静默整跑
M5 §5 与 §8 第 1 步 output 携带矛盾 P1 已消解:ProgressEvent 不携带 Output(用户决定 resume 不需要output 回放交给 eino checkpoint事件层与 sink 层一致「都不存 output」§5.3 重写为两层共识
S1 ingestion_task_log schema 兼容性 P2 §8 新增第 0 步 schema 评估与迁移;优先新增 DAO 方法
S2 succeeded 重跑 vs resume 语义 P2 §4.3.a 明确统一语义「按 task_id 重做整图,框架跳过已执行节点」
S3 UserFillUp 在 ingest DSL 的禁止/过滤 P2 §4.2.b 加编译期禁止 / 运行期过滤守卫
S4 runner.go 内存 interruptIDs 处置 P2 §4.4 明确 agent/chat 路径不动,本次仅引入 ingest 持久化路径
S5 cancelled 状态去向 P2 §4.3.b 明确 cancelled 默认清 checkpoint+整跑,可选暴露显式 resume 入口
排版 §2 ⚠️/ 区分「功能完整」与「持久化到位」 §2 表格重排,分两列表述
排版 §3 体积量化 §3 给出 ballpark单 task 可达数十 MB
排版 §4.5 第 2 条表述 澄清「用户意图被升级为框架级 checkpoint非组件读自己 log」
排版 §9 helpers.go 影响面 §9 列出已知调用方 + 需 grep sweep 的说明
发现1 realComponentBodycpnID 是闭包参数,ProgressEvent.Component 应填 cpnID 而非 componentClass §5.1 改为「调用处第一参数由 componentClass 改 cpnID」不扩 helper 签名
发现2 helpers.go 影响面实为 42 处匹配6 文件),非「少量」 §9 以全仓 grep 复核:生产可执行调用方仅 pipeline.go+node_body.go(8)file.go/tokenizer.go 仅 dochelpers_test.go 14 为测试回归主成本无外部调用方§8 第 1 步加 1a 影响面清点 + 1b 向后兼容渐进迁移
问答1 WithInterruptAfter 与 UserFillUp 的 interrupt 能否共存 §4.2.b 新增「共存原理」:同一 eino interrupt 原语按 id 区分不重复注册同一节点即可共存ingestion 排除 UserFillUp 出 nonTerminalCpnIDs新增 R7 spike
问答2 WithInterruptAfter 不应传 allCpnIDs §4.1 改为 nonTerminalCpnIDs(出度>0 的中间节点),新增 §4.1.a 解释终态节点不应 interrupt 的原因
建议1 WithInterruptAfter(nonTerminalCpnIDs) 参数化 → 无参 §4.1 / §4.1.a 改为无参 WithInterruptAfterNonTerminalCpn()Canvas.Components 出度>0 的节点集合由 canvas.Compile 内部计算,调用方无需也不能传错;UserFillUp 排除§4.2.b一并下沉到 Compile。§7/§8-3/§9 同步更新
建议2 Pipeline.Run 的 for 循环应先判断 interrupt 是否存在 §4.2 循环顶部新增「GetInterruptID 读遗留中断 → ResumeWithData 恢复 → ClearInterruptID」作为每轮首要动作;底部命中 interrupt 只 AttachInterrupt 持久化、不内联 resume避免同一 ctx 重复 resume。§4.3 状态机只做 keep/deleterunning 的恢复入口收敛到循环顶部一处,杜绝与 §4.2 两处 resume 漂移
决策 选择路线 B 路线 Beino interrupt-after-node + Redis checkpoint正式定为方案§5/§8 的 progress 改进对 B 同样必做(原本标注 A/B 兼容,现仅服务于 B
决策 ProgressEvent 不再存储 Output §5 开头、§5.1 类型定义、§5.3 落库策略、§8 第1c 步 全部改为事件与 sink 均不携带/不落库 Output理由resume 由 eino checkpoint 负责progress 仅观测,不需要 output同时彻底消解原 M5 矛盾)
决策 ProgressEvent 不携带时间戳Q1 §5.3 新增event 不带时间戳;时间戳只由 BaseModel.create_time 自动写入(秒级),排序以自增 id 为准(复用 LatestLogByTaskIDid DESC tie-break删除现有 pipeline.go 把 create_time 塞进 checkpoint JSON 的冗余
决策 schema 与前端取数契约Q2/Q3 §8 第0步补沿用 ingestion_task_log 表、checkpoint JSON 改为 {index,phase,component,message}ingestion_task 新增加法列 component_total;新增 GET .../logsid ASC+ GET .../progress{total,done,failed,running,percent,status} 服务端聚合)端点契约
决策 ingestion_task_log.checkpoint 拆成独立列 §8 第0步 / §5.3 / §8-1ccheckpoint LONGTEXT(JSON) 按 key 拆为 component_index(INT) / phase(TINYINT) / component(VARCHAR,建议索引) / message(TEXT) 四列,删除 checkpoint 列;AggregateProgress 改为纯 SQL GROUP BY phase,component(无需解析 JSONCreate(写 JSONMap) 路径废弃,无需 CreateStructured 双写
决策 §8 第1步 结构化 progress 事件(干净硬切,非渐进迁移) §8 第1步 / §5.1ProgressCallback 改为 func(ProgressEvent)(新增 ProgressPhase 枚举 0/1/2 + ProgressEvent{Phase,Component,Err},不携带 Output/时间戳);TrackProgress(cpnID,cb,fn) 发 enter/exit/error 事件;node_body.go 第一参数 componentClasscpnIDpipeline.go sink 消费 ProgressEvent 并由 componentIndexMap()(排序 cpnID 确定性序号)填 ComponentIndex+写 Component/Phase;未保留 1b 适配器(按 AGENTS.md 单路径原则);AggregateProgressGROUP BY component 在 1c 后已生效
决策 §8 第2步 CompileOptions.CheckPointID + 无参 WithInterruptAfterNonTerminalCpn() §8 第2步 / §4.1.a / §4.2.bAPI 事实修正——compose.WithCheckPointID 是运行期 compose.Option(非 GraphCompileOption),无法在 Compile 内直接 appendWithCheckPointID(id) 仅记到 CompileOptions.CheckPointIDCompile 存进 CompiledCanvas.CheckPointID真正 compose.WithCheckPointIDInvoke 侧接线留 step 3service 层 agent.go:1041 自行维护 cpID=runID,不受影响。②无参 WithInterruptAfterNonTerminalCpn()Compile 内部 computeNonTerminalCpnIDs(c) 算「出度>0 的中间节点」(排除终态与 UserFillUp),与调用方 InterruptAfter 合并去重后统一 compose.WithInterruptAfterNodes。③接线约束:该选项未接入 pipeline.go(无 step 3 resume 循环会破坏 ingestion整体接线归 step 3。④单测覆盖选项 setter + computeNonTerminalCpnIDs + 容错传播
决策 §6 R3 spike 结论P0 gate 已通过) R3 / DAG+Loop+Parallel / WithInterruptAfterNodes+ResumeWithData:①新增 internal/agent/workflowx/r3_interrupt_test.go 五个 eino 集成用例DAG 链 A→B→C、A→loop(B→C)→D 中断在 A、A→loop→D 中断在 loop 节点本身、A→parallel(B)→D、以及跨进程「重编译图+同 CheckPointID」恢复全部 bash build.sh --test ./internal/agent/workflowx/... 通过2026-07-09②核心结论已完成的节点 resume 后不重跑(节点 counter 恒为 1Loop/Parallel 正确重入或跳过,无需 M2 回退§8 第 3 步按原计划推进③关键实现约束after-node interrupt 仅在有状态图WithGenLocalState,与 canvas.BuildWorkflow/CanvasState 一致)下才被包成 *interruptError 并被 ExtractInterruptInfo 识别;无状态图返回裸 *core.InterruptSignalExtractInterruptInfo→nil。自定义 state 类型须 compose.RegisterSerializableType[T](仿 runtime.CanvasState),否则 checkpoint marshal 报 unknown type
决策 §8 第3步+第4步M3实现Pipeline.Run resume 循环 + interrupt id 持久化 internal/ingestion/pipeline/pipeline.goRun 改为 resolveStore()/resolveTracker()(注入优先,否则 redis.Get() 解析)→ 有 store 才进 resumable 路径(编译接 WithCheckPointStore+WithCheckPointID(p.taskID)+WithInterruptAfterNonTerminalCpn()for 循环(maxResumeRounds=1000顶部恢复中断RunTracker GetInterruptID 跨进程 > localInterruptID 进程内兜底),命中仅 AttachInterrupt 持久化、下轮顶部 ResumeWithData 恢复(不内联 resumeRunTracker.Start/MarkSucceeded/MarkFailed/MarkCancelled 接入;cancelled 分支按 §4.3.b 调 cleanupCheckpoint 删 eino checkpoint+ClearInterruptID;无 store 降级 runPlain 单次 InvokeNewPipelineFromDSL...PipelineOptionWithCheckPointStore/WithRunTracker 注入)。②internal/agent/canvas/run_tracker.goAttachInterrupt/GetInterruptID/ClearInterruptID(同 hash interrupt_id 字段)。③测试:pipeline_test.goTestPipelineRunResumableAutoResumes(注入 in-memory store断言每节点恰好执行 1 次);bash build.sh --test ./internal/ingestion/pipeline/... ./internal/agent/canvas/... 全绿2026-07-09
决策 §8 第5步M4+S3实现可见降级 + UserFillUp 守卫 M4§6.a 方案 A 可见拒绝)internal/ingestion/pipeline/pipeline.gorequireResume bool 字段 + WithRequireResume() 选项 + var ErrResumeUnavailable = errors.New("resume unavailable: no checkpoint store (Redis down or not configured)") sentinelRunresolveStore() 后、Compile 前检查 requireResume && store==nil → 返回 fmt.Errorf("pipeline: Run: %w", ErrResumeUnavailable),由调用方 errors.Is 识别并拒绝入队(而非静默 runPlain)。默认不置 requireResume 保持静默降级(覆盖无 Redis 的单元/headless 测试);生产 ingestion 接线时启用 WithRequireResume() 即「Redis 不可用 → 拒绝启动」强信号。②S3§4.2.b 方案 A 编译期硬拒)internal/agent/canvas/compile.goBuildWorkflow 前加护栏——cfg.InterruptAfterNonTerminal && len(AutoDiscoverUserFillUpIDs(c))>0 → 返回 "...WithInterruptAfterNonTerminalCpn forbids UserFillUp nodes ... would be mis-resumed with nil data";理由:UserFillUp 自发包 compose.InterruptrunResumable 全捕获并以 nil 续跑会静默跳过人工交互,硬拒不使其发生;与 step 2 的 computeNonTerminalCpnIDs 过滤(方案 B构成双层守卫。③测试pipeline_test.goTestPipelineRun_RequireResumeRejectsWithoutStorecompile_test.goTestCompile_RejectsUserFillUpInResumeModebash build.sh --test ./internal/ingestion/pipeline/... ./internal/agent/canvas/... 全绿2026-07-09