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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/db.js +42 -28
- package/lib/handlers/dispatcher.js +7 -1
- package/lib/ops/cgroup-oom-observer.js +139 -0
- package/lib/ops/shutdown-disposition.js +36 -0
- package/package.json +1 -1
- package/polygram.js +25 -14
|
@@ -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.
|
|
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
|
|
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
|
});
|
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
|
3099
|
+
replay_marked: res.replayMarked,
|
|
3091
3100
|
elapsed_ms: drainElapsed,
|
|
3092
|
-
clean:
|
|
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
|
|
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]
|
|
3107
|
+
console.error(`[shutdown] persistence failed: ${err.message}`);
|
|
3097
3108
|
}
|
|
3098
3109
|
}
|
|
3099
3110
|
|