polygram 0.20.2 → 0.20.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.20.2",
4
+ "version": "0.20.3",
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 plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
package/lib/db.js CHANGED
@@ -239,6 +239,38 @@ function wrap(db) {
239
239
  UPDATE messages SET status = 'failed', error = '${CRASHED_MID_SEND}'
240
240
  WHERE status = 'pending' AND ts < ? AND bot_name = ?
241
241
  `);
242
+ const markReplayPendingStmt = db.prepare(`
243
+ UPDATE messages SET handler_status = 'replay-pending'
244
+ WHERE direction = 'in'
245
+ AND handler_status IN ('dispatched', 'processing')
246
+ AND bot_name = ?
247
+ AND ts > ?
248
+ `);
249
+ const clearCleanShutdownMarkerStmt = db.prepare(`
250
+ UPDATE polling_state SET clean_shutdown_at = NULL
251
+ WHERE bot_name = ?
252
+ `);
253
+
254
+ function recordShutdown({ botName, now = Date.now(), since, clean }) {
255
+ const cutoff = since ?? now - 30 * 60 * 1000;
256
+ const txn = db.transaction(() => {
257
+ const marked = markReplayPendingStmt.run(botName, cutoff);
258
+ if (clean) {
259
+ db.prepare(`
260
+ INSERT INTO polling_state (bot_name, last_update_id, ts, clean_shutdown_at)
261
+ VALUES (?,
262
+ COALESCE((SELECT last_update_id FROM polling_state WHERE bot_name = ?), 0),
263
+ COALESCE((SELECT ts FROM polling_state WHERE bot_name = ?), ?),
264
+ ?)
265
+ ON CONFLICT(bot_name) DO UPDATE SET clean_shutdown_at = excluded.clean_shutdown_at
266
+ `).run(botName, botName, botName, now, now);
267
+ } else {
268
+ clearCleanShutdownMarkerStmt.run(botName);
269
+ }
270
+ return marked.changes;
271
+ });
272
+ return { replayMarked: txn() };
273
+ }
242
274
 
243
275
  return {
244
276
  raw: db,
@@ -635,13 +667,7 @@ function wrap(db) {
635
667
  // null to mark all dispatched/processing rows for a bot).
636
668
  markReplayPending({ botName, since }) {
637
669
  const cutoff = since ?? Date.now() - 30 * 60 * 1000;
638
- return db.prepare(`
639
- UPDATE messages SET handler_status = 'replay-pending'
640
- WHERE direction = 'in'
641
- AND handler_status IN ('dispatched', 'processing')
642
- AND bot_name = ?
643
- AND ts > ?
644
- `).run(botName, cutoff);
670
+ return markReplayPendingStmt.run(botName, cutoff);
645
671
  },
646
672
 
647
673
  // 0.14 boot-replay: record a DELIBERATE (clean) shutdown. Atomically, in ONE
@@ -657,26 +683,14 @@ function wrap(db) {
657
683
  // only on a non-empty getUpdates batch): COALESCE the existing values, else
658
684
  // seed (0, now).
659
685
  recordCleanShutdown({ botName, now = Date.now(), since } = {}) {
660
- const cutoff = since ?? now - 30 * 60 * 1000;
661
- const txn = db.transaction(() => {
662
- const marked = db.prepare(`
663
- UPDATE messages SET handler_status = 'replay-pending'
664
- WHERE direction = 'in'
665
- AND handler_status IN ('dispatched', 'processing')
666
- AND bot_name = ?
667
- AND ts > ?
668
- `).run(botName, cutoff);
669
- db.prepare(`
670
- INSERT INTO polling_state (bot_name, last_update_id, ts, clean_shutdown_at)
671
- VALUES (?,
672
- COALESCE((SELECT last_update_id FROM polling_state WHERE bot_name = ?), 0),
673
- COALESCE((SELECT ts FROM polling_state WHERE bot_name = ?), ?),
674
- ?)
675
- ON CONFLICT(bot_name) DO UPDATE SET clean_shutdown_at = excluded.clean_shutdown_at
676
- `).run(botName, botName, botName, now, now);
677
- return marked.changes;
678
- });
679
- return { replayMarked: txn() };
686
+ return recordShutdown({ botName, now, since, clean: true });
687
+ },
688
+
689
+ // A handled stop can still be crash-like when the supervisor is stopping
690
+ // the service after a child OOM. Mark current work replayable and clear any
691
+ // clean marker in one transaction so the next boot fails toward recovery.
692
+ recordCrashShutdown({ botName, now = Date.now(), since } = {}) {
693
+ return recordShutdown({ botName, now, since, clean: false });
680
694
  },
681
695
 
682
696
  // 0.14: read AND clear the clean-shutdown marker in one txn. "Clean" iff a
@@ -689,7 +703,7 @@ function wrap(db) {
689
703
  const row = db.prepare('SELECT clean_shutdown_at FROM polling_state WHERE bot_name = ?').get(botName);
690
704
  const at = row ? row.clean_shutdown_at : null;
691
705
  if (row && at != null) {
692
- db.prepare('UPDATE polling_state SET clean_shutdown_at = NULL WHERE bot_name = ?').run(botName);
706
+ clearCleanShutdownMarkerStmt.run(botName);
693
707
  }
694
708
  return at;
695
709
  });
@@ -69,6 +69,7 @@ function createDispatcher({
69
69
  authDisabledGate = createAuthDisabledGate(),
70
70
  // State accessors (need late binding because polygram.js mutates):
71
71
  getIsShuttingDown, // () → boolean
72
+ getOomObservation = () => null, // () → cgroup OOM observation
72
73
  logger = console,
73
74
  } = {}) {
74
75
  // Per-session in-flight handler count.
@@ -188,7 +189,11 @@ function createDispatcher({
188
189
  handleMessage(sessionKey, chatId, msg, bot).catch((err) => {
189
190
  const wasAborted = abortGrace.isRecent(sessionKey);
190
191
  const isReplay = msg._isReplay === true;
191
- const isShuttingDown = getIsShuttingDown();
192
+ let oomShutdown = false;
193
+ try {
194
+ oomShutdown = getOomObservation()?.detected === true;
195
+ } catch {}
196
+ const isShuttingDown = getIsShuttingDown() || oomShutdown;
192
197
  logger.error?.(`[${sessionKey}] Error: ${err.message}`);
193
198
  // Mark the row terminal so the right thing happens on next
194
199
  // boot:
@@ -212,6 +217,7 @@ function createDispatcher({
212
217
  stack: err.stack?.split('\n').slice(0, 5).join('\n'),
213
218
  aborted: wasAborted || undefined,
214
219
  replay: isReplay || undefined,
220
+ oom_shutdown: oomShutdown || undefined,
215
221
  });
216
222
  // Startup-gate death (claude exited during spawn / the dialog gate timed
217
223
  // out) of a likely-aged RESUMED session — the persisted claude_session_id
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const CGROUP_MEMBERSHIP_PATH = '/proc/self/cgroup';
7
+ const CGROUP_ROOT = '/sys/fs/cgroup';
8
+
9
+ function parseCgroupV2Path(contents) {
10
+ if (typeof contents !== 'string') throw new TypeError('cgroup membership must be text');
11
+ const matches = contents
12
+ .split(/\r?\n/)
13
+ .filter((line) => line.startsWith('0::'));
14
+ if (matches.length === 0) return null;
15
+ if (matches.length !== 1) throw new Error('multiple cgroup v2 memberships');
16
+
17
+ const cgroupPath = matches[0].slice(3);
18
+ if (!cgroupPath.startsWith('/')) throw new Error('cgroup v2 path is not absolute');
19
+ if (cgroupPath.includes('\0')) throw new Error('cgroup v2 path contains NUL');
20
+ if (/\s+\(deleted\)$/.test(cgroupPath)) throw new Error('cgroup v2 path is deleted');
21
+
22
+ const relative = cgroupPath.slice(1);
23
+ if (relative !== '') {
24
+ const components = relative.split('/');
25
+ if (components.some((component) => component === '' || component === '.' || component === '..')) {
26
+ throw new Error('cgroup v2 path contains an invalid component');
27
+ }
28
+ }
29
+ return cgroupPath;
30
+ }
31
+
32
+ function resolveMemoryEventsPath(cgroupPath, cgroupRoot = CGROUP_ROOT) {
33
+ if (typeof cgroupPath !== 'string' || !cgroupPath.startsWith('/')) {
34
+ throw new Error('cgroup v2 path is invalid');
35
+ }
36
+ const root = path.resolve(cgroupRoot);
37
+ const cgroupDir = path.resolve(root, cgroupPath.slice(1));
38
+ const relative = path.relative(root, cgroupDir);
39
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
40
+ throw new Error('cgroup v2 path escapes the cgroup root');
41
+ }
42
+ return path.join(cgroupDir, 'memory.events');
43
+ }
44
+
45
+ function parseOomKillCounter(contents) {
46
+ if (typeof contents !== 'string') throw new TypeError('memory.events must be text');
47
+ const values = [];
48
+ for (const line of contents.split(/\r?\n/)) {
49
+ const fields = line.trim().split(/\s+/);
50
+ if (fields[0] !== 'oom_kill') continue;
51
+ if (fields.length !== 2 || !/^\d+$/.test(fields[1])) {
52
+ throw new Error('memory.events oom_kill counter is invalid');
53
+ }
54
+ values.push(fields[1]);
55
+ }
56
+ if (values.length !== 1) {
57
+ throw new Error('memory.events oom_kill counter is missing or invalid');
58
+ }
59
+ return BigInt(values[0]);
60
+ }
61
+
62
+ function observation(status, {
63
+ detected = false,
64
+ delta = null,
65
+ reason = null,
66
+ } = {}) {
67
+ return Object.freeze({
68
+ status,
69
+ detected,
70
+ delta,
71
+ ...(reason ? { reason } : {}),
72
+ });
73
+ }
74
+
75
+ function createCgroupOomObserver({
76
+ platform = process.platform,
77
+ readFileSync = fs.readFileSync,
78
+ membershipPath = CGROUP_MEMBERSHIP_PATH,
79
+ cgroupRoot = CGROUP_ROOT,
80
+ } = {}) {
81
+ if (platform !== 'linux') {
82
+ const startup = observation('unsupported', { reason: 'platform' });
83
+ return { startup, sample: () => startup };
84
+ }
85
+
86
+ let cgroupPath;
87
+ try {
88
+ cgroupPath = parseCgroupV2Path(readFileSync(membershipPath, 'utf8'));
89
+ } catch {
90
+ const startup = observation('unavailable', { reason: 'membership-invalid' });
91
+ return { startup, sample: () => startup };
92
+ }
93
+ if (cgroupPath == null) {
94
+ const startup = observation('unsupported', { reason: 'cgroup-v2-unavailable' });
95
+ return { startup, sample: () => startup };
96
+ }
97
+
98
+ let memoryEventsPath;
99
+ try {
100
+ memoryEventsPath = resolveMemoryEventsPath(cgroupPath, cgroupRoot);
101
+ } catch {
102
+ const startup = observation('unavailable', { reason: 'membership-invalid' });
103
+ return { startup, sample: () => startup };
104
+ }
105
+
106
+ let baseline;
107
+ try {
108
+ baseline = parseOomKillCounter(readFileSync(memoryEventsPath, 'utf8'));
109
+ } catch {
110
+ const startup = observation('unavailable', { reason: 'memory-events-unavailable' });
111
+ return { startup, sample: () => startup };
112
+ }
113
+
114
+ const startup = observation('unchanged', { delta: 0n });
115
+ return {
116
+ startup,
117
+ sample() {
118
+ let current;
119
+ try {
120
+ current = parseOomKillCounter(readFileSync(memoryEventsPath, 'utf8'));
121
+ } catch {
122
+ return observation('unavailable', { reason: 'memory-events-unavailable' });
123
+ }
124
+ if (current < baseline) {
125
+ return observation('unavailable', { reason: 'oom-kill-counter-decreased' });
126
+ }
127
+ const delta = current - baseline;
128
+ if (delta > 0n) return observation('detected', { detected: true, delta });
129
+ return observation('unchanged', { delta });
130
+ },
131
+ };
132
+ }
133
+
134
+ module.exports = {
135
+ parseCgroupV2Path,
136
+ resolveMemoryEventsPath,
137
+ parseOomKillCounter,
138
+ createCgroupOomObserver,
139
+ };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ function shutdownReasonFor(observation) {
4
+ if (observation?.detected === true) return 'cgroup-oom-kill';
5
+ if (observation?.status === 'unchanged') return 'no-oom-delta';
6
+ if (observation?.status === 'unsupported') return 'oom-observer-unsupported';
7
+ return 'oom-observer-unavailable';
8
+ }
9
+
10
+ /**
11
+ * Persist the replay state associated with a handled shutdown signal.
12
+ *
13
+ * Keeping this decision outside the signal handler makes the shutdown
14
+ * classification independently testable while the caller retains ownership of
15
+ * draining processes and emitting telemetry.
16
+ */
17
+ function persistShutdownDisposition({
18
+ db,
19
+ botName,
20
+ now = Date.now(),
21
+ since,
22
+ observation,
23
+ } = {}) {
24
+ const oomDetected = observation?.detected === true;
25
+ const result = oomDetected
26
+ ? db.recordCrashShutdown({ botName, now, since })
27
+ : db.recordCleanShutdown({ botName, now, since });
28
+ return {
29
+ clean: !oomDetected,
30
+ shutdownReason: shutdownReasonFor(observation),
31
+ replayMarked: result?.replayMarked ?? 0,
32
+ ...(oomDetected ? { oomKillDelta: String(observation.delta) } : {}),
33
+ };
34
+ }
35
+
36
+ module.exports = { persistShutdownDisposition };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.20.2",
3
+ "version": "0.20.3",
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
@@ -129,6 +129,8 @@ const { classify: classifyError, classifyTurnEndError, detectWedgedSessionError,
129
129
  const { createAutoResumeTracker, isAutoResumable } = require('./lib/db/auto-resume');
130
130
  const { createAuthDisabledGate } = require('./lib/ops/auth-disabled-gate');
131
131
  const { createHeartbeat } = require('./lib/ops/heartbeat');
132
+ const { createCgroupOomObserver } = require('./lib/ops/cgroup-oom-observer');
133
+ const { persistShutdownDisposition } = require('./lib/ops/shutdown-disposition');
132
134
  const { resolveReplayWindowMs } = require('./lib/db/replay-window');
133
135
  const { pruneEvents, resolveRetentionPolicy, validatePolicy } = require('./lib/db/events-retention');
134
136
  const { sweepSecrets, resolveSecretSweepConfig } = require('./lib/db/secret-sweep');
@@ -2373,6 +2375,7 @@ let startPollWatchdog = null;
2373
2375
  // ─── Main ───────────────────────────────────────────────────────────
2374
2376
 
2375
2377
  async function main() {
2378
+ const cgroupOomObserver = createCgroupOomObserver();
2376
2379
  loadConfig();
2377
2380
 
2378
2381
  let dbOverride;
@@ -2469,6 +2472,10 @@ async function main() {
2469
2472
  db.logEvent('inbox-swept', { files: swept.swept, bytes: swept.bytes, retention_days: inboxRetentionMs / 86_400_000 });
2470
2473
  }
2471
2474
  db.logEvent('polygram-start', { migration: migration.reason, imported: migration.imported });
2475
+ if (cgroupOomObserver.startup.status === 'unavailable') {
2476
+ console.warn(`[oom-observer] unavailable (${cgroupOomObserver.startup.reason}); handled shutdown signals will retain clean-restart behavior`);
2477
+ db.logEvent('oom-observer-unavailable', { reason: cgroupOomObserver.startup.reason });
2478
+ }
2472
2479
  } catch (err) {
2473
2480
  console.error(`[db] FATAL: ${err.message}`);
2474
2481
  console.error('Bridge cannot run without a DB (Phase 2: DB is source of truth).');
@@ -2908,6 +2915,7 @@ async function main() {
2908
2915
  chunkMarkdownText, deliverReplies,
2909
2916
  chunkBudget: TG_CHUNK_BUDGET,
2910
2917
  getIsShuttingDown: () => isShuttingDown,
2918
+ getOomObservation: () => cgroupOomObserver.sample(),
2911
2919
  authDisabledGate,
2912
2920
  logger: console,
2913
2921
  }));
@@ -2986,6 +2994,7 @@ async function main() {
2986
2994
  const SHUTDOWN_DRAIN_MS = 30_000;
2987
2995
  const shutdown = async () => {
2988
2996
  if (isShuttingDown) return;
2997
+ const shutdownObservation = cgroupOomObserver.sample();
2989
2998
  isShuttingDown = true;
2990
2999
  console.log('\nShutting down...');
2991
3000
  // 1. Stop accepting new inbound first so nothing new queues behind the drain.
@@ -3072,28 +3081,30 @@ async function main() {
3072
3081
  let remaining = 0;
3073
3082
  for (const n of inFlightHandlers.values()) remaining += n;
3074
3083
 
3075
- // 3. This handler only runs on a DELIBERATE shutdown (SIGINT/SIGTERM/SIGHUP);
3076
- // a crash (SIGKILL/OOM/panic) never reaches here. Record a clean-shutdown
3077
- // marker AND mark any still-in-flight rows replay-pending, atomically, on
3078
- // EVERY clean shutdown (not just when in-flight>0) — boot uses the marker
3079
- // to SKIP re-answering stale messages on a deliberate restart while still
3080
- // recovering everything on a crash (0.14, §H1). markReplayPending alone
3081
- // (the old behavior) couldn't distinguish the two, so every deploy
3082
- // re-answered. A stale replay-pending row from a prior life would also be
3083
- // crash-recovered on a deliberate restart without the unconditional marker.
3084
+ // 3. A handled signal is normally deliberate, but a Linux supervisor can
3085
+ // also stop the service after an OOM-killed child. Persist crash-like
3086
+ // state when the process cgroup recorded an OOM kill; otherwise retain
3087
+ // the clean-restart marker. Both paths mark in-flight work replayable in
3088
+ // the same transaction as the marker update.
3084
3089
  if (db) {
3085
3090
  try {
3086
- const res = db.recordCleanShutdown({ botName: BOT_NAME });
3091
+ const res = persistShutdownDisposition({
3092
+ db,
3093
+ botName: BOT_NAME,
3094
+ observation: shutdownObservation,
3095
+ });
3087
3096
  logEvent('shutdown-drain', {
3088
3097
  bot: BOT_NAME,
3089
3098
  in_flight: remaining,
3090
- replay_marked: res?.replayMarked ?? 0,
3099
+ replay_marked: res.replayMarked,
3091
3100
  elapsed_ms: drainElapsed,
3092
- clean: true,
3101
+ clean: res.clean,
3102
+ shutdown_reason: res.shutdownReason,
3103
+ ...(res.oomKillDelta != null ? { oom_kill_delta: res.oomKillDelta } : {}),
3093
3104
  });
3094
- console.log(`[shutdown] clean shutdown recorded; drained ${drainElapsed}ms, ${remaining} in-flight, ${res?.replayMarked ?? 0} marked replay-pending`);
3105
+ console.log(`[shutdown] ${res.clean ? 'clean' : 'crash-like'} shutdown recorded (${res.shutdownReason}); drained ${drainElapsed}ms, ${remaining} in-flight, ${res.replayMarked} marked replay-pending`);
3095
3106
  } catch (err) {
3096
- console.error(`[shutdown] recordCleanShutdown failed: ${err.message}`);
3107
+ console.error(`[shutdown] persistence failed: ${err.message}`);
3097
3108
  }
3098
3109
  }
3099
3110