polygram 0.37.2 → 0.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/codex/fault-provenance.js +75 -0
- package/lib/codex/runtime-controller.js +4 -0
- package/lib/db/codex-failed-generation.js +8 -1
- package/lib/db/events-retention.js +1 -0
- package/lib/db.js +4 -0
- package/lib/handlers/config-ui.js +53 -44
- package/lib/handlers/poll.js +138 -59
- package/lib/handlers/questions.js +26 -1
- package/lib/ipc/handlers.js +25 -0
- package/lib/ops/clean-restart-qualification.js +260 -0
- package/lib/ops/clean-resume.js +12 -1
- package/lib/ops/clean-shutdown.js +105 -6
- package/lib/ops/daemon-identity.js +42 -0
- package/lib/ops/systemd-invocation.js +10 -3
- package/lib/telegram/display-hint.js +1 -0
- package/package.json +2 -2
- package/polygram.js +206 -83
- package/scripts/ipc-smoke.js +38 -3
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 {
|
|
@@ -482,6 +489,13 @@ let richEditMessageText;
|
|
|
482
489
|
// classification, but it never throws: its caller holds the only copy of the
|
|
483
490
|
// reply and owns the plain fallback.
|
|
484
491
|
let richSendMessage;
|
|
492
|
+
// The rich half of the reply-tool delivery chain, hoisted so the interactive
|
|
493
|
+
// turn path can reuse the same instance rather than configure a second one.
|
|
494
|
+
// Two instances would be two capability latches, two styling verdicts and two
|
|
495
|
+
// sets of ceilings to keep in step; one is the only way they cannot drift.
|
|
496
|
+
// Null until main() wires it — callers fall back to plain, which is what a
|
|
497
|
+
// chat without rich gets anyway.
|
|
498
|
+
let makeRichDeliverText = null;
|
|
485
499
|
let richMediaFileIdCache;
|
|
486
500
|
|
|
487
501
|
// 0.15 secret redaction (agent-flagged path): the agent marks a secret it saw
|
|
@@ -1554,12 +1568,15 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1554
1568
|
// path — same contract for "not opted in", "capability known
|
|
1555
1569
|
// unsupported", and "content doesn't need it" (needsRichRendering
|
|
1556
1570
|
// inside toTelegramRichBlocks handles the last one).
|
|
1557
|
-
//
|
|
1558
|
-
//
|
|
1559
|
-
//
|
|
1571
|
+
// This callback covers the streamed bubble only. A turn that ends with
|
|
1572
|
+
// text instead of streaming it renders rich too, through the reply tool's
|
|
1573
|
+
// own factory reused at the non-streamed branch below — one gate, one
|
|
1574
|
+
// sender, one latch, so the same markdown cannot format two ways
|
|
1575
|
+
// depending on how the answer arrived.
|
|
1576
|
+
// Still plain: the non-streaming send paths — autonomous-wakeup/
|
|
1560
1577
|
// auto-resume/autosteer via lib/telegram/process-agent-reply.js, cron/
|
|
1561
|
-
// IPC sends
|
|
1562
|
-
//
|
|
1578
|
+
// IPC sends. Those render plain even when richText is on and the content
|
|
1579
|
+
// qualifies.
|
|
1563
1580
|
// Re-read both controls for every flush so a config change or a
|
|
1564
1581
|
// capability result applies to the turn already in progress.
|
|
1565
1582
|
// Typed media resolution for rich replies. Same
|
|
@@ -2907,27 +2924,78 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
2907
2924
|
console.error(`[${label}] sendSticker failed: ${err.message}`);
|
|
2908
2925
|
});
|
|
2909
2926
|
} else if (parsed.text) {
|
|
2910
|
-
//
|
|
2911
|
-
//
|
|
2912
|
-
//
|
|
2913
|
-
//
|
|
2914
|
-
//
|
|
2915
|
-
//
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2927
|
+
// A turn that ends with text instead of calling the reply tool lands
|
|
2928
|
+
// here. It is the same agent writing the same markdown as the reply
|
|
2929
|
+
// tool, so it renders through the same gate and the same sender —
|
|
2930
|
+
// otherwise a table is a table or a wall of pipes depending only on
|
|
2931
|
+
// which way the answer happened to arrive, which is not a distinction
|
|
2932
|
+
// the reader can see or act on.
|
|
2933
|
+
//
|
|
2934
|
+
// Text-only by construction: no allowedRoots, so media stays degraded
|
|
2935
|
+
// to caption text exactly as it was here before, and this path opens no
|
|
2936
|
+
// upload surface of its own.
|
|
2937
|
+
// Wrapped even though the factory is contracted not to throw: that
|
|
2938
|
+
// contract is convention, and its styling-retry ladder evaluates
|
|
2939
|
+
// blocksAreStyled/flattenStyledBlocks outside any try. A throw escaping
|
|
2940
|
+
// here would skip the plain arm and cost the whole reply, so this catch
|
|
2941
|
+
// is the difference between a flat answer and no answer.
|
|
2942
|
+
let richOut = null;
|
|
2943
|
+
try {
|
|
2944
|
+
richOut = makeRichDeliverText
|
|
2945
|
+
? await makeRichDeliverText({ chatId, threadId })({
|
|
2946
|
+
text: parsed.text,
|
|
2947
|
+
replyToMessageId: msg.message_id,
|
|
2948
|
+
// Its own source, because rich-message-sent carries none and the
|
|
2949
|
+
// streamed rich open reports as bot-reply-stream-open-rich.
|
|
2950
|
+
// Without a distinct tag nothing downstream can tell this branch
|
|
2951
|
+
// from the other sites writing the bare streamer tag.
|
|
2952
|
+
meta: { ...outMeta, source: 'bot-reply-final-rich' },
|
|
2953
|
+
})
|
|
2954
|
+
: null;
|
|
2955
|
+
} catch (richErr) {
|
|
2956
|
+
// Named apart from the turn's own `err` on purpose: this is the
|
|
2957
|
+
// rich attempt failing, not the turn, and the completion tail is
|
|
2958
|
+
// located in tests by scanning for the turn handler's catch.
|
|
2959
|
+
console.error(`[${label}] rich delivery threw, falling back to plain: ${richErr.message}`);
|
|
2960
|
+
richOut = null;
|
|
2961
|
+
}
|
|
2962
|
+
if (richOut?.handled) {
|
|
2963
|
+
// One bubble, no chunks, nothing failed — but still report, because
|
|
2964
|
+
// this is the same incomplete-delivery gate the plain arm feeds and
|
|
2965
|
+
// it must hear from whichever arm ran.
|
|
2966
|
+
mediaContext.recordTextFailures(richOut.failed.length);
|
|
2967
|
+
} else {
|
|
2968
|
+
// 0.7.0: use markdown-aware chunker + deliverReplies primitive.
|
|
2969
|
+
// The old chunkText was newline/byte-only; chunkMarkdownText also
|
|
2970
|
+
// respects code-fence boundaries (closes + reopens across chunks).
|
|
2971
|
+
// Image markdown from a rich-enabled chat degrades to caption text
|
|
2972
|
+
// here too — never a raw local path in the bubble.
|
|
2973
|
+
//
|
|
2974
|
+
// The `??` arm is load-bearing: a chat with rich off declines with
|
|
2975
|
+
// no body at all, and chunking `undefined` would drop the reply
|
|
2976
|
+
// entirely for most of the fleet. Both arms strip through the same
|
|
2977
|
+
// call, so the delivered text is byte-identical either way.
|
|
2978
|
+
const shortText = richOut?.text ?? (resolveRichTextEnabled(config, chatId, threadId)
|
|
2979
|
+
? stripMediaMarkdown(parsed.text)
|
|
2980
|
+
: parsed.text);
|
|
2981
|
+
const chunks = chunkMarkdownText(shortText, TG_CHUNK_BUDGET);
|
|
2982
|
+
const deliveryResult = await deliverReplies({
|
|
2983
|
+
bot,
|
|
2984
|
+
send: (b, method, params, m) => tg(b, method, params, m),
|
|
2985
|
+
chatId,
|
|
2986
|
+
threadId,
|
|
2987
|
+
chunks,
|
|
2988
|
+
replyToMessageId: msg.message_id,
|
|
2989
|
+
// Tagged like its rich sibling. Five call sites write the bare
|
|
2990
|
+
// 'bot-reply-stream' tag and three of them deliver reply bodies,
|
|
2991
|
+
// so a row carrying it says nothing about which one produced it.
|
|
2992
|
+
// Naming both arms makes this branch identifiable from the
|
|
2993
|
+
// messages table whichever way the reply went out.
|
|
2994
|
+
meta: { ...outMeta, source: 'bot-reply-final' },
|
|
2995
|
+
logger: { error: (m) => console.error(`[${label}] ${m}`) },
|
|
2996
|
+
});
|
|
2997
|
+
mediaContext.recordTextFailures(deliveryResult.failed.length);
|
|
2998
|
+
}
|
|
2931
2999
|
}
|
|
2932
3000
|
|
|
2933
3001
|
// A turn whose final output was a solo sticker/reaction skips the
|
|
@@ -3478,10 +3546,16 @@ function createBot(token) {
|
|
|
3478
3546
|
// ─── Manual polling — extracted to lib/handlers/poll.js ────────────
|
|
3479
3547
|
let pollBot = null;
|
|
3480
3548
|
let startPollWatchdog = null;
|
|
3549
|
+
let stopPolling = null;
|
|
3550
|
+
let awaitPollSettlement = null;
|
|
3481
3551
|
|
|
3482
3552
|
// ─── Main ───────────────────────────────────────────────────────────
|
|
3483
3553
|
|
|
3484
3554
|
async function main() {
|
|
3555
|
+
const daemonIdentity = createDaemonIdentity({
|
|
3556
|
+
mainPath: __filename,
|
|
3557
|
+
packageVersion: packageMetadata.version,
|
|
3558
|
+
});
|
|
3485
3559
|
const cgroupOomObserver = createCgroupOomObserver();
|
|
3486
3560
|
const invocationId = parseSystemdInvocationId();
|
|
3487
3561
|
let abnormalProviderTermination = null;
|
|
@@ -3726,7 +3800,7 @@ async function main() {
|
|
|
3726
3800
|
db.logEvent('polygram-start', lifecycleDetail({
|
|
3727
3801
|
migration: migration.reason,
|
|
3728
3802
|
imported: migration.imported,
|
|
3729
|
-
}, invocationId));
|
|
3803
|
+
}, invocationId, daemonIdentity));
|
|
3730
3804
|
if (cgroupOomObserver.startup.status === 'unavailable') {
|
|
3731
3805
|
console.warn(`[oom-observer] unavailable (${cgroupOomObserver.startup.reason}); handled shutdown signals will retain clean-restart behavior`);
|
|
3732
3806
|
db.logEvent('oom-observer-unavailable', { reason: cgroupOomObserver.startup.reason });
|
|
@@ -4062,6 +4136,38 @@ async function main() {
|
|
|
4062
4136
|
//
|
|
4063
4137
|
// Neither feature may switch the other off, which a single strategy slot
|
|
4064
4138
|
// could not express.
|
|
4139
|
+
// Built before the chain rather than inline in it, because the interactive
|
|
4140
|
+
// turn path reuses this exact instance for its non-streamed replies. See
|
|
4141
|
+
// the makeRichDeliverText declaration for why it must be one instance.
|
|
4142
|
+
// Injected rather than imported by the dispatcher: rich-media.js requires
|
|
4143
|
+
// the dispatcher module, so the reverse direction would be a require cycle.
|
|
4144
|
+
makeRichDeliverText = createRichDeliveryFactory({
|
|
4145
|
+
bot,
|
|
4146
|
+
sendRich: (args) => richSendMessage(args),
|
|
4147
|
+
isRichTextEnabled: (chatId, threadId) => resolveRichTextEnabled(config, chatId, threadId),
|
|
4148
|
+
getRichKnownUnsupported: () => richSendKnownUnsupported,
|
|
4149
|
+
// No config knob: the automatic flatten-and-retry below IS the control,
|
|
4150
|
+
// and it needs no operator to notice anything. A knob would add a state
|
|
4151
|
+
// where styling is off while the server accepts it, which nothing in
|
|
4152
|
+
// the system can detect or correct.
|
|
4153
|
+
isInlineStylingEnabled: () => !richInlineStylingUnsupported,
|
|
4154
|
+
onStylingRejected: () => richStylingLatch?.recordStylingRejection(),
|
|
4155
|
+
onStylingAccepted: () => richStylingLatch?.recordHealthyOutcome(),
|
|
4156
|
+
redactError: redactBotToken,
|
|
4157
|
+
// Media for one reply. The envelope itself (no URL media, the `files:`
|
|
4158
|
+
// fan-out ceiling, the shared stat) lives in rich-media.js so it is
|
|
4159
|
+
// pinned by behavior rather than by this literal — nothing executes
|
|
4160
|
+
// this file. `allowedRoots` arrives per call from the dispatcher, which
|
|
4161
|
+
// computed it once and validates `files:` against that very array, so a
|
|
4162
|
+
// path the reply tool may upload is exactly a path image syntax may
|
|
4163
|
+
// upload. The interactive path passes none, so it renders text-only.
|
|
4164
|
+
makeMediaWiring: makeReplyMediaWiring({
|
|
4165
|
+
config,
|
|
4166
|
+
fileIdCache: richMediaFileIdCache,
|
|
4167
|
+
logEvent,
|
|
4168
|
+
botName: BOT_NAME,
|
|
4169
|
+
}),
|
|
4170
|
+
});
|
|
4065
4171
|
const makeDeliverText = composeDeliverTextFactories([
|
|
4066
4172
|
createDeliverTextFactory({
|
|
4067
4173
|
registry: streamerRegistry,
|
|
@@ -4079,35 +4185,7 @@ async function main() {
|
|
|
4079
4185
|
resolveRichTextEnabled(config, chatId, threadId) ? stripMediaMarkdown(text) : text
|
|
4080
4186
|
),
|
|
4081
4187
|
}),
|
|
4082
|
-
|
|
4083
|
-
// the dispatcher module, so the reverse direction would be a require cycle.
|
|
4084
|
-
createRichDeliveryFactory({
|
|
4085
|
-
bot,
|
|
4086
|
-
sendRich: (args) => richSendMessage(args),
|
|
4087
|
-
isRichTextEnabled: (chatId, threadId) => resolveRichTextEnabled(config, chatId, threadId),
|
|
4088
|
-
getRichKnownUnsupported: () => richSendKnownUnsupported,
|
|
4089
|
-
// No config knob: the automatic flatten-and-retry below IS the control,
|
|
4090
|
-
// and it needs no operator to notice anything. A knob would add a state
|
|
4091
|
-
// where styling is off while the server accepts it, which nothing in
|
|
4092
|
-
// the system can detect or correct.
|
|
4093
|
-
isInlineStylingEnabled: () => !richInlineStylingUnsupported,
|
|
4094
|
-
onStylingRejected: () => richStylingLatch?.recordStylingRejection(),
|
|
4095
|
-
onStylingAccepted: () => richStylingLatch?.recordHealthyOutcome(),
|
|
4096
|
-
redactError: redactBotToken,
|
|
4097
|
-
// Media for one reply. The envelope itself (no URL media, the `files:`
|
|
4098
|
-
// fan-out ceiling, the shared stat) lives in rich-media.js so it is
|
|
4099
|
-
// pinned by behavior rather than by this literal — nothing executes
|
|
4100
|
-
// this file. `allowedRoots` arrives per call from the dispatcher, which
|
|
4101
|
-
// computed it once and validates `files:` against that very array, so a
|
|
4102
|
-
// path the reply tool may upload is exactly a path image syntax may
|
|
4103
|
-
// upload.
|
|
4104
|
-
makeMediaWiring: makeReplyMediaWiring({
|
|
4105
|
-
config,
|
|
4106
|
-
fileIdCache: richMediaFileIdCache,
|
|
4107
|
-
logEvent,
|
|
4108
|
-
botName: BOT_NAME,
|
|
4109
|
-
}),
|
|
4110
|
-
}),
|
|
4188
|
+
makeRichDeliverText,
|
|
4111
4189
|
]);
|
|
4112
4190
|
|
|
4113
4191
|
// The edit tool's counterpart to the reply chain above. An agent that posts
|
|
@@ -4418,7 +4496,7 @@ async function main() {
|
|
|
4418
4496
|
dropRedeliverer = createDropRedeliverer({
|
|
4419
4497
|
db, redeliver: redeliverAsFreshTurn, logEvent, logger: console,
|
|
4420
4498
|
});
|
|
4421
|
-
({ pollBot, startPollWatchdog } = createPollLoop({
|
|
4499
|
+
({ pollBot, startPollWatchdog, stopPolling, awaitPollSettlement } = createPollLoop({
|
|
4422
4500
|
db, dbWrite, config, botName: BOT_NAME,
|
|
4423
4501
|
isWellFormedMessage, getTopicName,
|
|
4424
4502
|
logger: console,
|
|
@@ -4468,6 +4546,7 @@ async function main() {
|
|
|
4468
4546
|
continuationAuthorized = false,
|
|
4469
4547
|
trigger = 'signal',
|
|
4470
4548
|
restartRequestId = null,
|
|
4549
|
+
qualificationExpectation,
|
|
4471
4550
|
} = {}) => {
|
|
4472
4551
|
if (isShuttingDown) return;
|
|
4473
4552
|
const shutdownObservation = cgroupOomObserver.sample();
|
|
@@ -4480,22 +4559,30 @@ async function main() {
|
|
|
4480
4559
|
isShuttingDown = true;
|
|
4481
4560
|
console.log(`\nShutting down (${trigger})...`);
|
|
4482
4561
|
// 1. Stop accepting new inbound first so nothing new queues behind the drain.
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
//
|
|
4489
|
-
//
|
|
4490
|
-
//
|
|
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.
|
|
4491
4570
|
try {
|
|
4492
4571
|
const openQuestions = questionStore.listOpen?.(BOT_NAME) || [];
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
}
|
|
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
|
+
}
|
|
4499
4586
|
}
|
|
4500
4587
|
if (openQuestions.length) {
|
|
4501
4588
|
logEvent('shutdown-questions-cancelled', { count: openQuestions.length });
|
|
@@ -4554,13 +4641,15 @@ async function main() {
|
|
|
4554
4641
|
// 2. Drain in-flight handlers. Wait for inFlightHandlers to empty or
|
|
4555
4642
|
// SHUTDOWN_DRAIN_MS to elapse. pm handlers resolve naturally when
|
|
4556
4643
|
// result events arrive; the dispatcher's .finally decrements.
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
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;
|
|
4561
4652
|
}
|
|
4562
|
-
const drainElapsed = Date.now() - drainStart;
|
|
4563
|
-
const remaining = countInFlight(inFlightHandlers);
|
|
4564
4653
|
|
|
4565
4654
|
// 3. A clean stop closes both output boundaries before persisting any
|
|
4566
4655
|
// recovery intent. The ProcessManager receipt is the only authority for
|
|
@@ -4571,14 +4660,38 @@ async function main() {
|
|
|
4571
4660
|
? 'provider-process-terminated'
|
|
4572
4661
|
: null;
|
|
4573
4662
|
let resumeIntents = [];
|
|
4574
|
-
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 {
|
|
4575
4677
|
try {
|
|
4576
4678
|
const retirement = await prepareCleanRetirement({
|
|
4577
4679
|
pm,
|
|
4578
4680
|
deliveryBarrier,
|
|
4681
|
+
awaitIngressSettlement,
|
|
4579
4682
|
awaitHandlerSettlement,
|
|
4580
4683
|
settlementTimeoutMs: SHUTDOWN_DRAIN_MS,
|
|
4684
|
+
qualificationExpectation,
|
|
4581
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
|
+
}
|
|
4582
4695
|
pmRetired = true;
|
|
4583
4696
|
const projected = continuationAuthorized ? buildResumeIntents({
|
|
4584
4697
|
snapshots: retirement.snapshots,
|
|
@@ -4621,12 +4734,16 @@ async function main() {
|
|
|
4621
4734
|
});
|
|
4622
4735
|
}
|
|
4623
4736
|
} catch (err) {
|
|
4624
|
-
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';
|
|
4625
4742
|
console.error(`[shutdown] clean retirement failed: ${err.message}`);
|
|
4626
|
-
if (pm) await pm.shutdown().catch(() => {});
|
|
4627
4743
|
pmRetired = true;
|
|
4628
4744
|
}
|
|
4629
4745
|
}
|
|
4746
|
+
const remaining = countInFlight(inFlightHandlers);
|
|
4630
4747
|
|
|
4631
4748
|
// 4. Persist only after the old delivery/process boundary is closed and
|
|
4632
4749
|
// every dispatcher-owned continuation has settled. Any uncertainty is
|
|
@@ -4667,7 +4784,7 @@ async function main() {
|
|
|
4667
4784
|
? restartRequestId
|
|
4668
4785
|
: null,
|
|
4669
4786
|
...(res.oomKillDelta != null ? { oom_kill_delta: res.oomKillDelta } : {}),
|
|
4670
|
-
}, invocationId));
|
|
4787
|
+
}, invocationId, daemonIdentity));
|
|
4671
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`);
|
|
4672
4789
|
} catch (err) {
|
|
4673
4790
|
console.error(`[shutdown] persistence failed: ${err.message}`);
|
|
@@ -4701,7 +4818,7 @@ async function main() {
|
|
|
4701
4818
|
if (pm && !pmRetired) await pm.shutdown().catch(() => {});
|
|
4702
4819
|
if (db) {
|
|
4703
4820
|
try {
|
|
4704
|
-
db.logEvent('polygram-stop', lifecycleDetail({}, invocationId));
|
|
4821
|
+
db.logEvent('polygram-stop', lifecycleDetail({}, invocationId, daemonIdentity));
|
|
4705
4822
|
db.raw.close();
|
|
4706
4823
|
} catch {}
|
|
4707
4824
|
}
|
|
@@ -4737,8 +4854,12 @@ async function main() {
|
|
|
4737
4854
|
secret: ipcSecret,
|
|
4738
4855
|
handlers: createIpcHandlers({
|
|
4739
4856
|
botName: BOT_NAME,
|
|
4857
|
+
daemonIdentity,
|
|
4740
4858
|
getInFlightHandlers: () => inFlightHandlers,
|
|
4741
4859
|
handleSendOverIpc: (req) => handleSendOverIpc(req),
|
|
4860
|
+
inspectCleanRestartQualification: () => (
|
|
4861
|
+
pm.inspectCleanRestartQualification(undefined)
|
|
4862
|
+
),
|
|
4742
4863
|
requestDeployRestart: (req) => {
|
|
4743
4864
|
if (isShuttingDown) {
|
|
4744
4865
|
const error = new Error('shutdown already in progress');
|
|
@@ -4746,11 +4867,13 @@ async function main() {
|
|
|
4746
4867
|
throw error;
|
|
4747
4868
|
}
|
|
4748
4869
|
const restartRequestId = requireRestartRequestId(req.id);
|
|
4870
|
+
const qualificationExpectation = normalizeDeployQualificationExpectation(req);
|
|
4749
4871
|
const oldPid = process.pid;
|
|
4750
4872
|
shutdown({
|
|
4751
4873
|
continuationAuthorized: true,
|
|
4752
4874
|
trigger: 'deploy-ipc',
|
|
4753
4875
|
restartRequestId,
|
|
4876
|
+
qualificationExpectation,
|
|
4754
4877
|
}).catch((error) => {
|
|
4755
4878
|
console.error(`[shutdown] deploy restart failed: ${error.message}`);
|
|
4756
4879
|
});
|
|
@@ -5272,7 +5395,7 @@ async function main() {
|
|
|
5272
5395
|
|
|
5273
5396
|
logEvent('polygram-admission-open', lifecycleDetail({
|
|
5274
5397
|
bot: BOT_NAME,
|
|
5275
|
-
}, invocationId));
|
|
5398
|
+
}, invocationId, daemonIdentity));
|
|
5276
5399
|
console.log(`[${BOT_NAME}] Starting...`);
|
|
5277
5400
|
const pollPromise = pollBot(bot).catch(err => {
|
|
5278
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);
|