Skip to content

Commit c018a5a

Browse files
fix(am): Improve agent manager and agent startup performance (#4567)
* tmp * feat(agent-manager): add CliSessionLauncher with pre-warming support - Extract CLI session launching logic into dedicated CliSessionLauncher class - Pre-warm slow lookups (CLI path: 500-2000ms, git URL: 50-100ms) on panel open - Run CLI path, git URL, and API config lookups in parallel during spawn - Add handleSessionRenamed callback for provisional session upgrades - Clear pre-warm state when panel closes or on dispose * Extract and simplify * Extract renameMapKey
1 parent 0642041 commit c018a5a

File tree

8 files changed

+671
-57
lines changed

8 files changed

+671
-57
lines changed

src/core/kilocode/agent-manager/AgentManagerProvider.ts

Lines changed: 41 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,27 @@ import * as fs from "node:fs"
33
import * as path from "node:path"
44
import { t } from "i18next"
55
import { AgentRegistry } from "./AgentRegistry"
6+
import { renameMapKey } from "./mapUtils"
67
import {
78
parseParallelModeBranch,
89
parseParallelModeWorktreePath,
910
isParallelModeCompletionMessage,
1011
parseParallelModeCompletionBranch,
1112
} from "./parallelModeParser"
12-
import { findKilocodeCli } from "./CliPathResolver"
1313
import { canInstallCli, getCliInstallCommand, getLocalCliInstallCommand, getLocalCliBinDir } from "./CliInstaller"
1414
import { CliProcessHandler, type CliProcessHandlerCallbacks } from "./CliProcessHandler"
1515
import type { StreamEvent, KilocodeStreamEvent, KilocodePayload, WelcomeStreamEvent } from "./CliOutputParser"
1616
import { extractRawText, tryParsePayloadJson } from "./askErrorParser"
1717
import { RemoteSessionService } from "./RemoteSessionService"
1818
import { KilocodeEventProcessor } from "./KilocodeEventProcessor"
19+
import { CliSessionLauncher } from "./CliSessionLauncher"
1920
import type { RemoteSession } from "./types"
2021
import { getUri } from "../../webview/getUri"
2122
import { getNonce } from "../../webview/getNonce"
2223
import { getViteDevServerConfig } from "../../webview/getViteDevServerConfig"
2324
import { getRemoteUrl } from "../../../services/code-index/managed/git-utils"
2425
import { normalizeGitUrl } from "./normalizeGitUrl"
25-
import type { ClineMessage } from "@roo-code/types"
26-
import type { ProviderSettings } from "@roo-code/types"
26+
import type { ClineMessage, ProviderSettings } from "@roo-code/types"
2727
import {
2828
captureAgentManagerOpened,
2929
captureAgentManagerSessionStarted,
@@ -53,6 +53,7 @@ export class AgentManagerProvider implements vscode.Disposable {
5353
private remoteSessionService: RemoteSessionService
5454
private processHandler: CliProcessHandler
5555
private eventProcessor: KilocodeEventProcessor
56+
private sessionLauncher: CliSessionLauncher
5657
private sessionMessages: Map<string, ClineMessage[]> = new Map()
5758
// Track first api_req_started per session to filter user-input echoes
5859
private firstApiReqStarted: Map<string, boolean> = new Map()
@@ -72,6 +73,12 @@ export class AgentManagerProvider implements vscode.Disposable {
7273
this.registry = new AgentRegistry()
7374
this.remoteSessionService = new RemoteSessionService({ outputChannel })
7475

76+
// Initialize session launcher with pre-warming
77+
// Pre-warming starts slow lookups (CLI: 500-2000ms, git: 50-100ms) immediately
78+
// so they complete before the user clicks "Start" to reduce time-to-first-token
79+
this.sessionLauncher = new CliSessionLauncher(outputChannel, () => this.getApiConfigurationForCli())
80+
this.sessionLauncher.startPrewarm()
81+
7582
// Initialize currentGitUrl from workspace
7683
void this.initializeCurrentGitUrl()
7784

@@ -142,6 +149,7 @@ export class AgentManagerProvider implements vscode.Disposable {
142149
})
143150
},
144151
onPaymentRequiredPrompt: (payload) => this.showPaymentRequiredPrompt(payload),
152+
onSessionRenamed: (oldId, newId) => this.handleSessionRenamed(oldId, newId),
145153
}
146154

147155
this.processHandler = new CliProcessHandler(this.registry, callbacks)
@@ -196,6 +204,8 @@ export class AgentManagerProvider implements vscode.Disposable {
196204
() => {
197205
this.panel = undefined
198206
this.stopAllAgents()
207+
// Clear pre-warm state when panel closes
208+
this.sessionLauncher.clearPrewarm()
199209
},
200210
null,
201211
this.disposables,
@@ -207,6 +217,21 @@ export class AgentManagerProvider implements vscode.Disposable {
207217
captureAgentManagerOpened()
208218
}
209219

220+
/** Rename session key in all session-keyed maps. */
221+
private handleSessionRenamed(oldId: string, newId: string): void {
222+
this.outputChannel.appendLine(`[AgentManager] Renaming session: ${oldId} -> ${newId}`)
223+
224+
renameMapKey(this.sessionMessages, oldId, newId)
225+
renameMapKey(this.firstApiReqStarted, oldId, newId)
226+
renameMapKey(this.processStartTimes, oldId, newId)
227+
renameMapKey(this.sendingMessageMap, oldId, newId)
228+
229+
const messages = this.sessionMessages.get(newId)
230+
if (messages) {
231+
this.postMessage({ type: "agentManager.chatMessages", sessionId: newId, messages })
232+
}
233+
}
234+
210235
private handleMessage(message: { type: string; [key: string]: unknown }): void {
211236
this.outputChannel.appendLine(`Agent Manager received message: ${JSON.stringify(message)}`)
212237

@@ -419,36 +444,23 @@ export class AgentManagerProvider implements vscode.Disposable {
419444
return
420445
}
421446

422-
// Get workspace folder early to fetch git URL before spawning
423447
// Note: we intentionally allow starting parallel mode from within an existing git worktree.
424448
// Git worktrees share a common .git dir, so `git worktree add/remove` still works from a worktree root.
425449
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
426450

427-
// Get git URL for the workspace (used for filtering sessions)
428-
let gitUrl: string | undefined
429-
if (workspaceFolder) {
430-
try {
431-
gitUrl = normalizeGitUrl(await getRemoteUrl(workspaceFolder))
432-
} catch (error) {
433-
this.outputChannel.appendLine(
434-
`[AgentManager] Could not get git URL: ${error instanceof Error ? error.message : String(error)}`,
435-
)
436-
}
437-
}
438-
439451
const onSetupFailed = () => {
440452
if (!workspaceFolder) {
441453
void vscode.window.showErrorMessage("Please open a folder before starting an agent.")
442454
}
443455
this.postMessage({ type: "agentManager.startSessionFailed" })
444456
}
445457

458+
// Git URL lookup is now handled by spawnCliWithCommonSetup using pre-warmed promise
446459
await this.spawnCliWithCommonSetup(
447460
prompt,
448461
{
449462
parallelMode: options?.parallelMode,
450463
label: options?.labelOverride,
451-
gitUrl,
452464
existingBranch: options?.existingBranch,
453465
},
454466
onSetupFailed,
@@ -462,7 +474,7 @@ export class AgentManagerProvider implements vscode.Disposable {
462474

463475
/**
464476
* Common helper to spawn a CLI process with standard setup.
465-
* Handles CLI path lookup, workspace folder validation, API config, and event callback wiring.
477+
* Delegates to CliSessionLauncher for pre-warming and spawning.
466478
* @returns true if process was spawned, false if setup failed
467479
*/
468480
private async spawnCliWithCommonSetup(
@@ -476,47 +488,23 @@ export class AgentManagerProvider implements vscode.Disposable {
476488
},
477489
onSetupFailed?: () => void,
478490
): Promise<boolean> {
479-
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
480-
if (!workspaceFolder) {
481-
this.outputChannel.appendLine("ERROR: No workspace folder open")
482-
onSetupFailed?.()
483-
return false
484-
}
485-
486-
const cliPath = await findKilocodeCli((msg) => this.outputChannel.appendLine(`[AgentManager] ${msg}`))
487-
if (!cliPath) {
488-
this.outputChannel.appendLine("ERROR: kilocode CLI not found")
489-
this.showCliNotFoundError()
490-
onSetupFailed?.()
491-
return false
492-
}
493-
494-
const processStartTime = Date.now()
495-
let apiConfiguration: ProviderSettings | undefined
496-
try {
497-
apiConfiguration = await this.getApiConfigurationForCli()
498-
} catch (error) {
499-
this.outputChannel.appendLine(
500-
`[AgentManager] Failed to read provider settings for CLI: ${
501-
error instanceof Error ? error.message : String(error)
502-
}`,
503-
)
504-
}
505-
506-
this.processHandler.spawnProcess(
507-
cliPath,
508-
workspaceFolder,
491+
const result = await this.sessionLauncher.spawn(
509492
prompt,
510-
{ ...options, apiConfiguration },
493+
options,
494+
this.processHandler,
511495
(sid, event) => {
512-
if (!this.processStartTimes.has(sid)) {
513-
this.processStartTimes.set(sid, processStartTime)
496+
if (result.processStartTime && !this.processStartTimes.has(sid)) {
497+
this.processStartTimes.set(sid, result.processStartTime)
514498
}
515499
this.handleCliEvent(sid, event)
516500
},
501+
() => {
502+
this.showCliNotFoundError()
503+
onSetupFailed?.()
504+
},
517505
)
518506

519-
return true
507+
return result.success
520508
}
521509

522510
/**
@@ -1150,7 +1138,7 @@ export class AgentManagerProvider implements vscode.Disposable {
11501138
this.processHandler.dispose()
11511139
this.sessionMessages.clear()
11521140
this.firstApiReqStarted.clear()
1153-
1141+
this.sessionLauncher.clearPrewarm()
11541142
this.panel?.dispose()
11551143
this.disposables.forEach((d) => d.dispose())
11561144
}

src/core/kilocode/agent-manager/AgentRegistry.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,31 @@ export class AgentRegistry {
209209
return this.sessions.delete(sessionId)
210210
}
211211

212+
/**
213+
* Rename a session from one ID to another.
214+
* Used when upgrading a provisional session to a real session ID.
215+
*/
216+
public renameSession(oldId: string, newId: string): boolean {
217+
const session = this.sessions.get(oldId)
218+
if (!session) {
219+
return false
220+
}
221+
222+
// Update the session's internal ID
223+
session.sessionId = newId
224+
225+
// Move in the map
226+
this.sessions.delete(oldId)
227+
this.sessions.set(newId, session)
228+
229+
// Update selectedId if it was pointing to the old ID
230+
if (this._selectedId === oldId) {
231+
this._selectedId = newId
232+
}
233+
234+
return true
235+
}
236+
212237
private pruneOldSessions(): void {
213238
const sessions = this.getSessions()
214239
const overflow = sessions.length - MAX_SESSIONS

src/core/kilocode/agent-manager/CliProcessHandler.ts

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ interface PendingProcessInfo {
3737
gitUrl?: string
3838
stderrBuffer: string[] // Capture stderr for error detection
3939
timeoutId?: NodeJS.Timeout // Timer for auto-failing stuck pending sessions
40+
provisionalSessionId?: string // Temporary session ID created when api_req_started arrives (before session_created)
4041
}
4142

4243
interface ActiveProcessInfo {
@@ -60,6 +61,7 @@ export interface CliProcessHandlerCallbacks {
6061
onSessionCreated: (sawApiReqStarted: boolean) => void
6162
onPaymentRequiredPrompt?: (payload: KilocodePayload) => void
6263
onSessionCompleted?: (sessionId: string, exitCode: number | null) => void // Called when process exits successfully
64+
onSessionRenamed?: (oldId: string, newId: string) => void // Called when provisional session is upgraded to real session ID
6365
}
6466

6567
export class CliProcessHandler {
@@ -365,10 +367,11 @@ export class CliProcessHandler {
365367
}
366368
return
367369
}
368-
// Track api_req_started that arrives before session_created
369-
// This is needed so KilocodeEventProcessor knows the user echo has already happened
370+
// Handle kilocode events during pending state
370371
if (event.streamEventType === "kilocode") {
371372
const payload = (event as KilocodeStreamEvent).payload
373+
374+
// Handle error cases that should abort session creation
372375
if (payload?.ask === "payment_required_prompt") {
373376
this.handlePaymentRequiredDuringPending(payload)
374377
return
@@ -377,11 +380,34 @@ export class CliProcessHandler {
377380
this.handleApiReqFailedDuringPending(payload)
378381
return
379382
}
383+
384+
// Track api_req_started for KilocodeEventProcessor
380385
if (payload?.say === "api_req_started") {
381386
this.pendingProcess.sawApiReqStarted = true
382387
this.debugLog(`Captured api_req_started before session_created`)
383388
}
389+
390+
// Create provisional session on first content event (text, api_req_started, etc.)
391+
// This ensures we don't lose the user's prompt echo or other early events
392+
if (!this.pendingProcess.provisionalSessionId) {
393+
this.createProvisionalSession(proc)
394+
}
395+
396+
// Forward the event to the provisional session
397+
if (this.pendingProcess?.provisionalSessionId) {
398+
onCliEvent(this.pendingProcess.provisionalSessionId, event)
399+
this.callbacks.onStateChanged()
400+
}
401+
return
402+
}
403+
404+
// If we have a provisional session, forward non-kilocode events to it
405+
if (this.pendingProcess?.provisionalSessionId) {
406+
onCliEvent(this.pendingProcess.provisionalSessionId, event)
407+
this.callbacks.onStateChanged()
408+
return
384409
}
410+
385411
// Events before session_created are typically status messages
386412
if (event.streamEventType === "status") {
387413
this.debugLog(`Pending session status: ${event.message}`)
@@ -397,6 +423,64 @@ export class CliProcessHandler {
397423
}
398424
}
399425

426+
/** Create a provisional session to show streaming content before session_created arrives. */
427+
private createProvisionalSession(proc: ChildProcess): void {
428+
if (!this.pendingProcess || this.pendingProcess.provisionalSessionId) {
429+
return
430+
}
431+
432+
const provisionalId = `provisional-${Date.now()}`
433+
this.pendingProcess.provisionalSessionId = provisionalId
434+
435+
const { prompt, startTime, parallelMode, desiredLabel, gitUrl, parser } = this.pendingProcess
436+
437+
this.registry.createSession(provisionalId, prompt, startTime, {
438+
parallelMode,
439+
labelOverride: desiredLabel,
440+
gitUrl,
441+
})
442+
443+
this.activeSessions.set(provisionalId, { process: proc, parser })
444+
445+
if (proc.pid) {
446+
this.registry.setSessionPid(provisionalId, proc.pid)
447+
}
448+
449+
this.registry.clearPendingSession()
450+
this.callbacks.onPendingSessionChanged(null)
451+
this.callbacks.onSessionCreated(this.pendingProcess?.sawApiReqStarted ?? false)
452+
453+
this.debugLog(`Created provisional session: ${provisionalId}`)
454+
this.callbacks.onStateChanged()
455+
}
456+
457+
/** Upgrade provisional session to real session ID when session_created arrives. */
458+
private upgradeProvisionalSession(
459+
provisionalSessionId: string,
460+
realSessionId: string,
461+
worktreeBranch: string | undefined,
462+
parallelMode: boolean | undefined,
463+
): void {
464+
this.debugLog(`Upgrading provisional session ${provisionalSessionId} -> ${realSessionId}`)
465+
466+
this.registry.renameSession(provisionalSessionId, realSessionId)
467+
468+
const activeInfo = this.activeSessions.get(provisionalSessionId)
469+
if (activeInfo) {
470+
this.activeSessions.delete(provisionalSessionId)
471+
this.activeSessions.set(realSessionId, activeInfo)
472+
}
473+
474+
this.callbacks.onSessionRenamed?.(provisionalSessionId, realSessionId)
475+
476+
if (worktreeBranch && parallelMode) {
477+
this.registry.updateParallelModeInfo(realSessionId, { branch: worktreeBranch })
478+
}
479+
480+
this.pendingProcess = null
481+
this.callbacks.onStateChanged()
482+
}
483+
400484
private handlePendingTimeout(): void {
401485
if (!this.pendingProcess) {
402486
return
@@ -445,10 +529,19 @@ export class CliProcessHandler {
445529
desiredLabel,
446530
sawApiReqStarted,
447531
gitUrl,
532+
provisionalSessionId,
448533
} = this.pendingProcess
449534

450535
// Use desired sessionId when provided (resuming) to keep UI continuity
451536
const sessionId = desiredSessionId ?? event.sessionId
537+
538+
// Handle provisional session upgrade if one exists
539+
if (provisionalSessionId) {
540+
this.upgradeProvisionalSession(provisionalSessionId, sessionId, worktreeBranch, parallelMode)
541+
return
542+
}
543+
544+
// Normal path: no provisional session, create the session now
452545
const existing = this.registry.getSession(sessionId)
453546

454547
let session: ReturnType<typeof this.registry.createSession>

0 commit comments

Comments
 (0)