Files
QiuSW 12857fdf32
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
feat(sense): add reconciliation safety controls [T-012]
2026-08-07 23:00:03 +08:00

13 KiB
Raw Permalink Blame History

id, title, phase, deps, status, created, issue, context_ref, claim_branch, work_branch, write_paths
id title phase deps status created issue context_ref claim_branch work_branch write_paths
T-012 补齐 Sense 对账安全闸、孤儿受控处置与多实例可观测性 2
T-011
DONE 2026-08-07 43 25357723a0 claims/T-012 agent/codex/T-012
docs/tasks/T-012.md
Sense/README.md
Sense/cmd/sense-api/
Sense/cmd/sense-orphan/
Sense/internal/config/
Sense/internal/metrics/
Sense/internal/mtx/
Sense/internal/orphan/
Sense/internal/reconcile/
Sense/internal/store/
deploy/postgres/
scripts/test_postgres.ps1
docs/00-ai-start-here.md
docs/03-tech-stack.md
docs/04-architecture.md
docs/06-tasks.md
docs/api.md
docs/current-state.md
docs/runbooks/
tests/test_postgres_contract.py
tests/test_sense_reconcile_safety.py

问题 / 背景

T-003 已实现幂等调和与持久化退避,T-011 已完成 Control API、批量期望态和停用设备的精确 Path 删除,但生产 PostgreSQL 路径仍由每个 Sense 实例独立读取同一批 due row;多实例会重复探测、重复写 MediaMTX,并可能由过期 worker 覆盖新 worker 的完成结果。当前 MediaMTX 客户端也不枚举配置 Path,因此无法区分台账、媒体配置和历史归属之间的孤儿差异。

直接把“MediaMTX 中存在、当前设备表中不存在”的所有 Path 自动删除并不安全:MediaMTX 可能包含人工或其他系统管理的 Path,Sense 也没有历史归属证据。本任务先建立可证明的所有权、只读报告、比例安全闸和受控的一次性处置流程,并提供不含租户/设备高基数标签的 Prometheus 文本指标;它是进入 WireGuard 与 16 路批量基准前的 M2 生产安全基础,不等于容量验收。

关联需求与交互(如适用)

  • 用户故事:US-001、US-002、US-008、US-009;本任务提供接入运维所需的调和与孤儿诊断,不新增最终 Bell 管理页面。
  • 交互清单:IX-003、IX-004、IX-015、IX-016 的后端运维状态基础;HTML 原型不改,正式运维中心 API/UI 留给后续契约任务。
  • 相关页面 / 路由:新增低基数 /metrics 运维端点和本地 sense-orphan 命令;不修改 T-008 冻结的 /api/v1 Control API。

方案

  1. 增加 PostgreSQL 010/011 migration,把 sense.schema_migrations 提升到 v5:sense.reconcile_state 增加 owner/token/expiry 租约列;新增 MediaMTX Path 历史归属、孤儿扫描租约、扫描报告/发现项和处置结果表,并补齐 sense_app 最小权限、PUBLIC 拒绝与启动前置检查。migration 从现有视频设备脱敏回填 Path 归属,不保存 endpoint、credential 或 source URI。
  2. 将调和 repository port 改为“领取 due row → 每项开工前续租 → 持 token 完成/失败”的 fencing 模型。PostgreSQL 使用短事务、FOR UPDATE SKIP LOCKED 和随机 claim token;完成、失败和续租必须同时匹配 device、owner、token 且租约未被新实例接管。SQLite 保持单进程开发语义,不宣称跨进程互斥。
  3. 每项外部调和使用短于租约的 deadline;批量领取后逐项续租,等待期间已被其他实例接管的项只记录 lease_lost,不得执行 MediaMTX/ONVIF 变更。启用设备成功收敛时在同一 PostgreSQL 完成事务中刷新其 Path 历史归属;停用设备的既有精确删除仍不经过孤儿批量逻辑。
  4. 扩展 MediaMTX 薄客户端,以官方分页 API 枚举 Path 名称;循环设置页数上限、重复页保护和缺失字段校验,只返回排序去重后的名称,永不返回或记录 source URI。现有 EnsurePath/精确 DeletePath 语义不变。
  5. 新增 PostgreSQL 专用孤儿扫描器并在 sense-api 中默认按 60 秒执行只读扫描。单例扫描使用带 fencing token 的数据库租约;一次报告将 MediaMTX 配置分为:仍被设备当前占用、Sense 历史拥有但已失配的 owned_stale、从未有 Sense 归属证据的 unowned。unowned 永远只报告;扫描失败不产生可执行快照。
  6. 新增本地运维命令 sense-orphan:report 只产生脱敏 JSON 摘要和 15 分钟有效的 scan ID;apply 必须给出该 scan ID、合法 actor 和精确确认文本。执行前重新枚举 MediaMTX、重新读取所有权并重新计算安全闸,只处理仍为 owned_stale 的快照项;每项结果持久化,重复 apply 幂等。
  7. 孤儿删除安全闸固定为 候选数 * 100 <= 当前 MediaMTX 配置 Path 总数 * 10,并同时要求候选数 1~128、报告未超过 15 分钟、当前总数大于 0、当前候选是报告候选的子集。任何条件失败均整批不删除;不提供 force/bypass。删除逐项执行,部分失败可重试,但一次运行不得扩大目标集合。
  8. 新增无第三方依赖的 Prometheus/OpenMetrics 文本 handler,暴露构建/实例信息、调和 run/item/lease-lost/时长/未收敛汇总,以及孤儿扫描 observed/owned-stale/unowned、blocked/deleted/failed 汇总。标签只使用固定结果枚举和经过格式校验的 instance ID,不使用 tenant、Site、device、Path、URI 或错误正文。
  9. 更新 Sense 启动与文档:生成进程级 instance ID,也允许 SENSE_INSTANCE_ID 显式指定;配置调和租约/操作 deadline、孤儿只读扫描开关/周期和指标开关。默认 SQLite 继续可运行;孤儿持久化/处置只在 PostgreSQL v5 上启用。修正当前状态中“T-011 公共设备 API 尚未实现”的过期描述。

不可变约束

  • 阈值 / 数值边界:调和租约默认 30 秒,单项 operation deadline 默认 20 秒且必须严格小于租约;调和 batch 最大 128。孤儿扫描默认 60 秒;报告有效期固定 15 分钟;单次处置 1~128 项;删除比例最多 10%,使用整数交叉相乘,禁止浮点边界漂移。
  • 判定式 / 状态转换:只有 media_path_ownership 有历史记录、且当前没有任何视频设备继续声明同一 (device_id,path_name) 的运行时 Path 才是 owned_stale。未归属 Path 是 unowned,永不由 Sense 删除。过期或失配 fencing token 不得更新调和完成/失败,也不得产生可执行扫描报告。
  • 安全边界:默认和周期任务只读扫描,绝不自动 apply;apply 必须二次确认并重新取运行时/数据库快照。比例闸、快照时效、上限、所有权或确认任一失败时零删除;不提供环境变量、flag 或隐藏接口绕过。完整 URI、endpoint、credential、profile token、DSN、认证 token 和错误响应正文不得进入指标、日志、报告或审计表。
  • 既有契约:不修改 Sense Control OpenAPI v1、设备审计 v1/v2 和 7 个业务 endpoint;停用设备按精确台账 Path 幂等删除的行为保持。site.max_video_channels 默认 16、上限 128,不把 16 写成业务或数组硬上限。
  • 多实例边界:PostgreSQL v5 才提供调和/孤儿租约语义;SQLite 只保留本地单实例开发,不得通过 SQLite 测试宣称生产多实例安全。租约不是长期锁,所有外部调用必须可取消并受 deadline 限制。

验收要点

  • 任务相关验证:Go 单元测试覆盖 MediaMTX 多页/重复页/脱敏失败、租约失效时零副作用、两个 PostgreSQL store 并发只领取一次、租约过期接管与旧 token fencing、归属回填、三类孤儿判定、10% 边界、15 分钟过期、二次快照收窄、未知 Path 不删除、部分失败幂等和 metrics 无敏感/高基数标签。
  • PostgreSQL 门禁:./scripts/test_postgres.ps1 -PgRoot D:\pgsql17 连续重放 001~011、执行权限/约束断言和全部 TestPostgres*;随机回环临时集群自动清理,不读取、停止或修改现有 D:\pgsql17\data/5432 实例。
  • 完整门禁:./init.ps1、三条 Python 治理命令、go -C Sense test ./...、go -C Sense vet ./...、go -C Sense build ./...、go test -race ./internal/metrics ./internal/mtx ./internal/orphan ./internal/reconcile ./internal/store、git diff --check 全部通过。
  • 人工 / 设备验收:不需要摄像头、GPU 或客户现场;使用 fake MediaMTX 与隔离 PostgreSQL 验证。真实五路与容量结论仍由 T-007/后续 16 路基准提供。
  • 构建产物:sense-api 的 /metrics、sense-orphan 本地命令、PostgreSQL v5 migration、运维 runbook、测试和同步文档;不生成或提交任何私有配置。

边界(不改什么)

不实现 Bell 运维 UI/RBAC/JWT、WireGuard、Outbox relay、设备删除 API、跨 MediaMTX 分片编排、16 路容量压测、自动修复 unowned Path 或 T-007 现场验收;不修改 MediaMTX 生成代码、冻结 OpenAPI/审计契约和已确认 Sense/Bell HTML 原型。指标只建立进程内 Prometheus scrape 面,不引入 Grafana dashboard、OpenTelemetry SDK 或新的第三方依赖。

协作约束

  • 责任 Agent:由 dispatcher 分配。
  • 唯一写入者:同责任 Agent。
  • 委派:默认不启用。
  • Gitea:任务规格先进入默认分支,再创建唯一 Issue 并回填编号;领取时记录 context_ref、claim / 工作分支和全部允许写路径。

任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 write_paths。T-007 虽处于 waiting,但其未来写路径包含 Sense/;T-012 活跃期间不得同时恢复 T-007。

执行记录

2026-08-07 完成多实例对账安全与孤儿受控处置

  • PostgreSQL schema 提升到 v5:010/011 为 due row 增加 owner/token/expiry fencing,新增不含 source URI 的 MediaMTX Path 历史归属、孤儿扫描租约、15 分钟报告、发现项和处置审计,并补齐 sense_app 最小权限与 PUBLIC 拒绝;启动会拒绝旧 schema 或越权运行角色。
  • 调和器改为 FOR UPDATE SKIP LOCKED 批量领取、逐项续租、短于租期的外部调用 deadline 和带 token 的完成/失败;数据库时钟判定租约,旧 worker 不能覆盖接管者结果。成功启用在同一完成事务中刷新 Path 归属;SQLite 明确保留单进程开发语义。
  • MediaMTX 薄客户端新增只返回排序去重 Path 名称的有界分页枚举,包含重复页保护且不读取/返回 source;独立孤儿扫描把差异分为 owned_stale 与永不删除的 unowned。周期任务默认只报告,人工 sense-orphan apply 必须使用 15 分钟内 scan ID、合法 actor 和精确确认文本,并在删除前重取库存/归属。
  • 处置安全闸固定为 1~128 项且不超过当前 Path 的 10%,使用整数交叉相乘,无 force/bypass;目标只能从原快照收窄。逐项结果可审计和幂等重试,数据库外键/约束也拒绝为 unowned 写入删除记录。
  • 新增无第三方运行时依赖的 /metrics:只使用构建/实例及固定结果枚举标签,覆盖调和 run/item/lease-lost/时长/未收敛和孤儿扫描/阻断/删除汇总;tenant、Site、device、Path、URI 与错误正文均不进入标签。
  • ./scripts/test_postgres.ps1 -PgRoot D:\pgsql17 通过:隔离 PostgreSQL 17.10 临时集群连续重放 001~011,SQL 权限/约束断言及 29 个 TestPostgres* 全绿,覆盖两个 store 并发唯一领取、租约接管/旧 token fencing、归属记录、扫描租约、未知 Path 数据库拒删和处置幂等;随机端口实例已停止并清理,现有 D:\pgsql17\data/5432 未被读取、停止或修改。
  • ./init.ps1 通过;Python 治理/契约测试共 52 项通过,T-012 定向静态契约 12 项通过;全部 Go 测试、生成漂移、go vet、go build 与 git diff --check 通过;go test -race ./internal/metrics ./internal/mtx ./internal/orphan ./internal/reconcile ./internal/store 通过。不需要摄像头/GPU,本结果不解除 T-007,也不形成 16/128 路容量承诺。

2026-08-07 领取任务

  • dispatcher ila 将 Issue #43 分配给 codex;context_ref 为 25357723a09218bae58626a9934309abcda34899,claim 为 claims/T-012,工作分支为 agent/codex/T-012。
  • 已读回 Issue status/doing、assignee、dispatcher 发布的完整 CLAIM 与两个分支 SHA;接受 frontmatter 全部写路径。T-007 继续 waiting,本任务期间不恢复,当前无活跃写路径冲突。

2026-08-07 Gitea 映射

  • 任务规格先通过 PR #42 合入默认分支,再创建唯一主 Issue #43;本次只回填双向映射,映射合入并读回前不领取任务。

2026-08-07 任务定义

  • 项目负责人要求在 T-011 后创建并实施 T-012;当前只有 T-007 因外部真实设备条件处于 waiting,无开放 PR 或活跃 claim。
  • 本任务把路线图的“对账器并发/10% 安全闸”和当前状态建议收敛为 PostgreSQL fencing、可证明 Path 所有权、只读孤儿报告、不可绕过的受控处置和低基数指标;不以未知 Path 的存在推断 Sense 有删除权。