diff --git a/CLAUDE.md b/CLAUDE.md index 27e5cf6b..55b8b392 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 = 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. +**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 ]` is the operation entry (no `clear`: sub-agent sessions have no lifecycle of their own, see below). 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. **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「来自后台任务:」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 always follow their main session (user decision — analysis needs the full context):** there is no per-child archive or delete anywhere. The panel has no clear/archive button and TUI `/tasks` has no `clear`; the archive routes and `set_thread_archived` refuse to archive a child (409 / tool error; unarchive stays allowed to clear legacy flags); `?purge=1` and `delete_thread` refuse a child (409 / tool error); `?parent=` lists every child regardless of a legacy `archived` flag. Permanently deleting an archived main session cascades to all its children through `domain/session/session_tree_purge` (`session_tree_delete_order`: children first, main last — shared by the Web purge, the TUI settings center and `delete_thread`); any running child → 409. 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 0d21bc2e..4dbbd306 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -813,16 +813,18 @@ the user-message search index, then removes `<id>.jsonl`, the per-session 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. +have no lifecycle of their own: they cannot be archived or deleted separately +and live and die with their main session. Purging a main session first applies +the same cleanup to each of its child sessions (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 - `404` when the workspace or session does not exist +- `409 {"error":"subagent sessions are deleted together with their main session"}` + when the target is a sub-agent session; nothing is deleted - `409 {"error":"session must be archived before permanent deletion"}` when the target is not archived - `409` when the target is unexpectedly busy @@ -1036,8 +1038,8 @@ storage directly; they do not call the daemon HTTP API: | `wait_threads` | wait for up to eight targets across workspaces using event cursors | | `set_thread_title` | rename a thread | | `set_thread_pinned` | update the existing pinned-session state | -| `set_thread_archived` | archive or unarchive a thread | -| `delete_thread` | permanently delete a thread and all descendants | +| `set_thread_archived` | archive or unarchive a thread (sub-agent threads cannot be archived) | +| `delete_thread` | permanently delete a main thread and all descendants (sub-agent threads are refused) | | `repair_thread` | append a deterministic repair checkpoint to another thread | | `create_workspace` | register an existing absolute directory as a visible workspace | @@ -1087,17 +1089,18 @@ Destroys an active in-memory session: aborts the current turn, joins the worker 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 (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 +`DELETE /api/sessions/:id?purge=1` is the compatibility form of the workspace +purge above, used by the archived-session settings page, with the same +semantics: only an archived main session is accepted, and it is deleted +together with its child sessions. The background-task panel has no archive or +delete action; `?parent=` listings return every child regardless of a legacy +`archived` flag, and child sessions never appear in the `?archived=1` list. +Guard rails: + +- `409 {"error":"subagent sessions are deleted together with their main session"}` + for a sub-agent session +- `409 {"error":"session must be archived before permanent deletion"}` for a + non-archived main session - `409 {"error":"session is busy; abort it first"}` while the target is running a turn - `404` for a missing session, `500` for incomplete durable cleanup, and `503` @@ -1175,7 +1178,7 @@ Workspace-scoped and compatibility paths share the same behavior: | Method | Path shape | Body | Response | |---|---|---|---| -| PUT | `.../sessions/:id/archive` | ignored | updated `SessionSummary` | +| PUT | `.../sessions/:id/archive` | ignored | updated `SessionSummary`; `409` for a sub-agent session (children follow their main session) | | DELETE | `.../sessions/:id/archive` | none | updated `SessionSummary` | | PUT | `.../sessions/:id/title` | `{"title":"..."}` | updated `SessionSummary` | | GET | `.../sessions/:id/draft` | none | `{"session_id","id","text"}` | diff --git a/docs/help-source/group5.py b/docs/help-source/group5.py index 46dd1e24..54cd6732 100644 --- a/docs/help-source/group5.py +++ b/docs/help-source/group5.py @@ -24,8 +24,8 @@ '''<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>和 TUI 的 <code>/tasks clear</code> 只把已结束任务从列表中收起,不删除记录;网状模式下被收起的 Agent 再次收到消息时会自动恢复并重新出现在面板中。只有把主会话归档后再永久删除,它的全部子任务记录才会随之删除。</p>''', - code("/tasks\n/tasks list\n/tasks abort 子任务ID\n/tasks clear", "TUI · 管理后台任务"), + '''<p>产生子任务后,在主会话的<strong>后台任务</strong>面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。</p><p>来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。</p><p>可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。</p><p>子任务始终跟随主会话:已结束的任务一直留在面板里,随时可以打开查看,不能单独归档或删除。只有把主会话归档后再永久删除,它的全部子任务记录才会随之删除。</p>''', + code("/tasks\n/tasks list\n/tasks abort 子任务ID", "TUI · 管理后台任务"), figure("AD-04", "后台任务与来自子任务的请求", "展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。"), '''<p>全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。</p>''') ], ["web/src/components/InputBar.jsx", "web/src/components/ComposerSessionControls.jsx", "web/src/components/SubagentPanel.jsx", "src/apps/tui/commands/builtin_commands.cpp", "src/apps/tui/commands/swarm_mode_command.cpp", "src/host/session_host/mesh/mesh_agent_service.cpp", "docs/subagents.md"]), diff --git a/docs/help/assets/search-index.js b/docs/help/assets/search-index.js index bb213ebe..d163e715 100644 --- a/docs/help/assets/search-index.js +++ b/docs/help/assets/search-index.js @@ -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 clear 只把已结束任务从列表中收起,不删除记录;网状模式下被收起的 Agent 再次收到消息时会自动恢复并重新出现在面板中。只有把主会话归档后再永久删除,它的全部子任务记录才会随之删除。 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 /tasks list /tasks abort 子任务ID AD-04 图片占位 后台任务与来自子任务的请求 此处补充实际界面截图 16 : 9 展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。 全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。" }, { "page": "蜂群模式与子代理", @@ -1006,7 +1006,7 @@ window.ACECODE_HELP_INDEX = [ "title": "查看进度与处理操作确认", "url": "swarm.html#progress", "description": "查看进度与处理操作确认 产生子任务后,在主会话的 后台任务 面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。 来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务", - "text": "查看进度与处理操作确认 产生子任务后,在主会话的 后台任务 面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。 来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。 可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。 子任务的会话记录与普通会话一样长期保留。面板中的 归档 和 TUI 的 /tasks clear 只把已结束任务从列表中收起,不删除记录;网状模式下被收起的 Agent 再次收到消息时会自动恢复并重新出现在面板中。只有把主会话归档后再永久删除,它的全部子任务记录才会随之删除。 TUI · 管理后台任务 复制 /tasks /tasks list /tasks abort 子任务ID /tasks clear AD-04 图片占位 后台任务与来自子任务的请求 此处补充实际界面截图 16 : 9 展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。 全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。" + "text": "查看进度与处理操作确认 产生子任务后,在主会话的 后台任务 面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。 来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。 可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。 子任务始终跟随主会话:已结束的任务一直留在面板里,随时可以打开查看,不能单独归档或删除。只有把主会话归档后再永久删除,它的全部子任务记录才会随之删除。 TUI · 管理后台任务 复制 /tasks /tasks list /tasks abort 子任务ID AD-04 图片占位 后台任务与来自子任务的请求 此处补充实际界面截图 16 : 9 展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。 全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。" }, { "page": "定时与循环任务", diff --git a/docs/help/swarm.html b/docs/help/swarm.html index c8850796..83ae9491 100644 --- a/docs/help/swarm.html +++ b/docs/help/swarm.html @@ -44,11 +44,10 @@ <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><p>子任务的会话记录与普通会话一样长期保留。面板中的<strong>归档</strong>和 TUI 的 <code>/tasks clear</code> 只把已结束任务从列表中收起,不删除记录;网状模式下被收起的 Agent 再次收到消息时会自动恢复并重新出现在面板中。只有把主会话归档后再永久删除,它的全部子任务记录才会随之删除。</p> +<section aria-labelledby="progress"><h2 id="progress">查看进度与处理操作确认</h2><p>产生子任务后,在主会话的<strong>后台任务</strong>面板查看运行中和已完成的任务,打开任务可阅读其会话与工具结果。子任务归属于父会话,不会作为普通任务充满主侧边栏和全局搜索结果。</p><p>来自子任务的权限请求与问题会在主界面出现,并标注来源。检查任务名称、工具参数与目标路径后处理;不要仅因为主任务尚有输出,就忽略另一个子任务正在等待回答。</p><p>可以在面板中停止运行中的子任务。停止父任务也会向其子任务传播中断;已经产生的文件和记录会保留,不自动回滚。</p><p>子任务始终跟随主会话:已结束的任务一直留在面板里,随时可以打开查看,不能单独归档或删除。只有把主会话归档后再永久删除,它的全部子任务记录才会随之删除。</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 -/tasks clear</code></pre></div> +/tasks abort 子任务ID</code></pre></div> <figure class="screenshot" data-placeholder="AD-04"><div class="image-placeholder" role="img" aria-label="图片占位:后台任务与来自子任务的请求"><span class="placeholder-id">AD-04</span><div class="placeholder-content"><span class="placeholder-mark" aria-hidden="true"></span><span class="placeholder-kind">图片占位</span><strong>后台任务与来自子任务的请求</strong><span>此处补充实际界面截图</span></div><span class="placeholder-ratio" aria-hidden="true">16 : 9</span></div><figcaption>展示运行中与已完成分组、查看会话和停止入口,以及带来源名称的权限确认。</figcaption></figure> <p>全部子任务完成后,让主任务检查它们之间的冲突、统一差异并运行必要验证。各自报告“完成”不能代替整合后的构建或测试。</p></section> <nav class="page-navigation" aria-label="文章翻页"><a class="page-link previous" href="experts.html"><span>上一篇</span><strong><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="M20 12H5m6-6-6 6 6 6"/></svg>专家与专家团</strong></a><a class="page-link next" href="schedules.html"><span>下一篇</span><strong>定时与循环任务<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="M4 12h15m-6-6 6 6-6 6"/></svg></strong></a></nav> diff --git a/docs/subagents.md b/docs/subagents.md index b7144903..30ec7e31 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 路由用;主会话永久删除时对它的每个子会话各调一次) | +| `src/domain/session/session_storage.{hpp,cpp}` | `SessionMeta.parent_session_id`(空省略序列化);`purge_session_files(project_dir, id)` 删 jsonl + meta + `<id>/` 目录;`session_tree_purge.{hpp,cpp}` 的 `session_tree_delete_order` / `purge_session_tree` 给出「子会话在前、主会话最后」的删除顺序(Web 永久删除、TUI 设置中心、线程工具 `delete_thread` 共用) | | `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;实现在 `server_helpers.cpp::purge_session_data`,主会话连同子会话级联删除) | +| `src/apps/web/routes/routes_sessions.cpp` | `GET /api/sessions?parent=<id>`;`DELETE /api/sessions/:id?purge=1`(只收已归档主会话,连同子会话级联删除;子会话 / 未归档主会话 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,13 +97,15 @@ Multi-Agent V2,openspec `add-mesh-swarm-mode`)。 - `GET /api/sessions` 与 `GET /api/workspaces/:hash/sessions` 默认排除子会话; `?parent=<session_id>` 反查该父会话的子任务(后台任务面板数据源)。 - `SessionSummary` 增 `parent_session_id` 字段(普通会话为空串)。 -- 子会话与普通会话一样长期保存。面板「归档」= `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`),面板已不再调用。 +- **子会话始终跟随主会话(用户决策,事后分析要完整上下文)**:哪里都没有单独归档 / 删除子会话的入口。 + 归档路由对子会话回 `409 subagent sessions follow their main session and cannot be archived separately` + (取消归档仍允许,用来清掉旧版本留下的标记);`?purge=1` 对子会话回 + `409 subagent sessions are deleted together with their main session`;`?parent=` 列出全部子会话, + 不看 `archived` 标记;「已归档会话」列表只列主会话。 +- 永久删除(`DELETE …?purge=1`,两条路由同语义)只收已归档主会话,连同它的全部子会话(含还没落盘、 + 只在内存里的)一起删,子会话在前、主会话最后,中途失败时主会话仍在可重试;任一子会话运行中 → + `409 subagent session <id> is busy; abort it first`;未归档主会话 → + `409 session must be archived before permanent deletion`。 - `tool_end` payload 的 `metadata` 原样透传(含 `subagent_session_id`),这是前端即时发现 新子任务的通道之一。 @@ -126,8 +128,8 @@ session_status 帧(未知 busy 会话) → refetch ← wait=true `connection.retainSession`(不依赖面板开关)** —— 这是子会话的 permission_request / question_request 能到达 App 全局监听并冒泡的前提。parent 切换有迟到响应守卫。 - `web/src/components/SubagentPanel.jsx` — overlay(absolute 挂在 ChatView 消息区容器内, - 只覆盖聊天区,不动 Sidebar/SidePanel):运行中 / 已完成分组卡片;运行中卡片右上停止按钮; - 「归档」批量归档已结束任务(按父会话所在工作区走工作区路由);「查看会话」原地切到只读 + 只覆盖聊天区,不动 Sidebar/SidePanel):运行中 / 已完成分组卡片;运行中卡片右上停止按钮, + 已完成组没有归档 / 清除入口(子会话始终留在面板里);「查看会话」原地切到只读 transcript,历史请求带父会话的 `workspaceHash` —— Desktop 的 daemon 同时服务多个工作区,子会话 不在内存里(Desktop 重启后、网状 agent 被换出)时只能按它读盘,否则 404 SESSION_NOT_FOUND(复用主会话 Message/ToolBlock 紧凑渲染,`useSessionTranscript` live:'auto' 实时跟尾,AskUserQuestion @@ -162,9 +164,8 @@ SessionRegistry / LocalSessionClient 无 web 依赖 → TUI 进程直接实例 运行中**,用户决策);`SessionUpdated` → 标题更新;`PermissionRequest` → `Deps::on_permission_request` 冒泡。快照经 `Deps::publish_tasks` 交付 (main.cpp 写入 `TuiState.subagent_tasks` + PostEvent)。 -- `/tasks` 后端:`list_tasks`(运行中 registry + 磁盘 parent 匹配的已结束)、 - `abort_task`、`clear_settled`(归档:卸载 + 写 `meta.archived`,`list_tasks` 不再列出)、 - `respond_permission`。 +- `/tasks` 后端:`list_tasks`(运行中 registry + 磁盘 parent 匹配的全部已结束,不看归档标记)、 + `abort_task`、`respond_permission`。 - 不显式退订:dispatcher 生命周期 = SessionEntry;host 析构(main 栈)→ registry 析构 → 逐个 abort+join 子会话。 @@ -202,10 +203,11 @@ TuiState overlay,工具线程 wait ask_cv 天然带回结果)。只需两点:入 - `render_regular_sidebar` 的「Background Tasks」区块:bash 前台任务(原有)+ 子代理 运行中任务(`●` + 标题截断 + 耗时);anim_thread 在 `subagent_tasks` 非空时持续 tick(否则 `wait=false` 点火后主会话 idle,耗时不刷新)。 -- `/tasks [list|abort <id>|clear]`(builtin_commands.cpp,`CommandContext::subagent_host` - 仅斜杠 dispatch 路径注入):abort 支持 id 前缀唯一匹配;clear 与 Web「归档」同语义(只收起, - 记录保留)。TUI 退出时的 `cleanup_old_sessions` 只按主会话计 `max_sessions` 名额,被清理的主会话 - 连同子会话一起删;父会话已不在的孤儿子会话按主会话计,否则永远清不掉。 +- `/tasks [list|abort <id>]`(builtin_commands.cpp,`CommandContext::subagent_host` + 仅斜杠 dispatch 路径注入):abort 支持 id 前缀唯一匹配;没有 clear(子会话不能单独清除)。 + TUI 退出时的 `cleanup_old_sessions` 只按主会话计 `max_sessions` 名额,被清理的主会话连同子会话 + 一起删;父会话已不在的孤儿子会话按主会话计,否则永远清不掉。设置中心永久删除已归档主会话走 + `purge_session_tree`,同样连同子会话(并清搜索索引)。 ## 6. 测试地图 @@ -217,10 +219,12 @@ 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 不删盘;归档子会话离开面板但保留、主会话永久删除级联子会话(PurgingArchivedMainSessionCascadesToItsSubagents) | -| `tests/tui/subagent_host_test.cpp` | 快照发布/BusyChanged 移除;list 合并 + clear 只归档已结束;abort 路由 | +| `tests/web/web_server_smoke_test.cpp` | 列表隐藏 + ?parent= 反查;子会话不能单独 purge / 归档(PurgeRejectsSubagentAndUnarchivedMainSession)+ 普通 DELETE 不删盘;?parent= 不看归档标记、主会话永久删除级联子会话(PurgingArchivedMainSessionCascadesToItsSubagents) | +| `tests/tui/subagent_host_test.cpp` | 快照发布/BusyChanged 移除;list 合并运行中与全部已结束;abort 路由 | | `tests/session/session_cleanup_test.cpp` | 退出清理只按主会话计名额、级联删子会话、孤儿子会话按主会话计 | -| `web/src/lib/subagentPanelSplitArchitecture.test.js` | 子会话 transcript 带父会话工作区;面板只归档不 purge | +| `tests/session/session_tree_purge_test.cpp` | 删除顺序(子在前、主会话最后、不碰别的树)、连同子会话删数据与搜索索引 | +| `tests/session_host/thread_tools_test.cpp` | `delete_thread` / `set_thread_archived` 拒绝子会话(含子会话删自己),删主会话级联 | +| `web/src/lib/subagentPanelSplitArchitecture.test.js` | 子会话 transcript 带父会话工作区;面板没有归档 / 清除 / 删除入口 | | `web/src/lib/subagentTasks.test.js` | 归一化/合并/事件增量/aborted 保持/分组/格式化(13 例) | 统一模式:EchoStreamProvider stub 让子会话 turn 真实完成(消息落盘、busy 迁移),不打真实 LLM。 @@ -313,7 +317,6 @@ 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`)对新建 / 恢复的 子会话重新订阅事件。 @@ -337,6 +340,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 2a87a21c..9737d1dd 100644 --- a/src/apps/tui/commands/builtin_commands.cpp +++ b/src/apps/tui/commands/builtin_commands.cpp @@ -1952,8 +1952,8 @@ static void cmd_tasks(CommandContext& ctx, const std::string& args) { << e.id; if (!e.title.empty()) oss << " " << e.title; } - oss << "\n\nUse /tasks abort <id> to stop a running task, " - "/tasks clear to archive settled ones (kept until the main session is deleted)."; + oss << "\n\nUse /tasks abort <id> to stop a running task. Subagent tasks " + "stay with this session and are deleted only together with it."; push_system(oss.str()); return; } @@ -1982,15 +1982,7 @@ static void cmd_tasks(CommandContext& ctx, const std::string& args) { push_system("Abort requested for subagent task " + id + "."); return; } - if (sub == "clear") { - const int removed = ctx.subagent_host->clear_settled(project_dir); - push_system(removed > 0 - ? "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; - } - push_system("Usage: /tasks [list|abort <id>|clear]"); + push_system("Usage: /tasks [list|abort <id>]"); } void register_builtin_commands(CommandRegistry& registry) { @@ -2066,7 +2058,7 @@ void register_builtin_commands(CommandRegistry& registry) { register_remote_control_command(registry); register_desktop_command(registry); registry.register_command({"feedback", "Upload current session and runtime logs to the configured upgrade service", cmd_feedback}); - registry.register_command({"tasks", "List, abort, or clear subagent background tasks", cmd_tasks}); + registry.register_command({"tasks", "List or abort subagent background tasks", cmd_tasks}); registry.register_command({"title", "Set or show the window title for this session", cmd_title}); registry.register_command({"page-step", "Toggle single-line PgUp/PgDn scrolling (for terminals that swallow Alt+Arrow)", cmd_page_step}); registry.register_command({"theme", "Switch TUI color theme (dark/light/auto)", cmd_theme}); diff --git a/src/apps/tui/settings/settings_center.cpp b/src/apps/tui/settings/settings_center.cpp index f2fd19a9..93c2fbb9 100644 --- a/src/apps/tui/settings/settings_center.cpp +++ b/src/apps/tui/settings/settings_center.cpp @@ -13,6 +13,7 @@ #include "network/proxy_resolver.hpp" #include "provider/auth/github_auth.hpp" #include "session/session_storage.hpp" +#include "session/session_tree_purge.hpp" #include "session/session_usage_ledger.hpp" #include "platform/clipboard.hpp" #include "utils/utf8_path.hpp" @@ -1427,7 +1428,7 @@ struct SettingsCenter::Impl { continue; } std::string error; - if (SessionStorage::purge_session_files( + if (purge_session_tree( row.project_dir, row.meta.id, &error)) { ++purged; archived_selected_ids.erase(archived_row_key(row)); diff --git a/src/apps/tui/subagent_host.cpp b/src/apps/tui/subagent_host.cpp index 442a609d..c12ad4ea 100644 --- a/src/apps/tui/subagent_host.cpp +++ b/src/apps/tui/subagent_host.cpp @@ -137,8 +137,8 @@ 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; @@ -160,48 +160,6 @@ bool SubagentHost::abort_task(const std::string& id) { return true; } -int SubagentHost::clear_settled(const std::string& project_dir) { - const std::string parent = - parent_session_id_ ? parent_session_id_() : std::string{}; - if (parent.empty() || project_dir.empty()) return 0; - std::vector<std::string> running_ids; - { - std::lock_guard<std::mutex> lk(mu_); - for (const auto& t : running_) running_ids.push_back(t.id); - } - // 子会话与普通会话一样长期保存:clear 只归档(从列表收起并卸载), - // 磁盘记录在主会话被永久删除时才随之删除(Web 面板「归档」同语义)。 - int removed = 0; - 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; - } - ScopedSubscription subscription; - { - std::lock_guard<std::mutex> lock(mu_); - const auto found = subscriptions_.find(meta.id); - if (found != subscriptions_.end()) { - subscription = std::move(found->second); - subscriptions_.erase(found); - } - } - subscription.reset(); - 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] archived settled task " + meta.id); - } - return removed; -} - void SubagentHost::respond_permission(const std::string& session_id, const std::string& request_id, const std::string& choice) { diff --git a/src/apps/tui/subagent_host.hpp b/src/apps/tui/subagent_host.hpp index e51d74c9..f89ae84a 100644 --- a/src/apps/tui/subagent_host.hpp +++ b/src/apps/tui/subagent_host.hpp @@ -90,9 +90,6 @@ class SubagentHost { // 中止运行中的子会话(loop abort;不销毁)。返回 false = 未找到。 bool abort_task(const std::string& id); - // 永久删除全部已结束的子任务(destroy + 删磁盘)。返回删除数。 - int clear_settled(const std::string& project_dir); - // 把 confirm overlay 的用户选择路由回子会话。choice 取 // "allow" / "allow_session" / "deny"。 void respond_permission(const std::string& session_id, diff --git a/src/apps/web/routes/routes_sessions.cpp b/src/apps/web/routes/routes_sessions.cpp index 8f24df5d..91d284e7 100644 --- a/src/apps/web/routes/routes_sessions.cpp +++ b/src/apps/web/routes/routes_sessions.cpp @@ -1331,8 +1331,7 @@ void WebServer::Impl::register_sessions() { deps.session_client->destroy_session(id); return with_cors(req, crow::response(204)); } - return purge_session_data( - req, compatibility_workspace(), id, /*require_archived=*/false); + return purge_session_data(req, compatibility_workspace(), id); }); // GET /api/sessions/:id/messages?since=N: 拉历史 + 缓存事件。spec 9.6 diff --git a/src/apps/web/routes/routes_workspaces.cpp b/src/apps/web/routes/routes_workspaces.cpp index b3a725b7..557af89f 100644 --- a/src/apps/web/routes/routes_workspaces.cpp +++ b/src/apps/web/routes/routes_workspaces.cpp @@ -1052,7 +1052,7 @@ void WebServer::Impl::register_workspaces() { r.add_header("Content-Type", "application/json"); return with_cors(req, std::move(r)); } - return purge_session_data(req, *ws, id, /*require_archived=*/true); + return purge_session_data(req, *ws, id); }); CROW_ROUTE(app, "/api/workspaces/<string>/sessions/<string>/archive").methods(crow::HTTPMethod::PUT) diff --git a/src/apps/web/server_helpers.cpp b/src/apps/web/server_helpers.cpp index 9244e75d..0067109a 100644 --- a/src/apps/web/server_helpers.cpp +++ b/src/apps/web/server_helpers.cpp @@ -13,6 +13,7 @@ #include "session/session_user_message_search.hpp" #include "session/composer_content.hpp" #include "session/session_title_text.hpp" +#include "session/session_tree_purge.hpp" #include "utils/encoding.hpp" namespace acecode::web { @@ -1203,7 +1204,7 @@ json WebServer::Impl::sessions_for_workspace(const acecode::desktop::WorkspaceMe if (!disk_meta.id.empty()) meta = std::move(disk_meta); } const bool archived = meta ? meta->archived : false; - if (archived != archived_only) continue; + if (parent_filter.empty() && archived != archived_only) continue; push_row(s.id, session_info_to_json(s, meta ? &*meta : nullptr)); } const std::size_t active_rows = rows.size(); @@ -1211,7 +1212,7 @@ json WebServer::Impl::sessions_for_workspace(const acecode::desktop::WorkspaceMe const auto accept_disk = [&](const SessionMeta& m) { if (seen.count(m.id)) return false; if (parent_mismatch(m.parent_session_id)) return false; - if (m.archived != archived_only) return false; + if (parent_filter.empty() && m.archived != archived_only) return false; if (m.no_workspace && !include_no_workspace) return false; return true; }; @@ -1374,6 +1375,13 @@ crow::response WebServer::Impl::set_session_archive_state( } SessionMeta meta = *maybe_meta; + if (archived && !meta.parent_session_id.empty()) { + // 子会话跟随主会话,没有单独归档(用户决策,见 session_tree_purge.hpp)。 + crow::response r(409); + r.body = R"({"error":"subagent sessions follow their main session and cannot be archived separately"})"; + r.add_header("Content-Type", "application/json"); + return with_cors(req, std::move(r)); + } if (archived && deps.session_client) { deps.session_client->destroy_session(id); const auto reread = find_session_meta_for_workspace(ws, id); @@ -1404,8 +1412,7 @@ crow::response WebServer::Impl::set_session_archive_state( crow::response WebServer::Impl::purge_session_data( const crow::request& req, const acecode::desktop::WorkspaceMeta& ws, - const std::string& id, - bool require_archived) { + const std::string& id) { const auto error_response = [this, &req](int status, const std::string& message) { crow::response r(status); r.body = json{{"error", message}}.dump(); @@ -1432,14 +1439,11 @@ crow::response WebServer::Impl::purge_session_data( return error_response(404, "session not found"); } const SessionMeta meta = *maybe_meta; - if (require_archived && !meta.archived) { - return error_response(409, "session must be archived before permanent deletion"); + if (!meta.parent_session_id.empty()) { + return error_response(409, "subagent sessions are deleted together with their main session"); } - if (!require_archived && !meta.archived && meta.parent_session_id.empty()) { - // Preserve the existing compatibility-route error for active main - // sessions while allowing archived main sessions through the same - // explicit ?purge=1 path. - return error_response(400, "only subagent sessions can be purged"); + if (!meta.archived) { + return error_response(409, "session must be archived before permanent deletion"); } if (deps.session_registry) { @@ -1460,32 +1464,25 @@ crow::response WebServer::Impl::purge_session_data( : (meta.cwd.empty() ? ws.cwd : meta.cwd)); const auto project_dir = SessionStorage::get_project_dir(storage_cwd); - // 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); - } + // Child sessions (spawn_subagent tasks, every mesh agent of the tree) have + // no lifecycle of their own: they go together with their main session. + // Children first, the main session last, so a failure keeps it retryable. + std::vector<std::string> doomed = session_tree_delete_order(project_dir, 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.insert(doomed.end() - 1, 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"); - } + } + for (const auto& child_id : doomed) { + if (child_id == id || !deps.session_registry) continue; + 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); SessionUserMessageIndex search_index(project_dir); for (const auto& target : doomed) { @@ -1515,10 +1512,8 @@ crow::response WebServer::Impl::purge_session_data( global_session_search->invalidate_project(ws.hash); } - LOG_INFO("[web] permanently deleted session " + id + - (meta.parent_session_id.empty() - ? " (archived, " + std::to_string(doomed.size() - 1) + " subagent session(s))" - : " (parent=" + meta.parent_session_id + ")")); + LOG_INFO("[web] permanently deleted archived session " + id + " with " + + std::to_string(doomed.size() - 1) + " subagent session(s)"); return with_cors(req, crow::response(204)); } diff --git a/src/apps/web/server_impl.hpp b/src/apps/web/server_impl.hpp index c6fbde8a..2f940870 100644 --- a/src/apps/web/server_impl.hpp +++ b/src/apps/web/server_impl.hpp @@ -475,10 +475,10 @@ struct WebServer::Impl { const acecode::desktop::WorkspaceMeta& ws, const std::string& id, bool archived); + // 永久删除已归档主会话,连同它的全部子会话;子会话不能单独删(409)。 crow::response purge_session_data(const crow::request& req, const acecode::desktop::WorkspaceMeta& ws, - const std::string& id, - bool require_archived); + const std::string& id); crow::response session_input_draft_response(const crow::request& req, const std::string& id, const std::string& text, diff --git a/src/domain/session/session_purge_listeners.hpp b/src/domain/session/session_purge_listeners.hpp index a1e78743..190aea5e 100644 --- a/src/domain/session/session_purge_listeners.hpp +++ b/src/domain/session/session_purge_listeners.hpp @@ -7,7 +7,7 @@ namespace acecode { // 会话被永久删除(SessionStorage::purge_session_files 成功)后的进程内通知。 -// 所有「清除」入口(REST ?purge=1、TUI /tasks clear、设置页清理、线程服务) +// 所有「清除」入口(REST ?purge=1、设置页清理、线程服务) // 最终都走 purge_session_files,所以记忆摘要的「遗忘」只需挂在这一处。 // 回调在 purge 的调用线程上同步执行,不得抛异常,也不应长时间阻塞。 using SessionPurgeListener = diff --git a/src/domain/session/session_tree_purge.cpp b/src/domain/session/session_tree_purge.cpp new file mode 100644 index 00000000..72419755 --- /dev/null +++ b/src/domain/session/session_tree_purge.cpp @@ -0,0 +1,48 @@ +#include "session_tree_purge.hpp" + +#include "session_storage.hpp" +#include "session_user_message_search.hpp" + +#include <functional> +#include <unordered_map> +#include <unordered_set> + +namespace acecode { + +std::vector<std::string> session_tree_delete_order(const std::string& project_dir, + const std::string& root_id) { + std::unordered_multimap<std::string, std::string> children; + for (const auto& meta : SessionStorage::list_session_metadata(project_dir)) { + if (!meta.parent_session_id.empty()) { + children.emplace(meta.parent_session_id, meta.id); + } + } + std::vector<std::string> order; + std::unordered_set<std::string> visited; + std::function<void(const std::string&)> collect = [&](const std::string& id) { + if (!visited.insert(id).second) return; + const auto range = children.equal_range(id); + for (auto it = range.first; it != range.second; ++it) collect(it->second); + order.push_back(id); + }; + collect(root_id); + return order; +} + +bool purge_session_tree(const std::string& project_dir, + const std::string& root_id, + std::string* error) { + if (error) error->clear(); + SessionUserMessageIndex search_index(project_dir); + for (const auto& id : session_tree_delete_order(project_dir, root_id)) { + std::string step_error; + if (!search_index.remove_session(id, &step_error) || + !SessionStorage::purge_session_files(project_dir, id, &step_error)) { + if (error) *error = id + ": " + step_error; + return false; + } + } + return true; +} + +} // namespace acecode diff --git a/src/domain/session/session_tree_purge.hpp b/src/domain/session/session_tree_purge.hpp new file mode 100644 index 00000000..6a76c411 --- /dev/null +++ b/src/domain/session/session_tree_purge.hpp @@ -0,0 +1,23 @@ +#pragma once + +// 子会话(spawn_subagent 后台任务、网状 agent)没有独立的生命周期:不能单独 +// 归档或删除,只随主会话一起永久删除,否则事后分析会缺上下文(用户决策)。 +// Web 永久删除、TUI 设置中心与线程工具都按这里的顺序删。 + +#include <string> +#include <vector> + +namespace acecode { + +// root_id 的全部后代会话(按 parent_session_id 递归)在前、root_id 本身在最后。 +// 只读磁盘 meta;还没落盘、只在内存里的子会话由调用方自己补。 +std::vector<std::string> session_tree_delete_order(const std::string& project_dir, + const std::string& root_id); + +// 按 session_tree_delete_order 逐个删除用户消息搜索索引与磁盘数据(jsonl、meta、 +// <id>/ 目录)。中途失败即停并返回 false:主会话最后删,失败时仍可重试。 +bool purge_session_tree(const std::string& project_dir, + const std::string& root_id, + std::string* error = nullptr); + +} // namespace acecode diff --git a/src/host/session_host/mesh/mesh_agent_service.cpp b/src/host/session_host/mesh/mesh_agent_service.cpp index e59b65b2..db0ad524 100644 --- a/src/host/session_host/mesh/mesh_agent_service.cpp +++ b/src/host/session_host/mesh/mesh_agent_service.cpp @@ -416,10 +416,6 @@ 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/src/host/session_host/thread_service.cpp b/src/host/session_host/thread_service.cpp index d53d3a46..686218be 100644 --- a/src/host/session_host/thread_service.cpp +++ b/src/host/session_host/thread_service.cpp @@ -8,6 +8,7 @@ #include "session/session_pin_store.hpp" #include "session_registry.hpp" #include "session/session_storage.hpp" +#include "session/session_tree_purge.hpp" #include "session/session_user_message_search.hpp" #include "session/thread_repair.hpp" #include "llm/message_predicates.hpp" @@ -942,6 +943,11 @@ ThreadServiceResult ThreadService::set_archived( return ThreadServiceResult::fail( "thread is unavailable in the current workspace"); } + if (archived && !scoped->meta.parent_session_id.empty()) { + // 子会话跟随主会话,没有单独归档(见 session_tree_purge.hpp)。 + return ThreadServiceResult::fail( + "subagent threads follow their main thread and cannot be archived separately"); + } if (auto* manager = scoped->manager()) { manager->set_session_archived(archived); } else { @@ -975,32 +981,6 @@ ThreadServiceResult ThreadService::set_archived( namespace { -std::vector<std::string> child_first_delete_order( - const std::string& project_dir, - const std::string& root_thread_id) { - const auto metas = SessionStorage::list_sessions(project_dir); - std::unordered_multimap<std::string, std::string> children; - for (const auto& meta : metas) { - if (!meta.parent_session_id.empty()) { - children.emplace(meta.parent_session_id, meta.id); - } - } - - std::vector<std::string> delete_order; - std::unordered_set<std::string> visited; - std::function<void(const std::string&)> collect = - [&](const std::string& id) { - if (!visited.insert(id).second) return; - const auto range = children.equal_range(id); - for (auto it = range.first; it != range.second; ++it) { - collect(it->second); - } - delete_order.push_back(id); - }; - collect(root_thread_id); - return delete_order; -} - json deleted_ids_json(const std::vector<std::string>& delete_order) { json deleted = json::array(); for (const auto& id : delete_order) deleted.push_back(id); @@ -1069,9 +1049,14 @@ ThreadServiceResult ThreadService::delete_thread( return ThreadServiceResult::fail( "thread is unavailable in the current workspace"); } + if (!target->meta.parent_session_id.empty()) { + return ThreadServiceResult::fail( + "subagent threads are deleted together with their main thread; " + "delete the main thread instead"); + } const std::string project_dir = target->project_dir; - const auto delete_order = child_first_delete_order(project_dir, thread_id); + const auto delete_order = session_tree_delete_order(project_dir, thread_id); const bool contains_caller = !scope.caller_thread_id.empty() && std::find(delete_order.begin(), delete_order.end(), diff --git a/tests/session/session_tree_purge_test.cpp b/tests/session/session_tree_purge_test.cpp new file mode 100644 index 00000000..376465a2 --- /dev/null +++ b/tests/session/session_tree_purge_test.cpp @@ -0,0 +1,133 @@ +// 覆盖 src/domain/session/session_tree_purge.{hpp,cpp}:主会话连同全部子会话一起 +// 永久删除的公共实现(Web 永久删除、TUI 设置中心、线程工具共用)。 +// +// 子会话(后台任务、网状 agent)没有独立的生命周期:不能单独归档 / 删除,只随 +// 主会话一起删(用户决策:事后分析要完整上下文)。一旦回归: +// - 删除顺序漏掉子会话 → 主会话没了,子会话变成永远清不掉的孤儿记录 +// - 主会话先删 → 中途失败时设置页那一行已经消失,剩下的子会话无从重试 +// - 连带删到别的主会话的子会话 → 别的会话丢上下文 + +#include <gtest/gtest.h> + +#include "llm/llm_provider.hpp" +#include "session/session_storage.hpp" +#include "session/session_tree_purge.hpp" +#include "session/session_user_message_search.hpp" + +#include <algorithm> +#include <filesystem> +#include <random> +#include <string> +#include <vector> + +namespace fs = std::filesystem; + +using acecode::SessionMeta; +using acecode::SessionStorage; + +namespace { + +fs::path make_project_dir(const std::string& hint) { + auto dir = fs::temp_directory_path() / + ("acecode_session_tree_purge_" + hint + "_" + + std::to_string(std::random_device{}())); + fs::remove_all(dir); + fs::create_directories(dir); + return dir; +} + +// 手造一条落盘会话:meta + 一条用户消息(正文用来验证搜索索引被清掉)。 +void write_session(const std::string& project_dir, + const std::string& id, + const std::string& parent, + const std::string& user_text) { + SessionMeta meta; + meta.id = id; + meta.cwd = "/tmp/x"; + meta.created_at = "2026-10-03T00:00:00Z"; + meta.updated_at = meta.created_at; + meta.parent_session_id = parent; + ASSERT_TRUE(SessionStorage::write_meta(SessionStorage::meta_path(project_dir, id), meta)); + acecode::ChatMessage message; + message.role = "user"; + message.content = user_text; + SessionStorage::write_messages(SessionStorage::session_path(project_dir, id), {message}); +} + +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)); +} + +std::size_t index_of(const std::vector<std::string>& order, const std::string& id) { + return static_cast<std::size_t>(std::find(order.begin(), order.end(), id) - order.begin()); +} + +} // namespace + +// 场景: 主会话 root 有两个子会话 a、b,a 下还挂着 a1(层级更深的子会话);另有一个 +// 无关的主会话 other 带着自己的子会话。 +// 期望: root 的删除顺序恰好包含 root 树里的 4 个会话,每个子会话排在它的上级之前, +// root 排最后;不含 other 树里的任何会话。没有子会话的会话只删它自己。 +TEST(SessionTreePurge, DeleteOrderPutsDescendantsBeforeTheirMainSession) { + const auto dir = make_project_dir("order"); + const std::string project_dir = dir.string(); + write_session(project_dir, "20261003-000001-root", "", "root"); + write_session(project_dir, "20261003-000002-kida", "20261003-000001-root", "a"); + write_session(project_dir, "20261003-000003-kidb", "20261003-000001-root", "b"); + write_session(project_dir, "20261003-000004-deep", "20261003-000002-kida", "a1"); + write_session(project_dir, "20261003-000005-othr", "", "other"); + write_session(project_dir, "20261003-000006-okid", "20261003-000005-othr", "other kid"); + + const auto order = acecode::session_tree_delete_order(project_dir, "20261003-000001-root"); + ASSERT_EQ(order.size(), 4u); + EXPECT_EQ(order.back(), "20261003-000001-root"); + EXPECT_LT(index_of(order, "20261003-000004-deep"), index_of(order, "20261003-000002-kida")) + << "更深的子会话要排在它的上级之前"; + EXPECT_LT(index_of(order, "20261003-000003-kidb"), order.size()); + EXPECT_EQ(index_of(order, "20261003-000005-othr"), order.size()); + EXPECT_EQ(index_of(order, "20261003-000006-okid"), order.size()); + + EXPECT_EQ(acecode::session_tree_delete_order(project_dir, "20261003-000006-okid"), + std::vector<std::string>{"20261003-000006-okid"}); + std::error_code ec; + fs::remove_all(dir, ec); +} + +// 场景: 永久删除主会话 root(带一个子会话),同目录还有一个无关的主会话。 +// 期望: root 与它的子会话的 jsonl / meta 全部删除,搜索索引里也查不到它们的用户 +// 消息;无关会话的数据与索引原样保留。 +// 回归: TUI 设置中心曾只删主会话本身(子会话成孤儿),也没清搜索索引。 +TEST(SessionTreePurge, PurgesMainSessionWithItsSubagentsAndSearchIndex) { + const auto dir = make_project_dir("purge"); + const std::string project_dir = dir.string(); + write_session(project_dir, "20261003-000001-root", "", "root-needle"); + write_session(project_dir, "20261003-000002-kida", "20261003-000001-root", "child-needle"); + write_session(project_dir, "20261003-000005-othr", "", "other-needle"); + { + acecode::SessionUserMessageIndex index(project_dir); + std::string error; + for (const char* id : {"20261003-000001-root", "20261003-000002-kida", + "20261003-000005-othr"}) { + ASSERT_TRUE(index.rebuild_session( + id, SessionStorage::session_path(project_dir, id), &error)) << error; + } + } + + std::string error; + ASSERT_TRUE(acecode::purge_session_tree(project_dir, "20261003-000001-root", &error)) + << error; + EXPECT_FALSE(session_exists(project_dir, "20261003-000001-root")); + EXPECT_FALSE(session_exists(project_dir, "20261003-000002-kida")) + << "子会话要随主会话一起删除"; + EXPECT_TRUE(session_exists(project_dir, "20261003-000005-othr")); + + { + acecode::SessionUserMessageIndex index(project_dir); + EXPECT_TRUE(index.search("root-needle", 10, &error).empty()) << error; + EXPECT_TRUE(index.search("child-needle", 10, &error).empty()) << error; + EXPECT_EQ(index.search("other-needle", 10, &error).size(), 1u) << error; + } + std::error_code ec; + fs::remove_all(dir, ec); +} diff --git a/tests/session_host/mesh_agent_service_test.cpp b/tests/session_host/mesh_agent_service_test.cpp index 8be6f92f..1799d627 100644 --- a/tests/session_host/mesh_agent_service_test.cpp +++ b/tests/session_host/mesh_agent_service_test.cpp @@ -15,7 +15,6 @@ #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" @@ -600,37 +599,6 @@ 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/session_host/thread_tools_test.cpp b/tests/session_host/thread_tools_test.cpp index f837c606..ab188cfb 100644 --- a/tests/session_host/thread_tools_test.cpp +++ b/tests/session_host/thread_tools_test.cpp @@ -470,6 +470,56 @@ TEST_F(ThreadTools, DeleteCascadesToDescendantsAndCleansPins) { std::filesystem::remove_all(cwd); } +// 场景: 模型经线程工具对一个子会话(后台任务 / 网状 agent)调 delete_thread 或 +// set_thread_archived(true);子会话自己调 delete_thread(不带 id = 删自己)。 +// 期望: 全部失败并提示去处理主会话,子会话的磁盘记录与归档状态原样不动; +// 取消归档(清掉旧版本留下的标记)仍然允许。删除主会话时子会话照常级联 +// (见 DeleteCascadesToDescendantsAndCleansPins)。 +// 回归: 子会话曾能被单独删除 / 归档,用户要求子会话始终跟随主会话, +// 单独删掉会让事后分析缺上下文。 +TEST_F(ThreadTools, SubagentThreadsCannotBeDeletedOrArchivedSeparately) { + const auto cwd = unique_cwd("subagent_guard"); + const std::string cwd_string = cwd.string(); + const std::string project_dir = + acecode::SessionStorage::get_project_dir(cwd_string); + std::filesystem::remove_all(project_dir); + + const std::string root = "20261003-100000-0001"; + const std::string child = "20261003-100001-0002"; + persist_thread(cwd_string, root, {}, "root"); + persist_thread(cwd_string, child, root, "child"); + + acecode::ThreadService service({}); + acecode::ThreadScope scope; + scope.cwd = cwd_string; + scope.caller_thread_id = "20261003-090000-cafe"; + + const auto deleted = service.delete_thread(scope, child); + EXPECT_FALSE(deleted.success); + EXPECT_NE(deleted.error.find("main thread"), std::string::npos) + << deleted.error; + EXPECT_FALSE(acecode::SessionStorage::find_session_files( + project_dir, child).empty()); + + // 子会话自己调 delete_thread(不带 id)同样被拒。 + acecode::ThreadScope child_scope; + child_scope.cwd = cwd_string; + child_scope.caller_thread_id = child; + EXPECT_FALSE(service.delete_thread(child_scope, "").success); + EXPECT_FALSE(acecode::SessionStorage::find_session_files( + project_dir, child).empty()); + + const auto archived = service.set_archived(scope, child, true); + EXPECT_FALSE(archived.success); + EXPECT_FALSE(acecode::SessionStorage::read_meta( + acecode::SessionStorage::meta_path(project_dir, child)).archived); + EXPECT_TRUE(service.set_archived(scope, child, false).success) + << "取消归档仍允许"; + + std::filesystem::remove_all(project_dir); + std::filesystem::remove_all(cwd); +} + TEST_F(ThreadTools, SelfDeleteWaitsForTuiTurnBoundaryBeforePurging) { const auto cwd = unique_cwd("self_delete_tui"); const std::string cwd_string = cwd.string(); diff --git a/tests/tui/subagent_host_test.cpp b/tests/tui/subagent_host_test.cpp index 1d55f7d8..e500c669 100644 --- a/tests/tui/subagent_host_test.cpp +++ b/tests/tui/subagent_host_test.cpp @@ -1,11 +1,11 @@ // 覆盖 src/tui/subagent_host.cpp。 // // SubagentHost 是 TUI 的子代理宿主:进程内 SessionRegistry + 事件桥接 + -// 右侧「运行中任务」快照 + /tasks 的 list/abort/clear 后端。一旦回归: +// 右侧「运行中任务」快照 + /tasks 的 list/abort 后端。一旦回归: // - publish 快照失灵 → 右侧栏不出现/不消失(wait=false 的任务无感知) // - busy=false 不移除 → 「只显示运行中」的用户决策被打破 -// - clear_settled 误删运行中任务,或把已结束任务的记录永久删掉 → 数据丢失 -// (子会话与普通会话一样长期保存,clear 只归档,主会话永久删除时才一起删) +// - /tasks list 漏掉已结束的子任务 → 子会话脱离主会话,事后分析缺上下文 +// (子会话没有单独归档 / 清除,始终跟随主会话) // - permission_request 不冒泡 → default 模式子代理卡 5 分钟超时 // // 测试不真跑 LLM:EchoStreamProvider 立即完成 turn,让 busy 迁移与消息 @@ -149,12 +149,12 @@ TEST(SubagentHost, PublishesRunningTaskAndRemovesOnIdle) { } // 场景: /tasks list 合并「运行中(registry)+ 已结束(磁盘 parent 匹配)」; -// 用户执行 /tasks clear。 -// 期望: clear_settled 只处理已结束的,不碰运行中;已结束任务只是归档 -// (meta.archived=true,从列表收起),jsonl 与 meta 都还在,再 clear 一次是 0。 -// 回归: 旧实现 clear 直接永久删除子会话记录,用户要求子会话与普通会话一样 -// 长期保存,只在主会话被永久删除时才随之删除。 -TEST(SubagentHost, ListMergesAndClearOnlyArchivesSettled) { +// 已结束的那个子任务带着旧版本 /tasks clear 留下的 archived 标记。 +// 期望: 运行中在前、已结束在后,两条都列出 —— 子会话始终跟随主会话, +// 归档标记不影响它出现在主会话的任务列表里。 +// 回归: 上一版 /tasks clear 会把已结束子任务归档并从列表收起,用户要求子会话 +// 不能单独归档 / 清除,只随主会话一起永久删除。 +TEST(SubagentHost, ListShowsRunningAndEverySettledTask) { HostFixture fx; const auto project_dir = acecode::SessionStorage::get_project_dir(fx.cwd.string()); fs::create_directories(project_dir); @@ -167,6 +167,7 @@ TEST(SubagentHost, ListMergesAndClearOnlyArchivesSettled) { meta.cwd = fx.cwd.string(); meta.parent_session_id = fx.parent_id; meta.title = "settled task"; + meta.archived = true; // 旧版本 /tasks clear 留下的标记 acecode::SessionStorage::write_meta( acecode::SessionStorage::meta_path(project_dir, settled_id), meta); std::ofstream(acecode::SessionStorage::session_path(project_dir, settled_id)) << ""; @@ -185,23 +186,6 @@ TEST(SubagentHost, ListMergesAndClearOnlyArchivesSettled) { EXPECT_FALSE(entries[1].running); EXPECT_EQ(entries[1].id, settled_id); - // clear 只归档已结束任务;运行中任务与它的持久化数据不受影响。 - EXPECT_EQ(fx.host->clear_settled(project_dir), 1); - 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 92268a75..2a69b0bf 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -5507,7 +5507,7 @@ TEST(WebServerHttp, CompatibilityArchiveSessionHidesFromDefaultList) { } // 场景:无 workspace registry 的归档页回退到兼容路由时,已归档普通会话 -// 也能通过显式 purge 永久删除;未归档普通会话仍由既有 guard 拒绝。 +// 也能通过显式 purge 永久删除;未归档普通会话按与工作区路由相同的 409 拒绝。 TEST(WebServerHttp, CompatibilityPurgeAcceptsArchivedMainSession) { WebServerFixture fx; @@ -5521,9 +5521,9 @@ TEST(WebServerHttp, CompatibilityPurgeAcceptsArchivedMainSession) { auto rejected = cpr::Delete(cpr::Url{ fx.url("/api/sessions/" + session_id + "?purge=1")}); - EXPECT_EQ(rejected.status_code, 400); + EXPECT_EQ(rejected.status_code, 409); EXPECT_EQ(json::parse(rejected.text)["error"], - "only subagent sessions can be purged"); + "session must be archived before permanent deletion"); auto archive = cpr::Put(cpr::Url{ fx.url("/api/sessions/" + session_id + "/archive")}); @@ -5935,10 +5935,13 @@ 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 拒绝(防误删)。 -TEST(WebServerHttp, PurgeDeletesSubagentDiskDataAndRejectsMainSession) { +// 场景: 直接调 DELETE /api/sessions/:id?purge=1 单独永久删除一个子会话, +// 以及永久删除一个还没归档的主会话。 +// 期望: 两者都 409 拒绝且磁盘数据原地不动 —— 子会话没有独立的生命周期,只随主会话 +// 一起删;不带 purge 的 DELETE 只卸载内存会话,对磁盘无破坏。 +// 回归: 这条兼容路由曾是后台任务面板「清除」的实现,子会话一点就被永久删除, +// 用户要求子会话始终跟随主会话(事后分析要完整上下文)。 +TEST(WebServerHttp, PurgeRejectsSubagentAndUnarchivedMainSession) { WebServerFixture fx; const std::string parent_id = "20260705-091000-cccc"; const std::string child_id = "20260705-091100-dddd"; @@ -5955,18 +5958,20 @@ TEST(WebServerHttp, PurgeDeletesSubagentDiskDataAndRejectsMainSession) { acecode::SessionStorage::session_path(fx.project_dir, id)), ""); } - // 主会话 purge → 400,文件原地不动。 + // 未归档主会话 purge → 409,文件原地不动。 auto bad = cpr::Delete(cpr::Url{fx.url("/api/sessions/" + parent_id + "?purge=1")}); - EXPECT_EQ(bad.status_code, 400); + EXPECT_EQ(bad.status_code, 409); EXPECT_TRUE(std::filesystem::exists(path_from_utf8( acecode::SessionStorage::meta_path(fx.project_dir, parent_id)))); - // 子会话 purge → 204,jsonl + meta 都消失。 - auto ok = cpr::Delete(cpr::Url{fx.url("/api/sessions/" + child_id + "?purge=1")}); - EXPECT_EQ(ok.status_code, 204); - EXPECT_FALSE(std::filesystem::exists(path_from_utf8( + // 子会话单独 purge → 409,jsonl + meta 都还在。 + auto child = cpr::Delete(cpr::Url{fx.url("/api/sessions/" + child_id + "?purge=1")}); + EXPECT_EQ(child.status_code, 409) << child.text; + EXPECT_EQ(json::parse(child.text)["error"], + "subagent sessions are deleted together with their main session"); + EXPECT_TRUE(std::filesystem::exists(path_from_utf8( acecode::SessionStorage::meta_path(fx.project_dir, child_id)))); - EXPECT_FALSE(std::filesystem::exists(path_from_utf8( + EXPECT_TRUE(std::filesystem::exists(path_from_utf8( acecode::SessionStorage::session_path(fx.project_dir, child_id)))); // 不带 purge 的 DELETE 语义不变:对磁盘数据无破坏。 @@ -5976,13 +5981,13 @@ TEST(WebServerHttp, PurgeDeletesSubagentDiskDataAndRejectsMainSession) { acecode::SessionStorage::meta_path(fx.project_dir, parent_id)))); } -// 场景: 用户在后台任务面板点「归档」收起已结束的子会话(网状 agent / 后台任务), -// 之后把主会话归档并在设置里永久删除。 -// 期望: 归档的子会话从 ?parent= 列表消失,但磁盘记录保留,也不出现在 -// 「已归档会话」列表里(那里只列主会话);主会话永久删除时连同它的全部 -// 子会话(归档与否)一起删除,别的主会话及其子会话不受影响。 -// 回归: 旧面板「清除」直接永久删除子会话,用户要求子会话与普通会话一样 -// 长期保存;而只删主会话不删子会话又会留下永远清不掉的孤儿记录。 +// 场景: 主会话下有两个子会话(其中一个带着上一版面板「归档」留下的标记), +// 有人试图单独归档 / 永久删除子会话,之后用户把主会话归档并在设置里永久删除。 +// 期望: 单独归档、单独永久删除子会话都 409;?parent= 列表照常列出全部子会话 +// (不看归档标记);「已归档会话」列表只列主会话;主会话永久删除时连同全部子会话 +// 一起删除,别的主会话及其子会话不受影响。 +// 回归: 子会话曾能被面板「清除」/「归档」单独处理,用户要求子会话始终跟随主会话; +// 只删主会话不删子会话又会留下永远清不掉的孤儿记录。 TEST(WebServerHttp, PurgingArchivedMainSessionCascadesToItsSubagents) { WebServerFixture fx; const std::string hash = acecode::compute_cwd_hash(fx.cwd); @@ -5999,6 +6004,7 @@ TEST(WebServerHttp, PurgingArchivedMainSessionCascadesToItsSubagents) { meta.id = id; meta.cwd = fx.cwd; meta.parent_session_id = parent; + meta.archived = id == child_a; // 上一版面板「归档」留下的标记 acecode::SessionStorage::write_meta( acecode::SessionStorage::meta_path(fx.project_dir, id), meta); write_text(path_from_utf8( @@ -6021,17 +6027,24 @@ TEST(WebServerHttp, PurgingArchivedMainSessionCascadesToItsSubagents) { 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; + fx.url("/api/workspaces/" + hash + "/sessions/" + child_b + "/archive")}); + EXPECT_EQ(archive_child.status_code, 409) << archive_child.text; + EXPECT_FALSE(acecode::SessionStorage::read_meta( + acecode::SessionStorage::meta_path(fx.project_dir, child_b)).archived); + auto purge_child = cpr::Delete(cpr::Url{ + fx.url("/api/workspaces/" + hash + "/sessions/" + child_a + "?purge=1")}); + EXPECT_EQ(purge_child.status_code, 409) << purge_child.text; + EXPECT_TRUE(exists(child_a)) << "子会话不能被单独永久删除"; + + // 后台任务列表列出全部子会话,带旧归档标记的也在。 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_a)) << "子会话始终跟随主会话出现在面板里"; EXPECT_TRUE(contains(task_ids, child_b)); - EXPECT_TRUE(exists(child_a)) << "归档不能删除子会话记录"; // 主会话归档后,「已归档会话」列表只列主会话,不列子会话。 auto archive_parent = cpr::Put(cpr::Url{ diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 02abe6f2..4fcbfbe6 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -985,11 +985,6 @@ 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 f0d39a4e..9764da66 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -696,7 +696,6 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses : ''; const subagentTasks = useSubagentTasks(sid, { onSpawnStart: openSubagentPanelForSpawn, - workspaceHash: subagentWorkspaceHash, }); // 当前视图可见的待答问题。提问挂起期间 composer dock 由提问框整体替换 // (方案 A),所以它只驱动渲染,不再参与 submit 的分支判定。 @@ -6174,12 +6173,6 @@ export function ChatView({ titleTarget, actionsTarget, children, sessionRef, ses 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} 个任务归档失败(可能仍在运行)` }); - } - }} /> </div> diff --git a/web/src/components/SubagentPanel.jsx b/web/src/components/SubagentPanel.jsx index ff54f554..3055e859 100644 --- a/web/src/components/SubagentPanel.jsx +++ b/web/src/components/SubagentPanel.jsx @@ -7,9 +7,9 @@ // 已约束的 width 渲染内容。 // // 两个视图: -// - 列表:运行中 / 已完成 分组卡片。运行中卡片右上有中止(stop); -// 已完成组标题行有「归档」:把已结束任务从面板收起,记录仍保留在磁盘上, -// 主会话被永久删除时才随之删除(子会话与普通会话一样长期保存)。 +// - 列表:运行中 / 已完成 分组卡片。运行中卡片右上有中止(stop)。 +// 子会话没有单独归档 / 删除入口:它们始终跟随主会话留在这里, +// 只在主会话归档后被永久删除时一起删除(用户决策,事后分析要完整上下文)。 // - transcript:点卡片「查看会话」原地切换,复用主会话的完整 transcript // 投影与渲染链路,仅通过能力开关保持只读。AskUserQuestion 工具行不显示—— // 子代理的提问/权限确认冒泡到主会话 UI 回答,这里只看执行过程。 @@ -22,7 +22,6 @@ import { useRef, useState, } from 'react'; -import { clsx } from '../lib/format.js'; import { DEFAULT_SUBAGENT_PANEL_WIDTH } from '../lib/singleLayout.js'; import { useSessionTranscript } from '../lib/sessionTranscript.js'; import { createApi } from '../lib/api.js'; @@ -275,9 +274,8 @@ function SubagentTranscriptView({ task, workspaceHash = '', messageAutoCollapse ); } -export function SubagentPanel({ open, width = DEFAULT_SUBAGENT_PANEL_WIDTH, focus, onClose, tasks, workspaceHash = '', onAbort, onClearSettled, messageAutoCollapse = true }) { +export function SubagentPanel({ open, width = DEFAULT_SUBAGENT_PANEL_WIDTH, focus, onClose, tasks, workspaceHash = '', onAbort, messageAutoCollapse = true }) { const [transcriptTaskId, setTranscriptTaskId] = useState(''); - const [clearing, setClearing] = useState(false); // 面板关闭后回到列表视图,重开不残留上一次的 transcript。 useEffect(() => { @@ -291,7 +289,7 @@ export function SubagentPanel({ open, width = DEFAULT_SUBAGENT_PANEL_WIDTH, focu }, [focus?.n, focus?.id]); const groups = useMemo(() => subagentTaskGroups(tasks), [tasks]); - // 目标任务不在列表(如已清除但聊天流仍留有分组项)时,合成一个最小任务对象, + // 目标任务不在列表(如列表尚未刷新到它)时,合成一个最小任务对象, // transcript 仍能按 session_id 拉取展示。 const transcriptTask = transcriptTaskId ? (tasks.find((t) => t.id === transcriptTaskId) @@ -308,16 +306,6 @@ export function SubagentPanel({ open, width = DEFAULT_SUBAGENT_PANEL_WIDTH, focu if (!open) return null; - const clearSettled = async () => { - if (clearing) return; - setClearing(true); - try { - await onClearSettled?.(); - } finally { - setClearing(false); - } - }; - return ( <div className="shrink-0 min-w-0 h-full flex flex-col bg-surface" @@ -392,18 +380,6 @@ export function SubagentPanel({ open, width = DEFAULT_SUBAGENT_PANEL_WIDTH, focu <span className="text-[11.5px] font-medium text-fg-2"> 已完成 {groups.settled.length} </span> - <button - type="button" - onClick={clearSettled} - disabled={clearing} - className={clsx( - 'text-[11.5px] text-fg-mute transition hover:text-danger', - clearing && 'opacity-50 cursor-default', - )} - title="把已结束任务从面板收起;记录保留,主会话永久删除时一并删除" - > - {clearing ? '归档中…' : '归档'} - </button> </div> {groups.settled.map((task) => ( <TaskCard diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index 5ad113eb..3e3c8c7b 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -744,7 +744,6 @@ export const sourceCatalogs = { "s_4de2c4bd41b3582e": "全局 {{p0}}", "s_4e033adb8cd398ff": "复制附件名", "s_4e55dcd70d111b0c": "无法获取文件夹路径", - "s_4e6c70fe4c52f15d": "有 {{p0}} 个任务归档失败(可能仍在运行)", "s_4e6fd0e28c55860b": "停用", "s_4eafa9e925b30bcd": "自定义", "s_4eb2c65beb7f5731": "使用已下载版本", @@ -1132,7 +1131,6 @@ export const sourceCatalogs = { "s_77322b7c285eb8bd": "主题已删除", "s_7734b5d136de7bdb": "自动重启失败:", "s_77423bdd32d35ce3": "编辑刚刚发出的内容", - "s_7763947b4de0e389": "归档中…", "s_776dab5ded273567": "加载命令规则失败", "s_77d29a97cba5c38f": "手动模型 ID", "s_77d77f57b34df366": ",有效上下文 {{p0}}k", @@ -1703,7 +1701,6 @@ export const sourceCatalogs = { "s_b49bc69cbbba7212": "已创建 {{p0}}", "s_b4c6c3eb0bce6b78": "本周", "s_b4f57abef650cc61": "没有匹配的模型。", - "s_b508ee8b2e9964c0": "把已结束任务从面板收起;记录保留,主会话永久删除时一并删除", "s_b52258f32c3bbaf7": "未派遣专家", "s_b52786ac0ce72cdb": "已选 {{p0}} / {{p1}}", "s_b52b7d39abcf20e5": "搜索当前对话内容", @@ -3169,7 +3166,6 @@ 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", @@ -3557,7 +3553,6 @@ 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", @@ -4128,7 +4123,6 @@ 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", diff --git a/web/src/lib/api.js b/web/src/lib/api.js index dcafe10e..a25f9752 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -445,9 +445,6 @@ 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)。后台任务面板只归档,不调用它。 - purgeSession: (id) => request('DELETE', `/api/sessions/${encodeURIComponent(id)}?purge=1`, undefined, base), getSessionDraft: (id, workspaceHash = '') => request('GET', sessionDraftPath(id, workspaceHash), undefined, base), setSessionDraft: (id, text = '', workspaceHash = '', composerContent = null) => diff --git a/web/src/lib/subagentPanelSplitArchitecture.test.js b/web/src/lib/subagentPanelSplitArchitecture.test.js index 8f141044..2aef9c04 100644 --- a/web/src/lib/subagentPanelSplitArchitecture.test.js +++ b/web/src/lib/subagentPanelSplitArchitecture.test.js @@ -70,11 +70,11 @@ 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, workspaceHash = '' \} = \{\}\)/); + assert.match(taskHook, /useSubagentTasks\(parentSessionId, \{ onSpawnStart \} = \{\}\)/); 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*workspaceHash: subagentWorkspaceHash,\s*\}\)/); + assert.match(chat, /useSubagentTasks\(sid, \{\s*onSpawnStart: openSubagentPanelForSpawn,\s*\}\)/); assert.match(chat, /onClick=\{\(\) => setSubagentPanelOpen\(\(v\) => !v\)\}/); assert.match(chat, /onClose=\{\(\) => setSubagentPanelOpen\(false\)\}/); }); @@ -92,26 +92,24 @@ run('subagent transcript reads the parent workspace so unloaded children still o 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 = '' \} = \{\}\)/); + assert.match(hook, /useSubagentTasks\(parentSessionId, \{ onSpawnStart \} = \{\}\)/); }); -// 场景:用户点后台任务面板「已完成」组的按钮。 -// 期望:只归档(从面板收起),不再永久删除;子会话与普通会话一样长期保存, -// 主会话永久删除时由 daemon 级联删除。按钮文案随之改为「归档」。 -// 回归:旧按钮「清除」直接 purge,子会话记录一点就没了。 -run('settled subagent tasks are archived, never purged from the panel', () => { +// 场景:后台任务面板里已结束的子任务(星型后台任务、网状 agent)。 +// 期望:面板没有任何单独归档 / 清除 / 删除入口,子任务始终跟随主会话留在面板里, +// 只在主会话归档后被永久删除时由 daemon 一起删除(用户决策:事后分析要完整上下文)。 +// 回归:曾经有「清除」(直接永久删除),后来改成「归档」(从面板收起), +// 两种都会让子会话离开主会话,用户要求整个入口去掉。 +run('settled subagent tasks stay in the panel with no archive or delete entry', () => { const panel = source('components/SubagentPanel.jsx'); const hook = source('lib/useSubagentTasks.js'); + const chat = source('components/ChatView.jsx'); - 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, /永久删除全部已结束任务/); + assert.doesNotMatch(hook, /clearSettled|archiveSession|archiveWorkspaceSession|purgeSession|purgeTask/); + // 旧按钮文案是 JSX 字符串 '清除' / '归档'(注释里出现这两个词不算)。 + assert.doesNotMatch(panel, /onClearSettled|clearSettled|['>]归档|['>]清除/); + assert.doesNotMatch(chat, /onClearSettled|clearSettled/); }); console.log('subagentPanelSplitArchitecture.test.js: all tests passed'); diff --git a/web/src/lib/useSubagentTasks.js b/web/src/lib/useSubagentTasks.js index f1cda3e6..479d656d 100644 --- a/web/src/lib/useSubagentTasks.js +++ b/web/src/lib/useSubagentTasks.js @@ -7,9 +7,8 @@ // 3. 对运行中的子任务保持 WS 订阅(connection.retainSession)—— // **不依赖面板是否打开**:这是子会话 permission_request / question_request // 能到达 App 全局监听并冒泡到主会话 UI 的前提。已结束任务不订阅。 -// 4. 操作:中止(sendAbort + 本地标记)、归档(archive REST + 本地移除)。 -// 子会话和普通会话一样长期保存:面板的「归档」只把已结束任务收起, -// 记录随父会话一起在永久删除时才清掉(daemon purge 级联)。 +// 4. 操作:只有中止(sendAbort + 本地标记)。子会话没有单独的归档 / 删除: +// 它们始终跟随主会话,只在主会话归档后被永久删除时一起删除(daemon 级联)。 import { useCallback, useEffect, useRef, useState } from 'react'; import { api } from './api.js'; @@ -24,7 +23,7 @@ import { shouldRefreshSubagentTasksFromStatus, } from './subagentTasks.js'; -export function useSubagentTasks(parentSessionId, { onSpawnStart, workspaceHash = '' } = {}) { +export function useSubagentTasks(parentSessionId, { onSpawnStart } = {}) { const [tasks, setTasks] = useState([]); const retainedRef = useRef(new Set()); const parentRef = useRef(parentSessionId); @@ -124,28 +123,10 @@ export function useSubagentTasks(parentSessionId, { onSpawnStart, workspaceHash setTasks((prev) => markSubagentTaskAborted(prev, id)); }, []); - const clearSettled = useCallback(async () => { - const settled = tasks.filter((t) => t.status !== SUBAGENT_TASK_STATUS.RUNNING); - // 子会话跟随父会话所在工作区;多工作区 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) { - setTasks((prev) => prev.filter((t) => !removed.has(t.id))); - } - const failed = results.filter((r) => r.status === 'rejected').length; - return { removed: removed.size, failed }; - }, [tasks, workspaceHash]); - return { tasks, runningCount: runningSubagentCount(tasks), refresh, abortTask, - clearSettled, }; }