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.
51 KiB
Ingestion Pipeline:Progress / Log / Resume 机制计划书
范围:
internal/ingestion/pipeline的执行可观测性与断点续跑。 关联代码:internal/agent/canvas/*(框架闸口)、internal/agent/runtime/*(progress 回调)、internal/ingestion/*(ingestion 专属逻辑)。 状态:设计稿 v2(v1 经评审修订,修订记录见文末 §10)。结论:续跑走 B(eino interrupt/checkpoint),观测走轻量 task_log(不存全量 output)。
1. 背景与目标
ingestion pipeline 是把一份 DSL 编译成 eino 图后完整执行(Pipeline.Run → compiled.Workflow.Invoke)。需要三件事:
- Progress(进度):框架层对每个 component 的发令(进入/退出/失败),供 UI/API 展示。
- Log(日志):把 progress 落库成可回读的
ingestion_task_log,API 侧IngestionTaskLogDAO.ListLogsByTaskID读取。 - Resume(续跑):重跑同一个 task(task_id / pipeline_id 不变)时,已成功执行过的 component 不必重做。
目标约束(来自 node_body.go:156 的项目自身原则):
cross-cutting concerns belong in the framework body (
realComponentBody), not inside each component'sInvoke.
即 progress / resume 不应回到 Parser 等组件内部(否则每个组件都依赖 dao,且违反单一闸口原则)。
2. 现状(已落地的部分)
区分两件事:功能是否完整(能否在进程内 interrupt→resume)与持久化是否到位(崩溃/重启后能否恢复)。
| 能力 | 位置 | 状态 |
|---|---|---|
| 框架级 progress 发令 | node_body.go:187 runtime.TrackProgress → 从 ctx 取 ProgressCallback |
✅ 已落地(功能完整) |
pipeline 写 ingestion_task_log |
pipeline.go:165 taskLogProgressCallback(dao.DB==nil 时 no-op) |
✅ 已落地,但 progress 字段语义是状态 {0,1,-1},无序号、无进入/退出区分、无输出序列化 |
eino WithInterruptAfterNodes 接线 |
compile.go:56,78,143 CompileOptions.InterruptAfter → compose.WithInterruptAfterNodes |
✅ 已落地(功能完整) |
| eino CheckPointStore(Redis) | compile.go:63 + checkpoint_store.go RedisCheckPointStore,key=agent:cp:{id} |
✅ 已落地(功能完整) |
| 状态机(running/succeeded/failed/cancelled) | run_tracker.go RunTracker,key=agent:run:{run_id},状态 0/1/2/3 |
✅ 已落地(功能完整) |
| interrupt 捕获 / resume id 暂存 | runner.go:163 interruptIDs(内存 map) |
✅ 功能完整(agent/chat 用,进程内可用);⚠️ 持久化待补(跨进程重启即丢,ingestion 不能依赖) |
UserFillUp interrupt 发令 |
interrupt_resume.go UserFillUpNodeBody(compose.Interrupt / GetResumeContext) |
✅ 发令功能完整;⚠️ resume 侧(ResumeWithData)尚未接线(仅注释提及) |
稳定 CheckPointID 选项 |
compile.go CompileOptions |
❌ 缺 WithCheckPointID,目前 CheckPointID 每编译一次生成 |
关键结论:续跑要用的「执行完 interrupt → 持久化 checkpoint → 后续 resume」底座在 canvas 层已经齐全(功能层面),ingestion 只需把 Pipeline.Run 接上,并补齐 interrupt id 的跨进程持久化 这一缺口。
3. 路线 A:自研 task_log 缓存 + 框架闸口跳过(先前提案,已放弃用于续跑)
机制
realComponentBody在comp.Invoke前,从 ctx 取ResumeProvider(agent 侧为 nil),按cpnID命中则直接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 时组件输入指纹不匹配则重跑。
优点
- 不依赖 Redis;DB 无关(测试友好)。
- 对 eino resume 语义零依赖,完全自控。
缺点(这是放弃它做续跑的原因)
- 重造 checkpoint:eino 已有现成的图级 checkpoint + 序列化 + 恢复,A 等于自己再写一遍,且对 DAG / Loop / Parallel 的拓扑正确性要自己保证。
- 框架闸口 skip 是 hack:在
realComponentBody里短路Invoke,与「pipeline 完整执行 DSL」的契约微妙冲突;eino 也失去了它自己对该节点执行结果的一致性保证。 - 体积(量化):exit log 要存全量 output(longtext)。预估:单文件 parser 输出通常 KB~MB;长文档 / 多分片可达 十 MB 级;若按「每 component 一条 exit 行 + 全量 output」落库,单 task 的
ingestion_task_log体积 = Σ(output_size),可能达 数十 MB。这正是 B 方案把全量 output 交给 eino checkpoint(Redis,带 TTL,可过期)而非 task_log(DB longtext,需永久保留)的原因。 - 指纹失效不彻底:仅
(cpnID, fingerprint)仍可能在 DSL 结构变化(节点增删、边重连)时误命中。
4. 路线 B:eino interrupt-after-node + Redis checkpoint(推荐)
思路:每个 component 执行完成后 由 eino 自动
interrupt,把整图状态(含该节点输出)序列化进 Redis checkpoint;orchestrator 捕获 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.go(JSON)
canvas.WithInterruptAfterNonTerminalCpn(), // 无参;canvas.Compile 内部算出非终态节点并 interrupt
canvas.WithCheckPointID(p.taskID), // 稳定 id = taskID(需新增选项,见 §6)
)
WithInterruptAfterNonTerminalCpn()(无参,用户建议):调用方不再需要计算 / 传入nonTerminalCpnIDs。canvas.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.b:ingestion 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增加InterruptAfterNonTerminalCpn与UserFillUp的互斥校验——ingestion 走该选项时,若 DSL 含UserFillUp节点直接编译失败,返回明确错误。 - 方案 B(运行期过滤):
canvas.Compile在计算nonTerminalCpnIDs(供WithInterruptAfterNonTerminalCpn使用)时显式剔除UserFillUp类节点,且不对其注册 after-node interrupt;其余节点的 interrupt 才走续跑路径。
共存原理(Question:WithInterruptAfter 与用户导致的 interrupt 能否共存?):两者都是 eino 的同一套 interrupt 原语,仅靠 interrupt id 区分,由 IsInterruptError 统一捕获、ExtractInterruptContexts / RootInterruptID 取出对应 id。WithInterruptAfterNodes 是「编排级」pause(节点跑完由框架插入,resume 传 nil);compose.Interrupt(UserFillUp 内部调用)是「节点级」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.go的Start/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)
RunTracker 有 runStatusCancelled = "3"(run_tracker.go:44)。本期策略:
- 命中
cancelled默认不允许自动续跑 → 清 checkpoint + 整跑(与failed一致),避免「用户取消后悄悄继续」的语义歧义。 - 可选增强(不在本期):暴露显式
resume cancelled入口,由人工/API 触发,才走running的复用分支。
4.4 需要新增的持久化(M3 + S4)
runner.go:163 的 interruptIDs 是内存 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.md:agent 仍在用就保留)。文档明确这一点,避免后续实现者把它当 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)
- 依赖 Redis(ingestion 已用 Redis,可接受;无 Redis 时的可见降级见 R5)。
- 需把
Pipeline.Run从单次Invoke改为循环,且管理 interrupt id 持久化。 - eino 序列化保真度:checkpoint =
CanvasState整体 JSON(state_serializer.go用json.Marshal)。native 解析器等若产出非 JSON 类型([]byte、自定义 struct)会断 resume——需 spike(R1)。 - 需确认
WithInterruptAfterNodes+ 全图ResumeWithData在 Loop/Parallel 下的精确语义(R3,P0)。
5. Progress / Log 改进(路线无关,必做)
无论续跑走 A 还是 B,taskLogProgressCallback 都该升级为结构化事件,满足用户最初要求:
progress字段改为(component 序号 0-based, 相位 enter/exit/error)。- 不携带 component 的
Output:resume 由框架 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 非空
// 不携带 Output:resume 由 eino checkpoint 负责,progress 仅是观测(用户决定)
}
type ProgressCallback func(ProgressEvent)
realComponentBody 的 cpnID 是闭包参数(node_body.go:180),而 node_body.go:187 当前传给 TrackProgress 的第一参数是 componentClass(class 名),不是节点 id。改造时把该第一参数从 componentClass 改为 cpnID:runtime.TrackProgress(cpnID, ...),使 ProgressEvent.Component 填的是节点 id(ProgressEvent.Component 用于落库定位与排序,需唯一标识节点;class 名无法区分同名多实例)。采用「只改调用处第一参数」方案,不扩 TrackProgress 签名(更少改动,符合用户建议)。realComponentBody 改为发 ProgressEvent{Phase, Component: cpnID, ...}(enter 时 Phase=PhaseEnter;exit 时 Phase=PhaseExit;error 时 Phase=PhaseError 且填 Err),不读取、不传递组件 output。
5.2 ingestion 侧注入 Index(M1 落地)
在 pipeline 包内定义轻量包装,Index 由 sink 层用 pipeline 注入 ctx 的 cpnID→index 映射算出,不污染共享类型:
// internal/ingestion/pipeline(ingestion 专有)
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 / Redis,progress 仅是观测)。因此 M5 原本的「事件携带 vs sink 不落库」紧张关系不再存在——两层一致:事件不带、sink 也不存。
时间戳(Q1 结论:event 不携带时间戳):
ProgressEvent不携带时间戳。回调在realComponentBody内同步触发、sink 立即落库,emit≈落库。- 时间戳只由 GORM
BaseModel.create_time自动写入(秒级精度),作为行级元数据;现有pipeline.go把create_time又塞进checkpointJSON 是冗余,应删除。 - 行内排序以自增
id为准(LatestLogByTaskID已依赖id DESC做确定性 tie-break,因create_time秒级会撞)——event 时间戳对排序无增量价值。 - 与移除
Output一致,保持共享ProgressEvent精简(M1 纯净化)。
taskLogProgressCallback直接写独立列(component_index/phase/component/message),不再写checkpointJSON(不含 output、不含 create_time);时间戳仅来自BaseModel.create_time。任何地方都不在 progress 通道里携带组件全量 output(体积问题见 §3 量化,且 resume 不依赖它)。
6. 风险与待验证(Spike,附优先级)
| # | 风险 | 优先级 | 验证方式 |
|---|---|---|---|
| R1 | CanvasState 经 JSON 序列化/反序列化保真(native 解析器 []byte/自定义类型) |
P1 | 集成测试:跑含 Parser 的 DSL,中断→恢复,断言恢复后输出与一次性跑一致 |
| R2 | 稳定 CheckPointID 接线 |
P1 | 给 CompileOptions 加 CheckPointID + WithCheckPointID;单测验证 key=agent:cp:{taskID} |
| R3 | WithInterruptAfterNodes + 全图 ResumeWithData 在 Loop/Parallel/DAG 下的语义 |
P0 | ✅ 已通过(2026-07-09):internal/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.BuildWorkflow 的 CanvasState 一致)下才会被包成 *interruptError 并被 ExtractInterruptInfo 识别;无状态图返回裸 *core.InterruptSignal(ExtractInterruptInfo 返回 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.Interrupt(UserFillUp)在同一图的共存语义 |
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_degradedwarning(含原因),使 API 侧能查询到「本次为整跑、无续跑保证」。
两种都保证不静默。本期选 A(强信号),B 作为可加的审计记录。
6.b R3 不通过的回退(M2)
若 eino v0.9.12 不支持「全图 interrupt-after-node + ResumeWithData」:
- 降级为「只在 DAG 节点上 interrupt」(Loop/Parallel 内部不 interrupt,整段作为原子单元),或
- 回退到路线 A 的轻量版(仅 progress/log,无 resume)。
✅ R3 已于 2026-07-09 通过(见 §6 R3 行 +
internal/agent/workflowx/r3_interrupt_test.go),M2 回退路径当前不需要;§8 第 3 步的 resume 循环可按原计划安全推进。
7. 推荐方案
续跑走 B,观测走轻量 task_log:
ProgressCallback升级为结构化事件(序号 + 进入/退出 + 退出带输出),taskLogProgressCallback写轻量ingestion_task_log(sink 不存全量 output,见 §5.3)。Pipeline.Run接 eino interrupt/checkpoint:编译期WithInterruptAfterNonTerminalCpn()(无参)+WithCheckPointStore+WithCheckPointID(taskID);运行期内部 resume 循环(循环顶部先判断遗留中断并恢复);RunTracker状态机驱动跨调用恢复与失效。- 组件(
Parser等)完全不改、不依赖dao;续跑由框架闸口 + Redis checkpoint 统一负责。
理由:B 复用仓库已建好的 eino 底座(功能完整,仅缺 ingest 用持久化),最省事且对图拓扑正确性最稳;A 的「自研缓存 + 闸口跳过」仅在「无 Redis、必须 DB 无关」的极端约束下才值得。
8. 实施步骤(建议顺序,含评审修订)
-
schema 评估与迁移(S1,新增)+ 前端取数契约(Q2/Q3) ✅ 已实现(entity/DAO/pipeline 落地,dao+pipeline 包测试通过):
- 表结构(最小迁移,沿用
ingestion_task_log,但checkpointJSON 拆列):原checkpointLONGTEXT(JSON) 按 key 拆成独立列便于直接访问与聚合(用户决定):idBIGINT 自增 PK;task_idVARCHAR(32) 索引;component_indexINT(0-based 序号,keyindex);phaseTINYINT(keyphase,复用ProgressPhase:0=enter / 1=exit / 2=error);componentVARCHAR(64)(cpnID,keycomponent,建议加索引便于聚合);messageTEXT(keymessage);BaseModel(create_time/update_time/deleted_at,时间戳只此一处)。不再有checkpoint列(旧{progress,message,create_time}整体废弃)。拆列后AggregateProgress可纯 SQLGROUP BY phase, component完成,无需解析 JSON(见下方端点)。 - 老
progress字段兼容:旧编码{progress:0/1/-1, message, create_time}原本整体在checkpointJSON 列内(无独立列)。现改为拆列后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 完成(无需解析 JSON):按component取每个组件最新phase(按id DESC取每组件末行),再CASE WHEN 末phase=1 THEN 'done' WHEN 末phase=2 THEN 'failed' ELSE 'running' END计数;percent = done/total*100;total取ingestion_task.component_total。前端拉进度条不必先拉全量日志。
- 产出:migration 脚本(含
component_total加法列)+ API 兼容性结论,进入第 1 步前完成。 - 已落地(本次):
entity.IngestionTaskLog:Checkpoint JSONMap→ 四列ComponentIndex(INT) /Phase(INT) /Component(VARCHAR(64),index) /Message(TEXT);entity.IngestionTask新增ComponentTotal(INT,default 0)。GORMAutoMigrate自动加列(加法安全)。dao:IngestionTaskDAO.UpdateComponentTotal(taskID,total);IngestionTaskLogDAO.ListLogsByTaskID改id ASC;新增TaskProgress{Total,Done,Failed,Running,Percent}与AggregateProgress(taskID,total)(子查询取每 component 最新id→按 phase 分类计数;分类向前兼容 1c renumber:done=1,failed∈{-1,2})。pipeline.Run:编译后 best-effortUpdateComponentTotal(taskID, len(canvas.Components))(DB 为空则跳过)。- ⚠️ 依赖 1c:sink 目前仍写
Component=""/ComponentIndex=0,故AggregateProgress的GROUP BY component在 1c(cpnID 注入)前会退化为单组;method 与 schema 已就绪,待 1c 填列后生效。
- 表结构(最小迁移,沿用
-
结构化 progress 事件(独立、低风险,但先做完影响面评估) ✅ 已实现(runtime/canvas/pipeline 落地 + runtime/canvas/pipeline 包测试通过;agent/ingestion 整树编译通过):
- 1a. 影响面清点(已核实):
ProgressCallback|TrackProgress|WithProgressCallback|ProgressCallbackFromContext全仓仅helpers.go/helpers_test.go/pipeline.go/node_body.go4 个 Go 文件有真实引用;file.go/tokenizer.go经内容搜索无Progress/TrackProgress引用(§9 标注为旧注释,已不实)。生产可执行调用方仅node_body.go(TrackProgress一处)+pipeline.go(sink);WithProgressCallback/ProgressCallbackFromContext签名随类型别名自动迁移,零改动。无 SDK / 外部调用方,无需对外征询。 - 1b. 向后兼容策略 → 改为干净硬切:影响面仅 8 处生产 + 4 个测试函数,按 AGENTS.md「优先单路径、删废弃分支」原则未保留旧
ProgressCallback func(int,string)适配器(渐进迁移方案被否决,避免遗留 deprecated 双轨)。一次性把ProgressCallback改为func(ProgressEvent),并同步重写helpers_test.go4 个测试。 - 1c. 落地(已做):
runtime/helpers.go:新增ProgressPhase(PhaseEnter=0/PhaseExit=1/PhaseError=2,值稳定并持久化到phase列,属数据契约)+ProgressEvent{Phase, Component, Err}(Err仅 error 相位非空;不携带 Output,见 §5.3);ProgressCallback改为func(ProgressEvent);TrackProgress(cpnID, cb, fn)发ProgressEvent{PhaseEnter}/PhaseExit/{PhaseError, Err}(不再拼compName Started/Done/:err字符串,消息改由 sink 派生)。node_body.go:187:TrackProgress(componentClass, ...)→TrackProgress(cpnID, ...),ProgressEvent.Component现填节点 id(唯一标识,可区分同名多实例);componentClass仍用于resolveTimeoutFromContext,无冗余。pipeline.go:taskLogProgressCallback改消费ProgressEvent,componentIndexMap()(按排序 cpnID 给确定性 0-based 序号,因 map 遍历无序)算ComponentIndex,并写Component/Phase(=int(Phase))/ComponentIndex/Message(消息由相位派生:cpnID Started/Done/:err,保持前端兼容)。AggregateProgress的GROUP BY component在 1c 后已生效(按 cpnID 正确分组)。
- ⚠️ 已知不一致(非阻塞):
component_total在 step 0 取len(canvas.Components)(含 no-op/UserFillUp 节点),而这些节点的 body(legacyNoOpBody/UserFillUpNodeBody)不发TrackProgress,故done计数可能永远 <total,进度百分比上限 < 100%。属观测精度问题,留待后续按需把分母改为「实际会发事件的组件数」。
- 1a. 影响面清点(已核实):
-
CompileOptions加CheckPointID(R2) ✅ 已实现(canvas 包测试通过):- 关键 API 事实修正:
compose.WithCheckPointID(id)返回的是 运行期compose.Option(checkpoint.go:74),不是GraphCompileOption,无法在Compile(Workflow.Compile(...GraphCompileOption))里直接append。因此 step 2 不把compose.WithCheckPointID塞进编译选项,而是:CompileOptions新增CheckPointID string字段 +WithCheckPointID(id)选项(仅把 id 记到选项里);Compile把cfg.CheckPointID存进返回的CompiledCanvas.CheckPointID(该字段早已存在却从未赋值);- 运行期接线(真正的
compose.WithCheckPointID)留到 step 3:pipeline.go在compiled.Workflow.Invoke(runCtx, current, compose.WithCheckPointID(compiled.CheckPointID))时传。service 层agent.go:1041早已自行维护cpID=runID并compose.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,不与之重复注册)。Canvas无Outputs字段,故「非终态」以len(Downstream)>0为准(与 §4.1.a 出度>0 等价)。 - ⚠️ 接线约束:
WithInterruptAfterNonTerminalCpn()一旦被pipeline.go调用而尚无 step 3 的 resume 循环,Invoke会在第一个非终态节点暂停并返回 interrupt 错误,直接破坏 ingestion。故 step 2 只动compile.go+ 单测,pipeline.go的真正接线(compile 选项 +Invoke传compose.WithCheckPointID+ resume 循环)整体归入 step 3(R3 gate 之后)。 - 单测:
compile_test.go新增TestWithCheckPointID_OptionSetsField、TestWithInterruptAfterNonTerminalCpn_OptionSetsField、TestComputeNonTerminalCpnIDs(断言n1,n2命中、n3/n4终态排除、ufUserFillUp 排除)、TestCompile_PropagatesCheckPointID(编译成功则断言CompiledCanvas.CheckPointID==task-9,编译失败则 skip,与现有 compile_test 忽略编译错误的约定一致)。
- 关键 API 事实修正:
-
Pipeline.Runresume 循环 ✅ 已实现(2026-07-09):R3 已于 2026-07-09 通过(见 §6 R3 行),无需 M2 回退。落地于internal/ingestion/pipeline/pipeline.go:Run先resolveStore()/resolveTracker()(注入优先,否则按redis.Get()解析),有 store 才编译期接WithCheckPointStore+WithCheckPointID(p.taskID)+WithInterruptAfterNonTerminalCpn()进入 resumable 路径;运行期for循环(maxResumeRounds=1000防呆)顶部先恢复中断(优先级:RunTrackerGetInterruptID跨进程恢复 > 进程内localInterruptID兜底),命中 interrupt 后仅AttachInterrupt持久化、下一轮顶部统一ResumeWithData(ctx, id, nil)(不内联 resume,避免同 ctx 双 resume,依 §4.2 建议2);RunTracker.Start/MarkSucceeded/MarkFailed接入;cancelled(context.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 层成立)。 -
interrupt id 持久化(M3)✅ 已实现(2026-07-09):
internal/agent/canvas/run_tracker.go加AttachInterrupt/GetInterruptID/ClearInterruptID,写同一 hash 的interrupt_id字段(不新增 key);agent/chat 路径不动(S4)。与 step 3 的进程内localInterruptID兜底共同构成跨/进程内双层恢复。 -
降级与守卫(M4 + S3)✅ 已实现(2026-07-09):
- M4(§6.a 方案 A,可见拒绝,非静默):
Pipeline加requireResume bool+WithRequireResume()选项 +var ErrResumeUnavailable = errors.New(...)sentinel。Run在resolveStore()之后、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.go在BuildWorkflow之前加护栏——当cfg.InterruptAfterNonTerminal(ingestion resume 模式)且AutoDiscoverUserFillUpIDs(c)非空时,Compile直接返回错误"...WithInterruptAfterNonTerminalCpn forbids UserFillUp nodes ... would be mis-resumed with nil data"。理由:UserFillUp自身发compose.Interrupt(wait-for-user),runResumable的IsInterruptError全捕获并以nil自动续跑,会静默跳过人工交互;编译期硬拒使其永不发生。先于 step 2 已有的computeNonTerminalCpnIDs过滤(方案 B,不重复注册)构成双层守卫。 - 测试:
pipeline_test.go加TestPipelineRun_RequireResumeRejectsWithoutStore(无 store +WithRequireResume→ErrResumeUnavailable);compile_test.go加TestCompile_RejectsUserFillUpInResumeMode(含UserFillUp+WithInterruptAfterNonTerminalCpn→ 编译错误且含 "UserFillUp")。bash build.sh --test ./internal/ingestion/pipeline/... ./internal/agent/canvas/...全绿(2026-07-09)。
- M4(§6.a 方案 A,可见拒绝,非静默):
-
Spike 验证(§6 R1–R4)通过后,默认开启 checkpoint;否则回退为纯 progress/log(无 resume)(M2 回退路径)。
9. 涉及文件与影响面
- 改:
-
internal/agent/runtime/helpers.go(ProgressCallback/TrackProgress/WithProgressCallback/ProgressCallbackFromContext签名变更)。 影响面(已全仓 grep 核实):共 42 处匹配、6 个文件,分布如下:文件 匹配数 性质 internal/agent/runtime/helpers.go17 被改文件本身(定义+实现+doc) internal/agent/runtime/helpers_test.go14 测试回归主成本 internal/ingestion/pipeline/pipeline.go6 生产调用方(sink + WithProgressCallback注入)internal/agent/canvas/node_body.go2 生产调用方(框架发令) internal/ingestion/component/file.go2 仅 doc 注释,非调用 internal/ingestion/component/tokenizer.go1 仅 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:187把TrackProgress第一参数由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.go(Runresume 循环——循环顶部先GetInterruptID恢复崩溃遗留中断、命中后AttachInterrupt持久化;升级taskLogProgressCallback+ComponentIndexMap注入,§4.2/§5.1;UserFillUp过滤已下沉到compile.go,此处不再计算nonTerminalCpnIDs,§4.2.b)。 -
internal/dao(新增IngestionTaskLogDAO结构化写入方法 / schema 迁移,S1)。
-
- 复用(不改):
internal/agent/canvas/checkpoint_store.go、internal/agent/canvas/interrupt_resume.go、internal/agent/canvas/state_serializer.go、runner.go的 agent/chat 内存interruptIDs(保留,S4)。 - 不改:
internal/ingestion/component/*、internal/agent/component/*(组件零改动)。
10. 修订记录(v1 → v2,基于评审)
| # | 项 | 优先级 | 修订 |
|---|---|---|---|
| M1 | ProgressEvent.Index 不应进共享 runtime 类型 |
P0 | 共享类型去除 Index;ingestion 包内 pipelineProgressEvent{Index, Event} 包装,Index 由 sink 用 cpnID→index 映射算出(§5.1/§5.2) |
| M2 | R3(Loop/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 | realComponentBody 的 cpnID 是闭包参数,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 仅 doc;helpers_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/delete,running 的恢复入口收敛到循环顶部一处,杜绝与 §4.2 两处 resume 漂移 |
| 决策 | 选择路线 B | — | 路线 B(eino 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 为准(复用 LatestLogByTaskID 的 id 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 .../logs(id ASC)+ GET .../progress({total,done,failed,running,percent,status} 服务端聚合)端点契约 |
| 决策 | ingestion_task_log.checkpoint 拆成独立列 |
— | §8 第0步 / §5.3 / §8-1c:checkpoint LONGTEXT(JSON) 按 key 拆为 component_index(INT) / phase(TINYINT) / component(VARCHAR,建议索引) / message(TEXT) 四列,删除 checkpoint 列;AggregateProgress 改为纯 SQL GROUP BY phase,component(无需解析 JSON);旧 Create(写 JSONMap) 路径废弃,无需 CreateStructured 双写 |
| 决策 | §8 第1步 结构化 progress 事件(干净硬切,非渐进迁移) | — | §8 第1步 / §5.1:ProgressCallback 改为 func(ProgressEvent)(新增 ProgressPhase 枚举 0/1/2 + ProgressEvent{Phase,Component,Err},不携带 Output/时间戳);TrackProgress(cpnID,cb,fn) 发 enter/exit/error 事件;node_body.go 第一参数 componentClass→cpnID;pipeline.go sink 消费 ProgressEvent 并由 componentIndexMap()(排序 cpnID 确定性序号)填 ComponentIndex+写 Component/Phase;未保留 1b 适配器(按 AGENTS.md 单路径原则);AggregateProgress 的 GROUP BY component 在 1c 后已生效 |
| 决策 | §8 第2步 CompileOptions.CheckPointID + 无参 WithInterruptAfterNonTerminalCpn() |
— | §8 第2步 / §4.1.a / §4.2.b:①API 事实修正——compose.WithCheckPointID 是运行期 compose.Option(非 GraphCompileOption),无法在 Compile 内直接 append;故 WithCheckPointID(id) 仅记到 CompileOptions.CheckPointID,Compile 存进 CompiledCanvas.CheckPointID,真正 compose.WithCheckPointID 在 Invoke 侧接线留 step 3;service 层 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 恒为 1),Loop/Parallel 正确重入或跳过,无需 M2 回退,§8 第 3 步按原计划推进;③关键实现约束:after-node interrupt 仅在有状态图(WithGenLocalState,与 canvas.BuildWorkflow/CanvasState 一致)下才被包成 *interruptError 并被 ExtractInterruptInfo 识别;无状态图返回裸 *core.InterruptSignal(ExtractInterruptInfo→nil)。自定义 state 类型须 compose.RegisterSerializableType[T](仿 runtime.CanvasState),否则 checkpoint marshal 报 unknown type |
| 决策 | §8 第3步+第4步(M3)实现:Pipeline.Run resume 循环 + interrupt id 持久化 | — | ①internal/ingestion/pipeline/pipeline.go:Run 改为 resolveStore()/resolveTracker()(注入优先,否则 redis.Get() 解析)→ 有 store 才进 resumable 路径(编译接 WithCheckPointStore+WithCheckPointID(p.taskID)+WithInterruptAfterNonTerminalCpn()),for 循环(maxResumeRounds=1000)顶部恢复中断(RunTracker GetInterruptID 跨进程 > localInterruptID 进程内兜底),命中仅 AttachInterrupt 持久化、下轮顶部 ResumeWithData 恢复(不内联 resume);RunTracker.Start/MarkSucceeded/MarkFailed/MarkCancelled 接入;cancelled 分支按 §4.3.b 调 cleanupCheckpoint 删 eino checkpoint+ClearInterruptID;无 store 降级 runPlain 单次 Invoke;NewPipelineFromDSL 加 ...PipelineOption(WithCheckPointStore/WithRunTracker 注入)。②internal/agent/canvas/run_tracker.go 加 AttachInterrupt/GetInterruptID/ClearInterruptID(同 hash interrupt_id 字段)。③测试:pipeline_test.go 加 TestPipelineRunResumableAutoResumes(注入 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.go 加 requireResume bool 字段 + WithRequireResume() 选项 + var ErrResumeUnavailable = errors.New("resume unavailable: no checkpoint store (Redis down or not configured)") sentinel;Run 在 resolveStore() 后、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.go 在 BuildWorkflow 前加护栏——cfg.InterruptAfterNonTerminal && len(AutoDiscoverUserFillUpIDs(c))>0 → 返回 "...WithInterruptAfterNonTerminalCpn forbids UserFillUp nodes ... would be mis-resumed with nil data";理由:UserFillUp 自发包 compose.Interrupt,runResumable 全捕获并以 nil 续跑会静默跳过人工交互,硬拒不使其发生;与 step 2 的 computeNonTerminalCpnIDs 过滤(方案 B)构成双层守卫。③测试:pipeline_test.go 加 TestPipelineRun_RequireResumeRejectsWithoutStore;compile_test.go 加 TestCompile_RejectsUserFillUpInResumeMode;bash build.sh --test ./internal/ingestion/pipeline/... ./internal/agent/canvas/... 全绿(2026-07-09) |