@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.
package/dist/tool-base.js CHANGED
@@ -1,14 +1,49 @@
1
1
  import { loadProject } from "./store.js";
2
2
  import { IdempotencyKeySchema, runCoalescedRead, runIdempotentWrite, runIdempotentWriteSync } from "./idempotency.js";
3
+ import { drainFoldSavings } from "./budget.js";
4
+ import { estimateTokens, logRuntime, logUsage } from "./usage-log.js";
5
+ /** 工具/资源调用结束后写使用日志(含本次折叠省下的 token 估算);绝不抛错。 */
6
+ export function recordUsage(root, tool, kind, started, outcome) {
7
+ try {
8
+ const folded = drainFoldSavings();
9
+ logUsage(root, {
10
+ ts: new Date().toISOString(),
11
+ tool,
12
+ kind,
13
+ ok: outcome.ok,
14
+ ms: Date.now() - started,
15
+ ...(outcome.text !== undefined ? { out_chars: outcome.text.length, est_tokens: estimateTokens(outcome.text) } : {}),
16
+ ...(folded ? { folded_chars: folded.chars, folded_tokens: folded.tokens } : {}),
17
+ ...(outcome.replayed ? { replayed: true } : {}),
18
+ ...(outcome.pending ? { pending: true } : {}),
19
+ ...(outcome.uncertain ? { uncertain: true } : {}),
20
+ ...(outcome.error ? { error: outcome.error.slice(0, 300) } : {}),
21
+ });
22
+ }
23
+ catch {
24
+ /* 使用日志绝不影响工具调用 */
25
+ }
26
+ }
3
27
  /** 只读工具:跨 Agent/进程合并完全相同的并行请求,短窗复用结果。 */
4
28
  export function toolR(server, root, name, description, schema, fn) {
5
29
  server.registerTool(name, { description, inputSchema: schema, annotations: { readOnlyHint: true } }, (async (args) => {
30
+ const started = Date.now();
6
31
  try {
7
32
  const result = await runCoalescedRead(root, name, args, fn);
33
+ recordUsage(root, name, "read", started, {
34
+ ok: !result.pending,
35
+ text: result.text,
36
+ replayed: result.replayed,
37
+ pending: result.pending,
38
+ uncertain: result.uncertain,
39
+ });
8
40
  return { content: [{ type: "text", text: result.text }] };
9
41
  }
10
42
  catch (error) {
11
- return { content: [{ type: "text", text: `错误: ${error.message}` }], isError: true };
43
+ const message = error.message;
44
+ recordUsage(root, name, "read", started, { ok: false, error: message });
45
+ logRuntime(root, "error", "tool.error", `${name}: ${message.slice(0, 200)}`);
46
+ return { content: [{ type: "text", text: `错误: ${message}` }], isError: true };
12
47
  }
13
48
  }));
14
49
  }
@@ -20,28 +55,52 @@ function writeSchema(schema) {
20
55
  };
21
56
  }
22
57
  /** 同步写工具:业务幂等占位 + 跨进程账本锁覆盖完整读-改-写。 */
23
- export function toolW(server, root, name, description, schema, fn) {
58
+ export function toolW(server, root, name, description, schema, fn, prepare) {
24
59
  server.registerTool(name, { description: `${description} ${idempotencyDescription}`, inputSchema: writeSchema(schema), annotations: { readOnlyHint: false } }, ((args) => {
60
+ const started = Date.now();
25
61
  try {
26
- const result = runIdempotentWriteSync(root, name, args, fn);
62
+ const result = runIdempotentWriteSync(root, name, args, fn, prepare);
27
63
  const prefix = result.replayed ? `↩️ 幂等复用 ${result.key}\n` : result.pending ? `⏳ 幂等占位 ${result.key}\n` : "";
28
- return { content: [{ type: "text", text: prefix + result.text }] };
64
+ const text = prefix + result.text;
65
+ recordUsage(root, name, "write", started, {
66
+ ok: !result.pending,
67
+ text,
68
+ replayed: result.replayed,
69
+ pending: result.pending,
70
+ uncertain: result.uncertain,
71
+ });
72
+ return { content: [{ type: "text", text }], ...(result.pending ? { isError: true } : {}) };
29
73
  }
30
74
  catch (error) {
31
- return { content: [{ type: "text", text: `错误: ${error.message}` }], isError: true };
75
+ const message = error.message;
76
+ recordUsage(root, name, "write", started, { ok: false, error: message });
77
+ logRuntime(root, "error", "tool.error", `${name}: ${message.slice(0, 200)}`);
78
+ return { content: [{ type: "text", text: `错误: ${message}` }], isError: true };
32
79
  }
33
80
  }));
34
81
  }
35
82
  /** 已自带内部锁或异步执行的写工具:先跨进程占位,完成后缓存结果。 */
36
83
  export function toolI(server, root, name, description, schema, fn) {
37
84
  server.registerTool(name, { description: `${description} ${idempotencyDescription}`, inputSchema: writeSchema(schema), annotations: { readOnlyHint: false } }, (async (args) => {
85
+ const started = Date.now();
38
86
  try {
39
87
  const result = await runIdempotentWrite(root, name, args, fn);
40
88
  const prefix = result.replayed ? `↩️ 幂等复用 ${result.key}\n` : result.pending ? `⏳ 幂等占位 ${result.key}\n` : "";
41
- return { content: [{ type: "text", text: prefix + result.text }] };
89
+ const text = prefix + result.text;
90
+ recordUsage(root, name, "write-async", started, {
91
+ ok: !result.pending,
92
+ text,
93
+ replayed: result.replayed,
94
+ pending: result.pending,
95
+ uncertain: result.uncertain,
96
+ });
97
+ return { content: [{ type: "text", text }], ...(result.pending ? { isError: true } : {}) };
42
98
  }
43
99
  catch (error) {
44
- return { content: [{ type: "text", text: `错误: ${error.message}` }], isError: true };
100
+ const message = error.message;
101
+ recordUsage(root, name, "write-async", started, { ok: false, error: message });
102
+ logRuntime(root, "error", "tool.error", `${name}: ${message.slice(0, 200)}`);
103
+ return { content: [{ type: "text", text: `错误: ${message}` }], isError: true };
45
104
  }
46
105
  }));
47
106
  }
@@ -0,0 +1,278 @@
1
+ import fs from "node:fs";
2
+ import { isInitialized, pmPath } from "./paths.js";
3
+ /**
4
+ * 使用日志 + 运行日志(运行时数据,非账本):
5
+ * - usage-log.jsonl:每次 MCP 工具/资源调用一条(工具名、成败、耗时、输出与折叠省下的 token 估算)。
6
+ * 只记计量不记参数内容,避免敏感信息与大参数进日志。
7
+ * - runtime-log.jsonl:服务级事件(server 就绪、watcher 状态、工具报错、轮转)。
8
+ * 均为追加式 JSONL,超限自动轮转(当前 + .1 共两份),绝不抛错——日志永远不能影响工具调用本身。
9
+ */
10
+ export const USAGE_LOG = "usage-log.jsonl";
11
+ export const RUNTIME_LOG = "runtime-log.jsonl";
12
+ const DEFAULT_MAX_BYTES = 1024 * 1024;
13
+ const ROTATE_CHECK_EVERY = 32;
14
+ const rotateCheckCounter = new Map();
15
+ function maxLogBytes() {
16
+ const override = Number(process.env.PM_TEST_LOG_MAX_BYTES);
17
+ return Number.isFinite(override) && override > 0 ? override : DEFAULT_MAX_BYTES;
18
+ }
19
+ function rotatedFile(file) {
20
+ return file.replace(/\.jsonl$/, ".1.jsonl");
21
+ }
22
+ function appendLine(root, file, line) {
23
+ try {
24
+ if (!isInitialized(root))
25
+ return;
26
+ fs.appendFileSync(file, line + "\n", "utf8");
27
+ // 每 N 次写查一次大小(测试覆盖了轮转,此时每次都查)
28
+ const every = process.env.PM_TEST_LOG_MAX_BYTES ? 1 : ROTATE_CHECK_EVERY;
29
+ const n = (rotateCheckCounter.get(file) ?? 0) + 1;
30
+ rotateCheckCounter.set(file, n);
31
+ if (n % every !== 0)
32
+ return;
33
+ if (fs.statSync(file).size <= maxLogBytes())
34
+ return;
35
+ const rotated = rotatedFile(file);
36
+ try {
37
+ fs.rmSync(rotated, { force: true });
38
+ fs.renameSync(file, rotated);
39
+ }
40
+ catch {
41
+ // 多进程并发轮转竞争失败可接受:当前文件继续追加,下次再试
42
+ }
43
+ }
44
+ catch {
45
+ /* 日志绝不影响工具调用 */
46
+ }
47
+ }
48
+ export function logUsage(root, entry) {
49
+ appendLine(root, pmPath(root, USAGE_LOG), JSON.stringify(entry));
50
+ }
51
+ export function logRuntime(root, level, event, detail) {
52
+ const entry = { ts: new Date().toISOString(), level, event, ...(detail ? { detail } : {}) };
53
+ appendLine(root, pmPath(root, RUNTIME_LOG), JSON.stringify(entry));
54
+ }
55
+ /** 粗估 token 数:CJK 每字≈1 token,其余≈4字符/token(混合中英文经验值,够做趋势判断) */
56
+ export function estimateTokens(text) {
57
+ let cjk = 0;
58
+ for (const ch of text) {
59
+ const c = ch.codePointAt(0);
60
+ if ((c >= 0x4e00 && c <= 0x9fff) || (c >= 0x3400 && c <= 0x4dbf) ||
61
+ (c >= 0x3000 && c <= 0x303f) || (c >= 0xff00 && c <= 0xffef))
62
+ cjk += 1;
63
+ }
64
+ return cjk + Math.ceil((text.length - cjk) / 4);
65
+ }
66
+ function readJsonl(root, file, coerce) {
67
+ const out = [];
68
+ for (const target of [rotatedFile(file), file]) {
69
+ let raw;
70
+ try {
71
+ raw = fs.readFileSync(target, "utf8");
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ for (const line of raw.split("\n")) {
77
+ const trimmed = line.trim();
78
+ if (!trimmed)
79
+ continue;
80
+ try {
81
+ const parsed = coerce(JSON.parse(trimmed));
82
+ if (parsed !== null)
83
+ out.push(parsed);
84
+ }
85
+ catch {
86
+ /* 坏行跳过(轮转竞争/手工编辑残留) */
87
+ }
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+ function asString(v) {
93
+ return typeof v === "string" ? v : undefined;
94
+ }
95
+ function coerceUsage(raw) {
96
+ const tool = asString(raw.tool);
97
+ const ts = asString(raw.ts);
98
+ if (!tool || !ts)
99
+ return null;
100
+ return {
101
+ ts,
102
+ tool,
103
+ kind: raw.kind === "write" || raw.kind === "write-async" ? raw.kind : "read",
104
+ ok: raw.ok === true,
105
+ ms: typeof raw.ms === "number" && Number.isFinite(raw.ms) ? raw.ms : 0,
106
+ ...(typeof raw.out_chars === "number" ? { out_chars: raw.out_chars } : {}),
107
+ ...(typeof raw.est_tokens === "number" ? { est_tokens: raw.est_tokens } : {}),
108
+ ...(typeof raw.folded_chars === "number" ? { folded_chars: raw.folded_chars } : {}),
109
+ ...(typeof raw.folded_tokens === "number" ? { folded_tokens: raw.folded_tokens } : {}),
110
+ ...(raw.replayed === true ? { replayed: true } : {}),
111
+ ...(raw.pending === true ? { pending: true } : {}),
112
+ ...(raw.uncertain === true ? { uncertain: true } : {}),
113
+ ...(asString(raw.error) ? { error: asString(raw.error) } : {}),
114
+ };
115
+ }
116
+ function coerceRuntime(raw) {
117
+ const ts = asString(raw.ts);
118
+ const event = asString(raw.event);
119
+ const level = raw.level === "info" || raw.level === "warn" || raw.level === "error" ? raw.level : null;
120
+ if (!ts || !event || !level)
121
+ return null;
122
+ return { ts, level, event, ...(asString(raw.detail) ? { detail: asString(raw.detail) } : {}) };
123
+ }
124
+ /** 读全部保留的使用日志(轮转份 + 当前份,按时间序) */
125
+ export function loadUsageEntries(root) {
126
+ return readJsonl(root, pmPath(root, USAGE_LOG), coerceUsage);
127
+ }
128
+ export function loadRuntimeEntries(root) {
129
+ return readJsonl(root, pmPath(root, RUNTIME_LOG), coerceRuntime);
130
+ }
131
+ export function usageSummary(entries) {
132
+ const calls = entries.length;
133
+ const errors = entries.filter((e) => !e.ok).length;
134
+ const totalMs = entries.reduce((sum, e) => sum + e.ms, 0);
135
+ const slowest = entries.reduce((best, e) => (best === null || e.ms > best.ms ? e : best), null);
136
+ const estTokensOut = entries.reduce((sum, e) => sum + (e.est_tokens ?? 0), 0);
137
+ const estTokensSaved = entries.reduce((sum, e) => sum + (e.folded_tokens ?? 0), 0);
138
+ const byTool = new Map();
139
+ for (const e of entries) {
140
+ const bucket = byTool.get(e.tool) ?? { calls: 0, errors: 0, ms: 0 };
141
+ bucket.calls += 1;
142
+ if (!e.ok)
143
+ bucket.errors += 1;
144
+ bucket.ms += e.ms;
145
+ byTool.set(e.tool, bucket);
146
+ }
147
+ const topTools = [...byTool.entries()]
148
+ .map(([tool, b]) => ({ tool, calls: b.calls, errors: b.errors, avgMs: Math.round(b.ms / Math.max(1, b.calls)) }))
149
+ .sort((a, b) => b.calls - a.calls || a.tool.localeCompare(b.tool))
150
+ .slice(0, 8);
151
+ return {
152
+ calls,
153
+ errors,
154
+ errorRate: calls === 0 ? 0 : errors / calls,
155
+ avgMs: calls === 0 ? 0 : Math.round(totalMs / calls),
156
+ maxMs: slowest?.ms ?? 0,
157
+ slowestTool: slowest?.tool ?? "",
158
+ estTokensOut,
159
+ estTokensSaved,
160
+ savePct: estTokensOut + estTokensSaved === 0 ? 0 : estTokensSaved / (estTokensOut + estTokensSaved),
161
+ replayed: entries.filter((e) => e.replayed).length,
162
+ pending: entries.filter((e) => e.pending).length,
163
+ uncertain: entries.filter((e) => e.uncertain).length,
164
+ folds: entries.filter((e) => (e.folded_chars ?? 0) > 0).length,
165
+ topTools,
166
+ };
167
+ }
168
+ /** 过滤出明细(汇总始终基于全部保留日志,明细才吃 filter) */
169
+ export function filterUsage(entries, filter) {
170
+ let list = entries;
171
+ if (filter.tool)
172
+ list = list.filter((e) => e.tool === filter.tool);
173
+ if (filter.ok !== undefined)
174
+ list = list.filter((e) => e.ok === filter.ok);
175
+ if (filter.since)
176
+ list = list.filter((e) => e.ts >= filter.since);
177
+ const last = filter.last ?? 20;
178
+ return list.slice(-last).reverse();
179
+ }
180
+ export function filterRuntime(entries, filter) {
181
+ let list = entries;
182
+ if (filter.level)
183
+ list = list.filter((e) => e.level === filter.level);
184
+ if (filter.event)
185
+ list = list.filter((e) => e.event === filter.event);
186
+ if (filter.since)
187
+ list = list.filter((e) => e.ts >= filter.since);
188
+ const last = filter.last ?? 20;
189
+ return list.slice(-last).reverse();
190
+ }
191
+ /* ------------------------------ 渲染(供工具层) ------------------------------ */
192
+ function fmtK(n) {
193
+ return n >= 10000 ? `${(n / 1000).toFixed(0)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
194
+ }
195
+ function fmtPct(rate) {
196
+ return `${(rate * 100).toFixed(rate < 0.1 && rate > 0 ? 1 : 0)}%`;
197
+ }
198
+ function outcomeMark(e) {
199
+ if (!e.ok)
200
+ return "❌";
201
+ if (e.uncertain)
202
+ return "⚠️";
203
+ if (e.replayed)
204
+ return "↩️";
205
+ if (e.pending)
206
+ return "⏳";
207
+ return "✅";
208
+ }
209
+ export function renderUsageLogLines(root, filter) {
210
+ const entries = loadUsageEntries(root);
211
+ if (entries.length === 0) {
212
+ return [
213
+ "(使用日志为空:还没有记录到工具调用;日志在项目初始化后自动开始记录。)",
214
+ "提示: 本日志只记工具名与计量(耗时/输出量),不记录参数内容。",
215
+ ];
216
+ }
217
+ const summary = usageSummary(entries);
218
+ const list = filterUsage(entries, filter);
219
+ const conditions = [
220
+ filter.tool ? `工具=${filter.tool}` : "",
221
+ filter.ok !== undefined ? `结果=${filter.ok ? "成功" : "失败"}` : "",
222
+ filter.since ? `since=${filter.since}` : "",
223
+ ].filter(Boolean).join(" · ");
224
+ const L = [];
225
+ L.push(`## 使用日志汇总(保留 ${entries.length} 次调用)`);
226
+ L.push(`- 调用 ${summary.calls} 次 · 失败 ${summary.errors} 次(${fmtPct(summary.errorRate)})· 平均 ${summary.avgMs}ms · 最慢 ${summary.maxMs}ms(${summary.slowestTool})`);
227
+ L.push(`- 幂等复用 ${summary.replayed} 次 · 等待占位 ${summary.pending} 次 · 不确定 ${summary.uncertain} 次 · 发生折叠 ${summary.folds} 次`);
228
+ L.push(`- 估算输出 tokens ≈ ${fmtK(summary.estTokensOut)} · 折叠省下 ≈ ${fmtK(summary.estTokensSaved)}(占总产出 ${fmtPct(summary.savePct)})——省 token 主要来自输出折叠与过滤引导`);
229
+ if (summary.topTools.length > 0) {
230
+ L.push(`- 热门工具: ${summary.topTools.map((t) => `${t.tool}×${t.calls}${t.errors > 0 ? `(${t.errors}败)` : ""}`).join(" · ")}`);
231
+ }
232
+ L.push("");
233
+ L.push(`## 明细(最近 ${list.length} 条${conditions ? `,过滤: ${conditions}` : ""})`);
234
+ if (list.length === 0)
235
+ L.push("(无匹配条目,放宽过滤条件试试。)");
236
+ for (const e of list) {
237
+ const parts = [
238
+ e.ts.slice(0, 19),
239
+ `${outcomeMark(e)} ${e.tool}`,
240
+ `${e.ms}ms`,
241
+ e.out_chars !== undefined ? `输出 ${fmtK(e.out_chars)} 字/tok≈${fmtK(e.est_tokens ?? 0)}` : "",
242
+ (e.folded_tokens ?? 0) > 0 ? `折叠省 tok≈${fmtK(e.folded_tokens)}` : "",
243
+ e.error ? `错误: ${e.error.slice(0, 160)}` : "",
244
+ ].filter(Boolean);
245
+ L.push(parts.join(" "));
246
+ }
247
+ return L;
248
+ }
249
+ export function renderRuntimeLogLines(root, filter) {
250
+ const entries = loadRuntimeEntries(root);
251
+ if (entries.length === 0) {
252
+ return ["(运行日志为空:服务级事件(启动、watcher、工具报错)会在项目初始化后自动记录。)"];
253
+ }
254
+ const counts = { info: 0, warn: 0, error: 0 };
255
+ for (const e of entries)
256
+ counts[e.level] += 1;
257
+ const list = filterRuntime(entries, filter);
258
+ const conditions = [
259
+ filter.level ? `level=${filter.level}` : "",
260
+ filter.event ? `event=${filter.event}` : "",
261
+ filter.since ? `since=${filter.since}` : "",
262
+ ].filter(Boolean).join(" · ");
263
+ const L = [];
264
+ L.push(`## 运行日志(保留 ${entries.length} 条:info ${counts.info} · warn ${counts.warn} · error ${counts.error})`);
265
+ if (counts.error > 0) {
266
+ const lastError = [...entries].reverse().find((e) => e.level === "error");
267
+ if (lastError)
268
+ L.push(`- 最近 error: ${lastError.ts.slice(0, 19)} ${lastError.event} ${lastError.detail ?? ""}`.trimEnd());
269
+ }
270
+ L.push("");
271
+ L.push(`## 明细(最近 ${list.length} 条${conditions ? `,过滤: ${conditions}` : ""})`);
272
+ if (list.length === 0)
273
+ L.push("(无匹配条目,放宽过滤条件试试。)");
274
+ for (const e of list) {
275
+ L.push([e.ts.slice(0, 19), `[${e.level}]`, e.event, e.detail ?? ""].filter(Boolean).join(" ").trimEnd());
276
+ }
277
+ return L;
278
+ }
@@ -0,0 +1,182 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ const ELECTION_POLL_MS = 1_000;
5
+ const ELECTION_BUSY_MS = 20;
6
+ export function watcherDirtyFile(root) {
7
+ return path.join(path.resolve(root), ".pm", ".runtime", "watcher-dirty");
8
+ }
9
+ export function markWatcherDirty(root) {
10
+ const file = watcherDirtyFile(root);
11
+ fs.mkdirSync(path.dirname(file), { recursive: true });
12
+ fs.writeFileSync(file, `${new Date().toISOString()}\n`, "utf8");
13
+ }
14
+ export function clearWatcherDirty(root) {
15
+ try {
16
+ fs.rmSync(watcherDirtyFile(root));
17
+ }
18
+ catch (error) {
19
+ if (error.code !== "ENOENT")
20
+ throw error;
21
+ }
22
+ }
23
+ export function watcherIsClean(root) {
24
+ return !fs.existsSync(watcherDirtyFile(root));
25
+ }
26
+ function sqliteBusy(error) {
27
+ const candidate = error;
28
+ return candidate.errcode === 5 ||
29
+ (typeof candidate.message === "string" && /database is (?:locked|busy)/i.test(candidate.message));
30
+ }
31
+ function openElection(root) {
32
+ const file = path.join(path.resolve(root), ".pm", ".runtime", "watcher-leader.db");
33
+ fs.mkdirSync(path.dirname(file), { recursive: true });
34
+ const db = new DatabaseSync(file);
35
+ try {
36
+ db.exec(`PRAGMA busy_timeout=${ELECTION_BUSY_MS}`);
37
+ return db;
38
+ }
39
+ catch (error) {
40
+ try {
41
+ db.close();
42
+ }
43
+ catch { /* best effort */ }
44
+ throw error;
45
+ }
46
+ }
47
+ function acquire(db) {
48
+ if (db.isTransaction)
49
+ return true;
50
+ try {
51
+ // Avoid startup DDL so late standbys can join while a leader holds the DB.
52
+ // The transactional header write forces ownership even when an empty DB
53
+ // would otherwise defer materializing the write reservation on this OS.
54
+ db.exec("BEGIN IMMEDIATE");
55
+ db.exec("PRAGMA user_version=1");
56
+ return true;
57
+ }
58
+ catch (error) {
59
+ if (db.isTransaction) {
60
+ try {
61
+ db.exec("ROLLBACK");
62
+ }
63
+ catch { /* close remains the fail-safe */ }
64
+ }
65
+ if (sqliteBusy(error))
66
+ return false;
67
+ throw error;
68
+ }
69
+ }
70
+ function release(db) {
71
+ if (db.isTransaction)
72
+ db.exec("ROLLBACK");
73
+ }
74
+ /**
75
+ * Elect exactly one watcher with a dedicated SQLite write transaction held for
76
+ * its lifetime. The OS releases the transaction after a force kill; standbys
77
+ * poll and take over without PID guesses, leases, or stale-file deletion.
78
+ */
79
+ export function coordinateWatcherLeader(root, activate, report) {
80
+ let election;
81
+ try {
82
+ election = openElection(root);
83
+ }
84
+ catch (error) {
85
+ report("leader election unavailable", error);
86
+ return null;
87
+ }
88
+ let cleanup = null;
89
+ let timer = null;
90
+ let stopped = false;
91
+ let unavailable = false;
92
+ const relinquish = () => {
93
+ try {
94
+ cleanup?.();
95
+ }
96
+ catch (error) {
97
+ report("leader cleanup", error);
98
+ }
99
+ cleanup = null;
100
+ try {
101
+ release(election);
102
+ }
103
+ catch (error) {
104
+ report("leadership release", error);
105
+ unavailable = true;
106
+ try {
107
+ election.close();
108
+ }
109
+ catch { /* OS close releases the transaction */ }
110
+ }
111
+ };
112
+ const elect = () => {
113
+ if (stopped || unavailable || cleanup)
114
+ return;
115
+ let acquired = false;
116
+ try {
117
+ acquired = acquire(election);
118
+ }
119
+ catch (error) {
120
+ report("leader election", error);
121
+ unavailable = true;
122
+ stopped = true;
123
+ if (timer)
124
+ clearInterval(timer);
125
+ timer = null;
126
+ try {
127
+ election.close();
128
+ }
129
+ catch { /* best effort */ }
130
+ return;
131
+ }
132
+ if (!acquired)
133
+ return;
134
+ try {
135
+ const activated = activate();
136
+ if (!activated)
137
+ unavailable = true;
138
+ else
139
+ cleanup = activated;
140
+ }
141
+ catch (error) {
142
+ report("leader activation", error);
143
+ }
144
+ if (!cleanup)
145
+ relinquish();
146
+ if (unavailable) {
147
+ if (timer)
148
+ clearInterval(timer);
149
+ timer = null;
150
+ stopped = true;
151
+ try {
152
+ election.close();
153
+ }
154
+ catch { /* best effort */ }
155
+ }
156
+ };
157
+ elect();
158
+ if (unavailable) {
159
+ try {
160
+ election.close();
161
+ }
162
+ catch { /* best effort */ }
163
+ return null;
164
+ }
165
+ timer = setInterval(elect, ELECTION_POLL_MS);
166
+ timer.unref();
167
+ return {
168
+ stop() {
169
+ if (stopped)
170
+ return;
171
+ stopped = true;
172
+ if (timer)
173
+ clearInterval(timer);
174
+ timer = null;
175
+ relinquish();
176
+ try {
177
+ election.close();
178
+ }
179
+ catch { /* best effort */ }
180
+ },
181
+ };
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckychen1993/pm-mcp",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "AI 编码项目的单一事实来源 + 健康台账 MCP 服务(事实来源 / 路线图 / 防幻觉 / 七本账 / 断点续做 / token 经济)",
5
5
  "keywords": [
6
6
  "mcp",