polygram 0.38.2 → 0.38.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.
@@ -31,6 +31,20 @@
31
31
  'use strict';
32
32
 
33
33
  const Q = require('../questions/questions');
34
+
35
+ // Shown when an answer cannot be delivered because the exact value is gone.
36
+ // It must be true both when the provider never received anything and when it
37
+ // received an earlier answer before the interruption.
38
+ const QUESTION_INTERRUPTED_NOTICE = 'This question was interrupted. Polygram did not keep your'
39
+ + ' sensitive answer, so nothing was stored for a retry — wait for the assistant to ask again,'
40
+ + ' or send your original request once more.';
41
+
42
+ // Corrections for a card left behind by a call that finished after the
43
+ // question was already settled. They state only what the durable status says:
44
+ // the answer itself belongs to the turn that delivered it, not to a late
45
+ // caller trying to reconstruct a receipt it overwrote.
46
+ const QUESTION_ANSWERED_NOTICE = 'This question was answered.';
47
+ const QUESTION_TIMED_OUT_NOTICE = 'This question timed out.';
34
48
  const { tokensEqual } = require('../questions/store');
35
49
 
36
50
  function createQuestionHandlers({
@@ -43,6 +57,195 @@ function createQuestionHandlers({
43
57
  logger = console,
44
58
  } = {}) {
45
59
 
60
+ /**
61
+ * Live question context, one entry per open ask, keyed by tool_call_id (the
62
+ * same identity the answer routes back on). It holds everything that must
63
+ * not reach a durable row:
64
+ *
65
+ * questions — the agent's exact question array. The row keeps a sanitized
66
+ * copy; this one is hydrated back before the state machine
67
+ * renders a card or matches an option, so the live interaction
68
+ * is unchanged.
69
+ * answers — exact answers whose text carries a credential, keyed by
70
+ * question index. The row keeps a marker with no text.
71
+ * finalizing — the terminal claim. Taken synchronously before any provider
72
+ * write so concurrent taps produce exactly one answer.
73
+ *
74
+ * Entries are removed explicitly on every terminal path; nothing is evicted,
75
+ * because evicting another chat's pending answer to make room would destroy
76
+ * the only copy that exists. The bound is structural: one open question per
77
+ * session, one held answer per question index.
78
+ */
79
+ const liveContexts = new Map();
80
+
81
+ const contextFor = (toolCallId) => (toolCallId == null ? null : liveContexts.get(toolCallId) || null);
82
+
83
+ function openContext(toolCallId, sessionKey, exactQuestions) {
84
+ if (toolCallId == null) return null;
85
+ const ctx = {
86
+ sessionKey,
87
+ questions: Array.isArray(exactQuestions) ? exactQuestions : null,
88
+ answers: new Map(),
89
+ finalizing: false,
90
+ };
91
+ liveContexts.set(toolCallId, ctx);
92
+ return ctx;
93
+ }
94
+
95
+ const closeContext = (toolCallId) => { if (toolCallId != null) liveContexts.delete(toolCallId); };
96
+
97
+ /**
98
+ * Hold the newly recorded answer live when it is flagged. The row will get a
99
+ * marker in its place; the exact value never leaves this process.
100
+ *
101
+ * The context is never created here. One that vanished mid-interaction was
102
+ * disposed on purpose — the session was retired, or another path settled the
103
+ * question — and recreating it would resurrect an owner that no terminal
104
+ * path is tracking. Returns false when the answer needed holding and could
105
+ * not be held, which the caller turns into a cancellation.
106
+ */
107
+ function holdIfFlagged(ctx, state) {
108
+ const index = (state.answers?.length ?? 0) - 1;
109
+ if (index < 0) return true;
110
+ const answer = state.answers[index];
111
+ if (!Q.answerIsFlagged(answer)) return true;
112
+ if (!ctx) return false;
113
+ ctx.answers.set(index, answer);
114
+ return true;
115
+ }
116
+
117
+ /**
118
+ * Forget everything exact for this question. `keepOwnership` leaves a
119
+ * content-free tombstone that still blocks a second provider write — used
120
+ * when the answer WAS delivered but the terminal bookkeeping failed, so the
121
+ * row can stay pending without a duplicate delivery becoming possible.
122
+ */
123
+ function scrubContext(toolCallId, { keepOwnership = false } = {}) {
124
+ if (toolCallId == null) return;
125
+ const ctx = contextFor(toolCallId);
126
+ if (!keepOwnership) { liveContexts.delete(toolCallId); return; }
127
+ liveContexts.set(toolCallId, {
128
+ sessionKey: ctx?.sessionKey ?? null,
129
+ questions: null,
130
+ answers: new Map(),
131
+ finalizing: true,
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Retire a session's question state. One operation for every path that
137
+ * retires the process owning an open ask — a fresh session, a reload, an
138
+ * auto-recover reset, a verified stop, a rewind: the open row is closed as
139
+ * cancelled, its card is corrected, and every exact value held for that
140
+ * session is dropped.
141
+ *
142
+ * Best effort by contract. Callers are mid-teardown and must not be made to
143
+ * handle a failure here, so nothing is thrown; the scrub happens regardless
144
+ * of what the row write did, because a retiring session's exact values have
145
+ * nowhere left to go either way.
146
+ */
147
+ async function retireSession(sessionKey) {
148
+ let row = null;
149
+ try { row = questions.getOpenForSession(sessionKey); }
150
+ catch (e) { logger.error?.(`[${botName}] question retire lookup failed: ${e.message}`); }
151
+ // Drop the session's exact values first. Cancelling afterwards may install
152
+ // an ownership tombstone if the row write fails, and that tombstone is the
153
+ // only thing still blocking callbacks — a discard after it would delete it.
154
+ discardSession(sessionKey);
155
+ if (row) {
156
+ const ids = (() => {
157
+ try { return JSON.parse(row.message_ids_json || '[]'); } catch { return []; }
158
+ })();
159
+ try { await cancelWithoutLiveAnswer(row, ids[0]); }
160
+ catch (e) { logger.error?.(`[${botName}] question retire failed for ${row.tool_call_id}: ${e.message}`); }
161
+ }
162
+ }
163
+
164
+ /** Drop every live entry belonging to a session that is going away. */
165
+ function discardSession(sessionKey) {
166
+ for (const [toolCallId, ctx] of liveContexts) {
167
+ if (ctx.sessionKey === sessionKey) liveContexts.delete(toolCallId);
168
+ }
169
+ }
170
+
171
+ /** Drop everything; the process is stopping. */
172
+ function discardAll() { liveContexts.clear(); }
173
+
174
+ // Diagnostic seams: how much is being held right now. Counting is safe;
175
+ // reading is not, so no accessor exposes a value.
176
+ const liveAnswerCount = () => {
177
+ let total = 0;
178
+ for (const ctx of liveContexts.values()) total += ctx.answers.size;
179
+ return total;
180
+ };
181
+ const liveContextCount = () => liveContexts.size;
182
+
183
+ /**
184
+ * The one path from state to the row. Every durable write goes through it so
185
+ * the masking invariant cannot be forgotten at a new call site: sanitized
186
+ * questions (the store enforces those too) and a marker for every answer the
187
+ * context is holding.
188
+ */
189
+ function writeState(row, state, awaitingOther = false) {
190
+ return questions.updateState(row.id, forDurableWrite(state, contextFor(row.tool_call_id)), awaitingOther);
191
+ }
192
+
193
+ /**
194
+ * A durable write that cannot be completed ends the question rather than
195
+ * throwing out of a Telegram callback: the exact values are already live,
196
+ * and a caller that fails here must not leave them held with the row
197
+ * half-advanced. Returns false once the question has been cancelled.
198
+ */
199
+ async function writeStateOrCancel(row, state, awaitingOther, msgId, { claimHeld = false } = {}) {
200
+ try {
201
+ writeState(row, state, awaitingOther);
202
+ return true;
203
+ } catch (e) {
204
+ logger.error?.(`[${botName}] question state write failed for ${row.tool_call_id}: ${e.message}`);
205
+ // Sealing locally would leave the agent's `ask` blocked on an answer that
206
+ // is never coming. One cancellation goes out under terminal ownership —
207
+ // it carries no state, only the fact that the question ended.
208
+ if (claimHeld || claimTerminal(row)) {
209
+ try { answerQuestion?.(row.session_key, row.tool_call_id, { cancelled: true }); }
210
+ catch (err) { logger.error?.(`[${botName}] question cancel write-back failed for ${row.tool_call_id}: ${err.message}`); }
211
+ }
212
+ await cancelWithoutLiveAnswer(row, msgId);
213
+ return false;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * The state the state machine and the renderer see: the durable row with its
219
+ * exact questions and exact held answers put back. Without a context (after a
220
+ * restart, or once a session was retired) the durable copy is all there is,
221
+ * which is deliberate — the exact text went with the process.
222
+ */
223
+ function hydrate(row, ctx) {
224
+ const state = JSON.parse(row.state_json);
225
+ if (!ctx) return state;
226
+ return {
227
+ ...state,
228
+ questions: ctx.questions || state.questions,
229
+ answers: (state.answers || []).map((answer, index) => (
230
+ Q.isOmitted(answer) && ctx.answers.has(index) ? ctx.answers.get(index) : answer
231
+ )),
232
+ };
233
+ }
234
+
235
+ /**
236
+ * The state the row may hold: sanitized questions (the store enforces this
237
+ * too) and a marker in place of every answer being held live.
238
+ */
239
+ function forDurableWrite(state, ctx) {
240
+ if (!ctx) return state;
241
+ return {
242
+ ...state,
243
+ answers: (state.answers || []).map((answer, index) => (
244
+ ctx.answers.has(index) ? Q.markOmitted(answer) : answer
245
+ )),
246
+ };
247
+ }
248
+
46
249
  function strip(chatId, msgId, threadId, text) {
47
250
  if (msgId == null) return Promise.resolve();
48
251
  return tg(bot, 'editMessageText', {
@@ -67,31 +270,105 @@ function createQuestionHandlers({
67
270
  }
68
271
 
69
272
  /**
70
- * Hand the result to claude FIRST (guarded), then mark the row terminal. On
71
- * write-back failure: leave the row pending (the timeout sweep recovers it) and
72
- * return false — NEVER resolved-but-hung. Returns true when claude was answered.
273
+ * Hand the result to the agent, then mark the row terminal.
274
+ *
275
+ * Returns 'delivered' when the agent has the answer, 'settled' when another
276
+ * caller already owned this row, and 'failed' when the answer did not get
277
+ * through. Every failure scrubs: the context holds the exact question array
278
+ * as well as any held answer, and neither may survive a question that can no
279
+ * longer complete. A failure therefore cancels the row rather than leaving
280
+ * it pending for a retry it could only serve from sanitized text.
73
281
  */
74
- function finalize(row, result, status = 'answered') {
282
+ function finalize(row, result, status = 'answered', { claimHeld = false } = {}) {
283
+ // `claimHeld` is for a caller that already took the claim before its own
284
+ // Telegram awaits; claiming twice would make it lose to itself.
285
+ if (!claimHeld && !claimTerminal(row)) return 'settled'; // another caller is delivering
286
+ // The row's own status is the lasting record of "already delivered": the
287
+ // claim covers the in-flight window, and this covers a caller that started
288
+ // before the winner resolved the row. Re-read it rather than trusting the
289
+ // snapshot this caller has been holding across its awaits. The read is I/O
290
+ // like any other: failing it after an answer was accepted would leave the
291
+ // exact value held with the row still pending, so it fails the same way a
292
+ // failed write-back does.
293
+ let current;
294
+ try {
295
+ current = questions.getById(row.id);
296
+ } catch (e) {
297
+ logger.error?.(`[${botName}] question row read failed for ${row.tool_call_id}: ${e.message}`);
298
+ return failWithoutDelivery(row);
299
+ }
300
+ if (current && current.status !== 'pending') {
301
+ // Someone else settled this row and owns its cleanup. Only an empty
302
+ // entry — one this claim just created — is ours to drop.
303
+ if ((contextFor(row.tool_call_id)?.answers.size ?? 0) === 0) closeContext(row.tool_call_id);
304
+ return 'settled';
305
+ }
75
306
  let delivered;
76
307
  try {
77
308
  delivered = answerQuestion?.(row.session_key, row.tool_call_id, result);
78
309
  } catch (e) {
79
310
  logger.error?.(`[${botName}] answerQuestion failed for ${row.tool_call_id}: ${e.message}`);
80
- return false; // threw → leave the row pending; the timeout sweep recovers it
311
+ // Reported here, once, for every caller that can reach a write-back.
312
+ logEvent('question-answer-writeback-failed', {
313
+ session_key: row.session_key, tool_call_id: row.tool_call_id,
314
+ });
315
+ return failWithoutDelivery(row);
81
316
  }
82
- // A *false* return is a silent no-op (session gone / no live bridge), NOT a
83
- // throw. Surface it loudly and still resolve: a dead session can never be
84
- // delivered, so leaving it pending would have the 30s sweep re-strip +
85
- // re-answer it forever. The rare live-proc-but-unwritable-bridge case is
86
- // backstopped by the bridge's own 20-min answer ceiling.
317
+ // A *false* return is a confirmed local non-delivery — the session is gone
318
+ // or there is no live bridge. It is not success, and the row must not be
319
+ // marked answered on the strength of a write that did not happen.
87
320
  if (delivered === false) {
88
321
  logger.error?.(`[${botName}] answerQuestion undelivered (session gone / no bridge) for ${row.tool_call_id}`);
89
322
  logEvent('question-answer-undelivered', { session_key: row.session_key, tool_call_id: row.tool_call_id });
323
+ return failWithoutDelivery(row);
90
324
  }
91
- questions.resolve(row.id, status);
325
+ // Delivered. If the bookkeeping now fails, the exact values still go —
326
+ // only the ownership tombstone stays, so the still-pending row cannot be
327
+ // answered a second time.
328
+ try {
329
+ questions.resolve(row.id, status);
330
+ } catch (e) {
331
+ logger.error?.(`[${botName}] question resolve failed for ${row.tool_call_id}: ${e.message}`);
332
+ scrubContext(row.tool_call_id, { keepOwnership: true });
333
+ logEvent('question-resolve-failed', {
334
+ session_key: row.session_key, tool_call_id: row.tool_call_id,
335
+ });
336
+ return 'delivered';
337
+ }
338
+ closeContext(row.tool_call_id);
339
+ return 'delivered';
340
+ }
341
+
342
+ /** Nothing reached the agent: forget the exact values and close the row. */
343
+ function failWithoutDelivery(row) {
344
+ scrubContext(row.tool_call_id);
345
+ try { questions.resolve(row.id, 'cancelled'); }
346
+ catch (e) { logger.error?.(`[${botName}] question cancel failed for ${row.tool_call_id}: ${e.message}`); }
347
+ return 'failed';
348
+ }
349
+
350
+ /**
351
+ * The terminal claim. JavaScript runs this to completion, so the check and the
352
+ * set cannot interleave: concurrent taps, a tap racing a typed answer, and a
353
+ * duplicate completion all resolve to exactly one provider write. A losing
354
+ * caller returns without touching the live values the winner still needs.
355
+ */
356
+ function claimTerminal(row) {
357
+ const toolCallId = row?.tool_call_id;
358
+ if (toolCallId == null) return true;
359
+ // An entry created here carries the session, or retiring that session
360
+ // could never reach it again.
361
+ const ctx = contextFor(toolCallId) || openContext(toolCallId, row.session_key, null);
362
+ if (ctx.finalizing) return false;
363
+ ctx.finalizing = true;
92
364
  return true;
93
365
  }
94
366
 
367
+ const releaseTerminal = (toolCallId) => {
368
+ const ctx = contextFor(toolCallId);
369
+ if (ctx) ctx.finalizing = false;
370
+ };
371
+
95
372
  // ── claude called ask → render the first question ──────────────────
96
373
  async function renderAsk({ sessionKey, chatId, threadId = null, turnId = null, toolCallId, questions: qs }) {
97
374
  let row = null;
@@ -113,6 +390,10 @@ function createQuestionHandlers({
113
390
  if (pIds[0]) strip(prior.chat_id, pIds[0], prior.thread_id, 'This question was replaced.');
114
391
  }
115
392
  const state = Q.initState(qs);
393
+ // The exact array goes to the live context; the row gets the sanitized
394
+ // copy the store writes. Everything the user sees is rendered from the
395
+ // exact one.
396
+ openContext(toolCallId, sessionKey, qs);
116
397
  row = questions.issue({
117
398
  bot_name: botName, session_key: sessionKey, chat_id: chatId, thread_id: threadId,
118
399
  turn_id: turnId, tool_call_id: toolCallId, questions: qs, state,
@@ -134,6 +415,9 @@ function createQuestionHandlers({
134
415
  // now. If the row WAS issued (throw in a later step), it is pending and the
135
416
  // timeout sweep will recover it with {timedout}.
136
417
  if (!row) {
418
+ // The context was opened before the row existed, so a store that threw
419
+ // leaves an entry keyed by a tool call no terminal path can ever reach.
420
+ closeContext(toolCallId);
137
421
  try { answerQuestion?.(sessionKey, toolCallId, { cancelled: true, error: 'failed to render question' }); } catch (e2) {
138
422
  logger.error?.(`[${botName}] renderAsk fallback answer failed for ${toolCallId}: ${e2.message}`);
139
423
  }
@@ -143,8 +427,8 @@ function createQuestionHandlers({
143
427
  }
144
428
 
145
429
  // ── a `q:<id>:<token>:<action>` button tap ─────────────────────────
146
- async function handleQuestionCallback(ctx) {
147
- const data = ctx.callbackQuery?.data || '';
430
+ async function handleQuestionCallback(callbackCtx) {
431
+ const data = callbackCtx.callbackQuery?.data || '';
148
432
  const m = String(data).match(/^q:(\d+):([^:]+):(.+)$/);
149
433
  if (!m) return;
150
434
  const id = parseInt(m[1], 10);
@@ -152,51 +436,70 @@ function createQuestionHandlers({
152
436
  const actionStr = m[3];
153
437
 
154
438
  const row = questions.getById(id);
155
- if (!row) { await ack(ctx, 'This question expired.', true); return; }
439
+ if (!row) { await ack(callbackCtx, 'This question expired.', true); return; }
156
440
  if (!tokensEqual(row.callback_token, token)) {
157
- logEvent('question-token-mismatch', { id, from_user: ctx.from?.id });
158
- await ack(ctx, 'Bad token.', true); return;
441
+ logEvent('question-token-mismatch', { id, from_user: callbackCtx.from?.id });
442
+ await ack(callbackCtx, 'Bad token.', true); return;
159
443
  }
160
- if (row.status !== 'pending') { await ack(ctx, `Already ${row.status}.`, true); return; }
444
+ if (row.status !== 'pending') { await ack(callbackCtx, `Already ${row.status}.`, true); return; }
161
445
 
162
446
  // Respondent authorization: first tapper claims the question; others rejected.
163
- const auth = questions.claimOrCheck(id, ctx.from?.id);
447
+ const auth = questions.claimOrCheck(id, callbackCtx.from?.id);
164
448
  if (!auth.ok) {
165
- logEvent('question-foreign-responder', { id, from_user: ctx.from?.id, owner: row.from_id });
166
- await ack(ctx, 'This question is for someone else.', true); return;
449
+ logEvent('question-foreign-responder', { id, from_user: callbackCtx.from?.id, owner: row.from_id });
450
+ await ack(callbackCtx, 'This question is for someone else.', true); return;
167
451
  }
168
452
 
169
- const state = JSON.parse(row.state_json);
453
+ // Captured before any Telegram await, so every later step can tell whether
454
+ // the interaction it belongs to is still the live one.
455
+ const captured = contextFor(row.tool_call_id);
456
+ const state = hydrate(row, captured);
170
457
  const res = Q.applyTap(state, Q.parseAction(actionStr));
171
458
  const msgId = (JSON.parse(row.message_ids_json || '[]'))[0];
172
459
 
173
- if (res.kind === 'reject') { await ack(ctx, res.message, true); return; }
460
+ if (res.kind === 'reject') { await ack(callbackCtx, res.message, true); return; }
174
461
 
175
462
  if (res.kind === 'toggled') {
176
- questions.updateState(id, res.state, false);
463
+ if (!await writeStateOrCancel(row, res.state, false, msgId)) return;
177
464
  const view = Q.renderCurrent(res.state, `q:${id}:${token}`);
178
465
  await tg(bot, 'editMessageReplyMarkup', {
179
466
  chat_id: row.chat_id, message_id: msgId, reply_markup: view.reply_markup,
180
467
  ...(row.thread_id && { message_thread_id: row.thread_id }),
181
468
  }, { source: 'question-edit', botName })
182
469
  .catch((e) => logger.error?.(`[${botName}] toggle re-render failed (q ${id}): ${e.message}`));
183
- await ack(ctx);
470
+ // The keyboard edit can land after a retirement's notice, which would
471
+ // make a closed question tappable again.
472
+ if (isStale(row, captured)) {
473
+ await settleInterrupted(row, msgId);
474
+ return;
475
+ }
476
+ await ack(callbackCtx);
184
477
  return;
185
478
  }
186
479
 
187
480
  if (res.kind === 'awaiting-other') {
188
- questions.updateState(id, res.state, true);
481
+ if (!await writeStateOrCancel(row, res.state, true, msgId)) return;
189
482
  await strip(row.chat_id, msgId, row.thread_id, 'Send your answer as a message.');
190
- await ack(ctx, 'Type your answer ↓');
483
+ // An invitation to type is the one thing a retired question must not be
484
+ // left showing, and the ack behind it would read as success.
485
+ if (isStale(row, captured)) {
486
+ await settleInterrupted(row, msgId);
487
+ return;
488
+ }
489
+ await ack(callbackCtx, 'Type your answer ↓');
191
490
  return;
192
491
  }
193
492
 
194
493
  // advanced — record + receipt, then next question or finish.
195
- await advance(ctx, row, res, false);
494
+ await advance(callbackCtx, row, res, false, captured);
196
495
  }
197
496
 
198
497
  // ── a typed message while a question is open (Other / nudge) ────────
199
498
  async function tryConsumeAsAnswer({ sessionKey, fromId, text }) {
499
+ // Once the text has been accepted as this question's answer it stays
500
+ // consumed. Letting a later failure report "not an answer" would send the
501
+ // same text on as an ordinary turn — the one place it must never go.
502
+ let acceptedAsAnswer = false;
200
503
  try {
201
504
  const row = questions.getOpenForSession(sessionKey);
202
505
  if (!row) return { consumed: false };
@@ -205,7 +508,7 @@ function createQuestionHandlers({
205
508
  // every group member's message for the whole question lifetime).
206
509
  if (!row.awaiting_other) return { consumed: false };
207
510
  // /stop, /new and other commands are never consumed as a free-text answer.
208
- if (/^\/(stop|new|reset|clear|cancel|abort)\b/i.test(String(text || '').trim())) return { consumed: false };
511
+ if (/^\/(stop|new|reset|clear|cancel|abort|reload)\b/i.test(String(text || '').trim())) return { consumed: false };
209
512
  // Identity: only the claimed owner supplies the free-text answer.
210
513
  const auth = questions.claimOrCheck(row.id, fromId);
211
514
  if (!auth.ok) {
@@ -214,17 +517,19 @@ function createQuestionHandlers({
214
517
  .catch((e) => logger.error?.(`[${botName}] question nudge failed: ${e.message}`));
215
518
  return { consumed: true };
216
519
  }
217
- const state = JSON.parse(row.state_json);
520
+ const captured = contextFor(row.tool_call_id);
521
+ const state = hydrate(row, captured);
218
522
  const res = Q.applyFreeText(state, text);
219
523
  if (res.kind !== 'advanced') return { consumed: false };
220
- await advance({ from: { id: fromId } }, row, res, true);
524
+ acceptedAsAnswer = true;
525
+ await advance({ from: { id: fromId } }, row, res, true, captured);
221
526
  return { consumed: true };
222
527
  } catch (e) {
223
- // Never throw out of the message dispatcher: a store/parse error here must
224
- // degrade to "not an answer" so the user's message still reaches normal
225
- // dispatch instead of being silently dropped.
528
+ // Never throw out of the message dispatcher: a store/parse error BEFORE
529
+ // the text was accepted degrades to "not an answer" so the message still
530
+ // reaches normal dispatch. After acceptance it stays consumed.
226
531
  logger.error?.(`[${botName}] tryConsumeAsAnswer failed: ${e.message}`);
227
- return { consumed: false };
532
+ return { consumed: acceptedAsAnswer };
228
533
  }
229
534
  }
230
535
 
@@ -239,12 +544,68 @@ function createQuestionHandlers({
239
544
  } catch { return false; }
240
545
  }
241
546
 
547
+ /**
548
+ * Boot: a row whose state carries a marker was left behind by a process that
549
+ * no longer exists, and the exact answer went with it. It is cancelled — not
550
+ * replayed, because a socket write was never proof the provider consumed
551
+ * anything, and not answered with the marker, which carries no answer at all.
552
+ *
553
+ * Only marked rows are touched. An ordinary leftover row is a separate
554
+ * problem with its own fix.
555
+ */
556
+ async function reconcileMarkedQuestionsAtBoot(rows) {
557
+ let reconciled = 0;
558
+ for (const row of rows || []) {
559
+ let marked = false;
560
+ try {
561
+ marked = (JSON.parse(row.state_json)?.answers || []).some(Q.isOmitted);
562
+ } catch { marked = false; }
563
+ if (!marked) continue;
564
+ const ids = JSON.parse(row.message_ids_json || '[]');
565
+ // Propagates: a marked row left pending must fail the boot fence rather
566
+ // than be counted as reconciled.
567
+ // eslint-disable-next-line no-await-in-loop
568
+ await cancelWithoutLiveAnswer(row, ids[0], { propagate: true });
569
+ reconciled += 1;
570
+ }
571
+ if (reconciled) logEvent('questions-reconciled-at-boot', { count: reconciled });
572
+ return reconciled;
573
+ }
574
+
242
575
  // ── timeout sweep / external cancel ────────────────────────────────
243
576
  async function expireQuestion(row, { status = 'timeout', message = 'This question timed out.' } = {}) {
577
+ // Ownership first, and before any await: the sweep works from a snapshot
578
+ // and can reach a row that has just been answered. A loser that edits the
579
+ // card would replace a real answer's receipt with "timed out" and report
580
+ // an expiry that never happened.
581
+ if (!claimTerminal(row)) return;
582
+ let fresh = null;
583
+ try {
584
+ fresh = questions.getById(row.id);
585
+ } catch (e) {
586
+ // Ownership is unproven, and unproven is not owned: proceeding would
587
+ // overwrite whatever the real owner left on the card. Release the claim
588
+ // so the next sweep can evaluate the row properly.
589
+ logger.error?.(`[${botName}] question expiry read failed for ${row.tool_call_id}: ${e.message}`);
590
+ releaseTerminal(row.tool_call_id);
591
+ return;
592
+ }
593
+ if (fresh && fresh.status !== 'pending') {
594
+ if ((contextFor(row.tool_call_id)?.answers.size ?? 0) === 0) closeContext(row.tool_call_id);
595
+ return;
596
+ }
597
+
244
598
  const ids = JSON.parse(row.message_ids_json || '[]');
245
599
  if (ids[0]) await strip(row.chat_id, ids[0], row.thread_id, message);
246
600
  const result = status === 'cancelled' ? { cancelled: true } : { timedout: true };
247
- finalize(row, result, status);
601
+ // finalize owns the cleanup for the disposition it actually performed;
602
+ // closing here as well would let a sweep that lost the claim, or arrived
603
+ // after another path settled the row, clear state it never owned.
604
+ // Only the caller that actually carried out the disposition reports it:
605
+ // a row another path had already settled, or one whose write-back failed,
606
+ // did not expire here.
607
+ const outcome = finalize(row, result, status, { claimHeld: true });
608
+ if (outcome !== 'delivered') return;
248
609
  logEvent('question-expired', { session_key: row.session_key, tool_call_id: row.tool_call_id, status });
249
610
  }
250
611
 
@@ -256,6 +617,7 @@ function createQuestionHandlers({
256
617
  ? strip(row.chat_id, ids[0], row.thread_id, message)
257
618
  : Promise.resolve();
258
619
  questions.resolve(row.id, 'cancelled');
620
+ closeContext(row.tool_call_id);
259
621
  logEvent('question-expired', {
260
622
  session_key: row.session_key,
261
623
  tool_call_id: row.tool_call_id,
@@ -264,38 +626,171 @@ function createQuestionHandlers({
264
626
  return editing;
265
627
  }
266
628
 
267
- // shared: record an advanced result, post a receipt, then next-Q or resolve.
268
- async function advance(ctx, row, res, fromText) {
629
+ /**
630
+ * Record an advanced result, post a receipt, then move to the next question
631
+ * or finish.
632
+ *
633
+ * `captured` is the live context this interaction started from, taken before
634
+ * any Telegram await. Everything after an await re-checks it: a reset, a
635
+ * retirement or a competing terminal path can dispose or replace the context
636
+ * while this call is suspended, and such a caller must change nothing —
637
+ * no persistence, no delivery, no receipt. The terminal claim is likewise
638
+ * taken before the receipt, so two taps on different final options produce
639
+ * one answer and one receipt rather than two of each.
640
+ */
641
+ async function advance(callbackCtx, row, res, fromText, captured) {
269
642
  const msgId = (JSON.parse(row.message_ids_json || '[]'))[0];
643
+ if (isStale(row, captured)) return;
644
+
645
+ // Hold before anything is written or shown: the marker that goes to the row
646
+ // depends on it. A flagged answer with nowhere live to go cannot be stored
647
+ // and cannot be delivered, so the question ends with the notice.
648
+ if (!holdIfFlagged(captured, res.state)) {
649
+ await cancelWithoutLiveAnswer(row, msgId);
650
+ return;
651
+ }
652
+ const claimHeld = res.done ? claimTerminal(row) : false;
653
+ if (res.done && !claimHeld) return; // a competing final answer won
654
+
655
+ if (!await writeStateOrCancel(row, res.state, false, msgId, { claimHeld })) return;
656
+
270
657
  // Visible receipt on the question card — for BOTH a button tap and a typed "Other"
271
658
  // answer. The typed-answer path used to skip this entirely, so a free-text answer (esp.
272
659
  // the LAST question) left the card frozen on "Send your answer as a message." with no
273
660
  // acknowledgment — "I answered and nothing happened" (prod: hire topic 2026-06-09).
274
661
  await strip(row.chat_id, msgId, row.thread_id, `✓ ${res.receipt}`);
275
- if (!fromText) await ack(ctx, 'Recorded'); // callback-query ack — button taps only
662
+ if (!fromText) await ack(callbackCtx, 'Recorded'); // callback-query ack — button taps only
663
+
664
+ // The awaits above can span a reset, a reload or a retirement. Checked once
665
+ // here, before the branch, because both continuations are wrong for a dead
666
+ // interaction: the final one must not deliver, and the next question must
667
+ // not be sent into a session that no longer exists.
668
+ if (isStale(row, captured)) {
669
+ await settleInterrupted(row, msgId);
670
+ return;
671
+ }
276
672
 
277
673
  if (res.done) {
278
- questions.updateState(row.id, res.state, false);
279
- // Answer claude FIRST (guarded), THEN mark answered. If the write-back
280
- // throws, leave the row pending → the timeout sweep recovers it.
281
- if (!finalize(row, Q.assemble(res.state), 'answered')) {
282
- logEvent('question-answer-writeback-failed', { session_key: row.session_key, tool_call_id: row.tool_call_id });
674
+ // Resolve the payload from the live values. A marker still standing here
675
+ // means the exact answer went with a retired session or a dead process:
676
+ // cancel and say so rather than send a placeholder upstream.
677
+ const resolved = Q.resolveForDelivery(res.state);
678
+ if (!resolved.ok) {
679
+ await cancelWithoutLiveAnswer(row, msgId);
680
+ return;
681
+ }
682
+ // Answer the agent FIRST, THEN mark answered. A failed write-back reports
683
+ // itself from finalize, the one place every caller reaches; the card is
684
+ // corrected here so a success receipt never stands over a failure.
685
+ const outcome = finalize(row, resolved.result, 'answered', { claimHeld });
686
+ if (outcome === 'failed') {
687
+ await strip(row.chat_id, msgId, row.thread_id, QUESTION_INTERRUPTED_NOTICE);
283
688
  return;
284
689
  }
690
+ if (outcome === 'settled') return;
285
691
  logEvent('question-answered', { session_key: row.session_key, tool_call_id: row.tool_call_id });
286
692
  return;
287
693
  }
288
- // next question → new message; on send failure, don't strand claude.
694
+ // next question → new message; on send failure, don't strand the agent.
289
695
  const nextMsgId = await sendCurrent(row, res.state);
290
696
  if (nextMsgId == null) {
291
697
  finalize(row, { cancelled: true, error: 'failed to deliver the next question' }, 'cancelled');
292
698
  logEvent('question-send-failed', { session_key: row.session_key, tool_call_id: row.tool_call_id, phase: 'next', q_index: res.state.qIndex });
293
699
  return;
294
700
  }
295
- questions.updateState(row.id, res.state, false);
701
+ // The send can outlive the interaction. The row must not adopt a card for
702
+ // a question it will never ask, and the card that was just posted is the
703
+ // one now needing the notice.
704
+ if (isStale(row, captured)) {
705
+ await settleInterrupted(row, nextMsgId);
706
+ return;
707
+ }
296
708
  questions.setMessageIds(row.id, [nextMsgId]);
297
709
  }
298
710
 
711
+ /**
712
+ * The interaction this call belonged to is gone, and it may have shown or
713
+ * created something on the way out. Settle whatever it left behind against
714
+ * the row as it stands now:
715
+ *
716
+ * still pending — this call closes it, with the notice on `msgId`.
717
+ * cancelled — already closed; correct `msgId`, which may be a card
718
+ * this call itself just posted or overwrote.
719
+ * answered/timed out — a legitimate winner owns that card. Leave it.
720
+ *
721
+ * `msgId` is deliberately a parameter: the card needing correction is not
722
+ * always the row's own — a next-question send that landed after the
723
+ * retirement created a new one.
724
+ */
725
+ async function settleInterrupted(row, msgId) {
726
+ let fresh;
727
+ try {
728
+ fresh = questions.getById(row.id);
729
+ } catch (e) {
730
+ // A failed read is not a missing row. Without a status there is nothing
731
+ // to correct TO, and guessing could overwrite a real answer's card, so
732
+ // this caller does nothing at all.
733
+ logger.error?.(`[${botName}] stale row read failed for ${row.tool_call_id}: ${e.message}`);
734
+ return;
735
+ }
736
+ if (fresh && fresh.status === 'pending') {
737
+ await cancelWithoutLiveAnswer(row, msgId);
738
+ return;
739
+ }
740
+ // Already terminal: correct only the card, never the row, and say only
741
+ // what the durable status supports — the receipt this call overwrote is
742
+ // not reconstructable and must not be invented.
743
+ await strip(row.chat_id, msgId, row.thread_id, terminalNoticeFor(fresh?.status));
744
+ }
745
+
746
+ function terminalNoticeFor(status) {
747
+ if (status === 'answered') return QUESTION_ANSWERED_NOTICE;
748
+ if (status === 'timeout' || status === 'expired') return QUESTION_TIMED_OUT_NOTICE;
749
+ return QUESTION_INTERRUPTED_NOTICE; // cancelled, or a row that is gone
750
+ }
751
+
752
+ /**
753
+ * True when the live context this interaction started from is no longer the
754
+ * one in place — disposed, or replaced by a newer ask on the same tool call.
755
+ */
756
+ function isStale(row, captured) {
757
+ if (captured == null) return false; // the interaction never had one
758
+ return contextFor(row.tool_call_id) !== captured;
759
+ }
760
+
761
+ /**
762
+ * The exact answer is gone and nothing may be sent in its place. The wording
763
+ * has to hold whether or not an earlier answer reached the provider, because
764
+ * a socket write is not proof the provider consumed it.
765
+ */
766
+ function cancelWithoutLiveAnswer(row, msgId, { propagate = false } = {}) {
767
+ // The card edit is started but NOT awaited yet: yielding here would leave a
768
+ // window where the row still reads pending with no context, which is
769
+ // exactly the state an older callback needs to claim ownership and deliver.
770
+ // Everything that closes the question happens synchronously first.
771
+ const editing = strip(row.chat_id, msgId, row.thread_id, QUESTION_INTERRUPTED_NOTICE);
772
+ let failure = null;
773
+ try {
774
+ questions.resolve(row.id, 'cancelled');
775
+ scrubContext(row.tool_call_id);
776
+ } catch (e) {
777
+ failure = e;
778
+ logger.error?.(`[${botName}] question cancel failed for ${row.tool_call_id}: ${e.message}`);
779
+ // The row is still pending, so the block has to come from memory: a
780
+ // content-free tombstone keeps the claim taken and holds nothing.
781
+ scrubContext(row.tool_call_id, { keepOwnership: true });
782
+ }
783
+ logEvent('question-live-answer-missing', {
784
+ session_key: row.session_key, tool_call_id: row.tool_call_id,
785
+ });
786
+ return editing.then(() => {
787
+ // A Telegram callback must never throw — the dispatcher would treat the
788
+ // user's own text as an ordinary turn. Boot is the opposite: a row it
789
+ // could not cancel has to stop startup, so that caller asks to know.
790
+ if (failure && propagate) throw failure;
791
+ });
792
+ }
793
+
299
794
  function ack(ctx, text, alert = false) {
300
795
  if (!ctx || typeof ctx.answerCallbackQuery !== 'function') return Promise.resolve();
301
796
  return ctx.answerCallbackQuery(text ? { text, show_alert: alert } : undefined).catch(() => {});
@@ -308,6 +803,12 @@ function createQuestionHandlers({
308
803
  expireQuestion,
309
804
  beginShutdownDisposition,
310
805
  isAwaitingOtherFrom,
806
+ reconcileMarkedQuestionsAtBoot,
807
+ retireSession,
808
+ discardSession,
809
+ discardAll,
810
+ liveAnswerCount,
811
+ liveContextCount,
311
812
  };
312
813
  }
313
814