fix(lanes): removing a lane deletes its record only, never the worktree

A lane's `remove` tore down what the lane pointed at: `git worktree remove
--force`, a branch delete, and a drop of the lane's own database. One
mis-click on a dashboard row cost a whole branch's unpushed code — and the
row is the only thing AgentHub actually owns. `remove` now stops the run and
the stack, clears AgentHub's own runtime bookkeeping under
`LANES_ROOT/.state/lane<slot>/`, and deletes the row. The working tree, its
branch, git's worktree records and the database stay exactly where they are,
for the user to delete by hand when they mean to.

That also fixes #28. Removal used to route through `assertDestroyable`, whose
third check demands the cwd appear in `git worktree list`; a managed lane
whose directory git no longer listed answered `ENOTWORKTREE` ("lane is not
listed as a worktree in …") and could never be dropped from the dashboard at
all. Removal no longer asks git anything, so no preflight blocker stops it,
and the unpushed-commits force-gate now applies to `reset` alone — nothing
`remove` does can discard a commit.

The UI follows: one label for both lane kinds ("Delete lane record"), copy
that says what survives, and no Force checkbox on removal.
`removeWorktree`/`removeLaneData` remain in place as guarded primitives; no
route calls them any more.
This commit is contained in:
2026-09-18 09:44:17 +07:00
parent 75a2de60b2
commit 0004cf7036
13 changed files with 136 additions and 103 deletions
+17 -20
View File
@@ -35,7 +35,6 @@ const {
isGitRepo,
isValidBranchName,
listBranches,
removeWorktree,
resetWorktree,
resolveBase,
slugify,
@@ -53,7 +52,6 @@ const {
requireProfile,
provisionLane,
resetLaneData,
removeLaneData,
} = require("../lib/lane-runtime");
const { reapOrphanMcp, capOversizedLogs } = require("../lib/lane-gc");
const { detectNode, scaffoldProfile } = require("../lib/lane-detect");
@@ -1250,21 +1248,20 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
if (!lockedLane) throw lifecycleError("ENOLANE", "lane not found");
await stopLaneRun(lockedLane);
// A running stack holds files open in the very directory reset and remove
// are about to rewrite or delete, and its processes would outlive the lane
// still bound to its ports. Stop it before touching git. Idempotent and a
// no-op for a lane that was never brought up.
// A running stack holds files open in the very directory `reset` is about
// to rewrite, and either way its processes would outlive the lane still
// bound to its ports. Stop it before touching git or dropping the row.
// Idempotent and a no-op for a lane that was never brought up.
if (action === "reset" || action === "remove") {
await downLane(lanesLib.getLane(lane.id));
}
const current = lanesLib.getLane(lane.id);
const facts = await preflight(current, action);
assertExpectedPreflight(action, facts, body.expect);
if (
(action === "reset" || (action === "remove" && current.kind === "managed")) &&
facts.unpushed > 0 &&
body.force !== true
) {
// Only `reset` can lose committed work now — `remove` forgets the row and
// touches neither the worktree nor the branch, so unpushed commits are
// nothing it can discard.
if (action === "reset" && facts.unpushed > 0 && body.force !== true) {
throw lifecycleError(
"EUNPUSHED",
`${action} requires force: true when commits are unpushed`
@@ -1286,15 +1283,15 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
return { lane: lanesLib.getLane(current.id) };
}
if (action === "remove") {
// Drop the lane's own database(s) before anything else — its state
// directory (the drop-created marker) is about to be deleted too.
const profile = resolveProfile(current);
if (profile && current.slot) await removeLaneData(current, profile);
// Forgetting an adopted lane only removes dashboard metadata. The
// filesystem destroy guard is deliberately reached only for managed
// worktrees, where removal can actually touch a directory.
if (current.kind === "managed") await removeWorktree(current);
// Runtime bookkeeping outlives the row otherwise: pid files and hook
// `remove` forgets the lane, it does not destroy it. The worktree
// directory, its branch and the lane's own database(s) are the USER's
// data and outlive the dashboard's record of them — one mis-click here
// used to cost a whole branch's unpushed code, and no dashboard row is
// worth that. Deleting any of it is the user's own call, by hand
// (`git worktree remove` / `git branch -d` / dropping the db).
//
// Runtime bookkeeping is the exception, because it is AgentHub's and
// not the user's: pid files and hook
// logs under .state/lane<slot>/ would be inherited by whichever lane
// claims that slot next. Deleting the row is what frees the slot —
// usedSlots() reads the table, so there is nothing else to release.