Skip to content

刷新或关闭会话页面会导致后台 shell 任务被杀死(Background shell tasks are killed when the session page is refreshed or closed) #1096

Closed

Description

@monkeyDragonLiu

问题概述

当 Claude agent 启动一个后台 shell 任务时,Web UI 能正常显示;但只要清理会话视图(应用内关闭会话面板、刷新浏览器或关闭标签页),客户端就可能对连接发送 acp_disconnect。服务端随后拆除 ACP agent 进程树(kill_tree),后台子进程一并被杀。对话 transcript 可以恢复,但正在运行的后台任务无法恢复——重新打开页面也找不回来。

客户端其实已经知道如何识别“仍有后台工作”(60 秒空闲 GC 里就有正确实现),但其他清理路径用的是更弱甚至完全没有的判定。服务端同理:idle sweep 会豁免仍有后台工作的连接,但客户端的显式 disconnect 不受这条策略保护。

环境

  • codeg-server 0.42.0,Linux(x86_64),用户级部署
  • Web UI 由内置静态目录提供;Chromium 内核浏览器
  • Agent:Claude Code,通过 @agentclientprotocol/[email protected]
  • 观察到的相关配置项:CODEG_ACP_IDLE_TIMEOUT_SECS(默认 180)、CODEG_ACP_BACKGROUND_KEEPALIVE_MAX_SECS

复现步骤

  1. 在 Web UI 中打开一个会话。
  2. 让 agent 启动一个长时间运行的后台 shell,例如后台执行
    bash -c 'while true; do date; sleep 5; done'。UI 会显示这条后台任务,服务端日志出现
    [bg-watch] registered background shell task=<id>,且 outstanding=1。
  3. 制造会话视图的拆除:应用内关闭会话面板、刷新浏览器,或关闭标签页。
  4. 观察 ps / 任务输出文件:后台进程已经不见了。
  5. 重新打开该会话:transcript 恢复,但后台任务无法恢复或续跑。

预期行为

拆除视图不应拆除会话。仍有后台工作的连接应当被**挂起(detach)**并在下次页面加载时重新接回,或者至少受保护直到后台工作结束(以可配置的最大时长封顶)。只有用户的显式操作(“停止任务”“断开连接”“关闭会话”)才应该杀掉进程树。

实际行为

清理路径并非用户主动发起,却发送了 acp_disconnect;服务端把它当作无条件拆除,连同后台子进程一起杀掉了 agent 进程树。

证据(服务端日志,0.42.0)

视图拆除时出现“断开 + 立即重建连接”,两者相隔约 190 ms,说明是客户端在页面交互中先断开再重连:

14:21:09.143 [ACP] disconnect connection=d831d0df-…   (POST /acp_disconnect)
14:21:09.330 [ACP] spawning connection id=90d8efe7-…  (POST /acp_connect)

后台任务在 turn 结束时被登记并计入 outstanding:

14:12:16 [bg-watch] registered background shell task=b0o25fe9x
14:12:25 [ACP] turn_complete … background_outstanding=1

没有后台工作的空闲连接会被 sweep 正常回收:

00:16:58 [bg-watch] armed connection=b8109960-…
00:20:35 [ACP] idle sweep disconnecting connection=b8109960-…   # 无 outstanding 工作

反过来,仍有后台工作的连接可以熬过 sweep——见下面的对照实验。也就是说,服务端本来就有这条策略,只是被客户端显式 disconnect 绕过了。

对照实验:在本地打了一个补丁,去掉客户端无条件的断连逻辑后,在后台 shell 运行(background_outstanding=1)的情况下关闭标签页约 13 分钟。期间心跳每 5 秒写一次、没有任何断档;该连接没有出现 disconnect,也没有被 idle sweep 回收;重新打开页面后接回的是同一个 connection id。这同时验证了两点:服务端在存在后台工作时会保持连接存活;此前杀死任务的唯一原因就是客户端发起的 acp_disconnect。

根因

客户端(Web 产物,0.42.0)

三条清理路径,三种不同策略:

  1. 60 秒空闲 GC —— 正确:会跳过 backgroundOutstanding > 0,或 asyncTasks 中存在未终态任务的连接。
  2. disconnectIfIdle(预览标签被替换等) —— 只判断
    status === "prompting" || backgroundOutstanding > 0,忽略 asyncTasks。
  3. 单面板卸载钩子 —— 复用 (2) 的判定,并且传入的是一个裁剪过的对象
    (status、isViewer、backgroundOutstanding、transientUnmount),不含 asyncTasks;即使判定函数修好了,这条路径也看不到活动的异步任务。
  4. Provider 级卸载清理 —— 完全没有保护:对所有非 viewer、非 delegation 的连接直接调用 acpDisconnect。

客户端使用的异步任务终态为 completed、failed、stopped。

服务端

acp_disconnect → ConnectionManager::disconnect → kill_tree。idle sweep 路径会参考后台工作
(发布二进制中存在符号 has_active_background_work、background_keepalive_max_age);而显式 disconnect 路径不区分“视图要走了”和“用户要停掉会话”。

修复建议

1. 客户端:统一为一个判定函数,所有“非显式”清理路径都使用它

function hasLiveWork(c) {
  if (!c) return false;
  if (c.status === 'prompting') return true;
  if ((c.backgroundOutstanding ?? 0) > 0) return true;
  const terminal = new Set(['completed', 'failed', 'stopped']);
  return (c.asyncTasks ?? []).some(t => !terminal.has(t.state));
}
  • 在单面板卸载钩子、Provider 卸载清理、disconnectIfIdle、预览标签替换和 60 秒 GC 中统一使用(替换掉现在这三套不一致的实现)。
  • 单面板路径要传入完整连接对象(或至少带上 asyncTasks);目前它构造的子集根本无法携带该信息。
  • 用户的显式操作保持无条件执行(disconnect、disconnectAll、“停止任务”)。

2. 服务端:让显式 disconnect 也遵守同一套后台工作策略

二选一即可:

  • 给 acp_disconnect 增加 reason/origin(例如 renderer_teardown),当存在活动后台工作、且连接年龄小于 CODEG_ACP_BACKGROUND_KEEPALIVE_MAX_SECS 时,按 detach 处理而不是拆除;客户端现有的按会话重连逻辑会在下次页面加载时自动接回。
  • 或者对不带“用户显式停止”标记的 disconnect,复用 idle sweep 的判定。

无论选哪种,请把 CODEG_ACP_BACKGROUND_KEEPALIVE_MAX_SECS 的语义和重连(reattach)行为写进文档——目前这两点都无法从外部得知。

3. UX(可选)

当用户显式关闭仍有后台工作的会话时弹确认框(“仍有 1 个后台任务在运行——停止,还是让它在后台继续?”),把“有意杀死”和“视图拆除”区分开。

验收标准

  • 刷新页面,或关闭/重新打开标签页,都不会停掉后台 shell;重新打开后能看到它的输出(连接被接回,而不是重建)。
  • 显式“停止任务”“断开连接”仍然立即杀掉进程树。
  • 没有活动工作的空闲连接仍会在 idle 超时内被回收(不泄漏)。
  • 回归测试:在异步任务运行时模拟 renderer 拆除 → 服务端连接至少存活到配置的后台保活时长,且任务正常结束。

分析方法说明

本报告基于发布的生产构建产物(压缩后的 Web 资源)、服务端日志和运行时观察,未拉取上游源码。上述判定逻辑是从 0.42.0 Web 构建部署后的 _next/static/chunks/*.js 中读出的,函数名在源码中很可能不同——重点是这三条清理路径及其不一致的判定条件,而不是压缩后的标识符。

Summary

When the Claude agent starts a background shell task, the web UI shows it correctly, but
tearing down the session view (in‑app pane close, page refresh, or closing the tab) can
send acp_disconnect for the connection. The server then tears down the ACP agent process
tree (kill_tree), which takes every background child process with it. The conversation
transcript is resumable, but the running background work is not — reopening the page cannot
recover it.

The client already knows how to recognize live background work in one place (the 60 s idle
GC), but the other teardown paths use weaker or missing checks. The server, likewise, exempts
connections with outstanding background work from its idle sweep but not from an explicit
client disconnect.

Environment

  • codeg-server 0.42.0, Linux (x86_64), user-level service
  • Web UI served from the bundled static directory; Chromium-based browser
  • Agent: Claude Code via @agentclientprotocol/[email protected]
  • Relevant knobs observed: CODEG_ACP_IDLE_TIMEOUT_SECS (default 180),
    CODEG_ACP_BACKGROUND_KEEPALIVE_MAX_SECS

Steps to reproduce

  1. Open a conversation in the web UI.
  2. Ask the agent to start a long-running background shell, e.g.
    bash -c 'while true; do date; sleep 5; done' in the background. The UI shows a
    background task and the server logs
    [bg-watch] registered background shell task=<id> with outstanding=1.
  3. Trigger a session-view teardown: close the in‑app session pane, refresh the browser, or
    close the tab.
  4. Observe ps / the task output file: the background process is gone.
  5. Reopen the conversation: the transcript is restored, but the background task cannot be
    resumed or recovered.

Expected

Tearing down the view should not tear down the session. A connection with live background
work should either be detached (and reattached on the next page load) or protected until the
work settles, bounded by a configurable maximum age. Only an explicit user action
(“stop task”, “disconnect”, “close session”) should kill the process tree.

Actual

acp_disconnect is sent on teardown paths that are not user-initiated; the server treats it
as an unconditional teardown and kills the agent tree including background children.

Evidence (server log, 0.42.0)

Disconnect + immediate respawn on a view teardown — the pair is ~190 ms apart, i.e. the
client disconnected and reconnected as part of a page interaction:

14:21:09.143 [ACP] disconnect connection=d831d0df-…   (POST /acp_disconnect)
14:21:09.330 [ACP] spawning connection id=90d8efe7-…  (POST /acp_connect)

The background work was registered and counted as outstanding at turn completion:

14:12:16 [bg-watch] registered background shell task=b0o25fe9x
14:12:25 [ACP] turn_complete … background_outstanding=1

An idle connection with no outstanding work is reclaimed by the sweep as expected:

00:16:58 [bg-watch] armed connection=b8109960-…
00:20:35 [ACP] idle sweep disconnecting connection=b8109960-…   # no outstanding work

Conversely, a connection with outstanding background work survives the sweep — see the
control experiment below. So the server already has the policy; it is only bypassed by the
explicit client disconnect.

Control experiment: with a local hotfix that removes the client-side unconditional disconnect
(see below), the tab was closed for ~13 minutes while a background shell was running
(background_outstanding=1). The shell kept writing a heartbeat every 5 s with no gap; there
was no disconnect and no idle sweep for that connection; the reopened page reattached to
the same connection id. This confirms both halves: the server keeps a connection alive
while background work is outstanding, and the only thing that killed it previously was the
client-initiated acp_disconnect.

Root cause

Client (web bundle, 0.42.0)

Three teardown paths, three different policies:

  1. 60 s idle GC — correct: skips connections whose
    backgroundOutstanding > 0 or whose asyncTasks contain a non-terminal task.
  2. disconnectIfIdle (preview-tab replacement, etc.) — checks only
    status === "prompting" || backgroundOutstanding > 0; ignores asyncTasks.
  3. Per-pane unmount hook — calls the same predicate as (2), and passes it a partial
    object (status, isViewer, backgroundOutstanding, transientUnmount) that does not
    contain asyncTasks, so even a fixed predicate cannot see live tasks on this path.
  4. Provider-level unmount cleanup — no guard at all: it calls acpDisconnect for every
    non-viewer, non-delegation connection it knows about.

Terminal async-task states used by the client are completed, failed, stopped.

Server

acp_disconnect → ConnectionManager::disconnect → kill_tree. The idle-sweep path
consults background work (symbol names present in the shipped binary:
has_active_background_work, background_keepalive_max_age); the explicit disconnect path
does not distinguish “view is going away” from “user wants the session stopped”.

Proposed fix

1. Client: one predicate, used by every non-explicit teardown path

function hasLiveWork(c) {
  if (!c) return false;
  if (c.status === 'prompting') return true;
  if ((c.backgroundOutstanding ?? 0) > 0) return true;
  const terminal = new Set(['completed', 'failed', 'stopped']);
  return (c.asyncTasks ?? []).some(t => !terminal.has(t.state));
}
  • Use it in the per-pane unmount hook, the provider unmount cleanup, disconnectIfIdle,
    preview-tab replacement and the 60 s GC (replacing the three divergent versions).
  • Pass the whole connection object (or at least asyncTasks) to it on the per-pane path;
    today that path builds a subset that cannot carry the information.
  • Keep explicit user actions unconditional (disconnect, disconnectAll, “stop task”).

2. Server: make explicit disconnect obey the same background-work policy

Two options, either is fine:

  • Add a reason/origin to acp_disconnect (e.g. renderer_teardown) and, when live background
    work exists and the connection is younger than CODEG_ACP_BACKGROUND_KEEPALIVE_MAX_SECS,
    treat it as detach instead of teardown; the existing reattach-by-conversation logic in
    the client already reconnects on the next page load.
  • Or reuse the idle-sweep predicate inside disconnect for calls that carry no explicit
    user-stop marker.

Either way, please document the intended semantics of CODEG_ACP_BACKGROUND_KEEPALIVE_MAX_SECS
and the reattach behavior — neither is currently discoverable.

3. UX (optional)

When the user explicitly closes a session that has live background work, show a confirm
dialog (“1 background task is still running — stop it or keep it running in the background?”),
so an intentional kill is distinguishable from a view teardown.

Acceptance criteria

  • Refreshing the page or closing/reopening the tab does not stop background shells; their
    output is visible again after reopening (the connection is reattached, not respawned).
  • Explicit “stop task” / “disconnect” still stops the process tree immediately.
  • Idle connections with no live work are still reclaimed within the idle timeout (no leak).
  • Regression test: simulate a renderer teardown while an async task is running → the server
    connection survives for at least the configured background keepalive window, and the task
    settles normally.

Notes on method

This report is based on the shipped production bundle (minified web assets), server log
messages, and runtime observation; no upstream source was fetched. The predicate behaviors
above were read from the deployed _next/static/chunks/*.js of the 0.42.0 web build, so
function names there are minified and may map differently in the source tree — the three
teardown paths and their differing conditions are the substance, not the identifiers.

Activity

  1. xintaofei commented on Nov 6, 2026

    @xintaofei
    Collaborator

    codeg work task 472 is done — merged locally into main as b182303 (25 files, +1361/-123).

  2. added a commit that references this issue on Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions