talon-agent 1.42.1 → 1.42.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.42.1",
3
+ "version": "1.42.2",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -5,7 +5,11 @@
5
5
  */
6
6
 
7
7
  import { classify, friendlyMessage } from "../../../core/errors.js";
8
- import { recordMessageProcessed, recordError } from "../../../util/watchdog.js";
8
+ import {
9
+ recordMessageProcessed,
10
+ recordMessageReceived,
11
+ recordError,
12
+ } from "../../../util/watchdog.js";
9
13
  import { appendDailyLog } from "../../../storage/daily-log.js";
10
14
  import { log, logError } from "../../../util/log.js";
11
15
  import { processAndReply } from "./delivery.js";
@@ -24,6 +28,9 @@ export function enqueueMessage(
24
28
  numericChatId: number,
25
29
  msg: QueuedMessage,
26
30
  ): void {
31
+ // Tell the watchdog work has arrived — stuck detection compares this
32
+ // against processing completions (idle chats must never look wedged).
33
+ recordMessageReceived();
27
34
  const existing = messageQueues.get(chatId);
28
35
  if (existing) {
29
36
  if (existing.messages.length >= MAX_QUEUED_PER_CHAT) return;
@@ -14,7 +14,11 @@ import {
14
14
  getRecentHistory,
15
15
  type HistoryMessage,
16
16
  } from "../../../storage/history.js";
17
- import { recordMessageProcessed, recordError } from "../../../util/watchdog.js";
17
+ import {
18
+ recordMessageProcessed,
19
+ recordMessageReceived,
20
+ recordError,
21
+ } from "../../../util/watchdog.js";
18
22
  import { log, logError, logWarn, logDebug } from "../../../util/log.js";
19
23
  import { appendDailyLog } from "../../../storage/daily-log.js";
20
24
  import { processAndReply, sendHtml } from "./delivery.js";
@@ -75,6 +79,9 @@ export function enqueueMessage(
75
79
  numericChatId: number,
76
80
  msg: QueuedMessage,
77
81
  ): void {
82
+ // Tell the watchdog work has arrived — stuck detection compares this
83
+ // against processing completions (idle chats must never look wedged).
84
+ recordMessageReceived();
78
85
  const existing = messageQueues.get(chatId);
79
86
  if (existing) {
80
87
  if (existing.messages.length >= MAX_QUEUED_PER_CHAT) return; // drop excess
@@ -9,13 +9,46 @@ import { logWarn } from "./log.js";
9
9
  // ── Message processing tracking ──────────────────────────────────────────────
10
10
 
11
11
  let lastProcessedAt = Date.now();
12
+ let lastReceivedAt = 0;
12
13
  let totalMessagesProcessed = 0;
13
14
  const startTime = Date.now();
14
15
 
16
+ /**
17
+ * Record that a message ARRIVED (enqueued for processing). Paired with
18
+ * `recordMessageProcessed`, this is what lets the watchdog tell a wedged
19
+ * message loop (work arrived, nothing finishing) apart from a bot that's
20
+ * simply idle because nobody is talking to it. Idle is not a fault.
21
+ */
22
+ export function recordMessageReceived(): void {
23
+ lastReceivedAt = Date.now();
24
+ }
25
+
15
26
  /** Record that a message was successfully processed. */
16
27
  export function recordMessageProcessed(): void {
17
28
  lastProcessedAt = Date.now();
18
29
  totalMessagesProcessed++;
30
+ resetStuckWarnBackoff();
31
+ }
32
+
33
+ /**
34
+ * Test-only: reset the activity clocks to "just processed, nothing pending"
35
+ * at the CURRENT Date.now(). Fake-timer suites need this because each test
36
+ * restarts its fake clock at the real now, while timestamps recorded by an
37
+ * earlier test may sit hours into that test's own fake future.
38
+ */
39
+ export function resetWatchdogActivityForTests(): void {
40
+ lastProcessedAt = Date.now();
41
+ lastReceivedAt = 0;
42
+ resetStuckWarnBackoff();
43
+ }
44
+
45
+ /**
46
+ * How long the newest RECEIVED message has been waiting with no processing
47
+ * completed after it. Zero when idle or keeping up.
48
+ */
49
+ function stuckMs(now: number): number {
50
+ if (lastReceivedAt === 0 || lastReceivedAt <= lastProcessedAt) return 0;
51
+ return now - lastReceivedAt;
19
52
  }
20
53
 
21
54
  /** Get total messages processed since startup. */
@@ -53,17 +86,38 @@ export function getRecentErrors(limit = 5): ErrorRecord[] {
53
86
 
54
87
  // ── Inactivity monitoring ────────────────────────────────────────────────────
55
88
 
56
- const INACTIVITY_WARN_MS = 10 * 60 * 1000; // 10 minutes
89
+ const STUCK_WARN_MS = 10 * 60 * 1000; // 10 minutes with unprocessed work
90
+ const STUCK_WARN_MAX_BACKOFF_MS = 60 * 60 * 1000;
57
91
  let watchdogTimer: ReturnType<typeof setInterval> | null = null;
92
+ let stuckWarnBackoffMs = STUCK_WARN_MS;
93
+ let nextStuckWarnAt = 0;
94
+
95
+ function resetStuckWarnBackoff(): void {
96
+ stuckWarnBackoffMs = STUCK_WARN_MS;
97
+ nextStuckWarnAt = 0;
98
+ }
58
99
 
59
100
  export function startWatchdog(workspaceDir?: string): void {
60
101
  if (watchdogTimer) return;
61
102
 
62
103
  watchdogTimer = setInterval(() => {
63
- const elapsed = Date.now() - lastProcessedAt;
64
- if (totalMessagesProcessed > 0 && elapsed > INACTIVITY_WARN_MS) {
65
- const mins = Math.round(elapsed / 60000);
66
- logWarn("watchdog", `No messages processed for ${mins} minutes`);
104
+ // Warn only when work is actually stuck — a message arrived and nothing
105
+ // has finished processing since. A quiet chat used to trip this every
106
+ // minute all night ("No messages processed for N minutes"), burying real
107
+ // warnings in noise. Repeat warnings back off exponentially.
108
+ const now = Date.now();
109
+ const stuck = stuckMs(now);
110
+ if (stuck > STUCK_WARN_MS && now >= nextStuckWarnAt) {
111
+ const mins = Math.round(stuck / 60000);
112
+ logWarn(
113
+ "watchdog",
114
+ `Message received ${mins} minutes ago is still unprocessed — the message loop may be wedged`,
115
+ );
116
+ nextStuckWarnAt = now + stuckWarnBackoffMs;
117
+ stuckWarnBackoffMs = Math.min(
118
+ stuckWarnBackoffMs * 2,
119
+ STUCK_WARN_MAX_BACKOFF_MS,
120
+ );
67
121
  }
68
122
 
69
123
  // Ensure workspace still exists (might have been deleted externally)
@@ -101,8 +155,11 @@ export function getHealthStatus(): HealthStatus {
101
155
  const now = Date.now();
102
156
  const msSinceLastMessage = now - lastProcessedAt;
103
157
  return {
104
- // Unhealthy if no messages processed for 30+ minutes when we've seen at least one
105
- healthy: totalMessagesProcessed === 0 || msSinceLastMessage < 30 * 60_000,
158
+ // Unhealthy only when the message loop is actually stuck: a received
159
+ // message has gone 30+ minutes with no processing completing after it.
160
+ // Mere idleness (nobody texting the bot) used to flip this false and
161
+ // show a red "unhealthy" row in the companion diagnostics overnight.
162
+ healthy: stuckMs(now) < 30 * 60_000,
106
163
  uptimeMs: now - startTime,
107
164
  totalMessagesProcessed,
108
165
  lastProcessedAt,