diff --git a/internal/rpc/approved_recorded_real.go b/internal/rpc/approved_recorded_real.go index 0c63844..fc3bf4c 100644 --- a/internal/rpc/approved_recorded_real.go +++ b/internal/rpc/approved_recorded_real.go @@ -99,21 +99,43 @@ func (r *ApprovedRecordedRealCall) Prepare(approved ApprovedExecution) (func(con if err != nil { return err // unknown origination is never retried or reported as a completed call } - if call.ctx == nil || call.session == nil || call.close == nil { - if call.close != nil { - return errors.Join(errors.New("real call media unavailable"), call.close()) + startedAt := time.Now().UTC() // Originate returns only after the actual channel entered Stasis. + // A real channel may have answered even if media/AI setup subsequently + // fails. Report a failed call only after its hangup is confirmed; never + // keep a known-ended call occupying capacity or invent a recording. + reportSetupFailure := func(reason string, cause error) error { + if call.close == nil { + return errors.Join(cause, errors.New("real call has no hangup function; outcome unknown")) } + if err := call.close(); err != nil { + return errors.Join(cause, err) + } + payload, err := callflow.FinalResultPayload(callflow.FinalCallFacts{ + TaskID: approved.TaskID, CallerProfileID: approved.CallerProfileID, + Callee: approved.Callee, TrunkID: approved.SelectedTrunkID, + StartedAt: startedAt, EndedAt: time.Now().UTC(), Outcome: "failed", ReasonMessage: reason, + }, callflow.Result{}) + if err != nil { + return errors.Join(cause, err) + } + reportCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), r.ReportTimeout) + defer cancel() + return errors.Join(cause, r.Delivery.Complete(reportCtx, agent.CompletedRecording{ResultPayload: payload})) + } + if call.ctx == nil || call.session == nil { + return reportSetupFailure("real media was unavailable", errors.New("real call media unavailable")) + } + if call.close == nil { return errors.New("real call has no hangup function; outcome unknown") } capture, err := callflow.NewRecordingSession(call.session, r.MaxWAVBytes) if err != nil { - return errors.Join(err, call.close()) + return reportSetupFailure("real recording setup failed", err) } - startedAt := time.Now().UTC() // Originate returns only after the actual channel entered Stasis. hangup := func(context.Context) error { return call.close() } pipeline, err := ai.NewCall(approved.AI, hangup) if err != nil { - return errors.Join(err, call.close()) + return reportSetupFailure("real AI instance setup failed", err) } observed, runErr := RunApprovedCall(call.ctx, approved, capture, hangup, pipeline) if err := call.close(); err != nil { diff --git a/internal/rpc/approved_recorded_real_test.go b/internal/rpc/approved_recorded_real_test.go index 0a9d469..99160cc 100644 --- a/internal/rpc/approved_recorded_real_test.go +++ b/internal/rpc/approved_recorded_real_test.go @@ -78,6 +78,47 @@ func TestApprovedRecordedRealCallReportsOnlyEndedObservedCall(t *testing.T) { } } +func TestApprovedRecordedRealCallReportsConfirmedMediaSetupFailure(t *testing.T) { + fixture, stub, puts, _, approved := recordedMockFixture(t, nil, time.Second) + approved.CallerID, approved.DialedCallee, approved.RingTimeout = "BD93205882", "7089"+approved.Callee, time.Second + var originate, hangup int + runner := &ApprovedRecordedRealCall{ + MaxWAVBytes: 4096, MediaPayloadType: 118, ReportTimeout: time.Second, + Delivery: fixture.Delivery, + originator: func(ctx context.Context, _ asterisk.NativeDial) (realMedia, error) { + originate++ + return realMedia{ctx: ctx, close: func() error { hangup++; return nil }}, nil // answered channel, but no RTP capture session + }, + } + err := runner.Run(context.Background(), approved) + if err == nil || originate != 1 || hangup != 1 || strings.Join(stub.calls, ",") != "end,result" || puts.Load() != 0 { + t.Fatalf("confirmed ended call without RTP still needs true failed result and empty recording: err=%v originate=%d hangup=%d calls=%v puts=%d", err, originate, hangup, stub.calls, puts.Load()) + } + var result struct { + Outcome string `json:"outcome"` + Recording map[string]any `json:"recording"` + ReasonMessage string `json:"reason_message"` + } + if err := json.Unmarshal(stub.result, &result); err != nil || result.Outcome != "failed" || len(result.Recording) != 0 || result.ReasonMessage == "" { + t.Fatalf("false success after media setup failure: %+v %v", result, err) + } +} + +func TestApprovedRecordedRealCallMissingMediaWithUnknownHangupDoesNotReport(t *testing.T) { + fixture, stub, _, _, approved := recordedMockFixture(t, nil, time.Second) + approved.CallerID, approved.DialedCallee, approved.RingTimeout = "BD93205882", "7089"+approved.Callee, time.Second + runner := &ApprovedRecordedRealCall{ + MaxWAVBytes: 4096, MediaPayloadType: 118, ReportTimeout: time.Second, + Delivery: fixture.Delivery, + originator: func(ctx context.Context, _ asterisk.NativeDial) (realMedia, error) { + return realMedia{ctx: ctx, close: func() error { return errors.New("hangup response missing") }}, nil + }, + } + if err := runner.Run(context.Background(), approved); err == nil || len(stub.calls) != 0 { + t.Fatalf("ambiguous hangup cannot be reported as a confirmed end: err=%v calls=%v", err, stub.calls) + } +} + func TestApprovedRecordedRealCallDoesNotReportUnknownHangup(t *testing.T) { fixture, stub, puts, _, approved := recordedMockFixture(t, nil, time.Second) approved.CallerID, approved.DialedCallee, approved.RingTimeout = "BD93205882", "7089"+approved.Callee, time.Second