diff --git a/.github/workflows/refactor-matrix.yml b/.github/workflows/refactor-matrix.yml index 7f5ea26a9..495f2bfe1 100644 --- a/.github/workflows/refactor-matrix.yml +++ b/.github/workflows/refactor-matrix.yml @@ -305,6 +305,19 @@ jobs: libegl1-mesa-dev libudev-dev libxi-dev libcups2-dev librsvg2-dev rm -rf /var/lib/apt/lists/* + - name: Record verified source revision + shell: bash + run: | + # Buster's Git security backport ignores command-scope safe.directory. + # Trust only this ephemeral checkout, then fail if HEAD is unavailable. + git config --global --add safe.directory "$GITHUB_WORKSPACE" + source_sha="$(git -C "$GITHUB_WORKSPACE" rev-parse --verify HEAD)" + if [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid source revision: $source_sha" >&2 + exit 1 + fi + printf 'SOURCE_REVISION=%s\n' "$source_sha" >> "$GITHUB_ENV" + - name: Set up CMake uses: jwlawson/actions-setup-cmake@v2 with: @@ -366,7 +379,7 @@ jobs: "runner_image": "ubuntu-22.04 / buildpack-deps:buster", "label": "${{ inputs.label }}", "source_ref_input": "${{ inputs.source_ref }}", - "source_revision": "$(git -c safe.directory='*' rev-parse HEAD)", + "source_revision": "${SOURCE_REVISION}", "tools_revision": "${{ github.sha }}", "workflow_run_id": "${{ github.run_id }}", "run_tests": false, diff --git a/.gitignore b/.gitignore index 09a543354..5af25b69b 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,7 @@ skills-lock.json /docs/desktop-shell/composer-diagnostic.md /docs/desktop-shell/composer-diagnostic-handoff.md /pnpm-lock.yaml + +# Python bytecode caches +__pycache__/ +*.py[cod] diff --git a/CLAUDE.md b/CLAUDE.md index cbd214dda..9853a0084 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,8 @@ generates one standard-quality image and must never run on load or save. Core tools are registered for both TUI and daemon paths: `bash`, `file_read`, `file_write`, `file_edit`, `grep`, `glob`, `task_complete`, `AskUserQuestion`, skill tools, memory tools, optional `web_search`, and MCP tools. `ToolResult` can carry summaries and hunks so TUI/web resume can render useful compact rows instead of raw output folds. +**侧边对话只放只读工具(side chat read-only tools)。** Web/Desktop 浮动侧边对话曾不带工具表,模型照着主会话历史把 `bash` 调用写成尖括号正文显示出来。现在 `run_side_chat`(`src/engine/agent/side_question/side_chat.cpp`)是一个小工具循环:工具集由 `side_chat_tools.cpp::build_side_chat_toolset` 组装,白名单 `file_read` / `grep` / `glob` / `lsp` 与会话专家策略取交集、MCP 一律不放(**不要**改成按 `is_read_only` 筛 —— spawn_subagent / AskUserQuestion / goal 工具也带这个免确认标记);每次调用过 Deny 规则 → 主会话同款 `PathAccessPolicy` → 危险路径,凡是要弹确认的一律拒绝(侧边对话没有确认通道),并在 `MtimeTracker::DetachedReadScope` 里执行 —— 否则侧边读过的文件,主代理再读只拿到「未变化」短桩,或不读就能编辑。最多 8 轮工具;正文里的文本工具调用丢弃并纠正(最多 2 次),绝不显示。协议:`side_chat_tool` 帧报工具进度,`side_chat_reset` 只丢弃当前这一步的正文(工具之前的正文保留),前端 `sideChatTurnParts` 按工具发生位置把回答切成「正文 / 工具行」。TUI `/btw` / `/side` 与同步 HTTP `/side-question` 走同一个循环(`SideQuestionService::run_question`,无追问历史、无流式回调):结果的 `tools_used` 由 TUI 经 `side_question_tools_note` 显示在回答前,HTTP 原样返回;Codex 原生 provider 自带工具运行时,一律拒绝。`stop_requests` 会取消在途的旁路请求(含 Web 流式),关会话不用等完整个工具循环。回归:`tests/agent/side_chat_test.cpp`、`tests/agent/side_question/side_chat_tools_test.cpp`、`side_question_service_test.cpp::StopRequestsCancelsInFlightQuestion`、`session_registry_test.cpp::SideQuestionReadsFilesWithReadOnlyToolsOnly`、`mtime_tracker_test.cpp::DetachedReadScope*`、前端 `sideChatController` / `sideChatStream` 测试。 + `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. @@ -259,12 +261,12 @@ Rewind support uses per-user-turn checkpoints. `SessionManager::track_file_write - **`accept` 拒掉的条目不占 limit 名额**,否则一串归档会话就能把整页挤空,侧边栏会显示成「这个 workspace 没有会话」。 - **`sessions_for_workspace` 里活跃会话逐个点读自己那一个 meta**(`read_meta(meta_path(dir, id))`),不要为了给它们配 meta 而把整个目录读一遍 —— 那正是这条路径原来 100% 的耗时来源。行先攒成 (updated_at, id, body) 再统一排序截断:旧实现按「active 一段 + disk 一段」的拼接顺序截断,取出来的前 N 条并不是最新的 N 条。 - **那个 `dir` 必须由会话自己的 cwd 推导,不能用调用方 workspace 的 cwd。** 会话 meta 的落盘目录恒为 `get_project_dir(该会话的 cwd)`(`SessionManager::ensure_created` 就是这么定 `project_dir_` 的),而**一个 daemon 经 routes_workspaces 服务多个 workspace** —— 侧栏切项目不换进程,`compatibility_workspace()` 始终是进程 cwd。拿进程 cwd 去读别的 workspace 的会话 meta 只会读到空,而 `SessionRegistry::list_active()` 出于热路径考虑本来就不填 `created_at`/`updated_at`(靠这次点读补),于是这两个字段静默变成空串。实测症状:Web「后台任务」面板卡片耗时**永远停在 00s**(前端 `taskElapsedSeconds` 在 `createdAtMs` 为 0 时直接 return 0,每秒 tick 也推不动),`GET /api/sessions?parent=` 整个返回 `[]` 而同一时刻 `GET /api/workspaces//sessions?parent=` 返回完整两条。同理,后台任务查询的**磁盘枚举**要用父会话 cwd 的 project_dir,否则已结束的子任务在面板里整个消失。这是 junction 教训(见 LSP 一节)的同族问题 —— 凡是拿路径当 key 的地方,两侧形态必须同源。回归测试:`web_server_smoke_test.cpp::SubagentQueryReadsMetaFromSessionOwnWorkspace`。注意该用例必须给子会话补一条消息才有意义 —— meta 是 lazy 落盘的,只 create 不发消息时磁盘上根本没有 meta,测到的就不是「读错目录」。 -- **截断后 `total` 只是上界**(目录里的候选文件数),精确总数拿不到。REST 因此多回 `total_exact` 与 `has_more`,前端判断「是否已全量加载」只能看 `has_more`;拿 `sessions.length >= total` 比会在上界下判错,展开时该补的全量请求就被跳过了。 +- **截断后 `total` 只是上界**(目录里的候选文件数),精确总数拿不到。REST 因此多回 `total_exact` 与 `has_more`,前端判断「是否已全量加载」只能看 `has_more`;拿 `sessions.length >= total` 比会在上界下判错,展开时该补的有界请求就被跳过了。 - 文件名判定(`is_canonical_meta_filename`)是手写字符扫描而不是 `std::regex` —— 上千条目的目录里每项跑两次正则的开销已经能量到。改它要同时守住:PID 后缀的旧实验数据必须排除,headless `--session-id` 的自定义 id 字符集必须接受。 回归测试:[tests/session/session_metadata_page_test.cpp](tests/session/session_metadata_page_test.cpp)(其中 `BoundedPageStopsReadingOnceItHasEnough` 的 `accepted <= 13` 就是「没退化成读全部再截断」的哨兵)+ `tests/web/session_list_handler_test.cpp` + `web_server_smoke_test.cpp::WorkspaceSessionListLimitReturnsEnvelope`。 -**仍未分页的两条全量路径**(已知,尚未优化):`GET /api/sessions`(`include_no_workspace=true`,要并 no-workspace 缓存目录)与 WS 连接建立时的 `send_status_snapshot()` —— 后者每次建连都对当前 workspace 全量枚举一次。 +**旧全量列表继续兼容。** 侧栏使用 `GET /api/sessions?scope=no-workspace`,展开工作区按当前可见行数加 5 再加置顶数请求,周期刷新保持该界限;父会话查询只读父会话的工作区并默认限制 100 条。WS 首次状态订阅仍需要工作区快照,客户端和服务端均去重,重连后才重新订阅。 **侧边栏卡顿的第二个来源:请求条数,不是单个请求的耗时。** 侧边栏有个 5 秒的 `setInterval(refresh)`,而 `refresh()` 里对**全部** workspace 各扇出一次 `pinned-sessions`(还是 `await Promise.all`,阻塞后续)和一次 `opencode-import`。14 个 workspace 就是 28 个请求/轮,浏览器对同一域名只有 6 条并发连接 —— 实测页面开着 30 秒发了 **220 个 API 请求**,`pinned-sessions` 84 次累计 8974ms、`opencode-import` 84 次累计 7660ms,而**每个请求的 `responseStart - startTime` 几乎等于它的总耗时**,即 400ms+ 全是排队,服务端处理只要十几毫秒。用户点击展开时那个几毫秒的会话列表请求排在一百多个请求后面,表现就是侧边栏长时间停在「加载中...」。 @@ -274,7 +276,7 @@ Rewind support uses per-user-turn checkpoints. `SessionManager::track_file_write - **`pinned-sessions` 只对可见 workspace 重取**,折叠的沿用 `pinnedByWorkspaceRef` 缓存值 —— 注意是「沿用」不是「清空」,否则展开时置顶标记会闪一下。 - **`opencode-import` 首轮全探、之后只重探可见的**。它探的是「这个目录有没有 opencode 数据可导入」,近乎静态,5 秒一次对全部 workspace 重探纯属浪费;新出现的 workspace 仍会补探一次,导入提示不会丢。 -- **`session_ids_for_workspace` 曾用 `list_sessions()`** —— 它只需要 id 与 archived,却让 `enrich_meta_from_messages` 逐个打开 JSONL(单个 workspace 实测 523MB),而且把同一个目录扫了两遍。改用 `list_session_metadata()` 后单次 `pinned-sessions` 从 9~16ms 降到 5.7ms(冷缓存差距更大)。 +- **`session_ids_for_workspace` 曾用 `list_sessions()`** —— 它只需要 id 与 archived,却让 `enrich_meta_from_messages` 逐个打开 JSONL(单个 workspace 实测 523MB),而且把同一个目录扫了两遍。现在只对置顶 ID 点读对应 meta;无置顶时零元数据读取。侧栏有界会话页先显示,置顶标记与排序随后合并。 **`WorkspaceRegistry::scan()` 的负缓存。** `~/.acecode/projects` 下每个用过的 cwd 都留一个 hash 目录,实测 **16568 个**,而带 `workspace.json` 的只有 34 个。拆开测:枚举全部目录连 mtime 只要 **12.8ms**,逐个探 `workspace.json` 却要 **267ms** —— 95% 的时间花在对一万六千个目录做 `fs::exists`。而 `/api/workspaces` 与 `/api/pinned-sessions/order` 每轮 refresh 各调一次 scan。 @@ -488,7 +490,7 @@ writes finish and refills the password from the authenticated settings response. 5. **AsyncPrompter waiters** — AgentLoop blocks on a per-session condvar; unblock posted from the Crow handler thread processing `decision` 6. **Attention flusher** — `WebServer::Impl::start_attention_flusher`,每 `kAttentionFlushIntervalMs`(1000)把脏 workspace 的 session 未读态落盘 -**`app_config_mu` 是 shared_mutex;只读路径共享、写路径独占。** 两条 web resume 路由使用 `shared_lock`:resume 对 config 只读但要全量解析 jsonl(实测 574~824ms/次),独占持有会把整个 HTTP 面卡停近一秒。其他只读取或快照 config 的路由也必须使用 `shared_lock`,这样恢复会话时仍可并发处理模型列表、健康状态等读取;settings 变更、saved_models 落盘、`refresh_default_session_preferences` 等写方必须使用 `lock_guard` 独占。同 id 并发 resume 由 `SessionRegistry::resume` 入口的单飞守卫(`resume_inflight_`)串行化,后到者等首个完成后命中 `entries_` 快速路径;没有这层,两个 `make_entry` 并发会互抢 writer lease,输家析构时还可能清掉赢家的 lease。 +**`app_config_mu` 是 shared_mutex;只读快照共享、写路径独占。** Web 恢复入口使用会话注册表持有的依赖与配置快照,不能把全量 IO 包在配置锁内;两条恢复路由已不再直接持有旧的 `shared_lock`。只读取/复制 config 的路由在复制期间用共享锁,settings、saved_models 与默认偏好刷新等写路径用独占锁。同 id 并发恢复仍由 `SessionRegistry::resume` 的 `resume_inflight_` 单飞守卫串行化,防止重复 entry 与 writer lease 竞争。 **Attention 落盘是节流的,别改回逐事件写。** `note_session_event_for_attention` 由 WS 订阅的 listener 调用,而 `EventDispatcher::emit` 是在**发射线程(AgentLoop worker)上同步 drain 订阅者**的 —— 所以这个函数跑在 agent 线程上,且 Token / Reasoning / Tool* 事件都会推进 `update_cursor`。曾经每个这样的事件都整份重写 workspace 的 attention 文件(tmp + rename),实测流式峰值约 500 事件/秒(feedback IQSZ-D0668:相邻日志行 lastSeq 差 45 / 92ms),多会话并发时还是同一个文件,把磁盘和 `attention_mu` 一起打满。现在热路径只 `attention_dirty_workspaces.insert`,由上面那条 flusher 线程合并写;只有状态跃迁(read↔unread↔in_progress / busy 翻转,即回合边界)才同步落盘。不变量:成功写出才清掉对应脏标记,失败保留到下个周期重试;析构时先停止事件 producer,最后停 flusher 并落盘剩余状态。 @@ -571,6 +573,10 @@ SidePanel 折叠 UI:`ChatView` 把 `SidePanel` 包到 `