@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.
@@ -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
- /** Frozen, credential-free view of the task for hook authors. */
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
- * @param {Object} ctx - { db, masterKey, vapid, webpush }
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 db.updateTaskById(task.id, { status: 'failed' });
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 db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
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 db.updateTaskById(task.id, { status: 'sent', retry_count: 0 });
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 db.updateTaskById(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
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: 10,
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(this._now(), limit).all();
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);