@love-moon/conductor-sdk 0.2.15 → 0.2.17

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.
@@ -4,6 +4,30 @@ export interface BackendClientOptions {
4
4
  fetchImpl?: FetchFn;
5
5
  timeoutMs?: number;
6
6
  }
7
+ export type AgentCommitEvent = {
8
+ eventType: 'sdk_message';
9
+ taskId: string;
10
+ content: string;
11
+ metadata?: Record<string, unknown>;
12
+ messageId?: string | null;
13
+ } | {
14
+ eventType: 'task_status_update';
15
+ taskId: string;
16
+ status: string;
17
+ summary?: string | null;
18
+ statusEventId?: string | null;
19
+ } | {
20
+ eventType: 'agent_command_ack';
21
+ requestId: string;
22
+ taskId?: string | null;
23
+ accepted?: boolean;
24
+ commandEventType?: string | null;
25
+ } | {
26
+ eventType: 'task_stop_ack';
27
+ taskId: string;
28
+ requestId: string;
29
+ accepted?: boolean;
30
+ };
7
31
  export declare class BackendApiError extends Error {
8
32
  readonly statusCode?: number | undefined;
9
33
  readonly details?: unknown | undefined;
@@ -73,6 +97,39 @@ export declare class BackendApiClient {
73
97
  content: string;
74
98
  metadata?: Record<string, unknown>;
75
99
  }): Promise<any>;
100
+ commitAgentEvents(params: {
101
+ agentHost: string;
102
+ events: AgentCommitEvent[];
103
+ }): Promise<{
104
+ results: Array<Record<string, unknown>>;
105
+ }>;
106
+ commitSdkMessage(params: {
107
+ agentHost: string;
108
+ taskId: string;
109
+ content: string;
110
+ metadata?: Record<string, unknown>;
111
+ messageId?: string | null;
112
+ }): Promise<Record<string, unknown>>;
113
+ commitTaskStatusUpdate(params: {
114
+ agentHost: string;
115
+ taskId: string;
116
+ status: string;
117
+ summary?: string | null;
118
+ statusEventId?: string | null;
119
+ }): Promise<Record<string, unknown>>;
120
+ commitAgentCommandAck(params: {
121
+ agentHost: string;
122
+ requestId: string;
123
+ taskId?: string | null;
124
+ accepted?: boolean;
125
+ commandEventType?: string | null;
126
+ }): Promise<Record<string, unknown>>;
127
+ commitTaskStopAck(params: {
128
+ agentHost: string;
129
+ taskId: string;
130
+ requestId: string;
131
+ accepted?: boolean;
132
+ }): Promise<Record<string, unknown>>;
76
133
  matchProjectByPath(params: {
77
134
  hostname: string;
78
135
  path: string;
@@ -152,6 +152,108 @@ export class BackendApiClient {
152
152
  });
153
153
  return this.parseJson(response);
154
154
  }
155
+ async commitAgentEvents(params) {
156
+ const response = await this.request('POST', '/agent/events', {
157
+ body: JSON.stringify({
158
+ agent_host: params.agentHost,
159
+ events: params.events.map((event) => {
160
+ if (event.eventType === 'sdk_message') {
161
+ return {
162
+ event_type: event.eventType,
163
+ task_id: event.taskId,
164
+ content: event.content,
165
+ metadata: event.metadata,
166
+ message_id: event.messageId ?? undefined,
167
+ };
168
+ }
169
+ if (event.eventType === 'task_status_update') {
170
+ return {
171
+ event_type: event.eventType,
172
+ task_id: event.taskId,
173
+ status: event.status,
174
+ summary: event.summary ?? undefined,
175
+ status_event_id: event.statusEventId ?? undefined,
176
+ };
177
+ }
178
+ if (event.eventType === 'task_stop_ack') {
179
+ return {
180
+ event_type: event.eventType,
181
+ task_id: event.taskId,
182
+ request_id: event.requestId,
183
+ accepted: event.accepted !== false,
184
+ };
185
+ }
186
+ return {
187
+ event_type: event.eventType,
188
+ request_id: event.requestId,
189
+ task_id: event.taskId ?? undefined,
190
+ accepted: event.accepted !== false,
191
+ command_event_type: event.commandEventType ?? undefined,
192
+ };
193
+ }),
194
+ }),
195
+ });
196
+ return this.parseJson(response);
197
+ }
198
+ async commitSdkMessage(params) {
199
+ const payload = await this.commitAgentEvents({
200
+ agentHost: params.agentHost,
201
+ events: [
202
+ {
203
+ eventType: 'sdk_message',
204
+ taskId: params.taskId,
205
+ content: params.content,
206
+ metadata: params.metadata,
207
+ messageId: params.messageId,
208
+ },
209
+ ],
210
+ });
211
+ return (Array.isArray(payload.results) ? payload.results[0] : null) ?? {};
212
+ }
213
+ async commitTaskStatusUpdate(params) {
214
+ const payload = await this.commitAgentEvents({
215
+ agentHost: params.agentHost,
216
+ events: [
217
+ {
218
+ eventType: 'task_status_update',
219
+ taskId: params.taskId,
220
+ status: params.status,
221
+ summary: params.summary,
222
+ statusEventId: params.statusEventId,
223
+ },
224
+ ],
225
+ });
226
+ return (Array.isArray(payload.results) ? payload.results[0] : null) ?? {};
227
+ }
228
+ async commitAgentCommandAck(params) {
229
+ const payload = await this.commitAgentEvents({
230
+ agentHost: params.agentHost,
231
+ events: [
232
+ {
233
+ eventType: 'agent_command_ack',
234
+ requestId: params.requestId,
235
+ taskId: params.taskId,
236
+ accepted: params.accepted,
237
+ commandEventType: params.commandEventType,
238
+ },
239
+ ],
240
+ });
241
+ return (Array.isArray(payload.results) ? payload.results[0] : null) ?? {};
242
+ }
243
+ async commitTaskStopAck(params) {
244
+ const payload = await this.commitAgentEvents({
245
+ agentHost: params.agentHost,
246
+ events: [
247
+ {
248
+ eventType: 'task_stop_ack',
249
+ taskId: params.taskId,
250
+ requestId: params.requestId,
251
+ accepted: params.accepted,
252
+ },
253
+ ],
254
+ });
255
+ return (Array.isArray(payload.results) ? payload.results[0] : null) ?? {};
256
+ }
155
257
  async matchProjectByPath(params) {
156
258
  const response = await this.request('POST', '/projects/match-path', {
157
259
  body: JSON.stringify(params),
package/dist/client.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { BackendApiClient } from './backend/index.js';
2
2
  import { ConductorConfig } from './config/index.js';
3
3
  import { MessageRouter } from './message/index.js';
4
+ import { DownstreamCursorStore, DurableUpstreamOutboxStore } from './outbox/index.js';
4
5
  import { SessionDiskStore, SessionManager } from './session/index.js';
5
6
  import { ConductorWebSocketClient } from './ws/index.js';
6
- type BackendApiLike = Pick<BackendApiClient, 'listProjects' | 'createProject' | 'listTasks' | 'createTask' | 'updateTask' | 'matchProjectByPath' | 'getProject' | 'updateProject'>;
7
+ type BackendApiLike = Pick<BackendApiClient, 'listProjects' | 'createProject' | 'listTasks' | 'createTask' | 'updateTask' | 'commitSdkMessage' | 'commitTaskStatusUpdate' | 'commitAgentCommandAck' | 'commitTaskStopAck' | 'matchProjectByPath' | 'getProject' | 'updateProject'>;
7
8
  type RealtimeClientLike = Pick<ConductorWebSocketClient, 'registerHandler' | 'connect' | 'disconnect' | 'sendJson'>;
8
9
  export interface ConductorClientConnectOptions {
9
10
  config?: ConductorConfig;
@@ -11,11 +12,14 @@ export interface ConductorClientConnectOptions {
11
12
  env?: Record<string, string | undefined>;
12
13
  extraEnv?: Record<string, string | undefined>;
13
14
  projectPath?: string;
15
+ deliveryScopeId?: string;
14
16
  backendApi?: BackendApiLike;
15
17
  wsClient?: RealtimeClientLike;
16
18
  sessionManager?: SessionManager;
17
19
  sessionStore?: SessionDiskStore;
18
20
  messageRouter?: MessageRouter;
21
+ upstreamOutbox?: DurableUpstreamOutboxStore;
22
+ downstreamCursorStore?: DownstreamCursorStore;
19
23
  agentHost?: string;
20
24
  onConnected?: (event: {
21
25
  isReconnect: boolean;
@@ -27,11 +31,14 @@ interface ConductorClientInit {
27
31
  config: ConductorConfig;
28
32
  env: Record<string, string | undefined>;
29
33
  projectPath: string;
34
+ deliveryScopeId: string;
30
35
  backendApi: BackendApiLike;
31
36
  wsClient: RealtimeClientLike;
32
37
  sessionManager: SessionManager;
33
38
  sessionStore: SessionDiskStore;
34
39
  messageRouter: MessageRouter;
40
+ upstreamOutbox: DurableUpstreamOutboxStore;
41
+ downstreamCursorStore: DownstreamCursorStore;
35
42
  agentHost: string;
36
43
  onStopTask?: (event: StopTaskEvent) => Promise<void> | void;
37
44
  }
@@ -49,9 +56,15 @@ export declare class ConductorClient {
49
56
  private readonly sessions;
50
57
  private readonly sessionStore;
51
58
  private readonly messageRouter;
59
+ private upstreamOutbox;
60
+ private downstreamCursorStore;
52
61
  private readonly agentHost;
53
62
  private readonly onStopTask?;
63
+ private deliveryScopeId;
54
64
  private closed;
65
+ private durableOutboxFlushPromise;
66
+ private durableOutboxTimer;
67
+ private durableOutboxTimerDueAt;
55
68
  constructor(init: ConductorClientInit);
56
69
  static connect(options?: ConductorClientConnectOptions): Promise<ConductorClient>;
57
70
  close(): Promise<void>;
@@ -63,6 +76,10 @@ export declare class ConductorClient {
63
76
  active_tasks?: string[];
64
77
  source?: string;
65
78
  metadata?: Record<string, any>;
79
+ last_applied_cursor?: {
80
+ created_at: string;
81
+ request_id: string;
82
+ };
66
83
  }): Promise<Record<string, any>>;
67
84
  sendAgentCommandAck(payload: {
68
85
  request_id: string;
@@ -86,9 +103,22 @@ export declare class ConductorClient {
86
103
  matchProjectByPath(payload?: Record<string, any>): Promise<Record<string, any>>;
87
104
  bindProjectPath(projectId: string, payload?: Record<string, any>): Promise<Record<string, any>>;
88
105
  private readonly handleBackendEvent;
106
+ private extractDownstreamCommandContext;
107
+ private handleStopTaskCommand;
108
+ private invokeStopTaskHandler;
109
+ private persistAndCommitUpstreamEvent;
110
+ private requestDurableOutboxFlush;
111
+ private hasEarlierPendingSdkMessage;
112
+ private commitDurableUpstreamEvent;
113
+ private isRetryableUpstreamError;
114
+ private computeDurableOutboxRetryDelay;
115
+ private scheduleDurableOutboxFlush;
116
+ private clearDurableOutboxTimer;
89
117
  private sendEnvelope;
90
118
  private maybeAckInboundCommand;
91
- private handleStopTaskEvent;
119
+ private promoteTaskDeliveryScope;
120
+ private setDeliveryScopeId;
121
+ private shouldAutoFlushDurableOutbox;
92
122
  private resolveHostname;
93
123
  private waitForTaskCreation;
94
124
  private readIntEnv;
package/dist/client.js CHANGED
@@ -1,8 +1,9 @@
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
8
  import { ConductorWebSocketClient } from './ws/index.js';
8
9
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -15,9 +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;
25
+ durableOutboxFlushPromise = null;
26
+ durableOutboxTimer = null;
27
+ durableOutboxTimerDueAt = null;
21
28
  constructor(init) {
22
29
  this.config = init.config;
23
30
  this.env = init.env;
@@ -27,8 +34,11 @@ export class ConductorClient {
27
34
  this.sessions = init.sessionManager;
28
35
  this.sessionStore = init.sessionStore;
29
36
  this.messageRouter = init.messageRouter;
37
+ this.upstreamOutbox = init.upstreamOutbox;
38
+ this.downstreamCursorStore = init.downstreamCursorStore;
30
39
  this.agentHost = init.agentHost;
31
40
  this.onStopTask = init.onStopTask;
41
+ this.deliveryScopeId = init.deliveryScopeId;
32
42
  this.wsClient.registerHandler(this.handleBackendEvent);
33
43
  }
34
44
  static async connect(options = {}) {
@@ -40,25 +50,39 @@ export class ConductorClient {
40
50
  const sessionStore = options.sessionStore ?? SessionDiskStore.forBackendUrl(config.backendUrl);
41
51
  const messageRouter = options.messageRouter ?? new MessageRouter(sessions);
42
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);
43
56
  const wsClient = options.wsClient ??
44
57
  new ConductorWebSocketClient(config, {
45
58
  hostName: agentHost,
46
59
  onConnected: options.onConnected,
47
60
  onDisconnected: options.onDisconnected,
61
+ onReconnected: () => {
62
+ if (client.shouldAutoFlushDurableOutbox()) {
63
+ void client.requestDurableOutboxFlush(true);
64
+ }
65
+ },
48
66
  });
49
67
  const client = new ConductorClient({
50
68
  config,
51
69
  env,
52
70
  projectPath,
71
+ deliveryScopeId,
53
72
  backendApi,
54
73
  wsClient,
55
74
  sessionManager: sessions,
56
75
  sessionStore,
57
76
  messageRouter,
77
+ upstreamOutbox,
78
+ downstreamCursorStore,
58
79
  agentHost,
59
80
  onStopTask: options.onStopTask,
60
81
  });
61
82
  await client.wsClient.connect();
83
+ if (client.shouldAutoFlushDurableOutbox()) {
84
+ void client.requestDurableOutboxFlush(true);
85
+ }
62
86
  return client;
63
87
  }
64
88
  async close() {
@@ -66,6 +90,7 @@ export class ConductorClient {
66
90
  return;
67
91
  }
68
92
  this.closed = true;
93
+ this.clearDurableOutboxTimer();
69
94
  await this.wsClient.disconnect();
70
95
  }
71
96
  async createTaskSession(payload) {
@@ -126,6 +151,7 @@ export class ConductorClient {
126
151
  throw error;
127
152
  }
128
153
  await this.waitForTaskCreation(projectId, taskId);
154
+ this.promoteTaskDeliveryScope(taskId);
129
155
  const projectPath = typeof payload.project_path === 'string' && payload.project_path
130
156
  ? payload.project_path
131
157
  : this.projectPath;
@@ -145,26 +171,32 @@ export class ConductorClient {
145
171
  };
146
172
  }
147
173
  async sendMessage(taskId, content, metadata) {
148
- await this.sendEnvelope({
149
- type: 'sdk_message',
174
+ const messageId = safeRandomUuid();
175
+ const result = await this.persistAndCommitUpstreamEvent({
176
+ stableId: messageId,
177
+ eventType: 'sdk_message',
150
178
  payload: {
151
- task_id: taskId,
179
+ taskId,
152
180
  content,
153
181
  metadata,
182
+ messageId,
154
183
  },
155
184
  });
156
- return { delivered: true };
185
+ return { ...result, message_id: messageId };
157
186
  }
158
187
  async sendTaskStatus(taskId, payload) {
159
- await this.sendEnvelope({
160
- type: 'task_status_update',
188
+ const statusEventId = safeRandomUuid();
189
+ const result = await this.persistAndCommitUpstreamEvent({
190
+ stableId: statusEventId,
191
+ eventType: 'task_status_update',
161
192
  payload: {
162
- task_id: taskId,
193
+ taskId,
163
194
  status: payload?.status,
164
195
  summary: payload?.summary,
196
+ statusEventId,
165
197
  },
166
198
  });
167
- return { delivered: true };
199
+ return { ...result, status_event_id: statusEventId };
168
200
  }
169
201
  async sendRuntimeStatus(taskId, payload) {
170
202
  await this.sendEnvelope({
@@ -193,6 +225,13 @@ export class ConductorClient {
193
225
  return { delivered: true };
194
226
  }
195
227
  async sendAgentResume(payload = {}) {
228
+ const storedCursor = this.downstreamCursorStore.get(this.agentHost);
229
+ const lastAppliedCursor = payload.last_applied_cursor ?? (storedCursor
230
+ ? {
231
+ created_at: storedCursor.createdAt,
232
+ request_id: storedCursor.requestId,
233
+ }
234
+ : undefined);
196
235
  await this.sendEnvelope({
197
236
  type: 'agent_resume',
198
237
  payload: {
@@ -201,6 +240,7 @@ export class ConductorClient {
201
240
  : [],
202
241
  source: payload.source,
203
242
  metadata: payload.metadata,
243
+ last_applied_cursor: lastAppliedCursor,
204
244
  },
205
245
  });
206
246
  return { delivered: true };
@@ -210,16 +250,23 @@ export class ConductorClient {
210
250
  if (!requestId) {
211
251
  throw new Error('request_id is required');
212
252
  }
213
- await this.sendEnvelope({
214
- type: 'agent_command_ack',
253
+ const envelopePayload = {
254
+ request_id: requestId,
255
+ task_id: payload.task_id,
256
+ event_type: payload.event_type,
257
+ accepted: payload.accepted !== false,
258
+ };
259
+ const result = await this.persistAndCommitUpstreamEvent({
260
+ stableId: requestId,
261
+ eventType: 'agent_command_ack',
215
262
  payload: {
216
- request_id: requestId,
217
- task_id: payload.task_id,
218
- event_type: payload.event_type,
219
- accepted: payload.accepted !== false,
263
+ requestId,
264
+ taskId: envelopePayload.task_id,
265
+ commandEventType: envelopePayload.event_type,
266
+ accepted: envelopePayload.accepted,
220
267
  },
221
268
  });
222
- return { delivered: true };
269
+ return { ...result, request_id: requestId };
223
270
  }
224
271
  async sendTaskStopAck(payload) {
225
272
  const taskId = String(payload.task_id || '').trim();
@@ -230,15 +277,16 @@ export class ConductorClient {
230
277
  if (!requestId) {
231
278
  throw new Error('request_id is required');
232
279
  }
233
- await this.sendEnvelope({
234
- type: 'task_stop_ack',
280
+ const result = await this.persistAndCommitUpstreamEvent({
281
+ stableId: requestId,
282
+ eventType: 'task_stop_ack',
235
283
  payload: {
236
- task_id: taskId,
237
- request_id: requestId,
284
+ taskId,
285
+ requestId,
238
286
  accepted: payload.accepted !== false,
239
287
  },
240
288
  });
241
- return { delivered: true };
289
+ return { ...result, request_id: requestId, task_id: taskId };
242
290
  }
243
291
  async receiveMessages(taskId, limit = 20) {
244
292
  const messages = await this.sessions.popMessages(taskId, limit);
@@ -326,6 +374,7 @@ export class ConductorClient {
326
374
  if (!normalizedTaskId) {
327
375
  throw new Error('task_id is required');
328
376
  }
377
+ this.promoteTaskDeliveryScope(normalizedTaskId);
329
378
  const existing = this.sessionStore.findByTaskId(normalizedTaskId);
330
379
  const inMemorySession = await this.sessions.getSession(normalizedTaskId);
331
380
  const projectId = existing?.projectId ||
@@ -418,49 +467,74 @@ export class ConductorClient {
418
467
  };
419
468
  }
420
469
  handleBackendEvent = async (payload) => {
421
- const stopCommandAccepted = await this.handleStopTaskEvent(payload);
422
- await this.messageRouter.handleBackendEvent(payload);
470
+ const command = this.extractDownstreamCommandContext(payload);
471
+ if (command?.eventType === 'stop_task') {
472
+ await this.handleStopTaskCommand(payload, command);
473
+ return;
474
+ }
475
+ const alreadyApplied = command ? this.downstreamCursorStore.hasApplied(this.agentHost, command.cursor) : false;
476
+ if (!alreadyApplied) {
477
+ await this.messageRouter.handleBackendEvent(payload);
478
+ if (command) {
479
+ this.downstreamCursorStore.advance(this.agentHost, command.cursor);
480
+ }
481
+ }
423
482
  await this.maybeAckInboundCommand(payload, {
424
- accepted: typeof stopCommandAccepted === 'boolean'
425
- ? stopCommandAccepted
426
- : undefined,
483
+ accepted: true,
427
484
  });
428
485
  };
429
- async sendEnvelope(envelope) {
430
- await this.wsClient.sendJson(envelope);
431
- }
432
- async maybeAckInboundCommand(payload, options = {}) {
486
+ extractDownstreamCommandContext(payload) {
433
487
  const eventType = typeof payload?.type === 'string' ? payload.type : '';
434
488
  if (eventType !== 'task_user_message' && eventType !== 'task_action' && eventType !== 'stop_task') {
435
- return;
489
+ return null;
436
490
  }
437
491
  const data = payload?.payload && typeof payload.payload === 'object'
438
492
  ? payload.payload
439
493
  : null;
440
494
  if (!data) {
441
- return;
495
+ return null;
442
496
  }
497
+ const taskId = typeof data.task_id === 'string' ? data.task_id.trim() : '';
443
498
  const requestId = typeof data.request_id === 'string' ? data.request_id.trim() : '';
444
- if (!requestId) {
499
+ const cursor = normalizeDownstreamCommandCursor(data.delivery_cursor);
500
+ if (!taskId || !requestId || !cursor) {
501
+ return null;
502
+ }
503
+ return {
504
+ eventType,
505
+ taskId,
506
+ requestId,
507
+ cursor,
508
+ };
509
+ }
510
+ async handleStopTaskCommand(payload, command) {
511
+ const alreadyApplied = this.downstreamCursorStore.hasApplied(this.agentHost, command.cursor);
512
+ let accepted = false;
513
+ if (alreadyApplied) {
514
+ accepted = true;
515
+ }
516
+ else {
517
+ accepted = await this.invokeStopTaskHandler(payload);
518
+ if (accepted) {
519
+ this.downstreamCursorStore.advance(this.agentHost, command.cursor);
520
+ }
521
+ }
522
+ if (!command.requestId) {
445
523
  return;
446
524
  }
447
525
  try {
448
- await this.sendAgentCommandAck({
449
- request_id: requestId,
450
- task_id: typeof data.task_id === 'string' ? data.task_id : undefined,
451
- event_type: eventType,
452
- accepted: typeof options.accepted === 'boolean' ? options.accepted : true,
526
+ await this.sendTaskStopAck({
527
+ task_id: command.taskId,
528
+ request_id: command.requestId,
529
+ accepted,
453
530
  });
454
531
  }
455
532
  catch (error) {
456
533
  const message = error instanceof Error ? error.message : String(error);
457
- console.warn(`[sdk] failed to ack inbound command ${requestId}: ${message}`);
534
+ console.warn(`[sdk] failed to send task_stop_ack for task ${command.taskId}: ${message}`);
458
535
  }
459
536
  }
460
- async handleStopTaskEvent(payload) {
461
- if (typeof payload?.type !== 'string' || payload.type !== 'stop_task') {
462
- return undefined;
463
- }
537
+ async invokeStopTaskHandler(payload) {
464
538
  const data = payload?.payload && typeof payload.payload === 'object'
465
539
  ? payload.payload
466
540
  : null;
@@ -473,35 +547,250 @@ export class ConductorClient {
473
547
  if (!taskId) {
474
548
  return false;
475
549
  }
476
- let accepted = false;
477
- if (this.onStopTask) {
478
- try {
479
- await this.onStopTask({
480
- taskId,
481
- requestId: requestId || undefined,
482
- reason: reason || undefined,
483
- });
484
- accepted = true;
485
- }
486
- catch (error) {
487
- const message = error instanceof Error ? error.message : String(error);
488
- console.warn(`[sdk] stop_task callback failed for task ${taskId}: ${message}`);
550
+ if (!this.onStopTask) {
551
+ return false;
552
+ }
553
+ try {
554
+ await this.onStopTask({
555
+ taskId,
556
+ requestId: requestId || undefined,
557
+ reason: reason || undefined,
558
+ });
559
+ return true;
560
+ }
561
+ catch (error) {
562
+ const message = error instanceof Error ? error.message : String(error);
563
+ console.warn(`[sdk] stop_task callback failed for task ${taskId}: ${message}`);
564
+ return false;
565
+ }
566
+ }
567
+ async persistAndCommitUpstreamEvent(entry) {
568
+ const stored = this.upstreamOutbox.upsert(entry);
569
+ if (stored.eventType === 'sdk_message' && this.hasEarlierPendingSdkMessage(stored.stableId)) {
570
+ this.scheduleDurableOutboxFlush(0);
571
+ return { delivered: false, pending: true };
572
+ }
573
+ try {
574
+ await this.commitDurableUpstreamEvent(stored);
575
+ this.upstreamOutbox.remove(stored.stableId);
576
+ return { delivered: true };
577
+ }
578
+ catch (error) {
579
+ if (!this.isRetryableUpstreamError(error)) {
580
+ this.upstreamOutbox.remove(stored.stableId);
581
+ throw error;
489
582
  }
583
+ const updated = this.upstreamOutbox.markRetry(stored.stableId, this.computeDurableOutboxRetryDelay(stored.attemptCount + 1));
584
+ const delay = updated?.nextAttemptAt
585
+ ? Math.max(Date.parse(updated.nextAttemptAt) - Date.now(), 0)
586
+ : this.computeDurableOutboxRetryDelay(stored.attemptCount + 1);
587
+ console.warn(`[sdk] queued ${stored.eventType} (${stored.stableId}) for durable retry after HTTP failure: ${error instanceof Error ? error.message : String(error)}`);
588
+ this.scheduleDurableOutboxFlush(delay);
589
+ return { delivered: false, pending: true };
490
590
  }
491
- if (requestId) {
492
- try {
493
- await this.sendTaskStopAck({
494
- task_id: taskId,
495
- request_id: requestId,
496
- accepted,
497
- });
591
+ }
592
+ async requestDurableOutboxFlush(force = false) {
593
+ if (this.closed) {
594
+ return;
595
+ }
596
+ if (this.durableOutboxFlushPromise) {
597
+ return this.durableOutboxFlushPromise;
598
+ }
599
+ this.durableOutboxFlushPromise = (async () => {
600
+ const readyEntries = force ? this.upstreamOutbox.load() : this.upstreamOutbox.listReady();
601
+ let sdkMessageBlocked = false;
602
+ for (const entry of readyEntries) {
603
+ if (this.closed) {
604
+ break;
605
+ }
606
+ if (sdkMessageBlocked && entry.eventType === 'sdk_message') {
607
+ continue;
608
+ }
609
+ try {
610
+ await this.commitDurableUpstreamEvent(entry);
611
+ this.upstreamOutbox.remove(entry.stableId);
612
+ }
613
+ catch (error) {
614
+ if (!this.isRetryableUpstreamError(error)) {
615
+ this.upstreamOutbox.remove(entry.stableId);
616
+ console.warn(`[sdk] dropping durable upstream event ${entry.eventType} (${entry.stableId}) after terminal HTTP failure: ${error instanceof Error ? error.message : String(error)}`);
617
+ continue;
618
+ }
619
+ const updated = this.upstreamOutbox.markRetry(entry.stableId, this.computeDurableOutboxRetryDelay(entry.attemptCount + 1));
620
+ console.warn(`[sdk] durable retry failed for ${entry.eventType} (${entry.stableId}): ${error instanceof Error ? error.message : String(error)}`);
621
+ if (entry.eventType === 'sdk_message') {
622
+ sdkMessageBlocked = true;
623
+ }
624
+ if (updated?.nextAttemptAt) {
625
+ this.scheduleDurableOutboxFlush(Math.max(Date.parse(updated.nextAttemptAt) - Date.now(), 0));
626
+ }
627
+ }
498
628
  }
499
- catch (error) {
500
- const message = error instanceof Error ? error.message : String(error);
501
- console.warn(`[sdk] failed to send task_stop_ack for task ${taskId}: ${message}`);
629
+ const nextDelay = this.upstreamOutbox.nextRetryDelay();
630
+ if (nextDelay !== null) {
631
+ this.scheduleDurableOutboxFlush(nextDelay);
502
632
  }
633
+ })().finally(() => {
634
+ this.durableOutboxFlushPromise = null;
635
+ });
636
+ return this.durableOutboxFlushPromise;
637
+ }
638
+ hasEarlierPendingSdkMessage(stableId) {
639
+ const entries = this.upstreamOutbox.load();
640
+ const currentIndex = entries.findIndex((candidate) => candidate.stableId === stableId);
641
+ if (currentIndex <= 0) {
642
+ return false;
503
643
  }
504
- return accepted;
644
+ return entries.slice(0, currentIndex).some((candidate) => candidate.eventType === 'sdk_message');
645
+ }
646
+ async commitDurableUpstreamEvent(entry) {
647
+ if (entry.eventType === 'sdk_message') {
648
+ await this.backendApi.commitSdkMessage({
649
+ agentHost: this.agentHost,
650
+ taskId: String(entry.payload.taskId || ''),
651
+ content: String(entry.payload.content || ''),
652
+ metadata: entry.payload.metadata && typeof entry.payload.metadata === 'object' && !Array.isArray(entry.payload.metadata)
653
+ ? entry.payload.metadata
654
+ : undefined,
655
+ messageId: entry.payload.messageId ? String(entry.payload.messageId) : null,
656
+ });
657
+ return;
658
+ }
659
+ if (entry.eventType === 'task_status_update') {
660
+ await this.backendApi.commitTaskStatusUpdate({
661
+ agentHost: this.agentHost,
662
+ taskId: String(entry.payload.taskId || ''),
663
+ status: String(entry.payload.status || ''),
664
+ summary: entry.payload.summary ? String(entry.payload.summary) : null,
665
+ statusEventId: entry.payload.statusEventId ? String(entry.payload.statusEventId) : null,
666
+ });
667
+ return;
668
+ }
669
+ if (entry.eventType === 'task_stop_ack') {
670
+ await this.backendApi.commitTaskStopAck({
671
+ agentHost: this.agentHost,
672
+ taskId: String(entry.payload.taskId || ''),
673
+ requestId: String(entry.payload.requestId || ''),
674
+ accepted: entry.payload.accepted !== false,
675
+ });
676
+ return;
677
+ }
678
+ await this.backendApi.commitAgentCommandAck({
679
+ agentHost: this.agentHost,
680
+ requestId: String(entry.payload.requestId || ''),
681
+ taskId: entry.payload.taskId ? String(entry.payload.taskId) : null,
682
+ accepted: entry.payload.accepted !== false,
683
+ commandEventType: entry.payload.commandEventType ? String(entry.payload.commandEventType) : null,
684
+ });
685
+ }
686
+ isRetryableUpstreamError(error) {
687
+ if (!(error instanceof BackendApiError)) {
688
+ return true;
689
+ }
690
+ if (error.statusCode === undefined || error.statusCode === null) {
691
+ return true;
692
+ }
693
+ if (error.statusCode >= 500) {
694
+ return true;
695
+ }
696
+ return error.statusCode === 408 || error.statusCode === 429;
697
+ }
698
+ computeDurableOutboxRetryDelay(attemptCount) {
699
+ const cappedExponent = Math.max(0, Math.min(attemptCount - 1, 5));
700
+ return 1_000 * 2 ** cappedExponent;
701
+ }
702
+ scheduleDurableOutboxFlush(delayMs) {
703
+ if (this.closed) {
704
+ return;
705
+ }
706
+ const normalizedDelay = Math.max(delayMs, 0);
707
+ const dueAt = Date.now() + normalizedDelay;
708
+ if (this.durableOutboxTimer &&
709
+ this.durableOutboxTimerDueAt !== null &&
710
+ this.durableOutboxTimerDueAt <= dueAt) {
711
+ return;
712
+ }
713
+ this.clearDurableOutboxTimer();
714
+ this.durableOutboxTimerDueAt = dueAt;
715
+ this.durableOutboxTimer = setTimeout(() => {
716
+ this.durableOutboxTimer = null;
717
+ this.durableOutboxTimerDueAt = null;
718
+ void this.requestDurableOutboxFlush(false);
719
+ }, normalizedDelay);
720
+ }
721
+ clearDurableOutboxTimer() {
722
+ if (this.durableOutboxTimer) {
723
+ clearTimeout(this.durableOutboxTimer);
724
+ this.durableOutboxTimer = null;
725
+ }
726
+ this.durableOutboxTimerDueAt = null;
727
+ }
728
+ async sendEnvelope(envelope) {
729
+ await this.wsClient.sendJson(envelope);
730
+ }
731
+ async maybeAckInboundCommand(payload, options = {}) {
732
+ const eventType = typeof payload?.type === 'string' ? payload.type : '';
733
+ if (eventType !== 'task_user_message' && eventType !== 'task_action') {
734
+ return;
735
+ }
736
+ const data = payload?.payload && typeof payload.payload === 'object'
737
+ ? payload.payload
738
+ : null;
739
+ if (!data) {
740
+ return;
741
+ }
742
+ const requestId = typeof data.request_id === 'string' ? data.request_id.trim() : '';
743
+ if (!requestId) {
744
+ return;
745
+ }
746
+ try {
747
+ await this.sendAgentCommandAck({
748
+ request_id: requestId,
749
+ task_id: typeof data.task_id === 'string' ? data.task_id : undefined,
750
+ event_type: eventType,
751
+ accepted: typeof options.accepted === 'boolean' ? options.accepted : true,
752
+ });
753
+ }
754
+ catch (error) {
755
+ const message = error instanceof Error ? error.message : String(error);
756
+ console.warn(`[sdk] failed to ack inbound command ${requestId}: ${message}`);
757
+ }
758
+ }
759
+ promoteTaskDeliveryScope(taskId) {
760
+ const normalizedTaskId = String(taskId || '').trim();
761
+ if (!normalizedTaskId) {
762
+ return;
763
+ }
764
+ this.setDeliveryScopeId(`task:${normalizedTaskId}`);
765
+ }
766
+ setDeliveryScopeId(scopeId) {
767
+ const nextScopeId = normalizeDeliveryScopeId(scopeId);
768
+ if (nextScopeId === this.deliveryScopeId) {
769
+ return;
770
+ }
771
+ const currentUpstream = this.upstreamOutbox;
772
+ const currentCursorStore = this.downstreamCursorStore;
773
+ const nextUpstream = DurableUpstreamOutboxStore.forProjectPath(this.projectPath, nextScopeId);
774
+ const nextCursorStore = DownstreamCursorStore.forProjectPath(this.projectPath, nextScopeId);
775
+ for (const entry of currentUpstream.load()) {
776
+ nextUpstream.upsert({
777
+ stableId: entry.stableId,
778
+ eventType: entry.eventType,
779
+ payload: entry.payload,
780
+ });
781
+ currentUpstream.remove(entry.stableId);
782
+ }
783
+ const currentCursor = currentCursorStore.get(this.agentHost);
784
+ if (currentCursor && !nextCursorStore.get(this.agentHost)) {
785
+ nextCursorStore.advance(this.agentHost, currentCursor);
786
+ }
787
+ this.upstreamOutbox = nextUpstream;
788
+ this.downstreamCursorStore = nextCursorStore;
789
+ this.deliveryScopeId = nextScopeId;
790
+ void this.requestDurableOutboxFlush(true);
791
+ }
792
+ shouldAutoFlushDurableOutbox() {
793
+ return this.deliveryScopeId.startsWith('task:') || this.deliveryScopeId.startsWith('session:');
505
794
  }
506
795
  resolveHostname() {
507
796
  const records = this.sessionStore.load();
@@ -580,3 +869,7 @@ function resolveAgentHost(env, explicit) {
580
869
  const host = env.HOSTNAME || env.COMPUTERNAME || 'unknown-host';
581
870
  return `conductor-fire-${host}-${pid}`;
582
871
  }
872
+ function normalizeDeliveryScopeId(scopeId) {
873
+ const normalized = String(scopeId || '').trim();
874
+ return normalized || 'default';
875
+ }
package/dist/index.d.ts CHANGED
@@ -6,3 +6,4 @@ export * from './message/index.js';
6
6
  export * from './client.js';
7
7
  export * from './context/index.js';
8
8
  export * from './limits/index.js';
9
+ export * from './outbox/index.js';
package/dist/index.js CHANGED
@@ -6,3 +6,4 @@ export * from './message/index.js';
6
6
  export * from './client.js';
7
7
  export * from './context/index.js';
8
8
  export * from './limits/index.js';
9
+ export * from './outbox/index.js';
@@ -0,0 +1,16 @@
1
+ export interface DownstreamCommandCursor {
2
+ createdAt: string;
3
+ requestId: string;
4
+ }
5
+ export declare function normalizeDownstreamCommandCursor(value: unknown): DownstreamCommandCursor | null;
6
+ export declare class DownstreamCursorStore {
7
+ private readonly filePath;
8
+ constructor(filePath: string);
9
+ static filePathForProjectPath(projectPath: string, scopeId: string): string;
10
+ static forProjectPath(projectPath: string, scopeId: string): DownstreamCursorStore;
11
+ get(agentHost: string): DownstreamCommandCursor | null;
12
+ hasApplied(agentHost: string, cursor: DownstreamCommandCursor): boolean;
13
+ advance(agentHost: string, cursor: DownstreamCommandCursor): DownstreamCommandCursor;
14
+ private loadState;
15
+ private saveState;
16
+ }
@@ -0,0 +1,96 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const STATE_DIR = path.join('.conductor', 'state');
4
+ const CURSOR_BASENAME = 'agent-downstream-cursor';
5
+ function normalizeCursorValue(value) {
6
+ if (typeof value !== 'string') {
7
+ return null;
8
+ }
9
+ const normalized = value.trim();
10
+ return normalized || null;
11
+ }
12
+ function compareCursor(left, right) {
13
+ const leftTime = Date.parse(left.createdAt);
14
+ const rightTime = Date.parse(right.createdAt);
15
+ const normalizedLeftTime = Number.isFinite(leftTime) ? leftTime : 0;
16
+ const normalizedRightTime = Number.isFinite(rightTime) ? rightTime : 0;
17
+ if (normalizedLeftTime !== normalizedRightTime) {
18
+ return normalizedLeftTime < normalizedRightTime ? -1 : 1;
19
+ }
20
+ return left.requestId.localeCompare(right.requestId);
21
+ }
22
+ const sanitizeScopeId = (scopeId) => scopeId
23
+ .trim()
24
+ .replace(/[^a-zA-Z0-9._-]+/g, '_')
25
+ .replace(/^_+|_+$/g, '')
26
+ || 'default';
27
+ export function normalizeDownstreamCommandCursor(value) {
28
+ if (!value || typeof value !== 'object') {
29
+ return null;
30
+ }
31
+ const source = value;
32
+ const createdAt = normalizeCursorValue(source.created_at) ?? normalizeCursorValue(source.createdAt);
33
+ const requestId = normalizeCursorValue(source.request_id) ?? normalizeCursorValue(source.requestId);
34
+ if (!createdAt || !requestId) {
35
+ return null;
36
+ }
37
+ return {
38
+ createdAt,
39
+ requestId,
40
+ };
41
+ }
42
+ export class DownstreamCursorStore {
43
+ filePath;
44
+ constructor(filePath) {
45
+ this.filePath = path.resolve(filePath);
46
+ }
47
+ static filePathForProjectPath(projectPath, scopeId) {
48
+ return path.join(projectPath, STATE_DIR, `${CURSOR_BASENAME}.${sanitizeScopeId(scopeId)}.json`);
49
+ }
50
+ static forProjectPath(projectPath, scopeId) {
51
+ return new DownstreamCursorStore(DownstreamCursorStore.filePathForProjectPath(projectPath, scopeId));
52
+ }
53
+ get(agentHost) {
54
+ const state = this.loadState();
55
+ return state.agents[agentHost] ?? null;
56
+ }
57
+ hasApplied(agentHost, cursor) {
58
+ const current = this.get(agentHost);
59
+ if (!current) {
60
+ return false;
61
+ }
62
+ return compareCursor(current, cursor) >= 0;
63
+ }
64
+ advance(agentHost, cursor) {
65
+ const state = this.loadState();
66
+ const current = state.agents[agentHost] ?? null;
67
+ if (current && compareCursor(current, cursor) >= 0) {
68
+ return current;
69
+ }
70
+ state.agents[agentHost] = cursor;
71
+ this.saveState(state);
72
+ return cursor;
73
+ }
74
+ loadState() {
75
+ if (!fs.existsSync(this.filePath)) {
76
+ return { agents: {} };
77
+ }
78
+ try {
79
+ const contents = fs.readFileSync(this.filePath, 'utf-8');
80
+ const parsed = JSON.parse(contents);
81
+ if (!parsed || typeof parsed !== 'object' || !parsed.agents || typeof parsed.agents !== 'object') {
82
+ return { agents: {} };
83
+ }
84
+ return { agents: parsed.agents };
85
+ }
86
+ catch {
87
+ return { agents: {} };
88
+ }
89
+ }
90
+ saveState(state) {
91
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
92
+ const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
93
+ fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), 'utf-8');
94
+ fs.renameSync(tempPath, this.filePath);
95
+ }
96
+ }
@@ -0,0 +1,2 @@
1
+ export * from './store.js';
2
+ export * from './downstream-cursor-store.js';
@@ -0,0 +1,2 @@
1
+ export * from './store.js';
2
+ export * from './downstream-cursor-store.js';
@@ -0,0 +1,31 @@
1
+ export type DurableUpstreamEventType = 'sdk_message' | 'task_status_update' | 'agent_command_ack' | 'task_stop_ack';
2
+ export interface DurableUpstreamEvent {
3
+ stableId: string;
4
+ eventType: DurableUpstreamEventType;
5
+ payload: Record<string, unknown>;
6
+ createdAt: string;
7
+ attemptCount: number;
8
+ lastAttemptAt?: string | null;
9
+ nextAttemptAt?: string | null;
10
+ }
11
+ export declare class DurableUpstreamOutboxStore {
12
+ private readonly filePath;
13
+ private readonly lockPath;
14
+ constructor(filePath: string);
15
+ static filePathForProjectPath(projectPath: string, scopeId: string): string;
16
+ static forProjectPath(projectPath: string, scopeId: string): DurableUpstreamOutboxStore;
17
+ load(): DurableUpstreamEvent[];
18
+ upsert(entry: {
19
+ stableId: string;
20
+ eventType: DurableUpstreamEventType;
21
+ payload: Record<string, unknown>;
22
+ }): DurableUpstreamEvent;
23
+ remove(stableId: string): void;
24
+ markRetry(stableId: string, delayMs: number): DurableUpstreamEvent | null;
25
+ listReady(nowMs?: number): DurableUpstreamEvent[];
26
+ nextRetryDelay(nowMs?: number): number | null;
27
+ private loadUnlocked;
28
+ private saveUnlocked;
29
+ private withLock;
30
+ private acquireLock;
31
+ }
@@ -0,0 +1,174 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const OUTBOX_DIR = path.join('.conductor', 'state');
4
+ const OUTBOX_BASENAME = 'agent-upstream-outbox';
5
+ const LOCK_TIMEOUT_MS = 10_000;
6
+ const LOCK_RETRY_MS = 50;
7
+ const sleepSync = (ms) => {
8
+ if (ms <= 0)
9
+ return;
10
+ try {
11
+ const buffer = new SharedArrayBuffer(4);
12
+ const arr = new Int32Array(buffer);
13
+ Atomics.wait(arr, 0, 0, ms);
14
+ }
15
+ catch {
16
+ const startedAt = Date.now();
17
+ while (Date.now() - startedAt < ms) {
18
+ // busy wait fallback
19
+ }
20
+ }
21
+ };
22
+ const sanitizeScopeId = (scopeId) => scopeId
23
+ .trim()
24
+ .replace(/[^a-zA-Z0-9._-]+/g, '_')
25
+ .replace(/^_+|_+$/g, '')
26
+ || 'default';
27
+ export class DurableUpstreamOutboxStore {
28
+ filePath;
29
+ lockPath;
30
+ constructor(filePath) {
31
+ this.filePath = path.resolve(filePath);
32
+ this.lockPath = `${this.filePath}.lock`;
33
+ }
34
+ static filePathForProjectPath(projectPath, scopeId) {
35
+ return path.join(projectPath, OUTBOX_DIR, `${OUTBOX_BASENAME}.${sanitizeScopeId(scopeId)}.json`);
36
+ }
37
+ static forProjectPath(projectPath, scopeId) {
38
+ return new DurableUpstreamOutboxStore(DurableUpstreamOutboxStore.filePathForProjectPath(projectPath, scopeId));
39
+ }
40
+ load() {
41
+ return this.withLock(() => this.loadUnlocked());
42
+ }
43
+ upsert(entry) {
44
+ return this.withLock(() => {
45
+ const entries = this.loadUnlocked();
46
+ const existing = entries.find((candidate) => candidate.stableId === entry.stableId);
47
+ const record = {
48
+ stableId: entry.stableId,
49
+ eventType: entry.eventType,
50
+ payload: entry.payload,
51
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
52
+ attemptCount: existing?.attemptCount ?? 0,
53
+ lastAttemptAt: existing?.lastAttemptAt ?? null,
54
+ nextAttemptAt: existing?.nextAttemptAt ?? new Date().toISOString(),
55
+ };
56
+ const nextEntries = existing
57
+ ? entries.map((candidate) => (candidate.stableId === entry.stableId ? record : candidate))
58
+ : [...entries, record];
59
+ this.saveUnlocked(nextEntries);
60
+ return record;
61
+ });
62
+ }
63
+ remove(stableId) {
64
+ this.withLock(() => {
65
+ const entries = this.loadUnlocked().filter((candidate) => candidate.stableId !== stableId);
66
+ this.saveUnlocked(entries);
67
+ });
68
+ }
69
+ markRetry(stableId, delayMs) {
70
+ return this.withLock(() => {
71
+ const entries = this.loadUnlocked();
72
+ const index = entries.findIndex((candidate) => candidate.stableId === stableId);
73
+ if (index < 0) {
74
+ return null;
75
+ }
76
+ const now = Date.now();
77
+ const updated = {
78
+ ...entries[index],
79
+ attemptCount: entries[index].attemptCount + 1,
80
+ lastAttemptAt: new Date(now).toISOString(),
81
+ nextAttemptAt: new Date(now + Math.max(delayMs, 0)).toISOString(),
82
+ };
83
+ entries[index] = updated;
84
+ this.saveUnlocked(entries);
85
+ return updated;
86
+ });
87
+ }
88
+ listReady(nowMs = Date.now()) {
89
+ return this.load()
90
+ .filter((entry) => {
91
+ if (!entry.nextAttemptAt)
92
+ return true;
93
+ const nextAttemptMs = Date.parse(entry.nextAttemptAt);
94
+ return !Number.isFinite(nextAttemptMs) || nextAttemptMs <= nowMs;
95
+ });
96
+ }
97
+ nextRetryDelay(nowMs = Date.now()) {
98
+ const entries = this.load();
99
+ let minDelay = null;
100
+ for (const entry of entries) {
101
+ if (!entry.nextAttemptAt) {
102
+ return 0;
103
+ }
104
+ const nextAttemptMs = Date.parse(entry.nextAttemptAt);
105
+ if (!Number.isFinite(nextAttemptMs)) {
106
+ return 0;
107
+ }
108
+ const delay = Math.max(nextAttemptMs - nowMs, 0);
109
+ minDelay = minDelay === null ? delay : Math.min(minDelay, delay);
110
+ }
111
+ return minDelay;
112
+ }
113
+ loadUnlocked() {
114
+ if (!fs.existsSync(this.filePath)) {
115
+ return [];
116
+ }
117
+ try {
118
+ const contents = fs.readFileSync(this.filePath, 'utf-8');
119
+ const parsed = JSON.parse(contents);
120
+ return Array.isArray(parsed.entries) ? parsed.entries : [];
121
+ }
122
+ catch {
123
+ return [];
124
+ }
125
+ }
126
+ saveUnlocked(entries) {
127
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
128
+ const payload = { entries };
129
+ const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
130
+ fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2), 'utf-8');
131
+ fs.renameSync(tempPath, this.filePath);
132
+ }
133
+ withLock(fn) {
134
+ const release = this.acquireLock();
135
+ try {
136
+ return fn();
137
+ }
138
+ finally {
139
+ release();
140
+ }
141
+ }
142
+ acquireLock() {
143
+ const startedAt = Date.now();
144
+ fs.mkdirSync(path.dirname(this.lockPath), { recursive: true });
145
+ while (true) {
146
+ try {
147
+ const fd = fs.openSync(this.lockPath, 'wx');
148
+ return () => {
149
+ try {
150
+ fs.closeSync(fd);
151
+ }
152
+ catch {
153
+ // ignore
154
+ }
155
+ try {
156
+ fs.unlinkSync(this.lockPath);
157
+ }
158
+ catch {
159
+ // ignore
160
+ }
161
+ };
162
+ }
163
+ catch (error) {
164
+ if (error?.code !== 'EEXIST') {
165
+ throw error;
166
+ }
167
+ if (Date.now() - startedAt > LOCK_TIMEOUT_MS) {
168
+ throw new Error(`Timed out waiting for outbox lock: ${this.lockPath}`);
169
+ }
170
+ sleepSync(LOCK_RETRY_MS);
171
+ }
172
+ }
173
+ }
174
+ }
@@ -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.17",
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": "c2654e1"
31
31
  }