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.
- 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/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/package.json +2 -2
- package/polygram.js +86 -28
- 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.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
|
|
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
|
}
|
|
@@ -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
|
+
};
|
package/lib/ops/clean-resume.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
30
|
+
...(qualificationExpectation !== undefined
|
|
31
|
+
? { qualificationExpectation }
|
|
32
|
+
: {}),
|
|
33
|
+
}));
|
|
34
|
+
const [deliveryResult, retirementResult] = await Promise.allSettled([
|
|
27
35
|
deliveryDrain,
|
|
28
36
|
processRetirement,
|
|
29
37
|
]);
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
14
|
+
const withDaemonIdentity = daemonIdentity == null
|
|
15
15
|
? value
|
|
16
|
-
: {
|
|
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.
|
|
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.
|
|
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
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
//
|
|
4554
|
-
//
|
|
4555
|
-
//
|
|
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
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
}
|
|
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
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
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
|
|
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 = '
|
|
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);
|
package/scripts/ipc-smoke.js
CHANGED
|
@@ -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
|
|
75
|
-
|
|
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);
|