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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/approvals/store.js +45 -7
- package/lib/codex/runtime-controller.js +107 -1
- package/lib/db/event-detail-schema.js +523 -0
- package/lib/db/pairings.js +9 -1
- package/lib/db/secret-sweep.js +6 -3
- package/lib/db/sessions.js +2 -2
- package/lib/db.js +308 -49
- package/lib/handlers/abort.js +20 -3
- package/lib/handlers/approvals.js +2 -2
- package/lib/handlers/config-callback.js +7 -7
- package/lib/handlers/dispatcher.js +11 -9
- package/lib/handlers/questions.js +546 -45
- package/lib/handlers/slash-commands.js +54 -13
- package/lib/handlers/voice.js +1 -1
- package/lib/ops/clean-shutdown.js +5 -0
- package/lib/questions/questions.js +51 -0
- package/lib/questions/store.js +61 -5
- package/lib/rewind/execute.js +12 -2
- package/lib/rewind/rewind.js +1 -1
- package/lib/sdk/build-options.js +2 -2
- package/lib/sdk/callbacks.js +14 -9
- package/lib/secret-detect.js +280 -12
- package/lib/telegram/api.js +10 -2
- package/lib/telegram/rich-edit.js +9 -5
- package/lib/telegram/rich-send.js +6 -6
- package/migrations/020-drop-secret-fingerprint.sql +13 -0
- package/package.json +2 -2
- package/polygram.js +82 -45
|
@@ -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
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
-
|
|
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
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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
|
-
|
|
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(
|
|
147
|
-
const 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(
|
|
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:
|
|
158
|
-
await ack(
|
|
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(
|
|
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,
|
|
447
|
+
const auth = questions.claimOrCheck(id, callbackCtx.from?.id);
|
|
164
448
|
if (!auth.ok) {
|
|
165
|
-
logEvent('question-foreign-responder', { id, from_user:
|
|
166
|
-
await ack(
|
|
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
|
-
|
|
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(
|
|
460
|
+
if (res.kind === 'reject') { await ack(callbackCtx, res.message, true); return; }
|
|
174
461
|
|
|
175
462
|
if (res.kind === 'toggled') {
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
224
|
-
//
|
|
225
|
-
// dispatch
|
|
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:
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
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(
|
|
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
|
-
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|