Skip to content

fix(server): interrupt Claude query before closing transport on session close#2178

Open
jasonhnd wants to merge 7 commits into
getpaseo:mainfrom
jasonhnd:fix/2034-claude-refresh-close-transport
Open

fix(server): interrupt Claude query before closing transport on session close#2178
jasonhnd wants to merge 7 commits into
getpaseo:mainfrom
jasonhnd:fix/2034-claude-refresh-close-transport

Conversation

@jasonhnd

Copy link
Copy Markdown
Contributor

Summary

Closes #2034.

Refreshing or resuming a Claude-backed agent closes the previous session via AgentManager.closeReloadedSession. On main, ClaudeAgentSession.close() tears the SDK query down in this order:

  1. query.close() — shuts ProcessTransport (stdin cleared, ready=false)
  2. query.interrupt() — control request that must transport.write(...)
  3. query.return()

That order violates the Claude Agent SDK contract: after close(), the transport is not writable, so interrupt() fails with ProcessTransport is not ready for writing. The failure is logged from ClaudeAgentSession.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

  • Capture the query reference before teardown (the pump may clear this.query after return()).
  • Order: interrupt → return → close.
  • Keep existing awaitWithTimeout + tree-kill behavior unchanged.

Evidence

  • SDK Query.interrupt() is a streaming control request; ProcessTransport.write throws exactly ProcessTransport is not ready for writing when closed.
  • SDK Query.close() starts cleanup without awaiting; return() awaits the same cleanup.
  • Issue bug: Claude session refresh interrupts an already-closed transport and can time out #2034 production stack matches: ProcessTransport.writeinterruptClaudeAgentSession.closecloseReloadedSession.
  • Harness tests lock the broken vs fixed order contracts.

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)
  • Pre-commit format / lint / typecheck
  • Pre-push lint / typecheck / build
  • CI green on this PR
  • Optional manual: refresh a long Claude session and confirm daemon.log no longer shows ProcessTransport is not ready for writing on close query interrupt

jasonhnd added 2 commits July 18, 2026 10:01
…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.
@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a session teardown ordering bug where ClaudeAgentSession.close() called query.close() (which shuts down ProcessTransport) before query.interrupt(), causing interrupt to throw "ProcessTransport is not ready for writing" and racing the 3-second refresh rescue timeout in AgentManager.closeReloadedSession.

  • agent.ts: Reorders teardown to interrupt → return → close, and captures the query reference before teardown so the pump clearing this.query after return() doesn't lose the reference.
  • agent.interrupt-restart-regression.test.ts: Adds two behavioral tests that go through session.close() and record observable call order via a callOrder array, confirming both the happy path and the interrupted-transport path continue teardown correctly.
  • relationship-harness.ts: Broadens retry logic in removeRoot() to retry ENOTEMPTY, EBUSY, and EPERM on all platforms (not only darwin/win32) to fix Linux CI flakes in afterEach teardown.

Confidence Score: 5/5

Safe to merge — the core change is a minimal, well-scoped reordering of three existing method calls with a captured reference guard, backed by two new behavioral tests that directly exercise the fixed path.

The agent.ts fix is a three-line reorder with a one-line capture; the old order was provably wrong per the SDK contract and the new order is verified by both the new tests and the existing awaitWithTimeout error-swallowing behavior. The test additions follow the project state-recording pattern. The harness change is an independent test-stability improvement.

The EPERM addition in relationship-harness.ts is the only thing worth a second look — confirm the Linux CI flakes were genuinely EPERM-driven before landing.

Important Files Changed

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
Loading
%%{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
Loading

Reviews (6): Last reviewed commit: "Merge branch 'main' into fix/2034-claude..." | Re-trigger Greptile

Comment thread packages/server/src/server/agent/providers/claude/agent.close-order-proof.test.ts Outdated
…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.
@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Want your agent to iterate on Greptile's feedback? Try greploops.

jasonhnd added 4 commits July 18, 2026 13:29
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: Claude session refresh interrupts an already-closed transport and can time out

1 participant