polygram 0.8.0-rc.37 → 0.8.0-rc.38

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.8.0-rc.37",
4
+ "version": "0.8.0-rc.38",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -120,7 +120,19 @@ function makePostToolBatchHook({ buffer, sessionKey, logEvent = null, chatId = n
120
120
  } catch { /* logger errors must not break the hook */ }
121
121
  }
122
122
  if (typeof onDrained === 'function') {
123
- try { onDrained(sessionKey, drained.length); }
123
+ // rc.38: async-safe. onDrained may return a Promise (it does
124
+ // today — clearAutosteeredReactions is async). A bare
125
+ // synchronous try/catch only catches throws, not rejections;
126
+ // an unhandled rejection escaping the hook would land on the
127
+ // process-level handler as misleading noise. Detect a
128
+ // thenable and attach .catch so async failures are logged at
129
+ // the same site, not as out-of-band unhandledRejection.
130
+ try {
131
+ const r = onDrained(sessionKey, drained.length);
132
+ if (r && typeof r.then === 'function') {
133
+ r.catch((err) => logger?.error?.(`[${sessionKey}] onDrained async: ${err?.message || err}`));
134
+ }
135
+ }
124
136
  catch (err) { logger?.error?.(`[${sessionKey}] onDrained: ${err?.message || err}`); }
125
137
  }
126
138
  return {
@@ -298,6 +298,14 @@ class ProcessManager {
298
298
  }
299
299
 
300
300
  async shutdown() {
301
+ // rc.38: mark "we're shutting down" so the proc.on('close') handler
302
+ // suppresses the misleading `resume-fail` event for signal-driven
303
+ // exits (SIGHUP from tmux pty close, SIGTERM from our own kill,
304
+ // SIGKILL from the kill-timeout escalator). Pre-rc.38 every deploy
305
+ // logged a `resume-fail` for every CLI-pm chat AND cleared the
306
+ // saved session_id, forcing a fresh resume on the next user turn
307
+ // — slower first turn, fresh context — for no real reason.
308
+ this._shuttingDown = true;
301
309
  const keys = Array.from(this.procs.keys());
302
310
  for (const key of keys) await this.kill(key);
303
311
  }
@@ -542,7 +550,22 @@ class ProcessManager {
542
550
  this.procs.delete(sessionKey);
543
551
  // A slot freed up → maybe an LRU waiter can run now.
544
552
  this._maybeSignalLruWaiter();
545
- if (code !== 0 && ctx.existingSessionId && this.db?.clearSessionId) {
553
+ // rc.38: only fire `resume-fail` for UNEXPECTED non-zero exits.
554
+ // Signal-driven exits during planned shutdown (SIGHUP from tmux
555
+ // pty close on `tmux kill-session`, SIGTERM from our own kill(),
556
+ // SIGKILL from the kill-timeout escalator) are NOT resume
557
+ // failures — the saved session_id is still valid, we'd just be
558
+ // clearing it for nothing and logging misleading noise on every
559
+ // deploy. The real signal we care about is "the CLI rejected a
560
+ // stale or corrupt resume id at startup with a non-zero exit
561
+ // while polygram is healthy."
562
+ const isPlannedShutdown = this._shuttingDown
563
+ || code === null // killed without an exit code
564
+ || code === 129 // SIGHUP (tmux pty close on deploy kickstart)
565
+ || code === 143 // SIGTERM (our own kill())
566
+ || code === 137; // SIGKILL (kill-timeout escalation)
567
+ if (code !== 0 && ctx.existingSessionId && this.db?.clearSessionId
568
+ && !isPlannedShutdown) {
546
569
  this._logEvent('resume-fail', { session_key: sessionKey, session_id: ctx.existingSessionId, code });
547
570
  try { this.db.clearSessionId(sessionKey); } catch (err) {
548
571
  this.logger.error(`[${entry.label}] clearSessionId failed: ${err.message}`);
@@ -551,6 +574,16 @@ class ProcessManager {
551
574
  if (this.onClose) this.onClose(sessionKey, code, entry);
552
575
  });
553
576
 
577
+ // rc.38: stdin error listener. Async EIO writes (the kernel reports
578
+ // them after the subprocess pipe closed during shutdown) had no
579
+ // listener pre-rc.38 → bubbled to the global uncaughtException
580
+ // handler → emitted misleading `uncaught-exception: write EIO`
581
+ // events on every deploy. Listening swallows that path; runtime
582
+ // stdin errors (rare; usually a real problem) still log here.
583
+ proc.stdin?.on?.('error', (err) => {
584
+ this.logger.error(`[${entry.label}] stdin error: ${err.message}`);
585
+ });
586
+
554
587
  proc.on('error', (err) => {
555
588
  this.logger.error(`[${entry.label}] proc error: ${err.message}`);
556
589
  entry.closed = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.8.0-rc.37",
3
+ "version": "0.8.0-rc.38",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc-client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -2532,6 +2532,14 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2532
2532
  // applyChain — so it serializes after any in-flight
2533
2533
  // QUEUED apply and lands as the final visible reaction.
2534
2534
  await reactor.setState('AUTOSTEERED');
2535
+ // rc.38: stop the reactor's STALL/TIMEOUT timers. Pre-rc.38
2536
+ // the timers stayed armed, holding setTimeout handles for
2537
+ // up to 30s and pinning the closure (and the bot/chatId
2538
+ // captures) until they fired. AUTOSTEERED is terminal — no
2539
+ // further state changes — so the timers serve no purpose
2540
+ // and just delay GC. One-line patch; small steady-state
2541
+ // heap relief in busy chats.
2542
+ reactor.stop();
2535
2543
  markReplied();
2536
2544
  return;
2537
2545
  }
@@ -2868,6 +2876,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2868
2876
  } finally {
2869
2877
  stopTyping();
2870
2878
  reactor.stop();
2879
+ // rc.38: defensive clear-on-exit for ✍ reactions. Pre-rc.38 only
2880
+ // the success path (line ~2622), the abort path (line ~2858), and
2881
+ // the tool-only-completion path (line ~2681) cleared
2882
+ // autosteeredRefs. The plain error path (`if (result.error)` →
2883
+ // throw at ~2612), the empty-response fallback failure (~2714),
2884
+ // and the streamer-overflow path could all leave ✍ reactions
2885
+ // stuck on follow-ups whose buffer entries had never been
2886
+ // drained by PostToolBatch. The clear is idempotent (the second
2887
+ // call returns 0 against an already-emptied map) so adding it
2888
+ // here covers ALL exit paths without double-clearing harm.
2889
+ clearAutosteeredReactions(sessionKey).catch(() => {});
2871
2890
  }
2872
2891
  }
2873
2892