@luckychen1993/pm-mcp 0.1.3 → 0.1.5

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.
@@ -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 READ_WAIT_MS = 30_000;
28
- const LOCK_STALE_MS = 10_000;
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
- return path.join(path.dirname(registryFile()), "idempotency", sha256(path.resolve(root)));
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 lockOwner(file) {
99
- try {
100
- const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
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
- const lock = `${file}.lock`;
109
- const token = randomUUID();
110
- const payload = JSON.stringify({ pid: process.pid, token });
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 || (mode === "write" && age <= AUTO_REPLAY_MS))
162
+ if (existing.explicit ||
163
+ (mode === "write" && age <= AUTO_REPLAY_MS))
160
164
  return { kind: "replay", record: existing };
161
165
  }
162
- else if (processAlive(existing.owner_pid)) {
163
- return { kind: "pending", file, record: existing };
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 ?? `⏳ 相同业务正在由另一 Agent 执行:${record.key}`,
250
+ text: record.result ?? (uncertain
251
+ ? `⚠️ 业务结果不确定,已禁止自动重放:${record.key}。请核对目标账本后决定是否使用新的业务键。`
252
+ : `⏳ 相同业务正在由另一 Agent 执行:${record.key}`),
212
253
  replayed: record.status === "completed",
213
- pending: record.status === "pending",
254
+ pending: record.status !== "completed",
255
+ uncertain,
214
256
  key: record.key,
215
257
  };
216
258
  }
217
- export function runIdempotentWriteSync(root, tool, args, fn) {
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
- const reservation = claim(root, tool, businessArgs, "write", explicitKey);
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
- const value = fn(businessArgs);
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
- abandon(reservation.file, reservation.record);
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
- const reservation = claim(root, tool, businessArgs, "write", explicitKey);
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
- abandon(reservation.file, reservation.record);
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() + READ_WAIT_MS;
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 !== "execute")
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
+ }