From db21c454590b272825fb564112a5f3e128e01b5b Mon Sep 17 00:00:00 2001 From: lyq3 <20572844+lyq3@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:48:50 +0000 Subject: [PATCH] fix(core): cancel agent-session idle close during unsolicited turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idle-close timer added in #1338 is only cancelled by foreground turns: unsolicited background activity (background task completions relayed by the unsolicited reader) neither cancels nor resets it, so a session doing background work past the timeout gets its live agent killed mid-stream, and a completed background turn never restarts the idle clock. Cancel the timer when the unsolicited reader marks a turn active, and reschedule it after the turn completes cleanly (after eventsNeedResync is cleared, since scheduling is refused while a resync is pending) — mirroring the existing foreground begin/end pairing. Also adds a real-agent blackbox E2E for agent_session_idle_timeout_mins (process reaped, next message resumes with context intact) and usage docs (EN/zh) for the option. Co-Authored-By: Claude Fable 5 --- core/engine.go | 9 +++ core/engine_test.go | 79 ++++++++++++++++++++ docs/usage.md | 15 ++++ docs/usage.zh-CN.md | 15 ++++ tests/blackbox/p1/agent_session_idle_test.go | 77 +++++++++++++++++++ 5 files changed, 195 insertions(+) create mode 100644 tests/blackbox/p1/agent_session_idle_test.go diff --git a/core/engine.go b/core/engine.go index d51fc2799..f7808f895 100644 --- a/core/engine.go +++ b/core/engine.go @@ -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() @@ -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)) diff --git a/core/engine_test.go b/core/engine_test.go index b53f3c639..6d5d79aa6 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -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) { diff --git a/docs/usage.md b/docs/usage.md index f5206e095..4583e2955 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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. diff --git a/docs/usage.zh-CN.md b/docs/usage.zh-CN.md index 9ea8d1d90..54cbf2ba9 100644 --- a/docs/usage.zh-CN.md +++ b/docs/usage.zh-CN.md @@ -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,模型变更会影响所有平台。 diff --git a/tests/blackbox/p1/agent_session_idle_test.go b/tests/blackbox/p1/agent_session_idle_test.go new file mode 100644 index 000000000..6fb1a85df --- /dev/null +++ b/tests/blackbox/p1/agent_session_idle_test.go @@ -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 +}