tianshu-mcp 0.6.5 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.en.md CHANGED
@@ -8,6 +8,60 @@ Chinese version: [CHANGELOG.md](CHANGELOG.md)
8
8
 
9
9
  ---
10
10
 
11
+ ## [0.6.7] - 2026-09-24
12
+
13
+ ### Added
14
+
15
+ - **Terminal-state webhook notifications** ([issue #22](https://github.com/lanlan0811/tianshu-mcp/issues/22)): when a task completes, fails or enters `needs_attention`, a JSON body is **asynchronously POSTed** to a configured URL, cutting the babysitting cost of long tasks. See [task notifications](docs/notifications.en.md).
16
+ - **New `notifications.webhook` in the global `config.json`**: `enabled` (default false) / `url` / `timeoutMs` / `maxRetries` / `backoffMs` / `secret` (HMAC-SHA256 signing) / `events`.
17
+ - **`src/tasks/notifier.ts`**: `TaskNotifier` (fire-and-forget, retry + backoff + timeout, warnings only on failure) and the `statusToEvent()` mapping.
18
+
19
+ ### Changed
20
+
21
+ - `TaskStore`'s constructor gained an **optional** third argument, `notifier`; terminal notifications are dispatched inside `updateStatus`'s write closure **after** `appendEvent` + `writeSnapshot` succeed. Existing `new TaskStore(home, logger)` calls are entirely unaffected.
22
+ - `src/server.ts` assembles the `TaskNotifier` (reading the same `config.json` lazily, so config edits take effect while running).
23
+
24
+ ### Compatibility
25
+
26
+ - **Off by default**: unset or `enabled:false` sends **no requests at all**, behaving exactly as v0.6.6.
27
+ - **No tool contract, data model or MCP annotation changes**; only a new **optional** config section.
28
+
29
+ ### Notes (disclosed honestly)
30
+
31
+ - **The hook point is `TaskStore.updateStatus()`** (the single state-transition choke point), **not** `TaskOrchestrator.finish()` — the latter only covers orchestrator-driven ends, while `cancel()`'s queued branch, `initialize()`'s restart archiving, and `shutdownInterrupt()` / `persistInterrupted()` all bypass it.
32
+ - **"Exactly once" dedupes on `taskId + status + finishedAt`; `prev !== status` cannot be used**: several paths **write `meta.status` directly first** and only then call `updateStatus`, at which point `prev` already equals the target state. `finishedAt` is refreshed by `updateStatus` on a terminal write and cleared by `rework`/`continueTask`, so repeated writes for one episode are suppressed while **a new episode after rework notifies again**. Delivery can still repeat across a server restart or a receiver retry, so receivers should dedupe on the same key.
33
+ - **Only true terminal states are pushed by default**: `needs_attention` (terminal) → `needs_human`; `needs_user` (**non-terminal**, restorable via `continue_task`, possibly re-entered) is a separate class that is **off by default** — callers who want it must add it to `events` explicitly (knowing it will repeat). `cancelled` is likewise off by default.
34
+ - **Best-effort, not guaranteed**: sending is fully asynchronous, so a slow or dead endpoint **does not block the state machine**; failures (network error / timeout / non-2xx) are retried `maxRetries` times and then only logged as a single `warn` — they **never change a task's terminal state**.
35
+ - **The body contains local paths**: it includes `projectPath` and absolute paths to acceptance report files; confirm the receiver is trusted before forwarding to a public service.
36
+ - **`enabled=true` without `url` is rejected by the schema** (no silent "enabled but never sends" confusion); `config.json` follows last-known-good, so a broken file only warns and keeps the previous valid config.
37
+
38
+ ## [0.6.6] - 2026-09-24
39
+
40
+ ### Added
41
+
42
+ - **Dry-run mode `dryRun` for `run_task`** ([issue #21](https://github.com/lanlan0811/tianshu-mcp/issues/21)): the agent analyses and plans only — outputting the file list and approach **without touching source** — and the acceptance engine performs static analysis only (do the referenced files exist, do the proposed edit locations exist, any obvious logical conflicts), skipping typecheck/test/build. See [dryRun mode](docs/dry-run.en.md).
43
+ - **`src/verify/dry-run.ts`**: plan schema (`path` / `action` / `reason` / `edits`), the static checks, and the report / plan-document renderers.
44
+ - **A review-then-do loop**: the plan document produced by a dry run lands **inside the project** at `.tianshu-mcp/dry-run-plan-<taskId>.md`, with `meta.dryRunPlanDoc` giving the project-relative path, directly usable as a later real `run_task`'s `planDoc`.
45
+
46
+ ### Changed
47
+
48
+ - **`TaskMeta` gained `dryRun` / `dryRunReportMd` / `dryRunReportJson` / `dryRunPlanMd`**; `metaFromTask` exposes `dryRun` / `dryRunReportFiles` / `dryRunPlanDoc`.
49
+ - Report artifacts are **strictly separated**: a dry run writes `dry-run-report-<round>.md` / `.json` (the JSON carries `kind: "dry-run"`) and **never touches** `report-<round>.*`, so it consumes no acceptance round and does not pollute the regular report list.
50
+
51
+ ### Compatibility
52
+
53
+ - **`dryRun` is a new optional argument, off by default**: when omitted, `run_task` behaves exactly as in v0.6.5.
54
+ - No tool contract or breaking data-model changes; MCP annotations unchanged.
55
+
56
+ ### Notes (disclosed honestly)
57
+
58
+ - **The verdict is `needs_attention`, not `failed`**: a bad plan needs a human decision, not automatic rework; a dry run **never enters the rework loop**, **ignores `autoVerify`** and **requires `projectPath`** (project-less mode rejects it explicitly).
59
+ - **A missing plan degrades but stays visible**: `planExtracted: false` plus a `fallbackReason` stating why, with checks degrading to the zero-change gate only; both the report and the message state "plan extraction: failed" honestly instead of passing silently.
60
+ - **The zero-change gate is the core evidence**: diffing against the pre-work baseline, changes remaining after excluding MCP-owned artifacts (the plan file and any `planDoc` named in the task book) yield `dry_run_violation` (blocking). It **does not depend on the plan being correct** — it still works when the agent produces no plan at all.
61
+ - **The agent is not guaranteed to obey the read-only constraint**: that relies on explicit task-book instructions plus the post-hoc gate. **Violations are caught and reported honestly, but changes that already happened are not rolled back** (the MCP never auto-commits, auto-stashes or auto-checks-out).
62
+ - **Static checks cannot judge whether a plan is sensible**: they only verify "the file exists, the location matches, no obvious contradiction" — which is exactly what the human review step is for.
63
+ - **Adapter differences around `planDoc`**: it is currently consumed only by the **Codex and Qoder CN** prompt builders; CLI agents and ZCode / Kimi Code / TraeWork do not read it, so for those the plan path must go into the `task` text (the file is inside the project, so they can read it). This is documented in both `docs/dry-run` files and the README.
64
+
11
65
  ## [0.6.5] - 2026-09-24
12
66
 
13
67
  ### Added
package/CHANGELOG.md CHANGED
@@ -7,6 +7,60 @@
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.6.7] - 2026-09-24
11
+
12
+ ### 新增
13
+
14
+ - **任务终态 webhook 通知**([issue #22](https://github.com/lanlan0811/tianshu-mcp/issues/22)):任务完成 / 失败 / 进入 `needs_attention` 时向配置的 URL **异步 POST** 一条 JSON,降低长任务盯屏成本。详见 [任务终态通知](docs/notifications.md)。
15
+ - **全局 `config.json` 新增 `notifications.webhook`**:`enabled`(默认 false)/ `url` / `timeoutMs` / `maxRetries` / `backoffMs` / `secret`(HMAC-SHA256 签名)/ `events`。
16
+ - **`src/tasks/notifier.ts`**:`TaskNotifier`(fire-and-forget、重试 + 退避 + 超时、失败仅告警)与 `statusToEvent()` 映射。
17
+
18
+ ### 变更
19
+
20
+ - `TaskStore` 构造函数新增**可选**第三参 `notifier`;终态通知在 `updateStatus` 写闭包内 `appendEvent` + `writeSnapshot` **成功之后**派发。既有 `new TaskStore(home, logger)` 调用完全不受影响。
21
+ - `src/server.ts` 装配 `TaskNotifier`(惰性读取同一份 `config.json`,故运行中改配置也能生效)。
22
+
23
+ ### 兼容性
24
+
25
+ - **默认关闭**:不配置或 `enabled:false` 时**完全不发起任何请求**,行为与 v0.6.6 一致。
26
+ - **无工具契约、数据模型或 MCP 注解变更**;仅新增一个**可选**的 config 段。
27
+
28
+ ### 说明(如实披露)
29
+
30
+ - **钩子点是 `TaskStore.updateStatus()`**(状态跃迁的唯一咽喉),**不是** `TaskOrchestrator.finish()` —— 后者只覆盖编排器主导的结束,`cancel()` 的 queued 分支、`initialize()` 的重启归档、`shutdownInterrupt()` / `persistInterrupted()` 全都绕过它。
31
+ - **「恰好一次」按 `taskId + status + finishedAt` 去重,不能用 `prev !== status`**:多条路径会**先直接改写 `meta.status`** 再调用 `updateStatus`,那时 `prev` 已等于目标状态。`finishedAt` 由 `updateStatus` 在写终态时刷新、并被 `rework`/`continueTask` 清空 —— 故同回合重复写入被抑制,而**返修后的新回合会再次通知**。跨 server 重启或接收端重试仍可能重复送达,建议接收端按同一键幂等。
32
+ - **默认只推真终态**:`needs_attention`(终态)→ `needs_human`;`needs_user`(**非终态**,可被 `continue_task` 恢复、之后可能再次进入)单独成类且**默认不订阅**,需要它的调用方须显式加入 `events`(已知会反复推送)。`cancelled` 同样默认关闭。
33
+ - **尽力投递,不保证送达**:发送全异步,端点慢或挂掉**不阻塞状态机**;失败(网络错误 / 超时 / 非 2xx)重试 `maxRetries` 次后仅记一条 `warn`,**绝不改变任务终态**。
34
+ - **请求体含本地路径**:包含 `projectPath` 与验收报告文件的绝对路径;转发到公网服务前请确认接收端可信。
35
+ - **`enabled=true` 但缺 `url` 会被 schema 拒绝**(禁止「开了却不发」的静默混淆);`config.json` 走 last-known-good,写坏只告警并沿用上一份有效配置。
36
+
37
+ ## [0.6.6] - 2026-09-24
38
+
39
+ ### 新增
40
+
41
+ - **`run_task` 干跑模式 `dryRun`**([issue #21](https://github.com/lanlan0811/tianshu-mcp/issues/21)):agent 只分析规划、输出将要修改的文件清单与方案、**不动源码**;验收引擎只做静态分析(引用文件是否存在、拟改位置是否存在、明显逻辑冲突),跳过 typecheck/test/build。详见 [dryRun 干跑模式](docs/dry-run.md)。
42
+ - **`src/verify/dry-run.ts`**:计划 schema(`path` / `action` / `reason` / `edits`)、静态检查、报告与方案文档渲染。
43
+ - **「先审后做」闭环**:dryRun 产出的方案文档落在**项目内** `.tianshu-mcp/dry-run-plan-<taskId>.md`,`meta.dryRunPlanDoc` 给出项目相对路径,可直接作为后续正式 `run_task` 的 `planDoc`。
44
+
45
+ ### 变更
46
+
47
+ - **`TaskMeta` 新增 `dryRun` / `dryRunReportMd` / `dryRunReportJson` / `dryRunPlanMd`**;`metaFromTask` 暴露 `dryRun` / `dryRunReportFiles` / `dryRunPlanDoc`。
48
+ - 报告产物**严格分离**:dryRun 写 `dry-run-report-<round>.md` / `.json`(json 带 `kind: "dry-run"`),**不碰** `report-<round>.*`,因此不消耗验收轮次、也不污染常规报告列表。
49
+
50
+ ### 兼容性
51
+
52
+ - **`dryRun` 是新增可选参数,默认关闭**:不传时 `run_task` 行为与 v0.6.5 完全一致。
53
+ - 无工具契约、数据模型破坏性变更;MCP 注解不变。
54
+
55
+ ### 说明(如实披露)
56
+
57
+ - **判定为 `needs_attention` 而非 `failed`**:方案有问题属人工裁决,不是可以自动返修的代码缺陷;dryRun **不进入返修循环**、**忽略 `autoVerify`**、**需要 `projectPath`**(无项目模式显式拒绝)。
58
+ - **计划缺失时降级但可见**:`planExtracted: false` + `fallbackReason` 写明原因,检查降级为仅零改动门禁;报告与文案都如实标注「计划提取: 失败」,不静默通过。
59
+ - **零改动门禁是核心证据**:相对动工前基线求差,排除 MCP 自有产物(计划文件、任务书点名的 planDoc)后仍有变更 → `dry_run_violation`(阻断)。它**不依赖计划写对** —— agent 完全不产出计划时这条仍然有效。
60
+ - **不保证 agent 遵守只读约束**:靠任务书里的明确指令 + 事后门禁。**违反会被拦下并如实报告,但已发生的改动不会自动回滚**(MCP 从不自动 commit / stash / checkout)。
61
+ - **静态检查无法判断方案是否合理**:只能验证「文件存在、位置对得上、无明显矛盾」——那正是「先审」要人工做的事。
62
+ - **`planDoc` 的适配器差异**:目前只由 **Codex 与 Qoder CN** 的提示词构造消费;CLI 类 agent 与 ZCode / Kimi Code / TraeWork 不读取它,对这些 agent 需把方案路径写进 `task` 文本(文件在项目内,它们能读)。这一点已写入 `docs/dry-run` 双语与 README。
63
+
10
64
  ## [0.6.5] - 2026-09-24
11
65
 
12
66
  ### 新增
package/README.en.md CHANGED
@@ -41,6 +41,7 @@ Tianshu plays the role of the overall commander; this MCP server is the **schedu
41
41
  - **Async contract**: `run_task` returns a `taskId` immediately; long-running work is polled via `query_task` (never blocks `tools/call`).
42
42
  - **Long-task observability (issue #18)**: adapters report fine-grained events at key nodes (`task_dispatched` / `confirmation_dialog_detected` / `awaiting_user_authorization` / `file_modification_started` / `rework_triggered`), and `query_task` returns the most recent N via `eventLimit` (default 10) — **so you can tell "the agent is working" apart from "stuck on a dialog waiting for a human"**. Event reporting is an optional capability: adapters that don't implement it behave unchanged. codex and traework report in this version. See [event stream](docs/event-stream.en.md).
43
43
  - **Structured repair directives (issue #19)**: failed rounds parse the reasons into **directly executable actions** (`file:line / issue / action`) carried in both the repair plan and the rework message, sparing the agent the cost of locating problems in a full narrative report; when extraction is unavailable it **explicitly falls back** to the full report (never a silent gap). `rework_task` also accepts an optional `repairHint`. See [structured repair directives](docs/repair-directives.en.md).
44
+ - **Dry-run mode (issue #21)**: `run_task(dryRun=true)` makes the agent **analyse and plan only — outputting the file list and approach without touching source**; the acceptance engine then does static analysis only (do the referenced files exist, do the proposed edit locations exist, any obvious logical conflicts) and skips typecheck/test/build. A bad plan yields `needs_attention` (a human decision), never enters automatic rework, and **consumes no acceptance round**. Two separate artifacts: a static-analysis report plus a plan document inside the project (`meta.dryRunPlanDoc`, directly usable as a later real task's `planDoc`), forming a review-then-do loop. See [dryRun mode](docs/dry-run.en.md).
44
45
  - **Objective acceptance**: automated command checks (typecheck/lint/test/build — skipped when absent, plus tech-stack derivation) + programmatic code analysis (changed-file list / diffstat / suspicious signals such as TODO, debugger, secret-like patterns), all relative to a **git baseline**; never auto-commits or stashes. The acceptance engine is **fail-closed**: a test check fails when its output reports zero executed tests even if the exit code is 0; git projects must produce changes relative to the pre-work baseline by default (pure analysis tasks can opt out with `"requireChanges": false` in `.tianshu-mcp/acceptance.json`).
45
46
  - **Acceptance parallelism**: command checks run **bounded-parallel** by default (`verifyConcurrency`, default 2, range 1–4). When checks depend on an order (a later check reading build output, `--fix`, shared cache dirs), set it to `1` for fully serial behaviour; a project can override it in `.tianshu-mcp/acceptance.json`, and the server level lives in `config.json`. Report and log formats are unchanged (results are returned in declaration order).
46
47
  - **Three-level acceptance config inheritance (issue #20)**: `<data home>/acceptance.default.json` (global fallback) → `<project>/.tianshu-mcp/acceptance.json` (project override) → the `acceptanceOverride` argument of `run_task`/`verify_task` (transient task override, applying only to that task and never written to disk as config). For the same field a higher layer wins; arrays replace wholesale rather than concatenating. Inspect the final effective configuration with `tianshu-mcp config acceptance <projectPath> [--task <id>]`. See the [acceptance config spec](docs/acceptance-config.en.md).
@@ -50,6 +51,7 @@ Tianshu plays the role of the overall commander; this MCP server is the **schedu
50
51
  - **Idempotent retries (issue #15)**: `run_task` / `verify_task` accept an optional `idempotencyKey` — a retry with the same key within the TTL (24 h default) never duplicates a dispatch (the original `taskId` and its current status are returned) or re-runs verification (a running pass answers "in progress", a finished one returns the existing report); the same key with different arguments fails closed. The mapping is persisted in `<data-dir>/idempotency.json` and survives a server restart. See the [v0.5.10 release notes](<docs/release-v0.5.10.en.md>).
51
52
  - **Skill self-install (issue #16)**: on startup the **in-package** `skills/tianshu-mcp/` is synced idempotently to `~/.rivet/skills/tianshu-mcp/`. The source is located relative to the package via `import.meta.url` (no cwd content discovery); an install manifest inside the target lets it auto-upgrade **only when the copy is provably untouched**, while **detected local edits or an unknown source are kept with a warning**; overwrites go through an atomic "tmp dir → backup → swap in" path and are governed by `skills.autoInstall` (`true`/`"prompt"`/`false`) and `skills.backupKeep`. See [README §Skill self-install](#skill-self-install).
52
53
  - **Scheduling discipline**: per-project serial queue + global concurrency cap (default 2, configurable); even without an idempotency key, `run_task` names the unfinished task in the same workspace so a retry is not mistaken for a fresh dispatch.
54
+ - **Terminal-state notifications (issue #22)**: an optional `notifications.webhook` (global `config.json`) **asynchronously POSTs** a JSON body (with `taskId` / `event` / `status` / timestamp / report paths) to a URL when a task completes, fails or enters `needs_attention`, with optional HMAC-SHA256 signing. **Off by default**, and a failed send only logs — it **never affects the state machine**. Only true terminal states are pushed by default; `needs_user` (non-terminal, can repeat) must be subscribed explicitly. See [task notifications](docs/notifications.en.md).
53
55
  - **Optional AI content validation (v0.5.4, off by default)**: validates whether the **content** of an image or page screenshot matches an expectation you declare explicitly. Judgement is fully **delegated to a local command you supply** (the MCP reads, stores, and forwards no keys and ships no model client), it **warns only** by default and can be upgraded to failing per rule, and it debounces with majority sampling plus a task-level cache; split votes or confidence below the threshold yield `uncertain`, which never gates and never triggers rework. Configuration and the command contract are in [visual acceptance](docs/visual-acceptance.en.md).
54
56
  - **No key handling**: each agent uses its own login state; this server never stores or forwards any API key. Optional AI content validation adds no credential management either — the judge command manages its own key (see [SECURITY.en.md](SECURITY.en.md)).
55
57
  - **Extensible**: a new agent = one profile (data) + (if needed) one adapter file — no changes to the orchestration core.
@@ -189,7 +191,7 @@ run_task(projectPath=D:/xxx/my-app, agentId=qoder, planDoc=./plans/development.m
189
191
 
190
192
  | Tool | Capability / approval | Purpose |
191
193
  |---|---|---|
192
- | `run_task` | write + approval | Dispatch work (optional auto-verify / auto-rework); returns `taskId` asynchronously. Optional `idempotencyKey` (a retry with the same key returns the original `taskId` instead of creating a task) and `acceptanceOverride` (transient acceptance-config override applying only to this task) |
194
+ | `run_task` | write + approval | Dispatch work (optional auto-verify / auto-rework); returns `taskId` asynchronously. Optional `idempotencyKey` (a retry with the same key returns the original `taskId` instead of creating a task), `acceptanceOverride` (transient acceptance-config override applying only to this task) and `dryRun` (dry-run mode: plan only without touching source; its artifact feeds a later `planDoc`) |
193
195
  | `continue_task` | write + approval | Resume the session behind `needs_user` (ZCode resumes the recorded session; Codex re-observes for `user_confirmation` / re-dispatches for `login_required`; Kimi Code resumes the recorded session and distinguishes question answering / re-observation / full re-dispatch) |
194
196
  | `query_task` | read | Poll status / progress / log tail / recent fine-grained events. The optional `eventLimit` (1..50, default 10) controls how many `recentEvents` the meta block carries, so long tasks can be told apart as "working normally" versus "stuck on a dialog waiting for a human" |
195
197
  | `list_tasks` | read | Filtered history of tasks |
package/README.md CHANGED
@@ -45,11 +45,13 @@
45
45
  - **验收配置三级继承(issue #20)**:`<数据目录>/acceptance.default.json`(全局兜底)→ `<project>/.tianshu-mcp/acceptance.json`(项目覆盖)→ `run_task`/`verify_task` 的 `acceptanceOverride` 参数(任务级临时覆盖,仅当次生效、不落盘为配置)。同名字段高优先级取胜、数组整体覆盖不拼接。用 `tianshu-mcp config acceptance <projectPath> [--task <id>]` 查看最终生效配置。详见 [验收配置规范](docs/acceptance-config.md)。
46
46
  - **失败返修闭环**:自动返修(`autoFixRounds`)+ 手动 `rework_task`;验收失败时自动生成修复计划文件并回填给 agent;轮次用尽 → `needs_attention` 等天枢裁决。
47
47
  - **结构化修复指令(issue #19)**:失败轮次会把原因解析为**可直接执行的动作**(`文件:行 / 问题 / 做什么`),随返修计划与返修消息一起喂给 agent,省去它从整篇报告里定位的开销;提取不到时**显式回退**到完整报告(不静默留空)。`rework_task` 另可选 `repairHint` 自带提示。详见 [结构化修复指令](docs/repair-directives.md)。
48
+ - **干跑模式 dryRun(issue #21)**:`run_task(dryRun=true)` 让 agent **只分析规划、输出将要修改的文件清单与方案、不动源码**;验收引擎只做静态分析(引用文件是否存在、拟改位置是否存在、明显逻辑冲突),跳过 typecheck/test/build。方案有问题 → `needs_attention`(人工裁决),不进入自动返修、**不消耗验收轮次**。产物分两份:静态分析报告 + 项目内的方案文档(`meta.dryRunPlanDoc`,可直接作为后续正式任务的 `planDoc`),构成「先审后做」闭环。详见 [dryRun 干跑模式](docs/dry-run.md)。
48
49
  - **执行面**:`driver: "gui"` 由显式 adapter 驱动桌面 UI(Codex / TraeWork / ZCode / Kimi Code 各自使用隔离的 CDP 流程);`driver: "spawn"` 走外部 CLI 子进程。
49
50
  - **无项目派发(ZCode,issue #12)**:`run_task` 的 `projectPath` 可省略——ZCode 在 `default` 工作区承接任务,不登记/导入项目、不采集 Git 基线、不执行项目验收(结果以 `verificationNotApplicable: "no_project"` 结构化标注,`verify_task`/`get_task_report` 返回不适用说明)。配套 `allowCreateProject: false` 可在目标目录未登记时于任何导入副作用之前停止派发。详见 [ZCode CDP 适配器](docs/zcode-cdp.md)。
50
51
  - **幂等重试(issue #15)**:`run_task` / `verify_task` 接受可选 `idempotencyKey`——同一 key 在 TTL(默认 24h)内的重试**不会**重复派单(恒返回原 `taskId` 与当前状态)或重复跑验收(执行中返回进行中提示,已完成直接返回既有报告);同键异参 fail-closed 报错。映射落盘于 `<数据目录>/idempotency.json`,跨 server 重启仍生效。详见 [v0.5.10 发布说明](<docs/release-v0.5.10.md>)。
51
52
  - **技能自检安装(issue #16)**:启动时把**包内** `skills/tianshu-mcp/` 幂等同步到 `~/.rivet/skills/tianshu-mcp/`。源只由 `import.meta.url` 相对包自身定位(无 cwd 内容发现);目标内含安装清单,据此仅在**可证未被改动**时自动升级,**检出你的本地修改或来源不明一律保留 + 告警**;覆盖走「临时目录 → 备份 → 换入」的原子路径,并按 `skills.autoInstall`(`true`/`"prompt"`/`false`)与 `skills.backupKeep` 治理。详见 [README §技能自检安装](#技能自检安装)。
52
53
  - **调度纪律**:每项目串行队列 + 全局并发上限(默认 2,可配);未传幂等键时,`run_task` 仍会点名同工作区未结束的任务,避免误判为重试。
54
+ - **终态通知(issue #22)**:可选 `notifications.webhook`(全局 `config.json`)—— 任务完成 / 失败 / 进入 `needs_attention` 时向指定 URL **异步 POST** 一条 JSON(含 `taskId` / `event` / `status` / 时间戳 / 报告路径),可选 HMAC-SHA256 签名。**默认关闭**,且发送失败只记日志、**绝不影响状态机**。默认只推真终态;`needs_user`(非终态,可能反复触发)需显式订阅。详见 [任务终态通知](docs/notifications.md)。
53
55
  - **可选 AI 内容校验(v0.5.4,默认关闭)**:校验图片或页面截图**内容**是否符合你显式声明的期望描述。判定完全**委托给你自备的本地命令**(MCP 不读取、不存储、不转发任何密钥,也不内置模型客户端),默认**仅告警**、逐规则可升级为致败;采样多数票 + 任务级缓存防抖,票不集中或低于置信度阈值判 `uncertain`(永不阻塞、不触发返修)。配置与命令契约见 [视觉验收](docs/visual-acceptance.md)。
54
56
  - **不碰密钥**:各 agent 用自己的登录态;本 server 不保存/转发任何 API key。可选 AI 内容校验同样不引入凭证管理——判定命令自己管密钥(见 [SECURITY.md](SECURITY.md))。
55
57
  - **可扩展**:新 agent = 一个 profile(数据)+(如需)一个 adapter 文件,零改编排核心。
@@ -185,7 +187,7 @@ run_task(projectPath=D:/xxx/my-app, agentId=qoder, planDoc=./plans/development.m
185
187
 
186
188
  | 工具 | 能力 / 审批 | 作用 |
187
189
  |---|---|---|
188
- | `run_task` | write + 审批 | 派活(可带自动验收/自动返修),异步返回 `taskId`;可选 `idempotencyKey`(同键重试恒返回原 `taskId`,不新建任务)与 `acceptanceOverride`(任务级临时验收配置覆盖,仅本任务生效) |
190
+ | `run_task` | write + 审批 | 派活(可带自动验收/自动返修),异步返回 `taskId`;可选 `idempotencyKey`(同键重试恒返回原 `taskId`,不新建任务)、`acceptanceOverride`(任务级临时验收配置覆盖,仅本任务生效)与 `dryRun`(干跑模式:只分析规划不改源码,产物可作后续 `planDoc`) |
189
191
  | `continue_task` | write + 审批 | 恢复 `needs_user` 的原会话(ZCode 恢复原会话;Codex 按 `user_confirmation` 重新观察 / `login_required` 重派;Kimi Code 恢复原会话并区分提问续答 / 重新观察 / 补发任务书) |
190
192
  | `query_task` | read | 轮询状态 / 进度 / 日志尾 / 最近细粒度事件。可选 `eventLimit`(1..50,默认 10)控制 meta 的 `recentEvents` 条数,长任务下可区分「正常执行」与「卡在弹窗等人」 |
191
193
  | `list_tasks` | read | 历史任务过滤列表 |
@@ -139,6 +139,15 @@ export const RunTaskParamsSchema = z.object({
139
139
  * 不影响其他任务或项目。不传则完全沿用全局/项目层配置。
140
140
  */
141
141
  acceptanceOverride: PartialAcceptanceConfigSchema.optional(),
142
+ /**
143
+ * 干跑模式(issue #21):agent **只分析、只规划**,输出将要修改的文件清单与方案,
144
+ * 不动源码;验收引擎只做静态分析(引用文件是否存在、拟改位置是否存在、明显逻辑冲突),
145
+ * 跳过 typecheck/test/build 等需要实际改动的检查。
146
+ *
147
+ * 默认关闭(缺省 = 与既有行为完全一致)。**需要 projectPath**:无项目模式没有可静态分析的基线。
148
+ * dryRun 下忽略 `autoVerify`、不进入自动返修。
149
+ */
150
+ dryRun: z.boolean().optional(),
142
151
  });
143
152
  /** query_task 返回的细粒度事件条数默认值(issue #18) */
144
153
  export const QUERY_TASK_EVENT_LIMIT_DEFAULT = 10;
@@ -201,6 +210,44 @@ export const ContinueTaskParamsSchema = z.object({
201
210
  message: z.string().min(1, "message 不能为空"),
202
211
  });
203
212
  /* ---------------- server 配置 config.json ---------------- */
213
+ /**
214
+ * webhook 通知可订阅的事件类别(按任务**状态语义**归类,而非原始 status 字符串)。
215
+ *
216
+ * `needs_human` 与 `needs_user` 刻意分开:前者对应 `needs_attention`(**真终态**,等人工裁决后
217
+ * 任务就结束了),后者对应 `needs_user`(**非终态** —— 它可被 `continue_task` 恢复到 `queued`,
218
+ * 之后可能**再次**进入 `needs_user`)。混为一类会让「默认只推真终态」这条约定失效。
219
+ */
220
+ export const NotificationEventSchema = z.enum([
221
+ "done",
222
+ "failed",
223
+ "needs_human",
224
+ "needs_user",
225
+ "cancelled",
226
+ ]);
227
+ /**
228
+ * 默认订阅集:**只含真终态**。
229
+ * `needs_user` 不是终态,默认关闭;需要它的调用方显式加进 `events`(已知会反复推送)。
230
+ * `cancelled` 同样默认关闭(多数场景无需被取消任务打扰)。
231
+ */
232
+ export const NOTIFICATION_EVENTS_DEFAULT = ["done", "failed", "needs_human"];
233
+ export const WebhookConfigSchema = z
234
+ .object({
235
+ enabled: z.boolean().default(false),
236
+ url: z.string().url().optional(),
237
+ /** 单次请求超时(不阻塞状态机 —— 发送全程异步) */
238
+ timeoutMs: z.number().int().positive().default(5_000),
239
+ /** 失败重试次数上限(0 = 不重试);总尝试次数 = 1 + maxRetries */
240
+ maxRetries: z.number().int().min(0).max(5).default(2),
241
+ /** 重试退避基数:第 n 次重试前等待 backoffMs × n(0 = 不退避) */
242
+ backoffMs: z.number().int().min(0).default(500),
243
+ /** 配置后对请求体做 HMAC-SHA256 签名,附 `X-Tianshu-Signature: sha256=<hex>` */
244
+ secret: z.string().optional(),
245
+ events: z.array(NotificationEventSchema).default(NOTIFICATION_EVENTS_DEFAULT),
246
+ })
247
+ .refine((v) => !v.enabled || (v.url !== undefined && v.url.length > 0), {
248
+ message: "notifications.webhook.enabled=true 时必须提供 url",
249
+ path: ["url"],
250
+ });
204
251
  export const ServerConfigSchema = z.object({
205
252
  concurrency: z
206
253
  .object({
@@ -265,6 +312,20 @@ export const ServerConfigSchema = z.object({
265
312
  .default(IDEMPOTENCY_MAX_ENTRIES_DEFAULT),
266
313
  })
267
314
  .default({}),
315
+ /**
316
+ * 任务状态跃迁的通知钩子(issue #22)。
317
+ *
318
+ * 放在**全局** `config.json` 而非项目 `acceptance.json`:通知路由是宿主/传输层关注点,
319
+ * 不是项目验收策略;一个端点通常按 `taskId` 自行分流即可。且状态跃迁的咽喉
320
+ * (`TaskStore.updateStatus`)只有 `home` / `taskId` / `logger`,无法在每次跃迁时廉价读项目配置。
321
+ *
322
+ * **默认关闭**:不配置或 `enabled:false` 时**完全不发起任何请求**。
323
+ */
324
+ notifications: z
325
+ .object({
326
+ webhook: WebhookConfigSchema.optional(),
327
+ })
328
+ .default({}),
268
329
  });
269
330
  /* ---------------- agent-profiles.json ---------------- */
270
331
  export const ExecutableDiscoverySchema = z.object({
@@ -6,11 +6,12 @@
6
6
  *
7
7
  * 并发闸/队列由 TaskManager 负责;这里只负责单任务推进(支持 AbortSignal 中止)。
8
8
  */
9
- import { readTextSafe, exists, readDirSafe, mkdirp, writeJsonAtomic, readJsonSafe, } from "../util/fs.js";
9
+ import { readTextSafe, exists, readDirSafe, mkdirp, writeJsonAtomic, readJsonSafe, writeTextAtomic, } from "../util/fs.js";
10
10
  import { runChild } from "../agents/spawn.js";
11
11
  import { captureBaseline } from "../verify/git-baseline.js";
12
12
  import { summarizeReport } from "../verify/report.js";
13
13
  import { renderDirectiveLines } from "../verify/directives.js";
14
+ import { DRY_RUN_PLAN_REL_PATH, dryRunPlanDocRelPath, renderDryRunPlanDoc, } from "../verify/dry-run.js";
14
15
  import { writeRepairPlan } from "./repair-plan.js";
15
16
  import { writeCodexFixPlan } from "../agents/codex/fixplan.js";
16
17
  import { buildFixPrompt } from "../agents/codex/input.js";
@@ -189,6 +190,47 @@ export class TaskOrchestrator {
189
190
  if (runRes.killed) {
190
191
  return this.abortTerminal(runRes.guiStop);
191
192
  }
193
+ // ---- issue #21:dryRun 只做静态分析 ----
194
+ // 放在这里(agent 已返回、正常验收之前)的理由:先让 agent 跑完只读预演与计划产出,
195
+ // 再静态核对;硬失败/超时/取消/等人已在上面处理,不会走到这里。
196
+ // dryRun 刻意**忽略 autoVerify**(它本来就是「先审」而不是「验收」),也**不进返修循环**
197
+ // —— 方案有问题属人工决策,不是可以自动返修的代码缺陷。
198
+ if (meta.dryRun) {
199
+ const dryReport = await this.deps.engine.runDryRun({
200
+ taskId: meta.taskId,
201
+ projectPath: meta.projectPath,
202
+ round,
203
+ baseline,
204
+ taskText: meta.task,
205
+ // 允许 agent 写的产物:计划文件本身,以及任务书里点名的 planDoc(若有)
206
+ allowedArtifacts: [
207
+ DRY_RUN_PLAN_REL_PATH,
208
+ ...(meta.planDoc ? [meta.planDoc] : []),
209
+ ],
210
+ store,
211
+ });
212
+ meta.dryRunReportMd = store.dryRunReportMdPath(meta.taskId, round);
213
+ meta.dryRunReportJson = store.dryRunReportJsonPath(meta.taskId, round);
214
+ // 计划渲染成项目内 markdown,供后续正式任务作 planDoc 复用(「先审后做」闭环)
215
+ if (dryReport.plan) {
216
+ const rel = dryRunPlanDocRelPath(meta.taskId);
217
+ const abs = path.join(meta.projectPath, ...rel.split("/"));
218
+ await mkdirp(path.dirname(abs));
219
+ await writeTextAtomic(abs, renderDryRunPlanDoc(dryReport));
220
+ meta.dryRunPlanMd = abs;
221
+ }
222
+ const tail = [
223
+ dryReport.summary,
224
+ `静态分析报告:${meta.dryRunReportMd}`,
225
+ meta.dryRunPlanMd
226
+ ? `方案文档(可作为后续正式任务的 planDoc):${dryRunPlanDocRelPath(meta.taskId)}`
227
+ : "(未取得结构化计划,无法作为 planDoc 复用)",
228
+ ].join("\n");
229
+ // 方案有问题 → needs_attention(人工裁决),不是 failed(并非代码缺陷)
230
+ return dryReport.passed
231
+ ? this.finish("succeeded", null, tail)
232
+ : this.finish("needs_attention", "verify_failed", tail);
233
+ }
192
234
  if (!runRes.ok && !meta.autoVerify) {
193
235
  return this.finish("failed", "agent_failed", runRes.error ??
194
236
  `agent 执行失败(exit=${runRes.exitCode ?? "n/a"})。日志 ${runRes.logFile}`);
@@ -1,3 +1,28 @@
1
+ /**
2
+ * dryRun 的只读预演约束(issue #21)。
3
+ *
4
+ * 注入点刻意选在这里而不是各个适配器的 prompt builder:全部 5 个适配器的提示词构造
5
+ * 都会拼 `ctx.context`(codex `input.ts`、traework `ui/composer.ts`、cli `cli.ts`、
6
+ * zcode/kimicode `run.ts`),因此**一次改动即覆盖全部适配器**;且 dryRun 是 round 0 的
7
+ * 首次派发,zcode/kimicode 仅在 `initialDispatch` 时附加 context 的守卫不会把它吞掉。
8
+ */
9
+ export const DRY_RUN_CONSTRAINT = [
10
+ "【本任务是 dryRun 只读预演 —— 必须严格遵守】",
11
+ "",
12
+ "1. **不得创建、修改或删除任何源文件**;不要运行会改动工作区的命令(如格式化、自动修复、构建产物写入)。",
13
+ " 允许且**必须**写入的唯一文件是下面第 2 条要求的计划文件。",
14
+ "2. 必须把修改方案写成机器可读的计划文件:`.tianshu-mcp/dry-run-plan.json`(相对项目根),格式:",
15
+ " ```json",
16
+ ' { "summary": "一句话方案", "files": [ { "path": "src/foo.ts", "action": "modify",',
17
+ ' "reason": "为什么要改", "edits": [ { "line": 42, "symbol": "functionName", "action": "怎么改" } ] } ] }',
18
+ " ```",
19
+ ' `path` 必须是**项目相对路径**(不得写绝对路径、不得包含 `..`、不得指向 `.git` 或 `node_modules`);',
20
+ ' `action` 取值为 `create` / `modify` / `delete`;`edits` 可选,但写了就要与文件真实内容对得上',
21
+ " (行号必须在文件范围内、`symbol` 必须在文件中真实存在)—— 验收引擎会逐条静态核对。",
22
+ "3. 验收引擎只做静态分析(文件是否存在、拟改位置是否存在、是否有明显逻辑冲突),",
23
+ " **不跑 typecheck / test / build**。因此请不要在回复里声称测试已通过。",
24
+ "4. 在回复里用自然语言说明方案要点即可;**不要**开始实施改动。",
25
+ ].join("\n");
1
26
  export function makeBuildCtx(services) {
2
27
  return (meta, round, feedback) => ({
3
28
  taskId: meta.taskId,
@@ -6,7 +31,10 @@ export function makeBuildCtx(services) {
6
31
  displayPath: meta.displayPath,
7
32
  agentId: meta.agentId,
8
33
  task: meta.task,
9
- context: meta.context,
34
+ // dryRun:把只读预演约束并入 context(既有的用户 context 保留在前)
35
+ context: meta.dryRun
36
+ ? [meta.context, DRY_RUN_CONSTRAINT].filter((s) => s && s.trim() !== "").join("\n\n")
37
+ : meta.context,
10
38
  model: meta.model,
11
39
  reasoningLevel: meta.reasoningLevel,
12
40
  modelSource: meta.modelSource,
@@ -1,3 +1,10 @@
1
+ /**
2
+ * formatter:统一结果文本 + ---tianshu-mcp-meta--- JSON 块拼装(开发计划 §5)。
3
+ * meta 块固定以 ---tianshu-mcp-meta--- 起止行包裹,天枢可正则抽取。
4
+ * ToolResult 使用 type alias(带隐式索引签名),以匹配官方 SDK 的 CallToolResult。
5
+ */
6
+ import path from "node:path";
7
+ import { toPosix } from "../util/fs.js";
1
8
  export function textResult(text, isError = false) {
2
9
  return { content: [{ type: "text", text }], isError };
3
10
  }
@@ -52,6 +59,14 @@ export function metaFromTask(meta, extra) {
52
59
  guiStopUnconfirmed: meta.guiResidualUnconfirmed === true || meta.interruptedCleanStop === false ? true : undefined,
53
60
  lastRunSignal: meta.lastRunSignal,
54
61
  idempotencyKey: meta.idempotencyKey,
62
+ ...(meta.dryRun ? { dryRun: true } : {}),
63
+ ...(meta.dryRunReportMd || meta.dryRunReportJson
64
+ ? { dryRunReportFiles: { md: meta.dryRunReportMd, json: meta.dryRunReportJson } }
65
+ : {}),
66
+ // 方案文档报**项目相对路径**:调用方要把它原样传给后续 run_task 的 planDoc
67
+ ...(meta.dryRunPlanMd
68
+ ? { dryRunPlanDoc: toPosix(path.relative(meta.projectPath, meta.dryRunPlanMd)) }
69
+ : {}),
55
70
  ...extra,
56
71
  };
57
72
  }
@@ -66,6 +66,8 @@ function runTaskKeyedFields(args) {
66
66
  // issue #20:验收策略覆盖会改变这一轮的门禁,必须计入摘要 —— 否则同键重放
67
67
  // 会返回一个「策略不同」的旧任务,调用方以为重试成功、实际拿到的是另一套验收口径。
68
68
  acceptanceOverride: args.acceptanceOverride,
69
+ // issue #21:dryRun 改变 agent 的行为约束与验收口径,同属「会改变结果」的入参
70
+ dryRun: args.dryRun,
69
71
  };
70
72
  }
71
73
  /** 幂等重放的响应行:如实回报既有任务的当前状态(含终态),不承诺会重新派发。 */
@@ -334,6 +336,8 @@ function runTaskHandler(ctx, defaults, idempotency) {
334
336
  taskTimeoutMs,
335
337
  // issue #20:任务级临时验收配置覆盖,随快照保存、仅本任务生效
336
338
  acceptanceOverride: args.acceptanceOverride,
339
+ // issue #21:干跑模式(只分析规划、不改源码)
340
+ dryRun: args.dryRun,
337
341
  idempotencyKey: args.idempotencyKey,
338
342
  idempotencyScope: args.idempotencyKey === undefined ? undefined : "run_task",
339
343
  idempotencyDigest: args.idempotencyKey === undefined ? undefined : digest,
@@ -425,6 +429,11 @@ async function runTaskWithoutProject(ctx, defaults, args, idempotency) {
425
429
  // 无项目模式没有项目验收,覆盖无的放矢;显式拒绝而非静默忽略,避免调用方误以为已生效
426
430
  return errorResult("无项目模式不支持 acceptanceOverride:没有项目验收可覆盖。请提供 projectPath,或移除该参数。");
427
431
  }
432
+ if (args.dryRun === true) {
433
+ // issue #21:dryRun 的静态分析依赖项目基线(引用文件是否存在、源码是否零改动),
434
+ // 无项目模式没有可分析的文件树,显式拒绝而不是让它退化成一次普通无项目任务。
435
+ return errorResult("无项目模式不支持 dryRun:没有可静态分析的项目基线与文件树。请提供 projectPath,或移除 dryRun。");
436
+ }
428
437
  try {
429
438
  parseZcodeModel(args.model);
430
439
  // 无项目模式不做项目引用解析:识别到本地引用就在发送前说明需要 projectPath。
package/dist/mcp/tools.js CHANGED
@@ -34,7 +34,8 @@ export const TOOL_DEFS = [
34
34
  },
35
35
  {
36
36
  name: "run_task",
37
- description: "派活:启动外部 AI-Agent 开发任务并可自动验收返修,异步返回 taskId。ZCode 要求 model=供应商/模型,不支持 mode;TraeWork 的 model 可选并支持 Work/Code/Design mode。task/context 内的 ZCode 项目路径引用会在发送前校验。Qoder CN 要求已有 projectPath 和可读 planDoc;modelSource 可选 default/custom,省略模型或等级则沿用当前设置。思考等级通过模型管理保存为全局偏好,权限模式不变;macOS research 禁止派发。可选 idempotencyKey(1..128 字符):同一 key 在 TTL(默认 24h)内重复提交恒返回原 taskId 与当前状态、不新建任务,参数变更则报冲突——重试请复用同一 key。",
37
+ description: "派活:启动外部 AI-Agent 开发任务并可自动验收返修,异步返回 taskId。ZCode 要求 model=供应商/模型,不支持 mode;TraeWork 的 model 可选并支持 Work/Code/Design mode。task/context 内的 ZCode 项目路径引用会在发送前校验。Qoder CN 要求已有 projectPath 和可读 planDoc;modelSource 可选 default/custom,省略模型或等级则沿用当前设置。思考等级通过模型管理保存为全局偏好,权限模式不变;macOS research 禁止派发。可选 idempotencyKey(1..128 字符):同一 key 在 TTL(默认 24h)内重复提交恒返回原 taskId 与当前状态、不新建任务,参数变更则报冲突——重试请复用同一 key。 " +
38
+ "可选 dryRun=true 进入干跑模式(先审后做):agent 只分析规划、输出将要修改的文件清单与方案、不动源码;验收引擎只做静态分析(引用文件是否存在、拟改位置是否存在、明显逻辑冲突),跳过 typecheck/test/build。dryRun 需提供 projectPath、忽略 autoVerify、不进入自动返修;产物为独立报告(meta.dryRunReportFiles,不消耗验收轮次)与方案文档(meta.dryRunPlanDoc,可直接作为后续正式任务的 planDoc)。默认关闭。",
38
39
  inputSchema: RunTaskParamsSchema,
39
40
  capability: "write",
40
41
  requireApproval: true,
package/dist/server.js CHANGED
@@ -8,6 +8,7 @@ import { resolveDataHome, DataHome } from "./config/store.js";
8
8
  import { BUILTIN_PROFILES } from "./agents/builtin.js";
9
9
  import { AgentAdapterRegistry } from "./agents/registry.js";
10
10
  import { TaskStore } from "./tasks/task-store.js";
11
+ import { TaskNotifier } from "./tasks/notifier.js";
11
12
  import { AcceptanceEngine } from "./verify/acceptance.js";
12
13
  import { TaskManager } from "./tasks/task-manager.js";
13
14
  import { makeBuildCtx } from "./mcp/context.js";
@@ -26,7 +27,10 @@ export async function buildServer(opts = {}) {
26
27
  // shutdown 预算(issue #14):GUI 任务在 server 退出时的停止等待上限,注入 manager 供
27
28
  // shutdownInterrupt() 使用;与 profile 的 gui.cancelWaitMs(取消路径)解耦。
28
29
  const guiStopWaitMs = cfg.shutdown?.guiStopWaitMs ?? 15_000;
29
- const store = new TaskStore(home, logger);
30
+ // 任务终态通知(issue #22):配置读同一份 config.json(支持热加载),故用 getter 惰性取,
31
+ // 运行中改配置也能生效。默认关闭 —— 未配置 webhook 时 notify 是空操作。
32
+ const notifier = new TaskNotifier(() => dataHome.loadConfig(), logger);
33
+ const store = new TaskStore(home, logger, notifier);
30
34
  const registry = new AgentAdapterRegistry(() => dataHome.loadProfiles(), logger);
31
35
  const engine = new AcceptanceEngine(store, logger);
32
36
  const manager = new TaskManager(store, dataHome, registry, engine, logger, makeBuildCtx({ store, dataHome }));
@@ -0,0 +1,117 @@
1
+ /**
2
+ * 任务终态通知(issue #22)。
3
+ *
4
+ * 长任务下调用方原先必须一直挂在天枢界面轮询 `query_task`;任务完成/失败/进入 `needs_attention`
5
+ * 时没有任何主动推送,盯屏成本高。本模块在终态跃迁时向配置的 URL POST 一条 JSON。
6
+ *
7
+ * 设计约束(都是 issue 明确要求的):
8
+ * - **异步、不阻塞状态机**:`notify()` 立即返回,发送在后台进行。
9
+ * - **失败不影响任务**:任何异常只记 `warn`,绝不外抛、绝不改变状态机。
10
+ * - **默认关闭**:未配置 / `enabled:false` / 事件不在订阅集 → **完全不发起请求**。
11
+ * - 重试 + 超时控制:总尝试 `1 + maxRetries` 次,退避 `backoffMs × 第几次`。
12
+ *
13
+ * 「恰好一次」的保证方式:按 `taskId + status + finishedAt` 进程内去重。
14
+ * `finishedAt` 由 `updateStatus` 在写入终态时刷新为当前时刻,并被 `rework` / `continueTask`
15
+ * 清空 —— 因此**同一回合**的重复写入被抑制,而返修后的**新一回合同样状态会再次通知**。
16
+ * 不能改用 `prev !== status` 作为门条件:多条路径(cancel 的 queued 分支、shutdownInterrupt)
17
+ * 会先直接改写 `meta.status` 再调用 `updateStatus`,那时 `prev` 已等于目标状态。
18
+ */
19
+ import { createHmac } from "node:crypto";
20
+ /**
21
+ * 任务状态 → 通知事件类别;非终态/不产生通知的状态返回 undefined。
22
+ *
23
+ * `needs_attention`(真终态)→ `needs_human`;`needs_user`(**非终态**,可被 continue 恢复、
24
+ * 之后可能再次进入)→ 单独的 `needs_user` 类别,默认不在订阅集里(见 schema)。
25
+ * 两者分开是刻意的:混为一类会让「默认只推真终态」失效。
26
+ */
27
+ export function statusToEvent(status) {
28
+ switch (status) {
29
+ case "succeeded":
30
+ return "done";
31
+ case "failed":
32
+ return "failed";
33
+ case "needs_attention":
34
+ return "needs_human";
35
+ case "needs_user":
36
+ return "needs_user";
37
+ case "cancelled":
38
+ case "interrupted":
39
+ return "cancelled";
40
+ default:
41
+ return undefined;
42
+ }
43
+ }
44
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
45
+ export class TaskNotifier {
46
+ getConfig;
47
+ logger;
48
+ /** 已通知过的 `taskId:status:finishedAt`(进程内;重启后按新回合重新通知,如实) */
49
+ sent = new Set();
50
+ constructor(getConfig, logger) {
51
+ this.getConfig = getConfig;
52
+ this.logger = logger;
53
+ }
54
+ /** fire-and-forget:立即返回,发送与重试都在后台;绝不外抛 */
55
+ notify(payload) {
56
+ const key = `${payload.taskId}:${payload.status}:${payload.finishedAt ?? ""}`;
57
+ if (this.sent.has(key))
58
+ return;
59
+ this.sent.add(key);
60
+ void this.send(payload).catch((e) => {
61
+ // send 内部已吞掉异常,这里是最后一道保险:通知绝不允许影响任务
62
+ this.logger.warn(`任务 ${payload.taskId} 通知发送出现未预期异常:${e instanceof Error ? e.message : String(e)}`);
63
+ });
64
+ }
65
+ async send(payload) {
66
+ let webhook;
67
+ try {
68
+ webhook = (await this.getConfig()).notifications?.webhook;
69
+ }
70
+ catch (e) {
71
+ this.logger.warn(`读取通知配置失败,跳过任务 ${payload.taskId} 的通知:${e instanceof Error ? e.message : String(e)}`);
72
+ return;
73
+ }
74
+ // 默认关闭:未配置 / 未启用 / 无 url / 事件未订阅 → 不发起任何请求
75
+ if (!webhook?.enabled || !webhook.url)
76
+ return;
77
+ if (!webhook.events.includes(payload.event))
78
+ return;
79
+ const body = JSON.stringify(payload);
80
+ const headers = {
81
+ "content-type": "application/json",
82
+ "x-tianshu-event": payload.event,
83
+ };
84
+ if (webhook.secret) {
85
+ headers["x-tianshu-signature"] =
86
+ `sha256=${createHmac("sha256", webhook.secret).update(body, "utf8").digest("hex")}`;
87
+ }
88
+ const attempts = 1 + webhook.maxRetries;
89
+ let lastError = "未知原因";
90
+ for (let attempt = 1; attempt <= attempts; attempt++) {
91
+ try {
92
+ const res = await fetch(webhook.url, {
93
+ method: "POST",
94
+ headers,
95
+ body,
96
+ // 与 visual 服务同一约定:不自动跟随重定向,超时用 AbortSignal.timeout
97
+ redirect: "manual",
98
+ signal: AbortSignal.timeout(webhook.timeoutMs),
99
+ });
100
+ if (res.ok) {
101
+ this.logger.info(`任务 ${payload.taskId} 通知已送达(${payload.event},HTTP ${res.status}${attempt > 1 ? `,第 ${attempt} 次尝试` : ""})`);
102
+ return;
103
+ }
104
+ lastError = `HTTP ${res.status}`;
105
+ }
106
+ catch (e) {
107
+ lastError = e instanceof Error ? e.message : String(e);
108
+ }
109
+ if (attempt < attempts && webhook.backoffMs > 0) {
110
+ // eslint-disable-next-line no-await-in-loop
111
+ await sleep(webhook.backoffMs * attempt);
112
+ }
113
+ }
114
+ // 尽最大努力通知即止:失败不影响任务本体,也不重排状态机
115
+ this.logger.warn(`任务 ${payload.taskId} 通知发送失败(${payload.event},已尝试 ${attempts} 次):${lastError}`);
116
+ }
117
+ }
@@ -181,6 +181,7 @@ export class TaskManager {
181
181
  autoFixRounds: input.autoFixRounds,
182
182
  taskTimeoutMs: input.taskTimeoutMs,
183
183
  acceptanceOverride: input.acceptanceOverride,
184
+ dryRun: input.dryRun,
184
185
  idempotencyKey: input.idempotencyKey,
185
186
  idempotencyScope: input.idempotencyScope,
186
187
  idempotencyDigest: input.idempotencyDigest,