cyrus-edge-worker 0.0.36 → 0.0.38
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/AgentSessionManager.d.ts +26 -1
- package/dist/AgentSessionManager.d.ts.map +1 -1
- package/dist/AgentSessionManager.js +227 -30
- package/dist/AgentSessionManager.js.map +1 -1
- package/dist/EdgeWorker.d.ts +66 -3
- package/dist/EdgeWorker.d.ts.map +1 -1
- package/dist/EdgeWorker.js +660 -55
- package/dist/EdgeWorker.js.map +1 -1
- package/dist/SharedApplicationServer.d.ts +29 -4
- package/dist/SharedApplicationServer.d.ts.map +1 -1
- package/dist/SharedApplicationServer.js +262 -0
- package/dist/SharedApplicationServer.js.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/procedures/ProcedureRouter.d.ts +60 -0
- package/dist/procedures/ProcedureRouter.d.ts.map +1 -0
- package/dist/procedures/ProcedureRouter.js +201 -0
- package/dist/procedures/ProcedureRouter.js.map +1 -0
- package/dist/procedures/index.d.ts +7 -0
- package/dist/procedures/index.d.ts.map +1 -0
- package/dist/procedures/index.js +7 -0
- package/dist/procedures/index.js.map +1 -0
- package/dist/procedures/registry.d.ts +76 -0
- package/dist/procedures/registry.d.ts.map +1 -0
- package/dist/procedures/registry.js +130 -0
- package/dist/procedures/registry.js.map +1 -0
- package/dist/procedures/types.d.ts +64 -0
- package/dist/procedures/types.d.ts.map +1 -0
- package/dist/procedures/types.js +5 -0
- package/dist/procedures/types.js.map +1 -0
- package/dist/prompts/subroutines/concise-summary.md +53 -0
- package/dist/prompts/subroutines/debugger-fix.md +108 -0
- package/dist/prompts/subroutines/debugger-reproduction.md +106 -0
- package/dist/prompts/subroutines/get-approval.md +175 -0
- package/dist/prompts/subroutines/git-gh.md +52 -0
- package/dist/prompts/subroutines/verbose-summary.md +46 -0
- package/dist/prompts/subroutines/verifications.md +46 -0
- package/dist/types.d.ts +0 -97
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -5
- package/prompt-template-v2.md +3 -19
- package/prompts/builder.md +1 -23
- package/prompts/debugger.md +11 -174
- package/prompts/orchestrator.md +41 -64
package/dist/EdgeWorker.js
CHANGED
|
@@ -3,14 +3,15 @@ import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { LinearClient, } from "@linear/sdk";
|
|
6
|
-
import {
|
|
6
|
+
import { watch as chokidarWatch } from "chokidar";
|
|
7
|
+
import { ClaudeRunner, createCyrusToolsServer, createImageToolsServer, createSoraToolsServer, getAllTools, getCoordinatorTools, getReadOnlyTools, getSafeTools, } from "cyrus-claude-runner";
|
|
7
8
|
import { isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueUnassignedWebhook, PersistenceManager, } from "cyrus-core";
|
|
8
9
|
import { LinearWebhookClient } from "cyrus-linear-webhook-client";
|
|
9
10
|
import { NdjsonClient } from "cyrus-ndjson-client";
|
|
10
11
|
import { fileTypeFromBuffer } from "file-type";
|
|
11
12
|
import { AgentSessionManager } from "./AgentSessionManager.js";
|
|
13
|
+
import { ProcedureRouter, } from "./procedures/index.js";
|
|
12
14
|
import { SharedApplicationServer } from "./SharedApplicationServer.js";
|
|
13
|
-
const LAST_MESSAGE_MARKER = "\n\nIMPORTANT: When providing your final summary response, include the special marker ___LAST_MESSAGE_MARKER___ at the very beginning of your message. This marker will be automatically removed before posting.";
|
|
14
15
|
/**
|
|
15
16
|
* Unified edge worker that **orchestrates**
|
|
16
17
|
* capturing Linear webhooks,
|
|
@@ -27,11 +28,21 @@ export class EdgeWorker extends EventEmitter {
|
|
|
27
28
|
sharedApplicationServer;
|
|
28
29
|
cyrusHome;
|
|
29
30
|
childToParentAgentSession = new Map(); // Maps child agentSessionId to parent agentSessionId
|
|
31
|
+
procedureRouter; // Intelligent workflow routing
|
|
32
|
+
configWatcher; // File watcher for config.json
|
|
33
|
+
configPath; // Path to config.json file
|
|
34
|
+
tokenToRepoIds = new Map(); // Maps Linear token to repository IDs using that token
|
|
30
35
|
constructor(config) {
|
|
31
36
|
super();
|
|
32
37
|
this.config = config;
|
|
33
38
|
this.cyrusHome = config.cyrusHome;
|
|
34
39
|
this.persistenceManager = new PersistenceManager(join(this.cyrusHome, "state"));
|
|
40
|
+
// Initialize procedure router with haiku model for fast classification
|
|
41
|
+
this.procedureRouter = new ProcedureRouter({
|
|
42
|
+
cyrusHome: this.cyrusHome,
|
|
43
|
+
model: "haiku",
|
|
44
|
+
timeoutMs: 10000,
|
|
45
|
+
});
|
|
35
46
|
console.log(`[EdgeWorker Constructor] Initializing parent-child session mapping system`);
|
|
36
47
|
console.log(`[EdgeWorker Constructor] Parent-child mapping initialized with 0 entries`);
|
|
37
48
|
// Initialize shared application server
|
|
@@ -67,40 +78,48 @@ export class EdgeWorker extends EventEmitter {
|
|
|
67
78
|
console.log(`[Parent-Child Lookup] Child ${childSessionId} -> Parent ${parentId || "not found"}`);
|
|
68
79
|
return parentId;
|
|
69
80
|
}, async (parentSessionId, prompt, childSessionId) => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
if (!
|
|
76
|
-
console.error(`[
|
|
81
|
+
await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
|
|
82
|
+
}, async (linearAgentActivitySessionId) => {
|
|
83
|
+
console.log(`[Subroutine Transition] Advancing to next subroutine for session ${linearAgentActivitySessionId}`);
|
|
84
|
+
// Get the session
|
|
85
|
+
const session = agentSessionManager.getSession(linearAgentActivitySessionId);
|
|
86
|
+
if (!session) {
|
|
87
|
+
console.error(`[Subroutine Transition] Session ${linearAgentActivitySessionId} not found`);
|
|
77
88
|
return;
|
|
78
89
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
childWorkspaceDirs.push(childSession.workspace.path);
|
|
85
|
-
console.log(`[Parent Session Resume] Adding child workspace to parent allowed directories: ${childSession.workspace.path}`);
|
|
90
|
+
// Get next subroutine (advancement already handled by AgentSessionManager)
|
|
91
|
+
const nextSubroutine = this.procedureRouter.getCurrentSubroutine(session);
|
|
92
|
+
if (!nextSubroutine) {
|
|
93
|
+
console.log(`[Subroutine Transition] Procedure complete for session ${linearAgentActivitySessionId}`);
|
|
94
|
+
return;
|
|
86
95
|
}
|
|
87
|
-
|
|
88
|
-
|
|
96
|
+
console.log(`[Subroutine Transition] Next subroutine: ${nextSubroutine.name}`);
|
|
97
|
+
// Load subroutine prompt
|
|
98
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
99
|
+
const __dirname = dirname(__filename);
|
|
100
|
+
const subroutinePromptPath = join(__dirname, "prompts", nextSubroutine.promptPath);
|
|
101
|
+
let subroutinePrompt;
|
|
102
|
+
try {
|
|
103
|
+
subroutinePrompt = await readFile(subroutinePromptPath, "utf-8");
|
|
104
|
+
console.log(`[Subroutine Transition] Loaded ${nextSubroutine.name} subroutine prompt (${subroutinePrompt.length} characters)`);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
console.error(`[Subroutine Transition] Failed to load subroutine prompt from ${subroutinePromptPath}:`, error);
|
|
108
|
+
// Fallback to simple prompt
|
|
109
|
+
subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
|
|
89
110
|
}
|
|
90
|
-
|
|
91
|
-
// Resume the parent session with the child's result
|
|
92
|
-
console.log(`[Parent Session Resume] Resuming parent Claude session with child results`);
|
|
111
|
+
// Resume Claude session with subroutine prompt
|
|
93
112
|
try {
|
|
94
|
-
await this.resumeClaudeSession(
|
|
113
|
+
await this.resumeClaudeSession(session, repo, linearAgentActivitySessionId, agentSessionManager, subroutinePrompt, "", // No attachment manifest
|
|
95
114
|
false, // Not a new session
|
|
96
|
-
|
|
97
|
-
|
|
115
|
+
[], // No additional allowed directories
|
|
116
|
+
nextSubroutine.maxTurns);
|
|
117
|
+
console.log(`[Subroutine Transition] Successfully resumed session for ${nextSubroutine.name} subroutine${nextSubroutine.maxTurns ? ` (maxTurns=${nextSubroutine.maxTurns})` : ""}`);
|
|
98
118
|
}
|
|
99
119
|
catch (error) {
|
|
100
|
-
console.error(`[
|
|
101
|
-
console.error(`[Parent Session Resume] Error context - Parent issue: ${parentSession.issueId}, Repository: ${repo.name}`);
|
|
120
|
+
console.error(`[Subroutine Transition] Failed to resume session for ${nextSubroutine.name} subroutine:`, error);
|
|
102
121
|
}
|
|
103
|
-
});
|
|
122
|
+
}, this.procedureRouter, this.sharedApplicationServer);
|
|
104
123
|
this.agentSessionManagers.set(repo.id, agentSessionManager);
|
|
105
124
|
}
|
|
106
125
|
}
|
|
@@ -110,6 +129,12 @@ export class EdgeWorker extends EventEmitter {
|
|
|
110
129
|
const repos = tokenToRepos.get(repo.linearToken) || [];
|
|
111
130
|
repos.push(repo);
|
|
112
131
|
tokenToRepos.set(repo.linearToken, repos);
|
|
132
|
+
// Track token-to-repo-id mapping for dynamic config updates
|
|
133
|
+
const repoIds = this.tokenToRepoIds.get(repo.linearToken) || [];
|
|
134
|
+
if (!repoIds.includes(repo.id)) {
|
|
135
|
+
repoIds.push(repo.id);
|
|
136
|
+
}
|
|
137
|
+
this.tokenToRepoIds.set(repo.linearToken, repoIds);
|
|
113
138
|
}
|
|
114
139
|
// Create one NDJSON client per unique token using shared application server
|
|
115
140
|
for (const [token, repos] of tokenToRepos) {
|
|
@@ -144,12 +169,20 @@ export class EdgeWorker extends EventEmitter {
|
|
|
144
169
|
const ndjsonClient = useLinearDirectWebhooks
|
|
145
170
|
? new LinearWebhookClient({
|
|
146
171
|
...clientConfig,
|
|
147
|
-
onWebhook: (payload) =>
|
|
172
|
+
onWebhook: (payload) => {
|
|
173
|
+
// Get fresh repositories for this token to avoid stale closures
|
|
174
|
+
const freshRepos = this.getRepositoriesForToken(token);
|
|
175
|
+
this.handleWebhook(payload, freshRepos);
|
|
176
|
+
},
|
|
148
177
|
})
|
|
149
178
|
: new NdjsonClient(clientConfig);
|
|
150
179
|
// Set up webhook handler for NdjsonClient (LinearWebhookClient uses onWebhook in constructor)
|
|
151
180
|
if (!useLinearDirectWebhooks) {
|
|
152
|
-
ndjsonClient.on("webhook", (data) =>
|
|
181
|
+
ndjsonClient.on("webhook", (data) => {
|
|
182
|
+
// Get fresh repositories for this token to avoid stale closures
|
|
183
|
+
const freshRepos = this.getRepositoriesForToken(token);
|
|
184
|
+
this.handleWebhook(data, freshRepos);
|
|
185
|
+
});
|
|
153
186
|
}
|
|
154
187
|
// Optional heartbeat logging (only for NdjsonClient)
|
|
155
188
|
if (process.env.DEBUG_EDGE === "true" && !useLinearDirectWebhooks) {
|
|
@@ -168,6 +201,10 @@ export class EdgeWorker extends EventEmitter {
|
|
|
168
201
|
async start() {
|
|
169
202
|
// Load persisted state for each repository
|
|
170
203
|
await this.loadPersistedState();
|
|
204
|
+
// Start config file watcher if configPath is provided
|
|
205
|
+
if (this.configPath) {
|
|
206
|
+
this.startConfigWatcher();
|
|
207
|
+
}
|
|
171
208
|
// Start shared application server first
|
|
172
209
|
await this.sharedApplicationServer.start();
|
|
173
210
|
// Connect all NDJSON clients
|
|
@@ -219,6 +256,12 @@ export class EdgeWorker extends EventEmitter {
|
|
|
219
256
|
* Stop the edge worker
|
|
220
257
|
*/
|
|
221
258
|
async stop() {
|
|
259
|
+
// Stop config file watcher
|
|
260
|
+
if (this.configWatcher) {
|
|
261
|
+
await this.configWatcher.close();
|
|
262
|
+
this.configWatcher = undefined;
|
|
263
|
+
console.log("✅ Config file watcher stopped");
|
|
264
|
+
}
|
|
222
265
|
try {
|
|
223
266
|
await this.savePersistedState();
|
|
224
267
|
console.log("✅ EdgeWorker state saved successfully");
|
|
@@ -249,6 +292,450 @@ export class EdgeWorker extends EventEmitter {
|
|
|
249
292
|
// Stop shared application server
|
|
250
293
|
await this.sharedApplicationServer.stop();
|
|
251
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Set the config file path for dynamic reloading
|
|
297
|
+
*/
|
|
298
|
+
setConfigPath(configPath) {
|
|
299
|
+
this.configPath = configPath;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Get fresh list of repositories for a given Linear token
|
|
303
|
+
* This ensures webhook handlers always work with current repository state
|
|
304
|
+
*/
|
|
305
|
+
getRepositoriesForToken(token) {
|
|
306
|
+
const repoIds = this.tokenToRepoIds.get(token) || [];
|
|
307
|
+
const repos = [];
|
|
308
|
+
for (const repoId of repoIds) {
|
|
309
|
+
const repo = this.repositories.get(repoId);
|
|
310
|
+
if (repo) {
|
|
311
|
+
repos.push(repo);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return repos;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Handle resuming a parent session when a child session completes
|
|
318
|
+
* This is the core logic used by the resume parent session callback
|
|
319
|
+
* Extracted to reduce duplication between constructor and addNewRepositories
|
|
320
|
+
*/
|
|
321
|
+
async handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager) {
|
|
322
|
+
console.log(`[Parent Session Resume] Child session completed, resuming parent session ${parentSessionId}`);
|
|
323
|
+
// Get the parent session and repository
|
|
324
|
+
console.log(`[Parent Session Resume] Retrieving parent session ${parentSessionId} from agent session manager`);
|
|
325
|
+
const parentSession = agentSessionManager.getSession(parentSessionId);
|
|
326
|
+
if (!parentSession) {
|
|
327
|
+
console.error(`[Parent Session Resume] Parent session ${parentSessionId} not found in agent session manager`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
console.log(`[Parent Session Resume] Found parent session - Issue: ${parentSession.issueId}, Workspace: ${parentSession.workspace.path}`);
|
|
331
|
+
// Get the child session to access its workspace path
|
|
332
|
+
const childSession = agentSessionManager.getSession(childSessionId);
|
|
333
|
+
const childWorkspaceDirs = [];
|
|
334
|
+
if (childSession) {
|
|
335
|
+
childWorkspaceDirs.push(childSession.workspace.path);
|
|
336
|
+
console.log(`[Parent Session Resume] Adding child workspace to parent allowed directories: ${childSession.workspace.path}`);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
console.warn(`[Parent Session Resume] Could not find child session ${childSessionId} to add workspace to parent allowed directories`);
|
|
340
|
+
}
|
|
341
|
+
await this.postParentResumeAcknowledgment(parentSessionId, repo.id);
|
|
342
|
+
// Resume the parent session with the child's result
|
|
343
|
+
console.log(`[Parent Session Resume] Resuming parent Claude session with child results`);
|
|
344
|
+
try {
|
|
345
|
+
await this.resumeClaudeSession(parentSession, repo, parentSessionId, agentSessionManager, prompt, "", // No attachment manifest for child results
|
|
346
|
+
false, // Not a new session
|
|
347
|
+
childWorkspaceDirs);
|
|
348
|
+
console.log(`[Parent Session Resume] Successfully resumed parent session ${parentSessionId} with child results`);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
console.error(`[Parent Session Resume] Failed to resume parent session ${parentSessionId}:`, error);
|
|
352
|
+
console.error(`[Parent Session Resume] Error context - Parent issue: ${parentSession.issueId}, Repository: ${repo.name}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Start watching config file for changes
|
|
357
|
+
*/
|
|
358
|
+
startConfigWatcher() {
|
|
359
|
+
if (!this.configPath) {
|
|
360
|
+
console.warn("⚠️ No config path set, skipping config file watcher");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
console.log(`👀 Watching config file for changes: ${this.configPath}`);
|
|
364
|
+
this.configWatcher = chokidarWatch(this.configPath, {
|
|
365
|
+
persistent: true,
|
|
366
|
+
ignoreInitial: true,
|
|
367
|
+
awaitWriteFinish: {
|
|
368
|
+
stabilityThreshold: 500,
|
|
369
|
+
pollInterval: 100,
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
this.configWatcher.on("change", async () => {
|
|
373
|
+
console.log("🔄 Config file changed, reloading...");
|
|
374
|
+
await this.handleConfigChange();
|
|
375
|
+
});
|
|
376
|
+
this.configWatcher.on("error", (error) => {
|
|
377
|
+
console.error("❌ Config watcher error:", error);
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Handle configuration file changes
|
|
382
|
+
*/
|
|
383
|
+
async handleConfigChange() {
|
|
384
|
+
try {
|
|
385
|
+
const newConfig = await this.loadConfigSafely();
|
|
386
|
+
if (!newConfig) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const changes = this.detectRepositoryChanges(newConfig);
|
|
390
|
+
if (changes.added.length === 0 &&
|
|
391
|
+
changes.modified.length === 0 &&
|
|
392
|
+
changes.removed.length === 0) {
|
|
393
|
+
console.log("ℹ️ No repository changes detected");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
console.log(`📊 Repository changes detected: ${changes.added.length} added, ${changes.modified.length} modified, ${changes.removed.length} removed`);
|
|
397
|
+
// Apply changes incrementally
|
|
398
|
+
await this.removeDeletedRepositories(changes.removed);
|
|
399
|
+
await this.updateModifiedRepositories(changes.modified);
|
|
400
|
+
await this.addNewRepositories(changes.added);
|
|
401
|
+
// Update config reference
|
|
402
|
+
this.config = newConfig;
|
|
403
|
+
console.log("✅ Configuration reloaded successfully");
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
console.error("❌ Failed to reload configuration:", error);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Safely load configuration from file with validation
|
|
411
|
+
*/
|
|
412
|
+
async loadConfigSafely() {
|
|
413
|
+
try {
|
|
414
|
+
if (!this.configPath) {
|
|
415
|
+
console.error("❌ No config path set");
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
const configContent = await readFile(this.configPath, "utf-8");
|
|
419
|
+
const parsedConfig = JSON.parse(configContent);
|
|
420
|
+
// Merge with current EdgeWorker config structure
|
|
421
|
+
const newConfig = {
|
|
422
|
+
...this.config,
|
|
423
|
+
repositories: parsedConfig.repositories || [],
|
|
424
|
+
ngrokAuthToken: parsedConfig.ngrokAuthToken || this.config.ngrokAuthToken,
|
|
425
|
+
defaultModel: parsedConfig.defaultModel || this.config.defaultModel,
|
|
426
|
+
defaultFallbackModel: parsedConfig.defaultFallbackModel || this.config.defaultFallbackModel,
|
|
427
|
+
defaultAllowedTools: parsedConfig.defaultAllowedTools || this.config.defaultAllowedTools,
|
|
428
|
+
defaultDisallowedTools: parsedConfig.defaultDisallowedTools ||
|
|
429
|
+
this.config.defaultDisallowedTools,
|
|
430
|
+
};
|
|
431
|
+
// Basic validation
|
|
432
|
+
if (!Array.isArray(newConfig.repositories)) {
|
|
433
|
+
console.error("❌ Invalid config: repositories must be an array");
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
// Validate each repository has required fields
|
|
437
|
+
for (const repo of newConfig.repositories) {
|
|
438
|
+
if (!repo.id ||
|
|
439
|
+
!repo.name ||
|
|
440
|
+
!repo.repositoryPath ||
|
|
441
|
+
!repo.baseBranch) {
|
|
442
|
+
console.error(`❌ Invalid repository config: missing required fields (id, name, repositoryPath, baseBranch)`, repo);
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return newConfig;
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
console.error("❌ Failed to load config file:", error);
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Detect changes between current and new repository configurations
|
|
455
|
+
*/
|
|
456
|
+
detectRepositoryChanges(newConfig) {
|
|
457
|
+
const currentRepos = new Map(this.repositories);
|
|
458
|
+
const newRepos = new Map(newConfig.repositories.map((r) => [r.id, r]));
|
|
459
|
+
const added = [];
|
|
460
|
+
const modified = [];
|
|
461
|
+
const removed = [];
|
|
462
|
+
// Find added and modified repositories
|
|
463
|
+
for (const [id, repo] of newRepos) {
|
|
464
|
+
if (!currentRepos.has(id)) {
|
|
465
|
+
added.push(repo);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
const currentRepo = currentRepos.get(id);
|
|
469
|
+
if (currentRepo && !this.deepEqual(currentRepo, repo)) {
|
|
470
|
+
modified.push(repo);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Find removed repositories
|
|
475
|
+
for (const [id, repo] of currentRepos) {
|
|
476
|
+
if (!newRepos.has(id)) {
|
|
477
|
+
removed.push(repo);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return { added, modified, removed };
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Deep equality check for repository configs
|
|
484
|
+
*/
|
|
485
|
+
deepEqual(obj1, obj2) {
|
|
486
|
+
return JSON.stringify(obj1) === JSON.stringify(obj2);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Add new repositories to the running EdgeWorker
|
|
490
|
+
*/
|
|
491
|
+
async addNewRepositories(repos) {
|
|
492
|
+
for (const repo of repos) {
|
|
493
|
+
if (repo.isActive === false) {
|
|
494
|
+
console.log(`⏭️ Skipping inactive repository: ${repo.name}`);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
try {
|
|
498
|
+
console.log(`➕ Adding repository: ${repo.name} (${repo.id})`);
|
|
499
|
+
// Add to internal map
|
|
500
|
+
this.repositories.set(repo.id, repo);
|
|
501
|
+
// Create Linear client
|
|
502
|
+
const linearClient = new LinearClient({
|
|
503
|
+
accessToken: repo.linearToken,
|
|
504
|
+
});
|
|
505
|
+
this.linearClients.set(repo.id, linearClient);
|
|
506
|
+
// Create AgentSessionManager with same pattern as constructor
|
|
507
|
+
const agentSessionManager = new AgentSessionManager(linearClient, (childSessionId) => {
|
|
508
|
+
return this.childToParentAgentSession.get(childSessionId);
|
|
509
|
+
}, async (parentSessionId, prompt, childSessionId) => {
|
|
510
|
+
await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
|
|
511
|
+
}, undefined, // No resumeNextSubroutine callback for dynamically added repos
|
|
512
|
+
this.procedureRouter, this.sharedApplicationServer);
|
|
513
|
+
this.agentSessionManagers.set(repo.id, agentSessionManager);
|
|
514
|
+
// Update token-to-repo mapping
|
|
515
|
+
const repoIds = this.tokenToRepoIds.get(repo.linearToken) || [];
|
|
516
|
+
if (!repoIds.includes(repo.id)) {
|
|
517
|
+
repoIds.push(repo.id);
|
|
518
|
+
}
|
|
519
|
+
this.tokenToRepoIds.set(repo.linearToken, repoIds);
|
|
520
|
+
// Set up webhook listener
|
|
521
|
+
await this.setupWebhookListener(repo);
|
|
522
|
+
console.log(`✅ Repository added successfully: ${repo.name}`);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
console.error(`❌ Failed to add repository ${repo.name}:`, error);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Update existing repositories
|
|
531
|
+
*/
|
|
532
|
+
async updateModifiedRepositories(repos) {
|
|
533
|
+
for (const repo of repos) {
|
|
534
|
+
try {
|
|
535
|
+
const oldRepo = this.repositories.get(repo.id);
|
|
536
|
+
if (!oldRepo) {
|
|
537
|
+
console.warn(`⚠️ Repository ${repo.id} not found for update, skipping`);
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
console.log(`🔄 Updating repository: ${repo.name} (${repo.id})`);
|
|
541
|
+
// Update stored config
|
|
542
|
+
this.repositories.set(repo.id, repo);
|
|
543
|
+
// If token changed, recreate Linear client
|
|
544
|
+
if (oldRepo.linearToken !== repo.linearToken) {
|
|
545
|
+
console.log(` 🔑 Token changed, recreating Linear client`);
|
|
546
|
+
const linearClient = new LinearClient({
|
|
547
|
+
accessToken: repo.linearToken,
|
|
548
|
+
});
|
|
549
|
+
this.linearClients.set(repo.id, linearClient);
|
|
550
|
+
// Update token mapping
|
|
551
|
+
const oldRepoIds = this.tokenToRepoIds.get(oldRepo.linearToken) || [];
|
|
552
|
+
const filteredOldIds = oldRepoIds.filter((id) => id !== repo.id);
|
|
553
|
+
if (filteredOldIds.length > 0) {
|
|
554
|
+
this.tokenToRepoIds.set(oldRepo.linearToken, filteredOldIds);
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
this.tokenToRepoIds.delete(oldRepo.linearToken);
|
|
558
|
+
}
|
|
559
|
+
const newRepoIds = this.tokenToRepoIds.get(repo.linearToken) || [];
|
|
560
|
+
if (!newRepoIds.includes(repo.id)) {
|
|
561
|
+
newRepoIds.push(repo.id);
|
|
562
|
+
}
|
|
563
|
+
this.tokenToRepoIds.set(repo.linearToken, newRepoIds);
|
|
564
|
+
// Reconnect webhook if needed
|
|
565
|
+
await this.reconnectWebhook(oldRepo, repo);
|
|
566
|
+
}
|
|
567
|
+
// If active status changed
|
|
568
|
+
if (oldRepo.isActive !== repo.isActive) {
|
|
569
|
+
if (repo.isActive === false) {
|
|
570
|
+
console.log(` ⏸️ Repository set to inactive - existing sessions will continue`);
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
console.log(` ▶️ Repository reactivated`);
|
|
574
|
+
await this.setupWebhookListener(repo);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
console.log(`✅ Repository updated successfully: ${repo.name}`);
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
console.error(`❌ Failed to update repository ${repo.name}:`, error);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Remove deleted repositories
|
|
586
|
+
*/
|
|
587
|
+
async removeDeletedRepositories(repos) {
|
|
588
|
+
for (const repo of repos) {
|
|
589
|
+
try {
|
|
590
|
+
console.log(`🗑️ Removing repository: ${repo.name} (${repo.id})`);
|
|
591
|
+
// Check for active sessions
|
|
592
|
+
const manager = this.agentSessionManagers.get(repo.id);
|
|
593
|
+
const activeSessions = manager?.getActiveSessions() || [];
|
|
594
|
+
if (activeSessions.length > 0) {
|
|
595
|
+
console.warn(` ⚠️ Repository has ${activeSessions.length} active sessions - stopping them`);
|
|
596
|
+
// Stop all active sessions and notify Linear
|
|
597
|
+
for (const session of activeSessions) {
|
|
598
|
+
try {
|
|
599
|
+
console.log(` 🛑 Stopping session for issue ${session.issueId}`);
|
|
600
|
+
// Get the Claude runner for this session
|
|
601
|
+
const runner = manager?.getClaudeRunner(session.linearAgentActivitySessionId);
|
|
602
|
+
if (runner) {
|
|
603
|
+
// Stop the Claude process
|
|
604
|
+
runner.stop();
|
|
605
|
+
console.log(` ✅ Stopped Claude runner for session ${session.linearAgentActivitySessionId}`);
|
|
606
|
+
}
|
|
607
|
+
// Post cancellation message to Linear
|
|
608
|
+
const linearClient = this.linearClients.get(repo.id);
|
|
609
|
+
if (linearClient) {
|
|
610
|
+
await linearClient.createAgentActivity({
|
|
611
|
+
agentSessionId: session.linearAgentActivitySessionId,
|
|
612
|
+
content: {
|
|
613
|
+
type: "response",
|
|
614
|
+
body: `**Repository Removed from Configuration**\n\nThis repository (\`${repo.name}\`) has been removed from the Cyrus configuration. All active sessions for this repository have been stopped.\n\nIf you need to continue working on this issue, please contact your administrator to restore the repository configuration.`,
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
console.log(` 📤 Posted cancellation message to Linear for issue ${session.issueId}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
console.error(` ❌ Failed to stop session ${session.linearAgentActivitySessionId}:`, error);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// Remove repository from all maps
|
|
626
|
+
this.repositories.delete(repo.id);
|
|
627
|
+
this.linearClients.delete(repo.id);
|
|
628
|
+
this.agentSessionManagers.delete(repo.id);
|
|
629
|
+
// Update token mapping
|
|
630
|
+
const repoIds = this.tokenToRepoIds.get(repo.linearToken) || [];
|
|
631
|
+
const filteredIds = repoIds.filter((id) => id !== repo.id);
|
|
632
|
+
if (filteredIds.length > 0) {
|
|
633
|
+
this.tokenToRepoIds.set(repo.linearToken, filteredIds);
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
this.tokenToRepoIds.delete(repo.linearToken);
|
|
637
|
+
}
|
|
638
|
+
// Clean up webhook listener if no other repos use the same token
|
|
639
|
+
await this.cleanupWebhookIfUnused(repo);
|
|
640
|
+
console.log(`✅ Repository removed successfully: ${repo.name}`);
|
|
641
|
+
}
|
|
642
|
+
catch (error) {
|
|
643
|
+
console.error(`❌ Failed to remove repository ${repo.name}:`, error);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Set up webhook listener for a repository
|
|
649
|
+
*/
|
|
650
|
+
async setupWebhookListener(repo) {
|
|
651
|
+
// Check if we already have a client for this token
|
|
652
|
+
const existingRepoIds = this.tokenToRepoIds.get(repo.linearToken) || [];
|
|
653
|
+
const existingClient = existingRepoIds.length > 0
|
|
654
|
+
? this.ndjsonClients.get(existingRepoIds[0] || "")
|
|
655
|
+
: null;
|
|
656
|
+
if (existingClient) {
|
|
657
|
+
console.log(` ℹ️ Reusing existing webhook connection for token ...${repo.linearToken.slice(-4)}`);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// Create new NDJSON client for this token
|
|
661
|
+
const serverPort = this.config.serverPort || this.config.webhookPort || 3456;
|
|
662
|
+
const serverHost = this.config.serverHost || "localhost";
|
|
663
|
+
const useLinearDirectWebhooks = process.env.LINEAR_DIRECT_WEBHOOKS?.toLowerCase().trim() === "true";
|
|
664
|
+
const clientConfig = {
|
|
665
|
+
proxyUrl: this.config.proxyUrl,
|
|
666
|
+
token: repo.linearToken,
|
|
667
|
+
name: repo.name,
|
|
668
|
+
transport: "webhook",
|
|
669
|
+
useExternalWebhookServer: true,
|
|
670
|
+
externalWebhookServer: this.sharedApplicationServer,
|
|
671
|
+
webhookPort: serverPort,
|
|
672
|
+
webhookPath: "/webhook",
|
|
673
|
+
webhookHost: serverHost,
|
|
674
|
+
...(this.config.baseUrl && { webhookBaseUrl: this.config.baseUrl }),
|
|
675
|
+
...(!this.config.baseUrl &&
|
|
676
|
+
this.config.webhookBaseUrl && {
|
|
677
|
+
webhookBaseUrl: this.config.webhookBaseUrl,
|
|
678
|
+
}),
|
|
679
|
+
onConnect: () => this.handleConnect(repo.id, [repo]),
|
|
680
|
+
onDisconnect: (reason) => this.handleDisconnect(repo.id, [repo], reason),
|
|
681
|
+
onError: (error) => this.handleError(error),
|
|
682
|
+
};
|
|
683
|
+
const ndjsonClient = useLinearDirectWebhooks
|
|
684
|
+
? new LinearWebhookClient({
|
|
685
|
+
...clientConfig,
|
|
686
|
+
onWebhook: (payload) => {
|
|
687
|
+
// Get fresh repositories for this token to avoid stale closures
|
|
688
|
+
const freshRepos = this.getRepositoriesForToken(repo.linearToken);
|
|
689
|
+
this.handleWebhook(payload, freshRepos);
|
|
690
|
+
},
|
|
691
|
+
})
|
|
692
|
+
: new NdjsonClient(clientConfig);
|
|
693
|
+
if (!useLinearDirectWebhooks) {
|
|
694
|
+
ndjsonClient.on("webhook", (data) => {
|
|
695
|
+
// Get fresh repositories for this token to avoid stale closures
|
|
696
|
+
const freshRepos = this.getRepositoriesForToken(repo.linearToken);
|
|
697
|
+
this.handleWebhook(data, freshRepos);
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
this.ndjsonClients.set(repo.id, ndjsonClient);
|
|
701
|
+
// Connect the client
|
|
702
|
+
try {
|
|
703
|
+
await ndjsonClient.connect();
|
|
704
|
+
console.log(` ✅ Webhook listener connected for ${repo.name}`);
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
console.error(` ❌ Failed to connect webhook listener:`, error);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Reconnect webhook when token changes
|
|
712
|
+
*/
|
|
713
|
+
async reconnectWebhook(oldRepo, newRepo) {
|
|
714
|
+
console.log(` 🔌 Reconnecting webhook due to token change`);
|
|
715
|
+
// Disconnect old client if no other repos use it
|
|
716
|
+
await this.cleanupWebhookIfUnused(oldRepo);
|
|
717
|
+
// Set up new connection
|
|
718
|
+
await this.setupWebhookListener(newRepo);
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Clean up webhook listener if no other repositories use the token
|
|
722
|
+
*/
|
|
723
|
+
async cleanupWebhookIfUnused(repo) {
|
|
724
|
+
const repoIds = this.tokenToRepoIds.get(repo.linearToken) || [];
|
|
725
|
+
const otherRepos = repoIds.filter((id) => id !== repo.id);
|
|
726
|
+
if (otherRepos.length === 0) {
|
|
727
|
+
// No other repos use this token, safe to disconnect
|
|
728
|
+
const client = this.ndjsonClients.get(repo.id);
|
|
729
|
+
if (client) {
|
|
730
|
+
console.log(` 🔌 Disconnecting webhook for token ...${repo.linearToken.slice(-4)}`);
|
|
731
|
+
client.disconnect();
|
|
732
|
+
this.ndjsonClients.delete(repo.id);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
console.log(` ℹ️ Token still used by ${otherRepos.length} other repository(ies), keeping connection`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
252
739
|
/**
|
|
253
740
|
* Handle connection established
|
|
254
741
|
*/
|
|
@@ -563,8 +1050,62 @@ export class EdgeWorker extends EventEmitter {
|
|
|
563
1050
|
const sessionData = await this.createLinearAgentSession(linearAgentActivitySessionId, issue, repository, agentSessionManager);
|
|
564
1051
|
// Destructure the session data (excluding allowedTools which we'll build with promptType)
|
|
565
1052
|
const { session, fullIssue, workspace: _workspace, attachmentResult, attachmentsDir: _attachmentsDir, allowedDirectories, } = sessionData;
|
|
566
|
-
//
|
|
1053
|
+
// Initialize procedure metadata using intelligent routing
|
|
1054
|
+
if (!session.metadata) {
|
|
1055
|
+
session.metadata = {};
|
|
1056
|
+
}
|
|
1057
|
+
// Post ephemeral "Routing..." thought
|
|
1058
|
+
await agentSessionManager.postRoutingThought(linearAgentActivitySessionId);
|
|
1059
|
+
// Fetch labels early (needed for label override check)
|
|
567
1060
|
const labels = await this.fetchIssueLabels(fullIssue);
|
|
1061
|
+
// Check for label overrides BEFORE AI routing
|
|
1062
|
+
const debuggerConfig = repository.labelPrompts?.debugger;
|
|
1063
|
+
const debuggerLabels = Array.isArray(debuggerConfig)
|
|
1064
|
+
? debuggerConfig
|
|
1065
|
+
: debuggerConfig?.labels;
|
|
1066
|
+
const hasDebuggerLabel = debuggerLabels?.some((label) => labels.includes(label));
|
|
1067
|
+
const orchestratorConfig = repository.labelPrompts?.orchestrator;
|
|
1068
|
+
const orchestratorLabels = Array.isArray(orchestratorConfig)
|
|
1069
|
+
? orchestratorConfig
|
|
1070
|
+
: orchestratorConfig?.labels;
|
|
1071
|
+
const hasOrchestratorLabel = orchestratorLabels?.some((label) => labels.includes(label));
|
|
1072
|
+
let finalProcedure;
|
|
1073
|
+
let finalClassification;
|
|
1074
|
+
// If labels indicate a specific procedure, use that instead of AI routing
|
|
1075
|
+
if (hasDebuggerLabel) {
|
|
1076
|
+
const debuggerProcedure = this.procedureRouter.getProcedure("debugger-full");
|
|
1077
|
+
if (!debuggerProcedure) {
|
|
1078
|
+
throw new Error("debugger-full procedure not found in registry");
|
|
1079
|
+
}
|
|
1080
|
+
finalProcedure = debuggerProcedure;
|
|
1081
|
+
finalClassification = "debugger";
|
|
1082
|
+
console.log(`[EdgeWorker] Using debugger-full procedure due to debugger label (skipping AI routing)`);
|
|
1083
|
+
}
|
|
1084
|
+
else if (hasOrchestratorLabel) {
|
|
1085
|
+
const orchestratorProcedure = this.procedureRouter.getProcedure("orchestrator-full");
|
|
1086
|
+
if (!orchestratorProcedure) {
|
|
1087
|
+
throw new Error("orchestrator-full procedure not found in registry");
|
|
1088
|
+
}
|
|
1089
|
+
finalProcedure = orchestratorProcedure;
|
|
1090
|
+
finalClassification = "orchestrator";
|
|
1091
|
+
console.log(`[EdgeWorker] Using orchestrator-full procedure due to orchestrator label (skipping AI routing)`);
|
|
1092
|
+
}
|
|
1093
|
+
else {
|
|
1094
|
+
// No label override - use AI routing
|
|
1095
|
+
const issueDescription = `${issue.title}\n\n${fullIssue.description || ""}`.trim();
|
|
1096
|
+
const routingDecision = await this.procedureRouter.determineRoutine(issueDescription);
|
|
1097
|
+
finalProcedure = routingDecision.procedure;
|
|
1098
|
+
finalClassification = routingDecision.classification;
|
|
1099
|
+
// Log AI routing decision
|
|
1100
|
+
console.log(`[EdgeWorker] AI routing decision for ${linearAgentActivitySessionId}:`);
|
|
1101
|
+
console.log(` Classification: ${routingDecision.classification}`);
|
|
1102
|
+
console.log(` Procedure: ${finalProcedure.name}`);
|
|
1103
|
+
console.log(` Reasoning: ${routingDecision.reasoning}`);
|
|
1104
|
+
}
|
|
1105
|
+
// Initialize procedure metadata in session with final decision
|
|
1106
|
+
this.procedureRouter.initializeProcedureMetadata(session, finalProcedure);
|
|
1107
|
+
// Post single procedure selection result (replaces ephemeral routing thought)
|
|
1108
|
+
await agentSessionManager.postProcedureSelectionThought(linearAgentActivitySessionId, finalProcedure.name, finalClassification);
|
|
568
1109
|
// Only determine system prompt for delegation (not mentions) or when /label-based-prompt is requested
|
|
569
1110
|
let systemPrompt;
|
|
570
1111
|
let systemPromptVersion;
|
|
@@ -659,6 +1200,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
659
1200
|
}
|
|
660
1201
|
let session = agentSessionManager.getSession(linearAgentActivitySessionId);
|
|
661
1202
|
let isNewSession = false;
|
|
1203
|
+
let fullIssue = null;
|
|
662
1204
|
if (!session) {
|
|
663
1205
|
console.log(`[EdgeWorker] No existing session found for agent activity session ${linearAgentActivitySessionId}, creating new session`);
|
|
664
1206
|
isNewSession = true;
|
|
@@ -667,23 +1209,70 @@ export class EdgeWorker extends EventEmitter {
|
|
|
667
1209
|
// Create the session using the shared method
|
|
668
1210
|
const sessionData = await this.createLinearAgentSession(linearAgentActivitySessionId, issue, repository, agentSessionManager);
|
|
669
1211
|
// Destructure session data for new session
|
|
670
|
-
|
|
1212
|
+
fullIssue = sessionData.fullIssue;
|
|
671
1213
|
session = sessionData.session;
|
|
1214
|
+
console.log(`[EdgeWorker] Created new session ${linearAgentActivitySessionId} (prompted webhook)`);
|
|
672
1215
|
// Save state and emit events for new session
|
|
673
1216
|
await this.savePersistedState();
|
|
674
|
-
this.emit("session:started",
|
|
675
|
-
this.config.handlers?.onSessionStart?.(
|
|
1217
|
+
this.emit("session:started", fullIssue.id, fullIssue, repository.id);
|
|
1218
|
+
this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
|
|
1219
|
+
}
|
|
1220
|
+
else {
|
|
1221
|
+
console.log(`[EdgeWorker] Found existing session ${linearAgentActivitySessionId} for new user prompt`);
|
|
1222
|
+
// Post instant acknowledgment for existing session BEFORE any async work
|
|
1223
|
+
// Check streaming status first to determine the message
|
|
1224
|
+
const isCurrentlyStreaming = session?.claudeRunner?.isStreaming() || false;
|
|
1225
|
+
await this.postInstantPromptedAcknowledgment(linearAgentActivitySessionId, repository.id, isCurrentlyStreaming);
|
|
1226
|
+
// Need to fetch full issue for routing context
|
|
1227
|
+
const linearClient = this.linearClients.get(repository.id);
|
|
1228
|
+
if (linearClient) {
|
|
1229
|
+
try {
|
|
1230
|
+
fullIssue = await linearClient.issue(issue.id);
|
|
1231
|
+
}
|
|
1232
|
+
catch (error) {
|
|
1233
|
+
console.warn(`[EdgeWorker] Failed to fetch full issue for routing: ${issue.id}`, error);
|
|
1234
|
+
// Continue with degraded routing context
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
// Check if runner is actively streaming before routing
|
|
1239
|
+
const existingRunner = session?.claudeRunner;
|
|
1240
|
+
const isStreaming = existingRunner?.isStreaming() || false;
|
|
1241
|
+
// Always route procedure for new comments, UNLESS actively streaming
|
|
1242
|
+
if (!isStreaming) {
|
|
1243
|
+
// Initialize procedure metadata using intelligent routing
|
|
1244
|
+
if (!session.metadata) {
|
|
1245
|
+
session.metadata = {};
|
|
1246
|
+
}
|
|
1247
|
+
// Post ephemeral "Routing..." thought
|
|
1248
|
+
await agentSessionManager.postRoutingThought(linearAgentActivitySessionId);
|
|
1249
|
+
// For prompted events, use the actual prompt content from the user
|
|
1250
|
+
// Combine with issue context for better routing
|
|
1251
|
+
if (!fullIssue) {
|
|
1252
|
+
console.warn(`[EdgeWorker] Routing without full issue details for ${linearAgentActivitySessionId}`);
|
|
1253
|
+
}
|
|
1254
|
+
const promptBody = webhook.agentActivity.content.body;
|
|
1255
|
+
const routingDecision = await this.procedureRouter.determineRoutine(promptBody.trim());
|
|
1256
|
+
const selectedProcedure = routingDecision.procedure;
|
|
1257
|
+
// Initialize procedure metadata in session (resets for each new comment)
|
|
1258
|
+
this.procedureRouter.initializeProcedureMetadata(session, selectedProcedure);
|
|
1259
|
+
// Post procedure selection result (replaces ephemeral routing thought)
|
|
1260
|
+
await agentSessionManager.postProcedureSelectionThought(linearAgentActivitySessionId, selectedProcedure.name, routingDecision.classification);
|
|
1261
|
+
// Log routing decision
|
|
1262
|
+
console.log(`[EdgeWorker] Routing decision for ${linearAgentActivitySessionId} (prompted webhook, ${isNewSession ? "new" : "existing"} session):`);
|
|
1263
|
+
console.log(` Classification: ${routingDecision.classification}`);
|
|
1264
|
+
console.log(` Procedure: ${selectedProcedure.name}`);
|
|
1265
|
+
console.log(` Reasoning: ${routingDecision.reasoning}`);
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
console.log(`[EdgeWorker] Skipping routing for ${linearAgentActivitySessionId} - runner is actively streaming`);
|
|
676
1269
|
}
|
|
677
1270
|
// Ensure session is not null after creation/retrieval
|
|
678
1271
|
if (!session) {
|
|
679
1272
|
throw new Error(`Failed to get or create session for agent activity session ${linearAgentActivitySessionId}`);
|
|
680
1273
|
}
|
|
681
|
-
//
|
|
682
|
-
|
|
683
|
-
if (!isNewSession) {
|
|
684
|
-
// Only post acknowledgment for existing sessions (new sessions already handled it above)
|
|
685
|
-
await this.postInstantPromptedAcknowledgment(linearAgentActivitySessionId, repository.id, existingRunner?.isStreaming() || false);
|
|
686
|
-
}
|
|
1274
|
+
// Acknowledgment already posted above for both new and existing sessions
|
|
1275
|
+
// (before any async routing work to ensure instant user feedback)
|
|
687
1276
|
// Get Linear client for this repository
|
|
688
1277
|
const linearClient = this.linearClients.get(repository.id);
|
|
689
1278
|
if (!linearClient) {
|
|
@@ -747,7 +1336,6 @@ export class EdgeWorker extends EventEmitter {
|
|
|
747
1336
|
if (attachmentManifest) {
|
|
748
1337
|
fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
|
|
749
1338
|
}
|
|
750
|
-
fullPrompt = `${fullPrompt}${LAST_MESSAGE_MARKER}`;
|
|
751
1339
|
existingRunner.addStreamMessage(fullPrompt);
|
|
752
1340
|
return; // Exit early - comment has been added to stream
|
|
753
1341
|
}
|
|
@@ -977,7 +1565,6 @@ export class EdgeWorker extends EventEmitter {
|
|
|
977
1565
|
console.log(`[EdgeWorker] Adding attachment manifest to label-based prompt, length: ${attachmentManifest.length} characters`);
|
|
978
1566
|
prompt = `${prompt}\n\n${attachmentManifest}`;
|
|
979
1567
|
}
|
|
980
|
-
prompt = `${prompt}${LAST_MESSAGE_MARKER}`;
|
|
981
1568
|
console.log(`[EdgeWorker] Label-based prompt built successfully, length: ${prompt.length} characters`);
|
|
982
1569
|
return { prompt, version: templateVersion };
|
|
983
1570
|
}
|
|
@@ -1021,7 +1608,6 @@ IMPORTANT: You were specifically mentioned in the comment above. Focus on addres
|
|
|
1021
1608
|
if (attachmentManifest) {
|
|
1022
1609
|
prompt = `${prompt}\n\n${attachmentManifest}`;
|
|
1023
1610
|
}
|
|
1024
|
-
prompt = `${prompt}${LAST_MESSAGE_MARKER}`;
|
|
1025
1611
|
return { prompt };
|
|
1026
1612
|
}
|
|
1027
1613
|
catch (error) {
|
|
@@ -1342,7 +1928,6 @@ IMPORTANT: Focus specifically on addressing the new comment above. This is a new
|
|
|
1342
1928
|
console.log(`[EdgeWorker] Adding repository-specific instruction`);
|
|
1343
1929
|
prompt = `${prompt}\n\n<repository-specific-instruction>\n${repository.appendInstruction}\n</repository-specific-instruction>`;
|
|
1344
1930
|
}
|
|
1345
|
-
prompt = `${prompt}${LAST_MESSAGE_MARKER}`;
|
|
1346
1931
|
console.log(`[EdgeWorker] Final prompt length: ${prompt.length} characters`);
|
|
1347
1932
|
return { prompt, version: templateVersion };
|
|
1348
1933
|
}
|
|
@@ -1366,7 +1951,7 @@ Branch: ${issue.branchName}
|
|
|
1366
1951
|
Working directory: ${repository.repositoryPath}
|
|
1367
1952
|
Base branch: ${baseBranch}
|
|
1368
1953
|
|
|
1369
|
-
${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please analyze this issue and help implement a solution
|
|
1954
|
+
${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please analyze this issue and help implement a solution.`;
|
|
1370
1955
|
return { prompt: fallbackPrompt, version: undefined };
|
|
1371
1956
|
}
|
|
1372
1957
|
}
|
|
@@ -1877,13 +2462,13 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
|
|
|
1877
2462
|
*/
|
|
1878
2463
|
buildMcpConfig(repository, parentSessionId) {
|
|
1879
2464
|
// Always inject the Linear MCP servers with the repository's token
|
|
2465
|
+
// https://linear.app/docs/mcp
|
|
1880
2466
|
const mcpConfig = {
|
|
1881
2467
|
linear: {
|
|
1882
|
-
type: "
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
LINEAR_API_TOKEN: repository.linearToken,
|
|
2468
|
+
type: "http",
|
|
2469
|
+
url: "https://mcp.linear.app/mcp",
|
|
2470
|
+
headers: {
|
|
2471
|
+
Authorization: `Bearer ${repository.linearToken}`,
|
|
1887
2472
|
},
|
|
1888
2473
|
},
|
|
1889
2474
|
"cyrus-tools": createCyrusToolsServer(repository.linearToken, {
|
|
@@ -1939,6 +2524,20 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
|
|
|
1939
2524
|
},
|
|
1940
2525
|
}),
|
|
1941
2526
|
};
|
|
2527
|
+
// Add OpenAI-based MCP servers if API key is configured
|
|
2528
|
+
if (repository.openaiApiKey) {
|
|
2529
|
+
// Sora video generation tools
|
|
2530
|
+
mcpConfig["sora-tools"] = createSoraToolsServer({
|
|
2531
|
+
apiKey: repository.openaiApiKey,
|
|
2532
|
+
outputDirectory: repository.openaiOutputDirectory,
|
|
2533
|
+
});
|
|
2534
|
+
// GPT Image generation tools
|
|
2535
|
+
mcpConfig["image-tools"] = createImageToolsServer({
|
|
2536
|
+
apiKey: repository.openaiApiKey,
|
|
2537
|
+
outputDirectory: repository.openaiOutputDirectory,
|
|
2538
|
+
});
|
|
2539
|
+
console.log(`[EdgeWorker] Configured OpenAI MCP servers (Sora + GPT Image) for repository: ${repository.name}`);
|
|
2540
|
+
}
|
|
1942
2541
|
return mcpConfig;
|
|
1943
2542
|
}
|
|
1944
2543
|
/**
|
|
@@ -1977,13 +2576,13 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
|
|
|
1977
2576
|
const manifestSuffix = attachmentManifest
|
|
1978
2577
|
? `\n\n${attachmentManifest}`
|
|
1979
2578
|
: "";
|
|
1980
|
-
return `${promptBody}${manifestSuffix}
|
|
2579
|
+
return `${promptBody}${manifestSuffix}`;
|
|
1981
2580
|
}
|
|
1982
2581
|
}
|
|
1983
2582
|
/**
|
|
1984
2583
|
* Build Claude runner configuration with common settings
|
|
1985
2584
|
*/
|
|
1986
|
-
buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels) {
|
|
2585
|
+
buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, maxTurns) {
|
|
1987
2586
|
// Configure PostToolUse hook for playwright screenshots
|
|
1988
2587
|
const hooks = {
|
|
1989
2588
|
PostToolUse: [
|
|
@@ -2043,7 +2642,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
|
|
|
2043
2642
|
cyrusHome: this.cyrusHome,
|
|
2044
2643
|
mcpConfigPath: repository.mcpConfigPath,
|
|
2045
2644
|
mcpConfig: this.buildMcpConfig(repository, linearAgentActivitySessionId),
|
|
2046
|
-
appendSystemPrompt:
|
|
2645
|
+
appendSystemPrompt: systemPrompt || "",
|
|
2047
2646
|
// Priority order: label override > repository config > global default
|
|
2048
2647
|
model: modelOverride || repository.model || this.config.defaultModel,
|
|
2049
2648
|
fallbackModel: fallbackModelOverride ||
|
|
@@ -2058,6 +2657,9 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
|
|
|
2058
2657
|
if (resumeSessionId) {
|
|
2059
2658
|
config.resumeSessionId = resumeSessionId;
|
|
2060
2659
|
}
|
|
2660
|
+
if (maxTurns !== undefined) {
|
|
2661
|
+
config.maxTurns = maxTurns;
|
|
2662
|
+
}
|
|
2061
2663
|
return config;
|
|
2062
2664
|
}
|
|
2063
2665
|
/**
|
|
@@ -2373,7 +2975,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
|
|
|
2373
2975
|
* @param attachmentManifest Optional attachment manifest
|
|
2374
2976
|
* @param isNewSession Whether this is a new session
|
|
2375
2977
|
*/
|
|
2376
|
-
async resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest = "", isNewSession = false, additionalAllowedDirectories = []) {
|
|
2978
|
+
async resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest = "", isNewSession = false, additionalAllowedDirectories = [], maxTurns) {
|
|
2377
2979
|
// Check for existing runner
|
|
2378
2980
|
const existingRunner = session.claudeRunner;
|
|
2379
2981
|
// If there's an existing streaming runner, add to it
|
|
@@ -2382,7 +2984,6 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
|
|
|
2382
2984
|
if (attachmentManifest) {
|
|
2383
2985
|
fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
|
|
2384
2986
|
}
|
|
2385
|
-
fullPrompt = `${fullPrompt}${LAST_MESSAGE_MARKER}`;
|
|
2386
2987
|
existingRunner.addStreamMessage(fullPrompt);
|
|
2387
2988
|
return;
|
|
2388
2989
|
}
|
|
@@ -2415,7 +3016,11 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
|
|
|
2415
3016
|
...additionalAllowedDirectories,
|
|
2416
3017
|
];
|
|
2417
3018
|
// Create runner configuration
|
|
2418
|
-
const
|
|
3019
|
+
const resumeSessionId = needsNewClaudeSession
|
|
3020
|
+
? undefined
|
|
3021
|
+
: session.claudeSessionId;
|
|
3022
|
+
const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, // Pass labels for model override
|
|
3023
|
+
maxTurns);
|
|
2419
3024
|
const runner = new ClaudeRunner(runnerConfig);
|
|
2420
3025
|
// Store runner
|
|
2421
3026
|
agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
|