@northflare/runner 0.0.12 → 0.0.13

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.
Files changed (77) hide show
  1. package/package.json +2 -3
  2. package/coverage/base.css +0 -224
  3. package/coverage/block-navigation.js +0 -87
  4. package/coverage/coverage-final.json +0 -12
  5. package/coverage/favicon.png +0 -0
  6. package/coverage/index.html +0 -176
  7. package/coverage/lib/index.html +0 -116
  8. package/coverage/lib/preload-script.js.html +0 -964
  9. package/coverage/prettify.css +0 -1
  10. package/coverage/prettify.js +0 -2
  11. package/coverage/sort-arrow-sprite.png +0 -0
  12. package/coverage/sorter.js +0 -196
  13. package/coverage/src/collections/index.html +0 -116
  14. package/coverage/src/collections/runner-messages.ts.html +0 -312
  15. package/coverage/src/components/claude-manager.ts.html +0 -1290
  16. package/coverage/src/components/index.html +0 -146
  17. package/coverage/src/components/message-handler.ts.html +0 -730
  18. package/coverage/src/components/repository-manager.ts.html +0 -841
  19. package/coverage/src/index.html +0 -131
  20. package/coverage/src/index.ts.html +0 -448
  21. package/coverage/src/runner.ts.html +0 -1239
  22. package/coverage/src/utils/config.ts.html +0 -780
  23. package/coverage/src/utils/console.ts.html +0 -121
  24. package/coverage/src/utils/index.html +0 -161
  25. package/coverage/src/utils/logger.ts.html +0 -475
  26. package/coverage/src/utils/status-line.ts.html +0 -445
  27. package/exceptions.log +0 -24
  28. package/lib/codex-sdk/src/codex.ts +0 -38
  29. package/lib/codex-sdk/src/codexOptions.ts +0 -10
  30. package/lib/codex-sdk/src/events.ts +0 -80
  31. package/lib/codex-sdk/src/exec.ts +0 -336
  32. package/lib/codex-sdk/src/index.ts +0 -39
  33. package/lib/codex-sdk/src/items.ts +0 -127
  34. package/lib/codex-sdk/src/outputSchemaFile.ts +0 -40
  35. package/lib/codex-sdk/src/thread.ts +0 -155
  36. package/lib/codex-sdk/src/threadOptions.ts +0 -18
  37. package/lib/codex-sdk/src/turnOptions.ts +0 -6
  38. package/lib/codex-sdk/tests/abort.test.ts +0 -165
  39. package/lib/codex-sdk/tests/codexExecSpy.ts +0 -37
  40. package/lib/codex-sdk/tests/responsesProxy.ts +0 -225
  41. package/lib/codex-sdk/tests/run.test.ts +0 -687
  42. package/lib/codex-sdk/tests/runStreamed.test.ts +0 -211
  43. package/lib/codex-sdk/tsconfig.json +0 -24
  44. package/rejections.log +0 -68
  45. package/runner.log +0 -488
  46. package/src/components/claude-sdk-manager.ts +0 -1425
  47. package/src/components/codex-sdk-manager.ts +0 -1358
  48. package/src/components/enhanced-repository-manager.ts +0 -823
  49. package/src/components/message-handler-sse.ts +0 -1097
  50. package/src/components/repository-manager.ts +0 -337
  51. package/src/index.ts +0 -168
  52. package/src/runner-sse.ts +0 -917
  53. package/src/services/RunnerAPIClient.ts +0 -175
  54. package/src/services/SSEClient.ts +0 -258
  55. package/src/types/claude.ts +0 -66
  56. package/src/types/computer-name.d.ts +0 -4
  57. package/src/types/index.ts +0 -64
  58. package/src/types/messages.ts +0 -39
  59. package/src/types/runner-interface.ts +0 -36
  60. package/src/utils/StateManager.ts +0 -187
  61. package/src/utils/config.ts +0 -327
  62. package/src/utils/console.ts +0 -15
  63. package/src/utils/debug.ts +0 -18
  64. package/src/utils/expand-env.ts +0 -22
  65. package/src/utils/logger.ts +0 -134
  66. package/src/utils/model.ts +0 -29
  67. package/src/utils/status-line.ts +0 -122
  68. package/src/utils/tool-response-sanitizer.ts +0 -160
  69. package/test-debug.sh +0 -26
  70. package/tests/retry-strategies.test.ts +0 -410
  71. package/tests/sdk-integration.test.ts +0 -329
  72. package/tests/sdk-streaming.test.ts +0 -1180
  73. package/tests/setup.ts +0 -5
  74. package/tests/test-claude-manager.ts +0 -120
  75. package/tests/tool-response-sanitizer.test.ts +0 -63
  76. package/tsconfig.json +0 -36
  77. package/vitest.config.ts +0 -27
package/src/runner-sse.ts DELETED
@@ -1,917 +0,0 @@
1
- /**
2
- * Main RunnerApp class - entry point and lifecycle manager for the runner app
3
- * Updated to use SSE for real-time messaging instead of ElectricSQL
4
- */
5
-
6
- import {
7
- RunnerConfig,
8
- ConversationContext,
9
- JsonRpcMessage,
10
- RetryStrategy,
11
- RunnerRepo,
12
- } from "./types";
13
- import { IRunnerApp } from "./types/runner-interface";
14
- import { MessageHandler } from "./components/message-handler-sse";
15
- import { ClaudeManager } from "./components/claude-sdk-manager";
16
- import { CodexManager } from "./components/codex-sdk-manager";
17
- import { EnhancedRepositoryManager } from "./components/enhanced-repository-manager";
18
- import { StateManager } from "./utils/StateManager";
19
- import { createLogger } from "./utils/logger";
20
- import { statusLineManager } from "./utils/status-line";
21
- import { isRunnerDebugEnabled } from "./utils/debug";
22
- import fs from "fs/promises";
23
- import path from "path";
24
- import computerName from "computer-name";
25
- import {
26
- sanitizeToolResponsePayload,
27
- TOOL_RESPONSE_BYTE_LIMIT,
28
- } from "./utils/tool-response-sanitizer";
29
-
30
- const logger = createLogger("RunnerApp");
31
-
32
- function logToolResponseTruncation(toolUseId?: string) {
33
- const message = `Tool response exceeded ${TOOL_RESPONSE_BYTE_LIMIT} bytes and was truncated`;
34
- if (toolUseId) {
35
- logger.warn(message, { toolUseId });
36
- } else {
37
- logger.warn(message);
38
- }
39
- }
40
-
41
- export class RunnerApp implements IRunnerApp {
42
- private messageHandler!: MessageHandler;
43
- private claudeManager!: ClaudeManager;
44
- private codexManager!: CodexManager;
45
- private repositoryManager!: EnhancedRepositoryManager;
46
- private stateManager!: StateManager;
47
- private agentConversations: Map<string, ConversationContext>; // Keyed by conversation.id
48
- private config: RunnerConfig;
49
- private configPath?: string;
50
- private heartbeatInterval?: NodeJS.Timeout;
51
- private isRunning: boolean = false;
52
- private isRegistered: boolean = false;
53
- private delayFn: (ms: number) => Promise<void> = (ms) =>
54
- new Promise((resolve) => setTimeout(resolve, ms));
55
-
56
- // UID ownership fields
57
- private runnerUid: string | null = null;
58
- private lastProcessedAt: Date | null = null;
59
- private isActiveRunner: boolean = false;
60
-
61
- // Track conversations started before takeover
62
- private preHandoffConversations = new Set<string>();
63
-
64
- constructor(config: RunnerConfig, configPath?: string) {
65
- this.config = config;
66
- this.configPath = configPath;
67
- this.agentConversations = new Map();
68
- // Note: State manager will be initialized in start() after fetching runnerId
69
- }
70
-
71
- private async fetchRunnerId(): Promise<string> {
72
- const token = process.env["NORTHFLARE_RUNNER_TOKEN"];
73
- if (!token) {
74
- throw new Error("NORTHFLARE_RUNNER_TOKEN environment variable is required");
75
- }
76
-
77
- try {
78
- const response = await fetch(`${this.config.orchestratorUrl}/api/runner/id`, {
79
- method: "GET",
80
- headers: {
81
- Authorization: `Bearer ${token}`,
82
- },
83
- });
84
-
85
- if (!response.ok) {
86
- const error = await response.json().catch(() => ({ error: response.statusText })) as any;
87
- throw new Error(`Failed to fetch runner ID: ${error.error || response.statusText}`);
88
- }
89
-
90
- const data = await response.json() as { runnerId?: string };
91
- if (!data.runnerId) {
92
- throw new Error("Server did not return runnerId");
93
- }
94
-
95
- logger.info(`Fetched runnerId: ${data.runnerId}`);
96
- return data.runnerId;
97
- } catch (error) {
98
- logger.error("Failed to fetch runner ID from orchestrator", error);
99
- throw error;
100
- }
101
- }
102
-
103
- private initializeComponents(): void {
104
- // Initialize repository manager first as it's used by Claude manager
105
- this.repositoryManager = new EnhancedRepositoryManager(this);
106
-
107
- // Initialize Claude manager with repository manager (using SDK-native manager)
108
- this.claudeManager = new ClaudeManager(this, this.repositoryManager);
109
-
110
- // Initialize Codex manager for OpenAI-based conversations
111
- this.codexManager = new CodexManager(this, this.repositoryManager);
112
-
113
- // Initialize message handler with SSE support
114
- this.messageHandler = new MessageHandler(this);
115
- }
116
-
117
- async start(): Promise<void> {
118
- if (this.isRunning) {
119
- throw new Error("Runner is already running");
120
- }
121
-
122
- try {
123
- // Fetch runnerId from orchestrator using token
124
- const runnerId = await this.fetchRunnerId();
125
- this.config.runnerId = runnerId;
126
-
127
- // Initialize state manager with runnerId
128
- this.stateManager = new StateManager(this.config.dataDir, runnerId);
129
-
130
- // Initialize other components
131
- this.initializeComponents();
132
-
133
- // Load persisted state
134
- const savedState = await this.stateManager.loadState();
135
- if (savedState) {
136
- // Restore state from previous run
137
- this.config.runnerId = savedState.runnerId;
138
- this.runnerUid = savedState.runnerUid;
139
- this.lastProcessedAt = savedState.lastProcessedAt
140
- ? new Date(savedState.lastProcessedAt)
141
- : null;
142
- this.isActiveRunner = savedState.isActiveRunner;
143
-
144
- logger.info("Restored runner state from disk", {
145
- runnerId: savedState.runnerId,
146
- runnerUid: savedState.runnerUid,
147
- lastProcessedAt: savedState.lastProcessedAt,
148
- isActiveRunner: savedState.isActiveRunner,
149
- });
150
-
151
- // Load runnerRepos for this specific runnerId
152
- if (this.configPath && savedState.runnerId) {
153
- await this.loadRunnerReposForId(savedState.runnerId);
154
- }
155
- }
156
-
157
- // Register with retry strategy
158
- await this.registerWithRetry();
159
-
160
- // Log debug info after registration
161
- if (isRunnerDebugEnabled()) {
162
- logger.debug("Runner initialized with ownership details", {
163
- runnerId: this.config.runnerId,
164
- runnerUid: this.runnerUid,
165
- lastProcessedAt: this.lastProcessedAt?.toISOString() || "null",
166
- isActiveRunner: this.isActiveRunner,
167
- orchestratorUrl: this.config.orchestratorUrl,
168
- });
169
- }
170
-
171
- // Start message processing with SSE
172
- await this.messageHandler.startProcessing();
173
-
174
- // Start heartbeat after successful registration
175
- this.startHeartbeat();
176
-
177
- this.isRunning = true;
178
-
179
- logger.info(`Runner ${this.config.runnerId} started successfully`);
180
- } catch (error) {
181
- logger.error("Failed to start runner:", error);
182
- throw error;
183
- }
184
- }
185
-
186
- async stop(): Promise<void> {
187
- if (!this.isRunning) {
188
- return;
189
- }
190
-
191
- logger.info(`Stopping runner ${this.config.runnerId}...`);
192
-
193
- // Stop heartbeat
194
- if (this.heartbeatInterval) {
195
- clearInterval(this.heartbeatInterval);
196
- this.heartbeatInterval = undefined;
197
- }
198
-
199
- // Stop all conversations
200
- await this.stopAllConversations();
201
-
202
- // Stop message processing
203
- await this.messageHandler.stopProcessing();
204
-
205
- // Clean up status line
206
- statusLineManager.dispose();
207
-
208
- this.isRunning = false;
209
-
210
- logger.info(`Runner ${this.config.runnerId} stopped`);
211
- }
212
-
213
- async notify(method: string, params: any): Promise<void> {
214
- try {
215
- const sanitization = sanitizeToolResponsePayload(method, params);
216
- if (sanitization.truncated) {
217
- logToolResponseTruncation(sanitization.toolUseId);
218
- }
219
- const safeParams = sanitization.truncated
220
- ? sanitization.params
221
- : params;
222
- // Log RPC notification in debug mode
223
- logger.debug(`[RPC] Sending notification: ${method}`, {
224
- method,
225
- params: JSON.stringify(safeParams, null, 2),
226
- });
227
-
228
- // Send notification with retry logic
229
- await this.sendToOrchestratorWithRetry({
230
- jsonrpc: "2.0",
231
- method,
232
- params: safeParams,
233
- });
234
- } catch (error) {
235
- // Special handling for heartbeat errors - just log a simple line
236
- if (method === "runner.heartbeat") {
237
- const errorMessage =
238
- error instanceof Error ? error.message : String(error);
239
- logger.error(`Heartbeat failed: ${errorMessage}`);
240
- } else {
241
- // For other RPC errors, log the method and error without stack
242
- const errorMessage =
243
- error instanceof Error ? error.message : String(error);
244
- logger.error(
245
- `RPC Error: Failed to notify with method '${method}': ${errorMessage}`
246
- );
247
- }
248
- throw error;
249
- }
250
- }
251
-
252
- async sendToOrchestrator(message: JsonRpcMessage): Promise<any> {
253
- const sanitization = sanitizeToolResponsePayload(
254
- message.method,
255
- message.params
256
- );
257
- if (sanitization.truncated) {
258
- logToolResponseTruncation(sanitization.toolUseId);
259
- }
260
-
261
- const messageToSend = sanitization.truncated
262
- ? {
263
- ...message,
264
- params: sanitization.params,
265
- }
266
- : message;
267
-
268
- try {
269
- // Log RPC request in debug mode
270
- logger.debug(`[RPC] Sending request:`, {
271
- method: messageToSend.method,
272
- id: messageToSend.id,
273
- params: messageToSend.params
274
- ? JSON.stringify(messageToSend.params, null, 2)
275
- : undefined,
276
- });
277
-
278
- // Build headers - only include X-Runner-Id if we have one (not during registration)
279
- const headers: Record<string, string> = {
280
- "Content-Type": "application/json",
281
- Authorization: `Bearer ${process.env["NORTHFLARE_RUNNER_TOKEN"]}`,
282
- };
283
-
284
- // Only add X-Runner-Id if we're registered (not during registration)
285
- if (this.config.runnerId && message.method !== "runner.register") {
286
- headers["X-Runner-Id"] = this.config.runnerId;
287
- }
288
-
289
- const response = await fetch(
290
- `${this.config.orchestratorUrl}/api/runner/messages`,
291
- {
292
- method: "POST",
293
- headers,
294
- body: JSON.stringify(messageToSend),
295
- signal: AbortSignal.timeout(30000), // 30 second timeout
296
- }
297
- );
298
-
299
- if (!response.ok) {
300
- const errorText = await response.text();
301
- throw new Error(`HTTP ${response.status}: ${errorText}`);
302
- }
303
-
304
- const result = (await response.json()) as any;
305
-
306
- // Log RPC response in debug mode
307
- logger.debug(`[RPC] Received response:`, {
308
- method: messageToSend.method,
309
- id: messageToSend.id,
310
- result: result?.result
311
- ? JSON.stringify(result.result, null, 2)
312
- : undefined,
313
- error: result?.error
314
- ? JSON.stringify(result.error, null, 2)
315
- : undefined,
316
- });
317
-
318
- return result;
319
- } catch (error) {
320
- const errorMessage =
321
- error instanceof Error ? error.message : String(error);
322
-
323
- // Don't log verbose errors for heartbeat failures
324
- if (message.method === "runner.heartbeat") {
325
- // Heartbeat errors are already logged in notify()
326
- } else if (message.method === "runner.register") {
327
- // Special case for registration failures
328
- logger.error(`Registration failed: ${errorMessage}`);
329
- } else {
330
- // For other RPC messages, log the attempted message and error
331
- logger.error(`RPC failed:`, {
332
- method: messageToSend.method,
333
- params: messageToSend.params,
334
- error: errorMessage,
335
- });
336
- }
337
- throw error;
338
- }
339
- }
340
-
341
- getConversationContext(
342
- conversationId: string
343
- ): ConversationContext | undefined {
344
- // Using conversation.id as primary key for conversation tracking
345
- const context = this.agentConversations.get(conversationId);
346
- logger.debug(`[Runner] getConversationContext lookup:`, {
347
- conversationId,
348
- found: !!context,
349
- totalConversations: this.agentConversations.size,
350
- allConversationIds: Array.from(this.agentConversations.keys()),
351
- });
352
- return context;
353
- }
354
-
355
- private async registerRunner(): Promise<void> {
356
- try {
357
- // Filter runnerRepos by valid paths
358
- let filteredRunnerRepos: RunnerRepo[] = [];
359
-
360
- if (this.config.runnerRepos && Array.isArray(this.config.runnerRepos)) {
361
- for (const repo of this.config.runnerRepos) {
362
- try {
363
- const resolvedPath = path.resolve(repo.path);
364
- const stats = await fs.stat(resolvedPath);
365
- if (stats.isDirectory()) {
366
- filteredRunnerRepos.push(repo);
367
- logger.info(
368
- `RunnerRepo validated: ${repo.name} at ${resolvedPath}`
369
- );
370
- } else {
371
- logger.warn(
372
- `RunnerRepo path is not a directory: ${repo.name} at ${resolvedPath}`
373
- );
374
- }
375
- } catch (error) {
376
- logger.warn(
377
- `RunnerRepo path does not exist: ${repo.name} at ${repo.path}`
378
- );
379
- }
380
- }
381
- }
382
-
383
- // Get computer name for registration
384
- let hostComputerName: string | undefined;
385
- try {
386
- hostComputerName = await computerName();
387
- } catch (error) {
388
- logger.warn("Failed to get computer name, will use default", error);
389
- }
390
-
391
- const response = await this.sendToOrchestratorWithRetry({
392
- jsonrpc: "2.0",
393
- id: "register-" + Date.now(),
394
- method: "runner.register",
395
- params: {
396
- version: "1.0.0",
397
- runnerRepos: filteredRunnerRepos,
398
- computerName: hostComputerName,
399
- },
400
- });
401
-
402
- // Debug log the full response
403
- logger.info(`Registration response:`, JSON.stringify(response, null, 2));
404
-
405
- // Log the runnerRepos being sent and received
406
- if (isRunnerDebugEnabled()) {
407
- logger.debug(
408
- "Registration runnerRepos sent:",
409
- JSON.stringify(filteredRunnerRepos)
410
- );
411
- logger.debug(
412
- "Registration runnerRepos received:",
413
- JSON.stringify(response?.result?.runnerRepos)
414
- );
415
- }
416
-
417
- // Check for JSONRPC error response
418
- if (response?.error) {
419
- logger.error(`Registration error from server:`, response.error);
420
- throw new Error(
421
- `Server error: ${response.error.message || "Unknown error"}`
422
- );
423
- }
424
-
425
- // Extract result from JSONRPC response structure
426
- const result = response?.result;
427
-
428
- if (result?.runnerId) {
429
- this.config.runnerId = result.runnerId;
430
- this.runnerUid = result.runnerUid;
431
- this.lastProcessedAt = result.lastProcessedAt
432
- ? new Date(result.lastProcessedAt)
433
- : null;
434
- this.isRegistered = true;
435
-
436
- // If this is a fresh start (no previous lastProcessedAt), set as active runner immediately
437
- // The server will also send a runner.uid.changed message, but we set it here to avoid
438
- // any window where isActiveRunner is incorrectly false
439
- if (!result.lastProcessedAt) {
440
- this.isActiveRunner = true;
441
- logger.info(
442
- `First-time registration - setting as active runner immediately`
443
- );
444
- }
445
-
446
- // Save registration state
447
- await this.stateManager.updateRunnerRegistration(
448
- result.runnerId,
449
- result.runnerUid,
450
- this.lastProcessedAt
451
- );
452
-
453
- // Persist the active status if we set it
454
- if (this.isActiveRunner) {
455
- await this.stateManager.updateActiveStatus(true);
456
- }
457
-
458
- logger.info(
459
- `Runner registered successfully with ID: ${this.config.runnerId} and UID: ${this.runnerUid}`
460
- );
461
-
462
- // Process runnerRepos from response
463
- if (result.runnerRepos && Array.isArray(result.runnerRepos)) {
464
- await this.updateRunnerReposWithIds(result.runnerRepos);
465
- }
466
-
467
- // Debug logging for registration details
468
- if (isRunnerDebugEnabled()) {
469
- logger.debug("Registration complete with details", {
470
- runnerId: this.config.runnerId,
471
- runnerUid: this.runnerUid,
472
- lastProcessedAt: this.lastProcessedAt?.toISOString() || "null",
473
- orchestratorUrl: this.config.orchestratorUrl,
474
- dataDir: this.config.dataDir,
475
- runnerReposCount: result.runnerRepos?.length || 0,
476
- });
477
- }
478
- } else {
479
- logger.error(`Registration failed. Expected result.runnerId but got:`, {
480
- hasResult: !!result,
481
- resultKeys: result ? Object.keys(result) : [],
482
- fullResponse: JSON.stringify(response, null, 2),
483
- });
484
- throw new Error("Registration response did not include runnerId");
485
- }
486
- } catch (error) {
487
- const errorMessage =
488
- error instanceof Error ? error.message : String(error);
489
- logger.error(`Failed to register runner: ${errorMessage}`);
490
- throw error;
491
- }
492
- }
493
-
494
- private async registerWithRetry(): Promise<void> {
495
- const strategy = this.config.retryStrategy;
496
-
497
- if (strategy === "none") {
498
- // Single attempt, no retries
499
- await this.registerRunner();
500
- return;
501
- }
502
-
503
- if (strategy === "interval") {
504
- // Retry at fixed intervals
505
- const intervalMs = this.config.retryIntervalSecs * 1000;
506
- let attempt = 0;
507
-
508
- while (!this.isRegistered) {
509
- attempt++;
510
- try {
511
- logger.info(`Registration attempt ${attempt}...`);
512
- await this.registerRunner();
513
- return;
514
- } catch (error) {
515
- const errorMessage =
516
- error instanceof Error ? error.message : String(error);
517
- logger.error(
518
- `Registration attempt ${attempt} failed: ${errorMessage}`
519
- );
520
- logger.info(
521
- `Retrying in ${this.config.retryIntervalSecs} seconds...`
522
- );
523
- await this.delayFn(intervalMs);
524
- }
525
- }
526
- }
527
-
528
- if (strategy === "exponential") {
529
- // Exponential backoff with max duration
530
- const maxDurationMs = this.config.retryDurationSecs * 1000;
531
- const startTime = Date.now();
532
- let delayMs = 8000; // Start with 8 seconds
533
- let attempt = 0;
534
-
535
- while (!this.isRegistered) {
536
- attempt++;
537
- try {
538
- logger.info(`Registration attempt ${attempt}...`);
539
- await this.registerRunner();
540
- return;
541
- } catch (error) {
542
- const errorMessage =
543
- error instanceof Error ? error.message : String(error);
544
- logger.error(
545
- `Registration attempt ${attempt} failed: ${errorMessage}`
546
- );
547
-
548
- const elapsedMs = Date.now() - startTime;
549
- if (elapsedMs >= maxDurationMs) {
550
- // Final retry at max duration
551
- const remainingMs = maxDurationMs - elapsedMs;
552
- if (remainingMs > 0) {
553
- logger.info(
554
- `Final retry in ${Math.ceil(remainingMs / 1000)} seconds...`
555
- );
556
- await this.delayFn(remainingMs);
557
-
558
- try {
559
- logger.info("Final registration attempt...");
560
- await this.registerRunner();
561
- return;
562
- } catch (finalError) {
563
- const errorMessage =
564
- finalError instanceof Error
565
- ? finalError.message
566
- : String(finalError);
567
- logger.error(
568
- `Final registration attempt failed: ${errorMessage}`
569
- );
570
- throw new Error(
571
- `Failed to register after ${this.config.retryDurationSecs} seconds`
572
- );
573
- }
574
- } else {
575
- throw new Error(
576
- `Failed to register after ${this.config.retryDurationSecs} seconds`
577
- );
578
- }
579
- }
580
-
581
- // Calculate next delay with exponential backoff
582
- const nextDelayMs = Math.min(delayMs, maxDurationMs - elapsedMs);
583
- logger.info(
584
- `Retrying in ${Math.ceil(nextDelayMs / 1000)} seconds...`
585
- );
586
- await this.delayFn(nextDelayMs);
587
-
588
- // Double the delay for next attempt
589
- delayMs = Math.min(delayMs * 2, maxDurationMs);
590
- }
591
- }
592
- }
593
- }
594
-
595
- /**
596
- * Load runnerRepos for a specific runnerId from the config file
597
- */
598
- private async loadRunnerReposForId(runnerId: string): Promise<void> {
599
- if (!this.configPath) {
600
- logger.debug("No config path specified, cannot load runnerRepos");
601
- return;
602
- }
603
-
604
- try {
605
- const configContent = await fs.readFile(
606
- path.resolve(this.configPath),
607
- "utf-8"
608
- );
609
- const configFile = JSON.parse(configContent);
610
-
611
- if (!configFile.runnerRepos || typeof configFile.runnerRepos !== "object") {
612
- logger.debug("No runnerRepos in config file");
613
- this.config.runnerRepos = [];
614
- return;
615
- }
616
-
617
- const reposForThisRunner = configFile.runnerRepos[runnerId];
618
- if (reposForThisRunner && Array.isArray(reposForThisRunner)) {
619
- this.config.runnerRepos = reposForThisRunner;
620
- logger.info(
621
- `Loaded ${reposForThisRunner.length} runnerRepos for runner ${runnerId}`
622
- );
623
- } else {
624
- this.config.runnerRepos = [];
625
- logger.debug(
626
- `No runnerRepos found for runner ${runnerId}, using empty array`
627
- );
628
- }
629
- } catch (error) {
630
- logger.error("Failed to load runnerRepos for runner", error);
631
- this.config.runnerRepos = [];
632
- }
633
- }
634
-
635
- private async updateRunnerReposWithIds(
636
- serverRunnerRepos: any[]
637
- ): Promise<void> {
638
- if (!this.configPath || !this.config.runnerId) {
639
- logger.info(
640
- "No config path or runnerId specified, cannot update runnerRepos with IDs"
641
- );
642
- return;
643
- }
644
-
645
- try {
646
- const { ConfigManager } = await import("./utils/config");
647
- const configContent = await fs.readFile(
648
- path.resolve(this.configPath),
649
- "utf-8"
650
- );
651
- const configFile = JSON.parse(configContent);
652
-
653
- // Initialize runnerRepos namespace if not present
654
- if (!configFile.runnerRepos) {
655
- configFile.runnerRepos = {};
656
- }
657
-
658
- // Get or create repos array for this runnerId
659
- let reposForThisRunner = configFile.runnerRepos[this.config.runnerId];
660
- if (!reposForThisRunner || !Array.isArray(reposForThisRunner)) {
661
- reposForThisRunner = [];
662
- configFile.runnerRepos[this.config.runnerId] = reposForThisRunner;
663
- }
664
-
665
- // Update runnerRepos with UUIDs from server
666
- let hasUpdates = false;
667
- for (const serverRepo of serverRunnerRepos) {
668
- const localRepo = reposForThisRunner.find(
669
- (r: RunnerRepo) =>
670
- r.name === serverRepo.name && r.path === serverRepo.path
671
- );
672
-
673
- if (localRepo && serverRepo.uuid) {
674
- // Store UUID if not already present or different
675
- if (!localRepo.uuid || localRepo.uuid !== serverRepo.uuid) {
676
- localRepo.uuid = serverRepo.uuid;
677
- hasUpdates = true;
678
- logger.info(
679
- `Updated RunnerRepo "${localRepo.name}" with UUID: ${serverRepo.uuid}`
680
- );
681
- }
682
- } else if (serverRepo.uuid) {
683
- // Add new repo from server
684
- reposForThisRunner.push({
685
- uuid: serverRepo.uuid,
686
- name: serverRepo.name,
687
- path: serverRepo.path,
688
- });
689
- hasUpdates = true;
690
- logger.info(
691
- `Added new RunnerRepo "${serverRepo.name}" with UUID: ${serverRepo.uuid}`
692
- );
693
- }
694
- }
695
-
696
- // Save the updated config if there were changes
697
- if (hasUpdates) {
698
- await ConfigManager.saveConfigFile(this.configPath, configFile);
699
- // Update our in-memory config
700
- this.config.runnerRepos = reposForThisRunner;
701
- logger.info(`Saved ${reposForThisRunner.length} runnerRepos for runner ${this.config.runnerId}`);
702
- }
703
- } catch (error) {
704
- logger.error("Failed to update runnerRepos with IDs", error);
705
- }
706
- }
707
-
708
- private async sendToOrchestratorWithRetry(
709
- message: JsonRpcMessage
710
- ): Promise<any> {
711
- const maxRetries = 5;
712
- const initialDelayMs = 1000; // Start with 1 second
713
- const maxDelayMs = 60000; // Max 60 seconds
714
- const jitterMs = 500; // Add up to 500ms of random jitter
715
-
716
- let lastError: Error | undefined;
717
-
718
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
719
- try {
720
- return await this.sendToOrchestrator(message);
721
- } catch (error) {
722
- lastError = error instanceof Error ? error : new Error(String(error));
723
-
724
- // Don't retry on certain errors
725
- if (
726
- lastError.message.includes("HTTP 401") ||
727
- lastError.message.includes("HTTP 403") ||
728
- lastError.message.includes("HTTP 404")
729
- ) {
730
- throw lastError;
731
- }
732
-
733
- if (attempt < maxRetries) {
734
- // Calculate exponential backoff with jitter
735
- const baseDelay = Math.min(
736
- initialDelayMs * Math.pow(2, attempt),
737
- maxDelayMs
738
- );
739
- const jitter = Math.random() * jitterMs;
740
- const delayMs = baseDelay + jitter;
741
-
742
- // Log retry attempt
743
- const errorMessage = lastError.message || String(lastError);
744
- if (message.method === "runner.heartbeat") {
745
- // Less verbose for heartbeat retries
746
- logger.debug(
747
- `Heartbeat retry ${attempt + 1}/${maxRetries + 1} in ${Math.round(
748
- delayMs
749
- )}ms`
750
- );
751
- } else {
752
- logger.warn(
753
- `RPC retry ${attempt + 1}/${maxRetries + 1} for '${
754
- message.method
755
- }' after error: ${errorMessage}. Retrying in ${Math.round(
756
- delayMs
757
- )}ms`
758
- );
759
- }
760
-
761
- await this.delayFn(delayMs);
762
- }
763
- }
764
- }
765
-
766
- // All retries exhausted
767
- const finalError = lastError || new Error("Unknown error");
768
- if (message.method !== "runner.heartbeat") {
769
- logger.error(`RPC failed after ${maxRetries + 1} attempts:`, {
770
- method: message.method,
771
- error: finalError.message,
772
- });
773
- }
774
- throw finalError;
775
- }
776
-
777
- private startHeartbeat(): void {
778
- if (!this.isRegistered || !this.config.runnerId) {
779
- logger.warn("Cannot start heartbeat: runner not registered");
780
- return;
781
- }
782
-
783
- this.heartbeatInterval = setInterval(async () => {
784
- try {
785
- await this.notify("runner.heartbeat", {
786
- runnerId: this.config.runnerId,
787
- activeConversations: this.agentConversations.size,
788
- uptime: process.uptime(),
789
- memoryUsage: process.memoryUsage(),
790
- });
791
- } catch (error) {
792
- // Error is already logged in notify() method
793
- }
794
- }, this.config.heartbeatInterval);
795
- }
796
-
797
- private async stopAllConversations(
798
- isRunnerShutdown: boolean = true
799
- ): Promise<void> {
800
- const stopPromises: Promise<void>[] = [];
801
-
802
- for (const [conversationId, context] of this.agentConversations) {
803
- if (context.status === "active" || context.status === "starting") {
804
- const manager = this.getManagerForContext(context);
805
- stopPromises.push(
806
- manager
807
- .stopConversation(context.agentSessionId, context, isRunnerShutdown)
808
- .catch((error) => {
809
- logger.error(
810
- `Failed to stop conversation ${context.agentSessionId}:`,
811
- error
812
- );
813
- })
814
- );
815
- }
816
- }
817
-
818
- await Promise.all(stopPromises);
819
- this.agentConversations.clear();
820
- }
821
-
822
- // Update lastProcessedAt and persist state
823
- async updateLastProcessedAt(timestamp: Date | null): Promise<void> {
824
- this.lastProcessedAt = timestamp;
825
- if (timestamp) {
826
- await this.stateManager.updateLastProcessedAt(timestamp);
827
- }
828
- }
829
-
830
- // Getters for components
831
- get config_(): RunnerConfig {
832
- return this.config;
833
- }
834
-
835
- get activeConversations_(): Map<string, ConversationContext> {
836
- return this.agentConversations;
837
- }
838
-
839
- get claudeManager_(): ClaudeManager {
840
- return this.claudeManager;
841
- }
842
- get codexManager_(): CodexManager {
843
- return this.codexManager;
844
- }
845
- get repositoryManager_(): EnhancedRepositoryManager {
846
- return this.repositoryManager;
847
- }
848
-
849
- getRunnerId(): string | undefined {
850
- return this.config.runnerId;
851
- }
852
-
853
- // UID ownership getters/setters
854
- getRunnerUid(): string | null {
855
- return this.runnerUid;
856
- }
857
-
858
- getLastProcessedAt(): Date | null {
859
- return this.lastProcessedAt;
860
- }
861
-
862
- getIsActiveRunner(): boolean {
863
- return this.isActiveRunner;
864
- }
865
-
866
- setIsActiveRunner(active: boolean): void {
867
- const previousState = this.isActiveRunner;
868
- this.isActiveRunner = active;
869
-
870
- // Persist state change
871
- this.stateManager.updateActiveStatus(active).catch((error) => {
872
- logger.error("Failed to persist active status:", error);
873
- });
874
-
875
- if (isRunnerDebugEnabled()) {
876
- logger.debug("Active runner status changed", {
877
- previous: previousState,
878
- new: active,
879
- runnerUid: this.runnerUid,
880
- lastProcessedAt: this.lastProcessedAt?.toISOString() || "null",
881
- });
882
- }
883
- }
884
-
885
- setLastProcessedAt(timestamp: Date | null): void {
886
- const previousTimestamp = this.lastProcessedAt;
887
- this.lastProcessedAt = timestamp;
888
-
889
- if (isRunnerDebugEnabled()) {
890
- logger.debug("LastProcessedAt updated", {
891
- previous: previousTimestamp?.toISOString() || "null",
892
- new: timestamp?.toISOString() || "null",
893
- runnerUid: this.runnerUid,
894
- isActiveRunner: this.isActiveRunner,
895
- });
896
- }
897
- }
898
-
899
- getPreHandoffConversations(): Set<string> {
900
- return this.preHandoffConversations;
901
- }
902
-
903
- // For testing purposes - allows mocking delays
904
- setDelayFunction(fn: (ms: number) => Promise<void>): void {
905
- this.delayFn = fn;
906
- }
907
-
908
- private getManagerForContext(
909
- context?: ConversationContext | null
910
- ): ClaudeManager | CodexManager {
911
- const provider = context?.provider?.toLowerCase();
912
- if (provider === "openai") {
913
- return this.codexManager;
914
- }
915
- return this.claudeManager;
916
- }
917
- }