# ARCHITECTURE.md — tianshu-mcp 架构说明 > 适用版本:`0.9.4`(2026-10-09)。 > 本文描述**系统结构与模块边界**,面向要改动本仓库的开发者;版本号只在发布时随提交更新,最新发布说明见 `docs/release-v<最新版本>.md`。 > 本文描述**系统结构与模块边界**,面向要改动本仓库的开发者。 > 运行方式、安装步骤与用法见 [README.md](README.md);交接状态、排障手册与踩坑记录见 [HANDOFF.md](HANDOFF.md)。 > 英文版:[ARCHITECTURE.en.md](ARCHITECTURE.en.md)。 --- ## 1. 定位与系统上下文 `tianshu-mcp` 是一个**被天枢(Tianshu)当作标准 MCP server 接入的编排层**。天枢是总指挥与用户交互面,本 server 承担三件事: 1. **调度**——任务队列、并发闸、状态机、超时与取消; 2. **执行面**——把任务书送达外部 AI-Agent(Codex 桌面端 / TraeWork / ZCode / 任意 CLI); 3. **客观验收仪**——相对 git 基线做命令检查、代码分析与(可选)视觉像素比对,产出报告。 ```text ┌─────────────────────────────────────────────────────────────┐ │ 天枢(Tianshu TUI × GUI) │ │ · 按次同步调用 tools/call,只消费 content[].text 与 isError │ └───────────────────────────┬─────────────────────────────────┘ │ MCP over stdio(stdout 仅承载 JSON-RPC) ┌───────────────────────────▼─────────────────────────────────┐ │ tianshu-mcp(本仓库) │ │ 调度 ── 执行面 ── 验收仪 │ └──────────┬──────────────────────────────┬───────────────────┘ │ │ ┌───────▼────────┐ ┌────────▼─────────┐ │ 外部 AI-Agent │ │ 目标项目工作区 │ │ GUI:CDP 驱动 UI│ │ git 仓库 + 测试 │ │ CLI:子进程 │ │ .tianshu-mcp/ │ └────────────────┘ └──────────────────┘ ``` ### 1.1 四条硬约束,以及它们造成的架构后果 本项目的形态不是自由设计的结果,而是四条**实测硬约束**逼出来的。改架构前必须先读这张表: | # | 实测约束 | 架构后果 | |---|---|---| | C1 | 天枢的 MCP 工具**只回文本**:`content[]` 的 `text` 被拼成字符串,`isError` 透传 | 所有结果统一为「人类可读文本 + `---tianshu-mcp-meta---` JSON 块」;不依赖 resources / prompts(`src/mcp/formatter.ts`) | | C2 | 天枢**按次同步**调用 `tools/call` | 长任务必须异步化:`run_task` 秒回 `taskId`,用 `query_task` 轮询;无服务端推送 | | C3 | TraeWork 的 agent 请求在 TTNet 层 TDE 加密,无法在客户端外构造 | 唯一可行路径是 **CDP 驱动桌面 UI**,从 DOM 提取结果(`src/agents/traework/`) | | C4 | Codex 桌面端是 **MSIX 商店包**,GUI 宿主无法 `CreateProcess` 直启 | 必须经 `IApplicationActivationManager` COM 激活并注入专属 `--user-data-dir` 才能开 CDP 端口(`src/agents/codex/launcher.ts`) | ZCode 属于同类问题:无随包 headless CLI,故同样走 CDP(M2 已实测定论)。 --- ## 2. 分层架构总览 ```text ┌──────────────────────────────────────────────────────────────────┐ │ L1 协议边 src/index.ts · src/server.ts · src/mcp/ │ │ 入口与 CLI 分流 · 装配 · 8 工具注册 · 参数校验 · 文本+meta 格式化 │ ├──────────────────────────────────────────────────────────────────┤ │ L2 任务域 src/tasks/ │ │ 状态机 · 每项目串行队列 · 全局并发闸 · 事件流落盘 · 取消语义 │ ├──────────────────────────────────────────────────────────────────┤ │ L3 编排 src/loop/ │ │ TaskOrchestrator:派发 → 验收 → 返修 → 再验收;轮次记账与终止判定 │ ├───────────────────────────────┬──────────────────────────────────┤ │ L4a 执行面 src/agents/ │ L4b 验收面 src/verify/ src/visual/│ │ AgentAdapter 契约 │ git 基线 · 命令检查 · 代码分析 │ │ CLI spawn / GUI CDP 驱动 │ 视觉像素比对 · 报告产物 │ ├───────────────────────────────┴──────────────────────────────────┤ │ L5 基础 src/config/ · src/util/ │ │ zod schema · 数据目录 · 热加载 · 原子写 · 路径归一 · 日志 · 技能自检 │ └──────────────────────────────────────────────────────────────────┘ ``` 依赖方向**严格单向向下**:L1 → L2 → L3 → {L4a, L4b} → L5。L4a 与 L4b 互不依赖,只在 L3 汇合;这是本项目最重要的解耦边界——**「谁来干活」与「怎么算干得好」是两件独立可替换的事**。 --- ## 3. 启动装配与生命周期 ### 3.1 入口分流(`src/index.ts`) ```text node dist/index.js → stdio MCP server node dist/index.js visual → 视觉验收 CLI 子命令族 (在建立 stdio 连接之前分流,不占用协议流) ``` - 数据目录由 `resolveDataHome()` 解析:环境变量 `TIANSHU_MCP_HOME` 优先,否则 `~/.tianshu-mcp`。 - 日志器初始化到 `<数据目录>/logs/`。 - 技能自检安装可经 `--no-skill-install` 或 `TIANSHU_MCP_NO_SKILL_INSTALL=1` 关闭;「需变更但不自动覆盖」(`autoInstall:"prompt"` 或来源不明)可经 `--approve-skill-update` 或 `TIANSHU_MCP_APPROVE_SKILL_UPDATE=1` 放行(见 §3.4)。 - 退出路径:`SIGINT` / `SIGTERM` / stdin EOF / stdin close → 归档活动任务并终止子进程 → `exit(0)`。 ### 3.2 组装顺序(`src/server.ts`) `buildServer()` 是唯一的装配点,顺序有语义: ```text resolveDataHome → Logger → DataHome(BUILTIN_PROFILES) → init() → loadConfig() → maxRunning / shutdown.guiStopWaitMs → TaskStore → AgentAdapterRegistry(loadProfiles) → AcceptanceEngine → TaskManager(+makeBuildCtx) → manager.initialize({maxRunning, guiStopWaitMs}) # 归档重启遗留的 active 任务 → 技能自检安装(后台,不阻塞握手) → 注册 8 个工具 → 返回 ServerAssembly{server, manager, dataHome, store, logger, close} ``` `close()` = `manager.shutdownInterrupt()`(归档活动任务 + 终止子进程)→ `engine.close()` → `server.close()`。 工具注册是**数据驱动**的:遍历 `TOOL_DEFS`,按名字在 `handlers` 里取实现,缺失即 log error 并跳过;`registerTool` 时顺带下发 `_meta.requireApproval`、`_meta.capability` 与 MCP `annotations`(readOnly / destructive / openWorld / idempotent——`idempotentHint` 只对 `run_task` / `verify_task` 为 true,语义前提是调用方传入 `idempotencyKey`,见 §5.6),供宿主策略层使用。 ### 3.3 数据目录布局 ```text <数据目录>/ 默认 ~/.tianshu-mcp ├── config.json server 配置(并发、超时、技能开关) ├── agent-profiles.json 用户自定义/覆盖的 agent profile ├── projects.json 项目登记表(含每项目验收配置) ├── idempotency.json 幂等键映射(键→taskId/digest,TTL + 容量裁剪;issue #15) ├── logs/server.log 全级别诊断日志(与 stderr 同源) ├── browsers/ 视觉模块托管的 Chrome(managed 模式) ├── visual-candidates// 待批准基准候选(candidate.json + PNG + preview.html) ├── visual-locks/.lock 视觉操作互斥锁 └── tasks// 单任务隔离目录(见下) ``` 单任务目录(`src/tasks/task-store.ts`): | 文件 | 内容 | |---|---| | `task.jsonl` | 追加式事件流(唯一权威时间线) | | `task.json` | 最新 `TaskMeta` 快照(原子写) | | `baseline.json` | 动工前的 git 基线 | | `agent-.log` | agent 输出(round 从 0 起) | | `verify-.log` | 验收命令输出 | | `report-.md` / `.json` | 验收报告(`.md` 人读,`.json` 机读) | | `report-.html` | 视觉离线报告,**仅当报告含视觉结果时**生成 | | `rework--r.md` | 返修计划(非 codex 路径) | | `qoder-session.json` | Qoder CN 发送检查点(**只有 qoder 有**,见 §8.2) | | `visual////` | 视觉四联图:`actual/baseline/diff/regions.png` + `metrics.json` | | `visual-snapshot.json` | 任务期冻结的视觉规则快照 | 项目侧产物:`<项目>/.tianshu-mcp/acceptance.json`(项目级验收配置)、`<项目>/tests/visual/baselines/...`(视觉基准)、codex 路径的 `<项目>/.zcode/plans/codex-fix-r.md`(修复计划)。 ### 3.4 技能自检安装的信任与判定模型(issue #16) 模块 `src/util/skill-install.ts`;在 `buildServer()` 内**后台**执行(`void`,不阻塞握手),失败仅告警不阻断。 **源定位**:只由 `import.meta.url` 相对包自身定位(`<模块>/../../skills/tianshu-mcp`)——dev 直跑与 dist 运行相对深度一致,因此单条候选即够。**刻意不回退 `process.cwd()`**:任何基于 cwd 的内容发现都会让「在某个第三方仓库里调试起 server」变成投毒面。找不到源时跳过安装并告警。 **安装清单**:目标目录内维护 `<目标>/.tianshu-mcp-install.json`(`schema`/`name`/`packageVersion`/`contentHash`/`installedAt`/`sourceDir`,`"prompt"` 保留时另有 `pendingUpdate`)。`contentHash` 由 `hashSkillTree()` 计算,**排除清单自身**(否则写清单即自证被改动)与平台噪声(`.DS_Store`/`Thumbs.db`/`desktop.ini`/`._*`/`.git*`);拷贝用同一排除谓词,保证「装完立即算 hash」与源 hash 严格相等(幂等的根因)。 **判定矩阵**(`decideInstall()`,纯函数,判定与 IO 分离): | 目标状态 | 判据 | `auto`(默认) | `"prompt"` | 追加 `--approve-skill-update` | |---|---|---|---|---| | 不存在 | — | 安装 | 安装 | 安装 | | 存在,内容 == 包内 | — | 跳过(按需补写/校准清单) | 跳过 | 跳过 | | 清单 ok,内容 == 清单记录 ≠ 包内 | 未改动的旧版包副本(可信) | **备份 + 覆盖**(warn) | 保留 + warn + 清单记 `pendingUpdate` | **备份 + 覆盖**(warn) | | 清单 ok,内容 ≠ 清单记录 | 用户本地修改(确证) | **保留 + 强 warn** | 同左 | **同左(放行不生效)** | | 无有效清单(缺失/损坏/非目录)且内容 ≠ 包内 | 来源不明 | **保留 + warn** | 同左 | **备份 + 覆盖**(warn) | - `skills.autoInstall: false` 时 `server.ts` 直接短路,不调用本模块;`--no-skill-install` 与 `false` 的否决权高于放行参数。 - 覆盖**只**发生在「可信旧版」与「显式放行」两格,`--approve-skill-update` 对已确证的用户本地修改**不生效**(保护用户对技能文档的调优不被静默冲掉)。 **安装原子性**:`installFromSource()` 走「`<目标>.incoming--` 拷贝(含写清单)→ 旧目录 `rename` 为 `<目标>.bak-` → `rename` 换入」;失败清 tmp 并回滚 bak(回滚失败仅告警,bak 仍在)。启动时另清理 mtime 早于 1 小时的 `.incoming-*` 崩溃残留。 **日志分级**:跳过/补写清单 = `INFO`;覆盖旧版、保留用户修改、来源不明、安装失败 = `WARN`。便于检索的稳定短句:`含本地修改`、`来源不明`、`未自动覆盖`。 **备份治理**:覆盖成功后按 `skills.backupKeep`(默认 3,`0` = 不清理)收敛 —— 只匹配精确模式 `.bak-<数字>` 的**目录**,按时间戳保留最新 N 个,删除前 `INFO` 记录,删除失败仅 `WARN`。首个安装与跳过/保留路径都不清理。 --- ## 4. MCP 工具面与返回契约 8 个工具(`src/mcp/tools.ts`;v0.9.0 起由 13 个按域合并),按能力分为三族:`read`(读/查询,无副作用)、`write`(有副作用,全部需审批)、`execute`(执行项目侧命令但不改源码,当前仅 `verify_task`,按 R11 仍免审批): | 工具 | 能力 | 审批 | 作用 | |---|---|---|---| | `run_task` | write | 是 | 派活,异步返回 `taskId` | | `query_task` | read | 否 | 轮询状态 / 进度 / 日志尾 | | `manage_task` | write | 是 | 任务生命周期管理(合并原 `cancel_task` / `continue_task` / `rework_task`):`action=cancel` 取消(CLI 杀进程树;GUI 尽力点停止 + 有界等待);`action=continue` 恢复 `needs_user` 的原会话;`action=rework` 手动返修,把失败摘要喂回同一 agent。**注解层 `destructiveHint=true`**(工具级、无法按 action 区分) | | `verify_task` | **execute** | 否 | 对任务或项目路径做一次验收:会跑项目命令、可产生构建产物,但**不改源码**,故免审批 | | `query_info` | read | 否 | 统一信息查询(合并原 `list_tasks` / `get_task_report` / `get_profiles`):`type=tasks` 历史任务列表;`type=report` 某轮 `report.md` 全文;`type=profiles` agent 适配与可执行探测结果 | | `wait_task` | read | 否 | 阻塞等待任务到停点(终态或 `needs_user`)或超时;单任务给 `taskId`,批量给 `taskIds`(1..20,等数组顺序首个停者,返回其快照 + 全部状态)。纯只读、无害(v0.9.0 合并原 `wait_any`) | | `prepare_visual_baseline` | write | 是 | 生成基准候选与摘要(不落正式基准) | | `approve_visual_baseline` | write | 是 | 用户审阅后核对摘要并写入基准 | > **`readOnlyHint` 推导规则**:`server.ts` 按 `capability === "read"` 下发 MCP `readOnlyHint`,因此 `verify_task` 的该注解为 **false**(v0.6.1 起;此前误为 true)。**`readOnlyHint` 不是审批信号**——审批与否由 `_meta.requireApproval` 单独承载,`verify_task` 该字段恒为 false。 **返回契约**(`src/mcp/formatter.ts`):人类可读正文 + 尾随元块,便于宿主正则抽取。 ```text <人类可读文本> ---tianshu-mcp-meta--- { ...MetaBlockFields: taskId, status, ok, round, changedFiles, diffstat, ... } ---tianshu-mcp-meta--- ``` **参数校验是两段式的**:`server.ts` 先用 `inputSchema.safeParse()` 做协议级校验(失败回 `Error: 参数不合法 — : `);handler 内再做语义闸门,例如 `projectPath` 安全校验(绝对路径 + 存在 + realpath 归一 + 拒绝主目录与根级/系统目录)、`mode` 仅 TraeWork 可用的拒绝、`allowCreateProject` 仅 ZCode 可用等。 进度**只落盘、不推送**:GUI adapter 按 `gui.progressIntervalMs`(默认 30s)回报进度,`TaskOrchestrator` 写成 `task.jsonl` 的 `note` 事件并刷新快照的 `progressSummary` / `lastRunSignal`;`query_task` 每次读取最新快照与事件流,因此轮询者看到的是「最后一次落盘的进度」。除自由文本进度外还有**语义化**的细粒度事件流(`onEvent`,issue #18),见 §5.8——两者同写 `task.jsonl`:`note` 承载自由文本,细粒度事件承载节点语义。 --- ## 5. 任务域:状态机、队列与持久化 ### 5.1 状态机(`src/tasks/task.ts`) ```text ┌──────────────────────────────┐ run_task ──► queued ─┤──► running ──► verify_start ──┤──► succeeded │ │ ▲ │ │──► failed │ │ │ ▼ │──► needs_attention │ │ └──── fixing ◄──────────┘ │ │ │ ├──► needs_user ──► queued (manage_task(action='continue') 恢复) │ ├──► cancelled │ └──► interrupted └──► cancelled | interrupted ``` - **状态集**:`queued, running, verify_start, fixing, succeeded, failed, needs_attention, needs_user, cancelled, interrupted`。 - **终态**:`succeeded, failed, needs_attention, cancelled, interrupted`。 - **活动态**:`queued, running, verify_start, fixing`。 - **`needs_user` 既非活动态也非终态**——它可被 `manage_task(action="continue")` 恢复到 `queued`,也可被取消。这是 GUI agent 等待人工介入时的宿主可见形态。 - 转移表 `TRANSITIONS` 显式枚举合法迁移;`TaskStore.updateStatus` 额外守卫「终态只能由显式 continue/rework 重新进入」。 - `errorType`:`timeout | spawn | setup_failed | agent_failed | verify_failed | cancelled | interrupted | agent_unresolved | internal`。 (`setup_failed`=setup 阶段的**逻辑性**失败(模式未就绪/项目未绑定/模型不可用),重试换环境不会成功; `spawn`=进程/安装/CDP 等基础设施失败。由适配器在 `AgentRunResult.errorType` 自归类,编排器缺省按 `spawn`。) ### 5.2 持久化与崩溃恢复 双写策略,两者职责不同: - **`task.jsonl`(权威事件流)**:追加写,事件名如 `created / queued / started / verify_start / verify_round / fix_start / succeeded / failed / needs_attention / needs_user / continued / cancel_requested / cancelled / interrupted / timeout_killed / note`。读取时跳过损坏行。 - **`task.json`(查询快照)**:原子写(临时文件 + rename),供快速读取与崩溃后重建。 并发写保护:`TaskStore` 用 `statusWriteTails` 把同一任务的写操作串成链,避免状态乱序;`waitForStatusWrite()` 是**读屏障**——保证查询/取消不会在 JSONL 事件落盘之前就观察到内存里的终态。 **崩后恢复策略是「归档而非续跑」**:`TaskManager.initialize()` 扫描遗留的活动态任务,一律标 `interrupted` + `abortSource="shutdown"`。理由:GUI 会话与子进程已随 server 退出而失联,静默续跑会产生无法归因的半成品。恢复动作必须由人显式 `manage_task(action="rework")` 触发。 ### 5.3 队列与并发 | 机制 | 规则 | |---|---| | 全局并发闸 | `concurrency.maxRunning`(默认 2,可被 `maxRunningOverride` 覆盖) | | 每项目串行 | 队列按 `queueKeyOf()` 分桶:项目模式用规范化 `projectPath`,无项目模式用常量键 `__zcode_default_workspace__`(绝不用 `undefined` / `""`) | | 调度 | `pump()`:只要 `runningCount < maxRunning`,就为每个「队首可用」的项目启动一个任务;`projectBusy()` 阻止同项目第二个任务并发 | | 任务超时护栏 | 编排层超时之外,`startTask` 另加 `taskTimeoutMs + 15s` 的兜底计时器,防编排层自身挂死 | ### 5.4 取消语义(分状态) | 取消时状态 | 行为 | |---|---| | `queued` | 直接从队列移除 → `cancelled` | | `needs_user` | 直接 → `cancelled`;终态文案明示「GUI 内等待中的会话未被停止」(MCP 侧无 CDP 连接) | | 活动态 | `markCancelRequested` → abort → 有界轮询等待终态(`CANCEL_SETTLE_TIMEOUT_MS = 30s`);未确认停止时返回 `settled:false` | | 终态 | 无取消动作;若带 GUI 待确认标记(`guiResidualUnconfirmed`)则由本次调用**人工确认清除**(不改终态,见 §5.5) | GUI agent 的取消是**尽力而为且诚实回报**,但各适配器能力不同: | 适配器 | 取消时是否点界面停止 | 是否回传 `guiStop` | |---|---|---| | Codex / Kimi Code / Qoder CN | 点停止按钮 + `cancelWaitMs` 内有界等待空闲 | 是(`idle=false` 时终态必须明示未确认停止) | | ZCode / TraeWork | **不点停止按钮**,只终止 MCP 侧观察并保留实例 | 否(终态文案按「无停止结果可确认」如实披露) | 关停路径 `shutdownInterrupt()`(issue #14):abort 全部控制器 → **spawn 类每任务有界等待 2s;GUI 类等到共享的 `shutdown.guiStopWaitMs`(默认 15s)全局 deadline** → 把 `running` 与 `queued` 一并标 `interrupted`。GUI 任务的终态文案按 `guiStop` 如实分流(已确认停止 / 未确认停止 / 无停止结果),**绝不写只对 spawn 子进程成立的「进程已终止」**。 ### 5.5 GUI 终态的事实字段与人工确认(issue #14) | 字段 | 语义 | 写入点 | |---|---|---| | `TaskMeta.guiStop` | 最近一次 abort 的 GUI 侧停止结果 `{clicked, idle}`(**所有** GUI agent 都落盘) | `abortTerminal()` / 运行结果落盘 | | `TaskMeta.interruptedCleanStop` | 本次 interrupted 是否**已确认**停止(仅 `guiStop.idle === true` 为 true) | `abortTerminal()` / `persistInterrupted()` | | `TaskMeta.guiResidualUnconfirmed` | 重启归档的 GUI 遗留任务待人工确认残留(重启时无任何连接可确认,故无条件置 true) | `initialize()`;由 `manage_task(action="cancel")` 清除 | | meta 块 `guiStopUnconfirmed` | 读侧单一判据:`guiResidualUnconfirmed===true \|\| interruptedCleanStop===false` | `metaFromTask()` | 判定矩阵(红线 8 的单一实现,`guiStopDisclosure()`):`idle===true` → 已确认停止;`idle===false` → 点击过但未确认;**字段缺失 → 无停止结果,同样不得声称已停止**。 窗口名由 `profile.displayName` 派生(`guiAppNameOf()`,剥说明性括号与通用后缀,剥空退回原名),不再按 `agentId` 硬编码。**明确不做**:`initialize()` 不自动 CDP 重连去点停止——重启后无会话锚点、适配器对无归属证明的实例 fail-closed,自动动手风险高于收益。 ### 5.6 幂等键:派单与验收的重试安全(issue #15) `run_task`(有项目与无项目两条派发路径)与 `verify_task` 都接受可选 `idempotencyKey`,实现集中在 `src/tasks/idempotency.ts`(`IdempotencyIndex`),由 `makeHandlers()` 闭包持有——**不进 `AppContext`**,避免牵动全部构造点(含测试假上下文)。 | 决策 | 实现 | |---|---| | 命名空间 | `run_task` / `verify_task` 各自独立(映射键为 `scope\u0000key`) | | 入参摘要 | `canonicalDigest()`:稳定序列化(对象键排序、`undefined` 省略)→ 同键异参 fail-closed;摘要只取「已解析入参 + 规范化路径」,不含运行期默认值与 profile 派生物 | | 命中判定 | `lookup()`:TTL 过期即 miss 并惰性清除;`hit` / `conflict` / `miss` 三态 | | `run_task` 命中 | 恒返回原 `taskId` 与当前 meta(含终态,只读不重派);记录存在但任务快照不可读 → 视为未生效,重新派发 | | `verify_task` 命中 | 已完成 → 返回既有 `reportRound`/结论/报告路径(不重跑);执行中 → 成功结果 + `idempotencyReplay: "in_progress"`(不是 `isError`) | | 并发 | `runExclusive()` 按 `(scope,key)` 串行化「二次判定 → 落映射 → 建任务」,并发同名请求不会各建一个任务 | | 崩溃窗口 | 同一临界区内**先落映射、后建任务**;`TaskManager.submit()` 因此接受可选 `taskId` | | 落盘 | `<数据目录>/idempotency.json`(原子写、TTL 与 `maxEntries` 裁剪);损坏时告警并从任务快照重建一次(依据 `TaskMeta.idempotencyKey/Scope/Digest`) | | 写失败 | fail-open:仍返回已派发的任务,响应与 meta 明示「无法被同键重放」 | | 键隐私 | 键明文只落本地快照与映射文件;日志与事件流只用 `keyDigest()`(sha256 前 8 位) | **「执行中」标记是进程内的**(`reserveInFlight` / `releaseInFlight`):重启后未完成的验收不会被缓存(没有报告可返回,重试即重新执行,如实),也避免映射挂着 `in_progress` 而引擎已死。 **边界**:`verify_task(taskId=…)` 的键**不写入任务快照**(该字段承载任务的派单键,避免覆盖),这一种组合的重建覆盖依赖 `idempotency.json`;不给 `manage_task` / 视觉基准工具加键;不做跨进程分布式幂等(与 visual lock 同一假定)。 ### 5.7 终态通知:webhook 钩子(issue #22) 长任务下调用方原先必须挂屏轮询。本能力在状态跃迁时向配置的 URL 异步 POST 一条 JSON。 | 决策 | 实现与理由 | |---|---| | 钩子点是 `TaskStore.updateStatus()` | 它是状态跃迁的**唯一咽喉**。`TaskOrchestrator.finish()` 只覆盖编排器主导的结束 —— `cancel()` 的 queued 分支、`initialize()` 的重启归档、`shutdownInterrupt()` / `persistInterrupted()` 全都绕过它 | | 配置放全局 `config.json`(`notifications.webhook`) | 通知路由是宿主/传输层关注点,不是项目验收策略;且 `updateStatus` 处只有数据目录 / taskId / logger,无法每次跃迁都廉价读项目配置 | | 「恰好一次」按 `taskId + status + finishedAt` 去重 | **不能**用 `prev !== status` 作门条件:多条路径(cancel 的 queued 分支、shutdownInterrupt)会**先直接改写 `meta.status`** 再调用 `updateStatus`,那时 `prev` 已等于目标状态。`finishedAt` 由 `updateStatus` 在写终态时刷新、并被 `rework`/`continueTask` 清空 —— 故同回合重复写入被抑制,而**返修后的新回合同样状态会再次通知** | | 在 `appendEvent` + `writeSnapshot` **之后**才发 | 先保证本地事实落盘,再对外通知 | | `notify()` 返回 void(fire-and-forget) | 发送与重试全在后台;端点慢或挂掉**不阻塞状态机写入链**,任何异常只记 `warn` | | 只订阅**真终态**为默认 | `needs_attention`(终态)→ `needs_human`;`needs_user`(**非终态**,可被 continue 恢复、之后可能再次进入)单独成类且默认关闭 —— 混为一类会让「默认只推真终态」失效 | | 用全局 `fetch` + `AbortSignal.timeout` + `redirect:"manual"` | 与 `src/visual/services.ts` 同一约定,Node ≥ 20 自带,不引入依赖 | | 可选 HMAC-SHA256 签名 | 对**原始请求体字符串**签名(`X-Tianshu-Signature: sha256=`);接收端可用同一 secret 复算校验 | | notifier 是 `TaskStore` 的**可选**第三参 | 大量测试直接 `new TaskStore(home, logger)`,必须保持向后兼容 | **如实披露**:尽力投递、**不保证送达**,失败只记日志;跨 server 重启或接收端重试仍可能重复送达(建议接收端幂等);请求体含 `projectPath` 与报告文件的本地绝对路径,转发到公网服务前需确认可信。详见 [任务终态通知](docs/notifications.md)。 ### 5.8 细粒度事件流:长任务可观测性(issue #18) `query_task` 原先只能返回粗粒度状态:长任务(尤其 GUI agent 卡在确认弹窗 / 文件选择对话框 / 授权提示)下,调用方无法区分「agent 正在正常工作」与「已卡死等待人工干预」。本能力让适配器在关键节点上报语义事件。 词表定义在 `src/agents/agent-events.ts`(**零依赖**,避免 `adapter.ts` / `tasks/task.ts` / `tasks/task-store.ts` 之间的循环引用): | 事件 | 语义 | |---|---| | `task_dispatched` | 指令已确认送达 agent | | `confirmation_dialog_detected` | 检测到确认类对话框(含残留弹窗清理、原生「选择文件夹」) | | `awaiting_user_authorization` | 等待用户授权 / 登录 / 确认 | | `file_modification_started` | agent 开始执行(**启发式**:界面运行信号首次出现,不声称文件已改动) | | `rework_triggered` | 验收失败后进入返修(引擎侧统一上报,`mode` 区分 auto/manual) | | 决策 | 实现与理由 | |---|---| | 钩子归属 | `AgentRunOptions.onEvent`(`src/agents/adapter.ts`),**不是** agent profile——`agent-profiles.json` 是纯 JSON,装不下函数,硬塞会破坏 `AgentProfilesFileSchema` 解析与热重载。「可选」由 `opts.onEvent?.()` + `makeEmitter` 表达 | | 存储 | 写**既有** `task.jsonl`(复用 `TaskStore.appendEvent`),**不建内存环形缓冲**:GUI 长任务中宿主可能重启,纯内存队列会丢掉最需要的现场;并行流会产生第二个事实来源与排序不一致 | | 内存有界 | 在**读取侧**:新增 `readTextTail(p, maxBytes)`(`src/util/fs.ts`)+ `TaskStore.readRecentAgentEvents(taskId, limit, maxBytes=64KiB)`,只读尾部窗口,内存占用与文件总大小解耦。`readTextTail` 保证从完整行开始(截断点落在换行符上时不丢整行) | | 上报健壮性 | 适配器一律经 `makeEmitter`:未提供钩子时空操作,且**吞掉上报异常**——事件上报属观测能力,绝不允许影响任务本体 | | 暴露 | `query_task` 的 `eventLimit`(1..50,缺省 10);`MetaBlockFields.recentEvents`(经 `metaFromTask(meta, extra)` 的 `...extra` 透传)+ 文本区「最近事件」段落。未上报的适配器返回空数组,其余字段与 v0.6.2 一致 | | 本版范围 | 只有 **codex** 与 **traework** 真正上报(各 4 个发射点);zcode / kimicode / qoder 与全部 CLI 适配器保留接口、暂不上报 | | 与 `note` 的关系 | `note` 语义不变,仍是进度 / 审计通道(承载 `progressSummary` / `lastRunSignal`);`recentEvents` 只过滤词表内 5 类,不混入 `note` | **如实披露**:事件属观测能力而非交付保证——不保证送达,`query_task` 只反映「最后一次落盘的事件」;`file_modification_started` 是启发式推断,确切改动证据请看验收报告的 `changedFiles` / `diffstat`。详见 [事件流](docs/event-stream.md)。 ### 5.9 阻塞等待原语(`wait_task`,issue #28;v0.9.0 合并 `wait_any`) 回合驱动的调用方(天枢 agent 会话)只在收到用户消息的回合内运行,无法自行轮询——`run_task` 秒回 `taskId` 后,「谁在任务完成时叫醒会话」成为工具面的真实空白。本能力以**一次阻塞式只读调用**承载等待。 | 决策 | 实现与理由 | |---|---| | 停点定义 | `isWaitSettled(status) = isTerminal(status) \|\| status === "needs_user"`(`src/tasks/task.ts`,单一判定点)。任务**停止推进**的时刻即应唤醒调用方:`needs_user` 非终态但已停等人工(可被 `manage_task(action="continue")` 恢复,之后可能再次进入)——不等它,wait 会空等到 timeout,调用方对「任务在等人」一无所知 | | 等待核心 | `src/tasks/wait.ts` 的 `waitForStops(taskIds, getMeta, {timeoutMs, pollIntervalMs=500, signal})`:**纯逻辑、依赖注入 `getMeta`**,不触文件系统 / TaskManager 构造,可独立单测;`TaskManager.waitForStops` 只做接线(注入 `(id) => this.getMeta(id)`,复用 `waitForStatusWrite` 屏障 + 内存优先 + 快照兜底) | | 只读与无损 | 等待期间**不写任何任务状态、不动任务本体**;被客户端截断 / 连接中断 / 超时都不影响任务继续执行——最坏结果只是调用方多调几次,重连后 `query_task` 即拿到最新事实 | | 超时策略 | `timeoutMs` 缺省 `WAIT_TASK_TIMEOUT_DEFAULT_MS`(50s,低于生态常见 60s 客户端超时,留序列化/往返余量);显式上限 `WAIT_TASK_TIMEOUT_MAX_MS`(600s),超上限**钳制并如实披露**(不静默改值,`clampWaitTimeout`)。超时返回体引导循环调用(每轮 ≈50s,长任务靠多次调用) | | 中断感知 | SDK 的 `RequestHandlerExtra.signal`(`server.ts` 把 `extra` 透传给 handler)在连接关闭 / 请求取消时触发,等待循环立即退出、不泄漏后台等待(SDK `_onclose` 会 abort 全部 in-flight handler) | | `wait_task` 批量模式返回语义 | 按 `taskIds` **数组顺序**返回首个处于停点的任务(确定性优先,不做 `finishedAt` 排序);入口预检全部 id 存在,缺一即 fail-closed 报错并列出缺失 id | **已知限制**:等待是**进程内**的——server 重启后原 wait 调用随连接终止(调用方重连后 `query_task` 复核);单次调用等待上限 600s,更长场景靠循环调用(无损)。详见 [等待原语](docs/wait-task.md)。 --- ## 6. 编排:自动验收与自动返修闭环 `TaskOrchestrator`(`src/loop/fix-loop.ts`)是单任务的执行主体。 ```text 启动 ├─ 采集 git 基线 + 冻结/核对视觉快照 │ └─ 视觉错误 → needs_attention(verify_failed) + pendingVisualVerification ├─ 若为阻塞任务恢复:先重新验收(通过即结束,不进返修,不浪费轮次) └─ 返修循环(round = meta.roundsUsed,maxRounds = meta.autoFixRounds) ├─ status=running → buildCtx(meta, round, feedback) → 持有项目锁 → runAgentOnce │ runAgentOnce: adapter.run 存在 → 调用它;否则 runChild + parseExit ├─ agent 结果分支 │ needs_user → needs_user(等待 manage_task(action='continue')) │ idle/timeout/cdp 断开 → needs_attention │ hardFailure → failed(spawn) # 基础设施/认证错误,不进验收 │ timeout / killed → 对应终态 ├─ 验收(runVerifyOnce):roundsUsed = round + 1,记录报告与变更集 └─ 终止判定 blockingIssues → needs_attention + pendingVisualVerification verdict.passed → succeeded maxRounds > round → fixing,round += 1,写修复计划,continue maxRounds == 0 → failed(verify_failed) + 返修提示 轮次耗尽 → needs_attention ``` **失败信息如何回到 agent**——两条路径,按 agent 分派: | 路径 | 产物 | 反馈内容 | |---|---|---| | Codex | `<项目>/.zcode/plans/codex-fix-r.md` | 从报告抽取失败证据拼进修复提示 | | 其他(含 CLI) | `<任务目录>/rework--r.md` | 标题 + 计划路径 + `verifySummary` + 报告路径 | **关键设计点**:`hardFailure` 与「验收失败」被严格区分。前者是环境/认证/启动问题,进验收与返修毫无意义,直接终态失败;只有 agent 真正跑完才进入验收与返修记账。 **`pendingVisualVerification` 的意义**:因缺少已批准基准或基准被改动而阻塞的任务,在 `manage_task(action="rework")` 恢复时**先重新验收**,通过即结束。这避免了「规则问题被当成代码问题」白烧一轮 agent。 --- ## 7. 验收引擎 `AcceptanceEngine.runVerify()`(`src/verify/acceptance.ts`)串行执行以下阶段,任一致命错误即 fail-closed: ```text 0. 互斥键 withVisualLock(task:) → withVisualLock(realpath(projectPath));同任务重复验收直接拒绝 1. 基线 采集或复用 git 基线(verify_task 复用任务已存基线) 2. 解析检查项 优先级:extraChecks > 项目 .tianshu-mcp/acceptance.json > projects.json 的 verify > 按项目类型推导的默认集 3. 快照冻结 #1 冻结视觉规则/基准摘要(**与 visual.enabled 无关,恒定执行**) 4. git 检查 内置 git-diff-check 最先执行(git diff --check,覆盖基线→HEAD 与工作区) 5. 命令检查 串行或受限并行(verifyConcurrency,默认 2,钳制 1..4) 并行时各写 verify-.parts/NNN-.log,事后按声明顺序合并回主日志 6. 视觉检查 仅当 acceptance.json 开启 visual.enabled;命令检查之后跑(读 build 产物) 7. 代码分析 相对基线归因变更集 → 变更清单 / diffstat / 可疑标记 8. 变更闸门 git 项目「零净变更」判失败(requireChanges 默认 true,可关)→ 追加 no-changes 检查项 9. 快照核对 #2/#3 命令后与视觉后再各核对一次;摘要漂移即 VISUAL_INTEGRITY ``` > **注意顺序**:内置 `git-diff-check` 在**配置的检查项之前**执行,视觉检查在**命令检查之后**执行(页面往往需要先 build)。 > 快照冻结与核对**不受 `visual.enabled` 控制**——`enabled` 只决定是否真的跑截图/规格/内容判定;也就是说, > 「视觉未启用」并不等于「完全没有视觉相关动作」,而是「不做视觉比对,但仍会冻结并核对摘要」。 > 这样改验收配置或基准文件本身也会被检出(`VISUAL_INTEGRITY`)。 **检查项自动推导**(`deriveDefaultChecks`):读 `package.json` 的 `typecheck / lint / test / build` 脚本 → 映射为 `npm run