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/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
- // Rich delivery is limited to this interactive streamer instance.
1558
- // A reply that never crosses minChars (stays idle → delivered via
1559
- // deliverReplies) and any non-streaming send path — autonomous-wakeup/
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 — never render rich even when richText is on and the
1562
- // content qualifies. Those delivery paths intentionally remain plain.
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
- // 0.7.0: use markdown-aware chunker + deliverReplies primitive.
2911
- // The old chunkText was newline/byte-only; chunkMarkdownText also
2912
- // respects code-fence boundaries (closes + reopens across chunks).
2913
- // Short replies never went live-rich, so image markdown from a
2914
- // rich-enabled chat degrades to caption text here too — never a
2915
- // raw local path in the bubble.
2916
- const shortText = resolveRichTextEnabled(config, chatId, threadId)
2917
- ? stripMediaMarkdown(parsed.text)
2918
- : parsed.text;
2919
- const chunks = chunkMarkdownText(shortText, TG_CHUNK_BUDGET);
2920
- const deliveryResult = await deliverReplies({
2921
- bot,
2922
- send: (b, method, params, m) => tg(b, method, params, m),
2923
- chatId,
2924
- threadId,
2925
- chunks,
2926
- replyToMessageId: msg.message_id,
2927
- meta: outMeta,
2928
- logger: { error: (m) => console.error(`[${label}] ${m}`) },
2929
- });
2930
- mediaContext.recordTextFailures(deliveryResult.failed.length);
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
- // Injected rather than imported by the dispatcher: rich-media.js requires
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
- if (bot && bot._stop) bot._stop();
4484
-
4485
- // 1.5 (0.13 D1): expire open interactive questions {cancelled} BEFORE the
4486
- // drain. With D1 the asking turn stays in flight for the whole wait, so a
4487
- // deploy during a question would otherwise eat the entire 30s drain and
4488
- // mark the inbound replay-pending mid-ask. Cancelling unblocks claude's
4489
- // ask so the cycle can end inside the drain; the boot-replay re-ask is the
4490
- // 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.
4491
4570
  try {
4492
4571
  const openQuestions = questionStore.listOpen?.(BOT_NAME) || [];
4493
- for (const row of openQuestions) {
4494
- // eslint-disable-next-line no-await-in-loop
4495
- await questionHandlers.expireQuestion(row, {
4496
- status: 'cancelled',
4497
- message: 'Bot is restarting — this question was cancelled. It may be re-asked in a moment.',
4498
- }).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
+ }
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
- const drainStart = Date.now();
4558
- while (inFlightHandlers.size > 0) {
4559
- if (Date.now() - drainStart >= SHUTDOWN_DRAIN_MS) break;
4560
- 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;
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 !== 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 {
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 = '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';
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);
@@ -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);