@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.
- package/dist/backend/client.d.ts +57 -0
- package/dist/backend/client.js +102 -0
- package/dist/client.d.ts +32 -2
- package/dist/client.js +360 -67
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/outbox/downstream-cursor-store.d.ts +16 -0
- package/dist/outbox/downstream-cursor-store.js +96 -0
- package/dist/outbox/index.d.ts +2 -0
- package/dist/outbox/index.js +2 -0
- package/dist/outbox/store.d.ts +31 -0
- package/dist/outbox/store.js +174 -0
- package/dist/ws/client.d.ts +3 -0
- package/dist/ws/client.js +16 -0
- package/package.json +2 -2
package/dist/backend/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/backend/client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
149
|
-
|
|
174
|
+
const messageId = safeRandomUuid();
|
|
175
|
+
const result = await this.persistAndCommitUpstreamEvent({
|
|
176
|
+
stableId: messageId,
|
|
177
|
+
eventType: 'sdk_message',
|
|
150
178
|
payload: {
|
|
151
|
-
|
|
179
|
+
taskId,
|
|
152
180
|
content,
|
|
153
181
|
metadata,
|
|
182
|
+
messageId,
|
|
154
183
|
},
|
|
155
184
|
});
|
|
156
|
-
return {
|
|
185
|
+
return { ...result, message_id: messageId };
|
|
157
186
|
}
|
|
158
187
|
async sendTaskStatus(taskId, payload) {
|
|
159
|
-
|
|
160
|
-
|
|
188
|
+
const statusEventId = safeRandomUuid();
|
|
189
|
+
const result = await this.persistAndCommitUpstreamEvent({
|
|
190
|
+
stableId: statusEventId,
|
|
191
|
+
eventType: 'task_status_update',
|
|
161
192
|
payload: {
|
|
162
|
-
|
|
193
|
+
taskId,
|
|
163
194
|
status: payload?.status,
|
|
164
195
|
summary: payload?.summary,
|
|
196
|
+
statusEventId,
|
|
165
197
|
},
|
|
166
198
|
});
|
|
167
|
-
return {
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
accepted:
|
|
263
|
+
requestId,
|
|
264
|
+
taskId: envelopePayload.task_id,
|
|
265
|
+
commandEventType: envelopePayload.event_type,
|
|
266
|
+
accepted: envelopePayload.accepted,
|
|
220
267
|
},
|
|
221
268
|
});
|
|
222
|
-
return {
|
|
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.
|
|
234
|
-
|
|
280
|
+
const result = await this.persistAndCommitUpstreamEvent({
|
|
281
|
+
stableId: requestId,
|
|
282
|
+
eventType: 'task_stop_ack',
|
|
235
283
|
payload: {
|
|
236
|
-
|
|
237
|
-
|
|
284
|
+
taskId,
|
|
285
|
+
requestId,
|
|
238
286
|
accepted: payload.accepted !== false,
|
|
239
287
|
},
|
|
240
288
|
});
|
|
241
|
-
return {
|
|
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
|
|
422
|
-
|
|
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:
|
|
425
|
-
? stopCommandAccepted
|
|
426
|
-
: undefined,
|
|
483
|
+
accepted: true,
|
|
427
484
|
});
|
|
428
485
|
};
|
|
429
|
-
|
|
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
|
-
|
|
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.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
534
|
+
console.warn(`[sdk] failed to send task_stop_ack for task ${command.taskId}: ${message}`);
|
|
458
535
|
}
|
|
459
536
|
}
|
|
460
|
-
async
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
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
package/dist/index.js
CHANGED
|
@@ -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,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/dist/ws/client.d.ts
CHANGED
|
@@ -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.
|
|
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": "
|
|
30
|
+
"gitCommitId": "c2654e1"
|
|
31
31
|
}
|