iop/packages/go/streamgate/recovery_coordinator.go
toki 7634ca8962 feat(stream-evidence-gate-core): complete stream evidence gate core implementation
- OpenAI request rebuilder with tool validation and provider tunnel
- Edge config runtime refresh for stream evidence gate
- Filter observation contract and runtime with sink/correlation
- Stream gate dispatcher, release sink, and vertical slice
- Recovery coordinator for evidence tail
- Parallel evaluation and commit boundary
- E2E test script for OpenAI vLLM
- Archive completed task groups to archive/2026/07
2026-07-28 04:08:12 +09:00

664 lines
25 KiB
Go

package streamgate
import (
"context"
"errors"
"sort"
"sync"
"time"
)
var (
// ErrRecoveryCycleInProgress rejects overlapping recovery cycles for the
// same request. Host calls remain strictly serialized by the coordinator.
ErrRecoveryCycleInProgress = errors.New("streamgate: recovery cycle already in progress")
// ErrRecoveryNoCurrentAttempt indicates there is no provider transport
// ownership to close before recovery.
ErrRecoveryNoCurrentAttempt = errors.New("streamgate: recovery current attempt is not bound")
// ErrRecoveryArbitrationIneligible indicates the arbiter did not select one
// validated recovery intent.
ErrRecoveryArbitrationIneligible = errors.New("streamgate: arbitration result is not recoverable")
// ErrRecoveryAbortFailed indicates current provider ownership could not be
// closed. No preparation, rebuild, budget consume, or dispatch follows.
ErrRecoveryAbortFailed = errors.New("streamgate: recovery attempt abort failed")
// ErrRecoveryPreparerUnavailable indicates a selected preparer id has no
// registered request-local implementation.
ErrRecoveryPreparerUnavailable = errors.New("streamgate: recovery plan preparer is unavailable")
// ErrRecoveryPreparationFailed indicates a bounded preparer invocation
// failed without exposing its potentially raw error.
ErrRecoveryPreparationFailed = errors.New("streamgate: recovery plan preparation failed")
// ErrRecoveryPreparationDeadline indicates the bounded preparer deadline
// elapsed. It is distinct from caller cancellation.
ErrRecoveryPreparationDeadline = errors.New("streamgate: recovery plan preparation deadline exceeded")
// ErrRecoveryPreparationInvalid indicates the preparer returned a directive
// that does not satisfy the selected immutable plan.
ErrRecoveryPreparationInvalid = errors.New("streamgate: recovery plan preparation result is invalid")
// ErrRecoveryPreparationSnapshotRelease indicates request-local preparation
// state could not be released. Rebuild and dispatch are not attempted.
ErrRecoveryPreparationSnapshotRelease = errors.New("streamgate: recovery preparation snapshot release failed")
// ErrRecoveryRebuildFailed sanitizes a host rebuilder failure.
ErrRecoveryRebuildFailed = errors.New("streamgate: recovery request rebuild failed")
// ErrRecoveryRebuildInvalid indicates the rebuilt draft could not be bound
// to the immutable recovery plan.
ErrRecoveryRebuildInvalid = errors.New("streamgate: recovery rebuilt request is invalid")
// ErrRecoveryDispatchFailed sanitizes dispatcher errors and invalid returned
// bindings. Fault usage remains consumed because an outbound attempt began.
ErrRecoveryDispatchFailed = errors.New("streamgate: recovery attempt dispatch failed")
)
// RecoveryCoordinatorOptions binds request-local policy and host seams. The
// policy, usage, ingress snapshot, preparer registry, and current attempt are
// defensively snapshotted at construction. Concrete request/auth data remains
// behind host-owned references.
type RecoveryCoordinatorOptions struct {
Policy RecoveryPolicySnapshot
Usage RecoveryUsageSnapshot
RequestSnapshot RecoveryRequestSnapshotRef
CurrentBinding AttemptBinding
Rebuilder RequestRebuilder
Dispatcher AttemptDispatcher
Preparers map[string]RecoveryPlanPreparer
PreparationTimeout time.Duration
ObservationSink ObservationSink
}
// RecoveryCycleInput carries the immutable data needed to turn one selected
// ArbitrationResult.RecoveryIntent into one plan and at most one dispatch.
// ConsumerID completes the selected arbiter filter/rule contributor identity.
// A non-nil PreparationSnapshot transfers release responsibility to Execute.
type RecoveryCycleInput struct {
Arbitration ArbitrationResult
PlanID string
IdempotencyKey string
ConsumerID string
CommitState CommitState
CallerCanceled bool
HasToolSideEffect bool
HasCompleteToolCall bool
ManagedTrajectory *ManagedTrajectoryBudget
TerminalReason TerminalReason
PreparerID string
PreparationSnapshot RecoveryPreparationSnapshot
RequiredCapabilities []string
FailureCauses FailureCauseChain
}
// RecoveryCycleResult is an immutable, raw-free account of one cycle. A
// failed cycle may still expose its selected plan and consumed usage so the
// host can produce one terminal result without retrying the same plan.
type RecoveryCycleResult struct {
plan RecoveryPlan
hasPlan bool
binding AttemptBinding
hasBinding bool
usage RecoveryUsageSnapshot
failureCauses FailureCauseChain
previousAttemptClosed bool
}
// PreviousAttemptClosed returns true if the previous attempt was closed/aborted.
func (r RecoveryCycleResult) PreviousAttemptClosed() bool {
return r.previousAttemptClosed
}
// Plan returns the immutable selected/finalized plan when plan creation
// succeeded.
func (r RecoveryCycleResult) Plan() (RecoveryPlan, bool) {
if !r.hasPlan {
return RecoveryPlan{}, false
}
return r.plan, true
}
// Binding returns the single successfully dispatched attempt binding.
func (r RecoveryCycleResult) Binding() (AttemptBinding, bool) {
if !r.hasBinding {
return AttemptBinding{}, false
}
return r.binding, true
}
// UsageSnapshot returns the coordinator ledger after this cycle. A failed
// outbound dispatch consumes fault usage; failures before dispatch do not.
func (r RecoveryCycleResult) UsageSnapshot() RecoveryUsageSnapshot {
return copyRecoveryUsageSnapshot(r.usage)
}
// FailureCauses returns a defensive bounded sanitized cause chain.
func (r RecoveryCycleResult) FailureCauses() FailureCauseChain {
return r.failureCauses.Copy()
}
func (r RecoveryCycleResult) withPlan(plan RecoveryPlan) RecoveryCycleResult {
r.plan = plan
r.hasPlan = true
return r
}
func (r RecoveryCycleResult) withFailure(code string) RecoveryCycleResult {
if r.failureCauses.Len() >= MaxFailureCauses {
return r
}
cause, err := NewFailureCause("recovery_coordinator", code, "", "", "")
if err != nil {
return r
}
chain, err := r.failureCauses.Append(cause)
if err == nil {
r.failureCauses = chain
}
return r
}
// RecoveryCoordinator owns the mutable request-local recovery ledger and
// current provider binding. It never serializes endpoint errors and never
// participates in filter evaluation.
type RecoveryCoordinator struct {
mu sync.Mutex
busy bool
policy RecoveryPolicySnapshot
usage RecoveryUsageSnapshot
requestSnapshot RecoveryRequestSnapshotRef
currentBinding AttemptBinding
hasCurrentBinding bool
rebuilder RequestRebuilder
dispatcher AttemptDispatcher
preparers map[string]RecoveryPlanPreparer
preparationTimeout time.Duration
usedPlanIDs map[string]struct{}
preparedPlanKeys map[string]struct{}
sequencer *ObservationSequencer
}
// NewRecoveryCoordinator creates one request-local coordinator.
func NewRecoveryCoordinator(options RecoveryCoordinatorOptions) (*RecoveryCoordinator, error) {
if err := options.Policy.Validate(); err != nil {
return nil, err
}
if err := options.Usage.ValidateAgainst(options.Policy); err != nil {
return nil, err
}
if err := options.RequestSnapshot.Validate(); err != nil {
return nil, err
}
if err := options.CurrentBinding.Validate(); err != nil {
return nil, err
}
if options.Rebuilder == nil {
return nil, errors.New("streamgate: recovery coordinator rebuilder is required")
}
if options.Dispatcher == nil {
return nil, errors.New("streamgate: recovery coordinator dispatcher is required")
}
if options.PreparationTimeout < 0 {
return nil, errors.New("streamgate: recovery coordinator preparation timeout must not be negative")
}
if len(options.Preparers) > 0 && options.PreparationTimeout <= 0 {
return nil, errors.New("streamgate: recovery coordinator preparation timeout must be positive")
}
preparers := make(map[string]RecoveryPlanPreparer, len(options.Preparers))
for id, preparer := range options.Preparers {
if err := validateStableTokenRequired("preparerID", id); err != nil {
return nil, err
}
if preparer == nil {
return nil, errors.New("streamgate: recovery coordinator preparer must not be nil")
}
preparers[id] = preparer
}
return &RecoveryCoordinator{
policy: copyRecoveryPolicySnapshot(options.Policy),
usage: copyRecoveryUsageSnapshot(options.Usage),
requestSnapshot: options.RequestSnapshot,
currentBinding: options.CurrentBinding,
hasCurrentBinding: true,
rebuilder: options.Rebuilder,
dispatcher: options.Dispatcher,
preparers: preparers,
preparationTimeout: options.PreparationTimeout,
usedPlanIDs: make(map[string]struct{}),
preparedPlanKeys: make(map[string]struct{}),
sequencer: NewObservationSequencer(options.ObservationSink, nil),
}, nil
}
// SetObservationSequencer sets or overrides the coordinator's ObservationSequencer.
func (c *RecoveryCoordinator) SetObservationSequencer(seq *ObservationSequencer) {
c.mu.Lock()
defer c.mu.Unlock()
c.sequencer = seq
}
// UsageSnapshot returns the current request-local fault dispatch ledger.
func (c *RecoveryCoordinator) UsageSnapshot() RecoveryUsageSnapshot {
c.mu.Lock()
defer c.mu.Unlock()
return copyRecoveryUsageSnapshot(c.usage)
}
// CurrentBinding returns the currently owned provider attempt, if any.
func (c *RecoveryCoordinator) CurrentBinding() (AttemptBinding, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if !c.hasCurrentBinding {
return AttemptBinding{}, false
}
return c.currentBinding, true
}
// UsedPlanIDs returns a stable sorted copy of the request-local plan ledger.
func (c *RecoveryCoordinator) UsedPlanIDs() []string {
c.mu.Lock()
defer c.mu.Unlock()
return c.usedPlanIDsLocked()
}
func (c *RecoveryCoordinator) usedPlanIDsLocked() []string {
ids := make([]string, 0, len(c.usedPlanIDs))
for id := range c.usedPlanIDs {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
func (c *RecoveryCoordinator) beginCycle(planID string) (AttemptBinding, RecoveryUsageSnapshot, []string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.busy {
return AttemptBinding{}, RecoveryUsageSnapshot{}, nil, ErrRecoveryCycleInProgress
}
if _, used := c.usedPlanIDs[planID]; used {
return AttemptBinding{}, RecoveryUsageSnapshot{}, nil, ErrRecoveryPlanReentry
}
if !c.hasCurrentBinding {
return AttemptBinding{}, RecoveryUsageSnapshot{}, nil, ErrRecoveryNoCurrentAttempt
}
c.busy = true
return c.currentBinding, copyRecoveryUsageSnapshot(c.usage), c.usedPlanIDsLocked(), nil
}
func (c *RecoveryCoordinator) finishCycle() {
c.mu.Lock()
c.busy = false
c.mu.Unlock()
}
func (c *RecoveryCoordinator) markPlanUsed(planID string) {
c.mu.Lock()
c.usedPlanIDs[planID] = struct{}{}
c.mu.Unlock()
}
func (c *RecoveryCoordinator) clearCurrentBinding(attemptID string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.hasCurrentBinding && c.currentBinding.AttemptID() == attemptID {
c.currentBinding = AttemptBinding{}
c.hasCurrentBinding = false
}
}
func (c *RecoveryCoordinator) consumeUsage(usage RecoveryUsageSnapshot) {
c.mu.Lock()
c.usage = copyRecoveryUsageSnapshot(usage)
c.mu.Unlock()
}
func (c *RecoveryCoordinator) installBinding(binding AttemptBinding) {
c.mu.Lock()
c.currentBinding = binding
c.hasCurrentBinding = true
c.mu.Unlock()
}
func (c *RecoveryCoordinator) claimPreparation(plan RecoveryPlan) bool {
key := plan.PlanID() + "\x00" + plan.IdempotencyKey()
c.mu.Lock()
defer c.mu.Unlock()
if _, exists := c.preparedPlanKeys[key]; exists {
return false
}
c.preparedPlanKeys[key] = struct{}{}
return true
}
func (c *RecoveryCoordinator) preparer(id string) RecoveryPlanPreparer {
c.mu.Lock()
defer c.mu.Unlock()
return c.preparers[id]
}
// Execute performs exactly one recovery cycle. Ordering is fixed as:
// eligibility/plan -> abort -> optional one-shot prepare -> rebuild/finalize ->
// pre-dispatch usage consume -> single dispatch -> immutable binding install.
func (c *RecoveryCoordinator) Execute(ctx context.Context, input RecoveryCycleInput) (result RecoveryCycleResult, retErr error) {
result.failureCauses = input.FailureCauses.Copy()
result.usage = c.UsageSnapshot()
c.mu.Lock()
seq := c.sequencer
c.mu.Unlock()
correlation, configGen, attemptID := "default", "default", "default"
attemptTarget, _ := NewObservationAttemptTarget("default", "default", "default", "default")
var epochID uint64
if ctx != nil {
if obsCtx, ok := ObservationContextFromContext(ctx); ok {
if obsCtx.Sequencer != nil {
seq = obsCtx.Sequencer
}
if obsCtx.StableCorrelation != "" {
correlation = obsCtx.StableCorrelation
}
if obsCtx.ConfigGeneration != "" {
configGen = obsCtx.ConfigGeneration
}
if obsCtx.AttemptID != "" {
attemptID = obsCtx.AttemptID
}
if err := obsCtx.AttemptTarget.Validate(); err == nil {
attemptTarget = obsCtx.AttemptTarget
}
epochID = obsCtx.EpochID
}
}
var snapshotReleased bool
releasePreparationSnapshot := func() error {
if input.PreparationSnapshot == nil || snapshotReleased {
return nil
}
snapshotReleased = true
if err := input.PreparationSnapshot.Release(); err != nil {
return ErrRecoveryPreparationSnapshotRelease
}
return nil
}
defer func() {
// Every result path below finalizes the snapshot before it emits a
// terminal observation. This is only a safety net for future paths.
_ = releasePreparationSnapshot()
}()
emitPlanRejected := func(failed RecoveryCycleResult) {
if seq == nil {
return
}
_, _ = seq.Emit(context.Background(), FilterObservationInput{
Kind: ObservationKindRecoveryPlanRejected,
StableCorrelation: correlation,
ConfigGeneration: configGen,
AttemptID: attemptID,
AttemptTarget: attemptTarget,
EpochID: epochID,
CommitState: input.CommitState,
Causes: failed.FailureCauses(),
OccurredAt: time.Now(),
})
}
finishPlanRejection := func(failed RecoveryCycleResult, cause error) (RecoveryCycleResult, error) {
if err := releasePreparationSnapshot(); err != nil {
failed = failed.withFailure("preparation_snapshot_release_failed")
cause = errors.Join(cause, err)
}
emitPlanRejected(failed)
return failed, cause
}
if err := input.Arbitration.Validate(); err != nil || input.Arbitration.Action() != ArbitrationActionRecover {
return finishPlanRejection(result.withFailure("arbitration_ineligible"), ErrRecoveryArbitrationIneligible)
}
intent := input.Arbitration.RecoveryIntent()
if intent == nil {
return finishPlanRejection(result.withFailure("arbitration_ineligible"), ErrRecoveryArbitrationIneligible)
}
if err := validateStableTokenRequired("planID", input.PlanID); err != nil {
return finishPlanRejection(result.withFailure("plan_ineligible"), err)
}
if ctx == nil {
return finishPlanRejection(result.withFailure("caller_canceled"), ErrRecoveryCallerCanceled)
}
current, usage, usedPlanIDs, err := c.beginCycle(input.PlanID)
if err != nil {
if errors.Is(err, ErrRecoveryCycleInProgress) && attemptID == "default" {
c.mu.Lock()
if c.hasCurrentBinding {
attemptID = c.currentBinding.AttemptID()
}
c.mu.Unlock()
}
code := "cycle_ineligible"
if errors.Is(err, ErrRecoveryCycleInProgress) {
code = "cycle_in_progress"
} else if errors.Is(err, ErrRecoveryPlanReentry) {
code = "plan_reentry"
} else if errors.Is(err, ErrRecoveryNoCurrentAttempt) {
code = "current_attempt_missing"
}
return finishPlanRejection(result.withFailure(code), err)
}
defer c.finishCycle()
if attemptID == "default" {
attemptID = current.AttemptID()
}
if attemptTarget.ModelGroup() == "default" {
attemptTarget, _ = NewObservationAttemptTarget("default", current.Model(), current.Provider(), current.ExecutionPath())
}
emitRecoveryObs := func(kind ObservationKind, plan RecoveryPlan, observedAttemptID string, observedTarget ObservationAttemptTarget, preparerInfo *ObservationPreparerInfo, causes FailureCauseChain) {
if seq == nil {
return
}
sharedAttemptID := ""
if plan.ResumeMode() == RecoveryResumeModeContinueStream {
sharedAttemptID = current.AttemptID()
}
recInfo, err := NewObservationRecoveryInfo(plan.PlanID(), plan.Strategy(), plan.ResumeMode(), sharedAttemptID)
if err != nil {
return
}
_, _ = seq.Emit(ctx, FilterObservationInput{
Kind: kind,
StableCorrelation: correlation,
ConfigGeneration: configGen,
AttemptID: observedAttemptID,
AttemptTarget: observedTarget,
EpochID: epochID,
CommitState: input.CommitState,
Recovery: &recInfo,
Preparer: preparerInfo,
Causes: causes,
OccurredAt: time.Now(),
})
}
callerCanceled := input.CallerCanceled || ctx.Err() != nil
var preparationRef string
if input.PreparerID != "" {
if input.PreparationSnapshot == nil {
return finishPlanRejection(result.withFailure("preparation_snapshot_missing"), ErrRecoveryPreparationInvalid)
}
preparationRef = input.PreparationSnapshot.SnapshotRef()
} else if input.PreparationSnapshot != nil {
return finishPlanRejection(result.withFailure("preparer_missing"), ErrRecoveryPreparationInvalid)
}
contributor, err := NewRecoveryContributor(input.ConsumerID, input.Arbitration.FilterID(), input.Arbitration.RuleID())
if err != nil {
return finishPlanRejection(result.withFailure("plan_ineligible"), err)
}
plan, err := NewRecoveryPlan(RecoveryEligibilityInput{
PlanID: input.PlanID,
IdempotencyKey: input.IdempotencyKey,
Intent: *intent,
Contributors: []RecoveryContributor{contributor},
CommitState: input.CommitState,
CallerCanceled: callerCanceled,
HasToolSideEffect: input.HasToolSideEffect,
HasCompleteToolCall: input.HasCompleteToolCall,
PreviouslyUsedPlanIDs: usedPlanIDs,
Policy: c.policy,
Usage: usage,
ManagedTrajectory: input.ManagedTrajectory,
TerminalReason: input.TerminalReason,
PreparerID: input.PreparerID,
PreparationSnapshotRef: preparationRef,
RequiredCapabilities: input.RequiredCapabilities,
FailureCauses: input.FailureCauses,
})
if err != nil {
return finishPlanRejection(result.withFailure("plan_ineligible"), err)
}
result = result.withPlan(plan)
c.markPlanUsed(plan.PlanID())
emitRecoveryObs(ObservationKindRecoveryPlanSelected, plan, attemptID, attemptTarget, nil, result.FailureCauses())
// Closing current provider ownership is the first host side effect.
if err := current.Controller().AbortAttempt(ctx); err != nil {
result = result.withFailure("attempt_abort_failed")
retErr := error(ErrRecoveryAbortFailed)
if releaseErr := releasePreparationSnapshot(); releaseErr != nil {
result = result.withFailure("preparation_snapshot_release_failed")
retErr = errors.Join(retErr, releaseErr)
}
emitRecoveryObs(ObservationKindRecoveryAttemptAbortFailed, plan, attemptID, attemptTarget, nil, result.FailureCauses())
return result, retErr
}
result.previousAttemptClosed = true
c.clearCurrentBinding(current.AttemptID())
emitRecoveryObs(ObservationKindRecoveryAttemptAborted, plan, attemptID, attemptTarget, nil, result.FailureCauses())
if ctx.Err() != nil {
return result.withFailure("caller_canceled"), ErrRecoveryCallerCanceled
}
if plan.RequiresPreparation() {
preparer := c.preparer(plan.PreparerID())
emitPreparationFailure := func(code string, deadlineOutcome ObservationDeadlineOutcome, retErr error) (RecoveryCycleResult, error) {
result = result.withFailure(code)
if err := releasePreparationSnapshot(); err != nil {
result = result.withFailure("preparation_snapshot_release_failed")
retErr = errors.Join(retErr, err)
}
preparerInfo, infoErr := NewObservationPreparerInfo(plan.PreparerID(), RecoveryPreparationRequired, deadlineOutcome)
if infoErr == nil {
emitRecoveryObs(ObservationKindRecoveryPrepared, plan, attemptID, attemptTarget, &preparerInfo, result.FailureCauses())
}
return result, retErr
}
if preparer == nil || c.preparationTimeout <= 0 {
return emitPreparationFailure("preparer_unavailable", ObservationDeadlineOutcomeFailed, ErrRecoveryPreparerUnavailable)
}
if input.PreparationSnapshot.SnapshotRef() != plan.PreparationSnapshotRef() {
return emitPreparationFailure("preparation_snapshot_mismatch", ObservationDeadlineOutcomeFailed, ErrRecoveryPreparationInvalid)
}
if !c.claimPreparation(plan) {
return emitPreparationFailure("preparation_reentry", ObservationDeadlineOutcomeFailed, ErrRecoveryPlanReentry)
}
prepareCtx, cancel := context.WithTimeout(ctx, c.preparationTimeout)
directive, prepareErr := preparer.PrepareRecoveryPlan(prepareCtx, plan, input.PreparationSnapshot)
prepareCtxErr := prepareCtx.Err()
cancel()
if prepareErr != nil {
if errors.Is(prepareCtxErr, context.DeadlineExceeded) {
return emitPreparationFailure("preparation_deadline", ObservationDeadlineOutcomeTimeout, ErrRecoveryPreparationDeadline)
}
if errors.Is(prepareCtxErr, context.Canceled) || ctx.Err() != nil {
return emitPreparationFailure("caller_canceled", ObservationDeadlineOutcomeFailed, ErrRecoveryCallerCanceled)
}
return emitPreparationFailure("preparation_failed", ObservationDeadlineOutcomeFailed, ErrRecoveryPreparationFailed)
}
if prepareCtxErr != nil {
if errors.Is(prepareCtxErr, context.DeadlineExceeded) {
return emitPreparationFailure("preparation_deadline", ObservationDeadlineOutcomeTimeout, ErrRecoveryPreparationDeadline)
}
return emitPreparationFailure("caller_canceled", ObservationDeadlineOutcomeFailed, ErrRecoveryCallerCanceled)
}
preparedPlan, err := plan.WithPreparedDirective(directive)
if err != nil {
return emitPreparationFailure("preparation_invalid", ObservationDeadlineOutcomeFailed, ErrRecoveryPreparationInvalid)
}
plan = preparedPlan
result = result.withPlan(plan)
if err := releasePreparationSnapshot(); err != nil {
return emitPreparationFailure("preparation_snapshot_release_failed", ObservationDeadlineOutcomeFailed, err)
}
preparerInfo, _ := NewObservationPreparerInfo(plan.PreparerID(), RecoveryPreparationPrepared, ObservationDeadlineOutcomeCompleted)
emitRecoveryObs(ObservationKindRecoveryPrepared, plan, attemptID, attemptTarget, &preparerInfo, result.FailureCauses())
}
if ctx.Err() != nil {
return result.withFailure("caller_canceled"), ErrRecoveryCallerCanceled
}
draft, err := c.rebuilder.RebuildRequest(ctx, c.requestSnapshot, plan)
if err != nil {
result = result.withFailure("request_rebuild_failed")
emitRecoveryObs(ObservationKindRecoveryRebuildFailed, plan, attemptID, attemptTarget, nil, result.FailureCauses())
return result, ErrRecoveryRebuildFailed
}
finalPlan, request, err := plan.FinalizeRebuiltRequest(draft)
if err != nil {
if errors.Is(err, ErrManagedTrajectoryExhausted) {
return result.withFailure("managed_trajectory_exhausted"), ErrManagedTrajectoryExhausted
}
result = result.withFailure("rebuilt_request_invalid")
emitRecoveryObs(ObservationKindRecoveryRebuildFailed, plan, attemptID, attemptTarget, nil, result.FailureCauses())
return result, ErrRecoveryRebuildInvalid
}
plan = finalPlan
result = result.withPlan(plan)
emitRecoveryObs(ObservationKindRecoveryRebuilt, plan, attemptID, attemptTarget, nil, result.FailureCauses())
if ctx.Err() != nil {
return result.withFailure("caller_canceled"), ErrRecoveryCallerCanceled
}
usageAfterDispatch, err := plan.UsageAfterDispatch()
if err != nil {
return result.withFailure("dispatch_budget_ineligible"), err
}
// This write is intentionally immediately before the outbound call. It is
// not rolled back when the dispatcher reports failure.
c.consumeUsage(usageAfterDispatch)
result.usage = copyRecoveryUsageSnapshot(usageAfterDispatch)
binding, err := c.dispatcher.DispatchAttempt(ctx, request)
if err != nil {
result = result.withFailure("attempt_dispatch_failed")
emitRecoveryObs(ObservationKindRecoveryDispatchFailed, plan, attemptID, attemptTarget, nil, result.FailureCauses())
return result, ErrRecoveryDispatchFailed
}
if err := binding.Validate(); err != nil {
result = result.withFailure("attempt_binding_invalid")
emitRecoveryObs(ObservationKindRecoveryDispatchFailed, plan, attemptID, attemptTarget, nil, result.FailureCauses())
return result, ErrRecoveryDispatchFailed
}
reboundTarget, targetErr := NewObservationAttemptTarget(attemptTarget.ModelGroup(), binding.Model(), binding.Provider(), binding.ExecutionPath())
if targetErr != nil {
_ = binding.Controller().AbortAttempt(ctx)
result = result.withFailure("attempt_target_invalid")
emitRecoveryObs(ObservationKindRecoveryDispatchFailed, plan, attemptID, attemptTarget, nil, result.FailureCauses())
return result, ErrRecoveryDispatchFailed
}
c.installBinding(binding)
result.binding = binding
result.hasBinding = true
emitRecoveryObs(ObservationKindRecoveryDispatched, plan, binding.AttemptID(), reboundTarget, nil, result.FailureCauses())
return result, nil
}