@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 +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-OVGQHTSE.mjs → chunk-JODQV7MD.mjs} +108 -10
- package/dist/{chunk-REX5MCEZ.cjs → chunk-WFFIWXM5.cjs} +108 -10
- package/dist/{cloudflare-sVIPkSsf.d.cts → cloudflare-qy1YWQcp.d.cts} +186 -15
- package/dist/{cloudflare-sVIPkSsf.d.ts → cloudflare-qy1YWQcp.d.ts} +186 -15
- 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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
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.
|
|
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);
|