polygram 0.38.4 → 0.38.6

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.
@@ -42,7 +42,8 @@ function createDispatcher({
42
42
  botName,
43
43
  logEvent,
44
44
  // Closures that polygram.js owns; passed in:
45
- handleMessage, // async (sessionKey, chatId, msg, bot)
45
+ handleMessage, // async (sessionKey, chatId, msg, bot, dispatchContext)
46
+ awaitSessionRecovery = () => Promise.resolve(),
46
47
  sendToProcess, // async (sessionKey, prompt, ctx)
47
48
  recoverCodex, // async ({ sessionKey, chatId, msg, bot, providerRecovery })
48
49
  // Cross-cutting helpers:
@@ -265,7 +266,13 @@ function createDispatcher({
265
266
  return resultText;
266
267
  }
267
268
 
268
- function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
269
+ function dispatchHandleMessage(
270
+ sessionKey,
271
+ chatId,
272
+ msg,
273
+ bot,
274
+ dispatchContext = null,
275
+ ) {
269
276
  const activeHandlerTarget = Object.freeze({
270
277
  sessionKey: String(sessionKey),
271
278
  chatId: String(chatId),
@@ -284,7 +291,19 @@ function createDispatcher({
284
291
  in_flight: count, threshold: warnAt,
285
292
  });
286
293
  }
287
- const task = handleMessage(sessionKey, chatId, msg, bot).catch(async (err) => {
294
+ const task = Promise.resolve()
295
+ .then(() => awaitSessionRecovery(
296
+ sessionKey,
297
+ dispatchContext?.recoveryReceipt ?? null,
298
+ ))
299
+ .then(() => handleMessage(
300
+ sessionKey,
301
+ chatId,
302
+ msg,
303
+ bot,
304
+ dispatchContext,
305
+ ))
306
+ .catch(async (err) => {
288
307
  const wasAborted = abortGrace.isRecent(sessionKey);
289
308
  const isReplay = msg._isReplay === true;
290
309
  let oomShutdown = false;
@@ -292,6 +311,10 @@ function createDispatcher({
292
311
  oomShutdown = getOomObservation()?.detected === true;
293
312
  } catch {}
294
313
  const isShuttingDown = getIsShuttingDown() || oomShutdown;
314
+ const cleanReplayProcessDrift = (
315
+ dispatchContext?.cleanReplay != null
316
+ && err.code === 'CODEX_CLEAN_REPLAY_PROCESS_CHANGED'
317
+ );
295
318
  let processLossResetFailed = false;
296
319
  logger.error?.(`[${sessionKey}] Error: ${err.message}`);
297
320
  // Mark the row terminal so the right thing happens on next
@@ -306,12 +329,14 @@ function createDispatcher({
306
329
  : isShuttingDown
307
330
  ? (isReplay ? 'replay-attempted' : 'replay-pending')
308
331
  : 'failed';
309
- dbWrite(() => (
310
- db.setInboundHandlerStatusUnlessCodexTerminal
311
- ?? db.setInboundHandlerStatus
312
- ).call(db, {
313
- chat_id: chatId, msg_id: msg.message_id, status,
314
- }), `set handler_status=${status}`);
332
+ if (!cleanReplayProcessDrift) {
333
+ dbWrite(() => (
334
+ db.setInboundHandlerStatusUnlessCodexTerminal
335
+ ?? db.setInboundHandlerStatus
336
+ ).call(db, {
337
+ chat_id: chatId, msg_id: msg.message_id, status,
338
+ }), `set handler_status=${status}`);
339
+ }
315
340
  logEvent('handler-error', {
316
341
  chat_id: chatId, session_key: sessionKey,
317
342
  msg_id: msg?.message_id,
@@ -417,7 +442,13 @@ function createDispatcher({
417
442
  // through into this hardcoded reply — contradicting the "chat is
418
443
  // never told, only the operator is" contract classify.js establishes
419
444
  // via userMessage: null (found in code review).
420
- if (isReplay && !wasAborted && !isShuttingDown && err.code !== 'AUTH_DISABLED') {
445
+ if (
446
+ isReplay
447
+ && !wasAborted
448
+ && !isShuttingDown
449
+ && err.code !== 'AUTH_DISABLED'
450
+ && !cleanReplayProcessDrift
451
+ ) {
421
452
  tg(bot, 'sendMessage', {
422
453
  chat_id: chatId,
423
454
  text: '⚠️ This turn was interrupted and didn\'t complete on retry — please rephrase or simplify, or split into smaller steps.',
@@ -573,7 +604,7 @@ function createDispatcher({
573
604
  });
574
605
  }
575
606
  }
576
- }).finally(() => {
607
+ }).finally(() => {
577
608
  activeHandlerTargets.delete(activeHandlerTarget);
578
609
  const n = (inFlightHandlers.get(sessionKey) || 1) - 1;
579
610
  if (n <= 0) inFlightHandlers.delete(sessionKey);
@@ -63,10 +63,20 @@ function createRedeliver({
63
63
  * @param {string|null} [opts.preMark] — handler_status to pre-mark (one-shot guard), e.g. 'replay-attempted'
64
64
  * @param {boolean} [opts.gate=true] — run the D5 gate at tier 'redelivery'
65
65
  * @param {boolean} [opts.ack=true] — 👀 on the redelivered message
66
+ * @param {Function|null} [opts.prepareDispatch] — post-gate durable setup
67
+ * returning private context for the normal dispatcher
68
+ * @param {Function|null} [opts.onGateBlocked] — terminalize a blocked replay
66
69
  * @returns {Promise<{ok:boolean, reason?:string}>}
67
70
  */
68
71
  return async function redeliverAsFreshTurn({
69
- chatId, msg, source, preMark = null, gate = true, ack = true,
72
+ chatId,
73
+ msg,
74
+ source,
75
+ preMark = null,
76
+ gate = true,
77
+ ack = true,
78
+ prepareDispatch = null,
79
+ onGateBlocked = null,
70
80
  } = {}) {
71
81
  if (!msg || !msg.chat || msg.message_id == null) return { ok: false, reason: 'malformed message' };
72
82
  const chatConfig = config.chats[String(chatId)];
@@ -101,21 +111,42 @@ function createRedeliver({
101
111
  logEvent('input-dropped-no-redeliver', {
102
112
  chat_id: chatId, msg_id: msg.message_id, source, stage: res.stage ?? null,
103
113
  });
104
- return { ok: false, reason: res.stage || res.action };
114
+ if (typeof onGateBlocked === 'function') {
115
+ await onGateBlocked({
116
+ chatId,
117
+ msg,
118
+ source,
119
+ stage: res.stage ?? null,
120
+ action: res.action,
121
+ });
122
+ }
123
+ return {
124
+ ok: false,
125
+ reason: res.stage || res.action,
126
+ ...(typeof onGateBlocked === 'function' ? { terminal: true } : {}),
127
+ };
105
128
  }
106
129
  }
107
130
 
131
+ const threadId = msg.message_thread_id?.toString();
132
+ const sessionKey = getSessionKey(String(chatId), threadId, chatConfig);
133
+ const dispatchContext = typeof prepareDispatch === 'function'
134
+ ? await prepareDispatch({ chatId, msg, source, sessionKey })
135
+ : null;
108
136
  if (ack) {
109
137
  try { react?.(chatId, msg.message_id); } catch { /* best-effort — never blocks */ }
110
138
  }
111
-
112
- const threadId = msg.message_thread_id?.toString();
113
- const sessionKey = getSessionKey(String(chatId), threadId, chatConfig);
114
139
  logEvent('redelivered-as-fresh-turn', {
115
140
  chat_id: chatId, msg_id: msg.message_id, source, session_key: sessionKey,
116
141
  });
117
- dispatchHandleMessage(sessionKey, String(chatId), msg, bot);
118
- return { ok: true };
142
+ const task = dispatchHandleMessage(
143
+ sessionKey,
144
+ String(chatId),
145
+ msg,
146
+ bot,
147
+ dispatchContext,
148
+ );
149
+ return { ok: true, task };
119
150
  };
120
151
  }
121
152
 
@@ -53,14 +53,10 @@ function providerReplayDisposition(candidate, getProviderRecovery) {
53
53
  * - CRASH (cleanShutdown=false): recover every still-unanswered candidate —
54
54
  * the existing rc.57 behavior. An unexpected exit must never silently drop
55
55
  * interrupted work.
56
- * - CLEAN (cleanShutdown=true): a deliberate restart. Prod data (n=3430)
57
- * showed skip-vs-recover CANNOT be auto-classified (53% of turns run >=30s
58
- * so ordinary interactive turns are replay-pending just like a long task,
59
- * and the rc.57 Xero case is itself a user message). So we decide by RESTART
60
- * INTENT, not message state: skip ALL pending candidates (don't re-answer
61
- * stale messages) and surface ONE visibility notice per chat/topic so a
62
- * genuinely-needed reply can be re-sent. rc.57's harm was *silent* loss;
63
- * this makes the skip *visible*.
56
+ * - CLEAN (cleanShutdown=true): a deliberate restart. Exact Codex evidence
57
+ * that proves a request never crossed the provider boundary stays eligible
58
+ * for its dedicated recoverer. Other pending candidates are skipped, with
59
+ * one visibility notice per chat/topic for announceable legacy work.
64
60
  *
65
61
  * Dedup: a candidate already answered (hasCompletedTurn -> true) is dropped from
66
62
  * the plan entirely — never recovered, never announced. The caller wires this to
@@ -89,6 +85,7 @@ function classifyReplay({
89
85
  const pending = (candidates || []).filter((c) => !answered(c));
90
86
  const legacy = [];
91
87
  const recoverCodex = [];
88
+ const restartSkippedCodex = [];
92
89
  const cancelled = [];
93
90
  const defer = [];
94
91
 
@@ -100,7 +97,11 @@ function classifyReplay({
100
97
  if (disposition.action === 'legacy') {
101
98
  legacy.push(candidate);
102
99
  } else if (disposition.action === 'recover') {
103
- recoverCodex.push(candidate);
100
+ if (!cleanShutdown || disposition.cleanRestartSafe === true) {
101
+ recoverCodex.push(candidate);
102
+ } else {
103
+ restartSkippedCodex.push(candidate);
104
+ }
104
105
  } else if (disposition.action === 'skip') {
105
106
  cancelled.push(candidate);
106
107
  } else {
@@ -121,15 +122,16 @@ function classifyReplay({
121
122
  return plan;
122
123
  }
123
124
 
124
- // Deliberate restart: skip ALL pending (none re-answered), and group the
125
- // ANNOUNCEABLE ones per (chat, thread) for one visibility notice each
125
+ // Deliberate restart: preserve exact clean-safe Codex recovery, skip the
126
+ // remaining pending work, and group the ANNOUNCEABLE skipped rows per
127
+ // (chat, thread) for one visibility notice each
126
128
  // (isolateTopics-safe). announceable excludes admin/slash + abort-shaped rows
127
129
  // (H5) — those are skipped silently, mirroring the crash path's redelivery
128
130
  // gate which never re-executes them. Default: everything announceable.
129
131
  const isAnnounceable = typeof announceable === 'function' ? announceable : () => true;
130
132
  const SEP = String.fromCharCode(0); // NUL separator (H8, collision-proof)
131
133
  const groups = new Map();
132
- const restartSkipped = [...legacy, ...recoverCodex];
134
+ const restartSkipped = [...legacy, ...restartSkippedCodex];
133
135
  for (const c of restartSkipped) {
134
136
  if (!isAnnounceable(c)) continue;
135
137
  const thread = c.thread_id == null ? null : c.thread_id;
@@ -143,6 +145,7 @@ function classifyReplay({
143
145
  skip: [...restartSkipped, ...cancelled],
144
146
  notices: Array.from(groups.values()),
145
147
  };
148
+ if (recoverCodex.length > 0) plan.recoverCodex = recoverCodex;
146
149
  if (defer.length > 0) plan.defer = defer;
147
150
  return plan;
148
151
  }
@@ -165,7 +168,7 @@ function classifyReplay({
165
168
  * @param {object} opts.deps
166
169
  * @param {(candidate)=>Promise<{ok:boolean}>} opts.deps.recover crash-path re-dispatch
167
170
  * @param {(candidate)=>Promise<{ok:boolean}>} [opts.deps.recoverCodex]
168
- * exact-runtime Codex crash-path re-dispatch
171
+ * exact-runtime Codex re-dispatch
169
172
  * @param {(group)=>Promise<{ok:boolean, messageId?:any, error?:string}>} opts.deps.sendNotice
170
173
  * @param {(candidate)=>void} opts.deps.markSkipped mark row terminal 'replay-skipped'
171
174
  * @param {(kind, detail)=>void} [opts.deps.logEvent]
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ function scheduleCleanCodexReplaySessions({
4
+ candidates = [],
5
+ getSessionKey,
6
+ getContinuationTasks,
7
+ coordinator,
8
+ recover,
9
+ trackTask,
10
+ onOutcome = () => {},
11
+ } = {}) {
12
+ if (
13
+ !Array.isArray(candidates)
14
+ || typeof getSessionKey !== 'function'
15
+ || typeof getContinuationTasks !== 'function'
16
+ || typeof coordinator?.schedule !== 'function'
17
+ || typeof recover !== 'function'
18
+ || typeof trackTask !== 'function'
19
+ || typeof onOutcome !== 'function'
20
+ ) {
21
+ throw new TypeError('clean Codex boot replay dependencies are invalid');
22
+ }
23
+
24
+ const candidatesBySession = new Map();
25
+ for (const candidate of candidates) {
26
+ const sessionKey = getSessionKey(candidate);
27
+ if (typeof sessionKey !== 'string' || sessionKey.length === 0) {
28
+ throw new TypeError('clean Codex boot replay candidate has no session');
29
+ }
30
+ const sessionCandidates = candidatesBySession.get(sessionKey) || [];
31
+ sessionCandidates.push(candidate);
32
+ candidatesBySession.set(sessionKey, sessionCandidates);
33
+ }
34
+
35
+ let scheduled = 0;
36
+ for (const [sessionKey, followers] of candidatesBySession) {
37
+ const task = coordinator.schedule({
38
+ sessionKey,
39
+ continuationTasks: getContinuationTasks(sessionKey),
40
+ followers,
41
+ recover,
42
+ }).then((outcome) => {
43
+ onOutcome({ sessionKey, outcome });
44
+ return outcome;
45
+ });
46
+ scheduled += followers.length;
47
+ trackTask(task);
48
+ }
49
+
50
+ return {
51
+ scheduled,
52
+ sessions: candidatesBySession.size,
53
+ };
54
+ }
55
+
56
+ module.exports = { scheduleCleanCodexReplaySessions };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ function createCleanReplaySessionCoordinator() {
4
+ const sessions = new Map();
5
+
6
+ function wait(sessionKey, receipt = null) {
7
+ const state = sessions.get(sessionKey);
8
+ if (!state || (receipt !== null && receipt === state.receipt)) {
9
+ return Promise.resolve();
10
+ }
11
+ return state.barrier;
12
+ }
13
+
14
+ function schedule({
15
+ sessionKey,
16
+ continuationTasks = [],
17
+ followers = [],
18
+ recover,
19
+ } = {}) {
20
+ if (
21
+ typeof sessionKey !== 'string'
22
+ || sessionKey.length === 0
23
+ || !Array.isArray(continuationTasks)
24
+ || !Array.isArray(followers)
25
+ || typeof recover !== 'function'
26
+ || sessions.has(sessionKey)
27
+ ) {
28
+ throw new TypeError('clean replay session schedule is invalid');
29
+ }
30
+
31
+ let release;
32
+ const state = {
33
+ receipt: null,
34
+ barrier: new Promise((resolve) => { release = resolve; }),
35
+ };
36
+ sessions.set(sessionKey, state);
37
+
38
+ const task = Promise.resolve().then(async () => {
39
+ let admitted = 0;
40
+ let terminal = 0;
41
+ try {
42
+ const continuationResults = await Promise.allSettled(continuationTasks);
43
+ if (continuationResults.some((result) => (
44
+ result.status !== 'fulfilled'
45
+ || result.value?.status !== 'replied'
46
+ ))) {
47
+ return {
48
+ status: 'deferred',
49
+ admitted,
50
+ terminal,
51
+ deferred: followers.length,
52
+ reason: 'continuation-not-replied',
53
+ };
54
+ }
55
+
56
+ for (let index = 0; index < followers.length; index += 1) {
57
+ const receipt = Object.freeze({});
58
+ state.receipt = receipt;
59
+ let result;
60
+ try {
61
+ // eslint-disable-next-line no-await-in-loop
62
+ result = await recover(followers[index], { receipt });
63
+ } catch (error) {
64
+ result = {
65
+ status: 'failed',
66
+ reason: error?.code || 'recovery-failed',
67
+ };
68
+ } finally {
69
+ state.receipt = null;
70
+ }
71
+ if (result?.status === 'dispatched') {
72
+ admitted += 1;
73
+ continue;
74
+ }
75
+ if (result?.status === 'gate-terminal') {
76
+ terminal += 1;
77
+ continue;
78
+ }
79
+ return {
80
+ status: 'deferred',
81
+ admitted,
82
+ terminal,
83
+ deferred: followers.length - index,
84
+ reason: result?.reason || 'recovery-not-dispatched',
85
+ };
86
+ }
87
+ return {
88
+ status: 'complete',
89
+ admitted,
90
+ terminal,
91
+ deferred: 0,
92
+ reason: null,
93
+ };
94
+ } finally {
95
+ if (sessions.get(sessionKey) === state) sessions.delete(sessionKey);
96
+ release();
97
+ }
98
+ });
99
+
100
+ return task;
101
+ }
102
+
103
+ return Object.freeze({ wait, schedule });
104
+ }
105
+
106
+ module.exports = { createCleanReplaySessionCoordinator };
@@ -434,6 +434,10 @@ async function startCleanRestartRecovery({
434
434
  ready = true;
435
435
  rejectReady(error);
436
436
  }
437
+ return {
438
+ status: 'failed',
439
+ reason: error?.code || 'unexpected-recovery-error',
440
+ };
437
441
  })
438
442
  .finally(releaseReady);
439
443
  trackTask(task);
@@ -97,19 +97,9 @@ function createSdkCallbacks({
97
97
  const chatConfig = config.chats[entry.chatId] || {};
98
98
  const topicConfig = getTopicConfig(chatConfig, entry.threadId || null);
99
99
  if (isCodexProcess(entry)) {
100
- dbWrite(() => db.upsertProviderSession({
101
- session_key: sessionKey,
102
- namespace: 'codex:app-server',
103
- provider: 'codex',
104
- provider_session_id: event.providerSessionId ?? event.session_id,
105
- app_server_session_id: event.appServerSessionId ?? null,
106
- agent: null,
107
- cwd: entry.cwd,
108
- model: entry.model,
109
- effort: entry.effort,
110
- pm_backend: 'codex',
111
- spawn_profile_id: entry.spawnProfileId,
112
- }), `upsert Codex session ${sessionKey}`);
100
+ // Codex init does not carry the settings admitted for a turn. The
101
+ // request-prepared checkpoint persists that exact provider identity
102
+ // atomically with the turn attempt before any app-server write.
113
103
  return;
114
104
  }
115
105
 
@@ -0,0 +1,19 @@
1
+ -- Boot replay only needs to distinguish zero, one, or conflicting evidence
2
+ -- for a Telegram input. These indexes keep those bounded probes independent
3
+ -- of the accumulated Codex attempt and linked-input history.
4
+ CREATE INDEX idx_codex_attempts_replay_source
5
+ ON codex_turn_attempts(
6
+ session_key,
7
+ telegram_source_message_id,
8
+ created_ts,
9
+ attempt_id
10
+ );
11
+
12
+ CREATE INDEX idx_codex_linked_inputs_replay_source
13
+ ON codex_linked_inputs(
14
+ telegram_chat_id,
15
+ telegram_message_id,
16
+ generation_id,
17
+ created_ts,
18
+ linked_input_id
19
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.38.4",
3
+ "version": "0.38.6",
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.10.17",
66
+ "@shumkov/orchestra": "0.10.19",
67
67
  "better-sqlite3": "^12.9.0",
68
68
  "grammy": "^1.42.0",
69
69
  "markdown-it": "^14.1.1",