polygram 0.38.0 → 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.
@@ -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.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({ source, reason }),
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
 
@@ -33,76 +33,150 @@ function createPollLoop({
33
33
  getTopicName,
34
34
  logger = console,
35
35
  } = {}) {
36
- async function pollBot(bot) {
37
- await bot.init();
38
- bot._setBotUsername(bot.botInfo.username);
39
- logger.log?.(`[${botName}] Bot @${bot.botInfo.username} ready`);
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
- await bot.api.deleteWebhook();
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
- // Restore polling offset from DB so a restart doesn't re-process
44
- // the backlog Telegram accumulated while we were down.
45
- let offset = 0;
77
+ async function pollBot(bot) {
78
+ if (!running) return;
79
+ pollStarted = true;
80
+ bot._stop = stopPolling;
46
81
  try {
47
- const saved = db?.getPollingOffset?.(botName);
48
- if (saved && saved > 0) {
49
- offset = saved + 1;
50
- logger.log?.(`[${botName}] resuming polling from update_id ${saved}`);
82
+ await bot.init();
83
+ if (!running) {
84
+ settlePollSuccess();
85
+ return;
51
86
  }
52
- } catch (err) {
53
- logger.error?.(`[${botName}] getPollingOffset failed: ${err.message}`);
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
- while (running) {
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 updates = await bot.api.getUpdates({
62
- offset,
63
- timeout: 25,
64
- allowed_updates: ['message', 'edited_message', 'callback_query'],
65
- });
66
- bot._lastPollTs = Date.now();
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
- for (const update of updates) {
69
- offset = update.update_id + 1;
70
- if (update.message && isWellFormedMessage(update.message)) {
71
- const m = update.message;
72
- const chatId = m.chat.id.toString();
73
- const chatConfig = config.chats[chatId];
74
- const threadId = m.message_thread_id?.toString();
75
- const topicName = threadId
76
- ? (chatConfig ? getTopicName(chatConfig, threadId) : threadId)
77
- : null;
78
- const chatLabel = chatConfig?.name || chatId;
79
- const label = topicName ? `${chatLabel}/${topicName}` : chatLabel;
80
- logger.log?.(`[${botName}] ← ${label}: ${(m.text || m.caption || '(media)').slice(0, 60)}`);
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
- try {
83
- await bot.handleUpdate(update);
84
- } catch (err) {
85
- logger.error?.(`[${botName}] Handler error: ${err.message}`);
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
- // Persist offset after batch dispatch — only on non-empty
89
- // batches to avoid churning the row on every 25s idle poll.
90
- if (updates.length > 0) {
91
- dbWrite(() => db.savePollingOffset(botName, updates[updates.length - 1].update_id),
92
- 'save polling offset');
93
- }
94
- // No sleep on the success path: long-poll already blocks
95
- // up to 25s when idle.
96
- } catch (err) {
97
- if (!running) break;
98
- if (err.error_code === 409) {
99
- logger.log?.(`[${botName}] 409, waiting 3s...`);
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 { pollBot, startPollWatchdog };
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 { renderAsk, handleQuestionCallback, tryConsumeAsAnswer, expireQuestion, isAwaitingOtherFrom };
304
+ return {
305
+ renderAsk,
306
+ handleQuestionCallback,
307
+ tryConsumeAsAnswer,
308
+ expireQuestion,
309
+ beginShutdownDisposition,
310
+ isAwaitingOtherFrom,
311
+ };
287
312
  }
288
313
 
289
314
  module.exports = { createQuestionHandlers };
@@ -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
  }
@@ -0,0 +1,260 @@
1
+ 'use strict';
2
+
3
+ const { createHash } = require('node:crypto');
4
+
5
+ const SHA256_RE = /^[a-f0-9]{64}$/;
6
+ const PROCESS_STATES = new Set([
7
+ 'Spawning', 'Initializing', 'AttachingThread', 'Idle', 'StartingTurn',
8
+ 'Active', 'BackgroundWorking', 'BackgroundSettling', 'Settling',
9
+ 'Quiescing', 'Stopped', 'Closing', 'Closed', 'FailedAmbiguous',
10
+ 'RecoveryConflict', 'DurabilityBlocked', 'ContainmentFailed', 'unknown',
11
+ ]);
12
+ const OUTCOME_CODES = new Set([
13
+ 'eligible', 'active-turn', 'pending-delivery', 'background-owner',
14
+ 'background-terminals', 'pagination-incomplete', 'process-not-idle',
15
+ 'process-not-ready', 'no-codex-generation', 'unsupported', 'lease-drift',
16
+ 'generation-drift', 'activity-epoch-drift', 'fence-drift',
17
+ 'inspection-failed', 'invalid-expectation', 'incoherent',
18
+ ]);
19
+ const OBSERVATION_KEYS = [
20
+ 'outcome',
21
+ 'reason',
22
+ 'generationDigest',
23
+ 'activityEpoch',
24
+ 'processState',
25
+ 'activeTurnCount',
26
+ 'pendingDeliveryCount',
27
+ 'backgroundOwnerCount',
28
+ 'backgroundTerminalCount',
29
+ 'backgroundTerminalRegistryComplete',
30
+ 'observedAtMs',
31
+ ];
32
+
33
+ function isPlainObject(value) {
34
+ return value != null
35
+ && typeof value === 'object'
36
+ && !Array.isArray(value)
37
+ && Object.getPrototypeOf(value) === Object.prototype;
38
+ }
39
+
40
+ function hasExactKeys(value, expectedKeys) {
41
+ if (!isPlainObject(value)) return false;
42
+ const keys = Object.keys(value);
43
+ return keys.length === expectedKeys.length
44
+ && expectedKeys.every((key) => Object.hasOwn(value, key));
45
+ }
46
+
47
+ function validOptionalFence(value, validator) {
48
+ return value === null || validator(value);
49
+ }
50
+
51
+ function fallbackObservation(now = Date.now) {
52
+ return Object.freeze({
53
+ outcome: 'mismatch',
54
+ reason: 'inspection-failed',
55
+ generationDigest: null,
56
+ activityEpoch: null,
57
+ processState: 'unknown',
58
+ activeTurnCount: 0,
59
+ pendingDeliveryCount: 0,
60
+ backgroundOwnerCount: 0,
61
+ backgroundTerminalCount: 0,
62
+ backgroundTerminalRegistryComplete: false,
63
+ observedAtMs: now(),
64
+ });
65
+ }
66
+
67
+ function normalizeQualificationObservation(observation, { now = Date.now } = {}) {
68
+ if (
69
+ !hasExactKeys(observation, OBSERVATION_KEYS)
70
+ || !['qualified', 'mismatch'].includes(observation.outcome)
71
+ || !OUTCOME_CODES.has(observation.reason)
72
+ || !validOptionalFence(
73
+ observation.generationDigest,
74
+ (value) => SHA256_RE.test(value),
75
+ )
76
+ || !validOptionalFence(
77
+ observation.activityEpoch,
78
+ (value) => Number.isSafeInteger(value) && value >= 0,
79
+ )
80
+ || !PROCESS_STATES.has(observation.processState)
81
+ || ![0, 1].includes(observation.activeTurnCount)
82
+ || ![0, 1].includes(observation.pendingDeliveryCount)
83
+ || ![0, 1].includes(observation.backgroundOwnerCount)
84
+ || !Number.isSafeInteger(observation.backgroundTerminalCount)
85
+ || observation.backgroundTerminalCount < 0
86
+ || observation.backgroundTerminalCount > 1_600
87
+ || typeof observation.backgroundTerminalRegistryComplete !== 'boolean'
88
+ || !Number.isSafeInteger(observation.observedAtMs)
89
+ || observation.observedAtMs < 0
90
+ ) {
91
+ return fallbackObservation(now);
92
+ }
93
+
94
+ const qualified = observation.outcome === 'qualified';
95
+ const eligibleShape = (
96
+ observation.reason === 'eligible'
97
+ && SHA256_RE.test(observation.generationDigest)
98
+ && Number.isSafeInteger(observation.activityEpoch)
99
+ && observation.processState === 'Idle'
100
+ && observation.activeTurnCount === 0
101
+ && observation.pendingDeliveryCount === 0
102
+ && observation.backgroundOwnerCount === 0
103
+ && observation.backgroundTerminalCount === 0
104
+ && observation.backgroundTerminalRegistryComplete === true
105
+ );
106
+ if (
107
+ (qualified && !eligibleShape)
108
+ || (!qualified && observation.reason === 'eligible')
109
+ ) {
110
+ return fallbackObservation(now);
111
+ }
112
+
113
+ return Object.freeze({
114
+ outcome: observation.outcome,
115
+ reason: observation.reason,
116
+ generationDigest: observation.generationDigest,
117
+ activityEpoch: observation.activityEpoch,
118
+ processState: observation.processState,
119
+ activeTurnCount: observation.activeTurnCount,
120
+ pendingDeliveryCount: observation.pendingDeliveryCount,
121
+ backgroundOwnerCount: observation.backgroundOwnerCount,
122
+ backgroundTerminalCount: observation.backgroundTerminalCount,
123
+ backgroundTerminalRegistryComplete: observation.backgroundTerminalRegistryComplete,
124
+ observedAtMs: observation.observedAtMs,
125
+ });
126
+ }
127
+
128
+ function normalizeDeployQualificationExpectation(request) {
129
+ if (!isPlainObject(request)) return null;
130
+ const requestKeys = Object.keys(request);
131
+ const allowedRequestKeys = new Set([
132
+ 'op', 'id', 'secret', 'qualification_expectation',
133
+ ]);
134
+ const hasUnknownRequestField = requestKeys.some(
135
+ (key) => !allowedRequestKeys.has(key),
136
+ );
137
+ const hasExpectation = Object.hasOwn(request, 'qualification_expectation');
138
+ if (!hasExpectation && !hasUnknownRequestField) return undefined;
139
+ if (hasUnknownRequestField) return null;
140
+
141
+ const expectation = request.qualification_expectation;
142
+ if (!hasExactKeys(expectation, ['generation_digest', 'activity_epoch'])) {
143
+ return null;
144
+ }
145
+ if (
146
+ !SHA256_RE.test(expectation.generation_digest)
147
+ || !Number.isSafeInteger(expectation.activity_epoch)
148
+ || expectation.activity_epoch < 0
149
+ ) {
150
+ return null;
151
+ }
152
+ return Object.freeze({
153
+ expectedGenerationDigest: expectation.generation_digest,
154
+ expectedActivityEpoch: expectation.activity_epoch,
155
+ });
156
+ }
157
+
158
+ function projectQualificationResponse({
159
+ botName,
160
+ daemonIdentity,
161
+ observation,
162
+ now = Date.now,
163
+ } = {}) {
164
+ const normalized = normalizeQualificationObservation(observation, { now });
165
+ return Object.freeze({
166
+ bot: botName,
167
+ daemon_instance_id: daemonIdentity?.daemon_instance_id ?? null,
168
+ package_version: daemonIdentity?.package_version ?? null,
169
+ observed_at_ms: normalized.observedAtMs,
170
+ generation_digest: normalized.generationDigest,
171
+ activity_epoch: normalized.activityEpoch,
172
+ process_state: normalized.processState,
173
+ active_turn_count: normalized.activeTurnCount,
174
+ pending_delivery_count: normalized.pendingDeliveryCount,
175
+ background_owner_count: normalized.backgroundOwnerCount,
176
+ background_terminal_count: normalized.backgroundTerminalCount,
177
+ background_terminal_registry_complete: normalized.backgroundTerminalRegistryComplete,
178
+ outcome_code: normalized.reason,
179
+ });
180
+ }
181
+
182
+ function buildQualificationEvent({
183
+ botName,
184
+ daemonIdentity,
185
+ restartRequestId,
186
+ expectation,
187
+ observation,
188
+ now = Date.now,
189
+ } = {}) {
190
+ const normalized = normalizeQualificationObservation(observation, { now });
191
+ const expectationValid = (
192
+ isPlainObject(expectation)
193
+ && hasExactKeys(expectation, [
194
+ 'expectedGenerationDigest',
195
+ 'expectedActivityEpoch',
196
+ ])
197
+ && SHA256_RE.test(expectation.expectedGenerationDigest)
198
+ && Number.isSafeInteger(expectation.expectedActivityEpoch)
199
+ && expectation.expectedActivityEpoch >= 0
200
+ );
201
+ const exactMatch = Boolean(
202
+ expectationValid
203
+ && normalized.outcome === 'qualified'
204
+ && normalized.reason === 'eligible'
205
+ && normalized.generationDigest === expectation.expectedGenerationDigest
206
+ && normalized.activityEpoch === expectation.expectedActivityEpoch
207
+ && normalized.processState === 'Idle'
208
+ && normalized.activeTurnCount === 0
209
+ && normalized.pendingDeliveryCount === 0
210
+ && normalized.backgroundOwnerCount === 0
211
+ && normalized.backgroundTerminalCount === 0
212
+ && normalized.backgroundTerminalRegistryComplete === true
213
+ );
214
+ return Object.freeze({
215
+ bot: botName,
216
+ restart_request_sha256: createHash('sha256')
217
+ .update(restartRequestId)
218
+ .digest('hex'),
219
+ daemon_instance_id: daemonIdentity?.daemon_instance_id ?? null,
220
+ package_version: daemonIdentity?.package_version ?? null,
221
+ observed_at_ms: normalized.observedAtMs,
222
+ generation_digest: normalized.generationDigest,
223
+ expected_activity_epoch: expectationValid
224
+ ? expectation.expectedActivityEpoch
225
+ : null,
226
+ observed_activity_epoch: normalized.activityEpoch,
227
+ process_state: normalized.processState,
228
+ active_turn_count: normalized.activeTurnCount,
229
+ pending_delivery_count: normalized.pendingDeliveryCount,
230
+ background_owner_count: normalized.backgroundOwnerCount,
231
+ background_terminal_count: normalized.backgroundTerminalCount,
232
+ background_terminal_registry_complete: normalized.backgroundTerminalRegistryComplete,
233
+ exact_match: exactMatch,
234
+ outcome_code: normalized.reason,
235
+ });
236
+ }
237
+
238
+ function assertClosedQualificationRequest(request) {
239
+ if (!isPlainObject(request)) {
240
+ throw new TypeError('clean restart qualification request is invalid');
241
+ }
242
+ const allowed = new Set(['op', 'id', 'secret']);
243
+ if (
244
+ request.op !== 'clean_restart_qualification'
245
+ || Object.keys(request).some((key) => !allowed.has(key))
246
+ ) {
247
+ const error = new Error('clean restart qualification request has unknown fields');
248
+ error.code = 'INVALID_CLEAN_RESTART_QUALIFICATION_REQUEST';
249
+ throw error;
250
+ }
251
+ return request;
252
+ }
253
+
254
+ module.exports = {
255
+ assertClosedQualificationRequest,
256
+ buildQualificationEvent,
257
+ normalizeDeployQualificationExpectation,
258
+ normalizeQualificationObservation,
259
+ projectQualificationResponse,
260
+ };
@@ -147,6 +147,17 @@ async function executeCleanResumeClaim(claim, {
147
147
  policy_version: claim?.policy_version ?? null,
148
148
  };
149
149
  const codex = isCodexClaim(claim);
150
+ let continuationDispatched = false;
151
+ const markContinuationDispatched = () => {
152
+ if (continuationDispatched) return;
153
+ continuationDispatched = true;
154
+ logEvent('clean-resume-continuation-dispatched', {
155
+ ...eventDetail,
156
+ provider: codex ? 'codex' : 'claude',
157
+ command_kind: 'continue',
158
+ });
159
+ markReady();
160
+ };
150
161
  const source = typeof loadSource === 'function'
151
162
  ? loadSource(claim.source_message_id)
152
163
  : null;
@@ -295,7 +306,7 @@ async function executeCleanResumeClaim(claim, {
295
306
  text: 'continue',
296
307
  sourceMsgId: codex ? null : source.msg_id,
297
308
  expectedProcess: spawned.process,
298
- onDispatched: markReady,
309
+ onDispatched: markContinuationDispatched,
299
310
  });
300
311
  markReady();
301
312
  } catch (error) {
@@ -3,8 +3,10 @@
3
3
  async function prepareCleanRetirement({
4
4
  pm,
5
5
  deliveryBarrier,
6
+ awaitIngressSettlement,
6
7
  awaitHandlerSettlement,
7
8
  settlementTimeoutMs = 30_000,
9
+ qualificationExpectation,
8
10
  } = {}) {
9
11
  if (typeof deliveryBarrier?.fenceAndDrain !== 'function'
10
12
  || typeof deliveryBarrier?.inspect !== 'function') {
@@ -13,22 +15,118 @@ async function prepareCleanRetirement({
13
15
  if (typeof pm?.retireForCleanRestart !== 'function') {
14
16
  throw new TypeError('ProcessManager.retireForCleanRestart is required');
15
17
  }
18
+ if (typeof awaitIngressSettlement !== 'function') {
19
+ throw new TypeError('clean shutdown requires ingress settlement');
20
+ }
16
21
  if (typeof awaitHandlerSettlement !== 'function') {
17
22
  throw new TypeError('clean shutdown requires handler settlement');
18
23
  }
19
24
 
20
- const deliveryDrain = deliveryBarrier.fenceAndDrain();
21
- const processRetirement = pm.retireForCleanRestart({
25
+ const deliveryDrain = captureSettlement(() => deliveryBarrier.fenceAndDrain());
26
+ const processRetirement = captureSettlement(() => pm.retireForCleanRestart({
22
27
  getDeliveryEvidence: (sessionKey, sourceMsgId) => (
23
28
  deliveryBarrier.inspect(sessionKey, sourceMsgId)
24
29
  ),
25
- });
26
- const [, snapshots] = await Promise.all([
30
+ ...(qualificationExpectation !== undefined
31
+ ? { qualificationExpectation }
32
+ : {}),
33
+ }));
34
+ const [deliveryResult, retirementResult] = await Promise.allSettled([
27
35
  deliveryDrain,
28
36
  processRetirement,
29
37
  ]);
30
- await awaitHandlerSettlement({ timeoutMs: settlementTimeoutMs });
31
- return { snapshots: Array.isArray(snapshots) ? snapshots : [] };
38
+
39
+ let fallbackResult = null;
40
+ if (retirementResult.status === 'rejected') {
41
+ fallbackResult = await settleOne(() => {
42
+ if (typeof pm?.shutdown !== 'function') {
43
+ throw new TypeError('ProcessManager.shutdown fallback is required');
44
+ }
45
+ return pm.shutdown();
46
+ });
47
+ }
48
+
49
+ const ingressResult = await settleOne(() => (
50
+ awaitIngressSettlement({ timeoutMs: settlementTimeoutMs })
51
+ ));
52
+ const handlerResult = await settleOne(() => (
53
+ awaitHandlerSettlement({ timeoutMs: settlementTimeoutMs })
54
+ ));
55
+
56
+ throwFirstRejected([
57
+ retirementResult,
58
+ deliveryResult,
59
+ fallbackResult,
60
+ ingressResult,
61
+ handlerResult,
62
+ ]);
63
+ const snapshots = retirementResult.value;
64
+ const normalizedSnapshots = Array.isArray(snapshots) ? snapshots : [];
65
+ return {
66
+ snapshots: normalizedSnapshots,
67
+ qualification: Object.hasOwn(normalizedSnapshots, 'qualification')
68
+ ? normalizedSnapshots.qualification
69
+ : null,
70
+ };
71
+ }
72
+
73
+ async function settleCrashShutdown({
74
+ pm,
75
+ deliveryBarrier,
76
+ awaitIngressSettlement,
77
+ awaitHandlerSettlement,
78
+ settlementTimeoutMs = 30_000,
79
+ } = {}) {
80
+ if (typeof deliveryBarrier?.fenceAndDrain !== 'function') {
81
+ throw new TypeError('crash shutdown requires a delivery barrier');
82
+ }
83
+ if (typeof pm?.shutdown !== 'function') {
84
+ throw new TypeError('ProcessManager.shutdown is required');
85
+ }
86
+ if (typeof awaitIngressSettlement !== 'function') {
87
+ throw new TypeError('crash shutdown requires ingress settlement');
88
+ }
89
+ if (typeof awaitHandlerSettlement !== 'function') {
90
+ throw new TypeError('crash shutdown requires handler settlement');
91
+ }
92
+
93
+ const deliveryDrain = captureSettlement(() => deliveryBarrier.fenceAndDrain());
94
+ const processShutdown = captureSettlement(() => pm.shutdown());
95
+ const [deliveryResult, shutdownResult] = await Promise.allSettled([
96
+ deliveryDrain,
97
+ processShutdown,
98
+ ]);
99
+ const ingressResult = await settleOne(() => (
100
+ awaitIngressSettlement({ timeoutMs: settlementTimeoutMs })
101
+ ));
102
+ const handlerResult = await settleOne(() => (
103
+ awaitHandlerSettlement({ timeoutMs: settlementTimeoutMs })
104
+ ));
105
+
106
+ throwFirstRejected([
107
+ shutdownResult,
108
+ deliveryResult,
109
+ ingressResult,
110
+ handlerResult,
111
+ ]);
112
+ }
113
+
114
+ function captureSettlement(operation) {
115
+ try {
116
+ return Promise.resolve(operation());
117
+ } catch (error) {
118
+ return Promise.reject(error);
119
+ }
120
+ }
121
+
122
+ async function settleOne(operation) {
123
+ const [result] = await Promise.allSettled([captureSettlement(operation)]);
124
+ return result;
125
+ }
126
+
127
+ function throwFirstRejected(results) {
128
+ const rejected = results.find((result) => result?.status === 'rejected');
129
+ if (rejected) throw rejected.reason;
32
130
  }
33
131
 
34
132
  function buildResumeIntents({
@@ -116,5 +214,6 @@ function buildResumeIntents({
116
214
 
117
215
  module.exports = {
118
216
  prepareCleanRetirement,
217
+ settleCrashShutdown,
119
218
  buildResumeIntents,
120
219
  };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+
6
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
7
+ const PACKAGE_VERSION_RE = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$/;
8
+
9
+ function createDaemonIdentity({
10
+ mainPath,
11
+ packageVersion,
12
+ pid = process.pid,
13
+ randomUUID = crypto.randomUUID,
14
+ realpathSync = fs.realpathSync,
15
+ } = {}) {
16
+ if (typeof mainPath !== 'string' || mainPath.length === 0) {
17
+ throw new TypeError('daemon main path is required');
18
+ }
19
+ if (!PACKAGE_VERSION_RE.test(packageVersion || '')) {
20
+ throw new TypeError('daemon package version is invalid');
21
+ }
22
+ if (!Number.isSafeInteger(pid) || pid <= 1) {
23
+ throw new TypeError('daemon PID is invalid');
24
+ }
25
+
26
+ const daemonInstanceId = randomUUID();
27
+ if (!UUID_RE.test(daemonInstanceId)) {
28
+ throw new TypeError('daemon instance identity is invalid');
29
+ }
30
+ const mainRealpath = realpathSync(mainPath);
31
+
32
+ return Object.freeze({
33
+ pid,
34
+ daemon_instance_id: daemonInstanceId,
35
+ package_version: packageVersion,
36
+ main_realpath_sha256: crypto.createHash('sha256')
37
+ .update(mainRealpath)
38
+ .digest('hex'),
39
+ });
40
+ }
41
+
42
+ module.exports = { createDaemonIdentity };
@@ -9,11 +9,18 @@ function parseSystemdInvocationId(env = process.env) {
9
9
  return value;
10
10
  }
11
11
 
12
- function lifecycleDetail(detail, invocationId) {
12
+ function lifecycleDetail(detail, invocationId, daemonIdentity = null) {
13
13
  const value = detail && typeof detail === 'object' ? detail : {};
14
- return invocationId == null
14
+ const withDaemonIdentity = daemonIdentity == null
15
15
  ? value
16
- : { ...value, invocation_id: invocationId };
16
+ : {
17
+ ...value,
18
+ daemon_instance_id: daemonIdentity.daemon_instance_id,
19
+ pid: daemonIdentity.pid,
20
+ };
21
+ return invocationId == null
22
+ ? withDaemonIdentity
23
+ : { ...withDaemonIdentity, invocation_id: invocationId };
17
24
  }
18
25
 
19
26
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.38.0",
3
+ "version": "0.38.1",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc/client.js",
6
6
  "bin": {
@@ -63,7 +63,7 @@
63
63
  "dependencies": {
64
64
  "@anthropic-ai/claude-agent-sdk": "0.2.123",
65
65
  "@modelcontextprotocol/sdk": "^1.29.0",
66
- "@shumkov/orchestra": "0.10.14",
66
+ "@shumkov/orchestra": "0.10.16",
67
67
  "better-sqlite3": "^12.9.0",
68
68
  "grammy": "^1.42.0",
69
69
  "markdown-it": "^14.1.1",
package/polygram.js CHANGED
@@ -34,11 +34,17 @@ const { createIpcHandlers } = require('./lib/ipc/handlers');
34
34
  const {
35
35
  requireRestartRequestId,
36
36
  } = require('./lib/ipc/restart-request-id');
37
+ const {
38
+ buildQualificationEvent,
39
+ normalizeDeployQualificationExpectation,
40
+ } = require('./lib/ops/clean-restart-qualification');
37
41
  const { classifyOrphanSweep } = require('./lib/ops/tmux-preflight');
38
42
  const {
39
43
  parseSystemdInvocationId,
40
44
  lifecycleDetail,
41
45
  } = require('./lib/ops/systemd-invocation');
46
+ const { createDaemonIdentity } = require('./lib/ops/daemon-identity');
47
+ const packageMetadata = require('./package.json');
42
48
  const { filterAttachments, resolveFileCaps, resolveMaxFileOverride, MAX_TOTAL_BYTES } = require('./lib/attachments');
43
49
  // 0.9.0: SDK ProcessManager is the only pm. CLI pm
44
50
  // (lib/process-manager.js) deleted in commit 6.
@@ -194,6 +200,7 @@ const { createCgroupOomObserver } = require('./lib/ops/cgroup-oom-observer');
194
200
  const { persistShutdownDisposition } = require('./lib/ops/shutdown-disposition');
195
201
  const {
196
202
  prepareCleanRetirement,
203
+ settleCrashShutdown,
197
204
  buildResumeIntents,
198
205
  } = require('./lib/ops/clean-shutdown');
199
206
  const {
@@ -3539,10 +3546,16 @@ function createBot(token) {
3539
3546
  // ─── Manual polling — extracted to lib/handlers/poll.js ────────────
3540
3547
  let pollBot = null;
3541
3548
  let startPollWatchdog = null;
3549
+ let stopPolling = null;
3550
+ let awaitPollSettlement = null;
3542
3551
 
3543
3552
  // ─── Main ───────────────────────────────────────────────────────────
3544
3553
 
3545
3554
  async function main() {
3555
+ const daemonIdentity = createDaemonIdentity({
3556
+ mainPath: __filename,
3557
+ packageVersion: packageMetadata.version,
3558
+ });
3546
3559
  const cgroupOomObserver = createCgroupOomObserver();
3547
3560
  const invocationId = parseSystemdInvocationId();
3548
3561
  let abnormalProviderTermination = null;
@@ -3787,7 +3800,7 @@ async function main() {
3787
3800
  db.logEvent('polygram-start', lifecycleDetail({
3788
3801
  migration: migration.reason,
3789
3802
  imported: migration.imported,
3790
- }, invocationId));
3803
+ }, invocationId, daemonIdentity));
3791
3804
  if (cgroupOomObserver.startup.status === 'unavailable') {
3792
3805
  console.warn(`[oom-observer] unavailable (${cgroupOomObserver.startup.reason}); handled shutdown signals will retain clean-restart behavior`);
3793
3806
  db.logEvent('oom-observer-unavailable', { reason: cgroupOomObserver.startup.reason });
@@ -4483,7 +4496,7 @@ async function main() {
4483
4496
  dropRedeliverer = createDropRedeliverer({
4484
4497
  db, redeliver: redeliverAsFreshTurn, logEvent, logger: console,
4485
4498
  });
4486
- ({ pollBot, startPollWatchdog } = createPollLoop({
4499
+ ({ pollBot, startPollWatchdog, stopPolling, awaitPollSettlement } = createPollLoop({
4487
4500
  db, dbWrite, config, botName: BOT_NAME,
4488
4501
  isWellFormedMessage, getTopicName,
4489
4502
  logger: console,
@@ -4533,6 +4546,7 @@ async function main() {
4533
4546
  continuationAuthorized = false,
4534
4547
  trigger = 'signal',
4535
4548
  restartRequestId = null,
4549
+ qualificationExpectation,
4536
4550
  } = {}) => {
4537
4551
  if (isShuttingDown) return;
4538
4552
  const shutdownObservation = cgroupOomObserver.sample();
@@ -4545,22 +4559,30 @@ async function main() {
4545
4559
  isShuttingDown = true;
4546
4560
  console.log(`\nShutting down (${trigger})...`);
4547
4561
  // 1. Stop accepting new inbound first so nothing new queues behind the drain.
4548
- if (bot && bot._stop) bot._stop();
4549
-
4550
- // 1.5 (0.13 D1): expire open interactive questions {cancelled} BEFORE the
4551
- // drain. With D1 the asking turn stays in flight for the whole wait, so a
4552
- // deploy during a question would otherwise eat the entire 30s drain and
4553
- // mark the inbound replay-pending mid-ask. Cancelling unblocks claude's
4554
- // ask so the cycle can end inside the drain; the boot-replay re-ask is the
4555
- // documented recovery path (design §3 D1 ask-wait semantics).
4562
+ stopPolling();
4563
+ const awaitIngressSettlement = ({ timeoutMs } = {}) => (
4564
+ awaitPollSettlement({ timeoutMs })
4565
+ );
4566
+
4567
+ // Resolve open questions before provider settlement. Authorized deploys
4568
+ // register the card edit and cancel the row without answering the retiring
4569
+ // provider; ordinary stops keep the existing answer-and-await behavior.
4556
4570
  try {
4557
4571
  const openQuestions = questionStore.listOpen?.(BOT_NAME) || [];
4558
- for (const row of openQuestions) {
4559
- // eslint-disable-next-line no-await-in-loop
4560
- await questionHandlers.expireQuestion(row, {
4561
- status: 'cancelled',
4562
- message: 'Bot is restarting — this question was cancelled. It may be re-asked in a moment.',
4563
- }).catch(() => {});
4572
+ if (continuationAuthorized) {
4573
+ for (const row of openQuestions) {
4574
+ questionHandlers.beginShutdownDisposition(row, {
4575
+ message: 'Bot is restarting — this question was cancelled. It may be re-asked in a moment.',
4576
+ });
4577
+ }
4578
+ } else {
4579
+ for (const row of openQuestions) {
4580
+ // eslint-disable-next-line no-await-in-loop
4581
+ await questionHandlers.expireQuestion(row, {
4582
+ status: 'cancelled',
4583
+ message: 'Bot is restarting — this question was cancelled. It may be re-asked in a moment.',
4584
+ }).catch(() => {});
4585
+ }
4564
4586
  }
4565
4587
  if (openQuestions.length) {
4566
4588
  logEvent('shutdown-questions-cancelled', { count: openQuestions.length });
@@ -4619,13 +4641,15 @@ async function main() {
4619
4641
  // 2. Drain in-flight handlers. Wait for inFlightHandlers to empty or
4620
4642
  // SHUTDOWN_DRAIN_MS to elapse. pm handlers resolve naturally when
4621
4643
  // result events arrive; the dispatcher's .finally decrements.
4622
- const drainStart = Date.now();
4623
- while (inFlightHandlers.size > 0) {
4624
- if (Date.now() - drainStart >= SHUTDOWN_DRAIN_MS) break;
4625
- await new Promise((r) => setTimeout(r, 100));
4644
+ let drainElapsed = 0;
4645
+ if (!continuationAuthorized) {
4646
+ const drainStart = Date.now();
4647
+ while (inFlightHandlers.size > 0) {
4648
+ if (Date.now() - drainStart >= SHUTDOWN_DRAIN_MS) break;
4649
+ await new Promise((r) => setTimeout(r, 100));
4650
+ }
4651
+ drainElapsed = Date.now() - drainStart;
4626
4652
  }
4627
- const drainElapsed = Date.now() - drainStart;
4628
- const remaining = countInFlight(inFlightHandlers);
4629
4653
 
4630
4654
  // 3. A clean stop closes both output boundaries before persisting any
4631
4655
  // recovery intent. The ProcessManager receipt is the only authority for
@@ -4636,14 +4660,38 @@ async function main() {
4636
4660
  ? 'provider-process-terminated'
4637
4661
  : null;
4638
4662
  let resumeIntents = [];
4639
- if (shutdownObservation?.detected !== true) {
4663
+ if (shutdownObservation?.detected === true) {
4664
+ try {
4665
+ await settleCrashShutdown({
4666
+ pm,
4667
+ deliveryBarrier,
4668
+ awaitIngressSettlement,
4669
+ awaitHandlerSettlement,
4670
+ settlementTimeoutMs: SHUTDOWN_DRAIN_MS,
4671
+ });
4672
+ } catch (err) {
4673
+ console.error(`[shutdown] crash settlement failed: ${err.message}`);
4674
+ }
4675
+ pmRetired = true;
4676
+ } else {
4640
4677
  try {
4641
4678
  const retirement = await prepareCleanRetirement({
4642
4679
  pm,
4643
4680
  deliveryBarrier,
4681
+ awaitIngressSettlement,
4644
4682
  awaitHandlerSettlement,
4645
4683
  settlementTimeoutMs: SHUTDOWN_DRAIN_MS,
4684
+ qualificationExpectation,
4646
4685
  });
4686
+ if (qualificationExpectation !== undefined) {
4687
+ logEvent('clean-restart-qualification-observed', buildQualificationEvent({
4688
+ botName: BOT_NAME,
4689
+ daemonIdentity,
4690
+ restartRequestId,
4691
+ expectation: qualificationExpectation,
4692
+ observation: retirement.qualification,
4693
+ }));
4694
+ }
4647
4695
  pmRetired = true;
4648
4696
  const projected = continuationAuthorized ? buildResumeIntents({
4649
4697
  snapshots: retirement.snapshots,
@@ -4686,12 +4734,16 @@ async function main() {
4686
4734
  });
4687
4735
  }
4688
4736
  } catch (err) {
4689
- forceCrashReason = 'clean-retirement-failed';
4737
+ forceCrashReason = err?.code === 'POLL_OFFSET_PERSISTENCE_FAILED'
4738
+ ? 'poll-offset-persistence-failed'
4739
+ : err?.code === 'POLL_SETTLEMENT_TIMEOUT'
4740
+ ? 'poll-settlement-timeout'
4741
+ : 'clean-retirement-failed';
4690
4742
  console.error(`[shutdown] clean retirement failed: ${err.message}`);
4691
- if (pm) await pm.shutdown().catch(() => {});
4692
4743
  pmRetired = true;
4693
4744
  }
4694
4745
  }
4746
+ const remaining = countInFlight(inFlightHandlers);
4695
4747
 
4696
4748
  // 4. Persist only after the old delivery/process boundary is closed and
4697
4749
  // every dispatcher-owned continuation has settled. Any uncertainty is
@@ -4732,7 +4784,7 @@ async function main() {
4732
4784
  ? restartRequestId
4733
4785
  : null,
4734
4786
  ...(res.oomKillDelta != null ? { oom_kill_delta: res.oomKillDelta } : {}),
4735
- }, invocationId));
4787
+ }, invocationId, daemonIdentity));
4736
4788
  console.log(`[shutdown] ${res.clean ? 'clean' : 'crash-like'} shutdown recorded (${res.shutdownReason}); ${inFlightAtSignal} in-flight at signal, drained ${drainElapsed}ms, ${remaining} still in-flight, ${res.replayMarked} marked replay-pending`);
4737
4789
  } catch (err) {
4738
4790
  console.error(`[shutdown] persistence failed: ${err.message}`);
@@ -4766,7 +4818,7 @@ async function main() {
4766
4818
  if (pm && !pmRetired) await pm.shutdown().catch(() => {});
4767
4819
  if (db) {
4768
4820
  try {
4769
- db.logEvent('polygram-stop', lifecycleDetail({}, invocationId));
4821
+ db.logEvent('polygram-stop', lifecycleDetail({}, invocationId, daemonIdentity));
4770
4822
  db.raw.close();
4771
4823
  } catch {}
4772
4824
  }
@@ -4802,8 +4854,12 @@ async function main() {
4802
4854
  secret: ipcSecret,
4803
4855
  handlers: createIpcHandlers({
4804
4856
  botName: BOT_NAME,
4857
+ daemonIdentity,
4805
4858
  getInFlightHandlers: () => inFlightHandlers,
4806
4859
  handleSendOverIpc: (req) => handleSendOverIpc(req),
4860
+ inspectCleanRestartQualification: () => (
4861
+ pm.inspectCleanRestartQualification(undefined)
4862
+ ),
4807
4863
  requestDeployRestart: (req) => {
4808
4864
  if (isShuttingDown) {
4809
4865
  const error = new Error('shutdown already in progress');
@@ -4811,11 +4867,13 @@ async function main() {
4811
4867
  throw error;
4812
4868
  }
4813
4869
  const restartRequestId = requireRestartRequestId(req.id);
4870
+ const qualificationExpectation = normalizeDeployQualificationExpectation(req);
4814
4871
  const oldPid = process.pid;
4815
4872
  shutdown({
4816
4873
  continuationAuthorized: true,
4817
4874
  trigger: 'deploy-ipc',
4818
4875
  restartRequestId,
4876
+ qualificationExpectation,
4819
4877
  }).catch((error) => {
4820
4878
  console.error(`[shutdown] deploy restart failed: ${error.message}`);
4821
4879
  });
@@ -5337,7 +5395,7 @@ async function main() {
5337
5395
 
5338
5396
  logEvent('polygram-admission-open', lifecycleDetail({
5339
5397
  bot: BOT_NAME,
5340
- }, invocationId));
5398
+ }, invocationId, daemonIdentity));
5341
5399
  console.log(`[${BOT_NAME}] Starting...`);
5342
5400
  const pollPromise = pollBot(bot).catch(err => {
5343
5401
  console.error(`[${BOT_NAME}] Fatal:`, err.message);
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * Quick IPC round-trip probe.
4
- * Usage: node scripts/ipc-smoke.js <bot-name> [busy|restart] [restart-request-id]
4
+ * Usage: node scripts/ipc-smoke.js <bot-name> [busy|identity|restart] [restart-request-id]
5
5
  */
6
6
 
7
7
  const crypto = require('node:crypto');
@@ -14,6 +14,9 @@ function restartRequestId(value) {
14
14
  return requireRestartRequestId(value || crypto.randomUUID());
15
15
  }
16
16
 
17
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
18
+ const SHA256_RE = /^[0-9a-f]{64}$/;
19
+
17
20
  (async () => {
18
21
  const bot = process.argv[2] || 'shumabit';
19
22
  const command = process.argv[3] || 'ping';
@@ -67,12 +70,44 @@ function restartRequestId(value) {
67
70
  return;
68
71
  }
69
72
 
73
+ if (command === 'identity') {
74
+ const result = await call({
75
+ path,
76
+ op: 'identity',
77
+ secret: readSecret(bot),
78
+ connectTimeoutMs: 5_000,
79
+ callTimeoutMs: 5_000,
80
+ });
81
+ if (
82
+ result.ok !== true
83
+ || result.bot !== bot
84
+ || !Number.isSafeInteger(result.pid)
85
+ || result.pid <= 1
86
+ || !UUID_RE.test(result.daemon_instance_id)
87
+ || typeof result.package_version !== 'string'
88
+ || result.package_version.length === 0
89
+ || result.package_version.length > 128
90
+ || !SHA256_RE.test(result.main_realpath_sha256)
91
+ ) {
92
+ throw new Error('invalid daemon identity response');
93
+ }
94
+ console.log(JSON.stringify({
95
+ bot: result.bot,
96
+ pid: result.pid,
97
+ daemon_instance_id: result.daemon_instance_id,
98
+ package_version: result.package_version,
99
+ main_realpath_sha256: result.main_realpath_sha256,
100
+ }));
101
+ return;
102
+ }
103
+
70
104
  console.log('path:', path);
71
105
  console.log('ping:', JSON.stringify(await call({ path, op: 'ping' })));
72
106
  console.log('DONE');
73
107
  })().catch((err) => {
74
- const message = process.argv[3] === 'busy'
75
- ? 'busy request failed'
108
+ const command = process.argv[3];
109
+ const message = command === 'busy' || command === 'identity'
110
+ ? `${command} request failed`
76
111
  : err.message;
77
112
  console.error('ERR:', message);
78
113
  process.exit(1);