polygram 0.38.1 → 0.38.3

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
@@ -31,13 +31,15 @@ const {
31
31
  const { buildPrompt, resolvePromptBackend } = require('./lib/prompt');
32
32
  const { countInFlight } = require('./lib/queue-utils');
33
33
  const { createIpcHandlers } = require('./lib/ipc/handlers');
34
- const {
35
- requireRestartRequestId,
36
- } = require('./lib/ipc/restart-request-id');
37
34
  const {
38
35
  buildQualificationEvent,
39
- normalizeDeployQualificationExpectation,
40
36
  } = require('./lib/ops/clean-restart-qualification');
37
+ const {
38
+ createForegroundCanaryAuthorizer,
39
+ } = require('./lib/ops/foreground-canary-target');
40
+ const {
41
+ createDeployRestartHandler,
42
+ } = require('./lib/ops/deploy-restart-handler');
41
43
  const { classifyOrphanSweep } = require('./lib/ops/tmux-preflight');
42
44
  const {
43
45
  parseSystemdInvocationId,
@@ -119,7 +121,7 @@ const { createRedeliver } = require('./lib/handlers/redeliver');
119
121
  const { classifyReplay, executeReplayPlan } = require('./lib/handlers/replay-disposition');
120
122
  const { createDropRedeliverer } = require('./lib/handlers/drop-redeliver');
121
123
  const { createSessionFeedback } = require('./lib/feedback/session-feedback');
122
- const { createSlashCommands } = require('./lib/handlers/slash-commands');
124
+ const { createSlashCommands, normalizeCompactCommand } = require('./lib/handlers/slash-commands');
123
125
  const {
124
126
  buildCodexReconciliationView,
125
127
  createHandleCodexReconciliationCallback,
@@ -1075,6 +1077,8 @@ let attemptAutoResume = null;
1075
1077
  let errorReplyText = null;
1076
1078
  let queueWarnThreshold = null;
1077
1079
  let inFlightHandlers = null;
1080
+ let getActiveHandlerCount = null;
1081
+ let getActiveHandlerTargets = null;
1078
1082
  let awaitHandlerSettlement = null;
1079
1083
  let trackHandlerTask = null;
1080
1084
  let recoverCodexRequest = null;
@@ -1394,7 +1398,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1394
1398
  // /pair-code /pairings /unpair /pair. Returns true when handled;
1395
1399
  // caller short-circuits.
1396
1400
  if (await dispatchSlashCommand({
1397
- text, sessionKey, chatId, threadIdStr, chatConfig,
1401
+ text, msgId: msg.message_id, sessionKey, chatId, threadIdStr, chatConfig,
1398
1402
  cmdUser, cmdUserId, label, sendReply,
1399
1403
  })) return;
1400
1404
 
@@ -1507,7 +1511,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1507
1511
  console.error(`[${label}] failed to notify user of skipped attachments: ${err.message}`);
1508
1512
  logEvent('attachment-skip-notice-failed', {
1509
1513
  chat_id: chatId, msg_id: msg.message_id,
1510
- error: err.message?.slice(0, 200),
1514
+ error_class: err.name || 'Error',
1511
1515
  rejected_count: rejected.length,
1512
1516
  });
1513
1517
  }
@@ -1789,7 +1793,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1789
1793
  // Network errors can embed the request URL, which carries the
1790
1794
  // bot token — redact before persisting, matching rich-edit.js
1791
1795
  // and api.js's handling of the same error class.
1792
- api_error: redactBotToken(err.message)?.slice(0, 200),
1796
+ api_error_code: err.code || err.name || 'error',
1797
+ error_len: err.message?.length ?? 0,
1793
1798
  bot: BOT_NAME,
1794
1799
  });
1795
1800
  throw err;
@@ -2449,7 +2454,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2449
2454
  logEvent('wedged-session-detected', {
2450
2455
  chat_id: chatId, session_key: sessionKey,
2451
2456
  kind: wedge.kind,
2452
- text_preview: result.text.slice(0, 200),
2457
+ // The wrapper's classification is the forensic signal; the wrapped
2458
+ // payload is provider content and stays out of the row.
2459
+ text_len: result.text.length,
2453
2460
  });
2454
2461
  // Promote the wrapped error to result.error so the existing
2455
2462
  // auto-recover + thrown-error machinery handles it uniformly.
@@ -2470,6 +2477,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2470
2477
  // to type /new.
2471
2478
  const cls = classifyError(result.error);
2472
2479
  if (cls.autoRecover === 'reset_session') {
2480
+ // The process that owns any open question is going away with the
2481
+ // session. Retirement closes the row and drops what is held exactly
2482
+ // for it — that part runs synchronously, so starting it here is enough
2483
+ // to have happened before the reset. Only the card edit it leaves
2484
+ // behind is awaited, and the reset must not wait on Telegram.
2485
+ questionHandlers?.retireSession(sessionKey)
2486
+ ?.catch((err) => console.error(`[${label}] question retire failed: ${err.message}`));
2473
2487
  pm.resetSession(sessionKey, { reason: cls.kind })
2474
2488
  .catch((err) => console.error(`[${label}] auto-reset failed: ${err.message}`));
2475
2489
  logEvent('auto-recover', {
@@ -2644,7 +2658,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2644
2658
  reactor.setState('ERROR');
2645
2659
  logEvent('telegram-empty-response-fallback-failed', {
2646
2660
  chat_id: chatId, msg_id: msg.message_id, bot: BOT_NAME,
2647
- error: err.message?.slice(0, 200),
2661
+ error_class: err.name || 'Error',
2648
2662
  });
2649
2663
  throw new Error(`empty-response fallback send failed: ${err.message}`);
2650
2664
  }
@@ -2733,7 +2747,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
2733
2747
  logEvent('canned-reply-suppressed', {
2734
2748
  chat_id: chatId,
2735
2749
  msg_id: msg.message_id,
2736
- original: sanitized.original,
2750
+ // The suppressed reply is provider content; its size is the signal.
2751
+ original_len: sanitized.original?.length ?? 0,
2737
2752
  backend: result?.backend || null,
2738
2753
  });
2739
2754
  parsed.text = sanitized.text;
@@ -3531,7 +3546,7 @@ function createBot(token) {
3531
3546
  bot: BOT_NAME,
3532
3547
  update_id: updateId,
3533
3548
  msg_id: msgId,
3534
- error: err.message?.slice(0, 300),
3549
+ error_class: err.name || 'Error',
3535
3550
  });
3536
3551
  });
3537
3552
 
@@ -3871,7 +3886,7 @@ async function main() {
3871
3886
  }
3872
3887
  } catch (err) {
3873
3888
  console.error(`[secret-sweep] FAILED (${trigger}): ${err.message}`);
3874
- try { db.logEvent('secret-sweep-failed', { trigger, error: err.message }); } catch {}
3889
+ try { db.logEvent('secret-sweep-failed', { trigger, error_class: err.name || 'Error' }); } catch {}
3875
3890
  }
3876
3891
  };
3877
3892
  if (secretSweepCfg.enabled) {
@@ -4042,6 +4057,20 @@ async function main() {
4042
4057
  answerQuestion: (sk, tc, result) => pm.answerQuestion(sk, tc, result),
4043
4058
  logger: console,
4044
4059
  });
4060
+ // A question whose answer was held in memory cannot be completed by this
4061
+ // process: the exact value went with the one that died. Cancel those rows
4062
+ // here — before replay, redelivery, provider recovery, polling or any other
4063
+ // path can act on them — so none is ever answered from a marker.
4064
+ // A failure here is fatal on purpose. Rows this could not cancel would meet
4065
+ // replay and inbound traffic with an answer no live process can complete, so
4066
+ // startup stops instead of admitting messages behind an open fence.
4067
+ try {
4068
+ await questionHandlers.reconcileMarkedQuestionsAtBoot(questionStore.listOpen(BOT_NAME));
4069
+ } catch (e) {
4070
+ console.error(`[${BOT_NAME}] question boot reconciliation failed; refusing to admit traffic: ${e.message}`);
4071
+ throw e;
4072
+ }
4073
+
4045
4074
  // Resolve expired questions with {timedout} so claude never hangs on an ignored ask.
4046
4075
  setInterval(() => {
4047
4076
  try {
@@ -4055,7 +4084,10 @@ async function main() {
4055
4084
  // the copy-only transcript-fork executor (P2/P3: fork → repoint the session → kill → delete
4056
4085
  // orphaned bot messages). channels/cli only; the fork mechanism was proven in P0.6.
4057
4086
  // See docs/0.13-rewind-design.md.
4058
- const executeRewind = createRewindExecutor({ db, pm, tg, bot, botName: BOT_NAME, logEvent, logger: console });
4087
+ const executeRewind = createRewindExecutor({
4088
+ db, pm, tg, bot, botName: BOT_NAME, logEvent, logger: console,
4089
+ retireQuestionSession: (sessionKey) => questionHandlers?.retireSession(sessionKey),
4090
+ });
4059
4091
  rewindHandler = createRewindHandler({
4060
4092
  pm, tg, bot, botName: BOT_NAME, logEvent, logger: console, executeRewind,
4061
4093
  });
@@ -4403,6 +4435,7 @@ async function main() {
4403
4435
  handleAbortIfRequested = createHandleAbort({
4404
4436
  pm, bot, tg, logEvent, isAbortRequest,
4405
4437
  markSessionAborted, clearAutosteeredReactions, getSessionKey,
4438
+ retireQuestionSession: (sessionKey) => questionHandlers?.retireSession(sessionKey),
4406
4439
  botName: BOT_NAME, logger: console,
4407
4440
  });
4408
4441
  autosteer = createAutosteerHandlers({
@@ -4423,6 +4456,8 @@ async function main() {
4423
4456
  errorReplyText,
4424
4457
  queueWarnThreshold,
4425
4458
  inFlightHandlers,
4459
+ getActiveHandlerCount,
4460
+ getActiveHandlerTargets,
4426
4461
  awaitSettlement: awaitHandlerSettlement,
4427
4462
  trackTask: trackHandlerTask,
4428
4463
  } = createDispatcher({
@@ -4508,6 +4543,7 @@ async function main() {
4508
4543
  modelVersionsDesc: MODEL_VERSIONS_DESC, saveConfig,
4509
4544
  resolveRuntimeView: resolveSessionRuntimeView,
4510
4545
  botName: BOT_NAME, logEvent, logger: console,
4546
+ retireQuestionSession: (sessionKey) => questionHandlers?.retireSession(sessionKey),
4511
4547
  });
4512
4548
  console.log('[polygram] using SDK ProcessManager');
4513
4549
 
@@ -4569,19 +4605,19 @@ async function main() {
4569
4605
  // provider; ordinary stops keep the existing answer-and-await behavior.
4570
4606
  try {
4571
4607
  const openQuestions = questionStore.listOpen?.(BOT_NAME) || [];
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(() => {});
4608
+ const shutdownNotice = 'Bot is restarting — this question was cancelled. It may be re-asked in a moment.';
4609
+ // Each row is disposed on its own: one that throws must not take the
4610
+ // rows after it, nor the drop of everything held in memory, with it.
4611
+ for (const row of openQuestions) {
4612
+ try {
4613
+ if (continuationAuthorized) {
4614
+ questionHandlers.beginShutdownDisposition(row, { message: shutdownNotice });
4615
+ } else {
4616
+ // eslint-disable-next-line no-await-in-loop
4617
+ await questionHandlers.expireQuestion(row, { status: 'cancelled', message: shutdownNotice });
4618
+ }
4619
+ } catch (err) {
4620
+ console.error(`[shutdown] question ${row.id} disposition failed: ${err.message}`);
4585
4621
  }
4586
4622
  }
4587
4623
  if (openQuestions.length) {
@@ -4589,6 +4625,11 @@ async function main() {
4589
4625
  }
4590
4626
  } catch (err) {
4591
4627
  console.error(`[shutdown] question expiry failed: ${err.message}`);
4628
+ } finally {
4629
+ // Nothing exact survives the process on purpose, whatever any single row
4630
+ // did on the way out.
4631
+ try { questionHandlers.discardAll(); }
4632
+ catch (err) { console.error(`[shutdown] question context drop failed: ${err.message}`); }
4592
4633
  }
4593
4634
 
4594
4635
  // 1.6 Album siblings still buffered (arrived <flushMs before shutdown,
@@ -4681,6 +4722,7 @@ async function main() {
4681
4722
  awaitIngressSettlement,
4682
4723
  awaitHandlerSettlement,
4683
4724
  settlementTimeoutMs: SHUTDOWN_DRAIN_MS,
4725
+ continuationAuthorized,
4684
4726
  qualificationExpectation,
4685
4727
  });
4686
4728
  if (qualificationExpectation !== undefined) {
@@ -4793,7 +4835,7 @@ async function main() {
4793
4835
  logEvent('shutdown-persistence-fallback', {
4794
4836
  bot: BOT_NAME,
4795
4837
  replay_marked: fallback.replayMarked,
4796
- error: err.message?.slice(0, 200),
4838
+ error_class: err.name || 'Error',
4797
4839
  });
4798
4840
  } catch (fallbackErr) {
4799
4841
  console.error(
@@ -4849,6 +4891,27 @@ async function main() {
4849
4891
  // scripts, hook); also exported to spawned Claude processes via env.
4850
4892
  const ipcSecret = ipcServer.writeSecret(BOT_NAME);
4851
4893
  process.env.POLYGRAM_IPC_SECRET = ipcSecret;
4894
+ const foregroundCanaryAuthorizer = createForegroundCanaryAuthorizer({
4895
+ botName: BOT_NAME,
4896
+ daemonIdentity,
4897
+ config,
4898
+ tokenSecret: ipcSecret,
4899
+ getActiveHandlerCount: () => getActiveHandlerCount(),
4900
+ getActiveHandlerTargets: () => getActiveHandlerTargets(),
4901
+ getForegroundCanaryTarget: (input) => (
4902
+ db.getForegroundCanaryTarget(input)
4903
+ ),
4904
+ });
4905
+ const requestDeployRestart = createDeployRestartHandler({
4906
+ getIsShuttingDown: () => isShuttingDown,
4907
+ getPid: () => process.pid,
4908
+ foregroundCanaryAuthorizer,
4909
+ persistForegroundCanaryAuthorization: (detail) => (
4910
+ db.logEvent('foreground-canary-target-authorized', detail)
4911
+ ),
4912
+ shutdown,
4913
+ logger: console,
4914
+ });
4852
4915
  ipcCloser = await ipcServer.start({
4853
4916
  path: ipcServer.socketPathFor(BOT_NAME),
4854
4917
  secret: ipcSecret,
@@ -4857,28 +4920,13 @@ async function main() {
4857
4920
  daemonIdentity,
4858
4921
  getInFlightHandlers: () => inFlightHandlers,
4859
4922
  handleSendOverIpc: (req) => handleSendOverIpc(req),
4923
+ requestForegroundCanaryTarget: (req) => (
4924
+ foregroundCanaryAuthorizer.probe(req)
4925
+ ),
4860
4926
  inspectCleanRestartQualification: () => (
4861
4927
  pm.inspectCleanRestartQualification(undefined)
4862
4928
  ),
4863
- requestDeployRestart: (req) => {
4864
- if (isShuttingDown) {
4865
- const error = new Error('shutdown already in progress');
4866
- error.code = 'SHUTDOWN_IN_PROGRESS';
4867
- throw error;
4868
- }
4869
- const restartRequestId = requireRestartRequestId(req.id);
4870
- const qualificationExpectation = normalizeDeployQualificationExpectation(req);
4871
- const oldPid = process.pid;
4872
- shutdown({
4873
- continuationAuthorized: true,
4874
- trigger: 'deploy-ipc',
4875
- restartRequestId,
4876
- qualificationExpectation,
4877
- }).catch((error) => {
4878
- console.error(`[shutdown] deploy restart failed: ${error.message}`);
4879
- });
4880
- return { accepted: true, old_pid: oldPid };
4881
- },
4929
+ requestDeployRestart,
4882
4930
  }),
4883
4931
  logger: console,
4884
4932
  });
@@ -5274,18 +5322,17 @@ async function main() {
5274
5322
  console.error(`[replay] boot replay failed: ${err.message}`);
5275
5323
  }
5276
5324
 
5277
- // rc.61 + rc.65: handle compact-command events that were never
5278
- // paired with a compact-boundary (deploy / crash interrupted them
5279
- // before the SDK processed). rc.65 changes behaviour from
5280
- // "post a 'please retry' message and ask the user" to
5281
- // "silently retry by re-pushing the same /compact text to a
5282
- // freshly-spawned (resumed) Query." Same pattern as boot-replay
5283
- // for inbound messages — recovery should be invisible.
5325
+ // Handle compact-command events that were never paired with a
5326
+ // compact-boundary (a deploy or crash interrupted them before the SDK
5327
+ // processed them). Recovery re-pushes the same /compact line into a
5328
+ // freshly-spawned (resumed) session, invisibly — the same pattern as
5329
+ // boot-replay for inbound messages.
5284
5330
  //
5285
5331
  // Requirements for a silent retry:
5286
- // 1. The compact-command event was logged with full `text`
5287
- // (rc.65+ does this; pre-rc.65 events have only text_len —
5288
- // we fall back to the old "please retry" message for those).
5332
+ // 1. The command line is recoverable — from the inbound message row the
5333
+ // event points at, or from an older event that still stored it — and
5334
+ // it normalizes to a real /compact command. Otherwise the user gets
5335
+ // the "please retry" message instead.
5289
5336
  // 2. The chat is still configured (config.chats[chat_id] exists).
5290
5337
  // 3. The session has a saved claude_session_id we can resume.
5291
5338
  //
@@ -5313,9 +5360,13 @@ async function main() {
5313
5360
  const threadId = o.thread_id ? Number(o.thread_id) : null;
5314
5361
  const savedSessionId = getClaudeSessionId(db, o.session_key);
5315
5362
 
5316
- // Silent retry path: only when we have BOTH the original text
5317
- // (rc.65+) AND a session_id to resume into.
5318
- if (o.text && savedSessionId) {
5363
+ // Silent retry path: only when the stored line is provably a compact
5364
+ // command AND there is a session_id to resume into. The line comes back
5365
+ // from storage, so it is normalized and validated before it is fired at
5366
+ // a live session — a mention-suffixed command is accepted in its
5367
+ // normalized form, anything else is refused and surfaced instead.
5368
+ const replayCommand = normalizeCompactCommand(o.text);
5369
+ if (replayCommand && savedSessionId) {
5319
5370
  try {
5320
5371
  const entry = await pm.getOrSpawn(
5321
5372
  o.session_key,
@@ -5327,7 +5378,7 @@ async function main() {
5327
5378
  if (!entry || typeof entry.fireUserMessage !== 'function') {
5328
5379
  throw new Error('Process.fireUserMessage not available');
5329
5380
  }
5330
- const ok = entry.fireUserMessage(o.text);
5381
+ const ok = entry.fireUserMessage(replayCommand);
5331
5382
  if (!ok) {
5332
5383
  throw new Error('fireUserMessage refused (closed or empty content)');
5333
5384
  }
@@ -5336,8 +5387,7 @@ async function main() {
5336
5387
  thread_id: o.thread_id,
5337
5388
  session_key: o.session_key,
5338
5389
  original_ts: o.ts,
5339
- text_len: o.text.length,
5340
- user: o.user,
5390
+ text_len: replayCommand.length,
5341
5391
  user_id: o.user_id,
5342
5392
  });
5343
5393
  replayed += 1;
@@ -5348,9 +5398,9 @@ async function main() {
5348
5398
  }
5349
5399
  }
5350
5400
 
5351
- // Fallback: surface the legacy "please retry" message. Only
5352
- // happens for pre-rc.65 events (no `text` field) or when
5353
- // the silent-retry spawn failed.
5401
+ // Fallback: surface the "please retry" message. Happens when the hint
5402
+ // could not be recovered, when the stored line is not a compact
5403
+ // command, or when the silent retry failed.
5354
5404
  try {
5355
5405
  await tg(bot, 'sendMessage', {
5356
5406
  chat_id: o.chat_id,
@@ -5362,9 +5412,8 @@ async function main() {
5362
5412
  thread_id: o.thread_id,
5363
5413
  session_key: o.session_key,
5364
5414
  original_ts: o.ts,
5365
- user: o.user,
5366
5415
  user_id: o.user_id,
5367
- reason: o.text ? 'spawn-failed' : 'pre-rc65-event-no-text',
5416
+ reason: replayCommand ? 'spawn-failed' : (o.text ? 'not-a-compact-command' : 'no-recoverable-text'),
5368
5417
  });
5369
5418
  surfacedFallback += 1;
5370
5419
  } catch (err) {