refactor(agent-manager): consolidate lifecycle deps into ctx plus a capability host

The worktree lifecycle handlers took a 27-field closure bag that mixed
project-state accessors with provider capabilities, a service-locator
pattern with the same hidden-dependency problem as the ambient scope it
replaced.

Handlers now receive the ProjectContext directly for everything that is
project state (peekState, worktreeManager, stale set, root, readiness via
initContextState), and a small LifecycleHost carries only what genuinely
belongs to the provider: the shared creation helpers, the panel session
facade, route registration, poller skips, the diff controller, telemetry,
and the webview boundary. The provider resolves ctx explicitly at each
handler boundary.
This commit is contained in:
marius-kilocode
2026-07-29 09:28:08 +02:00
parent c271a8ea51
commit 6617b2433c
4 changed files with 174 additions and 133 deletions
@@ -18,7 +18,7 @@ import {
deleteLifecycleWorktree,
promoteLifecycleSession,
removeStaleLifecycleWorktree,
type LifecycleDeps,
type LifecycleHost,
} from "./provider-lifecycle"
import { normalizeBaseBranch } from "./base-branch"
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller"
@@ -1039,27 +1039,37 @@ export class AgentManagerProvider implements Disposable {
/** Create a new worktree with an auto-created first session. */
private async onCreateWorktree(baseBranch?: string, branchName?: string): Promise<null> {
return createLifecycleWorktree(this.lifecycle, { baseBranch, branchName })
const ctx = this.context
if (!ctx) return null
return createLifecycleWorktree(ctx, this.lifecycleHost, { baseBranch, branchName })
}
/** Delete a worktree and dissociate its sessions. */
private async onDeleteWorktree(worktreeId: string): Promise<null> {
return deleteLifecycleWorktree(this.lifecycle, worktreeId)
const ctx = this.context
if (!ctx) return null
return deleteLifecycleWorktree(ctx, this.lifecycleHost, worktreeId)
}
/** Remove a stale worktree entry from state without touching the filesystem. */
private async onRemoveStaleWorktree(worktreeId: string): Promise<null> {
return removeStaleLifecycleWorktree(this.lifecycle, worktreeId)
const ctx = this.context
if (!ctx) return null
return removeStaleLifecycleWorktree(ctx, this.lifecycleHost, worktreeId)
}
/** Promote a session: create a worktree and move the session into it. */
private async onPromoteSession(sessionId: string): Promise<null> {
return promoteLifecycleSession(this.lifecycle, sessionId)
const ctx = this.context
if (!ctx) return null
return promoteLifecycleSession(ctx, this.lifecycleHost, sessionId)
}
/** Add a new session to an existing worktree. */
private async onAddSessionToWorktree(worktreeId: string, sessionId?: string): Promise<null> {
return addSessionToLifecycleWorktree(this.lifecycle, worktreeId, sessionId)
const ctx = this.context
if (!ctx) return null
return addSessionToLifecycleWorktree(ctx, this.lifecycleHost, worktreeId, sessionId)
}
private onForkSession(sessionId: string, worktreeId?: string, messageId?: string) {
@@ -1089,7 +1099,9 @@ export class AgentManagerProvider implements Disposable {
/** Stop a session and remove it from Agent Manager. */
private async onCloseSession(sessionId: string): Promise<null> {
return closeLifecycleSession(this.lifecycle, sessionId)
const ctx = this.context
if (!ctx) return null
return closeLifecycleSession(ctx, this.lifecycleHost, sessionId)
}
// Multi-version worktree creation
@@ -1513,37 +1525,34 @@ export class AgentManagerProvider implements Disposable {
}
// Manager accessors — repository-bound services are owned by the active ProjectContext (immutable per root).
private get lifecycle(): LifecycleDeps {
/** Provider capabilities for the worktree lifecycle handlers (state stays in ProjectContext). */
private get lifecycleHost(): LifecycleHost {
return {
waitReady: (context) => this.waitForStateReady(context),
createOnDisk: (opts) => this.createWorktreeOnDisk(opts),
setup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id),
runSetup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id),
createSession: (dir, branch, id) => this.createSessionInWorktree(dir, branch, id),
state: () => this.getStateManager(),
manager: () => this.getWorktreeManager(),
notifyReady: (sid, result, id) => this.notifyWorktreeReady(sid, result, id),
sessions: {
register: (session) => this.panel?.sessions.registerSession(session),
clearDirectory: (sid) => this.panel?.sessions.clearSessionDirectory(sid),
directories: () => this.panel?.sessions.getSessionDirectories(),
abort: (ids) => this.panel?.sessions.abortSessions(ids) ?? Promise.resolve(),
forget: (sid) => void this.panelSessions.delete(sid),
},
push: () => this.pushState(),
register: (sid, dir) => this.registerWorktreeSession(sid, dir),
ready: (sid, result, id) => this.notifyWorktreeReady(sid, result, id),
registerSession: (session) => this.panel?.sessions.registerSession(session),
clearDirectory: (sid) => this.panel?.sessions.clearSessionDirectory(sid),
directories: () => this.panel?.sessions.getSessionDirectories(),
abort: (ids) => this.panel?.sessions.abortSessions(ids) ?? Promise.resolve(),
forgetPanel: (sid) => void this.panelSessions.delete(sid),
skipStats: (id) => this.statsPoller.skipWorktree(id),
removePR: (id) => this.prBridge.remove(id),
removeRun: (id) => this.run.remove(id),
forgetName: (id) => this.naming.forget(id),
stale: () => this.staleWorktreeIds,
clearStale: (id) => this.clearStaleTracking(id),
stopDiffsFor: (path, orphaned) => {
stopDiffs: (path, orphaned) => {
if (this.diffs.shouldStopForWorktree(path, orphaned)) this.diffs.stop()
},
capture: (event, props) => this.host.capture(event, props),
autoName: () => this.host.autoBranchNaming(),
client: () => this.connectionService.getClient(),
metadata: (client, dir) => sandboxSessionMetadata(this.connectionService.sandboxPreference, client, dir),
root: () => this.getRoot(),
post: (msg) => this.postToWebview(msg as unknown as AgentManagerOutMessage),
post: (msg) => this.postToWebview(msg),
log: (...args) => this.log(...args),
}
}
@@ -1,217 +1,233 @@
import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
import { getErrorMessage } from "../kilo-provider-utils"
import type { WorktreeManager, CreateWorktreeResult } from "./WorktreeManager"
import type { AgentManagerOutMessage } from "./types"
import { PLATFORM } from "./constants"
import type { ManagedSession, WorktreeStateManager } from "./WorktreeStateManager"
import { initContextState } from "./project-init"
import type { ProjectContext } from "./project-context"
import type { ManagedSession } from "./WorktreeStateManager"
import type { CreateWorktreeResult, WorktreeManager } from "./WorktreeManager"
import type { CreateWorktreeOnDiskOptions, CreateWorktreeOnDiskResult } from "./worktree-create"
import { recordPromotionHandoff } from "./promotion-handoff"
import { stopSessionProcesses } from "../kilo-provider/background-process"
/** Everything the worktree lifecycle handlers read from the provider. */
export interface LifecycleDeps {
waitReady: (context: string) => Promise<void>
/**
* Provider capabilities the worktree lifecycle needs beyond project state.
* State is reached through the ProjectContext the handler receives; this host
* only carries what genuinely belongs to the provider: shared creation
* helpers, the panel session facade, route registration, poller skips, the
* diff controller, telemetry, and the webview boundary.
*/
export interface LifecycleHost {
createOnDisk: (opts?: CreateWorktreeOnDiskOptions) => Promise<CreateWorktreeOnDiskResult | null>
setup: (dir: string, branch: string, id: string) => Promise<void>
runSetup: (dir: string, branch: string, id: string) => Promise<void>
createSession: (dir: string, branch: string, id: string) => Promise<Session | null>
state: () => WorktreeStateManager | undefined
manager: () => WorktreeManager | undefined
notifyReady: (sessionId: string, result: CreateWorktreeResult, worktreeId?: string) => void
sessions: {
register: (session: Session) => void
clearDirectory: (sessionId: string) => void
directories: () => ReadonlyMap<string, string> | undefined
abort: (sessionIds: string[]) => Promise<void>
forget: (sessionId: string) => void
}
push: () => void
register: (sessionId: string, dir: string) => void
ready: (sessionId: string, result: CreateWorktreeResult, worktreeId?: string) => void
registerSession: (session: Session) => void
clearDirectory: (sessionId: string) => void
directories: () => ReadonlyMap<string, string> | undefined
abort: (sessionIds: string[]) => Promise<void>
forgetPanel: (sessionId: string) => void
skipStats: (worktreeId: string) => void
removePR: (worktreeId: string) => void
removeRun: (worktreeId: string) => void
forgetName: (worktreeId: string) => void
stale: () => Set<string>
clearStale: (worktreeId: string) => void
stopDiffsFor: (path: string, orphaned: ManagedSession[]) => void
stopDiffs: (path: string, orphaned: ManagedSession[]) => void
capture: (event: string, props: Record<string, unknown>) => void
autoName: () => { enabled: boolean }
client: () => KiloClient
metadata: (client: KiloClient, dir: string) => Promise<Record<string, unknown>>
root: () => string | undefined
post: (message: Record<string, unknown>) => void
post: (message: AgentManagerOutMessage) => void
log: (...args: unknown[]) => void
}
/** Create a new worktree with an auto-created first session. */
export async function createLifecycleWorktree(
deps: LifecycleDeps,
ctx: ProjectContext,
host: LifecycleHost,
opts: { baseBranch?: string; branchName?: string },
): Promise<null> {
await deps.waitReady("onCreateWorktree")
await initContextState(ctx, host.log)
const created = await deps.createOnDisk({ baseBranch: opts.baseBranch, branchName: opts.branchName })
const created = await host.createOnDisk({ baseBranch: opts.baseBranch, branchName: opts.branchName })
if (!created) return null
// Run setup script for new worktree (blocks until complete, shows in overlay)
await deps.setup(created.result.path, created.result.branch, created.worktree.id)
await host.runSetup(created.result.path, created.result.branch, created.worktree.id)
const session = await deps.createSession(created.result.path, created.result.branch, created.worktree.id)
const session = await host.createSession(created.result.path, created.result.branch, created.worktree.id)
if (!session) {
const state = deps.state()
const manager = deps.manager()
state?.removeWorktree(created.worktree.id)
await manager?.removeWorktree(created.result.path)
deps.push()
ctx.peekState()?.removeWorktree(created.worktree.id)
await ctx.worktreeManager().removeWorktree(created.result.path)
host.push()
return null
}
const state = deps.state()!
const state = ctx.peekState()!
state.addSession(session.id, created.worktree.id)
if (!opts.branchName && deps.autoName().enabled) state.armAutoName(created.worktree.id, session.id)
deps.register(session.id, created.result.path)
if (!opts.branchName && host.autoName().enabled) state.armAutoName(created.worktree.id, session.id)
host.register(session.id, created.result.path)
// Push state before registerSession so the webview's sessionCreated handler
// sees the worktree mapping and routes the session to the worktree tab.
deps.ready(session.id, created.result, created.worktree.id)
deps.registerSession(session)
deps.capture("Agent Manager Session Started", {
host.notifyReady(session.id, created.result, created.worktree.id)
host.sessions.register(session)
host.capture("Agent Manager Session Started", {
source: PLATFORM,
sessionId: session.id,
worktreeId: created.worktree.id,
branch: created.result.branch,
})
deps.log(`Created worktree ${created.worktree.id} with session ${session.id}`)
host.log(`Created worktree ${created.worktree.id} with session ${session.id}`)
return null
}
/** Delete a worktree and dissociate its sessions. */
export async function deleteLifecycleWorktree(deps: LifecycleDeps, worktreeId: string): Promise<null> {
const manager = deps.manager()
const state = deps.state()
if (!manager || !state) return null
export async function deleteLifecycleWorktree(
ctx: ProjectContext,
host: LifecycleHost,
worktreeId: string,
): Promise<null> {
const state = ctx.peekState()
if (!state) return null
const worktree = state.getWorktree(worktreeId)
if (!worktree) {
deps.log(`Worktree ${worktreeId} not found in state`)
host.log(`Worktree ${worktreeId} not found in state`)
return null
}
// Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree.
// Pre-emptive skip covers any in-flight poll that already captured getWorktrees().
deps.skipStats(worktreeId)
deps.removePR(worktreeId)
deps.removeRun(worktreeId)
deps.forgetName(worktreeId)
host.skipStats(worktreeId)
host.removePR(worktreeId)
host.removeRun(worktreeId)
host.forgetName(worktreeId)
const orphaned = state.removeWorktree(worktreeId)
deps.stopDiffsFor(worktree.path, orphaned)
for (const s of orphaned) deps.clearDirectory(s.id)
deps.push()
host.stopDiffs(worktree.path, orphaned)
for (const s of orphaned) host.sessions.clearDirectory(s.id)
host.push()
// Disk removal after state is clean — pollers no longer reference this worktree.
const branch = worktree.branchOwned === false ? undefined : (worktree.originalBranch ?? worktree.branch)
try {
await manager.removeWorktree(worktree.path, branch)
await ctx.worktreeManager().removeWorktree(worktree.path, branch)
} catch (error) {
deps.log(`Failed to remove worktree from disk: ${error}`)
host.log(`Failed to remove worktree from disk: ${error}`)
}
deps.log(`Deleted worktree ${worktreeId}${branch ? ` (${branch})` : ""}`)
host.log(`Deleted worktree ${worktreeId}${branch ? ` (${branch})` : ""}`)
return null
}
/** Remove a stale worktree entry from state without touching the filesystem. */
export async function removeStaleLifecycleWorktree(deps: LifecycleDeps, worktreeId: string): Promise<null> {
const state = deps.state()
export async function removeStaleLifecycleWorktree(
ctx: ProjectContext,
host: LifecycleHost,
worktreeId: string,
): Promise<null> {
const state = ctx.peekState()
if (!state) return null
if (!deps.stale().has(worktreeId)) {
deps.log(`Ignored stale removal for non-stale worktree ${worktreeId}`)
if (!ctx.stale.has(worktreeId)) {
host.log(`Ignored stale removal for non-stale worktree ${worktreeId}`)
return null
}
const worktree = state.getWorktree(worktreeId)
if (!worktree) {
deps.clearStale(worktreeId)
deps.push()
ctx.stale.delete(worktreeId)
host.push()
return null
}
deps.forgetName(worktreeId)
host.forgetName(worktreeId)
const orphaned = state.removeWorktree(worktreeId)
deps.stopDiffsFor(worktree.path, orphaned)
for (const session of orphaned) deps.clearDirectory(session.id)
deps.clearStale(worktreeId)
deps.push()
deps.log(`Removed stale worktree entry ${worktreeId} (${worktree.branch})`)
host.stopDiffs(worktree.path, orphaned)
for (const session of orphaned) host.sessions.clearDirectory(session.id)
ctx.stale.delete(worktreeId)
host.push()
host.log(`Removed stale worktree entry ${worktreeId} (${worktree.branch})`)
return null
}
/** Promote a session: create a worktree and move the session into it. */
export async function promoteLifecycleSession(deps: LifecycleDeps, sessionId: string): Promise<null> {
await deps.waitReady("onPromoteSession")
const created = await deps.createOnDisk({})
export async function promoteLifecycleSession(
ctx: ProjectContext,
host: LifecycleHost,
sessionId: string,
): Promise<null> {
await initContextState(ctx, host.log)
const created = await host.createOnDisk({})
if (!created) return null
// Run setup script for new worktree (blocks until complete, shows in overlay)
await deps.setup(created.result.path, created.result.branch, created.worktree.id)
await host.runSetup(created.result.path, created.result.branch, created.worktree.id)
const state = deps.state()!
const state = ctx.peekState()!
if (!state.getSession(sessionId)) {
state.addSession(sessionId, created.worktree.id)
} else {
state.moveSession(sessionId, created.worktree.id)
}
deps.register(sessionId, created.result.path)
host.register(sessionId, created.result.path)
try {
await recordPromotionHandoff({
client: deps.client(),
client: host.client(),
sessionId,
directory: created.result.path,
branch: created.result.branch,
})
} catch (err) {
deps.log("Failed to record worktree promotion handoff:", getErrorMessage(err))
host.log("Failed to record worktree promotion handoff:", getErrorMessage(err))
}
deps.ready(sessionId, created.result, created.worktree.id)
deps.log(`Promoted session ${sessionId} to worktree ${created.worktree.id}`)
host.notifyReady(sessionId, created.result, created.worktree.id)
host.log(`Promoted session ${sessionId} to worktree ${created.worktree.id}`)
return null
}
/** Add a new or existing session to an existing worktree. */
export async function addSessionToLifecycleWorktree(
deps: LifecycleDeps,
ctx: ProjectContext,
host: LifecycleHost,
worktreeId: string,
sessionId?: string,
): Promise<null> {
let client: KiloClient
try {
client = deps.client()
client = host.client()
} catch (err) {
deps.log("onAddSessionToWorktree: client not available:", err)
deps.post({ type: "error", message: "Not connected to CLI backend" })
host.log("onAddSessionToWorktree: client not available:", err)
host.post({ type: "error", message: "Not connected to CLI backend" })
return null
}
const state = deps.state()
const state = ctx.peekState()
if (!state) return null
const worktree = state.getWorktree(worktreeId)
if (!worktree) {
deps.log(`Worktree ${worktreeId} not found`)
host.log(`Worktree ${worktreeId} not found`)
return null
}
if (sessionId) {
if (state.getSession(sessionId)) state.moveSession(sessionId, worktreeId)
else state.addSession(sessionId, worktreeId)
deps.register(sessionId, worktree.path)
deps.push()
deps.post({ type: "agentManager.sessionAdded", sessionId, worktreeId })
deps.capture("Agent Manager Session Started", {
host.register(sessionId, worktree.path)
host.push()
host.post({ type: "agentManager.sessionAdded", sessionId, worktreeId })
host.capture("Agent Manager Session Started", {
source: PLATFORM,
sessionId,
worktreeId,
existing: true,
})
deps.log(`Added existing session ${sessionId} to worktree ${worktreeId}`)
host.log(`Added existing session ${sessionId} to worktree ${worktreeId}`)
return null
}
let session: Session
try {
const metadata = await deps.metadata(client, worktree.path)
const metadata = await host.metadata(client, worktree.path)
const { data } = await client.session.create(
{ directory: worktree.path, platform: PLATFORM, metadata },
{ throwOnError: true },
@@ -219,8 +235,8 @@ export async function addSessionToLifecycleWorktree(
session = data
} catch (error) {
const err = getErrorMessage(error)
deps.post({ type: "error", message: `Failed to create session: ${err}` })
deps.capture("Agent Manager Session Error", {
host.post({ type: "error", message: `Failed to create session: ${err}` })
host.capture("Agent Manager Session Error", {
source: PLATFORM,
error: err,
context: "addSessionToWorktree",
@@ -230,35 +246,41 @@ export async function addSessionToLifecycleWorktree(
}
state.addSession(session.id, worktreeId)
deps.register(session.id, worktree.path)
deps.push()
deps.post({ type: "agentManager.sessionAdded", sessionId: session.id, worktreeId })
deps.registerSession(session)
host.register(session.id, worktree.path)
host.push()
host.post({ type: "agentManager.sessionAdded", sessionId: session.id, worktreeId })
host.sessions.register(session)
deps.capture("Agent Manager Session Started", {
host.capture("Agent Manager Session Started", {
source: PLATFORM,
sessionId: session.id,
worktreeId,
})
deps.log(`Added session ${session.id} to worktree ${worktreeId}`)
host.log(`Added session ${session.id} to worktree ${worktreeId}`)
return null
}
/** Stop a session and remove it from Agent Manager. */
export async function closeLifecycleSession(deps: LifecycleDeps, sessionId: string): Promise<null> {
const state = deps.state()
const dir = state?.directoryFor(sessionId) ?? deps.directories()?.get(sessionId) ?? deps.root() ?? process.cwd()
await deps.abort([sessionId])
deps.forgetPanel(sessionId)
export async function closeLifecycleSession(
ctx: ProjectContext,
host: LifecycleHost,
sessionId: string,
): Promise<null> {
const state = ctx.peekState()
const dir = state?.directoryFor(sessionId) ?? host.sessions.directories()?.get(sessionId) ?? ctx.root ?? process.cwd()
await host.sessions.abort([sessionId])
host.sessions.forget(sessionId)
try {
await stopSessionProcesses(deps.client(), sessionId, dir)
await stopSessionProcesses(host.client(), sessionId, dir)
} catch (err) {
deps.log("onCloseSession: client not available:", err)
host.log("onCloseSession: client not available:", err)
}
state?.removeSession(sessionId)
deps.clearDirectory(sessionId)
if (state) deps.push()
deps.log(`Closed session ${sessionId}`)
host.sessions.clearDirectory(sessionId)
if (state) host.push()
host.log(`Closed session ${sessionId}`)
return null
}
export type { WorktreeManager }
@@ -270,7 +270,7 @@ describe("Agent Manager Provider Messages", () => {
expect(body).toContain("closedDrafts.add(sessionId)")
expect(body).toContain('vscode.postMessage({ type: "agentManager.closeSession", sessionId })')
expect(body).not.toContain('type: "agentManager.forgetSession"')
expect(getMethodBody("onCloseSession")).toContain("await deps.abort([sessionId])")
expect(getMethodBody("onCloseSession")).toContain("await host.sessions.abort([sessionId])")
expect(text).toContain("if (created.draftID && closedDrafts.delete(created.draftID)) return")
})
@@ -517,10 +517,10 @@ describe("Agent Manager Provider — onMessage routing", () => {
*/
it("onDeleteWorktree removes from disk, state, clears orphans, and pushes", () => {
const text = body("onDeleteWorktree")
expect(text).toContain("manager.removeWorktree")
expect(text).toContain("worktreeManager().removeWorktree")
expect(text).toContain("state.removeWorktree")
expect(text).toContain("deps.clearDirectory")
expect(text).toContain("deps.push()")
expect(text).toContain("sessions.clearDirectory")
expect(text).toContain("host.push()")
})
// -- onCreateWorktree invariants -------------------------------------------
@@ -532,8 +532,8 @@ describe("Agent Manager Provider — onMessage routing", () => {
*/
it("onCreateWorktree runs setup script before creating session", () => {
const text = body("onCreateWorktree")
const setupIdx = text.indexOf("deps.setup(")
const sessionIdx = text.indexOf("deps.createSession(")
const setupIdx = text.indexOf("host.runSetup(")
const sessionIdx = text.indexOf("host.createSession(")
expect(setupIdx, "setup script call must exist").toBeGreaterThan(-1)
expect(sessionIdx, "session creation call must exist").toBeGreaterThan(-1)
expect(setupIdx, "setup script must run before session creation").toBeLessThan(sessionIdx)
@@ -568,7 +568,7 @@ describe("Agent Manager Provider — onMessage routing", () => {
*/
it("onPromoteSession runs setup script before modifying session", () => {
const text = body("onPromoteSession")
const setupIdx = text.indexOf("deps.setup(")
const setupIdx = text.indexOf("host.runSetup(")
const moveIdx = text.indexOf("moveSession")
expect(setupIdx).toBeGreaterThan(-1)
expect(moveIdx).toBeGreaterThan(-1)
@@ -3,6 +3,8 @@ import { describe, expect, it, mock } from "bun:test"
const { AgentManagerProvider } = await import("../../src/agent-manager/AgentManagerProvider")
type Manager = {
projectScope: { current: () => unknown; run: (ctx: unknown, fn: () => unknown) => unknown }
contexts: { active: () => unknown }
connectionService: { getClient: () => unknown }
panel: {
postMessage: (message: unknown) => void
@@ -61,6 +63,14 @@ function createManager(options?: { dir?: string; panelDir?: string; state?: bool
manager.getRoot = () => "/repo"
manager.pushState = mock(() => undefined)
manager.log = mock(() => undefined)
const ctx = {
peekState: () => (options?.state === false ? undefined : state),
root: "/repo",
stale: new Set<string>(),
worktreeManager: () => ({}),
}
manager.projectScope = { current: () => ctx, run: (_: unknown, fn: () => unknown) => fn() }
manager.contexts = { active: () => ctx }
return { manager, stopped, aborted, cleared, removed, messages, events }
}