diff --git a/AGENTS.md b/AGENTS.md index f5e8245..0fd75b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,8 @@ ## 当前范围与权威入口 +- **SQLite cleanup v0.1 首期(开发分支,本地验收)**:`feat/sqlite-cleanup-v0.1` 仅调整完成数据的本地保留周期。源 `event_id`(含 numberID)原样回传,不新增 `execution_id`,SaaS 如何处理重复结果不在范围;共享合同仍固定 `ef3009d`,接口/类型/路由不变,无共享发布。Agent 不再读写永久 `.approved`;活动 TaskCalls 与 Dispatcher 未决/未知保护保留。已派发呼叫需终结/结果成功及派发回执、最终结果分别 MQ confirm;未发起拒绝只需拒绝确认;取消记录需 durable stopped 和成功 stop confirm。清理事务不删共享配置/任务停止状态,布局仍为 **4**,无迁移/重置。录音仅 delivered 且无未完成 `.results` 引用后删音频、信息、空叶,删除中断凭 delivered 依据续处理,不再 PUT/报告;拒绝原件按完整回执/原身份/内容精确关联,先全部原件后回执。新原件文件名为回执 SHA-256 与完整记录 SHA-256,仅磁盘关联,不是业务编号;既有完整文件校验读取、不迁移,不能关联的损坏旧文件和潜在依据保留待人工。两角色独立 Asia/Shanghai 每日 24:00 补扫复用实时资格,无 12:00/年龄删除/VACUUM/永久清理历史,清理错误不关闭业务准入。仅测试目录验证 `.approved` 退役,没有实际主机退役、部署、真实数据/OSS 删除、拨号、抓包、AI/OSS 调用或 main 合入/推送;本地通过不代表真实服务验收。检查和逐项证据见 [`docs/evidence/sqlite-cleanup-v0.1-local.md`](docs/evidence/sqlite-cleanup-v0.1-local.md)。此条是开发分支状态,不改变下述线上事实。 + - **2026-10-10 event_id 修复获批部署与重置现场**:使用者在要求直接部署后最终同意必要私密备份与测试运行数据重置。修复 `f7aa766` 已合入并推送 main,安装至登记 `server.sip`,共享合同固定 `ef3009d`。先停 Dispatcher 关准入,核实零通道、无未完成/未知执行、无待确认反馈/Agent 恢复;MQ 控制及任务3–5队列为空/消费者0,任务1–2队列不存在,无发布/消费/ACK/purge。私密备份与原29文件 bytes/清单完全一致,恢复验证包含 WAL/SHM,保留原版本3、8条完成及25条确认事实;不改写旧结果。新运行库布局4、inbox/outbox0,不继承旧事件防重历史,不主动重投旧命令。实际独立复现二进制 SHA-256 `6886987795b208e79964053328262c450a3249ee3c3f7e9e75bc7ca1c10c0586`;Agent2910678 enabled+active、0重启、Debug开启/Capture关闭。Dispatcher enabled且实际auto-restart,最新结构化错误为SaaS HTTP502(初始独立探测404),`business_ready=false`,不把failed/数据库/证书/未知错误算等待成功。Asterisk777461保持不变、0通道;未重启、抓包、主动投递/试拨或额外AI/OSS,未清外部SaaS/MQ/OSS。程序安装与获批重置完成,但线上event_id反馈/真实通话/生产未验证。证据与源制品边界见 [`docs/evidence/event-id-lifecycle-20261010.md`](docs/evidence/event-id-lifecycle-20261010.md) 文末与 `.local/event-id-deployment/`。本条新授权与现场事实优先于下方本地未部署的历史阶段。 - **2026-10-10 event_id 全链路原值规则(本地修复,未部署)**:针对已接收事件的回执、拒绝、执行反馈及 `call.execute.result` 必须原样保留源 `event_id`,包括其中的 numberID,不哈希、拼接或清洗。共享合同 `ef3009d` 已推送;本项目同步固定引用及实现/测试。Dispatcher 外发保存、查询、发送确认及恢复统一以 `(dispatcher_id,event_type,event_id)` 区分,同 ID 的派发回执与最终结果独立处理;录音授权只检查实际最终结果。移除测试专用 SaaS Mock 的旧派生 ID 判定,不改变真实 SaaS 代码。SQLite 当前布局为 **4**,旧布局只读拒绝,不自动迁移、重置或绕过;现存业务数据与运行服务未改。源码文件名、录音存储键、上传及 RPC 动作编号不是源 event_id,保留各自独立身份。核验及 SaaS 开发同步说明见 [`docs/evidence/event-id-lifecycle-20261010.md`](docs/evidence/event-id-lifecycle-20261010.md)。 diff --git a/docs/evidence/sqlite-cleanup-v0.1-local.md b/docs/evidence/sqlite-cleanup-v0.1-local.md index 93227e1..3136537 100644 --- a/docs/evidence/sqlite-cleanup-v0.1-local.md +++ b/docs/evidence/sqlite-cleanup-v0.1-local.md @@ -2,7 +2,7 @@ ## 范围与状态 -- 开发分支:`feat/sqlite-cleanup-v0.1`;首期开发尚未全部完成。 +- 开发分支:`feat/sqlite-cleanup-v0.1`;首期本地开发及规定验收完成。 - 用户确认:仅本项目内部数据保留周期调整,不新增 `execution_id`,源 `event_id`(包括 numberID)原样回传;SaaS 如何处理再次返回的同 ID 结果不在范围内。 - 共享合同固定 `ef3009d`,`make contracts-update` 的 43 个 JSON 检查与 8 项合同测试通过;接口字段、消息类型、路由、共享文件及 submodule 指针不变,没有共享发布。 - 没有合入或推送本项目 main、部署、操作现存运行数据库/恢复目录、退役主机 `.approved`、拨号、抓包或调用真实 AI/OSS。 @@ -56,6 +56,37 @@ - UTC/上海转换、12:00 不清理、午夜启动安排次日、时钟回退、失败等待次日、取消计时器,以及 205 条跨页候选、未确认/待拨邻居保留、取消后续处理、只清 delivered 不重试其他录音验证通过。 - `go test -race ./internal/daily ./internal/store ./internal/dispatcher ./internal/agent ./cmd/sip-go-agent` 通过。 -## 尚待完成 +## 最终验收 -全量检查、隔离 MQ/HTTPS/mTLS、覆盖率和增长验收。上述局部验证不代表首期全部完成,不代表真实 SaaS、通话或生产验收。 +- `make check`:格式、合同固定引用/43 JSON及8合同测试、Proto来源、`go vet ./...`、`go test -race ./...`、二进制构建及隔离 MQ 检查通过。 +- `make release-check-local`:制品及固定合同/队列门禁通过;`production_approval=false` 保持不变,未部署。最终源码的干净提交检查见后续补充,不把最初 dirty 源发布检查当作固定提交制品。 +- 实际运行的隔离 RabbitMQ + HTTPS + gRPC mTLS 端到端通过。更新旧“完成后永不重用”断言:收到原 ID 最终结果、两个消息类型分别确认、本地清理后,再通过 MQ 投递同一源 ID,Agent 再次接纳且 ID 不变。没有以孤立单元测试代替 MQ/HTTPS/mTLS 运行事实。 +- 业务覆盖率 **72.9%**(新增纯测试后复核),高于 65%;Python生产部署 **26** 项、脚本 **9** 项通过。 +- SQLite增长:4轮各40通、同源 numberID 重用及重启,完成后 inbox/outbox/recordings 都为0,共享task/config仍各1;预热后 page_count 不继续增长,不执行 VACUUM、不要求数据库文件立即缩小。 +- 录音/拒绝文件增长:各3轮×20条重复身份,完成后原件/回执或录音叶目录不存在;保留共享目录,不留 `.approved` 或内存永久完成身份历史。 +- 没有修改部署主机或现有运行数据库/文件,未 main 合入/推送、部署、重置、迁移、拨号、抓包、实际 AI/OSS 调用或 OSS 对象删除。SaaS收到、判重、覆盖和真实通话/生产验收不在本次范围。 + +### 计划验收逐项对应 + +| 项目 | 本地证据 | +| --- | --- | +| finished需结果成功与派发/最终结果各自confirm;任一缺失保留 | store/cleanup_test.go、dispatcher/cleanup_test.go及隔离runtime集成 | +| rejected需正式未发起拒绝confirm,不要求不存在的最终结果 | store/cleanup_test.go、rpc definitive-no-dial及原有占用释放回归 | +| suppressed需durable stopped和成功stop confirm | store/cleanup_control_test.go:205条、单条SQL故障、取消中断及补收尾 | +| Stop回执后删;停止状态/共享配置/未决/unknown保留 | store清理/占用恢复、dispatcher缓冲旧批次/并发派发及既有恢复测试 | +| .approved不生成;活动并发仍受保护 | rpc/approved_retirement_test.go:活动重叠拒绝、结束/重启后重用、旧标记不读改 | +| 已存主机.approved受控退役 | 仅临时目录验证私密备份bytes/size/SHA和相邻边界;主机操作范围外未执行 | +| 音频删、delivered info仍在时续处理;其他状态缺音频报错 | agent/recording_cleanup_test.go,信息/叶目录失败和坏SHA,重启不PUT/report | +| 未完成.results引用、未知侧文件/坏记录保留,独立记录继续 | agent录音清理/扫描及全量结果日志回归;临时锁释放和跨实例一次PUT | +| 原始拒绝准确关联;一回执多文件;孤立/未确认不删 | store/cleanup_rejected_test.go,完整回执/身份/内容/文件索引证明 | +| 两阶段删除失败/并发新捕获不会提前清回执 | 原件半途失败、回执SQL触发器失败、capture-race及独立坏组回归 | +| .results正常成功收尾、SIP来源、既有临时行为不退化 | 全量agent/result_journal、store/sip_source及cmd/rpc回归;未知.pending保持 | +| 实时与午夜同条件;0点非12点;故障/取消不关准入 | daily/midnight_test.go、dispatcher/cleanup_scan_test.go、Agent delivered-only回归 | +| 同源ID原值重用,不新增字段,不验证SaaS重复处理 | controller单元与隔离MQ最终结果/再次执行;Schema/Proto/submodule均未改 | + +### A01–A12 / K01–K16 范围核对 + +- A01–A03、K01–K02:固定共享合同、来源检查、事件身份及入站/出站边界由本次完整检查和现行Schema回归核验;不另复制或修改运行契约。 +- A04–A06、K03–K10:HTTP完整配置、任务控制优先级、租户/任务/线路占用、停止/缓冲并发、publisher confirm及未决重启保护由全量回归及实际隔离MQ/HTTPS/mTLS核验;已完成数据只在本地条件满足后释放记录。 +- A07–A09、K11–K14:Agent会话/原值派发、执行终结、上传绑定/未知PUT/恢复与结果交付由rpc/agent/store回归核验;本次只清理成功delivered,不扩大重试或真实云授权。 +- A10–A12、K15–K16:可观测性、构建制品、本地门禁和单节点边界检查通过;真实SaaS/management、真实通话/AI/OSS、ECS、容量、多节点/租户及生产签收不适用且未宣称通过。 diff --git a/docs/plan-sqlite-cleanup-v0.1.md b/docs/plan-sqlite-cleanup-v0.1.md index 901788e..d632390 100644 --- a/docs/plan-sqlite-cleanup-v0.1.md +++ b/docs/plan-sqlite-cleanup-v0.1.md @@ -1,6 +1,6 @@ # Dispatcher SQLite、本地文件清理与配置缓存调整计划(v0.1) -- **状态:** 首期实施中;按已确认目标执行,尚未完成验收 +- **状态:** 首期本地实施与验收完成;未合入/推送 main、未部署。逐项证据见 `docs/evidence/sqlite-cleanup-v0.1-local.md` - **日期:** 2026-10-10 - **首期范围:** Dispatcher SQLite 内已满足条件的单次呼叫执行记录及回执;Agent `.approved` 持久执行标记、已完成交付的录音恢复文件;Dispatcher 已确认拒绝的原始数据文件。遗留临时文件仅发现并报告,不自动删除。 - **后续独立范围:** 当前配置内存化;不作为首期清理的开发或验收前置条件。 diff --git a/internal/agent/recording_cleanup_test.go b/internal/agent/recording_cleanup_test.go index e531b21..4f68956 100644 --- a/internal/agent/recording_cleanup_test.go +++ b/internal/agent/recording_cleanup_test.go @@ -260,6 +260,23 @@ func TestMidnightDeliveredOnlySweepPreservesUnfinishedFiles(t *testing.T) { } } +func TestRepeatedDeliveredRecordingCleanupDoesNotAccumulateFiles(t *testing.T) { + root := filepath.Join(t.TempDir(), "private") + for round := 0; round < 3; round++ { + r := &RecordingRecovery{Root: root} + for i := 0; i < 20; i++ { + e := deliveredRecordingFixture(t, r) + if count, err := r.CleanupDelivered(context.Background()); err != nil || count != 1 { + t.Fatalf("round=%d entry=%d count=%d err=%v", round, i, count, err) + } + dir, _, _, _ := r.paths(e.Bucket, e.ObjectKey) + if _, err := os.Stat(dir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("completed files accumulated: %v", err) + } + } + } +} + func TestMissingAudioDoesNotRelaxUnfinishedRecordingValidation(t *testing.T) { r := &RecordingRecovery{Root: filepath.Join(t.TempDir(), "private")} entry := failedRecordingFixture() diff --git a/internal/dispatcher/runtime_integration_test.go b/internal/dispatcher/runtime_integration_test.go index d4804db..f24a018 100644 --- a/internal/dispatcher/runtime_integration_test.go +++ b/internal/dispatcher/runtime_integration_test.go @@ -4,6 +4,7 @@ package dispatcher import ( "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -328,14 +329,32 @@ func TestRuntimeIsolatedControlBacklogExecuteAndSharedResult(t *testing.T) { case <-time.After(20 * time.Millisecond): } } - if _, created, err := db.RecordCallResult(id, sourceID, resultPayload); err != nil || created { - t.Fatalf("result duplicate became new output: %t %v", created, err) + cleanupDeadline := time.After(5 * time.Second) + for { + err := db.RequireReservedCall(id, sourceID, 1001) + if errors.Is(err, sql.ErrNoRows) { + break + } + if err != nil { + t.Fatal(err) + } + select { + case <-cleanupDeadline: + t.Fatal("confirmed isolated call was not cleaned") + case <-time.After(20 * time.Millisecond): + } + } + if _, created, err := db.RecordCallResult(id, sourceID, resultPayload); err == nil || created { + t.Fatalf("cleaned call invented a permanent duplicate-result history: %t %v", created, err) } publish(taskRoute, executeBody(t, sourceID, "15803300952")) select { case spec := <-agent.calls: - t.Fatalf("redelivery reoriginated call %s", spec.EventID) - case <-time.After(200 * time.Millisecond): + if spec.EventID != sourceID { + t.Fatalf("new execution changed original identity: %s", spec.EventID) + } + case <-time.After(5 * time.Second): + t.Fatal("same source ID was not executable after confirmed cleanup") } publish(controlRoute, []byte(strings.ReplaceAll(string(controlBody(t, "new-task-start", "start", "")), "task-asr", "task-new"))) startDeadline := time.After(5 * time.Second) diff --git a/internal/store/cleanup_growth_test.go b/internal/store/cleanup_growth_test.go new file mode 100644 index 0000000..937e8d1 --- /dev/null +++ b/internal/store/cleanup_growth_test.go @@ -0,0 +1,51 @@ +package store + +import ( + "testing" +) + +func TestCompletedCleanupReusesSQLitePagesAndOriginalIdentityAcrossRestarts(t *testing.T) { + s := preparedCurrentCallStore(t) + path := s.path + var warmedPages int + for round := 0; round < 4; round++ { + for attempt := 0; attempt < 40; attempt++ { + cmd := currentResultCall(t, s, "call:numberID:000042", true) + binding := currentUploadBinding(cmd) + if _, _, err := s.BindRecordingUpload(binding); err != nil { + t.Fatal(err) + } + if _, _, err := s.RecordUploadedCallResult(cmd.DispatcherID, cmd.EventID, currentUploadedResultPayload(t, binding), currentUploadProof(binding)); err != nil { + t.Fatal(err) + } + confirmCleanupFeedback(t, s, cmd.EventID, "call.execute") + confirmCleanupFeedback(t, s, cmd.EventID, "call.execute.result") + if result, err := s.CleanupCall(cmd.DispatcherID, cmd.EventID); err != nil || result.DeletedRows != 4 { + t.Fatalf("round=%d attempt=%d cleanup=%+v err=%v", round, attempt, result, err) + } + requireCleanupCount(t, s, "dispatcher_inbox", 0) + requireCleanupCount(t, s, "dispatcher_outbox", 0) + requireCleanupCount(t, s, "dispatcher_recordings", 0) + } + var pages int + if err := s.db.QueryRow(`PRAGMA page_count`).Scan(&pages); err != nil { + t.Fatal(err) + } + if round == 0 { + warmedPages = pages + } else if pages > warmedPages { + t.Fatalf("completed data grew SQLite after warmup without VACUUM: round=%d pages=%d warmed=%d", round, pages, warmedPages) + } + requireCleanupCount(t, s, "dispatcher_tasks", 1) + requireCleanupCount(t, s, "dispatcher_configs", 1) + if err := s.Close(); err != nil { + t.Fatal(err) + } + var err error + s, err = Open(path) + if err != nil { + t.Fatal(err) + } + } + defer s.Close() +} diff --git a/internal/store/cleanup_rejected_test.go b/internal/store/cleanup_rejected_test.go index a9ab9df..4f8fe43 100644 --- a/internal/store/cleanup_rejected_test.go +++ b/internal/store/cleanup_rejected_test.go @@ -262,6 +262,27 @@ func TestRejectionCaptureCannotRaceOriginalDeletionAndReceiptCommit(t *testing.T } } +func TestRepeatedRejectedCleanupDoesNotAccumulateOriginalsOrReceipts(t *testing.T) { + s := preparedCurrentCallStore(t) + for round := 0; round < 3; round++ { + for i := 0; i < 20; i++ { + if err := s.RejectData(currentDispatcherID, rejectedRaw("repeated-original", "call.execute", "same"), errors.New("invalid command")); err != nil { + t.Fatal(err) + } + if err := s.MarkOutboxConfirmed(currentDispatcherID, "call.execute", "repeated-original"); err != nil { + t.Fatal(err) + } + if result, err := s.CleanupRejectedFeedback(currentDispatcherID, "call.execute", "repeated-original"); err != nil || result.DeletedRows != 1 || result.DeletedFiles != 1 { + t.Fatalf("round=%d entry=%d result=%+v err=%v", round, i, result, err) + } + if len(rejectionFiles(t, s)) != 0 { + t.Fatal("completed originals accumulated") + } + requireCleanupCount(t, s, "dispatcher_outbox", 0) + } + } +} + func TestCleanupRejectedEvidenceConflictPreservesItsProofNotOtherRecords(t *testing.T) { s := preparedCurrentCallStore(t) if err := s.RejectData(currentDispatcherID, rejectedRaw("rejection-corrupt", "call.execute", "corrupt"), errors.New("invalid command")); err != nil {