cyrus-edge-worker 0.2.0-rc.5 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AgentSessionManager.d.ts +5 -0
- package/dist/AgentSessionManager.d.ts.map +1 -1
- package/dist/AgentSessionManager.js +59 -0
- package/dist/AgentSessionManager.js.map +1 -1
- package/dist/EdgeWorker.d.ts +70 -17
- package/dist/EdgeWorker.d.ts.map +1 -1
- package/dist/EdgeWorker.js +334 -179
- package/dist/EdgeWorker.js.map +1 -1
- package/dist/RepositoryRouter.d.ts +118 -0
- package/dist/RepositoryRouter.d.ts.map +1 -0
- package/dist/RepositoryRouter.js +372 -0
- package/dist/RepositoryRouter.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/procedures/registry.d.ts +4 -0
- package/dist/procedures/registry.d.ts.map +1 -1
- package/dist/procedures/registry.js +4 -0
- package/dist/procedures/registry.js.map +1 -1
- package/dist/procedures/types.d.ts +2 -0
- package/dist/procedures/types.d.ts.map +1 -1
- package/package.json +7 -7
package/dist/EdgeWorker.js
CHANGED
|
@@ -11,6 +11,7 @@ import { LinearEventTransport } from "cyrus-linear-event-transport";
|
|
|
11
11
|
import { fileTypeFromBuffer } from "file-type";
|
|
12
12
|
import { AgentSessionManager } from "./AgentSessionManager.js";
|
|
13
13
|
import { ProcedureRouter, } from "./procedures/index.js";
|
|
14
|
+
import { RepositoryRouter, } from "./RepositoryRouter.js";
|
|
14
15
|
import { SharedApplicationServer } from "./SharedApplicationServer.js";
|
|
15
16
|
/**
|
|
16
17
|
* Unified edge worker that **orchestrates**
|
|
@@ -32,6 +33,8 @@ export class EdgeWorker extends EventEmitter {
|
|
|
32
33
|
procedureRouter; // Intelligent workflow routing
|
|
33
34
|
configWatcher; // File watcher for config.json
|
|
34
35
|
configPath; // Path to config.json file
|
|
36
|
+
/** @internal - Exposed for testing only */
|
|
37
|
+
repositoryRouter; // Repository routing and selection
|
|
35
38
|
constructor(config) {
|
|
36
39
|
super();
|
|
37
40
|
this.config = config;
|
|
@@ -43,6 +46,36 @@ export class EdgeWorker extends EventEmitter {
|
|
|
43
46
|
model: "haiku",
|
|
44
47
|
timeoutMs: 10000,
|
|
45
48
|
});
|
|
49
|
+
// Initialize repository router with dependencies
|
|
50
|
+
const repositoryRouterDeps = {
|
|
51
|
+
fetchIssueLabels: async (issueId, workspaceId) => {
|
|
52
|
+
// Get Linear client for this workspace
|
|
53
|
+
const linearClient = this.getLinearClientForWorkspace(workspaceId);
|
|
54
|
+
if (!linearClient) {
|
|
55
|
+
console.warn(`[EdgeWorker] No Linear client found for workspace ${workspaceId}`);
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const issue = await linearClient.issue(issueId);
|
|
60
|
+
return await this.fetchIssueLabels(issue);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
console.error(`[EdgeWorker] Failed to fetch issue labels for ${issueId}:`, error);
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
hasActiveSession: (issueId, repositoryId) => {
|
|
68
|
+
const sessionManager = this.agentSessionManagers.get(repositoryId);
|
|
69
|
+
if (!sessionManager)
|
|
70
|
+
return false;
|
|
71
|
+
const activeSessions = sessionManager.getActiveSessionsByIssueId(issueId);
|
|
72
|
+
return activeSessions.length > 0;
|
|
73
|
+
},
|
|
74
|
+
getLinearClient: (workspaceId) => {
|
|
75
|
+
return this.getLinearClientForWorkspace(workspaceId);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
this.repositoryRouter = new RepositoryRouter(repositoryRouterDeps);
|
|
46
79
|
console.log(`[EdgeWorker Constructor] Initializing parent-child session mapping system`);
|
|
47
80
|
console.log(`[EdgeWorker Constructor] Parent-child mapping initialized with 0 entries`);
|
|
48
81
|
// Initialize shared application server
|
|
@@ -108,16 +141,16 @@ export class EdgeWorker extends EventEmitter {
|
|
|
108
141
|
}
|
|
109
142
|
console.log(`[Subroutine Transition] Next subroutine: ${nextSubroutine.name}`);
|
|
110
143
|
// Load subroutine prompt
|
|
111
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
112
|
-
const __dirname = dirname(__filename);
|
|
113
|
-
const subroutinePromptPath = join(__dirname, "prompts", nextSubroutine.promptPath);
|
|
114
144
|
let subroutinePrompt;
|
|
115
145
|
try {
|
|
116
|
-
subroutinePrompt = await
|
|
117
|
-
|
|
146
|
+
subroutinePrompt = await this.loadSubroutinePrompt(nextSubroutine, this.config.linearWorkspaceSlug);
|
|
147
|
+
if (!subroutinePrompt) {
|
|
148
|
+
// Fallback if loadSubroutinePrompt returns null
|
|
149
|
+
subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
|
|
150
|
+
}
|
|
118
151
|
}
|
|
119
152
|
catch (error) {
|
|
120
|
-
console.error(`[Subroutine Transition] Failed to load subroutine prompt
|
|
153
|
+
console.error(`[Subroutine Transition] Failed to load subroutine prompt:`, error);
|
|
121
154
|
// Fallback to simple prompt
|
|
122
155
|
subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
|
|
123
156
|
}
|
|
@@ -375,6 +408,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
375
408
|
...this.config,
|
|
376
409
|
repositories: parsedConfig.repositories || [],
|
|
377
410
|
ngrokAuthToken: parsedConfig.ngrokAuthToken || this.config.ngrokAuthToken,
|
|
411
|
+
linearWorkspaceSlug: parsedConfig.linearWorkspaceSlug || this.config.linearWorkspaceSlug,
|
|
378
412
|
defaultModel: parsedConfig.defaultModel || this.config.defaultModel,
|
|
379
413
|
defaultFallbackModel: parsedConfig.defaultFallbackModel || this.config.defaultFallbackModel,
|
|
380
414
|
defaultAllowedTools: parsedConfig.defaultAllowedTools || this.config.defaultAllowedTools,
|
|
@@ -603,29 +637,21 @@ export class EdgeWorker extends EventEmitter {
|
|
|
603
637
|
this.config.handlers?.onError?.(error);
|
|
604
638
|
}
|
|
605
639
|
/**
|
|
606
|
-
*
|
|
640
|
+
* Get cached repository for an issue (used by agentSessionPrompted Branch 3)
|
|
641
|
+
*/
|
|
642
|
+
getCachedRepository(issueId) {
|
|
643
|
+
return this.repositoryRouter.getCachedRepository(issueId, this.repositories);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Handle webhook events from proxy - main router for all webhooks
|
|
607
647
|
*/
|
|
608
648
|
async handleWebhook(webhook, repos) {
|
|
609
649
|
// Log verbose webhook info if enabled
|
|
610
650
|
if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
|
|
611
651
|
console.log(`[handleWebhook] Full webhook payload:`, JSON.stringify(webhook, null, 2));
|
|
612
652
|
}
|
|
613
|
-
// Find the appropriate repository for this webhook
|
|
614
|
-
const repository = await this.findRepositoryForWebhook(webhook, repos);
|
|
615
|
-
if (!repository) {
|
|
616
|
-
if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
|
|
617
|
-
console.log(`[handleWebhook] No repository configured for webhook from workspace ${webhook.organizationId}`);
|
|
618
|
-
console.log(`[handleWebhook] Available repositories:`, repos.map((r) => ({
|
|
619
|
-
name: r.name,
|
|
620
|
-
workspaceId: r.linearWorkspaceId,
|
|
621
|
-
teamKeys: r.teamKeys,
|
|
622
|
-
routingLabels: r.routingLabels,
|
|
623
|
-
})));
|
|
624
|
-
}
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
653
|
try {
|
|
628
|
-
//
|
|
654
|
+
// Route to specific webhook handlers based on webhook type
|
|
629
655
|
// NOTE: Traditional webhooks (assigned, comment) are disabled in favor of agent session events
|
|
630
656
|
if (isIssueAssignedWebhook(webhook)) {
|
|
631
657
|
return;
|
|
@@ -638,22 +664,22 @@ export class EdgeWorker extends EventEmitter {
|
|
|
638
664
|
}
|
|
639
665
|
else if (isIssueUnassignedWebhook(webhook)) {
|
|
640
666
|
// Keep unassigned webhook active
|
|
641
|
-
await this.handleIssueUnassignedWebhook(webhook
|
|
667
|
+
await this.handleIssueUnassignedWebhook(webhook);
|
|
642
668
|
}
|
|
643
669
|
else if (isAgentSessionCreatedWebhook(webhook)) {
|
|
644
|
-
await this.handleAgentSessionCreatedWebhook(webhook,
|
|
670
|
+
await this.handleAgentSessionCreatedWebhook(webhook, repos);
|
|
645
671
|
}
|
|
646
672
|
else if (isAgentSessionPromptedWebhook(webhook)) {
|
|
647
|
-
await this.
|
|
673
|
+
await this.handleUserPromptedAgentActivity(webhook);
|
|
648
674
|
}
|
|
649
675
|
else {
|
|
650
676
|
if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
|
|
651
|
-
console.log(`[handleWebhook] Unhandled webhook type: ${webhook.action}
|
|
677
|
+
console.log(`[handleWebhook] Unhandled webhook type: ${webhook.action}`);
|
|
652
678
|
}
|
|
653
679
|
}
|
|
654
680
|
}
|
|
655
681
|
catch (error) {
|
|
656
|
-
console.error(`[handleWebhook] Failed to process webhook: ${webhook.action}
|
|
682
|
+
console.error(`[handleWebhook] Failed to process webhook: ${webhook.action}`, error);
|
|
657
683
|
// Don't re-throw webhook processing errors to prevent application crashes
|
|
658
684
|
// The error has been logged and individual webhook failures shouldn't crash the entire system
|
|
659
685
|
}
|
|
@@ -661,7 +687,14 @@ export class EdgeWorker extends EventEmitter {
|
|
|
661
687
|
/**
|
|
662
688
|
* Handle issue unassignment webhook
|
|
663
689
|
*/
|
|
664
|
-
async handleIssueUnassignedWebhook(webhook
|
|
690
|
+
async handleIssueUnassignedWebhook(webhook) {
|
|
691
|
+
const issueId = webhook.notification.issue.id;
|
|
692
|
+
// Get cached repository (unassignment should only happen on issues with active sessions)
|
|
693
|
+
const repository = this.getCachedRepository(issueId);
|
|
694
|
+
if (!repository) {
|
|
695
|
+
console.log(`[EdgeWorker] No cached repository for issue unassignment webhook ${webhook.notification.issue.identifier} (no active sessions to stop)`);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
665
698
|
console.log(`[EdgeWorker] Handling issue unassignment: ${webhook.notification.issue.identifier}`);
|
|
666
699
|
// Log the complete webhook payload for TypeScript type definition
|
|
667
700
|
// console.log('=== ISSUE UNASSIGNMENT WEBHOOK PAYLOAD ===')
|
|
@@ -670,128 +703,15 @@ export class EdgeWorker extends EventEmitter {
|
|
|
670
703
|
await this.handleIssueUnassigned(webhook.notification.issue, repository);
|
|
671
704
|
}
|
|
672
705
|
/**
|
|
673
|
-
*
|
|
674
|
-
* Now supports async operations for label-based and project-based routing
|
|
675
|
-
* Priority: routingLabels > projectKeys > teamKeys
|
|
676
|
-
*/
|
|
677
|
-
async findRepositoryForWebhook(webhook, repos) {
|
|
678
|
-
const workspaceId = webhook.organizationId;
|
|
679
|
-
if (!workspaceId)
|
|
680
|
-
return repos[0] || null; // Fallback to first repo if no workspace ID
|
|
681
|
-
// Get issue information from webhook
|
|
682
|
-
let issueId;
|
|
683
|
-
let teamKey;
|
|
684
|
-
let issueIdentifier;
|
|
685
|
-
// Handle agent session webhooks which have different structure
|
|
686
|
-
if (isAgentSessionCreatedWebhook(webhook) ||
|
|
687
|
-
isAgentSessionPromptedWebhook(webhook)) {
|
|
688
|
-
issueId = webhook.agentSession?.issue?.id;
|
|
689
|
-
teamKey = webhook.agentSession?.issue?.team?.key;
|
|
690
|
-
issueIdentifier = webhook.agentSession?.issue?.identifier;
|
|
691
|
-
}
|
|
692
|
-
else {
|
|
693
|
-
issueId = webhook.notification?.issue?.id;
|
|
694
|
-
teamKey = webhook.notification?.issue?.team?.key;
|
|
695
|
-
issueIdentifier = webhook.notification?.issue?.identifier;
|
|
696
|
-
}
|
|
697
|
-
// Filter repos by workspace first
|
|
698
|
-
const workspaceRepos = repos.filter((repo) => repo.linearWorkspaceId === workspaceId);
|
|
699
|
-
if (workspaceRepos.length === 0)
|
|
700
|
-
return null;
|
|
701
|
-
// Priority 1: Check routing labels (highest priority)
|
|
702
|
-
const reposWithRoutingLabels = workspaceRepos.filter((repo) => repo.routingLabels && repo.routingLabels.length > 0);
|
|
703
|
-
if (reposWithRoutingLabels.length > 0 && issueId && workspaceRepos[0]) {
|
|
704
|
-
// We need a Linear client to fetch labels
|
|
705
|
-
// Use the first workspace repo's client temporarily
|
|
706
|
-
const linearClient = this.linearClients.get(workspaceRepos[0].id);
|
|
707
|
-
if (linearClient) {
|
|
708
|
-
try {
|
|
709
|
-
// Fetch the issue to get labels
|
|
710
|
-
const issue = await linearClient.issue(issueId);
|
|
711
|
-
const labels = await this.fetchIssueLabels(issue);
|
|
712
|
-
// Check each repo with routing labels
|
|
713
|
-
for (const repo of reposWithRoutingLabels) {
|
|
714
|
-
if (repo.routingLabels?.some((routingLabel) => labels.includes(routingLabel))) {
|
|
715
|
-
console.log(`[EdgeWorker] Repository selected: ${repo.name} (label-based routing)`);
|
|
716
|
-
return repo;
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
catch (error) {
|
|
721
|
-
console.error(`[EdgeWorker] Failed to fetch labels for routing:`, error);
|
|
722
|
-
// Continue to project-based routing
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
// Priority 2: Check project-based routing
|
|
727
|
-
if (issueId) {
|
|
728
|
-
const projectBasedRepo = await this.findRepositoryByProject(issueId, workspaceRepos);
|
|
729
|
-
if (projectBasedRepo) {
|
|
730
|
-
console.log(`[EdgeWorker] Repository selected: ${projectBasedRepo.name} (project-based routing)`);
|
|
731
|
-
return projectBasedRepo;
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
// Priority 3: Check team-based routing
|
|
735
|
-
if (teamKey) {
|
|
736
|
-
const repo = workspaceRepos.find((r) => r.teamKeys?.includes(teamKey));
|
|
737
|
-
if (repo) {
|
|
738
|
-
console.log(`[EdgeWorker] Repository selected: ${repo.name} (team-based routing)`);
|
|
739
|
-
return repo;
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
// Try parsing issue identifier as fallback for team routing
|
|
743
|
-
if (issueIdentifier?.includes("-")) {
|
|
744
|
-
const prefix = issueIdentifier.split("-")[0];
|
|
745
|
-
if (prefix) {
|
|
746
|
-
const repo = workspaceRepos.find((r) => r.teamKeys?.includes(prefix));
|
|
747
|
-
if (repo) {
|
|
748
|
-
console.log(`[EdgeWorker] Repository selected: ${repo.name} (team prefix routing)`);
|
|
749
|
-
return repo;
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
// Workspace fallback - find first repo without routing configuration
|
|
754
|
-
const catchAllRepo = workspaceRepos.find((repo) => (!repo.teamKeys || repo.teamKeys.length === 0) &&
|
|
755
|
-
(!repo.routingLabels || repo.routingLabels.length === 0) &&
|
|
756
|
-
(!repo.projectKeys || repo.projectKeys.length === 0));
|
|
757
|
-
if (catchAllRepo) {
|
|
758
|
-
console.log(`[EdgeWorker] Repository selected: ${catchAllRepo.name} (workspace catch-all)`);
|
|
759
|
-
return catchAllRepo;
|
|
760
|
-
}
|
|
761
|
-
// Final fallback to first workspace repo
|
|
762
|
-
const fallbackRepo = workspaceRepos[0] || null;
|
|
763
|
-
if (fallbackRepo) {
|
|
764
|
-
console.log(`[EdgeWorker] Repository selected: ${fallbackRepo.name} (workspace fallback)`);
|
|
765
|
-
}
|
|
766
|
-
return fallbackRepo;
|
|
767
|
-
}
|
|
768
|
-
/**
|
|
769
|
-
* Helper method to find repository by project name
|
|
706
|
+
* Get Linear client for a workspace by finding first repository with that workspace ID
|
|
770
707
|
*/
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
continue;
|
|
776
|
-
try {
|
|
777
|
-
const fullIssue = await this.fetchFullIssueDetails(issueId, repo.id);
|
|
778
|
-
const project = await fullIssue?.project;
|
|
779
|
-
if (!project || !project.name) {
|
|
780
|
-
console.warn(`[EdgeWorker] No project name found for issue ${issueId} in repository ${repo.name}`);
|
|
781
|
-
continue;
|
|
782
|
-
}
|
|
783
|
-
const projectName = project.name;
|
|
784
|
-
if (repo.projectKeys.includes(projectName)) {
|
|
785
|
-
console.log(`[EdgeWorker] Matched issue ${issueId} to repository ${repo.name} via project: ${projectName}`);
|
|
786
|
-
return repo;
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
catch (error) {
|
|
790
|
-
// Continue to next repository if this one fails
|
|
791
|
-
console.debug(`[EdgeWorker] Failed to fetch project for issue ${issueId} from repository ${repo.name}:`, error);
|
|
708
|
+
getLinearClientForWorkspace(workspaceId) {
|
|
709
|
+
for (const [repoId, repo] of this.repositories) {
|
|
710
|
+
if (repo.linearWorkspaceId === workspaceId) {
|
|
711
|
+
return this.linearClients.get(repoId);
|
|
792
712
|
}
|
|
793
713
|
}
|
|
794
|
-
return
|
|
714
|
+
return undefined;
|
|
795
715
|
}
|
|
796
716
|
/**
|
|
797
717
|
* Create a new Linear agent session with all necessary setup
|
|
@@ -849,13 +769,67 @@ export class EdgeWorker extends EventEmitter {
|
|
|
849
769
|
}
|
|
850
770
|
/**
|
|
851
771
|
* Handle agent session created webhook
|
|
852
|
-
*
|
|
853
|
-
* @param webhook
|
|
854
|
-
* @param
|
|
855
|
-
*/
|
|
856
|
-
async handleAgentSessionCreatedWebhook(webhook,
|
|
772
|
+
* Can happen due to being 'delegated' or @ mentioned in a new thread
|
|
773
|
+
* @param webhook The agent session created webhook
|
|
774
|
+
* @param repos All available repositories for routing
|
|
775
|
+
*/
|
|
776
|
+
async handleAgentSessionCreatedWebhook(webhook, repos) {
|
|
777
|
+
const issueId = webhook.agentSession?.issue?.id;
|
|
778
|
+
// Check the cache first, as the agentSessionCreated webhook may have been triggered by an @mention
|
|
779
|
+
// on an issue that already has an agentSession and an associated repository.
|
|
780
|
+
let repository = null;
|
|
781
|
+
if (issueId) {
|
|
782
|
+
repository = this.getCachedRepository(issueId);
|
|
783
|
+
if (repository) {
|
|
784
|
+
console.log(`[EdgeWorker] Using cached repository ${repository.name} for issue ${issueId}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
// If not cached, perform routing logic
|
|
788
|
+
if (!repository) {
|
|
789
|
+
const routingResult = await this.repositoryRouter.determineRepositoryForWebhook(webhook, repos);
|
|
790
|
+
if (routingResult.type === "none") {
|
|
791
|
+
if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
|
|
792
|
+
console.log(`[EdgeWorker] No repository configured for webhook from workspace ${webhook.organizationId}`);
|
|
793
|
+
}
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
// Handle needs_selection case
|
|
797
|
+
if (routingResult.type === "needs_selection") {
|
|
798
|
+
await this.repositoryRouter.elicitUserRepositorySelection(webhook, routingResult.workspaceRepos);
|
|
799
|
+
// Selection in progress - will be handled by handleRepositorySelectionResponse
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
// At this point, routingResult.type === "selected"
|
|
803
|
+
repository = routingResult.repository;
|
|
804
|
+
const routingMethod = routingResult.routingMethod;
|
|
805
|
+
// Cache the repository for this issue
|
|
806
|
+
if (issueId) {
|
|
807
|
+
this.repositoryRouter
|
|
808
|
+
.getIssueRepositoryCache()
|
|
809
|
+
.set(issueId, repository.id);
|
|
810
|
+
}
|
|
811
|
+
// Post agent activity showing auto-matched routing
|
|
812
|
+
await this.postRepositorySelectionActivity(webhook.agentSession.id, repository.id, repository.name, routingMethod);
|
|
813
|
+
}
|
|
857
814
|
console.log(`[EdgeWorker] Handling agent session created: ${webhook.agentSession.issue.identifier}`);
|
|
858
815
|
const { agentSession, guidance } = webhook;
|
|
816
|
+
const commentBody = agentSession.comment?.body;
|
|
817
|
+
// Initialize Claude runner using shared logic
|
|
818
|
+
await this.initializeClaudeRunner(agentSession, repository, guidance, commentBody);
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Initialize and start Claude runner for an agent session
|
|
824
|
+
* This method contains the shared logic for creating a Claude runner that both
|
|
825
|
+
* handleAgentSessionCreatedWebhook and handleUserPromptedAgentActivity use.
|
|
826
|
+
*
|
|
827
|
+
* @param agentSession The Linear agent session
|
|
828
|
+
* @param repository The repository configuration
|
|
829
|
+
* @param guidance Optional guidance rules from Linear
|
|
830
|
+
* @param commentBody Optional comment body (for mentions)
|
|
831
|
+
*/
|
|
832
|
+
async initializeClaudeRunner(agentSession, repository, guidance, commentBody) {
|
|
859
833
|
const linearAgentActivitySessionId = agentSession.id;
|
|
860
834
|
const { issue } = agentSession;
|
|
861
835
|
// Log guidance if present
|
|
@@ -874,7 +848,6 @@ export class EdgeWorker extends EventEmitter {
|
|
|
874
848
|
console.log(`[EdgeWorker] - ${origin}: ${rule.body.substring(0, 100)}...`);
|
|
875
849
|
}
|
|
876
850
|
}
|
|
877
|
-
const commentBody = agentSession.comment?.body;
|
|
878
851
|
// HACK: This is required since the comment body is always populated, thus there is no other way to differentiate between the two trigger events
|
|
879
852
|
const AGENT_SESSION_MARKER = "This thread is for an agent session";
|
|
880
853
|
const isMentionTriggered = commentBody && !commentBody.includes(AGENT_SESSION_MARKER);
|
|
@@ -982,7 +955,9 @@ export class EdgeWorker extends EventEmitter {
|
|
|
982
955
|
}
|
|
983
956
|
// Build allowed tools list with Linear MCP tools (now with prompt type context)
|
|
984
957
|
const allowedTools = this.buildAllowedTools(repository, promptType);
|
|
985
|
-
const
|
|
958
|
+
const baseDisallowedTools = this.buildDisallowedTools(repository, promptType);
|
|
959
|
+
// Merge subroutine-level disallowedTools if applicable
|
|
960
|
+
const disallowedTools = this.mergeSubroutineDisallowedTools(session, baseDisallowedTools, "EdgeWorker");
|
|
986
961
|
console.log(`[EdgeWorker] Configured allowed tools for ${fullIssue.identifier}:`, allowedTools);
|
|
987
962
|
if (disallowedTools.length > 0) {
|
|
988
963
|
console.log(`[EdgeWorker] Configured disallowed tools for ${fullIssue.identifier}:`, disallowedTools);
|
|
@@ -1018,13 +993,78 @@ export class EdgeWorker extends EventEmitter {
|
|
|
1018
993
|
}
|
|
1019
994
|
}
|
|
1020
995
|
/**
|
|
1021
|
-
* Handle
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
996
|
+
* Handle stop signal from prompted webhook
|
|
997
|
+
* Branch 1 of agentSessionPrompted (see packages/CLAUDE.md)
|
|
998
|
+
*
|
|
999
|
+
* IMPORTANT: Stop signals do NOT require repository lookup.
|
|
1000
|
+
* The session must already exist (per CLAUDE.md), so we search
|
|
1001
|
+
* all agent session managers to find it.
|
|
1002
|
+
*/
|
|
1003
|
+
async handleStopSignal(webhook) {
|
|
1004
|
+
const agentSessionId = webhook.agentSession.id;
|
|
1005
|
+
const { issue } = webhook.agentSession;
|
|
1006
|
+
console.log(`[EdgeWorker] Received stop signal for agent activity session ${agentSessionId}`);
|
|
1007
|
+
// Find the agent session manager that contains this session
|
|
1008
|
+
// We don't need repository lookup - just search all managers
|
|
1009
|
+
let foundManager = null;
|
|
1010
|
+
let foundSession = null;
|
|
1011
|
+
for (const manager of this.agentSessionManagers.values()) {
|
|
1012
|
+
const session = manager.getSession(agentSessionId);
|
|
1013
|
+
if (session) {
|
|
1014
|
+
foundManager = manager;
|
|
1015
|
+
foundSession = session;
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
if (!foundManager || !foundSession) {
|
|
1020
|
+
console.warn(`[EdgeWorker] No session found for stop signal: ${agentSessionId}`);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
// Stop the existing runner if it's active
|
|
1024
|
+
const existingRunner = foundSession.claudeRunner;
|
|
1025
|
+
if (existingRunner) {
|
|
1026
|
+
existingRunner.stop();
|
|
1027
|
+
console.log(`[EdgeWorker] Stopped Claude session for agent activity session ${agentSessionId}`);
|
|
1028
|
+
}
|
|
1029
|
+
// Post confirmation
|
|
1030
|
+
const issueTitle = issue.title || "this issue";
|
|
1031
|
+
const stopConfirmation = `I've stopped working on ${issueTitle} as requested.\n\n**Stop Signal:** Received from ${webhook.agentSession.creator?.name || "user"}\n**Action Taken:** All ongoing work has been halted`;
|
|
1032
|
+
await foundManager.createResponseActivity(agentSessionId, stopConfirmation);
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Handle repository selection response from prompted webhook
|
|
1036
|
+
* Branch 2 of agentSessionPrompted (see packages/CLAUDE.md)
|
|
1037
|
+
*
|
|
1038
|
+
* This method extracts the user's repository selection from their response,
|
|
1039
|
+
* or uses the fallback repository if their message doesn't match any option.
|
|
1040
|
+
* In both cases, the selected repository is cached for future use.
|
|
1025
1041
|
*/
|
|
1026
|
-
async
|
|
1027
|
-
|
|
1042
|
+
async handleRepositorySelectionResponse(webhook) {
|
|
1043
|
+
const { agentSession, agentActivity, guidance } = webhook;
|
|
1044
|
+
const commentBody = agentSession.comment?.body;
|
|
1045
|
+
const agentSessionId = agentSession.id;
|
|
1046
|
+
const userMessage = agentActivity.content.body;
|
|
1047
|
+
console.log(`[EdgeWorker] Processing repository selection response: "${userMessage}"`);
|
|
1048
|
+
// Get the selected repository (or fallback)
|
|
1049
|
+
const repository = await this.repositoryRouter.selectRepositoryFromResponse(agentSessionId, userMessage);
|
|
1050
|
+
if (!repository) {
|
|
1051
|
+
console.error(`[EdgeWorker] Failed to select repository for agent session ${agentSessionId}`);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
// Cache the selected repository for this issue
|
|
1055
|
+
const issueId = agentSession.issue.id;
|
|
1056
|
+
this.repositoryRouter.getIssueRepositoryCache().set(issueId, repository.id);
|
|
1057
|
+
// Post agent activity showing user-selected repository
|
|
1058
|
+
await this.postRepositorySelectionActivity(agentSessionId, repository.id, repository.name, "user-selected");
|
|
1059
|
+
console.log(`[EdgeWorker] Initializing Claude runner after repository selection: ${agentSession.issue.identifier} -> ${repository.name}`);
|
|
1060
|
+
// Initialize Claude runner with the selected repository
|
|
1061
|
+
await this.initializeClaudeRunner(agentSession, repository, guidance, commentBody);
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Handle normal prompted activity (existing session continuation)
|
|
1065
|
+
* Branch 3 of agentSessionPrompted (see packages/CLAUDE.md)
|
|
1066
|
+
*/
|
|
1067
|
+
async handleNormalPromptedActivity(webhook, repository) {
|
|
1028
1068
|
const { agentSession } = webhook;
|
|
1029
1069
|
const linearAgentActivitySessionId = agentSession.id;
|
|
1030
1070
|
const { issue } = agentSession;
|
|
@@ -1133,21 +1173,6 @@ export class EdgeWorker extends EventEmitter {
|
|
|
1133
1173
|
console.error("Failed to fetch comments for attachments:", error);
|
|
1134
1174
|
}
|
|
1135
1175
|
const promptBody = webhook.agentActivity.content.body;
|
|
1136
|
-
const stopSignal = webhook.agentActivity.signal === "stop";
|
|
1137
|
-
// Handle stop signal
|
|
1138
|
-
if (stopSignal) {
|
|
1139
|
-
console.log(`[EdgeWorker] Received stop signal for agent activity session ${linearAgentActivitySessionId}`);
|
|
1140
|
-
// Stop the existing runner if it's active
|
|
1141
|
-
const existingRunner = session.claudeRunner;
|
|
1142
|
-
if (existingRunner) {
|
|
1143
|
-
existingRunner.stop();
|
|
1144
|
-
console.log(`[EdgeWorker] Stopped Claude session for agent activity session ${linearAgentActivitySessionId}`);
|
|
1145
|
-
}
|
|
1146
|
-
const issueTitle = issue.title || "this issue";
|
|
1147
|
-
const stopConfirmation = `I've stopped working on ${issueTitle} as requested.\n\n**Stop Signal:** Received from ${webhook.agentSession.creator?.name || "user"}\n**Action Taken:** All ongoing work has been halted`;
|
|
1148
|
-
await agentSessionManager.createResponseActivity(linearAgentActivitySessionId, stopConfirmation);
|
|
1149
|
-
return; // Exit early - stop signal handled
|
|
1150
|
-
}
|
|
1151
1176
|
// Use centralized streaming check and routing logic
|
|
1152
1177
|
try {
|
|
1153
1178
|
await this.handlePromptWithStreamingCheck(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, [], // No additional allowed directories for regular continuation
|
|
@@ -1157,6 +1182,48 @@ export class EdgeWorker extends EventEmitter {
|
|
|
1157
1182
|
console.error("Failed to handle prompted webhook:", error);
|
|
1158
1183
|
}
|
|
1159
1184
|
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Handle user-prompted agent activity webhook
|
|
1187
|
+
* Implements three-branch architecture from packages/CLAUDE.md:
|
|
1188
|
+
* 1. Stop signal - terminate existing runner
|
|
1189
|
+
* 2. Repository selection response - initialize Claude runner for first time
|
|
1190
|
+
* 3. Normal prompted activity - continue existing session or create new one
|
|
1191
|
+
*
|
|
1192
|
+
* @param webhook The prompted webhook containing user's message
|
|
1193
|
+
*/
|
|
1194
|
+
async handleUserPromptedAgentActivity(webhook) {
|
|
1195
|
+
const agentSessionId = webhook.agentSession.id;
|
|
1196
|
+
// Branch 1: Handle stop signal (checked FIRST, before any routing work)
|
|
1197
|
+
// Per CLAUDE.md: "an agentSession MUST already exist" for stop signals
|
|
1198
|
+
// IMPORTANT: Stop signals do NOT require repository lookup
|
|
1199
|
+
if (webhook.agentActivity.signal === "stop") {
|
|
1200
|
+
await this.handleStopSignal(webhook);
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
// Branch 2: Handle repository selection response
|
|
1204
|
+
// This is the first Claude runner initialization after user selects a repository.
|
|
1205
|
+
// The selection handler extracts the choice from the response (or uses fallback)
|
|
1206
|
+
// and caches the repository for future use.
|
|
1207
|
+
if (this.repositoryRouter.hasPendingSelection(agentSessionId)) {
|
|
1208
|
+
await this.handleRepositorySelectionResponse(webhook);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
// Branch 3: Handle normal prompted activity (existing session continuation)
|
|
1212
|
+
// Per CLAUDE.md: "an agentSession MUST exist and a repository MUST already
|
|
1213
|
+
// be associated with the Linear issue. The repository will be retrieved from
|
|
1214
|
+
// the issue-to-repository cache - no new routing logic is performed."
|
|
1215
|
+
const issueId = webhook.agentSession?.issue?.id;
|
|
1216
|
+
if (!issueId) {
|
|
1217
|
+
console.error(`[EdgeWorker] No issue ID found in prompted webhook ${agentSessionId}`);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
const repository = this.getCachedRepository(issueId);
|
|
1221
|
+
if (!repository) {
|
|
1222
|
+
console.warn(`[EdgeWorker] No cached repository found for prompted webhook ${agentSessionId}`);
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
await this.handleNormalPromptedActivity(webhook, repository);
|
|
1226
|
+
}
|
|
1160
1227
|
/**
|
|
1161
1228
|
* Handle issue unassignment
|
|
1162
1229
|
* @param issue Linear issue object from webhook data
|
|
@@ -2520,7 +2587,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
|
|
|
2520
2587
|
const currentSubroutine = this.procedureRouter.getCurrentSubroutine(input.session);
|
|
2521
2588
|
let subroutineName;
|
|
2522
2589
|
if (currentSubroutine) {
|
|
2523
|
-
const subroutinePrompt = await this.loadSubroutinePrompt(currentSubroutine);
|
|
2590
|
+
const subroutinePrompt = await this.loadSubroutinePrompt(currentSubroutine, this.config.linearWorkspaceSlug);
|
|
2524
2591
|
if (subroutinePrompt) {
|
|
2525
2592
|
parts.push(subroutinePrompt);
|
|
2526
2593
|
components.push("subroutine-prompt");
|
|
@@ -2616,7 +2683,7 @@ ${input.userComment}
|
|
|
2616
2683
|
* Load a subroutine prompt file
|
|
2617
2684
|
* Extracted helper to make prompt assembly more readable
|
|
2618
2685
|
*/
|
|
2619
|
-
async loadSubroutinePrompt(subroutine) {
|
|
2686
|
+
async loadSubroutinePrompt(subroutine, workspaceSlug) {
|
|
2620
2687
|
// Skip loading for "primary" - it's a placeholder that doesn't have a file
|
|
2621
2688
|
if (subroutine.promptPath === "primary") {
|
|
2622
2689
|
return null;
|
|
@@ -2625,8 +2692,12 @@ ${input.userComment}
|
|
|
2625
2692
|
const __dirname = dirname(__filename);
|
|
2626
2693
|
const subroutinePromptPath = join(__dirname, "prompts", subroutine.promptPath);
|
|
2627
2694
|
try {
|
|
2628
|
-
|
|
2695
|
+
let prompt = await readFile(subroutinePromptPath, "utf-8");
|
|
2629
2696
|
console.log(`[EdgeWorker] Loaded ${subroutine.name} subroutine prompt (${prompt.length} characters)`);
|
|
2697
|
+
// Perform template substitution if workspace slug is provided
|
|
2698
|
+
if (workspaceSlug) {
|
|
2699
|
+
prompt = prompt.replace(/https:\/\/linear\.app\/linear\/profiles\//g, `https://linear.app/${workspaceSlug}/profiles/`);
|
|
2700
|
+
}
|
|
2630
2701
|
return prompt;
|
|
2631
2702
|
}
|
|
2632
2703
|
catch (error) {
|
|
@@ -2797,6 +2868,27 @@ ${input.userComment}
|
|
|
2797
2868
|
}
|
|
2798
2869
|
return disallowedTools;
|
|
2799
2870
|
}
|
|
2871
|
+
/**
|
|
2872
|
+
* Merge subroutine-level disallowedTools with base disallowedTools
|
|
2873
|
+
* @param session Current agent session
|
|
2874
|
+
* @param baseDisallowedTools Base disallowed tools from repository/global config
|
|
2875
|
+
* @param logContext Context string for logging (e.g., "EdgeWorker", "resumeClaudeSession")
|
|
2876
|
+
* @returns Merged disallowed tools list
|
|
2877
|
+
*/
|
|
2878
|
+
mergeSubroutineDisallowedTools(session, baseDisallowedTools, logContext) {
|
|
2879
|
+
const currentSubroutine = this.procedureRouter.getCurrentSubroutine(session);
|
|
2880
|
+
if (currentSubroutine?.disallowedTools) {
|
|
2881
|
+
const mergedTools = [
|
|
2882
|
+
...new Set([
|
|
2883
|
+
...baseDisallowedTools,
|
|
2884
|
+
...currentSubroutine.disallowedTools,
|
|
2885
|
+
]),
|
|
2886
|
+
];
|
|
2887
|
+
console.log(`[${logContext}] Merged subroutine-level disallowedTools for ${currentSubroutine.name}:`, currentSubroutine.disallowedTools);
|
|
2888
|
+
return mergedTools;
|
|
2889
|
+
}
|
|
2890
|
+
return baseDisallowedTools;
|
|
2891
|
+
}
|
|
2800
2892
|
/**
|
|
2801
2893
|
* Build allowed tools list with Linear MCP tools automatically included
|
|
2802
2894
|
*/
|
|
@@ -2890,10 +2982,13 @@ ${input.userComment}
|
|
|
2890
2982
|
}
|
|
2891
2983
|
// Serialize child to parent agent session mapping
|
|
2892
2984
|
const childToParentAgentSession = Object.fromEntries(this.childToParentAgentSession.entries());
|
|
2985
|
+
// Serialize issue to repository cache from RepositoryRouter
|
|
2986
|
+
const issueRepositoryCache = Object.fromEntries(this.repositoryRouter.getIssueRepositoryCache().entries());
|
|
2893
2987
|
return {
|
|
2894
2988
|
agentSessions,
|
|
2895
2989
|
agentSessionEntries,
|
|
2896
2990
|
childToParentAgentSession,
|
|
2991
|
+
issueRepositoryCache,
|
|
2897
2992
|
};
|
|
2898
2993
|
}
|
|
2899
2994
|
/**
|
|
@@ -2917,6 +3012,12 @@ ${input.userComment}
|
|
|
2917
3012
|
this.childToParentAgentSession = new Map(Object.entries(state.childToParentAgentSession));
|
|
2918
3013
|
console.log(`[EdgeWorker] Restored ${this.childToParentAgentSession.size} child-to-parent agent session mappings`);
|
|
2919
3014
|
}
|
|
3015
|
+
// Restore issue to repository cache in RepositoryRouter
|
|
3016
|
+
if (state.issueRepositoryCache) {
|
|
3017
|
+
const cache = new Map(Object.entries(state.issueRepositoryCache));
|
|
3018
|
+
this.repositoryRouter.restoreIssueRepositoryCache(cache);
|
|
3019
|
+
console.log(`[EdgeWorker] Restored ${cache.size} issue-to-repository cache mappings`);
|
|
3020
|
+
}
|
|
2920
3021
|
}
|
|
2921
3022
|
/**
|
|
2922
3023
|
* Post instant acknowledgment thought when agent session is created
|
|
@@ -2976,6 +3077,58 @@ ${input.userComment}
|
|
|
2976
3077
|
console.error(`[EdgeWorker] Error posting parent resumption acknowledgment:`, error);
|
|
2977
3078
|
}
|
|
2978
3079
|
}
|
|
3080
|
+
/**
|
|
3081
|
+
* Post repository selection activity to Linear
|
|
3082
|
+
* Shows which method was used to select the repository (auto-routing or user selection)
|
|
3083
|
+
*/
|
|
3084
|
+
async postRepositorySelectionActivity(linearAgentActivitySessionId, repositoryId, repositoryName, selectionMethod) {
|
|
3085
|
+
try {
|
|
3086
|
+
const linearClient = this.linearClients.get(repositoryId);
|
|
3087
|
+
if (!linearClient) {
|
|
3088
|
+
console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
let methodDisplay;
|
|
3092
|
+
if (selectionMethod === "user-selected") {
|
|
3093
|
+
methodDisplay = "selected by user";
|
|
3094
|
+
}
|
|
3095
|
+
else if (selectionMethod === "label-based") {
|
|
3096
|
+
methodDisplay = "matched via label-based routing";
|
|
3097
|
+
}
|
|
3098
|
+
else if (selectionMethod === "project-based") {
|
|
3099
|
+
methodDisplay = "matched via project-based routing";
|
|
3100
|
+
}
|
|
3101
|
+
else if (selectionMethod === "team-based") {
|
|
3102
|
+
methodDisplay = "matched via team-based routing";
|
|
3103
|
+
}
|
|
3104
|
+
else if (selectionMethod === "team-prefix") {
|
|
3105
|
+
methodDisplay = "matched via team prefix routing";
|
|
3106
|
+
}
|
|
3107
|
+
else if (selectionMethod === "catch-all") {
|
|
3108
|
+
methodDisplay = "matched via catch-all routing";
|
|
3109
|
+
}
|
|
3110
|
+
else {
|
|
3111
|
+
methodDisplay = "matched via workspace fallback";
|
|
3112
|
+
}
|
|
3113
|
+
const activityInput = {
|
|
3114
|
+
agentSessionId: linearAgentActivitySessionId,
|
|
3115
|
+
content: {
|
|
3116
|
+
type: "thought",
|
|
3117
|
+
body: `Repository "${repositoryName}" has been ${methodDisplay}.`,
|
|
3118
|
+
},
|
|
3119
|
+
};
|
|
3120
|
+
const result = await linearClient.createAgentActivity(activityInput);
|
|
3121
|
+
if (result.success) {
|
|
3122
|
+
console.log(`[EdgeWorker] Posted repository selection activity for session ${linearAgentActivitySessionId} (${selectionMethod})`);
|
|
3123
|
+
}
|
|
3124
|
+
else {
|
|
3125
|
+
console.error(`[EdgeWorker] Failed to post repository selection activity:`, result);
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
catch (error) {
|
|
3129
|
+
console.error(`[EdgeWorker] Error posting repository selection activity:`, error);
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
2979
3132
|
/**
|
|
2980
3133
|
* Re-route procedure for a session (used when resuming from child or give feedback)
|
|
2981
3134
|
* This ensures the currentSubroutine is reset to avoid suppression issues
|
|
@@ -3210,7 +3363,9 @@ ${input.userComment}
|
|
|
3210
3363
|
const promptType = systemPromptResult?.type;
|
|
3211
3364
|
// Build allowed tools list
|
|
3212
3365
|
const allowedTools = this.buildAllowedTools(repository, promptType);
|
|
3213
|
-
const
|
|
3366
|
+
const baseDisallowedTools = this.buildDisallowedTools(repository, promptType);
|
|
3367
|
+
// Merge subroutine-level disallowedTools if applicable
|
|
3368
|
+
const disallowedTools = this.mergeSubroutineDisallowedTools(session, baseDisallowedTools, "resumeClaudeSession");
|
|
3214
3369
|
// Set up attachments directory
|
|
3215
3370
|
const workspaceFolderName = basename(session.workspace.path);
|
|
3216
3371
|
const attachmentsDir = join(this.cyrusHome, workspaceFolderName, "attachments");
|