@shumkov/orchestra 0.2.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.
@@ -0,0 +1,297 @@
1
+ // provenance: polygram@0.17.11 lib/process-guard.js (git 746bca6) — verbatim: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * rc.50: process-guard helpers — orphan-detection PID file + safety
4
+ * handlers for uncaughtException / unhandledRejection that don't
5
+ * re-enter on broken stdout.
6
+ *
7
+ * Background — the rc.50 incident:
8
+ * PID 6335 (rc.48) was orphaned when its tmux pane was destroyed
9
+ * during `launchctl kickstart -k`. polygram's existing SIGHUP
10
+ * handler should have drained cleanly, but during the drain
11
+ * `console.error` inside the uncaughtException handler itself
12
+ * threw EIO (stdout was wired to a now-destroyed pty). That fired
13
+ * the same handler, which logged again, which threw EIO again — a
14
+ * tight re-entrant loop that hijacked the event loop and prevented
15
+ * shutdown from completing. The orphan ran for 3+ hours writing
16
+ * 3.59M+ uncaught-exception rows to the DB at ~12k/sec, and
17
+ * polled the same Telegram bot token in parallel with the new
18
+ * daemon.
19
+ *
20
+ * This module provides three primitives. polygram.js wires them
21
+ * together at boot.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('fs');
27
+
28
+ /**
29
+ * Boot-time orphan detection. Writes our PID to `pidPath`. If the
30
+ * file already exists with a different live PID, kill it before
31
+ * proceeding (SIGTERM, then SIGKILL after `sigtermWaitMs`). Without
32
+ * this, two daemons can end up sharing the same Telegram bot token
33
+ * and SQLite DB — the cascade that made the rc.50 incident
34
+ * production-visible.
35
+ *
36
+ * @returns {{ priorPid: number|null, priorAction: string }}
37
+ */
38
+ function claimPidFile(pidPath, { logger = console, sigtermWaitMs = 2000 } = {}) {
39
+ const ownPid = process.pid;
40
+ let priorPid = null;
41
+ let priorAction = 'no-prior';
42
+
43
+ if (fs.existsSync(pidPath)) {
44
+ const raw = (() => {
45
+ try { return fs.readFileSync(pidPath, 'utf8').trim(); }
46
+ catch { return ''; }
47
+ })();
48
+ const parsed = /^\d+$/.test(raw) ? parseInt(raw, 10) : null;
49
+ if (!parsed) {
50
+ priorAction = 'malformed-overwritten';
51
+ } else if (parsed === ownPid) {
52
+ // Re-entrant call from same process — write but don't kill self.
53
+ priorPid = parsed;
54
+ priorAction = 'self-skip';
55
+ } else {
56
+ priorPid = parsed;
57
+ const alive = isAlive(parsed);
58
+ if (!alive) {
59
+ priorAction = 'stale-overwritten';
60
+ } else {
61
+ logger.log?.(`[orphan-guard] prior daemon PID ${parsed} still alive — sending SIGTERM`);
62
+ try { process.kill(parsed, 'SIGTERM'); } catch {}
63
+ const start = Date.now();
64
+ while (Date.now() - start < sigtermWaitMs && isAlive(parsed)) {
65
+ // Busy-wait. Boot is single-threaded; we have nothing else to do
66
+ // until the orphan is gone, and we don't want to bind the bot
67
+ // token while it's still polling. sigtermWaitMs is configurable
68
+ // (default 2s; tests override to 100ms).
69
+ sleepSync(50);
70
+ }
71
+ if (isAlive(parsed)) {
72
+ logger.log?.(`[orphan-guard] PID ${parsed} ignored SIGTERM — escalating to SIGKILL`);
73
+ try { process.kill(parsed, 'SIGKILL'); } catch {}
74
+ // Poll for actual death — SIGKILL is delivered async, the
75
+ // kernel may take a tick to reap (esp. for detached children).
76
+ const killStart = Date.now();
77
+ while (Date.now() - killStart < 1000 && isAlive(parsed)) {
78
+ sleepSync(20);
79
+ }
80
+ priorAction = 'sigkill-killed';
81
+ } else {
82
+ priorAction = 'sigterm-killed';
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ fs.writeFileSync(pidPath, String(ownPid) + '\n', { mode: 0o600 });
89
+ return { priorPid, priorAction };
90
+ }
91
+
92
+ /**
93
+ * Delete the PID file on clean shutdown. Only deletes if the file
94
+ * still contains OUR PID — protects against the race where a new
95
+ * daemon already claimed the file and rewrote it before we got here.
96
+ */
97
+ function releasePidFile(pidPath) {
98
+ if (!fs.existsSync(pidPath)) return;
99
+ try {
100
+ const content = fs.readFileSync(pidPath, 'utf8').trim();
101
+ if (content === String(process.pid)) {
102
+ fs.unlinkSync(pidPath);
103
+ }
104
+ // Else: another daemon owns it now. Leaving alone is correct.
105
+ } catch {}
106
+ }
107
+
108
+ /**
109
+ * Build an uncaughtException handler that:
110
+ * 1. Wraps `logger.error` AND `logEvent` in try/catch — neither
111
+ * can re-throw out of the handler. (Pre-rc.50 the bare
112
+ * console.error threw EIO and re-fired this same handler in
113
+ * an event-loop-hijacking loop.)
114
+ * 2. Tracks repetitions of the same exception message in a sliding
115
+ * window. If the same message fires `eioThreshold` times within
116
+ * `eioWindowMs`, calls `panicExit(2)` so launchd restarts us
117
+ * cleanly. Without the circuit breaker, a stuck-stdout EIO
118
+ * cascade just keeps writing rows forever.
119
+ *
120
+ * @param {object} opts
121
+ * @param {object} opts.logger - { error(msg) } sink for human-readable logs.
122
+ * @param {function(string, object)} opts.logEvent - DB persist sink.
123
+ * @param {string} opts.botName
124
+ * @param {number} [opts.eioThreshold=100]
125
+ * @param {number} [opts.eioWindowMs=5000]
126
+ * @param {function(number)} [opts.panicExit=process.exit]
127
+ * @param {function(): number} [opts.now=Date.now]
128
+ * @returns {function(Error)}
129
+ */
130
+ function _makeUncaughtHandler({
131
+ logger,
132
+ logEvent,
133
+ botName,
134
+ eioThreshold = 100,
135
+ eioWindowMs = 5000,
136
+ panicExit = (code) => process.exit(code),
137
+ now = Date.now,
138
+ } = {}) {
139
+ // Per-message sliding-window timestamps. Map<message, number[]>.
140
+ const recent = new Map();
141
+ let panicked = false;
142
+
143
+ return function uncaughtHandler(err) {
144
+ if (panicked) return; // bail — we're on our way out
145
+ const msg = String(err?.message || err || 'unknown').slice(0, 500);
146
+ const stack = err?.stack?.split('\n').slice(0, 5).join('\n') || '';
147
+
148
+ // 1. Log defensively. Stdout may be broken (the original incident);
149
+ // must not re-throw out of this handler.
150
+ try {
151
+ logger?.error?.(`[polygram] uncaughtException: ${msg}\n${stack}`);
152
+ } catch { /* swallow — broken stdout */ }
153
+
154
+ // 2. Persist defensively. DB might be closing during shutdown.
155
+ try {
156
+ logEvent?.('uncaught-exception', { message: msg, bot_name: botName });
157
+ } catch { /* swallow */ }
158
+
159
+ // 3. Storm circuit breaker: same message N times in window → exit.
160
+ const t = now();
161
+ let timestamps = recent.get(msg);
162
+ if (!timestamps) { timestamps = []; recent.set(msg, timestamps); }
163
+ timestamps.push(t);
164
+ // Drop expired.
165
+ while (timestamps.length && t - timestamps[0] > eioWindowMs) timestamps.shift();
166
+ if (timestamps.length >= eioThreshold) {
167
+ panicked = true;
168
+ try {
169
+ logger?.error?.(`[polygram] uncaughtException circuit breaker: ${timestamps.length}× "${msg}" in ${eioWindowMs}ms — exit(2)`);
170
+ } catch {}
171
+ try {
172
+ logEvent?.('panic-exit', { message: msg, count: timestamps.length, window_ms: eioWindowMs, bot_name: botName });
173
+ } catch {}
174
+ panicExit(2);
175
+ }
176
+ };
177
+ }
178
+
179
+ // Build a parallel handler for unhandledRejection: same defensive
180
+ // posture, separate counter (rejections and exceptions can come
181
+ // from different code paths and shouldn't share a budget).
182
+ function _makeUnhandledRejectionHandler(opts) {
183
+ const inner = _makeUncaughtHandler({
184
+ ...opts,
185
+ // Override the 'kind' written to events table.
186
+ logEvent: opts.logEvent
187
+ ? (kind, detail) => opts.logEvent(kind === 'panic-exit' ? 'panic-exit' : 'unhandled-rejection', detail)
188
+ : undefined,
189
+ });
190
+ return (reason /* , promise */) => {
191
+ const err = reason instanceof Error ? reason : new Error(String(reason));
192
+ inner(err);
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Swallow EPIPE/EIO on the process's own stdout/stderr so a broken pipe during
198
+ * shutdown can't become an uncaughtException.
199
+ *
200
+ * rc.50 stopped the re-entrant LOOP (the handler no longer re-throws), but the
201
+ * write errors themselves still surface: when `launchctl kickstart` destroys the
202
+ * tmux pane, in-flight `console.log`/`console.error` calls hit a now-dead pty.
203
+ * Because stdout/stderr are TTYs, those writes throw EIO **synchronously** — each
204
+ * unguarded throw becomes an uncaughtException. Observed live on the rc.29→rc.30
205
+ * restart (2026-06-08): 100 `write EIO` rows then a circuit-breaker panic-exit on
206
+ * every deploy, interrupting the graceful drain.
207
+ *
208
+ * This guards BOTH delivery paths:
209
+ * (a) sync: wraps `write()` to drop EPIPE/EIO throws (TTY case — the real one);
210
+ * (b) async: attaches an `error` listener for the pipe case (errors arrive as
211
+ * events). Genuine, non-EPIPE/EIO errors still surface unchanged.
212
+ *
213
+ * @returns {{ uninstall: function() }}
214
+ */
215
+ function guardStdio({ streams = [process.stdout, process.stderr] } = {}) {
216
+ const guarded = streams.filter(Boolean);
217
+ const isBrokenPipe = (err) => err && (err.code === 'EPIPE' || err.code === 'EIO');
218
+ const onError = (err) => { if (isBrokenPipe(err)) return; throw err; };
219
+ const restores = [];
220
+
221
+ for (const s of guarded) {
222
+ s.on?.('error', onError);
223
+ restores.push(() => s.off?.('error', onError));
224
+
225
+ if (typeof s.write === 'function' && !s.__polygramStdioGuarded) {
226
+ const origWrite = s.write;
227
+ s.write = function guardedWrite(...args) {
228
+ try {
229
+ return origWrite.apply(this, args);
230
+ } catch (err) {
231
+ if (!isBrokenPipe(err)) throw err;
232
+ // Pane is gone — nothing to write to. Invoke the write callback (the
233
+ // last arg, if any) so callers awaiting it don't hang, and report
234
+ // backpressure (false) instead of throwing to uncaughtException.
235
+ const cb = args[args.length - 1];
236
+ if (typeof cb === 'function') { try { cb(err); } catch { /* ignore */ } }
237
+ return false;
238
+ }
239
+ };
240
+ s.__polygramStdioGuarded = true;
241
+ restores.push(() => { s.write = origWrite; delete s.__polygramStdioGuarded; });
242
+ }
243
+ }
244
+
245
+ return { uninstall() { for (const r of restores) r(); } };
246
+ }
247
+
248
+ /**
249
+ * Convenience: install both handlers in one call (plus the stdio guard, so the
250
+ * shutdown broken-pipe writes never reach the uncaughtException handler).
251
+ * @returns {{ uninstall: function() }}
252
+ */
253
+ function installSafetyHandlers(opts) {
254
+ const onException = _makeUncaughtHandler(opts);
255
+ const onRejection = _makeUnhandledRejectionHandler(opts);
256
+ process.on('uncaughtException', onException);
257
+ process.on('unhandledRejection', onRejection);
258
+ const stdio = guardStdio();
259
+ return {
260
+ uninstall() {
261
+ process.off('uncaughtException', onException);
262
+ process.off('unhandledRejection', onRejection);
263
+ stdio.uninstall();
264
+ },
265
+ };
266
+ }
267
+
268
+ // ─── helpers ─────────────────────────────────────────────────────────
269
+
270
+ function isAlive(pid) {
271
+ try {
272
+ process.kill(pid, 0);
273
+ return true;
274
+ } catch (err) {
275
+ // ESRCH = no such process. EPERM = exists but we lack rights
276
+ // (treat as alive — same UID typically; we will fail to kill it
277
+ // but at least we know it's there).
278
+ if (err.code === 'EPERM') return true;
279
+ return false;
280
+ }
281
+ }
282
+
283
+ function sleepSync(ms) {
284
+ // Atomics-based busy-wait. 50ms granularity is fine for boot
285
+ // orphan-killing; we're not in a hot path.
286
+ const buf = new Int32Array(new SharedArrayBuffer(4));
287
+ Atomics.wait(buf, 0, 0, ms);
288
+ }
289
+
290
+ module.exports = {
291
+ claimPidFile,
292
+ releasePidFile,
293
+ installSafetyHandlers,
294
+ guardStdio,
295
+ _makeUncaughtHandler,
296
+ _makeUnhandledRejectionHandler,
297
+ };
@@ -0,0 +1,107 @@
1
+ // provenance: polygram@0.17.11 lib/questions/store.js (git 746bca6) — verbatim: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * pending_questions store — persistence for the 0.12 interactive-question flow.
4
+ *
5
+ * Mirrors lib/approvals/store.js: per-row 128-bit callback token, status
6
+ * lifecycle, audit-kept rows (never deleted; 'pending' at boot → 'expired').
7
+ * One OPEN question per session at a time. The answer routes back to claude on
8
+ * tool_call_id (a `question_answer` bridge message).
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { newToken, tokensEqual } = require('../approvals/store');
14
+
15
+ // A question waits for the user — the turn no longer times out while an `ask` is open
16
+ // (cli-process defers its ceilings during a question wait, docs/progress-is-not-turn-end-spec.md),
17
+ // so this is only the long SAFETY BACKSTOP: a forgotten/abandoned question eventually
18
+ // expires {timedout} instead of pinning the session forever. Generous (a full day) so a
19
+ // real user answering hours later is never cut off; tune via the `questionTimeoutMs` config
20
+ // if a chat needs shorter/longer.
21
+ const DEFAULT_TIMEOUT_MS = 24 * 60 * 60 * 1000;
22
+
23
+ function createQuestionStore(rawDb, now = () => Date.now()) {
24
+ const insertStmt = rawDb.prepare(`
25
+ INSERT INTO pending_questions (
26
+ bot_name, session_key, chat_id, thread_id, turn_id, tool_call_id,
27
+ callback_token, questions_json, state_json, created_ts, timeout_ts
28
+ ) VALUES (
29
+ @bot_name, @session_key, @chat_id, @thread_id, @turn_id, @tool_call_id,
30
+ @callback_token, @questions_json, @state_json, @created_ts, @timeout_ts
31
+ )
32
+ `);
33
+ const getByIdStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE id = ?`);
34
+ const getOpenForSessStmt = rawDb.prepare(`
35
+ SELECT * FROM pending_questions WHERE session_key = ? AND status = 'pending'
36
+ ORDER BY created_ts DESC LIMIT 1`);
37
+ const getByToolCallStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE tool_call_id = ? LIMIT 1`);
38
+ const setMsgIdsStmt = rawDb.prepare(`UPDATE pending_questions SET message_ids_json = ? WHERE id = ?`);
39
+ const updateStateStmt = rawDb.prepare(`
40
+ UPDATE pending_questions SET state_json = @state_json, awaiting_other = @awaiting_other
41
+ WHERE id = @id AND status = 'pending'`);
42
+ const claimStmt = rawDb.prepare(`
43
+ UPDATE pending_questions SET from_id = @from_id
44
+ WHERE id = @id AND from_id IS NULL AND status = 'pending'`);
45
+ const resolveStmt = rawDb.prepare(`
46
+ UPDATE pending_questions SET status = @status, answered_ts = @answered_ts
47
+ WHERE id = @id AND status = 'pending'`);
48
+ const listTimedOutStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE status = 'pending' AND timeout_ts < ?`);
49
+ const listOpenStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE bot_name = ? AND status = 'pending'`);
50
+
51
+ return {
52
+ issue({ bot_name, session_key, chat_id, thread_id = null, turn_id = null, tool_call_id, questions, state, timeoutMs = DEFAULT_TIMEOUT_MS }) {
53
+ if (!bot_name || !session_key || !chat_id || !tool_call_id) {
54
+ throw new Error('issue: bot_name, session_key, chat_id, tool_call_id required');
55
+ }
56
+ const created_ts = now();
57
+ const res = insertStmt.run({
58
+ bot_name,
59
+ session_key,
60
+ chat_id: String(chat_id),
61
+ thread_id: thread_id != null ? String(thread_id) : null,
62
+ turn_id,
63
+ tool_call_id,
64
+ callback_token: newToken(),
65
+ questions_json: JSON.stringify(questions ?? []),
66
+ state_json: JSON.stringify(state ?? {}),
67
+ created_ts,
68
+ timeout_ts: created_ts + timeoutMs,
69
+ });
70
+ return getByIdStmt.get(res.lastInsertRowid);
71
+ },
72
+
73
+ getById(id) { return getByIdStmt.get(id); },
74
+ getOpenForSession(session_key) { return getOpenForSessStmt.get(session_key); },
75
+ getByToolCallId(tool_call_id) { return getByToolCallStmt.get(tool_call_id); },
76
+ setMessageIds(id, ids) { return setMsgIdsStmt.run(JSON.stringify(ids ?? []), id).changes; },
77
+
78
+ updateState(id, state, awaitingOther = false) {
79
+ return updateStateStmt.run({ id, state_json: JSON.stringify(state ?? {}), awaiting_other: awaitingOther ? 1 : 0 }).changes;
80
+ },
81
+
82
+ /**
83
+ * Authorize a responder. Claim-on-first-tap: if no from_id is recorded yet,
84
+ * the first interacting user claims the question; thereafter only that user
85
+ * may answer. Returns { ok, claimed }.
86
+ */
87
+ claimOrCheck(id, from_id) {
88
+ if (from_id == null) return { ok: false, claimed: false };
89
+ const claimed = claimStmt.run({ id, from_id }).changes > 0;
90
+ if (claimed) return { ok: true, claimed: true };
91
+ const row = getByIdStmt.get(id);
92
+ return { ok: row && Number(row.from_id) === Number(from_id), claimed: false };
93
+ },
94
+
95
+ resolve(id, status) {
96
+ if (!['answered', 'cancelled', 'timeout', 'expired'].includes(status)) {
97
+ throw new Error(`bad status: ${status}`);
98
+ }
99
+ return resolveStmt.run({ id, status, answered_ts: now() }).changes;
100
+ },
101
+
102
+ sweepTimedOut() { return listTimedOutStmt.all(now()); },
103
+ listOpen(bot_name) { return listOpenStmt.all(bot_name); },
104
+ };
105
+ }
106
+
107
+ module.exports = { createQuestionStore, tokensEqual, DEFAULT_TIMEOUT_MS };