@love-moon/conductor-sdk 0.2.15 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -52,6 +52,8 @@ export declare class ConductorClient {
52
52
  private readonly agentHost;
53
53
  private readonly onStopTask?;
54
54
  private closed;
55
+ private pendingOutbound;
56
+ private readonly ACK_TIMEOUT_MS;
55
57
  constructor(init: ConductorClientInit);
56
58
  static connect(options?: ConductorClientConnectOptions): Promise<ConductorClient>;
57
59
  close(): Promise<void>;
@@ -86,6 +88,25 @@ export declare class ConductorClient {
86
88
  matchProjectByPath(payload?: Record<string, any>): Promise<Record<string, any>>;
87
89
  bindProjectPath(projectId: string, payload?: Record<string, any>): Promise<Record<string, any>>;
88
90
  private readonly handleBackendEvent;
91
+ /**
92
+ * Handle acknowledgment messages from backend for confirmable outbound messages
93
+ */
94
+ private handleAcknowledgment;
95
+ /**
96
+ * Send a confirmable message and wait for acknowledgment from backend.
97
+ * Implements reliable delivery with idempotency.
98
+ */
99
+ private sendConfirmable;
100
+ /**
101
+ * Actually send a confirmable message over websocket.
102
+ * Called initially and on reconnection.
103
+ */
104
+ private doSendConfirmable;
105
+ /**
106
+ * Flush all pending confirmable messages.
107
+ * Called after websocket reconnection.
108
+ */
109
+ private flushPendingOutbound;
89
110
  private sendEnvelope;
90
111
  private maybeAckInboundCommand;
91
112
  private handleStopTaskEvent;
package/dist/client.js CHANGED
@@ -18,6 +18,8 @@ export class ConductorClient {
18
18
  agentHost;
19
19
  onStopTask;
20
20
  closed = false;
21
+ pendingOutbound = new Map();
22
+ ACK_TIMEOUT_MS = 30000; // 30 seconds timeout for acknowledgment
21
23
  constructor(init) {
22
24
  this.config = init.config;
23
25
  this.env = init.env;
@@ -45,6 +47,10 @@ export class ConductorClient {
45
47
  hostName: agentHost,
46
48
  onConnected: options.onConnected,
47
49
  onDisconnected: options.onDisconnected,
50
+ onReconnected: () => {
51
+ // Resend pending confirmable messages after reconnection
52
+ void client.flushPendingOutbound();
53
+ },
48
54
  });
49
55
  const client = new ConductorClient({
50
56
  config,
@@ -145,15 +151,15 @@ export class ConductorClient {
145
151
  };
146
152
  }
147
153
  async sendMessage(taskId, content, metadata) {
148
- await this.sendEnvelope({
149
- type: 'sdk_message',
150
- payload: {
151
- task_id: taskId,
152
- content,
153
- metadata,
154
- },
155
- });
156
- return { delivered: true };
154
+ const messageId = safeRandomUuid();
155
+ const payload = {
156
+ task_id: taskId,
157
+ content,
158
+ metadata,
159
+ message_id: messageId,
160
+ };
161
+ await this.sendConfirmable('sdk_message', payload, messageId);
162
+ return { delivered: true, message_id: messageId };
157
163
  }
158
164
  async sendTaskStatus(taskId, payload) {
159
165
  await this.sendEnvelope({
@@ -210,16 +216,14 @@ export class ConductorClient {
210
216
  if (!requestId) {
211
217
  throw new Error('request_id is required');
212
218
  }
213
- await this.sendEnvelope({
214
- type: 'agent_command_ack',
215
- payload: {
216
- request_id: requestId,
217
- task_id: payload.task_id,
218
- event_type: payload.event_type,
219
- accepted: payload.accepted !== false,
220
- },
221
- });
222
- return { delivered: true };
219
+ const envelopePayload = {
220
+ request_id: requestId,
221
+ task_id: payload.task_id,
222
+ event_type: payload.event_type,
223
+ accepted: payload.accepted !== false,
224
+ };
225
+ await this.sendConfirmable('agent_command_ack', envelopePayload, requestId);
226
+ return { delivered: true, request_id: requestId };
223
227
  }
224
228
  async sendTaskStopAck(payload) {
225
229
  const taskId = String(payload.task_id || '').trim();
@@ -418,6 +422,8 @@ export class ConductorClient {
418
422
  };
419
423
  }
420
424
  handleBackendEvent = async (payload) => {
425
+ // First handle acknowledgments for confirmable outbound messages
426
+ this.handleAcknowledgment(payload);
421
427
  const stopCommandAccepted = await this.handleStopTaskEvent(payload);
422
428
  await this.messageRouter.handleBackendEvent(payload);
423
429
  await this.maybeAckInboundCommand(payload, {
@@ -426,6 +432,126 @@ export class ConductorClient {
426
432
  : undefined,
427
433
  });
428
434
  };
435
+ /**
436
+ * Handle acknowledgment messages from backend for confirmable outbound messages
437
+ */
438
+ handleAcknowledgment(payload) {
439
+ const eventType = typeof payload?.type === 'string' ? payload.type : '';
440
+ const data = payload?.payload && typeof payload.payload === 'object'
441
+ ? payload.payload
442
+ : null;
443
+ if (!data)
444
+ return;
445
+ if (eventType === 'message_recorded') {
446
+ // Acknowledgment for sdk_message
447
+ const messageId = typeof data.message_id === 'string' ? data.message_id : null;
448
+ if (messageId && this.pendingOutbound.has(messageId)) {
449
+ const pending = this.pendingOutbound.get(messageId);
450
+ if (pending.type === 'sdk_message') {
451
+ this.pendingOutbound.delete(messageId);
452
+ pending.resolve();
453
+ }
454
+ }
455
+ }
456
+ else if (eventType === 'agent_command_ack_recorded') {
457
+ // Acknowledgment for agent_command_ack
458
+ const requestId = typeof data.request_id === 'string' ? data.request_id : null;
459
+ if (requestId && this.pendingOutbound.has(requestId)) {
460
+ const pending = this.pendingOutbound.get(requestId);
461
+ if (pending.type === 'agent_command_ack') {
462
+ this.pendingOutbound.delete(requestId);
463
+ pending.resolve();
464
+ }
465
+ }
466
+ }
467
+ }
468
+ /**
469
+ * Send a confirmable message and wait for acknowledgment from backend.
470
+ * Implements reliable delivery with idempotency.
471
+ */
472
+ async sendConfirmable(type, payload, stableId) {
473
+ // Check if there's already a pending message with this stableId
474
+ const existingPending = this.pendingOutbound.get(stableId);
475
+ if (existingPending) {
476
+ // Return the existing promise to deduplicate in-flight requests
477
+ return new Promise((resolve, reject) => {
478
+ const checkResolved = () => {
479
+ if (!this.pendingOutbound.has(stableId)) {
480
+ resolve();
481
+ return;
482
+ }
483
+ const pending = this.pendingOutbound.get(stableId);
484
+ if (pending.retryCount > 100) { // Safety limit
485
+ reject(new Error(`Message ${stableId} exceeded max retry attempts`));
486
+ return;
487
+ }
488
+ setTimeout(checkResolved, 100);
489
+ };
490
+ checkResolved();
491
+ });
492
+ }
493
+ return new Promise((resolve, reject) => {
494
+ const now = Date.now();
495
+ const pendingMessage = {
496
+ type,
497
+ payload,
498
+ stableId,
499
+ firstSentAt: now,
500
+ lastSentAt: now,
501
+ retryCount: 0,
502
+ resolve,
503
+ reject,
504
+ };
505
+ this.pendingOutbound.set(stableId, pendingMessage);
506
+ // Send the message
507
+ void this.doSendConfirmable(stableId);
508
+ // Set timeout for acknowledgment
509
+ setTimeout(() => {
510
+ if (this.pendingOutbound.has(stableId)) {
511
+ this.pendingOutbound.delete(stableId);
512
+ reject(new Error(`Timeout waiting for acknowledgment of ${type} (${stableId})`));
513
+ }
514
+ }, this.ACK_TIMEOUT_MS);
515
+ });
516
+ }
517
+ /**
518
+ * Actually send a confirmable message over websocket.
519
+ * Called initially and on reconnection.
520
+ */
521
+ async doSendConfirmable(stableId) {
522
+ const pending = this.pendingOutbound.get(stableId);
523
+ if (!pending)
524
+ return; // Already acknowledged or timed out
525
+ try {
526
+ await this.sendEnvelope({
527
+ type: pending.type,
528
+ payload: pending.payload,
529
+ });
530
+ pending.lastSentAt = Date.now();
531
+ pending.retryCount++;
532
+ }
533
+ catch (error) {
534
+ // Send failed - message remains in pending queue for retry on reconnection
535
+ console.warn(`[sdk] Failed to send ${pending.type} (${stableId}): ${error instanceof Error ? error.message : String(error)}`);
536
+ }
537
+ }
538
+ /**
539
+ * Flush all pending confirmable messages.
540
+ * Called after websocket reconnection.
541
+ */
542
+ async flushPendingOutbound() {
543
+ if (this.pendingOutbound.size === 0)
544
+ return;
545
+ console.log(`[sdk] Flushing ${this.pendingOutbound.size} pending confirmable messages after reconnection`);
546
+ const promises = [];
547
+ for (const [stableId, pending] of this.pendingOutbound) {
548
+ promises.push(this.doSendConfirmable(stableId));
549
+ }
550
+ // Wait for all sends to complete (but not for acknowledgments)
551
+ await Promise.all(promises).catch(() => {
552
+ // Errors are logged in doSendConfirmable, messages remain pending
553
+ });
554
+ }
429
555
  async sendEnvelope(envelope) {
430
556
  await this.wsClient.sendJson(envelope);
431
557
  }
@@ -21,6 +21,7 @@ export interface WebSocketClientOptions {
21
21
  isReconnect: boolean;
22
22
  }) => void;
23
23
  onDisconnected?: () => void;
24
+ onReconnected?: () => void;
24
25
  }
25
26
  export declare class ConductorWebSocketClient {
26
27
  private readonly url;
@@ -30,6 +31,7 @@ export declare class ConductorWebSocketClient {
30
31
  private readonly connectImpl;
31
32
  private readonly onConnected?;
32
33
  private readonly onDisconnected?;
34
+ private readonly onReconnected?;
33
35
  private readonly handlers;
34
36
  private readonly extraHeaders;
35
37
  private conn;
@@ -52,6 +54,7 @@ export declare class ConductorWebSocketClient {
52
54
  private handleConnectionLoss;
53
55
  private dispatch;
54
56
  private notifyConnected;
57
+ private notifyReconnected;
55
58
  private notifyDisconnected;
56
59
  private isConnectionClosed;
57
60
  private sendWithReconnect;
package/dist/ws/client.js CHANGED
@@ -16,6 +16,7 @@ export class ConductorWebSocketClient {
16
16
  connectImpl;
17
17
  onConnected;
18
18
  onDisconnected;
19
+ onReconnected;
19
20
  handlers = [];
20
21
  extraHeaders;
21
22
  conn = null;
@@ -37,6 +38,7 @@ export class ConductorWebSocketClient {
37
38
  this.connectImpl = options.connectImpl ?? defaultConnectImpl;
38
39
  this.onConnected = options.onConnected;
39
40
  this.onDisconnected = options.onDisconnected;
41
+ this.onReconnected = options.onReconnected;
40
42
  }
41
43
  registerHandler(handler) {
42
44
  this.handlers.push(handler);
@@ -85,6 +87,9 @@ export class ConductorWebSocketClient {
85
87
  const isReconnect = this.hasConnectedAtLeastOnce;
86
88
  this.hasConnectedAtLeastOnce = true;
87
89
  this.notifyConnected(isReconnect);
90
+ if (isReconnect) {
91
+ this.notifyReconnected();
92
+ }
88
93
  this.listenTask = this.listenLoop(this.conn);
89
94
  this.heartbeatTask = this.heartbeatLoop(this.conn);
90
95
  return;
@@ -165,6 +170,17 @@ export class ConductorWebSocketClient {
165
170
  // Swallow callback errors to avoid impacting reconnect behavior.
166
171
  }
167
172
  }
173
+ notifyReconnected() {
174
+ if (!this.onReconnected) {
175
+ return;
176
+ }
177
+ try {
178
+ this.onReconnected();
179
+ }
180
+ catch {
181
+ // Swallow callback errors to avoid impacting reconnect behavior.
182
+ }
183
+ }
168
184
  notifyDisconnected() {
169
185
  if (!this.onDisconnected) {
170
186
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-sdk",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -27,5 +27,5 @@
27
27
  "typescript": "^5.6.3",
28
28
  "vitest": "^2.1.4"
29
29
  },
30
- "gitCommitId": "e500981"
30
+ "gitCommitId": "13b4d6c"
31
31
  }