tianshu-mcp 0.1.2 → 0.1.3
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/README.en.md +20 -11
- package/README.md +16 -9
- package/dist/config/schema.d.ts +49 -0
- package/dist/config/schema.js +2 -0
- package/dist/config/store.d.ts +7 -3
- package/dist/config/store.js +77 -34
- package/dist/loop/fix-loop.d.ts +9 -3
- package/dist/loop/fix-loop.js +30 -13
- package/dist/mcp/formatter.d.ts +3 -0
- package/dist/mcp/formatter.js +3 -0
- package/dist/mcp/handlers.js +56 -29
- package/dist/server.js +2 -1
- package/dist/tasks/task-manager.d.ts +3 -1
- package/dist/tasks/task-manager.js +16 -1
- package/dist/tasks/task-store.d.ts +1 -1
- package/dist/tasks/task-store.js +5 -3
- package/dist/tasks/task.d.ts +10 -0
- package/dist/verify/code-analysis.js +20 -5
- package/dist/verify/git-baseline.d.ts +5 -2
- package/dist/verify/git-baseline.js +27 -7
- package/dist/verify/report.js +7 -7
- package/dist/version.generated.d.ts +6 -0
- package/dist/version.generated.js +7 -0
- package/package.json +5 -3
package/README.en.md
CHANGED
|
@@ -29,7 +29,7 @@ Tianshu plays the role of the overall commander; this MCP server is the **schedu
|
|
|
29
29
|
```bash
|
|
30
30
|
npm install
|
|
31
31
|
npm run build # → dist/
|
|
32
|
-
npm test #
|
|
32
|
+
npm test # 72 tests: unit + stub-agent 3-playbook integration + protocol + cancel/timeout/baseline/params/config regression
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
Register as a Tianshu MCP server (local dev mode):
|
|
@@ -75,7 +75,7 @@ run_task(projectPath=D:/xxx/my-app, task=「…task brief…」, agentId=codex,
|
|
|
75
75
|
- 8 tools, TaskManager state machine / queue / concurrency gate / cancel (kill tree) / event-stream persistence
|
|
76
76
|
- Acceptance engine (git baseline & diff, default-set derivation, command runner, code analysis, report.md/json)
|
|
77
77
|
- fix-loop auto rework + needs_attention; skill self-install
|
|
78
|
-
- Stub-agent 3 playbooks (good / fix-on-first / never) integration tests + protocol tests — **
|
|
78
|
+
- Stub-agent 3 playbooks (good / fix-on-first / never) integration tests + protocol tests — **72/72 green** (incl. R1–R5/S1–S6 cancel/timeout/baseline/params/config regressions)
|
|
79
79
|
- **M2 — Real Codex CLI smoke + rework loop** ✅ (2026-09-07)
|
|
80
80
|
- Real `codex exec` completed `run_task → query_task → verify_task` (see [m2-smoke-record.md](docs/m2-smoke-record.md))
|
|
81
81
|
- Real **failure → rework_task → re-verify succeeded** loop ([m2-rework-record.md](docs/m2-rework-record.md), artifacts in `docs/m2-evidence/`)
|
|
@@ -86,18 +86,27 @@ run_task(projectPath=D:/xxx/my-app, task=「…task brief…」, agentId=codex,
|
|
|
86
86
|
- npm name `tianshu-mcp` published: `tianshu-mcp@0.1.1` (`npm view` resolves; `npx -y tianshu-mcp` raises and connects 8 tools, see [dod7-release-record.md](docs/dod7-release-record.md))
|
|
87
87
|
- Real Tianshu-session skill-trigger validation (DoD #8) needs a GUI session (skill self-installed and ready)
|
|
88
88
|
|
|
89
|
-
## Acceptance remediation (R1–R8, 2026-09-07)
|
|
89
|
+
## Acceptance remediation (R1–R8, 2026-09-07; S1–S6, 2026-09-08)
|
|
90
90
|
|
|
91
|
-
Per the acceptance-remediation
|
|
91
|
+
Per the acceptance-remediation plans, all P1/P2 findings are fixed with regression tests (total **72/72**):
|
|
92
92
|
|
|
93
93
|
- **R1** ✅ cancel/interrupt state persistence (`cancel_requested → cancelled`, cancelReason/finishedAt/errorType, restart-recoverable, idempotent, bounded shutdown)
|
|
94
|
-
- **R2** ✅ call-level `taskTimeoutMs` wins
|
|
95
|
-
- **R3** ✅ git baseline participates in diff (baseline.head boundary; agent commits don't lose changes; dirty-worktree hash attribution
|
|
96
|
-
- **R4** ✅ params: `round=0` valid; manual verify allocates next round
|
|
97
|
-
- **R5** ✅ hardcoded paths removed (`{LOCALAPPDATA}` placeholders
|
|
98
|
-
- **R6** ✅ CI matrix win/mac/linux × Node 20/22 all green
|
|
99
|
-
- **R7** ✅ real Tianshu
|
|
100
|
-
- **R8**
|
|
94
|
+
- **R2** ✅ call-level `taskTimeoutMs` wins; POSIX process-group SIGTERM→SIGKILL; Windows `taskkill /T /F`; single kill-tree impl + abort-race guard
|
|
95
|
+
- **R3** ✅ git baseline participates in diff (baseline.head boundary; agent commits don't lose changes; dirty-worktree hash attribution)
|
|
96
|
+
- **R4** ✅ params: `round=0` valid; manual verify allocates next round; `extraChecks` append + `checksMode=replace`; `optional` semantics; `baselineRef` validated
|
|
97
|
+
- **R5** ✅ hardcoded paths removed (`{LOCALAPPDATA}` placeholders); content-stamp hot reload for config/profile/projects
|
|
98
|
+
- **R6** ✅ CI matrix win/mac/linux × Node 20/22 all green; Release version consistency; tarball content check
|
|
99
|
+
- **R7** ✅ real Tianshu serve session (skill load + MCP tool call + stub task loop); npm v0.1.2 published + npx raise connect
|
|
100
|
+
- **R8** ✅ docs synced (ZH/EN + dev-plan checklist); recheck report
|
|
101
|
+
|
|
102
|
+
Second-round remediation (per `.codex/plans/2026-09-08-second-remediation-plan.md`):
|
|
103
|
+
- **S1** ✅ no-reason cancel lands `cancelled` (independent `cancelRequestedAt`/`abortSource`, no reliance on optional reason)
|
|
104
|
+
- **S2** ✅ normal timeout → `failed(timeout)` + exactly one `timeout_killed` event, fixed order
|
|
105
|
+
- **S3** ✅ tracked pre-dirty net-diff attribution (unchanged staged/unstaged no longer reported as agent changes)
|
|
106
|
+
- **S4** ✅ `verify_task(taskId)` persists real-task metadata (`reportRound`/`verificationSource`/`latestVerificationVerdict`, keeps agentId); single-source MCP version (build-injected, live-tested = 0.1.2)
|
|
107
|
+
- **S5** ✅ config/profiles/projects last-known-good + sha256 invalidation (fixed corrupt-JSON-reset bug)
|
|
108
|
+
- **S6** ✅ CI/Release npm ci retry corrected; Vitest v3 upgrade (audit 0); plain-text status markers (emoji scan test)
|
|
109
|
+
- **S7/S8/S9** full real-loop evidence & new release pending final close-out (see `.codex/review/`)
|
|
101
110
|
|
|
102
111
|
## Recommended phrasing (for Tianshu)
|
|
103
112
|
|
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ TypeScript · Node.js ≥ 20 · `@modelcontextprotocol/sdk`(stdio)
|
|
|
29
29
|
```bash
|
|
30
30
|
npm install
|
|
31
31
|
npm run build # → dist/
|
|
32
|
-
npm test #
|
|
32
|
+
npm test # 72 项测试:单元 + stub-agent 三剧本集成 + 协议 + 取消/超时/基线/参数/配置回归
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
配置为天枢 MCP server(本地开发模式):
|
|
@@ -76,7 +76,7 @@ run_task(projectPath=D:/xxx/my-app, task=「…任务书…」, agentId=codex, a
|
|
|
76
76
|
- 8 工具、TaskManager 状态机/队列/并发闸/cancel(kill tree)/事件流落盘
|
|
77
77
|
- 验收引擎(git 基线/diff、默认集推导、命令 runner、代码分析、report.md/json)
|
|
78
78
|
- fix-loop 自动返修 + needs_attention;技能自检安装(已在本机真实 `~/.rivet/skills` 验证)
|
|
79
|
-
- stub-agent 三剧本(good/fix-on-first/never)集成测试 + 协议测试,**
|
|
79
|
+
- stub-agent 三剧本(good/fix-on-first/never)集成测试 + 协议测试,**72/72 绿**(含 R1–R5/S1–S6 取消/超时/基线/参数/配置回归)
|
|
80
80
|
- **M2 — 真实 Codex CLI 冒烟 + rework 闭环** ✅(2026-09-07)
|
|
81
81
|
- 真实 `codex exec` 跑通 `run_task → query_task → verify_task`([m2-smoke-record.md](docs/m2-smoke-record.md))
|
|
82
82
|
- 真实 **失败→rework_task→再验收 succeeded** 闭环([m2-rework-record.md](docs/m2-rework-record.md),物证 `docs/m2-evidence/`)
|
|
@@ -94,18 +94,25 @@ run_task(projectPath=D:/xxx/my-app, task=「…任务书…」, agentId=codex, a
|
|
|
94
94
|
- **npm 已发布**:`tianshu-mcp@0.1.1`(`npm view` 可查,`npx -y tianshu-mcp` 拉起 8 工具连通,见 [dod7-release-record.md](docs/dod7-release-record.md))
|
|
95
95
|
- GUI 聊天会话内实际调用工具(DoD #8 最后一环)需用户开天枢新会话(宿主连通与 8 工具注册已就位)
|
|
96
96
|
|
|
97
|
-
## 验收整改(R1–R8,2026-09-07)
|
|
98
|
-
|
|
99
|
-
按 [验收修复计划](.zcode/plans/tianshu-mcp-development-plan.md) 与 `.codex/plans/2026-09-07-acceptance-remediation-plan.md` 完成:
|
|
97
|
+
## 验收整改(R1–R8,2026-09-07;S1–S6,2026-09-08)
|
|
100
98
|
|
|
101
99
|
- **R1** ✅ 取消/中断状态机持久化:运行中取消 `cancel_requested → cancelled`(cancelReason/finishedAt/errorType 落盘)、重启可恢复、幂等、shutdown 有界等待。
|
|
102
100
|
- **R2** ✅ 调用级 `taskTimeoutMs` 覆盖生效(adapter 不再用 profile 覆盖)、POSIX 进程组 SIGTERM→SIGKILL、Windows taskkill /T /F,kill-tree 单实现。
|
|
103
101
|
- **R3** ✅ Git 基线参与差异计算:以 baseline.head 为边界,agent 提交不丢变更、脏工作区 hash 归因、porcelain 逐文件展开。
|
|
104
102
|
- **R4** ✅ 参数语义:`get_task_report(round=0)` 合法、手动验收分配新轮次不覆盖、`extraChecks` 追加 + `checksMode=replace`、`optional` 不影响 verdict、`baselineRef` 校验。
|
|
105
|
-
- **R5** ✅ 移除路径硬编码(`{LOCALAPPDATA}` 等占位符 + 平台标准候选),config/profile/projects
|
|
106
|
-
- **R6**
|
|
107
|
-
- **R7**
|
|
108
|
-
- **R8**
|
|
103
|
+
- **R5** ✅ 移除路径硬编码(`{LOCALAPPDATA}` 等占位符 + 平台标准候选),config/profile/projects 热加载。
|
|
104
|
+
- **R6** ✅ CI 矩阵 Windows/macOS/Linux × Node 20/22 全绿;Release 版本一致性校验;tarball 内容校验。
|
|
105
|
+
- **R7** ✅ 真实 Tianshu serve 会话实测(技能加载 + MCP 工具 + stub 任务闭环);npm v0.1.2 发布 + npx 拉起连通。
|
|
106
|
+
- **R8** ✅ 文档同步 + 复验报告。
|
|
107
|
+
|
|
108
|
+
二次整改(按 `.codex/plans/2026-09-08-second-remediation-plan.md`):
|
|
109
|
+
- **S1** ✅ 无理由取消稳定落 `cancelled`(新增 `cancelRequestedAt`/`abortSource` 独立字段,不依赖可选 reason)。
|
|
110
|
+
- **S2** ✅ 普通超时统一落 `failed(timeout)` + 一次 `timeout_killed` 事件,顺序固定。
|
|
111
|
+
- **S3** ✅ tracked 预脏净差异归因:staged/unstaged 未变文件不再误报为 agent 变更。
|
|
112
|
+
- **S4** ✅ `verify_task(taskId)` 持久化更新原任务元数据(`reportRound`/`verificationSource`/`latestVerificationVerdict`,保留 agentId);MCP 版本单一来源(build 注入,服务端实测 version=0.1.2)。
|
|
113
|
+
- **S5** ✅ config/profiles/projects 热加载 last-known-good + 内容 sha256 失效检测(修复损坏 JSON 被当缺失重置的 bug)。
|
|
114
|
+
- **S6** ✅ CI/Release npm ci 重试修正(成功即停/3 次上限/attempt 计数);Vitest v3 升级(audit 0 漏洞);纯文本状态标记(emoji 扫描测试)。
|
|
115
|
+
- **S7/S8/S9** 真实完整证据与新版发布待最终闭环(详见 `.codex/review/`)。
|
|
109
116
|
|
|
110
117
|
## 推荐用法(给天枢的提示语)
|
|
111
118
|
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -453,6 +453,55 @@ export declare const ProjectRecordSchema: z.ZodObject<{
|
|
|
453
453
|
defaultAgentId?: string | undefined;
|
|
454
454
|
}>;
|
|
455
455
|
export type ProjectRecord = z.infer<typeof ProjectRecordSchema>;
|
|
456
|
+
/** projects.json 整体文件 schema(S5:不再用对象强制类型转换) */
|
|
457
|
+
export declare const ProjectsFileSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
458
|
+
path: z.ZodString;
|
|
459
|
+
displayPath: z.ZodOptional<z.ZodString>;
|
|
460
|
+
firstSeenAt: z.ZodString;
|
|
461
|
+
lastSeenAt: z.ZodString;
|
|
462
|
+
verify: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
463
|
+
name: z.ZodString;
|
|
464
|
+
cmd: z.ZodUnion<[z.ZodString, z.ZodArray<z.ZodString, "many">]>;
|
|
465
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
466
|
+
optional: z.ZodOptional<z.ZodBoolean>;
|
|
467
|
+
}, "strip", z.ZodTypeAny, {
|
|
468
|
+
name: string;
|
|
469
|
+
cmd: string | string[];
|
|
470
|
+
timeoutMs?: number | undefined;
|
|
471
|
+
optional?: boolean | undefined;
|
|
472
|
+
}, {
|
|
473
|
+
name: string;
|
|
474
|
+
cmd: string | string[];
|
|
475
|
+
timeoutMs?: number | undefined;
|
|
476
|
+
optional?: boolean | undefined;
|
|
477
|
+
}>, "many">>;
|
|
478
|
+
defaultAgentId: z.ZodOptional<z.ZodString>;
|
|
479
|
+
}, "strip", z.ZodTypeAny, {
|
|
480
|
+
path: string;
|
|
481
|
+
firstSeenAt: string;
|
|
482
|
+
lastSeenAt: string;
|
|
483
|
+
displayPath?: string | undefined;
|
|
484
|
+
verify?: {
|
|
485
|
+
name: string;
|
|
486
|
+
cmd: string | string[];
|
|
487
|
+
timeoutMs?: number | undefined;
|
|
488
|
+
optional?: boolean | undefined;
|
|
489
|
+
}[] | undefined;
|
|
490
|
+
defaultAgentId?: string | undefined;
|
|
491
|
+
}, {
|
|
492
|
+
path: string;
|
|
493
|
+
firstSeenAt: string;
|
|
494
|
+
lastSeenAt: string;
|
|
495
|
+
displayPath?: string | undefined;
|
|
496
|
+
verify?: {
|
|
497
|
+
name: string;
|
|
498
|
+
cmd: string | string[];
|
|
499
|
+
timeoutMs?: number | undefined;
|
|
500
|
+
optional?: boolean | undefined;
|
|
501
|
+
}[] | undefined;
|
|
502
|
+
defaultAgentId?: string | undefined;
|
|
503
|
+
}>>;
|
|
504
|
+
export type ProjectsFile = z.infer<typeof ProjectsFileSchema>;
|
|
456
505
|
export declare const AcceptanceConfigSchema: z.ZodObject<{
|
|
457
506
|
checks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
458
507
|
name: z.ZodString;
|
package/dist/config/schema.js
CHANGED
|
@@ -111,6 +111,8 @@ export const ProjectRecordSchema = z.object({
|
|
|
111
111
|
verify: z.array(AcceptanceCheckSchema).optional(), // 管理员补录的验收配置(可选)
|
|
112
112
|
defaultAgentId: z.string().optional(),
|
|
113
113
|
});
|
|
114
|
+
/** projects.json 整体文件 schema(S5:不再用对象强制类型转换) */
|
|
115
|
+
export const ProjectsFileSchema = z.record(z.string().min(1), ProjectRecordSchema);
|
|
114
116
|
/* ---------------- 项目内 .tianshu-mcp/acceptance.json ---------------- */
|
|
115
117
|
export const AcceptanceConfigSchema = z.object({
|
|
116
118
|
checks: z.array(AcceptanceCheckSchema).default([]),
|
package/dist/config/store.d.ts
CHANGED
|
@@ -23,17 +23,21 @@ export declare class DataHome {
|
|
|
23
23
|
taskDir(taskId: string): string;
|
|
24
24
|
init(): Promise<void>;
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* 内容失效签名(S5):文件内容 sha256。能检测同一 mtime tick 内的同长度修改;
|
|
27
|
+
* 无法读文件时回退 "0"(每次重读)。
|
|
28
28
|
*/
|
|
29
29
|
private fileStamp;
|
|
30
30
|
loadConfig(): Promise<ServerConfig>;
|
|
31
|
+
/** 读 JSON 且区分"文件不存在"与"JSON 损坏" */
|
|
32
|
+
private readJsonWithExists;
|
|
31
33
|
saveConfig(cfg: ServerConfig): Promise<void>;
|
|
32
|
-
/** 用户级 profiles(数据目录)叠加内置 profiles
|
|
34
|
+
/** 用户级 profiles(数据目录)叠加内置 profiles,用户键覆盖内置;按内容 stamp 热加载(S5) */
|
|
33
35
|
loadProfiles(): Promise<Record<string, AgentProfile>>;
|
|
34
36
|
saveProfiles(profiles: Record<string, AgentProfile>): Promise<void>;
|
|
35
37
|
/** 供 get_profiles / adapter resolve 使用:返回一份 profile(含内置合并) */
|
|
36
38
|
getProfile(id: string): Promise<AgentProfile | undefined>;
|
|
39
|
+
/** 解析 projects.json:Zod 校验(S5),解析/校验失败保留上一有效值 */
|
|
40
|
+
private loadProjectsRaw;
|
|
37
41
|
loadProjects(): Promise<Record<string, ProjectRecord>>;
|
|
38
42
|
registerProject(p: string, defaultAgentId?: string): Promise<{
|
|
39
43
|
hash: string;
|
package/dist/config/store.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import fsp from "node:fs/promises";
|
|
8
|
-
import { AgentProfilesFileSchema, ServerConfigSchema, } from "./schema.js";
|
|
9
|
-
import {
|
|
8
|
+
import { AgentProfilesFileSchema, ProjectsFileSchema, ServerConfigSchema, } from "./schema.js";
|
|
9
|
+
import { writeJsonAtomic, mkdirp, readDirSafe, exists } from "../util/fs.js";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
10
11
|
import { projectHash, normPath } from "../util/path.js";
|
|
11
12
|
import { nowIso } from "../util/id.js";
|
|
12
13
|
export function resolveDataHome() {
|
|
@@ -76,43 +77,63 @@ export class DataHome {
|
|
|
76
77
|
}
|
|
77
78
|
/* ---------- config ---------- */
|
|
78
79
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
80
|
+
* 内容失效签名(S5):文件内容 sha256。能检测同一 mtime tick 内的同长度修改;
|
|
81
|
+
* 无法读文件时回退 "0"(每次重读)。
|
|
81
82
|
*/
|
|
82
83
|
async fileStamp(p) {
|
|
83
84
|
try {
|
|
84
|
-
const
|
|
85
|
-
return
|
|
85
|
+
const buf = await fsp.readFile(p);
|
|
86
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
86
87
|
}
|
|
87
88
|
catch {
|
|
88
|
-
return "0
|
|
89
|
+
return "0";
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
async loadConfig() {
|
|
92
93
|
const stamp = await this.fileStamp(this.configPath);
|
|
93
94
|
if (this.configCache && this.configCacheStamp === stamp)
|
|
94
95
|
return this.configCache;
|
|
95
|
-
const raw = await
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
this.configCache = cfg;
|
|
96
|
+
const { raw, exists: fileExists } = await this.readJsonWithExists(this.configPath);
|
|
97
|
+
// 文件不存在 → 首次默认
|
|
98
|
+
if (!fileExists) {
|
|
99
|
+
const def = ServerConfigSchema.parse({});
|
|
100
|
+
if (!this.configCache) {
|
|
101
|
+
this.configCache = def;
|
|
102
102
|
this.configCacheStamp = stamp;
|
|
103
103
|
}
|
|
104
|
-
|
|
105
|
-
// 解析失败保留上一有效配置(R5)
|
|
106
|
-
this.logger.warn(`config.json 解析失败,保留上一有效配置: ${r.error.message}`);
|
|
107
|
-
if (this.configCache)
|
|
108
|
-
return this.configCache;
|
|
109
|
-
}
|
|
104
|
+
return this.configCache;
|
|
110
105
|
}
|
|
111
|
-
|
|
112
|
-
|
|
106
|
+
if (raw == null) {
|
|
107
|
+
// 文件存在但 JSON 损坏 → 保留上一有效配置(S5 last-known-good)
|
|
108
|
+
this.logger.warn(`config.json JSON 损坏,保留上一有效配置`);
|
|
109
|
+
if (this.configCache)
|
|
110
|
+
return this.configCache;
|
|
111
|
+
return ServerConfigSchema.parse({});
|
|
112
|
+
}
|
|
113
|
+
const r = ServerConfigSchema.safeParse(raw);
|
|
114
|
+
if (r.success) {
|
|
115
|
+
this.configCache = r.data;
|
|
113
116
|
this.configCacheStamp = stamp;
|
|
117
|
+
return r.data;
|
|
118
|
+
}
|
|
119
|
+
// schema 校验失败 → 保留上一有效配置
|
|
120
|
+
this.logger.warn(`config.json schema 校验失败,保留上一有效配置: ${r.error.message}`);
|
|
121
|
+
return this.configCache ?? ServerConfigSchema.parse({});
|
|
122
|
+
}
|
|
123
|
+
/** 读 JSON 且区分"文件不存在"与"JSON 损坏" */
|
|
124
|
+
async readJsonWithExists(p) {
|
|
125
|
+
try {
|
|
126
|
+
const text = await fsp.readFile(p, "utf8");
|
|
127
|
+
try {
|
|
128
|
+
return { raw: JSON.parse(text), exists: true };
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return { raw: null, exists: true };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return { raw: null, exists: false };
|
|
114
136
|
}
|
|
115
|
-
return this.configCache ?? cfg;
|
|
116
137
|
}
|
|
117
138
|
async saveConfig(cfg) {
|
|
118
139
|
await writeJsonAtomic(this.configPath, cfg);
|
|
@@ -120,7 +141,7 @@ export class DataHome {
|
|
|
120
141
|
this.configCacheStamp = await this.fileStamp(this.configPath);
|
|
121
142
|
}
|
|
122
143
|
/* ---------- agent-profiles ---------- */
|
|
123
|
-
/** 用户级 profiles(数据目录)叠加内置 profiles
|
|
144
|
+
/** 用户级 profiles(数据目录)叠加内置 profiles,用户键覆盖内置;按内容 stamp 热加载(S5) */
|
|
124
145
|
async loadProfiles() {
|
|
125
146
|
const stamp = await this.fileStamp(this.profilesPath);
|
|
126
147
|
if (this._profilesCache && this._profilesCacheStamp === stamp)
|
|
@@ -130,7 +151,16 @@ export class DataHome {
|
|
|
130
151
|
for (const [k, v] of Object.entries(this.builtinProfiles)) {
|
|
131
152
|
merged[k] = { ...v, id: k };
|
|
132
153
|
}
|
|
133
|
-
const raw = await
|
|
154
|
+
const { raw, exists: fileExists } = await this.readJsonWithExists(this.profilesPath);
|
|
155
|
+
if (fileExists && raw == null) {
|
|
156
|
+
// JSON 损坏 → 保留上一有效(若有);否则仅内置
|
|
157
|
+
this.logger.warn(`agent-profiles.json JSON 损坏,保留上一有效 profiles`);
|
|
158
|
+
if (this._profilesCache)
|
|
159
|
+
return this._profilesCache;
|
|
160
|
+
this._profilesCache = merged;
|
|
161
|
+
this._profilesCacheStamp = stamp;
|
|
162
|
+
return merged;
|
|
163
|
+
}
|
|
134
164
|
if (raw != null) {
|
|
135
165
|
const r = AgentProfilesFileSchema.safeParse(raw);
|
|
136
166
|
if (r.success) {
|
|
@@ -139,7 +169,7 @@ export class DataHome {
|
|
|
139
169
|
}
|
|
140
170
|
}
|
|
141
171
|
else {
|
|
142
|
-
this.logger.warn(`agent-profiles.json
|
|
172
|
+
this.logger.warn(`agent-profiles.json 校验失败,保留上一有效 profiles: ${r.error.message}`);
|
|
143
173
|
if (this._profilesCache)
|
|
144
174
|
return this._profilesCache;
|
|
145
175
|
}
|
|
@@ -166,18 +196,29 @@ export class DataHome {
|
|
|
166
196
|
return all[id];
|
|
167
197
|
}
|
|
168
198
|
/* ---------- projects 自动登记 ---------- */
|
|
199
|
+
/** 解析 projects.json:Zod 校验(S5),解析/校验失败保留上一有效值 */
|
|
200
|
+
async loadProjectsRaw() {
|
|
201
|
+
const { raw, exists: fileExists } = await this.readJsonWithExists(this.projectsPath);
|
|
202
|
+
if (!fileExists)
|
|
203
|
+
return { ok: true, map: {}, missing: true }; // 文件不存在 = 空(首次)
|
|
204
|
+
if (raw == null)
|
|
205
|
+
return { ok: false, map: {}, error: "JSON 损坏" };
|
|
206
|
+
const r = ProjectsFileSchema.safeParse(raw);
|
|
207
|
+
if (!r.success) {
|
|
208
|
+
return { ok: false, map: {}, error: r.error.message };
|
|
209
|
+
}
|
|
210
|
+
return { ok: true, map: r.data };
|
|
211
|
+
}
|
|
169
212
|
async loadProjects() {
|
|
170
213
|
const stamp = await this.fileStamp(this.projectsPath);
|
|
171
214
|
if (this.projectsCache && this.projectsCacheStamp === stamp)
|
|
172
215
|
return this.projectsCache;
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
}
|
|
216
|
+
const { ok, map, error } = await this.loadProjectsRaw();
|
|
217
|
+
if (!ok) {
|
|
218
|
+
this.logger.warn(`projects.json 解析失败,保留上一有效 projects: ${error}`);
|
|
219
|
+
if (this.projectsCache)
|
|
220
|
+
return this.projectsCache;
|
|
221
|
+
return {}; // 首次即非法:给空 map(不写入覆盖磁盘)
|
|
181
222
|
}
|
|
182
223
|
this.projectsCache = map;
|
|
183
224
|
this.projectsCacheStamp = stamp;
|
|
@@ -195,6 +236,7 @@ export class DataHome {
|
|
|
195
236
|
projects[hash] = record;
|
|
196
237
|
this.projectsCache = projects;
|
|
197
238
|
await writeJsonAtomic(this.projectsPath, projects);
|
|
239
|
+
this.projectsCacheStamp = await this.fileStamp(this.projectsPath);
|
|
198
240
|
return { hash, record };
|
|
199
241
|
}
|
|
200
242
|
async updateProjectVerify(hash, verify) {
|
|
@@ -205,6 +247,7 @@ export class DataHome {
|
|
|
205
247
|
rec.verify = verify.map((v) => ({ name: v.name, cmd: v.cmd }));
|
|
206
248
|
this.projectsCache = projects;
|
|
207
249
|
await writeJsonAtomic(this.projectsPath, projects);
|
|
250
|
+
this.projectsCacheStamp = await this.fileStamp(this.projectsPath);
|
|
208
251
|
}
|
|
209
252
|
async projectByPath(p) {
|
|
210
253
|
const norm = normPath(p);
|
package/dist/loop/fix-loop.d.ts
CHANGED
|
@@ -30,9 +30,15 @@ export declare class TaskOrchestrator {
|
|
|
30
30
|
run(): Promise<OrchestrateResult>;
|
|
31
31
|
private aborted;
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
33
|
+
* 统一超时终态(S2):落 failed(errorType=timeout) 并记录一次 timeout_killed 事件。
|
|
34
|
+
* 普通 runRes.timeout 与 abort guard 超时共用此路径,保证事件不重复、顺序固定。
|
|
35
|
+
*/
|
|
36
|
+
private timeoutTerminal;
|
|
37
|
+
/**
|
|
38
|
+
* 取消/中断的终态落盘(S1):用独立 cancelRequestedAt/abortSource 判断来源,不依赖可选 reason。
|
|
39
|
+
* - 用户取消(cancelRequestedAt 已置位 / abortSource=user / 排队中)→ cancelled
|
|
40
|
+
* - server 关闭/EOF/未显式取消的中止 → interrupted
|
|
41
|
+
* - 超时 → failed(timeout) + timeout_killed(委托 timeoutTerminal)
|
|
36
42
|
*/
|
|
37
43
|
private abortTerminal;
|
|
38
44
|
private finish;
|
package/dist/loop/fix-loop.js
CHANGED
|
@@ -53,7 +53,8 @@ export class TaskOrchestrator {
|
|
|
53
53
|
return this.finish("failed", "spawn", `agent 基础设施失败:${runRes.error ?? "未知"}(日志 ${runRes.logFile})`);
|
|
54
54
|
}
|
|
55
55
|
if (runRes.timeout) {
|
|
56
|
-
|
|
56
|
+
// S2:统一超时终态 —— 落 failed(timeout) 并记录一次 timeout_killed
|
|
57
|
+
return this.timeoutTerminal(`任务超时(${meta.taskTimeoutMs}ms),进程树已终止。日志 ${runRes.logFile}`);
|
|
57
58
|
}
|
|
58
59
|
if (runRes.killed) {
|
|
59
60
|
return this.abortTerminal();
|
|
@@ -105,31 +106,47 @@ export class TaskOrchestrator {
|
|
|
105
106
|
return this.signal?.aborted ?? false;
|
|
106
107
|
}
|
|
107
108
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* 由 orchestrator 在子进程真正关闭后调用,避免只返回游离 status。
|
|
109
|
+
* 统一超时终态(S2):落 failed(errorType=timeout) 并记录一次 timeout_killed 事件。
|
|
110
|
+
* 普通 runRes.timeout 与 abort guard 超时共用此路径,保证事件不重复、顺序固定。
|
|
111
111
|
*/
|
|
112
|
-
async
|
|
112
|
+
async timeoutTerminal(reason) {
|
|
113
113
|
if (this.done)
|
|
114
114
|
return { status: this.meta.status, meta: this.meta, reason: this.meta.lastMessage };
|
|
115
115
|
this.done = true;
|
|
116
116
|
const meta = this.meta;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
117
|
+
meta.errorType = "timeout";
|
|
118
|
+
meta.abortSource = "timeout";
|
|
119
|
+
meta.lastMessage = reason;
|
|
120
|
+
// 事件顺序固定:先 failed 落盘(含 finishedAt),再记 timeout_killed
|
|
121
|
+
await this.deps.store.updateStatus(meta, "failed", reason);
|
|
122
|
+
await this.deps.store.appendEvent(meta.taskId, "timeout_killed", "failed", reason).catch(() => { });
|
|
123
|
+
return { status: "failed", meta, reason, summary: reason };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 取消/中断的终态落盘(S1):用独立 cancelRequestedAt/abortSource 判断来源,不依赖可选 reason。
|
|
127
|
+
* - 用户取消(cancelRequestedAt 已置位 / abortSource=user / 排队中)→ cancelled
|
|
128
|
+
* - server 关闭/EOF/未显式取消的中止 → interrupted
|
|
129
|
+
* - 超时 → failed(timeout) + timeout_killed(委托 timeoutTerminal)
|
|
130
|
+
*/
|
|
131
|
+
async abortTerminal() {
|
|
132
|
+
if (this.done)
|
|
133
|
+
return { status: this.meta.status, meta: this.meta, reason: this.meta.lastMessage };
|
|
134
|
+
if (this.meta.abortSource === "timeout") {
|
|
135
|
+
return this.timeoutTerminal(this.meta.lastMessage || "任务超时,进程树已终止。");
|
|
123
136
|
}
|
|
124
|
-
|
|
125
|
-
const
|
|
137
|
+
this.done = true;
|
|
138
|
+
const meta = this.meta;
|
|
139
|
+
// 用户取消判定:cancelRequestedAt 置位 或 显式 user abortSource 或 queued 阶段取消
|
|
140
|
+
const isCancelled = Boolean(meta.cancelRequestedAt) || meta.abortSource === "user" || meta.status === "queued";
|
|
126
141
|
if (isCancelled) {
|
|
127
142
|
meta.errorType = "cancelled";
|
|
143
|
+
meta.abortSource = "user";
|
|
128
144
|
meta.lastMessage = meta.cancelReason ? `已取消:${meta.cancelReason}` : "已取消";
|
|
129
145
|
await this.deps.store.updateStatus(meta, "cancelled", meta.lastMessage);
|
|
130
146
|
}
|
|
131
147
|
else {
|
|
132
148
|
meta.errorType = "interrupted";
|
|
149
|
+
meta.abortSource = "shutdown";
|
|
133
150
|
meta.lastMessage = "任务已中断(server 退出 / 父进程 EOF / 未显式取消的中止)。";
|
|
134
151
|
await this.deps.store.updateStatus(meta, "interrupted", meta.lastMessage);
|
|
135
152
|
}
|
package/dist/mcp/formatter.d.ts
CHANGED
package/dist/mcp/formatter.js
CHANGED
|
@@ -26,6 +26,9 @@ export function metaFromTask(meta, extra) {
|
|
|
26
26
|
errorType: meta.errorType ?? undefined,
|
|
27
27
|
cancelReason: meta.cancelReason,
|
|
28
28
|
finishedAt: meta.finishedAt,
|
|
29
|
+
reportRound: meta.reportRound,
|
|
30
|
+
verificationSource: meta.verificationSource,
|
|
31
|
+
latestVerificationVerdict: meta.latestVerificationVerdict,
|
|
29
32
|
...extra,
|
|
30
33
|
};
|
|
31
34
|
}
|
package/dist/mcp/handlers.js
CHANGED
|
@@ -126,9 +126,9 @@ function describeStatus(meta) {
|
|
|
126
126
|
running: "运行中(agent 正在开发)",
|
|
127
127
|
verify_start: "验收中(自动命令检查 + 代码分析)",
|
|
128
128
|
fixing: "返修中(上一轮验收失败,agent 正在按反馈修改)",
|
|
129
|
-
succeeded: "
|
|
130
|
-
failed: "
|
|
131
|
-
needs_attention: "
|
|
129
|
+
succeeded: "[PASS] 任务成功",
|
|
130
|
+
failed: "[FAIL] 任务失败",
|
|
131
|
+
needs_attention: "[WARN] 需要人工介入(自动返修轮次已用尽或可修性存疑)",
|
|
132
132
|
cancelled: "已取消",
|
|
133
133
|
interrupted: "已中断(server 重启/退出)",
|
|
134
134
|
};
|
|
@@ -249,8 +249,9 @@ function verifyTaskHandler(ctx) {
|
|
|
249
249
|
});
|
|
250
250
|
// round 分配:手动验收写入任务目录时不能覆盖已有 report-0.*,分配下一可用轮次
|
|
251
251
|
const round = await nextReportRound(store, taskId);
|
|
252
|
+
const verifyTaskId = taskId ?? `vfy_${Date.now()}`;
|
|
252
253
|
const req = {
|
|
253
|
-
taskId:
|
|
254
|
+
taskId: verifyTaskId,
|
|
254
255
|
projectPath,
|
|
255
256
|
displayPath,
|
|
256
257
|
taskText,
|
|
@@ -265,31 +266,57 @@ function verifyTaskHandler(ctx) {
|
|
|
265
266
|
};
|
|
266
267
|
const { report, passed } = await engine.runVerify(req);
|
|
267
268
|
const head = passed
|
|
268
|
-
?
|
|
269
|
-
:
|
|
269
|
+
? `[PASS] 手动验收通过(reportRound ${round}):${report.checks.filter((c) => c.passed).length}/${report.checks.length} 项检查通过。`
|
|
270
|
+
: `[FAIL] 手动验收失败(reportRound ${round}):${report.checks.filter((c) => !c.passed && !c.skipped).length} 项检查未通过。`;
|
|
270
271
|
const changed = report.analysis.changedFiles.length + report.analysis.untrackedFiles.length;
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
272
|
+
const diffstat = `+${report.analysis.diffstat.totalAdd} -${report.analysis.diffstat.totalDel}`;
|
|
273
|
+
// S4:taskId 模式下更新并持久化原任务元数据(保留原 agentId,不改任务终态;新增单独验收结论字段)。
|
|
274
|
+
let resultMeta;
|
|
275
|
+
if (taskId) {
|
|
276
|
+
const real = await manager.getMeta(taskId);
|
|
277
|
+
if (!real)
|
|
278
|
+
return errorResult(`任务不存在: ${taskId}`);
|
|
279
|
+
real.reportRound = round;
|
|
280
|
+
real.verificationSource = "manual";
|
|
281
|
+
real.latestVerificationVerdict = passed ? "passed" : "failed";
|
|
282
|
+
real.reportMd = report.files.md;
|
|
283
|
+
real.reportJson = report.files.json;
|
|
284
|
+
real.lastMessage = head;
|
|
285
|
+
real.changedFiles = [...report.analysis.changedFiles, ...report.analysis.untrackedFiles];
|
|
286
|
+
real.diffstat = diffstat;
|
|
287
|
+
real.updatedAt = report.finishedAt;
|
|
288
|
+
await manager.persistMetaUpdate(real);
|
|
289
|
+
resultMeta = real;
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
// 独立 projectPath 验收:创建并持久化独立 vfy 记录
|
|
293
|
+
resultMeta = {
|
|
294
|
+
taskId: verifyTaskId,
|
|
295
|
+
status: passed ? "succeeded" : "failed",
|
|
296
|
+
projectPath,
|
|
297
|
+
displayPath,
|
|
298
|
+
agentId: "manual-verify",
|
|
299
|
+
task: taskText ?? "(手动验收)",
|
|
300
|
+
autoVerify: true,
|
|
301
|
+
autoFixRounds: 0,
|
|
302
|
+
taskTimeoutMs: 0,
|
|
303
|
+
round: 0,
|
|
304
|
+
roundsUsed: 0,
|
|
305
|
+
reportRound: round,
|
|
306
|
+
verificationSource: "manual",
|
|
307
|
+
latestVerificationVerdict: passed ? "passed" : "failed",
|
|
308
|
+
createdAt: report.startedAt,
|
|
309
|
+
updatedAt: report.finishedAt,
|
|
310
|
+
lastMessage: head,
|
|
311
|
+
changedFiles: [...report.analysis.changedFiles, ...report.analysis.untrackedFiles],
|
|
312
|
+
diffstat,
|
|
313
|
+
reportMd: report.files.md,
|
|
314
|
+
reportJson: report.files.json,
|
|
315
|
+
};
|
|
316
|
+
await manager.persistMetaUpdate(resultMeta);
|
|
317
|
+
}
|
|
318
|
+
const detailLines = [`${head}`, `变更 ${changed} 个文件,diffstat ${resultMeta.diffstat}。`, `报告:${report.files.md}`, `JSON:${report.files.json}`];
|
|
319
|
+
return formatToolResult(detailLines.join("\n"), metaFromTask(resultMeta));
|
|
293
320
|
};
|
|
294
321
|
}
|
|
295
322
|
function reworkTaskHandler(ctx) {
|
|
@@ -320,7 +347,7 @@ function getProfilesHandler(ctx) {
|
|
|
320
347
|
const rows = [];
|
|
321
348
|
for (const id of ids) {
|
|
322
349
|
const r = await registry.resolve(id, true);
|
|
323
|
-
const mark = r.ok ? "
|
|
350
|
+
const mark = r.ok ? "[PASS] 可用" : "[FAIL] 不可用";
|
|
324
351
|
rows.push(`${mark}\t${id}\t${r.displayName}\t${r.message}${r.discovered ? ` [探测来源: ${r.discovered.source}]` : ""}`);
|
|
325
352
|
}
|
|
326
353
|
const head = "Agent 适配与可执行探测结果(列: 可用 / agentId / 名称 / 说明)";
|
package/dist/server.js
CHANGED
|
@@ -15,6 +15,7 @@ import { makeHandlers } from "./mcp/handlers.js";
|
|
|
15
15
|
import { TOOL_DEFS } from "./mcp/tools.js";
|
|
16
16
|
import { Logger as LoggerCtor } from "./util/log.js";
|
|
17
17
|
import { skillSelfInstall } from "./util/skill-install.js";
|
|
18
|
+
import { MCP_SERVER_VERSION } from "./version.generated.js";
|
|
18
19
|
export async function buildServer(opts = {}) {
|
|
19
20
|
const home = opts.home ?? resolveDataHome();
|
|
20
21
|
const logger = opts.logger ?? (await LoggerCtor.create(path.join(home, "logs")));
|
|
@@ -42,7 +43,7 @@ export async function buildServer(opts = {}) {
|
|
|
42
43
|
defaultAutoVerify: true,
|
|
43
44
|
defaultAutoFixRounds: 0,
|
|
44
45
|
});
|
|
45
|
-
const server = new McpServer({ name: "tianshu-mcp", version:
|
|
46
|
+
const server = new McpServer({ name: "tianshu-mcp", version: MCP_SERVER_VERSION }, {
|
|
46
47
|
capabilities: { tools: {} },
|
|
47
48
|
instructions: "tianshu-mcp:调度外部 AI-Agent(codex/zcode)完成 项目开发 → 验收 → 返修 闭环。工具返回文本 + ---tianshu-mcp-meta--- JSON 块。run_task 是异步的:先拿 taskId 再用 query_task 轮询。",
|
|
48
49
|
});
|
|
@@ -43,6 +43,8 @@ export declare class TaskManager {
|
|
|
43
43
|
get activeCount(): number;
|
|
44
44
|
getMaxRunning(): number;
|
|
45
45
|
getMeta(taskId: string): Promise<TaskMeta | null>;
|
|
46
|
+
/** S4:外部对终态任务元数据的更新(如手动 verify 更新报告指针/轮次)——写快照并同步内存 map */
|
|
47
|
+
persistMetaUpdate(meta: TaskMeta): Promise<void>;
|
|
46
48
|
listTasks(filter?: {
|
|
47
49
|
projectPath?: string;
|
|
48
50
|
status?: string;
|
|
@@ -66,7 +68,7 @@ export declare class TaskManager {
|
|
|
66
68
|
}>;
|
|
67
69
|
/** server 退出:终止全部活动任务并标 interrupted(排队中任务也归档) */
|
|
68
70
|
shutdownInterrupt(): Promise<void>;
|
|
69
|
-
/** 有界等待子进程真正关闭后落 interrupted */
|
|
71
|
+
/** 有界等待子进程真正关闭后落 interrupted(server 关闭路径,不得误记为 cancelled) */
|
|
70
72
|
private persistInterrupted;
|
|
71
73
|
private enqueue;
|
|
72
74
|
/** 该项目当前是否有 running 任务(每项目串行闸) */
|
|
@@ -30,6 +30,7 @@ export class TaskManager {
|
|
|
30
30
|
for (const meta of legacy) {
|
|
31
31
|
this.tasks.set(meta.taskId, meta);
|
|
32
32
|
meta.errorType = "interrupted";
|
|
33
|
+
meta.abortSource = "shutdown";
|
|
33
34
|
meta.lastMessage = "server 重启遗留(启动时归档,不续跑)。";
|
|
34
35
|
await this.store.updateStatus(meta, "interrupted", "server 重启遗留归档");
|
|
35
36
|
}
|
|
@@ -45,6 +46,11 @@ export class TaskManager {
|
|
|
45
46
|
async getMeta(taskId) {
|
|
46
47
|
return this.tasks.get(taskId) ?? (await this.store.readSnapshot(taskId));
|
|
47
48
|
}
|
|
49
|
+
/** S4:外部对终态任务元数据的更新(如手动 verify 更新报告指针/轮次)——写快照并同步内存 map */
|
|
50
|
+
async persistMetaUpdate(meta) {
|
|
51
|
+
this.tasks.set(meta.taskId, meta);
|
|
52
|
+
await this.store.writeSnapshot(meta);
|
|
53
|
+
}
|
|
48
54
|
async listTasks(filter) {
|
|
49
55
|
let metas = await this.store.listTaskSnapshots();
|
|
50
56
|
if (filter?.projectPath) {
|
|
@@ -123,8 +129,14 @@ export class TaskManager {
|
|
|
123
129
|
}
|
|
124
130
|
meta.status = "cancelled";
|
|
125
131
|
meta.errorType = "cancelled";
|
|
132
|
+
meta.abortSource = "user";
|
|
133
|
+
meta.cancelRequestedAt = nowIso();
|
|
126
134
|
meta.cancelReason = reason;
|
|
135
|
+
meta.finishedAt = meta.cancelRequestedAt;
|
|
136
|
+
meta.updatedAt = meta.cancelRequestedAt;
|
|
127
137
|
meta.lastMessage = reason ? `已取消(排队中):${reason}` : "已取消(排队中)";
|
|
138
|
+
// 追加 cancel_requested 事件后再落 cancelled,保证事件流完整
|
|
139
|
+
await this.store.appendEvent(meta.taskId, "cancel_requested", "queued", reason ? `收到取消请求:${reason}` : "收到取消请求(无 reason)");
|
|
128
140
|
await this.store.updateStatus(meta, "cancelled", meta.lastMessage);
|
|
129
141
|
return { found: true };
|
|
130
142
|
}
|
|
@@ -154,6 +166,7 @@ export class TaskManager {
|
|
|
154
166
|
if (meta.status === "queued") {
|
|
155
167
|
meta.status = "interrupted";
|
|
156
168
|
meta.errorType = "interrupted";
|
|
169
|
+
meta.abortSource = "shutdown";
|
|
157
170
|
meta.lastMessage = "server 退出,排队中任务已归档";
|
|
158
171
|
await this.store.updateStatus(meta, "interrupted", meta.lastMessage).catch(() => { });
|
|
159
172
|
void taskId;
|
|
@@ -163,7 +176,7 @@ export class TaskManager {
|
|
|
163
176
|
this.runningCount = 0;
|
|
164
177
|
this.queue.clear();
|
|
165
178
|
}
|
|
166
|
-
/** 有界等待子进程真正关闭后落 interrupted */
|
|
179
|
+
/** 有界等待子进程真正关闭后落 interrupted(server 关闭路径,不得误记为 cancelled) */
|
|
167
180
|
async persistInterrupted(meta) {
|
|
168
181
|
const deadline = Date.now() + 2000;
|
|
169
182
|
for (;;) {
|
|
@@ -176,6 +189,7 @@ export class TaskManager {
|
|
|
176
189
|
if (ACTIVE_STATUSES.includes(meta.status)) {
|
|
177
190
|
meta.status = "interrupted";
|
|
178
191
|
meta.errorType = "interrupted";
|
|
192
|
+
meta.abortSource = "shutdown";
|
|
179
193
|
meta.lastMessage = "server 退出,进程已终止";
|
|
180
194
|
await this.store.updateStatus(meta, "interrupted", meta.lastMessage).catch(() => { });
|
|
181
195
|
}
|
|
@@ -248,6 +262,7 @@ export class TaskManager {
|
|
|
248
262
|
if (m && ACTIVE_STATUSES.includes(m.status)) {
|
|
249
263
|
this.logger.warn(`任务 ${meta.taskId} 超过任务级超时兜底(${meta.taskTimeoutMs}+${KILL_GRACE_MS}ms),标记 timeout 并 abort`);
|
|
250
264
|
m.errorType = "timeout";
|
|
265
|
+
m.abortSource = "timeout";
|
|
251
266
|
m.lastMessage = `任务超时兜底触发(${meta.taskTimeoutMs}ms + ${KILL_GRACE_MS}ms grace)。`;
|
|
252
267
|
ac.abort();
|
|
253
268
|
}
|
|
@@ -21,7 +21,7 @@ export declare class TaskStore {
|
|
|
21
21
|
readSnapshot(taskId: string): Promise<TaskMeta | null>;
|
|
22
22
|
updateStatus(meta: TaskMeta, status: TaskStatus, detail?: string, eventName?: TaskEvent["event"]): Promise<void>;
|
|
23
23
|
addNote(meta: TaskMeta, detail: string): Promise<void>;
|
|
24
|
-
/** cancel_requested 事件 +
|
|
24
|
+
/** cancel_requested 事件 + 独立取消意图字段落盘(S1:不依赖可选 reason 判断取消来源) */
|
|
25
25
|
markCancelRequested(meta: TaskMeta, reason?: string): Promise<void>;
|
|
26
26
|
saveReport(taskId: string, report: VerifyReport): Promise<void>;
|
|
27
27
|
/** 启动扫描:找出 running/interrupted/queued 遗留,供归档 */
|
package/dist/tasks/task-store.js
CHANGED
|
@@ -102,11 +102,13 @@ export class TaskStore {
|
|
|
102
102
|
await this.appendEvent(meta.taskId, "note", meta.status, detail);
|
|
103
103
|
await this.writeSnapshot(meta);
|
|
104
104
|
}
|
|
105
|
-
/** cancel_requested 事件 +
|
|
105
|
+
/** cancel_requested 事件 + 独立取消意图字段落盘(S1:不依赖可选 reason 判断取消来源) */
|
|
106
106
|
async markCancelRequested(meta, reason) {
|
|
107
107
|
meta.cancelReason = reason;
|
|
108
|
-
meta.
|
|
109
|
-
|
|
108
|
+
meta.cancelRequestedAt = nowIso();
|
|
109
|
+
meta.abortSource = "user";
|
|
110
|
+
meta.updatedAt = meta.cancelRequestedAt;
|
|
111
|
+
await this.appendEvent(meta.taskId, "cancel_requested", meta.status, reason ? `收到取消请求:${reason}` : "收到取消请求(无 reason)");
|
|
110
112
|
await this.writeSnapshot(meta);
|
|
111
113
|
}
|
|
112
114
|
/* ---------- 报告 ---------- */
|
package/dist/tasks/task.d.ts
CHANGED
|
@@ -98,8 +98,18 @@ export interface TaskMeta {
|
|
|
98
98
|
reportMd?: string;
|
|
99
99
|
reportJson?: string;
|
|
100
100
|
cancelReason?: string;
|
|
101
|
+
/** 用户取消意图的独立结构化标记(S1):一旦用户调用 cancel_task 即置位,不依赖可选 reason 推断 */
|
|
102
|
+
cancelRequestedAt?: string;
|
|
103
|
+
/** 中断来源:user=用户取消;shutdown=server 关闭/EOF;timeout=任务超时;internal=内部错误 */
|
|
104
|
+
abortSource?: "user" | "shutdown" | "timeout" | "internal";
|
|
101
105
|
/** rework_task 注入的追加指示(仅作用于下一轮 agent) */
|
|
102
106
|
reworkFeedback?: string;
|
|
107
|
+
/** S4:最近一次验收的报告轮次(0-based,reportRound)——区别于 agent roundsUsed */
|
|
108
|
+
reportRound?: number;
|
|
109
|
+
/** S4:最近一次验收来源:run_task 自动 / verify_task 手动;不覆盖 agentId */
|
|
110
|
+
verificationSource?: "auto" | "manual";
|
|
111
|
+
/** S4:最近一次手动验收结论(不改变 agent 任务终态时单独记录) */
|
|
112
|
+
latestVerificationVerdict?: "passed" | "failed";
|
|
103
113
|
}
|
|
104
114
|
/** manager 记录任务所需最小信息(内存态),与 TaskMeta 解耦 */
|
|
105
115
|
export interface TaskRecord {
|
|
@@ -30,8 +30,19 @@ export async function analyzeChanges(opts) {
|
|
|
30
30
|
// 基线前已存在的脏文件集合
|
|
31
31
|
const preChanged = new Set(baseline.preExistingChanged);
|
|
32
32
|
const preUntracked = new Set(baseline.preExistingUntracked);
|
|
33
|
-
//
|
|
34
|
-
|
|
33
|
+
// S3:排除"基线前脏且任务期间内容未变化"的 tracked 文件(避免把用户原有改动归因给 agent)。
|
|
34
|
+
// 对每个基线前脏文件,比较基线内容 hash 与当前工作树内容 hash;一致 = agent 未触碰 → 完全排除。
|
|
35
|
+
const unchangedPreDirty = new Set();
|
|
36
|
+
for (const f of [...preChanged]) {
|
|
37
|
+
const baseHash = baseDirtyHash(baseline, f);
|
|
38
|
+
if (!baseHash)
|
|
39
|
+
continue; // 无基线 hash(超大/删除等)不排除,交给 diff 判断
|
|
40
|
+
const nowHash = await fileHashQuick(path.join(opts.projectPath, f));
|
|
41
|
+
if (nowHash === baseHash)
|
|
42
|
+
unchangedPreDirty.add(f);
|
|
43
|
+
}
|
|
44
|
+
// 相对基线 ref 的已跟踪差异(含 agent 提交前移 + 工作树);排除未变化的预脏文件
|
|
45
|
+
const { numstat, changedLinesPerFile } = await diffSinceBaseline(opts.projectPath, baseline.head, unchangedPreDirty);
|
|
35
46
|
const perFile = [];
|
|
36
47
|
const trackedSeen = new Set();
|
|
37
48
|
// 已跟踪变更:diffSinceBaseline 以 baseline.head 为边界(含 agent 提交前移),净增删为 0 的剔除。
|
|
@@ -47,7 +58,7 @@ export async function analyzeChanges(opts) {
|
|
|
47
58
|
continue;
|
|
48
59
|
const full = path.join(opts.projectPath, f);
|
|
49
60
|
if (preUntracked.has(f)) {
|
|
50
|
-
const baseHash = baseline
|
|
61
|
+
const baseHash = baseDirtyHash(baseline, f);
|
|
51
62
|
const nowHash = await fileHashQuick(full);
|
|
52
63
|
if (baseHash && nowHash && baseHash === nowHash)
|
|
53
64
|
continue; // 基线前已有且未变:非任务改动
|
|
@@ -79,7 +90,7 @@ export async function analyzeChanges(opts) {
|
|
|
79
90
|
for (const f of parsed.untracked) {
|
|
80
91
|
if (preUntracked.has(f)) {
|
|
81
92
|
const full = path.join(opts.projectPath, f);
|
|
82
|
-
const baseHash = baseline
|
|
93
|
+
const baseHash = baseDirtyHash(baseline, f);
|
|
83
94
|
const nowHash = await fileHashQuick(full);
|
|
84
95
|
if (baseHash && nowHash && baseHash === nowHash)
|
|
85
96
|
continue;
|
|
@@ -102,7 +113,7 @@ export async function analyzeChanges(opts) {
|
|
|
102
113
|
return true;
|
|
103
114
|
// 基线前已有:内容变了才作为本轮新增未跟踪
|
|
104
115
|
const full = path.join(opts.projectPath, f);
|
|
105
|
-
const b = baseline
|
|
116
|
+
const b = baseDirtyHash(baseline, f);
|
|
106
117
|
const n = fileHashSync(full);
|
|
107
118
|
return !(b && n && b === n);
|
|
108
119
|
});
|
|
@@ -159,6 +170,10 @@ async function fileHashQuick(p) {
|
|
|
159
170
|
return null;
|
|
160
171
|
}
|
|
161
172
|
}
|
|
173
|
+
/** 基线脏文件内容 hash:优先新字段 preDirtyHashes,兼容旧快照回退 preUntrackedHashes */
|
|
174
|
+
function baseDirtyHash(baseline, f) {
|
|
175
|
+
return baseline.preDirtyHashes?.[f] ?? baseline.preUntrackedHashes?.[f];
|
|
176
|
+
}
|
|
162
177
|
function fileHashSync(p) {
|
|
163
178
|
try {
|
|
164
179
|
const buf = fs.readFileSync(p);
|
|
@@ -6,7 +6,9 @@ export interface Baseline {
|
|
|
6
6
|
/** 相对动工前 HEAD 的已跟踪变更(基线前已存在) */
|
|
7
7
|
preExistingChanged: string[];
|
|
8
8
|
preExistingUntracked: string[];
|
|
9
|
-
/**
|
|
9
|
+
/** 基线前脏文件(已跟踪 staged/unstaged + 未跟踪)的内容 hash:区分基线已有内容与 agent 后续改动 */
|
|
10
|
+
preDirtyHashes: Record<string, string>;
|
|
11
|
+
/** 基线前未跟踪文件的内容 hash(兼容旧字段,逻辑并入 preDirtyHashes) */
|
|
10
12
|
preUntrackedHashes: Record<string, string>;
|
|
11
13
|
capturedAt: string;
|
|
12
14
|
message: string;
|
|
@@ -40,9 +42,10 @@ export declare function gitRefExists(projectPath: string, ref: string): Promise<
|
|
|
40
42
|
* 相对基线 ref 的工作树差异 numstat:
|
|
41
43
|
* - 若当前 HEAD === baseline.head:`git diff baseline.head`(含 staged+unstaged)
|
|
42
44
|
* - 若 agent 创建了新提交(HEAD 前移):`git diff baseline.head..HEAD` 拿到已提交变更,再叠加工作树 diff
|
|
45
|
+
* excludeUnchangedPreDirty:基线前脏文件集合(按内容 hash 判定未变的应被排除,避免把用户原有改动归因给 agent,S3)。
|
|
43
46
|
* 返回已跟踪文件相对基线的全部变更。
|
|
44
47
|
*/
|
|
45
|
-
export declare function diffSinceBaseline(projectPath: string, baseRef: string | null): Promise<{
|
|
48
|
+
export declare function diffSinceBaseline(projectPath: string, baseRef: string | null, excludeFiles?: ReadonlySet<string>): Promise<{
|
|
46
49
|
numstat: NumstatRow[];
|
|
47
50
|
changedLinesPerFile: Map<string, string[]>;
|
|
48
51
|
}>;
|
|
@@ -16,6 +16,7 @@ export async function captureBaseline(projectPath) {
|
|
|
16
16
|
dirtyFiles: [],
|
|
17
17
|
preExistingChanged: [],
|
|
18
18
|
preExistingUntracked: [],
|
|
19
|
+
preDirtyHashes: {},
|
|
19
20
|
preUntrackedHashes: {},
|
|
20
21
|
capturedAt: new Date().toISOString(),
|
|
21
22
|
message: "非 git 仓库:变更清单/diffstat 不可用(验收仍执行命令检查)。",
|
|
@@ -26,12 +27,20 @@ export async function captureBaseline(projectPath) {
|
|
|
26
27
|
const statusRes = await gitOk(projectPath, ["status", "--porcelain", "--untracked-files=all"]);
|
|
27
28
|
const porcelain = statusRes.ok ? statusRes.stdout.split("\n").filter((s) => s.trim().length > 0) : [];
|
|
28
29
|
const parsed = parsePorcelain(porcelain);
|
|
30
|
+
// 记录全部预脏文件(已跟踪 staged/unstaged + 未跟踪)的内容 hash
|
|
31
|
+
const dirtyPaths = [...parsed.changed, ...parsed.untracked];
|
|
29
32
|
const hashes = {};
|
|
30
|
-
for (const f of
|
|
33
|
+
for (const f of dirtyPaths) {
|
|
31
34
|
const h = await fileHash(path.join(projectPath, f));
|
|
32
35
|
if (h)
|
|
33
36
|
hashes[f] = h;
|
|
34
37
|
}
|
|
38
|
+
const preUntrackedHashes = {};
|
|
39
|
+
for (const f of parsed.untracked) {
|
|
40
|
+
const h = await fileHash(path.join(projectPath, f));
|
|
41
|
+
if (h)
|
|
42
|
+
preUntrackedHashes[f] = h;
|
|
43
|
+
}
|
|
35
44
|
return {
|
|
36
45
|
isRepo: true,
|
|
37
46
|
head,
|
|
@@ -39,7 +48,8 @@ export async function captureBaseline(projectPath) {
|
|
|
39
48
|
dirtyFiles: porcelain,
|
|
40
49
|
preExistingChanged: parsed.changed,
|
|
41
50
|
preExistingUntracked: parsed.untracked,
|
|
42
|
-
|
|
51
|
+
preDirtyHashes: hashes,
|
|
52
|
+
preUntrackedHashes,
|
|
43
53
|
capturedAt: new Date().toISOString(),
|
|
44
54
|
message: head
|
|
45
55
|
? `基线 HEAD=${head},工作树${porcelain.length > 0 ? `脏(${porcelain.length} 项)` : "干净"}。`
|
|
@@ -117,14 +127,20 @@ export async function gitRefExists(projectPath, ref) {
|
|
|
117
127
|
* 相对基线 ref 的工作树差异 numstat:
|
|
118
128
|
* - 若当前 HEAD === baseline.head:`git diff baseline.head`(含 staged+unstaged)
|
|
119
129
|
* - 若 agent 创建了新提交(HEAD 前移):`git diff baseline.head..HEAD` 拿到已提交变更,再叠加工作树 diff
|
|
130
|
+
* excludeUnchangedPreDirty:基线前脏文件集合(按内容 hash 判定未变的应被排除,避免把用户原有改动归因给 agent,S3)。
|
|
120
131
|
* 返回已跟踪文件相对基线的全部变更。
|
|
121
132
|
*/
|
|
122
|
-
export async function diffSinceBaseline(projectPath, baseRef) {
|
|
133
|
+
export async function diffSinceBaseline(projectPath, baseRef, excludeFiles) {
|
|
134
|
+
const skip = (f) => (excludeFiles ? excludeFiles.has(f) : false);
|
|
123
135
|
if (!baseRef || !(await gitRefExists(projectPath, baseRef))) {
|
|
124
136
|
// 基线 ref 无效/缺失:退回当前 HEAD(仅跟踪工作树 + 已提交到 HEAD 的变化)
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
137
|
+
const allN = await gitNumstat(projectPath, "HEAD");
|
|
138
|
+
const n = allN.filter((r) => !skip(r.file));
|
|
139
|
+
const allLines = await gitChangedLines(projectPath, "HEAD");
|
|
140
|
+
for (const f of [...allLines.keys()])
|
|
141
|
+
if (skip(f))
|
|
142
|
+
allLines.delete(f);
|
|
143
|
+
return { numstat: n, changedLinesPerFile: allLines };
|
|
128
144
|
}
|
|
129
145
|
const headNow = (await gitOk(projectPath, ["rev-parse", "HEAD"])).stdout.trim();
|
|
130
146
|
// 工作树中相对基线的变更 = (baseline→工作树),含 commit 前移部分
|
|
@@ -134,9 +150,13 @@ export async function diffSinceBaseline(projectPath, baseRef) {
|
|
|
134
150
|
const worktree = parseNumstat(w.stdout);
|
|
135
151
|
const byFile = new Map();
|
|
136
152
|
for (const row of committed) {
|
|
153
|
+
if (skip(row.file))
|
|
154
|
+
continue;
|
|
137
155
|
byFile.set(row.file, { ...row });
|
|
138
156
|
}
|
|
139
157
|
for (const row of worktree) {
|
|
158
|
+
if (skip(row.file))
|
|
159
|
+
continue;
|
|
140
160
|
const prev = byFile.get(row.file);
|
|
141
161
|
if (prev) {
|
|
142
162
|
// 合并:同文件在基线→HEAD 与 HEAD→工作树都有改动
|
|
@@ -161,7 +181,7 @@ export async function diffSinceBaseline(projectPath, baseRef) {
|
|
|
161
181
|
cur = fh[1] ?? null;
|
|
162
182
|
continue;
|
|
163
183
|
}
|
|
164
|
-
if (line.startsWith("+") && !line.startsWith("+++") && cur) {
|
|
184
|
+
if (line.startsWith("+") && !line.startsWith("+++") && cur && !skip(cur)) {
|
|
165
185
|
const arr = toMap.get(cur) ?? [];
|
|
166
186
|
arr.push(line.slice(1));
|
|
167
187
|
toMap.set(cur, arr);
|
package/dist/verify/report.js
CHANGED
|
@@ -20,7 +20,7 @@ export function reportToMd(report) {
|
|
|
20
20
|
L.push(`- 项目: ${report.projectPath}`);
|
|
21
21
|
L.push(`- 开始: ${report.startedAt}`);
|
|
22
22
|
L.push(`- 结束: ${report.finishedAt}`);
|
|
23
|
-
L.push(`- 结论:
|
|
23
|
+
L.push(`- 结论: **[${report.verdict === "passed" ? "PASS" : "FAIL"}]**`);
|
|
24
24
|
L.push("", "## 自动命令检查", "");
|
|
25
25
|
if (report.checks.length === 0)
|
|
26
26
|
L.push("(无可运行的检查项)", "");
|
|
@@ -29,7 +29,7 @@ export function reportToMd(report) {
|
|
|
29
29
|
L.push(`- [${mark}] ${c.name} — \`${c.cmd}\`${c.durationMs >= 0 ? ` (${c.durationMs}ms)` : ""}${c.skipped && c.reason ? ` — ${c.reason}` : ""}`);
|
|
30
30
|
if (!c.passed && !c.skipped) {
|
|
31
31
|
if (c.timeout)
|
|
32
|
-
L.push(` -
|
|
32
|
+
L.push(` - [WARN] 超时(${c.durationMs}ms)`);
|
|
33
33
|
L.push(` - 退出码: ${c.exitCode ?? "n/a"}`);
|
|
34
34
|
if (c.outputTail) {
|
|
35
35
|
L.push(" - 输出尾部:", "", " ```");
|
|
@@ -68,12 +68,12 @@ export function reportToMd(report) {
|
|
|
68
68
|
if (sig.secretLike)
|
|
69
69
|
L.push(`- 疑似密钥/令牌形态 ${sig.secretLike} 处`);
|
|
70
70
|
for (const f of a.bigFileChanges)
|
|
71
|
-
L.push(`-
|
|
71
|
+
L.push(`- [WARN] 超大单文件改动: ${f}`);
|
|
72
72
|
for (const w of a.warnings)
|
|
73
|
-
L.push(`-
|
|
73
|
+
L.push(`- [WARN] ${w}`);
|
|
74
74
|
}
|
|
75
75
|
for (const n of a.notes)
|
|
76
|
-
L.push(`-
|
|
76
|
+
L.push(`- [INFO] ${n}`);
|
|
77
77
|
L.push("", "---", "", report.message, "");
|
|
78
78
|
return L.join("\n");
|
|
79
79
|
}
|
|
@@ -83,8 +83,8 @@ export function summarizeReport(report) {
|
|
|
83
83
|
const fail = report.checks.filter((c) => !c.passed && !c.skipped);
|
|
84
84
|
const skip = report.checks.filter((c) => c.skipped).length;
|
|
85
85
|
const head = report.passed
|
|
86
|
-
?
|
|
87
|
-
:
|
|
86
|
+
? `[PASS] 验收通过(第 ${report.round} 轮):${passCount}/${report.checks.length} 项命令检查通过${skip ? `,${skip} 项跳过` : ""}。`
|
|
87
|
+
: `[FAIL] 验收失败(第 ${report.round} 轮):${fail.length} 项检查未通过${skip ? `,${skip} 项跳过` : ""}。`;
|
|
88
88
|
const lines = [head, ""];
|
|
89
89
|
for (const c of report.checks) {
|
|
90
90
|
const st = c.skipped ? "SKIP" : c.passed ? "PASS" : "FAIL";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tianshu-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "天枢 × AI-Agent 编排 MCP server —— 由天枢调度本 server 去驱动外部 AI-Agent CLI(Codex/Zcode/TraeWork)完成 项目开发 → 验收 → 失败返修 → 再验收 的闭环。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"node": ">=20"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
|
-
"
|
|
25
|
+
"sync-version": "node scripts/sync-version.mjs",
|
|
26
|
+
"build": "npm run sync-version && tsc -p tsconfig.build.json",
|
|
26
27
|
"dev": "tsx src/index.ts",
|
|
27
28
|
"start": "node dist/index.js",
|
|
28
29
|
"test": "vitest run",
|
|
@@ -47,7 +48,8 @@
|
|
|
47
48
|
"prettier": "^3.4.2",
|
|
48
49
|
"tsx": "^4.19.2",
|
|
49
50
|
"typescript": "^5.7.2",
|
|
50
|
-
"
|
|
51
|
+
"vite": "^6.4.3",
|
|
52
|
+
"vitest": "^3.2.7"
|
|
51
53
|
},
|
|
52
54
|
"keywords": [
|
|
53
55
|
"mcp",
|