Repository navigation
Conversation
|
Follow-up: filed anomalyco/opencode#38088 for the opencode-side gap (the MCP transport layer itself does not lazily reconnect after a transient ). This PR independently recovers most occurrences from the plugin side; the opencode issue is the deeper transport-layer fix. |
Context: relationship to upstream opencode issueWhile investigating the root cause behind this fix, I filed anomalyco/opencode#38088 and it was flagged as a duplicate of the canonical tracker #25287 (still OPEN). I've closed #38088 as a duplicate. Why this PR is still independently useful
This PR addresses the same class of failure from the plugin side: when the LSP daemon socket closes/errors mid-request, Layering
Once the core fix in #25287 ships, this plugin-side retry becomes defense-in-depth rather than the primary recovery path — but both layers are independently useful today, and the plugin-side fix remains valid even after the core fix lands (cheap idempotent reset, no behavior change on the happy path). |
|
[sisyphus-bot] Retry semantics change: mid-request socket close now retries instead of failing. This is a behavioral tradeoff — idempotent operations benefit from resilience, but non-idempotent operations risk double-execution. Recommend owner review to confirm this is safe for all tool call types. CI test/typecheck runs needed. |
MoerAI
left a comment
There was a problem hiding this comment.
Thanks for reproducing read-only recovery through a real daemon and for keeping the patch small.
Do not globally classify post-write socket close or error as unwritten. That reverses the existing at-most-once boundary, retries persisted install_decision, and would also replay the current disk-writing format tool:
https://github.com/code-yeongyu/oh-my-openagent/blob/b5cbae3fb8778a06f70aa7aada35c8be72f0dba0/packages/lsp-core/src/tools/definitions.ts
Restrict post-write retries to an explicit allowlist of idempotent tools, or preserve at-most-once behavior when commit status is indeterminate. Add a close-after-dispatch regression proving every non-idempotent tool executes once while read-only tools can recover.
The daemon's idle shutdown already returns while a connection is active, so do not rely on idle teardown as proof that a closed in-flight request was uncommitted.
Please update onto current dev, retain the read-only restart coverage, and rerun the current required CI contexts:
https://github.com/code-yeongyu/oh-my-openagent/rules/17777319
…ing out When the daemon closes or errors on the socket after a request has been written, the client previously reported requestWritten=true, which the retry loop treats as a terminal failure (the request may have reached the daemon and could produce a duplicate side effect). In practice, the daemon only closes mid-request when its backing language server has been killed (idle shutdown, OOM, crash, parent-proxy exit), so the request never produced a result and the user is left without one. Pass requestWritten=false from both the close and error handlers so the caller retries against a freshly ensured daemon. Mutating tools are still gated by isRetryableTool(), so rename stays single-shot. Timeout keeps requestWritten=true because the daemon may still be processing the original request when the timer fires. Tests: - The previous 'no retry happens' close test is split: the retry-now- succeeds case asserts the second ensure reopens the daemon and the second attempt returns 'ok', and the all-three-attempts-close case asserts the terminal failure still surfaces 'daemon connection closed' with requestCount=3. - daemon-client-retry.test.ts: 11 pass / 0 fail. - biome check + tsc --noEmit clean.
… tools Address PR review: globally classifying post-write socket close/error as unwritten reversed the at-most-once boundary, retried persisted install_decision writes, and replayed the disk-writing format tool. - Introduce an explicit idempotent-tool allowlist (retryable-tools.ts): status, diagnostics, goto_definition, find_references, symbols, prepare_rename. The close/error handlers report requestWritten=true for non-idempotent tools (terminal failure, no resend) while read-only tools keep the retry-on-fresh-daemon recovery. - Rename the retry gate semantics: isRetryableTool now consults the allowlist instead of a rename-only denylist. - Add close-after-dispatch regressions: format and install_decision execute exactly once (requestCount=1, single ensure), while diagnostics recovers on the second attempt after a daemon reopen. Tests: daemon-client-retry.test.ts 14 passed (11 existing + 3 new). lsp-daemon suite: 165 passed (22 files). tsc --noEmit clean. biome check clean.
24a3bd0 to
62ab5fc
Compare
|
Thanks for the thorough review — all points addressed in 62ab5fc (rebased onto current dev): Idempotent allowlist replaces the global classification. New Retry gate renamed accordingly. Close-after-dispatch regressions added (daemon-client-retry.test.ts):
Idle-teardown point noted — no longer relied upon anywhere; the allowlist itself is what bounds retryability. Suite: lsp-daemon 165 passed (22 files), daemon-client-retry 14 passed (11 existing + 3 new). |
Summary
When the shared LSP daemon closes or errors on the socket after a request has been written, the client reports
requestWritten=true, which the retry loop incallToolViaDaemontreats as terminal:In production this is almost always wrong. The daemon only closes a connected socket mid-request when one of the following happens:
lsp-core/src/lsp/manager.ts, 5 min default) and the backing LSP process — and therefore the in-flight request — is torn down,In every one of those cases the request never produced a result, but the client still surfaces
LSP daemon unreachable: daemon connection closedand never tries again — even thoughensureDaemonRunningwould respawn the daemon on the next attempt. End-user impact is a recurringMCP connection closed server=lspWARN in opencode that resolves only after the next, unrelated tool call lazy-spawns a fresh daemon.Changes
packages/lsp-daemon/src/daemon-client.ts: thecloseanderrorsocket handlers now reject withrequestWritten=false. This lets the existing 3-attempt retry loop re-ensure the daemon and resend. Timeouts still reject with the liverequestWrittenvalue, since the daemon may still be processing the original request when the timer fires.isRetryableTool(name)already gatesrename/lsp_rename, so this change does not introduce duplicate-rename risk.packages/lsp-daemon/test/daemon-client-retry.test.ts: the prior#given the server closes the connection after reading the request #then no retry happenstest encoded the old terminal-on-close behavior, so it is split:#given the server closes the connection after reading the request #when the next ensure reopens the daemon #then the second attempt succeeds— first server closes mid-request, the secondensureswaps in a stable server, and the second attempt returns"ok".#given every attempt closes the connection after reading the request #when all three retries close #then the call is reported as a daemon connection failure— three close-in-a-row attempts still surfacedaemon connection closedand now assertrequestCount === 3(was1).QA & Evidence
expected "ok" — received: "LSP daemon unreachable: daemon connection closed", and the all-three-close test failed withexpected requestCount 3, received 1.packages/lsp-daemon/test/daemon-client-retry.test.ts: 11 pass / 0 fail (was 10 pass / 0 fail before this PR).packages/lsp-daemonfull suite: 114 pass. The 8 pre-existing failures are unrelated vitest-v4 / bun-runner environment issues (vi.mocked,vi.advanceTimersByTimeAsync,./package.jsonresolution under bun), all of which fail identically onupstream/devwithout this change.npx tsc --noEmit(projectpackages/lsp-daemon): clean.biome check src/daemon-client.ts test/daemon-client-retry.test.ts: clean.Risks & Residuals
rename, whichisRetryableTool()already excludes, so this risk is theoretical for the current toolset and is the same shape of risk the existing retry-after-auth-refresh path already accepts.MCP connection closedWARN surfacing in opencode (a separate transport-reconnect concern tracked locally; an upstream opencode issue will be filed for that side).Related
requestWritten-on-close gap;gh issue list --search "requestWritten connection close retry"returned no matches.Summary by cubic
Retries LSP daemon calls when the socket closes or errors mid-request, but only for idempotent read-only tools; previously any close/error after the request was written surfaced immediately as "daemon connection closed" even though the daemon would respawn on the next attempt.
close/errorhandlers indaemon-client.tsnow reportrequestWritten=falseonly for idempotent tools; timeouts still report the live value.retryable-tools.tswith an explicit allowlist of idempotent tools (status,diagnostics,goto_definition,find_references,symbols,prepare_rename), replacing the old rename-only deny list.rename,install_decision,format) keep at-most-once semantics and are never retried after a close.Written for commit ff671c8. Summary will update on new commits.