@ouro.bot/cli 0.1.0-alpha.726 → 0.1.0-alpha.728

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 (35) hide show
  1. package/README.md +14 -1
  2. package/assets/bluebubbles-host +264 -0
  3. package/changelog.json +18 -0
  4. package/dist/heart/config.js +1 -2
  5. package/dist/heart/core.js +181 -209
  6. package/dist/heart/daemon/bluebubbles-cli-integration.js +71 -0
  7. package/dist/heart/daemon/bluebubbles-host-protocol.js +433 -0
  8. package/dist/heart/daemon/bluebubbles-host.js +273 -0
  9. package/dist/heart/daemon/cli-defaults.js +8 -1
  10. package/dist/heart/daemon/cli-exec.js +239 -34
  11. package/dist/heart/daemon/cli-help.js +17 -6
  12. package/dist/heart/daemon/cli-parse.js +69 -1
  13. package/dist/heart/daemon/cli-render.js +7 -0
  14. package/dist/heart/daemon/daemon.js +79 -14
  15. package/dist/heart/daemon/doctor.js +21 -1
  16. package/dist/heart/daemon/mcp-canary.js +160 -29
  17. package/dist/heart/daemon/process-manager.js +100 -18
  18. package/dist/heart/daemon/socket-client.js +28 -19
  19. package/dist/heart/providers/anthropic.js +17 -12
  20. package/dist/heart/streaming.js +6 -6
  21. package/dist/heart/versioning/ouro-path-installer.js +30 -24
  22. package/dist/heart/versioning/ouro-recovery-launcher.js +76 -0
  23. package/dist/heart/versioning/version-intent.js +114 -0
  24. package/dist/mind/prompt-budget.js +104 -5
  25. package/dist/repertoire/tools.js +0 -10
  26. package/dist/senses/bluebubbles/client.js +43 -57
  27. package/dist/senses/bluebubbles/context-packet.js +104 -19
  28. package/dist/senses/bluebubbles/entry.js +14 -1
  29. package/dist/senses/bluebubbles/index.js +1398 -719
  30. package/dist/senses/bluebubbles/latest-turn.js +311 -0
  31. package/dist/senses/bluebubbles/outbound-state.js +4 -0
  32. package/dist/senses/bluebubbles/reaction-policy.js +4 -12
  33. package/dist/senses/bluebubbles/semantic-receipts.js +178 -10
  34. package/dist/senses/bluebubbles/webhook-registration.js +228 -0
  35. package/package.json +4 -2
@@ -33,11 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.BB_IN_FLIGHT_MAX_AGE_MS = void 0;
37
- exports.resolveReactionTarget = resolveReactionTarget;
36
+ exports.BB_IN_FLIGHT_MAX_AGE_MS = exports.BLUEBUBBLES_SEMANTIC_GENERATION_RETENTION_MS = void 0;
37
+ exports.pruneBlueBubblesSemanticObservationGenerations = pruneBlueBubblesSemanticObservationGenerations;
38
38
  exports.tokenizeForDedupe = tokenizeForDedupe;
39
39
  exports.jaccardSimilarity = jaccardSimilarity;
40
- exports.createStatusBatcher = createStatusBatcher;
41
40
  exports.isBlueBubblesMessageInFlight = isBlueBubblesMessageInFlight;
42
41
  exports.beginBlueBubblesMessageInFlight = beginBlueBubblesMessageInFlight;
43
42
  exports.endBlueBubblesMessageInFlight = endBlueBubblesMessageInFlight;
@@ -47,6 +46,7 @@ exports.isAgentSelfHandle = isAgentSelfHandle;
47
46
  exports.getDiscoveredOwnHandles = getDiscoveredOwnHandles;
48
47
  exports.clearDiscoveredOwnHandles = clearDiscoveredOwnHandles;
49
48
  exports.recordDiscoveredOwnHandle = recordDiscoveredOwnHandle;
49
+ exports.seedBlueBubblesStartupSemanticObservations = seedBlueBubblesStartupSemanticObservations;
50
50
  exports.handleBlueBubblesEvent = handleBlueBubblesEvent;
51
51
  exports.recoverQueuedBlueBubblesMessages = recoverQueuedBlueBubblesMessages;
52
52
  exports.catchUpMissedBlueBubblesMessages = catchUpMissedBlueBubblesMessages;
@@ -63,6 +63,7 @@ const node_crypto_1 = require("node:crypto");
63
63
  const core_1 = require("../../heart/core");
64
64
  const config_1 = require("../../heart/config");
65
65
  const identity_1 = require("../../heart/identity");
66
+ const machine_identity_1 = require("../../heart/machine-identity");
66
67
  const runtime_cwd_1 = require("../../heart/runtime-cwd");
67
68
  const turn_coordinator_1 = require("../../heart/turn-coordinator");
68
69
  const context_1 = require("../../mind/context");
@@ -76,6 +77,7 @@ const proactive_content_guard_1 = require("../proactive-content-guard");
76
77
  const bluebubbles_meta_guard_1 = require("../bluebubbles-meta-guard");
77
78
  const model_1 = require("./model");
78
79
  const client_1 = require("./client");
80
+ const webhook_registration_1 = require("./webhook-registration");
79
81
  const inbound_log_1 = require("./inbound-log");
80
82
  const mutation_log_1 = require("./mutation-log");
81
83
  const processed_log_1 = require("./processed-log");
@@ -83,14 +85,106 @@ const semantic_receipts_1 = require("./semantic-receipts");
83
85
  const runtime_state_1 = require("./runtime-state");
84
86
  const session_cleanup_1 = require("./session-cleanup");
85
87
  const reaction_policy_1 = require("./reaction-policy");
88
+ const latest_turn_1 = require("./latest-turn");
86
89
  const active_turns_1 = require("./active-turns");
87
90
  const trust_gate_1 = require("../trust-gate");
88
91
  const pipeline_1 = require("../pipeline");
89
- const context_packets_1 = require("../context-packets");
90
92
  const context_packet_ledger_1 = require("../context-packet-ledger");
91
93
  const context_packet_1 = require("./context-packet");
92
94
  const autonomy_budget_1 = require("../../heart/autonomy-budget");
95
+ const outbound_state_1 = require("./outbound-state");
93
96
  const bbFailoverStates = new Map();
97
+ const activeSemanticHandlingSlots = new Map();
98
+ const semanticObservationGenerations = new Map();
99
+ const BLUEBUBBLES_VISIBLE_OUTBOUND_STATUSES = new Set([
100
+ "accepted",
101
+ "enqueued",
102
+ "local-visible",
103
+ "delivered",
104
+ ]);
105
+ exports.BLUEBUBBLES_SEMANTIC_GENERATION_RETENTION_MS = 15 * 60_000;
106
+ function pruneBlueBubblesSemanticObservationGenerations(nowMs = Date.now()) {
107
+ let pruned = 0;
108
+ for (const [keyHash, generation] of semanticObservationGenerations) {
109
+ if (generation.retainedAtMs === null
110
+ || nowMs - generation.retainedAtMs < exports.BLUEBUBBLES_SEMANTIC_GENERATION_RETENTION_MS)
111
+ continue;
112
+ (0, latest_turn_1.clearPending)(generation.reservation);
113
+ semanticObservationGenerations.delete(keyHash);
114
+ pruned += 1;
115
+ }
116
+ return pruned;
117
+ }
118
+ function adoptBlueBubblesSemanticObservation(keyHash, observedReservation, activate) {
119
+ pruneBlueBubblesSemanticObservationGenerations();
120
+ const existing = semanticObservationGenerations.get(keyHash);
121
+ if (!existing) {
122
+ const generation = { reservation: observedReservation, retry: false, retainedAtMs: null };
123
+ semanticObservationGenerations.set(keyHash, generation);
124
+ return generation;
125
+ }
126
+ (0, latest_turn_1.mergeObservationReservations)(existing.reservation, observedReservation);
127
+ if (activate) {
128
+ existing.retainedAtMs = null;
129
+ (0, latest_turn_1.reactivateObservation)(existing.reservation);
130
+ }
131
+ return existing;
132
+ }
133
+ function suspendBlueBubblesSemanticObservation(generation) {
134
+ generation.retry = true;
135
+ generation.retainedAtMs = Date.now();
136
+ (0, latest_turn_1.reactivateObservation)(generation.reservation);
137
+ }
138
+ function completeBlueBubblesSemanticObservation(keyHash, generation) {
139
+ (0, latest_turn_1.clearPending)(generation.reservation);
140
+ semanticObservationGenerations.delete(keyHash);
141
+ }
142
+ async function acquireBlueBubblesSemanticHandlingSlot(keyHash, generation) {
143
+ const active = activeSemanticHandlingSlots.get(keyHash);
144
+ if (!active) {
145
+ const slot = {
146
+ generation,
147
+ reservation: generation.reservation,
148
+ waiters: [],
149
+ };
150
+ activeSemanticHandlingSlots.set(keyHash, slot);
151
+ return {
152
+ status: "owner",
153
+ slot,
154
+ reservation: generation.reservation,
155
+ allowSameGenerationRetry: generation.retry,
156
+ };
157
+ }
158
+ return await new Promise((resolve) => {
159
+ active.waiters.push(resolve);
160
+ });
161
+ }
162
+ function releaseBlueBubblesSemanticHandlingSlot(keyHash, slot, outcome) {
163
+ if (outcome === "retryable") {
164
+ slot.generation.retry = true;
165
+ const next = slot.waiters.shift();
166
+ if (next) {
167
+ (0, latest_turn_1.reactivateObservation)(slot.reservation);
168
+ next({
169
+ status: "owner",
170
+ slot,
171
+ reservation: slot.reservation,
172
+ allowSameGenerationRetry: true,
173
+ });
174
+ return;
175
+ }
176
+ }
177
+ activeSemanticHandlingSlots.delete(keyHash);
178
+ if (outcome === "terminal") {
179
+ completeBlueBubblesSemanticObservation(keyHash, slot.generation);
180
+ }
181
+ else {
182
+ suspendBlueBubblesSemanticObservation(slot.generation);
183
+ }
184
+ for (const resolve of slot.waiters.splice(0)) {
185
+ resolve({ status: "handled_by_owner" });
186
+ }
187
+ }
94
188
  /**
95
189
  * In-flight message tracker.
96
190
  *
@@ -118,20 +212,6 @@ const bbInFlightMessageClaims = new Map();
118
212
  * a leaked marker from blocking forward progress for hours.
119
213
  */
120
214
  exports.BB_IN_FLIGHT_MAX_AGE_MS = 15 * 60_000;
121
- // Resolve the message a reaction points at, so the agent is told *what* was
122
- // reacted to and *whose* message it was rather than a bare "reacted with love".
123
- // Prefers the authorship-carrying lookup; falls back to text-only when the client
124
- // does not provide one. Every failure path returns an explicit "unknown" rather
125
- // than a value the caller could mistake for a resolved target.
126
- async function resolveReactionTarget(client, targetMessageGuid) {
127
- const base = { guid: targetMessageGuid };
128
- if (client.getMessageDetails) {
129
- const details = await client.getMessageDetails(targetMessageGuid).catch(() => null);
130
- return { ...base, text: details?.text ?? null, fromMe: details?.fromMe ?? null };
131
- }
132
- const text = await client.getMessageText(targetMessageGuid).catch(() => null);
133
- return { ...base, text, fromMe: null };
134
- }
135
215
  // ── Near-duplicate outward-text detection ────────────────────────
136
216
  // Used by createBlueBubblesCallbacks to collapse mid-turn rephrasings of the
137
217
  // same answer/status into a single delivery. Exposed for direct unit testing
@@ -155,44 +235,6 @@ function jaccardSimilarity(a, b) {
155
235
  const union = a.size + b.size - intersection;
156
236
  return intersection / union;
157
237
  }
158
- /**
159
- * Accumulates status descriptions and debounces them.
160
- * If multiple descriptions arrive within `delayMs`, they are joined with ` · `
161
- * and sent as a single message. Flush sends immediately and clears the timer.
162
- */
163
- function createStatusBatcher(send, delayMs) {
164
- (0, runtime_1.emitNervesEvent)({
165
- component: "senses",
166
- event: "senses.bluebubbles_status_batcher_created",
167
- message: "status batcher initialized",
168
- meta: { delayMs },
169
- });
170
- let pending = [];
171
- let timer = null;
172
- function fire() {
173
- if (pending.length === 0)
174
- return;
175
- const combined = pending.join(" \u00b7 ");
176
- pending = [];
177
- timer = null;
178
- send(combined);
179
- }
180
- return {
181
- add(text) {
182
- pending.push(text);
183
- if (timer !== null)
184
- clearTimeout(timer);
185
- timer = setTimeout(fire, delayMs);
186
- },
187
- flush() {
188
- if (timer !== null) {
189
- clearTimeout(timer);
190
- timer = null;
191
- }
192
- fire();
193
- },
194
- };
195
- }
196
238
  function blueBubblesMessageKey(sessionKey, messageGuid) {
197
239
  return `${sessionKey}:${messageGuid.trim()}`;
198
240
  }
@@ -249,8 +291,9 @@ const defaultDeps = {
249
291
  buildSystem: prompt_1.buildSystem,
250
292
  runAgent: core_1.runAgent,
251
293
  loadSession: context_1.loadSession,
294
+ saveSession: context_1.saveSession,
252
295
  postTurnTrim: context_1.postTurnTrim,
253
- deferPostTurnPersist: context_1.deferPostTurnPersist,
296
+ postTurnPersist: context_1.postTurnPersist,
254
297
  sessionPath: config_1.sessionPath,
255
298
  accumulateFriendTokens: friends_1.accumulateFriendTokens,
256
299
  createClient: () => (0, client_1.createBlueBubblesClient)(),
@@ -259,6 +302,10 @@ const defaultDeps = {
259
302
  createFriendResolver: (store, params) => new friends_1.FriendResolver(store, params),
260
303
  createServer: http.createServer,
261
304
  getOwnHandles: () => [...(0, config_1.getBlueBubblesConfig)().ownHandles, ...discoveredOwnHandles],
305
+ promoteFailoverState: (sessionKey, state) => {
306
+ bbFailoverStates.set(sessionKey, structuredClone(state));
307
+ },
308
+ recordProcessed: processed_log_1.recordProcessedBlueBubblesMessage,
262
309
  };
263
310
  const BLUEBUBBLES_RUNTIME_SYNC_INTERVAL_MS = 30_000;
264
311
  const BLUEBUBBLES_RECOVERY_PASS_DELAY_MS = 1_000;
@@ -266,7 +313,6 @@ const BLUEBUBBLES_RECOVERY_PASS_INTERVAL_MS = 30_000;
266
313
  const BLUEBUBBLES_LIVE_TURN_TIMEOUT_MS = 2 * 60_000;
267
314
  const BLUEBUBBLES_RECOVERY_TURN_TIMEOUT_MS = 10 * 60_000;
268
315
  const BLUEBUBBLES_LIVE_TURN_STALLED_MS = 90_000;
269
- const BLUEBUBBLES_SILENCE_WATCHDOG_MS = 75_000;
270
316
  const BLUEBUBBLES_CALLBACK_CLEANUP_TIMEOUT_MS = 2_000;
271
317
  const BLUEBUBBLES_ACTIVITY_OPERATION_TIMEOUT_MS = 20_000;
272
318
  const BLUEBUBBLES_CATCHUP_PAGE_SIZE = 50;
@@ -280,32 +326,31 @@ class BlueBubblesRecoveryTurnTimeoutError extends Error {
280
326
  this.name = "BlueBubblesRecoveryTurnTimeoutError";
281
327
  }
282
328
  }
283
- function getBlueBubblesTimeoutReason(timeoutReason, timeoutMs) {
284
- /* v8 ignore next 3 -- timeoutReason is set atomically with recoveryTimedOut; fallback keeps the invariant fail-closed @preserve */
285
- if (!timeoutReason) {
286
- return new BlueBubblesRecoveryTurnTimeoutError(timeoutMs);
287
- }
288
- return timeoutReason;
289
- }
290
329
  class BlueBubblesActivityTimeoutError extends Error {
291
330
  constructor(operation, timeoutMs) {
292
331
  super(`bluebubbles ${operation} activity timed out after ${timeoutMs}ms`);
293
332
  this.name = "BlueBubblesActivityTimeoutError";
294
333
  }
295
334
  }
296
- function isBlueBubblesRecoveryTurnTimeout(error) {
297
- return error instanceof BlueBubblesRecoveryTurnTimeoutError
298
- || (error instanceof Error
299
- && error.name === "BlueBubblesRecoveryTurnTimeoutError"
300
- && error.message.startsWith("bluebubbles recovery turn timed out after "));
301
- }
302
- function withBlueBubblesActivityTimeout(operation, timeoutMs, task) {
335
+ function withBlueBubblesActivityTimeout(operation, timeoutMs, controller, task) {
303
336
  let timer = null;
337
+ controller.signal.throwIfAborted();
338
+ let removeAbortListener;
339
+ const aborted = new Promise((_, reject) => {
340
+ const rejectFromAbort = () => {
341
+ reject(controller.signal.reason);
342
+ };
343
+ controller.signal.addEventListener("abort", rejectFromAbort, { once: true });
344
+ removeAbortListener = () => controller.signal.removeEventListener("abort", rejectFromAbort);
345
+ });
304
346
  return Promise.race([
305
- task(),
347
+ Promise.resolve().then(() => task(controller.signal)),
348
+ aborted,
306
349
  new Promise((_, reject) => {
307
350
  timer = setTimeout(() => {
308
- reject(new BlueBubblesActivityTimeoutError(operation, timeoutMs));
351
+ const error = new BlueBubblesActivityTimeoutError(operation, timeoutMs);
352
+ controller.abort(error);
353
+ reject(error);
309
354
  }, timeoutMs);
310
355
  /* v8 ignore next -- timer handles expose unref only in some runtimes @preserve */
311
356
  if (typeof timer.unref === "function") {
@@ -313,6 +358,7 @@ function withBlueBubblesActivityTimeout(operation, timeoutMs, task) {
313
358
  }
314
359
  }),
315
360
  ]).finally(() => {
361
+ removeAbortListener();
316
362
  /* v8 ignore next -- timer is assigned unless a callback task throws synchronously; callback tasks are async @preserve */
317
363
  if (timer !== null)
318
364
  clearTimeout(timer);
@@ -339,7 +385,7 @@ async function waitForBlueBubblesCallbackCleanup(queue, timeoutMs, meta) {
339
385
  clearTimeout(timer);
340
386
  });
341
387
  if (!timedOut)
342
- return;
388
+ return true;
343
389
  (0, runtime_1.emitNervesEvent)({
344
390
  level: "warn",
345
391
  component: "senses",
@@ -347,13 +393,13 @@ async function waitForBlueBubblesCallbackCleanup(queue, timeoutMs, meta) {
347
393
  message: "bluebubbles callback cleanup timed out; releasing live turn lane",
348
394
  meta: { ...meta, timeoutMs },
349
395
  });
396
+ return false;
350
397
  }
351
398
  function resolveFriendParams(event) {
352
399
  if (event.chat.isGroup) {
353
- const groupKey = event.chat.chatGuid ?? event.chat.chatIdentifier ?? event.sender.externalId;
354
400
  return {
355
401
  provider: "imessage-handle",
356
- externalId: `group:${groupKey}`,
402
+ externalId: `group:${event.chat.chatGuid}`,
357
403
  displayName: event.chat.displayName ?? "Unknown Group",
358
404
  channel: "bluebubbles",
359
405
  };
@@ -366,8 +412,7 @@ function resolveFriendParams(event) {
366
412
  };
367
413
  }
368
414
  function resolveGroupExternalId(event) {
369
- const groupKey = event.chat.chatGuid ?? event.chat.chatIdentifier ?? event.sender.externalId;
370
- return `group:${groupKey}`;
415
+ return `group:${event.chat.chatGuid}`;
371
416
  }
372
417
  /**
373
418
  * Check if any participant in a group chat is a known family member.
@@ -376,7 +421,7 @@ function resolveGroupExternalId(event) {
376
421
  async function checkGroupHasFamilyMember(store, event) {
377
422
  if (!event.chat.isGroup)
378
423
  return false;
379
- for (const handle of event.chat.participantHandles ?? []) {
424
+ for (const handle of event.chat.participantHandles) {
380
425
  const friend = await store.findByExternalId("imessage-handle", handle);
381
426
  if (friend?.trustLevel === "family")
382
427
  return true;
@@ -477,8 +522,6 @@ function buildBlueBubblesOrientationSource(event, existingMessages, evidence, co
477
522
  externalId: participant.externalId,
478
523
  displayName: participant.displayName,
479
524
  }));
480
- const targetGuid = evidence.targetGuid;
481
- const targetAuthorship = evidence.targetAuthorship;
482
525
  const presentation = {
483
526
  authority: "presentation_only",
484
527
  conversationKind,
@@ -495,23 +538,7 @@ function buildBlueBubblesOrientationSource(event, existingMessages, evidence, co
495
538
  displayName: actor.displayName,
496
539
  },
497
540
  participants,
498
- ...(targetGuid
499
- ? {
500
- target: {
501
- messageGuid: targetGuid,
502
- authorship: targetAuthorship ?? "unknown",
503
- },
504
- }
505
- : {}),
506
541
  };
507
- if (event.kind !== "message") {
508
- return {
509
- kind: "bluebubbles",
510
- ...presentation,
511
- lane: "mutation",
512
- ...(repairNotice ? { repairNotice } : {}),
513
- };
514
- }
515
542
  const summaries = extractHistoricalLaneSummary(existingMessages);
516
543
  const threadId = event.threadOriginatorGuid?.trim() || undefined;
517
544
  const hasThreadRoute = !!threadId || summaries.some((summary) => summary.key.startsWith("thread:"));
@@ -534,14 +561,11 @@ function buildInboundText(event) {
534
561
  if (!event.chat.isGroup) {
535
562
  return baseText;
536
563
  }
537
- if (event.kind === "mutation") {
538
- return `${event.sender.displayName} ${baseText}`;
539
- }
540
564
  return `${event.sender.displayName}: ${baseText}`;
541
565
  }
542
566
  function buildInboundContent(event) {
543
567
  const text = buildInboundText(event);
544
- if (event.kind !== "message" || !event.inputPartsForAgent || event.inputPartsForAgent.length === 0) {
568
+ if (!event.inputPartsForAgent || event.inputPartsForAgent.length === 0) {
545
569
  return text;
546
570
  }
547
571
  return [
@@ -560,8 +584,6 @@ function sessionLikelyContainsMessage(event, existingMessages) {
560
584
  });
561
585
  }
562
586
  function getBlueBubblesContinuityIngressTexts(event) {
563
- if (event.kind !== "message")
564
- return [];
565
587
  const text = event.textForAgent.trim();
566
588
  if (text.length > 0)
567
589
  return [text];
@@ -629,10 +651,11 @@ function recordBlueBubblesRecoveryFailureForBudget(agentName, event, source) {
629
651
  // Autonomy failure accounting must not turn a recovery error into a broader sense outage.
630
652
  }
631
653
  }
632
- function insertEphemeralContextMessages(messages, contextMessages) {
654
+ function insertEphemeralContextMessages(messages, contextMessages, currentUserMessage) {
633
655
  if (contextMessages.length === 0)
634
656
  return messages;
635
- const insertionIndex = Math.max(0, messages.length - 1);
657
+ const requiredCurrentIndex = currentUserMessage ? messages.indexOf(currentUserMessage) : -1;
658
+ const insertionIndex = requiredCurrentIndex >= 0 ? requiredCurrentIndex : Math.max(0, messages.length - 1);
636
659
  return [
637
660
  ...messages.slice(0, insertionIndex),
638
661
  ...contextMessages,
@@ -646,23 +669,41 @@ function knownProviderMessageTexts(messages) {
646
669
  .filter((text) => text.length > 0);
647
670
  }
648
671
  async function buildBlueBubblesContextMessages(input) {
649
- if (input.event.kind !== "message")
650
- return { messages: [], contextPacketIds: [] };
651
672
  const event = input.event;
652
673
  if (!Number.isFinite(event.timestamp))
653
674
  return { messages: [], contextPacketIds: [] };
654
- const chatGuidHash = (0, context_packet_1.blueBubblesContextChatKeyHash)(event);
655
- const anchorTimestamp = new Date(event.timestamp).toISOString();
675
+ let result;
656
676
  try {
657
- const result = await (0, context_packet_1.buildBlueBubblesContextPacket)({
677
+ result = await (0, context_packet_1.buildBlueBubblesContextPacket)({
658
678
  agentName: input.agentName,
659
679
  client: input.client,
660
680
  event,
661
- knownMessageTexts: knownProviderMessageTexts(input.knownMessages ?? []),
681
+ knownMessageTexts: knownProviderMessageTexts(input.knownMessages),
662
682
  });
663
- if (!result)
664
- return { messages: [], contextPacketIds: [] };
665
- const { packet, rendered, historyCount } = result;
683
+ }
684
+ catch (error) {
685
+ (0, runtime_1.emitNervesEvent)({
686
+ level: "warn",
687
+ component: "senses",
688
+ event: "senses.bluebubbles_context_packet_error",
689
+ message: "failed to build live bluebubbles context packet",
690
+ meta: {
691
+ messageGuid: event.messageGuid,
692
+ reason: error instanceof Error ? error.message : String(error),
693
+ },
694
+ });
695
+ return { messages: [], contextPacketIds: [] };
696
+ }
697
+ if (!result)
698
+ return { messages: [], contextPacketIds: [] };
699
+ const { packet, optionalRendered, verifiedPredecessorMessage, historyCount } = result;
700
+ const prepared = {
701
+ messages: optionalRendered
702
+ ? [{ role: "system", content: optionalRendered.text }]
703
+ : [],
704
+ verifiedPredecessorMessage,
705
+ };
706
+ try {
666
707
  (0, context_packet_ledger_1.writeSenseContextPacket)(input.agentRoot, packet);
667
708
  (0, runtime_1.emitNervesEvent)({
668
709
  component: "senses",
@@ -675,7 +716,7 @@ async function buildBlueBubblesContextMessages(input) {
675
716
  },
676
717
  });
677
718
  return {
678
- messages: [{ role: "system", content: rendered.text }],
719
+ ...prepared,
679
720
  contextPacketIds: [packet.packetId],
680
721
  };
681
722
  }
@@ -690,30 +731,16 @@ async function buildBlueBubblesContextMessages(input) {
690
731
  reason: error instanceof Error ? error.message : String(error),
691
732
  },
692
733
  });
693
- const fallback = (0, context_packet_ledger_1.readLatestVisibleSenseContextPacket)(input.agentRoot, {
694
- sense: "bluebubbles",
695
- chatKeyHash: chatGuidHash,
696
- beforeAnchorTimestamp: anchorTimestamp,
697
- maxAgeMs: 24 * 60 * 60 * 1000,
698
- });
699
- if (!fallback)
700
- return { messages: [], contextPacketIds: [] };
701
- const rendered = (0, context_packets_1.renderSenseContextPacketForPrompt)(fallback);
702
- return {
703
- messages: [{ role: "system", content: rendered.text }],
704
- contextPacketIds: [fallback.packetId],
705
- };
734
+ return { ...prepared, contextPacketIds: [] };
706
735
  }
707
736
  }
708
737
  function createReplyTargetController(event) {
709
- const defaultTargetLabel = event.kind === "message" && event.threadOriginatorGuid?.trim() ? "current_lane" : "top_level";
710
- let selection = event.kind === "message" && event.threadOriginatorGuid?.trim()
738
+ const defaultTargetLabel = event.threadOriginatorGuid?.trim() ? "current_lane" : "top_level";
739
+ let selection = event.threadOriginatorGuid?.trim()
711
740
  ? { target: "current_lane" }
712
741
  : { target: "top_level" };
713
742
  return {
714
743
  getReplyToMessageGuid() {
715
- if (event.kind !== "message")
716
- return undefined;
717
744
  if (selection.target === "top_level")
718
745
  return undefined;
719
746
  if (selection.target === "thread")
@@ -747,10 +774,12 @@ function emitBlueBubblesMarkReadWarning(chat, error) {
747
774
  function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVisibleActivity, options = {}) {
748
775
  let textBuffer = "";
749
776
  let typingActive = false;
777
+ let typingCouldBeActive = false;
750
778
  let queue = Promise.resolve();
751
- let lastVisibleActivityMs = Date.now();
752
- let silenceWatchdog = null;
753
779
  let outboundClosed = false;
780
+ let nextCleanupToken = 0;
781
+ const activeCleanupTokens = new Set();
782
+ const activeActivityControllers = new Set();
754
783
  // Per-turn outward-send dedupe. A single createBlueBubblesCallbacks lifetime
755
784
  // serves one inbound turn, so collapsing identical outward bodies inside
756
785
  // this closure is scoped tightly: each fresh inbound turn starts with an
@@ -769,13 +798,37 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
769
798
  // check catches near-duplicates from the same turn.
770
799
  const sentOutwardTextNorms = new Set();
771
800
  const sentOutwardTokenSets = [];
772
- function enqueue(operation, task, options = {}) {
773
- /* v8 ignore next -- defensive guard: public callback entrypoints already drop after outbound close @preserve */
774
- if (outboundClosed && !options.allowAfterClose)
775
- return;
801
+ async function canRunOutbound() {
802
+ if (outboundClosed)
803
+ return false;
804
+ if (options.isOutboundCurrent && !options.isOutboundCurrent())
805
+ return false;
806
+ if (options.admitOutbound && !await options.admitOutbound())
807
+ return false;
808
+ if (outboundClosed)
809
+ return false;
810
+ if (options.isOutboundCurrent && !options.isOutboundCurrent())
811
+ return false;
812
+ return true;
813
+ }
814
+ function enqueue(operation, task, enqueueOptions = {}) {
815
+ const cleanupToken = enqueueOptions.cleanupToken;
816
+ const controller = new AbortController();
817
+ activeActivityControllers.add(controller);
776
818
  queue = queue
777
- .then(() => withBlueBubblesActivityTimeout(operation, BLUEBUBBLES_ACTIVITY_OPERATION_TIMEOUT_MS, task))
819
+ .then(async () => {
820
+ if (cleanupToken === undefined) {
821
+ if (!await canRunOutbound())
822
+ return;
823
+ }
824
+ else if (!activeCleanupTokens.has(cleanupToken)) {
825
+ return;
826
+ }
827
+ await withBlueBubblesActivityTimeout(operation, BLUEBUBBLES_ACTIVITY_OPERATION_TIMEOUT_MS, controller, task);
828
+ })
778
829
  .catch((error) => {
830
+ if (controller.signal.aborted && !(error instanceof BlueBubblesActivityTimeoutError))
831
+ return;
779
832
  (0, runtime_1.emitNervesEvent)({
780
833
  level: "warn",
781
834
  component: "senses",
@@ -783,8 +836,30 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
783
836
  message: "bluebubbles activity transport failed",
784
837
  meta: { operation, reason: error instanceof Error ? error.message : String(error) },
785
838
  });
839
+ })
840
+ .finally(() => {
841
+ activeActivityControllers.delete(controller);
786
842
  });
787
843
  }
844
+ function abortActiveActivity(reason) {
845
+ for (const controller of activeActivityControllers) {
846
+ controller.abort(new Error(reason));
847
+ }
848
+ }
849
+ function enqueueTypingStop() {
850
+ typingActive = false;
851
+ const cleanupToken = ++nextCleanupToken;
852
+ activeCleanupTokens.add(cleanupToken);
853
+ enqueue("typing_stop", async (signal) => {
854
+ try {
855
+ await client.setTyping(chat, false, signal);
856
+ typingCouldBeActive = false;
857
+ }
858
+ finally {
859
+ activeCleanupTokens.delete(cleanupToken);
860
+ }
861
+ }, { cleanupToken });
862
+ }
788
863
  function startTypingNow() {
789
864
  /* v8 ignore next -- defensive guard: callers already check typingActive @preserve */
790
865
  if (typingActive)
@@ -793,10 +868,11 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
793
868
  if (outboundClosed)
794
869
  return;
795
870
  typingActive = true;
796
- enqueue("typing_start", async () => {
871
+ typingCouldBeActive = true;
872
+ enqueue("typing_start", async (signal) => {
797
873
  const [markReadResult, typingResult] = await Promise.allSettled([
798
- client.markChatRead(chat),
799
- client.setTyping(chat, true),
874
+ client.markChatRead(chat, signal),
875
+ client.setTyping(chat, true, signal),
800
876
  ]);
801
877
  if (markReadResult.status === "rejected") {
802
878
  emitBlueBubblesMarkReadWarning(chat, markReadResult.reason);
@@ -807,7 +883,6 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
807
883
  });
808
884
  }
809
885
  function recordVisibleActivity() {
810
- lastVisibleActivityMs = Date.now();
811
886
  onVisibleActivity?.();
812
887
  }
813
888
  function isDuplicateOutwardText(trimmed) {
@@ -848,62 +923,11 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
848
923
  },
849
924
  });
850
925
  }
851
- function stopSilenceWatchdog() {
852
- if (silenceWatchdog === null)
853
- return;
854
- clearInterval(silenceWatchdog);
855
- silenceWatchdog = null;
856
- }
857
- function startSilenceWatchdog() {
858
- if (silenceWatchdog !== null)
859
- return;
860
- silenceWatchdog = setInterval(() => {
861
- if (Date.now() - lastVisibleActivityMs < BLUEBUBBLES_SILENCE_WATCHDOG_MS)
862
- return;
863
- sendStatus("still working on this...");
864
- }, BLUEBUBBLES_SILENCE_WATCHDOG_MS);
865
- /* v8 ignore next -- timer handles expose unref only in some runtimes @preserve */
866
- if (typeof silenceWatchdog.unref === "function") {
867
- silenceWatchdog.unref();
868
- }
869
- }
870
- function sendStatus(text) {
871
- /* v8 ignore next -- defensive guard: cancelOutbound stops the only status caller before it can fire @preserve */
872
- if (outboundClosed)
873
- return;
874
- const trimmed = text.trim();
875
- /* v8 ignore next -- defensive guard; current status callers always provide non-empty text @preserve */
876
- if (!trimmed)
877
- return;
878
- // Status surfaces share the per-turn dedupe set so a status-style
879
- // outward message (tool description, error notice, watchdog ping)
880
- // can't deliver a near-duplicate of an already-sent answer or status
881
- // (post-#699 evidence: evt-001820 + evt-001821 + evt-001823 on
882
- // 2026-05-09 — overlapping status surface updates about the same
883
- // duplicate issue, which the flushNow/flush-only guard could not catch
884
- // because sendStatus writes directly via client.sendText).
885
- if (isDuplicateOutwardText(trimmed)) {
886
- emitDuplicateOutwardSuppressed("status", trimmed.length);
887
- return;
888
- }
889
- enqueue("send_status", async () => {
890
- await client.sendText({
891
- chat,
892
- text: trimmed,
893
- replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
894
- });
895
- recordVisibleActivity();
896
- // Re-enable typing indicator — sending a message clears the typing bubble
897
- await client.setTyping(chat, true);
898
- });
899
- }
900
926
  return {
901
927
  settleOutputMode: "retractable_buffer",
902
928
  onModelStart() {
903
929
  if (outboundClosed)
904
930
  return;
905
- if (options.enableSilenceWatchdog !== false)
906
- startSilenceWatchdog();
907
931
  if (options.enableActivitySignals !== false && !isGroupChat)
908
932
  startTypingNow();
909
933
  (0, runtime_1.emitNervesEvent)({
@@ -934,8 +958,8 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
934
958
  return;
935
959
  // Tool activity is a reply commitment, but iMessage is not a tool-progress
936
960
  // console. Keep the human-facing thread quiet until the agent has real
937
- // text (or the generic silence watchdog fires), while preserving native
938
- // read/typing signals and nerves telemetry for debugging.
961
+ // text, while preserving native read/typing signals and nerves telemetry
962
+ // for debugging.
939
963
  if (options.enableActivitySignals !== false && !typingActive && name !== "observe" && name !== "speak")
940
964
  startTypingNow();
941
965
  (0, runtime_1.emitNervesEvent)({
@@ -993,6 +1017,8 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
993
1017
  emitDuplicateOutwardSuppressed("flushNow", trimmed.length);
994
1018
  return;
995
1019
  }
1020
+ if (!await canRunOutbound())
1021
+ return;
996
1022
  await client.sendText({
997
1023
  chat,
998
1024
  text: trimmed,
@@ -1015,23 +1041,21 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
1015
1041
  operation: "flush_after_close",
1016
1042
  chatGuid: chat.chatGuid ?? null,
1017
1043
  });
1018
- return;
1044
+ return { status: "not_invoked", reason: "closed" };
1019
1045
  }
1020
1046
  await queue;
1021
1047
  const trimmed = textBuffer.trim();
1022
1048
  if (!trimmed) {
1023
1049
  if (typingActive) {
1024
- typingActive = false;
1025
- enqueue("typing_stop", async () => { await client.setTyping(chat, false); }, { allowAfterClose: true });
1050
+ enqueueTypingStop();
1026
1051
  await queue;
1027
1052
  }
1028
- return;
1053
+ return { status: "not_invoked", reason: "empty" };
1029
1054
  }
1030
1055
  textBuffer = "";
1031
1056
  /* v8 ignore next 4 -- branch: typing may already be stopped before flush @preserve */
1032
1057
  if (typingActive) {
1033
- typingActive = false;
1034
- enqueue("typing_stop", async () => { await client.setTyping(chat, false); }, { allowAfterClose: true });
1058
+ enqueueTypingStop();
1035
1059
  await queue;
1036
1060
  }
1037
1061
  if ((0, bluebubbles_meta_guard_1.containsInternalMetaMarkers)(trimmed)) {
@@ -1043,35 +1067,181 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat, onVi
1043
1067
  messageLength: trimmed.length,
1044
1068
  },
1045
1069
  });
1046
- return;
1070
+ return { status: "not_invoked", reason: "blocked_meta" };
1047
1071
  }
1048
1072
  if (isDuplicateOutwardText(trimmed)) {
1049
1073
  emitDuplicateOutwardSuppressed("flush", trimmed.length);
1050
- return;
1074
+ return { status: "not_invoked", reason: "duplicate" };
1051
1075
  }
1052
- await client.sendText({
1053
- chat,
1054
- text: trimmed,
1055
- replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
1056
- ...(flushOptions.signal ? { signal: flushOptions.signal } : {}),
1057
- });
1076
+ if (!await canRunOutbound())
1077
+ return { status: "not_invoked", reason: "not_current" };
1078
+ const replyToMessageGuid = replyTarget.getReplyToMessageGuid();
1079
+ const durableOutbound = options.durableOutbound;
1080
+ const durableTempGuid = durableOutbound
1081
+ ? `ouro-inbound-${(0, node_crypto_1.createHash)("sha256")
1082
+ .update(durableOutbound.idempotencyKey)
1083
+ .digest("hex")
1084
+ .slice(0, 32)}`
1085
+ : null;
1086
+ const boundaryState = { durableRecord: null, denialReason: "not_current" };
1087
+ const admitFinalTransport = () => {
1088
+ if (outboundClosed
1089
+ || (options.isOutboundCurrent && !options.isOutboundCurrent())
1090
+ || (options.isOutboundAdmittedNow && !options.isOutboundAdmittedNow())) {
1091
+ boundaryState.denialReason = "not_current";
1092
+ return false;
1093
+ }
1094
+ if (!durableOutbound || boundaryState.durableRecord)
1095
+ return true;
1096
+ const reservation = (0, outbound_state_1.reserveBlueBubblesOutbound)({
1097
+ agentRoot: durableOutbound.agentRoot,
1098
+ idempotencyKey: durableOutbound.idempotencyKey,
1099
+ chat,
1100
+ attachment: durableOutbound.attachment,
1101
+ text: trimmed,
1102
+ tempGuid: durableTempGuid,
1103
+ ...(replyToMessageGuid ? { replyToMessageGuid } : {}),
1104
+ });
1105
+ if (reservation.status !== "reserved") {
1106
+ boundaryState.denialReason = "durable_duplicate";
1107
+ return false;
1108
+ }
1109
+ boundaryState.durableRecord = reservation.record;
1110
+ return true;
1111
+ };
1112
+ const markDurableFailure = (error) => {
1113
+ const durableRecord = boundaryState.durableRecord;
1114
+ if (!durableRecord || !durableOutbound)
1115
+ return;
1116
+ try {
1117
+ (0, outbound_state_1.markBlueBubblesOutboundFailed)({
1118
+ agentRoot: durableOutbound.agentRoot,
1119
+ recordId: durableRecord.recordId,
1120
+ failedAt: new Date().toISOString(),
1121
+ reason: error instanceof Error ? error.message : String(error),
1122
+ });
1123
+ }
1124
+ catch (stateError) {
1125
+ (0, runtime_1.emitNervesEvent)({
1126
+ level: "error",
1127
+ component: "senses",
1128
+ event: "senses.bluebubbles_outbound_state_error",
1129
+ message: "failed to record bluebubbles outbound transport failure",
1130
+ meta: {
1131
+ recordId: durableRecord.recordId,
1132
+ reason: String(stateError),
1133
+ },
1134
+ });
1135
+ }
1136
+ };
1137
+ let response;
1138
+ while (true) {
1139
+ try {
1140
+ flushOptions.signal?.throwIfAborted();
1141
+ response = await client.sendText({
1142
+ chat,
1143
+ text: trimmed,
1144
+ replyToMessageGuid,
1145
+ ...(durableTempGuid ? { tempGuid: durableTempGuid } : {}),
1146
+ ...(flushOptions.signal ? { signal: flushOptions.signal } : {}),
1147
+ beforeTransportInvocation: admitFinalTransport,
1148
+ onTransportInvocation: options.onFinalTransportInvoked,
1149
+ });
1150
+ break;
1151
+ }
1152
+ catch (error) {
1153
+ if (error instanceof client_1.BlueBubblesSendError
1154
+ && error.transportInvoked === false
1155
+ && error.errorCode === "admission_denied") {
1156
+ if (boundaryState.denialReason === "durable_duplicate") {
1157
+ return { status: "not_invoked", reason: "durable_duplicate" };
1158
+ }
1159
+ // A newer observation may be only a temporary fence (duplicate,
1160
+ // audit-only, or otherwise non-promoting). Re-await admission and
1161
+ // retry the same durable reservation if this turn remains current.
1162
+ if (!await canRunOutbound()) {
1163
+ markDurableFailure(error);
1164
+ return { status: "not_invoked", reason: "not_current" };
1165
+ }
1166
+ continue;
1167
+ }
1168
+ markDurableFailure(error);
1169
+ throw error;
1170
+ }
1171
+ }
1172
+ const durableRecord = boundaryState.durableRecord;
1173
+ if (durableRecord && durableOutbound) {
1174
+ try {
1175
+ (0, outbound_state_1.markBlueBubblesOutboundAccepted)({
1176
+ agentRoot: durableOutbound.agentRoot,
1177
+ recordId: durableRecord.recordId,
1178
+ acceptedAt: new Date().toISOString(),
1179
+ ...(response.messageGuid ? { messageGuid: response.messageGuid } : {}),
1180
+ });
1181
+ }
1182
+ catch (stateError) {
1183
+ (0, runtime_1.emitNervesEvent)({
1184
+ level: "error",
1185
+ component: "senses",
1186
+ event: "senses.bluebubbles_outbound_state_error",
1187
+ message: "failed to record accepted bluebubbles outbound transport",
1188
+ meta: {
1189
+ recordId: durableRecord.recordId,
1190
+ reason: String(stateError),
1191
+ },
1192
+ });
1193
+ }
1194
+ }
1195
+ options.onFinalTransportAccepted?.();
1058
1196
  recordVisibleActivity();
1197
+ return { status: "accepted" };
1059
1198
  },
1060
1199
  async finish(options = {}) {
1061
- stopSilenceWatchdog();
1062
- if (typingActive) {
1063
- typingActive = false;
1064
- enqueue("typing_stop", async () => { await client.setTyping(chat, false); }, { allowAfterClose: true });
1200
+ const cleanupTimeoutMs = options.timeoutMs ?? BLUEBUBBLES_CALLBACK_CLEANUP_TIMEOUT_MS;
1201
+ const needsTypingStop = typingActive;
1202
+ if (needsTypingStop) {
1203
+ enqueueTypingStop();
1065
1204
  }
1066
- await waitForBlueBubblesCallbackCleanup(queue, options.timeoutMs ?? BLUEBUBBLES_CALLBACK_CLEANUP_TIMEOUT_MS, {
1205
+ const drained = await waitForBlueBubblesCallbackCleanup(queue, cleanupTimeoutMs, {
1067
1206
  operation: "finish",
1068
1207
  chatGuid: chat.chatGuid ?? null,
1069
1208
  });
1209
+ if (!drained) {
1210
+ // The queued start/read request may outlive this turn's bounded lane.
1211
+ // Invalidate its deferred stop, then issue one stop request now while A
1212
+ // still owns the canonical lock so B cannot start typing ahead of it.
1213
+ activeCleanupTokens.clear();
1214
+ abortActiveActivity("bluebubbles activity superseded by bounded cleanup");
1215
+ await waitForBlueBubblesCallbackCleanup(queue, cleanupTimeoutMs, {
1216
+ operation: "finish_after_activity_abort",
1217
+ chatGuid: chat.chatGuid ?? null,
1218
+ });
1219
+ }
1220
+ if (typingCouldBeActive) {
1221
+ const fallbackController = new AbortController();
1222
+ try {
1223
+ await withBlueBubblesActivityTimeout("typing_stop_fallback", cleanupTimeoutMs, fallbackController, (signal) => client.setTyping(chat, false, signal));
1224
+ typingCouldBeActive = false;
1225
+ }
1226
+ catch (error) {
1227
+ (0, runtime_1.emitNervesEvent)({
1228
+ level: "warn",
1229
+ component: "senses",
1230
+ event: "senses.bluebubbles_activity_error",
1231
+ message: "bluebubbles activity transport failed",
1232
+ meta: {
1233
+ operation: "typing_stop_fallback",
1234
+ reason: error instanceof Error ? error.message : String(error),
1235
+ },
1236
+ });
1237
+ }
1238
+ }
1239
+ activeCleanupTokens.clear();
1070
1240
  },
1071
1241
  cancelOutbound(reason) {
1072
1242
  outboundClosed = true;
1073
1243
  textBuffer = "";
1074
- stopSilenceWatchdog();
1244
+ abortActiveActivity(`bluebubbles outbound ${reason}`);
1075
1245
  (0, runtime_1.emitNervesEvent)({
1076
1246
  level: "warn",
1077
1247
  component: "senses",
@@ -1204,62 +1374,98 @@ async function shouldFilterAgentSelfHandle(event, resolvedDeps) {
1204
1374
  }
1205
1375
  return true;
1206
1376
  }
1207
- function hasObservedBlueBubblesDirection(event) {
1208
- return typeof event.fromMe === "boolean";
1377
+ function createBlueBubblesTurnStage(sessionKey) {
1378
+ return {
1379
+ failoverState: structuredClone(bbFailoverStates.get(sessionKey) ?? { pending: null }),
1380
+ };
1209
1381
  }
1210
- async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, options) {
1211
- const client = resolvedDeps.createClient();
1212
- const agentName = resolvedDeps.getAgentName();
1213
- (0, runtime_cwd_1.recoverRuntimeCwd)();
1214
- if (event.fromMe) {
1215
- (0, runtime_1.emitNervesEvent)({
1216
- component: "senses",
1217
- event: "senses.bluebubbles_from_me_ignored",
1218
- message: "ignored from-me bluebubbles event",
1219
- meta: {
1220
- messageGuid: event.messageGuid,
1221
- kind: event.kind,
1222
- },
1223
- });
1224
- if (event.chat.isGroup)
1225
- recordDiscoveredOwnHandle(event.sender.externalId);
1226
- return { handled: true, notifiedAgent: false, kind: event.kind, reason: "from_me" };
1227
- }
1228
- // Fallback self-detection: BlueBubbles sometimes broadcasts a group-chat
1229
- // outbound message back through the webhook with `isFromMe` missing/false.
1230
- // Without this guard the agent ingests its own message and replies to it
1231
- // ("the agent talking to itself"). Compare the sender's externalId against
1232
- // the agent's known iMessage handles. Keep this group-only: 1:1 outbound
1233
- // echoes can be attributed to the peer handle, and stale ownHandles entries
1234
- // must not make real DMs disappear.
1235
- if (await shouldFilterAgentSelfHandle(event, resolvedDeps)) {
1382
+ function persistAcceptedBlueBubblesProjection(input) {
1383
+ const { stage, deps, event } = input;
1384
+ if (stage.postTurn) {
1385
+ try {
1386
+ deps.postTurnPersist(stage.postTurn.sessionPath, stage.postTurn.prepared, stage.postTurn.usage, stage.postTurn.state);
1387
+ }
1388
+ catch (error) {
1389
+ (0, runtime_1.emitNervesEvent)({
1390
+ level: "error",
1391
+ component: "senses",
1392
+ event: "senses.bluebubbles_accepted_projection_failed",
1393
+ message: "accepted bluebubbles reply could not be projected into canonical session history",
1394
+ meta: {
1395
+ messageGuid: event.messageGuid,
1396
+ sessionKey: event.chat.sessionKey,
1397
+ reason: error instanceof Error ? error.message : String(error),
1398
+ },
1399
+ });
1400
+ }
1401
+ }
1402
+ }
1403
+ function promoteAcceptedBlueBubblesAncillaryState(input) {
1404
+ const { stage, deps, agentName, event, source } = input;
1405
+ try {
1406
+ deps.promoteFailoverState(event.chat.sessionKey, stage.failoverState);
1407
+ }
1408
+ catch (error) {
1236
1409
  (0, runtime_1.emitNervesEvent)({
1237
1410
  level: "warn",
1238
1411
  component: "senses",
1239
- event: "senses.bluebubbles_self_handle_filtered",
1240
- message: "filtered bluebubbles event whose sender matched an agent-owned handle (isFromMe was missing/false)",
1412
+ event: "senses.bluebubbles_failover_state_promotion_failed",
1413
+ message: "accepted bluebubbles reply could not promote staged failover state",
1241
1414
  meta: {
1242
1415
  messageGuid: event.messageGuid,
1243
- kind: event.kind,
1244
- senderExternalId: event.sender.externalId,
1416
+ sessionKey: event.chat.sessionKey,
1417
+ reason: error instanceof Error ? error.message : String(error),
1245
1418
  },
1246
1419
  });
1247
- return { handled: true, notifiedAgent: false, kind: event.kind, reason: "from_me" };
1248
1420
  }
1249
- if (event.kind === "mutation" && !event.shouldNotifyAgent) {
1421
+ if (stage.tokens) {
1422
+ const emitTokenPromotionFailure = (error) => {
1423
+ (0, runtime_1.emitNervesEvent)({
1424
+ level: "warn",
1425
+ component: "senses",
1426
+ event: "senses.bluebubbles_token_promotion_failed",
1427
+ message: "accepted bluebubbles reply could not promote staged token usage",
1428
+ meta: {
1429
+ messageGuid: event.messageGuid,
1430
+ sessionKey: event.chat.sessionKey,
1431
+ reason: error instanceof Error ? error.message : String(error),
1432
+ },
1433
+ });
1434
+ };
1435
+ try {
1436
+ void deps.accumulateFriendTokens(stage.tokens.store, stage.tokens.friendId, stage.tokens.usage).catch(emitTokenPromotionFailure);
1437
+ }
1438
+ catch (error) {
1439
+ emitTokenPromotionFailure(error);
1440
+ }
1441
+ }
1442
+ try {
1443
+ deps.recordProcessed(agentName, event, source, input.processedOutcome);
1444
+ }
1445
+ catch (error) {
1250
1446
  (0, runtime_1.emitNervesEvent)({
1447
+ level: "warn",
1251
1448
  component: "senses",
1252
- event: "senses.bluebubbles_state_mutation_recorded",
1253
- message: "recorded non-notify bluebubbles mutation",
1449
+ event: "senses.bluebubbles_processed_promotion_failed",
1450
+ message: "accepted bluebubbles reply could not promote processed sidecar state",
1254
1451
  meta: {
1255
1452
  messageGuid: event.messageGuid,
1256
- mutationType: event.mutationType,
1453
+ sessionKey: event.chat.sessionKey,
1454
+ reason: error instanceof Error ? error.message : String(error),
1257
1455
  },
1258
1456
  });
1259
- return { handled: true, notifiedAgent: false, kind: event.kind, reason: "mutation_state_only" };
1260
1457
  }
1458
+ }
1459
+ function hasObservedBlueBubblesDirection(event) {
1460
+ return typeof event.fromMe === "boolean";
1461
+ }
1462
+ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, options) {
1463
+ options.lifecycleSignal?.throwIfAborted();
1464
+ const client = resolvedDeps.createClient();
1465
+ const agentName = resolvedDeps.getAgentName();
1466
+ (0, runtime_cwd_1.recoverRuntimeCwd)();
1261
1467
  let activeTurnId = null;
1262
- if (event.kind === "message" && shouldUseBlueBubblesRecoveryBudget(source, options)) {
1468
+ if (shouldUseBlueBubblesRecoveryBudget(source, options)) {
1263
1469
  const decision = (0, autonomy_budget_1.reserveAutonomyBudget)((0, identity_1.getAgentRoot)(agentName), {
1264
1470
  agent: agentName,
1265
1471
  triggerType: "recovery",
@@ -1275,66 +1481,100 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1275
1481
  }
1276
1482
  }
1277
1483
  try {
1278
- // ── Adapter setup: friend, session, content, callbacks ──────────
1279
- const store = resolvedDeps.createFriendStore();
1280
- const resolver = resolvedDeps.createFriendResolver(store, resolveFriendParams(event));
1281
- const baseContext = await resolver.resolve();
1282
- const context = { ...baseContext, isGroupChat: event.chat.isGroup };
1283
- const replyTarget = createReplyTargetController(event);
1284
- const friendId = context.friend.id;
1285
- const sessPath = resolvedDeps.sessionPath(friendId, "bluebubbles", event.chat.sessionKey);
1286
- try {
1287
- (0, session_cleanup_1.findObsoleteBlueBubblesThreadSessions)(sessPath);
1288
- }
1289
- catch (error) {
1290
- (0, runtime_1.emitNervesEvent)({
1291
- level: "warn",
1292
- component: "senses",
1293
- event: "senses.bluebubbles_thread_lane_cleanup_error",
1294
- message: "failed to inspect obsolete bluebubbles thread-lane sessions",
1295
- meta: {
1296
- sessionPath: sessPath,
1297
- reason: error instanceof Error ? error.message : String(error),
1298
- },
1299
- });
1300
- }
1301
- return await (0, turn_coordinator_1.withSharedTurnLock)("bluebubbles", sessPath, async () => {
1302
- if (event.kind === "message" && activeTurnId === null) {
1303
- activeTurnId = (0, active_turns_1.beginBlueBubblesActiveTurn)(agentName, event);
1484
+ // Reserve the canonical chat lane before any asynchronous adapter setup.
1485
+ // Every admitted inbound is projected in observation order even when a
1486
+ // newer turn supersedes its inference before provider work begins.
1487
+ return await (0, turn_coordinator_1.withSharedTurnLock)("bluebubbles", `${agentName}:${event.chat.sessionKey}`, async () => {
1488
+ options.lifecycleSignal?.throwIfAborted();
1489
+ // Fallback self-detection: BlueBubbles sometimes broadcasts a group-chat
1490
+ // outbound message back through the webhook with `isFromMe` missing/false.
1491
+ // Keep this group-only: stale ownHandles entries must not hide real DMs.
1492
+ if (await shouldFilterAgentSelfHandle(event, resolvedDeps)) {
1493
+ (0, runtime_1.emitNervesEvent)({
1494
+ level: "warn",
1495
+ component: "senses",
1496
+ event: "senses.bluebubbles_self_handle_filtered",
1497
+ message: "filtered bluebubbles event whose sender matched an agent-owned handle (isFromMe was missing/false)",
1498
+ meta: {
1499
+ messageGuid: event.messageGuid,
1500
+ kind: event.kind,
1501
+ senderExternalId: event.sender.externalId,
1502
+ },
1503
+ });
1504
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "from_me" };
1304
1505
  }
1305
- // Pre-load session inside the turn lock so same-chat deliveries cannot race on stale trunk state.
1506
+ options.lifecycleSignal?.throwIfAborted();
1507
+ // ── Adapter setup: friend, session, content, callbacks ────────
1508
+ const store = resolvedDeps.createFriendStore();
1509
+ const resolver = resolvedDeps.createFriendResolver(store, resolveFriendParams(event));
1510
+ const baseContext = await resolver.resolve();
1511
+ options.lifecycleSignal?.throwIfAborted();
1512
+ const context = { ...baseContext, isGroupChat: event.chat.isGroup };
1513
+ const replyTarget = createReplyTargetController(event);
1514
+ const friendId = context.friend.id;
1515
+ const sessPath = resolvedDeps.sessionPath(friendId, "bluebubbles", event.chat.sessionKey);
1516
+ try {
1517
+ (0, session_cleanup_1.findObsoleteBlueBubblesThreadSessions)(sessPath);
1518
+ }
1519
+ catch (error) {
1520
+ (0, runtime_1.emitNervesEvent)({
1521
+ level: "warn",
1522
+ component: "senses",
1523
+ event: "senses.bluebubbles_thread_lane_cleanup_error",
1524
+ message: "failed to inspect obsolete bluebubbles thread-lane sessions",
1525
+ meta: {
1526
+ sessionPath: sessPath,
1527
+ reason: error instanceof Error ? error.message : String(error),
1528
+ },
1529
+ });
1530
+ }
1531
+ // Pre-load session inside the canonical turn lock so same-chat
1532
+ // deliveries cannot race on stale trunk state.
1306
1533
  const existing = resolvedDeps.loadSession(sessPath);
1307
- const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
1308
1534
  const sessionMessages = existing?.messages && existing.messages.length > 0
1309
- ? existing.messages
1535
+ ? structuredClone(existing.messages)
1310
1536
  : [{ role: "system", content: (0, prompt_1.flattenSystemPrompt)(await resolvedDeps.buildSystem("bluebubbles", {}, context)) }];
1311
- if (event.kind === "message") {
1312
- // Record EARLY for audit and crash recovery. This is capture truth, not
1313
- // a claim that the agent turn completed successfully.
1314
- const inboundSource = (options.semanticRecovery || source !== "webhook")
1315
- && sessionLikelyContainsMessage(event, existing?.messages ?? sessionMessages)
1316
- ? "recovery-bootstrap"
1317
- : source;
1318
- (0, inbound_log_1.recordBlueBubblesInbound)(agentName, event, inboundSource);
1319
- if (inboundSource === "recovery-bootstrap") {
1320
- (0, runtime_1.emitNervesEvent)({
1321
- component: "senses",
1322
- event: "senses.bluebubbles_recovery_skip",
1323
- message: "skipped bluebubbles recovery because the session already contains the message text",
1324
- meta: {
1325
- messageGuid: event.messageGuid,
1326
- sessionKey: event.chat.sessionKey,
1327
- source,
1328
- },
1329
- });
1330
- (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, inboundSource, "session-bootstrap");
1331
- return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
1332
- }
1537
+ // Record EARLY for audit and crash recovery. This is capture truth, not
1538
+ // a claim that the agent turn completed successfully.
1539
+ const inboundSource = (options.semanticRecovery || source !== "webhook")
1540
+ && sessionLikelyContainsMessage(event, existing?.messages ?? sessionMessages)
1541
+ ? "recovery-bootstrap"
1542
+ : source;
1543
+ (0, inbound_log_1.recordBlueBubblesInbound)(agentName, event, inboundSource);
1544
+ if (inboundSource === "recovery-bootstrap") {
1545
+ (0, runtime_1.emitNervesEvent)({
1546
+ component: "senses",
1547
+ event: "senses.bluebubbles_recovery_skip",
1548
+ message: "skipped bluebubbles recovery because the session already contains the message text",
1549
+ meta: {
1550
+ messageGuid: event.messageGuid,
1551
+ sessionKey: event.chat.sessionKey,
1552
+ source,
1553
+ },
1554
+ });
1555
+ (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, inboundSource, "session-bootstrap");
1556
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "already_processed" };
1333
1557
  }
1334
- if (event.kind === "message" && event.chat.isGroup) {
1558
+ // The inbound event is canonical truth as soon as its canonical lane is
1559
+ // reserved. Persist it before any supersedable orientation/provider work.
1560
+ const priorMessages = sessionMessages;
1561
+ const userMessage = {
1562
+ role: "user",
1563
+ content: buildInboundContent(event),
1564
+ };
1565
+ resolvedDeps.saveSession(sessPath, [...structuredClone(sessionMessages), structuredClone(userMessage)], existing?.lastUsage, existing?.state);
1566
+ if (!await (0, latest_turn_1.awaitDeliveryAdmission)(options.latestTurnCapability)
1567
+ || !(0, latest_turn_1.isCurrent)(options.latestTurnCapability)) {
1568
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "superseded" };
1569
+ }
1570
+ options.lifecycleSignal?.throwIfAborted();
1571
+ const liveTurnId = (0, active_turns_1.beginBlueBubblesActiveTurn)(agentName, event);
1572
+ activeTurnId = liveTurnId;
1573
+ const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
1574
+ if (event.chat.isGroup) {
1335
1575
  await (0, friends_1.upsertGroupContextParticipants)({
1336
1576
  store,
1337
- participants: (event.chat.participantHandles ?? []).map((externalId) => ({
1577
+ participants: event.chat.participantHandles.map((externalId) => ({
1338
1578
  provider: "imessage-handle",
1339
1579
  externalId,
1340
1580
  })),
@@ -1342,10 +1582,10 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1342
1582
  });
1343
1583
  }
1344
1584
  // Fetch the text of the message being replied to (if this is a threaded reply)
1345
- const threadGuid = event.kind === "message" ? event.threadOriginatorGuid?.trim() : undefined;
1585
+ const threadGuid = event.threadOriginatorGuid?.trim();
1346
1586
  let repliedToText = null;
1347
1587
  if (threadGuid) {
1348
- repliedToText = await client.getMessageText(threadGuid).catch(/* v8 ignore next */ () => null);
1588
+ repliedToText = await client.getMessageText(threadGuid).catch(() => null);
1349
1589
  (0, runtime_1.emitNervesEvent)({
1350
1590
  component: "senses",
1351
1591
  event: "senses.bluebubbles_reply_context",
@@ -1353,12 +1593,9 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1353
1593
  meta: { threadGuid, hasText: !!repliedToText },
1354
1594
  });
1355
1595
  }
1356
- // Build inbound user message as user-visible speech only; source/routing facts live in the orientation frame.
1357
- const priorMessages = existing?.messages ?? sessionMessages;
1358
- const userMessage = {
1359
- role: "user",
1360
- content: buildInboundContent(event),
1361
- };
1596
+ // Shutdown can land during MCP/group/reply-context awaits above. Recheck
1597
+ // before the remaining synchronous setup installs its lifecycle listener.
1598
+ options.lifecycleSignal?.throwIfAborted();
1362
1599
  const orientationFrame = (0, orientation_frame_1.buildOrientationFrame)({
1363
1600
  channel: "bluebubbles",
1364
1601
  messages: [...priorMessages, userMessage],
@@ -1366,26 +1603,76 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1366
1603
  structuredOutputs: existing?.structuredOutputs ?? [],
1367
1604
  source: buildBlueBubblesOrientationSource(event, priorMessages, options.orientationEvidence, options.orientationConversationKind, repliedToText),
1368
1605
  });
1369
- const visibleActivityTurnId = activeTurnId;
1370
- const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup, visibleActivityTurnId
1371
- ? () => (0, active_turns_1.noteBlueBubblesActiveTurnVisibleActivity)(agentName, visibleActivityTurnId)
1372
- : undefined);
1606
+ if (!(0, latest_turn_1.isCurrent)(options.latestTurnCapability)) {
1607
+ return { handled: true, notifiedAgent: false, kind: event.kind, reason: "superseded" };
1608
+ }
1609
+ const turnStage = createBlueBubblesTurnStage(event.chat.sessionKey);
1610
+ let finalTransportInvoked = false;
1611
+ let finalTransportAccepted = false;
1612
+ const outboundRuntimeConfig = (0, config_1.getBlueBubblesConfig)();
1613
+ const callbacks = createBlueBubblesCallbacks(client, event.chat, replyTarget, event.chat.isGroup, () => (0, active_turns_1.noteBlueBubblesActiveTurnVisibleActivity)(agentName, liveTurnId), {
1614
+ admitOutbound: async () => (!options.lifecycleSignal?.aborted
1615
+ && await (0, latest_turn_1.awaitDeliveryAdmission)(options.latestTurnCapability)
1616
+ && !options.lifecycleSignal?.aborted),
1617
+ isOutboundCurrent: () => (!options.lifecycleSignal?.aborted
1618
+ && (0, latest_turn_1.isCurrent)(options.latestTurnCapability)),
1619
+ isOutboundAdmittedNow: () => (!options.lifecycleSignal?.aborted
1620
+ && (0, latest_turn_1.isDeliveryAdmittedNow)(options.latestTurnCapability)),
1621
+ onFinalTransportInvoked: () => {
1622
+ finalTransportInvoked = true;
1623
+ },
1624
+ onFinalTransportAccepted: () => {
1625
+ finalTransportAccepted = true;
1626
+ },
1627
+ durableOutbound: {
1628
+ agentRoot: (0, identity_1.getAgentRoot)(),
1629
+ idempotencyKey: blueBubblesInboundReplyIdempotencyKey(options.currentIngressEvidence.captureKeyHash),
1630
+ attachment: {
1631
+ serverUrl: outboundRuntimeConfig.serverUrl,
1632
+ accountId: outboundRuntimeConfig.accountId,
1633
+ },
1634
+ },
1635
+ });
1373
1636
  const controller = new AbortController();
1637
+ const lifecycleSignal = options.lifecycleSignal;
1638
+ let rejectLifecycle;
1639
+ const lifecyclePromise = new Promise((_resolve, reject) => {
1640
+ rejectLifecycle = reject;
1641
+ });
1642
+ let resolveSupersession;
1643
+ const supersessionPromise = new Promise((resolve) => {
1644
+ resolveSupersession = resolve;
1645
+ });
1646
+ const cancelForSupersession = () => {
1647
+ callbacks.cancelOutbound("superseded");
1648
+ if (!finalTransportInvoked) {
1649
+ controller.abort(options.latestTurnCapability.signal.reason);
1650
+ resolveSupersession({
1651
+ handled: true,
1652
+ notifiedAgent: false,
1653
+ kind: event.kind,
1654
+ reason: "superseded",
1655
+ });
1656
+ }
1657
+ };
1658
+ options.latestTurnCapability.signal.addEventListener("abort", cancelForSupersession, { once: true });
1659
+ const cancelForShutdown = () => {
1660
+ callbacks.cancelOutbound("shutdown");
1661
+ if (!finalTransportInvoked) {
1662
+ const reason = lifecycleSignal.reason;
1663
+ controller.abort(reason);
1664
+ rejectLifecycle(reason);
1665
+ }
1666
+ };
1667
+ lifecycleSignal?.addEventListener("abort", cancelForShutdown, { once: true });
1374
1668
  let timeoutTimer;
1375
1669
  let timeoutPromise;
1376
- let timeoutReject;
1670
+ let resolveTimeout;
1377
1671
  let recoveryTimedOut = false;
1378
- let timeoutReason = null;
1379
1672
  // BB-specific tool context wrappers
1380
1673
  const summarize = (0, core_1.createSummarize)("human");
1381
1674
  const bbCapabilities = (0, friends_1.getChannelCapabilities)("bluebubbles");
1382
1675
  const pendingDir = (0, pending_1.getPendingDir)(resolvedDeps.getAgentName(), friendId, "bluebubbles", event.chat.sessionKey);
1383
- // ── Compute trust gate context for group/acquaintance rules ─────
1384
- const groupHasFamilyMember = await checkGroupHasFamilyMember(store, event);
1385
- const hasExistingGroupWithFamily = event.chat.isGroup
1386
- ? false
1387
- : await checkHasExistingGroupWithFamily(store, context.friend);
1388
- // ── Call shared pipeline ──────────────────────────────────────────
1389
1676
  // Buffer terminal errors so failover can suppress them.
1390
1677
  // If failover produces a message, the buffered error is skipped.
1391
1678
  // If failover doesn't fire, the buffered error is replayed.
@@ -1403,17 +1690,50 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1403
1690
  };
1404
1691
  /* v8 ignore stop */
1405
1692
  try {
1693
+ // ── Compute trust gate context for group/acquaintance rules ─────
1694
+ // These store reads can block on filesystem I/O. Race them against the
1695
+ // runtime lifecycle before constructing the shared pipeline so a closed
1696
+ // worker cannot begin new durable or outward work after the read settles.
1697
+ const trustContext = await Promise.race([
1698
+ Promise.all([
1699
+ checkGroupHasFamilyMember(store, event),
1700
+ event.chat.isGroup
1701
+ ? Promise.resolve(false)
1702
+ : checkHasExistingGroupWithFamily(store, context.friend),
1703
+ ]),
1704
+ lifecyclePromise,
1705
+ supersessionPromise.then(() => null),
1706
+ ]);
1707
+ if (trustContext === null) {
1708
+ return {
1709
+ handled: true,
1710
+ notifiedAgent: false,
1711
+ kind: event.kind,
1712
+ reason: "superseded",
1713
+ };
1714
+ }
1715
+ const [groupHasFamilyMember, hasExistingGroupWithFamily] = trustContext;
1716
+ lifecycleSignal?.throwIfAborted();
1717
+ // ── Call shared pipeline ────────────────────────────────────────
1406
1718
  const timeoutMs = options.timeoutMs ?? BLUEBUBBLES_LIVE_TURN_TIMEOUT_MS;
1407
- timeoutPromise = new Promise((_, reject) => {
1408
- timeoutReject = reject;
1719
+ timeoutPromise = new Promise((resolve) => {
1720
+ resolveTimeout = resolve;
1409
1721
  });
1410
1722
  timeoutTimer = setTimeout(() => {
1411
1723
  const reason = new BlueBubblesRecoveryTurnTimeoutError(timeoutMs);
1412
- timeoutReason = reason;
1413
1724
  recoveryTimedOut = true;
1725
+ if (!finalTransportInvoked) {
1726
+ resolveTimeout?.({
1727
+ handled: true,
1728
+ notifiedAgent: false,
1729
+ kind: event.kind,
1730
+ reason: "turn_timeout",
1731
+ });
1732
+ }
1414
1733
  callbacks.cancelOutbound("turn_timeout");
1415
- controller.abort(reason);
1416
- timeoutReject?.(reason);
1734
+ (0, latest_turn_1.cancel)(options.latestTurnCapability, "turn_timeout");
1735
+ if (!finalTransportInvoked)
1736
+ controller.abort(reason);
1417
1737
  (0, runtime_1.emitNervesEvent)({
1418
1738
  level: "warn",
1419
1739
  component: "senses",
@@ -1426,6 +1746,18 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1426
1746
  timeoutMs,
1427
1747
  },
1428
1748
  });
1749
+ (0, runtime_1.emitNervesEvent)({
1750
+ level: "warn",
1751
+ component: "senses",
1752
+ event: "senses.bluebubbles_timeout_notice_suppressed",
1753
+ message: "bluebubbles timeout notice suppressed from iMessage",
1754
+ meta: {
1755
+ messageGuid: event.messageGuid,
1756
+ sessionKey: event.chat.sessionKey,
1757
+ source,
1758
+ timeoutMs,
1759
+ },
1760
+ });
1429
1761
  }, timeoutMs);
1430
1762
  /* v8 ignore next -- timer handles expose unref only in some runtimes @preserve */
1431
1763
  if (typeof timeoutTimer.unref === "function") {
@@ -1461,7 +1793,7 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1461
1793
  enforceTrustGate: trust_gate_1.enforceTrustGate,
1462
1794
  drainPending: pending_1.drainPending,
1463
1795
  drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(resolvedDeps.getAgentName(), deferredFriendId),
1464
- prepareRunAgentOptions: async ({ messages, runAgentOptions }) => {
1796
+ prepareRunAgentOptions: async ({ messages = [], currentUserMessages = [], runAgentOptions }) => {
1465
1797
  preparedBlueBubblesContext = await buildBlueBubblesContextMessages({
1466
1798
  agentName,
1467
1799
  agentRoot: (0, identity_1.getAgentRoot)(),
@@ -1469,41 +1801,75 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1469
1801
  event,
1470
1802
  knownMessages: messages,
1471
1803
  });
1472
- if (preparedBlueBubblesContext.contextPacketIds.length === 0)
1804
+ const currentUserMessage = currentUserMessages.findLast((message) => message.role === "user")
1805
+ ?? messages.findLast((message) => message.role === "user");
1806
+ if (!currentUserMessage)
1473
1807
  return undefined;
1808
+ Object.freeze(currentUserMessage);
1809
+ const { verifiedPredecessorMessage } = preparedBlueBubblesContext;
1474
1810
  return {
1475
- contextPacketIds: [
1476
- ...(runAgentOptions.contextPacketIds ?? []),
1477
- ...preparedBlueBubblesContext.contextPacketIds,
1478
- ],
1811
+ ...(preparedBlueBubblesContext.contextPacketIds.length > 0 ? {
1812
+ contextPacketIds: [
1813
+ ...(runAgentOptions.contextPacketIds ?? []),
1814
+ ...preparedBlueBubblesContext.contextPacketIds,
1815
+ ],
1816
+ } : {}),
1817
+ ...(preparedBlueBubblesContext.messages.length > 0 ? {
1818
+ promptOnlyEvidenceMessages: preparedBlueBubblesContext.messages,
1819
+ } : {}),
1820
+ requiredPromptEvidence: {
1821
+ currentUserMessage,
1822
+ ...(verifiedPredecessorMessage ? { verifiedPredecessorMessage } : {}),
1823
+ },
1479
1824
  };
1480
1825
  },
1481
- runAgent: async (msgs, cb, channel, sig, opts) => resolvedDeps.runAgent(insertEphemeralContextMessages(msgs, preparedBlueBubblesContext.messages), cb, channel, sig, {
1482
- ...opts,
1483
- toolContext: {
1484
- /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
1485
- signin: async () => undefined,
1486
- ...opts?.toolContext,
1487
- summarize,
1488
- bluebubblesReplyTarget: {
1489
- setSelection: (selection) => replyTarget.setSelection(selection),
1826
+ runAgent: async (msgs, cb, channel, sig, opts) => {
1827
+ const { codingFeedback: _omittedCodingFeedback, ...safeToolContext } = opts?.toolContext ?? {};
1828
+ const requiredCurrent = opts?.requiredPromptEvidence?.currentUserMessage;
1829
+ const providerMessages = insertEphemeralContextMessages(msgs.map((message) => message === requiredCurrent ? message : structuredClone(message)), [
1830
+ ...preparedBlueBubblesContext.messages,
1831
+ ...(preparedBlueBubblesContext.verifiedPredecessorMessage
1832
+ ? [preparedBlueBubblesContext.verifiedPredecessorMessage]
1833
+ : []),
1834
+ ], requiredCurrent);
1835
+ let generatedMessages = [];
1836
+ const agentResult = await resolvedDeps.runAgent(providerMessages, cb, channel, sig, {
1837
+ ...opts,
1838
+ captureGeneratedMessages: (generated) => {
1839
+ opts?.captureGeneratedMessages?.(generated);
1840
+ generatedMessages = structuredClone(generated);
1490
1841
  },
1491
- codingFeedback: {
1492
- send: async (message) => {
1493
- await client.sendText({
1494
- chat: event.chat,
1495
- text: message,
1496
- replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
1497
- });
1842
+ toolContext: {
1843
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
1844
+ signin: async () => undefined,
1845
+ ...safeToolContext,
1846
+ summarize,
1847
+ bluebubblesReplyTarget: {
1848
+ setSelection: (selection) => replyTarget.setSelection(selection),
1498
1849
  },
1499
1850
  },
1500
- },
1501
- }),
1851
+ });
1852
+ if (generatedMessages.length > 0) {
1853
+ msgs.push(...generatedMessages);
1854
+ }
1855
+ return agentResult;
1856
+ },
1502
1857
  postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
1503
1858
  const prepared = resolvedDeps.postTurnTrim(turnMessages, usage, hooks);
1504
- resolvedDeps.deferPostTurnPersist(sessionPathArg, prepared, usage, state);
1859
+ turnStage.postTurn = {
1860
+ sessionPath: sessionPathArg,
1861
+ prepared,
1862
+ usage,
1863
+ state,
1864
+ };
1865
+ },
1866
+ accumulateFriendTokens: async (tokenStore, tokenFriendId, usage) => {
1867
+ turnStage.tokens = {
1868
+ store: tokenStore,
1869
+ friendId: tokenFriendId,
1870
+ usage,
1871
+ };
1505
1872
  },
1506
- accumulateFriendTokens: resolvedDeps.accumulateFriendTokens,
1507
1873
  signal: controller.signal,
1508
1874
  runAgentOptions: {
1509
1875
  mcpManager,
@@ -1515,12 +1881,7 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1515
1881
  },
1516
1882
  },
1517
1883
  callbacks: failoverAwareCallbacks,
1518
- failoverState: (() => {
1519
- if (!bbFailoverStates.has(event.chat.sessionKey)) {
1520
- bbFailoverStates.set(event.chat.sessionKey, { pending: null });
1521
- }
1522
- return bbFailoverStates.get(event.chat.sessionKey);
1523
- })(),
1884
+ failoverState: turnStage.failoverState,
1524
1885
  });
1525
1886
  /* v8 ignore start -- detached late-rejection telemetry is asserted in timeout tests, but V8 does not reliably attribute Promise.catch callbacks @preserve */
1526
1887
  void turnPromise
@@ -1543,43 +1904,108 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1543
1904
  /* v8 ignore stop */
1544
1905
  const runTurn = (async () => {
1545
1906
  const result = await turnPromise;
1546
- if (recoveryTimedOut)
1547
- throw getBlueBubblesTimeoutReason(timeoutReason, timeoutMs);
1907
+ if (recoveryTimedOut && !finalTransportInvoked) {
1908
+ (0, runtime_1.emitNervesEvent)({
1909
+ level: "warn",
1910
+ component: "senses",
1911
+ event: "senses.bluebubbles_timed_out_turn_suppressed",
1912
+ message: "suppressed late bluebubbles turn result after timeout",
1913
+ meta: {
1914
+ messageGuid: event.messageGuid,
1915
+ sessionKey: event.chat.sessionKey,
1916
+ source,
1917
+ reason: "turn completed after its final-transport boundary closed",
1918
+ },
1919
+ });
1920
+ return {
1921
+ handled: true,
1922
+ notifiedAgent: false,
1923
+ kind: event.kind,
1924
+ reason: "turn_timeout",
1925
+ };
1926
+ }
1548
1927
  /* v8 ignore start -- failover display + error replay @preserve */
1549
1928
  if (result.failoverMessage) {
1550
- // Failover handled it — show the failover message, skip the buffered error
1551
- await client.sendText({ chat: event.chat, text: result.failoverMessage });
1929
+ callbacks.onClearText?.();
1930
+ callbacks.onTextChunk(result.failoverMessage);
1931
+ bufferedTerminalError = null;
1552
1932
  }
1553
1933
  else if (bufferedTerminalError) {
1554
- // No failover — replay the buffered terminal error
1555
1934
  callbacks.onError(bufferedTerminalError, "terminal");
1935
+ bufferedTerminalError = null;
1556
1936
  }
1557
1937
  /* v8 ignore stop */
1558
1938
  // ── Handle gate result ────────────────────────────────────────
1939
+ let processedOutcome = "turn-complete";
1559
1940
  if (!result.gateResult.allowed) {
1560
- // Send auto-reply via BB API if the gate provides one
1941
+ callbacks.onClearText?.();
1561
1942
  if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
1562
- await client.sendText({
1563
- chat: event.chat,
1564
- text: result.gateResult.autoReply,
1565
- });
1566
- }
1567
- if (event.kind === "message") {
1568
- (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, source, "trust-gated");
1943
+ callbacks.onTextChunk(result.gateResult.autoReply);
1569
1944
  }
1945
+ processedOutcome = "trust-gated";
1946
+ }
1947
+ let delivery;
1948
+ try {
1949
+ delivery = await callbacks.flush({ signal: controller.signal });
1950
+ }
1951
+ catch (error) {
1952
+ (0, runtime_1.emitNervesEvent)({
1953
+ level: "warn",
1954
+ component: "senses",
1955
+ event: "senses.bluebubbles_final_transport_failed",
1956
+ message: "bluebubbles final transport failed after invocation",
1957
+ meta: {
1958
+ messageGuid: event.messageGuid,
1959
+ sessionKey: event.chat.sessionKey,
1960
+ reason: error instanceof Error ? error.message : String(error),
1961
+ },
1962
+ });
1570
1963
  return {
1571
1964
  handled: true,
1572
1965
  notifiedAgent: false,
1573
1966
  kind: event.kind,
1967
+ reason: "delivery_failed",
1574
1968
  };
1575
1969
  }
1576
- // Gate allowed — flush the agent's reply
1577
- await callbacks.flush();
1578
- if (recoveryTimedOut)
1579
- throw getBlueBubblesTimeoutReason(timeoutReason, timeoutMs);
1580
- if (event.kind === "message") {
1581
- (0, processed_log_1.recordProcessedBlueBubblesMessage)(agentName, event, source, "turn-complete");
1970
+ if (delivery.status !== "accepted" || !finalTransportAccepted) {
1971
+ return {
1972
+ handled: true,
1973
+ notifiedAgent: false,
1974
+ kind: event.kind,
1975
+ reason: !(0, latest_turn_1.isCurrent)(options.latestTurnCapability)
1976
+ ? "superseded"
1977
+ : "no_visible_reply",
1978
+ };
1979
+ }
1980
+ // Acceptance is the visibility boundary. Publish the terminal dedupe
1981
+ // receipt first, then project the private generated tail while this
1982
+ // chat's canonical lock remains held. Even if receipt publication
1983
+ // fails, make the best possible canonical projection before surfacing
1984
+ // the retained-claim failure. Ancillary accounting follows both.
1985
+ let receiptFailed = false;
1986
+ let receiptFailure;
1987
+ try {
1988
+ options.publishAcceptedReceipt();
1989
+ }
1990
+ catch (error) {
1991
+ receiptFailed = true;
1992
+ receiptFailure = error;
1582
1993
  }
1994
+ persistAcceptedBlueBubblesProjection({
1995
+ stage: turnStage,
1996
+ deps: resolvedDeps,
1997
+ event,
1998
+ });
1999
+ if (receiptFailed)
2000
+ throw receiptFailure;
2001
+ promoteAcceptedBlueBubblesAncillaryState({
2002
+ stage: turnStage,
2003
+ deps: resolvedDeps,
2004
+ agentName,
2005
+ event,
2006
+ source,
2007
+ processedOutcome,
2008
+ });
1583
2009
  (0, runtime_1.emitNervesEvent)({
1584
2010
  component: "senses",
1585
2011
  event: "senses.bluebubbles_turn_end",
@@ -1614,28 +2040,7 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1614
2040
  });
1615
2041
  });
1616
2042
  /* v8 ignore stop */
1617
- return await (async () => {
1618
- try {
1619
- return await Promise.race([runTurn, timeoutPromise]);
1620
- }
1621
- catch (error) {
1622
- if (isBlueBubblesRecoveryTurnTimeout(error)) {
1623
- (0, runtime_1.emitNervesEvent)({
1624
- level: "warn",
1625
- component: "senses",
1626
- event: "senses.bluebubbles_timeout_notice_suppressed",
1627
- message: "bluebubbles timeout notice suppressed from iMessage",
1628
- meta: {
1629
- messageGuid: event.messageGuid,
1630
- sessionKey: event.chat.sessionKey,
1631
- source,
1632
- timeoutMs,
1633
- },
1634
- });
1635
- }
1636
- throw error;
1637
- }
1638
- })();
2043
+ return await Promise.race([runTurn, timeoutPromise, supersessionPromise, lifecyclePromise]);
1639
2044
  }
1640
2045
  finally {
1641
2046
  // If a terminal error was buffered and never replayed (e.g., handleInboundTurn threw),
@@ -1647,10 +2052,10 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source, opt
1647
2052
  }
1648
2053
  /* v8 ignore stop */
1649
2054
  clearTimeout(timeoutTimer);
1650
- if (activeTurnId) {
1651
- (0, active_turns_1.finishBlueBubblesActiveTurn)(agentName, activeTurnId);
1652
- activeTurnId = null;
1653
- }
2055
+ options.latestTurnCapability.signal.removeEventListener("abort", cancelForSupersession);
2056
+ lifecycleSignal?.removeEventListener("abort", cancelForShutdown);
2057
+ (0, active_turns_1.finishBlueBubblesActiveTurn)(agentName, liveTurnId);
2058
+ activeTurnId = null;
1654
2059
  await callbacks.finish({ timeoutMs: BLUEBUBBLES_CALLBACK_CLEANUP_TIMEOUT_MS });
1655
2060
  }
1656
2061
  });
@@ -1683,11 +2088,9 @@ function recordBlueBubblesAuditSidecar(agentName, event, source, resolvedDeps) {
1683
2088
  });
1684
2089
  }
1685
2090
  }
1686
- function reactionTargetAuthorship(target) {
1687
- return target.fromMe === true ? "agent" : "non_agent_unknown";
1688
- }
1689
- async function captureBlueBubblesSemanticEvent(normalized, resolvedDeps, source) {
2091
+ async function captureBlueBubblesSemanticEvent(normalized, resolvedDeps, source, observationReservation) {
1690
2092
  const agentName = resolvedDeps.getAgentName();
2093
+ const capturedAt = new Date().toISOString();
1691
2094
  if (!hasObservedBlueBubblesDirection(normalized)) {
1692
2095
  (0, runtime_1.emitNervesEvent)({
1693
2096
  level: "warn",
@@ -1705,14 +2108,6 @@ async function captureBlueBubblesSemanticEvent(normalized, resolvedDeps, source)
1705
2108
  }
1706
2109
  const observed = normalized;
1707
2110
  const cutover = (0, semantic_receipts_1.initializeBlueBubblesSemanticCutover)(agentName);
1708
- let resolvedReactionTarget;
1709
- let targetAuthorship = null;
1710
- if (observed.kind === "mutation"
1711
- && observed.mutationType === "reaction"
1712
- && observed.targetMessageGuid) {
1713
- resolvedReactionTarget = await resolveReactionTarget(resolvedDeps.createClient(), observed.targetMessageGuid);
1714
- targetAuthorship = reactionTargetAuthorship(resolvedReactionTarget);
1715
- }
1716
2111
  let coordinateGeneration;
1717
2112
  if (observed.kind === "mutation"
1718
2113
  && observed.mutationType === "reaction"
@@ -1741,9 +2136,11 @@ async function captureBlueBubblesSemanticEvent(normalized, resolvedDeps, source)
1741
2136
  }
1742
2137
  const capture = (0, semantic_receipts_1.buildBlueBubblesSemanticCapture)({
1743
2138
  cutover,
1744
- capturedAt: new Date().toISOString(),
2139
+ capturedAt,
2140
+ observationEpoch: observationReservation.observationEpoch,
2141
+ observationOrdinal: observationReservation.ordinal,
1745
2142
  event: observed,
1746
- targetAuthorship,
2143
+ targetAuthorship: null,
1747
2144
  coordinateGeneration,
1748
2145
  });
1749
2146
  if (!capture) {
@@ -1761,7 +2158,6 @@ async function captureBlueBubblesSemanticEvent(normalized, resolvedDeps, source)
1761
2158
  capture,
1762
2159
  normalized: observed,
1763
2160
  writeResult,
1764
- ...(resolvedReactionTarget ? { resolvedReactionTarget } : {}),
1765
2161
  };
1766
2162
  }
1767
2163
  function semanticCaptureChat(capture) {
@@ -1792,11 +2188,58 @@ function semanticCaptureChat(capture) {
1792
2188
  participantHandles: event.participants.map((participant) => participant.externalId),
1793
2189
  };
1794
2190
  }
1795
- function semanticCaptureRequiresRoutingRepair(capture) {
1796
- return !capture.event.chatGuid?.trim() && !capture.event.chatIdentifier?.trim();
2191
+ function semanticCaptureRequiresRoutingRepair(capture) {
2192
+ return !capture.event.chatGuid?.trim() && !capture.event.chatIdentifier?.trim();
2193
+ }
2194
+ function hasResolvedBlueBubblesRouting(event) {
2195
+ return Boolean(event.chat.chatGuid?.trim() || event.chat.chatIdentifier?.trim());
2196
+ }
2197
+ function reserveBlueBubblesObservation(event) {
2198
+ return (0, latest_turn_1.reserveObservation)({
2199
+ chatGuid: event.chat.chatGuid,
2200
+ chatIdentifier: event.chat.chatIdentifier,
2201
+ });
2202
+ }
2203
+ function groupBlueBubblesObservationsByLane(candidates) {
2204
+ let groups = [];
2205
+ for (const candidate of candidates) {
2206
+ const keys = new Set((0, latest_turn_1.observationSchedulingKeys)(candidate.observationReservation));
2207
+ const destination = { keys: new Set(keys), candidates: [candidate] };
2208
+ const independentGroups = [];
2209
+ for (const group of groups) {
2210
+ const intersects = (keys.has("unresolved:*")
2211
+ || group.keys.has("unresolved:*")
2212
+ || [...keys].some((key) => group.keys.has(key)));
2213
+ if (!intersects) {
2214
+ independentGroups.push(group);
2215
+ continue;
2216
+ }
2217
+ destination.candidates.push(...group.candidates);
2218
+ for (const key of group.keys)
2219
+ destination.keys.add(key);
2220
+ }
2221
+ groups = [...independentGroups, destination];
2222
+ }
2223
+ return groups.map((group) => group.candidates.sort((left, right) => (right.observationReservation.ordinal - left.observationReservation.ordinal)));
2224
+ }
2225
+ function canonicalizeBlueBubblesEvent(event, capability) {
2226
+ const canonical = capability.canonicalChat;
2227
+ const { chatIdentifier: _observedIdentifier, ...observedChat } = event.chat;
2228
+ return {
2229
+ ...event,
2230
+ chat: {
2231
+ ...observedChat,
2232
+ chatGuid: canonical.chatGuid,
2233
+ ...(canonical.chatIdentifier ? { chatIdentifier: canonical.chatIdentifier } : {}),
2234
+ sessionKey: canonical.sessionKey,
2235
+ sendTarget: { kind: "chat_guid", value: canonical.chatGuid },
2236
+ },
2237
+ };
1797
2238
  }
1798
- function hasResolvedBlueBubblesRouting(event) {
1799
- return Boolean(event.chat.chatGuid?.trim() || event.chat.chatIdentifier?.trim());
2239
+ function isCaptureOnlyBlueBubblesMutation(capture) {
2240
+ return capture.event.kind === "reaction"
2241
+ || capture.event.kind === "edit"
2242
+ || capture.event.kind === "unsend";
1800
2243
  }
1801
2244
  function isAuditOnlySemanticCapture(capture) {
1802
2245
  return capture.event.kind === "read" || capture.event.kind === "delivery";
@@ -1872,43 +2315,30 @@ function semanticCaptureToNormalizedEvent(capture) {
1872
2315
  : {}),
1873
2316
  };
1874
2317
  }
1875
- function semanticHandledOutcome(event, result) {
1876
- if (result.reason === "from_me")
1877
- return "ignored_self";
1878
- if (event.kind === "message") {
1879
- return result.notifiedAgent ? "message_completed" : "message_observed";
1880
- }
1881
- switch (event.mutationType) {
1882
- case "edit": return "edit_capture_only";
1883
- case "unsend": return "unsend_capture_only";
1884
- case "read": return "read_audit_only";
1885
- case "delivery": return "delivery_audit_only";
1886
- case "reaction":
1887
- if (result.reason === "restricted_feedback_settled")
1888
- return "restricted_feedback_settled";
1889
- if (result.reason === "restricted_feedback_observed")
1890
- return "restricted_feedback_observed";
1891
- return "restricted_feedback_failed";
1892
- }
1893
- }
1894
- function materializeRestrictedBlueBubblesReactionEvent(captured, repaired) {
1895
- // Classification reached `restricted_feedback` from the validated v1
1896
- // capture's reaction tuple. Direct capture and recovery both construct the
1897
- // paired normalized event from that same tuple, so it is the authoritative
1898
- // reaction object; repair contributes routing only and may legitimately
1899
- // promote its own transport-shaped result to `message`.
1900
- const event = captured.normalized;
1901
- return {
1902
- ...event,
1903
- mutationType: "reaction",
1904
- reaction: event.reaction,
1905
- targetMessageGuid: event.targetMessageGuid,
1906
- chat: repaired.chat,
1907
- requiresRepair: repaired.requiresRepair,
1908
- repairNotice: repaired.repairNotice,
1909
- };
2318
+ function seedBlueBubblesStartupSemanticObservations(deps = {}) {
2319
+ const resolvedDeps = { ...defaultDeps, ...deps };
2320
+ const agentName = resolvedDeps.getAgentName();
2321
+ const cutover = (0, semantic_receipts_1.initializeBlueBubblesSemanticCutover)(agentName);
2322
+ let seeded = 0;
2323
+ for (const capture of (0, semantic_receipts_1.listPendingBlueBubblesSemanticCaptures)(agentName)
2324
+ .sort(semantic_receipts_1.compareBlueBubblesSemanticCaptureOrder)) {
2325
+ const disposition = (0, semantic_receipts_1.classifyBlueBubblesRecoveryRecord)(capture, cutover);
2326
+ if (disposition.disposition === "audit_only")
2327
+ continue;
2328
+ const normalized = semanticCaptureToNormalizedEvent(capture);
2329
+ if (!normalized)
2330
+ continue;
2331
+ const generation = adoptBlueBubblesSemanticObservation(capture.keyHash, reserveBlueBubblesObservation(normalized), false);
2332
+ // Startup seeding runs synchronously before listen. Retain each fence so
2333
+ // persisted pre-restart observations remain causally older than any live
2334
+ // webhook accepted by this process and can reuse the same generation when
2335
+ // the delayed recovery pass begins.
2336
+ suspendBlueBubblesSemanticObservation(generation);
2337
+ seeded += 1;
2338
+ }
2339
+ return seeded;
1910
2340
  }
1911
- async function classifyCapturedBlueBubblesReaction(captured, resolvedDeps) {
2341
+ async function classifyCapturedBlueBubblesReaction(captured) {
1912
2342
  const event = captured.capture.event;
1913
2343
  if (event.kind !== "reaction" || !event.canonicalAction || !event.canonicalValue)
1914
2344
  return null;
@@ -1916,151 +2346,78 @@ async function classifyCapturedBlueBubblesReaction(captured, resolvedDeps) {
1916
2346
  fromMe: event.fromMe,
1917
2347
  action: event.canonicalAction,
1918
2348
  canonicalValue: event.canonicalValue,
1919
- targetAuthorship: event.targetAuthorship,
1920
- };
1921
- const initial = (0, reaction_policy_1.classifyBlueBubblesReaction)(input);
1922
- if (initial.route !== "trust_required") {
1923
- return { decision: initial, trustedFriend: null };
1924
- }
1925
- let trustedActor = false;
1926
- let trustedFriend = null;
1927
- try {
1928
- const friend = await resolvedDeps.createFriendStore().findByExternalId("imessage-handle", event.actor.externalId);
1929
- trustedActor = friends_1.TRUSTED_LEVELS.has(friend?.trustLevel ?? "stranger");
1930
- if (trustedActor && friend)
1931
- trustedFriend = friend;
1932
- }
1933
- catch (error) {
1934
- (0, runtime_1.emitNervesEvent)({
1935
- level: "warn",
1936
- component: "senses",
1937
- event: "senses.bluebubbles_reaction_trust_lookup_error",
1938
- message: "failed direct actor trust lookup for bluebubbles reaction",
1939
- meta: {
1940
- keyHash: captured.capture.keyHash,
1941
- provider: event.actor.provider,
1942
- reason: error instanceof Error ? error.message : String(error),
1943
- },
1944
- });
1945
- }
1946
- return {
1947
- decision: (0, reaction_policy_1.classifyBlueBubblesReaction)({ ...input, trustedActor }),
1948
- trustedFriend,
1949
2349
  };
2350
+ return { decision: (0, reaction_policy_1.classifyBlueBubblesReaction)(input) };
1950
2351
  }
1951
- async function handleRestrictedBlueBubblesReactionFeedback(input) {
1952
- const { event, trustedFriend, client, resolvedDeps, options } = input;
1953
- const target = input.resolvedReactionTarget
1954
- ?? await resolveReactionTarget(client, event.targetMessageGuid);
1955
- event.textForAgent = (0, model_1.renderBlueBubblesReactionText)(event.reaction, target);
1956
- const channel = (0, friends_1.getChannelCapabilities)("bluebubbles");
1957
- const context = { friend: trustedFriend, channel, isGroupChat: event.chat.isGroup };
1958
- const userMessage = {
1959
- role: "user",
1960
- content: buildInboundContent(event),
1961
- };
1962
- const orientationFrame = (0, orientation_frame_1.buildOrientationFrame)({
1963
- channel: "bluebubbles",
1964
- messages: [userMessage],
1965
- currentUserMessages: [userMessage],
1966
- source: buildBlueBubblesOrientationSource(event, [], options.orientationEvidence, options.orientationConversationKind),
1967
- speechKind: "reaction",
1968
- });
1969
- const callbacks = createBlueBubblesCallbacks(client, event.chat, createReplyTargetController(event), event.chat.isGroup, undefined, { enableSilenceWatchdog: false, enableActivitySignals: false });
1970
- const controller = new AbortController();
1971
- const timeoutMs = options.timeoutMs ?? BLUEBUBBLES_LIVE_TURN_TIMEOUT_MS;
1972
- let timeoutTimer;
1973
- const timeoutPromise = new Promise((_, reject) => {
1974
- timeoutTimer = setTimeout(() => {
1975
- const error = new BlueBubblesRecoveryTurnTimeoutError(timeoutMs);
1976
- callbacks.cancelOutbound("turn_timeout");
1977
- controller.abort(error);
1978
- reject(error);
1979
- }, timeoutMs);
1980
- if (typeof timeoutTimer.unref === "function") {
1981
- timeoutTimer.unref();
1982
- }
1983
- });
1984
- try {
1985
- const restrictedTurnAndDelivery = async () => {
1986
- const result = await resolvedDeps.runAgent([userMessage], callbacks, "bluebubbles", controller.signal, {
1987
- isReactionSignal: true,
1988
- restrictedReactionFeedback: true,
1989
- skipKeptNotes: true,
1990
- orientationFrame,
1991
- toolContext: {
1992
- signin: async () => undefined,
1993
- context,
1994
- currentIngressEvidence: options.currentIngressEvidence,
1995
- },
1996
- });
1997
- if (result.outcome === "settled" || result.outcome === "blocked") {
1998
- await callbacks.flush({ signal: controller.signal });
1999
- }
2000
- return result;
2001
- };
2002
- const result = await Promise.race([
2003
- restrictedTurnAndDelivery(),
2004
- timeoutPromise,
2005
- ]);
2006
- if (result.outcome === "settled" || result.outcome === "blocked") {
2007
- return {
2008
- handled: true,
2009
- notifiedAgent: true,
2010
- kind: event.kind,
2011
- reason: "restricted_feedback_settled",
2012
- };
2013
- }
2014
- if (result.outcome === "observed") {
2015
- return {
2016
- handled: true,
2017
- notifiedAgent: false,
2018
- kind: event.kind,
2019
- reason: "restricted_feedback_observed",
2020
- };
2021
- }
2352
+ function captureOnlyHandledDisposition(capture, reactionDecision) {
2353
+ if (capture.event.kind !== "reaction") {
2022
2354
  return {
2023
- handled: true,
2024
- notifiedAgent: false,
2025
- kind: event.kind,
2026
- reason: "restricted_feedback_failed",
2355
+ outcome: capture.event.kind === "edit" ? "edit_capture_only" : "unsend_capture_only",
2356
+ detailCode: null,
2027
2357
  };
2028
2358
  }
2029
- catch (error) {
2030
- callbacks.onError(error instanceof Error ? error : new Error(String(error)), "terminal");
2359
+ const classifiedOutcome = reactionDecision.outcome;
2360
+ if (classifiedOutcome === "capture_only_negative" || classifiedOutcome === "capture_only_question") {
2031
2361
  return {
2032
- handled: true,
2033
- notifiedAgent: false,
2034
- kind: event.kind,
2035
- reason: "restricted_feedback_failed",
2362
+ outcome: "capture_only_unknown",
2363
+ detailCode: classifiedOutcome,
2036
2364
  };
2037
2365
  }
2038
- finally {
2039
- clearTimeout(timeoutTimer);
2040
- await callbacks.finish({ timeoutMs: BLUEBUBBLES_CALLBACK_CLEANUP_TIMEOUT_MS });
2041
- }
2366
+ return { outcome: classifiedOutcome, detailCode: null };
2367
+ }
2368
+ function auditOnlyHandledOutcome(kind) {
2369
+ return kind === "read" ? "read_audit_only" : "delivery_audit_only";
2042
2370
  }
2043
- function writeCapturedBlueBubblesHandled(agentName, capture, outcome) {
2371
+ function writeCapturedBlueBubblesHandled(agentName, capture, outcome, detailCode = null) {
2044
2372
  const handledResult = (0, semantic_receipts_1.writeBlueBubblesSemanticHandled)(agentName, {
2045
2373
  schemaVersion: 1,
2046
2374
  canonicalKey: capture.canonicalKey,
2047
2375
  keyHash: capture.keyHash,
2048
2376
  handledAt: new Date().toISOString(),
2049
2377
  outcome,
2050
- detailCode: null,
2378
+ detailCode,
2051
2379
  });
2052
2380
  if (handledResult === "semantic_handled_collision") {
2053
2381
  throw new Error("semantic_handled_collision");
2054
2382
  }
2055
2383
  }
2056
- async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, source, options = {}) {
2384
+ function blueBubblesInboundReplyIdempotencyKey(captureKeyHash) {
2385
+ return `bluebubbles-inbound-reply:${captureKeyHash}`;
2386
+ }
2387
+ function blueBubblesRecoveredOutboundOutcome(record) {
2388
+ return BLUEBUBBLES_VISIBLE_OUTBOUND_STATUSES.has(record.status)
2389
+ ? "message_completed"
2390
+ : "message_observed";
2391
+ }
2392
+ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, source, options) {
2393
+ options.lifecycleSignal?.throwIfAborted();
2057
2394
  const agentName = resolvedDeps.getAgentName();
2058
2395
  const identity = {
2059
2396
  canonicalKey: captured.capture.canonicalKey,
2060
2397
  keyHash: captured.capture.keyHash,
2061
2398
  };
2062
- const claim = await (0, semantic_receipts_1.acquireBlueBubblesSemanticClaim)(agentName, identity);
2399
+ const generation = adoptBlueBubblesSemanticObservation(identity.keyHash, options.observationReservation, true);
2400
+ const semanticHandling = await acquireBlueBubblesSemanticHandlingSlot(identity.keyHash, generation);
2401
+ if (semanticHandling.status === "handled_by_owner") {
2402
+ return {
2403
+ handled: true,
2404
+ notifiedAgent: false,
2405
+ kind: captured.normalized.kind,
2406
+ reason: "already_processed",
2407
+ };
2408
+ }
2409
+ const reservation = semanticHandling.reservation;
2410
+ let claim;
2411
+ try {
2412
+ options.lifecycleSignal?.throwIfAborted();
2413
+ claim = await (0, semantic_receipts_1.acquireBlueBubblesSemanticClaim)(agentName, identity);
2414
+ }
2415
+ catch (error) {
2416
+ releaseBlueBubblesSemanticHandlingSlot(identity.keyHash, semanticHandling.slot, "retryable");
2417
+ throw error;
2418
+ }
2063
2419
  if (claim.status === "already_handled") {
2420
+ releaseBlueBubblesSemanticHandlingSlot(identity.keyHash, semanticHandling.slot, "terminal");
2064
2421
  return {
2065
2422
  handled: true,
2066
2423
  notifiedAgent: false,
@@ -2069,6 +2426,7 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2069
2426
  };
2070
2427
  }
2071
2428
  if (claim.status === "timeout") {
2429
+ releaseBlueBubblesSemanticHandlingSlot(identity.keyHash, semanticHandling.slot, "retryable");
2072
2430
  return {
2073
2431
  handled: false,
2074
2432
  notifiedAgent: false,
@@ -2076,32 +2434,97 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2076
2434
  reason: "semantic_claim_timeout",
2077
2435
  };
2078
2436
  }
2437
+ let latestTurnCapability = null;
2438
+ let semanticHandlingCompleted = false;
2439
+ let retainSemanticClaim = false;
2440
+ let acceptedReceiptPublished = false;
2441
+ const publishTerminalReceipt = (outcome, detailCode = null) => {
2442
+ try {
2443
+ writeCapturedBlueBubblesHandled(agentName, captured.capture, outcome, detailCode);
2444
+ }
2445
+ catch (error) {
2446
+ // A missing terminal receipt must never reopen this semantic event. Keep
2447
+ // the durable claim live so recovery cannot resurrect a canceled turn or
2448
+ // duplicate a reply whose transport is already externally visible.
2449
+ retainSemanticClaim = true;
2450
+ semanticHandlingCompleted = true;
2451
+ (0, runtime_1.emitNervesEvent)({
2452
+ level: "error",
2453
+ component: "senses",
2454
+ event: "senses.bluebubbles_terminal_receipt_failed",
2455
+ message: "bluebubbles event could not publish its terminal semantic receipt",
2456
+ meta: {
2457
+ messageGuid: captured.normalized.messageGuid,
2458
+ keyHash: captured.capture.keyHash,
2459
+ outcome,
2460
+ reason: error instanceof Error ? error.message : String(error),
2461
+ },
2462
+ });
2463
+ throw error;
2464
+ }
2465
+ };
2466
+ const publishAcceptedReceipt = () => {
2467
+ if (acceptedReceiptPublished)
2468
+ return;
2469
+ publishTerminalReceipt("message_completed");
2470
+ acceptedReceiptPublished = true;
2471
+ };
2079
2472
  try {
2080
- const capturedOptions = {
2081
- ...options,
2082
- currentIngressEvidence: Object.freeze({
2083
- schemaVersion: 1,
2084
- provider: "bluebubbles",
2085
- captureKeyHash: captured.capture.keyHash,
2086
- }),
2087
- orientationEvidence: captured.capture.event,
2088
- orientationConversationKind: captured.normalized.chat.isGroup ? "group" : "one_to_one",
2089
- };
2090
- const reactionClassification = await classifyCapturedBlueBubblesReaction(captured, resolvedDeps);
2473
+ options.lifecycleSignal?.throwIfAborted();
2474
+ const reactionClassification = await classifyCapturedBlueBubblesReaction(captured);
2475
+ options.lifecycleSignal?.throwIfAborted();
2091
2476
  const reactionDecision = reactionClassification?.decision ?? null;
2092
- if (reactionDecision?.route === "capture_only") {
2477
+ if (captured.capture.event.fromMe) {
2478
+ (0, runtime_1.emitNervesEvent)({
2479
+ component: "senses",
2480
+ event: "senses.bluebubbles_from_me_ignored",
2481
+ message: "ignored from-me bluebubbles event",
2482
+ meta: {
2483
+ messageGuid: captured.normalized.messageGuid,
2484
+ kind: captured.normalized.kind,
2485
+ },
2486
+ });
2487
+ if (captured.normalized.chat.isGroup) {
2488
+ recordDiscoveredOwnHandle(captured.normalized.sender.externalId);
2489
+ }
2093
2490
  const result = {
2094
2491
  handled: true,
2095
2492
  notifiedAgent: false,
2096
2493
  kind: captured.normalized.kind,
2097
- reason: reactionDecision.outcome === "ignored_self" ? "from_me" : "mutation_state_only",
2494
+ reason: "from_me",
2098
2495
  };
2099
- writeCapturedBlueBubblesHandled(agentName, captured.capture, reactionDecision.outcome);
2496
+ publishTerminalReceipt("ignored_self");
2497
+ semanticHandlingCompleted = true;
2100
2498
  return result;
2101
2499
  }
2102
- let handledEvent = captured.normalized;
2500
+ const durableOutbound = (0, outbound_state_1.readBlueBubblesOutboundRecordByIdempotencyKey)((0, identity_1.getAgentRoot)(), blueBubblesInboundReplyIdempotencyKey(captured.capture.keyHash));
2501
+ if (durableOutbound) {
2502
+ const outcome = blueBubblesRecoveredOutboundOutcome(durableOutbound);
2503
+ (0, runtime_1.emitNervesEvent)({
2504
+ level: "warn",
2505
+ component: "senses",
2506
+ event: "senses.bluebubbles_inbound_reply_recovery_suppressed",
2507
+ message: "suppressed bluebubbles inbound reply recovery at its durable send boundary",
2508
+ meta: {
2509
+ messageGuid: captured.normalized.messageGuid,
2510
+ recordId: durableOutbound.recordId,
2511
+ outboundStatus: durableOutbound.status,
2512
+ outcome,
2513
+ },
2514
+ });
2515
+ publishTerminalReceipt(outcome);
2516
+ semanticHandlingCompleted = true;
2517
+ return {
2518
+ handled: true,
2519
+ notifiedAgent: false,
2520
+ kind: captured.normalized.kind,
2521
+ reason: "already_processed",
2522
+ };
2523
+ }
2524
+ let handledOutcome = "message_observed";
2103
2525
  let result;
2104
2526
  if (isAuditOnlySemanticCapture(captured.capture)) {
2527
+ handledOutcome = auditOnlyHandledOutcome(captured.capture.event.kind);
2105
2528
  result = {
2106
2529
  handled: true,
2107
2530
  notifiedAgent: false,
@@ -2110,8 +2533,11 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2110
2533
  };
2111
2534
  }
2112
2535
  else {
2113
- const client = resolvedDeps.createClient();
2114
- const repaired = await client.repairEvent(captured.normalized);
2536
+ const captureOnlyMutation = isCaptureOnlyBlueBubblesMutation(captured.capture);
2537
+ const repaired = captureOnlyMutation && captured.normalized.chat.chatGuid?.trim()
2538
+ ? captured.normalized
2539
+ : await resolvedDeps.createClient().repairEvent(captured.normalized);
2540
+ options.lifecycleSignal?.throwIfAborted();
2115
2541
  if (semanticCaptureRequiresRoutingRepair(captured.capture)
2116
2542
  && !hasResolvedBlueBubblesRouting(repaired)) {
2117
2543
  throw new Error("semantic_capture_routing_invalid");
@@ -2121,31 +2547,89 @@ async function handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, so
2121
2547
  fromMe: captured.normalized.fromMe,
2122
2548
  sender: captured.normalized.sender,
2123
2549
  };
2124
- handledEvent = identityGroundedRepaired;
2125
- if (reactionDecision?.route === "restricted_feedback") {
2126
- const restrictedEvent = materializeRestrictedBlueBubblesReactionEvent(captured, repaired);
2127
- handledEvent = restrictedEvent;
2128
- result = await handleRestrictedBlueBubblesReactionFeedback({
2129
- event: restrictedEvent,
2130
- trustedFriend: reactionClassification.trustedFriend,
2131
- client,
2132
- resolvedDeps,
2133
- options: capturedOptions,
2134
- resolvedReactionTarget: captured.resolvedReactionTarget ?? options.resolvedReactionTarget,
2135
- });
2550
+ const promotion = (0, latest_turn_1.promote)(reservation, {
2551
+ chatGuid: identityGroundedRepaired.chat.chatGuid,
2552
+ chatIdentifier: identityGroundedRepaired.chat.chatIdentifier,
2553
+ }, {
2554
+ allowSameGenerationRetry: semanticHandling.allowSameGenerationRetry,
2555
+ });
2556
+ if (promotion.status !== "promoted") {
2557
+ if (!captureOnlyMutation && promotion.status === "unresolved") {
2558
+ throw new Error("semantic_capture_routing_invalid");
2559
+ }
2560
+ const disposition = captureOnlyMutation
2561
+ ? captureOnlyHandledDisposition(captured.capture, reactionDecision)
2562
+ : { outcome: "message_observed", detailCode: null };
2563
+ publishTerminalReceipt(disposition.outcome, disposition.detailCode);
2564
+ semanticHandlingCompleted = true;
2565
+ return {
2566
+ handled: true,
2567
+ notifiedAgent: false,
2568
+ kind: captured.normalized.kind,
2569
+ reason: captureOnlyMutation ? "mutation_state_only" : "superseded",
2570
+ };
2136
2571
  }
2137
- else {
2138
- result = await handleBlueBubblesNormalizedEvent(identityGroundedRepaired, resolvedDeps, source, {
2139
- ...capturedOptions,
2140
- resolvedReactionTarget: captured.resolvedReactionTarget ?? options.resolvedReactionTarget,
2141
- });
2572
+ latestTurnCapability = promotion.capability;
2573
+ if (captureOnlyMutation) {
2574
+ const disposition = captureOnlyHandledDisposition(captured.capture, reactionDecision);
2575
+ publishTerminalReceipt(disposition.outcome, disposition.detailCode);
2576
+ semanticHandlingCompleted = true;
2577
+ return {
2578
+ handled: true,
2579
+ notifiedAgent: false,
2580
+ kind: captured.normalized.kind,
2581
+ reason: "mutation_state_only",
2582
+ };
2583
+ }
2584
+ if (identityGroundedRepaired.kind === "mutation") {
2585
+ const outcome = auditOnlyHandledOutcome(identityGroundedRepaired.mutationType);
2586
+ publishTerminalReceipt(outcome);
2587
+ semanticHandlingCompleted = true;
2588
+ return {
2589
+ handled: true,
2590
+ notifiedAgent: false,
2591
+ kind: captured.normalized.kind,
2592
+ reason: "mutation_state_only",
2593
+ };
2142
2594
  }
2595
+ const canonicalEvent = canonicalizeBlueBubblesEvent(identityGroundedRepaired, latestTurnCapability);
2596
+ const capturedOptions = {
2597
+ ...options,
2598
+ latestTurnCapability,
2599
+ currentIngressEvidence: Object.freeze({
2600
+ schemaVersion: 1,
2601
+ provider: "bluebubbles",
2602
+ captureKeyHash: captured.capture.keyHash,
2603
+ }),
2604
+ orientationEvidence: captured.capture.event,
2605
+ orientationConversationKind: canonicalEvent.chat.isGroup ? "group" : "one_to_one",
2606
+ publishAcceptedReceipt,
2607
+ };
2608
+ result = await handleBlueBubblesNormalizedEvent(canonicalEvent, resolvedDeps, source, capturedOptions);
2609
+ handledOutcome = result.notifiedAgent ? "message_completed" : "message_observed";
2610
+ }
2611
+ if (result.notifiedAgent) {
2612
+ publishAcceptedReceipt();
2613
+ }
2614
+ else {
2615
+ publishTerminalReceipt(handledOutcome);
2143
2616
  }
2144
- writeCapturedBlueBubblesHandled(agentName, captured.capture, semanticHandledOutcome(handledEvent, result));
2617
+ semanticHandlingCompleted = true;
2145
2618
  return result;
2146
2619
  }
2147
2620
  finally {
2148
- (0, semantic_receipts_1.releaseBlueBubblesSemanticClaim)(agentName, claim);
2621
+ if (latestTurnCapability)
2622
+ (0, latest_turn_1.finish)(latestTurnCapability);
2623
+ try {
2624
+ if (!retainSemanticClaim) {
2625
+ (0, semantic_receipts_1.releaseBlueBubblesSemanticClaim)(agentName, claim);
2626
+ }
2627
+ }
2628
+ finally {
2629
+ releaseBlueBubblesSemanticHandlingSlot(identity.keyHash, semanticHandling.slot, retainSemanticClaim
2630
+ ? "retained"
2631
+ : semanticHandlingCompleted ? "terminal" : "retryable");
2632
+ }
2149
2633
  }
2150
2634
  }
2151
2635
  async function handleBlueBubblesEvent(payload, deps = {}) {
@@ -2172,11 +2656,22 @@ async function handleBlueBubblesEvent(payload, deps = {}) {
2172
2656
  }
2173
2657
  throw error;
2174
2658
  }
2175
- const captured = await captureBlueBubblesSemanticEvent(normalized, resolvedDeps, "webhook");
2659
+ const observationReservation = reserveBlueBubblesObservation(normalized);
2660
+ let captured;
2661
+ try {
2662
+ captured = await captureBlueBubblesSemanticEvent(normalized, resolvedDeps, "webhook", observationReservation);
2663
+ }
2664
+ catch (error) {
2665
+ (0, latest_turn_1.clearPending)(observationReservation);
2666
+ throw error;
2667
+ }
2176
2668
  if (captured.status === "audit_only") {
2669
+ (0, latest_turn_1.clearPending)(observationReservation);
2177
2670
  return { handled: true, notifiedAgent: false, kind: normalized.kind, reason: "ignored" };
2178
2671
  }
2179
- return handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, "webhook");
2672
+ return handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, "webhook", {
2673
+ observationReservation,
2674
+ });
2180
2675
  }
2181
2676
  function parseTimestampMs(value) {
2182
2677
  if (!value)
@@ -2220,14 +2715,20 @@ function blueBubblesPendingRecoverySnapshot(agentName, nowMs = Date.now()) {
2220
2715
  oldestPendingRecoveryAgeMs: oldest ? Math.max(0, nowMs - oldest.ms) : undefined,
2221
2716
  };
2222
2717
  }
2223
- async function syncBlueBubblesRuntime(deps = {}) {
2718
+ async function syncBlueBubblesRuntime(deps = {}, options = {}) {
2719
+ options.lifecycleSignal?.throwIfAborted();
2224
2720
  const resolvedDeps = { ...defaultDeps, ...deps };
2225
2721
  const agentName = resolvedDeps.getAgentName();
2722
+ // Allocate before the first health-probe await. Any live webhook accepted
2723
+ // while that probe is pending must remain causally newer than this pass.
2724
+ const catchUpObservationBatch = options.catchUpObservationBatch ?? (0, latest_turn_1.beginObservationBatch)(BLUEBUBBLES_CATCHUP_PAGE_SIZE * BLUEBUBBLES_CATCHUP_MAX_PAGES);
2725
+ pruneBlueBubblesSemanticObservationGenerations();
2226
2726
  const client = resolvedDeps.createClient();
2227
2727
  const checkedAt = new Date().toISOString();
2228
2728
  const previousState = (0, runtime_state_1.readBlueBubblesRuntimeState)(agentName);
2229
2729
  try {
2230
2730
  await client.checkHealth();
2731
+ options.lifecycleSignal?.throwIfAborted();
2231
2732
  const pendingBeforeCatchup = blueBubblesPendingRecoverySnapshot(agentName);
2232
2733
  const activeBeforeCatchup = (0, active_turns_1.snapshotBlueBubblesActiveTurns)(agentName, BLUEBUBBLES_LIVE_TURN_STALLED_MS);
2233
2734
  (0, runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
@@ -2242,7 +2743,10 @@ async function syncBlueBubblesRuntime(deps = {}) {
2242
2743
  });
2243
2744
  const catchUp = await catchUpMissedBlueBubblesMessages(resolvedDeps, previousState, {
2244
2745
  processTurns: false,
2746
+ observationBatch: catchUpObservationBatch,
2747
+ lifecycleSignal: options.lifecycleSignal,
2245
2748
  });
2749
+ options.lifecycleSignal?.throwIfAborted();
2246
2750
  const failed = catchUp.failed;
2247
2751
  const pendingAfterCatchup = blueBubblesPendingRecoverySnapshot(agentName);
2248
2752
  const activeAfterCatchup = (0, active_turns_1.snapshotBlueBubblesActiveTurns)(agentName, BLUEBUBBLES_LIVE_TURN_STALLED_MS);
@@ -2265,6 +2769,8 @@ async function syncBlueBubblesRuntime(deps = {}) {
2265
2769
  });
2266
2770
  }
2267
2771
  catch (error) {
2772
+ if (options.lifecycleSignal?.aborted)
2773
+ return;
2268
2774
  (0, runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
2269
2775
  upstreamStatus: "error",
2270
2776
  detail: error instanceof Error ? error.message : String(error),
@@ -2276,7 +2782,8 @@ async function syncBlueBubblesRuntime(deps = {}) {
2276
2782
  });
2277
2783
  }
2278
2784
  }
2279
- async function recoverQueuedBlueBubblesMessages(deps = {}) {
2785
+ async function recoverQueuedBlueBubblesMessages(deps = {}, options = {}) {
2786
+ options.lifecycleSignal?.throwIfAborted();
2280
2787
  const resolvedDeps = { ...defaultDeps, ...deps };
2281
2788
  const agentName = resolvedDeps.getAgentName();
2282
2789
  const previousState = (0, runtime_state_1.readBlueBubblesRuntimeState)(agentName);
@@ -2284,8 +2791,10 @@ async function recoverQueuedBlueBubblesMessages(deps = {}) {
2284
2791
  if (initialPending === 0) {
2285
2792
  return { recovered: 0, skipped: 0, failed: 0, pendingRecoveryCount: 0 };
2286
2793
  }
2287
- const captured = await recoverCapturedBlueBubblesInboundMessages(resolvedDeps);
2794
+ const captured = await recoverCapturedBlueBubblesInboundMessages(resolvedDeps, options);
2795
+ options.lifecycleSignal?.throwIfAborted();
2288
2796
  const recovery = await recoverMissedBlueBubblesMessages(resolvedDeps);
2797
+ options.lifecycleSignal?.throwIfAborted();
2289
2798
  const pendingSnapshot = blueBubblesPendingRecoverySnapshot(agentName);
2290
2799
  const pendingRecoveryCount = pendingSnapshot.pendingRecoveryCount;
2291
2800
  const failed = captured.failed + recovery.failed;
@@ -2294,6 +2803,7 @@ async function recoverQueuedBlueBubblesMessages(deps = {}) {
2294
2803
  const checkedAt = new Date().toISOString();
2295
2804
  try {
2296
2805
  await resolvedDeps.createClient().checkHealth();
2806
+ options.lifecycleSignal?.throwIfAborted();
2297
2807
  const activeSnapshot = (0, active_turns_1.snapshotBlueBubblesActiveTurns)(agentName, BLUEBUBBLES_LIVE_TURN_STALLED_MS);
2298
2808
  (0, runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
2299
2809
  upstreamStatus: "ok",
@@ -2308,6 +2818,8 @@ async function recoverQueuedBlueBubblesMessages(deps = {}) {
2308
2818
  });
2309
2819
  }
2310
2820
  catch (error) {
2821
+ if (options.lifecycleSignal?.aborted)
2822
+ throw options.lifecycleSignal.reason;
2311
2823
  (0, runtime_state_1.writeBlueBubblesRuntimeState)(agentName, {
2312
2824
  upstreamStatus: "error",
2313
2825
  detail: error instanceof Error ? error.message : String(error),
@@ -2323,6 +2835,7 @@ async function recoverQueuedBlueBubblesMessages(deps = {}) {
2323
2835
  return { recovered, skipped, failed, pendingRecoveryCount };
2324
2836
  }
2325
2837
  async function catchUpMissedBlueBubblesMessages(deps = {}, previousState, options = {}) {
2838
+ options.lifecycleSignal?.throwIfAborted();
2326
2839
  const resolvedDeps = { ...defaultDeps, ...deps };
2327
2840
  const agentName = resolvedDeps.getAgentName();
2328
2841
  const client = resolvedDeps.createClient();
@@ -2344,16 +2857,21 @@ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState, option
2344
2857
  maxPages: BLUEBUBBLES_CATCHUP_MAX_PAGES,
2345
2858
  },
2346
2859
  });
2347
- const recentEvents = [];
2860
+ const preparedCandidates = [];
2861
+ const seenMessageGuids = new Set();
2862
+ const observationBatch = options.observationBatch ?? (0, latest_turn_1.beginObservationBatch)(BLUEBUBBLES_CATCHUP_PAGE_SIZE * BLUEBUBBLES_CATCHUP_MAX_PAGES);
2348
2863
  for (let page = 0; page < BLUEBUBBLES_CATCHUP_MAX_PAGES; page++) {
2349
2864
  let pageEvents;
2350
2865
  try {
2351
2866
  pageEvents = await client.listRecentMessages({
2352
2867
  limit: BLUEBUBBLES_CATCHUP_PAGE_SIZE,
2353
2868
  offset: page * BLUEBUBBLES_CATCHUP_PAGE_SIZE,
2869
+ beforeTimestamp: observationBatch.beforeTimestamp,
2354
2870
  });
2355
2871
  }
2356
2872
  catch (error) {
2873
+ if (options.lifecycleSignal?.aborted)
2874
+ throw options.lifecycleSignal.reason;
2357
2875
  result.failed++;
2358
2876
  (0, runtime_1.emitNervesEvent)({
2359
2877
  level: "warn",
@@ -2367,11 +2885,36 @@ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState, option
2367
2885
  });
2368
2886
  break;
2369
2887
  }
2370
- recentEvents.push(...pageEvents);
2888
+ options.lifecycleSignal?.throwIfAborted();
2889
+ const pageMessages = pageEvents
2890
+ .filter((event) => event.kind === "message")
2891
+ .sort((left, right) => right.timestamp - left.timestamp);
2892
+ for (let pageIndex = 0; pageIndex < pageMessages.length; pageIndex++) {
2893
+ const event = pageMessages[pageIndex];
2894
+ if (seenMessageGuids.has(event.messageGuid))
2895
+ continue;
2896
+ seenMessageGuids.add(event.messageGuid);
2897
+ if (event.fromMe
2898
+ || event.timestamp < catchUpSince
2899
+ || (Number.isFinite(event.timestamp) && event.timestamp > observationBatch.beforeTimestamp)) {
2900
+ preparedCandidates.push({ status: "skip", event });
2901
+ continue;
2902
+ }
2903
+ // The bounded ordinal range and upstream snapshot were fixed before the
2904
+ // first query. Each returned page can therefore publish its fences
2905
+ // before the next await, while later ingress stays outside this pass.
2906
+ preparedCandidates.push({
2907
+ status: "ready",
2908
+ event,
2909
+ observationReservation: (0, latest_turn_1.reserveObservationFromBatch)(observationBatch, page * BLUEBUBBLES_CATCHUP_PAGE_SIZE + pageIndex, {
2910
+ chatGuid: event.chat.chatGuid,
2911
+ chatIdentifier: event.chat.chatIdentifier,
2912
+ }),
2913
+ });
2914
+ }
2371
2915
  if (pageEvents.length < BLUEBUBBLES_CATCHUP_PAGE_SIZE)
2372
2916
  break;
2373
- const oldestMessageTimestamp = pageEvents
2374
- .filter((event) => event.kind === "message")
2917
+ const oldestMessageTimestamp = pageMessages
2375
2918
  .reduce((oldest, event) => Math.min(oldest, event.timestamp), Number.POSITIVE_INFINITY);
2376
2919
  if (oldestMessageTimestamp <= catchUpSince)
2377
2920
  break;
@@ -2389,68 +2932,91 @@ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState, option
2389
2932
  });
2390
2933
  }
2391
2934
  }
2392
- const seenMessageGuids = new Set();
2393
- const candidates = recentEvents
2394
- .filter((event) => event.kind === "message")
2395
- .filter((event) => {
2396
- if (seenMessageGuids.has(event.messageGuid))
2397
- return false;
2398
- seenMessageGuids.add(event.messageGuid);
2399
- return true;
2400
- })
2401
- .sort((left, right) => left.timestamp - right.timestamp);
2402
- for (const event of candidates) {
2935
+ for (const prepared of preparedCandidates) {
2403
2936
  result.inspected++;
2404
- if (event.fromMe
2405
- || event.timestamp < catchUpSince) {
2937
+ if (prepared.status === "skip") {
2406
2938
  result.skipped++;
2407
- continue;
2408
2939
  }
2409
- try {
2410
- const captured = await captureBlueBubblesSemanticEvent(event, resolvedDeps, "upstream-catchup");
2411
- if (captured.status === "audit_only") {
2412
- result.skipped++;
2413
- continue;
2414
- }
2415
- if (!processTurns) {
2416
- if (captured.writeResult === "semantic_capture_duplicate") {
2940
+ }
2941
+ const readyCandidates = preparedCandidates.filter((prepared) => prepared.status === "ready");
2942
+ let lastRecoveredOrdinal = -1;
2943
+ await Promise.all(groupBlueBubblesObservationsByLane(readyCandidates).map(async (lane) => {
2944
+ for (const prepared of lane) {
2945
+ const { event, observationReservation } = prepared;
2946
+ let semanticGenerationOwned = false;
2947
+ try {
2948
+ options.lifecycleSignal?.throwIfAborted();
2949
+ const captured = await captureBlueBubblesSemanticEvent(event, resolvedDeps, "upstream-catchup", observationReservation);
2950
+ if (captured.status === "audit_only") {
2951
+ (0, latest_turn_1.clearPending)(observationReservation);
2952
+ result.skipped++;
2953
+ continue;
2954
+ }
2955
+ if (!processTurns) {
2956
+ const generation = adoptBlueBubblesSemanticObservation(captured.capture.keyHash, observationReservation, false);
2957
+ semanticGenerationOwned = true;
2958
+ if (captured.writeResult === "semantic_capture_duplicate") {
2959
+ if (!activeSemanticHandlingSlots.has(captured.capture.keyHash)) {
2960
+ suspendBlueBubblesSemanticObservation(generation);
2961
+ }
2962
+ result.skipped++;
2963
+ }
2964
+ else {
2965
+ // Runtime sync only captures work for the delayed recovery pass,
2966
+ // but its original generation must revoke older work now and be
2967
+ // reused by the queued recovery pass after any intervening turn.
2968
+ const promotion = (0, latest_turn_1.promote)(generation.reservation, {
2969
+ chatGuid: event.chat.chatGuid,
2970
+ chatIdentifier: event.chat.chatIdentifier,
2971
+ });
2972
+ if (promotion.status === "promoted")
2973
+ (0, latest_turn_1.finish)(promotion.capability);
2974
+ suspendBlueBubblesSemanticObservation(generation);
2975
+ result.queued = (result.queued ?? 0) + 1;
2976
+ }
2977
+ continue;
2978
+ }
2979
+ semanticGenerationOwned = true;
2980
+ const handled = await handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, "upstream-catchup", {
2981
+ timeoutMs: BLUEBUBBLES_RECOVERY_TURN_TIMEOUT_MS,
2982
+ autonomyBudgetTrigger: "recovery",
2983
+ observationReservation,
2984
+ lifecycleSignal: options.lifecycleSignal,
2985
+ });
2986
+ if (handled.reason === "semantic_claim_timeout") {
2987
+ continue;
2988
+ }
2989
+ if (shouldCountBlueBubblesRecoveryResultAsSkipped(handled.reason) || !handled.notifiedAgent) {
2417
2990
  result.skipped++;
2418
2991
  }
2419
2992
  else {
2420
- result.queued = (result.queued ?? 0) + 1;
2993
+ result.recovered++;
2994
+ if (observationReservation.ordinal > lastRecoveredOrdinal) {
2995
+ lastRecoveredOrdinal = observationReservation.ordinal;
2996
+ result.lastRecoveredMessageGuid = event.messageGuid;
2997
+ }
2421
2998
  }
2422
- continue;
2423
- }
2424
- const handled = await handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, "upstream-catchup", {
2425
- timeoutMs: BLUEBUBBLES_RECOVERY_TURN_TIMEOUT_MS,
2426
- autonomyBudgetTrigger: "recovery",
2427
- });
2428
- if (handled.reason === "semantic_claim_timeout") {
2429
- continue;
2430
- }
2431
- if (shouldCountBlueBubblesRecoveryResultAsSkipped(handled.reason) || !handled.notifiedAgent) {
2432
- result.skipped++;
2433
2999
  }
2434
- else {
2435
- result.recovered++;
2436
- result.lastRecoveredMessageGuid = event.messageGuid;
3000
+ catch (error) {
3001
+ if (!semanticGenerationOwned)
3002
+ (0, latest_turn_1.clearPending)(observationReservation);
3003
+ if (options.lifecycleSignal?.aborted)
3004
+ throw options.lifecycleSignal.reason;
3005
+ recordBlueBubblesRecoveryFailureForBudget(agentName, event, "upstream-catchup");
3006
+ result.failed++;
3007
+ (0, runtime_1.emitNervesEvent)({
3008
+ level: "warn",
3009
+ component: "senses",
3010
+ event: "senses.bluebubbles_catchup_error",
3011
+ message: "bluebubbles upstream catch-up message failed",
3012
+ meta: {
3013
+ messageGuid: event.messageGuid,
3014
+ reason: error instanceof Error ? error.message : String(error),
3015
+ },
3016
+ });
2437
3017
  }
2438
3018
  }
2439
- catch (error) {
2440
- recordBlueBubblesRecoveryFailureForBudget(agentName, event, "upstream-catchup");
2441
- result.failed++;
2442
- (0, runtime_1.emitNervesEvent)({
2443
- level: "warn",
2444
- component: "senses",
2445
- event: "senses.bluebubbles_catchup_error",
2446
- message: "bluebubbles upstream catch-up message failed",
2447
- meta: {
2448
- messageGuid: event.messageGuid,
2449
- reason: error instanceof Error ? error.message : String(error),
2450
- },
2451
- });
2452
- }
2453
- }
3019
+ }));
2454
3020
  if (result.inspected > 0 || result.recovered > 0 || result.skipped > 0 || result.failed > 0) {
2455
3021
  (0, runtime_1.emitNervesEvent)({
2456
3022
  component: "senses",
@@ -2461,52 +3027,47 @@ async function catchUpMissedBlueBubblesMessages(deps = {}, previousState, option
2461
3027
  }
2462
3028
  return result;
2463
3029
  }
2464
- async function recoverCapturedBlueBubblesInboundMessages(deps = {}) {
3030
+ async function recoverCapturedBlueBubblesInboundMessages(deps = {}, options = {}) {
3031
+ options.lifecycleSignal?.throwIfAborted();
2465
3032
  const resolvedDeps = { ...defaultDeps, ...deps };
2466
3033
  const agentName = resolvedDeps.getAgentName();
2467
3034
  const cutover = (0, semantic_receipts_1.initializeBlueBubblesSemanticCutover)(agentName);
2468
3035
  const result = { recovered: 0, skipped: 0, failed: 0 };
2469
3036
  const seenKeyHashes = new Set();
2470
3037
  const candidates = (0, semantic_receipts_1.listPendingBlueBubblesSemanticCaptures)(agentName)
2471
- .sort((left, right) => left.capturedAt.localeCompare(right.capturedAt));
3038
+ .sort(semantic_receipts_1.compareBlueBubblesSemanticCaptureOrder);
3039
+ const preparedCandidates = [];
2472
3040
  for (const capture of candidates) {
3041
+ options.lifecycleSignal?.throwIfAborted();
2473
3042
  if (seenKeyHashes.has(capture.keyHash)) {
2474
- result.skipped++;
3043
+ preparedCandidates.push({ status: "skip", capture });
2475
3044
  continue;
2476
3045
  }
2477
3046
  seenKeyHashes.add(capture.keyHash);
2478
3047
  const disposition = (0, semantic_receipts_1.classifyBlueBubblesRecoveryRecord)(capture, cutover);
2479
3048
  if (disposition.disposition === "audit_only" && disposition.reason !== "audit_event") {
2480
- result.skipped++;
3049
+ preparedCandidates.push({ status: "skip", capture });
2481
3050
  continue;
2482
3051
  }
2483
3052
  const normalized = semanticCaptureToNormalizedEvent(capture);
2484
- try {
2485
- if (!normalized)
2486
- throw new Error("semantic_capture_routing_invalid");
2487
- const handled = await handleCapturedBlueBubblesSemanticEvent({
2488
- status: "captured",
2489
- capture,
2490
- normalized,
2491
- writeResult: "semantic_capture_duplicate",
2492
- }, resolvedDeps, "webhook", {
2493
- timeoutMs: BLUEBUBBLES_RECOVERY_TURN_TIMEOUT_MS,
2494
- autonomyBudgetTrigger: "recovery",
2495
- semanticRecovery: true,
2496
- });
2497
- if (handled.reason === "semantic_claim_timeout") {
2498
- continue;
2499
- }
2500
- if (shouldCountBlueBubblesRecoveryResultAsSkipped(handled.reason) || !handled.notifiedAgent) {
2501
- result.skipped++;
2502
- }
2503
- else {
2504
- result.recovered++;
2505
- }
3053
+ if (!normalized) {
3054
+ preparedCandidates.push({ status: "invalid", capture });
3055
+ continue;
2506
3056
  }
2507
- catch (error) {
2508
- if (normalized)
2509
- recordBlueBubblesRecoveryFailureForBudget(agentName, normalized, "webhook");
3057
+ preparedCandidates.push({
3058
+ status: "ready",
3059
+ capture,
3060
+ normalized,
3061
+ observationReservation: reserveBlueBubblesObservation(normalized),
3062
+ });
3063
+ }
3064
+ for (const prepared of preparedCandidates) {
3065
+ const { capture } = prepared;
3066
+ if (prepared.status === "skip") {
3067
+ result.skipped++;
3068
+ continue;
3069
+ }
3070
+ if (prepared.status === "invalid") {
2510
3071
  result.failed++;
2511
3072
  (0, runtime_1.emitNervesEvent)({
2512
3073
  level: "warn",
@@ -2516,11 +3077,58 @@ async function recoverCapturedBlueBubblesInboundMessages(deps = {}) {
2516
3077
  meta: {
2517
3078
  messageGuid: capture.event.eventGuid,
2518
3079
  sessionKey: capture.event.sessionKey,
2519
- reason: error instanceof Error ? error.message : String(error),
3080
+ reason: "semantic_capture_routing_invalid",
2520
3081
  },
2521
3082
  });
2522
3083
  }
2523
3084
  }
3085
+ const readyCandidates = preparedCandidates.filter((prepared) => prepared.status === "ready");
3086
+ await Promise.all(groupBlueBubblesObservationsByLane(readyCandidates).map(async (lane) => {
3087
+ for (const prepared of lane) {
3088
+ const { capture, normalized, observationReservation } = prepared;
3089
+ try {
3090
+ options.lifecycleSignal?.throwIfAborted();
3091
+ const handled = await handleCapturedBlueBubblesSemanticEvent({
3092
+ status: "captured",
3093
+ capture,
3094
+ normalized,
3095
+ writeResult: "semantic_capture_duplicate",
3096
+ }, resolvedDeps, "webhook", {
3097
+ timeoutMs: BLUEBUBBLES_RECOVERY_TURN_TIMEOUT_MS,
3098
+ autonomyBudgetTrigger: "recovery",
3099
+ semanticRecovery: true,
3100
+ observationReservation,
3101
+ lifecycleSignal: options.lifecycleSignal,
3102
+ });
3103
+ if (handled.reason === "semantic_claim_timeout") {
3104
+ continue;
3105
+ }
3106
+ if (shouldCountBlueBubblesRecoveryResultAsSkipped(handled.reason) || !handled.notifiedAgent) {
3107
+ result.skipped++;
3108
+ }
3109
+ else {
3110
+ result.recovered++;
3111
+ }
3112
+ }
3113
+ catch (error) {
3114
+ if (options.lifecycleSignal?.aborted)
3115
+ throw options.lifecycleSignal.reason;
3116
+ recordBlueBubblesRecoveryFailureForBudget(agentName, normalized, "webhook");
3117
+ result.failed++;
3118
+ (0, runtime_1.emitNervesEvent)({
3119
+ level: "warn",
3120
+ component: "senses",
3121
+ event: "senses.bluebubbles_capture_recovery_error",
3122
+ message: "captured bluebubbles message recovery failed",
3123
+ meta: {
3124
+ messageGuid: capture.event.eventGuid,
3125
+ sessionKey: capture.event.sessionKey,
3126
+ reason: error instanceof Error ? error.message : String(error),
3127
+ },
3128
+ });
3129
+ }
3130
+ }
3131
+ }));
2524
3132
  const semanticEventGuids = new Set(candidates
2525
3133
  .map((capture) => capture.event.eventGuid)
2526
3134
  .filter((eventGuid) => typeof eventGuid === "string")
@@ -2552,7 +3160,7 @@ async function recoverMissedBlueBubblesMessages(deps = {}) {
2552
3160
  }
2553
3161
  return result;
2554
3162
  }
2555
- function createBlueBubblesWebhookHandler(deps = {}) {
3163
+ function createBlueBubblesWebhookHandler(deps = {}, options = {}) {
2556
3164
  return async (req, res) => {
2557
3165
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
2558
3166
  if (url.pathname === "/health") {
@@ -2633,11 +3241,13 @@ function createBlueBubblesWebhookHandler(deps = {}) {
2633
3241
  return;
2634
3242
  }
2635
3243
  const resolvedDeps = { ...defaultDeps, ...deps };
3244
+ const observationReservation = reserveBlueBubblesObservation(normalized);
2636
3245
  let captured;
2637
3246
  try {
2638
- captured = await captureBlueBubblesSemanticEvent(normalized, resolvedDeps, "webhook");
3247
+ captured = await captureBlueBubblesSemanticEvent(normalized, resolvedDeps, "webhook", observationReservation);
2639
3248
  }
2640
3249
  catch (error) {
3250
+ (0, latest_turn_1.clearPending)(observationReservation);
2641
3251
  (0, runtime_1.emitNervesEvent)({
2642
3252
  level: "error",
2643
3253
  component: "senses",
@@ -2652,6 +3262,7 @@ function createBlueBubblesWebhookHandler(deps = {}) {
2652
3262
  return;
2653
3263
  }
2654
3264
  if (captured.status === "audit_only") {
3265
+ (0, latest_turn_1.clearPending)(observationReservation);
2655
3266
  writeJson(res, 200, {
2656
3267
  handled: true,
2657
3268
  notifiedAgent: false,
@@ -2669,7 +3280,16 @@ function createBlueBubblesWebhookHandler(deps = {}) {
2669
3280
  reason: "queued",
2670
3281
  });
2671
3282
  setTimeout(() => {
2672
- void handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, "webhook").catch((error) => {
3283
+ if (options.lifecycleSignal?.aborted) {
3284
+ (0, latest_turn_1.clearPending)(observationReservation);
3285
+ return;
3286
+ }
3287
+ void handleCapturedBlueBubblesSemanticEvent(captured, resolvedDeps, "webhook", {
3288
+ observationReservation,
3289
+ lifecycleSignal: options.lifecycleSignal,
3290
+ }).catch((error) => {
3291
+ if (options.lifecycleSignal?.aborted)
3292
+ return;
2673
3293
  (0, runtime_1.emitNervesEvent)({
2674
3294
  level: "error",
2675
3295
  component: "senses",
@@ -3088,20 +3708,35 @@ async function drainAndSendPendingBlueBubbles(deps = {}, pendingRoot) {
3088
3708
  }
3089
3709
  function startBlueBubblesApp(deps = {}) {
3090
3710
  const resolvedDeps = { ...defaultDeps, ...deps };
3711
+ const lifecycleController = new AbortController();
3091
3712
  (0, semantic_receipts_1.initializeBlueBubblesSemanticCutover)(resolvedDeps.getAgentName());
3092
3713
  resolvedDeps.createClient();
3714
+ seedBlueBubblesStartupSemanticObservations(resolvedDeps);
3715
+ const startupCatchUpObservationBatch = (0, latest_turn_1.beginObservationBatch)(BLUEBUBBLES_CATCHUP_PAGE_SIZE * BLUEBUBBLES_CATCHUP_MAX_PAGES);
3093
3716
  const channelConfig = (0, config_1.getBlueBubblesChannelConfig)();
3094
- const server = resolvedDeps.createServer(createBlueBubblesWebhookHandler(deps));
3717
+ const server = resolvedDeps.createServer(createBlueBubblesWebhookHandler(deps, {
3718
+ lifecycleSignal: lifecycleController.signal,
3719
+ }));
3095
3720
  let recoveryPassRunning = false;
3721
+ let runtimeSyncRunning = false;
3096
3722
  let recoveryDelayTimer = null;
3723
+ let webhookReconciler = null;
3724
+ let closed = false;
3097
3725
  function triggerRecoveryPass() {
3726
+ /* v8 ignore next -- close clears both recovery timers; guard covers an already-queued host callback @preserve */
3727
+ if (closed)
3728
+ return;
3098
3729
  /* v8 ignore next -- re-entrant timer guard; difficult to force deterministically without timing the turn lock @preserve */
3099
3730
  if (recoveryPassRunning)
3100
3731
  return;
3101
3732
  recoveryPassRunning = true;
3102
- void recoverQueuedBlueBubblesMessages(resolvedDeps)
3733
+ void recoverQueuedBlueBubblesMessages(resolvedDeps, {
3734
+ lifecycleSignal: lifecycleController.signal,
3735
+ })
3103
3736
  /* v8 ignore start -- defensive wrapper; expected per-message failures are handled inside recovery helpers @preserve */
3104
3737
  .catch((error) => {
3738
+ if (closed || lifecycleController.signal.aborted)
3739
+ return;
3105
3740
  (0, runtime_1.emitNervesEvent)({
3106
3741
  level: "warn",
3107
3742
  component: "senses",
@@ -3116,6 +3751,8 @@ function startBlueBubblesApp(deps = {}) {
3116
3751
  });
3117
3752
  }
3118
3753
  function scheduleRecoveryPass() {
3754
+ if (closed)
3755
+ return;
3119
3756
  /* v8 ignore next -- duplicate scheduling guard for overlapping health sync completions @preserve */
3120
3757
  if (recoveryDelayTimer !== null)
3121
3758
  return;
@@ -3124,11 +3761,42 @@ function startBlueBubblesApp(deps = {}) {
3124
3761
  triggerRecoveryPass();
3125
3762
  }, BLUEBUBBLES_RECOVERY_PASS_DELAY_MS);
3126
3763
  }
3764
+ function triggerRuntimeSync(catchUpObservationBatch) {
3765
+ if (closed || runtimeSyncRunning)
3766
+ return;
3767
+ runtimeSyncRunning = true;
3768
+ void syncBlueBubblesRuntime(resolvedDeps, {
3769
+ ...(catchUpObservationBatch ? { catchUpObservationBatch } : {}),
3770
+ lifecycleSignal: lifecycleController.signal,
3771
+ })
3772
+ .then(scheduleRecoveryPass)
3773
+ .catch((error) => {
3774
+ if (closed)
3775
+ return;
3776
+ (0, runtime_1.emitNervesEvent)({
3777
+ level: "warn",
3778
+ component: "senses",
3779
+ event: "senses.bluebubbles_recovery_error",
3780
+ message: "bluebubbles runtime sync failed",
3781
+ meta: { reason: error instanceof Error ? error.message : String(error) },
3782
+ });
3783
+ })
3784
+ .finally(() => {
3785
+ runtimeSyncRunning = false;
3786
+ });
3787
+ }
3127
3788
  const runtimeTimer = setInterval(() => {
3128
- void syncBlueBubblesRuntime(resolvedDeps).then(scheduleRecoveryPass);
3789
+ /* v8 ignore next -- close clears this interval; guard covers an already-queued host callback @preserve */
3790
+ if (closed)
3791
+ return;
3792
+ triggerRuntimeSync();
3129
3793
  }, BLUEBUBBLES_RUNTIME_SYNC_INTERVAL_MS);
3130
3794
  const recoveryTimer = setInterval(triggerRecoveryPass, BLUEBUBBLES_RECOVERY_PASS_INTERVAL_MS);
3131
3795
  server.on?.("close", () => {
3796
+ closed = true;
3797
+ webhookReconciler?.close();
3798
+ webhookReconciler = null;
3799
+ lifecycleController.abort(new Error("bluebubbles_runtime_shutdown"));
3132
3800
  clearInterval(runtimeTimer);
3133
3801
  clearInterval(recoveryTimer);
3134
3802
  if (recoveryDelayTimer !== null) {
@@ -3137,6 +3805,17 @@ function startBlueBubblesApp(deps = {}) {
3137
3805
  }
3138
3806
  });
3139
3807
  server.listen(channelConfig.port, () => {
3808
+ const blueBubblesConfig = (0, config_1.getBlueBubblesConfig)();
3809
+ webhookReconciler = (0, webhook_registration_1.createBlueBubblesWebhookReconciler)({
3810
+ serverUrl: blueBubblesConfig.serverUrl,
3811
+ password: blueBubblesConfig.password,
3812
+ callbackPort: channelConfig.port,
3813
+ callbackPath: channelConfig.webhookPath,
3814
+ agentName: resolvedDeps.getAgentName(),
3815
+ machineId: (0, machine_identity_1.loadOrCreateMachineIdentity)().machineId,
3816
+ requestTimeoutMs: channelConfig.requestTimeoutMs,
3817
+ listenerReady: true,
3818
+ });
3140
3819
  (0, runtime_1.emitNervesEvent)({
3141
3820
  component: "channels",
3142
3821
  event: "channel.app_started",
@@ -3144,6 +3823,6 @@ function startBlueBubblesApp(deps = {}) {
3144
3823
  meta: { port: channelConfig.port, webhookPath: channelConfig.webhookPath },
3145
3824
  });
3146
3825
  });
3147
- void syncBlueBubblesRuntime(resolvedDeps).then(scheduleRecoveryPass);
3826
+ triggerRuntimeSync(startupCatchUpObservationBatch);
3148
3827
  return server;
3149
3828
  }