fix(node): terminal 之后与取消之后不再转发流式工具的尾随 artifact - #95
Merged
Conversation
流式工具(如 katana)通过后台 worker 的 OnResult 回调持续 Emit artifact, 这些 goroutine 与工具 Execute() 的返回并不同步:Execute 返回后 tool node 已 发出 terminal tool.result,而后台仍在为同一个 call 发 artifact。由于 eventbus.Emit 同步、WS 写为单条 FIFO 通道,这些晚到的 artifact 排在 terminal 之后送达控制面,每条都被判为 "after terminal barrier" 拒收——一次长扫描可 产生数万条,足以打满控制面单核并刷爆日志。 修复:让 terminal 成为一个 call 在连线上的最后一条消息。 - operations live-set 声明上移到 artifact 转发订阅者之前; - 订阅者转发 artifact 前检查该 call 是否仍在 operations 中,不在则丢弃 (连 WS 都不发); - handleAgentToolMessage 在发送 terminal 之前先 seal()(从 operations 删除), 配合 sendCh 的 FIFO 顺序,确保 seal 之后后台 worker 再 Emit 的 artifact 一律被丢。ToolResult 事件不受影响(仅对 artifact/extension 事件做存活门控)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
上一版按 `operations` 活跃集判定,等于"不在活跃集就丢"。这会连**从未 被本连接派发过**的 call 一并杀掉:agent 模式下节点自己 agent loop 的 工具调用,CallID 是模型给的 `tc.Id`(agent/loop.go),standalone scan 用 的是 `scan-…-call`(pkg/runner/runner.go),两者都进不了 `operations`, 于是它们的 artifact 全被静默丢弃,服务端 forwardAOPFrame → IngestArtifact 再也收不到资产/漏洞。`TestRunToolNodeWireInterop` 就是这个回归的最小复现。 改为墓碑判据:只丢**本连接明确 seal 过**的 call,未知 id 一律放行。 同时补上取消路径。katana 的 `crawler.Crawl(u)` 不吃 ctx,取消只在 URL 之间生效,所以 hub 300s 超时发 cancel 之后爬虫会把整轮爬完、继续发 artifact;此时 call 在 node 侧仍然"活跃",上一版一条都拦不住,而控制面 早已 retire 这个 call,每条照样记一次拒收。现在 CancelOperation 在 cancel 之前先 seal。 其余: - seal 移到 `AgentRuntime.EmitEvent` 之前。runtime 发布用的是订阅者读的 同一条总线,agent 模式下 terminal 其实是从 EmitEvent 里上线的,seal 排 在它后面等于没排。 - 墓碑集按 1 分钟保留期在每次 seal 时顺带清理,不随连接寿命无界增长。 - 注释不再声称"无中间态":检查与入队之间仍有一个窄窗口,漏一条尾随记录 是可能的。堵住整条尾巴才是目的,漏网的那条由控制面计数丢弃。 测试: - `TestRunToolNodeWireInterop` 改成真实形态——工具执行期间从 invocation context 取 CallID 发 artifact(等同 toolargs.Base 的真实链路),而不是 在 call 结算之后再补发;末尾新增断言:terminal 之后为同一 call 发的被 丢弃,未派发过的 call 的 artifact 仍然送达。 - 新增 `TestCancelOperationSealsTheCallArtifactWindow`。 - 反向验证:门控置为恒不丢时,新断言会挂在尾随 artifact 抢先到达。 `gofmt`/`go vet`/`golangci-lint v2.12.2`(全仓,含 re2 tags)均干净; `go test ./pkg/node/ -count=3` 绿。`-race` 本地工具链编译不了(与本改动 无关),交 CI 的 race-stress。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
archtest 的 `TestGoTestFilesFollowSourceFiles` 要求每个 _test.go 有同名 源文件,而 `websocket_liveness_test.go` 没有 `websocket_liveness.go`, 所以 master 的 test job 一直是红的(与本 PR 无关,顺手带上)。 这三个用例测的 `webSocketEnvelopeStream`、`newWebSocketEnvelopeStream`、 `dialProtoWebSocket`、`websocket*` 常量全部定义在 proto_connection.go, 并入 proto_connection_test.go 就是归位。纯文件移动,用例本身一行未改。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
问题
流式工具(如 katana)通过后台 worker 的
OnResult回调持续Emitartifact,这些 goroutine 与工具Execute()的返回并不同步。Execute()返回后 tool node 已发出 terminaltool.result,而后台 worker 仍在为同一个 call 继续发 artifact。由于
eventbus.Emit是同步的、WS 写又是单条 FIFO 通道(sendCh),线上顺序 = 发射顺序,于是这些晚到的 artifact 排在 terminal 之后到达控制面。控制面一旦结算了该 call,就把后续每一条 artifact 判为runner artifact arrived after terminal barrier拒收 + 记一条 ERROR。一次长扫描可产生数万条这样的拒收(实测单个 katana 调用一次爬取 34,012 条),足以打满控制面单核 CPU 并刷爆日志,而任务域状态看上去完全正常(工具结果已正确返回引擎)。
还有一条同样通向该拒收的路径:取消停不掉扫描器。katana 的
crawler.Crawl(u)不接受 ctx(tools/katana/katana.go:204-208只在 URL 之间检查一次),控制面 300s 超时发CancelOperation之后,爬虫会把整轮爬完并继续发 artifact;此时 call 在 node 侧仍然"活跃",而控制面早已 retire 掉它,于是剩下的每一条照样被拒。修复
核心不变式:一个 call 的 terminal 是它在连线上的最后一条消息;控制面已经放弃的 call 不再往线上发 artifact。
pkg/node/proto_connection.go:sealed map[string]time.Time(与operations共用operationsMu),记录本连接已关闭 artifact 窗口的 call id;sealed,命中就丢,连 WS 都不发,从发射端掐断尾流;handleAgentToolMessage在发送 terminal 之前先 seal。配合sendCh的 FIFO:seal 之前发的 artifact 必排在 terminal 前(正常转发);AgentRuntime.EmitEvent之前——runtime 发布用的是订阅者读的同一条总线,agent 模式下 terminal 其实是从EmitEvent里上线的,seal 排在它后面等于没排;handleAgentCoreMessage处理CancelOperation时,在cancel()之前先 seal,盖住"取消停不掉扫描器"那条路径;sealed按 1 分钟保留期在每次 seal 时顺带清理,不随连接寿命无界增长。为什么是墓碑集而不是活跃集:按"不在
operations里就丢"会连从未被本连接派发过的 call 一并杀掉——agent 模式下节点自身 agent loop 的工具调用,CallID 是模型给的tc.Id(agent/loop.go:538);standalone scan 用的是scan-…-call(pkg/runner/runner.go:647)。两者都进不了operations,它们的 artifact 会被静默丢弃,服务端forwardAOPFrame→IngestArtifact再也收不到资产/漏洞。改成只丢明确 seal 过的 id,未知 id 一律放行。ToolResult事件不受影响——门控只作用于 artifact 扩展事件;finishOperation仍在 defer 里 cancel taskCtx 并从operations删除。已知边界:检查与入队之间仍有一个窄窗口(订阅者查完
sealed释放锁,到真正入sendCh之间),极少数情况下可能漏一条尾随记录排到 terminal 之后。把 check+enqueue 放进同一把锁会在sendCh满时持锁阻塞,连带卡住CancelOperation查表,不划算。堵住整条尾巴是目的,漏网的那条由控制面计数丢弃。测试
TestRunToolNodeWireInterop改成真实形态:工具执行期间从 invocation context 取 CallID 发 artifact(等同toolargs.Base的真实链路),而不是在 call 结算之后再补发;末尾新增断言——terminal 之后为同一 call 发的被丢弃,未派发过的 call 的 artifact 仍然送达。TestCancelOperationSealsTheCallArtifactWindow。gofmt/go vet/golangci-lint v2.12.2(全仓,含 re2 tags)干净;go test ./pkg/node/ -count=3绿。附带
第二个 commit 修的是 master 就存在、与本 PR 无关的 archtest 失败:
websocket_liveness_test.go没有同名源文件,TestGoTestFilesFollowSourceFiles一直红。这三个用例测的符号全部定义在proto_connection.go,并入proto_connection_test.go即归位,用例本身一行未改。🤖 Generated with Claude Code