fix(pi): keep turn open while Pi auto-retries transient errors (willRetry) - #1597
Conversation
|
+1. I reproduced the same Pi transient termination behavior through cc-connect: the model can emit |
|
Thanks for reproducing and confirming the issue! Your validation really helps. I agree — handling willRetry at the protocol level is much cleaner than each adapter implementing its own retry heuristics. Appreciate the +1 🙌 |
chenhg5
left a comment
There was a problem hiding this comment.
Conclusion: Approve
Overall assessment:
- This is exactly the kind of adapter-scoped fix we want for an upstream quirk: Pi's
agent_end.willRetry=truewas being ignored, so every transient provider failure (429 / 5xx / overloaded) was being surfaced as a turn-fatalEventErroreven though the agent was about to recover it within seconds. The PR fixes that by deferring the assistanterrorMessageinto apendingErrbuffer that is flushed only when the turn truly ends. The change is contained toagent/pi/session.go+ its test file, does not touch the engine or any other adapter, and the buffer is flushed from one of two well-defined exit points (agent_end.willRetry=falseandsendJSONexit). Three new tests cover the three observable paths (willRetry keeps the turn open, terminal flushes the error, successful retry drops the buffer), andTestHandleMessageEnd_AssistantErroris correctly updated to assert deferred behavior. Tests are green.
Review scope:
- Reviewed
agent/pi/session.go(the newpendingErrfield, thehandleEvent("agent_end")branch, thehandleMessageEnd("assistant")change, and thesendJSONexit flush) plusagent/pi/pi_test.go. - Focused on adapter scope, race safety (single-goroutine invariant), error UX on the truly-fatal path, and backward compatibility for Pi builds that don't emit
willRetry.
✅ What looks good:
- Adapter-scoped, no engine or platform touches. This is the right shape for a quirk in one upstream agent's protocol — the engine continues to treat
EventErroras turn-fatal, and only the pi adapter decides when to emit it. - Thread-safety argument is sound.
pendingErris documented as "Only written from handleEvent, which runs on a single goroutine per mode." Verified: in JSON mode,sendJSONis the calling goroutine andhandleEventruns in its own stdout-read loop synchronously insidesendJSON(no separate goroutine); in RPC mode,sendRPConly writes to stdin, whilereadLoopRPCis the only goroutine that callshandleEvent. So the "one writer per mode" invariant holds, no atomic / mutex needed. - Truly-fatal errors still get reported. When
agent_end.willRetryis false/absent, the bufferedpendingErris emitted beforeEventResult— so an actual final 400 / 500 still surfaces asEventError, just one event later. Verified byTestHandleEvent_AgentEndFlushesPendingError. Older Pi builds withoutwillRetrybehave exactly as before but with this one-event deferral. - Robust exit-time flush.
sendJSONflushespendingErron process exit in case no terminalagent_endwas observed (e.g. agent crashed mid-retry). This closes the "Pi retries -> pi process crashes before the second agent_end" gap and matches the spirit of thestderr-basedEventErroralready emitted inreadLoopJSON. - PR description is exemplary. "Problem / root cause / fix / verification / tests / notes" with explicit pointers to Pi's
dist/core/agent-session.jsfor thewillRetrysemantics. Note thatwillRetryis documented as abooleanin the SDUI; the test uses bothtrueandfalseliterals and verifies they are handled correctly.
🚨/🔴 Must fix:
- None.
🟠 Should improve:
- Minor: "successful retry drops pending error" is asserted on, but the buffer-drop is only implicit.
TestHandleEvent_AgentEndRetrySuccessDropsPendingErrorchecks the event sequence but does not assert ons.pendingErr == ""after the secondmessage_end. Adding that assertion would lock in the "healthy assistant message supersedes any earlier error" contract inhandleMessageEnd. Quick one-liner. - Minor: consider documenting the deferral in
core.EventErrordoc comments / CHANGELOG. Other consumers (messaging platforms that key offEventErrorto surface a "failed" UX) may notice the one-event delay for older Pi builds. Worth noting in the adapter's own section of CHANGELOG so power users running a pinned Pi version understand the behavior change.
🔵 Optional:
- The PR description mentions a follow-up idea: render a transient "retrying…" hint on the platform when
willRetryis seen (e.g. via the progress card). Consider filing that as a follow-up ticket; it is genuinely useful UX for users hitting rate-limited providers. - A small comment near the new
pendingErrfield noting that older Pi builds (nowillRetry) still rely on the terminal-agent_endflush path would help future readers grep for the protocol version constraint.
❓ Questions:
- Is there a CHANGELOG entry we should add for the one-event deferral of truly-final errors on older Pi builds? Probably yes — worth confirming with the author / PM.
Testing / Risk:
- Verified:
go test -race ./agent/pi/... -count=1 -timeout 60s(7.0s, all green); new + updated tests pass. FullTestHandleEvent_*andTestHandleMessageEnd_*suite green. - Blast radius is
agent/pi/only. Nocore/, no platform, no CLI touched. - Backward compatibility:
- Newer Pi (with
willRetry): transient errors no longer surface mid-turn. Truly-final errors still surface, just one event later. This is the intended behavior change and unblocks users on rate-limited providers (Kimi, etc.). - Older Pi (without
willRetry): truly-final errors surface one event later, with no other behavior change. See 🟠 / ❓ above.
- Newer Pi (with
Next step:
- Merge. Suggest adding the two 🟠 minor test/CHANGELOG items in a follow-up PR (or in this one if the author prefers). Solid first-time contribution from Duliy.
…etry) Pi's agent loop auto-retries transient provider failures (HTTP 429 rate limits, 5xx, network errors) inside the same turn: it emits agent_end with willRetry=true, then re-runs the loop and emits a fresh agent_start/agent_end cycle. cc-connect did not read willRetry and surfaced every assistant errorMessage immediately as EventError. The engine treats EventError as turn-fatal: it finalizes the progress card as failed, pushes the error to the platform, and ends the turn — while Pi is still recovering in the background. The retried run's events are then drained as stale and the user never sees the recovered result, making every transient 429 look like a hard failure. Fix in the pi adapter (both json and rpc modes): - Buffer the latest assistant errorMessage in pendingErr instead of emitting EventError right away; a subsequent healthy assistant message clears it. - On agent_end with willRetry=true, keep the turn open and wait for the retry outcome. - On a terminal agent_end (willRetry absent/false), flush the buffered error as EventError before closing the turn, preserving the existing failure UX for errors that are truly final. - sendJSON also flushes pendingErr on process exit as a fallback. Tests: update TestHandleMessageEnd_AssistantError for the deferred behavior and add coverage for willRetry keeping the turn open, pending error flush on terminal agent_end, and pending error dropped after a successful retry.
cb34cef to
b955b1d
Compare
|
Thanks for the thorough review! All items addressed in the rebased branch (b955b1d): 🟠 Done:
🔵 Follow-up: I'll file the transient "retrying…" platform hint as a separate ticket so it doesn't block this one. Rebased onto latest From my side this is ready to merge — let me know if anything else is needed. 🙏 |
…notice Resolve conflicts caused by #1597 (willRetry turn-open) and #1693 (v1.5.0-beta.3 P1 stability) landing on main after #1685 was branched. Conflict in agent/pi/session.go (agent_end case): - PR #1597 (origin/main): keep turn open on willRetry=true, surface deferred pendingErr on real close, emit EventResult on rpc mode. - PR #1685 (HEAD): emit a transient EventNotice via emitRetryNotice before falling through so the progress card stays informative. Resolution: call emitRetryNotice first (it's a no-op when willRetry=false because buildRetryHint returns ok=false for normal agent_end), then keep #1597's willRetry break + pendingErr flush + rpc EventResult path. This preserves #1597's turn-open invariant while adding #1684's progress hint.
Problem
Pi (earendil-works/pi-coding-agent) has a built-in agent-level auto-retry for transient provider failures (HTTP 429 rate limits, 5xx, overloaded, network errors). When a run ends with a retryable error, Pi emits
agent_endwithwillRetry: true, then re-runs the agent loop and emits a freshagent_start/agent_endcycle. (Seedist/core/agent-session.js:_emit({ ...event, willRetry: this._willRetryAfterAgentEnd(event) }).)cc-connect's pi adapter doesn't read
willRetry, and surfaces every assistanterrorMessageimmediately asEventError(handleMessageEnd). The engine treatsEventErroras turn-fatal (core/engine.go: finalize failed card, push error to platform,return). As a result, on every transient 429:EventErrorends the loop; rpc mode: firstagent_endalso emitsEventResult Done);drained stale events from previous turn) and the user never sees the result.Real-world impact: with rate-limited providers (e.g. Kimi
rate_limit_error: The engine is currently overloaded), users see "429 error → silence" on virtually every busy turn, even though Pi's retry succeeds almost every time. May be related to #1499 / #1498.Fix
In
agent/pi/session.go(shared by json and rpc modes):errorMessagegoes intos.pendingErrinstead of an immediateEventError. A subsequent healthy assistantmessage_endclears the buffer.willRetrykeeps the turn open: onagent_endwithwillRetry: true, emit nothing — the retried run produces a fresh event cycle.agent_endflushes: withwillRetryabsent/false, a buffered error is emitted asEventErrorbefore the turn closes — the failure UX for truly-final errors is unchanged (and older Pi builds withoutwillRetrybehave exactly as before, just deferred by one event).sendJSONexit fallback: flushespendingErron process exit in case no terminalagent_endwas observed.Verified against pi-coding-agent 0.81: both
--mode jsonand--mode rpcstream the same session events, soagent_end.willRetryis available in both modes.Tests
TestHandleMessageEnd_AssistantErrorfor deferred buffering.TestHandleEvent_AgentEndWillRetryKeepsTurnOpen,TestHandleEvent_AgentEndFlushesPendingError,TestHandleEvent_AgentEndRetrySuccessDropsPendingError.go build ./...,go vet ./agent/pi/,go test ./agent/pi/andgo test ./core/all pass (go 1.25.0).Notes
willRetryis seen, e.g. via the progress card.