feat: 保存设备心跳与在线状态变化历史,Agent 记录每轮同步的步骤耗时并在心跳中补报 #378

Open
opened 2026-10-09 18:02:22 +08:00 by ila · 6 comments
Owner

基本信息

  • 类型:诊断能力(服务端保存设备心跳和在线状态变化历史;Agent 记录每轮同步的步骤耗时和失败原因,并在心跳中补报)。

  • 风险等级:高(新增数据库表和迁移,修改 Agent 心跳接口)。2026-10-10 用户已授权合并 #377、补齐本单边界并实施、本地迁移验证、提交和推送;用户随后明确授权「合并main」,已执行;仍不包含线上迁移、发布或手机安装。

  • 状态:正文 v4(2026-10-10):Server 与 Android 已实现并完成本地验证,已合并并推送 main(db64a49),待验收;未发布或装机。用户确认「把这些修改并入 #377 和 #378 的正文」,已并入 Codex 审核意见(SynapBus #139/#140 第 1~5 点)和评论 9110。实施顺序:先 #377,后本单。

  • 来源:2026-10-09 用户先后提出:

    • 「最近半个小时,采购1设备发给服务器的心跳信息正常吗」——服务端只保存最近一次心跳,无法回答;
    • 「你觉得要保存每个终端设备一定时间内的心跳信息呢」;
    • 「现在是记录了采购1设备离线的记录吗,是最后一次还是多少次呢」——服务端没有离线记录;
    • 两台设备卡住(#377)后问「关于这个问题的修复有建工单吗」。

    Claude Code 建议把心跳历史和同步诊断合并为一张单。用户确认:「1和2都做,然后通知codex审核」(本单为第 2 项)。

  • 关联:#377(卡住后的自行恢复;本单负责找出触发原因)。

已核验事实

现场(2026-10-09,只读)

  • 两次设备卡住,都是在一次很长的规格探测结束时,心跳和结果上传同时中断约 50 秒,随后被服务端判为离线:
    • 采购1 / 2222(0.9.64):探测 148 秒,最后心跳 17:24:36,结果写入本地 17:24:50,判离线 17:25:29;
    • 采购2 / 2246(0.9.69):探测 156 秒,最后心跳 17:50:49,结果写入本地 17:50:54,判离线 17:51:44。
    • 平时规格探测只要 25~35 秒。
  • 同一时间服务端在正常处理其他设备的请求。数据库行锁的历史最长等待是 15 秒(Innodb_row_lock_time_max)。两台手机在不同网段。
  • 两台手机当时的 logcat 都已被覆盖:采购2 只保留到 17:54:51 之后,Agent 也没有写网络错误日志。服务端日志级别为 info,不记录每个请求(config/settings.yml 的 logger.level)。中断原因无法查明。
  • 采集任务之间的间隔期,设置页会闪现「未连接」,采购员以为断开了;当时也无法用数据证明有没有真的断开。

代码事实(origin/main 3ee8dfc0aa93ef9020786244cce29c487631ddbe)

  • 服务端 device.Service.Heartbeat(server/app/goauto/device/heartbeat.go)只更新 agent_device 的 status、last_heartbeat_at、last_heartbeat_request_id、capabilities_json,不保留历史。同一 requestId 重放时不会再次更新。
  • 离线扫描 MarkStaleDevicesOffline(:120):每 15 秒扫描一次(DefaultOfflineScan),超过 45 秒没有心跳(DefaultOfflineThreshold)就把设备改为 offline,并让执行中的任务失败。不记录事件,调用方也丢弃了「改了几台」的返回值(:230)。下次心跳到达时设备直接恢复 online,同样不记录。
  • 心跳请求只有 requestId、currentTaskId、capabilities 三个字段(HeartbeatRequest),契约见 docs/08「Android:注册与心跳」。
  • 服务端解析 Agent 请求时调用 DisallowUnknownFields()(device/handler.go:211-214):旧版服务端收到未知字段会拒绝整个心跳,因此新增字段必须先协商。
  • Agent 的同步任务和定时触发都跑在同一个单线程调度器上(executor,scheduleWithFixedDelay(::triggerSync))。只在一轮结束时记录耗时,看不到「调度迟迟没执行」「卡在某一步」「进程被杀」。
  • Android 上连接超时和读超时都抛 SocketTimeoutException,仅凭异常类名区分不了。
  • Agent AgentForegroundService.synchronizeAgent:每 15 秒左右执行一轮(scheduleWithFixedDelay),没有记录每一步的耗时和失败原因;出错时只把状态写入 SharedPreferences,而且会被下一轮覆盖。
  • Agent 已有本地诊断库 goauto_diagnostics.db(AgentDiagnosticStore):最多保留 50 条、7 天(MAX_RECORDS、RETENTION_MILLIS),目前用于采集诊断。

目标

  1. 能事后回答:「设备 X 在某段时间内的心跳是否正常,离线了几次、每次多久」。
  2. 能事后回答:「心跳没送到服务端时,Agent 卡在哪一步、等了多久、出的是什么错」。
  3. 不保存页面内容、个人信息、Token 或请求正文。

非目标

  • 不修复 #377 的「卡住后不能自行恢复」,也不改变同步顺序和重试策略(由 #377 负责)。
  • 不改离线阈值、扫描间隔和心跳间隔。
  • 本期不做 Admin 页面,先通过只读 SQL 查询;以后用得多再加。
  • 不把服务端日志级别调为 debug。
  • 不上传 logcat,不保存截图或控件树。

方案

1. 服务端:心跳历史(新表,保留 7 天)

  • 新表 agent_heartbeat_log,字段:
    • id、device_id、received_at(服务端时间)、request_id;
    • current_task_id(设备上报的值)、running_task_id(服务端认定的值)、busy;
    • agent_version;
    • client_report_json:校验通过后的报告(见方案 3),可以为空;
    • client_report_status:none、accepted 或 client_report_invalid。
  • 索引:(device_id, received_at),以及用于清理的 received_at。
  • 写入时机:心跳成功、并且不是重放时,在心跳事务提交后、返回响应前同步写入一条。使用独立 context,超时 200 ms;超时或失败就放弃,只记一条 warn 日志,不影响心跳结果。不启动后台 goroutine,也不排队。
  • 清理:沿用现有定时任务机制,每小时删除 7 天前的记录。每次最多删 5000 条,按 received_at 索引分批删除,删不完下次继续;不在心跳路径上清理。
  • 数据量:每台设备每天约 5800 条;3 台设备 7 天约 12 万条。

2. 服务端:在线状态变化记录(新表,保留 30 天)

  • 新表 agent_device_status_event,字段:id、device_id、occurred_at、from_status、to_status、reason、last_heartbeat_at、failed_task_count、order_result_unknown_count。
    • reason 取值:heartbeat_timeout(离线扫描)、heartbeat_resumed(离线后收到心跳)、disabled、enabled。
    • failed_task_count:离线扫描时一并判为失败的采集和采购任务数。
    • order_result_unknown_count:离线扫描时从 order_submit_started 转为 order_result_unknown 的采购任务数。这两个数分别对应 markOfflinePurchaseTasks 的两个分支。
  • 只在状态真正变化时写入。离线扫描改为每台设备独立事务,在该设备状态、相关任务及 attempt 变化的同一事务内写一条;心跳把 offline 改回 online 时写一条。状态没有变化就不写,避免重复记录。
  • 取舍:事件和状态放在同一事务里,可以保证一致;代价是事件表写入失败时,离线处理或恢复在线也会一起回滚。为了降低风险:
    • 事件写入只有一条 INSERT,不做清理,不附带查询,不在持锁期间做慢操作;
    • 补充故障测试:模拟事件写入失败,确认整个事务回滚、下一轮扫描或心跳能重新处理。
  • 设备停用和启用如果已有记录点,一并写入;没有记录点就不扩大范围。
  • 离线扫描在服务日志中记一行 info:设备 ID、两个计数。
  • 清理:每天删除 30 天前的记录,按 occurred_at 索引分批进行,每批最多 5000 条。

2.1 v4:离线扫描并发修正(2026-10-10 用户确认)

依据 Claude 评论 9140 / SynapBus #145 及 Codex 代码复核,用户确认按最小范围补入本单并修正。实现基线为本地 Server 提交 010e9b9,这不是线上已发生故障的结论。

  • 问题:整轮扫描共用事务时,任务 NOWAIT 争用会回滚其他设备的处理;等待后续设备锁期间还会持有前面设备的锁。采购任务再次使用普通快照查询,在 MySQL RR 下存在读到旧任务状态的风险。
  • 候选设备 ID 仍在事务外读取,每台设备单独事务。设备主键行及相关任务均使用 FOR UPDATE NOWAIT,锁定设备后重新检查其在线状态及心跳是否超时。
  • 获取相关任务的 ID 和状态采用锁定当前读;后续任务及 attempt 更新、失败/未知计数直接使用这组已锁定数据,不再次依赖普通快照读重选任务。保持设备→任务→attempt 的本路径顺序,不重构其他业务。
  • 精确识别 MySQL ER_LOCK_NOWAIT(3572):只回滚并跳过当前设备,释放其锁,继续处理本轮其他设备;原 15 秒扫描下轮再试,不在本轮循环重试。不把预期争用包装成系统错误,warn 仅含固定分类及 deviceId。
  • 其他数据库错误和取消信号不吞掉:当前设备事务回滚并返回错误;此前已提交设备不回滚,返回值/日志只反映实际已提交数量。事件仅在本设备提交成功后记录日志。
  • 每台设备的状态、任务、attempt 和状态事件保持原子性;任务锁争用时绝不能只把设备标离线。45 秒离线阈值、15 秒扫描间隔、正常心跳及管理员重置业务规则保持不变。
  • 核验实际锁定 SQL 的 EXPLAIN 及索引;如存在扫描历史任务带来的保守争用,明确记录证据,不在无证据时新增索引或追加迁移。检查 attempt 的现有加锁路径,注释只描述已核验前提,不把未强制的规则当作全系统保证。
  • 不扩大到 Android、全系统锁顺序重构、新 UI、生产 SQL 或线上发布。本轮该修正完成不代表本单 Android 剩余范围完成。

3. Agent:每轮同步的诊断记录,并在下次心跳中补报

  • 每一轮同步一开始就写一条记录,状态为「进行中」;每开始和结束一个步骤就更新一次;整轮结束时标记完成或失败。这样即使卡在某一步或进程退出,也能看出停在哪里(记录一直停留在「进行中」)。步骤包括:恢复中断任务、补传结果、心跳、领任务。
  • 每条记录包括:
    • 本轮开始时间(墙钟时间,用于和服务端对照);
    • 与上一轮实际开始时刻的间隔,以及每一步的开始和结束,都用 SystemClock.elapsedRealtime() 计算,这样能看出调度是否被推迟;
    • 结果:成功,或失败的步骤及错误分类。分类取值:AgentApiException.code 加 HTTP 状态;网络超时按请求阶段分为 connect、response(已发出、等待响应)和 unknown(无法区分时),不根据异常类名推断;其他网络异常只记异常类名;
    • 当前任务的 taskId、attemptId、phase(如果有);
    • 网络状态:是否有已验证网络,Wi-Fi 还是蜂窝;Agent 是否在前台;
    • 不记录异常消息、URL、请求或响应正文,不记录 PDD 页面内容。
  • 保存位置:goauto_diagnostics.db v6 追加同步记录表 sync_round,并用单行 sync_meta 保存跨清理/重启的递增序号;保留最近 24 小时或 6000 轮(取较少者),不占用现有采集诊断的 50 条额度,保留既有采集和采购失败快照数据。
  • 心跳补报(先协商):
    • 服务端在心跳响应中新增 acceptsClientReport: true。旧版服务端不会返回这个字段。
    • Agent 只有在上一次心跳响应带了这个标志时,才在下一次心跳中附带可选字段 clientReport。没有这个标志就不发,因此不会被旧版服务端拒绝。进程重启后,要等到第一次收到带标志的响应后才开始补报。
    • clientReport 内容(字段白名单,每个数值和字符串都有上限):
      • snapshotSeq(快照序号);
      • 自上次补报成功以来的失败轮数;
      • 最近一次失败的时间、步骤、错误分类、耗时;
      • 上一轮同步的总耗时;
      • 是否存在仍处于「进行中」的旧记录。
    • 服务端只校验,不参与在线判断。格式不对、字段不在白名单或超出上限时,丢弃这份报告,client_report_status 记为 client_report_invalid,心跳照常成功;心跳主请求本身不合法时,仍按原规则处理。按字段截断,不按字节截断 JSON。
    • 清零:收到成功响应后,Agent 只清除 snapshotSeq 及之前的失败计数,发送期间新增的失败保留到下一次。响应丢失或进程重启时,允许重复上报,但不能漏报。整个补报是尽力而为的。

3.1 v3 补充边界(2026-10-10 用户确认)

  • 补报能力不是永久缓存:成功响应缺少 acceptsClientReport 或为 false 时撤销;进程重启、服务地址或设备认证上下文改变时清空,重新协商。
  • 运行中服务端降级:已携带报告的心跳收到旧服务端明确的请求协议校验拒绝时,撤销能力,移除 clientReport,沿用原 requestId 最多补发一次基础心跳。核对实际旧版错误码后限定识别条件;401/403、设备认证失败、任务冲突、网络超时、5xx 不触发兼容补发,不能循环重试。
  • 回退心跳未携带报告,即使成功也不能确认或清除该 snapshotSeq;保留未确认诊断,后续重新协商成功后再尽力补报。
  • Android 本地诊断的打开、开始/步骤/结束写入、报告读取/确认、过期清理发生可恢复存储异常时,只损失诊断证据,不阻断 #377 的心跳、恢复、补传或领取流程,不伪造业务同步失败;不得吞掉业务异常或取消信号。
  • 诊断故障只记录固定分类,不写异常 message、SQL 参数、页面或个人信息;持久化失败不伪造成功。确认失败允许重复上报。
  • 在现有 goauto_diagnostics.db 版本上追加升级,保留既有采集及采购诊断表和数据;新同步表独立执行24小时/6000轮保留限制。此边界不改变服务端状态事件写入失败需整体回滚的 v2 取舍。

4. 查询方法(写入 Troubleshooting)

  • 离线次数和时长:以 agent_device_status_event 中成对的 heartbeat_timeout / heartbeat_resumed 为准。缺少配对的事件(例如只有离线没有恢复)要单独列出,不能当作完整的一次离线。
  • 心跳记录缺口:在 agent_heartbeat_log 中按 received_at 计算相邻两条的间隔。间隔超过 45 秒只说明记录有缺口,可能是真的离线,也可能是历史写入被放弃,需要结合状态事件判断。
  • 某次中断时设备在做什么:查中断前后的 client_report_json,以及手机本地诊断表里停在「进行中」的记录。

设计证据

非 UI 改动(本期不做页面),无需原型;以本正文为准。接口变化须同步更新 docs/08。

子项目影响、依赖与并行

  • Server:
    • device/heartbeat.go:写心跳历史,处理 clientReport;
    • 离线扫描:写状态变化记录,记日志;
    • 新增迁移;
    • 清理定时任务;
    • 以及对应测试。
  • Android:
    • AgentForegroundService.kt:同步分步计时和分类;
    • AgentApiClient.heartbeat:读取 acceptsClientReport,按协商结果发送 clientReport;
    • AgentDiagnosticStore.kt:新表和迁移;
    • 以及对应测试。
  • Web:无。
  • 依赖:无硬性依赖。与 #377 都改了 synchronizeAgent,建议在 #377 合并后实施,在新的同步顺序上加计时,避免冲突和重复返工。

验收标准

  • 服务端测试:
    • 心跳成功写入一条历史;
    • 重放不重复写入;
    • 历史写入失败或超过独立 200 ms context 预算时,合法心跳仍成功;计时允许数据库取消/线程调度开销,不承诺严格 200 ms 墙钟硬上限;
    • clientReport 格式非法、字段不在白名单或超出上限 → 报告被丢弃,记为 client_report_invalid,心跳成功;缺失时正常处理;
    • 心跳响应带 acceptsClientReport: true。
  • 服务端测试:
    • 离线扫描为每台被判离线的设备写一条 heartbeat_timeout 事件,failed_task_count 正确;
    • 离线后收到心跳写一条 heartbeat_resumed;
    • 状态没有变化时不写事件;
    • failed_task_count 和 order_result_unknown_count 分别计数正确;
    • 模拟事件写入失败:离线处理或恢复整体回滚,下一轮扫描或心跳能重新处理。
  • 服务端测试:清理任务只删除过期记录,并按批次限制条数。
  • MySQL 8 隔离库追加迁移、重复 up 及测试清理后 re-up 通过;现有框架无生产 down,运行回退保留数据表,不把测试删表表述为生产回滚。
  • Android 测试:
    • 每一轮同步开始时就写入「进行中」记录,并逐步更新;模拟卡在某一步时,记录停在该步骤;
    • 启动间隔和步骤耗时使用单调时钟计算;
    • 连接阶段超时记为 connect,等待响应超时记为 response,无法区分时记为 unknown;409、5xx 分别归入对应类别;
    • 记录 taskId、attemptId、phase;不含异常消息和 URL;
    • 保留上限生效。
  • 兼容性协议矩阵测试(新旧 Agent × 新旧服务端,共四种组合;HTTP 合成/真实序列化与服务端 handler 测试,不代表四套真机部署):旧服务端不返回 acceptsClientReport,新 Agent 不发送 clientReport,心跳正常;新服务端配旧 Agent 时,心跳正常,client_report_status=none。
  • Android 测试:
    • 收到带标志的响应后,下一次心跳附带 clientReport;
    • 只清除 snapshotSeq 及之前的失败,发送期间新增的失败保留;
    • 响应丢失或进程重启后,允许重复上报,不会漏报。
  • 运行中服务端降级:true 协商后旧版拒绝报告,一次同 requestId 基础心跳回退;无报告不清除诊断;之后不重复发送;服务升级后可重新协商。认证/任务冲突/网络/5xx 不兼容重发。
  • 成功响应缺少能力或 false、服务地址/认证上下文改变、进程重启均撤销旧能力。
  • Android 诊断故障注入覆盖打开、每一步写入、读取、确认及清理,业务流程不受影响;原业务异常不被吞掉,既有库数据升级后保留。
  • 文档:docs/08 心跳契约(acceptsClientReport、clientReport),Architecture-and-Code-Map(两张新表),Troubleshooting(查询方法)。
  • 服务端和 Android 的构建与回归测试通过,如实记录。
  • 本轮实施、本地迁移验证、提交、推送及用户追加授权的 main 合并完成;未执行线上迁移、发布或装机。
  • 后续另行授权发布/装机后,以只读 SQL 抽查一台设备30分钟心跳及真机升级;本地验证不冒充线上/真机验收。

v4 追加回归

  • MySQL:采集任务锁、采购任务锁或设备行锁被占用时,只跳过该设备,其他超时设备同一轮正常处理;前面已经提交的设备不回滚,后面设备不被阻塞。
  • MySQL:扫描与心跳并发、与 Agent 认领并发,无 1213,无设备/任务/attempt/事件半完成或错误计数。
  • MySQL:第一台处理期间第二台任务才转 running 或 order_submit_started,第二台处理使用最新锁定任务及状态,不能漏掉或错分。
  • 非 3572 错误与取消不吞掉;事件失败只回滚当前设备,返回已提交计数真实,下一轮可重新处理。
  • 记录 EXPLAIN 结果、锁争用日志分类和受影响包回归;先失败后修正,不以 SQLite 测试代替真实 MySQL 并发验证。

验证边界:真实采集首次 Claim、真实采购 Claim 重放及心跳已覆盖;普通 pending 采购首次 Claim 未覆盖。心跳用例证明扫描期间发起后的恢复及事件结果,不宣称已观测数据库锁等待。EXPLAIN 来自隔离合成数据,非线上执行计划。完整证据见本轮回写。

风险与回退

  • 心跳路径多一次写入:放在事务提交之后,200 ms 超时,失败不影响在线判断,使用独立 200 ms context 限制额外等待;实际墙钟包含取消/调度开销。
  • 状态事件和状态在同一事务内:事件写入失败会让离线处理或恢复回滚,由下一轮重新处理(见方案 2 的取舍)。
  • 数据量:7 天约 12 万行,加 (device_id, received_at) 索引;清理分批进行。
  • 隐私:只存设备 ID、时间、任务 ID、版本、错误分类和耗时,不存 IP、Token 或请求正文。
  • 回退:服务端停止写入、不再返回 acceptsClientReport,Agent 随之停止补报;数据表保留。删表需另行确认,不作为常规回退方式。新旧版本的兼容性由协商机制保证。

文档影响

  • docs/08 Agent API 契约:心跳响应新增 acceptsClientReport,心跳请求新增可选字段 clientReport,写明协商规则和非法报告的处理方式。
  • Wiki Architecture-and-Code-Map(新表与清理任务)、Troubleshooting(查询心跳与离线的方法)、Deployment-and-Operations(迁移与清理任务)。
  • 先改线上 Wiki 并回读 revision,再执行一次 sync 与 sync --check。

备注

本单由 Claude Code 依据线上只读查询、手机只读检查和代码阅读起草;不含账号、地址、Cookie 或 Token。

本轮实现与交付(2026-10-10)

  • 已补齐 Android:sync_round + 单行 sync_meta、v5→v6 追加升级、独立24小时/6000轮清理、单调时钟步骤耗时、网络阶段分类、前台服务级能力协商、持久快照完成边界确认、旧版422精确兼容回退及诊断存储故障隔离。
  • 恢复流程内部已处理的500/409同步错误也记入诊断;不改变原恢复、占位、补传和领任务行为。取消/fatal不吞掉。
  • Android 实现 a28b869、内部已处理恢复错误修正 dbe7e98;跨端测试 9f489fd;Wiki 镜像 b79e281。Server 并发修正仍为 d84728d。
  • 规格审查与代码质量审查均通过,已修复审查指出的「恢复函数内部吞错导致诊断假成功」;主代理独立复测。
  • main 合并提交 db64a49e69e6cd8bd42015751203d3d8be4d9baa 已推送并在线核对;第一父提交为原 main 575d5f3,第二父为工单分支 b79e281。保留 main 的 #372 内容。仅3个Wiki镜像冲突,恢复为本轮已在线回读并同步的权威版本,未改写镜像事实。
  • 本地 D:/OPC/goauto 与 main 所在 release-316 均有原有未提交内容,未切换、覆盖或重置;本次在工单工作区的集成分支合并后推送远端 main。
  • 工单工作区 D:/OPC/goauto-worktrees/issue-378 暂保留:线上发布尚未授权,含本地构建APK/测试产物,待后续发布和产物去向确认后按仓库规则清理;不删分支历史。
  • 保持工单开放待验收。没有执行线上迁移、发布、重启、手机安装、真实下单或付款。
## 基本信息 - 类型:诊断能力(服务端保存设备心跳和在线状态变化历史;Agent 记录每轮同步的步骤耗时和失败原因,并在心跳中补报)。 - 风险等级:**高**(新增数据库表和迁移,修改 Agent 心跳接口)。2026-10-10 用户已授权合并 #377、补齐本单边界并实施、本地迁移验证、提交和推送;用户随后明确授权「合并main」,已执行;仍不包含线上迁移、发布或手机安装。 - 状态:**正文 v4(2026-10-10):Server 与 Android 已实现并完成本地验证,已合并并推送 main(db64a49),待验收;未发布或装机**。用户确认「把这些修改并入 #377 和 #378 的正文」,已并入 Codex 审核意见(SynapBus #139/#140 第 1~5 点)和评论 9110。实施顺序:先 #377,后本单。 - 来源:2026-10-09 用户先后提出: - 「最近半个小时,采购1设备发给服务器的心跳信息正常吗」——服务端只保存最近一次心跳,无法回答; - 「你觉得要保存每个终端设备一定时间内的心跳信息呢」; - 「现在是记录了采购1设备离线的记录吗,是最后一次还是多少次呢」——服务端没有离线记录; - 两台设备卡住(#377)后问「关于这个问题的修复有建工单吗」。 Claude Code 建议把心跳历史和同步诊断合并为一张单。用户确认:「1和2都做,然后通知codex审核」(本单为第 2 项)。 - 关联:#377(卡住后的自行恢复;本单负责找出触发原因)。 ## 已核验事实 ### 现场(2026-10-09,只读) - 两次设备卡住,都是在一次很长的规格探测结束时,心跳和结果上传同时中断约 50 秒,随后被服务端判为离线: - 采购1 / 2222(0.9.64):探测 148 秒,最后心跳 17:24:36,结果写入本地 17:24:50,判离线 17:25:29; - 采购2 / 2246(0.9.69):探测 156 秒,最后心跳 17:50:49,结果写入本地 17:50:54,判离线 17:51:44。 - 平时规格探测只要 25~35 秒。 - 同一时间服务端在正常处理其他设备的请求。数据库行锁的历史最长等待是 15 秒(`Innodb_row_lock_time_max`)。两台手机在不同网段。 - 两台手机当时的 logcat 都已被覆盖:采购2 只保留到 17:54:51 之后,Agent 也没有写网络错误日志。服务端日志级别为 info,不记录每个请求(`config/settings.yml` 的 `logger.level`)。**中断原因无法查明。** - 采集任务之间的间隔期,设置页会闪现「未连接」,采购员以为断开了;当时也无法用数据证明有没有真的断开。 ### 代码事实(`origin/main` `3ee8dfc0aa93ef9020786244cce29c487631ddbe`) - 服务端 `device.Service.Heartbeat`(`server/app/goauto/device/heartbeat.go`)只更新 `agent_device` 的 `status`、`last_heartbeat_at`、`last_heartbeat_request_id`、`capabilities_json`,不保留历史。同一 `requestId` 重放时不会再次更新。 - 离线扫描 `MarkStaleDevicesOffline`(`:120`):每 15 秒扫描一次(`DefaultOfflineScan`),超过 45 秒没有心跳(`DefaultOfflineThreshold`)就把设备改为 `offline`,并让执行中的任务失败。不记录事件,调用方也丢弃了「改了几台」的返回值(`:230`)。下次心跳到达时设备直接恢复 `online`,同样不记录。 - 心跳请求只有 `requestId`、`currentTaskId`、`capabilities` 三个字段(`HeartbeatRequest`),契约见 docs/08「Android:注册与心跳」。 - 服务端解析 Agent 请求时调用 `DisallowUnknownFields()`(`device/handler.go:211-214`):**旧版服务端收到未知字段会拒绝整个心跳**,因此新增字段必须先协商。 - Agent 的同步任务和定时触发都跑在同一个单线程调度器上(`executor`,`scheduleWithFixedDelay(::triggerSync)`)。只在一轮结束时记录耗时,看不到「调度迟迟没执行」「卡在某一步」「进程被杀」。 - Android 上连接超时和读超时都抛 `SocketTimeoutException`,仅凭异常类名区分不了。 - Agent `AgentForegroundService.synchronizeAgent`:每 15 秒左右执行一轮(`scheduleWithFixedDelay`),没有记录每一步的耗时和失败原因;出错时只把状态写入 SharedPreferences,而且会被下一轮覆盖。 - Agent 已有本地诊断库 `goauto_diagnostics.db`(`AgentDiagnosticStore`):最多保留 50 条、7 天(`MAX_RECORDS`、`RETENTION_MILLIS`),目前用于采集诊断。 ## 目标 1. 能事后回答:「设备 X 在某段时间内的心跳是否正常,离线了几次、每次多久」。 2. 能事后回答:「心跳没送到服务端时,Agent 卡在哪一步、等了多久、出的是什么错」。 3. 不保存页面内容、个人信息、Token 或请求正文。 ## 非目标 - 不修复 #377 的「卡住后不能自行恢复」,也不改变同步顺序和重试策略(由 #377 负责)。 - 不改离线阈值、扫描间隔和心跳间隔。 - 本期不做 Admin 页面,先通过只读 SQL 查询;以后用得多再加。 - 不把服务端日志级别调为 debug。 - 不上传 logcat,不保存截图或控件树。 ## 方案 ### 1. 服务端:心跳历史(新表,保留 7 天) - 新表 `agent_heartbeat_log`,字段: - `id`、`device_id`、`received_at`(服务端时间)、`request_id`; - `current_task_id`(设备上报的值)、`running_task_id`(服务端认定的值)、`busy`; - `agent_version`; - `client_report_json`:校验通过后的报告(见方案 3),可以为空; - `client_report_status`:`none`、`accepted` 或 `client_report_invalid`。 - 索引:`(device_id, received_at)`,以及用于清理的 `received_at`。 - 写入时机:心跳成功、并且不是重放时,**在心跳事务提交后、返回响应前**同步写入一条。使用独立 context,**超时 200 ms**;超时或失败就放弃,只记一条 warn 日志,不影响心跳结果。不启动后台 goroutine,也不排队。 - 清理:沿用现有定时任务机制,每小时删除 7 天前的记录。每次最多删 5000 条,按 `received_at` 索引分批删除,删不完下次继续;不在心跳路径上清理。 - 数据量:每台设备每天约 5800 条;3 台设备 7 天约 12 万条。 ### 2. 服务端:在线状态变化记录(新表,保留 30 天) - 新表 `agent_device_status_event`,字段:`id`、`device_id`、`occurred_at`、`from_status`、`to_status`、`reason`、`last_heartbeat_at`、`failed_task_count`、`order_result_unknown_count`。 - `reason` 取值:`heartbeat_timeout`(离线扫描)、`heartbeat_resumed`(离线后收到心跳)、`disabled`、`enabled`。 - `failed_task_count`:离线扫描时一并判为失败的采集和采购任务数。 - `order_result_unknown_count`:离线扫描时从 `order_submit_started` 转为 `order_result_unknown` 的采购任务数。这两个数分别对应 `markOfflinePurchaseTasks` 的两个分支。 - 只在状态**真正变化**时写入。离线扫描改为**每台设备独立事务**,在该设备状态、相关任务及 attempt 变化的同一事务内写一条;心跳把 `offline` 改回 `online` 时写一条。状态没有变化就不写,避免重复记录。 - 取舍:事件和状态放在同一事务里,可以保证一致;代价是事件表写入失败时,离线处理或恢复在线也会一起回滚。为了降低风险: - 事件写入只有一条 INSERT,不做清理,不附带查询,不在持锁期间做慢操作; - 补充故障测试:模拟事件写入失败,确认整个事务回滚、下一轮扫描或心跳能重新处理。 - 设备停用和启用如果已有记录点,一并写入;没有记录点就不扩大范围。 - 离线扫描在服务日志中记一行 info:设备 ID、两个计数。 - 清理:每天删除 30 天前的记录,按 `occurred_at` 索引分批进行,每批最多 5000 条。 ### 2.1 v4:离线扫描并发修正(2026-10-10 用户确认) 依据 Claude 评论 9140 / SynapBus #145 及 Codex 代码复核,用户确认按最小范围补入本单并修正。实现基线为本地 Server 提交 `010e9b9`,这不是线上已发生故障的结论。 - 问题:整轮扫描共用事务时,任务 NOWAIT 争用会回滚其他设备的处理;等待后续设备锁期间还会持有前面设备的锁。采购任务再次使用普通快照查询,在 MySQL RR 下存在读到旧任务状态的风险。 - 候选设备 ID 仍在事务外读取,**每台设备单独事务**。设备主键行及相关任务均使用 `FOR UPDATE NOWAIT`,锁定设备后重新检查其在线状态及心跳是否超时。 - 获取相关任务的 ID 和状态采用锁定当前读;后续任务及 attempt 更新、失败/未知计数直接使用这组已锁定数据,不再次依赖普通快照读重选任务。保持设备→任务→attempt 的本路径顺序,不重构其他业务。 - 精确识别 MySQL `ER_LOCK_NOWAIT(3572)`:只回滚并跳过当前设备,释放其锁,继续处理本轮其他设备;原 15 秒扫描下轮再试,不在本轮循环重试。不把预期争用包装成系统错误,warn 仅含固定分类及 deviceId。 - 其他数据库错误和取消信号不吞掉:当前设备事务回滚并返回错误;此前已提交设备不回滚,返回值/日志只反映实际已提交数量。事件仅在本设备提交成功后记录日志。 - 每台设备的状态、任务、attempt 和状态事件保持原子性;任务锁争用时绝不能只把设备标离线。45 秒离线阈值、15 秒扫描间隔、正常心跳及管理员重置业务规则保持不变。 - 核验实际锁定 SQL 的 EXPLAIN 及索引;如存在扫描历史任务带来的保守争用,明确记录证据,不在无证据时新增索引或追加迁移。检查 attempt 的现有加锁路径,注释只描述已核验前提,不把未强制的规则当作全系统保证。 - 不扩大到 Android、全系统锁顺序重构、新 UI、生产 SQL 或线上发布。本轮该修正完成不代表本单 Android 剩余范围完成。 ### 3. Agent:每轮同步的诊断记录,并在下次心跳中补报 - **每一轮同步一开始就写一条记录**,状态为「进行中」;每开始和结束一个步骤就更新一次;整轮结束时标记完成或失败。这样即使卡在某一步或进程退出,也能看出停在哪里(记录一直停留在「进行中」)。步骤包括:恢复中断任务、补传结果、心跳、领任务。 - 每条记录包括: - 本轮开始时间(墙钟时间,用于和服务端对照); - 与上一轮实际开始时刻的间隔,以及每一步的开始和结束,都用 `SystemClock.elapsedRealtime()` 计算,这样能看出调度是否被推迟; - 结果:成功,或失败的步骤及错误分类。分类取值:`AgentApiException.code` 加 HTTP 状态;网络超时按**请求阶段**分为 `connect`、`response`(已发出、等待响应)和 `unknown`(无法区分时),不根据异常类名推断;其他网络异常只记异常类名; - 当前任务的 `taskId`、`attemptId`、`phase`(如果有); - 网络状态:是否有已验证网络,Wi-Fi 还是蜂窝;Agent 是否在前台; - 不记录异常消息、URL、请求或响应正文,不记录 PDD 页面内容。 - 保存位置:`goauto_diagnostics.db` v6 追加同步记录表 `sync_round`,并用单行 `sync_meta` 保存跨清理/重启的递增序号;保留最近 24 小时或 6000 轮(取较少者),不占用现有采集诊断的 50 条额度,保留既有采集和采购失败快照数据。 - **心跳补报(先协商)**: - 服务端在心跳响应中新增 `acceptsClientReport: true`。旧版服务端不会返回这个字段。 - Agent 只有在**上一次心跳响应**带了这个标志时,才在下一次心跳中附带可选字段 `clientReport`。没有这个标志就不发,因此不会被旧版服务端拒绝。进程重启后,要等到第一次收到带标志的响应后才开始补报。 - `clientReport` 内容(字段白名单,每个数值和字符串都有上限): - `snapshotSeq`(快照序号); - 自上次补报成功以来的失败轮数; - 最近一次失败的时间、步骤、错误分类、耗时; - 上一轮同步的总耗时; - 是否存在仍处于「进行中」的旧记录。 - 服务端只校验,不参与在线判断。格式不对、字段不在白名单或超出上限时,**丢弃这份报告**,`client_report_status` 记为 `client_report_invalid`,心跳照常成功;心跳主请求本身不合法时,仍按原规则处理。按字段截断,不按字节截断 JSON。 - 清零:收到成功响应后,Agent 只清除 `snapshotSeq` 及之前的失败计数,发送期间新增的失败保留到下一次。响应丢失或进程重启时,允许重复上报,但不能漏报。整个补报是尽力而为的。 ### 3.1 v3 补充边界(2026-10-10 用户确认) - 补报能力不是永久缓存:成功响应缺少 `acceptsClientReport` 或为 false 时撤销;进程重启、服务地址或设备认证上下文改变时清空,重新协商。 - 运行中服务端降级:已携带报告的心跳收到旧服务端明确的请求协议校验拒绝时,撤销能力,移除 `clientReport`,沿用原 requestId 最多补发一次基础心跳。核对实际旧版错误码后限定识别条件;401/403、设备认证失败、任务冲突、网络超时、5xx 不触发兼容补发,不能循环重试。 - 回退心跳未携带报告,即使成功也不能确认或清除该 snapshotSeq;保留未确认诊断,后续重新协商成功后再尽力补报。 - Android 本地诊断的打开、开始/步骤/结束写入、报告读取/确认、过期清理发生可恢复存储异常时,只损失诊断证据,不阻断 #377 的心跳、恢复、补传或领取流程,不伪造业务同步失败;不得吞掉业务异常或取消信号。 - 诊断故障只记录固定分类,不写异常 message、SQL 参数、页面或个人信息;持久化失败不伪造成功。确认失败允许重复上报。 - 在现有 `goauto_diagnostics.db` 版本上追加升级,保留既有采集及采购诊断表和数据;新同步表独立执行24小时/6000轮保留限制。此边界不改变服务端状态事件写入失败需整体回滚的 v2 取舍。 ### 4. 查询方法(写入 Troubleshooting) - **离线次数和时长**:以 `agent_device_status_event` 中成对的 `heartbeat_timeout` / `heartbeat_resumed` 为准。缺少配对的事件(例如只有离线没有恢复)要单独列出,不能当作完整的一次离线。 - **心跳记录缺口**:在 `agent_heartbeat_log` 中按 `received_at` 计算相邻两条的间隔。间隔超过 45 秒**只说明记录有缺口**,可能是真的离线,也可能是历史写入被放弃,需要结合状态事件判断。 - **某次中断时设备在做什么**:查中断前后的 `client_report_json`,以及手机本地诊断表里停在「进行中」的记录。 ## 设计证据 非 UI 改动(本期不做页面),无需原型;以本正文为准。接口变化须同步更新 docs/08。 ## 子项目影响、依赖与并行 - Server: - `device/heartbeat.go`:写心跳历史,处理 `clientReport`; - 离线扫描:写状态变化记录,记日志; - 新增迁移; - 清理定时任务; - 以及对应测试。 - Android: - `AgentForegroundService.kt`:同步分步计时和分类; - `AgentApiClient.heartbeat`:读取 `acceptsClientReport`,按协商结果发送 `clientReport`; - `AgentDiagnosticStore.kt`:新表和迁移; - 以及对应测试。 - Web:无。 - 依赖:无硬性依赖。与 #377 都改了 `synchronizeAgent`,**建议在 #377 合并后实施**,在新的同步顺序上加计时,避免冲突和重复返工。 ## 验收标准 - [x] 服务端测试: - 心跳成功写入一条历史; - 重放不重复写入; - 历史写入失败或超过独立 200 ms context 预算时,合法心跳仍成功;计时允许数据库取消/线程调度开销,不承诺严格 200 ms 墙钟硬上限; - `clientReport` 格式非法、字段不在白名单或超出上限 → 报告被丢弃,记为 `client_report_invalid`,心跳成功;缺失时正常处理; - 心跳响应带 `acceptsClientReport: true`。 - [x] 服务端测试: - 离线扫描为每台被判离线的设备写一条 `heartbeat_timeout` 事件,`failed_task_count` 正确; - 离线后收到心跳写一条 `heartbeat_resumed`; - 状态没有变化时不写事件; - `failed_task_count` 和 `order_result_unknown_count` 分别计数正确; - 模拟事件写入失败:离线处理或恢复整体回滚,下一轮扫描或心跳能重新处理。 - [x] 服务端测试:清理任务只删除过期记录,并按批次限制条数。 - [x] MySQL 8 隔离库追加迁移、重复 up 及测试清理后 re-up 通过;现有框架无生产 down,运行回退保留数据表,不把测试删表表述为生产回滚。 - [x] Android 测试: - 每一轮同步开始时就写入「进行中」记录,并逐步更新;模拟卡在某一步时,记录停在该步骤; - 启动间隔和步骤耗时使用单调时钟计算; - 连接阶段超时记为 `connect`,等待响应超时记为 `response`,无法区分时记为 `unknown`;409、5xx 分别归入对应类别; - 记录 `taskId`、`attemptId`、`phase`;不含异常消息和 URL; - 保留上限生效。 - [x] 兼容性协议矩阵测试(新旧 Agent × 新旧服务端,共四种组合;HTTP 合成/真实序列化与服务端 handler 测试,不代表四套真机部署):旧服务端不返回 `acceptsClientReport`,新 Agent 不发送 `clientReport`,心跳正常;新服务端配旧 Agent 时,心跳正常,`client_report_status=none`。 - [x] Android 测试: - 收到带标志的响应后,下一次心跳附带 `clientReport`; - 只清除 `snapshotSeq` 及之前的失败,发送期间新增的失败保留; - 响应丢失或进程重启后,允许重复上报,不会漏报。 - [x] 运行中服务端降级:true 协商后旧版拒绝报告,一次同 requestId 基础心跳回退;无报告不清除诊断;之后不重复发送;服务升级后可重新协商。认证/任务冲突/网络/5xx 不兼容重发。 - [x] 成功响应缺少能力或 false、服务地址/认证上下文改变、进程重启均撤销旧能力。 - [x] Android 诊断故障注入覆盖打开、每一步写入、读取、确认及清理,业务流程不受影响;原业务异常不被吞掉,既有库数据升级后保留。 - [x] 文档:docs/08 心跳契约(`acceptsClientReport`、`clientReport`),Architecture-and-Code-Map(两张新表),Troubleshooting(查询方法)。 - [x] 服务端和 Android 的构建与回归测试通过,如实记录。 - [x] 本轮实施、本地迁移验证、提交、推送及用户追加授权的 main 合并完成;未执行线上迁移、发布或装机。 - [ ] 后续另行授权发布/装机后,以只读 SQL 抽查一台设备30分钟心跳及真机升级;本地验证不冒充线上/真机验收。 ### v4 追加回归 - [x] MySQL:采集任务锁、采购任务锁或设备行锁被占用时,只跳过该设备,其他超时设备同一轮正常处理;前面已经提交的设备不回滚,后面设备不被阻塞。 - [x] MySQL:扫描与心跳并发、与 Agent 认领并发,无 1213,无设备/任务/attempt/事件半完成或错误计数。 - [x] MySQL:第一台处理期间第二台任务才转 running 或 order_submit_started,第二台处理使用最新锁定任务及状态,不能漏掉或错分。 - [x] 非 3572 错误与取消不吞掉;事件失败只回滚当前设备,返回已提交计数真实,下一轮可重新处理。 - [x] 记录 EXPLAIN 结果、锁争用日志分类和受影响包回归;先失败后修正,不以 SQLite 测试代替真实 MySQL 并发验证。 验证边界:真实采集首次 Claim、真实采购 Claim 重放及心跳已覆盖;普通 pending 采购首次 Claim 未覆盖。心跳用例证明扫描期间发起后的恢复及事件结果,不宣称已观测数据库锁等待。EXPLAIN 来自隔离合成数据,非线上执行计划。完整证据见本轮回写。 ## 风险与回退 - 心跳路径多一次写入:放在事务提交之后,200 ms 超时,失败不影响在线判断,使用独立 200 ms context 限制额外等待;实际墙钟包含取消/调度开销。 - 状态事件和状态在同一事务内:事件写入失败会让离线处理或恢复回滚,由下一轮重新处理(见方案 2 的取舍)。 - 数据量:7 天约 12 万行,加 `(device_id, received_at)` 索引;清理分批进行。 - 隐私:只存设备 ID、时间、任务 ID、版本、错误分类和耗时,不存 IP、Token 或请求正文。 - 回退:服务端停止写入、不再返回 `acceptsClientReport`,Agent 随之停止补报;**数据表保留**。删表需另行确认,不作为常规回退方式。新旧版本的兼容性由协商机制保证。 ## 文档影响 - docs/08 Agent API 契约:心跳响应新增 `acceptsClientReport`,心跳请求新增可选字段 `clientReport`,写明协商规则和非法报告的处理方式。 - Wiki Architecture-and-Code-Map(新表与清理任务)、Troubleshooting(查询心跳与离线的方法)、Deployment-and-Operations(迁移与清理任务)。 - 先改线上 Wiki 并回读 revision,再执行一次 sync 与 sync --check。 ## 备注 本单由 Claude Code 依据线上只读查询、手机只读检查和代码阅读起草;不含账号、地址、Cookie 或 Token。 ## 本轮实现与交付(2026-10-10) - 已补齐 Android:sync_round + 单行 sync_meta、v5→v6 追加升级、独立24小时/6000轮清理、单调时钟步骤耗时、网络阶段分类、前台服务级能力协商、持久快照完成边界确认、旧版422精确兼容回退及诊断存储故障隔离。 - 恢复流程内部已处理的500/409同步错误也记入诊断;不改变原恢复、占位、补传和领任务行为。取消/fatal不吞掉。 - Android 实现 `a28b869`、内部已处理恢复错误修正 `dbe7e98`;跨端测试 `9f489fd`;Wiki 镜像 `b79e281`。Server 并发修正仍为 `d84728d`。 - 规格审查与代码质量审查均通过,已修复审查指出的「恢复函数内部吞错导致诊断假成功」;主代理独立复测。 - **main 合并提交 `db64a49e69e6cd8bd42015751203d3d8be4d9baa` 已推送并在线核对**;第一父提交为原 main `575d5f3`,第二父为工单分支 `b79e281`。保留 main 的 #372 内容。仅3个Wiki镜像冲突,恢复为本轮已在线回读并同步的权威版本,未改写镜像事实。 - 本地 `D:/OPC/goauto` 与 main 所在 `release-316` 均有原有未提交内容,未切换、覆盖或重置;本次在工单工作区的集成分支合并后推送远端 main。 - 工单工作区 `D:/OPC/goauto-worktrees/issue-378` 暂保留:线上发布尚未授权,含本地构建APK/测试产物,待后续发布和产物去向确认后按仓库规则清理;不删分支历史。 - 保持工单开放待验收。没有执行线上迁移、发布、重启、手机安装、真实下单或付款。
Author
Owner

2026-10-09 Claude Code 审核回复(SynapBus #139/#140 第 1~5 点)

基线 origin/main 3ee8dfc0aa93ef9020786244cce29c487631ddbe。以下为讨论意见,正文未修改;并入正文需用户确认。

结论:5 点都成立,正文需要修改

  1. 兼容性(必须改):已核实 device/handler.go:211-214 的 decodeJSON 调用了 DisallowUnknownFields()。新版 Agent 向旧版服务端发送 clientReport,整个心跳都会被拒绝,正文「旧版服务端兼容」的说法错误。
    • 最小方案:服务端在心跳响应中增加 acceptsClientReport: true。Agent 只有在上一次心跳响应带了这个标志时,才在下一次心跳中附带 clientReport。旧版服务端不会返回这个标志,所以新版 Agent 不会发送这个字段。
    • 验收覆盖新旧 Agent 与新旧服务端的四种组合。
  2. 诊断字段不能拖垮心跳(必须改):
    • clientReport 格式或字段有问题时,丢弃这份报告,并在历史中记一个固定分类(例如 client_report_invalid),心跳照常成功;主请求本身不合法时,仍按原规则处理。
    • 字段采用白名单,数值和字符串都设上限,按字段截断,不按字节截断 JSON。
    • 清零规则:Agent 发送时给报告打一个快照序号,收到成功响应后,只清除这个序号之前的失败;发送期间新增的失败保留。进程重启或响应丢失时,允许重复上报,但不能漏报。
  3. 写入不能拖慢心跳(必须改):
    • 心跳历史在心跳事务提交后同步写入,设 200 ms 超时(使用独立 context),超时或失败就放弃。不启动后台 goroutine。
    • 状态事件放在同一事务内,代价是事件表出错时心跳恢复或离线处理会一起回滚。为此规定事件写入只有一条 INSERT,不做清理,也不附带查询,并补充故障测试。
    • 只在状态真正变化时写事件。
    • failed_task_count 拆成两项:failed_task_count 和 order_result_unknown_count,分别对应离线扫描的两个分支。
  4. 记录同步停顿的证据(必须改):
    • 每一轮同步在开始时就写一条「进行中」记录,每完成一步更新一次,结束时标记完成。这样即使卡住或进程退出,也能看到停在哪一步。
    • 同时记录实际启动间隔(与上一轮相比,使用 elapsedRealtime),以及任务的 taskId、attemptId、phase。
    • 超时按请求阶段分类:连接阶段、等待响应阶段,无法区分时标记为 unknown,不靠异常类名推断。
    • 不记录 PDD 页面内容。
  5. 查询定义和回退(必须改):
    • 「两条心跳间隔超过 45 秒」只说明心跳记录有缺口,不等于发生了离线。离线次数和时长以 heartbeat_timeout / heartbeat_resumed 事件为准,缺少配对事件的要单独列出。
    • 为 received_at、occurred_at 加索引,写明分批清理的条数上限。
    • 回退时优先停止写入、保留数据表;删表需另行确认,不作为常规回退方式。

顺序

同意 Codex 的建议:先做 #377,再做 #378。

未验证项

本次只读了代码,没有运行测试,也没有装机。

## 2026-10-09 Claude Code 审核回复(SynapBus #139/#140 第 1~5 点) 基线 `origin/main` `3ee8dfc0aa93ef9020786244cce29c487631ddbe`。以下为讨论意见,正文未修改;并入正文需用户确认。 ### 结论:5 点都成立,正文需要修改 1. **兼容性(必须改)**:已核实 `device/handler.go:211-214` 的 `decodeJSON` 调用了 `DisallowUnknownFields()`。新版 Agent 向旧版服务端发送 `clientReport`,**整个心跳都会被拒绝**,正文「旧版服务端兼容」的说法错误。 - 最小方案:服务端在心跳响应中增加 `acceptsClientReport: true`。Agent 只有在**上一次心跳响应**带了这个标志时,才在下一次心跳中附带 `clientReport`。旧版服务端不会返回这个标志,所以新版 Agent 不会发送这个字段。 - 验收覆盖新旧 Agent 与新旧服务端的四种组合。 2. **诊断字段不能拖垮心跳(必须改)**: - `clientReport` 格式或字段有问题时,丢弃这份报告,并在历史中记一个固定分类(例如 `client_report_invalid`),心跳照常成功;主请求本身不合法时,仍按原规则处理。 - 字段采用白名单,数值和字符串都设上限,按字段截断,不按字节截断 JSON。 - 清零规则:Agent 发送时给报告打一个快照序号,收到成功响应后,只清除这个序号之前的失败;发送期间新增的失败保留。进程重启或响应丢失时,允许重复上报,但不能漏报。 3. **写入不能拖慢心跳(必须改)**: - 心跳历史在心跳事务提交后同步写入,设 200 ms 超时(使用独立 context),超时或失败就放弃。不启动后台 goroutine。 - 状态事件放在同一事务内,代价是事件表出错时心跳恢复或离线处理会一起回滚。为此规定事件写入只有一条 INSERT,不做清理,也不附带查询,并补充故障测试。 - 只在状态真正变化时写事件。 - `failed_task_count` 拆成两项:`failed_task_count` 和 `order_result_unknown_count`,分别对应离线扫描的两个分支。 4. **记录同步停顿的证据(必须改)**: - 每一轮同步在**开始时**就写一条「进行中」记录,每完成一步更新一次,结束时标记完成。这样即使卡住或进程退出,也能看到停在哪一步。 - 同时记录实际启动间隔(与上一轮相比,使用 `elapsedRealtime`),以及任务的 `taskId`、`attemptId`、`phase`。 - 超时按请求阶段分类:连接阶段、等待响应阶段,无法区分时标记为 `unknown`,不靠异常类名推断。 - 不记录 PDD 页面内容。 5. **查询定义和回退(必须改)**: - 「两条心跳间隔超过 45 秒」只说明心跳记录有缺口,不等于发生了离线。离线次数和时长以 `heartbeat_timeout` / `heartbeat_resumed` 事件为准,缺少配对事件的要单独列出。 - 为 `received_at`、`occurred_at` 加索引,写明分批清理的条数上限。 - 回退时优先停止写入、保留数据表;删表需另行确认,不作为常规回退方式。 ### 顺序 同意 Codex 的建议:先做 #377,再做 #378。 ### 未验证项 本次只读了代码,没有运行测试,也没有装机。
Author
Owner

开始实施(2026-10-10,Codex)

用户已授权合并 #377、补齐 v3 边界并实施、本地迁移验证、提交和推送,暂不发布线上。#377 已快进合并并推送 main=4a669bca866e28b1454ba348fe0534ff6535f74d;本单 worktree D:/OPC/goauto-worktrees/issue-378,分支 feat/378-heartbeat-diagnostics。

执行顺序:服务端报告契约、历史与状态事件及追加迁移 → 服务端独立审核 → Android 本地库与分步诊断、协商降级与故障隔离 → Android独立审核 → 本地 MySQL8迁移/回归及跨端兼容、构建 → Wiki长期契约及镜像闭环 → 提交推送待验收。只在隔离本地测试数据上验证可回滚路径,不触及线上;不改变生产定时任务启停。

核验补充:旧服务端 decodeJSON 对未知字段统一返回 HTTP422/INVALID_REQUEST、固定消息“请求 JSON 无效”,并不是400。Android仅在确实携带报告且遇到该明确组合时允许一次相同requestId、不带报告的兼容补发;不持久化任意错误消息。服务端非法报告只丢弃报告、不拒绝基础心跳;非法主请求仍按原规则拒绝。

本机 config.yaml 数据库端点已核验为本机3308,实际MySQL8.4.3;不在工单记录连接密码。本轮没有授权或执行线上迁移、发布重启、手机安装及真实采购。

## 开始实施(2026-10-10,Codex) 用户已授权合并 #377、补齐 v3 边界并实施、本地迁移验证、提交和推送,暂不发布线上。#377 已快进合并并推送 main=4a669bca866e28b1454ba348fe0534ff6535f74d;本单 worktree D:/OPC/goauto-worktrees/issue-378,分支 feat/378-heartbeat-diagnostics。 执行顺序:服务端报告契约、历史与状态事件及追加迁移 → 服务端独立审核 → Android 本地库与分步诊断、协商降级与故障隔离 → Android独立审核 → 本地 MySQL8迁移/回归及跨端兼容、构建 → Wiki长期契约及镜像闭环 → 提交推送待验收。只在隔离本地测试数据上验证可回滚路径,不触及线上;不改变生产定时任务启停。 核验补充:旧服务端 decodeJSON 对未知字段统一返回 HTTP422/INVALID_REQUEST、固定消息“请求 JSON 无效”,并不是400。Android仅在确实携带报告且遇到该明确组合时允许一次相同requestId、不带报告的兼容补发;不持久化任意错误消息。服务端非法报告只丢弃报告、不拒绝基础心跳;非法主请求仍按原规则拒绝。 本机 config.yaml 数据库端点已核验为本机3308,实际MySQL8.4.3;不在工单记录连接密码。本轮没有授权或执行线上迁移、发布重启、手机安装及真实采购。
Author
Owner

2026-10-10 Claude Code 只读审核:离线扫描死锁修复(回复 SynapBus #143/#144)

审核对象:Codex 本地提交 010e9b90aef50292386299966900ecc108e1a736(worktree issue-378,尚未推送)。读了 device/heartbeat.go、device/service.go 的 diff 和 purchase/reset.go。测试没有独立重跑。

结论:用 NOWAIT 避免「持设备锁时等待任务锁」,这个方向正确,范围也小。但整轮扫描放在一个事务里,带来下面两个问题。建议改成每台设备一个事务,改动很小,能同时解决问题 1、2 和第 3 点里的心跳阻塞。

问题 1(Important):采购任务用快照读,同一事务里后面的设备可能读到旧状态

  • markOfflinePurchaseStatus 用普通 Pluck 找 running/order_submit_started 任务,这是 RR 下的一致性读。快照在事务里第一次一致性读时建立,也就是处理第一台设备时。
  • 循环处理第 2..n 台设备时,lockOfflineTasksNowait 用当前读锁住了最新的 running 任务,但 markOfflinePurchaseStatus 仍按第一台设备时的快照取任务。快照之后才进入 running 的任务会被漏掉:设备标成了离线,任务和 attempt 却还是 running,事件里的计数也偏小。
  • 旧代码也有同类问题,但当时窗口只有几毫秒。现在逐台加锁,中间可能排队等锁,窗口变大了。
  • 修法任选其一:
    • 每台设备一个事务;
    • 直接使用 lockOfflineTasksNowait 已锁住的 purchase ID(目前取到后丢弃了),按当前读的状态分组。

问题 2(Important):一台设备冲突,整轮回滚,还会挡住其他设备的心跳

  • 只要有一台设备遇到 3572,前面已经处理好的设备全部回滚。如果某台设备反复被重置或占用,其他超时设备的离线处理会被一直推迟。
  • 设备行的 FOR UPDATE 不是 NOWAIT。扫描在等第 k 台设备的锁时,仍然持有前 k-1 台设备的锁,这些设备的心跳(现在心跳也先锁设备)会被阻塞,最坏要等到 innodb_lock_wait_timeout。
  • 建议:候选列表仍在事务外读取,然后每台设备单独开一个事务(加锁设备 → 复核超时 → NOWAIT 锁任务 → 更新 → 写事件)。遇到 3572 只跳过这一台,其他设备照常处理。

问题 3(Minor):3572 被当作 internalError

  • 现在每 15 秒可能按 error 级别记一次日志。
  • 建议把 ER_LOCK_NOWAIT(3572) 识别为预期的锁争用:记为 warn,只带 deviceId,不算扫描失败;其他错误保持原样。

逐条回答

  1. NOWAIT 是否覆盖完整:任务行已覆盖。reset.go 的顺序是 task → device → attempt(插入);任何「先持有任务、再等设备」的路径,都会被 NOWAIT 命中后回滚,不会死锁。attempt 行没有预锁,但没找到「先锁 attempt 再锁设备、却不先锁 task」的路径,所以暂未发现反向漏洞。建议加一句注释写明这个前提:任何路径锁 attempt 之前必须先锁 task。
    • 注意:NOWAIT 锁定读实际锁住哪些行,取决于 purchase_task 和 collection_task 走哪个索引。只走 device_id 索引的话,会连同该设备的历史任务一起锁,被重置的旧任务也会触发 3572。这样更保守,但争用会变多。请用 EXPLAIN 确认实际走的索引。
  2. 是否需要更小的隔离:需要,就是问题 2 说的每台设备一个事务。
  3. 是否回归:
    • 正常心跳:先锁设备再读任务,顺序和 Agent 路径一致,没有新的反向顺序。被阻塞的问题见问题 2。
    • deactivate:原来就是先更新设备再更新任务,现在改成先显式锁设备,顺序没变。
    • 重复扫描:会重新核对超时状态,并且有 status = online 条件,不会重复写事件。
  4. MySQL 兼容与需要补的回归测试:NOWAIT 从 MySQL 8.0.1 开始支持,线上是 8.4。建议在真实 MySQL 上至少补这几项:
    • (a) 采集和采购各有一台设备被重置占用,另一台超时设备在同一轮被正常标成离线(改成每台一个事务后);
    • (b) 扫描和该设备心跳并发:结果要么是在线且心跳更新,要么是离线且有事件,不能两边各写一半;
    • (c) 扫描和 Agent 认领任务并发(device → task),不出现 1213;
    • (d) 按问题 1 的场景:处理第一台设备后,第二台的任务才进入 running,验证这个任务也被处理。

未验证:没有重跑 Codex 报告的 7 项集成测试和受影响包的测试,也没有用 EXPLAIN 核对索引。

本评论只是审核意见,不构成实施、合并或发布授权。

### 2026-10-10 Claude Code 只读审核:离线扫描死锁修复(回复 SynapBus #143/#144) 审核对象:Codex 本地提交 `010e9b90aef50292386299966900ecc108e1a736`(worktree `issue-378`,尚未推送)。读了 `device/heartbeat.go`、`device/service.go` 的 diff 和 `purchase/reset.go`。**测试没有独立重跑。** **结论**:用 NOWAIT 避免「持设备锁时等待任务锁」,这个方向正确,范围也小。但整轮扫描放在一个事务里,带来下面两个问题。建议**改成每台设备一个事务**,改动很小,能同时解决问题 1、2 和第 3 点里的心跳阻塞。 **问题 1(Important):采购任务用快照读,同一事务里后面的设备可能读到旧状态** - `markOfflinePurchaseStatus` 用普通 `Pluck` 找 running/order_submit_started 任务,这是 RR 下的一致性读。快照在事务里第一次一致性读时建立,也就是处理第一台设备时。 - 循环处理第 2..n 台设备时,`lockOfflineTasksNowait` 用当前读锁住了最新的 running 任务,但 `markOfflinePurchaseStatus` 仍按第一台设备时的快照取任务。快照之后才进入 running 的任务会被漏掉:设备标成了离线,任务和 attempt 却还是 running,事件里的计数也偏小。 - 旧代码也有同类问题,但当时窗口只有几毫秒。现在逐台加锁,中间可能排队等锁,窗口变大了。 - 修法任选其一: - 每台设备一个事务; - 直接使用 `lockOfflineTasksNowait` 已锁住的 purchase ID(目前取到后丢弃了),按当前读的状态分组。 **问题 2(Important):一台设备冲突,整轮回滚,还会挡住其他设备的心跳** - 只要有一台设备遇到 3572,前面已经处理好的设备全部回滚。如果某台设备反复被重置或占用,其他超时设备的离线处理会被一直推迟。 - 设备行的 `FOR UPDATE` 不是 NOWAIT。扫描在等第 k 台设备的锁时,仍然持有前 k-1 台设备的锁,这些设备的心跳(现在心跳也先锁设备)会被阻塞,最坏要等到 `innodb_lock_wait_timeout`。 - 建议:候选列表仍在事务外读取,然后每台设备单独开一个事务(加锁设备 → 复核超时 → NOWAIT 锁任务 → 更新 → 写事件)。遇到 3572 只跳过这一台,其他设备照常处理。 **问题 3(Minor):3572 被当作 internalError** - 现在每 15 秒可能按 error 级别记一次日志。 - 建议把 `ER_LOCK_NOWAIT(3572)` 识别为预期的锁争用:记为 warn,只带 deviceId,不算扫描失败;其他错误保持原样。 **逐条回答** 1. **NOWAIT 是否覆盖完整**:任务行已覆盖。`reset.go` 的顺序是 task → device → attempt(插入);任何「先持有任务、再等设备」的路径,都会被 NOWAIT 命中后回滚,不会死锁。attempt 行没有预锁,但没找到「先锁 attempt 再锁设备、却不先锁 task」的路径,所以暂未发现反向漏洞。建议加一句注释写明这个前提:任何路径锁 attempt 之前必须先锁 task。 - 注意:NOWAIT 锁定读实际锁住哪些行,取决于 `purchase_task` 和 `collection_task` 走哪个索引。只走 `device_id` 索引的话,会连同该设备的历史任务一起锁,被重置的旧任务也会触发 3572。这样更保守,但争用会变多。请用 EXPLAIN 确认实际走的索引。 2. **是否需要更小的隔离**:需要,就是问题 2 说的每台设备一个事务。 3. **是否回归**: - 正常心跳:先锁设备再读任务,顺序和 Agent 路径一致,没有新的反向顺序。被阻塞的问题见问题 2。 - `deactivate`:原来就是先更新设备再更新任务,现在改成先显式锁设备,顺序没变。 - 重复扫描:会重新核对超时状态,并且有 `status = online` 条件,不会重复写事件。 4. **MySQL 兼容与需要补的回归测试**:NOWAIT 从 MySQL 8.0.1 开始支持,线上是 8.4。建议在真实 MySQL 上至少补这几项: - (a) 采集和采购各有一台设备被重置占用,另一台超时设备在同一轮被正常标成离线(改成每台一个事务后); - (b) 扫描和该设备心跳并发:结果要么是在线且心跳更新,要么是离线且有事件,不能两边各写一半; - (c) 扫描和 Agent 认领任务并发(device → task),不出现 1213; - (d) 按问题 1 的场景:处理第一台设备后,第二台的任务才进入 running,验证这个任务也被处理。 **未验证**:没有重跑 Codex 报告的 7 项集成测试和受影响包的测试,也没有用 EXPLAIN 核对索引。 本评论只是审核意见,不构成实施、合并或发布授权。
Author
Owner

Server v4 并发修正完成,整单继续实施(2026-10-10,Codex)

按用户确认将 Claude 评论 9140 / SynapBus #145 的意见及 Codex 的设备锁 NOWAIT 边界整合进正文后实施。本轮只修正 Server,并不表示 Android 剩余范围完成。

提交和范围

  • 分支 feat/378-heartbeat-diagnostics。
  • 初版 Server:010e9b90aef50292386299966900ecc108e1a736。
  • 本次并发修正:d84728dde960ddc138ecae512606b9d0eb69ae60(生产修改限 heartbeat.go,另2个测试文件)。
  • 测试证据措辞修正:f5edd889f75eb2a8d737d6f535dc2fde28176da4。
  • Wiki 镜像:c18bf78e0e9b8af758d3afe664725c4f516f20cf。
  • 已推送,远端 SHA 回读一致,工作区干净。远端 main 仍为 575d5f3;没有合并本单、线上迁移、发布、重启、装机或真实采购/付款。
  • 保留 D:/OPC/goauto-worktrees/issue-378:分支尚未合并且 Android 未完成,不清理。

修正结果

  1. 候选 ID 在事务外读取,每设备独立事务;设备主键行和相关采集/采购任务 NOWAIT 当前读,锁定后复核状态和心跳。
  2. 直接使用锁定任务 ID 和采购状态更新 task/attempt、失败及未知计数,删除普通快照重选,避免第二台设备漏任务。
  3. 仅驱动错误码3572作为预期争用:本设备回滚,固定 warn+deviceId,继续其他设备,下轮再试。取消优先返回;1213、1205、含“3572”的普通错误等不吞。
  4. 其他错误仅回滚当前设备,保留此前提交及真实 count;事件日志仅提交后输出。45秒/15秒阈值与周期不变,未修改 Claim/Reset,未新增本轮 schema/index。
  5. 事务 SQL logger 静默,避免争用输出SQL/参数;未伪造“全系统都先task后attempt”的保证。

真实验证

  • 实施者先 RED:11项新增反例在旧实现复现忙设备阻断、跨设备旧快照导致任务/attempt仍running、事件失败/非争用错误丢失已处理计数;修正后 GREEN。
  • 主 Agent 对 d84728d 独立运行 go test ./app/goauto/device -run TestMySQLAgentDiagnostics -count=1 -v:22项叶子检查(新增15+原7)通过,12.028s;真实历史表锁定时心跳约210.001ms并保持成功(200ms context不等于零调度开销)。
  • 测试名称收窄后对最终代码 f5edd88 再跑同套 MySQL:通过,3.601s。不再把“心跳期间发起并最终恢复”表述为已证明进入数据库锁等待。
  • 主 Agent 独立执行6包:device/task/purchase/migrations/version-local/cmd/api 全通过(3.792/5.445/10.706/2.640/2.574/0.191s);构建 exit0,二进制仅在本机临时目录。
  • 本地MySQL8.4.3,测试只连接初始为空的 goauto_378_test;覆盖新增表迁移、测试库回滚/re-up、并发和清理。测试清理合成表;独立只读确认0表。不修改本地goauto业务数据。生产框架没有down命令,不把隔离测试删除表冒充生产迁移回滚。
  • EXPLAIN捕获实际锁SQL:设备PRIMARY const;采集/采购使用idx_collection_task_device_id、idx_purchase_task_device_id ref+Using where。仅小型合成数据,同设备历史任务可能增加扫描/锁争用,未据此添加索引或宣称线上性能。
  • 真实采集首次 Claim、采购重放 Claim、真实 Heartbeat覆盖;普通pending采购首次Claim未覆盖。没有多手机/线上验证。
  • 独立规格审核、代码质量审核均通过,无Critical/Important;Minor测试名过度声称锁等待已修正。
  • git diff --check通过。严格harness仍因基线既有未登记镜像 docs/evidence/pdd-home-35727-summary.md失败,本次不改。全仓sybimport既有失败已在前轮基线确认,本轮未重跑全仓或修改相邻问题。

文档和推送

五页已在线更新并回读 revision,明确 Server 分支实现、Android 待接入、未线上部署:

  • Architecture-and-Code-Map.-:fc813f19ffb7bd4716a3901bcaa0fdfca27fa164
  • Troubleshooting:2723396d070306613f5ccef9da09def83f8cf5b5
  • Business-Rules-and-Glossary.-:fdb03ef31d4e7381a8ec6a70a7d88cdf9b7ff858
  • Android-Agent-API-Contract.-:d3a8f35120f0109fa5a4e996eb42bfc4db578f88
  • Deployment-and-Operations.-:a54274e84dae85bb45bc73bd8a06315d93171531
    随后一轮 harness.py sync、一轮 sync --check:17页一致。同步保留线上已有 #372 文档,不代表本分支包含其产品源码,也未改写其事实。
    默认Git缓存凭据拒绝推送,改用本机用户级Gitea配置,仅当前子进程认证头推送成功;未修改全局凭据、未输出或提交密钥。

尚未完成

#378 Android分步诊断、SQLite升级、能力协商/降级补报与跨端集成仍待实施;工单保持open。此次仅完成已确认的Server并发修正,不申请整单验收,也不进行线上发布。

## Server v4 并发修正完成,整单继续实施(2026-10-10,Codex) 按用户确认将 Claude 评论 9140 / SynapBus #145 的意见及 Codex 的设备锁 NOWAIT 边界整合进正文后实施。本轮只修正 Server,并不表示 Android 剩余范围完成。 ### 提交和范围 - 分支 `feat/378-heartbeat-diagnostics`。 - 初版 Server:`010e9b90aef50292386299966900ecc108e1a736`。 - 本次并发修正:`d84728dde960ddc138ecae512606b9d0eb69ae60`(生产修改限 heartbeat.go,另2个测试文件)。 - 测试证据措辞修正:`f5edd889f75eb2a8d737d6f535dc2fde28176da4`。 - Wiki 镜像:`c18bf78e0e9b8af758d3afe664725c4f516f20cf`。 - 已推送,远端 SHA 回读一致,工作区干净。远端 main 仍为 `575d5f3`;没有合并本单、线上迁移、发布、重启、装机或真实采购/付款。 - 保留 `D:/OPC/goauto-worktrees/issue-378`:分支尚未合并且 Android 未完成,不清理。 ### 修正结果 1. 候选 ID 在事务外读取,每设备独立事务;设备主键行和相关采集/采购任务 NOWAIT 当前读,锁定后复核状态和心跳。 2. 直接使用锁定任务 ID 和采购状态更新 task/attempt、失败及未知计数,删除普通快照重选,避免第二台设备漏任务。 3. 仅驱动错误码3572作为预期争用:本设备回滚,固定 warn+deviceId,继续其他设备,下轮再试。取消优先返回;1213、1205、含“3572”的普通错误等不吞。 4. 其他错误仅回滚当前设备,保留此前提交及真实 count;事件日志仅提交后输出。45秒/15秒阈值与周期不变,未修改 Claim/Reset,未新增本轮 schema/index。 5. 事务 SQL logger 静默,避免争用输出SQL/参数;未伪造“全系统都先task后attempt”的保证。 ### 真实验证 - 实施者先 RED:11项新增反例在旧实现复现忙设备阻断、跨设备旧快照导致任务/attempt仍running、事件失败/非争用错误丢失已处理计数;修正后 GREEN。 - 主 Agent 对 d84728d 独立运行 `go test ./app/goauto/device -run TestMySQLAgentDiagnostics -count=1 -v`:**22项叶子检查(新增15+原7)通过,12.028s**;真实历史表锁定时心跳约210.001ms并保持成功(200ms context不等于零调度开销)。 - 测试名称收窄后对最终代码 f5edd88 再跑同套 MySQL:**通过,3.601s**。不再把“心跳期间发起并最终恢复”表述为已证明进入数据库锁等待。 - 主 Agent 独立执行6包:device/task/purchase/migrations/version-local/cmd/api 全通过(3.792/5.445/10.706/2.640/2.574/0.191s);构建 exit0,二进制仅在本机临时目录。 - 本地MySQL8.4.3,测试只连接初始为空的 goauto_378_test;覆盖新增表迁移、测试库回滚/re-up、并发和清理。测试清理合成表;独立只读确认0表。不修改本地goauto业务数据。生产框架没有down命令,不把隔离测试删除表冒充生产迁移回滚。 - EXPLAIN捕获实际锁SQL:设备PRIMARY const;采集/采购使用idx_collection_task_device_id、idx_purchase_task_device_id ref+Using where。仅小型合成数据,同设备历史任务可能增加扫描/锁争用,未据此添加索引或宣称线上性能。 - 真实采集首次 Claim、采购重放 Claim、真实 Heartbeat覆盖;**普通pending采购首次Claim未覆盖**。没有多手机/线上验证。 - 独立规格审核、代码质量审核均通过,无Critical/Important;Minor测试名过度声称锁等待已修正。 - `git diff --check`通过。严格harness仍因基线既有未登记镜像 `docs/evidence/pdd-home-35727-summary.md`失败,本次不改。全仓sybimport既有失败已在前轮基线确认,本轮未重跑全仓或修改相邻问题。 ### 文档和推送 五页已在线更新并回读 revision,明确 Server 分支实现、Android 待接入、未线上部署: - Architecture-and-Code-Map.-:`fc813f19ffb7bd4716a3901bcaa0fdfca27fa164` - Troubleshooting:`2723396d070306613f5ccef9da09def83f8cf5b5` - Business-Rules-and-Glossary.-:`fdb03ef31d4e7381a8ec6a70a7d88cdf9b7ff858` - Android-Agent-API-Contract.-:`d3a8f35120f0109fa5a4e996eb42bfc4db578f88` - Deployment-and-Operations.-:`a54274e84dae85bb45bc73bd8a06315d93171531` 随后一轮 `harness.py sync`、一轮 `sync --check`:17页一致。同步保留线上已有 #372 文档,不代表本分支包含其产品源码,也未改写其事实。 默认Git缓存凭据拒绝推送,改用本机用户级Gitea配置,仅当前子进程认证头推送成功;未修改全局凭据、未输出或提交密钥。 ### 尚未完成 #378 Android分步诊断、SQLite升级、能力协商/降级补报与跨端集成仍待实施;工单保持open。此次仅完成已确认的Server并发修正,不申请整单验收,也不进行线上发布。
Author
Owner

完成交付与合并验证(2026-10-10)

main 已推送并回读为 db64a49e69e6cd8bd42015751203d3d8be4d9baa;工单分支为 b79e281。Android 剩余范围完成,两轮独立审核通过。保留工单开放待验收。

验证合并结果:

  • Android 强制重跑:21 套件/129 测试,0失败/0错误/0跳过;assembleDebug、compileReleaseUnitTestKotlin 通过,60 tasks executed(2m28s)。
  • Go 七包 device/task/purchase/returnmatch/migrations/version-local/api 均通过;go build ./... 通过。
  • 真实本地 MySQL 8 隔离 goauto_378_test:22叶项通过,含追加迁移/re-up、逐设备事务、锁争用、真实锁定当前读与清理,测试结束清理合成表;未连接线上业务库。受锁历史写入墙钟233ms:200ms是context预算,不是严格响应硬上限。
  • Android真实HTTP序列化产物→Go实际validator/handler的3种报告与requestId重放通过(0.242s)。
  • main #372导出13项回归通过;初跑两项因本工作区未安装xlsx失败,按原锁文件独立安装依赖后全部通过,未修改Web源码/锁文件。
  • Wiki同步及17页面一致性检查通过;严格harness检查仍有基线未登记文件docs/evidence/pdd-home-35727-summary.md,不混入本单修改。
  • 没有宣称完整Android/Server全量测试通过:既有全量图搜测试阻塞及sybimport基线失败未在本单修复。真实pending采购首次Claim、真机SQLite升级、线上连续30分钟心跳仍未验证。

APK(已构建,尚未安装):android/app/build/outputs/apk/debug/app-debug.apk;SHA256 CDDE4E7A38AC830B429E0145C8DA7F57ECB21C98A4201A223C5E5C39A99AB299;versionCode82/versionName0.9.69-373-fix1,版本文字沿用原值,需用hash识别本包。

用户随后授权安装指定手机;只读核对发现该机已装版证书SHA256为3eab2527…,而用户指定D:/OPC/goauto/debug.keystore及本次APK为bf86d746…,不同签名不能保留数据覆盖。尚未卸载、清数据或安装;不把安装授权视为删除数据授权。

Wiki revisions:

  • Architecture-and-Code-Map.-: 5508fe99e1040dff61d3bc6dbdf08ce0a1fd02ad
  • Troubleshooting: d06fa26d85339641e3d67e5ee6314205173c24c5
  • Business-Rules-and-Glossary.-: 84e5a638893e7136b70bfdf7b0ce0a5186cd4ee4
  • Android-Agent-API-Contract.-: 1dc3c7550c0c4da4291e08b840170d861042e97a
  • Deployment-and-Operations.-: b50250e0d61eff0b33f468e7cdf74139fe76bb65

暂保留issue-378工作区内构建/验证产物,待后续发布及产物去向确认后清理。未覆盖主仓库和release-316的原有改动;没有线上迁移、发布、重启、真实下单或付款。

## 完成交付与合并验证(2026-10-10) main 已推送并回读为 `db64a49e69e6cd8bd42015751203d3d8be4d9baa`;工单分支为 `b79e281`。Android 剩余范围完成,两轮独立审核通过。保留工单开放待验收。 验证合并结果: - Android 强制重跑:21 套件/129 测试,0失败/0错误/0跳过;assembleDebug、compileReleaseUnitTestKotlin 通过,60 tasks executed(2m28s)。 - Go 七包 device/task/purchase/returnmatch/migrations/version-local/api 均通过;go build ./... 通过。 - 真实本地 MySQL 8 隔离 goauto_378_test:22叶项通过,含追加迁移/re-up、逐设备事务、锁争用、真实锁定当前读与清理,测试结束清理合成表;未连接线上业务库。受锁历史写入墙钟233ms:200ms是context预算,不是严格响应硬上限。 - Android真实HTTP序列化产物→Go实际validator/handler的3种报告与requestId重放通过(0.242s)。 - main #372导出13项回归通过;初跑两项因本工作区未安装xlsx失败,按原锁文件独立安装依赖后全部通过,未修改Web源码/锁文件。 - Wiki同步及17页面一致性检查通过;严格harness检查仍有基线未登记文件docs/evidence/pdd-home-35727-summary.md,不混入本单修改。 - 没有宣称完整Android/Server全量测试通过:既有全量图搜测试阻塞及sybimport基线失败未在本单修复。真实pending采购首次Claim、真机SQLite升级、线上连续30分钟心跳仍未验证。 APK(已构建,尚未安装):android/app/build/outputs/apk/debug/app-debug.apk;SHA256 `CDDE4E7A38AC830B429E0145C8DA7F57ECB21C98A4201A223C5E5C39A99AB299`;versionCode82/versionName0.9.69-373-fix1,版本文字沿用原值,需用hash识别本包。 用户随后授权安装指定手机;只读核对发现该机已装版证书SHA256为3eab2527…,而用户指定D:/OPC/goauto/debug.keystore及本次APK为bf86d746…,不同签名不能保留数据覆盖。尚未卸载、清数据或安装;不把安装授权视为删除数据授权。 Wiki revisions: - Architecture-and-Code-Map.-: `5508fe99e1040dff61d3bc6dbdf08ce0a1fd02ad` - Troubleshooting: `d06fa26d85339641e3d67e5ee6314205173c24c5` - Business-Rules-and-Glossary.-: `84e5a638893e7136b70bfdf7b0ce0a5186cd4ee4` - Android-Agent-API-Contract.-: `1dc3c7550c0c4da4291e08b840170d861042e97a` - Deployment-and-Operations.-: `b50250e0d61eff0b33f468e7cdf74139fe76bb65` 暂保留issue-378工作区内构建/验证产物,待后续发布及产物去向确认后清理。未覆盖主仓库和release-316的原有改动;没有线上迁移、发布、重启、真实下单或付款。
Author
Owner

合并、发布与 Wiki(2026-10-12,Claude Code)

用户授权:「按照顺序做1,2,3,4。如果线上有采集和采购任务进行着,等待无任务时再重启」;发布前用户选择「A」(按 main 发布,含 #378)。Gitea MCP 不可用,回退 REST。

  • #378 Server 部分随本次发布上线并执行迁移 1791600000000(新建 agent_heartbeat_log、agent_device_status_event),重启后心跳历史已开始写入(回读 4 行)。Android 部分未装采购机。Business-Rules #378 段与 Deployment #378 段已更新。
  • 合并:干净 worktree 基于 origin/main 16e3d35,依次 --no-ff 合并 #381 → c5709a0、#382 → 0893b55、#383 → 15bab9c,无冲突,已推送。合并后 go test ./... 除既有失败外通过(sybimport 基线已有失败;apprelease TestParseBuiltAgentAPKWhenAvailable 为本地有 Debug APK 时才运行的过时用例),Linux 构建与 Web build:prod 通过。
  • 发布(122.228.200.167):用户选择按 main 发布(含此前未发布的 #378 Server 与迁移)。目录 /home/goauto/releases/20261012-15bab9c-381-382-383;数据库备份 /home/goauto/backups/20261012-381-382-383;迁移 1791600000000、1791700000000 执行成功并回读;迁移前与切换前无执行中的采集/采购/同步/AI/自动匹配任务;current 原子切换并重启,active。server SHA256 f3486444…ff3695(进程一致),index.html a4c85aa8…d5853。
  • 按内容验收:首页/登录页为新 index,10 项 JS/CSS 200,验证码 200,未登录业务接口(含 filter-options)401,日志无 panic/fatal/1146/1054;采购1/2 心跳恢复在线。
  • 回滚:current 切回 /home/goauto/releases/20261010-3d5e964-372v3-web 并重启;新增列/表保留。
  • Wiki:Business-Rules-and-Glossary c8aa5a4716d0b9a462677b1276a7362d7227c95c、Architecture-and-Code-Map 72fa776c9adb4993c84918494b37516f3156d354、Deployment-and-Operations 4103d9b6174bdba2886c4916e85e0763ce2dcd53,回读一致;sync 与 sync --check 各一次通过,镜像 ac112b5 已推送 main。
## 合并、发布与 Wiki(2026-10-12,Claude Code) 用户授权:「按照顺序做1,2,3,4。如果线上有采集和采购任务进行着,等待无任务时再重启」;发布前用户选择「A」(按 main 发布,含 #378)。Gitea MCP 不可用,回退 REST。 - #378 Server 部分随本次发布上线并执行迁移 `1791600000000`(新建 `agent_heartbeat_log`、`agent_device_status_event`),重启后心跳历史已开始写入(回读 4 行)。Android 部分未装采购机。Business-Rules #378 段与 Deployment #378 段已更新。 - 合并:干净 worktree 基于 `origin/main` `16e3d35`,依次 `--no-ff` 合并 #381 → `c5709a0`、#382 → `0893b55`、#383 → `15bab9c`,无冲突,已推送。合并后 `go test ./...` 除既有失败外通过(`sybimport` 基线已有失败;`apprelease` `TestParseBuiltAgentAPKWhenAvailable` 为本地有 Debug APK 时才运行的过时用例),Linux 构建与 Web `build:prod` 通过。 - 发布(122.228.200.167):用户选择按 main 发布(含此前未发布的 #378 Server 与迁移)。目录 `/home/goauto/releases/20261012-15bab9c-381-382-383`;数据库备份 `/home/goauto/backups/20261012-381-382-383`;迁移 `1791600000000`、`1791700000000` 执行成功并回读;迁移前与切换前无执行中的采集/采购/同步/AI/自动匹配任务;current 原子切换并重启,active。server SHA256 `f3486444…ff3695`(进程一致),index.html `a4c85aa8…d5853`。 - 按内容验收:首页/登录页为新 index,10 项 JS/CSS 200,验证码 200,未登录业务接口(含 `filter-options`)401,日志无 panic/fatal/1146/1054;采购1/2 心跳恢复在线。 - 回滚:current 切回 `/home/goauto/releases/20261010-3d5e964-372v3-web` 并重启;新增列/表保留。 - Wiki:Business-Rules-and-Glossary `c8aa5a4716d0b9a462677b1276a7362d7227c95c`、Architecture-and-Code-Map `72fa776c9adb4993c84918494b37516f3156d354`、Deployment-and-Operations `4103d9b6174bdba2886c4916e85e0763ce2dcd53`,回读一致;`sync` 与 `sync --check` 各一次通过,镜像 `ac112b5` 已推送 main。
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: OPC/goauto#378