polygram 0.38.0 → 0.38.2

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
@@ -32,13 +32,21 @@ const { buildPrompt, resolvePromptBackend } = require('./lib/prompt');
32
32
  const { countInFlight } = require('./lib/queue-utils');
33
33
  const { createIpcHandlers } = require('./lib/ipc/handlers');
34
34
  const {
35
- requireRestartRequestId,
36
- } = require('./lib/ipc/restart-request-id');
35
+ buildQualificationEvent,
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');
37
43
  const { classifyOrphanSweep } = require('./lib/ops/tmux-preflight');
38
44
  const {
39
45
  parseSystemdInvocationId,
40
46
  lifecycleDetail,
41
47
  } = require('./lib/ops/systemd-invocation');
48
+ const { createDaemonIdentity } = require('./lib/ops/daemon-identity');
49
+ const packageMetadata = require('./package.json');
42
50
  const { filterAttachments, resolveFileCaps, resolveMaxFileOverride, MAX_TOTAL_BYTES } = require('./lib/attachments');
43
51
  // 0.9.0: SDK ProcessManager is the only pm. CLI pm
44
52
  // (lib/process-manager.js) deleted in commit 6.
@@ -194,6 +202,7 @@ const { createCgroupOomObserver } = require('./lib/ops/cgroup-oom-observer');
194
202
  const { persistShutdownDisposition } = require('./lib/ops/shutdown-disposition');
195
203
  const {
196
204
  prepareCleanRetirement,
205
+ settleCrashShutdown,
197
206
  buildResumeIntents,
198
207
  } = require('./lib/ops/clean-shutdown');
199
208
  const {
@@ -1068,6 +1077,8 @@ let attemptAutoResume = null;
1068
1077
  let errorReplyText = null;
1069
1078
  let queueWarnThreshold = null;
1070
1079
  let inFlightHandlers = null;
1080
+ let getActiveHandlerCount = null;
1081
+ let getActiveHandlerTargets = null;
1071
1082
  let awaitHandlerSettlement = null;
1072
1083
  let trackHandlerTask = null;
1073
1084
  let recoverCodexRequest = null;
@@ -3539,10 +3550,16 @@ function createBot(token) {
3539
3550
  // ─── Manual polling — extracted to lib/handlers/poll.js ────────────
3540
3551
  let pollBot = null;
3541
3552
  let startPollWatchdog = null;
3553
+ let stopPolling = null;
3554
+ let awaitPollSettlement = null;
3542
3555
 
3543
3556
  // ─── Main ───────────────────────────────────────────────────────────
3544
3557
 
3545
3558
  async function main() {
3559
+ const daemonIdentity = createDaemonIdentity({
3560
+ mainPath: __filename,
3561
+ packageVersion: packageMetadata.version,
3562
+ });
3546
3563
  const cgroupOomObserver = createCgroupOomObserver();
3547
3564
  const invocationId = parseSystemdInvocationId();
3548
3565
  let abnormalProviderTermination = null;
@@ -3787,7 +3804,7 @@ async function main() {
3787
3804
  db.logEvent('polygram-start', lifecycleDetail({
3788
3805
  migration: migration.reason,
3789
3806
  imported: migration.imported,
3790
- }, invocationId));
3807
+ }, invocationId, daemonIdentity));
3791
3808
  if (cgroupOomObserver.startup.status === 'unavailable') {
3792
3809
  console.warn(`[oom-observer] unavailable (${cgroupOomObserver.startup.reason}); handled shutdown signals will retain clean-restart behavior`);
3793
3810
  db.logEvent('oom-observer-unavailable', { reason: cgroupOomObserver.startup.reason });
@@ -4410,6 +4427,8 @@ async function main() {
4410
4427
  errorReplyText,
4411
4428
  queueWarnThreshold,
4412
4429
  inFlightHandlers,
4430
+ getActiveHandlerCount,
4431
+ getActiveHandlerTargets,
4413
4432
  awaitSettlement: awaitHandlerSettlement,
4414
4433
  trackTask: trackHandlerTask,
4415
4434
  } = createDispatcher({
@@ -4483,7 +4502,7 @@ async function main() {
4483
4502
  dropRedeliverer = createDropRedeliverer({
4484
4503
  db, redeliver: redeliverAsFreshTurn, logEvent, logger: console,
4485
4504
  });
4486
- ({ pollBot, startPollWatchdog } = createPollLoop({
4505
+ ({ pollBot, startPollWatchdog, stopPolling, awaitPollSettlement } = createPollLoop({
4487
4506
  db, dbWrite, config, botName: BOT_NAME,
4488
4507
  isWellFormedMessage, getTopicName,
4489
4508
  logger: console,
@@ -4533,6 +4552,7 @@ async function main() {
4533
4552
  continuationAuthorized = false,
4534
4553
  trigger = 'signal',
4535
4554
  restartRequestId = null,
4555
+ qualificationExpectation,
4536
4556
  } = {}) => {
4537
4557
  if (isShuttingDown) return;
4538
4558
  const shutdownObservation = cgroupOomObserver.sample();
@@ -4545,22 +4565,30 @@ async function main() {
4545
4565
  isShuttingDown = true;
4546
4566
  console.log(`\nShutting down (${trigger})...`);
4547
4567
  // 1. Stop accepting new inbound first so nothing new queues behind the drain.
4548
- if (bot && bot._stop) bot._stop();
4549
-
4550
- // 1.5 (0.13 D1): expire open interactive questions {cancelled} BEFORE the
4551
- // drain. With D1 the asking turn stays in flight for the whole wait, so a
4552
- // deploy during a question would otherwise eat the entire 30s drain and
4553
- // mark the inbound replay-pending mid-ask. Cancelling unblocks claude's
4554
- // ask so the cycle can end inside the drain; the boot-replay re-ask is the
4555
- // documented recovery path (design §3 D1 ask-wait semantics).
4568
+ stopPolling();
4569
+ const awaitIngressSettlement = ({ timeoutMs } = {}) => (
4570
+ awaitPollSettlement({ timeoutMs })
4571
+ );
4572
+
4573
+ // Resolve open questions before provider settlement. Authorized deploys
4574
+ // register the card edit and cancel the row without answering the retiring
4575
+ // provider; ordinary stops keep the existing answer-and-await behavior.
4556
4576
  try {
4557
4577
  const openQuestions = questionStore.listOpen?.(BOT_NAME) || [];
4558
- for (const row of openQuestions) {
4559
- // eslint-disable-next-line no-await-in-loop
4560
- await questionHandlers.expireQuestion(row, {
4561
- status: 'cancelled',
4562
- message: 'Bot is restarting — this question was cancelled. It may be re-asked in a moment.',
4563
- }).catch(() => {});
4578
+ if (continuationAuthorized) {
4579
+ for (const row of openQuestions) {
4580
+ questionHandlers.beginShutdownDisposition(row, {
4581
+ message: 'Bot is restarting — this question was cancelled. It may be re-asked in a moment.',
4582
+ });
4583
+ }
4584
+ } else {
4585
+ for (const row of openQuestions) {
4586
+ // eslint-disable-next-line no-await-in-loop
4587
+ await questionHandlers.expireQuestion(row, {
4588
+ status: 'cancelled',
4589
+ message: 'Bot is restarting — this question was cancelled. It may be re-asked in a moment.',
4590
+ }).catch(() => {});
4591
+ }
4564
4592
  }
4565
4593
  if (openQuestions.length) {
4566
4594
  logEvent('shutdown-questions-cancelled', { count: openQuestions.length });
@@ -4619,13 +4647,15 @@ async function main() {
4619
4647
  // 2. Drain in-flight handlers. Wait for inFlightHandlers to empty or
4620
4648
  // SHUTDOWN_DRAIN_MS to elapse. pm handlers resolve naturally when
4621
4649
  // result events arrive; the dispatcher's .finally decrements.
4622
- const drainStart = Date.now();
4623
- while (inFlightHandlers.size > 0) {
4624
- if (Date.now() - drainStart >= SHUTDOWN_DRAIN_MS) break;
4625
- await new Promise((r) => setTimeout(r, 100));
4650
+ let drainElapsed = 0;
4651
+ if (!continuationAuthorized) {
4652
+ const drainStart = Date.now();
4653
+ while (inFlightHandlers.size > 0) {
4654
+ if (Date.now() - drainStart >= SHUTDOWN_DRAIN_MS) break;
4655
+ await new Promise((r) => setTimeout(r, 100));
4656
+ }
4657
+ drainElapsed = Date.now() - drainStart;
4626
4658
  }
4627
- const drainElapsed = Date.now() - drainStart;
4628
- const remaining = countInFlight(inFlightHandlers);
4629
4659
 
4630
4660
  // 3. A clean stop closes both output boundaries before persisting any
4631
4661
  // recovery intent. The ProcessManager receipt is the only authority for
@@ -4636,14 +4666,38 @@ async function main() {
4636
4666
  ? 'provider-process-terminated'
4637
4667
  : null;
4638
4668
  let resumeIntents = [];
4639
- if (shutdownObservation?.detected !== true) {
4669
+ if (shutdownObservation?.detected === true) {
4670
+ try {
4671
+ await settleCrashShutdown({
4672
+ pm,
4673
+ deliveryBarrier,
4674
+ awaitIngressSettlement,
4675
+ awaitHandlerSettlement,
4676
+ settlementTimeoutMs: SHUTDOWN_DRAIN_MS,
4677
+ });
4678
+ } catch (err) {
4679
+ console.error(`[shutdown] crash settlement failed: ${err.message}`);
4680
+ }
4681
+ pmRetired = true;
4682
+ } else {
4640
4683
  try {
4641
4684
  const retirement = await prepareCleanRetirement({
4642
4685
  pm,
4643
4686
  deliveryBarrier,
4687
+ awaitIngressSettlement,
4644
4688
  awaitHandlerSettlement,
4645
4689
  settlementTimeoutMs: SHUTDOWN_DRAIN_MS,
4690
+ qualificationExpectation,
4646
4691
  });
4692
+ if (qualificationExpectation !== undefined) {
4693
+ logEvent('clean-restart-qualification-observed', buildQualificationEvent({
4694
+ botName: BOT_NAME,
4695
+ daemonIdentity,
4696
+ restartRequestId,
4697
+ expectation: qualificationExpectation,
4698
+ observation: retirement.qualification,
4699
+ }));
4700
+ }
4647
4701
  pmRetired = true;
4648
4702
  const projected = continuationAuthorized ? buildResumeIntents({
4649
4703
  snapshots: retirement.snapshots,
@@ -4686,12 +4740,16 @@ async function main() {
4686
4740
  });
4687
4741
  }
4688
4742
  } catch (err) {
4689
- forceCrashReason = 'clean-retirement-failed';
4743
+ forceCrashReason = err?.code === 'POLL_OFFSET_PERSISTENCE_FAILED'
4744
+ ? 'poll-offset-persistence-failed'
4745
+ : err?.code === 'POLL_SETTLEMENT_TIMEOUT'
4746
+ ? 'poll-settlement-timeout'
4747
+ : 'clean-retirement-failed';
4690
4748
  console.error(`[shutdown] clean retirement failed: ${err.message}`);
4691
- if (pm) await pm.shutdown().catch(() => {});
4692
4749
  pmRetired = true;
4693
4750
  }
4694
4751
  }
4752
+ const remaining = countInFlight(inFlightHandlers);
4695
4753
 
4696
4754
  // 4. Persist only after the old delivery/process boundary is closed and
4697
4755
  // every dispatcher-owned continuation has settled. Any uncertainty is
@@ -4732,7 +4790,7 @@ async function main() {
4732
4790
  ? restartRequestId
4733
4791
  : null,
4734
4792
  ...(res.oomKillDelta != null ? { oom_kill_delta: res.oomKillDelta } : {}),
4735
- }, invocationId));
4793
+ }, invocationId, daemonIdentity));
4736
4794
  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
4795
  } catch (err) {
4738
4796
  console.error(`[shutdown] persistence failed: ${err.message}`);
@@ -4766,7 +4824,7 @@ async function main() {
4766
4824
  if (pm && !pmRetired) await pm.shutdown().catch(() => {});
4767
4825
  if (db) {
4768
4826
  try {
4769
- db.logEvent('polygram-stop', lifecycleDetail({}, invocationId));
4827
+ db.logEvent('polygram-stop', lifecycleDetail({}, invocationId, daemonIdentity));
4770
4828
  db.raw.close();
4771
4829
  } catch {}
4772
4830
  }
@@ -4797,30 +4855,42 @@ async function main() {
4797
4855
  // scripts, hook); also exported to spawned Claude processes via env.
4798
4856
  const ipcSecret = ipcServer.writeSecret(BOT_NAME);
4799
4857
  process.env.POLYGRAM_IPC_SECRET = ipcSecret;
4858
+ const foregroundCanaryAuthorizer = createForegroundCanaryAuthorizer({
4859
+ botName: BOT_NAME,
4860
+ daemonIdentity,
4861
+ config,
4862
+ tokenSecret: ipcSecret,
4863
+ getActiveHandlerCount: () => getActiveHandlerCount(),
4864
+ getActiveHandlerTargets: () => getActiveHandlerTargets(),
4865
+ getForegroundCanaryTarget: (input) => (
4866
+ db.getForegroundCanaryTarget(input)
4867
+ ),
4868
+ });
4869
+ const requestDeployRestart = createDeployRestartHandler({
4870
+ getIsShuttingDown: () => isShuttingDown,
4871
+ getPid: () => process.pid,
4872
+ foregroundCanaryAuthorizer,
4873
+ persistForegroundCanaryAuthorization: (detail) => (
4874
+ db.logEvent('foreground-canary-target-authorized', detail)
4875
+ ),
4876
+ shutdown,
4877
+ logger: console,
4878
+ });
4800
4879
  ipcCloser = await ipcServer.start({
4801
4880
  path: ipcServer.socketPathFor(BOT_NAME),
4802
4881
  secret: ipcSecret,
4803
4882
  handlers: createIpcHandlers({
4804
4883
  botName: BOT_NAME,
4884
+ daemonIdentity,
4805
4885
  getInFlightHandlers: () => inFlightHandlers,
4806
4886
  handleSendOverIpc: (req) => handleSendOverIpc(req),
4807
- requestDeployRestart: (req) => {
4808
- if (isShuttingDown) {
4809
- const error = new Error('shutdown already in progress');
4810
- error.code = 'SHUTDOWN_IN_PROGRESS';
4811
- throw error;
4812
- }
4813
- const restartRequestId = requireRestartRequestId(req.id);
4814
- const oldPid = process.pid;
4815
- shutdown({
4816
- continuationAuthorized: true,
4817
- trigger: 'deploy-ipc',
4818
- restartRequestId,
4819
- }).catch((error) => {
4820
- console.error(`[shutdown] deploy restart failed: ${error.message}`);
4821
- });
4822
- return { accepted: true, old_pid: oldPid };
4823
- },
4887
+ requestForegroundCanaryTarget: (req) => (
4888
+ foregroundCanaryAuthorizer.probe(req)
4889
+ ),
4890
+ inspectCleanRestartQualification: () => (
4891
+ pm.inspectCleanRestartQualification(undefined)
4892
+ ),
4893
+ requestDeployRestart,
4824
4894
  }),
4825
4895
  logger: console,
4826
4896
  });
@@ -5337,7 +5407,7 @@ async function main() {
5337
5407
 
5338
5408
  logEvent('polygram-admission-open', lifecycleDetail({
5339
5409
  bot: BOT_NAME,
5340
- }, invocationId));
5410
+ }, invocationId, daemonIdentity));
5341
5411
  console.log(`[${BOT_NAME}] Starting...`);
5342
5412
  const pollPromise = pollBot(bot).catch(err => {
5343
5413
  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);