polygram 0.38.0 → 0.38.2
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 +49 -0
- package/lib/handlers/dispatcher.js +13 -0
- package/lib/handlers/poll.js +138 -59
- package/lib/handlers/questions.js +26 -1
- package/lib/ipc/handlers.js +29 -0
- package/lib/ops/clean-restart-qualification.js +261 -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/deploy-restart-handler.js +111 -0
- package/lib/ops/foreground-canary-target.js +454 -0
- package/lib/ops/systemd-invocation.js +10 -3
- package/package.json +2 -2
- package/polygram.js +117 -47
- 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.38.
|
|
4
|
+
"version": "0.38.2",
|
|
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
|
|
@@ -459,6 +462,25 @@ function wrap(db) {
|
|
|
459
462
|
ORDER BY id DESC LIMIT 1
|
|
460
463
|
`);
|
|
461
464
|
|
|
465
|
+
const getForegroundCanaryTargetStmt = db.prepare(`
|
|
466
|
+
SELECT message.id AS message_id,
|
|
467
|
+
message.chat_id,
|
|
468
|
+
message.thread_id,
|
|
469
|
+
selection.telegram_message_id,
|
|
470
|
+
message.handler_status,
|
|
471
|
+
selection.session_key,
|
|
472
|
+
selection.provider
|
|
473
|
+
FROM messages AS message
|
|
474
|
+
JOIN inbound_runtime_selections AS selection
|
|
475
|
+
ON selection.bot_name = message.bot_name
|
|
476
|
+
AND selection.telegram_chat_id = message.chat_id
|
|
477
|
+
AND selection.telegram_message_id = ?
|
|
478
|
+
WHERE message.direction = 'in'
|
|
479
|
+
AND message.bot_name = ?
|
|
480
|
+
AND message.chat_id = ?
|
|
481
|
+
AND message.msg_id = ?
|
|
482
|
+
`);
|
|
483
|
+
|
|
462
484
|
const setMessageTextStmt = db.prepare(`
|
|
463
485
|
UPDATE messages
|
|
464
486
|
SET text = @text
|
|
@@ -894,6 +916,7 @@ function wrap(db) {
|
|
|
894
916
|
if (value != null) detail[key] = value;
|
|
895
917
|
}
|
|
896
918
|
}
|
|
919
|
+
Object.assign(detail, sanitizeCodexFaultProvenance(input));
|
|
897
920
|
return Object.keys(detail).length > 0 ? JSON.stringify(detail) : null;
|
|
898
921
|
}
|
|
899
922
|
|
|
@@ -4694,6 +4717,32 @@ function wrap(db) {
|
|
|
4694
4717
|
`).get(messageId);
|
|
4695
4718
|
},
|
|
4696
4719
|
|
|
4720
|
+
getForegroundCanaryTarget({ botName, chatId, telegramMessageId }) {
|
|
4721
|
+
const messageIdText = String(telegramMessageId);
|
|
4722
|
+
const messageId = Number(messageIdText);
|
|
4723
|
+
if (
|
|
4724
|
+
!Number.isSafeInteger(messageId)
|
|
4725
|
+
|| messageId <= 0
|
|
4726
|
+
|| String(messageId) !== messageIdText
|
|
4727
|
+
) return null;
|
|
4728
|
+
const row = getForegroundCanaryTargetStmt.get(
|
|
4729
|
+
messageIdText,
|
|
4730
|
+
String(botName),
|
|
4731
|
+
String(chatId),
|
|
4732
|
+
messageId,
|
|
4733
|
+
);
|
|
4734
|
+
if (!row) return null;
|
|
4735
|
+
return {
|
|
4736
|
+
messageId: row.message_id,
|
|
4737
|
+
chatId: String(row.chat_id),
|
|
4738
|
+
threadId: row.thread_id == null ? null : String(row.thread_id),
|
|
4739
|
+
telegramMessageId: row.telegram_message_id,
|
|
4740
|
+
sessionKey: row.session_key,
|
|
4741
|
+
provider: row.provider,
|
|
4742
|
+
handlerStatus: row.handler_status,
|
|
4743
|
+
};
|
|
4744
|
+
},
|
|
4745
|
+
|
|
4697
4746
|
listFailedAttachments({ since = null, limit = 100 } = {}) {
|
|
4698
4747
|
const cutoff = since ?? Date.now() - 24 * 60 * 60 * 1000;
|
|
4699
4748
|
return db.prepare(`
|
|
@@ -77,6 +77,7 @@ function createDispatcher({
|
|
|
77
77
|
// Per-session in-flight handler count.
|
|
78
78
|
const inFlightHandlers = new Map();
|
|
79
79
|
const activeHandlers = new Set();
|
|
80
|
+
const activeHandlerTargets = new Set();
|
|
80
81
|
|
|
81
82
|
function trackTask(task) {
|
|
82
83
|
activeHandlers.add(task);
|
|
@@ -265,6 +266,15 @@ function createDispatcher({
|
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
|
|
269
|
+
const activeHandlerTarget = Object.freeze({
|
|
270
|
+
sessionKey: String(sessionKey),
|
|
271
|
+
chatId: String(chatId),
|
|
272
|
+
threadId: msg?.message_thread_id == null
|
|
273
|
+
? null
|
|
274
|
+
: String(msg.message_thread_id),
|
|
275
|
+
telegramMessageId: String(msg?.message_id),
|
|
276
|
+
});
|
|
277
|
+
activeHandlerTargets.add(activeHandlerTarget);
|
|
268
278
|
const count = (inFlightHandlers.get(sessionKey) || 0) + 1;
|
|
269
279
|
inFlightHandlers.set(sessionKey, count);
|
|
270
280
|
const warnAt = queueWarnThreshold();
|
|
@@ -562,6 +572,7 @@ function createDispatcher({
|
|
|
562
572
|
}
|
|
563
573
|
}
|
|
564
574
|
}).finally(() => {
|
|
575
|
+
activeHandlerTargets.delete(activeHandlerTarget);
|
|
565
576
|
const n = (inFlightHandlers.get(sessionKey) || 1) - 1;
|
|
566
577
|
if (n <= 0) inFlightHandlers.delete(sessionKey);
|
|
567
578
|
else inFlightHandlers.set(sessionKey, n);
|
|
@@ -575,6 +586,8 @@ function createDispatcher({
|
|
|
575
586
|
errorReplyText,
|
|
576
587
|
queueWarnThreshold,
|
|
577
588
|
inFlightHandlers, // exposed so polygram.js can introspect for shutdown drain
|
|
589
|
+
getActiveHandlerCount: () => activeHandlers.size,
|
|
590
|
+
getActiveHandlerTargets: () => [...activeHandlerTargets],
|
|
578
591
|
awaitSettlement,
|
|
579
592
|
trackTask,
|
|
580
593
|
};
|
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,37 @@
|
|
|
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 {(req) => any} opts.requestForegroundCanaryTarget
|
|
26
|
+
* @param {() => any} opts.inspectCleanRestartQualification
|
|
20
27
|
*/
|
|
21
28
|
function createIpcHandlers({
|
|
22
29
|
botName,
|
|
30
|
+
daemonIdentity,
|
|
23
31
|
getInFlightHandlers,
|
|
24
32
|
handleSendOverIpc,
|
|
25
33
|
requestDeployRestart,
|
|
34
|
+
requestForegroundCanaryTarget,
|
|
35
|
+
inspectCleanRestartQualification,
|
|
26
36
|
} = {}) {
|
|
27
37
|
return {
|
|
28
38
|
ping: async () => ({ pong: true, bot: botName }),
|
|
29
39
|
|
|
40
|
+
identity: async () => ({ bot: botName, ...daemonIdentity }),
|
|
41
|
+
|
|
30
42
|
// Deploy pre-flight: what would restarting this daemon interrupt right now?
|
|
31
43
|
// Counts and session keys only — enough to decide, nothing quotable.
|
|
32
44
|
busy: async () => buildBusySnapshot({
|
|
@@ -36,6 +48,23 @@ function createIpcHandlers({
|
|
|
36
48
|
|
|
37
49
|
deploy_restart: (req) => requestDeployRestart(req),
|
|
38
50
|
|
|
51
|
+
foreground_canary_target: (req) => requestForegroundCanaryTarget(req),
|
|
52
|
+
|
|
53
|
+
clean_restart_qualification: async (req) => {
|
|
54
|
+
assertClosedQualificationRequest(req);
|
|
55
|
+
let observation = null;
|
|
56
|
+
try {
|
|
57
|
+
observation = typeof inspectCleanRestartQualification === 'function'
|
|
58
|
+
? await inspectCleanRestartQualification()
|
|
59
|
+
: null;
|
|
60
|
+
} catch {}
|
|
61
|
+
return projectQualificationResponse({
|
|
62
|
+
botName,
|
|
63
|
+
daemonIdentity,
|
|
64
|
+
observation,
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
|
|
39
68
|
send: (req) => handleSendOverIpc(req),
|
|
40
69
|
};
|
|
41
70
|
}
|