mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
chore: merge main and resolve worktree activity conflicts
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep Agent Manager worktree spinners active while background agents are running.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Use the Git executable configured in VS Code when creating worktrees on Windows.
|
||||
@@ -87,6 +87,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
|
||||
|
||||
- Keep things in one function unless composable or reusable
|
||||
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
|
||||
- Avoid possibly out-of-bounds array access. Instead of `array[index] ?? {}`, use `array.at(index) ?? {}`. Instead of `array[array.length - 1]`, use `array.at(-1)`
|
||||
- Avoid `try`/`catch` where possible
|
||||
- Avoid using the `any` type
|
||||
- Prefer single word variable names where possible
|
||||
|
||||
@@ -46,6 +46,7 @@ import { forkSession } from "./fork-session"
|
||||
import { AgentManagerVisiblePresence } from "./am-visible-presence"
|
||||
import { continueInWorktree } from "./continue-in-worktree"
|
||||
import { WorktreeDiffController } from "./worktree-diff-controller"
|
||||
import { createWorktreeActivity } from "./worktree-activity"
|
||||
import { sendDiffBranches as postDiffBranches } from "./project/diff-branches"
|
||||
import { WorktreeImporter } from "./worktree-importer"
|
||||
import {
|
||||
@@ -106,8 +107,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
private cachedWorktreeStats: { type: "agentManager.worktreeStats"; stats: WorktreeStats[] } | undefined
|
||||
private cachedLocalStats: { type: "agentManager.localStats"; stats: LocalStats } | undefined
|
||||
private unsubTool: (() => void) | undefined
|
||||
private unsubStatus: (() => void) | undefined
|
||||
private unsubSessions: (() => void) | undefined
|
||||
private activity: ReturnType<typeof createWorktreeActivity>
|
||||
private unsubFont: (() => void) | undefined
|
||||
private unsubProjects: (() => void) | undefined
|
||||
/** Scratch set returned when no active context exists; mutations are discarded. */
|
||||
@@ -129,7 +129,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
constructor(
|
||||
private readonly host: Host,
|
||||
private readonly connectionService: KiloConnectionService,
|
||||
binary: GitExecutable = () => Promise.resolve("git"),
|
||||
binary: GitExecutable | string = "git",
|
||||
) {
|
||||
this.outputChannel = host.createOutput("Kilo Agent Manager")
|
||||
this.terminalManager = new SessionTerminalManager(
|
||||
@@ -284,22 +284,15 @@ export class AgentManagerProvider implements Disposable {
|
||||
(event) => (event as { type?: string }).type === "kilocode.agent_manager.start",
|
||||
(event, directory) => this.onToolEvent(event, directory),
|
||||
)
|
||||
this.unsubStatus = this.connectionService.onEventFiltered(
|
||||
(event) => (event as { type?: string }).type === "session.status",
|
||||
(event) => this.onSessionStatus(event),
|
||||
)
|
||||
this.unsubSessions = this.connectionService.onEventFiltered(
|
||||
(event) => {
|
||||
const type = (event as { type?: string }).type
|
||||
return (
|
||||
type === "session.created" ||
|
||||
type === "session.updated" ||
|
||||
type === "session.deleted" ||
|
||||
type === "session.error"
|
||||
)
|
||||
},
|
||||
(event) => this.onSessionLifecycle(event),
|
||||
)
|
||||
this.activity = createWorktreeActivity({
|
||||
connection: this.connectionService,
|
||||
paths: () =>
|
||||
[...this.contexts.values()].flatMap((ctx) => ctx.peekState()?.getWorktrees() ?? []).map((wt) => wt.path),
|
||||
post: (active) => this.postToWebview({ type: "agentManager.worktreeActivity", active }),
|
||||
status: (event) => this.onSessionStatus(event),
|
||||
lifecycle: (event) => this.onSessionLifecycle(event),
|
||||
log: (err) => this.log("Failed to load worktree activity:", err),
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Keep each project's cached sidebar session list in sync with backend
|
||||
@@ -906,6 +899,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
// instance are reaped by the router's generation guard.
|
||||
void this.terminalRouter.dispose()
|
||||
this.scripts.manager.snapshot()
|
||||
void this.activity.sync(true)
|
||||
this.log(
|
||||
`onRequestState: stateReady=${this.stateReady ? "pending" : "missing"}, state=${this.state ? "ok" : "missing"}`,
|
||||
)
|
||||
@@ -1428,6 +1422,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
activeTarget: state.getActiveTarget(),
|
||||
...(active ? this.runStateFor(target) : {}),
|
||||
})
|
||||
void this.activity.sync()
|
||||
void pushProjectSessions(target, this.panel?.sessions, (message) => this.postToWebview(message))
|
||||
if (!active) return
|
||||
|
||||
@@ -1441,6 +1436,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
/** Push empty state when the folder is not a git repo or has no folder open. */
|
||||
private pushEmptyState(): void {
|
||||
void this.activity.sync()
|
||||
this.staleWorktreeIds.clear()
|
||||
this.postToWebview({
|
||||
type: "agentManager.state",
|
||||
@@ -1621,6 +1617,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
private pushProjects(): void {
|
||||
const projects = this.contexts.snapshots()
|
||||
void this.activity.sync()
|
||||
this.postToWebview({
|
||||
type: "agentManager.projects",
|
||||
multiProject: this.host.multiProject(),
|
||||
@@ -1800,10 +1797,10 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
this.openPanel()
|
||||
await this.waitForStateReady("continueFromSidebar")
|
||||
|
||||
await continueInWorktree(
|
||||
{
|
||||
root,
|
||||
binary: this.gitOps.path,
|
||||
getClient: () => this.connectionService.getClient(),
|
||||
createWorktreeOnDisk: (opts) => this.createWorktreeOnDisk(opts),
|
||||
runSetupScript: (p, b, id) => this.runSetupScriptForWorktree(p, b, id),
|
||||
@@ -1872,8 +1869,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
await this.stateReady?.catch((err) => this.log("dispose: stateReady rejected:", err))
|
||||
await this.contexts.dispose()
|
||||
this.unsubTool?.()
|
||||
this.unsubStatus?.()
|
||||
this.unsubSessions?.()
|
||||
this.activity.dispose()
|
||||
this.unsubFont?.()
|
||||
this.unsubProjects?.()
|
||||
this.unsubDestination?.()
|
||||
|
||||
@@ -20,7 +20,7 @@ interface GitOpsOptions {
|
||||
/** Shared concurrency gate for child process spawning. */
|
||||
semaphore?: Semaphore
|
||||
/** Validated Git executable shared by Agent Manager operations. */
|
||||
binary?: GitExecutable
|
||||
binary?: GitExecutable | string
|
||||
}
|
||||
|
||||
export interface ApplyConflict {
|
||||
@@ -131,6 +131,8 @@ export class GitOps {
|
||||
private static readonly DEFAULT_BRANCH_CACHE_TTL_MS = 10 * 60_000
|
||||
private static readonly MAX_CACHE_SIZE = 100
|
||||
|
||||
public readonly path: string
|
||||
|
||||
get disposed(): boolean {
|
||||
return this.controller.signal.aborted
|
||||
}
|
||||
@@ -138,7 +140,12 @@ export class GitOps {
|
||||
constructor(options: GitOpsOptions) {
|
||||
this.log = options.log
|
||||
this.semaphore = options.semaphore
|
||||
this.binary = options.binary ?? (() => Promise.resolve("git"))
|
||||
const configured = options.binary
|
||||
this.path = typeof configured === "string" ? configured : "git"
|
||||
this.binary =
|
||||
typeof configured === "string"
|
||||
? () => Promise.resolve(configured)
|
||||
: (configured ?? (() => Promise.resolve("git")))
|
||||
this.injected = options.runGit !== undefined
|
||||
this.runGit =
|
||||
options.runGit ??
|
||||
@@ -147,6 +154,7 @@ export class GitOps {
|
||||
return simpleGit(cwd, {
|
||||
abort: this.controller.signal,
|
||||
binary,
|
||||
unsafe: { allowUnsafeCustomBinary: binary !== "git" },
|
||||
})
|
||||
.raw(args)
|
||||
.then((out) => out.trim())
|
||||
|
||||
@@ -89,14 +89,16 @@ export class WorktreeManager {
|
||||
private readonly dir: string
|
||||
private readonly git: SimpleGit
|
||||
private readonly ops: GitOps | undefined
|
||||
private readonly binary: string
|
||||
private readonly log: (msg: string) => void
|
||||
private migrated = false
|
||||
|
||||
constructor(root: string, log: (msg: string) => void, ops?: GitOps) {
|
||||
constructor(root: string, log: (msg: string) => void, ops?: GitOps, binary?: string) {
|
||||
this.root = root
|
||||
this.dir = path.join(root, KILO_DIR, "worktrees")
|
||||
this.git = simpleGit(root)
|
||||
this.ops = ops
|
||||
this.binary = binary ?? ops?.path ?? "git"
|
||||
this.git = this.client(root)
|
||||
this.log = log
|
||||
}
|
||||
|
||||
@@ -121,8 +123,8 @@ export class WorktreeManager {
|
||||
// Key: `${root}:${remote}:${branch}`, Value: timestamp when fetch was done
|
||||
private static fetchCache = new Map<string, number>()
|
||||
private static readonly FETCH_CACHE_TTL = 60_000 // 1 minute
|
||||
private static gitAvailable = false
|
||||
private static lfsAvailable: boolean | undefined
|
||||
private gitAvailable = false
|
||||
private lfsAvailable: boolean | undefined
|
||||
|
||||
private withGitLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const key = this.root
|
||||
@@ -136,6 +138,16 @@ export class WorktreeManager {
|
||||
return result
|
||||
}
|
||||
|
||||
private client(cwd: string, ssh = false): SimpleGit {
|
||||
return simpleGit(cwd, {
|
||||
binary: this.binary,
|
||||
unsafe: {
|
||||
allowUnsafeCustomBinary: this.binary !== "git",
|
||||
allowUnsafeSshCommand: ssh,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API (acquires git lock)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -169,7 +181,7 @@ export class WorktreeManager {
|
||||
async hasWork(worktreePath: string, base: string): Promise<boolean> {
|
||||
if (!this.isManagedPath(worktreePath)) return false
|
||||
return this.withGitLock(async () => {
|
||||
const git = simpleGit(worktreePath)
|
||||
const git = this.client(worktreePath)
|
||||
const status = await git.status()
|
||||
if (status.files.length > 0) return true
|
||||
return git
|
||||
@@ -185,12 +197,12 @@ export class WorktreeManager {
|
||||
}
|
||||
|
||||
private async ensureGitAvailable(): Promise<void> {
|
||||
if (WorktreeManager.gitAvailable) return
|
||||
if (this.gitAvailable) return
|
||||
try {
|
||||
await execWithShellEnv("git", ["--version"])
|
||||
WorktreeManager.gitAvailable = true
|
||||
await execWithShellEnv(this.binary, ["--version"])
|
||||
this.gitAvailable = true
|
||||
} catch (error) {
|
||||
WorktreeManager.gitAvailable = false
|
||||
this.gitAvailable = false
|
||||
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
throw new Error(
|
||||
"Git is not installed or not found in PATH. Please install Git (https://git-scm.com) and restart VS Code.",
|
||||
@@ -313,7 +325,7 @@ export class WorktreeManager {
|
||||
private async renameBranchImpl(worktreePath: string, current: string, requested: string): Promise<string> {
|
||||
if (!this.isManagedPath(worktreePath)) throw new Error("Worktree is not managed by Agent Manager")
|
||||
|
||||
const git = simpleGit(worktreePath)
|
||||
const git = this.client(worktreePath)
|
||||
const actual = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
|
||||
if (actual === "HEAD" || actual !== current) throw new Error("Branch changed before automatic naming")
|
||||
|
||||
@@ -721,7 +733,7 @@ export class WorktreeManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const git = simpleGit(wtPath)
|
||||
const git = this.client(wtPath)
|
||||
const [branch, stat, meta] = await Promise.all([
|
||||
git.revparse(["--abbrev-ref", "HEAD"]),
|
||||
fs.promises.stat(wtPath),
|
||||
@@ -855,7 +867,7 @@ export class WorktreeManager {
|
||||
// is the fixed value Kilo injects — never for an inherited one, which
|
||||
// could be attacker-controlled.
|
||||
const env = nonInteractiveEnv()
|
||||
await simpleGit(this.root, { unsafe: { allowUnsafeSshCommand: isKiloOwnedSshCommand(env) } })
|
||||
await this.client(this.root, isKiloOwnedSshCommand(env))
|
||||
.env(env)
|
||||
.raw(["fetch", "--quiet", "--no-tags", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`])
|
||||
WorktreeManager.fetchCache.set(key, Date.now())
|
||||
@@ -925,13 +937,13 @@ export class WorktreeManager {
|
||||
}
|
||||
|
||||
async checkLfsAvailable(): Promise<boolean> {
|
||||
if (WorktreeManager.lfsAvailable) return true
|
||||
if (this.lfsAvailable) return true
|
||||
try {
|
||||
await execWithShellEnv("git", ["lfs", "version"], { cwd: this.root, timeout: 5000 })
|
||||
WorktreeManager.lfsAvailable = true
|
||||
await execWithShellEnv(this.binary, ["lfs", "version"], { cwd: this.root, timeout: 5000 })
|
||||
this.lfsAvailable = true
|
||||
return true
|
||||
} catch {
|
||||
WorktreeManager.lfsAvailable = false
|
||||
this.lfsAvailable = false
|
||||
// git-lfs not installed
|
||||
return false
|
||||
}
|
||||
@@ -1174,7 +1186,7 @@ export class WorktreeManager {
|
||||
}
|
||||
|
||||
private async gitExec(args: string[]): Promise<void> {
|
||||
await this.exec("git", args)
|
||||
await this.exec(this.binary, args)
|
||||
}
|
||||
|
||||
private async gitTry(args: string[]): Promise<boolean> {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { recordForkHandoff } from "./fork-handoff"
|
||||
|
||||
export interface ContinueContext {
|
||||
root: string
|
||||
binary?: string
|
||||
getClient: () => KiloClient
|
||||
createWorktreeOnDisk: (opts: { baseBranch: string; baseRef: string }) => Promise<{
|
||||
worktree: { id: string }
|
||||
@@ -42,7 +43,7 @@ export async function abortSession(ctx: ContinueContext, sessionId: string): Pro
|
||||
/** Capture git state from the workspace root. */
|
||||
export async function captureState(ctx: ContinueContext): Promise<StepResult<GitSnapshot>> {
|
||||
try {
|
||||
const snapshot = await captureGitState(ctx.root, (...args) => ctx.log(...args))
|
||||
const snapshot = await captureGitState(ctx.root, (...args) => ctx.log(...args), ctx.binary)
|
||||
return { ok: true, value: snapshot }
|
||||
} catch (err) {
|
||||
return { ok: false, error: `Failed to capture git state: ${getErrorMessage(err)}` }
|
||||
@@ -67,7 +68,7 @@ export async function transferState(
|
||||
snapshot: GitSnapshot,
|
||||
target: string,
|
||||
): Promise<StepResult<void>> {
|
||||
const applied = await applyGitState(snapshot, target, (...args) => ctx.log(...args))
|
||||
const applied = await applyGitState(snapshot, target, (...args) => ctx.log(...args), ctx.binary)
|
||||
if (!applied.ok) {
|
||||
ctx.log("Git state transfer failed:", applied.error)
|
||||
return { ok: false, error: applied.error ?? "Failed to apply changes to worktree" }
|
||||
|
||||
@@ -29,11 +29,16 @@ export interface UntrackedFile {
|
||||
|
||||
const MAX_FILE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
function git(args: string[], cwd: string, stdin?: string): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
function git(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
stdin?: string,
|
||||
binary = "git",
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
if (stdin !== undefined) {
|
||||
// Use spawn for stdin piping — execFile doesn't reliably create a stdin pipe
|
||||
const child = cp.spawn("git", args, { cwd, windowsHide: true })
|
||||
const child = cp.spawn(binary, args, { cwd, windowsHide: true })
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
child.stdout.on("data", (d: Buffer) => (stdout += d.toString()))
|
||||
@@ -42,7 +47,7 @@ function git(args: string[], cwd: string, stdin?: string): Promise<{ code: numbe
|
||||
child.stdin.end(stdin)
|
||||
} else {
|
||||
cp.execFile(
|
||||
"git",
|
||||
binary,
|
||||
args,
|
||||
{ cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, windowsHide: true },
|
||||
(error, stdout, stderr) => {
|
||||
@@ -58,8 +63,8 @@ function git(args: string[], cwd: string, stdin?: string): Promise<{ code: numbe
|
||||
})
|
||||
}
|
||||
|
||||
async function raw(args: string[], cwd: string): Promise<string> {
|
||||
const result = await git(args, cwd)
|
||||
async function raw(args: string[], cwd: string, binary = "git"): Promise<string> {
|
||||
const result = await git(args, cwd, undefined, binary)
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
@@ -67,19 +72,19 @@ async function raw(args: string[], cwd: string): Promise<string> {
|
||||
* Capture the current git state from `cwd` as a portable snapshot.
|
||||
* This is a read-only operation — the source directory is never modified.
|
||||
*/
|
||||
export async function capture(cwd: string, log: (...args: unknown[]) => void): Promise<GitSnapshot> {
|
||||
export async function capture(cwd: string, log: (...args: unknown[]) => void, binary = "git"): Promise<GitSnapshot> {
|
||||
const patch = (args: string[]) =>
|
||||
git(args, cwd).then((r) => {
|
||||
git(args, cwd, undefined, binary).then((r) => {
|
||||
const out = r.stdout
|
||||
return out.trim() ? out : null
|
||||
})
|
||||
|
||||
const [branch, head, unstaged, staged, untrackedRaw] = await Promise.all([
|
||||
raw(["branch", "--show-current"], cwd),
|
||||
raw(["rev-parse", "HEAD"], cwd),
|
||||
raw(["branch", "--show-current"], cwd, binary),
|
||||
raw(["rev-parse", "HEAD"], cwd, binary),
|
||||
patch(["diff", "--binary"]),
|
||||
patch(["diff", "--cached", "--binary"]),
|
||||
raw(["ls-files", "--others", "--exclude-standard"], cwd).then((s: string) =>
|
||||
raw(["ls-files", "--others", "--exclude-standard"], cwd, binary).then((s: string) =>
|
||||
s.split("\n").filter((l: string) => l.length > 0),
|
||||
),
|
||||
])
|
||||
@@ -111,10 +116,11 @@ export async function apply(
|
||||
snapshot: GitSnapshot,
|
||||
target: string,
|
||||
log: (...args: unknown[]) => void,
|
||||
binary = "git",
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
// Apply staged patch first, then re-stage those files
|
||||
if (snapshot.staged) {
|
||||
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.staged)
|
||||
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.staged, binary)
|
||||
if (result.code !== 0) {
|
||||
const msg = result.stderr.trim() || "Patch did not apply"
|
||||
log("Failed to apply staged patch:", msg)
|
||||
@@ -122,13 +128,13 @@ export async function apply(
|
||||
}
|
||||
const files = parsePatchFiles(snapshot.staged)
|
||||
if (files.length > 0) {
|
||||
await git(["add", "--", ...files], target)
|
||||
await git(["add", "--", ...files], target, undefined, binary)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply unstaged patch (leave as unstaged working-tree changes)
|
||||
if (snapshot.unstaged) {
|
||||
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.unstaged)
|
||||
const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.unstaged, binary)
|
||||
if (result.code !== 0) {
|
||||
const msg = result.stderr.trim() || "Patch did not apply"
|
||||
log("Failed to apply unstaged patch:", msg)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import simpleGit from "simple-git"
|
||||
import type { GitOps } from "../GitOps"
|
||||
import type { AgentManagerInMessage } from "../types"
|
||||
import type { ProjectRegistry } from "./registry"
|
||||
import type { ProjectContext, ProjectInitResult } from "./context"
|
||||
@@ -57,6 +58,7 @@ export interface ProjectMessageDeps {
|
||||
ready: (ctx: ProjectContext) => Promise<ProjectInitResult>
|
||||
/** Route one session to a directory inside a project (session override + project route). */
|
||||
routeSession?: (projectId: string, sessionId: string, directory: string, generation: number) => void
|
||||
git?: GitOps
|
||||
log: (...args: unknown[]) => void
|
||||
}
|
||||
|
||||
@@ -206,7 +208,17 @@ async function addProject(deps: ProjectMessageDeps): Promise<void> {
|
||||
if (!dir) return
|
||||
// resolveProjectRoot (not resolveGitRoot) so a folder inside a linked worktree
|
||||
// registers the primary checkout and cannot duplicate an existing project.
|
||||
const root = await resolveProjectRoot(dir, (cwd, args) => simpleGit(cwd).raw(args))
|
||||
const git = deps.git
|
||||
const root = await resolveProjectRoot(
|
||||
dir,
|
||||
git
|
||||
? async (cwd, args) => {
|
||||
const result = await git.execGit(args, cwd)
|
||||
if (result.code !== 0) throw new Error(result.stderr)
|
||||
return result.stdout
|
||||
}
|
||||
: (cwd, args) => simpleGit(cwd).raw(args),
|
||||
)
|
||||
if (!root) {
|
||||
deps.error("The selected folder is not inside a Git repository.")
|
||||
return
|
||||
|
||||
@@ -70,6 +70,7 @@ export function createProjectWiring(opts: {
|
||||
pushState: opts.pushState,
|
||||
selected: opts.selected,
|
||||
routeSession: opts.routeSession,
|
||||
git: opts.git,
|
||||
error: (message) => opts.host.showError(message),
|
||||
openSettings: (tab, projectId) => opts.host.openSettings(tab, projectId),
|
||||
log: opts.log,
|
||||
|
||||
@@ -127,6 +127,11 @@ interface WorktreeStatsMessage {
|
||||
stats: WorktreeStats[]
|
||||
}
|
||||
|
||||
interface WorktreeActivityMessage {
|
||||
type: "agentManager.worktreeActivity"
|
||||
active: string[]
|
||||
}
|
||||
|
||||
interface LocalStatsMessage {
|
||||
type: "agentManager.localStats"
|
||||
/** Owning project; absent in single-project mode. */
|
||||
@@ -452,6 +457,7 @@ interface RunStatusMessage extends RunStatus {
|
||||
|
||||
/** All messages the Agent Manager extension sends to the webview. */
|
||||
export type AgentManagerOutMessage =
|
||||
| WorktreeActivityMessage
|
||||
| WorktreeStatsMessage
|
||||
| LocalStatsMessage
|
||||
| WorktreeSetupMessage
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { samePath } from "./project/paths"
|
||||
import type { KiloConnectionService } from "../services/cli-backend"
|
||||
|
||||
type Snapshot = {
|
||||
statuses: Record<string, { type: string }>
|
||||
permissions: Array<{ id: string; sessionID: string }>
|
||||
questions: Array<{ id: string; sessionID: string; blocking?: boolean }>
|
||||
}
|
||||
|
||||
type Change =
|
||||
| { kind: "status"; sessionID: string; type: string }
|
||||
| { kind: "permission.add"; id: string; sessionID: string }
|
||||
| { kind: "permission.remove"; id: string; sessionID: string }
|
||||
| { kind: "question.add"; id: string; sessionID: string }
|
||||
| { kind: "question.remove"; id: string; sessionID: string }
|
||||
| { kind: "clear"; sessionID: string }
|
||||
|
||||
type State = {
|
||||
dir: string
|
||||
loaded: boolean
|
||||
statuses: Map<string, string>
|
||||
permissions: Map<string, string>
|
||||
questions: Map<string, string>
|
||||
request?: Request
|
||||
}
|
||||
|
||||
type Request = {
|
||||
readonly state: State
|
||||
readonly events: Change[]
|
||||
readonly promise: Promise<void>
|
||||
}
|
||||
|
||||
type Options = {
|
||||
paths: () => string[]
|
||||
load: (dir: string) => Promise<Snapshot>
|
||||
post: (active: string[]) => void
|
||||
log: (err: unknown) => void
|
||||
}
|
||||
|
||||
type FactoryOptions = {
|
||||
connection: KiloConnectionService
|
||||
paths: () => string[]
|
||||
post: (active: string[]) => void
|
||||
status: (event: unknown) => void
|
||||
lifecycle: (event: unknown) => void
|
||||
log: (err: unknown) => void
|
||||
}
|
||||
|
||||
const TYPES = new Set([
|
||||
"session.status",
|
||||
"session.deleted",
|
||||
"session.error",
|
||||
"permission.asked",
|
||||
"permission.replied",
|
||||
"question.asked",
|
||||
"question.replied",
|
||||
"question.rejected",
|
||||
"server.instance.disposed",
|
||||
])
|
||||
|
||||
function normalize(dir: string): string {
|
||||
const value = dir.replace(/\\/g, "/")
|
||||
if (/^[A-Za-z]:\/+$/u.test(value)) return `${value.slice(0, 2)}/`
|
||||
const result = value.replace(/\/+$/u, "")
|
||||
return result || "/"
|
||||
}
|
||||
|
||||
function matches(a: string, b: string): boolean {
|
||||
return samePath(normalize(a), normalize(b))
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
function string(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export class WorktreeActivity {
|
||||
private states: State[] = []
|
||||
private cache: string[] = []
|
||||
private dead = false
|
||||
|
||||
constructor(private readonly opts: Options) {}
|
||||
|
||||
static accepts(event: unknown): boolean {
|
||||
const value = record(event)
|
||||
return value !== undefined && typeof value.type === "string" && TYPES.has(value.type)
|
||||
}
|
||||
|
||||
async sync(force = false): Promise<void> {
|
||||
if (this.dead) return
|
||||
|
||||
let changed = false
|
||||
const wanted: State[] = []
|
||||
const current = this.states
|
||||
for (const dir of this.opts.paths()) {
|
||||
const state = this.find(current, wanted, dir)
|
||||
if (!state) continue
|
||||
if (wanted.includes(state)) continue
|
||||
if (state.dir !== dir) changed = true
|
||||
state.dir = dir
|
||||
wanted.push(state)
|
||||
}
|
||||
|
||||
changed = this.prune(current, wanted) || changed
|
||||
if (wanted.length !== current.length) changed = true
|
||||
this.states = wanted
|
||||
|
||||
const jobs: Promise<void>[] = []
|
||||
changed = this.load(wanted, force, jobs) || changed
|
||||
await Promise.all(jobs)
|
||||
if (changed) this.publish()
|
||||
}
|
||||
|
||||
replay(): void {
|
||||
if (this.dead) return
|
||||
this.opts.post(this.cache.slice())
|
||||
}
|
||||
|
||||
event(event: unknown, directory?: string): void {
|
||||
if (this.dead || !WorktreeActivity.accepts(event)) return
|
||||
const value = record(event)
|
||||
if (!value) return
|
||||
|
||||
const type = value.type
|
||||
if (type === "server.instance.disposed") {
|
||||
const props = record(value.properties)
|
||||
const dir = string(props?.directory) ?? directory
|
||||
if (!dir) return
|
||||
const state = this.states.find((item) => matches(item.dir, dir))
|
||||
if (!state) return
|
||||
this.invalidate(state)
|
||||
state.loaded = false
|
||||
this.publish()
|
||||
return
|
||||
}
|
||||
if (!directory) return
|
||||
const state = this.states.find((item) => matches(item.dir, directory))
|
||||
if (!state) return
|
||||
|
||||
const change = this.change(type, value.properties)
|
||||
if (!change) return
|
||||
if (state.request) state.request.events.push(change)
|
||||
this.apply(state, change)
|
||||
this.publish()
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.dead) return
|
||||
for (const state of this.states) this.invalidate(state)
|
||||
this.states = []
|
||||
this.cache = []
|
||||
this.opts.post([])
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.dead) return
|
||||
this.dead = true
|
||||
for (const state of this.states) this.invalidate(state)
|
||||
this.states = []
|
||||
this.cache = []
|
||||
}
|
||||
|
||||
private state(dir: string): State {
|
||||
return {
|
||||
dir,
|
||||
loaded: false,
|
||||
statuses: new Map(),
|
||||
permissions: new Map(),
|
||||
questions: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
private find(current: State[], wanted: State[], dir: unknown): State | undefined {
|
||||
if (typeof dir !== "string" || !dir) return undefined
|
||||
return (
|
||||
current.find((item) => matches(item.dir, dir)) ?? wanted.find((item) => matches(item.dir, dir)) ?? this.state(dir)
|
||||
)
|
||||
}
|
||||
|
||||
private prune(current: State[], wanted: State[]): boolean {
|
||||
let changed = false
|
||||
for (const state of current) {
|
||||
if (wanted.includes(state)) continue
|
||||
this.invalidate(state)
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private load(wanted: State[], force: boolean, jobs: Promise<void>[]): boolean {
|
||||
let changed = false
|
||||
for (const state of wanted) {
|
||||
const req = state.request ?? (!state.loaded || force ? this.request(state) : undefined)
|
||||
if (req) jobs.push(req.promise)
|
||||
if (req || !state.loaded || force) changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private request(state: State): Request {
|
||||
const req: Request = {
|
||||
state,
|
||||
events: [],
|
||||
promise: Promise.resolve()
|
||||
.then(() => this.opts.load(state.dir))
|
||||
.then((snapshot) => this.finish(req, snapshot))
|
||||
.catch((err: unknown) => {
|
||||
if (this.valid(req)) req.state.loaded = false
|
||||
this.opts.log(err)
|
||||
})
|
||||
.finally(() => {
|
||||
if (state.request === req) state.request = undefined
|
||||
}),
|
||||
}
|
||||
state.request = req
|
||||
return req
|
||||
}
|
||||
|
||||
private finish(req: Request, snapshot: Snapshot): void {
|
||||
if (!this.valid(req)) return
|
||||
const next = this.state(req.state.dir)
|
||||
for (const [sessionID, status] of Object.entries(snapshot.statuses ?? {})) {
|
||||
if (typeof status?.type === "string") next.statuses.set(sessionID, status.type)
|
||||
}
|
||||
for (const item of snapshot.permissions ?? []) {
|
||||
if (typeof item?.id === "string" && typeof item.sessionID === "string")
|
||||
next.permissions.set(item.id, item.sessionID)
|
||||
}
|
||||
for (const item of snapshot.questions ?? []) {
|
||||
if (item?.blocking !== false && typeof item?.id === "string" && typeof item.sessionID === "string")
|
||||
next.questions.set(item.id, item.sessionID)
|
||||
}
|
||||
for (const change of req.events) this.apply(next, change)
|
||||
Object.assign(req.state, {
|
||||
statuses: next.statuses,
|
||||
permissions: next.permissions,
|
||||
questions: next.questions,
|
||||
loaded: true,
|
||||
})
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private valid(req: Request): boolean {
|
||||
return !this.dead && req.state.request === req && this.states.includes(req.state)
|
||||
}
|
||||
|
||||
private invalidate(state: State): void {
|
||||
state.request = undefined
|
||||
state.loaded = false
|
||||
state.statuses.clear()
|
||||
state.permissions.clear()
|
||||
state.questions.clear()
|
||||
}
|
||||
|
||||
private change(type: unknown, props: unknown): Change | undefined {
|
||||
const value = record(props)
|
||||
if (!value) return undefined
|
||||
if (type === "session.status") return this.status(value)
|
||||
if (type === "session.deleted" || type === "session.error") return this.cleared(value)
|
||||
if (type === "permission.asked" || type === "question.asked") return this.add(type, value)
|
||||
if (type === "permission.replied" || type === "question.replied" || type === "question.rejected")
|
||||
return this.remove(type, value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
private status(value: Record<string, unknown>): Change | undefined {
|
||||
const sessionID = string(value.sessionID)
|
||||
const status = record(value.status)
|
||||
const type = string(status?.type)
|
||||
return sessionID && type ? { kind: "status", sessionID, type } : undefined
|
||||
}
|
||||
|
||||
private cleared(value: Record<string, unknown>): Change | undefined {
|
||||
const info = record(value.info)
|
||||
const sessionID = string(value.sessionID) ?? string(info?.id)
|
||||
return sessionID ? { kind: "clear", sessionID } : undefined
|
||||
}
|
||||
|
||||
private add(type: unknown, value: Record<string, unknown>): Change | undefined {
|
||||
const id = string(value.id)
|
||||
const sessionID = string(value.sessionID)
|
||||
if (!id || !sessionID) return undefined
|
||||
if (type === "question.asked" && value.blocking === false) return { kind: "question.remove", id, sessionID }
|
||||
return type === "permission.asked"
|
||||
? { kind: "permission.add", id, sessionID }
|
||||
: { kind: "question.add", id, sessionID }
|
||||
}
|
||||
|
||||
private remove(type: unknown, value: Record<string, unknown>): Change | undefined {
|
||||
const id = string(value.requestID)
|
||||
const sessionID = string(value.sessionID)
|
||||
if (!id || !sessionID) return undefined
|
||||
return type === "permission.replied"
|
||||
? { kind: "permission.remove", id, sessionID }
|
||||
: { kind: "question.remove", id, sessionID }
|
||||
}
|
||||
|
||||
private apply(state: State, change: Change): void {
|
||||
if (change.kind === "status") {
|
||||
state.statuses.set(change.sessionID, change.type)
|
||||
return
|
||||
}
|
||||
if (change.kind === "permission.add") {
|
||||
state.permissions.set(change.id, change.sessionID)
|
||||
return
|
||||
}
|
||||
if (change.kind === "permission.remove") {
|
||||
if (state.permissions.get(change.id) === change.sessionID) state.permissions.delete(change.id)
|
||||
return
|
||||
}
|
||||
if (change.kind === "question.add") {
|
||||
state.questions.set(change.id, change.sessionID)
|
||||
return
|
||||
}
|
||||
if (change.kind === "question.remove") {
|
||||
if (state.questions.get(change.id) === change.sessionID) state.questions.delete(change.id)
|
||||
return
|
||||
}
|
||||
state.statuses.delete(change.sessionID)
|
||||
for (const [id, sessionID] of state.permissions) {
|
||||
if (sessionID === change.sessionID) state.permissions.delete(id)
|
||||
}
|
||||
for (const [id, sessionID] of state.questions) {
|
||||
if (sessionID === change.sessionID) state.questions.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private active(state: State): boolean {
|
||||
const blocked = new Set([...state.permissions.values(), ...state.questions.values()])
|
||||
for (const [sessionID, type] of state.statuses) {
|
||||
if ((type === "busy" || type === "retry") && !blocked.has(sessionID)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
if (this.dead) return
|
||||
const active = this.states.filter((state) => this.active(state)).map((state) => state.dir)
|
||||
this.cache = active.slice()
|
||||
this.opts.post(active)
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorktreeActivity(opts: FactoryOptions) {
|
||||
const activity = new WorktreeActivity({
|
||||
paths: opts.paths,
|
||||
load: async (dir) => {
|
||||
const client = opts.connection.getClient()
|
||||
const [status, permission, question] = await Promise.all([
|
||||
client.session.status({ directory: dir }, { throwOnError: true }),
|
||||
client.permission.list({ directory: dir }, { throwOnError: true }),
|
||||
client.question.list({ directory: dir }, { throwOnError: true }),
|
||||
])
|
||||
return {
|
||||
statuses: status.data ?? {},
|
||||
permissions: permission.data ?? [],
|
||||
questions: question.data ?? [],
|
||||
}
|
||||
},
|
||||
post: opts.post,
|
||||
log: opts.log,
|
||||
})
|
||||
const filter = (event: unknown) => {
|
||||
const type = record(event)?.type
|
||||
return WorktreeActivity.accepts(event) || type === "session.created" || type === "session.updated"
|
||||
}
|
||||
const unsubEvent = opts.connection.onEventFiltered(filter, (event, directory) => {
|
||||
activity.event(event, directory)
|
||||
const type = record(event)?.type
|
||||
if (type === "session.status") opts.status(event)
|
||||
if (
|
||||
type === "session.created" ||
|
||||
type === "session.updated" ||
|
||||
type === "session.deleted" ||
|
||||
type === "session.error"
|
||||
)
|
||||
opts.lifecycle(event)
|
||||
})
|
||||
const sync = (force = false) => {
|
||||
if (force) activity.replay()
|
||||
if (opts.connection.getConnectionState() !== "connected") return Promise.resolve()
|
||||
return activity.sync(force)
|
||||
}
|
||||
const unsubState = opts.connection.onStateChange((state) => {
|
||||
if (state !== "connected") return activity.clear()
|
||||
void sync(true)
|
||||
})
|
||||
return {
|
||||
sync,
|
||||
dispose: () => {
|
||||
unsubEvent()
|
||||
unsubState()
|
||||
activity.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ const panelTitleHandler = (panel: vscode.WebviewPanel) => (title: string) => {
|
||||
// keybindings, autocomplete, commit-message generation, and URI deep links all work immediately —
|
||||
// without requiring the user to open a Kilo sidebar or panel first. The CLI backend is NOT spawned here;
|
||||
// it starts lazily when a webview connects or when ensureBackendForAutocomplete() triggers it.
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
console.log("Kilo Code extension is now active")
|
||||
shuttingDown = false
|
||||
|
||||
@@ -162,9 +162,16 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
// Create Agent Manager provider for editor panel
|
||||
const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService)
|
||||
const git = createGitExecutable({
|
||||
preferred: async () => {
|
||||
const extension = vscode.extensions.getExtension("vscode.git")
|
||||
if (!extension) return undefined
|
||||
if (!extension.isActive) await extension.activate()
|
||||
return extension.exports?.getAPI(1).git.path
|
||||
},
|
||||
log: (message) => console.warn(`[Kilo New] ${message}`),
|
||||
})
|
||||
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, git)
|
||||
const binary = process.platform === "win32" ? await git() : git
|
||||
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, binary)
|
||||
agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible }))
|
||||
agentManager = agentManagerProvider
|
||||
context.subscriptions.push(agentManagerProvider)
|
||||
|
||||
@@ -11,6 +11,8 @@ interface GitExecutableOptions {
|
||||
run?: (cmd: string, args: string[]) => Promise<{ stdout: string }>
|
||||
access?: (file: string, mode: number) => Promise<void>
|
||||
realpath?: (file: string) => Promise<string>
|
||||
preferred?: () => Promise<string | undefined>
|
||||
timeout?: number
|
||||
log?: (message: string) => void
|
||||
}
|
||||
|
||||
@@ -29,6 +31,27 @@ export function createGitExecutable(options: GitExecutableOptions = {}): GitExec
|
||||
|
||||
return (): Promise<string> => {
|
||||
cached ??= (async () => {
|
||||
if (platform === "win32") {
|
||||
const timeout = options.timeout ?? 3_000
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return (
|
||||
(await Promise.race([
|
||||
options.preferred?.(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`VS Code Git activation timed out after ${timeout}ms`))
|
||||
}, timeout)
|
||||
}),
|
||||
])) ?? "git"
|
||||
)
|
||||
} catch (err) {
|
||||
log(`Unable to resolve the preferred Git executable, using PATH: ${err}`)
|
||||
return "git"
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
if (platform !== "darwin") return "git"
|
||||
|
||||
try {
|
||||
|
||||
@@ -249,6 +249,22 @@ try {
|
||||
await check("root", "idle")
|
||||
assert.equal(value.permissions().length, 0)
|
||||
|
||||
await emit({ type: "sessionStatus", sessionID: "task-child", status: "busy" })
|
||||
await emit({
|
||||
type: "questionRequest",
|
||||
question: { id: "notice", sessionID: "task-child", blocking: false, questions: [] },
|
||||
})
|
||||
await check("root", "busy")
|
||||
await check("task-child", "busy")
|
||||
await emit({
|
||||
type: "questionRequest",
|
||||
question: { id: "notice", sessionID: "task-child", blocking: true, questions: [] },
|
||||
})
|
||||
await check("root", "waiting")
|
||||
await emit({ type: "questionResolved", requestID: "notice" })
|
||||
await check("root", "busy")
|
||||
await emit({ type: "sessionStatus", sessionID: "task-child", status: "idle" })
|
||||
|
||||
await emit({
|
||||
type: "questionRequest",
|
||||
question: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import { execFileSync } from "child_process"
|
||||
import { GitOps } from "../../src/agent-manager/GitOps"
|
||||
import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project/messages"
|
||||
import { ProjectRegistry, type RegistryStorage } from "../../src/agent-manager/project/registry"
|
||||
import { ProjectContexts } from "../../src/agent-manager/project/contexts"
|
||||
@@ -17,7 +18,7 @@ function gitRepo(): string {
|
||||
return fs.realpathSync(dir)
|
||||
}
|
||||
|
||||
function setup(opts: { enabled?: boolean; workspace?: string } = {}) {
|
||||
function setup(opts: { enabled?: boolean; workspace?: string; git?: GitOps } = {}) {
|
||||
let stored: unknown
|
||||
let pickResult: string | undefined
|
||||
const storage: RegistryStorage = {
|
||||
@@ -58,6 +59,7 @@ function setup(opts: { enabled?: boolean; workspace?: string } = {}) {
|
||||
calls.ready.push(ctx.id)
|
||||
return calls.readyResult
|
||||
},
|
||||
git: opts.git,
|
||||
log: () => {},
|
||||
}
|
||||
const pick = (dir: string | undefined) => {
|
||||
@@ -118,6 +120,19 @@ describe("handleProjectMessage", () => {
|
||||
expect(calls.error).toEqual(["The selected folder is not inside a Git repository."])
|
||||
})
|
||||
|
||||
it("uses the configured Git executable when adding a project", async () => {
|
||||
const repo = gitRepo()
|
||||
const git = new GitOps({ log: () => {}, binary: path.join(repo, "missing-git") })
|
||||
const { deps, calls, registry, pick } = setup({ git })
|
||||
pick(repo)
|
||||
|
||||
await handleProjectMessage(msg("agentManager.addProject"), deps)
|
||||
git.dispose()
|
||||
|
||||
expect(registry.list()).toEqual([])
|
||||
expect(calls.error).toEqual(["The selected folder is not inside a Git repository."])
|
||||
})
|
||||
|
||||
it("rejects the pinned workspace repository", async () => {
|
||||
const repo = gitRepo()
|
||||
const { deps, calls, pick } = setup({ workspace: repo })
|
||||
|
||||
@@ -2,6 +2,112 @@ import { describe, expect, it } from "bun:test"
|
||||
import { createGitExecutable } from "../../src/util/git-executable"
|
||||
|
||||
describe("createGitExecutable", () => {
|
||||
it("uses the configured Git executable on Windows", async () => {
|
||||
const git = createGitExecutable({
|
||||
platform: "win32",
|
||||
preferred: async () => "C:\\Program Files\\Git\\cmd\\git.exe",
|
||||
})
|
||||
|
||||
expect(await git()).toBe("C:\\Program Files\\Git\\cmd\\git.exe")
|
||||
})
|
||||
|
||||
it("falls back to PATH when the preferred Windows executable is missing", async () => {
|
||||
const git = createGitExecutable({
|
||||
platform: "win32",
|
||||
preferred: async () => undefined,
|
||||
})
|
||||
|
||||
expect(await git()).toBe("git")
|
||||
})
|
||||
|
||||
it("logs and falls back to PATH when preferred Windows resolution fails", async () => {
|
||||
const messages: string[] = []
|
||||
const git = createGitExecutable({
|
||||
platform: "win32",
|
||||
preferred: async () => {
|
||||
throw new Error("Git API unavailable")
|
||||
},
|
||||
log: (message) => messages.push(message),
|
||||
})
|
||||
|
||||
expect(await git()).toBe("git")
|
||||
expect(messages).toEqual(["Unable to resolve the preferred Git executable, using PATH: Error: Git API unavailable"])
|
||||
})
|
||||
|
||||
it("falls back to PATH when preferred Windows resolution hangs", async () => {
|
||||
const messages: string[] = []
|
||||
let calls = 0
|
||||
const git = createGitExecutable({
|
||||
platform: "win32",
|
||||
timeout: 10,
|
||||
preferred: () => {
|
||||
calls++
|
||||
return new Promise<string>(() => undefined)
|
||||
},
|
||||
log: (message) => messages.push(message),
|
||||
})
|
||||
|
||||
expect(await Promise.all([git(), git(), git()])).toEqual(["git", "git", "git"])
|
||||
expect(await git()).toBe("git")
|
||||
expect(calls).toBe(1)
|
||||
expect(messages).toEqual([
|
||||
"Unable to resolve the preferred Git executable, using PATH: Error: VS Code Git activation timed out after 10ms",
|
||||
])
|
||||
}, 1_000)
|
||||
|
||||
it("keeps the PATH fallback when preferred Windows resolution rejects after timeout", async () => {
|
||||
const messages: string[] = []
|
||||
let reject!: (error: Error) => void
|
||||
const pending = new Promise<string>((_, fail) => {
|
||||
reject = fail
|
||||
})
|
||||
const git = createGitExecutable({
|
||||
platform: "win32",
|
||||
timeout: 10,
|
||||
preferred: () => pending,
|
||||
log: (message) => messages.push(message),
|
||||
})
|
||||
|
||||
expect(await git()).toBe("git")
|
||||
reject(new Error("Late Git activation failure"))
|
||||
await Bun.sleep(0)
|
||||
expect(await git()).toBe("git")
|
||||
expect(messages).toHaveLength(1)
|
||||
}, 1_000)
|
||||
|
||||
it("keeps the preferred Windows executable after its timeout deadline", async () => {
|
||||
const messages: string[] = []
|
||||
const git = createGitExecutable({
|
||||
platform: "win32",
|
||||
timeout: 10,
|
||||
preferred: async () => "C:\\Program Files\\Git\\cmd\\git.exe",
|
||||
log: (message) => messages.push(message),
|
||||
})
|
||||
|
||||
expect(await git()).toBe("C:\\Program Files\\Git\\cmd\\git.exe")
|
||||
await Bun.sleep(20)
|
||||
expect(await git()).toBe("C:\\Program Files\\Git\\cmd\\git.exe")
|
||||
expect(messages).toEqual([])
|
||||
})
|
||||
|
||||
it("caches the preferred Windows executable", async () => {
|
||||
let calls = 0
|
||||
const git = createGitExecutable({
|
||||
platform: "win32",
|
||||
preferred: async () => {
|
||||
calls++
|
||||
return "C:\\Git\\git.exe"
|
||||
},
|
||||
})
|
||||
|
||||
expect(await Promise.all([git(), git(), git()])).toEqual([
|
||||
"C:\\Git\\git.exe",
|
||||
"C:\\Git\\git.exe",
|
||||
"C:\\Git\\git.exe",
|
||||
])
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it("preserves PATH lookup on other platforms", async () => {
|
||||
const git = createGitExecutable({
|
||||
platform: "linux",
|
||||
|
||||
@@ -57,6 +57,25 @@ describe("GitOps", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("uses an explicit Git executable path with spaces", async () => {
|
||||
await withRepo(async (cwd) => {
|
||||
const real = Bun.which("git")
|
||||
if (!real) throw new Error("Git is required for this test")
|
||||
|
||||
const dir = await fs.mkdtemp(nodePath.join(os.tmpdir(), "kilo-gitops executable-"))
|
||||
const binary = process.platform === "win32" ? real : nodePath.join(dir, "git")
|
||||
try {
|
||||
if (process.platform !== "win32") await fs.symlink(real, binary)
|
||||
|
||||
const git = new GitOps({ log: () => undefined, binary })
|
||||
expect(git.path).toBe(binary)
|
||||
expect(await fs.realpath(await git.root(cwd))).toBe(await fs.realpath(cwd))
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it("does not hold a semaphore slot while resolving Git", async () => {
|
||||
const semaphore = new Semaphore(1)
|
||||
let resolve!: (value: string) => void
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createSessionActivity } from "../../webview-ui/agent-manager/project/session-busy"
|
||||
import { createSessionActivity, createWorktreeActivity } from "../../webview-ui/agent-manager/project/session-busy"
|
||||
import type { ExtensionMessage } from "../../webview-ui/src/types/messages"
|
||||
import type { Activity } from "../../webview-ui/src/utils/session-activity"
|
||||
|
||||
const activity = (values: Record<string, "waiting" | "error" | "retry" | "busy" | "done" | "idle">) =>
|
||||
createSessionActivity({
|
||||
managed: () => [
|
||||
{ id: "current-wt", worktreeId: "wt-current" },
|
||||
{ id: "current-other", worktreeId: "wt-other" },
|
||||
{ id: "priority-busy", worktreeId: "wt-priority" },
|
||||
{ id: "priority-waiting", worktreeId: "wt-priority" },
|
||||
const options = (values: Record<string, Activity>) => ({
|
||||
managed: () => [
|
||||
{ id: "current-wt", worktreeId: "wt-current" },
|
||||
{ id: "current-other", worktreeId: "wt-other" },
|
||||
{ id: "priority-busy", worktreeId: "wt-priority" },
|
||||
{ id: "priority-waiting", worktreeId: "wt-priority" },
|
||||
],
|
||||
local: () => ["current-local"],
|
||||
projects: () => ({
|
||||
background: [
|
||||
{ id: "background-local", worktreeId: null },
|
||||
{ id: "background-wt", worktreeId: "wt-background" },
|
||||
],
|
||||
local: () => ["current-local"],
|
||||
projects: () => ({
|
||||
background: [
|
||||
{ id: "background-local", worktreeId: null },
|
||||
{ id: "background-wt", worktreeId: "wt-background" },
|
||||
],
|
||||
}),
|
||||
active: () => "current",
|
||||
activityFor: (id) => values[id] ?? "idle",
|
||||
})
|
||||
}),
|
||||
active: () => "current",
|
||||
activityFor: (id: string) => values[id] ?? "idle",
|
||||
})
|
||||
const activity = (values: Record<string, Activity>) => createSessionActivity(options(values))
|
||||
|
||||
describe("createSessionActivity", () => {
|
||||
it("returns idle for groups without sessions", () => {
|
||||
const state = activity({})
|
||||
|
||||
expect(state.agent("wt-missing")).toBe("idle")
|
||||
expect(state.project("background", "wt-missing")).toBe("idle")
|
||||
})
|
||||
@@ -36,7 +37,6 @@ describe("createSessionActivity", () => {
|
||||
"background-local": "retry",
|
||||
"background-wt": "error",
|
||||
})
|
||||
|
||||
expect(state.local()).toBe("done")
|
||||
expect(state.project("current", null)).toBe("done")
|
||||
expect(state.project("current", "wt-current")).toBe("busy")
|
||||
@@ -53,9 +53,57 @@ describe("createSessionActivity", () => {
|
||||
"background-local": "error",
|
||||
"background-wt": "waiting",
|
||||
})
|
||||
|
||||
expect(state.agent("wt-priority")).toBe("waiting")
|
||||
expect(state.project("current", "wt-other")).toBe("waiting")
|
||||
expect(state.project("background", "wt-background")).toBe("waiting")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createWorktreeActivity", () => {
|
||||
it("keeps directory activity separate from parent status and other projects", () => {
|
||||
const listeners = new Set<(message: ExtensionMessage) => void>()
|
||||
const state = createWorktreeActivity({
|
||||
...options({ "current-wt": "done", "current-other": "busy", "current-local": "done" }),
|
||||
worktrees: (project) => [
|
||||
{ id: "wt-current", path: project === "background" ? "/other/worktree" : "/repo/worktree" },
|
||||
],
|
||||
subscribe: (callback) => {
|
||||
listeners.add(callback)
|
||||
return () => listeners.delete(callback)
|
||||
},
|
||||
})
|
||||
const send = (active: string[]) => {
|
||||
for (const callback of listeners) callback({ type: "agentManager.worktreeActivity", active })
|
||||
}
|
||||
expect(state.agent("wt-current")).toBe("done")
|
||||
expect(state.agent("wt-other")).toBe("busy")
|
||||
send(["/repo/worktree"])
|
||||
expect(state.agent("wt-current")).toBe("busy")
|
||||
expect(state.project("current", "wt-current")).toBe("busy")
|
||||
expect(state.project("background", "wt-current")).toBe("idle")
|
||||
expect(state.project("background", null)).toBe("idle")
|
||||
expect(state.agent("missing")).toBe("idle")
|
||||
expect(state.local()).toBe("done")
|
||||
send(["/other/worktree"])
|
||||
expect(state.agent("wt-current")).toBe("done")
|
||||
expect(state.project("background", "wt-current")).toBe("busy")
|
||||
send([])
|
||||
expect(state.project("background", "wt-current")).toBe("idle")
|
||||
expect(state.agent("wt-other")).toBe("busy")
|
||||
})
|
||||
|
||||
it.each(["waiting", "error", "retry"] as const)("does not hide %s behind directory activity", (value) => {
|
||||
const listeners = new Set<(message: ExtensionMessage) => void>()
|
||||
const state = createWorktreeActivity({
|
||||
...options({ "current-wt": value }),
|
||||
worktrees: () => [{ id: "wt-current", path: "/repo/worktree" }],
|
||||
subscribe: (callback) => {
|
||||
listeners.add(callback)
|
||||
return () => listeners.delete(callback)
|
||||
},
|
||||
})
|
||||
for (const callback of listeners) callback({ type: "agentManager.worktreeActivity", active: ["/repo/worktree"] })
|
||||
expect(state.agent("wt-current")).toBe(value)
|
||||
expect(state.project("current", "wt-current")).toBe(value)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createWorktreeActivity, WorktreeActivity } from "../../src/agent-manager/worktree-activity"
|
||||
|
||||
type Snapshot = {
|
||||
statuses: Record<string, { type: string }>
|
||||
permissions: Array<{ id: string; sessionID: string }>
|
||||
questions: Array<{ id: string; sessionID: string; blocking?: boolean }>
|
||||
}
|
||||
|
||||
function defer<T>() {
|
||||
return Promise.withResolvers<T>()
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
statuses: Record<string, string> = {},
|
||||
permissions: string[][] = [],
|
||||
questions: string[][] = [],
|
||||
): Snapshot {
|
||||
return {
|
||||
statuses: Object.fromEntries(Object.entries(statuses).map(([id, type]) => [id, { type }])),
|
||||
permissions: permissions.map(([id, sessionID]) => ({ id, sessionID })),
|
||||
questions: questions.map(([id, sessionID]) => ({ id, sessionID })),
|
||||
}
|
||||
}
|
||||
|
||||
function status(sessionID: string, type: string) {
|
||||
return { type: "session.status", properties: { sessionID, status: { type } } }
|
||||
}
|
||||
|
||||
function asked(type: "permission" | "question", id: string, sessionID: string) {
|
||||
return { type: `${type}.asked`, properties: { id, sessionID } }
|
||||
}
|
||||
|
||||
function replied(type: "permission" | "question", requestID: string, sessionID: string, kind = "replied") {
|
||||
return { type: `${type}.${kind}`, properties: { requestID, sessionID } }
|
||||
}
|
||||
|
||||
function setup(dirs: string[], load: (dir: string) => Promise<Snapshot>) {
|
||||
const posted: string[][] = []
|
||||
const errors: unknown[] = []
|
||||
const activity = new WorktreeActivity({
|
||||
paths: () => dirs,
|
||||
load,
|
||||
post: (active) => posted.push(active),
|
||||
log: (err) => errors.push(err),
|
||||
})
|
||||
return { activity, posted, errors }
|
||||
}
|
||||
|
||||
function connection(client: unknown, state = "connected") {
|
||||
let stateListener: ((value: string) => void) | undefined
|
||||
let eventListener: ((event: unknown, directory?: string) => void) | undefined
|
||||
let eventFilter: ((event: unknown) => boolean) | undefined
|
||||
const value = {
|
||||
getClient: () => client,
|
||||
getConnectionState: () => state,
|
||||
onStateChange: (listener: (value: string) => void) => {
|
||||
stateListener = listener
|
||||
return () => {
|
||||
stateListener = undefined
|
||||
}
|
||||
},
|
||||
onEventFiltered: (filter: (event: unknown) => boolean, listener: (event: unknown, directory?: string) => void) => {
|
||||
eventFilter = filter
|
||||
eventListener = listener
|
||||
return () => {
|
||||
eventFilter = undefined
|
||||
eventListener = undefined
|
||||
}
|
||||
},
|
||||
change: (next: string) => {
|
||||
state = next
|
||||
stateListener?.(next)
|
||||
},
|
||||
set: (next: string) => {
|
||||
state = next
|
||||
},
|
||||
emit: (event: unknown, directory?: string) => {
|
||||
if (eventFilter?.(event)) eventListener?.(event, directory)
|
||||
},
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
describe("WorktreeActivity", () => {
|
||||
it("accepts only relevant SDK events", () => {
|
||||
expect(WorktreeActivity.accepts({ type: "session.status" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "session.deleted" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "session.error" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "permission.asked" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "permission.replied" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "question.asked" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "question.replied" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "question.rejected" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "server.instance.disposed" })).toBe(true)
|
||||
expect(WorktreeActivity.accepts({ type: "session.created" })).toBe(false)
|
||||
expect(WorktreeActivity.accepts(null)).toBe(false)
|
||||
expect(WorktreeActivity.accepts("session.status")).toBe(false)
|
||||
})
|
||||
|
||||
it("counts busy and retry children while ignoring idle and offline sessions", async () => {
|
||||
const test = setup(["/repo"], async () => snapshot({ parent: "idle", child: "busy" }))
|
||||
await test.activity.sync()
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
|
||||
test.activity.event(status("child", "idle"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
test.activity.event(status("parent", "retry"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
test.activity.event(status("parent", "offline"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("excludes blocked children without suppressing active siblings", async () => {
|
||||
const test = setup(["/repo"], async () => snapshot({ child: "busy", sibling: "idle" }))
|
||||
await test.activity.sync()
|
||||
|
||||
test.activity.event(asked("permission", "p1", "child"), "/repo")
|
||||
test.activity.event(asked("permission", "p2", "child"), "/repo")
|
||||
test.activity.event(asked("question", "q1", "child"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.event(status("sibling", "busy"), "/repo")
|
||||
test.activity.event(replied("permission", "p1", "child"), "/repo")
|
||||
test.activity.event(replied("question", "q1", "child"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
|
||||
test.activity.event(replied("permission", "p2", "child"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
test.activity.event(status("sibling", "idle"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
test.activity.event(status("child", "idle"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.event(asked("question", "q2", "child"), "/repo")
|
||||
test.activity.event(replied("question", "q2", "child", "rejected"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("ignores non-blocking questions in snapshots and live events", async () => {
|
||||
const test = setup(["/repo"], async () => ({
|
||||
...snapshot({ child: "busy" }),
|
||||
questions: [{ id: "note", sessionID: "child", blocking: false }],
|
||||
}))
|
||||
await test.activity.sync()
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
|
||||
const question = asked("question", "live", "child")
|
||||
test.activity.event({ ...question, properties: { ...question.properties, blocking: false } }, "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
test.activity.event(question, "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
test.activity.event({ ...question, properties: { ...question.properties, blocking: false } }, "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
})
|
||||
|
||||
it("clears sessions on completion, deletion, errors, and offline status", async () => {
|
||||
const test = setup(["/repo"], async () => snapshot())
|
||||
await test.activity.sync()
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
test.activity.event(status("s1", "complete"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
test.activity.event({ type: "session.error", properties: { sessionID: "s1" } }, "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
test.activity.event({ type: "session.deleted", properties: { info: { id: "s1" } } }, "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
test.activity.event(status("s1", "offline"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("isolates normalized directories and ignores unknown ownership", async () => {
|
||||
const dirs = ["/repo/a/", "/repo/b"]
|
||||
const test = setup(dirs, async () => snapshot())
|
||||
await test.activity.sync()
|
||||
|
||||
test.activity.event(status("a1", "busy"), "\\repo\\a\\")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo/a/"])
|
||||
test.activity.event(status("b1", "busy"), "/repo/b/")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo/a/", "/repo/b"])
|
||||
test.activity.event(status("unknown", "busy"), "/repo/unknown")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo/a/", "/repo/b"])
|
||||
})
|
||||
|
||||
it("deduplicates loads, hydrates new paths, force refreshes, and replays cached output", async () => {
|
||||
const dirs = ["/repo"]
|
||||
const firstGate = defer<Snapshot>()
|
||||
const forceGate = defer<Snapshot>()
|
||||
const newGate = defer<Snapshot>()
|
||||
const gates = [firstGate, forceGate, newGate]
|
||||
const calls: string[] = []
|
||||
const test = setup(dirs, (dir) => {
|
||||
calls.push(dir)
|
||||
const gate = gates.shift()
|
||||
if (!gate) return Promise.resolve(snapshot())
|
||||
return gate.promise
|
||||
})
|
||||
|
||||
const first = test.activity.sync()
|
||||
const second = test.activity.sync()
|
||||
await Bun.sleep(0)
|
||||
expect(calls).toEqual(["/repo"])
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
firstGate.resolve(snapshot({ s1: "busy" }))
|
||||
await Promise.all([first, second])
|
||||
expect(calls).toEqual(["/repo"])
|
||||
|
||||
test.activity.replay()
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
const force = test.activity.sync(true)
|
||||
await Bun.sleep(0)
|
||||
expect(calls).toEqual(["/repo", "/repo"])
|
||||
forceGate.resolve(snapshot())
|
||||
await force
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
dirs.push("/repo/new")
|
||||
const add = test.activity.sync()
|
||||
await Bun.sleep(0)
|
||||
expect(calls).toEqual(["/repo", "/repo", "/repo/new"])
|
||||
newGate.resolve(snapshot({ newSession: "retry" }))
|
||||
await add
|
||||
expect(test.posted.at(-1)).toEqual(["/repo/new"])
|
||||
})
|
||||
|
||||
it("defers the loader and recovers from synchronous failures", async () => {
|
||||
const error = new Error("load failed")
|
||||
let calls = 0
|
||||
const test = setup(["/repo"], () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw error
|
||||
return Promise.resolve(snapshot({ child: "busy" }))
|
||||
})
|
||||
const pending = test.activity.sync()
|
||||
expect(calls).toBe(0)
|
||||
await pending
|
||||
expect(test.errors).toEqual([error])
|
||||
|
||||
await test.activity.sync()
|
||||
expect(calls).toBe(2)
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
await test.activity.sync()
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
it("commits snapshots to the tracked state used by later events and refreshes", async () => {
|
||||
const initial = snapshot({ child: "busy" }, [["p1", "child"]], [["q1", "child"]])
|
||||
const snapshots = [initial, snapshot({ child: "retry" })]
|
||||
const test = setup(["/repo"], async () => snapshots.shift()!)
|
||||
await test.activity.sync()
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.event(replied("permission", "p1", "child"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
test.activity.event(replied("question", "q1", "child"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
|
||||
await test.activity.sync(true)
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
test.activity.event(status("child", "idle"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
expect(initial).toEqual(snapshot({ child: "busy" }, [["p1", "child"]], [["q1", "child"]]))
|
||||
expect(test.errors).toEqual([])
|
||||
})
|
||||
|
||||
it("does not let a snapshot overwrite newer events or remove other active children", async () => {
|
||||
const gate = defer<Snapshot>()
|
||||
const test = setup(["/repo"], () => gate.promise)
|
||||
const pending = test.activity.sync()
|
||||
test.activity.event(status("one", "idle"), "/repo")
|
||||
test.activity.event(status("two", "idle"), "/repo")
|
||||
test.activity.event(status("one", "busy"), "/repo")
|
||||
test.activity.event(status("two", "busy"), "/repo")
|
||||
test.activity.event(status("one", "idle"), "/repo")
|
||||
gate.resolve(snapshot({ one: "busy", two: "busy" }))
|
||||
await pending
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
test.activity.event(status("two", "idle"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("prunes removed paths and prevents old loads from reviving activity", async () => {
|
||||
const dirs = ["/repo"]
|
||||
const gate = defer<Snapshot>()
|
||||
const test = setup(dirs, () => gate.promise)
|
||||
const pending = test.activity.sync()
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
dirs.length = 0
|
||||
await test.activity.sync()
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
gate.resolve(snapshot({ s1: "busy" }))
|
||||
await pending
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("clears on disconnect and disposal, then allows a fresh sync", async () => {
|
||||
const dirs = ["/repo"]
|
||||
const firstGate = defer<Snapshot>()
|
||||
const nextGate = defer<Snapshot>()
|
||||
const gates = [firstGate, nextGate]
|
||||
const test = setup(dirs, () => gates.shift()!.promise)
|
||||
const first = test.activity.sync()
|
||||
test.activity.event(status("s1", "busy"), "/repo")
|
||||
test.activity.event({ type: "server.instance.disposed", properties: { directory: "/repo" } }, "/repo")
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
firstGate.resolve(snapshot({ s1: "busy" }))
|
||||
await first
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
|
||||
test.activity.clear()
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
const next = test.activity.sync()
|
||||
nextGate.resolve(snapshot({ s2: "busy" }))
|
||||
await next
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
|
||||
test.activity.dispose()
|
||||
const count = test.posted.length
|
||||
test.activity.event(status("s3", "busy"), "/repo")
|
||||
await test.activity.sync()
|
||||
test.activity.replay()
|
||||
expect(test.posted).toHaveLength(count)
|
||||
})
|
||||
|
||||
it("logs failed paths without erasing successful siblings and retries them", async () => {
|
||||
const dirs = ["/repo/a", "/repo/b"]
|
||||
const a = defer<Snapshot>()
|
||||
const b = defer<Snapshot>()
|
||||
const retry = defer<Snapshot>()
|
||||
const calls: string[] = []
|
||||
const test = setup(dirs, (dir) => {
|
||||
calls.push(dir)
|
||||
if (dir === "/repo/a") return a.promise
|
||||
if (calls.filter((item) => item === "/repo/b").length === 1) return b.promise
|
||||
return retry.promise
|
||||
})
|
||||
const pending = test.activity.sync()
|
||||
a.resolve(snapshot({ a1: "busy" }))
|
||||
b.reject(new Error("b failed"))
|
||||
await pending
|
||||
expect(test.errors).toHaveLength(1)
|
||||
expect(test.posted.at(-1)).toEqual(["/repo/a"])
|
||||
expect(calls).toEqual(["/repo/a", "/repo/b"])
|
||||
|
||||
const again = test.activity.sync()
|
||||
await Bun.sleep(0)
|
||||
expect(calls).toEqual(["/repo/a", "/repo/b", "/repo/b"])
|
||||
retry.resolve(snapshot())
|
||||
await again
|
||||
expect(test.posted.at(-1)).toEqual(["/repo/a"])
|
||||
})
|
||||
|
||||
it("does not replay failed-load events over a newer recovery snapshot", async () => {
|
||||
const first = defer<Snapshot>()
|
||||
const next = defer<Snapshot>()
|
||||
const gates = [first, next]
|
||||
const test = setup(["/repo"], () => gates.shift()!.promise)
|
||||
const pending = test.activity.sync()
|
||||
test.activity.event(status("child", "busy"), "/repo")
|
||||
first.reject(new Error("snapshot failed"))
|
||||
await pending
|
||||
expect(test.posted.at(-1)).toEqual(["/repo"])
|
||||
|
||||
const recovery = test.activity.sync(true)
|
||||
next.resolve(snapshot())
|
||||
await recovery
|
||||
expect(test.posted.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
it("publishes a ready worktree while another snapshot is still loading", async () => {
|
||||
const gate = defer<Snapshot>()
|
||||
const ready = defer<string[]>()
|
||||
const activity = new WorktreeActivity({
|
||||
paths: () => ["/fast", "/slow"],
|
||||
load: async (dir) => (dir === "/fast" ? snapshot({ child: "busy" }) : gate.promise),
|
||||
post: (active) => ready.resolve(active),
|
||||
log: (err) => {
|
||||
throw err
|
||||
},
|
||||
})
|
||||
const pending = activity.sync()
|
||||
expect(await ready.promise).toEqual(["/fast"])
|
||||
gate.resolve(snapshot())
|
||||
await pending
|
||||
activity.dispose()
|
||||
})
|
||||
|
||||
it("wires the activity wrapper to the connection without loading histories", async () => {
|
||||
const calls: string[] = []
|
||||
const client = {
|
||||
session: {
|
||||
status: async (input: { directory: string }, options: { throwOnError: true }) => {
|
||||
calls.push(`status:${input.directory}:${options.throwOnError}`)
|
||||
return { data: { s1: { type: "busy" } } }
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
list: async (input: { directory: string }, options: { throwOnError: true }) => {
|
||||
calls.push(`permission:${input.directory}:${options.throwOnError}`)
|
||||
return { data: [] }
|
||||
},
|
||||
},
|
||||
question: {
|
||||
list: async (input: { directory: string }, options: { throwOnError: true }) => {
|
||||
calls.push(`question:${input.directory}:${options.throwOnError}`)
|
||||
return { data: [] }
|
||||
},
|
||||
},
|
||||
}
|
||||
const conn = connection(client)
|
||||
const posted: string[][] = []
|
||||
const statuses: unknown[] = []
|
||||
const lifecycle: unknown[] = []
|
||||
const wrapper = createWorktreeActivity({
|
||||
connection: conn as never,
|
||||
paths: () => ["/repo"],
|
||||
post: (active) => posted.push(active),
|
||||
status: (event) => statuses.push(event),
|
||||
lifecycle: (event) => lifecycle.push(event),
|
||||
log: () => {},
|
||||
})
|
||||
|
||||
await wrapper.sync()
|
||||
expect(calls).toEqual(["status:/repo:true", "permission:/repo:true", "question:/repo:true"])
|
||||
expect(posted.at(-1)).toEqual(["/repo"])
|
||||
expect(conn.emit({ type: "session.created", properties: {} }, "/repo")).toBeUndefined()
|
||||
expect(conn.emit(status("s1", "idle"), "/repo")).toBeUndefined()
|
||||
expect(statuses).toHaveLength(1)
|
||||
expect(lifecycle).toHaveLength(1)
|
||||
expect(posted.at(-1)).toEqual([])
|
||||
|
||||
conn.change("disconnected")
|
||||
expect(posted.at(-1)).toEqual([])
|
||||
await wrapper.sync()
|
||||
expect(calls).toHaveLength(3)
|
||||
|
||||
conn.change("connected")
|
||||
await Bun.sleep(0)
|
||||
expect(calls).toHaveLength(6)
|
||||
wrapper.dispose()
|
||||
conn.emit(status("s1", "busy"), "/repo")
|
||||
expect(statuses).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("replays before a forced sync checks connection state", async () => {
|
||||
const client = {
|
||||
session: { status: async () => ({ data: { s1: { type: "busy" } } }) },
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
}
|
||||
const conn = connection(client)
|
||||
const posted: string[][] = []
|
||||
const wrapper = createWorktreeActivity({
|
||||
connection: conn as never,
|
||||
paths: () => ["/repo"],
|
||||
post: (active) => posted.push(active),
|
||||
status: () => {},
|
||||
lifecycle: () => {},
|
||||
log: () => {},
|
||||
})
|
||||
await wrapper.sync()
|
||||
conn.set("disconnected")
|
||||
posted.length = 0
|
||||
await wrapper.sync(true)
|
||||
expect(posted).toEqual([["/repo"]])
|
||||
wrapper.dispose()
|
||||
})
|
||||
})
|
||||
@@ -263,6 +263,96 @@ describe("WorktreeStateManager.updateWorktreeLabel", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("WorktreeManager.createWorktree", () => {
|
||||
it("uses a configured Git executable for worktree creation", async () => {
|
||||
const root = await createTempRepo()
|
||||
gitExec(["git", "-C", root, "config", "core.autocrlf", "false"])
|
||||
gitExec(["git", "-C", root, "config", "core.eol", "lf"])
|
||||
const real = Bun.which("git")
|
||||
if (!real) throw new Error("Git is required for this test")
|
||||
|
||||
const fake = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-no-git-"))
|
||||
tempDirs.push(fake)
|
||||
const file = path.join(fake, process.platform === "win32" ? "git.cmd" : "git")
|
||||
await fs.writeFile(file, process.platform === "win32" ? "@exit /b 127\r\n" : "#!/bin/sh\nexit 127\n")
|
||||
if (process.platform !== "win32") await fs.chmod(file, 0o755)
|
||||
|
||||
const bin =
|
||||
process.platform === "win32"
|
||||
? real
|
||||
: path.join(await fs.mkdtemp(path.join(os.tmpdir(), "kilo-git executable-")), "git")
|
||||
if (process.platform !== "win32") {
|
||||
const dir = path.dirname(bin)
|
||||
tempDirs.push(dir)
|
||||
await fs.symlink(real, bin)
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (typeof value === "string" && key.toLowerCase() !== "path") env[key] = value
|
||||
}
|
||||
const key = Object.keys(process.env).find((name) => name.toLowerCase() === "path") ?? "PATH"
|
||||
const dirs = [fake]
|
||||
if (process.platform === "win32") {
|
||||
const root = process.env.SystemRoot ?? process.env.windir
|
||||
if (root) {
|
||||
dirs.push(
|
||||
path.join(root, "System32"),
|
||||
path.join(root, "System32", "Wbem"),
|
||||
path.join(root, "System32", "WindowsPowerShell", "v1.0"),
|
||||
)
|
||||
}
|
||||
}
|
||||
env[key] = dirs.join(path.delimiter)
|
||||
env.KILO_TEST_ROOT = root
|
||||
env.KILO_TEST_GIT = bin
|
||||
|
||||
const script = `
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { GitOps } from "./src/agent-manager/GitOps"
|
||||
import { apply, capture } from "./src/agent-manager/git-transfer"
|
||||
import { WorktreeManager } from "./src/agent-manager/WorktreeManager"
|
||||
|
||||
const root = process.env.KILO_TEST_ROOT
|
||||
const git = process.env.KILO_TEST_GIT
|
||||
if (!root || !git) throw new Error("Missing configured Git test environment")
|
||||
|
||||
const ops = new GitOps({ log: () => undefined, binary: git })
|
||||
const manager = new WorktreeManager(root, () => undefined, ops)
|
||||
const result = await manager.createWorktree({ branchName: "configured-git" })
|
||||
if (!existsSync(path.join(result.path, ".git"))) throw new Error("Worktree was not created")
|
||||
if ((await ops.currentBranch(result.path)) !== result.branch) throw new Error("GitOps did not use configured Git")
|
||||
if (await manager.hasWork(result.path, result.parentBranch)) throw new Error("New worktree unexpectedly has work")
|
||||
await Bun.write(path.join(result.path, "configured.txt"), "configured")
|
||||
if (!(await manager.hasWork(result.path, result.parentBranch))) throw new Error("WorktreeManager did not use configured Git")
|
||||
await Bun.write(path.join(root, "README.md"), "staged\\n")
|
||||
const staged = await ops.execGit(["add", "README.md"], root)
|
||||
if (staged.code !== 0) throw new Error("Could not stage configured Git test change")
|
||||
await Bun.write(path.join(root, "README.md"), "unstaged\\n")
|
||||
const snapshot = await capture(root, () => undefined, git)
|
||||
if (!snapshot.staged?.includes("staged") || !snapshot.unstaged?.includes("unstaged")) {
|
||||
throw new Error("Git transfer did not capture staged and unstaged changes")
|
||||
}
|
||||
const applied = await apply(snapshot, result.path, () => undefined, git)
|
||||
if (!applied.ok) throw new Error(applied.error ?? "Git transfer did not apply changes")
|
||||
if ((await Bun.file(path.join(result.path, "README.md")).text()) !== "unstaged\\n") {
|
||||
throw new Error("Git transfer did not apply the working tree content")
|
||||
}
|
||||
const status = (await ops.execGit(["status", "--porcelain", "--", "README.md"], result.path)).stdout.trim()
|
||||
if (status !== "MM README.md") throw new Error("Git transfer did not preserve staged state: " + status)
|
||||
`
|
||||
const child = Bun.spawnSync([process.execPath, "-e", script], {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const stderr = child.stderr.toString("utf8")
|
||||
|
||||
expect(child.exitCode, stderr).toBe(0)
|
||||
expect(existsSync(path.join(root, ".kilo", "worktrees", "configured-git", ".git"))).toBe(true)
|
||||
}, 120_000)
|
||||
|
||||
it("creates a worktree with a new branch", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
|
||||
@@ -91,7 +91,7 @@ import { createProjectRegistry, type PersistedProjectTabs } from "./project/regi
|
||||
import type { WorktreeBusyState } from "./project/store"
|
||||
import { rememberTarget, restoreProjectTarget } from "./project/restore"
|
||||
import { createProjectStateRouter } from "./project/state"
|
||||
import { createSessionActivity } from "./project/session-busy"
|
||||
import { createWorktreeActivity } from "./project/session-busy"
|
||||
import { switchProject } from "./project/switch"
|
||||
import { createProjectStateHandlers } from "./project/state-handlers"
|
||||
import { ownsParent as ownsParentSession, isCurrent } from "./project/message-ownership"
|
||||
@@ -124,7 +124,6 @@ import {
|
||||
adjacentHint,
|
||||
focusChatSearch,
|
||||
LOCAL,
|
||||
remoteSessions,
|
||||
} from "./navigate"
|
||||
import { buildProjectNavEntries, createProjectNav } from "./project-nav"
|
||||
import {
|
||||
@@ -899,13 +898,14 @@ const AgentManagerContent: Component = () => {
|
||||
const label = worktreeLabel(wt)
|
||||
return label !== wt.branch ? wt.branch : undefined
|
||||
}
|
||||
|
||||
const activity = createSessionActivity({
|
||||
const activity = createWorktreeActivity({
|
||||
managed: managedSessions,
|
||||
local: localSessionIDs,
|
||||
projects: projectSessionsLive,
|
||||
active: activeProjectId,
|
||||
activityFor: session.activityFor,
|
||||
worktrees: (id) => (id ? registry.ensure(id) : registry.active()).worktrees(),
|
||||
subscribe: vscode.onMessage,
|
||||
})
|
||||
const sessionActivity = createMemo(() =>
|
||||
strongest(
|
||||
@@ -916,7 +916,7 @@ const AgentManagerContent: Component = () => {
|
||||
activity.project(project.id, null),
|
||||
...projectStates()[project.id]!.worktrees.map((worktree) => activity.project(project.id, worktree.id)),
|
||||
])
|
||||
: remoteSessions(localSessionIDs(), managedSessions(), isPending).map(session.activityFor),
|
||||
: [activity.local(), ...worktrees().map((worktree) => activity.agent(worktree.id))],
|
||||
),
|
||||
)
|
||||
createEffect(() => vscode.postMessage({ type: "sessionActivity", state: sessionActivity() }))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import type { ExtensionMessage } from "../../src/types/messages"
|
||||
import { strongest, type Activity } from "../../src/utils/session-activity"
|
||||
|
||||
interface Item {
|
||||
@@ -39,3 +40,26 @@ export function createSessionActivity(opts: {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createWorktreeActivity(
|
||||
opts: Parameters<typeof createSessionActivity>[0] & {
|
||||
worktrees: (project?: string) => { id: string; path: string }[]
|
||||
subscribe: (callback: (message: ExtensionMessage) => void) => () => void
|
||||
},
|
||||
) {
|
||||
const activity = createSessionActivity(opts)
|
||||
const [active, setActive] = createSignal(new Set<string>())
|
||||
onCleanup(
|
||||
opts.subscribe((message) => {
|
||||
if (message.type === "agentManager.worktreeActivity") setActive(new Set(message.active))
|
||||
}),
|
||||
)
|
||||
const working = (id: string, project?: string): Activity =>
|
||||
active().has(opts.worktrees(project).find((worktree) => worktree.id === id)?.path ?? "") ? "busy" : "idle"
|
||||
return {
|
||||
...activity,
|
||||
agent: (id: string) => strongest([activity.agent(id), working(id)]),
|
||||
project: (project: string, id: string | null) =>
|
||||
strongest([activity.project(project, id), id === null ? "idle" : working(id, project)]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1944,7 +1944,9 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
parents: lineage().parents,
|
||||
statuses: statusMap,
|
||||
outcomes: closeMap,
|
||||
blocked: [...permissions(), ...questions(), ...suggestions()].map((item) => item.sessionID),
|
||||
blocked: [...permissions(), ...questions().filter((item) => item.blocking !== false), ...suggestions()].map(
|
||||
(item) => item.sessionID,
|
||||
),
|
||||
submitting: Object.keys(submissionMap),
|
||||
disconnected: connection !== "connected",
|
||||
}),
|
||||
|
||||
@@ -809,6 +809,11 @@ export interface AgentManagerSessionForkedMessage {
|
||||
worktreeId?: string
|
||||
}
|
||||
|
||||
export interface AgentManagerWorktreeActivityMessage {
|
||||
type: "agentManager.worktreeActivity"
|
||||
active: string[]
|
||||
}
|
||||
|
||||
export interface AgentManagerSessionClosedMessage {
|
||||
type: "agentManager.sessionClosed"
|
||||
projectId?: string
|
||||
@@ -1505,6 +1510,7 @@ export type ExtensionMessage =
|
||||
| AgentManagerSessionAddedMessage
|
||||
| AgentManagerSessionForkedMessage
|
||||
| AgentManagerSessionClosedMessage
|
||||
| AgentManagerWorktreeActivityMessage
|
||||
| AgentManagerStateMessage
|
||||
| AgentManagerProjectsMessage
|
||||
| AgentManagerSelectionActivatedMessage
|
||||
|
||||
Reference in New Issue
Block a user