polygram 0.20.2 → 0.21.0

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.21.0",
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",
@@ -90,6 +90,9 @@ function createAutoResumeTracker({ cooldownMs = DEFAULT_COOLDOWN_MS, now = Date.
90
90
  */
91
91
  function isAutoResumable({ error, aborted, replay, shuttingDown }) {
92
92
  if (aborted || replay || shuttingDown) return false;
93
+ // Delivery may have happened before the contained process tree disappeared.
94
+ // Automatic continuation could duplicate work; a user resend starts fresh.
95
+ if (error?.code === 'SESSION_PROCESS_LOST') return false;
93
96
  // Review F#6: channels analog of the tmux 'idle with no Claude activity'
94
97
  // pattern. The bridge socket dropped mid-turn (claude crashed, bridge
95
98
  // process died) — that's a wedge, not a runaway. Same intent as the
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
  });
@@ -184,6 +184,15 @@ const CODES = {
184
184
  isTransient: true,
185
185
  autoRecover: null,
186
186
  },
187
+ // The isolated process tree exited, so its persisted session cannot be
188
+ // resumed safely. The dispatcher clears that session before surfacing this
189
+ // guidance, and the user's resend starts a fresh process.
190
+ SESSION_PROCESS_LOST: {
191
+ kind: 'sessionProcessLost',
192
+ userMessage: '🔄 That Claude session stopped before the turn finished. Please resend your message and I\'ll start a fresh session.',
193
+ isTransient: false,
194
+ autoRecover: null,
195
+ },
187
196
  // CHANNELS_HANDSHAKE_TIMEOUT: bridge process never sent session_init
188
197
  // within the handshake window during start(). Usually means the bridge
189
198
  // crashed pre-init or the socket file is stale.
@@ -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,12 @@ 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;
197
+ let processLossResetFailed = false;
192
198
  logger.error?.(`[${sessionKey}] Error: ${err.message}`);
193
199
  // Mark the row terminal so the right thing happens on next
194
200
  // boot:
@@ -212,7 +218,28 @@ function createDispatcher({
212
218
  stack: err.stack?.split('\n').slice(0, 5).join('\n'),
213
219
  aborted: wasAborted || undefined,
214
220
  replay: isReplay || undefined,
221
+ oom_shutdown: oomShutdown || undefined,
215
222
  });
223
+ // A lost contained process cannot service a resume. Only promise a fresh
224
+ // session after the durable mapping has actually been removed.
225
+ if (err.code === 'SESSION_PROCESS_LOST') {
226
+ try {
227
+ if (typeof db.clearSessionId !== 'function') {
228
+ throw new Error('clearSessionId unavailable');
229
+ }
230
+ db.clearSessionId(sessionKey);
231
+ logEvent('session-reset-after-process-loss', {
232
+ chat_id: chatId, session_key: sessionKey, msg_id: msg?.message_id,
233
+ });
234
+ } catch (resetErr) {
235
+ processLossResetFailed = true;
236
+ logger.error?.(`[${sessionKey}] failed to clear lost session: ${resetErr.message}`);
237
+ logEvent('session-reset-after-process-loss-failed', {
238
+ chat_id: chatId, session_key: sessionKey, msg_id: msg?.message_id,
239
+ error: resetErr.message?.slice(0, 200),
240
+ });
241
+ }
242
+ }
216
243
  // Startup-gate death (claude exited during spawn / the dialog gate timed
217
244
  // out) of a likely-aged RESUMED session — the persisted claude_session_id
218
245
  // can't be resumed cleanly (shumorobot general chat 2026-06-01→03: a
@@ -330,9 +357,10 @@ function createDispatcher({
330
357
  // (not handleMessage), so its errors don't re-enter this
331
358
  // catch block — autoResumeTracker.isInCooldown() is the
332
359
  // only guard needed against runaway loops.
333
- const resumable = isAutoResumable({
334
- error: err, aborted: wasAborted, replay: isReplay, shuttingDown: isShuttingDown,
335
- });
360
+ const resumable = err.code !== 'SESSION_PROCESS_LOST'
361
+ && isAutoResumable({
362
+ error: err, aborted: wasAborted, replay: isReplay, shuttingDown: isShuttingDown,
363
+ });
336
364
  if (resumable && !autoResumeTracker.isInCooldown(sessionKey)) {
337
365
  autoResumeTracker.markAttempt(sessionKey);
338
366
  logEvent('auto-resume-attempted', {
@@ -387,7 +415,9 @@ function createDispatcher({
387
415
  }
388
416
  // 0.7.7: errorReplyText may return null (suppress reply
389
417
  // signal — INTERRUPTED inside abort grace).
390
- const replyText = errorReplyText(err);
418
+ const replyText = err.code === 'SESSION_PROCESS_LOST' && processLossResetFailed
419
+ ? '⚠️ That Claude session stopped, but I couldn\'t safely reset its saved state. Please try again later.'
420
+ : errorReplyText(err);
391
421
  if (replyText) {
392
422
  tg(bot, 'sendMessage', {
393
423
  chat_id: chatId,
@@ -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.21.0",
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": {
@@ -63,7 +63,7 @@
63
63
  "dependencies": {
64
64
  "@anthropic-ai/claude-agent-sdk": "0.2.123",
65
65
  "@modelcontextprotocol/sdk": "^1.29.0",
66
- "@shumkov/orchestra": "0.4.3",
66
+ "@shumkov/orchestra": "0.5.0",
67
67
  "better-sqlite3": "^12.9.0",
68
68
  "grammy": "^1.42.0",
69
69
  "markdown-it": "^14.1.1",
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;
@@ -2403,6 +2406,9 @@ async function main() {
2403
2406
  loadStickers();
2404
2407
  DB_PATH = dbOverride || path.join(DB_DIR, `${BOT_NAME}.db`);
2405
2408
  console.log(`[polygram] bot: ${BOT_NAME} (${Object.keys(config.chats).length} chats) db: ${DB_PATH}`);
2409
+ const sessionLauncher = process.env.ORCHESTRA_SESSION_LAUNCHER;
2410
+ const requireExistingServer = process.env.ORCHESTRA_TMUX_REQUIRE_SERVER === '1';
2411
+ console.log(`[polygram] session containment configured: ${sessionLauncher ? 'yes' : 'no'}`);
2406
2412
 
2407
2413
  // rc.50: claim our PID file BEFORE binding the bot token. If a
2408
2414
  // prior daemon (orphan from a botched restart) is still running,
@@ -2426,7 +2432,11 @@ async function main() {
2426
2432
  // sessions (dead safety net + EEXIST on cli respawn of a leaked session).
2427
2433
  const sweep = await sweepTmuxOrphans({
2428
2434
  botName: BOT_NAME,
2429
- runner: createTmuxRunner({ logger: console, sessionPrefix: 'polygram' }),
2435
+ runner: createTmuxRunner({
2436
+ logger: console,
2437
+ sessionPrefix: 'polygram',
2438
+ requireExistingServer,
2439
+ }),
2430
2440
  logger: console,
2431
2441
  });
2432
2442
  if (sweep.swept.length > 0) {
@@ -2469,6 +2479,10 @@ async function main() {
2469
2479
  db.logEvent('inbox-swept', { files: swept.swept, bytes: swept.bytes, retention_days: inboxRetentionMs / 86_400_000 });
2470
2480
  }
2471
2481
  db.logEvent('polygram-start', { migration: migration.reason, imported: migration.imported });
2482
+ if (cgroupOomObserver.startup.status === 'unavailable') {
2483
+ console.warn(`[oom-observer] unavailable (${cgroupOomObserver.startup.reason}); handled shutdown signals will retain clean-restart behavior`);
2484
+ db.logEvent('oom-observer-unavailable', { reason: cgroupOomObserver.startup.reason });
2485
+ }
2472
2486
  } catch (err) {
2473
2487
  console.error(`[db] FATAL: ${err.message}`);
2474
2488
  console.error('Bridge cannot run without a DB (Phase 2: DB is source of truth).');
@@ -2732,7 +2746,11 @@ async function main() {
2732
2746
  // tmux backend runner — one per daemon, shared across all TmuxProcess
2733
2747
  // instances. Construction is cheap (no system call until first
2734
2748
  // spawn/send). Only used if any chat in config has pm:'tmux'.
2735
- const tmuxRunner = createTmuxRunner({ logger: console, sessionPrefix: 'polygram' });
2749
+ const tmuxRunner = createTmuxRunner({
2750
+ logger: console,
2751
+ sessionPrefix: 'polygram',
2752
+ requireExistingServer,
2753
+ });
2736
2754
  // Verify the pinned claude CLI binary is present. The tmux
2737
2755
  // backend spawns this exact binary by absolute path (see
2738
2756
  // lib/claude-bin.js + TmuxProcess.start) — it never resolves
@@ -2794,6 +2812,7 @@ async function main() {
2794
2812
  db,
2795
2813
  logger: console,
2796
2814
  tmuxRunner,
2815
+ sessionLauncher,
2797
2816
  botName: BOT_NAME,
2798
2817
  // channels backend
2799
2818
  toolDispatcher: channelsToolDispatcher,
@@ -2908,6 +2927,7 @@ async function main() {
2908
2927
  chunkMarkdownText, deliverReplies,
2909
2928
  chunkBudget: TG_CHUNK_BUDGET,
2910
2929
  getIsShuttingDown: () => isShuttingDown,
2930
+ getOomObservation: () => cgroupOomObserver.sample(),
2911
2931
  authDisabledGate,
2912
2932
  logger: console,
2913
2933
  }));
@@ -2986,6 +3006,7 @@ async function main() {
2986
3006
  const SHUTDOWN_DRAIN_MS = 30_000;
2987
3007
  const shutdown = async () => {
2988
3008
  if (isShuttingDown) return;
3009
+ const shutdownObservation = cgroupOomObserver.sample();
2989
3010
  isShuttingDown = true;
2990
3011
  console.log('\nShutting down...');
2991
3012
  // 1. Stop accepting new inbound first so nothing new queues behind the drain.
@@ -3072,28 +3093,30 @@ async function main() {
3072
3093
  let remaining = 0;
3073
3094
  for (const n of inFlightHandlers.values()) remaining += n;
3074
3095
 
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.
3096
+ // 3. A handled signal is normally deliberate, but a Linux supervisor can
3097
+ // also stop the service after an OOM-killed child. Persist crash-like
3098
+ // state when the process cgroup recorded an OOM kill; otherwise retain
3099
+ // the clean-restart marker. Both paths mark in-flight work replayable in
3100
+ // the same transaction as the marker update.
3084
3101
  if (db) {
3085
3102
  try {
3086
- const res = db.recordCleanShutdown({ botName: BOT_NAME });
3103
+ const res = persistShutdownDisposition({
3104
+ db,
3105
+ botName: BOT_NAME,
3106
+ observation: shutdownObservation,
3107
+ });
3087
3108
  logEvent('shutdown-drain', {
3088
3109
  bot: BOT_NAME,
3089
3110
  in_flight: remaining,
3090
- replay_marked: res?.replayMarked ?? 0,
3111
+ replay_marked: res.replayMarked,
3091
3112
  elapsed_ms: drainElapsed,
3092
- clean: true,
3113
+ clean: res.clean,
3114
+ shutdown_reason: res.shutdownReason,
3115
+ ...(res.oomKillDelta != null ? { oom_kill_delta: res.oomKillDelta } : {}),
3093
3116
  });
3094
- console.log(`[shutdown] clean shutdown recorded; drained ${drainElapsed}ms, ${remaining} in-flight, ${res?.replayMarked ?? 0} marked replay-pending`);
3117
+ console.log(`[shutdown] ${res.clean ? 'clean' : 'crash-like'} shutdown recorded (${res.shutdownReason}); drained ${drainElapsed}ms, ${remaining} in-flight, ${res.replayMarked} marked replay-pending`);
3095
3118
  } catch (err) {
3096
- console.error(`[shutdown] recordCleanShutdown failed: ${err.message}`);
3119
+ console.error(`[shutdown] persistence failed: ${err.message}`);
3097
3120
  }
3098
3121
  }
3099
3122