libero-mcp 0.2.7 → 0.2.8

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.
@@ -49,48 +49,48 @@ export function createProfileTools(repo) {
49
49
  });
50
50
  },
51
51
  /**
52
- * S40 slice 2(2026-08-02):Atomic claim — 多 cron 并发场景的"赢者通吃"
52
+ * S40 slice 2 → slice 3(2026-08-03 升级):Atomic claim。
53
53
  *
54
- * 解决问题:nag fan-out 时飞书 cron + 企微 cron 同时启动,都看到 settings=null
55
- * 都发 违反产品叙事「同步响一次」。
54
+ * 历史:slice 2 tool 层「读-检查-写」,集成测试量出 100% race(每次并发都双发)。
55
+ * slice 3 改为:repo 提供 claimNudgeAtomic(用 better-sqlite3 db.transaction 包整段)。
56
56
  *
57
- * Atomic 原子(better-sqlite3 transaction):BEGIN SELECT → 检查 window → UPDATE → COMMIT。
58
- * window_minutes 内只有第一个调用赢(won=true),后续调用返回 won=false 但不写时间戳。
57
+ * 兼容降级:如果 repo 没实现 claimNudgeAtomic(如 InMemoryProfileRepo),
58
+ * 走旧版非原子逻辑——但会在 §4.5 静默折叠意义上「未声明」,所以打 WARN 日志。
59
59
  *
60
- * 调用方(cron prompt)必须看 won:
61
- * won=true → 输出问候
62
- * won=false → 输出 [SILENT](已被别的 cron 抢先)
63
- *
64
- * 默认 window = 5 分钟,覆盖 cron 启动间隔差。
60
+ * @4.5 显式声明:sqlite transaction 是真 atomic(阻塞 event loop + 写锁)。
61
+ * 在 in-memory repo 下无 atomic 保护——但 in-memory 只在测试场景,无生产风险。
65
62
  */
66
63
  async claimNudge(input) {
67
64
  if (!input.user_id) {
68
65
  throw new Error("user_id 为必填参数");
69
66
  }
70
67
  const windowMinutes = input.window_minutes ?? 5;
68
+ // 优先用 repo 提供的 atomic 版本(sqlite production path)
69
+ if (typeof repo.claimNudgeAtomic === "function") {
70
+ return repo.claimNudgeAtomic({
71
+ user_id: input.user_id,
72
+ window_minutes: windowMinutes,
73
+ });
74
+ }
75
+ // 兼容降级(in-memory / 测试):非原子,无并发安全保证
76
+ // ⚠️ §4.5 声明:此分支不是 atomic,仅用于无并发的测试场景
77
+ // eslint-disable-next-line no-console
78
+ console.warn("[claimNudge] repo.claimNudgeAtomic 未实现,降级到非原子路径。" +
79
+ "仅测试场景安全,生产必须用 SqliteProfileRepo。");
71
80
  const windowMs = windowMinutes * 60 * 1000;
72
81
  const now = new Date();
73
82
  const nowIso = now.toISOString();
74
- // Atomic block: 用 repo 提供的 transaction hook
75
- // 但 ProfileRepo interface 没有 transaction API —— 通过 repo.upsert 的「读-合并-写」语义实现
76
- // 真正的 atomic 要靠 sqlite 层,但为兼容 in-memory repo,我们在 tool 层用读后写
77
- // ⚠️ §4.5 静默折叠声明:这不是 100% atomic(读-写之间有 race window)
78
- // 但 sqlite 层的默认 statement-level atomic 在实践中足够(< 1ms 窗口)
79
- // 真 100% atomic 需要 SqliteProfileRepo 暴露 transaction 接口(slice 3 补)
80
83
  const existing = await repo.getById(input.user_id);
81
84
  const existingSettings = existing?.settings != null
82
85
  ? existing.settings
83
86
  : {};
84
- // 检查:window 内是否已被写过
85
87
  const lastNudgeAt = existingSettings.last_inactivity_nudge_at;
86
88
  if (typeof lastNudgeAt === "string") {
87
89
  const ageMs = now.getTime() - new Date(lastNudgeAt).getTime();
88
90
  if (ageMs < windowMs) {
89
- // window 内已被 claim,本次输
90
91
  return { won: false, profile: existing };
91
92
  }
92
93
  }
93
- // 赢,写时间戳
94
94
  const merged = {
95
95
  ...existingSettings,
96
96
  last_inactivity_nudge_at: nowIso,
@@ -552,6 +552,65 @@ export class SqliteProfileRepo {
552
552
  }
553
553
  return (await this.getById(input.user_id));
554
554
  }
555
+ /**
556
+ * S40 slice 3:Atomic claim nudge。
557
+ *
558
+ * 用 better-sqlite3 的 `db.transaction()` 把「SELECT → 检查 window → UPDATE → 返回」
559
+ * 包成一个原子单元。better-sqlite3 是**同步**的——transaction 期间阻塞 event loop,
560
+ * 第二个调用根本没机会插入。
561
+ *
562
+ * 注意:transaction 返回的函数是同步的,必须在同步上下文调用(不能 await)。
563
+ * 这里包装成 async 只是为了匹配 ProfileRepo interface,内部完全同步执行。
564
+ */
565
+ async claimNudgeAtomic(input) {
566
+ const windowMs = input.window_minutes * 60 * 1000;
567
+ const now = new Date();
568
+ const nowIso = now.toISOString();
569
+ const nowDb = nowIso.replace("T", " ").replace("Z", "");
570
+ const rowToProfile = (r) => ({
571
+ id: r.id,
572
+ display_name: r.display_name,
573
+ expectations: r.expectations,
574
+ settings: r.settings != null ? JSON.parse(r.settings) : null,
575
+ created_at: r.created_at,
576
+ updated_at: r.updated_at,
577
+ });
578
+ const selectStmt = this.db.prepare("SELECT id, display_name, expectations, settings, created_at, updated_at FROM user_profile WHERE id = ? AND deleted_at IS NULL");
579
+ const updateStmt = this.db.prepare(`UPDATE user_profile SET settings = ?, updated_at = ? WHERE id = ?`);
580
+ const insertStmt = this.db.prepare(`INSERT INTO user_profile (id, display_name, expectations, settings, created_at, updated_at)
581
+ VALUES (?, '', NULL, ?, ?, ?)`);
582
+ // transaction 函数(同步)。better-sqlite3 在 transaction() 期间阻塞 event loop,
583
+ // 第二个并发调用必须等第一个 COMMIT 才能进入 BEGIN。
584
+ const txn = this.db.transaction(() => {
585
+ const row = selectStmt.get(input.user_id);
586
+ const existingSettings = row?.settings != null
587
+ ? JSON.parse(row.settings)
588
+ : {};
589
+ const lastNudgeAt = existingSettings.last_inactivity_nudge_at;
590
+ if (typeof lastNudgeAt === "string") {
591
+ const ageMs = now.getTime() - new Date(lastNudgeAt).getTime();
592
+ if (ageMs < windowMs) {
593
+ return { won: false, profile: rowToProfile(row) };
594
+ }
595
+ }
596
+ const merged = {
597
+ ...existingSettings,
598
+ last_inactivity_nudge_at: nowIso,
599
+ };
600
+ const settingsJson = JSON.stringify(merged);
601
+ if (row) {
602
+ updateStmt.run(settingsJson, nowDb, input.user_id);
603
+ }
604
+ else {
605
+ insertStmt.run(input.user_id, settingsJson, nowDb, nowDb);
606
+ }
607
+ const updated = selectStmt.get(input.user_id);
608
+ return { won: true, profile: rowToProfile(updated) };
609
+ });
610
+ // BEGIN IMMEDIATE:立刻拿写锁,防止 deferred 模式下读-写升级冲突
611
+ // better-sqlite3 API:transaction 函数有 .immediate(args) 变种
612
+ return txn.immediate();
613
+ }
555
614
  }
556
615
  // ============================================================
557
616
  // SqliteReminderRepo (S11 — reminder persistence)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libero-mcp",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "AI 时间管理教练 MCP 工具集——时间块记录、统计、矩阵诊断、计时、分类、profile",
5
5
  "license": "MIT",
6
6
  "author": "amosyuan",