go build ./... 通过
go vet ./... 通过
go test ./... 28 个包全部 ok(含 internal/core/queue、portal/worker)
python dev_scripts/harness.py sync --check Wiki 镜像检查通过
python dev_scripts/harness.py check --strict DevHarness 检查通过
python -m unittest discover -s tests OK
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
基本信息
依赖与并行
子项目影响
portal(worker)、internal/core/queuegenerations.attempts与error_code的取值逻辑go test ./internal/core/queue/... ./portal/worker/...,以及全量go build ./... && go vet ./... && go test ./...原始需求
要解决什么
现象
上游超时(
upstream_timeout)的失败被记录并展示为route_unavailable,用户端提示"当前没有可用的图片生成路由"。但此时路由配置完全正常:active_routes.image_generate → route_pool 2,成员与 provider、model 三级enabled均为 1。错误信息把使用者引向错误的排查方向。复现
以 generation 13 为例,其
attempts中的 provider 尝试记录自相矛盾:route_unavailable表示"没有可用成员",但同一条记录却有 300 秒的 provider 调用耗时和retryable: true—— 说明调用真实发生过并超时,错误码事后被改写。期望结果
error_code反映真实失败原因(超时即upstream_timeout,候选耗尽即failover_exhausted),只有真正选不出成员时才是route_unavailable。attempts中每条 provider 尝试保留该次尝试自身的真实错误码,不被终态码覆盖。这两点是
AGENTS.md红线 9(rendered_prompt与attempts必须落库,失败必须写error_code与error_message)以及架构文档中"attempts是排障的唯一依据"的直接要求。做什么 / 不做什么
generations.status状态机(仍是pending → running → succeeded | failed)。retryable判定、熔断参数、选路算法或 SSRF 校验。已确认方案
根因一:终态判定使用了过期的内存快照
BeginProviderAttempt只在数据库里递增:它没有回写
claim.Generation.ProviderAttemptCount。该字段在ClaimNext时读取一次后再不更新,所以循环结束时仍是 0,落入route_unavailable分支。数据库中 generation 13 的provider_attempt_count = 1,与内存值不一致,可作为佐证。同一个过期值还被
worker.go:160、:175、:182使用。:182的remaining计算在单次process内只算一次,当前不受影响,但依赖的是同一个易错前提,需一并核对。修法:在 worker 内维护本次
process的真实尝试计数(以BeginProviderAttempt成功返回为准),终态判定和remaining计算都使用它,而不是 claim 时的快照。根因二:
Fail()覆盖最后一条尝试的错误码终态码被
JSON_MERGE_PATCH合并进最后一条 attempt,抹掉了finishFailure已经写入的真实upstream_timeout。修法:
Fail()不得覆盖已有error_code非空的 attempt。终态码只写generations.error_code;仅当最后一条 attempt 尚无错误码(例如租约、快照解码等非 provider 阶段的失败)时才填充。修复后该场景的正确记录
同样是单成员池、
max_failover=0、provider 超时:generations.error_code=failover_exhausted(可重试失败但已无候选)attempts[1].error_code=upstream_timeout,retryable: true,latency_ms保留预计修改文件:
portal/worker/worker.gointernal/core/queue/mysql_repository.goportal/worker/worker_test.gointernal/core/queue/mysql_integration_test.go需求变化记录
设计与原型门禁
AGENTS.md红线 9;架构与代码地图"不可破坏的边界";本工单"要解决什么"中的 generation 13 记录文档影响
Troubleshooting 中"
status=failed,error_code是 400/401 → 不应发生故障转移"一类的判读表,需要补上修复后failover_exhausted与route_unavailable的区分含义。交付文档影响
验收标准
max_failover=0、provider 返回可重试失败(超时)时,generations.error_code为failover_exhausted,不再是route_unavailable。attempts中该次 provider 尝试保留upstream_timeout,retryable与latency_ms不被改写。error_code仍为route_unavailable。generations.status流转未改变,仍只允许pending → running → succeeded | failed。go build ./...、go vet ./...、go test ./...全部通过。验证方式
需要 MySQL 的集成测试按既有约定使用本机测试库,不使用生产数据、不调用真实上游。
完成证据
最终差异
portal/worker/worker.go—— 引入本次process的真实尝试计数providerAttemptCount:claim.Generation.ProviderAttemptCount;BeginProviderAttempt成功后更新为attempt.ProviderOrdinal(该值由仓储层按数据库的provider_attempt_count + 1计算,是权威值);:160上限判断、:175选不出候选时的分支、:182remaining计算、:290终态判定)全部改用该变量。internal/core/queue/mysql_repository.go——Fail()不再无条件覆盖尾条尝试:判定条件从"是否有错误码"改为**"该尝试是否已结束"**,语义更清晰:已结束的尝试记录不可变,终态码只写
generations.error_code;只有尚未结束的尾条尝试(租约、快照解码等非 provider 阶段失败)才补写终态信息。GREATEST保证attempt_count = 0时 JSON 路径仍然合法。顺带覆盖的一个额外场景:
saveOutputs失败时(provider 调用已成功),该次尝试现在保持成功记录不变,generations.error_code记upstream_unknown——如实反映"上游成功、本地落盘失败"。测试结果
新增 5 个测试,全部先验证在未修复代码上会失败:
TestWorkerReportsFailoverExhaustedAfterRetryableFailurefailover_exhausted,且该尝试保留upstream_timeoutTestWorkerReportsFailoverExhaustedAfterAllCandidatesFailfailover_exhaustedTestWorkerReportsRouteUnavailableWhenNoMemberIsEligibleroute_unavailable,且begun为空TestWorkerKeepsNonRetryableProviderCodeAsTerminalupstream_bad_request,只尝试一次TestMySQLFailKeepsFinishedAttemptErrorCodeFail()后尾条尝试的error_code/retryable/latency_ms均未被改写反向验证(临时还原修复后运行):
全量验证(含 MySQL 集成测试,
CHORUS_TEST_DSN指向本机 3308 测试库):未验证部分
generations.error_code是否为failover_exhausted、attempts尾条是否保留upstream_*。route_unavailable保持原样作为审计证据,不修改。Fail()竞争未专门验证:依赖既有的 lease_token CAS 谓词,本次未改动该谓词,也未新增并发测试。worker.go:182的remaining计算:改用新变量后行为与原先一致(单次process内只算一次),但没有专门构造"claim 时已有尝试记录"的场景来验证跨轮次的正确性。提交哈希
52ebca2 fix: 修正生成失败错误码被终态码覆盖 (#63)f4a17af docs: 区分 route_unavailable 与 failover_exhausted 的判读 (#63)均已推送至
origin/main。长期 Wiki 页面与 revision
Troubleshooting→9803ea981876455054c5598a38b21fd030211d4c新增终态码判读表,明确区分
route_unavailable(一次上游都没调过)、failover_exhausted(调过且可重试但无候选可换)与upstream_*(不可重试),并说明attempts中每条尝试的error_code不会被终态码覆盖——若看到error_code=route_unavailable却带retryable=true和真实latency_ms,说明跑的是修复前的版本。风险和回退
retryable判定;每一条验收标准都必须有对应单元测试,包含失败分支。验收通过(2026-08-26)
用户于 2026-08-26 明确验收通过。
补充:真实运行证据(原列为未验证项)
工单"未验证部分"第一条"未做真实上游端到端复现",在 #64 的实施过程中被意外补齐。
2026-08-26 09:35,因
extra_body白名单拒绝resolution,generation 16 / 17 / 18 连续三次在毫秒级失败,运行的正是本工单修复后的二进制。以 generation 18 为例:error_code/retryable/latency_ms三者自洽:不可重试的本地配置错误、2 毫秒、未发出上游请求。终态generations.error_code同为upstream_unknown,未覆盖该尝试自身的记录。修复前的版本在这条路径上会把终态码合并进尾条尝试,产生与实际不符的记录。因此本条未验证项可以划掉:修复在真实运行中生效。
其余三条未验证项(历史记录未回溯、
Fail()并发竞争未专门验证、worker.go:182跨轮次场景未构造测试)仍然有效,不因验收而消除。提交
52ebca2 fix: 修正生成失败错误码被终态码覆盖 (#63)f4a17af docs: 区分 route_unavailable 与 failover_exhausted 的判读 (#63)均已推送至
origin/main。部署二进制已于 2026-08-26 10:30 重新编译,包含本修复。本工单关闭。