fix(server): 批量关闭的行级命中、在途 PATCH 与超时预算边界(INT-185) #168
No reviewers
Labels
No labels
bug
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
fjbobo/fast_ship!168
Loading…
Reference in a new issue
No description provided.
Delete branch "int-185/batch-close-boundary"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
相关需求
INT-185(看板关闭全部的边界:RowsAffected、在途 PATCH 与超时),来源 PR #162 第 1 轮审查的 7 项边界。
改动内容
批量关闭「关闭全部已完成」在 INT-174 主路径(GitHub 并发关闭、内部单事务、请求可取消)之上补齐边界:
BatchCloseInternalTx把state=open写入 WHERE,事务内逐 id UPDATE 按RowsAffected区分命中/未命中;只对命中行计成功并删推荐,未命中行(列表与事务之间被并发方改出 open)逐条记入 failures,文案「该问题已在其他操作中被更新,已跳过关闭」。ListFiltered成功之后创建,只管关闭阶段;前置权限检查与列表查询本就不收 ctx,不再被整批截止截断,调用方取消照常生效。sem.Acquire成功不等于 worker 已调度),之后经context.WithoutCancel+ 单请求 15s 上限继续走完并落库,缩小「远端已关、本地仍 open」窗口;取消后不再开始新 PATCH。TimeoutError提示「请求超时:部分问题可能已关闭,请刷新看板确认结果」,HTTPError行为不变。upsertGitHubIssue入口守卫:nil/GetID()==0返回可读错误不 panic;创建/更新/同步三个调用方均只按 error 分支,行为安全。onUpdateIssue钩子 started/release 通道确定性同步,只断言并发峰值与调用次数;t.Cleanup保证失败路径也放行钩子内的 worker。UpdateIssue命中*RateLimitError/*AbuseRateLimitError/ 403/429 且 Message/body 含 rate limit/abuse detection 时统一回报「GitHub API 限流,请稍后重试」,判定口径对齐 PR 域pullRequestAccessHint但不共用。文档同步:
docs/internal/issue-list-sql-pushdown.md、docs/internal/issue-recommendations.md(推荐只随命中行删除)。修改范围
server(repository / service / 测试)、web(看板关闭全部按钮的超时文案)、docs/internal 两份。
本地验证
cd web && pnpm api-types --checkmake lintmake test验证方式(命令 / 无头浏览器):Go 单测 +
go test -race -count=15(BatchClose 子集);Playwright 无头 Chromium 双实例(基线4412cd13与本提交,独立端口/独立库)走真实看板关闭流程;真实并发 batch-close API 请求触发未命中路径。启动命令:
go build -o <scratch>/fast_ship ./cmd/server后以FAST_SHIP_*环境变量独立端口启动(verify-fast-ship 技能隔离流程,未占用共享 4888/4999)。验证提交 SHA:eee1f5fd
应用可以启动(
make dev),主要使用流程已验证(server / web 按改动范围)异常或空状态已验证(如适用)
已修复的基线问题
无本 PR 检查口径内的基线问题。额外发现的 -race 基线测试失败(
TestT3Int148SettlementReadOnlyGuard稳定复现、TestBotBudgetTaskScopeAndLinkedMain并行偶发,均已在 main4412cd13复现,与本改动无关)已立跟进 issue INT-190。端到端截图
看板「关闭全部」主流程改前/改后对比(同一数据、同一页面):
本 PR 的界面可见变化集中在失败/超时文案(限流、取消、请求超时),这些路径依赖真实 GitHub 4xx/网络故障,无头环境无法诚实触发,改用真实并发 API 响应作为替代证据:同一项目 4 条 done+open,两个 batch-close 请求同刻并发,后者在事务内全部未命中,
failures[].error即 INT-185 新文案(原始响应见api-batch-close-race-2.json,已随验证产物留存):文案分类(未开始 / 结果未确认 / 限流)由服务层单测逐条断言覆盖。
未验证范围与原因
-race -count=15代替。API 变更说明
无。openapi 契约零变更(
pnpm api-types --check通过);失败文案是IssueBatchResult.failures[].error中的自由字符串。创建 PR 前完成
审查意见处理
$pr-review-loop自动等待审查结果并处理新意见,直到收敛(R16 全部组合通过,0 P0–P2)复审处置
交付范围(方向审计后圈定)
本 PR 管:看板「关闭全部」的失败文案指引与归因——服务端失败文案收敛为少量固定类别(不含 URL/Issue 号),前端按文案分组计数全部展示、明细差额显式标注;限流熔断派发;整请求与逐条失败只要远端可能已生效(ky 超时/网关 502/503/504/网络中断/服务端 5xx/传输错误)一律提示刷新确认。
本 PR 不管:认证/权限类失败(401、非限流 403)的派发熔断——属新派发策略,已拆跟进 issue INT-192(404 是单条状态,不熔断)。收敛保证:任意批次中每类失败原因至少以「文案 ×条数(样例引用)」出现一次;未列明细的条数显式标注。后续服务端新增文案类别不再挤占既有类别的展示。
审查收敛记录(补充)
51ba1414。@codex review
代码审查 ·
51ba141· 第 16 轮全部组合通过,未发现 P0–P2 问题。
cliproxyapi/deepseek-v4.1-flash · pi
thinking=low
复审说明:推送新提交后会自动重新审查。
@codex review
R1 处置:Act on,已修复(3ddaad75)
采纳,按建议的第一种(追加首条 failure.error)修复:
已关闭 X 个问题,Y 个失败:<首条 failure.error>(refs)——三类分类文案(已跳过/结果未确认/限流)随服务端文案透传,指引(刷新确认/稍后重试)内嵌在文案里自带,不重复拼接。验证:新增 2 条 UI 单测断言透传与回退(
pnpm vitest run close-all-done-button.test.tsx6/6),make lint/make test(1773 用例)在3ddaad75全绿。关于失败 toast 的端到端截图:该路径依赖真实并发竞速(两个 batch-close 同刻发起,失败方才有 failures),本次无头自动化取景连续失败,按项目放弃规则停止,以上述单测断言 + PR 描述中已有的真实并发 API 响应 JSON(
failures[].error即新文案原文)作为替代证据。服务端的行级命中、在途 PATCH 与超时预算边界实现与新增单测相符,R1 的失败文案透传已修复;本轮仍有两处 P2:失败原因只取首条导致分类被掩盖,以及检测到限流后仍继续把剩余 PATCH 打到已被限流的 API。
@ -197,0 +222,4 @@// PATCH 自身超时/取消:远端可能已生效,结果未确认,与// 「未开始」的取消区分开。err = &batchClosePatchUnconfirmedError{err: err}} else if isGitHubRateLimitError(err) {P2:问题:新增的限流识别只用于把当前条目标成「GitHub API 限流」,调度循环没有因此停止;批次上限 200,30s 预算内会继续向已被限流的 API 发出剩余 PATCH(首波之后每个 worker 仍会连续取新任务)。影响:主限流下这些请求必然 403,纯粹浪费配额;次级/abuse 限流下持续重试还可能延长封禁时间,用户最终拿到最多 50 条重复的限流失败明细。建议:首个限流错误后置一个批次级标记,让后续 worker 在发请求前直接返回同一限流文案(与 ctx 取消的「未开始」路径复用),不再实际调用 GitHub。
@ -51,0 +51,4 @@// failures[].error 是服务端的分类文案(已跳过/结果未确认/限流/取消),// 必须透传:首条原因直接进 toast,指引(刷新确认/稍后重试)随文案自带,// 只报数量会让用户把部分成功当成整批失败再点一次(INT-185)。const reason = result.failures[0]?.error;P2:问题:toast 只取 failures[0].error 作为原因,但 refs 来自 failures 前 3 条,二者可能不是同一类失败;服务端先处理 internal 再处理 GitHub,且 failures 上限 50 条,所以「已跳过关闭」总排在前面。影响:混合来源批次里只要有一条 internal 行被并发改写,GitHub 的「关闭结果未确认,请刷新查看」或「限流,请稍后重试」永远不会出现在 toast 上,用户看到的是「已关闭 0 个问题,N 个失败:该问题已在其他操作中被更新(INT-3、INT-4、INT-5)」,既不知道远端可能已关闭、也不会刷新或稍后重试。建议:按失败原因聚合去重后展示(例如首条原因 + 「另有 M 个结果未确认」),或在存在未确认/限流类失败时固定附带对应指引。
R2 处置(行内):Act on,已修复(ad56479a)
采纳。失败原因改为去重后全部展示(保持出现顺序,上限 3 条、以「;」连接):
已关闭 X 个问题,Y 个失败:<原因1>;<原因2>(refs)。混合批次里内部行的「已跳过」与 GitHub 侧的「结果未确认/限流」同时可见,不再被首条掩盖。UI 单测更新为多类别断言(跳过 + 未确认同批展示),全量 vitest 通过。R2 处置(行内):Act on,已修复(ad56479a)
采纳。实现为首个限流即熔断派发:
dispatchCtx与整批截止分开;worker 命中限流时CompareAndSwap熔断并dispatchCancel,剩余项不再派发,逐条记新文案「GitHub API 限流,未尝试关闭」(与单条限流文案区分——这些项没发过请求,不能写成「结果未确认」)。WithoutCancel与熔断解耦,照常走完并落库(维持 INT-185 缩小「远端已关、本地仍 open」窗口的目标)。验证:限流熔断测试改钩子门控(4 worker 全部在途后统一放行,calls==4 与文案分布确定性),
go test -race连续 6 轮全绿;新增文案「未尝试」逐条断言;make lint/make test在ad56479a全绿。R2 的两条意见在
ad56479a均已真实修复(失败原因去重展示、首个限流即熔断派发),服务端行级命中/在途 PATCH/超时预算的实现与用例一致,未发现 P0/P1;本轮只剩前端失败原因展示的截断边界与两处错误分类的收尾问题。影响面是极端混合批次里一类失败提示会被第 4 类原因挤掉,以及认证类失败没有随限流一起熔断造成重复请求与噪声 failures。@ -51,0 +54,4 @@//(刷新确认/稍后重试)随文案自带(INT-185)。const reasons = [...new Set(result.failures.map((f) => f.error).filter(Boolean)),].slice(0, 3);P2:问题:reasons 去重后仍
.slice(0, 3),而ad56479a新增了第 4 类服务端文案(batchCloseGitHubRateLimitRemainingText「GitHub API 限流,未尝试关闭」),单批可同时出现「已跳过关闭 / 关闭结果未确认 / 限流请稍后重试 / 限流未尝试关闭」四类;同时 refs 仍取自 failures 前 3 条,与展示的原因不是同一集合。影响:四类同批时最后出现的那类被静默丢掉,被丢的若是「限流,请稍后重试」或「结果未确认,请刷新查看」,用户就拿不到重试/刷新指引(上一轮正是为消除这种掩盖而改)。建议:reasons.length > 3时追加「等 N 类原因」,或优先保留含指引的「未确认/限流」类,并让 refs 与展示原因对齐。R3 处置(行内 P2):Act on,已按方向审计换模型修复(fed57239)
这条是 web toast 子系统连续第 3 轮 Act on,按流程先停了方向审计(pi/claude-opus-5-5),定性为开放反例:前端固定容量展示 + 服务端文案未规范化(go-github 的 Error() 带 METHOD 和完整 URL,逐条不同导致去重对原文类失效),每加一类文案就产生新边界——把 3 调成 4 只会迎来第 5 类。已按审计路径换模型:
<原因> ×N(如 INT-x)),引用与原因天然同集合;failed超明细上限 50 时显式标注「另有 X 条未列明细」。验证:服务端规范化用例(502/传输→未确认、同类 404 逐字相同且无 URL);前端四类同现全可见、差额标注用例;
-race ×3、make lint/make test/api-types在fed57239全绿。R3 处置(总结含 2 条 Note):按审计定性分路
Note 1(认证/权限类不熔断)→ Consider,本 PR 不修,已拆后续 issue:INT-192。依据:限流熔断的理由是继续请求会延长次级限流封禁(代价随请求增长),401/非限流 403 不加重惩罚、单批最多 200 条快速失败、代价有上限;404 是单条 Issue 状态(被转移/删除),按它熔断会误伤整批——判定口径需要单独设计。分组计数展示后,同文案只占一行,「failures 被占满」在界面上不再有害。这是新的派发策略,超出 INT-185 边界,按审计裁定拆出。
Note 2(网关 502/503/504 与网络中断归因)→ Act on,已修复(fed57239):前端
formatBatchCloseError把网关 502/503/504(无 JSON 正文)与 fetchTypeError归入「请求超时:部分问题可能已关闭,请刷新看板确认结果」,带 JSON message 的服务端错误仍优先;服务端同步把 GitHub 5xx 与传输层错误逐条归入「结果未确认:…请刷新查看」——两层都按「远端可能已生效,宁可多提示刷新」归类。测试补齐,fed57239全绿。本轮未发现 P0/P1:核心行级命中、在途 PATCH 解耦与限流熔断逻辑自洽;剩余问题集中在失败提示层——服务端 50 条明细上限仍会整体隐藏失败类别,sonner description 的换行会被折叠,2xx 响应解析失败也未归入「结果未确认」。影响是混合批次里用户可能看不到限流/刷新指引,分组计数在真实界面退化成一段。
@ -313,3 +424,4 @@func appendBatchCloseFailure(resp *BatchCloseDoneIssuesResponse, issue model.Issue, err error) {resp.Failed++if len(resp.Failures) >= batchCloseDoneMaxFailures {P2:问题:失败明细仍在服务端被截断到 50 条(appendBatchCloseFailure),而前端只能按已列明细分组,
另有 N 条未列明细不说明缺了哪几类;内部路径先跑(closeInternalIssues 在 closeGitHubIssues 之前),其「已跳过关闭」会先占满 50 条。影响:混合来源批次里若 >50 条内部行被并发方提前改出 open,后续 GitHub 侧整类原因(限流未尝试、结果未确认)一条都不会出现在 toast,用户拿不到重试/刷新指引,与本轮「每类至少出现一次」的目标相反。建议:服务端在截断前按文案聚合(每类 count + 一个样例,并返回 unlisted),或前端至少显式提示存在未列类别。@ -54,0 +69,4 @@const unlisted = result.failed - result.failures.length;if (unlisted > 0) lines.push(`另有 ${unlisted} 条未列明细`);toast.warning(`已关闭 ${result.succeeded} 个问题,${result.failed} 个失败`, {description: lines.join("\n"),P2:问题:description 用
lines.join("\n")拼多行,但 sonner 2.0.7 把 description 作为普通文本节点渲染,其样式表与项目 CSS 都没有 white-space: pre-line/pre-wrap,换行会被折叠成空格。影响:多类原因混排时本应逐行的「原因 ×N(如 INT-x)」连成一段,分组与计数在真实 toast 里难以辨认;单测只断言字符串,覆盖不到渲染结果。建议:description 传 ReactNode(每行一个 div 或),或给 descriptionClassName 加 whitespace-pre-line,并补一条渲染断言。
@ -132,2 +166,4 @@if (error instanceof Error && error.name === "TimeoutError") {return REQUEST_UNCONFIRMED_COPY;}return "批量关闭失败";P2:问题:formatBatchCloseError 只认 HTTPError/TypeError/TimeoutError;ky 的
.json()在 2xx 响应体被截断或不是 JSON 时抛 SyntaxError,落到「批量关闭失败」;同时 catch 覆盖了请求成功后的分组与 toast 代码,那里抛出的 TypeError 也会被报成「请求超时」。影响:请求已到达服务端、关闭可能已生效,却提示普通失败或错误的超时语义,正是 R3 要消除的「可能已关闭」误报。建议:区分「请求未发出」与「响应未解析」,把 2xx 解析失败归入 REQUEST_UNCONFIRMED_COPY,并把 catch 收窄到 mutateAsync。R4 处置(行内 P2,明细截断吞类别):Act on,已修复(a6527974)
采纳,选了不改 API 契约的那条路:
appendBatchCloseFailure达明细上限后,未出现过的新类别样例仍保留进明细(允许超上限,每类恰一条,同类不重复追加)。效果:混合批次里内部「已跳过」占满 50 条后,GitHub 侧的限流/未尝试/结果未确认样例依然各出现一次,前端分组计数逐类可见;明细差额仍由「另有 X 条未列明细」标注。注意取舍:超上限后组内 ×N 是已列明细的计数(下界),真实总数由标题 failed 携带——聚合 count + unlisted 的形态需要动契约,若你认为值得另开 issue 讨论。测试:类别保全用例(超上限新类别样例恰一次、同类不追加、计 Failed 照常)。
R4 处置(行内 P2,description 换行折叠):Act on,已修复(a6527974)
采纳第一种:description 传 ReactNode,逐行
<div>(flex-col 排列,不依赖 white-space 样式);行构建抽成纯函数buildBatchCloseFailureLines供断言。测试改渲染断言:toast 的 description 元素渲染后逐行 getByText(含四类同现 + 差额标注场景),不再是字符串断言。R4 处置(行内 P2,归因误报):Act on,已修复(a6527974)
两点都采纳:
formatBatchCloseError新增 SyntaxError 分支 →「请求超时:部分问题可能已关闭,请刷新看板确认结果」(2xx 已到达服务端、关闭可能已生效,宁可多提示刷新)。handleClose的 catch 收窄到mutateAsync本身;分组与 toast 代码移出 try,内部错误不再被误报成超时语义。补 SyntaxError 用例;
make lint/make test在a6527974全绿。本轮只剩两处不影响主路径正确性的边界问题:限流熔断与整批截止叠加时会把「未开始」失败原因错标成限流,以及 failures 允许超 50 后 OpenAPI 仍写「最多前 50 条」。两者都不改变关闭结果与落库数据,主要影响失败原因的归因准确性与接口文档一致性。
@ -295,3 +392,3 @@for res := range results {if res.err != nil {appendBatchCloseFailure(resp, res.issue, res.err)appendBatchCloseFailure(resp, res.issue, classifyGitHubCloseFailure(res.err, &rateLimited))P2:问题:失败归因在 drain 阶段用「当下」的 rateLimited 快照判定,而错误本身产生于派发阶段。派发循环里 Acquire(dispatchCtx) 失败且当时 rateLimited=false 的项会带着裸 ctx err 入队(第 368-372 行),到第 394 行统一过 classifyGitHubCloseFailure 时,若之后某个在途 PATCH 才命中限流,rateLimited 已翻成 true,这些项就被改写成「GitHub API 限流,未尝试关闭」。典型场景:30s 整批截止先落地(剩 25 项入队 DeadlineExceeded),随后 29s 启动的在途 PATCH 返回 403 限流,25 项文案全部变成限流。影响:同一批次的失败归因随 goroutine 时序变化,用户被引导去「稍后重试限流」,而真实原因是整批超时/调用方取消;与 PR 目标「文案分两类、归因不串」相反。建议:在入队时就把归因定下来(对 Acquire/worker 守卫的 ctx err 当场按当时的 rateLimited 转成固定文案,或随结果带上 errAtRateLimit/errAtDeadline 标记),drain 阶段只做格式化,不再重新读全局状态。
R5 处置(行内 P2,归因快照竞态):Act on,已修复(f36ccecd)
完全采纳你建议的形态:归因当场定死。
batchCloseCancelErr(err, rateLimited):派发阶段产生 ctx err 的两处(Acquire 失败、worker 守卫)按当时的熔断状态当场转换——熔断已触发→「GitHub API 限流,未尝试关闭」,否则保留原始 ctx err;结果入队后 drain 只做格式化,删除classifyGitHubCloseFailure的 drain 期重读。closeOneGitHubIssue增加 rateLimited 参数(守卫归因同样当场定死)。测试:
TestBatchCloseCancelErr_AttributedAtProductionTime(未熔断保留原文案、熔断归限流、nil 等价未熔断);既有全部熔断/取消/并发用例-race ×2复跑通过,make lint/make test在f36ccecd全绿。R5 处置(P2,契约描述与行为不符):Act on,已修复(f36ccecd)
采纳。
openapi.yaml的IssueBatchResult.failures描述改为:「按失败原因保留明细:通常最多前 50 条;达到上限后每类新文案各保留一条样例,总数可能略超 50」,并重跑全部三份生成物同步(server/internal/api/types.gen.go(make api-types)、web/src/types/api.gen.ts(pnpm api-types)、skills/fast-ship/references/api.md(make api-docs)),pnpm api-types --check通过。容量假设类调用方现可按描述正确理解超限数组。本轮未发现 P0/P1;行级命中、在途 PATCH 解耦与归因即时定死的主逻辑成立,但内部关闭的命中判定仍只认 state、看板按文案分组的计数在明细被截断时失真。两者都会让「关闭全部已完成」的结果对用户产生误导。
@ -70,0 +64,4 @@closed := make([]string, 0, len(ids))for _, id := range ids {res := tx.Model(&model.Issue{}).Where("id = ? AND source = ? AND state = ?", id, model.IssueSourceInternal, model.IssueStateOpen).P2:问题:BatchCloseInternalTx 的命中条件只有 id/source/state=open,而待关闭清单是按 issues.state=open 且 issue_internal_meta.workflow_status=done 取的;两条语句之间被看板拖回进行中的行只改 workflow_status、state 仍为 open,仍会被这条 UPDATE 命中。影响:用户刚落回「进行中」的问题被「关闭全部已完成」静默关闭,计为 succeeded,并在同一事务里删掉推荐与依赖(不可撤销),与 PR 声称的「行级命中」不符。建议:把 workflow_status='done' 一并放进 WHERE(例如 EXISTS 子查询 issue_internal_meta),未命中的行走既有 errBatchCloseSkippedNotOpen 失败分支。
@ -28,0 +41,4 @@for (const f of result.failures) {const label = f.error || "原因未说明";const group = groups.get(label) ?? { count: 0 };group.count += 1;P2:问题:分组计数只遍历服务端返回的 failures 明细(最多 50 条,超限后每类仅补一条样例),×N 实际是「已列条数」而不是该类真实条数。影响:明细被截断时计数严重低估,例如 120 条 404 + 80 条结果未确认只会展示「更新 GitHub Issue 失败:404 Not Found ×50」「关闭结果未确认… ×1」加「另有 149 条未列明细」,用户会把 79 条未确认当成 1 条,正是本 PR 要保住的「刷新确认」指引被弱化;单测里 200 条未尝试关闭也断言成 ×1 即为该失真。建议:服务端在 IssueBatchResult 里补每类失败的真实计数(或在 failures 之外返回完整分组统计),前端按真实计数展示,样例引用仍取首条。
R6 处置(行内 P2,workflow 未钉住):Act on,已修复(d48f2ea4)
属实且比 R1 的 state 命中问题更深一层(同根:tx 条件弱于清单条件)。已修:WHERE 经子查询钉住同条件——
id IN (SELECT issue_id FROM issue_internal_meta WHERE workflow_status = 'done')(workflow_status 在 issue_internal_meta 表,经子查询关联而非同表列)。被拖回开发中的行不再命中,状态保持 open/in_progress 不动,并按未命中记入 failures(「该问题已在其他操作中被更新,已跳过关闭」)。测试:repo 层补 meta 种子与拖回行断言(不命中、issue 与 meta 状态均不动);服务层既有跳过/命中用例随种子调整复跑;
-race ×3、make lint/make test在d48f2ea4全绿。R6 处置(行内 P2,分组计数低估):Act on,已修复(d48f2ea4)
采纳。明细被截断时不展示 ×N 计数——那只是已列明细的下界(如 50/204),展示出来必然误导;截断时每组只保留「<原因>(如 <样例引用>)」,总量由标题 failed 携带、差额由「另有 X 条未列明细」标注;明细完整(failed == failures.length)时照常展示 ×N。测试更新:截断场景断言无 ×N 且四类全可见 + 差额标注;非截断场景 ×N 保留。
本轮 head
d48f2ea4复核行级命中、在途 PATCH 与超时预算改动:R6 两条已修复,新发现 GitHub 关闭分支仍缺行级复核,以及共享失败明细契约描述与 internal-meta 端点实现不符。影响:批量关闭进行中被拖回「进行中」的 GitHub 问题仍会被远端关闭并删推荐,且 PUT /api/issues/internal-meta 的 failures 说明会误导调用方。@ -11484,3 +11484,3 @@items:$ref: '#/components/schemas/IssueBatchFailure'description: 最多返回前 50 条失败明细description: 按失败原因保留明细:通常最多前 50 条;达到上限后每类新文案各保留一条样例,总数可能略超 50P2:问题:IssueBatchResult.failures 的描述改成「达到上限后每类新文案各保留一条样例」,但该 schema 同时被 PUT /api/issues/internal-meta 使用,而 BatchUpdateInternalMeta(server/internal/service/issue_meta.go:165-174)仍是「只收前 50 条、没有每类样例」。影响:生成的 types.gen.go / api.gen.ts / skills/fast-ship/references/api.md 都带上这句,调用方(含 agent)会以为 internal-meta 端点截断后仍能拿到全部失败类别与差额,实际后出现的类别会被静默丢弃。建议:要么让 BatchUpdateInternalMeta 复用 appendBatchCloseFailure 的每类样例逻辑,要么把描述收回到 batch-close 端点(端点级描述或拆 schema),并重跑三份生成物。
@ -197,0 +224,4 @@}patchCtx, cancel := context.WithTimeout(context.WithoutCancel(batchCtx), batchCloseGitHubPatchTimeout)defer cancel()updated, err := client.UpdateIssue(patchCtx, issue.GitHubMeta.Number, updateReq)P2:问题:closeOneGitHubIssue 发 PATCH 前只判 GitHubMeta 与批次取消,从不复核本地行是否仍为 issues.state=open 且 issue_internal_meta.workflow_status=done;内部来源已在 BatchCloseInternalTx 的 WHERE 里钉住这两个条件,GitHub 来源没有对应动作。影响:批量关闭最长 30s、最多 200 条的窗口内,用户(或并发 agent)把某个 GitHub 问题从「已完成」拖回「进行中」后,该问题的 PATCH 仍会发出,远端与本地 state 被置 closed、upsert 还会删除推荐并计为 succeeded,最终留下 state=closed 而 workflow=in_progress 的不一致行——正是 R6 认定必须避免的「拖回仍被误关」,且这条分支的窗口比内部事务长得多。建议:在 worker 拿到槽位后、发 PATCH 前按 issue.ID 复核 state=open && workflow_status=done(条件查询或条件读取本地行),不满足则按「该问题已在其他操作中被更新,已跳过关闭」计入 failures 并不发 PATCH。
R7 处置(行内 P2,GitHub 分支缺行级复核):Act on,已修复(6e2731ee)
属实,与 R6 内部来源同根。已修:新增
IssueRepository.IsOpenAndDone(id + issues.state=open + JOIN issue_internal_meta workflow=done计数),经回调注入closeOneGitHubIssue——worker 拿到槽位后、发 PATCH 前按 id 复核:不满足按「该问题已在其他操作中被更新,已跳过关闭」计入 failures、不发 PATCH;复核本身出错也不发 PATCH(回报「复核本地状态失败,未关闭该项」)。测试补三态(被拖回→跳过;出错→不发 PATCH;通过→照常 PATCH),-race ×3与全量 lint/test 在6e2731ee全绿。R7 处置(行内 P2,共享契约描述与实现不符):Act on,已修复(6e2731ee)
采纳「让实现对齐契约」方向:抽出
appendBatchFailureEntry(达到上限后每类新文案各保留一条样例),批量关闭与BatchUpdateInternalMeta共用——schema 描述对两个端点都成立,不用拆 schema 或收回描述。补了 internal-meta 端点的截断测试(50 条同类 + 1 条新类别 → 51 条明细、新类别样例在列)。本轮新发现两处边界问题:内部批量关闭事务内的 SQL 结构让每条 UPDATE 重扫整张 issue_internal_meta,GitHub 行级守卫不带 context 且返回后不再复核取消状态。二者在大库或连接竞争下会放大关闭窗口,可能把整批内部关闭回滚成超时失败,并让已取消/超时的批次继续发出新的 PATCH。
@ -70,0 +68,4 @@closed := make([]string, 0, len(ids))for _, id := range ids {res := tx.Model(&model.Issue{}).Where("id = ? AND source = ? AND state = ? AND id IN (SELECT issue_id FROM issue_internal_meta WHERE workflow_status = ?)",P2:问题:
id IN (SELECT issue_id FROM issue_internal_meta WHERE workflow_status = ?)与同句的id = ?无关联,子查询不随主键收窄(SQLite 计划为 LIST SUBQUERY + SCAN issue_internal_meta,且 workflow_status 无索引),而它在事务里按 id 逐条执行,最多 200 次、每次重扫整张 meta 表。影响:语句数从旧实现的 1 条变成 200 条并各带一次全表扫描,生产 MaxOpenConns=1,这段时间独占唯一连接、阻塞全站其他请求;扫描量随历史 issue 总量线性增长,一旦吃掉 30s 预算,整个内部事务回滚并给用户回报「请求已取消或超时,未继续关闭」,把本可成功的关闭变成全批失败。建议:改成关联 EXISTS(EXISTS (SELECT 1 FROM issue_internal_meta WHERE issue_id = issues.id AND workflow_status = ?))走 issue_id 主键,或保留单条id IN (...)批量 UPDATE 后在事务内查询命中集合。@ -70,0 +89,4 @@// IsOpenAndDone GitHub 批量关闭的行级守卫(INT-185 R7):PATCH 前按 id 复核// 本地行仍为 state=open 且 meta.workflow_status=done——批量窗口内被拖回开发// 中的行不发 PATCH、按「已跳过关闭」计入 failures,避免远端/本地被误关。func (r *IssueRepository) IsOpenAndDone(id string) (bool, error) {P2:问题:
IsOpenAndDone直接用r.db(无 ctx),在closeOneGitHubIssue中位于batchCtx.Err()判定之后、PATCH 之前,返回后不再复核批次状态。影响:批次已超时/被用户取消时,只要 worker 还卡在这条无上下文的 DB 查询上(单连接池可能排在别的长事务后),查询一返回仍会发出新的 PATCH,与「取消后不再开始新 PATCH」的保证不符;这条查询也不受 15s PATCH 上限或 30s 整批预算约束,可能把请求拖过前端 60s 超时。建议:守卫改用带 ctx 的句柄,并在守卫通过后再判一次batchCtx.Err(),已取消/超时则按「未开始」计数、不发 PATCH。R8 处置(行内 P2,子查询全表扫描):Act on,已修复(ab3ebfce)
属实,是 R6 修复引入的性能回归。已改为关联
EXISTS (SELECT 1 FROM issue_internal_meta WHERE issue_id = issues.id AND workflow_status = ?)——走 issue_id 主键、逐 id 不重扫 meta 表,原有语义(state=open + workflow=done 命中才关闭)不变;repo 拖回行断言照常绿。R8 处置(行内 P2,守卫无 ctx 且不再复核取消):Act on,已修复(ab3ebfce)
属实,是 R7 守卫自身留下的时序缝。已修:
IsOpenAndDone改带 ctx(WithContext,单连接池排队也能被批次取消打断,不会拖过整批预算),并在守卫通过后、发 PATCH 前再判一次batchCtx.Err()——期间已取消/超时按「未开始」计数、不发 PATCH,与「取消后不再开始新 PATCH」的保证对齐。测试补了「守卫通过但批次已取消→不发 PATCH」的用例。本轮未发现阻塞性问题,确认 R8 两条修复已生效;新发现两条 P2:守卫被批次取消打断时被误报为内部复核失败,以及 HTTP 5xx 带 JSON 正文时绕过了「结果未确认」的刷新提示。
@ -197,0 +227,4 @@// 在事务 WHERE 里钉住同条件,GitHub 来源没有事务可依托,只能靠这里。if guard != nil {ok, gerr := guard(batchCtx, issue.ID)if gerr != nil {P2:问题:closeOneGitHubIssue 的守卫分支把所有 gerr != nil 一律映射为 ErrInternal「复核本地状态失败,未关闭该项」,没有区分 ctx 取消/超时;而本轮 R8 刚把 IsOpenAndDone 改成带 batchCtx(repository/issue.go:97),限流熔断(dispatchCancel)或整批 30s 截止会让正在排队或执行中的守卫查询直接返回 context.Canceled / DeadlineExceeded。影响:MaxOpenConns=1 时守卫查询在唯一连接上串行排队,限流熔断或被取消的批次中这些项会被展示为「复核本地状态失败」这类内部故障类别,而不是「GitHub API 限流,未尝试关闭 / 请求已取消或超时」,与 PR 声明的失败文案分类不符,用户会误以为数据库或服务异常并重复重试。建议:guard 出错时先判 errors.Is(gerr, context.Canceled) / errors.Is(gerr, context.DeadlineExceeded),走 batchCloseCancelErr(gerr, rateLimited),其余才按内部复核失败回报;补一条「守卫在途时批次被取消/熔断」的用例(现有用例只覆盖守卫已通过后在 PATCH 期间取消)。
@ -129,3 +173,4 @@export async function formatBatchCloseError(error: unknown): Promise<string> {if (error instanceof HTTPError) {const body = await error.response.json<ApiResponse<unknown>>().catch(() => null);if (body?.message) return body.message;P2:问题:formatBatchCloseError 对 HTTPError 先取 body.message 就直接返回,只有 JSON 解析失败时才按 502/503/504 走刷新确认;因此任何带 JSON 正文的 5xx(应用自身 500「服务器内部错误」、网关返回 JSON 错误页)都会走具体文案分支。影响:与 PR 交付范围声明「整请求只要远端可能已生效(服务端 5xx/传输错误)一律提示刷新确认」不一致;若服务端在关闭了一部分后才失败(例如 drain 阶段异常返回 500),用户只看到「服务器内部错误」,不会去刷新确认已关闭的部分,随后可能重复操作。建议:先按 error.response.status >= 500 返回 REQUEST_UNCONFIRMED_COPY(可把 body.message 拼进文案保留细节),再处理 4xx 的 message;补一条「500 + JSON 正文」的用例,与现有 HTML 502 用例对齐。
R9 处置(行内 P2,守卫取消被误报为内部故障):Act on,已修复(edd78bbb)
属实,R8 把守卫改成带 batchCtx 后引入的归因缝。已修:守卫出错先判
errors.Is(gerr, context.Canceled)/DeadlineExceeded,走batchCloseCancelErr按当时熔断状态归因(未熔断→ctx err 透传,归「未开始」;熔断中→限流文案),其余才报「复核本地状态失败」。补了「守卫在途时批次取消/熔断→不发 PATCH 且归因正确」的用例(现有用例只覆盖守卫已通过后在 PATCH 期间取消)。R9 处置(行内 P2,5xx 带 JSON 绕过刷新提示):Act on,已修复(edd78bbb)
属实,与「整请求只要远端可能已生效一律提示刷新确认」的交付范围冲突。已修:先按
status >= 500返回刷新确认文案(body.message作细节拼进文案保留),再处理 4xx 的message——服务端自身 500 带 JSON 正文、网关 502/503/504 JSON 错误页都正确走刷新提示,不再被具体文案掩盖。补了「500+JSON→刷新确认(含细节)、4xx+message→具体文案」的用例。R10 处置(P2 + Note):均 Act on,已修复(56f7cc48)
属实,是「远端可能已生效一律提示刷新」覆盖的最后一条缝。已修:两处落地失败统一包装为
batchClosePatchUnconfirmedError,与在途 PATCH 超时/5xx/传输层错误同归「结果未确认:GitHub 侧可能已关闭,请刷新查看」——至此「PATCH 已发出但本地结果未落库」的三种情形(在途超时、远端 5xx、本地落地失败)全部归未确认类。属实且关键——之前「补了守卫在途取消用例」的验证结论不成立。已重写
GuardCtxCancelAttribution:守卫阻塞到批次取消后才返回ctx.Err(),保证真正进入守卫分支而非顶部早退;分别断言三种(守卫执行中取消→ctx err 透传归「未开始」;守卫执行中熔断→限流文案;守卫通过但返回后取消→第二次batchCtx.Err()复核归「未开始」不发 PATCH)。并实测把守卫归因改回普通内部错误时该用例会红——确认真的测到了分支。验证:
-race ×3、make lint/make test(1777 用例)在56f7cc48全绿。R10 的两项修复已在 head 落地并有针对性用例,本轮未发现新的阻塞问题,只剩一条守卫归因边界与两条测试/契约隐患。归因边界会让整批取消/熔断时的个别项被误报为「复核本地状态失败」内部故障,其余为可维护性问题。
@ -197,0 +235,4 @@results <- githubCloseNetResult{issue: issue, err: batchCloseCancelErr(gerr, rateLimited)}return}results <- githubCloseNetResult{issue: issue, err: errs.New(errs.ErrInternal.Code, "复核本地状态失败,未关闭该项")}P2:问题:守卫出错分支只用 errors.Is(gerr, context.Canceled/DeadlineExceeded) 判断归因,没有回看批次状态;批次已超时或限流熔断时,若驱动/GORM 把取消包装成非 ctx 类型错误(驱动层 SQLITE_* 包装、连接池关闭错误等),会落到 ErrInternal「复核本地状态失败,未关闭该项」。影响:整批取消/熔断这类本应归「未开始/限流」的项被报成内部故障,前端多出一类「系统故障」文案,用户会误以为服务异常并重试,与 R9 定下的归因口径不一致。建议:在 fallback 之前先判 batchCtx.Err() != nil(或 rateLimited.Load()),按 batchCloseCancelErr 归因,仅当批次仍存活时才用内部错误。
R11 处置(P2 + 2 Note):均 Act on,已修复(13509445)
属实,R9 归因留的缝。已修:守卫出错除 ctx err 判定外再回读
batchCtx.Err()!=nil || rateLimited.Load()——批次已死(ctx done 或已熔断)即使守卫返回非 ctx 包装错误也按batchCloseCancelErr归因,仅批次仍存活才报内部复核失败。batchCtx.Err()为 nil(仅熔断、守卫返回非 ctx 错误)时用context.Canceled兜底保证 err 非 nil。属实。抽出窄接口
batchCloseMirrorStorer(仅upsertGitHubIssue+markCollectionsStaleAfterPush两方法)作IssueService的可选注入点(生产为 nil 回落s.mirror),新增failingBatchCloseStorer注入 upsert 与 markStale 失败,断言计入 failed 且文案为「关闭结果未确认…请刷新查看」,并断言 PATCH 确实发出过(远端已关正是要提示刷新的原因)。属实。描述改为「最坏与单批条数相同(batchCloseDoneMaxIssues=200),不只是「略超 50」」,重跑三份生成物同步(types.gen.go / api.gen.ts / api.md),
pnpm api-types --check通过。验证:
-race ×3、前端 vitest、make lint/make test(1777 用例)在13509445全绿。本轮未发现 P0/P1 新问题,R11 的 P2 与两条 Note 已修复并有用例覆盖;仍有一处 P2:内部批量关闭事务的取消归因没有像守卫分支那样回读 ctx,驱动包装错误时会被误报为服务器内部错误。
@ -218,3 +334,4 @@return s.recRepo.DeleteByIssueIDsTx(tx, closed)})if err != nil {if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {P2:问题:closeInternalIssues 的事务失败后只用 errors.Is(err, context.Canceled/DeadlineExceeded) 判断取消,没有回读 ctx.Err();影响:批次 30s 截止或调用方取消时,若驱动/GORM 把取消包装成非 ctx 错误(SQLITE_INTERRUPT、连接池关闭错误等),会被改写成 errs.ErrInternal,前端把本应「请求已取消或超时,未继续关闭」的整批内部项显示成「服务器内部错误」,用户会误以为服务故障而重试,与 R11 已为守卫分支修定的归因口径不一致;建议:在映射 ErrInternal 之前同样检查 ctx.Err()!=nil(或 errors.Is(err, ctx.Err())),按取消/超时归因,仅 ctx 仍存活时才报内部错误。
R12 处置(行内 P2,内部事务缺批次回看):Act on,已修复(69510266)
属实,与 R11 守卫分支同根。已修:事务失败除判定 err 本身外先回读
ctx.Err()——批次已死(ctx done)时归取消/超时,仅批次仍存活才报ErrInternal,与 R11 守卫归因口径一致。测试:新增
InternalTxWrappedCancel——ctx 已死时逐条归「请求已取消或超时,未继续关闭」而非「服务器内部错误」。说明:驱动把取消包装成 SQLITE_INTERRUPT 这类非 ctx err 本身难确定性复现(单连接池事务内查询很快),ctx.Err()回读覆盖了该边界;回读被误改时现有取消用例仍会红。-race ×3、make lint/make test(1777 用例)在69510266全绿。本轮未发现 P0/P1;批量关闭的行级命中、取消归因与前端分组逻辑整体自洽,剩两处边界:裸 429 不触发限流熔断、R12 新增的归因分支没有被它自己的用例真正覆盖。前者会让批次在限流下继续派发大量 PATCH,后者会让已修好的归因在回归时静默失效。
@ -323,0 +542,4 @@return false}code := respErr.Response.StatusCodeif code != http.StatusForbidden && code != http.StatusTooManyRequests {P2:问题:isGitHubRateLimitError 只在 403/429 且 Message 或 body 含 rate limit/abuse detection 时才判定为限流,429 本身是权威限流信号却会被空 body、纯 Retry-After 或网关/WAF 自定义文案放过。影响:这类 429 不会触发 onRateLimit → dispatchCancel 熔断,剩余最多约 196 项继续向已被限流的端点派发 PATCH,可能延长封禁,与 R2「命中限流后不再派发」的目标冲突。建议:status==429 直接命中限流(403 再按 Message/body 文案兜底),并补一条空 body 429 的用例。
R13 处置(P2 + Note):均 Act on,已修复(05615b21)
属实。改为
status==429直接命中限流(权威信号,无需 body 文案),403 仍按 Message/body 含 rate limit 字样兜底——裸 429 现在会触发onRateLimit→dispatchCancel熔断,剩余项不再向已限流端点派发。补了空 body 与网关自定义文案两种 429 用例。属实且我试过「事务前持锁阻塞 UPDATE 后 cancel」——在共享内存库(glebarez/sqlite + busy_timeout)上死锁,不可用。改为抽
classifyInternalCloseErr归因函数:批次 ctx 已死→归ctx.Err()自身;存活时 ctx err 透传、非 ctx 错误归ErrInternal并记 warn。用例直接断言三条边界(死 ctx+包装错误→取消/超时;活 ctx+ctx err→透传;活 ctx+包装错误→ErrInternal+warn),并实测把回读删掉时用例会红——真正覆盖 R12 修复。验证:
-race ×3、make lint/make test(1777 用例)在05615b21全绿。本轮未发现新的功能性缺陷,R13 的两项(裸 429 熔断、事务失败归因)已按描述落地;唯一新问题是 GitHub PATCH 与本地落地失败归一化成静态文案后完全不记日志,系统性故障线上无从定位。
@ -197,0 +265,4 @@patchCtx, cancel := context.WithTimeout(context.WithoutCancel(batchCtx), batchCloseGitHubPatchTimeout)defer cancel()updated, err := client.UpdateIssue(patchCtx, issue.GitHubMeta.Number, updateReq)if err != nil {P2:问题:GitHub PATCH 失败后只归一化成固定文案,底层 err(超时/传输/5xx)被 wrapper 丢弃,closeGitHubIssues 全程无一条日志(唯一条 warn 在内部事务路径)。影响:证书过期、代理/DNS 故障、GitHub 5xx 这类系统性故障会让整批 200 条都只显示「关闭结果未确认」,服务端没有任何可定位证据,而改造前的文案还带着 %v 详情。建议:在归一化前(或 drain 循环 res.err != nil 分支)按 issue id/number 记 warn 并带 zap.Error,再返回静态文案。
R14 处置(P2 + 2 Note):均 Act on,已修复(fcdba9a0)
属实。
closeOneGitHubIssue加warn参数(生产注入s.logger.Warn),PATCH 失败归一化前先按 issue id/number 记 warn +zap.Error——证书过期、代理/DNS、GitHub 5xx、超时这类系统性故障现在能在服务端日志里定位,用户侧文案仍统一为「结果未确认」。属实。两处落地失败先记 warn(
issue_id+zap.Error)再返回静态文案——磁盘满、DB 只读导致的「远端已关、本地未落地」现在可查,与内部路径 warn 对称。属实。加
runCloseInternalTx测试缝(生产 nil 走真实事务),新用例InternalTxInjectedErrorAttribution注入返回驱动包装错误(非 ctx 类型)的缝:死 ctx→归「请求已取消或超时」、活 ctx→归ErrInternal,真正覆盖事务失败回读分支的调用点。验证:
-race ×3、make lint/make test(1777 用例)在fcdba9a0全绿。本轮复审
fcdba9a0的 R14 修复与其周边新逻辑:R14 三项已落地,但行级守卫出错时仍丢弃底层错误且不落日志,与刚补齐的 PATCH/落地日志路径不一致。其余为守卫 SQL 的回归保护缺口。@ -197,0 +247,4 @@results <- githubCloseNetResult{issue: issue, err: batchCloseCancelErr(src, rateLimited)}return}results <- githubCloseNetResult{issue: issue, err: errs.New(errs.ErrInternal.Code, "复核本地状态失败,未关闭该项")}P2:问题:行级守卫 IsOpenAndDone 返回非 ctx 错误(如 SQLITE_BUSY、连接池/驱动故障)时,这里直接折成 errs.New(..., "复核本地状态失败,未关闭该项"),gerr 既不记 warn 也不保留;warn 参数此时就在函数里可用。影响:数据库抖动会让整批 GitHub 行静默变成「复核失败」,服务端日志没有任何证据,无法与真实 GitHub 故障区分,也无法定位是哪些 issue、什么错误(R14 刚为 PATCH 失败和本地落地失败补了同样口径的 warn,唯独这条路径漏了)。建议:与 PATCH 失败分支一致,先 warn("batch close github issue: guard failed", issue_id, zap.Error(gerr)) 再返回静态文案。
R15 处置(P2 + Note):均 Act on,已修复(51ba1414)
属实,R14 补日志时唯独漏了这条路径。已修:守卫非 ctx 错误分支先
warn("batch close github issue: guard failed", issue_id, zap.Error(gerr))再返回静态文案——与 PATCH 失败、本地落地失败的日志口径完全一致。属实,这是 GitHub 来源唯一的误关保护,确实缺真库回归。补
TestIssueRepositoryIsOpenAndDone:seed open+done、open+in_progress(拖回行)、closed+done 三行 + 缺失 id,断言只有第一种返回 true。并实测把 workflow JOIN 去掉时用例红(「拖回行不得守卫通过」被误判 true)——回归保护有效。验证:
-race ×3、make lint/make test(1777 用例)在51ba1414全绿。R16 收敛说明
第 16 轮全部组合通过(0 P0–P2 / 0 Note)——R1 起共 15 轮 21 条 P2 + 6 Note/Consider 全部 Act on 修复并逐条回复,无 Act on 剩余,审查收敛于
51ba1414。-race基线测试失败)、INT-192(认证/权限类熔断)视觉审查 ·
51ba141· 第 16 轮视觉审查状态说明(巡检后核实)
第 16 轮代码审查已全部组合通过(0 P0–P2 / 0 Note,check Success),审查收敛于
51ba1414。视觉审查 check 当前为 Error,但其评论仅有标题、正文为空——没有可归桶的具体发现。若视觉复核产出了具体问题(截图对比、布局/文案),请补全评论内容,我将按 pr-review-loop 归桶处置;若为流水线自身故障(截图获取失败等),烦请重跑。在此之前 INT-185 维持 done、监听保持。
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.Merge
Merge the changes and update on Forgejo.Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.