metheus-governance-mcp-cli 0.2.192 → 0.2.193

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
@@ -213,6 +213,7 @@ const BOT_RUNNER_ACTIVE_EXECUTION_HEARTBEAT_INTERVAL_MS = 5000;
213
213
  const BOT_RUNNER_ACTIVE_EXECUTION_HEARTBEAT_STALE_MS = 60 * 1000;
214
214
  const BOT_RUNNER_PENDING_COMMENT_MAX_AGE_MS = 15 * 60 * 1000;
215
215
  const BOT_RUNNER_EXCLUDED_COMMENT_KEEP_MS = 14 * 24 * 60 * 60 * 1000;
216
+ const BOT_RUNNER_REQUEST_KEEP_MS = 14 * 24 * 60 * 60 * 1000;
216
217
  const BOT_RUNNER_DEFAULT_CONCURRENCY = 2;
217
218
  const BOT_RUNNER_MAX_CONCURRENCY = 8;
218
219
  const TELEGRAM_ROOT_LEGACY_ENV_RELATIVE_PATH = path.join(".metheus", "telegram.env");
@@ -1998,6 +1999,8 @@ function loadBotRunnerState() {
1998
1999
  routes: {},
1999
2000
  sharedInboxes: {},
2000
2001
  excludedComments: {},
2002
+ requests: {},
2003
+ consumedComments: {},
2001
2004
  migrated: false,
2002
2005
  migratedKeys: [],
2003
2006
  remainingAnonymousKeys: [],
@@ -2011,6 +2014,8 @@ function loadBotRunnerState() {
2011
2014
  routes: migratedState.routes,
2012
2015
  sharedInboxes: safeObject(parsed?.shared_inboxes || parsed?.sharedInboxes),
2013
2016
  excludedComments: safeObject(parsed?.excluded_comments || parsed?.excludedComments),
2017
+ requests: safeObject(parsed?.requests),
2018
+ consumedComments: safeObject(parsed?.consumed_comments || parsed?.consumedComments),
2014
2019
  });
2015
2020
  }
2016
2021
  return {
@@ -2018,6 +2023,8 @@ function loadBotRunnerState() {
2018
2023
  routes: migratedState.routes,
2019
2024
  sharedInboxes: safeObject(parsed?.shared_inboxes || parsed?.sharedInboxes),
2020
2025
  excludedComments: normalizeBotRunnerExcludedComments(parsed?.excluded_comments || parsed?.excludedComments),
2026
+ requests: normalizeBotRunnerRequests(parsed?.requests),
2027
+ consumedComments: normalizeBotRunnerConsumedComments(parsed?.consumed_comments || parsed?.consumedComments),
2021
2028
  migrated: migratedState.changed,
2022
2029
  migratedKeys: migratedState.migratedKeys,
2023
2030
  remainingAnonymousKeys: migratedState.remainingAnonymousKeys,
@@ -2028,6 +2035,8 @@ function loadBotRunnerState() {
2028
2035
  routes: {},
2029
2036
  sharedInboxes: {},
2030
2037
  excludedComments: {},
2038
+ requests: {},
2039
+ consumedComments: {},
2031
2040
  migrated: false,
2032
2041
  migratedKeys: [],
2033
2042
  remainingAnonymousKeys: [],
@@ -2047,6 +2056,8 @@ function saveBotRunnerState(nextState) {
2047
2056
  routes: safeObject(nextState?.routes ?? current.routes),
2048
2057
  shared_inboxes: safeObject(nextState?.sharedInboxes ?? nextState?.shared_inboxes ?? current.shared_inboxes ?? current.sharedInboxes),
2049
2058
  excluded_comments: normalizeBotRunnerExcludedComments(nextState?.excludedComments ?? nextState?.excluded_comments ?? current.excluded_comments ?? current.excludedComments),
2059
+ requests: normalizeBotRunnerRequests(nextState?.requests ?? current.requests),
2060
+ consumed_comments: normalizeBotRunnerConsumedComments(nextState?.consumedComments ?? nextState?.consumed_comments ?? current.consumed_comments ?? current.consumedComments),
2050
2061
  };
2051
2062
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
2052
2063
  fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
@@ -2079,6 +2090,100 @@ function normalizeBotRunnerExcludedComments(rawExcluded, nowMs = Date.now()) {
2079
2090
  return normalized;
2080
2091
  }
2081
2092
 
2093
+ function normalizeRunnerRequestStatus(rawStatus) {
2094
+ const status = String(rawStatus || "").trim().toLowerCase();
2095
+ return [
2096
+ "planned",
2097
+ "claimed",
2098
+ "running",
2099
+ "completed",
2100
+ "closed",
2101
+ "expired",
2102
+ "loop_closed",
2103
+ ].includes(status)
2104
+ ? status
2105
+ : "planned";
2106
+ }
2107
+
2108
+ function isFinalRunnerRequestStatus(rawStatus) {
2109
+ const status = normalizeRunnerRequestStatus(rawStatus);
2110
+ return status === "completed" || status === "closed" || status === "expired" || status === "loop_closed";
2111
+ }
2112
+
2113
+ function isActiveRunnerRequestStatus(rawStatus) {
2114
+ const status = normalizeRunnerRequestStatus(rawStatus);
2115
+ return status === "planned" || status === "claimed" || status === "running";
2116
+ }
2117
+
2118
+ function normalizeBotRunnerRequests(rawRequests, nowMs = Date.now()) {
2119
+ const normalized = {};
2120
+ for (const [requestKeyRaw, entryRaw] of Object.entries(safeObject(rawRequests))) {
2121
+ const requestKey = String(requestKeyRaw || safeObject(entryRaw).request_key || "").trim();
2122
+ if (!requestKey) continue;
2123
+ const entry = safeObject(entryRaw);
2124
+ const status = normalizeRunnerRequestStatus(entry.status);
2125
+ const updatedAt = firstNonEmptyString([entry.updated_at, entry.updatedAt, entry.completed_at, entry.completedAt, entry.closed_at, entry.closedAt, entry.claimed_at, entry.claimedAt]);
2126
+ const updatedAtMs = Date.parse(updatedAt);
2127
+ if (isFinalRunnerRequestStatus(status) && Number.isFinite(updatedAtMs) && nowMs - updatedAtMs > BOT_RUNNER_REQUEST_KEEP_MS) {
2128
+ continue;
2129
+ }
2130
+ normalized[requestKey] = {
2131
+ request_key: requestKey,
2132
+ project_id: String(entry.project_id || entry.projectID || "").trim(),
2133
+ provider: String(entry.provider || "").trim(),
2134
+ chat_id: String(entry.chat_id || entry.chatID || "").trim(),
2135
+ source_message_id: intFromRawAllowZero(entry.source_message_id || entry.sourceMessageID, 0) || undefined,
2136
+ root_comment_id: String(entry.root_comment_id || entry.rootCommentID || "").trim(),
2137
+ root_comment_kind: String(entry.root_comment_kind || entry.rootCommentKind || "").trim().toLowerCase(),
2138
+ conversation_id: String(entry.conversation_id || entry.conversationId || "").trim(),
2139
+ selected_bot_usernames: ensureArray(entry.selected_bot_usernames || entry.selectedBotUsernames)
2140
+ .map((value) => normalizeTelegramMentionUsername(value))
2141
+ .filter(Boolean),
2142
+ conversation_allowed_responders: ensureArray(entry.conversation_allowed_responders || entry.conversationAllowedResponders)
2143
+ .map((value) => normalizeTelegramMentionUsername(value))
2144
+ .filter(Boolean),
2145
+ normalized_intent: String(entry.normalized_intent || entry.normalizedIntent || "").trim().toLowerCase(),
2146
+ status,
2147
+ claimed_by_route: String(entry.claimed_by_route || entry.claimedByRoute || "").trim(),
2148
+ claimed_at: firstNonEmptyString([entry.claimed_at, entry.claimedAt]),
2149
+ started_at: firstNonEmptyString([entry.started_at, entry.startedAt]),
2150
+ completed_at: firstNonEmptyString([entry.completed_at, entry.completedAt]),
2151
+ closed_at: firstNonEmptyString([entry.closed_at, entry.closedAt]),
2152
+ closed_reason: String(entry.closed_reason || entry.closedReason || "").trim(),
2153
+ last_comment_id: String(entry.last_comment_id || entry.lastCommentID || "").trim(),
2154
+ last_comment_kind: String(entry.last_comment_kind || entry.lastCommentKind || "").trim().toLowerCase(),
2155
+ last_source_message_id: intFromRawAllowZero(entry.last_source_message_id || entry.lastSourceMessageID, 0) || undefined,
2156
+ updated_at: updatedAt || new Date(nowMs).toISOString(),
2157
+ };
2158
+ }
2159
+ return normalized;
2160
+ }
2161
+
2162
+ function normalizeBotRunnerConsumedComments(rawConsumed, nowMs = Date.now()) {
2163
+ const normalized = {};
2164
+ for (const [commentIDRaw, entryRaw] of Object.entries(safeObject(rawConsumed))) {
2165
+ const commentID = String(commentIDRaw || safeObject(entryRaw).comment_id || "").trim();
2166
+ if (!commentID) continue;
2167
+ const entry = safeObject(entryRaw);
2168
+ const consumedAt = firstNonEmptyString([entry.consumed_at, entry.consumedAt, entry.updated_at, entry.updatedAt]);
2169
+ const consumedAtMs = Date.parse(consumedAt);
2170
+ if (Number.isFinite(consumedAtMs) && nowMs - consumedAtMs > BOT_RUNNER_REQUEST_KEEP_MS) {
2171
+ continue;
2172
+ }
2173
+ normalized[commentID] = {
2174
+ comment_id: commentID,
2175
+ request_key: String(entry.request_key || entry.requestKey || "").trim(),
2176
+ consumed_at: consumedAt || new Date(nowMs).toISOString(),
2177
+ route_key: String(entry.route_key || entry.routeKey || "").trim(),
2178
+ conversation_id: String(entry.conversation_id || entry.conversationId || "").trim(),
2179
+ source_message_id: intFromRawAllowZero(entry.source_message_id || entry.sourceMessageID, 0) || undefined,
2180
+ comment_kind: String(entry.comment_kind || entry.commentKind || "").trim().toLowerCase(),
2181
+ request_status: normalizeRunnerRequestStatus(entry.request_status || entry.requestStatus),
2182
+ };
2183
+ }
2184
+ return normalized;
2185
+ }
2186
+
2082
2187
  function runnerRouteMatchesProjectConversationScope(candidateRouteRaw, normalizedRouteRaw) {
2083
2188
  const candidateRoute = normalizeRunnerRoute(candidateRouteRaw);
2084
2189
  const normalizedRoute = normalizeRunnerRoute(normalizedRouteRaw);
@@ -2211,6 +2316,452 @@ function collectBotRunnerConversationSessionFacts(routeRaw, conversationIDRaw) {
2211
2316
  };
2212
2317
  }
2213
2318
 
2319
+ function uniqueOrderedStrings(values, normalizer = (value) => String(value || "").trim()) {
2320
+ const output = [];
2321
+ const seen = new Set();
2322
+ for (const value of ensureArray(values)) {
2323
+ const normalized = normalizer(value);
2324
+ if (!normalized || seen.has(normalized)) continue;
2325
+ seen.add(normalized);
2326
+ output.push(normalized);
2327
+ }
2328
+ return output;
2329
+ }
2330
+
2331
+ function buildRunnerRequestKey({
2332
+ normalizedRoute,
2333
+ selectedRecord,
2334
+ selectedBotUsernames = [],
2335
+ normalizedIntent = "",
2336
+ }) {
2337
+ const parsed = safeObject(selectedRecord?.parsedArchive);
2338
+ const chatID = String(parsed.chatID || parsed.chatId || "").trim() || "-";
2339
+ const messageID = intFromRawAllowZero(parsed.messageID, 0);
2340
+ const kind = String(parsed.kind || "").trim().toLowerCase() || "-";
2341
+ const responders = uniqueOrderedStrings(selectedBotUsernames, normalizeTelegramMentionUsername).join(",") || "-";
2342
+ const intent = String(normalizedIntent || "").trim().toLowerCase() || "-";
2343
+ const conversationID = String(parsed.conversationID || "").trim() || "-";
2344
+ return [
2345
+ String(normalizedRoute?.projectID || "").trim() || "-",
2346
+ String(normalizedRoute?.provider || "").trim() || "-",
2347
+ chatID,
2348
+ String(messageID || "-"),
2349
+ kind,
2350
+ responders,
2351
+ intent,
2352
+ conversationID,
2353
+ ].join("::");
2354
+ }
2355
+
2356
+ function findRunnerRequestsForScope(state, normalizedRoute, selectors = {}) {
2357
+ const requests = normalizeBotRunnerRequests(state?.requests);
2358
+ const projectID = String(normalizedRoute?.projectID || "").trim();
2359
+ const provider = String(normalizedRoute?.provider || "").trim();
2360
+ const conversationID = String(selectors.conversationID || "").trim();
2361
+ const chatID = String(selectors.chatID || "").trim();
2362
+ const requestKey = String(selectors.requestKey || "").trim();
2363
+ return Object.values(requests)
2364
+ .filter((entry) => !requestKey || entry.request_key === requestKey)
2365
+ .filter((entry) => !projectID || entry.project_id === projectID)
2366
+ .filter((entry) => !provider || entry.provider === provider)
2367
+ .filter((entry) => !chatID || entry.chat_id === chatID)
2368
+ .filter((entry) => !conversationID || entry.conversation_id === conversationID)
2369
+ .sort((left, right) => {
2370
+ const leftTime = firstNonEmptyString([left.updated_at, left.completed_at, left.closed_at, left.claimed_at]);
2371
+ const rightTime = firstNonEmptyString([right.updated_at, right.completed_at, right.closed_at, right.claimed_at]);
2372
+ if (leftTime && rightTime && leftTime !== rightTime) {
2373
+ return leftTime < rightTime ? 1 : -1;
2374
+ }
2375
+ return String(left.request_key || "").localeCompare(String(right.request_key || ""));
2376
+ });
2377
+ }
2378
+
2379
+ function upsertRunnerRequest(state, requestKey, patch = {}) {
2380
+ const currentState = safeObject(state);
2381
+ const requests = normalizeBotRunnerRequests(currentState.requests);
2382
+ const existing = safeObject(requests[requestKey]);
2383
+ const nextEntry = {
2384
+ ...existing,
2385
+ ...safeObject(patch),
2386
+ request_key: requestKey,
2387
+ status: normalizeRunnerRequestStatus(patch.status || existing.status),
2388
+ updated_at: new Date().toISOString(),
2389
+ };
2390
+ requests[requestKey] = nextEntry;
2391
+ return {
2392
+ requests,
2393
+ request: nextEntry,
2394
+ };
2395
+ }
2396
+
2397
+ function upsertRunnerConsumedComment(state, commentIDRaw, patch = {}) {
2398
+ const commentID = String(commentIDRaw || "").trim();
2399
+ const currentState = safeObject(state);
2400
+ const consumedComments = normalizeBotRunnerConsumedComments(currentState.consumedComments || currentState.consumed_comments);
2401
+ const existing = safeObject(consumedComments[commentID]);
2402
+ const nextEntry = {
2403
+ ...existing,
2404
+ ...safeObject(patch),
2405
+ comment_id: commentID,
2406
+ consumed_at: new Date().toISOString(),
2407
+ request_status: normalizeRunnerRequestStatus(patch.request_status || existing.request_status),
2408
+ };
2409
+ consumedComments[commentID] = nextEntry;
2410
+ return {
2411
+ consumedComments,
2412
+ consumedComment: nextEntry,
2413
+ };
2414
+ }
2415
+
2416
+ function claimRunnerRequestForHumanComment({
2417
+ normalizedRoute,
2418
+ routeKey,
2419
+ selectedRecord,
2420
+ selectedBotUsernames = [],
2421
+ normalizedIntent = "",
2422
+ }) {
2423
+ const parsed = safeObject(selectedRecord?.parsedArchive);
2424
+ const commentKind = String(parsed.kind || "").trim().toLowerCase();
2425
+ if (!isInboundArchiveKind(commentKind)) {
2426
+ return {
2427
+ ok: false,
2428
+ reason: "non_human_comment_cannot_create_request",
2429
+ };
2430
+ }
2431
+ const requestKey = buildRunnerRequestKey({
2432
+ normalizedRoute,
2433
+ selectedRecord,
2434
+ selectedBotUsernames,
2435
+ normalizedIntent,
2436
+ });
2437
+ const currentState = loadBotRunnerState();
2438
+ const requests = normalizeBotRunnerRequests(currentState.requests);
2439
+ const existing = safeObject(requests[requestKey]);
2440
+ if (isFinalRunnerRequestStatus(existing.status)) {
2441
+ return {
2442
+ ok: false,
2443
+ reason: "request_already_finalized",
2444
+ requestKey,
2445
+ };
2446
+ }
2447
+ if (
2448
+ isActiveRunnerRequestStatus(existing.status)
2449
+ && String(existing.claimed_by_route || "").trim()
2450
+ && String(existing.claimed_by_route || "").trim() !== String(routeKey || "").trim()
2451
+ ) {
2452
+ return {
2453
+ ok: false,
2454
+ reason: "request_already_claimed",
2455
+ requestKey,
2456
+ };
2457
+ }
2458
+ const nowISO = new Date().toISOString();
2459
+ const { requests: nextRequests, request } = upsertRunnerRequest(currentState, requestKey, {
2460
+ project_id: String(normalizedRoute?.projectID || "").trim(),
2461
+ provider: String(normalizedRoute?.provider || "").trim(),
2462
+ chat_id: String(parsed.chatID || parsed.chatId || "").trim(),
2463
+ source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
2464
+ root_comment_id: String(selectedRecord?.id || "").trim(),
2465
+ root_comment_kind: commentKind,
2466
+ conversation_id: String(parsed.conversationID || "").trim(),
2467
+ selected_bot_usernames: uniqueOrderedStrings(selectedBotUsernames, normalizeTelegramMentionUsername),
2468
+ normalized_intent: String(normalizedIntent || "").trim().toLowerCase(),
2469
+ status: "claimed",
2470
+ claimed_by_route: String(routeKey || "").trim(),
2471
+ claimed_at: firstNonEmptyString([existing.claimed_at, nowISO]) || nowISO,
2472
+ last_comment_id: String(selectedRecord?.id || "").trim(),
2473
+ last_comment_kind: commentKind,
2474
+ last_source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
2475
+ });
2476
+ const { consumedComments: nextConsumedComments } = upsertRunnerConsumedComment(currentState, selectedRecord?.id, {
2477
+ request_key: requestKey,
2478
+ route_key: String(routeKey || "").trim(),
2479
+ conversation_id: String(parsed.conversationID || "").trim(),
2480
+ source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
2481
+ comment_kind: commentKind,
2482
+ request_status: "claimed",
2483
+ });
2484
+ saveBotRunnerState({
2485
+ routes: currentState.routes,
2486
+ sharedInboxes: currentState.sharedInboxes || currentState.shared_inboxes,
2487
+ excludedComments: currentState.excludedComments || currentState.excluded_comments,
2488
+ requests: nextRequests,
2489
+ consumedComments: nextConsumedComments,
2490
+ });
2491
+ return {
2492
+ ok: true,
2493
+ requestKey,
2494
+ request,
2495
+ };
2496
+ }
2497
+
2498
+ function resolveRunnerContinuationRequestForBotReply({
2499
+ normalizedRoute,
2500
+ routeKey,
2501
+ selectedRecord,
2502
+ }) {
2503
+ const parsed = safeObject(selectedRecord?.parsedArchive);
2504
+ const commentKind = String(parsed.kind || "").trim().toLowerCase();
2505
+ if (commentKind !== "bot_reply") {
2506
+ return {
2507
+ ok: false,
2508
+ reason: "not_a_bot_reply",
2509
+ };
2510
+ }
2511
+ const conversationID = String(parsed.conversationID || "").trim();
2512
+ if (!conversationID) {
2513
+ return {
2514
+ ok: false,
2515
+ reason: "bot_reply_without_conversation",
2516
+ };
2517
+ }
2518
+ const currentState = loadBotRunnerState();
2519
+ const requests = findRunnerRequestsForScope(currentState, normalizedRoute, {
2520
+ conversationID,
2521
+ chatID: String(parsed.chatID || parsed.chatId || "").trim(),
2522
+ }).filter((entry) => isActiveRunnerRequestStatus(entry.status));
2523
+ const request = safeObject(requests[0]);
2524
+ if (!Object.keys(request).length) {
2525
+ return {
2526
+ ok: false,
2527
+ reason: "bot_reply_without_active_request",
2528
+ conversationID,
2529
+ };
2530
+ }
2531
+ const { consumedComments: nextConsumedComments } = upsertRunnerConsumedComment(currentState, selectedRecord?.id, {
2532
+ request_key: request.request_key,
2533
+ route_key: String(routeKey || "").trim(),
2534
+ conversation_id: conversationID,
2535
+ source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
2536
+ comment_kind: commentKind,
2537
+ request_status: request.status,
2538
+ });
2539
+ const { requests: nextRequests, request: nextRequest } = upsertRunnerRequest(currentState, request.request_key, {
2540
+ last_comment_id: String(selectedRecord?.id || "").trim(),
2541
+ last_comment_kind: commentKind,
2542
+ last_source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
2543
+ });
2544
+ saveBotRunnerState({
2545
+ routes: currentState.routes,
2546
+ sharedInboxes: currentState.sharedInboxes || currentState.shared_inboxes,
2547
+ excludedComments: currentState.excludedComments || currentState.excluded_comments,
2548
+ requests: nextRequests,
2549
+ consumedComments: nextConsumedComments,
2550
+ });
2551
+ return {
2552
+ ok: true,
2553
+ requestKey: nextRequest.request_key,
2554
+ request: nextRequest,
2555
+ };
2556
+ }
2557
+
2558
+ function markRunnerRequestLifecycle({
2559
+ normalizedRoute,
2560
+ requestKey,
2561
+ selectedRecord,
2562
+ routeKey,
2563
+ outcome,
2564
+ conversationIDRaw = "",
2565
+ allowedResponders = [],
2566
+ normalizedIntent = "",
2567
+ closedReason = "",
2568
+ }) {
2569
+ const key = String(requestKey || "").trim();
2570
+ if (!key) return null;
2571
+ const currentState = loadBotRunnerState();
2572
+ const existing = safeObject(normalizeBotRunnerRequests(currentState.requests)[key]);
2573
+ if (!Object.keys(existing).length) return null;
2574
+ const parsed = safeObject(selectedRecord?.parsedArchive);
2575
+ const conversationID = String(conversationIDRaw || existing.conversation_id || parsed.conversationID || "").trim();
2576
+ const conversationFacts = conversationID
2577
+ ? collectBotRunnerConversationSessionFacts(normalizedRoute, conversationID)
2578
+ : {};
2579
+ const normalizedOutcome = String(outcome || "").trim().toLowerCase();
2580
+ const nextStatus = (() => {
2581
+ if (normalizedOutcome === "claimed") return "claimed";
2582
+ if (normalizedOutcome === "running") return "running";
2583
+ if (normalizedOutcome === "replied") {
2584
+ return conversationFacts.any_open ? "running" : "completed";
2585
+ }
2586
+ if (normalizedOutcome === "loop_closed") return "loop_closed";
2587
+ if (normalizedOutcome === "expired") return "expired";
2588
+ if (normalizedOutcome === "error" || normalizedOutcome === "skipped" || normalizedOutcome === "closed") {
2589
+ return conversationFacts.any_open ? "running" : "closed";
2590
+ }
2591
+ return normalizeRunnerRequestStatus(existing.status);
2592
+ })();
2593
+ const nowISO = new Date().toISOString();
2594
+ const patch = {
2595
+ conversation_id: conversationID,
2596
+ conversation_allowed_responders: uniqueOrderedStrings(
2597
+ ensureArray(allowedResponders).length ? allowedResponders : existing.conversation_allowed_responders,
2598
+ normalizeTelegramMentionUsername,
2599
+ ),
2600
+ normalized_intent: String(normalizedIntent || existing.normalized_intent || "").trim().toLowerCase(),
2601
+ status: nextStatus,
2602
+ started_at: firstNonEmptyString([existing.started_at, nowISO]),
2603
+ completed_at: nextStatus === "completed" ? nowISO : String(existing.completed_at || "").trim(),
2604
+ closed_at: (nextStatus === "closed" || nextStatus === "expired" || nextStatus === "loop_closed")
2605
+ ? nowISO
2606
+ : String(existing.closed_at || "").trim(),
2607
+ closed_reason: (nextStatus === "closed" || nextStatus === "expired" || nextStatus === "loop_closed")
2608
+ ? String(closedReason || existing.closed_reason || normalizedOutcome).trim()
2609
+ : String(existing.closed_reason || "").trim(),
2610
+ last_comment_id: String(selectedRecord?.id || existing.last_comment_id || "").trim(),
2611
+ last_comment_kind: String(parsed.kind || existing.last_comment_kind || "").trim().toLowerCase(),
2612
+ last_source_message_id: intFromRawAllowZero(parsed.messageID, 0) || existing.last_source_message_id,
2613
+ };
2614
+ const { requests: nextRequests, request } = upsertRunnerRequest(currentState, key, patch);
2615
+ const commentID = String(selectedRecord?.id || "").trim();
2616
+ let nextConsumedComments = currentState.consumedComments || currentState.consumed_comments;
2617
+ if (commentID) {
2618
+ ({ consumedComments: nextConsumedComments } = upsertRunnerConsumedComment(currentState, commentID, {
2619
+ request_key: key,
2620
+ route_key: String(routeKey || "").trim(),
2621
+ conversation_id: conversationID,
2622
+ source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
2623
+ comment_kind: String(parsed.kind || "").trim().toLowerCase(),
2624
+ request_status: nextStatus,
2625
+ }));
2626
+ }
2627
+ saveBotRunnerState({
2628
+ routes: currentState.routes,
2629
+ sharedInboxes: currentState.sharedInboxes || currentState.shared_inboxes,
2630
+ excludedComments: currentState.excludedComments || currentState.excluded_comments,
2631
+ requests: nextRequests,
2632
+ consumedComments: nextConsumedComments,
2633
+ });
2634
+ return request;
2635
+ }
2636
+
2637
+ function cleanupBotRunnerRequestState({
2638
+ normalizedRoute,
2639
+ comments = [],
2640
+ }) {
2641
+ const currentState = loadBotRunnerState();
2642
+ const config = loadBotRunnerConfig({ persistIfNeeded: true });
2643
+ const nextRoutes = {
2644
+ ...safeObject(currentState.routes),
2645
+ };
2646
+ let nextRequests = normalizeBotRunnerRequests(currentState.requests);
2647
+ let nextExcludedComments = normalizeBotRunnerExcludedComments(currentState.excludedComments || currentState.excluded_comments);
2648
+ let changed = false;
2649
+ const orderedComments = ensureArray(comments)
2650
+ .map((comment) => normalizeArchiveCommentRecord(comment, parseArchivedChatComment))
2651
+ .filter((record) => record.id && record.parsedArchive);
2652
+ const nowMs = Date.now();
2653
+ const nowISO = new Date(nowMs).toISOString();
2654
+ const candidateRoutes = [];
2655
+ const seenRouteKeys = new Set();
2656
+ const pushCandidateRoute = (routeRaw) => {
2657
+ const routeObject = safeObject(routeRaw);
2658
+ if (!Object.keys(routeObject).length) {
2659
+ return;
2660
+ }
2661
+ const candidateRoute = normalizeRunnerRoute(routeObject);
2662
+ const candidateRouteKey = runnerRouteKey(candidateRoute);
2663
+ if (seenRouteKeys.has(candidateRouteKey)) {
2664
+ return;
2665
+ }
2666
+ seenRouteKeys.add(candidateRouteKey);
2667
+ candidateRoutes.push(candidateRoute);
2668
+ };
2669
+ pushCandidateRoute(normalizedRoute);
2670
+ for (const candidateRouteRaw of ensureArray(config.routes)) {
2671
+ if (!runnerRouteMatchesProjectConversationScope(candidateRouteRaw, normalizedRoute)) {
2672
+ continue;
2673
+ }
2674
+ pushCandidateRoute(candidateRouteRaw);
2675
+ }
2676
+ for (const candidateRoute of candidateRoutes) {
2677
+ const routeKey = runnerRouteKey(candidateRoute);
2678
+ const currentRouteState = safeObject(nextRoutes[routeKey]);
2679
+ const conversationSessions = {
2680
+ ...safeObject(currentRouteState.conversation_sessions),
2681
+ };
2682
+ let routeChanged = false;
2683
+ for (const [conversationID, sessionRaw] of Object.entries(safeObject(conversationSessions))) {
2684
+ const session = safeObject(sessionRaw);
2685
+ if (String(session.status || "").trim() !== "open") {
2686
+ continue;
2687
+ }
2688
+ const expiresAtMs = Date.parse(String(session.expires_at || "").trim());
2689
+ const expired = Number.isFinite(expiresAtMs) && expiresAtMs <= nowMs;
2690
+ const activeRequests = Object.values(nextRequests).filter((entry) => (
2691
+ String(entry.project_id || "").trim() === String(candidateRoute.projectID || "").trim()
2692
+ && String(entry.provider || "").trim() === String(candidateRoute.provider || "").trim()
2693
+ && String(entry.conversation_id || "").trim() === String(conversationID || "").trim()
2694
+ && isActiveRunnerRequestStatus(entry.status)
2695
+ ));
2696
+ if (!expired && activeRequests.length > 0) {
2697
+ continue;
2698
+ }
2699
+ const closedReason = expired ? "expired_session" : "orphaned_open_session";
2700
+ conversationSessions[conversationID] = {
2701
+ ...session,
2702
+ status: "closed",
2703
+ closed_reason: closedReason,
2704
+ closed_at: nowISO,
2705
+ last_activity_at: nowISO,
2706
+ };
2707
+ routeChanged = true;
2708
+ changed = true;
2709
+ for (const request of activeRequests) {
2710
+ nextRequests[request.request_key] = {
2711
+ ...request,
2712
+ status: expired ? "expired" : "closed",
2713
+ closed_reason: closedReason,
2714
+ closed_at: nowISO,
2715
+ updated_at: nowISO,
2716
+ };
2717
+ }
2718
+ for (const record of orderedComments) {
2719
+ const parsed = safeObject(record.parsedArchive);
2720
+ if (String(parsed.conversationID || "").trim() !== String(conversationID || "").trim()) {
2721
+ continue;
2722
+ }
2723
+ if (String(parsed.kind || "").trim().toLowerCase() !== "bot_reply") {
2724
+ continue;
2725
+ }
2726
+ nextExcludedComments[String(record.id || "").trim()] = {
2727
+ ...safeObject(nextExcludedComments[String(record.id || "").trim()]),
2728
+ comment_id: String(record.id || "").trim(),
2729
+ excluded_at: nowISO,
2730
+ reason_code: closedReason,
2731
+ decision: expired ? "expired" : "closed",
2732
+ action: "skip_closed_request_comment",
2733
+ conversation_id: String(conversationID || "").trim(),
2734
+ source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
2735
+ context_excluded: true,
2736
+ closed_reason: closedReason,
2737
+ };
2738
+ }
2739
+ }
2740
+ if (routeChanged) {
2741
+ nextRoutes[routeKey] = {
2742
+ ...currentRouteState,
2743
+ conversation_sessions: conversationSessions,
2744
+ updated_at: nowISO,
2745
+ };
2746
+ }
2747
+ }
2748
+ if (changed) {
2749
+ saveBotRunnerState({
2750
+ routes: nextRoutes,
2751
+ sharedInboxes: currentState.sharedInboxes || currentState.shared_inboxes,
2752
+ excludedComments: nextExcludedComments,
2753
+ requests: nextRequests,
2754
+ consumedComments: currentState.consumedComments || currentState.consumed_comments,
2755
+ });
2756
+ }
2757
+ return {
2758
+ routes: nextRoutes,
2759
+ requests: nextRequests,
2760
+ excludedComments: nextExcludedComments,
2761
+ changed,
2762
+ };
2763
+ }
2764
+
2214
2765
  function normalizeRunnerProcessLaunchEntry(rawEntry) {
2215
2766
  const entry = safeObject(rawEntry);
2216
2767
  return cleanupRunnerStateRecord({
@@ -4435,6 +4986,7 @@ function emptyRunnerActiveExecutionPatch() {
4435
4986
  active_comment_id: "",
4436
4987
  active_comment_created_at: "",
4437
4988
  active_source_message_id: undefined,
4989
+ active_request_key: "",
4438
4990
  active_started_at: "",
4439
4991
  active_heartbeat_at: "",
4440
4992
  active_runner_pid: undefined,
@@ -4442,13 +4994,14 @@ function emptyRunnerActiveExecutionPatch() {
4442
4994
  };
4443
4995
  }
4444
4996
 
4445
- function buildRunnerActiveExecutionPatch(selectedRecord) {
4997
+ function buildRunnerActiveExecutionPatch(selectedRecord, requestKey = "") {
4446
4998
  const nowISO = new Date().toISOString();
4447
4999
  const parsed = safeObject(selectedRecord?.parsedArchive);
4448
5000
  return {
4449
5001
  active_comment_id: String(selectedRecord?.id || "").trim(),
4450
5002
  active_comment_created_at: firstNonEmptyString([selectedRecord?.createdAt, selectedRecord?.updatedAt]),
4451
5003
  active_source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
5004
+ active_request_key: String(requestKey || "").trim(),
4452
5005
  active_started_at: nowISO,
4453
5006
  active_heartbeat_at: nowISO,
4454
5007
  active_runner_pid: process.pid,
@@ -4762,12 +5315,43 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
4762
5315
  actorUserID: runtime.actor.user_id,
4763
5316
  limit: Math.max(1000, normalizedRoute.contextComments * 20),
4764
5317
  });
5318
+ cleanupBotRunnerRequestState({
5319
+ normalizedRoute,
5320
+ comments,
5321
+ });
5322
+ const latestRunnerState = loadBotRunnerState();
4765
5323
  const excludedComments = normalizeBotRunnerExcludedComments(
4766
- loadBotRunnerState().excludedComments || loadBotRunnerState().excluded_comments,
5324
+ latestRunnerState.excludedComments || latestRunnerState.excluded_comments,
5325
+ );
5326
+ const consumedComments = normalizeBotRunnerConsumedComments(
5327
+ latestRunnerState.consumedComments || latestRunnerState.consumed_comments,
4767
5328
  );
5329
+ const requests = normalizeBotRunnerRequests(latestRunnerState.requests);
4768
5330
  const commentsForPending = ensureArray(comments).filter((comment) => {
4769
- const commentID = String(safeObject(comment).id || "").trim();
4770
- return commentID ? !safeObject(excludedComments)[commentID] : true;
5331
+ const normalizedComment = normalizeArchiveCommentRecord(comment, parseArchivedChatComment);
5332
+ const commentID = String(normalizedComment.id || "").trim();
5333
+ if (commentID && safeObject(excludedComments)[commentID]) {
5334
+ return false;
5335
+ }
5336
+ if (commentID && safeObject(consumedComments)[commentID]) {
5337
+ return false;
5338
+ }
5339
+ const parsed = safeObject(normalizedComment.parsedArchive);
5340
+ if (String(parsed.kind || "").trim().toLowerCase() === "bot_reply") {
5341
+ const conversationID = String(parsed.conversationID || "").trim();
5342
+ if (!conversationID) {
5343
+ return false;
5344
+ }
5345
+ const activeRequests = Object.values(requests).filter((entry) => (
5346
+ String(entry.project_id || "").trim() === String(normalizedRoute.projectID || "").trim()
5347
+ && String(entry.provider || "").trim() === String(normalizedRoute.provider || "").trim()
5348
+ && String(entry.chat_id || "").trim() === String(parsed.chatID || "").trim()
5349
+ && String(entry.conversation_id || "").trim() === conversationID
5350
+ && isActiveRunnerRequestStatus(entry.status)
5351
+ ));
5352
+ return activeRequests.length > 0;
5353
+ }
5354
+ return true;
4771
5355
  });
4772
5356
  const pendingWork = selectRunnerPendingWork({
4773
5357
  comments: commentsForPending,
@@ -4882,6 +5466,35 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
4882
5466
  }
4883
5467
  return null;
4884
5468
  };
5469
+ const prepareRunnerRequestClaim = (selectedRecord, selectedResponderSelectors = []) => {
5470
+ const parsed = safeObject(selectedRecord?.parsedArchive);
5471
+ const kind = String(parsed.kind || "").trim().toLowerCase();
5472
+ if (kind === "bot_reply") {
5473
+ const continuation = resolveRunnerContinuationRequestForBotReply({
5474
+ normalizedRoute,
5475
+ routeKey,
5476
+ selectedRecord,
5477
+ });
5478
+ if (!continuation.ok) {
5479
+ markBotRunnerExcludedComment(selectedRecord?.id, {
5480
+ reason_code: String(continuation.reason || "bot_reply_without_active_request").trim() || "bot_reply_without_active_request",
5481
+ decision: "closed",
5482
+ action: "skip_bot_reply_without_active_request",
5483
+ conversation_id: String(parsed.conversationID || "").trim(),
5484
+ source_message_id: intFromRawAllowZero(parsed.messageID, 0) || undefined,
5485
+ context_excluded: true,
5486
+ closed_reason: String(continuation.reason || "bot_reply_without_active_request").trim() || "bot_reply_without_active_request",
5487
+ });
5488
+ }
5489
+ return continuation;
5490
+ }
5491
+ return claimRunnerRequestForHumanComment({
5492
+ normalizedRoute,
5493
+ routeKey,
5494
+ selectedRecord,
5495
+ selectedBotUsernames: selectedResponderSelectors,
5496
+ });
5497
+ };
4885
5498
  if (deferExecution) {
4886
5499
  const skippedRecords = [];
4887
5500
  for (const selectedRecord of pending.pending) {
@@ -4948,7 +5561,31 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
4948
5561
  });
4949
5562
  continue;
4950
5563
  }
4951
- saveRunnerRouteState(routeKey, buildRunnerActiveExecutionPatch(selectedRecord));
5564
+ const requestClaim = prepareRunnerRequestClaim(selectedRecord, adjudication.selected_bot_usernames);
5565
+ if (!requestClaim.ok) {
5566
+ saveRunnerRouteState(
5567
+ routeKey,
5568
+ buildRunnerRouteStateFromComment(selectedRecord, {
5569
+ last_action: "request_skipped",
5570
+ last_reason: String(requestClaim.reason || "request_unavailable").trim() || "request_unavailable",
5571
+ last_trigger: "request_ledger",
5572
+ }),
5573
+ );
5574
+ skippedRecords.push({
5575
+ id: selectedRecord.id,
5576
+ reason: String(requestClaim.reason || "request_unavailable").trim() || "request_unavailable",
5577
+ messageID: intFromRawAllowZero(selectedRecord?.parsedArchive?.messageID, 0),
5578
+ diagnosticType: "skip",
5579
+ contextExcluded: String(requestClaim.reason || "").trim() === "bot_reply_without_active_request",
5580
+ action: "skip_invalid_request_state",
5581
+ closedReason: String(requestClaim.reason || "").trim(),
5582
+ });
5583
+ continue;
5584
+ }
5585
+ saveRunnerRouteState(routeKey, {
5586
+ ...buildRunnerActiveExecutionPatch(selectedRecord, requestClaim.requestKey),
5587
+ last_request_key: String(requestClaim.requestKey || "").trim(),
5588
+ });
4952
5589
  return {
4953
5590
  route_key: routeKey,
4954
5591
  route_name: normalizedRoute.name,
@@ -4964,7 +5601,7 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
4964
5601
  deferred_execution: {
4965
5602
  routeKey,
4966
5603
  normalizedRoute,
4967
- routeState: refreshedState,
5604
+ routeState: safeObject(loadBotRunnerState().routes[routeKey]),
4968
5605
  selectedRecord,
4969
5606
  pendingOrdered: pending.ordered,
4970
5607
  bot,
@@ -4973,6 +5610,7 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
4973
5610
  executionPlan,
4974
5611
  runtime,
4975
5612
  managedConversationBots,
5613
+ requestKey: String(requestClaim.requestKey || "").trim(),
4976
5614
  },
4977
5615
  };
4978
5616
  }
@@ -5037,11 +5675,79 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
5037
5675
  skippedRecords.push(startupLoopSkipped.skippedRecord);
5038
5676
  continue;
5039
5677
  }
5678
+ const inlineAdjudication = await resolveRunnerResponderAdjudication({
5679
+ selectedRecord,
5680
+ pendingOrdered: pending.ordered,
5681
+ normalizedRoute,
5682
+ bot,
5683
+ executionPlan,
5684
+ deps: {
5685
+ ...buildRunnerExecutionDeps(),
5686
+ managedConversationBots,
5687
+ resolveConversationPeerBots: resolveRunnerConversationPeers,
5688
+ },
5689
+ });
5690
+ const inlineCurrentBotSelected = ensureArray(inlineAdjudication.selected_bot_usernames)
5691
+ .map((value) => String(value || "").trim().replace(/^@+/, "").toLowerCase())
5692
+ .includes(currentBotSelector);
5693
+ if (!inlineCurrentBotSelected) {
5694
+ saveRunnerRouteState(
5695
+ routeKey,
5696
+ buildRunnerRouteStateFromComment(selectedRecord, {
5697
+ last_action: "adjudication_skipped",
5698
+ last_reason: String(inlineAdjudication.reason_code || "").trim() || "not_selected_by_adjudicator",
5699
+ last_trigger: "responder_adjudication",
5700
+ }),
5701
+ );
5702
+ skippedRecords.push({
5703
+ id: selectedRecord.id,
5704
+ reason: String(inlineAdjudication.reason_code || "").trim() || "not_selected_by_adjudicator",
5705
+ messageID: intFromRawAllowZero(selectedRecord?.parsedArchive?.messageID, 0),
5706
+ });
5707
+ continue;
5708
+ }
5040
5709
  const currentRouteState = safeObject(loadBotRunnerState().routes[routeKey]);
5710
+ const requestClaim = prepareRunnerRequestClaim(selectedRecord, inlineAdjudication.selected_bot_usernames);
5711
+ if (!requestClaim.ok) {
5712
+ saveRunnerRouteState(
5713
+ routeKey,
5714
+ buildRunnerRouteStateFromComment(selectedRecord, {
5715
+ last_action: "request_skipped",
5716
+ last_reason: String(requestClaim.reason || "request_unavailable").trim() || "request_unavailable",
5717
+ last_trigger: "request_ledger",
5718
+ }),
5719
+ );
5720
+ skippedRecords.push({
5721
+ id: selectedRecord.id,
5722
+ reason: String(requestClaim.reason || "request_unavailable").trim() || "request_unavailable",
5723
+ messageID: intFromRawAllowZero(selectedRecord?.parsedArchive?.messageID, 0),
5724
+ diagnosticType: "skip",
5725
+ contextExcluded: String(requestClaim.reason || "").trim() === "bot_reply_without_active_request",
5726
+ action: "skip_invalid_request_state",
5727
+ closedReason: String(requestClaim.reason || "").trim(),
5728
+ });
5729
+ continue;
5730
+ }
5731
+ saveRunnerRouteState(routeKey, {
5732
+ active_request_key: String(requestClaim.requestKey || "").trim(),
5733
+ last_request_key: String(requestClaim.requestKey || "").trim(),
5734
+ });
5735
+ if (String(requestClaim.requestKey || "").trim()) {
5736
+ markRunnerRequestLifecycle({
5737
+ normalizedRoute,
5738
+ requestKey: requestClaim.requestKey,
5739
+ selectedRecord,
5740
+ routeKey,
5741
+ outcome: "running",
5742
+ });
5743
+ }
5041
5744
  const processed = await processRunnerSelectedRecord({
5042
5745
  routeKey,
5043
5746
  normalizedRoute,
5044
- routeState: currentRouteState,
5747
+ routeState: {
5748
+ ...currentRouteState,
5749
+ active_request_key: String(requestClaim.requestKey || "").trim(),
5750
+ },
5045
5751
  selectedRecord,
5046
5752
  pendingOrdered: pending.ordered,
5047
5753
  bot,
@@ -5064,9 +5770,31 @@ async function processRunnerRouteOnce(route, runtime, mode, options = {}) {
5064
5770
  },
5065
5771
  });
5066
5772
  if (processed.kind === "skipped") {
5773
+ if (String(requestClaim.requestKey || "").trim()) {
5774
+ markRunnerRequestLifecycle({
5775
+ normalizedRoute,
5776
+ requestKey: requestClaim.requestKey,
5777
+ selectedRecord,
5778
+ routeKey,
5779
+ outcome: "skipped",
5780
+ closedReason: String(processed.skippedRecord?.reason || "skipped").trim() || "skipped",
5781
+ });
5782
+ }
5067
5783
  skippedRecords.push(processed.skippedRecord);
5068
5784
  continue;
5069
5785
  }
5786
+ if (String(requestClaim.requestKey || "").trim()) {
5787
+ markRunnerRequestLifecycle({
5788
+ normalizedRoute,
5789
+ requestKey: requestClaim.requestKey,
5790
+ selectedRecord,
5791
+ routeKey,
5792
+ outcome: String(processed.result?.outcome || "replied").trim().toLowerCase(),
5793
+ conversationIDRaw: String(processed.result?.conversation_id || "").trim(),
5794
+ allowedResponders: ensureArray(processed.result?.conversation_allowed_responders),
5795
+ normalizedIntent: String(safeObject(loadBotRunnerState().routes[routeKey]).last_intent_type || "").trim(),
5796
+ });
5797
+ }
5070
5798
  return {
5071
5799
  logical_signature: runnerRouteLogicalSignature(normalizedRoute),
5072
5800
  archive_source: String(archiveThread.source || "").trim() || "-",
@@ -7297,6 +8025,15 @@ async function runRunnerStartResolvedRoutes(routes, flags, options = {}) {
7297
8025
  deferredExecution.routeKey,
7298
8026
  deferredExecution.selectedRecord,
7299
8027
  );
8028
+ if (String(deferredExecution.requestKey || "").trim()) {
8029
+ markRunnerRequestLifecycle({
8030
+ normalizedRoute: deferredExecution.normalizedRoute,
8031
+ requestKey: deferredExecution.requestKey,
8032
+ selectedRecord: deferredExecution.selectedRecord,
8033
+ routeKey: deferredExecution.routeKey,
8034
+ outcome: "running",
8035
+ });
8036
+ }
7300
8037
  const executionPromise = (async () => {
7301
8038
  try {
7302
8039
  const processed = await processRunnerSelectedRecord({
@@ -7343,6 +8080,16 @@ async function runRunnerStartResolvedRoutes(routes, flags, options = {}) {
7343
8080
  },
7344
8081
  });
7345
8082
  if (processed.kind === "skipped") {
8083
+ if (String(deferredExecution.requestKey || "").trim()) {
8084
+ markRunnerRequestLifecycle({
8085
+ normalizedRoute: deferredExecution.normalizedRoute,
8086
+ requestKey: deferredExecution.requestKey,
8087
+ selectedRecord: deferredExecution.selectedRecord,
8088
+ routeKey: deferredExecution.routeKey,
8089
+ outcome: "skipped",
8090
+ closedReason: String(processed.skippedRecord?.reason || "skipped").trim() || "skipped",
8091
+ });
8092
+ }
7346
8093
  return {
7347
8094
  route_key: deferredExecution.routeKey,
7348
8095
  route_name: deferredExecution.normalizedRoute.name,
@@ -7357,6 +8104,18 @@ async function runRunnerStartResolvedRoutes(routes, flags, options = {}) {
7357
8104
  role_profile: deferredExecution.executionPlan.roleProfileName,
7358
8105
  };
7359
8106
  }
8107
+ if (String(deferredExecution.requestKey || "").trim()) {
8108
+ markRunnerRequestLifecycle({
8109
+ normalizedRoute: deferredExecution.normalizedRoute,
8110
+ requestKey: deferredExecution.requestKey,
8111
+ selectedRecord: deferredExecution.selectedRecord,
8112
+ routeKey: deferredExecution.routeKey,
8113
+ outcome: String(processed.result?.outcome || "replied").trim().toLowerCase(),
8114
+ conversationIDRaw: String(processed.result?.conversation_id || "").trim(),
8115
+ allowedResponders: ensureArray(processed.result?.conversation_allowed_responders),
8116
+ normalizedIntent: String(safeObject(loadBotRunnerState().routes[deferredExecution.routeKey]).last_intent_type || "").trim(),
8117
+ });
8118
+ }
7360
8119
  return {
7361
8120
  logical_signature: runnerRouteLogicalSignature(deferredExecution.normalizedRoute),
7362
8121
  archive_source: String(deferredExecution.archiveThread.source || "").trim() || "-",
@@ -7371,6 +8130,16 @@ async function runRunnerStartResolvedRoutes(routes, flags, options = {}) {
7371
8130
  ...emptyRunnerActiveExecutionPatch(),
7372
8131
  last_error: errorText,
7373
8132
  });
8133
+ if (String(deferredExecution.requestKey || "").trim()) {
8134
+ markRunnerRequestLifecycle({
8135
+ normalizedRoute: deferredExecution.normalizedRoute,
8136
+ requestKey: deferredExecution.requestKey,
8137
+ selectedRecord: deferredExecution.selectedRecord,
8138
+ routeKey: deferredExecution.routeKey,
8139
+ outcome: "error",
8140
+ closedReason: errorText || "execution_error",
8141
+ });
8142
+ }
7374
8143
  return {
7375
8144
  route_key: deferredExecution.routeKey,
7376
8145
  route_name: deferredExecution.normalizedRoute.name,
@@ -11629,6 +12398,7 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
11629
12398
  runnerRouteKey,
11630
12399
  runnerRouteLogicalSignature,
11631
12400
  loadBotRunnerState,
12401
+ saveBotRunnerState,
11632
12402
  tryJsonParse,
11633
12403
  safeObject,
11634
12404
  normalizeRunnerTriggerPolicy,
@@ -11637,6 +12407,9 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
11637
12407
  selectRunnerPendingWork,
11638
12408
  processRunnerSelectedRecord,
11639
12409
  resolveRunnerStartupLoopAdjudication,
12410
+ claimRunnerRequestForHumanComment,
12411
+ resolveRunnerContinuationRequestForBotReply,
12412
+ cleanupBotRunnerRequestState,
11640
12413
  runRunnerAIExecution,
11641
12414
  buildLocalBotPrompt,
11642
12415
  formatBotReplyArchiveComment,
@@ -267,6 +267,7 @@ export function buildRunnerRouteStateFromComment(record, patch = {}) {
267
267
  last_processed_created_at: firstNonEmptyString([record?.createdAt, record?.updatedAt]),
268
268
  last_source_message_id: intFromRawAllowZero(parsed.messageID, 0),
269
269
  last_source_kind: String(parsed.kind || "").trim(),
270
+ active_request_key: "",
270
271
  last_intent_type: "",
271
272
  last_error: "",
272
273
  active_comment_id: "",
@@ -45,6 +45,10 @@ export async function runSelftestRunnerScenarios(push, deps) {
45
45
  const runnerRouteKey = requireDependency(deps, "runnerRouteKey");
46
46
  const runnerRouteLogicalSignature = requireDependency(deps, "runnerRouteLogicalSignature");
47
47
  const loadBotRunnerState = requireDependency(deps, "loadBotRunnerState");
48
+ const saveBotRunnerState = requireDependency(deps, "saveBotRunnerState");
49
+ const claimRunnerRequestForHumanComment = requireDependency(deps, "claimRunnerRequestForHumanComment");
50
+ const resolveRunnerContinuationRequestForBotReply = requireDependency(deps, "resolveRunnerContinuationRequestForBotReply");
51
+ const cleanupBotRunnerRequestState = requireDependency(deps, "cleanupBotRunnerRequestState");
48
52
  const tryJsonParse = requireDependency(deps, "tryJsonParse");
49
53
  const safeObject = requireDependency(deps, "safeObject");
50
54
  const normalizeRunnerTriggerPolicy = requireDependency(deps, "normalizeRunnerTriggerPolicy");
@@ -1362,6 +1366,137 @@ export async function runSelftestRunnerScenarios(push, deps) {
1362
1366
  }
1363
1367
  }
1364
1368
 
1369
+ const originalRequestLedgerHome = process.env.HOME;
1370
+ const originalRequestLedgerUserProfile = process.env.USERPROFILE;
1371
+ let requestLedgerTempRoot = "";
1372
+ try {
1373
+ requestLedgerTempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "metheus-runner-request-ledger-selftest-"));
1374
+ const requestLedgerHome = path.join(requestLedgerTempRoot, "home");
1375
+ fs.mkdirSync(path.join(requestLedgerHome, ".metheus"), { recursive: true });
1376
+ process.env.HOME = requestLedgerHome;
1377
+ process.env.USERPROFILE = requestLedgerHome;
1378
+ saveBotRunnerState({
1379
+ routes: {},
1380
+ sharedInboxes: {},
1381
+ excludedComments: {},
1382
+ requests: {},
1383
+ consumedComments: {},
1384
+ });
1385
+ const requestRoute = normalizeRunnerRoute({
1386
+ name: "telegram-monitor-request-ledger",
1387
+ project_id: selftestProjectID,
1388
+ provider: "telegram",
1389
+ role: "monitor",
1390
+ destination_label: "Main Room",
1391
+ });
1392
+ const requestRouteKey = runnerRouteKey(requestRoute);
1393
+ const humanRecord = {
1394
+ id: "comment-request-human-1",
1395
+ createdAt: "2026-03-22T00:00:00.000Z",
1396
+ updatedAt: "2026-03-22T00:00:00.000Z",
1397
+ parsedArchive: {
1398
+ kind: "telegram_message",
1399
+ chatID: "-100123",
1400
+ chatType: "supergroup",
1401
+ body: "@RyoAI_bot please coordinate this",
1402
+ messageID: 501,
1403
+ senderIsBot: false,
1404
+ },
1405
+ };
1406
+ const claimed = claimRunnerRequestForHumanComment({
1407
+ normalizedRoute: requestRoute,
1408
+ routeKey: requestRouteKey,
1409
+ selectedRecord: humanRecord,
1410
+ selectedBotUsernames: ["ryoai_bot"],
1411
+ normalizedIntent: "coordination_request",
1412
+ });
1413
+ const claimedState = loadBotRunnerState();
1414
+ const claimedRequest = safeObject(safeObject(claimedState.requests)[claimed.requestKey]);
1415
+ const claimedConsumed = safeObject(safeObject(claimedState.consumedComments)[humanRecord.id]);
1416
+ push(
1417
+ "runner_request_claim_persists_request_and_consumed_comment",
1418
+ claimed.ok === true
1419
+ && String(claimedRequest.status || "") === "claimed"
1420
+ && String(claimedRequest.source_message_id || "") === "501"
1421
+ && String(claimedRequest.normalized_intent || "") === "coordination_request"
1422
+ && String(claimedConsumed.request_key || "") === String(claimed.requestKey || ""),
1423
+ `request=${String(claimed.requestKey || "(none)")} status=${String(claimedRequest.status || "(none)")} consumed=${String(claimedConsumed.request_key || "(none)")}`,
1424
+ );
1425
+
1426
+ saveBotRunnerState({
1427
+ ...claimedState,
1428
+ routes: {
1429
+ [requestRouteKey]: {
1430
+ conversation_sessions: {
1431
+ "conv-stale-1": {
1432
+ status: "open",
1433
+ expires_at: "2026-03-20T00:00:00.000Z",
1434
+ conversation_id: "conv-stale-1",
1435
+ },
1436
+ },
1437
+ },
1438
+ },
1439
+ });
1440
+ const botReplyComments = [
1441
+ {
1442
+ id: "comment-stale-bot-reply-1",
1443
+ body: [
1444
+ "[Bot reply]",
1445
+ "chat_id: -100123",
1446
+ "chat_type: supergroup",
1447
+ "message_id: 777",
1448
+ "bot_username: @RyoAI3_bot",
1449
+ "conversation_id: conv-stale-1",
1450
+ "",
1451
+ "@RyoAI3_bot stale follow-up",
1452
+ ].join("\n"),
1453
+ },
1454
+ ];
1455
+ cleanupBotRunnerRequestState({
1456
+ normalizedRoute: requestRoute,
1457
+ comments: botReplyComments,
1458
+ });
1459
+ const cleanedState = loadBotRunnerState();
1460
+ const cleanedSession = safeObject(safeObject(safeObject(cleanedState.routes)[requestRouteKey]).conversation_sessions)["conv-stale-1"];
1461
+ const excludedEntry = safeObject(safeObject(cleanedState.excludedComments)["comment-stale-bot-reply-1"]);
1462
+ const continuation = resolveRunnerContinuationRequestForBotReply({
1463
+ normalizedRoute: requestRoute,
1464
+ routeKey: requestRouteKey,
1465
+ selectedRecord: {
1466
+ id: "comment-stale-bot-reply-1",
1467
+ parsedArchive: parseArchivedChatComment(botReplyComments[0].body),
1468
+ },
1469
+ });
1470
+ push(
1471
+ "runner_stale_open_session_cleanup_excludes_bot_reply_replay",
1472
+ String(cleanedSession.status || "") === "closed"
1473
+ && String(cleanedSession.closed_reason || "") === "expired_session"
1474
+ && String(excludedEntry.reason_code || "") === "expired_session"
1475
+ && continuation.ok === false
1476
+ && String(continuation.reason || "") === "bot_reply_without_active_request",
1477
+ `session=${String(cleanedSession.status || "(none)")} reason=${String(cleanedSession.closed_reason || "(none)")} excluded=${String(excludedEntry.reason_code || "(none)")} continuation=${String(continuation.reason || "(none)")}`,
1478
+ );
1479
+ } catch (err) {
1480
+ push("runner_request_claim_persists_request_and_consumed_comment", false, String(err?.message || err));
1481
+ push("runner_stale_open_session_cleanup_excludes_bot_reply_replay", false, String(err?.message || err));
1482
+ } finally {
1483
+ if (typeof originalRequestLedgerHome === "string") {
1484
+ process.env.HOME = originalRequestLedgerHome;
1485
+ } else {
1486
+ delete process.env.HOME;
1487
+ }
1488
+ if (typeof originalRequestLedgerUserProfile === "string") {
1489
+ process.env.USERPROFILE = originalRequestLedgerUserProfile;
1490
+ } else {
1491
+ delete process.env.USERPROFILE;
1492
+ }
1493
+ if (requestLedgerTempRoot) {
1494
+ try {
1495
+ fs.rmSync(requestLedgerTempRoot, { recursive: true, force: true });
1496
+ } catch {}
1497
+ }
1498
+ }
1499
+
1365
1500
  const defaultMonitorTriggerPolicy = normalizeRunnerTriggerPolicy({}, { role: "monitor" });
1366
1501
  push(
1367
1502
  "bot_runner_default_monitor_trigger_policy",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metheus-governance-mcp-cli",
3
- "version": "0.2.192",
3
+ "version": "0.2.193",
4
4
  "description": "Metheus Governance MCP CLI (setup + stdio proxy)",
5
5
  "type": "module",
6
6
  "files": [