@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 +16 -0
- package/dist/{chunk-RGECD4OH.cjs → chunk-DCKCAGFV.cjs} +10 -1
- package/dist/{chunk-5ENJGVZX.mjs → chunk-GOBCWCJP.mjs} +9 -0
- package/dist/{chunk-ES2HUXYZ.mjs → chunk-JODQV7MD.mjs} +96 -8
- package/dist/{chunk-IFI4EWDW.cjs → chunk-WFFIWXM5.cjs} +96 -8
- package/dist/{cloudflare-DM_Njij8.d.cts → cloudflare-qy1YWQcp.d.cts} +160 -10
- package/dist/{cloudflare-DM_Njij8.d.ts → cloudflare-qy1YWQcp.d.ts} +160 -10
- package/dist/cloudflare.cjs +2 -2
- package/dist/cloudflare.d.cts +1 -1
- package/dist/cloudflare.d.ts +1 -1
- package/dist/cloudflare.mjs +1 -1
- package/dist/index.cjs +13 -13
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.mjs +3 -3
- package/dist/{neon-DYvGnCzx.d.ts → neon-D8Z2Nn97.d.cts} +45 -1
- package/dist/{neon-CJl66EGy.d.cts → neon-PqNFJIz2.d.ts} +45 -1
- package/dist/{neon-CU5N3CSW.mjs → neon-QJ7KYHEX.mjs} +45 -1
- package/dist/{neon-BFUS25UX.cjs → neon-V4VOH55N.cjs} +49 -5
- package/dist/{pg-IIH3M4OM.cjs → pg-6X74HKMX.cjs} +48 -5
- package/dist/{pg-Du-pN_UT.d.ts → pg-C48FwCWW.d.cts} +44 -1
- package/dist/{pg-07S-u_H4.d.cts → pg-C5gtWUfU.d.ts} +44 -1
- package/dist/{pg-QO6NKTGL.mjs → pg-DFJSFOJQ.mjs} +44 -1
- package/dist/{schema-C8OnYk6j.d.cts → schema-BHuyH_6Z.d.cts} +15 -1
- package/dist/{schema-C8OnYk6j.d.ts → schema-BHuyH_6Z.d.ts} +15 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
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.
|
|
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);
|