polygram 0.17.11 → 0.17.12
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/error/classify.js +14 -0
- package/lib/handlers/dispatcher.js +54 -1
- package/lib/handlers/drop-redeliver.js +19 -0
- package/lib/handlers/should-handle.js +52 -0
- package/lib/media-group-buffer.js +29 -0
- package/lib/ops/auth-disabled-gate.js +43 -0
- package/lib/ops/heartbeat.js +56 -0
- package/lib/process/channels-tool-dispatcher.js +12 -1
- package/lib/sdk/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/streamer.js +6 -1
- package/package.json +2 -1
- package/polygram.js +188 -41
- package/lib/async-lock.js +0 -49
- package/lib/claude-bin.js +0 -246
- package/lib/compaction-warn.js +0 -59
- package/lib/context-usage.js +0 -93
- package/lib/process/channels-bridge-protocol.js +0 -199
- package/lib/process/channels-bridge-server.js +0 -274
- package/lib/process/channels-bridge.mjs +0 -477
- package/lib/process/cli-process.js +0 -4029
- package/lib/process/factory.js +0 -215
- package/lib/process/hook-event-tail.js +0 -162
- package/lib/process/hook-settings.js +0 -181
- package/lib/process/polygram-hook-append.js +0 -71
- package/lib/process/process.js +0 -215
- package/lib/process/sdk-process.js +0 -880
- package/lib/process-guard.js +0 -296
- package/lib/process-manager.js +0 -628
- package/lib/tmux/log-tail.js +0 -334
- package/lib/tmux/orphan-sweep.js +0 -79
- package/lib/tmux/poll-scheduler.js +0 -110
- package/lib/tmux/startup-gate.js +0 -250
- package/lib/tmux/tmux-runner.js +0 -412
package/lib/process-guard.js
DELETED
|
@@ -1,296 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* rc.50: process-guard helpers — orphan-detection PID file + safety
|
|
3
|
-
* handlers for uncaughtException / unhandledRejection that don't
|
|
4
|
-
* re-enter on broken stdout.
|
|
5
|
-
*
|
|
6
|
-
* Background — the rc.50 incident:
|
|
7
|
-
* PID 6335 (rc.48) was orphaned when its tmux pane was destroyed
|
|
8
|
-
* during `launchctl kickstart -k`. polygram's existing SIGHUP
|
|
9
|
-
* handler should have drained cleanly, but during the drain
|
|
10
|
-
* `console.error` inside the uncaughtException handler itself
|
|
11
|
-
* threw EIO (stdout was wired to a now-destroyed pty). That fired
|
|
12
|
-
* the same handler, which logged again, which threw EIO again — a
|
|
13
|
-
* tight re-entrant loop that hijacked the event loop and prevented
|
|
14
|
-
* shutdown from completing. The orphan ran for 3+ hours writing
|
|
15
|
-
* 3.59M+ uncaught-exception rows to the DB at ~12k/sec, and
|
|
16
|
-
* polled the same Telegram bot token in parallel with the new
|
|
17
|
-
* daemon.
|
|
18
|
-
*
|
|
19
|
-
* This module provides three primitives. polygram.js wires them
|
|
20
|
-
* together at boot.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
'use strict';
|
|
24
|
-
|
|
25
|
-
const fs = require('fs');
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Boot-time orphan detection. Writes our PID to `pidPath`. If the
|
|
29
|
-
* file already exists with a different live PID, kill it before
|
|
30
|
-
* proceeding (SIGTERM, then SIGKILL after `sigtermWaitMs`). Without
|
|
31
|
-
* this, two daemons can end up sharing the same Telegram bot token
|
|
32
|
-
* and SQLite DB — the cascade that made the rc.50 incident
|
|
33
|
-
* production-visible.
|
|
34
|
-
*
|
|
35
|
-
* @returns {{ priorPid: number|null, priorAction: string }}
|
|
36
|
-
*/
|
|
37
|
-
function claimPidFile(pidPath, { logger = console, sigtermWaitMs = 2000 } = {}) {
|
|
38
|
-
const ownPid = process.pid;
|
|
39
|
-
let priorPid = null;
|
|
40
|
-
let priorAction = 'no-prior';
|
|
41
|
-
|
|
42
|
-
if (fs.existsSync(pidPath)) {
|
|
43
|
-
const raw = (() => {
|
|
44
|
-
try { return fs.readFileSync(pidPath, 'utf8').trim(); }
|
|
45
|
-
catch { return ''; }
|
|
46
|
-
})();
|
|
47
|
-
const parsed = /^\d+$/.test(raw) ? parseInt(raw, 10) : null;
|
|
48
|
-
if (!parsed) {
|
|
49
|
-
priorAction = 'malformed-overwritten';
|
|
50
|
-
} else if (parsed === ownPid) {
|
|
51
|
-
// Re-entrant call from same process — write but don't kill self.
|
|
52
|
-
priorPid = parsed;
|
|
53
|
-
priorAction = 'self-skip';
|
|
54
|
-
} else {
|
|
55
|
-
priorPid = parsed;
|
|
56
|
-
const alive = isAlive(parsed);
|
|
57
|
-
if (!alive) {
|
|
58
|
-
priorAction = 'stale-overwritten';
|
|
59
|
-
} else {
|
|
60
|
-
logger.log?.(`[orphan-guard] prior daemon PID ${parsed} still alive — sending SIGTERM`);
|
|
61
|
-
try { process.kill(parsed, 'SIGTERM'); } catch {}
|
|
62
|
-
const start = Date.now();
|
|
63
|
-
while (Date.now() - start < sigtermWaitMs && isAlive(parsed)) {
|
|
64
|
-
// Busy-wait. Boot is single-threaded; we have nothing else to do
|
|
65
|
-
// until the orphan is gone, and we don't want to bind the bot
|
|
66
|
-
// token while it's still polling. sigtermWaitMs is configurable
|
|
67
|
-
// (default 2s; tests override to 100ms).
|
|
68
|
-
sleepSync(50);
|
|
69
|
-
}
|
|
70
|
-
if (isAlive(parsed)) {
|
|
71
|
-
logger.log?.(`[orphan-guard] PID ${parsed} ignored SIGTERM — escalating to SIGKILL`);
|
|
72
|
-
try { process.kill(parsed, 'SIGKILL'); } catch {}
|
|
73
|
-
// Poll for actual death — SIGKILL is delivered async, the
|
|
74
|
-
// kernel may take a tick to reap (esp. for detached children).
|
|
75
|
-
const killStart = Date.now();
|
|
76
|
-
while (Date.now() - killStart < 1000 && isAlive(parsed)) {
|
|
77
|
-
sleepSync(20);
|
|
78
|
-
}
|
|
79
|
-
priorAction = 'sigkill-killed';
|
|
80
|
-
} else {
|
|
81
|
-
priorAction = 'sigterm-killed';
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
fs.writeFileSync(pidPath, String(ownPid) + '\n', { mode: 0o600 });
|
|
88
|
-
return { priorPid, priorAction };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Delete the PID file on clean shutdown. Only deletes if the file
|
|
93
|
-
* still contains OUR PID — protects against the race where a new
|
|
94
|
-
* daemon already claimed the file and rewrote it before we got here.
|
|
95
|
-
*/
|
|
96
|
-
function releasePidFile(pidPath) {
|
|
97
|
-
if (!fs.existsSync(pidPath)) return;
|
|
98
|
-
try {
|
|
99
|
-
const content = fs.readFileSync(pidPath, 'utf8').trim();
|
|
100
|
-
if (content === String(process.pid)) {
|
|
101
|
-
fs.unlinkSync(pidPath);
|
|
102
|
-
}
|
|
103
|
-
// Else: another daemon owns it now. Leaving alone is correct.
|
|
104
|
-
} catch {}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Build an uncaughtException handler that:
|
|
109
|
-
* 1. Wraps `logger.error` AND `logEvent` in try/catch — neither
|
|
110
|
-
* can re-throw out of the handler. (Pre-rc.50 the bare
|
|
111
|
-
* console.error threw EIO and re-fired this same handler in
|
|
112
|
-
* an event-loop-hijacking loop.)
|
|
113
|
-
* 2. Tracks repetitions of the same exception message in a sliding
|
|
114
|
-
* window. If the same message fires `eioThreshold` times within
|
|
115
|
-
* `eioWindowMs`, calls `panicExit(2)` so launchd restarts us
|
|
116
|
-
* cleanly. Without the circuit breaker, a stuck-stdout EIO
|
|
117
|
-
* cascade just keeps writing rows forever.
|
|
118
|
-
*
|
|
119
|
-
* @param {object} opts
|
|
120
|
-
* @param {object} opts.logger - { error(msg) } sink for human-readable logs.
|
|
121
|
-
* @param {function(string, object)} opts.logEvent - DB persist sink.
|
|
122
|
-
* @param {string} opts.botName
|
|
123
|
-
* @param {number} [opts.eioThreshold=100]
|
|
124
|
-
* @param {number} [opts.eioWindowMs=5000]
|
|
125
|
-
* @param {function(number)} [opts.panicExit=process.exit]
|
|
126
|
-
* @param {function(): number} [opts.now=Date.now]
|
|
127
|
-
* @returns {function(Error)}
|
|
128
|
-
*/
|
|
129
|
-
function _makeUncaughtHandler({
|
|
130
|
-
logger,
|
|
131
|
-
logEvent,
|
|
132
|
-
botName,
|
|
133
|
-
eioThreshold = 100,
|
|
134
|
-
eioWindowMs = 5000,
|
|
135
|
-
panicExit = (code) => process.exit(code),
|
|
136
|
-
now = Date.now,
|
|
137
|
-
} = {}) {
|
|
138
|
-
// Per-message sliding-window timestamps. Map<message, number[]>.
|
|
139
|
-
const recent = new Map();
|
|
140
|
-
let panicked = false;
|
|
141
|
-
|
|
142
|
-
return function uncaughtHandler(err) {
|
|
143
|
-
if (panicked) return; // bail — we're on our way out
|
|
144
|
-
const msg = String(err?.message || err || 'unknown').slice(0, 500);
|
|
145
|
-
const stack = err?.stack?.split('\n').slice(0, 5).join('\n') || '';
|
|
146
|
-
|
|
147
|
-
// 1. Log defensively. Stdout may be broken (the original incident);
|
|
148
|
-
// must not re-throw out of this handler.
|
|
149
|
-
try {
|
|
150
|
-
logger?.error?.(`[polygram] uncaughtException: ${msg}\n${stack}`);
|
|
151
|
-
} catch { /* swallow — broken stdout */ }
|
|
152
|
-
|
|
153
|
-
// 2. Persist defensively. DB might be closing during shutdown.
|
|
154
|
-
try {
|
|
155
|
-
logEvent?.('uncaught-exception', { message: msg, bot_name: botName });
|
|
156
|
-
} catch { /* swallow */ }
|
|
157
|
-
|
|
158
|
-
// 3. Storm circuit breaker: same message N times in window → exit.
|
|
159
|
-
const t = now();
|
|
160
|
-
let timestamps = recent.get(msg);
|
|
161
|
-
if (!timestamps) { timestamps = []; recent.set(msg, timestamps); }
|
|
162
|
-
timestamps.push(t);
|
|
163
|
-
// Drop expired.
|
|
164
|
-
while (timestamps.length && t - timestamps[0] > eioWindowMs) timestamps.shift();
|
|
165
|
-
if (timestamps.length >= eioThreshold) {
|
|
166
|
-
panicked = true;
|
|
167
|
-
try {
|
|
168
|
-
logger?.error?.(`[polygram] uncaughtException circuit breaker: ${timestamps.length}× "${msg}" in ${eioWindowMs}ms — exit(2)`);
|
|
169
|
-
} catch {}
|
|
170
|
-
try {
|
|
171
|
-
logEvent?.('panic-exit', { message: msg, count: timestamps.length, window_ms: eioWindowMs, bot_name: botName });
|
|
172
|
-
} catch {}
|
|
173
|
-
panicExit(2);
|
|
174
|
-
}
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Build a parallel handler for unhandledRejection: same defensive
|
|
179
|
-
// posture, separate counter (rejections and exceptions can come
|
|
180
|
-
// from different code paths and shouldn't share a budget).
|
|
181
|
-
function _makeUnhandledRejectionHandler(opts) {
|
|
182
|
-
const inner = _makeUncaughtHandler({
|
|
183
|
-
...opts,
|
|
184
|
-
// Override the 'kind' written to events table.
|
|
185
|
-
logEvent: opts.logEvent
|
|
186
|
-
? (kind, detail) => opts.logEvent(kind === 'panic-exit' ? 'panic-exit' : 'unhandled-rejection', detail)
|
|
187
|
-
: undefined,
|
|
188
|
-
});
|
|
189
|
-
return (reason /* , promise */) => {
|
|
190
|
-
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
191
|
-
inner(err);
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Swallow EPIPE/EIO on the process's own stdout/stderr so a broken pipe during
|
|
197
|
-
* shutdown can't become an uncaughtException.
|
|
198
|
-
*
|
|
199
|
-
* rc.50 stopped the re-entrant LOOP (the handler no longer re-throws), but the
|
|
200
|
-
* write errors themselves still surface: when `launchctl kickstart` destroys the
|
|
201
|
-
* tmux pane, in-flight `console.log`/`console.error` calls hit a now-dead pty.
|
|
202
|
-
* Because stdout/stderr are TTYs, those writes throw EIO **synchronously** — each
|
|
203
|
-
* unguarded throw becomes an uncaughtException. Observed live on the rc.29→rc.30
|
|
204
|
-
* restart (2026-06-08): 100 `write EIO` rows then a circuit-breaker panic-exit on
|
|
205
|
-
* every deploy, interrupting the graceful drain.
|
|
206
|
-
*
|
|
207
|
-
* This guards BOTH delivery paths:
|
|
208
|
-
* (a) sync: wraps `write()` to drop EPIPE/EIO throws (TTY case — the real one);
|
|
209
|
-
* (b) async: attaches an `error` listener for the pipe case (errors arrive as
|
|
210
|
-
* events). Genuine, non-EPIPE/EIO errors still surface unchanged.
|
|
211
|
-
*
|
|
212
|
-
* @returns {{ uninstall: function() }}
|
|
213
|
-
*/
|
|
214
|
-
function guardStdio({ streams = [process.stdout, process.stderr] } = {}) {
|
|
215
|
-
const guarded = streams.filter(Boolean);
|
|
216
|
-
const isBrokenPipe = (err) => err && (err.code === 'EPIPE' || err.code === 'EIO');
|
|
217
|
-
const onError = (err) => { if (isBrokenPipe(err)) return; throw err; };
|
|
218
|
-
const restores = [];
|
|
219
|
-
|
|
220
|
-
for (const s of guarded) {
|
|
221
|
-
s.on?.('error', onError);
|
|
222
|
-
restores.push(() => s.off?.('error', onError));
|
|
223
|
-
|
|
224
|
-
if (typeof s.write === 'function' && !s.__polygramStdioGuarded) {
|
|
225
|
-
const origWrite = s.write;
|
|
226
|
-
s.write = function guardedWrite(...args) {
|
|
227
|
-
try {
|
|
228
|
-
return origWrite.apply(this, args);
|
|
229
|
-
} catch (err) {
|
|
230
|
-
if (!isBrokenPipe(err)) throw err;
|
|
231
|
-
// Pane is gone — nothing to write to. Invoke the write callback (the
|
|
232
|
-
// last arg, if any) so callers awaiting it don't hang, and report
|
|
233
|
-
// backpressure (false) instead of throwing to uncaughtException.
|
|
234
|
-
const cb = args[args.length - 1];
|
|
235
|
-
if (typeof cb === 'function') { try { cb(err); } catch { /* ignore */ } }
|
|
236
|
-
return false;
|
|
237
|
-
}
|
|
238
|
-
};
|
|
239
|
-
s.__polygramStdioGuarded = true;
|
|
240
|
-
restores.push(() => { s.write = origWrite; delete s.__polygramStdioGuarded; });
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return { uninstall() { for (const r of restores) r(); } };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Convenience: install both handlers in one call (plus the stdio guard, so the
|
|
249
|
-
* shutdown broken-pipe writes never reach the uncaughtException handler).
|
|
250
|
-
* @returns {{ uninstall: function() }}
|
|
251
|
-
*/
|
|
252
|
-
function installSafetyHandlers(opts) {
|
|
253
|
-
const onException = _makeUncaughtHandler(opts);
|
|
254
|
-
const onRejection = _makeUnhandledRejectionHandler(opts);
|
|
255
|
-
process.on('uncaughtException', onException);
|
|
256
|
-
process.on('unhandledRejection', onRejection);
|
|
257
|
-
const stdio = guardStdio();
|
|
258
|
-
return {
|
|
259
|
-
uninstall() {
|
|
260
|
-
process.off('uncaughtException', onException);
|
|
261
|
-
process.off('unhandledRejection', onRejection);
|
|
262
|
-
stdio.uninstall();
|
|
263
|
-
},
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// ─── helpers ─────────────────────────────────────────────────────────
|
|
268
|
-
|
|
269
|
-
function isAlive(pid) {
|
|
270
|
-
try {
|
|
271
|
-
process.kill(pid, 0);
|
|
272
|
-
return true;
|
|
273
|
-
} catch (err) {
|
|
274
|
-
// ESRCH = no such process. EPERM = exists but we lack rights
|
|
275
|
-
// (treat as alive — same UID typically; we will fail to kill it
|
|
276
|
-
// but at least we know it's there).
|
|
277
|
-
if (err.code === 'EPERM') return true;
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function sleepSync(ms) {
|
|
283
|
-
// Atomics-based busy-wait. 50ms granularity is fine for boot
|
|
284
|
-
// orphan-killing; we're not in a hot path.
|
|
285
|
-
const buf = new Int32Array(new SharedArrayBuffer(4));
|
|
286
|
-
Atomics.wait(buf, 0, 0, ms);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
module.exports = {
|
|
290
|
-
claimPidFile,
|
|
291
|
-
releasePidFile,
|
|
292
|
-
installSafetyHandlers,
|
|
293
|
-
guardStdio,
|
|
294
|
-
_makeUncaughtHandler,
|
|
295
|
-
_makeUnhandledRejectionHandler,
|
|
296
|
-
};
|