tianshu-mcp 0.6.6 → 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,33 @@ 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
+
11
38
  ## [0.6.6] - 2026-09-24
12
39
 
13
40
  ### Added
package/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@
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
+
10
37
  ## [0.6.6] - 2026-09-24
11
38
 
12
39
  ### 新增
package/README.en.md CHANGED
@@ -51,6 +51,7 @@ Tianshu plays the role of the overall commander; this MCP server is the **schedu
51
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>).
52
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).
53
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).
54
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).
55
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)).
56
57
  - **Extensible**: a new agent = one profile (data) + (if needed) one adapter file — no changes to the orchestration core.
package/README.md CHANGED
@@ -51,6 +51,7 @@
51
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>)。
52
52
  - **技能自检安装(issue #16)**:启动时把**包内** `skills/tianshu-mcp/` 幂等同步到 `~/.rivet/skills/tianshu-mcp/`。源只由 `import.meta.url` 相对包自身定位(无 cwd 内容发现);目标内含安装清单,据此仅在**可证未被改动**时自动升级,**检出你的本地修改或来源不明一律保留 + 告警**;覆盖走「临时目录 → 备份 → 换入」的原子路径,并按 `skills.autoInstall`(`true`/`"prompt"`/`false`)与 `skills.backupKeep` 治理。详见 [README §技能自检安装](#技能自检安装)。
53
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)。
54
55
  - **可选 AI 内容校验(v0.5.4,默认关闭)**:校验图片或页面截图**内容**是否符合你显式声明的期望描述。判定完全**委托给你自备的本地命令**(MCP 不读取、不存储、不转发任何密钥,也不内置模型客户端),默认**仅告警**、逐规则可升级为致败;采样多数票 + 任务级缓存防抖,票不集中或低于置信度阈值判 `uncertain`(永不阻塞、不触发返修)。配置与命令契约见 [视觉验收](docs/visual-acceptance.md)。
55
56
  - **不碰密钥**:各 agent 用自己的登录态;本 server 不保存/转发任何 API key。可选 AI 内容校验同样不引入凭证管理——判定命令自己管密钥(见 [SECURITY.md](SECURITY.md))。
56
57
  - **可扩展**:新 agent = 一个 profile(数据)+(如需)一个 adapter 文件,零改编排核心。
@@ -210,6 +210,44 @@ export const ContinueTaskParamsSchema = z.object({
210
210
  message: z.string().min(1, "message 不能为空"),
211
211
  });
212
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
+ });
213
251
  export const ServerConfigSchema = z.object({
214
252
  concurrency: z
215
253
  .object({
@@ -274,6 +312,20 @@ export const ServerConfigSchema = z.object({
274
312
  .default(IDEMPOTENCY_MAX_ENTRIES_DEFAULT),
275
313
  })
276
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({}),
277
329
  });
278
330
  /* ---------------- agent-profiles.json ---------------- */
279
331
  export const ExecutableDiscoverySchema = z.object({
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
+ }
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { TERMINAL_STATUSES, ACTIVE_STATUSES, } from "./task.js";
8
8
  import { appendLine, exists, mkdirp, readDirSafe, readJsonSafe, readTextSafe, readTextTail, writeJsonAtomic, writeTextAtomic, } from "../util/fs.js";
9
9
  import { isAgentEventName } from "../agents/agent-events.js";
10
+ import { statusToEvent } from "./notifier.js";
10
11
  import { nowIso } from "../util/id.js";
11
12
  import { reportToJsonable, reportToMd } from "../verify/report.js";
12
13
  import { dryRunReportToJsonable, renderDryRunReportMd, } from "../verify/dry-run.js";
@@ -26,10 +27,17 @@ const STATUS_EVENT_MAP = {
26
27
  export class TaskStore {
27
28
  home;
28
29
  logger;
30
+ notifier;
29
31
  statusWriteTails = new Map();
30
- constructor(home, logger) {
32
+ constructor(home, logger,
33
+ /**
34
+ * 任务终态通知器(issue #22,**可选**)。默认不传 = 与引入本能力前完全一致(测试大量
35
+ * 直接 `new TaskStore(home, logger)`,故必须保持可选)。
36
+ */
37
+ notifier) {
31
38
  this.home = home;
32
39
  this.logger = logger;
40
+ this.notifier = notifier;
33
41
  }
34
42
  dir(taskId) {
35
43
  return path.join(this.home, "tasks", taskId);
@@ -161,6 +169,9 @@ export class TaskStore {
161
169
  await this.appendEvent(taskId, name, status, detail);
162
170
  await this.writeSnapshot(meta);
163
171
  this.logger.debug(`任务 ${taskId}: ${prev} → ${status}${detail ? ` (${detail})` : ""}`);
172
+ // issue #22:终态通知。**在 appendEvent + writeSnapshot 成功之后**才发——先保证
173
+ // 本地事实已落盘,再对外通知;notify 是 fire-and-forget,不阻塞状态机写入链。
174
+ this.notifyTerminal(meta, status);
164
175
  })();
165
176
  this.statusWriteTails.set(taskId, write);
166
177
  try {
@@ -171,6 +182,28 @@ export class TaskStore {
171
182
  this.statusWriteTails.delete(taskId);
172
183
  }
173
184
  }
185
+ /** 终态跃迁的通知派发(issue #22);未传 notifier 或非通知状态时是空操作 */
186
+ notifyTerminal(meta, status) {
187
+ if (!this.notifier)
188
+ return;
189
+ const event = statusToEvent(status);
190
+ if (!event)
191
+ return;
192
+ this.notifier.notify({
193
+ taskId: meta.taskId,
194
+ event,
195
+ status,
196
+ ts: nowIso(),
197
+ finishedAt: meta.finishedAt,
198
+ agentId: meta.agentId,
199
+ projectPath: meta.projectPath,
200
+ round: meta.roundsUsed,
201
+ reportRound: meta.reportRound,
202
+ message: meta.lastMessage,
203
+ reportMd: meta.reportMd,
204
+ reportJson: meta.reportJson,
205
+ });
206
+ }
174
207
  async addNote(meta, detail) {
175
208
  meta.updatedAt = nowIso();
176
209
  await this.appendEvent(meta.taskId, "note", meta.status, detail);
@@ -4,4 +4,4 @@
4
4
  * 本文件由 scripts/sync-version.mjs 在每次 build 前重新生成。
5
5
  */
6
6
  // generated: 勿手改 —— 运行 `npm run build` 自动同步
7
- export const MCP_SERVER_VERSION = "0.6.6";
7
+ export const MCP_SERVER_VERSION = "0.6.7";
@@ -0,0 +1,127 @@
1
+ # Task notification (webhook, issue #22)
2
+
3
+ Chinese version: [notifications.md](notifications.md)
4
+
5
+ For long tasks the caller previously had to keep polling `query_task` from the Tianshu UI; nothing was
6
+ pushed when a task completed, failed or entered `needs_attention`, so babysitting was expensive. This
7
+ capability POSTs a JSON body to a configured URL on state transitions.
8
+
9
+ The receiver is yours to implement — a Feishu / DingTalk bot or a small self-hosted service both work.
10
+
11
+ ## 1. Configuration
12
+
13
+ It lives in the **global** `config.json` (in the data home, `~/.tianshu-mcp/config.json` by default,
14
+ overridable with `TIANSHU_MCP_HOME`):
15
+
16
+ ```jsonc
17
+ {
18
+ "notifications": {
19
+ "webhook": {
20
+ "enabled": true, // default false; unset or false sends **no requests at all**
21
+ "url": "https://example.com/hook", // required when enabled=true (the schema rejects its absence)
22
+ "timeoutMs": 5000, // per-request timeout, default 5s
23
+ "maxRetries": 2, // retry cap, default 2 (total attempts = 1 + maxRetries)
24
+ "backoffMs": 500, // backoff base: waits backoffMs × n before retry n, default 500
25
+ "secret": "your-signing-key", // optional; adds an HMAC-SHA256 signature over the body
26
+ "events": ["done", "failed", "needs_human"] // optional; defaults below
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ **Why the global config rather than a project `acceptance.json`**: notification routing is a
33
+ **host / transport** concern, not project acceptance policy; one endpoint normally demultiplexes by the
34
+ `taskId` in the body. Also, the state-transition choke point (`TaskStore.updateStatus`) only has the data
35
+ home, taskId and logger, so it cannot cheaply read project config on every transition.
36
+
37
+ **When the config is broken**: `config.json` follows a last-known-good policy — a validation failure logs
38
+ a warning and keeps the previous valid config, so one bad notification line cannot stop the server from
39
+ starting.
40
+
41
+ ## 2. Event classes and the default subscription
42
+
43
+ | Event | Task status | Subscribed by default |
44
+ |---|---|---|
45
+ | `done` | `succeeded` | ✅ |
46
+ | `failed` | `failed` | ✅ |
47
+ | `needs_human` | `needs_attention` (**terminal** — the task ends once a human rules) | ✅ |
48
+ | `needs_user` | `needs_user` (**non-terminal** — `continue_task` can restore it to `queued`, and it may enter `needs_user` again) | ❌ opt in |
49
+ | `cancelled` | `cancelled` / `interrupted` | ❌ opt in |
50
+
51
+ **Only true terminal states are pushed by default.** `needs_user` and `needs_attention` are deliberately
52
+ separate classes: the former is non-terminal and can fire repeatedly in one long task, so enabling it by
53
+ default would be noise; callers who want it can add it to `events` (knowing it will repeat).
54
+
55
+ ## 3. Request body
56
+
57
+ ```jsonc
58
+ {
59
+ "taskId": "tsk_xxx",
60
+ "event": "done", // done | failed | needs_human | needs_user | cancelled
61
+ "status": "succeeded", // raw task status (finer than event; lets receivers demultiplex)
62
+ "ts": "2026-09-24T12:00:00.000Z",// when the event happened
63
+ "finishedAt": "2026-09-24T12:00:00.000Z",
64
+ "agentId": "codex",
65
+ "projectPath": "D:/proj",
66
+ "round": 1, // rounds used
67
+ "reportRound": 0, // latest acceptance report round (if any)
68
+ "message": "验收通过。", // terminal message
69
+ "reportMd": "...report-0.md", // acceptance report paths (if any)
70
+ "reportJson": "...report-0.json"
71
+ }
72
+ ```
73
+
74
+ Headers:
75
+
76
+ | Header | Notes |
77
+ |---|---|
78
+ | `content-type` | `application/json` |
79
+ | `X-Tianshu-Event` | The event class (same as the body's `event`, so receivers can route without parsing) |
80
+ | `X-Tianshu-Signature` | Only when `secret` is configured: `sha256=<hex>`, HMAC-SHA256 over the **raw request body string** |
81
+
82
+ ## 4. Receiver guidance
83
+
84
+ **Verify the signature** (do this whenever `secret` is set, to prevent forged notifications):
85
+
86
+ ```js
87
+ import { createHmac, timingSafeEqual } from "node:crypto";
88
+
89
+ function verify(rawBody, signatureHeader, secret) {
90
+ const expected = `sha256=${createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")}`;
91
+ const a = Buffer.from(expected);
92
+ const b = Buffer.from(signatureHeader ?? "");
93
+ return a.length === b.length && timingSafeEqual(a, b);
94
+ }
95
+ ```
96
+
97
+ Use the **raw body string** (never a re-`JSON.stringify`'d object — key order changes and the signature
98
+ will not verify).
99
+
100
+ **Feishu custom bot**: its webhook expects its own envelope
101
+ (`{"msg_type":"text","content":{"text":"…"}}`), which differs from this MCP's body. Two options:
102
+
103
+ 1. Run a small forwarding service: receive the MCP notification → verify the signature → reshape into
104
+ the Feishu envelope and forward;
105
+ 2. Use a gateway / serverless function that rewrites the body the same way.
106
+
107
+ **DingTalk is analogous** (`{"msgtype":"text","text":{"content":"…"}}`, usually with extra signing params).
108
+
109
+ **Idempotency advice**: dedupe on the receiver by `taskId + status + finishedAt` (the MCP already dedupes
110
+ on that key in-process, but delivery can still repeat across restarts or retries — see below).
111
+
112
+ ## 5. Delivery semantics (disclosed honestly)
113
+
114
+ - **Best-effort, not delivery-guaranteed**: notifications are an observability capability, not a delivery
115
+ guarantee. A failed send only writes a `warn` log — it never reorders the state machine, changes a task's
116
+ terminal state, or blocks any call.
117
+ - **Non-blocking**: sending is fully asynchronous (fire-and-forget). A slow or dead endpoint **will not**
118
+ slow the task down.
119
+ - **Retries**: total attempts `1 + maxRetries`; network errors, timeouts and non-2xx are all retried;
120
+ backoff is `backoffMs × attempt number`. After exhausting them, one `warn` is logged.
121
+ - **What "exactly once" covers**: the MCP dedupes on `taskId + status + finishedAt` **in-process** — repeated
122
+ status writes for the same episode notify once, while a new episode after rework (a changed `finishedAt`)
123
+ notifies again. Across a server restart or a receiver retry, delivery can still repeat, so receivers
124
+ should be idempotent.
125
+ - **Redirects are not followed**: `redirect: "manual"` — the endpoint should return 2xx directly.
126
+ - **The body may contain project paths**: it includes `projectPath` and local absolute paths to report
127
+ files. Make sure the receiver is trusted (especially when forwarding notifications to a public service).
@@ -0,0 +1,118 @@
1
+ # 任务终态通知(webhook,issue #22)
2
+
3
+ 英文版:[notifications.en.md](notifications.en.md)
4
+
5
+ 长任务下调用方原先必须一直挂在天枢界面轮询 `query_task`;任务完成 / 失败 / 进入 `needs_attention`
6
+ 时没有任何主动推送,盯屏成本高。本能力在状态跃迁时向配置的 URL POST 一条 JSON。
7
+
8
+ 接收端由使用者自行实现 —— 飞书 / 钉钉机器人、自建小服务均可。
9
+
10
+ ## 一、配置
11
+
12
+ 放在**全局** `config.json`(数据目录,默认 `~/.tianshu-mcp/config.json`,可用环境变量
13
+ `TIANSHU_MCP_HOME` 覆盖):
14
+
15
+ ```jsonc
16
+ {
17
+ "notifications": {
18
+ "webhook": {
19
+ "enabled": true, // 默认 false;不配置或 false 时**完全不发请求**
20
+ "url": "https://example.com/hook", // enabled=true 时必填(缺它会被 schema 拒绝)
21
+ "timeoutMs": 5000, // 单次请求超时,默认 5s
22
+ "maxRetries": 2, // 失败重试次数上限,默认 2(总尝试 = 1 + maxRetries)
23
+ "backoffMs": 500, // 退避基数:第 n 次重试前等 backoffMs × n,默认 500
24
+ "secret": "your-signing-key", // 可选;配置后对请求体做 HMAC-SHA256 签名
25
+ "events": ["done", "failed", "needs_human"] // 可选;默认见下
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ **为什么放在全局 config 而不是项目 `acceptance.json`**:通知路由是**宿主 / 传输层**的关注点,
32
+ 不是项目验收策略;一个端点通常按请求体里的 `taskId` 自行分流即可。而且状态跃迁的咽喉
33
+ (`TaskStore.updateStatus`)只有数据目录 / taskId / logger,无法在每次跃迁时廉价读取项目配置。
34
+
35
+ **配置写坏时**:`config.json` 走「last-known-good」策略 —— 校验失败会告警并沿用上一份有效配置,
36
+ 不会因为写错一行通知配置就让整个 server 起不来。
37
+
38
+ ## 二、事件类别与默认订阅集
39
+
40
+ | 事件 | 对应任务状态 | 默认订阅 |
41
+ |---|---|---|
42
+ | `done` | `succeeded` | ✅ |
43
+ | `failed` | `failed` | ✅ |
44
+ | `needs_human` | `needs_attention`(**真终态**,等人工裁决后任务即结束) | ✅ |
45
+ | `needs_user` | `needs_user`(**非终态** —— 可被 `continue_task` 恢复到 `queued`,之后可能**再次**进入) | ❌ 需显式开启 |
46
+ | `cancelled` | `cancelled` / `interrupted` | ❌ 需显式开启 |
47
+
48
+ **默认只推真终态**。`needs_user` 与 `needs_attention` 刻意分成两个类别:前者不是终态,
49
+ 一次长任务里可能反复触发,默认打开会变成打扰;需要它的调用方把它加进 `events` 即可(已知会重复推送)。
50
+
51
+ ## 三、请求体
52
+
53
+ ```jsonc
54
+ {
55
+ "taskId": "tsk_xxx",
56
+ "event": "done", // done | failed | needs_human | needs_user | cancelled
57
+ "status": "succeeded", // 原始任务状态(比 event 更细,便于接收端自行分流)
58
+ "ts": "2026-09-24T12:00:00.000Z",// 事件发生时刻
59
+ "finishedAt": "2026-09-24T12:00:00.000Z",
60
+ "agentId": "codex",
61
+ "projectPath": "D:/proj",
62
+ "round": 1, // 已用轮次
63
+ "reportRound": 0, // 最新验收报告轮次(若有)
64
+ "message": "验收通过。", // 终态文案
65
+ "reportMd": "...report-0.md", // 验收报告路径(若有)
66
+ "reportJson": "...report-0.json"
67
+ }
68
+ ```
69
+
70
+ 请求头:
71
+
72
+ | 头 | 说明 |
73
+ |---|---|
74
+ | `content-type` | `application/json` |
75
+ | `X-Tianshu-Event` | 事件类别(与 body 的 `event` 一致,便于接收端免解析分流) |
76
+ | `X-Tianshu-Signature` | 仅在配置了 `secret` 时出现:`sha256=<hex>`,对**原始请求体字符串**做 HMAC-SHA256 |
77
+
78
+ ## 四、接收端实现建议
79
+
80
+ **校验签名**(配了 `secret` 时务必做,防止伪造通知):
81
+
82
+ ```js
83
+ import { createHmac, timingSafeEqual } from "node:crypto";
84
+
85
+ function verify(rawBody, signatureHeader, secret) {
86
+ const expected = `sha256=${createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")}`;
87
+ const a = Buffer.from(expected);
88
+ const b = Buffer.from(signatureHeader ?? "");
89
+ return a.length === b.length && timingSafeEqual(a, b);
90
+ }
91
+ ```
92
+
93
+ 注意要拿**原始 body 字符串**(不是重新 `JSON.stringify` 的对象 —— 键顺序会变,签不上)。
94
+
95
+ **飞书自定义机器人**:其 webhook 要求特定报文格式(`{"msg_type":"text","content":{"text":"…"}}`),
96
+ 与本 MCP 的请求体不同。两种接法:
97
+
98
+ 1. 自建一个转发小服务:收本 MCP 的通知 → 校验签名 → 拼成飞书格式再转发;
99
+ 2. 用一个能改写报文的网关 / Serverless 函数做同样的事。
100
+
101
+ **钉钉同理**(要求 `{"msgtype":"text","text":{"content":"…"}}`,且通常需要加签参数)。
102
+
103
+ **幂等建议**:接收端按 `taskId + status + finishedAt` 去重(MCP 侧已按同一键在进程内去重,
104
+ 但跨重启或重试仍可能重复送达 —— 见下节)。
105
+
106
+ ## 五、交付语义(如实披露)
107
+
108
+ - **尽力投递,不保证送达**:通知是观测能力,不是交付保证。发送失败只写 `warn` 日志,
109
+ **不重排状态机、不改任务终态、不阻塞任何调用**。
110
+ - **不阻塞**:发送全程异步(fire-and-forget)。端点慢或挂掉**不会**拖慢任务。
111
+ - **重试**:总尝试 `1 + maxRetries` 次;网络错误、超时、非 2xx 都重试;退避 `backoffMs × 第几次`。
112
+ 全部失败后仅记一条 `warn`。
113
+ - **恰好一次的范围**:MCP 侧按 `taskId + status + finishedAt` 在**进程内**去重 ——
114
+ 同一回合的重复状态写入只通知一次;返修后的新回合(`finishedAt` 变化)会**再次**通知。
115
+ 跨 server 重启或接收端重试仍可能重复送达,故建议接收端自行幂等。
116
+ - **不跟随重定向**:`redirect: "manual"`,端点请直接返回 2xx。
117
+ - **正文可能含项目路径**:请求体含 `projectPath` 与报告文件的本地绝对路径。请确认接收端可信
118
+ (尤其是把通知转发到公网服务时)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tianshu-mcp",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "description": "天枢 × AI-Agent 编排 MCP server —— 驱动 Codex、TraeWork、ZCode、Kimi Code 与 Qoder CN 完成项目开发、验收、失败返修与再验收闭环。",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -25,6 +25,8 @@
25
25
  "docs/repair-directives.en.md",
26
26
  "docs/dry-run.md",
27
27
  "docs/dry-run.en.md",
28
+ "docs/notifications.md",
29
+ "docs/notifications.en.md",
28
30
  "docs/visual-acceptance.md",
29
31
  "docs/visual-acceptance.en.md",
30
32
  "docs/visual-validation.md",