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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/db/auto-resume.js +3 -0
- package/lib/db.js +42 -28
- package/lib/error/classify.js +9 -0
- package/lib/handlers/dispatcher.js +35 -5
- package/lib/ops/cgroup-oom-observer.js +139 -0
- package/lib/ops/shutdown-disposition.js +36 -0
- package/package.json +2 -2
- package/polygram.js +39 -16
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.
|
|
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",
|
package/lib/db/auto-resume.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
706
|
+
clearCleanShutdownMarkerStmt.run(botName);
|
|
693
707
|
}
|
|
694
708
|
return at;
|
|
695
709
|
});
|
package/lib/error/classify.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
334
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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({
|
|
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({
|
|
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.
|
|
3076
|
-
//
|
|
3077
|
-
//
|
|
3078
|
-
//
|
|
3079
|
-
//
|
|
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 =
|
|
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
|
|
3111
|
+
replay_marked: res.replayMarked,
|
|
3091
3112
|
elapsed_ms: drainElapsed,
|
|
3092
|
-
clean:
|
|
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
|
|
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]
|
|
3119
|
+
console.error(`[shutdown] persistence failed: ${err.message}`);
|
|
3097
3120
|
}
|
|
3098
3121
|
}
|
|
3099
3122
|
|