@rei-standard/amsg-server 2.6.0-next.5 → 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
@@ -1091,9 +1091,18 @@ function createScheduleMessageHandler(ctx) {
1091
1091
  }
1092
1092
 
1093
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
+ }
1094
1102
  async function runScheduledTick(ctx) {
1095
1103
  const db = ctx.db;
1096
1104
  const masterKey = ctx.masterKey;
1105
+ const claimLeaseMs = resolveClaimLeaseMs(ctx);
1097
1106
  const startTime = Date.now();
1098
1107
  const tasks = await db.getPendingTasks(50);
1099
1108
  const MAX_CONCURRENT = 8;
@@ -1101,19 +1110,29 @@ async function runScheduledTick(ctx) {
1101
1110
  totalTasks: tasks.length,
1102
1111
  successCount: 0,
1103
1112
  failedCount: 0,
1113
+ claimSkippedTasks: 0,
1104
1114
  deletedOnceOffTasks: 0,
1105
1115
  updatedRecurringTasks: 0,
1106
1116
  failedTasks: []
1107
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
+ }
1108
1127
  async function handleDeliveryFailure(task, reason) {
1109
1128
  results.failedCount++;
1110
1129
  try {
1111
1130
  if (task.retry_count >= 3) {
1112
- await db.updateTaskById(task.id, { status: "failed" });
1131
+ await updateAndRelease(task.id, { status: "failed" });
1113
1132
  results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "permanently_failed" });
1114
1133
  } else {
1115
1134
  const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1e3);
1116
- 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 });
1117
1136
  results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() });
1118
1137
  }
1119
1138
  } catch (updateError) {
@@ -1124,7 +1143,7 @@ async function runScheduledTick(ctx) {
1124
1143
  results.failedCount++;
1125
1144
  let markedSent = false;
1126
1145
  try {
1127
- await db.updateTaskById(task.id, { status: "sent", retry_count: 0 });
1146
+ await updateAndRelease(task.id, { status: "sent", retry_count: 0 });
1128
1147
  markedSent = true;
1129
1148
  } catch (_markSentError) {
1130
1149
  markedSent = false;
@@ -1137,6 +1156,18 @@ async function runScheduledTick(ctx) {
1137
1156
  });
1138
1157
  }
1139
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
+ }
1140
1171
  let sendResult;
1141
1172
  try {
1142
1173
  sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey);
@@ -1162,7 +1193,7 @@ async function runScheduledTick(ctx) {
1162
1193
  } else if (decryptedPayload.recurrenceType === "weekly") {
1163
1194
  nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1e3);
1164
1195
  }
1165
- 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 });
1166
1197
  results.updatedRecurringTasks++;
1167
1198
  }
1168
1199
  results.successCount++;
@@ -1195,6 +1226,7 @@ async function runScheduledTick(ctx) {
1195
1226
  processedAt: (/* @__PURE__ */ new Date()).toISOString(),
1196
1227
  executionTime,
1197
1228
  details: {
1229
+ claimSkippedTasks: results.claimSkippedTasks,
1198
1230
  deletedOnceOffTasks: results.deletedOnceOffTasks,
1199
1231
  updatedRecurringTasks: results.updatedRecurringTasks,
1200
1232
  failedTasks: results.failedTasks
@@ -1456,12 +1488,20 @@ var SQLITE_TABLE_SQL = `
1456
1488
  encrypted_payload TEXT NOT NULL,
1457
1489
  message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
1458
1490
  next_send_at TEXT NOT NULL,
1491
+ lease_until TEXT,
1459
1492
  status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
1460
1493
  retry_count INTEGER NOT NULL DEFAULT 0,
1461
1494
  created_at TEXT NOT NULL,
1462
1495
  updated_at TEXT NOT NULL
1463
1496
  )
1464
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
+ ];
1465
1505
  var SQLITE_INDEXES = [
1466
1506
  {
1467
1507
  name: "idx_pending_tasks_optimized",
@@ -1521,6 +1561,7 @@ var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
1521
1561
  "encrypted_payload",
1522
1562
  "message_type",
1523
1563
  "next_send_at",
1564
+ "lease_until",
1524
1565
  "status",
1525
1566
  "retry_count",
1526
1567
  "created_at",
@@ -1549,6 +1590,13 @@ var D1Adapter = class {
1549
1590
  async initSchema() {
1550
1591
  await this._db.prepare(SQLITE_TABLE_SQL).run();
1551
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
+ }
1552
1600
  const indexResults = [];
1553
1601
  for (const index of SQLITE_INDEXES) {
1554
1602
  try {
@@ -1566,7 +1614,7 @@ var D1Adapter = class {
1566
1614
  );
1567
1615
  }
1568
1616
  return {
1569
- columnsCreated: 10,
1617
+ columnsCreated: 11,
1570
1618
  indexesCreated: indexResults.filter((r) => r.status === "success").length,
1571
1619
  indexesFailed: indexResults.filter((r) => r.status === "failed").length,
1572
1620
  columns: [],
@@ -1658,15 +1706,53 @@ var D1Adapter = class {
1658
1706
  return res.meta.changes > 0;
1659
1707
  }
1660
1708
  async getPendingTasks(limit = 50) {
1709
+ const now = this._now();
1661
1710
  const res = await this._db.prepare(
1662
1711
  `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
1663
1712
  FROM scheduled_messages
1664
1713
  WHERE status = 'pending' AND next_send_at <= ?
1714
+ AND (lease_until IS NULL OR lease_until <= ?)
1665
1715
  ORDER BY next_send_at ASC
1666
1716
  LIMIT ?`
1667
- ).bind(this._now(), limit).all();
1717
+ ).bind(now, now, limit).all();
1668
1718
  return res.results || [];
1669
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
+ }
1670
1756
  async listTasks(userId, opts = {}) {
1671
1757
  const { status = "all", limit = 20, offset = 0 } = opts;
1672
1758
  const conditions = ["user_id = ?"];
@@ -2092,7 +2178,7 @@ function createClientStateHandler(ctx) {
2092
2178
  }
2093
2179
 
2094
2180
  // src/server/lib/version.js
2095
- var SERVER_VERSION = true ? "2.6.0-next.5" : "0.0.0-dev";
2181
+ var SERVER_VERSION = true ? "2.6.0-next.6" : "0.0.0-dev";
2096
2182
 
2097
2183
  // src/server/handlers/capabilities.js
2098
2184
  var SERVER_FEATURES = Object.freeze([
@@ -2455,7 +2541,9 @@ function createSingleUserCloudflareWorker(buildConfig) {
2455
2541
  // spreads its ctx into processSingleMessage, so these ride along.
2456
2542
  hooks: cfg.hooks || null,
2457
2543
  maxToolIterations: cfg.maxToolIterations,
2458
- totalTimeoutMs: cfg.totalTimeoutMs
2544
+ totalTimeoutMs: cfg.totalTimeoutMs,
2545
+ // 任务占位租期(默认 10 分钟,随 totalTimeoutMs 抬高)。
2546
+ claimLeaseMs: cfg.claimLeaseMs
2459
2547
  });
2460
2548
  } catch (error) {
2461
2549
  console.error("[amsg single-user] scheduled(): tick failed:", error && error.message);
@@ -1091,9 +1091,18 @@ function createScheduleMessageHandler(ctx) {
1091
1091
  }
1092
1092
 
1093
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
+ }
1094
1102
  async function runScheduledTick(ctx) {
1095
1103
  const db = ctx.db;
1096
1104
  const masterKey = ctx.masterKey;
1105
+ const claimLeaseMs = resolveClaimLeaseMs(ctx);
1097
1106
  const startTime = Date.now();
1098
1107
  const tasks = await db.getPendingTasks(50);
1099
1108
  const MAX_CONCURRENT = 8;
@@ -1101,19 +1110,29 @@ async function runScheduledTick(ctx) {
1101
1110
  totalTasks: tasks.length,
1102
1111
  successCount: 0,
1103
1112
  failedCount: 0,
1113
+ claimSkippedTasks: 0,
1104
1114
  deletedOnceOffTasks: 0,
1105
1115
  updatedRecurringTasks: 0,
1106
1116
  failedTasks: []
1107
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
+ }
1108
1127
  async function handleDeliveryFailure(task, reason) {
1109
1128
  results.failedCount++;
1110
1129
  try {
1111
1130
  if (task.retry_count >= 3) {
1112
- await db.updateTaskById(task.id, { status: "failed" });
1131
+ await updateAndRelease(task.id, { status: "failed" });
1113
1132
  results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: "permanently_failed" });
1114
1133
  } else {
1115
1134
  const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1e3);
1116
- 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 });
1117
1136
  results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() });
1118
1137
  }
1119
1138
  } catch (updateError) {
@@ -1124,7 +1143,7 @@ async function runScheduledTick(ctx) {
1124
1143
  results.failedCount++;
1125
1144
  let markedSent = false;
1126
1145
  try {
1127
- await db.updateTaskById(task.id, { status: "sent", retry_count: 0 });
1146
+ await updateAndRelease(task.id, { status: "sent", retry_count: 0 });
1128
1147
  markedSent = true;
1129
1148
  } catch (_markSentError) {
1130
1149
  markedSent = false;
@@ -1137,6 +1156,18 @@ async function runScheduledTick(ctx) {
1137
1156
  });
1138
1157
  }
1139
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
+ }
1140
1171
  let sendResult;
1141
1172
  try {
1142
1173
  sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey);
@@ -1162,7 +1193,7 @@ async function runScheduledTick(ctx) {
1162
1193
  } else if (decryptedPayload.recurrenceType === "weekly") {
1163
1194
  nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1e3);
1164
1195
  }
1165
- 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 });
1166
1197
  results.updatedRecurringTasks++;
1167
1198
  }
1168
1199
  results.successCount++;
@@ -1195,6 +1226,7 @@ async function runScheduledTick(ctx) {
1195
1226
  processedAt: (/* @__PURE__ */ new Date()).toISOString(),
1196
1227
  executionTime,
1197
1228
  details: {
1229
+ claimSkippedTasks: results.claimSkippedTasks,
1198
1230
  deletedOnceOffTasks: results.deletedOnceOffTasks,
1199
1231
  updatedRecurringTasks: results.updatedRecurringTasks,
1200
1232
  failedTasks: results.failedTasks
@@ -1456,12 +1488,20 @@ var SQLITE_TABLE_SQL = `
1456
1488
  encrypted_payload TEXT NOT NULL,
1457
1489
  message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
1458
1490
  next_send_at TEXT NOT NULL,
1491
+ lease_until TEXT,
1459
1492
  status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
1460
1493
  retry_count INTEGER NOT NULL DEFAULT 0,
1461
1494
  created_at TEXT NOT NULL,
1462
1495
  updated_at TEXT NOT NULL
1463
1496
  )
1464
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
+ ];
1465
1505
  var SQLITE_INDEXES = [
1466
1506
  {
1467
1507
  name: "idx_pending_tasks_optimized",
@@ -1521,6 +1561,7 @@ var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
1521
1561
  "encrypted_payload",
1522
1562
  "message_type",
1523
1563
  "next_send_at",
1564
+ "lease_until",
1524
1565
  "status",
1525
1566
  "retry_count",
1526
1567
  "created_at",
@@ -1549,6 +1590,13 @@ var D1Adapter = class {
1549
1590
  async initSchema() {
1550
1591
  await this._db.prepare(SQLITE_TABLE_SQL).run();
1551
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
+ }
1552
1600
  const indexResults = [];
1553
1601
  for (const index of SQLITE_INDEXES) {
1554
1602
  try {
@@ -1566,7 +1614,7 @@ var D1Adapter = class {
1566
1614
  );
1567
1615
  }
1568
1616
  return {
1569
- columnsCreated: 10,
1617
+ columnsCreated: 11,
1570
1618
  indexesCreated: indexResults.filter((r) => r.status === "success").length,
1571
1619
  indexesFailed: indexResults.filter((r) => r.status === "failed").length,
1572
1620
  columns: [],
@@ -1658,15 +1706,53 @@ var D1Adapter = class {
1658
1706
  return res.meta.changes > 0;
1659
1707
  }
1660
1708
  async getPendingTasks(limit = 50) {
1709
+ const now = this._now();
1661
1710
  const res = await this._db.prepare(
1662
1711
  `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
1663
1712
  FROM scheduled_messages
1664
1713
  WHERE status = 'pending' AND next_send_at <= ?
1714
+ AND (lease_until IS NULL OR lease_until <= ?)
1665
1715
  ORDER BY next_send_at ASC
1666
1716
  LIMIT ?`
1667
- ).bind(this._now(), limit).all();
1717
+ ).bind(now, now, limit).all();
1668
1718
  return res.results || [];
1669
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
+ }
1670
1756
  async listTasks(userId, opts = {}) {
1671
1757
  const { status = "all", limit = 20, offset = 0 } = opts;
1672
1758
  const conditions = ["user_id = ?"];
@@ -2092,7 +2178,7 @@ function createClientStateHandler(ctx) {
2092
2178
  }
2093
2179
 
2094
2180
  // src/server/lib/version.js
2095
- var SERVER_VERSION = true ? "2.6.0-next.5" : "0.0.0-dev";
2181
+ var SERVER_VERSION = true ? "2.6.0-next.6" : "0.0.0-dev";
2096
2182
 
2097
2183
  // src/server/handlers/capabilities.js
2098
2184
  var SERVER_FEATURES = Object.freeze([
@@ -2455,7 +2541,9 @@ function createSingleUserCloudflareWorker(buildConfig) {
2455
2541
  // spreads its ctx into processSingleMessage, so these ride along.
2456
2542
  hooks: cfg.hooks || null,
2457
2543
  maxToolIterations: cfg.maxToolIterations,
2458
- totalTimeoutMs: cfg.totalTimeoutMs
2544
+ totalTimeoutMs: cfg.totalTimeoutMs,
2545
+ // 任务占位租期(默认 10 分钟,随 totalTimeoutMs 抬高)。
2546
+ claimLeaseMs: cfg.claimLeaseMs
2459
2547
  });
2460
2548
  } catch (error) {
2461
2549
  console.error("[amsg single-user] scheduled(): tick failed:", error && error.message);