cyrus-edge-worker 0.2.49 → 0.2.50
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/ActivityPoster.d.ts +1 -0
- package/dist/ActivityPoster.d.ts.map +1 -1
- package/dist/ActivityPoster.js +11 -0
- package/dist/ActivityPoster.js.map +1 -1
- package/dist/AgentSessionManager.d.ts +27 -1
- package/dist/AgentSessionManager.d.ts.map +1 -1
- package/dist/AgentSessionManager.js +110 -28
- package/dist/AgentSessionManager.js.map +1 -1
- package/dist/AttachmentService.d.ts +1 -1
- package/dist/AttachmentService.d.ts.map +1 -1
- package/dist/AttachmentService.js +13 -1
- package/dist/AttachmentService.js.map +1 -1
- package/dist/ChatSessionHandler.d.ts +13 -1
- package/dist/ChatSessionHandler.d.ts.map +1 -1
- package/dist/ChatSessionHandler.js +93 -30
- package/dist/ChatSessionHandler.js.map +1 -1
- package/dist/ConfigManager.d.ts.map +1 -1
- package/dist/ConfigManager.js +5 -0
- package/dist/ConfigManager.js.map +1 -1
- package/dist/EdgeWorker.d.ts +69 -0
- package/dist/EdgeWorker.d.ts.map +1 -1
- package/dist/EdgeWorker.js +522 -18
- package/dist/EdgeWorker.js.map +1 -1
- package/dist/EgressProxy.d.ts.map +1 -1
- package/dist/EgressProxy.js +14 -9
- package/dist/EgressProxy.js.map +1 -1
- package/dist/McpConfigService.d.ts +1 -1
- package/dist/McpConfigService.d.ts.map +1 -1
- package/dist/McpConfigService.js +1 -1
- package/dist/McpConfigService.js.map +1 -1
- package/dist/RunnerConfigBuilder.d.ts +13 -4
- package/dist/RunnerConfigBuilder.d.ts.map +1 -1
- package/dist/RunnerConfigBuilder.js +111 -21
- package/dist/RunnerConfigBuilder.js.map +1 -1
- package/dist/RunnerSelectionService.js +2 -2
- package/dist/RunnerSelectionService.js.map +1 -1
- package/dist/SharedApplicationServer.js +1 -1
- package/dist/SharedApplicationServer.js.map +1 -1
- package/dist/hooks/PrMarkerHook.d.ts +58 -0
- package/dist/hooks/PrMarkerHook.d.ts.map +1 -0
- package/dist/hooks/PrMarkerHook.js +149 -0
- package/dist/hooks/PrMarkerHook.js.map +1 -0
- package/package.json +15 -14
package/dist/EdgeWorker.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { execSync } from "node:child_process";
|
|
3
3
|
import { EventEmitter } from "node:events";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
5
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
5
6
|
import { basename, join } from "node:path";
|
|
6
7
|
import { LinearClient } from "@linear/sdk";
|
|
7
|
-
import { ClaudeRunner } from "cyrus-claude-runner";
|
|
8
|
+
import { buildBaseSessionEnv, ClaudeRunner, HttpSessionStore, normalizeMcpHttpTransport, } from "cyrus-claude-runner";
|
|
8
9
|
import { CodexRunner } from "cyrus-codex-runner";
|
|
9
10
|
import { ConfigUpdater } from "cyrus-config-updater";
|
|
10
|
-
import { CLIIssueTrackerService, CLIRPCServer, createLogger, DEFAULT_PROXY_URL, isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isContentUpdateMessage, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueDeletedWebhook, isIssueNewCommentWebhook, isIssueStateChangeMessage, isIssueStateChangeWebhook, isIssueTitleOrDescriptionUpdateWebhook, isIssueUnassignedWebhook, isSessionStartMessage, isStopSignalMessage, isUnassignMessage, isUserPromptMessage, PersistenceManager, requireLinearWorkspaceId, resolvePath, WebhookIpValidator, } from "cyrus-core";
|
|
11
|
+
import { CLIIssueTrackerService, CLIRPCServer, createLogger, DEFAULT_PROXY_URL, isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isContentUpdateMessage, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueDeletedWebhook, isIssueNewCommentWebhook, isIssueStateChangeMessage, isIssueStateChangeWebhook, isIssueStateIdUpdateWebhook, isIssueTitleOrDescriptionUpdateWebhook, isIssueUnassignedWebhook, isSessionStartMessage, isStopSignalMessage, isUnassignMessage, isUserPromptMessage, PersistenceManager, requireLinearWorkspaceId, resolvePath, WebhookIpValidator, } from "cyrus-core";
|
|
11
12
|
import { CursorRunner } from "cyrus-cursor-runner";
|
|
12
13
|
import { GeminiRunner } from "cyrus-gemini-runner";
|
|
13
14
|
import { extractCommentAuthor, extractCommentBody, extractCommentId, extractCommentUrl, extractPRBaseBranchRef, extractPRBranchRef, extractPRNumber, extractPRTitle, extractRepoFullName, extractRepoName, extractRepoOwner, extractSessionKey, GitHubAppTokenProvider, GitHubCommentService, GitHubEventTransport, isCommentOnPullRequest, isIssueCommentPayload, isPullRequestReviewCommentPayload, isPullRequestReviewPayload, stripMention, } from "cyrus-github-event-transport";
|
|
@@ -50,6 +51,8 @@ export class EdgeWorker extends EventEmitter {
|
|
|
50
51
|
agentSessionManager; // Single instance managing all agent sessions across repositories
|
|
51
52
|
activitySinks = new Map(); // Maps Linear workspace ID to activity sink (one per workspace, mirrors issueTrackers)
|
|
52
53
|
sessionRepositories = new Map(); // Maps session ID to repository ID
|
|
54
|
+
lastStopTimeBySession = new Map(); // Maps session ID to timestamp of last stop signal (for double-stop detection)
|
|
55
|
+
warmInstances = new Map(); // Pre-warmed Claude sessions keyed by agentSessionId
|
|
53
56
|
issueTrackers = new Map(); // one issue tracker per Linear workspace (keyed by linearWorkspaceId)
|
|
54
57
|
linearEventTransport = null; // Single event transport for webhook delivery
|
|
55
58
|
gitHubEventTransport = null; // GitHub event transport for forwarded GitHub webhooks
|
|
@@ -98,18 +101,61 @@ export class EdgeWorker extends EventEmitter {
|
|
|
98
101
|
sdkSandboxSettings = null;
|
|
99
102
|
/** CA cert path for MITM TLS termination (passed per-session env, not process.env) */
|
|
100
103
|
egressCaCertPath = null;
|
|
104
|
+
/**
|
|
105
|
+
* Remote SessionStore that mirrors Claude SDK transcripts to the Cyrus
|
|
106
|
+
* hosted control plane. Enabled when all three of `CYRUS_APP_URL`,
|
|
107
|
+
* `CYRUS_API_KEY`, and `CYRUS_TEAM_ID` are set — used by any Claude
|
|
108
|
+
* runner spawned from this worker so transcripts survive ephemeral
|
|
109
|
+
* worktrees and are resumable from any host.
|
|
110
|
+
*/
|
|
111
|
+
claudeSessionStore = null;
|
|
101
112
|
/**
|
|
102
113
|
* Tracks recently processed issue-update webhook keys to prevent
|
|
103
114
|
* duplicate deliveries from Linear's at-least-once delivery.
|
|
104
115
|
* Key format: `${createdAt}:${issueId}`
|
|
105
116
|
*/
|
|
106
117
|
processedIssueUpdateKeys = new Set();
|
|
118
|
+
/**
|
|
119
|
+
* Sessions parked due to blocked-by dependencies.
|
|
120
|
+
* Key: Linear issue ID (the blocked issue)
|
|
121
|
+
* Value: All data needed to replay initializeAgentRunner when unblocked
|
|
122
|
+
*/
|
|
123
|
+
parkedSessions = new Map();
|
|
107
124
|
constructor(config) {
|
|
108
125
|
super();
|
|
109
126
|
this.config = config;
|
|
110
127
|
this.cyrusHome = config.cyrusHome;
|
|
111
128
|
this.logger = createLogger({ component: "EdgeWorker" });
|
|
112
129
|
this.persistenceManager = new PersistenceManager(join(this.cyrusHome, "state"));
|
|
130
|
+
// Mirror Claude SDK session transcripts to the hosted control plane
|
|
131
|
+
// when CYRUS_APP_URL (destination), CYRUS_API_KEY (proof of team
|
|
132
|
+
// ownership), and CYRUS_TEAM_ID (which team the transcripts belong to)
|
|
133
|
+
// are all configured. If any is missing the store stays null and the
|
|
134
|
+
// SDK falls back to local JSONL only. Operators can also opt out
|
|
135
|
+
// explicitly by setting CYRUS_DISABLE_REMOTE_SESSION_STORE=1, which
|
|
136
|
+
// keeps transcripts local even when the three vars above are present.
|
|
137
|
+
const sessionStoreBaseUrl = process.env.CYRUS_APP_URL;
|
|
138
|
+
const sessionStoreApiKey = process.env.CYRUS_API_KEY;
|
|
139
|
+
const sessionStoreTeamId = process.env.CYRUS_TEAM_ID;
|
|
140
|
+
const sessionStoreDisabled = this.isRemoteSessionStoreDisabled();
|
|
141
|
+
if (!sessionStoreDisabled &&
|
|
142
|
+
sessionStoreBaseUrl &&
|
|
143
|
+
sessionStoreApiKey &&
|
|
144
|
+
sessionStoreTeamId) {
|
|
145
|
+
this.claudeSessionStore = new HttpSessionStore({
|
|
146
|
+
baseUrl: sessionStoreBaseUrl,
|
|
147
|
+
apiKey: sessionStoreApiKey,
|
|
148
|
+
teamId: sessionStoreTeamId,
|
|
149
|
+
logger: this.logger,
|
|
150
|
+
});
|
|
151
|
+
this.logger.info(`[SessionStore] Mirroring Claude sessions to ${sessionStoreBaseUrl} for team ${sessionStoreTeamId}`);
|
|
152
|
+
}
|
|
153
|
+
else if (sessionStoreDisabled &&
|
|
154
|
+
sessionStoreBaseUrl &&
|
|
155
|
+
sessionStoreApiKey &&
|
|
156
|
+
sessionStoreTeamId) {
|
|
157
|
+
this.logger.info("[SessionStore] Remote session store disabled via CYRUS_DISABLE_REMOTE_SESSION_STORE; transcripts will stay local.");
|
|
158
|
+
}
|
|
113
159
|
// Initialize GitHub comment service for posting replies to GitHub PRs
|
|
114
160
|
this.gitHubCommentService = new GitHubCommentService();
|
|
115
161
|
// Initialize GitLab comment service for posting replies to GitLab MRs
|
|
@@ -270,6 +316,14 @@ export class EdgeWorker extends EventEmitter {
|
|
|
270
316
|
await this.skillsPluginResolver.ensureUserPluginScaffolded();
|
|
271
317
|
// Load persisted state for each repository
|
|
272
318
|
await this.loadPersistedState();
|
|
319
|
+
// Pre-warm the 30 most recent Claude sessions in the background
|
|
320
|
+
// so their first query after restart has near-zero cold-start latency.
|
|
321
|
+
// Disabled by default; opt in with CYRUS_ENABLE_WARM_SESSIONS=1.
|
|
322
|
+
if (this.isWarmSessionsEnabled()) {
|
|
323
|
+
this.warmupRecentSessions(30).catch((err) => {
|
|
324
|
+
this.logger.warn("Session warmup failed (non-fatal):", err);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
273
327
|
// Start config file watcher via ConfigManager
|
|
274
328
|
this.configManager.on("configChanged", async (changes) => {
|
|
275
329
|
this.updateLinearWorkspaceTokens(changes.newConfig);
|
|
@@ -327,7 +381,19 @@ export class EdgeWorker extends EventEmitter {
|
|
|
327
381
|
async initializeComponents() {
|
|
328
382
|
// 1. Platform-specific initialization
|
|
329
383
|
if (this.config.platform === "cli") {
|
|
330
|
-
// CLI mode:
|
|
384
|
+
// CLI mode: ensure a CLIIssueTrackerService exists for each repo workspace.
|
|
385
|
+
// Repos from config.repositories don't go through linearWorkspaces init,
|
|
386
|
+
// so we create trackers here if missing.
|
|
387
|
+
for (const [repoId, repo] of this.repositories) {
|
|
388
|
+
const wsId = repo.linearWorkspaceId;
|
|
389
|
+
if (wsId && !this.issueTrackers.has(wsId)) {
|
|
390
|
+
const service = new CLIIssueTrackerService();
|
|
391
|
+
service.seedDefaultData();
|
|
392
|
+
this.issueTrackers.set(wsId, service);
|
|
393
|
+
const activitySink = new LinearActivitySink(service, wsId);
|
|
394
|
+
this.activitySinks.set(repoId, activitySink);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
331
397
|
const firstCliTracker = Array.from(this.issueTrackers.values()).find((tracker) => tracker instanceof CLIIssueTrackerService);
|
|
332
398
|
if (firstCliTracker) {
|
|
333
399
|
this.cliRPCServer = new CLIRPCServer({
|
|
@@ -388,7 +454,7 @@ export class EdgeWorker extends EventEmitter {
|
|
|
388
454
|
this.linearEventTransport.on("error", (error) => {
|
|
389
455
|
this.handleError(error);
|
|
390
456
|
});
|
|
391
|
-
// Register the /webhook endpoint
|
|
457
|
+
// Register the /linear-webhook endpoint (with /webhook retained as a deprecated alias)
|
|
392
458
|
this.linearEventTransport.register();
|
|
393
459
|
this.logger.info(`✅ Linear event transport registered (${verificationMode} mode)`);
|
|
394
460
|
this.logger.info(` Webhook endpoint: ${this.sharedApplicationServer.getWebhookUrl()}`);
|
|
@@ -468,6 +534,13 @@ export class EdgeWorker extends EventEmitter {
|
|
|
468
534
|
});
|
|
469
535
|
// Listen for legacy GitHub webhook events (deprecated, kept for backward compatibility)
|
|
470
536
|
this.gitHubEventTransport.on("event", (event) => {
|
|
537
|
+
// Route push events to the base branch notification handler
|
|
538
|
+
if (event.eventType === "push") {
|
|
539
|
+
this.handleGitHubPushWebhook(event.payload).catch((error) => {
|
|
540
|
+
this.logger.error("Failed to handle GitHub push webhook", error instanceof Error ? error : new Error(String(error)));
|
|
541
|
+
});
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
471
544
|
this.handleGitHubWebhook(event).catch((error) => {
|
|
472
545
|
this.logger.error("Failed to handle GitHub webhook", error instanceof Error ? error : new Error(String(error)));
|
|
473
546
|
});
|
|
@@ -834,6 +907,68 @@ export class EdgeWorker extends EventEmitter {
|
|
|
834
907
|
this.activeWebhookCount--;
|
|
835
908
|
}
|
|
836
909
|
}
|
|
910
|
+
/**
|
|
911
|
+
* Handle GitHub push webhook events.
|
|
912
|
+
* When a base branch receives new commits, find active sessions tracking that
|
|
913
|
+
* branch and stream a rebase notification to the running agent.
|
|
914
|
+
*/
|
|
915
|
+
async handleGitHubPushWebhook(payload) {
|
|
916
|
+
// Only handle branch pushes (refs/heads/*), not tags
|
|
917
|
+
if (!payload.ref.startsWith("refs/heads/")) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
// Ignore branch deletions
|
|
921
|
+
if (payload.deleted) {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const branchName = payload.ref.replace("refs/heads/", "");
|
|
925
|
+
const repoFullName = payload.repository.full_name;
|
|
926
|
+
// Find the matching repository config
|
|
927
|
+
const repository = this.findRepositoryByGitHubUrl(repoFullName);
|
|
928
|
+
if (!repository) {
|
|
929
|
+
this.logger.debug(`No repository configured for GitHub push from ${repoFullName}`);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
// Find active sessions tracking this branch as their base branch
|
|
933
|
+
const sessions = this.agentSessionManager.getSessionsByBaseBranch(branchName, repository.id);
|
|
934
|
+
if (sessions.length === 0) {
|
|
935
|
+
this.logger.debug(`No active sessions tracking base branch ${branchName} for ${repository.name}`);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
// Build a notification prompt with commit summary
|
|
939
|
+
const commitCount = payload.commits.length;
|
|
940
|
+
const commitSummary = payload.commits
|
|
941
|
+
.slice(0, 5)
|
|
942
|
+
.map((c) => `- ${c.message.split("\n")[0]}`)
|
|
943
|
+
.join("\n");
|
|
944
|
+
const moreCommits = commitCount > 5 ? `\n- ... and ${commitCount - 5} more` : "";
|
|
945
|
+
const notification = `<base_branch_update>
|
|
946
|
+
<branch>${branchName}</branch>
|
|
947
|
+
<repository>${repoFullName}</repository>
|
|
948
|
+
<commit_count>${commitCount}</commit_count>
|
|
949
|
+
<compare_url>${payload.compare}</compare_url>
|
|
950
|
+
<commits>
|
|
951
|
+
${commitSummary}${moreCommits}
|
|
952
|
+
</commits>
|
|
953
|
+
<guidance>
|
|
954
|
+
Your base branch \`${branchName}\` has received ${commitCount} new commit(s). Consider rebasing your working branch onto the updated base to avoid merge conflicts. You can do this with: \`git fetch origin && git rebase origin/${branchName}\`
|
|
955
|
+
</guidance>
|
|
956
|
+
</base_branch_update>`;
|
|
957
|
+
this.logger.info(`Base branch ${branchName} updated (${commitCount} commits) — notifying ${sessions.length} active session(s)`);
|
|
958
|
+
// Stream notification to the first running session that supports streaming
|
|
959
|
+
const sortedSessions = [...sessions].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
960
|
+
for (const session of sortedSessions) {
|
|
961
|
+
const existingRunner = session.agentRunner;
|
|
962
|
+
const isRunning = existingRunner?.isRunning() || false;
|
|
963
|
+
if (isRunning &&
|
|
964
|
+
existingRunner?.supportsStreamingInput &&
|
|
965
|
+
existingRunner.addStreamMessage) {
|
|
966
|
+
existingRunner.addStreamMessage(notification);
|
|
967
|
+
this.logger.debug(`[base-branch-update] Streamed notification to session ${session.id} for branch ${branchName}`);
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
837
972
|
/**
|
|
838
973
|
* Find a repository configuration that matches a GitHub repository URL.
|
|
839
974
|
* Matches against the githubUrl field in repository config.
|
|
@@ -1869,6 +2004,14 @@ ${taskSection}`;
|
|
|
1869
2004
|
async handleWebhook(webhook, repos) {
|
|
1870
2005
|
// Track active webhook processing for status endpoint
|
|
1871
2006
|
this.activeWebhookCount++;
|
|
2007
|
+
const webhookAction = webhook.action;
|
|
2008
|
+
const webhookType = webhook.type;
|
|
2009
|
+
this.logger.event("webhook_received", {
|
|
2010
|
+
source: "linear",
|
|
2011
|
+
action: webhookAction,
|
|
2012
|
+
type: webhookType,
|
|
2013
|
+
repoCount: repos.length,
|
|
2014
|
+
});
|
|
1872
2015
|
// Log verbose webhook info if enabled
|
|
1873
2016
|
if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
|
|
1874
2017
|
this.logger.debug(`Full webhook payload:`, JSON.stringify(webhook, null, 2));
|
|
@@ -1909,6 +2052,10 @@ ${taskSection}`;
|
|
|
1909
2052
|
// Handle issue title/description/attachments updates - feed changes into active session
|
|
1910
2053
|
await this.handleIssueContentUpdate(webhook);
|
|
1911
2054
|
}
|
|
2055
|
+
else if (isIssueStateIdUpdateWebhook(webhook)) {
|
|
2056
|
+
// Handle issue state changes — wake up parked sessions when blocking issues complete
|
|
2057
|
+
await this.handleIssueStateChange(webhook);
|
|
2058
|
+
}
|
|
1912
2059
|
else {
|
|
1913
2060
|
if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
|
|
1914
2061
|
this.logger.debug(`Unhandled webhook type: ${webhook.action}`);
|
|
@@ -2268,6 +2415,173 @@ ${taskSection}`;
|
|
|
2268
2415
|
* and includes guidance for the agent to evaluate whether these changes affect
|
|
2269
2416
|
* its current implementation or action plan.
|
|
2270
2417
|
*/
|
|
2418
|
+
/**
|
|
2419
|
+
* Check if an issue has unresolved blocked-by dependencies.
|
|
2420
|
+
* Fetches the issue from Linear and checks its inverse relations for blocking issues
|
|
2421
|
+
* that haven't been completed or canceled.
|
|
2422
|
+
*/
|
|
2423
|
+
async checkBlockedByDependencies(agentSession, linearWorkspaceId) {
|
|
2424
|
+
const issue = agentSession.issue;
|
|
2425
|
+
if (!issue) {
|
|
2426
|
+
return { blocked: false, blockingIssueIds: [], blockingIdentifiers: [] };
|
|
2427
|
+
}
|
|
2428
|
+
try {
|
|
2429
|
+
const fullIssue = await this.fetchFullIssueDetails(issue.id, linearWorkspaceId);
|
|
2430
|
+
if (!fullIssue) {
|
|
2431
|
+
return {
|
|
2432
|
+
blocked: false,
|
|
2433
|
+
blockingIssueIds: [],
|
|
2434
|
+
blockingIdentifiers: [],
|
|
2435
|
+
};
|
|
2436
|
+
}
|
|
2437
|
+
const blockingIssues = await this.promptBuilder.fetchBlockingIssues(fullIssue);
|
|
2438
|
+
if (blockingIssues.length === 0) {
|
|
2439
|
+
return {
|
|
2440
|
+
blocked: false,
|
|
2441
|
+
blockingIssueIds: [],
|
|
2442
|
+
blockingIdentifiers: [],
|
|
2443
|
+
};
|
|
2444
|
+
}
|
|
2445
|
+
// Filter to only unresolved blockers (not completed or canceled)
|
|
2446
|
+
const unresolvedBlockers = [];
|
|
2447
|
+
for (const blocker of blockingIssues) {
|
|
2448
|
+
try {
|
|
2449
|
+
const state = await blocker.state;
|
|
2450
|
+
if (state &&
|
|
2451
|
+
state.type !== "completed" &&
|
|
2452
|
+
state.type !== "canceled") {
|
|
2453
|
+
unresolvedBlockers.push({
|
|
2454
|
+
id: blocker.id,
|
|
2455
|
+
identifier: blocker.identifier,
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
catch {
|
|
2460
|
+
// If we can't resolve the state, assume it's unresolved
|
|
2461
|
+
unresolvedBlockers.push({
|
|
2462
|
+
id: blocker.id,
|
|
2463
|
+
identifier: blocker.identifier,
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
if (unresolvedBlockers.length === 0) {
|
|
2468
|
+
return {
|
|
2469
|
+
blocked: false,
|
|
2470
|
+
blockingIssueIds: [],
|
|
2471
|
+
blockingIdentifiers: [],
|
|
2472
|
+
};
|
|
2473
|
+
}
|
|
2474
|
+
return {
|
|
2475
|
+
blocked: true,
|
|
2476
|
+
blockingIssueIds: unresolvedBlockers.map((b) => b.id),
|
|
2477
|
+
blockingIdentifiers: unresolvedBlockers.map((b) => b.identifier),
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
catch (error) {
|
|
2481
|
+
this.logger.error(`Failed to check blocked-by dependencies for ${issue.identifier}:`, error);
|
|
2482
|
+
// On error, don't block — proceed with normal flow
|
|
2483
|
+
return { blocked: false, blockingIssueIds: [], blockingIdentifiers: [] };
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
/**
|
|
2487
|
+
* Handle issue state change webhooks.
|
|
2488
|
+
* When a blocking issue is completed, wake up any parked sessions that were waiting on it.
|
|
2489
|
+
*/
|
|
2490
|
+
async handleIssueStateChange(webhook) {
|
|
2491
|
+
const issueData = webhook.data;
|
|
2492
|
+
const completedIssueId = issueData.id;
|
|
2493
|
+
const issueIdentifier = issueData.identifier;
|
|
2494
|
+
// Only care about transitions TO completed or canceled states
|
|
2495
|
+
// The IssueWebhookPayload has a stateId field — resolve the state
|
|
2496
|
+
// via the issue tracker to check if it's a completion state
|
|
2497
|
+
const stateId = issueData.stateId;
|
|
2498
|
+
if (!stateId) {
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
// Find workspace for this webhook to resolve state type
|
|
2502
|
+
const linearWorkspaceId = webhook.organizationId;
|
|
2503
|
+
const issueTracker = this.issueTrackers.get(linearWorkspaceId);
|
|
2504
|
+
if (!issueTracker) {
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
2507
|
+
// Fetch the issue to check its current state type
|
|
2508
|
+
let stateType;
|
|
2509
|
+
try {
|
|
2510
|
+
const fullIssue = await issueTracker.fetchIssue(completedIssueId);
|
|
2511
|
+
const state = await fullIssue.state;
|
|
2512
|
+
stateType = state?.type;
|
|
2513
|
+
}
|
|
2514
|
+
catch {
|
|
2515
|
+
// Can't resolve state — skip
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
if (stateType !== "completed" && stateType !== "canceled") {
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
this.logger.debug(`Issue ${issueIdentifier} moved to ${stateType} — checking for parked sessions to wake`);
|
|
2522
|
+
// Find parked sessions that were blocked by this issue
|
|
2523
|
+
const sessionsToWake = [];
|
|
2524
|
+
for (const [blockedIssueId, parked] of this.parkedSessions.entries()) {
|
|
2525
|
+
if (parked.blockingIssueIds.includes(completedIssueId)) {
|
|
2526
|
+
// Remove this blocker from the list
|
|
2527
|
+
parked.blockingIssueIds = parked.blockingIssueIds.filter((id) => id !== completedIssueId);
|
|
2528
|
+
// If no more blockers, wake the session
|
|
2529
|
+
if (parked.blockingIssueIds.length === 0) {
|
|
2530
|
+
sessionsToWake.push(blockedIssueId);
|
|
2531
|
+
}
|
|
2532
|
+
else {
|
|
2533
|
+
this.logger.debug(`Parked session for issue ${blockedIssueId} still has ${parked.blockingIssueIds.length} remaining blocker(s)`);
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
// Wake up unblocked sessions
|
|
2538
|
+
for (const blockedIssueId of sessionsToWake) {
|
|
2539
|
+
const parked = this.parkedSessions.get(blockedIssueId);
|
|
2540
|
+
if (!parked)
|
|
2541
|
+
continue;
|
|
2542
|
+
this.parkedSessions.delete(blockedIssueId);
|
|
2543
|
+
this.logger.info(`Waking parked session for issue ${parked.agentSession.issue?.identifier} — all blockers resolved`);
|
|
2544
|
+
// Post activity about waking up
|
|
2545
|
+
await this.activityPoster.postThoughtActivity(parked.agentSession.id, parked.linearWorkspaceId, `All blocking dependencies are now resolved — starting work.`);
|
|
2546
|
+
// Replay the normal initializeAgentRunner flow
|
|
2547
|
+
try {
|
|
2548
|
+
await this.initializeAgentRunner(parked.agentSession, parked.repositories, parked.linearWorkspaceId, parked.guidance, parked.commentBody, parked.baseBranchOverrides, parked.routingMethod);
|
|
2549
|
+
}
|
|
2550
|
+
catch (error) {
|
|
2551
|
+
this.logger.error(`Failed to wake parked session for issue ${blockedIssueId}:`, error);
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
/**
|
|
2556
|
+
* Handle a user re-prompt on a parked (blocked-by) session.
|
|
2557
|
+
* Re-checks blocking status: if clear, wakes the session; if still blocked, re-posts status.
|
|
2558
|
+
*/
|
|
2559
|
+
async handleParkedSessionReprompt(_webhook, issueId) {
|
|
2560
|
+
const parked = this.parkedSessions.get(issueId);
|
|
2561
|
+
if (!parked)
|
|
2562
|
+
return;
|
|
2563
|
+
const blockResult = await this.checkBlockedByDependencies(parked.agentSession, parked.linearWorkspaceId);
|
|
2564
|
+
if (blockResult.blocked) {
|
|
2565
|
+
// Still blocked — update the parked entry and re-post status
|
|
2566
|
+
parked.blockingIssueIds = blockResult.blockingIssueIds;
|
|
2567
|
+
const blockerList = blockResult.blockingIdentifiers
|
|
2568
|
+
.map((id) => `**${id}**`)
|
|
2569
|
+
.join(", ");
|
|
2570
|
+
await this.activityPoster.postThoughtActivity(parked.agentSession.id, parked.linearWorkspaceId, `Still blocked by ${blockerList}. Will start automatically when resolved.`);
|
|
2571
|
+
this.logger.info(`Re-prompt on parked session for ${parked.agentSession.issue?.identifier}: still blocked by ${blockResult.blockingIdentifiers.join(", ")}`);
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
// Blockers resolved — wake the session
|
|
2575
|
+
this.parkedSessions.delete(issueId);
|
|
2576
|
+
this.logger.info(`Re-prompt cleared blockers for ${parked.agentSession.issue?.identifier} — waking session`);
|
|
2577
|
+
await this.activityPoster.postThoughtActivity(parked.agentSession.id, parked.linearWorkspaceId, `Blocking dependencies are now resolved — starting work.`);
|
|
2578
|
+
try {
|
|
2579
|
+
await this.initializeAgentRunner(parked.agentSession, parked.repositories, parked.linearWorkspaceId, parked.guidance, parked.commentBody, parked.baseBranchOverrides, parked.routingMethod);
|
|
2580
|
+
}
|
|
2581
|
+
catch (error) {
|
|
2582
|
+
this.logger.error(`Failed to wake parked session for issue ${issueId} on re-prompt:`, error);
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2271
2585
|
buildIssueUpdatePrompt(issueIdentifier, issueData, updatedFrom) {
|
|
2272
2586
|
return this.promptBuilder.buildIssueUpdatePrompt(issueIdentifier, issueData, updatedFrom);
|
|
2273
2587
|
}
|
|
@@ -2292,8 +2606,7 @@ ${taskSection}`;
|
|
|
2292
2606
|
getLinearTokenForWorkspace(linearWorkspaceId) {
|
|
2293
2607
|
const workspaceConfig = this.config.linearWorkspaces?.[linearWorkspaceId];
|
|
2294
2608
|
if (!workspaceConfig) {
|
|
2295
|
-
|
|
2296
|
-
`Ensure linearWorkspaces.${linearWorkspaceId} is configured.`);
|
|
2609
|
+
return null; // CLI platform or unconfigured workspace
|
|
2297
2610
|
}
|
|
2298
2611
|
return workspaceConfig.linearToken;
|
|
2299
2612
|
}
|
|
@@ -2484,6 +2797,29 @@ ${taskSection}`;
|
|
|
2484
2797
|
log.info(`Handling agent session created`);
|
|
2485
2798
|
const { agentSession, guidance } = webhook;
|
|
2486
2799
|
const commentBody = agentSession.comment?.body;
|
|
2800
|
+
// Check for blocked-by dependencies before starting work
|
|
2801
|
+
const blockResult = await this.checkBlockedByDependencies(agentSession, linearWorkspaceId);
|
|
2802
|
+
if (blockResult.blocked) {
|
|
2803
|
+
// Park the session — don't create worktree or runner
|
|
2804
|
+
const parkedIssueId = agentSession.issue.id;
|
|
2805
|
+
this.parkedSessions.set(parkedIssueId, {
|
|
2806
|
+
agentSession,
|
|
2807
|
+
repositories,
|
|
2808
|
+
linearWorkspaceId,
|
|
2809
|
+
guidance,
|
|
2810
|
+
commentBody,
|
|
2811
|
+
baseBranchOverrides,
|
|
2812
|
+
routingMethod,
|
|
2813
|
+
blockingIssueIds: blockResult.blockingIssueIds,
|
|
2814
|
+
});
|
|
2815
|
+
// Post acknowledgment to the Linear agent session
|
|
2816
|
+
const blockerList = blockResult.blockingIdentifiers
|
|
2817
|
+
.map((id) => `**${id}**`)
|
|
2818
|
+
.join(", ");
|
|
2819
|
+
await this.activityPoster.postThoughtActivity(agentSession.id, linearWorkspaceId, `Blocked by ${blockerList} — will start automatically when ${blockResult.blockingIdentifiers.length === 1 ? "it is" : "they are"} resolved.`);
|
|
2820
|
+
log.info(`Session parked: issue ${agentSession.issue.identifier} is blocked by ${blockResult.blockingIdentifiers.join(", ")}`);
|
|
2821
|
+
return;
|
|
2822
|
+
}
|
|
2487
2823
|
// Initialize agent runner using shared logic (pass full repositories array)
|
|
2488
2824
|
await this.initializeAgentRunner(agentSession, repositories, linearWorkspaceId, guidance, commentBody, baseBranchOverrides, routingMethod);
|
|
2489
2825
|
}
|
|
@@ -2656,17 +2992,39 @@ ${taskSection}`;
|
|
|
2656
2992
|
await this.agentSessionManager.createResponseActivity(agentSessionId, `Stop signal received for ${issueTitle}. No active session was found (the session may have ended or the system was restarted). No further action is needed.`);
|
|
2657
2993
|
return;
|
|
2658
2994
|
}
|
|
2659
|
-
//
|
|
2995
|
+
// Double-stop detection: two stop signals within 10s → full abort
|
|
2996
|
+
const now = Date.now();
|
|
2997
|
+
const lastStop = this.lastStopTimeBySession.get(agentSessionId);
|
|
2998
|
+
const isDoubleStop = lastStop !== undefined && now - lastStop < 10_000;
|
|
2999
|
+
this.lastStopTimeBySession.set(agentSessionId, now);
|
|
2660
3000
|
const existingRunner = foundSession.agentRunner;
|
|
2661
|
-
this.agentSessionManager.requestSessionStop(agentSessionId);
|
|
2662
|
-
if (existingRunner) {
|
|
2663
|
-
existingRunner.stop();
|
|
2664
|
-
log.info(`Stopped agent session for agent activity session ${agentSessionId}`);
|
|
2665
|
-
}
|
|
2666
|
-
// Post confirmation
|
|
2667
3001
|
const issueTitle = issue?.title || "this issue";
|
|
2668
|
-
const
|
|
2669
|
-
|
|
3002
|
+
const senderName = webhook.agentSession.creator?.name || "user";
|
|
3003
|
+
// Only warm sessions can be safely interrupted without killing the
|
|
3004
|
+
// underlying request. Non-warm sessions get a single-shot full stop —
|
|
3005
|
+
// calling interrupt() on them surfaces a "Request was aborted" error
|
|
3006
|
+
// from the SDK (see CYPACK-1145).
|
|
3007
|
+
const supportsInterrupt = Boolean(existingRunner?.interrupt && existingRunner?.isWarm?.());
|
|
3008
|
+
if (isDoubleStop || !supportsInterrupt) {
|
|
3009
|
+
// Either a second stop within window, or a non-warm runner — full kill
|
|
3010
|
+
this.agentSessionManager.requestSessionStop(agentSessionId);
|
|
3011
|
+
if (existingRunner) {
|
|
3012
|
+
existingRunner.stop();
|
|
3013
|
+
log.info(isDoubleStop
|
|
3014
|
+
? `Double-stop: fully aborted session ${agentSessionId}`
|
|
3015
|
+
: `Stopped session ${agentSessionId} (interrupt not supported)`);
|
|
3016
|
+
}
|
|
3017
|
+
this.lastStopTimeBySession.delete(agentSessionId);
|
|
3018
|
+
await this.agentSessionManager.createResponseActivity(agentSessionId, isDoubleStop
|
|
3019
|
+
? `I've fully stopped working on ${issueTitle}.\n\n**Stop Signal:** Received from ${senderName} (second stop)\n**Action Taken:** Session terminated`
|
|
3020
|
+
: `I've stopped working on ${issueTitle}.\n\n**Stop Signal:** Received from ${senderName}\n**Action Taken:** Session terminated`);
|
|
3021
|
+
}
|
|
3022
|
+
else {
|
|
3023
|
+
// First stop on a warm session — interrupt current turn, keep session warm
|
|
3024
|
+
await existingRunner.interrupt();
|
|
3025
|
+
log.info(`Interrupted current turn for session ${agentSessionId} (send stop again within 10s to fully terminate)`);
|
|
3026
|
+
await this.agentSessionManager.createResponseActivity(agentSessionId, `Interrupted by ${senderName}\n**Tip:** Type and send "stop" within 10 seconds to fully terminate the session.`);
|
|
3027
|
+
}
|
|
2670
3028
|
}
|
|
2671
3029
|
/**
|
|
2672
3030
|
* Handle repository selection response from prompted webhook
|
|
@@ -2883,6 +3241,15 @@ ${taskSection}`;
|
|
|
2883
3241
|
await this.handleStopSignal(webhook);
|
|
2884
3242
|
return;
|
|
2885
3243
|
}
|
|
3244
|
+
// Branch 1.5: Handle re-prompt for parked (blocked-by) sessions
|
|
3245
|
+
// When a user re-prompts and the session is parked, re-check blocking status.
|
|
3246
|
+
// If blockers are resolved, wake the session immediately.
|
|
3247
|
+
const issueIdForParkedCheck = webhook.agentSession?.issue?.id;
|
|
3248
|
+
if (issueIdForParkedCheck &&
|
|
3249
|
+
this.parkedSessions.has(issueIdForParkedCheck)) {
|
|
3250
|
+
await this.handleParkedSessionReprompt(webhook, issueIdForParkedCheck);
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
2886
3253
|
// Branch 2: Handle repository selection response
|
|
2887
3254
|
// This is the first Claude runner initialization after user selects a repository.
|
|
2888
3255
|
// The selection handler extracts the choice from the response (or uses fallback)
|
|
@@ -3027,8 +3394,14 @@ ${taskSection}`;
|
|
|
3027
3394
|
*/
|
|
3028
3395
|
createRunnerForType(runnerType, config) {
|
|
3029
3396
|
switch (runnerType) {
|
|
3030
|
-
case "claude":
|
|
3031
|
-
|
|
3397
|
+
case "claude": {
|
|
3398
|
+
// Inject the hosted SessionStore at the last moment so it only
|
|
3399
|
+
// attaches to Claude runners (the field is Claude-specific).
|
|
3400
|
+
const claudeConfig = this.claudeSessionStore
|
|
3401
|
+
? { ...config, sessionStore: this.claudeSessionStore }
|
|
3402
|
+
: config;
|
|
3403
|
+
return new ClaudeRunner(claudeConfig, this.isWarmSessionsEnabled());
|
|
3404
|
+
}
|
|
3032
3405
|
case "gemini":
|
|
3033
3406
|
return new GeminiRunner(config);
|
|
3034
3407
|
case "codex":
|
|
@@ -3637,7 +4010,7 @@ ${input.userComment}
|
|
|
3637
4010
|
platform: session.issueContext?.trackerId,
|
|
3638
4011
|
issueIdentifier: session.issueContext?.issueIdentifier,
|
|
3639
4012
|
});
|
|
3640
|
-
|
|
4013
|
+
const result = this.runnerConfigBuilder.buildIssueConfig({
|
|
3641
4014
|
session,
|
|
3642
4015
|
repository,
|
|
3643
4016
|
sessionId,
|
|
@@ -3663,6 +4036,17 @@ ${input.userComment}
|
|
|
3663
4036
|
createAskUserQuestionCallback: (sid, wid) => this.createAskUserQuestionCallback(sid, wid),
|
|
3664
4037
|
requireLinearWorkspaceId,
|
|
3665
4038
|
});
|
|
4039
|
+
// Attach pre-warmed session if available (only for Claude runner).
|
|
4040
|
+
// Skipped entirely when warm sessions are not enabled.
|
|
4041
|
+
if (result.runnerType === "claude" && this.isWarmSessionsEnabled()) {
|
|
4042
|
+
const warmSession = this.warmInstances.get(sessionId);
|
|
4043
|
+
if (warmSession) {
|
|
4044
|
+
this.warmInstances.delete(sessionId);
|
|
4045
|
+
result.config.warmSession = warmSession;
|
|
4046
|
+
log.debug("Attaching pre-warmed session to runner config");
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
return result;
|
|
3666
4050
|
}
|
|
3667
4051
|
/**
|
|
3668
4052
|
* Create an onAskUserQuestion callback for the ClaudeRunner.
|
|
@@ -3770,6 +4154,126 @@ ${input.userComment}
|
|
|
3770
4154
|
this.logger.error(`Failed to load persisted EdgeWorker state:`, error);
|
|
3771
4155
|
}
|
|
3772
4156
|
}
|
|
4157
|
+
/**
|
|
4158
|
+
* Whether the warm-session feature is enabled.
|
|
4159
|
+
*
|
|
4160
|
+
* Warm sessions are an opt-in optimization that pre-spawns Claude Code
|
|
4161
|
+
* subprocesses on startup so the first query after a restart skips the
|
|
4162
|
+
* cold-start cost. Disabled by default; opt in by setting
|
|
4163
|
+
* `CYRUS_ENABLE_WARM_SESSIONS=1` (or `=true`).
|
|
4164
|
+
*/
|
|
4165
|
+
isWarmSessionsEnabled() {
|
|
4166
|
+
const raw = process.env.CYRUS_ENABLE_WARM_SESSIONS;
|
|
4167
|
+
if (!raw)
|
|
4168
|
+
return false;
|
|
4169
|
+
const v = raw.toLowerCase().trim();
|
|
4170
|
+
return v === "1" || v === "true";
|
|
4171
|
+
}
|
|
4172
|
+
/**
|
|
4173
|
+
* Whether the remote Claude session store is explicitly disabled.
|
|
4174
|
+
*
|
|
4175
|
+
* The remote store mirrors SDK transcripts to the Cyrus hosted control
|
|
4176
|
+
* plane and is on by default whenever `CYRUS_APP_URL`, `CYRUS_API_KEY`,
|
|
4177
|
+
* and `CYRUS_TEAM_ID` are all set. Operators can opt out — without
|
|
4178
|
+
* unsetting those vars (which other features depend on) — by setting
|
|
4179
|
+
* `CYRUS_DISABLE_REMOTE_SESSION_STORE=1` (or `=true`).
|
|
4180
|
+
*/
|
|
4181
|
+
isRemoteSessionStoreDisabled() {
|
|
4182
|
+
const raw = process.env.CYRUS_DISABLE_REMOTE_SESSION_STORE;
|
|
4183
|
+
if (!raw)
|
|
4184
|
+
return false;
|
|
4185
|
+
const v = raw.toLowerCase().trim();
|
|
4186
|
+
return v === "1" || v === "true";
|
|
4187
|
+
}
|
|
4188
|
+
/**
|
|
4189
|
+
* Pre-warm the N most recently updated Claude sessions so the first query
|
|
4190
|
+
* after a CLI restart has near-zero cold-start latency (~20x faster).
|
|
4191
|
+
*
|
|
4192
|
+
* Uses startup() from @anthropic-ai/claude-agent-sdk with MCP_CONNECTION_NONBLOCKING=true
|
|
4193
|
+
* so the warm instances are ready in ~500ms rather than ~4s.
|
|
4194
|
+
* Warm instances are stored in this.warmInstances keyed by agentSessionId and
|
|
4195
|
+
* consumed by buildAgentRunnerConfig() when the first message arrives.
|
|
4196
|
+
*
|
|
4197
|
+
* Gated by `isWarmSessionsEnabled()` — callers should check before invoking.
|
|
4198
|
+
*/
|
|
4199
|
+
async warmupRecentSessions(count = 30) {
|
|
4200
|
+
const allSessions = this.agentSessionManager.getAllSessions();
|
|
4201
|
+
// Only warm Claude sessions that have a persisted session ID and a workspace path
|
|
4202
|
+
const candidates = allSessions
|
|
4203
|
+
.filter((s) => s.claudeSessionId && s.workspace?.path)
|
|
4204
|
+
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
|
|
4205
|
+
.slice(0, count);
|
|
4206
|
+
if (candidates.length === 0) {
|
|
4207
|
+
this.logger.debug("No Claude sessions to pre-warm");
|
|
4208
|
+
return;
|
|
4209
|
+
}
|
|
4210
|
+
this.logger.info(`Pre-warming ${candidates.length} most recent Claude sessions...`);
|
|
4211
|
+
const { startup } = await import("@anthropic-ai/claude-agent-sdk");
|
|
4212
|
+
await Promise.all(candidates.map(async (session) => {
|
|
4213
|
+
try {
|
|
4214
|
+
const repoId = this.sessionRepositories.get(session.id);
|
|
4215
|
+
const repo = repoId ? this.repositories.get(repoId) : undefined;
|
|
4216
|
+
if (!repo) {
|
|
4217
|
+
this.logger.debug(`No repo for session ${session.id}, skipping warmup`);
|
|
4218
|
+
return;
|
|
4219
|
+
}
|
|
4220
|
+
// Build MCP config for this session (same as the live runner would use)
|
|
4221
|
+
const linearWorkspaceId = requireLinearWorkspaceId(repo);
|
|
4222
|
+
const mcpConfig = this.mcpConfigService.buildMcpConfig(repo.id, linearWorkspaceId, session.id);
|
|
4223
|
+
// Merge any file-based MCP configs (reuses shared normalization)
|
|
4224
|
+
const mcpConfigPath = this.mcpConfigService.buildMergedMcpConfigPath(repo);
|
|
4225
|
+
let mcpServers = { ...mcpConfig };
|
|
4226
|
+
if (mcpConfigPath) {
|
|
4227
|
+
const paths = Array.isArray(mcpConfigPath)
|
|
4228
|
+
? mcpConfigPath
|
|
4229
|
+
: [mcpConfigPath];
|
|
4230
|
+
for (const filePath of paths) {
|
|
4231
|
+
try {
|
|
4232
|
+
if (existsSync(filePath)) {
|
|
4233
|
+
const fileContent = JSON.parse(readFileSync(filePath, "utf8"));
|
|
4234
|
+
const servers = fileContent.mcpServers || {};
|
|
4235
|
+
normalizeMcpHttpTransport(servers);
|
|
4236
|
+
mcpServers = { ...mcpServers, ...servers };
|
|
4237
|
+
}
|
|
4238
|
+
}
|
|
4239
|
+
catch {
|
|
4240
|
+
// Ignore unreadable MCP config files
|
|
4241
|
+
}
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
const repoConfig = repo;
|
|
4245
|
+
const model = session.metadata?.model ||
|
|
4246
|
+
repoConfig.claudeDefaultModel ||
|
|
4247
|
+
repoConfig.model ||
|
|
4248
|
+
"claude-opus-4-6";
|
|
4249
|
+
// Build allowed/disallowed tools — same as what buildAgentRunnerConfig() uses.
|
|
4250
|
+
// Without these, startup() inherits the user's defaultMode ("default"),
|
|
4251
|
+
// which causes macOS permission prompts for file writes.
|
|
4252
|
+
const allowedTools = this.buildAllowedTools(repo);
|
|
4253
|
+
const disallowedTools = this.buildDisallowedTools(repo);
|
|
4254
|
+
const warm = await startup({
|
|
4255
|
+
options: {
|
|
4256
|
+
resume: session.claudeSessionId,
|
|
4257
|
+
model,
|
|
4258
|
+
cwd: session.workspace.path,
|
|
4259
|
+
...(Object.keys(mcpServers).length > 0 && { mcpServers }),
|
|
4260
|
+
...(allowedTools.length > 0 && { allowedTools }),
|
|
4261
|
+
...(disallowedTools.length > 0 && { disallowedTools }),
|
|
4262
|
+
settingSources: ["user", "project", "local"],
|
|
4263
|
+
// CLAUDE_CODE_SUBPROCESS_ENV_SCRUB is intentionally not set here;
|
|
4264
|
+
// see CYPACK-1108 and ClaudeRunner.start() for context.
|
|
4265
|
+
env: buildBaseSessionEnv(),
|
|
4266
|
+
},
|
|
4267
|
+
});
|
|
4268
|
+
this.warmInstances.set(session.id, warm);
|
|
4269
|
+
this.logger.info(`Pre-warmed session ${session.id} (${session.issueContext?.issueIdentifier ?? "unknown"})`);
|
|
4270
|
+
}
|
|
4271
|
+
catch (err) {
|
|
4272
|
+
this.logger.debug(`Failed to pre-warm session ${session.id}:`, err);
|
|
4273
|
+
}
|
|
4274
|
+
}));
|
|
4275
|
+
this.logger.info(`Session pre-warm complete: ${this.warmInstances.size} sessions ready`);
|
|
4276
|
+
}
|
|
3773
4277
|
/**
|
|
3774
4278
|
* Save current EdgeWorker state for all repositories
|
|
3775
4279
|
*/
|