fix(server): interrupt Claude query before closing transport on session close#2178
fix(server): interrupt Claude query before closing transport on session close#2178jasonhnd wants to merge 7 commits into
Conversation
…on close Closing the query first shut ProcessTransport, so interrupt() failed with "ProcessTransport is not ready for writing" and raced the 3s refresh rescue timeout in AgentManager.closeReloadedSession. Capture the query ref, then interrupt → return → close (getpaseo#2034).
…ort contract Add an SDK-faithful harness showing upstream close→interrupt→return always fails with ProcessTransport is not ready for writing, while interrupt→return→close writes successfully. Locks the getpaseo#2034 root-cause contract next to the fix.
|
| Filename | Overview |
|---|---|
| packages/server/src/server/agent/providers/claude/agent.ts | Three-line reorder + one reference capture fixes the transport-writability contract; awaitWithTimeout already swallows errors so the chain is resilient to a racing interrupt failure. |
| packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts | Two new behavioral tests cross the session.close() interface and record observable call order; pattern is consistent with the existing test file's style. |
| packages/server/src/server/hub/test-utils/relationship-harness.ts | Retry set expanded to include EPERM on all platforms; functional but EPERM is a broader error class than ENOTEMPTY/EBUSY and could mask genuine permission issues during teardown — worth confirming the Linux CI flakes were indeed EPERM-driven. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant AM as AgentManager
participant CAS as ClaudeAgentSession
participant Q as Query (SDK)
participant PT as ProcessTransport
AM->>CAS: closeReloadedSession()
CAS->>CAS: "closed = true, capture query ref"
Note over CAS,PT: OLD order (broken)
CAS->>Q: query.close()
Q->>PT: "shutdown (ready=false)"
CAS->>Q: query.interrupt()
Q->>PT: transport.write(...)
PT-->>Q: throw ProcessTransport is not ready
Q-->>CAS: reject
CAS->>AM: races 3s rescue timeout
Note over CAS,PT: NEW order (fixed)
CAS->>Q: query.interrupt()
Q->>PT: transport.write(...) still writable
PT-->>Q: ok
Q-->>CAS: resolve
CAS->>Q: query.return()
Q-->>CAS: resolve awaits cleanup
CAS->>Q: query.close()
Q->>PT: shutdown already settled
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant AM as AgentManager
participant CAS as ClaudeAgentSession
participant Q as Query (SDK)
participant PT as ProcessTransport
AM->>CAS: closeReloadedSession()
CAS->>CAS: "closed = true, capture query ref"
Note over CAS,PT: OLD order (broken)
CAS->>Q: query.close()
Q->>PT: "shutdown (ready=false)"
CAS->>Q: query.interrupt()
Q->>PT: transport.write(...)
PT-->>Q: throw ProcessTransport is not ready
Q-->>CAS: reject
CAS->>AM: races 3s rescue timeout
Note over CAS,PT: NEW order (fixed)
CAS->>Q: query.interrupt()
Q->>PT: transport.write(...) still writable
PT-->>Q: ok
Q-->>CAS: resolve
CAS->>Q: query.return()
Q-->>CAS: resolve awaits cleanup
CAS->>Q: query.close()
Q->>PT: shutdown already settled
Reviews (6): Last reviewed commit: "Merge branch 'main' into fix/2034-claude..." | Re-trigger Greptile
…review Address Greptile P2s: drop redundant spy counts, replace wall-clock timing with deterministic call-order assertions, and remove the standalone SDK proof harness that did not exercise ClaudeAgentSession.
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
Ubuntu CI failed afterEach with ENOTEMPTY while rmdir'ing .paseo/projects after a projects watcher race. Retry ENOTEMPTY/EBUSY/EPERM on all platforms instead of only treating ENOTEMPTY as retryable on darwin.
Summary
Closes #2034.
Refreshing or resuming a Claude-backed agent closes the previous session via
AgentManager.closeReloadedSession. Onmain,ClaudeAgentSession.close()tears the SDK query down in this order:query.close()— shutsProcessTransport(stdin cleared,ready=false)query.interrupt()— control request that musttransport.write(...)query.return()That order violates the Claude Agent SDK contract: after
close(), the transport is not writable, sointerrupt()fails withProcessTransport is not ready for writing. The failure is logged fromClaudeAgentSession.close, and the whole close path then races the ~3s refresh rescue timeout (Timed out closing previous session during refresh).This was introduced by
771acd11a(“close query stream before interrupt/return”).Fix
this.queryafterreturn()).awaitWithTimeout+ tree-kill behavior unchanged.Evidence
Query.interrupt()is a streaming control request;ProcessTransport.writethrows exactlyProcessTransport is not ready for writingwhen closed.Query.close()starts cleanup without awaiting;return()awaits the same cleanup.ProcessTransport.write→interrupt→ClaudeAgentSession.close→closeReloadedSession.Test plan
npx vitest run packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts --bail=1(9/9)npx vitest run packages/server/src/server/agent/providers/claude/agent.close-order-proof.test.ts --bail=1(4/4)daemon.logno longer showsProcessTransport is not ready for writingonclose query interrupt