package rpc import ( "context" "errors" "log/slog" "time" "git.ipao.vip/rogee/go-sip/internal/agent" ) var ( ErrApprovedDialExpired = errors.New("Dispatcher-approved dial deadline expired") ErrApprovedCallPreparation = errors.New("approved call preparation rejected before dispatch") ) // ApprovedCallWorker keeps a call registered until its prepared synchronous // runner has finished physical hangup. Prepare must validate the per-call // snapshot, media and delivery without dialing or persisting business data; // the returned runner must not return before hangup. The process lifecycle, // not the initiating Unary request, owns the call context. type ApprovedCallWorker struct { Lifecycle context.Context Calls *agent.TaskCalls Prepare func(ApprovedExecution) (func(context.Context) error, error) OnFailure func(ApprovedExecution, error) error Now func() time.Time } // Originate returns after registration and launch, allowing Dispatcher to // acknowledge issuance while the call remains cancellable by task control. // If scheduling delays the worker past the signed deadline it reports failure // without dialing; an unknown durable execution is never automatically retried. func (w *ApprovedCallWorker) Originate(request context.Context, approved ApprovedExecution) error { if w == nil || request == nil || w.Lifecycle == nil || w.Calls == nil || w.Prepare == nil || w.OnFailure == nil { return errors.New("approved call lifecycle, runner and failure reporting are required") } if err := request.Err(); err != nil { return err } if err := w.Lifecycle.Err(); err != nil { return err } if approved.DispatcherID == "" || approved.TenantID <= 0 || approved.TaskID == "" || approved.CallID == "" || approved.MaxCallDuration <= 0 || approved.DialBefore.IsZero() { return errors.New("approved call identity, duration or dial deadline is incomplete") } now := w.Now if now == nil { now = time.Now } if !now().Before(approved.DialBefore) { return ErrApprovedDialExpired } prepared, err := w.Prepare(approved) if err != nil { return errors.Join(ErrApprovedCallPreparation, err) } if prepared == nil { return ErrApprovedCallPreparation } callContext, cancel := context.WithTimeout(w.Lifecycle, approved.MaxCallDuration) release, err := w.Calls.Register(agent.TaskIdentity{DispatcherID: approved.DispatcherID, TenantID: approved.TenantID, TaskID: approved.TaskID}, approved.CallID, cancel) if err != nil { cancel() return err } issued := make(chan error, 1) go func() { // Deferred cleanup also runs if a broken runner panics; panics are not // swallowed, and the process does not claim a successful outcome. defer cancel() defer release() if err := callContext.Err(); err != nil { issued <- err return } if err := request.Err(); err != nil { issued <- err return } if !now().Before(approved.DialBefore) { issued <- ErrApprovedDialExpired return } logger := slog.With("event_id", approved.SourceEventID, "task_id", approved.TaskID, "call_id", approved.CallID) logger.Info("Agent call scheduled", "stage", "scheduled") issued <- nil // the Unary response may now acknowledge scheduled work logger.Info("Agent call execution", "stage", "execution_started") runErr := prepared(callContext) // Physical execution resources are released independently of any later // failure reporting, OSS work or MQ publisher confirmation. release() if runErr == nil { logger.Info("Agent call execution complete", "stage", "execution_completed") return } logger.Error("Agent call execution failed", "stage", "execution_failed", "error", runErr) if err := w.OnFailure(approved, runErr); err != nil { logger.Error("Agent call failure reporting failed", "stage", "failure_report_failed", "error", err) } }() return <-issued }