@love-moon/conductor-sdk 0.2.16 → 0.2.18

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.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import crypto from 'node:crypto';
2
- import { BackendApiClient } from './backend/index.js';
2
+ import { BackendApiClient, BackendApiError } from './backend/index.js';
3
3
  import { loadConfig } from './config/index.js';
4
4
  import { getPlanLimitMessageFromError } from './limits/index.js';
5
5
  import { MessageRouter } from './message/index.js';
6
+ import { DownstreamCursorStore, DurableUpstreamOutboxStore, normalizeDownstreamCommandCursor, } from './outbox/index.js';
6
7
  import { SessionDiskStore, SessionManager, currentHostname } from './session/index.js';
7
- import { ConductorWebSocketClient } from './ws/index.js';
8
+ import { ConductorWebSocketClient, } from './ws/index.js';
8
9
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
9
10
  export class ConductorClient {
10
11
  config;
@@ -15,11 +16,15 @@ export class ConductorClient {
15
16
  sessions;
16
17
  sessionStore;
17
18
  messageRouter;
19
+ upstreamOutbox;
20
+ downstreamCursorStore;
18
21
  agentHost;
19
22
  onStopTask;
23
+ deliveryScopeId;
20
24
  closed = false;
21
- pendingOutbound = new Map();
22
- ACK_TIMEOUT_MS = 30000; // 30 seconds timeout for acknowledgment
25
+ durableOutboxFlushPromise = null;
26
+ durableOutboxTimer = null;
27
+ durableOutboxTimerDueAt = null;
23
28
  constructor(init) {
24
29
  this.config = init.config;
25
30
  this.env = init.env;
@@ -29,8 +34,11 @@ export class ConductorClient {
29
34
  this.sessions = init.sessionManager;
30
35
  this.sessionStore = init.sessionStore;
31
36
  this.messageRouter = init.messageRouter;
37
+ this.upstreamOutbox = init.upstreamOutbox;
38
+ this.downstreamCursorStore = init.downstreamCursorStore;
32
39
  this.agentHost = init.agentHost;
33
40
  this.onStopTask = init.onStopTask;
41
+ this.deliveryScopeId = init.deliveryScopeId;
34
42
  this.wsClient.registerHandler(this.handleBackendEvent);
35
43
  }
36
44
  static async connect(options = {}) {
@@ -42,29 +50,39 @@ export class ConductorClient {
42
50
  const sessionStore = options.sessionStore ?? SessionDiskStore.forBackendUrl(config.backendUrl);
43
51
  const messageRouter = options.messageRouter ?? new MessageRouter(sessions);
44
52
  const agentHost = resolveAgentHost(env, options.agentHost);
53
+ const deliveryScopeId = normalizeDeliveryScopeId(options.deliveryScopeId ?? `agent:${agentHost}`);
54
+ const upstreamOutbox = options.upstreamOutbox ?? DurableUpstreamOutboxStore.forProjectPath(projectPath, deliveryScopeId);
55
+ const downstreamCursorStore = options.downstreamCursorStore ?? DownstreamCursorStore.forProjectPath(projectPath, deliveryScopeId);
45
56
  const wsClient = options.wsClient ??
46
57
  new ConductorWebSocketClient(config, {
47
58
  hostName: agentHost,
48
59
  onConnected: options.onConnected,
49
60
  onDisconnected: options.onDisconnected,
50
61
  onReconnected: () => {
51
- // Resend pending confirmable messages after reconnection
52
- void client.flushPendingOutbound();
62
+ if (client.shouldAutoFlushDurableOutbox()) {
63
+ void client.requestDurableOutboxFlush(true);
64
+ }
53
65
  },
54
66
  });
55
67
  const client = new ConductorClient({
56
68
  config,
57
69
  env,
58
70
  projectPath,
71
+ deliveryScopeId,
59
72
  backendApi,
60
73
  wsClient,
61
74
  sessionManager: sessions,
62
75
  sessionStore,
63
76
  messageRouter,
77
+ upstreamOutbox,
78
+ downstreamCursorStore,
64
79
  agentHost,
65
80
  onStopTask: options.onStopTask,
66
81
  });
67
82
  await client.wsClient.connect();
83
+ if (client.shouldAutoFlushDurableOutbox()) {
84
+ void client.requestDurableOutboxFlush(true);
85
+ }
68
86
  return client;
69
87
  }
70
88
  async close() {
@@ -72,6 +90,7 @@ export class ConductorClient {
72
90
  return;
73
91
  }
74
92
  this.closed = true;
93
+ this.clearDurableOutboxTimer();
75
94
  await this.wsClient.disconnect();
76
95
  }
77
96
  async createTaskSession(payload) {
@@ -112,6 +131,7 @@ export class ConductorClient {
112
131
  id: taskId,
113
132
  projectId,
114
133
  title,
134
+ status: 'running',
115
135
  backendType,
116
136
  sessionId: logicalSessionId,
117
137
  sessionFilePath: explicitSessionFilePath,
@@ -132,6 +152,7 @@ export class ConductorClient {
132
152
  throw error;
133
153
  }
134
154
  await this.waitForTaskCreation(projectId, taskId);
155
+ this.promoteTaskDeliveryScope(taskId);
135
156
  const projectPath = typeof payload.project_path === 'string' && payload.project_path
136
157
  ? payload.project_path
137
158
  : this.projectPath;
@@ -152,25 +173,31 @@ export class ConductorClient {
152
173
  }
153
174
  async sendMessage(taskId, content, metadata) {
154
175
  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 };
176
+ const result = await this.persistAndCommitUpstreamEvent({
177
+ stableId: messageId,
178
+ eventType: 'sdk_message',
179
+ payload: {
180
+ taskId,
181
+ content,
182
+ metadata,
183
+ messageId,
184
+ },
185
+ });
186
+ return { ...result, message_id: messageId };
163
187
  }
164
188
  async sendTaskStatus(taskId, payload) {
165
- await this.sendEnvelope({
166
- type: 'task_status_update',
189
+ const statusEventId = safeRandomUuid();
190
+ const result = await this.persistAndCommitUpstreamEvent({
191
+ stableId: statusEventId,
192
+ eventType: 'task_status_update',
167
193
  payload: {
168
- task_id: taskId,
194
+ taskId,
169
195
  status: payload?.status,
170
196
  summary: payload?.summary,
197
+ statusEventId,
171
198
  },
172
199
  });
173
- return { delivered: true };
200
+ return { ...result, status_event_id: statusEventId };
174
201
  }
175
202
  async sendRuntimeStatus(taskId, payload) {
176
203
  await this.sendEnvelope({
@@ -199,6 +226,13 @@ export class ConductorClient {
199
226
  return { delivered: true };
200
227
  }
201
228
  async sendAgentResume(payload = {}) {
229
+ const storedCursor = this.downstreamCursorStore.get(this.agentHost);
230
+ const lastAppliedCursor = payload.last_applied_cursor ?? (storedCursor
231
+ ? {
232
+ created_at: storedCursor.createdAt,
233
+ request_id: storedCursor.requestId,
234
+ }
235
+ : undefined);
202
236
  await this.sendEnvelope({
203
237
  type: 'agent_resume',
204
238
  payload: {
@@ -207,6 +241,7 @@ export class ConductorClient {
207
241
  : [],
208
242
  source: payload.source,
209
243
  metadata: payload.metadata,
244
+ last_applied_cursor: lastAppliedCursor,
210
245
  },
211
246
  });
212
247
  return { delivered: true };
@@ -222,8 +257,17 @@ export class ConductorClient {
222
257
  event_type: payload.event_type,
223
258
  accepted: payload.accepted !== false,
224
259
  };
225
- await this.sendConfirmable('agent_command_ack', envelopePayload, requestId);
226
- return { delivered: true, request_id: requestId };
260
+ const result = await this.persistAndCommitUpstreamEvent({
261
+ stableId: requestId,
262
+ eventType: 'agent_command_ack',
263
+ payload: {
264
+ requestId,
265
+ taskId: envelopePayload.task_id,
266
+ commandEventType: envelopePayload.event_type,
267
+ accepted: envelopePayload.accepted,
268
+ },
269
+ });
270
+ return { ...result, request_id: requestId };
227
271
  }
228
272
  async sendTaskStopAck(payload) {
229
273
  const taskId = String(payload.task_id || '').trim();
@@ -234,15 +278,16 @@ export class ConductorClient {
234
278
  if (!requestId) {
235
279
  throw new Error('request_id is required');
236
280
  }
237
- await this.sendEnvelope({
238
- type: 'task_stop_ack',
281
+ const result = await this.persistAndCommitUpstreamEvent({
282
+ stableId: requestId,
283
+ eventType: 'task_stop_ack',
239
284
  payload: {
240
- task_id: taskId,
241
- request_id: requestId,
285
+ taskId,
286
+ requestId,
242
287
  accepted: payload.accepted !== false,
243
288
  },
244
289
  });
245
- return { delivered: true };
290
+ return { ...result, request_id: requestId, task_id: taskId };
246
291
  }
247
292
  async receiveMessages(taskId, limit = 20) {
248
293
  const messages = await this.sessions.popMessages(taskId, limit);
@@ -330,6 +375,7 @@ export class ConductorClient {
330
375
  if (!normalizedTaskId) {
331
376
  throw new Error('task_id is required');
332
377
  }
378
+ this.promoteTaskDeliveryScope(normalizedTaskId);
333
379
  const existing = this.sessionStore.findByTaskId(normalizedTaskId);
334
380
  const inMemorySession = await this.sessions.getSession(normalizedTaskId);
335
381
  const projectId = existing?.projectId ||
@@ -422,142 +468,270 @@ export class ConductorClient {
422
468
  };
423
469
  }
424
470
  handleBackendEvent = async (payload) => {
425
- // First handle acknowledgments for confirmable outbound messages
426
- this.handleAcknowledgment(payload);
427
- const stopCommandAccepted = await this.handleStopTaskEvent(payload);
428
- await this.messageRouter.handleBackendEvent(payload);
471
+ const command = this.extractDownstreamCommandContext(payload);
472
+ if (command?.eventType === 'stop_task') {
473
+ await this.handleStopTaskCommand(payload, command);
474
+ return;
475
+ }
476
+ const alreadyApplied = command ? this.downstreamCursorStore.hasApplied(this.agentHost, command.cursor) : false;
477
+ if (!alreadyApplied) {
478
+ await this.messageRouter.handleBackendEvent(payload);
479
+ if (command) {
480
+ this.downstreamCursorStore.advance(this.agentHost, command.cursor);
481
+ }
482
+ }
429
483
  await this.maybeAckInboundCommand(payload, {
430
- accepted: typeof stopCommandAccepted === 'boolean'
431
- ? stopCommandAccepted
432
- : undefined,
484
+ accepted: true,
433
485
  });
434
486
  };
435
- /**
436
- * Handle acknowledgment messages from backend for confirmable outbound messages
437
- */
438
- handleAcknowledgment(payload) {
487
+ extractDownstreamCommandContext(payload) {
439
488
  const eventType = typeof payload?.type === 'string' ? payload.type : '';
489
+ if (eventType !== 'task_user_message' && eventType !== 'task_action' && eventType !== 'stop_task') {
490
+ return null;
491
+ }
440
492
  const data = payload?.payload && typeof payload.payload === 'object'
441
493
  ? payload.payload
442
494
  : 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
- }
495
+ if (!data) {
496
+ return null;
497
+ }
498
+ const taskId = typeof data.task_id === 'string' ? data.task_id.trim() : '';
499
+ const requestId = typeof data.request_id === 'string' ? data.request_id.trim() : '';
500
+ const cursor = normalizeDownstreamCommandCursor(data.delivery_cursor);
501
+ if (!taskId || !requestId || !cursor) {
502
+ return null;
503
+ }
504
+ return {
505
+ eventType,
506
+ taskId,
507
+ requestId,
508
+ cursor,
509
+ };
510
+ }
511
+ async handleStopTaskCommand(payload, command) {
512
+ const alreadyApplied = this.downstreamCursorStore.hasApplied(this.agentHost, command.cursor);
513
+ let accepted = false;
514
+ if (alreadyApplied) {
515
+ accepted = true;
516
+ }
517
+ else {
518
+ accepted = await this.invokeStopTaskHandler(payload);
519
+ if (accepted) {
520
+ this.downstreamCursorStore.advance(this.agentHost, command.cursor);
454
521
  }
455
522
  }
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
- }
523
+ if (!command.requestId) {
524
+ return;
525
+ }
526
+ try {
527
+ await this.sendTaskStopAck({
528
+ task_id: command.taskId,
529
+ request_id: command.requestId,
530
+ accepted,
531
+ });
532
+ }
533
+ catch (error) {
534
+ const message = error instanceof Error ? error.message : String(error);
535
+ console.warn(`[sdk] failed to send task_stop_ack for task ${command.taskId}: ${message}`);
536
+ }
537
+ }
538
+ async invokeStopTaskHandler(payload) {
539
+ const data = payload?.payload && typeof payload.payload === 'object'
540
+ ? payload.payload
541
+ : null;
542
+ if (!data) {
543
+ return false;
544
+ }
545
+ const taskId = typeof data.task_id === 'string' ? data.task_id.trim() : '';
546
+ const requestId = typeof data.request_id === 'string' ? data.request_id.trim() : '';
547
+ const reason = typeof data.reason === 'string' ? data.reason.trim() : '';
548
+ if (!taskId) {
549
+ return false;
550
+ }
551
+ if (!this.onStopTask) {
552
+ return false;
553
+ }
554
+ try {
555
+ await this.onStopTask({
556
+ taskId,
557
+ requestId: requestId || undefined,
558
+ reason: reason || undefined,
559
+ });
560
+ return true;
561
+ }
562
+ catch (error) {
563
+ const message = error instanceof Error ? error.message : String(error);
564
+ console.warn(`[sdk] stop_task callback failed for task ${taskId}: ${message}`);
565
+ return false;
566
+ }
567
+ }
568
+ async persistAndCommitUpstreamEvent(entry) {
569
+ const stored = this.upstreamOutbox.upsert(entry);
570
+ if (stored.eventType === 'sdk_message' && this.hasEarlierPendingSdkMessage(stored.stableId)) {
571
+ this.scheduleDurableOutboxFlush(0);
572
+ return { delivered: false, pending: true };
573
+ }
574
+ try {
575
+ await this.commitDurableUpstreamEvent(stored);
576
+ this.upstreamOutbox.remove(stored.stableId);
577
+ return { delivered: true };
578
+ }
579
+ catch (error) {
580
+ if (!this.isRetryableUpstreamError(error)) {
581
+ this.upstreamOutbox.remove(stored.stableId);
582
+ throw error;
465
583
  }
584
+ const updated = this.upstreamOutbox.markRetry(stored.stableId, this.computeDurableOutboxRetryDelay(stored.attemptCount + 1));
585
+ const delay = updated?.nextAttemptAt
586
+ ? Math.max(Date.parse(updated.nextAttemptAt) - Date.now(), 0)
587
+ : this.computeDurableOutboxRetryDelay(stored.attemptCount + 1);
588
+ console.warn(`[sdk] queued ${stored.eventType} (${stored.stableId}) for durable retry after HTTP failure: ${error instanceof Error ? error.message : String(error)}`);
589
+ this.scheduleDurableOutboxFlush(delay);
590
+ return { delivered: false, pending: true };
466
591
  }
467
592
  }
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;
593
+ async requestDurableOutboxFlush(force = false) {
594
+ if (this.closed) {
595
+ return;
596
+ }
597
+ if (this.durableOutboxFlushPromise) {
598
+ return this.durableOutboxFlushPromise;
599
+ }
600
+ this.durableOutboxFlushPromise = (async () => {
601
+ const readyEntries = force ? this.upstreamOutbox.load() : this.upstreamOutbox.listReady();
602
+ let sdkMessageBlocked = false;
603
+ for (const entry of readyEntries) {
604
+ if (this.closed) {
605
+ break;
606
+ }
607
+ if (sdkMessageBlocked && entry.eventType === 'sdk_message') {
608
+ continue;
609
+ }
610
+ try {
611
+ await this.commitDurableUpstreamEvent(entry);
612
+ this.upstreamOutbox.remove(entry.stableId);
613
+ }
614
+ catch (error) {
615
+ if (!this.isRetryableUpstreamError(error)) {
616
+ this.upstreamOutbox.remove(entry.stableId);
617
+ console.warn(`[sdk] dropping durable upstream event ${entry.eventType} (${entry.stableId}) after terminal HTTP failure: ${error instanceof Error ? error.message : String(error)}`);
618
+ continue;
482
619
  }
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;
620
+ const updated = this.upstreamOutbox.markRetry(entry.stableId, this.computeDurableOutboxRetryDelay(entry.attemptCount + 1));
621
+ console.warn(`[sdk] durable retry failed for ${entry.eventType} (${entry.stableId}): ${error instanceof Error ? error.message : String(error)}`);
622
+ if (entry.eventType === 'sdk_message') {
623
+ sdkMessageBlocked = true;
624
+ }
625
+ if (updated?.nextAttemptAt) {
626
+ this.scheduleDurableOutboxFlush(Math.max(Date.parse(updated.nextAttemptAt) - Date.now(), 0));
487
627
  }
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
628
  }
514
- }, this.ACK_TIMEOUT_MS);
629
+ }
630
+ const nextDelay = this.upstreamOutbox.nextRetryDelay();
631
+ if (nextDelay !== null) {
632
+ this.scheduleDurableOutboxFlush(nextDelay);
633
+ }
634
+ })().finally(() => {
635
+ this.durableOutboxFlushPromise = null;
515
636
  });
637
+ return this.durableOutboxFlushPromise;
516
638
  }
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,
639
+ hasEarlierPendingSdkMessage(stableId) {
640
+ const entries = this.upstreamOutbox.load();
641
+ const currentIndex = entries.findIndex((candidate) => candidate.stableId === stableId);
642
+ if (currentIndex <= 0) {
643
+ return false;
644
+ }
645
+ return entries.slice(0, currentIndex).some((candidate) => candidate.eventType === 'sdk_message');
646
+ }
647
+ async commitDurableUpstreamEvent(entry) {
648
+ if (entry.eventType === 'sdk_message') {
649
+ await this.backendApi.commitSdkMessage({
650
+ agentHost: this.agentHost,
651
+ taskId: String(entry.payload.taskId || ''),
652
+ content: String(entry.payload.content || ''),
653
+ metadata: entry.payload.metadata && typeof entry.payload.metadata === 'object' && !Array.isArray(entry.payload.metadata)
654
+ ? entry.payload.metadata
655
+ : undefined,
656
+ messageId: entry.payload.messageId ? String(entry.payload.messageId) : null,
529
657
  });
530
- pending.lastSentAt = Date.now();
531
- pending.retryCount++;
658
+ return;
532
659
  }
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)}`);
660
+ if (entry.eventType === 'task_status_update') {
661
+ await this.backendApi.commitTaskStatusUpdate({
662
+ agentHost: this.agentHost,
663
+ taskId: String(entry.payload.taskId || ''),
664
+ status: String(entry.payload.status || ''),
665
+ summary: entry.payload.summary ? String(entry.payload.summary) : null,
666
+ statusEventId: entry.payload.statusEventId ? String(entry.payload.statusEventId) : null,
667
+ });
668
+ return;
536
669
  }
537
- }
538
- /**
539
- * Flush all pending confirmable messages.
540
- * Called after websocket reconnection.
541
- */
542
- async flushPendingOutbound() {
543
- if (this.pendingOutbound.size === 0)
670
+ if (entry.eventType === 'task_stop_ack') {
671
+ await this.backendApi.commitTaskStopAck({
672
+ agentHost: this.agentHost,
673
+ taskId: String(entry.payload.taskId || ''),
674
+ requestId: String(entry.payload.requestId || ''),
675
+ accepted: entry.payload.accepted !== false,
676
+ });
544
677
  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
678
+ }
679
+ await this.backendApi.commitAgentCommandAck({
680
+ agentHost: this.agentHost,
681
+ requestId: String(entry.payload.requestId || ''),
682
+ taskId: entry.payload.taskId ? String(entry.payload.taskId) : null,
683
+ accepted: entry.payload.accepted !== false,
684
+ commandEventType: entry.payload.commandEventType ? String(entry.payload.commandEventType) : null,
553
685
  });
554
686
  }
687
+ isRetryableUpstreamError(error) {
688
+ if (!(error instanceof BackendApiError)) {
689
+ return true;
690
+ }
691
+ if (error.statusCode === undefined || error.statusCode === null) {
692
+ return true;
693
+ }
694
+ if (error.statusCode >= 500) {
695
+ return true;
696
+ }
697
+ return error.statusCode === 408 || error.statusCode === 429;
698
+ }
699
+ computeDurableOutboxRetryDelay(attemptCount) {
700
+ const cappedExponent = Math.max(0, Math.min(attemptCount - 1, 5));
701
+ return 1_000 * 2 ** cappedExponent;
702
+ }
703
+ scheduleDurableOutboxFlush(delayMs) {
704
+ if (this.closed) {
705
+ return;
706
+ }
707
+ const normalizedDelay = Math.max(delayMs, 0);
708
+ const dueAt = Date.now() + normalizedDelay;
709
+ if (this.durableOutboxTimer &&
710
+ this.durableOutboxTimerDueAt !== null &&
711
+ this.durableOutboxTimerDueAt <= dueAt) {
712
+ return;
713
+ }
714
+ this.clearDurableOutboxTimer();
715
+ this.durableOutboxTimerDueAt = dueAt;
716
+ this.durableOutboxTimer = setTimeout(() => {
717
+ this.durableOutboxTimer = null;
718
+ this.durableOutboxTimerDueAt = null;
719
+ void this.requestDurableOutboxFlush(false);
720
+ }, normalizedDelay);
721
+ }
722
+ clearDurableOutboxTimer() {
723
+ if (this.durableOutboxTimer) {
724
+ clearTimeout(this.durableOutboxTimer);
725
+ this.durableOutboxTimer = null;
726
+ }
727
+ this.durableOutboxTimerDueAt = null;
728
+ }
555
729
  async sendEnvelope(envelope) {
556
730
  await this.wsClient.sendJson(envelope);
557
731
  }
558
732
  async maybeAckInboundCommand(payload, options = {}) {
559
733
  const eventType = typeof payload?.type === 'string' ? payload.type : '';
560
- if (eventType !== 'task_user_message' && eventType !== 'task_action' && eventType !== 'stop_task') {
734
+ if (eventType !== 'task_user_message' && eventType !== 'task_action') {
561
735
  return;
562
736
  }
563
737
  const data = payload?.payload && typeof payload.payload === 'object'
@@ -583,51 +757,41 @@ export class ConductorClient {
583
757
  console.warn(`[sdk] failed to ack inbound command ${requestId}: ${message}`);
584
758
  }
585
759
  }
586
- async handleStopTaskEvent(payload) {
587
- if (typeof payload?.type !== 'string' || payload.type !== 'stop_task') {
588
- return undefined;
589
- }
590
- const data = payload?.payload && typeof payload.payload === 'object'
591
- ? payload.payload
592
- : null;
593
- if (!data) {
594
- return false;
760
+ promoteTaskDeliveryScope(taskId) {
761
+ const normalizedTaskId = String(taskId || '').trim();
762
+ if (!normalizedTaskId) {
763
+ return;
595
764
  }
596
- const taskId = typeof data.task_id === 'string' ? data.task_id.trim() : '';
597
- const requestId = typeof data.request_id === 'string' ? data.request_id.trim() : '';
598
- const reason = typeof data.reason === 'string' ? data.reason.trim() : '';
599
- if (!taskId) {
600
- return false;
765
+ this.setDeliveryScopeId(`task:${normalizedTaskId}`);
766
+ }
767
+ setDeliveryScopeId(scopeId) {
768
+ const nextScopeId = normalizeDeliveryScopeId(scopeId);
769
+ if (nextScopeId === this.deliveryScopeId) {
770
+ return;
601
771
  }
602
- let accepted = false;
603
- if (this.onStopTask) {
604
- try {
605
- await this.onStopTask({
606
- taskId,
607
- requestId: requestId || undefined,
608
- reason: reason || undefined,
609
- });
610
- accepted = true;
611
- }
612
- catch (error) {
613
- const message = error instanceof Error ? error.message : String(error);
614
- console.warn(`[sdk] stop_task callback failed for task ${taskId}: ${message}`);
615
- }
772
+ const currentUpstream = this.upstreamOutbox;
773
+ const currentCursorStore = this.downstreamCursorStore;
774
+ const nextUpstream = DurableUpstreamOutboxStore.forProjectPath(this.projectPath, nextScopeId);
775
+ const nextCursorStore = DownstreamCursorStore.forProjectPath(this.projectPath, nextScopeId);
776
+ for (const entry of currentUpstream.load()) {
777
+ nextUpstream.upsert({
778
+ stableId: entry.stableId,
779
+ eventType: entry.eventType,
780
+ payload: entry.payload,
781
+ });
782
+ currentUpstream.remove(entry.stableId);
616
783
  }
617
- if (requestId) {
618
- try {
619
- await this.sendTaskStopAck({
620
- task_id: taskId,
621
- request_id: requestId,
622
- accepted,
623
- });
624
- }
625
- catch (error) {
626
- const message = error instanceof Error ? error.message : String(error);
627
- console.warn(`[sdk] failed to send task_stop_ack for task ${taskId}: ${message}`);
628
- }
784
+ const currentCursor = currentCursorStore.get(this.agentHost);
785
+ if (currentCursor && !nextCursorStore.get(this.agentHost)) {
786
+ nextCursorStore.advance(this.agentHost, currentCursor);
629
787
  }
630
- return accepted;
788
+ this.upstreamOutbox = nextUpstream;
789
+ this.downstreamCursorStore = nextCursorStore;
790
+ this.deliveryScopeId = nextScopeId;
791
+ void this.requestDurableOutboxFlush(true);
792
+ }
793
+ shouldAutoFlushDurableOutbox() {
794
+ return this.deliveryScopeId.startsWith('task:') || this.deliveryScopeId.startsWith('session:');
631
795
  }
632
796
  resolveHostname() {
633
797
  const records = this.sessionStore.load();
@@ -678,6 +842,8 @@ function formatMessagesResponse(messages) {
678
842
  role: msg.role,
679
843
  content: msg.content,
680
844
  ack_token: msg.ackToken,
845
+ metadata: msg.metadata ?? undefined,
846
+ attachments: msg.attachments?.length ? msg.attachments : undefined,
681
847
  created_at: msg.createdAt.toISOString(),
682
848
  })),
683
849
  next_ack_token: messages.length ? messages[messages.length - 1].ackToken ?? null : null,
@@ -706,3 +872,7 @@ function resolveAgentHost(env, explicit) {
706
872
  const host = env.HOSTNAME || env.COMPUTERNAME || 'unknown-host';
707
873
  return `conductor-fire-${host}-${pid}`;
708
874
  }
875
+ function normalizeDeliveryScopeId(scopeId) {
876
+ const normalized = String(scopeId || '').trim();
877
+ return normalized || 'default';
878
+ }