openclaw - 💡(How to fix) Fix [bug] acpx runtime: persistent ACP client not returned to pool after runTurn, causing per-send cold-start and making acp.runtime.ttlMinutes a dead config

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

When a persistent ACP session is created via sessions_spawn(runtime="acp", mode="session", ...), the first sessions_send after the spawn pays a full cold-start cost (new child process + ACP handshake + first-token latency). acp.runtime.ttlMinutes (e.g. 30) does not prevent this — because it is never consumed by the AcpxRuntime code path.

Root cause: asymmetric client lifecycle management between AcpxRuntime.ensureSession and AcpxRuntime.runTurn:

  • ensureSession places the live client into pendingPersistentClients and sets keepClientOpen = true when mode === "persistent".
  • runTurn takes the client out of the pool, but its finally branch unconditionally closes it, with no symmetric "return to pool" step for persistent sessions.

Net effect: after each send the adapter process is torn down; next send re-spawns from scratch.

Root Cause

When a persistent ACP session is created via sessions_spawn(runtime="acp", mode="session", ...), the first sessions_send after the spawn pays a full cold-start cost (new child process + ACP handshake + first-token latency). acp.runtime.ttlMinutes (e.g. 30) does not prevent this — because it is never consumed by the AcpxRuntime code path.

Fix Action

Fix / Workaround

Minimal patch — return the client to the pool in runTurn's finally when the session is persistent, instead of unconditionally closing:

Code Example

23:50:20  spawn claude-acp mode=session label=mem66        # 1st spawn
23:52:24  1st send completes (~25s turn)                   # cold path
23:53:10  2nd send completes (~40s turn, new child spawn)  # cold path again

---

// ensureSession (around L3955)
if (input.mode === "persistent") {
    const previousClient = this.pendingPersistentClients.get(record.acpxRecordId);
    this.pendingPersistentClients.set(record.acpxRecordId, client);
    keepClientOpen = true;            // finally does not close()
    await previousClient?.close().catch(() => {});
}

---

// runTurn (around L4022)
let pendingClient = this.pendingPersistentClients.get(record.acpxRecordId);
if (pendingClient) {
    this.pendingPersistentClients.delete(record.acpxRecordId);  // removed
    ...
}
const client = pendingClient ?? this.createClient({...});

---

// runTurn finally (around L4177-4188)
} finally {
    turnActive = false;
    ...
    await this.options.sessionStore.save(record).catch(() => {});
    await client.close().catch(() => {});   // <-- no "return to pool" branch
    queue.close();
}

---

} finally {
    turnActive = false;
    // ... existing bookkeeping ...
    await this.options.sessionStore.save(record).catch(() => {});

    if (record.mode === "persistent" && client.hasReusableSession(record.acpSessionId)) {
        const previous = this.pendingPersistentClients.get(record.acpxRecordId);
        this.pendingPersistentClients.set(record.acpxRecordId, client);
        await previous?.close().catch(() => {});
    } else {
        await client.close().catch(() => {});
    }
    queue.close();
}
RAW_BUFFERClick to expand / collapse

[bug] acpx runtime: persistent ACP client not returned to pool after runTurn, causing per-send cold-start and making acp.runtime.ttlMinutes a dead config

Version

  • openclaw 2026.4.15 (npm global install)
  • Observed with claude-agent-acp and gemini --acp adapters; reproducible across all ACP agents

Summary

When a persistent ACP session is created via sessions_spawn(runtime="acp", mode="session", ...), the first sessions_send after the spawn pays a full cold-start cost (new child process + ACP handshake + first-token latency). acp.runtime.ttlMinutes (e.g. 30) does not prevent this — because it is never consumed by the AcpxRuntime code path.

Root cause: asymmetric client lifecycle management between AcpxRuntime.ensureSession and AcpxRuntime.runTurn:

  • ensureSession places the live client into pendingPersistentClients and sets keepClientOpen = true when mode === "persistent".
  • runTurn takes the client out of the pool, but its finally branch unconditionally closes it, with no symmetric "return to pool" step for persistent sessions.

Net effect: after each send the adapter process is torn down; next send re-spawns from scratch.

Reproduction

  1. sessions_spawn(runtime="acp", agentId="claude-acp", mode="session", label="mem66", task="Remember number 66.")
  2. Observe adapter child process up after spawn (visible in ps).
  3. sessions_send(label="mem66", message="What was the number?")
  4. Observe: send completes, then the child process exits shortly after the turn finishes.
  5. sessions_send(label="mem66", message="...") again → new child process is spawned, full cold-start latency again.

Gateway log evidence (timestamps show per-send cold-start)

23:50:20  spawn claude-acp mode=session label=mem66        # 1st spawn
23:52:24  1st send completes (~25s turn)                   # cold path
23:53:10  2nd send completes (~40s turn, new child spawn)  # cold path again

Gemini family is more severe (~74-112s per cold start) because gemini --acp uses the full Gemini CLI as the ACP server, so every resurrection reloads the entire CLI runtime.

Root cause (code references)

File: dist/register.runtime-*.js → class AcpxRuntime

ensureSession correctly puts the live client into the persistent pool when mode === "persistent":

// ensureSession (around L3955)
if (input.mode === "persistent") {
    const previousClient = this.pendingPersistentClients.get(record.acpxRecordId);
    this.pendingPersistentClients.set(record.acpxRecordId, client);
    keepClientOpen = true;            // finally does not close()
    await previousClient?.close().catch(() => {});
}

runTurn takes the client out of the pool at the start:

// runTurn (around L4022)
let pendingClient = this.pendingPersistentClients.get(record.acpxRecordId);
if (pendingClient) {
    this.pendingPersistentClients.delete(record.acpxRecordId);  // removed
    ...
}
const client = pendingClient ?? this.createClient({...});

...but then unconditionally closes it in finally:

// runTurn finally (around L4177-4188)
} finally {
    turnActive = false;
    ...
    await this.options.sessionStore.save(record).catch(() => {});
    await client.close().catch(() => {});   // <-- no "return to pool" branch
    queue.close();
}

There is no symmetric if (record.mode === "persistent") pool.set(...) path, so the persistent adapter process dies after every turn.

ttlMinutes is a dead config

Searching the AcpxRuntime source for ttlMinutes|runtimeTtl|idleReaper|reaper|handleExpiry yields zero matches. The field is accepted by the zod schema but nothing in AcpxRuntime reads it or schedules an idle reaper. Users setting acp.runtime.ttlMinutes=30 see no effect on process longevity.

Expected behavior

  • mode: "persistent" → ACP child process should be reused across sessions_send calls within the TTL window.
  • ttlMinutes should govern an idle reaper that closes the pooled client after N minutes of inactivity.
  • Cold-start cost should only apply to the first sessions_spawn; subsequent sends should hit a warm process.

Proposed fix

Minimal patch — return the client to the pool in runTurn's finally when the session is persistent, instead of unconditionally closing:

} finally {
    turnActive = false;
    // ... existing bookkeeping ...
    await this.options.sessionStore.save(record).catch(() => {});

    if (record.mode === "persistent" && client.hasReusableSession(record.acpSessionId)) {
        const previous = this.pendingPersistentClients.get(record.acpxRecordId);
        this.pendingPersistentClients.set(record.acpxRecordId, client);
        await previous?.close().catch(() => {});
    } else {
        await client.close().catch(() => {});
    }
    queue.close();
}

Follow-up (separate PR): wire acp.runtime.ttlMinutes to a per-entry idle reaper that closes pooled clients after the configured idle timeout, so the config stops being dead.

Impact

  • Persistent sessions today pay ~25-30s cold-start per sessions_send for Claude family, ~74-112s for Gemini family. Fix would drop warm sends to single-digit seconds.
  • Behavior mismatch vs documented config (ttlMinutes) is also a discoverability bug.

Environment

  • Linux 6.17.0-20-generic
  • Install: global npm install of openclaw (inspected dist under node_modules/openclaw/dist/)
  • Adapters: @agentclientprotocol/claude-agent-acp, @google/gemini-cli
  • Config: plugins.entries.acpx with 6 ACP agents (3 Claude, 3 Gemini)

extent analysis

TL;DR

The most likely fix is to modify the runTurn method in AcpxRuntime to return the client to the pool when the session is persistent, instead of unconditionally closing it.

Guidance

  1. Modify the runTurn method: Update the finally block to check if the session is persistent and return the client to the pool if so.
  2. Implement idle reaper: Create a separate PR to wire acp.runtime.ttlMinutes to a per-entry idle reaper that closes pooled clients after the configured idle timeout.
  3. Verify the fix: Test the modified runTurn method and idle reaper to ensure that persistent sessions are reused across sessions_send calls and that the cold-start cost is only applied to the first sessions_spawn.
  4. Check for edge cases: Verify that the fix works correctly for different adapters (e.g., Claude, Gemini) and configurations (e.g., ttlMinutes values).

Example

The proposed fix includes a minimal patch for the runTurn method:

} finally {
    turnActive = false;
    // ... existing bookkeeping ...
    await this.options.sessionStore.save(record).catch(() => {});

    if (record.mode === "persistent" && client.hasReusableSession(record.acpSessionId)) {
        const previous = this.pendingPersistentClients.get(record.acpxRecordId);
        this.pendingPersistentClients.set(record.acpxRecordId, client);
        await previous?.close().catch(() => {});
    } else {
        await client.close().catch(() => {});
    }
    queue.close();
}

Notes

The fix assumes that the hasReusableSession method is implemented correctly and that the pendingPersistentClients map is properly maintained.

Recommendation

Apply the proposed workaround by modifying the runTurn method to return the client to the pool when the session is persistent. This should fix the immediate issue of persistent sessions not being reused. A follow-up PR can then be created to implement the idle reaper and wire acp.runtime.ttlMinutes to it.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

  • mode: "persistent" → ACP child process should be reused across sessions_send calls within the TTL window.
  • ttlMinutes should govern an idle reaper that closes the pooled client after N minutes of inactivity.
  • Cold-start cost should only apply to the first sessions_spawn; subsequent sends should hit a warm process.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING