cyrus-edge-worker 0.0.18 → 0.0.20
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/LICENSE +674 -21
- package/dist/AgentSessionManager.d.ts +142 -0
- package/dist/AgentSessionManager.d.ts.map +1 -0
- package/dist/AgentSessionManager.js +720 -0
- package/dist/AgentSessionManager.js.map +1 -0
- package/dist/EdgeWorker.d.ts +49 -74
- package/dist/EdgeWorker.d.ts.map +1 -1
- package/dist/EdgeWorker.js +704 -886
- package/dist/EdgeWorker.js.map +1 -1
- package/dist/SharedApplicationServer.d.ts +1 -3
- package/dist/SharedApplicationServer.d.ts.map +1 -1
- package/dist/SharedApplicationServer.js +63 -60
- package/dist/SharedApplicationServer.js.map +1 -1
- package/dist/SharedWebhookServer.d.ts.map +1 -1
- package/dist/SharedWebhookServer.js +32 -32
- package/dist/SharedWebhookServer.js.map +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +18 -12
- package/dist/types.d.ts.map +1 -1
- package/label-prompt-template.md +12 -0
- package/package.json +9 -6
- package/prompt-template.md +116 -0
- package/prompts/builder.md +213 -0
- package/prompts/debugger.md +91 -0
- package/prompts/scoper.md +95 -0
package/dist/EdgeWorker.js
CHANGED
|
@@ -1,42 +1,36 @@
|
|
|
1
|
-
import { EventEmitter } from
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { existsSync } from 'fs';
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { LinearClient, } from "@linear/sdk";
|
|
7
|
+
import { ClaudeRunner, getSafeTools } from "cyrus-claude-runner";
|
|
8
|
+
import { isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueUnassignedWebhook, PersistenceManager, } from "cyrus-core";
|
|
9
|
+
import { NdjsonClient } from "cyrus-ndjson-client";
|
|
10
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
11
|
+
import { AgentSessionManager } from "./AgentSessionManager.js";
|
|
12
|
+
import { SharedApplicationServer } from "./SharedApplicationServer.js";
|
|
14
13
|
/**
|
|
15
|
-
* Unified edge worker that orchestrates
|
|
14
|
+
* Unified edge worker that **orchestrates**
|
|
15
|
+
* capturing Linear webhooks,
|
|
16
|
+
* managing Claude Code processes, and
|
|
17
|
+
* processes results through to Linear Agent Activity Sessions
|
|
16
18
|
*/
|
|
17
19
|
export class EdgeWorker extends EventEmitter {
|
|
18
20
|
config;
|
|
19
|
-
repositories = new Map();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
repositories = new Map(); // repository 'id' (internal, stored in config.json) mapped to the full repo config
|
|
22
|
+
agentSessionManagers = new Map(); // Maps repository ID to AgentSessionManager, which manages ClaudeRunners for a repo
|
|
23
|
+
linearClients = new Map(); // one linear client per 'repository'
|
|
24
|
+
ndjsonClients = new Map(); // listeners for webhook events, one per linear token
|
|
23
25
|
persistenceManager;
|
|
24
|
-
claudeRunners = new Map(); // Maps comment ID to ClaudeRunner
|
|
25
|
-
commentToRepo = new Map(); // Maps comment ID to repository ID
|
|
26
|
-
commentToIssue = new Map(); // Maps comment ID to issue ID
|
|
27
|
-
commentToLatestAgentReply = new Map(); // Maps thread root comment ID to latest agent comment
|
|
28
|
-
issueToCommentThreads = new Map(); // Maps issue ID to all comment thread IDs
|
|
29
|
-
tokenToClientId = new Map(); // Maps token to NDJSON client ID
|
|
30
|
-
issueToReplyContext = new Map(); // Maps issue ID to reply context
|
|
31
26
|
sharedApplicationServer;
|
|
32
27
|
constructor(config) {
|
|
33
28
|
super();
|
|
34
29
|
this.config = config;
|
|
35
30
|
this.persistenceManager = new PersistenceManager();
|
|
36
|
-
this.sessionManager = new SessionManager(this.persistenceManager);
|
|
37
31
|
// Initialize shared application server
|
|
38
32
|
const serverPort = config.serverPort || config.webhookPort || 3456;
|
|
39
|
-
const serverHost = config.serverHost ||
|
|
33
|
+
const serverHost = config.serverHost || "localhost";
|
|
40
34
|
this.sharedApplicationServer = new SharedApplicationServer(serverPort, serverHost, config.ngrokAuthToken, config.proxyUrl);
|
|
41
35
|
// Register OAuth callback handler if provided
|
|
42
36
|
if (config.handlers?.onOAuthCallback) {
|
|
@@ -47,9 +41,12 @@ export class EdgeWorker extends EventEmitter {
|
|
|
47
41
|
if (repo.isActive !== false) {
|
|
48
42
|
this.repositories.set(repo.id, repo);
|
|
49
43
|
// Create Linear client for this repository's workspace
|
|
50
|
-
|
|
51
|
-
accessToken: repo.linearToken
|
|
52
|
-
})
|
|
44
|
+
const linearClient = new LinearClient({
|
|
45
|
+
accessToken: repo.linearToken,
|
|
46
|
+
});
|
|
47
|
+
this.linearClients.set(repo.id, linearClient);
|
|
48
|
+
// Create AgentSessionManager for this repository
|
|
49
|
+
this.agentSessionManagers.set(repo.id, new AgentSessionManager(linearClient));
|
|
53
50
|
}
|
|
54
51
|
}
|
|
55
52
|
// Group repositories by token to minimize NDJSON connections
|
|
@@ -70,34 +67,33 @@ export class EdgeWorker extends EventEmitter {
|
|
|
70
67
|
const ndjsonClient = new NdjsonClient({
|
|
71
68
|
proxyUrl: config.proxyUrl,
|
|
72
69
|
token: token,
|
|
73
|
-
name: repos.map(r => r.name).join(
|
|
74
|
-
transport:
|
|
70
|
+
name: repos.map((r) => r.name).join(", "), // Pass repository names
|
|
71
|
+
transport: "webhook",
|
|
75
72
|
// Use shared application server instead of individual servers
|
|
76
73
|
useExternalWebhookServer: true,
|
|
77
74
|
externalWebhookServer: this.sharedApplicationServer,
|
|
78
75
|
webhookPort: serverPort, // All clients use same port
|
|
79
|
-
webhookPath:
|
|
76
|
+
webhookPath: "/webhook",
|
|
80
77
|
webhookHost: serverHost,
|
|
81
78
|
...(config.baseUrl && { webhookBaseUrl: config.baseUrl }),
|
|
82
79
|
// Legacy fallback support
|
|
83
|
-
...(!config.baseUrl &&
|
|
80
|
+
...(!config.baseUrl &&
|
|
81
|
+
config.webhookBaseUrl && { webhookBaseUrl: config.webhookBaseUrl }),
|
|
84
82
|
onConnect: () => this.handleConnect(primaryRepoId, repos),
|
|
85
83
|
onDisconnect: (reason) => this.handleDisconnect(primaryRepoId, repos, reason),
|
|
86
|
-
onError: (error) => this.handleError(error)
|
|
84
|
+
onError: (error) => this.handleError(error),
|
|
87
85
|
});
|
|
88
86
|
// Set up webhook handler - data should be the native webhook payload
|
|
89
|
-
ndjsonClient.on(
|
|
87
|
+
ndjsonClient.on("webhook", (data) => this.handleWebhook(data, repos));
|
|
90
88
|
// Optional heartbeat logging
|
|
91
|
-
if (process.env.DEBUG_EDGE ===
|
|
92
|
-
ndjsonClient.on(
|
|
89
|
+
if (process.env.DEBUG_EDGE === "true") {
|
|
90
|
+
ndjsonClient.on("heartbeat", () => {
|
|
93
91
|
console.log(`❤️ Heartbeat received for token ending in ...${token.slice(-4)}`);
|
|
94
92
|
});
|
|
95
93
|
}
|
|
96
94
|
// Store with the first repo's ID as the key (for error messages)
|
|
97
95
|
// But also store the token mapping for lookup
|
|
98
96
|
this.ndjsonClients.set(primaryRepoId, ndjsonClient);
|
|
99
|
-
// Store token to client mapping for other lookups if needed
|
|
100
|
-
this.tokenToClientId.set(token, primaryRepoId);
|
|
101
97
|
}
|
|
102
98
|
}
|
|
103
99
|
/**
|
|
@@ -114,12 +110,12 @@ export class EdgeWorker extends EventEmitter {
|
|
|
114
110
|
await client.connect();
|
|
115
111
|
}
|
|
116
112
|
catch (error) {
|
|
117
|
-
const repoConfig = this.config.repositories.find(r => r.id === repoId);
|
|
113
|
+
const repoConfig = this.config.repositories.find((r) => r.id === repoId);
|
|
118
114
|
const repoName = repoConfig?.name || repoId;
|
|
119
115
|
// Check if it's an authentication error
|
|
120
|
-
if (error.isAuthError || error.code ===
|
|
116
|
+
if (error.isAuthError || error.code === "LINEAR_AUTH_FAILED") {
|
|
121
117
|
console.error(`\n❌ Linear authentication failed for repository: ${repoName}`);
|
|
122
|
-
console.error(` Workspace: ${repoConfig?.linearWorkspaceName || repoConfig?.linearWorkspaceId ||
|
|
118
|
+
console.error(` Workspace: ${repoConfig?.linearWorkspaceName || repoConfig?.linearWorkspaceId || "Unknown"}`);
|
|
123
119
|
console.error(` Error: ${error.message}`);
|
|
124
120
|
console.error(`\n To fix this issue:`);
|
|
125
121
|
console.error(` 1. Run: cyrus refresh-token`);
|
|
@@ -137,17 +133,17 @@ export class EdgeWorker extends EventEmitter {
|
|
|
137
133
|
return { repoId, success: true };
|
|
138
134
|
});
|
|
139
135
|
const results = await Promise.all(connections);
|
|
140
|
-
const failures = results.filter(r => !r.success);
|
|
136
|
+
const failures = results.filter((r) => !r.success);
|
|
141
137
|
if (failures.length === this.ndjsonClients.size) {
|
|
142
138
|
// All connections failed
|
|
143
|
-
throw new Error(
|
|
139
|
+
throw new Error("Failed to connect any repositories. Please check your configuration and Linear tokens.");
|
|
144
140
|
}
|
|
145
141
|
else if (failures.length > 0) {
|
|
146
142
|
// Some connections failed
|
|
147
143
|
console.warn(`\n⚠️ Connected ${results.length - failures.length} out of ${results.length} repositories`);
|
|
148
144
|
console.warn(` The following repositories could not be connected:`);
|
|
149
|
-
failures.forEach(f => {
|
|
150
|
-
const repoConfig = this.config.repositories.find(r => r.id === f.repoId);
|
|
145
|
+
failures.forEach((f) => {
|
|
146
|
+
const repoConfig = this.config.repositories.find((r) => r.id === f.repoId);
|
|
151
147
|
console.warn(` - ${repoConfig?.name || f.repoId}`);
|
|
152
148
|
});
|
|
153
149
|
console.warn(`\n Cyrus will continue running with the available repositories.\n`);
|
|
@@ -157,26 +153,29 @@ export class EdgeWorker extends EventEmitter {
|
|
|
157
153
|
* Stop the edge worker
|
|
158
154
|
*/
|
|
159
155
|
async stop() {
|
|
156
|
+
try {
|
|
157
|
+
await this.savePersistedState();
|
|
158
|
+
console.log("✅ EdgeWorker state saved successfully");
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
console.error("❌ Failed to save EdgeWorker state during shutdown:", error);
|
|
162
|
+
}
|
|
163
|
+
// get all claudeRunners
|
|
164
|
+
const claudeRunners = [];
|
|
165
|
+
for (const agentSessionManager of this.agentSessionManagers.values()) {
|
|
166
|
+
claudeRunners.push(...agentSessionManager.getAllClaudeRunners());
|
|
167
|
+
}
|
|
160
168
|
// Kill all Claude processes with null checking
|
|
161
|
-
for (const
|
|
162
|
-
if (runner
|
|
169
|
+
for (const runner of claudeRunners) {
|
|
170
|
+
if (runner) {
|
|
163
171
|
try {
|
|
164
172
|
runner.stop();
|
|
165
173
|
}
|
|
166
174
|
catch (error) {
|
|
167
|
-
console.error(
|
|
175
|
+
console.error("Error stopping Claude runner:", error);
|
|
168
176
|
}
|
|
169
177
|
}
|
|
170
178
|
}
|
|
171
|
-
this.claudeRunners.clear();
|
|
172
|
-
// Clear all sessions
|
|
173
|
-
for (const [commentId] of this.sessionManager.getAllSessions()) {
|
|
174
|
-
this.sessionManager.removeSession(commentId);
|
|
175
|
-
}
|
|
176
|
-
this.commentToRepo.clear();
|
|
177
|
-
this.commentToIssue.clear();
|
|
178
|
-
this.commentToLatestAgentReply.clear();
|
|
179
|
-
this.issueToCommentThreads.clear();
|
|
180
179
|
// Disconnect all NDJSON clients
|
|
181
180
|
for (const client of this.ndjsonClients.values()) {
|
|
182
181
|
client.disconnect();
|
|
@@ -190,7 +189,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
190
189
|
handleConnect(clientId, repos) {
|
|
191
190
|
// Get the token for backward compatibility with events
|
|
192
191
|
const token = repos[0]?.linearToken || clientId;
|
|
193
|
-
this.emit(
|
|
192
|
+
this.emit("connected", token);
|
|
194
193
|
// Connection logged by CLI app event handler
|
|
195
194
|
}
|
|
196
195
|
/**
|
|
@@ -199,52 +198,33 @@ export class EdgeWorker extends EventEmitter {
|
|
|
199
198
|
handleDisconnect(clientId, repos, reason) {
|
|
200
199
|
// Get the token for backward compatibility with events
|
|
201
200
|
const token = repos[0]?.linearToken || clientId;
|
|
202
|
-
this.emit(
|
|
201
|
+
this.emit("disconnected", token, reason);
|
|
203
202
|
}
|
|
204
203
|
/**
|
|
205
204
|
* Handle errors
|
|
206
205
|
*/
|
|
207
206
|
handleError(error) {
|
|
208
|
-
this.emit(
|
|
207
|
+
this.emit("error", error);
|
|
209
208
|
this.config.handlers?.onError?.(error);
|
|
210
209
|
}
|
|
211
|
-
/**
|
|
212
|
-
* Check if Claude logs exist for a workspace
|
|
213
|
-
*/
|
|
214
|
-
async hasExistingLogs(workspaceName) {
|
|
215
|
-
try {
|
|
216
|
-
const logsDir = join(homedir(), '.cyrus', 'logs', workspaceName);
|
|
217
|
-
// Check if directory exists
|
|
218
|
-
if (!existsSync(logsDir)) {
|
|
219
|
-
return false;
|
|
220
|
-
}
|
|
221
|
-
// Check if directory has any log files
|
|
222
|
-
const files = await readdir(logsDir);
|
|
223
|
-
return files.some(file => file.endsWith('.jsonl'));
|
|
224
|
-
}
|
|
225
|
-
catch (error) {
|
|
226
|
-
console.error(`Failed to check logs for workspace ${workspaceName}:`, error);
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
210
|
/**
|
|
231
211
|
* Handle webhook events from proxy - now accepts native webhook payloads
|
|
232
212
|
*/
|
|
233
213
|
async handleWebhook(webhook, repos) {
|
|
234
214
|
console.log(`[EdgeWorker] Processing webhook: ${webhook.type}`);
|
|
235
215
|
// Log verbose webhook info if enabled
|
|
236
|
-
if (process.env.CYRUS_WEBHOOK_DEBUG ===
|
|
216
|
+
if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
|
|
237
217
|
console.log(`[EdgeWorker] Webhook payload:`, JSON.stringify(webhook, null, 2));
|
|
238
218
|
}
|
|
239
219
|
// Find the appropriate repository for this webhook
|
|
240
220
|
const repository = this.findRepositoryForWebhook(webhook, repos);
|
|
241
221
|
if (!repository) {
|
|
242
|
-
console.log(
|
|
243
|
-
if (process.env.CYRUS_WEBHOOK_DEBUG ===
|
|
244
|
-
console.log(
|
|
222
|
+
console.log("No repository configured for webhook from workspace", webhook.organizationId);
|
|
223
|
+
if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
|
|
224
|
+
console.log("Available repositories:", repos.map((r) => ({
|
|
245
225
|
name: r.name,
|
|
246
226
|
workspaceId: r.linearWorkspaceId,
|
|
247
|
-
teamKeys: r.teamKeys
|
|
227
|
+
teamKeys: r.teamKeys,
|
|
248
228
|
})));
|
|
249
229
|
}
|
|
250
230
|
return;
|
|
@@ -252,18 +232,29 @@ export class EdgeWorker extends EventEmitter {
|
|
|
252
232
|
console.log(`[EdgeWorker] Webhook matched to repository: ${repository.name}`);
|
|
253
233
|
try {
|
|
254
234
|
// Handle specific webhook types with proper typing
|
|
235
|
+
// NOTE: Traditional webhooks (assigned, comment) are disabled in favor of agent session events
|
|
255
236
|
if (isIssueAssignedWebhook(webhook)) {
|
|
256
|
-
|
|
237
|
+
console.log(`[EdgeWorker] Ignoring traditional issue assigned webhook - using agent session events instead`);
|
|
238
|
+
return;
|
|
257
239
|
}
|
|
258
240
|
else if (isIssueCommentMentionWebhook(webhook)) {
|
|
259
|
-
|
|
241
|
+
console.log(`[EdgeWorker] Ignoring traditional comment mention webhook - using agent session events instead`);
|
|
242
|
+
return;
|
|
260
243
|
}
|
|
261
244
|
else if (isIssueNewCommentWebhook(webhook)) {
|
|
262
|
-
|
|
245
|
+
console.log(`[EdgeWorker] Ignoring traditional new comment webhook - using agent session events instead`);
|
|
246
|
+
return;
|
|
263
247
|
}
|
|
264
248
|
else if (isIssueUnassignedWebhook(webhook)) {
|
|
249
|
+
// Keep unassigned webhook active
|
|
265
250
|
await this.handleIssueUnassignedWebhook(webhook, repository);
|
|
266
251
|
}
|
|
252
|
+
else if (isAgentSessionCreatedWebhook(webhook)) {
|
|
253
|
+
await this.handleAgentSessionCreatedWebhook(webhook, repository);
|
|
254
|
+
}
|
|
255
|
+
else if (isAgentSessionPromptedWebhook(webhook)) {
|
|
256
|
+
await this.handleUserPostedAgentActivity(webhook, repository);
|
|
257
|
+
}
|
|
267
258
|
else {
|
|
268
259
|
console.log(`Unhandled webhook type: ${webhook.action}`);
|
|
269
260
|
}
|
|
@@ -274,32 +265,6 @@ export class EdgeWorker extends EventEmitter {
|
|
|
274
265
|
// The error has been logged and individual webhook failures shouldn't crash the entire system
|
|
275
266
|
}
|
|
276
267
|
}
|
|
277
|
-
/**
|
|
278
|
-
* Handle issue assignment webhook
|
|
279
|
-
*/
|
|
280
|
-
async handleIssueAssignedWebhook(webhook, repository) {
|
|
281
|
-
console.log(`[EdgeWorker] Handling issue assignment: ${webhook.notification.issue.identifier}`);
|
|
282
|
-
await this.handleIssueAssigned(webhook.notification.issue, repository);
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* Handle issue comment mention webhook
|
|
286
|
-
*/
|
|
287
|
-
async handleIssueCommentMentionWebhook(webhook, repository) {
|
|
288
|
-
console.log(`[EdgeWorker] Handling comment mention: ${webhook.notification.issue.identifier}`);
|
|
289
|
-
await this.handleNewComment(webhook.notification.issue, webhook.notification.comment, repository);
|
|
290
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* Handle issue new comment webhook
|
|
293
|
-
*/
|
|
294
|
-
async handleIssueNewCommentWebhook(webhook, repository) {
|
|
295
|
-
console.log(`[EdgeWorker] Handling new comment: ${webhook.notification.issue.identifier}`);
|
|
296
|
-
// Check if the comment mentions the agent (Cyrus) before proceeding
|
|
297
|
-
if (!(await this.isAgentMentionedInComment(webhook.notification.comment, repository))) {
|
|
298
|
-
console.log(`[EdgeWorker] Comment does not mention agent, ignoring: ${webhook.notification.issue.identifier}`);
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
await this.handleNewComment(webhook.notification.issue, webhook.notification.comment, repository);
|
|
302
|
-
}
|
|
303
268
|
/**
|
|
304
269
|
* Handle issue unassignment webhook
|
|
305
270
|
*/
|
|
@@ -318,58 +283,87 @@ export class EdgeWorker extends EventEmitter {
|
|
|
318
283
|
const workspaceId = webhook.organizationId;
|
|
319
284
|
if (!workspaceId)
|
|
320
285
|
return repos[0] || null; // Fallback to first repo if no workspace ID
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
333
|
-
const
|
|
286
|
+
// Handle agent session webhooks which have different structure
|
|
287
|
+
if (isAgentSessionCreatedWebhook(webhook) ||
|
|
288
|
+
isAgentSessionPromptedWebhook(webhook)) {
|
|
289
|
+
const teamKey = webhook.agentSession?.issue?.team?.key;
|
|
290
|
+
if (teamKey) {
|
|
291
|
+
const repo = repos.find((r) => r.teamKeys?.includes(teamKey));
|
|
292
|
+
if (repo)
|
|
293
|
+
return repo;
|
|
294
|
+
}
|
|
295
|
+
// Try parsing issue identifier as fallback
|
|
296
|
+
const issueId = webhook.agentSession?.issue?.identifier;
|
|
297
|
+
if (issueId?.includes("-")) {
|
|
298
|
+
const prefix = issueId.split("-")[0];
|
|
299
|
+
if (prefix) {
|
|
300
|
+
const repo = repos.find((r) => r.teamKeys?.includes(prefix));
|
|
301
|
+
if (repo)
|
|
302
|
+
return repo;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
// Original logic for other webhook types
|
|
308
|
+
const teamKey = webhook.notification?.issue?.team?.key;
|
|
309
|
+
if (teamKey) {
|
|
310
|
+
const repo = repos.find((r) => r.teamKeys?.includes(teamKey));
|
|
334
311
|
if (repo)
|
|
335
312
|
return repo;
|
|
336
313
|
}
|
|
314
|
+
// Try parsing issue identifier as fallback
|
|
315
|
+
const issueId = webhook.notification?.issue?.identifier;
|
|
316
|
+
if (issueId?.includes("-")) {
|
|
317
|
+
const prefix = issueId.split("-")[0];
|
|
318
|
+
if (prefix) {
|
|
319
|
+
const repo = repos.find((r) => r.teamKeys?.includes(prefix));
|
|
320
|
+
if (repo)
|
|
321
|
+
return repo;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
337
324
|
}
|
|
338
325
|
// Original workspace fallback - find first repo without teamKeys or matching workspace
|
|
339
|
-
return repos.find(
|
|
326
|
+
return (repos.find((repo) => repo.linearWorkspaceId === workspaceId &&
|
|
327
|
+
(!repo.teamKeys || repo.teamKeys.length === 0)) ||
|
|
328
|
+
repos.find((repo) => repo.linearWorkspaceId === workspaceId) ||
|
|
329
|
+
null);
|
|
340
330
|
}
|
|
341
331
|
/**
|
|
342
|
-
* Handle
|
|
343
|
-
*
|
|
332
|
+
* Handle agent session created webhook
|
|
333
|
+
* . Can happen due to being 'delegated' or @ mentioned in a new thread
|
|
334
|
+
* @param webhook
|
|
344
335
|
* @param repository Repository configuration
|
|
345
336
|
*/
|
|
346
|
-
async
|
|
347
|
-
console.log(`[EdgeWorker]
|
|
337
|
+
async handleAgentSessionCreatedWebhook(webhook, repository) {
|
|
338
|
+
console.log(`[EdgeWorker] Handling agent session created: ${webhook.agentSession.issue.identifier}`);
|
|
339
|
+
const { agentSession } = webhook;
|
|
340
|
+
const linearAgentActivitySessionId = agentSession.id;
|
|
341
|
+
const { issue } = agentSession;
|
|
342
|
+
// Initialize the agent session in AgentSessionManager
|
|
343
|
+
const agentSessionManager = this.agentSessionManagers.get(repository.id);
|
|
344
|
+
if (!agentSessionManager) {
|
|
345
|
+
console.error("There was no agentSessionManage for the repository with id", repository.id);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// Post instant acknowledgment thought
|
|
349
|
+
await this.postInstantAcknowledgment(linearAgentActivitySessionId, repository.id);
|
|
348
350
|
// Fetch full Linear issue details immediately
|
|
349
351
|
const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
|
|
350
352
|
if (!fullIssue) {
|
|
351
353
|
throw new Error(`Failed to fetch full issue details for ${issue.id}`);
|
|
352
354
|
}
|
|
353
|
-
|
|
354
|
-
await this.handleIssueAssignedWithFullIssue(fullIssue, repository);
|
|
355
|
-
}
|
|
356
|
-
async handleIssueAssignedWithFullIssue(fullIssue, repository) {
|
|
357
|
-
console.log(`[EdgeWorker] handleIssueAssignedWithFullIssue started for issue ${fullIssue.identifier} (${fullIssue.id})`);
|
|
358
|
-
// Move issue to started state automatically
|
|
355
|
+
// Move issue to started state automatically, in case it's not already
|
|
359
356
|
await this.moveIssueToStartedState(fullIssue, repository.id);
|
|
360
|
-
// Post initial comment immediately
|
|
361
|
-
const initialComment = await this.postInitialComment(fullIssue.id, repository.id);
|
|
362
|
-
if (!initialComment?.id) {
|
|
363
|
-
throw new Error(`Failed to create initial comment for issue ${fullIssue.identifier}`);
|
|
364
|
-
}
|
|
365
357
|
// Create workspace using full issue data
|
|
366
358
|
const workspace = this.config.handlers?.createWorkspace
|
|
367
359
|
? await this.config.handlers.createWorkspace(fullIssue, repository)
|
|
368
360
|
: {
|
|
369
361
|
path: `${repository.workspaceBaseDir}/${fullIssue.identifier}`,
|
|
370
|
-
isGitWorktree: false
|
|
362
|
+
isGitWorktree: false,
|
|
371
363
|
};
|
|
372
364
|
console.log(`[EdgeWorker] Workspace created at: ${workspace.path}`);
|
|
365
|
+
const issueMinimal = this.convertLinearIssueToCore(fullIssue);
|
|
366
|
+
agentSessionManager.createLinearAgentSession(linearAgentActivitySessionId, issue.id, issueMinimal, workspace);
|
|
373
367
|
// Download attachments before creating Claude runner
|
|
374
368
|
const attachmentResult = await this.downloadIssueAttachments(fullIssue, repository, workspace.path);
|
|
375
369
|
// Build allowed directories list
|
|
@@ -379,7 +373,18 @@ export class EdgeWorker extends EventEmitter {
|
|
|
379
373
|
}
|
|
380
374
|
// Build allowed tools list with Linear MCP tools
|
|
381
375
|
const allowedTools = this.buildAllowedTools(repository);
|
|
382
|
-
//
|
|
376
|
+
// Fetch issue labels and determine system prompt
|
|
377
|
+
const labels = await this.fetchIssueLabels(fullIssue);
|
|
378
|
+
const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
|
|
379
|
+
const systemPrompt = systemPromptResult?.prompt;
|
|
380
|
+
const systemPromptVersion = systemPromptResult?.version;
|
|
381
|
+
// Post thought about system prompt selection
|
|
382
|
+
if (systemPrompt) {
|
|
383
|
+
await this.postSystemPromptSelectionThought(linearAgentActivitySessionId, labels, repository.id);
|
|
384
|
+
}
|
|
385
|
+
// Create Claude runner with attachment directory access and optional system prompt
|
|
386
|
+
// Always append the last message marker to prevent duplication
|
|
387
|
+
const lastMessageMarker = "\n\n___LAST_MESSAGE_MARKER___\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.";
|
|
383
388
|
const runner = new ClaudeRunner({
|
|
384
389
|
workingDirectory: workspace.path,
|
|
385
390
|
allowedTools,
|
|
@@ -387,134 +392,39 @@ export class EdgeWorker extends EventEmitter {
|
|
|
387
392
|
workspaceName: fullIssue.identifier,
|
|
388
393
|
mcpConfigPath: repository.mcpConfigPath,
|
|
389
394
|
mcpConfig: this.buildMcpConfig(repository),
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
395
|
+
appendSystemPrompt: (systemPrompt || "") + lastMessageMarker,
|
|
396
|
+
onMessage: (message) => this.handleClaudeMessage(linearAgentActivitySessionId, message, repository.id),
|
|
397
|
+
// onComplete: (messages) => this.handleClaudeComplete(initialComment.id, messages, repository.id),
|
|
398
|
+
onError: (error) => this.handleClaudeError(error),
|
|
393
399
|
});
|
|
394
400
|
// Store runner by comment ID
|
|
395
|
-
|
|
396
|
-
this.commentToRepo.set(initialComment.id, repository.id);
|
|
397
|
-
this.commentToIssue.set(initialComment.id, fullIssue.id);
|
|
398
|
-
// Create session using full Linear issue (convert LinearIssue to CoreIssue)
|
|
399
|
-
const session = new Session({
|
|
400
|
-
issue: this.convertLinearIssueToCore(fullIssue),
|
|
401
|
-
workspace,
|
|
402
|
-
startedAt: new Date(),
|
|
403
|
-
agentRootCommentId: initialComment.id
|
|
404
|
-
});
|
|
405
|
-
// Store session by comment ID
|
|
406
|
-
this.sessionManager.addSession(initialComment.id, session);
|
|
407
|
-
// Track this thread for the issue
|
|
408
|
-
const threads = this.issueToCommentThreads.get(fullIssue.id) || new Set();
|
|
409
|
-
threads.add(initialComment.id);
|
|
410
|
-
this.issueToCommentThreads.set(fullIssue.id, threads);
|
|
401
|
+
agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
|
|
411
402
|
// Save state after mapping changes
|
|
412
403
|
await this.savePersistedState();
|
|
413
404
|
// Emit events using full Linear issue
|
|
414
|
-
this.emit(
|
|
405
|
+
this.emit("session:started", fullIssue.id, fullIssue, repository.id);
|
|
415
406
|
this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
|
|
416
407
|
// Build and start Claude with initial prompt using full issue (streaming mode)
|
|
417
408
|
console.log(`[EdgeWorker] Building initial prompt for issue ${fullIssue.identifier}`);
|
|
418
409
|
try {
|
|
419
|
-
//
|
|
420
|
-
const
|
|
421
|
-
|
|
410
|
+
// Choose the appropriate prompt builder based on system prompt availability
|
|
411
|
+
const promptResult = systemPrompt
|
|
412
|
+
? await this.buildLabelBasedPrompt(fullIssue, repository, attachmentResult.manifest)
|
|
413
|
+
: await this.buildPromptV2(fullIssue, repository, undefined, attachmentResult.manifest);
|
|
414
|
+
const { prompt, version: userPromptVersion } = promptResult;
|
|
415
|
+
// Update runner with version information
|
|
416
|
+
if (userPromptVersion || systemPromptVersion) {
|
|
417
|
+
runner.updatePromptVersions({
|
|
418
|
+
userPromptVersion,
|
|
419
|
+
systemPromptVersion,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
console.log(`[EdgeWorker] Initial prompt built successfully using ${systemPrompt ? "label-based" : "fallback"} workflow, length: ${prompt.length} characters`);
|
|
422
423
|
console.log(`[EdgeWorker] Starting Claude streaming session`);
|
|
423
424
|
const sessionInfo = await runner.startStreaming(prompt);
|
|
424
425
|
console.log(`[EdgeWorker] Claude streaming session started: ${sessionInfo.sessionId}`);
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
console.error(`[EdgeWorker] Error in prompt building/starting:`, error);
|
|
428
|
-
throw error;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
/**
|
|
432
|
-
* Find the root comment of a comment thread by traversing parent relationships
|
|
433
|
-
*/
|
|
434
|
-
/**
|
|
435
|
-
* Handle new root comment - creates a new Claude session for a new comment thread
|
|
436
|
-
* @param issue Linear issue object from webhook data
|
|
437
|
-
* @param comment Linear comment object from webhook data
|
|
438
|
-
* @param repository Repository configuration
|
|
439
|
-
*/
|
|
440
|
-
async handleNewRootComment(issue, comment, repository) {
|
|
441
|
-
console.log(`[EdgeWorker] Handling new root comment ${comment.id} on issue ${issue.identifier}`);
|
|
442
|
-
// Fetch full Linear issue details
|
|
443
|
-
const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
|
|
444
|
-
if (!fullIssue) {
|
|
445
|
-
throw new Error(`Failed to fetch full issue details for ${issue.id}`);
|
|
446
|
-
}
|
|
447
|
-
// Post immediate acknowledgment
|
|
448
|
-
const acknowledgment = await this.postComment(issue.id, "I'm getting started on that right away. I'll update this comment with my plan as I work through it.", repository.id, comment.id // Reply to the new root comment
|
|
449
|
-
);
|
|
450
|
-
if (!acknowledgment?.id) {
|
|
451
|
-
throw new Error(`Failed to create acknowledgment for root comment ${comment.id}`);
|
|
452
|
-
}
|
|
453
|
-
// Create or get workspace
|
|
454
|
-
const workspace = this.config.handlers?.createWorkspace
|
|
455
|
-
? await this.config.handlers.createWorkspace(fullIssue, repository)
|
|
456
|
-
: {
|
|
457
|
-
path: `${repository.workspaceBaseDir}/${fullIssue.identifier}`,
|
|
458
|
-
isGitWorktree: false
|
|
459
|
-
};
|
|
460
|
-
console.log(`[EdgeWorker] Using workspace at: ${workspace.path}`);
|
|
461
|
-
// Download attachments if any
|
|
462
|
-
const attachmentResult = await this.downloadIssueAttachments(fullIssue, repository, workspace.path);
|
|
463
|
-
// Build allowed directories and tools
|
|
464
|
-
const allowedDirectories = [];
|
|
465
|
-
if (attachmentResult.attachmentsDir) {
|
|
466
|
-
allowedDirectories.push(attachmentResult.attachmentsDir);
|
|
467
|
-
}
|
|
468
|
-
const allowedTools = this.buildAllowedTools(repository);
|
|
469
|
-
// Create Claude runner for this new comment thread
|
|
470
|
-
const runner = new ClaudeRunner({
|
|
471
|
-
workingDirectory: workspace.path,
|
|
472
|
-
allowedTools,
|
|
473
|
-
allowedDirectories,
|
|
474
|
-
workspaceName: fullIssue.identifier,
|
|
475
|
-
mcpConfigPath: repository.mcpConfigPath,
|
|
476
|
-
mcpConfig: this.buildMcpConfig(repository),
|
|
477
|
-
onMessage: (message) => {
|
|
478
|
-
// Update session with Claude session ID when first received
|
|
479
|
-
if (!session.claudeSessionId && message.session_id) {
|
|
480
|
-
session.claudeSessionId = message.session_id;
|
|
481
|
-
console.log(`[EdgeWorker] Claude session ID assigned: ${message.session_id}`);
|
|
482
|
-
}
|
|
483
|
-
this.handleClaudeMessage(acknowledgment.id, message, repository.id);
|
|
484
|
-
},
|
|
485
|
-
onComplete: (messages) => this.handleClaudeComplete(acknowledgment.id, messages, repository.id),
|
|
486
|
-
onError: (error) => this.handleClaudeError(acknowledgment.id, error, repository.id)
|
|
487
|
-
});
|
|
488
|
-
// Store runner and mappings
|
|
489
|
-
this.claudeRunners.set(comment.id, runner);
|
|
490
|
-
this.commentToRepo.set(comment.id, repository.id);
|
|
491
|
-
this.commentToIssue.set(comment.id, fullIssue.id);
|
|
492
|
-
// Create session for this new comment thread
|
|
493
|
-
const session = new Session({
|
|
494
|
-
issue: this.convertLinearIssueToCore(fullIssue),
|
|
495
|
-
workspace,
|
|
496
|
-
startedAt: new Date(),
|
|
497
|
-
agentRootCommentId: comment.id
|
|
498
|
-
});
|
|
499
|
-
this.sessionManager.addSession(comment.id, session);
|
|
500
|
-
// Track this new thread for the issue
|
|
501
|
-
const threads = this.issueToCommentThreads.get(issue.id) || new Set();
|
|
502
|
-
threads.add(comment.id);
|
|
503
|
-
this.issueToCommentThreads.set(issue.id, threads);
|
|
504
|
-
// Track latest reply
|
|
505
|
-
this.commentToLatestAgentReply.set(comment.id, acknowledgment.id);
|
|
506
|
-
// Save state after mapping changes
|
|
507
|
-
await this.savePersistedState();
|
|
508
|
-
// Emit session start event
|
|
509
|
-
this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
|
|
510
|
-
// Build prompt with new comment focus using V2 template
|
|
511
|
-
console.log(`[EdgeWorker] Building prompt for new root comment`);
|
|
512
|
-
try {
|
|
513
|
-
const prompt = await this.buildPromptV2(fullIssue, repository, comment, attachmentResult.manifest);
|
|
514
|
-
console.log(`[EdgeWorker] Prompt built successfully, length: ${prompt.length} characters`);
|
|
515
|
-
console.log(`[EdgeWorker] Starting Claude streaming session for new comment thread`);
|
|
516
|
-
const sessionInfo = await runner.startStreaming(prompt);
|
|
517
|
-
console.log(`[EdgeWorker] Claude streaming session started: ${sessionInfo.sessionId}`);
|
|
426
|
+
// Note: AgentSessionManager will be initialized automatically when the first system message
|
|
427
|
+
// is received via handleClaudeMessage() callback
|
|
518
428
|
}
|
|
519
429
|
catch (error) {
|
|
520
430
|
console.error(`[EdgeWorker] Error in prompt building/starting:`, error);
|
|
@@ -527,191 +437,93 @@ export class EdgeWorker extends EventEmitter {
|
|
|
527
437
|
* @param comment Linear comment object from webhook data
|
|
528
438
|
* @param repository Repository configuration
|
|
529
439
|
*/
|
|
530
|
-
async
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
|
|
440
|
+
async handleUserPostedAgentActivity(webhook, repository) {
|
|
441
|
+
// Look for existing session for this comment thread
|
|
442
|
+
const { agentSession } = webhook;
|
|
443
|
+
const linearAgentActivitySessionId = agentSession.id;
|
|
444
|
+
const { issue } = agentSession;
|
|
445
|
+
const promptBody = webhook.agentActivity.content.body;
|
|
446
|
+
// Initialize the agent session in AgentSessionManager
|
|
447
|
+
const agentSessionManager = this.agentSessionManagers.get(repository.id);
|
|
448
|
+
if (!agentSessionManager) {
|
|
449
|
+
console.error("Unexpected: There was no agentSessionManage for the repository with id", repository.id);
|
|
534
450
|
return;
|
|
535
451
|
}
|
|
536
|
-
|
|
537
|
-
const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
|
|
538
|
-
if (!fullIssue) {
|
|
539
|
-
throw new Error(`Failed to fetch full issue details for ${issue.id}`);
|
|
540
|
-
}
|
|
541
|
-
// IMPORTANT: Linear has exactly ONE level of comment nesting:
|
|
542
|
-
// - Root comments (no parent)
|
|
543
|
-
// - Reply comments (have a parent, which must be a root comment)
|
|
544
|
-
// There is NO recursion - a reply cannot have replies
|
|
545
|
-
// Fetch full comment to determine if this is a root or reply
|
|
546
|
-
let parentCommentId = null;
|
|
547
|
-
let rootCommentId = comment.id; // Default to this comment being the root
|
|
548
|
-
try {
|
|
549
|
-
const linearClient = this.linearClients.get(repository.id);
|
|
550
|
-
if (linearClient && comment.id) {
|
|
551
|
-
const fullComment = await linearClient.comment({ id: comment.id });
|
|
552
|
-
// Check if comment has a parent (making it a reply)
|
|
553
|
-
if (fullComment.parent) {
|
|
554
|
-
const parent = await fullComment.parent;
|
|
555
|
-
if (parent?.id) {
|
|
556
|
-
parentCommentId = parent.id;
|
|
557
|
-
// In Linear's 2-level structure, the parent IS always the root
|
|
558
|
-
// No need for recursion - replies can't have replies
|
|
559
|
-
rootCommentId = parent.id;
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
catch (error) {
|
|
565
|
-
console.error('Failed to fetch full comment data:', error);
|
|
566
|
-
}
|
|
567
|
-
// Determine comment type based on whether it has a parent
|
|
568
|
-
const isRootComment = parentCommentId === null;
|
|
569
|
-
const threadRootCommentId = rootCommentId;
|
|
570
|
-
console.log(`[EdgeWorker] Comment ${comment.id} - isRoot: ${isRootComment}, threadRoot: ${threadRootCommentId}, parent: ${parentCommentId}`);
|
|
571
|
-
// Store reply context for Linear commenting
|
|
572
|
-
// parentId will be: the parent comment ID (if this is a reply) OR this comment's ID (if root)
|
|
573
|
-
// This ensures our bot's replies appear at the correct nesting level
|
|
574
|
-
this.issueToReplyContext.set(issue.id, {
|
|
575
|
-
commentId: comment.id,
|
|
576
|
-
parentId: parentCommentId || comment.id
|
|
577
|
-
});
|
|
578
|
-
// Look for existing session for this comment thread
|
|
579
|
-
let session = this.sessionManager.getSession(threadRootCommentId);
|
|
580
|
-
// If no session exists, we need to create one
|
|
452
|
+
const session = agentSessionManager.getSession(linearAgentActivitySessionId);
|
|
581
453
|
if (!session) {
|
|
582
|
-
console.
|
|
583
|
-
|
|
584
|
-
const hasLogs = await this.hasExistingLogs(issue.identifier);
|
|
585
|
-
if (!hasLogs) {
|
|
586
|
-
console.log(`No existing logs found for ${issue.identifier}, treating as new assignment`);
|
|
587
|
-
// Start fresh - treat it like a new assignment
|
|
588
|
-
await this.handleIssueAssigned(issue, repository);
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
console.log(`Found existing logs for ${issue.identifier}, creating session for continuation`);
|
|
592
|
-
// Create workspace (or get existing one)
|
|
593
|
-
const workspace = this.config.handlers?.createWorkspace
|
|
594
|
-
? await this.config.handlers.createWorkspace(fullIssue, repository)
|
|
595
|
-
: {
|
|
596
|
-
path: `${repository.workspaceBaseDir}/${fullIssue.identifier}`,
|
|
597
|
-
isGitWorktree: false
|
|
598
|
-
};
|
|
599
|
-
// Create session for this comment thread
|
|
600
|
-
session = new Session({
|
|
601
|
-
issue: this.convertLinearIssueToCore(fullIssue),
|
|
602
|
-
workspace,
|
|
603
|
-
process: null,
|
|
604
|
-
startedAt: new Date(),
|
|
605
|
-
agentRootCommentId: threadRootCommentId
|
|
606
|
-
});
|
|
607
|
-
this.sessionManager.addSession(threadRootCommentId, session);
|
|
608
|
-
this.commentToRepo.set(threadRootCommentId, repository.id);
|
|
609
|
-
this.commentToIssue.set(threadRootCommentId, issue.id);
|
|
610
|
-
// Track this thread for the issue
|
|
611
|
-
const threads = this.issueToCommentThreads.get(issue.id) || new Set();
|
|
612
|
-
threads.add(threadRootCommentId);
|
|
613
|
-
this.issueToCommentThreads.set(issue.id, threads);
|
|
614
|
-
// Save state after mapping changes
|
|
615
|
-
await this.savePersistedState();
|
|
454
|
+
console.error(`Unexpected: could not find Cyrus Agent Session for agent activity session: ${linearAgentActivitySessionId}`);
|
|
455
|
+
return;
|
|
616
456
|
}
|
|
617
457
|
// Check if there's an existing runner for this comment thread
|
|
618
|
-
const existingRunner =
|
|
619
|
-
if (existingRunner
|
|
620
|
-
// Post
|
|
621
|
-
|
|
622
|
-
await this.postComment(issue.id, "I've queued up your message to address it right after I resolve my current focus.", repository.id, parentCommentId || comment.id // Same nesting level as the triggering comment
|
|
623
|
-
);
|
|
458
|
+
const existingRunner = session.claudeRunner;
|
|
459
|
+
if (existingRunner?.isStreaming()) {
|
|
460
|
+
// Post instant acknowledgment for streaming case
|
|
461
|
+
await this.postInstantPromptedAcknowledgment(linearAgentActivitySessionId, repository.id, true);
|
|
624
462
|
// Add comment to existing stream instead of restarting
|
|
625
|
-
console.log(`[EdgeWorker] Adding comment to existing stream for
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
return; // Exit early - comment has been added to stream
|
|
629
|
-
}
|
|
630
|
-
catch (error) {
|
|
631
|
-
console.error(`[EdgeWorker] Failed to add comment to stream, will stop the existing session and start a new one: ${error}`);
|
|
632
|
-
// Fall through to restart logic below
|
|
633
|
-
}
|
|
463
|
+
console.log(`[EdgeWorker] Adding comment to existing stream for agent activity session ${linearAgentActivitySessionId}`);
|
|
464
|
+
existingRunner.addStreamMessage(promptBody);
|
|
465
|
+
return; // Exit early - comment has been added to stream
|
|
634
466
|
}
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
console.log(`[EdgeWorker] Detected new root comment ${comment.id}, delegating to handleNewRootComment`);
|
|
638
|
-
await this.handleNewRootComment(issue, comment, repository);
|
|
639
|
-
return;
|
|
640
|
-
}
|
|
641
|
-
// Post immediate reply for continuing existing thread
|
|
642
|
-
// parentId ensures correct nesting: replies to parent if this is a reply, or to comment itself if root
|
|
643
|
-
await this.postComment(issue.id, "I'm getting started on that right away. I'll update this comment with my plan as I work through it.", repository.id, parentCommentId || comment.id // Same nesting level as the triggering comment
|
|
644
|
-
);
|
|
467
|
+
// Post instant acknowledgment for non-streaming case
|
|
468
|
+
await this.postInstantPromptedAcknowledgment(linearAgentActivitySessionId, repository.id, false);
|
|
645
469
|
// Stop existing runner if it's not streaming or stream addition failed
|
|
646
470
|
if (existingRunner) {
|
|
647
471
|
existingRunner.stop();
|
|
648
472
|
}
|
|
473
|
+
if (!session.claudeSessionId) {
|
|
474
|
+
console.error(`Unexpected: Handling a 'prompted' webhook but did not find an existing claudeSessionId for the linearAgentActivitySessionId ${linearAgentActivitySessionId}. Not continuing.`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
649
477
|
try {
|
|
650
478
|
// Build allowed tools list with Linear MCP tools
|
|
651
479
|
const allowedTools = this.buildAllowedTools(repository);
|
|
480
|
+
// Fetch full issue details to get labels
|
|
481
|
+
const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
|
|
482
|
+
if (!fullIssue) {
|
|
483
|
+
throw new Error(`Failed to fetch full issue details for ${issue.id}`);
|
|
484
|
+
}
|
|
485
|
+
// Fetch issue labels and determine system prompt (same as in handleAgentSessionCreatedWebhook)
|
|
486
|
+
const labels = await this.fetchIssueLabels(fullIssue);
|
|
487
|
+
const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
|
|
488
|
+
const systemPrompt = systemPromptResult?.prompt;
|
|
652
489
|
// Create new runner with resume mode if we have a Claude session ID
|
|
490
|
+
// Always append the last message marker to prevent duplication
|
|
491
|
+
const lastMessageMarker = "\n\n___LAST_MESSAGE_MARKER___\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.";
|
|
653
492
|
const runner = new ClaudeRunner({
|
|
654
493
|
workingDirectory: session.workspace.path,
|
|
655
494
|
allowedTools,
|
|
656
|
-
resumeSessionId: session.claudeSessionId
|
|
495
|
+
resumeSessionId: session.claudeSessionId,
|
|
657
496
|
workspaceName: issue.identifier,
|
|
658
497
|
mcpConfigPath: repository.mcpConfigPath,
|
|
659
498
|
mcpConfig: this.buildMcpConfig(repository),
|
|
499
|
+
appendSystemPrompt: (systemPrompt || "") + lastMessageMarker,
|
|
660
500
|
onMessage: (message) => {
|
|
661
|
-
|
|
662
|
-
if (!session.claudeSessionId && message.session_id) {
|
|
663
|
-
session.claudeSessionId = message.session_id;
|
|
664
|
-
console.log(`[EdgeWorker] Stored Claude session ID ${message.session_id} for comment thread ${threadRootCommentId}`);
|
|
665
|
-
}
|
|
666
|
-
// Check for continuation errors
|
|
667
|
-
if (message.type === 'assistant' && 'message' in message && message.message?.content) {
|
|
668
|
-
const content = Array.isArray(message.message.content) ? message.message.content : [message.message.content];
|
|
669
|
-
for (const item of content) {
|
|
670
|
-
if (item?.type === 'text' && item.text?.includes('tool_use` ids were found without `tool_result` blocks')) {
|
|
671
|
-
console.log('Detected corrupted conversation history, will restart fresh');
|
|
672
|
-
// Kill this runner
|
|
673
|
-
runner.stop();
|
|
674
|
-
// Remove from map
|
|
675
|
-
this.claudeRunners.delete(threadRootCommentId);
|
|
676
|
-
// Start fresh by calling root comment handler
|
|
677
|
-
this.handleNewRootComment(issue, comment, repository).catch(error => {
|
|
678
|
-
console.error(`[EdgeWorker] Failed to restart fresh session for comment thread ${threadRootCommentId}:`, error);
|
|
679
|
-
// Clean up any partial state
|
|
680
|
-
this.claudeRunners.delete(threadRootCommentId);
|
|
681
|
-
this.commentToRepo.delete(threadRootCommentId);
|
|
682
|
-
this.commentToIssue.delete(threadRootCommentId);
|
|
683
|
-
// Emit error event to notify handlers
|
|
684
|
-
this.emit('session:ended', threadRootCommentId, 1, repository.id);
|
|
685
|
-
this.config.handlers?.onSessionEnd?.(threadRootCommentId, 1, repository.id);
|
|
686
|
-
});
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
this.handleClaudeMessage(threadRootCommentId, message, repository.id);
|
|
501
|
+
this.handleClaudeMessage(linearAgentActivitySessionId, message, repository.id);
|
|
692
502
|
},
|
|
693
|
-
onComplete: (messages) => this.handleClaudeComplete(threadRootCommentId, messages, repository.id),
|
|
694
|
-
onError: (error) => this.handleClaudeError(
|
|
503
|
+
// onComplete: (messages) => this.handleClaudeComplete(threadRootCommentId, messages, repository.id),
|
|
504
|
+
onError: (error) => this.handleClaudeError(error),
|
|
695
505
|
});
|
|
696
506
|
// Store new runner by comment thread root
|
|
697
|
-
|
|
507
|
+
// Store runner by comment ID
|
|
508
|
+
agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
|
|
509
|
+
// Save state after mapping changes
|
|
510
|
+
await this.savePersistedState();
|
|
698
511
|
// Start streaming session with the comment as initial prompt
|
|
699
512
|
console.log(`[EdgeWorker] Starting new streaming session for issue ${issue.identifier}`);
|
|
700
|
-
await runner.startStreaming(
|
|
513
|
+
await runner.startStreaming(promptBody);
|
|
701
514
|
}
|
|
702
515
|
catch (error) {
|
|
703
|
-
console.error(
|
|
516
|
+
console.error("Failed to continue conversation:", error);
|
|
704
517
|
// Remove any partially created session
|
|
705
|
-
this.sessionManager.removeSession(threadRootCommentId)
|
|
706
|
-
this.commentToRepo.delete(threadRootCommentId)
|
|
707
|
-
this.commentToIssue.delete(threadRootCommentId)
|
|
708
|
-
// Start fresh for root comments, or fall back to issue assignment
|
|
709
|
-
if (isRootComment) {
|
|
710
|
-
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
}
|
|
518
|
+
// this.sessionManager.removeSession(threadRootCommentId)
|
|
519
|
+
// this.commentToRepo.delete(threadRootCommentId)
|
|
520
|
+
// this.commentToIssue.delete(threadRootCommentId)
|
|
521
|
+
// // Start fresh for root comments, or fall back to issue assignment
|
|
522
|
+
// if (isRootComment) {
|
|
523
|
+
// await this.handleNewRootComment(issue, comment, repository)
|
|
524
|
+
// } else {
|
|
525
|
+
// await this.handleIssueAssigned(issue, repository)
|
|
526
|
+
// }
|
|
715
527
|
}
|
|
716
528
|
}
|
|
717
529
|
/**
|
|
@@ -720,153 +532,42 @@ export class EdgeWorker extends EventEmitter {
|
|
|
720
532
|
* @param repository Repository configuration
|
|
721
533
|
*/
|
|
722
534
|
async handleIssueUnassigned(issue, repository) {
|
|
723
|
-
|
|
724
|
-
|
|
535
|
+
const agentSessionManager = this.agentSessionManagers.get(repository.id);
|
|
536
|
+
if (!agentSessionManager) {
|
|
537
|
+
console.log("No agentSessionManager for unassigned issue, so no sessions to stop");
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
// Get all Claude runners for this specific issue
|
|
541
|
+
const claudeRunners = agentSessionManager.getClaudeRunnersForIssue(issue.id);
|
|
725
542
|
// Stop all Claude runners for this issue
|
|
726
|
-
|
|
727
|
-
for (const
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
console.log(`[EdgeWorker] Stopping Claude runner for thread ${threadRootCommentId}`);
|
|
731
|
-
await runner.stop();
|
|
732
|
-
activeThreadCount++;
|
|
733
|
-
}
|
|
543
|
+
const activeThreadCount = claudeRunners.length;
|
|
544
|
+
for (const runner of claudeRunners) {
|
|
545
|
+
console.log(`[EdgeWorker] Stopping Claude runner for issue ${issue.identifier}`);
|
|
546
|
+
runner.stop();
|
|
734
547
|
}
|
|
735
548
|
// Post ONE farewell comment on the issue (not in any thread) if there were active sessions
|
|
736
549
|
if (activeThreadCount > 0) {
|
|
737
|
-
await this.postComment(issue.id, "I've been unassigned and am stopping work now.", repository.id
|
|
738
|
-
|
|
739
|
-
);
|
|
740
|
-
}
|
|
741
|
-
// Clean up thread mappings for each stopped thread
|
|
742
|
-
for (const threadRootCommentId of threadRootCommentIds) {
|
|
743
|
-
// Remove from runners map
|
|
744
|
-
this.claudeRunners.delete(threadRootCommentId);
|
|
745
|
-
// Clean up comment mappings
|
|
746
|
-
this.commentToRepo.delete(threadRootCommentId);
|
|
747
|
-
this.commentToIssue.delete(threadRootCommentId);
|
|
748
|
-
this.commentToLatestAgentReply.delete(threadRootCommentId);
|
|
749
|
-
// Remove session
|
|
750
|
-
this.sessionManager.removeSession(threadRootCommentId);
|
|
751
|
-
}
|
|
752
|
-
// Clean up issue-level mappings
|
|
753
|
-
this.issueToCommentThreads.delete(issue.id);
|
|
754
|
-
this.issueToReplyContext.delete(issue.id);
|
|
755
|
-
// Save state after mapping changes
|
|
756
|
-
await this.savePersistedState();
|
|
550
|
+
await this.postComment(issue.id, "I've been unassigned and am stopping work now.", repository.id);
|
|
551
|
+
}
|
|
757
552
|
// Emit events
|
|
758
553
|
console.log(`[EdgeWorker] Stopped ${activeThreadCount} sessions for unassigned issue ${issue.identifier}`);
|
|
759
|
-
this.emit('session:ended', issue.id, null, repository.id);
|
|
760
|
-
this.config.handlers?.onSessionEnd?.(issue.id, null, repository.id);
|
|
761
554
|
}
|
|
762
555
|
/**
|
|
763
556
|
* Handle Claude messages
|
|
764
557
|
*/
|
|
765
|
-
async handleClaudeMessage(
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
if (
|
|
769
|
-
|
|
770
|
-
return;
|
|
771
|
-
}
|
|
772
|
-
// Emit generic message event
|
|
773
|
-
this.emit('claude:message', issueId, message, repositoryId);
|
|
774
|
-
this.config.handlers?.onClaudeMessage?.(issueId, message, repositoryId);
|
|
775
|
-
// Handle specific messages
|
|
776
|
-
if (message.type === 'assistant') {
|
|
777
|
-
const content = this.extractTextContent(message);
|
|
778
|
-
if (content) {
|
|
779
|
-
this.emit('claude:response', issueId, content, repositoryId);
|
|
780
|
-
// Don't post assistant messages anymore - wait for result
|
|
781
|
-
}
|
|
782
|
-
// Also check for tool use in assistant messages
|
|
783
|
-
if ('message' in message && message.message && 'content' in message.message) {
|
|
784
|
-
const messageContent = Array.isArray(message.message.content) ? message.message.content : [message.message.content];
|
|
785
|
-
for (const item of messageContent) {
|
|
786
|
-
if (item && typeof item === 'object' && 'type' in item && item.type === 'tool_use') {
|
|
787
|
-
this.emit('claude:tool-use', issueId, item.name, item.input, repositoryId);
|
|
788
|
-
// Handle TodoWrite tool specifically
|
|
789
|
-
if ('name' in item && item.name === 'TodoWrite' && 'input' in item && item.input?.todos) {
|
|
790
|
-
console.log(`[EdgeWorker] Detected TodoWrite tool use with ${item.input.todos.length} todos`);
|
|
791
|
-
await this.updateCommentWithTodos(item.input.todos, repositoryId, commentId);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
else if (message.type === 'result') {
|
|
798
|
-
if (message.subtype === 'success' && 'result' in message && message.result) {
|
|
799
|
-
// Post the successful result to Linear
|
|
800
|
-
// For comment-based sessions, reply to the root comment of this thread
|
|
801
|
-
await this.postComment(issueId, message.result, repositoryId, commentId);
|
|
802
|
-
}
|
|
803
|
-
else if (message.subtype === 'error_max_turns' || message.subtype === 'error_during_execution') {
|
|
804
|
-
// Handle error results
|
|
805
|
-
const errorMessage = message.subtype === 'error_max_turns'
|
|
806
|
-
? 'Maximum turns reached'
|
|
807
|
-
: 'Error during execution';
|
|
808
|
-
this.handleError(new Error(`Claude error: ${errorMessage}`));
|
|
809
|
-
// Handle token limit specifically for max turns error
|
|
810
|
-
if (this.config.features?.enableTokenLimitHandling && message.subtype === 'error_max_turns') {
|
|
811
|
-
await this.handleTokenLimit(commentId, repositoryId);
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
/**
|
|
817
|
-
* Handle Claude session completion (successful)
|
|
818
|
-
*/
|
|
819
|
-
async handleClaudeComplete(commentId, messages, repositoryId) {
|
|
820
|
-
const issueId = this.commentToIssue.get(commentId);
|
|
821
|
-
console.log(`[EdgeWorker] Claude session completed for comment thread ${commentId} (issue ${issueId}) with ${messages.length} messages`);
|
|
822
|
-
this.claudeRunners.delete(commentId);
|
|
823
|
-
if (issueId) {
|
|
824
|
-
this.emit('session:ended', issueId, 0, repositoryId); // 0 indicates success
|
|
825
|
-
this.config.handlers?.onSessionEnd?.(issueId, 0, repositoryId);
|
|
558
|
+
async handleClaudeMessage(linearAgentActivitySessionId, message, repositoryId) {
|
|
559
|
+
const agentSessionManager = this.agentSessionManagers.get(repositoryId);
|
|
560
|
+
// Integrate with AgentSessionManager to capture streaming messages
|
|
561
|
+
if (agentSessionManager) {
|
|
562
|
+
await agentSessionManager.handleClaudeMessage(linearAgentActivitySessionId, message);
|
|
826
563
|
}
|
|
827
564
|
}
|
|
828
565
|
/**
|
|
829
566
|
* Handle Claude session error
|
|
567
|
+
* TODO: improve this
|
|
830
568
|
*/
|
|
831
|
-
async handleClaudeError(
|
|
832
|
-
|
|
833
|
-
console.error(`[EdgeWorker] Claude session error for comment thread ${commentId} (issue ${issueId}):`, error.message);
|
|
834
|
-
console.error(`[EdgeWorker] Error type: ${error.constructor.name}`);
|
|
835
|
-
if (error.stack) {
|
|
836
|
-
console.error(`[EdgeWorker] Stack trace:`, error.stack);
|
|
837
|
-
}
|
|
838
|
-
// Clean up resources
|
|
839
|
-
this.claudeRunners.delete(commentId);
|
|
840
|
-
if (issueId) {
|
|
841
|
-
// Emit events for external handlers
|
|
842
|
-
this.emit('session:ended', issueId, 1, repositoryId); // 1 indicates error
|
|
843
|
-
this.config.handlers?.onSessionEnd?.(issueId, 1, repositoryId);
|
|
844
|
-
}
|
|
845
|
-
console.log(`[EdgeWorker] Cleaned up resources for failed session ${commentId}`);
|
|
846
|
-
}
|
|
847
|
-
/**
|
|
848
|
-
* Handle token limit by restarting session
|
|
849
|
-
*/
|
|
850
|
-
async handleTokenLimit(commentId, repositoryId) {
|
|
851
|
-
const session = this.sessionManager.getSession(commentId);
|
|
852
|
-
if (!session)
|
|
853
|
-
return;
|
|
854
|
-
const repository = this.repositories.get(repositoryId);
|
|
855
|
-
if (!repository)
|
|
856
|
-
return;
|
|
857
|
-
const issueId = this.commentToIssue.get(commentId);
|
|
858
|
-
if (!issueId)
|
|
859
|
-
return;
|
|
860
|
-
// Post warning to Linear
|
|
861
|
-
await this.postComment(issueId, '[System] Token limit reached. Starting fresh session with issue context.', repositoryId, commentId);
|
|
862
|
-
// Fetch fresh LinearIssue data and restart session for this comment thread
|
|
863
|
-
const linearIssue = await this.fetchFullIssueDetails(issueId, repositoryId);
|
|
864
|
-
if (!linearIssue) {
|
|
865
|
-
throw new Error(`Failed to fetch full issue details for ${issueId}`);
|
|
866
|
-
}
|
|
867
|
-
// For now, fall back to creating a new root comment handler
|
|
868
|
-
// TODO: Implement proper comment thread restart
|
|
869
|
-
await this.handleIssueAssignedWithFullIssue(linearIssue, repository);
|
|
569
|
+
async handleClaudeError(error) {
|
|
570
|
+
console.error("Unhandled claude error:", error);
|
|
870
571
|
}
|
|
871
572
|
/**
|
|
872
573
|
* Fetch complete issue details from Linear API
|
|
@@ -881,6 +582,16 @@ export class EdgeWorker extends EventEmitter {
|
|
|
881
582
|
console.log(`[EdgeWorker] Fetching full issue details for ${issueId}`);
|
|
882
583
|
const fullIssue = await linearClient.issue(issueId);
|
|
883
584
|
console.log(`[EdgeWorker] Successfully fetched issue details for ${issueId}`);
|
|
585
|
+
// Check if issue has a parent
|
|
586
|
+
try {
|
|
587
|
+
const parent = await fullIssue.parent;
|
|
588
|
+
if (parent) {
|
|
589
|
+
console.log(`[EdgeWorker] Issue ${issueId} has parent: ${parent.identifier}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
catch (_error) {
|
|
593
|
+
// Parent field might not exist, ignore error
|
|
594
|
+
}
|
|
884
595
|
return fullIssue;
|
|
885
596
|
}
|
|
886
597
|
catch (error) {
|
|
@@ -888,6 +599,107 @@ export class EdgeWorker extends EventEmitter {
|
|
|
888
599
|
return null;
|
|
889
600
|
}
|
|
890
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* Fetch issue labels for a given issue
|
|
604
|
+
*/
|
|
605
|
+
async fetchIssueLabels(issue) {
|
|
606
|
+
try {
|
|
607
|
+
const labels = await issue.labels();
|
|
608
|
+
return labels.nodes.map((label) => label.name);
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
console.error(`[EdgeWorker] Failed to fetch labels for issue ${issue.id}:`, error);
|
|
612
|
+
return [];
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Determine system prompt based on issue labels and repository configuration
|
|
617
|
+
*/
|
|
618
|
+
async determineSystemPromptFromLabels(labels, repository) {
|
|
619
|
+
if (!repository.labelPrompts || labels.length === 0) {
|
|
620
|
+
return undefined;
|
|
621
|
+
}
|
|
622
|
+
// Check each prompt type for matching labels
|
|
623
|
+
const promptTypes = ["debugger", "builder", "scoper"];
|
|
624
|
+
for (const promptType of promptTypes) {
|
|
625
|
+
const configuredLabels = repository.labelPrompts[promptType];
|
|
626
|
+
if (configuredLabels?.some((label) => labels.includes(label))) {
|
|
627
|
+
try {
|
|
628
|
+
// Load the prompt template from file
|
|
629
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
630
|
+
const __dirname = dirname(__filename);
|
|
631
|
+
const promptPath = join(__dirname, "..", "prompts", `${promptType}.md`);
|
|
632
|
+
const promptContent = await readFile(promptPath, "utf-8");
|
|
633
|
+
console.log(`[EdgeWorker] Using ${promptType} system prompt for labels: ${labels.join(", ")}`);
|
|
634
|
+
// Extract and log version tag if present
|
|
635
|
+
const promptVersion = this.extractVersionTag(promptContent);
|
|
636
|
+
if (promptVersion) {
|
|
637
|
+
console.log(`[EdgeWorker] ${promptType} system prompt version: ${promptVersion}`);
|
|
638
|
+
}
|
|
639
|
+
return { prompt: promptContent, version: promptVersion };
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
console.error(`[EdgeWorker] Failed to load ${promptType} prompt template:`, error);
|
|
643
|
+
return undefined;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return undefined;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Build simplified prompt for label-based workflows
|
|
651
|
+
* @param issue Full Linear issue
|
|
652
|
+
* @param repository Repository configuration
|
|
653
|
+
* @returns Formatted prompt string
|
|
654
|
+
*/
|
|
655
|
+
async buildLabelBasedPrompt(issue, repository, attachmentManifest = "") {
|
|
656
|
+
console.log(`[EdgeWorker] buildLabelBasedPrompt called for issue ${issue.identifier}`);
|
|
657
|
+
try {
|
|
658
|
+
// Load the label-based prompt template
|
|
659
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
660
|
+
const __dirname = dirname(__filename);
|
|
661
|
+
const templatePath = resolve(__dirname, "../label-prompt-template.md");
|
|
662
|
+
console.log(`[EdgeWorker] Loading label prompt template from: ${templatePath}`);
|
|
663
|
+
const template = await readFile(templatePath, "utf-8");
|
|
664
|
+
console.log(`[EdgeWorker] Template loaded, length: ${template.length} characters`);
|
|
665
|
+
// Extract and log version tag if present
|
|
666
|
+
const templateVersion = this.extractVersionTag(template);
|
|
667
|
+
if (templateVersion) {
|
|
668
|
+
console.log(`[EdgeWorker] Label prompt template version: ${templateVersion}`);
|
|
669
|
+
}
|
|
670
|
+
// Build the simplified prompt with only essential variables
|
|
671
|
+
let prompt = template
|
|
672
|
+
.replace(/{{repository_name}}/g, repository.name)
|
|
673
|
+
.replace(/{{base_branch}}/g, repository.baseBranch)
|
|
674
|
+
.replace(/{{issue_id}}/g, issue.id || "")
|
|
675
|
+
.replace(/{{issue_identifier}}/g, issue.identifier || "")
|
|
676
|
+
.replace(/{{issue_title}}/g, issue.title || "")
|
|
677
|
+
.replace(/{{issue_description}}/g, issue.description || "No description provided")
|
|
678
|
+
.replace(/{{issue_url}}/g, issue.url || "");
|
|
679
|
+
if (attachmentManifest) {
|
|
680
|
+
console.log(`[EdgeWorker] Adding attachment manifest to label-based prompt, length: ${attachmentManifest.length} characters`);
|
|
681
|
+
prompt = `${prompt}\n\n${attachmentManifest}`;
|
|
682
|
+
}
|
|
683
|
+
console.log(`[EdgeWorker] Label-based prompt built successfully, length: ${prompt.length} characters`);
|
|
684
|
+
return { prompt, version: templateVersion };
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
console.error(`[EdgeWorker] Error building label-based prompt:`, error);
|
|
688
|
+
throw error;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Extract version tag from template content
|
|
693
|
+
* @param templateContent The template content to parse
|
|
694
|
+
* @returns The version value if found, undefined otherwise
|
|
695
|
+
*/
|
|
696
|
+
extractVersionTag(templateContent) {
|
|
697
|
+
// Match the version tag pattern: <version-tag value="..." />
|
|
698
|
+
const versionTagMatch = templateContent.match(/<version-tag\s+value="([^"]*)"\s*\/>/i);
|
|
699
|
+
const version = versionTagMatch ? versionTagMatch[1] : undefined;
|
|
700
|
+
// Return undefined for empty strings
|
|
701
|
+
return version?.trim() ? version : undefined;
|
|
702
|
+
}
|
|
891
703
|
/**
|
|
892
704
|
* Convert full Linear SDK issue to CoreIssue interface for Session creation
|
|
893
705
|
*/
|
|
@@ -895,18 +707,16 @@ export class EdgeWorker extends EventEmitter {
|
|
|
895
707
|
return {
|
|
896
708
|
id: issue.id,
|
|
897
709
|
identifier: issue.identifier,
|
|
898
|
-
title: issue.title ||
|
|
710
|
+
title: issue.title || "",
|
|
899
711
|
description: issue.description || undefined,
|
|
900
|
-
|
|
901
|
-
return issue.branchName; // Use the real branchName property!
|
|
902
|
-
}
|
|
712
|
+
branchName: issue.branchName, // Use the real branchName property!
|
|
903
713
|
};
|
|
904
714
|
}
|
|
905
715
|
/**
|
|
906
716
|
* Sanitize branch name by removing backticks to prevent command injection
|
|
907
717
|
*/
|
|
908
718
|
sanitizeBranchName(name) {
|
|
909
|
-
return name ? name.replace(/`/g,
|
|
719
|
+
return name ? name.replace(/`/g, "") : name;
|
|
910
720
|
}
|
|
911
721
|
/**
|
|
912
722
|
* Format Linear comments into a threaded structure that mirrors the Linear UI
|
|
@@ -915,7 +725,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
915
725
|
*/
|
|
916
726
|
async formatCommentThreads(comments) {
|
|
917
727
|
if (comments.length === 0) {
|
|
918
|
-
return
|
|
728
|
+
return "No comments yet.";
|
|
919
729
|
}
|
|
920
730
|
// Group comments by thread (root comments and their replies)
|
|
921
731
|
const threads = new Map();
|
|
@@ -947,7 +757,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
947
757
|
continue;
|
|
948
758
|
// Format root comment
|
|
949
759
|
const rootUser = await rootComment.user;
|
|
950
|
-
const rootAuthor = rootUser?.displayName || rootUser?.name || rootUser?.email ||
|
|
760
|
+
const rootAuthor = rootUser?.displayName || rootUser?.name || rootUser?.email || "Unknown";
|
|
951
761
|
const rootTime = new Date(rootComment.createdAt).toLocaleString();
|
|
952
762
|
let threadText = `<comment_thread>
|
|
953
763
|
<root_comment>
|
|
@@ -959,10 +769,13 @@ ${rootComment.body}
|
|
|
959
769
|
</root_comment>`;
|
|
960
770
|
// Format replies if any
|
|
961
771
|
if (thread.replies.length > 0) {
|
|
962
|
-
threadText +=
|
|
772
|
+
threadText += "\n <replies>";
|
|
963
773
|
for (const reply of thread.replies) {
|
|
964
774
|
const replyUser = await reply.user;
|
|
965
|
-
const replyAuthor = replyUser?.displayName ||
|
|
775
|
+
const replyAuthor = replyUser?.displayName ||
|
|
776
|
+
replyUser?.name ||
|
|
777
|
+
replyUser?.email ||
|
|
778
|
+
"Unknown";
|
|
966
779
|
const replyTime = new Date(reply.createdAt).toLocaleString();
|
|
967
780
|
threadText += `
|
|
968
781
|
<reply>
|
|
@@ -973,12 +786,12 @@ ${reply.body}
|
|
|
973
786
|
</content>
|
|
974
787
|
</reply>`;
|
|
975
788
|
}
|
|
976
|
-
threadText +=
|
|
789
|
+
threadText += "\n </replies>";
|
|
977
790
|
}
|
|
978
|
-
threadText +=
|
|
791
|
+
threadText += "\n</comment_thread>";
|
|
979
792
|
formattedThreads.push(threadText);
|
|
980
793
|
}
|
|
981
|
-
return formattedThreads.join(
|
|
794
|
+
return formattedThreads.join("\n\n");
|
|
982
795
|
}
|
|
983
796
|
/**
|
|
984
797
|
* Build a prompt for Claude using the improved XML-style template
|
|
@@ -988,56 +801,63 @@ ${reply.body}
|
|
|
988
801
|
* @param attachmentManifest Optional attachment manifest
|
|
989
802
|
* @returns Formatted prompt string
|
|
990
803
|
*/
|
|
991
|
-
async buildPromptV2(issue, repository, newComment, attachmentManifest =
|
|
992
|
-
console.log(`[EdgeWorker] buildPromptV2 called for issue ${issue.identifier}${newComment ?
|
|
804
|
+
async buildPromptV2(issue, repository, newComment, attachmentManifest = "") {
|
|
805
|
+
console.log(`[EdgeWorker] buildPromptV2 called for issue ${issue.identifier}${newComment ? " with new comment" : ""}`);
|
|
993
806
|
try {
|
|
994
807
|
// Use custom template if provided (repository-specific takes precedence)
|
|
995
|
-
let templatePath = repository.promptTemplatePath ||
|
|
808
|
+
let templatePath = repository.promptTemplatePath ||
|
|
809
|
+
this.config.features?.promptTemplatePath;
|
|
996
810
|
// If no custom template, use the v2 template
|
|
997
811
|
if (!templatePath) {
|
|
998
812
|
const __filename = fileURLToPath(import.meta.url);
|
|
999
813
|
const __dirname = dirname(__filename);
|
|
1000
|
-
templatePath = resolve(__dirname,
|
|
814
|
+
templatePath = resolve(__dirname, "../prompt-template-v2.md");
|
|
1001
815
|
}
|
|
1002
816
|
// Load the template
|
|
1003
817
|
console.log(`[EdgeWorker] Loading prompt template from: ${templatePath}`);
|
|
1004
|
-
const template = await readFile(templatePath,
|
|
818
|
+
const template = await readFile(templatePath, "utf-8");
|
|
1005
819
|
console.log(`[EdgeWorker] Template loaded, length: ${template.length} characters`);
|
|
820
|
+
// Extract and log version tag if present
|
|
821
|
+
const templateVersion = this.extractVersionTag(template);
|
|
822
|
+
if (templateVersion) {
|
|
823
|
+
console.log(`[EdgeWorker] Prompt template version: ${templateVersion}`);
|
|
824
|
+
}
|
|
1006
825
|
// Get state name from Linear API
|
|
1007
826
|
const state = await issue.state;
|
|
1008
|
-
const stateName = state?.name ||
|
|
827
|
+
const stateName = state?.name || "Unknown";
|
|
1009
828
|
// Get formatted comment threads
|
|
1010
829
|
const linearClient = this.linearClients.get(repository.id);
|
|
1011
|
-
let commentThreads =
|
|
830
|
+
let commentThreads = "No comments yet.";
|
|
1012
831
|
if (linearClient && issue.id) {
|
|
1013
832
|
try {
|
|
1014
833
|
console.log(`[EdgeWorker] Fetching comments for issue ${issue.identifier}`);
|
|
1015
834
|
const comments = await linearClient.comments({
|
|
1016
|
-
filter: { issue: { id: { eq: issue.id } } }
|
|
835
|
+
filter: { issue: { id: { eq: issue.id } } },
|
|
1017
836
|
});
|
|
1018
|
-
const commentNodes =
|
|
837
|
+
const commentNodes = comments.nodes;
|
|
1019
838
|
if (commentNodes.length > 0) {
|
|
1020
839
|
commentThreads = await this.formatCommentThreads(commentNodes);
|
|
1021
840
|
console.log(`[EdgeWorker] Formatted ${commentNodes.length} comments into threads`);
|
|
1022
841
|
}
|
|
1023
842
|
}
|
|
1024
843
|
catch (error) {
|
|
1025
|
-
console.error(
|
|
844
|
+
console.error("Failed to fetch comments:", error);
|
|
1026
845
|
}
|
|
1027
846
|
}
|
|
1028
847
|
// Build the prompt with all variables
|
|
1029
848
|
let prompt = template
|
|
1030
849
|
.replace(/{{repository_name}}/g, repository.name)
|
|
1031
|
-
.replace(/{{issue_id}}/g, issue.id ||
|
|
1032
|
-
.replace(/{{issue_identifier}}/g, issue.identifier ||
|
|
1033
|
-
.replace(/{{issue_title}}/g, issue.title ||
|
|
1034
|
-
.replace(/{{issue_description}}/g, issue.description ||
|
|
850
|
+
.replace(/{{issue_id}}/g, issue.id || "")
|
|
851
|
+
.replace(/{{issue_identifier}}/g, issue.identifier || "")
|
|
852
|
+
.replace(/{{issue_title}}/g, issue.title || "")
|
|
853
|
+
.replace(/{{issue_description}}/g, issue.description || "No description provided")
|
|
1035
854
|
.replace(/{{issue_state}}/g, stateName)
|
|
1036
|
-
.replace(/{{issue_priority}}/g, issue.priority?.toString() ||
|
|
1037
|
-
.replace(/{{issue_url}}/g, issue.url ||
|
|
855
|
+
.replace(/{{issue_priority}}/g, issue.priority?.toString() || "None")
|
|
856
|
+
.replace(/{{issue_url}}/g, issue.url || "")
|
|
1038
857
|
.replace(/{{comment_threads}}/g, commentThreads)
|
|
1039
|
-
.replace(/{{working_directory}}/g, this.config.handlers?.createWorkspace
|
|
1040
|
-
|
|
858
|
+
.replace(/{{working_directory}}/g, this.config.handlers?.createWorkspace
|
|
859
|
+
? "Will be created based on issue"
|
|
860
|
+
: repository.repositoryPath)
|
|
1041
861
|
.replace(/{{base_branch}}/g, repository.baseBranch)
|
|
1042
862
|
.replace(/{{branch_name}}/g, this.sanitizeBranchName(issue.branchName));
|
|
1043
863
|
// Handle the optional new comment section
|
|
@@ -1055,77 +875,63 @@ IMPORTANT: Focus specifically on addressing the new comment above. This is a new
|
|
|
1055
875
|
prompt = prompt.replace(/{{#if new_comment}}[\s\S]*?{{\/if}}/g, newCommentSection);
|
|
1056
876
|
// Now replace the new comment variables
|
|
1057
877
|
// We'll need to fetch the comment author
|
|
1058
|
-
let authorName =
|
|
878
|
+
let authorName = "Unknown";
|
|
1059
879
|
if (linearClient) {
|
|
1060
880
|
try {
|
|
1061
|
-
const fullComment = await linearClient.comment({
|
|
881
|
+
const fullComment = await linearClient.comment({
|
|
882
|
+
id: newComment.id,
|
|
883
|
+
});
|
|
1062
884
|
const user = await fullComment.user;
|
|
1063
|
-
authorName =
|
|
885
|
+
authorName =
|
|
886
|
+
user?.displayName || user?.name || user?.email || "Unknown";
|
|
1064
887
|
}
|
|
1065
888
|
catch (error) {
|
|
1066
|
-
console.error(
|
|
889
|
+
console.error("Failed to fetch comment author:", error);
|
|
1067
890
|
}
|
|
1068
891
|
}
|
|
1069
892
|
prompt = prompt
|
|
1070
893
|
.replace(/{{new_comment_author}}/g, authorName)
|
|
1071
894
|
.replace(/{{new_comment_timestamp}}/g, new Date().toLocaleString())
|
|
1072
|
-
.replace(/{{new_comment_content}}/g, newComment.body ||
|
|
895
|
+
.replace(/{{new_comment_content}}/g, newComment.body || "");
|
|
1073
896
|
}
|
|
1074
897
|
else {
|
|
1075
898
|
// Remove the new comment section entirely
|
|
1076
|
-
prompt = prompt.replace(/{{#if new_comment}}[\s\S]*?{{\/if}}/g,
|
|
899
|
+
prompt = prompt.replace(/{{#if new_comment}}[\s\S]*?{{\/if}}/g, "");
|
|
1077
900
|
}
|
|
1078
901
|
// Append attachment manifest if provided
|
|
1079
902
|
if (attachmentManifest) {
|
|
1080
903
|
console.log(`[EdgeWorker] Adding attachment manifest, length: ${attachmentManifest.length} characters`);
|
|
1081
|
-
prompt = prompt
|
|
904
|
+
prompt = `${prompt}\n\n${attachmentManifest}`;
|
|
905
|
+
}
|
|
906
|
+
// Append repository-specific instruction if provided
|
|
907
|
+
if (repository.appendInstruction) {
|
|
908
|
+
console.log(`[EdgeWorker] Adding repository-specific instruction`);
|
|
909
|
+
prompt = `${prompt}\n\n<repository-specific-instruction>\n${repository.appendInstruction}\n</repository-specific-instruction>`;
|
|
1082
910
|
}
|
|
1083
911
|
console.log(`[EdgeWorker] Final prompt length: ${prompt.length} characters`);
|
|
1084
|
-
return prompt;
|
|
912
|
+
return { prompt, version: templateVersion };
|
|
1085
913
|
}
|
|
1086
914
|
catch (error) {
|
|
1087
|
-
console.error(
|
|
915
|
+
console.error("[EdgeWorker] Failed to load prompt template:", error);
|
|
1088
916
|
// Fallback to simple prompt
|
|
1089
917
|
const state = await issue.state;
|
|
1090
|
-
const stateName = state?.name ||
|
|
1091
|
-
|
|
918
|
+
const stateName = state?.name || "Unknown";
|
|
919
|
+
const fallbackPrompt = `Please help me with the following Linear issue:
|
|
1092
920
|
|
|
1093
921
|
Repository: ${repository.name}
|
|
1094
922
|
Issue: ${issue.identifier}
|
|
1095
923
|
Title: ${issue.title}
|
|
1096
|
-
Description: ${issue.description ||
|
|
924
|
+
Description: ${issue.description || "No description provided"}
|
|
1097
925
|
State: ${stateName}
|
|
1098
|
-
Priority: ${issue.priority?.toString() ||
|
|
926
|
+
Priority: ${issue.priority?.toString() || "None"}
|
|
1099
927
|
Branch: ${issue.branchName}
|
|
1100
928
|
|
|
1101
929
|
Working directory: ${repository.repositoryPath}
|
|
1102
930
|
Base branch: ${repository.baseBranch}
|
|
1103
931
|
|
|
1104
|
-
${newComment ? `New comment to address:\n${newComment.body}\n\n` :
|
|
1105
|
-
|
|
1106
|
-
}
|
|
1107
|
-
/**
|
|
1108
|
-
* Extract text content from Claude message
|
|
1109
|
-
*/
|
|
1110
|
-
extractTextContent(sdkMessage) {
|
|
1111
|
-
if (sdkMessage.type !== 'assistant')
|
|
1112
|
-
return null;
|
|
1113
|
-
const message = sdkMessage.message;
|
|
1114
|
-
if (!message?.content)
|
|
1115
|
-
return null;
|
|
1116
|
-
if (typeof message.content === 'string') {
|
|
1117
|
-
return message.content;
|
|
1118
|
-
}
|
|
1119
|
-
if (Array.isArray(message.content)) {
|
|
1120
|
-
const textBlocks = [];
|
|
1121
|
-
for (const block of message.content) {
|
|
1122
|
-
if (typeof block === 'object' && block !== null && 'type' in block && block.type === 'text' && 'text' in block) {
|
|
1123
|
-
textBlocks.push(block.text);
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
return textBlocks.join('');
|
|
932
|
+
${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please analyze this issue and help implement a solution.`;
|
|
933
|
+
return { prompt: fallbackPrompt, version: undefined };
|
|
1127
934
|
}
|
|
1128
|
-
return null;
|
|
1129
935
|
}
|
|
1130
936
|
/**
|
|
1131
937
|
* Get connection status by repository ID
|
|
@@ -1137,12 +943,6 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1137
943
|
}
|
|
1138
944
|
return status;
|
|
1139
945
|
}
|
|
1140
|
-
/**
|
|
1141
|
-
* Get active sessions
|
|
1142
|
-
*/
|
|
1143
|
-
getActiveSessions() {
|
|
1144
|
-
return Array.from(this.sessionManager.getAllSessions().keys());
|
|
1145
|
-
}
|
|
1146
946
|
/**
|
|
1147
947
|
* Get NDJSON client by token (for testing purposes)
|
|
1148
948
|
* @internal
|
|
@@ -1189,7 +989,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1189
989
|
}
|
|
1190
990
|
// Check if issue is already in a started state
|
|
1191
991
|
const currentState = await issue.state;
|
|
1192
|
-
if (currentState?.type ===
|
|
992
|
+
if (currentState?.type === "started") {
|
|
1193
993
|
console.log(`Issue ${issue.identifier} is already in started state (${currentState.name})`);
|
|
1194
994
|
return;
|
|
1195
995
|
}
|
|
@@ -1201,13 +1001,13 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1201
1001
|
}
|
|
1202
1002
|
// Get available workflow states for the issue's team
|
|
1203
1003
|
const teamStates = await linearClient.workflowStates({
|
|
1204
|
-
filter: { team: { id: { eq: team.id } } }
|
|
1004
|
+
filter: { team: { id: { eq: team.id } } },
|
|
1205
1005
|
});
|
|
1206
1006
|
const states = await teamStates;
|
|
1207
1007
|
// Find all states with type "started" and pick the one with lowest position
|
|
1208
1008
|
// This ensures we pick "In Progress" over "In Review" when both have type "started"
|
|
1209
1009
|
// Linear uses standardized state types: triage, backlog, unstarted, started, completed, canceled
|
|
1210
|
-
const startedStates = states.nodes.filter(state => state.type ===
|
|
1010
|
+
const startedStates = states.nodes.filter((state) => state.type === "started");
|
|
1211
1011
|
const startedState = startedStates.sort((a, b) => a.position - b.position)[0];
|
|
1212
1012
|
if (!startedState) {
|
|
1213
1013
|
throw new Error('Could not find a state with type "started" for this team');
|
|
@@ -1219,7 +1019,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1219
1019
|
return;
|
|
1220
1020
|
}
|
|
1221
1021
|
await linearClient.updateIssue(issue.id, {
|
|
1222
|
-
stateId: startedState.id
|
|
1022
|
+
stateId: startedState.id,
|
|
1223
1023
|
});
|
|
1224
1024
|
console.log(`✅ Successfully moved issue ${issue.identifier} to ${startedState.name} state`);
|
|
1225
1025
|
}
|
|
@@ -1231,125 +1031,48 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1231
1031
|
/**
|
|
1232
1032
|
* Post initial comment when assigned to issue
|
|
1233
1033
|
*/
|
|
1234
|
-
async postInitialComment(issueId, repositoryId) {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
// Linear SDK returns CommentPayload with structure: { comment, success, lastSyncId }
|
|
1248
|
-
if (response && response.comment) {
|
|
1249
|
-
const comment = await response.comment;
|
|
1250
|
-
console.log(`✅ Posted initial comment on issue ${issueId} (ID: ${comment.id})`);
|
|
1251
|
-
// Track this as the latest agent reply for the thread (initial comment is its own root)
|
|
1252
|
-
if (comment.id) {
|
|
1253
|
-
this.commentToLatestAgentReply.set(comment.id, comment.id);
|
|
1254
|
-
// Save state after successful comment creation and mapping update
|
|
1255
|
-
await this.savePersistedState();
|
|
1256
|
-
}
|
|
1257
|
-
return comment;
|
|
1258
|
-
}
|
|
1259
|
-
else {
|
|
1260
|
-
throw new Error('Initial comment creation failed');
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
catch (error) {
|
|
1264
|
-
console.error(`Failed to create initial comment on issue ${issueId}:`, error);
|
|
1265
|
-
return null;
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1034
|
+
// private async postInitialComment(issueId: string, repositoryId: string): Promise<void> {
|
|
1035
|
+
// const body = "I'm getting started right away."
|
|
1036
|
+
// // Get the Linear client for this repository
|
|
1037
|
+
// const linearClient = this.linearClients.get(repositoryId)
|
|
1038
|
+
// if (!linearClient) {
|
|
1039
|
+
// throw new Error(`No Linear client found for repository ${repositoryId}`)
|
|
1040
|
+
// }
|
|
1041
|
+
// const commentData = {
|
|
1042
|
+
// issueId,
|
|
1043
|
+
// body
|
|
1044
|
+
// }
|
|
1045
|
+
// await linearClient.createComment(commentData)
|
|
1046
|
+
// }
|
|
1268
1047
|
/**
|
|
1269
1048
|
* Post a comment to Linear
|
|
1270
1049
|
*/
|
|
1271
1050
|
async postComment(issueId, body, repositoryId, parentId) {
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
throw new Error(`No Linear client found for repository ${repositoryId}`);
|
|
1277
|
-
}
|
|
1278
|
-
const commentData = {
|
|
1279
|
-
issueId,
|
|
1280
|
-
body
|
|
1281
|
-
};
|
|
1282
|
-
// Add parent ID if provided (for reply)
|
|
1283
|
-
if (parentId) {
|
|
1284
|
-
commentData.parentId = parentId;
|
|
1285
|
-
}
|
|
1286
|
-
const response = await linearClient.createComment(commentData);
|
|
1287
|
-
// Linear SDK returns CommentPayload with structure: { comment, success, lastSyncId }
|
|
1288
|
-
if (response && response.comment) {
|
|
1289
|
-
console.log(`✅ Successfully created comment on issue ${issueId}`);
|
|
1290
|
-
const comment = await response.comment;
|
|
1291
|
-
if (comment?.id) {
|
|
1292
|
-
console.log(`Comment ID: ${comment.id}`);
|
|
1293
|
-
// Track this as the latest agent reply for the thread
|
|
1294
|
-
// If parentId exists, that's the thread root; otherwise this comment IS the root
|
|
1295
|
-
const threadRootCommentId = parentId || comment.id;
|
|
1296
|
-
this.commentToLatestAgentReply.set(threadRootCommentId, comment.id);
|
|
1297
|
-
// Save state after successful comment creation and mapping update
|
|
1298
|
-
await this.savePersistedState();
|
|
1299
|
-
return comment;
|
|
1300
|
-
}
|
|
1301
|
-
return null;
|
|
1302
|
-
}
|
|
1303
|
-
else {
|
|
1304
|
-
throw new Error('Comment creation failed');
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
catch (error) {
|
|
1308
|
-
console.error(`Failed to create comment on issue ${issueId}:`, error);
|
|
1309
|
-
// Don't re-throw - just log the error so the edge worker doesn't crash
|
|
1310
|
-
// TODO: Implement retry logic or token refresh
|
|
1311
|
-
return null;
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
/**
|
|
1315
|
-
* Update initial comment with TODO checklist
|
|
1316
|
-
*/
|
|
1317
|
-
async updateCommentWithTodos(todos, repositoryId, threadRootCommentId) {
|
|
1318
|
-
try {
|
|
1319
|
-
// Get the latest agent comment in this thread
|
|
1320
|
-
const commentId = this.commentToLatestAgentReply.get(threadRootCommentId) || threadRootCommentId;
|
|
1321
|
-
if (!commentId) {
|
|
1322
|
-
console.log('No comment ID found for thread, cannot update with todos');
|
|
1323
|
-
return;
|
|
1324
|
-
}
|
|
1325
|
-
// Convert todos to Linear checklist format
|
|
1326
|
-
const checklist = this.formatTodosAsChecklist(todos);
|
|
1327
|
-
const body = `I've been assigned to this issue and am getting started right away. Here's my plan:\n\n${checklist}`;
|
|
1328
|
-
// Get the Linear client
|
|
1329
|
-
const linearClient = this.linearClients.get(repositoryId);
|
|
1330
|
-
if (!linearClient) {
|
|
1331
|
-
throw new Error(`No Linear client found for repository ${repositoryId}`);
|
|
1332
|
-
}
|
|
1333
|
-
// Update the comment
|
|
1334
|
-
const response = await linearClient.updateComment(commentId, { body });
|
|
1335
|
-
if (response) {
|
|
1336
|
-
console.log(`✅ Updated comment ${commentId} with ${todos.length} todos`);
|
|
1337
|
-
}
|
|
1051
|
+
// Get the Linear client for this repository
|
|
1052
|
+
const linearClient = this.linearClients.get(repositoryId);
|
|
1053
|
+
if (!linearClient) {
|
|
1054
|
+
throw new Error(`No Linear client found for repository ${repositoryId}`);
|
|
1338
1055
|
}
|
|
1339
|
-
|
|
1340
|
-
|
|
1056
|
+
const commentData = {
|
|
1057
|
+
issueId,
|
|
1058
|
+
body,
|
|
1059
|
+
};
|
|
1060
|
+
// Add parent ID if provided (for reply)
|
|
1061
|
+
if (parentId) {
|
|
1062
|
+
commentData.parentId = parentId;
|
|
1341
1063
|
}
|
|
1064
|
+
await linearClient.createComment(commentData);
|
|
1342
1065
|
}
|
|
1343
1066
|
/**
|
|
1344
1067
|
* Format todos as Linear checklist markdown
|
|
1345
1068
|
*/
|
|
1346
|
-
formatTodosAsChecklist(todos) {
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
}
|
|
1069
|
+
// private formatTodosAsChecklist(todos: Array<{id: string, content: string, status: string, priority: string}>): string {
|
|
1070
|
+
// return todos.map(todo => {
|
|
1071
|
+
// const checkbox = todo.status === 'completed' ? '[x]' : '[ ]'
|
|
1072
|
+
// const statusEmoji = todo.status === 'in_progress' ? ' 🔄' : ''
|
|
1073
|
+
// return `- ${checkbox} ${todo.content}${statusEmoji}`
|
|
1074
|
+
// }).join('\n')
|
|
1075
|
+
// }
|
|
1353
1076
|
/**
|
|
1354
1077
|
* Extract attachment URLs from text (issue description or comment)
|
|
1355
1078
|
*/
|
|
@@ -1379,27 +1102,27 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1379
1102
|
const maxAttachments = 10;
|
|
1380
1103
|
// Create attachments directory in home directory
|
|
1381
1104
|
const workspaceFolderName = basename(workspacePath);
|
|
1382
|
-
const attachmentsDir = join(homedir(),
|
|
1105
|
+
const attachmentsDir = join(homedir(), ".cyrus", workspaceFolderName, "attachments");
|
|
1383
1106
|
// Ensure directory exists
|
|
1384
1107
|
await mkdir(attachmentsDir, { recursive: true });
|
|
1385
1108
|
// Extract URLs from issue description
|
|
1386
|
-
const descriptionUrls = this.extractAttachmentUrls(issue.description ||
|
|
1109
|
+
const descriptionUrls = this.extractAttachmentUrls(issue.description || "");
|
|
1387
1110
|
// Extract URLs from comments if available
|
|
1388
1111
|
const commentUrls = [];
|
|
1389
1112
|
const linearClient = this.linearClients.get(repository.id);
|
|
1390
1113
|
if (linearClient && issue.id) {
|
|
1391
1114
|
try {
|
|
1392
1115
|
const comments = await linearClient.comments({
|
|
1393
|
-
filter: { issue: { id: { eq: issue.id } } }
|
|
1116
|
+
filter: { issue: { id: { eq: issue.id } } },
|
|
1394
1117
|
});
|
|
1395
|
-
const commentNodes =
|
|
1118
|
+
const commentNodes = comments.nodes;
|
|
1396
1119
|
for (const comment of commentNodes) {
|
|
1397
1120
|
const urls = this.extractAttachmentUrls(comment.body);
|
|
1398
1121
|
commentUrls.push(...urls);
|
|
1399
1122
|
}
|
|
1400
1123
|
}
|
|
1401
1124
|
catch (error) {
|
|
1402
|
-
console.error(
|
|
1125
|
+
console.error("Failed to fetch comments for attachments:", error);
|
|
1403
1126
|
}
|
|
1404
1127
|
}
|
|
1405
1128
|
// Combine and deduplicate all URLs
|
|
@@ -1423,10 +1146,10 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1423
1146
|
let finalFilename;
|
|
1424
1147
|
if (result.isImage) {
|
|
1425
1148
|
imageCount++;
|
|
1426
|
-
finalFilename = `image_${imageCount}${result.fileType ||
|
|
1149
|
+
finalFilename = `image_${imageCount}${result.fileType || ".png"}`;
|
|
1427
1150
|
}
|
|
1428
1151
|
else {
|
|
1429
|
-
finalFilename = `attachment_${attachmentCount + 1}${result.fileType ||
|
|
1152
|
+
finalFilename = `attachment_${attachmentCount + 1}${result.fileType || ""}`;
|
|
1430
1153
|
}
|
|
1431
1154
|
const finalPath = join(attachmentsDir, finalFilename);
|
|
1432
1155
|
// Rename the file to include the correct extension
|
|
@@ -1453,17 +1176,17 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1453
1176
|
downloaded: attachmentCount,
|
|
1454
1177
|
imagesDownloaded: imageCount,
|
|
1455
1178
|
skipped: skippedCount,
|
|
1456
|
-
failed: failedCount
|
|
1179
|
+
failed: failedCount,
|
|
1457
1180
|
});
|
|
1458
1181
|
// Return manifest and directory path if any attachments were downloaded
|
|
1459
1182
|
return {
|
|
1460
1183
|
manifest,
|
|
1461
|
-
attachmentsDir: attachmentCount > 0 ? attachmentsDir : null
|
|
1184
|
+
attachmentsDir: attachmentCount > 0 ? attachmentsDir : null,
|
|
1462
1185
|
};
|
|
1463
1186
|
}
|
|
1464
1187
|
catch (error) {
|
|
1465
|
-
console.error(
|
|
1466
|
-
return { manifest:
|
|
1188
|
+
console.error("Error downloading attachments:", error);
|
|
1189
|
+
return { manifest: "", attachmentsDir: null }; // Return empty manifest on error
|
|
1467
1190
|
}
|
|
1468
1191
|
}
|
|
1469
1192
|
/**
|
|
@@ -1474,8 +1197,8 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1474
1197
|
console.log(`Downloading attachment from: ${attachmentUrl}`);
|
|
1475
1198
|
const response = await fetch(attachmentUrl, {
|
|
1476
1199
|
headers: {
|
|
1477
|
-
|
|
1478
|
-
}
|
|
1200
|
+
Authorization: `Bearer ${linearToken}`,
|
|
1201
|
+
},
|
|
1479
1202
|
});
|
|
1480
1203
|
if (!response.ok) {
|
|
1481
1204
|
console.error(`Attachment download failed: ${response.status} ${response.statusText}`);
|
|
@@ -1484,11 +1207,11 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1484
1207
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1485
1208
|
// Detect the file type from the buffer
|
|
1486
1209
|
const fileType = await fileTypeFromBuffer(buffer);
|
|
1487
|
-
let detectedExtension
|
|
1210
|
+
let detectedExtension;
|
|
1488
1211
|
let isImage = false;
|
|
1489
1212
|
if (fileType) {
|
|
1490
1213
|
detectedExtension = `.${fileType.ext}`;
|
|
1491
|
-
isImage = fileType.mime.startsWith(
|
|
1214
|
+
isImage = fileType.mime.startsWith("image/");
|
|
1492
1215
|
console.log(`Detected file type: ${fileType.mime} (${fileType.ext}), is image: ${isImage}`);
|
|
1493
1216
|
}
|
|
1494
1217
|
else {
|
|
@@ -1514,10 +1237,10 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1514
1237
|
* Generate a markdown section describing downloaded attachments
|
|
1515
1238
|
*/
|
|
1516
1239
|
generateAttachmentManifest(downloadResult) {
|
|
1517
|
-
const { attachmentMap, imageMap, totalFound, downloaded, imagesDownloaded, skipped, failed } = downloadResult;
|
|
1518
|
-
let manifest =
|
|
1240
|
+
const { attachmentMap, imageMap, totalFound, downloaded, imagesDownloaded, skipped, failed, } = downloadResult;
|
|
1241
|
+
let manifest = "\n## Downloaded Attachments\n\n";
|
|
1519
1242
|
if (totalFound === 0) {
|
|
1520
|
-
manifest +=
|
|
1243
|
+
manifest += "No attachments were found in this issue.\n";
|
|
1521
1244
|
return manifest;
|
|
1522
1245
|
}
|
|
1523
1246
|
manifest += `Found ${totalFound} attachments. Downloaded ${downloaded}`;
|
|
@@ -1530,89 +1253,90 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1530
1253
|
if (failed > 0) {
|
|
1531
1254
|
manifest += `, failed to download ${failed}`;
|
|
1532
1255
|
}
|
|
1533
|
-
manifest +=
|
|
1256
|
+
manifest += ".\n\n";
|
|
1534
1257
|
if (failed > 0) {
|
|
1535
|
-
manifest +=
|
|
1258
|
+
manifest +=
|
|
1259
|
+
"**Note**: Some attachments failed to download. This may be due to authentication issues or the files being unavailable. The agent will continue processing the issue with the available information.\n\n";
|
|
1536
1260
|
}
|
|
1537
|
-
manifest +=
|
|
1261
|
+
manifest +=
|
|
1262
|
+
"Attachments have been downloaded to the `~/.cyrus/<workspace>/attachments` directory:\n\n";
|
|
1538
1263
|
// List images first
|
|
1539
1264
|
if (Object.keys(imageMap).length > 0) {
|
|
1540
|
-
manifest +=
|
|
1265
|
+
manifest += "### Images\n";
|
|
1541
1266
|
Object.entries(imageMap).forEach(([url, localPath], index) => {
|
|
1542
1267
|
const filename = basename(localPath);
|
|
1543
1268
|
manifest += `${index + 1}. ${filename} - Original URL: ${url}\n`;
|
|
1544
1269
|
manifest += ` Local path: ${localPath}\n\n`;
|
|
1545
1270
|
});
|
|
1546
|
-
manifest +=
|
|
1271
|
+
manifest += "You can use the Read tool to view these images.\n\n";
|
|
1547
1272
|
}
|
|
1548
1273
|
// List other attachments
|
|
1549
1274
|
if (Object.keys(attachmentMap).length > 0) {
|
|
1550
|
-
manifest +=
|
|
1275
|
+
manifest += "### Other Attachments\n";
|
|
1551
1276
|
Object.entries(attachmentMap).forEach(([url, localPath], index) => {
|
|
1552
1277
|
const filename = basename(localPath);
|
|
1553
1278
|
manifest += `${index + 1}. ${filename} - Original URL: ${url}\n`;
|
|
1554
1279
|
manifest += ` Local path: ${localPath}\n\n`;
|
|
1555
1280
|
});
|
|
1556
|
-
manifest +=
|
|
1281
|
+
manifest += "You can use the Read tool to view these files.\n\n";
|
|
1557
1282
|
}
|
|
1558
1283
|
return manifest;
|
|
1559
1284
|
}
|
|
1560
|
-
/**
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
async isAgentMentionedInComment(comment, repository) {
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
}
|
|
1285
|
+
// /**
|
|
1286
|
+
// * Check if the agent (Cyrus) is mentioned in a comment
|
|
1287
|
+
// * @param comment Linear comment object from webhook data
|
|
1288
|
+
// * @param repository Repository configuration
|
|
1289
|
+
// * @returns true if the agent is mentioned, false otherwise
|
|
1290
|
+
// */
|
|
1291
|
+
// private async isAgentMentionedInComment(comment: LinearWebhookComment, repository: RepositoryConfig): Promise<boolean> {
|
|
1292
|
+
// try {
|
|
1293
|
+
// const linearClient = this.linearClients.get(repository.id)
|
|
1294
|
+
// if (!linearClient) {
|
|
1295
|
+
// console.warn(`No Linear client found for repository ${repository.id}`)
|
|
1296
|
+
// return false
|
|
1297
|
+
// }
|
|
1298
|
+
// // Get the current user (agent) information
|
|
1299
|
+
// const viewer = await linearClient.viewer
|
|
1300
|
+
// if (!viewer) {
|
|
1301
|
+
// console.warn('Unable to fetch viewer information')
|
|
1302
|
+
// return false
|
|
1303
|
+
// }
|
|
1304
|
+
// // Check for mentions in the comment body
|
|
1305
|
+
// // Linear mentions can be in formats like:
|
|
1306
|
+
// // @username, @"Display Name", or @userId
|
|
1307
|
+
// const commentBody = comment.body
|
|
1308
|
+
// // Check for mention by user ID (most reliable)
|
|
1309
|
+
// if (commentBody.includes(`@${viewer.id}`)) {
|
|
1310
|
+
// return true
|
|
1311
|
+
// }
|
|
1312
|
+
// // Check for mention by name (case-insensitive)
|
|
1313
|
+
// if (viewer.name) {
|
|
1314
|
+
// const namePattern = new RegExp(`@"?${viewer.name}"?`, 'i')
|
|
1315
|
+
// if (namePattern.test(commentBody)) {
|
|
1316
|
+
// return true
|
|
1317
|
+
// }
|
|
1318
|
+
// }
|
|
1319
|
+
// // Check for mention by display name (case-insensitive)
|
|
1320
|
+
// if (viewer.displayName && viewer.displayName !== viewer.name) {
|
|
1321
|
+
// const displayNamePattern = new RegExp(`@"?${viewer.displayName}"?`, 'i')
|
|
1322
|
+
// if (displayNamePattern.test(commentBody)) {
|
|
1323
|
+
// return true
|
|
1324
|
+
// }
|
|
1325
|
+
// }
|
|
1326
|
+
// // Check for mention by email (less common but possible)
|
|
1327
|
+
// if (viewer.email) {
|
|
1328
|
+
// const emailPattern = new RegExp(`@"?${viewer.email}"?`, 'i')
|
|
1329
|
+
// if (emailPattern.test(commentBody)) {
|
|
1330
|
+
// return true
|
|
1331
|
+
// }
|
|
1332
|
+
// }
|
|
1333
|
+
// return false
|
|
1334
|
+
// } catch (error) {
|
|
1335
|
+
// console.error('Failed to check if agent is mentioned in comment:', error)
|
|
1336
|
+
// // If we can't determine, err on the side of caution and allow the trigger
|
|
1337
|
+
// return true
|
|
1338
|
+
// }
|
|
1339
|
+
// }
|
|
1616
1340
|
/**
|
|
1617
1341
|
* Build MCP configuration with automatic Linear server injection
|
|
1618
1342
|
*/
|
|
@@ -1620,13 +1344,13 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1620
1344
|
// Always inject the Linear MCP server with the repository's token
|
|
1621
1345
|
const mcpConfig = {
|
|
1622
1346
|
linear: {
|
|
1623
|
-
type:
|
|
1624
|
-
command:
|
|
1625
|
-
args: [
|
|
1347
|
+
type: "stdio",
|
|
1348
|
+
command: "npx",
|
|
1349
|
+
args: ["-y", "@tacticlaunch/mcp-linear"],
|
|
1626
1350
|
env: {
|
|
1627
|
-
LINEAR_API_TOKEN: repository.linearToken
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1351
|
+
LINEAR_API_TOKEN: repository.linearToken,
|
|
1352
|
+
},
|
|
1353
|
+
},
|
|
1630
1354
|
};
|
|
1631
1355
|
return mcpConfig;
|
|
1632
1356
|
}
|
|
@@ -1635,116 +1359,210 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
|
|
|
1635
1359
|
*/
|
|
1636
1360
|
buildAllowedTools(repository) {
|
|
1637
1361
|
// Start with configured tools or defaults
|
|
1638
|
-
const baseTools = repository.allowedTools ||
|
|
1362
|
+
const baseTools = repository.allowedTools ||
|
|
1363
|
+
this.config.defaultAllowedTools ||
|
|
1364
|
+
getSafeTools();
|
|
1639
1365
|
// Ensure baseTools is an array
|
|
1640
1366
|
const baseToolsArray = Array.isArray(baseTools) ? baseTools : [];
|
|
1641
1367
|
// Linear MCP tools that should always be available
|
|
1642
1368
|
// See: https://docs.anthropic.com/en/docs/claude-code/iam#tool-specific-permission-rules
|
|
1643
|
-
const linearMcpTools = [
|
|
1644
|
-
"mcp__linear"
|
|
1645
|
-
];
|
|
1369
|
+
const linearMcpTools = ["mcp__linear"];
|
|
1646
1370
|
// Combine and deduplicate
|
|
1647
1371
|
const allTools = [...new Set([...baseToolsArray, ...linearMcpTools])];
|
|
1648
1372
|
return allTools;
|
|
1649
1373
|
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Get Agent Sessions for an issue
|
|
1376
|
+
*/
|
|
1377
|
+
getAgentSessionsForIssue(issueId, repositoryId) {
|
|
1378
|
+
const agentSessionManager = this.agentSessionManagers.get(repositoryId);
|
|
1379
|
+
if (!agentSessionManager) {
|
|
1380
|
+
return [];
|
|
1381
|
+
}
|
|
1382
|
+
return agentSessionManager.getSessionsByIssueId(issueId);
|
|
1383
|
+
}
|
|
1650
1384
|
/**
|
|
1651
1385
|
* Load persisted EdgeWorker state for all repositories
|
|
1652
1386
|
*/
|
|
1653
1387
|
async loadPersistedState() {
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
console.log(`✅ Loaded persisted state for repository: ${repo.name}`);
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
catch (error) {
|
|
1663
|
-
console.error(`Failed to load persisted state for repository ${repo.name}:`, error);
|
|
1388
|
+
try {
|
|
1389
|
+
const state = await this.persistenceManager.loadEdgeWorkerState();
|
|
1390
|
+
if (state) {
|
|
1391
|
+
this.restoreMappings(state);
|
|
1392
|
+
console.log(`✅ Loaded persisted EdgeWorker state with ${Object.keys(state.agentSessions || {}).length} repositories`);
|
|
1664
1393
|
}
|
|
1665
1394
|
}
|
|
1395
|
+
catch (error) {
|
|
1396
|
+
console.error(`Failed to load persisted EdgeWorker state:`, error);
|
|
1397
|
+
}
|
|
1666
1398
|
}
|
|
1667
1399
|
/**
|
|
1668
1400
|
* Save current EdgeWorker state for all repositories
|
|
1669
1401
|
*/
|
|
1670
1402
|
async savePersistedState() {
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
}
|
|
1403
|
+
try {
|
|
1404
|
+
const state = this.serializeMappings();
|
|
1405
|
+
await this.persistenceManager.saveEdgeWorkerState(state);
|
|
1406
|
+
console.log(`✅ Saved EdgeWorker state for ${Object.keys(state.agentSessions || {}).length} repositories`);
|
|
1407
|
+
}
|
|
1408
|
+
catch (error) {
|
|
1409
|
+
console.error(`Failed to save persisted EdgeWorker state:`, error);
|
|
1679
1410
|
}
|
|
1680
1411
|
}
|
|
1681
1412
|
/**
|
|
1682
1413
|
* Serialize EdgeWorker mappings to a serializable format
|
|
1683
1414
|
*/
|
|
1684
1415
|
serializeMappings() {
|
|
1685
|
-
//
|
|
1686
|
-
const
|
|
1687
|
-
|
|
1688
|
-
|
|
1416
|
+
// Serialize Agent Session state for all repositories
|
|
1417
|
+
const agentSessions = {};
|
|
1418
|
+
const agentSessionEntries = {};
|
|
1419
|
+
for (const [repositoryId, agentSessionManager,] of this.agentSessionManagers.entries()) {
|
|
1420
|
+
const serializedState = agentSessionManager.serializeState();
|
|
1421
|
+
agentSessions[repositoryId] = serializedState.sessions;
|
|
1422
|
+
agentSessionEntries[repositoryId] = serializedState.entries;
|
|
1689
1423
|
}
|
|
1690
|
-
// Serialize session manager state
|
|
1691
|
-
const sessionManagerState = this.sessionManager.serializeSessions();
|
|
1692
1424
|
return {
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
commentToLatestAgentReply: PersistenceManager.mapToRecord(this.commentToLatestAgentReply),
|
|
1696
|
-
issueToCommentThreads,
|
|
1697
|
-
issueToReplyContext: PersistenceManager.mapToRecord(this.issueToReplyContext),
|
|
1698
|
-
sessionsByCommentId: sessionManagerState.sessionsByCommentId,
|
|
1699
|
-
sessionsByIssueId: sessionManagerState.sessionsByIssueId
|
|
1425
|
+
agentSessions,
|
|
1426
|
+
agentSessionEntries,
|
|
1700
1427
|
};
|
|
1701
1428
|
}
|
|
1702
1429
|
/**
|
|
1703
1430
|
* Restore EdgeWorker mappings from serialized state
|
|
1704
1431
|
*/
|
|
1705
1432
|
restoreMappings(state) {
|
|
1706
|
-
// Restore
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
sessionsByCommentId: state.sessionsByCommentId,
|
|
1719
|
-
sessionsByIssueId: state.sessionsByIssueId
|
|
1720
|
-
});
|
|
1433
|
+
// Restore Agent Session state for all repositories
|
|
1434
|
+
if (state.agentSessions && state.agentSessionEntries) {
|
|
1435
|
+
for (const [repositoryId, agentSessionManager,] of this.agentSessionManagers.entries()) {
|
|
1436
|
+
const repositorySessions = state.agentSessions[repositoryId] || {};
|
|
1437
|
+
const repositoryEntries = state.agentSessionEntries[repositoryId] || {};
|
|
1438
|
+
if (Object.keys(repositorySessions).length > 0 ||
|
|
1439
|
+
Object.keys(repositoryEntries).length > 0) {
|
|
1440
|
+
agentSessionManager.restoreState(repositorySessions, repositoryEntries);
|
|
1441
|
+
console.log(`[EdgeWorker] Restored Agent Session state for repository ${repositoryId}`);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1721
1445
|
}
|
|
1722
1446
|
/**
|
|
1723
|
-
*
|
|
1447
|
+
* Post instant acknowledgment thought when agent session is created
|
|
1724
1448
|
*/
|
|
1725
|
-
async
|
|
1449
|
+
async postInstantAcknowledgment(linearAgentActivitySessionId, repositoryId) {
|
|
1726
1450
|
try {
|
|
1727
|
-
|
|
1728
|
-
|
|
1451
|
+
const linearClient = this.linearClients.get(repositoryId);
|
|
1452
|
+
if (!linearClient) {
|
|
1453
|
+
console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const activityInput = {
|
|
1457
|
+
agentSessionId: linearAgentActivitySessionId,
|
|
1458
|
+
content: {
|
|
1459
|
+
type: "thought",
|
|
1460
|
+
body: "I've received your request and I'm starting to work on it. Let me analyze the issue and prepare my approach.",
|
|
1461
|
+
},
|
|
1462
|
+
};
|
|
1463
|
+
const result = await linearClient.createAgentActivity(activityInput);
|
|
1464
|
+
if (result.success) {
|
|
1465
|
+
console.log(`[EdgeWorker] Posted instant acknowledgment thought for session ${linearAgentActivitySessionId}`);
|
|
1466
|
+
}
|
|
1467
|
+
else {
|
|
1468
|
+
console.error(`[EdgeWorker] Failed to post instant acknowledgment:`, result);
|
|
1469
|
+
}
|
|
1729
1470
|
}
|
|
1730
1471
|
catch (error) {
|
|
1731
|
-
console.error(
|
|
1472
|
+
console.error(`[EdgeWorker] Error posting instant acknowledgment:`, error);
|
|
1732
1473
|
}
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Post thought about system prompt selection based on labels
|
|
1477
|
+
*/
|
|
1478
|
+
async postSystemPromptSelectionThought(linearAgentActivitySessionId, labels, repositoryId) {
|
|
1479
|
+
try {
|
|
1480
|
+
const linearClient = this.linearClients.get(repositoryId);
|
|
1481
|
+
if (!linearClient) {
|
|
1482
|
+
console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
|
|
1483
|
+
return;
|
|
1737
1484
|
}
|
|
1738
|
-
|
|
1739
|
-
|
|
1485
|
+
// Determine which prompt type was selected and which label triggered it
|
|
1486
|
+
let selectedPromptType = null;
|
|
1487
|
+
let triggerLabel = null;
|
|
1488
|
+
const repository = Array.from(this.repositories.values()).find((r) => r.id === repositoryId);
|
|
1489
|
+
if (repository?.labelPrompts) {
|
|
1490
|
+
// Check debugger labels
|
|
1491
|
+
const debuggerLabel = repository.labelPrompts.debugger?.find((label) => labels.includes(label));
|
|
1492
|
+
if (debuggerLabel) {
|
|
1493
|
+
selectedPromptType = "debugger";
|
|
1494
|
+
triggerLabel = debuggerLabel;
|
|
1495
|
+
}
|
|
1496
|
+
else {
|
|
1497
|
+
// Check builder labels
|
|
1498
|
+
const builderLabel = repository.labelPrompts.builder?.find((label) => labels.includes(label));
|
|
1499
|
+
if (builderLabel) {
|
|
1500
|
+
selectedPromptType = "builder";
|
|
1501
|
+
triggerLabel = builderLabel;
|
|
1502
|
+
}
|
|
1503
|
+
else {
|
|
1504
|
+
// Check scoper labels
|
|
1505
|
+
const scoperLabel = repository.labelPrompts.scoper?.find((label) => labels.includes(label));
|
|
1506
|
+
if (scoperLabel) {
|
|
1507
|
+
selectedPromptType = "scoper";
|
|
1508
|
+
triggerLabel = scoperLabel;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
// Only post if a role was actually triggered
|
|
1514
|
+
if (!selectedPromptType || !triggerLabel) {
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
const activityInput = {
|
|
1518
|
+
agentSessionId: linearAgentActivitySessionId,
|
|
1519
|
+
content: {
|
|
1520
|
+
type: "thought",
|
|
1521
|
+
body: `Entering '${selectedPromptType}' mode because of the '${triggerLabel}' label. I'll follow the ${selectedPromptType} process...`,
|
|
1522
|
+
},
|
|
1523
|
+
};
|
|
1524
|
+
const result = await linearClient.createAgentActivity(activityInput);
|
|
1525
|
+
if (result.success) {
|
|
1526
|
+
console.log(`[EdgeWorker] Posted system prompt selection thought for session ${linearAgentActivitySessionId} (${selectedPromptType} mode)`);
|
|
1527
|
+
}
|
|
1528
|
+
else {
|
|
1529
|
+
console.error(`[EdgeWorker] Failed to post system prompt selection thought:`, result);
|
|
1740
1530
|
}
|
|
1741
1531
|
}
|
|
1742
|
-
|
|
1532
|
+
catch (error) {
|
|
1533
|
+
console.error(`[EdgeWorker] Error posting system prompt selection thought:`, error);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Post instant acknowledgment thought when receiving prompted webhook
|
|
1538
|
+
*/
|
|
1539
|
+
async postInstantPromptedAcknowledgment(linearAgentActivitySessionId, repositoryId, isStreaming) {
|
|
1743
1540
|
try {
|
|
1744
|
-
|
|
1541
|
+
const linearClient = this.linearClients.get(repositoryId);
|
|
1542
|
+
if (!linearClient) {
|
|
1543
|
+
console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
const message = isStreaming
|
|
1547
|
+
? "I've queued up your message as guidance"
|
|
1548
|
+
: "Getting started on that...";
|
|
1549
|
+
const activityInput = {
|
|
1550
|
+
agentSessionId: linearAgentActivitySessionId,
|
|
1551
|
+
content: {
|
|
1552
|
+
type: "thought",
|
|
1553
|
+
body: message,
|
|
1554
|
+
},
|
|
1555
|
+
};
|
|
1556
|
+
const result = await linearClient.createAgentActivity(activityInput);
|
|
1557
|
+
if (result.success) {
|
|
1558
|
+
console.log(`[EdgeWorker] Posted instant prompted acknowledgment thought for session ${linearAgentActivitySessionId} (streaming: ${isStreaming})`);
|
|
1559
|
+
}
|
|
1560
|
+
else {
|
|
1561
|
+
console.error(`[EdgeWorker] Failed to post instant prompted acknowledgment:`, result);
|
|
1562
|
+
}
|
|
1745
1563
|
}
|
|
1746
1564
|
catch (error) {
|
|
1747
|
-
console.error(
|
|
1565
|
+
console.error(`[EdgeWorker] Error posting instant prompted acknowledgment:`, error);
|
|
1748
1566
|
}
|
|
1749
1567
|
}
|
|
1750
1568
|
}
|