@canonmsg/agent-sdk 11.0.1 → 11.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,7 +45,7 @@ export function createCanonAttachedSession(options) {
45
45
  let recoveryPromise;
46
46
  let nativeReconcilePromise;
47
47
  const tasks = new Set();
48
- const receiving = new Set();
48
+ const receiving = new Map();
49
49
  const reportedHistory = new Set();
50
50
  let publicationFailed = false;
51
51
  let historyIncomplete = false;
@@ -84,7 +84,10 @@ export function createCanonAttachedSession(options) {
84
84
  const transcriptPublisher = createCanonAttachedSessionPublisher(client, binding);
85
85
  const core = createAttachedNativeSession({
86
86
  binding, store: options.store, adapter: options.native,
87
- beforeNativeSubmit: (input) => endpoint.claimInbound(`message:${binding.conversationId}:${input.messageId}`),
87
+ submitNative: (input, submit) => {
88
+ const handoff = receiving.get(input.messageId);
89
+ return handoff ? endpoint.startInbound(handoff, submit) : Promise.resolve({ status: 'unavailable' });
90
+ },
88
91
  isPublicationReady: () => options.transport?.isRouteActive?.(binding.conversationId) !== false,
89
92
  publisher: {
90
93
  prepare(input) {
@@ -194,11 +197,11 @@ export function createCanonAttachedSession(options) {
194
197
  }
195
198
  return true;
196
199
  }
197
- async function receive(payload) {
200
+ async function receive(payload, handoff) {
198
201
  if (!running || stopped || payload.conversationId !== binding.conversationId)
199
- return;
202
+ return 'deferred';
200
203
  if (options.transport?.isRouteActive?.(binding.conversationId) === false)
201
- return;
204
+ return 'deferred';
202
205
  const message = payload.message;
203
206
  if (message.senderId === binding.agentId || core.hasCanonMessage(message.id)
204
207
  || core.isBeforeCanonBaseline(message.id, message.createdAt ?? '') || receiving.has(message.id))
@@ -208,7 +211,7 @@ export function createCanonAttachedSession(options) {
208
211
  emit({ status: 'blocked', reason: 'invalid-provenance', messageId: message.id });
209
212
  return;
210
213
  }
211
- receiving.add(message.id);
214
+ receiving.set(message.id, handoff);
212
215
  try {
213
216
  if (payload.turnDispatch?.kind !== 'run_turn') {
214
217
  await core.recordSkippedCanonMessage(message.id, 'not-dispatched');
@@ -240,12 +243,10 @@ export function createCanonAttachedSession(options) {
240
243
  });
241
244
  // A conclusive provider acknowledgement settles command delivery. Native
242
245
  // turn progress and transcript correlation remain in the provider journal.
243
- const inputId = `message:${binding.conversationId}:${message.id}`;
244
- if (result.status === 'uncertain' && result.messageId === message.id) {
245
- await endpoint.setInboundState(inputId, 'uncertain', 'native-reconciliation-required');
246
- }
247
- else if (!(result.status === 'not_submitted' && result.reason === 'This input already has another execution owner.')) {
248
- await endpoint.setInboundState(inputId, 'settled', `native-${result.status}`);
246
+ if (result.status === 'deferred')
247
+ return 'deferred';
248
+ if (!(result.status === 'uncertain' && result.messageId === message.id)) {
249
+ await handoff.settle(`native-${result.status}`);
249
250
  }
250
251
  if (stopped)
251
252
  return;
@@ -430,20 +431,32 @@ export function createCanonAttachedSession(options) {
430
431
  running = true;
431
432
  inbound = endpoint.acceptInbound({ kind: 'message.created', conversationId: binding.conversationId,
432
433
  offer: async (event) => {
433
- if (!running || stopped || options.transport?.isRouteActive?.(binding.conversationId) === false) {
434
- await endpoint.setInboundState(event.id, 'deferred', 'attachment-not-ready');
434
+ const handoff = await endpoint.acquireInbound(event.id);
435
+ if (!handoff)
435
436
  return;
436
- }
437
- const payload = event.data;
438
- if (core.getState().pendingSourceMessageId === payload.message.id && core.getState().status === 'uncertain') {
439
- await endpoint.setInboundState(event.id, 'uncertain', 'native-reconciliation-required');
440
- return;
441
- }
442
- await receive(payload);
443
- const record = await endpoint.getInbound(event.id);
444
- if (record && record.state !== 'uncertain' && record.state !== 'settled') {
445
- await endpoint.setInboundState(event.id, 'settled', 'native-input-reviewed');
446
- }
437
+ // The handoff owns deduplication while Core decides busy/accepted.
438
+ // Do not block the inbox pump on an external provider acknowledgement.
439
+ const received = (async () => {
440
+ try {
441
+ if (!running || stopped || options.transport?.isRouteActive?.(binding.conversationId) === false)
442
+ return;
443
+ const payload = event.data;
444
+ if (core.getState().pendingSourceMessageId === payload.message.id && core.getState().status === 'uncertain') {
445
+ // A recovered native journal can predate endpoint adoption. This
446
+ // is provider proof of uncertainty, not permission to resubmit.
447
+ await endpoint.setInboundState(event.id, 'uncertain', 'native-reconciliation-required');
448
+ return;
449
+ }
450
+ const result = await receive(payload, handoff);
451
+ if (result !== 'deferred' && handoff.phase === 'queued') {
452
+ await handoff.cancel('native-input-reviewed');
453
+ }
454
+ }
455
+ finally {
456
+ await handoff.release('attached-offer-finished');
457
+ }
458
+ })();
459
+ track(received, 'receive');
447
460
  }, onError: (error) => report(error, 'inbound-recovery') });
448
461
  inbound.start();
449
462
  const handler = {
@@ -527,6 +540,10 @@ export function createCanonAttachedSession(options) {
527
540
  return stopPromise;
528
541
  stopped = true;
529
542
  running = false;
543
+ for (const handoff of receiving.values()) {
544
+ // Register release before waiting for the pending native submission.
545
+ track(handoff.release('attachment-closed'), 'release-inbound');
546
+ }
530
547
  if (timer)
531
548
  clearInterval(timer);
532
549
  stream?.stop();
@@ -87,6 +87,7 @@ export declare class CanonAgent {
87
87
  private runtimeStatePublisher;
88
88
  private rtdbHandle;
89
89
  private controlPoller;
90
+ private readonly activeInbound;
90
91
  private readonly activeAbortControllers;
91
92
  private readonly activeTurns;
92
93
  private readonly conversationMemberIds;
@@ -221,6 +222,7 @@ export declare class CanonAgent {
221
222
  private requireRuntimeStatePublisher;
222
223
  private handleMessages;
223
224
  private executeHandler;
225
+ private executeOwnedHandler;
224
226
  static register(options: Omit<RegistrationInput, 'baseUrl' | 'developerInfo'> & {
225
227
  developerInfo: string;
226
228
  } & CanonAgentConnectionOptions): Promise<{
@@ -1,6 +1,7 @@
1
1
  import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, isPendingCanonOperation, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, normalizeTurnVerbosityConversationType, reportNoReplyOutcome, hydrateCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, resolveTurnVerbosity, resolveConversationPolicyScope, selectActiveSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, shouldPublishTurnTrail, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { Debouncer } from './debouncer.js';
4
+ import { disposeInboundHandoffs, selectInboundHandoffs } from './inbound-handoff.js';
4
5
  import { DEFAULT_RUNTIME_INPUT_TIMEOUT_MS, RUNTIME_INPUT_ID_PATTERN, buildRuntimeCardCreateArgs, normalizeResponseUserId, resolveRuntimeCardRouting, } from './runtime-card.js';
5
6
  import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, uploadMediaFile, } from './media.js';
6
7
  import { SessionManager } from './session-manager.js';
@@ -325,6 +326,7 @@ export class CanonAgent {
325
326
  runtimeStatePublisher = null;
326
327
  rtdbHandle = null;
327
328
  controlPoller = null;
329
+ activeInbound = new Map();
328
330
  activeAbortControllers = new Map();
329
331
  activeTurns = new Map();
330
332
  conversationMemberIds = new Map();
@@ -628,9 +630,9 @@ export class CanonAgent {
628
630
  });
629
631
  console.log(`[canon-sdk] Authenticated as ${agentId}`);
630
632
  // 2. Wire debouncer to handler
631
- this.debouncer.setCallback(async (conversationId, messages, provenanceByMessageId) => {
633
+ this.debouncer.setCallback(async (conversationId, messages, provenanceByMessageId, handoffs) => {
632
634
  this.rememberConversationId(conversationId);
633
- await this.handleMessages(conversationId, messages, provenanceByMessageId);
635
+ await this.handleMessages(conversationId, messages, provenanceByMessageId, handoffs);
634
636
  });
635
637
  // 3. Fetch conversations (used for delivery mode + session state)
636
638
  let conversations = [];
@@ -725,6 +727,9 @@ export class CanonAgent {
725
727
  });
726
728
  rtm.setMessageDeletedHandler((payload) => {
727
729
  this.sessionManager?.dropQueuedMessage(payload.conversationId, payload.messageId);
730
+ for (const handoffs of this.activeInbound.get(payload.conversationId) ?? []) {
731
+ void disposeInboundHandoffs(selectInboundHandoffs(handoffs, [payload.messageId]), 'cancel', 'message-deleted');
732
+ }
728
733
  });
729
734
  rtm.setConnectionHandlers({
730
735
  onConnected: () => {
@@ -868,6 +873,10 @@ export class CanonAgent {
868
873
  this.realtimeManager?.stop();
869
874
  this.realtimeManager = null;
870
875
  this.debouncer.destroy();
876
+ for (const batches of this.activeInbound.values()) {
877
+ for (const handoffs of batches)
878
+ void disposeInboundHandoffs(handoffs, 'release', 'runtime-closed');
879
+ }
871
880
  this.sessionManager?.destroy();
872
881
  this.sessionManager = null;
873
882
  const heartbeat = this.runtimeHeartbeat;
@@ -1005,7 +1014,8 @@ export class CanonAgent {
1005
1014
  }
1006
1015
  if (this.agentId && !memberIds.includes(this.agentId)) {
1007
1016
  this.cachedConversationIds = this.cachedConversationIds.filter((id) => id !== payload.conversationId);
1008
- this.abortActiveTurns(payload.conversationId);
1017
+ this.abortActiveTurns(payload.conversationId, false);
1018
+ this.debouncer.dropConversation(payload.conversationId);
1009
1019
  this.sessionManager?.dropQueued(payload.conversationId);
1010
1020
  this.pendingMembershipChanges.delete(payload.conversationId);
1011
1021
  }
@@ -1132,11 +1142,14 @@ export class CanonAgent {
1132
1142
  return;
1133
1143
  const activeTurn = this.firstActiveTurn(conversationId);
1134
1144
  const abortSignal = this.abortActiveTurns(conversationId);
1135
- const droppedMessages = signal === 'new_session'
1145
+ const debouncedMessages = signal === 'new_session' || signal === 'stop_and_drop'
1146
+ ? this.debouncer.dropConversation(conversationId) : [];
1147
+ const sessionMessages = signal === 'new_session'
1136
1148
  ? this.sessionManager?.resetSession(conversationId) ?? []
1137
1149
  : signal === 'stop_and_drop'
1138
1150
  ? this.sessionManager?.dropQueued(conversationId) ?? []
1139
1151
  : [];
1152
+ const droppedMessages = [...debouncedMessages, ...sessionMessages];
1140
1153
  const droppedMessageIds = droppedMessages.map((message) => message.id);
1141
1154
  await Promise.all(droppedMessages.map((message) => {
1142
1155
  if (message.metadata?.inboundDisposition !== 'queued')
@@ -1190,7 +1203,12 @@ export class CanonAgent {
1190
1203
  this.typingSignals.clear(conversationId).catch(() => { }),
1191
1204
  ]);
1192
1205
  }
1193
- abortActiveTurns(conversationId) {
1206
+ abortActiveTurns(conversationId, cancelPreparing = true) {
1207
+ // Membership snapshots may arrive while a newer authoritative refresh is
1208
+ // pending. Only explicit controls discard those still-preparing inputs.
1209
+ for (const handoffs of cancelPreparing ? this.activeInbound.get(conversationId) ?? [] : []) {
1210
+ void disposeInboundHandoffs(handoffs, 'cancel', 'turn-canceled-before-submit');
1211
+ }
1194
1212
  const controllers = this.activeAbortControllers.get(conversationId);
1195
1213
  if (!controllers || controllers.size === 0)
1196
1214
  return undefined;
@@ -1254,81 +1272,79 @@ export class CanonAgent {
1254
1272
  }
1255
1273
  return publisher;
1256
1274
  }
1257
- async handleMessages(conversationId, messages, provenanceByMessageId) {
1275
+ async handleMessages(conversationId, messages, provenanceByMessageId, handoffs = new Map()) {
1258
1276
  const actionableMessages = this.filterApprovalReplyMessages(conversationId, messages);
1259
1277
  const consumed = messages.filter((message) => !actionableMessages.includes(message));
1260
- if (consumed.length) {
1261
- const endpoint = await this.apiClient.getEndpoint();
1262
- for (const message of consumed) {
1263
- const id = `message:${conversationId}:${message.id}`;
1264
- if (await endpoint.getInbound(id))
1265
- await endpoint.setInboundState(id, 'settled', 'interaction-consumed');
1266
- }
1267
- }
1278
+ void disposeInboundHandoffs(selectInboundHandoffs(handoffs, consumed.map((message) => message.id)), 'cancel', 'interaction-consumed');
1268
1279
  if (actionableMessages.length === 0)
1269
1280
  return;
1270
1281
  messages = actionableMessages;
1282
+ handoffs = selectInboundHandoffs(handoffs, messages.map((message) => message.id));
1271
1283
  if (!this.handler) {
1272
- const endpoint = await this.apiClient.getEndpoint();
1273
- for (const message of messages) {
1274
- const id = `message:${conversationId}:${message.id}`;
1275
- if (await endpoint.getInbound(id))
1276
- await endpoint.setInboundState(id, 'deferred', 'no-handler');
1277
- }
1284
+ await disposeInboundHandoffs(handoffs, 'release', 'no-handler');
1278
1285
  console.warn(`[canon-sdk] No message handler registered — input for ${conversationId} is deferred. Call agent.on('message', handler) before starting.`);
1279
1286
  return;
1280
1287
  }
1281
1288
  const deliveryIntent = this.resolveBatchDeliveryIntent(messages);
1282
1289
  const shouldInterrupt = deliveryIntent === 'interrupt' && this.hasInterruptSupport();
1283
1290
  const abortSignal = shouldInterrupt ? this.abortActiveTurns(conversationId) : undefined;
1284
- await this.notifyMessageInterrupt(conversationId, abortSignal);
1291
+ const notification = this.notifyMessageInterrupt(conversationId, abortSignal);
1285
1292
  if (this.sessionManager) {
1286
- await this.sessionManager.enqueue(conversationId, messages, async (session, newMessages) => {
1287
- await this.executeHandler(conversationId, newMessages, session, provenanceByMessageId);
1288
- }, { toFront: shouldInterrupt });
1293
+ await this.sessionManager.enqueue(conversationId, messages, async (session, newMessages, batchHandoffs) => {
1294
+ await this.executeHandler(conversationId, newMessages, session, provenanceByMessageId, batchHandoffs, notification);
1295
+ }, { toFront: shouldInterrupt, handoffs });
1289
1296
  }
1290
1297
  else {
1291
- await this.executeHandler(conversationId, messages, undefined, provenanceByMessageId);
1298
+ await this.executeHandler(conversationId, messages, undefined, provenanceByMessageId, handoffs, notification);
1292
1299
  }
1293
1300
  }
1294
- async executeHandler(conversationId, messages, session, provenanceByMessageId) {
1295
- if (!this.handler)
1301
+ async executeHandler(conversationId, messages, session, provenanceByMessageId, handoffs = new Map(), notification) {
1302
+ const batches = this.activeInbound.get(conversationId) ?? new Set();
1303
+ batches.add(handoffs);
1304
+ this.activeInbound.set(conversationId, batches);
1305
+ try {
1306
+ await notification;
1307
+ await this.executeOwnedHandler(conversationId, messages, session, provenanceByMessageId, handoffs);
1308
+ }
1309
+ finally {
1310
+ batches.delete(handoffs);
1311
+ if (batches.size === 0)
1312
+ this.activeInbound.delete(conversationId);
1313
+ // Queued/deferred inputs go back to the engine's durable retry loop.
1314
+ // Submitted effects cannot be released: failures remain uncertain.
1315
+ await disposeInboundHandoffs(handoffs, 'release', 'sdk-batch-finished');
1316
+ }
1317
+ }
1318
+ async executeOwnedHandler(conversationId, messages, session, provenanceByMessageId, handoffs) {
1319
+ if (!this.handler || messages.length === 0 || handoffs.size !== messages.length
1320
+ || [...handoffs.values()].some((handoff) => handoff.phase !== 'queued'))
1296
1321
  return;
1297
- // Message rediscovery can precede the SSE roster snapshot after admission.
1298
- // Refresh before trusting cached exclusion, accepting queued input, or
1299
- // freezing output policy. Reuse this same snapshot for handler context.
1322
+ // Refresh admission before freezing output policy or constructing context.
1300
1323
  const endpoint = await this.apiClient.getEndpoint();
1301
- const setInputState = async (state, reason) => {
1302
- for (const message of messages) {
1303
- const id = `message:${conversationId}:${message.id}`;
1304
- if (await endpoint.getInbound(id))
1305
- await endpoint.setInboundState(id, state, reason);
1306
- }
1307
- };
1324
+ const cancelInputs = (reason) => disposeInboundHandoffs(handoffs, 'cancel', reason);
1308
1325
  let conversation;
1309
1326
  try {
1310
1327
  conversation = await this.apiClient.getConversation(conversationId);
1311
1328
  }
1312
1329
  catch (error) {
1313
- await setInputState('deferred', 'membership-unavailable');
1314
1330
  console.error(`[canon-sdk] Input deferred until conversation refresh recovers for ${conversationId}:`, error);
1315
1331
  return;
1316
1332
  }
1317
1333
  if (!conversation) {
1318
- await setInputState('settled', 'conversation-unavailable');
1334
+ await cancelInputs('conversation-unavailable');
1319
1335
  return;
1320
1336
  }
1321
1337
  this.rememberConversationMembers([conversation]);
1322
1338
  const currentMembers = this.conversationMemberIds.get(conversationId);
1323
1339
  if (currentMembers && this.agentId && !currentMembers.includes(this.agentId)) {
1324
- await setInputState('settled', 'membership-lost');
1340
+ await cancelInputs('membership-lost');
1325
1341
  return;
1326
1342
  }
1327
1343
  this.rememberConversationId(conversationId);
1328
1344
  await this.markQueuedMessagesAccepted(conversationId, messages);
1329
1345
  const acceptedMembers = this.conversationMemberIds.get(conversationId);
1330
1346
  if (acceptedMembers && this.agentId && !acceptedMembers.includes(this.agentId)) {
1331
- await setInputState('settled', 'membership-lost');
1347
+ await cancelInputs('membership-lost');
1332
1348
  return;
1333
1349
  }
1334
1350
  const turnId = randomUUID();
@@ -1356,6 +1372,9 @@ export class CanonAgent {
1356
1372
  const runtimeState = this.createRuntimeStatePublisher();
1357
1373
  const queueDepth = () => this.sessionManager?.getQueueDepth(conversationId) ?? 0;
1358
1374
  const abortController = new AbortController();
1375
+ abortController.signal.addEventListener('abort', () => {
1376
+ void disposeInboundHandoffs(handoffs, 'cancel', 'turn-canceled-before-submit');
1377
+ }, { once: true });
1359
1378
  const throwIfAborted = () => {
1360
1379
  if (abortController.signal.aborted) {
1361
1380
  throw createTurnAbortError();
@@ -2196,13 +2215,7 @@ export class CanonAgent {
2196
2215
  // Fence uncertain native/business effects before handing execution out.
2197
2216
  // A crash after this write requires provider/operator reconciliation.
2198
2217
  throwIfAborted();
2199
- // Every SDK message handler invocation originates in Canon admission. A
2200
- // restored native queue without its journal must await migration instead
2201
- // of treating a missing record as permission to execute.
2202
- const journaled = messages.map((message) => `message:${conversationId}:${message.id}`);
2203
- if (!journaled.length || !await endpoint.claimInbound(journaled))
2204
- return;
2205
- await this.handler({
2218
+ const submission = await endpoint.startInbound([...handoffs.values()], () => this.handler({
2206
2219
  messages: hydratedMessages,
2207
2220
  history,
2208
2221
  replyContext,
@@ -2374,8 +2387,10 @@ export class CanonAgent {
2374
2387
  }
2375
2388
  },
2376
2389
  },
2377
- });
2378
- await setInputState('settled', 'handler-completed');
2390
+ }));
2391
+ if (submission.status !== 'submitted')
2392
+ return;
2393
+ await disposeInboundHandoffs(handoffs, 'settle', 'handler-completed');
2379
2394
  // Auto-mark conversation as read after successful processing
2380
2395
  if (this.options.autoMarkRead) {
2381
2396
  try {
@@ -1,15 +1,18 @@
1
- import type { CanonMessage, CanonRuntimeProvenance } from '@canonmsg/core';
1
+ import type { CanonMessage, CanonRuntimeProvenance, EndpointInboundHandoff } from '@canonmsg/core';
2
+ import { type InboundHandoffs } from './inbound-handoff.js';
2
3
  export declare class Debouncer {
3
4
  private debounceMs;
4
5
  private pending;
5
- private provenanceByMessageId;
6
+ private provenanceByConversation;
7
+ private handoffsByConversation;
6
8
  private timers;
7
9
  private orderedFlags;
8
10
  private callback;
9
11
  constructor(debounceMs: number);
10
- setCallback(cb: (conversationId: string, messages: CanonMessage[], provenanceByMessageId: ReadonlyMap<string, CanonRuntimeProvenance>) => void): void;
11
- add(conversationId: string, message: CanonMessage, provenance?: CanonRuntimeProvenance | null): void;
12
+ setCallback(cb: (conversationId: string, messages: CanonMessage[], provenanceByMessageId: ReadonlyMap<string, CanonRuntimeProvenance>, handoffs: InboundHandoffs) => void | Promise<void>): void;
13
+ add(conversationId: string, message: CanonMessage, provenance?: CanonRuntimeProvenance | null, handoff?: EndpointInboundHandoff): void;
12
14
  removeMessage(conversationId: string, messageId: string): boolean;
13
15
  private flush;
16
+ dropConversation(conversationId: string): CanonMessage[];
14
17
  destroy(): void;
15
18
  }
package/dist/debouncer.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { disposeInboundHandoffs } from './inbound-handoff.js';
1
2
  export class Debouncer {
2
3
  debounceMs;
3
4
  pending = new Map();
4
- provenanceByMessageId = new Map();
5
+ provenanceByConversation = new Map();
6
+ handoffsByConversation = new Map();
5
7
  timers = new Map();
6
8
  // Track whether each conversation's pending messages are already in sorted
7
9
  // order so we can skip the sort on flush when possible (#10)
@@ -13,10 +15,17 @@ export class Debouncer {
13
15
  setCallback(cb) {
14
16
  this.callback = cb;
15
17
  }
16
- add(conversationId, message, provenance) {
18
+ add(conversationId, message, provenance, handoff) {
17
19
  const existing = this.pending.get(conversationId) || [];
18
20
  if (provenance) {
19
- this.provenanceByMessageId.set(message.id, provenance);
21
+ const provenanceByMessageId = this.provenanceByConversation.get(conversationId) ?? new Map();
22
+ provenanceByMessageId.set(message.id, provenance);
23
+ this.provenanceByConversation.set(conversationId, provenanceByMessageId);
24
+ }
25
+ if (handoff) {
26
+ const handoffs = this.handoffsByConversation.get(conversationId) ?? new Map();
27
+ handoffs.set(message.id, handoff);
28
+ this.handoffsByConversation.set(conversationId, handoffs);
20
29
  }
21
30
  // Deduplicate by message ID
22
31
  if (!existing.some((m) => m.id === message.id)) {
@@ -50,9 +59,15 @@ export class Debouncer {
50
59
  const next = existing.filter((message) => message.id !== messageId);
51
60
  if (next.length === existing.length)
52
61
  return false;
53
- this.provenanceByMessageId.delete(messageId);
62
+ this.provenanceByConversation.get(conversationId)?.delete(messageId);
63
+ const handoff = this.handoffsByConversation.get(conversationId)?.get(messageId);
64
+ if (handoff)
65
+ void disposeInboundHandoffs(new Map([[messageId, handoff]]), 'cancel', 'message-deleted');
66
+ this.handoffsByConversation.get(conversationId)?.delete(messageId);
54
67
  if (next.length === 0) {
55
68
  this.pending.delete(conversationId);
69
+ this.provenanceByConversation.delete(conversationId);
70
+ this.handoffsByConversation.delete(conversationId);
56
71
  this.orderedFlags.delete(conversationId);
57
72
  const timer = this.timers.get(conversationId);
58
73
  if (timer)
@@ -75,24 +90,44 @@ export class Debouncer {
75
90
  if (!isOrdered) {
76
91
  messages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
77
92
  }
78
- const provenanceByMessageId = new Map();
79
- for (const message of messages) {
80
- const provenance = this.provenanceByMessageId.get(message.id);
81
- if (provenance) {
82
- provenanceByMessageId.set(message.id, provenance);
83
- this.provenanceByMessageId.delete(message.id);
84
- }
93
+ const provenanceByMessageId = this.provenanceByConversation.get(conversationId) ?? new Map();
94
+ const handoffs = this.handoffsByConversation.get(conversationId) ?? new Map();
95
+ this.provenanceByConversation.delete(conversationId);
96
+ this.handoffsByConversation.delete(conversationId);
97
+ const failed = async (error) => {
98
+ await disposeInboundHandoffs(handoffs, 'release', 'dispatch-failed');
99
+ console.error('[canon-sdk] Inbound dispatch failed:', error);
100
+ };
101
+ try {
102
+ void Promise.resolve(this.callback(conversationId, messages, provenanceByMessageId, handoffs)).catch(failed);
103
+ }
104
+ catch (error) {
105
+ void failed(error);
85
106
  }
86
- this.callback(conversationId, messages, provenanceByMessageId);
87
107
  }
108
+ else {
109
+ void disposeInboundHandoffs(this.handoffsByConversation.get(conversationId) ?? new Map(), 'release', 'no-dispatcher');
110
+ this.provenanceByConversation.delete(conversationId);
111
+ this.handoffsByConversation.delete(conversationId);
112
+ }
113
+ }
114
+ dropConversation(conversationId) {
115
+ const messages = this.pending.get(conversationId) ?? [];
116
+ for (const message of messages)
117
+ this.removeMessage(conversationId, message.id);
118
+ return messages;
88
119
  }
89
120
  destroy() {
121
+ for (const handoffs of this.handoffsByConversation.values()) {
122
+ void disposeInboundHandoffs(handoffs, 'release', 'debouncer-closed');
123
+ }
124
+ this.handoffsByConversation.clear();
90
125
  for (const timer of this.timers.values()) {
91
126
  clearTimeout(timer);
92
127
  }
93
128
  this.timers.clear();
94
129
  this.pending.clear();
95
- this.provenanceByMessageId.clear();
130
+ this.provenanceByConversation.clear();
96
131
  this.orderedFlags.clear();
97
132
  }
98
133
  }
@@ -0,0 +1,6 @@
1
+ import type { EndpointInboundHandoff } from '@canonmsg/core';
2
+ /** Message-keyed handles within one conversation, carried with its queue entry. */
3
+ export type InboundHandoffs = ReadonlyMap<string, EndpointInboundHandoff>;
4
+ export declare function selectInboundHandoffs(handoffs: InboundHandoffs, messageIds: readonly string[]): InboundHandoffs;
5
+ /** Calls record intent synchronously, even when a durable claim is awaiting IO. */
6
+ export declare function disposeInboundHandoffs(handoffs: InboundHandoffs, disposition: 'cancel' | 'release' | 'settle', reason: string): Promise<void>;
@@ -0,0 +1,17 @@
1
+ export function selectInboundHandoffs(handoffs, messageIds) {
2
+ const selected = new Map();
3
+ for (const id of messageIds) {
4
+ const handoff = handoffs.get(id);
5
+ if (handoff)
6
+ selected.set(id, handoff);
7
+ }
8
+ return selected;
9
+ }
10
+ /** Calls record intent synchronously, even when a durable claim is awaiting IO. */
11
+ export async function disposeInboundHandoffs(handoffs, disposition, reason) {
12
+ const results = await Promise.allSettled([...handoffs.values()].map((handoff) => handoff[disposition](reason)));
13
+ for (const result of results) {
14
+ if (result.status === 'rejected')
15
+ console.error(`[canon-sdk] Inbound ${disposition} failed:`, result.reason);
16
+ }
17
+ }
package/dist/realtime.js CHANGED
@@ -98,8 +98,11 @@ export class RealtimeManager {
98
98
  }
99
99
  async offer(payload) {
100
100
  const id = `message:${payload.conversationId}:${payload.message.id}`;
101
+ const handoff = await this.endpoint.acquireInbound(id);
102
+ if (!handoff)
103
+ return;
101
104
  if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
102
- await this.endpoint.setInboundState(id, 'settled', 'observe-only');
105
+ await handoff.cancel('observe-only');
103
106
  return;
104
107
  }
105
108
  const m = payload.message;
@@ -129,7 +132,7 @@ export class RealtimeManager {
129
132
  ...(m.metadata ? { metadata: m.metadata } : {}),
130
133
  ...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
131
134
  };
132
- this.debouncer.add(payload.conversationId, message, payload.provenance ?? null);
135
+ this.debouncer.add(payload.conversationId, message, payload.provenance ?? null, handoff);
133
136
  }
134
137
  logSseError(err) {
135
138
  const code = err.code;
@@ -1,4 +1,5 @@
1
1
  import type { CanonMessage } from '@canonmsg/core';
2
+ import { type InboundHandoffs } from './inbound-handoff.js';
2
3
  export interface SessionConfig {
3
4
  /** Max messages to retain per session (default: 50) */
4
5
  contextLimit?: number;
@@ -17,10 +18,12 @@ export interface Session {
17
18
  /** When this session last processed a message */
18
19
  lastActiveAt: number;
19
20
  }
20
- type SessionHandler = (session: Session, newMessages: CanonMessage[]) => Promise<void>;
21
+ type SessionHandler = (session: Session, newMessages: CanonMessage[], handoffs: InboundHandoffs) => Promise<void>;
21
22
  export interface EnqueueOptions {
22
23
  /** Place this batch ahead of other not-yet-running batches. */
23
24
  toFront?: boolean;
25
+ /** Execution receipts carried with this batch, independent of retained history. */
26
+ handoffs?: InboundHandoffs;
24
27
  }
25
28
  /**
26
29
  * Manages per-conversation sessions with:
@@ -33,6 +36,7 @@ export declare class SessionManager {
33
36
  private sessions;
34
37
  private queues;
35
38
  private activeCount;
39
+ private destroyed;
36
40
  private pending;
37
41
  private processing;
38
42
  private seenMessages;
@@ -1,3 +1,4 @@
1
+ import { disposeInboundHandoffs, selectInboundHandoffs } from './inbound-handoff.js';
1
2
  const DEFAULT_CONTEXT_LIMIT = 50;
2
3
  const DEFAULT_CONCURRENCY = 10;
3
4
  const DEFAULT_IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
@@ -13,6 +14,7 @@ export class SessionManager {
13
14
  sessions = new Map();
14
15
  queues = new Map();
15
16
  activeCount = 0;
17
+ destroyed = false;
16
18
  // Use Set<string> instead of Array for O(1) dedup and correct pop semantics (#1, #2)
17
19
  pending = new Set();
18
20
  processing = new Set();
@@ -96,6 +98,10 @@ export class SessionManager {
96
98
  * - Messages across different sessions run concurrently up to the concurrency limit.
97
99
  */
98
100
  async enqueue(conversationId, messages, handler, options = {}) {
101
+ if (this.destroyed) {
102
+ await disposeInboundHandoffs(options.handoffs ?? new Map(), 'release', 'session-manager-closed');
103
+ return;
104
+ }
99
105
  const session = this.getSession(conversationId);
100
106
  session.lastActiveAt = Date.now();
101
107
  return new Promise((resolve, reject) => {
@@ -105,7 +111,7 @@ export class SessionManager {
105
111
  queue = [];
106
112
  this.queues.set(conversationId, queue);
107
113
  }
108
- const batch = { messages, handler, resolve, reject };
114
+ const batch = { messages, handler, resolve, reject, handoffs: options.handoffs ?? new Map() };
109
115
  if (options.toFront) {
110
116
  queue.unshift(batch);
111
117
  }
@@ -118,7 +124,7 @@ export class SessionManager {
118
124
  }
119
125
  drainSession(conversationId) {
120
126
  // Already processing this session — the current run will pick up queued items
121
- if (this.processing.has(conversationId))
127
+ if (this.destroyed || this.processing.has(conversationId))
122
128
  return;
123
129
  const queue = this.queues.get(conversationId);
124
130
  if (!queue || queue.length === 0)
@@ -137,7 +143,7 @@ export class SessionManager {
137
143
  this.activeCount++;
138
144
  this.processing.add(conversationId);
139
145
  const session = this.appendMessagesToSession(conversationId, item.messages);
140
- item.handler(session, item.messages)
146
+ item.handler(session, item.messages, item.handoffs)
141
147
  .then(() => item.resolve())
142
148
  .catch((err) => item.reject(err))
143
149
  .finally(() => {
@@ -205,15 +211,17 @@ export class SessionManager {
205
211
  for (const item of queue) {
206
212
  const keptMessages = item.messages.filter((message) => {
207
213
  const drop = message.id === messageId;
208
- if (drop)
214
+ if (drop) {
209
215
  droppedMessages.push(message);
216
+ void disposeInboundHandoffs(selectInboundHandoffs(item.handoffs, [message.id]), 'cancel', 'message-deleted');
217
+ }
210
218
  return !drop;
211
219
  });
212
220
  if (keptMessages.length === 0) {
213
221
  item.resolve();
214
222
  }
215
223
  else if (keptMessages.length !== item.messages.length) {
216
- nextQueue.push({ ...item, messages: keptMessages });
224
+ nextQueue.push({ ...item, messages: keptMessages, handoffs: selectInboundHandoffs(item.handoffs, keptMessages.map((message) => message.id)) });
217
225
  }
218
226
  else {
219
227
  nextQueue.push(item);
@@ -241,6 +249,7 @@ export class SessionManager {
241
249
  const droppedMessages = queue.flatMap((item) => item.messages);
242
250
  this.removeMessagesFromSession(conversationId, droppedMessages);
243
251
  for (const item of queue) {
252
+ void disposeInboundHandoffs(item.handoffs, 'cancel', 'queue-dropped');
244
253
  item.resolve();
245
254
  }
246
255
  return droppedMessages;
@@ -255,6 +264,13 @@ export class SessionManager {
255
264
  }
256
265
  /** Clean up all state */
257
266
  destroy() {
267
+ this.destroyed = true;
268
+ for (const queue of this.queues.values()) {
269
+ for (const item of queue) {
270
+ void disposeInboundHandoffs(item.handoffs, 'release', 'session-manager-closed');
271
+ item.resolve();
272
+ }
273
+ }
258
274
  if (this.sweepTimer) {
259
275
  clearInterval(this.sweepTimer);
260
276
  this.sweepTimer = null;
@@ -265,6 +281,5 @@ export class SessionManager {
265
281
  this.processing.clear();
266
282
  this.seenMessages.clear();
267
283
  this.seededSessions.clear();
268
- this.activeCount = 0;
269
284
  }
270
285
  }
@@ -397,11 +397,22 @@ export function createCanonWorkSessionHost(options) {
397
397
  }
398
398
  async function renew() {
399
399
  fence();
400
- const conversations = await client.getConversations();
401
- for (const room of [...rooms.values()]) {
402
- const conversation = conversations.find((entry) => entry.id === room.binding.conversationId);
403
- if (!conversation?.memberIds.includes(connection.agentId) || !conversation.memberIds.includes(broker.ownerId))
404
- await removeRoom(room.binding.conversationId, true);
400
+ // Renewal only needs the bound audience. Never enumerate the account's
401
+ // unrelated rooms, and bound concurrent reads even for a busy host.
402
+ const boundRooms = [...rooms.values()];
403
+ for (let offset = 0; offset < boundRooms.length; offset += 8) {
404
+ fence();
405
+ const checked = await Promise.all(boundRooms.slice(offset, offset + 8).map(async (room) => ({
406
+ room, conversation: await client.getConversation(room.binding.conversationId),
407
+ })));
408
+ fence();
409
+ for (const { room, conversation } of checked) {
410
+ // A stream update may already have removed/replaced this attachment.
411
+ if (rooms.get(room.binding.conversationId) !== room)
412
+ continue;
413
+ if (!conversation?.memberIds.includes(connection.agentId) || !conversation.memberIds.includes(broker.ownerId))
414
+ await removeRoom(room.binding.conversationId, true);
415
+ }
405
416
  }
406
417
  // Catalog has its own freshness deadline; it is refreshed independently of public presence.
407
418
  const catalog = await options.provider.catalog();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "11.0.1",
3
+ "version": "11.0.2",
4
4
  "description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "node": ">=22.22.3"
29
29
  },
30
30
  "dependencies": {
31
- "@canonmsg/core": "^13.0.4"
31
+ "@canonmsg/core": "^13.0.5"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"