polygram 0.13.1 → 0.14.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.
@@ -40,6 +40,10 @@ const DEFAULT_POLICY = {
40
40
  keepForeverKinds: [
41
41
  'polygram-start', 'polygram-stop', 'shutdown-drain',
42
42
  'handler-error', 'auth-expired', 'resume-fail',
43
+ // 0.14 boot-replay forensics — was a real task skipped-and-announced, or
44
+ // silently lost? Kept so "why didn't my message get answered after the
45
+ // restart?" is answerable beyond the 90d default (still capped).
46
+ 'replay-on-boot', 'replay-notice-sent', 'replay-notice-failed',
43
47
  // the prune's own audit trail — kept so it survives a prune (still capped):
44
48
  'events-pruned', 'events-prune-preview', 'events-prune-skipped',
45
49
  ],
package/lib/db.js CHANGED
@@ -19,7 +19,11 @@ const Database = require('better-sqlite3');
19
19
  // SCHEMA_VERSION; the early-return on line ~42 then skipped the
20
20
  // migration loop on any DB already at user_version=8 → turn_metrics
21
21
  // table never created → INSERT prepare at startup crashed polygram.
22
- const SCHEMA_VERSION = 12;
22
+ //
23
+ // 0.14: bumped from 12 → 13. Adds migration 013-clean-shutdown-marker.sql
24
+ // (polling_state.clean_shutdown_at). Same footgun as the 8→9 note: forgetting
25
+ // the bump skips the migration on any DB already at user_version=12.
26
+ const SCHEMA_VERSION = 13;
23
27
 
24
28
  // Sentinel `error` value for outbound rows whose API call may or may not
25
29
  // have reached Telegram. markStalePending writes it; hasOutboundReplyTo
@@ -637,6 +641,61 @@ function wrap(db) {
637
641
  `).run(botName, cutoff);
638
642
  },
639
643
 
644
+ // 0.14 boot-replay: record a DELIBERATE (clean) shutdown. Atomically, in ONE
645
+ // transaction: (a) mark still-in-flight inbound rows replay-pending (so a
646
+ // deliberate restart that interrupted a long turn still recovers it), and
647
+ // (b) stamp polling_state.clean_shutdown_at so boot can tell clean from
648
+ // crash. Written UNCONDITIONALLY on every clean shutdown — NOT gated on
649
+ // in-flight count — because a stale replay-pending row from a prior life
650
+ // must NOT be crash-recovered (re-answered) on a deliberate restart.
651
+ //
652
+ // The upsert satisfies polling_state's NOT NULL last_update_id/ts (migration
653
+ // 005) for a fresh/quiet bot that has no row yet (a row is otherwise created
654
+ // only on a non-empty getUpdates batch): COALESCE the existing values, else
655
+ // seed (0, now).
656
+ recordCleanShutdown({ botName, now = Date.now(), since } = {}) {
657
+ const cutoff = since ?? now - 30 * 60 * 1000;
658
+ const txn = db.transaction(() => {
659
+ const marked = db.prepare(`
660
+ UPDATE messages SET handler_status = 'replay-pending'
661
+ WHERE direction = 'in'
662
+ AND handler_status IN ('dispatched', 'processing')
663
+ AND bot_name = ?
664
+ AND ts > ?
665
+ `).run(botName, cutoff);
666
+ db.prepare(`
667
+ INSERT INTO polling_state (bot_name, last_update_id, ts, clean_shutdown_at)
668
+ VALUES (?,
669
+ COALESCE((SELECT last_update_id FROM polling_state WHERE bot_name = ?), 0),
670
+ COALESCE((SELECT ts FROM polling_state WHERE bot_name = ?), ?),
671
+ ?)
672
+ ON CONFLICT(bot_name) DO UPDATE SET clean_shutdown_at = excluded.clean_shutdown_at
673
+ `).run(botName, botName, botName, now, now);
674
+ return marked.changes;
675
+ });
676
+ return { replayMarked: txn() };
677
+ },
678
+
679
+ // 0.14: read AND clear the clean-shutdown marker in one txn. "Clean" iff a
680
+ // marker is present, not future-dated (clock skew → crash), and within
681
+ // maxAgeMs (derived from the replay window). Clear-on-read so a marker from
682
+ // a prior boot can never be inherited as "clean" after a later crash. Any
683
+ // ambiguity ⇒ clean:false (the caller treats that as crash → recover).
684
+ consumeCleanShutdownMarker({ botName, now = Date.now(), maxAgeMs }) {
685
+ const txn = db.transaction(() => {
686
+ const row = db.prepare('SELECT clean_shutdown_at FROM polling_state WHERE bot_name = ?').get(botName);
687
+ const at = row ? row.clean_shutdown_at : null;
688
+ if (row && at != null) {
689
+ db.prepare('UPDATE polling_state SET clean_shutdown_at = NULL WHERE bot_name = ?').run(botName);
690
+ }
691
+ return at;
692
+ });
693
+ const at = txn();
694
+ const age = typeof at === 'number' ? now - at : null;
695
+ const clean = age != null && age >= 0 && (maxAgeMs == null || age <= maxAgeMs);
696
+ return { clean, markerAt: at };
697
+ },
698
+
640
699
  // ─── Attachments (migration 007, polygram 0.6.0) ──────────────────
641
700
  //
642
701
  // Replaces the messages.attachments_json blob. Each attachment is its
@@ -106,10 +106,19 @@ function createDownloadAttachments({
106
106
  } catch (e) {
107
107
  if (e.code === 'EEXIST') {
108
108
  logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
109
- } else if (e.code === 'EXDEV') {
110
- fs.copyFileSync(fileInfo.file_path, localPath); // cross-device fallback
111
109
  } else {
112
- throw e;
110
+ // A hard link can fail even when a byte copy succeeds: EXDEV (the
111
+ // bot-api data dir is a different device/overlay than the inbox) or
112
+ // EPERM (the volume's filesystem refuses a cross-fs / cross-owner
113
+ // hard link — the local Bot API container writes files as a
114
+ // different user, e.g. `messagebus`, and polygram reads them via
115
+ // group membership). Copy works whenever we can READ the source
116
+ // (group perm) + WRITE the inbox; if the source read is genuinely
117
+ // denied, copyFileSync throws EACCES and the outer handler marks
118
+ // the attachment failed. Falling back on EXDEV only (the old
119
+ // behaviour) left EPERM uncaught → every inbound file failed once
120
+ // the local Bot API server was a Docker volume (2026-06-16).
121
+ fs.copyFileSync(fileInfo.file_path, localPath);
113
122
  }
114
123
  }
115
124
  }
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Boot-replay disposition (0.14) — decide what to do with the replay candidate
5
+ * set, given whether the last shutdown was a DELIBERATE restart or a CRASH.
6
+ *
7
+ * Pure function (no I/O) so it's unit-testable without booting the daemon; the
8
+ * caller performs the actual dispatch / skip-marking / notice-sending from the
9
+ * returned plan (see executeReplayPlan).
10
+ *
11
+ * - CRASH (cleanShutdown=false): recover every still-unanswered candidate —
12
+ * the existing rc.57 behavior. An unexpected exit must never silently drop
13
+ * interrupted work.
14
+ * - CLEAN (cleanShutdown=true): a deliberate restart. Prod data (n=3430)
15
+ * showed skip-vs-recover CANNOT be auto-classified (53% of turns run >=30s
16
+ * so ordinary interactive turns are replay-pending just like a long task,
17
+ * and the rc.57 Xero case is itself a user message). So we decide by RESTART
18
+ * INTENT, not message state: skip ALL pending candidates (don't re-answer
19
+ * stale messages) and surface ONE visibility notice per chat/topic so a
20
+ * genuinely-needed reply can be re-sent. rc.57's harm was *silent* loss;
21
+ * this makes the skip *visible*.
22
+ *
23
+ * Dedup: a candidate already answered (hasCompletedTurn -> true) is dropped from
24
+ * the plan entirely — never recovered, never announced. The caller wires this to
25
+ * db.hasCompletedTurnFor (turn_metrics), NOT hasOutboundReplyTo (rc.51).
26
+ *
27
+ * @param {object} opts
28
+ * @param {Array<{chat_id, thread_id?, msg_id}>} opts.candidates
29
+ * @param {boolean} opts.cleanShutdown true iff the clean-shutdown marker was present+fresh
30
+ * @param {(candidate)=>boolean} [opts.hasCompletedTurn] already-answered predicate (default: none)
31
+ * @param {(candidate)=>boolean} [opts.announceable] notice-eligible predicate (default: all). Excludes
32
+ * admin/slash + abort-shaped rows so we don't announce "I didn't resume your /new".
33
+ * @returns {{recover: Array, skip: Array, notices: Array<{chat_id, thread_id, items: Array}>}}
34
+ */
35
+ function classifyReplay({ candidates = [], cleanShutdown = false, hasCompletedTurn, announceable } = {}) {
36
+ const answered = typeof hasCompletedTurn === 'function' ? hasCompletedTurn : () => false;
37
+ const pending = (candidates || []).filter((c) => !answered(c));
38
+
39
+ if (!cleanShutdown) {
40
+ // Crash: recover everything unanswered (unchanged rc.57 behavior).
41
+ return { recover: pending, skip: [], notices: [] };
42
+ }
43
+
44
+ // Deliberate restart: skip ALL pending (none re-answered), and group the
45
+ // ANNOUNCEABLE ones per (chat, thread) for one visibility notice each
46
+ // (isolateTopics-safe). announceable excludes admin/slash + abort-shaped rows
47
+ // (H5) — those are skipped silently, mirroring the crash path's redelivery
48
+ // gate which never re-executes them. Default: everything announceable.
49
+ const isAnnounceable = typeof announceable === 'function' ? announceable : () => true;
50
+ const SEP = String.fromCharCode(0); // NUL separator (H8, collision-proof)
51
+ const groups = new Map();
52
+ for (const c of pending) {
53
+ if (!isAnnounceable(c)) continue;
54
+ const thread = c.thread_id == null ? null : c.thread_id;
55
+ const key = `${c.chat_id}${SEP}${thread == null ? '' : thread}`;
56
+ let g = groups.get(key);
57
+ if (!g) { g = { chat_id: c.chat_id, thread_id: thread, items: [] }; groups.set(key, g); }
58
+ g.items.push(c);
59
+ }
60
+ return { recover: [], skip: pending, notices: Array.from(groups.values()) };
61
+ }
62
+
63
+ /**
64
+ * Execute a classified plan (0.14, H6). Pure-ish: all I/O via injected deps, so
65
+ * it's unit-testable and the crash-seam ordering can be asserted.
66
+ *
67
+ * Ordering (fail-toward-recovery): the caller has already read-and-cleared the
68
+ * marker BEFORE this runs, so a crash mid-execution leaves un-marked rows as
69
+ * candidates -> next boot has no marker -> CRASH branch recovers them (never a
70
+ * silent drop). Within the clean branch: send each group's notice FIRST; only on
71
+ * a CONFIRMED send mark that group's rows terminal ('replay-skipped'); on a send
72
+ * FAILURE leave the rows recoverable and emit replay-notice-failed. Gate-blocked
73
+ * skip items (not in any notice group) are marked terminal silently.
74
+ *
75
+ * @param {object} opts
76
+ * @param {{recover:Array, skip:Array, notices:Array}} opts.plan
77
+ * @param {object} opts.deps
78
+ * @param {(candidate)=>Promise<{ok:boolean}>} opts.deps.recover crash-path re-dispatch
79
+ * @param {(group)=>Promise<{ok:boolean, messageId?:any, error?:string}>} opts.deps.sendNotice
80
+ * @param {(candidate)=>void} opts.deps.markSkipped mark row terminal 'replay-skipped'
81
+ * @param {(kind, detail)=>void} [opts.deps.logEvent]
82
+ * @returns {Promise<{recovered:number, skipped:number, noticed:number, noticeFailed:number}>}
83
+ */
84
+ async function executeReplayPlan({ plan, deps }) {
85
+ const { recover = [], skip = [], notices = [] } = plan || {};
86
+ const log = (deps && deps.logEvent) || (() => {});
87
+ let recovered = 0; let skipped = 0; let noticed = 0; let noticeFailed = 0;
88
+
89
+ // CRASH branch — recover each (unchanged rc.57 behavior).
90
+ for (const c of recover) {
91
+ // eslint-disable-next-line no-await-in-loop
92
+ const r = await deps.recover(c);
93
+ if (r && r.ok) recovered += 1; else skipped += 1;
94
+ }
95
+
96
+ // CLEAN branch — notice-then-mark, per group.
97
+ const inNotices = new Set();
98
+ for (const g of notices) for (const c of g.items) inNotices.add(c);
99
+ for (const g of notices) {
100
+ let res;
101
+ // eslint-disable-next-line no-await-in-loop
102
+ try { res = await deps.sendNotice(g); } catch (e) { res = { ok: false, error: e && e.message }; }
103
+ if (res && res.ok) {
104
+ for (const c of g.items) { deps.markSkipped(c); skipped += 1; }
105
+ noticed += 1;
106
+ log('replay-notice-sent', { chat_id: g.chat_id, thread_id: g.thread_id, count: g.items.length, notice_msg_id: res.messageId });
107
+ } else {
108
+ noticeFailed += 1;
109
+ log('replay-notice-failed', { chat_id: g.chat_id, thread_id: g.thread_id, count: g.items.length, error: res && res.error });
110
+ // leave rows recoverable (no markSkipped) — next boot's crash branch recovers them
111
+ }
112
+ }
113
+ // Gate-blocked skip items (never in a notice group) -> terminal, silent.
114
+ for (const c of skip) {
115
+ if (!inNotices.has(c)) { deps.markSkipped(c); skipped += 1; }
116
+ }
117
+ return { recovered, skipped, noticed, noticeFailed };
118
+ }
119
+
120
+ module.exports = { classifyReplay, executeReplayPlan };
@@ -0,0 +1,10 @@
1
+ -- 0.14: clean-shutdown marker for boot-replay.
2
+ --
3
+ -- On a DELIBERATE restart polygram skips re-dispatching stale candidates (so it
4
+ -- doesn't re-answer messages the user already saw it working on) and posts one
5
+ -- visibility notice instead. On a CRASH it recovers everything (unchanged).
6
+ --
7
+ -- The marker is written in the shutdown handler (same txn as markReplayPending)
8
+ -- and read-and-cleared at boot. NULL = no clean shutdown recorded → treat as
9
+ -- crash (recover). One row per bot (shares polling_state's PK).
10
+ ALTER TABLE polling_state ADD COLUMN clean_shutdown_at INTEGER;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.13.1",
3
+ "version": "0.14.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": {
package/polygram.js CHANGED
@@ -67,8 +67,9 @@ const { createHandleAbort } = require('./lib/handlers/abort');
67
67
  const { createAutosteerHandlers } = require('./lib/handlers/autosteer');
68
68
  const { createEditCorrectionInjector } = require('./lib/handlers/edit-correction');
69
69
  const { createEditRedelivery } = require('./lib/handlers/edit-redelivery');
70
- const { createGateInbound } = require('./lib/handlers/gate-inbound');
70
+ const { createGateInbound, ADMIN_CMD_RE, PAIR_CLAIM_RE } = require('./lib/handlers/gate-inbound');
71
71
  const { createRedeliver } = require('./lib/handlers/redeliver');
72
+ const { classifyReplay, executeReplayPlan } = require('./lib/handlers/replay-disposition');
72
73
  const { createDropRedeliverer } = require('./lib/handlers/drop-redeliver');
73
74
  const { createSessionFeedback } = require('./lib/feedback/session-feedback');
74
75
  const { createSlashCommands } = require('./lib/handlers/slash-commands');
@@ -2650,28 +2651,29 @@ async function main() {
2650
2651
  let remaining = 0;
2651
2652
  for (const n of inFlightHandlers.values()) remaining += n;
2652
2653
 
2653
- // 3. Anything still in-flight mark in DB as replay-pending so the
2654
- // next polygram boot re-dispatches it. User never sees an error.
2655
- if (remaining > 0 && db) {
2654
+ // 3. This handler only runs on a DELIBERATE shutdown (SIGINT/SIGTERM/SIGHUP);
2655
+ // a crash (SIGKILL/OOM/panic) never reaches here. Record a clean-shutdown
2656
+ // marker AND mark any still-in-flight rows replay-pending, atomically, on
2657
+ // EVERY clean shutdown (not just when in-flight>0) — boot uses the marker
2658
+ // to SKIP re-answering stale messages on a deliberate restart while still
2659
+ // recovering everything on a crash (0.14, §H1). markReplayPending alone
2660
+ // (the old behavior) couldn't distinguish the two, so every deploy
2661
+ // re-answered. A stale replay-pending row from a prior life would also be
2662
+ // crash-recovered on a deliberate restart without the unconditional marker.
2663
+ if (db) {
2656
2664
  try {
2657
- const res = db.markReplayPending({ botName: BOT_NAME });
2665
+ const res = db.recordCleanShutdown({ botName: BOT_NAME });
2658
2666
  logEvent('shutdown-drain', {
2659
2667
  bot: BOT_NAME,
2660
2668
  in_flight: remaining,
2661
- replay_marked: res?.changes ?? 0,
2669
+ replay_marked: res?.replayMarked ?? 0,
2662
2670
  elapsed_ms: drainElapsed,
2671
+ clean: true,
2663
2672
  });
2664
- console.log(`[shutdown] drained ${drainElapsed}ms, ${remaining} still in-flight, ${res?.changes ?? 0} rows marked replay-pending`);
2673
+ console.log(`[shutdown] clean shutdown recorded; drained ${drainElapsed}ms, ${remaining} in-flight, ${res?.replayMarked ?? 0} marked replay-pending`);
2665
2674
  } catch (err) {
2666
- console.error(`[shutdown] markReplayPending failed: ${err.message}`);
2675
+ console.error(`[shutdown] recordCleanShutdown failed: ${err.message}`);
2667
2676
  }
2668
- } else if (db) {
2669
- logEvent('shutdown-drain', {
2670
- bot: BOT_NAME,
2671
- in_flight: 0,
2672
- elapsed_ms: drainElapsed,
2673
- });
2674
- console.log(`[shutdown] clean drain in ${drainElapsed}ms`);
2675
2677
  }
2676
2678
 
2677
2679
  // 4. Remaining shutdown: approvals sweeper, IPC, resolve hook waiters,
@@ -2752,34 +2754,52 @@ async function main() {
2752
2754
  const chatIds = Object.keys(config.chats);
2753
2755
  if (chatIds.length > 0) {
2754
2756
  const replayWindowMs = resolveReplayWindowMs(config);
2757
+
2758
+ // 0.14: classify by RESTART INTENT. Read-and-clear the clean-shutdown
2759
+ // marker FIRST, in its own try/catch — ANY error => treat as crash
2760
+ // (recover), never skip-all (fail toward recovery). A deliberate restart
2761
+ // skips re-answering stale messages and posts one visibility notice; a
2762
+ // crash recovers everything (unchanged rc.57 behavior).
2763
+ let cleanShutdown = false;
2764
+ try {
2765
+ const maxAgeMs = 2 * (replayWindowMs || 3 * 60 * 1000);
2766
+ cleanShutdown = db.consumeCleanShutdownMarker({ botName: BOT_NAME, maxAgeMs }).clean;
2767
+ } catch (err) {
2768
+ console.error(`[replay] clean-shutdown marker read failed (-> crash recover): ${err.message}`);
2769
+ cleanShutdown = false;
2770
+ }
2771
+
2755
2772
  const candidates = db.getReplayCandidates({ chatIds, ...(replayWindowMs && { olderThanMs: replayWindowMs }) });
2756
- let replayed = 0;
2757
- let skipped = 0;
2773
+
2774
+ // Cleanup pass: a candidate with a COMPLETED turn (turn_metrics, not just
2775
+ // an ack-bubble — rc.51, the rc.50 msg-12158 lesson) was already answered.
2776
+ // Mark it terminal 'replied' and exclude it from the plan (recovered nor
2777
+ // announced). Single pass → reuse the result as the dedup predicate.
2778
+ const completed = new Set();
2758
2779
  for (const row of candidates) {
2759
- // rc.51: dedupe on turn_metrics (definitive turn completion),
2760
- // NOT just on hasOutboundReplyTo. The latter trips on
2761
- // intermediate ack-bubbles (e.g. "Catching up on history…",
2762
- // "I'll write a quick inline script…") and silently skips the
2763
- // replay even when the actual answer never arrived. The rc.50
2764
- // EIO-orphan incident lost Ivan DM msg 12158 this way: an ack
2765
- // bubble was sent at 13:20:36, the turn was killed mid-flight,
2766
- // boot-replay saw the ack and assumed "answered."
2767
- //
2768
- // turn_metrics is only inserted by the SDK pm's onResult
2769
- // callback, which fires only when the turn definitively
2770
- // completes. No row → no completion → re-dispatch.
2771
2780
  if (db.hasCompletedTurnFor({ chat_id: row.chat_id, msg_id: row.msg_id })) {
2772
- db.setInboundHandlerStatus({
2773
- chat_id: row.chat_id, msg_id: row.msg_id, status: 'replied',
2774
- });
2775
- skipped += 1;
2776
- continue;
2781
+ completed.add(`${row.chat_id}/${row.msg_id}`);
2782
+ db.setInboundHandlerStatus({ chat_id: row.chat_id, msg_id: row.msg_id, status: 'replied' });
2777
2783
  }
2778
- // Reconstruct a minimal grammy-like Message object. Enough for
2779
- // dispatchRegularMessage (mention detect, abort, admin cmds,
2780
- // shouldHandle, enqueue). Attachments carry file_ids so the
2781
- // normal download path re-fetches on replay.
2782
- const reconstructed = {
2784
+ }
2785
+ const hasCompletedTurn = (row) => completed.has(`${row.chat_id}/${row.msg_id}`);
2786
+
2787
+ // Notice eligibility (H5): never announce admin/slash or abort-shaped rows
2788
+ // (the crash path's redelivery gate never re-executes them either). An
2789
+ // attachment-only message (no text, e.g. a screenshot) IS announceable.
2790
+ const announceable = (row) => {
2791
+ const t = (row.text || '').trim();
2792
+ if (!t) return true;
2793
+ if (typeof isAbortRequest === 'function' && isAbortRequest(t)) return false;
2794
+ if (ADMIN_CMD_RE.test(t) || PAIR_CLAIM_RE.test(t)) return false;
2795
+ return true;
2796
+ };
2797
+
2798
+ // Reconstruct a minimal grammy-like Message for the crash-path
2799
+ // re-dispatch (the shape dispatchRegularMessage expects; attachments via
2800
+ // the media-group shortcut so the normal download path re-fetches).
2801
+ const reconstruct = (row) => {
2802
+ const msg = {
2783
2803
  chat: { id: Number(row.chat_id), type: row.chat_id.startsWith('-') ? 'supergroup' : 'private' },
2784
2804
  message_id: row.msg_id,
2785
2805
  from: { id: row.user_id, first_name: row.user },
@@ -2788,36 +2808,54 @@ async function main() {
2788
2808
  ...(row.thread_id && { message_thread_id: Number(row.thread_id) }),
2789
2809
  ...(row.reply_to_id && { reply_to_message: { message_id: row.reply_to_id } }),
2790
2810
  };
2791
- // Attach already-recorded attachments via the media-group shortcut
2792
- // field so extractAttachments picks them up without re-parsing
2793
- // grammy fields that don't exist on this reconstructed object.
2794
2811
  const attRows = db.getAttachmentsByMessage(row.id);
2795
2812
  if (attRows.length) {
2796
- reconstructed._mergedAttachments = attRows.map((a) => ({
2813
+ msg._mergedAttachments = attRows.map((a) => ({
2797
2814
  kind: a.kind, name: a.name, mime_type: a.mime_type,
2798
2815
  size: a.size_bytes, file_id: a.file_id, file_unique_id: a.file_unique_id,
2799
2816
  }));
2800
2817
  }
2801
- const chatConfig = config.chats[row.chat_id];
2802
- if (!chatConfig) { skipped += 1; continue; }
2803
- // 0.13 D4: through the unified redelivery tail — _isReplay tag (error
2804
- // reply suppressed, not replay-eligible), 'replay-attempted' pre-mark
2805
- // (one-shot: even if THIS attempt dies mid-turn, the next boot won't
2806
- // loop), the D5 gate at tier 'redelivery' (abort/admin-shaped rows are
2807
- // never auto-re-executed; a row whose chat lost its pairing since is
2808
- // re-checked), a 👀 ack so the recovery is visible, then dispatch.
2809
- // eslint-disable-next-line no-await-in-loop
2810
- const r = await redeliverAsFreshTurn({
2811
- chatId: row.chat_id, msg: reconstructed,
2812
- source: 'boot-replay', preMark: 'replay-attempted',
2813
- });
2814
- if (r.ok) replayed += 1;
2815
- else skipped += 1;
2816
- }
2818
+ return msg;
2819
+ };
2820
+
2821
+ const plan = classifyReplay({ candidates, cleanShutdown, hasCompletedTurn, announceable });
2822
+
2823
+ const result = await executeReplayPlan({
2824
+ plan,
2825
+ deps: {
2826
+ // CRASH path — unchanged: through the unified redelivery tail (D5 gate
2827
+ // at tier 'redelivery', 'replay-attempted' one-shot pre-mark, ack).
2828
+ recover: async (row) => {
2829
+ const chatConfig = config.chats[row.chat_id];
2830
+ if (!chatConfig) return { ok: false };
2831
+ return redeliverAsFreshTurn({
2832
+ chatId: row.chat_id, msg: reconstruct(row),
2833
+ source: 'boot-replay', preMark: 'replay-attempted',
2834
+ });
2835
+ },
2836
+ // CLEAN path — one visibility notice per (chat, thread). plainText so
2837
+ // no markdown/HTML parse on a boot send.
2838
+ sendNotice: async (g) => {
2839
+ const n = g.items.length;
2840
+ const text = `↺ Restarted — I didn't auto-resume ${n} message${n > 1 ? 's' : ''} you sent just before. If any still need a reply, send it again.`;
2841
+ const res = await tg(bot, 'sendMessage', {
2842
+ chat_id: Number(g.chat_id), text,
2843
+ ...(g.thread_id ? { message_thread_id: Number(g.thread_id) } : {}),
2844
+ }, { source: 'boot-replay-notice', plainText: true });
2845
+ return { ok: true, messageId: res?.message_id };
2846
+ },
2847
+ markSkipped: (row) => db.setInboundHandlerStatus({ chat_id: row.chat_id, msg_id: row.msg_id, status: 'replay-skipped' }),
2848
+ logEvent,
2849
+ },
2850
+ });
2851
+
2817
2852
  if (candidates.length > 0) {
2818
- console.log(`[replay] ${replayed} turns re-dispatched, ${skipped} skipped (already replied or no chat config)`);
2853
+ console.log(`[replay] ${cleanShutdown ? 'clean restart' : 'crash'} recovered ${result.recovered}, skipped ${result.skipped}, noticed ${result.noticed}${result.noticeFailed ? `, notice-failed ${result.noticeFailed}` : ''}`);
2819
2854
  logEvent('replay-on-boot', {
2820
- bot: BOT_NAME, replayed, skipped, total: candidates.length,
2855
+ bot: BOT_NAME, clean: cleanShutdown,
2856
+ recovered: result.recovered, skipped: result.skipped,
2857
+ noticed: result.noticed, notice_failed: result.noticeFailed,
2858
+ total: candidates.length,
2821
2859
  });
2822
2860
  }
2823
2861
  }