@rei-standard/amsg-server 2.6.0-next.4 → 2.6.0-next.6

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 CHANGED
@@ -123,6 +123,22 @@ AI 配置消息的提示词可以用两种形态之一,**互斥二选一**:
123
123
  - `send-notifications`
124
124
  - `Authorization: Bearer <cronToken>` 或 `?token=<cronToken>`
125
125
 
126
+ ## 触发任务时的占位
127
+
128
+ `send-notifications`(以及单用户 Worker 的 `scheduled()`)每条任务开跑前会先占位:在这一行的 `lease_until` 上写下「归我管到现在 + 租期为止」,本次投递期间别的 tick 领不走它;占位改到 0 行说明别人先领走了,本次直接跳过。cron 一分钟一跳而带工具的 AI 任务常常跑过一分钟,没有这层占位同一条任务会被相邻几跳重复触发。
129
+
130
+ 租约写在自己的列上,`next_send_at` 全程不动——任务列表读到的一直是用户设的那个时刻,循环任务也按它推进到下一次。投递收尾时租约就放掉,失败重试的退避(2 分钟起)不会被租期压住。
131
+
132
+ 领了任务的那一跳中途没了(Worker 被回收之类)就没人来放租约,这条任务要等租约到期才会被后面的 tick 接手。把租期设得比最慢的一次投递长一点即可。
133
+
134
+ 租期默认 10 分钟;配了 `totalTimeoutMs` 的话按它 + 2 分钟往上抬。想自己定就在 `runScheduledTick` 的 ctx(或单用户 Worker 的 config)里传 `claimLeaseMs`——注意 `createReiServer` 内置的 `/send-notifications` 处理器不透传这两个值,要调租期就自己调 `runScheduledTick`。`onBeforeFire` 里按次放宽的预算占位时看不到,那种情况也要显式设 `claimLeaseMs`。
135
+
136
+ 占位管的是定时触发这条路径。`messageType: 'instant'` 走的是「建行 → 当场投递」,不经过占位。
137
+
138
+ 内置适配器都实现了占位。自定义适配器可以不实现 `claimTask`,跑得动,只是回到不占位的行为。
139
+
140
+ `lease_until` 是这次新加的列。走 `POST /init-tenant`(或任何一次 `initSchema`)会自动给已有的表补上;手工建表的看 `examples/cloudflare-single-user/schema.sql`。
141
+
126
142
  ## 导出 API(Exports)
127
143
 
128
144
  - `createReiServer`
@@ -7,12 +7,20 @@ var TABLE_SQL = `
7
7
  encrypted_payload TEXT NOT NULL,
8
8
  message_type VARCHAR(50) NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
9
9
  next_send_at TIMESTAMP WITH TIME ZONE NOT NULL,
10
+ lease_until TIMESTAMP WITH TIME ZONE,
10
11
  status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
11
12
  retry_count INTEGER DEFAULT 0,
12
13
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
13
14
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
14
15
  )
15
16
  `;
17
+ var MIGRATIONS = [
18
+ {
19
+ name: "add_lease_until",
20
+ sql: "ALTER TABLE scheduled_messages ADD COLUMN IF NOT EXISTS lease_until TIMESTAMP WITH TIME ZONE",
21
+ description: "Task claim lease (2.6.0)"
22
+ }
23
+ ];
16
24
  var INDEXES = [
17
25
  {
18
26
  name: "idx_pending_tasks_optimized",
@@ -69,4 +77,5 @@ var COLUMNS_SQL = `
69
77
 
70
78
 
71
79
 
72
- exports.TABLE_SQL = TABLE_SQL; exports.INDEXES = INDEXES; exports.VERIFY_TABLE_SQL = VERIFY_TABLE_SQL; exports.COLUMNS_SQL = COLUMNS_SQL;
80
+
81
+ exports.TABLE_SQL = TABLE_SQL; exports.MIGRATIONS = MIGRATIONS; exports.INDEXES = INDEXES; exports.VERIFY_TABLE_SQL = VERIFY_TABLE_SQL; exports.COLUMNS_SQL = COLUMNS_SQL;
@@ -7,12 +7,20 @@ var TABLE_SQL = `
7
7
  encrypted_payload TEXT NOT NULL,
8
8
  message_type VARCHAR(50) NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
9
9
  next_send_at TIMESTAMP WITH TIME ZONE NOT NULL,
10
+ lease_until TIMESTAMP WITH TIME ZONE,
10
11
  status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
11
12
  retry_count INTEGER DEFAULT 0,
12
13
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
13
14
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
14
15
  )
15
16
  `;
17
+ var MIGRATIONS = [
18
+ {
19
+ name: "add_lease_until",
20
+ sql: "ALTER TABLE scheduled_messages ADD COLUMN IF NOT EXISTS lease_until TIMESTAMP WITH TIME ZONE",
21
+ description: "Task claim lease (2.6.0)"
22
+ }
23
+ ];
16
24
  var INDEXES = [
17
25
  {
18
26
  name: "idx_pending_tasks_optimized",
@@ -66,6 +74,7 @@ var COLUMNS_SQL = `
66
74
 
67
75
  export {
68
76
  TABLE_SQL,
77
+ MIGRATIONS,
69
78
  INDEXES,
70
79
  VERIFY_TABLE_SQL,
71
80
  COLUMNS_SQL
@@ -598,7 +598,7 @@ function normalizeBeforeFireResult(result) {
598
598
  };
599
599
  }
600
600
  throw new TypeError(
601
- "AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs? } | null"
601
+ "AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs? } | { skip: true } | null"
602
602
  );
603
603
  }
604
604
  function firstPositiveInt(values, fallback) {
@@ -642,6 +642,9 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
642
642
  });
643
643
  const before = await hooks.onBeforeFire(fireCtx);
644
644
  if (before == null) return { handled: false };
645
+ if (typeof before === "object" && before.skip === true) {
646
+ return { handled: true, result: { success: true, messagesSent: 0, status: "skipped", iterations: 0 } };
647
+ }
645
648
  const normalized = normalizeBeforeFireResult(before);
646
649
  const maxToolIterations = firstPositiveInt(
647
650
  [normalized.maxToolIterations, ctx.maxToolIterations],
@@ -1088,9 +1091,18 @@ function createScheduleMessageHandler(ctx) {
1088
1091
  }
1089
1092
 
1090
1093
  // src/server/lib/run-tick.js
1094
+ var DEFAULT_CLAIM_LEASE_MS = 10 * 60 * 1e3;
1095
+ var CLAIM_LEASE_MARGIN_MS = 2 * 60 * 1e3;
1096
+ function positiveNumber(value) {
1097
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
1098
+ }
1099
+ function resolveClaimLeaseMs(ctx) {
1100
+ return positiveNumber(ctx.claimLeaseMs) || Math.max(DEFAULT_CLAIM_LEASE_MS, positiveNumber(ctx.totalTimeoutMs) + CLAIM_LEASE_MARGIN_MS);
1101
+ }
1091
1102
  async function runScheduledTick(ctx) {
1092
1103
  const db = ctx.db;
1093
1104
  const masterKey = ctx.masterKey;
1105
+ const claimLeaseMs = resolveClaimLeaseMs(ctx);
1094
1106
  const startTime = Date.now();
1095
1107
  const tasks = await db.getPendingTasks(50);
1096
1108
  const MAX_CONCURRENT = 8;
@@ -1098,19 +1110,29 @@ async function runScheduledTick(ctx) {
1098
1110
  totalTasks: tasks.length,
1099
1111
  successCount: 0,
1100
1112
  failedCount: 0,
1113
+ claimSkippedTasks: 0,
1101
1114
  deletedOnceOffTasks: 0,
1102
1115
  updatedRecurringTasks: 0,
1103
1116
  failedTasks: []
1104
1117
  };
1118
+ const supportsClaim = typeof db.claimTask === "function";
1119
+ async function claimForThisTick(task) {
1120
+ if (!supportsClaim) return true;
1121
+ const leaseUntil = new Date(Date.now() + claimLeaseMs).toISOString();
1122
+ return !!await db.claimTask(task.id, task.next_send_at, leaseUntil);
1123
+ }
1124
+ async function updateAndRelease(taskId, fields) {
1125
+ return db.updateTaskById(taskId, supportsClaim ? { ...fields, lease_until: null } : fields);
1126
+ }
1105
1127
  async function handleDeliveryFailure(task, reason) {
1106
1128
  results.failedCount++;
1107
1129
  try {
1108
1130
  if (task.retry_count >= 3) {
1109
- await db.updateTaskById(task.id, { status: "failed" });
1131
+ await updateAndRelease(task.id, { status: "failed" });
1110
1132
  results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "permanently_failed" });
1111
1133
  } else {
1112
1134
  const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1e3);
1113
- await db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
1135
+ await updateAndRelease(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
1114
1136
  results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() });
1115
1137
  }
1116
1138
  } catch (updateError) {
@@ -1121,7 +1143,7 @@ async function runScheduledTick(ctx) {
1121
1143
  results.failedCount++;
1122
1144
  let markedSent = false;
1123
1145
  try {
1124
- await db.updateTaskById(task.id, { status: "sent", retry_count: 0 });
1146
+ await updateAndRelease(task.id, { status: "sent", retry_count: 0 });
1125
1147
  markedSent = true;
1126
1148
  } catch (_markSentError) {
1127
1149
  markedSent = false;
@@ -1134,6 +1156,18 @@ async function runScheduledTick(ctx) {
1134
1156
  });
1135
1157
  }
1136
1158
  async function processTask(task) {
1159
+ let claimed;
1160
+ try {
1161
+ claimed = await claimForThisTick(task);
1162
+ } catch (error) {
1163
+ results.failedCount++;
1164
+ results.failedTasks.push({ taskId: task.id, reason: error.message || "\u4EFB\u52A1\u5360\u4F4D\u5931\u8D25", status: "claim_failed" });
1165
+ return;
1166
+ }
1167
+ if (!claimed) {
1168
+ results.claimSkippedTasks++;
1169
+ return;
1170
+ }
1137
1171
  let sendResult;
1138
1172
  try {
1139
1173
  sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey);
@@ -1159,7 +1193,7 @@ async function runScheduledTick(ctx) {
1159
1193
  } else if (decryptedPayload.recurrenceType === "weekly") {
1160
1194
  nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1e3);
1161
1195
  }
1162
- await db.updateTaskById(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
1196
+ await updateAndRelease(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
1163
1197
  results.updatedRecurringTasks++;
1164
1198
  }
1165
1199
  results.successCount++;
@@ -1192,6 +1226,7 @@ async function runScheduledTick(ctx) {
1192
1226
  processedAt: (/* @__PURE__ */ new Date()).toISOString(),
1193
1227
  executionTime,
1194
1228
  details: {
1229
+ claimSkippedTasks: results.claimSkippedTasks,
1195
1230
  deletedOnceOffTasks: results.deletedOnceOffTasks,
1196
1231
  updatedRecurringTasks: results.updatedRecurringTasks,
1197
1232
  failedTasks: results.failedTasks
@@ -1416,7 +1451,14 @@ function createMessagesHandler(ctx) {
1416
1451
  status: task.status,
1417
1452
  retryCount: task.retry_count,
1418
1453
  createdAt: task.created_at,
1419
- updatedAt: task.updated_at
1454
+ updatedAt: task.updated_at,
1455
+ // Character ownership / client-side task identity, pulled from the
1456
+ // scheduling host's metadata so it can filter tasks by character
1457
+ // (contactName can collide across characters). Only these two
1458
+ // metadata fields are surfaced — the rest of metadata may hold
1459
+ // host-private data and stays server-side. Absent → null.
1460
+ charId: decrypted.metadata?.charId ?? null,
1461
+ clientTaskId: decrypted.metadata?.amsgClientTaskId ?? null
1420
1462
  };
1421
1463
  }));
1422
1464
  const responsePayload = {
@@ -1446,12 +1488,20 @@ var SQLITE_TABLE_SQL = `
1446
1488
  encrypted_payload TEXT NOT NULL,
1447
1489
  message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
1448
1490
  next_send_at TEXT NOT NULL,
1491
+ lease_until TEXT,
1449
1492
  status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
1450
1493
  retry_count INTEGER NOT NULL DEFAULT 0,
1451
1494
  created_at TEXT NOT NULL,
1452
1495
  updated_at TEXT NOT NULL
1453
1496
  )
1454
1497
  `;
1498
+ var SQLITE_MIGRATIONS = [
1499
+ {
1500
+ name: "add_lease_until",
1501
+ sql: "ALTER TABLE scheduled_messages ADD COLUMN lease_until TEXT",
1502
+ description: "Task claim lease (2.6.0)"
1503
+ }
1504
+ ];
1455
1505
  var SQLITE_INDEXES = [
1456
1506
  {
1457
1507
  name: "idx_pending_tasks_optimized",
@@ -1511,6 +1561,7 @@ var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
1511
1561
  "encrypted_payload",
1512
1562
  "message_type",
1513
1563
  "next_send_at",
1564
+ "lease_until",
1514
1565
  "status",
1515
1566
  "retry_count",
1516
1567
  "created_at",
@@ -1539,6 +1590,13 @@ var D1Adapter = class {
1539
1590
  async initSchema() {
1540
1591
  await this._db.prepare(SQLITE_TABLE_SQL).run();
1541
1592
  await this._db.prepare(CLIENT_STATE_TABLE_SQL).run();
1593
+ for (const migration of SQLITE_MIGRATIONS) {
1594
+ try {
1595
+ await this._db.prepare(migration.sql).run();
1596
+ } catch (error) {
1597
+ if (!/duplicate column name/i.test(error.message || "")) throw error;
1598
+ }
1599
+ }
1542
1600
  const indexResults = [];
1543
1601
  for (const index of SQLITE_INDEXES) {
1544
1602
  try {
@@ -1556,7 +1614,7 @@ var D1Adapter = class {
1556
1614
  );
1557
1615
  }
1558
1616
  return {
1559
- columnsCreated: 10,
1617
+ columnsCreated: 11,
1560
1618
  indexesCreated: indexResults.filter((r) => r.status === "success").length,
1561
1619
  indexesFailed: indexResults.filter((r) => r.status === "failed").length,
1562
1620
  columns: [],
@@ -1648,15 +1706,53 @@ var D1Adapter = class {
1648
1706
  return res.meta.changes > 0;
1649
1707
  }
1650
1708
  async getPendingTasks(limit = 50) {
1709
+ const now = this._now();
1651
1710
  const res = await this._db.prepare(
1652
1711
  `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
1653
1712
  FROM scheduled_messages
1654
1713
  WHERE status = 'pending' AND next_send_at <= ?
1714
+ AND (lease_until IS NULL OR lease_until <= ?)
1655
1715
  ORDER BY next_send_at ASC
1656
1716
  LIMIT ?`
1657
- ).bind(this._now(), limit).all();
1717
+ ).bind(now, now, limit).all();
1658
1718
  return res.results || [];
1659
1719
  }
1720
+ /**
1721
+ * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
1722
+ * 本次投递期间别的 tick 领不走它。
1723
+ *
1724
+ * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
1725
+ * 任务列表要读它、循环任务推进下一次也要拿它当基准。
1726
+ *
1727
+ * 两个 tick 抢同一行时只有一个能改到行,另一个拿到 changes = 0,据此跳过。
1728
+ * WHERE 里的两个条件各管一件事:
1729
+ * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
1730
+ * 也不会把行焊死,租约到期后自然可以被接手。
1731
+ * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
1732
+ * 这一跳就不该再按旧时刻发。
1733
+ *
1734
+ * 不加一个 'sending' 状态来表达「正在跑」:建表语句里 status 有
1735
+ * CHECK (status IN ('pending','sent','failed')),加值要重建表。
1736
+ *
1737
+ * expectedNextSendAt 按读到的原样比对,不做时区归一化——老部署里可能还留
1738
+ * 着非归一化写法的行(如 +08:00 结尾),归一化后反而对不上,那条任务会永
1739
+ * 远领不到。
1740
+ *
1741
+ * @param {number} taskId
1742
+ * @param {string} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
1743
+ * @param {string|Date} leaseUntil - 租期末尾
1744
+ * @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、排期被改过、或行已不是 pending
1745
+ */
1746
+ async claimTask(taskId, expectedNextSendAt, leaseUntil) {
1747
+ const expected = typeof expectedNextSendAt === "string" ? expectedNextSendAt : this._iso(expectedNextSendAt);
1748
+ const res = await this._db.prepare(
1749
+ `UPDATE scheduled_messages
1750
+ SET lease_until = ?, updated_at = ?
1751
+ WHERE id = ? AND status = 'pending' AND next_send_at = ?
1752
+ AND (lease_until IS NULL OR lease_until <= ?)`
1753
+ ).bind(this._iso(leaseUntil), this._now(), taskId, expected, this._now()).run();
1754
+ return (res.meta.changes || 0) > 0;
1755
+ }
1660
1756
  async listTasks(userId, opts = {}) {
1661
1757
  const { status = "all", limit = 20, offset = 0 } = opts;
1662
1758
  const conditions = ["user_id = ?"];
@@ -2082,7 +2178,7 @@ function createClientStateHandler(ctx) {
2082
2178
  }
2083
2179
 
2084
2180
  // src/server/lib/version.js
2085
- var SERVER_VERSION = true ? "2.6.0-next.4" : "0.0.0-dev";
2181
+ var SERVER_VERSION = true ? "2.6.0-next.6" : "0.0.0-dev";
2086
2182
 
2087
2183
  // src/server/handlers/capabilities.js
2088
2184
  var SERVER_FEATURES = Object.freeze([
@@ -2445,7 +2541,9 @@ function createSingleUserCloudflareWorker(buildConfig) {
2445
2541
  // spreads its ctx into processSingleMessage, so these ride along.
2446
2542
  hooks: cfg.hooks || null,
2447
2543
  maxToolIterations: cfg.maxToolIterations,
2448
- totalTimeoutMs: cfg.totalTimeoutMs
2544
+ totalTimeoutMs: cfg.totalTimeoutMs,
2545
+ // 任务占位租期(默认 10 分钟,随 totalTimeoutMs 抬高)。
2546
+ claimLeaseMs: cfg.claimLeaseMs
2449
2547
  });
2450
2548
  } catch (error) {
2451
2549
  console.error("[amsg single-user] scheduled(): tick failed:", error && error.message);
@@ -598,7 +598,7 @@ function normalizeBeforeFireResult(result) {
598
598
  };
599
599
  }
600
600
  throw new TypeError(
601
- "AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs? } | null"
601
+ "AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs? } | { skip: true } | null"
602
602
  );
603
603
  }
604
604
  function firstPositiveInt(values, fallback) {
@@ -642,6 +642,9 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
642
642
  });
643
643
  const before = await hooks.onBeforeFire(fireCtx);
644
644
  if (before == null) return { handled: false };
645
+ if (typeof before === "object" && before.skip === true) {
646
+ return { handled: true, result: { success: true, messagesSent: 0, status: "skipped", iterations: 0 } };
647
+ }
645
648
  const normalized = normalizeBeforeFireResult(before);
646
649
  const maxToolIterations = firstPositiveInt(
647
650
  [normalized.maxToolIterations, ctx.maxToolIterations],
@@ -1088,9 +1091,18 @@ function createScheduleMessageHandler(ctx) {
1088
1091
  }
1089
1092
 
1090
1093
  // src/server/lib/run-tick.js
1094
+ var DEFAULT_CLAIM_LEASE_MS = 10 * 60 * 1e3;
1095
+ var CLAIM_LEASE_MARGIN_MS = 2 * 60 * 1e3;
1096
+ function positiveNumber(value) {
1097
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
1098
+ }
1099
+ function resolveClaimLeaseMs(ctx) {
1100
+ return positiveNumber(ctx.claimLeaseMs) || Math.max(DEFAULT_CLAIM_LEASE_MS, positiveNumber(ctx.totalTimeoutMs) + CLAIM_LEASE_MARGIN_MS);
1101
+ }
1091
1102
  async function runScheduledTick(ctx) {
1092
1103
  const db = ctx.db;
1093
1104
  const masterKey = ctx.masterKey;
1105
+ const claimLeaseMs = resolveClaimLeaseMs(ctx);
1094
1106
  const startTime = Date.now();
1095
1107
  const tasks = await db.getPendingTasks(50);
1096
1108
  const MAX_CONCURRENT = 8;
@@ -1098,19 +1110,29 @@ async function runScheduledTick(ctx) {
1098
1110
  totalTasks: tasks.length,
1099
1111
  successCount: 0,
1100
1112
  failedCount: 0,
1113
+ claimSkippedTasks: 0,
1101
1114
  deletedOnceOffTasks: 0,
1102
1115
  updatedRecurringTasks: 0,
1103
1116
  failedTasks: []
1104
1117
  };
1118
+ const supportsClaim = typeof db.claimTask === "function";
1119
+ async function claimForThisTick(task) {
1120
+ if (!supportsClaim) return true;
1121
+ const leaseUntil = new Date(Date.now() + claimLeaseMs).toISOString();
1122
+ return !!await db.claimTask(task.id, task.next_send_at, leaseUntil);
1123
+ }
1124
+ async function updateAndRelease(taskId, fields) {
1125
+ return db.updateTaskById(taskId, supportsClaim ? { ...fields, lease_until: null } : fields);
1126
+ }
1105
1127
  async function handleDeliveryFailure(task, reason) {
1106
1128
  results.failedCount++;
1107
1129
  try {
1108
1130
  if (task.retry_count >= 3) {
1109
- await db.updateTaskById(task.id, { status: "failed" });
1131
+ await updateAndRelease(task.id, { status: "failed" });
1110
1132
  results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "permanently_failed" });
1111
1133
  } else {
1112
1134
  const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1e3);
1113
- await db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
1135
+ await updateAndRelease(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
1114
1136
  results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() });
1115
1137
  }
1116
1138
  } catch (updateError) {
@@ -1121,7 +1143,7 @@ async function runScheduledTick(ctx) {
1121
1143
  results.failedCount++;
1122
1144
  let markedSent = false;
1123
1145
  try {
1124
- await db.updateTaskById(task.id, { status: "sent", retry_count: 0 });
1146
+ await updateAndRelease(task.id, { status: "sent", retry_count: 0 });
1125
1147
  markedSent = true;
1126
1148
  } catch (_markSentError) {
1127
1149
  markedSent = false;
@@ -1134,6 +1156,18 @@ async function runScheduledTick(ctx) {
1134
1156
  });
1135
1157
  }
1136
1158
  async function processTask(task) {
1159
+ let claimed;
1160
+ try {
1161
+ claimed = await claimForThisTick(task);
1162
+ } catch (error) {
1163
+ results.failedCount++;
1164
+ results.failedTasks.push({ taskId: task.id, reason: error.message || "\u4EFB\u52A1\u5360\u4F4D\u5931\u8D25", status: "claim_failed" });
1165
+ return;
1166
+ }
1167
+ if (!claimed) {
1168
+ results.claimSkippedTasks++;
1169
+ return;
1170
+ }
1137
1171
  let sendResult;
1138
1172
  try {
1139
1173
  sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey);
@@ -1159,7 +1193,7 @@ async function runScheduledTick(ctx) {
1159
1193
  } else if (decryptedPayload.recurrenceType === "weekly") {
1160
1194
  nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1e3);
1161
1195
  }
1162
- await db.updateTaskById(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
1196
+ await updateAndRelease(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
1163
1197
  results.updatedRecurringTasks++;
1164
1198
  }
1165
1199
  results.successCount++;
@@ -1192,6 +1226,7 @@ async function runScheduledTick(ctx) {
1192
1226
  processedAt: (/* @__PURE__ */ new Date()).toISOString(),
1193
1227
  executionTime,
1194
1228
  details: {
1229
+ claimSkippedTasks: results.claimSkippedTasks,
1195
1230
  deletedOnceOffTasks: results.deletedOnceOffTasks,
1196
1231
  updatedRecurringTasks: results.updatedRecurringTasks,
1197
1232
  failedTasks: results.failedTasks
@@ -1416,7 +1451,14 @@ function createMessagesHandler(ctx) {
1416
1451
  status: task.status,
1417
1452
  retryCount: task.retry_count,
1418
1453
  createdAt: task.created_at,
1419
- updatedAt: task.updated_at
1454
+ updatedAt: task.updated_at,
1455
+ // Character ownership / client-side task identity, pulled from the
1456
+ // scheduling host's metadata so it can filter tasks by character
1457
+ // (contactName can collide across characters). Only these two
1458
+ // metadata fields are surfaced — the rest of metadata may hold
1459
+ // host-private data and stays server-side. Absent → null.
1460
+ charId: _nullishCoalesce(_optionalChain([decrypted, 'access', _7 => _7.metadata, 'optionalAccess', _8 => _8.charId]), () => ( null)),
1461
+ clientTaskId: _nullishCoalesce(_optionalChain([decrypted, 'access', _9 => _9.metadata, 'optionalAccess', _10 => _10.amsgClientTaskId]), () => ( null))
1420
1462
  };
1421
1463
  }));
1422
1464
  const responsePayload = {
@@ -1446,12 +1488,20 @@ var SQLITE_TABLE_SQL = `
1446
1488
  encrypted_payload TEXT NOT NULL,
1447
1489
  message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
1448
1490
  next_send_at TEXT NOT NULL,
1491
+ lease_until TEXT,
1449
1492
  status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
1450
1493
  retry_count INTEGER NOT NULL DEFAULT 0,
1451
1494
  created_at TEXT NOT NULL,
1452
1495
  updated_at TEXT NOT NULL
1453
1496
  )
1454
1497
  `;
1498
+ var SQLITE_MIGRATIONS = [
1499
+ {
1500
+ name: "add_lease_until",
1501
+ sql: "ALTER TABLE scheduled_messages ADD COLUMN lease_until TEXT",
1502
+ description: "Task claim lease (2.6.0)"
1503
+ }
1504
+ ];
1455
1505
  var SQLITE_INDEXES = [
1456
1506
  {
1457
1507
  name: "idx_pending_tasks_optimized",
@@ -1511,6 +1561,7 @@ var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
1511
1561
  "encrypted_payload",
1512
1562
  "message_type",
1513
1563
  "next_send_at",
1564
+ "lease_until",
1514
1565
  "status",
1515
1566
  "retry_count",
1516
1567
  "created_at",
@@ -1539,6 +1590,13 @@ var D1Adapter = class {
1539
1590
  async initSchema() {
1540
1591
  await this._db.prepare(SQLITE_TABLE_SQL).run();
1541
1592
  await this._db.prepare(CLIENT_STATE_TABLE_SQL).run();
1593
+ for (const migration of SQLITE_MIGRATIONS) {
1594
+ try {
1595
+ await this._db.prepare(migration.sql).run();
1596
+ } catch (error) {
1597
+ if (!/duplicate column name/i.test(error.message || "")) throw error;
1598
+ }
1599
+ }
1542
1600
  const indexResults = [];
1543
1601
  for (const index of SQLITE_INDEXES) {
1544
1602
  try {
@@ -1556,7 +1614,7 @@ var D1Adapter = class {
1556
1614
  );
1557
1615
  }
1558
1616
  return {
1559
- columnsCreated: 10,
1617
+ columnsCreated: 11,
1560
1618
  indexesCreated: indexResults.filter((r) => r.status === "success").length,
1561
1619
  indexesFailed: indexResults.filter((r) => r.status === "failed").length,
1562
1620
  columns: [],
@@ -1648,15 +1706,53 @@ var D1Adapter = class {
1648
1706
  return res.meta.changes > 0;
1649
1707
  }
1650
1708
  async getPendingTasks(limit = 50) {
1709
+ const now = this._now();
1651
1710
  const res = await this._db.prepare(
1652
1711
  `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
1653
1712
  FROM scheduled_messages
1654
1713
  WHERE status = 'pending' AND next_send_at <= ?
1714
+ AND (lease_until IS NULL OR lease_until <= ?)
1655
1715
  ORDER BY next_send_at ASC
1656
1716
  LIMIT ?`
1657
- ).bind(this._now(), limit).all();
1717
+ ).bind(now, now, limit).all();
1658
1718
  return res.results || [];
1659
1719
  }
1720
+ /**
1721
+ * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
1722
+ * 本次投递期间别的 tick 领不走它。
1723
+ *
1724
+ * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
1725
+ * 任务列表要读它、循环任务推进下一次也要拿它当基准。
1726
+ *
1727
+ * 两个 tick 抢同一行时只有一个能改到行,另一个拿到 changes = 0,据此跳过。
1728
+ * WHERE 里的两个条件各管一件事:
1729
+ * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
1730
+ * 也不会把行焊死,租约到期后自然可以被接手。
1731
+ * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
1732
+ * 这一跳就不该再按旧时刻发。
1733
+ *
1734
+ * 不加一个 'sending' 状态来表达「正在跑」:建表语句里 status 有
1735
+ * CHECK (status IN ('pending','sent','failed')),加值要重建表。
1736
+ *
1737
+ * expectedNextSendAt 按读到的原样比对,不做时区归一化——老部署里可能还留
1738
+ * 着非归一化写法的行(如 +08:00 结尾),归一化后反而对不上,那条任务会永
1739
+ * 远领不到。
1740
+ *
1741
+ * @param {number} taskId
1742
+ * @param {string} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
1743
+ * @param {string|Date} leaseUntil - 租期末尾
1744
+ * @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、排期被改过、或行已不是 pending
1745
+ */
1746
+ async claimTask(taskId, expectedNextSendAt, leaseUntil) {
1747
+ const expected = typeof expectedNextSendAt === "string" ? expectedNextSendAt : this._iso(expectedNextSendAt);
1748
+ const res = await this._db.prepare(
1749
+ `UPDATE scheduled_messages
1750
+ SET lease_until = ?, updated_at = ?
1751
+ WHERE id = ? AND status = 'pending' AND next_send_at = ?
1752
+ AND (lease_until IS NULL OR lease_until <= ?)`
1753
+ ).bind(this._iso(leaseUntil), this._now(), taskId, expected, this._now()).run();
1754
+ return (res.meta.changes || 0) > 0;
1755
+ }
1660
1756
  async listTasks(userId, opts = {}) {
1661
1757
  const { status = "all", limit = 20, offset = 0 } = opts;
1662
1758
  const conditions = ["user_id = ?"];
@@ -2082,7 +2178,7 @@ function createClientStateHandler(ctx) {
2082
2178
  }
2083
2179
 
2084
2180
  // src/server/lib/version.js
2085
- var SERVER_VERSION = true ? "2.6.0-next.4" : "0.0.0-dev";
2181
+ var SERVER_VERSION = true ? "2.6.0-next.6" : "0.0.0-dev";
2086
2182
 
2087
2183
  // src/server/handlers/capabilities.js
2088
2184
  var SERVER_FEATURES = Object.freeze([
@@ -2445,7 +2541,9 @@ function createSingleUserCloudflareWorker(buildConfig) {
2445
2541
  // spreads its ctx into processSingleMessage, so these ride along.
2446
2542
  hooks: cfg.hooks || null,
2447
2543
  maxToolIterations: cfg.maxToolIterations,
2448
- totalTimeoutMs: cfg.totalTimeoutMs
2544
+ totalTimeoutMs: cfg.totalTimeoutMs,
2545
+ // 任务占位租期(默认 10 分钟,随 totalTimeoutMs 抬高)。
2546
+ claimLeaseMs: cfg.claimLeaseMs
2449
2547
  });
2450
2548
  } catch (error) {
2451
2549
  console.error("[amsg single-user] scheduled(): tick failed:", error && error.message);