From 790c2ba1c92589476b392df6cb890a90ab35e22b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 17 Oct 2026 03:40:31 +0200 Subject: [PATCH] feat(exchange): flag an exchange whose accept outcome is unknown When the accept response is lost, ErrOutcomeUnknown means the exchange may have executed. RunAutoExchange kept the ledger record processing and halted the run, but the outcome in Failed carried only an error string, so a reader could not tell it from an ordinary failure. Add ExchangeOutcome.OutcomeUnknown, set where ErrOutcomeUnknown is handled. The outcome stays in Failed, so existing readers of Failed still see it. Behavior is otherwise unchanged. The manual approve and interactive paths call ExecuteExchange from the platform and cannot receive this flag from pkg; they are tracked in LeanerCloud/cloud-commitments-platform#1050. Refs #443 Co-Authored-By: Claude Opus 5.5 --- pkg/exchange/auto.go | 6 ++++++ pkg/exchange/outcome_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/pkg/exchange/auto.go b/pkg/exchange/auto.go index e8397f56..27e06834 100644 --- a/pkg/exchange/auto.go +++ b/pkg/exchange/auto.go @@ -163,6 +163,11 @@ type ExchangeOutcome struct { ExchangeID string UtilizationPct float64 Error string + // OutcomeUnknown is true when the accept response was lost, so the exchange + // may have executed. It must be reconciled before any retry. The outcome + // stays in AutoExchangeResult.Failed (readers of Failed still see it) and + // halts the run (no further exchanges are attempted). + OutcomeUnknown bool // Simulated is true when RunAutoExchangeParams.DryRun was set. The outcome // was analyzed and quoted but no record was saved and no exchange was executed. Simulated bool @@ -573,6 +578,7 @@ func processAutoExchange(ctx context.Context, params RunAutoExchangeParams, rec logging.Errorf("exchange execution failed for %s: %v", rec.SourceRIID, execErr) outcome.Error = execErr.Error() if errors.Is(execErr, common.ErrOutcomeUnknown) { + outcome.OutcomeUnknown = true // The exchange may have happened: keep the record processing (still // counted against the cap) and stop, instead of marking it failed. return outcome, true diff --git a/pkg/exchange/outcome_test.go b/pkg/exchange/outcome_test.go index ba627a29..f5ca053e 100644 --- a/pkg/exchange/outcome_test.go +++ b/pkg/exchange/outcome_test.go @@ -3,6 +3,7 @@ package exchange import ( "context" "errors" + "fmt" "math/big" "net" "net/http" @@ -111,3 +112,32 @@ func TestRunAutoExchange_UnknownAcceptLeavesRecordProcessingAndHalts(t *testing. require.NotEmpty(t, store.savedRecords) assert.NotEqual(t, "failed", store.savedRecords[0].Status) } + +func TestRunAutoExchange_OutcomeUnknownFlag(t *testing.T) { + t.Parallel() + cases := []struct { + name string + execErr error + wantUnknown bool + wantFailed int + }{ + {"wrapped unknown sets the flag", fmt.Errorf("x: %w", common.ErrOutcomeUnknown), true, 0}, + {"plain error leaves it false", errors.New("boom"), false, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + store := &mockExchangeStore{dailySpend: "0"} + client := &mockExchangeClient{quoteResult: defaultQuote(), executeErr: tc.execErr} + params := defaultParams(store, client) + params.Config.Mode = "auto" + + result, err := RunAutoExchange(context.Background(), params) + require.NoError(t, err) + assert.Equal(t, 1, client.executeCalls) + assert.Equal(t, tc.wantFailed, store.failCalls) + require.Len(t, result.Failed, 1) + assert.Equal(t, tc.wantUnknown, result.Failed[0].OutcomeUnknown) + }) + } +}