diff --git a/CLAUDE.md b/CLAUDE.md index badf17cc..27e5cf6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,11 +162,11 @@ Core tools are registered for both TUI and daemon paths: `bash`, `file_read`, `f `spawn_subagent` / `wait_subagent` are registered in both daemon (`worker.cpp`) and TUI (via `src/apps/tui/subagent_host.{hpp,cpp}` — SessionRegistry/LocalSessionClient have no web dependency, so the TUI process instantiates them directly; the TUI main session lives outside the registry, so its permission mode reaches children through `SubagentToolDeps::fallback_permissions`, and `on_spawn` lets the host register tasks + subscribe child events): a sub-agent is a normal SessionRegistry session (isolated context) created with the parent's cwd and permission mode. `spawn_subagent(prompt, wait=true)` blocks until the child turn finishes and returns its final assistant reply into the parent context; `wait=false` is fire-and-forget for pipeline handoff, joined later via `wait_subagent(session_id)`. Prompts starting with `/` go through the same skill-command expansion as Web input. Sub-agents cannot spawn further sub-agents (`SessionEntry::subagent_depth`). Implementation: [src/host/session_host/tools/spawn_subagent_tool.cpp](src/host/session_host/tools/spawn_subagent_tool.cpp); deps are late-bound via shared_ptr because ToolExecutor is constructed before SessionRegistry in worker.cpp. -**TUI surface**: the right sidebar's "Background Tasks" section lists running sub-agents only (● title + elapsed; removed the moment the child turn ends — user decision); `/tasks [list|abort |clear]` is the operation entry (clear = same permanent-purge semantics as Web, via `SessionStorage::purge_session_files`). A child's `permission_request` is queued (`TuiState::remote_confirm_queue`) and pumped into the confirm overlay when free (origin-labelled; the answer routes back via `SubagentHost::respond_permission`). AskUserQuestion needs no bridging — children share the TUI ToolExecutor, so the TUI ask tool runs directly; it now queues on `TuiState::overlay_cv` until the confirm/ask overlay is free and sets `ask_origin_label` when the caller is a sub-agent. `on_tool_confirm` in tui/app/tui_agent_bridge.cpp queues on the same cv, so concurrent overlay claims (main confirm / child ask / remote pump) serialize instead of clobbering each other. +**TUI surface**: the right sidebar's "Background Tasks" section lists running sub-agents only (● title + elapsed; removed the moment the child turn ends — user decision); `/tasks [list|abort |clear]` is the operation entry (clear = archive, same as the Web panel: unload + `meta.archived`, records kept). TUI exit cleanup (`cleanup_old_sessions`) counts only main sessions against `max_sessions` and deletes children together with their main session; orphans whose parent is gone count as main sessions. A child's `permission_request` is queued (`TuiState::remote_confirm_queue`) and pumped into the confirm overlay when free (origin-labelled; the answer routes back via `SubagentHost::respond_permission`). AskUserQuestion needs no bridging — children share the TUI ToolExecutor, so the TUI ask tool runs directly; it now queues on `TuiState::overlay_cv` until the confirm/ask overlay is free and sets `ask_origin_label` when the caller is a sub-agent. `on_tool_confirm` in tui/app/tui_agent_bridge.cpp queues on the same cv, so concurrent overlay claims (main confirm / child ask / remote pump) serialize instead of clobbering each other. Full subagent reference (design, wire protocol, Web panel, TUI bridging, test map): [docs/subagents.md](docs/subagents.md). -Sub-agent sessions are **hidden from normal session lists** and surfaced only in the parent session's Web「后台任务」panel: the child meta persists `parent_session_id` (survives daemon restart; resume restores `subagent_depth=1` so the depth limit keeps holding), `GET /api/sessions` excludes them by default, `?parent=` queries them, and `DELETE /api/sessions/:id?purge=1` is the panel's「清除」(destroy + delete disk data; sub-agent sessions only, busy → 409). Frontend: `web/src/lib/subagentTasks.js` (pure state, Node tests) + `lib/useSubagentTasks.js` (REST list + WS increments + retainSession for running children so their permission/question requests reach App-level listeners even with the panel closed) + `components/SubagentPanel.jsx` (overlay inside ChatView's message area: running/settled card groups, per-card stop, read-only transcript that shares the main conversation's `projectCollapsedTranscriptItems` projection and `TranscriptItems` renderer, with AskUserQuestion rows filtered out through the projection seam). A running child's `permission_request` pops the global PermissionModal and its `question_request` renders in the parent's QuestionPicker (payloads carry the child `session_id`, so answers route back automatically); both show a「来自后台任务:」origin label. +Sub-agent sessions are **hidden from normal session lists** and surfaced only in the parent session's Web「后台任务」panel: the child meta persists `parent_session_id` (survives daemon restart; resume restores `subagent_depth=1` so the depth limit keeps holding), `GET /api/sessions` excludes them by default, `?parent=<id>` queries them. **Children are kept like ordinary sessions (user decision):** the panel's「归档」archives settled children (`PUT /api/workspaces/:hash/sessions/:id/archive`; hidden from `?parent=` and from the `?archived=1` list, records kept), and permanently deleting an archived main session (`purge_session_data`) cascades to all its children — any running child → 409, children go first so a failure keeps the parent retryable. A mesh agent archived from the panel is unarchived when addressed again (`MeshAgentService::ensure_loaded`). The panel's read-only transcript passes the parent's `workspaceHash`: the Desktop daemon serves many workspaces and an unloaded child (after a Desktop restart or a mesh eviction) is otherwise 404 SESSION_NOT_FOUND. Frontend: `web/src/lib/subagentTasks.js` (pure state, Node tests) + `lib/useSubagentTasks.js` (REST list + WS increments + retainSession for running children so their permission/question requests reach App-level listeners even with the panel closed) + `components/SubagentPanel.jsx` (overlay inside ChatView's message area: running/settled card groups, per-card stop, read-only transcript that shares the main conversation's `projectCollapsedTranscriptItems` projection and `TranscriptItems` renderer, with AskUserQuestion rows filtered out through the projection seam). A running child's `permission_request` pops the global PermissionModal and its `question_request` renders in the parent's QuestionPicker (payloads carry the child `session_id`, so answers route back automatically); both show a「来自后台任务:<title>」origin label. **蜂群模式(网状)(openspec add-mesh-swarm-mode,复刻 Codex Multi-Agent V2,全文见 [docs/subagents.md](docs/subagents.md) §9)。** 蜂群模式是会话级三态 `off | star | mesh`(`SessionMeta.swarm_mode`,入口:消息体 `swarm_mode`、`/swarm`、headless `--swarm`),AgentLoop 每回合从 SessionManager 读,切换只影响下一回合。两套协作工具互斥:`swarm_mode_hidden_tools` → `ToolCapabilityPolicy::hidden_builtin_tools`(schema 与执行同一谓词),mesh 隐藏 `spawn_subagent` / `wait_subagent` / thread 工具,off / star 隐藏六个 `agent_*`。网状子 agent 的 `parent_session_id` 恒为**根** id(扁平,面板 / 权限冒泡 / purge 全复用星型通道),真实层级是 `SessionMeta.agent_path`(`/root/a/b`),树目录落在根会话目录的 `mesh_agents.json`。服务 `host/session_host/mesh/MeshAgentService` 在 worker / TUI(`SubagentHost`,TUI 主会话是外部根)/ headless 各一份,工具只捕获 `weak_ptr`。几条别改回去的语义:(1) 邮件一律进 AgentLoop 邮箱(`engine/agent/mailbox`),每次模型请求前并入;**子 agent 完成回报不唤醒空闲的父 agent**(Codex 原样),只有 `agent_followup_task` / NEW_TASK 唤醒;interrupted 回合不回报。(2) 信封落盘为 **user 角色** + `metadata.inter_agent`(有意偏离 Codex 的 assistant 角色:连续 assistant 在 Anthropic / DeepSeek 上会被拒),NEW_TASK 算真实用户消息、其余是内部上下文,TUI / Web 都转成系统提示行,别让它变成用户气泡(会开出假回合、触发「重发末尾用户消息」)。(3) 驻留上限 `swarm.mesh.max_concurrent_agents` 默认 4 **含根**,满了按 LRU 换出已结束的子 agent(`registry.destroy`,发消息时 `resume` 恢复),都在跑就报 `agent thread limit reached`。(4) Web 输入框芯片 = 服务端模式 + 未提交的本地选择,**只有二者不同才随消息提交 `swarm_mode`**(`web/src/lib/swarmMode.js`):每条消息都带会把 `/swarm` 刚切的模式改回去,网状树里有 agent 在跑时还会直接 409。回归:`tests/session_host/mesh_agent_service_test.cpp`(真实 SessionRegistry + 脚本化 provider 跑整棵树)、`tests/agent/agent_loop_mesh_mailbox_test.cpp`、`swarmMode.test.js` / `interAgentMessage.test.js`。 diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 8b9b5b09..0d21bc2e 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -812,6 +812,13 @@ the user-message search index, then removes `<id>.jsonl`, the per-session `<id>/` persisted-data directory, and `<id>.meta.json` last. Returns `204` only after cleanup succeeds. +Sub-agent sessions (`spawn_subagent` tasks and every mesh agent of the tree) +are kept like ordinary sessions and live and die with their main session: +purging a main session first applies the same cleanup to each of its child +sessions (archived or not, including children that exist only in memory), then +to the main session itself. A failure stops before the main session is removed, +so the operation stays retryable. + Guard rails and errors: - `400` when `purge=1` is missing or the session id is invalid @@ -819,6 +826,8 @@ Guard rails and errors: - `409 {"error":"session must be archived before permanent deletion"}` when the target is not archived - `409` when the target is unexpectedly busy +- `409 {"error":"subagent session <id> is busy; abort it first"}` when one of + the main session's child sessions is running a turn; nothing is deleted - `500` when search-index or file cleanup fails; metadata is retained until the other known session data has been removed so the operation remains retryable @@ -1079,9 +1088,13 @@ thread, and removes it from the registry. It does not delete disk history. Returns `204`; returns `503` when the session client is unavailable. `DELETE /api/sessions/:id?purge=1` performs the same durable cleanup for either -an archived main session or a sub-agent session. It remains the background-task -"clear" action for sub-agents and is also the compatibility fallback used by -the archived-session settings page. Guard rails: +an archived main session (cascading to its child sessions as above) or a single +sub-agent session. It is the compatibility fallback used by the +archived-session settings page. The background-task panel no longer purges: +its "archive" action uses `PUT /api/workspaces/:hash/sessions/:id/archive` +(or `PUT /api/sessions/:id/archive` without a workspace), which unloads the +child, hides it from `?parent=` listings and keeps its records. Archived child +sessions never appear in the `?archived=1` list. Guard rails: - `400 {"error":"only subagent sessions can be purged"}` for a non-archived main session diff --git a/docs/help-source/group5.py b/docs/help-source/group5.py index c8fd63b8..46dd1e24 100644 --- a/docs/help-source/group5.py +++ b/docs/help-source/group5.py @@ -24,7 +24,7 @@ '''<p>子代理拥有独立上下文,并继承父任务的工作目录与权限模式。主任务通常收到子代理最终答复,而非自动把所有中间工具输出复制进自身上下文。共享工作目录意味着文件变化会相互可见;独立上下文不等于独立 Git 工作树。</p><p>网状模式下,子 Agent 默认继承派生者的对话上下文(只保留用户消息与最终回答),也可以只继承最近几轮或完全不继承。下级完成或出错时,结果以「某某已完成」「某某执行出错」提示行送到上级,不会打断上级正在进行的工作,上级下一次回复时会看到它。同一棵协作树同时最多保留 4 个 Agent(含主任务,可在配置文件的 <code>swarm.mesh.max_concurrent_agents</code> 调整);满了会暂时卸载最久没有活动、已经结束的 Agent,再次给它发消息时自动恢复。仍有子 Agent 在运行时不能退出网状模式,先等待它们完成或停止它们。</p>''', figure("AD-03", "开启蜂群模式并提交分工", "展示添加能力菜单中的蜂群模式、开启后的标签,以及说明任务边界的输入内容。")), section("progress", "查看进度与处理操作确认", - '''<p>产生子任务后,在主会话的<strong>后台任务</strong>面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。</p><p>来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。</p><p>可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。清除已结束任务会删除这些子会话记录,确认不再需要查看后再清理。</p>''', + '''<p>产生子任务后,在主会话的<strong>后台任务</strong>面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。</p><p>来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。</p><p>可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。</p><p>子任务的会话记录与普通会话一样长期保留。面板中的<strong>归档</strong>和 TUI 的 <code>/tasks clear</code> 只把已结束任务从列表中收起,不删除记录;网状模式下被收起的 Agent 再次收到消息时会自动恢复并重新出现在面板中。只有把主会话归档后再永久删除,它的全部子任务记录才会随之删除。</p>''', code("/tasks\n/tasks list\n/tasks abort 子任务ID\n/tasks clear", "TUI · 管理后台任务"), figure("AD-04", "后台任务与来自子任务的请求", "展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。"), '''<p>全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。</p>''') diff --git a/docs/help/assets/search-index.js b/docs/help/assets/search-index.js index aa857271..bb213ebe 100644 --- a/docs/help/assets/search-index.js +++ b/docs/help/assets/search-index.js @@ -717,7 +717,7 @@ window.ACECODE_HELP_INDEX = [ "group": "配置与扩展", "title": "项目规则文件", "url": "project-rules.html#files", - "description": "项目规则文件 ACECode 默认识别 AGENTS.md 、 AGENTS.md 和 CLAUDE.md 。同一目录按配置中的文件名顺序选用第一个匹配文件,默认顺序如上;不会把同目录三个文件无条件全部合并。 加载先读取用户数据目录中的全局规则,再按项", + "description": "项目规则文件 ACECode 默认识别 AGENTS.md 、 AGENTS.md 和 CLAUDE.md 。同一目录按配置中的文件名顺序选用第一个匹配文件,默认顺序如上;不会把同目录三个文件无条件全部合并。 加载先读取用户数据目录中的全局规则,再按", "text": "项目规则文件 ACECode 默认识别 AGENTS.md 、 AGENTS.md 和 CLAUDE.md 。同一目录按配置中的文件名顺序选用第一个匹配文件,默认顺序如上;不会把同目录三个文件无条件全部合并。 加载先读取用户数据目录中的全局规则,再按项目目录层级从外到内收集。是否读取、文件名顺序及内容长度都受配置控制,CLAUDE.md 还受独立读取开关控制。 规则适合记录目录职责、构建测试命令、编码风格、禁止修改的区域和交付标准。不要把临时任务状态、长日志和密钥放进去,冗长规则也会占用模型上下文。" }, { @@ -725,7 +725,7 @@ window.ACECODE_HELP_INDEX = [ "group": "配置与扩展", "title": "使用初始化命令", "url": "project-rules.html#init", - "description": "使用初始化命令 在正确的项目中提交 /init ,让 ACECode 分析仓库并生成或改进 AGENTS.md 。它会调用模型,可能需要阅读文件和写入确认;生成后仍应检查其中的目录与命令是否真实可用。 在 ACECode 输入框中提交 复制 /init", + "description": "使用初始化命令 在正确的项目中提交 /init ,让 ACECode 分析仓库并生成或改进 AGENTS.md 。它会调用模型,可能需要阅读文件和写入确认;生成后仍应检查其中的目录与命令是否真实可用。 在 ACECode 输入框中提交 复制 /ini", "text": "使用初始化命令 在正确的项目中提交 /init ,让 ACECode 分析仓库并生成或改进 AGENTS.md 。它会调用模型,可能需要阅读文件和写入确认;生成后仍应检查其中的目录与命令是否真实可用。 在 ACECode 输入框中提交 复制 /init 已有团队维护的 AGENTS.md 时,先明确希望更新哪个文件,避免无意引入优先级更高但内容重复的 AGENTS.md。桌面/Web 选择命令只把文本填入输入框,提交后才执行。 CF-04 图片占位 初始化后的项目规则检查 此处补充实际界面截图 16 : 9 展示提交 /init 的位置以及生成规则的文件预览,标出构建命令、测试命令和禁止修改范围。" }, { @@ -733,7 +733,7 @@ window.ACECODE_HELP_INDEX = [ "group": "配置与扩展", "title": "一份小而明确的规则", "url": "project-rules.html#example", - "description": "一份小而明确的规则 AGENTS.md 内容示例 复制 项目使用 Python,业务代码放在 src/,测试放在 tests/。 修改前先阅读相关模块与测试;不要修改 generated/ 下的生成文件。 保持现有公开接口,新增行为需要相应测试。 完成", + "description": "一份小而明确的规则 AGENTS.md 内容示例 复制 项目使用 Python,业务代码放在 src/,测试放在 tests/。 修改前先阅读相关模块与测试;不要修改 generated/ 下的生成文件。 保持现有公开接口,新增行为需要相应测试。 完", "text": "一份小而明确的规则 AGENTS.md 内容示例 复制 项目使用 Python,业务代码放在 src/,测试放在 tests/。 修改前先阅读相关模块与测试;不要修改 generated/ 下的生成文件。 保持现有公开接口,新增行为需要相应测试。 完成后运行项目 README 中记录的验证命令,并报告未执行的检查。 把示例中的语言、路径和验证命令替换成自己的项目约定。稳定的个人偏好放在个性化或记忆中,可复用的操作流程整理为 技能 ;本项目特有的约束留在项目规则里。" }, { @@ -990,7 +990,7 @@ window.ACECODE_HELP_INDEX = [ "title": "蜂群模式与子代理", "url": "swarm.html", "description": "把可以独立推进的工作交给子代理,同时保留主任务负责整合、验证和最终交付。", - "text": "任务分工与并行执行 在输入框左侧 添加能力或上下文 菜单里有 蜂群模式(星型) 与 蜂群模式(网状) 两项,二选一;再点一次已选中的那项即关闭。选中后输入区出现对应标签。 模式 协作方式 适合的任务 蜂群模式(星型) 主任务派出子 Agent,子 Agent 只向主任务汇报最终结果,不再继续派生。 几项互不相关、各自汇报即可的调查或检查。 蜂群模式(网状) 任意 Agent 都能继续派生下级(路径形如 /root/research/web),可以互相发消息、追加后续任务,并等待下级回报。 需要分层拆解、中途协调或多轮往返的大任务。 蜂群模式是会话级设置:随下一条消息生效并一直保持,直到切换或关闭;TUI 与对话中都可以用 /swarm star 、 /swarm mesh 、 /swarm off 切换, /swarm 查看当前模式。开启状态本身不会立即启动任务,也不保证每一个请求都会产生相同数量的子任务。 适合并行的工作包括分别调查两个独立模块、检查不同平台的入口、准备测试与阅读接口。先说明每个子任务的范围、允许修改的文件和需要返回的结果,避免多个代理同时改同一处内容。 可独立并行的任务示例 复制 并行检查前端表单与后端校验:一位代理只阅读前端并列出边界情况,另一位只阅读后端并找出校验差异。由主任务汇总证据后提出修复方案,暂不修改文件。 子代理拥有独立上下文,并继承父任务的工作目录与权限模式。主任务通常收到子代理最终答复,而非自动把所有中间工具输出复制进自身上下文。共享工作目录意味着文件变化会相互可见;独立上下文不等于独立 Git 工作树。 网状模式下,子 Agent 默认继承派生者的对话上下文(只保留用户消息与最终回答),也可以只继承最近几轮或完全不继承。下级完成或出错时,结果以「某某已完成」「某某执行出错」提示行送到上级,不会打断上级正在进行的工作,上级下一次回复时会看到它。同一棵协作树同时最多保留 4 个 Agent(含主任务,可在配置文件的 swarm.mesh.max_concurrent_agents 调整);满了会暂时卸载最久没有活动、已经结束的 Agent,再次给它发消息时自动恢复。仍有子 Agent 在运行时不能退出网状模式,先等待它们完成或停止它们。 从添加能力菜单选择蜂群模式后,输入区显示模式标签。草稿中说明负责的文件与只读边界;截图时尚未提交此分工请求。 Windows 实际界面 · 点击图片查看原图 查看进度与处理操作确认 产生子任务后,在主会话的 后台任务 面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。 来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。 可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。清除已结束任务会删除这些子会话记录,确认不再需要查看后再清理。 TUI · 管理后台任务 复制 /tasks /tasks list /tasks abort 子任务ID /tasks clear AD-04 图片占位 后台任务与来自子任务的请求 此处补充实际界面截图 16 : 9 展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。 全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。" + "text": "任务分工与并行执行 在输入框左侧 添加能力或上下文 菜单里有 蜂群模式(星型) 与 蜂群模式(网状) 两项,二选一;再点一次已选中的那项即关闭。选中后输入区出现对应标签。 模式 协作方式 适合的任务 蜂群模式(星型) 主任务派出子 Agent,子 Agent 只向主任务汇报最终结果,不再继续派生。 几项互不相关、各自汇报即可的调查或检查。 蜂群模式(网状) 任意 Agent 都能继续派生下级(路径形如 /root/research/web),可以互相发消息、追加后续任务,并等待下级回报。 需要分层拆解、中途协调或多轮往返的大任务。 蜂群模式是会话级设置:随下一条消息生效并一直保持,直到切换或关闭;TUI 与对话中都可以用 /swarm star 、 /swarm mesh 、 /swarm off 切换, /swarm 查看当前模式。开启状态本身不会立即启动任务,也不保证每一个请求都会产生相同数量的子任务。 适合并行的工作包括分别调查两个独立模块、检查不同平台的入口、准备测试与阅读接口。先说明每个子任务的范围、允许修改的文件和需要返回的结果,避免多个代理同时改同一处内容。 可独立并行的任务示例 复制 并行检查前端表单与后端校验:一位代理只阅读前端并列出边界情况,另一位只阅读后端并找出校验差异。由主任务汇总证据后提出修复方案,暂不修改文件。 子代理拥有独立上下文,并继承父任务的工作目录与权限模式。主任务通常收到子代理最终答复,而非自动把所有中间工具输出复制进自身上下文。共享工作目录意味着文件变化会相互可见;独立上下文不等于独立 Git 工作树。 网状模式下,子 Agent 默认继承派生者的对话上下文(只保留用户消息与最终回答),也可以只继承最近几轮或完全不继承。下级完成或出错时,结果以「某某已完成」「某某执行出错」提示行送到上级,不会打断上级正在进行的工作,上级下一次回复时会看到它。同一棵协作树同时最多保留 4 个 Agent(含主任务,可在配置文件的 swarm.mesh.max_concurrent_agents 调整);满了会暂时卸载最久没有活动、已经结束的 Agent,再次给它发消息时自动恢复。仍有子 Agent 在运行时不能退出网状模式,先等待它们完成或停止它们。 从添加能力菜单选择蜂群模式后,输入区显示模式标签。草稿中说明负责的文件与只读边界;截图时尚未提交此分工请求。 Windows 实际界面 · 点击图片查看原图 查看进度与处理操作确认 产生子任务后,在主会话的 后台任务 面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。 来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。 可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。 子任务的会话记录与普通会话一样长期保留。面板中的 归档 和 TUI 的 /tasks clear 只把已结束任务从列表中收起,不删除记录;网状模式下被收起的 Agent 再次收到消息时会自动恢复并重新出现在面板中。只有把主会话归档后再永久删除,它的全部子任务记录才会随之删除。 TUI · 管理后台任务 复制 /tasks /tasks list /tasks abort 子任务ID /tasks clear AD-04 图片占位 后台任务与来自子任务的请求 此处补充实际界面截图 16 : 9 展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。 全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。" }, { "page": "蜂群模式与子代理", @@ -1006,7 +1006,7 @@ window.ACECODE_HELP_INDEX = [ "title": "查看进度与处理操作确认", "url": "swarm.html#progress", "description": "查看进度与处理操作确认 产生子任务后,在主会话的 后台任务 面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。 来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务", - "text": "查看进度与处理操作确认 产生子任务后,在主会话的 后台任务 面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。 来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。 可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。清除已结束任务会删除这些子会话记录,确认不再需要查看后再清理。 TUI · 管理后台任务 复制 /tasks /tasks list /tasks abort 子任务ID /tasks clear AD-04 图片占位 后台任务与来自子任务的请求 此处补充实际界面截图 16 : 9 展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。 全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。" + "text": "查看进度与处理操作确认 产生子任务后,在主会话的 后台任务 面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。 来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。 可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。 子任务的会话记录与普通会话一样长期保留。面板中的 归档 和 TUI 的 /tasks clear 只把已结束任务从列表中收起,不删除记录;网状模式下被收起的 Agent 再次收到消息时会自动恢复并重新出现在面板中。只有把主会话归档后再永久删除,它的全部子任务记录才会随之删除。 TUI · 管理后台任务 复制 /tasks /tasks list /tasks abort 子任务ID /tasks clear AD-04 图片占位 后台任务与来自子任务的请求 此处补充实际界面截图 16 : 9 展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。 全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。" }, { "page": "定时与循环任务", diff --git a/docs/help/swarm.html b/docs/help/swarm.html index 70c2f042..c8850796 100644 --- a/docs/help/swarm.html +++ b/docs/help/swarm.html @@ -44,7 +44,7 @@ <p>子代理拥有独立上下文,并继承父任务的工作目录与权限模式。主任务通常收到子代理最终答复,而非自动把所有中间工具输出复制进自身上下文。共享工作目录意味着文件变化会相互可见;独立上下文不等于独立 Git 工作树。</p><p>网状模式下,子 Agent 默认继承派生者的对话上下文(只保留用户消息与最终回答),也可以只继承最近几轮或完全不继承。下级完成或出错时,结果以「某某已完成」「某某执行出错」提示行送到上级,不会打断上级正在进行的工作,上级下一次回复时会看到它。同一棵协作树同时最多保留 4 个 Agent(含主任务,可在配置文件的 <code>swarm.mesh.max_concurrent_agents</code> 调整);满了会暂时卸载最久没有活动、已经结束的 Agent,再次给它发消息时自动恢复。仍有子 Agent 在运行时不能退出网状模式,先等待它们完成或停止它们。</p> <figure class="screenshot" data-figure="AD-03" data-image-status="captured"><a class="screenshot-link" href="assets/screenshots/composer-capability-menu.jpg" aria-label="查看原图:添加能力菜单中的蜂群模式、专家组件与文件或文件夹入口"><img src="assets/screenshots/composer-capability-menu.jpg" width="1282" height="727" alt="添加能力菜单中的蜂群模式、专家组件与文件或文件夹入口" loading="lazy" decoding="async"></a> <a class="screenshot-link" href="assets/screenshots/composer-swarm-draft.jpg" aria-label="查看原图:蜂群模式标签与说明分工、文件范围和只读边界的草稿,截图时尚未发送"><img src="assets/screenshots/composer-swarm-draft.jpg" width="1282" height="727" alt="蜂群模式标签与说明分工、文件范围和只读边界的草稿,截图时尚未发送" loading="lazy" decoding="async"></a><figcaption>从添加能力菜单选择蜂群模式后,输入区显示模式标签。草稿中说明负责的文件与只读边界;截图时尚未提交此分工请求。<span class="screenshot-hint">Windows 实际界面 · 点击图片查看原图</span></figcaption></figure></section> -<section aria-labelledby="progress"><h2 id="progress">查看进度与处理操作确认</h2><p>产生子任务后,在主会话的<strong>后台任务</strong>面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。</p><p>来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。</p><p>可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。清除已结束任务会删除这些子会话记录,确认不再需要查看后再清理。</p> +<section aria-labelledby="progress"><h2 id="progress">查看进度与处理操作确认</h2><p>产生子任务后,在主会话的<strong>后台任务</strong>面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。</p><p>来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。</p><p>可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。</p><p>子任务的会话记录与普通会话一样长期保留。面板中的<strong>归档</strong>和 TUI 的 <code>/tasks clear</code> 只把已结束任务从列表中收起,不删除记录;网状模式下被收起的 Agent 再次收到消息时会自动恢复并重新出现在面板中。只有把主会话归档后再永久删除,它的全部子任务记录才会随之删除。</p> <div class="code-block"><div class="code-heading"><span>TUI · 管理后台任务</span><button class="copy-button js-only" type="button" aria-label="复制代码"><svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M8 8h12v13H8zM16 8V3H3v13h5"/></svg><span>复制</span></button></div><pre><code>/tasks /tasks list /tasks abort 子任务ID diff --git a/docs/subagents.md b/docs/subagents.md index 5ccb946f..b7144903 100644 --- a/docs/subagents.md +++ b/docs/subagents.md @@ -83,12 +83,12 @@ Multi-Agent V2,openspec `add-mesh-swarm-mode`)。 | 文件 | 职责 | |---|---| | `src/host/session_host/tools/spawn_subagent_tool.{hpp,cpp}` | 两个工具本体 + `SubagentToolDeps{registry, client, config, fallback_permissions, on_spawn}`。daemon 里 deps 用 shared_ptr 延迟回填(ToolExecutor 先于 SessionRegistry 构造,见 worker.cpp) | -| `src/domain/session/session_storage.{hpp,cpp}` | `SessionMeta.parent_session_id`(空省略序列化);`purge_session_files(project_dir, id)` 删 jsonl + meta + `<id>/` 目录(web purge 路由与 TUI `/tasks clear` 共用) | +| `src/domain/session/session_storage.{hpp,cpp}` | `SessionMeta.parent_session_id`(空省略序列化);`purge_session_files(project_dir, id)` 删 jsonl + meta + `<id>/` 目录(web purge 路由用;主会话永久删除时对它的每个子会话各调一次) | | `src/domain/session/session_manager.{hpp,cpp}` | `set_parent_session_id` / `current_parent_session_id`;start_session 重置、ensure_created/update_meta 落盘、resume_session 读回 | | `src/host/session_host/session_registry.{hpp,cpp}` | `SessionEntry::{subagent_depth, parent_session_id}`;make_entry_locked 从 opts 写入 / 从 resumed_meta 恢复(恢复时强制 depth≥1);list_active 透出 parent 字段 | | `src/domain/session/session_client.hpp` | `SessionOptions::{subagent_depth, parent_session_id}`、`SessionInfo::parent_session_id` | | `src/apps/web/server_helpers.cpp` | `sessions_for_workspace(..., parent_filter)`:空 = 常规列表**排除**全部子会话;非空 = 只返回该父会话的子任务(active 部分跳过 workspace 过滤);`session_info_to_json` / `session_meta_to_json` 输出 `parent_session_id` | -| `src/apps/web/routes/routes_sessions.cpp` | `GET /api/sessions?parent=<id>`;`DELETE /api/sessions/:id?purge=1`(仅子会话,主会话 400,busy 409;destroy 后 purge_session_files) | +| `src/apps/web/routes/routes_sessions.cpp` | `GET /api/sessions?parent=<id>`;`DELETE /api/sessions/:id?purge=1`(子会话或已归档主会话,未归档主会话 400,busy 409;实现在 `server_helpers.cpp::purge_session_data`,主会话连同子会话级联删除) | | `src/apps/web/routes/routes_workspaces.cpp` | workspace 路由的 `?parent=` 同语义 | | `src/apps/daemon/worker.cpp` | daemon 注册点(registry/client 就绪后回填 deps) | @@ -97,8 +97,13 @@ Multi-Agent V2,openspec `add-mesh-swarm-mode`)。 - `GET /api/sessions` 与 `GET /api/workspaces/:hash/sessions` 默认排除子会话; `?parent=<session_id>` 反查该父会话的子任务(后台任务面板数据源)。 - `SessionSummary` 增 `parent_session_id` 字段(普通会话为空串)。 -- `DELETE /api/sessions/:id?purge=1` = 面板「清除」:销毁 + 永久删磁盘; - `400 only subagent sessions can be purged` / `409 session is busy`。 +- 子会话与普通会话一样长期保存。面板「归档」= `PUT /api/workspaces/:hash/sessions/:id/archive` + (父会话没有工作区时走 `PUT /api/sessions/:id/archive`):卸载 + `meta.archived=true`,`?parent=` + 不再返回它,「已归档会话」列表也不列子会话,磁盘记录保留。 +- 永久删除(`DELETE …?purge=1`)一个已归档主会话时级联删除它的全部子会话(归档与否,含还没落盘、 + 只在内存里的),子会话在前、主会话最后,中途失败时主会话仍在可重试;任一子会话运行中 → + `409 subagent session <id> is busy; abort it first`。单独 purge 子会话的兼容路由保留 + (`400 only subagent sessions can be purged` / `409 session is busy`),面板已不再调用。 - `tool_end` payload 的 `metadata` 原样透传(含 `subagent_session_id`),这是前端即时发现 新子任务的通道之一。 @@ -122,7 +127,9 @@ session_status 帧(未知 busy 会话) → refetch ← wait=true question_request 能到达 App 全局监听并冒泡的前提。parent 切换有迟到响应守卫。 - `web/src/components/SubagentPanel.jsx` — overlay(absolute 挂在 ChatView 消息区容器内, 只覆盖聊天区,不动 Sidebar/SidePanel):运行中 / 已完成分组卡片;运行中卡片右上停止按钮; - 「清除」批量 purge 已结束任务;「查看会话」原地切到只读 transcript(复用主会话 + 「归档」批量归档已结束任务(按父会话所在工作区走工作区路由);「查看会话」原地切到只读 + transcript,历史请求带父会话的 `workspaceHash` —— Desktop 的 daemon 同时服务多个工作区,子会话 + 不在内存里(Desktop 重启后、网状 agent 被换出)时只能按它读盘,否则 404 SESSION_NOT_FOUND(复用主会话 Message/ToolBlock 紧凑渲染,`useSessionTranscript` live:'auto' 实时跟尾,AskUserQuestion 工具行过滤不显示)。 - ChatView 标题栏最右「后台任务」按钮:有任务或面板开着才出现,徽标 = 运行中数。 @@ -156,7 +163,8 @@ SessionRegistry / LocalSessionClient 无 web 依赖 → TUI 进程直接实例 `Deps::on_permission_request` 冒泡。快照经 `Deps::publish_tasks` 交付 (main.cpp 写入 `TuiState.subagent_tasks` + PostEvent)。 - `/tasks` 后端:`list_tasks`(运行中 registry + 磁盘 parent 匹配的已结束)、 - `abort_task`、`clear_settled`(purge_session_files)、`respond_permission`。 + `abort_task`、`clear_settled`(归档:卸载 + 写 `meta.archived`,`list_tasks` 不再列出)、 + `respond_permission`。 - 不显式退订:dispatcher 生命周期 = SessionEntry;host 析构(main 栈)→ registry 析构 → 逐个 abort+join 子会话。 @@ -195,7 +203,9 @@ TuiState overlay,工具线程 wait ask_cv 天然带回结果)。只需两点:入 运行中任务(`●` + 标题截断 + 耗时);anim_thread 在 `subagent_tasks` 非空时持续 tick(否则 `wait=false` 点火后主会话 idle,耗时不刷新)。 - `/tasks [list|abort <id>|clear]`(builtin_commands.cpp,`CommandContext::subagent_host` - 仅斜杠 dispatch 路径注入):abort 支持 id 前缀唯一匹配;clear 与 Web「清除」同语义。 + 仅斜杠 dispatch 路径注入):abort 支持 id 前缀唯一匹配;clear 与 Web「归档」同语义(只收起, + 记录保留)。TUI 退出时的 `cleanup_old_sessions` 只按主会话计 `max_sessions` 名额,被清理的主会话 + 连同子会话一起删;父会话已不在的孤儿子会话按主会话计,否则永远清不掉。 ## 6. 测试地图 @@ -207,8 +217,10 @@ TuiState overlay,工具线程 wait ask_cv 天然带回结果)。只需两点:入 | `tests/loop/loop_scheduler_test.cpp` + `loop_store_test.cpp` | `detect_workspace_touched` 真实 git;`workspace_touched` 持久化与 v3 迁移 | | `tests/project_instructions/instructions_loader_test.cpp` | linked worktree 根止步(主 checkout 的 AGENTS.md 不再重复加载) | | `tests/session/session_storage_test.cpp` | meta parent_session_id 回环 + 空省略 | -| `tests/web/web_server_smoke_test.cpp` | 列表隐藏 + ?parent= 反查;purge 护栏(主会话 400)+ 真删 + 普通 DELETE 不删盘 | -| `tests/tui/subagent_host_test.cpp` | 快照发布/BusyChanged 移除;list 合并 + clear 只删已结束;abort 路由 | +| `tests/web/web_server_smoke_test.cpp` | 列表隐藏 + ?parent= 反查;purge 护栏(主会话 400)+ 真删 + 普通 DELETE 不删盘;归档子会话离开面板但保留、主会话永久删除级联子会话(PurgingArchivedMainSessionCascadesToItsSubagents) | +| `tests/tui/subagent_host_test.cpp` | 快照发布/BusyChanged 移除;list 合并 + clear 只归档已结束;abort 路由 | +| `tests/session/session_cleanup_test.cpp` | 退出清理只按主会话计名额、级联删子会话、孤儿子会话按主会话计 | +| `web/src/lib/subagentPanelSplitArchitecture.test.js` | 子会话 transcript 带父会话工作区;面板只归档不 purge | | `web/src/lib/subagentTasks.test.js` | 归一化/合并/事件增量/aborted 保持/分组/格式化(13 例) | 统一模式:EchoStreamProvider stub 让子会话 turn 真实完成(消息落盘、busy 迁移),不打真实 LLM。 @@ -301,6 +313,7 @@ provider 上会被拒或合并)。 最久没有活动、已有结果、无待办与空邮箱的子 agent(`registry.destroy`,记录仍在磁盘);一个都换不出 时报 `collab spawn failed: agent thread limit reached`(投递恢复时是 `collab tool failed: …`)。 - 给未加载的 agent 发消息 / 后续任务会先 `registry.resume` 恢复(可能换出别的),再投递并冲刷暂存邮件。 + 被面板「归档」收起的 agent 同样走这条路,恢复时取消归档,重新出现在后台任务面板里。 - `on_agent_loaded` 回调让 Web(`track_subagent`)与 TUI(`SubagentHost::on_spawned`)对新建 / 恢复的 子会话重新订阅事件。 @@ -324,6 +337,6 @@ provider 上会被拒或合并)。 |---|---| | `tests/session/agent_path_test.cpp`、`mesh_swarm_domain_test.cpp`、`inter_agent_message_test.cpp` | 路径 / 模式名 / 工具互斥 / 信封 / fork_turns / 树索引 | | `tests/agent/agent_mailbox_test.cpp`、`agent_loop_mesh_mailbox_test.cpp` | 邮箱等待;运行中并入、空闲触发唤醒、只排队、agent_wait 结局 | -| `tests/session_host/mesh_agent_service_test.cpp` | spawn → 回报、send / followup、参数校验、LRU 换出与恢复、上限、interrupt、errored、嵌套、退出守卫、重启重建、工具输出契约 | +| `tests/session_host/mesh_agent_service_test.cpp` | spawn → 回报、send / followup、参数校验、LRU 换出与恢复、上限、interrupt、errored、嵌套、退出守卫、重启重建、归档后再寻址取消归档、工具输出契约 | | `tests/session_host/swarm_command_test.cpp`、`tests/config/config_swarm_test.cpp`、`tests/headless/headless_options_test.cpp` | `/swarm` 文本、配置夹取、`--swarm` | | `web/src/lib/swarmMode.test.js`、`interAgentMessage.test.js`、`subagentTasks.test.js`、`chatInputQueue.test.js` | 芯片状态、信封显示、面板路径、排队模式 | diff --git a/src/apps/tui/commands/builtin_commands.cpp b/src/apps/tui/commands/builtin_commands.cpp index 942bd4ef..2a87a21c 100644 --- a/src/apps/tui/commands/builtin_commands.cpp +++ b/src/apps/tui/commands/builtin_commands.cpp @@ -1953,7 +1953,7 @@ static void cmd_tasks(CommandContext& ctx, const std::string& args) { if (!e.title.empty()) oss << " " << e.title; } oss << "\n\nUse /tasks abort <id> to stop a running task, " - "/tasks clear to permanently delete settled ones."; + "/tasks clear to archive settled ones (kept until the main session is deleted)."; push_system(oss.str()); return; } @@ -1985,8 +1985,8 @@ static void cmd_tasks(CommandContext& ctx, const std::string& args) { if (sub == "clear") { const int removed = ctx.subagent_host->clear_settled(project_dir); push_system(removed > 0 - ? "Cleared " + std::to_string(removed) + - " settled subagent task(s) (disk data permanently deleted)." + ? "Archived " + std::to_string(removed) + + " settled subagent task(s); records are kept until the main session is deleted." : "No settled subagent tasks to clear."); return; } diff --git a/src/apps/tui/subagent_host.cpp b/src/apps/tui/subagent_host.cpp index ec6b65da..442a609d 100644 --- a/src/apps/tui/subagent_host.cpp +++ b/src/apps/tui/subagent_host.cpp @@ -3,7 +3,6 @@ #include "agent/agent_loop.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" -#include "session/session_user_message_search.hpp" #include "utils/logger.hpp" #include <algorithm> @@ -139,6 +138,7 @@ SubagentHost::list_tasks(const std::string& project_dir) const { if (!parent.empty() && !project_dir.empty()) { for (const auto& meta : SessionStorage::list_sessions(project_dir)) { if (meta.parent_session_id != parent) continue; + if (meta.archived) continue; // /tasks clear 收起的任务不再列出 if (std::find(running_ids.begin(), running_ids.end(), meta.id) != running_ids.end()) { continue; @@ -169,10 +169,11 @@ int SubagentHost::clear_settled(const std::string& project_dir) { std::lock_guard<std::mutex> lk(mu_); for (const auto& t : running_) running_ids.push_back(t.id); } + // 子会话与普通会话一样长期保存:clear 只归档(从列表收起并卸载), + // 磁盘记录在主会话被永久删除时才随之删除(Web 面板「归档」同语义)。 int removed = 0; - SessionUserMessageIndex search_index(project_dir); - for (const auto& meta : SessionStorage::list_sessions(project_dir)) { - if (meta.parent_session_id != parent) continue; + for (auto meta : SessionStorage::list_sessions(project_dir)) { + if (meta.parent_session_id != parent || meta.archived) continue; if (std::find(running_ids.begin(), running_ids.end(), meta.id) != running_ids.end()) { continue; @@ -187,19 +188,16 @@ int SubagentHost::clear_settled(const std::string& project_dir) { } } subscription.reset(); - registry_.destroy(meta.id); // 不在 registry 时是 no-op - SessionStorage::purge_session_files(project_dir, meta.id); - { - // 与 Web 端 purge 一致:永久删除必须连用户消息搜索索引一起清, - // 否则子会话的用户输入全文残留在索引数据库。 - std::string index_error; - if (!search_index.remove_session(meta.id, &index_error)) { - LOG_WARN("[subagent] purge failed to remove search index for " + - meta.id + ": " + index_error); - } + registry_.destroy(meta.id); // 不在 registry 时是 no-op;先落盘再改 meta + meta = SessionStorage::read_meta(SessionStorage::meta_path(project_dir, meta.id)); + if (meta.id.empty()) continue; + meta.archived = true; + if (!SessionStorage::write_meta(SessionStorage::meta_path(project_dir, meta.id), meta)) { + LOG_WARN("[subagent] failed to archive settled task " + meta.id); + continue; } ++removed; - LOG_INFO("[subagent] purged settled task " + meta.id); + LOG_INFO("[subagent] archived settled task " + meta.id); } return removed; } diff --git a/src/apps/web/server_helpers.cpp b/src/apps/web/server_helpers.cpp index 9f09de8b..9244e75d 100644 --- a/src/apps/web/server_helpers.cpp +++ b/src/apps/web/server_helpers.cpp @@ -1453,9 +1453,6 @@ crow::response WebServer::Impl::purge_session_data( } } - // Destroy first so SessionManager flushes and releases the writer lease. - deps.session_client->destroy_session(id); - const std::string storage_cwd = meta.no_workspace ? meta.cwd : (meta.archived @@ -1463,23 +1460,55 @@ crow::response WebServer::Impl::purge_session_data( : (meta.cwd.empty() ? ws.cwd : meta.cwd)); const auto project_dir = SessionStorage::get_project_dir(storage_cwd); - // Remove the full-text projection first. If this fails, leave the files - // and metadata untouched so the settings row remains retryable. - SessionUserMessageIndex search_index(project_dir); - std::string index_error; - if (!search_index.remove_session(id, &index_error)) { - LOG_WARN("[web] purge failed to remove search index for " + id + - ": " + index_error); - return error_response(500, "failed to remove session search index"); + // Child sessions (spawn_subagent tasks, every mesh agent of the tree) are + // kept like ordinary sessions and only deleted together with their + // top-level session. Children go first so a failure leaves the parent + // (and the settings row) in place for a retry. + std::vector<std::string> doomed; + if (meta.parent_session_id.empty()) { + for (const auto& child : SessionStorage::list_session_metadata(project_dir)) { + if (child.parent_session_id == id) doomed.push_back(child.id); + } + // A child that has not written anything yet exists only in memory. + for (const auto& s : deps.session_client->list_sessions()) { + if (s.parent_session_id == id && + std::find(doomed.begin(), doomed.end(), s.id) == doomed.end()) { + doomed.push_back(s.id); + } + } + for (const auto& child_id : doomed) { + if (!deps.session_registry) break; + if (auto entry = deps.session_registry->acquire(child_id); + entry && entry->loop && entry->loop->is_busy()) { + return error_response(409, "subagent session " + child_id + + " is busy; abort it first"); + } + } } + doomed.push_back(id); - std::string purge_error; - if (!SessionStorage::purge_session_files(project_dir, id, &purge_error)) { - LOG_WARN("[web] purge failed to remove session files for " + id + - ": " + purge_error); - return error_response(500, purge_error.empty() - ? "failed to remove session files" - : purge_error); + SessionUserMessageIndex search_index(project_dir); + for (const auto& target : doomed) { + // Destroy first so SessionManager flushes and releases the writer lease. + deps.session_client->destroy_session(target); + + // Remove the full-text projection first. If this fails, leave the files + // and metadata untouched so the settings row remains retryable. + std::string index_error; + if (!search_index.remove_session(target, &index_error)) { + LOG_WARN("[web] purge failed to remove search index for " + target + + ": " + index_error); + return error_response(500, "failed to remove session search index"); + } + + std::string purge_error; + if (!SessionStorage::purge_session_files(project_dir, target, &purge_error)) { + LOG_WARN("[web] purge failed to remove session files for " + target + + ": " + purge_error); + return error_response(500, purge_error.empty() + ? "failed to remove session files" + : purge_error); + } } if (global_session_search) { @@ -1488,7 +1517,7 @@ crow::response WebServer::Impl::purge_session_data( LOG_INFO("[web] permanently deleted session " + id + (meta.parent_session_id.empty() - ? std::string{" (archived)"} + ? " (archived, " + std::to_string(doomed.size() - 1) + " subagent session(s))" : " (parent=" + meta.parent_session_id + ")")); return with_cors(req, crow::response(204)); } diff --git a/src/domain/session/session_manager.cpp b/src/domain/session/session_manager.cpp index e580cd02..3fe456f8 100644 --- a/src/domain/session/session_manager.cpp +++ b/src/domain/session/session_manager.cpp @@ -1067,13 +1067,29 @@ void SessionManager::cleanup_old_sessions(int max_sessions) { std::lock_guard<std::mutex> lk(mu_); if (project_dir_.empty()) return; - auto sessions = SessionStorage::list_sessions(project_dir_); - if (static_cast<int>(sessions.size()) <= max_sessions) return; + // Child sessions (spawn_subagent / mesh agents) do not use up the quota: + // they live and die with their top-level session. Orphans whose parent is + // already gone count as top-level, otherwise nothing would ever remove them. + const auto sessions = SessionStorage::list_sessions(project_dir_); + std::unordered_set<std::string> ids; + for (const auto& meta : sessions) ids.insert(meta.id); + std::vector<std::string> top; + std::unordered_map<std::string, std::vector<std::string>> children; + for (const auto& meta : sessions) { + if (meta.parent_session_id.empty() || !ids.count(meta.parent_session_id)) top.push_back(meta.id); + else children[meta.parent_session_id].push_back(meta.id); + } + if (static_cast<int>(top.size()) <= max_sessions) return; // Sessions are sorted newest-first; remove canonical files from the tail. // PID-suffixed files are incompatible old data and are not counted here. - for (size_t i = static_cast<size_t>(max_sessions); i < sessions.size(); ++i) { - const std::string& id = sessions[i].id; + std::vector<std::string> doomed; + for (size_t i = static_cast<size_t>(max_sessions); i < top.size(); ++i) { + doomed.push_back(top[i]); + const auto& kids = children[top[i]]; + doomed.insert(doomed.end(), kids.begin(), kids.end()); + } + for (const std::string& id : doomed) { std::error_code ec; fs::remove(SessionStorage::session_path(project_dir_, id), ec); fs::remove(SessionStorage::meta_path(project_dir_, id), ec); diff --git a/src/host/session_host/mesh/mesh_agent_service.cpp b/src/host/session_host/mesh/mesh_agent_service.cpp index db0ad524..e59b65b2 100644 --- a/src/host/session_host/mesh/mesh_agent_service.cpp +++ b/src/host/session_host/mesh/mesh_agent_service.cpp @@ -416,6 +416,10 @@ std::string MeshAgentService::ensure_loaded(const Caller& caller, const std::str finish(false); return "collab tool failed: agent `" + path + "` could not be restored"; } + if (auto entry = deps_.registry->acquire(session_id); entry && entry->sm) { + // 面板「归档」收起的 agent 被再次寻址:取消归档,让它重新出现在后台任务面板里。 + entry->sm->set_session_archived(false); + } subscribe_child(caller.root_id, session_id); commit_resident(caller.root_id, session_id); LOG_INFO("[mesh] restored agent " + path + " (" + session_id + ")"); diff --git a/tests/session/session_cleanup_test.cpp b/tests/session/session_cleanup_test.cpp new file mode 100644 index 00000000..90c55f56 --- /dev/null +++ b/tests/session/session_cleanup_test.cpp @@ -0,0 +1,125 @@ +// 覆盖 SessionManager::cleanup_old_sessions(TUI 退出时按 config.max_sessions +// 清理最旧的会话)。 +// +// 子会话(spawn_subagent 后台任务、网状 agent)与普通会话一样长期保存,只跟随 +// 主会话一起删除:名额只按主会话计,被清理的主会话连同它的子会话一起删除。 +// 一旦回归: +// - 子会话占名额 → 一次网状协作派出十几个 agent,就把用户的老会话全挤掉 +// - 只删主会话不删子会话 → 子会话变成永远清不掉的孤儿记录 +// - 父会话早已不在的孤儿子会话不计名额 → 它们永远不会被清理 + +#include <gtest/gtest.h> + +#include "session/session_manager.hpp" +#include "session/session_storage.hpp" + +#include <filesystem> +#include <fstream> +#include <random> +#include <string> + +namespace fs = std::filesystem; + +using acecode::SessionManager; +using acecode::SessionMeta; +using acecode::SessionStorage; + +namespace { + +fs::path make_temp_cwd(const std::string& hint) { + auto dir = fs::temp_directory_path() / + ("acecode_session_cleanup_" + hint + "_" + + std::to_string(std::random_device{}())); + fs::remove_all(dir); + fs::create_directories(dir); + return dir; +} + +// 手造一条落盘会话(meta + 空 jsonl);updated_at 决定新旧顺序。 +void write_session(const std::string& project_dir, + const std::string& id, + const std::string& updated_at, + const std::string& parent = {}) { + SessionMeta meta; + meta.id = id; + meta.cwd = "/tmp/x"; + meta.created_at = updated_at; + meta.updated_at = updated_at; + meta.message_count = 1; + meta.parent_session_id = parent; + SessionStorage::write_meta(SessionStorage::meta_path(project_dir, id), meta); + std::ofstream(SessionStorage::session_path(project_dir, id)) << ""; +} + +bool session_exists(const std::string& project_dir, const std::string& id) { + return fs::exists(SessionStorage::meta_path(project_dir, id)) || + fs::exists(SessionStorage::session_path(project_dir, id)); +} + +} // namespace + +// 场景: max_sessions=2,磁盘上有 3 个主会话(newest / middle / oldest),最新的 +// 主会话派过两个子会话(更新时间比所有主会话都新),最旧的主会话有一个子会话。 +// 期望: 只按主会话计名额 → 只清掉最旧的主会话,并连同它的子会话一起删除; +// 另外两个主会话和最新主会话的子会话全部保留。 +// 回归: 旧实现把子会话也计入名额,两个新子会话会把 newest 与 middle 两个主会话都挤掉, +// 而 oldest 的子会话却可能留下来成为孤儿。 +TEST(SessionCleanup, ChildrenDoNotUseQuotaAndDieWithTheirParent) { + const auto cwd = make_temp_cwd("cascade"); + const auto project_dir = SessionStorage::get_project_dir(cwd.string()); + fs::remove_all(project_dir); + fs::create_directories(project_dir); + + write_session(project_dir, "20261001-000005-newa", "2026-10-01T00:00:05Z"); + write_session(project_dir, "20261001-000004-mida", "2026-10-01T00:00:04Z"); + write_session(project_dir, "20261001-000001-olda", "2026-10-01T00:00:01Z"); + write_session(project_dir, "20261001-000006-kida", "2026-10-01T00:00:06Z", + "20261001-000005-newa"); + write_session(project_dir, "20261001-000007-kidb", "2026-10-01T00:00:07Z", + "20261001-000005-newa"); + write_session(project_dir, "20261001-000002-kidc", "2026-10-01T00:00:02Z", + "20261001-000001-olda"); + + SessionManager sm; + sm.start_session(cwd.string(), "test-provider", "test-model"); + sm.cleanup_old_sessions(2); + + EXPECT_TRUE(session_exists(project_dir, "20261001-000005-newa")); + EXPECT_TRUE(session_exists(project_dir, "20261001-000004-mida")) + << "子会话不占名额,不能把较新的主会话挤掉"; + EXPECT_TRUE(session_exists(project_dir, "20261001-000006-kida")); + EXPECT_TRUE(session_exists(project_dir, "20261001-000007-kidb")); + EXPECT_FALSE(session_exists(project_dir, "20261001-000001-olda")); + EXPECT_FALSE(session_exists(project_dir, "20261001-000002-kidc")) + << "被清理主会话的子会话要一起删除"; + + fs::remove_all(project_dir); + fs::remove_all(cwd); +} + +// 场景: max_sessions=1,磁盘上有 1 个主会话和 1 个更旧的孤儿子会话(它的父会话 +// 早已被删掉,例如旧版本只删了主会话)。 +// 期望: 孤儿按主会话计名额,超额后被清掉;唯一的主会话保留。 +// 回归: 若孤儿一律当子会话处理,它的父会话永远不会再出现在清理名单里, +// 孤儿记录就再也清不掉。 +TEST(SessionCleanup, OrphanChildrenCountAsTopLevel) { + const auto cwd = make_temp_cwd("orphan"); + const auto project_dir = SessionStorage::get_project_dir(cwd.string()); + fs::remove_all(project_dir); + fs::create_directories(project_dir); + + write_session(project_dir, "20261001-000009-tops", "2026-10-01T00:00:09Z"); + write_session(project_dir, "20261001-000003-orph", "2026-10-01T00:00:03Z", + "20261001-000000-gone"); + + SessionManager sm; + sm.start_session(cwd.string(), "test-provider", "test-model"); + sm.cleanup_old_sessions(1); + + EXPECT_TRUE(session_exists(project_dir, "20261001-000009-tops")); + EXPECT_FALSE(session_exists(project_dir, "20261001-000003-orph")) + << "父会话已不在的孤儿子会话要按主会话计名额并被清理"; + + fs::remove_all(project_dir); + fs::remove_all(cwd); +} diff --git a/tests/session_host/mesh_agent_service_test.cpp b/tests/session_host/mesh_agent_service_test.cpp index 1799d627..8be6f92f 100644 --- a/tests/session_host/mesh_agent_service_test.cpp +++ b/tests/session_host/mesh_agent_service_test.cpp @@ -15,6 +15,7 @@ #include "session/inter_agent_message.hpp" #include "session/mesh_tree_index.hpp" #include "session/session_manager.hpp" +#include "session/session_storage.hpp" #include "session_host/mesh/mesh_agent_service.hpp" #include "session_host/session_registry.hpp" #include "session_host/tools/mesh_agent_tools.hpp" @@ -599,6 +600,37 @@ TEST_F(MeshAgentServiceTest, RestartRebuildsTreeFromIndexAndRestoresOnDemand) { "answer:first")); } +// 场景:用户在后台任务面板把已完成的子 agent「归档」收起(卸载会话 + meta.archived=true), +// 之后根 agent 又对它发 followup_task。 +// 期望:子 agent 照常从磁盘恢复并带着原上下文工作,同时取消归档,让它重新出现在 +// 后台任务面板里;归档只是收起,不影响 agent 树的寻址。 +// 回归:若恢复时不取消归档,还在干活的 agent 在面板里永远看不到。 +TEST_F(MeshAgentServiceTest, ArchivedAgentIsUnarchivedWhenAddressedAgain) { + start_service(); + create_root(); + const auto worker = spawn(root_id, "worker", "first"); + ASSERT_EQ(worker.error, ""); + ASSERT_TRUE(wait_until([&] { return root_mail() == 1; })); + ASSERT_TRUE(wait_until([&] { return !loop(worker.session_id)->is_busy(); })); + + // 模拟面板「归档」:Web 端先卸载会话,再把落盘 meta 标成 archived。 + registry->destroy(worker.session_id); + const auto meta_path = acecode::SessionStorage::meta_path( + acecode::SessionStorage::get_project_dir(workspace), worker.session_id); + auto meta = acecode::SessionStorage::read_meta(meta_path); + ASSERT_EQ(meta.id, worker.session_id); + meta.archived = true; + ASSERT_TRUE(acecode::SessionStorage::write_meta(meta_path, meta)); + + EXPECT_EQ(service->deliver(ctx_for(root_id), "worker", "second", true), ""); + ASSERT_TRUE(wait_until([&] { return provider->payloads().back() == "second"; })); + EXPECT_NE(registry->acquire(worker.session_id), nullptr); + EXPECT_TRUE(any_content_contains(provider->request(provider->request_count() - 1), + "answer:first")); + EXPECT_FALSE(acecode::SessionStorage::read_meta(meta_path).archived) + << "再次被寻址的 agent 要取消归档,重新出现在后台任务面板里"; +} + // 场景:模型经工具调用整套协作工具(工具层的参数解析与输出格式)。 // 期望:输出逐字对齐 Codex —— spawn 回 {"task_name": 路径};send_message 成功输出为空; // wait 回 {"message","timed_out"} 且按最小值夹取并附说明;超出最大值报错; diff --git a/tests/tui/subagent_host_test.cpp b/tests/tui/subagent_host_test.cpp index 17bbefc8..1d55f7d8 100644 --- a/tests/tui/subagent_host_test.cpp +++ b/tests/tui/subagent_host_test.cpp @@ -4,7 +4,8 @@ // 右侧「运行中任务」快照 + /tasks 的 list/abort/clear 后端。一旦回归: // - publish 快照失灵 → 右侧栏不出现/不消失(wait=false 的任务无感知) // - busy=false 不移除 → 「只显示运行中」的用户决策被打破 -// - clear_settled 误删运行中任务 → 数据丢失 +// - clear_settled 误删运行中任务,或把已结束任务的记录永久删掉 → 数据丢失 +// (子会话与普通会话一样长期保存,clear 只归档,主会话永久删除时才一起删) // - permission_request 不冒泡 → default 模式子代理卡 5 分钟超时 // // 测试不真跑 LLM:EchoStreamProvider 立即完成 turn,让 busy 迁移与消息 @@ -148,8 +149,12 @@ TEST(SubagentHost, PublishesRunningTaskAndRemovesOnIdle) { } // 场景: /tasks list 合并「运行中(registry)+ 已结束(磁盘 parent 匹配)」; -// clear_settled 只删已结束的,不碰运行中。 -TEST(SubagentHost, ListMergesAndClearOnlyRemovesSettled) { +// 用户执行 /tasks clear。 +// 期望: clear_settled 只处理已结束的,不碰运行中;已结束任务只是归档 +// (meta.archived=true,从列表收起),jsonl 与 meta 都还在,再 clear 一次是 0。 +// 回归: 旧实现 clear 直接永久删除子会话记录,用户要求子会话与普通会话一样 +// 长期保存,只在主会话被永久删除时才随之删除。 +TEST(SubagentHost, ListMergesAndClearOnlyArchivesSettled) { HostFixture fx; const auto project_dir = acecode::SessionStorage::get_project_dir(fx.cwd.string()); fs::create_directories(project_dir); @@ -180,11 +185,23 @@ TEST(SubagentHost, ListMergesAndClearOnlyRemovesSettled) { EXPECT_FALSE(entries[1].running); EXPECT_EQ(entries[1].id, settled_id); - // clear 只删已结束;运行中任务与它的持久化数据不受影响。 + // clear 只归档已结束任务;运行中任务与它的持久化数据不受影响。 EXPECT_EQ(fx.host->clear_settled(project_dir), 1); - EXPECT_FALSE(fs::exists( - acecode::SessionStorage::meta_path(project_dir, settled_id))); + const auto archived = acecode::SessionStorage::read_meta( + acecode::SessionStorage::meta_path(project_dir, settled_id)); + ASSERT_EQ(archived.id, settled_id) << "归档不能删除子会话 meta"; + EXPECT_TRUE(archived.archived); + EXPECT_EQ(archived.parent_session_id, fx.parent_id) << "归档后仍归属原主会话"; + EXPECT_TRUE(fs::exists( + acecode::SessionStorage::session_path(project_dir, settled_id))) + << "归档不能删除子会话的对话记录"; EXPECT_NE(fx.host->registry().acquire(running_id), nullptr); + + // 归档后的任务从 /tasks list 收起,再 clear 也不会重复计数。 + entries = fx.host->list_tasks(project_dir); + ASSERT_EQ(entries.size(), 1u); + EXPECT_EQ(entries[0].id, running_id); + EXPECT_EQ(fx.host->clear_settled(project_dir), 0); fx.host->registry().destroy(running_id); } diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index 469c261f..92268a75 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -5935,8 +5935,9 @@ TEST(WebServerHttp, SubagentQueryReadsMetaFromSessionOwnWorkspace) { path_from_utf8(acecode::SessionStorage::get_project_dir(other_cwd)), cleanup_ec); } -// 场景: DELETE /api/sessions/:id?purge=1 是「后台任务-清除」——只允许子会话, -// 成功后磁盘 jsonl/meta 永久删除;对主会话 purge 必须 400 拒绝(防误删)。 +// 场景: DELETE /api/sessions/:id?purge=1 兼容路由(后台任务面板现在只归档, +// 不再调用它)——只允许子会话或已归档主会话,成功后磁盘 jsonl/meta 永久删除; +// 对未归档主会话 purge 必须 400 拒绝(防误删)。 TEST(WebServerHttp, PurgeDeletesSubagentDiskDataAndRejectsMainSession) { WebServerFixture fx; const std::string parent_id = "20260705-091000-cccc"; @@ -5975,6 +5976,90 @@ TEST(WebServerHttp, PurgeDeletesSubagentDiskDataAndRejectsMainSession) { acecode::SessionStorage::meta_path(fx.project_dir, parent_id)))); } +// 场景: 用户在后台任务面板点「归档」收起已结束的子会话(网状 agent / 后台任务), +// 之后把主会话归档并在设置里永久删除。 +// 期望: 归档的子会话从 ?parent= 列表消失,但磁盘记录保留,也不出现在 +// 「已归档会话」列表里(那里只列主会话);主会话永久删除时连同它的全部 +// 子会话(归档与否)一起删除,别的主会话及其子会话不受影响。 +// 回归: 旧面板「清除」直接永久删除子会话,用户要求子会话与普通会话一样 +// 长期保存;而只删主会话不删子会话又会留下永远清不掉的孤儿记录。 +TEST(WebServerHttp, PurgingArchivedMainSessionCascadesToItsSubagents) { + WebServerFixture fx; + const std::string hash = acecode::compute_cwd_hash(fx.cwd); + const std::string parent_id = "20261003-090000-aaaa"; + const std::string child_a = "20261003-090100-bbbb"; + const std::string child_b = "20261003-090200-cccc"; + const std::string other_parent = "20261003-090300-dddd"; + const std::string other_child = "20261003-090400-eeee"; + for (const auto& [id, parent] : + std::vector<std::pair<std::string, std::string>>{ + {parent_id, ""}, {child_a, parent_id}, {child_b, parent_id}, + {other_parent, ""}, {other_child, other_parent}}) { + acecode::SessionMeta meta; + meta.id = id; + meta.cwd = fx.cwd; + meta.parent_session_id = parent; + acecode::SessionStorage::write_meta( + acecode::SessionStorage::meta_path(fx.project_dir, id), meta); + write_text(path_from_utf8( + acecode::SessionStorage::session_path(fx.project_dir, id)), ""); + } + const auto exists = [&](const std::string& id) { + return std::filesystem::exists(path_from_utf8( + acecode::SessionStorage::meta_path(fx.project_dir, id))) && + std::filesystem::exists(path_from_utf8( + acecode::SessionStorage::session_path(fx.project_dir, id))); + }; + const auto ids_of = [](const cpr::Response& r) { + std::vector<std::string> ids; + for (const auto& item : json::parse(r.text)) { + ids.push_back(item.value("id", std::string{})); + } + return ids; + }; + const auto contains = [](const std::vector<std::string>& ids, const std::string& id) { + return std::find(ids.begin(), ids.end(), id) != ids.end(); + }; + + // 面板「归档」:子会话从后台任务列表收起,记录仍在。 + auto archive_child = cpr::Put(cpr::Url{ + fx.url("/api/workspaces/" + hash + "/sessions/" + child_a + "/archive")}); + ASSERT_EQ(archive_child.status_code, 200) << archive_child.text; + auto tasks = cpr::Get(cpr::Url{ + fx.url("/api/workspaces/" + hash + "/sessions?parent=" + parent_id)}); + ASSERT_EQ(tasks.status_code, 200) << tasks.text; + const auto task_ids = ids_of(tasks); + EXPECT_FALSE(contains(task_ids, child_a)) << "归档的子会话不应再出现在面板里"; + EXPECT_TRUE(contains(task_ids, child_b)); + EXPECT_TRUE(exists(child_a)) << "归档不能删除子会话记录"; + + // 主会话归档后,「已归档会话」列表只列主会话,不列子会话。 + auto archive_parent = cpr::Put(cpr::Url{ + fx.url("/api/workspaces/" + hash + "/sessions/" + parent_id + "/archive")}); + ASSERT_EQ(archive_parent.status_code, 200) << archive_parent.text; + auto archived = cpr::Get(cpr::Url{ + fx.url("/api/workspaces/" + hash + "/sessions?archived=1")}); + ASSERT_EQ(archived.status_code, 200) << archived.text; + const auto archived_ids = ids_of(archived); + EXPECT_TRUE(contains(archived_ids, parent_id)); + EXPECT_FALSE(contains(archived_ids, child_a)); + + // 永久删除主会话:它的子会话(归档与否)一起删除,别的会话原样保留。 + auto purge = cpr::Delete(cpr::Url{ + fx.url("/api/workspaces/" + hash + "/sessions/" + parent_id + "?purge=1")}); + EXPECT_EQ(purge.status_code, 204) << purge.text; + EXPECT_FALSE(std::filesystem::exists(path_from_utf8( + acecode::SessionStorage::meta_path(fx.project_dir, parent_id)))); + for (const auto& id : {child_a, child_b}) { + EXPECT_FALSE(std::filesystem::exists(path_from_utf8( + acecode::SessionStorage::meta_path(fx.project_dir, id)))) << id; + EXPECT_FALSE(std::filesystem::exists(path_from_utf8( + acecode::SessionStorage::session_path(fx.project_dir, id)))) << id; + } + EXPECT_TRUE(exists(other_parent)); + EXPECT_TRUE(exists(other_child)) << "别的主会话的子会话不能被连带删除"; +} + // 场景: GET /api/sessions/:id/messages 第一次(无 since)返回 {events:[], messages:[]}。 // 测试不真跑 LLM,所以两个数组都是空 — 但 wire format 必须正确(spec 9.6)。 TEST(WebServerHttp, GetMessagesReturnsEventsAndMessagesSchema) { diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 4fcbfbe6..02abe6f2 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -985,6 +985,11 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '批注 {{p0}}': 'Annotation {{p0}}', '{{p0}} · 原文已变化': '{{p0}} · Source text changed', '添加能力或上下文': 'Add capability or context', + // 后台任务面板:已结束子任务只归档、不再永久删除(子会话随主会话一起删除) + '归档中…': 'Archiving…', + '把已结束任务从面板收起;记录保留,主会话永久删除时一并删除': + 'Hide finished tasks from the panel; records are kept and deleted together with the main session', + '有 {{p0}} 个任务归档失败(可能仍在运行)': '{{p0}} task(s) could not be archived (they may still be running)', // 蜂群模式分星型 / 网状(openspec add-mesh-swarm-mode) '蜂群模式(星型)': 'Swarm mode (Star)', '蜂群模式(网状)': 'Swarm mode (Mesh)', diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index 59e4c221..f0d39a4e 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -689,8 +689,14 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses }, []); // 后台任务(spawn_subagent 子会话):数据 hook 常驻(运行中任务保持 WS // 订阅,权限/问题请求才能冒泡到主会话 UI);新调用开始时自动打开面板。 + // 子会话跟随父会话所在工作区:Desktop 的 daemon 同时服务多个工作区,子会话 + // 不在内存里时(Desktop 重启后、网状 agent 被换出)只能按这个工作区读盘。 + const subagentWorkspaceHash = isRealWorkspaceHash(ref?.workspaceHash || ref?.workspace_hash) + ? (ref?.workspaceHash || ref?.workspace_hash) + : ''; const subagentTasks = useSubagentTasks(sid, { onSpawnStart: openSubagentPanelForSpawn, + workspaceHash: subagentWorkspaceHash, }); // 当前视图可见的待答问题。提问挂起期间 composer dock 由提问框整体替换 // (方案 A),所以它只驱动渲染,不再参与 submit 的分支判定。 @@ -6166,11 +6172,12 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses focus={subagentFocus} onClose={() => setSubagentPanelOpen(false)} tasks={subagentTasks.tasks} + workspaceHash={subagentWorkspaceHash} onAbort={(task) => subagentTasks.abortTask(task.id)} onClearSettled={async () => { const result = await subagentTasks.clearSettled(); if (result?.failed > 0) { - toast({ kind: 'err', text: `有 ${result.failed} 个任务清除失败(可能仍在运行)` }); + toast({ kind: 'err', text: `有 ${result.failed} 个任务归档失败(可能仍在运行)` }); } }} /> diff --git a/web/src/components/SubagentPanel.jsx b/web/src/components/SubagentPanel.jsx index b22c3439..ff54f554 100644 --- a/web/src/components/SubagentPanel.jsx +++ b/web/src/components/SubagentPanel.jsx @@ -8,7 +8,8 @@ // // 两个视图: // - 列表:运行中 / 已完成 分组卡片。运行中卡片右上有中止(stop); -// 已完成组标题行有「清除」(purge 全部已结束任务,永久删除)。 +// 已完成组标题行有「归档」:把已结束任务从面板收起,记录仍保留在磁盘上, +// 主会话被永久删除时才随之删除(子会话与普通会话一样长期保存)。 // - transcript:点卡片「查看会话」原地切换,复用主会话的完整 transcript // 投影与渲染链路,仅通过能力开关保持只读。AskUserQuestion 工具行不显示—— // 子代理的提问/权限确认冒泡到主会话 UI 回答,这里只看执行过程。 @@ -100,12 +101,16 @@ function TaskCard({ task, nowMs, onAbort, onOpenTranscript }) { ); } -function SubagentTranscriptView({ task, messageAutoCollapse }) { +function SubagentTranscriptView({ task, workspaceHash = '', messageAutoCollapse }) { + // workspaceHash 让历史请求带上 ?workspace=:子会话不在 daemon 内存里时 + // (Desktop 重启后、网状 agent 被换出)daemon 才知道去哪个工作区读盘, + // 否则多工作区 daemon 只查自己的 cwd,返回 404 SESSION_NOT_FOUND。 const sessionRef = useMemo(() => ({ sessionId: task.id, + workspaceHash, busy: task.status === SUBAGENT_TASK_STATUS.RUNNING, title: taskDisplayTitle(task), - }), [task.id, task.status, task.title, task.summary]); + }), [task.id, task.status, task.title, task.summary, workspaceHash]); const transcript = useSessionTranscript(sessionRef, { live: 'auto' }); // 子会话记录里的附件 blob_url 本来就在子会话 id 下;loader 走 request() 带 token。 const attachmentApi = useMemo(() => createApi(sessionRef), [sessionRef]); @@ -270,7 +275,7 @@ function SubagentTranscriptView({ task, messageAutoCollapse }) { ); } -export function SubagentPanel({ open, width = DEFAULT_SUBAGENT_PANEL_WIDTH, focus, onClose, tasks, onAbort, onClearSettled, messageAutoCollapse = true }) { +export function SubagentPanel({ open, width = DEFAULT_SUBAGENT_PANEL_WIDTH, focus, onClose, tasks, workspaceHash = '', onAbort, onClearSettled, messageAutoCollapse = true }) { const [transcriptTaskId, setTranscriptTaskId] = useState(''); const [clearing, setClearing] = useState(false); @@ -353,7 +358,12 @@ export function SubagentPanel({ open, width = DEFAULT_SUBAGENT_PANEL_WIDTH, focu </div> {transcriptTask ? ( - <SubagentTranscriptView key={transcriptTask.id} task={transcriptTask} messageAutoCollapse={messageAutoCollapse} /> + <SubagentTranscriptView + key={transcriptTask.id} + task={transcriptTask} + workspaceHash={workspaceHash} + messageAutoCollapse={messageAutoCollapse} + /> ) : ( <div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3"> {tasks.length === 0 && ( @@ -390,9 +400,9 @@ export function SubagentPanel({ open, width = DEFAULT_SUBAGENT_PANEL_WIDTH, focu 'text-[11.5px] text-fg-mute transition hover:text-danger', clearing && 'opacity-50 cursor-default', )} - title="永久删除全部已结束任务(不影响主会话)" + title="把已结束任务从面板收起;记录保留,主会话永久删除时一并删除" > - {clearing ? '清除中…' : '清除'} + {clearing ? '归档中…' : '归档'} </button> </div> {groups.settled.map((task) => ( diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index acb401c6..5ad113eb 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -744,6 +744,7 @@ export const sourceCatalogs = { "s_4de2c4bd41b3582e": "全局 {{p0}}", "s_4e033adb8cd398ff": "复制附件名", "s_4e55dcd70d111b0c": "无法获取文件夹路径", + "s_4e6c70fe4c52f15d": "有 {{p0}} 个任务归档失败(可能仍在运行)", "s_4e6fd0e28c55860b": "停用", "s_4eafa9e925b30bcd": "自定义", "s_4eb2c65beb7f5731": "使用已下载版本", @@ -1131,6 +1132,7 @@ export const sourceCatalogs = { "s_77322b7c285eb8bd": "主题已删除", "s_7734b5d136de7bdb": "自动重启失败:", "s_77423bdd32d35ce3": "编辑刚刚发出的内容", + "s_7763947b4de0e389": "归档中…", "s_776dab5ded273567": "加载命令规则失败", "s_77d29a97cba5c38f": "手动模型 ID", "s_77d77f57b34df366": ",有效上下文 {{p0}}k", @@ -1666,7 +1668,6 @@ export const sourceCatalogs = { "s_af8043aec70ffeb2": "展开智能体", "s_af8f7e7c5bb42c09": "下一题 (Tab)", "s_af952ae82e16b8b1": "这些路径读写都拒绝;优先级高于白名单", - "s_afa546d0e611093d": "永久删除全部已结束任务(不影响主会话)", "s_afba84d2a5f90faf": "彻底删除选中的 {{p0}} 个会话?此操作不可撤销。", "s_afd4b783536b8297": "收起", "s_b02ea970d4d6f982": "继续发送排队的消息", @@ -1702,6 +1703,7 @@ export const sourceCatalogs = { "s_b49bc69cbbba7212": "已创建 {{p0}}", "s_b4c6c3eb0bce6b78": "本周", "s_b4f57abef650cc61": "没有匹配的模型。", + "s_b508ee8b2e9964c0": "把已结束任务从面板收起;记录保留,主会话永久删除时一并删除", "s_b52258f32c3bbaf7": "未派遣专家", "s_b52786ac0ce72cdb": "已选 {{p0}} / {{p1}}", "s_b52b7d39abcf20e5": "搜索当前对话内容", @@ -1923,14 +1925,12 @@ export const sourceCatalogs = { "s_cc91b14c8a02a155": "将 {{p0}} 设为默认模型", "s_cce894450069ba5b": "未知来源", "s_cd0832ff9b505231": "主题包已交给浏览器下载", - "s_cd23a528a4cd4463": "清除中…", "s_cd32a8c1d823d060": "{{p0}} · 原文已变化", "s_cd3b78eb00f7cd12": "已配置的钩子将显示在此处", "s_cd62bee012d94183": "页面组件意外停止,请重新加载。", "s_cd7395dfd00b67f2": "只放行写入 {{p0}}", "s_cda33af428d74680": "命中沙箱内放行规则", "s_cdae8daae433af05": "原生选择器不可用", - "s_cdb179f0114a4b9c": "有 {{p0}} 个任务清除失败(可能仍在运行)", "s_cde72ec561d498af": "保存失败:", "s_ce0c41c85d922168": "正在等待权限确认", "s_ce0f89592805f215": "没有匹配的专家", @@ -3169,6 +3169,7 @@ export const sourceCatalogs = { "s_4de2c4bd41b3582e": "Global {{p0}}", "s_4e033adb8cd398ff": "Copy attachment name", "s_4e55dcd70d111b0c": "Unable to get folder path", + "s_4e6c70fe4c52f15d": "{{p0}} task(s) could not be archived (they may still be running)", "s_4e6fd0e28c55860b": "deactivate", "s_4eafa9e925b30bcd": "Custom", "s_4eb2c65beb7f5731": "Use downloaded version", @@ -3556,6 +3557,7 @@ export const sourceCatalogs = { "s_77322b7c285eb8bd": "Theme deleted", "s_7734b5d136de7bdb": "Automatic restart failed:", "s_77423bdd32d35ce3": "Edit the message you just sent", + "s_7763947b4de0e389": "Archiving…", "s_776dab5ded273567": "Could not load command rules", "s_77d29a97cba5c38f": "Manual model ID", "s_77d77f57b34df366": ", effective context {{p0}} k", @@ -4091,7 +4093,6 @@ export const sourceCatalogs = { "s_af8043aec70ffeb2": "Expand the agent", "s_af8f7e7c5bb42c09": "Next (Tab)", "s_af952ae82e16b8b1": "Reads and writes to these paths are denied; overrides the allow lists", - "s_afa546d0e611093d": "Permanently delete all completed tasks (does not affect the main session)", "s_afba84d2a5f90faf": "Completely delete selected {{p0}} conversations? This action cannot be undone.", "s_afd4b783536b8297": "close", "s_b02ea970d4d6f982": "Resume sending the queued messages", @@ -4127,6 +4128,7 @@ export const sourceCatalogs = { "s_b49bc69cbbba7212": "Created {{p0}}", "s_b4c6c3eb0bce6b78": "this week", "s_b4f57abef650cc61": "No matching model.", + "s_b508ee8b2e9964c0": "Hide finished tasks from the panel; records are kept and deleted together with the main session", "s_b52258f32c3bbaf7": "No expert dispatched", "s_b52786ac0ce72cdb": "Selected {{p0}}/{{p1}}", "s_b52b7d39abcf20e5": "Search the current conversation", @@ -4348,14 +4350,12 @@ export const sourceCatalogs = { "s_cc91b14c8a02a155": "Set {{p0}} as the default model", "s_cce894450069ba5b": "unknown source", "s_cd0832ff9b505231": "Theme package sent to browser downloads", - "s_cd23a528a4cd4463": "Clearing…", "s_cd32a8c1d823d060": "{{p0}} · Source text changed", "s_cd3b78eb00f7cd12": "Configured hooks will appear here", "s_cd62bee012d94183": "The page component stopped unexpectedly. Reload it.", "s_cd7395dfd00b67f2": "Only allow writes to {{p0}}", "s_cda33af428d74680": "Matched a sandboxed allow rule", "s_cdae8daae433af05": "Native selector is not available", - "s_cdb179f0114a4b9c": "There are {{p0}} tasks that failed to clear (may still be running)", "s_cde72ec561d498af": "Save failed:", "s_ce0c41c85d922168": "Waiting for permission confirmation", "s_ce0f89592805f215": "No matching experts", diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 31871a29..dcafe10e 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -445,7 +445,8 @@ export function createApi(base = null) { setPinnedSessionOrder: (items=[]) => request('PUT', '/api/pinned-sessions/order', { items }, base), destroySession: (id) => request('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, base), - // 后台任务「清除」:销毁 + 永久删除磁盘数据。daemon 仅对子会话放行(400 拒主会话)。 + // 永久删除:单个子会话,或已归档的主会话(daemon 连同它的全部子会话一起删, + // 未归档主会话 400)。后台任务面板只归档,不调用它。 purgeSession: (id) => request('DELETE', `/api/sessions/${encodeURIComponent(id)}?purge=1`, undefined, base), getSessionDraft: (id, workspaceHash = '') => request('GET', sessionDraftPath(id, workspaceHash), undefined, base), diff --git a/web/src/lib/subagentPanelSplitArchitecture.test.js b/web/src/lib/subagentPanelSplitArchitecture.test.js index aafe956b..8f141044 100644 --- a/web/src/lib/subagentPanelSplitArchitecture.test.js +++ b/web/src/lib/subagentPanelSplitArchitecture.test.js @@ -70,13 +70,48 @@ run('live spawn_subagent tool_start opens the panel through the task hook', () = taskState, /eventSessionId === parentId &&\s*\(payload\.tool === 'spawn_subagent' \|\| payload\.tool === 'agent_spawn'\)/, ); - assert.match(taskHook, /useSubagentTasks\(parentSessionId, \{ onSpawnStart \} = \{\}\)/); + assert.match(taskHook, /useSubagentTasks\(parentSessionId, \{ onSpawnStart, workspaceHash = '' \} = \{\}\)/); assert.match(taskHook, /isSubagentSpawnStartEvent\(parentSessionId, msg\)/); assert.match(taskHook, /onSpawnStartRef\.current\?\.\(msg\)/); assert.match(chat, /openSubagentPanelForSpawn[\s\S]*setSubagentPanelOpen\(true\)/); - assert.match(chat, /useSubagentTasks\(sid, \{\s*onSpawnStart: openSubagentPanelForSpawn,\s*\}\)/); + assert.match(chat, /useSubagentTasks\(sid, \{\s*onSpawnStart: openSubagentPanelForSpawn,\s*workspaceHash: subagentWorkspaceHash,\s*\}\)/); assert.match(chat, /onClick=\{\(\) => setSubagentPanelOpen\(\(v\) => !v\)\}/); assert.match(chat, /onClose=\{\(\) => setSubagentPanelOpen\(false\)\}/); }); +// 场景:Desktop 的 daemon 同时服务多个工作区,子会话不在内存里(Desktop 重启后、 +// 网状 agent 被换出)时打开后台任务里的子会话。 +// 期望:子会话历史请求带上父会话所在工作区,daemon 才能去那个工作区读盘。 +// 回归:曾经只带 sessionId,多工作区 daemon 只查自己的 cwd,面板显示 +// 「加载失败:HTTP 404 SESSION_NOT_FOUND」,子会话内容看不到。 +run('subagent transcript reads the parent workspace so unloaded children still open', () => { + const panel = source('components/SubagentPanel.jsx'); + const chat = source('components/ChatView.jsx'); + const hook = source('lib/useSubagentTasks.js'); + + assert.match(panel, /function SubagentTranscriptView\(\{ task, workspaceHash = '', messageAutoCollapse \}\)/); + assert.match(panel, /sessionId: task\.id,\s*workspaceHash,/); + assert.match(panel, /workspaceHash=\{workspaceHash\}/); + assert.match(chat, /useSubagentTasks\(sid, \{\s*onSpawnStart: openSubagentPanelForSpawn,\s*workspaceHash: subagentWorkspaceHash,\s*\}\)/); + assert.match(chat, /workspaceHash=\{subagentWorkspaceHash\}/); + assert.match(hook, /useSubagentTasks\(parentSessionId, \{ onSpawnStart, workspaceHash = '' \} = \{\}\)/); +}); + +// 场景:用户点后台任务面板「已完成」组的按钮。 +// 期望:只归档(从面板收起),不再永久删除;子会话与普通会话一样长期保存, +// 主会话永久删除时由 daemon 级联删除。按钮文案随之改为「归档」。 +// 回归:旧按钮「清除」直接 purge,子会话记录一点就没了。 +run('settled subagent tasks are archived, never purged from the panel', () => { + const panel = source('components/SubagentPanel.jsx'); + const hook = source('lib/useSubagentTasks.js'); + + assert.match(hook, /api\.archiveWorkspaceSession\(workspaceHash, id\)/); + assert.match(hook, /: api\.archiveSession\(id\)\)/); + const clearStart = hook.indexOf('const clearSettled = useCallback'); + assert.ok(clearStart >= 0); + assert.doesNotMatch(hook, /purgeSession|purgeTask/); + assert.match(panel, /\{clearing \? '归档中…' : '归档'\}/); + assert.doesNotMatch(panel, /永久删除全部已结束任务/); +}); + console.log('subagentPanelSplitArchitecture.test.js: all tests passed'); diff --git a/web/src/lib/useSubagentTasks.js b/web/src/lib/useSubagentTasks.js index 5b02ccdf..f1cda3e6 100644 --- a/web/src/lib/useSubagentTasks.js +++ b/web/src/lib/useSubagentTasks.js @@ -7,7 +7,9 @@ // 3. 对运行中的子任务保持 WS 订阅(connection.retainSession)—— // **不依赖面板是否打开**:这是子会话 permission_request / question_request // 能到达 App 全局监听并冒泡到主会话 UI 的前提。已结束任务不订阅。 -// 4. 操作:中止(sendAbort + 本地标记)、清除(purge REST + 本地移除)。 +// 4. 操作:中止(sendAbort + 本地标记)、归档(archive REST + 本地移除)。 +// 子会话和普通会话一样长期保存:面板的「归档」只把已结束任务收起, +// 记录随父会话一起在永久删除时才清掉(daemon purge 级联)。 import { useCallback, useEffect, useRef, useState } from 'react'; import { api } from './api.js'; @@ -18,12 +20,11 @@ import { isSubagentSpawnStartEvent, markSubagentTaskAborted, mergeSubagentTaskList, - removeSubagentTask, runningSubagentCount, shouldRefreshSubagentTasksFromStatus, } from './subagentTasks.js'; -export function useSubagentTasks(parentSessionId, { onSpawnStart } = {}) { +export function useSubagentTasks(parentSessionId, { onSpawnStart, workspaceHash = '' } = {}) { const [tasks, setTasks] = useState([]); const retainedRef = useRef(new Set()); const parentRef = useRef(parentSessionId); @@ -123,15 +124,14 @@ export function useSubagentTasks(parentSessionId, { onSpawnStart } = {}) { setTasks((prev) => markSubagentTaskAborted(prev, id)); }, []); - const purgeTask = useCallback(async (id) => { - if (!id) return; - await api.purgeSession(id); - setTasks((prev) => removeSubagentTask(prev, id)); - }, []); - const clearSettled = useCallback(async () => { const settled = tasks.filter((t) => t.status !== SUBAGENT_TASK_STATUS.RUNNING); - const results = await Promise.allSettled(settled.map((t) => api.purgeSession(t.id))); + // 子会话跟随父会话所在工作区;多工作区 daemon 的兼容路由只认自己的 cwd, + // 知道工作区时走工作区路由,否则(无工作区父会话)走兼容路由。 + const archive = (id) => (workspaceHash + ? api.archiveWorkspaceSession(workspaceHash, id) + : api.archiveSession(id)); + const results = await Promise.allSettled(settled.map((t) => archive(t.id))); const removed = new Set( settled.filter((_, i) => results[i].status === 'fulfilled').map((t) => t.id)); if (removed.size > 0) { @@ -139,14 +139,13 @@ export function useSubagentTasks(parentSessionId, { onSpawnStart } = {}) { } const failed = results.filter((r) => r.status === 'rejected').length; return { removed: removed.size, failed }; - }, [tasks]); + }, [tasks, workspaceHash]); return { tasks, runningCount: runningSubagentCount(tasks), refresh, abortTask, - purgeTask, clearSettled, }; }