@love-moon/conductor-sdk 0.2.16 → 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,11 +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;
55
- private pendingOutbound;
56
- private readonly ACK_TIMEOUT_MS;
65
+ private durableOutboxFlushPromise;
66
+ private durableOutboxTimer;
67
+ private durableOutboxTimerDueAt;
57
68
  constructor(init: ConductorClientInit);
58
69
  static connect(options?: ConductorClientConnectOptions): Promise<ConductorClient>;
59
70
  close(): Promise<void>;
@@ -65,6 +76,10 @@ export declare class ConductorClient {
65
76
  active_tasks?: string[];
66
77
  source?: string;
67
78
  metadata?: Record<string, any>;
79
+ last_applied_cursor?: {
80
+ created_at: string;
81
+ request_id: string;
82
+ };
68
83
  }): Promise<Record<string, any>>;
69
84
  sendAgentCommandAck(payload: {
70
85
  request_id: string;
@@ -88,28 +103,22 @@ export declare class ConductorClient {
88
103
  matchProjectByPath(payload?: Record<string, any>): Promise<Record<string, any>>;
89
104
  bindProjectPath(projectId: string, payload?: Record<string, any>): Promise<Record<string, any>>;
90
105
  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;
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;
110
117
  private sendEnvelope;
111
118
  private maybeAckInboundCommand;
112
- private handleStopTaskEvent;
119
+ private promoteTaskDeliveryScope;
120
+ private setDeliveryScopeId;
121
+ private shouldAutoFlushDurableOutbox;
113
122
  private resolveHostname;
114
123
  private waitForTaskCreation;
115
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,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) {
@@ -132,6 +151,7 @@ export class ConductorClient {
132
151
  throw error;
133
152
  }
134
153
  await this.waitForTaskCreation(projectId, taskId);
154
+ this.promoteTaskDeliveryScope(taskId);
135
155
  const projectPath = typeof payload.project_path === 'string' && payload.project_path
136
156
  ? payload.project_path
137
157
  : this.projectPath;
@@ -152,25 +172,31 @@ export class ConductorClient {
152
172
  }
153
173
  async sendMessage(taskId, content, metadata) {
154
174
  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 };
175
+ const result = await this.persistAndCommitUpstreamEvent({
176
+ stableId: messageId,
177
+ eventType: 'sdk_message',
178
+ payload: {
179
+ taskId,
180
+ content,
181
+ metadata,
182
+ messageId,
183
+ },
184
+ });
185
+ return { ...result, message_id: messageId };
163
186
  }
164
187
  async sendTaskStatus(taskId, payload) {
165
- await this.sendEnvelope({
166
- type: 'task_status_update',
188
+ const statusEventId = safeRandomUuid();
189
+ const result = await this.persistAndCommitUpstreamEvent({
190
+ stableId: statusEventId,
191
+ eventType: 'task_status_update',
167
192
  payload: {
168
- task_id: taskId,
193
+ taskId,
169
194
  status: payload?.status,
170
195
  summary: payload?.summary,
196
+ statusEventId,
171
197
  },
172
198
  });
173
- return { delivered: true };
199
+ return { ...result, status_event_id: statusEventId };
174
200
  }
175
201
  async sendRuntimeStatus(taskId, payload) {
176
202
  await this.sendEnvelope({
@@ -199,6 +225,13 @@ export class ConductorClient {
199
225
  return { delivered: true };
200
226
  }
201
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);
202
235
  await this.sendEnvelope({
203
236
  type: 'agent_resume',
204
237
  payload: {
@@ -207,6 +240,7 @@ export class ConductorClient {
207
240
  : [],
208
241
  source: payload.source,
209
242
  metadata: payload.metadata,
243
+ last_applied_cursor: lastAppliedCursor,
210
244
  },
211
245
  });
212
246
  return { delivered: true };
@@ -222,8 +256,17 @@ export class ConductorClient {
222
256
  event_type: payload.event_type,
223
257
  accepted: payload.accepted !== false,
224
258
  };
225
- await this.sendConfirmable('agent_command_ack', envelopePayload, requestId);
226
- return { delivered: true, request_id: requestId };
259
+ const result = await this.persistAndCommitUpstreamEvent({
260
+ stableId: requestId,
261
+ eventType: 'agent_command_ack',
262
+ payload: {
263
+ requestId,
264
+ taskId: envelopePayload.task_id,
265
+ commandEventType: envelopePayload.event_type,
266
+ accepted: envelopePayload.accepted,
267
+ },
268
+ });
269
+ return { ...result, request_id: requestId };
227
270
  }
228
271
  async sendTaskStopAck(payload) {
229
272
  const taskId = String(payload.task_id || '').trim();
@@ -234,15 +277,16 @@ export class ConductorClient {
234
277
  if (!requestId) {
235
278
  throw new Error('request_id is required');
236
279
  }
237
- await this.sendEnvelope({
238
- type: 'task_stop_ack',
280
+ const result = await this.persistAndCommitUpstreamEvent({
281
+ stableId: requestId,
282
+ eventType: 'task_stop_ack',
239
283
  payload: {
240
- task_id: taskId,
241
- request_id: requestId,
284
+ taskId,
285
+ requestId,
242
286
  accepted: payload.accepted !== false,
243
287
  },
244
288
  });
245
- return { delivered: true };
289
+ return { ...result, request_id: requestId, task_id: taskId };
246
290
  }
247
291
  async receiveMessages(taskId, limit = 20) {
248
292
  const messages = await this.sessions.popMessages(taskId, limit);
@@ -330,6 +374,7 @@ export class ConductorClient {
330
374
  if (!normalizedTaskId) {
331
375
  throw new Error('task_id is required');
332
376
  }
377
+ this.promoteTaskDeliveryScope(normalizedTaskId);
333
378
  const existing = this.sessionStore.findByTaskId(normalizedTaskId);
334
379
  const inMemorySession = await this.sessions.getSession(normalizedTaskId);
335
380
  const projectId = existing?.projectId ||
@@ -422,142 +467,270 @@ export class ConductorClient {
422
467
  };
423
468
  }
424
469
  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);
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
+ }
429
482
  await this.maybeAckInboundCommand(payload, {
430
- accepted: typeof stopCommandAccepted === 'boolean'
431
- ? stopCommandAccepted
432
- : undefined,
483
+ accepted: true,
433
484
  });
434
485
  };
435
- /**
436
- * Handle acknowledgment messages from backend for confirmable outbound messages
437
- */
438
- handleAcknowledgment(payload) {
486
+ extractDownstreamCommandContext(payload) {
439
487
  const eventType = typeof payload?.type === 'string' ? payload.type : '';
488
+ if (eventType !== 'task_user_message' && eventType !== 'task_action' && eventType !== 'stop_task') {
489
+ return null;
490
+ }
440
491
  const data = payload?.payload && typeof payload.payload === 'object'
441
492
  ? payload.payload
442
493
  : 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
- }
494
+ if (!data) {
495
+ return null;
496
+ }
497
+ const taskId = typeof data.task_id === 'string' ? data.task_id.trim() : '';
498
+ const requestId = typeof data.request_id === 'string' ? data.request_id.trim() : '';
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);
454
520
  }
455
521
  }
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
- }
522
+ if (!command.requestId) {
523
+ return;
524
+ }
525
+ try {
526
+ await this.sendTaskStopAck({
527
+ task_id: command.taskId,
528
+ request_id: command.requestId,
529
+ accepted,
530
+ });
531
+ }
532
+ catch (error) {
533
+ const message = error instanceof Error ? error.message : String(error);
534
+ console.warn(`[sdk] failed to send task_stop_ack for task ${command.taskId}: ${message}`);
535
+ }
536
+ }
537
+ async invokeStopTaskHandler(payload) {
538
+ const data = payload?.payload && typeof payload.payload === 'object'
539
+ ? payload.payload
540
+ : null;
541
+ if (!data) {
542
+ return false;
543
+ }
544
+ const taskId = typeof data.task_id === 'string' ? data.task_id.trim() : '';
545
+ const requestId = typeof data.request_id === 'string' ? data.request_id.trim() : '';
546
+ const reason = typeof data.reason === 'string' ? data.reason.trim() : '';
547
+ if (!taskId) {
548
+ return false;
549
+ }
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;
465
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 };
466
590
  }
467
591
  }
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;
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;
482
618
  }
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;
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));
487
626
  }
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
627
  }
514
- }, this.ACK_TIMEOUT_MS);
628
+ }
629
+ const nextDelay = this.upstreamOutbox.nextRetryDelay();
630
+ if (nextDelay !== null) {
631
+ this.scheduleDurableOutboxFlush(nextDelay);
632
+ }
633
+ })().finally(() => {
634
+ this.durableOutboxFlushPromise = null;
515
635
  });
636
+ return this.durableOutboxFlushPromise;
516
637
  }
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,
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;
643
+ }
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,
529
656
  });
530
- pending.lastSentAt = Date.now();
531
- pending.retryCount++;
657
+ return;
532
658
  }
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)}`);
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;
536
668
  }
537
- }
538
- /**
539
- * Flush all pending confirmable messages.
540
- * Called after websocket reconnection.
541
- */
542
- async flushPendingOutbound() {
543
- if (this.pendingOutbound.size === 0)
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
+ });
544
676
  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
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,
553
684
  });
554
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
+ }
555
728
  async sendEnvelope(envelope) {
556
729
  await this.wsClient.sendJson(envelope);
557
730
  }
558
731
  async maybeAckInboundCommand(payload, options = {}) {
559
732
  const eventType = typeof payload?.type === 'string' ? payload.type : '';
560
- if (eventType !== 'task_user_message' && eventType !== 'task_action' && eventType !== 'stop_task') {
733
+ if (eventType !== 'task_user_message' && eventType !== 'task_action') {
561
734
  return;
562
735
  }
563
736
  const data = payload?.payload && typeof payload.payload === 'object'
@@ -583,51 +756,41 @@ export class ConductorClient {
583
756
  console.warn(`[sdk] failed to ack inbound command ${requestId}: ${message}`);
584
757
  }
585
758
  }
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;
759
+ promoteTaskDeliveryScope(taskId) {
760
+ const normalizedTaskId = String(taskId || '').trim();
761
+ if (!normalizedTaskId) {
762
+ return;
595
763
  }
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;
764
+ this.setDeliveryScopeId(`task:${normalizedTaskId}`);
765
+ }
766
+ setDeliveryScopeId(scopeId) {
767
+ const nextScopeId = normalizeDeliveryScopeId(scopeId);
768
+ if (nextScopeId === this.deliveryScopeId) {
769
+ return;
601
770
  }
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
- }
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);
616
782
  }
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
- }
783
+ const currentCursor = currentCursorStore.get(this.agentHost);
784
+ if (currentCursor && !nextCursorStore.get(this.agentHost)) {
785
+ nextCursorStore.advance(this.agentHost, currentCursor);
629
786
  }
630
- return accepted;
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:');
631
794
  }
632
795
  resolveHostname() {
633
796
  const records = this.sessionStore.load();
@@ -706,3 +869,7 @@ function resolveAgentHost(env, explicit) {
706
869
  const host = env.HOSTNAME || env.COMPUTERNAME || 'unknown-host';
707
870
  return `conductor-fire-${host}-${pid}`;
708
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@love-moon/conductor-sdk",
3
- "version": "0.2.16",
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": "13b4d6c"
30
+ "gitCommitId": "c2654e1"
31
31
  }