assistme 0.7.0 → 0.8.2
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/{chunk-QHMIXIWO.js → chunk-A2NR7LCQ.js} +69 -9
- package/dist/chunk-IKYXC4RJ.js +3383 -0
- package/dist/index.js +1324 -7269
- package/dist/{job-runner-YM2NBIL3.js → job-runner-PECVS424.js} +1 -1
- package/dist/workers/entry.d.ts +1 -0
- package/dist/workers/entry.js +3280 -0
- package/package.json +3 -3
- package/src/agent/self-analyzer.ts +1 -1
- package/src/commands/monitor.ts +4 -6
- package/src/commands/start.ts +24 -17
- package/src/db/analysis-data.ts +4 -1
- package/src/db/session-log.ts +3 -2
- package/src/orchestrator.ts +492 -0
- package/src/utils/logger.ts +60 -10
- package/src/workers/base-handler.ts +94 -0
- package/src/workers/conversation.ts +74 -0
- package/src/workers/entry.ts +37 -0
- package/src/workers/index.ts +9 -0
- package/src/workers/manager.ts +506 -0
- package/src/workers/types.ts +61 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkerManager — runs in the main process (orchestrator).
|
|
3
|
+
*
|
|
4
|
+
* Manages the lifecycle of worker child processes:
|
|
5
|
+
* - Spawns workers via child_process.fork()
|
|
6
|
+
* - Routes tasks to the appropriate worker (non-blocking)
|
|
7
|
+
* - Monitors worker health and restarts on crash
|
|
8
|
+
* - Tracks busy count via reference counting
|
|
9
|
+
* - Handles graceful shutdown
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { fork, type ChildProcess } from "child_process";
|
|
13
|
+
import { existsSync } from "fs";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
import { dirname, join } from "path";
|
|
16
|
+
import { randomUUID } from "crypto";
|
|
17
|
+
import type {
|
|
18
|
+
WorkerType,
|
|
19
|
+
WorkerConfig,
|
|
20
|
+
WorkerInfo,
|
|
21
|
+
WorkerStatus,
|
|
22
|
+
OrchestratorMessage,
|
|
23
|
+
WorkerMessage,
|
|
24
|
+
} from "./types.js";
|
|
25
|
+
import type { ConversationMessage } from "../db/types.js";
|
|
26
|
+
import { log, setLogConversationId } from "../utils/logger.js";
|
|
27
|
+
|
|
28
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
29
|
+
const __dirname = dirname(__filename);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the worker entry point path.
|
|
33
|
+
* tsup may bundle this into dist/index.js or dist/chunk-*.js,
|
|
34
|
+
* so we check both sibling and nested paths.
|
|
35
|
+
*/
|
|
36
|
+
function resolveWorkerEntry(): string {
|
|
37
|
+
const sibling = join(__dirname, "entry.js");
|
|
38
|
+
if (existsSync(sibling)) return sibling;
|
|
39
|
+
|
|
40
|
+
const nested = join(__dirname, "workers", "entry.js");
|
|
41
|
+
if (existsSync(nested)) return nested;
|
|
42
|
+
|
|
43
|
+
return nested;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const WORKER_ENTRY = resolveWorkerEntry();
|
|
47
|
+
|
|
48
|
+
/** Auto-terminate idle conversation workers after this duration. */
|
|
49
|
+
const IDLE_TIMEOUT_MS = 5 * 60_000; // 5 minutes
|
|
50
|
+
|
|
51
|
+
interface ManagedWorker {
|
|
52
|
+
id: string;
|
|
53
|
+
type: WorkerType;
|
|
54
|
+
process: ChildProcess;
|
|
55
|
+
status: WorkerStatus;
|
|
56
|
+
config: WorkerConfig;
|
|
57
|
+
conversationId?: string;
|
|
58
|
+
startedAt: Date;
|
|
59
|
+
lastActivityAt: Date;
|
|
60
|
+
tasksProcessed: number;
|
|
61
|
+
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
62
|
+
/** Resolves when the worker reports "ready" after init. Rejects if worker exits first. */
|
|
63
|
+
readyPromise: Promise<void>;
|
|
64
|
+
readyResolve: () => void;
|
|
65
|
+
readyReject: (err: Error) => void;
|
|
66
|
+
/** Task IDs currently being processed by this worker. */
|
|
67
|
+
pendingTaskIds: Set<string>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type BusyChangeCallback = (busyCount: number) => void;
|
|
71
|
+
|
|
72
|
+
/** Pending completion tracker for a specific task. */
|
|
73
|
+
interface TaskCompletionTracker {
|
|
74
|
+
resolve: () => void;
|
|
75
|
+
reject: (err: Error) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class WorkerManager {
|
|
79
|
+
private workers = new Map<string, ManagedWorker>();
|
|
80
|
+
/** Maps conversation ID → worker ID for fast lookup. */
|
|
81
|
+
private conversationWorkers = new Map<string, string>();
|
|
82
|
+
private userId: string;
|
|
83
|
+
private sessionId: string;
|
|
84
|
+
private running = true;
|
|
85
|
+
|
|
86
|
+
/** Number of conversation workers currently processing a task. */
|
|
87
|
+
private busyCount = 0;
|
|
88
|
+
/** Called whenever busyCount changes. */
|
|
89
|
+
private onBusyChange: BusyChangeCallback | null = null;
|
|
90
|
+
|
|
91
|
+
/** Maps task ID → completion promise, for callers that need to await a specific task. */
|
|
92
|
+
private taskCompletions = new Map<string, TaskCompletionTracker>();
|
|
93
|
+
|
|
94
|
+
constructor(userId: string, sessionId: string) {
|
|
95
|
+
this.userId = userId;
|
|
96
|
+
this.sessionId = sessionId;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Register a callback for busy count changes.
|
|
101
|
+
* Called with the new count whenever a worker starts or finishes a task.
|
|
102
|
+
*/
|
|
103
|
+
setBusyChangeCallback(cb: BusyChangeCallback): void {
|
|
104
|
+
this.onBusyChange = cb;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Public API ──────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Dispatch a task to a conversation worker. Non-blocking — returns
|
|
111
|
+
* immediately after sending the task. The orchestrator can continue
|
|
112
|
+
* polling for more tasks.
|
|
113
|
+
*/
|
|
114
|
+
async dispatchTask(task: ConversationMessage): Promise<void> {
|
|
115
|
+
const conversationId = task.conversation_id;
|
|
116
|
+
let worker = this.getConversationWorker(conversationId);
|
|
117
|
+
|
|
118
|
+
if (!worker || worker.status === "stopped" || worker.status === "error") {
|
|
119
|
+
worker = await this.spawnWorker("conversation", { conversationId });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Wait for ready, but not for task completion
|
|
123
|
+
await worker.readyPromise;
|
|
124
|
+
|
|
125
|
+
// If this worker is still busy with a previous task, log a warning.
|
|
126
|
+
// The worker will queue it internally (single-threaded).
|
|
127
|
+
if (worker.status === "busy") {
|
|
128
|
+
log.warn(`[worker:${worker.id}] Still busy — task ${task.id.slice(0, 8)} will queue`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.sendToWorker(worker, { type: "process_task", task });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Dispatch a task and wait for completion.
|
|
136
|
+
* Use this when you need to know when the task finishes
|
|
137
|
+
* (e.g. job runs that need completion tracking).
|
|
138
|
+
*/
|
|
139
|
+
async dispatchAndWait(task: ConversationMessage): Promise<void> {
|
|
140
|
+
const completionPromise = new Promise<void>((resolve, reject) => {
|
|
141
|
+
this.taskCompletions.set(task.id, { resolve, reject });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await this.dispatchTask(task);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// Clean up tracker if dispatch fails
|
|
148
|
+
this.taskCompletions.delete(task.id);
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await completionPromise;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Graceful shutdown: stop all workers.
|
|
157
|
+
*/
|
|
158
|
+
async shutdown(timeoutMs = 5_000): Promise<void> {
|
|
159
|
+
this.running = false;
|
|
160
|
+
|
|
161
|
+
const shutdownPromises = Array.from(this.workers.values()).map((worker) =>
|
|
162
|
+
this.stopWorker(worker, timeoutMs)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
await Promise.allSettled(shutdownPromises);
|
|
166
|
+
|
|
167
|
+
// Reject any remaining task completions
|
|
168
|
+
const shutdownError = new Error("WorkerManager shutting down");
|
|
169
|
+
for (const tracker of this.taskCompletions.values()) {
|
|
170
|
+
tracker.reject(shutdownError);
|
|
171
|
+
}
|
|
172
|
+
this.taskCompletions.clear();
|
|
173
|
+
|
|
174
|
+
this.workers.clear();
|
|
175
|
+
this.conversationWorkers.clear();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get info about all active workers.
|
|
180
|
+
*/
|
|
181
|
+
getWorkerInfos(): WorkerInfo[] {
|
|
182
|
+
return Array.from(this.workers.values()).map((w) => ({
|
|
183
|
+
id: w.id,
|
|
184
|
+
type: w.type,
|
|
185
|
+
status: w.status,
|
|
186
|
+
pid: w.process.pid,
|
|
187
|
+
conversationId: w.conversationId,
|
|
188
|
+
startedAt: w.startedAt,
|
|
189
|
+
lastActivityAt: w.lastActivityAt,
|
|
190
|
+
tasksProcessed: w.tasksProcessed,
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Number of conversation workers currently processing a task.
|
|
196
|
+
*/
|
|
197
|
+
getBusyCount(): number {
|
|
198
|
+
return this.busyCount;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Worker Lifecycle ────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
private async spawnWorker(
|
|
204
|
+
type: WorkerType,
|
|
205
|
+
opts?: { conversationId?: string }
|
|
206
|
+
): Promise<ManagedWorker> {
|
|
207
|
+
const id = randomUUID().slice(0, 8);
|
|
208
|
+
const config: WorkerConfig = {
|
|
209
|
+
type,
|
|
210
|
+
userId: this.userId,
|
|
211
|
+
sessionId: this.sessionId,
|
|
212
|
+
conversationId: opts?.conversationId,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
let readyResolve!: () => void;
|
|
216
|
+
let readyReject!: (err: Error) => void;
|
|
217
|
+
const readyPromise = new Promise<void>((resolve, reject) => {
|
|
218
|
+
readyResolve = resolve;
|
|
219
|
+
readyReject = reject;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const child = fork(WORKER_ENTRY, ["--type", type], {
|
|
223
|
+
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
224
|
+
env: { ...process.env },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const worker: ManagedWorker = {
|
|
228
|
+
id,
|
|
229
|
+
type,
|
|
230
|
+
process: child,
|
|
231
|
+
status: "starting",
|
|
232
|
+
config,
|
|
233
|
+
conversationId: opts?.conversationId,
|
|
234
|
+
startedAt: new Date(),
|
|
235
|
+
lastActivityAt: new Date(),
|
|
236
|
+
tasksProcessed: 0,
|
|
237
|
+
idleTimer: null,
|
|
238
|
+
readyPromise,
|
|
239
|
+
readyResolve,
|
|
240
|
+
readyReject,
|
|
241
|
+
pendingTaskIds: new Set(),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
this.workers.set(id, worker);
|
|
245
|
+
|
|
246
|
+
if (opts?.conversationId) {
|
|
247
|
+
this.conversationWorkers.set(opts.conversationId, id);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Handle IPC messages from worker
|
|
251
|
+
child.on("message", (raw: unknown) => {
|
|
252
|
+
this.handleWorkerMessage(worker, raw as WorkerMessage);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Handle worker exit
|
|
256
|
+
child.on("exit", (code, signal) => {
|
|
257
|
+
this.handleWorkerExit(worker, code, signal);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Forward stderr for debugging
|
|
261
|
+
child.stderr?.on("data", (data: Buffer) => {
|
|
262
|
+
log.debug(`[worker:${id}] ${data.toString().trim()}`);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Send init message
|
|
266
|
+
const initMsg: OrchestratorMessage = { type: "init", config };
|
|
267
|
+
child.send(initMsg);
|
|
268
|
+
|
|
269
|
+
log.info(`Worker spawned: ${type} (${id}, pid=${child.pid})`);
|
|
270
|
+
return worker;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private handleWorkerMessage(worker: ManagedWorker, message: WorkerMessage): void {
|
|
274
|
+
worker.lastActivityAt = new Date();
|
|
275
|
+
|
|
276
|
+
switch (message.type) {
|
|
277
|
+
case "ready":
|
|
278
|
+
worker.status = "ready";
|
|
279
|
+
worker.readyResolve();
|
|
280
|
+
break;
|
|
281
|
+
|
|
282
|
+
case "task_completed": {
|
|
283
|
+
worker.tasksProcessed++;
|
|
284
|
+
worker.pendingTaskIds.delete(message.taskId);
|
|
285
|
+
log.info(`[worker:${worker.id}] Task completed: ${message.taskId.slice(0, 8)}`);
|
|
286
|
+
this.workerBecameIdle(worker);
|
|
287
|
+
const completedTracker = this.taskCompletions.get(message.taskId);
|
|
288
|
+
if (completedTracker) {
|
|
289
|
+
this.taskCompletions.delete(message.taskId);
|
|
290
|
+
completedTracker.resolve();
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
case "task_failed": {
|
|
296
|
+
worker.tasksProcessed++;
|
|
297
|
+
worker.pendingTaskIds.delete(message.taskId);
|
|
298
|
+
log.error(`[worker:${worker.id}] Task failed: ${message.error}`);
|
|
299
|
+
this.workerBecameIdle(worker);
|
|
300
|
+
const failedTracker = this.taskCompletions.get(message.taskId);
|
|
301
|
+
if (failedTracker) {
|
|
302
|
+
this.taskCompletions.delete(message.taskId);
|
|
303
|
+
failedTracker.reject(new Error(message.error));
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
case "idle":
|
|
309
|
+
this.workerBecameIdle(worker);
|
|
310
|
+
break;
|
|
311
|
+
|
|
312
|
+
case "log":
|
|
313
|
+
switch (message.level) {
|
|
314
|
+
case "error":
|
|
315
|
+
log.error(`[worker:${worker.id}] ${message.message}`);
|
|
316
|
+
break;
|
|
317
|
+
case "warn":
|
|
318
|
+
log.warn(`[worker:${worker.id}] ${message.message}`);
|
|
319
|
+
break;
|
|
320
|
+
case "info":
|
|
321
|
+
log.info(`[worker:${worker.id}] ${message.message}`);
|
|
322
|
+
break;
|
|
323
|
+
default:
|
|
324
|
+
log.debug(`[worker:${worker.id}] ${message.message}`);
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
|
|
328
|
+
case "log_forward": {
|
|
329
|
+
// Replay worker process logs through the main logger (with conversation context).
|
|
330
|
+
// This feeds both the console output and the SessionLogEmitter (Supabase persistence).
|
|
331
|
+
// Set conversationId context so logHook passes it to SessionLogEmitter.
|
|
332
|
+
const convId = worker.conversationId || null;
|
|
333
|
+
setLogConversationId(convId);
|
|
334
|
+
|
|
335
|
+
const convTag = convId
|
|
336
|
+
? `[conv:${convId.slice(0, 8)}]`
|
|
337
|
+
: `[worker:${worker.id}]`;
|
|
338
|
+
switch (message.method) {
|
|
339
|
+
case "agent":
|
|
340
|
+
log.agent(`${convTag} ${message.message}`);
|
|
341
|
+
break;
|
|
342
|
+
case "tool":
|
|
343
|
+
log.tool(message.extra || "unknown", `${convTag} ${message.message}`);
|
|
344
|
+
break;
|
|
345
|
+
case "result":
|
|
346
|
+
log.result(`${convTag} ${message.message}`);
|
|
347
|
+
break;
|
|
348
|
+
case "success":
|
|
349
|
+
log.success(`${convTag} ${message.message}`);
|
|
350
|
+
break;
|
|
351
|
+
case "error":
|
|
352
|
+
log.error(`${convTag} ${message.message}`);
|
|
353
|
+
break;
|
|
354
|
+
case "warn":
|
|
355
|
+
log.warn(`${convTag} ${message.message}`);
|
|
356
|
+
break;
|
|
357
|
+
case "info":
|
|
358
|
+
log.info(`${convTag} ${message.message}`);
|
|
359
|
+
break;
|
|
360
|
+
default:
|
|
361
|
+
log.debug(`${convTag} ${message.message}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
setLogConversationId(null);
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
case "error":
|
|
369
|
+
log.error(`[worker:${worker.id}] Error: ${message.error}`);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Transition a worker from busy → idle and update busy count.
|
|
376
|
+
*/
|
|
377
|
+
private workerBecameIdle(worker: ManagedWorker): void {
|
|
378
|
+
if (worker.status === "busy" && worker.type === "conversation") {
|
|
379
|
+
this.busyCount = Math.max(0, this.busyCount - 1);
|
|
380
|
+
this.onBusyChange?.(this.busyCount);
|
|
381
|
+
}
|
|
382
|
+
worker.status = "idle";
|
|
383
|
+
this.scheduleIdleTimeout(worker);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Send a message to a worker and mark it busy (for conversation workers).
|
|
388
|
+
*/
|
|
389
|
+
private sendToWorker(worker: ManagedWorker, message: OrchestratorMessage): void {
|
|
390
|
+
if (worker.type === "conversation" && worker.status !== "busy") {
|
|
391
|
+
worker.status = "busy";
|
|
392
|
+
this.busyCount++;
|
|
393
|
+
this.onBusyChange?.(this.busyCount);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Track task ID for cleanup on worker exit
|
|
397
|
+
if (message.type === "process_task") {
|
|
398
|
+
worker.pendingTaskIds.add(message.task.id);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
worker.lastActivityAt = new Date();
|
|
402
|
+
|
|
403
|
+
// Cancel idle timer
|
|
404
|
+
if (worker.idleTimer) {
|
|
405
|
+
clearTimeout(worker.idleTimer);
|
|
406
|
+
worker.idleTimer = null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
worker.process.send(message);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private handleWorkerExit(
|
|
413
|
+
worker: ManagedWorker,
|
|
414
|
+
code: number | null,
|
|
415
|
+
signal: string | null
|
|
416
|
+
): void {
|
|
417
|
+
// If worker was busy, decrement count
|
|
418
|
+
if (worker.status === "busy" && worker.type === "conversation") {
|
|
419
|
+
this.busyCount = Math.max(0, this.busyCount - 1);
|
|
420
|
+
this.onBusyChange?.(this.busyCount);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
worker.status = "stopped";
|
|
424
|
+
|
|
425
|
+
if (worker.idleTimer) {
|
|
426
|
+
clearTimeout(worker.idleTimer);
|
|
427
|
+
worker.idleTimer = null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (worker.conversationId) {
|
|
431
|
+
this.conversationWorkers.delete(worker.conversationId);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (code !== 0 && code !== null) {
|
|
435
|
+
log.warn(`Worker ${worker.id} (${worker.type}) exited with code ${code}`);
|
|
436
|
+
worker.status = "error";
|
|
437
|
+
} else if (signal) {
|
|
438
|
+
log.debug(`Worker ${worker.id} (${worker.type}) killed by signal ${signal}`);
|
|
439
|
+
} else {
|
|
440
|
+
log.debug(`Worker ${worker.id} (${worker.type}) exited normally`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Reject readyPromise if worker exited before becoming ready
|
|
444
|
+
const exitError = new Error(`Worker ${worker.id} exited (code=${code}, signal=${signal})`);
|
|
445
|
+
worker.readyReject(exitError);
|
|
446
|
+
|
|
447
|
+
// Reject all pending task completions for this worker
|
|
448
|
+
for (const taskId of worker.pendingTaskIds) {
|
|
449
|
+
const tracker = this.taskCompletions.get(taskId);
|
|
450
|
+
if (tracker) {
|
|
451
|
+
this.taskCompletions.delete(taskId);
|
|
452
|
+
tracker.reject(exitError);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
worker.pendingTaskIds.clear();
|
|
456
|
+
|
|
457
|
+
this.workers.delete(worker.id);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private async stopWorker(worker: ManagedWorker, timeoutMs: number): Promise<void> {
|
|
461
|
+
if (worker.status === "stopped") return;
|
|
462
|
+
|
|
463
|
+
worker.status = "stopping";
|
|
464
|
+
|
|
465
|
+
if (worker.idleTimer) {
|
|
466
|
+
clearTimeout(worker.idleTimer);
|
|
467
|
+
worker.idleTimer = null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
const shutdownMsg: OrchestratorMessage = { type: "shutdown" };
|
|
472
|
+
worker.process.send(shutdownMsg);
|
|
473
|
+
|
|
474
|
+
await Promise.race([
|
|
475
|
+
new Promise<void>((resolve) => {
|
|
476
|
+
worker.process.on("exit", () => resolve());
|
|
477
|
+
}),
|
|
478
|
+
new Promise<void>((_, reject) =>
|
|
479
|
+
setTimeout(() => reject(new Error("Shutdown timeout")), timeoutMs)
|
|
480
|
+
),
|
|
481
|
+
]);
|
|
482
|
+
} catch {
|
|
483
|
+
worker.process.kill("SIGKILL");
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private scheduleIdleTimeout(worker: ManagedWorker): void {
|
|
488
|
+
if (worker.type !== "conversation" || !this.running) return;
|
|
489
|
+
|
|
490
|
+
if (worker.idleTimer) {
|
|
491
|
+
clearTimeout(worker.idleTimer);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
worker.idleTimer = setTimeout(() => {
|
|
495
|
+
if (worker.status === "idle" || worker.status === "ready") {
|
|
496
|
+
log.debug(`Worker ${worker.id} idle timeout — terminating`);
|
|
497
|
+
this.stopWorker(worker, 3_000).catch(() => {});
|
|
498
|
+
}
|
|
499
|
+
}, IDLE_TIMEOUT_MS);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private getConversationWorker(conversationId: string): ManagedWorker | undefined {
|
|
503
|
+
const workerId = this.conversationWorkers.get(conversationId);
|
|
504
|
+
return workerId ? this.workers.get(workerId) : undefined;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC message protocol between orchestrator (main process) and workers (child processes).
|
|
3
|
+
*
|
|
4
|
+
* Workers are spawned via child_process.fork() and communicate via JSON messages.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ConversationMessage } from "../db/types.js";
|
|
8
|
+
|
|
9
|
+
// ── Worker Types ──────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export type WorkerType = "conversation";
|
|
12
|
+
|
|
13
|
+
export interface WorkerConfig {
|
|
14
|
+
type: WorkerType;
|
|
15
|
+
userId: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
/** For conversation workers: the conversation this worker handles. */
|
|
18
|
+
conversationId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Orchestrator → Worker Messages ────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export type OrchestratorMessage =
|
|
24
|
+
| { type: "init"; config: WorkerConfig }
|
|
25
|
+
| { type: "process_task"; task: ConversationMessage }
|
|
26
|
+
| { type: "shutdown" };
|
|
27
|
+
|
|
28
|
+
// ── Worker → Orchestrator Messages ────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export type LogMethod = "debug" | "info" | "success" | "warn" | "error" | "agent" | "tool" | "result";
|
|
31
|
+
|
|
32
|
+
export type WorkerMessage =
|
|
33
|
+
| { type: "ready" }
|
|
34
|
+
| { type: "task_completed"; taskId: string }
|
|
35
|
+
| { type: "task_failed"; taskId: string; error: string }
|
|
36
|
+
| { type: "error"; error: string }
|
|
37
|
+
| { type: "idle" }
|
|
38
|
+
| { type: "log"; level: string; message: string }
|
|
39
|
+
| { type: "log_forward"; method: LogMethod; message: string; extra?: string };
|
|
40
|
+
|
|
41
|
+
// ── Worker Status ─────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export type WorkerStatus =
|
|
44
|
+
| "starting"
|
|
45
|
+
| "ready"
|
|
46
|
+
| "busy"
|
|
47
|
+
| "idle"
|
|
48
|
+
| "stopping"
|
|
49
|
+
| "stopped"
|
|
50
|
+
| "error";
|
|
51
|
+
|
|
52
|
+
export interface WorkerInfo {
|
|
53
|
+
id: string;
|
|
54
|
+
type: WorkerType;
|
|
55
|
+
status: WorkerStatus;
|
|
56
|
+
pid?: number;
|
|
57
|
+
conversationId?: string;
|
|
58
|
+
startedAt: Date;
|
|
59
|
+
lastActivityAt: Date;
|
|
60
|
+
tasksProcessed: number;
|
|
61
|
+
}
|