metheus-governance-mcp-cli 0.2.184 → 0.2.185

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/cli.mjs CHANGED
@@ -130,6 +130,8 @@ import {
130
130
  import {
131
131
  applyPendingAgeSelection,
132
132
  buildRunnerRouteStateFromComment,
133
+ buildProcessableArchiveLogicalKey,
134
+ findEarlierProcessableArchiveDuplicate,
133
135
  isInboundArchiveKind,
134
136
  normalizeArchiveCommentRecord,
135
137
  selectPendingArchiveComments,
@@ -148,6 +150,7 @@ import {
148
150
  listProjectContextItems as listProjectContextItemsImpl,
149
151
  listProjectCtxpackFiles as listProjectCtxpackFilesImpl,
150
152
  listThreadComments as listThreadCommentsImpl,
153
+ listThreadCommentsTail as listThreadCommentsTailImpl,
151
154
  listUserBotsForRunner as listUserBotsForRunnerImpl,
152
155
  selectProjectChatDestination as selectProjectChatDestinationImpl,
153
156
  selectRunnerBot as selectRunnerBotImpl,
@@ -3977,6 +3980,10 @@ async function listThreadComments(params) {
3977
3980
  return listThreadCommentsImpl(params, buildRunnerDataDeps());
3978
3981
  }
3979
3982
 
3983
+ async function listThreadCommentsTail(params) {
3984
+ return listThreadCommentsTailImpl(params, buildRunnerDataDeps());
3985
+ }
3986
+
3980
3987
  async function createThreadComment(params) {
3981
3988
  return createThreadCommentImpl(params, buildRunnerDataDeps());
3982
3989
  }
@@ -4005,6 +4012,7 @@ function buildRunnerDeliveryDeps() {
4005
4012
  providerEnvConfig,
4006
4013
  discoverArchiveThreadForDestination,
4007
4014
  listThreadComments,
4015
+ listThreadCommentsTail,
4008
4016
  createThreadComment,
4009
4017
  parseArchivedChatComment,
4010
4018
  };
@@ -4377,6 +4385,7 @@ function buildRunnerRuntimeDeps() {
4377
4385
  getTelegramUpdates,
4378
4386
  normalizeLocalTelegramUpdate,
4379
4387
  listThreadComments,
4388
+ listThreadCommentsTail,
4380
4389
  applyPendingAgeSelection,
4381
4390
  normalizeArchiveCommentRecord,
4382
4391
  isInboundArchiveKind,
@@ -4819,9 +4828,67 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
4819
4828
  pending_count: pending.pending.length,
4820
4829
  };
4821
4830
  }
4831
+ const maybeBuildDuplicateArchivedSkip = (selectedRecord, routeStateForDuplicate) => {
4832
+ const selectedParsed = safeObject(selectedRecord?.parsedArchive);
4833
+ const selectedLogicalKey = buildProcessableArchiveLogicalKey(selectedRecord);
4834
+ if (!selectedLogicalKey) {
4835
+ return null;
4836
+ }
4837
+ const earlierDuplicate = findEarlierProcessableArchiveDuplicate(pending.ordered, selectedRecord);
4838
+ if (earlierDuplicate) {
4839
+ return {
4840
+ id: selectedRecord.id,
4841
+ reason: "duplicate_archived_source_message",
4842
+ messageID: intFromRawAllowZero(selectedParsed.messageID, 0),
4843
+ diagnosticType: "skip",
4844
+ contextExcluded: true,
4845
+ action: "skip_duplicate_archived_message",
4846
+ closedReason: "",
4847
+ };
4848
+ }
4849
+ const duplicateState = safeObject(routeStateForDuplicate);
4850
+ const lastSourceMessageID = intFromRawAllowZero(duplicateState.last_source_message_id, 0);
4851
+ const lastSourceKind = String(duplicateState.last_source_kind || "").trim().toLowerCase();
4852
+ const selectedMessageID = intFromRawAllowZero(selectedParsed.messageID, 0);
4853
+ const selectedKind = String(selectedParsed.kind || "").trim().toLowerCase();
4854
+ const lastCommentID = String(duplicateState.last_processed_comment_id || "").trim();
4855
+ if (
4856
+ selectedMessageID > 0
4857
+ && lastSourceMessageID > 0
4858
+ && selectedMessageID === lastSourceMessageID
4859
+ && selectedKind
4860
+ && lastSourceKind === selectedKind
4861
+ && lastCommentID
4862
+ && lastCommentID !== String(selectedRecord?.id || "").trim()
4863
+ ) {
4864
+ return {
4865
+ id: selectedRecord.id,
4866
+ reason: "duplicate_archived_source_message",
4867
+ messageID: selectedMessageID,
4868
+ diagnosticType: "skip",
4869
+ contextExcluded: true,
4870
+ action: "skip_duplicate_archived_message",
4871
+ closedReason: "",
4872
+ };
4873
+ }
4874
+ return null;
4875
+ };
4822
4876
  if (deferExecution) {
4823
4877
  const skippedRecords = [];
4824
4878
  for (const selectedRecord of pending.pending) {
4879
+ const duplicateArchivedSkip = maybeBuildDuplicateArchivedSkip(selectedRecord, refreshedState);
4880
+ if (duplicateArchivedSkip) {
4881
+ saveRunnerRouteState(
4882
+ routeKey,
4883
+ buildRunnerRouteStateFromComment(selectedRecord, {
4884
+ last_action: "duplicate_skipped",
4885
+ last_reason: String(duplicateArchivedSkip.reason || "duplicate_archived_source_message").trim(),
4886
+ last_trigger: "archive_dedupe",
4887
+ }),
4888
+ );
4889
+ skippedRecords.push(duplicateArchivedSkip);
4890
+ continue;
4891
+ }
4825
4892
  const startupLoopSkipped = await maybeHandleRunnerStartupLoopCandidate({
4826
4893
  routeKey,
4827
4894
  normalizedRoute,
@@ -4929,6 +4996,19 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
4929
4996
  }
4930
4997
  const skippedRecords = [];
4931
4998
  for (const selectedRecord of pending.pending) {
4999
+ const duplicateArchivedSkip = maybeBuildDuplicateArchivedSkip(selectedRecord, refreshedState);
5000
+ if (duplicateArchivedSkip) {
5001
+ saveRunnerRouteState(
5002
+ routeKey,
5003
+ buildRunnerRouteStateFromComment(selectedRecord, {
5004
+ last_action: "duplicate_skipped",
5005
+ last_reason: String(duplicateArchivedSkip.reason || "duplicate_archived_source_message").trim(),
5006
+ last_trigger: "archive_dedupe",
5007
+ }),
5008
+ );
5009
+ skippedRecords.push(duplicateArchivedSkip);
5010
+ continue;
5011
+ }
4932
5012
  const startupLoopSkipped = await maybeHandleRunnerStartupLoopCandidate({
4933
5013
  routeKey,
4934
5014
  normalizedRoute,
@@ -11341,6 +11421,7 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
11341
11421
  normalizeRunnerTriggerPolicy,
11342
11422
  evaluateTelegramRunnerTrigger,
11343
11423
  selectPendingArchiveComments,
11424
+ selectRunnerPendingWork,
11344
11425
  processRunnerSelectedRecord,
11345
11426
  resolveRunnerStartupLoopAdjudication,
11346
11427
  runRunnerAIExecution,
@@ -348,6 +348,55 @@ export async function listThreadComments(
348
348
  return items;
349
349
  }
350
350
 
351
+ export async function listThreadCommentsTail(
352
+ {
353
+ siteBaseURL,
354
+ threadID,
355
+ token,
356
+ timeoutSeconds,
357
+ tailLimit = 200,
358
+ scanLimit = 5000,
359
+ actorUserID = "",
360
+ },
361
+ deps,
362
+ ) {
363
+ const getJSONWithAuthHeaders = requireDependency(deps, "getJSONWithAuthHeaders");
364
+ const extraHeaders = actorUserID ? { "X-Actor-User-Id": actorUserID } : {};
365
+ const maxTail = Math.max(1, parsePositiveInt(tailLimit, 200));
366
+ const maxScan = Math.max(maxTail, parsePositiveInt(scanLimit, 5000));
367
+ const items = [];
368
+ let nextOffset = 0;
369
+ let scanned = 0;
370
+ let previousPageSignature = "";
371
+
372
+ while (scanned < maxScan) {
373
+ const pageSize = Math.min(100, maxScan - scanned);
374
+ if (pageSize <= 0) break;
375
+ const url = `${siteBaseURL}/api/v1/threads/${encodeURIComponent(threadID)}/comments?limit=${pageSize}&offset=${nextOffset}`;
376
+ const page = ensureArray(await getJSONWithAuthHeaders(url, timeoutSeconds, token, extraHeaders))
377
+ .map((row) => safeObject(row));
378
+ if (!page.length) {
379
+ break;
380
+ }
381
+ const pageSignature = `${page.length}:${String(page[0]?.id || "").trim()}:${String(page[page.length - 1]?.id || "").trim()}`;
382
+ if (nextOffset > 0 && pageSignature === previousPageSignature) {
383
+ break;
384
+ }
385
+ previousPageSignature = pageSignature;
386
+ items.push(...page);
387
+ if (items.length > maxTail) {
388
+ items.splice(0, items.length - maxTail);
389
+ }
390
+ scanned += page.length;
391
+ if (page.length < pageSize) {
392
+ break;
393
+ }
394
+ nextOffset += page.length;
395
+ }
396
+
397
+ return items;
398
+ }
399
+
351
400
  export async function createThreadComment(
352
401
  {
353
402
  siteBaseURL,
@@ -251,6 +251,9 @@ export async function performLocalBotDelivery({
251
251
  const providerEnvConfig = requireDependency(deps, "providerEnvConfig");
252
252
  const discoverArchiveThreadForDestination = requireDependency(deps, "discoverArchiveThreadForDestination");
253
253
  const listThreadComments = requireDependency(deps, "listThreadComments");
254
+ const listThreadCommentsTail = typeof deps?.listThreadCommentsTail === "function"
255
+ ? deps.listThreadCommentsTail
256
+ : listThreadComments;
254
257
  const createThreadComment = requireDependency(deps, "createThreadComment");
255
258
  const parseArchivedChatComment = requireDependency(deps, "parseArchivedChatComment");
256
259
 
@@ -352,12 +355,13 @@ export async function performLocalBotDelivery({
352
355
  );
353
356
  const archiveReplyToMessageID = intFromRawAllowZero(delivery.effectiveReplyToMessageID, replyToMessageID);
354
357
  if (archiveDedupeOutbound && deliveredMessageID > 0) {
355
- const existingComments = await listThreadComments({
358
+ const existingComments = await listThreadCommentsTail({
356
359
  siteBaseURL,
357
360
  threadID: thread.threadID,
358
361
  token,
359
362
  timeoutSeconds,
360
- limit: 200,
363
+ tailLimit: 500,
364
+ scanLimit: 5000,
361
365
  actorUserID,
362
366
  });
363
367
  const existingRecord = findArchivedBotReplyRecord(existingComments, {
@@ -192,15 +192,70 @@ function isRunnerProcessableArchiveKind(kind) {
192
192
  return isInboundArchiveKind(kind) || String(kind || "").trim().toLowerCase() === "bot_reply";
193
193
  }
194
194
 
195
+ export function buildProcessableArchiveLogicalKey(recordRaw) {
196
+ const record = safeObject(recordRaw);
197
+ const parsed = safeObject(record.parsedArchive);
198
+ const kind = String(parsed.kind || "").trim().toLowerCase();
199
+ if (!isRunnerProcessableArchiveKind(kind)) {
200
+ return "";
201
+ }
202
+ const chatID = firstNonEmptyString([parsed.chatID, parsed.chatId]);
203
+ const messageID = intFromRawAllowZero(parsed.messageID, 0);
204
+ if (!chatID || !(messageID > 0)) {
205
+ return "";
206
+ }
207
+ return `${kind}:${chatID}:${messageID}`;
208
+ }
209
+
210
+ export function findEarlierProcessableArchiveDuplicate(records, selectedRecord) {
211
+ const selected = safeObject(selectedRecord);
212
+ const selectedID = String(selected.id || "").trim();
213
+ if (!selectedID) return null;
214
+ const selectedLogicalKey = buildProcessableArchiveLogicalKey(selected);
215
+ if (!selectedLogicalKey) return null;
216
+ const ordered = ensureArray(records).filter(Boolean).sort(compareArchiveCommentRecords);
217
+ for (const record of ordered) {
218
+ const recordID = String(safeObject(record).id || "").trim();
219
+ if (!recordID || recordID === selectedID) {
220
+ break;
221
+ }
222
+ if (buildProcessableArchiveLogicalKey(record) === selectedLogicalKey) {
223
+ return record;
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+
229
+ export function dedupeProcessableArchiveComments(records) {
230
+ const deduped = [];
231
+ const seenLogicalKeys = new Set();
232
+ for (const record of ensureArray(records)) {
233
+ const logicalKey = buildProcessableArchiveLogicalKey(record);
234
+ if (logicalKey) {
235
+ if (seenLogicalKeys.has(logicalKey)) {
236
+ continue;
237
+ }
238
+ seenLogicalKeys.add(logicalKey);
239
+ }
240
+ deduped.push(record);
241
+ }
242
+ return deduped;
243
+ }
244
+
195
245
  function isArchiveRecordAfterState(record, routeState) {
196
246
  const state = safeObject(routeState);
197
247
  const lastCommentID = String(state.last_processed_comment_id || "").trim();
198
248
  const lastCreatedAt = String(state.last_processed_created_at || "").trim();
249
+ const createdAt = firstNonEmptyString([record.createdAt, record.updatedAt]);
250
+ if (lastCreatedAt && createdAt) {
251
+ if (createdAt > lastCreatedAt) return true;
252
+ if (createdAt < lastCreatedAt) return false;
253
+ return lastCommentID ? String(record.id || "").trim() !== lastCommentID : false;
254
+ }
199
255
  if (lastCommentID) {
200
256
  return String(record.id || "").trim() !== lastCommentID;
201
257
  }
202
258
  if (!lastCreatedAt) return true;
203
- const createdAt = firstNonEmptyString([record.createdAt, record.updatedAt]);
204
259
  if (!createdAt) return true;
205
260
  return createdAt > lastCreatedAt;
206
261
  }
@@ -244,7 +299,9 @@ export function selectPendingArchiveComments(records, routeState, mode, normaliz
244
299
  .map((record) => normalizeArchiveCommentRecord(record))
245
300
  .filter((record) => record.id && record.parsedArchive)
246
301
  .sort(compareArchiveCommentRecords);
247
- const processable = ordered.filter((record) => isRunnerProcessableArchiveKind(record.parsedArchive.kind));
302
+ const processable = dedupeProcessableArchiveComments(
303
+ ordered.filter((record) => isRunnerProcessableArchiveKind(record.parsedArchive.kind)),
304
+ );
248
305
  const latest = processable.length ? processable[processable.length - 1] : null;
249
306
  const state = safeObject(routeState);
250
307
  const activeCommentID = String(state.active_comment_id || "").trim();
@@ -282,6 +339,19 @@ export function selectPendingArchiveComments(records, routeState, mode, normaliz
282
339
  pending: processable.slice(activeIndex + 1),
283
340
  }, rawOptions);
284
341
  }
342
+ const activeRaw = ordered.find((record) => record.id === activeCommentID);
343
+ const activeLogicalKey = buildProcessableArchiveLogicalKey(activeRaw);
344
+ if (activeLogicalKey) {
345
+ const activeLogicalIndex = processable.findIndex((record) => buildProcessableArchiveLogicalKey(record) === activeLogicalKey);
346
+ if (activeLogicalIndex >= 0) {
347
+ return applyPendingAgeSelection({
348
+ ordered,
349
+ latest,
350
+ shouldPrime: false,
351
+ pending: processable.slice(activeLogicalIndex + 1),
352
+ }, rawOptions);
353
+ }
354
+ }
285
355
  }
286
356
 
287
357
  const lastCommentID = String(state.last_processed_comment_id || "").trim();
@@ -295,6 +365,19 @@ export function selectPendingArchiveComments(records, routeState, mode, normaliz
295
365
  pending: processable.slice(seenIndex + 1),
296
366
  }, rawOptions);
297
367
  }
368
+ const lastRaw = ordered.find((record) => record.id === lastCommentID);
369
+ const lastLogicalKey = buildProcessableArchiveLogicalKey(lastRaw);
370
+ if (lastLogicalKey) {
371
+ const seenLogicalIndex = processable.findIndex((record) => buildProcessableArchiveLogicalKey(record) === lastLogicalKey);
372
+ if (seenLogicalIndex >= 0) {
373
+ return applyPendingAgeSelection({
374
+ ordered,
375
+ latest,
376
+ shouldPrime: false,
377
+ pending: processable.slice(seenLogicalIndex + 1),
378
+ }, rawOptions);
379
+ }
380
+ }
298
381
  }
299
382
 
300
383
  return applyPendingAgeSelection({
@@ -4,9 +4,10 @@ import {
4
4
  buildRunnerContextWindow,
5
5
  buildRunnerRouteStateFromComment,
6
6
  compareArchiveCommentRecords,
7
+ dedupeProcessableArchiveComments,
7
8
  isInboundArchiveKind,
8
- selectPendingArchiveComments,
9
- } from "./runner-helpers.mjs";
9
+ selectPendingArchiveComments,
10
+ } from "./runner-helpers.mjs";
10
11
  import {
11
12
  buildRunnerInputPayload,
12
13
  evaluateTelegramRunnerTrigger,
@@ -3538,31 +3539,40 @@ export function selectRunnerPendingWork({
3538
3539
  const normalizeArchiveCommentRecord = requireDependency(deps, "normalizeArchiveCommentRecord");
3539
3540
  const applyPendingAgeSelection = requireDependency(deps, "applyPendingAgeSelection");
3540
3541
  const orderedComments = ensureArray(comments)
3541
- .map((record) => normalizeArchiveCommentRecord(record, parseArchivedChatComment))
3542
- .filter((record) => record.id && record.parsedArchive)
3543
- .sort(compareArchiveCommentRecords);
3544
- const inboundComments = orderedComments.filter((record) => isInboundArchiveKind(record.parsedArchive.kind));
3545
- const importedRecords = ensureArray(importOutcome?.importedCommentIDs)
3546
- .map((commentID) => orderedComments.find((record) => record.id === commentID))
3547
- .filter(Boolean);
3548
- const pending = importedRecords.length > 0
3542
+ .map((record) => normalizeArchiveCommentRecord(record, parseArchivedChatComment))
3543
+ .filter((record) => record.id && record.parsedArchive)
3544
+ .sort(compareArchiveCommentRecords);
3545
+ const inboundComments = orderedComments.filter((record) => isInboundArchiveKind(record.parsedArchive.kind));
3546
+ const importedRecords = dedupeProcessableArchiveComments(
3547
+ ensureArray(importOutcome?.importedCommentIDs)
3548
+ .map((commentID) => orderedComments.find((record) => record.id === commentID))
3549
+ .filter(Boolean),
3550
+ );
3551
+ const hasCursor = Boolean(
3552
+ String(safeObject(refreshedState).active_comment_id || "").trim()
3553
+ || String(safeObject(refreshedState).active_comment_created_at || "").trim()
3554
+ || String(safeObject(refreshedState).last_processed_comment_id || "").trim()
3555
+ || String(safeObject(refreshedState).last_processed_created_at || "").trim(),
3556
+ );
3557
+ const fullPending = selectPendingArchiveComments(
3558
+ comments,
3559
+ refreshedState,
3560
+ mode,
3561
+ (record) => normalizeArchiveCommentRecord(record, parseArchivedChatComment),
3562
+ pendingSelectionOptions,
3563
+ );
3564
+ const pending = importedRecords.length > 0 && !hasCursor
3549
3565
  ? applyPendingAgeSelection({
3550
3566
  ordered: orderedComments,
3551
3567
  latest: inboundComments.length ? inboundComments[inboundComments.length - 1] : null,
3552
3568
  shouldPrime: false,
3553
3569
  pending: importedRecords,
3554
3570
  }, pendingSelectionOptions)
3555
- : selectPendingArchiveComments(
3556
- comments,
3557
- refreshedState,
3558
- mode,
3559
- (record) => normalizeArchiveCommentRecord(record, parseArchivedChatComment),
3560
- pendingSelectionOptions,
3561
- );
3562
- return {
3563
- orderedComments,
3564
- inboundComments,
3565
- importedRecords,
3571
+ : fullPending;
3572
+ return {
3573
+ orderedComments,
3574
+ inboundComments,
3575
+ importedRecords,
3566
3576
  pending,
3567
3577
  };
3568
3578
  }
@@ -198,6 +198,57 @@ function mergeRunnerSharedInboxUpdates(existingUpdates, incomingUpdates) {
198
198
  .slice(-500);
199
199
  }
200
200
 
201
+ const RUNNER_INBOUND_ARCHIVE_RESERVATION_TTL_MS = 10 * 60 * 1000;
202
+ const runnerInboundArchiveReservations = new Map();
203
+
204
+ function buildRunnerInboundArchiveReservationKey(threadID, chatID, messageID) {
205
+ const normalizedThreadID = String(threadID || "").trim();
206
+ const normalizedChatID = String(chatID || "").trim();
207
+ const normalizedMessageID = intFromRawAllowZero(messageID, 0);
208
+ if (!normalizedThreadID || !normalizedChatID || !(normalizedMessageID > 0)) {
209
+ return "";
210
+ }
211
+ return `${normalizedThreadID}::${normalizedChatID}:${normalizedMessageID}`;
212
+ }
213
+
214
+ function cleanupRunnerInboundArchiveReservations(nowMs = Date.now()) {
215
+ for (const [key, expiresAtMs] of runnerInboundArchiveReservations.entries()) {
216
+ if (!(Number(expiresAtMs) > nowMs)) {
217
+ runnerInboundArchiveReservations.delete(key);
218
+ }
219
+ }
220
+ }
221
+
222
+ function reserveRunnerInboundArchiveMessage(threadID, chatID, messageID) {
223
+ const reservationKey = buildRunnerInboundArchiveReservationKey(threadID, chatID, messageID);
224
+ if (!reservationKey) {
225
+ return {
226
+ ok: false,
227
+ reservationKey: "",
228
+ };
229
+ }
230
+ cleanupRunnerInboundArchiveReservations();
231
+ if (runnerInboundArchiveReservations.has(reservationKey)) {
232
+ return {
233
+ ok: false,
234
+ reservationKey,
235
+ };
236
+ }
237
+ runnerInboundArchiveReservations.set(reservationKey, Date.now() + RUNNER_INBOUND_ARCHIVE_RESERVATION_TTL_MS);
238
+ return {
239
+ ok: true,
240
+ reservationKey,
241
+ };
242
+ }
243
+
244
+ function releaseRunnerInboundArchiveMessageReservation(reservationKey) {
245
+ const normalizedKey = String(reservationKey || "").trim();
246
+ if (!normalizedKey) {
247
+ return;
248
+ }
249
+ runnerInboundArchiveReservations.delete(normalizedKey);
250
+ }
251
+
201
252
  export async function maybeSendRunnerChatAction({
202
253
  provider,
203
254
  bot,
@@ -346,6 +397,9 @@ export async function archiveLocalTelegramMessagesForRoute({
346
397
  const getTelegramUpdates = requireDependency(deps, "getTelegramUpdates");
347
398
  const normalizeLocalTelegramUpdate = requireDependency(deps, "normalizeLocalTelegramUpdate");
348
399
  const listThreadComments = requireDependency(deps, "listThreadComments");
400
+ const listThreadCommentsTail = typeof deps?.listThreadCommentsTail === "function"
401
+ ? deps.listThreadCommentsTail
402
+ : listThreadComments;
349
403
  const normalizeArchiveCommentRecord = requireDependency(deps, "normalizeArchiveCommentRecord");
350
404
  const isInboundArchiveKind = requireDependency(deps, "isInboundArchiveKind");
351
405
  const buildArchivedInboundMessageKey = requireDependency(deps, "buildArchivedInboundMessageKey");
@@ -417,7 +471,7 @@ export async function archiveLocalTelegramMessagesForRoute({
417
471
  let handledUpdateID = lastUpdateID;
418
472
  const mergedSharedUpdates = mergeRunnerSharedInboxUpdates(sharedInbox.updates, updates);
419
473
 
420
- const persistPollingProgress = () => {
474
+ const persistPollingProgress = (remainingSharedUpdates = []) => {
421
475
  if (sharedInboxKey && saveBotRunnerState) {
422
476
  saveBotRunnerState({
423
477
  sharedInboxes: {
@@ -425,7 +479,7 @@ export async function archiveLocalTelegramMessagesForRoute({
425
479
  [sharedInboxKey]: serializeRunnerSharedInbox({
426
480
  lastProviderUpdateID: handledUpdateID,
427
481
  updatedAt: new Date().toISOString(),
428
- updates: mergedSharedUpdates,
482
+ updates: remainingSharedUpdates,
429
483
  }),
430
484
  },
431
485
  });
@@ -447,12 +501,13 @@ export async function archiveLocalTelegramMessagesForRoute({
447
501
  };
448
502
  }
449
503
 
450
- const existingComments = await listThreadComments({
504
+ const existingComments = await listThreadCommentsTail({
451
505
  siteBaseURL: runtime.baseURL,
452
506
  threadID: archiveThread.threadID,
453
507
  token: runtime.token,
454
508
  timeoutSeconds: runtime.timeoutSeconds,
455
- limit: 200,
509
+ tailLimit: 500,
510
+ scanLimit: 5000,
456
511
  actorUserID: runtime.actor.user_id,
457
512
  });
458
513
  const existingKeys = new Set(
@@ -466,10 +521,13 @@ export async function archiveLocalTelegramMessagesForRoute({
466
521
  );
467
522
 
468
523
  const importedCommentIDs = [];
469
- for (const update of mergedSharedUpdates) {
524
+ const retainedSharedUpdates = [];
525
+ for (let index = 0; index < mergedSharedUpdates.length; index += 1) {
526
+ const update = mergedSharedUpdates[index];
470
527
  const updateID = intFromRawAllowZero(update.updateID, 0);
471
528
  if (String(update.chatID || "").trim() !== String(destination.chatID || "").trim()) {
472
529
  handledUpdateID = Math.max(handledUpdateID, updateID);
530
+ retainedSharedUpdates.push(update);
473
531
  continue;
474
532
  }
475
533
  const allowManagedBotImport = shouldImportManagedBotMessageForRoute({
@@ -491,6 +549,13 @@ export async function archiveLocalTelegramMessagesForRoute({
491
549
  handledUpdateID = Math.max(handledUpdateID, updateID);
492
550
  continue;
493
551
  }
552
+ const reservation = boolFromRaw(archivePolicy.dedupeInbound, true)
553
+ ? reserveRunnerInboundArchiveMessage(archiveThread?.threadID, update.chatID, update.messageID)
554
+ : { ok: true, reservationKey: "" };
555
+ if (!reservation.ok) {
556
+ handledUpdateID = Math.max(handledUpdateID, updateID);
557
+ continue;
558
+ }
494
559
  let createdComment;
495
560
  try {
496
561
  createdComment = await createThreadComment({
@@ -502,7 +567,10 @@ export async function archiveLocalTelegramMessagesForRoute({
502
567
  body: formatTelegramInboundArchiveComment(update),
503
568
  });
504
569
  } catch (err) {
505
- persistPollingProgress();
570
+ releaseRunnerInboundArchiveMessageReservation(reservation.reservationKey);
571
+ persistPollingProgress(
572
+ mergeRunnerSharedInboxUpdates(retainedSharedUpdates, mergedSharedUpdates.slice(index)),
573
+ );
506
574
  throw err;
507
575
  }
508
576
  if (String(createdComment.id || "").trim()) {
@@ -513,7 +581,7 @@ export async function archiveLocalTelegramMessagesForRoute({
513
581
  }
514
582
  handledUpdateID = Math.max(handledUpdateID, updateID);
515
583
  }
516
- persistPollingProgress();
584
+ persistPollingProgress(retainedSharedUpdates);
517
585
 
518
586
  return {
519
587
  importedCommentIDs,
@@ -50,6 +50,7 @@ export async function runSelftestRunnerScenarios(push, deps) {
50
50
  const normalizeRunnerTriggerPolicy = requireDependency(deps, "normalizeRunnerTriggerPolicy");
51
51
  const evaluateTelegramRunnerTrigger = requireDependency(deps, "evaluateTelegramRunnerTrigger");
52
52
  const selectPendingArchiveComments = requireDependency(deps, "selectPendingArchiveComments");
53
+ const selectRunnerPendingWork = requireDependency(deps, "selectRunnerPendingWork");
53
54
  const processRunnerSelectedRecord = requireDependency(deps, "processRunnerSelectedRecord");
54
55
  const resolveRunnerStartupLoopAdjudication = requireDependency(deps, "resolveRunnerStartupLoopAdjudication");
55
56
  const runRunnerAIExecution = requireDependency(deps, "runRunnerAIExecution");
@@ -348,6 +349,86 @@ export async function runSelftestRunnerScenarios(push, deps) {
348
349
  );
349
350
  }
350
351
 
352
+ try {
353
+ const pendingSelection = selectPendingArchiveComments(
354
+ [
355
+ {
356
+ id: "dup-comment-1",
357
+ createdAt: "2026-03-18T00:00:01.000Z",
358
+ updatedAt: "2026-03-18T00:00:01.000Z",
359
+ parsedArchive: { kind: "telegram_message", chatID: "-1001", messageID: 353, body: "@RyoAI_bot first copy" },
360
+ },
361
+ {
362
+ id: "dup-comment-2",
363
+ createdAt: "2026-03-18T00:00:02.000Z",
364
+ updatedAt: "2026-03-18T00:00:02.000Z",
365
+ parsedArchive: { kind: "telegram_message", chatID: "-1001", messageID: 353, body: "@RyoAI_bot duplicate copy" },
366
+ },
367
+ ],
368
+ {
369
+ last_processed_comment_id: "dup-comment-1",
370
+ },
371
+ "start",
372
+ (record) => record,
373
+ );
374
+ push(
375
+ "runner_pending_selection_ignores_duplicate_archived_inbound_message_ids",
376
+ pendingSelection.pending.length === 0,
377
+ `pending=${pendingSelection.pending.map((item) => item.id).join(",") || "(none)"}`,
378
+ );
379
+ } catch (err) {
380
+ push(
381
+ "runner_pending_selection_ignores_duplicate_archived_inbound_message_ids",
382
+ false,
383
+ String(err?.message || err),
384
+ );
385
+ }
386
+
387
+ try {
388
+ const duplicateComments = [
389
+ {
390
+ id: "dup-comment-earliest",
391
+ createdAt: "2026-03-18T00:00:01.000Z",
392
+ updatedAt: "2026-03-18T00:00:01.000Z",
393
+ parsedArchive: { kind: "telegram_message", chatID: "-1001", messageID: 353, body: "@RyoAI_bot first copy" },
394
+ },
395
+ {
396
+ id: "dup-comment-imported",
397
+ createdAt: "2026-03-18T00:00:05.000Z",
398
+ updatedAt: "2026-03-18T00:00:05.000Z",
399
+ parsedArchive: { kind: "telegram_message", chatID: "-1001", messageID: 353, body: "@RyoAI_bot imported duplicate" },
400
+ },
401
+ ];
402
+ const pendingWork = selectRunnerPendingWork({
403
+ comments: duplicateComments,
404
+ importOutcome: {
405
+ importedCommentIDs: ["dup-comment-imported"],
406
+ },
407
+ refreshedState: {
408
+ last_processed_comment_id: "dup-comment-earliest",
409
+ last_processed_created_at: "2026-03-18T00:00:01.000Z",
410
+ },
411
+ mode: "start",
412
+ parseArchivedChatComment: () => null,
413
+ deps: {
414
+ normalizeArchiveCommentRecord: (record) => record,
415
+ applyPendingAgeSelection: (selection) => selection,
416
+ },
417
+ pendingSelectionOptions: {},
418
+ });
419
+ push(
420
+ "runner_pending_selection_does_not_requeue_imported_duplicate_after_cursor",
421
+ pendingWork.pending.pending.length === 0,
422
+ `pending=${pendingWork.pending.pending.map((item) => item.id).join(",") || "(none)"}`,
423
+ );
424
+ } catch (err) {
425
+ push(
426
+ "runner_pending_selection_does_not_requeue_imported_duplicate_after_cursor",
427
+ false,
428
+ String(err?.message || err),
429
+ );
430
+ }
431
+
351
432
  try {
352
433
  const selected = selectProjectChatDestination(
353
434
  [
@@ -106,17 +106,22 @@ async function startLocalTelegramRunnerSelftestServer({
106
106
  return;
107
107
  }
108
108
 
109
- const commentsMatch = pathname.match(/^\/api\/v1\/threads\/([^/]+)\/comments$/);
110
- if (commentsMatch) {
111
- const requestedThreadID = decodeURIComponent(String(commentsMatch[1] || "").trim());
112
- if (requestedThreadID !== threadID) {
113
- writeJSON(res, 404, { error: "thread not found" });
114
- return;
115
- }
116
- if (req.method === "GET") {
117
- writeJSON(res, 200, state.comments);
118
- return;
119
- }
109
+ const commentsMatch = pathname.match(/^\/api\/v1\/threads\/([^/]+)\/comments$/);
110
+ if (commentsMatch) {
111
+ const requestedThreadID = decodeURIComponent(String(commentsMatch[1] || "").trim());
112
+ if (requestedThreadID !== threadID) {
113
+ writeJSON(res, 404, { error: "thread not found" });
114
+ return;
115
+ }
116
+ if (req.method === "GET") {
117
+ const limit = Math.max(0, intFromRawAllowZero(requestURL.searchParams.get("limit"), state.comments.length || 0));
118
+ const offset = Math.max(0, intFromRawAllowZero(requestURL.searchParams.get("offset"), 0));
119
+ const paged = limit > 0
120
+ ? state.comments.slice(offset, offset + limit)
121
+ : state.comments.slice(offset);
122
+ writeJSON(res, 200, paged);
123
+ return;
124
+ }
120
125
  if (req.method === "POST") {
121
126
  const payload = await readJSONBody(req);
122
127
  const created = {
@@ -609,6 +614,260 @@ export async function runSelftestTelegramE2E(push, deps) {
609
614
  `comments=${sharedInboxBodies.length} bodies=${sharedInboxBodies.join(" || ")}`,
610
615
  );
611
616
 
617
+ telegramE2EServer.state.comments = [];
618
+ telegramE2EServer.state.updates = [
619
+ {
620
+ update_id: 330,
621
+ message: {
622
+ message_id: 79,
623
+ date: Math.floor(Date.now() / 1000),
624
+ chat: {
625
+ id: Number(e2eDestination.chat_id),
626
+ type: "supergroup",
627
+ title: e2eDestination.label,
628
+ },
629
+ from: {
630
+ id: 5001,
631
+ is_bot: false,
632
+ first_name: "Operator",
633
+ username: "operator_user",
634
+ },
635
+ text: `shared inbox prune @${String(e2eBot.name || "monitor_bot").replace(/^@+/, "")}`,
636
+ },
637
+ },
638
+ ];
639
+ const pruneRoute = {
640
+ ...e2eRoute,
641
+ name: "selftest-runner-e2e-shared-inbox-prune",
642
+ };
643
+ const pruneRouteKey = runnerRouteKey(normalizeRunnerRoute(pruneRoute));
644
+ await archiveLocalTelegramMessagesForRoute({
645
+ routeKey: pruneRouteKey,
646
+ route: pruneRoute,
647
+ routeState: {},
648
+ runtime: {
649
+ baseURL: telegramE2EServer.baseURL,
650
+ timeoutSeconds: 10,
651
+ token: e2eToken,
652
+ actor: {
653
+ user_id: e2eActorUserID,
654
+ },
655
+ },
656
+ bot: e2eBot,
657
+ destination: {
658
+ chatID: e2eDestination.chat_id,
659
+ },
660
+ archiveThread: {
661
+ threadID: e2eThreadID,
662
+ },
663
+ deps: buildRunnerRuntimeDeps(),
664
+ });
665
+ telegramE2EServer.state.updates = [];
666
+ const pruneRouteState = safeObject(loadBotRunnerState().routes[pruneRouteKey]);
667
+ await archiveLocalTelegramMessagesForRoute({
668
+ routeKey: pruneRouteKey,
669
+ route: pruneRoute,
670
+ routeState: pruneRouteState,
671
+ runtime: {
672
+ baseURL: telegramE2EServer.baseURL,
673
+ timeoutSeconds: 10,
674
+ token: e2eToken,
675
+ actor: {
676
+ user_id: e2eActorUserID,
677
+ },
678
+ },
679
+ bot: e2eBot,
680
+ destination: {
681
+ chatID: e2eDestination.chat_id,
682
+ },
683
+ archiveThread: {
684
+ threadID: e2eThreadID,
685
+ },
686
+ deps: buildRunnerRuntimeDeps(),
687
+ });
688
+ const prunedSharedBodies = telegramE2EServer.state.comments.map((item) => String(item.body || ""));
689
+ push(
690
+ "telegram_shared_inbox_prunes_handled_updates_after_import",
691
+ prunedSharedBodies.filter((item) => item.includes("message_id: 79")).length === 1,
692
+ `count=${prunedSharedBodies.filter((item) => item.includes("message_id: 79")).length} comments=${prunedSharedBodies.length}`,
693
+ );
694
+
695
+ telegramE2EServer.state.comments = Array.from({ length: 240 }, (_, index) => ({
696
+ id: `historical-comment-${index + 1}`,
697
+ body: [
698
+ "[Telegram message]",
699
+ `chat_id: ${e2eDestination.chat_id}`,
700
+ "chat_type: supergroup",
701
+ `message_id: ${1000 + index}`,
702
+ `occurred_at: ${new Date(Date.now() - ((241 - index) * 1000)).toISOString()}`,
703
+ `sender_id: ${5000 + index}`,
704
+ `sender: Historical User ${index + 1}`,
705
+ "sender_is_bot: false",
706
+ "",
707
+ `historical message ${index + 1}`,
708
+ ].join("\n"),
709
+ created_at: new Date(Date.now() - ((241 - index) * 1000)).toISOString(),
710
+ updated_at: new Date(Date.now() - ((241 - index) * 1000)).toISOString(),
711
+ author_user_id: e2eActorUserID,
712
+ }));
713
+ telegramE2EServer.state.comments.push({
714
+ id: "historical-tail-duplicate",
715
+ body: [
716
+ "[Telegram message]",
717
+ `chat_id: ${e2eDestination.chat_id}`,
718
+ "chat_type: supergroup",
719
+ "message_id: 353",
720
+ `occurred_at: ${new Date().toISOString()}`,
721
+ "sender_id: 5001",
722
+ "sender: Operator",
723
+ "sender_is_bot: false",
724
+ "telegram_username: @operator_user",
725
+ "",
726
+ "tail duplicate should dedupe",
727
+ ].join("\n"),
728
+ created_at: new Date().toISOString(),
729
+ updated_at: new Date().toISOString(),
730
+ author_user_id: e2eActorUserID,
731
+ });
732
+ telegramE2EServer.state.updates = [
733
+ {
734
+ update_id: 351,
735
+ message: {
736
+ message_id: 353,
737
+ date: Math.floor(Date.now() / 1000),
738
+ chat: {
739
+ id: Number(e2eDestination.chat_id),
740
+ type: "supergroup",
741
+ title: e2eDestination.label,
742
+ },
743
+ from: {
744
+ id: 5001,
745
+ is_bot: false,
746
+ first_name: "Operator",
747
+ username: "operator_user",
748
+ },
749
+ text: "tail duplicate should dedupe",
750
+ },
751
+ },
752
+ ];
753
+ await archiveLocalTelegramMessagesForRoute({
754
+ routeKey: pruneRouteKey,
755
+ route: pruneRoute,
756
+ routeState: safeObject(loadBotRunnerState().routes[pruneRouteKey]),
757
+ runtime: {
758
+ baseURL: telegramE2EServer.baseURL,
759
+ timeoutSeconds: 10,
760
+ token: e2eToken,
761
+ actor: {
762
+ user_id: e2eActorUserID,
763
+ },
764
+ },
765
+ bot: e2eBot,
766
+ destination: {
767
+ chatID: e2eDestination.chat_id,
768
+ },
769
+ archiveThread: {
770
+ threadID: e2eThreadID,
771
+ },
772
+ deps: buildRunnerRuntimeDeps(),
773
+ });
774
+ push(
775
+ "telegram_inbound_archive_dedupe_checks_recent_thread_tail",
776
+ telegramE2EServer.state.comments.filter((item) => String(item.body || "").includes("message_id: 353")).length === 1,
777
+ `count=${telegramE2EServer.state.comments.filter((item) => String(item.body || "").includes("message_id: 353")).length}`,
778
+ );
779
+
780
+ telegramE2EServer.state.comments = [];
781
+ telegramE2EServer.state.updates = [
782
+ {
783
+ update_id: 350,
784
+ message: {
785
+ message_id: 91,
786
+ date: Math.floor(Date.now() / 1000),
787
+ chat: {
788
+ id: Number(e2eDestination.chat_id),
789
+ type: "supergroup",
790
+ title: e2eDestination.label,
791
+ },
792
+ from: {
793
+ id: 5001,
794
+ is_bot: false,
795
+ first_name: "Operator",
796
+ username: "operator_user",
797
+ },
798
+ text: "same inbound message for concurrent routes",
799
+ },
800
+ },
801
+ ];
802
+ const concurrentRouteA = {
803
+ ...e2eRoute,
804
+ name: "selftest-runner-e2e-concurrent-archive-a",
805
+ server_bot_name: "ConcurrentBotA",
806
+ };
807
+ const concurrentRouteB = {
808
+ ...e2eRoute,
809
+ name: "selftest-runner-e2e-concurrent-archive-b",
810
+ server_bot_name: "ConcurrentBotB",
811
+ };
812
+ await Promise.all([
813
+ archiveLocalTelegramMessagesForRoute({
814
+ routeKey: runnerRouteKey(normalizeRunnerRoute(concurrentRouteA)),
815
+ route: concurrentRouteA,
816
+ routeState: {},
817
+ runtime: {
818
+ baseURL: telegramE2EServer.baseURL,
819
+ timeoutSeconds: 10,
820
+ token: e2eToken,
821
+ actor: {
822
+ user_id: e2eActorUserID,
823
+ },
824
+ },
825
+ bot: {
826
+ id: "concurrent-bot-a",
827
+ name: "ConcurrentBotA",
828
+ role: "monitor",
829
+ },
830
+ destination: {
831
+ chatID: e2eDestination.chat_id,
832
+ },
833
+ archiveThread: {
834
+ threadID: e2eThreadID,
835
+ },
836
+ deps: buildRunnerRuntimeDeps(),
837
+ }),
838
+ archiveLocalTelegramMessagesForRoute({
839
+ routeKey: runnerRouteKey(normalizeRunnerRoute(concurrentRouteB)),
840
+ route: concurrentRouteB,
841
+ routeState: {},
842
+ runtime: {
843
+ baseURL: telegramE2EServer.baseURL,
844
+ timeoutSeconds: 10,
845
+ token: e2eToken,
846
+ actor: {
847
+ user_id: e2eActorUserID,
848
+ },
849
+ },
850
+ bot: {
851
+ id: "concurrent-bot-b",
852
+ name: "ConcurrentBotB",
853
+ role: "monitor",
854
+ },
855
+ destination: {
856
+ chatID: e2eDestination.chat_id,
857
+ },
858
+ archiveThread: {
859
+ threadID: e2eThreadID,
860
+ },
861
+ deps: buildRunnerRuntimeDeps(),
862
+ }),
863
+ ]);
864
+ const concurrentBodies = telegramE2EServer.state.comments.map((item) => String(item.body || ""));
865
+ push(
866
+ "telegram_concurrent_routes_archive_same_inbound_message_only_once",
867
+ concurrentBodies.filter((item) => item.includes("message_id: 91")).length === 1,
868
+ `count=${concurrentBodies.filter((item) => item.includes("message_id: 91")).length} comments=${concurrentBodies.length}`,
869
+ );
870
+
612
871
  telegramE2EServer.state.comments = [];
613
872
  telegramE2EServer.state.updates = [
614
873
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metheus-governance-mcp-cli",
3
- "version": "0.2.184",
3
+ "version": "0.2.185",
4
4
  "description": "Metheus Governance MCP CLI (setup + stdio proxy)",
5
5
  "type": "module",
6
6
  "files": [