@s0nderlabs/anima-plugin-telegram 0.19.12 → 0.19.14

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": "@s0nderlabs/anima-plugin-telegram",
3
- "version": "0.19.12",
3
+ "version": "0.19.14",
4
4
  "type": "module",
5
5
  "description": "Telegram gateway plugin for anima — long-poll bot, debounced dispatch, reactions, allowlist",
6
6
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  "test": "bun test"
29
29
  },
30
30
  "dependencies": {
31
- "@s0nderlabs/anima-core": "0.19.12",
31
+ "@s0nderlabs/anima-core": "0.19.14",
32
32
  "grammy": "^1.42.0",
33
33
  "zod": "^3.23.8"
34
34
  }
@@ -0,0 +1,97 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { TelegramListener } from './listener'
6
+ import { acquireTelegramTokenLock } from './recovery'
7
+ import type { TelegramRuntimeContext } from './types'
8
+
9
+ let lockDir: string
10
+
11
+ beforeEach(() => {
12
+ lockDir = mkdtempSync(join(tmpdir(), 'anima-listener-retry-'))
13
+ })
14
+
15
+ afterEach(() => {
16
+ rmSync(lockDir, { recursive: true, force: true })
17
+ })
18
+
19
+ const FAKE_TOKEN = '999:does-not-call-network'
20
+
21
+ function makeOpts(): TelegramRuntimeContext & { lockRootDir: string; apiRoot: string } {
22
+ return {
23
+ botToken: FAKE_TOKEN,
24
+ allowedUserIds: [42],
25
+ agentName: 'retry-canary',
26
+ pairingStore: undefined,
27
+ dispatchUserMessage: async () => ({ response: 'ok' }),
28
+ onProcessingStart: async () => {},
29
+ onProcessingEnd: async () => {},
30
+ approvalBridge: undefined,
31
+ lockRootDir: lockDir,
32
+ // Point grammY at an unreachable host so any accidental network call
33
+ // would fail fast. We never reach bot.start() in these tests because
34
+ // the lock path returns first.
35
+ apiRoot: 'http://127.0.0.1:1',
36
+ }
37
+ }
38
+
39
+ describe('TelegramListener lock-retry', () => {
40
+ it('does NOT throw when the bot-token lock is held; retains running=false until the lock frees', async () => {
41
+ // Pre-occupy the lock. From the listener's perspective this is a
42
+ // zombie/leftover holder it must wait out.
43
+ const blocker = acquireTelegramTokenLock(FAKE_TOKEN, {
44
+ agentId: 'retry-canary',
45
+ rootDir: lockDir,
46
+ })
47
+
48
+ const listener = new TelegramListener(makeOpts())
49
+ // Pre-fix this would throw BotTokenLockedError synchronously after the
50
+ // build-runtime catch and never re-attempt. Now it must swallow,
51
+ // schedule a retry timer, and remain stoppable.
52
+ await expect(listener.start()).resolves.toBeUndefined()
53
+
54
+ // stop() should release whatever we held + cancel the retry timer.
55
+ await listener.stop()
56
+ blocker.release()
57
+ })
58
+
59
+ it('stop() cancels a pending retry without leaking timers', async () => {
60
+ const blocker = acquireTelegramTokenLock(FAKE_TOKEN, {
61
+ agentId: 'retry-canary',
62
+ rootDir: lockDir,
63
+ })
64
+ const listener = new TelegramListener(makeOpts())
65
+ await listener.start() // schedules retry because blocker holds the lock
66
+ // Immediately stop. If the retry timer wasn't unref'd / cleared the
67
+ // bun:test process would hang waiting for it (visible as a >30s test
68
+ // timeout; this assertion fails fast otherwise).
69
+ await listener.stop()
70
+ blocker.release()
71
+ // After stop+release, fresh acquisition by an outside caller works
72
+ // (no orphaned listener still holding the lock).
73
+ const now = acquireTelegramTokenLock(FAKE_TOKEN, {
74
+ agentId: 'retry-canary',
75
+ rootDir: lockDir,
76
+ })
77
+ expect(now).toBeDefined()
78
+ now.release()
79
+ })
80
+
81
+ it('lock-clear path: when the prior holder releases, the next start succeeds', async () => {
82
+ const prior = acquireTelegramTokenLock(FAKE_TOKEN, {
83
+ agentId: 'retry-canary',
84
+ rootDir: lockDir,
85
+ })
86
+ const listener = new TelegramListener(makeOpts())
87
+ await listener.start() // pending retry; lock not yet acquired
88
+ prior.release()
89
+ // Retry runs every 30s in production; we verify the lockfile state
90
+ // rather than waiting on real timers. Pending retry won't fire in this
91
+ // synchronous window, but the listener.stop() path must still succeed.
92
+ await listener.stop()
93
+ // After listener.stop() with retry pending and prior released, the
94
+ // lockfile dir is empty (no orphan).
95
+ expect(existsSync(join(lockDir, 'telegram-bot-token-cbae9eeaf0ee85c6.lock'))).toBe(false)
96
+ })
97
+ })
package/src/listener.ts CHANGED
@@ -9,6 +9,7 @@ import { formatPairingMessage } from './pairing-flow'
9
9
  import { reactError, reactProcessing, reactSuccess } from './reactions'
10
10
  import {
11
11
  BotTokenLockedError,
12
+ TELEGRAM_TOKEN_LOCK_SCOPE,
12
13
  type TokenLock,
13
14
  acquireTelegramTokenLock,
14
15
  classifyStartFailure,
@@ -29,6 +30,12 @@ import type { TelegramDispatchInput, TelegramRuntimeContext } from './types'
29
30
  * webhook, then boots grammy in long-poll mode. `stop()` releases the lock
30
31
  * and stops the bot. Both are idempotent.
31
32
  */
33
+ /** Retry cadence + cap when the TG bot-token lock is held by a (possibly
34
+ * zombie) prior holder. 12 × 30s = 6 minutes, comfortably past the 5-minute
35
+ * lock TTL so a stale-but-tenable lock auto-evicts. */
36
+ const RETRY_INTERVAL_MS = 30_000
37
+ const MAX_LOCK_RETRY_ATTEMPTS = 12
38
+
32
39
  export interface TelegramListenerOpts extends TelegramRuntimeContext {
33
40
  /** Optional override of the Telegram Bot API root. Used by the mock-bot test. */
34
41
  apiRoot?: string
@@ -49,6 +56,9 @@ export class TelegramListener {
49
56
  private running = false
50
57
  private tokenLock: TokenLock | null = null
51
58
  private refreshTimer: ReturnType<typeof setInterval> | null = null
59
+ private retryTimer: ReturnType<typeof setTimeout> | null = null
60
+ private retryAttempts = 0
61
+ private stopped = false
52
62
  private approvalResolver:
53
63
  | ((approvalId: string, choice: ApprovalChoice, fromUserId: number) => void)
54
64
  | null = null
@@ -110,7 +120,7 @@ export class TelegramListener {
110
120
  }
111
121
 
112
122
  async start(): Promise<void> {
113
- if (this.running) return
123
+ if (this.running || this.stopped) return
114
124
 
115
125
  try {
116
126
  this.tokenLock = acquireTelegramTokenLock(this.opts.botToken, {
@@ -118,12 +128,24 @@ export class TelegramListener {
118
128
  rootDir: this.opts.lockRootDir,
119
129
  })
120
130
  } catch (err) {
131
+ // Lock contention is recoverable: the prior holder may be a zombie or
132
+ // a stale lockfile from an ungraceful exit (see
133
+ // feedback-tg-token-lock-zombie-after-upgrade.md). Retry every 30s up
134
+ // to 12 attempts (6 minutes, past the 5-minute lock TTL) so we
135
+ // eventually reclaim once the existing entry expires. Without this,
136
+ // a single failed lock acquisition silenced the bot for the entire
137
+ // harness lifetime.
121
138
  if (err instanceof BotTokenLockedError) {
122
- console.warn(`[telegram] cannot start listener: ${err.message}`)
139
+ console.warn(
140
+ `[telegram] cannot start listener: ${err.message}; will retry in ${RETRY_INTERVAL_MS / 1000}s`,
141
+ )
142
+ this.scheduleStartRetry()
143
+ return
123
144
  }
124
145
  throw err
125
146
  }
126
147
 
148
+ this.retryAttempts = 0
127
149
  this.running = true
128
150
  console.log(`[telegram] listener.start() called for @${this.opts.agentName}`)
129
151
 
@@ -169,6 +191,11 @@ export class TelegramListener {
169
191
  }
170
192
 
171
193
  async stop(): Promise<void> {
194
+ this.stopped = true
195
+ if (this.retryTimer) {
196
+ clearTimeout(this.retryTimer)
197
+ this.retryTimer = null
198
+ }
172
199
  if (!this.running) {
173
200
  this.releaseLock()
174
201
  return
@@ -188,6 +215,23 @@ export class TelegramListener {
188
215
  this.releaseLock()
189
216
  }
190
217
 
218
+ private scheduleStartRetry(): void {
219
+ if (this.stopped) return
220
+ if (this.retryAttempts >= MAX_LOCK_RETRY_ATTEMPTS) {
221
+ console.error(
222
+ `[telegram] gave up acquiring bot-token lock after ${this.retryAttempts} attempts; manual intervention required (rm ~/.anima/locks/${TELEGRAM_TOKEN_LOCK_SCOPE}-*.lock)`,
223
+ )
224
+ return
225
+ }
226
+ if (this.retryTimer) clearTimeout(this.retryTimer)
227
+ this.retryAttempts += 1
228
+ this.retryTimer = setTimeout(() => {
229
+ this.retryTimer = null
230
+ void this.start()
231
+ }, RETRY_INTERVAL_MS)
232
+ this.retryTimer.unref?.()
233
+ }
234
+
191
235
  private releaseLock(): void {
192
236
  if (this.tokenLock) {
193
237
  try {