@luckychen1993/pm-mcp 0.1.3 → 0.1.4
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.md +8 -6
- package/dist/audit-tools.js +8 -7
- package/dist/audit.js +10 -5
- package/dist/idempotency.js +201 -67
- package/dist/index-store.js +163 -94
- package/dist/index.js +22 -17
- package/dist/license.js +7 -4
- package/dist/paths.js +35 -0
- package/dist/search.js +2 -2
- package/dist/security.js +15 -6
- package/dist/setup.js +1 -1
- package/dist/store.js +175 -58
- package/dist/tool-base.js +4 -4
- package/dist/watcher-coordinator.js +182 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ AI 编码项目的典型失控(本工具逐一给出机制):
|
|
|
25
25
|
| 调试过程随会话消失 | `log_debug` 调试知识账(症状/根因/修法/验证) |
|
|
26
26
|
| 长任务崩溃、上下文丢失 | 任务步骤清单 + `checkpoint` 断点存档,新会话从恢复点续做 |
|
|
27
27
|
| 团队协作与知识传递 | Git 项目中共享 `.pm/` + 会话署名 + `onboard` 入职简报工作流 |
|
|
28
|
-
| 多 Agent 重复读写 |
|
|
28
|
+
| 多 Agent 重复读写 | 实际重叠的同参读跨进程合并;写工具支持业务幂等键、瞬时自动去重与故障不确定态 fail-closed |
|
|
29
29
|
| token 猛烧 | 三段式定位 + 读类工具输出硬预算 + 文件用途索引 |
|
|
30
30
|
| 法律知识产权 | `audit_license` 依赖许可证/copyleft 冲突 + 来源登记(provenance) |
|
|
31
31
|
| 测试投机(删测试、skip 凑绿) | 禁用/蒸发/空测试检测 + 功能测试背书占比 + done 必附验证 |
|
|
@@ -53,7 +53,7 @@ npx -y @luckychen1993/pm-mcp@latest setup --dry-run
|
|
|
53
53
|
Codex 的本地客户端共享同一份 MCP 配置。复制执行:
|
|
54
54
|
|
|
55
55
|
```bash
|
|
56
|
-
codex mcp add pm-mcp -- npx -y @luckychen1993/pm-mcp@0.1.
|
|
56
|
+
codex mcp add pm-mcp -- npx -y @luckychen1993/pm-mcp@0.1.4
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
重启客户端后可用 `codex mcp list` 检查。该命令遵循 [OpenAI 官方 MCP CLI 格式](https://learn.chatgpt.com/docs/extend/mcp?surface=cli)。
|
|
@@ -61,7 +61,7 @@ codex mcp add pm-mcp -- npx -y @luckychen1993/pm-mcp@0.1.3
|
|
|
61
61
|
#### Claude Code
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
|
-
claude mcp add pm-mcp --scope user -- npx -y @luckychen1993/pm-mcp@0.1.
|
|
64
|
+
claude mcp add pm-mcp --scope user -- npx -y @luckychen1993/pm-mcp@0.1.4
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
#### ZCode、Cursor、VS Code(Windows 备用脚本)
|
|
@@ -69,7 +69,7 @@ claude mcp add pm-mcp --scope user -- npx -y @luckychen1993/pm-mcp@0.1.3
|
|
|
69
69
|
将 `<client>` 替换为 `zcode`、`cursor` 或 `vscode`,整行复制到 PowerShell:
|
|
70
70
|
|
|
71
71
|
```powershell
|
|
72
|
-
$u='https://raw.githubusercontent.com/happy520ai/pm-mcp/v0.1.
|
|
72
|
+
$u='https://raw.githubusercontent.com/happy520ai/pm-mcp/v0.1.4/install.ps1'; $f=Join-Path $env:TEMP 'install-pm-mcp.ps1'; Invoke-WebRequest $u -OutFile $f; & $f -Client <client>
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
脚本只写对应客户端的 MCP 配置;已有 JSON 配置会先生成带时间戳的备份。建议执行前先打开 [`install.ps1`](install.ps1) 审阅。ZCode 默认写入 `$ZCODE_HOME/cli/config.json` 或 `~/.zcode/cli/config.json`,Cursor 默认写入 `~/.cursor/mcp.json`;也可用 `-ConfigPath` 指定路径。
|
|
@@ -81,7 +81,7 @@ $u='https://raw.githubusercontent.com/happy520ai/pm-mcp/v0.1.3/install.ps1'; $f=
|
|
|
81
81
|
"mcpServers": {
|
|
82
82
|
"pm-mcp": {
|
|
83
83
|
"command": "npx",
|
|
84
|
-
"args": ["-y", "@luckychen1993/pm-mcp@0.1.
|
|
84
|
+
"args": ["-y", "@luckychen1993/pm-mcp@0.1.4"],
|
|
85
85
|
"env": {}
|
|
86
86
|
}
|
|
87
87
|
}
|
|
@@ -156,6 +156,8 @@ Resources:`pm://dashboard`、`pm://roadmap`、`pm://tasks`、`pm://changelog`
|
|
|
156
156
|
|
|
157
157
|
所有写工具都接受可选 `idempotency_key`。同一业务的多个 Agent 应传同一个稳定键(例如 `T-123:add-login-task`):同键同参数只执行一次并复用首次结果,同键不同参数直接拒绝。即使没有显式键,完全相同的并发写也会在短窗口内自动合并;完全相同的并行读会等待首个执行者并复用结果。
|
|
158
158
|
|
|
159
|
+
并发保证有明确边界:正常运行的同键写只落一次,follower 最多等待 3 秒复用首次结果;仍未完成时以 MCP error 返回 pending,不把占位冒充成功。若进程在业务写入后、幂等完成记录前被强杀,pm-mcp 会把该键标为 `uncertain` 并禁止自动重放。它提供的是 **at-most-once + 不确定时 fail-closed**,不是跨多个 JSON 文件的事务型 exactly-once;核对目标账本后才能决定是否换新键。v0.1.3 的标准全局幂等记录会在首次复用时迁移到项目运行态,旧文件保留以便回滚。读合并只覆盖业务处理实际重叠的在途请求,完成结果不会继续缓存,避免外部代码刚变化却返回旧内容。每个项目由 SQLite 生命周期选举一个 watcher leader,其余进程低频待命;leader 被强杀后由一个 standby 接管。结构、安全、许可证与快照等权威审计强制重读内容,不把 `fs.watch` 或 mtime/size 缓存的最终一致性冒充强一致性。
|
|
160
|
+
|
|
159
161
|
## 数据模型(被管理项目内的 `.pm/`;JSON/Markdown 账本可 git diff,`index.db` 是忽略并可重建的缓存)
|
|
160
162
|
|
|
161
163
|
```
|
|
@@ -178,7 +180,7 @@ PROJECT.md # 仓库根,自动生成的仪表盘(勿手改)
|
|
|
178
180
|
changelog.md # 自动生成
|
|
179
181
|
```
|
|
180
182
|
|
|
181
|
-
全局注册表:`~/.pm-mcp/registry.json
|
|
183
|
+
全局注册表:`~/.pm-mcp/registry.json`。跨进程读合并、写幂等、恢复锁与 watcher 选举运行态位于被管理项目的 `.pm/.runtime/`,由 `.pm/.gitignore` 排除;因此路径别名、不同客户端或不同 `PM_MCP_HOME` 仍共享同一协调域,但运行态不会进入 Git。
|
|
182
184
|
|
|
183
185
|
## 跨文件、跨模块、跨语言治理
|
|
184
186
|
|
package/dist/audit-tools.js
CHANGED
|
@@ -5,25 +5,26 @@ import { refreshDerived } from "./dashboard.js";
|
|
|
5
5
|
import { auditLicense } from "./license.js";
|
|
6
6
|
import { isInitialized, requireInitialized } from "./paths.js";
|
|
7
7
|
import { listRegistry } from "./registry.js";
|
|
8
|
-
import { auditSecurity, listFindings, resolveFinding } from "./security.js";
|
|
8
|
+
import { auditSecurity, listFindings, prepareSecurityAudit, resolveFinding } from "./security.js";
|
|
9
9
|
import { budgetLines, toolR, toolW } from "./tool-base.js";
|
|
10
|
+
import { walkRefresh } from "./index-store.js";
|
|
10
11
|
export function registerAuditTools(server, root) {
|
|
11
12
|
toolW(server, root, "snapshot_codebase", "给代码结构拍快照(文件数/行数/目录分布/测试数/skip 标记/依赖清单)。之后 audit_structure 会与上次快照 diff。建议每个会话或每个里程碑结束时拍一次。", {}, () => {
|
|
12
13
|
requireInitialized(root);
|
|
13
14
|
const { snapshot, summary } = snapshotCodebase(root);
|
|
14
15
|
refreshDerived(root);
|
|
15
16
|
return [...summary, `下次 audit_structure 将以此为基线(${snapshot.file})。`].join("\n");
|
|
16
|
-
});
|
|
17
|
+
}, () => { requireInitialized(root); walkRefresh(root, { forceContent: true }); });
|
|
17
18
|
toolR(server, root, "audit_structure", "八项结构对账(定期做):①增长与新增依赖 ②漂移对账(功能↔文件,防幻觉)③债务与重构配额 ④churn 热点 ⑤复杂度预算 ⑥索引覆盖率 ⑦足迹/产出 ⑧测试健康(禁用/蒸发/空测试/背书占比)。", {}, () => {
|
|
18
19
|
requireInitialized(root);
|
|
19
|
-
return auditStructure(root, budgetLines(root));
|
|
20
|
+
return auditStructure(root, budgetLines(root), true);
|
|
20
21
|
});
|
|
21
|
-
toolW(server, root, "audit_security", "安全体检(只扫描本地、不联网,结果写入安全台账):密钥泄露 / 危险模式(eval、SQL 拼接、禁用证书校验等)/ 依赖风险。修复后重扫自动关闭;接受风险必须留理由。", {}, () => {
|
|
22
|
+
toolW(server, root, "audit_security", "安全体检(只扫描本地、不联网,结果写入安全台账):密钥泄露 / 危险模式(eval、SQL 拼接、禁用证书校验等)/ 依赖风险。修复后重扫自动关闭;接受风险必须留理由。", {}, (_args, prepared) => {
|
|
22
23
|
requireInitialized(root);
|
|
23
|
-
const report = auditSecurity(root);
|
|
24
|
+
const report = auditSecurity(root, prepared);
|
|
24
25
|
refreshDerived(root);
|
|
25
26
|
return report.text.join("\n");
|
|
26
|
-
});
|
|
27
|
+
}, () => { requireInitialized(root); return prepareSecurityAudit(root); });
|
|
27
28
|
toolR(server, root, "list_findings", "查看安全台账(可按状态过滤)。", { status: z.enum(["open", "fixed", "accepted"]).optional() }, (args) => {
|
|
28
29
|
requireInitialized(root);
|
|
29
30
|
const list = listFindings(root, args.status);
|
|
@@ -44,7 +45,7 @@ export function registerAuditTools(server, root) {
|
|
|
44
45
|
});
|
|
45
46
|
toolR(server, root, "audit_license", "许可证审计(法律账):依赖许可证清单与 copyleft 冲突、源码中的 GPL 家族许可证头、LICENSE 文件检查、来源登记清单。离线启发式,不替代法务。", {}, () => {
|
|
46
47
|
requireInitialized(root);
|
|
47
|
-
return auditLicense(root, budgetLines(root));
|
|
48
|
+
return auditLicense(root, budgetLines(root), true);
|
|
48
49
|
});
|
|
49
50
|
}
|
|
50
51
|
export function registerRegistryTools(server, root) {
|
package/dist/audit.js
CHANGED
|
@@ -5,7 +5,7 @@ import { loadFeatures, loadFileNotes, loadProject, loadRoadmap, loadSessions, lo
|
|
|
5
5
|
import { decisionsDir, pmPath, snapshotsDir } from "./paths.js";
|
|
6
6
|
import { normSep, foldLines } from "./budget.js";
|
|
7
7
|
import { readDirectDeps } from "./scan.js";
|
|
8
|
-
import { aggregates, ensureFresh, getIndex, getMeta } from "./index-store.js";
|
|
8
|
+
import { aggregates, ensureFresh, freshness, getIndex, getMeta, walkRefresh } from "./index-store.js";
|
|
9
9
|
import { quotaWarnings, milestoneStats } from "./roadmap.js";
|
|
10
10
|
import { blastRadiusWarning, churnStats, debtAging, footprint } from "./health.js";
|
|
11
11
|
import { now } from "./types.js";
|
|
@@ -21,8 +21,11 @@ export function annotatedCount(root, db) {
|
|
|
21
21
|
}
|
|
22
22
|
return n;
|
|
23
23
|
}
|
|
24
|
-
export function snapshotCodebase(root) {
|
|
25
|
-
|
|
24
|
+
export function snapshotCodebase(root, forceFresh = false, indexPrepared = false) {
|
|
25
|
+
if (forceFresh)
|
|
26
|
+
walkRefresh(root, { forceContent: true });
|
|
27
|
+
else if (!indexPrepared)
|
|
28
|
+
ensureFresh(root);
|
|
26
29
|
const db = getIndex(root);
|
|
27
30
|
const agg = aggregates(db);
|
|
28
31
|
const annotated = annotatedCount(root, db);
|
|
@@ -165,13 +168,15 @@ export function gitAudit(root) {
|
|
|
165
168
|
return out;
|
|
166
169
|
}
|
|
167
170
|
/** 完整结构对账:九项报告 */
|
|
168
|
-
export function auditStructure(root, maxLines = 150) {
|
|
171
|
+
export function auditStructure(root, maxLines = 150, forceFresh = false, indexPrepared = false) {
|
|
169
172
|
const project = loadProject(root);
|
|
170
173
|
const { features } = loadFeatures(root);
|
|
171
174
|
const { tasks } = loadTasks(root);
|
|
172
175
|
const { milestones } = loadRoadmap(root);
|
|
173
176
|
const { sessions } = loadSessions(root);
|
|
174
|
-
const fresh =
|
|
177
|
+
const fresh = forceFresh
|
|
178
|
+
? { used: "walk", freshness: (walkRefresh(root, { forceContent: true }), freshness(root)) }
|
|
179
|
+
: indexPrepared ? { used: "walk", freshness: freshness(root) } : ensureFresh(root);
|
|
175
180
|
const db = getIndex(root);
|
|
176
181
|
const agg = aggregates(db);
|
|
177
182
|
const deps = readDirectDeps(root).deps;
|
package/dist/idempotency.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
5
|
import { z } from "zod";
|
|
5
|
-
import { atomicWrite, withLedgerLock } from "./store.js";
|
|
6
|
-
import { pmPath, registryFile } from "./paths.js";
|
|
6
|
+
import { atomicWrite, withFileRecoveryLock, withLedgerLock } from "./store.js";
|
|
7
|
+
import { ensurePmRuntimeIgnored, pmPath, registryFile } from "./paths.js";
|
|
7
8
|
export const IdempotencyKeySchema = z.string()
|
|
8
9
|
.trim()
|
|
9
10
|
.min(1)
|
|
@@ -16,7 +17,7 @@ const OperationRecordSchema = z.object({
|
|
|
16
17
|
tool: z.string().min(1),
|
|
17
18
|
args_sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
18
19
|
mode: z.enum(["read", "write"]),
|
|
19
|
-
status: z.enum(["pending", "completed"]),
|
|
20
|
+
status: z.enum(["pending", "completed", "uncertain"]),
|
|
20
21
|
owner_pid: z.number().int().positive(),
|
|
21
22
|
owner_token: z.string().uuid(),
|
|
22
23
|
created_at: z.string().datetime({ offset: true }),
|
|
@@ -24,8 +25,13 @@ const OperationRecordSchema = z.object({
|
|
|
24
25
|
result: z.string().nullable(),
|
|
25
26
|
});
|
|
26
27
|
const AUTO_REPLAY_MS = 1_000;
|
|
27
|
-
const
|
|
28
|
-
const
|
|
28
|
+
const DEFAULT_PENDING_LEASE_MS = 60 * 60_000;
|
|
29
|
+
const DEFAULT_WRITE_WAIT_MS = 3_000;
|
|
30
|
+
const MAX_CLOCK_SKEW_MS = 5_000;
|
|
31
|
+
const SYNC_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
32
|
+
let readWaitMs = 30_000;
|
|
33
|
+
let pendingLeaseMs = DEFAULT_PENDING_LEASE_MS;
|
|
34
|
+
let writeWaitMs = DEFAULT_WRITE_WAIT_MS;
|
|
29
35
|
function sha256(value) {
|
|
30
36
|
return createHash("sha256").update(value).digest("hex");
|
|
31
37
|
}
|
|
@@ -60,7 +66,13 @@ function processAlive(pid) {
|
|
|
60
66
|
}
|
|
61
67
|
}
|
|
62
68
|
function operationDirectory(root) {
|
|
63
|
-
|
|
69
|
+
let canonical = path.resolve(root);
|
|
70
|
+
try {
|
|
71
|
+
canonical = fs.realpathSync.native(canonical);
|
|
72
|
+
}
|
|
73
|
+
catch { /* init_project may not exist yet */ }
|
|
74
|
+
ensurePmRuntimeIgnored(canonical);
|
|
75
|
+
return pmPath(canonical, ".runtime", "idempotency");
|
|
64
76
|
}
|
|
65
77
|
function revisionFile(root) {
|
|
66
78
|
return path.join(operationDirectory(root), "revision");
|
|
@@ -80,6 +92,24 @@ function bumpRevision(root) {
|
|
|
80
92
|
function operationFile(root, key) {
|
|
81
93
|
return path.join(operationDirectory(root), `${sha256(key)}.json`);
|
|
82
94
|
}
|
|
95
|
+
function legacyOperationFiles(root, key) {
|
|
96
|
+
const resolved = path.resolve(root);
|
|
97
|
+
let real = resolved;
|
|
98
|
+
try {
|
|
99
|
+
real = fs.realpathSync.native(resolved);
|
|
100
|
+
}
|
|
101
|
+
catch { /* keep resolved */ }
|
|
102
|
+
const identities = new Set([resolved, real]);
|
|
103
|
+
if (process.platform === "win32") {
|
|
104
|
+
identities.add(resolved.toLowerCase());
|
|
105
|
+
identities.add(resolved.toUpperCase());
|
|
106
|
+
identities.add(real.toLowerCase());
|
|
107
|
+
identities.add(real.toUpperCase());
|
|
108
|
+
}
|
|
109
|
+
const homes = new Set([path.dirname(registryFile()), path.join(os.homedir(), ".pm-mcp")]);
|
|
110
|
+
const name = `${sha256(key)}.json`;
|
|
111
|
+
return [...homes].flatMap((home) => [...identities].map((identity) => path.join(home, "idempotency", sha256(identity), name)));
|
|
112
|
+
}
|
|
83
113
|
function readRecord(file) {
|
|
84
114
|
if (!fs.existsSync(file))
|
|
85
115
|
return null;
|
|
@@ -95,53 +125,26 @@ function readRecord(file) {
|
|
|
95
125
|
throw new Error(`幂等记录 schema 无效,拒绝继续写入: ${file}`);
|
|
96
126
|
return parsed.data;
|
|
97
127
|
}
|
|
98
|
-
function
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return typeof parsed.pid === "number" && typeof parsed.token === "string" ? { pid: parsed.pid, token: parsed.token } : null;
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
128
|
+
function migrateLegacyRecord(root, key, currentFile) {
|
|
129
|
+
const legacy = [...new Set(legacyOperationFiles(root, key))].filter((file) => fs.existsSync(file));
|
|
130
|
+
if (legacy.length === 0)
|
|
104
131
|
return null;
|
|
132
|
+
const records = legacy.map((file) => readRecord(file));
|
|
133
|
+
const first = JSON.stringify(records[0]);
|
|
134
|
+
if (records.some((record) => JSON.stringify(record) !== first)) {
|
|
135
|
+
throw new Error(`检测到多个不一致的 v0.1.3 幂等记录,拒绝自动迁移业务键: ${key}`);
|
|
105
136
|
}
|
|
137
|
+
atomicWrite(currentFile, JSON.stringify(OperationRecordSchema.parse(records[0]), null, 2) + "\n");
|
|
138
|
+
return records[0];
|
|
139
|
+
}
|
|
140
|
+
function pendingLeaseExpired(record) {
|
|
141
|
+
const age = Date.now() - Date.parse(record.updated_at);
|
|
142
|
+
return !Number.isFinite(age) || age < -MAX_CLOCK_SKEW_MS || age >= pendingLeaseMs;
|
|
106
143
|
}
|
|
107
144
|
function withOperationLock(file, fn) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
let held = false;
|
|
112
|
-
for (let index = 0; index < 150 && !held; index += 1) {
|
|
113
|
-
try {
|
|
114
|
-
fs.writeFileSync(lock, payload, { flag: "wx" });
|
|
115
|
-
held = true;
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
try {
|
|
119
|
-
const current = lockOwner(lock);
|
|
120
|
-
const stale = Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS;
|
|
121
|
-
if (stale && (!current || !processAlive(current.pid)))
|
|
122
|
-
fs.rmSync(lock, { force: true });
|
|
123
|
-
}
|
|
124
|
-
catch {
|
|
125
|
-
// The lock disappeared between checks; retry.
|
|
126
|
-
}
|
|
127
|
-
if (!held)
|
|
128
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
if (!held)
|
|
132
|
-
throw new Error("幂等键锁获取超时(3s),请稍后重试同一 idempotency_key。");
|
|
133
|
-
try {
|
|
134
|
-
return fn();
|
|
135
|
-
}
|
|
136
|
-
finally {
|
|
137
|
-
try {
|
|
138
|
-
if (lockOwner(lock)?.token === token)
|
|
139
|
-
fs.rmSync(lock, { force: true });
|
|
140
|
-
}
|
|
141
|
-
catch {
|
|
142
|
-
// Best-effort release; stale-owner recovery handles process crashes.
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
+
return withFileRecoveryLock(`${file}.lock`, fn, {
|
|
146
|
+
timeoutMessage: "幂等键锁获取超时(3s),请稍后重试同一 idempotency_key。",
|
|
147
|
+
});
|
|
145
148
|
}
|
|
146
149
|
function claim(root, tool, args, mode, explicitKey) {
|
|
147
150
|
const argsHash = operationArgsHash(args);
|
|
@@ -149,18 +152,37 @@ function claim(root, tool, args, mode, explicitKey) {
|
|
|
149
152
|
const file = operationFile(root, key);
|
|
150
153
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
151
154
|
return withOperationLock(file, () => {
|
|
152
|
-
const existing = readRecord(file);
|
|
155
|
+
const existing = readRecord(file) ?? migrateLegacyRecord(root, key, file);
|
|
153
156
|
if (existing) {
|
|
154
157
|
if (existing.key !== key || existing.tool !== tool || existing.mode !== mode || existing.args_sha256 !== argsHash) {
|
|
155
158
|
throw new Error(`idempotency_key 冲突:${key} 已绑定其他工具或参数,拒绝误复用。`);
|
|
156
159
|
}
|
|
157
160
|
if (existing.status === "completed") {
|
|
158
161
|
const age = Date.now() - Date.parse(existing.updated_at);
|
|
159
|
-
if (existing.explicit ||
|
|
162
|
+
if (existing.explicit ||
|
|
163
|
+
(mode === "write" && age <= AUTO_REPLAY_MS))
|
|
160
164
|
return { kind: "replay", record: existing };
|
|
161
165
|
}
|
|
162
|
-
else if (
|
|
163
|
-
return { kind: "
|
|
166
|
+
else if (existing.status === "uncertain") {
|
|
167
|
+
return { kind: "uncertain", record: existing };
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
const expired = pendingLeaseExpired(existing);
|
|
171
|
+
const ownerAlive = processAlive(existing.owner_pid);
|
|
172
|
+
if (mode === "write") {
|
|
173
|
+
if (!expired && ownerAlive)
|
|
174
|
+
return { kind: "pending", file, record: existing };
|
|
175
|
+
// A write may already have changed external or ledger state. Neither an
|
|
176
|
+
// expired lease nor a dead/reused PID proves that replay is safe.
|
|
177
|
+
existing.status = "uncertain";
|
|
178
|
+
existing.updated_at = new Date().toISOString();
|
|
179
|
+
atomicWrite(file, JSON.stringify(OperationRecordSchema.parse(existing), null, 2) + "\n");
|
|
180
|
+
return { kind: "uncertain", record: existing };
|
|
181
|
+
}
|
|
182
|
+
// Reads are side-effect-free by contract. Keep a real active lease, but
|
|
183
|
+
// replace an expired or dead-owner reservation with a fresh execution.
|
|
184
|
+
if (!expired && ownerAlive)
|
|
185
|
+
return { kind: "pending", file, record: existing };
|
|
164
186
|
}
|
|
165
187
|
}
|
|
166
188
|
const timestamp = new Date().toISOString();
|
|
@@ -194,6 +216,21 @@ function complete(file, reservation, result) {
|
|
|
194
216
|
atomicWrite(file, JSON.stringify(OperationRecordSchema.parse(current), null, 2) + "\n");
|
|
195
217
|
});
|
|
196
218
|
}
|
|
219
|
+
function markUncertain(file, reservation) {
|
|
220
|
+
try {
|
|
221
|
+
withOperationLock(file, () => {
|
|
222
|
+
const current = readRecord(file);
|
|
223
|
+
if (current?.owner_token !== reservation.owner_token || current.status !== "pending")
|
|
224
|
+
return;
|
|
225
|
+
current.status = "uncertain";
|
|
226
|
+
current.updated_at = new Date().toISOString();
|
|
227
|
+
atomicWrite(file, JSON.stringify(OperationRecordSchema.parse(current), null, 2) + "\n");
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
// Preserve the business error. The pending explicit key remains fail-closed.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
197
234
|
function abandon(file, reservation) {
|
|
198
235
|
try {
|
|
199
236
|
withOperationLock(file, () => {
|
|
@@ -207,50 +244,114 @@ function abandon(file, reservation) {
|
|
|
207
244
|
}
|
|
208
245
|
}
|
|
209
246
|
function replay(record) {
|
|
247
|
+
const uncertain = record.status === "uncertain" || (record.status === "pending" && record.mode === "write" &&
|
|
248
|
+
(!processAlive(record.owner_pid) || pendingLeaseExpired(record)));
|
|
210
249
|
return {
|
|
211
|
-
text: record.result ??
|
|
250
|
+
text: record.result ?? (uncertain
|
|
251
|
+
? `⚠️ 业务结果不确定,已禁止自动重放:${record.key}。请核对目标账本后决定是否使用新的业务键。`
|
|
252
|
+
: `⏳ 相同业务正在由另一 Agent 执行:${record.key}`),
|
|
212
253
|
replayed: record.status === "completed",
|
|
213
|
-
pending: record.status
|
|
254
|
+
pending: record.status !== "completed",
|
|
255
|
+
uncertain,
|
|
214
256
|
key: record.key,
|
|
215
257
|
};
|
|
216
258
|
}
|
|
217
|
-
|
|
259
|
+
function currentWriteSettlement(file, reservation) {
|
|
260
|
+
const current = readRecord(file);
|
|
261
|
+
if (!current || current.owner_token !== reservation.owner_token)
|
|
262
|
+
return null;
|
|
263
|
+
if (current.status !== "pending")
|
|
264
|
+
return current;
|
|
265
|
+
if (!processAlive(current.owner_pid) || pendingLeaseExpired(current))
|
|
266
|
+
return null;
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
function waitForWriteSync(file, reservation) {
|
|
270
|
+
const deadline = Date.now() + writeWaitMs;
|
|
271
|
+
while (Date.now() < deadline) {
|
|
272
|
+
const current = currentWriteSettlement(file, reservation);
|
|
273
|
+
if (current !== undefined)
|
|
274
|
+
return current;
|
|
275
|
+
Atomics.wait(SYNC_WAIT, 0, 0, Math.min(25, Math.max(0, deadline - Date.now())));
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
async function waitForWrite(file, reservation) {
|
|
280
|
+
const deadline = Date.now() + writeWaitMs;
|
|
281
|
+
while (Date.now() < deadline) {
|
|
282
|
+
const current = currentWriteSettlement(file, reservation);
|
|
283
|
+
if (current !== undefined)
|
|
284
|
+
return current;
|
|
285
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(0, deadline - Date.now()))));
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
export function runIdempotentWriteSync(root, tool, args, fn, prepare) {
|
|
218
290
|
const { businessArgs, explicitKey } = splitIdempotencyArgs(args);
|
|
219
|
-
|
|
291
|
+
let reservation = claim(root, tool, businessArgs, "write", explicitKey);
|
|
292
|
+
if (reservation.kind === "pending") {
|
|
293
|
+
const settled = waitForWriteSync(reservation.file, reservation.record);
|
|
294
|
+
if (settled)
|
|
295
|
+
return replay(settled);
|
|
296
|
+
reservation = claim(root, tool, businessArgs, "write", explicitKey);
|
|
297
|
+
}
|
|
220
298
|
if (reservation.kind !== "execute")
|
|
221
299
|
return replay(reservation.record);
|
|
300
|
+
let handlerStarted = false;
|
|
222
301
|
try {
|
|
302
|
+
const prepared = prepare?.(businessArgs);
|
|
223
303
|
const text = withLedgerLock(root, () => {
|
|
224
|
-
|
|
304
|
+
handlerStarted = true;
|
|
305
|
+
const value = fn(businessArgs, prepared);
|
|
306
|
+
idempotencyFault("after-business");
|
|
225
307
|
bumpRevision(root);
|
|
308
|
+
idempotencyFault("after-revision");
|
|
226
309
|
return value;
|
|
227
310
|
});
|
|
311
|
+
idempotencyFault("before-complete");
|
|
228
312
|
complete(reservation.file, reservation.record, text);
|
|
229
|
-
return { text, replayed: false, pending: false, key: reservation.record.key };
|
|
313
|
+
return { text, replayed: false, pending: false, uncertain: false, key: reservation.record.key };
|
|
230
314
|
}
|
|
231
315
|
catch (error) {
|
|
232
|
-
|
|
316
|
+
if (handlerStarted)
|
|
317
|
+
markUncertain(reservation.file, reservation.record);
|
|
318
|
+
else
|
|
319
|
+
abandon(reservation.file, reservation.record);
|
|
233
320
|
throw error;
|
|
234
321
|
}
|
|
235
322
|
}
|
|
236
323
|
export async function runIdempotentWrite(root, tool, args, fn) {
|
|
237
324
|
const { businessArgs, explicitKey } = splitIdempotencyArgs(args);
|
|
238
|
-
|
|
325
|
+
let reservation = claim(root, tool, businessArgs, "write", explicitKey);
|
|
326
|
+
if (reservation.kind === "pending") {
|
|
327
|
+
const settled = await waitForWrite(reservation.file, reservation.record);
|
|
328
|
+
if (settled)
|
|
329
|
+
return replay(settled);
|
|
330
|
+
reservation = claim(root, tool, businessArgs, "write", explicitKey);
|
|
331
|
+
}
|
|
239
332
|
if (reservation.kind !== "execute")
|
|
240
333
|
return replay(reservation.record);
|
|
334
|
+
let handlerStarted = false;
|
|
241
335
|
try {
|
|
336
|
+
handlerStarted = true;
|
|
242
337
|
const text = await fn(businessArgs);
|
|
338
|
+
idempotencyFault("after-business");
|
|
243
339
|
bumpRevision(root);
|
|
340
|
+
idempotencyFault("after-revision");
|
|
341
|
+
idempotencyFault("before-complete");
|
|
244
342
|
complete(reservation.file, reservation.record, text);
|
|
245
|
-
return { text, replayed: false, pending: false, key: reservation.record.key };
|
|
343
|
+
return { text, replayed: false, pending: false, uncertain: false, key: reservation.record.key };
|
|
246
344
|
}
|
|
247
345
|
catch (error) {
|
|
248
|
-
|
|
346
|
+
if (handlerStarted)
|
|
347
|
+
markUncertain(reservation.file, reservation.record);
|
|
348
|
+
else
|
|
349
|
+
abandon(reservation.file, reservation.record);
|
|
249
350
|
throw error;
|
|
250
351
|
}
|
|
251
352
|
}
|
|
252
353
|
async function waitForRead(file, record) {
|
|
253
|
-
const deadline = Date.now() +
|
|
354
|
+
const deadline = Date.now() + readWaitMs;
|
|
254
355
|
while (Date.now() < deadline) {
|
|
255
356
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
256
357
|
const current = readRecord(file);
|
|
@@ -267,27 +368,60 @@ export async function runCoalescedRead(root, tool, args, fn) {
|
|
|
267
368
|
// Read-only calls before project initialization must not create .pm state.
|
|
268
369
|
if (!fs.existsSync(pmPath(root, "project.json"))) {
|
|
269
370
|
const text = await fn(args);
|
|
270
|
-
return { text, replayed: false, pending: false, key: "uncached:uninitialized" };
|
|
371
|
+
return { text, replayed: false, pending: false, uncertain: false, key: "uncached:uninitialized" };
|
|
271
372
|
}
|
|
272
373
|
const readClaim = () => claim(root, tool, { revision: readRevision(root), args }, "read");
|
|
273
374
|
let reservation = readClaim();
|
|
274
375
|
if (reservation.kind === "replay")
|
|
275
376
|
return replay(reservation.record);
|
|
377
|
+
if (reservation.kind === "uncertain")
|
|
378
|
+
throw new Error("并行读取状态不确定;请稍后重试。");
|
|
276
379
|
if (reservation.kind === "pending") {
|
|
277
380
|
const completed = await waitForRead(reservation.file, reservation.record);
|
|
278
381
|
if (completed)
|
|
279
382
|
return replay(completed);
|
|
280
383
|
reservation = readClaim();
|
|
281
|
-
if (reservation.kind
|
|
384
|
+
if (reservation.kind === "pending")
|
|
385
|
+
throw new Error(`并行读取等待超过 ${readWaitMs}ms,leader 仍在执行;请稍后重试。`);
|
|
386
|
+
if (reservation.kind === "uncertain")
|
|
387
|
+
throw new Error("并行读取状态不确定;请稍后重试。");
|
|
388
|
+
if (reservation.kind === "replay")
|
|
282
389
|
return replay(reservation.record);
|
|
283
390
|
}
|
|
391
|
+
if (reservation.kind !== "execute")
|
|
392
|
+
throw new Error("并行读取未获得执行权;请稍后重试。");
|
|
284
393
|
try {
|
|
285
394
|
const text = await fn(args);
|
|
286
395
|
complete(reservation.file, reservation.record, text);
|
|
287
|
-
return { text, replayed: false, pending: false, key: reservation.record.key };
|
|
396
|
+
return { text, replayed: false, pending: false, uncertain: false, key: reservation.record.key };
|
|
288
397
|
}
|
|
289
398
|
catch (error) {
|
|
290
399
|
abandon(reservation.file, reservation.record);
|
|
291
400
|
throw error;
|
|
292
401
|
}
|
|
293
402
|
}
|
|
403
|
+
let faultHook;
|
|
404
|
+
function idempotencyFault(stage) {
|
|
405
|
+
faultHook?.(stage);
|
|
406
|
+
}
|
|
407
|
+
/** 仅供故障注入测试;生产调用方不应设置。 */
|
|
408
|
+
export function setIdempotencyFaultHookForTests(hook) {
|
|
409
|
+
faultHook = hook;
|
|
410
|
+
}
|
|
411
|
+
/** 仅供故障注入测试,传 undefined 恢复生产默认值。 */
|
|
412
|
+
export function setReadWaitMsForTests(value) {
|
|
413
|
+
readWaitMs = value ?? 30_000;
|
|
414
|
+
}
|
|
415
|
+
/** 仅供 PID 复用与 lease 故障注入测试;传 undefined 恢复生产默认值。 */
|
|
416
|
+
export function setPendingLeaseMsForTests(value) {
|
|
417
|
+
if (value !== undefined && (!Number.isFinite(value) || value <= 0)) {
|
|
418
|
+
throw new RangeError("pending lease 必须大于 0ms。");
|
|
419
|
+
}
|
|
420
|
+
pendingLeaseMs = value ?? DEFAULT_PENDING_LEASE_MS;
|
|
421
|
+
}
|
|
422
|
+
/** 仅供 follower 等待边界测试;传 undefined 恢复生产默认值。 */
|
|
423
|
+
export function setWriteWaitMsForTests(value) {
|
|
424
|
+
if (value !== undefined && (!Number.isFinite(value) || value < 0))
|
|
425
|
+
throw new RangeError("write wait 不得小于 0ms。");
|
|
426
|
+
writeWaitMs = value ?? DEFAULT_WRITE_WAIT_MS;
|
|
427
|
+
}
|