@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
|
@@ -1009,7 +1009,7 @@ async function resolveClientStateEntries(rows, fetchChunkRows, decryptValue) {
|
|
|
1009
1009
|
* When the host configures fire-time hooks, an LLM task stops replaying
|
|
1010
1010
|
* the completePrompt frozen at schedule time. At fire time instead:
|
|
1011
1011
|
*
|
|
1012
|
-
* onBeforeFire(fireCtx) → fresh messages (may read client_state)
|
|
1012
|
+
* onBeforeFire(fireCtx) → fresh messages (may read client_state) | { skip: true }
|
|
1013
1013
|
* → callLlm → onLLMOutput(sessionCtx) → decision
|
|
1014
1014
|
* ├─ 'finish' → push decision.pushPayloads, done
|
|
1015
1015
|
* ├─ 'skip-push' → record, done (task counts as delivered)
|
|
@@ -1073,7 +1073,13 @@ function taskNeedsLlm(decryptedPayload) {
|
|
|
1073
1073
|
return false;
|
|
1074
1074
|
}
|
|
1075
1075
|
|
|
1076
|
-
/**
|
|
1076
|
+
/**
|
|
1077
|
+
* Frozen, credential-free view of the task for hook authors.
|
|
1078
|
+
*
|
|
1079
|
+
* nextSendAt 是这条任务原本的触发时刻。run-tick 领取任务时写的是 lease_until,
|
|
1080
|
+
* next_send_at 那一列不动,所以这里给出去的和库里的是同一个值——宿主拿它当
|
|
1081
|
+
* 时间锚点(窗口判断、缓存键)时对得上。
|
|
1082
|
+
*/
|
|
1077
1083
|
function buildHookTask(task, decryptedPayload) {
|
|
1078
1084
|
const safe = {};
|
|
1079
1085
|
for (const [key, value] of Object.entries(decryptedPayload)) {
|
|
@@ -1100,7 +1106,7 @@ function normalizeBeforeFireResult(result) {
|
|
|
1100
1106
|
};
|
|
1101
1107
|
}
|
|
1102
1108
|
throw new TypeError(
|
|
1103
|
-
'AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs? } | null'
|
|
1109
|
+
'AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs? } | { skip: true } | null'
|
|
1104
1110
|
);
|
|
1105
1111
|
}
|
|
1106
1112
|
|
|
@@ -1127,7 +1133,11 @@ function firstPositiveNumber(values, fallback) {
|
|
|
1127
1133
|
* @param {string} args.userKey - per-user storage key (for readState decryption)
|
|
1128
1134
|
* @param {Object} args.ctx - processor ctx ({ db, webpush, vapid, hooks, maxToolIterations, totalTimeoutMs })
|
|
1129
1135
|
* @returns {Promise<{ handled: false } | { handled: true, result: { success: true, messagesSent: number, status: 'finished'|'skipped', iterations: number } }>}
|
|
1130
|
-
* `handled: false` → caller falls back to the legacy frozen-prompt path
|
|
1136
|
+
* `handled: false` → caller falls back to the legacy frozen-prompt path
|
|
1137
|
+
* (onBeforeFire returned null).
|
|
1138
|
+
* `onBeforeFire` may also return `{ skip: true }` to complete the fire
|
|
1139
|
+
* before the first LLM call → `status: 'skipped', iterations: 0`, same
|
|
1140
|
+
* success handling as the post-LLM skip-push path.
|
|
1131
1141
|
* Failures (timeout / loop exceeded / config errors) throw — the caller's
|
|
1132
1142
|
* existing error handling turns them into task retry/failure.
|
|
1133
1143
|
*/
|
|
@@ -1173,6 +1183,14 @@ async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
|
|
|
1173
1183
|
const before = await hooks.onBeforeFire(fireCtx);
|
|
1174
1184
|
if (before == null) return { handled: false };
|
|
1175
1185
|
|
|
1186
|
+
// Pre-LLM skip: the host judged this fire moot before generation (e.g. the
|
|
1187
|
+
// conversation moved on after the task was scheduled). Shaped exactly like
|
|
1188
|
+
// the post-LLM 'skipped' result, so run-tick's success handling (delete
|
|
1189
|
+
// once-off / advance recurrence) applies unchanged — and zero tokens spent.
|
|
1190
|
+
if (typeof before === 'object' && before.skip === true) {
|
|
1191
|
+
return { handled: true, result: { success: true, messagesSent: 0, status: 'skipped', iterations: 0 } };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1176
1194
|
const normalized = normalizeBeforeFireResult(before);
|
|
1177
1195
|
const maxToolIterations = firstPositiveInt(
|
|
1178
1196
|
[normalized.maxToolIterations, ctx.maxToolIterations],
|
|
@@ -1410,7 +1428,9 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
|
1410
1428
|
// needs the LLM, offer the agentic path first. onBeforeFire → null
|
|
1411
1429
|
// falls straight through to the frozen-prompt chain below, and
|
|
1412
1430
|
// deployments without hooks never enter this branch — legacy behavior
|
|
1413
|
-
// is byte-identical.
|
|
1431
|
+
// is byte-identical. onBeforeFire → { skip: true } completes the fire
|
|
1432
|
+
// here as a zero-push success (no LLM call, no frozen-prompt fallback):
|
|
1433
|
+
// use it when the host can tell at fire time the message is moot.
|
|
1414
1434
|
if (ctx.hooks && typeof ctx.hooks.onBeforeFire === 'function' && taskNeedsLlm(decryptedPayload)) {
|
|
1415
1435
|
const agentic = await runAgenticFire({ task, decryptedPayload, userKey, ctx });
|
|
1416
1436
|
if (agentic.handled) return agentic.result;
|
|
@@ -1870,14 +1890,47 @@ function createScheduleMessageHandler(ctx) {
|
|
|
1870
1890
|
* Extracted verbatim from the send-notifications handler so both the HTTP
|
|
1871
1891
|
* handler (multi-tenant) and the CF scheduled() path (single-user) share it.
|
|
1872
1892
|
*
|
|
1873
|
-
*
|
|
1893
|
+
* 每个 tick 是一次独立调用(cron 每分钟一跳,不会因为上一跳还没跑完就跳过
|
|
1894
|
+
* 这一跳),而一次投递「组 prompt → 调 LLM → 跑工具 → 推送」可能要几分钟。
|
|
1895
|
+
* 所以每条任务开跑前先占位:在这一行的 lease_until 上写下「归我管到什么时
|
|
1896
|
+
* 候」,本次投递期间别的 tick 领不走它。占位失败(0 行)说明别人先领走了,
|
|
1897
|
+
* 直接跳过。
|
|
1898
|
+
*
|
|
1899
|
+
* 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
|
|
1900
|
+
* 任务列表要读它、循环任务推进下一次也要拿它当基准。投递收尾时把租约放掉,
|
|
1901
|
+
* 这条任务立刻可以被下一跳接手(失败重试的退避只有 2 分钟,比租期短得多,
|
|
1902
|
+
* 不放掉的话退避就白设了)。
|
|
1903
|
+
*
|
|
1904
|
+
* 领了任务的 tick 中途没了(Worker 被回收之类)就没人来放租约,这条任务要
|
|
1905
|
+
* 等租约到期才会被接手——这是租约本身的代价,把租期设得比最慢的一次投递长
|
|
1906
|
+
* 一点就行。
|
|
1907
|
+
*
|
|
1908
|
+
* @param {Object} ctx - { db, masterKey, vapid, webpush, claimLeaseMs? }
|
|
1874
1909
|
* @returns {Promise<Object>} summary { totalTasks, successCount, failedCount, processedAt, executionTime, details }
|
|
1875
1910
|
*/
|
|
1876
1911
|
|
|
1877
1912
|
|
|
1913
|
+
// 占位租期:要盖住最慢的一次投递。老链路单次 LLM 调用上限 300s,agentic 链路
|
|
1914
|
+
// 整链默认 240s,再留出推送节奏的余量。
|
|
1915
|
+
const DEFAULT_CLAIM_LEASE_MS = 10 * 60 * 1000;
|
|
1916
|
+
// 宿主把 totalTimeoutMs 调大时租期跟着抬,别让下一跳在投递还没跑完时就把行
|
|
1917
|
+
// 捞回去。(onBeforeFire 里按次放宽的预算这里看不到,那种情况请显式设
|
|
1918
|
+
// ctx.claimLeaseMs。)
|
|
1919
|
+
const CLAIM_LEASE_MARGIN_MS = 2 * 60 * 1000;
|
|
1920
|
+
|
|
1921
|
+
function positiveNumber(value) {
|
|
1922
|
+
return (typeof value === 'number' && Number.isFinite(value) && value > 0) ? value : 0;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function resolveClaimLeaseMs(ctx) {
|
|
1926
|
+
return positiveNumber(ctx.claimLeaseMs)
|
|
1927
|
+
|| Math.max(DEFAULT_CLAIM_LEASE_MS, positiveNumber(ctx.totalTimeoutMs) + CLAIM_LEASE_MARGIN_MS);
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1878
1930
|
async function runScheduledTick(ctx) {
|
|
1879
1931
|
const db = ctx.db;
|
|
1880
1932
|
const masterKey = ctx.masterKey;
|
|
1933
|
+
const claimLeaseMs = resolveClaimLeaseMs(ctx);
|
|
1881
1934
|
|
|
1882
1935
|
const startTime = Date.now();
|
|
1883
1936
|
const tasks = await db.getPendingTasks(50);
|
|
@@ -1887,20 +1940,42 @@ async function runScheduledTick(ctx) {
|
|
|
1887
1940
|
totalTasks: tasks.length,
|
|
1888
1941
|
successCount: 0,
|
|
1889
1942
|
failedCount: 0,
|
|
1943
|
+
claimSkippedTasks: 0,
|
|
1890
1944
|
deletedOnceOffTasks: 0,
|
|
1891
1945
|
updatedRecurringTasks: 0,
|
|
1892
1946
|
failedTasks: []
|
|
1893
1947
|
};
|
|
1894
1948
|
|
|
1949
|
+
// 适配器没实现 claimTask(自定义适配器)→ 退回不占位的老行为:跑得动,只是
|
|
1950
|
+
// 超过一跳间隔的慢任务仍可能被下一跳重复触发。
|
|
1951
|
+
const supportsClaim = typeof db.claimTask === 'function';
|
|
1952
|
+
|
|
1953
|
+
async function claimForThisTick(task) {
|
|
1954
|
+
if (!supportsClaim) return true;
|
|
1955
|
+
const leaseUntil = new Date(Date.now() + claimLeaseMs).toISOString();
|
|
1956
|
+
return !!(await db.claimTask(task.id, task.next_send_at, leaseUntil));
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/**
|
|
1960
|
+
* 投递收尾时写库,顺手把租约放掉。占位之后的每一次写库都要走这里,漏掉
|
|
1961
|
+
* 一条那条任务就得等租约到期才动得了。
|
|
1962
|
+
*
|
|
1963
|
+
* 没实现 claimTask 的适配器不会有 lease_until 这一列,就别往它的
|
|
1964
|
+
* updateTaskById 里塞这个字段了。
|
|
1965
|
+
*/
|
|
1966
|
+
async function updateAndRelease(taskId, fields) {
|
|
1967
|
+
return db.updateTaskById(taskId, supportsClaim ? { ...fields, lease_until: null } : fields);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1895
1970
|
async function handleDeliveryFailure(task, reason) {
|
|
1896
1971
|
results.failedCount++;
|
|
1897
1972
|
try {
|
|
1898
1973
|
if (task.retry_count >= 3) {
|
|
1899
|
-
await
|
|
1974
|
+
await updateAndRelease(task.id, { status: 'failed' });
|
|
1900
1975
|
results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: 'permanently_failed' });
|
|
1901
1976
|
} else {
|
|
1902
1977
|
const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1000);
|
|
1903
|
-
await
|
|
1978
|
+
await updateAndRelease(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
|
|
1904
1979
|
results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() });
|
|
1905
1980
|
}
|
|
1906
1981
|
} catch (updateError) {
|
|
@@ -1912,7 +1987,7 @@ async function runScheduledTick(ctx) {
|
|
|
1912
1987
|
results.failedCount++;
|
|
1913
1988
|
let markedSent = false;
|
|
1914
1989
|
try {
|
|
1915
|
-
await
|
|
1990
|
+
await updateAndRelease(task.id, { status: 'sent', retry_count: 0 });
|
|
1916
1991
|
markedSent = true;
|
|
1917
1992
|
} catch (_markSentError) {
|
|
1918
1993
|
markedSent = false;
|
|
@@ -1926,6 +2001,22 @@ async function runScheduledTick(ctx) {
|
|
|
1926
2001
|
}
|
|
1927
2002
|
|
|
1928
2003
|
async function processTask(task) {
|
|
2004
|
+
let claimed;
|
|
2005
|
+
try {
|
|
2006
|
+
claimed = await claimForThisTick(task);
|
|
2007
|
+
} catch (error) {
|
|
2008
|
+
// 占位这一步就出错,说明库有问题——此时不知道别人有没有在跑这条,宁可
|
|
2009
|
+
// 不发。行还是 pending,下一跳会重新捞。
|
|
2010
|
+
results.failedCount++;
|
|
2011
|
+
results.failedTasks.push({ taskId: task.id, reason: error.message || '任务占位失败', status: 'claim_failed' });
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
if (!claimed) {
|
|
2015
|
+
// 另一个 tick 已经领走了这条,本次什么都不做。
|
|
2016
|
+
results.claimSkippedTasks++;
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
1929
2020
|
let sendResult;
|
|
1930
2021
|
try {
|
|
1931
2022
|
sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey);
|
|
@@ -1948,13 +2039,14 @@ async function runScheduledTick(ctx) {
|
|
|
1948
2039
|
results.deletedOnceOffTasks++;
|
|
1949
2040
|
} else {
|
|
1950
2041
|
let nextSendAt;
|
|
2042
|
+
// 以这条任务原本的触发时刻为基准往后推。
|
|
1951
2043
|
const currentSendAt = new Date(task.next_send_at);
|
|
1952
2044
|
if (decryptedPayload.recurrenceType === 'daily') {
|
|
1953
2045
|
nextSendAt = new Date(currentSendAt.getTime() + 24 * 60 * 60 * 1000);
|
|
1954
2046
|
} else if (decryptedPayload.recurrenceType === 'weekly') {
|
|
1955
2047
|
nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
1956
2048
|
}
|
|
1957
|
-
await
|
|
2049
|
+
await updateAndRelease(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
|
|
1958
2050
|
results.updatedRecurringTasks++;
|
|
1959
2051
|
}
|
|
1960
2052
|
|
|
@@ -1993,6 +2085,7 @@ async function runScheduledTick(ctx) {
|
|
|
1993
2085
|
processedAt: new Date().toISOString(),
|
|
1994
2086
|
executionTime,
|
|
1995
2087
|
details: {
|
|
2088
|
+
claimSkippedTasks: results.claimSkippedTasks,
|
|
1996
2089
|
deletedOnceOffTasks: results.deletedOnceOffTasks,
|
|
1997
2090
|
updatedRecurringTasks: results.updatedRecurringTasks,
|
|
1998
2091
|
failedTasks: results.failedTasks
|
|
@@ -2302,7 +2395,14 @@ function createMessagesHandler(ctx) {
|
|
|
2302
2395
|
status: task.status,
|
|
2303
2396
|
retryCount: task.retry_count,
|
|
2304
2397
|
createdAt: task.created_at,
|
|
2305
|
-
updatedAt: task.updated_at
|
|
2398
|
+
updatedAt: task.updated_at,
|
|
2399
|
+
// Character ownership / client-side task identity, pulled from the
|
|
2400
|
+
// scheduling host's metadata so it can filter tasks by character
|
|
2401
|
+
// (contactName can collide across characters). Only these two
|
|
2402
|
+
// metadata fields are surfaced — the rest of metadata may hold
|
|
2403
|
+
// host-private data and stays server-side. Absent → null.
|
|
2404
|
+
charId: decrypted.metadata?.charId ?? null,
|
|
2405
|
+
clientTaskId: decrypted.metadata?.amsgClientTaskId ?? null
|
|
2306
2406
|
};
|
|
2307
2407
|
}));
|
|
2308
2408
|
|
|
@@ -2348,6 +2448,7 @@ const SQLITE_TABLE_SQL = `
|
|
|
2348
2448
|
encrypted_payload TEXT NOT NULL,
|
|
2349
2449
|
message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
|
|
2350
2450
|
next_send_at TEXT NOT NULL,
|
|
2451
|
+
lease_until TEXT,
|
|
2351
2452
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
|
2352
2453
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
2353
2454
|
created_at TEXT NOT NULL,
|
|
@@ -2355,6 +2456,18 @@ const SQLITE_TABLE_SQL = `
|
|
|
2355
2456
|
)
|
|
2356
2457
|
`;
|
|
2357
2458
|
|
|
2459
|
+
/**
|
|
2460
|
+
* 建表语句用的是 CREATE TABLE IF NOT EXISTS,已经存在的表不会被改动,所以
|
|
2461
|
+
* 后加的列要单独补。initSchema 每次都会跑一遍,列已经在了就跳过。
|
|
2462
|
+
*/
|
|
2463
|
+
const SQLITE_MIGRATIONS = [
|
|
2464
|
+
{
|
|
2465
|
+
name: 'add_lease_until',
|
|
2466
|
+
sql: 'ALTER TABLE scheduled_messages ADD COLUMN lease_until TEXT',
|
|
2467
|
+
description: 'Task claim lease (2.6.0)'
|
|
2468
|
+
}
|
|
2469
|
+
];
|
|
2470
|
+
|
|
2358
2471
|
const SQLITE_INDEXES = [
|
|
2359
2472
|
{
|
|
2360
2473
|
name: 'idx_pending_tasks_optimized',
|
|
@@ -2431,7 +2544,7 @@ const CLIENT_STATE_TABLE_SQL = `
|
|
|
2431
2544
|
// from ever turning a caller-supplied key into interpolated SQL.
|
|
2432
2545
|
const UPDATABLE_COLUMNS = new Set([
|
|
2433
2546
|
'user_id', 'uuid', 'encrypted_payload', 'message_type',
|
|
2434
|
-
'next_send_at', 'status', 'retry_count', 'created_at', 'updated_at'
|
|
2547
|
+
'next_send_at', 'lease_until', 'status', 'retry_count', 'created_at', 'updated_at'
|
|
2435
2548
|
]);
|
|
2436
2549
|
|
|
2437
2550
|
// LIKE 前缀转义:用户 key 里的 % _ \ 不能变成通配符/转义符。
|
|
@@ -2464,6 +2577,17 @@ class D1Adapter {
|
|
|
2464
2577
|
await this._db.prepare(SQLITE_TABLE_SQL).run();
|
|
2465
2578
|
await this._db.prepare(CLIENT_STATE_TABLE_SQL).run();
|
|
2466
2579
|
|
|
2580
|
+
// SQLite 的 ALTER TABLE 没有 ADD COLUMN IF NOT EXISTS,列已经在了就会
|
|
2581
|
+
// 报 duplicate column name。那正是「这一步不用做」的意思,跳过即可;
|
|
2582
|
+
// 其他错误照常抛出去。
|
|
2583
|
+
for (const migration of SQLITE_MIGRATIONS) {
|
|
2584
|
+
try {
|
|
2585
|
+
await this._db.prepare(migration.sql).run();
|
|
2586
|
+
} catch (error) {
|
|
2587
|
+
if (!/duplicate column name/i.test(error.message || '')) throw error;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2467
2591
|
const indexResults = [];
|
|
2468
2592
|
for (const index of SQLITE_INDEXES) {
|
|
2469
2593
|
try {
|
|
@@ -2484,7 +2608,7 @@ class D1Adapter {
|
|
|
2484
2608
|
}
|
|
2485
2609
|
|
|
2486
2610
|
return {
|
|
2487
|
-
columnsCreated:
|
|
2611
|
+
columnsCreated: 11,
|
|
2488
2612
|
indexesCreated: indexResults.filter((r) => r.status === 'success').length,
|
|
2489
2613
|
indexesFailed: indexResults.filter((r) => r.status === 'failed').length,
|
|
2490
2614
|
columns: [],
|
|
@@ -2591,16 +2715,57 @@ class D1Adapter {
|
|
|
2591
2715
|
}
|
|
2592
2716
|
|
|
2593
2717
|
async getPendingTasks(limit = 50) {
|
|
2718
|
+
const now = this._now();
|
|
2594
2719
|
const res = await this._db.prepare(
|
|
2595
2720
|
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
|
|
2596
2721
|
FROM scheduled_messages
|
|
2597
2722
|
WHERE status = 'pending' AND next_send_at <= ?
|
|
2723
|
+
AND (lease_until IS NULL OR lease_until <= ?)
|
|
2598
2724
|
ORDER BY next_send_at ASC
|
|
2599
2725
|
LIMIT ?`
|
|
2600
|
-
).bind(
|
|
2726
|
+
).bind(now, now, limit).all();
|
|
2601
2727
|
return res.results || [];
|
|
2602
2728
|
}
|
|
2603
2729
|
|
|
2730
|
+
/**
|
|
2731
|
+
* 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
|
|
2732
|
+
* 本次投递期间别的 tick 领不走它。
|
|
2733
|
+
*
|
|
2734
|
+
* 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
|
|
2735
|
+
* 任务列表要读它、循环任务推进下一次也要拿它当基准。
|
|
2736
|
+
*
|
|
2737
|
+
* 两个 tick 抢同一行时只有一个能改到行,另一个拿到 changes = 0,据此跳过。
|
|
2738
|
+
* WHERE 里的两个条件各管一件事:
|
|
2739
|
+
* - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
|
|
2740
|
+
* 也不会把行焊死,租约到期后自然可以被接手。
|
|
2741
|
+
* - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
|
|
2742
|
+
* 这一跳就不该再按旧时刻发。
|
|
2743
|
+
*
|
|
2744
|
+
* 不加一个 'sending' 状态来表达「正在跑」:建表语句里 status 有
|
|
2745
|
+
* CHECK (status IN ('pending','sent','failed')),加值要重建表。
|
|
2746
|
+
*
|
|
2747
|
+
* expectedNextSendAt 按读到的原样比对,不做时区归一化——老部署里可能还留
|
|
2748
|
+
* 着非归一化写法的行(如 +08:00 结尾),归一化后反而对不上,那条任务会永
|
|
2749
|
+
* 远领不到。
|
|
2750
|
+
*
|
|
2751
|
+
* @param {number} taskId
|
|
2752
|
+
* @param {string} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
|
|
2753
|
+
* @param {string|Date} leaseUntil - 租期末尾
|
|
2754
|
+
* @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、排期被改过、或行已不是 pending
|
|
2755
|
+
*/
|
|
2756
|
+
async claimTask(taskId, expectedNextSendAt, leaseUntil) {
|
|
2757
|
+
const expected = typeof expectedNextSendAt === 'string'
|
|
2758
|
+
? expectedNextSendAt
|
|
2759
|
+
: this._iso(expectedNextSendAt);
|
|
2760
|
+
const res = await this._db.prepare(
|
|
2761
|
+
`UPDATE scheduled_messages
|
|
2762
|
+
SET lease_until = ?, updated_at = ?
|
|
2763
|
+
WHERE id = ? AND status = 'pending' AND next_send_at = ?
|
|
2764
|
+
AND (lease_until IS NULL OR lease_until <= ?)`
|
|
2765
|
+
).bind(this._iso(leaseUntil), this._now(), taskId, expected, this._now()).run();
|
|
2766
|
+
return (res.meta.changes || 0) > 0;
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2604
2769
|
async listTasks(userId, opts = {}) {
|
|
2605
2770
|
const { status = 'all', limit = 20, offset = 0 } = opts;
|
|
2606
2771
|
const conditions = ['user_id = ?'];
|
|
@@ -3640,6 +3805,10 @@ function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}
|
|
|
3640
3805
|
* config to let scheduled AI tasks assemble their prompt and run a server-side
|
|
3641
3806
|
* tool loop at fire time. Omit them and AI tasks replay the schedule-time frozen
|
|
3642
3807
|
* prompt exactly as before. See lib/agentic-fire.js.
|
|
3808
|
+
*
|
|
3809
|
+
* scheduled() 每次触发都会先给任务占位(在行的 lease_until 上写租约),同一
|
|
3810
|
+
* 条任务不会被相邻两跳重复触发(见 lib/run-tick.js)。租期默认 10 分钟,可以
|
|
3811
|
+
* 用 config 里的 `claimLeaseMs` 调整。
|
|
3643
3812
|
*/
|
|
3644
3813
|
|
|
3645
3814
|
|
|
@@ -3772,7 +3941,9 @@ function createSingleUserCloudflareWorker(buildConfig) {
|
|
|
3772
3941
|
// spreads its ctx into processSingleMessage, so these ride along.
|
|
3773
3942
|
hooks: cfg.hooks || null,
|
|
3774
3943
|
maxToolIterations: cfg.maxToolIterations,
|
|
3775
|
-
totalTimeoutMs: cfg.totalTimeoutMs
|
|
3944
|
+
totalTimeoutMs: cfg.totalTimeoutMs,
|
|
3945
|
+
// 任务占位租期(默认 10 分钟,随 totalTimeoutMs 抬高)。
|
|
3946
|
+
claimLeaseMs: cfg.claimLeaseMs
|
|
3776
3947
|
});
|
|
3777
3948
|
} catch (error) {
|
|
3778
3949
|
console.error('[amsg single-user] scheduled(): tick failed:', error && error.message);
|