refactor(agent): retire permanent approved execution markers
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
# Dispatcher SQLite、本地文件清理与配置缓存调整计划(v0.1)
|
||||
|
||||
- **状态:** 首期实施中;按已确认目标执行,尚未完成验收
|
||||
- **日期:** 2026-10-10
|
||||
- **首期范围:** Dispatcher SQLite 内已满足条件的单次呼叫执行记录及回执;Agent `.approved` 持久执行标记、已完成交付的录音恢复文件;Dispatcher 已确认拒绝的原始数据文件。遗留临时文件仅发现并报告,不自动删除。
|
||||
- **后续独立范围:** 当前配置内存化;不作为首期清理的开发或验收前置条件。
|
||||
- **执行授权:** 已确认首期本地开发、测试和相关文档更新;不授权部署、操作现存运行数据或发布共享合同改动
|
||||
- **决策来源:** 记录本轮较新的用户决定;若与旧项目计划中的清理门槛不一致,以本文件作为本地目标方案,不视作共享合同已变更。
|
||||
|
||||
旧计划 [`plan-dispatcher-state-v0.1.md`](plan-dispatcher-state-v0.1.md) 对确认边界有不同的暂定描述。本文件不修改旧计划或共享合同;实施前需按 [`contracts.md`](contracts.md) 更新并核验共享合同,再同步整理相关项目文档。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. 避免已完成呼叫和已确认回执长期留在 SQLite,导致数据库持续增长。
|
||||
2. 保留仍需恢复、重试或计量的运行状态,不因清理丢失未完成呼叫或未投递回执。
|
||||
3. 首期保留现有当前配置的存储和读取方式;配置内存化作为后续独立调整,不阻塞已完成呼叫数据清理。
|
||||
4. 清理逻辑简单、可重试;异常记录保留并可观察,不静默删除。
|
||||
5. 移除 Agent 不再需要的永久 `.approved` 文件排重,停止每通呼叫无限新增执行标记,并对现存目录作一次受控退役。
|
||||
6. 清理已完成交付的本地录音恢复文件和已确认拒绝的原始数据,避免只清数据库却保留文件;不删除未解决的恢复事实或 OSS 对象。临时文件异常退出遗留先保留并报告,暂不建设自动识别和删除机制。
|
||||
|
||||
### 首期取舍与收益
|
||||
|
||||
优先消除随已完成呼叫持续累积的数据,不把低频残留文件的自动识别或配置重构绑定进来。当前配置按任务覆盖更新,不是每通增加一份的历史;首期仍会删除满足条件的逐通执行快照,无须先改变当前配置存储方式。
|
||||
|
||||
本次简化不削减三个必要条件:未知及未确认数据保留;录音须在上传和结果交付完成后删除;删除中断后能保留依据并继续收尾。沿用现有状态、回执和日志,不新增通用清理框架或永久清理历史。
|
||||
|
||||
收益是减少已完成数据的长期累计,不是保证磁盘占用归零。正常录音走内存上传,不能把全部接通录音都算成本地长期增长;未解决的恢复积压仍会占用空间,48 小时重试窗口不是保留上限。验收看已完成记录/文件是否清除、相同业务量下是否避免持续累积,而不要求 SQLite 文件立即缩小。此前容量数字仅为带假设的情景估算,不作为线上测量值或固定容量承诺。
|
||||
|
||||
## 2. 已确认的行为规则
|
||||
|
||||
- 定时清理**每天仅在 24:00(Asia/Shanghai)运行**;取消 12:00 清理。
|
||||
- 定时清理是实时清理的补充,只重试已满足清理条件、但实时清理未完成的记录。
|
||||
- 呼叫因暂时不在可呼叫时间段而等待时,继续等待;不得按清理时间废弃待呼叫命令。
|
||||
- 呼叫按单次 `call.execute` 执行清理,不删除仍被其他并发呼叫共享的 SaaS `task_id` 配置和控制状态。
|
||||
- 已实际派发的呼叫仅在以下条件全部满足后清理:
|
||||
1. Agent 结果 RPC 成功返回;
|
||||
2. 呼叫接收/派发回执已获 MQ publisher confirm;
|
||||
3. 最终任务结果已获 MQ publisher confirm。
|
||||
- 本计划不处理结果 RPC 已在 Dispatcher 持久化、但成功响应未被 Agent 收到的边界;不增加第二次确认协议。MQ publisher confirm 是最终结果的投递边界,不等待 SaaS 应用层确认。
|
||||
- 对已持久化为终态 `rejected`、且能证明呼叫未发起的 `call.execute`,只需其拒绝回执获 publisher confirm;不要求不存在的 Agent 结果 RPC 或 `call.execute.result`。拒绝回执未确认时保留相关记录。
|
||||
- 对因任务停止而取消、且确认未发起的 `suppressed` 记录,所属任务持久状态为 `stopped`,且对应成功停止回执已获 publisher confirm 后,按任务清理;不等待 Agent 最终结果,不补造逐通拒绝或最终结果回执。停止回执须保留到相关 `suppressed` 记录清理完成,任务停止状态继续保留。
|
||||
- 满足各自清理条件后,删除该次呼叫的 inbox、执行快照、录音上传元数据和对应 outbox 数据。按本地执行规则,清理完成后再次收到相同 `event_id`,按新执行处理;不保留永久 Dispatcher 防重记录,也不要求 SaaS 必须改用新 `event_id`。
|
||||
- **本次范围澄清:**不新增 `execution_id` 或其他外部编号;源 `event_id`(包括 numberID)在回执及最终结果中原样保留。SaaS 如何接收、判重、覆盖或区分同 ID 多次结果不在本次范围,也不作为开发和验收前置条件。本次只调整已完成数据的本地保留周期,不修改共享 Schema、消息类型、路由或发布共享合同改动;现有数据尚未清理期间的防重及未知执行保护继续有效,不保留永久完成历史。
|
||||
- 其他消息类型按各自所需回执处理:需要一份回执的,等该回执确认;需要多份的,等全部确认。
|
||||
- 活动、未知、待执行、派发状态不确定、已派发但结果 RPC 未成功或任何必需 MQ 回执未确认的数据均保留。不得把未知执行当作失败或自动释放额度。
|
||||
|
||||
## 3. 当前 SQLite 数据与清理候选
|
||||
|
||||
当前 Store 使用单个 SQLite 数据库,包含以下运行数据。代码中没有通用 `DELETE` 清理器或保留期限;本节的候选资格是**未来实现目标**,不表示现有代码已证明或执行了删除。
|
||||
|
||||
| 表/数据 | 当前用途 | 计划处理 |
|
||||
| --- | --- | --- |
|
||||
| `dispatcher_inbox` | 单次呼叫身份、状态及执行快照;快照可能重复包含任务配置和 AI 连接凭据 | 已派发呼叫满足结果 RPC 与两类回执确认后删除;明确未发起的 `rejected` 呼叫在拒绝回执确认后删除;确认未发起的 `suppressed` 记录在任务停止完成且成功停止回执确认后按任务删除。pending、活动及未知状态不删。删除后不保留 Dispatcher 永久防重 tombstone。 |
|
||||
| `dispatcher_outbox` | 呼叫接收回执、最终结果、控制回执及拒绝反馈 | 已派发呼叫须确认接收/派发回执和最终结果;未发起的拒绝只须确认拒绝回执。成功停止回执即使已确认,也不得早于相关 `suppressed` 记录清理完成而删除;其他类型按各自必需回执确认后清理。 |
|
||||
| `dispatcher_recordings` | 单次呼叫的录音上传授权/校验元数据,不存音频本体 | 与对应呼叫数据一起清理;先清依赖记录,再清 inbox。 |
|
||||
| `dispatcher_configs` | 每个任务的最新组合配置快照,更新时覆盖,不是版本历史 | 首期保留此表及现有读取/覆盖更新方式,不以删表或配置内存化作为清理前提。后续独立完成配置内存化后,再评估不再需要的持久内容;当前不能直接删除。 |
|
||||
| `dispatcher_tasks` | 任务发现状态及 pause/stop 等持久控制状态 | 保留共享任务状态;单通呼叫完成不删除。退役任务行暂不纳入首期自动清理。 |
|
||||
| `dispatcher_sip_snapshots` | 每个 Dispatcher 的当前 SIP 快照 | 不是逐次变更历史;首期保留恢复所需的 SIP 加载代次/状态。完整配置内存化后再评估是否缩减持久内容。 |
|
||||
| `dispatcher_state` | Dispatcher 当前准入、发现和 SIP 运行状态 | 保留,不作为历史数据清理。 |
|
||||
|
||||
### 本地文件清理候选
|
||||
|
||||
以下文件不属于 SQLite 表。首期只自动清理已完成交付的录音恢复文件和已确认拒绝的原始数据;临时文件仅记录发现,不自动清理。可删除对象仍须满足各自完成条件,不按目录整体或文件年龄清空。
|
||||
|
||||
| 文件/目录 | 当前行为 | 计划处理 |
|
||||
| --- | --- | --- |
|
||||
| Agent 录音恢复目录内的 `recording.wav`、`info.json` | 上传失败后保存;恢复上传和结果上报成功后只写入 `delivered`,仍保留文件 | 原上传与结果交付成功已持久确认、且无未完成恢复引用后,删除该条录音和恢复信息,再删除空的自有末级目录;保留共享目录及其他记录。 |
|
||||
| Dispatcher `rejected-data/*.json` | 在拒绝回执写入 SQLite 前保存原始消息及反馈,当前没有删除路径 | 准确关联的拒绝回执已持久确认且无恢复引用后删除;原始文件清理完成前保留该回执的确认依据。 |
|
||||
| `.pending-*`、`.rpc-journal-*` 等已知写入路径的临时文件 | 正常退出通过 `defer` 删除,异常终止可能遗留 | 沿用正常写入收尾的删除逻辑;首期对遗留文件仅发现、报告并保留,不新增自动识别/删除或专用扫描任务。前缀、时间、扩展名均不构成删除依据。 |
|
||||
|
||||
Agent `.results` 成功上报后的文件、已处理完成的 SIP 来源文件已有删除路径,继续沿用并做回归验证,不再增加平行清理器。`.approved` 一次性退役仍按第 7 节的独立范围执行,不能借此删除上述恢复文件。
|
||||
|
||||
核查位置:`internal/agent/recording_retry.go`、`internal/agent/recording_recovery.go`、`internal/agent/recording_scan.go`、`internal/store/rejected_data.go`、`internal/store/private_data.go`、`internal/rpc/session_journal.go`、`internal/agent/result_journal.go`、`internal/store/sip_source.go`。
|
||||
|
||||
## 4. 配置缓存调整(后续独立阶段)
|
||||
|
||||
本节保留后续调整方向,不属于首期清理交付条件。首期保留现有配置读取与持久方式,按单次执行删除满足条件的历史快照即可;不为清理先重写启动、配置变更和准入路径。后续另行安排配置内存化时再实施并验证下列规则:
|
||||
|
||||
- 启动时完整读取 AI 服务商、SIP、任务及租户额度配置,保持准入关闭,完成必要的 Agent/Asterisk SIP 加载核验后再开放准入。
|
||||
- 运行中的配置更新按现有事件触发读取并更新内存快照。
|
||||
- SQLite 保留 pause/stop、未知执行、SIP 内部加载代次等不能由当前配置重建的运行状态。
|
||||
- 单次未完成呼叫所需的执行快照可暂时随该次执行记录保存;呼叫完成并满足清理门槛后删除。不得把它扩展成历史配置档案。
|
||||
|
||||
## 5. 清理流程
|
||||
|
||||
### 实时清理
|
||||
|
||||
每当 Agent 结果 RPC 成功、相关 MQ publisher confirm 成功,或拒绝回执 publisher confirm 成功时,检查该单次执行对应终态的清理条件;成功停止回执获 confirm 时,另外触发下述按任务清理:
|
||||
|
||||
- 已派发呼叫:Agent 结果 RPC 成功返回,且接收/派发回执与最终结果分别获 confirm。
|
||||
- 已明确拒绝且确认未发起的呼叫:拒绝回执获 confirm 即可清理;不等待 Agent 结果 RPC 或最终结果事件。
|
||||
- pending、dispatching、dispatched 但无已确认最终结果、unknown 等未满足终态条件的记录继续保留。
|
||||
|
||||
满足条件后,在事务中删除该执行及关联记录。接收/派发回执与最终结果是不同 outbox 消息,必须分别确认。拒绝回执是另一条消息类型,其 confirm 不应被误判为最终结果 confirm。
|
||||
|
||||
### 任务停止后的 `suppressed` 清理
|
||||
|
||||
当前 `PrepareControl(stop)` 会把尚未发起的 pending 记录改为 `suppressed`;拨号前取消占用也可能在任务停止期间产生此状态。它不等于逐通 `rejected`,没有必须等待的 Agent 最终结果或逐通拒绝回执。
|
||||
|
||||
清理须同时满足:
|
||||
|
||||
1. 记录属于本 Dispatcher、租户和任务,状态为 `suppressed`,且确认未发起呼叫。
|
||||
2. 所属任务的持久控制状态为 `stopped`,不是仅收到 stop 命令或仍在 `stopping`。停止流程中的队列清空、Agent 控制处理或状态保存失败时,不满足此条件。
|
||||
3. 同一 Dispatcher、租户和任务的成功停止回执(`task.control`,`action=stop`、`status=applied`、`task_state=stopped`)已持久记录 publisher confirm;不以其他任务、其他动作或拒绝回执代替。
|
||||
|
||||
执行与恢复规则:
|
||||
|
||||
- 成功停止回执确认后立即触发;每日 24:00 复用同一条件补扫。只删除符合条件的 `suppressed` 记录及其自有、已满足交付条件的关联数据,不按整个任务级联删除呼叫。
|
||||
- 停止回执不得被通用 outbox 清理提前删除。清理时在同一事务中复核条件、删除符合条件的取消记录;确认相关取消记录已全部清理后,才可一并删除该停止回执。数量较大需分批时,在最后一批完成前保留停止回执,供中断或重启后继续核验。
|
||||
- 复用现有按任务串行处理方式,协调停止、待拨处理与清理。执行前须在该任务的串行保护内重新检查已取出的待拨条目;已取消或因获批清理而删除的条目不再发起呼叫,数据库读取错误仍明确报错,不能当作已清理而跳过。
|
||||
- 保留任务的持久停止状态,不因删除取消记录重新开放任务。该任务中的活动、未知或其他未满足自身清理条件的呼叫继续保留,但不阻塞已经符合条件的 `suppressed` 记录清理;停止成功不等于全部活动呼叫已经结束。
|
||||
- 若发现与“未发起”矛盾的执行、录音或结果证据,停止删除对应记录并明确报错,不强行清除。事务失败回滚该批,保留尚未清理的记录与停止回执,记录任务身份、删除数量、跳过原因和错误,供后续重试。
|
||||
- `stopping`、停止失败或成功停止回执未确认时继续保留,等待真实停止流程恢复;不按记录年龄强制清理,不伪造停止成功或逐通最终结果。
|
||||
|
||||
### 已完成交付的 Agent 录音恢复文件
|
||||
|
||||
- 清理条件是:原上传已确认成功、原最终结果 RPC 成功返回、`delivered` 状态已持久保存,且不存在仍需使用这些文件的未完成恢复引用。仅上传成功或内存状态已改变不足以删除;`retry_pending`、`put_in_flight`、`outcome_unknown`、`uploaded_unreported`、`expired` 及损坏记录继续保留。
|
||||
- 在持久记录 `delivered` 后触发清理。以该条记录为单位协调恢复、写入与删除,先删除 `recording.wav` 并确保持久完成,再删除作为完成依据的 `info.json`,最后只删除确实为空且属于该条记录的末级目录。不递归删除恢复根目录、共享 bucket 目录或其他调用的文件,不触碰 OSS 对象。
|
||||
- 文件删除不是一个可回滚的数据库事务。若中途失败或重启,保留尚在的 `delivered` 信息,后续只继续删除,不能重新 PUT 或重报已确认结果。恢复扫描须能核验持久 `delivered` 后处理“音频已删、信息仍在”的合法中间状态,不能先要求音频完整而把清理永远阻断;非 `delivered` 缺音频仍是错误。只在该已确认清理路径中将目标已不存在视为完成,其他读取、权限和删除错误必须报出。
|
||||
- 此项会改变现有“恢复成功后仍保留录音”的行为,须同步调整对应测试;不把当前实现或仅有 `delivered` 写入说成已经完成清理。
|
||||
|
||||
### Dispatcher 拒绝原始数据文件
|
||||
|
||||
- 按持久回执身份及保存的反馈内容准确关联文件;文件内容校验通过、对应拒绝回执已获 publisher confirm,且无未完成执行或恢复引用,才可清理。不能只凭相同 `event_id`、文件名摘要或目录位置认定已交付。
|
||||
- 在该拒绝回执确认后触发。相关原始文件尚未清理时,通用 outbox 清理不得先删除作为依据的回执;所有关联文件删除并持久完成后,才按其自身条件删除回执。协调同身份消息处理与清理,避免在复核和删除之间覆盖新的未确认事实。
|
||||
- 若文件已删但回执删除失败或进程退出,持久确认的原回执仍须支持继续收尾;若文件删除失败,保留回执和剩余文件,明确报错后重试。不得声称 SQLite 回滚能够恢复已删除的文件。
|
||||
- 仅原始文件落盘、但拒绝回执事务失败,或回执缺失、未确认、关联冲突的文件,不能按“已交付”删除;保留并报告,等待原处理恢复或另行人工处置,不用年龄兜底。
|
||||
|
||||
### 遗留临时文件(首期仅发现并报告)
|
||||
|
||||
- 保留 `persistPrivateData` 的 `.pending-*`、`writeRPCJournal` 的 `.rpc-journal-*` 在正常写入结束时删除自身临时文件的现有逻辑。
|
||||
- 异常退出遗留属于低频问题,证明其已提交且无人引用所需的自动判断不纳入首期。现有启动、恢复或写入检查发现遗留时,记录所属位置和原因并保留,供另行人工核验;不新增全目录扫描、正式状态匹配机制或定时删除任务,不因文件名或年龄删除。
|
||||
- 实际残留规模证明有必要后,再单独安排自动清理方案。首期不改变现有文件校验、错误报告和旧 `.executions` 等状态的拒绝启动规则,也不删除会话主文件或待提交状态。
|
||||
|
||||
### 每日 24:00 清理
|
||||
|
||||
重查已终结且所有必需确认均已记录、但实时清理未完成的执行,并按上述任务条件补扫 `suppressed` 记录;同时按各自条件补扫已交付录音和已确认拒绝原始文件,分批清理。首期不扫描或删除遗留临时文件。复用各自的资格校验和失败续处理,不增加独立按年龄删文件的定时脚本,也不把现有每 10 秒恢复循环改为全目录清理定时任务。清理失败或条件未满足时保留剩余数据及完成依据,记录对象身份、扫描/删除数量、释放字节数、跳过原因和错误,不记录文件正文;等待恢复、实时条件满足或下一次 24:00 扫描,不丢弃未确认消息。
|
||||
|
||||
不实施每日 SQLite 文件轮转,不实施 12:00 清理,也不按呼叫年龄删除 pending/unknown 数据。
|
||||
|
||||
## 6. 实施阶段与验证
|
||||
|
||||
以下为首期步骤;配置内存化和遗留临时文件自动清理不列为前置任务,也不阻塞首期验收。
|
||||
|
||||
1. **梳理依赖与清理条件:**确认各消息类型所需的 RPC/MQ 回执、首期 per-call 数据行及本地文件的关联关系、完成依据与删除顺序;不能准确关联的文件不具备自动清理资格。不改共享任务状态或当前配置读取方式。
|
||||
2. **停止 Agent 标记增长:**移除 `.approved` 永久排重文件的产生路径,保留活动期内存保护及 Dispatcher 未决执行不重发规则;现存目录仅按第 7 节另获授权后受控退役。
|
||||
3. **实时清理:**增加按单次执行分组的确认状态检查;最后一个必需确认完成后删除关联数据。对 `suppressed` 增加按任务的停止状态与停止回执确认检查,协调取消记录和停止回执的删除顺序。按第 5 节补齐已交付录音和已确认拒绝原始文件的清理;文件未清完前保留相应完成依据。清理必须可重复执行。
|
||||
4. **24:00 补扫:**增加单一午夜调度,复用同一清理资格检查;记录扫描数、清理数、跳过原因和错误。
|
||||
5. **验收:**覆盖任一必需确认缺失时不清理;派发完成后全部条件满足只清理该次呼叫;明确未发起的拒绝仅在拒绝回执 confirm 后清理且不等待最终结果;共享 `task_id` 状态保留;pending/unknown 保留;清理中断可重试;清理后相同 `event_id` 可在 Dispatcher 重新接纳并由 Agent 执行。验证 Agent 执行不再创建 `.approved` 文件、相同身份不再被永久文件门闩拒绝,同时保留活动期内存并发保护和 Dispatcher 不重发 dispatching/unknown 执行的规则;另验证现存目录退役范围、私密备份校验和相邻 Agent/Dispatcher 状态未被删除。完成 `go vet`、`go test -race`、构建、隔离 MQ 确认测试及业务覆盖率不低于 65%。
|
||||
|
||||
`suppressed` 专项验收还须覆盖:
|
||||
|
||||
- 大量待拨记录在任务停止完成、成功停止回执确认后清空;不产生额外逐通拒绝或最终结果回执。
|
||||
- 任务仍为 `stopping`、队列清空或 Agent 控制失败、停止状态保存失败、回执未确认时不删除。
|
||||
- 通用 outbox 清理不能提前删除用于核验的停止回执;分批中断、事务失败或重启后,实时触发或 24:00 补扫能继续完成,重复清理无副作用。
|
||||
- 其他任务、同任务活动及未知呼叫不受影响;任务停止状态仍保留,且活动或未知呼叫不阻塞已经符合条件的取消记录清理。
|
||||
- 停止、待拨处理与清理并发时不拨出已取消条目;真实数据库错误明确暴露,与“未发起”矛盾的证据阻止对应删除。
|
||||
|
||||
本地文件专项验收还须覆盖:
|
||||
|
||||
- 原录音上传、结果上报及 `delivered` 持久化都成功后清除该条音频、恢复信息和空末级目录;任一条件缺失、仍有恢复引用或状态未知/过期时不删,其他记录及 OSS 对象不受影响。
|
||||
- 音频删除后、恢复信息删除前中断或重启,仍能依据持久完成状态继续清理,不重新上传或重报结果;删除失败保持完成依据且可重试,非已交付记录缺音频仍明确报错。
|
||||
- 拒绝原始文件仅在正确关联回执确认后删除;通用 outbox 清理不能先丢弃依据。覆盖一份回执关联多份原始文件、仅落盘但回执事务失败、文件删除中断和回执删除失败,确保未确认或关联不明的数据保留。
|
||||
- 临时文件仅做现有行为回归:正常写入收尾仍删除自身临时文件;异常遗留、正在写入、唯一未提交副本及未知文件均不会被新增清理路径或 24:00 补扫误删,现有检查发现的问题可见。首期不要求实现或验收自动冗余识别。
|
||||
- 文件删除与恢复/写入并发时不误删;24:00 补扫可完成遗漏,重复执行无副作用;已存在的 `.results` 和 SIP 来源文件成功清理行为不退化,不新增永久清理历史或按年龄删除未知记录。
|
||||
|
||||
共享合同仍按 `docs/contracts.md` 执行 `make contracts-update` 和固定引用核验,不为本次内部清理新增接口字段或发布合同改动。SaaS 对重复结果的处理不属于本次验收,不宣称 SaaS 已收到或能区分多次执行结果。若改动 SQLite 表结构,按项目规则评估新的数据库布局版本;不自动迁移或重置现有数据库。
|
||||
|
||||
## 7. Agent 本地恢复与全局停接边界(只读核查)
|
||||
|
||||
以下是 2026-10-10 对已同步本地源码(`main`,`6ab297c`)的核查结论,不代表主机运行态验证。Agent 不直接消费业务 MQ,Dispatcher 才是全局任务接纳入口。
|
||||
|
||||
### 同一 `event_id` 重用与 Agent 执行标记(新增代码核查)
|
||||
|
||||
- Dispatcher 是当前持久去重及派发状态的所有者:`RecordExecute` 先按 `(dispatcher_id,event_id)` 查重;`ReserveExecute` 在 Agent RPC 前写入 `dispatching`;`ListPendingExecute` 只返回 `status='pending'`。Agent RPC 出错后 Dispatcher 将明确未发起的调用记为 `rejected`,其余不确定结果记为 `unknown`;`dispatching`/`unknown` 不会由 `ProcessPending` 重发。因此当前 Dispatcher 不会因普通重试再次向 Agent 下发同一未决执行。
|
||||
- Agent 的 `internal/rpc/approved_execution.go` 另写入 `StatePath + ".approved"` 下的永久身份文件:文件只存请求摘要及 `unknown` 状态,按 Dispatcher/tenant/task/event 身份命名,在执行回调之前以 `O_EXCL` 创建;同身份再次调用一律拒绝为“prior outcome unknown”。现有代码没有把它更新为完成/拒绝状态,也没有清理路径。它不是 `ApprovedCallWorker` 自身的去重表,而是 Agent RPC 服务前置的一次性持久门闩。
|
||||
- Agent `TaskCalls.Register` 还有活动期内的内存保护,可阻止同一 `call_id` 并发登记;它随呼叫结束释放,不是永久身份历史。
|
||||
- 每个通过校验并进入记录步骤的首次 `ExecuteApproved` 身份,都会在 `.approved` 下新增一个 JSON 文件;即使随后在拨号前被拒绝,或最终成功结束,记录都只保持 `unknown`,当前没有更新或删除路径。因此文件数和目录 inode 随此类请求数持续增长。这是文件目录,不是 Agent SQLite。
|
||||
- 本地目标是:Dispatcher 按当前保留的 inbox 状态负责持久防重和未知执行不重试;Agent 不保留生命周期永久的 `.approved` 身份门闩,避免 SQLite 清理后同一 `event_id` 仍被 Agent 永久拒绝。实现时移除 `approvedJournalDir`、`recordApprovedAttempt` 及其目录创建、文件写入/同步逻辑;不以 Agent SQLite 或其他文件日志替代。保留 `TaskCalls` 的活动期内存并发保护,并验证未决执行仍由 Dispatcher 持久状态阻止重发。
|
||||
- 同 ID 清理后重用是本地执行规则,不要求 SaaS 改号或增加字段;SaaS 对再次返回的同 ID 结果如何处理不属于本次范围。本项目只验证清理后重新接纳和执行、源 ID 原样回传,以及未清理的未决执行仍受保护,不声称外部结果消费或防重已验证。
|
||||
|
||||
### 现存 `.approved` 目录一次性退役
|
||||
|
||||
此处规定的是未来实施步骤,不授权现在删除目录或文件:
|
||||
|
||||
1. 先停止 Agent 并核对 Asterisk 当前没有活动通道;服务停止或活动通道核验失败时保留目录并中止退役。
|
||||
2. 在 Agent 停止期间部署不再创建/读取 `.approved` 标记的版本;先用隔离测试验证该版本不依赖该目录。测试失败则不删除现存目录。
|
||||
3. 对 `StatePath + ".approved"` 单独生成清单(相对文件名、所有者/权限、大小、SHA-256)和私密原字节备份;逐项核对备份与清单后,才可移除该目录。备份失败、内容不符或目录归属/权限异常时停止,不扩大删除范围。目录不存在时记录为空操作,不创建目录。
|
||||
4. 删除范围仅限该 `.approved` 目录。不得删除或遍历清理 `StatePath` 主文件、`.results`、`.executions`、`.uploads`、`.upload-locks`、Dispatcher SQLite、录音或其他状态;Dispatcher 中的 unknown/active 占用照常保留,不借退役 Agent 标记释放。
|
||||
5. 启动新 Agent 后验证 `.approved` 未重新创建且相邻状态未变;目标目录已按授权退役,备份清单可核对。实际执行该删除仍须另行取得部署/数据处置授权。
|
||||
|
||||
| 故障 | 当前影响范围与行为 |
|
||||
| --- | --- |
|
||||
| 单条结果恢复文件损坏、单条录音恢复文件无效、授权/结果上报失败、上传超过 48 小时或 PUT 结果未知 | 仅阻塞对应恢复记录。`ResultJournal.RecoverWithEnd` 和 `RecordingRecovery.RecoverDue` 记录并累积单条错误,继续扫描其他独立记录;后台恢复循环每 10 秒再次运行。未知 PUT 不重放,过期/未知文件保留待检查。恢复根目录或遍历本身出错可中止本轮扫描,但不会由该后台循环关闭 Agent RPC 服务或 Dispatcher 准入。 |
|
||||
| Agent 已接纳的单次呼叫执行返回错误 | `ApprovedCallWorker` 记录该次错误并调用单次失败回报;失败回报本身也只记录错误,不主动停止 Agent 服务。未处理的执行 goroutine panic 则可能终止整个 Agent 进程,属于进程级故障。 |
|
||||
| Agent 收到某任务的 pause/stop | 只关闭对应 Dispatcher/tenant/task 身份的任务接纳,并处理该任务的活动呼叫,不暂停其他任务。 |
|
||||
| Agent 启动/服务级故障 | 可能影响全部呼叫:配置、身份、证书或监听失败会阻止服务启动;RecoveryRoot 权限不符或遗留 `.executions`、`.uploads`、`.upload-locks` 等旧 spool 需要人工处置,也会使服务拒绝启动。运行中的 gRPC 监听故障,或 `nonprod-real` 模式下必需 HEP 监听故障,会停止 Agent 服务;单个 HEP 帧被拒绝则只记录错误。 |
|
||||
| Dispatcher 共享就绪/会话故障 | 影响全局新接纳:启动/SIP 核验未完成时准入保持关闭;Agent 会话续期失败会关闭 Dispatcher 准入并取消运行上下文。 |
|
||||
| Dispatcher 重试单条 pending 呼叫时发生错误 | **当前代码会升级为 Dispatcher 运行级故障**:`processPending` 遇到第一条 `dispatchPending` 错误即返回;`Runtime.Serve` 随后退出并在清理时关闭全局准入。Agent 对单通呼叫明确拒绝或结果未知时,Dispatcher 虽先持久化拒绝/未知状态,仍会返回错误并走到这条路径。任务级校验/读取等错误也可能触发;规则窗口尚未开放(`ErrRuleWait`)、容量不足等明确等待结果则不会以错误退出。 |
|
||||
|
||||
这一区分说明:单条 Agent 恢复文件等待人工检查,本身不会暂停其他业务;但 Agent 对单通呼叫的同步拒绝/未知结果,经 Dispatcher 当前错误传播路径,确实可能关闭所有任务的准入。代码核查没有验证 systemd 是否重启及重启耗时,因此不把进程退出说成永久停接,也不假设重启必然发生。
|
||||
|
||||
对本清理计划的约束:
|
||||
|
||||
- 不为 Agent 增加按年龄删文件的独立定时脚本;成功路径按呼叫生命周期清理,异常、未知、过期、未确认结果继续保留。
|
||||
- 清理/恢复应按单条记录隔离;单条损坏或失败不得被清理器转化成全局准入关闭。根目录或扫描故障须保留数据、记录原因并重试,不得静默跳过或扩大删除范围。
|
||||
- 不在本计划中改写 Dispatcher 当前“单条 pending 错误导致运行退出”的行为。若要改变该全局错误升级策略,须另行明确安全边界与处理方式,不能靠吞错或将未知执行当作失败。
|
||||
- 后续实施需分别验证:一个坏记录不阻止同一扫描处理其他独立记录;过期/未知记录仍保留;恢复错误不关闭 Agent RPC 服务;单通错误与共享会话/服务故障的影响范围可观测。
|
||||
|
||||
核查位置:`cmd/sip-go-agent/agent_recovery.go`、`cmd/sip-go-agent/agent_real.go`、`cmd/sip-go-agent/agent_setup.go`、`cmd/sip-go-agent/agent_command.go`、`cmd/sip-go-agent/dispatcher_session.go`、`cmd/sip-go-agent/dispatcher_command.go`、`internal/agent/result_journal.go`、`internal/agent/recording_scan.go`、`internal/agent/recording_retry.go`、`internal/agent/task_calls.go`、`internal/rpc/approved_worker.go`、`internal/dispatcher/runtime.go`、`internal/dispatcher/execute.go`。
|
||||
|
||||
## 8. 明确不在本计划内
|
||||
|
||||
- 删除活动、未知、pending 或未确认的执行/回执。
|
||||
- 对 SaaS 做应用层回执等待。
|
||||
- 增加永久 `event_id` 防重表、消息年龄过期规则或 Agent 二次确认协议。
|
||||
- 定时轮转整库或自动执行 `VACUUM`。SQLite 删除后可复用空闲页,但物理文件可能保持历史高水位;如运行数据证明需要,再单独制定压缩维护方案。
|
||||
@@ -2,14 +2,10 @@ package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
|
||||
@@ -20,7 +16,6 @@ import (
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// ApprovedExecution is the frozen Agent-side call handed to the isolated
|
||||
@@ -71,9 +66,9 @@ func (s *Server) GetLoadedSIP(ctx context.Context, req *agentpb.GetLoadedSIPRequ
|
||||
return &agentpb.GetLoadedSIPResponse{TrunkRevision: copyOfLoaded}, nil
|
||||
}
|
||||
|
||||
// ExecuteApproved is mock-only until real Agent/Asterisk loading and supplier
|
||||
// contracts have been separately verified. It persists a one-shot unknown
|
||||
// execution BEFORE calling the selected adapter. Ambiguous attempts never redial.
|
||||
// ExecuteApproved validates the frozen call before handing it to the selected
|
||||
// adapter. Dispatcher owns durable protection for unfinished executions; the
|
||||
// Agent worker prevents overlapping active calls without permanent markers.
|
||||
// approvedNoIssueError is only used when the Agent can prove it has not
|
||||
// submitted an ARI originate. A bare gRPC status must never free quota:
|
||||
// replays of an earlier unknown attempt can return FailedPrecondition too.
|
||||
@@ -148,9 +143,6 @@ func (s *Server) ExecuteApproved(ctx context.Context, req *agentpb.ExecuteApprov
|
||||
if err := s.requireApprovedSIP(ctx, req.SelectedTrunkId, req.SipRevision); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.recordApprovedAttempt(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.DialBeforeUnixMs <= s.now().UnixMilli() {
|
||||
return nil, status.Error(codes.DeadlineExceeded, "Dispatcher dial authorization expired before issuing")
|
||||
}
|
||||
@@ -195,89 +187,3 @@ func (s *Server) requireApprovedSIP(ctx context.Context, trunk string, revision
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type approvedAttempt struct {
|
||||
Digest string `json:"digest"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// recordApprovedAttempt writes only a digest. The request body (including
|
||||
// plaintext provider credentials) is never placed in the Agent journal.
|
||||
func (s *Server) recordApprovedAttempt(req *agentpb.ExecuteApprovedRequest) error {
|
||||
if s.approvedJournalDir == "" {
|
||||
return status.Error(codes.FailedPrecondition, "durable Agent execution directory is required")
|
||||
}
|
||||
copyOfRequest := proto.Clone(req).(*agentpb.ExecuteApprovedRequest)
|
||||
copyOfRequest.Meta = nil // a refreshed session must not change call identity
|
||||
body, err := (proto.MarshalOptions{Deterministic: true}).Marshal(copyOfRequest)
|
||||
if err != nil {
|
||||
return status.Error(codes.Internal, "compute approved execution digest")
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
key, err := json.Marshal(struct {
|
||||
Dispatcher string `json:"dispatcher"`
|
||||
Tenant int64 `json:"tenant"`
|
||||
Task string `json:"task"`
|
||||
Event string `json:"event"`
|
||||
}{req.DispatcherId, req.TenantId, req.TaskId, req.SourceEventId})
|
||||
if err != nil {
|
||||
return status.Error(codes.Internal, "compute approved execution identity")
|
||||
}
|
||||
identity := sha256.Sum256(key)
|
||||
path := filepath.Join(s.approvedJournalDir, hex.EncodeToString(identity[:])+".json")
|
||||
record, err := json.Marshal(approvedAttempt{Digest: hex.EncodeToString(digest[:]), State: "unknown"})
|
||||
if err != nil {
|
||||
return status.Error(codes.Internal, "serialize approved execution identity")
|
||||
}
|
||||
|
||||
s.approvedMu.Lock()
|
||||
defer s.approvedMu.Unlock()
|
||||
if err := os.MkdirAll(s.approvedJournalDir, 0700); err != nil {
|
||||
return status.Errorf(codes.Internal, "create Agent execution directory: %v", err)
|
||||
}
|
||||
if err := syncApprovedDir(filepath.Dir(s.approvedJournalDir)); err != nil {
|
||||
return status.Errorf(codes.Internal, "sync Agent execution parent directory: %v", err)
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
existingBytes, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return status.Error(codes.FailedPrecondition, "prior Agent execution state is unreadable")
|
||||
}
|
||||
var existing approvedAttempt
|
||||
if json.Unmarshal(existingBytes, &existing) != nil || existing.State != "unknown" || existing.Digest == "" {
|
||||
return status.Error(codes.FailedPrecondition, "prior Agent execution state is incomplete")
|
||||
}
|
||||
if existing.Digest != hex.EncodeToString(digest[:]) {
|
||||
return status.Error(codes.AlreadyExists, "execution identity has conflicting immutable content")
|
||||
}
|
||||
return status.Error(codes.FailedPrecondition, "prior Agent execution outcome is unknown; no redial")
|
||||
}
|
||||
if err != nil {
|
||||
return status.Errorf(codes.Internal, "create Agent execution identity: %v", err)
|
||||
}
|
||||
if _, err := file.Write(record); err != nil {
|
||||
_ = file.Close()
|
||||
return status.Errorf(codes.Internal, "persist Agent execution identity: %v", err)
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
_ = file.Close()
|
||||
return status.Errorf(codes.Internal, "sync Agent execution identity: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return status.Errorf(codes.Internal, "close Agent execution identity: %v", err)
|
||||
}
|
||||
if err := syncApprovedDir(s.approvedJournalDir); err != nil {
|
||||
return status.Errorf(codes.Internal, "sync Agent execution directory: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncApprovedDir(path string) error {
|
||||
dir, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = dir.Sync()
|
||||
return errors.Join(err, dir.Close())
|
||||
}
|
||||
|
||||
@@ -3,10 +3,8 @@ package rpc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -119,70 +117,8 @@ func TestExecuteApprovedRealModeNeverUsesMockAdapter(t *testing.T) {
|
||||
if err != nil || response == nil || !response.Accepted || attempts != 1 {
|
||||
t.Fatalf("real mode should dispatch exactly once through its adapter: response=%v err=%v attempts=%d", response, err, attempts)
|
||||
}
|
||||
if _, err := s.ExecuteApproved(context.Background(), req); status.Code(err) != codes.FailedPrecondition || attempts != 1 {
|
||||
t.Fatalf("real mode must not redial an accepted identity: err=%v attempts=%d", err, attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteApprovedBindsConfigAndPreventsRedialAcrossRestart(t *testing.T) {
|
||||
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
||||
req := approvedTestRequest(t, now)
|
||||
statePath := filepath.Join(t.TempDir(), "agent-session.json")
|
||||
attempts := 0
|
||||
originate := func(context.Context, ApprovedExecution) error {
|
||||
attempts++
|
||||
return errors.New("mock originated; outcome unknown")
|
||||
}
|
||||
s := activatedApprovedServer(t, now, statePath, req.DispatcherId, 1, originate)
|
||||
if _, err := s.ExecuteApproved(context.Background(), req); err == nil || attempts != 1 {
|
||||
t.Fatalf("first attempt must preserve unknown outcome: attempts=%d err=%v", attempts, err)
|
||||
}
|
||||
if _, err := s.ExecuteApproved(context.Background(), req); status.Code(err) != codes.FailedPrecondition || attempts != 1 {
|
||||
t.Fatalf("repeat must not redial: attempts=%d err=%v", attempts, err)
|
||||
}
|
||||
restarted := activatedApprovedServer(t, now, statePath, req.DispatcherId, 2, originate)
|
||||
req.Meta.SessionGeneration = 2
|
||||
if _, err := restarted.ExecuteApproved(context.Background(), req); status.Code(err) != codes.FailedPrecondition || attempts != 1 {
|
||||
t.Fatalf("restart must retain unknown outcome: attempts=%d err=%v", attempts, err)
|
||||
}
|
||||
entries, err := os.ReadDir(statePath + ".approved")
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("one durable execution identity expected: %d %v", len(entries), err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(statePath+".approved", entries[0].Name()))
|
||||
if err != nil || strings.Contains(string(body), "example-only-not-a-real-secret") {
|
||||
t.Fatalf("journal must not persist raw provider credentials: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteApprovedJournalWriteFailureCannotReplayFromMemory(t *testing.T) {
|
||||
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
||||
req := approvedTestRequest(t, now)
|
||||
state := filepath.Join(t.TempDir(), "agent-session.json")
|
||||
attempts := 0
|
||||
s := activatedApprovedServer(t, now, state, req.DispatcherId, 1, func(context.Context, ApprovedExecution) error {
|
||||
attempts++
|
||||
return errors.New("mock originated; outcome unknown")
|
||||
})
|
||||
blocker := state + ".approved"
|
||||
if err := os.WriteFile(blocker, []byte("not a directory"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
response, err := s.ExecuteApproved(context.Background(), req)
|
||||
if err == nil || response != nil || attempts != 0 {
|
||||
t.Fatalf("failed journal write acknowledged or originated call: attempt=%d response=%v err=%v", i, response, err)
|
||||
}
|
||||
}
|
||||
if err := os.Remove(blocker); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := s.ExecuteApproved(context.Background(), req)
|
||||
if err == nil || response != nil || attempts != 1 {
|
||||
t.Fatalf("recovered journal must originate only once: attempts=%d response=%v err=%v", attempts, response, err)
|
||||
}
|
||||
if _, err := s.ExecuteApproved(context.Background(), req); status.Code(err) != codes.FailedPrecondition || attempts != 1 {
|
||||
t.Fatalf("unknown outcome must not redial: attempts=%d err=%v", attempts, err)
|
||||
if response, err := s.ExecuteApproved(context.Background(), req); err != nil || response == nil || !response.Accepted || attempts != 2 {
|
||||
t.Fatalf("finished identity must not have a permanent Agent barrier: response=%v err=%v attempts=%d", response, err, attempts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ func mockApprovedGRPC(t *testing.T, server *Server) agentpb.AgentControlServiceC
|
||||
return agentpb.NewAgentControlServiceClient(conn)
|
||||
}
|
||||
|
||||
func TestApprovedDispatcherToAgentUnaryMockRetainsSnapshotAndOneShotCall(t *testing.T) {
|
||||
func TestApprovedDispatcherToAgentUnaryMockRetainsSnapshotWithoutPermanentMarker(t *testing.T) {
|
||||
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
||||
req := approvedTestRequest(t, now)
|
||||
calls := 0
|
||||
@@ -82,7 +82,7 @@ func TestApprovedDispatcherToAgentUnaryMockRetainsSnapshotAndOneShotCall(t *test
|
||||
if err := orig.Originate(context.Background(), spec); err != nil || calls != 1 {
|
||||
t.Fatalf("approved mock call was not accepted once: calls=%d err=%v", calls, err)
|
||||
}
|
||||
if err := orig.Originate(context.Background(), spec); err == nil || calls != 1 {
|
||||
t.Fatalf("duplicate unary call must not redial: calls=%d err=%v", calls, err)
|
||||
if err := orig.Originate(context.Background(), spec); err != nil || calls != 2 {
|
||||
t.Fatalf("finished identity must be reusable without a permanent Agent marker: calls=%d err=%v", calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
|
||||
"git.ipao.vip/rogee/go-sip/internal/agent"
|
||||
)
|
||||
|
||||
func TestExecuteApprovedDoesNotCreatePermanentMarker(t *testing.T) {
|
||||
now := time.Date(2026, 10, 10, 10, 0, 0, 0, time.UTC)
|
||||
req := approvedTestRequest(t, now)
|
||||
state := filepath.Join(t.TempDir(), "agent-session.json")
|
||||
attempts := 0
|
||||
server := activatedApprovedServer(t, now, state, req.DispatcherId, 1, func(context.Context, ApprovedExecution) error {
|
||||
attempts++
|
||||
return nil
|
||||
})
|
||||
for i := 0; i < 2; i++ {
|
||||
response, err := server.ExecuteApproved(context.Background(), req)
|
||||
if err != nil || response == nil || !response.Accepted {
|
||||
t.Fatalf("completed identity must be reusable: attempt=%d response=%v err=%v", i, response, err)
|
||||
}
|
||||
if _, err := os.Lstat(state + ".approved"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("execution created a permanent marker: %v", err)
|
||||
}
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("permanent identity blocked new execution: attempts=%d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteApprovedDoesNotReadOrMutateRetiredMarker(t *testing.T) {
|
||||
now := time.Date(2026, 10, 10, 10, 0, 0, 0, time.UTC)
|
||||
for _, directory := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "file", true: "directory"}[directory], func(t *testing.T) {
|
||||
state := filepath.Join(t.TempDir(), "agent-session.json")
|
||||
marker := state + ".approved"
|
||||
if directory {
|
||||
if err := os.Mkdir(marker, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker = filepath.Join(marker, "retired.json")
|
||||
}
|
||||
original := []byte("retired marker must remain untouched")
|
||||
if err := os.WriteFile(marker, original, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attempts := 0
|
||||
originate := func(context.Context, ApprovedExecution) error { attempts++; return nil }
|
||||
for generation := uint64(1); generation <= 2; generation++ {
|
||||
req := approvedTestRequest(t, now)
|
||||
req.Meta.SessionGeneration = generation
|
||||
server := activatedApprovedServer(t, now, state, req.DispatcherId, generation, originate)
|
||||
if response, err := server.ExecuteApproved(context.Background(), req); err != nil || response == nil || !response.Accepted {
|
||||
t.Fatalf("retired marker blocked execution: generation=%d err=%v", generation, err)
|
||||
}
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("retired marker affected execution: attempts=%d", attempts)
|
||||
}
|
||||
retained, err := os.ReadFile(marker)
|
||||
if err != nil || string(retained) != string(original) {
|
||||
t.Fatalf("retired data changed: %v", err)
|
||||
}
|
||||
if directory {
|
||||
entries, err := os.ReadDir(state + ".approved")
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("execution added permanent markers: entries=%d err=%v", len(entries), err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// This exercises the documented operator-only retirement in an isolated
|
||||
// directory. The runtime never backs up, removes or migrates existing markers.
|
||||
func TestApprovedMarkerRetirementIsolatedBackupAndDeletionBoundary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
state := filepath.Join(root, "agent-session.json")
|
||||
neighbors := map[string][]byte{
|
||||
state: []byte(`{"generations":{}}`),
|
||||
state + ".results/retained.json": []byte("pending result"),
|
||||
state + ".executions": []byte("unknown historical execution"),
|
||||
filepath.Join(root, "recordings/.uploads/retained.wav"): []byte("pending audio"),
|
||||
filepath.Join(root, "recordings/.upload-locks/retained"): []byte("upload claim"),
|
||||
filepath.Join(root, "dispatcher.sqlite"): []byte("durable dispatcher state"),
|
||||
}
|
||||
for path, body := range neighbors {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, body, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
markerDir := state + ".approved"
|
||||
if err := os.Mkdir(markerDir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := filepath.Join(markerDir, "retired.json")
|
||||
original := []byte(`{"digest":"example-only","state":"unknown"}`)
|
||||
if err := os.WriteFile(marker, original, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup := filepath.Join(root, "private-backup", "retired.json")
|
||||
if err := os.Mkdir(filepath.Dir(backup), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(marker)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(marker)
|
||||
if err != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("unexpected marker permissions: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(backup, body, info.Mode().Perm()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
copied, err := os.ReadFile(backup)
|
||||
if err != nil || len(copied) != len(original) || sha256.Sum256(copied) != sha256.Sum256(original) {
|
||||
t.Fatalf("private backup verification failed: %v", err)
|
||||
}
|
||||
if err := os.Remove(marker); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(markerDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, expected := range neighbors {
|
||||
retained, err := os.ReadFile(path)
|
||||
if err != nil || string(retained) != string(expected) {
|
||||
t.Fatalf("retirement changed adjacent data %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Lstat(markerDir); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("retired directory remains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovedAgentActiveIdentityProtectedAndReusableAfterFinish(t *testing.T) {
|
||||
now := time.Date(2026, 10, 10, 10, 0, 0, 0, time.UTC)
|
||||
req := approvedTestRequest(t, now)
|
||||
state := filepath.Join(t.TempDir(), "agent-session.json")
|
||||
started := make(chan struct{}, 2)
|
||||
finish := make(chan struct{}, 2)
|
||||
var attempts atomic.Int32
|
||||
calls := &agent.TaskCalls{}
|
||||
worker := &ApprovedCallWorker{
|
||||
Lifecycle: context.Background(), Calls: calls, Now: func() time.Time { return now },
|
||||
Prepare: prepareWorkerRun(func(context.Context, ApprovedExecution) error {
|
||||
attempts.Add(1)
|
||||
started <- struct{}{}
|
||||
<-finish
|
||||
return nil
|
||||
}),
|
||||
OnFailure: func(ApprovedExecution, error) error { return nil },
|
||||
}
|
||||
// Ensure a failed assertion cannot leave an isolated runner blocked.
|
||||
defer func() { finish <- struct{}{}; finish <- struct{}{} }()
|
||||
server, err := NewApprovedAgentServer(ServerOptions{
|
||||
Mode: "mock", StatePath: state, ApprovedDispatcherID: req.DispatcherId,
|
||||
Now: func() time.Time { return now },
|
||||
Status: &agentpb.AgentStatus{AgentId: "agent-1", CellId: "cell-1", BootId: "boot-1"},
|
||||
LoadedSIP: func(context.Context) (map[string]int64, error) {
|
||||
return map[string]int64{req.SelectedTrunkId: req.SipRevision}, nil
|
||||
},
|
||||
}, worker)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := server.ActivateAgent(context.Background(), &agentpb.ActivateAgentRequest{
|
||||
Meta: testMeta("activate-retirement", "", 0),
|
||||
Binding: &agentpb.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1",
|
||||
DispatcherEpoch: "epoch-1", SessionGeneration: 1, DispatcherId: req.DispatcherId},
|
||||
ActivationOperationId: "activate-retirement",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := server.ExecuteApproved(context.Background(), req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForStart := func() {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("worker did not start")
|
||||
}
|
||||
}
|
||||
waitForStart()
|
||||
if _, err := server.ExecuteApproved(context.Background(), req); err == nil {
|
||||
t.Fatal("active identity was scheduled twice")
|
||||
}
|
||||
if attempts.Load() != 1 {
|
||||
t.Fatal("duplicate request started a second runner")
|
||||
}
|
||||
finish <- struct{}{}
|
||||
identity := agent.TaskIdentity{DispatcherID: req.DispatcherId, TenantID: req.TenantId, TaskID: req.TaskId}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := calls.Apply(ctx, identity, "pause", "drain"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := calls.Apply(ctx, identity, "resume", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := server.ExecuteApproved(context.Background(), req); err != nil {
|
||||
t.Fatalf("finished identity remained permanently blocked: %v", err)
|
||||
}
|
||||
waitForStart()
|
||||
finish <- struct{}{}
|
||||
if err := calls.Apply(ctx, identity, "pause", "drain"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts.Load() != 2 {
|
||||
t.Fatalf("expected two separate executions: %d", attempts.Load())
|
||||
}
|
||||
if _, err := os.Lstat(state + ".approved"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("worker created permanent markers: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func TestApprovedAgentServerRegistersCallsBeforeExecuteAckAndDrainsOnControl(t *
|
||||
t.Fatalf("stop did not wait for the actual runner: %v", err)
|
||||
}
|
||||
if _, err := server.ExecuteApproved(context.Background(), req); status.Code(err) != codes.FailedPrecondition {
|
||||
t.Fatalf("completed or unknown durable identity was redialed: %v", err)
|
||||
t.Fatalf("stopped task admitted another call: %v", err)
|
||||
}
|
||||
select {
|
||||
case err := <-failures:
|
||||
@@ -258,7 +258,7 @@ func TestApprovedAgentServerRejectsSelfReportedDispatcherBeforeSessionWrite(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteApprovedReturnsDefinitiveNoDialRefusalWithoutRetry(t *testing.T) {
|
||||
func TestExecuteApprovedRetainsDefinitiveNoDialEvidenceWithoutPermanentMarker(t *testing.T) {
|
||||
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
@@ -278,8 +278,13 @@ func TestExecuteApprovedReturnsDefinitiveNoDialRefusalWithoutRetry(t *testing.T)
|
||||
if _, err := server.ExecuteApproved(context.Background(), req); status.Code(err) != tc.want {
|
||||
t.Fatalf("known no-dial was treated as ambiguous: %v", err)
|
||||
}
|
||||
if _, err := server.ExecuteApproved(context.Background(), req); status.Code(err) != codes.FailedPrecondition || attempts != 1 {
|
||||
t.Fatalf("durable no-redial guard was bypassed: attempts=%d err=%v", attempts, err)
|
||||
if _, err := server.ExecuteApproved(context.Background(), req); status.Code(err) != tc.want || attempts != 2 {
|
||||
t.Fatalf("pre-dial rejection became a permanent identity barrier: attempts=%d err=%v", attempts, err)
|
||||
} else {
|
||||
st, ok := status.FromError(err)
|
||||
if !ok || len(st.Details()) != 1 {
|
||||
t.Fatalf("new rejection lost machine-readable no-dial evidence: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@ func TestApprovedAgentReceivesFrozenASROnlySnapshot(t *testing.T) {
|
||||
if err != nil || result == nil || !result.Accepted || result.CallId != req.CallId || attempts != 1 {
|
||||
t.Fatalf("Agent did not accept exactly one mock call: %v %v attempts=%d", result, err, attempts)
|
||||
}
|
||||
if _, err := s.ExecuteApproved(context.Background(), req); status.Code(err) != codes.FailedPrecondition || attempts != 1 {
|
||||
t.Fatalf("accepted call cannot be replayed: err=%v attempts=%d", err, attempts)
|
||||
if _, err := s.ExecuteApproved(context.Background(), req); err != nil || attempts != 2 {
|
||||
t.Fatalf("new execution lost its frozen snapshot or retained a permanent barrier: err=%v attempts=%d", err, attempts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,9 +63,6 @@ type Server struct {
|
||||
nonprodRealOriginate func(context.Context, ApprovedExecution) error
|
||||
approvedTaskCalls *agent.TaskCalls
|
||||
sessions *SessionRegistry
|
||||
|
||||
approvedJournalDir string
|
||||
approvedMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewServer constructs a handler suitable for registration with a gRPC server.
|
||||
@@ -100,9 +97,6 @@ func NewServer(options ServerOptions) *Server {
|
||||
approvedTaskCalls: options.ApprovedTaskCalls,
|
||||
sessions: NewSessionRegistry(options.StatePath),
|
||||
}
|
||||
if options.StatePath != "" {
|
||||
server.approvedJournalDir = options.StatePath + ".approved"
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user