PR #3338
Sections
Review

fix(agentctl): stop workflow step transitions from leaking claude subprocesses

main ← feature/acp-context-reset-le-v96 4 files +210 −5 PR #3338 ↗

ResetSession now closes the superseded ACP session after a successful reset, so workflow step transitions no longer leak Claude child processes.

Why this change

Workflow step transitions call ResetSession to start a fresh session. The old session spawns a Claude child process that never closes, so processes leak across steps.

What it does

Architecture, end to end

Workflow step transition triggers ResetSession. The adapter creates a new session, then closes the old one to free the Claude subprocess.

flowchart LR
  Workflow[Workflow engine] --> Reset[Adapter.ResetSession]
  Reset --> New[session/new]
  New --> Close[session/close]
  Close --> Claude[Claude subprocess]
  Claude -- released --> Free[Resources freed]
  Reset --> Guard[Concurrency guard]
  Guard -- checks --> Close

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

func (a *Adapter) ResetSession(ctx context.Context, mcpServers []types.McpServer) (string, error)
Click for details →

ResetSession now captures the old session and closes it after a successful new session, with guards for concurrency and capability.

ResetSession and close helper
// ResetSession creates a new session on the existing connection, effectively resetting
// the agent's conversation context without restarting the subprocess. This is much faster
// than a full process restart since the ACP protocol supports multiple sessions per connection.
//
// The session/new call spawns a fresh agent-side child process without releasing the
// superseded one, so a successful reset closes the outgoing session to release its
// resources. The old session is captured before NewSession overwrites a.sessionID.
func (a *Adapter) ResetSession(ctx context.Context, mcpServers []types.McpServer) (string, error) {
	return a.NewSession(ctx, mcpServers)
	a.mu.RLock()
	previous, conn := a.sessionID, a.acpConn
	a.mu.RUnlock()

	newID, err := a.NewSession(ctx, mcpServers)
	if err != nil {
		return "", err
	}
	a.closeSupersededSession(ctx, conn, previous, newID)
	return newID, nil
}

// closeSupersededSessionTimeout bounds session/close after a reset so cleanup
// can't hang the caller when the agent doesn't respond.
const closeSupersededSessionTimeout = 10 * time.Second

// closeSupersededSession releases the session a successful reset just replaced.
func (a *Adapter) closeSupersededSession(ctx context.Context, conn *acp.ClientSideConnection, previous, newID string) {
	if previous == "" || previous == newID || conn == nil {
		return
	}
	a.mu.RLock()
	supportsClose := a.capabilities.SessionCapabilities.Close != nil
	stillCurrent := a.sessionID == newID
	a.mu.RUnlock()
	if !supportsClose || !stillCurrent {
		return
	}

	closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), closeSupersededSessionTimeout)
	defer cancel()
	if _, err := conn.CloseSession(closeCtx, acp.CloseSessionRequest{
		SessionId: acp.SessionId(previous),
	}); err != nil {
		a.logger.Warn("failed to close superseded session after reset",
			zap.String("session_id", previous), zap.Error(err))
	}
}
Mock agent advertises session closeapps/backend/cmd/mock-agent/main.go ↗
func (a *mockAgent) Initialize(_ context.Context, _ acp.InitializeRequest) (acp.InitializeResponse, error)
Click for details →

The mock agent now advertises SessionCapabilities.Close so tests can exercise the new close path.

Initialize
	return acp.InitializeResponse{
		ProtocolVersion: acp.ProtocolVersionNumber,
		AgentCapabilities: acp.AgentCapabilities{
			LoadSession:     true,
			McpCapabilities: acp.McpCapabilities{Sse: true},
			Meta:            meta,
			SessionCapabilities: acp.SessionCapabilities{
				Close: &acp.SessionCloseCapabilities{},
			},
			Meta: meta,
		},
	}, nil
}
CloseSession cleanup
func (a *mockAgent) CloseSession(_ context.Context, req acp.CloseSessionRequest) (acp.CloseSessionResponse, error) {
	a.mu.Lock()
	delete(a.sessions, req.SessionId)
	delete(a.sessionConfig, req.SessionId)
	delete(a.commandsEmitted, req.SessionId)
	a.mu.Unlock()
	_ = os.Remove(overloadedCounterPath(req.SessionId))
	_ = os.Remove(transportLostCounterPath(req.SessionId))
	return acp.CloseSessionResponse{}, nil
}
func TestResetSessionClosesSupersededSessionWhenAdvertised(t *testing.T)
Click for details →

New tests verify close happens when advertised, and is skipped on concurrency, missing capability, failure, or close error.

Happy path
func TestResetSessionClosesSupersededSessionWhenAdvertised(t *testing.T) {
	adapter, capture := newSessionRequestCaptureAdapter(t, acpsdk.McpCapabilities{})
	adapter.capabilities.SessionCapabilities.Close = &acpsdk.SessionCloseCapabilities{}
	firstID, _ := adapter.NewSession(context.Background(), nil)
	secondID, _ := adapter.ResetSession(context.Background(), nil)
	closes := capture.recordedCloseRequests()
	// closes[0].SessionId == firstID
}
Concurrency guard
func TestResetSessionSkipsCloseWhenSessionChangedConcurrently(t *testing.T) {
	adapter, capture := newSessionRequestCaptureAdapter(t, acpsdk.McpCapabilities{})
	adapter.capabilities.SessionCapabilities.Close = &acpsdk.SessionCloseCapabilities{}
	firstID, _ := adapter.NewSession(context.Background(), nil)
	secondID, _ := adapter.NewSession(context.Background(), nil)
	_ = adapter.LoadSession(context.Background(), firstID, nil)
	adapter.closeSupersededSession(context.Background(), adapter.acpConn, firstID, secondID)
	// no close when session is current again
}
Docs: session/close in ACP protocolapps/backend/internal/agentctl/AGENTS.md ↗
ACP Protocol
Click for details →

Documentation now lists session/close as a supported ACP request and notes the adapter file split.

Protocol line
The `acp` transport is split by concern across `adapter_*.go` files: `adapter.go` (core/lifecycle), `adapter_session.go` (initialize/new/load/resume), `adapter_prompt.go` (prompt/cancel), `adapter_updates.go` (`session/update` notification fan-out), `adapter_tools.go` (`convertToolCallUpdate` / `convertToolCallResultUpdate` -> normalized payloads), `adapter_permissions.go`, and `adapter_helpers.go`. Agent-specific ACP extensions use the package-private `acpDialect` function table in `dialect.go`; keep observed wire translation in `dialect_<agent>.go`. Dialect hooks return normalized data or request descriptions and never receive `*Adapter` or execute RPCs. Shared capability normalization used by both live sessions and utility probes belongs in `internal/agentctl/acpcompat/`. Tool-call conversion lives in `adapter_tools.go`, not `adapter.go`. See ADR-0043.
The `acp` transport is split by concern across `adapter_*.go` files: `adapter.go` (core/lifecycle), `adapter_session.go` (initialize/new/load/resume/close), `adapter_prompt.go` (prompt/cancel), `adapter_updates.go` (`session/update` notification fan-out), `adapter_tools.go` (`convertToolCallUpdate` / `convertToolCallResultUpdate` -> normalized payloads), `adapter_permissions.go`, and `adapter_helpers.go`. Agent-specific ACP extensions use the package-private `acpDialect` function table in `dialect.go`; keep observed wire translation in `dialect_<agent>.go`. Dialect hooks return normalized data or request descriptions and never receive `*Adapter` or execute RPCs. Shared capability normalization used by both live sessions and utility probes belongs in `internal/agentctl/acpcompat/`. Tool-call conversion lives in `adapter_tools.go`, not `adapter.go`. See ADR-0043.
JSON-RPC 2.0 over stdin/stdout between agentctl and agent process. Requests: `initialize`, `session/new`, `session/load`, `session/prompt`, `session/cancel`. Notifications: `session/update` with types `message_chunk`, `tool_call`, `tool_update`, `complete`, `error`, `permission_request`, `context_window`.
JSON-RPC 2.0 over stdin/stdout between agentctl and agent process. Requests: `initialize`, `session/new`, `session/load`, `session/prompt`, `session/cancel`, `session/close`. Notifications: `session/update` with types `message_chunk`, `tool_call`, `tool_update`, `complete`, `error`, `permission_request`, `context_window`.
Read the changes as a list

Close superseded session after reset

apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go

ResetSession now captures the old session and closes it after a successful new session, with guards for concurrency and capability.

ResetSession and close helper
// ResetSession creates a new session on the existing connection, effectively resetting
// the agent's conversation context without restarting the subprocess. This is much faster
// than a full process restart since the ACP protocol supports multiple sessions per connection.
//
// The session/new call spawns a fresh agent-side child process without releasing the
// superseded one, so a successful reset closes the outgoing session to release its
// resources. The old session is captured before NewSession overwrites a.sessionID.
func (a *Adapter) ResetSession(ctx context.Context, mcpServers []types.McpServer) (string, error) {
	return a.NewSession(ctx, mcpServers)
	a.mu.RLock()
	previous, conn := a.sessionID, a.acpConn
	a.mu.RUnlock()

	newID, err := a.NewSession(ctx, mcpServers)
	if err != nil {
		return "", err
	}
	a.closeSupersededSession(ctx, conn, previous, newID)
	return newID, nil
}

// closeSupersededSessionTimeout bounds session/close after a reset so cleanup
// can't hang the caller when the agent doesn't respond.
const closeSupersededSessionTimeout = 10 * time.Second

// closeSupersededSession releases the session a successful reset just replaced.
func (a *Adapter) closeSupersededSession(ctx context.Context, conn *acp.ClientSideConnection, previous, newID string) {
	if previous == "" || previous == newID || conn == nil {
		return
	}
	a.mu.RLock()
	supportsClose := a.capabilities.SessionCapabilities.Close != nil
	stillCurrent := a.sessionID == newID
	a.mu.RUnlock()
	if !supportsClose || !stillCurrent {
		return
	}

	closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), closeSupersededSessionTimeout)
	defer cancel()
	if _, err := conn.CloseSession(closeCtx, acp.CloseSessionRequest{
		SessionId: acp.SessionId(previous),
	}); err != nil {
		a.logger.Warn("failed to close superseded session after reset",
			zap.String("session_id", previous), zap.Error(err))
	}
}

Mock agent advertises session close

apps/backend/cmd/mock-agent/main.go

The mock agent now advertises SessionCapabilities.Close so tests can exercise the new close path.

Initialize
	return acp.InitializeResponse{
		ProtocolVersion: acp.ProtocolVersionNumber,
		AgentCapabilities: acp.AgentCapabilities{
			LoadSession:     true,
			McpCapabilities: acp.McpCapabilities{Sse: true},
			Meta:            meta,
			SessionCapabilities: acp.SessionCapabilities{
				Close: &acp.SessionCloseCapabilities{},
			},
			Meta: meta,
		},
	}, nil
}
CloseSession cleanup
func (a *mockAgent) CloseSession(_ context.Context, req acp.CloseSessionRequest) (acp.CloseSessionResponse, error) {
	a.mu.Lock()
	delete(a.sessions, req.SessionId)
	delete(a.sessionConfig, req.SessionId)
	delete(a.commandsEmitted, req.SessionId)
	a.mu.Unlock()
	_ = os.Remove(overloadedCounterPath(req.SessionId))
	_ = os.Remove(transportLostCounterPath(req.SessionId))
	return acp.CloseSessionResponse{}, nil
}

Tests for close guards

apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session_test.go

New tests verify close happens when advertised, and is skipped on concurrency, missing capability, failure, or close error.

Happy path
func TestResetSessionClosesSupersededSessionWhenAdvertised(t *testing.T) {
	adapter, capture := newSessionRequestCaptureAdapter(t, acpsdk.McpCapabilities{})
	adapter.capabilities.SessionCapabilities.Close = &acpsdk.SessionCloseCapabilities{}
	firstID, _ := adapter.NewSession(context.Background(), nil)
	secondID, _ := adapter.ResetSession(context.Background(), nil)
	closes := capture.recordedCloseRequests()
	// closes[0].SessionId == firstID
}
Concurrency guard
func TestResetSessionSkipsCloseWhenSessionChangedConcurrently(t *testing.T) {
	adapter, capture := newSessionRequestCaptureAdapter(t, acpsdk.McpCapabilities{})
	adapter.capabilities.SessionCapabilities.Close = &acpsdk.SessionCloseCapabilities{}
	firstID, _ := adapter.NewSession(context.Background(), nil)
	secondID, _ := adapter.NewSession(context.Background(), nil)
	_ = adapter.LoadSession(context.Background(), firstID, nil)
	adapter.closeSupersededSession(context.Background(), adapter.acpConn, firstID, secondID)
	// no close when session is current again
}

Docs: session/close in ACP protocol

apps/backend/internal/agentctl/AGENTS.md

Documentation now lists session/close as a supported ACP request and notes the adapter file split.

Protocol line
The `acp` transport is split by concern across `adapter_*.go` files: `adapter.go` (core/lifecycle), `adapter_session.go` (initialize/new/load/resume), `adapter_prompt.go` (prompt/cancel), `adapter_updates.go` (`session/update` notification fan-out), `adapter_tools.go` (`convertToolCallUpdate` / `convertToolCallResultUpdate` -> normalized payloads), `adapter_permissions.go`, and `adapter_helpers.go`. Agent-specific ACP extensions use the package-private `acpDialect` function table in `dialect.go`; keep observed wire translation in `dialect_<agent>.go`. Dialect hooks return normalized data or request descriptions and never receive `*Adapter` or execute RPCs. Shared capability normalization used by both live sessions and utility probes belongs in `internal/agentctl/acpcompat/`. Tool-call conversion lives in `adapter_tools.go`, not `adapter.go`. See ADR-0043.
The `acp` transport is split by concern across `adapter_*.go` files: `adapter.go` (core/lifecycle), `adapter_session.go` (initialize/new/load/resume/close), `adapter_prompt.go` (prompt/cancel), `adapter_updates.go` (`session/update` notification fan-out), `adapter_tools.go` (`convertToolCallUpdate` / `convertToolCallResultUpdate` -> normalized payloads), `adapter_permissions.go`, and `adapter_helpers.go`. Agent-specific ACP extensions use the package-private `acpDialect` function table in `dialect.go`; keep observed wire translation in `dialect_<agent>.go`. Dialect hooks return normalized data or request descriptions and never receive `*Adapter` or execute RPCs. Shared capability normalization used by both live sessions and utility probes belongs in `internal/agentctl/acpcompat/`. Tool-call conversion lives in `adapter_tools.go`, not `adapter.go`. See ADR-0043.
JSON-RPC 2.0 over stdin/stdout between agentctl and agent process. Requests: `initialize`, `session/new`, `session/load`, `session/prompt`, `session/cancel`. Notifications: `session/update` with types `message_chunk`, `tool_call`, `tool_update`, `complete`, `error`, `permission_request`, `context_window`.
JSON-RPC 2.0 over stdin/stdout between agentctl and agent process. Requests: `initialize`, `session/new`, `session/load`, `session/prompt`, `session/cancel`, `session/close`. Notifications: `session/update` with types `message_chunk`, `tool_call`, `tool_update`, `complete`, `error`, `permission_request`, `context_window`.

Risk

4 / 10 Medium
1 low5 medium10 high

Why this score

  • Change is scoped to ResetSession and only runs after a successful session/new.
  • Close is guarded by capability, empty-id, and concurrency checks, and errors are logged not surfaced.
  • New tests cover happy path, concurrency, missing capability, and failure modes.

Trade-offs and review notes

Where to look first

  1. Verify closeSupersededSession guards: empty id, same id, nil conn, missing capability, and stillCurrent check.
  2. Check that ResetSession captures previous and conn before NewSession overwrites sessionID.
  3. Confirm mock agent CloseSession cleans all session maps and that tests cover the concurrency interleaving.