From 7a6ab9abf4104bd76a80d018e0a0af948e13ef01 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 11 Sep 2026 11:04:58 +0200 Subject: [PATCH 01/10] feat(agent-manager): pre-warm worktrees to speed up session creation Register a detached worktree in the background and claim it for a new session instead of running a full worktree checkout at creation time. Add per-phase creation timing and the "Pre-warm worktrees" Agent Manager setting, enabled by default. Also fix a claim path that could reset an existing branch whose name matched a pooled worktree directory, and raise the worktree add worker count for the fallback path. --- .changeset/worktree-pool-prewarm.md | 5 + packages/kilo-vscode/package.json | 6 + packages/kilo-vscode/src/KiloProvider.ts | 1 + .../src/agent-manager/AgentManagerProvider.ts | 14 +- .../src/agent-manager/WorktreeManager.ts | 230 ++++++++-- .../src/agent-manager/creation-timing.ts | 73 +++ .../kilo-vscode/src/agent-manager/host.ts | 6 + .../src/agent-manager/project/context.ts | 12 +- .../src/agent-manager/project/init.ts | 5 + .../src/agent-manager/project/wiring.ts | 10 +- .../src/agent-manager/provider-lifecycle.ts | 60 ++- .../agent-manager/provider-multi-version.ts | 25 +- .../src/agent-manager/tool-start.ts | 33 +- .../src/agent-manager/vscode-host.ts | 10 + .../src/agent-manager/worktree-pool.ts | 426 ++++++++++++++++++ .../src/kilo-provider/config-snapshot.ts | 1 + .../unit/agent-manager-tool-start.test.ts | 15 +- .../tests/unit/creation-timing.test.ts | 34 ++ .../tests/unit/provider-multi-version.test.ts | 62 +++ .../tests/unit/worktree-manager.test.ts | 4 +- .../tests/unit/worktree-pool.test.ts | 206 +++++++++ .../webview-ui/agent-manager/i18n/ar.ts | 3 + .../webview-ui/agent-manager/i18n/br.ts | 3 + .../webview-ui/agent-manager/i18n/bs.ts | 3 + .../webview-ui/agent-manager/i18n/da.ts | 3 + .../webview-ui/agent-manager/i18n/de.ts | 3 + .../webview-ui/agent-manager/i18n/en.ts | 3 + .../webview-ui/agent-manager/i18n/es.ts | 3 + .../webview-ui/agent-manager/i18n/fa.ts | 3 + .../webview-ui/agent-manager/i18n/fr.ts | 3 + .../webview-ui/agent-manager/i18n/it.ts | 3 + .../webview-ui/agent-manager/i18n/ja.ts | 3 + .../webview-ui/agent-manager/i18n/ko.ts | 3 + .../webview-ui/agent-manager/i18n/nl.ts | 3 + .../webview-ui/agent-manager/i18n/no.ts | 3 + .../webview-ui/agent-manager/i18n/pl.ts | 3 + .../webview-ui/agent-manager/i18n/ru.ts | 3 + .../webview-ui/agent-manager/i18n/th.ts | 3 + .../webview-ui/agent-manager/i18n/tr.ts | 3 + .../webview-ui/agent-manager/i18n/uk.ts | 3 + .../webview-ui/agent-manager/i18n/zh.ts | 3 + .../webview-ui/agent-manager/i18n/zht.ts | 3 + .../src/components/settings/Settings.tsx | 12 + 43 files changed, 1240 insertions(+), 73 deletions(-) create mode 100644 .changeset/worktree-pool-prewarm.md create mode 100644 packages/kilo-vscode/src/agent-manager/creation-timing.ts create mode 100644 packages/kilo-vscode/src/agent-manager/worktree-pool.ts create mode 100644 packages/kilo-vscode/tests/unit/creation-timing.test.ts create mode 100644 packages/kilo-vscode/tests/unit/worktree-pool.test.ts diff --git a/.changeset/worktree-pool-prewarm.md b/.changeset/worktree-pool-prewarm.md new file mode 100644 index 000000000000..eaa9eb410951 --- /dev/null +++ b/.changeset/worktree-pool-prewarm.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Speed up Agent Manager worktree creation by pre-warming reusable worktrees and claiming a ready one instead of running a full checkout. Control the pre-warming in Agent Manager settings under "Pre-warm worktrees"; it is enabled by default and uses one extra checkout of disk space per open project. diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index b8833f3d1788..67b8eb830b0d 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -1070,6 +1070,12 @@ "scope": "application", "description": "Prefix for automatically named Agent Manager branches, for example 'marius/' or 'feature/'. Explicit branch names are unchanged." }, + "kilo-code.new.agentManager.worktreePool": { + "type": "boolean", + "default": true, + "scope": "application", + "description": "Pre-warm a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space equal to one checkout per open project. Turn off to create worktrees only on demand." + }, "kilo-code.new.experimental.multiProject": { "type": "boolean", "default": false, diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 153505f439a7..2790a606cc7b 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -3984,6 +3984,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper browserAutomation: this.browserAutomationSetting(), "agentManager.autoBranchNaming": naming.get("autoBranchNaming", true), "agentManager.branchPrefix": naming.get("branchPrefix", ""), + "agentManager.worktreePool": naming.get("worktreePool", true), "agentManager.pushFixes": pushFixes(), } } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 25ceaf1fa62b..13a3bd42e95a 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -20,6 +20,7 @@ import { removeStaleLifecycleWorktree, type LifecycleHost, } from "./provider-lifecycle" +import { Timing } from "./creation-timing" import { normalizeBaseBranch } from "./base-branch" import { handleBaseUpdate } from "./base-update" import { pushFixes } from "../kilo-provider/push-fixes-settings" @@ -951,6 +952,8 @@ export class AgentManagerProvider implements Disposable { branch: string, worktreeId?: string, source?: { sandboxInheritanceToken?: string }, + boot?: { at: number; metadata: () => Promise> }, + timing?: Timing, ): Promise { let client: KiloClient try { @@ -980,7 +983,9 @@ export class AgentManagerProvider implements Disposable { }) try { - const metadata = await sandboxSessionMetadata(this.connectionService.sandboxPreference, client, worktreePath) + const metadata = await (boot?.metadata() ?? + sandboxSessionMetadata(this.connectionService.sandboxPreference, client, worktreePath)) + if (boot) timing?.mark("boot", boot.at) const { data: session } = await startSession( client, worktreePath, @@ -996,6 +1001,7 @@ export class AgentManagerProvider implements Disposable { ), (...args) => this.log(...args), ) + timing?.mark("session") return session } catch (error) { const err = getErrorMessage(error) @@ -1101,7 +1107,8 @@ export class AgentManagerProvider implements Disposable { } }, setup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id), - createSessionInWorktree: (dir, branch, id, source) => this.createSessionInWorktree(dir, branch, id, source), + createSessionInWorktree: (dir, branch, id, source, boot, timing) => + this.createSessionInWorktree(dir, branch, id, source, boot, timing), sessionMetadata: (client, dir) => sandboxSessionMetadata(this.connectionService.sandboxPreference, client, dir), registerWorktreeSession: (sid, dir) => this.registerWorktreeSession(sid, dir), notifyReady: (sid, result, wid) => this.notifyWorktreeReady(sid, result, wid), @@ -1450,7 +1457,8 @@ export class AgentManagerProvider implements Disposable { return { createOnDisk: (opts) => this.createWorktreeOnDisk(opts), runSetup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id), - createSession: (dir, branch, id) => this.createSessionInWorktree(dir, branch, id), + createSession: (dir, branch, id, boot, timing) => + this.createSessionInWorktree(dir, branch, id, undefined, boot, timing), notifyReady: (sid, result, id) => this.notifyWorktreeReady(sid, result, id), sessions: { register: (session) => this.panel?.sessions.registerSession(session), diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 1d27e9c0f2cb..5d5a52e5788e 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -15,6 +15,7 @@ import { type GitOps, isKiloOwnedSshCommand, nonInteractiveEnv } from "./GitOps" import { execWithShellEnv } from "./shell-env" import { execGhRead } from "./gh" import { markNoIndex } from "../util/spotlight" +import { WorktreePool, type PoolStart } from "./worktree-pool" import { parsePRUrl, localBranchName, @@ -83,6 +84,16 @@ export interface CreateWorktreeResult { startPointWarning?: string } +interface Metadata { + sessionId: string + parentBranch?: string + remote?: string + pooled?: boolean + owner?: number + baseRef?: string + baseOid?: string +} + /** * Backward compat: split a possibly-prefixed branch like "origin/main" into * `{ branch: "main", remote: "origin" }`. If no slash is found, returns bare branch. @@ -106,15 +117,32 @@ export class WorktreeManager { private readonly ops: GitOps | undefined private readonly binary: string private readonly log: (msg: string) => void + private readonly pool: WorktreePool private migrated = false - constructor(root: string, log: (msg: string) => void, ops?: GitOps, binary?: string) { + constructor( + root: string, + log: (msg: string) => void, + ops?: GitOps, + binary?: string, + poolSize: number | (() => number) = 1, + ) { this.root = root this.dir = path.join(root, KILO_DIR, "worktrees") this.ops = ops this.binary = binary ?? ops?.path ?? "git" this.git = this.client(root) this.log = log + this.pool = new WorktreePool({ + root, + dir: this.dir, + poolSize, + log, + client: (cwd) => this.client(cwd), + lock: (fn) => this.withGitLock(fn), + gitdir: (wtPath) => this.worktreeGitDir(wtPath), + start: (base) => this.poolStart(base), + }) } /** Run once before first read/write to migrate Agent Manager data from .kilocode → .kilo. */ @@ -186,6 +214,94 @@ export class WorktreeManager { await this.withGitLock(() => this.refreshBase(base)) } + /** + * Fire-and-forget warm-up of pooled worktrees. Idempotent, at most one warm + * in flight, and never blocks callers. `poolSize` 0 disables the pool. + */ + warmPool(base?: string): void { + this.pool.warm(base) + } + + /** Adopt leftover pooled slots at startup and discard broken ones. */ + async reconcilePool(): Promise { + await this.ensureMigrated() + await this.ensureDir() + await this.ensureGitExclude() + return this.pool.reconcile() + } + + /** Remove idle pooled slots when the feature is turned off. */ + async disposePool(): Promise { + return this.pool.dispose() + } + + private async poolStart(base?: string): Promise { + const branch = base || (await this.defaultBranch()) + const point = await this.resolveStartPoint(branch) + return { ref: point.ref, branch: point.branch, remote: point.remote } + } + + /** + * Run independent preflight checks in parallel. Validates the requested ref, + * confirms commits exist for an explicit base, and resolves LFS and remote. + */ + private async preflight( + params: { existingBranch?: string; branchName?: string; baseBranch?: string }, + requested: string | undefined, + ): Promise<{ resolvedRemote: string | undefined }> { + // Validate the literal ref first so --branch cannot expand checkout shorthand. + const refFormat = + requested === undefined + ? Promise.resolve() + : Promise.all([ + this.git.raw(["check-ref-format", `refs/heads/${requested}`]), + this.git.raw(["check-ref-format", "--branch", requested]), + ]) + // An explicit base branch skips defaultBranch(), so check repository state here. + const commit = params.baseBranch ? this.ensureCommit() : Promise.resolve() + const [, , usesLfs, resolvedRemote] = await Promise.all([ + refFormat, + commit, + this.repoUsesLfs(), + this.resolveRemote(), + ]) + if (usesLfs && !(await this.checkLfsAvailable())) { + throw new Error( + "This repository uses Git LFS, but git-lfs was not found. Please install Git LFS to use this repository.", + ) + } + return { resolvedRemote } + } + + /** Claim a pooled slot for a new branch and schedule a replacement warm-up. */ + private async tryClaimPool( + branch: string, + oid: string, + auto: boolean, + base?: string, + ): Promise<{ path: string; branch: string } | undefined> { + const slot = await this.pool.claim(branch, oid, auto) + if (!slot) return undefined + setTimeout(() => this.pool.warm(base), 0) + + // Keep the folder name aligned with the branch, as the normal path does. + const target = path.join(this.dir, directory(slot.branch)) + if (target === slot.path || fs.existsSync(target)) { + this.log(`Reused pooled worktree: ${slot.path} (branch: ${slot.branch})`) + return slot + } + const moved = await this.git + .raw(["worktree", "move", slot.path, target]) + .then(() => true) + .catch((error: unknown) => { + this.log(`Pooled worktree move failed, keeping ${slot.path}: ${error}`) + return false + }) + const result = moved ? { path: target, branch: slot.branch } : slot + this.log(`Reused pooled worktree: ${result.path} (branch: ${result.branch})`) + return result + } + async renameBranch(worktreePath: string, current: string, requested: string): Promise { await this.ensureMigrated() return this.withGitLock(() => this.renameBranchImpl(worktreePath, current, requested)) @@ -228,13 +344,14 @@ export class WorktreeManager { } private async ensureCommit(): Promise { - try { - const commit = await this.git.raw(["rev-list", "-n", "1", "--all"]) - if (!commit.trim()) throw new Error("No commits found") - } catch (error) { - this.log(`ensureCommit: ${error}`) - throw new Error(NO_COMMITS_MESSAGE) - } + // Fast path: HEAD resolves to a commit in the common case. + const head = await this.git.raw(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]).catch(() => "") + if (head.trim()) return + + // HEAD can be unborn while other refs still hold commits (orphan checkout), + // so only treat the repository as empty when no ref has a commit. + const any = await this.git.raw(["rev-list", "-n", "1", "--all"]).catch(() => "") + if (!any.trim()) throw new Error(NO_COMMITS_MESSAGE) } private async createWorktreeImpl(params: { @@ -253,27 +370,8 @@ export class WorktreeManager { ) const requested = params.existingBranch ?? params.branchName - if (requested !== undefined) { - // Validate the literal ref first so --branch cannot expand checkout shorthand. - await this.git.raw(["check-ref-format", `refs/heads/${requested}`]) - await this.git.raw(["check-ref-format", "--branch", requested]) - } - - // An explicit base branch skips defaultBranch(), so check the repository - // state here before trying to resolve a start point. - if (params.baseBranch) await this.ensureCommit() - - // Git LFS Pre-flight Check - if (await this.repoUsesLfs()) { - if (!(await this.checkLfsAvailable())) { - throw new Error( - "This repository uses Git LFS, but git-lfs was not found. Please install Git LFS to use this repository.", - ) - } - } - - await this.ensureDir() - await this.ensureGitExclude() + const { resolvedRemote } = await this.preflight(params, requested) + await Promise.all([this.ensureDir(), this.ensureGitExclude()]) // Resolve start point (parent branch + remote) let parent: string @@ -283,14 +381,13 @@ export class WorktreeManager { if (params.existingBranch) { // Existing branch provided directly — only attach remote when the // remote tracking ref actually exists (the branch may be local-only). - const remote = await this.resolveRemote() - const hasRemoteRef = remote && (await this.refExistsLocally(`${remote}/${params.existingBranch}`)) + const hasRemoteRef = resolvedRemote && (await this.refExistsLocally(`${resolvedRemote}/${params.existingBranch}`)) parent = params.existingBranch - parentRemote = hasRemoteRef ? remote : undefined + parentRemote = hasRemoteRef ? resolvedRemote : undefined startPoint = { ref: params.existingBranch, branch: params.existingBranch, - remote: hasRemoteRef ? remote : undefined, + remote: hasRemoteRef ? resolvedRemote : undefined, source: "local-branch", } } else { @@ -308,7 +405,30 @@ export class WorktreeManager { parentRemote = startPoint.remote } - let branch = await this.resolveBranch(params) + // Dereference to commit SHA to prevent upstream tracking for new branches + const startRef = params.existingBranch ? undefined : `${params.baseRef ?? startPoint.ref}^{commit}` + + // Resolve the pool base commit alongside the branch name; both are read-only. + const [resolved, oid] = await Promise.all([ + this.resolveBranch(params), + startRef && this.pool.has() ? this.git.raw(["rev-parse", "--verify", startRef]).then((s) => s.trim()) : undefined, + ]) + let branch = resolved + + const slot = oid + ? await this.tryClaimPool(branch, oid, params.branchName === undefined, params.baseBranch) + : undefined + if (slot) { + return { + branch: slot.branch, + path: slot.path, + parentBranch: parent, + remote: parentRemote, + startPointSource: startPoint.source, + startPointWarning: startPoint.warning, + } + } + const dirName = directory(branch) let worktreePath = path.join(this.dir, dirName) @@ -316,9 +436,6 @@ export class WorktreeManager { params.onProgress?.("creating", `Creating worktree for ${branch}...`) - // Dereference to commit SHA to prevent upstream tracking for new branches - const startRef = params.existingBranch ? undefined : `${params.baseRef ?? startPoint.ref}^{commit}` - try { const args = params.existingBranch ? ["worktree", "add", worktreePath, branch] @@ -460,7 +577,7 @@ export class WorktreeManager { ) return undefined }) - await this.git.raw(workers?.value === null ? ["-c", "checkout.workers=2", ...args] : args) + await this.git.raw(workers?.value === null ? ["-c", "checkout.workers=4", ...args] : args) } catch (error) { const msg = error instanceof Error ? error.message : String(error) if (this.isHookError(msg) && (await this.worktreeRegistered(wtPath))) { @@ -527,6 +644,8 @@ export class WorktreeManager { return } + this.pool.release(worktreePath) + // 1. Atomic rename — makes the worktree instantly invisible to git and pollers. // rename() is near-instant on the same filesystem (same parent dir guarantees this). const temp = path.join(path.dirname(worktreePath), `.kilo-delete-${randomUUID()}`) @@ -610,9 +729,7 @@ export class WorktreeManager { this.log(`Wrote metadata for session ${sessionId} to ${worktreePath}`) } - async readMetadata( - worktreePath: string, - ): Promise<{ sessionId: string; parentBranch?: string; remote?: string } | undefined> { + async readMetadata(worktreePath: string): Promise { const current = await this.readCurrentMetadata(worktreePath) if (current) return current @@ -624,14 +741,23 @@ export class WorktreeManager { return undefined } - private async readCurrentMetadata( - worktreePath: string, - ): Promise<{ sessionId: string; parentBranch?: string; remote?: string } | undefined> { + private async readCurrentMetadata(worktreePath: string): Promise { try { const file = await this.gitMetadataPath(worktreePath) if (!file) return undefined const content = await fs.promises.readFile(file, "utf-8") - const data = JSON.parse(content) as { sessionId?: string; parentBranch?: string; remote?: string } + const data = JSON.parse(content) as Partial + if (data.pooled) { + return { + sessionId: data.sessionId ?? "", + pooled: true, + owner: data.owner, + baseRef: data.baseRef, + baseOid: data.baseOid, + parentBranch: data.parentBranch, + remote: data.remote, + } + } if (!data.sessionId) return undefined return { sessionId: data.sessionId, @@ -652,7 +778,12 @@ export class WorktreeManager { private async worktreeGitDir(worktreePath: string): Promise { const gitPath = path.join(worktreePath, ".git") - const stat = await fs.promises.stat(gitPath) + let stat: fs.Stats + try { + stat = await fs.promises.stat(gitPath) + } catch { + return undefined + } if (stat.isDirectory()) return gitPath if (!stat.isFile()) return undefined @@ -662,10 +793,7 @@ export class WorktreeManager { return path.resolve(worktreePath, match[1].trim()) } - private async readMetadataFrom( - worktreePath: string, - dirName: string, - ): Promise<{ sessionId: string; parentBranch?: string; remote?: string } | undefined> { + private async readMetadataFrom(worktreePath: string, dirName: string): Promise { const dir = path.join(worktreePath, dirName) // Try metadata.json first (has parentBranch + remote) @@ -783,6 +911,8 @@ export class WorktreeManager { fs.promises.stat(wtPath), this.readMetadata(wtPath), ]) + // Pooled slots are internal warm-up worktrees, not user sessions. + if (meta?.pooled) return undefined // Use persisted metadata if available, fall back to resolveBaseBranch. // Backward compat: old metadata may store "origin/main" in parentBranch without // a separate remote field. Try to detect this by checking if the prefix is a known remote. diff --git a/packages/kilo-vscode/src/agent-manager/creation-timing.ts b/packages/kilo-vscode/src/agent-manager/creation-timing.ts new file mode 100644 index 000000000000..4b488e1614f5 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/creation-timing.ts @@ -0,0 +1,73 @@ +/** Structured timing for worktree creation flows. */ + +export type TimingLog = (...args: unknown[]) => void +export type TimingClock = () => number + +export interface Span { + label: string + total: number + phases: Record + line: string +} + +/** + * Lap timer for worktree creation. Call `mark(phase)` when a phase finishes to + * record the elapsed time since the previous mark, or pass a `start` reading to + * record a span that began earlier (used for overlapped work). `end()` writes + * one line through the provided logger and returns the same numbers. + */ +export class Timing { + static start(label: string, log?: TimingLog, clock: TimingClock = () => performance.now()): Timing { + return new Timing(label, log, clock) + } + + private readonly spans: Record = {} + private readonly order: string[] = [] + private readonly startAt: number + private last: number + + private constructor( + private readonly label: string, + private readonly log: TimingLog | undefined, + private readonly clock: TimingClock, + ) { + this.startAt = this.clock() + this.last = this.startAt + } + + /** Current reading of the timing clock, for spans that start before a mark. */ + now(): number { + return this.clock() + } + + mark(phase: string, start?: number): void { + const at = this.clock() + this.spans[phase] = (this.spans[phase] ?? 0) + (at - (start ?? this.last)) + this.order.push(phase) + this.last = at + } + + result(): Span { + const total = Math.round(this.clock() - this.startAt) + const phases: Record = {} + const parts: string[] = [] + for (const phase of this.order) { + const value = Math.round(this.spans[phase]!) + phases[phase] = value + parts.push(`${phase}=${value}`) + } + const suffix = parts.length > 0 ? ` ${parts.join(" ")}` : "" + return { + label: this.label, + total, + phases, + line: `[agent-manager] ${this.label} total=${total}ms${suffix}`, + } + } + + end(): Span { + const span = this.result() + this.log?.(span.line) + return span + } +} diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index ee8f70f533e2..388f391139e9 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -136,6 +136,12 @@ export interface Host { multiProject(): boolean browserAutomation(): boolean + /** Whether background worktree pre-warming is enabled. */ + worktreePool(): boolean + + /** Listen for changes to the worktree pre-warming setting. */ + onDidChangeWorktreePool(cb: (enabled: boolean) => void): Disposable + /** Read the persisted additional-project registry payload. */ readProjects(): unknown diff --git a/packages/kilo-vscode/src/agent-manager/project/context.ts b/packages/kilo-vscode/src/agent-manager/project/context.ts index ae39181bfc95..6fac6f239bee 100644 --- a/packages/kilo-vscode/src/agent-manager/project/context.ts +++ b/packages/kilo-vscode/src/agent-manager/project/context.ts @@ -29,6 +29,8 @@ export interface ProjectContextDeps { log: (msg: string) => void git?: GitOps exists?: (dir: string) => boolean + /** Whether background worktree pre-warming is enabled for this project. */ + worktreePool?: () => boolean /** Factory overrides for tests. */ state?: (root: string, log: (msg: string) => void) => WorktreeStateManager worktrees?: (root: string, log: (msg: string) => void, git?: GitOps) => WorktreeManager @@ -134,11 +136,11 @@ export class ProjectContext { } worktreeManager(): WorktreeManager { - this.worktrees ??= (this.deps.worktrees ?? ((root, log, git) => new WorktreeManager(root, log, git)))( - this.root, - (msg) => this.deps.log(`[WorktreeManager] ${msg}`), - this.deps.git, - ) + this.worktrees ??= ( + this.deps.worktrees ?? + ((root, log, git) => + new WorktreeManager(root, log, git, undefined, () => (this.deps.worktreePool?.() === false ? 0 : 1))) + )(this.root, (msg) => this.deps.log(`[WorktreeManager] ${msg}`), this.deps.git) return this.worktrees } diff --git a/packages/kilo-vscode/src/agent-manager/project/init.ts b/packages/kilo-vscode/src/agent-manager/project/init.ts index 307dfc34e450..3a21033ae655 100644 --- a/packages/kilo-vscode/src/agent-manager/project/init.ts +++ b/packages/kilo-vscode/src/agent-manager/project/init.ts @@ -92,6 +92,11 @@ export async function initContextState( await state.flush() } } + // Adopt or clean leftover pooled slots, then pre-warm one off the click path. + void manager + .reconcilePool() + .then(() => manager.warmPool()) + .catch((err) => log("Failed to reconcile worktree pool:", err)) return { ok: true, refsFixed: loaded.refsFixed } }) } diff --git a/packages/kilo-vscode/src/agent-manager/project/wiring.ts b/packages/kilo-vscode/src/agent-manager/project/wiring.ts index 2ae51fb0c8fa..181b09e8411a 100644 --- a/packages/kilo-vscode/src/agent-manager/project/wiring.ts +++ b/packages/kilo-vscode/src/agent-manager/project/wiring.ts @@ -60,7 +60,7 @@ export function createProjectWiring(opts: { opts.host.unregisterProjectRoutes(id) opts.removed?.(id) }, - deps: { log: opts.output, git: opts.git }, + deps: { log: opts.output, git: opts.git, worktreePool: () => opts.host.worktreePool() }, }) const messages: ProjectMessageDeps = { registry, @@ -95,6 +95,14 @@ export function createProjectWiring(opts: { opts.push() opts.pushState() }), + opts.host.onDidChangeWorktreePool((enabled) => { + for (const project of contexts.snapshots()) { + const manager = contexts.get(project.id)?.peekWorktrees() + if (!manager) continue + if (enabled) manager.warmPool() + else manager.disposePool().catch((err) => opts.log("Failed to clear worktree pool:", err)) + } + }), ] return { registry, diff --git a/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts b/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts index 99ef9e2ad6ce..ee9643a2d7fd 100644 --- a/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts +++ b/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts @@ -11,6 +11,26 @@ import type { CreateWorktreeOnDiskOptions, CreateWorktreeOnDiskResult } from "./ import { recordPromotionHandoff } from "./promotion-handoff" import { stopSessionProcesses } from "../kilo-provider/background-process" import { routeProjectSession } from "./project/messages" +import { Timing } from "./creation-timing" + +/** A backend-instance boot that started before setup, awaited before session creation. */ +export interface CreationBoot { + /** Timing clock reading captured when the boot request started. */ + at: number + metadata: () => Promise> +} + +/** + * Start a directory boot without blocking its caller. The error is retained and + * rethrown when `metadata` is awaited, so a setup failure before that point + * cannot leave an unhandled rejection. + */ +export function beginBoot(start: () => Promise>, timing?: Timing): CreationBoot { + const at = timing?.now() ?? performance.now() + const pending = (async () => start())() + void pending.catch(() => undefined) + return { at, metadata: () => pending } +} /** * Provider capabilities the worktree lifecycle needs beyond project state. @@ -22,7 +42,13 @@ import { routeProjectSession } from "./project/messages" export interface LifecycleHost { createOnDisk: (opts?: CreateWorktreeOnDiskOptions) => Promise runSetup: (dir: string, branch: string, id: string) => Promise - createSession: (dir: string, branch: string, id: string) => Promise + createSession: ( + dir: string, + branch: string, + id: string, + boot?: CreationBoot, + timing?: Timing, + ) => Promise notifyReady: (sessionId: string, result: CreateWorktreeResult, worktreeId?: string) => void sessions: { register: (session: Session) => void @@ -63,21 +89,42 @@ export async function createLifecycleWorktree( host: LifecycleHost, opts: { baseBranch?: string; branchName?: string }, ): Promise { + const timing = Timing.start(`create ${opts.branchName ?? "worktree"}`, host.log) + await initContextState(ctx, host.log) + timing.mark("context") const created = await host.createOnDisk({ baseBranch: opts.baseBranch, branchName: opts.branchName }) - if (!created) return null + timing.mark("create") + if (!created) { + timing.end() + return null + } + + // Boot the new directory's backend instance at once, concurrently with the + // .env copy and setup script. Session creation and MCP warmup still wait for + // setup to finish because plugins may depend on installed files. + const boot = beginBoot(() => host.metadata(host.client(), created.result.path), timing) // Run setup script for new worktree (blocks until complete, shows in overlay) await host.runSetup(created.result.path, created.result.branch, created.worktree.id) + timing.mark("setup") - const session = await host.createSession(created.result.path, created.result.branch, created.worktree.id) + const session = await host.createSession( + created.result.path, + created.result.branch, + created.worktree.id, + boot, + timing, + ) if (!session) { let releasePtyCleanup: () => void try { releasePtyCleanup = await host.acquirePtyCleanup(created.result.path) } catch (error) { host.log("Failed to remove worktree PTYs:", error) + timing.mark("cleanup") + timing.end() return null } try { @@ -89,6 +136,8 @@ export async function createLifecycleWorktree( } finally { releasePtyCleanup() } + timing.mark("cleanup") + timing.end() return null } @@ -96,15 +145,20 @@ export async function createLifecycleWorktree( state.addSession(session.id, created.worktree.id) if (!opts.branchName && host.autoName().enabled) state.armAutoName(created.worktree.id, session.id) host.register(session.id, created.result.path) + timing.mark("state") // Push state before registerSession so the webview's sessionCreated handler // sees the worktree mapping and routes the session to the worktree tab. host.notifyReady(session.id, created.result, created.worktree.id) host.sessions.register(session) + timing.mark("ready") + const span = timing.end() host.capture("Agent Manager Session Started", { source: PLATFORM, sessionId: session.id, worktreeId: created.worktree.id, branch: created.result.branch, + durationMs: span.total, + ...span.phases, }) host.log(`Created worktree ${created.worktree.id} with session ${session.id}`) return null diff --git a/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts b/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts index 21856506d298..9ab63f56d39c 100644 --- a/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts +++ b/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts @@ -5,7 +5,8 @@ import type { AgentManagerInMessage } from "./types" import { sanitizeBranchName, versionedName } from "./branch-name" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" import { ensureSandbox } from "./sandbox-bootstrap" -import type { LifecycleHost } from "./provider-lifecycle" +import { beginBoot, type LifecycleHost } from "./provider-lifecycle" +import { Timing } from "./creation-timing" import { Semaphore } from "./semaphore" import type { WorktreeCreationFailure } from "./worktree-create" @@ -188,16 +189,23 @@ async function provisionVersion( prepared: PreparedVersion, ): Promise { const { spec, wt } = prepared + const timing = Timing.start(`create ${wt.result.branch} v${spec.index + 1}`, host.log) + // Boot the new directory concurrently with the setup script. Session creation + // and MCP warmup still wait for setup, which may install files plugins need. + const boot = beginBoot(() => host.metadata(host.client(), wt.result.path), timing) await host.runSetup(wt.result.path, wt.result.branch, wt.worktree.id) + timing.mark("setup") - const session = await host.createSession(wt.result.path, wt.result.branch, wt.worktree.id) + const session = await host.createSession(wt.result.path, wt.result.branch, wt.worktree.id, boot, timing) if (!session) { let releasePtyCleanup: () => void try { releasePtyCleanup = await host.acquirePtyCleanup(wt.result.path) } catch (error) { host.log("Failed to remove worktree PTYs:", error) + timing.mark("cleanup") + timing.end() return null } try { @@ -210,6 +218,8 @@ async function provisionVersion( releasePtyCleanup() } host.log(`Failed to create session for version ${spec.index + 1}`) + timing.mark("cleanup") + timing.end() return null } @@ -218,14 +228,20 @@ async function provisionVersion( if (!spec.branchName && !spec.worktreeName && host.autoName().enabled) { state.armAutoName(wt.worktree.id, session.id) } + timing.mark("state") // Sandbox must match the user's choice before this session is exposed or // receives its initial prompt. A failed reconciliation aborts this version. - if (spec.sandbox !== undefined && !(await reconcileSandbox(host, spec, wt, session.id))) return null + if (spec.sandbox !== undefined && !(await reconcileSandbox(host, spec, wt, session.id))) { + timing.mark("cleanup") + timing.end() + return null + } host.register(session.id, wt.result.path) host.notifyReady(session.id, wt.result, wt.worktree.id) host.sessions.register(session) + timing.mark("ready") // Set the per-version model immediately so the UI selector reflects // the correct model as soon as the worktree appears, before Phase 2. @@ -243,6 +259,7 @@ async function provisionVersion( }) } + const span = timing.end() host.capture("Agent Manager Session Started", { source: PLATFORM, sessionId: session.id, @@ -252,6 +269,8 @@ async function provisionVersion( version: spec.index + 1, totalVersions: spec.versions, groupId: spec.groupId, + durationMs: span.total, + ...span.phases, }) host.log(`Version ${spec.index + 1} worktree ready: session=${session.id}`) diff --git a/packages/kilo-vscode/src/agent-manager/tool-start.ts b/packages/kilo-vscode/src/agent-manager/tool-start.ts index 8dba518780cb..f5804b3dba04 100644 --- a/packages/kilo-vscode/src/agent-manager/tool-start.ts +++ b/packages/kilo-vscode/src/agent-manager/tool-start.ts @@ -6,6 +6,8 @@ import type { PanelContext } from "./host" import { PLATFORM, SNAPSHOT_INITIALIZATION } from "./constants" import { sameDirectory } from "../kilo-provider-utils" import { attribute } from "./prompt-attribution" +import { beginBoot, type CreationBoot } from "./provider-lifecycle" +import { Timing } from "./creation-timing" const LABEL_MAX = 28 const PREFIX = new Set(["feat", "fix", "chore", "bug", "issue", "task", "branch"]) @@ -56,7 +58,14 @@ export interface ToolDeps { claimRequest?: (requestID: string) => boolean cleanupWorktree: (wid: string, dir: string) => Promise setup: (dir: string, branch?: string, id?: string) => Promise - createSessionInWorktree: (dir: string, branch: string, id?: string, source?: ToolSource) => Promise + createSessionInWorktree: ( + dir: string, + branch: string, + id?: string, + source?: ToolSource, + boot?: CreationBoot, + timing?: Timing, + ) => Promise sessionMetadata: (client: KiloClient, dir: string) => Promise> registerWorktreeSession: (sid: string, dir: string) => void notifyReady: (sid: string, result: CreateWorktreeResult, wid?: string) => void @@ -198,42 +207,62 @@ async function worktree( const baseBranch = task.branchName ?? branch(task.name) const baseLabel = label(task.name) ?? label(task.branchName) ?? label(task.prompt) const version = versionedName(baseBranch, versions ? index : 0, versions ? total : 1) + const timing = Timing.start(`create ${version.branch ?? "worktree"}`, deps.log) const created = await deps.createWorktree({ groupId, branchName: version.branch, name: version.branch, label: versionedLabel(baseLabel, versions ? index : 0, versions ? total : 1), }) - if (!created) return false + timing.mark("create") + if (!created) { + timing.end() + return false + } + // Boot the new directory while the setup script runs. Session creation and + // MCP warmup still wait for setup, which may install files plugins need. + const boot = beginBoot(() => deps.sessionMetadata(client, created.result.path), timing) await deps.setup(created.result.path, created.result.branch, created.worktree.id) + timing.mark("setup") const session = await deps.createSessionInWorktree( created.result.path, created.result.branch, created.worktree.id, source, + boot, + timing, ) if (!session) { await deps.cleanupWorktree(created.worktree.id, created.result.path) + timing.mark("cleanup") + timing.end() return false } const state = deps.getState() if (!state) { await deps.cleanupWorktree(created.worktree.id, created.result.path) + timing.mark("cleanup") + timing.end() return false } state.addSession(session.id, created.worktree.id) deps.registerWorktreeSession(session.id, created.result.path) + timing.mark("state") deps.notifyReady(session.id, created.result, created.worktree.id) deps.getPanel()?.sessions.registerSession(session) + timing.mark("ready") await prompt(client, session.id, created.result.path, task, source) + const span = timing.end() deps.capture("Agent Manager Session Started", { source: PLATFORM, sessionId: session.id, worktreeId: created.worktree.id, branch: created.result.branch, tool: true, + durationMs: span.total, + ...span.phases, }) return true } diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index c07d26f7709b..a16104f21e7d 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -284,6 +284,10 @@ export class VscodeHost implements Host { return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("browserAutomation", false) } + worktreePool(): boolean { + return vscode.workspace.getConfiguration("kilo-code.new.agentManager").get("worktreePool", true) + } + readProjects(): unknown { return this.context.globalState.get("agentManager.projects") } @@ -318,6 +322,12 @@ export class VscodeHost implements Host { }) } + onDidChangeWorktreePool(cb: (enabled: boolean) => void): Disposable { + return vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("kilo-code.new.agentManager.worktreePool")) cb(this.worktreePool()) + }) + } + isTrusted(): boolean { return vscode.workspace.isTrusted } diff --git a/packages/kilo-vscode/src/agent-manager/worktree-pool.ts b/packages/kilo-vscode/src/agent-manager/worktree-pool.ts new file mode 100644 index 000000000000..577d027314cb --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/worktree-pool.ts @@ -0,0 +1,426 @@ +/** + * WorktreePool - Pre-creates detached git worktrees so new Agent Manager + * sessions can claim a ready worktree instead of paying the full + * `git worktree add` checkout cost. + * + * Slots are created at their final path under `.kilo/worktrees/` and tagged + * with pooled metadata. A later claim turns a + * slot into a named branch with a cheap ref update (exact match) or a bounded + * checkout (small delta). This module is vscode-free so it can be tested with a + * real temporary git repository. + */ + +import * as path from "path" +import * as fs from "fs" +import type { SimpleGit } from "simple-git" +import { generateBranchName } from "./branch-name" +import { normalizePath, parseWorktreeList } from "./git-import" + +const METADATA_FILE = "kilo-agent-manager-metadata.json" +/** Maximum commits between a slot base and the requested base for a delta claim. */ +const MAX_DELTA = 50 + +export interface PoolStart { + ref: string + branch: string + remote?: string +} + +export interface PoolDeps { + root: string + dir: string + /** Target slot count. A function is read live so a settings change applies without a restart. */ + poolSize: number | (() => number) + log: (msg: string) => void + client: (cwd: string) => SimpleGit + lock: (fn: () => Promise) => Promise + /** Resolve the git directory for a worktree so pool metadata can be written. */ + gitdir: (wtPath: string) => Promise + /** Cache-aware start point resolution. Must not force a fresh network fetch. */ + start: (base?: string) => Promise +} + +interface PoolSlot { + path: string + baseRef: string + baseOid: string + ready: Promise + refreshed: boolean +} + +interface PoolMeta { + pooled?: boolean + owner?: number + baseRef?: string + baseOid?: string +} + +export class WorktreePool { + private readonly deps: PoolDeps + private slots: PoolSlot[] = [] + private warming = false + + /** Current target size, read live when configured with a function. */ + private size(): number { + return typeof this.deps.poolSize === "function" ? this.deps.poolSize() : this.deps.poolSize + } + + constructor(deps: PoolDeps) { + this.deps = deps + } + + /** + * Fire-and-forget warm-up. Idempotent and at most one warm runs at a time. + * Never forces a fresh network fetch: start resolution reuses the manager's + * 60 s fetch cache. + */ + warm(base?: string): void { + if (this.size() <= 0 || this.warming) return + this.warming = true + queueMicrotask(() => { + void this.deps + .lock(() => this.fill(base)) + .catch((e) => this.deps.log(`worktree pool: warm failed: ${e}`)) + .finally(() => { + this.warming = false + }) + }) + } + + /** + * Claim a ready slot for a new branch. Runs while the caller already holds + * the git lock. Returns the slot path on success, or undefined to fall back + * to a normal `git worktree add`. + */ + async claim(branch: string, oid: string, auto = false): Promise<{ path: string; branch: string } | undefined> { + if (!this.has()) return undefined + const exact = this.slots.find((slot) => slot.baseOid === oid) + if (exact) return this.take(exact, branch, oid, true, auto) + const delta = await this.findDelta(oid) + if (!delta) return undefined + return this.take(delta, branch, oid, false, auto) + } + + /** True when at least one slot is available. Pure in-memory check. */ + has(): boolean { + return this.size() > 0 && this.slots.length > 0 + } + + /** Adopt leftover pooled slots from a previous run and discard broken ones. */ + async reconcile(): Promise { + await this.deps.lock(() => this.adopt()) + } + + /** Remove every idle slot, used when the feature is turned off in settings. */ + async dispose(): Promise { + await this.deps.lock(async () => { + const slots = this.slots + this.slots = [] + for (const slot of slots) await this.removePath(slot.path) + }) + } + + /** Forget a slot so the normal removal path can clean it up. */ + release(wtPath: string): void { + this.slots = this.slots.filter((slot) => normalizePath(slot.path) !== normalizePath(wtPath)) + } + + private async fill(base?: string): Promise { + if (this.size() <= 0) return + await fs.promises.mkdir(this.deps.dir, { recursive: true }) + const point = await this.deps.start(base) + const oid = (await this.deps.client(this.deps.root).raw(["rev-parse", "--verify", `${point.ref}^{commit}`])).trim() + + await this.prune() + await this.retarget(point, oid) + const missing = this.size() - this.slots.length + if (missing <= 0) return + + const names = await this.dirNames() + for (let i = 0; i < missing; i++) { + const slot = await this.build(point, oid, names) + if (!slot) continue + names.push(path.basename(slot.path)) + this.slots.push(slot) + } + } + + private async build(point: PoolStart, oid: string, names: string[]): Promise { + const name = generateBranchName("pool", names) + const slotPath = path.join(this.deps.dir, name) + const ok = await this.attempt(async () => { + await this.raw(["worktree", "add", "--detach", slotPath, oid]) + await this.writeMeta(slotPath, { pooled: true, owner: process.pid, baseRef: point.ref, baseOid: oid }) + }, `create slot ${slotPath}`) + if (!ok) { + await this.removePath(slotPath) + return undefined + } + + const slot: PoolSlot = { + path: slotPath, + baseRef: point.ref, + baseOid: oid, + ready: Promise.resolve(oid), + refreshed: false, + } + this.refresh(slot) + return slot + } + + private refresh(slot: PoolSlot): void { + void Promise.resolve() + .then(() => this.deps.client(slot.path).raw(["status", "--porcelain"])) + .then(() => { + slot.refreshed = true + }) + .catch((e) => this.deps.log(`worktree pool: status refresh failed for ${slot.path}: ${e}`)) + } + + private async take( + slot: PoolSlot, + requested: string, + oid: string, + exact: boolean, + auto: boolean, + ): Promise<{ path: string; branch: string } | undefined> { + const git = this.deps.client(slot.path) + // For generated names, reuse the slot directory name as the branch so the + // worktree folder and branch keep matching, as they do without the pool. + // Try the slot name first; if that branch already exists, use the requested one. + const name = path.basename(slot.path) + const own = auto && name !== requested + const make = (branch: string) => + exact + ? this.attempt(() => git.raw(["branch", branch, "HEAD"]), `branch ${branch}`) + : this.attempt(() => git.raw(["checkout", "-b", branch, oid]), `checkout ${branch}`) + const first = own && (await make(name)) + const branch = first ? name : requested + const made = first || (await make(requested)) + if (!made) { + await this.discard(slot) + return undefined + } + + if (exact) { + const linked = await this.attempt( + () => git.raw(["symbolic-ref", "HEAD", `refs/heads/${branch}`]), + `symbolic-ref ${branch}`, + ) + if (!linked) { + await this.deleteBranch(branch) + await this.discard(slot) + return undefined + } + } + + await this.attempt(() => this.clearMeta(slot.path), `clear metadata ${slot.path}`) + this.slots = this.slots.filter((known) => known !== slot) + return { path: slot.path, branch } + } + + private async findDelta(oid: string): Promise { + const ordered = [...this.slots].sort((a, b) => Number(b.refreshed) - Number(a.refreshed)) + for (const slot of ordered) { + if (!(await this.withinDelta(slot.baseOid, oid))) continue + return slot + } + return undefined + } + + private async withinDelta(from: string, to: string): Promise { + const ok = await this.attemptValue(async () => { + const raw = await this.raw(["rev-list", "--count", `${from}..${to}`]) + return parseInt(raw.trim(), 10) <= MAX_DELTA + }, `rev-list ${from}..${to}`) + return ok === true + } + + private async adopt(): Promise { + if (!fs.existsSync(this.deps.dir)) return + const known = new Set(this.slots.map((slot) => normalizePath(slot.path))) + const entries = await fs.promises.readdir(this.deps.dir, { withFileTypes: true }) + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".kilo-delete-")) continue + const slotPath = path.join(this.deps.dir, entry.name) + if (known.has(normalizePath(slotPath))) continue + const meta = await this.readMeta(slotPath) + if (!meta?.pooled) continue + if (meta.owner !== process.pid && this.alive(meta.owner)) continue + // Turning the feature off must clean slots owned by this or a dead process. + if (this.size() <= 0) { + await this.removePath(slotPath) + continue + } + const usable = meta.baseOid !== undefined && (await this.registered(slotPath)) + if (!usable || this.slots.length >= this.size()) { + await this.removePath(slotPath) + continue + } + await this.writeMeta(slotPath, { + pooled: true, + owner: process.pid, + baseRef: meta.baseRef, + baseOid: meta.baseOid, + }) + this.slots.push({ + path: slotPath, + baseRef: meta.baseRef ?? "", + baseOid: meta.baseOid!, + ready: Promise.resolve(meta.baseOid!), + refreshed: false, + }) + } + } + + /** Move stale slots to the current base so a later claim stays an exact match. */ + private async retarget(point: PoolStart, oid: string): Promise { + for (const slot of [...this.slots]) { + if (slot.baseOid === oid) continue + const ok = await this.attempt( + () => this.deps.client(slot.path).raw(["checkout", "--detach", oid]), + `retarget ${slot.path}`, + ) + if (!ok) { + await this.discard(slot) + continue + } + slot.baseOid = oid + slot.baseRef = point.ref + slot.refreshed = false + await this.writeMeta(slot.path, { pooled: true, owner: process.pid, baseRef: point.ref, baseOid: oid }) + this.refresh(slot) + } + } + + private async prune(): Promise { + const alive: PoolSlot[] = [] + for (const slot of this.slots) { + if (await this.registered(slot.path)) { + alive.push(slot) + continue + } + await this.removePath(slot.path) + } + this.slots = alive + } + + private async registered(wtPath: string): Promise { + if (!fs.existsSync(path.join(wtPath, ".git"))) return false + const raw = await this.raw(["worktree", "list", "--porcelain"]).catch((e) => { + this.deps.log(`worktree pool: worktree list failed: ${e}`) + return "" + }) + const target = await this.canonical(wtPath) + for (const entry of parseWorktreeList(raw)) { + if ((await this.canonical(entry.path)) === target) return true + } + return false + } + + /** Resolve symlinked temp paths (macOS /var) before comparing worktree paths. */ + private async canonical(target: string): Promise { + return fs.promises.realpath(target).catch(() => normalizePath(target)) + } + + private async discard(slot: PoolSlot): Promise { + this.slots = this.slots.filter((known) => known !== slot) + await this.removePath(slot.path) + } + + private async removePath(wtPath: string): Promise { + await this.raw(["worktree", "remove", "--force", "--force", wtPath]).catch((e) => { + this.deps.log(`worktree pool: remove failed for ${wtPath}: ${e}`) + }) + if (fs.existsSync(wtPath)) { + await fs.promises.rm(wtPath, { recursive: true, force: true }).catch((e) => { + this.deps.log(`worktree pool: rm failed for ${wtPath}: ${e}`) + }) + } + await this.raw(["worktree", "prune", "--expire", "now"]).catch((e) => { + this.deps.log(`worktree pool: prune failed: ${e}`) + }) + } + + private async deleteBranch(branch: string): Promise { + await this.raw(["branch", "-D", branch]).catch((e) => { + this.deps.log(`worktree pool: failed to delete branch ${branch}: ${e}`) + }) + } + + private async dirNames(): Promise { + if (!fs.existsSync(this.deps.dir)) return [] + const entries = await fs.promises.readdir(this.deps.dir, { withFileTypes: true }) + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + } + + private raw(args: string[]): Promise { + return this.deps.client(this.deps.root).raw(args) + } + + private async metaPath(wtPath: string): Promise { + const dir = await this.attemptValue(() => this.deps.gitdir(wtPath), `resolve gitdir ${wtPath}`) + return dir ? path.join(dir, METADATA_FILE) : undefined + } + + private async writeMeta(wtPath: string, meta: PoolMeta): Promise { + const file = await this.metaPath(wtPath) + if (!file) return + await fs.promises.writeFile(file, JSON.stringify(meta), "utf-8") + } + + private async clearMeta(wtPath: string): Promise { + const file = await this.metaPath(wtPath) + if (!file) return + await fs.promises.writeFile(file, "{}", "utf-8") + } + + private async readMeta(wtPath: string): Promise { + return this.readMetaFile(await this.metaPath(wtPath)) + } + + private async readMetaFile(file: string | undefined): Promise { + if (!file) return undefined + // A missing file is the normal case for a non-pooled worktree, so stay quiet. + const content = await fs.promises.readFile(file, "utf-8").catch((e: NodeJS.ErrnoException) => { + if (e.code !== "ENOENT") this.deps.log(`worktree pool: read metadata ${file}: ${e}`) + return undefined + }) + if (content === undefined) return undefined + return await Promise.resolve() + .then(() => JSON.parse(content) as PoolMeta) + .catch((e) => { + this.deps.log(`worktree pool: parse metadata ${file}: ${e}`) + return undefined + }) + } + + private alive(pid: number | undefined): boolean { + if (pid === undefined) return false + try { + process.kill(pid, 0) + return true + } catch (e) { + return (e as NodeJS.ErrnoException).code === "EPERM" + } + } + + private async attempt(fn: () => Promise, label: string): Promise { + try { + await fn() + return true + } catch (e) { + this.deps.log(`worktree pool: ${label}: ${e}`) + return false + } + } + + private async attemptValue(fn: () => Promise, label: string): Promise { + try { + return await fn() + } catch (e) { + this.deps.log(`worktree pool: ${label}: ${e}`) + return undefined + } + } +} diff --git a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts index 99315384c44c..be2f7f1dc308 100644 --- a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts +++ b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts @@ -11,6 +11,7 @@ type Settings = { claudeMigration: boolean "agentManager.autoBranchNaming": boolean "agentManager.branchPrefix": string + "agentManager.worktreePool": boolean } export async function fetchSnapshot(client: Client, dir: string, settings: () => Settings) { const [{ data: config }, { data: global }, { data: overlay }, capabilities] = await Promise.all([ diff --git a/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts index 6d397174e148..3287651b2dc3 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts @@ -399,10 +399,17 @@ describe("agent manager tool start", () => { expect.objectContaining({ branchName: "fix/One_two.3", name: "fix/One_two.3", label: "one two 3" }), ) expect(c.setup).toHaveBeenCalled() - expect(c.createSessionInWorktree).toHaveBeenCalledWith("/repo/.kilo/worktrees/wt-1", "kilo/test", "wt-1", { - sessionID: "s-parent", - sandboxInheritanceToken: "si-token", - }) + expect(c.createSessionInWorktree).toHaveBeenCalledWith( + "/repo/.kilo/worktrees/wt-1", + "kilo/test", + "wt-1", + { + sessionID: "s-parent", + sandboxInheritanceToken: "si-token", + }, + expect.any(Object), + expect.any(Object), + ) expect(c.registerWorktreeSession).toHaveBeenCalledWith("s-wt", "/repo/.kilo/worktrees/wt-1") expect(c.notifyReady).toHaveBeenCalled() expect(client.session.promptAsync).toHaveBeenCalledWith( diff --git a/packages/kilo-vscode/tests/unit/creation-timing.test.ts b/packages/kilo-vscode/tests/unit/creation-timing.test.ts new file mode 100644 index 000000000000..92dd8e83afdc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/creation-timing.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "bun:test" +import { Timing } from "../../src/agent-manager/creation-timing" + +function ticking(values: number[]): () => number { + let index = 0 + return () => values[index++]! +} + +describe("creation timing", () => { + it("records one lap per mark and logs a single structured line", () => { + const lines: unknown[][] = [] + const timing = Timing.start("create demo", (...args) => lines.push(args), ticking([0, 10, 20, 30])) + + timing.mark("context") + timing.mark("preflight") + const span = timing.end() + + expect(span.phases).toEqual({ context: 10, preflight: 10 }) + expect(span.total).toBe(30) + expect(span.line).toBe("[agent-manager] create demo total=30ms context=10 preflight=10") + expect(lines).toEqual([["[agent-manager] create demo total=30ms context=10 preflight=10"]]) + }) + + it("attributes an explicit start reading to its phase", () => { + const timing = Timing.start("create demo", undefined, ticking([0, 5, 50, 60])) + + timing.mark("setup") + timing.mark("boot", 1) + const span = timing.result() + + expect(span.phases).toEqual({ setup: 5, boot: 49 }) + expect(span.line).toBe("[agent-manager] create demo total=60ms setup=5 boot=49") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/provider-multi-version.test.ts b/packages/kilo-vscode/tests/unit/provider-multi-version.test.ts index 6587a61a1744..02f266792a42 100644 --- a/packages/kilo-vscode/tests/unit/provider-multi-version.test.ts +++ b/packages/kilo-vscode/tests/unit/provider-multi-version.test.ts @@ -129,4 +129,66 @@ describe("multi-version provisioning", () => { expect(error).toHaveBeenCalledWith("Failed to create any of the 1 multi-version worktrees.") }) + + it("boots the directory before setup finishes and creates the session after", async () => { + const flow: string[] = [] + const setupEntered = Promise.withResolvers() + const setupGate = Promise.withResolvers() + const state = { addSession: mock(() => {}), armAutoName: mock(() => {}) } + const ctx = { + id: "project-1", + stateManager: () => state, + peekState: () => state, + worktreeManager: () => ({ removeWorktree: mock(async () => {}) }), + } as unknown as ProjectContext + const host = { + log: mock(() => {}), + post: mock(() => {}), + createOnDisk: mock(async () => { + return { + worktree: { id: "wt-0" }, + result: { path: "/repo/wt-0", branch: "branch-0", parentBranch: "main" }, + } as CreateWorktreeOnDiskResult + }), + metadata: mock(async () => { + flow.push("boot") + return {} + }), + client: () => ({}) as never, + runSetup: mock(async () => { + flow.push("setup:start") + setupEntered.resolve() + await setupGate.promise + flow.push("setup:end") + }), + createSession: mock( + async (_dir: string, _branch: string, _id: string, boot: { metadata: () => Promise }) => { + flow.push("create") + await boot.metadata() + return { id: "session-0" } as Session + }, + ), + autoName: () => ({ enabled: false }), + register: mock(() => {}), + notifyReady: mock(() => {}), + sessions: { register: mock(() => {}) }, + promptName: mock(() => {}), + capture: mock(() => {}), + error: mock(() => {}), + } as unknown as MultiVersionHost + + const pending = createMultiVersion(ctx, host, { + type: "agentManager.createMultiVersion", + text: "Fix it", + versions: 1, + }) + await setupEntered.promise + + expect(flow).toEqual(["boot", "setup:start"]) + + setupGate.resolve() + await pending + + expect(flow).toEqual(["boot", "setup:start", "setup:end", "create"]) + }) }) diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index 3d9b43e231f8..86cf19bddfca 100644 --- a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts @@ -477,7 +477,7 @@ describe("WorktreeManager.createWorktree", () => { expect(result.parentBranch).toBe(branch) }) - it("uses two checkout workers without changing Git configuration", async () => { + it("uses four checkout workers without changing Git configuration", async () => { const root = await createTempRepo() const hook = path.join(root, ".git", "hooks", "post-checkout") const file = path.join(root, "workers") @@ -486,7 +486,7 @@ describe("WorktreeManager.createWorktree", () => { await createManager(root).createWorktree({ branchName: "parallel-checkout" }) - expect((await fs.readFile(file, "utf8")).trim()).toBe("2") + expect((await fs.readFile(file, "utf8")).trim()).toBe("4") expect((await simpleGit(root).getConfig("checkout.workers")).value).toBeNull() }) diff --git a/packages/kilo-vscode/tests/unit/worktree-pool.test.ts b/packages/kilo-vscode/tests/unit/worktree-pool.test.ts new file mode 100644 index 000000000000..2af9155e6d4b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/worktree-pool.test.ts @@ -0,0 +1,206 @@ +import { afterEach, describe, expect, it } from "bun:test" +import os from "node:os" +import path from "node:path" +import fs from "node:fs/promises" +import { existsSync } from "node:fs" +import simpleGit from "simple-git" +import { WorktreeManager } from "../../src/agent-manager/WorktreeManager" + +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0, tempDirs.length).map(async (dir) => { + await fs.rm(dir, { recursive: true, force: true }) + }), + ) +}) + +function gitExec(args: string[]) { + const res = Bun.spawnSync(args, { stdout: "ignore", stderr: "pipe" }) + if (res.exitCode !== 0) { + const err = Buffer.from(res.stderr).toString("utf8") + throw new Error(`git command failed (${args.join(" ")}): ${err}`) + } +} + +async function createTempRepo(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-pool-")) + tempDirs.push(dir) + gitExec(["git", "init", "-b", "main", dir]) + gitExec(["git", "-C", dir, "config", "user.email", "test@test.com"]) + gitExec(["git", "-C", dir, "config", "user.name", "Test"]) + await fs.writeFile(path.join(dir, "README.md"), "init") + gitExec(["git", "-C", dir, "add", "."]) + gitExec(["git", "-C", dir, "commit", "-m", "initial commit"]) + return dir +} + +function createManager(root: string, poolSize = 1): WorktreeManager { + return new WorktreeManager(root, () => undefined, undefined, undefined, poolSize) +} + +async function pooledSlots(root: string): Promise { + const raw = await simpleGit(root).raw(["worktree", "list", "--porcelain"]) + const slots: string[] = [] + for (const block of raw.split("\n\n")) { + const lines = block.split("\n") + const worktree = lines.find((line) => line.startsWith("worktree "))?.slice(9) + const detached = lines.some((line) => line === "detached") + if (worktree && detached) slots.push(worktree) + } + return slots +} + +async function slotMeta(slot: string): Promise | undefined> { + const pointer = await fs.readFile(path.join(slot, ".git"), "utf-8").catch(() => undefined) + const match = pointer?.match(/^gitdir:\s*(.+)$/m) + if (!match) return undefined + const dir = path.resolve(slot, match[1]!.trim()) + const raw = await fs.readFile(path.join(dir, "kilo-agent-manager-metadata.json"), "utf-8").catch(() => undefined) + if (!raw) return undefined + return JSON.parse(raw) as Record +} + +async function waitForPooledSlot(root: string, timeout = 10000): Promise { + const deadline = Date.now() + timeout + while (Date.now() < deadline) { + for (const slot of await pooledSlots(root)) { + if ((await slotMeta(slot))?.pooled === true) return slot + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + throw new Error("Timed out waiting for a pooled slot") +} + +describe("WorktreeManager pool warm-up", () => { + it("creates a detached slot that discoverWorktrees skips", async () => { + const root = await createTempRepo() + const manager = createManager(root) + + manager.warmPool() + await waitForPooledSlot(root) + + const slots = await pooledSlots(root) + expect(slots).toHaveLength(1) + expect(await manager.discoverWorktrees()).toEqual([]) + }) +}) + +describe("WorktreeManager pool claim", () => { + it("claims an exact-match slot for a generated name and keeps the slot path", async () => { + const root = await createTempRepo() + const manager = createManager(root) + + manager.warmPool() + const slot = await waitForPooledSlot(root) + + const result = await manager.createWorktree({}) + + expect(await fs.realpath(result.path)).toBe(await fs.realpath(slot)) + expect(result.branch).toBe(path.basename(slot)) + expect((await simpleGit(slot).raw(["symbolic-ref", "--short", "HEAD"])).trim()).toBe(result.branch) + expect(await fs.stat(path.join(result.path, ".git")).then((stat) => stat.isFile())).toBe(true) + + const raw = await simpleGit(root).raw(["worktree", "list", "--porcelain"]) + const block = raw.split("\n\n").find((entry) => entry.includes(slot)) + expect(block).toBeDefined() + expect(block).not.toContain("detached") + + expect((await slotMeta(slot))?.pooled).toBeFalsy() + + // A replacement slot is warmed after the claim, off the click path. + const next = await waitForPooledSlot(root) + expect(next).not.toBe(slot) + }) + + it("moves a claimed slot to the branch-named directory for an explicit branch", async () => { + const root = await createTempRepo() + const manager = createManager(root) + + manager.warmPool() + const slot = await waitForPooledSlot(root) + + const result = await manager.createWorktree({ branchName: "feature" }) + + expect(result.path).toBe(path.join(root, ".kilo", "worktrees", "feature")) + expect(existsSync(slot)).toBe(false) + expect((await simpleGit(result.path).raw(["symbolic-ref", "--short", "HEAD"])).trim()).toBe("feature") + expect((await simpleGit(result.path).raw(["status", "--porcelain"])).trim()).toBe("") + }) + + it("preserves an existing branch when a delta claim collides with the slot name", async () => { + const root = await createTempRepo() + const manager = createManager(root) + + manager.warmPool() + const slot = await waitForPooledSlot(root) + const name = path.basename(slot) + const git = simpleGit(root) + + gitExec(["git", "-C", root, "checkout", "-b", name]) + gitExec(["git", "-C", root, "commit", "--allow-empty", "-m", "preserve this commit"]) + const original = (await git.revparse(["HEAD"])).trim() + gitExec(["git", "-C", root, "checkout", "main"]) + gitExec(["git", "-C", root, "commit", "--allow-empty", "-m", "advance base"]) + const head = (await git.revparse(["HEAD"])).trim() + + const result = await manager.createWorktree({}) + + expect((await git.revparse([`refs/heads/${name}`])).trim()).toBe(original) + expect(result.branch).not.toBe(name) + expect((await simpleGit(result.path).revparse(["HEAD"])).trim()).toBe(head) + expect((await simpleGit(result.path).raw(["symbolic-ref", "--short", "HEAD"])).trim()).toBe(result.branch) + expect((await simpleGit(result.path).raw(["status", "--porcelain"])).trim()).toBe("") + }) + + it("claims a small-delta slot and yields a clean worktree at the requested commit", async () => { + const root = await createTempRepo() + const manager = createManager(root) + + manager.warmPool() + const slot = await waitForPooledSlot(root) + await new Promise((resolve) => setTimeout(resolve, 150)) + + await fs.writeFile(path.join(root, "next.txt"), "next") + gitExec(["git", "-C", root, "add", "."]) + gitExec(["git", "-C", root, "commit", "-m", "second"]) + const head = (await simpleGit(root).revparse(["HEAD"])).trim() + + const result = await manager.createWorktree({ branchName: "delta" }) + + expect(result.path).toBe(path.join(root, ".kilo", "worktrees", "delta")) + expect(existsSync(slot)).toBe(false) + expect((await simpleGit(result.path).revparse(["HEAD"])).trim()).toBe(head) + expect((await simpleGit(result.path).raw(["symbolic-ref", "--short", "HEAD"])).trim()).toBe("delta") + expect((await simpleGit(result.path).raw(["status", "--porcelain"])).trim()).toBe("") + }) +}) + +describe("WorktreeManager pool disabled", () => { + it("keeps creation behavior unchanged when poolSize is 0", async () => { + const root = await createTempRepo() + const manager = createManager(root, 0) + + manager.warmPool() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await pooledSlots(root)).toEqual([]) + + const result = await manager.createWorktree({ branchName: "plain" }) + + expect(result.path).toBe(path.join(root, ".kilo", "worktrees", "plain")) + expect((await simpleGit(result.path).raw(["symbolic-ref", "--short", "HEAD"])).trim()).toBe("plain") + }) +}) + +describe("WorktreeManager commit detection", () => { + it("reports an empty repository through the commit check", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-pool-empty-")) + tempDirs.push(root) + gitExec(["git", "init", "-b", "main", root]) + + await expect(createManager(root).defaultBranch()).rejects.toThrow( + "This repository has no commits yet. Create an initial commit before using worktrees.", + ) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 9dffe7fcd8d0..f09232dd2b4f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -40,6 +40,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "بادئة الفرع", "agentManager.settings.branchPrefix.description": "بادئة للفروع المسماة تلقائيًا في جميع المشاريع، مثل feature/. لا تنطبق على أسماء الفروع الصريحة. اتركها فارغة لعدم استخدام بادئة.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "المشروع", "agentManager.settings.project.description": "اختر repository الذي تريد تعديل إعدادات worktree الخاصة به.", "agentManager.settings.project.empty": "لا تتوفر أي مشاريع في Agent Manager.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 50696625a1dd..3f9092041a15 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -42,6 +42,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Prefixo da branch", "agentManager.settings.branchPrefix.description": "Prefixo para branches nomeadas automaticamente em todos os projetos, por exemplo feature/. Não se aplica a nomes explícitos de branches. Deixe vazio para não usar prefixo.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Projeto", "agentManager.settings.project.description": "Escolha o repository cujas configurações de worktree você deseja editar.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index 786933dd7f77..9995ac7df645 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -41,6 +41,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Prefiks grane", "agentManager.settings.branchPrefix.description": "Prefiks za automatski imenovane grane u svim projektima, na primjer feature/. Ne primjenjuje se na izričite nazive grana. Ostavite prazno ako ne želite prefiks.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Projekat", "agentManager.settings.project.description": "Izaberite repository čije worktree postavke želite urediti.", "agentManager.settings.project.empty": "Nema dostupnih projekata u Agent Manager.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index 954b34e272ef..1000fc8d4aaa 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -41,6 +41,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Grenpræfiks", "agentManager.settings.branchPrefix.description": "Præfiks for automatisk navngivne grene i alle projekter, for eksempel feature/. Gælder ikke eksplicitte grennavne. Lad feltet være tomt for intet præfiks.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Projekt", "agentManager.settings.project.description": "Vælg det repository, hvis worktree-indstillinger du vil redigere.", "agentManager.settings.project.empty": "Der er ingen tilgængelige projekter i Agent Manager.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index 4384386718c4..e7f775a2458b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -45,6 +45,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Branch-Präfix", "agentManager.settings.branchPrefix.description": "Präfix für automatisch benannte Branches in allen Projekten, zum Beispiel feature/. Gilt nicht für explizite Branch-Namen. Für kein Präfix leer lassen.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Projekt", "agentManager.settings.project.description": "Wählen Sie das repository aus, dessen worktree-Einstellungen Sie bearbeiten möchten.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index 01e27fcf5a6a..da1f7bcc5fa9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -40,6 +40,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Branch prefix", "agentManager.settings.branchPrefix.description": "Prefix for automatically named branches in all projects, for example feature/. Does not apply to explicit branch names. Leave empty for no prefix.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Project", "agentManager.settings.project.description": "Choose the repository whose worktree settings you want to edit.", "agentManager.settings.project.empty": "No Agent Manager projects are available.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index 58b6138743ca..29f0486d0e7d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -44,6 +44,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Prefijo de rama", "agentManager.settings.branchPrefix.description": "Prefijo para las ramas nombradas automáticamente en todos los proyectos, por ejemplo feature/. No se aplica a nombres de rama explícitos. Déjalo vacío para no usar prefijo.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Proyecto", "agentManager.settings.project.description": "Elige el repository cuyos ajustes de worktree quieres editar.", "agentManager.settings.project.empty": "No hay proyectos de Agent Manager disponibles.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts index 8ff591fb0f54..303432e9f945 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -41,6 +41,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "پیشوند شاخه", "agentManager.settings.branchPrefix.description": "پیشوند شاخه‌هایی که در همه پروژه‌ها خودکار نام‌گذاری می‌شوند، برای مثال feature/. برای نام‌های صریح شاخه‌ها اعمال نمی‌شود. برای نداشتن پیشوند، خالی بگذارید.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "پروژه", "agentManager.settings.project.description": "repository موردنظر را انتخاب کنید تا تنظیمات worktree آن را ویرایش کنید.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index f0bef734c425..01fbada7ff86 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -45,6 +45,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Préfixe de branche", "agentManager.settings.branchPrefix.description": "Préfixe des branches nommées automatiquement dans tous les projets, par exemple feature/. Ne s’applique pas aux noms de branches explicites. Laissez vide pour ne pas utiliser de préfixe.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Projet", "agentManager.settings.project.description": "Choisissez le repository dont vous souhaitez modifier les paramètres du worktree.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index ba04dafd8ca2..f0cc60cc937f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -43,6 +43,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Prefisso del branch", "agentManager.settings.branchPrefix.description": "Prefisso per i branch denominati automaticamente in tutti i progetti, ad esempio feature/. Non si applica ai nomi espliciti dei branch. Lascia vuoto per non usare un prefisso.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Progetto", "agentManager.settings.project.description": "Scegli il repository di cui vuoi modificare le impostazioni del worktree.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 4d8c6ef21068..b44034be485d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -41,6 +41,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "ブランチのプレフィックス", "agentManager.settings.branchPrefix.description": "すべてのプロジェクトで自動命名されるブランチのプレフィックスです(例:feature/)。明示的なブランチ名には適用されません。プレフィックスを使わない場合は空欄にしてください。", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "プロジェクト", "agentManager.settings.project.description": "編集する worktree 設定の repository を選択してください。", "agentManager.settings.project.empty": "利用可能な Agent Manager プロジェクトはありません。", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 959e6ebe3653..2a0e0c25caa6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -41,6 +41,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "브랜치 접두사", "agentManager.settings.branchPrefix.description": "모든 프로젝트에서 자동으로 이름이 지정되는 브랜치의 접두사입니다(예: feature/). 명시적인 브랜치 이름에는 적용되지 않습니다. 접두사를 사용하지 않으려면 비워 두세요.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "프로젝트", "agentManager.settings.project.description": "편집하려는 worktree 설정의 repository를 선택하세요.", "agentManager.settings.project.empty": "사용 가능한 Agent Manager 프로젝트가 없습니다.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 4c2e62106848..216be9cca241 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -43,6 +43,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Branchprefix", "agentManager.settings.branchPrefix.description": "Prefix voor automatisch benoemde branches in alle projecten, bijvoorbeeld feature/. Geldt niet voor expliciete branchnamen. Laat leeg om geen prefix te gebruiken.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Project", "agentManager.settings.project.description": "Kies de repository waarvan je de worktree-instellingen wilt bewerken.", "agentManager.settings.project.empty": "Er zijn geen Agent Manager-projecten beschikbaar.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index c3202c70b7cd..4bc81695137e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -42,6 +42,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Grenprefiks", "agentManager.settings.branchPrefix.description": "Prefiks for automatisk navngitte grener i alle prosjekter, for eksempel feature/. Gjelder ikke eksplisitte grennavn. La feltet stå tomt for å ikke bruke prefiks.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Prosjekt", "agentManager.settings.project.description": "Velg repository hvis worktree-innstillinger du vil redigere.", "agentManager.settings.project.empty": "Ingen Agent Manager-prosjekter er tilgjengelige.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index ca3d0ef15cda..5affa19bdfbd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -42,6 +42,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Prefiks gałęzi", "agentManager.settings.branchPrefix.description": "Prefiks automatycznie nazywanych gałęzi we wszystkich projektach, na przykład feature/. Nie dotyczy jawnych nazw gałęzi. Pozostaw puste, aby nie używać prefiksu.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Projekt", "agentManager.settings.project.description": "Wybierz repository, którego ustawienia worktree chcesz edytować.", "agentManager.settings.project.empty": "Brak dostępnych projektów Agent Manager.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 0e8abb52bc82..fa3c17753182 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -43,6 +43,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Префикс ветки", "agentManager.settings.branchPrefix.description": "Префикс автоматически именуемых веток во всех проектах, например feature/. Не применяется к явно заданным именам веток. Оставьте пустым, чтобы не использовать префикс.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Проект", "agentManager.settings.project.description": "Выберите repository, настройки worktree которого хотите изменить.", "agentManager.settings.project.empty": "Нет доступных проектов Agent Manager.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index c169772521f2..762092ccc99c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -41,6 +41,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "คำนำหน้าบรานช์", "agentManager.settings.branchPrefix.description": "คำนำหน้าสำหรับบรานช์ที่ตั้งชื่ออัตโนมัติในทุกโปรเจกต์ เช่น feature/ ไม่ใช้กับชื่อบรานช์ที่ระบุไว้อย่างชัดเจน เว้นว่างไว้หากไม่ต้องการคำนำหน้า", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "โปรเจกต์", "agentManager.settings.project.description": "เลือก repository ที่มีการตั้งค่า worktree ที่คุณต้องการแก้ไข", "agentManager.settings.project.empty": "ไม่มีโปรเจกต์ Agent Manager ที่พร้อมใช้งาน", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index 97db13c415b2..af87d2ed5372 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -42,6 +42,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Dal öneki", "agentManager.settings.branchPrefix.description": "Tüm projelerde otomatik adlandırılan dallar için önek, örneğin feature/. Açıkça belirtilen dal adlarına uygulanmaz. Önek kullanmamak için boş bırakın.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Proje", "agentManager.settings.project.description": "Worktree ayarlarını düzenlemek istediğiniz repository'yi seçin.", "agentManager.settings.project.empty": "Kullanılabilir Agent Manager projesi yok.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index 08755e2b1c97..52db789c72fa 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -44,6 +44,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Префікс гілки", "agentManager.settings.branchPrefix.description": "Префікс автоматично іменованих гілок у всіх проєктах, наприклад feature/. Не застосовується до явно заданих назв гілок. Залиште порожнім, щоб не використовувати префікс.", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "Проєкт", "agentManager.settings.project.description": "Виберіть repository, налаштування worktree якого потрібно змінити.", "agentManager.settings.project.empty": "Немає доступних проєктів Agent Manager.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index cfd7cd449e67..878ff086ce6f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -39,6 +39,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "分支前缀", "agentManager.settings.branchPrefix.description": "所有项目中自动命名分支的前缀,例如 feature/。不适用于明确指定的分支名称。留空则不使用前缀。", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "项目", "agentManager.settings.project.description": "选择要编辑其 worktree 设置的 repository。", "agentManager.settings.project.empty": "没有可用的 Agent Manager 项目。", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index 194cd10acd57..a1fca89b9a3c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -39,6 +39,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "分支前綴", "agentManager.settings.branchPrefix.description": "所有專案中自動命名分支的前綴,例如 feature/。不適用於明確指定的分支名稱。留空則不使用前綴。", + "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.description": + "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", "agentManager.settings.project.title": "專案", "agentManager.settings.project.description": "選擇要編輯其 worktree 設定的 repository。", "agentManager.settings.project.empty": "沒有可用的 Agent Manager 專案。", diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx index d624fc2f0e49..19e8c131201e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx @@ -179,6 +179,18 @@ const AgentManagerTab: Component<{ projectId?: string }> = (props) => { onChange={(value) => updateSetting("agentManager.branchPrefix", value)} /> + + updateSetting("agentManager.worktreePool", value)} + hideLabel + > + {language.t("agentManager.settings.worktreePool.title")} + + Date: Fri, 11 Sep 2026 11:06:58 +0200 Subject: [PATCH 02/10] fix(agent-manager): translate worktree pre-warm setting strings --- packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts | 4 ++-- packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts | 4 ++-- 20 files changed, 40 insertions(+), 40 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index f09232dd2b4f..faf62311a1cf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -40,9 +40,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "بادئة الفرع", "agentManager.settings.branchPrefix.description": "بادئة للفروع المسماة تلقائيًا في جميع المشاريع، مثل feature/. لا تنطبق على أسماء الفروع الصريحة. اتركها فارغة لعدم استخدام بادئة.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "تسخين Worktrees مسبقًا", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "جهّز worktree جاهزًا في الخلفية حتى تبدأ جلسات Agent Manager الجديدة بشكل أسرع. يستخدم مساحة إضافية على القرص مقابل checkout واحد لكل مشروع مفتوح.", "agentManager.settings.project.title": "المشروع", "agentManager.settings.project.description": "اختر repository الذي تريد تعديل إعدادات worktree الخاصة به.", "agentManager.settings.project.empty": "لا تتوفر أي مشاريع في Agent Manager.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 3f9092041a15..e9fcea6435f8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -42,9 +42,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Prefixo da branch", "agentManager.settings.branchPrefix.description": "Prefixo para branches nomeadas automaticamente em todos os projetos, por exemplo feature/. Não se aplica a nomes explícitos de branches. Deixe vazio para não usar prefixo.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Pré-aquecimento de worktrees", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "Prepare um worktree pronto em segundo plano para que novas sessões do Agent Manager iniciem mais rápido. Usa espaço em disco extra para um checkout por projeto aberto.", "agentManager.settings.project.title": "Projeto", "agentManager.settings.project.description": "Escolha o repository cujas configurações de worktree você deseja editar.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index 9995ac7df645..945136e7200e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -41,9 +41,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Prefiks grane", "agentManager.settings.branchPrefix.description": "Prefiks za automatski imenovane grane u svim projektima, na primjer feature/. Ne primjenjuje se na izričite nazive grana. Ostavite prazno ako ne želite prefiks.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Prethodno zagrijavanje worktree-a", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "Pripremite spreman worktree u pozadini da nove sesije Agent Manager-a počinju brže. Koristi dodatni prostor na disku za jedan checkout po otvorenom projektu.", "agentManager.settings.project.title": "Projekat", "agentManager.settings.project.description": "Izaberite repository čije worktree postavke želite urediti.", "agentManager.settings.project.empty": "Nema dostupnih projekata u Agent Manager.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index 1000fc8d4aaa..210dcae706f1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -41,9 +41,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Grenpræfiks", "agentManager.settings.branchPrefix.description": "Præfiks for automatisk navngivne grene i alle projekter, for eksempel feature/. Gælder ikke eksplicitte grennavne. Lad feltet være tomt for intet præfiks.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Forvarm worktrees", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "Forbered et klart worktree i baggrunden, så nye Agent Manager-sessioner starter hurtigere. Bruger ekstra diskplads til ét checkout pr. åbent projekt.", "agentManager.settings.project.title": "Projekt", "agentManager.settings.project.description": "Vælg det repository, hvis worktree-indstillinger du vil redigere.", "agentManager.settings.project.empty": "Der er ingen tilgængelige projekter i Agent Manager.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index e7f775a2458b..c8d9dd71b5ec 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -45,9 +45,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Branch-Präfix", "agentManager.settings.branchPrefix.description": "Präfix für automatisch benannte Branches in allen Projekten, zum Beispiel feature/. Gilt nicht für explizite Branch-Namen. Für kein Präfix leer lassen.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Worktrees vorwärmen", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "Bereitet im Hintergrund einen bereitstehenden Worktree vor, damit neue Agent-Manager-Sitzungen schneller starten. Benötigt zusätzlichen Speicherplatz für einen Checkout pro geöffnetem Projekt.", "agentManager.settings.project.title": "Projekt", "agentManager.settings.project.description": "Wählen Sie das repository aus, dessen worktree-Einstellungen Sie bearbeiten möchten.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index 29f0486d0e7d..a89dc4e76bbe 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -44,9 +44,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Prefijo de rama", "agentManager.settings.branchPrefix.description": "Prefijo para las ramas nombradas automáticamente en todos los proyectos, por ejemplo feature/. No se aplica a nombres de rama explícitos. Déjalo vacío para no usar prefijo.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Precalentar worktrees", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "Prepara un worktree listo en segundo plano para que las nuevas sesiones de Agent Manager se inicien más rápido. Usa espacio adicional en disco para un checkout por cada proyecto abierto.", "agentManager.settings.project.title": "Proyecto", "agentManager.settings.project.description": "Elige el repository cuyos ajustes de worktree quieres editar.", "agentManager.settings.project.empty": "No hay proyectos de Agent Manager disponibles.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts index 303432e9f945..6c94376b7c7a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -41,9 +41,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "پیشوند شاخه", "agentManager.settings.branchPrefix.description": "پیشوند شاخه‌هایی که در همه پروژه‌ها خودکار نام‌گذاری می‌شوند، برای مثال feature/. برای نام‌های صریح شاخه‌ها اعمال نمی‌شود. برای نداشتن پیشوند، خالی بگذارید.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "آماده‌سازی از پیش worktreeها", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "یک worktree آماده را در پس‌زمینه فراهم کنید تا نشست‌های جدید Agent Manager سریع‌تر شروع شوند. برای هر پروژه باز، یک checkout روی دیسک فضای اضافی مصرف می‌کند.", "agentManager.settings.project.title": "پروژه", "agentManager.settings.project.description": "repository موردنظر را انتخاب کنید تا تنظیمات worktree آن را ویرایش کنید.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 01fbada7ff86..28b4cc4614cb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -45,9 +45,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Préfixe de branche", "agentManager.settings.branchPrefix.description": "Préfixe des branches nommées automatiquement dans tous les projets, par exemple feature/. Ne s’applique pas aux noms de branches explicites. Laissez vide pour ne pas utiliser de préfixe.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Préchauffer les worktrees", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "Prépare en arrière-plan un worktree prêt à l'emploi afin que les nouvelles sessions Agent Manager démarrent plus vite. Utilise de l'espace disque supplémentaire pour un checkout par projet ouvert.", "agentManager.settings.project.title": "Projet", "agentManager.settings.project.description": "Choisissez le repository dont vous souhaitez modifier les paramètres du worktree.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index f0cc60cc937f..89351b8208fc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -43,9 +43,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Prefisso del branch", "agentManager.settings.branchPrefix.description": "Prefisso per i branch denominati automaticamente in tutti i progetti, ad esempio feature/. Non si applica ai nomi espliciti dei branch. Lascia vuoto per non usare un prefisso.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Preriscaldamento dei worktree", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "Prepara un worktree pronto in background, così le nuove sessioni di Agent Manager si avviano più rapidamente. Usa spazio su disco aggiuntivo per un checkout per ogni progetto aperto.", "agentManager.settings.project.title": "Progetto", "agentManager.settings.project.description": "Scegli il repository di cui vuoi modificare le impostazioni del worktree.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index b44034be485d..7cd78afb35a9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -41,9 +41,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "ブランチのプレフィックス", "agentManager.settings.branchPrefix.description": "すべてのプロジェクトで自動命名されるブランチのプレフィックスです(例:feature/)。明示的なブランチ名には適用されません。プレフィックスを使わない場合は空欄にしてください。", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Worktreeの事前準備", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "バックグラウンドで準備済みの worktree を用意し、新しい Agent Manager セッションがより速く開始できるようにします。開いているプロジェクトごとに 1 つの checkout 分の追加ディスク容量を使用します。", "agentManager.settings.project.title": "プロジェクト", "agentManager.settings.project.description": "編集する worktree 設定の repository を選択してください。", "agentManager.settings.project.empty": "利用可能な Agent Manager プロジェクトはありません。", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 2a0e0c25caa6..d2e11dffde0c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -41,9 +41,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "브랜치 접두사", "agentManager.settings.branchPrefix.description": "모든 프로젝트에서 자동으로 이름이 지정되는 브랜치의 접두사입니다(예: feature/). 명시적인 브랜치 이름에는 적용되지 않습니다. 접두사를 사용하지 않으려면 비워 두세요.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Worktree 미리 준비", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "백그라운드에서 준비된 worktree를 미리 만들어 두면 새 Agent Manager 세션이 더 빠르게 시작됩니다. 열린 프로젝트마다 checkout 하나를 위해 추가 디스크 공간을 사용합니다.", "agentManager.settings.project.title": "프로젝트", "agentManager.settings.project.description": "편집하려는 worktree 설정의 repository를 선택하세요.", "agentManager.settings.project.empty": "사용 가능한 Agent Manager 프로젝트가 없습니다.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 216be9cca241..717be3b60ed1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -43,9 +43,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Branchprefix", "agentManager.settings.branchPrefix.description": "Prefix voor automatisch benoemde branches in alle projecten, bijvoorbeeld feature/. Geldt niet voor expliciete branchnamen. Laat leeg om geen prefix te gebruiken.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Worktrees vooraf opwarmen", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "Bereid op de achtergrond een kant-en-klare worktree voor, zodat nieuwe Agent Manager-sessies sneller starten. Gebruikt extra schijfruimte voor één checkout per geopend project.", "agentManager.settings.project.title": "Project", "agentManager.settings.project.description": "Kies de repository waarvan je de worktree-instellingen wilt bewerken.", "agentManager.settings.project.empty": "Er zijn geen Agent Manager-projecten beschikbaar.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index 4bc81695137e..bb419db15ae6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -42,9 +42,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Grenprefiks", "agentManager.settings.branchPrefix.description": "Prefiks for automatisk navngitte grener i alle prosjekter, for eksempel feature/. Gjelder ikke eksplisitte grennavn. La feltet stå tomt for å ikke bruke prefiks.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Forvarm worktrees", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "Forbered et klart worktree i bakgrunnen slik at nye Agent Manager-økter starter raskere. Bruker ekstra diskplass for én checkout per åpne prosjekt.", "agentManager.settings.project.title": "Prosjekt", "agentManager.settings.project.description": "Velg repository hvis worktree-innstillinger du vil redigere.", "agentManager.settings.project.empty": "Ingen Agent Manager-prosjekter er tilgjengelige.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 5affa19bdfbd..aba43408b689 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -42,9 +42,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Prefiks gałęzi", "agentManager.settings.branchPrefix.description": "Prefiks automatycznie nazywanych gałęzi we wszystkich projektach, na przykład feature/. Nie dotyczy jawnych nazw gałęzi. Pozostaw puste, aby nie używać prefiksu.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Wstępne przygotowanie worktree", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "Przygotuj gotowy worktree w tle, aby nowe sesje Agent Manager uruchamiały się szybciej. Wykorzystuje dodatkowe miejsce na dysku na jeden checkout na otwarty projekt.", "agentManager.settings.project.title": "Projekt", "agentManager.settings.project.description": "Wybierz repository, którego ustawienia worktree chcesz edytować.", "agentManager.settings.project.empty": "Brak dostępnych projektów Agent Manager.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index fa3c17753182..b5de7f6635ac 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -43,9 +43,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Префикс ветки", "agentManager.settings.branchPrefix.description": "Префикс автоматически именуемых веток во всех проектах, например feature/. Не применяется к явно заданным именам веток. Оставьте пустым, чтобы не использовать префикс.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Предварительный прогрев worktree", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "Подготовьте готовый worktree в фоне, чтобы новые сессии Agent Manager запускались быстрее. Использует дополнительное место на диске для одного checkout на каждый открытый проект.", "agentManager.settings.project.title": "Проект", "agentManager.settings.project.description": "Выберите repository, настройки worktree которого хотите изменить.", "agentManager.settings.project.empty": "Нет доступных проектов Agent Manager.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index 762092ccc99c..e8e60105fd19 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -41,9 +41,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "คำนำหน้าบรานช์", "agentManager.settings.branchPrefix.description": "คำนำหน้าสำหรับบรานช์ที่ตั้งชื่ออัตโนมัติในทุกโปรเจกต์ เช่น feature/ ไม่ใช้กับชื่อบรานช์ที่ระบุไว้อย่างชัดเจน เว้นว่างไว้หากไม่ต้องการคำนำหน้า", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "อุ่นเครื่อง Worktree ล่วงหน้า", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "เตรียม Worktree ที่พร้อมใช้งานไว้ในเบื้องหลังเพื่อให้เซสชัน Agent Manager ใหม่เริ่มได้เร็วขึ้น ใช้พื้นที่ดิสก์เพิ่มขึ้นสำหรับหนึ่ง checkout ต่อโปรเจกต์ที่เปิดอยู่", "agentManager.settings.project.title": "โปรเจกต์", "agentManager.settings.project.description": "เลือก repository ที่มีการตั้งค่า worktree ที่คุณต้องการแก้ไข", "agentManager.settings.project.empty": "ไม่มีโปรเจกต์ Agent Manager ที่พร้อมใช้งาน", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index af87d2ed5372..a19a2ed50b94 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -42,9 +42,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Dal öneki", "agentManager.settings.branchPrefix.description": "Tüm projelerde otomatik adlandırılan dallar için önek, örneğin feature/. Açıkça belirtilen dal adlarına uygulanmaz. Önek kullanmamak için boş bırakın.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Worktree'leri önceden ısıtma", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "Yeni Agent Manager oturumlarının daha hızlı başlaması için arka planda hazır bir worktree oluşturun. Açık proje başına bir checkout için fazladan disk alanı kullanır.", "agentManager.settings.project.title": "Proje", "agentManager.settings.project.description": "Worktree ayarlarını düzenlemek istediğiniz repository'yi seçin.", "agentManager.settings.project.empty": "Kullanılabilir Agent Manager projesi yok.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index 52db789c72fa..a1931da04b57 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -44,9 +44,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "Префікс гілки", "agentManager.settings.branchPrefix.description": "Префікс автоматично іменованих гілок у всіх проєктах, наприклад feature/. Не застосовується до явно заданих назв гілок. Залиште порожнім, щоб не використовувати префікс.", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "Попереднє прогрівання worktree", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "Готувати worktree заздалегідь у фоновому режимі, щоб нові сесії Agent Manager запускалися швидше. Використовує додатковий простір на диску для одного checkout на кожен відкритий проєкт.", "agentManager.settings.project.title": "Проєкт", "agentManager.settings.project.description": "Виберіть repository, налаштування worktree якого потрібно змінити.", "agentManager.settings.project.empty": "Немає доступних проєктів Agent Manager.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index 878ff086ce6f..ff7c427d76fe 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -39,9 +39,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "分支前缀", "agentManager.settings.branchPrefix.description": "所有项目中自动命名分支的前缀,例如 feature/。不适用于明确指定的分支名称。留空则不使用前缀。", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "预热 Worktree", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "在后台准备一个就绪的 Worktree,让新的 Agent Manager 会话启动更快。每个打开的项目会额外占用一个 checkout 的磁盘空间。", "agentManager.settings.project.title": "项目", "agentManager.settings.project.description": "选择要编辑其 worktree 设置的 repository。", "agentManager.settings.project.empty": "没有可用的 Agent Manager 项目。", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index a1fca89b9a3c..24ddeb8be63a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -39,9 +39,9 @@ export const dict = { "agentManager.settings.branchPrefix.title": "分支前綴", "agentManager.settings.branchPrefix.description": "所有專案中自動命名分支的前綴,例如 feature/。不適用於明確指定的分支名稱。留空則不使用前綴。", - "agentManager.settings.worktreePool.title": "Pre-warm worktrees", + "agentManager.settings.worktreePool.title": "預先預熱 Worktree", "agentManager.settings.worktreePool.description": - "Prepare a ready worktree in the background so new Agent Manager sessions start faster. Uses extra disk space for one checkout per open project.", + "在背景中準備好一個立即可用的 Worktree,讓新的 Agent Manager 工作階段能更快啟動。每個開啟的專案會佔用一個 checkout 的額外磁碟空間。", "agentManager.settings.project.title": "專案", "agentManager.settings.project.description": "選擇要編輯其 worktree 設定的 repository。", "agentManager.settings.project.empty": "沒有可用的 Agent Manager 專案。", From b5ab64439b28b709b41df95411826a25fa02bad1 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 11 Sep 2026 11:33:26 +0200 Subject: [PATCH 03/10] fix(agent-manager): keep pool warm-up off the git lock and heal adopted slots Resolve the base ref and its commit before taking the shared git lock so a cold fetch cache never stalls user worktree operations. When adopting a leftover slot, use the worktree's real HEAD instead of persisted metadata. Apply the pre-warm toggle immediately instead of on Save, and drop fixed sleeps from the pool tests. --- .../src/agent-manager/worktree-pool.ts | 33 ++++++++++++------- .../tests/unit/worktree-pool.test.ts | 26 +++++++++++++-- .../src/components/settings/Settings.tsx | 4 +-- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/worktree-pool.ts b/packages/kilo-vscode/src/agent-manager/worktree-pool.ts index 577d027314cb..1900ebe0f9b2 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-pool.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-pool.ts @@ -71,15 +71,15 @@ export class WorktreePool { /** * Fire-and-forget warm-up. Idempotent and at most one warm runs at a time. - * Never forces a fresh network fetch: start resolution reuses the manager's - * 60 s fetch cache. + * The start point (which may fetch when the 60 s cache is cold) is resolved + * before the git lock is taken, so user operations never wait on the network. */ warm(base?: string): void { if (this.size() <= 0 || this.warming) return this.warming = true queueMicrotask(() => { - void this.deps - .lock(() => this.fill(base)) + void this.resolve(base) + .then((start) => this.deps.lock(() => this.fill(start.point, start.oid))) .catch((e) => this.deps.log(`worktree pool: warm failed: ${e}`)) .finally(() => { this.warming = false @@ -87,6 +87,13 @@ export class WorktreePool { }) } + /** Resolve the base ref and its commit outside the git lock. */ + private async resolve(base?: string): Promise<{ point: PoolStart; oid: string }> { + const point = await this.deps.start(base) + const oid = (await this.deps.client(this.deps.root).raw(["rev-parse", "--verify", `${point.ref}^{commit}`])).trim() + return { point, oid } + } + /** * Claim a ready slot for a new branch. Runs while the caller already holds * the git lock. Returns the slot path on success, or undefined to fall back @@ -125,11 +132,9 @@ export class WorktreePool { this.slots = this.slots.filter((slot) => normalizePath(slot.path) !== normalizePath(wtPath)) } - private async fill(base?: string): Promise { + private async fill(point: PoolStart, oid: string): Promise { if (this.size() <= 0) return await fs.promises.mkdir(this.deps.dir, { recursive: true }) - const point = await this.deps.start(base) - const oid = (await this.deps.client(this.deps.root).raw(["rev-parse", "--verify", `${point.ref}^{commit}`])).trim() await this.prune() await this.retarget(point, oid) @@ -252,7 +257,13 @@ export class WorktreePool { await this.removePath(slotPath) continue } - const usable = meta.baseOid !== undefined && (await this.registered(slotPath)) + // Trust the worktree's real HEAD over persisted metadata: a crash between + // a retarget checkout and its metadata write leaves them different. + const head = await this.attemptValue( + async () => (await this.deps.client(slotPath).raw(["rev-parse", "--verify", "HEAD^{commit}"])).trim(), + `resolve HEAD ${slotPath}`, + ) + const usable = head !== undefined && head !== "" && (await this.registered(slotPath)) if (!usable || this.slots.length >= this.size()) { await this.removePath(slotPath) continue @@ -261,13 +272,13 @@ export class WorktreePool { pooled: true, owner: process.pid, baseRef: meta.baseRef, - baseOid: meta.baseOid, + baseOid: head, }) this.slots.push({ path: slotPath, baseRef: meta.baseRef ?? "", - baseOid: meta.baseOid!, - ready: Promise.resolve(meta.baseOid!), + baseOid: head, + ready: Promise.resolve(head), refreshed: false, }) } diff --git a/packages/kilo-vscode/tests/unit/worktree-pool.test.ts b/packages/kilo-vscode/tests/unit/worktree-pool.test.ts index 2af9155e6d4b..1dfc57bb30f7 100644 --- a/packages/kilo-vscode/tests/unit/worktree-pool.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-pool.test.ts @@ -160,7 +160,6 @@ describe("WorktreeManager pool claim", () => { manager.warmPool() const slot = await waitForPooledSlot(root) - await new Promise((resolve) => setTimeout(resolve, 150)) await fs.writeFile(path.join(root, "next.txt"), "next") gitExec(["git", "-C", root, "add", "."]) @@ -177,13 +176,36 @@ describe("WorktreeManager pool claim", () => { }) }) +describe("WorktreeManager pool reconcile", () => { + it("trusts the slot HEAD over stale metadata when adopting", async () => { + const root = await createTempRepo() + createManager(root).warmPool() + const slot = await waitForPooledSlot(root) + const head = (await simpleGit(slot).revparse(["HEAD"])).trim() + + // Simulate a crash between a retarget checkout and its metadata write. + const pointer = await fs.readFile(path.join(slot, ".git"), "utf-8") + const dir = path.resolve(slot, pointer.match(/^gitdir:\s*(.+)$/m)![1]!.trim()) + const file = path.join(dir, "kilo-agent-manager-metadata.json") + const meta = JSON.parse(await fs.readFile(file, "utf-8")) as Record + await fs.writeFile(file, JSON.stringify({ ...meta, owner: 999999, baseOid: "0".repeat(40) })) + + const manager = createManager(root) + await manager.reconcilePool() + const result = await manager.createWorktree({}) + + expect(await fs.realpath(result.path)).toBe(await fs.realpath(slot)) + expect((await simpleGit(result.path).revparse(["HEAD"])).trim()).toBe(head) + expect((await simpleGit(result.path).raw(["status", "--porcelain"])).trim()).toBe("") + }) +}) + describe("WorktreeManager pool disabled", () => { it("keeps creation behavior unchanged when poolSize is 0", async () => { const root = await createTempRepo() const manager = createManager(root, 0) manager.warmPool() - await new Promise((resolve) => setTimeout(resolve, 100)) expect(await pooledSlots(root)).toEqual([]) const result = await manager.createWorktree({ branchName: "plain" }) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx index 19e8c131201e..8805298fd4c3 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx @@ -54,7 +54,7 @@ export interface SettingsProps { const AgentManagerTab: Component<{ projectId?: string }> = (props) => { const language = useLanguage() - const { settings, updateSetting } = useConfig() + const { settings, updateSetting, applySetting } = useConfig() const vscode = useVSCode() const dialog = useDialog() const [projects, setProjects] = createSignal([]) @@ -185,7 +185,7 @@ const AgentManagerTab: Component<{ projectId?: string }> = (props) => { > updateSetting("agentManager.worktreePool", value)} + onChange={(value) => applySetting("agentManager.worktreePool", value)} hideLabel > {language.t("agentManager.settings.worktreePool.title")} From 7b189396c86f6a5d05cdfad7a2ab17155073fad2 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Sat, 12 Sep 2026 13:14:55 +0200 Subject: [PATCH 04/10] feat(agent-manager): prepare snapshots during session startup --- .changeset/worktree-pool-prewarm.md | 3 + .../src/agent-manager/AgentManagerProvider.ts | 70 +++--- .../src/agent-manager/creation-plan.ts | 5 + .../src/agent-manager/mcp-warmup.ts | 13 + .../src/agent-manager/provider-lifecycle.ts | 87 +++++-- .../agent-manager/provider-multi-version.ts | 78 +++--- .../src/agent-manager/tool-start.ts | 45 ++-- .../tests/unit/agent-manager-arch.test.ts | 4 +- .../agent-manager-provider-lifecycle.test.ts | 32 +++ .../unit/agent-manager-tool-start.test.ts | 50 ++++ .../tests/unit/creation-plan.test.ts | 134 ++++++++++ .../tests/unit/provider-multi-version.test.ts | 25 +- .../server/httpapi/groups/kilocode.ts | 15 ++ .../server/httpapi/handlers/kilocode.ts | 10 + .../opencode/src/kilocode/snapshot/prepare.ts | 67 +++++ packages/opencode/src/snapshot/index.ts | 64 ++--- .../test/kilocode/snapshot-prepare.test.ts | 235 ++++++++++++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 43 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 31 +++ packages/sdk/openapi.json | 85 +++++++ 20 files changed, 958 insertions(+), 138 deletions(-) create mode 100644 packages/kilo-vscode/src/agent-manager/creation-plan.ts create mode 100644 packages/kilo-vscode/tests/unit/creation-plan.test.ts create mode 100644 packages/opencode/src/kilocode/snapshot/prepare.ts create mode 100644 packages/opencode/test/kilocode/snapshot-prepare.test.ts diff --git a/.changeset/worktree-pool-prewarm.md b/.changeset/worktree-pool-prewarm.md index eaa9eb410951..24020f94339d 100644 --- a/.changeset/worktree-pool-prewarm.md +++ b/.changeset/worktree-pool-prewarm.md @@ -1,5 +1,8 @@ --- "kilo-code": minor +"@kilocode/cli": patch --- Speed up Agent Manager worktree creation by pre-warming reusable worktrees and claiming a ready one instead of running a full checkout. Control the pre-warming in Agent Manager settings under "Pre-warm worktrees"; it is enabled by default and uses one extra checkout of disk space per open project. + +Prepare snapshots during session creation to reduce first-prompt initialization work. Start no-script sessions after environment files are copied, while preserving setup-script completion before agent startup. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 13a3bd42e95a..9a029b915137 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -31,14 +31,13 @@ import type { GitExecutable } from "../util/git-executable" import { versionedName } from "./branch-name" import { BranchNamingController } from "./branch-naming" import { SetupScriptService } from "./SetupScriptService" -import { copyEnvFiles } from "./env-copy" import { SessionTerminalManager } from "./SessionTerminalManager" import { createTerminalHost } from "./terminal-host" import { TerminalRouter } from "./terminal-routing" import { discardWorktree as discard } from "./discard-worktree" import { acquirePtyCleanup } from "./pty-cleanup" import { executeVscodeTask } from "./task-runner" -import { runWorktreeSetupScript } from "./setup-script-task" +import { runLifecycleSetup } from "./provider-lifecycle" import { RunController } from "./run/controller" import { handleRunMessage } from "./run/message" import { createRunController, createScriptTerminalRuntime, clearScriptTerminals } from "./script-terminal-runtime" @@ -62,7 +61,7 @@ import { sandboxSessionMetadata } from "../shared/sandbox-session" import { createOrchestrationBridge } from "./orchestration-setup" import type { AgentManagerOrchestrationBridge } from "./orchestration-bridge" import { pruneSubagents } from "./prune-subagents" -import { startSession } from "./mcp-warmup" +import { prepareDirectory, startSession } from "./mcp-warmup" import { readTerminalFont, watchTerminalFont } from "./terminal-font" import { DestinationState, handleDestination, watchTerminalDestination } from "./terminal-destination" import { buildKeybindingMap } from "./format-keybinding" @@ -982,7 +981,11 @@ export class AgentManagerProvider implements Disposable { worktreeId, }) + const preparation = { pending: Promise.resolve() } try { + preparation.pending = prepareDirectory(client, worktreePath).catch((err) => + this.log("Worktree preparation failed:", err), + ) const metadata = await (boot?.metadata() ?? sandboxSessionMetadata(this.connectionService.sandboxPreference, client, worktreePath)) if (boot) timing?.mark("boot", boot.at) @@ -1004,6 +1007,7 @@ export class AgentManagerProvider implements Disposable { timing?.mark("session") return session } catch (error) { + await preparation.pending const err = getErrorMessage(error) this.postToWebview({ type: "agentManager.worktreeSetup", @@ -1106,7 +1110,8 @@ export class AgentManagerProvider implements Disposable { releasePtyCleanup() } }, - setup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id), + hasScript: () => this.getSetupScriptService()?.hasScript() ?? false, + setup: (dir, branch, id, early) => this.runSetupScriptForWorktree(dir, branch, id, early), createSessionInWorktree: (dir, branch, id, source, boot, timing) => this.createSessionInWorktree(dir, branch, id, source, boot, timing), sessionMetadata: (client, dir) => sandboxSessionMetadata(this.connectionService.sandboxPreference, client, dir), @@ -1127,7 +1132,8 @@ export class AgentManagerProvider implements Disposable { private async onCreateWorktree(baseBranch?: string, branchName?: string): Promise { const ctx = this.context if (!ctx) return null - return createLifecycleWorktree(ctx, this.lifecycleHost, { baseBranch, branchName }) + await createLifecycleWorktree(ctx, this.lifecycleHost, { baseBranch, branchName }) + return null } /** Delete a worktree and dissociate its sessions. */ @@ -1228,41 +1234,32 @@ export class AgentManagerProvider implements Disposable { } /** Copy .env files and run the worktree setup script. Blocks until complete. Shows progress in overlay. */ - private async runSetupScriptForWorktree(worktreePath: string, branch?: string, worktreeId?: string): Promise { + private async runSetupScriptForWorktree( + worktreePath: string, + branch?: string, + worktreeId?: string, + early?: () => Promise, + ): Promise { const root = this.getRoot() if (!root) return - // Always copy .env files from the main repo (before the setup script so it can override) - await copyEnvFiles(root, worktreePath, (msg) => this.outputChannel.appendLine(`[EnvCopy] ${msg}`)) - - try { - await runWorktreeSetupScript( - { - service: this.getSetupScriptService(), - destination: this.destination.value(), - projectId: this.context?.id, - worktreeId, - branch, - trusted: () => this.host.isTrusted(), - manager: this.scripts.manager, - vscode: executeVscodeTask, - log: (msg) => this.outputChannel.appendLine(`[SetupScript] ${msg}`), - post: (message) => this.postToWebview(message), - }, - { worktreePath, repoPath: root }, - ) - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - this.outputChannel.appendLine(`[AgentManager] Setup script error: ${msg}`) - this.postToWebview({ - type: "agentManager.worktreeSetup", - status: "error", - message: `Setup script failed: ${msg}`, + await runLifecycleSetup( + { + service: this.getSetupScriptService(), + destination: this.destination.value(), projectId: this.context?.id, - branch, worktreeId, - }) - } + branch, + trusted: () => this.host.isTrusted(), + manager: this.scripts.manager, + vscode: executeVscodeTask, + log: (msg) => this.outputChannel.appendLine(`[SetupScript] ${msg}`), + post: (message) => this.postToWebview(message), + }, + { worktreePath, repoPath: root }, + (msg) => this.outputChannel.appendLine(msg), + early, + ) } // Repo info @@ -1456,7 +1453,8 @@ export class AgentManagerProvider implements Disposable { private get lifecycleHost(): LifecycleHost { return { createOnDisk: (opts) => this.createWorktreeOnDisk(opts), - runSetup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id), + hasScript: () => this.getSetupScriptService()?.hasScript() ?? false, + runSetup: (dir, branch, id, early) => this.runSetupScriptForWorktree(dir, branch, id, early), createSession: (dir, branch, id, boot, timing) => this.createSessionInWorktree(dir, branch, id, undefined, boot, timing), notifyReady: (sid, result, id) => this.notifyWorktreeReady(sid, result, id), diff --git a/packages/kilo-vscode/src/agent-manager/creation-plan.ts b/packages/kilo-vscode/src/agent-manager/creation-plan.ts new file mode 100644 index 000000000000..3a7fb34af34f --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/creation-plan.ts @@ -0,0 +1,5 @@ +export type Start = "immediate" | "afterSetup" + +export function plan(input: { setupScript: boolean }): Start { + return input.setupScript ? "afterSetup" : "immediate" +} diff --git a/packages/kilo-vscode/src/agent-manager/mcp-warmup.ts b/packages/kilo-vscode/src/agent-manager/mcp-warmup.ts index 60f95f836e66..fffbca0bc210 100644 --- a/packages/kilo-vscode/src/agent-manager/mcp-warmup.ts +++ b/packages/kilo-vscode/src/agent-manager/mcp-warmup.ts @@ -3,6 +3,19 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" type Client = Pick type Log = (...args: unknown[]) => void +export async function prepareDirectory( + client: Pick, + dir: string, +): Promise { + const results = await Promise.allSettled([ + client.config.get({ directory: dir }, { throwOnError: true }), + client.mcp.status({ directory: dir }, { throwOnError: true }), + client.kilocode.snapshot.prepare({ directory: dir }, { throwOnError: true }), + ]) + const failure = results.find((result) => result.status === "rejected") + if (failure?.status === "rejected") throw failure.reason +} + async function warm(client: Client, dir: string, log: Log): Promise { log(`[MCPWarmup] Starting for ${dir}`) await client.mcp.status({ directory: dir }, { throwOnError: true }) diff --git a/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts b/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts index ee9643a2d7fd..08e4568a92df 100644 --- a/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts +++ b/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts @@ -12,8 +12,59 @@ import { recordPromotionHandoff } from "./promotion-handoff" import { stopSessionProcesses } from "../kilo-provider/background-process" import { routeProjectSession } from "./project/messages" import { Timing } from "./creation-timing" +import { plan, type Start } from "./creation-plan" +import { copyEnvFiles } from "./env-copy" +import { runWorktreeSetupScript } from "./setup-script-task" -/** A backend-instance boot that started before setup, awaited before session creation. */ +export async function runLifecycleSetup( + input: Parameters[0], + env: Parameters[1], + output: (message: string) => void, + early?: () => Promise, +): Promise { + await copyEnvFiles(env.repoPath, env.worktreePath, (msg) => output(`[EnvCopy] ${msg}`)) + if (!input.service?.hasScript()) await early?.() + try { + await runWorktreeSetupScript(input, env) + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + output(`[AgentManager] Setup script error: ${msg}`) + input.post({ + type: "agentManager.worktreeSetup", + status: "error", + message: `Setup script failed: ${msg}`, + projectId: input.projectId, + branch: input.branch, + worktreeId: input.worktreeId, + }) + } +} + +/** Setup calls the optional early step only after copying .env files. */ +export async function prepareSession( + start: Start, + setup: (early?: () => Promise) => Promise, + create: () => Promise, +) { + const result = Promise.withResolvers<{ session: Session | null; ready: Promise; done: Promise }>() + const ready = Promise.withResolvers() + const pending = { created: false } + const provision = async () => { + if (pending.created) return + pending.created = true + const session = await create() + ready.resolve() + result.resolve({ session, ready: ready.promise, done }) + } + const done = Promise.resolve() + .then(() => setup(start === "immediate" ? provision : undefined)) + .then(provision) + // The caller owns completion, including failures after the early result. + void done.catch(result.reject) + return result.promise +} + +/** A backend-instance boot started after directory preparation, awaited before session creation. */ export interface CreationBoot { /** Timing clock reading captured when the boot request started. */ at: number @@ -41,7 +92,8 @@ export function beginBoot(start: () => Promise>, timing? */ export interface LifecycleHost { createOnDisk: (opts?: CreateWorktreeOnDiskOptions) => Promise - runSetup: (dir: string, branch: string, id: string) => Promise + hasScript: () => boolean + runSetup: (dir: string, branch: string, id: string, early?: () => Promise) => Promise createSession: ( dir: string, branch: string, @@ -88,7 +140,7 @@ export async function createLifecycleWorktree( ctx: ProjectContext, host: LifecycleHost, opts: { baseBranch?: string; branchName?: string }, -): Promise { +): Promise<{ session: Session; ready: Promise } | null> { const timing = Timing.start(`create ${opts.branchName ?? "worktree"}`, host.log) await initContextState(ctx, host.log) @@ -101,22 +153,19 @@ export async function createLifecycleWorktree( return null } - // Boot the new directory's backend instance at once, concurrently with the - // .env copy and setup script. Session creation and MCP warmup still wait for - // setup to finish because plugins may depend on installed files. - const boot = beginBoot(() => host.metadata(host.client(), created.result.path), timing) - - // Run setup script for new worktree (blocks until complete, shows in overlay) - await host.runSetup(created.result.path, created.result.branch, created.worktree.id) - timing.mark("setup") - - const session = await host.createSession( - created.result.path, - created.result.branch, - created.worktree.id, - boot, - timing, + const prepared = await prepareSession( + plan({ setupScript: host.hasScript() }), + async (early) => { + await host.runSetup(created.result.path, created.result.branch, created.worktree.id, early) + timing.mark("setup") + }, + () => { + const boot = beginBoot(() => host.metadata(host.client(), created.result.path), timing) + return host.createSession(created.result.path, created.result.branch, created.worktree.id, boot, timing) + }, ) + const { session, ready } = prepared + await prepared.done if (!session) { let releasePtyCleanup: () => void try { @@ -161,7 +210,7 @@ export async function createLifecycleWorktree( ...span.phases, }) host.log(`Created worktree ${created.worktree.id} with session ${session.id}`) - return null + return { session, ready } } /** Delete a worktree and dissociate its sessions. */ diff --git a/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts b/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts index 9ab63f56d39c..952896965655 100644 --- a/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts +++ b/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts @@ -5,7 +5,8 @@ import type { AgentManagerInMessage } from "./types" import { sanitizeBranchName, versionedName } from "./branch-name" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" import { ensureSandbox } from "./sandbox-bootstrap" -import { beginBoot, type LifecycleHost } from "./provider-lifecycle" +import { beginBoot, prepareSession, type LifecycleHost } from "./provider-lifecycle" +import { plan } from "./creation-plan" import { Timing } from "./creation-timing" import { Semaphore } from "./semaphore" import type { WorktreeCreationFailure } from "./worktree-create" @@ -88,24 +89,24 @@ export async function createMultiVersion( // Phase 2: Git creation is complete, so independent setup/session pipelines // can overlap without racing the shared worktree metadata mutation. const provision = async (version: PreparedVersion) => { - const ready = await provisionVersion(ctx, host, version) + const ready = await provisionVersion(ctx, host, version, (session) => + sendInitialPrompt( + host, + ctx.id, + session, + models, + { providerID, modelID }, + { + text, + agent, + variant: msg.variant, + files, + }, + ), + ) if (!ready) return created.push(ready) - sendInitialPrompt( - host, - ctx.id, - ready, - models, - { providerID, modelID }, - { - text, - agent, - variant: msg.variant, - files, - }, - ) - host.post({ type: "agentManager.multiVersionProgress", projectId: ctx.id, @@ -187,18 +188,25 @@ async function provisionVersion( ctx: ProjectContext, host: MultiVersionHost, prepared: PreparedVersion, + initial: (created: CreatedVersion) => void, ): Promise { const { spec, wt } = prepared const timing = Timing.start(`create ${wt.result.branch} v${spec.index + 1}`, host.log) - // Boot the new directory concurrently with the setup script. Session creation - // and MCP warmup still wait for setup, which may install files plugins need. - const boot = beginBoot(() => host.metadata(host.client(), wt.result.path), timing) - await host.runSetup(wt.result.path, wt.result.branch, wt.worktree.id) - timing.mark("setup") - - const session = await host.createSession(wt.result.path, wt.result.branch, wt.worktree.id, boot, timing) + const provisioned = await prepareSession( + plan({ setupScript: host.hasScript() }), + async (early) => { + await host.runSetup(wt.result.path, wt.result.branch, wt.worktree.id, early) + timing.mark("setup") + }, + () => { + const boot = beginBoot(() => host.metadata(host.client(), wt.result.path), timing) + return host.createSession(wt.result.path, wt.result.branch, wt.worktree.id, boot, timing) + }, + ) + const { session, ready, done } = provisioned if (!session) { + await done let releasePtyCleanup: () => void try { releasePtyCleanup = await host.acquirePtyCleanup(wt.result.path) @@ -233,6 +241,7 @@ async function provisionVersion( // Sandbox must match the user's choice before this session is exposed or // receives its initial prompt. A failed reconciliation aborts this version. if (spec.sandbox !== undefined && !(await reconcileSandbox(host, spec, wt, session.id))) { + await done timing.mark("cleanup") timing.end() return null @@ -259,6 +268,20 @@ async function provisionVersion( }) } + const result: CreatedVersion = { + worktreeId: wt.worktree.id, + sessionId: session.id, + path: wt.result.path, + branch: wt.result.branch, + parentBranch: wt.result.parentBranch, + versionIndex: spec.index, + } + await ready + try { + initial(result) + } finally { + await done + } const span = timing.end() host.capture("Agent Manager Session Started", { source: PLATFORM, @@ -274,14 +297,7 @@ async function provisionVersion( }) host.log(`Version ${spec.index + 1} worktree ready: session=${session.id}`) - return { - worktreeId: wt.worktree.id, - sessionId: session.id, - path: wt.result.path, - branch: wt.result.branch, - parentBranch: wt.result.parentBranch, - versionIndex: spec.index, - } + return result } /** Reconcile the sandbox preference for one version; rolls the worktree back on failure. */ diff --git a/packages/kilo-vscode/src/agent-manager/tool-start.ts b/packages/kilo-vscode/src/agent-manager/tool-start.ts index f5804b3dba04..ec7735c8587c 100644 --- a/packages/kilo-vscode/src/agent-manager/tool-start.ts +++ b/packages/kilo-vscode/src/agent-manager/tool-start.ts @@ -6,8 +6,9 @@ import type { PanelContext } from "./host" import { PLATFORM, SNAPSHOT_INITIALIZATION } from "./constants" import { sameDirectory } from "../kilo-provider-utils" import { attribute } from "./prompt-attribution" -import { beginBoot, type CreationBoot } from "./provider-lifecycle" +import { beginBoot, prepareSession, type CreationBoot } from "./provider-lifecycle" import { Timing } from "./creation-timing" +import { plan } from "./creation-plan" const LABEL_MAX = 28 const PREFIX = new Set(["feat", "fix", "chore", "bug", "issue", "task", "branch"]) @@ -57,7 +58,8 @@ export interface ToolDeps { }) => Promise claimRequest?: (requestID: string) => boolean cleanupWorktree: (wid: string, dir: string) => Promise - setup: (dir: string, branch?: string, id?: string) => Promise + hasScript: () => boolean + setup: (dir: string, branch?: string, id?: string, early?: () => Promise) => Promise createSessionInWorktree: ( dir: string, branch: string, @@ -220,20 +222,27 @@ async function worktree( return false } - // Boot the new directory while the setup script runs. Session creation and - // MCP warmup still wait for setup, which may install files plugins need. - const boot = beginBoot(() => deps.sessionMetadata(client, created.result.path), timing) - await deps.setup(created.result.path, created.result.branch, created.worktree.id) - timing.mark("setup") - const session = await deps.createSessionInWorktree( - created.result.path, - created.result.branch, - created.worktree.id, - source, - boot, - timing, + const prepared = await prepareSession( + plan({ setupScript: deps.hasScript() }), + async (early) => { + await deps.setup(created.result.path, created.result.branch, created.worktree.id, early) + timing.mark("setup") + }, + () => { + const boot = beginBoot(() => deps.sessionMetadata(client, created.result.path), timing) + return deps.createSessionInWorktree( + created.result.path, + created.result.branch, + created.worktree.id, + source, + boot, + timing, + ) + }, ) + const { session, ready } = prepared if (!session) { + await prepared.done await deps.cleanupWorktree(created.worktree.id, created.result.path) timing.mark("cleanup") timing.end() @@ -242,6 +251,7 @@ async function worktree( const state = deps.getState() if (!state) { + await prepared.done await deps.cleanupWorktree(created.worktree.id, created.result.path) timing.mark("cleanup") timing.end() @@ -253,7 +263,12 @@ async function worktree( deps.notifyReady(session.id, created.result, created.worktree.id) deps.getPanel()?.sessions.registerSession(session) timing.mark("ready") - await prompt(client, session.id, created.result.path, task, source) + await ready + try { + await prompt(client, session.id, created.result.path, task, source) + } finally { + await prepared.done + } const span = timing.end() deps.capture("Agent Manager Session Started", { source: PLATFORM, diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 6e035725143b..71112ab645d7 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -232,7 +232,7 @@ describe("Agent Manager Provider Messages", () => { const text = method!.getText() // Follow one-line delegations into the extracted lifecycle module so the // assertions keep covering the real handler logic. - const delegated = text.match(/return (\w+Lifecycle\w+)\(/) + const delegated = text.match(/(?:return|await) (\w+Lifecycle\w+)\(/) if (!delegated) return text const lifecycle = project.addSourceFileAtPath(path.join(ROOT, "src/agent-manager/provider-lifecycle.ts")) const fn = lifecycle.getFunction(delegated[1]!) @@ -532,7 +532,7 @@ describe("Agent Manager Provider — onMessage routing", () => { const text = method!.getText() // Follow one-line delegations into the extracted handler modules so the // assertions keep covering the real handler logic. - const delegated = text.match(/return (\w+Lifecycle\w+|createMultiVersion)\(/) + const delegated = text.match(/(?:return|await) (\w+Lifecycle\w+|createMultiVersion)\(/) if (!delegated) return text const module = delegated[1] === "createMultiVersion" ? "provider-multi-version.ts" : "provider-lifecycle.ts" const lifecycle = source.getProject().addSourceFileAtPath(path.join(ROOT, "src/agent-manager", module)) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-provider-lifecycle.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-provider-lifecycle.test.ts index 0ad5120dbb24..714492542437 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-provider-lifecycle.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-provider-lifecycle.test.ts @@ -5,6 +5,7 @@ import * as path from "node:path" import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client" import { ProjectContext } from "../../src/agent-manager/project/context" import { + createLifecycleWorktree, deleteLifecycleWorktree, removeStaleLifecycleWorktree, type LifecycleHost, @@ -84,6 +85,7 @@ describe("Agent Manager worktree deletion lifecycle", () => { } host = { createOnDisk: async () => null, + hasScript: () => true, runSetup: async () => undefined, createSession: async () => null, notifyReady: () => undefined, @@ -132,6 +134,36 @@ describe("Agent Manager worktree deletion lifecycle", () => { const deleteWorktree = async () => deleteLifecycleWorktree(ctx, host, state.getWorktrees()[0]!.id) + it("does not boot the interactive directory until the setup script finishes", async () => { + await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 })) + const entered = Promise.withResolvers() + const gate = Promise.withResolvers() + const wt = state.getWorktrees().at(0)! + host.createOnDisk = async () => ({ worktree: wt, result: { path: worktree, branch: wt.branch } }) as never + host.runSetup = async () => { + calls.push("setup:start") + entered.resolve() + await gate.promise + calls.push("setup:end") + } + host.metadata = async () => { + calls.push("boot") + return {} + } + host.createSession = async (_dir, _branch, _id, boot) => { + await boot!.metadata() + calls.push("session") + return { id: "created" } as never + } + const pending = createLifecycleWorktree(ctx, host, {}) + await entered.promise + expect(calls).toEqual(["setup:start"]) + gate.resolve() + const result = await pending + await result!.ready + expect(calls).toEqual(["setup:start", "setup:end", "boot", "session"]) + }) + it("removes and persists a missing stale entry even when backend terminal cleanup fails", async () => { const id = state.getWorktrees().at(0)!.id state.addSession("first", id) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts index 3287651b2dc3..eb13e8711622 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-tool-start.test.ts @@ -44,6 +44,7 @@ function deps(overrides: Partial = {}): ToolDeps { waitReady: mock(async () => calls.push("waitReady")), createWorktree: mock(async () => ({ worktree: { id: "wt-1" }, result: result("/repo/.kilo/worktrees/wt-1") })), cleanupWorktree: mock(async () => calls.push("cleanupWorktree")), + hasScript: () => true, setup: mock(async () => calls.push("setup")), createSessionInWorktree: mock(async () => session("s-wt")), sessionMetadata: mock(async () => ({ "kilocode.sandbox": { enabled: true, version: 0 } })), @@ -59,6 +60,55 @@ function deps(overrides: Partial = {}): ToolDeps { } describe("agent manager tool start", () => { + it.each([false, true])("gates the worktree prompt on setup script presence (%s)", async (script) => { + const flow: string[] = [] + const gate = Promise.withResolvers() + const entered = Promise.withResolvers() + const prompted = Promise.withResolvers() + const client = { + session: { + promptAsync: async () => { + flow.push("prompt") + prompted.resolve() + return {} + }, + }, + } + const host = deps({ + getClient: () => client as never, + hasScript: () => script, + sessionMetadata: async () => { + flow.push("boot") + return {} + }, + setup: async (_dir, _branch, _id, early) => { + flow.push("env") + await early?.() + entered.resolve() + await gate.promise + flow.push("setup:end") + }, + createSessionInWorktree: async () => { + flow.push("create") + return session("s-wt") + }, + }) + const pending = startFromTool(host, { + requestID: "gate", + mode: "worktree", + tasks: [{ prompt: "Fix it" }], + }) + await entered.promise + if (!script) await prompted.promise + expect(flow.includes("prompt")).toBe(!script) + expect(flow.includes("boot")).toBe(!script) + gate.resolve() + await pending + expect(flow).toEqual( + script ? ["env", "setup:end", "boot", "create", "prompt"] : ["env", "boot", "create", "prompt", "setup:end"], + ) + }) + for (const mode of ["local", "worktree"] as const) { for (const source of [undefined, "ses_source"]) { it(`attributes initial ${mode} prompts only with a source (${source ?? "ordinary"})`, async () => { diff --git a/packages/kilo-vscode/tests/unit/creation-plan.test.ts b/packages/kilo-vscode/tests/unit/creation-plan.test.ts new file mode 100644 index 000000000000..0c4c0ad628c1 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/creation-plan.test.ts @@ -0,0 +1,134 @@ +import { expect, it } from "bun:test" +import { plan } from "../../src/agent-manager/creation-plan" +import { prepareDirectory } from "../../src/agent-manager/mcp-warmup" +import { prepareSession, runLifecycleSetup } from "../../src/agent-manager/provider-lifecycle" +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { SetupScriptService } from "../../src/agent-manager/SetupScriptService" + +it("defers provisioning when a real setup script appears after the initial plan", async () => { + const root = await mkdtemp(join(tmpdir(), "creation-script-")) + const dir = join(root, "worktree") + const service = new SetupScriptService(root) + const start = plan({ setupScript: service.hasScript() }) + expect(start).toBe("immediate") + const entered = Promise.withResolvers() + const gate = Promise.withResolvers() + const flow: string[] = [] + try { + await mkdir(dir) + await service.createDefaultScript() + const input = { + service, + destination: "vscode", + log: () => {}, + post: () => {}, + vscode: async () => { + flow.push("setup:start") + entered.resolve() + await gate.promise + await writeFile(join(dir, ".env"), "PLUGIN=installed\n") + flow.push("setup:end") + return 0 + }, + } as Parameters[0] + const pending = prepareSession( + start, + (early) => runLifecycleSetup(input, { repoPath: root, worktreePath: dir }, () => {}, early), + async () => { + flow.push("boot") + expect(await readFile(join(dir, ".env"), "utf8")).toBe("PLUGIN=installed\n") + return null + }, + ) + await entered.promise + expect(flow).toEqual(["setup:start"]) + gate.resolve() + await ( + await pending + ).done + expect(flow).toEqual(["setup:start", "setup:end", "boot"]) + } finally { + gate.resolve() + await rm(root, { recursive: true, force: true }) + } +}) + +it("starts immediately only when no setup script exists", () => { + expect(plan({ setupScript: false })).toBe("immediate") + expect(plan({ setupScript: true })).toBe("afterSetup") +}) + +it("copies real .env files before the early session step", async () => { + const root = await mkdtemp(join(tmpdir(), "creation-plan-")) + const dir = join(root, "worktree") + try { + await mkdir(dir) + await writeFile(join(root, ".env"), "VALUE=ready\n") + const input = { service: undefined } as Parameters[0] + await runLifecycleSetup( + input, + { repoPath: root, worktreePath: dir }, + () => {}, + async () => { + expect(await readFile(join(dir, ".env"), "utf8")).toBe("VALUE=ready\n") + }, + ) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it("rejects setup and session failures instead of leaving readiness pending", async () => { + const fail = async () => { + throw new Error("creation failed") + } + await expect(prepareSession("afterSetup", fail, async () => null)).rejects.toThrow("creation failed") + await expect(prepareSession("immediate", async (early) => early?.(), fail)).rejects.toThrow("creation failed") +}) + +it("prepares directory endpoints in parallel and rejects failures", async () => { + const calls: string[] = [] + const gate = Promise.withResolvers() + const snapshot = Promise.withResolvers<{ data: boolean }>() + const settled = { value: false } + const client = { + config: { + get: ({ directory }: { directory: string }) => { + calls.push(`config:${directory}`) + return gate.promise + }, + }, + mcp: { + status: async ({ directory }: { directory: string }) => { + calls.push(`mcp:${directory}`) + return { data: {} } + }, + }, + kilocode: { + snapshot: { + prepare: ({ directory }: { directory: string }) => { + calls.push(`snapshot:${directory}`) + return snapshot.promise + }, + }, + }, + } as unknown as Parameters[0] + const pending = prepareDirectory(client, "/slot") + const result = pending.then( + () => { + settled.value = true + }, + (err: unknown) => { + settled.value = true + return err + }, + ) + expect(calls).toEqual(["config:/slot", "mcp:/slot", "snapshot:/slot"]) + gate.reject(new Error("boot failed")) + await new Promise((resolve) => setImmediate(resolve)) + expect(settled.value).toBe(false) + snapshot.resolve({ data: true }) + expect(await result).toEqual(new Error("boot failed")) +}) diff --git a/packages/kilo-vscode/tests/unit/provider-multi-version.test.ts b/packages/kilo-vscode/tests/unit/provider-multi-version.test.ts index 02f266792a42..cb02e8d8600b 100644 --- a/packages/kilo-vscode/tests/unit/provider-multi-version.test.ts +++ b/packages/kilo-vscode/tests/unit/provider-multi-version.test.ts @@ -61,6 +61,7 @@ describe("multi-version provisioning", () => { await gates[index]?.promise } }), + hasScript: () => true, createSession: mock(async (dir: string) => ({ id: `session-${dir.at(-1)!}` }) as Session), autoName: () => ({ enabled: false }), register: mock(() => {}), @@ -130,10 +131,11 @@ describe("multi-version provisioning", () => { expect(error).toHaveBeenCalledWith("Failed to create any of the 1 multi-version worktrees.") }) - it("boots the directory before setup finishes and creates the session after", async () => { + it.each([false, true])("gates initial prompts on setup script presence (%s)", async (script) => { const flow: string[] = [] const setupEntered = Promise.withResolvers() const setupGate = Promise.withResolvers() + const prompted = Promise.withResolvers() const state = { addSession: mock(() => {}), armAutoName: mock(() => {}) } const ctx = { id: "project-1", @@ -143,7 +145,11 @@ describe("multi-version provisioning", () => { } as unknown as ProjectContext const host = { log: mock(() => {}), - post: mock(() => {}), + post: mock((msg: { type: string }) => { + if (msg.type !== "agentManager.sendInitialMessage") return + flow.push("prompt") + prompted.resolve() + }), createOnDisk: mock(async () => { return { worktree: { id: "wt-0" }, @@ -155,8 +161,11 @@ describe("multi-version provisioning", () => { return {} }), client: () => ({}) as never, - runSetup: mock(async () => { + hasScript: () => script, + runSetup: mock(async (_dir: string, _branch: string, _id: string, early?: () => Promise) => { flow.push("setup:start") + flow.push("env") + await early?.() setupEntered.resolve() await setupGate.promise flow.push("setup:end") @@ -184,11 +193,17 @@ describe("multi-version provisioning", () => { }) await setupEntered.promise - expect(flow).toEqual(["boot", "setup:start"]) + if (!script) await prompted.promise + expect(flow.includes("prompt")).toBe(!script) + expect(flow.includes("boot")).toBe(!script) setupGate.resolve() await pending - expect(flow).toEqual(["boot", "setup:start", "setup:end", "create"]) + expect(flow).toEqual( + script + ? ["setup:start", "env", "setup:end", "boot", "create", "prompt"] + : ["setup:start", "env", "boot", "create", "prompt", "setup:end"], + ) }) }) diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts index 4b8b35caa50e..aafd7454eeb4 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts @@ -99,6 +99,7 @@ export const KilocodePaths = { removeSkill: `${root}/skill/remove`, removeAgent: `${root}/agent/remove`, removeSnapshot: `${root}/snapshot/remove`, + prepareSnapshot: `${root}/snapshot/prepare`, providerUsage: `${root}/provider-usage`, providerUsageRefresh: `${root}/provider-usage/refresh`, notebookList: `${root}/notebook`, @@ -244,6 +245,20 @@ export const KilocodeApi = HttpApi.make("kilocode") description: "Remove the snapshot repository for an already deleted Agent Manager worktree.", }), ), + HttpApiEndpoint.post("prepareSnapshot", KilocodePaths.prepareSnapshot, { + query: WorkspaceRoutingQuery, + success: described( + Schema.Struct({ prepared: Schema.Boolean, durationMs: Schema.Number }), + "Snapshot repository preparation result", + ), + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.snapshot.prepare", + summary: "Prepare a snapshot repository", + description: + "Initialize and seed snapshots for the routed directory without creating a session or tracking ref.", + }), + ), HttpApiEndpoint.get("providerUsage", KilocodePaths.providerUsage, { query: WorkspaceRoutingQuery, success: described(ProviderUsage.Info, "Current provider usage"), diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index 9ee1cba8b41d..84521274bf75 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -41,6 +41,8 @@ import { Drained } from "@opencode-ai/schema/kilocode/session-drain" import { SessionID } from "@/session/schema" import { RuntimeFlags } from "@/effect/runtime-flags" import { KiloSnapshotCleanup } from "@/kilocode/snapshot/cleanup" +import { Snapshot } from "@/snapshot" +import { KiloSnapshotPrepare } from "@/kilocode/snapshot/prepare" import { Global } from "@opencode-ai/core/global" import { FSUtil } from "@opencode-ai/core/fs-util" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" @@ -86,6 +88,7 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" const events = yield* EventV2Bridge.Service const database = yield* Database.Service const scope = yield* Scope.Scope + const snapshot = yield* Snapshot.Service const board = (work: Effect.Effect) => work.pipe( @@ -402,6 +405,13 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" .handle("removeSkill", removeSkill) .handle("removeAgent", removeAgent) .handle("removeSnapshot", removeSnapshot) + .handle("prepareSnapshot", () => + Effect.gen(function* () { + const started = performance.now() + const prepared = yield* KiloSnapshotPrepare.run(snapshot) + return { prepared, durationMs: performance.now() - started } + }), + ) .handle("providerUsage", providerUsage) .handle("providerUsageRefresh", providerUsageRefresh) .handle("notebookList", notebookList) diff --git a/packages/opencode/src/kilocode/snapshot/prepare.ts b/packages/opencode/src/kilocode/snapshot/prepare.ts new file mode 100644 index 000000000000..c5e6268ac8a2 --- /dev/null +++ b/packages/opencode/src/kilocode/snapshot/prepare.ts @@ -0,0 +1,67 @@ +import { Effect } from "effect" +import path from "path" +import { KiloSnapshotSeed } from "./seed" +import { KiloSnapshotMaterialize } from "./materialize" +import type { Snapshot } from "@/snapshot" + +export namespace KiloSnapshotPrepare { + const services = new WeakMap Effect.Effect>() + + export function bind(service: Snapshot.Interface, prepare: () => Effect.Effect) { + services.set(service, prepare) + return service + } + + export const run = Effect.fnUntraced(function* (service: Snapshot.Interface) { + const prepare = services.get(service) + if (!prepare) return yield* Effect.die(new Error("Snapshot preparation is unavailable")) + return yield* prepare() + }) + + // Called under the snapshot lock so preparation cannot race startup recovery. + export const resume = Effect.fnUntraced(function* (input: KiloSnapshotMaterialize.Input) { + const marker = path.join(input.gitdir, "kilo-prepared") + if (yield* input.fs.exists(marker)) { + const refs = yield* input.git([ + "--git-dir", + input.gitdir, + "for-each-ref", + "--format=%(refname)", + "refs/kilo/snapshots", + ]) + if (refs.code === 0 && !refs.text.trim()) return false + if (refs.code === 0) yield* input.fs.remove(marker) + } + return yield* KiloSnapshotMaterialize.run(input) + }) + + export const initialize = Effect.fnUntraced(function* (input: KiloSnapshotSeed.Input, prepare = false) { + if (yield* input.fs.exists(input.gitdir).pipe(Effect.orDie)) return + yield* input.fs.ensureDir(input.gitdir).pipe(Effect.orDie) + const commands = [ + ["init"], + ["--git-dir", input.gitdir, "config", "core.autocrlf", "false"], + ["--git-dir", input.gitdir, "config", "core.longpaths", "true"], + ["--git-dir", input.gitdir, "config", "core.symlinks", "true"], + ["--git-dir", input.gitdir, "config", "core.fsmonitor", "false"], + ] + return yield* Effect.gen(function* () { + for (const cmd of commands) { + const result = yield* input.git(cmd, { + env: { GIT_DIR: input.gitdir, GIT_WORK_TREE: input.worktree }, + }) + if (result.code !== 0) return yield* Effect.die(new Error(`Snapshot initialization failed: ${result.stderr}`)) + } + const seeded: KiloSnapshotSeed.Output = yield* KiloSnapshotSeed.seed(input) + if (prepare) yield* input.fs.writeFileString(path.join(input.gitdir, "kilo-prepared"), "").pipe(Effect.orDie) + yield* Effect.logInfo("initialized") + return seeded + }).pipe( + Effect.onExit((exit) => + exit._tag === "Success" + ? Effect.void + : input.fs.remove(input.gitdir, { recursive: true, force: true }).pipe(Effect.orDie), + ), + ) + }) +} diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 3336c0836fb7..2e44886fb0a6 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -17,7 +17,7 @@ import { Info } from "@opencode-ai/schema/file-diff" import { Flag } from "@opencode-ai/core/flag/flag" import { DiffFull } from "../kilocode/snapshot/diff-full" import { KiloSnapshotTrack } from "../kilocode/snapshot/track" -import { KiloSnapshotSeed } from "../kilocode/snapshot/seed" +import { KiloSnapshotPrepare } from "../kilocode/snapshot/prepare" import { KiloSnapshotMaterialize } from "../kilocode/snapshot/materialize" import type { MessageID, SessionID } from "../session/schema" import { withStatics } from "@opencode-ai/core/schema" @@ -55,7 +55,7 @@ interface GitResult { export const MAX_DIFF_SIZE = 256 * 1024 // kilocode_change -type State = Omit +type State = Omit & { prepare: () => Effect.Effect } // kilocode_change export interface Interface { readonly init: () => Effect.Effect @@ -322,7 +322,7 @@ export const layer: Layer.Layer = }) const materialize = Effect.fnUntraced(function* () { - yield* locked(KiloSnapshotMaterialize.run({ gitdir: state.gitdir, git, fs }).pipe(Effect.orDie)).pipe( + yield* locked(KiloSnapshotPrepare.resume({ gitdir: state.gitdir, git, fs }).pipe(Effect.orDie)).pipe( Effect.timeout("5 minutes"), Effect.catchCause((cause) => Effect.logError("snapshot materialization failed", { cause: Cause.pretty(cause) }), @@ -355,37 +355,39 @@ export const layer: Layer.Layer = ) }) - // kilocode_change start + // kilocode_change start - share locked initialization without creating a tracking ref + const initialize = (prepare = false) => + KiloSnapshotPrepare.initialize( + { + dir: state.directory, + worktree: state.worktree, + gitdir: state.gitdir, + limit, + git, + fs, + }, + prepare, + ) + + const prepare = Effect.fnUntraced(function* () { + if (yield* exists(state.gitdir)) return false + return yield* locked( + Effect.gen(function* () { + if (!(yield* enabled())) return false + return (yield* initialize(true)) !== undefined + }), + ) + }) + const track = Effect.fnUntraced(function* (opts?: Parameters[0]) { // kilocode_change end return yield* locked( Effect.gen(function* () { if (!(yield* enabled())) return - const existed = yield* exists(state.gitdir) - const seeded: { value?: KiloSnapshotSeed.Output } = {} // kilocode_change - yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie) - if (!existed) { - yield* git(["init"], { - env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree }, - }) - yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"]) - yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"]) - yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"]) - yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"]) - // kilocode_change start - seed all eligible new snapshots from the worktree index - seeded.value = yield* KiloSnapshotSeed.seed({ - dir: state.directory, - worktree: state.worktree, - gitdir: state.gitdir, - limit, - git, - fs, - }) - // kilocode_change end - yield* Effect.logInfo("initialized") - } // kilocode_change start - pin every snapshot before background materialization - const seed = seeded.value?.source + const seeded = yield* initialize() + const existed = seeded === undefined + const seed = seeded?.source const env = seed ? { GIT_OBJECT_DIRECTORY: seed.staging, @@ -912,7 +914,7 @@ export const layer: Layer.Layer = }) // kilocode_change end - return { cleanup, track, patch, restore, revert, diff, diffFull, diffFile } // kilocode_change - diffFile + return { cleanup, prepare, track, patch, restore, revert, diff, diffFull, diffFile } // kilocode_change }), ) @@ -922,7 +924,8 @@ export const layer: Layer.Layer = const max = 100 // kilocode_change end - return Service.of({ + // kilocode_change - bind preparation without changing the shared service interface + const service = Service.of({ init: Effect.fn("Snapshot.init")(function* () { yield* InstanceState.get(state) }), @@ -998,6 +1001,7 @@ export const layer: Layer.Layer = }), // kilocode_change end }) + return KiloSnapshotPrepare.bind(service, () => InstanceState.useEffect(state, (s) => s.prepare())) // kilocode_change }), ) diff --git a/packages/opencode/test/kilocode/snapshot-prepare.test.ts b/packages/opencode/test/kilocode/snapshot-prepare.test.ts new file mode 100644 index 000000000000..4fbd80397141 --- /dev/null +++ b/packages/opencode/test/kilocode/snapshot-prepare.test.ts @@ -0,0 +1,235 @@ +import { afterEach, expect, test } from "bun:test" +import { $ } from "bun" +import fs from "fs/promises" +import { existsSync } from "fs" +import path from "path" +import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Global } from "@opencode-ai/core/global" +import { Hash } from "@opencode-ai/core/util/hash" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { createKiloClient } from "@kilocode/sdk/v2" +import { Snapshot } from "../../src/snapshot" +import { Session } from "../../src/session/session" +import { Server } from "../../src/server/server" +import { InstanceState } from "../../src/effect/instance-state" +import { InstanceStore } from "../../src/project/instance-store" +import { KiloSnapshotPrepare } from "../../src/kilocode/snapshot/prepare" +import { KiloSnapshotMaterialize } from "../../src/kilocode/snapshot/materialize" +import { + disposeAllInstances, + provideInstance, + reloadTestInstance, + testInstanceStoreLayer, + tmpdir, + tmpdirScoped, +} from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" +import { pollWithTimeout, testEffect } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +test("prepares a routed worktree once without tracking, then tracks current content without reseeding", async () => { + await using source = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "note.txt"), "committed\n") + await $`git add note.txt`.cwd(dir).quiet() + await $`git commit -m baseline`.cwd(dir).quiet() + }, + }) + await using root = await tmpdir() + const dir = path.join(root.path, "worktree") + await $`git worktree add --detach ${dir} HEAD`.cwd(source.path).quiet() + const ctx = await reloadTestInstance({ directory: dir }) + const gitdir = path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)) + const app = Server.Default().app + const headers = { "x-kilo-directory": dir } + const route = "/kilocode/snapshot/prepare" + expect(existsSync(gitdir)).toBe(false) + + const first = await app.request(route, { method: "POST", headers }) + expect(first.status).toBe(200) + expect(await first.json()).toEqual({ prepared: true, durationMs: expect.any(Number) }) + expect(existsSync(path.join(gitdir, "HEAD"))).toBe(true) + const index = await fs.readFile(path.join(gitdir, "index")) + const stat = await fs.stat(path.join(gitdir, "index")) + expect((await $`git --git-dir=${gitdir} ls-files`.text()).trim()).toBe("note.txt") + expect((await $`git --git-dir=${gitdir} for-each-ref`.text()).trim()).toBe("") + const sessions = await app.request("/session", { headers }) + expect(await sessions.json()).toEqual([]) + + // A private config sentinel detects gitdir reinitialization without replacing the seed implementation. + await $`git --git-dir=${gitdir} config core.autocrlf input`.quiet() + const second = await Effect.runPromise( + EffectFlock.Service.use((flock) => + flock.withLock( + Effect.promise(async () => app.request(`${route}?directory=${encodeURIComponent(dir)}`, { method: "POST" })), + `snapshot:${gitdir}`, + ), + ).pipe(Effect.timeout("5 seconds"), Effect.provide(AppNodeBuilder.build(EffectFlock.node))), + ) + expect(second.status).toBe(200) + expect(await second.json()).toEqual({ prepared: false, durationMs: expect.any(Number) }) + expect(await fs.readFile(path.join(gitdir, "index"))).toEqual(index) + expect((await fs.stat(path.join(gitdir, "index"))).mtimeMs).toBe(stat.mtimeMs) + expect((await $`git --git-dir=${gitdir} for-each-ref`.text()).trim()).toBe("") + + // Reload must not materialize a prepared index or manufacture a tracking ref. + await reloadTestInstance({ directory: dir }) + const listener = await Server.listen({ hostname: "127.0.0.1", port: 0 }) + try { + const client = createKiloClient({ baseUrl: listener.url.toString() }) + const third = await client.kilocode.snapshot.prepare({ directory: dir }, { throwOnError: true }) + expect(third.response.status).toBe(200) + expect(third.data).toEqual({ prepared: false, durationMs: expect.any(Number) }) + } finally { + await listener.stop(true) + } + expect((await $`git --git-dir=${gitdir} for-each-ref`.text()).trim()).toBe("") + + await Bun.write(path.join(dir, "note.txt"), "changed after preparation\n") + await Bun.write(path.join(dir, "new.txt"), "new file\n") + const trace = path.join(root.path, "git-trace.jsonl") + const previous = process.env.GIT_TRACE2_EVENT + process.env.GIT_TRACE2_EVENT = trace + const hash = await Effect.runPromise( + Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const session = yield* (yield* Session.Service).create({ title: "prepared snapshot" }) + return yield* snapshot.track({ sessionID: session.id }) + }).pipe( + provideInstance(dir), + Effect.provide(Layer.mergeAll(AppNodeBuilder.build(Snapshot.node), AppNodeBuilder.build(Session.node))), + Effect.provide(testInstanceStoreLayer), + ), + ).finally(() => { + if (previous === undefined) delete process.env.GIT_TRACE2_EVENT + if (previous !== undefined) process.env.GIT_TRACE2_EVENT = previous + }) + expect(hash).toBeTruthy() + expect(await $`git --git-dir=${gitdir} show ${hash!}:note.txt`.text()).toBe("changed after preparation\n") + expect(await $`git --git-dir=${gitdir} show ${hash!}:new.txt`.text()).toBe("new file\n") + expect((await $`git --git-dir=${gitdir} config core.autocrlf`.text()).trim()).toBe("input") + expect((await $`git --git-dir=${gitdir} for-each-ref refs/kilo/snapshots`.text()).trim()).not.toBe("") + expect(await Bun.file(trace).text()).not.toContain("--no-split-index") + const alt = path.join(gitdir, "objects", "info", "alternates") + await Effect.runPromise( + pollWithTimeout( + Effect.sync(() => (!existsSync(alt) && !existsSync(`${alt}.materializing`) ? true : undefined)), + "snapshot materialization did not finish before fixture cleanup", + "10 seconds", + ), + ) +}, 30_000) + +const it = testEffect( + Layer.mergeAll(AppNodeBuilder.build(Snapshot.node), testInstanceStoreLayer).pipe( + Layer.provideMerge(AppNodeBuilder.build(CrossSpawnSpawner.node)), + ), +) + +it.live( + "prepared objects survive source pruning and later materialization preserves index-only recovery", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ + git: true, + init: (dir) => + Effect.promise(async () => { + await Bun.write(path.join(dir, "staged.txt"), "committed\n") + await $`git add .`.cwd(dir).quiet() + await $`git commit -m baseline`.cwd(dir).quiet() + await Bun.write(path.join(dir, "staged.txt"), "staged dirty content\n") + await $`git add staged.txt`.cwd(dir).quiet() + }), + }) + const git = (cmd: string[]) => Effect.promise(() => $`git ${cmd}`.cwd(dir).quiet().text()) + yield* Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + const ctx = yield* InstanceState.context + const gitdir = path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)) + const common = (yield* git(["rev-parse", "--path-format=absolute", "--git-common-dir"])).trim() + const ref = KiloSnapshotMaterialize.ref(gitdir) + const alt = path.join(gitdir, "objects", "info", "alternates") + const staging = path.join(gitdir, "seed-objects") + const staged = (yield* git(["rev-parse", ":staged.txt"])).trim() + + expect(yield* KiloSnapshotPrepare.run(snapshot)).toBe(true) + expect((yield* git(["--git-dir", gitdir, "for-each-ref"])).trim()).toBe("") + const seed = (yield* git(["rev-parse", ref])).trim() + + // Keep one index entry exclusively in the snapshot-owned staging alternate. + const file = path.join(dir, "private.txt") + yield* Effect.promise(() => Bun.write(file, "quarantined content\n")) + const privateHash = (yield* Effect.promise(() => + $`git --git-dir=${gitdir} hash-object -w ${file}` + .env({ ...process.env, GIT_OBJECT_DIRECTORY: staging }) + .quiet() + .text(), + )).trim() + yield* git(["--git-dir", gitdir, "update-index", "--add", "--cacheinfo", `100644,${privateHash},private.txt`]) + expect(existsSync(path.join(common, "objects", privateHash.slice(0, 2), privateHash.slice(2)))).toBe(false) + + // Remove the dirty blob from the source index, leaving only the seed pin to protect it. + yield* git(["read-tree", "HEAD"]) + yield* git(["reflog", "expire", "--expire=now", "--all"]) + yield* git(["gc", "--prune=now"]) + yield* git(["prune", "--expire=now"]) + expect((yield* git(["rev-parse", `${ref}:staged.txt`])).trim()).toBe(staged) + expect(yield* git(["--git-dir", gitdir, "show", `${seed}:staged.txt`])).toBe("staged dirty content\n") + expect(yield* git(["--git-dir", gitdir, "cat-file", "blob", privateHash])).toBe("quarantined content\n") + + yield* (yield* InstanceStore.Service).dispose(ctx) + yield* snapshot.init().pipe(provideInstance(dir)) + expect(yield* KiloSnapshotPrepare.run(snapshot).pipe(provideInstance(dir))).toBe(false) + expect((yield* git(["--git-dir", gitdir, "for-each-ref"])).trim()).toBe("") + expect(yield* git(["--git-dir", gitdir, "cat-file", "blob", privateHash])).toBe("quarantined content\n") + + const hash = yield* snapshot.track().pipe(provideInstance(dir)) + expect(hash).toBeTruthy() + const wait = pollWithTimeout( + Effect.sync(() => (!existsSync(alt) && !existsSync(`${alt}.materializing`) ? true : undefined)), + "snapshot materialization did not finish", + "5 seconds", + ) + yield* wait + expect(existsSync(staging)).toBe(false) + expect((yield* git(["for-each-ref", ref])).trim()).toBe("") + expect(yield* git(["--git-dir", gitdir, "show", `${hash}:staged.txt`])).toBe("staged dirty content\n") + expect(yield* git(["--git-dir", gitdir, "show", `${hash}:private.txt`])).toBe("quarantined content\n") + yield* git(["--git-dir", gitdir, "fsck", "--connectivity-only", "--no-dangling", "--no-reflogs"]) + + // Existing index-only repositories must still use the materializer's fallback pin. + const refs = (yield* git(["--git-dir", gitdir, "for-each-ref", "--format=%(refname)"])).trim().split("\n") + for (const ref of refs) yield* git(["--git-dir", gitdir, "update-ref", "-d", ref]) + yield* Effect.promise(() => fs.writeFile(alt, `${path.join(common, "objects")}\n`)) + yield* git(["update-ref", ref, seed]) + const current = yield* InstanceState.context.pipe(provideInstance(dir)) + yield* (yield* InstanceStore.Service).dispose(current) + yield* snapshot.init().pipe(provideInstance(dir)) + yield* wait + expect((yield* git(["--git-dir", gitdir, "for-each-ref", "refs/kilo/snapshots"])).trim()).not.toBe("") + expect((yield* git(["for-each-ref", ref])).trim()).toBe("") + }).pipe(provideInstance(dir)) + }), + 30_000, +) + +test("does not prepare disabled snapshots or directories outside git", async () => { + for (const opts of [{ git: true, config: { snapshot: false } }, {}]) { + await using tmp = await tmpdir(opts) + const ctx = await reloadTestInstance({ directory: tmp.path }) + const response = await Server.Default().app.request("/kilocode/snapshot/prepare", { + method: "POST", + headers: { "x-kilo-directory": tmp.path }, + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ prepared: false, durationMs: expect.any(Number) }) + expect(existsSync(path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)))).toBe(false) + } +}, 30_000) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index a758b596a839..e852e431e9c5 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -221,6 +221,8 @@ import type { KilocodeSessionImportSessionResponses, KilocodeSessionModelUsageErrors, KilocodeSessionModelUsageResponses, + KilocodeSnapshotPrepareErrors, + KilocodeSnapshotPrepareResponses, KiloEditErrors, KiloEditResponses, KiloFimErrors, @@ -7356,6 +7358,42 @@ export class Heap extends HeyApiClient { } } +export class Snapshot extends HeyApiClient { + /** + * Prepare a snapshot repository + * + * Initialize and seed snapshots for the routed directory without creating a session or tracking ref. + */ + public prepare( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeSnapshotPrepareResponses, + KilocodeSnapshotPrepareErrors, + ThrowOnError + >({ + url: "/kilocode/snapshot/prepare", + ...options, + ...params, + }) + } +} + export class ProviderUsage extends HeyApiClient { /** * Get provider usage @@ -8670,6 +8708,11 @@ export class Kilocode extends HeyApiClient { return (this._heap ??= new Heap({ client: this.client })) } + private _snapshot?: Snapshot + get snapshot(): Snapshot { + return (this._snapshot ??= new Snapshot({ client: this.client })) + } + private _providerUsage?: ProviderUsage get providerUsage(): ProviderUsage { return (this._providerUsage ??= new ProviderUsage({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 412b8e5291f1..4f287170aa7a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -16898,6 +16898,37 @@ export type KilocodeRemoveSnapshotResponses = { export type KilocodeRemoveSnapshotResponse = KilocodeRemoveSnapshotResponses[keyof KilocodeRemoveSnapshotResponses] +export type KilocodeSnapshotPrepareData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/snapshot/prepare" +} + +export type KilocodeSnapshotPrepareErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type KilocodeSnapshotPrepareError = KilocodeSnapshotPrepareErrors[keyof KilocodeSnapshotPrepareErrors] + +export type KilocodeSnapshotPrepareResponses = { + /** + * Snapshot repository preparation result + */ + 200: { + prepared: boolean + durationMs: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } +} + +export type KilocodeSnapshotPrepareResponse = KilocodeSnapshotPrepareResponses[keyof KilocodeSnapshotPrepareResponses] + export type KilocodeProviderUsageGetData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 81f9bcddefa8..7f0c978d3506 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -15609,6 +15609,91 @@ ] } }, + "/kilocode/snapshot/prepare": { + "post": { + "tags": ["kilocode"], + "operationId": "kilocode.snapshot.prepare", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Snapshot repository preparation result", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "prepared": { + "type": "boolean" + }, + "durationMs": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["prepared", "durationMs"], + "additionalProperties": false, + "description": "Snapshot repository preparation result" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Initialize and seed snapshots for the routed directory without creating a session or tracking ref.", + "summary": "Prepare a snapshot repository", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.kilocode.snapshot.prepare({\n ...\n})" + } + ] + } + }, "/kilocode/provider-usage": { "get": { "tags": ["kilocode"], From 01a37d638ef1eddeeb17b5387d50729fef0714c4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Sat, 12 Sep 2026 15:23:03 +0200 Subject: [PATCH 05/10] perf(agent-manager): cut git calls and warm agent discovery before the first prompt --- .changeset/worktree-pool-prewarm.md | 2 ++ .../src/agent-manager/mcp-warmup.ts | 5 ++++- .../tests/unit/creation-plan.test.ts | 8 +++++++- .../opencode/src/kilocode/primary-worktree.ts | 20 +++++++++++-------- packages/opencode/src/skill/index.ts | 10 +++++++--- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/.changeset/worktree-pool-prewarm.md b/.changeset/worktree-pool-prewarm.md index 24020f94339d..9489a78e4e7f 100644 --- a/.changeset/worktree-pool-prewarm.md +++ b/.changeset/worktree-pool-prewarm.md @@ -6,3 +6,5 @@ Speed up Agent Manager worktree creation by pre-warming reusable worktrees and claiming a ready one instead of running a full checkout. Control the pre-warming in Agent Manager settings under "Pre-warm worktrees"; it is enabled by default and uses one extra checkout of disk space per open project. Prepare snapshots during session creation to reduce first-prompt initialization work. Start no-script sessions after environment files are copied, while preserving setup-script completion before agent startup. + +Resolve the primary checkout with one git call instead of five and discover agents and skills for a new worktree before the first prompt arrives, so the first response starts sooner. diff --git a/packages/kilo-vscode/src/agent-manager/mcp-warmup.ts b/packages/kilo-vscode/src/agent-manager/mcp-warmup.ts index fffbca0bc210..7b22376f85ac 100644 --- a/packages/kilo-vscode/src/agent-manager/mcp-warmup.ts +++ b/packages/kilo-vscode/src/agent-manager/mcp-warmup.ts @@ -4,11 +4,14 @@ type Client = Pick type Log = (...args: unknown[]) => void export async function prepareDirectory( - client: Pick, + client: Pick, dir: string, ): Promise { + // Listing agents computes the agent and skill state for the directory, so + // the first prompt does not pay for that discovery after it arrives. const results = await Promise.allSettled([ client.config.get({ directory: dir }, { throwOnError: true }), + client.app.agents({ directory: dir }, { throwOnError: true }), client.mcp.status({ directory: dir }, { throwOnError: true }), client.kilocode.snapshot.prepare({ directory: dir }, { throwOnError: true }), ]) diff --git a/packages/kilo-vscode/tests/unit/creation-plan.test.ts b/packages/kilo-vscode/tests/unit/creation-plan.test.ts index 0c4c0ad628c1..83a9c294b64c 100644 --- a/packages/kilo-vscode/tests/unit/creation-plan.test.ts +++ b/packages/kilo-vscode/tests/unit/creation-plan.test.ts @@ -100,6 +100,12 @@ it("prepares directory endpoints in parallel and rejects failures", async () => return gate.promise }, }, + app: { + agents: async ({ directory }: { directory: string }) => { + calls.push(`agents:${directory}`) + return { data: [] } + }, + }, mcp: { status: async ({ directory }: { directory: string }) => { calls.push(`mcp:${directory}`) @@ -125,7 +131,7 @@ it("prepares directory endpoints in parallel and rejects failures", async () => return err }, ) - expect(calls).toEqual(["config:/slot", "mcp:/slot", "snapshot:/slot"]) + expect(calls).toEqual(["config:/slot", "agents:/slot", "mcp:/slot", "snapshot:/slot"]) gate.reject(new Error("boot failed")) await new Promise((resolve) => setImmediate(resolve)) expect(settled.value).toBe(false) diff --git a/packages/opencode/src/kilocode/primary-worktree.ts b/packages/opencode/src/kilocode/primary-worktree.ts index 23e4707f75c4..8ad63db1b96e 100644 --- a/packages/opencode/src/kilocode/primary-worktree.ts +++ b/packages/opencode/src/kilocode/primary-worktree.ts @@ -46,14 +46,18 @@ export const primaryWorktree = Effect.fn("PrimaryWorktree.find")(function* (dir: }) const resolve = (value: string) => FSUtil.normalizePath(path.isAbsolute(value) ? path.normalize(value) : path.resolve(cwd, value)) - const line = (value: string | undefined) => value?.replace(/\r?\n$/, "") - - if (line(yield* run(["rev-parse", "--is-inside-work-tree"])) !== "true") return undefined - - const root = line(yield* run(["rev-parse", "--path-format=absolute", "--show-toplevel"])) - const gitdir = line(yield* run(["rev-parse", "--path-format=absolute", "--git-dir"])) - const common = line(yield* run(["rev-parse", "--path-format=absolute", "--git-common-dir"])) - if (!root || !gitdir || !common) return undefined + // One rev-parse answers all four questions, in argument order. Outside a + // work tree --show-toplevel fails, so the command fails as a whole. + const info = yield* run([ + "rev-parse", + "--is-inside-work-tree", + "--path-format=absolute", + "--show-toplevel", + "--git-dir", + "--git-common-dir", + ]) + const [inside, root, gitdir, common] = info?.split(/\r?\n/) ?? [] + if (inside !== "true" || !root || !gitdir || !common) return undefined if (resolve(gitdir) === resolve(common)) return resolve(root) const listing = yield* run(["worktree", "list", "--porcelain", "-z"]) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index cc91a01f8620..8d9087f039f7 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -215,6 +215,12 @@ const discoverSkills = Effect.fnUntraced(function* ( if (Flag.KILO_EXPERIMENTAL_CLAUDE_MIGRATION || ClaudeMigration.hasAttempt()) yield* config.getGlobal() // kilocode_change end + // kilocode_change start - one primary checkout lookup serves both the external and the config dir scans + const projectDirs = disableClaudeCodeSkills ? [AGENTS_EXTERNAL_DIR] : [CLAUDE_EXTERNAL_DIR, AGENTS_EXTERNAL_DIR] + const mirrored = yield* primaryPaths(directory, worktree, [...projectDirs, ".kilocode", ".kilo"]) + const fallbacks = mirrored.filter((file) => projectDirs.includes(path.basename(file))) + // kilocode_change end + const externalDirs: string[] = [] if (!disableExternalSkills) { if (!disableClaudeCodeSkills && !ClaudeMigration.globalHandoff()) externalDirs.push(CLAUDE_EXTERNAL_DIR) @@ -227,11 +233,9 @@ const discoverSkills = Effect.fnUntraced(function* ( } // kilocode_change start - const projectDirs = disableClaudeCodeSkills ? [AGENTS_EXTERNAL_DIR] : [CLAUDE_EXTERNAL_DIR, AGENTS_EXTERNAL_DIR] const local = yield* fsys .up({ targets: projectDirs, start: directory, stop: projectRoot }) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) - const fallbacks = yield* primaryPaths(directory, worktree, projectDirs) // kilocode_change const upDirs = [...fallbacks, ...local] // kilocode_change end @@ -249,7 +253,7 @@ const discoverSkills = Effect.fnUntraced(function* ( } const configDirs = yield* config.directories() - const primary = new Set(yield* primaryPaths(directory, worktree, [".kilocode", ".kilo"])) // kilocode_change + const primary = new Set(mirrored.filter((file) => !projectDirs.includes(path.basename(file)))) // kilocode_change for (const dir of configDirs) { // kilocode_change start - global and explicit KILO_CONFIG_DIR skills are trusted; project and primary-checkout // skills remain confined to the active project boundary. From bdb303f0938a7a3a667fb897628baad12048f3b4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 14 Sep 2026 09:41:31 +0200 Subject: [PATCH 06/10] fix: clean discarded worktree snapshots and cover snapshot routes - remove a worktree's snapshot repository on every teardown path that deletes the worktree, and stop blocking error reporting on preparation - let cleanup remove a prepared repository that was never tracked, and skip preparation when the worktree is already gone - require auth for POST /kilocode/snapshot/prepare like snapshot/remove - keep the multi-version structure test and the snapshot materialization poll aligned with the current code shape --- .changeset/worktree-pool-prewarm.md | 2 +- .../src/agent-manager/AgentManagerProvider.ts | 10 +++--- .../src/agent-manager/discard-worktree.ts | 3 +- .../src/agent-manager/provider-lifecycle.ts | 17 +++++++--- .../agent-manager/provider-multi-version.ts | 3 +- .../tests/unit/agent-manager-arch.test.ts | 19 +++++++---- .../tests/unit/pty-cleanup.test.ts | 11 ++++-- .../tests/unit/sandbox-bootstrap.test.ts | 2 +- .../opencode/src/kilocode/snapshot/cleanup.ts | 8 ++++- .../opencode/src/kilocode/snapshot/prepare.ts | 10 ++++-- .../httpapi/middleware/authorization.ts | 6 +++- .../server/httpapi-exercise-scenarios.ts | 19 ++++++++--- .../test/kilocode/snapshot-prepare.test.ts | 34 ++++++++++++++++++- .../snapshot-repository-cleanup.test.ts | 16 +++++++++ 14 files changed, 128 insertions(+), 32 deletions(-) diff --git a/.changeset/worktree-pool-prewarm.md b/.changeset/worktree-pool-prewarm.md index 9489a78e4e7f..aa240f9cbc62 100644 --- a/.changeset/worktree-pool-prewarm.md +++ b/.changeset/worktree-pool-prewarm.md @@ -5,6 +5,6 @@ Speed up Agent Manager worktree creation by pre-warming reusable worktrees and claiming a ready one instead of running a full checkout. Control the pre-warming in Agent Manager settings under "Pre-warm worktrees"; it is enabled by default and uses one extra checkout of disk space per open project. -Prepare snapshots during session creation to reduce first-prompt initialization work. Start no-script sessions after environment files are copied, while preserving setup-script completion before agent startup. +Prepare snapshots during session creation to reduce first-prompt initialization work. Start no-script sessions after environment files are copied, while preserving setup-script completion before agent startup. Discarded worktrees now remove their checkpoint data instead of leaving it behind. Resolve the primary checkout with one git call instead of five and discover agents and skills for a new worktree before the first prompt arrives, so the first response starts sooner. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 9a029b915137..56256f4adcb6 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -18,6 +18,7 @@ import { deleteLifecycleWorktree, promoteLifecycleSession, removeStaleLifecycleWorktree, + removeWorktreeSnapshot, type LifecycleHost, } from "./provider-lifecycle" import { Timing } from "./creation-timing" @@ -981,11 +982,9 @@ export class AgentManagerProvider implements Disposable { worktreeId, }) - const preparation = { pending: Promise.resolve() } try { - preparation.pending = prepareDirectory(client, worktreePath).catch((err) => - this.log("Worktree preparation failed:", err), - ) + // Detached: preparation must not gate session creation or failure reporting. + void prepareDirectory(client, worktreePath).catch((err) => this.log("Worktree preparation failed:", err)) const metadata = await (boot?.metadata() ?? sandboxSessionMetadata(this.connectionService.sandboxPreference, client, worktreePath)) if (boot) timing?.mark("boot", boot.at) @@ -1007,7 +1006,6 @@ export class AgentManagerProvider implements Disposable { timing?.mark("session") return session } catch (error) { - await preparation.pending const err = getErrorMessage(error) this.postToWebview({ type: "agentManager.worktreeSetup", @@ -1104,6 +1102,8 @@ export class AgentManagerProvider implements Disposable { const releasePtyCleanup = await this.acquirePtyCleanup(dir) try { await this.getWorktreeManager()?.removeWorktree(dir) + const root = this.getRoot() + if (root) await removeWorktreeSnapshot(this.lifecycleHost, root, dir) this.getStateManager()?.removeWorktree(wid) this.pushState() } finally { diff --git a/packages/kilo-vscode/src/agent-manager/discard-worktree.ts b/packages/kilo-vscode/src/agent-manager/discard-worktree.ts index f37354b2241c..01930450f3b8 100644 --- a/packages/kilo-vscode/src/agent-manager/discard-worktree.ts +++ b/packages/kilo-vscode/src/agent-manager/discard-worktree.ts @@ -1,5 +1,5 @@ import type { ProjectContext } from "./project/context" -import type { LifecycleHost } from "./provider-lifecycle" +import { removeWorktreeSnapshot, type LifecycleHost } from "./provider-lifecycle" export async function discardWorktree( ctx: ProjectContext, @@ -26,6 +26,7 @@ export async function discardWorktree( } } await ctx.worktreeManager().removeWorktree(dir, branch) + await removeWorktreeSnapshot(host, ctx.root, dir) ctx.peekState()?.removeWorktree(id) host.push() } catch (error) { diff --git a/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts b/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts index 08e4568a92df..743e35e7fa06 100644 --- a/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts +++ b/packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts @@ -178,6 +178,7 @@ export async function createLifecycleWorktree( } try { await ctx.worktreeManager().removeWorktree(created.result.path, created.result.branch) + await removeWorktreeSnapshot(host, ctx.root, created.result.path) ctx.peekState()?.removeWorktree(created.worktree.id) host.push() } catch (error) { @@ -213,6 +214,17 @@ export async function createLifecycleWorktree( return { session, ready } } +/** Remove a worktree's snapshot repository. Teardown must still complete if removal fails. */ +export async function removeWorktreeSnapshot(host: LifecycleHost, root: string, dir: string): Promise { + try { + await host.client().kilocode.removeSnapshot({ directory: root, worktree: dir }, { throwOnError: true }) + return true + } catch (error) { + host.log(`Failed to remove worktree snapshots: ${error}`) + return false + } +} + /** Delete a worktree and dissociate its sessions. */ export async function deleteLifecycleWorktree( ctx: ProjectContext, @@ -300,10 +312,7 @@ export async function deleteLifecycleWorktree( ), ), ) - try { - await client.kilocode.removeSnapshot({ directory: ctx.root, worktree: worktree.path }, { throwOnError: true }) - } catch (error) { - host.log(`Failed to remove worktree snapshots: ${error}`) + if (!(await removeWorktreeSnapshot(host, ctx.root, worktree.path))) { host.notify( "The worktree was deleted, but its checkpoint data could not be removed. Conversation history is preserved.", ) diff --git a/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts b/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts index 952896965655..0fe7b313a633 100644 --- a/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts +++ b/packages/kilo-vscode/src/agent-manager/provider-multi-version.ts @@ -5,7 +5,7 @@ import type { AgentManagerInMessage } from "./types" import { sanitizeBranchName, versionedName } from "./branch-name" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" import { ensureSandbox } from "./sandbox-bootstrap" -import { beginBoot, prepareSession, type LifecycleHost } from "./provider-lifecycle" +import { beginBoot, prepareSession, removeWorktreeSnapshot, type LifecycleHost } from "./provider-lifecycle" import { plan } from "./creation-plan" import { Timing } from "./creation-timing" import { Semaphore } from "./semaphore" @@ -218,6 +218,7 @@ async function provisionVersion( } try { await ctx.worktreeManager().removeWorktree(wt.result.path, wt.result.branch) + await removeWorktreeSnapshot(host, ctx.root, wt.result.path) ctx.peekState()?.removeWorktree(wt.worktree.id) host.push() } catch (error) { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 71112ab645d7..143fdceebccb 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -697,13 +697,20 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(status).toContain("this.removedSessions.has(sid)") }) - it("limits snapshot cleanup to explicit worktree deletion without deleting sessions", () => { - const text = body("onDeleteWorktree") - expect(text).toContain(".kilocode.removeSnapshot") - expect(text).not.toContain("session.delete") - for (const name of ["onCreateWorktree", "onCreateMultiVersion", "onRemoveStaleWorktree"]) { - expect(body(name)).not.toContain("removeSnapshot") + it("cleans worktree snapshots only after worktree removal without deleting sessions", () => { + const del = body("onDeleteWorktree") + expect(del).toContain("removeWorktreeSnapshot") + expect(del).not.toContain("session.delete") + for (const name of ["onCreateWorktree", "onCreateMultiVersion"]) { + const text = body(name) + expect(text, `${name} must not delete sessions`).not.toContain("session.delete") + const disk = text.indexOf(".removeWorktree(") + const snapshot = text.indexOf("removeWorktreeSnapshot(") + if (snapshot < 0) continue + expect(disk, `${name} must remove the worktree before its snapshots`).toBeGreaterThanOrEqual(0) + expect(snapshot, `${name} must remove the worktree before its snapshots`).toBeGreaterThan(disk) } + expect(body("onRemoveStaleWorktree")).not.toContain("removeWorktreeSnapshot") }) // -- onCreateWorktree invariants ------------------------------------------- diff --git a/packages/kilo-vscode/tests/unit/pty-cleanup.test.ts b/packages/kilo-vscode/tests/unit/pty-cleanup.test.ts index 3cba58d21740..bf21822745d9 100644 --- a/packages/kilo-vscode/tests/unit/pty-cleanup.test.ts +++ b/packages/kilo-vscode/tests/unit/pty-cleanup.test.ts @@ -191,12 +191,16 @@ describe("Agent Manager PTY cleanup", () => { const host = { push: () => calls.push("push"), acquirePtyCleanup: async () => release, - client: () => ({ session: { delete: async () => undefined } }) as unknown as KiloClient, + client: () => + ({ + session: { delete: async () => undefined }, + kilocode: { removeSnapshot: async () => calls.push("snapshots") }, + }) as unknown as KiloClient, log: () => undefined, } as unknown as LifecycleHost await discardWorktree(ctx, host, "wt-1", "/worktree", "branch") - expect(calls).toEqual(["disk", "state", "push", "release"]) + expect(calls).toEqual(["disk", "snapshots", "state", "push", "release"]) }) it("continues disk cleanup when session deletion fails", async () => { @@ -215,11 +219,12 @@ describe("Agent Manager PTY cleanup", () => { throw new Error("session offline") }, }, + kilocode: { removeSnapshot: async () => calls.push("snapshots") }, }) as unknown as KiloClient, log: () => calls.push("log"), } as unknown as LifecycleHost await discardWorktree(ctx, host, "wt-1", "/worktree", "branch", "session-1") - expect(calls).toEqual(["log", "disk", "state", "push", "release"]) + expect(calls).toEqual(["log", "disk", "snapshots", "state", "push", "release"]) }) }) diff --git a/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts b/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts index 7ebbb61e72ae..026b10c668d3 100644 --- a/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts +++ b/packages/kilo-vscode/tests/unit/sandbox-bootstrap.test.ts @@ -105,7 +105,7 @@ describe("Agent Manager sandbox startup", () => { const gate = version.indexOf("await reconcileSandbox") const register = version.indexOf("host.register", gate) const ready = version.indexOf("host.notifyReady", register) - const created = version.indexOf("return {", ready) + const created = version.indexOf("const result: CreatedVersion = {", ready) expect(gate).toBeGreaterThan(-1) expect(register).toBeGreaterThan(gate) expect(ready).toBeGreaterThan(register) diff --git a/packages/opencode/src/kilocode/snapshot/cleanup.ts b/packages/opencode/src/kilocode/snapshot/cleanup.ts index f66456001453..7e3b08b4fd65 100644 --- a/packages/opencode/src/kilocode/snapshot/cleanup.ts +++ b/packages/opencode/src/kilocode/snapshot/cleanup.ts @@ -3,6 +3,7 @@ import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Hash } from "@opencode-ai/core/util/hash" import { Effect } from "effect" import path from "path" +import { KiloSnapshotPrepare } from "./prepare" export namespace KiloSnapshotCleanup { export interface Input { @@ -123,7 +124,12 @@ export namespace KiloSnapshotCleanup { const pending = Effect.fnUntraced(function* (fs: FSUtil.Interface, gitdir: string) { const root = yield* fs.readDirectoryEntries(gitdir) const names = new Set(root.map((entry) => entry.name)) - if (names.has("seed.index") || names.has("seed.index.lock") || names.has("seed-objects")) return true + if (names.has("seed.index") || names.has("seed.index.lock")) return true + // A prepared repository that was never tracked keeps its seed artifacts but has + // nothing materializing, so cleanup may remove it. Preparation clears this marker + // before it starts materializing. + if (names.has(KiloSnapshotPrepare.MARKER)) return false + if (names.has("seed-objects")) return true const objects = root.find((entry) => entry.name === "objects") if (!objects) return false diff --git a/packages/opencode/src/kilocode/snapshot/prepare.ts b/packages/opencode/src/kilocode/snapshot/prepare.ts index c5e6268ac8a2..c21b0835f1c0 100644 --- a/packages/opencode/src/kilocode/snapshot/prepare.ts +++ b/packages/opencode/src/kilocode/snapshot/prepare.ts @@ -5,6 +5,9 @@ import { KiloSnapshotMaterialize } from "./materialize" import type { Snapshot } from "@/snapshot" export namespace KiloSnapshotPrepare { + /** Marks a repository whose seed finished before any snapshot was tracked. */ + export const MARKER = "kilo-prepared" + const services = new WeakMap Effect.Effect>() export function bind(service: Snapshot.Interface, prepare: () => Effect.Effect) { @@ -20,7 +23,7 @@ export namespace KiloSnapshotPrepare { // Called under the snapshot lock so preparation cannot race startup recovery. export const resume = Effect.fnUntraced(function* (input: KiloSnapshotMaterialize.Input) { - const marker = path.join(input.gitdir, "kilo-prepared") + const marker = path.join(input.gitdir, MARKER) if (yield* input.fs.exists(marker)) { const refs = yield* input.git([ "--git-dir", @@ -37,6 +40,9 @@ export namespace KiloSnapshotPrepare { export const initialize = Effect.fnUntraced(function* (input: KiloSnapshotSeed.Input, prepare = false) { if (yield* input.fs.exists(input.gitdir).pipe(Effect.orDie)) return + // Preparation runs detached from session creation, so it can arrive after the + // worktree was removed. Do not recreate a repository for a worktree that is gone. + if (prepare && !(yield* input.fs.exists(input.worktree).pipe(Effect.orDie))) return yield* input.fs.ensureDir(input.gitdir).pipe(Effect.orDie) const commands = [ ["init"], @@ -53,7 +59,7 @@ export namespace KiloSnapshotPrepare { if (result.code !== 0) return yield* Effect.die(new Error(`Snapshot initialization failed: ${result.stderr}`)) } const seeded: KiloSnapshotSeed.Output = yield* KiloSnapshotSeed.seed(input) - if (prepare) yield* input.fs.writeFileString(path.join(input.gitdir, "kilo-prepared"), "").pipe(Effect.orDie) + if (prepare) yield* input.fs.writeFileString(path.join(input.gitdir, MARKER), "").pipe(Effect.orDie) yield* Effect.logInfo("initialized") return seeded }).pipe( diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index e9358b004e79..96351ca8a796 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -13,7 +13,11 @@ const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' // kilocode_change start - require auth for high-risk permission toggles even when global auth is optional -const REQUIRED_AUTH_PATHS = new Set(["/permission/allow-everything", "/kilocode/snapshot/remove"]) +const REQUIRED_AUTH_PATHS = new Set([ + "/permission/allow-everything", + "/kilocode/snapshot/remove", + "/kilocode/snapshot/prepare", +]) // kilocode_change end // Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index 1b9786e230c1..a3d6e656b80d 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -727,6 +727,15 @@ export const kiloScenarios: Scenario[] = [ yield* Effect.promise(() => rm(body, { force: true })) }), ), + http.protected + .post("/kilocode/snapshot/prepare", "kilocode.snapshot.prepare") + .mutating() + .inProject({ git: true }) + .at((ctx) => ({ + path: `/kilocode/snapshot/prepare?directory=${encodeURIComponent(directory(ctx))}`, + headers: ctx.headers(), + })) + .status(401), http.protected .post("/kilocode/snapshot/remove", "kilocode.removeSnapshot") .mutating() @@ -761,7 +770,10 @@ export const kiloScenarios: Scenario[] = [ check(item.builtin === false, "command file should not be builtin") check(item.model === "anthropic/claude-sonnet-4-6", "command file should include model metadata") check(item.variant === "high", "command file should include variant metadata") - check(typeof item.content === "string" && item.content.includes("Run command."), "command file should include content") + check( + typeof item.content === "string" && item.content.includes("Run command."), + "command file should include content", + ) }), http.protected .post("/kilocode/command/remove", "kilocode.removeCommand") @@ -777,10 +789,7 @@ export const kiloScenarios: Scenario[] = [ Effect.gen(function* () { check(body === true, "command removal should return true") const location = path.join(directory(ctx), ".kilo/command/httpapi-remove.md") - check( - !(yield* Effect.promise(() => Bun.file(location).exists())), - "removed command should not remain on disk", - ) + check(!(yield* Effect.promise(() => Bun.file(location).exists())), "removed command should not remain on disk") }), ), http.protected diff --git a/packages/opencode/test/kilocode/snapshot-prepare.test.ts b/packages/opencode/test/kilocode/snapshot-prepare.test.ts index 4fbd80397141..16ae134c4ac2 100644 --- a/packages/opencode/test/kilocode/snapshot-prepare.test.ts +++ b/packages/opencode/test/kilocode/snapshot-prepare.test.ts @@ -6,6 +6,7 @@ import path from "path" import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Hash } from "@opencode-ai/core/util/hash" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" @@ -133,6 +134,34 @@ const it = testEffect( ), ) +it.live( + "does not prepare a worktree that no longer exists", + () => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const worktree = path.join(root, "gone") + const gitdir = path.join(root, "snapshot") + + const result = yield* FSUtil.Service.use((fs) => + KiloSnapshotPrepare.initialize( + { + dir: worktree, + worktree, + gitdir, + limit: 1024, + git: () => Effect.die(new Error("preparation must not run git for a missing worktree")), + fs, + }, + true, + ), + ).pipe(Effect.provide(AppNodeBuilder.build(FSUtil.node))) + + expect(result).toBeUndefined() + expect(existsSync(gitdir)).toBe(false) + }), + 30_000, +) + it.live( "prepared objects survive source pruning and later materialization preserves index-only recovery", () => @@ -192,8 +221,11 @@ it.live( const hash = yield* snapshot.track().pipe(provideInstance(dir)) expect(hash).toBeTruthy() + // Materialization removes the alternate then the staging directory, so wait for both. const wait = pollWithTimeout( - Effect.sync(() => (!existsSync(alt) && !existsSync(`${alt}.materializing`) ? true : undefined)), + Effect.sync(() => + !existsSync(alt) && !existsSync(`${alt}.materializing`) && !existsSync(staging) ? true : undefined, + ), "snapshot materialization did not finish", "5 seconds", ) diff --git a/packages/opencode/test/kilocode/snapshot-repository-cleanup.test.ts b/packages/opencode/test/kilocode/snapshot-repository-cleanup.test.ts index e5566433a3f0..1040ed7de48b 100644 --- a/packages/opencode/test/kilocode/snapshot-repository-cleanup.test.ts +++ b/packages/opencode/test/kilocode/snapshot-repository-cleanup.test.ts @@ -15,6 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionID } from "../../src/session/schema" import { KiloSnapshotCleanup } from "../../src/kilocode/snapshot/cleanup" +import { KiloSnapshotPrepare } from "../../src/kilocode/snapshot/prepare" import { tmpdirScoped, testInstanceStoreLayer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import path from "path" @@ -520,6 +521,21 @@ for (const marker of [ ) } +it.live("removes a prepared repository that was never tracked", () => + Effect.gen(function* () { + const base = yield* tmpdirScoped() + const input = item(base, "project", "prepared") + const current = yield* repo(input) + yield* drop(input.worktree) + yield* write(path.join(current.dir, KiloSnapshotPrepare.MARKER), "") + yield* write(path.join(current.dir, "objects", "info", "alternates"), "pending") + yield* write(path.join(current.dir, "seed-objects", "part"), "pending") + + expect(yield* remove(input)).toBe(true) + expect(yield* exist(current.dir)).toBe(false) + }), +) + it.live("accepts a macOS temporary-directory alias", () => Effect.gen(function* () { if (process.platform !== "darwin") return From eb495fac2a390b01ff0728e45e17a3b52b1c8a97 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 14 Sep 2026 09:53:58 +0200 Subject: [PATCH 07/10] fix(opencode): exercise snapshot prepare without forcing auth on the route --- .../routes/instance/httpapi/middleware/authorization.ts | 6 +----- .../test/kilocode/server/httpapi-exercise-scenarios.ts | 6 +++++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 96351ca8a796..e9358b004e79 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -13,11 +13,7 @@ const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' // kilocode_change start - require auth for high-risk permission toggles even when global auth is optional -const REQUIRED_AUTH_PATHS = new Set([ - "/permission/allow-everything", - "/kilocode/snapshot/remove", - "/kilocode/snapshot/prepare", -]) +const REQUIRED_AUTH_PATHS = new Set(["/permission/allow-everything", "/kilocode/snapshot/remove"]) // kilocode_change end // Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index a3d6e656b80d..92d357e371af 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -735,7 +735,11 @@ export const kiloScenarios: Scenario[] = [ path: `/kilocode/snapshot/prepare?directory=${encodeURIComponent(directory(ctx))}`, headers: ctx.headers(), })) - .status(401), + .json(200, (body) => { + object(body) + check(typeof body.prepared === "boolean", "snapshot preparation should report whether it prepared") + check(typeof body.durationMs === "number", "snapshot preparation should report its duration") + }), http.protected .post("/kilocode/snapshot/remove", "kilocode.removeSnapshot") .mutating() From a81cdf905fad96183f6ddbe438323fc6603b996b Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 14 Sep 2026 10:16:42 +0200 Subject: [PATCH 08/10] fix(opencode): release the seed pin when removing an untracked snapshot repository - delete the matching refs/kilo/materialize pin from the project checkout after a prepared but never tracked snapshot repository is removed - keep primary checkout paths that contain newlines working by falling back to per-field rev-parse when the combined output does not have exactly four lines - correct the changeset wording (four git calls into one) --- .changeset/worktree-pool-prewarm.md | 2 +- .../opencode/src/kilocode/primary-worktree.ts | 14 ++++- .../opencode/src/kilocode/snapshot/cleanup.ts | 21 +++++++ .../test/kilocode/primary-worktree.test.ts | 2 + .../test/kilocode/snapshot-prepare.test.ts | 55 +++++++++++++++++++ 5 files changed, 92 insertions(+), 2 deletions(-) diff --git a/.changeset/worktree-pool-prewarm.md b/.changeset/worktree-pool-prewarm.md index aa240f9cbc62..01a89f88b07f 100644 --- a/.changeset/worktree-pool-prewarm.md +++ b/.changeset/worktree-pool-prewarm.md @@ -7,4 +7,4 @@ Speed up Agent Manager worktree creation by pre-warming reusable worktrees and c Prepare snapshots during session creation to reduce first-prompt initialization work. Start no-script sessions after environment files are copied, while preserving setup-script completion before agent startup. Discarded worktrees now remove their checkpoint data instead of leaving it behind. -Resolve the primary checkout with one git call instead of five and discover agents and skills for a new worktree before the first prompt arrives, so the first response starts sooner. +Resolve the primary checkout with one git call instead of four and discover agents and skills for a new worktree before the first prompt arrives, so the first response starts sooner. diff --git a/packages/opencode/src/kilocode/primary-worktree.ts b/packages/opencode/src/kilocode/primary-worktree.ts index 8ad63db1b96e..398363b7aa66 100644 --- a/packages/opencode/src/kilocode/primary-worktree.ts +++ b/packages/opencode/src/kilocode/primary-worktree.ts @@ -46,6 +46,7 @@ export const primaryWorktree = Effect.fn("PrimaryWorktree.find")(function* (dir: }) const resolve = (value: string) => FSUtil.normalizePath(path.isAbsolute(value) ? path.normalize(value) : path.resolve(cwd, value)) + const line = (value: string | undefined) => value?.replace(/\r?\n$/, "") // One rev-parse answers all four questions, in argument order. Outside a // work tree --show-toplevel fails, so the command fails as a whole. const info = yield* run([ @@ -56,7 +57,18 @@ export const primaryWorktree = Effect.fn("PrimaryWorktree.find")(function* (dir: "--git-dir", "--git-common-dir", ]) - const [inside, root, gitdir, common] = info?.split(/\r?\n/) ?? [] + if (info === undefined) return undefined + const lines = line(info)!.split(/\r?\n/) + // A path that contains a newline spreads over extra lines; fall back to one query per field. + const [inside, root, gitdir, common] = + lines.length === 4 + ? lines + : [ + line(yield* run(["rev-parse", "--is-inside-work-tree"])), + line(yield* run(["rev-parse", "--path-format=absolute", "--show-toplevel"])), + line(yield* run(["rev-parse", "--path-format=absolute", "--git-dir"])), + line(yield* run(["rev-parse", "--path-format=absolute", "--git-common-dir"])), + ] if (inside !== "true" || !root || !gitdir || !common) return undefined if (resolve(gitdir) === resolve(common)) return resolve(root) diff --git a/packages/opencode/src/kilocode/snapshot/cleanup.ts b/packages/opencode/src/kilocode/snapshot/cleanup.ts index 7e3b08b4fd65..21cb354b8233 100644 --- a/packages/opencode/src/kilocode/snapshot/cleanup.ts +++ b/packages/opencode/src/kilocode/snapshot/cleanup.ts @@ -1,11 +1,17 @@ import { FSUtil } from "@opencode-ai/core/fs-util" +import { AppProcess } from "@opencode-ai/core/process" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Hash } from "@opencode-ai/core/util/hash" +import * as Log from "@opencode-ai/core/util/log" import { Effect } from "effect" +import { ChildProcess } from "effect/unstable/process" import path from "path" +import { KiloSnapshotMaterialize } from "./materialize" import { KiloSnapshotPrepare } from "./prepare" export namespace KiloSnapshotCleanup { + const log = Log.create({ service: "snapshot.cleanup" }) + export interface Input { readonly root: string readonly project: string @@ -148,6 +154,20 @@ export namespace KiloSnapshotCleanup { ) }) + // Seeding pins the seed tree in the project's common git dir so the source objects + // survive gc while the snapshot repository borrows them. Materialization releases + // that pin, so a repository removed before it ever materialized must release it here. + // Git run from the project root resolves the shared refs itself, also for worktrees. + const release = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string, gitdir: string) { + if (!(yield* inspect(fs, path.join(directory, ".git"))).exists) return + const app = yield* AppProcess.Service + const result = yield* app.run( + ChildProcess.make("git", ["update-ref", "-d", KiloSnapshotMaterialize.ref(gitdir)], { cwd: directory }), + ) + if (result.exitCode !== 0) + log.warn("failed to release snapshot seed pin", { directory, stderr: result.stderr.toString() }) + }) + export const remove = Effect.fnUntraced(function* (input: Input) { const root = path.resolve(input.root) const directory = path.resolve(input.directory) @@ -217,6 +237,7 @@ export namespace KiloSnapshotCleanup { ) return yield* Effect.fail(new Error("snapshot repository changed during cleanup")) yield* Effect.uninterruptible(input.fs.remove(quarantine, { recursive: true, force: true })) + yield* release(input.fs, directory, gitdir) return true }), `snapshot:${gitdir}`, diff --git a/packages/opencode/test/kilocode/primary-worktree.test.ts b/packages/opencode/test/kilocode/primary-worktree.test.ts index 656baaf7d757..ff521aa40c3e 100644 --- a/packages/opencode/test/kilocode/primary-worktree.test.ts +++ b/packages/opencode/test/kilocode/primary-worktree.test.ts @@ -111,6 +111,8 @@ describe("primaryWorktree", () => { yield* Effect.promise(() => $`git worktree add -b primary-newline-worktree ${worktree}`.cwd(repo).quiet()) expect(yield* primaryWorktree(worktree)).toBe(repo) + // Resolving from inside the primary checkout itself must survive the newline too. + expect(yield* primaryWorktree(repo)).toBe(repo) }), ) diff --git a/packages/opencode/test/kilocode/snapshot-prepare.test.ts b/packages/opencode/test/kilocode/snapshot-prepare.test.ts index 16ae134c4ac2..8c7a983754b6 100644 --- a/packages/opencode/test/kilocode/snapshot-prepare.test.ts +++ b/packages/opencode/test/kilocode/snapshot-prepare.test.ts @@ -5,6 +5,8 @@ import { existsSync } from "fs" import path from "path" import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppProcess } from "@opencode-ai/core/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" @@ -16,6 +18,7 @@ import { Session } from "../../src/session/session" import { Server } from "../../src/server/server" import { InstanceState } from "../../src/effect/instance-state" import { InstanceStore } from "../../src/project/instance-store" +import { KiloSnapshotCleanup } from "../../src/kilocode/snapshot/cleanup" import { KiloSnapshotPrepare } from "../../src/kilocode/snapshot/prepare" import { KiloSnapshotMaterialize } from "../../src/kilocode/snapshot/materialize" import { @@ -34,6 +37,58 @@ afterEach(async () => { await resetDatabase() }) +test("removing a prepared worktree that was never tracked releases only its seed pin", async () => { + await using source = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "note.txt"), "committed\n") + await $`git add note.txt`.cwd(dir).quiet() + await $`git commit -m baseline`.cwd(dir).quiet() + }, + }) + const dir = path.join(source.path, ".kilo", "worktrees", "abandoned") + await $`git worktree add --detach ${dir} HEAD`.cwd(source.path).quiet() + const ctx = await reloadTestInstance({ directory: dir }) + const gitdir = path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)) + const pin = KiloSnapshotMaterialize.ref(gitdir) + const other = KiloSnapshotMaterialize.ref(path.join(gitdir, "other")) + const app = Server.Default().app + + const prepared = await app.request("/kilocode/snapshot/prepare", { + method: "POST", + headers: { "x-kilo-directory": dir }, + }) + expect(prepared.status).toBe(200) + const hash = (await $`git rev-parse --verify ${pin}`.cwd(source.path).text()).trim() + await $`git update-ref ${other} ${hash}`.cwd(source.path).quiet() + + await $`git worktree remove --force ${dir}`.cwd(source.path).quiet() + await disposeAllInstances() + const removed = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const flock = yield* EffectFlock.Service + return yield* KiloSnapshotCleanup.remove({ + root: path.join(Global.Path.data, "snapshot"), + project: ctx.project.id, + directory: source.path, + worktree: dir, + fs, + flock, + }) + }).pipe( + Effect.provide( + LayerNode.compile(LayerNode.group([FSUtil.node, AppProcess.node, EffectFlock.node, CrossSpawnSpawner.node])), + ), + ), + ) + expect(removed).toBe(true) + expect(existsSync(gitdir)).toBe(false) + const format = "%(refname)" + const refs = (await $`git for-each-ref --format=${format} refs/kilo/materialize`.cwd(source.path).text()).trim() + expect(refs).toBe(other) +}, 30_000) + test("prepares a routed worktree once without tracking, then tracks current content without reseeding", async () => { await using source = await tmpdir({ git: true, From 6435aa954f372841090bece355b9831deeff3e08 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 14 Sep 2026 10:26:15 +0200 Subject: [PATCH 09/10] fix(opencode): release the seed pin whenever the snapshot repository is gone --- .../opencode/src/kilocode/snapshot/cleanup.ts | 7 ++- .../test/kilocode/snapshot-prepare.test.ts | 49 +++++++++++-------- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/kilocode/snapshot/cleanup.ts b/packages/opencode/src/kilocode/snapshot/cleanup.ts index 21cb354b8233..f0f55be90dbb 100644 --- a/packages/opencode/src/kilocode/snapshot/cleanup.ts +++ b/packages/opencode/src/kilocode/snapshot/cleanup.ts @@ -237,9 +237,12 @@ export namespace KiloSnapshotCleanup { ) return yield* Effect.fail(new Error("snapshot repository changed during cleanup")) yield* Effect.uninterruptible(input.fs.remove(quarantine, { recursive: true, force: true })) - yield* release(input.fs, directory, gitdir) return true - }), + }).pipe( + // Every success exit means the repository is gone, including one already removed by + // an earlier interrupted cleanup, so none of them may leave the seed pin behind. + Effect.tap(() => release(input.fs, directory, gitdir)), + ), `snapshot:${gitdir}`, ) }) diff --git a/packages/opencode/test/kilocode/snapshot-prepare.test.ts b/packages/opencode/test/kilocode/snapshot-prepare.test.ts index 8c7a983754b6..c4abf87801d4 100644 --- a/packages/opencode/test/kilocode/snapshot-prepare.test.ts +++ b/packages/opencode/test/kilocode/snapshot-prepare.test.ts @@ -64,29 +64,36 @@ test("removing a prepared worktree that was never tracked releases only its seed await $`git worktree remove --force ${dir}`.cwd(source.path).quiet() await disposeAllInstances() - const removed = await Effect.runPromise( - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const flock = yield* EffectFlock.Service - return yield* KiloSnapshotCleanup.remove({ - root: path.join(Global.Path.data, "snapshot"), - project: ctx.project.id, - directory: source.path, - worktree: dir, - fs, - flock, - }) - }).pipe( - Effect.provide( - LayerNode.compile(LayerNode.group([FSUtil.node, AppProcess.node, EffectFlock.node, CrossSpawnSpawner.node])), + const remove = () => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const flock = yield* EffectFlock.Service + return yield* KiloSnapshotCleanup.remove({ + root: path.join(Global.Path.data, "snapshot"), + project: ctx.project.id, + directory: source.path, + worktree: dir, + fs, + flock, + }) + }).pipe( + Effect.provide( + LayerNode.compile(LayerNode.group([FSUtil.node, AppProcess.node, EffectFlock.node, CrossSpawnSpawner.node])), + ), ), - ), - ) - expect(removed).toBe(true) - expect(existsSync(gitdir)).toBe(false) + ) const format = "%(refname)" - const refs = (await $`git for-each-ref --format=${format} refs/kilo/materialize`.cwd(source.path).text()).trim() - expect(refs).toBe(other) + const pins = async () => + (await $`git for-each-ref --format=${format} refs/kilo/materialize`.cwd(source.path).text()).trim() + expect(await remove()).toBe(true) + expect(existsSync(gitdir)).toBe(false) + expect(await pins()).toBe(other) + + // A repository already removed by an interrupted earlier cleanup still releases its pin. + await $`git update-ref ${pin} ${hash}`.cwd(source.path).quiet() + expect(await remove()).toBe(true) + expect(await pins()).toBe(other) }, 30_000) test("prepares a routed worktree once without tracking, then tracks current content without reseeding", async () => { From 1aa8e5bd19a5e3d4c44d4ea9f7562655e2e3b98d Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 14 Sep 2026 10:38:25 +0200 Subject: [PATCH 10/10] fix(opencode): release the snapshot seed pin without an AppProcess service dependency --- packages/opencode/src/kilocode/snapshot/cleanup.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/kilocode/snapshot/cleanup.ts b/packages/opencode/src/kilocode/snapshot/cleanup.ts index f0f55be90dbb..ff3d9f0341c2 100644 --- a/packages/opencode/src/kilocode/snapshot/cleanup.ts +++ b/packages/opencode/src/kilocode/snapshot/cleanup.ts @@ -1,11 +1,10 @@ import { FSUtil } from "@opencode-ai/core/fs-util" -import { AppProcess } from "@opencode-ai/core/process" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Hash } from "@opencode-ai/core/util/hash" import * as Log from "@opencode-ai/core/util/log" import { Effect } from "effect" -import { ChildProcess } from "effect/unstable/process" import path from "path" +import { Process } from "@/util/process" import { KiloSnapshotMaterialize } from "./materialize" import { KiloSnapshotPrepare } from "./prepare" @@ -160,11 +159,10 @@ export namespace KiloSnapshotCleanup { // Git run from the project root resolves the shared refs itself, also for worktrees. const release = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string, gitdir: string) { if (!(yield* inspect(fs, path.join(directory, ".git"))).exists) return - const app = yield* AppProcess.Service - const result = yield* app.run( - ChildProcess.make("git", ["update-ref", "-d", KiloSnapshotMaterialize.ref(gitdir)], { cwd: directory }), + const result = yield* Effect.promise(() => + Process.run(["git", "update-ref", "-d", KiloSnapshotMaterialize.ref(gitdir)], { cwd: directory, nothrow: true }), ) - if (result.exitCode !== 0) + if (result.code !== 0) log.warn("failed to release snapshot seed pin", { directory, stderr: result.stderr.toString() }) })