polygram 0.37.2 → 0.38.1
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/codex/fault-provenance.js +75 -0
- package/lib/codex/runtime-controller.js +4 -0
- package/lib/db/codex-failed-generation.js +8 -1
- package/lib/db/events-retention.js +1 -0
- package/lib/db.js +4 -0
- package/lib/handlers/config-ui.js +53 -44
- package/lib/handlers/poll.js +138 -59
- package/lib/handlers/questions.js +26 -1
- package/lib/ipc/handlers.js +25 -0
- package/lib/ops/clean-restart-qualification.js +260 -0
- package/lib/ops/clean-resume.js +12 -1
- package/lib/ops/clean-shutdown.js +105 -6
- package/lib/ops/daemon-identity.js +42 -0
- package/lib/ops/systemd-invocation.js +10 -3
- package/lib/telegram/display-hint.js +1 -0
- package/package.json +2 -2
- package/polygram.js +206 -83
- package/scripts/ipc-smoke.js +38 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.38.1",
|
|
5
5
|
"description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ROOT_ERROR_CODES = new Set([
|
|
4
|
+
'CODEX_PROTOCOL_ERROR',
|
|
5
|
+
'CODEX_TRANSPORT_ERROR',
|
|
6
|
+
'CODEX_PROCESS_EXITED',
|
|
7
|
+
'CODEX_PROCESS_ERROR',
|
|
8
|
+
'CODEX_RPC_TIMEOUT',
|
|
9
|
+
'CODEX_SINK_TIMEOUT',
|
|
10
|
+
'CODEX_PROCESS_CLOSE_TIMEOUT',
|
|
11
|
+
'CODEX_PROCESS_CLEANUP_UNVERIFIED',
|
|
12
|
+
'unknown',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const FAULT_CLASSES = new Set([
|
|
16
|
+
'stderr-limit',
|
|
17
|
+
'transport',
|
|
18
|
+
'protocol',
|
|
19
|
+
'process-exit',
|
|
20
|
+
'rpc-timeout',
|
|
21
|
+
'sink',
|
|
22
|
+
'cleanup',
|
|
23
|
+
'unknown',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const NOTIFICATION_METHODS = new Set([
|
|
27
|
+
'error',
|
|
28
|
+
'thread/status/changed',
|
|
29
|
+
'thread/settings/updated',
|
|
30
|
+
'turn/started',
|
|
31
|
+
'turn/completed',
|
|
32
|
+
'item/started',
|
|
33
|
+
'item/completed',
|
|
34
|
+
'item/agentMessage/delta',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const PROCESS_STATES = new Set([
|
|
38
|
+
'Spawning',
|
|
39
|
+
'Initializing',
|
|
40
|
+
'AttachingThread',
|
|
41
|
+
'Idle',
|
|
42
|
+
'StartingTurn',
|
|
43
|
+
'Active',
|
|
44
|
+
'BackgroundWorking',
|
|
45
|
+
'BackgroundSettling',
|
|
46
|
+
'Settling',
|
|
47
|
+
'Quiescing',
|
|
48
|
+
'Stopped',
|
|
49
|
+
'Closing',
|
|
50
|
+
'Closed',
|
|
51
|
+
'FailedAmbiguous',
|
|
52
|
+
'RecoveryConflict',
|
|
53
|
+
'DurabilityBlocked',
|
|
54
|
+
'ContainmentFailed',
|
|
55
|
+
'unknown',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
function sanitizeCodexFaultProvenance(input) {
|
|
59
|
+
const safe = {};
|
|
60
|
+
if (ROOT_ERROR_CODES.has(input?.clientRootErrorCode)) {
|
|
61
|
+
safe.clientRootErrorCode = input.clientRootErrorCode;
|
|
62
|
+
}
|
|
63
|
+
if (FAULT_CLASSES.has(input?.clientFaultClass)) {
|
|
64
|
+
safe.clientFaultClass = input.clientFaultClass;
|
|
65
|
+
}
|
|
66
|
+
if (NOTIFICATION_METHODS.has(input?.notificationMethod)) {
|
|
67
|
+
safe.notificationMethod = input.notificationMethod;
|
|
68
|
+
}
|
|
69
|
+
if (PROCESS_STATES.has(input?.observedProcessState)) {
|
|
70
|
+
safe.observedProcessState = input.observedProcessState;
|
|
71
|
+
}
|
|
72
|
+
return safe;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { sanitizeCodexFaultProvenance };
|
|
@@ -6,6 +6,9 @@ const orchestraDefault = require('@shumkov/orchestra');
|
|
|
6
6
|
const { resolvePinnedCodexBinary } = require('./binary');
|
|
7
7
|
const { resolveCodexHostIdentity } = require('./host-identity');
|
|
8
8
|
const { CodexModelCatalog } = require('./model-catalog');
|
|
9
|
+
const {
|
|
10
|
+
sanitizeCodexFaultProvenance,
|
|
11
|
+
} = require('./fault-provenance');
|
|
9
12
|
const {
|
|
10
13
|
createCodexRuntimeProfileBuilder,
|
|
11
14
|
} = require('./runtime-profile');
|
|
@@ -1432,6 +1435,7 @@ function createCodexRuntimeController({
|
|
|
1432
1435
|
app_server_session_id: expectedAppServerSessionId,
|
|
1433
1436
|
reason: deliveryResultString(payload.reason, 'containment reason'),
|
|
1434
1437
|
source: 'managed-group-empty',
|
|
1438
|
+
...sanitizeCodexFaultProvenance(payload),
|
|
1435
1439
|
allow_missing_generation: !record.durable,
|
|
1436
1440
|
ts: payload.ts ?? Date.now(),
|
|
1437
1441
|
});
|
|
@@ -7,6 +7,9 @@ const {
|
|
|
7
7
|
const {
|
|
8
8
|
CODEX_APP_SERVER_NAMESPACE,
|
|
9
9
|
} = require('./sessions');
|
|
10
|
+
const {
|
|
11
|
+
sanitizeCodexFaultProvenance,
|
|
12
|
+
} = require('../codex/fault-provenance');
|
|
10
13
|
const SETTLEMENT_SOURCES = new Set([
|
|
11
14
|
'managed-group-empty',
|
|
12
15
|
'exclusive-takeover-grace',
|
|
@@ -428,7 +431,11 @@ function settleCodexFailedGeneration(rawDb, input) {
|
|
|
428
431
|
`).run(
|
|
429
432
|
generationId,
|
|
430
433
|
providerSessionId,
|
|
431
|
-
JSON.stringify({
|
|
434
|
+
JSON.stringify({
|
|
435
|
+
source,
|
|
436
|
+
reason,
|
|
437
|
+
...sanitizeCodexFaultProvenance(input),
|
|
438
|
+
}),
|
|
432
439
|
ts,
|
|
433
440
|
);
|
|
434
441
|
|
|
@@ -44,6 +44,7 @@ const DEFAULT_POLICY = {
|
|
|
44
44
|
// silently lost? Kept so "why didn't my message get answered after the
|
|
45
45
|
// restart?" is answerable beyond the 90d default (still capped).
|
|
46
46
|
'replay-on-boot', 'replay-notice-sent', 'replay-notice-failed',
|
|
47
|
+
'clean-restart-qualification-observed',
|
|
47
48
|
// 0.15 secret-redaction audit — what was redacted/flagged, kept for review.
|
|
48
49
|
'secret-sweep', 'secret-sweep-failed',
|
|
49
50
|
'secret-redacted-by-agent', 'secret-redact-requested-no-match',
|
package/lib/db.js
CHANGED
|
@@ -23,6 +23,9 @@ const {
|
|
|
23
23
|
codexError,
|
|
24
24
|
requiredString: requiredCodexString,
|
|
25
25
|
} = require('./db/codex-input');
|
|
26
|
+
const {
|
|
27
|
+
sanitizeCodexFaultProvenance,
|
|
28
|
+
} = require('./codex/fault-provenance');
|
|
26
29
|
|
|
27
30
|
// 0.8.0 Phase 1: bumped from 9 → 10. Adds migration
|
|
28
31
|
// 010-tool-use-id.sql (pending_approvals.tool_use_id column for the
|
|
@@ -894,6 +897,7 @@ function wrap(db) {
|
|
|
894
897
|
if (value != null) detail[key] = value;
|
|
895
898
|
}
|
|
896
899
|
}
|
|
900
|
+
Object.assign(detail, sanitizeCodexFaultProvenance(input));
|
|
897
901
|
return Object.keys(detail).length > 0 ? JSON.stringify(detail) : null;
|
|
898
902
|
}
|
|
899
903
|
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
|
|
19
19
|
const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
|
|
20
20
|
const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
21
|
-
const CODEX_MODEL_OPTIONS =
|
|
22
|
-
'gpt-5.6-sol',
|
|
23
|
-
'gpt-5.6-terra',
|
|
24
|
-
'gpt-5.6-luna',
|
|
21
|
+
const CODEX_MODEL_OPTIONS = Object.freeze([
|
|
22
|
+
Object.freeze({ model: 'gpt-5.6-sol', label: 'Sol' }),
|
|
23
|
+
Object.freeze({ model: 'gpt-5.6-terra', label: 'Terra' }),
|
|
24
|
+
Object.freeze({ model: 'gpt-5.6-luna', label: 'Luna' }),
|
|
25
25
|
]);
|
|
26
26
|
// Which runtimes the card offers. The sdk backend stays fully supported in
|
|
27
27
|
// code and config files — it is per-token and nobody picks it from a chat, so
|
|
@@ -63,20 +63,30 @@ function getCodexCatalogModels(runtimeView) {
|
|
|
63
63
|
if (!isCodexRuntimeView(runtimeView) || !Array.isArray(runtimeView.models)) {
|
|
64
64
|
return [];
|
|
65
65
|
}
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
!entry
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|| !CODEX_MODEL_OPTIONS.has(entry.model)
|
|
73
|
-
|| seen.has(entry.model)
|
|
74
|
-
) {
|
|
75
|
-
return false;
|
|
66
|
+
const catalogByModel = new Map();
|
|
67
|
+
for (const entry of runtimeView.models) {
|
|
68
|
+
if (entry && typeof entry.model === 'string' && entry.model.length > 0) {
|
|
69
|
+
if (!catalogByModel.has(entry.model)) {
|
|
70
|
+
catalogByModel.set(entry.model, entry);
|
|
71
|
+
}
|
|
76
72
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
73
|
+
}
|
|
74
|
+
return CODEX_MODEL_OPTIONS.flatMap(({ model }) => (
|
|
75
|
+
catalogByModel.has(model) ? [catalogByModel.get(model)] : []
|
|
76
|
+
));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function codexModelLabel(model) {
|
|
80
|
+
return CODEX_MODEL_OPTIONS.find((entry) => entry.model === model)?.label
|
|
81
|
+
?? model;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hasCodexCatalogModels(runtimeView) {
|
|
85
|
+
return isCodexRuntimeView(runtimeView)
|
|
86
|
+
&& Array.isArray(runtimeView.models)
|
|
87
|
+
&& runtimeView.models.some(
|
|
88
|
+
(entry) => typeof entry?.model === 'string' && entry.model.length > 0,
|
|
89
|
+
);
|
|
80
90
|
}
|
|
81
91
|
|
|
82
92
|
function getCodexCatalogEfforts(runtimeView, model = runtimeView?.model) {
|
|
@@ -262,10 +272,15 @@ function buildConfigKeyboard(
|
|
|
262
272
|
}
|
|
263
273
|
if ((show === 'model' || show === 'all') && modelOptions.length > 0) {
|
|
264
274
|
rows.push(modelOptions.map((entry) => {
|
|
265
|
-
|
|
275
|
+
let label = entry.model;
|
|
276
|
+
if (codex) {
|
|
277
|
+
label = codexModelLabel(entry.model);
|
|
278
|
+
} else if (
|
|
266
279
|
typeof entry.displayName === 'string'
|
|
267
280
|
&& entry.displayName.length > 0
|
|
268
|
-
)
|
|
281
|
+
) {
|
|
282
|
+
label = entry.displayName;
|
|
283
|
+
}
|
|
269
284
|
return {
|
|
270
285
|
text: entry.model === model ? `✓ ${label}` : label,
|
|
271
286
|
callback_data: `cfg:model:${entry.model}`,
|
|
@@ -325,33 +340,30 @@ function createFormatConfigInfoText({
|
|
|
325
340
|
? codexModels.length > 0 ? [
|
|
326
341
|
'',
|
|
327
342
|
'**Models**',
|
|
328
|
-
...codexModels.map(
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
&& entry.displayName.length > 0
|
|
332
|
-
&& entry.displayName !== entry.model
|
|
333
|
-
) ? ` — ${entry.displayName}` : '';
|
|
334
|
-
return `• **${entry.model}**${displayName}`;
|
|
335
|
-
}),
|
|
343
|
+
...codexModels.map(
|
|
344
|
+
(entry) => `• **${codexModelLabel(entry.model)}** — ${entry.model}`,
|
|
345
|
+
),
|
|
336
346
|
].join('\n') : [
|
|
337
347
|
'',
|
|
338
348
|
'**Models**',
|
|
339
|
-
|
|
349
|
+
hasCodexCatalogModels(runtimeView)
|
|
350
|
+
? 'Sol, Terra, and Luna are unavailable for this account.'
|
|
351
|
+
: 'Models are unavailable until Codex preflight succeeds.',
|
|
340
352
|
].join('\n')
|
|
341
353
|
: [
|
|
342
354
|
'',
|
|
343
355
|
'**Models**',
|
|
344
|
-
'🧠 **opus** —
|
|
345
|
-
'🤖 **sonnet** — default
|
|
346
|
-
'⚡ **haiku** —
|
|
356
|
+
'🧠 **opus** — deepest reasoning',
|
|
357
|
+
'🤖 **sonnet** — balanced default',
|
|
358
|
+
'⚡ **haiku** — fastest',
|
|
347
359
|
].join('\n');
|
|
348
360
|
|
|
349
361
|
const effortHelp = codex
|
|
350
362
|
? codexEfforts.length > 0 ? [
|
|
351
363
|
'',
|
|
352
|
-
`**Effort
|
|
353
|
-
|
|
354
|
-
'
|
|
364
|
+
`**Effort for ${codexModelLabel(codexModel)}**`,
|
|
365
|
+
codexEfforts.map((value) => `**${value}**`).join(' · '),
|
|
366
|
+
'Changes apply on the next turn; an active turn is unchanged.',
|
|
355
367
|
].join('\n') : [
|
|
356
368
|
'',
|
|
357
369
|
'**Effort**',
|
|
@@ -359,11 +371,9 @@ function createFormatConfigInfoText({
|
|
|
359
371
|
].join('\n')
|
|
360
372
|
: [
|
|
361
373
|
'',
|
|
362
|
-
'**Effort**
|
|
363
|
-
'
|
|
364
|
-
'
|
|
365
|
-
'• **high** — multi-step tasks. Audit, debug, multi-source analysis.',
|
|
366
|
-
'• **xhigh** / **max** — heaviest. Hard reasoning, edge cases.',
|
|
374
|
+
'**Effort**',
|
|
375
|
+
'**low** · **medium** · **high** · **xhigh** · **max**',
|
|
376
|
+
'Higher values allow more reasoning when needed.',
|
|
367
377
|
].join('\n');
|
|
368
378
|
|
|
369
379
|
// The card is its buttons. Everything the body used to state — model,
|
|
@@ -373,11 +383,10 @@ function createFormatConfigInfoText({
|
|
|
373
383
|
// button to live on.
|
|
374
384
|
let body = '⚙️ Settings';
|
|
375
385
|
if (codex) {
|
|
376
|
-
body += '\n\
|
|
377
|
-
+ '
|
|
378
|
-
+ '
|
|
379
|
-
+ 'Native goals are disabled
|
|
380
|
-
+ 'native goal support is implemented.';
|
|
386
|
+
body += '\n\n_Codex beta: no command network, web search, product MCP tools, '
|
|
387
|
+
+ 'or interactive approvals. Detached/background servers are unsupported '
|
|
388
|
+
+ 'and may survive hard runtime loss. '
|
|
389
|
+
+ 'Native goals are currently disabled._';
|
|
381
390
|
}
|
|
382
391
|
if (show === 'model' || show === 'all') body += '\n' + modelHelp;
|
|
383
392
|
if (show === 'effort' || show === 'all') body += '\n' + effortHelp;
|
package/lib/handlers/poll.js
CHANGED
|
@@ -33,76 +33,150 @@ function createPollLoop({
|
|
|
33
33
|
getTopicName,
|
|
34
34
|
logger = console,
|
|
35
35
|
} = {}) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
let running = true;
|
|
37
|
+
let pollStarted = false;
|
|
38
|
+
let pollSettled = false;
|
|
39
|
+
let lastAdmittedUpdateId = null;
|
|
40
|
+
let resolvePollQuiescence;
|
|
41
|
+
let rejectPollQuiescence;
|
|
42
|
+
const pollQuiescence = new Promise((resolve, reject) => {
|
|
43
|
+
resolvePollQuiescence = resolve;
|
|
44
|
+
rejectPollQuiescence = reject;
|
|
45
|
+
});
|
|
46
|
+
pollQuiescence.catch(() => {});
|
|
40
47
|
|
|
41
|
-
|
|
48
|
+
const settlePollSuccess = () => {
|
|
49
|
+
if (pollSettled) return;
|
|
50
|
+
pollSettled = true;
|
|
51
|
+
resolvePollQuiescence({ lastAdmittedUpdateId });
|
|
52
|
+
};
|
|
53
|
+
const settlePollFailure = (error) => {
|
|
54
|
+
if (pollSettled) return;
|
|
55
|
+
pollSettled = true;
|
|
56
|
+
rejectPollQuiescence(error);
|
|
57
|
+
};
|
|
58
|
+
const stopPolling = () => {
|
|
59
|
+
running = false;
|
|
60
|
+
if (!pollStarted) settlePollSuccess();
|
|
61
|
+
return pollQuiescence;
|
|
62
|
+
};
|
|
63
|
+
const awaitPollSettlement = ({ timeoutMs } = {}) => {
|
|
64
|
+
if (timeoutMs == null) return pollQuiescence;
|
|
65
|
+
let timer;
|
|
66
|
+
const timeout = new Promise((_, reject) => {
|
|
67
|
+
timer = setTimeout(() => {
|
|
68
|
+
const error = new Error(`polling ingress did not settle within ${timeoutMs}ms`);
|
|
69
|
+
error.code = 'POLL_SETTLEMENT_TIMEOUT';
|
|
70
|
+
reject(error);
|
|
71
|
+
}, timeoutMs);
|
|
72
|
+
});
|
|
73
|
+
return Promise.race([pollQuiescence, timeout])
|
|
74
|
+
.finally(() => clearTimeout(timer));
|
|
75
|
+
};
|
|
42
76
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
77
|
+
async function pollBot(bot) {
|
|
78
|
+
if (!running) return;
|
|
79
|
+
pollStarted = true;
|
|
80
|
+
bot._stop = stopPolling;
|
|
46
81
|
try {
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
82
|
+
await bot.init();
|
|
83
|
+
if (!running) {
|
|
84
|
+
settlePollSuccess();
|
|
85
|
+
return;
|
|
51
86
|
}
|
|
52
|
-
|
|
53
|
-
logger.
|
|
54
|
-
}
|
|
55
|
-
let running = true;
|
|
56
|
-
bot._lastPollTs = Date.now();
|
|
57
|
-
bot._stop = () => { running = false; };
|
|
87
|
+
bot._setBotUsername(bot.botInfo.username);
|
|
88
|
+
logger.log?.(`[${botName}] Bot @${bot.botInfo.username} ready`);
|
|
58
89
|
|
|
59
|
-
|
|
90
|
+
await bot.api.deleteWebhook();
|
|
91
|
+
if (!running) {
|
|
92
|
+
settlePollSuccess();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Restore polling offset from DB so a restart doesn't re-process
|
|
97
|
+
// the backlog Telegram accumulated while we were down.
|
|
98
|
+
let offset = 0;
|
|
60
99
|
try {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
100
|
+
const saved = db?.getPollingOffset?.(botName);
|
|
101
|
+
if (saved && saved > 0) {
|
|
102
|
+
offset = saved + 1;
|
|
103
|
+
logger.log?.(`[${botName}] resuming polling from update_id ${saved}`);
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
logger.error?.(`[${botName}] getPollingOffset failed: ${err.message}`);
|
|
107
|
+
}
|
|
108
|
+
bot._lastPollTs = Date.now();
|
|
109
|
+
|
|
110
|
+
while (running) {
|
|
111
|
+
try {
|
|
112
|
+
const updates = await bot.api.getUpdates({
|
|
113
|
+
offset,
|
|
114
|
+
timeout: 25,
|
|
115
|
+
allowed_updates: ['message', 'edited_message', 'callback_query'],
|
|
116
|
+
});
|
|
117
|
+
bot._lastPollTs = Date.now();
|
|
118
|
+
if (!running) break;
|
|
67
119
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
120
|
+
let batchLastAdmittedId = null;
|
|
121
|
+
for (const update of updates) {
|
|
122
|
+
if (!running) break;
|
|
123
|
+
offset = update.update_id + 1;
|
|
124
|
+
batchLastAdmittedId = update.update_id;
|
|
125
|
+
lastAdmittedUpdateId = update.update_id;
|
|
126
|
+
if (update.message && isWellFormedMessage(update.message)) {
|
|
127
|
+
const m = update.message;
|
|
128
|
+
const chatId = m.chat.id.toString();
|
|
129
|
+
const chatConfig = config.chats[chatId];
|
|
130
|
+
const threadId = m.message_thread_id?.toString();
|
|
131
|
+
const topicName = threadId
|
|
132
|
+
? (chatConfig ? getTopicName(chatConfig, threadId) : threadId)
|
|
133
|
+
: null;
|
|
134
|
+
const chatLabel = chatConfig?.name || chatId;
|
|
135
|
+
const label = topicName ? `${chatLabel}/${topicName}` : chatLabel;
|
|
136
|
+
logger.log?.(`[${botName}] ← ${label}: ${(m.text || m.caption || '(media)').slice(0, 60)}`);
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
await bot.handleUpdate(update);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
logger.error?.(`[${botName}] Handler error: ${err.message}`);
|
|
142
|
+
}
|
|
81
143
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
144
|
+
// Persist offset after batch dispatch — only on non-empty
|
|
145
|
+
// batches to avoid churning the row on every 25s idle poll.
|
|
146
|
+
if (batchLastAdmittedId != null) {
|
|
147
|
+
dbWrite(() => db.savePollingOffset(botName, batchLastAdmittedId),
|
|
148
|
+
'save polling offset');
|
|
149
|
+
}
|
|
150
|
+
// No sleep on the success path: long-poll already blocks
|
|
151
|
+
// up to 25s when idle.
|
|
152
|
+
} catch (err) {
|
|
153
|
+
if (!running) break;
|
|
154
|
+
if (err.error_code === 409) {
|
|
155
|
+
logger.log?.(`[${botName}] 409, waiting 3s...`);
|
|
156
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
157
|
+
} else {
|
|
158
|
+
logger.error?.(`[${botName}] Poll error: ${err.message}`);
|
|
159
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
86
160
|
}
|
|
87
161
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
await new Promise((r) => setTimeout(r, 3000));
|
|
101
|
-
} else {
|
|
102
|
-
logger.error?.(`[${botName}] Poll error: ${err.message}`);
|
|
103
|
-
await new Promise((r) => setTimeout(r, 3000));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (lastAdmittedUpdateId != null) {
|
|
165
|
+
try {
|
|
166
|
+
await db.savePollingOffset(botName, lastAdmittedUpdateId);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const persistenceError = new Error(
|
|
169
|
+
`failed to persist the shutdown polling offset: ${error.message}`,
|
|
170
|
+
{ cause: error },
|
|
171
|
+
);
|
|
172
|
+
persistenceError.code = 'POLL_OFFSET_PERSISTENCE_FAILED';
|
|
173
|
+
throw persistenceError;
|
|
104
174
|
}
|
|
105
175
|
}
|
|
176
|
+
settlePollSuccess();
|
|
177
|
+
} catch (error) {
|
|
178
|
+
settlePollFailure(error);
|
|
179
|
+
throw error;
|
|
106
180
|
}
|
|
107
181
|
}
|
|
108
182
|
|
|
@@ -131,7 +205,12 @@ function createPollLoop({
|
|
|
131
205
|
}, 30_000);
|
|
132
206
|
}
|
|
133
207
|
|
|
134
|
-
return {
|
|
208
|
+
return {
|
|
209
|
+
pollBot,
|
|
210
|
+
startPollWatchdog,
|
|
211
|
+
stopPolling,
|
|
212
|
+
awaitPollSettlement,
|
|
213
|
+
};
|
|
135
214
|
}
|
|
136
215
|
|
|
137
216
|
module.exports = {
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* tryConsumeAsAnswer — dispatcher hook: a typed message while a question is
|
|
9
9
|
* open (free-text "Other", or a nudge for the wrong user).
|
|
10
10
|
* expireQuestion — timeout sweep / cancel: answer {timedout}/{cancelled}, strip.
|
|
11
|
+
* beginShutdownDisposition — cancel locally during provider retirement without
|
|
12
|
+
* sending a synthetic answer into the retiring turn.
|
|
11
13
|
*
|
|
12
14
|
* Security (review): per-row 128-bit token in callback_data + claim-on-first-tap
|
|
13
15
|
* respondent authz + plain-text body (no parse_mode) for agent content.
|
|
@@ -246,6 +248,22 @@ function createQuestionHandlers({
|
|
|
246
248
|
logEvent('question-expired', { session_key: row.session_key, tool_call_id: row.tool_call_id, status });
|
|
247
249
|
}
|
|
248
250
|
|
|
251
|
+
function beginShutdownDisposition(row, {
|
|
252
|
+
message = 'Bot is restarting — this question was cancelled.',
|
|
253
|
+
} = {}) {
|
|
254
|
+
const ids = JSON.parse(row.message_ids_json || '[]');
|
|
255
|
+
const editing = ids[0]
|
|
256
|
+
? strip(row.chat_id, ids[0], row.thread_id, message)
|
|
257
|
+
: Promise.resolve();
|
|
258
|
+
questions.resolve(row.id, 'cancelled');
|
|
259
|
+
logEvent('question-expired', {
|
|
260
|
+
session_key: row.session_key,
|
|
261
|
+
tool_call_id: row.tool_call_id,
|
|
262
|
+
status: 'cancelled',
|
|
263
|
+
});
|
|
264
|
+
return editing;
|
|
265
|
+
}
|
|
266
|
+
|
|
249
267
|
// shared: record an advanced result, post a receipt, then next-Q or resolve.
|
|
250
268
|
async function advance(ctx, row, res, fromText) {
|
|
251
269
|
const msgId = (JSON.parse(row.message_ids_json || '[]'))[0];
|
|
@@ -283,7 +301,14 @@ function createQuestionHandlers({
|
|
|
283
301
|
return ctx.answerCallbackQuery(text ? { text, show_alert: alert } : undefined).catch(() => {});
|
|
284
302
|
}
|
|
285
303
|
|
|
286
|
-
return {
|
|
304
|
+
return {
|
|
305
|
+
renderAsk,
|
|
306
|
+
handleQuestionCallback,
|
|
307
|
+
tryConsumeAsAnswer,
|
|
308
|
+
expireQuestion,
|
|
309
|
+
beginShutdownDisposition,
|
|
310
|
+
isAwaitingOtherFrom,
|
|
311
|
+
};
|
|
287
312
|
}
|
|
288
313
|
|
|
289
314
|
module.exports = { createQuestionHandlers };
|
package/lib/ipc/handlers.js
CHANGED
|
@@ -8,25 +8,35 @@
|
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
10
10
|
const { buildBusySnapshot } = require('../queue-utils');
|
|
11
|
+
const {
|
|
12
|
+
assertClosedQualificationRequest,
|
|
13
|
+
projectQualificationResponse,
|
|
14
|
+
} = require('../ops/clean-restart-qualification');
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
17
|
* @param {object} opts
|
|
14
18
|
* @param {string} opts.botName
|
|
19
|
+
* @param {object} opts.daemonIdentity
|
|
15
20
|
* @param {() => Map<string, number>} opts.getInFlightHandlers — read late, not
|
|
16
21
|
* captured: the dispatcher's map is assigned during boot and replaced on
|
|
17
22
|
* reload, so a value bound at wiring time can go stale.
|
|
18
23
|
* @param {(req) => any} opts.handleSendOverIpc
|
|
19
24
|
* @param {(req) => any} opts.requestDeployRestart
|
|
25
|
+
* @param {() => any} opts.inspectCleanRestartQualification
|
|
20
26
|
*/
|
|
21
27
|
function createIpcHandlers({
|
|
22
28
|
botName,
|
|
29
|
+
daemonIdentity,
|
|
23
30
|
getInFlightHandlers,
|
|
24
31
|
handleSendOverIpc,
|
|
25
32
|
requestDeployRestart,
|
|
33
|
+
inspectCleanRestartQualification,
|
|
26
34
|
} = {}) {
|
|
27
35
|
return {
|
|
28
36
|
ping: async () => ({ pong: true, bot: botName }),
|
|
29
37
|
|
|
38
|
+
identity: async () => ({ bot: botName, ...daemonIdentity }),
|
|
39
|
+
|
|
30
40
|
// Deploy pre-flight: what would restarting this daemon interrupt right now?
|
|
31
41
|
// Counts and session keys only — enough to decide, nothing quotable.
|
|
32
42
|
busy: async () => buildBusySnapshot({
|
|
@@ -36,6 +46,21 @@ function createIpcHandlers({
|
|
|
36
46
|
|
|
37
47
|
deploy_restart: (req) => requestDeployRestart(req),
|
|
38
48
|
|
|
49
|
+
clean_restart_qualification: async (req) => {
|
|
50
|
+
assertClosedQualificationRequest(req);
|
|
51
|
+
let observation = null;
|
|
52
|
+
try {
|
|
53
|
+
observation = typeof inspectCleanRestartQualification === 'function'
|
|
54
|
+
? await inspectCleanRestartQualification()
|
|
55
|
+
: null;
|
|
56
|
+
} catch {}
|
|
57
|
+
return projectQualificationResponse({
|
|
58
|
+
botName,
|
|
59
|
+
daemonIdentity,
|
|
60
|
+
observation,
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
|
|
39
64
|
send: (req) => handleSendOverIpc(req),
|
|
40
65
|
};
|
|
41
66
|
}
|