fix: prevent silent message loss from EmbeddedAttemptSessionTakeoverError#89039
fix: prevent silent message loss from EmbeddedAttemptSessionTakeoverError#89039Jerry-Xin wants to merge 33 commits into
Conversation
|
Codex review: needs maintainer review before merge. Reviewed July 25, 2026, 12:13 AM ET / 04:13 UTC. ClawSweeper reviewWhat this changesThe PR makes embedded session steering fence-aware, prevents SDK retries while the session lock is released, restores retry handling at the outer reply attempt, and sends actionable resend guidance instead of silently losing a reply after session takeover. Merge readinessThis PR remains necessary: it targets the closed root-cause report’s silent-reply-loss path, and the latest head appears to address the prior configured-retry blocker by moving that budget to the full-attempt owner. The supplied loopback/process proof is substantial, but this is a compatibility-sensitive 31-file change with the current required checks still running, so it should stay open for normal maintainer review rather than cleanup closure. Likely related people: Priority: P1 Review scores
Verification
How this fits togetherOpenClaw’s reply runner invokes an embedded agent while coordinating a durable session transcript and model-provider transport retries. Incoming steering messages and provider failures feed that runner; it must preserve session ownership and then emit either a delivered reply or a clear user-facing failure. flowchart LR
Inbound[Inbound or steering message] --> Queue[Reply queue and prepared run]
Queue --> Lock[Embedded session lock and transcript fence]
Lock --> Model[Model transport call]
Model --> Retry{Provider failure?}
Retry -->|retryable| Outer[Whole-attempt retry owner]
Outer --> Lock
Retry -->|takeover| Guidance[Resend guidance]
Retry -->|success| Delivery[Reply payload and channel delivery]
Guidance --> Delivery
Before merge
Agent review detailsSecurityNone. PR surfaceSource +282, Tests +2294. Total +2576 across 31 files. View PR surface stats
Review metrics
Stored data modelPersistent data-model change detected: Merge-risk optionsMaintainer options:
Copy recommended automerge instructionTechnical reviewBest possible solution: Keep SDK retries disabled only inside the released embedded prompt-lock window, preserve configured retry resilience at the full-attempt owner, and land only after focused configured-retry coverage plus the current head’s required checks confirm the intended upgrade behavior. Do we have a high-confidence way to reproduce the issue? Yes, source-reproducible with high confidence: the supplied end-to-end proof drives a real loopback model transport plus a real session-file fence through the reply runner, covering both a reset connection and an organic takeover. This review did not independently execute that path. Is this the best way to solve the issue? Yes, conditionally: preventing SDK-local retries only while the prompt lock is released and retrying from the full-attempt owner is the narrowest design that protects session ownership while retaining retry resilience. The remaining question is compatibility confirmation for configured retry budgets across fallback cycles. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against c780bfbf0d54. LabelsLabel changes:
Label justifications:
EvidenceWhat I checked:
Likely related people:
Rank-up movesOptional improvements that raise the rating; they are not merge blockers.
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (53 earlier review cycles; latest 8 shown)
|
6c0fb05 to
36ce09b
Compare
|
Rebased on latest upstream/main (965e680, clean rebase, no conflicts). |
36ce09b to
1e7989e
Compare
|
Rebased on latest upstream/main (646df2d) and resolved conflict in |
1e7989e to
8730a7e
Compare
|
Rebased on latest upstream/main (8a9acd2, clean rebase, no conflicts). |
8730a7e to
813557d
Compare
|
Rebased on latest upstream/main (57ea5af, clean rebase, no conflicts). |
813557d to
27ff3b2
Compare
|
Rebased on latest upstream/main (ffbd02f, clean rebase, no conflicts). |
27ff3b2 to
2c5326a
Compare
|
Rebased on latest upstream/main (85d2dd8, clean rebase, no conflicts). |
|
@clawsweeper re-review Addressed all review findings from the previous review:
|
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@clawsweeper re-review Addressed remaining review findings:
|
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
@clawsweeper re-review Addressed remaining P1 findings:
|
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
aa77669 to
5bedf1e
Compare
…ReleaseStreamFn The installEmbeddedPromptRetryDefault wrapper used a narrowed (model, context, options?) signature that is not assignable to agent.streamFn's PromptReleaseStreamFn ((...args: unknown[]) => unknown), breaking tsgo:core and the plugin-sdk dts strict-smoke guard. Switch to a variadic signature that destructures the positional args and narrows the request options safely, preserving the maxRetries:0 default with caller override ordering. Also alias the reassigned catch binding in runAgentTurnWithFallback to satisfy no-ex-assign.
…87180) Each embedded turn re-runs installEmbeddedPromptRetryDefault then installPromptSubmissionLockRelease. The retry-default installer had no install guard, so repeated turns stacked maxRetries:0 wrappers and release/reacquire cycles around a single model call. Both installers wrap agent.streamFn and the lock-release wrapper sits outermost after turn 1, hiding the retry wrapper's marker underneath. A marker on the retry wrapper alone is therefore insufficient: on the next turn the retry installer inspects the outer fn, misses its marker, and wraps again. Each wrapper now propagates the other's marker onto the new outermost fn, so whichever wrapper is outermost carries both markers and both installers stay idempotent across turns.
…nclaw#87180) The embedded prompt retry default wrapper injected a hard maxRetries:0 for SDK calls inside the prompt-lock release window. The sdk.ts seam then computed `optionsLocal?.maxRetries ?? providerRetrySettings.maxRetries`, so the injected 0 shadowed any configured settings.retry.provider.maxRetries (0 ?? 3 = 0). Users who set provider retries silently lost them for embedded prompt submissions. installEmbeddedPromptRetryDefault now accepts the configured provider retry and injects it as the default; the install site in attempt.ts passes settingsManager.getProviderRetrySettings().maxRetries. Precedence is now: explicit per-call options.maxRetries > configured provider maxRetries > 0. Unconfigured still defaults to 0 so SDK retries stay out of the lock-release window where model-fallback owns retries and SDK retries race with session takeover. Adds regression coverage for the wrapper defaults/override and a chain test proving the configured provider maxRetries forwards through to the native SDK request options.
… test The ReplyOperation type gained retainFailureUntilComplete and completeWithAfterClearBarrier; the takeover real-setup test mock was missing both, failing the test-types lane (TS2739). Add them as vi.fn stubs to match the type, mirroring the existing mock in agent-runner-execution.test.ts.
When a gateway restart or user stop has already aborted the reply operation, an EmbeddedAttemptSessionTakeoverError thrown during cleanup or prompt reacquire must not clobber the established lifecycle outcome with run_failed and 'Please resend your message' guidance. Guard the takeover resend early-return so it only fires when the reply operation is not already restart-aborted or user-aborted. In the abort-owned case, fall through to the existing isReplyOperationRestartAbort / isReplyOperationUserAbort checks, which emit the restart lifecycle text or the silent user-abort token. The classification between the takeover branch and those checks has no early return and reads replyOperation.result rather than err, so plain fall-through is correct. Add regression tests for takeover combined with restart abort and user abort, and annotate the normal-takeover test as the guard that the resend path is not over-suppressed.
…penclaw#87180) The idempotent installEmbeddedPromptRetryDefault early-returned on repeat installs, so an already-wrapped session kept injecting the first-captured maxRetries and silently shadowed later configured changes. After turn 1 the retry wrapper is buried beneath the lock-release wrapper, so updating only an outer property was insufficient. Replace the captured local value with a shared mutable ref object that the wrapper reads at call time. A repeat install now updates that ref in place instead of re-wrapping, and the lock-release installer carries the same ref forward by reference so the buried retry wrapper sees refreshed values.
The pure EmbeddedAttemptSessionTakeoverError resend branch returned resend guidance without consuming the pending lifecycle terminal, so lifecycle/status consumers could miss the terminal failure event even though the user saw the resend prompt. Emit the terminal error like the sibling terminal-failure exits in the same catch path, and add a focused regression test asserting a lifecycle error event is emitted on the normal takeover resend path.
The fallback candidate runner now resolves per-candidate fast mode via resolveRunFastModeForFallbackCandidate. The session-takeover real test's agent-runner-utils mock predated that export, so the runner threw a missing-export error before the on-disk lock fence could raise the takeover error, masking the resend-guidance path. Mirror the sibling agent-runner-execution test mock so the real takeover flow runs.
…ator layer The embedded prompt-lock window pins SDK maxRetries to 0 so an in-window retry cannot widen the session-takeover race (openclaw#87180). That correctly suppresses in-window retries, but it also dropped the SDK's default retry of bare connection errors (ECONNRESET, socket hang up, Connection error) for users who have not configured provider retries. Restore that resilience at the orchestrator layer, where each retry re-acquires the session lock and does not widen any single release window: gate the existing one-shot transient retry on connection errors in addition to transient HTTP statuses. Add an isConnectionError predicate that reuses the canonical INTERRUPTED_NETWORK_ERROR_RE so it stays in sync with failover routing, with direct unit coverage plus orchestrator one-retry/bounded-loop tests.
…e cause-neutral Preserve the existing 'Transient HTTP provider error before reply' log wording for status-based transients (downstream tooling and tests key on that exact string); bare connection retries get a distinct 'Transient connection error' label so the cause stays clear in logs. Make the session-takeover resend guidance cause-neutral: a takeover can be triggered by any concurrent transcript write during a released prompt lock (tool, cron, cleanup, or another inbound), not only a connection retry, so the user-facing message no longer attributes it specifically to a retry.
…ed outer retry gate The embedded prompt-lock window pins SDK maxRetries to 0 for unconfigured single-model users, so request-timeout transport errors are rethrown to the outer one-shot retry gate. That gate only retried HTTP-status transients and bare connection errors, so unconfigured single-model users lost the SDK's default timeout retry entirely. Widen the gate to also retry request-timeout transport errors, with its own 'Transient timeout error' diagnostic label. The timeout disjunct is scoped to exclude (a) timeout-only fallback summaries (multi-model failover already ran), (b) CLI subprocess budget kills, and (c) Codex app-server bridge failures -- those read like timeouts but are not transport timeouts and have their own surfaced copy and replay handling. The existing connection-retry path is unchanged. Mirror the connection-retry tests for the timeout path and add exclusion tests for each non-transport family.
…d transient-timeout retry gate SessionWriteLockTimeoutError messages are formatted as 'session file locked (timeout ...ms): ...', which matches the broad isTimeoutErrorMessage predicate. After the timeout disjunct was added to the embedded outer retry gate, a local session write-lock coordination timeout was classified as a transient transport timeout and re-ran the whole model-fallback chain, even though retrying any model hits the same local condition. Exclude non-provider runtime coordination errors from the timeout disjunct using the canonical isNonProviderRuntimeCoordinationError classifier (the same seam model-fallback uses to abort the chain on coordination errors). Add a regression test asserting a lock-timeout-shaped preserved prompt error does not consume a retry (runWithModelFallback called once, terminal run_failed), and add the missing isConnectionError/isTimeoutErrorMessage mock exports to the real-setup takeover test so it imports and runs to its assertions.
… the real-setup takeover mock The ReplyOperation interface gained terminalRecovery and markTerminalRecovery members. The real-setup takeover test's hand-built ReplyOperation mock did not include them, so check-test-types failed on this file. Add the two members (terminalRecovery: false, markTerminalRecovery stub) mirroring the sibling execution test mock, so the test type-checks against the current interface.
…peration mock Upstream added the required ReplyOperation.hasOwnedSessionId member, which the real-setup takeover test mock was missing, breaking check-test-types. Stub it the same way the other reply operation mocks do.
…members Upstream added acceptedSteeredInboundAudio, startedAtMs, lastActivityAtMs, recordActivity, and markAcceptedSteeredInboundAudio to ReplyOperation. Add them to the createMockReplyOperation() literal so check-test-types passes.
The configured provider maxRetries was injected as the SDK default for calls made inside the embedded prompt lock window. That window releases the session lock across the model call, and the outer reply orchestrator owns the single whole-attempt retry (each retry reacquires the lock). An in-window SDK retry therefore runs against a released lock and races session takeover, silently losing the message (openclaw#87180). Pin the in-window SDK retry default to 0 unconditionally. Explicit per-call maxRetries still overrides (spread last). Removes the now-dead configurable-default ref machinery and aligns with the invariant already documented in embedded-agent-helpers/errors.ts.
Upstream moved the transcript-append helper to a test-support module
that no longer consults the withOwnedSessionTranscriptWrites async
context, so the owned steering-write lifecycle test stopped publishing
an owned write and tripped the reacquire fence takeover guard. Route the
steering append through controller.withSessionWriteLock(..., {
publishOwnedWrite: true }) — the same idiom the sibling owned-write
tests use — so the owned write is published and reacquireAfterPrompt
trusts it. Preserves the invariant contrast against the foreign
raw-append case which still triggers takeover.
Align the streamSimple test mock in sdk.test.ts with the tightened provider signature that requires an AssistantMessageEventStreamContract return. The mock still records the forwarded options for retry assertions and now returns a minimal completed assistant done stream so the type matches.
Fix lint max-lines by moving the connection/timeout transient-retry cases into agent-runner-execution-transient-retry.test.ts, drop the now-unused FallbackRunnerParams import, and change the catch alias to const. Update the CLI/Codex timeout assertions to the current surfaced copy and advance the retry backoff by a bounded interval instead of draining all timers so the one-shot orchestrator retry settles deterministically.
…ge entry and reformat Rebase onto upstream moved provider transports into @openclaw/ai (typed host port), so the local openai-transport-stream re-export no longer forwards createOpenAICompletionsTransportStreamFn. Point the two takeover tests at the package entry that still exports it, matching the existing streaming tests. Also reformat two transport source files to satisfy oxfmt.
The two configured-provider maxRetries forwarding tests built their session with a bare ModelRegistry.inMemory() that registered no provider backed by the mocked streamSimple, so the sdk streamFn seam routed through the model registry runtime and the mock never fired (streamSimpleCalls stayed empty). Register a test provider wired to the streamSimple mock and set a runtime API key, matching the existing retry-suite helpers, so the seam's call is captured. Assertions and precedence (explicit per-call > configured provider > 0) are unchanged.
|
Rebased onto latest upstream/main (c780bfb, clean rebase, no conflicts). Head is now f79e5ad. @clawsweeper re-review |
Summary
When the OpenAI SDK retried an
ECONNRESETinternally (defaultmaxRetries=2), the session write lock was released during the retry window. Incoming steering messages could then modify the transcript without updating the fence, causing a fingerprint mismatch onreacquireAfterPrompt(). The resultingEmbeddedAttemptSessionTakeoverErrorwas caught bymodel-fallback.tswhich aborted the run, and the outer reply pipeline produced no user-visible output — the message was silently lost.What Problem This Solves
A user's steering message could be silently dropped during a session takeover. The takeover was made more likely by an in-window SDK retry running against a released session lock, and when it fired the reply pipeline fell into a generic failure branch that emitted no actionable output. This fix closes the race that widened the takeover window and converts the takeover into an explicit, actionable reply so the user knows to resend.
Why This Change Was Made
Two invariants must hold together. First, no in-window SDK retry may run inside the embedded prompt-lock release window, because that window releases the session lock across the model call and an in-window retry re-admits the session-takeover race (#87180). Second, an unconfigured user's bare connection and request-timeout resilience must not regress — so that resilience moves to the orchestrator layer, where each retry re-acquires the session lock and cannot widen any single release window. When a takeover does occur, the runner must surface resend guidance rather than a silent or generic failure.
Root Cause Chain
Fix
Seven coordinated changes that address the bug at multiple layers:
1. Wrap steering in
withOwnedSessionTranscriptWrites(attempt.ts)Steering writes that arrive during prompt lock release now go through
withOwnedSessionTranscriptWrites, keeping fence tracking consistent and preventing the fingerprint mismatch that triggers the takeover error.2. Dedicated error handler for takeover (
agent-runner-execution.ts,failover-error.ts)Added
isEmbeddedAttemptSessionTakeoverError()export and a dedicated catch block inrunAgentTurnWithFallback. Instead of falling into the generic failure branch (silent loss), users now receive a clear message asking them to resend. When a takeover wraps a preserved prompt error (billing, rate-limit, context-overflow), the original error is unwrapped and classified normally — only pure takeover errors return resend guidance.3. Pin SDK retries to 0 inside the embedded prompt lock window (
attempt.ts,attempt.session-lock.ts)installEmbeddedPromptRetryDefaultwraps the session'sstreamFnand forces{ ...options, maxRetries: 0 }— the pin is applied last, so neither the configuredsettings.retry.provider.maxRetriesnor an explicit per-callmaxRetriescan widen the retry count inside this window. The prompt-lock window releases the session lock across the model call, so any in-window SDK retry would run against a released lock and re-admit the session-takeover race (#87180). The configured retry budget is restored at the outer full-attempt owner instead (see the current-head update below), where every retry re-acquires the session lock. Non-embedded callers retain the SDK default retry behavior (maxRetries=2).4. Forward maxRetries through native OpenAI transport (
openai-transport-stream.ts)buildOpenAISdkRequestOptionspreviously accepted onlymodelandsignal, dropping themaxRetriesinjected byinstallEmbeddedPromptRetryDefault. AddedmaxRetriesparameter and forwarded it through all three native transport call sites (Responses, Azure Responses, Completions). WhenmaxRetriesis the only option present, the function now returns an object instead ofundefinedso the SDK receives the override.5. Phase tracking in
EmbeddedAttemptSessionTakeoverError(attempt.session-lock.ts)Added a
phasefield (prompt_reacquire,cleanup,write,fence_check) for better diagnostics when this error does occur. Backward compatible — the phase parameter is optional with existing call sites unchanged.6. Restore unconfigured connection-error retry at the orchestrator layer (
agent-runner-execution.ts,embedded-agent-helpers/errors.ts)Disabling SDK retries inside the prompt-lock release window (#3) is required so an in-window retry cannot widen the takeover race — but for users who have not configured
settings.retry.provider.maxRetries, it also dropped the SDK's default retry of bare connection errors (ECONNRESET,socket hang up,Connection error.) that carry no leading HTTP status. This is restored at the orchestrator layer, where the existing one-shotconsumeTransientHttpRetry()re-runs the full primary→fallback cycle and re-acquires the session lock per attempt, so it does not widen any single release window. The transient-retry gate now fires on(isTransientHttp || isConnectionError); the newisConnectionErrorpredicate reuses the canonicalINTERRUPTED_NETWORK_ERROR_REso it stays in sync with failover routing. In-windowmaxRetries:0is unchanged. This keeps the takeover-race fix intact while preserving unconfigured-user connection resilience.7. Restore unconfigured request-timeout retry at the orchestrator layer (
agent-runner-execution.ts)The orchestrator transient-retry restore in #6 left a symmetric gap: in addition to bare connection errors, the OpenAI/Anthropic SDKs retry request-timeout transport errors by default, and after the in-window
maxRetries:0, an unconfigured single-model user's timeout error is rethrown to the same outer gate — which only matched HTTP-status transients and connection errors, dropping timeout retry entirely (multi-model users were unaffected because failover already routes timeouts). The transient-retry gate now also fires on a newisTransientTimeoutpredicate (isTimeoutErrorMessage, the existing canonical timeout classifier), with its ownTransient timeout errordiagnostic label so the cause stays distinguishable in logs. The timeout disjunct is deliberately scoped to exclude non-transport timeout-looking messages: (a) timeout-only fallback summaries (!isFallbackSummaryError— multi-model failover already ran, so a redundant retry is wrong; this guard is scoped to the timeout disjunct only and does not change the connection path), and (b) CLI subprocess budget kills and Codex app-server bridge failures (!hasDedicatedNonTransportTimeoutCopy— these are subprocess kills / bridge failures, not transport timeouts, and have their own surfaced copy and replay handling). The one-shotconsumeTransientHttpRetry()budget is shared, so timeout retry is bounded exactly like the connection retry. The connection-retry path semantics from #6 are unchanged. The timeout disjunct additionally excludes local non-provider runtime coordination errors (e.g.SessionWriteLockTimeoutError, whose message reads assession file locked (timeout ...ms)) via the canonicalisNonProviderRuntimeCoordinationErrorclassifier: retrying any model would hit the same local condition, so these abort the fallback chain rather than re-run as a transport timeout.User Impact
Evidence
Two complementary proofs cover this fix, both green at the current head
413a69e6d83f6c1612e2bb7aea9192a49a4a3b25:src/auto-reply/reply/agent-runner-takeover-live.e2e.test.ts) that drives the realrunAgentTurnWithFallbackand asserts the user-facing reply.src/**modules and drives the realopenai-completionstransport over real HTTP against a loopback fake model server, capturing real terminal output.Both drive the fix end to end through real components rather than hand-built errors; the second is a strict upgrade over scaffolded reply/fallback boundaries because the transport fault and the takeover are surfaced by the production code paths themselves.
Proof 1 — orchestrator-level e2e (
agent-runner-takeover-live.e2e.test.ts)What is real (no hand-built errors, no mocked runner internals):
node:httpserver acting as the modelbaseUrl(api: openai-completions).openai-completionstransport stream fn (createOpenAICompletionsTransportStreamFn) doing real HTTP I/O.createEmbeddedAttemptSessionLockController), wired through the realinstallEmbeddedPromptRetryDefault(pins SDKmaxRetriesto 0) andinstallPromptSubmissionLockRelease— exactly assrc/agents/embedded-agent-runner/run/attempt.tswires them in production.isConnectionError/isTransientHttpError/isTimeoutErrorMessagefromembedded-agent-helpers/errors.ts) and the real failover classification (isNonProviderRuntimeCoordinationError), consumed by the realrunAgentTurnWithFallbackretry gate and takeover catch.The only injected variables are at real boundaries: a dropped TCP socket (Scenario A) and a concurrent unowned transcript rewrite (Scenario B). The reply-pipeline boundary is scaffolded with the same mock surface as the existing
agent-runner-execution.test.ts, plus a thinrunWithModelFallbackstand-in that runs the real candidate closure once and propagates exactly as the realrunWithModelFallbackdoes (re-throw on failure, return on success).Scenario A — pure transient transport fault. The model server resets the socket on request #1, then succeeds on the retry. Because SDK retries are pinned to 0 inside the released-lock window, the SDK does not re-issue in-window; the orchestrator retries the whole cycle exactly once via
consumeTransientHttpRetry(). Observed and asserted:== 2— one failed request plus exactly one orchestrator retry (the SDK did not retry in-window).OK(not silent loss).Transient connection error before reply (...). Retrying once in 2500ms.was emitted exactly once.Scenario B — organic session takeover → resend guidance. While the real model call holds the released prompt lock, a concurrent unowned rewrite of the same session transcript changes the session-file fingerprint, so
reacquireAfterPromptthrowsEmbeddedAttemptSessionTakeoverErrororganically inside the wrapped call'sfinally. Observed and asserted:== 1— no silent in-window SDK retry.src/auto-reply/reply/agent-runner-execution.ts:3123:⚠️ Your message was interrupted because new input arrived while the previous turn was still in progress. Please resend your message.run_failedlifecycle terminal was recorded via the reply operation.Revert-resistance (each assertion is load-bearing):
maxRetriespin makes Scenario A fail: the SDK silently retries in-window, the orchestrator never engages, and the transient diagnostic count is 0.Reproduce:
Run summary (exact head
413a69e6d83f6c1612e2bb7aea9192a49a4a3b25):Proof 2 — real-process live proof (standalone Node process, real HTTP)
This proof does not use vitest. A standalone throwaway harness runs under a real
node v24.15.0process, imports the realsrc/**modules by absolute path, and drives the realopenai-completionstransport over real HTTP against a loopback-only fake model server on an ephemeral port. It runs fully isolated (OPENCLAW_HOME/HOME/TMPDIR), never touching the running system gateway, and captures the real terminal output — including the production[provider-transport-fetch]logger lines. Both scenarios passed (process exit 0,ALL CHECKS PASSED).Scenario A — real socket destroy → delivered reply. The model server performs a real TCP socket destroy on request #1. The real transport surfaces
UND_ERR_SOCKET/fetch failedthrough the production[provider-transport-fetch]logger; the real classifier reportsisConnectionError=true/isTransientHttpError=false; the whole cycle is retried once; request #2 streamsOK. Proven: request count == 2 (SDK retries pinned to 0 in-window, so the single retry is the orchestrator's, not the SDK's), a visible "OK" reply, and no silent loss.Scenario B — organic session takeover → resend guidance. While the real model call holds the released prompt lock, a concurrent unowned rewrite of the same session file changes its fingerprint, and
reacquireAfterPromptthrowsEmbeddedAttemptSessionTakeoverError(phaseprompt_reacquire) organically. Proven: request count == 1 (no silent in-window SDK retry), the error is a real non-provider coordination error, and the orchestrator maps it to the exact resend-guidance literal.Captured real terminal output (redacted — username, absolute paths, and ephemeral ports scrubbed):
Scope of this proof
This is strong real-transport + real-coordination component proof captured from live processes — a strict upgrade over scaffolded reply/fallback boundaries. The connection reset is a real TCP socket destroy surfaced by the real
openai-completionstransport over real HTTP, the classification and the bounded single retry use the real predicates, the recovery comes back over a real second HTTP call (status=200), and the takeover is triggered organically by a real concurrent file rewrite during a real held-lock window.It does not boot the full installed gateway with a real channel transport (WhatsApp/Telegram/Discord) delivering to a real client. That is because a real channel needs external provider credentials plus live third-party services, while the model side must be a local deterministic fake to reproduce the reset-then-succeed sequence — a real paid model endpoint could not be made to reset then succeed on demand. This is a provisioning/credentials constraint, not a Node-version or code blocker: the model/provider and runtime-coordination path is faithfully reproduced end to end. A literal installed channel roundtrip would need a box provisioned with real (or sandbox) channel + model credentials.
Configured provider retry budget
Earlier revisions left the configured provider retry-budget question — whether
settings.retry.provider.maxRetriessurvives the outer whole-attempt retries — as a follow-up. It is now resolved on the current head (commit0ad78ae26d); see the current-head update below. In-window SDK retries stay pinned at 0 (the safety invariant), and the configured budget is honored only at the outer owner where each retry re-acquires the session lock.Current-head update (
0ad78ae26d)This commit closes the previously-deferred configured-retry gap while preserving the takeover-race safety invariant. Three behaviors:
maxRetries: 0last ({ ...requestOptions, maxRetries: 0 }), so neither the configured provider default nor an explicit per-callmaxRetriescan retry inside the unsafe window where the session lock is released.settings.retry.provider.maxRetriesis resolved once during prepared reply construction (resolveConfiguredProviderRetryMaxRetries) and carried asproviderRetryMaxRetrieson the prepared run to the outer full-attempt retry owner inrunAgentTurnWithFallback, where every retry re-runs the whole cycle and re-acquires the session lock. Unset still defaults to one outer retry (the shipped behavior).Evidence (current head
0ad78ae26d)Focused local proof (
node scripts/run-vitest.mjs):src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts— 109/109 passed (in-window pin wins over config and per-call overrides).src/auto-reply/reply/agent-runner-execution-transient-retry.test.ts— 9/9 passed (outer owner honors the configured budget; unset keeps a single retry).src/agents/sessions/sdk.test.ts— 28/28 passed.A fresh autoreview found one tautological test and it was removed; a re-run then found no actionable issues.
scripts/check-changed.mjs(typecheck/lint fan-out) delegated to the remote Blacksmith Testbox. Package downloads there repeatedly failed with error 23 and the process was SIGKILLed, so this infrastructure-only check did not complete locally. It is not claimed as passing.Changed Files
packages/ai/src/providers/openai-completions.test.tspackages/ai/src/providers/openai-responses-shared.test.tssrc/agents/agent-project-settings.tssrc/agents/embedded-agent-helpers.tssrc/agents/embedded-agent-helpers/errors.tssrc/agents/embedded-agent-helpers/errors.test.tssrc/agents/embedded-agent-runner/run/attempt-stream-prepare.tssrc/agents/embedded-agent-runner/run/attempt.session-lock.tssrc/agents/embedded-agent-runner/run/attempt.session-lock.test.tssrc/agents/embedded-agent-runner/run/attempt.tssrc/agents/failover-error.tssrc/agents/failover-error.test.tssrc/agents/openai-completions-transport.tssrc/agents/openai-responses-transport.tssrc/agents/openai-transport-params.tssrc/agents/openai-transport-stream.streaming.test-utils.tssrc/agents/sessions/sdk.test.tssrc/auto-reply/reply/agent-runner-execution.tssrc/auto-reply/reply/agent-runner-execution.test.tssrc/auto-reply/reply/agent-runner-execution-transient-retry.test.tssrc/auto-reply/reply/agent-runner-session-takeover.real.test.tssrc/auto-reply/reply/agent-runner-takeover-live.e2e.test.tssrc/auto-reply/reply/get-reply-run.tssrc/auto-reply/reply/queue/types.tsFixes #87180