Table of Contents
同步来源:
docs/tasks/T-259.md· commitafc651f75a3a
id: T-259 title: 安全停止后补报 outbox 待传结果 phase: 2 deps:
- T-257
- T-258 status: DOING created: 2026-07-31 context_ref: 3cc7da0 work_branch: null write_paths:
- docs/tasks/T-259.md
- docs/current-state.md
- android-buyer/app/src/main/**
- android-buyer/app/src/test/**
问题 / 背景
真机 execution 9c634321-7f77-49d9-9444-3ccd2104c104 已采集到一个完全合格的候选
(goodsId=970618634860、EXACT_MATCH、双证据齐全),但 30 分钟离线执行授权到期后,
候选永久滞留在设备 outbox 里传不出去,任务在 Admin 上永远停在 RUNNING。
ProcurementRepository.kt:364-370:
if (
execution.safetyStopped &&
!allowSafetyStoppedSync &&
!reconciliationPending
) {
return@withLock ExecutionSyncDecision.STOP // 在 flush 之前返回
}
// flushExecutionOutbox(session) 在这之后,永远走不到
而 ProcurementExecutionService 只调用:
suspend fun synchronizeRunning(): ExecutionSyncDecision =
synchronize(allowSafetyStoppedSync = false) // 硬编码 false
allowSafetyStoppedSync 这个参数存在,说明设计上考虑过该场景,但代码库中没有任何
调用方传 true。
死锁链条
授权到期 → safetyStopped=true 并持久化
↓
服务循环调 synchronizeRunning() → 撞早退 → STOP → stopSelf()
↓
App 冷启动 → App.kt 检测 hasRunningExecution → 重启服务
↓
又撞同一个早退 → 又 STOP
实测:重启后端、强制停止 Roubao、重新打开,Admin 仍为 RUNNING,App 仍提示
「授权到期,已停止」。重启多少次都是同一结果,没有任何自动或人工恢复路径。
设计缺陷的本质
「安全停止」把两件语义不同的事混在了同一个开关里:
| 动作 | 授权到期后应当 |
|---|---|
| 拼多多 UI 自动化、下单、支付 | 必须停止——这是安全边界 |
| 向自己的管理后端补报已产生的结果 | 应当继续——这是数据完整性 |
业务规则 18 要求「到期后必须安全停止」,指的是停止对拼多多的外部动作;US-009 验收 场景 5 明确要求「离线完成的结果恢复联网后幂等补报」。当前实现把后者也一并停掉了, 与需求相悖。
后端侧不存在障碍:candidate_decision_repository 与 execution_result_repository
对到期后到达的数据只打 received_after_execution_expiry 标记,并不拒绝。
关联需求与交互
- 功能:F-007、F-009
- 用户故事:US-006、US-009
- 交互:Roubao 执行页需显示待补传条数,不新增采购操作
- 架构/API:沿用既有 outbox 与
received_after_execution_expiry语义,后端零改动
行为契约
一、安全停止后执行一次终态补报
synchronize 在 execution.safetyStopped 早退分支返回 STOP 之前,必须先尝试
一次 flushExecutionOutbox,前提是 session 存在且有效。
补报完成(outbox 清空)或本次尝试失败后,仍然返回 STOP,不得让服务循环继续。
这样可以保证外部自动化没有任何机会被重新触发。
session 缺失或已失效时跳过补报直接 STOP,行为与现状一致。
二、绝不因补报而恢复任何外部动作
这是本任务的硬边界:
- 不得让
synchronize在safetyStopped时返回CONTINUE。 - 不得触发
shouldRunOrderDryRun或shouldRunOrderSubmission对应的协调器。 - 不得恢复候选搜索、规格选择、下单 dry-run、订单提交或任何拼多多 UI 操作。
- 不得续租、不得重新领取任务、不得延长执行授权。
补报只允许发生 HTTP 回传,不允许任何设备端自动化动作。必须有单测证明安全停止后 补报路径不会调用任何拼多多驱动。
三、有界重试
单次 synchronize 调用最多尝试一轮 outbox flush,不得在其内部无限循环。
由于返回 STOP 后服务会退出,恢复节奏为:App 每次冷启动获得一次补报机会
(App.kt 已有 hasRunningExecution 判断会重启服务)。这一节奏是可接受的,不要求
本任务实现后台无限重试。
四、待补传可见
Roubao 执行页当前只显示「授权到期,已停止」,用户无法得知数据仍在本地、也不知道 如何补救。要求:
- outbox 非空时,状态文案追加待补传条数,例如 「离线执行授权已到期,自动化已安全停止;有 N 条结果待补传」。
- 补报成功且 outbox 清空后,文案更新为已回传完成。
- 文案只显示条数,不得显示商品标题、店铺名、goods_id、链接或价格。
五、幂等与既有语义不变
- 补报复用既有 outbox 幂等 key 与
received_after_execution_expiry语义,不得重复 写入或伪装成授权期内到达。 reconciliationPending分支、订单对账路径、OrderSubmissionStatus.RECONCILED早退、session 失效分支的现有行为一律不变。- 不改
ExecutionSyncDecision既有取值的语义。
方案
ProcurementRepository.synchronize:在safetyStopped早退分支返回STOP前, 若 session 有效则调用一次flushExecutionOutbox,随后仍返回STOP。- outbox 待传条数暴露为只读状态,供 UI 展示。
- 执行页文案追加待补传条数,补报成功后更新。
- 单测覆盖:安全停止且 outbox 非空时会尝试补报并最终返回
STOP;补报成功后 outbox 清空;session 无效时跳过补报直接STOP;补报路径不调用任何拼多多驱动;reconciliationPending与订单对账分支行为不变;补报幂等不重复提交。
验收要点
- 安全停止且 outbox 非空时,
synchronize会尝试一次补报并返回STOP。 - 补报成功后 outbox 清空,再次调用不重复提交。
- session 无效时跳过补报直接
STOP,行为与本任务前一致。 - 有单测证明补报路径不调用任何拼多多驱动,不触发下单 dry-run 或订单提交。
reconciliationPending、RECONCILED早退和 session 失效分支行为不变,有回归测试。- 执行页在 outbox 非空时显示待补传条数,文案不含标题、店铺名、goods_id、链接或价格。
- Android Debug/Release 单测、
lintDebug、assembleDebug、assembleRelease全部通过。 - 真机验证:安装新 APK 后打开 Roubao,execution
9c634321-7f77-49d9-9444-3ccd2104c104的滞留候选自动补传成功,任务从RUNNING收敛到WAITING_CONFIRMATION,候选goodsId=970618634860出现在 Admin 候选确认区,执行事件标注为授权到期后补报。
边界
- 不放松任何安全停止语义:拼多多 UI 自动化、下单、支付在授权到期后一律不得恢复。
- 不续租、不重新领取任务、不延长执行授权、不新增第四轮搜索。
- 不改
ExecutionSyncDecision既有取值语义,不让safetyStopped返回CONTINUE。 - 不改订单对账、授权价格策略、
OrderSubmissionStatus流转或支付边界。 - 不改 T-254/T-255/T-257 的诊断字段、T-256 的候选提交门槛、T-258 的后端校验。
- 不改后端代码、数据库、API 契约或 Admin 模板。
- 不实现后台无限重试或定时补报——本任务只保证每次 App 冷启动有一次补报机会。
- 不改候选识别、签名算法、跨轮去重或价格解析。
执行记录
开工后记录修改、命令、结果、环境、决策和 blocker。
2026-07-31
- 状态:开工时由
TODO改为DOING;按任务要求和真机 blocker 未改为DONE。 - 修改范围:仅修改本任务
write_paths内的 Android 主代码、Android 单测、docs/current-state.md和本任务文件;未修改backend-api,未运行init.ps1, 未执行 git commit 或 push,未触碰工作区既有无关未跟踪文件。 - 实现:
ProcurementRepository.synchronizeRunning()在普通safetyStopped早退中先验证 既有 session;有效时只调用一轮既有flushExecutionOutbox,成功、清空或失败后均 返回ExecutionSyncDecision.STOP。session 缺失/失效仍直接STOP。reconciliationPending继续绕过普通安全停止早退并保留订单对账CONTINUE;OrderSubmissionStatus.RECONCILED仍在 session/outbox 之前早退;未修改ExecutionSyncDecision、heartbeat、租约、claim 或授权语义。- 冷启动恢复条件只增加“安全停止且仍有实际待上传 outbox 项”;补传清空后不再启动 服务,失败时仅在下一次 App 冷启动再尝试。已上传且仅用于本地引用替换的 evidence 记录不计入待补传数量。
- 将服务单次循环边界提取为可注入协调器的
ProcurementExecutionCycle;同步返回STOP后立即退出,单测中的拼多多 dry-run 与订单提交假驱动调用数均为 0。 ProcurementUiState暴露只读pendingResultCount;授权到期页面仅显示“有 N 条结果 待补传”或“结果已回传完成”,未加入商品标题、店铺、goods_id、链接或价格。- 为 JVM repository 单测增加仅测试可用的 files directory 构造入口;生产构造与文件 位置保持不变。同步失败日志继续使用原诊断内容,并兼容 JVM Android stub。
- 单测覆盖:
- 安全停止且 outbox 非空时上传、清空、返回
STOP,第二次调用不重复上传。 - 上传失败只尝试一次并返回
STOP;session 失效跳过上传。 RECONCILED早退跳过上传;reconciliationPending保持CONTINUE且不发 heartbeat。- 服务收到
STOP后 dry-run/订单提交协调器均为零调用;状态文案只含待补传数量。
- 安全停止且 outbox 非空时上传、清空、返回
- 命令与结果(Windows PowerShell):
- 在
android-buyer运行.\gradlew.bat :app:testDebugUnitTest --tests "com.roubao.autopilot.procurement.ProcurementSafetyStoppedSyncTest" --no-daemon: 退出码 0,最初 5 项定向单测通过。 - 增加失败补传测试后运行
.\gradlew.bat :app:testDebugUnitTest --tests "com.roubao.autopilot.procurement.ProcurementSafetyStoppedSyncTest" --no-daemon --console=plain: 首次退出码 1,6 项中 1 项因 JVM 的android.util.Log.wstub 未实现而失败;未涉及 业务断言。日志调用增加 JVM 安全包装后以完全相同命令重跑,退出码 0,6 项全部通过。 - 在
android-buyer运行.\gradlew.bat testDebugUnitTest testReleaseUnitTest lintDebug assembleDebug assembleRelease --no-daemon --console=plain: 退出码 0;五项全部通过。Debug/Release 各 262 项单测、0 failure、0 error;lintDebug成功;Debug APK 与 Release unsigned APK 均生成。输出只有既有 Android SDK XML 版本兼容 warning。 - 在仓库根目录运行
git diff --check:退出码 0,无 whitespace error。 - 在仓库根目录运行
$paths = @('docs/tasks/T-259.md','docs/current-state.md','android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementExecutionService.kt','android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementModels.kt','android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt','android-buyer/app/src/main/java/com/roubao/autopilot/ui/screens/ProcurementScreen.kt','android-buyer/app/src/test/java/com/roubao/autopilot/procurement/ProcurementSafetyStoppedSyncTest.kt'); $crlf = @($paths | Where-Object { $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $_)); for ($i = 0; $i -lt $bytes.Length - 1; $i++) { if ($bytes[$i] -eq 13 -and $bytes[$i + 1] -eq 10) { return $true } }; return $false }); if ($crlf.Count -gt 0) { $crlf; exit 1 } else { 'LF_ONLY' }: 退出码 0,输出LF_ONLY。 - 最终范围审计运行
git diff --name-only、git diff --name-only -- backend-api和git status --short:任务修改仅位于声明的write_paths,backend-api无 tracked diff;既有无关未跟踪文件保持未改动。
- 在
- 环境:Windows 工作区
D:\chengma\cmroubao,JDK 17 / Android SDK 34;所有 Gradle 命令均在android-buyer执行。 - 未验证 / blocker:无法执行最终真机验收,因此尚未确认安装 APK 后 execution
9c634321-7f77-49d9-9444-3ccd2104c104的滞留候选补传、任务收敛到WAITING_CONFIRMATION、Admin 显示候选及事件的授权到期后补报标记。必须由采购人员 在已授权测试设备完成;本任务保持DOING。