Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-stale-worktree-removal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Allow removing stale Agent Manager entries for deleted worktrees when terminal cleanup cannot reach the backend.
12 changes: 11 additions & 1 deletion packages/kilo-vscode/src/agent-manager/provider-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
import { lstat } from "node:fs/promises"
import { getErrorMessage } from "../kilo-provider-utils"
import type { AgentManagerOutMessage } from "./types"
import { PLATFORM } from "./constants"
Expand Down Expand Up @@ -251,7 +252,16 @@ export async function removeStaleLifecycleWorktree(
releasePtyCleanup()
} catch (error) {
host.log(`Failed to remove stale worktree PTYs: ${error}`)
return null
// A deleted directory may no longer be reachable through the backend.
// Only bypass cleanup when the path is missing, not when access is denied.
const missing = await lstat(worktree.path).then(
() => false,
(err: NodeJS.ErrnoException) => err.code === "ENOENT",
)
if (!missing) {
host.post({ type: "error", message: "Failed to stop terminals before removing the stale worktree" })
return null
}
}
host.forgetName(worktreeId)
const orphaned = state.removeWorktree(worktreeId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import * as os from "node:os"
import * as path from "node:path"
import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client"
import { ProjectContext } from "../../src/agent-manager/project/context"
import { deleteLifecycleWorktree, type LifecycleHost } from "../../src/agent-manager/provider-lifecycle"
import {
deleteLifecycleWorktree,
removeStaleLifecycleWorktree,
type LifecycleHost,
} from "../../src/agent-manager/provider-lifecycle"
import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"

describe("Agent Manager worktree deletion lifecycle", () => {
Expand Down Expand Up @@ -128,6 +132,69 @@ describe("Agent Manager worktree deletion lifecycle", () => {

const deleteWorktree = async () => deleteLifecycleWorktree(ctx, host, state.getWorktrees()[0]!.id)

it("removes and persists a missing stale entry even when backend terminal cleanup fails", async () => {
const id = state.getWorktrees().at(0)!.id
state.addSession("first", id)
state.addSession("second", id)
ctx.stale.add(id)
fs.rmdirSync(worktree)
host.acquirePtyCleanup = async () => {
calls.push("pty")
throw new Error("directory not found")
}

await removeStaleLifecycleWorktree(ctx, host, id)
await state.flush()

expect(state.getWorktrees()).toEqual([])
expect(state.getSessions()).toEqual([])
expect(ctx.stale.has(id)).toBe(false)
expect(calls).toEqual(["run:remove", "run:clear", "pty", "name", "diff", "clear:first", "clear:second", "push"])
const saved = new WorktreeStateManager(root, () => undefined)
await saved.load()
expect(saved.getWorktrees()).toEqual([])
expect(saved.getSessions()).toEqual([])
expect(client.session.delete).not.toHaveBeenCalled()
})

it("preserves a stale entry and reports terminal cleanup failure when its directory still exists", async () => {
const id = state.getWorktrees().at(0)!.id
ctx.stale.add(id)
host.acquirePtyCleanup = async () => {
throw new Error("backend unavailable")
}

await removeStaleLifecycleWorktree(ctx, host, id)

expect(state.getWorktree(id)).toBeDefined()
expect(ctx.stale.has(id)).toBe(true)
expect(calls).toEqual(["run:remove", "run:clear", "post:error"])
expect(fs.existsSync(worktree)).toBe(true)
})

it("removes an unregistered stale entry without deleting its remaining directory", async () => {
const id = state.getWorktrees().at(0)!.id
ctx.stale.add(id)

await removeStaleLifecycleWorktree(ctx, host, id)

expect(state.getWorktrees()).toEqual([])
expect(ctx.stale.has(id)).toBe(false)
expect(calls).toContain("pty:release")
expect(calls).toContain("push")
expect(calls).not.toContain("disk")
expect(fs.existsSync(worktree)).toBe(true)
})

it("ignores stale removal for a worktree that is not marked stale", async () => {
const id = state.getWorktrees().at(0)!.id

await removeStaleLifecycleWorktree(ctx, host, id)

expect(state.getWorktree(id)).toBeDefined()
expect(calls).toEqual([])
})

it("acknowledges completion only after disk deletion, before pushing the removed state", async () => {
const disk = Promise.withResolvers<void>()
const entered = Promise.withResolvers<void>()
Expand Down
Loading