instar 1.3.500 → 1.3.502

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.
Files changed (140) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +943 -14
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +51 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/BackupManager.d.ts.map +1 -1
  8. package/dist/core/BackupManager.js +7 -0
  9. package/dist/core/BackupManager.js.map +1 -1
  10. package/dist/core/DeliverMessageHandler.d.ts +13 -1
  11. package/dist/core/DeliverMessageHandler.d.ts.map +1 -1
  12. package/dist/core/DeliverMessageHandler.js +7 -0
  13. package/dist/core/DeliverMessageHandler.js.map +1 -1
  14. package/dist/core/MachinePoolRegistry.d.ts +3 -0
  15. package/dist/core/MachinePoolRegistry.d.ts.map +1 -1
  16. package/dist/core/MachinePoolRegistry.js +1 -0
  17. package/dist/core/MachinePoolRegistry.js.map +1 -1
  18. package/dist/core/OwnerSuspectBreaker.d.ts +37 -0
  19. package/dist/core/OwnerSuspectBreaker.d.ts.map +1 -1
  20. package/dist/core/OwnerSuspectBreaker.js +58 -0
  21. package/dist/core/OwnerSuspectBreaker.js.map +1 -1
  22. package/dist/core/PendingInboundStore.d.ts +310 -0
  23. package/dist/core/PendingInboundStore.d.ts.map +1 -0
  24. package/dist/core/PendingInboundStore.js +715 -0
  25. package/dist/core/PendingInboundStore.js.map +1 -0
  26. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  27. package/dist/core/PostUpdateMigrator.js +44 -0
  28. package/dist/core/PostUpdateMigrator.js.map +1 -1
  29. package/dist/core/QueueDrainLoop.d.ts +247 -0
  30. package/dist/core/QueueDrainLoop.d.ts.map +1 -0
  31. package/dist/core/QueueDrainLoop.js +0 -0
  32. package/dist/core/QueueDrainLoop.js.map +1 -0
  33. package/dist/core/ReapGuard.d.ts +24 -0
  34. package/dist/core/ReapGuard.d.ts.map +1 -1
  35. package/dist/core/ReapGuard.js +62 -0
  36. package/dist/core/ReapGuard.js.map +1 -1
  37. package/dist/core/SessionManager.d.ts +32 -0
  38. package/dist/core/SessionManager.d.ts.map +1 -1
  39. package/dist/core/SessionManager.js +74 -2
  40. package/dist/core/SessionManager.js.map +1 -1
  41. package/dist/core/SessionRouter.d.ts +36 -2
  42. package/dist/core/SessionRouter.d.ts.map +1 -1
  43. package/dist/core/SessionRouter.js +34 -9
  44. package/dist/core/SessionRouter.js.map +1 -1
  45. package/dist/core/WorkEvidence.d.ts +45 -0
  46. package/dist/core/WorkEvidence.d.ts.map +1 -0
  47. package/dist/core/WorkEvidence.js +88 -0
  48. package/dist/core/WorkEvidence.js.map +1 -0
  49. package/dist/core/componentCategories.d.ts.map +1 -1
  50. package/dist/core/componentCategories.js +3 -0
  51. package/dist/core/componentCategories.js.map +1 -1
  52. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  53. package/dist/core/devGatedFeatures.js +15 -0
  54. package/dist/core/devGatedFeatures.js.map +1 -1
  55. package/dist/core/inboundQueueBootSweep.d.ts +61 -0
  56. package/dist/core/inboundQueueBootSweep.d.ts.map +1 -0
  57. package/dist/core/inboundQueueBootSweep.js +207 -0
  58. package/dist/core/inboundQueueBootSweep.js.map +1 -0
  59. package/dist/core/inboundQueueConfig.d.ts +78 -0
  60. package/dist/core/inboundQueueConfig.d.ts.map +1 -0
  61. package/dist/core/inboundQueueConfig.js +135 -0
  62. package/dist/core/inboundQueueConfig.js.map +1 -0
  63. package/dist/core/types.d.ts +73 -0
  64. package/dist/core/types.d.ts.map +1 -1
  65. package/dist/core/types.js.map +1 -1
  66. package/dist/messaging/NotificationBatcher.d.ts +12 -0
  67. package/dist/messaging/NotificationBatcher.d.ts.map +1 -1
  68. package/dist/messaging/NotificationBatcher.js +36 -0
  69. package/dist/messaging/NotificationBatcher.js.map +1 -1
  70. package/dist/messaging/TelegramAdapter.d.ts +4 -0
  71. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  72. package/dist/messaging/TelegramAdapter.js +12 -0
  73. package/dist/messaging/TelegramAdapter.js.map +1 -1
  74. package/dist/messaging/pending-relay-store.d.ts +67 -8
  75. package/dist/messaging/pending-relay-store.d.ts.map +1 -1
  76. package/dist/messaging/pending-relay-store.js +151 -14
  77. package/dist/messaging/pending-relay-store.js.map +1 -1
  78. package/dist/messaging/reap-notice-delivery-id.d.ts +37 -0
  79. package/dist/messaging/reap-notice-delivery-id.d.ts.map +1 -0
  80. package/dist/messaging/reap-notice-delivery-id.js +52 -0
  81. package/dist/messaging/reap-notice-delivery-id.js.map +1 -0
  82. package/dist/monitoring/QuotaManager.d.ts +6 -0
  83. package/dist/monitoring/QuotaManager.d.ts.map +1 -1
  84. package/dist/monitoring/QuotaManager.js +11 -0
  85. package/dist/monitoring/QuotaManager.js.map +1 -1
  86. package/dist/monitoring/ReapLog.d.ts +39 -4
  87. package/dist/monitoring/ReapLog.d.ts.map +1 -1
  88. package/dist/monitoring/ReapLog.js +54 -4
  89. package/dist/monitoring/ReapLog.js.map +1 -1
  90. package/dist/monitoring/ReapNoticeDrain.d.ts +113 -0
  91. package/dist/monitoring/ReapNoticeDrain.d.ts.map +1 -0
  92. package/dist/monitoring/ReapNoticeDrain.js +231 -0
  93. package/dist/monitoring/ReapNoticeDrain.js.map +1 -0
  94. package/dist/monitoring/ReapNotifier.d.ts +89 -18
  95. package/dist/monitoring/ReapNotifier.d.ts.map +1 -1
  96. package/dist/monitoring/ReapNotifier.js +329 -22
  97. package/dist/monitoring/ReapNotifier.js.map +1 -1
  98. package/dist/monitoring/ResumeQueue.d.ts +223 -0
  99. package/dist/monitoring/ResumeQueue.d.ts.map +1 -0
  100. package/dist/monitoring/ResumeQueue.js +600 -0
  101. package/dist/monitoring/ResumeQueue.js.map +1 -0
  102. package/dist/monitoring/ResumeQueueDrainer.d.ts +151 -0
  103. package/dist/monitoring/ResumeQueueDrainer.d.ts.map +1 -0
  104. package/dist/monitoring/ResumeQueueDrainer.js +420 -0
  105. package/dist/monitoring/ResumeQueueDrainer.js.map +1 -0
  106. package/dist/monitoring/SessionMigrator.d.ts +10 -0
  107. package/dist/monitoring/SessionMigrator.d.ts.map +1 -1
  108. package/dist/monitoring/SessionMigrator.js +43 -1
  109. package/dist/monitoring/SessionMigrator.js.map +1 -1
  110. package/dist/monitoring/SessionReaper.d.ts +1 -0
  111. package/dist/monitoring/SessionReaper.d.ts.map +1 -1
  112. package/dist/monitoring/SessionReaper.js +5 -0
  113. package/dist/monitoring/SessionReaper.js.map +1 -1
  114. package/dist/monitoring/delivery-failure-sentinel.d.ts.map +1 -1
  115. package/dist/monitoring/delivery-failure-sentinel.js +7 -3
  116. package/dist/monitoring/delivery-failure-sentinel.js.map +1 -1
  117. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  118. package/dist/monitoring/guardManifest.js +27 -0
  119. package/dist/monitoring/guardManifest.js.map +1 -1
  120. package/dist/scaffold/templates.d.ts.map +1 -1
  121. package/dist/scaffold/templates.js +10 -3
  122. package/dist/scaffold/templates.js.map +1 -1
  123. package/dist/server/AgentServer.d.ts +7 -0
  124. package/dist/server/AgentServer.d.ts.map +1 -1
  125. package/dist/server/AgentServer.js +4 -0
  126. package/dist/server/AgentServer.js.map +1 -1
  127. package/dist/server/routes.d.ts +12 -0
  128. package/dist/server/routes.d.ts.map +1 -1
  129. package/dist/server/routes.js +119 -0
  130. package/dist/server/routes.js.map +1 -1
  131. package/package.json +1 -1
  132. package/scripts/lint-guard-manifest.js +2 -0
  133. package/src/data/builtin-manifest.json +66 -66
  134. package/src/data/state-coherence-registry.json +14 -1
  135. package/src/scaffold/templates.ts +10 -3
  136. package/upgrades/1.3.501.md +51 -0
  137. package/upgrades/1.3.502.md +75 -0
  138. package/upgrades/side-effects/1.3.492.md +20 -0
  139. package/upgrades/side-effects/durable-inbound-message-queue.md +213 -0
  140. package/upgrades/side-effects/reap-notify-resume-queue.md +89 -0
@@ -100,7 +100,7 @@ import { MessageProcessingLedger } from '../messaging/MessageProcessingLedger.js
100
100
  import { recoverStuckMessages } from '../messaging/stuckMessageRecovery.js';
101
101
  import { ReplyMarkerTransport } from '../core/ReplyMarkerTransport.js';
102
102
  import { decryptFromSync, encryptForSync } from '../core/SecretStore.js';
103
- import { createPrivateKey, createPublicKey } from 'node:crypto';
103
+ import { createPrivateKey, createPublicKey, createHash } from 'node:crypto';
104
104
  import { sign as signEd25519, verify as verifyEd25519 } from '../core/MachineIdentity.js';
105
105
  import { ProjectMapper } from '../core/ProjectMapper.js';
106
106
  import { CartographerTree } from '../core/CartographerTree.js';
@@ -366,6 +366,20 @@ let _topicResumeMap = null;
366
366
  // is byte-identical to today's always-local dispatch. Set once in startServer().
367
367
  let _sessionRouter = null;
368
368
  let _sessionPoolStage = () => 'dark';
369
+ // ── Durable Inbound Message Queue (docs/specs/durable-inbound-message-queue.md) ──
370
+ // The custody engine (null = feature dark / gate failed / invariants violated —
371
+ // every consumer treats null as "refused → today's fall-through").
372
+ let _inboundQueue = null;
373
+ // The store the unconditional boot sweep opened when the queue will run this
374
+ // boot — adopted by the engine construction (one open handle, single-writer).
375
+ let _sweptInboundStore = null;
376
+ // The drain's local-delivery tail (§3.1 via:'drain') — assigned inside
377
+ // wireTelegramRouting where the session primitives live in scope.
378
+ let _drainLocalDeliver = null;
379
+ // Emergency-stop integration (§3.6): marks a topic stopped for the drain's
380
+ // pass/batch/chokepoint consults AND settles its custody (terminal
381
+ // operator-stop + PIS cleanup + loss report). Set with the engine.
382
+ let _inboundQueueStop = null;
369
383
  /** This machine's mesh id — lets the inbound dispatch tell a REMOTE placement
370
384
  * (forward/spawn on another machine → must NOT also dispatch locally) from a
371
385
  * self placement. Set once in startServer()'s mesh block. */
@@ -516,7 +530,11 @@ async function spawnSessionForTopic(sessionManager, telegram, sessionName, topic
516
530
  /** Subscription & Auth Standard P1.3 (additive): launch under this account's
517
531
  * config home + record its id (the quota-aware account-swap mechanism).
518
532
  * Omitted = unchanged behaviour. */
519
- accountSwap) {
533
+ accountSwap,
534
+ /** Reap-notify spec R2.8 / L13 — explicit per-spawn working directory (the
535
+ * resume-queue drainer passes a queue entry's recorded cwd so interrupted
536
+ * worktree work resumes in ITS tree). Omitted = module project dir. */
537
+ spawnOpts) {
520
538
  const msg = latestMessage || 'Session started — send a message to continue.';
521
539
  // If memory is elevated/critical and we have the reaper, try to free memory
522
540
  // by cleaning orphans before spawning. Interactive sessions are NEVER blocked
@@ -787,6 +805,8 @@ accountSwap) {
787
805
  // this account's config home + record its id. Unset = unchanged.
788
806
  ...(accountSwap?.configHome ? { configHome: accountSwap.configHome } : {}),
789
807
  ...(accountSwap?.accountId ? { subscriptionAccountId: accountSwap.accountId } : {}),
808
+ // R2.8/L13: per-spawn cwd from the resume-queue entry. Unset = projectDir.
809
+ ...(spawnOpts?.cwd ? { cwd: spawnOpts.cwd } : {}),
790
810
  });
791
811
  // Clear the resume entry after successful spawn to prevent stale reuse
792
812
  if (resumeSessionId) {
@@ -1740,17 +1760,42 @@ getAttentionTopicId) {
1740
1760
  }
1741
1761
  }
1742
1762
  if (_sessionRouter && _sessionPoolStage() !== 'dark') {
1763
+ // Ordering gate (Durable Inbound Message Queue §2.3): a live message for
1764
+ // a session with queued custody enqueues BEHIND the existing entries —
1765
+ // injecting it now would deliver out of order. Gated on a live engine.
1766
+ if (_inboundQueue) {
1767
+ try {
1768
+ if (_inboundQueue.hasQueued(String(topicId))) {
1769
+ const ord = _inboundQueue.enqueueLive({
1770
+ sessionKey: String(topicId),
1771
+ messageId: String(msg.id),
1772
+ payload: text,
1773
+ senderEnvelope: { userId: telegramUserId || undefined, firstName: pipeline.sender.firstName },
1774
+ topicMetadata: _topicPinStore?.asTopicMetadata(String(topicId)),
1775
+ }, 'ordering-behind-queued');
1776
+ if (ord.result === 'queued' || ord.result === 'already-queued') {
1777
+ console.log(`[inbound-queue] topic ${topicId} msg ${msg.id} queued behind existing entries (ordering)`);
1778
+ return;
1779
+ }
1780
+ // refused → fall through to route() — delivery beats both loss and
1781
+ // silence; the ordering violation is counted by the engine.
1782
+ }
1783
+ }
1784
+ catch { /* gate is best-effort; route() owns the message */ }
1785
+ }
1743
1786
  try {
1744
1787
  const outcome = await _sessionRouter.route({
1745
1788
  sessionKey: String(topicId),
1746
1789
  messageId: String(msg.id),
1747
1790
  payload: text,
1748
1791
  topicMetadata: _topicPinStore?.asTopicMetadata(String(topicId)),
1792
+ // §2.2: sender identity captured at ingress, persisted with custody.
1793
+ senderEnvelope: { userId: telegramUserId || undefined, firstName: pipeline.sender.firstName },
1749
1794
  });
1750
1795
  // Routing-decision observability — the live transfer path is otherwise a black
1751
1796
  // box (the recognizer logs its pin, but route()'s actual placement/forward
1752
1797
  // decision was invisible; that hid the bug below from the first live test).
1753
- console.log(`[session-pool] route topic ${topicId} → action=${outcome.action} owner=${outcome.owner ?? '?'} self=${_meshSelfId ?? '?'}`);
1798
+ console.log(`[session-pool] route topic ${topicId} → action=${outcome.action} owner=${outcome.owner ?? '?'} self=${_meshSelfId ?? '?'} acked=${outcome.acked}`);
1754
1799
  // Short-circuit local dispatch whenever the session ended up on ANOTHER machine
1755
1800
  // (forward/duplicate, OR a fresh remote 'spawned'/'owner-dead-replaced'). Before
1756
1801
  // this, only 'forwarded'/'duplicate' were caught, so a just-moved topic was
@@ -1760,10 +1805,30 @@ getAttentionTopicId) {
1760
1805
  console.log(`[session-pool] topic ${topicId} handled by owner ${outcome.owner ?? '?'} (${outcome.action}) — not dispatching locally`);
1761
1806
  return;
1762
1807
  }
1763
- // 'handled-locally' / 'spawned'(self) / 'owner-dead-replaced'(self) / 'queued' /
1764
- // 'placement-blocked' fall through to the existing local dispatch below.
1808
+ // Custody-ack short-circuit (§2.2): a queued/placement-blocked verdict
1809
+ // whose enqueue COMMITTED (acked) is the queue's message now — no local
1810
+ // fall-through. Un-custodied (refused/off/dry-run) keeps today's
1811
+ // fall-through. Wiring pins assert both directions.
1812
+ if ((outcome.action === 'queued' || outcome.action === 'placement-blocked') && outcome.acked) {
1813
+ console.log(`[inbound-queue] topic ${topicId} msg ${msg.id} in durable custody (${outcome.detail ?? outcome.action}) — drain will deliver`);
1814
+ return;
1815
+ }
1816
+ // 'handled-locally' / 'spawned'(self) / 'owner-dead-replaced'(self) /
1817
+ // un-acked 'queued'/'placement-blocked' → fall through to local dispatch.
1765
1818
  }
1766
1819
  catch (err) {
1820
+ // Route-throw fail-open is CUSTODY-AWARE (§2.2): a per-MESSAGE point
1821
+ // read against the store — a committed non-terminal row for THIS
1822
+ // message means the queue owns it (skip local dispatch); no row (or
1823
+ // engine dark) → today's fall-through. A point-read ERROR fails OPEN
1824
+ // to fall-through — the bounded duplicate window, §5-enumerated.
1825
+ try {
1826
+ if (_inboundQueue?.hasCommittedRow(String(topicId), String(msg.id))) {
1827
+ console.warn(`[session-pool] route error for topic ${topicId} but custody is committed — not dispatching locally: ${err instanceof Error ? err.message : String(err)}`);
1828
+ return;
1829
+ }
1830
+ }
1831
+ catch { /* point-read error → fail open (fall through) */ }
1767
1832
  console.warn(`[session-pool] route error for topic ${topicId} — falling back to local dispatch: ${err instanceof Error ? err.message : String(err)}`);
1768
1833
  }
1769
1834
  }
@@ -1887,6 +1952,99 @@ getAttentionTopicId) {
1887
1952
  });
1888
1953
  }
1889
1954
  };
1955
+ // ── Durable Inbound Message Queue: the drain's local-delivery tail ──
1956
+ // (§3.1 via:'drain'.) Built from the SAME primitives as the live tail above
1957
+ // (injectTelegramMessage / respawnSessionForTopic / spawnSessionForTopic +
1958
+ // the spawningTopics guard) with the drain contract's divergences, each
1959
+ // enumerated in the spec: bypasses the intercept stack (a stored message is
1960
+ // DATA — this function never re-interprets commands or re-binds operators),
1961
+ // bypasses the ingress ledger (the receipt is the at-most-once authority),
1962
+ // suppresses the per-message "✓ Delivered" confirmation, AWAITS the tail
1963
+ // through the receipt write, injects with the STORED sender envelope, and
1964
+ // paces same-session runs at 1s.
1965
+ _drainLocalDeliver = async (dmsg, handover) => {
1966
+ const topicId = Number(dmsg.sessionKey);
1967
+ if (!Number.isFinite(topicId))
1968
+ return { kind: 'failed', error: new Error(`non-numeric sessionKey ${dmsg.sessionKey}`) };
1969
+ if (!_sessionRouter)
1970
+ return { kind: 'un-routable', reason: 'router-not-constructed' };
1971
+ // Routing first — ownership may have moved while the entry waited.
1972
+ const outcome = await _sessionRouter.route({
1973
+ sessionKey: dmsg.sessionKey,
1974
+ messageId: dmsg.messageId,
1975
+ payload: dmsg.payload,
1976
+ topicMetadata: dmsg.topicMetadata
1977
+ ?? _topicPinStore?.asTopicMetadata(dmsg.sessionKey),
1978
+ senderEnvelope: dmsg.senderEnvelope,
1979
+ });
1980
+ if (outcome.action === 'forwarded' && outcome.detail === 'sender-rejected') {
1981
+ return { kind: 'sender-rejected' };
1982
+ }
1983
+ if (isRemotelyHandled(outcome, _meshSelfId)) {
1984
+ return { kind: 'remote-delivered' };
1985
+ }
1986
+ if (outcome.action === 'queued' || outcome.action === 'placement-blocked') {
1987
+ // The router re-queued our own entry ('already-queued' against the
1988
+ // claimed row) — un-routable: release + backoff + attempts++ (§3.1).
1989
+ return { kind: 'un-routable', reason: outcome.detail ?? outcome.action };
1990
+ }
1991
+ // Local delivery ('handled-locally' / self 'owner-dead-replaced').
1992
+ const sender = dmsg.senderEnvelope ?? undefined;
1993
+ const senderFirstName = sender?.firstName ?? 'User';
1994
+ const senderUserId = typeof sender?.userId === 'number' ? sender.userId : Number(sender?.userId ?? 0) || 0;
1995
+ const resolvedUser = senderUserId && userManager ? userManager.resolveFromTelegramUserId(senderUserId) : null;
1996
+ const targetSession = telegram.getSessionForTopic(topicId);
1997
+ const topicName = telegram.getTopicName(topicId) || `topic-${topicId}`;
1998
+ if (targetSession && sessionManager.isSessionAlive(targetSession)) {
1999
+ // Direct inject: receipt FIRST (the §3.4 handover point), stop re-check,
2000
+ // then the inject. A caught inject error AFTER the receipt is
2001
+ // local-delivered+injectError (never silent loss).
2002
+ if (!handover.commitReceipt())
2003
+ return { kind: 'handover-refused' };
2004
+ if (handover.stopRecheck())
2005
+ return { kind: 'stopped-before-inject' };
2006
+ try {
2007
+ sessionManager.injectTelegramMessage(targetSession, topicId, dmsg.payload, topicName, senderFirstName, senderUserId, Number(dmsg.messageId.replace(/^\D*/, '')) || undefined);
2008
+ telegram.trackMessageInjection(topicId, targetSession, dmsg.payload);
2009
+ // 1s inter-inject pacing for same-session runs (§3.1, pinned round-6).
2010
+ await new Promise((r) => setTimeout(r, 1000));
2011
+ return { kind: 'local-delivered' };
2012
+ }
2013
+ catch (err) {
2014
+ return { kind: 'local-delivered', injectError: err instanceof Error ? err.message : String(err) };
2015
+ }
2016
+ }
2017
+ // Respawn / auto-spawn path. The spawn-in-progress guard maps to
2018
+ // un-routable (§3.1) — never a silent skip.
2019
+ if (spawningTopics.has(topicId)) {
2020
+ return { kind: 'un-routable', reason: 'spawn-in-progress' };
2021
+ }
2022
+ if (!handover.commitReceipt())
2023
+ return { kind: 'handover-refused' };
2024
+ if (handover.stopRecheck())
2025
+ return { kind: 'stopped-before-inject' };
2026
+ spawningTopics.add(topicId);
2027
+ try {
2028
+ if (targetSession) {
2029
+ // Dead session — respawn, AWAITED through the spawn+inject (the PIS
2030
+ // record is written inside, AFTER our receipt — the round-7 order pin).
2031
+ await respawnSessionForTopic(sessionManager, telegram, targetSession, topicId, dmsg.payload, topicMemory, resolvedUser ?? undefined);
2032
+ }
2033
+ else {
2034
+ const newSessionName = await spawnSessionForTopic(sessionManager, telegram, topicName, topicId, dmsg.payload, topicMemory, resolvedUser ?? undefined);
2035
+ telegram.registerTopicSession(topicId, newSessionName, topicName);
2036
+ }
2037
+ return { kind: 'local-delivered' };
2038
+ }
2039
+ catch (err) {
2040
+ // Receipt already committed — the at-most-once authority forbids a
2041
+ // re-inject; honest disposition: delivered-unconfirmed + report (§3.4).
2042
+ return { kind: 'local-delivered', injectError: err instanceof Error ? err.message : String(err) };
2043
+ }
2044
+ finally {
2045
+ spawningTopics.delete(topicId);
2046
+ }
2047
+ };
1890
2048
  }
1891
2049
  /**
1892
2050
  * Wire WhatsApp message routing: incoming messages → Claude sessions.
@@ -5422,6 +5580,28 @@ export async function startServer(options) {
5422
5580
  const { ReapLog } = await import('../monitoring/ReapLog.js');
5423
5581
  const { ReapNotifier } = await import('../monitoring/ReapNotifier.js');
5424
5582
  const reapLog = new ReapLog(config.stateDir, () => (coordinator.enabled ? coordinator.identity?.machineId : undefined));
5583
+ // ── Durable notice lane (reap-notify spec R1.3) ──
5584
+ // The notifier enqueues notices as `reap-notify:<id>` rows in the shared
5585
+ // PendingRelayStore; the ALWAYS-ON ReapNoticeDrain (started after the
5586
+ // Telegram adapter exists, below) delivers them — independent of the
5587
+ // default-OFF DeliveryFailureSentinel. A store open failure degrades the
5588
+ // notifier to its loud direct-send fallback (R1.3 enqueue-failed path).
5589
+ let reapNoticeStore = null;
5590
+ try {
5591
+ const { PendingRelayStore, assertSqliteAvailable } = await import('../messaging/pending-relay-store.js');
5592
+ if (assertSqliteAvailable().ok) {
5593
+ reapNoticeStore = PendingRelayStore.open(config.projectName, config.stateDir);
5594
+ }
5595
+ }
5596
+ catch (err) {
5597
+ // @silent-fallback-ok — not silent: warns with full context here, and the
5598
+ // notifier's enqueue-failed path reports via DegradationReporter (R1.3).
5599
+ console.warn('[reap-notify] pending-relay store unavailable — notices fall back to direct send:', err);
5600
+ reapNoticeStore = null;
5601
+ }
5602
+ // Wired by the resume-queue boot block (Part B) once the queue exists;
5603
+ // stays false (no "restart queued" lines) until then or in dry-run.
5604
+ let resumeQueuedForSession = () => false;
5425
5605
  const reapNotifier = new ReapNotifier({
5426
5606
  resolveTopic: (tmuxSession) => {
5427
5607
  const t = telegram?.getTopicForSession(tmuxSession);
@@ -5431,14 +5611,353 @@ export async function startServer(options) {
5431
5611
  return Number.isFinite(n) ? n : null;
5432
5612
  },
5433
5613
  lifelineTopic: () => telegram?.getLifelineTopicId() ?? null,
5434
- // SUMMARY tier → through the formatter/tone-gate (HTML-escaped, quiet-hours
5435
- // aware). The notifier already coalesces, so a burst is one message.
5614
+ // Legacy + fallback transport → through the formatter/tone-gate
5615
+ // (HTML-escaped, quiet-hours aware). The notifier already coalesces.
5436
5616
  send: (topicId, text) => notify('SUMMARY', 'session-reap', text, topicId),
5617
+ enqueueNotice: reapNoticeStore
5618
+ ? (input) => reapNoticeStore.enqueue({
5619
+ delivery_id: input.delivery_id,
5620
+ topic_id: input.topic_id,
5621
+ text_hash: createHash('sha256').update(input.text).digest('hex'),
5622
+ text: input.text,
5623
+ next_attempt_at: input.next_attempt_at,
5624
+ })
5625
+ : undefined,
5626
+ recordNotify: (e) => reapLog.recordNotify(e),
5627
+ quietHoursEndAt: (now) => notificationBatcher.quietHoursEndAt(now),
5628
+ summaryReleaseAt: (now) => notificationBatcher.nextSummaryReleaseAt(now),
5629
+ resumeQueuedFor: (tmuxSession) => resumeQueuedForSession(tmuxSession),
5630
+ reportDegradation: (reason, impact) => {
5631
+ try {
5632
+ DegradationReporter.getInstance().report({
5633
+ feature: 'reap-notice-enqueue',
5634
+ primary: 'durable reap-notice delivery via PendingRelayStore + ReapNoticeDrain',
5635
+ fallback: 'one direct send attempt (fire-and-forget)',
5636
+ reason,
5637
+ impact,
5638
+ });
5639
+ }
5640
+ catch { /* @silent-fallback-ok — guard around the degradation REPORTER itself; the degradation it describes was already logged by the caller */ }
5641
+ },
5437
5642
  }, {
5438
5643
  enabled: config.monitoring?.reapNotify?.enabled ?? true,
5439
5644
  coalesceWindowMs: config.monitoring?.reapNotify?.coalesceWindowMs ?? 60_000,
5645
+ perTopic: config.monitoring?.reapNotify?.perTopic ?? true,
5646
+ maxImmediatePerFlush: config.monitoring?.reapNotify?.maxImmediatePerFlush ?? 5,
5647
+ drainEnabled: config.monitoring?.reapNotify?.drainEnabled ?? true,
5440
5648
  });
5649
+ // ReapNoticeDrain — the always-on delivery loop over the reap-notify lane
5650
+ // (R1.3; independent of the default-OFF DeliveryFailureSentinel). The
5651
+ // telegram adapter is referenced lazily: it does not exist yet at this
5652
+ // point in boot, and a tick with no adapter simply retries on backoff.
5653
+ let reapNoticeDrain = null;
5654
+ if (reapNoticeStore && (config.monitoring?.reapNotify?.drainEnabled ?? true)) {
5655
+ const { ReapNoticeDrain } = await import('../monitoring/ReapNoticeDrain.js');
5656
+ const { getCurrentBootId } = await import('../server/boot-id.js');
5657
+ reapNoticeDrain = new ReapNoticeDrain({
5658
+ store: reapNoticeStore,
5659
+ sendToTopic: async (topicId, text) => {
5660
+ if (!telegram)
5661
+ throw new Error('telegram adapter not available');
5662
+ await telegram.sendToTopic(topicId, text);
5663
+ },
5664
+ recordNotify: (e) => reapLog.recordNotify(e),
5665
+ emitAttention: async (item) => {
5666
+ if (!telegram)
5667
+ return;
5668
+ await telegram.createAttentionItem({
5669
+ id: item.id,
5670
+ title: item.title,
5671
+ summary: item.summary ?? item.title,
5672
+ description: item.description,
5673
+ category: item.category ?? 'delivery',
5674
+ priority: item.priority === 'high' ? 'HIGH' : item.priority === 'low' ? 'LOW' : 'NORMAL',
5675
+ sourceContext: item.sourceContext,
5676
+ });
5677
+ },
5678
+ bootId: getCurrentBootId() ?? `boot-${Date.now().toString(36)}`,
5679
+ });
5680
+ reapNoticeDrain.start();
5681
+ console.log(pc.green(' ReapNoticeDrain started (always-on durable reap-notice delivery)'));
5682
+ }
5441
5683
  sessionManager.setAwakeChecker(() => !coordinator.enabled || coordinator.isAwake);
5684
+ // Pressure-tier provider for the evidence fallback (reap-notify R2.1):
5685
+ // the SAME shared HostPressureSampler definition the reaper and the
5686
+ // resume-queue drainer read — one definition of "pressure", never two.
5687
+ const { sampleHostPressure: samplePressureShared } = await import('../monitoring/HostPressureSampler.js');
5688
+ const reaperPressureCfg = config.monitoring?.sessionReaper;
5689
+ const sharedPressureTier = () => samplePressureShared({
5690
+ cpuModerateLoadPerCore: reaperPressureCfg?.cpuModerateLoadPerCore ?? 1.0,
5691
+ cpuCriticalLoadPerCore: reaperPressureCfg?.cpuCriticalLoadPerCore ?? 1.5,
5692
+ }).tier;
5693
+ sessionManager.setPressureTierProvider(sharedPressureTier);
5694
+ // ── ResumeQueue + drainer (reap-notify spec Part B, R2.2–R2.11) ──
5695
+ // Ships enabled + dryRun (observe-only) as CODE defaults — deliberately
5696
+ // not in ConfigDefaults so the later fleet flip of the shipped default
5697
+ // takes effect. Classified in DARK_GATE_EXCLUSIONS (cost-bearing).
5698
+ const rqCfg = config.monitoring?.resumeQueue ?? {};
5699
+ let resumeQueue = null;
5700
+ let resumeDrainer = null;
5701
+ // Operator-stop record for the drainer's R2.6 validation (in-memory map +
5702
+ // the durable autonomous-emergency-stop flag file's mtime as global stop).
5703
+ const operatorStopsByTopic = new Map();
5704
+ let globalOperatorStopAt = 0;
5705
+ const recordOperatorStop = (topicId) => {
5706
+ if (topicId == null)
5707
+ globalOperatorStopAt = Date.now();
5708
+ else
5709
+ operatorStopsByTopic.set(topicId, Date.now());
5710
+ };
5711
+ if (rqCfg.enabled ?? true) {
5712
+ const { ResumeQueue } = await import('../monitoring/ResumeQueue.js');
5713
+ const { ResumeQueueDrainer } = await import('../monitoring/ResumeQueueDrainer.js');
5714
+ // Decision-transition audit sink: logs/resume-queue.jsonl, 5MB×2 rotation.
5715
+ const resumeAuditPath = path.join(_projectDir, 'logs', 'resume-queue.jsonl');
5716
+ const auditResumeQueue = (event) => {
5717
+ try {
5718
+ fs.mkdirSync(path.dirname(resumeAuditPath), { recursive: true });
5719
+ try {
5720
+ const st = fs.statSync(resumeAuditPath);
5721
+ if (st.size > 5 * 1024 * 1024) {
5722
+ fs.renameSync(resumeAuditPath, `${resumeAuditPath}.1`); // 5MB×2: .1 replaced each rotation
5723
+ }
5724
+ }
5725
+ catch { /* no file yet */ }
5726
+ fs.appendFileSync(resumeAuditPath, JSON.stringify(event) + '\n');
5727
+ }
5728
+ catch { /* the audit sink never endangers the queue */ }
5729
+ };
5730
+ // ALL give-up classes fold into ONE rolling deduped attention item (P17).
5731
+ const resumeAggregate = { counts: new Map(), recent: [] };
5732
+ const raiseResumeAggregated = (kind, detail) => {
5733
+ try {
5734
+ resumeAggregate.counts.set(kind, (resumeAggregate.counts.get(kind) ?? 0) + 1);
5735
+ resumeAggregate.recent.push(`[${kind}] ${detail}`);
5736
+ if (resumeAggregate.recent.length > 8)
5737
+ resumeAggregate.recent.shift();
5738
+ if (!telegram)
5739
+ return;
5740
+ const total = [...resumeAggregate.counts.values()].reduce((a, b) => a + b, 0);
5741
+ const breakdown = [...resumeAggregate.counts.entries()].map(([k, c]) => `${k}×${c}`).join(', ');
5742
+ void telegram.createAttentionItem({
5743
+ id: 'resume-queue:aggregate',
5744
+ title: `Resume queue: ${total} notice${total === 1 ? '' : 's'} (${breakdown})`,
5745
+ summary: detail,
5746
+ description: resumeAggregate.recent.join('\n'),
5747
+ category: 'sessions',
5748
+ priority: 'NORMAL', // per-entry HIGH items are forbidden (P17)
5749
+ sourceContext: 'resume-queue',
5750
+ }).catch(() => { });
5751
+ }
5752
+ catch { /* never endanger the caller */ }
5753
+ };
5754
+ resumeQueue = new ResumeQueue({
5755
+ stateDir: path.join(_projectDir, '.instar'),
5756
+ audit: auditResumeQueue,
5757
+ raiseAggregated: raiseResumeAggregated,
5758
+ }, {
5759
+ enabled: rqCfg.enabled ?? true,
5760
+ dryRun: rqCfg.dryRun ?? true, // shipped observe-only (decision 2)
5761
+ maxAttempts: rqCfg.maxAttempts ?? 3,
5762
+ maxResurrections: rqCfg.maxResurrections ?? 2,
5763
+ entryTtlHours: rqCfg.entryTtlHours ?? 24,
5764
+ maxQueueSize: rqCfg.maxQueueSize ?? 50,
5765
+ includeOperatorKills: rqCfg.includeOperatorKills ?? false,
5766
+ });
5767
+ const queueStarted = resumeQueue.start();
5768
+ if (!queueStarted) {
5769
+ console.log(pc.yellow(` ResumeQueue disabled: ${resumeQueue.isDisabled()}`));
5770
+ }
5771
+ else {
5772
+ // Feed the notifier's "restart is queued" line (live, non-dry-run only).
5773
+ const rq = resumeQueue;
5774
+ resumeQueuedForSession = (tmuxSession) => rq.hasLiveQueuedEntryFor(tmuxSession);
5775
+ const resolveTopicForTmux = (tmuxSession) => {
5776
+ try {
5777
+ const t = telegram?.getTopicForSession(tmuxSession);
5778
+ if (t == null)
5779
+ return null;
5780
+ const n = typeof t === 'number' ? t : Number(t);
5781
+ return Number.isFinite(n) ? n : null;
5782
+ }
5783
+ catch {
5784
+ // @silent-fallback-ok — null = "unbound": the notifier then routes
5785
+ // the session to the lifeline index line, never a dropped notice.
5786
+ return null;
5787
+ }
5788
+ };
5789
+ resumeDrainer = new ResumeQueueDrainer({
5790
+ queue: rq,
5791
+ pressureTier: sharedPressureTier,
5792
+ canSpawnSession: () => (quotaManager ? quotaManager.canSpawnSession().allowed : true),
5793
+ sessionCountOk: () => sessionManager.listRunningSessions().length <
5794
+ (config.maxSessions ?? 10),
5795
+ // No catch: a throwing dep resolves to the SAFE side (blocked)
5796
+ // inside the drainer's gate — wrapping it here would flip the
5797
+ // failure to the lenient side.
5798
+ migrationInFlight: () => quotaManager?.isMigrationInFlight() ?? false,
5799
+ liveSessionForTopic: (topicId) => sessionManager
5800
+ .listRunningSessions()
5801
+ .some((s) => resolveTopicForTmux(s.tmuxSession) === topicId),
5802
+ currentResumeUuid: (topicId) => _topicResumeMap?.get(topicId) ?? null,
5803
+ topicOwnerElsewhere: (topicId) => {
5804
+ // Pool not wired → single-machine → always local. No catch: a
5805
+ // registry error propagates to the drainer's validateReality,
5806
+ // which resolves a throwing dep to the SAFE side (invalidated).
5807
+ const reg = sessionOwnershipRegistry;
5808
+ const self = _meshSelfId;
5809
+ if (!reg || !self)
5810
+ return false;
5811
+ const owner = reg.ownerOf(String(topicId));
5812
+ return !!owner && owner !== self;
5813
+ },
5814
+ topicBindingMatches: (topicId, cwd) => {
5815
+ const bindings = scopeVerifier?.loadTopicBindings?.();
5816
+ const binding = bindings?.[String(topicId)];
5817
+ if (!binding?.projectDir)
5818
+ return true; // unbound topic → default project
5819
+ return path.resolve(cwd).startsWith(path.resolve(binding.projectDir));
5820
+ },
5821
+ operatorStopSince: (topicId, sinceIso) => {
5822
+ const since = Date.parse(sinceIso);
5823
+ const perTopic = operatorStopsByTopic.get(topicId) ?? 0;
5824
+ let flagAt = 0;
5825
+ try {
5826
+ flagAt = fs.statSync(path.join(_projectDir, '.instar', 'autonomous-emergency-stop')).mtimeMs;
5827
+ }
5828
+ catch { /* no flag */ }
5829
+ return Math.max(perTopic, globalOperatorStopAt, flagAt) > since;
5830
+ },
5831
+ jobCheck: (slug, queuedAtIso) => {
5832
+ if (!scheduler)
5833
+ return { ok: false, why: 'scheduler-unavailable' };
5834
+ const job = scheduler.getJobs().find((j) => j.slug === slug);
5835
+ if (!job)
5836
+ return { ok: false, why: 'job-missing' };
5837
+ // 'disabled' also covers CrashLoopPauser-paused jobs — the
5838
+ // pauser's mechanism IS setting enabled=false (+ provenance note).
5839
+ if (!job.enabled)
5840
+ return { ok: false, why: 'job-disabled' };
5841
+ const lastRun = state.getJobState(slug)?.lastRun;
5842
+ if (lastRun && Date.parse(lastRun) > Date.parse(queuedAtIso)) {
5843
+ return { ok: false, why: 'job-ran-since' };
5844
+ }
5845
+ return { ok: true };
5846
+ },
5847
+ pathExists: (p) => fs.existsSync(p),
5848
+ respawnTopic: async (entry, continuationPrompt) => {
5849
+ if (!telegram)
5850
+ throw new Error('telegram adapter not available');
5851
+ return await spawnSessionForTopic(sessionManager, telegram, entry.sessionName, entry.topicId, continuationPrompt, topicMemory, undefined, undefined, undefined, { cwd: entry.worktreePath ?? entry.cwd });
5852
+ },
5853
+ triggerJob: async (slug) => {
5854
+ if (!scheduler)
5855
+ return 'skipped';
5856
+ return await scheduler.triggerJob(slug, 'resume-queue');
5857
+ },
5858
+ spawnAliveAfterGrace: async (tmuxSession) => {
5859
+ await new Promise((resolve) => {
5860
+ const t = setTimeout(resolve, 15_000);
5861
+ if (typeof t.unref === 'function')
5862
+ t.unref();
5863
+ });
5864
+ return sessionManager.isSessionAlive(tmuxSession);
5865
+ },
5866
+ notifyResumed: (entry) => {
5867
+ // R2.11 — honest wording: "restarted", never a transcript-resume
5868
+ // claim (--resume can fall back to a fresh conversation in-pane).
5869
+ if (entry.topicId == null)
5870
+ return;
5871
+ notify('SUMMARY', 'session-resume', `🔁 I restarted this session to pick the work back up after it was shut down mid-work.`, entry.topicId);
5872
+ },
5873
+ raiseAggregated: raiseResumeAggregated,
5874
+ audit: auditResumeQueue,
5875
+ tier1Check: async (entry) => {
5876
+ // Observe-only Tier 1 sanity check via the shared LlmQueue
5877
+ // (P7). Throws when the LLM substrate is unavailable — the
5878
+ // drainer audits that as supervision:'shed'.
5879
+ const q = sharedLlmQueue;
5880
+ const intel = _sharedIntelligence;
5881
+ if (!q || !intel)
5882
+ throw new Error('llm-unavailable');
5883
+ const reasonLiteral = entry.reason.slice(0, 200).replace(/`/g, "'");
5884
+ const prompt = `A session was shut down mid-work and is queued for automatic restart. Given ONLY these ` +
5885
+ `recorded fields, is restarting it sensible? Look for internal contradictions (a "mid-work" ` +
5886
+ `entry whose reason describes completed work; a resurrection history that reads as a crash loop).\n` +
5887
+ `Recorded reason (literal data): \`${reasonLiteral}\`\n` +
5888
+ `Work signals: ${entry.workEvidence.join(', ') || '(none)'}\n` +
5889
+ `Queued: ${entry.queuedAt}; attempts so far: ${entry.attempts}.\n` +
5890
+ `Reply with JSON only: {"sensible": true|false, "reasoning": "<one sentence>"}`;
5891
+ const raw = await q.enqueue('background', (signal) => intel.evaluate(prompt, {
5892
+ model: 'fast',
5893
+ maxTokens: 150,
5894
+ temperature: 0,
5895
+ signal,
5896
+ attribution: { component: 'ResumeQueueDrainer' }, // attribution for /metrics/features
5897
+ }));
5898
+ try {
5899
+ const t = String(raw).trim();
5900
+ const j = t.startsWith('```') ? t.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '') : t;
5901
+ const parsed = JSON.parse(j);
5902
+ return { sensible: parsed.sensible !== false, reasoning: parsed.reasoning };
5903
+ }
5904
+ catch {
5905
+ return { sensible: true, reasoning: 'unparseable verdict — treated as no-concern (observe-only)' };
5906
+ }
5907
+ },
5908
+ }, {
5909
+ drainIntervalSec: rqCfg.drainIntervalSec ?? 60,
5910
+ requiredCalmTicks: rqCfg.requiredCalmTicks ?? 3,
5911
+ maxAttempts: rqCfg.maxAttempts ?? 3,
5912
+ breakerThreshold: rqCfg.breakerThreshold ?? 3,
5913
+ breakerCooldownMin: rqCfg.breakerCooldownMin ?? 30,
5914
+ tier1Check: rqCfg.tier1Check ?? true,
5915
+ });
5916
+ resumeDrainer.start();
5917
+ console.log(pc.green(` ResumeQueue started (${rqCfg.dryRun ?? true ? 'dry-run observe-only' : 'LIVE'}; drainer ${rqCfg.drainIntervalSec ?? 60}s tick)`));
5918
+ // Boot reconciliation half 2 (R2.4): re-enqueue recent mid-work reaps
5919
+ // the queue lost to a crash window. Deferred 30s so the Telegram
5920
+ // adapter (topic resolution) exists; topic-bound candidates only —
5921
+ // job entries rely on cron recurrence and opt-in we cannot
5922
+ // reconstruct from the reap-log.
5923
+ const reconcileTimer = setTimeout(() => {
5924
+ try {
5925
+ const ttlMs = (rqCfg.entryTtlHours ?? 24) * 3600_000;
5926
+ const cutoff = Date.now() - ttlMs;
5927
+ const candidates = reapLog
5928
+ .read(1000)
5929
+ .filter((en) => en.type === 'reaped' &&
5930
+ en.midWork === true &&
5931
+ en.disposition === 'terminal' &&
5932
+ en.origin === 'autonomous' &&
5933
+ Date.parse(en.ts) > cutoff)
5934
+ .map((en) => {
5935
+ const topicId = resolveTopicForTmux(en.tmuxSession);
5936
+ return {
5937
+ sessionName: en.session,
5938
+ tmuxSession: en.tmuxSession,
5939
+ topicId,
5940
+ resumeUuid: topicId != null ? (_topicResumeMap?.get(topicId) ?? null) : null,
5941
+ cwd: _projectDir,
5942
+ reason: en.reason,
5943
+ disposition: 'terminal',
5944
+ origin: 'autonomous',
5945
+ workEvidence: en.workEvidence ?? [],
5946
+ };
5947
+ });
5948
+ const enqueued = rq.reconcileFromReapLog(candidates);
5949
+ if (enqueued > 0) {
5950
+ console.log(`[resume-queue] boot reconciliation re-enqueued ${enqueued} lost mid-work reap(s) from the reap-log`);
5951
+ }
5952
+ }
5953
+ catch (err) {
5954
+ console.warn('[resume-queue] boot reconciliation failed (non-fatal):', err);
5955
+ }
5956
+ }, 30_000);
5957
+ if (typeof reconcileTimer.unref === 'function')
5958
+ reconcileTimer.unref();
5959
+ }
5960
+ }
5442
5961
  sessionManager.on('sessionReaped', (e) => {
5443
5962
  reapLog.recordReaped({
5444
5963
  session: e.session.name,
@@ -5453,8 +5972,42 @@ export async function startServer(options) {
5453
5972
  // which billing lane the reaped session ran on so the soak can confirm
5454
5973
  // rerouted sessions reach their completion from the reap-log too.
5455
5974
  ...(e.session.launchLane ? { launchLane: e.session.launchLane } : {}),
5975
+ // Mid-work stamp (reap-notify spec R2.1) — evidence clamped at the
5976
+ // chokepoint; the reap-log row is the boot-reconciliation source of truth.
5977
+ ...(e.midWork !== undefined ? { midWork: e.midWork } : {}),
5978
+ ...(e.workEvidence && e.workEvidence.length > 0 ? { workEvidence: e.workEvidence } : {}),
5456
5979
  });
5457
- reapNotifier.onReaped({ session: e.session, reason: e.reason, disposition: e.disposition, origin: e.origin });
5980
+ // Enqueue hook (reap-notify R2.2): every terminal autonomous reap is
5981
+ // OFFERED to the resume queue; eligibility (evidence classes, job
5982
+ // opt-in, operator exclusion, resurrection cap) is decided inside.
5983
+ // Runs BEFORE the notifier so the "restart is queued" line can see the
5984
+ // fresh entry. Never endangers the kill path.
5985
+ try {
5986
+ if (resumeQueue && !resumeQueue.isDisabled()) {
5987
+ const rawTopic = telegram?.getTopicForSession(e.session.tmuxSession);
5988
+ const topicId = rawTopic == null ? null : Number.isFinite(Number(rawTopic)) ? Number(rawTopic) : null;
5989
+ const jobDef = e.session.jobSlug
5990
+ ? scheduler?.getJobs().find((j) => j.slug === e.session.jobSlug)
5991
+ : undefined;
5992
+ resumeQueue.considerEnqueue({
5993
+ sessionName: e.session.name,
5994
+ tmuxSession: e.session.tmuxSession,
5995
+ topicId,
5996
+ jobSlug: e.session.jobSlug,
5997
+ jobResumeOptIn: jobDef?.resumeOnReap === true,
5998
+ resumeUuid: topicId != null ? (_topicResumeMap?.get(topicId) ?? null) : null,
5999
+ cwd: e.session.cwd ?? _projectDir,
6000
+ reason: e.reason,
6001
+ disposition: e.disposition ?? 'terminal',
6002
+ origin: e.origin ?? 'autonomous',
6003
+ workEvidence: e.workEvidence ?? [],
6004
+ });
6005
+ }
6006
+ }
6007
+ catch (err) {
6008
+ console.warn('[resume-queue] enqueue hook raised (non-fatal):', err);
6009
+ }
6010
+ reapNotifier.onReaped({ session: e.session, reason: e.reason, disposition: e.disposition, origin: e.origin, midWork: e.midWork, workEvidence: e.workEvidence });
5458
6011
  // Coherence journal 'reaped' (§3.3): emitted HERE, alongside the
5459
6012
  // reap-log append it references — never derived in the saveSession
5460
6013
  // funnel (which records the plain killed/completed transition).
@@ -5484,6 +6037,104 @@ export async function startServer(options) {
5484
6037
  // lifeline to restart the server in a tight loop.
5485
6038
  await sessionManager.purgeDeadSessions();
5486
6039
  sessionManager.startMonitoring();
6040
+ // ── Durable Inbound Message Queue: unconditional boot sweep (§5.3) ──
6041
+ // MUST run BEFORE recoverPendingInjects (boot ordering, spec §3.4): the
6042
+ // sweep consults injection receipts and vetoes PIS records for
6043
+ // operator-stop rows — running PIS recovery first would replay an inject
6044
+ // the operator stopped. Keyed on store-file existence; fail-open
6045
+ // (quarantine) on a corrupt store; gate-expires all custody with a NAMED
6046
+ // reason when the drain will not run this boot. The mesh-identity gate is
6047
+ // resolved later (the mesh block) — when the wiring there finds no
6048
+ // identity, it runs the same expire-all with reason no-mesh-identity
6049
+ // before any drain could have started.
6050
+ try {
6051
+ const sweepMod = await import('../core/inboundQueueBootSweep.js');
6052
+ const pisMod = await import('../core/PendingInjectStore.js');
6053
+ const sweepPis = new pisMod.PendingInjectStore(path.join(config.stateDir, 'state'));
6054
+ const pisRecordsForTopic = (sessionKey) => sweepPis.list().records.filter((r) => String(r.telegramTopicId ?? '') === sessionKey);
6055
+ const qRaw = config.multiMachine?.sessionPool?.inboundQueue;
6056
+ const poolRaw = config.multiMachine?.sessionPool;
6057
+ const queueWillRun = qRaw?.enabled !== true ? { run: false, gateReason: 'feature-disabled' }
6058
+ : qRaw?.dryRun !== false ? { run: false, gateReason: 'dry-run' }
6059
+ : (poolRaw?.enabled !== true || (poolRaw?.stage ?? 'dark') === 'dark') ? { run: false, gateReason: 'pool-dark' }
6060
+ : { run: true };
6061
+ const sweepRes = sweepMod.runInboundQueueBootSweep({
6062
+ stateDir: config.stateDir,
6063
+ agentId: config.projectName ?? 'agent',
6064
+ queueWillRun,
6065
+ hasPisRecord: (sk) => pisRecordsForTopic(sk).length > 0,
6066
+ clearPisRecord: (sk) => { for (const r of pisRecordsForTopic(sk))
6067
+ sweepPis.clear(r.tmuxSession); },
6068
+ reportLoss: (items, reason) => {
6069
+ const topics = [...new Set(items.map((i) => i.sessionKey))].join(', ');
6070
+ notify('SUMMARY', 'inbound-queue', `I didn't get to ${items.length} queued message(s) (${reason}; topics: ${topics}) — resend anything still needed.`);
6071
+ },
6072
+ reportPossiblyNotInjected: (items) => {
6073
+ const topics = [...new Set(items.map((i) => i.sessionKey))].join(', ');
6074
+ notify('SUMMARY', 'inbound-queue', `${items.length} message(s) may not have been injected before a crash (topics: ${topics}) — if a message went unanswered, resend it.`);
6075
+ },
6076
+ raiseAttention: (title, body) => notify('IMMEDIATE', 'inbound-queue', `${title}: ${body}`),
6077
+ log: (line) => console.log(pc.dim(` ${line}`)),
6078
+ nowMs: () => Date.now(),
6079
+ });
6080
+ _sweptInboundStore = sweepRes.store;
6081
+ if (sweepRes.storePresent) {
6082
+ console.log(pc.dim(` [inbound-queue] boot sweep: gateExpired=${sweepRes.gateExpired} recovered=${sweepRes.recoveredToQueued} ` +
6083
+ `delivered=${sweepRes.settledDelivered} pni=${sweepRes.possiblyNotInjected} pisVetoed=${sweepRes.pisVetoed} quarantined=${sweepRes.quarantined}`));
6084
+ }
6085
+ // Engine-never-constructed backstop (second-pass concern 1 — the
6086
+ // no-mesh-identity gate, made unreachable-proof): the sweep ran with
6087
+ // run:true expecting the mesh block to construct the drain. If, 90s into
6088
+ // boot, the swept store is still unadopted (no mesh identity, or the
6089
+ // construction threw), expire its custody with the NAMED reason and
6090
+ // close the handle — custody is NEVER silently stranded (§5.3), through
6091
+ // ANY non-construction path. One-shot; a no-op when the engine is live.
6092
+ if (sweepRes.store) {
6093
+ const orphanTimer = setTimeout(() => {
6094
+ if (!_sweptInboundStore || _inboundQueue)
6095
+ return;
6096
+ try {
6097
+ const store = _sweptInboundStore;
6098
+ const nowIso = new Date().toISOString();
6099
+ const rows = store.listNonTerminal();
6100
+ const dropped = [];
6101
+ for (const row of rows) {
6102
+ if (store.transition(row.enqueue_seq, row.state, 'expired', { nowIso, terminalReason: 'gate:no-mesh-identity' })) {
6103
+ dropped.push(row.session_key);
6104
+ }
6105
+ }
6106
+ if (dropped.length > 0) {
6107
+ notify('SUMMARY', 'inbound-queue', `I didn't get to ${dropped.length} queued message(s) (the queue is enabled but this machine has no mesh identity, so the drain never started; topics: ${[...new Set(dropped)].join(', ')}) — resend anything still needed.`);
6108
+ }
6109
+ store.close();
6110
+ _sweptInboundStore = null;
6111
+ console.warn('[inbound-queue] swept store never adopted by an engine — custody gate-expired (no-mesh-identity) and store closed');
6112
+ }
6113
+ catch (e) {
6114
+ console.warn(`[inbound-queue] orphan-store backstop failed (next boot's sweep retries): ${e instanceof Error ? e.message : String(e)}`);
6115
+ }
6116
+ }, 90_000);
6117
+ orphanTimer.unref?.();
6118
+ }
6119
+ }
6120
+ catch (err) {
6121
+ // The sweep is a recovery backstop — its own failure must never block
6122
+ // boot. The next boot retries; rows are durable.
6123
+ console.error(`[server] inbound-queue boot sweep failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
6124
+ }
6125
+ // Hold-for-stability effective-state getter (§4.2) — registered on the
6126
+ // UNCONDITIONAL boot path (same path as the sweep above), as a closure
6127
+ // over the effective-state computation: always-failover default ⇒
6128
+ // enabled:false, so the orphaned-config case (hold on, queue off/dark)
6129
+ // derives /guards off-runtime-divergent instead of on-unverified.
6130
+ try {
6131
+ guardRegistry.register('multiMachine.sessionPool.holdForStability.enabled', () => ({
6132
+ enabled: config.multiMachine?.sessionPool?.holdForStability?.enabled === true &&
6133
+ _inboundQueue !== null,
6134
+ lastTickAt: Date.now(),
6135
+ }));
6136
+ }
6137
+ catch { /* posture observability never blocks boot */ }
5487
6138
  // Pending-inject recovery (finding 8d300555): re-deliver initial messages
5488
6139
  // orphaned by the previous server process dying in the spawn→ready→inject
5489
6140
  // window (the auto-updater restart race). Runs AFTER the purge so dead
@@ -8248,6 +8899,13 @@ export async function startServer(options) {
8248
8899
  semanticMemory?.checkpoint();
8249
8900
  }
8250
8901
  catch { /* non-critical */ }
8902
+ // Durable Inbound Message Queue §6: sleep-shift backoff deadlines by the
8903
+ // nap span + the nap clamp (stale rows expire reported). Low wake
8904
+ // confidence → the conservative branch (clamp without shift).
8905
+ try {
8906
+ void _inboundQueue?.onWake(event.sleepDurationSeconds * 1000, event.lowConfidence ? 'low' : 'high');
8907
+ }
8908
+ catch { /* the backstop tick covers a missed wake trigger */ }
8251
8909
  // Re-validate tmux sessions
8252
8910
  try {
8253
8911
  const tmuxPath = detectTmuxPath();
@@ -8626,6 +9284,13 @@ export async function startServer(options) {
8626
9284
  }
8627
9285
  return null; // Normal messages pass through
8628
9286
  };
9287
+ // Durable Inbound Message Queue §3.6: emergency stop reaches custody.
9288
+ telegram.onSentinelStopCustody = (topicId) => {
9289
+ try {
9290
+ _inboundQueueStop?.(String(topicId));
9291
+ }
9292
+ catch { /* best-effort */ }
9293
+ };
8629
9294
  telegram.onSentinelKillSession = (sessionName) => {
8630
9295
  // Save resume UUID before killing so respawn can --resume
8631
9296
  if (_topicResumeMap) {
@@ -11214,6 +11879,28 @@ export async function startServer(options) {
11214
11879
  loadAvg: osMod.loadavg()[0],
11215
11880
  quotaState: selfQuotaState(),
11216
11881
  guardPosture: selfGuardPosture(),
11882
+ // Durable Inbound Message Queue §5.1: depth + oldest + tenure +
11883
+ // bounded top-K — the survivor's loss-SUSPECTED item, capped
11884
+ // re-placement arm, and supersede-dedupe key all read these.
11885
+ // Absent while the queue is dark — depth honestly unknown.
11886
+ ...(() => {
11887
+ try {
11888
+ if (!_inboundQueue)
11889
+ return {};
11890
+ const snap = _inboundQueue.snapshot();
11891
+ return {
11892
+ inboundQueue: {
11893
+ queueDepth: snap.counts.queued + snap.counts.claimed,
11894
+ oldestQueuedAt: snap.counts.oldestQueuedAt,
11895
+ tenure: snap.tenure,
11896
+ topK: _inboundQueue.topKSessionDepths(10),
11897
+ },
11898
+ };
11899
+ }
11900
+ catch {
11901
+ return {};
11902
+ }
11903
+ })(),
11217
11904
  });
11218
11905
  const hbApi = machineHeartbeat?.api;
11219
11906
  if (hbApi) {
@@ -11318,6 +12005,15 @@ export async function startServer(options) {
11318
12005
  // cas-pairing lint (scripts/lint-cas-emit-placement.js) fails CI on
11319
12006
  // any cas( call site missing this pairing.
11320
12007
  const emitPlacement = (sessionKey, r, reason, prevOwner) => {
12008
+ // Durable Inbound Message Queue §3.2 event trigger: an ownership
12009
+ // transition for a session with queued entries makes its head due
12010
+ // now (scoped reset + a drain pass). Fire-and-forget; the engine
12011
+ // no-ops when the session has nothing queued.
12012
+ try {
12013
+ if (r?.ok)
12014
+ void _inboundQueue?.onOwnershipTransition(sessionKey);
12015
+ }
12016
+ catch { /* trigger is best-effort; the backstop tick covers it */ }
11321
12017
  try {
11322
12018
  if (!coherenceJournal || !r?.ok)
11323
12019
  return;
@@ -11373,6 +12069,15 @@ export async function startServer(options) {
11373
12069
  const deliverMessageHandler = deliverMod.createDeliverMessageHandler({
11374
12070
  ownerEpochOf: (s) => ownReg.read(s)?.ownershipEpoch ?? null,
11375
12071
  recordReceipt: (messageId, session) => {
12072
+ // Durable Inbound Message Queue §3.4 remote path: the queue store's
12073
+ // remote receipt (canonical-id keyed, carries the `injected` marker
12074
+ // that makes peer-crash-between-receipt-and-inject boot-detectable —
12075
+ // loss window 6). Recorded ALONGSIDE the existing ledger receipt;
12076
+ // engine dark → the prior posture, named in the spec's skew note.
12077
+ try {
12078
+ _inboundQueue?.recordRemoteReceipt(session, messageId);
12079
+ }
12080
+ catch { /* receipt best-effort; ledger is authoritative for dedupe */ }
11376
12081
  if (messageLedger)
11377
12082
  return messageLedger.record(messageId, { platform: 'mesh', topic: session }).firstSeen;
11378
12083
  if (deliverSeenFallback.has(messageId))
@@ -11380,6 +12085,23 @@ export async function startServer(options) {
11380
12085
  deliverSeenFallback.add(messageId);
11381
12086
  return true;
11382
12087
  },
12088
+ // §3.4 sender re-validation (per-machine registries can diverge during
12089
+ // a deauthorization): a carried envelope whose userId no longer
12090
+ // resolves on THIS machine NACKs `sender-rejected`. Envelope absent
12091
+ // (old peer / live local frame) → not consulted.
12092
+ validateSender: (envelope) => {
12093
+ const uid = Number(envelope.userId);
12094
+ if (!Number.isFinite(uid) || uid === 0)
12095
+ return true;
12096
+ // THIS machine's registry (file-backed; the telegram block's
12097
+ // instance is scoped there — same files, same truth).
12098
+ try {
12099
+ return new UserManager(config.stateDir, config.users).resolveFromTelegramUserId(uid) !== null;
12100
+ }
12101
+ catch {
12102
+ return true;
12103
+ }
12104
+ },
11383
12105
  // Owner-side bridge (§L4 handoff): a forwarded message landed → spawn/resume
11384
12106
  // the local session for the topic so the conversation continues on THIS machine.
11385
12107
  // Only fires for a FIRST-seen forwarded deliverMessage (the ledger dedupes
@@ -11423,8 +12145,17 @@ export async function startServer(options) {
11423
12145
  if (existing && sessionManager.isSessionAlive(existing)) {
11424
12146
  if (text) {
11425
12147
  console.log(pc.green(` [session-pool] owner-side inject for forwarded topic ${topicId} → ${existing}`));
11426
- sessionManager.injectTelegramMessage(existing, topicId, text, tg.getTopicName?.(topicId) ?? undefined);
11427
- tg.trackMessageInjection(topicId, existing, text);
12148
+ // Loss window 6 (Durable Inbound Message Queue §3.4 remote):
12149
+ // flip the receipt's injected marker on success; a CAUGHT
12150
+ // failure reports at error time — never silent.
12151
+ try {
12152
+ sessionManager.injectTelegramMessage(existing, topicId, text, tg.getTopicName?.(topicId) ?? undefined);
12153
+ tg.trackMessageInjection(topicId, existing, text);
12154
+ _inboundQueue?.markRemoteInjected(cmd.session, cmd.messageId);
12155
+ }
12156
+ catch (err) {
12157
+ _inboundQueue?.reportPeerInjectError(cmd.session, cmd.messageId, err instanceof Error ? err.message : String(err));
12158
+ }
11428
12159
  }
11429
12160
  return;
11430
12161
  }
@@ -11458,8 +12189,13 @@ export async function startServer(options) {
11458
12189
  .then((name) => {
11459
12190
  tg.registerTopicSession(topicId, name, spawnName);
11460
12191
  console.log(pc.green(` [session-pool] owner-side resume for forwarded topic ${topicId} → ${name}`));
12192
+ // Loss window 6: the forwarded message reached a real session.
12193
+ _inboundQueue?.markRemoteInjected(cmd.session, cmd.messageId);
11461
12194
  })
11462
- .catch((err) => console.warn(` [session-pool] owner-side resume failed for topic ${topicId}: ${err instanceof Error ? err.message : String(err)}`));
12195
+ .catch((err) => {
12196
+ console.warn(` [session-pool] owner-side resume failed for topic ${topicId}: ${err instanceof Error ? err.message : String(err)}`);
12197
+ _inboundQueue?.reportPeerInjectError(cmd.session, cmd.messageId, err instanceof Error ? err.message : String(err));
12198
+ });
11463
12199
  },
11464
12200
  });
11465
12201
  // ── Secret-sync inbound handler (cross-machine secret distribution, spec Phase 4) ──
@@ -12287,6 +13023,15 @@ export async function startServer(options) {
12287
13023
  // signal per episode.
12288
13024
  const ownerSuspectBreaker = new (await import('../core/OwnerSuspectBreaker.js')).OwnerSuspectBreaker({
12289
13025
  logger: (m) => console.log(pc.dim(m)),
13026
+ // Durable Inbound Message Queue §3.2: breaker close delivers held
13027
+ // rows instantly. Engine constructed later in this block — the
13028
+ // closure reads at fire time.
13029
+ onClose: () => { void _inboundQueue?.onBreakerClose(); },
13030
+ flapThresholdPerHour: config.multiMachine?.sessionPool?.holdForStability?.flapThresholdPerHour ?? 6,
13031
+ reportFlapEpisode: ({ machineId, episodesLastHour }) => {
13032
+ const nickname = machinePoolRegistry?.getCapacity(machineId)?.nickname ?? machineId;
13033
+ notify('SUMMARY', 'inbound-queue', `"${nickname}" is flapping (${episodesLastHour} suspect episodes in the last hour) — holds are disabled for it until it calms; its conversations move on the usual failover path.`);
13034
+ },
12290
13035
  reportSustained: ({ machineId, suspectForMs, marks }) => {
12291
13036
  DegradationReporter.getInstance().report({
12292
13037
  feature: 'SessionPool.ownerDelivery',
@@ -12352,7 +13097,10 @@ export async function startServer(options) {
12352
13097
  const url = peerUrl(target);
12353
13098
  if (!url)
12354
13099
  throw new Error(`no peer url for ${target}`);
12355
- const res = await meshClient.send({ machineId: target, url }, { type: 'deliverMessage', session: env.sessionKey, messageId: env.messageId, payload: env.payload, ownershipEpoch: env.ownershipEpoch }, env.ownershipEpoch);
13100
+ // senderEnvelope (Durable Inbound Message Queue §2.2): a drained
13101
+ // forward carries the STORED sender frame; an old peer ignores
13102
+ // the extra field (version skew named in the spec).
13103
+ const res = await meshClient.send({ machineId: target, url }, { type: 'deliverMessage', session: env.sessionKey, messageId: env.messageId, payload: env.payload, ownershipEpoch: env.ownershipEpoch, ...(env.senderEnvelope ? { senderEnvelope: env.senderEnvelope } : {}) }, env.ownershipEpoch);
12356
13104
  if (res.ok && res.result && typeof res.result === 'object' && 'accepted' in res.result) {
12357
13105
  return res.result;
12358
13106
  }
@@ -12365,11 +13113,192 @@ export async function startServer(options) {
12365
13113
  await meshClient.send({ machineId, url }, { type: 'deliverMessage', session: msg.sessionKey, messageId: msg.messageId, payload: msg.payload, ownershipEpoch: 0 }, 0);
12366
13114
  },
12367
13115
  handleLocally: async () => { },
12368
- queueMessage: () => { },
13116
+ // Durable Inbound Message Queue §2.2: tri-state custody taking.
13117
+ // Null engine (dark / gate failed / invariants violated) → 'refused'
13118
+ // → the router leaves acked:false → today's fall-through. enqueueLive
13119
+ // never throws (storage failure maps to 'refused' — fail-safe).
13120
+ queueMessage: (msg, reason) => {
13121
+ if (!_inboundQueue)
13122
+ return 'refused';
13123
+ const out = _inboundQueue.enqueueLive({
13124
+ sessionKey: msg.sessionKey,
13125
+ messageId: msg.messageId,
13126
+ payload: typeof msg.payload === 'string' ? msg.payload : JSON.stringify(msg.payload ?? ''),
13127
+ senderEnvelope: msg.senderEnvelope ?? null,
13128
+ topicMetadata: msg.topicMetadata,
13129
+ }, reason);
13130
+ return out.result;
13131
+ },
12369
13132
  raiseAttention: (title, body) => console.log(pc.dim(` [session-router] attention: ${title} — ${body}`)),
12370
13133
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
12371
13134
  log: (line) => console.log(pc.dim(` [session-router] ${line}`)),
12372
13135
  });
13136
+ // ── Durable Inbound Message Queue: engine construction (gated) ──
13137
+ // Gate: pool active (this block) + mesh identity (this block) +
13138
+ // inboundQueue.enabled + !dryRun + the six config-seam invariants.
13139
+ // A violated invariant keeps the queue OFF for the boot — one loud
13140
+ // config-error per violated inequality + one attention item, never a
13141
+ // half-configured queue (spec §Config).
13142
+ try {
13143
+ const iqcMod = await import('../core/inboundQueueConfig.js');
13144
+ const qcfg = { ...iqcMod.DEFAULT_INBOUND_QUEUE_CONFIG, ...(config.multiMachine?.sessionPool?.inboundQueue ?? {}) };
13145
+ const hcfg = { ...iqcMod.DEFAULT_HOLD_FOR_STABILITY_CONFIG, ...(config.multiMachine?.sessionPool?.holdForStability ?? {}) };
13146
+ // Dry-run constructs the engine too (second-pass concern 2): the
13147
+ // §2.4 dry-run branch never takes custody, but its durable
13148
+ // wouldEnqueue/wouldHold/wouldRefuse counters ARE the promotion
13149
+ // evidence and /pool/queue must serve them. The boot sweep already
13150
+ // gate-expired any live→dry-run residual rows.
13151
+ if (qcfg.enabled && _sessionPoolStage() !== 'dark') {
13152
+ const inv = iqcMod.validateInboundQueueInvariants(qcfg, hcfg);
13153
+ if (!inv.ok) {
13154
+ for (const v of inv.violations) {
13155
+ console.error(`[inbound-queue] CONFIG ERROR — invariant ${v.invariant} (${v.name}): ${v.message}`);
13156
+ }
13157
+ notify('IMMEDIATE', 'inbound-queue', `Inbound queue NOT started — ${inv.violations.length} config invariant(s) violated (${inv.violations.map((v) => v.name).join(', ')}). The queue stays OFF; messages use today's delivery path.`);
13158
+ _sweptInboundStore?.close();
13159
+ _sweptInboundStore = null;
13160
+ }
13161
+ else {
13162
+ const qdlMod = await import('../core/QueueDrainLoop.js');
13163
+ const pisStoreMod = await import('../core/PendingInboundStore.js');
13164
+ const pisMod2 = await import('../core/PendingInjectStore.js');
13165
+ const queuePis = new pisMod2.PendingInjectStore(path.join(config.stateDir, 'state'));
13166
+ const store = _sweptInboundStore ?? pisStoreMod.PendingInboundStore.open(config.projectName ?? 'agent', config.stateDir);
13167
+ const bootSessionId = `${meshSelfId}:${Date.now()}`;
13168
+ // §4.2 hold verdict — effective state honesty: always-'failover'
13169
+ // when the policy is off or the queue is dry-run (unreachable
13170
+ // here: dryRun gates construction) — config-coupled both ways.
13171
+ const holdOn = hcfg.enabled === true;
13172
+ const holdVerdict = (sessionKey) => {
13173
+ if (!holdOn)
13174
+ return 'failover';
13175
+ const own = ownReg.read(sessionKey);
13176
+ const owner = own ? ownReg.ownerOf(sessionKey) : null;
13177
+ if (!owner || owner === meshSelfId)
13178
+ return 'deliver';
13179
+ const cap = machinePoolRegistry?.getCapacity(owner);
13180
+ if (!cap?.online)
13181
+ return 'failover'; // heartbeat offline — dead is dead
13182
+ if (ownerSuspectBreaker.isFlapping(owner))
13183
+ return 'failover'; // §4.4
13184
+ if (ownerSuspectBreaker.isSuspect(owner))
13185
+ return 'hold'; // suspect + online
13186
+ return 'deliver'; // not suspect (exhaustion site) — enqueue-and-drain
13187
+ };
13188
+ const stoppedTopics = new Set();
13189
+ _inboundQueue = new qdlMod.QueueDrainLoop({
13190
+ store,
13191
+ qcfg,
13192
+ hcfg,
13193
+ selfMachineId: meshSelfId,
13194
+ // The real serving-lease signal (second-pass concern 3): when
13195
+ // a lease coordinator exists, custody is gated on actually
13196
+ // HOLDING the lease (§2.2 — custody only where it can be
13197
+ // drained); a single-machine install with no coordinator
13198
+ // defaults true (the single-router topology).
13199
+ holdsLease: () => (leaseCoordinatorRef ? leaseCoordinatorRef.holdsLease() : true),
13200
+ isStopped: (sk) => stoppedTopics.has(sk),
13201
+ dispatchInbound: async (msg, handover) => {
13202
+ if (!_drainLocalDeliver)
13203
+ return { kind: 'un-routable', reason: 'local-tail-not-wired' };
13204
+ return _drainLocalDeliver(msg, handover);
13205
+ },
13206
+ forceReplace: async (msg) => {
13207
+ if (!_sessionRouter)
13208
+ return false;
13209
+ return _sessionRouter.forceReplace({
13210
+ sessionKey: msg.sessionKey,
13211
+ messageId: msg.messageId,
13212
+ payload: msg.payload,
13213
+ senderEnvelope: msg.senderEnvelope,
13214
+ topicMetadata: msg.topicMetadata,
13215
+ });
13216
+ },
13217
+ holdVerdict,
13218
+ clearPisRecord: (sk) => {
13219
+ for (const r of queuePis.list().records.filter((x) => String(x.telegramTopicId ?? '') === sk)) {
13220
+ queuePis.clear(r.tmuxSession);
13221
+ }
13222
+ },
13223
+ reportLoss: (items, reason) => {
13224
+ const topics = [...new Set(items.map((i) => i.sessionKey))].join(', ');
13225
+ notify('SUMMARY', 'inbound-queue', `I didn't get to ${items.length} queued message(s) (${reason}; topics: ${topics}) — resend anything still needed.`);
13226
+ },
13227
+ reportPossiblyNotInjected: (items) => {
13228
+ const topics = [...new Set(items.map((i) => i.sessionKey))].join(', ');
13229
+ notify('SUMMARY', 'inbound-queue', `${items.length} message(s) may not have been injected (topics: ${topics}) — if a message went unanswered, resend it.`);
13230
+ },
13231
+ log: (line) => console.log(pc.dim(` ${line}`)),
13232
+ reportDegradation: (reason) => {
13233
+ DegradationReporter.getInstance().report({
13234
+ feature: 'InboundQueue.drain',
13235
+ primary: 'Durable inbound custody drain',
13236
+ fallback: 'Rows remain durable; the Eternal-Sentinel tick keeps retrying',
13237
+ reason,
13238
+ impact: 'Queued inbound messages may deliver late until the tick recovers.',
13239
+ });
13240
+ },
13241
+ now: () => Date.now(),
13242
+ mono: () => performance.now(),
13243
+ bootSessionId,
13244
+ });
13245
+ _inboundQueue.onLeaseAcquired(null);
13246
+ // Expose the stop hook for the emergency-stop integration and
13247
+ // the engine for the /pool/queue route via module state.
13248
+ _inboundQueueStop = (sk) => { stoppedTopics.add(sk); _inboundQueue?.onOperatorStop(sk); };
13249
+ // Backstop tick (Eternal Sentinel — declared in QueueDrainLoop).
13250
+ const tickHandle = setInterval(() => { void _inboundQueue?.tick(); }, qcfg.drainTickMs);
13251
+ tickHandle.unref?.();
13252
+ console.log(pc.dim(` [inbound-queue] engine live — tick every ${qcfg.drainTickMs}ms, tenure ${_inboundQueue.currentTenure()}`));
13253
+ // Survivor arm (spec §5.1, loss window 1): the machine that
13254
+ // holds the routing role checks — once heartbeats have had a
13255
+ // cycle to repopulate — for OFFLINE peers whose last capacity
13256
+ // heartbeat reported nonzero queue depth. ONE loss-SUSPECTED
13257
+ // item per (machine + tenure) episode, then capped synthetic
13258
+ // re-placement for the top-K sessions THROUGH the router path
13259
+ // (PlacementExecutor honors pins; CAS; spawn on the chosen
13260
+ // machine) — SESSION RECOVERY WITHOUT MESSAGE REPLAY, framed
13261
+ // exactly so in the copy. A mesh-less/old peer carries no
13262
+ // depth field — honestly unknown, no item.
13263
+ const survivorEpisodesSeen = new Set();
13264
+ const survivorCheck = () => {
13265
+ try {
13266
+ for (const cap of machinePoolRegistry?.getCapacities() ?? []) {
13267
+ const iq = cap.inboundQueue;
13268
+ if (cap.machineId === meshSelfId || cap.online || !iq || iq.queueDepth <= 0)
13269
+ continue;
13270
+ const episodeKey = `${cap.machineId}|${iq.tenure ?? 'unknown'}`;
13271
+ if (survivorEpisodesSeen.has(episodeKey))
13272
+ continue;
13273
+ survivorEpisodesSeen.add(episodeKey);
13274
+ notify('IMMEDIATE', 'inbound-queue', `"${cap.nickname ?? cap.machineId}" went dark holding ~${iq.queueDepth} queued message(s) — SUSPECTED loss (it may have delivered some before going dark; last heartbeat ${cap.selfReportedLastSeen ?? 'unknown'}). I'm re-opening its top conversations on a healthy machine — session recovery WITHOUT message replay: the queued messages themselves are not recovered, so resend anything still needed.`);
13275
+ const respawnCap = Math.min(qcfg.maxFailoverRespawns, iq.topK.length);
13276
+ for (let i = 0; i < respawnCap; i++) {
13277
+ const sk = iq.topK[i].sessionKey;
13278
+ setTimeout(() => {
13279
+ void _sessionRouter?.forceReplace({ sessionKey: sk, messageId: `survivor-replace:${episodeKey}:${sk}`, payload: '', senderEnvelope: null })
13280
+ .catch(() => { });
13281
+ }, 2000 * i).unref?.(); // staggered
13282
+ }
13283
+ }
13284
+ }
13285
+ catch { /* survivor check is best-effort observability+recovery */ }
13286
+ };
13287
+ const survivorTimer = setInterval(survivorCheck, 60_000);
13288
+ survivorTimer.unref?.();
13289
+ }
13290
+ }
13291
+ else if (_sweptInboundStore) {
13292
+ // Sweep opened the store expecting a live engine but the stage/
13293
+ // dry-run gate says otherwise (mid-boot config nuance) — close it;
13294
+ // the sweep already settled rows per its own gate verdict.
13295
+ _sweptInboundStore.close();
13296
+ _sweptInboundStore = null;
13297
+ }
13298
+ }
13299
+ catch (err) {
13300
+ console.error(`[inbound-queue] engine construction failed (queue stays OFF): ${err instanceof Error ? err.message : String(err)}`);
13301
+ }
12373
13302
  // ── B (HTTP presence transport): pull each peer's self-capacity over
12374
13303
  // the signed /mesh/rpc channel and record it into the pool registry.
12375
13304
  // This is the credential-less presence path — a standby that cannot
@@ -12763,7 +13692,7 @@ export async function startServer(options) {
12763
13692
  carrier: _topicProfileCarrier,
12764
13693
  }
12765
13694
  : null;
12766
- const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, feedbackAnomalyDetector, dispatches, updateChecker, autoUpdater, autoDispatcher, quotaTracker, quotaManager, publisher, viewer, tunnel, evolution, watchdog, topicMemory, triageNurse, projectMapper, cartographer: cartographer ?? undefined, coherenceGate: scopeVerifier, contextHierarchy, canonicalState, operationGate, sentinel, adaptiveTrust, memoryMonitor, orphanReaper, coherenceMonitor, commitmentTracker, subscriptionPool, quotaPoller, quotaAwareScheduler: _quotaAwareScheduler ?? undefined, proactiveSwapMonitor: _proactiveSwapMonitor ?? undefined, inUseAccountResolver, enrollmentWizard, semanticMemory, activitySentinel, rateLimitSentinel, releaseReadinessSentinel: releaseReadinessSentinel ?? undefined, messageRouter, summarySentinel, spawnManager, systemReviewer, capabilityMapper, selfKnowledgeTree, coverageAuditor, topicResumeMap: _topicResumeMap ?? undefined, topicProfile: _topicProfileCtx ?? undefined, sessionRefresh: _sessionRefresh ?? undefined, autonomyManager, trustElevationTracker, autonomousEvolution, coordinator: coordinator.enabled ? coordinator : undefined, localSigningKeyPem, leaseTransport, onLeasePullRequest: () => leaseCoordinatorRef?.currentLease() ?? null, liveTailReceiver, handoffWireTransport, onHandoffBegin, onHandoffInitiate: handoffInitiate, handoffInProgress: handoffSentinelInProgress, messageLedger, currentInboundByTopic, replyMarkerTransport, onReplyMarker: messageLedger ? (marker) => { const m = marker; messageLedger.applyRemoteReplyMarker(m.dedupeKey, { platform: m.platform, replyIdempotencyKey: m.replyIdempotencyKey, epoch: m.epoch, topic: m.topic ?? null }); } : undefined, whatsapp: whatsappAdapter, slack: slackAdapter, imessage: imessageAdapter, whatsappBusinessBackend, messageBridge, hookEventReceiver, worktreeMonitor, subagentTracker, instructionsVerifier, handshakeManager: threadlineHandshake, threadlineRouter, conversationStore, warrantsReplyGate, collaborationSurfacer, threadResumeMap, topicLinkageHandler: topicLinkageHandler ?? undefined, threadlineRelayClient, threadlineReplyWaiters, listenerManager: listenerManager ?? undefined, a2aDeliveryTracker: a2aDeliveryTracker ?? undefined, responseReviewGate, messagingToneGate, outboundDedupGate, telemetryHeartbeat, pasteManager, featureRegistry, discoveryEvaluator, completionEvaluator, unifiedTrust, liveConfig, sharedStateLedger, ledgerSessionRegistry, worktreeManager, oidcEnrolledRepos: parallelDevConfig?.oidcEnrolledRepos, initiativeTracker, projectRoundRunner, projectDriftChecker, machineHeartbeat, machinePoolRegistry, meshRpcDispatcher, workingSetPullCoordinator, commitmentReplicaStore, forwardCommitmentMutate, sessionOwnershipRegistry, topicPinStore: _topicPinStore ?? undefined, streamTicketStore: _streamTicketStore ?? undefined, poolStreamAllowRemoteInput: config.dashboard?.poolStream?.allowRemoteInput ?? false, poolStreamConnector: _poolStreamConnector ?? undefined, secretSync: _secretSyncHandle ?? undefined, meshSelfId: _meshSelfId ?? undefined, resolveRouterUrl: _resolveRouterUrl ?? undefined, resolvePeerUrls: _resolvePeerUrls ?? undefined, guardRegistry, listPoolMachines: _listPoolMachines ?? undefined, sessionPoolE2EResultStore, proxyCoordinator, topicIntentStore, topicIntentArcCheck, usherSignalStore, intelligence: sharedIntelligence ?? undefined, telegramBridgeConfig, telegramBridge: telegramBridge ?? undefined, threadlineObservability, briefDeps, workingMemory, taskFlowRegistry, threadlineFlowBridge, sessionReaper, agentWorktreeReaper, mcpProcessReaper, geminiLoopRunner, sleepController, agentActivityState, reapLog, sleepWakeDetector, unjustifiedStopGate, stopGateDb, stopNotifier });
13695
+ const server = new AgentServer({ config, sessionManager, state, scheduler, telegram, relationships, feedback, feedbackAnomalyDetector, dispatches, updateChecker, autoUpdater, autoDispatcher, quotaTracker, quotaManager, publisher, viewer, tunnel, evolution, watchdog, topicMemory, triageNurse, projectMapper, cartographer: cartographer ?? undefined, coherenceGate: scopeVerifier, contextHierarchy, canonicalState, operationGate, sentinel, adaptiveTrust, memoryMonitor, orphanReaper, coherenceMonitor, commitmentTracker, subscriptionPool, quotaPoller, quotaAwareScheduler: _quotaAwareScheduler ?? undefined, proactiveSwapMonitor: _proactiveSwapMonitor ?? undefined, inUseAccountResolver, enrollmentWizard, semanticMemory, activitySentinel, rateLimitSentinel, releaseReadinessSentinel: releaseReadinessSentinel ?? undefined, messageRouter, summarySentinel, spawnManager, systemReviewer, capabilityMapper, selfKnowledgeTree, coverageAuditor, topicResumeMap: _topicResumeMap ?? undefined, topicProfile: _topicProfileCtx ?? undefined, sessionRefresh: _sessionRefresh ?? undefined, autonomyManager, trustElevationTracker, autonomousEvolution, coordinator: coordinator.enabled ? coordinator : undefined, localSigningKeyPem, leaseTransport, onLeasePullRequest: () => leaseCoordinatorRef?.currentLease() ?? null, liveTailReceiver, handoffWireTransport, onHandoffBegin, onHandoffInitiate: handoffInitiate, handoffInProgress: handoffSentinelInProgress, messageLedger, currentInboundByTopic, replyMarkerTransport, onReplyMarker: messageLedger ? (marker) => { const m = marker; messageLedger.applyRemoteReplyMarker(m.dedupeKey, { platform: m.platform, replyIdempotencyKey: m.replyIdempotencyKey, epoch: m.epoch, topic: m.topic ?? null }); } : undefined, whatsapp: whatsappAdapter, slack: slackAdapter, imessage: imessageAdapter, whatsappBusinessBackend, messageBridge, hookEventReceiver, worktreeMonitor, subagentTracker, instructionsVerifier, handshakeManager: threadlineHandshake, threadlineRouter, conversationStore, warrantsReplyGate, collaborationSurfacer, threadResumeMap, topicLinkageHandler: topicLinkageHandler ?? undefined, threadlineRelayClient, threadlineReplyWaiters, listenerManager: listenerManager ?? undefined, a2aDeliveryTracker: a2aDeliveryTracker ?? undefined, responseReviewGate, messagingToneGate, outboundDedupGate, telemetryHeartbeat, pasteManager, featureRegistry, discoveryEvaluator, completionEvaluator, unifiedTrust, liveConfig, sharedStateLedger, ledgerSessionRegistry, worktreeManager, oidcEnrolledRepos: parallelDevConfig?.oidcEnrolledRepos, initiativeTracker, projectRoundRunner, projectDriftChecker, machineHeartbeat, machinePoolRegistry, getInboundQueue: () => _inboundQueue, meshRpcDispatcher, workingSetPullCoordinator, commitmentReplicaStore, forwardCommitmentMutate, sessionOwnershipRegistry, topicPinStore: _topicPinStore ?? undefined, streamTicketStore: _streamTicketStore ?? undefined, poolStreamAllowRemoteInput: config.dashboard?.poolStream?.allowRemoteInput ?? false, poolStreamConnector: _poolStreamConnector ?? undefined, secretSync: _secretSyncHandle ?? undefined, meshSelfId: _meshSelfId ?? undefined, resolveRouterUrl: _resolveRouterUrl ?? undefined, resolvePeerUrls: _resolvePeerUrls ?? undefined, guardRegistry, listPoolMachines: _listPoolMachines ?? undefined, sessionPoolE2EResultStore, proxyCoordinator, topicIntentStore, topicIntentArcCheck, usherSignalStore, intelligence: sharedIntelligence ?? undefined, telegramBridgeConfig, telegramBridge: telegramBridge ?? undefined, threadlineObservability, briefDeps, workingMemory, taskFlowRegistry, threadlineFlowBridge, sessionReaper, agentWorktreeReaper, mcpProcessReaper, geminiLoopRunner, sleepController, agentActivityState, reapLog, resumeQueue, resumeDrainer, operatorStopRecorder: recordOperatorStop, sleepWakeDetector, unjustifiedStopGate, stopGateDb, stopNotifier });
12767
13696
  // Resolve the late-bound topic-operator getter (increment 2e): routing was
12768
13697
  // wired before the server existed; from here on inbound binds use the
12769
13698
  // server's own store instance.