metheus-governance-mcp-cli 0.2.271 → 0.2.272

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
@@ -2669,39 +2669,47 @@ function saveBotRunnerState(nextState) {
2669
2669
  });
2670
2670
  }
2671
2671
 
2672
- function normalizeBotRunnerExcludedComments(rawExcluded, nowMs = Date.now()) {
2673
- const normalized = {};
2674
- for (const [commentIDRaw, entryRaw] of Object.entries(safeObject(rawExcluded))) {
2675
- const commentID = String(commentIDRaw || "").trim();
2676
- if (!commentID) continue;
2672
+ function normalizeBotRunnerExcludedComments(rawExcluded, nowMs = Date.now()) {
2673
+ const normalized = {};
2674
+ for (const [commentIDRaw, entryRaw] of Object.entries(safeObject(rawExcluded))) {
2675
+ const commentID = String(commentIDRaw || "").trim();
2676
+ if (!commentID) continue;
2677
2677
  const entry = safeObject(entryRaw);
2678
2678
  const excludedAt = firstNonEmptyString([entry.excluded_at, entry.excludedAt, entry.updated_at, entry.updatedAt]);
2679
2679
  const excludedAtMs = Date.parse(excludedAt);
2680
2680
  if (Number.isFinite(excludedAtMs) && nowMs - excludedAtMs > BOT_RUNNER_EXCLUDED_COMMENT_KEEP_MS) {
2681
2681
  continue;
2682
2682
  }
2683
- normalized[commentID] = {
2684
- comment_id: commentID,
2685
- project_id: String(entry.project_id || entry.projectID || "").trim(),
2686
- provider: String(entry.provider || "").trim(),
2687
- request_key: String(entry.request_key || entry.requestKey || "").trim(),
2688
- route_key: String(entry.route_key || entry.routeKey || "").trim(),
2689
- excluded_at: excludedAt || new Date(nowMs).toISOString(),
2690
- reason_code: String(entry.reason_code || entry.reasonCode || entry.reason || "").trim(),
2691
- decision: String(entry.decision || "").trim().toLowerCase(),
2692
- action: String(entry.action || "").trim().toLowerCase(),
2693
- conversation_id: String(entry.conversation_id || entry.conversationId || "").trim(),
2694
- source_message_id: intFromRawAllowZero(entry.source_message_id || entry.sourceMessageID, 0) || undefined,
2695
- context_excluded: entry.context_excluded !== false,
2696
- closed_reason: String(entry.closed_reason || entry.closedReason || "").trim(),
2697
- };
2698
- }
2699
- return normalized;
2683
+ normalized[commentID] = {
2684
+ comment_id: commentID,
2685
+ project_id: String(entry.project_id || entry.projectID || "").trim(),
2686
+ provider: String(entry.provider || "").trim(),
2687
+ request_key: String(entry.request_key || entry.requestKey || "").trim(),
2688
+ route_key: String(entry.route_key || entry.routeKey || "").trim(),
2689
+ excluded_at: excludedAt || new Date(nowMs).toISOString(),
2690
+ reason_code: String(entry.reason_code || entry.reasonCode || entry.reason || "").trim(),
2691
+ decision: String(entry.decision || "").trim().toLowerCase(),
2692
+ action: String(entry.action || "").trim().toLowerCase(),
2693
+ conversation_id: String(entry.conversation_id || entry.conversationId || "").trim(),
2694
+ source_message_id: intFromRawAllowZero(entry.source_message_id || entry.sourceMessageID, 0) || undefined,
2695
+ comment_kind: String(entry.comment_kind || entry.commentKind || "").trim().toLowerCase(),
2696
+ request_status: normalizeRunnerRequestStatus(entry.request_status || entry.requestStatus),
2697
+ source_occurred_at: String(entry.source_occurred_at || entry.sourceOccurredAt || "").trim(),
2698
+ stale_after_at: String(entry.stale_after_at || entry.staleAfterAt || "").trim(),
2699
+ selection_state: normalizeRunnerCommentSelectionState(
2700
+ entry.selection_state || entry.selectionState,
2701
+ "superseded",
2702
+ ),
2703
+ context_excluded: entry.context_excluded !== false,
2704
+ closed_reason: String(entry.closed_reason || entry.closedReason || "").trim(),
2705
+ };
2706
+ }
2707
+ return normalized;
2700
2708
  }
2701
2709
 
2702
- function normalizeRunnerRequestStatus(rawStatus) {
2703
- const status = String(rawStatus || "").trim().toLowerCase();
2704
- return [
2710
+ function normalizeRunnerRequestStatus(rawStatus) {
2711
+ const status = String(rawStatus || "").trim().toLowerCase();
2712
+ return [
2705
2713
  "planned",
2706
2714
  "claimed",
2707
2715
  "running",
@@ -2710,9 +2718,54 @@ function normalizeRunnerRequestStatus(rawStatus) {
2710
2718
  "expired",
2711
2719
  "loop_closed",
2712
2720
  ].includes(status)
2713
- ? status
2714
- : "planned";
2715
- }
2721
+ ? status
2722
+ : "planned";
2723
+ }
2724
+
2725
+ function normalizeRunnerCommentSelectionState(rawState, fallback = "pending") {
2726
+ const normalizedFallback = String(fallback || "").trim().toLowerCase() || "pending";
2727
+ const normalized = String(rawState || "").trim().toLowerCase();
2728
+ return [
2729
+ "pending",
2730
+ "consumed",
2731
+ "closed",
2732
+ "superseded",
2733
+ "stale",
2734
+ ].includes(normalized)
2735
+ ? normalized
2736
+ : normalizedFallback;
2737
+ }
2738
+
2739
+ function resolveRunnerCommentStateSourceOccurredAt(recordRaw) {
2740
+ const record = safeObject(recordRaw);
2741
+ const parsed = safeObject(record.parsedArchive);
2742
+ return firstNonEmptyString([
2743
+ record.source_occurred_at,
2744
+ record.sourceOccurredAt,
2745
+ parsed.occurredAt,
2746
+ parsed.occurred_at,
2747
+ record.created_at,
2748
+ record.createdAt,
2749
+ record.updated_at,
2750
+ record.updatedAt,
2751
+ ]);
2752
+ }
2753
+
2754
+ function resolveRunnerCommentStateStaleAfterAt(recordRaw, maxAgeMs = BOT_RUNNER_PENDING_COMMENT_MAX_AGE_MS) {
2755
+ const explicit = firstNonEmptyString([
2756
+ safeObject(recordRaw).stale_after_at,
2757
+ safeObject(recordRaw).staleAfterAt,
2758
+ ]);
2759
+ if (explicit) {
2760
+ return explicit;
2761
+ }
2762
+ const sourceOccurredAt = resolveRunnerCommentStateSourceOccurredAt(recordRaw);
2763
+ const sourceOccurredAtMs = Date.parse(sourceOccurredAt);
2764
+ if (!Number.isFinite(sourceOccurredAtMs) || !(maxAgeMs > 0)) {
2765
+ return "";
2766
+ }
2767
+ return new Date(sourceOccurredAtMs + maxAgeMs).toISOString();
2768
+ }
2716
2769
 
2717
2770
  function isFinalRunnerRequestStatus(rawStatus) {
2718
2771
  const status = normalizeRunnerRequestStatus(rawStatus);
@@ -3245,9 +3298,9 @@ function normalizeBotRunnerRequests(rawRequests, nowMs = Date.now()) {
3245
3298
  return normalized;
3246
3299
  }
3247
3300
 
3248
- function normalizeBotRunnerConsumedComments(rawConsumed, nowMs = Date.now()) {
3249
- const normalized = {};
3250
- for (const [commentIDRaw, entryRaw] of Object.entries(safeObject(rawConsumed))) {
3301
+ function normalizeBotRunnerConsumedComments(rawConsumed, nowMs = Date.now()) {
3302
+ const normalized = {};
3303
+ for (const [commentIDRaw, entryRaw] of Object.entries(safeObject(rawConsumed))) {
3251
3304
  const entry = safeObject(entryRaw);
3252
3305
  const commentID = String(entry.comment_id || commentIDRaw || "").trim();
3253
3306
  if (!commentID) continue;
@@ -3257,21 +3310,27 @@ function normalizeBotRunnerConsumedComments(rawConsumed, nowMs = Date.now()) {
3257
3310
  if (Number.isFinite(consumedAtMs) && nowMs - consumedAtMs > BOT_RUNNER_REQUEST_KEEP_MS) {
3258
3311
  continue;
3259
3312
  }
3260
- normalized[ledgerKey] = {
3261
- ledger_key: ledgerKey,
3262
- comment_id: commentID,
3263
- project_id: String(entry.project_id || entry.projectID || "").trim(),
3264
- provider: String(entry.provider || "").trim(),
3265
- request_key: String(entry.request_key || entry.requestKey || "").trim(),
3266
- consumed_at: consumedAt || new Date(nowMs).toISOString(),
3267
- route_key: String(entry.route_key || entry.routeKey || "").trim(),
3268
- conversation_id: String(entry.conversation_id || entry.conversationId || "").trim(),
3269
- source_message_id: intFromRawAllowZero(entry.source_message_id || entry.sourceMessageID, 0) || undefined,
3270
- comment_kind: String(entry.comment_kind || entry.commentKind || "").trim().toLowerCase(),
3271
- request_status: normalizeRunnerRequestStatus(entry.request_status || entry.requestStatus),
3272
- };
3273
- }
3274
- return normalized;
3313
+ normalized[ledgerKey] = {
3314
+ ledger_key: ledgerKey,
3315
+ comment_id: commentID,
3316
+ project_id: String(entry.project_id || entry.projectID || "").trim(),
3317
+ provider: String(entry.provider || "").trim(),
3318
+ request_key: String(entry.request_key || entry.requestKey || "").trim(),
3319
+ consumed_at: consumedAt || new Date(nowMs).toISOString(),
3320
+ route_key: String(entry.route_key || entry.routeKey || "").trim(),
3321
+ conversation_id: String(entry.conversation_id || entry.conversationId || "").trim(),
3322
+ source_message_id: intFromRawAllowZero(entry.source_message_id || entry.sourceMessageID, 0) || undefined,
3323
+ source_occurred_at: String(entry.source_occurred_at || entry.sourceOccurredAt || "").trim(),
3324
+ stale_after_at: String(entry.stale_after_at || entry.staleAfterAt || "").trim(),
3325
+ selection_state: normalizeRunnerCommentSelectionState(
3326
+ entry.selection_state || entry.selectionState,
3327
+ "consumed",
3328
+ ),
3329
+ comment_kind: String(entry.comment_kind || entry.commentKind || "").trim().toLowerCase(),
3330
+ request_status: normalizeRunnerRequestStatus(entry.request_status || entry.requestStatus),
3331
+ };
3332
+ }
3333
+ return normalized;
3275
3334
  }
3276
3335
 
3277
3336
  function buildRunnerConsumedCommentLedgerKey(commentIDRaw, routeKeyRaw = "", commentKindRaw = "") {
@@ -4788,10 +4847,10 @@ function upsertRunnerRequest(state, requestKey, patch = {}) {
4788
4847
  };
4789
4848
  }
4790
4849
 
4791
- function upsertRunnerConsumedComment(state, commentIDRaw, patch = {}) {
4792
- const commentID = String(commentIDRaw || "").trim();
4793
- const currentState = safeObject(state);
4794
- const consumedComments = normalizeBotRunnerConsumedComments(currentState.consumedComments || currentState.consumed_comments);
4850
+ function upsertRunnerConsumedComment(state, commentIDRaw, patch = {}) {
4851
+ const commentID = String(commentIDRaw || "").trim();
4852
+ const currentState = safeObject(state);
4853
+ const consumedComments = normalizeBotRunnerConsumedComments(currentState.consumedComments || currentState.consumed_comments);
4795
4854
  const ledgerKey = buildRunnerConsumedCommentLedgerKey(commentID, patch.route_key, patch.comment_kind);
4796
4855
  const existing = safeObject(consumedComments[ledgerKey]);
4797
4856
  const nextEntry = {
@@ -4799,10 +4858,22 @@ function upsertRunnerConsumedComment(state, commentIDRaw, patch = {}) {
4799
4858
  ...safeObject(patch),
4800
4859
  comment_id: commentID,
4801
4860
  project_id: String(patch.project_id || existing.project_id || "").trim(),
4802
- provider: String(patch.provider || existing.provider || "").trim(),
4803
- consumed_at: new Date().toISOString(),
4804
- request_status: normalizeRunnerRequestStatus(patch.request_status || existing.request_status),
4805
- };
4861
+ provider: String(patch.provider || existing.provider || "").trim(),
4862
+ consumed_at: new Date().toISOString(),
4863
+ source_occurred_at: firstNonEmptyString([
4864
+ patch.source_occurred_at,
4865
+ existing.source_occurred_at,
4866
+ ]),
4867
+ stale_after_at: firstNonEmptyString([
4868
+ patch.stale_after_at,
4869
+ existing.stale_after_at,
4870
+ ]),
4871
+ selection_state: normalizeRunnerCommentSelectionState(
4872
+ patch.selection_state || existing.selection_state,
4873
+ "consumed",
4874
+ ),
4875
+ request_status: normalizeRunnerRequestStatus(patch.request_status || existing.request_status),
4876
+ };
4806
4877
  consumedComments[ledgerKey] = nextEntry;
4807
4878
  return {
4808
4879
  consumedComments,
@@ -5128,16 +5199,19 @@ async function claimRunnerRequestForHumanComment({
5128
5199
  last_source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
5129
5200
  last_source_message_thread_id: intFromRawAllowZero(parsed.messageThreadID, 0) || undefined,
5130
5201
  });
5131
- const { consumedComments: nextConsumedComments } = upsertRunnerConsumedComment(stateForClaim, selectedRecord?.id, {
5132
- project_id: String(normalizedRoute?.projectID || "").trim(),
5133
- provider: String(normalizedRoute?.provider || "").trim(),
5134
- request_key: requestKey,
5135
- route_key: String(routeKey || "").trim(),
5136
- conversation_id: resolvedConversationID,
5137
- source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
5138
- comment_kind: commentKind,
5139
- request_status: "claimed",
5140
- });
5202
+ const { consumedComments: nextConsumedComments } = upsertRunnerConsumedComment(stateForClaim, selectedRecord?.id, {
5203
+ project_id: String(normalizedRoute?.projectID || "").trim(),
5204
+ provider: String(normalizedRoute?.provider || "").trim(),
5205
+ request_key: requestKey,
5206
+ route_key: String(routeKey || "").trim(),
5207
+ conversation_id: resolvedConversationID,
5208
+ source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
5209
+ source_occurred_at: resolveRunnerCommentStateSourceOccurredAt(selectedRecord),
5210
+ stale_after_at: resolveRunnerCommentStateStaleAfterAt(selectedRecord),
5211
+ selection_state: "consumed",
5212
+ comment_kind: commentKind,
5213
+ request_status: "claimed",
5214
+ });
5141
5215
  saveBotRunnerState({
5142
5216
  routes: stateForClaim.routes,
5143
5217
  sharedInboxes: stateForClaim.sharedInboxes || stateForClaim.shared_inboxes,
@@ -6561,17 +6635,20 @@ function markRunnerRequestLifecycle({
6561
6635
  const commentID = String(selectedRecord?.id || "").trim();
6562
6636
  let nextConsumedComments = currentState.consumedComments || currentState.consumed_comments;
6563
6637
  if (commentID) {
6564
- ({ consumedComments: nextConsumedComments } = upsertRunnerConsumedComment(currentState, commentID, {
6565
- project_id: String(normalizedRoute?.projectID || "").trim(),
6566
- provider: String(normalizedRoute?.provider || "").trim(),
6567
- request_key: key,
6568
- route_key: String(routeKey || "").trim(),
6569
- conversation_id: conversationID,
6570
- source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
6571
- comment_kind: String(parsed.kind || "").trim().toLowerCase(),
6572
- request_status: nextStatus,
6573
- }));
6574
- }
6638
+ ({ consumedComments: nextConsumedComments } = upsertRunnerConsumedComment(currentState, commentID, {
6639
+ project_id: String(normalizedRoute?.projectID || "").trim(),
6640
+ provider: String(normalizedRoute?.provider || "").trim(),
6641
+ request_key: key,
6642
+ route_key: String(routeKey || "").trim(),
6643
+ conversation_id: conversationID,
6644
+ source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
6645
+ source_occurred_at: resolveRunnerCommentStateSourceOccurredAt(selectedRecord),
6646
+ stale_after_at: resolveRunnerCommentStateStaleAfterAt(selectedRecord),
6647
+ selection_state: isFinalRunnerRequestStatus(nextStatus) ? "closed" : "consumed",
6648
+ comment_kind: String(parsed.kind || "").trim().toLowerCase(),
6649
+ request_status: nextStatus,
6650
+ }));
6651
+ }
6575
6652
  saveBotRunnerState({
6576
6653
  routes: currentState.routes,
6577
6654
  sharedInboxes: currentState.sharedInboxes || currentState.shared_inboxes,
@@ -6900,16 +6977,22 @@ function mergeServerRunnerRequestLedgerIntoLocalState(currentState, normalizedRo
6900
6977
  const commentState = safeObject(commentStateRaw);
6901
6978
  const commentID = String(commentState.comment_id || commentState.commentID || "").trim();
6902
6979
  if (!commentID) continue;
6903
- const normalizedCommentState = normalizeBotRunnerConsumedComments({ [commentID]: commentState })[commentID];
6904
- if (normalizedCommentState) {
6905
- nextConsumedComments[commentID] = normalizedCommentState;
6906
- }
6907
- if (commentState.context_excluded === true) {
6908
- nextExcludedComments[commentID] = normalizeBotRunnerExcludedComments({
6909
- [commentID]: {
6910
- ...commentState,
6911
- excluded_at: commentState.updated_at || commentState.consumed_at,
6912
- },
6980
+ const normalizedCommentState = normalizeBotRunnerConsumedComments({ [commentID]: commentState })[commentID];
6981
+ if (normalizedCommentState) {
6982
+ nextConsumedComments[commentID] = normalizedCommentState;
6983
+ }
6984
+ const normalizedSelectionState = normalizeRunnerCommentSelectionState(commentState.selection_state, "pending");
6985
+ if (
6986
+ commentState.context_excluded === true
6987
+ || normalizedSelectionState === "stale"
6988
+ || normalizedSelectionState === "superseded"
6989
+ || normalizedSelectionState === "closed"
6990
+ ) {
6991
+ nextExcludedComments[commentID] = normalizeBotRunnerExcludedComments({
6992
+ [commentID]: {
6993
+ ...commentState,
6994
+ excluded_at: commentState.updated_at || commentState.consumed_at,
6995
+ },
6913
6996
  })[commentID];
6914
6997
  }
6915
6998
  }
@@ -6967,24 +7050,27 @@ function buildProjectRunnerRequestCommentStatesForSync(state, normalizedRoute) {
6967
7050
  if (!runnerLedgerEntryMatchesProject(entry, normalizedRoute, requestIndex)) continue;
6968
7051
  const commentID = String(entry.comment_id || "").trim();
6969
7052
  if (!commentID) continue;
6970
- commentStateMap.set(commentID, {
6971
- comment_id: commentID,
6972
- project_id: projectID,
6973
- request_key: String(entry.request_key || "").trim(),
6974
- provider: String(entry.provider || provider).trim(),
7053
+ commentStateMap.set(commentID, {
7054
+ comment_id: commentID,
7055
+ project_id: projectID,
7056
+ request_key: String(entry.request_key || "").trim(),
7057
+ provider: String(entry.provider || provider).trim(),
6975
7058
  route_key: String(entry.route_key || "").trim(),
6976
7059
  conversation_id: String(entry.conversation_id || "").trim(),
6977
7060
  source_message_id: intFromRawAllowZero(entry.source_message_id, 0) || undefined,
6978
7061
  comment_kind: String(entry.comment_kind || "").trim(),
6979
7062
  request_status: String(entry.request_status || "").trim(),
6980
- reason_code: "",
6981
- decision: "",
6982
- action: "",
6983
- context_excluded: false,
6984
- closed_reason: "",
6985
- consumed_at: String(entry.consumed_at || "").trim(),
6986
- });
6987
- }
7063
+ reason_code: "",
7064
+ decision: "",
7065
+ action: "",
7066
+ context_excluded: false,
7067
+ closed_reason: "",
7068
+ source_occurred_at: String(entry.source_occurred_at || "").trim(),
7069
+ stale_after_at: String(entry.stale_after_at || "").trim(),
7070
+ selection_state: normalizeRunnerCommentSelectionState(entry.selection_state, "consumed"),
7071
+ consumed_at: String(entry.consumed_at || "").trim(),
7072
+ });
7073
+ }
6988
7074
 
6989
7075
  for (const entryRaw of Object.values(excludedComments)) {
6990
7076
  const entry = safeObject(entryRaw);
@@ -7004,13 +7090,19 @@ function buildProjectRunnerRequestCommentStatesForSync(state, normalizedRoute) {
7004
7090
  comment_kind: String(entry.comment_kind || existing.comment_kind || "").trim(),
7005
7091
  request_status: String(entry.request_status || existing.request_status || "").trim(),
7006
7092
  reason_code: String(entry.reason_code || "").trim(),
7007
- decision: String(entry.decision || "").trim(),
7008
- action: String(entry.action || "").trim(),
7009
- context_excluded: entry.context_excluded !== false,
7010
- closed_reason: String(entry.closed_reason || existing.closed_reason || "").trim(),
7011
- consumed_at: String(existing.consumed_at || entry.excluded_at || "").trim(),
7012
- });
7013
- }
7093
+ decision: String(entry.decision || "").trim(),
7094
+ action: String(entry.action || "").trim(),
7095
+ context_excluded: entry.context_excluded !== false,
7096
+ closed_reason: String(entry.closed_reason || existing.closed_reason || "").trim(),
7097
+ source_occurred_at: String(entry.source_occurred_at || existing.source_occurred_at || "").trim(),
7098
+ stale_after_at: String(entry.stale_after_at || existing.stale_after_at || "").trim(),
7099
+ selection_state: normalizeRunnerCommentSelectionState(
7100
+ entry.selection_state || existing.selection_state,
7101
+ entry.context_excluded !== false ? "superseded" : "pending",
7102
+ ),
7103
+ consumed_at: String(existing.consumed_at || entry.excluded_at || "").trim(),
7104
+ });
7105
+ }
7014
7106
 
7015
7107
  return Array.from(commentStateMap.values());
7016
7108
  }
@@ -10258,10 +10350,10 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
10258
10350
  }
10259
10351
  return true;
10260
10352
  });
10261
- const pendingWork = selectRunnerPendingWork({
10262
- comments: commentsForPending,
10263
- importOutcome,
10264
- refreshedState,
10353
+ const pendingWork = selectRunnerPendingWork({
10354
+ comments: commentsForPending,
10355
+ importOutcome,
10356
+ refreshedState,
10265
10357
  mode,
10266
10358
  parseArchivedChatComment,
10267
10359
  pendingSelectionOptions: {
@@ -10271,13 +10363,52 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
10271
10363
  applyPendingAgeSelection,
10272
10364
  normalizeArchiveCommentRecord,
10273
10365
  },
10274
- });
10275
- const pending = pendingWork.pending;
10276
- if (pending.staleSkippedLatest) {
10277
- saveRunnerRouteState(routeKey, buildRunnerRouteStateFromComment(pending.staleSkippedLatest, {
10278
- last_reason: `skipped ${ensureArray(pending.staleSkipped).length} stale archive message(s) older than ${Math.floor(BOT_RUNNER_PENDING_COMMENT_MAX_AGE_MS / 60000)} minutes`,
10279
- }));
10280
- refreshedState = safeObject(loadBotRunnerState().routes[routeKey]);
10366
+ });
10367
+ const pending = pendingWork.pending;
10368
+ if (pending.staleSkippedLatest) {
10369
+ let staleStateChanged = false;
10370
+ for (const staleRecordRaw of ensureArray(pending.staleSkipped)) {
10371
+ const staleRecord = safeObject(staleRecordRaw);
10372
+ const commentID = String(staleRecord.id || "").trim();
10373
+ if (!commentID) continue;
10374
+ const parsed = safeObject(staleRecord.parsedArchive);
10375
+ const existingExcludedEntry = safeObject(excludedComments[commentID]);
10376
+ const stalePatch = {
10377
+ project_id: String(normalizedRoute.projectID || "").trim(),
10378
+ provider: String(normalizedRoute.provider || "").trim(),
10379
+ route_key: routeKey,
10380
+ request_key: String(existingExcludedEntry.request_key || "").trim(),
10381
+ conversation_id: String(parsed.conversationID || parsed.conversationId || existingExcludedEntry.conversation_id || "").trim(),
10382
+ source_message_id: intFromRawAllowZero(parsed.messageID || existingExcludedEntry.source_message_id, 0) || undefined,
10383
+ comment_kind: String(parsed.kind || existingExcludedEntry.comment_kind || "").trim().toLowerCase(),
10384
+ request_status: "expired",
10385
+ source_occurred_at: resolveRunnerCommentStateSourceOccurredAt(staleRecord),
10386
+ stale_after_at: resolveRunnerCommentStateStaleAfterAt(staleRecord),
10387
+ selection_state: "stale",
10388
+ reason_code: "stale_archive_message",
10389
+ decision: "skip",
10390
+ action: "stale_skipped",
10391
+ context_excluded: true,
10392
+ closed_reason: "stale_source_message",
10393
+ };
10394
+ const updatedExcludedEntry = markBotRunnerExcludedComment(commentID, stalePatch);
10395
+ if (
10396
+ normalizeRunnerCommentSelectionState(updatedExcludedEntry.selection_state, "superseded") === "stale"
10397
+ && String(updatedExcludedEntry.closed_reason || "").trim() === "stale_source_message"
10398
+ ) {
10399
+ staleStateChanged = true;
10400
+ }
10401
+ }
10402
+ if (staleStateChanged) {
10403
+ await syncRunnerRequestLedgerForProjectToServer({
10404
+ normalizedRoute,
10405
+ runtime,
10406
+ });
10407
+ }
10408
+ saveRunnerRouteState(routeKey, buildRunnerRouteStateFromComment(pending.staleSkippedLatest, {
10409
+ last_reason: `skipped ${ensureArray(pending.staleSkipped).length} stale archive message(s) older than ${Math.floor(BOT_RUNNER_PENDING_COMMENT_MAX_AGE_MS / 60000)} minutes`,
10410
+ }));
10411
+ refreshedState = safeObject(loadBotRunnerState().routes[routeKey]);
10281
10412
  }
10282
10413
  if (pending.shouldPrime && pending.latest) {
10283
10414
  saveRunnerRouteState(routeKey, buildRunnerRouteStateFromComment(pending.latest, { primed: true }));
@@ -478,6 +478,9 @@ export async function listProjectRunnerRequestCommentStates(
478
478
  action: String(row.action || row.Action || "").trim(),
479
479
  context_excluded: row.context_excluded === true || row.ContextExcluded === true,
480
480
  closed_reason: String(row.closed_reason || row.closedReason || row.ClosedReason || "").trim(),
481
+ source_occurred_at: String(row.source_occurred_at || row.sourceOccurredAt || row.SourceOccurredAt || "").trim(),
482
+ stale_after_at: String(row.stale_after_at || row.staleAfterAt || row.StaleAfterAt || "").trim(),
483
+ selection_state: String(row.selection_state || row.selectionState || row.SelectionState || "").trim(),
481
484
  consumed_at: String(row.consumed_at || row.consumedAt || row.ConsumedAt || "").trim(),
482
485
  updated_at: String(row.updated_at || row.updatedAt || row.UpdatedAt || "").trim(),
483
486
  };
@@ -513,6 +516,9 @@ export async function upsertProjectRunnerRequestCommentState(
513
516
  action: String(raw.action || "").trim(),
514
517
  context_excluded: raw.context_excluded === true,
515
518
  closed_reason: String(raw.closed_reason || "").trim(),
519
+ source_occurred_at: String(raw.source_occurred_at || "").trim() || undefined,
520
+ stale_after_at: String(raw.stale_after_at || "").trim() || undefined,
521
+ selection_state: String(raw.selection_state || "").trim() || undefined,
516
522
  consumed_at: String(raw.consumed_at || "").trim() || undefined,
517
523
  };
518
524
  const extraHeaders = actorUserID ? { "X-Actor-User-Id": actorUserID } : {};
@@ -271,6 +271,10 @@ function isArchiveRecordWithinPendingAgeLimit(record, rawOptions) {
271
271
  if (!(options.maxPendingAgeMs > 0)) {
272
272
  return true;
273
273
  }
274
+ const staleAfterTime = Date.parse(String(safeObject(record).staleAfterAt || ""));
275
+ if (Number.isFinite(staleAfterTime)) {
276
+ return options.nowMs <= staleAfterTime;
277
+ }
274
278
  const recordTime = Date.parse(resolveArchiveRecordEventTime(record));
275
279
  if (!Number.isFinite(recordTime)) {
276
280
  return true;
@@ -541,9 +545,16 @@ export function normalizeArchiveCommentRecord(rawComment, parseArchivedChatComme
541
545
  updatedAt: firstNonEmptyString([comment.updated_at, comment.updatedAt]),
542
546
  authorUserID: firstNonEmptyString([comment.author_user_id, comment.authorUserId, comment.created_by]),
543
547
  sourceOccurredAt: firstNonEmptyString([
548
+ comment.source_occurred_at,
549
+ comment.sourceOccurredAt,
544
550
  safeObject(parsedArchive).occurredAt,
545
551
  safeObject(parsedArchive).occurred_at,
546
552
  ]),
553
+ staleAfterAt: firstNonEmptyString([
554
+ comment.stale_after_at,
555
+ comment.staleAfterAt,
556
+ ]),
557
+ selectionState: String(comment.selection_state || comment.selectionState || "").trim().toLowerCase(),
547
558
  parsedArchive,
548
559
  };
549
560
  }
@@ -844,6 +844,71 @@ export async function runSelftestRunnerScenarios(push, deps) {
844
844
  );
845
845
  }
846
846
 
847
+ try {
848
+ const nowMs = Date.parse("2026-03-31T12:00:00.000Z");
849
+ const pendingSelection = selectPendingArchiveComments(
850
+ [
851
+ {
852
+ id: "cursor-comment-persisted-stale",
853
+ createdAt: "2026-03-31T11:55:00.000Z",
854
+ updatedAt: "2026-03-31T11:55:00.000Z",
855
+ parsedArchive: {
856
+ kind: "telegram_message",
857
+ chatID: "-1001",
858
+ messageID: 1148,
859
+ body: "@RyoAI_bot 기준 댓글",
860
+ },
861
+ },
862
+ {
863
+ id: "stale-by-persisted-cutoff",
864
+ createdAt: "2026-03-31T11:59:58.000Z",
865
+ updatedAt: "2026-03-31T11:59:58.000Z",
866
+ staleAfterAt: "2026-03-31T11:50:00.000Z",
867
+ parsedArchive: {
868
+ kind: "telegram_message",
869
+ chatID: "-1001",
870
+ messageID: 327,
871
+ body: "@RyoAI_bot archive created recently but source already stale",
872
+ },
873
+ },
874
+ {
875
+ id: "fresh-by-persisted-cutoff",
876
+ createdAt: "2026-03-31T11:59:59.000Z",
877
+ updatedAt: "2026-03-31T11:59:59.000Z",
878
+ staleAfterAt: "2026-03-31T12:05:00.000Z",
879
+ parsedArchive: {
880
+ kind: "telegram_message",
881
+ chatID: "-1001",
882
+ messageID: 1149,
883
+ body: "@RyoAI_bot still fresh by persisted cutoff",
884
+ },
885
+ },
886
+ ],
887
+ {
888
+ last_processed_comment_id: "cursor-comment-persisted-stale",
889
+ },
890
+ "start",
891
+ (record) => record,
892
+ {
893
+ maxPendingAgeMs: 15 * 60 * 1000,
894
+ nowMs,
895
+ },
896
+ );
897
+ push(
898
+ "runner_pending_selection_prefers_persisted_stale_after_at_over_recent_archive_created_at",
899
+ pendingSelection.pending.length === 1
900
+ && String(pendingSelection.pending[0]?.id || "") === "fresh-by-persisted-cutoff"
901
+ && ensureArray(pendingSelection.staleSkipped).some((record) => String(record?.id || "") === "stale-by-persisted-cutoff"),
902
+ `pending=${pendingSelection.pending.map((item) => item.id).join(",") || "(none)"} stale=${ensureArray(pendingSelection.staleSkipped).map((item) => item.id).join(",") || "(none)"}`,
903
+ );
904
+ } catch (err) {
905
+ push(
906
+ "runner_pending_selection_prefers_persisted_stale_after_at_over_recent_archive_created_at",
907
+ false,
908
+ String(err?.message || err),
909
+ );
910
+ }
911
+
847
912
  try {
848
913
  const duplicateComments = [
849
914
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metheus-governance-mcp-cli",
3
- "version": "0.2.271",
3
+ "version": "0.2.272",
4
4
  "description": "Metheus Governance MCP CLI (setup + stdio proxy)",
5
5
  "type": "module",
6
6
  "files": [