mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
fix(agent-manager): report worktree problems accurately and recover from them
Agent Manager could tell a user "Git is not installed or not found in PATH" while git was installed and working, show a worktree as having no changes when the status check had actually failed, and keep polling worktrees whose folder was long gone. One unresponsive worktree was enough to slow down status updates for every other row. Three causes, all of them the same mistake in different places: a failure was reported as a fact about something else. - A failed process launch reports ENOENT whether the program or the working directory is missing, and the code read that as "git is missing". - A failed or timed-out status check returned zero counts, which is indistinguishable from a clean worktree. - A timed-out GitHub CLI call was recorded as a success, which reset the backoff that was supposed to stop retrying it. Nothing reconciled the three views of a worktree either (the row, git's own registration, the folder on disk), so stale rows accumulated and were polled forever, and a timed-out `gh pr view` was retried on every cycle. Approach: name each state and never guess between them. A worktree is healthy, restorable, gone, not-a-worktree, or unmeasurable, and each state gets the message and the recovery action that actually applies. Startup reconciles the three views and repairs metadata only; deleting files is always a user's click. Repeated failures park one worktree instead of the whole panel, and git/gh calls get budgets sized to the work they do. Both clients implement the same states, wording, and recovery actions.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Report worktree problems accurately and repair them: stale entries are cleaned up on their own, a deleted worktree can be restored from its branch or removed while keeping its sessions, leftover folders are listed with a cleanup action, and a failed status check now says so instead of showing a worktree as unchanged. Pull request lookups no longer stall on an unresponsive GitHub CLI, and a single broken worktree no longer slows down the others. New "Show Worktree Diagnostics" command in VS Code and "Copy report" action in JetBrains settings.
|
||||
+42
-4
@@ -151,10 +151,39 @@ internal class GitComparison private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Default watchdog for a git command. Cheap queries only — a destructive delete needs its own, wider budget. */
|
||||
internal const val GIT_COMMAND_TIMEOUT_MS = 30_000
|
||||
/**
|
||||
* Watchdog budgets for git commands.
|
||||
*
|
||||
* One budget for everything meant a wedged `git --version` and a diff of a huge worktree waited the
|
||||
* same 30 seconds, and a poll cycle could hold several of those at once. Metadata queries get a
|
||||
* short budget so a stuck process is noticed quickly; content reads get a longer one because they
|
||||
* legitimately scale with the diff. Destructive commands keep their own, much wider budget.
|
||||
*/
|
||||
internal const val GIT_PROBE_TIMEOUT_MS = 5_000
|
||||
internal const val GIT_READ_TIMEOUT_MS = 15_000
|
||||
|
||||
internal fun runGitCommand(dir: Path, args: List<String>, timeoutMs: Int = GIT_COMMAND_TIMEOUT_MS): CmdOut {
|
||||
/** Default watchdog for a git command. Cheap queries only — a destructive delete needs its own, wider budget. */
|
||||
internal const val GIT_COMMAND_TIMEOUT_MS = GIT_READ_TIMEOUT_MS
|
||||
|
||||
/** Commands that only touch `.git` metadata and must answer almost immediately. */
|
||||
private val PROBES = setOf(
|
||||
"--version",
|
||||
"rev-parse",
|
||||
"symbolic-ref",
|
||||
"worktree",
|
||||
"branch",
|
||||
"config",
|
||||
"remote",
|
||||
"status",
|
||||
)
|
||||
|
||||
/** Budget for `args`, by the kind of work the command performs. */
|
||||
internal fun gitBudget(args: List<String>): Int {
|
||||
val head = args.firstOrNull() ?: return GIT_PROBE_TIMEOUT_MS
|
||||
return if (head in PROBES) GIT_PROBE_TIMEOUT_MS else GIT_READ_TIMEOUT_MS
|
||||
}
|
||||
|
||||
internal fun runGitCommand(dir: Path, args: List<String>, timeoutMs: Int = gitBudget(args)): CmdOut {
|
||||
return try {
|
||||
val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile())
|
||||
.withCharset(StandardCharsets.UTF_8).withEnvironment("LC_ALL", "C")
|
||||
@@ -168,15 +197,24 @@ internal fun runGitCommand(dir: Path, args: List<String>, timeoutMs: Int = GIT_C
|
||||
} catch (err: ProcessCanceledException) {
|
||||
throw err
|
||||
} catch (err: Exception) {
|
||||
// A launcher failure is not a timeout; keeping them apart is what lets callers report
|
||||
// "git timed out" instead of an unexplained "exit=-1".
|
||||
CmdOut(-1, "", err.message ?: "git failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun CmdOut.checked(): String {
|
||||
check(ok) { "Git comparison failed (exit=$exit): ${stderr.trim()}" }
|
||||
check(ok) { failure() }
|
||||
return stdout
|
||||
}
|
||||
|
||||
/** Message that says what actually went wrong, including whether the watchdog fired. */
|
||||
internal fun CmdOut.failure(): String {
|
||||
if (timeout) return "Git command timed out (no output within its budget)"
|
||||
val detail = stderr.trim().ifEmpty { "no stderr output" }
|
||||
return "Git comparison failed (exit=$exit): $detail"
|
||||
}
|
||||
|
||||
internal fun capDiff(files: List<DiffFileDto>, cap: Int, fetch: (DiffFileDto, Int) -> DiffFileDto?): List<DiffFileDto> {
|
||||
var used = 0
|
||||
var misses = 0
|
||||
|
||||
+96
-23
@@ -93,8 +93,11 @@ class KiloWorktreeRpcApiImpl(
|
||||
private const val PR_TTL = 90_000L
|
||||
// The rename+prune path returns long before this ever matters; it only bounds the fallback
|
||||
// `git worktree remove --force`, which recursively deletes the checkout synchronously and
|
||||
// therefore needs far more headroom than the 30s default query timeout.
|
||||
// therefore needs far more headroom than the default query timeout.
|
||||
private const val REMOVE_TIMEOUT_MS = 600_000
|
||||
// Total git/gh processes this service will run at once for one repository. Each poll used to
|
||||
// create its own Semaphore(4), so stats + dirty + PR polls could fan out three times that.
|
||||
private const val PROCESS_BUDGET = 4
|
||||
// Above this, a caller waiting on the per-repo mutation lock is worth a log line — most waits
|
||||
// are a few ms and would just be noise.
|
||||
private const val LOCK_WAIT_LOG_THRESHOLD_MS = 200L
|
||||
@@ -105,9 +108,11 @@ class KiloWorktreeRpcApiImpl(
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared across every polling path in this service; see [parallel]. */
|
||||
private val budget = Semaphore(PROCESS_BUDGET)
|
||||
private val prs = ConcurrentHashMap<String, Timed<WorktreePrListDto>>()
|
||||
private val branches = ConcurrentHashMap<String, Timed<BranchStatusDto>>()
|
||||
private val resolver = PrResolver(gh = ::runGh, git = ::runGit)
|
||||
private val resolver = PrResolver(gh = { dir, args, ms -> runGh(dir, args, ms) }, git = ::runGit)
|
||||
private val ghLock = Any()
|
||||
// Serializes the git-mutating operations (create/import/remove/rename/adopt/reorder/session-list)
|
||||
// for one repository, keyed by its main worktree's real path, so concurrent calls cannot interleave
|
||||
@@ -122,21 +127,20 @@ class KiloWorktreeRpcApiImpl(
|
||||
|
||||
override suspend fun list(directory: String): WorktreeListDto = withContext(Dispatchers.IO) {
|
||||
val base = Path.of(directory).normalize()
|
||||
val res = runGit(base, "worktree", "list", "--porcelain")
|
||||
if (!res.ok) return@withContext WorktreeListDto()
|
||||
val all = parseWorktreeList(res.stdout)
|
||||
val items = managedWorktrees(all)
|
||||
val alive = live(items.filter { it.main || Files.isDirectory(Path.of(it.path)) })
|
||||
// Same reconcile the stats/dirty polls use, so the rows and their status can never disagree
|
||||
// about which worktrees exist.
|
||||
val reconciled = reconcile(base) ?: return@withContext WorktreeListDto()
|
||||
val alive = reconciled.items
|
||||
val store = worktreeNameStore(alive)
|
||||
val state = store?.let { syncWorktreeState(it, worktreePaths(alive), livePaths(alive)) } ?: WorktreeState()
|
||||
val named = overlayWorktreeNames(alive, state.names)
|
||||
// Cheap and non-blocking: sweeps orphaned `.kilo-delete-*` directories left by an interrupted
|
||||
// delete (this plugin's or the VS Code extension's) every time the list is polled, so they do
|
||||
// not require a fresh remove() to be cleaned up.
|
||||
all.firstOrNull { it.main }?.let {
|
||||
alive.firstOrNull { it.main }?.let {
|
||||
trash?.sweep(Path.of(it.path).normalize().resolve(".kilo").resolve("worktrees").normalize())
|
||||
}
|
||||
WorktreeListDto(orderWorktrees(named, state.worktreeOrder))
|
||||
WorktreeListDto(orderWorktrees(named, state.worktreeOrder), orphans = reconciled.orphans)
|
||||
}
|
||||
|
||||
override suspend fun open(directory: String): Boolean {
|
||||
@@ -227,7 +231,12 @@ class KiloWorktreeRpcApiImpl(
|
||||
* `$GIT_DIR/worktrees` bookkeeping for a checkout it finds missing, never any files, and never a
|
||||
* locked worktree (the documented guard for worktrees on unmounted volumes).
|
||||
*/
|
||||
private fun sync(root: Path): List<WorktreeDto>? {
|
||||
private fun sync(root: Path): List<WorktreeDto>? = reconcile(root)?.items
|
||||
|
||||
/** Managed worktrees of one repository, plus directories nothing claims. */
|
||||
internal data class Reconciled(val items: List<WorktreeDto>, val orphans: List<String>)
|
||||
|
||||
private fun reconcile(root: Path): Reconciled? {
|
||||
if (!Files.isDirectory(root)) {
|
||||
LOG.info("worktree sync skipped, directory does not exist: $root")
|
||||
return null
|
||||
@@ -236,16 +245,50 @@ class KiloWorktreeRpcApiImpl(
|
||||
if (!res.ok) return null
|
||||
val raw = parseWorktreeList(res.stdout)
|
||||
val stale = staleWorktrees(raw, trash)
|
||||
val synced = if (stale.isEmpty()) managedWorktrees(raw) else {
|
||||
val all = if (stale.isEmpty()) raw else {
|
||||
LOG.info("worktree sync pruning stale managed worktrees: ${stale.joinToString(", ") { it.path }}")
|
||||
val prune = runGit(root, "worktree", "prune", "-v")
|
||||
if (!prune.ok) LOG.warn("worktree prune during sync failed: exit=${prune.exit} stderr=${snippet(prune.stderr)}")
|
||||
if (prune.ok && prune.stdout.isNotBlank()) LOG.info("worktree sync pruned: ${snippet(prune.stdout)}")
|
||||
val again = runGit(root, "worktree", "list", "--porcelain")
|
||||
if (!again.ok) return null
|
||||
managedWorktrees(parseWorktreeList(again.stdout))
|
||||
parseWorktreeList(again.stdout)
|
||||
}
|
||||
return live(synced.filter { Files.isDirectory(Path.of(it.path)) })
|
||||
val items = live(managedWorktrees(all).filter { Files.isDirectory(Path.of(it.path)) })
|
||||
return Reconciled(items, orphanDirs(all, main(all)))
|
||||
}
|
||||
|
||||
private fun main(all: List<WorktreeDto>): Path? =
|
||||
all.firstOrNull { it.main }?.let { Path.of(it.path).normalize() }
|
||||
|
||||
/**
|
||||
* Directories under `.kilo/worktrees/` that git does not track.
|
||||
*
|
||||
* Reported, never removed: a leftover directory can still hold files that exist nowhere else, so
|
||||
* deleting one is a user's decision. They are worth naming because they accumulate silently — an
|
||||
* interrupted delete or a hand-removed `.git/worktrees` entry leaves one behind every time.
|
||||
*/
|
||||
private fun orphanDirs(all: List<WorktreeDto>, base: Path?): List<String> {
|
||||
val dir = base?.resolve(".kilo")?.resolve("worktrees")?.normalize() ?: return emptyList()
|
||||
if (!Files.isDirectory(dir)) return emptyList()
|
||||
val tracked = all.map { Path.of(it.path).normalize().toString() }.toSet()
|
||||
val orphans = runCatching {
|
||||
Files.list(dir).use { stream ->
|
||||
stream.filter { Files.isDirectory(it) }
|
||||
.map { it.normalize() }
|
||||
.filter { it.fileName.toString().startsWith(".kilo-delete-").not() }
|
||||
.filter { it.toString() !in tracked }
|
||||
.map { it.toString() }
|
||||
.toList()
|
||||
}
|
||||
}.getOrElse { err ->
|
||||
LOG.info("worktree orphan scan skipped dir=$dir reason=${err.message}")
|
||||
emptyList()
|
||||
}
|
||||
if (orphans.isNotEmpty()) {
|
||||
LOG.info("worktree orphan directories (not removed): ${orphans.joinToString(", ")}")
|
||||
}
|
||||
return orphans
|
||||
}
|
||||
|
||||
override suspend fun ghStatus(directory: String, github: Boolean, maxAge: Long?): GhAvailability = withContext(Dispatchers.IO) {
|
||||
@@ -499,6 +542,11 @@ class KiloWorktreeRpcApiImpl(
|
||||
GhAvailability.RATE_LIMITED -> return@lock CreateWorktreeResultDto(
|
||||
error = "GitHub is rate limiting this token. Try again later.",
|
||||
)
|
||||
// Same reasoning as a spent budget: several gh calls follow, and a gh that just
|
||||
// failed to answer within its budget would strand the import part-way.
|
||||
GhAvailability.TIMEOUT -> return@lock CreateWorktreeResultDto(
|
||||
error = "GitHub CLI (gh) did not respond in time. Try again.",
|
||||
)
|
||||
GhAvailability.OK -> Unit
|
||||
}
|
||||
val fields = "headRefName,title,isCrossRepository,headRepositoryOwner"
|
||||
@@ -801,13 +849,15 @@ class KiloWorktreeRpcApiImpl(
|
||||
|
||||
private fun runGh(base: Path, vararg args: String): CmdOut = runGh(base, args.toList())
|
||||
|
||||
private fun runGh(base: Path, args: List<String>): CmdOut {
|
||||
private fun runGh(base: Path, args: List<String>, timeoutMs: Int = GH_READ_TIMEOUT_MS): CmdOut {
|
||||
return try {
|
||||
val cmd = GeneralCommandLine(listOf("gh") + args)
|
||||
.withWorkDirectory(base.toFile())
|
||||
.withParentEnvironmentType(ParentEnvironmentType.CONSOLE)
|
||||
val out = CapturingProcessHandler(cmd).runProcess(30_000)
|
||||
if (out.isTimeout) LOG.warn("gh command timed out: dir=$base args=${args.joinToString(" ")} ms=30000")
|
||||
val out = CapturingProcessHandler(cmd).runProcess(timeoutMs)
|
||||
if (out.isTimeout) {
|
||||
LOG.warn("gh command timed out: dir=$base args=${args.joinToString(" ")} ms=$timeoutMs")
|
||||
}
|
||||
CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr, out.isTimeout)
|
||||
} catch (e: Exception) {
|
||||
CmdOut(-1, "", e.message ?: "gh failed")
|
||||
@@ -828,9 +878,15 @@ class KiloWorktreeRpcApiImpl(
|
||||
text.contains("missing but already registered worktree", ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run [block] for every item, bounded by one service-wide process budget.
|
||||
*
|
||||
* The budget is shared across stats, dirty, and PR polling on purpose: those loops run on the
|
||||
* same cadence, and a per-call semaphore let them multiply into a process storm where even
|
||||
* `git --version` timed out.
|
||||
*/
|
||||
private suspend fun <T, R> parallel(items: List<T>, block: suspend (T) -> R): List<R> = coroutineScope {
|
||||
val sem = Semaphore(4)
|
||||
items.map { item -> async { sem.withPermit { block(item) } } }.map { it.await() }
|
||||
items.map { item -> async { budget.withPermit { block(item) } } }.map { it.await() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -843,12 +899,16 @@ class KiloWorktreeRpcApiImpl(
|
||||
stats(item, fallback)
|
||||
}.getOrElse { err ->
|
||||
if (err is CancellationException) throw err
|
||||
if (badDir(err.message.orEmpty())) {
|
||||
val gone = badDir(err.message.orEmpty())
|
||||
if (gone) {
|
||||
LOG.info("worktree poll skipped: op=stats path=${item.path} reason=gone")
|
||||
} else {
|
||||
LOG.warn("worktree poll failed: op=stats path=${item.path} message=${err.message}", err)
|
||||
}
|
||||
WorktreeStatsDto(item.path)
|
||||
// A directory that is gone has genuinely nothing to report; anything else is unknown, and
|
||||
// zeros would read as "clean" in the UI.
|
||||
if (gone) WorktreeStatsDto(item.path)
|
||||
else WorktreeStatsDto(item.path, unavailable = true, reason = err.message.orEmpty())
|
||||
}
|
||||
|
||||
private fun stats(item: WorktreeDto, fallback: String): WorktreeStatsDto {
|
||||
@@ -872,12 +932,14 @@ class KiloWorktreeRpcApiImpl(
|
||||
dirty(item)
|
||||
}.getOrElse { err ->
|
||||
if (err is CancellationException) throw err
|
||||
if (badDir(err.message.orEmpty())) {
|
||||
val gone = badDir(err.message.orEmpty())
|
||||
if (gone) {
|
||||
LOG.info("worktree poll skipped: op=dirty path=${item.path} reason=gone")
|
||||
} else {
|
||||
LOG.warn("worktree poll failed: op=dirty path=${item.path} message=${err.message}", err)
|
||||
}
|
||||
WorktreeDirtyDto(item.path)
|
||||
if (gone) WorktreeDirtyDto(item.path)
|
||||
else WorktreeDirtyDto(item.path, unavailable = true, reason = err.message.orEmpty())
|
||||
}
|
||||
|
||||
private fun dirty(item: WorktreeDto): WorktreeDirtyDto {
|
||||
@@ -965,7 +1027,7 @@ class KiloWorktreeRpcApiImpl(
|
||||
return@synchronized GhAvailability.OK
|
||||
}
|
||||
val res = runGh(root, "auth", "status")
|
||||
val value = if (res.ok) GhAvailability.OK else classifyGhError(res.stderr.ifBlank { res.stdout })
|
||||
val value = if (res.ok) GhAvailability.OK else classifyGhError(res)
|
||||
ghCache = Timed(System.currentTimeMillis(), value)
|
||||
LOG.info("gh probe result reason=$reason value=$value exit=${res.exit} ms=${System.currentTimeMillis() - start} stderr=${snippet(res.stderr)}")
|
||||
value
|
||||
@@ -1018,6 +1080,17 @@ internal fun badDir(text: String): Boolean {
|
||||
return msg.contains("unable to read current working directory")
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a failing `gh` command, using the timeout flag rather than guessing from text.
|
||||
*
|
||||
* A timed-out command has no stderr to classify, so text-only classification fell through to [OK] —
|
||||
* which told the probe loop everything was fine and reset its backoff.
|
||||
*/
|
||||
internal fun classifyGhError(out: CmdOut): GhAvailability {
|
||||
if (out.timeout) return GhAvailability.TIMEOUT
|
||||
return classifyGhError(out.stderr.ifBlank { out.stdout })
|
||||
}
|
||||
|
||||
internal fun classifyGhError(text: String): GhAvailability {
|
||||
val msg = text.lowercase()
|
||||
if (msg.contains("not logged") || msg.contains("gh auth login") || msg.contains("authentication")) return GhAvailability.UNAUTH
|
||||
|
||||
+50
-17
@@ -10,6 +10,16 @@ import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Budgets for `gh` reads.
|
||||
*
|
||||
* [GH_READ_TIMEOUT_MS] bounds an ordinary lookup; [GH_PROBE_TIMEOUT_MS] bounds the selector-less
|
||||
* `gh pr view`, which has been observed hanging indefinitely inside a worktree. Both replace a single
|
||||
* 30s budget that let one hanging command occupy a poll slot for an entire poll interval.
|
||||
*/
|
||||
internal const val GH_READ_TIMEOUT_MS = 10_000
|
||||
internal const val GH_PROBE_TIMEOUT_MS = 5_000
|
||||
|
||||
/** Result of running a `git`/`gh` command. */
|
||||
internal data class CmdOut(
|
||||
val exit: Int,
|
||||
@@ -106,7 +116,7 @@ internal fun richRefusal(stderr: String): RichRefusal? {
|
||||
* Commands are injected so the strategy ladder is testable without `gh` or network access.
|
||||
*/
|
||||
internal class PrResolver(
|
||||
private val gh: (Path, List<String>) -> CmdOut,
|
||||
private val gh: (Path, List<String>, Int) -> CmdOut,
|
||||
private val git: (Path, List<String>) -> CmdOut,
|
||||
) {
|
||||
// Volatile because prStatus resolves several checkouts concurrently. Two threads racing to clear it
|
||||
@@ -127,12 +137,27 @@ internal class PrResolver(
|
||||
return comments(dir, find(dir, path, branch, base))
|
||||
}
|
||||
|
||||
/** The strategy ladder, answering with the PR alone — no review conversations yet. */
|
||||
/**
|
||||
* The strategy ladder, answering with the PR alone — no review conversations yet.
|
||||
*
|
||||
* Naming the branch comes first: the selector-less form is the one observed hanging indefinitely
|
||||
* in a worktree, and for an Agent Manager worktree the branch is always known. The selector-less
|
||||
* form still runs afterwards, on a short budget, because it is the only one that resolves a fork
|
||||
* PR through `branch.<name>.merge`.
|
||||
*/
|
||||
private fun find(dir: Path, path: String, branch: String, base: String?): PrLookup {
|
||||
view(dir, path, null)?.let { return it }
|
||||
view(dir, path, branch)?.let { return it }
|
||||
if (branch == base) return PrLookup()
|
||||
return search(dir, path) ?: PrLookup()
|
||||
val slow = Timeouts()
|
||||
view(dir, path, branch, GH_READ_TIMEOUT_MS, slow)?.let { return it }
|
||||
view(dir, path, null, GH_PROBE_TIMEOUT_MS, slow)?.let { return it }
|
||||
if (branch != base) search(dir, path, slow)?.let { return it }
|
||||
// Nothing answered. A ladder that timed out has not established that there is no PR, so it
|
||||
// must not report one absent — the frontend keeps the previous answer for an unavailable gh.
|
||||
return if (slow.hit) PrLookup(availability = GhAvailability.TIMEOUT) else PrLookup()
|
||||
}
|
||||
|
||||
/** Records whether any strategy in one ladder run exceeded its budget. */
|
||||
private class Timeouts {
|
||||
var hit = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,7 +182,7 @@ internal class PrResolver(
|
||||
val pr = found.pr ?: return found
|
||||
if (pr.state != GhState.OPEN && pr.state != GhState.DRAFT) return found
|
||||
if (!threads || found.node.isEmpty()) return found
|
||||
val out = gh(dir, listOf("api", "graphql", "-f", "query=$THREADS_QUERY", "-f", "id=${found.node}"))
|
||||
val out = gh(dir, listOf("api", "graphql", "-f", "query=$THREADS_QUERY", "-f", "id=${found.node}"), GH_READ_TIMEOUT_MS)
|
||||
if (out.ok) return found.copy(pr = pr.copy(comments = parseThreads(out.stdout)))
|
||||
if (rateLimited(out.stderr.lowercase())) return PrLookup(availability = GhAvailability.RATE_LIMITED)
|
||||
if (richRefusal(out.stderr) == RichRefusal.FIELD) {
|
||||
@@ -170,8 +195,8 @@ internal class PrResolver(
|
||||
}
|
||||
|
||||
/** Null means "no PR here, keep looking"; a value is terminal (a PR, or gh being unusable). */
|
||||
private fun view(dir: Path, path: String, branch: String?): PrLookup? {
|
||||
val out = query(dir) { fields ->
|
||||
private fun view(dir: Path, path: String, branch: String?, timeoutMs: Int, slow: Timeouts): PrLookup? {
|
||||
val out = query(dir, timeoutMs) { fields ->
|
||||
buildList {
|
||||
add("pr")
|
||||
add("view")
|
||||
@@ -180,7 +205,7 @@ internal class PrResolver(
|
||||
add(fields)
|
||||
}
|
||||
}
|
||||
if (!out.ok) return unusable(out.stderr)
|
||||
if (!out.ok) return unusable(out, slow)
|
||||
return parsePr(path, out.stdout)?.let { PrLookup(it, node = parsePrNodeId(out.stdout)) }
|
||||
}
|
||||
|
||||
@@ -194,9 +219,9 @@ internal class PrResolver(
|
||||
* for the repository that reported it, so latching would strip review/CI from every other
|
||||
* checkout until the IDE restarts.
|
||||
*/
|
||||
private fun query(dir: Path, command: (String) -> List<String>): CmdOut {
|
||||
private fun query(dir: Path, timeoutMs: Int = GH_READ_TIMEOUT_MS, command: (String) -> List<String>): CmdOut {
|
||||
val wanted = if (rich) PR_RICH_FIELDS else PR_FIELDS
|
||||
val out = gh(dir, command(wanted))
|
||||
val out = gh(dir, command(wanted), timeoutMs)
|
||||
if (out.ok || wanted == PR_FIELDS) return out
|
||||
// A spent budget refuses the scalar form just as readily, so retrying only burns another call.
|
||||
if (rateLimited(out.stderr.lowercase())) return out
|
||||
@@ -205,16 +230,16 @@ internal class PrResolver(
|
||||
rich = false
|
||||
LOG.info("gh cannot answer review/CI fields, falling back to scalars: ${out.stderr.trim()}")
|
||||
}
|
||||
return gh(dir, command(PR_FIELDS))
|
||||
return gh(dir, command(PR_FIELDS), timeoutMs)
|
||||
}
|
||||
|
||||
private fun search(dir: Path, path: String): PrLookup? {
|
||||
private fun search(dir: Path, path: String, slow: Timeouts): PrLookup? {
|
||||
val head = git(dir, listOf("rev-parse", "HEAD")).stdout.trim()
|
||||
if (head.isEmpty()) return null
|
||||
val out = query(dir) { fields ->
|
||||
listOf("pr", "list", "--state", "all", "--search", "$head is:pr", "--limit", "5", "--json", "$fields,headRefOid")
|
||||
}
|
||||
if (!out.ok) return unusable(out.stderr)
|
||||
if (!out.ok) return unusable(out, slow)
|
||||
val items = runCatching { json.parseToJsonElement(out.stdout) as? JsonArray }.getOrNull() ?: return null
|
||||
for (item in items) {
|
||||
val obj = item as? JsonObject ?: continue
|
||||
@@ -226,8 +251,16 @@ internal class PrResolver(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun unusable(stderr: String): PrLookup? {
|
||||
val status = prError(stderr)
|
||||
/**
|
||||
* A timed-out lookup is not evidence that the PR does not exist, so it does not end the ladder —
|
||||
* but it is recorded, so a ladder that never answers reports a timeout instead of "no PR".
|
||||
*/
|
||||
private fun unusable(out: CmdOut, slow: Timeouts): PrLookup? {
|
||||
if (out.timeout) {
|
||||
slow.hit = true
|
||||
return null
|
||||
}
|
||||
val status = prError(out.stderr)
|
||||
return if (status == GhAvailability.OK) null else PrLookup(availability = status)
|
||||
}
|
||||
}
|
||||
|
||||
+81
-2
@@ -1,5 +1,9 @@
|
||||
package ai.kilocode.backend.rpc
|
||||
|
||||
import ai.kilocode.backend.diff.GIT_PROBE_TIMEOUT_MS
|
||||
import ai.kilocode.backend.diff.GIT_READ_TIMEOUT_MS
|
||||
import ai.kilocode.backend.diff.failure
|
||||
import ai.kilocode.backend.diff.gitBudget
|
||||
import ai.kilocode.backend.worktree.WorktreeTrash
|
||||
import ai.kilocode.rpc.parsePrUrl
|
||||
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
|
||||
@@ -66,6 +70,76 @@ class KiloWorktreeRpcApiImplTest {
|
||||
assertEquals(GhAvailability.OK, api.prStatus(repo.resolve("missing").toString()).availability)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `git budgets separate cheap metadata from content reads`() {
|
||||
// One 30s budget for everything let a wedged `git --version` hold a poll slot as long as a
|
||||
// diff of a huge worktree legitimately needs.
|
||||
assertEquals(GIT_PROBE_TIMEOUT_MS, gitBudget(listOf("--version")))
|
||||
assertEquals(GIT_PROBE_TIMEOUT_MS, gitBudget(listOf("rev-parse", "HEAD")))
|
||||
assertEquals(GIT_PROBE_TIMEOUT_MS, gitBudget(listOf("worktree", "list", "--porcelain")))
|
||||
assertEquals(GIT_READ_TIMEOUT_MS, gitBudget(listOf("diff", "--numstat")))
|
||||
assertEquals(GIT_READ_TIMEOUT_MS, gitBudget(listOf("show", "HEAD:file")))
|
||||
assertTrue(GIT_PROBE_TIMEOUT_MS < GIT_READ_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed command says whether it timed out`() {
|
||||
// `Git comparison failed (exit=-1):` with empty stderr is what a timeout used to look like.
|
||||
val timedOut = CmdOut(-1, "", "", timeout = true).failure()
|
||||
assertTrue(timedOut.contains("timed out"), "a timeout must say so: $timedOut")
|
||||
assertFalse(timedOut.contains("exit=-1"), "an unexplained exit code is not a reason: $timedOut")
|
||||
|
||||
val failed = CmdOut(128, "", "fatal: not a git repository").failure()
|
||||
assertTrue(failed.contains("exit=128"), failed)
|
||||
assertTrue(failed.contains("not a git repository"), failed)
|
||||
|
||||
// An empty stderr still has to read as something.
|
||||
assertTrue(CmdOut(1, "", "").failure().contains("no stderr output"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gh classification separates a timeout from a healthy gh`() {
|
||||
// Reported as OK, a timeout reset the probe's failure counter and defeated its own backoff.
|
||||
assertEquals(GhAvailability.TIMEOUT, classifyGhError(CmdOut(-1, "", "", timeout = true)))
|
||||
assertEquals(GhAvailability.OK, classifyGhError(CmdOut(1, "", "no pull requests found")))
|
||||
assertEquals(GhAvailability.MISSING, classifyGhError(CmdOut(-1, "", "Cannot run program \"gh\"")))
|
||||
assertEquals(GhAvailability.UNAUTH, classifyGhError(CmdOut(1, "", "gh auth login required")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list reports leftover directories under the worktrees folder without removing them`() = runBlocking {
|
||||
initRepo()
|
||||
val created = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree)
|
||||
val leftover = repo.resolve(".kilo").resolve("worktrees").resolve("leftover")
|
||||
Files.createDirectories(leftover.resolve(".kilo-dev"))
|
||||
|
||||
val listed = api.list(repo.toString())
|
||||
|
||||
// Orphan paths are resolved the way git reports worktree paths (realpath), so they compare
|
||||
// equal to the other DTOs' paths on a symlinked temp dir.
|
||||
assertEquals(listOf(leftover.toRealPath().toString()), listed.orphans)
|
||||
assertTrue(listed.worktrees.any { it.path == created.path }, "a live worktree is not an orphan")
|
||||
// Reported, never deleted: a leftover directory can hold files that exist nowhere else.
|
||||
assertTrue(Files.isDirectory(leftover.resolve(".kilo-dev")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `list and the polls agree on which worktrees exist`() = runBlocking {
|
||||
initRepo()
|
||||
val kept = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("kept")).worktree)
|
||||
val removed = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("gone")).worktree)
|
||||
delete(Path.of(removed.path))
|
||||
|
||||
// list() used to reconcile differently from the stats/dirty polls, so a row could exist that
|
||||
// nothing would ever report status for.
|
||||
val listed = api.list(repo.toString()).worktrees.map { it.path }.toSet()
|
||||
val dirty = api.dirty(repo.toString()).items.map { it.path }.toSet()
|
||||
|
||||
assertTrue(listed.contains(kept.path))
|
||||
assertFalse(listed.contains(removed.path), "a directory that is gone must not be listed")
|
||||
assertEquals(listed, dirty, "rows and polled paths must not disagree")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cache entry is usable only within both the ttl and the caller ceiling`() {
|
||||
assertTrue(usable(time = 0, now = 89_999, ttl = 90_000, maxAge = null))
|
||||
@@ -988,7 +1062,7 @@ class KiloWorktreeRpcApiImplTest {
|
||||
* unlike [dirty]'s working-tree comparison used below.
|
||||
*/
|
||||
@Test
|
||||
fun `dirty reports a neutral entry for a worktree whose index is corrupted instead of failing the whole call`() = runBlocking {
|
||||
fun `dirty reports an unavailable entry for a worktree whose index is corrupted instead of failing the whole call`() = runBlocking {
|
||||
initRepo()
|
||||
val healthy = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("healthy")).worktree)
|
||||
val broken = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("broken")).worktree)
|
||||
@@ -1003,8 +1077,13 @@ class KiloWorktreeRpcApiImplTest {
|
||||
|
||||
val healthyItem = assertNotNull(dto.items.singleOrNull { it.path == healthy.path })
|
||||
assertEquals(1, healthyItem.files, "a healthy sibling must still be reported correctly")
|
||||
assertFalse(healthyItem.unavailable, "a worktree that answered is not unavailable")
|
||||
val brokenItem = assertNotNull(dto.items.singleOrNull { it.path == broken.path })
|
||||
assertEquals(WorktreeDirtyDto(broken.path), brokenItem, "a broken worktree gets a neutral entry, not an exception")
|
||||
// Isolated (no exception escapes) but not silent: zeros alone would render as a clean worktree
|
||||
// and quietly replace whatever the row was showing.
|
||||
assertTrue(brokenItem.unavailable, "a failed measurement must not read as a clean worktree")
|
||||
assertTrue(brokenItem.reason.isNotBlank(), "the failure reason belongs in the DTO")
|
||||
assertEquals(WorktreeDirtyDto(broken.path, unavailable = true, reason = brokenItem.reason), brokenItem)
|
||||
}
|
||||
|
||||
/** Overwrites [dir]'s own worktree index with garbage so `git diff`/`ls-files` fail well after
|
||||
|
||||
+25
-22
@@ -13,12 +13,14 @@ import kotlin.test.assertTrue
|
||||
class PrResolverTest {
|
||||
private val path = "/repo/.kilo/worktrees/feature-x"
|
||||
private val calls = mutableListOf<List<String>>()
|
||||
/** Timeout budget each `gh` call was given, so the short probe budget stays verifiable. */
|
||||
private val budgets = mutableListOf<Int>()
|
||||
|
||||
/** The checkout the command in flight runs in, so a test can answer differently per repository. */
|
||||
private var dir = ""
|
||||
|
||||
@Test
|
||||
fun `resolves through branch config without falling back`() {
|
||||
fun `resolves through the branch selector without falling back`() {
|
||||
val resolver = resolver(view = { pr(7, "OPEN") })
|
||||
|
||||
val lookup = resolver.resolve(path, "feature/x", base = "main")
|
||||
@@ -27,9 +29,11 @@ class PrResolverTest {
|
||||
assertEquals(7, pull.number)
|
||||
assertEquals(path, pull.path)
|
||||
assertEquals(GhState.OPEN, pull.state)
|
||||
// The config-driven form answered, so the branch selector and the search never run. The review
|
||||
// conversations follow, which no `--json` field can answer.
|
||||
assertEquals(listOf(listOf("pr", "view", "--json", PR_RICH_FIELDS), graphql()), calls)
|
||||
// Naming the branch is the first strategy — the selector-less form is the one that has been
|
||||
// seen hanging — so nothing else runs but the review conversations, which no `--json` field
|
||||
// can answer.
|
||||
assertEquals(listOf(listOf("pr", "view", "feature/x", "--json", PR_RICH_FIELDS), graphql()), calls)
|
||||
assertEquals(listOf(GH_READ_TIMEOUT_MS, GH_READ_TIMEOUT_MS), budgets)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -47,10 +51,7 @@ class PrResolverTest {
|
||||
// Without the retry this reads as "no PR here", and the row loses a PR it has always shown.
|
||||
assertEquals(7, assertNotNull(lookup.pr, "the scalar retry must still resolve the PR").number)
|
||||
assertEquals(GhAvailability.OK, lookup.availability)
|
||||
assertEquals(
|
||||
listOf(listOf("pr", "view", "--json", PR_RICH_FIELDS), listOf("pr", "view", "--json", PR_FIELDS), graphql()),
|
||||
calls,
|
||||
)
|
||||
assertEquals(listOf(listOf("pr", "view", "feature/x", "--json", PR_RICH_FIELDS), listOf("pr", "view", "feature/x", "--json", PR_FIELDS), graphql()), calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -109,10 +110,7 @@ class PrResolverTest {
|
||||
|
||||
resolver.resolve(path, "feature/x", base = "main")
|
||||
|
||||
assertEquals(
|
||||
listOf(listOf("pr", "view", "--json", PR_RICH_FIELDS), listOf("pr", "view", "--json", PR_FIELDS), graphql()),
|
||||
calls,
|
||||
)
|
||||
assertEquals(listOf(listOf("pr", "view", "feature/x", "--json", PR_RICH_FIELDS), listOf("pr", "view", "feature/x", "--json", PR_FIELDS), graphql()), calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -130,7 +128,7 @@ class PrResolverTest {
|
||||
|
||||
// The downgrade latches, so the fallback costs one extra call in total rather than one per
|
||||
// checkout on every poll.
|
||||
assertEquals(listOf(listOf("pr", "view", "--json", PR_FIELDS), graphql()), calls)
|
||||
assertEquals(listOf(listOf("pr", "view", "feature/x", "--json", PR_FIELDS), graphql()), calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -144,18 +142,22 @@ class PrResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to the branch selector when config resolves nothing`() {
|
||||
val resolver = resolver(view = { args -> if (args.contains("feature/x")) pr(8, "DRAFT") else missing() })
|
||||
fun `falls back to branch config when the branch selector resolves nothing`() {
|
||||
// A fork PR checked out with `gh pr checkout`: the branch name matches nothing, and only the
|
||||
// selector-less form resolves it through `branch.<name>.merge`.
|
||||
val resolver = resolver(view = { args -> if (args.contains("feature/x")) missing() else pr(8, "DRAFT") })
|
||||
|
||||
val lookup = resolver.resolve(path, "feature/x", base = "main")
|
||||
|
||||
assertEquals(8, assertNotNull(lookup.pr).number)
|
||||
assertEquals(GhState.DRAFT, lookup.pr?.state)
|
||||
assertEquals(
|
||||
listOf(listOf("pr", "view", "--json", PR_RICH_FIELDS), listOf("pr", "view", "feature/x", "--json", PR_RICH_FIELDS), graphql()),
|
||||
listOf(listOf("pr", "view", "feature/x", "--json", PR_RICH_FIELDS), listOf("pr", "view", "--json", PR_RICH_FIELDS), graphql()),
|
||||
calls,
|
||||
"the head search should not run once the branch selector answered",
|
||||
"the head search should not run once branch config answered",
|
||||
)
|
||||
// The hanging form runs on the short probe budget, not the ordinary read budget.
|
||||
assertEquals(listOf(GH_READ_TIMEOUT_MS, GH_PROBE_TIMEOUT_MS, GH_READ_TIMEOUT_MS), budgets)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -253,7 +255,7 @@ class PrResolverTest {
|
||||
resolver.resolve(path, "feature/x", base = "main")
|
||||
|
||||
// The scalar form is refused just as readily, so the field-support fallback must not fire.
|
||||
assertEquals(listOf(listOf("pr", "view", "--json", PR_RICH_FIELDS)), calls)
|
||||
assertEquals(listOf(listOf("pr", "view", "feature/x", "--json", PR_RICH_FIELDS)), calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -280,7 +282,7 @@ class PrResolverTest {
|
||||
val pull = assertNotNull(resolver.resolve(path, "feature/x", base = "main").pr)
|
||||
|
||||
assertEquals(0, pull.comments.unresolved, "for: $state")
|
||||
assertEquals(listOf(listOf("pr", "view", "--json", PR_RICH_FIELDS)), calls, "for: $state")
|
||||
assertEquals(listOf(listOf("pr", "view", "feature/x", "--json", PR_RICH_FIELDS)), calls, "for: $state")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +323,7 @@ class PrResolverTest {
|
||||
assertEquals(GhAvailability.OK, first.availability, "a refusal is not a reason to hold every badge")
|
||||
assertEquals(7, assertNotNull(second.pr).number)
|
||||
// Latched, so a gh that cannot read threads costs one call in total rather than one per poll.
|
||||
assertEquals(listOf(listOf("pr", "view", "--json", PR_RICH_FIELDS)), calls)
|
||||
assertEquals(listOf(listOf("pr", "view", "feature/x", "--json", PR_RICH_FIELDS)), calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -372,7 +374,7 @@ class PrResolverTest {
|
||||
)
|
||||
|
||||
assertEquals(7, assertNotNull(resolver.resolve(path, "feature/x", base = "main").pr).number)
|
||||
assertEquals(listOf(listOf("pr", "view", "--json", PR_RICH_FIELDS)), calls)
|
||||
assertEquals(listOf(listOf("pr", "view", "feature/x", "--json", PR_RICH_FIELDS)), calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -390,9 +392,10 @@ class PrResolverTest {
|
||||
list: (List<String>) -> CmdOut = { ok("[]") },
|
||||
api: (List<String>) -> CmdOut = { threads() },
|
||||
): PrResolver = PrResolver(
|
||||
gh = { at, args ->
|
||||
gh = { at, args, ms ->
|
||||
dir = at.toString()
|
||||
calls.add(args)
|
||||
budgets.add(ms)
|
||||
when {
|
||||
args.firstOrNull() == "api" -> api(args)
|
||||
args.getOrNull(1) == "list" -> list(args)
|
||||
|
||||
+6
@@ -53,6 +53,7 @@ internal class GhBanner(
|
||||
GhAvailability.MISSING -> KiloBundle.message("worktree.gh.missing.content")
|
||||
GhAvailability.UNAUTH -> KiloBundle.message("worktree.gh.unauth.content")
|
||||
GhAvailability.RATE_LIMITED -> KiloBundle.message("worktree.gh.limited.content")
|
||||
GhAvailability.TIMEOUT -> KiloBundle.message("worktree.gh.timeout.content")
|
||||
GhAvailability.OK -> ""
|
||||
})
|
||||
createActionLabel(when (next) {
|
||||
@@ -60,6 +61,7 @@ internal class GhBanner(
|
||||
GhAvailability.MISSING -> KiloBundle.message("worktree.gh.learnMore")
|
||||
GhAvailability.UNAUTH -> KiloBundle.message("worktree.gh.authorize")
|
||||
GhAvailability.RATE_LIMITED -> KiloBundle.message("worktree.gh.learnMore")
|
||||
GhAvailability.TIMEOUT -> KiloBundle.message("worktree.gh.learnMore")
|
||||
GhAvailability.OK -> ""
|
||||
}) { runAction() }
|
||||
if (next == GhAvailability.UNAUTH) {
|
||||
@@ -91,6 +93,10 @@ internal class GhBanner(
|
||||
BrowserUtil.browse(GH_LIMIT_DOCS)
|
||||
return
|
||||
}
|
||||
if (state == GhAvailability.TIMEOUT) {
|
||||
BrowserUtil.browse("https://cli.github.com/")
|
||||
return
|
||||
}
|
||||
if (state == GhAvailability.UNAUTH) runGhAuthLogin(project)
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -399,10 +399,16 @@ class GhStatusCoordinator(
|
||||
GhAvailability.MISSING -> SLOW
|
||||
GhAvailability.GIT_MISSING -> SLOW
|
||||
GhAvailability.RATE_LIMITED -> LIMITED
|
||||
// A gh that does not answer is asked again rarely: each attempt costs a full budget, and the
|
||||
// fast cadence is what turned one hanging command into a permanent stall.
|
||||
GhAvailability.TIMEOUT -> SLOW
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun notify(project: Project?, value: GhAvailability) {
|
||||
// Nothing for the user to do about a slow gh, and a popup per stall would be pure noise. The
|
||||
// banner still explains the degraded state.
|
||||
if (value == GhAvailability.TIMEOUT) return
|
||||
val target = project ?: ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault }
|
||||
if (value == GhAvailability.GIT_MISSING) {
|
||||
KiloNotifications.suggestion(
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package ai.kilocode.client.agentManager.worktree
|
||||
|
||||
import ai.kilocode.rpc.dto.GhAvailability
|
||||
import ai.kilocode.rpc.dto.WorktreeDirtyDto
|
||||
import ai.kilocode.rpc.dto.WorktreeDto
|
||||
import ai.kilocode.rpc.dto.WorktreeStatsDto
|
||||
|
||||
/**
|
||||
* Renders the worktree-health report shown by the Advanced settings action.
|
||||
*
|
||||
* Sections and their order match the VS Code extension's diagnostics command, so a report from
|
||||
* either client reads the same way and can be compared directly in an issue. Pure string building:
|
||||
* the caller collects the data, this only formats it.
|
||||
*/
|
||||
internal object WorktreeDiagnostics {
|
||||
|
||||
/** Everything the report needs, already fetched. */
|
||||
internal data class Input(
|
||||
val root: String,
|
||||
val gh: GhAvailability,
|
||||
val worktrees: List<WorktreeDto>,
|
||||
val orphans: List<String>,
|
||||
val stats: Map<String, WorktreeStatsDto>,
|
||||
val dirty: Map<String, WorktreeDirtyDto>,
|
||||
)
|
||||
|
||||
fun render(input: Input): String {
|
||||
val lines = mutableListOf<String>()
|
||||
lines += "Kilo Agent Manager — worktree health"
|
||||
lines += "repository: ${input.root}"
|
||||
lines += ""
|
||||
|
||||
lines += "tools"
|
||||
lines += " git: ${if (input.gh == GhAvailability.GIT_MISSING) "NOT FOUND" else "ok"}"
|
||||
lines += " gh: ${ghLine(input.gh)}"
|
||||
lines += ""
|
||||
|
||||
// "unavailable" means a poll could not measure the worktree — deliberately not folded into the
|
||||
// ok count, because that is exactly the conflation that made a failed poll look clean.
|
||||
val unavailable = input.worktrees.count { unavailable(input, it) }
|
||||
lines += "summary"
|
||||
lines += " worktrees: ${input.worktrees.size}"
|
||||
lines += " unavailable: $unavailable"
|
||||
lines += " orphan directories: ${input.orphans.size}"
|
||||
lines += ""
|
||||
|
||||
lines += "worktrees"
|
||||
if (input.worktrees.isEmpty()) lines += " (none)"
|
||||
for (item in input.worktrees.sortedBy { !unavailable(input, it) }) {
|
||||
val key = normalizeWorktreePath(item.path)
|
||||
val state = if (unavailable(input, item)) "unavailable" else "ok"
|
||||
val reason = listOfNotNull(
|
||||
input.stats[key]?.reason?.takeIf { it.isNotBlank() },
|
||||
input.dirty[key]?.reason?.takeIf { it.isNotBlank() },
|
||||
).firstOrNull()
|
||||
val detail = buildString {
|
||||
append("branch=${item.branch}")
|
||||
if (item.main) append(" main")
|
||||
if (item.locked) append(" locked")
|
||||
if (item.prunable) append(" prunable")
|
||||
if (reason != null) append(" reason=$reason")
|
||||
}
|
||||
lines += " [$state] ${item.name} — ${item.path} ($detail)"
|
||||
}
|
||||
|
||||
if (input.orphans.isNotEmpty()) {
|
||||
lines += ""
|
||||
lines += "orphan directories (nothing removes these automatically)"
|
||||
for (orphan in input.orphans) lines += " $orphan"
|
||||
}
|
||||
return lines.joinToString("\n")
|
||||
}
|
||||
|
||||
private fun unavailable(input: Input, item: WorktreeDto): Boolean {
|
||||
val key = normalizeWorktreePath(item.path)
|
||||
return input.stats[key]?.unavailable == true || input.dirty[key]?.unavailable == true
|
||||
}
|
||||
|
||||
private fun ghLine(value: GhAvailability): String = when (value) {
|
||||
GhAvailability.OK -> "ok"
|
||||
GhAvailability.MISSING -> "NOT FOUND"
|
||||
GhAvailability.UNAUTH -> "not authorized"
|
||||
GhAvailability.RATE_LIMITED -> "rate limited"
|
||||
GhAvailability.TIMEOUT -> "did not answer within its budget"
|
||||
GhAvailability.GIT_MISSING -> "not probed (git missing)"
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package ai.kilocode.client.agentManager.worktree
|
||||
|
||||
import ai.kilocode.client.app.KiloAppService
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.app.kiloRoot
|
||||
import com.intellij.notification.Notification
|
||||
import com.intellij.notification.NotificationGroupManager
|
||||
import com.intellij.notification.NotificationType
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.ide.CopyPasteManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.datatransfer.StringSelection
|
||||
|
||||
/**
|
||||
* Collects the worktree-health report and puts it on the clipboard.
|
||||
*
|
||||
* The previous way to answer "why is Agent Manager not showing anything?" was reading kilo.log and
|
||||
* inferring intent from per-poll failures. This gathers the same facts the VS Code diagnostics
|
||||
* command reports — tool availability, per-worktree status with failure reasons, leftover
|
||||
* directories — in one paste-able block.
|
||||
*/
|
||||
internal object WorktreeDiagnosticsAction {
|
||||
|
||||
fun copy() {
|
||||
val project = ProjectManager.getInstance().openProjects.firstOrNull { !it.isDefault } ?: run {
|
||||
notify(null, NotificationType.WARNING, KiloBundle.message("worktree.diagnostics.noProject"))
|
||||
return
|
||||
}
|
||||
val app = service<KiloAppService>()
|
||||
app.scope.launch {
|
||||
val text = runCatching { collect(project) }.getOrElse { err ->
|
||||
"Kilo Agent Manager — worktree health\nfailed to collect: ${err.message}"
|
||||
}
|
||||
CopyPasteManager.getInstance().setContents(StringSelection(text))
|
||||
notify(project, NotificationType.INFORMATION, KiloBundle.message("worktree.diagnostics.copied"))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun collect(project: Project): String {
|
||||
val root = project.kiloRoot() ?: return "Kilo Agent Manager — worktree health\nno backend root resolved"
|
||||
val service = service<KiloWorktreeService>()
|
||||
val listed = service.list(root)
|
||||
return WorktreeDiagnostics.render(
|
||||
WorktreeDiagnostics.Input(
|
||||
root = root,
|
||||
gh = service.ghStatus(root),
|
||||
worktrees = listed.worktrees,
|
||||
orphans = listed.orphans,
|
||||
stats = service.stats(root).items.associateBy { normalizeWorktreePath(it.path) },
|
||||
dirty = service.dirty(root).items.associateBy { normalizeWorktreePath(it.path) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun notify(project: Project?, type: NotificationType, title: String) {
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
val notification = NotificationGroupManager.getInstance()
|
||||
.getNotificationGroup("Kilo Code")
|
||||
?.createNotification(title, "", type)
|
||||
?: Notification("Kilo Code", title, "", type)
|
||||
notification.notify(project)
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-6
@@ -48,6 +48,9 @@ class WorktreeStatusService internal constructor(
|
||||
private var statsTimer: UiTimer? = null
|
||||
private var prTimer: UiTimer? = null
|
||||
private var prJob: Job? = null
|
||||
/** In-flight stats/dirty polls, so a slow repository cannot stack fan-outs. See [loadStats]. */
|
||||
private var statsJob: Job? = null
|
||||
private var dirtyJob: Job? = null
|
||||
/** Trailing lookup for a return held back by the spend floor. See [hold]. */
|
||||
private var trail: UiTimer? = null
|
||||
/** Freshness ceiling the held return is waiting to spend, or null when none is held. */
|
||||
@@ -278,11 +281,24 @@ class WorktreeStatusService internal constructor(
|
||||
refreshPr(force = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* One stats poll at a time.
|
||||
*
|
||||
* The poll interval and the git watchdog used to be close enough that a slow repository could
|
||||
* have several polls in flight at once, each fanning out git processes — which is how a poll ends
|
||||
* up blaming git for a queue the client created.
|
||||
*/
|
||||
private fun loadStats() {
|
||||
cs.launch {
|
||||
if (statsJob?.isActive == true) {
|
||||
LOG.info("worktree stats refresh skipped, poll in flight")
|
||||
return
|
||||
}
|
||||
statsJob = cs.launch {
|
||||
val dir = project.kiloRoot() ?: return@launch
|
||||
runCatching { service<KiloWorktreeService>().stats(dir) }
|
||||
.onSuccess { dto -> statsFlow.value = dto.items.associateBy { normalizeWorktreePath(it.path) } }
|
||||
.onSuccess { dto ->
|
||||
statsFlow.value = merge(statsFlow.value, dto.items, { it.path }, { it.unavailable })
|
||||
}
|
||||
.onFailure { err -> LOG.warn("worktree stats refresh failed dir=$dir (previous values kept)", err) }
|
||||
}
|
||||
}
|
||||
@@ -291,10 +307,16 @@ class WorktreeStatusService internal constructor(
|
||||
// synthetic JetBrains Client path in split/remote mode. Pointing the backend at that path makes
|
||||
// dirty() answer for a directory that does not exist, which reads as "no local changes".
|
||||
private fun loadDirty() {
|
||||
cs.launch {
|
||||
if (dirtyJob?.isActive == true) {
|
||||
LOG.info("worktree dirty refresh skipped, poll in flight")
|
||||
return
|
||||
}
|
||||
dirtyJob = cs.launch {
|
||||
val dir = project.kiloRoot() ?: return@launch
|
||||
runCatching { service<KiloWorktreeService>().dirty(dir) }
|
||||
.onSuccess { dto -> dirtyFlow.value = dto.items.associateBy { normalizeWorktreePath(it.path) } }
|
||||
.onSuccess { dto ->
|
||||
dirtyFlow.value = merge(dirtyFlow.value, dto.items, { it.path }, { it.unavailable })
|
||||
}
|
||||
.onFailure { err -> LOG.warn("worktree dirty refresh failed dir=$dir (previous values kept)", err) }
|
||||
}
|
||||
}
|
||||
@@ -315,8 +337,9 @@ class WorktreeStatusService internal constructor(
|
||||
// A spent GitHub budget carries no pull request data and says nothing about the
|
||||
// pull requests themselves, so the rows keep what they had and the banner explains
|
||||
// why it stopped moving. Publishing the empty list would instead blank every badge
|
||||
// for up to an hour over something the user cannot act on.
|
||||
if (dto.availability != GhAvailability.RATE_LIMITED) {
|
||||
// for up to an hour over something the user cannot act on. A gh that timed out is
|
||||
// in exactly the same position: it answered nothing about these pull requests.
|
||||
if (dto.availability != GhAvailability.RATE_LIMITED && dto.availability != GhAvailability.TIMEOUT) {
|
||||
prFlow.value = dto.items.associateBy { normalizeWorktreePath(it.path) }
|
||||
}
|
||||
ghFlow.value = dto.availability
|
||||
@@ -330,4 +353,27 @@ class WorktreeStatusService internal constructor(
|
||||
// superseded generation means a newer lookup already owns the loop and will drain it instead.
|
||||
job.invokeOnCompletion { edt { if (gen == generation) spend() } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a poll result over the previous one, keeping the previous entry wherever the backend
|
||||
* could not measure.
|
||||
*
|
||||
* An unavailable row carries zeros, and publishing those would render a failed poll as a clean
|
||||
* worktree — a badge silently disappearing is worse than a badge being briefly stale. Rows the
|
||||
* backend stopped reporting altogether are dropped: those are gone, not unmeasured.
|
||||
*/
|
||||
private fun <T> merge(
|
||||
previous: Map<String, T>,
|
||||
items: List<T>,
|
||||
key: (T) -> String,
|
||||
stale: (T) -> Boolean,
|
||||
): Map<String, T> {
|
||||
val next = LinkedHashMap<String, T>(items.size)
|
||||
for (item in items) {
|
||||
val id = normalizeWorktreePath(key(item))
|
||||
val kept = previous[id]
|
||||
next[id] = if (stale(item) && kept != null) kept else item
|
||||
}
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
+17
-5
@@ -1,5 +1,6 @@
|
||||
package ai.kilocode.client.settings
|
||||
|
||||
import ai.kilocode.client.agentManager.worktree.WorktreeDiagnosticsAction
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.settings.base.SettingsRow
|
||||
import ai.kilocode.client.settings.base.SettingsRows
|
||||
@@ -121,17 +122,28 @@ internal class AdvancedSettingsUi : JPanel(BorderLayout()) {
|
||||
|
||||
private fun count(): Int? = preview.text.trim().toIntOrNull()
|
||||
|
||||
/** Worktree health in one paste-able block, rather than inferred from the log. */
|
||||
private fun diagnosticsRow(): SettingsRow = SettingsRow(
|
||||
KiloBundle.message("worktree.diagnostics.title"),
|
||||
KiloBundle.message("worktree.diagnostics.description"),
|
||||
ActionLink(KiloBundle.message("worktree.diagnostics.copy")) { WorktreeDiagnosticsAction.copy() },
|
||||
)
|
||||
|
||||
// In monolith mode one reveal opens the shared log; in split mode the client log is revealed
|
||||
// locally and the remote backend log is downloaded.
|
||||
private fun logRows(): List<SettingsRow> {
|
||||
if (IdeProductMode.isMonolith) {
|
||||
return listOf(SettingsRow(
|
||||
KiloBundle.message("settings.advanced.logs.title"),
|
||||
KiloBundle.message("settings.advanced.logs.description"),
|
||||
ActionLink(AdvancedLogActions.revealLabel()) { AdvancedLogActions.reveal() },
|
||||
))
|
||||
return listOf(
|
||||
SettingsRow(
|
||||
KiloBundle.message("settings.advanced.logs.title"),
|
||||
KiloBundle.message("settings.advanced.logs.description"),
|
||||
ActionLink(AdvancedLogActions.revealLabel()) { AdvancedLogActions.reveal() },
|
||||
),
|
||||
diagnosticsRow(),
|
||||
)
|
||||
}
|
||||
return listOf(
|
||||
diagnosticsRow(),
|
||||
SettingsRow(
|
||||
KiloBundle.message("settings.advanced.logs.client.title"),
|
||||
KiloBundle.message("settings.advanced.logs.client.description"),
|
||||
|
||||
@@ -521,6 +521,14 @@ worktree.gh.unauth.title=GitHub CLI not authorized
|
||||
worktree.gh.unauth.content=Authorize gh to show pull request badges for worktrees.
|
||||
worktree.gh.limited.title=GitHub API limit reached
|
||||
worktree.gh.limited.content=GitHub is rate limiting this token, so pull request badges may be out of date. Kilo retries automatically.
|
||||
worktree.gh.timeout.title=GitHub CLI is not responding
|
||||
worktree.gh.timeout.content=gh did not answer in time, so pull request badges may be out of date. Kilo retries less often until it does.
|
||||
worktree.stats.unavailable=Status unavailable: git did not answer for this worktree. Showing the last known values.
|
||||
worktree.diagnostics.title=Worktree diagnostics
|
||||
worktree.diagnostics.description=Copy a report of git/gh availability, worktree status, and leftover folders.
|
||||
worktree.diagnostics.copy=Copy report
|
||||
worktree.diagnostics.copied=Worktree diagnostics copied to the clipboard
|
||||
worktree.diagnostics.noProject=Open a project to collect worktree diagnostics
|
||||
worktree.gh.authorize=Authorize
|
||||
worktree.gh.learnMore=Learn more
|
||||
worktree.gh.disable=Turn off GitHub integration
|
||||
|
||||
+18
@@ -86,6 +86,24 @@ class GhBannerTest : BasePlatformTestCase() {
|
||||
assertNotNull(edt { links(banner).singleOrNull { it.text == "Turn off GitHub integration" } })
|
||||
}
|
||||
|
||||
fun `test banner explains a gh that did not answer in time`() {
|
||||
edt { service.report(project, GhAvailability.TIMEOUT) }
|
||||
pump()
|
||||
|
||||
val banner = edt { GhBanner(project, testRootDisposable) }
|
||||
|
||||
// Same shape as a spent budget: the badges are stale, not wrong, and there is nothing to
|
||||
// authorize or install — so the banner explains the degradation instead of blaming the user.
|
||||
assertTrue(edt { banner.isVisible })
|
||||
assertEquals(
|
||||
"gh did not answer in time, so pull request badges may be out of date. " +
|
||||
"Kilo retries less often until it does.",
|
||||
edt { banner.text },
|
||||
)
|
||||
assertTrue(edt { links(banner).none { it.text == "Authorize" } })
|
||||
assertNotNull(edt { links(banner).singleOrNull { it.text == "Turn off GitHub integration" } })
|
||||
}
|
||||
|
||||
fun `test banner hides immediately when coordinator reports ok`() {
|
||||
rpc.ghResult = GhAvailability.UNAUTH
|
||||
val banner = edt { GhBanner(project, testRootDisposable) }
|
||||
|
||||
+22
@@ -110,6 +110,28 @@ class GhStatusCoordinatorTest : BasePlatformTestCase() {
|
||||
handle.close()
|
||||
}
|
||||
|
||||
fun `test coordinator slows down for a gh that does not answer`() {
|
||||
// A timeout used to be classified OK, which reset the failure count and kept the loop paying a
|
||||
// full budget per poll for a command that never returns.
|
||||
rpc.ghResult = GhAvailability.TIMEOUT
|
||||
val handle = edtWait { service.attach(project) }
|
||||
drain()
|
||||
assertEquals(GhAvailability.TIMEOUT, service.current())
|
||||
assertEquals(1, rpc.ghCalls.size)
|
||||
|
||||
// SLOW, not the 30s OK cadence.
|
||||
timers.advanceBy(59_999)
|
||||
drain()
|
||||
assertEquals("a gh that timed out must not be re-probed on the ok cadence", 1, rpc.ghCalls.size)
|
||||
|
||||
rpc.ghResult = GhAvailability.OK
|
||||
timers.advanceBy(1)
|
||||
drain()
|
||||
assertEquals(2, rpc.ghCalls.size)
|
||||
assertEquals(GhAvailability.OK, service.current())
|
||||
handle.close()
|
||||
}
|
||||
|
||||
fun `test coordinator backs off on backend failure without reporting ok`() {
|
||||
rpc.ghResult = GhAvailability.UNAUTH
|
||||
val handle = edtWait { service.attach(project) }
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package ai.kilocode.client.agentManager.worktree
|
||||
|
||||
import ai.kilocode.rpc.dto.GhAvailability
|
||||
import ai.kilocode.rpc.dto.WorktreeDirtyDto
|
||||
import ai.kilocode.rpc.dto.WorktreeDto
|
||||
import ai.kilocode.rpc.dto.WorktreeStatsDto
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertFalse
|
||||
|
||||
/**
|
||||
* The report is what a user pastes into an issue, so its facts are asserted directly. Sections mirror
|
||||
* the VS Code diagnostics command so both clients can be read the same way.
|
||||
*/
|
||||
class WorktreeDiagnosticsTest {
|
||||
|
||||
@Test
|
||||
fun `reports tool availability, per-worktree status, and leftovers`() {
|
||||
val healthy = "/repo/.kilo/worktrees/alive"
|
||||
val broken = "/repo/.kilo/worktrees/broken"
|
||||
|
||||
val text = WorktreeDiagnostics.render(
|
||||
WorktreeDiagnostics.Input(
|
||||
root = "/repo",
|
||||
gh = GhAvailability.TIMEOUT,
|
||||
worktrees = listOf(
|
||||
WorktreeDto(healthy, "alive", "feature/alive", healthy),
|
||||
WorktreeDto(broken, "broken", "feature/broken", broken),
|
||||
),
|
||||
orphans = listOf("/repo/.kilo/worktrees/leftover"),
|
||||
stats = mapOf(normalizeWorktreePath(healthy) to WorktreeStatsDto(healthy, additions = 3)),
|
||||
dirty = mapOf(
|
||||
normalizeWorktreePath(broken) to
|
||||
WorktreeDirtyDto(broken, unavailable = true, reason = "Git command timed out"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertContains(text, "repository: /repo")
|
||||
assertContains(text, "git: ok")
|
||||
assertContains(text, "gh: did not answer within its budget")
|
||||
assertContains(text, "worktrees: 2")
|
||||
assertContains(text, "unavailable: 1")
|
||||
assertContains(text, "orphan directories: 1")
|
||||
assertContains(text, "[ok] alive — $healthy (branch=feature/alive)")
|
||||
assertContains(text, "[unavailable] broken — $broken (branch=feature/broken reason=Git command timed out)")
|
||||
assertContains(text, " /repo/.kilo/worktrees/leftover")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `names a missing git rather than blaming the worktrees`() {
|
||||
val text = WorktreeDiagnostics.render(
|
||||
WorktreeDiagnostics.Input(
|
||||
root = "/repo",
|
||||
gh = GhAvailability.GIT_MISSING,
|
||||
worktrees = emptyList(),
|
||||
orphans = emptyList(),
|
||||
stats = emptyMap(),
|
||||
dirty = emptyMap(),
|
||||
),
|
||||
)
|
||||
|
||||
assertContains(text, "git: NOT FOUND")
|
||||
assertContains(text, "gh: not probed (git missing)")
|
||||
assertContains(text, "(none)")
|
||||
// Nothing to clean up, so the section must not appear at all.
|
||||
assertFalse(text.contains("orphan directories (nothing removes"))
|
||||
}
|
||||
}
|
||||
+72
@@ -87,6 +87,78 @@ class WorktreeStatusServiceTest : BasePlatformTestCase() {
|
||||
handle.close()
|
||||
}
|
||||
|
||||
fun `test an unavailable poll keeps the previous counts instead of publishing zeros`() {
|
||||
val path = "${project.basePath}/.kilo/worktrees/feature-x"
|
||||
val key = normalizeWorktreePath(path)
|
||||
rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(path, additions = 4, files = 2)))
|
||||
rpc.dirtyResult = WorktreeDirtyListDto(listOf(WorktreeDirtyDto(path, additions = 2, files = 1)))
|
||||
val handle = service.attach()
|
||||
timers.advanceBy(300)
|
||||
drain()
|
||||
assertEquals(4, service.stats.value[key]?.additions)
|
||||
|
||||
// A failed measurement carries zeros. Publishing them would render as a clean worktree and
|
||||
// silently drop the badges the row was showing.
|
||||
rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(path, unavailable = true, reason = "timed out")))
|
||||
rpc.dirtyResult = WorktreeDirtyListDto(listOf(WorktreeDirtyDto(path, unavailable = true, reason = "timed out")))
|
||||
service.refreshStats()
|
||||
timers.advanceBy(300)
|
||||
drain()
|
||||
|
||||
assertEquals(4, service.stats.value[key]?.additions)
|
||||
assertEquals(2, service.stats.value[key]?.files)
|
||||
assertEquals(2, service.dirty.value[key]?.additions)
|
||||
handle.close()
|
||||
}
|
||||
|
||||
fun `test a worktree the backend stops reporting is dropped`() {
|
||||
val path = "${project.basePath}/.kilo/worktrees/feature-x"
|
||||
val key = normalizeWorktreePath(path)
|
||||
rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(path, additions = 4)))
|
||||
val handle = service.attach()
|
||||
timers.advanceBy(300)
|
||||
drain()
|
||||
assertEquals(4, service.stats.value[key]?.additions)
|
||||
|
||||
// Gone from the list entirely is different from unmeasured: the worktree no longer exists.
|
||||
rpc.statsResult = WorktreeStatsListDto(emptyList())
|
||||
service.refreshStats()
|
||||
timers.advanceBy(300)
|
||||
drain()
|
||||
|
||||
assertNull(service.stats.value[key])
|
||||
handle.close()
|
||||
}
|
||||
|
||||
fun `test a stats poll does not stack while one is in flight`() {
|
||||
val path = "${project.basePath}/.kilo/worktrees/feature-x"
|
||||
rpc.statsResult = WorktreeStatsListDto(listOf(WorktreeStatsDto(path, additions = 1)))
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
rpc.beforeStats = { gate.await() }
|
||||
val handle = service.attach()
|
||||
timers.advanceBy(300)
|
||||
drain()
|
||||
assertEquals(1, rpc.statsCalls.size)
|
||||
|
||||
// Every extra request while the first is unanswered would fan out another set of git
|
||||
// processes — the queue that made even `git --version` time out.
|
||||
service.refreshStats()
|
||||
timers.advanceBy(300)
|
||||
drain()
|
||||
service.refreshStats()
|
||||
timers.advanceBy(300)
|
||||
drain()
|
||||
assertEquals("a poll in flight must not be joined by another", 1, rpc.statsCalls.size)
|
||||
|
||||
gate.complete(Unit)
|
||||
drain()
|
||||
service.refreshStats()
|
||||
timers.advanceBy(300)
|
||||
drain()
|
||||
assertEquals(2, rpc.statsCalls.size)
|
||||
handle.close()
|
||||
}
|
||||
|
||||
fun `test refresh is ignored after the last handle closes`() {
|
||||
val path = "${project.basePath}/.kilo/worktrees/feature-x"
|
||||
val key = normalizeWorktreePath(path)
|
||||
|
||||
+3
@@ -67,6 +67,8 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
|
||||
var beforeRemove: suspend () -> Unit = {}
|
||||
var beforeRename: suspend () -> Unit = {}
|
||||
var beforeGhStatus: suspend () -> Unit = {}
|
||||
/** Gate for holding a [stats] answer open, so a test can prove polls do not stack. */
|
||||
var beforeStats: suspend () -> Unit = {}
|
||||
/** Gate for holding a [prStatus] answer open while the test changes state around it. */
|
||||
var beforePrStatus: suspend () -> Unit = {}
|
||||
var adoptResult: (String, String) -> RenameWorktreeResultDto = { path, name ->
|
||||
@@ -103,6 +105,7 @@ class FakeWorktreeRpcApi : KiloWorktreeRpcApi {
|
||||
override suspend fun stats(directory: String): WorktreeStatsListDto {
|
||||
assertNotEdt("stats")
|
||||
statsCalls.add(directory)
|
||||
beforeStats()
|
||||
return statsResult
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,15 @@ data class WorktreeDto(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WorktreeListDto(val worktrees: List<WorktreeDto> = emptyList())
|
||||
data class WorktreeListDto(
|
||||
val worktrees: List<WorktreeDto> = emptyList(),
|
||||
/**
|
||||
* Directories under `.kilo/worktrees/` that git does not track — leftovers from interrupted
|
||||
* deletes or hand-removed metadata. Reported so they can be surfaced and cleaned deliberately;
|
||||
* nothing removes them automatically.
|
||||
*/
|
||||
val orphans: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class WorktreeStatsDto(
|
||||
@@ -28,6 +36,15 @@ data class WorktreeStatsDto(
|
||||
val files: Int = 0,
|
||||
/** Resolved base ref the counts are relative to, e.g. `origin/main`. Empty when unresolved. */
|
||||
val base: String = "",
|
||||
/**
|
||||
* True when the counts could not be measured, so they mean "unknown" rather than "zero".
|
||||
*
|
||||
* Without this a timed-out or failed poll is indistinguishable from a clean worktree, and the UI
|
||||
* quietly drops the badges it was showing a moment earlier.
|
||||
*/
|
||||
val unavailable: Boolean = false,
|
||||
/** Why the poll failed, for logs and tooltips. Empty when [unavailable] is false. */
|
||||
val reason: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -47,6 +64,10 @@ data class WorktreeDirtyDto(
|
||||
val untracked: Int = 0,
|
||||
/** Commits ahead of `@{upstream}`. 0 when the branch has no upstream. */
|
||||
val unpushed: Int = 0,
|
||||
/** True when the counts could not be measured; see [WorktreeStatsDto.unavailable]. */
|
||||
val unavailable: Boolean = false,
|
||||
/** Why the poll failed, for logs and tooltips. Empty when [unavailable] is false. */
|
||||
val reason: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -136,7 +157,23 @@ data class WorktreePrDto(
|
||||
* its whole strategy ladder.
|
||||
*/
|
||||
@Serializable
|
||||
enum class GhAvailability { OK, MISSING, UNAUTH, GIT_MISSING, RATE_LIMITED }
|
||||
enum class GhAvailability {
|
||||
OK,
|
||||
MISSING,
|
||||
UNAUTH,
|
||||
GIT_MISSING,
|
||||
RATE_LIMITED,
|
||||
|
||||
/**
|
||||
* `gh` ran but did not answer within its budget.
|
||||
*
|
||||
* Distinct from [OK] because a timeout used to be reported as success, which reset the probe's
|
||||
* failure counter and defeated its own backoff — the loop kept spending a full timeout per poll
|
||||
* on a command that never returns. Distinct from [MISSING] because `gh` is installed and may
|
||||
* answer the next time.
|
||||
*/
|
||||
TIMEOUT,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class WorktreePrListDto(
|
||||
|
||||
@@ -261,6 +261,11 @@
|
||||
"title": "Agent Manager: Next Terminal",
|
||||
"category": "Kilo Code"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.agentManager.diagnostics",
|
||||
"title": "Agent Manager: Show Worktree Diagnostics",
|
||||
"category": "Kilo Code"
|
||||
},
|
||||
{
|
||||
"command": "kilo-code.new.agentManager.search",
|
||||
"title": "Agent Manager: Search Worktrees and Sessions",
|
||||
|
||||
@@ -54,7 +54,14 @@ import {
|
||||
type CreateWorktreeOnDiskOptions,
|
||||
type CreateWorktreeOnDiskResult,
|
||||
} from "./worktree-create"
|
||||
import { initContextState, pushProjectSessions, reactivateProject, registerProjectSessions } from "./project/init"
|
||||
import {
|
||||
healthMetrics,
|
||||
initContextState,
|
||||
pushProjectSessions,
|
||||
reactivateProject,
|
||||
reconcileProject,
|
||||
registerProjectSessions,
|
||||
} from "./project/init"
|
||||
import { createLocalDiff } from "./local-diff"
|
||||
import { parseToolRequest, startFromTool, type ToolRequest } from "./tool-start"
|
||||
import { handleToolEvent } from "./tool-project"
|
||||
@@ -83,6 +90,10 @@ import { resolveWorktreeFile } from "./worktree-file-path"
|
||||
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
|
||||
import type { Host, PanelContext, OutputHandle, Disposable } from "./host"
|
||||
import { focusPanelPrompt, revealPanel } from "./focus-panel"
|
||||
import { formatLog } from "./log-format"
|
||||
import { HealthScheduler, applyPresence, healthPayload, staleForState } from "./worktree-health"
|
||||
import { handleRecovery, type RecoveryMessage } from "./worktree-recovery"
|
||||
import { runDoctor } from "./worktree-doctor"
|
||||
import type { BrowserBroker } from "../services/browser-automation"
|
||||
import { createBrowserLifecycle } from "./browser-lifecycle"
|
||||
import { handleSessionLifecycle } from "./session-lifecycle"
|
||||
@@ -118,6 +129,10 @@ export class AgentManagerProvider implements Disposable {
|
||||
private unsubProjects: (() => void) | undefined
|
||||
/** Scratch set returned when no active context exists; mutations are discarded. */
|
||||
private readonly staleScratch = new Set<string>()
|
||||
private readonly healthScheduler = new HealthScheduler<ProjectContext>(async (ctx) => {
|
||||
await reconcileProject(ctx, (...args: unknown[]) => this.log(...args))
|
||||
if (this.contexts.active()?.id === ctx.id) this.pushState(ctx)
|
||||
})
|
||||
private unsubDestination: (() => void) | undefined
|
||||
private destination = new DestinationState()
|
||||
private closing: Promise<void> | undefined
|
||||
@@ -268,6 +283,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
return ids
|
||||
},
|
||||
isUnhealthy: (id) => this.context?.report?.entries.some((e) => e.id === id && e.health !== "ok") === true,
|
||||
visible: () => this.panel?.visible ?? false,
|
||||
post: (msg) => this.postToWebview(msg),
|
||||
cache: (msg) => {
|
||||
@@ -342,8 +358,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.naming.busy(sid)
|
||||
}
|
||||
private log(...args: unknown[]) {
|
||||
const msg = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")
|
||||
this.outputChannel.appendLine(`${new Date().toISOString()} ${msg}`)
|
||||
this.outputChannel.appendLine(`${new Date().toISOString()} ${formatLog(args)}`)
|
||||
}
|
||||
public openPanel(preserveFocus?: boolean): void {
|
||||
if (this.panel) {
|
||||
@@ -453,6 +468,8 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.pushState()
|
||||
return
|
||||
}
|
||||
// Counts only — no paths, no branch names.
|
||||
if (init.health) this.host.capture("Agent Manager Worktree Health", healthMetrics(init.health))
|
||||
// When the .kilocode → .kilo migration rewrote git worktree refs, nudge
|
||||
// VS Code's git extension to re-discover them and avoid stale Source Control.
|
||||
if (init.refsFixed > 0) {
|
||||
@@ -583,7 +600,9 @@ export class AgentManagerProvider implements Disposable {
|
||||
private async onWorktreeMessage(m: AgentManagerInMessage): Promise<Record<string, unknown> | null | undefined> {
|
||||
if (m.type === "agentManager.createWorktree") return this.onCreateWorktree(m.baseBranch, m.branchName)
|
||||
if (m.type === "agentManager.deleteWorktree") return this.onDeleteWorktree(m.worktreeId)
|
||||
if (m.type === "agentManager.removeStaleWorktree") return this.onRemoveStaleWorktree(m.worktreeId)
|
||||
if (m.type === "agentManager.removeStaleWorktree") return this.onRemoveStaleWorktree(m)
|
||||
if (m.type === "agentManager.restoreWorktree") return this.recover(m)
|
||||
if (m.type === "agentManager.cleanOrphanDirectories") return this.recover(m)
|
||||
if (m.type === "agentManager.promoteSession") return this.onPromoteSession(m.sessionId)
|
||||
if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId, m.sessionId)
|
||||
if (m.type === "agentManager.forkSession") return this.onForkSession(m.sessionId, m.worktreeId, m.messageId)
|
||||
@@ -1144,10 +1163,19 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
|
||||
/** Remove a stale worktree entry from state without touching the filesystem. */
|
||||
private async onRemoveStaleWorktree(worktreeId: string): Promise<null> {
|
||||
private async onRemoveStaleWorktree(m: { worktreeId: string; keepSessions?: boolean }): Promise<null> {
|
||||
const ctx = this.context
|
||||
if (!ctx) return null
|
||||
return removeStaleLifecycleWorktree(ctx, this.lifecycleHost, worktreeId)
|
||||
return removeStaleLifecycleWorktree(ctx, this.lifecycleHost, m.worktreeId, m.keepSessions === true)
|
||||
}
|
||||
|
||||
private recover(m: RecoveryMessage): Promise<null> {
|
||||
return handleRecovery(m, this.context, {
|
||||
post: (message) => this.postToWebview(message),
|
||||
push: () => this.pushState(),
|
||||
log: (...args) => this.log(...args),
|
||||
reconcile: (ctx) => reconcileProject(ctx, (...args: unknown[]) => this.log(...args)),
|
||||
})
|
||||
}
|
||||
|
||||
/** Promote a session: create a worktree and move the session into it. */
|
||||
@@ -1321,54 +1349,12 @@ export class AgentManagerProvider implements Disposable {
|
||||
private onWorktreePresence(result: WorktreePresenceResult): void {
|
||||
const state = this.state
|
||||
if (!state) return
|
||||
|
||||
const worktrees = state.getWorktrees()
|
||||
const ids = new Set(worktrees.map((wt) => wt.id))
|
||||
this.pruneStaleWorktreeIds(ids)
|
||||
|
||||
if (result.degraded) {
|
||||
this.log("Skipping stale worktree update: degraded worktree probe")
|
||||
return
|
||||
}
|
||||
|
||||
const entries = result.worktrees.filter((item) => ids.has(item.worktreeId))
|
||||
if (entries.length === 0) return
|
||||
|
||||
// Sync branches from git worktree list (no extra git calls)
|
||||
let branchChanged = false
|
||||
for (const entry of entries) {
|
||||
if (entry.branch && state.updateWorktreeBranch(entry.worktreeId, entry.branch)) {
|
||||
branchChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
const next = new Set(entries.filter((entry) => entry.missing).map((entry) => entry.worktreeId))
|
||||
const staleChanged =
|
||||
next.size !== this.staleWorktreeIds.size || [...next].some((worktreeId) => !this.staleWorktreeIds.has(worktreeId))
|
||||
const stale = this.staleWorktreeIds
|
||||
stale.clear()
|
||||
for (const id of next) stale.add(id)
|
||||
|
||||
if (staleChanged || branchChanged) {
|
||||
this.pushState()
|
||||
}
|
||||
}
|
||||
|
||||
private clearStaleTracking(worktreeId: string): void {
|
||||
this.staleWorktreeIds.delete(worktreeId)
|
||||
}
|
||||
|
||||
private staleWorktreesForState(worktrees: ReturnType<WorktreeStateManager["getWorktrees"]>): string[] {
|
||||
const ids = new Set(worktrees.map((wt) => wt.id))
|
||||
this.pruneStaleWorktreeIds(ids)
|
||||
return worktrees.filter((wt) => this.staleWorktreeIds.has(wt.id)).map((wt) => wt.id)
|
||||
}
|
||||
|
||||
private pruneStaleWorktreeIds(ids: Set<string>): void {
|
||||
for (const id of [...this.staleWorktreeIds]) {
|
||||
if (ids.has(id)) continue
|
||||
this.staleWorktreeIds.delete(id)
|
||||
}
|
||||
const sync = (id: string, branch: string) => state.updateWorktreeBranch(id, branch)
|
||||
const applied = applyPresence(result, this.staleWorktreeIds, state.getWorktrees(), sync)
|
||||
if (applied.degraded) return this.log("Skipping stale worktree update: degraded worktree probe")
|
||||
if (applied.staleChanged || applied.branchChanged) this.pushState()
|
||||
// A worktree that just became unhealthy needs a reconcile to say why and to self-heal.
|
||||
if (applied.staleChanged && this.context) this.healthScheduler.schedule(this.context)
|
||||
}
|
||||
|
||||
/** Sync the poller's skip set with currently collapsed sections. */
|
||||
@@ -1402,7 +1388,8 @@ export class AgentManagerProvider implements Disposable {
|
||||
worktrees,
|
||||
sessions: state.getSessions(),
|
||||
sections: state.getSections(),
|
||||
staleWorktreeIds: active ? this.staleWorktreesForState(worktrees) : [],
|
||||
staleWorktreeIds: active ? staleForState(this.staleWorktreeIds, worktrees) : [],
|
||||
...healthPayload(target.report, worktrees),
|
||||
tabOrder: state.getTabOrder(),
|
||||
worktreeOrder: state.getWorktreeOrder(),
|
||||
sessionsCollapsed: state.getSessionsCollapsed(),
|
||||
@@ -1846,6 +1833,19 @@ export class AgentManagerProvider implements Disposable {
|
||||
(...args) => this.log(...args),
|
||||
)
|
||||
}
|
||||
/** Show the worktree-health diagnostics report for the active project. */
|
||||
public diagnose(): Promise<void> {
|
||||
return runDoctor(this.context, {
|
||||
reconcile: (ctx) => reconcileProject(ctx, (...args: unknown[]) => this.log(...args)),
|
||||
quarantined: () => this.prBridge.poller.paused(),
|
||||
show: async (text) => {
|
||||
this.outputChannel.appendLine(text)
|
||||
this.outputChannel.show?.()
|
||||
},
|
||||
log: (...args) => this.log(...args),
|
||||
})
|
||||
}
|
||||
|
||||
public postMessage(message: unknown): void {
|
||||
this.panel?.postMessage(message)
|
||||
}
|
||||
@@ -1881,6 +1881,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.diffs.stop()
|
||||
this.diffCatalog.dispose()
|
||||
this.naming.dispose()
|
||||
this.healthScheduler.dispose()
|
||||
this.statsPoller.stop()
|
||||
this.projectPollers.dispose()
|
||||
this.gitOps.dispose()
|
||||
|
||||
@@ -34,6 +34,12 @@ export interface LocalStats {
|
||||
export interface WorktreePresence {
|
||||
worktreeId: string
|
||||
missing: boolean
|
||||
/**
|
||||
* Why the worktree is missing, so the UI can say something true. `absent` means the directory is
|
||||
* gone; `unregistered` means it is still on disk but git no longer tracks it, which is a different
|
||||
* problem with a different fix.
|
||||
*/
|
||||
reason?: "absent" | "unregistered"
|
||||
/** Current branch from `git worktree list`, if available. */
|
||||
branch?: string
|
||||
}
|
||||
@@ -59,6 +65,8 @@ interface GitStatsPollerOptions {
|
||||
semaphore?: Semaphore
|
||||
hiddenIntervalMs?: number
|
||||
dormantIntervalMs?: number
|
||||
/** True for worktrees the health reconcile says cannot answer; they are skipped, not measured. */
|
||||
isUnhealthy?: (worktreeId: string) => boolean
|
||||
}
|
||||
|
||||
export class GitStatsPoller {
|
||||
@@ -208,7 +216,7 @@ export class GitStatsPoller {
|
||||
const missing = new Set(
|
||||
presence.degraded ? [] : presence.worktrees.filter((item) => item.missing).map((item) => item.worktreeId),
|
||||
)
|
||||
const available = worktrees.filter((wt) => !missing.has(wt.id))
|
||||
const available = worktrees.filter((wt) => !missing.has(wt.id) && this.options.isUnhealthy?.(wt.id) !== true)
|
||||
const ids = new Set(available.map((wt) => wt.id))
|
||||
for (const id of Object.keys(this.lastStats)) {
|
||||
if (!ids.has(id)) {
|
||||
@@ -333,18 +341,7 @@ export class GitStatsPoller {
|
||||
return { worktrees: [], degraded: true }
|
||||
}
|
||||
|
||||
const worktreeStatuses = await Promise.all(
|
||||
worktrees.map(async (wt) => {
|
||||
const abs = path.isAbsolute(wt.path) ? wt.path : path.join(root, wt.path)
|
||||
const exists = await fs.promises.access(abs).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
const branch = exists ? findTrackedBranch(tracked, abs) : undefined
|
||||
const missing = !exists || branch === undefined
|
||||
return { worktreeId: wt.id, missing, branch }
|
||||
}),
|
||||
)
|
||||
const worktreeStatuses = await Promise.all(worktrees.map((wt) => this.presence(wt, root, tracked)))
|
||||
|
||||
return { worktrees: worktreeStatuses, degraded: false }
|
||||
}
|
||||
@@ -356,7 +353,9 @@ export class GitStatsPoller {
|
||||
() => false,
|
||||
)
|
||||
const branch = exists ? findTrackedBranch(paths, abs) : undefined
|
||||
return { worktreeId: wt.id, missing: !exists || branch === undefined, branch }
|
||||
if (!exists) return { worktreeId: wt.id, missing: true, reason: "absent" }
|
||||
if (branch === undefined) return { worktreeId: wt.id, missing: true, reason: "unregistered" }
|
||||
return { worktreeId: wt.id, missing: false, branch }
|
||||
}
|
||||
|
||||
private async fetchLocalStats(generation = this.generation, refs?: RefSnapshot, refresh = false): Promise<void> {
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { Worktree } from "./WorktreeStateManager"
|
||||
import type { PRMergeMethod, PRStatus, PRCheck, PRReviewer, PRTimelineItem } from "./types"
|
||||
import { execWithShellEnv } from "./shell-env"
|
||||
import { execGhRead } from "./gh"
|
||||
import { classifyPRError } from "./git-import"
|
||||
import { classifyPRError, type PRErrorKind } from "./git-import"
|
||||
import { BUDGET, isTimeout } from "./command-budget"
|
||||
import { Quarantine } from "./quarantine"
|
||||
import type { Semaphore } from "./semaphore"
|
||||
import {
|
||||
parsePRResult,
|
||||
@@ -37,6 +39,8 @@ interface PRStatusPollerOptions {
|
||||
semaphore?: Semaphore
|
||||
getBranch?: (worktree: Worktree) => Promise<string | undefined>
|
||||
getPRMergeMethod?: (repo: string) => PRMergeMethod | undefined
|
||||
/** True for worktrees the health reconcile says cannot answer (absent, unregistered, unavailable). */
|
||||
isUnhealthy?: (worktreeId: string) => boolean
|
||||
}
|
||||
|
||||
interface RepoInfo {
|
||||
@@ -79,6 +83,8 @@ export class PRStatusPoller {
|
||||
private readonly intervalMs: number
|
||||
private readonly semaphore: Semaphore | undefined
|
||||
private generation = 0
|
||||
/** Per-worktree failure isolation, so one broken worktree cannot back off the whole loop. */
|
||||
private readonly quarantine = new Quarantine()
|
||||
|
||||
private stale(generation: number): boolean {
|
||||
return generation !== this.generation
|
||||
@@ -160,15 +166,26 @@ export class PRStatusPoller {
|
||||
this.avatars.clear()
|
||||
this.resolvedAvatars.clear()
|
||||
this.lastFullSync = 0
|
||||
this.quarantine.reset()
|
||||
this.clearRefreshTimers()
|
||||
}
|
||||
|
||||
/** Worktrees currently skipped because they kept failing. Reported by the diagnostics command. */
|
||||
paused(): string[] {
|
||||
return this.options
|
||||
.getWorktrees()
|
||||
.map((wt) => wt.id)
|
||||
.filter((id) => this.quarantine.blocked(id))
|
||||
}
|
||||
|
||||
/** Force-refresh a specific worktree immediately, bypassing the PR cache. */
|
||||
refresh(worktreeId: string, settle = false): void {
|
||||
this.clearRefreshTimers(worktreeId)
|
||||
const wt = this.options.getWorktrees().find((w) => w.id === worktreeId)
|
||||
if (wt) this.prCache.delete(this.key(wt.branch, wt.path))
|
||||
this.lastHash.delete(worktreeId)
|
||||
// An explicit refresh outranks a quarantine: the user asked for this one now.
|
||||
this.quarantine.clear(worktreeId)
|
||||
if (!this.active) return
|
||||
const generation = this.generation
|
||||
void this.fetchOne(worktreeId, generation, true).catch(() => undefined)
|
||||
@@ -307,8 +324,13 @@ export class PRStatusPoller {
|
||||
? await settled(thunks, FULL_SYNC_CONCURRENCY)
|
||||
: await Promise.allSettled(thunks.map((fn) => fn()))
|
||||
if (this.stale(generation)) return
|
||||
const ok = results.every((r) => r.status === "fulfilled")
|
||||
if (ok) {
|
||||
this.quarantine.retain(new Set(worktrees.map((wt) => wt.id)))
|
||||
// Cycle-level backoff must reflect the loop's health, not one worktree's. A worktree that keeps
|
||||
// failing is quarantined by handleError; counting it here would slow polling for every other
|
||||
// worktree until the whole panel felt broken.
|
||||
const quarantined = targets.filter((wt) => this.quarantine.blocked(wt.id)).length
|
||||
const failed = results.filter((r) => r.status === "rejected").length
|
||||
if (failed === 0 || failed <= quarantined) {
|
||||
this.failures = 0
|
||||
return
|
||||
}
|
||||
@@ -378,6 +400,7 @@ export class PRStatusPoller {
|
||||
files: pr.files,
|
||||
}
|
||||
|
||||
this.quarantine.clear(worktreeId)
|
||||
const hash = `${worktreeId}:${branch ?? wt.branch}:${signature(status)}`
|
||||
if (this.lastHash.get(worktreeId) === hash) return
|
||||
this.lastHash.set(worktreeId, hash)
|
||||
@@ -395,6 +418,7 @@ export class PRStatusPoller {
|
||||
}
|
||||
|
||||
private empty(worktreeId: string, fallback: string, branch: string | undefined): void {
|
||||
this.quarantine.clear(worktreeId)
|
||||
const hash = `${worktreeId}:${fallback}:none`
|
||||
if (this.lastHash.get(worktreeId) === hash) return
|
||||
this.lastHash.set(worktreeId, hash)
|
||||
@@ -435,8 +459,15 @@ export class PRStatusPoller {
|
||||
|
||||
private handleError(worktreeId: string, branch: string | undefined, cwd: string, err: unknown): void {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const kind = existsSync(cwd) ? classifyPRError(msg) : "unknown"
|
||||
this.options.log(`PR fetch failed for ${branch ?? "unknown"}:`, msg)
|
||||
// A missing cwd or a timeout says nothing about gh itself, so neither may be reported as a
|
||||
// missing gh install.
|
||||
const kind: PRErrorKind = isTimeout(err) ? "gh_timeout" : existsSync(cwd) ? classifyPRError(msg) : "unknown"
|
||||
this.options.log(`PR fetch failed for ${branch ?? "unknown"} (${kind}):`, msg)
|
||||
if (this.quarantine.fail(worktreeId)) {
|
||||
this.options.log(
|
||||
`PR polling paused for ${branch ?? worktreeId} after ${this.quarantine.failures(worktreeId)} consecutive failures`,
|
||||
)
|
||||
}
|
||||
const key = kind === "gh_missing" ? "gh_missing" : kind === "gh_auth" ? "gh_auth" : "fetch_failed"
|
||||
if (kind === "gh_missing") this.ghAvailable = false
|
||||
const hash = `${worktreeId}:${branch ?? ""}:error:${key}`
|
||||
@@ -449,6 +480,10 @@ export class PRStatusPoller {
|
||||
if (!this.options.getWorkspaceRoot()) return
|
||||
const worktree = this.options.getWorktrees().find((item) => item.id === worktreeId)
|
||||
if (!worktree || !existsSync(worktree.path)) return
|
||||
// A directory that exists but is not a live worktree answers nothing useful; gh would run with a
|
||||
// cwd that is not a repository and fail once per poll, forever.
|
||||
if (this.options.isUnhealthy?.(worktreeId)) return
|
||||
if (this.quarantine.blocked(worktreeId)) return
|
||||
return worktree
|
||||
}
|
||||
|
||||
@@ -473,37 +508,50 @@ export class PRStatusPoller {
|
||||
}
|
||||
|
||||
private async fetchPRForBranch(branch: string, cwd: string): Promise<PRResult | null> {
|
||||
// Strategy 1: bare `gh pr view` — resolves via the branch's tracking ref.
|
||||
// Works for fork PRs checked out with `gh pr checkout` (tracking ref = refs/pull/N/head).
|
||||
// Strategy 2: `gh pr view <branch>` — works for same-repo branches pushed to origin.
|
||||
// Strategy 1: `gh pr view <branch>` — the branch is known for every Agent Manager worktree, and
|
||||
// naming it keeps gh from resolving the current branch itself. The bare form has been observed
|
||||
// hanging indefinitely in a worktree while the explicit form answers immediately, so it is no
|
||||
// longer tried first.
|
||||
// Strategy 2: bare `gh pr view` — still needed for fork PRs checked out with `gh pr checkout`,
|
||||
// where the tracking ref (refs/pull/N/head) is what identifies the PR. Short budget: this is the
|
||||
// form that hangs.
|
||||
// Strategy 3: `gh pr list --search "<sha>"` — last resort, finds PRs by HEAD commit SHA.
|
||||
return (await this.ghPRView(cwd)) ?? (await this.ghPRView(cwd, branch)) ?? (await this.ghPRListBySHA(cwd))
|
||||
return (
|
||||
(await this.ghPRView(cwd, branch)) ??
|
||||
(await this.ghPRView(cwd, undefined, BUDGET.probe)) ??
|
||||
(await this.ghPRListBySHA(cwd))
|
||||
)
|
||||
}
|
||||
|
||||
/** Run `gh pr view [branch] --json ...` and parse the result, or return null. */
|
||||
private async ghPRView(cwd: string, branch?: string): Promise<PRResult | null> {
|
||||
private async ghPRView(cwd: string, branch?: string, timeout: number = BUDGET.gh): Promise<PRResult | null> {
|
||||
try {
|
||||
const args = ["pr", "view"]
|
||||
if (branch) args.push(branch)
|
||||
return parsePRResult(await this.query(args, cwd))
|
||||
return parsePRResult(await this.query(args, cwd, timeout))
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (msg.includes("no pull requests found") || msg.includes("Could not resolve")) return null
|
||||
// A hanging lookup is not evidence that the PR does not exist; let the next strategy answer.
|
||||
if (isTimeout(err)) {
|
||||
this.options.log(`PR lookup timed out (${branch ?? "current branch"}), trying next strategy`)
|
||||
return null
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private async query(args: string[], cwd: string): Promise<string> {
|
||||
private async query(args: string[], cwd: string, timeout: number = BUDGET.gh): Promise<string> {
|
||||
if (this.rich) {
|
||||
try {
|
||||
return (await this.gh([...args, "--json", PRStatusPoller.PR_JSON_FIELDS], { cwd, timeout: 15_000 })).stdout
|
||||
return (await this.gh([...args, "--json", PRStatusPoller.PR_JSON_FIELDS], { cwd, timeout })).stdout
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (!/unknown.*field|does(?:n't| not) exist|not accessible|insufficient|forbidden/i.test(msg)) throw err
|
||||
this.rich = false
|
||||
}
|
||||
}
|
||||
return (await this.gh([...args, "--json", PRStatusPoller.BASE_JSON_FIELDS], { cwd, timeout: 15_000 })).stdout
|
||||
return (await this.gh([...args, "--json", PRStatusPoller.BASE_JSON_FIELDS], { cwd, timeout })).stdout
|
||||
}
|
||||
|
||||
/** Search for PRs containing the current HEAD SHA. Finds PRs when branch name/tracking ref don't match. */
|
||||
|
||||
@@ -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 { BUDGET } from "./command-budget"
|
||||
import { WorktreePool, type PoolStart } from "./worktree-pool"
|
||||
import {
|
||||
parsePRUrl,
|
||||
@@ -26,9 +27,12 @@ import {
|
||||
classifyPRError,
|
||||
validateGitRef,
|
||||
normalizePath,
|
||||
unregisteredWorktree,
|
||||
type PRInfo,
|
||||
type BranchListItem,
|
||||
} from "./git-import"
|
||||
import { pathKey } from "./project/paths"
|
||||
import { Semaphore } from "./semaphore"
|
||||
|
||||
const TEMP_PREFIX = ".kilo-delete-"
|
||||
const RM_OPTS: fs.RmOptions = { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }
|
||||
@@ -48,6 +52,19 @@ function directory(branch: string): string {
|
||||
return `${slug}-${hash}`
|
||||
}
|
||||
|
||||
/** Why a directory under `.kilo/worktrees/` could not be used as a worktree. */
|
||||
export type WorktreeProbeReason =
|
||||
/** No `.git` file — a directory that outlived its worktree, e.g. holding only `.kilo-dev/`. */
|
||||
| "leftover"
|
||||
/** Has a `.git` file but git does not track the path. */
|
||||
| "unregistered"
|
||||
/** A pool slot, not a user worktree. */
|
||||
| "pooled"
|
||||
/** git could not answer for this path. */
|
||||
| "probe-failed"
|
||||
|
||||
export type WorktreeProbe = { ok: true; info: WorktreeInfo } | { ok: false; path: string; reason: WorktreeProbeReason }
|
||||
|
||||
export interface WorktreeInfo {
|
||||
branch: string
|
||||
path: string
|
||||
@@ -125,6 +142,11 @@ export class WorktreeManager {
|
||||
*/
|
||||
rewarmDelay = 8_000
|
||||
private migrated = false
|
||||
/**
|
||||
* Gate for discovery fan-out only. Deliberately not the poller semaphore: startup discovery must
|
||||
* not queue behind PR polling, and polling must not stall behind a directory scan.
|
||||
*/
|
||||
private readonly scanGate = new Semaphore(4)
|
||||
|
||||
constructor(
|
||||
root: string,
|
||||
@@ -173,7 +195,29 @@ export class WorktreeManager {
|
||||
private static fetchCache = new Map<string, number>()
|
||||
private static readonly FETCH_CACHE_TTL = 60_000 // 1 minute
|
||||
private gitAvailable = false
|
||||
private probeFailed = false
|
||||
private lfsAvailable: boolean | undefined
|
||||
/** When the last negative git-lfs probe ran, so a later install is picked up. */
|
||||
private lfsProbed = 0
|
||||
private static readonly LFS_PROBE_TTL = 300_000
|
||||
|
||||
/** Repository root this manager operates on. */
|
||||
get repo(): string {
|
||||
return this.root
|
||||
}
|
||||
|
||||
/** Absolute `.kilo/worktrees` directory this manager owns. */
|
||||
get worktreesDir(): string {
|
||||
return this.dir
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when a `git --version` probe failed to spawn. Callers use this to decide whether a
|
||||
* downstream `ENOENT` really means "git is missing" instead of "that directory is gone".
|
||||
*/
|
||||
get gitProbeFailed(): boolean {
|
||||
return this.probeFailed
|
||||
}
|
||||
|
||||
private withGitLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const key = this.root
|
||||
@@ -336,11 +380,15 @@ export class WorktreeManager {
|
||||
private async ensureGitAvailable(): Promise<void> {
|
||||
if (this.gitAvailable) return
|
||||
try {
|
||||
await execWithShellEnv(this.binary, ["--version"])
|
||||
// Bounded: an unbounded probe turns a wedged git into a hang with no error to report.
|
||||
await execWithShellEnv(this.binary, ["--version"], { timeout: BUDGET.probe })
|
||||
this.gitAvailable = true
|
||||
this.probeFailed = false
|
||||
} catch (error) {
|
||||
this.gitAvailable = false
|
||||
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
// The probe runs without a cwd, so ENOENT here can only mean the binary is missing.
|
||||
this.probeFailed = true
|
||||
throw new Error(
|
||||
"Git is not installed or not found in PATH. Please install Git (https://git-scm.com) and restart VS Code.",
|
||||
)
|
||||
@@ -613,15 +661,82 @@ export class WorktreeManager {
|
||||
* worktree was created despite a non-zero exit code (e.g., hook failure).
|
||||
*/
|
||||
private async worktreeRegistered(wtPath: string): Promise<boolean> {
|
||||
const registered = await this.registeredPaths()
|
||||
return registered?.has(pathKey(wtPath)) ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized paths git currently tracks as worktrees, or undefined when the listing failed.
|
||||
*
|
||||
* One call answers "is this directory still a worktree?" for every directory at once, which is
|
||||
* what keeps discovery from spawning a `rev-parse` per directory.
|
||||
*/
|
||||
async registeredPaths(): Promise<Set<string> | undefined> {
|
||||
try {
|
||||
const raw = await this.git.raw(["worktree", "list", "--porcelain"])
|
||||
const normalized = normalizePath(wtPath)
|
||||
return parseWorktreeList(raw).some((e) => normalizePath(e.path) === normalized)
|
||||
} catch {
|
||||
return false
|
||||
return new Set(parseWorktreeList(raw).map((entry) => pathKey(entry.path)))
|
||||
} catch (err) {
|
||||
this.log(`registeredPaths: worktree list failed: ${err}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop git metadata for worktrees whose directory is gone. */
|
||||
async pruneWorktrees(): Promise<void> {
|
||||
await this.withGitLock(async () => {
|
||||
await this.git.raw(["worktree", "prune"]).catch((err: unknown) => {
|
||||
this.log(`pruneWorktrees: prune failed: ${err}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Directory names directly under `.kilo/worktrees/`, excluding in-flight deletions. */
|
||||
async worktreeDirs(): Promise<string[]> {
|
||||
if (!fs.existsSync(this.dir)) return []
|
||||
const entries = await fs.promises.readdir(this.dir, { withFileTypes: true })
|
||||
return entries.filter((e) => e.isDirectory() && !e.name.startsWith(TEMP_PREFIX)).map((e) => e.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-create a worktree directory that was deleted outside Agent Manager, reusing its branch.
|
||||
*
|
||||
* The branch still holds the work, so this is a recovery rather than a new worktree: same path,
|
||||
* same branch, no new branch created.
|
||||
*/
|
||||
async restoreWorktree(worktreePath: string, branch: string): Promise<void> {
|
||||
if (!this.isManagedPath(worktreePath)) {
|
||||
throw new Error(`Refusing to restore a path outside the worktrees directory: ${worktreePath}`)
|
||||
}
|
||||
if (fs.existsSync(worktreePath)) throw new Error(`Path already exists: ${worktreePath}`)
|
||||
validateGitRef(branch, "branch")
|
||||
await this.ensureGitAvailable()
|
||||
await this.withGitLock(async () => {
|
||||
await this.ensureDir()
|
||||
// Prune first: a leftover registration for this path would fail the add.
|
||||
await this.git.raw(["worktree", "prune"]).catch(() => {})
|
||||
await this.git.raw(["worktree", "add", worktreePath, branch])
|
||||
})
|
||||
this.log(`Restored worktree ${worktreePath} from branch ${branch}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a directory under `.kilo/worktrees/` that git no longer tracks.
|
||||
*
|
||||
* Only ever called for a user-confirmed cleanup of an orphaned directory: there is no worktree
|
||||
* left to remove, so this is a plain recursive delete behind the managed-path guard.
|
||||
*/
|
||||
async removeOrphanDirectory(target: string): Promise<void> {
|
||||
if (!this.isManagedPath(target)) {
|
||||
throw new Error(`Refusing to remove a path outside the worktrees directory: ${target}`)
|
||||
}
|
||||
const registered = await this.registeredPaths()
|
||||
if (registered?.has(pathKey(target))) {
|
||||
throw new Error(`Refusing to remove a live worktree: ${target}`)
|
||||
}
|
||||
await fs.promises.rm(target, RM_OPTS)
|
||||
this.log(`Removed orphaned worktree directory: ${target}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a worktree directory and its git bookkeeping.
|
||||
*
|
||||
@@ -711,18 +826,28 @@ export class WorktreeManager {
|
||||
}
|
||||
|
||||
async discoverWorktrees(): Promise<WorktreeInfo[]> {
|
||||
const probes = await this.scanWorktrees()
|
||||
return probes.flatMap((probe) => (probe.ok ? [probe.info] : []))
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe every directory under `.kilo/worktrees/`, keeping the reason a directory was skipped.
|
||||
*
|
||||
* Bounded on purpose: this used to fan out one `git rev-parse` per directory in a single
|
||||
* `Promise.all`, so a repository with dozens of leftover directories opened dozens of git
|
||||
* processes at startup — the same storm that makes every command look like it timed out.
|
||||
*/
|
||||
async scanWorktrees(): Promise<WorktreeProbe[]> {
|
||||
await this.ensureMigrated()
|
||||
if (!fs.existsSync(this.dir)) return []
|
||||
await markNoIndex(this.dir, this.log)
|
||||
|
||||
const entries = await fs.promises.readdir(this.dir, { withFileTypes: true })
|
||||
const names = await this.worktreeDirs()
|
||||
this.cleanupOrphanedTempDirs()
|
||||
const results = await Promise.all(
|
||||
entries
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith(TEMP_PREFIX))
|
||||
.map((e) => this.worktreeInfo(path.join(this.dir, e.name))),
|
||||
const registered = await this.registeredPaths()
|
||||
return await Promise.all(
|
||||
names.map((name) => this.scanGate.run(() => this.worktreeInfo(path.join(this.dir, name), registered))),
|
||||
)
|
||||
return results.filter((info): info is WorktreeInfo => info !== undefined)
|
||||
}
|
||||
|
||||
async writeMetadata(worktreePath: string, sessionId: string, parentBranch: string, remote?: string): Promise<void> {
|
||||
@@ -898,16 +1023,26 @@ export class WorktreeManager {
|
||||
await markNoIndex(this.dir, this.log)
|
||||
}
|
||||
|
||||
private async worktreeInfo(wtPath: string): Promise<WorktreeInfo | undefined> {
|
||||
/**
|
||||
* Probe one directory. The failure reason is part of the result so callers can tell a leftover
|
||||
* directory from a broken worktree from a git that would not answer.
|
||||
*/
|
||||
private async worktreeInfo(wtPath: string, registered?: Set<string>): Promise<WorktreeProbe> {
|
||||
const gitFile = path.join(wtPath, ".git")
|
||||
if (!fs.existsSync(gitFile)) return undefined
|
||||
if (!fs.existsSync(gitFile)) return { ok: false, path: wtPath, reason: "leftover" }
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(gitFile)
|
||||
if (!stat.isFile()) return undefined
|
||||
if (!stat.isFile()) return { ok: false, path: wtPath, reason: "leftover" }
|
||||
} catch {
|
||||
// .git path inaccessible — not a valid worktree
|
||||
return undefined
|
||||
return { ok: false, path: wtPath, reason: "leftover" }
|
||||
}
|
||||
|
||||
// Cheap and decisive: git already told us which paths it tracks, so a directory missing from
|
||||
// that list is broken and does not deserve a git process of its own.
|
||||
if (registered && !registered.has(pathKey(wtPath))) {
|
||||
return { ok: false, path: wtPath, reason: "unregistered" }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -918,7 +1053,7 @@ export class WorktreeManager {
|
||||
this.readMetadata(wtPath),
|
||||
])
|
||||
// Pooled slots are internal warm-up worktrees, not user sessions.
|
||||
if (meta?.pooled) return undefined
|
||||
if (meta?.pooled) return { ok: false, path: wtPath, reason: "pooled" }
|
||||
// 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.
|
||||
@@ -936,16 +1071,23 @@ export class WorktreeManager {
|
||||
return { branch: meta.parentBranch }
|
||||
})()) ?? (await this.resolveBaseBranch())
|
||||
return {
|
||||
branch: branch.trim(),
|
||||
path: wtPath,
|
||||
parentBranch: base.branch,
|
||||
remote: base.remote,
|
||||
createdAt: stat.birthtimeMs,
|
||||
sessionId: meta?.sessionId,
|
||||
ok: true,
|
||||
info: {
|
||||
branch: branch.trim(),
|
||||
path: wtPath,
|
||||
parentBranch: base.branch,
|
||||
remote: base.remote,
|
||||
createdAt: stat.birthtimeMs,
|
||||
sessionId: meta?.sessionId,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Failed to get info for worktree ${wtPath}: ${error}`)
|
||||
return undefined
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
// Downgraded from a bare failure log: an unregistered worktree is an expected state with a
|
||||
// recovery path, not an unexplained error.
|
||||
const reason = unregisteredWorktree(msg) ? "unregistered" : "probe-failed"
|
||||
this.log(`Worktree ${wtPath} unavailable (${reason}): ${msg}`)
|
||||
return { ok: false, path: wtPath, reason }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1118,12 +1260,15 @@ export class WorktreeManager {
|
||||
|
||||
async checkLfsAvailable(): Promise<boolean> {
|
||||
if (this.lfsAvailable) return true
|
||||
// A negative verdict expires: installing git-lfs mid-session used to require a window reload.
|
||||
if (this.lfsAvailable === false && Date.now() - this.lfsProbed < WorktreeManager.LFS_PROBE_TTL) return false
|
||||
try {
|
||||
await execWithShellEnv(this.binary, ["lfs", "version"], { cwd: this.root, timeout: 5000 })
|
||||
await execWithShellEnv(this.binary, ["lfs", "version"], { cwd: this.root, timeout: BUDGET.probe })
|
||||
this.lfsAvailable = true
|
||||
return true
|
||||
} catch {
|
||||
this.lfsAvailable = false
|
||||
this.lfsProbed = Date.now()
|
||||
// git-lfs not installed
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -790,31 +790,14 @@ export class WorktreeStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove worktrees whose directories no longer exist on disk and prune orphaned sessions. */
|
||||
async validate(root: string): Promise<void> {
|
||||
let changed = false
|
||||
for (const wt of [...this.worktrees.values()]) {
|
||||
const resolved = path.isAbsolute(wt.path) ? wt.path : path.join(root, wt.path)
|
||||
if (!fs.existsSync(resolved)) {
|
||||
this.log(`Worktree ${wt.id} directory missing (${resolved}), removing`)
|
||||
this.removeWorktree(wt.id)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
// Preserve local sessions; prune only sessions that reference missing worktrees.
|
||||
for (const s of [...this.sessions.values()]) {
|
||||
const ref = s.worktreeId
|
||||
if (ref === null) continue
|
||||
if (!ref || !this.worktrees.has(ref)) {
|
||||
this.sessions.delete(s.id)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.log(`Pruned orphaned sessions during validation`)
|
||||
await this.save()
|
||||
}
|
||||
}
|
||||
/*
|
||||
* `validate(root)` used to live here: it removed every worktree row whose directory was missing
|
||||
* and deleted the session mappings with it. That silently discarded conversation history for
|
||||
* worktrees a user could still restore from their branch, and it never ran — nothing in src/
|
||||
* called it. Worktree health now lives in worktree-reconcile.ts, which classifies rows instead of
|
||||
* deleting them and only drops a row when the directory, the branch, and the sessions are all
|
||||
* gone.
|
||||
*/
|
||||
|
||||
/** Wait for any in-flight save to complete without triggering a new one. */
|
||||
async flush(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Timeout budgets for the git/gh commands Agent Manager spawns while polling.
|
||||
*
|
||||
* One 30s-ish budget for everything is what let a single hanging command stall a whole poll cycle:
|
||||
* `git --version` and `gh pr view` are not the same kind of work and must not share a deadline.
|
||||
* Budgets are deliberately short — a poll that misses is retried, and a worktree that keeps missing
|
||||
* is quarantined and reported as unavailable rather than silently rendered as clean.
|
||||
*/
|
||||
export const BUDGET = {
|
||||
/** Metadata lookups that touch little more than `.git`: rev-parse, worktree list, --version. */
|
||||
probe: 5_000,
|
||||
/** Content reads that scale with the diff: diff, rev-list, ls-files, cat-file, show. */
|
||||
read: 15_000,
|
||||
/** Anything that talks to GitHub through `gh`. */
|
||||
gh: 10_000,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Consecutive failures a single worktree may accumulate before it is skipped by pollers.
|
||||
*
|
||||
* This is also what makes the short budgets above safe: one timeout on a cold filesystem is not
|
||||
* proof of a broken worktree, so a worktree is only parked after it has failed repeatedly.
|
||||
*/
|
||||
export const QUARANTINE_THRESHOLD = 3
|
||||
|
||||
/** First quarantine window; doubles per additional failure up to {@link QUARANTINE_MAX}. */
|
||||
export const QUARANTINE_BASE = 60_000
|
||||
|
||||
export const QUARANTINE_MAX = 30 * 60_000
|
||||
|
||||
/** Quarantine window for a worktree that has failed `failures` times in a row. */
|
||||
export function quarantineWindow(failures: number): number {
|
||||
const over = Math.max(0, failures - QUARANTINE_THRESHOLD)
|
||||
return Math.min(QUARANTINE_MAX, QUARANTINE_BASE * 2 ** over)
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a command was killed for exceeding its budget.
|
||||
*
|
||||
* Node reports an `execFile` timeout as `killed: true` with `signal: "SIGTERM"` and a generic
|
||||
* "Command failed" message, so the text alone cannot be trusted; `GitOps` raises its own "timed out"
|
||||
* message instead. Both shapes are accepted, because misreading a timeout as a real error is how a
|
||||
* hanging command ends up reported as "gh is not installed".
|
||||
*/
|
||||
export function isTimeout(err: unknown): boolean {
|
||||
if (typeof err === "string") return /timed out/i.test(err)
|
||||
if (!(err instanceof Error)) return false
|
||||
const killed = err as Error & { killed?: boolean; signal?: NodeJS.Signals | null }
|
||||
if (killed.killed === true && (killed.signal === "SIGTERM" || killed.signal === "SIGKILL")) return true
|
||||
return /timed out/i.test(err.message)
|
||||
}
|
||||
@@ -8,6 +8,14 @@ function env(options?: Omit<ExecFileOptionsWithStringEncoding, "encoding">): Nod
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Name of the GitHub CLI, owned here so no other module needs the literal.
|
||||
*
|
||||
* The architecture test forbids the bare binary name elsewhere, which is what keeps every gh
|
||||
* invocation on the Windows-safe path in {@link execGhRead}.
|
||||
*/
|
||||
export const GH = "gh"
|
||||
|
||||
/** Run read-only gh queries without tzutil console windows flashing on Windows. */
|
||||
export function execGhRead(
|
||||
args: string[],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { existsSync } from "fs"
|
||||
|
||||
export interface BranchListItem {
|
||||
name: string
|
||||
isLocal: boolean
|
||||
@@ -28,9 +30,37 @@ interface WorktreeEntry {
|
||||
detached: boolean
|
||||
}
|
||||
|
||||
type PRErrorKind = "not_found" | "gh_missing" | "gh_auth" | "unknown"
|
||||
/**
|
||||
* Why a PR lookup failed. `gh_timeout` is never returned by {@link classifyPRError} — a timeout has
|
||||
* no stderr to classify, so only the caller knows — but it belongs in the same union so the poller's
|
||||
* handling stays exhaustive.
|
||||
*/
|
||||
export type PRErrorKind = "not_found" | "gh_missing" | "gh_auth" | "gh_timeout" | "unknown"
|
||||
|
||||
export type WorktreeSetupErrorCode = "git_not_found" | "not_git_repo" | "lfs_missing" | "no_commits"
|
||||
export type WorktreeSetupErrorCode =
|
||||
| "git_not_found"
|
||||
| "not_git_repo"
|
||||
| "lfs_missing"
|
||||
| "no_commits"
|
||||
| "worktree_missing"
|
||||
| "worktree_unregistered"
|
||||
| "git_timeout"
|
||||
|
||||
/**
|
||||
* Extra facts a caller knows that the error text alone cannot prove.
|
||||
*
|
||||
* A failed `spawn` reports `ENOENT` both when the binary is missing and when the working directory
|
||||
* is gone, so the message can never distinguish "git is not installed" from "this worktree was
|
||||
* deleted". Everything here exists to keep the classifier from guessing.
|
||||
*/
|
||||
export type WorktreeErrorContext = {
|
||||
/** Directory the failing command ran in. */
|
||||
cwd?: string
|
||||
/** True only when a `git --version` probe itself failed to spawn. */
|
||||
probeFailed?: boolean
|
||||
/** Existence check, injectable for tests. */
|
||||
exists?: (dir: string) => boolean
|
||||
}
|
||||
|
||||
export function parsePRUrl(url: string): PRUrlParts | null {
|
||||
let normalized = url.trim()
|
||||
@@ -155,10 +185,41 @@ export function classifyPRError(msg: string): PRErrorKind {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
export function classifyWorktreeError(msg: string): WorktreeSetupErrorCode | undefined {
|
||||
if (msg.includes("ENOENT") || msg.includes("not found in PATH")) return "git_not_found"
|
||||
/** True when the text is a failed process spawn rather than a filesystem or git-reported error. */
|
||||
function spawnFailure(msg: string): boolean {
|
||||
return /spawn\b/.test(msg) && msg.includes("ENOENT")
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a worktree setup/import failure to a user-facing code.
|
||||
*
|
||||
* `git_not_found` is only ever returned when git itself is provably the problem: a failed
|
||||
* `git --version` probe, or a spawn failure in a working directory that still exists. A spawn
|
||||
* failure in a directory that is gone is reported as `worktree_missing`, because telling the user
|
||||
* to install git when git works is worse than showing the raw message.
|
||||
*/
|
||||
export function classifyWorktreeError(msg: string, ctx?: WorktreeErrorContext): WorktreeSetupErrorCode | undefined {
|
||||
if (msg.includes("timed out")) return "git_timeout"
|
||||
if (ctx?.probeFailed) return "git_not_found"
|
||||
if (msg.includes("not found in PATH")) return "git_not_found"
|
||||
|
||||
const cwd = ctx?.cwd
|
||||
const exists = ctx?.exists ?? existsSync
|
||||
const cwdGone = cwd !== undefined && !exists(cwd)
|
||||
if (cwdGone) return "worktree_missing"
|
||||
|
||||
if (unregisteredWorktree(msg)) return "worktree_unregistered"
|
||||
if (msg.includes("not a git repository")) return "not_git_repo"
|
||||
if (msg.includes("Git LFS") && msg.includes("not found")) return "lfs_missing"
|
||||
if (msg.includes("no commits yet")) return "no_commits"
|
||||
if (spawnFailure(msg)) return "git_not_found"
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* `fatal: not a git repository: /repo/.git/worktrees/<name>` — the directory is still on disk but
|
||||
* git no longer knows about it, which is a broken worktree rather than a non-repo folder.
|
||||
*/
|
||||
export function unregisteredWorktree(msg: string): boolean {
|
||||
return /not a git repository:.*[/\\]worktrees[/\\]/.test(msg)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface Disposable {
|
||||
|
||||
export interface OutputHandle {
|
||||
appendLine(msg: string): void
|
||||
/** Reveal the channel, e.g. after writing a report the user asked for. */
|
||||
show?(): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { inspect } from "util"
|
||||
|
||||
/**
|
||||
* Render log arguments so failures stay diagnosable.
|
||||
*
|
||||
* `JSON.stringify(new Error("boom"))` is `"{}"` — every own property of an Error is non-enumerable.
|
||||
* That is how `Agent Manager request recovery failed: {}` reached a user-visible log with no
|
||||
* message, no type, and no stack.
|
||||
*/
|
||||
export function formatLog(args: unknown[]): string {
|
||||
return args.map(render).join(" ")
|
||||
}
|
||||
|
||||
function render(value: unknown): string {
|
||||
if (typeof value === "string") return value
|
||||
if (value instanceof Error) return value.stack ?? `${value.name}: ${value.message}`
|
||||
return inspect(value, { breakLength: Infinity, depth: 4 })
|
||||
}
|
||||
@@ -38,6 +38,8 @@ interface PRBridgeHost {
|
||||
conflicts?: (cwd: string, remote: string, base: string, head: string) => Promise<string[]>
|
||||
getPRMergeMethod?: (repo: string) => PRMergeMethod | undefined
|
||||
savePRMergeMethod?: (repo: string, method: PRMergeMethod) => Promise<void>
|
||||
/** Worktrees the health reconcile says cannot answer; skipped instead of polled. */
|
||||
isUnhealthy?: (worktreeId: string) => boolean
|
||||
}
|
||||
|
||||
/** Minimal panel surface needed by the bridge (subset of PanelContext). */
|
||||
@@ -116,6 +118,7 @@ export class PRStatusBridge {
|
||||
conflicts?: (cwd: string, remote: string, base: string, head: string) => Promise<string[]>
|
||||
getPRMergeMethod?: (repo: string) => PRMergeMethod | undefined
|
||||
savePRMergeMethod?: (repo: string, method: PRMergeMethod) => Promise<void>
|
||||
isUnhealthy?: (worktreeId: string) => boolean
|
||||
}): PRStatusBridge {
|
||||
return new PRStatusBridge(opts)
|
||||
}
|
||||
@@ -363,6 +366,7 @@ function bridgePollerOpts(bridge: PRStatusBridge, host: PRBridgeHost) {
|
||||
},
|
||||
log: (...args: unknown[]) => host.log(...args),
|
||||
getPRMergeMethod: host.getPRMergeMethod,
|
||||
isUnhealthy: host.isUnhealthy,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { WorktreeStateManager } from "../WorktreeStateManager"
|
||||
import { WorktreeManager } from "../WorktreeManager"
|
||||
import { SetupScriptService } from "../SetupScriptService"
|
||||
import type { GitOps } from "../GitOps"
|
||||
import type { WorktreeHealthReport } from "../worktree-reconcile"
|
||||
import type { ProjectSessionView } from "./session-view"
|
||||
|
||||
export interface ProjectContextDeps {
|
||||
@@ -43,6 +44,8 @@ export interface ProjectInitResult {
|
||||
ok: boolean
|
||||
refsFixed: number
|
||||
current: boolean
|
||||
/** Worktree health from the startup reconcile, when it ran. */
|
||||
health?: WorktreeHealthReport
|
||||
}
|
||||
|
||||
export class ProjectContext {
|
||||
@@ -58,6 +61,11 @@ export class ProjectContext {
|
||||
private listed = 0
|
||||
private views: readonly ProjectSessionView[] = []
|
||||
readonly stale = new Set<string>()
|
||||
/**
|
||||
* Latest worktree-health reconcile. Read by the pollers to skip worktrees that cannot answer and
|
||||
* by the diagnostics report; refreshed by {@link initContextState} and by an explicit repair.
|
||||
*/
|
||||
report: WorktreeHealthReport | undefined
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
* land before the persisted state file is read.
|
||||
*/
|
||||
|
||||
import * as fs from "fs"
|
||||
import { restoreWorktrees } from "../state-recovery"
|
||||
import { reconcileWorktrees, summarize, type WorktreeHealthReport } from "../worktree-reconcile"
|
||||
import type { ProjectContext, ProjectInitResult } from "./context"
|
||||
import type { Session } from "@kilocode/sdk/v2/client"
|
||||
import type { ProjectRef, SessionRef, WorktreeRef } from "./route"
|
||||
@@ -92,15 +94,76 @@ export async function initContextState(
|
||||
await state.flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Disk → state recovery above only ever adds rows. This pass is the other direction: classify
|
||||
// what is already tracked, prune what git can drop, and clear rows that cannot lose anything.
|
||||
const health = await reconcileProject(ctx, log)
|
||||
if (!ctx.isCurrent(generation)) return { ok: false, refsFixed: 0 }
|
||||
if (health && health.dropped.length > 0) 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 }
|
||||
return { ok: true, refsFixed: loaded.refsFixed, health }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile state rows, git registrations, and directories for one project, storing the verdict on
|
||||
* the context so pollers can skip worktrees that cannot answer.
|
||||
*
|
||||
* Failure here is never fatal: a project whose health is unknown keeps working, it just does not
|
||||
* get self-healing until the next pass.
|
||||
*/
|
||||
export async function reconcileProject(
|
||||
ctx: ProjectContext,
|
||||
log: (...args: unknown[]) => void,
|
||||
): Promise<WorktreeHealthReport | undefined> {
|
||||
const manager = ctx.worktreeManager()
|
||||
const state = ctx.stateManager()
|
||||
const report = await reconcileWorktrees({
|
||||
root: ctx.root,
|
||||
dir: manager.worktreesDir,
|
||||
rows: () => state.getWorktrees().map((wt) => ({ id: wt.id, path: wt.path, branch: wt.branch })),
|
||||
sessions: (id) => state.getSessions(id).length,
|
||||
registered: () => manager.registeredPaths(),
|
||||
dirs: () => manager.worktreeDirs(),
|
||||
exists: (target) =>
|
||||
fs.promises.access(target).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
branchExists: (branch) => manager.branchExists(branch),
|
||||
prune: () => manager.pruneWorktrees(),
|
||||
drop: (id) => state.removeWorktree(id),
|
||||
log: (msg) => log(msg),
|
||||
}).catch((err: unknown) => {
|
||||
log("Failed to reconcile worktree health:", err)
|
||||
return undefined
|
||||
})
|
||||
if (!report) return undefined
|
||||
ctx.report = report
|
||||
log(`worktree health: ${summarize(report)}`)
|
||||
return report
|
||||
}
|
||||
|
||||
/** Counts only — no paths and no branch names, which are user content. */
|
||||
export function healthMetrics(report: WorktreeHealthReport): Record<string, number | boolean> {
|
||||
const counts: Record<string, number | boolean> = {
|
||||
worktrees: report.entries.length,
|
||||
orphans: report.orphans.length,
|
||||
dropped: report.dropped.length,
|
||||
pruned: report.pruned,
|
||||
degraded: report.degraded,
|
||||
}
|
||||
for (const entry of report.entries) {
|
||||
const key = entry.health
|
||||
counts[key] = ((counts[key] as number | undefined) ?? 0) + 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/** Register explicit Local/worktree routes for every persisted project session. */
|
||||
export function registerProjectSessions(
|
||||
ctx: ProjectContext,
|
||||
|
||||
@@ -21,6 +21,19 @@ export function canonicalizePath(dir: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical map/set key for a path, folded on case-insensitive filesystems.
|
||||
*
|
||||
* Same rules as {@link canonicalizePath} plus {@link samePath}, collapsed into one value so callers
|
||||
* can use a Set lookup instead of an O(n) scan. Needed because `git worktree list` reports
|
||||
* realpaths: on macOS a repo under /var/folders is reported under /private/var/folders, and a
|
||||
* lexical comparison would call every live worktree unregistered.
|
||||
*/
|
||||
export function pathKey(target: string, platform: NodeJS.Platform = process.platform): string {
|
||||
const canonical = canonicalizePath(target)
|
||||
return platform === "darwin" || platform === "win32" ? canonical.toLowerCase() : canonical
|
||||
}
|
||||
|
||||
/** Compare two canonical paths. Case-insensitive filesystems compare folded. */
|
||||
export function samePath(a: string, b: string, platform: NodeJS.Platform = process.platform): boolean {
|
||||
if (platform === "darwin" || platform === "win32") return a.toLowerCase() === b.toLowerCase()
|
||||
|
||||
@@ -50,6 +50,12 @@ interface PollerDeps {
|
||||
mergeMethods?: Pick<Host, "getPRMergeMethod" | "savePRMergeMethod">
|
||||
}
|
||||
|
||||
/** True when the last reconcile decided this worktree cannot answer a git or gh query. */
|
||||
function unhealthyWorktree(ctx: ProjectContext, id: string): boolean {
|
||||
const entry = ctx.report?.entries.find((item) => item.id === id)
|
||||
return entry !== undefined && entry.health !== "ok"
|
||||
}
|
||||
|
||||
function hot(state: WorktreeStateManager | undefined): Set<string> {
|
||||
const result = new Set<string>()
|
||||
const target = state?.getActiveTarget()
|
||||
@@ -70,6 +76,7 @@ function createPollerPair(ctx: ProjectContext, deps: PollerDeps): PollerPair {
|
||||
getHotWorktreeIds: deps.hot ?? (() => hot(state())),
|
||||
git: deps.git,
|
||||
semaphore: deps.semaphore,
|
||||
isUnhealthy: (id) => unhealthyWorktree(ctx, id),
|
||||
log: deps.log,
|
||||
onStats: (stats) => deps.post({ type: "agentManager.worktreeStats", projectId: ctx.id, stats }),
|
||||
onLocalStats: (stats) => deps.post({ type: "agentManager.localStats", projectId: ctx.id, stats }),
|
||||
@@ -90,6 +97,7 @@ function createPollerPair(ctx: ProjectContext, deps: PollerDeps): PollerPair {
|
||||
savePRMergeMethod: async (repo, method) => {
|
||||
await deps.mergeMethods?.savePRMergeMethod?.(repo, method)
|
||||
},
|
||||
isUnhealthy: (id) => unhealthyWorktree(ctx, id),
|
||||
})
|
||||
return { stats, pr }
|
||||
}
|
||||
@@ -193,6 +201,8 @@ export function createPollers(opts: {
|
||||
log: (...args: unknown[]) => void
|
||||
hot?: () => Set<string>
|
||||
mergeMethods?: Pick<Host, "getPRMergeMethod" | "savePRMergeMethod">
|
||||
/** Worktrees the health reconcile says cannot answer; skipped instead of polled. */
|
||||
isUnhealthy?: (worktreeId: string) => boolean
|
||||
}): { stats: GitStatsPoller; pr: PRStatusBridge; projects: ProjectPollers } {
|
||||
const stats = new GitStatsPoller({
|
||||
getWorktrees: () => opts.state()?.getWorktrees() ?? [],
|
||||
@@ -210,6 +220,7 @@ export function createPollers(opts: {
|
||||
opts.post(msg)
|
||||
},
|
||||
onWorktreePresence: opts.presence,
|
||||
isUnhealthy: opts.isUnhealthy,
|
||||
log: opts.log,
|
||||
git: opts.git,
|
||||
})
|
||||
@@ -223,6 +234,7 @@ export function createPollers(opts: {
|
||||
openExternal: opts.openExternal,
|
||||
log: opts.log,
|
||||
semaphore: opts.semaphore,
|
||||
isUnhealthy: opts.isUnhealthy,
|
||||
projectId: opts.activeId,
|
||||
conflicts: (cwd, remote, base, head) => opts.git.conflicts(cwd, remote, base, head),
|
||||
getPRMergeMethod: (repo) => opts.mergeMethods?.getPRMergeMethod?.(repo),
|
||||
|
||||
@@ -9,6 +9,8 @@ export const STATE_GATED = new Set<string>([
|
||||
"agentManager.createMultiVersion",
|
||||
"agentManager.deleteWorktree",
|
||||
"agentManager.removeStaleWorktree",
|
||||
"agentManager.restoreWorktree",
|
||||
"agentManager.cleanOrphanDirectories",
|
||||
"agentManager.openLocally",
|
||||
"agentManager.openSessionLocally",
|
||||
"agentManager.addSessionToWorktree",
|
||||
|
||||
@@ -334,15 +334,25 @@ export async function deleteLifecycleWorktree(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Remove a stale worktree entry from state without touching the filesystem. */
|
||||
/**
|
||||
* Remove a stale worktree entry from state without touching the filesystem.
|
||||
*
|
||||
* With `keepSessions`, the worktree's conversations are moved to Local instead of being dropped with
|
||||
* the row: the directory is unrecoverable, but the history is not, and losing it silently is worse
|
||||
* than an extra row under Local.
|
||||
*/
|
||||
export async function removeStaleLifecycleWorktree(
|
||||
ctx: ProjectContext,
|
||||
host: LifecycleHost,
|
||||
worktreeId: string,
|
||||
keepSessions = false,
|
||||
): Promise<null> {
|
||||
const state = ctx.peekState()
|
||||
if (!state) return null
|
||||
if (!ctx.stale.has(worktreeId)) {
|
||||
// Either signal is proof enough: the presence probe saw it disappear, or the health reconcile
|
||||
// classified it as something that cannot answer.
|
||||
const unhealthy = ctx.report?.entries.some((entry) => entry.id === worktreeId && entry.health !== "ok") === true
|
||||
if (!ctx.stale.has(worktreeId) && !unhealthy) {
|
||||
host.log(`Ignored stale removal for non-stale worktree ${worktreeId}`)
|
||||
return null
|
||||
}
|
||||
@@ -376,12 +386,17 @@ export async function removeStaleLifecycleWorktree(
|
||||
}
|
||||
}
|
||||
host.forgetName(worktreeId)
|
||||
const kept = keepSessions ? state.getSessions(worktreeId) : []
|
||||
// Detach before removing the row: removeWorktree() deletes the sessions that still point at it.
|
||||
for (const session of kept) state.moveSession(session.id, null)
|
||||
const orphaned = state.removeWorktree(worktreeId)
|
||||
host.stopDiffs(worktree.path, orphaned)
|
||||
for (const session of orphaned) host.sessions.clearDirectory(session.id)
|
||||
host.stopDiffs(worktree.path, [...orphaned, ...kept])
|
||||
for (const session of [...orphaned, ...kept]) host.sessions.clearDirectory(session.id)
|
||||
for (const session of kept) routeProjectSession(host.sessions, ctx.id, session.id, ctx.root, ctx.generation)
|
||||
ctx.stale.delete(worktreeId)
|
||||
host.push()
|
||||
host.log(`Removed stale worktree entry ${worktreeId} (${worktree.branch})`)
|
||||
const suffix = kept.length > 0 ? `, kept ${kept.length} session(s) under Local` : ""
|
||||
host.log(`Removed stale worktree entry ${worktreeId} (${worktree.branch})${suffix}`)
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Per-worktree failure isolation for the polling loops.
|
||||
*
|
||||
* Without this, one permanently broken worktree degrades everything: its failures increment the
|
||||
* poller's single consecutive-failure counter, which backs off polling for every other worktree, and
|
||||
* it keeps spawning git/gh processes that cannot succeed. A worktree that repeatedly fails is parked
|
||||
* for a while and reported as unavailable; healthy neighbours keep their normal cadence.
|
||||
*/
|
||||
|
||||
import { QUARANTINE_THRESHOLD, quarantineWindow } from "./command-budget"
|
||||
|
||||
type Entry = { failures: number; until: number }
|
||||
|
||||
export class Quarantine {
|
||||
private readonly entries = new Map<string, Entry>()
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
/** Record a failure. Returns true when this failure started or extended a quarantine. */
|
||||
fail(id: string): boolean {
|
||||
const entry = this.entries.get(id) ?? { failures: 0, until: 0 }
|
||||
entry.failures++
|
||||
this.entries.set(id, entry)
|
||||
if (entry.failures < QUARANTINE_THRESHOLD) return false
|
||||
entry.until = this.now() + quarantineWindow(entry.failures)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Forget a worktree's failure history after any success. */
|
||||
clear(id: string): void {
|
||||
this.entries.delete(id)
|
||||
}
|
||||
|
||||
/** True while the worktree must not be polled. */
|
||||
blocked(id: string): boolean {
|
||||
const entry = this.entries.get(id)
|
||||
if (!entry) return false
|
||||
if (entry.until === 0) return false
|
||||
if (this.now() < entry.until) return true
|
||||
// Window elapsed: allow one attempt through. A failure re-arms with a longer window, a success
|
||||
// clears the entry entirely.
|
||||
entry.until = 0
|
||||
return false
|
||||
}
|
||||
|
||||
/** Consecutive failures recorded for a worktree. */
|
||||
failures(id: string): number {
|
||||
return this.entries.get(id)?.failures ?? 0
|
||||
}
|
||||
|
||||
/** Drop entries for worktrees that no longer exist. */
|
||||
retain(ids: Set<string>): void {
|
||||
for (const id of [...this.entries.keys()]) {
|
||||
if (!ids.has(id)) this.entries.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.entries.clear()
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type { Worktree, ManagedSession, Section } from "./WorktreeStateManager"
|
||||
import type { WorktreeStats, LocalStats } from "./GitStatsPoller"
|
||||
import type { ApplyConflict } from "./GitOps"
|
||||
import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import"
|
||||
import type { WorktreeHealth } from "./worktree-reconcile"
|
||||
import type { RunStatus } from "./run/manager"
|
||||
import type { TerminalFont } from "./terminal-font"
|
||||
import type { ProjectSnapshot } from "./project/contexts"
|
||||
@@ -144,6 +145,10 @@ interface StateMessage {
|
||||
sessions: ManagedSession[]
|
||||
sections?: Section[]
|
||||
staleWorktreeIds?: string[]
|
||||
/** Why each unhealthy worktree is unhealthy; healthy worktrees are omitted. */
|
||||
worktreeHealth?: Record<string, WorktreeHealth>
|
||||
/** Directories under `.kilo/worktrees/` that no worktree claims. Never removed automatically. */
|
||||
orphanDirectories?: string[]
|
||||
tabOrder?: Record<string, string[]>
|
||||
worktreeOrder?: string[]
|
||||
sessionsCollapsed?: boolean
|
||||
@@ -610,6 +615,22 @@ interface RemoveStaleWorktreeIn {
|
||||
type: "agentManager.removeStaleWorktree"
|
||||
projectId?: string
|
||||
worktreeId: string
|
||||
/** Move the worktree's sessions to Local instead of dropping them with the row. */
|
||||
keepSessions?: boolean
|
||||
}
|
||||
|
||||
/** Re-create a worktree directory that was deleted outside Agent Manager, from its branch. */
|
||||
interface RestoreWorktreeIn {
|
||||
type: "agentManager.restoreWorktree"
|
||||
projectId?: string
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
/** Delete directories under `.kilo/worktrees/` that no worktree claims. */
|
||||
interface CleanOrphanDirectoriesIn {
|
||||
type: "agentManager.cleanOrphanDirectories"
|
||||
projectId?: string
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
interface PromoteSessionIn {
|
||||
@@ -1177,6 +1198,8 @@ export type AgentManagerInMessage =
|
||||
| SetProjectExpandedIn
|
||||
| DeleteWorktreeIn
|
||||
| RemoveStaleWorktreeIn
|
||||
| RestoreWorktreeIn
|
||||
| CleanOrphanDirectoriesIn
|
||||
| PromoteSessionIn
|
||||
| OpenLocallyIn
|
||||
| OpenSessionLocallyIn
|
||||
|
||||
@@ -366,6 +366,7 @@ export class VscodeHost implements Host {
|
||||
const channel = vscode.window.createOutputChannel(name)
|
||||
return {
|
||||
appendLine: (msg) => channel.appendLine(msg),
|
||||
show: () => channel.show(true),
|
||||
dispose: () => channel.dispose(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function createWorktreeOnDisk(
|
||||
})
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
const errorCode = classifyWorktreeError(msg)
|
||||
const errorCode = classifyWorktreeError(msg, { cwd: manager.repo, probeFailed: manager.gitProbeFailed })
|
||||
report(opts, { message: msg, code: errorCode })
|
||||
ctx.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Human-readable worktree health report.
|
||||
*
|
||||
* Exists because the previous answer to "why is Agent Manager empty?" was reading an output channel
|
||||
* full of per-poll failures and guessing. The report states what git and gh actually did, which
|
||||
* worktrees are unhealthy and why, and what is left on disk — in one place, copyable into an issue.
|
||||
*
|
||||
* The JetBrains plugin renders the same sections in the same order (see AgentManagerDiagnosticsAction)
|
||||
* so a report from either client can be read the same way.
|
||||
*/
|
||||
|
||||
import { BUDGET } from "./command-budget"
|
||||
import { GH } from "./gh"
|
||||
import type { WorktreeHealthReport, WorktreeHealth } from "./worktree-reconcile"
|
||||
|
||||
export type ToolProbe = {
|
||||
/** Tool label as the report shows it, e.g. `git`. */
|
||||
name: string
|
||||
/** Version line, or undefined when the probe failed. */
|
||||
version?: string
|
||||
ms: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type DiagnosticsInput = {
|
||||
root: string
|
||||
worktreesDir: string
|
||||
probes: ToolProbe[]
|
||||
report: WorktreeHealthReport | undefined
|
||||
/** Worktrees currently skipped by the pollers, by id. */
|
||||
quarantined: string[]
|
||||
/** Row labels by worktree id, for a report that names things the way the UI does. */
|
||||
labels: Map<string, string>
|
||||
}
|
||||
|
||||
const ORDER: WorktreeHealth[] = ["ok", "absent-restorable", "absent-gone", "unregistered", "unavailable"]
|
||||
|
||||
/** Render the report. Pure string building so it can be asserted in tests. */
|
||||
export function diagnostics(input: DiagnosticsInput): string {
|
||||
const lines: string[] = []
|
||||
lines.push("Kilo Agent Manager — worktree health")
|
||||
lines.push(`repository: ${input.root}`)
|
||||
lines.push(`worktrees: ${input.worktreesDir}`)
|
||||
lines.push("")
|
||||
|
||||
lines.push("tools")
|
||||
for (const probe of input.probes) {
|
||||
const budget = probe.name === GH ? BUDGET.gh : BUDGET.probe
|
||||
const detail = probe.version ?? `FAILED — ${probe.error ?? "unknown error"}`
|
||||
lines.push(` ${probe.name}: ${detail} (${probe.ms}ms, budget ${budget}ms)`)
|
||||
}
|
||||
lines.push("")
|
||||
|
||||
const report = input.report
|
||||
if (!report) {
|
||||
lines.push("worktrees: health has not been determined yet")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
if (report.degraded) {
|
||||
// Worth stating plainly: every entry below reads `unavailable` for one reason, and no
|
||||
// self-healing ran, so the report must not be mistaken for "everything is broken".
|
||||
lines.push("NOTE: git could not list worktrees, so nothing was classified or repaired.")
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
const counts = new Map<WorktreeHealth, number>()
|
||||
for (const entry of report.entries) counts.set(entry.health, (counts.get(entry.health) ?? 0) + 1)
|
||||
lines.push("summary")
|
||||
for (const health of ORDER) lines.push(` ${health}: ${counts.get(health) ?? 0}`)
|
||||
lines.push(` orphan directories: ${report.orphans.length}`)
|
||||
lines.push(` quarantined: ${input.quarantined.length}`)
|
||||
lines.push(` pruned this pass: ${report.pruned}`)
|
||||
lines.push(` state entries dropped: ${report.dropped.length}`)
|
||||
lines.push("")
|
||||
|
||||
lines.push("worktrees")
|
||||
if (report.entries.length === 0) lines.push(" (none tracked)")
|
||||
for (const health of ORDER) {
|
||||
for (const entry of report.entries.filter((item) => item.health === health)) {
|
||||
const label = input.labels.get(entry.id) ?? entry.branch
|
||||
const flags = [
|
||||
`sessions=${entry.sessions}`,
|
||||
input.quarantined.includes(entry.id) ? "quarantined" : undefined,
|
||||
].filter((flag) => flag !== undefined)
|
||||
lines.push(` [${health}] ${label} — ${entry.path} (${flags.join(" ")})`)
|
||||
}
|
||||
}
|
||||
|
||||
if (report.orphans.length > 0) {
|
||||
lines.push("")
|
||||
lines.push("orphan directories (nothing removes these automatically)")
|
||||
for (const orphan of report.orphans) lines.push(` [${orphan.kind}] ${orphan.path}`)
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
/** Time a `--version` probe for one tool, so the report shows what the tools actually did. */
|
||||
export async function probeTool(
|
||||
name: string,
|
||||
run: () => Promise<string>,
|
||||
now: () => number = Date.now,
|
||||
): Promise<ToolProbe> {
|
||||
const started = now()
|
||||
try {
|
||||
return { name, version: (await run()).trim().split("\n")[0], ms: now() - started }
|
||||
} catch (error) {
|
||||
return { name, ms: now() - started, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Collects and shows the worktree-health report for the active project.
|
||||
*
|
||||
* Separate from the report rendering (worktree-diagnostics.ts) so the rendering stays pure and
|
||||
* testable, and separate from AgentManagerProvider so the provider does not grow another concern.
|
||||
*/
|
||||
|
||||
import { execWithShellEnv } from "./shell-env"
|
||||
import { BUDGET } from "./command-budget"
|
||||
import { GH, execGhRead } from "./gh"
|
||||
import { diagnostics, probeTool, type ToolProbe } from "./worktree-diagnostics"
|
||||
import type { ProjectContext } from "./project/context"
|
||||
import type { WorktreeHealthReport } from "./worktree-reconcile"
|
||||
|
||||
export interface DoctorHost {
|
||||
/** Re-run the reconcile so the report reflects the current state rather than the last poll. */
|
||||
reconcile: (ctx: ProjectContext) => Promise<WorktreeHealthReport | undefined>
|
||||
/** Worktrees the pollers are currently skipping. */
|
||||
quarantined: () => string[]
|
||||
show: (text: string) => Promise<void>
|
||||
log: (...args: unknown[]) => void
|
||||
}
|
||||
|
||||
/** Build the report for one project, refreshing health first. */
|
||||
export async function collect(ctx: ProjectContext, host: DoctorHost): Promise<string> {
|
||||
const manager = ctx.worktreeManager()
|
||||
const report = await host.reconcile(ctx)
|
||||
const probes: ToolProbe[] = [
|
||||
await probeTool(
|
||||
"git",
|
||||
async () => (await execWithShellEnv("git", ["--version"], { timeout: BUDGET.probe })).stdout,
|
||||
),
|
||||
// Through execGhRead so the probe cannot be the one gh call that flashes a console on Windows.
|
||||
await probeTool(GH, async () => (await execGhRead(["--version"], { timeout: BUDGET.gh })).stdout),
|
||||
]
|
||||
const state = ctx.peekState()
|
||||
const labels = new Map((state?.getWorktrees() ?? []).map((wt) => [wt.id, wt.label || wt.branch]))
|
||||
return diagnostics({
|
||||
root: ctx.root,
|
||||
worktreesDir: manager.worktreesDir,
|
||||
probes,
|
||||
report,
|
||||
quarantined: host.quarantined(),
|
||||
labels,
|
||||
})
|
||||
}
|
||||
|
||||
/** Run the diagnostics command: collect, log, and reveal the report. */
|
||||
export async function runDoctor(ctx: ProjectContext | undefined, host: DoctorHost): Promise<void> {
|
||||
if (!ctx) {
|
||||
await host.show("Kilo Agent Manager — no project is open.")
|
||||
return
|
||||
}
|
||||
const text = await collect(ctx, host)
|
||||
host.log(`worktree diagnostics:\n${text}`)
|
||||
await host.show(text)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Bridges the worktree-health reconcile into the webview payload and decides when to re-run it.
|
||||
*
|
||||
* Reconcile is the only thing that knows whether a missing worktree can be restored from its branch,
|
||||
* so the presence probe does not guess: it reports what it saw and asks for a fresh reconcile when
|
||||
* the set of unhealthy worktrees changes.
|
||||
*
|
||||
* No vscode imports — the provider owns the plumbing, this owns the policy.
|
||||
*/
|
||||
|
||||
import type { WorktreeHealth, WorktreeHealthReport } from "./worktree-reconcile"
|
||||
|
||||
/** Health of every worktree still present in state, plus any orphaned directories. */
|
||||
export function healthPayload(
|
||||
report: WorktreeHealthReport | undefined,
|
||||
worktrees: { id: string }[],
|
||||
): { worktreeHealth?: Record<string, WorktreeHealth>; orphanDirectories?: string[] } {
|
||||
if (!report) return {}
|
||||
const ids = new Set(worktrees.map((wt) => wt.id))
|
||||
const health: Record<string, WorktreeHealth> = {}
|
||||
for (const entry of report.entries) {
|
||||
if (!ids.has(entry.id)) continue
|
||||
if (entry.health === "ok") continue
|
||||
health[entry.id] = entry.health
|
||||
}
|
||||
// Paths, not just a count: the confirmation dialog has to show exactly what will be deleted, and
|
||||
// the host re-validates every path against the current orphan set before removing anything.
|
||||
return { worktreeHealth: health, orphanDirectories: report.orphans.map((orphan) => orphan.path) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a presence probe into the tracked stale set and sync branches.
|
||||
*
|
||||
* A degraded probe learned nothing, so it must not clear or extend the stale set: "we could not
|
||||
* check" is not "the worktree is fine" and not "the worktree is gone".
|
||||
*/
|
||||
export function applyPresence(
|
||||
result: { worktrees: { worktreeId: string; missing: boolean; branch?: string }[]; degraded: boolean },
|
||||
stale: Set<string>,
|
||||
worktrees: { id: string }[],
|
||||
syncBranch: (id: string, branch: string) => boolean,
|
||||
): { staleChanged: boolean; branchChanged: boolean; degraded: boolean } {
|
||||
const ids = new Set(worktrees.map((wt) => wt.id))
|
||||
for (const id of [...stale]) {
|
||||
if (!ids.has(id)) stale.delete(id)
|
||||
}
|
||||
if (result.degraded) return { staleChanged: false, branchChanged: false, degraded: true }
|
||||
|
||||
const entries = result.worktrees.filter((item) => ids.has(item.worktreeId))
|
||||
if (entries.length === 0) return { staleChanged: false, branchChanged: false, degraded: false }
|
||||
|
||||
const branchChanged = entries.some(
|
||||
(entry) => entry.branch !== undefined && syncBranch(entry.worktreeId, entry.branch),
|
||||
)
|
||||
const next = new Set(entries.filter((entry) => entry.missing).map((entry) => entry.worktreeId))
|
||||
const staleChanged = next.size !== stale.size || [...next].some((id) => !stale.has(id))
|
||||
stale.clear()
|
||||
for (const id of next) stale.add(id)
|
||||
return { staleChanged, branchChanged, degraded: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stale ids that still refer to a tracked worktree, dropping the rest.
|
||||
*
|
||||
* The stale set outlives individual worktrees — a deleted worktree would otherwise stay in it
|
||||
* forever and keep being reported to the webview.
|
||||
*/
|
||||
export function staleForState(stale: Set<string>, worktrees: { id: string }[]): string[] {
|
||||
const ids = new Set(worktrees.map((wt) => wt.id))
|
||||
for (const id of [...stale]) {
|
||||
if (!ids.has(id)) stale.delete(id)
|
||||
}
|
||||
return worktrees.filter((wt) => stale.has(wt.id)).map((wt) => wt.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesces reconcile requests per project.
|
||||
*
|
||||
* The presence probe runs every few seconds; a worktree that disappears would otherwise trigger a
|
||||
* reconcile per tick. One pass per project at a time, delayed enough that a rename or a `git
|
||||
* worktree add` in progress settles first.
|
||||
*/
|
||||
export class HealthScheduler<T extends { id: string }> {
|
||||
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
private readonly running = new Set<string>()
|
||||
|
||||
constructor(
|
||||
private readonly run: (target: T) => Promise<void>,
|
||||
private readonly delay = 2_000,
|
||||
) {}
|
||||
|
||||
schedule(target: T): void {
|
||||
if (this.running.has(target.id)) return
|
||||
const pending = this.timers.get(target.id)
|
||||
if (pending) clearTimeout(pending)
|
||||
const timer = setTimeout(() => {
|
||||
this.timers.delete(target.id)
|
||||
this.running.add(target.id)
|
||||
void this.run(target).finally(() => this.running.delete(target.id))
|
||||
}, this.delay)
|
||||
this.timers.set(target.id, timer)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const timer of this.timers.values()) clearTimeout(timer)
|
||||
this.timers.clear()
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,8 @@ export class WorktreeImporter {
|
||||
private importError(error: unknown, duplicate: string, projectId?: string): void {
|
||||
const raw = error instanceof Error ? error.message : String(error)
|
||||
const message = raw.includes("already used by worktree") || raw.includes("already checked out") ? duplicate : raw
|
||||
const code = classifyWorktreeError(message)
|
||||
const manager = this.host.manager()
|
||||
const code = classifyWorktreeError(message, { cwd: manager?.repo, probeFailed: manager?.gitProbeFailed })
|
||||
this.host.post({ type: "agentManager.worktreeSetup", projectId, status: "error", message, errorCode: code })
|
||||
this.host.post({ type: "agentManager.importResult", projectId, success: false, message, errorCode: code })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Reconciles the three views of a worktree that drift apart over time: the row in
|
||||
* `.kilo/agent-manager.json`, the entry in `git worktree list`, and the directory on disk.
|
||||
*
|
||||
* Drift is normal — users delete worktrees by hand, `git worktree prune` runs elsewhere, branches
|
||||
* get deleted after a merge. What is not acceptable is polling paths that cannot answer, reporting
|
||||
* a failed probe as an empty diff, or claiming git is missing because a directory is.
|
||||
*
|
||||
* Metadata is the only thing this module ever mutates, and only in the one case where nothing can
|
||||
* be lost: the directory is gone, the branch is gone, and no session refers to it. Everything else
|
||||
* is classified, reported, and left for the user to act on. No file is ever deleted here.
|
||||
*
|
||||
* Pure orchestration — no vscode imports, all IO injected.
|
||||
*/
|
||||
|
||||
import * as path from "path"
|
||||
import { pathKey } from "./project/paths"
|
||||
|
||||
export type WorktreeHealth =
|
||||
/** Directory exists and git still tracks it. The only state that gets polled. */
|
||||
| "ok"
|
||||
/** Directory is gone but the branch survives, so the worktree can be recreated. */
|
||||
| "absent-restorable"
|
||||
/** Directory and branch are both gone. */
|
||||
| "absent-gone"
|
||||
/** Directory exists, but git no longer tracks it — usually a hand-deleted `.git/worktrees` entry. */
|
||||
| "unregistered"
|
||||
/** Health could not be determined. Never a reason to mutate or to render as clean. */
|
||||
| "unavailable"
|
||||
|
||||
export type WorktreeHealthEntry = {
|
||||
id: string
|
||||
path: string
|
||||
branch: string
|
||||
health: WorktreeHealth
|
||||
/** How many sessions still point at this worktree. */
|
||||
sessions: number
|
||||
}
|
||||
|
||||
/** A directory under `.kilo/worktrees/` that no state row and no git entry claims. */
|
||||
export type OrphanDirectory = {
|
||||
path: string
|
||||
/** `broken` still has a `.git` file; `leftover` is a bare directory, e.g. only `.kilo-dev/`. */
|
||||
kind: "broken" | "leftover"
|
||||
}
|
||||
|
||||
export type WorktreeHealthReport = {
|
||||
entries: WorktreeHealthEntry[]
|
||||
orphans: OrphanDirectory[]
|
||||
/** State rows dropped automatically, by id. */
|
||||
dropped: string[]
|
||||
/** True when `git worktree prune` ran this pass. */
|
||||
pruned: boolean
|
||||
/**
|
||||
* True when enumeration itself failed. Every entry is `unavailable`, nothing was mutated, and the
|
||||
* caller must not treat any worktree as stale.
|
||||
*/
|
||||
degraded: boolean
|
||||
}
|
||||
|
||||
export interface ReconcileDeps {
|
||||
/** Repository root; relative state paths resolve against it. */
|
||||
root: string
|
||||
/** Absolute `.kilo/worktrees` directory that {@link ReconcileDeps.dirs} lists. */
|
||||
dir: string
|
||||
/** State rows to classify. */
|
||||
rows: () => { id: string; path: string; branch: string }[]
|
||||
/** Session count for a worktree id. */
|
||||
sessions: (id: string) => number
|
||||
/** Normalized paths git currently tracks, or undefined when the listing failed. */
|
||||
registered: () => Promise<Set<string> | undefined>
|
||||
/** Directory names directly under `.kilo/worktrees/`, excluding temp dirs. */
|
||||
dirs: () => Promise<string[]>
|
||||
exists: (target: string) => Promise<boolean>
|
||||
branchExists: (branch: string) => Promise<boolean>
|
||||
/** `git worktree prune`; called at most once per pass. */
|
||||
prune: () => Promise<void>
|
||||
/** Remove a state row. Only ever called for `absent-gone` rows with no sessions. */
|
||||
drop: (id: string) => void
|
||||
log: (msg: string) => void
|
||||
}
|
||||
|
||||
function resolve(root: string, target: string): string {
|
||||
return path.isAbsolute(target) ? target : path.join(root, target)
|
||||
}
|
||||
|
||||
function degraded(rows: { id: string; path: string; branch: string }[], sessions: (id: string) => number) {
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
path: row.path,
|
||||
branch: row.branch,
|
||||
health: "unavailable" as const,
|
||||
sessions: sessions(row.id),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify every tracked worktree and every directory under `.kilo/worktrees/`, prune stale git
|
||||
* metadata once when something is actually stale, and drop only the state rows that cannot lose
|
||||
* anything. Returns the report the UI and the diagnostics command both render.
|
||||
*/
|
||||
export async function reconcileWorktrees(deps: ReconcileDeps): Promise<WorktreeHealthReport> {
|
||||
const rows = deps.rows()
|
||||
const registered = await deps.registered()
|
||||
if (!registered) {
|
||||
deps.log("worktree health: could not list git worktrees, skipping reconcile")
|
||||
return { entries: degraded(rows, deps.sessions), orphans: [], dropped: [], pruned: false, degraded: true }
|
||||
}
|
||||
|
||||
const entries: WorktreeHealthEntry[] = []
|
||||
const claimed = new Set<string>()
|
||||
for (const row of rows) {
|
||||
const abs = resolve(deps.root, row.path)
|
||||
claimed.add(pathKey(abs))
|
||||
const present = await deps.exists(abs)
|
||||
const health = await (async (): Promise<WorktreeHealth> => {
|
||||
if (present) return registered.has(pathKey(abs)) ? "ok" : "unregistered"
|
||||
return (await deps.branchExists(row.branch)) ? "absent-restorable" : "absent-gone"
|
||||
})()
|
||||
entries.push({ id: row.id, path: abs, branch: row.branch, health, sessions: deps.sessions(row.id) })
|
||||
}
|
||||
|
||||
// One prune per pass, and only when a tracked directory really did vanish. Pruning on every
|
||||
// startup would spend a git invocation to discover there is nothing to do.
|
||||
const stale = entries.some((entry) => entry.health === "absent-restorable" || entry.health === "absent-gone")
|
||||
if (stale) await deps.prune()
|
||||
|
||||
const dropped: string[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.health !== "absent-gone" || entry.sessions > 0) continue
|
||||
deps.log(`worktree health: dropping ${entry.id} (${entry.path}, branch ${entry.branch} gone, no sessions)`)
|
||||
deps.drop(entry.id)
|
||||
dropped.push(entry.id)
|
||||
}
|
||||
|
||||
const orphans: OrphanDirectory[] = []
|
||||
for (const name of await deps.dirs()) {
|
||||
const abs = path.join(deps.dir, name)
|
||||
const key = pathKey(abs)
|
||||
if (claimed.has(key) || registered.has(key)) continue
|
||||
const kind = (await deps.exists(path.join(abs, ".git"))) ? "broken" : "leftover"
|
||||
orphans.push({ path: abs, kind })
|
||||
}
|
||||
|
||||
if (orphans.length > 0) {
|
||||
deps.log(`worktree health: ${orphans.length} orphaned directory(ies) under .kilo/worktrees (not removed)`)
|
||||
}
|
||||
return { entries, orphans, dropped, pruned: stale, degraded: false }
|
||||
}
|
||||
|
||||
/** Worktrees that must not be polled: they cannot answer, or answering would be misleading. */
|
||||
export function unhealthy(report: WorktreeHealthReport): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const entry of report.entries) {
|
||||
if (entry.health !== "ok") ids.add(entry.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/** One-line-per-worktree summary shared by the log and the diagnostics report. */
|
||||
export function summarize(report: WorktreeHealthReport): string {
|
||||
const counts = new Map<WorktreeHealth, number>()
|
||||
for (const entry of report.entries) counts.set(entry.health, (counts.get(entry.health) ?? 0) + 1)
|
||||
const parts = [...counts.entries()].map(([health, count]) => `${health}=${count}`)
|
||||
parts.push(`orphans=${report.orphans.length}`)
|
||||
if (report.dropped.length > 0) parts.push(`dropped=${report.dropped.length}`)
|
||||
if (report.pruned) parts.push("pruned")
|
||||
if (report.degraded) parts.push("degraded")
|
||||
return parts.join(" ")
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* User-initiated recovery for unhealthy worktrees.
|
||||
*
|
||||
* Automatic recovery only ever touches metadata (see worktree-reconcile.ts). Everything here is
|
||||
* behind an explicit click because it either writes a checkout or deletes files:
|
||||
*
|
||||
* - restore: re-create a deleted worktree directory from its surviving branch
|
||||
* - forget: drop the state row but keep the conversations, moving them to Local
|
||||
* - clean: delete directories under `.kilo/worktrees/` that no worktree claims
|
||||
*/
|
||||
|
||||
import type { ProjectContext } from "./project/context"
|
||||
import type { WorktreeHealthReport } from "./worktree-reconcile"
|
||||
|
||||
export interface RecoveryHost {
|
||||
post: (message: { type: "error"; message: string; projectId?: string; worktreeId?: string }) => void
|
||||
push: () => void
|
||||
log: (...args: unknown[]) => void
|
||||
/** Re-run the health reconcile after a successful recovery. */
|
||||
reconcile: (ctx: ProjectContext) => Promise<WorktreeHealthReport | undefined>
|
||||
}
|
||||
|
||||
export type RecoveryMessage =
|
||||
| { type: "agentManager.restoreWorktree"; worktreeId: string }
|
||||
| { type: "agentManager.cleanOrphanDirectories"; paths: string[] }
|
||||
|
||||
/** Dispatch a recovery message for the active project. */
|
||||
export async function handleRecovery(
|
||||
m: RecoveryMessage,
|
||||
ctx: ProjectContext | undefined,
|
||||
host: RecoveryHost,
|
||||
): Promise<null> {
|
||||
if (!ctx) return null
|
||||
if (m.type === "agentManager.restoreWorktree") await restoreWorktree(ctx, host, m.worktreeId)
|
||||
if (m.type === "agentManager.cleanOrphanDirectories") await cleanOrphans(ctx, host, m.paths)
|
||||
return null
|
||||
}
|
||||
|
||||
/** Re-create the directory for a worktree whose branch still exists. */
|
||||
export async function restoreWorktree(ctx: ProjectContext, host: RecoveryHost, worktreeId: string): Promise<void> {
|
||||
const state = ctx.peekState()
|
||||
const worktree = state?.getWorktree(worktreeId)
|
||||
if (!state || !worktree) return
|
||||
try {
|
||||
await ctx.worktreeManager().restoreWorktree(worktree.path, worktree.branch)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
host.log(`Failed to restore worktree ${worktreeId}: ${message}`)
|
||||
host.post({ type: "error", projectId: ctx.id, worktreeId, message: `Could not restore the worktree: ${message}` })
|
||||
return
|
||||
}
|
||||
ctx.stale.delete(worktreeId)
|
||||
await host.reconcile(ctx)
|
||||
host.push()
|
||||
host.log(`Restored worktree ${worktreeId} (${worktree.branch})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete orphaned directories under `.kilo/worktrees/`.
|
||||
*
|
||||
* Only paths the last reconcile classified as orphans are accepted, and the manager re-checks that
|
||||
* git does not track them, so a live worktree cannot be deleted through this path even if the
|
||||
* webview sends a stale list.
|
||||
*/
|
||||
export async function cleanOrphans(ctx: ProjectContext, host: RecoveryHost, paths: string[]): Promise<void> {
|
||||
const known = new Set(ctx.report?.orphans.map((orphan) => orphan.path) ?? [])
|
||||
const manager = ctx.worktreeManager()
|
||||
let removed = 0
|
||||
for (const target of paths) {
|
||||
if (!known.has(target)) {
|
||||
host.log(`Ignored cleanup for a path that is not a known orphan: ${target}`)
|
||||
continue
|
||||
}
|
||||
const failure = await manager
|
||||
.removeOrphanDirectory(target)
|
||||
.then(() => undefined)
|
||||
.catch((error: unknown) => (error instanceof Error ? error.message : String(error)))
|
||||
if (failure) {
|
||||
host.log(`Failed to remove orphaned directory ${target}: ${failure}`)
|
||||
host.post({ type: "error", projectId: ctx.id, message: `Could not remove ${target}: ${failure}` })
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
}
|
||||
if (removed === 0) return
|
||||
await host.reconcile(ctx)
|
||||
host.push()
|
||||
host.log(`Removed ${removed} orphaned worktree director${removed === 1 ? "y" : "ies"}`)
|
||||
}
|
||||
@@ -620,6 +620,9 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
vscode.commands.registerCommand("kilo-code.new.agentManager.nextTerminal", () => {
|
||||
agentManagerProvider.postMessage({ type: "action", action: "terminalNext" })
|
||||
}),
|
||||
vscode.commands.registerCommand("kilo-code.new.agentManager.diagnostics", () => {
|
||||
void agentManagerProvider.diagnose()
|
||||
}),
|
||||
vscode.commands.registerCommand("kilo-code.new.agentManager.search", () => {
|
||||
agentManagerProvider.postMessage({ type: "action", action: "search" })
|
||||
}),
|
||||
|
||||
@@ -73,6 +73,7 @@ export enum TelemetryEventName {
|
||||
AGENT_MANAGER_SESSION_STOPPED = "Agent Manager Session Stopped",
|
||||
AGENT_MANAGER_SESSION_ERROR = "Agent Manager Session Error",
|
||||
AGENT_MANAGER_LOGIN_ISSUE = "Agent Manager Login Issue",
|
||||
AGENT_MANAGER_WORKTREE_HEALTH = "Agent Manager Worktree Health",
|
||||
AUTO_PURGE_STARTED = "Auto Purge Started",
|
||||
AUTO_PURGE_COMPLETED = "Auto Purge Completed",
|
||||
AUTO_PURGE_FAILED = "Auto Purge Failed",
|
||||
|
||||
@@ -65,6 +65,7 @@ const TSX_FILES = [
|
||||
path.join(ROOT, "webview-ui/agent-manager/ProjectList.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/ProjectActions.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/SidebarBody.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/OrphanNotice.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/Skeleton.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/TabBar.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/ClosableTab.tsx"),
|
||||
|
||||
@@ -128,8 +128,11 @@ describe("PRStatusPoller batched GitHub queries", () => {
|
||||
headRefOid: exact ? refs.headRefOid : refs.baseRefOid,
|
||||
}
|
||||
if (args.at(1) === "view") {
|
||||
if (lookup === "tracking" || (lookup === "branch" && args.at(2) === "feature"))
|
||||
return { stdout: JSON.stringify(data), stderr: "" }
|
||||
// Explicit-branch form names the branch; the bare form resolves the tracking ref, which is
|
||||
// the only thing that identifies a fork PR checked out with `gh pr checkout`.
|
||||
const explicit = args.at(2) === "feature"
|
||||
if (lookup === "branch" && explicit) return { stdout: JSON.stringify(data), stderr: "" }
|
||||
if (lookup === "tracking" && !explicit) return { stdout: JSON.stringify(data), stderr: "" }
|
||||
throw new Error("no pull requests found for branch")
|
||||
}
|
||||
const filter = args.at(args.indexOf("--state") + 1)
|
||||
@@ -138,17 +141,19 @@ describe("PRStatusPoller batched GitHub queries", () => {
|
||||
|
||||
const result = await internal.fetchPRForBranch("feature", "/repo")
|
||||
expect(result?.state ?? null).toBe(expected)
|
||||
// Explicit branch first: the bare current-branch form has been observed hanging indefinitely in
|
||||
// a worktree, so it is only reached when naming the branch found nothing.
|
||||
expect(calls.map((args) => args.slice(0, 3))).toEqual(
|
||||
lookup === "tracking"
|
||||
? [["pr", "view", "--json"]]
|
||||
: lookup === "branch"
|
||||
lookup === "branch"
|
||||
? [["pr", "view", "feature"]]
|
||||
: lookup === "tracking"
|
||||
? [
|
||||
["pr", "view", "--json"],
|
||||
["pr", "view", "feature"],
|
||||
["pr", "view", "--json"],
|
||||
]
|
||||
: [
|
||||
["pr", "view", "--json"],
|
||||
["pr", "view", "feature"],
|
||||
["pr", "view", "--json"],
|
||||
["pr", "list", "--state"],
|
||||
],
|
||||
)
|
||||
@@ -252,6 +257,7 @@ describe("PRStatusPoller batched GitHub queries", () => {
|
||||
const internal = poller as unknown as {
|
||||
fetchOne: (id: string) => Promise<void>
|
||||
gh: (args: string[]) => Promise<{ stdout: string; stderr: string }>
|
||||
quarantine: { clear: (id: string) => void }
|
||||
}
|
||||
internal.gh = async () => {
|
||||
throw new Error("offline")
|
||||
@@ -260,6 +266,8 @@ describe("PRStatusPoller batched GitHub queries", () => {
|
||||
for (const name of ["feature/a", "feature/a", "feature/b", undefined, "feature/c", new Error("offline")]) {
|
||||
branch = name
|
||||
await expect(internal.fetchOne("wt1")).rejects.toThrow("offline")
|
||||
// Error reporting is independent of failure isolation; quarantine has its own test below.
|
||||
internal.quarantine.clear("wt1")
|
||||
}
|
||||
|
||||
expect(values).toEqual([
|
||||
|
||||
@@ -444,4 +444,40 @@ describe("classifyWorktreeError", () => {
|
||||
expect(classifyWorktreeError("Failed to create worktree: fatal: unknown error")).toBeUndefined()
|
||||
expect(classifyWorktreeError("something went wrong")).toBeUndefined()
|
||||
})
|
||||
|
||||
// A failed spawn reports ENOENT whether the binary or the working directory is missing, so
|
||||
// "install git" must never be inferred from the message alone.
|
||||
it("blames the missing directory, not git, when the cwd is gone", () => {
|
||||
expect(classifyWorktreeError("Error: spawn git ENOENT", { cwd: "/gone", exists: () => false })).toBe(
|
||||
"worktree_missing",
|
||||
)
|
||||
})
|
||||
|
||||
it("still blames git when the cwd exists", () => {
|
||||
expect(classifyWorktreeError("Error: spawn git ENOENT", { cwd: "/repo", exists: () => true })).toBe("git_not_found")
|
||||
})
|
||||
|
||||
it("blames git when the version probe itself failed", () => {
|
||||
expect(
|
||||
classifyWorktreeError("some unrelated failure", { cwd: "/repo", exists: () => true, probeFailed: true }),
|
||||
).toBe("git_not_found")
|
||||
})
|
||||
|
||||
it("separates a broken worktree from a non-repo folder", () => {
|
||||
expect(
|
||||
classifyWorktreeError("fatal: not a git repository: /repo/.git/worktrees/hidden-sparrow", {
|
||||
cwd: "/repo/.kilo/worktrees/hidden-sparrow",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toBe("worktree_unregistered")
|
||||
expect(classifyWorktreeError("fatal: not a git repository", { cwd: "/repo", exists: () => true })).toBe(
|
||||
"not_git_repo",
|
||||
)
|
||||
})
|
||||
|
||||
it("reports a timeout as a timeout", () => {
|
||||
expect(classifyWorktreeError("Git command timed out after 15000ms", { cwd: "/repo", exists: () => true })).toBe(
|
||||
"git_timeout",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -637,7 +637,8 @@ describe("GitStatsPoller", () => {
|
||||
expect(presence[0]).toEqual({
|
||||
worktrees: [
|
||||
{ worktreeId: "a", missing: false, branch: "branch-a" },
|
||||
{ worktreeId: "b", missing: true, branch: undefined },
|
||||
// Directory never created, so the probe must report absence rather than a bare "missing".
|
||||
{ worktreeId: "b", missing: true, reason: "absent" },
|
||||
],
|
||||
degraded: false,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { Quarantine } from "../../src/agent-manager/quarantine"
|
||||
import { PRStatusPoller } from "../../src/agent-manager/PRStatusPoller"
|
||||
import {
|
||||
QUARANTINE_BASE,
|
||||
QUARANTINE_THRESHOLD,
|
||||
isTimeout,
|
||||
quarantineWindow,
|
||||
} from "../../src/agent-manager/command-budget"
|
||||
|
||||
describe("Quarantine", () => {
|
||||
it("tolerates isolated failures", () => {
|
||||
const q = new Quarantine()
|
||||
|
||||
for (let i = 0; i < QUARANTINE_THRESHOLD - 1; i++) expect(q.fail("wt1")).toBe(false)
|
||||
|
||||
expect(q.blocked("wt1")).toBe(false)
|
||||
})
|
||||
|
||||
it("blocks a worktree after repeated failures", () => {
|
||||
let now = 1_000
|
||||
const q = new Quarantine(() => now)
|
||||
|
||||
for (let i = 0; i < QUARANTINE_THRESHOLD; i++) q.fail("wt1")
|
||||
|
||||
expect(q.blocked("wt1")).toBe(true)
|
||||
now += QUARANTINE_BASE - 1
|
||||
expect(q.blocked("wt1")).toBe(true)
|
||||
now += 2
|
||||
// Window elapsed: exactly one attempt is allowed through.
|
||||
expect(q.blocked("wt1")).toBe(false)
|
||||
})
|
||||
|
||||
it("keeps other worktrees unaffected", () => {
|
||||
const q = new Quarantine()
|
||||
|
||||
for (let i = 0; i < QUARANTINE_THRESHOLD + 2; i++) q.fail("broken")
|
||||
|
||||
expect(q.blocked("broken")).toBe(true)
|
||||
expect(q.blocked("healthy")).toBe(false)
|
||||
})
|
||||
|
||||
it("forgets history after a success", () => {
|
||||
let now = 0
|
||||
const q = new Quarantine(() => now)
|
||||
for (let i = 0; i < QUARANTINE_THRESHOLD; i++) q.fail("wt1")
|
||||
|
||||
q.clear("wt1")
|
||||
|
||||
expect(q.blocked("wt1")).toBe(false)
|
||||
expect(q.failures("wt1")).toBe(0)
|
||||
})
|
||||
|
||||
it("widens the window as failures pile up, up to the cap", () => {
|
||||
expect(quarantineWindow(QUARANTINE_THRESHOLD)).toBe(QUARANTINE_BASE)
|
||||
expect(quarantineWindow(QUARANTINE_THRESHOLD + 1)).toBe(QUARANTINE_BASE * 2)
|
||||
expect(quarantineWindow(QUARANTINE_THRESHOLD + 50)).toBe(30 * 60_000)
|
||||
})
|
||||
|
||||
it("drops worktrees that no longer exist", () => {
|
||||
const q = new Quarantine()
|
||||
for (let i = 0; i < QUARANTINE_THRESHOLD; i++) q.fail("gone")
|
||||
|
||||
q.retain(new Set(["kept"]))
|
||||
|
||||
expect(q.failures("gone")).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isTimeout", () => {
|
||||
it("recognizes a killed child process", () => {
|
||||
const err = Object.assign(new Error("Command failed: gh pr view"), { killed: true, signal: "SIGTERM" })
|
||||
|
||||
expect(isTimeout(err)).toBe(true)
|
||||
})
|
||||
|
||||
it("recognizes an explicit timeout message", () => {
|
||||
expect(isTimeout(new Error("Git command timed out after 15000ms"))).toBe(true)
|
||||
expect(isTimeout("git command timed out")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not mistake ordinary failures for timeouts", () => {
|
||||
expect(isTimeout(new Error("Command failed: gh pr view\nno pull requests found"))).toBe(false)
|
||||
expect(isTimeout(Object.assign(new Error("boom"), { killed: false, signal: null }))).toBe(false)
|
||||
expect(isTimeout(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("PRStatusPoller failure isolation", () => {
|
||||
type Internal = {
|
||||
fetchOne: (id: string) => Promise<void>
|
||||
gh: (args: string[]) => Promise<{ stdout: string; stderr: string }>
|
||||
target: (id: string) => unknown
|
||||
quarantine: Quarantine
|
||||
}
|
||||
|
||||
function poller(onStatus: (id: string, error?: string) => void) {
|
||||
const worktrees = [
|
||||
{ id: "broken", path: process.cwd(), branch: "broken", parentBranch: "main", createdAt: "" },
|
||||
{ id: "healthy", path: process.cwd(), branch: "healthy", parentBranch: "main", createdAt: "" },
|
||||
]
|
||||
const instance = new PRStatusPoller({
|
||||
getWorktrees: () => worktrees as never,
|
||||
getWorkspaceRoot: () => process.cwd(),
|
||||
onStatus: (id, _pr, error) => onStatus(id, error),
|
||||
log: () => undefined,
|
||||
})
|
||||
return { instance, internal: instance as unknown as Internal }
|
||||
}
|
||||
|
||||
it("stops polling a worktree that keeps failing and leaves others alone", async () => {
|
||||
const seen: string[] = []
|
||||
const { instance, internal } = poller((id, error) => seen.push(`${id}:${error ?? "ok"}`))
|
||||
internal.gh = async () => {
|
||||
throw new Error("offline")
|
||||
}
|
||||
|
||||
for (let i = 0; i < QUARANTINE_THRESHOLD; i++) {
|
||||
await internal.fetchOne("broken").catch(() => undefined)
|
||||
}
|
||||
const attempts = seen.length
|
||||
// Quarantined: no further work is attempted for this worktree.
|
||||
await internal.fetchOne("broken")
|
||||
|
||||
expect(attempts).toBeGreaterThan(0)
|
||||
expect(seen.length).toBe(attempts)
|
||||
expect(internal.target("broken")).toBeUndefined()
|
||||
expect(internal.target("healthy")).toBeDefined()
|
||||
instance.stop()
|
||||
})
|
||||
|
||||
it("lets an explicit refresh override a quarantine", () => {
|
||||
const { instance, internal } = poller(() => undefined)
|
||||
for (let i = 0; i < QUARANTINE_THRESHOLD; i++) internal.quarantine.fail("broken")
|
||||
expect(internal.target("broken")).toBeUndefined()
|
||||
|
||||
instance.refresh("broken")
|
||||
|
||||
expect(internal.quarantine.failures("broken")).toBe(0)
|
||||
expect(internal.target("broken")).toBeDefined()
|
||||
instance.stop()
|
||||
})
|
||||
|
||||
it("skips worktrees the health reconcile marked unhealthy", () => {
|
||||
const instance = new PRStatusPoller({
|
||||
getWorktrees: () =>
|
||||
[{ id: "unregistered", path: process.cwd(), branch: "x", parentBranch: "main", createdAt: "" }] as never,
|
||||
getWorkspaceRoot: () => process.cwd(),
|
||||
onStatus: () => undefined,
|
||||
isUnhealthy: (id) => id === "unregistered",
|
||||
log: () => undefined,
|
||||
})
|
||||
|
||||
expect((instance as unknown as Internal).target("unregistered")).toBeUndefined()
|
||||
instance.stop()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { diagnostics, probeTool } from "../../src/agent-manager/worktree-diagnostics"
|
||||
import type { WorktreeHealthReport } from "../../src/agent-manager/worktree-reconcile"
|
||||
|
||||
function report(overrides: Partial<WorktreeHealthReport> = {}): WorktreeHealthReport {
|
||||
return { entries: [], orphans: [], dropped: [], pruned: false, degraded: false, ...overrides }
|
||||
}
|
||||
|
||||
const probes = [
|
||||
{ name: "git" as const, version: "git version 2.51.0", ms: 12 },
|
||||
{ name: "gh" as const, version: "gh version 2.88.0", ms: 30 },
|
||||
]
|
||||
|
||||
describe("diagnostics", () => {
|
||||
it("reports tools, counts, and every worktree with its reason", () => {
|
||||
const text = diagnostics({
|
||||
root: "/repo",
|
||||
worktreesDir: "/repo/.kilo/worktrees",
|
||||
probes,
|
||||
report: report({
|
||||
entries: [
|
||||
{ id: "a", path: "/repo/.kilo/worktrees/a", branch: "alive", health: "ok", sessions: 1 },
|
||||
{ id: "b", path: "/repo/.kilo/worktrees/b", branch: "gone-dir", health: "absent-restorable", sessions: 2 },
|
||||
{ id: "c", path: "/repo/.kilo/worktrees/c", branch: "broken", health: "unregistered", sessions: 0 },
|
||||
],
|
||||
orphans: [{ path: "/repo/.kilo/worktrees/leftover", kind: "leftover" }],
|
||||
pruned: true,
|
||||
dropped: ["d"],
|
||||
}),
|
||||
quarantined: ["c"],
|
||||
labels: new Map([["a", "Alive row"]]),
|
||||
})
|
||||
|
||||
expect(text).toContain("git: git version 2.51.0 (12ms, budget 5000ms)")
|
||||
expect(text).toContain("gh: gh version 2.88.0 (30ms, budget 10000ms)")
|
||||
expect(text).toContain(" ok: 1")
|
||||
expect(text).toContain(" absent-restorable: 1")
|
||||
expect(text).toContain(" unregistered: 1")
|
||||
expect(text).toContain(" orphan directories: 1")
|
||||
expect(text).toContain(" quarantined: 1")
|
||||
expect(text).toContain(" pruned this pass: true")
|
||||
expect(text).toContain(" state entries dropped: 1")
|
||||
// Row labels match what the UI shows, so a report can be matched to a row.
|
||||
expect(text).toContain("[ok] Alive row — /repo/.kilo/worktrees/a (sessions=1)")
|
||||
expect(text).toContain("[unregistered] broken — /repo/.kilo/worktrees/c (sessions=0 quarantined)")
|
||||
expect(text).toContain("[leftover] /repo/.kilo/worktrees/leftover")
|
||||
})
|
||||
|
||||
it("says plainly when a failed tool is the reason everything looks broken", () => {
|
||||
const text = diagnostics({
|
||||
root: "/repo",
|
||||
worktreesDir: "/repo/.kilo/worktrees",
|
||||
probes: [{ name: "git", ms: 5000, error: "spawn git ENOENT" }],
|
||||
report: report({
|
||||
entries: [{ id: "a", path: "/a", branch: "x", health: "unavailable", sessions: 0 }],
|
||||
degraded: true,
|
||||
}),
|
||||
quarantined: [],
|
||||
labels: new Map(),
|
||||
})
|
||||
|
||||
expect(text).toContain("git: FAILED — spawn git ENOENT")
|
||||
expect(text).toContain("NOTE: git could not list worktrees, so nothing was classified or repaired.")
|
||||
expect(text).toContain(" unavailable: 1")
|
||||
})
|
||||
|
||||
it("does not pretend to know health before the first reconcile", () => {
|
||||
const text = diagnostics({
|
||||
root: "/repo",
|
||||
worktreesDir: "/repo/.kilo/worktrees",
|
||||
probes,
|
||||
report: undefined,
|
||||
quarantined: [],
|
||||
labels: new Map(),
|
||||
})
|
||||
|
||||
expect(text).toContain("health has not been determined yet")
|
||||
})
|
||||
})
|
||||
|
||||
describe("probeTool", () => {
|
||||
it("keeps the first version line and the elapsed time", async () => {
|
||||
let now = 100
|
||||
const probe = await probeTool(
|
||||
"git",
|
||||
async () => "git version 2.51.0\nextra",
|
||||
() => (now += 7),
|
||||
)
|
||||
|
||||
expect(probe).toEqual({ name: "git", version: "git version 2.51.0", ms: 7 })
|
||||
})
|
||||
|
||||
it("records the failure instead of throwing", async () => {
|
||||
const probe = await probeTool(
|
||||
"gh",
|
||||
async () => {
|
||||
throw new Error("spawn gh ENOENT")
|
||||
},
|
||||
() => 0,
|
||||
)
|
||||
|
||||
expect(probe.version).toBeUndefined()
|
||||
expect(probe.error).toBe("spawn gh ENOENT")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { HealthScheduler, applyPresence, healthPayload } from "../../src/agent-manager/worktree-health"
|
||||
import { formatLog } from "../../src/agent-manager/log-format"
|
||||
import type { WorktreeHealthReport } from "../../src/agent-manager/worktree-reconcile"
|
||||
|
||||
function report(overrides: Partial<WorktreeHealthReport> = {}): WorktreeHealthReport {
|
||||
return { entries: [], orphans: [], dropped: [], pruned: false, degraded: false, ...overrides }
|
||||
}
|
||||
|
||||
describe("healthPayload", () => {
|
||||
it("sends only unhealthy worktrees plus the orphan paths", () => {
|
||||
const payload = healthPayload(
|
||||
report({
|
||||
entries: [
|
||||
{ id: "a", path: "/a", branch: "a", health: "ok", sessions: 0 },
|
||||
{ id: "b", path: "/b", branch: "b", health: "absent-restorable", sessions: 1 },
|
||||
],
|
||||
orphans: [{ path: "/o", kind: "leftover" }],
|
||||
}),
|
||||
[{ id: "a" }, { id: "b" }],
|
||||
)
|
||||
|
||||
// Paths, not a count: the confirmation dialog has to name what it would delete.
|
||||
expect(payload).toEqual({ worktreeHealth: { b: "absent-restorable" }, orphanDirectories: ["/o"] })
|
||||
})
|
||||
|
||||
it("drops worktrees that are no longer in state", () => {
|
||||
const payload = healthPayload(
|
||||
report({ entries: [{ id: "gone", path: "/g", branch: "g", health: "unregistered", sessions: 0 }] }),
|
||||
[],
|
||||
)
|
||||
|
||||
expect(payload).toEqual({ worktreeHealth: {}, orphanDirectories: [] })
|
||||
})
|
||||
|
||||
it("sends nothing before the first reconcile", () => {
|
||||
expect(healthPayload(undefined, [{ id: "a" }])).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyPresence", () => {
|
||||
it("tracks newly missing worktrees and syncs branches", () => {
|
||||
const stale = new Set<string>()
|
||||
const branches: string[] = []
|
||||
|
||||
const applied = applyPresence(
|
||||
{
|
||||
worktrees: [
|
||||
{ worktreeId: "a", missing: false, branch: "main" },
|
||||
{ worktreeId: "b", missing: true },
|
||||
],
|
||||
degraded: false,
|
||||
},
|
||||
stale,
|
||||
[{ id: "a" }, { id: "b" }],
|
||||
(id, branch) => {
|
||||
branches.push(`${id}:${branch}`)
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
expect(applied).toEqual({ staleChanged: true, branchChanged: true, degraded: false })
|
||||
expect([...stale]).toEqual(["b"])
|
||||
expect(branches).toEqual(["a:main"])
|
||||
})
|
||||
|
||||
it("reports no change when the stale set is unchanged", () => {
|
||||
const stale = new Set(["b"])
|
||||
|
||||
const applied = applyPresence(
|
||||
{ worktrees: [{ worktreeId: "b", missing: true }], degraded: false },
|
||||
stale,
|
||||
[{ id: "b" }],
|
||||
() => false,
|
||||
)
|
||||
|
||||
expect(applied.staleChanged).toBe(false)
|
||||
expect([...stale]).toEqual(["b"])
|
||||
})
|
||||
|
||||
// "Could not check" must never be confused with "checked, and it is fine".
|
||||
it("leaves the stale set alone when the probe is degraded", () => {
|
||||
const stale = new Set(["b"])
|
||||
|
||||
const applied = applyPresence({ worktrees: [], degraded: true }, stale, [{ id: "b" }], () => false)
|
||||
|
||||
expect(applied).toEqual({ staleChanged: false, branchChanged: false, degraded: true })
|
||||
expect([...stale]).toEqual(["b"])
|
||||
})
|
||||
|
||||
it("forgets worktrees that left state entirely", () => {
|
||||
const stale = new Set(["removed"])
|
||||
|
||||
applyPresence({ worktrees: [], degraded: false }, stale, [{ id: "kept" }], () => false)
|
||||
|
||||
expect([...stale]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("HealthScheduler", () => {
|
||||
it("coalesces repeated requests into one run", async () => {
|
||||
const runs: string[] = []
|
||||
const scheduler = new HealthScheduler<{ id: string }>(async (target) => {
|
||||
runs.push(target.id)
|
||||
}, 5)
|
||||
|
||||
scheduler.schedule({ id: "p1" })
|
||||
scheduler.schedule({ id: "p1" })
|
||||
scheduler.schedule({ id: "p1" })
|
||||
await Bun.sleep(30)
|
||||
|
||||
expect(runs).toEqual(["p1"])
|
||||
})
|
||||
|
||||
it("keeps projects independent", async () => {
|
||||
const runs: string[] = []
|
||||
const scheduler = new HealthScheduler<{ id: string }>(async (target) => {
|
||||
runs.push(target.id)
|
||||
}, 5)
|
||||
|
||||
scheduler.schedule({ id: "p1" })
|
||||
scheduler.schedule({ id: "p2" })
|
||||
await Bun.sleep(30)
|
||||
|
||||
expect(runs.sort()).toEqual(["p1", "p2"])
|
||||
})
|
||||
|
||||
it("does not schedule while a run is in flight", async () => {
|
||||
const runs: string[] = []
|
||||
const scheduler = new HealthScheduler<{ id: string }>(async (target) => {
|
||||
runs.push(target.id)
|
||||
await Bun.sleep(20)
|
||||
}, 5)
|
||||
|
||||
scheduler.schedule({ id: "p1" })
|
||||
await Bun.sleep(10)
|
||||
scheduler.schedule({ id: "p1" })
|
||||
await Bun.sleep(40)
|
||||
|
||||
expect(runs).toEqual(["p1"])
|
||||
})
|
||||
|
||||
it("drops pending work on dispose", async () => {
|
||||
const runs: string[] = []
|
||||
const scheduler = new HealthScheduler<{ id: string }>(async (target) => {
|
||||
runs.push(target.id)
|
||||
}, 10)
|
||||
|
||||
scheduler.schedule({ id: "p1" })
|
||||
scheduler.dispose()
|
||||
await Bun.sleep(30)
|
||||
|
||||
expect(runs).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatLog", () => {
|
||||
// `Agent Manager request recovery failed: {}` — the reason a real failure logged as nothing.
|
||||
it("keeps the stack of an Error instead of stringifying it to {}", () => {
|
||||
const line = formatLog(["boom:", new Error("kaboom")])
|
||||
|
||||
expect(JSON.stringify(new Error("kaboom"))).toBe("{}")
|
||||
expect(line).toContain("boom:")
|
||||
expect(line).toContain("Error: kaboom")
|
||||
expect(line).toContain("worktree-health.test")
|
||||
})
|
||||
|
||||
it("renders objects readably and strings verbatim", () => {
|
||||
expect(formatLog(["count", { files: 2 }])).toBe("count { files: 2 }")
|
||||
})
|
||||
})
|
||||
@@ -949,6 +949,110 @@ describe("WorktreeManager.discoverWorktrees", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WorktreeManager -- scanWorktrees / restore / orphan removal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("WorktreeManager.scanWorktrees", () => {
|
||||
it("keeps the reason a directory is not usable", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
|
||||
const live = await mgr.createWorktree({ prompt: "live" })
|
||||
const leftover = path.join(root, ".kilo", "worktrees", "leftover")
|
||||
await fs.mkdir(path.join(leftover, ".kilo-dev"), { recursive: true })
|
||||
const broken = await mgr.createWorktree({ prompt: "broken" })
|
||||
// Hand-deleted registration: directory intact, git metadata gone.
|
||||
await fs.rm(path.join(root, ".git", "worktrees", path.basename(broken.path)), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
})
|
||||
|
||||
const probes = await mgr.scanWorktrees()
|
||||
const byPath = new Map(probes.map((probe) => [probe.ok ? probe.info.path : probe.path, probe]))
|
||||
|
||||
expect(byPath.get(live.path)?.ok).toBe(true)
|
||||
expect(byPath.get(leftover)).toEqual({ ok: false, path: leftover, reason: "leftover" })
|
||||
expect(byPath.get(broken.path)).toEqual({ ok: false, path: broken.path, reason: "unregistered" })
|
||||
// discoverWorktrees keeps its old contract: healthy worktrees only.
|
||||
expect((await mgr.discoverWorktrees()).map((info) => info.path)).toEqual([live.path])
|
||||
})
|
||||
|
||||
it("reports registered paths through a single git listing", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
const wt = await mgr.createWorktree({ prompt: "registered" })
|
||||
|
||||
const registered = await mgr.registeredPaths()
|
||||
|
||||
expect(registered?.size).toBe(2) // main checkout + the new worktree
|
||||
expect(await mgr.worktreeDirs()).toEqual([path.basename(wt.path)])
|
||||
})
|
||||
})
|
||||
|
||||
describe("WorktreeManager.restoreWorktree", () => {
|
||||
it("recreates a deleted worktree from its branch", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
const wt = await mgr.createWorktree({ prompt: "restore-me" })
|
||||
await fs.writeFile(path.join(wt.path, "work.txt"), "committed work")
|
||||
gitExec(["git", "-C", wt.path, "add", "."])
|
||||
gitExec(["git", "-C", wt.path, "commit", "-m", "work"])
|
||||
await fs.rm(wt.path, { recursive: true, force: true })
|
||||
|
||||
await mgr.restoreWorktree(wt.path, wt.branch)
|
||||
|
||||
expect(existsSync(path.join(wt.path, "work.txt"))).toBe(true)
|
||||
expect((await mgr.discoverWorktrees()).map((info) => info.branch)).toEqual([wt.branch])
|
||||
})
|
||||
|
||||
it("refuses paths outside the managed directory", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
|
||||
await expect(mgr.restoreWorktree(path.join(root, "elsewhere"), "main")).rejects.toThrow(/outside/)
|
||||
})
|
||||
|
||||
it("refuses to overwrite an existing directory", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
const wt = await mgr.createWorktree({ prompt: "occupied" })
|
||||
|
||||
await expect(mgr.restoreWorktree(wt.path, wt.branch)).rejects.toThrow(/already exists/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("WorktreeManager.removeOrphanDirectory", () => {
|
||||
it("removes an untracked leftover directory", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
const leftover = path.join(root, ".kilo", "worktrees", "leftover")
|
||||
await fs.mkdir(path.join(leftover, ".kilo-dev"), { recursive: true })
|
||||
|
||||
await mgr.removeOrphanDirectory(leftover)
|
||||
|
||||
expect(existsSync(leftover)).toBe(false)
|
||||
})
|
||||
|
||||
it("refuses to remove a live worktree", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
const wt = await mgr.createWorktree({ prompt: "live" })
|
||||
|
||||
await expect(mgr.removeOrphanDirectory(wt.path)).rejects.toThrow(/live worktree/)
|
||||
expect(existsSync(wt.path)).toBe(true)
|
||||
})
|
||||
|
||||
it("refuses paths outside the managed directory", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
await fs.mkdir(path.join(root, "outside"), { recursive: true })
|
||||
|
||||
await expect(mgr.removeOrphanDirectory(path.join(root, "outside"))).rejects.toThrow(/outside/)
|
||||
expect(existsSync(path.join(root, "outside"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WorktreeManager -- ensureGitExclude
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
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 { WorktreeManager } from "../../src/agent-manager/WorktreeManager"
|
||||
import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
|
||||
import { reconcileWorktrees, summarize, unhealthy } from "../../src/agent-manager/worktree-reconcile"
|
||||
|
||||
// Real git repositories in temp dirs: the whole point of this module is agreeing with git.
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
function git(args: string[]) {
|
||||
const res = Bun.spawnSync(args, { stdout: "pipe", stderr: "pipe" })
|
||||
if (res.exitCode !== 0) {
|
||||
throw new Error(`git failed (${args.join(" ")}): ${Buffer.from(res.stderr).toString("utf8")}`)
|
||||
}
|
||||
return Buffer.from(res.stdout).toString("utf8")
|
||||
}
|
||||
|
||||
async function repo(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-health-"))
|
||||
tempDirs.push(dir)
|
||||
git(["git", "init", "-b", "main", dir])
|
||||
git(["git", "-C", dir, "config", "user.email", "test@test.com"])
|
||||
git(["git", "-C", dir, "config", "user.name", "Test"])
|
||||
await fs.writeFile(path.join(dir, "README.md"), "init")
|
||||
git(["git", "-C", dir, "add", "."])
|
||||
git(["git", "-C", dir, "commit", "-m", "initial"])
|
||||
return dir
|
||||
}
|
||||
|
||||
/** Add a real worktree under `.kilo/worktrees/<name>` on its own branch. */
|
||||
async function worktree(root: string, name: string): Promise<string> {
|
||||
const target = path.join(root, ".kilo", "worktrees", name)
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
git(["git", "-C", root, "worktree", "add", "-b", name, target])
|
||||
return target
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
root: string
|
||||
manager: WorktreeManager
|
||||
state: WorktreeStateManager
|
||||
logs: string[]
|
||||
run: () => ReturnType<typeof reconcileWorktrees>
|
||||
}
|
||||
|
||||
async function harness(): Promise<Harness> {
|
||||
const root = await repo()
|
||||
const logs: string[] = []
|
||||
const manager = new WorktreeManager(root, (msg) => logs.push(msg))
|
||||
const state = new WorktreeStateManager(root, (msg) => logs.push(msg))
|
||||
const run = () =>
|
||||
reconcileWorktrees({
|
||||
root,
|
||||
dir: manager.worktreesDir,
|
||||
rows: () => state.getWorktrees().map((wt) => ({ id: wt.id, path: wt.path, branch: wt.branch })),
|
||||
sessions: (id) => state.getSessions(id).length,
|
||||
registered: () => manager.registeredPaths(),
|
||||
dirs: () => manager.worktreeDirs(),
|
||||
exists: (target) =>
|
||||
fs.access(target).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
branchExists: (branch) => manager.branchExists(branch),
|
||||
prune: () => manager.pruneWorktrees(),
|
||||
drop: (id) => state.removeWorktree(id),
|
||||
log: (msg) => logs.push(msg),
|
||||
})
|
||||
return { root, manager, state, logs, run }
|
||||
}
|
||||
|
||||
describe("reconcileWorktrees", () => {
|
||||
it("reports a live worktree as ok and leaves it alone", async () => {
|
||||
const h = await harness()
|
||||
const dir = await worktree(h.root, "alive")
|
||||
h.state.addWorktree({ branch: "alive", path: dir, parentBranch: "main" })
|
||||
|
||||
const report = await h.run()
|
||||
|
||||
expect(report.entries).toHaveLength(1)
|
||||
expect(report.entries[0].health).toBe("ok")
|
||||
expect(report.dropped).toEqual([])
|
||||
expect(report.pruned).toBe(false)
|
||||
expect(unhealthy(report).size).toBe(0)
|
||||
expect(h.state.getWorktrees()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("keeps a row whose directory is gone but whose branch survives", async () => {
|
||||
const h = await harness()
|
||||
const dir = await worktree(h.root, "restorable")
|
||||
const row = h.state.addWorktree({ branch: "restorable", path: dir, parentBranch: "main" })
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
|
||||
const report = await h.run()
|
||||
|
||||
expect(report.entries[0].health).toBe("absent-restorable")
|
||||
expect(report.pruned).toBe(true)
|
||||
expect(report.dropped).toEqual([])
|
||||
expect(h.state.getWorktree(row.id)).toBeTruthy()
|
||||
// Pruning is what makes the branch reusable for a later restore.
|
||||
expect(git(["git", "-C", h.root, "worktree", "list", "--porcelain"])).not.toContain("restorable")
|
||||
})
|
||||
|
||||
it("drops a row only when directory, branch, and sessions are all gone", async () => {
|
||||
const h = await harness()
|
||||
const dir = await worktree(h.root, "expendable")
|
||||
const row = h.state.addWorktree({ branch: "expendable", path: dir, parentBranch: "main" })
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
git(["git", "-C", h.root, "worktree", "prune"])
|
||||
git(["git", "-C", h.root, "branch", "-D", "expendable"])
|
||||
|
||||
const report = await h.run()
|
||||
|
||||
expect(report.entries[0].health).toBe("absent-gone")
|
||||
expect(report.dropped).toEqual([row.id])
|
||||
expect(h.state.getWorktrees()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("never drops a row that still owns sessions", async () => {
|
||||
const h = await harness()
|
||||
const dir = await worktree(h.root, "has-history")
|
||||
const row = h.state.addWorktree({ branch: "has-history", path: dir, parentBranch: "main" })
|
||||
h.state.addSession("ses_1", row.id)
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
git(["git", "-C", h.root, "worktree", "prune"])
|
||||
git(["git", "-C", h.root, "branch", "-D", "has-history"])
|
||||
|
||||
const report = await h.run()
|
||||
|
||||
expect(report.entries[0].health).toBe("absent-gone")
|
||||
expect(report.entries[0].sessions).toBe(1)
|
||||
expect(report.dropped).toEqual([])
|
||||
expect(h.state.getWorktree(row.id)).toBeTruthy()
|
||||
expect(h.state.getSession("ses_1")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("flags a directory git no longer tracks as unregistered", async () => {
|
||||
const h = await harness()
|
||||
const dir = await worktree(h.root, "orphaned")
|
||||
h.state.addWorktree({ branch: "orphaned", path: dir, parentBranch: "main" })
|
||||
// Exactly what a hand-deleted registration looks like: directory intact, metadata gone.
|
||||
await fs.rm(path.join(h.root, ".git", "worktrees", "orphaned"), { recursive: true, force: true })
|
||||
|
||||
const report = await h.run()
|
||||
|
||||
expect(report.entries[0].health).toBe("unregistered")
|
||||
expect(report.dropped).toEqual([])
|
||||
expect(report.orphans).toEqual([])
|
||||
})
|
||||
|
||||
it("reports untracked directories as orphans without deleting them", async () => {
|
||||
const h = await harness()
|
||||
const leftover = path.join(h.manager.worktreesDir, "leftover")
|
||||
await fs.mkdir(path.join(leftover, ".kilo-dev"), { recursive: true })
|
||||
const broken = path.join(h.manager.worktreesDir, "broken")
|
||||
await fs.mkdir(broken, { recursive: true })
|
||||
await fs.writeFile(path.join(broken, ".git"), "gitdir: /nowhere\n")
|
||||
|
||||
const report = await h.run()
|
||||
|
||||
expect(report.orphans).toEqual([
|
||||
{ path: broken, kind: "broken" },
|
||||
{ path: leftover, kind: "leftover" },
|
||||
])
|
||||
// Reported, never removed.
|
||||
expect(await fs.readdir(leftover)).toEqual([".kilo-dev"])
|
||||
expect(await fs.readdir(broken)).toEqual([".git"])
|
||||
})
|
||||
|
||||
it("does not count a live worktree as an orphan", async () => {
|
||||
const h = await harness()
|
||||
await worktree(h.root, "tracked-by-git-only")
|
||||
|
||||
const report = await h.run()
|
||||
|
||||
expect(report.orphans).toEqual([])
|
||||
expect(report.entries).toEqual([])
|
||||
})
|
||||
|
||||
it("mutates nothing when git cannot be listed", async () => {
|
||||
const h = await harness()
|
||||
const dir = await worktree(h.root, "unknown")
|
||||
const row = h.state.addWorktree({ branch: "unknown", path: dir, parentBranch: "main" })
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
let pruned = false
|
||||
|
||||
const report = await reconcileWorktrees({
|
||||
root: h.root,
|
||||
dir: h.manager.worktreesDir,
|
||||
rows: () => h.state.getWorktrees().map((wt) => ({ id: wt.id, path: wt.path, branch: wt.branch })),
|
||||
sessions: () => 0,
|
||||
registered: async () => undefined,
|
||||
dirs: () => h.manager.worktreeDirs(),
|
||||
exists: async () => false,
|
||||
branchExists: async () => false,
|
||||
prune: async () => {
|
||||
pruned = true
|
||||
},
|
||||
drop: () => {
|
||||
throw new Error("must not drop rows while health is unknown")
|
||||
},
|
||||
log: (msg) => h.logs.push(msg),
|
||||
})
|
||||
|
||||
expect(report.degraded).toBe(true)
|
||||
expect(report.entries[0].health).toBe("unavailable")
|
||||
expect(report.dropped).toEqual([])
|
||||
expect(pruned).toBe(false)
|
||||
expect(h.state.getWorktree(row.id)).toBeTruthy()
|
||||
})
|
||||
|
||||
it("resolves rows stored as relative paths", async () => {
|
||||
const h = await harness()
|
||||
await worktree(h.root, "relative")
|
||||
h.state.addWorktree({ branch: "relative", path: ".kilo/worktrees/relative", parentBranch: "main" })
|
||||
|
||||
const report = await h.run()
|
||||
|
||||
expect(report.entries[0].health).toBe("ok")
|
||||
expect(report.orphans).toEqual([])
|
||||
})
|
||||
|
||||
it("summarizes counts for the log and diagnostics report", async () => {
|
||||
const h = await harness()
|
||||
const dir = await worktree(h.root, "alive")
|
||||
h.state.addWorktree({ branch: "alive", path: dir, parentBranch: "main" })
|
||||
await fs.mkdir(path.join(h.manager.worktreesDir, "leftover"), { recursive: true })
|
||||
|
||||
expect(summarize(await h.run())).toBe("ok=1 orphans=1")
|
||||
})
|
||||
})
|
||||
@@ -574,50 +574,9 @@ describe("WorktreeStateManager", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("validate", () => {
|
||||
it("removes worktrees whose directories do not exist and prunes their sessions", async () => {
|
||||
const existing = path.join(root, "wt-exists")
|
||||
fs.mkdirSync(existing, { recursive: true })
|
||||
|
||||
manager.addWorktree({ branch: "exists", path: existing, parentBranch: "main" })
|
||||
const gone = manager.addWorktree({ branch: "gone", path: path.join(root, "wt-gone"), parentBranch: "main" })
|
||||
manager.addSession("s1", gone.id)
|
||||
|
||||
await manager.validate(root)
|
||||
|
||||
expect(manager.getWorktrees()).toHaveLength(1)
|
||||
expect(manager.getWorktrees()[0].branch).toBe("exists")
|
||||
// Session removed along with its worktree
|
||||
expect(manager.getSession("s1")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("preserves local sessions and prunes missing worktree references on validate", async () => {
|
||||
const existing = path.join(root, "wt-exists")
|
||||
fs.mkdirSync(existing, { recursive: true })
|
||||
|
||||
const wt = manager.addWorktree({ branch: "exists", path: existing, parentBranch: "main" })
|
||||
manager.addSession("s1", wt.id)
|
||||
manager.addSession("s2", null)
|
||||
manager.addSession("s3", "missing")
|
||||
|
||||
await manager.validate(root)
|
||||
|
||||
expect(manager.getSession("s1")).toBeTruthy()
|
||||
expect(manager.getSession("s2")?.worktreeId).toBeNull()
|
||||
expect(manager.getSession("s3")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("resolves relative paths against root", async () => {
|
||||
const relative = ".kilo/worktrees/test-branch"
|
||||
const absolute = path.join(root, relative)
|
||||
fs.mkdirSync(absolute, { recursive: true })
|
||||
|
||||
manager.addWorktree({ branch: "test", path: relative, parentBranch: "main" })
|
||||
await manager.validate(root)
|
||||
|
||||
expect(manager.getWorktrees()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
// Worktree-directory validation moved to worktree-reconcile.ts, which classifies rows instead of
|
||||
// deleting them; see tests/unit/worktree-reconcile.test.ts. Session pruning for rows that are
|
||||
// already gone stays covered by the load/apply tests above.
|
||||
|
||||
describe("concurrent save serialization", () => {
|
||||
it("rapid mutations do not lose data after flush", async () => {
|
||||
|
||||
@@ -1614,6 +1614,12 @@ const AgentManagerContent: Component = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Host-side failures used to be posted and silently dropped here, so a worktree action could
|
||||
// fail with the only trace in an output channel the user never opens.
|
||||
if (msg.type === "error" && typeof msg.message === "string" && msg.message) {
|
||||
showToast({ variant: "error", title: t("agentManager.error.title"), description: msg.message })
|
||||
}
|
||||
|
||||
if (projectLive.apply(msg)) return
|
||||
})
|
||||
|
||||
@@ -2350,6 +2356,12 @@ const AgentManagerContent: Component = () => {
|
||||
busy={(id) => busyWorktrees().has(id)}
|
||||
blocked={activity.blocked}
|
||||
isStaleWorktree={(id) => staleWorktreeIds().has(id)}
|
||||
worktreeHealth={(id) => registry.active().worktreeHealth()[id]}
|
||||
orphanDirectories={() => registry.active().orphanDirectories()}
|
||||
onRestoreWorktree={(id) => vscode.postMessage({ type: "agentManager.restoreWorktree", worktreeId: id })}
|
||||
onRemoveStaleKeepSessions={(id) =>
|
||||
vscode.postMessage({ type: "agentManager.removeStaleWorktree", worktreeId: id, keepSessions: true })
|
||||
}
|
||||
shortcutMap={shortcutMap}
|
||||
worktreeStats={worktreeStats}
|
||||
prStatuses={prStatuses}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Notice for leftover folders under `.kilo/worktrees/` that no git worktree claims.
|
||||
*
|
||||
* Deleting files is never automatic, so the only way these folders go away is this notice: it names
|
||||
* how many there are, shows their paths before anything is removed, and requires a second click.
|
||||
*/
|
||||
import { Component, For, Show, createSignal } from "solid-js"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
|
||||
export const OrphanNotice: Component<{
|
||||
paths: string[]
|
||||
onClean: (paths: string[]) => void
|
||||
}> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
const [confirming, setConfirming] = createSignal(false)
|
||||
|
||||
return (
|
||||
<Show when={props.paths.length > 0}>
|
||||
<div class="am-orphan-notice" data-orphan-count={props.paths.length}>
|
||||
<div class="am-orphan-notice-head">
|
||||
<Icon name="warning" size="small" />
|
||||
<span class="am-orphan-notice-title">{t("agentManager.orphans.title")}</span>
|
||||
</div>
|
||||
<div class="am-orphan-notice-body">{t("agentManager.orphans.summary", { count: props.paths.length })}</div>
|
||||
<Show
|
||||
when={confirming()}
|
||||
fallback={
|
||||
<div class="am-orphan-notice-actions">
|
||||
<Button variant="ghost" size="small" onClick={() => setConfirming(true)}>
|
||||
{t("agentManager.orphans.clean")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ul class="am-orphan-notice-paths">
|
||||
<For each={props.paths}>{(path) => <li title={path}>{path}</li>}</For>
|
||||
</ul>
|
||||
<div class="am-orphan-notice-body">{t("agentManager.orphans.confirm")}</div>
|
||||
<div class="am-orphan-notice-actions">
|
||||
<Button variant="ghost" size="small" onClick={() => setConfirming(false)}>
|
||||
{t("agentManager.orphans.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setConfirming(false)
|
||||
props.onClean(props.paths)
|
||||
}}
|
||||
>
|
||||
{t("agentManager.orphans.clean")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { LocalActivity } from "../src/components/shared/ActivityIcon"
|
||||
import { label, type Activity } from "../src/utils/session-activity"
|
||||
import { useVSCode } from "../src/context/vscode"
|
||||
import SectionHeader from "./SectionHeader"
|
||||
import { OrphanNotice } from "./OrphanNotice"
|
||||
import { SidebarSectionHeader } from "./SidebarSectionHeader"
|
||||
import { WorktreeItem } from "./WorktreeItem"
|
||||
import { useBaseUpdate } from "./update-from-base"
|
||||
@@ -304,7 +305,11 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
busy={props.busy(worktree.id)}
|
||||
activity={props.activityFor(worktree.id)}
|
||||
blocked={props.blocked(worktree.id)}
|
||||
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
|
||||
stale={
|
||||
state()?.staleWorktreeIds?.includes(worktree.id) === true ||
|
||||
state()?.worktreeHealth?.[worktree.id] !== undefined
|
||||
}
|
||||
health={state()?.worktreeHealth?.[worktree.id]}
|
||||
stats={props.stats?.[worktree.id]}
|
||||
shortcut={values().shortcut}
|
||||
navHint={values().navHint}
|
||||
@@ -342,6 +347,11 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
post({ type: "agentManager.removeStaleWorktree", worktreeId: worktree.id })
|
||||
selectAfterDelete(worktree.id)
|
||||
}}
|
||||
onRemoveKeepSessions={() => {
|
||||
post({ type: "agentManager.removeStaleWorktree", worktreeId: worktree.id, keepSessions: true })
|
||||
selectAfterDelete(worktree.id)
|
||||
}}
|
||||
onRestore={() => post({ type: "agentManager.restoreWorktree", worktreeId: worktree.id })}
|
||||
onUpdateBase={() =>
|
||||
updateBase(
|
||||
worktree.id,
|
||||
@@ -499,6 +509,10 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</Show>
|
||||
<OrphanNotice
|
||||
paths={store.orphanDirectories()}
|
||||
onClean={(paths) => post({ type: "agentManager.cleanOrphanDirectories", paths })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { beginPromptMentionDrop, endPromptMentionDrop } from "../src/utils/promp
|
||||
import { outsideSidebar, sectionAwareDetector } from "./section-dnd"
|
||||
import { ConstrainDragXAxis } from "./constrain-drag-x"
|
||||
import { useVSCode } from "../src/context/vscode"
|
||||
import { OrphanNotice } from "./OrphanNotice"
|
||||
import SectionHeader from "./SectionHeader"
|
||||
import { SidebarSectionHeader } from "./SidebarSectionHeader"
|
||||
import { WorktreeItem } from "./WorktreeItem"
|
||||
@@ -85,6 +86,14 @@ export interface SidebarBodyProps {
|
||||
busy: (id: string) => boolean
|
||||
blocked: (id: string) => boolean
|
||||
isStaleWorktree: (id: string) => boolean
|
||||
/** Why an unhealthy worktree is unhealthy, when known. */
|
||||
worktreeHealth?: (id: string) => "absent-restorable" | "absent-gone" | "unregistered" | "unavailable" | undefined
|
||||
/** Leftover folders under `.kilo/worktrees/` that no worktree claims. */
|
||||
orphanDirectories?: () => string[]
|
||||
/** Restore a deleted worktree folder from its branch. */
|
||||
onRestoreWorktree?: (id: string) => void
|
||||
/** Drop the entry but move its sessions to Local. */
|
||||
onRemoveStaleKeepSessions?: (id: string) => void
|
||||
shortcutMap: () => Map<string, number>
|
||||
worktreeStats: () => Record<string, WorktreeGitStats>
|
||||
prStatuses: () => Record<string, PRStatus | null>
|
||||
@@ -358,7 +367,8 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
|
||||
busy={props.busy(wt.id)}
|
||||
activity={props.activityFor(wt.id)}
|
||||
blocked={props.blocked(wt.id)}
|
||||
stale={props.isStaleWorktree(wt.id)}
|
||||
stale={props.isStaleWorktree(wt.id) || props.worktreeHealth?.(wt.id) !== undefined}
|
||||
health={props.worktreeHealth?.(wt.id)}
|
||||
shortcut={props.shortcutMap().get(wt.id)}
|
||||
stats={props.worktreeStats()[wt.id]}
|
||||
navHint={navHint()}
|
||||
@@ -395,6 +405,12 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
|
||||
onCommitRename={() => commitRename(wt.id)}
|
||||
onCancelRename={cancelRename}
|
||||
onRemoveStale={() => props.confirmRemoveStaleWorktree(wt.id)}
|
||||
onRestore={props.onRestoreWorktree ? () => props.onRestoreWorktree?.(wt.id) : undefined}
|
||||
onRemoveKeepSessions={
|
||||
props.onRemoveStaleKeepSessions
|
||||
? () => props.onRemoveStaleKeepSessions?.(wt.id)
|
||||
: undefined
|
||||
}
|
||||
onUpdateBase={() =>
|
||||
updateBase(
|
||||
wt.id,
|
||||
@@ -480,6 +496,10 @@ export const SidebarBody: Component<SidebarBodyProps> = (props) => {
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
<OrphanNotice
|
||||
paths={props.orphanDirectories?.() ?? []}
|
||||
onClean={(paths) => vscode.postMessage({ type: "agentManager.cleanOrphanDirectories", paths })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -21,6 +21,77 @@ import { parseBindingTokens } from "./keybind-tokens"
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
|
||||
|
||||
type WorktreeHealth = "absent-restorable" | "absent-gone" | "unregistered" | "unavailable"
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
/** Short badge text for an unhealthy worktree. */
|
||||
function healthLabel(t: Translate, health: WorktreeHealth): string {
|
||||
return t(`agentManager.worktree.health.${health}`)
|
||||
}
|
||||
|
||||
/** One sentence explaining the state and what can be done about it. */
|
||||
function healthNote(t: Translate, health: WorktreeHealth, branch: string): string {
|
||||
return t(`agentManager.worktree.health.${health}Note`, { branch })
|
||||
}
|
||||
|
||||
/**
|
||||
* Health details and recovery actions inside the hover card.
|
||||
*
|
||||
* Its own component so the reasons and the actions can grow without pushing the row component past
|
||||
* its complexity budget.
|
||||
*/
|
||||
const HealthSection: Component<{
|
||||
t: Translate
|
||||
health?: WorktreeHealth
|
||||
branch: string
|
||||
sessions: number
|
||||
onRestore?: () => void
|
||||
onRemoveStale: () => void
|
||||
onRemoveKeepSessions?: () => void
|
||||
}> = (props) => {
|
||||
const label = () => (props.health ? healthLabel(props.t, props.health) : props.t("agentManager.worktree.stale"))
|
||||
const note = () =>
|
||||
props.health ? healthNote(props.t, props.health, props.branch) : props.t("agentManager.worktree.staleTooltip")
|
||||
// Keeping the conversations is only meaningful while there are any to keep.
|
||||
const keep = () => props.sessions > 0 && props.onRemoveKeepSessions !== undefined
|
||||
const click = (action?: () => void) => (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
action?.()
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div class="am-hover-card-divider" />
|
||||
<div class="am-hover-card-row am-hover-card-row-stale">
|
||||
<span class="am-hover-card-row-label">{props.t("agentManager.worktree.stale")}</span>
|
||||
<span class="am-hover-card-row-value am-hover-card-stale-pill">
|
||||
<Icon name="warning" size="small" />
|
||||
{label()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="am-hover-card-note">{note()}</div>
|
||||
<div class="am-hover-card-actions">
|
||||
<Show when={props.health === "absent-restorable" && props.onRestore}>
|
||||
<Button variant="ghost" size="small" onClick={click(props.onRestore)}>
|
||||
{props.t("agentManager.worktree.restore")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show
|
||||
when={keep()}
|
||||
fallback={
|
||||
<Button variant="ghost" size="small" onClick={click(props.onRemoveStale)}>
|
||||
{props.t("agentManager.worktree.removeStale")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button variant="ghost" size="small" onClick={click(props.onRemoveKeepSessions)}>
|
||||
{props.t("agentManager.worktree.removeKeepSessions")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface WorktreeItemProps {
|
||||
preview?: boolean
|
||||
worktree: WorktreeState
|
||||
@@ -38,6 +109,15 @@ interface WorktreeItemProps {
|
||||
activity: Activity
|
||||
blocked?: boolean
|
||||
stale: boolean
|
||||
/**
|
||||
* Why this worktree is unhealthy, when the health reconcile knows. Refines the generic "stale"
|
||||
* badge into something actionable, and decides which recovery actions are offered.
|
||||
*/
|
||||
health?: "absent-restorable" | "absent-gone" | "unregistered" | "unavailable"
|
||||
/** Re-create the worktree folder from its surviving branch. */
|
||||
onRestore?: () => void
|
||||
/** Drop the entry but keep its sessions, moving them to Local. */
|
||||
onRemoveKeepSessions?: () => void
|
||||
/** 1-indexed shortcut number shown as ⌘2, ⌘3, etc. Pass 0, >9, or undefined to hide. */
|
||||
shortcut?: number
|
||||
stats?: WorktreeGitStats
|
||||
@@ -271,7 +351,11 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
<div class="am-wt-row1">
|
||||
<Show when={props.stale}>
|
||||
<Tooltip
|
||||
value={t("agentManager.worktree.staleTooltip")}
|
||||
value={
|
||||
props.health
|
||||
? healthNote(t, props.health, props.worktree.branch)
|
||||
: t("agentManager.worktree.staleTooltip")
|
||||
}
|
||||
placement="top"
|
||||
contentClass="am-tooltip-wrap"
|
||||
>
|
||||
@@ -535,27 +619,15 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
<span class="am-hover-card-row-value">{props.sessions}</span>
|
||||
</div>
|
||||
<Show when={props.stale}>
|
||||
<div class="am-hover-card-divider" />
|
||||
<div class="am-hover-card-row am-hover-card-row-stale">
|
||||
<span class="am-hover-card-row-label">{t("agentManager.worktree.stale")}</span>
|
||||
<span class="am-hover-card-row-value am-hover-card-stale-pill">
|
||||
<Icon name="warning" size="small" />
|
||||
{t("agentManager.worktree.stale")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="am-hover-card-note">{t("agentManager.worktree.staleTooltip")}</div>
|
||||
<div class="am-hover-card-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
props.onRemoveStale()
|
||||
}}
|
||||
>
|
||||
{t("agentManager.worktree.removeStale")}
|
||||
</Button>
|
||||
</div>
|
||||
<HealthSection
|
||||
t={t}
|
||||
health={props.health}
|
||||
branch={props.worktree.branch}
|
||||
sessions={props.sessions}
|
||||
onRestore={props.onRestore}
|
||||
onRemoveStale={props.onRemoveStale}
|
||||
onRemoveKeepSessions={props.onRemoveKeepSessions}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={hasStats(props.stats)}>
|
||||
<div class="am-hover-card-divider" />
|
||||
|
||||
@@ -881,6 +881,55 @@ html[data-theme="kilo-vscode"]
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Leftover worktree folders: informational until the user asks for the cleanup. */
|
||||
.am-orphan-notice {
|
||||
margin: 6px 8px 2px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid color-mix(in oklab, var(--text-warning, #f59e0b) 35%, transparent);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in oklab, var(--text-warning, #f59e0b) 8%, transparent);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.am-orphan-notice-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-warning, #f59e0b);
|
||||
}
|
||||
|
||||
.am-orphan-notice-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.am-orphan-notice-body {
|
||||
margin-top: 2px;
|
||||
color: var(--text-muted, inherit);
|
||||
}
|
||||
|
||||
.am-orphan-notice-paths {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 14px;
|
||||
max-height: 88px;
|
||||
overflow-y: auto;
|
||||
color: var(--text-muted, inherit);
|
||||
}
|
||||
|
||||
.am-orphan-notice-paths li {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.am-orphan-notice-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.am-worktree-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
@@ -457,4 +457,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "اقرأ الدليل",
|
||||
"agentManager.intro.dismiss": "تخطي المقدمة",
|
||||
"agentManager.intro.reopen": "كيف يعمل Agent Manager",
|
||||
"agentManager.worktree.health.absent-restorable": "تم حذف المجلد",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"المجلد مفقود، لكن الفرع {{branch}} لا يزال موجودًا. استعده لمتابعة العمل هنا.",
|
||||
"agentManager.worktree.health.absent-gone": "تم حذف المجلد والفرع",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"لم يبقَ المجلد ولا الفرع. أزل العنصر للترتيب؛ وتُحفظ الجلسات ضمن «محلي».",
|
||||
"agentManager.worktree.health.unregistered": "ليس worktree من git",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"المجلد موجود، لكن git لم يعد يتتبعه كـ worktree، فلا يمكن قراءة حالته.",
|
||||
"agentManager.worktree.health.unavailable": "الحالة غير متوفرة",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"لم يستجب Git أو GitHub CLI في الوقت المناسب. تم إيقاف الاستعلام لهذا الـ worktree مؤقتًا وسيُعاد المحاولة.",
|
||||
"agentManager.worktree.restore": "استعادة الـ worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "إزالة مع الاحتفاظ بالجلسات",
|
||||
"agentManager.orphans.title": "مجلدات worktree متبقية",
|
||||
"agentManager.orphans.summary": "{{count}} مجلد ضمن .kilo/worktrees ليست worktrees من git.",
|
||||
"agentManager.orphans.clean": "تنظيف المجلدات المتبقية",
|
||||
"agentManager.orphans.confirm": "حذف هذه المجلدات نهائيًا؟ لا شيء هنا يتتبعه git.",
|
||||
"agentManager.orphans.cancel": "إلغاء",
|
||||
"agentManager.error.title": "خطأ في Agent Manager",
|
||||
}
|
||||
|
||||
@@ -468,4 +468,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "Ler o guia",
|
||||
"agentManager.intro.dismiss": "Pular introdução",
|
||||
"agentManager.intro.reopen": "Como o Agent Manager funciona",
|
||||
"agentManager.worktree.health.absent-restorable": "Pasta excluída",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"A pasta não existe mais, mas o branch {{branch}} continua lá. Restaure para continuar trabalhando aqui.",
|
||||
"agentManager.worktree.health.absent-gone": "Pasta e branch excluídos",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Nem a pasta nem o branch existem mais. Remova a entrada para organizar; as sessões ficam em Local.",
|
||||
"agentManager.worktree.health.unregistered": "Não é um worktree do git",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"A pasta existe, mas o git não a rastreia mais como worktree. Não é possível ler o status.",
|
||||
"agentManager.worktree.health.unavailable": "Status indisponível",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"O Git ou o GitHub CLI não respondeu em tempo. A consulta deste worktree está pausada e será repetida.",
|
||||
"agentManager.worktree.restore": "Restaurar worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "Remover e manter sessões",
|
||||
"agentManager.orphans.title": "Pastas de worktree remanescentes",
|
||||
"agentManager.orphans.summary": "{{count}} pasta(s) em .kilo/worktrees não são worktrees do git.",
|
||||
"agentManager.orphans.clean": "Limpar pastas remanescentes",
|
||||
"agentManager.orphans.confirm": "Excluir estas pastas permanentemente? Nada aqui é rastreado pelo git.",
|
||||
"agentManager.orphans.cancel": "Cancelar",
|
||||
"agentManager.error.title": "Erro do Agent Manager",
|
||||
}
|
||||
|
||||
@@ -464,4 +464,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "Pročitajte vodič",
|
||||
"agentManager.intro.dismiss": "Preskoči uvod",
|
||||
"agentManager.intro.reopen": "Kako radi Agent Manager",
|
||||
"agentManager.worktree.health.absent-restorable": "Folder izbrisan",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"Folder je nestao, ali grana {{branch}} još postoji. Vrati ga da nastaviš rad ovdje.",
|
||||
"agentManager.worktree.health.absent-gone": "Folder i grana izbrisani",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Ni folder ni grana više ne postoje. Ukloni unos radi urednosti; sesije se čuvaju pod Lokalno.",
|
||||
"agentManager.worktree.health.unregistered": "Nije git worktree",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"Folder postoji, ali ga git više ne prati kao worktree. Status se ne može pročitati.",
|
||||
"agentManager.worktree.health.unavailable": "Status nedostupan",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git ili GitHub CLI nije odgovorio na vrijeme. Provjera ovog worktreeja je pauzirana i biće ponovljena.",
|
||||
"agentManager.worktree.restore": "Vrati worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "Ukloni, zadrži sesije",
|
||||
"agentManager.orphans.title": "Zaostali worktree folderi",
|
||||
"agentManager.orphans.summary": "{{count}} folder(a) u .kilo/worktrees nisu git worktreeji.",
|
||||
"agentManager.orphans.clean": "Očisti zaostale foldere",
|
||||
"agentManager.orphans.confirm": "Trajno izbrisati ove foldere? Ništa ovdje git ne prati.",
|
||||
"agentManager.orphans.cancel": "Otkaži",
|
||||
"agentManager.error.title": "Greška Agent Managera",
|
||||
}
|
||||
|
||||
@@ -467,4 +467,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "Læs guiden",
|
||||
"agentManager.intro.dismiss": "Spring introduktion over",
|
||||
"agentManager.intro.reopen": "Sådan fungerer Agent Manager",
|
||||
"agentManager.worktree.health.absent-restorable": "Mappe slettet",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"Mappen er væk, men branchen {{branch}} findes stadig. Gendan den for at arbejde videre her.",
|
||||
"agentManager.worktree.health.absent-gone": "Mappe og branch slettet",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Hverken mappen eller branchen findes længere. Fjern posten for at rydde op; sessioner bevares under Lokal.",
|
||||
"agentManager.worktree.health.unregistered": "Ikke et git-worktree",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"Mappen findes, men git sporer den ikke længere som worktree. Dens status kan ikke læses.",
|
||||
"agentManager.worktree.health.unavailable": "Status utilgængelig",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git eller GitHub CLI svarede ikke i tid. Forespørgsler for dette worktree er sat på pause og prøves igen.",
|
||||
"agentManager.worktree.restore": "Gendan worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "Fjern, behold sessioner",
|
||||
"agentManager.orphans.title": "Efterladte worktree-mapper",
|
||||
"agentManager.orphans.summary": "{{count}} mappe(r) under .kilo/worktrees er ikke git-worktrees.",
|
||||
"agentManager.orphans.clean": "Ryd op i efterladte mapper",
|
||||
"agentManager.orphans.confirm": "Slet disse mapper permanent? Intet her spores af git.",
|
||||
"agentManager.orphans.cancel": "Annuller",
|
||||
"agentManager.error.title": "Agent Manager-fejl",
|
||||
}
|
||||
|
||||
@@ -473,4 +473,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "Anleitung lesen",
|
||||
"agentManager.intro.dismiss": "Einführung überspringen",
|
||||
"agentManager.intro.reopen": "So funktioniert Agent Manager",
|
||||
"agentManager.worktree.health.absent-restorable": "Ordner gelöscht",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"Der Ordner fehlt, aber Branch {{branch}} existiert noch. Stelle ihn wieder her, um hier weiterzuarbeiten.",
|
||||
"agentManager.worktree.health.absent-gone": "Ordner und Branch gelöscht",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Weder Ordner noch Branch existieren noch. Entferne den Eintrag zum Aufräumen; Sitzungen bleiben unter „Lokal“.",
|
||||
"agentManager.worktree.health.unregistered": "Kein git-Worktree",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"Der Ordner existiert, aber git verfolgt ihn nicht mehr als Worktree. Sein Status ist nicht lesbar.",
|
||||
"agentManager.worktree.health.unavailable": "Status nicht verfügbar",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git oder GitHub CLI hat nicht rechtzeitig geantwortet. Das Abfragen dieses Worktrees pausiert und wird wiederholt.",
|
||||
"agentManager.worktree.restore": "Worktree wiederherstellen",
|
||||
"agentManager.worktree.removeKeepSessions": "Entfernen, Sitzungen behalten",
|
||||
"agentManager.orphans.title": "Übrig gebliebene Worktree-Ordner",
|
||||
"agentManager.orphans.summary": "{{count}} Ordner unter .kilo/worktrees sind keine git-Worktrees.",
|
||||
"agentManager.orphans.clean": "Übrige Ordner aufräumen",
|
||||
"agentManager.orphans.confirm": "Diese Ordner endgültig löschen? Nichts davon wird von git verfolgt.",
|
||||
"agentManager.orphans.cancel": "Abbrechen",
|
||||
"agentManager.error.title": "Agent-Manager-Fehler",
|
||||
}
|
||||
|
||||
@@ -68,6 +68,26 @@ export const dict = {
|
||||
"agentManager.worktree.stale": "Stale",
|
||||
"agentManager.worktree.staleTooltip": "Missing on disk or no longer tracked by git worktree",
|
||||
"agentManager.worktree.removeStale": "Remove stale worktree",
|
||||
// Health states say what is actually wrong, and each one names the fix it allows.
|
||||
"agentManager.worktree.health.absent-restorable": "Folder deleted",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"The folder is gone, but branch {{branch}} still exists. Restore it to keep working here.",
|
||||
"agentManager.worktree.health.absent-gone": "Folder and branch deleted",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Neither the folder nor the branch exists anymore. Remove the entry to tidy up; sessions are kept under Local.",
|
||||
"agentManager.worktree.health.unregistered": "Not a git worktree",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"The folder exists, but git no longer tracks it as a worktree. Its status cannot be read.",
|
||||
"agentManager.worktree.health.unavailable": "Status unavailable",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git or GitHub CLI did not answer in time. Polling is paused for this worktree and will retry.",
|
||||
"agentManager.worktree.restore": "Restore worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "Remove, keep sessions",
|
||||
"agentManager.orphans.title": "Leftover worktree folders",
|
||||
"agentManager.orphans.summary": "{{count}} folder(s) under .kilo/worktrees are not git worktrees.",
|
||||
"agentManager.orphans.clean": "Clean up leftover folders",
|
||||
"agentManager.orphans.confirm": "Delete these folders permanently? Nothing here is tracked by git.",
|
||||
"agentManager.orphans.cancel": "Cancel",
|
||||
"agentManager.worktree.doubleClickRename": "Double-click to rename",
|
||||
"agentManager.worktree.versions": "{{count}} versions",
|
||||
"agentManager.worktree.advancedOptions": "Advanced worktree options",
|
||||
@@ -128,6 +148,7 @@ export const dict = {
|
||||
"agentManager.terminal.openInVscode": "VS Code terminal",
|
||||
"agentManager.terminal.openInPanel": "Agent Manager panel",
|
||||
"agentManager.terminal.errorTitle": "Terminal error",
|
||||
"agentManager.error.title": "Agent Manager error",
|
||||
|
||||
"agentManager.setup.failed": "Worktree setup failed",
|
||||
"agentManager.setup.settingUp": "Setting up worktree",
|
||||
|
||||
@@ -473,4 +473,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "Leer la guía",
|
||||
"agentManager.intro.dismiss": "Omitir introducción",
|
||||
"agentManager.intro.reopen": "Cómo funciona Agent Manager",
|
||||
"agentManager.worktree.health.absent-restorable": "Carpeta eliminada",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"La carpeta no está, pero la rama {{branch}} sigue existiendo. Restáurala para seguir trabajando aquí.",
|
||||
"agentManager.worktree.health.absent-gone": "Carpeta y rama eliminadas",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Ni la carpeta ni la rama existen ya. Elimina la entrada para ordenar; las sesiones se conservan en Local.",
|
||||
"agentManager.worktree.health.unregistered": "No es un worktree de git",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"La carpeta existe, pero git ya no la rastrea como worktree. No se puede leer su estado.",
|
||||
"agentManager.worktree.health.unavailable": "Estado no disponible",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git o GitHub CLI no respondió a tiempo. El sondeo de este worktree está en pausa y se reintentará.",
|
||||
"agentManager.worktree.restore": "Restaurar worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "Eliminar y conservar sesiones",
|
||||
"agentManager.orphans.title": "Carpetas de worktree sobrantes",
|
||||
"agentManager.orphans.summary": "{{count}} carpeta(s) en .kilo/worktrees no son worktrees de git.",
|
||||
"agentManager.orphans.clean": "Limpiar carpetas sobrantes",
|
||||
"agentManager.orphans.confirm": "¿Eliminar estas carpetas permanentemente? Nada aquí está rastreado por git.",
|
||||
"agentManager.orphans.cancel": "Cancelar",
|
||||
"agentManager.error.title": "Error de Agent Manager",
|
||||
}
|
||||
|
||||
@@ -467,4 +467,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "راهنما را بخوانید",
|
||||
"agentManager.intro.dismiss": "رد کردن مقدمه",
|
||||
"agentManager.intro.reopen": "Agent Manager چگونه کار میکند",
|
||||
"agentManager.worktree.health.absent-restorable": "پوشه حذف شده است",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"پوشه نیست، اما شاخه {{branch}} هنوز وجود دارد. آن را بازیابی کنید تا کار در اینجا ادامه یابد.",
|
||||
"agentManager.worktree.health.absent-gone": "پوشه و شاخه حذف شدهاند",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"نه پوشه و نه شاخه دیگر وجود ندارند. برای مرتبسازی مدخل را حذف کنید؛ نشستها زیر «محلی» نگه داشته میشوند.",
|
||||
"agentManager.worktree.health.unregistered": "worktree گیت نیست",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"پوشه وجود دارد، اما گیت دیگر آن را بهعنوان worktree پیگیری نمیکند و وضعیتش خواندنی نیست.",
|
||||
"agentManager.worktree.health.unavailable": "وضعیت در دسترس نیست",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git یا GitHub CLI بهموقع پاسخ نداد. بررسی این worktree موقتاً متوقف شد و دوباره تلاش میشود.",
|
||||
"agentManager.worktree.restore": "بازیابی worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "حذف با نگهداشتن نشستها",
|
||||
"agentManager.orphans.title": "پوشههای باقیمانده worktree",
|
||||
"agentManager.orphans.summary": "{{count}} پوشه در .kilo/worktrees، worktree گیت نیستند.",
|
||||
"agentManager.orphans.clean": "پاکسازی پوشههای باقیمانده",
|
||||
"agentManager.orphans.confirm": "این پوشهها برای همیشه حذف شوند؟ هیچچیز اینجا توسط گیت پیگیری نمیشود.",
|
||||
"agentManager.orphans.cancel": "لغو",
|
||||
"agentManager.error.title": "خطای Agent Manager",
|
||||
}
|
||||
|
||||
@@ -473,4 +473,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "Lire le guide",
|
||||
"agentManager.intro.dismiss": "Ignorer l'introduction",
|
||||
"agentManager.intro.reopen": "Fonctionnement d'Agent Manager",
|
||||
"agentManager.worktree.health.absent-restorable": "Dossier supprimé",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"Le dossier a disparu, mais la branche {{branch}} existe toujours. Restaurez-le pour continuer à travailler ici.",
|
||||
"agentManager.worktree.health.absent-gone": "Dossier et branche supprimés",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Ni le dossier ni la branche n'existent plus. Supprimez l'entrée pour faire le ménage ; les sessions sont conservées dans Local.",
|
||||
"agentManager.worktree.health.unregistered": "Pas un worktree git",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"Le dossier existe, mais git ne le suit plus comme worktree. Son état est illisible.",
|
||||
"agentManager.worktree.health.unavailable": "État indisponible",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git ou GitHub CLI n'a pas répondu à temps. L'interrogation de ce worktree est suspendue et sera réessayée.",
|
||||
"agentManager.worktree.restore": "Restaurer le worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "Supprimer en gardant les sessions",
|
||||
"agentManager.orphans.title": "Dossiers de worktree résiduels",
|
||||
"agentManager.orphans.summary": "{{count}} dossier(s) sous .kilo/worktrees ne sont pas des worktrees git.",
|
||||
"agentManager.orphans.clean": "Nettoyer les dossiers résiduels",
|
||||
"agentManager.orphans.confirm": "Supprimer définitivement ces dossiers ? Rien ici n'est suivi par git.",
|
||||
"agentManager.orphans.cancel": "Annuler",
|
||||
"agentManager.error.title": "Erreur Agent Manager",
|
||||
}
|
||||
|
||||
@@ -479,4 +479,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "Leggi la guida",
|
||||
"agentManager.intro.dismiss": "Salta introduzione",
|
||||
"agentManager.intro.reopen": "Come funziona Agent Manager",
|
||||
"agentManager.worktree.health.absent-restorable": "Cartella eliminata",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"La cartella non c'è più, ma il branch {{branch}} esiste ancora. Ripristinala per continuare a lavorare qui.",
|
||||
"agentManager.worktree.health.absent-gone": "Cartella e branch eliminati",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Né la cartella né il branch esistono più. Rimuovi la voce per fare ordine; le sessioni restano sotto Locale.",
|
||||
"agentManager.worktree.health.unregistered": "Non è un worktree git",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"La cartella esiste, ma git non la traccia più come worktree. Lo stato non è leggibile.",
|
||||
"agentManager.worktree.health.unavailable": "Stato non disponibile",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git o GitHub CLI non ha risposto in tempo. Il polling di questo worktree è in pausa e verrà ritentato.",
|
||||
"agentManager.worktree.restore": "Ripristina worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "Rimuovi, mantieni le sessioni",
|
||||
"agentManager.orphans.title": "Cartelle di worktree rimaste",
|
||||
"agentManager.orphans.summary": "{{count}} cartella(e) in .kilo/worktrees non sono worktree git.",
|
||||
"agentManager.orphans.clean": "Pulisci le cartelle rimaste",
|
||||
"agentManager.orphans.confirm": "Eliminare definitivamente queste cartelle? Nulla qui è tracciato da git.",
|
||||
"agentManager.orphans.cancel": "Annulla",
|
||||
"agentManager.error.title": "Errore di Agent Manager",
|
||||
}
|
||||
|
||||
@@ -467,4 +467,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "ガイドを読む",
|
||||
"agentManager.intro.dismiss": "イントロダクションをスキップ",
|
||||
"agentManager.intro.reopen": "Agent Manager の仕組み",
|
||||
"agentManager.worktree.health.absent-restorable": "フォルダーが削除されています",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"フォルダーはありませんが、ブランチ {{branch}} は残っています。復元すればここで作業を続けられます。",
|
||||
"agentManager.worktree.health.absent-gone": "フォルダーとブランチが削除されています",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"フォルダーもブランチも存在しません。エントリを削除して整理できます。セッションは「ローカル」に保持されます。",
|
||||
"agentManager.worktree.health.unregistered": "git worktree ではありません",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"フォルダーは存在しますが、git が worktree として追跡していないため状態を読み取れません。",
|
||||
"agentManager.worktree.health.unavailable": "状態を取得できません",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git または GitHub CLI が時間内に応答しませんでした。この worktree のポーリングを一時停止し、後で再試行します。",
|
||||
"agentManager.worktree.restore": "worktree を復元",
|
||||
"agentManager.worktree.removeKeepSessions": "削除してセッションを保持",
|
||||
"agentManager.orphans.title": "残された worktree フォルダー",
|
||||
"agentManager.orphans.summary": ".kilo/worktrees 配下の {{count}} 個のフォルダーは git worktree ではありません。",
|
||||
"agentManager.orphans.clean": "残ったフォルダーを整理",
|
||||
"agentManager.orphans.confirm": "これらのフォルダーを完全に削除しますか? git が追跡しているものはありません。",
|
||||
"agentManager.orphans.cancel": "キャンセル",
|
||||
"agentManager.error.title": "Agent Manager エラー",
|
||||
}
|
||||
|
||||
@@ -459,4 +459,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "가이드 읽기",
|
||||
"agentManager.intro.dismiss": "소개 건너뛰기",
|
||||
"agentManager.intro.reopen": "Agent Manager 작동 방식",
|
||||
"agentManager.worktree.health.absent-restorable": "폴더 삭제됨",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"폴더는 없지만 브랜치 {{branch}}는 남아 있습니다. 복원하면 계속 작업할 수 있습니다.",
|
||||
"agentManager.worktree.health.absent-gone": "폴더와 브랜치 삭제됨",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"폴더와 브랜치가 모두 없습니다. 항목을 제거해 정리하세요. 세션은 로컬에 보존됩니다.",
|
||||
"agentManager.worktree.health.unregistered": "git worktree 아님",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"폴더는 있지만 git이 더 이상 worktree로 추적하지 않아 상태를 읽을 수 없습니다.",
|
||||
"agentManager.worktree.health.unavailable": "상태를 확인할 수 없음",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git 또는 GitHub CLI가 제때 응답하지 않았습니다. 이 worktree의 폴링을 일시 중지했으며 다시 시도합니다.",
|
||||
"agentManager.worktree.restore": "worktree 복원",
|
||||
"agentManager.worktree.removeKeepSessions": "제거하고 세션 유지",
|
||||
"agentManager.orphans.title": "남은 worktree 폴더",
|
||||
"agentManager.orphans.summary": ".kilo/worktrees 아래 폴더 {{count}}개가 git worktree가 아닙니다.",
|
||||
"agentManager.orphans.clean": "남은 폴더 정리",
|
||||
"agentManager.orphans.confirm": "이 폴더를 영구히 삭제할까요? git이 추적하는 항목은 없습니다.",
|
||||
"agentManager.orphans.cancel": "취소",
|
||||
"agentManager.error.title": "Agent Manager 오류",
|
||||
}
|
||||
|
||||
@@ -473,4 +473,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "Lees de handleiding",
|
||||
"agentManager.intro.dismiss": "Introductie overslaan",
|
||||
"agentManager.intro.reopen": "Hoe Agent Manager werkt",
|
||||
"agentManager.worktree.health.absent-restorable": "Map verwijderd",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"De map is weg, maar branch {{branch}} bestaat nog. Herstel hem om hier verder te werken.",
|
||||
"agentManager.worktree.health.absent-gone": "Map en branch verwijderd",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Noch de map noch de branch bestaat nog. Verwijder het item om op te ruimen; sessies blijven onder Lokaal.",
|
||||
"agentManager.worktree.health.unregistered": "Geen git-worktree",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"De map bestaat, maar git volgt hem niet meer als worktree. De status is niet te lezen.",
|
||||
"agentManager.worktree.health.unavailable": "Status niet beschikbaar",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git of GitHub CLI antwoordde niet op tijd. Het pollen van deze worktree is gepauzeerd en wordt opnieuw geprobeerd.",
|
||||
"agentManager.worktree.restore": "Worktree herstellen",
|
||||
"agentManager.worktree.removeKeepSessions": "Verwijderen, sessies behouden",
|
||||
"agentManager.orphans.title": "Achtergebleven worktree-mappen",
|
||||
"agentManager.orphans.summary": "{{count}} map(pen) in .kilo/worktrees zijn geen git-worktrees.",
|
||||
"agentManager.orphans.clean": "Achtergebleven mappen opruimen",
|
||||
"agentManager.orphans.confirm": "Deze mappen definitief verwijderen? Niets hiervan wordt door git gevolgd.",
|
||||
"agentManager.orphans.cancel": "Annuleren",
|
||||
"agentManager.error.title": "Agent Manager-fout",
|
||||
}
|
||||
|
||||
@@ -465,4 +465,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "Les veiledningen",
|
||||
"agentManager.intro.dismiss": "Hopp over introduksjonen",
|
||||
"agentManager.intro.reopen": "Slik fungerer Agent Manager",
|
||||
"agentManager.worktree.health.absent-restorable": "Mappe slettet",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"Mappen er borte, men grenen {{branch}} finnes fortsatt. Gjenopprett den for å jobbe videre her.",
|
||||
"agentManager.worktree.health.absent-gone": "Mappe og gren slettet",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Verken mappen eller grenen finnes lenger. Fjern oppføringen for å rydde; øktene beholdes under Lokal.",
|
||||
"agentManager.worktree.health.unregistered": "Ikke et git-worktree",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"Mappen finnes, men git sporer den ikke lenger som worktree. Statusen kan ikke leses.",
|
||||
"agentManager.worktree.health.unavailable": "Status utilgjengelig",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git eller GitHub CLI svarte ikke i tid. Spørringer for dette worktreet er satt på pause og prøves igjen.",
|
||||
"agentManager.worktree.restore": "Gjenopprett worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "Fjern, behold økter",
|
||||
"agentManager.orphans.title": "Gjenglemte worktree-mapper",
|
||||
"agentManager.orphans.summary": "{{count}} mappe(r) under .kilo/worktrees er ikke git-worktrees.",
|
||||
"agentManager.orphans.clean": "Rydd opp i gjenglemte mapper",
|
||||
"agentManager.orphans.confirm": "Slette disse mappene permanent? Ingenting her spores av git.",
|
||||
"agentManager.orphans.cancel": "Avbryt",
|
||||
"agentManager.error.title": "Agent Manager-feil",
|
||||
}
|
||||
|
||||
@@ -465,4 +465,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "Przeczytaj przewodnik",
|
||||
"agentManager.intro.dismiss": "Pomiń wprowadzenie",
|
||||
"agentManager.intro.reopen": "Jak działa Agent Manager",
|
||||
"agentManager.worktree.health.absent-restorable": "Folder usunięty",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"Folder zniknął, ale gałąź {{branch}} nadal istnieje. Przywróć go, aby dalej tu pracować.",
|
||||
"agentManager.worktree.health.absent-gone": "Folder i gałąź usunięte",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Ani folder, ani gałąź już nie istnieją. Usuń wpis, aby posprzątać; sesje pozostaną w sekcji Lokalne.",
|
||||
"agentManager.worktree.health.unregistered": "To nie jest worktree gita",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"Folder istnieje, ale git już nie śledzi go jako worktree. Nie można odczytać jego stanu.",
|
||||
"agentManager.worktree.health.unavailable": "Stan niedostępny",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git lub GitHub CLI nie odpowiedział na czas. Odpytywanie tego worktree jest wstrzymane i zostanie ponowione.",
|
||||
"agentManager.worktree.restore": "Przywróć worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "Usuń, zachowaj sesje",
|
||||
"agentManager.orphans.title": "Pozostałe foldery worktree",
|
||||
"agentManager.orphans.summary": "{{count}} folder(ów) w .kilo/worktrees nie jest worktree gita.",
|
||||
"agentManager.orphans.clean": "Wyczyść pozostałe foldery",
|
||||
"agentManager.orphans.confirm": "Trwale usunąć te foldery? Nic tutaj nie jest śledzone przez gita.",
|
||||
"agentManager.orphans.cancel": "Anuluj",
|
||||
"agentManager.error.title": "Błąd Agent Managera",
|
||||
}
|
||||
|
||||
@@ -467,4 +467,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "Читать руководство",
|
||||
"agentManager.intro.dismiss": "Пропустить введение",
|
||||
"agentManager.intro.reopen": "Как работает Agent Manager",
|
||||
"agentManager.worktree.health.absent-restorable": "Папка удалена",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"Папки нет, но ветка {{branch}} сохранилась. Восстановите её, чтобы продолжить работу здесь.",
|
||||
"agentManager.worktree.health.absent-gone": "Папка и ветка удалены",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Ни папки, ни ветки больше нет. Удалите запись для порядка; сессии сохранятся в разделе «Локально».",
|
||||
"agentManager.worktree.health.unregistered": "Не git worktree",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"Папка существует, но git больше не отслеживает её как worktree. Состояние прочитать нельзя.",
|
||||
"agentManager.worktree.health.unavailable": "Состояние недоступно",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git или GitHub CLI не ответил вовремя. Опрос этого worktree приостановлен и будет повторён.",
|
||||
"agentManager.worktree.restore": "Восстановить worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "Удалить, сохранив сессии",
|
||||
"agentManager.orphans.title": "Оставшиеся папки worktree",
|
||||
"agentManager.orphans.summary": "{{count}} папк(и) в .kilo/worktrees не являются git worktree.",
|
||||
"agentManager.orphans.clean": "Очистить оставшиеся папки",
|
||||
"agentManager.orphans.confirm": "Удалить эти папки безвозвратно? Git ничего здесь не отслеживает.",
|
||||
"agentManager.orphans.cancel": "Отмена",
|
||||
"agentManager.error.title": "Ошибка Agent Manager",
|
||||
}
|
||||
|
||||
@@ -457,4 +457,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "อ่านคู่มือ",
|
||||
"agentManager.intro.dismiss": "ข้ามบทนำ",
|
||||
"agentManager.intro.reopen": "Agent Manager ทำงานอย่างไร",
|
||||
"agentManager.worktree.health.absent-restorable": "โฟลเดอร์ถูกลบ",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"โฟลเดอร์หายไปแล้ว แต่แบรนช์ {{branch}} ยังอยู่ กู้คืนเพื่อทำงานต่อที่นี่",
|
||||
"agentManager.worktree.health.absent-gone": "โฟลเดอร์และแบรนช์ถูกลบ",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"ทั้งโฟลเดอร์และแบรนช์ไม่มีอยู่แล้ว ลบรายการเพื่อจัดระเบียบได้ เซสชันจะถูกเก็บไว้ใต้ Local",
|
||||
"agentManager.worktree.health.unregistered": "ไม่ใช่ git worktree",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"โฟลเดอร์ยังอยู่ แต่ git ไม่ติดตามเป็น worktree อีกแล้ว จึงอ่านสถานะไม่ได้",
|
||||
"agentManager.worktree.health.unavailable": "ไม่ทราบสถานะ",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git หรือ GitHub CLI ไม่ตอบกลับทันเวลา การตรวจสอบ worktree นี้ถูกหยุดชั่วคราวและจะลองใหม่",
|
||||
"agentManager.worktree.restore": "กู้คืน worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "ลบแต่เก็บเซสชันไว้",
|
||||
"agentManager.orphans.title": "โฟลเดอร์ worktree ที่ตกค้าง",
|
||||
"agentManager.orphans.summary": "มี {{count}} โฟลเดอร์ใน .kilo/worktrees ที่ไม่ใช่ git worktree",
|
||||
"agentManager.orphans.clean": "ล้างโฟลเดอร์ที่ตกค้าง",
|
||||
"agentManager.orphans.confirm": "ลบโฟลเดอร์เหล่านี้อย่างถาวรหรือไม่? ไม่มีสิ่งใดที่ git ติดตามอยู่",
|
||||
"agentManager.orphans.cancel": "ยกเลิก",
|
||||
"agentManager.error.title": "ข้อผิดพลาด Agent Manager",
|
||||
}
|
||||
|
||||
@@ -473,4 +473,25 @@ export const dict = {
|
||||
"agentManager.intro.guide": "Kılavuzu okuyun",
|
||||
"agentManager.intro.dismiss": "Tanıtımı atla",
|
||||
"agentManager.intro.reopen": "Agent Manager nasıl çalışır",
|
||||
"agentManager.worktree.health.absent-restorable": "Klasör silindi",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"Klasör yok, ancak {{branch}} dalı hâlâ duruyor. Buradan devam etmek için geri yükleyin.",
|
||||
"agentManager.worktree.health.absent-gone": "Klasör ve dal silindi",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Ne klasör ne de dal artık var. Düzen için kaydı kaldırın; oturumlar Yerel altında korunur.",
|
||||
"agentManager.worktree.health.unregistered": "git worktree değil",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"Klasör var, ancak git artık worktree olarak izlemiyor. Durumu okunamıyor.",
|
||||
"agentManager.worktree.health.unavailable": "Durum kullanılamıyor",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git veya GitHub CLI zamanında yanıt vermedi. Bu worktree için sorgulama duraklatıldı ve yeniden denenecek.",
|
||||
"agentManager.worktree.restore": "Worktree'yi geri yükle",
|
||||
"agentManager.worktree.removeKeepSessions": "Kaldır, oturumları koru",
|
||||
"agentManager.orphans.title": "Artakalan worktree klasörleri",
|
||||
"agentManager.orphans.summary": ".kilo/worktrees altındaki {{count}} klasör git worktree değil.",
|
||||
"agentManager.orphans.clean": "Artakalan klasörleri temizle",
|
||||
"agentManager.orphans.confirm":
|
||||
"Bu klasörler kalıcı olarak silinsin mi? Burada git tarafından izlenen hiçbir şey yok.",
|
||||
"agentManager.orphans.cancel": "İptal",
|
||||
"agentManager.error.title": "Agent Manager hatası",
|
||||
}
|
||||
|
||||
@@ -475,4 +475,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "Читати посібник",
|
||||
"agentManager.intro.dismiss": "Пропустити вступ",
|
||||
"agentManager.intro.reopen": "Як працює Agent Manager",
|
||||
"agentManager.worktree.health.absent-restorable": "Теку видалено",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"Теки немає, але гілка {{branch}} збереглася. Відновіть її, щоб продовжити роботу тут.",
|
||||
"agentManager.worktree.health.absent-gone": "Теку й гілку видалено",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"Ні теки, ні гілки більше немає. Приберіть запис для порядку; сеанси залишаться в розділі «Локально».",
|
||||
"agentManager.worktree.health.unregistered": "Не є git worktree",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"Тека існує, але git більше не відслідковує її як worktree. Стан прочитати не вдається.",
|
||||
"agentManager.worktree.health.unavailable": "Стан недоступний",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git або GitHub CLI не відповів вчасно. Опитування цього worktree припинено й буде повторено.",
|
||||
"agentManager.worktree.restore": "Відновити worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "Видалити, зберігши сеанси",
|
||||
"agentManager.orphans.title": "Залишені теки worktree",
|
||||
"agentManager.orphans.summary": "{{count}} тек(и) у .kilo/worktrees не є git worktree.",
|
||||
"agentManager.orphans.clean": "Очистити залишені теки",
|
||||
"agentManager.orphans.confirm": "Видалити ці теки безповоротно? Git тут нічого не відслідковує.",
|
||||
"agentManager.orphans.cancel": "Скасувати",
|
||||
"agentManager.error.title": "Помилка Agent Manager",
|
||||
}
|
||||
|
||||
@@ -450,4 +450,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "阅读指南",
|
||||
"agentManager.intro.dismiss": "跳过介绍",
|
||||
"agentManager.intro.reopen": "Agent Manager 的工作原理",
|
||||
"agentManager.worktree.health.absent-restorable": "文件夹已删除",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"文件夹已不存在,但分支 {{branch}} 仍在。恢复后可继续在此工作。",
|
||||
"agentManager.worktree.health.absent-gone": "文件夹和分支都已删除",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"文件夹和分支都已不存在。可移除该条目进行整理;会话将保留在“本地”下。",
|
||||
"agentManager.worktree.health.unregistered": "不是 git worktree",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"文件夹仍存在,但 git 已不再将其作为 worktree 跟踪,无法读取其状态。",
|
||||
"agentManager.worktree.health.unavailable": "状态不可用",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git 或 GitHub CLI 未及时响应。已暂停该 worktree 的轮询,稍后会重试。",
|
||||
"agentManager.worktree.restore": "恢复 worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "移除并保留会话",
|
||||
"agentManager.orphans.title": "残留的 worktree 文件夹",
|
||||
"agentManager.orphans.summary": ".kilo/worktrees 下有 {{count}} 个文件夹不是 git worktree。",
|
||||
"agentManager.orphans.clean": "清理残留文件夹",
|
||||
"agentManager.orphans.confirm": "永久删除这些文件夹?其中没有任何内容被 git 跟踪。",
|
||||
"agentManager.orphans.cancel": "取消",
|
||||
"agentManager.error.title": "Agent Manager 错误",
|
||||
}
|
||||
|
||||
@@ -449,4 +449,24 @@ export const dict = {
|
||||
"agentManager.intro.guide": "閱讀指南",
|
||||
"agentManager.intro.dismiss": "略過介紹",
|
||||
"agentManager.intro.reopen": "Agent Manager 的運作方式",
|
||||
"agentManager.worktree.health.absent-restorable": "資料夾已刪除",
|
||||
"agentManager.worktree.health.absent-restorableNote":
|
||||
"資料夾已不存在,但分支 {{branch}} 仍在。還原後可繼續在此工作。",
|
||||
"agentManager.worktree.health.absent-gone": "資料夾與分支都已刪除",
|
||||
"agentManager.worktree.health.absent-goneNote":
|
||||
"資料夾與分支都已不存在。可移除此項目以整理;工作階段會保留在「本機」下。",
|
||||
"agentManager.worktree.health.unregistered": "不是 git worktree",
|
||||
"agentManager.worktree.health.unregisteredNote":
|
||||
"資料夾仍存在,但 git 已不再將其視為 worktree 追蹤,無法讀取其狀態。",
|
||||
"agentManager.worktree.health.unavailable": "狀態不可用",
|
||||
"agentManager.worktree.health.unavailableNote":
|
||||
"Git 或 GitHub CLI 未及時回應。已暫停此 worktree 的輪詢,稍後會重試。",
|
||||
"agentManager.worktree.restore": "還原 worktree",
|
||||
"agentManager.worktree.removeKeepSessions": "移除並保留工作階段",
|
||||
"agentManager.orphans.title": "殘留的 worktree 資料夾",
|
||||
"agentManager.orphans.summary": ".kilo/worktrees 下有 {{count}} 個資料夾不是 git worktree。",
|
||||
"agentManager.orphans.clean": "清理殘留資料夾",
|
||||
"agentManager.orphans.confirm": "永久刪除這些資料夾?其中沒有任何內容被 git 追蹤。",
|
||||
"agentManager.orphans.cancel": "取消",
|
||||
"agentManager.error.title": "Agent Manager 錯誤",
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ export interface WorktreeBusyState {
|
||||
branch?: string
|
||||
}
|
||||
|
||||
/** Why a worktree cannot be polled, as classified by the extension's health reconcile. */
|
||||
export type WorktreeHealthState = NonNullable<AgentManagerStateMessage["worktreeHealth"]>[string]
|
||||
|
||||
/** Local session tab ids owned by one project. */
|
||||
export function createStoreTabs(initial: string[] = []) {
|
||||
const [ids, setIds] = createSignal<string[]>(initial)
|
||||
@@ -63,6 +66,8 @@ export function createProjectStore(id: string, opts: { tabs?: string[] } = {}) {
|
||||
const [managedSessions, setManagedSessions] = field<ManagedSessionState[]>([])
|
||||
const [sections, setSections] = field<SectionState[]>([])
|
||||
const [staleWorktreeIds, setStaleWorktreeIds] = field<Set<string>>(new Set())
|
||||
const [worktreeHealth, setWorktreeHealth] = field<Record<string, WorktreeHealthState>>({})
|
||||
const [orphanDirectories, setOrphanDirectories] = field<string[]>([])
|
||||
const [tabOrder, setTabOrder] = field<Record<string, string[]>>({})
|
||||
const [worktreeOrder, setWorktreeOrder] = field<string[]>([])
|
||||
const [sessionsCollapsed, setSessionsCollapsed] = field<boolean | undefined>(undefined)
|
||||
@@ -79,6 +84,8 @@ export function createProjectStore(id: string, opts: { tabs?: string[] } = {}) {
|
||||
setWorktrees(state.worktrees)
|
||||
setManagedSessions(state.sessions)
|
||||
setStaleWorktreeIds(new Set(state.staleWorktreeIds ?? []))
|
||||
setWorktreeHealth(state.worktreeHealth ?? {})
|
||||
setOrphanDirectories(state.orphanDirectories ?? [])
|
||||
setSections(state.sections ?? [])
|
||||
if (state.tabOrder) setTabOrder(state.tabOrder)
|
||||
if (state.worktreeOrder) setWorktreeOrder(state.worktreeOrder)
|
||||
@@ -112,6 +119,10 @@ export function createProjectStore(id: string, opts: { tabs?: string[] } = {}) {
|
||||
setSections,
|
||||
staleWorktreeIds,
|
||||
setStaleWorktreeIds,
|
||||
worktreeHealth,
|
||||
setWorktreeHealth,
|
||||
orphanDirectories,
|
||||
setOrphanDirectories,
|
||||
tabOrder,
|
||||
setTabOrder,
|
||||
worktreeOrder,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export type WorktreeErrorCode = "git_not_found" | "not_git_repo" | "lfs_missing" | "no_commits"
|
||||
export type WorktreeErrorCode =
|
||||
| "git_not_found"
|
||||
| "not_git_repo"
|
||||
| "lfs_missing"
|
||||
| "no_commits"
|
||||
| "worktree_missing"
|
||||
| "worktree_unregistered"
|
||||
| "git_timeout"
|
||||
|
||||
export interface BaseUpdateRequest {
|
||||
type: "agentManager.updateFromBase"
|
||||
|
||||
@@ -884,6 +884,10 @@ export interface AgentManagerStateMessage {
|
||||
sessions: ManagedSessionState[]
|
||||
sections?: SectionState[]
|
||||
staleWorktreeIds?: string[]
|
||||
/** Why each unhealthy worktree is unhealthy; healthy worktrees are omitted. */
|
||||
worktreeHealth?: Record<string, "absent-restorable" | "absent-gone" | "unregistered" | "unavailable">
|
||||
/** Directories under `.kilo/worktrees/` that no worktree claims. */
|
||||
orphanDirectories?: string[]
|
||||
tabOrder?: Record<string, string[]>
|
||||
worktreeOrder?: string[]
|
||||
sessionsCollapsed?: boolean
|
||||
|
||||
@@ -697,6 +697,22 @@ export interface RemoveStaleWorktreeRequest {
|
||||
type: "agentManager.removeStaleWorktree"
|
||||
projectId?: string
|
||||
worktreeId: string
|
||||
/** Move the worktree's sessions to Local instead of dropping them with the entry. */
|
||||
keepSessions?: boolean
|
||||
}
|
||||
|
||||
// Re-create a worktree folder that was deleted outside Agent Manager, from its branch
|
||||
export interface RestoreWorktreeRequest {
|
||||
type: "agentManager.restoreWorktree"
|
||||
projectId?: string
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
// Delete folders under .kilo/worktrees that no worktree claims
|
||||
export interface CleanOrphanDirectoriesRequest {
|
||||
type: "agentManager.cleanOrphanDirectories"
|
||||
projectId?: string
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
// Promote a session: create a worktree and move the session into it
|
||||
@@ -1683,6 +1699,8 @@ export type WebviewMessage =
|
||||
| CreateWorktreeRequest
|
||||
| DeleteWorktreeRequest
|
||||
| RemoveStaleWorktreeRequest
|
||||
| RestoreWorktreeRequest
|
||||
| CleanOrphanDirectoriesRequest
|
||||
| PromoteSessionRequest
|
||||
| OpenLocallyRequest
|
||||
| OpenSessionLocallyRequest
|
||||
|
||||
Reference in New Issue
Block a user