require explicit Agent no-dial proof before releasing quota

This commit is contained in:
2026-10-04 20:28:58 +08:00
parent d81e4d3616
commit d2c4a99802
7 changed files with 87 additions and 11 deletions
+3 -1
View File
@@ -90,7 +90,9 @@ require_call_window() {
exit 1
fi
}
require_call_window
# A diagnostic that exits before the call command can run off-hours; every
# real attempt still checks the time gate here and again immediately pre-dial.
if (( ! preflight_only )); then require_call_window; fi
# "any" includes both provider SIP and the local Asterisk ExternalMedia RTP.
# Reducing it to the default-route NIC silently omits loopback media.
if [[ -z "$evidence_dir" ]]; then
@@ -28,3 +28,7 @@
使用者明确选择:确定在 Agent 接纳前被拒、或 Agent 能证明 ARI originate **从未提交**的执行应释放占用;其他 ARI/投递结果未知仍保留;隔离测试租户经获批将并发额度从 1 调为 2,以保留旧占用 1 并允许另一个任务/线路 1 个名额。分支 `fix/nonprod-definite-failure-release` 的本地修改:Dispatcher 对 Agent 明确的拨号前拒绝写入持久 `rejected` 回执并释放额度;Agent 对拨号前确定未提交的失败通过带会话校验的正式 `ReportCallEnded` 确认终结;SaaS 测试等待器持久保存并确认 `rejected` 回执后立即结束,不伪造通话结果。未确认 ARI originate 的结果不自动释放、不重试。已在测试机私有目录新建 `data-quota2-20261004/`,只更改租户并发上限为 2 且递增额度 revision,通过离线合同校验;**原始 `data/` 未修改,新快照未启用**。
本地 `make check`、`make release-check-local`、隔离 RabbitMQ 测试及业务单元覆盖率 **69.0%** 通过;仍没有新版主机部署、真实拨号或 LLM 应答证据,旧执行保留。此代码修复不构成旧执行已终结的证明,也不授权绕过当日 `20:00` 截止门禁。
## 合并 main 后的边界审查
按使用者明确指定的先后顺序,先将修复分支快进合并并推送到 `main`,再于不可呼出时段审查。发现一处关键误判:先前按 Agent 的 `FailedPrecondition` 等普通错误码直接释放占用;**同一错误码也用于拒绝重拨结果未知的旧执行**。已用回归测试先复现,再改为仅接受 Agent 明确标注“本次调用从未提交拨号”的机器可读回报,缺少标注一律仍占用;绑定及错误包装后的场景均有测试。另调整抓包脚本:`--preflight-only` 可以在禁止呼出的时段执行只读/不拨号诊断,真实拨号仍需通过开始和临拨前两次时段检查;有相应的时段回归测试。本地 `make check`、`make release-check-local`、隔离 MQ 测试和业务覆盖率 **69.1%** 通过。本次审查与测试没有发起真实呼叫;主机部署及新版诊断需要另留实际证据。
@@ -15,7 +15,7 @@ func TestNonprodUserAsteriskScopeRequiresExplicitConfig(t *testing.T) {
tools := t.TempDir()
for name, script := range map[string]string{
"id": "if [ \"$1\" = -u ]; then echo 0; else exec /usr/bin/id \"$@\"; fi\n",
"date": "if [ \"${TZ-}\" = Asia/Shanghai ] && [ \"$1\" = +%H%M ]; then echo 1000; else exec /usr/bin/date \"$@\"; fi\n",
"date": "if [ \"${TZ-}\" = Asia/Shanghai ] && [ \"$1\" = +%H%M ]; then echo 2100; else exec /usr/bin/date \"$@\"; fi\n",
} {
if err := os.WriteFile(filepath.Join(tools, name), []byte("#!/bin/sh\n"+script), 0700); err != nil {
t.Fatal(err)
@@ -33,7 +33,19 @@ func TestNonprodUserAsteriskScopeRequiresExplicitConfig(t *testing.T) {
command.Env = append(os.Environ(), "PATH="+tools+":"+os.Getenv("PATH"), "ASTERISK_BIN=/bin/true", "ASTERISK_CONFIG=")
output, err := command.CombinedOutput()
if err == nil || !strings.Contains(string(output), "explicit user Asterisk configuration required") {
t.Fatalf("user-scope diagnostics must refuse unbound Asterisk instance: err=%v output=%s", err, output)
t.Fatalf("off-hours non-dial preflight must check Asterisk configuration: err=%v output=%s", err, output)
}
withoutPreflight := exec.Command("bash", "../../deploys/test/nonprod-call-evidence.sh", "--environment", "mock",
"--asterisk-scope", "user", "--trunk", "provider-primary", "--target", "15003164745", "--run-as", currentUser.Username,
"--interface", "lo", "--recording-dir", filepath.Join(root, "recordings"), "--evidence-dir", filepath.Join(root, "real-attempt"),
"--attempt-ledger", filepath.Join(root, "attempts.tsv"), "--", "/bin/true")
withoutPreflight.Env = command.Env
output, err = withoutPreflight.CombinedOutput()
if err == nil || !strings.Contains(string(output), "outside Asia/Shanghai 09:00-20:00") {
t.Fatalf("real call must remain blocked outside hours: err=%v output=%s", err, output)
}
if _, err := os.Stat(filepath.Join(root, "attempts.tsv")); !os.IsNotExist(err) {
t.Fatalf("out-of-hours real call reserved an attempt: %v", err)
}
}
+17 -5
View File
@@ -11,6 +11,7 @@ import (
"git.ipao.vip/rogee/go-sip/internal/configread"
"git.ipao.vip/rogee/go-sip/internal/contract"
"git.ipao.vip/rogee/go-sip/internal/store"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
@@ -130,6 +131,19 @@ func (c *ExecuteController) ProcessPending(ctx context.Context) error {
return errors.Join(failures...)
}
func agentProvedCallNotIssued(err error) bool {
st, ok := status.FromError(err)
if !ok || st.Code() == codes.OK {
return false
}
for _, detail := range st.Details() {
if proof, ok := detail.(*errdetails.ErrorInfo); ok && proof.Reason == "GO_SIP_CALL_NOT_ISSUED" && proof.Domain == "agent.go-sip" {
return true
}
}
return false
}
func (c *ExecuteController) dispatchPending(ctx context.Context, cmd store.ExecuteCommand) error {
at := c.Now()
issued, err := time.Parse(time.RFC3339Nano, cmd.IssuedAt)
@@ -208,17 +222,15 @@ func (c *ExecuteController) dispatchPending(ctx context.Context, cmd store.Execu
Deadline: choice.Deadline, Snapshot: snapshot,
}
if err := c.Originator.Originate(ctx, spec); err != nil {
switch status.Code(err) {
case codes.FailedPrecondition, codes.InvalidArgument, codes.PermissionDenied:
if agentProvedCallNotIssued(err) {
rejectErr := c.Store.RejectUnissuedExecute(cmd.DispatcherID, cmd.EventID, "Agent refused before outbound call")
if rejectErr == nil {
log.Printf("Dispatcher call refused before dial: event_id=%q agent_code=%s", cmd.EventID, status.Code(err))
}
return errors.Join(fmt.Errorf("Agent rejected call %q before dialing: %w", cmd.EventID, err), rejectErr)
default:
unknownErr := c.Store.MarkExecuteUnknown(cmd.DispatcherID, cmd.EventID)
return errors.Join(fmt.Errorf("originator outcome unknown for call %q: %w", cmd.EventID, err), unknownErr)
}
unknownErr := c.Store.MarkExecuteUnknown(cmd.DispatcherID, cmd.EventID)
return errors.Join(fmt.Errorf("originator outcome unknown for call %q: %w", cmd.EventID, err), unknownErr)
}
if err := c.Store.MarkExecuteDispatched(cmd.DispatcherID, cmd.EventID); err != nil {
return fmt.Errorf("originated call %q has no durable acknowledgment: %w", cmd.EventID, err)
+19 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
@@ -12,6 +13,7 @@ import (
"git.ipao.vip/rogee/go-sip/internal/configread"
"git.ipao.vip/rogee/go-sip/internal/contract"
"git.ipao.vip/rogee/go-sip/internal/store"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
@@ -138,9 +140,25 @@ func TestExecuteWaitsForRulesThenDispatchesOriginalIdentity(t *testing.T) {
}
}
func TestAgentFailedPreconditionWithoutNoIssueProofKeepsUnknownReservation(t *testing.T) {
controller, originator, _, s := newExecuteFixture(t)
originator.err = status.Error(codes.FailedPrecondition, "prior Agent execution outcome is unknown; no redial")
if err := controller.ProcessExecute(context.Background(), executeBody(t, "prior-agent-unknown-1", "15003164745")); err == nil {
t.Fatal("prior unknown was silently treated as confirmed rejection")
}
occupied, err := s.TrunkOccupancy(controller.DispatcherID)
if err != nil || occupied["trunk-mock"] != 1 {
t.Fatalf("unproven Agent state was released: %+v %v", occupied, err)
}
}
func TestAgentExplicitRefusalBeforeDialReleasesReservationAndKeepsUnknownBlocked(t *testing.T) {
controller, originator, _, s := newExecuteFixture(t)
originator.err = status.Error(codes.FailedPrecondition, "Agent refused before issue")
confirmed, err := status.New(codes.FailedPrecondition, "Agent refused before issue").WithDetails(&errdetails.ErrorInfo{Reason: "GO_SIP_CALL_NOT_ISSUED", Domain: "agent.go-sip"})
if err != nil {
t.Fatal(err)
}
originator.err = fmt.Errorf("signed Agent RPC: %w", confirmed.Err())
body := executeBody(t, "agent-refused-1", "15003164745")
if err := controller.ProcessExecute(context.Background(), body); err == nil {
t.Fatal("Agent refusal hidden")
+14 -2
View File
@@ -16,6 +16,7 @@ import (
"git.ipao.vip/rogee/go-sip/internal/agent"
"git.ipao.vip/rogee/go-sip/internal/ai"
"git.ipao.vip/rogee/go-sip/internal/configread"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
@@ -73,6 +74,17 @@ func (s *Server) GetLoadedSIP(ctx context.Context, req *agentpb.GetLoadedSIPRequ
// 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.
// 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.
func approvedNoIssueError(code codes.Code, message string) error {
marked, err := status.New(code, message).WithDetails(&errdetails.ErrorInfo{Reason: "GO_SIP_CALL_NOT_ISSUED", Domain: "agent.go-sip"})
if err != nil {
return status.Error(codes.Internal, "cannot encode definite pre-dial rejection")
}
return marked.Err()
}
func (s *Server) ExecuteApproved(ctx context.Context, req *agentpb.ExecuteApprovedRequest) (*agentpb.ExecuteApprovedResponse, error) {
if req == nil || req.Meta == nil {
return nil, status.Error(codes.InvalidArgument, "approved execution metadata is required")
@@ -151,10 +163,10 @@ func (s *Server) ExecuteApproved(ctx context.Context, req *agentpb.ExecuteApprov
switch {
case errors.Is(err, ErrApprovedDialExpired):
log.Printf("Agent approved execution refused before dial: event_id=%q task_id=%q cause_type=%T", req.SourceEventId, req.TaskId, err)
return nil, status.Error(codes.DeadlineExceeded, "Dispatcher dial authorization expired before issuing")
return nil, approvedNoIssueError(codes.DeadlineExceeded, "Dispatcher dial authorization expired before issuing")
case errors.Is(err, agent.ErrTaskAdmissionClosed), errors.Is(err, agent.ErrTaskStopped), errors.Is(err, ErrApprovedCallPreparation):
log.Printf("Agent approved execution refused before dial: event_id=%q task_id=%q cause_type=%T", req.SourceEventId, req.TaskId, err)
return nil, status.Error(codes.FailedPrecondition, "approved call was not ready before dial")
return nil, approvedNoIssueError(codes.FailedPrecondition, "approved call was not ready before dial")
default:
log.Printf("Agent approved execution outcome unknown: event_id=%q task_id=%q cause_type=%T", req.SourceEventId, req.TaskId, err)
return nil, status.Error(codes.Unavailable, "approved execution outcome unknown")
+16
View File
@@ -12,10 +12,26 @@ import (
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
"git.ipao.vip/rogee/go-sip/internal/configread"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestAgentDefiniteNoDialStatusHasExplicitMachineReadableEvidence(t *testing.T) {
err := approvedNoIssueError(codes.FailedPrecondition, "call was not ready")
st, ok := status.FromError(err)
if !ok || st.Code() != codes.FailedPrecondition {
t.Fatalf("explicit refusal must retain gRPC code: %v", err)
}
if len(st.Details()) != 1 {
t.Fatalf("missing signed no-dial fact: %v", st.Details())
}
info, ok := st.Details()[0].(*errdetails.ErrorInfo)
if !ok || info.Reason != "GO_SIP_CALL_NOT_ISSUED" || info.Domain != "agent.go-sip" {
t.Fatalf("wrong no-call fact: %v", st.Details())
}
}
func approvedTestRequest(t *testing.T, now time.Time) *agentpb.ExecuteApprovedRequest {
t.Helper()
taskJSON, err := os.ReadFile("../../contracts/local/examples/config-read-task-asr.json")