cyrus-edge-worker 0.2.5 → 0.2.7
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 +42 -5
- package/dist/AgentSessionManager.d.ts.map +1 -1
- package/dist/AgentSessionManager.js +143 -16
- package/dist/AgentSessionManager.js.map +1 -1
- package/dist/AskUserQuestionHandler.d.ts +96 -0
- package/dist/AskUserQuestionHandler.d.ts.map +1 -0
- package/dist/AskUserQuestionHandler.js +203 -0
- package/dist/AskUserQuestionHandler.js.map +1 -0
- package/dist/EdgeWorker.d.ts +72 -11
- package/dist/EdgeWorker.d.ts.map +1 -1
- package/dist/EdgeWorker.js +469 -124
- package/dist/EdgeWorker.js.map +1 -1
- package/dist/GitService.d.ts +34 -0
- package/dist/GitService.d.ts.map +1 -0
- package/dist/GitService.js +347 -0
- package/dist/GitService.js.map +1 -0
- package/dist/SharedApplicationServer.d.ts +2 -1
- package/dist/SharedApplicationServer.d.ts.map +1 -1
- package/dist/SharedApplicationServer.js +5 -3
- package/dist/SharedApplicationServer.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/procedures/{ProcedureRouter.d.ts → ProcedureAnalyzer.d.ts} +11 -11
- package/dist/procedures/ProcedureAnalyzer.d.ts.map +1 -0
- package/dist/procedures/{ProcedureRouter.js → ProcedureAnalyzer.js} +21 -14
- package/dist/procedures/ProcedureAnalyzer.js.map +1 -0
- package/dist/procedures/index.d.ts +2 -2
- package/dist/procedures/index.d.ts.map +1 -1
- package/dist/procedures/index.js +2 -2
- package/dist/procedures/index.js.map +1 -1
- package/dist/procedures/registry.d.ts +20 -1
- package/dist/procedures/registry.d.ts.map +1 -1
- package/dist/procedures/registry.js +26 -1
- package/dist/procedures/registry.js.map +1 -1
- package/dist/procedures/types.d.ts +29 -5
- package/dist/procedures/types.d.ts.map +1 -1
- package/dist/procedures/types.js +1 -1
- package/dist/prompts/subroutines/user-testing-summary.md +87 -0
- package/dist/prompts/subroutines/user-testing.md +48 -0
- package/dist/prompts/subroutines/validation-fixer.md +56 -0
- package/dist/prompts/subroutines/verifications.md +51 -24
- package/dist/validation/ValidationLoopController.d.ts +54 -0
- package/dist/validation/ValidationLoopController.d.ts.map +1 -0
- package/dist/validation/ValidationLoopController.js +242 -0
- package/dist/validation/ValidationLoopController.js.map +1 -0
- package/dist/validation/index.d.ts +7 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +7 -0
- package/dist/validation/index.js.map +1 -0
- package/dist/validation/types.d.ts +90 -0
- package/dist/validation/types.d.ts.map +1 -0
- package/dist/validation/types.js +33 -0
- package/dist/validation/types.js.map +1 -0
- package/package.json +10 -8
- package/prompts/graphite-orchestrator.md +360 -0
- package/dist/procedures/ProcedureRouter.d.ts.map +0 -1
- package/dist/procedures/ProcedureRouter.js.map +0 -1
package/dist/EdgeWorker.js
CHANGED
|
@@ -4,14 +4,16 @@ import { basename, dirname, extname, join, resolve } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { LinearClient } from "@linear/sdk";
|
|
6
6
|
import { watch as chokidarWatch } from "chokidar";
|
|
7
|
-
import {
|
|
7
|
+
import { ClaudeRunner, createCyrusToolsServer, createImageToolsServer, createSoraToolsServer, getAllTools, getCoordinatorTools, getReadOnlyTools, getSafeTools, } from "cyrus-claude-runner";
|
|
8
8
|
import { ConfigUpdater } from "cyrus-config-updater";
|
|
9
|
-
import { DEFAULT_PROXY_URL, isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueUnassignedWebhook, PersistenceManager, resolvePath, } from "cyrus-core";
|
|
9
|
+
import { CLIIssueTrackerService, CLIRPCServer, DEFAULT_PROXY_URL, isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueUnassignedWebhook, PersistenceManager, resolvePath, } from "cyrus-core";
|
|
10
10
|
import { GeminiRunner } from "cyrus-gemini-runner";
|
|
11
11
|
import { LinearEventTransport, LinearIssueTrackerService, } from "cyrus-linear-event-transport";
|
|
12
12
|
import { fileTypeFromBuffer } from "file-type";
|
|
13
13
|
import { AgentSessionManager } from "./AgentSessionManager.js";
|
|
14
|
-
import {
|
|
14
|
+
import { AskUserQuestionHandler } from "./AskUserQuestionHandler.js";
|
|
15
|
+
import { GitService } from "./GitService.js";
|
|
16
|
+
import { ProcedureAnalyzer, } from "./procedures/index.js";
|
|
15
17
|
import { RepositoryRouter, } from "./RepositoryRouter.js";
|
|
16
18
|
import { SharedApplicationServer } from "./SharedApplicationServer.js";
|
|
17
19
|
/**
|
|
@@ -26,16 +28,21 @@ export class EdgeWorker extends EventEmitter {
|
|
|
26
28
|
agentSessionManagers = new Map(); // Maps repository ID to AgentSessionManager, which manages agent runners for a repo
|
|
27
29
|
issueTrackers = new Map(); // one issue tracker per 'repository'
|
|
28
30
|
linearEventTransport = null; // Single event transport for webhook delivery
|
|
31
|
+
cliRPCServer = null; // CLI RPC server for CLI platform mode
|
|
29
32
|
configUpdater = null; // Single config updater for configuration updates
|
|
30
33
|
persistenceManager;
|
|
31
34
|
sharedApplicationServer;
|
|
32
35
|
cyrusHome;
|
|
33
36
|
childToParentAgentSession = new Map(); // Maps child agentSessionId to parent agentSessionId
|
|
34
|
-
|
|
37
|
+
procedureAnalyzer; // Intelligent workflow routing
|
|
35
38
|
configWatcher; // File watcher for config.json
|
|
36
39
|
configPath; // Path to config.json file
|
|
37
40
|
/** @internal - Exposed for testing only */
|
|
38
41
|
repositoryRouter; // Repository routing and selection
|
|
42
|
+
gitService;
|
|
43
|
+
activeWebhookCount = 0; // Track number of webhooks currently being processed
|
|
44
|
+
/** Handler for AskUserQuestion tool invocations via Linear select signal */
|
|
45
|
+
askUserQuestionHandler;
|
|
39
46
|
constructor(config) {
|
|
40
47
|
super();
|
|
41
48
|
this.config = config;
|
|
@@ -43,7 +50,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
43
50
|
this.persistenceManager = new PersistenceManager(join(this.cyrusHome, "state"));
|
|
44
51
|
// Initialize procedure router with haiku for fast classification
|
|
45
52
|
// Default to claude runner
|
|
46
|
-
this.
|
|
53
|
+
this.procedureAnalyzer = new ProcedureAnalyzer({
|
|
47
54
|
cyrusHome: this.cyrusHome,
|
|
48
55
|
model: "haiku",
|
|
49
56
|
timeoutMs: 100000,
|
|
@@ -75,12 +82,20 @@ export class EdgeWorker extends EventEmitter {
|
|
|
75
82
|
},
|
|
76
83
|
};
|
|
77
84
|
this.repositoryRouter = new RepositoryRouter(repositoryRouterDeps);
|
|
85
|
+
this.gitService = new GitService();
|
|
86
|
+
// Initialize AskUserQuestion handler for elicitation via Linear select signal
|
|
87
|
+
this.askUserQuestionHandler = new AskUserQuestionHandler({
|
|
88
|
+
getIssueTracker: (workspaceId) => {
|
|
89
|
+
return this.getIssueTrackerForWorkspace(workspaceId) ?? null;
|
|
90
|
+
},
|
|
91
|
+
});
|
|
78
92
|
console.log(`[EdgeWorker Constructor] Initializing parent-child session mapping system`);
|
|
79
93
|
console.log(`[EdgeWorker Constructor] Parent-child mapping initialized with 0 entries`);
|
|
80
94
|
// Initialize shared application server
|
|
81
95
|
const serverPort = config.serverPort || config.webhookPort || 3456;
|
|
82
96
|
const serverHost = config.serverHost || "localhost";
|
|
83
|
-
|
|
97
|
+
const skipTunnel = config.platform === "cli"; // Skip Cloudflare tunnel in CLI mode
|
|
98
|
+
this.sharedApplicationServer = new SharedApplicationServer(serverPort, serverHost, skipTunnel);
|
|
84
99
|
// Initialize repositories with path resolution
|
|
85
100
|
for (const repo of config.repositories) {
|
|
86
101
|
if (repo.isActive !== false) {
|
|
@@ -103,10 +118,15 @@ export class EdgeWorker extends EventEmitter {
|
|
|
103
118
|
};
|
|
104
119
|
this.repositories.set(repo.id, resolvedRepo);
|
|
105
120
|
// Create issue tracker for this repository's workspace
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
const issueTracker = this.config.platform === "cli"
|
|
122
|
+
? (() => {
|
|
123
|
+
const service = new CLIIssueTrackerService();
|
|
124
|
+
service.seedDefaultData();
|
|
125
|
+
return service;
|
|
126
|
+
})()
|
|
127
|
+
: new LinearIssueTrackerService(new LinearClient({
|
|
128
|
+
accessToken: repo.linearToken,
|
|
129
|
+
}));
|
|
110
130
|
this.issueTrackers.set(repo.id, issueTracker);
|
|
111
131
|
// Create AgentSessionManager for this repository with parent session lookup and resume callback
|
|
112
132
|
//
|
|
@@ -125,11 +145,20 @@ export class EdgeWorker extends EventEmitter {
|
|
|
125
145
|
return parentId;
|
|
126
146
|
}, async (parentSessionId, prompt, childSessionId) => {
|
|
127
147
|
await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
|
|
128
|
-
}, this.
|
|
148
|
+
}, this.procedureAnalyzer, this.sharedApplicationServer);
|
|
129
149
|
// Subscribe to subroutine completion events
|
|
130
150
|
agentSessionManager.on("subroutineComplete", async ({ linearAgentActivitySessionId, session }) => {
|
|
131
151
|
await this.handleSubroutineTransition(linearAgentActivitySessionId, session, repo, agentSessionManager);
|
|
132
152
|
});
|
|
153
|
+
// Subscribe to validation loop events
|
|
154
|
+
agentSessionManager.on("validationLoopIteration", async ({ linearAgentActivitySessionId, session, fixerPrompt, iteration, maxIterations, }) => {
|
|
155
|
+
console.log(`[EdgeWorker] Validation loop iteration ${iteration}/${maxIterations}, running fixer`);
|
|
156
|
+
await this.handleValidationLoopFixer(linearAgentActivitySessionId, session, repo, agentSessionManager, fixerPrompt, iteration);
|
|
157
|
+
});
|
|
158
|
+
agentSessionManager.on("validationLoopRerun", async ({ linearAgentActivitySessionId, session, iteration }) => {
|
|
159
|
+
console.log(`[EdgeWorker] Validation loop re-running verifications (iteration ${iteration})`);
|
|
160
|
+
await this.handleValidationLoopRerun(linearAgentActivitySessionId, session, repo, agentSessionManager);
|
|
161
|
+
});
|
|
133
162
|
this.agentSessionManagers.set(repo.id, agentSessionManager);
|
|
134
163
|
}
|
|
135
164
|
}
|
|
@@ -159,39 +188,116 @@ export class EdgeWorker extends EventEmitter {
|
|
|
159
188
|
if (!firstRepo) {
|
|
160
189
|
throw new Error("No active repositories configured");
|
|
161
190
|
}
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
191
|
+
// Platform-specific initialization
|
|
192
|
+
if (this.config.platform === "cli") {
|
|
193
|
+
// CLI mode: Create and register CLIRPCServer
|
|
194
|
+
const firstIssueTracker = this.issueTrackers.get(firstRepo.id);
|
|
195
|
+
if (!firstIssueTracker) {
|
|
196
|
+
throw new Error("Issue tracker not found for first repository");
|
|
197
|
+
}
|
|
198
|
+
// Type guard to ensure it's a CLIIssueTrackerService
|
|
199
|
+
if (!(firstIssueTracker instanceof CLIIssueTrackerService)) {
|
|
200
|
+
throw new Error("CLI platform requires CLIIssueTrackerService but found different implementation");
|
|
201
|
+
}
|
|
202
|
+
this.cliRPCServer = new CLIRPCServer({
|
|
203
|
+
fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
|
|
204
|
+
issueTracker: firstIssueTracker,
|
|
205
|
+
version: "1.0.0",
|
|
206
|
+
});
|
|
207
|
+
// Register the /cli/rpc endpoint
|
|
208
|
+
this.cliRPCServer.register();
|
|
209
|
+
console.log("✅ CLI RPC server registered");
|
|
210
|
+
console.log(" RPC endpoint: /cli/rpc");
|
|
211
|
+
// Create CLI event transport and register listener
|
|
212
|
+
const cliEventTransport = firstIssueTracker.createEventTransport({
|
|
213
|
+
platform: "cli",
|
|
214
|
+
fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
|
|
215
|
+
});
|
|
216
|
+
// Listen for webhook events (same pattern as Linear mode)
|
|
217
|
+
cliEventTransport.on("event", (event) => {
|
|
218
|
+
// Get all active repositories for webhook handling
|
|
219
|
+
const repos = Array.from(this.repositories.values());
|
|
220
|
+
this.handleWebhook(event, repos);
|
|
221
|
+
});
|
|
222
|
+
// Listen for errors
|
|
223
|
+
cliEventTransport.on("error", (error) => {
|
|
224
|
+
this.handleError(error);
|
|
225
|
+
});
|
|
226
|
+
// Register the CLI event transport endpoints
|
|
227
|
+
cliEventTransport.register();
|
|
228
|
+
console.log("✅ CLI event transport registered");
|
|
229
|
+
console.log(" Event listener: listening for AgentSessionCreated events");
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
// Linear mode: Create and register LinearEventTransport
|
|
233
|
+
const useDirectWebhooks = process.env.LINEAR_DIRECT_WEBHOOKS?.toLowerCase() === "true";
|
|
234
|
+
const verificationMode = useDirectWebhooks ? "direct" : "proxy";
|
|
235
|
+
// Get appropriate secret based on mode
|
|
236
|
+
const secret = useDirectWebhooks
|
|
237
|
+
? process.env.LINEAR_WEBHOOK_SECRET || ""
|
|
238
|
+
: process.env.CYRUS_API_KEY || "";
|
|
239
|
+
this.linearEventTransport = new LinearEventTransport({
|
|
240
|
+
fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
|
|
241
|
+
verificationMode,
|
|
242
|
+
secret,
|
|
243
|
+
});
|
|
244
|
+
// Listen for webhook events
|
|
245
|
+
this.linearEventTransport.on("event", (event) => {
|
|
246
|
+
// Get all active repositories for webhook handling
|
|
247
|
+
const repos = Array.from(this.repositories.values());
|
|
248
|
+
this.handleWebhook(event, repos);
|
|
249
|
+
});
|
|
250
|
+
// Listen for errors
|
|
251
|
+
this.linearEventTransport.on("error", (error) => {
|
|
252
|
+
this.handleError(error);
|
|
253
|
+
});
|
|
254
|
+
// Register the /webhook endpoint
|
|
255
|
+
this.linearEventTransport.register();
|
|
256
|
+
console.log(`✅ Linear event transport registered (${verificationMode} mode)`);
|
|
257
|
+
console.log(` Webhook endpoint: ${this.sharedApplicationServer.getWebhookUrl()}`);
|
|
258
|
+
}
|
|
259
|
+
// 2. Create and register ConfigUpdater (both platforms)
|
|
189
260
|
this.configUpdater = new ConfigUpdater(this.sharedApplicationServer.getFastifyInstance(), this.cyrusHome, process.env.CYRUS_API_KEY || "");
|
|
190
261
|
// Register config update routes
|
|
191
262
|
this.configUpdater.register();
|
|
192
263
|
console.log("✅ Config updater registered");
|
|
193
264
|
console.log(" Routes: /api/update/cyrus-config, /api/update/cyrus-env,");
|
|
194
265
|
console.log(" /api/update/repository, /api/test-mcp, /api/configure-mcp");
|
|
266
|
+
// 3. Register /status endpoint for process activity monitoring
|
|
267
|
+
this.registerStatusEndpoint();
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Register the /status endpoint for checking if the process is busy or idle
|
|
271
|
+
* This endpoint is used to determine if the process can be safely restarted
|
|
272
|
+
*/
|
|
273
|
+
registerStatusEndpoint() {
|
|
274
|
+
const fastify = this.sharedApplicationServer.getFastifyInstance();
|
|
275
|
+
fastify.get("/status", async (_request, reply) => {
|
|
276
|
+
const status = this.computeStatus();
|
|
277
|
+
return reply.status(200).send({ status });
|
|
278
|
+
});
|
|
279
|
+
console.log("✅ Status endpoint registered");
|
|
280
|
+
console.log(" Route: GET /status");
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Compute the current status of the Cyrus process
|
|
284
|
+
* @returns "idle" if the process can be safely restarted, "busy" if work is in progress
|
|
285
|
+
*/
|
|
286
|
+
computeStatus() {
|
|
287
|
+
// Busy if any webhooks are currently being processed
|
|
288
|
+
if (this.activeWebhookCount > 0) {
|
|
289
|
+
return "busy";
|
|
290
|
+
}
|
|
291
|
+
// Busy if any runner is actively running
|
|
292
|
+
for (const manager of this.agentSessionManagers.values()) {
|
|
293
|
+
const runners = manager.getAllAgentRunners();
|
|
294
|
+
for (const runner of runners) {
|
|
295
|
+
if (runner.isRunning()) {
|
|
296
|
+
return "busy";
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return "idle";
|
|
195
301
|
}
|
|
196
302
|
/**
|
|
197
303
|
* Stop the edge worker
|
|
@@ -309,7 +415,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
309
415
|
async handleSubroutineTransition(linearAgentActivitySessionId, session, repo, agentSessionManager) {
|
|
310
416
|
console.log(`[Subroutine Transition] Handling subroutine completion for session ${linearAgentActivitySessionId}`);
|
|
311
417
|
// Get next subroutine (advancement already handled by AgentSessionManager)
|
|
312
|
-
const nextSubroutine = this.
|
|
418
|
+
const nextSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
|
|
313
419
|
if (!nextSubroutine) {
|
|
314
420
|
console.log(`[Subroutine Transition] Procedure complete for session ${linearAgentActivitySessionId}`);
|
|
315
421
|
return;
|
|
@@ -341,6 +447,51 @@ export class EdgeWorker extends EventEmitter {
|
|
|
341
447
|
console.error(`[Subroutine Transition] Failed to resume session for ${nextSubroutine.name} subroutine:`, error);
|
|
342
448
|
}
|
|
343
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* Handle validation loop fixer - run the fixer prompt
|
|
452
|
+
*/
|
|
453
|
+
async handleValidationLoopFixer(linearAgentActivitySessionId, session, repo, agentSessionManager, fixerPrompt, iteration) {
|
|
454
|
+
console.log(`[Validation Loop] Running fixer for session ${linearAgentActivitySessionId}, iteration ${iteration}`);
|
|
455
|
+
try {
|
|
456
|
+
await this.resumeAgentSession(session, repo, linearAgentActivitySessionId, agentSessionManager, fixerPrompt, "", // No attachment manifest
|
|
457
|
+
false, // Not a new session
|
|
458
|
+
[], // No additional allowed directories
|
|
459
|
+
undefined);
|
|
460
|
+
console.log(`[Validation Loop] Successfully started fixer for iteration ${iteration}`);
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
console.error(`[Validation Loop] Failed to run fixer for iteration ${iteration}:`, error);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Handle validation loop rerun - re-run the verifications subroutine
|
|
468
|
+
*/
|
|
469
|
+
async handleValidationLoopRerun(linearAgentActivitySessionId, session, repo, agentSessionManager) {
|
|
470
|
+
console.log(`[Validation Loop] Re-running verifications for session ${linearAgentActivitySessionId}`);
|
|
471
|
+
// Get the verifications subroutine definition
|
|
472
|
+
const verificationsSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
|
|
473
|
+
if (!verificationsSubroutine ||
|
|
474
|
+
verificationsSubroutine.name !== "verifications") {
|
|
475
|
+
console.error(`[Validation Loop] Expected verifications subroutine, got: ${verificationsSubroutine?.name}`);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
// Load the verifications prompt
|
|
480
|
+
const subroutinePrompt = await this.loadSubroutinePrompt(verificationsSubroutine, this.config.linearWorkspaceSlug);
|
|
481
|
+
if (!subroutinePrompt) {
|
|
482
|
+
console.error(`[Validation Loop] Failed to load verifications prompt`);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
await this.resumeAgentSession(session, repo, linearAgentActivitySessionId, agentSessionManager, subroutinePrompt, "", // No attachment manifest
|
|
486
|
+
false, // Not a new session
|
|
487
|
+
[], // No additional allowed directories
|
|
488
|
+
undefined);
|
|
489
|
+
console.log(`[Validation Loop] Successfully re-started verifications`);
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
console.error(`[Validation Loop] Failed to re-run verifications:`, error);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
344
495
|
/**
|
|
345
496
|
* Start watching config file for changes
|
|
346
497
|
*/
|
|
@@ -506,21 +657,35 @@ export class EdgeWorker extends EventEmitter {
|
|
|
506
657
|
// Add to internal map
|
|
507
658
|
this.repositories.set(repo.id, resolvedRepo);
|
|
508
659
|
// Create issue tracker
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
660
|
+
const issueTracker = this.config.platform === "cli"
|
|
661
|
+
? (() => {
|
|
662
|
+
const service = new CLIIssueTrackerService();
|
|
663
|
+
service.seedDefaultData();
|
|
664
|
+
return service;
|
|
665
|
+
})()
|
|
666
|
+
: new LinearIssueTrackerService(new LinearClient({
|
|
667
|
+
accessToken: repo.linearToken,
|
|
668
|
+
}));
|
|
513
669
|
this.issueTrackers.set(repo.id, issueTracker);
|
|
514
670
|
// Create AgentSessionManager with same pattern as constructor
|
|
515
671
|
const agentSessionManager = new AgentSessionManager(issueTracker, (childSessionId) => {
|
|
516
672
|
return this.childToParentAgentSession.get(childSessionId);
|
|
517
673
|
}, async (parentSessionId, prompt, childSessionId) => {
|
|
518
674
|
await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
|
|
519
|
-
}, this.
|
|
675
|
+
}, this.procedureAnalyzer, this.sharedApplicationServer);
|
|
520
676
|
// Subscribe to subroutine completion events
|
|
521
677
|
agentSessionManager.on("subroutineComplete", async ({ linearAgentActivitySessionId, session }) => {
|
|
522
678
|
await this.handleSubroutineTransition(linearAgentActivitySessionId, session, repo, agentSessionManager);
|
|
523
679
|
});
|
|
680
|
+
// Subscribe to validation loop events
|
|
681
|
+
agentSessionManager.on("validationLoopIteration", async ({ linearAgentActivitySessionId, session, fixerPrompt, iteration, maxIterations, }) => {
|
|
682
|
+
console.log(`[EdgeWorker] Validation loop iteration ${iteration}/${maxIterations}, running fixer`);
|
|
683
|
+
await this.handleValidationLoopFixer(linearAgentActivitySessionId, session, repo, agentSessionManager, fixerPrompt, iteration);
|
|
684
|
+
});
|
|
685
|
+
agentSessionManager.on("validationLoopRerun", async ({ linearAgentActivitySessionId, session, iteration }) => {
|
|
686
|
+
console.log(`[EdgeWorker] Validation loop re-running verifications (iteration ${iteration})`);
|
|
687
|
+
await this.handleValidationLoopRerun(linearAgentActivitySessionId, session, repo, agentSessionManager);
|
|
688
|
+
});
|
|
524
689
|
this.agentSessionManagers.set(repo.id, agentSessionManager);
|
|
525
690
|
console.log(`✅ Repository added successfully: ${repo.name}`);
|
|
526
691
|
}
|
|
@@ -563,10 +728,15 @@ export class EdgeWorker extends EventEmitter {
|
|
|
563
728
|
// If token changed, recreate issue tracker
|
|
564
729
|
if (oldRepo.linearToken !== repo.linearToken) {
|
|
565
730
|
console.log(` 🔑 Token changed, recreating issue tracker`);
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
731
|
+
const issueTracker = this.config.platform === "cli"
|
|
732
|
+
? (() => {
|
|
733
|
+
const service = new CLIIssueTrackerService();
|
|
734
|
+
service.seedDefaultData();
|
|
735
|
+
return service;
|
|
736
|
+
})()
|
|
737
|
+
: new LinearIssueTrackerService(new LinearClient({
|
|
738
|
+
accessToken: repo.linearToken,
|
|
739
|
+
}));
|
|
570
740
|
this.issueTrackers.set(repo.id, issueTracker);
|
|
571
741
|
}
|
|
572
742
|
// If active status changed
|
|
@@ -654,6 +824,8 @@ export class EdgeWorker extends EventEmitter {
|
|
|
654
824
|
* Handle webhook events from proxy - main router for all webhooks
|
|
655
825
|
*/
|
|
656
826
|
async handleWebhook(webhook, repos) {
|
|
827
|
+
// Track active webhook processing for status endpoint
|
|
828
|
+
this.activeWebhookCount++;
|
|
657
829
|
// Log verbose webhook info if enabled
|
|
658
830
|
if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
|
|
659
831
|
console.log(`[handleWebhook] Full webhook payload:`, JSON.stringify(webhook, null, 2));
|
|
@@ -691,6 +863,10 @@ export class EdgeWorker extends EventEmitter {
|
|
|
691
863
|
// Don't re-throw webhook processing errors to prevent application crashes
|
|
692
864
|
// The error has been logged and individual webhook failures shouldn't crash the entire system
|
|
693
865
|
}
|
|
866
|
+
finally {
|
|
867
|
+
// Always decrement counter when webhook processing completes
|
|
868
|
+
this.activeWebhookCount--;
|
|
869
|
+
}
|
|
694
870
|
}
|
|
695
871
|
/**
|
|
696
872
|
* Handle issue unassignment webhook
|
|
@@ -742,12 +918,10 @@ export class EdgeWorker extends EventEmitter {
|
|
|
742
918
|
// Move issue to started state automatically, in case it's not already
|
|
743
919
|
await this.moveIssueToStartedState(fullIssue, repository.id);
|
|
744
920
|
// Create workspace using full issue data
|
|
921
|
+
// Use custom handler if provided, otherwise create a git worktree by default
|
|
745
922
|
const workspace = this.config.handlers?.createWorkspace
|
|
746
923
|
? await this.config.handlers.createWorkspace(fullIssue, repository)
|
|
747
|
-
:
|
|
748
|
-
path: `${repository.workspaceBaseDir}/${fullIssue.identifier}`,
|
|
749
|
-
isGitWorktree: false,
|
|
750
|
-
};
|
|
924
|
+
: await this.gitService.createGitWorktree(fullIssue, repository);
|
|
751
925
|
console.log(`[EdgeWorker] Workspace created at: ${workspace.path}`);
|
|
752
926
|
const issueMinimal = this.convertLinearIssueToCore(fullIssue);
|
|
753
927
|
agentSessionManager.createLinearAgentSession(linearAgentActivitySessionId, issue.id, issueMinimal, workspace);
|
|
@@ -893,7 +1067,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
893
1067
|
session.metadata = {};
|
|
894
1068
|
}
|
|
895
1069
|
// Post ephemeral "Routing..." thought
|
|
896
|
-
await agentSessionManager.
|
|
1070
|
+
await agentSessionManager.postAnalyzingThought(linearAgentActivitySessionId);
|
|
897
1071
|
// Fetch labels early (needed for label override check)
|
|
898
1072
|
const labels = await this.fetchIssueLabels(fullIssue);
|
|
899
1073
|
// Check for label overrides BEFORE AI routing
|
|
@@ -905,13 +1079,19 @@ export class EdgeWorker extends EventEmitter {
|
|
|
905
1079
|
const orchestratorConfig = repository.labelPrompts?.orchestrator;
|
|
906
1080
|
const orchestratorLabels = Array.isArray(orchestratorConfig)
|
|
907
1081
|
? orchestratorConfig
|
|
908
|
-
: orchestratorConfig?.labels;
|
|
1082
|
+
: (orchestratorConfig?.labels ?? ["orchestrator"]);
|
|
909
1083
|
const hasOrchestratorLabel = orchestratorLabels?.some((label) => labels.includes(label));
|
|
1084
|
+
// Check for graphite label (for graphite-orchestrator combination)
|
|
1085
|
+
const graphiteConfig = repository.labelPrompts?.graphite;
|
|
1086
|
+
const graphiteLabels = graphiteConfig?.labels ?? ["graphite"];
|
|
1087
|
+
const hasGraphiteLabel = graphiteLabels?.some((label) => labels.includes(label));
|
|
1088
|
+
// Graphite-orchestrator requires BOTH graphite AND orchestrator labels
|
|
1089
|
+
const hasGraphiteOrchestratorLabels = hasGraphiteLabel && hasOrchestratorLabel;
|
|
910
1090
|
let finalProcedure;
|
|
911
1091
|
let finalClassification;
|
|
912
1092
|
// If labels indicate a specific procedure, use that instead of AI routing
|
|
913
1093
|
if (hasDebuggerLabel) {
|
|
914
|
-
const debuggerProcedure = this.
|
|
1094
|
+
const debuggerProcedure = this.procedureAnalyzer.getProcedure("debugger-full");
|
|
915
1095
|
if (!debuggerProcedure) {
|
|
916
1096
|
throw new Error("debugger-full procedure not found in registry");
|
|
917
1097
|
}
|
|
@@ -919,8 +1099,19 @@ export class EdgeWorker extends EventEmitter {
|
|
|
919
1099
|
finalClassification = "debugger";
|
|
920
1100
|
console.log(`[EdgeWorker] Using debugger-full procedure due to debugger label (skipping AI routing)`);
|
|
921
1101
|
}
|
|
1102
|
+
else if (hasGraphiteOrchestratorLabels) {
|
|
1103
|
+
// Graphite-orchestrator takes precedence over regular orchestrator when both labels present
|
|
1104
|
+
const orchestratorProcedure = this.procedureAnalyzer.getProcedure("orchestrator-full");
|
|
1105
|
+
if (!orchestratorProcedure) {
|
|
1106
|
+
throw new Error("orchestrator-full procedure not found in registry");
|
|
1107
|
+
}
|
|
1108
|
+
finalProcedure = orchestratorProcedure;
|
|
1109
|
+
// Use orchestrator classification but the system prompt will be graphite-orchestrator
|
|
1110
|
+
finalClassification = "orchestrator";
|
|
1111
|
+
console.log(`[EdgeWorker] Using orchestrator-full procedure with graphite-orchestrator prompt (graphite + orchestrator labels)`);
|
|
1112
|
+
}
|
|
922
1113
|
else if (hasOrchestratorLabel) {
|
|
923
|
-
const orchestratorProcedure = this.
|
|
1114
|
+
const orchestratorProcedure = this.procedureAnalyzer.getProcedure("orchestrator-full");
|
|
924
1115
|
if (!orchestratorProcedure) {
|
|
925
1116
|
throw new Error("orchestrator-full procedure not found in registry");
|
|
926
1117
|
}
|
|
@@ -931,7 +1122,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
931
1122
|
else {
|
|
932
1123
|
// No label override - use AI routing
|
|
933
1124
|
const issueDescription = `${issue.title}\n\n${fullIssue.description || ""}`.trim();
|
|
934
|
-
const routingDecision = await this.
|
|
1125
|
+
const routingDecision = await this.procedureAnalyzer.determineRoutine(issueDescription);
|
|
935
1126
|
finalProcedure = routingDecision.procedure;
|
|
936
1127
|
finalClassification = routingDecision.classification;
|
|
937
1128
|
// Log AI routing decision
|
|
@@ -941,7 +1132,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
941
1132
|
console.log(` Reasoning: ${routingDecision.reasoning}`);
|
|
942
1133
|
}
|
|
943
1134
|
// Initialize procedure metadata in session with final decision
|
|
944
|
-
this.
|
|
1135
|
+
this.procedureAnalyzer.initializeProcedureMetadata(session, finalProcedure);
|
|
945
1136
|
// Post single procedure selection result (replaces ephemeral routing thought)
|
|
946
1137
|
await agentSessionManager.postProcedureSelectionThought(linearAgentActivitySessionId, finalProcedure.name, finalClassification);
|
|
947
1138
|
// Build and start Claude with initial prompt using full issue (streaming mode)
|
|
@@ -986,7 +1177,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
986
1177
|
console.log(`[EdgeWorker] Configured disallowed tools for ${fullIssue.identifier}:`, disallowedTools);
|
|
987
1178
|
}
|
|
988
1179
|
// Get current subroutine to check for singleTurn mode
|
|
989
|
-
const currentSubroutine = this.
|
|
1180
|
+
const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
|
|
990
1181
|
// Create agent runner with system prompt from assembly
|
|
991
1182
|
// buildAgentRunnerConfig now determines runner type from labels internally
|
|
992
1183
|
const { config: runnerConfig, runnerType } = this.buildAgentRunnerConfig(session, repository, linearAgentActivitySessionId, assembly.systemPrompt, allowedTools, allowedDirectories, disallowedTools, undefined, // resumeSessionId
|
|
@@ -1001,7 +1192,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
1001
1192
|
agentSessionManager.addAgentRunner(linearAgentActivitySessionId, runner);
|
|
1002
1193
|
// Save state after mapping changes
|
|
1003
1194
|
await this.savePersistedState();
|
|
1004
|
-
// Emit events using full
|
|
1195
|
+
// Emit events using full issue (core Issue type)
|
|
1005
1196
|
this.emit("session:started", fullIssue.id, fullIssue, repository.id);
|
|
1006
1197
|
this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
|
|
1007
1198
|
// Update runner with version information (if available)
|
|
@@ -1110,6 +1301,33 @@ export class EdgeWorker extends EventEmitter {
|
|
|
1110
1301
|
// Initialize agent runner with the selected repository
|
|
1111
1302
|
await this.initializeAgentRunner(agentSession, repository, guidance, commentBody);
|
|
1112
1303
|
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Handle AskUserQuestion response from prompted webhook
|
|
1306
|
+
* Branch 2.5: User response to a question posed via AskUserQuestion tool
|
|
1307
|
+
*
|
|
1308
|
+
* @param webhook The prompted webhook containing user's response
|
|
1309
|
+
*/
|
|
1310
|
+
async handleAskUserQuestionResponse(webhook) {
|
|
1311
|
+
const { agentSession, agentActivity } = webhook;
|
|
1312
|
+
const agentSessionId = agentSession.id;
|
|
1313
|
+
if (!agentActivity) {
|
|
1314
|
+
console.warn("[EdgeWorker] Cannot handle AskUserQuestion response without agentActivity");
|
|
1315
|
+
// Resolve with a denial to unblock the waiting promise
|
|
1316
|
+
this.askUserQuestionHandler.cancelPendingQuestion(agentSessionId, "No agent activity in webhook");
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
// Extract the user's response from the activity body
|
|
1320
|
+
const userResponse = agentActivity.content?.body || "";
|
|
1321
|
+
console.log(`[EdgeWorker] Processing AskUserQuestion response for session ${agentSessionId}: "${userResponse}"`);
|
|
1322
|
+
// Pass the response to the handler to resolve the waiting promise
|
|
1323
|
+
const handled = this.askUserQuestionHandler.handleUserResponse(agentSessionId, userResponse);
|
|
1324
|
+
if (!handled) {
|
|
1325
|
+
console.warn(`[EdgeWorker] AskUserQuestion response not handled for session ${agentSessionId} (no pending question)`);
|
|
1326
|
+
}
|
|
1327
|
+
else {
|
|
1328
|
+
console.log(`[EdgeWorker] AskUserQuestion response handled for session ${agentSessionId}`);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1113
1331
|
/**
|
|
1114
1332
|
* Handle normal prompted activity (existing session continuation)
|
|
1115
1333
|
* Branch 3 of agentSessionPrompted (see packages/CLAUDE.md)
|
|
@@ -1149,6 +1367,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
1149
1367
|
console.log(`[EdgeWorker] Created new session ${linearAgentActivitySessionId} (prompted webhook)`);
|
|
1150
1368
|
// Save state and emit events for new session
|
|
1151
1369
|
await this.savePersistedState();
|
|
1370
|
+
// Emit events using full issue (core Issue type)
|
|
1152
1371
|
this.emit("session:started", fullIssue.id, fullIssue, repository.id);
|
|
1153
1372
|
this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
|
|
1154
1373
|
}
|
|
@@ -1263,6 +1482,13 @@ export class EdgeWorker extends EventEmitter {
|
|
|
1263
1482
|
await this.handleRepositorySelectionResponse(webhook);
|
|
1264
1483
|
return;
|
|
1265
1484
|
}
|
|
1485
|
+
// Branch 2.5: Handle AskUserQuestion response
|
|
1486
|
+
// This handles responses to questions posed via the AskUserQuestion tool.
|
|
1487
|
+
// The response is passed to the pending promise resolver.
|
|
1488
|
+
if (this.askUserQuestionHandler.hasPendingQuestion(agentSessionId)) {
|
|
1489
|
+
await this.handleAskUserQuestionResponse(webhook);
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1266
1492
|
// Branch 3: Handle normal prompted activity (existing session continuation)
|
|
1267
1493
|
// Per CLAUDE.md: "an agentSession MUST exist and a repository MUST already
|
|
1268
1494
|
// be associated with the Linear issue. The repository will be retrieved from
|
|
@@ -1321,7 +1547,11 @@ export class EdgeWorker extends EventEmitter {
|
|
|
1321
1547
|
*/
|
|
1322
1548
|
async handleClaudeError(error) {
|
|
1323
1549
|
// AbortError is expected when user stops Claude process, don't log it
|
|
1324
|
-
|
|
1550
|
+
// Check by name since the SDK's AbortError class may not match our imported definition
|
|
1551
|
+
const isAbortError = error.name === "AbortError" || error.message.includes("aborted by user");
|
|
1552
|
+
// Also check for SIGTERM (exit code 143), which indicates graceful termination
|
|
1553
|
+
const isSigterm = error.message.includes("Claude Code process exited with code 143");
|
|
1554
|
+
if (isAbortError || isSigterm) {
|
|
1325
1555
|
return;
|
|
1326
1556
|
}
|
|
1327
1557
|
console.error("Unhandled claude error:", error);
|
|
@@ -1353,8 +1583,8 @@ export class EdgeWorker extends EventEmitter {
|
|
|
1353
1583
|
if (!labels || labels.length === 0) {
|
|
1354
1584
|
return {
|
|
1355
1585
|
runnerType: "claude",
|
|
1356
|
-
modelOverride: "
|
|
1357
|
-
fallbackModelOverride: "
|
|
1586
|
+
modelOverride: "opus",
|
|
1587
|
+
fallbackModelOverride: "sonnet",
|
|
1358
1588
|
};
|
|
1359
1589
|
}
|
|
1360
1590
|
const lowercaseLabels = labels.map((label) => label.toLowerCase());
|
|
@@ -1415,8 +1645,8 @@ export class EdgeWorker extends EventEmitter {
|
|
|
1415
1645
|
// Default to claude if no runner labels found
|
|
1416
1646
|
return {
|
|
1417
1647
|
runnerType: "claude",
|
|
1418
|
-
modelOverride: "
|
|
1419
|
-
fallbackModelOverride: "
|
|
1648
|
+
modelOverride: "opus",
|
|
1649
|
+
fallbackModelOverride: "sonnet",
|
|
1420
1650
|
};
|
|
1421
1651
|
}
|
|
1422
1652
|
/**
|
|
@@ -1426,6 +1656,38 @@ export class EdgeWorker extends EventEmitter {
|
|
|
1426
1656
|
if (!repository.labelPrompts || labels.length === 0) {
|
|
1427
1657
|
return undefined;
|
|
1428
1658
|
}
|
|
1659
|
+
// Check for graphite-orchestrator first (requires BOTH graphite AND orchestrator labels)
|
|
1660
|
+
const graphiteConfig = repository.labelPrompts.graphite;
|
|
1661
|
+
const graphiteLabels = graphiteConfig?.labels ?? ["graphite"];
|
|
1662
|
+
const hasGraphiteLabel = graphiteLabels?.some((label) => labels.includes(label));
|
|
1663
|
+
const orchestratorConfig = repository.labelPrompts.orchestrator;
|
|
1664
|
+
const orchestratorLabels = Array.isArray(orchestratorConfig)
|
|
1665
|
+
? orchestratorConfig
|
|
1666
|
+
: (orchestratorConfig?.labels ?? ["orchestrator"]);
|
|
1667
|
+
const hasOrchestratorLabel = orchestratorLabels?.some((label) => labels.includes(label));
|
|
1668
|
+
// If both graphite AND orchestrator labels are present, use graphite-orchestrator prompt
|
|
1669
|
+
if (hasGraphiteLabel && hasOrchestratorLabel) {
|
|
1670
|
+
try {
|
|
1671
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
1672
|
+
const __dirname = dirname(__filename);
|
|
1673
|
+
const promptPath = join(__dirname, "..", "prompts", "graphite-orchestrator.md");
|
|
1674
|
+
const promptContent = await readFile(promptPath, "utf-8");
|
|
1675
|
+
console.log(`[EdgeWorker] Using graphite-orchestrator system prompt for labels: ${labels.join(", ")}`);
|
|
1676
|
+
const promptVersion = this.extractVersionTag(promptContent);
|
|
1677
|
+
if (promptVersion) {
|
|
1678
|
+
console.log(`[EdgeWorker] graphite-orchestrator system prompt version: ${promptVersion}`);
|
|
1679
|
+
}
|
|
1680
|
+
return {
|
|
1681
|
+
prompt: promptContent,
|
|
1682
|
+
version: promptVersion,
|
|
1683
|
+
type: "graphite-orchestrator",
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
catch (error) {
|
|
1687
|
+
console.error(`[EdgeWorker] Failed to load graphite-orchestrator prompt template:`, error);
|
|
1688
|
+
// Fall through to regular orchestrator if graphite-orchestrator prompt fails
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1429
1691
|
// Check each prompt type for matching labels
|
|
1430
1692
|
const promptTypes = [
|
|
1431
1693
|
"debugger",
|
|
@@ -1665,40 +1927,44 @@ Focus on addressing the specific request in the mention. You can use the Linear
|
|
|
1665
1927
|
return formatted;
|
|
1666
1928
|
}
|
|
1667
1929
|
/**
|
|
1668
|
-
*
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
cwd: repoPath,
|
|
1676
|
-
stdio: "pipe",
|
|
1677
|
-
});
|
|
1678
|
-
return true;
|
|
1679
|
-
}
|
|
1680
|
-
catch {
|
|
1681
|
-
// Branch doesn't exist locally, check remote
|
|
1682
|
-
try {
|
|
1683
|
-
execSync(`git ls-remote --heads origin "${branchName}"`, {
|
|
1684
|
-
cwd: repoPath,
|
|
1685
|
-
stdio: "pipe",
|
|
1686
|
-
});
|
|
1687
|
-
return true;
|
|
1688
|
-
}
|
|
1689
|
-
catch {
|
|
1690
|
-
// Branch doesn't exist remotely either
|
|
1691
|
-
return false;
|
|
1692
|
-
}
|
|
1693
|
-
}
|
|
1694
|
-
}
|
|
1695
|
-
/**
|
|
1696
|
-
* Determine the base branch for an issue, considering parent issues
|
|
1930
|
+
* Determine the base branch for an issue, considering parent issues and blocked-by relationships
|
|
1931
|
+
*
|
|
1932
|
+
* Priority order:
|
|
1933
|
+
* 1. If issue has graphite label AND has a "blocked by" relationship, use the blocking issue's branch
|
|
1934
|
+
* (This enables Graphite stacking where each sub-issue branches off the previous)
|
|
1935
|
+
* 2. If issue has a parent, use the parent's branch
|
|
1936
|
+
* 3. Fall back to repository's default base branch
|
|
1697
1937
|
*/
|
|
1698
1938
|
async determineBaseBranch(issue, repository) {
|
|
1699
1939
|
// Start with the repository's default base branch
|
|
1700
1940
|
let baseBranch = repository.baseBranch;
|
|
1701
|
-
// Check if issue has
|
|
1941
|
+
// Check if this issue has the graphite label - if so, blocked-by relationship takes priority
|
|
1942
|
+
const isGraphiteIssue = await this.hasGraphiteLabel(issue, repository);
|
|
1943
|
+
if (isGraphiteIssue) {
|
|
1944
|
+
// For Graphite stacking: use the blocking issue's branch as base
|
|
1945
|
+
const blockingIssues = await this.fetchBlockingIssues(issue);
|
|
1946
|
+
if (blockingIssues.length > 0) {
|
|
1947
|
+
// Use the first blocking issue's branch (typically there's only one in a stack)
|
|
1948
|
+
const blockingIssue = blockingIssues[0];
|
|
1949
|
+
console.log(`[EdgeWorker] Issue ${issue.identifier} has graphite label and is blocked by ${blockingIssue.identifier}`);
|
|
1950
|
+
// Get blocking issue's branch name
|
|
1951
|
+
const blockingRawBranchName = blockingIssue.branchName ||
|
|
1952
|
+
`${blockingIssue.identifier}-${(blockingIssue.title ?? "")
|
|
1953
|
+
.toLowerCase()
|
|
1954
|
+
.replace(/\s+/g, "-")
|
|
1955
|
+
.substring(0, 30)}`;
|
|
1956
|
+
const blockingBranchName = this.gitService.sanitizeBranchName(blockingRawBranchName);
|
|
1957
|
+
// Check if blocking issue's branch exists
|
|
1958
|
+
const blockingBranchExists = await this.gitService.branchExists(blockingBranchName, repository.repositoryPath);
|
|
1959
|
+
if (blockingBranchExists) {
|
|
1960
|
+
baseBranch = blockingBranchName;
|
|
1961
|
+
console.log(`[EdgeWorker] Using blocking issue branch '${blockingBranchName}' as base for Graphite-stacked issue ${issue.identifier}`);
|
|
1962
|
+
return baseBranch;
|
|
1963
|
+
}
|
|
1964
|
+
console.log(`[EdgeWorker] Blocking issue branch '${blockingBranchName}' not found, falling back to parent/default`);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
// Check if issue has a parent (standard sub-issue behavior)
|
|
1702
1968
|
try {
|
|
1703
1969
|
const parent = await issue.parent;
|
|
1704
1970
|
if (parent) {
|
|
@@ -1709,9 +1975,9 @@ Focus on addressing the specific request in the mention. You can use the Linear
|
|
|
1709
1975
|
?.toLowerCase()
|
|
1710
1976
|
.replace(/\s+/g, "-")
|
|
1711
1977
|
.substring(0, 30)}`;
|
|
1712
|
-
const parentBranchName = this.sanitizeBranchName(parentRawBranchName);
|
|
1978
|
+
const parentBranchName = this.gitService.sanitizeBranchName(parentRawBranchName);
|
|
1713
1979
|
// Check if parent branch exists
|
|
1714
|
-
const parentBranchExists = await this.branchExists(parentBranchName, repository.repositoryPath);
|
|
1980
|
+
const parentBranchExists = await this.gitService.branchExists(parentBranchName, repository.repositoryPath);
|
|
1715
1981
|
if (parentBranchExists) {
|
|
1716
1982
|
baseBranch = parentBranchName;
|
|
1717
1983
|
console.log(`[EdgeWorker] Using parent issue branch '${parentBranchName}' as base for sub-issue ${issue.identifier}`);
|
|
@@ -1740,10 +2006,60 @@ Focus on addressing the specific request in the mention. You can use the Linear
|
|
|
1740
2006
|
};
|
|
1741
2007
|
}
|
|
1742
2008
|
/**
|
|
1743
|
-
*
|
|
2009
|
+
* Fetch issues that block this issue (i.e., issues this one is "blocked by")
|
|
2010
|
+
* Uses the inverseRelations field with type "blocks"
|
|
2011
|
+
*
|
|
2012
|
+
* Linear relations work like this:
|
|
2013
|
+
* - When Issue A "blocks" Issue B, a relation is created with:
|
|
2014
|
+
* - issue = A (the blocker)
|
|
2015
|
+
* - relatedIssue = B (the blocked one)
|
|
2016
|
+
* - type = "blocks"
|
|
2017
|
+
*
|
|
2018
|
+
* So to find "who blocks Issue B", we need inverseRelations (where B is the relatedIssue)
|
|
2019
|
+
* and look for type === "blocks", then get the `issue` field (the blocker).
|
|
2020
|
+
*
|
|
2021
|
+
* @param issue The issue to fetch blocking issues for
|
|
2022
|
+
* @returns Array of issues that block this one, or empty array if none
|
|
2023
|
+
*/
|
|
2024
|
+
async fetchBlockingIssues(issue) {
|
|
2025
|
+
try {
|
|
2026
|
+
// inverseRelations contains relations where THIS issue is the relatedIssue
|
|
2027
|
+
// When type is "blocks", it means the `issue` field blocks THIS issue
|
|
2028
|
+
const inverseRelations = await issue.inverseRelations();
|
|
2029
|
+
if (!inverseRelations?.nodes) {
|
|
2030
|
+
return [];
|
|
2031
|
+
}
|
|
2032
|
+
const blockingIssues = [];
|
|
2033
|
+
for (const relation of inverseRelations.nodes) {
|
|
2034
|
+
// "blocks" type in inverseRelations means the `issue` blocks this one
|
|
2035
|
+
if (relation.type === "blocks") {
|
|
2036
|
+
// The `issue` field is the one that blocks THIS issue
|
|
2037
|
+
const blockingIssue = await relation.issue;
|
|
2038
|
+
if (blockingIssue) {
|
|
2039
|
+
blockingIssues.push(blockingIssue);
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
console.log(`[EdgeWorker] Issue ${issue.identifier} is blocked by ${blockingIssues.length} issue(s): ${blockingIssues.map((i) => i.identifier).join(", ") || "none"}`);
|
|
2044
|
+
return blockingIssues;
|
|
2045
|
+
}
|
|
2046
|
+
catch (error) {
|
|
2047
|
+
console.error(`[EdgeWorker] Failed to fetch blocking issues for ${issue.identifier}:`, error);
|
|
2048
|
+
return [];
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
/**
|
|
2052
|
+
* Check if an issue has the graphite label
|
|
2053
|
+
*
|
|
2054
|
+
* @param issue The issue to check
|
|
2055
|
+
* @param repository The repository configuration
|
|
2056
|
+
* @returns True if the issue has the graphite label
|
|
1744
2057
|
*/
|
|
1745
|
-
|
|
1746
|
-
|
|
2058
|
+
async hasGraphiteLabel(issue, repository) {
|
|
2059
|
+
const graphiteConfig = repository.labelPrompts?.graphite;
|
|
2060
|
+
const graphiteLabels = graphiteConfig?.labels ?? ["graphite"];
|
|
2061
|
+
const issueLabels = await this.fetchIssueLabels(issue);
|
|
2062
|
+
return graphiteLabels.some((label) => issueLabels.includes(label));
|
|
1747
2063
|
}
|
|
1748
2064
|
/**
|
|
1749
2065
|
* Format Linear comments into a threaded structure that mirrors the Linear UI
|
|
@@ -1887,7 +2203,7 @@ ${reply.body}
|
|
|
1887
2203
|
? "Will be created based on issue"
|
|
1888
2204
|
: repository.repositoryPath)
|
|
1889
2205
|
.replace(/{{base_branch}}/g, baseBranch)
|
|
1890
|
-
.replace(/{{branch_name}}/g, this.sanitizeBranchName(issue.branchName));
|
|
2206
|
+
.replace(/{{branch_name}}/g, this.gitService.sanitizeBranchName(issue.branchName));
|
|
1891
2207
|
// Handle the optional new comment section
|
|
1892
2208
|
if (newComment) {
|
|
1893
2209
|
// Replace the conditional block
|
|
@@ -2709,7 +3025,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
|
|
|
2709
3025
|
parts.push(issueContext.prompt);
|
|
2710
3026
|
components.push("issue-context");
|
|
2711
3027
|
// 4. Load and append initial subroutine prompt
|
|
2712
|
-
const currentSubroutine = this.
|
|
3028
|
+
const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(input.session);
|
|
2713
3029
|
let subroutineName;
|
|
2714
3030
|
if (currentSubroutine) {
|
|
2715
3031
|
const subroutinePrompt = await this.loadSubroutinePrompt(currentSubroutine, this.config.linearWorkspaceSlug);
|
|
@@ -2936,6 +3252,12 @@ ${input.userComment}
|
|
|
2936
3252
|
repository.fallbackModel ||
|
|
2937
3253
|
this.config.defaultFallbackModel,
|
|
2938
3254
|
hooks,
|
|
3255
|
+
// Enable Chrome integration for Claude runner (disabled for other runners)
|
|
3256
|
+
...(runnerType === "claude" && { extraArgs: { chrome: null } }),
|
|
3257
|
+
// AskUserQuestion callback - only for Claude runner
|
|
3258
|
+
...(runnerType === "claude" && {
|
|
3259
|
+
onAskUserQuestion: this.createAskUserQuestionCallback(linearAgentActivitySessionId, repository.linearWorkspaceId),
|
|
3260
|
+
}),
|
|
2939
3261
|
onMessage: (message) => {
|
|
2940
3262
|
this.handleClaudeMessage(linearAgentActivitySessionId, message, repository.id);
|
|
2941
3263
|
},
|
|
@@ -2952,23 +3274,43 @@ ${input.userComment}
|
|
|
2952
3274
|
}
|
|
2953
3275
|
return { config, runnerType };
|
|
2954
3276
|
}
|
|
3277
|
+
/**
|
|
3278
|
+
* Create an onAskUserQuestion callback for the ClaudeRunner.
|
|
3279
|
+
* This callback delegates to the AskUserQuestionHandler which posts
|
|
3280
|
+
* elicitations to Linear and waits for user responses.
|
|
3281
|
+
*
|
|
3282
|
+
* @param linearAgentSessionId - Linear agent session ID for tracking
|
|
3283
|
+
* @param organizationId - Linear organization/workspace ID
|
|
3284
|
+
*/
|
|
3285
|
+
createAskUserQuestionCallback(linearAgentSessionId, organizationId) {
|
|
3286
|
+
return async (input, _sessionId, signal) => {
|
|
3287
|
+
// Note: We use linearAgentSessionId (from closure) instead of the passed sessionId
|
|
3288
|
+
// because the passed sessionId is the Claude session ID, not the Linear agent session ID
|
|
3289
|
+
return this.askUserQuestionHandler.handleAskUserQuestion(input, linearAgentSessionId, organizationId, signal);
|
|
3290
|
+
};
|
|
3291
|
+
}
|
|
2955
3292
|
/**
|
|
2956
3293
|
* Build disallowed tools list following the same hierarchy as allowed tools
|
|
2957
3294
|
*/
|
|
2958
3295
|
buildDisallowedTools(repository, promptType) {
|
|
3296
|
+
// graphite-orchestrator uses the same tool config as orchestrator
|
|
3297
|
+
const effectivePromptType = promptType === "graphite-orchestrator" ? "orchestrator" : promptType;
|
|
2959
3298
|
let disallowedTools = [];
|
|
2960
3299
|
let toolSource = "";
|
|
2961
3300
|
// Priority order (same as allowedTools):
|
|
2962
3301
|
// 1. Repository-specific prompt type configuration
|
|
2963
|
-
if (
|
|
2964
|
-
|
|
2965
|
-
|
|
3302
|
+
if (effectivePromptType &&
|
|
3303
|
+
repository.labelPrompts?.[effectivePromptType]?.disallowedTools) {
|
|
3304
|
+
disallowedTools =
|
|
3305
|
+
repository.labelPrompts[effectivePromptType].disallowedTools;
|
|
3306
|
+
toolSource = `repository label prompt (${effectivePromptType})`;
|
|
2966
3307
|
}
|
|
2967
3308
|
// 2. Global prompt type defaults
|
|
2968
|
-
else if (
|
|
2969
|
-
this.config.promptDefaults?.[
|
|
2970
|
-
disallowedTools =
|
|
2971
|
-
|
|
3309
|
+
else if (effectivePromptType &&
|
|
3310
|
+
this.config.promptDefaults?.[effectivePromptType]?.disallowedTools) {
|
|
3311
|
+
disallowedTools =
|
|
3312
|
+
this.config.promptDefaults[effectivePromptType].disallowedTools;
|
|
3313
|
+
toolSource = `global prompt defaults (${effectivePromptType})`;
|
|
2972
3314
|
}
|
|
2973
3315
|
// 3. Repository-level disallowed tools
|
|
2974
3316
|
else if (repository.disallowedTools) {
|
|
@@ -2998,7 +3340,7 @@ ${input.userComment}
|
|
|
2998
3340
|
* @returns Merged disallowed tools list
|
|
2999
3341
|
*/
|
|
3000
3342
|
mergeSubroutineDisallowedTools(session, baseDisallowedTools, logContext) {
|
|
3001
|
-
const currentSubroutine = this.
|
|
3343
|
+
const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
|
|
3002
3344
|
if (currentSubroutine?.disallowedTools) {
|
|
3003
3345
|
const mergedTools = [
|
|
3004
3346
|
...new Set([
|
|
@@ -3015,19 +3357,22 @@ ${input.userComment}
|
|
|
3015
3357
|
* Build allowed tools list with Linear MCP tools automatically included
|
|
3016
3358
|
*/
|
|
3017
3359
|
buildAllowedTools(repository, promptType) {
|
|
3360
|
+
// graphite-orchestrator uses the same tool config as orchestrator
|
|
3361
|
+
const effectivePromptType = promptType === "graphite-orchestrator" ? "orchestrator" : promptType;
|
|
3018
3362
|
let baseTools = [];
|
|
3019
3363
|
let toolSource = "";
|
|
3020
3364
|
// Priority order:
|
|
3021
3365
|
// 1. Repository-specific prompt type configuration
|
|
3022
|
-
if (
|
|
3023
|
-
|
|
3024
|
-
|
|
3366
|
+
if (effectivePromptType &&
|
|
3367
|
+
repository.labelPrompts?.[effectivePromptType]?.allowedTools) {
|
|
3368
|
+
baseTools = this.resolveToolPreset(repository.labelPrompts[effectivePromptType].allowedTools);
|
|
3369
|
+
toolSource = `repository label prompt (${effectivePromptType})`;
|
|
3025
3370
|
}
|
|
3026
3371
|
// 2. Global prompt type defaults
|
|
3027
|
-
else if (
|
|
3028
|
-
this.config.promptDefaults?.[
|
|
3029
|
-
baseTools = this.resolveToolPreset(this.config.promptDefaults[
|
|
3030
|
-
toolSource = `global prompt defaults (${
|
|
3372
|
+
else if (effectivePromptType &&
|
|
3373
|
+
this.config.promptDefaults?.[effectivePromptType]?.allowedTools) {
|
|
3374
|
+
baseTools = this.resolveToolPreset(this.config.promptDefaults[effectivePromptType].allowedTools);
|
|
3375
|
+
toolSource = `global prompt defaults (${effectivePromptType})`;
|
|
3031
3376
|
}
|
|
3032
3377
|
// 3. Repository-level allowed tools
|
|
3033
3378
|
else if (repository.allowedTools) {
|
|
@@ -3261,7 +3606,7 @@ ${input.userComment}
|
|
|
3261
3606
|
session.metadata = {};
|
|
3262
3607
|
}
|
|
3263
3608
|
// Post ephemeral "Routing..." thought
|
|
3264
|
-
await agentSessionManager.
|
|
3609
|
+
await agentSessionManager.postAnalyzingThought(linearAgentActivitySessionId);
|
|
3265
3610
|
// Fetch full issue and labels to check for Orchestrator label override
|
|
3266
3611
|
const issueTracker = this.issueTrackers.get(repository.id);
|
|
3267
3612
|
let hasOrchestratorLabel = false;
|
|
@@ -3273,7 +3618,7 @@ ${input.userComment}
|
|
|
3273
3618
|
const orchestratorConfig = repository.labelPrompts?.orchestrator;
|
|
3274
3619
|
const orchestratorLabels = Array.isArray(orchestratorConfig)
|
|
3275
3620
|
? orchestratorConfig
|
|
3276
|
-
: orchestratorConfig?.labels;
|
|
3621
|
+
: (orchestratorConfig?.labels ?? ["orchestrator"]);
|
|
3277
3622
|
hasOrchestratorLabel =
|
|
3278
3623
|
orchestratorLabels?.some((label) => labels.includes(label)) || false;
|
|
3279
3624
|
}
|
|
@@ -3286,7 +3631,7 @@ ${input.userComment}
|
|
|
3286
3631
|
let finalClassification;
|
|
3287
3632
|
// If Orchestrator label is present, ALWAYS use orchestrator-full procedure
|
|
3288
3633
|
if (hasOrchestratorLabel) {
|
|
3289
|
-
const orchestratorProcedure = this.
|
|
3634
|
+
const orchestratorProcedure = this.procedureAnalyzer.getProcedure("orchestrator-full");
|
|
3290
3635
|
if (!orchestratorProcedure) {
|
|
3291
3636
|
throw new Error("orchestrator-full procedure not found in registry");
|
|
3292
3637
|
}
|
|
@@ -3296,7 +3641,7 @@ ${input.userComment}
|
|
|
3296
3641
|
}
|
|
3297
3642
|
else {
|
|
3298
3643
|
// No Orchestrator label - use AI routing based on prompt content
|
|
3299
|
-
const routingDecision = await this.
|
|
3644
|
+
const routingDecision = await this.procedureAnalyzer.determineRoutine(promptBody.trim());
|
|
3300
3645
|
selectedProcedure = routingDecision.procedure;
|
|
3301
3646
|
finalClassification = routingDecision.classification;
|
|
3302
3647
|
// Log AI routing decision
|
|
@@ -3306,7 +3651,7 @@ ${input.userComment}
|
|
|
3306
3651
|
console.log(` Reasoning: ${routingDecision.reasoning}`);
|
|
3307
3652
|
}
|
|
3308
3653
|
// Initialize procedure metadata in session (resets currentSubroutine)
|
|
3309
|
-
this.
|
|
3654
|
+
this.procedureAnalyzer.initializeProcedureMetadata(session, selectedProcedure);
|
|
3310
3655
|
// Post procedure selection result (replaces ephemeral routing thought)
|
|
3311
3656
|
await agentSessionManager.postProcedureSelectionThought(linearAgentActivitySessionId, selectedProcedure.name, finalClassification);
|
|
3312
3657
|
}
|
|
@@ -3412,7 +3757,7 @@ ${input.userComment}
|
|
|
3412
3757
|
const orchestratorConfig = repository.labelPrompts.orchestrator;
|
|
3413
3758
|
const orchestratorLabels = Array.isArray(orchestratorConfig)
|
|
3414
3759
|
? orchestratorConfig
|
|
3415
|
-
: orchestratorConfig?.labels;
|
|
3760
|
+
: (orchestratorConfig?.labels ?? ["orchestrator"]);
|
|
3416
3761
|
const orchestratorLabel = orchestratorLabels?.find((label) => labels.includes(label));
|
|
3417
3762
|
if (orchestratorLabel) {
|
|
3418
3763
|
selectedPromptType = "orchestrator";
|
|
@@ -3505,7 +3850,7 @@ ${input.userComment}
|
|
|
3505
3850
|
...additionalAllowedDirectories,
|
|
3506
3851
|
];
|
|
3507
3852
|
// Get current subroutine to check for singleTurn mode
|
|
3508
|
-
const currentSubroutine = this.
|
|
3853
|
+
const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
|
|
3509
3854
|
const resumeSessionId = needsNewSession
|
|
3510
3855
|
? undefined
|
|
3511
3856
|
: session.claudeSessionId
|