Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions core/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -4524,6 +4524,9 @@ func (e *Engine) runUnsolicitedReader(ctx context.Context, cancel context.Cancel
// Mark workspace active on first event.
if !turnActive {
turnActive = true
// The session is doing background work: stop the pending
// idle-close timer so it cannot kill the live agent mid-turn.
e.cancelAgentSessionIdleClose(state)
if workspaceDir != "" && e.workspacePool != nil {
if ws := e.workspacePool.Get(workspaceDir); ws != nil {
ws.BeginTurn()
Expand Down Expand Up @@ -4598,6 +4601,12 @@ func (e *Engine) runUnsolicitedReader(ctx context.Context, cancel context.Cancel
state.eventsNeedResync = false
state.mu.Unlock()

// The background turn finished cleanly: restart the idle
// clock, mirroring the foreground turn-end scheduling. Must
// run after eventsNeedResync is cleared — scheduling is
// refused while a resync is pending.
e.scheduleAgentSessionIdleClose(sessionKey, state)

slog.Info("unsolicited turn complete",
"session", sessionKey,
"response_len", len(fullResponse))
Expand Down
79 changes: 79 additions & 0 deletions core/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7405,6 +7405,85 @@ func TestProcessInteractiveMessage_SchedulesAgentSessionIdleCloseAfterCleanTurn(
waitForInteractiveStateRemoved(t, e, "test:chat:user1")
}

// waitForIdleTimerState polls until the presence of a scheduled idle-close
// timer on state matches want, failing the test on timeout.
func waitForIdleTimerState(t *testing.T, state *interactiveState, want bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
state.mu.Lock()
armed := state.agentSessionIdleCancel != nil
state.mu.Unlock()
if armed == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("idle timer armed state did not become %v", want)
}

// TestAgentSessionIdleTimeout_UnsolicitedTurnCancelsClose is the regression
// test for the unsolicited-activity gap: a session relaying background events
// (an in-flight unsolicited turn) must not have its live agent killed by the
// idle-close timer.
func TestAgentSessionIdleTimeout_UnsolicitedTurnCancelsClose(t *testing.T) {
e := newTestEngine()
e.SetAgentSessionIdleTimeout(40 * time.Millisecond)
key := "test:user1"
sess := newControllableSession("s1")
p := &stubPlatformEngine{n: "test"}
state := &interactiveState{agentSession: sess, platform: p, replyCtx: "ctx", eventsNeedResync: false}

e.interactiveMu.Lock()
e.interactiveStates[key] = state
e.interactiveMu.Unlock()

session := e.sessions.GetOrCreateActive(key)
e.startUnsolicitedReader(state, session, e.sessions, key, "")

e.scheduleAgentSessionIdleClose(key, state)
waitForIdleTimerState(t, state, true)

// Background activity begins: the reader must cancel the armed timer.
sess.events <- Event{Type: EventText, Content: "background work"}
waitForIdleTimerState(t, state, false)

select {
case <-sess.closed:
t.Fatal("idle close killed a session with an active unsolicited turn")
case <-time.After(120 * time.Millisecond):
}
}

// TestAgentSessionIdleTimeout_ReschedulesAfterUnsolicitedTurn verifies the
// complementary half: once the background turn finishes cleanly, the idle
// clock restarts and the session is closed after the timeout.
func TestAgentSessionIdleTimeout_ReschedulesAfterUnsolicitedTurn(t *testing.T) {
e := newTestEngine()
e.SetAgentSessionIdleTimeout(40 * time.Millisecond)
key := "test:user1"
sess := newControllableSession("s1")
p := &stubPlatformEngine{n: "test"}
state := &interactiveState{agentSession: sess, platform: p, replyCtx: "ctx", eventsNeedResync: false}

e.interactiveMu.Lock()
e.interactiveStates[key] = state
e.interactiveMu.Unlock()

session := e.sessions.GetOrCreateActive(key)
e.startUnsolicitedReader(state, session, e.sessions, key, "")

sess.events <- Event{Type: EventText, Content: "background work"}
sess.events <- Event{Type: EventResult, Content: "done", Done: true}

select {
case <-sess.closed:
case <-time.After(2 * time.Second):
t.Fatal("agent session was not closed after the unsolicited turn went idle")
}
waitForInteractiveStateRemoved(t, e, key)
}

// TestCleanupCAS_ConcurrentUnconditionalCloseOnce verifies that two concurrent
// unconditional cleanups for the same key only Close() the agent session once.
func TestCleanupCAS_ConcurrentUnconditionalCloseOnce(t *testing.T) {
Expand Down
15 changes: 15 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ The next normal message after a long idle period starts in a fresh session autom

To restore the previous behavior of always continuing, set `reset_on_idle_mins = 0`.

### Idle exit: reclaim memory from idle agent processes

Each session keeps a resident agent subprocess (for Claude Code, ~300 MB plus its MCP server children). On small-RAM machines you can let cc-connect close idle agent subprocesses after a clean turn and resume them on demand:

```toml
[[projects]]
name = "demo"
agent_session_idle_timeout_mins = 30 # 0 or unset disables (default)
```

After 30 minutes with no activity following a completed turn, the agent subprocess (and its MCP children) is closed and its memory released. The conversation is not lost: the next message transparently resumes it via the saved agent session ID, at the cost of a few seconds of cold start. Sessions with a running turn (foreground or background), a pending permission request, or queued messages are never closed.

This differs from `reset_on_idle_mins`: idle exit frees the *process* but keeps the conversation; reset-on-idle rotates to a *new conversation*. It is also per chat session, unlike `workspace_idle_timeout_mins` which reaps a whole workspace. They can be combined.


### Model switch preserves history

`/model` preserves the current session — the agent resumes the conversation with the new model (no extra token cost). Model switching affects the shared agent instance — if multiple platforms use the same project, the model change applies to all of them.
Expand Down
15 changes: 15 additions & 0 deletions docs/usage.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ reset_on_idle_mins = 60

开启后,如果用户长时间未发消息,下一条普通消息会自动进入一个新的会话;旧会话仍会保留在 `/list` 中,不会被删除。

### 空闲退出:回收空闲 agent 进程的内存

每个会话会常驻一个 agent 子进程(以 Claude Code 为例约 300 MB,还带一组 MCP server 子进程)。小内存机器可以让 cc-connect 在回合正常结束后自动关闭空闲的 agent 子进程、下次需要时再拉起:

```toml
[[projects]]
name = "demo"
agent_session_idle_timeout_mins = 30 # 0 或不设置表示禁用(默认)
```

回合结束后空闲 30 分钟,agent 子进程(连同它的 MCP 子进程)会被关闭、内存立即释放。对话不会丢失:下一条消息会用已保存的 agent session ID 透明地恢复会话,代价是几秒钟的冷启动。正在执行任务(前台或后台)、等待权限确认、或有排队消息的会话不会被关闭。

它与 `reset_on_idle_mins` 的区别:空闲退出释放的是*进程*、保留对话;空闲重置则是切换到*新对话*。它按聊天会话生效,也不同于按整个 workspace 回收的 `workspace_idle_timeout_mins`。三者可以组合使用。


### 切换模型时保留历史

`/model` 切换模型时保留当前会话——agent 会在新模型下继续对话(不额外消耗 token)。注意模型切换作用于共享的 agent 实例——如果多个平台使用同一个 project,模型变更会影响所有平台。
Expand Down
77 changes: 77 additions & 0 deletions tests/blackbox/p1/agent_session_idle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//go:build blackbox

package p1

import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"

"github.com/chenhg5/cc-connect/core"
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
)

// TestP1_AgentSessionIdleTimeout_ReclaimAndResume_ClaudeCode verifies
// agent_session_idle_timeout_mins end-to-end with a real agent: after the
// idle timeout the agent subprocess is closed (memory reclaimed), and the
// next message transparently resumes the same conversation with its context
// intact.
func TestP1_AgentSessionIdleTimeout_ReclaimAndResume_ClaudeCode(t *testing.T) {
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
e.SetAgentSessionIdleTimeout(5 * time.Second)
})

env.SendComplete("Remember the codeword: PINEAPPLE42. Reply with just OK.")

// The per-session timer fires ~5s after the clean turn end; poll
// generously. Process observation uses /proc, so it is Linux-only; on
// other platforms fall back to asserting resume behavior only.
if runtime.GOOS == "linux" {
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
if len(agentProcsInDirIdle(t, env.WorkDir)) == 0 {
break
}
time.Sleep(2 * time.Second)
}
if pids := agentProcsInDirIdle(t, env.WorkDir); len(pids) != 0 {
t.Fatalf("agent subprocess still alive after idle timeout: %v", pids)
}
} else {
time.Sleep(20 * time.Second)
}

reply := env.SendWithTimeout("What is the codeword? Reply with just the codeword.", 120*time.Second)
if !strings.Contains(reply.Text(), "PINEAPPLE42") {
t.Fatalf("resumed session lost context: reply %q does not contain codeword", reply.Text())
}
}

// agentProcsInDirIdle returns PIDs of processes whose cwd is dir (the agent
// subprocess is spawned with its working directory set to the session's
// work_dir, which is a unique temp dir per test).
func agentProcsInDirIdle(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir("/proc")
if err != nil {
t.Fatalf("read /proc: %v", err)
}
var pids []string
for _, e := range entries {
pid := e.Name()
if pid[0] < '0' || pid[0] > '9' {
continue
}
cwd, err := os.Readlink(filepath.Join("/proc", pid, "cwd"))
if err != nil {
continue // process exited or not ours
}
if cwd == dir {
pids = append(pids, pid)
}
}
return pids
}
Loading