bosun 0.41.2 → 0.41.3

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.
Files changed (71) hide show
  1. package/.env.example +1 -1
  2. package/agent/agent-prompt-catalog.mjs +971 -0
  3. package/agent/agent-prompts.mjs +2 -970
  4. package/agent/agent-supervisor.mjs +6 -3
  5. package/agent/autofix-git.mjs +33 -0
  6. package/agent/autofix-prompts.mjs +151 -0
  7. package/agent/autofix.mjs +11 -175
  8. package/agent/bosun-skills.mjs +3 -2
  9. package/bosun.config.example.json +17 -0
  10. package/bosun.schema.json +87 -188
  11. package/cli.mjs +34 -1
  12. package/config/config-doctor.mjs +5 -250
  13. package/config/config-file-names.mjs +5 -0
  14. package/config/config.mjs +89 -493
  15. package/config/executor-config.mjs +493 -0
  16. package/config/repo-root.mjs +1 -2
  17. package/config/workspace-health.mjs +242 -0
  18. package/git/git-safety.mjs +15 -0
  19. package/github/github-oauth-portal.mjs +46 -0
  20. package/infra/library-manager-utils.mjs +22 -0
  21. package/infra/library-manager-well-known-sources.mjs +578 -0
  22. package/infra/library-manager.mjs +512 -1030
  23. package/infra/monitor.mjs +28 -9
  24. package/infra/session-tracker.mjs +10 -7
  25. package/kanban/kanban-adapter.mjs +17 -1
  26. package/lib/codebase-audit-manifests.mjs +117 -0
  27. package/lib/codebase-audit.mjs +18 -115
  28. package/package.json +18 -3
  29. package/server/ui-server.mjs +1194 -79
  30. package/shell/codex-config-file.mjs +178 -0
  31. package/shell/codex-config.mjs +538 -575
  32. package/task/task-cli.mjs +54 -3
  33. package/task/task-executor.mjs +143 -13
  34. package/task/task-store.mjs +409 -1
  35. package/telegram/telegram-bot.mjs +127 -0
  36. package/tools/apply-pr-suggestions.mjs +401 -0
  37. package/tools/syntax-check.mjs +21 -9
  38. package/ui/app.js +3 -14
  39. package/ui/components/kanban-board.js +227 -4
  40. package/ui/components/session-list.js +85 -5
  41. package/ui/demo-defaults.js +334 -80
  42. package/ui/demo.html +155 -0
  43. package/ui/modules/session-api.js +96 -0
  44. package/ui/modules/settings-schema.js +1 -2
  45. package/ui/modules/state.js +21 -3
  46. package/ui/setup.html +4 -5
  47. package/ui/styles/components.css +58 -4
  48. package/ui/tabs/agents.js +12 -15
  49. package/ui/tabs/control.js +1 -0
  50. package/ui/tabs/library.js +484 -22
  51. package/ui/tabs/manual-flows.js +105 -29
  52. package/ui/tabs/tasks.js +785 -140
  53. package/ui/tabs/telemetry.js +129 -11
  54. package/ui/tabs/workflow-canvas-utils.mjs +130 -0
  55. package/ui/tabs/workflows.js +293 -23
  56. package/voice/voice-tool-definitions.mjs +757 -0
  57. package/voice/voice-tools.mjs +34 -778
  58. package/workflow/manual-flow-audit.mjs +165 -0
  59. package/workflow/manual-flows.mjs +164 -259
  60. package/workflow/workflow-engine.mjs +147 -58
  61. package/workflow/workflow-nodes/definitions.mjs +1207 -0
  62. package/workflow/workflow-nodes/transforms.mjs +612 -0
  63. package/workflow/workflow-nodes.mjs +304 -52
  64. package/workflow/workflow-templates.mjs +313 -191
  65. package/workflow-templates/_helpers.mjs +154 -0
  66. package/workflow-templates/agents.mjs +61 -4
  67. package/workflow-templates/code-quality.mjs +7 -7
  68. package/workflow-templates/github.mjs +20 -10
  69. package/workflow-templates/task-batch.mjs +20 -9
  70. package/workflow-templates/task-lifecycle.mjs +31 -6
  71. package/workspace/worktree-manager.mjs +277 -3
@@ -652,6 +652,138 @@ function compareTaskDagOrder(taskA, taskB) {
652
652
  return String(taskA?.id || "").localeCompare(String(taskB?.id || ""));
653
653
  }
654
654
 
655
+ function compareSprintDagOrder(sprintA, sprintB) {
656
+ const orderA = normalizeSprintOrder(sprintA?.order);
657
+ const orderB = normalizeSprintOrder(sprintB?.order);
658
+ if (orderA != null && orderB != null && orderA !== orderB) return orderA - orderB;
659
+ if (orderA != null && orderB == null) return -1;
660
+ if (orderA == null && orderB != null) return 1;
661
+ return String(sprintA?.name || sprintA?.id || "").localeCompare(String(sprintB?.name || sprintB?.id || ""));
662
+ }
663
+
664
+ function topoSortIds(seedIds, incomingMap, outgoingMap, compareEntries) {
665
+ const remaining = new Map();
666
+ for (const id of seedIds) remaining.set(id, incomingMap.get(id) || 0);
667
+ const ordered = [];
668
+ const ready = [...seedIds].filter((id) => (remaining.get(id) || 0) === 0);
669
+
670
+ while (ready.length > 0) {
671
+ ready.sort(compareEntries);
672
+ const current = ready.shift();
673
+ ordered.push(current);
674
+ for (const nextId of outgoingMap.get(current) || []) {
675
+ if (!remaining.has(nextId)) continue;
676
+ const nextCount = (remaining.get(nextId) || 0) - 1;
677
+ remaining.set(nextId, nextCount);
678
+ if (nextCount === 0) ready.push(nextId);
679
+ }
680
+ remaining.delete(current);
681
+ }
682
+
683
+ if (remaining.size > 0) {
684
+ ordered.push(...[...remaining.keys()].sort(compareEntries));
685
+ }
686
+
687
+ return ordered;
688
+ }
689
+
690
+ function hasDependencyPath(taskMap, fromTaskId, targetTaskId, visited = new Set()) {
691
+ const startId = String(fromTaskId || "").trim();
692
+ const targetId = String(targetTaskId || "").trim();
693
+ if (!startId || !targetId) return false;
694
+ if (startId === targetId) return true;
695
+ if (visited.has(startId)) return false;
696
+ visited.add(startId);
697
+ const task = taskMap.get(startId);
698
+ if (!task) return false;
699
+ for (const dependencyId of listTaskDependencyIds(task)) {
700
+ if (dependencyId === targetId) return true;
701
+ if (hasDependencyPath(taskMap, dependencyId, targetId, visited)) return true;
702
+ }
703
+ return false;
704
+ }
705
+
706
+ function collectDagRewriteSuggestions(taskMap, orderedTaskIds, sprint) {
707
+ const suggestions = [];
708
+ const sprintId = normalizeSprintId(sprint?.id);
709
+ const sprintMode = resolveSprintOrderMode(sprint?.executionMode || sprint?.taskOrderMode || "parallel");
710
+
711
+ // Cache for hasDependencyPath results to avoid repeated expensive traversals.
712
+ const pathCache = new Map();
713
+ function memoizedHasDependencyPath(fromTaskId, targetTaskId, initialVisited) {
714
+ const fromId = String(fromTaskId || "").trim();
715
+ const toId = String(targetTaskId || "").trim();
716
+ if (!fromId || !toId) return false;
717
+ const cacheKey = `${fromId}::${toId}`;
718
+ if (pathCache.has(cacheKey)) {
719
+ return pathCache.get(cacheKey);
720
+ }
721
+ const visited = initialVisited ? new Set(initialVisited) : new Set();
722
+ const result = hasDependencyPath(taskMap, fromId, toId, visited);
723
+ pathCache.set(cacheKey, result);
724
+ return result;
725
+ }
726
+
727
+ if (sprintMode === "sequential") {
728
+ for (let index = 1; index < orderedTaskIds.length; index += 1) {
729
+ const previousTaskId = orderedTaskIds[index - 1];
730
+ const currentTaskId = orderedTaskIds[index];
731
+ const currentTask = taskMap.get(currentTaskId);
732
+ if (!currentTask) continue;
733
+ if (new Set(listTaskDependencyIds(currentTask)).has(previousTaskId)) continue;
734
+ suggestions.push({
735
+ type: "missing_sequential_dependency",
736
+ sprintId,
737
+ taskId: currentTaskId,
738
+ dependencyTaskId: previousTaskId,
739
+ message: `Add dependency ${previousTaskId} -> ${currentTaskId} to encode sequential sprint order.`,
740
+ });
741
+ }
742
+ }
743
+
744
+ for (const taskId of orderedTaskIds) {
745
+ const task = taskMap.get(taskId);
746
+ if (!task) continue;
747
+ const directDependencies = listTaskDependencyIds(task);
748
+ for (const dependencyId of directDependencies) {
749
+ const redundant = directDependencies.some((otherDependencyId) => {
750
+ if (otherDependencyId === dependencyId) return false;
751
+ return memoizedHasDependencyPath(otherDependencyId, dependencyId, new Set([taskId]));
752
+ });
753
+ if (!redundant) continue;
754
+ suggestions.push({
755
+ type: "redundant_transitive_dependency",
756
+ sprintId,
757
+ taskId,
758
+ dependencyTaskId: dependencyId,
759
+ message: `Dependency ${dependencyId} -> ${taskId} is already implied transitively by another dependency.`,
760
+ });
761
+ }
762
+ }
763
+
764
+ return suggestions;
765
+ }
766
+
767
+ function getTaskEpicId(task) {
768
+ return String(task?.epicId ?? task?.meta?.epicId ?? "").trim();
769
+ }
770
+
771
+ function normalizeRecoveredTaskMeta(task, recoveredAt) {
772
+ const currentMeta = task?.meta && typeof task.meta === "object" ? task.meta : {};
773
+ const currentRecovery = currentMeta.autoRecovery && typeof currentMeta.autoRecovery === "object"
774
+ ? currentMeta.autoRecovery
775
+ : {};
776
+ return {
777
+ ...currentMeta,
778
+ autoRecovery: {
779
+ ...currentRecovery,
780
+ active: false,
781
+ recoveredAt,
782
+ recoveredStatus: "todo",
783
+ },
784
+ };
785
+ }
786
+
655
787
  function recalcStats() {
656
788
  const stats = {
657
789
  draft: 0,
@@ -1320,6 +1452,56 @@ export function setTaskStatus(taskId, status, source) {
1320
1452
  return { ...task };
1321
1453
  }
1322
1454
 
1455
+ export function unblockTask(taskId, options = {}) {
1456
+ ensureLoaded();
1457
+ const task = _store.tasks[taskId];
1458
+ if (!task) {
1459
+ console.warn(TAG, `unblockTask: task ${taskId} not found`);
1460
+ return null;
1461
+ }
1462
+
1463
+ const previousStatus = normalizeTaskStatus(task.status);
1464
+ const nextStatus = normalizeTaskStatus(
1465
+ options.status || options.targetStatus || "todo",
1466
+ );
1467
+ const timestamp = now();
1468
+ task.status = nextStatus;
1469
+ task.cooldownUntil = null;
1470
+ task.blockedReason = null;
1471
+ if (task.meta && typeof task.meta === "object") {
1472
+ const nextMeta = { ...task.meta };
1473
+ delete nextMeta.autoRecovery;
1474
+ task.meta = nextMeta;
1475
+ }
1476
+ task.updatedAt = timestamp;
1477
+ task.lastActivityAt = timestamp;
1478
+ task.syncDirty = options.source !== "external";
1479
+
1480
+ if (previousStatus !== nextStatus) {
1481
+ task.statusHistory.push({
1482
+ status: nextStatus,
1483
+ timestamp,
1484
+ source: options.source || "manual-unblock",
1485
+ });
1486
+ if (task.statusHistory.length > MAX_STATUS_HISTORY) {
1487
+ task.statusHistory = task.statusHistory.slice(-MAX_STATUS_HISTORY);
1488
+ }
1489
+ }
1490
+
1491
+ pushTaskTimeline(task, {
1492
+ type: "task.unblocked",
1493
+ source: options.source || "manual-unblock",
1494
+ fromStatus: previousStatus,
1495
+ toStatus: nextStatus,
1496
+ status: nextStatus,
1497
+ action: "unblock_task",
1498
+ message: `Cleared blocked state and moved task to ${nextStatus}`,
1499
+ });
1500
+
1501
+ saveStore();
1502
+ return { ...task };
1503
+ }
1504
+
1323
1505
  export function validateTaskStatusTransition(currentStatus, nextStatus, options = {}) {
1324
1506
  return validateTaskTransition(currentStatus, nextStatus, options);
1325
1507
  }
@@ -2076,6 +2258,232 @@ export function canTaskStart(taskId, options = {}) {
2076
2258
  };
2077
2259
  }
2078
2260
 
2261
+ export function recoverAutoBlockedTasks(options = {}) {
2262
+ ensureLoaded();
2263
+ const recoveredAtMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
2264
+ const recoveredAt = new Date(recoveredAtMs).toISOString();
2265
+ const recoveredTaskIds = [];
2266
+
2267
+ for (const task of Object.values(_store.tasks)) {
2268
+ if (!task || normalizeTaskStatus(task.status) !== "blocked") continue;
2269
+ const autoRecovery = task.meta?.autoRecovery;
2270
+ if (!autoRecovery || typeof autoRecovery !== "object") continue;
2271
+ if (autoRecovery.active === false) continue;
2272
+ if (String(autoRecovery.reason || "").trim() !== "worktree_failure") continue;
2273
+ const retryAtMs = Date.parse(String(autoRecovery.retryAt || task.cooldownUntil || ""));
2274
+ if (!Number.isFinite(retryAtMs) || retryAtMs > recoveredAtMs) continue;
2275
+
2276
+ const previousStatus = normalizeTaskStatus(task.status);
2277
+ task.status = "todo";
2278
+ task.cooldownUntil = null;
2279
+ task.blockedReason = null;
2280
+ task.meta = normalizeRecoveredTaskMeta(task, recoveredAt);
2281
+ task.updatedAt = recoveredAt;
2282
+ task.lastActivityAt = recoveredAt;
2283
+ task.syncDirty = true;
2284
+ task.statusHistory.push({
2285
+ status: "todo",
2286
+ timestamp: recoveredAt,
2287
+ source: "auto-recovery",
2288
+ });
2289
+ if (task.statusHistory.length > MAX_STATUS_HISTORY) {
2290
+ task.statusHistory = task.statusHistory.slice(-MAX_STATUS_HISTORY);
2291
+ }
2292
+ pushTaskTimeline(task, {
2293
+ type: "status.transition",
2294
+ source: "auto-recovery",
2295
+ fromStatus: previousStatus,
2296
+ toStatus: "todo",
2297
+ status: "todo",
2298
+ action: "recover_blocked_task",
2299
+ message: "Recovered timed blocked task back to todo",
2300
+ });
2301
+ markTaskTouched(task, "auto-recovery");
2302
+ recoveredTaskIds.push(task.id);
2303
+ }
2304
+
2305
+ if (recoveredTaskIds.length > 0) saveStore();
2306
+
2307
+ return {
2308
+ recoveredTaskIds,
2309
+ recoveredCount: recoveredTaskIds.length,
2310
+ recoveredAt,
2311
+ };
2312
+ }
2313
+
2314
+ export function organizeTaskDag(options = {}) {
2315
+ ensureLoaded();
2316
+ const sprintFilter = normalizeSprintId(options.sprintId);
2317
+ const applyDependencySuggestions = options.applyDependencySuggestions !== false;
2318
+ const syncEpicDependencies = options.syncEpicDependencies !== false;
2319
+ const sprintMap = ensureSprintsMap();
2320
+ const allTasks = Object.values(_store.tasks);
2321
+ const taskMap = new Map(allTasks.map((task) => [task.id, task]));
2322
+ const targetSprintIds = sprintFilter
2323
+ ? (sprintMap[sprintFilter] ? [sprintFilter] : [])
2324
+ : listSprints().map((sprint) => sprint.id);
2325
+
2326
+ const suggestions = [];
2327
+ const orderedTaskIdsBySprint = {};
2328
+ let updatedTaskCount = 0;
2329
+ let appliedDependencySuggestionCount = 0;
2330
+ let syncedEpicDependencyCount = 0;
2331
+
2332
+ for (const sprintId of targetSprintIds) {
2333
+ const sprint = sprintMap[sprintId];
2334
+ if (!sprint) continue;
2335
+ const sprintTasks = allTasks
2336
+ .filter((task) => normalizeSprintId(task?.sprintId) === sprintId)
2337
+ .sort(compareTaskDagOrder);
2338
+ const incomingCounts = new Map();
2339
+ const outgoingMap = new Map();
2340
+ for (const task of sprintTasks) {
2341
+ incomingCounts.set(task.id, 0);
2342
+ outgoingMap.set(task.id, new Set());
2343
+ }
2344
+ for (const task of sprintTasks) {
2345
+ for (const dependencyId of listTaskDependencyIds(task)) {
2346
+ const dependencyTask = taskMap.get(dependencyId);
2347
+ if (!dependencyTask || normalizeSprintId(dependencyTask.sprintId) !== sprintId) continue;
2348
+ outgoingMap.get(dependencyId).add(task.id);
2349
+ incomingCounts.set(task.id, (incomingCounts.get(task.id) || 0) + 1);
2350
+ }
2351
+ }
2352
+
2353
+ const orderedTaskIds = topoSortIds(
2354
+ sprintTasks.map((task) => task.id),
2355
+ incomingCounts,
2356
+ outgoingMap,
2357
+ (leftId, rightId) => compareTaskDagOrder(taskMap.get(leftId), taskMap.get(rightId)),
2358
+ );
2359
+ orderedTaskIdsBySprint[sprintId] = orderedTaskIds;
2360
+ const sprintSuggestions = collectDagRewriteSuggestions(taskMap, orderedTaskIds, sprint);
2361
+ for (const suggestion of sprintSuggestions) {
2362
+ if (applyDependencySuggestions !== true || suggestion?.type !== "missing_sequential_dependency") {
2363
+ suggestions.push(suggestion);
2364
+ continue;
2365
+ }
2366
+ const task = taskMap.get(suggestion.taskId);
2367
+ const dependencyTask = taskMap.get(suggestion.dependencyTaskId);
2368
+ if (!task || !dependencyTask) continue;
2369
+ const currentDependencies = listTaskDependencyIds(task);
2370
+ if (currentDependencies.includes(suggestion.dependencyTaskId)) continue;
2371
+ task.dependencyTaskIds = uniqueStringList([...(task.dependencyTaskIds || []), suggestion.dependencyTaskId]);
2372
+ task.dependsOn = uniqueStringList([...(task.dependsOn || []), suggestion.dependencyTaskId]);
2373
+ dependencyTask.blockedByTaskIds = uniqueStringList([...(dependencyTask.blockedByTaskIds || []), task.id]);
2374
+ markTaskTouched(task, "dag-organize");
2375
+ markTaskTouched(dependencyTask, "dag-organize");
2376
+ appliedDependencySuggestionCount += 1;
2377
+ }
2378
+
2379
+ orderedTaskIds.forEach((taskId, index) => {
2380
+ const task = taskMap.get(taskId);
2381
+ if (!task) return;
2382
+ const nextOrder = index + 1;
2383
+ if (normalizeSprintOrder(task.sprintOrder) === nextOrder) return;
2384
+ task.sprintOrder = nextOrder;
2385
+ markTaskTouched(task, "dag-organize");
2386
+ updatedTaskCount += 1;
2387
+ });
2388
+ }
2389
+
2390
+ const allSprintIds = listSprints().map((sprint) => sprint.id);
2391
+ const sprintIncoming = new Map();
2392
+ const sprintOutgoing = new Map();
2393
+ for (const sprintId of allSprintIds) {
2394
+ sprintIncoming.set(sprintId, 0);
2395
+ sprintOutgoing.set(sprintId, new Set());
2396
+ }
2397
+ for (const task of allTasks) {
2398
+ const taskSprintId = normalizeSprintId(task?.sprintId);
2399
+ if (!taskSprintId) continue;
2400
+ for (const dependencyId of listTaskDependencyIds(task)) {
2401
+ const dependencyTask = taskMap.get(dependencyId);
2402
+ const dependencySprintId = normalizeSprintId(dependencyTask?.sprintId);
2403
+ if (!dependencySprintId || dependencySprintId === taskSprintId) continue;
2404
+ if (!sprintOutgoing.get(dependencySprintId)?.has(taskSprintId)) {
2405
+ sprintOutgoing.get(dependencySprintId).add(taskSprintId);
2406
+ sprintIncoming.set(taskSprintId, (sprintIncoming.get(taskSprintId) || 0) + 1);
2407
+ }
2408
+ }
2409
+ }
2410
+
2411
+ const orderedSprintIds = topoSortIds(
2412
+ allSprintIds,
2413
+ sprintIncoming,
2414
+ sprintOutgoing,
2415
+ (leftId, rightId) => compareSprintDagOrder(sprintMap[leftId], sprintMap[rightId]),
2416
+ );
2417
+ let updatedSprintCount = 0;
2418
+ if (!sprintFilter) {
2419
+ orderedSprintIds.forEach((sprintId, index) => {
2420
+ const sprint = sprintMap[sprintId];
2421
+ if (!sprint) return;
2422
+ const nextOrder = index + 1;
2423
+ if (normalizeSprintOrder(sprint.order) === nextOrder) return;
2424
+ sprint.order = nextOrder;
2425
+ sprint.updatedAt = now();
2426
+ updatedSprintCount += 1;
2427
+ });
2428
+ }
2429
+
2430
+ if (syncEpicDependencies) {
2431
+ const epicDependencyMap = ensureEpicDependenciesMap();
2432
+ const relevantTasks = sprintFilter
2433
+ ? allTasks.filter((task) => targetSprintIds.includes(normalizeSprintId(task?.sprintId)))
2434
+ : allTasks;
2435
+ const nextDependenciesByEpic = new Map(
2436
+ Object.entries(epicDependencyMap).map(([epicId, dependencyIds]) => [epicId, uniqueStringList(dependencyIds)]),
2437
+ );
2438
+
2439
+ for (const task of relevantTasks) {
2440
+ const taskEpicId = getTaskEpicId(task);
2441
+ if (!taskEpicId) continue;
2442
+ const currentDependencies = nextDependenciesByEpic.get(taskEpicId) || [];
2443
+ let nextDependencies = currentDependencies;
2444
+ for (const dependencyTaskId of listTaskDependencyIds(task)) {
2445
+ const dependencyTask = taskMap.get(dependencyTaskId);
2446
+ const dependencyEpicId = getTaskEpicId(dependencyTask);
2447
+ if (!dependencyEpicId || dependencyEpicId === taskEpicId) continue;
2448
+ if (nextDependencies.includes(dependencyEpicId)) continue;
2449
+ nextDependencies = uniqueStringList([...nextDependencies, dependencyEpicId]);
2450
+ }
2451
+ nextDependenciesByEpic.set(taskEpicId, nextDependencies);
2452
+ }
2453
+
2454
+ for (const [epicId, nextDependencies] of nextDependenciesByEpic.entries()) {
2455
+ const currentDependencies = uniqueStringList(epicDependencyMap[epicId] || []);
2456
+ if (
2457
+ currentDependencies.length === nextDependencies.length &&
2458
+ currentDependencies.every((dependencyId, index) => dependencyId === nextDependencies[index])
2459
+ ) {
2460
+ continue;
2461
+ }
2462
+ if (nextDependencies.length > 0) {
2463
+ epicDependencyMap[epicId] = nextDependencies;
2464
+ } else {
2465
+ delete epicDependencyMap[epicId];
2466
+ }
2467
+ syncedEpicDependencyCount += 1;
2468
+ }
2469
+ }
2470
+
2471
+ if (updatedTaskCount > 0 || updatedSprintCount > 0 || appliedDependencySuggestionCount > 0 || syncedEpicDependencyCount > 0) {
2472
+ saveStore();
2473
+ }
2474
+
2475
+ return {
2476
+ sprintId: sprintFilter || null,
2477
+ orderedSprintIds,
2478
+ orderedTaskIdsBySprint,
2479
+ updatedSprintCount,
2480
+ updatedTaskCount,
2481
+ appliedDependencySuggestionCount,
2482
+ syncedEpicDependencyCount,
2483
+ suggestions,
2484
+ };
2485
+ }
2486
+
2079
2487
  // ---------------------------------------------------------------------------
2080
2488
  // Agent tracking
2081
2489
  // ---------------------------------------------------------------------------
@@ -2423,4 +2831,4 @@ export function getStaleInReviewTasks(maxAgeMs) {
2423
2831
  (t) => t.status === "inreview" && t.lastActivityAt < cutoff,
2424
2832
  );
2425
2833
  }
2426
-
2834
+
@@ -1126,6 +1126,11 @@ let agentChatId = null; // latest chat where an agent is running
1126
1126
  const stickyMenuState = new Map();
1127
1127
  const stickyMenuTimers = new Map();
1128
1128
  const STICKY_MENU_BUMP_MS = 600;
1129
+ const callbackActionDeduper = new Map();
1130
+ const CALLBACK_ACTION_DEDUPE_MS = Math.max(
1131
+ 150,
1132
+ Number(process.env.TELEGRAM_CALLBACK_ACTION_DEDUPE_MS || "1200") || 1200,
1133
+ );
1129
1134
 
1130
1135
  // ── Queues ──────────────────────────────────────────────────────────────────
1131
1136
 
@@ -1383,6 +1388,18 @@ function setStickyMenuState(chatId, patch) {
1383
1388
  stickyMenuState.set(chatId, { ...current, ...patch });
1384
1389
  }
1385
1390
 
1391
+ function logTelegramStructured(event, payload = {}) {
1392
+ try {
1393
+ console.log(`[telegram-bot] ${JSON.stringify({
1394
+ event,
1395
+ at: new Date().toISOString(),
1396
+ ...payload,
1397
+ })}`);
1398
+ } catch {
1399
+ console.log(`[telegram-bot] ${event}`);
1400
+ }
1401
+ }
1402
+
1386
1403
  function clearStickyMenuTimer(chatId) {
1387
1404
  const timer = stickyMenuTimers.get(chatId);
1388
1405
  if (timer) {
@@ -1396,6 +1413,94 @@ function isStickyMenuInteractive(chatId) {
1396
1413
  return stickyMenuState.get(chatId)?.mode === "interactive";
1397
1414
  }
1398
1415
 
1416
+ function isMenuCallbackData(data) {
1417
+ if (typeof data !== "string") return false;
1418
+ return data.startsWith("ui:") || data.startsWith("cb:");
1419
+ }
1420
+
1421
+ function shouldRecoverStickyFromCallback(query) {
1422
+ const data = String(query?.data || "");
1423
+ if (!isMenuCallbackData(data)) return false;
1424
+ const messageId = query?.message?.message_id;
1425
+ const chatId = String(query?.message?.chat?.id || "");
1426
+ if (!chatId || !messageId) return false;
1427
+ const current = stickyMenuState.get(chatId);
1428
+ if (current?.enabled && current?.messageId) return false;
1429
+ return true;
1430
+ }
1431
+
1432
+ function recoverStickyMenuContextFromCallback(query, reason = "callback") {
1433
+ if (!shouldRecoverStickyFromCallback(query)) return false;
1434
+ const chatId = String(query.message.chat.id || "");
1435
+ const messageId = query.message.message_id;
1436
+ const data = String(query.data || "");
1437
+ const prev = stickyMenuState.get(chatId) || {};
1438
+ const screenId = prev.screenId || "home";
1439
+ const params = prev.params || {};
1440
+ const mode = data === "cb:dismiss" || data === "ui:cancel"
1441
+ ? "interactive"
1442
+ : "menu";
1443
+ setStickyMenuState(chatId, {
1444
+ enabled: true,
1445
+ messageId,
1446
+ screenId,
1447
+ params,
1448
+ mode,
1449
+ restoreScreenId: prev.restoreScreenId || screenId,
1450
+ restoreParams: prev.restoreParams || params,
1451
+ });
1452
+ logTelegramStructured("sticky_menu.context_recovered", {
1453
+ reason,
1454
+ chatId,
1455
+ messageId,
1456
+ mode,
1457
+ data,
1458
+ });
1459
+ return true;
1460
+ }
1461
+
1462
+ function pruneCallbackActionDeduper(now = Date.now()) {
1463
+ for (const [key, entry] of callbackActionDeduper.entries()) {
1464
+ if (!entry || now - entry.atMs > CALLBACK_ACTION_DEDUPE_MS) {
1465
+ callbackActionDeduper.delete(key);
1466
+ }
1467
+ }
1468
+ }
1469
+
1470
+ function dedupeMenuCallbackAction({
1471
+ chatId,
1472
+ fromId,
1473
+ messageId,
1474
+ data,
1475
+ callbackId,
1476
+ }) {
1477
+ if (!isMenuCallbackData(data)) return { duplicate: false };
1478
+ const now = Date.now();
1479
+ pruneCallbackActionDeduper(now);
1480
+ const key = [
1481
+ String(chatId || ""),
1482
+ String(fromId || ""),
1483
+ String(messageId || ""),
1484
+ String(data || ""),
1485
+ ].join("|");
1486
+ const prev = callbackActionDeduper.get(key);
1487
+ callbackActionDeduper.set(key, {
1488
+ atMs: now,
1489
+ callbackId: String(callbackId || ""),
1490
+ });
1491
+ if (!prev) return { duplicate: false };
1492
+ if (String(prev.callbackId || "") === String(callbackId || "")) {
1493
+ return { duplicate: false };
1494
+ }
1495
+ const ageMs = now - prev.atMs;
1496
+ if (ageMs > CALLBACK_ACTION_DEDUPE_MS) return { duplicate: false };
1497
+ return {
1498
+ duplicate: true,
1499
+ key,
1500
+ ageMs,
1501
+ };
1502
+ }
1503
+
1399
1504
  function getStickyMenuRestoreTarget(chatId) {
1400
1505
  const state = stickyMenuState.get(chatId) || {};
1401
1506
  return {
@@ -2607,6 +2712,7 @@ async function handleCallbackQuery(query) {
2607
2712
  const fromId = String(query.from?.id || "");
2608
2713
  const data = query.data || "";
2609
2714
  const callbackId = query.id;
2715
+ const messageId = query.message?.message_id || null;
2610
2716
 
2611
2717
  // Security: only accept from configured chat/user allow-list
2612
2718
  if (!isAuthorizedTelegramActor(chatId, fromId)) {
@@ -2614,6 +2720,27 @@ async function handleCallbackQuery(query) {
2614
2720
  return;
2615
2721
  }
2616
2722
 
2723
+ recoverStickyMenuContextFromCallback(query, "reconnect");
2724
+
2725
+ const dedupe = dedupeMenuCallbackAction({
2726
+ chatId,
2727
+ fromId,
2728
+ messageId,
2729
+ data,
2730
+ callbackId,
2731
+ });
2732
+ if (dedupe.duplicate) {
2733
+ logTelegramStructured("sticky_menu.callback_deduped", {
2734
+ chatId,
2735
+ fromId,
2736
+ messageId,
2737
+ data,
2738
+ ageMs: dedupe.ageMs,
2739
+ });
2740
+ await answerCallbackQuery(callbackId, "Already processing...");
2741
+ return;
2742
+ }
2743
+
2617
2744
  console.log(`[telegram-bot] callback: "${data}" from chat ${chatId}`);
2618
2745
 
2619
2746
  // Always acknowledge the callback to dismiss loading indicator