Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ jobs:
OAC_HARNESSES: ${{ vars.OAC_HARNESSES }}
OAC_DEFAULT_HARNESS: ${{ vars.OAC_DEFAULT_HARNESS }}
OAC_EXECUTION_CONCURRENCY: ${{ vars.OAC_EXECUTION_CONCURRENCY }}
OAC_TURN_AUTO_RETRIES: ${{ vars.OAC_TURN_AUTO_RETRIES }}
OAC_WRITE_AUDIT_RETENTION: ${{ vars.OAC_WRITE_AUDIT_RETENTION }}
OAC_LOG_LEVEL: ${{ vars.OAC_LOG_LEVEL }}
OAC_DATABASE_PASSWORD: ${{ secrets.OAC_DATABASE_PASSWORD }}
Expand Down Expand Up @@ -352,7 +353,7 @@ jobs:
envsubst '$OAC_PUBLIC_URL $OAC_DATABASE_URL' \
< deploy/kubernetes/prod/core-env.yaml.tpl > "$work/core-env.yaml"
for name in OAC_HARNESSES OAC_DEFAULT_HARNESS OAC_EXECUTION_CONCURRENCY \
OAC_WRITE_AUDIT_RETENTION OAC_LOG_LEVEL; do
OAC_TURN_AUTO_RETRIES OAC_WRITE_AUDIT_RETENTION OAC_LOG_LEVEL; do
value="${!name:-}"
if [ -n "$value" ]; then printf ' %s: "%s"\n' "$name" "$value" >> "$work/core-env.yaml"; fi
done
Expand Down
1 change: 1 addition & 0 deletions deploy/compose/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ services:
OAC_EXECUTION_CONCURRENCY: ${OAC_EXECUTION_CONCURRENCY:-}
OAC_SANDBOX_MAX_ACTIVE: ${OAC_SANDBOX_MAX_ACTIVE:-}
OAC_SANDBOX_MAX_RETAINED: ${OAC_SANDBOX_MAX_RETAINED:-}
OAC_TURN_AUTO_RETRIES: ${OAC_TURN_AUTO_RETRIES:-}
OAC_DEFAULT_HARNESS: ${OAC_DEFAULT_HARNESS:-}
OAC_HARNESSES: ${OAC_HARNESSES:-}
OAC_WRITE_AUDIT_RETENTION: ${OAC_WRITE_AUDIT_RETENTION:-}
Expand Down
2 changes: 1 addition & 1 deletion deploy/compose/test_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def test_compose_uses_private_services_and_ordered_initialization(self):
self.assertEqual(services['web']['healthcheck']['test'], ['CMD', '/usr/local/bin/oac-web', 'healthcheck'])
self.assertNotIn('python3', json.dumps(self.compose))
self.assertEqual(services['init']['environment']['OAC_REVISION'], 'd' * 40)
for name in ('OAC_SANDBOX_MAX_ACTIVE', 'OAC_SANDBOX_MAX_RETAINED', 'OAC_EXECUTION_CONCURRENCY', 'OAC_DEFAULT_HARNESS', 'OAC_HARNESSES', 'OAC_WRITE_AUDIT_RETENTION', 'OAC_LOG_LEVEL'):
for name in ('OAC_SANDBOX_MAX_ACTIVE', 'OAC_SANDBOX_MAX_RETAINED', 'OAC_EXECUTION_CONCURRENCY', 'OAC_TURN_AUTO_RETRIES', 'OAC_DEFAULT_HARNESS', 'OAC_HARNESSES', 'OAC_WRITE_AUDIT_RETENTION', 'OAC_LOG_LEVEL'):
self.assertEqual(services['core']['environment'][name], '', name)

def test_direct_sandbox_capacity_reaches_core(self):
Expand Down
2 changes: 1 addition & 1 deletion deploy/kubernetes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Restrict the repository’s `production` GitHub Environment to deployments from
| `OAC_K8S_INSECURE` | `true` skips API server certificate verification, for an address the certificate does not name. Leave it unset otherwise |
| `OAC_DEPLOY_RUNNER` | Runner label; `ubuntu-22.04` by default. Set this one at repository level: the job that pins the commit runs before the environment is entered, so an environment-scoped value does not reach it |
| `OAC_CORE_CPU_REQUEST`, `OAC_CORE_CPU_LIMIT`, `OAC_CORE_MEMORY_REQUEST`, `OAC_CORE_MEMORY_LIMIT` | Core's resources; `500m`, `2`, `1Gi` and `4Gi` by default |
| `OAC_HARNESSES`, `OAC_DEFAULT_HARNESS`, `OAC_EXECUTION_CONCURRENCY`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_LOG_LEVEL` | The matching [process settings](../../docs/configuration.md#settings), which define their values and defaults |
| `OAC_HARNESSES`, `OAC_DEFAULT_HARNESS`, `OAC_EXECUTION_CONCURRENCY`, `OAC_TURN_AUTO_RETRIES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_LOG_LEVEL` | The matching [process settings](../../docs/configuration.md#settings), which define their values and defaults |

A change to any of these takes effect on the next run: a digest of the environment and the secrets enters both Pod templates, so the Pods are replaced even when the images are unchanged.

Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Model providers are not process settings; see [Default models](#default-models).
| `OAC_EXECUTION_CONCURRENCY` | `4` | Concurrent execution work, from 1 to 1024 |
| `OAC_SANDBOX_MAX_ACTIVE` | `100` | Active sandbox limit for direct Providers with suspension, from 1 to 100000. Independent of execution concurrency and node capacity |
| `OAC_SANDBOX_MAX_RETAINED` | `400` | Retained sandbox limit for direct Providers, from 1 to 100000, including active, suspended and unconfirmed cleanup. Must be at least the active limit |
| `OAC_TURN_AUTO_RETRIES` | `0` | Automatic retries of a Turn that failed on a recoverable model or tool error, from 0 to 5. `0` turns it off |
| `OAC_DEFAULT_HARNESS` | `codex` | Harness used when a request does not name one |
| `OAC_HARNESSES` | Every registered Harness | Comma-separated Harnesses to enable besides the default one. Unknown names stop startup |
| `OAC_WRITE_AUDIT_RETENTION` | `2160h` | Minimum `1h` |
Expand Down Expand Up @@ -272,7 +273,7 @@ Core reads its process environment. Compose interpolates `.env` into it and moun
| `OAC_CREDENTIAL_KEY_FILE` | Required. `/run/oac/credential.key`: a base64-encoded random 32-byte key. Core seals stored credentials with it |
| `OAC_CORE_KEY_DIGESTS_FILE` | Required. `/run/oac/core-key-digests.json`: a JSON array with the SHA-256 of the Core key |
| `OAC_INSTALLATION_ID_FILE` | Required. `/run/oac/installation.id`: the installation ID, a canonical UUID. Core refuses an ID other than the one its database recorded |
| `OAC_SANDBOX_MAX_ACTIVE`, `OAC_SANDBOX_MAX_RETAINED`, `OAC_EXECUTION_CONCURRENCY`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS`, `OAC_HISTORY_SETTINGS_FILE`, `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | The matching [process settings](#settings). Web reads the three log settings too |
| `OAC_SANDBOX_MAX_ACTIVE`, `OAC_SANDBOX_MAX_RETAINED`, `OAC_EXECUTION_CONCURRENCY`, `OAC_TURN_AUTO_RETRIES`, `OAC_DEFAULT_HARNESS`, `OAC_HARNESSES`, `OAC_WRITE_AUDIT_RETENTION`, `OAC_OAUTH_TRUSTED_ORIGINS`, `OAC_HISTORY_SETTINGS_FILE`, `OAC_LOG_LEVEL`, `OAC_LOG_FORMAT`, `OAC_LOG_ADD_SOURCE` | The matching [process settings](#settings). Web reads the three log settings too |
| `OAC_PROVIDER_ROOT` | Absolute adapter artifact root. The Core image sets `/opt/oac`. Each adapter owns its helper paths beneath this root. Core serves self-hosted daemon installers from its `native-installers/` directory when that holds a `catalog.json`, after checking the catalog against its own release. Adapter state lives at `/state`, the data volume's [`state/`](#compose-installations) |

Core logs the history file path it loads, never environment values or file contents.
Expand Down
5 changes: 3 additions & 2 deletions docs/zh/configuration.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "配置参考"
source: docs/configuration.md
source_hash: 04cde38505f1f09ea913ab2a966e09bb571350ecca8c6e100dbd2e77f143547f
source_hash: 9b3a2b1e7dcc2e742b252ebd700a088be56416ee0a293e78a79730df026b3576
---

Core 安装的每项设置都恰好只有一个归属位置,分属以下三类:
Expand Down Expand Up @@ -57,6 +57,7 @@ Core 安装的每项设置都恰好只有一个归属位置,分属以下三类
| `OAC_EXECUTION_CONCURRENCY` | `4` | Concurrent execution work, from 1 to 1024 |
| `OAC_SANDBOX_MAX_ACTIVE` | `100` | 启用暂停能力的直接 Provider 的活跃沙箱上限,范围 1–100000;独立于执行并发度和节点容量 |
| `OAC_SANDBOX_MAX_RETAINED` | `400` | 直接 Provider 的保留沙箱上限,范围 1–100000,包括活跃、已暂停以及尚未确认清理完成的分配;必须不小于活跃上限 |
| `OAC_TURN_AUTO_RETRIES` | `0` | Turn 因可恢复的模型或工具错误失败后的自动重试次数,0 到 5。`0` 表示关闭 |
| `OAC_DEFAULT_HARNESS` | `codex` | Harness used when a request does not name one |
| `OAC_HARNESSES` | Every registered Harness | Comma-separated Harnesses to enable besides the default one. Unknown names stop startup |
| `OAC_WRITE_AUDIT_RETENTION` | `2160h` | Minimum `1h` |
Expand Down Expand Up @@ -276,7 +277,7 @@ Core 读取进程环境。Compose 将 `.env` 插值到环境中,并把机密
| `OAC_CREDENTIAL_KEY_FILE` | 必填。`/run/oac/credential.key`:Base64 编码的 32 字节随机密钥。Core 用它加密存储的凭据 |
| `OAC_CORE_KEY_DIGESTS_FILE` | 必填。`/run/oac/core-key-digests.json`:一个包含 Core 密钥 SHA-256 的 JSON 数组 |
| `OAC_INSTALLATION_ID_FILE` | 必填。`/run/oac/installation.id`:安装 ID,采用规范 UUID 格式。如果 ID 与数据库记录的 ID 不一致,Core 会拒绝它 |
| `OAC_SANDBOX_MAX_ACTIVE`、`OAC_SANDBOX_MAX_RETAINED`、`OAC_EXECUTION_CONCURRENCY`、`OAC_DEFAULT_HARNESS`、`OAC_HARNESSES`、`OAC_WRITE_AUDIT_RETENTION`、`OAC_OAUTH_TRUSTED_ORIGINS`、`OAC_HISTORY_SETTINGS_FILE`、`OAC_LOG_LEVEL`、`OAC_LOG_FORMAT`、`OAC_LOG_ADD_SOURCE` | 对应的[进程设置](#settings)。Web 也读取三个日志设置 |
| `OAC_SANDBOX_MAX_ACTIVE`、`OAC_SANDBOX_MAX_RETAINED`、`OAC_EXECUTION_CONCURRENCY`、`OAC_TURN_AUTO_RETRIES`、`OAC_DEFAULT_HARNESS`、`OAC_HARNESSES`、`OAC_WRITE_AUDIT_RETENTION`、`OAC_OAUTH_TRUSTED_ORIGINS`、`OAC_HISTORY_SETTINGS_FILE`、`OAC_LOG_LEVEL`、`OAC_LOG_FORMAT`、`OAC_LOG_ADD_SOURCE` | 对应的[进程设置](#settings)。Web 也读取三个日志设置 |
| `OAC_PROVIDER_ROOT` | 适配器构件的绝对根目录。Core 镜像设置为 `/opt/oac`。每个适配器都拥有此根目录下的辅助路径。当其中的 `native-installers/` 目录包含 `catalog.json` 时,Core 在核对该目录清单与自身发行版后提供自托管守护进程安装程序。适配器状态位于 `/state`,即数据卷的 [`state/`](#compose-installations) |

Core 会记录所加载的历史文件路径,但绝不记录环境变量的值或文件内容。
Expand Down
2 changes: 1 addition & 1 deletion services/core/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ func run(config processconfig.Config) error {
Credentials: vaultService, Observer: modelConfigurationStore, Deployment: deploymentService, DeploymentReader: deploymentStore,
Sessions: sessionService,
SessionsReader: sessionStore,
ManagedRuntimes: managedNodes.runtime, MaxConcurrentExecutions: config.ExecutionConcurrency}
ManagedRuntimes: managedNodes.runtime, MaxConcurrentExecutions: config.ExecutionConcurrency, TurnAutoRetries: config.TurnAutoRetries}
lease, err := pgunit.AcquireLease(ctx, pool)
if err != nil {
return err
Expand Down
3 changes: 3 additions & 0 deletions services/core/internal/execution/dispatcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ type Dispatcher struct {
// MaxConcurrentExecutions bounds work admitted by this Core execution owner.
// Zero uses DefaultExecutionConcurrency. It is independent of sandbox capacity.
MaxConcurrentExecutions int
// TurnAutoRetries is how many times a Turn that failed on a recoverable model
// or tool error is retried automatically. Zero disables retries.
TurnAutoRetries int
}

// Credentials opens the bearer token of a Session's frozen MCP credential
Expand Down
250 changes: 250 additions & 0 deletions services/core/internal/execution/turn_retry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
package execution

import (
"context"
"encoding/json"
"fmt"
"strings"
"time"

"github.com/MiniMax-AI/OpenAgentCore/internal/obs/log"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions"
)

// MaxTurnAutoRetries bounds OAC_TURN_AUTO_RETRIES; zero turns the feature off.
const MaxTurnAutoRetries = 5

const retryPrefix = "[auto-retry "

// retryBackoff is the wait before attempt n (1-based); later attempts reuse the last value.
var retryBackoff = []time.Duration{5 * time.Second, 15 * time.Second, 30 * time.Second}

// retryableEngineCodes are the engine failure classes a new attempt can plausibly fix.
// An empty code is an unclassified failure. Authentication, quota, missing model,
// context length and policy failures are never retried.
var retryableEngineCodes = map[string]bool{
"": true, "invalid_request": true, "server_error": true, "server_overloaded": true,
"rate_limit_exceeded": true, "request_timeout": true, "connection_failed": true,
}

// retryFingerprints match the lowercased executor error text. Entries without retry
// are failures stored in the executor's native history: a new user message cannot
// remove them, so retrying would only spend tokens. The first match wins.
var retryFingerprints = []struct {
name string
all []string
retry bool
adjust bool // the model can fix it by changing the tool call
}{
{"encrypted_reasoning", []string{"encrypted content", "could not be verified"}, false, false},
{"image_unsupported", []string{"do not support image input"}, false, false},
{"tool_result_image", []string{"tool_result", "input_image"}, false, false},
{"missing_messages", []string{"field messages is required"}, true, false},
{"ends_with_model_turn", []string{"requests ending with a model turn"}, true, false},
{"tool_args", []string{"invalid_tool"}, true, true},
{"tool_args", []string{"tool_use"}, true, true},
{"tool_args", []string{"tool input"}, true, true},
{"tool_args", []string{"invalid arguments"}, true, true},
{"tool_args", []string{"invalid tool"}, true, true},
}

type retryDecision struct {
Retry bool
Fingerprint string
Reason string // why not, for logs
Hint string
}

// decideRetry judges a failed Turn. attempt is the number of retries already made.
func decideRetry(turn sessions.Turn, attempt, limit int) retryDecision {
skip := func(reason string) retryDecision { return retryDecision{Reason: reason} }
var result Result
switch {
case limit <= 0:
return skip("disabled")
case attempt >= limit:
return skip("attempts_exhausted")
case turn.Status != sessions.TurnFailed:
return skip("not_failed")
case !turn.CancelRequestedAt.IsZero():
return skip("cancel_requested")
case json.Unmarshal(turn.Outcome, &result) != nil:
return skip("outcome_unreadable")
case result.ErrorCode != "engine_failed":
return skip("error_code_not_retryable")
case !retryableEngineCodes[result.EngineErrorCode]:
return skip("engine_code_not_retryable")
case permanentHTTPStatus(result.EngineHTTPStatus):
return skip("engine_http_status_not_retryable")
}
name := cmpName(result.EngineErrorCode, "unclassified")
adjust := false
text := strings.ToLower(result.Error)
for _, fp := range retryFingerprints {
matched := true
for _, part := range fp.all {
matched = matched && strings.Contains(text, part)
}
if !matched {
continue
}
if !fp.retry {
return skip("unrecoverable_history:" + fp.name)
}
name, adjust = fp.name, fp.adjust
break
}
return retryDecision{Retry: true, Fingerprint: name, Hint: retryHint(attempt+1, adjust)}
}

// permanentHTTPStatus reports upstream statuses a new attempt cannot fix, which beta
// can carry on a connection_failed classification.
func permanentHTTPStatus(status *int) bool {
if status == nil {
return false
}
switch *status {
case 401, 402, 403, 404:
return true
}
return false
}

func cmpName(value, fallback string) string {
if value == "" {
return fallback
}
return value
}

// retryHint escalates with the attempt number. It never carries the original error text.
func retryHint(attempt int, adjust bool) string {
switch {
case attempt <= 1 && adjust:
return "Check the parameters of your last tool call and correct them before calling it again."
case attempt == 2:
return "If the same error happens again, complete the step a different way and do not repeat the same tool call arguments."
case attempt >= 3:
return "Do not call the tool that just failed again; use the information you already have to give a result."
}
return ""
}

func retryMessage(attempt, limit int, d retryDecision) string {
text := fmt.Sprintf("%s%d/%d] The previous attempt failed (%s). Continue the task from where you stopped.", retryPrefix, attempt, limit, d.Fingerprint)
if d.Hint != "" {
text += "\n" + d.Hint
}
return text
}

// retryAttempt reads how many retries led to this Turn from its first input.
func retryAttempt(inputs []sessions.TurnInput) int {
for _, input := range inputs {
var payload struct {
Input []struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
} `json:"input"`
}
if input.Kind != "message" || json.Unmarshal(input.Payload, &payload) != nil {
continue
}
for _, message := range payload.Input {
for _, part := range message.Content {
var n, m int
if strings.HasPrefix(part.Text, retryPrefix) {
if _, err := fmt.Sscanf(part.Text, retryPrefix+"%d/%d]", &n, &m); err == nil && n > 0 {
return n
}
}
return 0
}
}
}
return 0
}

// retryHost is what a retry needs from Core; Worker plus the Sessions reader provide it.
type retryHost interface {
ListTurns(ctx context.Context, tenant, session, cursor string, limit int, ascending bool) (sessions.TurnPage, error)
ListTurnInputs(ctx context.Context, tenant, session, turn string, after int64, limit int) ([]sessions.TurnInput, error)
SubmitInputs(ctx context.Context, tenant, session, key string, inputs []sessions.Input) ([]sessions.InputReceipt, error)
}

type workerRetryHost struct {
sessions.Reader
worker *Worker
}

func (h workerRetryHost) SubmitInputs(ctx context.Context, tenant, session, key string, inputs []sessions.Input) ([]sessions.InputReceipt, error) {
return h.worker.SubmitInputs(ctx, tenant, session, key, inputs)
}

// maybeAutoRetry schedules a retry for a Turn that just failed. It never blocks the caller.
func (w *Worker) maybeAutoRetry(ctx context.Context, tenant string, turn sessions.Turn) {
limit := w.dispatcher.TurnAutoRetries
if limit <= 0 || turn.Status != sessions.TurnFailed {
return
}
host := workerRetryHost{Reader: w.dispatcher.SessionsReader, worker: w}
go autoRetry(ctx, host, tenant, turn, limit, sleepContext)
}

func sleepContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}

// autoRetry waits, rechecks that the failed Turn is still the newest and not
// cancelled, then submits one retry message. The idempotency key is derived from
// the failed Turn, so a repeated call cannot start a second Turn.
func autoRetry(ctx context.Context, host retryHost, tenant string, turn sessions.Turn, limit int, wait func(context.Context, time.Duration) error) {
logger := log.Ctx(ctx)
read, cancel := context.WithTimeout(ctx, 10*time.Second)
inputs, err := host.ListTurnInputs(read, tenant, turn.SessionID, turn.ID, 0, 5)
cancel()
if err != nil {
logger.Warn("oac-core turn auto-retry skipped", "session_id", turn.SessionID, "turn_id", turn.ID, "decision", "skip:inputs_unavailable")
return
}
attempt := retryAttempt(inputs)
decision := decideRetry(turn, attempt, limit)
fields := []any{"session_id", turn.SessionID, "turn_id", turn.ID, "attempt", attempt + 1, "max", limit, "fingerprint", decision.Fingerprint}
if !decision.Retry {
logger.Info("oac-core turn auto-retry skipped", append(fields, "decision", "skip:"+decision.Reason)...)
return
}
if wait(ctx, retryBackoff[min(attempt, len(retryBackoff)-1)]) != nil {
return
}
submit, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
page, err := host.ListTurns(submit, tenant, turn.SessionID, "", 1, false)
if err != nil || len(page.Turns) != 1 || page.Turns[0].ID != turn.ID {
logger.Info("oac-core turn auto-retry skipped", append(fields, "decision", "skip:newer_turn_or_session_gone")...)
return
}
if !decideRetry(page.Turns[0], attempt, limit).Retry {
logger.Info("oac-core turn auto-retry skipped", append(fields, "decision", "skip:cancel_requested")...)
return
}
payload, _ := json.Marshal(map[string]any{
"type": "agent.session.input.message",
"input": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "input_text", "text": retryMessage(attempt+1, limit, decision)},
}}},
})
if _, err := host.SubmitInputs(submit, tenant, turn.SessionID, "auto-retry:"+turn.ID, []sessions.Input{{Kind: "message", Payload: payload}}); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep automatic retries out of fabricated user history

When OAC_TURN_AUTO_RETRIES is enabled and an eligible failure occurs, this persists Core's control prompt through the normal input path as a role: user message. It therefore creates a public user Item and root Turn, changes Session history and status despite no application request, and provides no provenance by which clients can distinguish the synthetic input. The Sessions contract defines these messages as client-admitted input, so the retry needs a typed representation below the public input boundary rather than inventing new public semantics.

AGENTS.md reference: AGENTS.md:L49-L50

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard retry admission atomically against newer input

If an application submits a follow-up after ListTurns returns but before this call acquires the Session lock, the latest-Turn check passes even though the application has now created a new queued or running Turn. SubmitInputs then treats the stale retry message as steering for that unrelated Turn—or starts another Turn if the follow-up already completed—so a user request receives an internal instruction meant for the preceding failure. The failed-Turn/latest-state precondition must be checked in the same transaction that admits the retry.

Useful? React with 👍 / 👎.

logger.Warn("oac-core turn auto-retry submit failed", append(fields, "decision", "skip:submit_failed")...)
return
}
logger.Info("oac-core turn auto-retry submitted", append(fields, "decision", "retry")...)
}
Loading