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:
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v1.6.10 (2026-09-17) + per-profile Claude quota (`server/lib/cc-usage.js`, `POST /api/cc-config/profiles/usage`, the **Check quota** button in the Profiles modal) — a POST because a saved profile's token is normally expired, so answering refreshes it and the refresh ROTATES the credentials written back to disk, which is also why it never runs on a poll; the LIVE account is the exception and is read from `.credentials.json` via `cc-profiles.liveCredentialsFor`, never refreshed, because the CLI has already retired the refresh token its profile copy holds + the settings patch that changes nothing now writing nothing (`patchSettings` in `server/lib/cc-mutate.js`, plus `.claude/.cc-config-backups/` in a lane's git exclude) + the Add-lane modal closing itself on a clean setup and offering a real Close when a step failed + a lane's router pick no longer landing on whichever lane the user switched to mid-apply (`LaneRouterRow`), nor reverting on the next lane switch — `POST /api/lanes/:id/router/apply` now `broadcastLane`s like every other mutating lane route, so the client's cached row stops disagreeing with what was written + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||
_Last synced: v1.6.11 (2026-09-18) + lane removal being record-only (the `remove` branch in `server/routes/lanes.js` no longer calls `removeWorktree`/`removeLaneData`, so a worktree, its branch and its database survive forgetting a lane — see the rule under **Lanes**) + v1.6.10 (2026-09-17) + per-profile Claude quota (`server/lib/cc-usage.js`, `POST /api/cc-config/profiles/usage`, the **Check quota** button in the Profiles modal) — a POST because a saved profile's token is normally expired, so answering refreshes it and the refresh ROTATES the credentials written back to disk, which is also why it never runs on a poll; the LIVE account is the exception and is read from `.credentials.json` via `cc-profiles.liveCredentialsFor`, never refreshed, because the CLI has already retired the refresh token its profile copy holds + the settings patch that changes nothing now writing nothing (`patchSettings` in `server/lib/cc-mutate.js`, plus `.claude/.cc-config-backups/` in a lane's git exclude) + the Add-lane modal closing itself on a clean setup and offering a real Close when a step failed + a lane's router pick no longer landing on whichever lane the user switched to mid-apply (`LaneRouterRow`), nor reverting on the next lane switch — `POST /api/lanes/:id/router/apply` now `broadcastLane`s like every other mutating lane route, so the client's cached row stops disagreeing with what was written + a run being opened by a DECLARATION only — a backwards DETECTION now continues the run in flight (`appendStageEvent` in `server/lib/lanes.js`), because detection bounces within one feature and `runs` is what every pass rate on the Workflows page is divided by + `SETTLE_MS` in `server/lib/cc-reload.js` — the two reload commands are typed a second apart, and `reloadOpenSessions` is now async, because Claude Code swallows the Enter after a slash command and back-to-back sends merged into one composer line + v1.6.0 (2026-09-14) + the lane strip's drag/Alt-arrow reordering and inline rename (`client/src/lib/laneOrderStorage.ts`, `LaneStripCard`'s `index`/`onReorder` props, the Workspace detail header's rename field, and `normalizeTitle`/`bindable` in `server/lib/lanes.js`) + the uncommitted-files hover panel (`LaneGitFilesPanel` in `LaneCard`, and `files` from `GET /api/lanes/:id/git` now being `{path,status,added,deleted}` records off one extra `git diff --numstat HEAD`) + AI Router profiles (`server/lib/ai-routers.js`, `server/lib/router-apply.js`, the Settings tab `/settings?tool=ai-router`, its **Test** button / `POST /api/ai-routers/test`, the per-lane router select — labelled **Model**, `LaneRouterRow` exported from `LaneCard` and rendered in the Workspace lane-detail header row — and `lanes.router_profile`) + the bootstrap's superseded-build guard and plugin-cache pruner (`supersededBy`/`pruneRetiredPluginRoots` in `scripts/plugin-bootstrap.js`, `unitRoot` in `scripts/systemd-service.js`) + v1.5.6 (2026-09-11) + the systemd handover rule below (`scripts/systemd-service.js`'s `writeUserService`/`restartUserService` split and `plugin-bootstrap.js`'s deferred `pendingSystemdRestart`) + eb63569 (2026-09-10) + release-based update detection (`server/lib/update-check.js` reads the forge releases API — a checkout via its `origin` remote, a plugin-cache install via the marketplace manifest on disk, since an archive-sourced cache dir has no `.git`; a `plugin_managed` install self-updates via the detached `plugin-update` chain) + the install-wide **Update all** batch and session reload (`server/lib/cc-update-all.js`, `server/lib/cc-reload.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
@@ -40,6 +40,7 @@ A **lane** is a durable unit of parallel agent work — one working directory, m
|
||||
- **A lane's `tool` is a lane fact, immutable, and never read from a request.** `lanes.tool` (`claude` | `codex`, default `claude`) is set by `createLane` only — it is deliberately absent from `PATCHABLE`, so no PATCH can flip the CLI under a live pane. Every spawn path (`POST /api/lanes/:id/start`, `POST /api/run`) takes the tool from the lane row and ignores whatever the body says; the run panel's Codex-only fields (`sandbox`, `approval`, `resumeLast`) are validated against the fixed allowlists in `server/lib/pty-run.js` before they reach an argv, and the bypass flags (`--dangerously-bypass-approvals-and-sandbox`, `--dangerously-bypass-hook-trust`) are never emitted. `GET /api/run/binary?tool=` resolves the probed binary name from that same allowlist — the query string reaches `which`.
|
||||
- **A lane's card position is a viewer preference; its name is a lane fact.** The Workspace strip's drag/Alt-arrow order lives in `localStorage` (`agenthub.workspace.laneOrder`, `client/src/lib/laneOrderStorage.ts`) and writes NOTHING server-side — `GET /api/lanes` and `agenthub lanes` stay in id order, so no `sort_order` column, no reorder route, no websocket message. Renaming is the opposite: it is `PATCH /api/lanes/:id` with `{title}`, the one lane column a user now types into directly, so `updateLane` normalizes it (`normalizeTitle`: trim, 200-char cap, `null` ⇒ `""`, because clearing is legitimate — every render is `lane.title || lane.cwd`). The rule is the column-wide one, not a special case for the field the UI exposes: `bindable` rejects any non-scalar patch value with `EBADFIELD` (`links` excepted — it is stored as JSON), and the route answers 400 for that AND for the `SQLITE_CONSTRAINT` family, rather than handing better-sqlite3 a datatype throw and reporting 500 on plain bad input.
|
||||
- **Adopted lanes are not destroyable.** A lane with `kind === "adopted"` may never have its worktree reset or removed; "removing" one only drops the dashboard's own record of it. This is enforced in code (`assertDestroyable`'s first check) — do not add a path that bypasses it.
|
||||
- **Removing a lane deletes the lane's RECORD and nothing else — for every kind, managed included.** `POST /api/lanes/:id/remove` stops the run and the stack, clears AgentHub's own runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/`, and deletes the row. It calls neither `removeWorktree` nor `removeLaneData`: the working tree, its branch and the lane's database are the USER's data, and a mis-click on a dashboard row may never cost a branch's unpushed code. Deleting those is the user's own `git worktree remove` / `git branch -d` / db drop. Consequently `remove` has no unpushed-commits force-gate (only `reset` does) and no preflight blocker stops it. Do not add a "also delete the worktree" flag back onto this route.
|
||||
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`, `resolveLaneForWork`) always pins it to `"claude"` because Codex has no hook mechanism in this codebase, so a hook event must never touch a `codex` lane sharing the path. Worktree mode is unaffected — it already allocates a unique directory per lane.
|
||||
|
||||
See `docs/LANES.md` for full guide: stage reporting, custom templates, lane actions, and the five node states.
|
||||
|
||||
@@ -219,9 +219,11 @@ A lane is a working directory the dashboard watches. Two kinds:
|
||||
|
||||
- **adopted** — a directory you already had. The dashboard only reads it; it is
|
||||
never reset or deleted.
|
||||
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It owns
|
||||
the full lifecycle and may reset or remove it, behind a three-check destroy
|
||||
guard and a counted preflight the caller has to echo back.
|
||||
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
|
||||
reset that worktree, behind a three-check destroy guard and a counted preflight
|
||||
the caller has to echo back. Removing a lane — either kind — deletes only the
|
||||
dashboard's record of it: the working tree, its branch and its database stay,
|
||||
for you to delete yourself.
|
||||
|
||||
```bash
|
||||
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
|
||||
|
||||
+10
-5
@@ -1799,9 +1799,14 @@ async function cmdLanesLifecycle(action, args) {
|
||||
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
|
||||
const keepDb = action === "reset" && args.includes("--keep-db");
|
||||
if (preflight.database) {
|
||||
const fate = action === "remove" ? "dropped" : keepDb ? "kept as-is" : "dropped and recreated";
|
||||
const fate = action === "remove" ? "kept" : keepDb ? "kept as-is" : "dropped and recreated";
|
||||
console.log(` database: ${preflight.database} (${fate})`);
|
||||
}
|
||||
if (action === "remove") {
|
||||
console.log(
|
||||
" Only the dashboard's record is dropped — the working tree, its branch and the database are left for you to delete yourself."
|
||||
);
|
||||
}
|
||||
if (Array.isArray(preflight.warnings) && preflight.warnings.length) {
|
||||
console.log("Warnings:");
|
||||
for (const warning of preflight.warnings) console.log(` ${warning}`);
|
||||
@@ -1813,9 +1818,9 @@ async function cmdLanesLifecycle(action, args) {
|
||||
return;
|
||||
}
|
||||
// Only `reset` is impossible for an adopted lane. `remove` IS supported by the
|
||||
// server: it drops the dashboard's record and never touches the directory
|
||||
// (server/routes/lanes.js scopes the worktree teardown to managed lanes), so
|
||||
// refusing it here would make a permitted action unreachable from the CLI.
|
||||
// server for every lane kind: it drops the dashboard's record and never touches
|
||||
// the directory, so refusing it here would make a permitted action unreachable
|
||||
// from the CLI.
|
||||
if (
|
||||
action === "reset" &&
|
||||
Array.isArray(preflight.blocked) &&
|
||||
@@ -2936,7 +2941,7 @@ const COMMAND_GROUPS = [
|
||||
[
|
||||
"lanes reset|remove|purge",
|
||||
"<id> [--force] [--keep-db] --yes",
|
||||
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only)",
|
||||
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
|
||||
],
|
||||
[
|
||||
"lanes up|down",
|
||||
|
||||
@@ -123,16 +123,15 @@ export function DestructiveLaneModal({
|
||||
|
||||
const blocked = blockingReason(preflight);
|
||||
// Mirrors the server's gate exactly (server/routes/lanes.js): force is required
|
||||
// for `reset`, and for `remove` only when a real worktree is at risk. Forgetting
|
||||
// an adopted lane risks nothing on disk, so it needs no force even with unpushed
|
||||
// commits. Never offered alongside a hard blocker, where confirming is
|
||||
// for `reset` alone. `remove` forgets the lane's row and deletes nothing on
|
||||
// disk — no worktree, no branch, no database — so there is no unpushed work it
|
||||
// could discard. Never offered alongside a hard blocker, where confirming is
|
||||
// impossible anyway.
|
||||
const requiresForce =
|
||||
!blocked &&
|
||||
preflight !== null &&
|
||||
preflight.action !== "purge" &&
|
||||
preflight.blocked.includes("unpushed-commits") &&
|
||||
(preflight.action === "reset" || preflight.kind === "managed");
|
||||
preflight.action === "reset" &&
|
||||
preflight.blocked.includes("unpushed-commits");
|
||||
const disabled =
|
||||
loading || !preflight || Boolean(error) || Boolean(blocked) || (requiresForce && !force);
|
||||
const facts = preflight ? Object.entries(expectFor(preflight)) : [];
|
||||
|
||||
@@ -135,7 +135,7 @@ describe("DestructiveLaneModal", () => {
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
// "adopted" is shown as context, not as a refusal.
|
||||
expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument();
|
||||
@@ -167,7 +167,7 @@ describe("DestructiveLaneModal", () => {
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
@@ -191,7 +191,7 @@ describe("DestructiveLaneModal", () => {
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it("ENABLES remove when the worktree directory is missing — the server takes the prune path", async () => {
|
||||
it("ENABLES remove when the worktree directory is missing — the row goes, git's records stay", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
@@ -212,7 +212,7 @@ describe("DestructiveLaneModal", () => {
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.getByText(/The lane directory is already gone/)).toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
@@ -238,7 +238,7 @@ describe("DestructiveLaneModal", () => {
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it("ENABLES remove when the worktree directory is unreadable — the server attempts removal and falls back to deregistering it", async () => {
|
||||
it("ENABLES remove when the worktree directory is unreadable — nothing on disk is touched either way", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
@@ -259,7 +259,7 @@ describe("DestructiveLaneModal", () => {
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
@@ -268,23 +268,34 @@ describe("DestructiveLaneModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the Force checkbox only when the sole blocker is unpushed commits, and ticking it enables confirm", async () => {
|
||||
it("never offers Force for a managed removal with unpushed commits — it deletes the row, not the branch", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({ action: "remove", unpushed: 3, blocked: ["unpushed-commits"] })
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
head: "abc0000",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
unpushed: 3,
|
||||
blocked: ["unpushed-commits"],
|
||||
})
|
||||
);
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="remove"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
await waitFor(() => expect(confirmButton).toBeDisabled());
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
expect(confirmButton).not.toBeDisabled();
|
||||
const confirmButton = await screen.findByRole("button", { name: "Delete lane record" });
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
fireEvent.click(confirmButton);
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
expect: { head: "abc0000", dirty: 0, untracked: 0, unpushed: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("a local-only managed lane (no-remote warning, unpushed-commits blocker) is resettable with Force", async () => {
|
||||
@@ -353,10 +364,10 @@ describe("DestructiveLaneModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("confirming an unpushed-commits removal with Force ticked sends force:true plus the same expect block", async () => {
|
||||
it("confirming an unpushed-commits RESET with Force ticked sends force:true plus the same expect block", async () => {
|
||||
preflightMock.mockResolvedValue(
|
||||
worktreePreflight({
|
||||
action: "remove",
|
||||
action: "reset",
|
||||
head: "abc0000",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
@@ -368,13 +379,13 @@ describe("DestructiveLaneModal", () => {
|
||||
render(
|
||||
<DestructiveLaneModal
|
||||
lane={makeLane()}
|
||||
action="remove"
|
||||
action="reset"
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
const confirmButton = await screen.findByRole("button", { name: "Remove lane" });
|
||||
const confirmButton = await screen.findByRole("button", { name: "Reset worktree" });
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
await waitFor(() => expect(confirmButton).not.toBeDisabled());
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"action.clear": "clear state",
|
||||
"action.forget": "Delete lane",
|
||||
"action.purge": "delete history",
|
||||
"action.remove": "delete lane + worktree",
|
||||
"action.reset": "reset worktree",
|
||||
"action.start": "start",
|
||||
"action.stop": "stop",
|
||||
@@ -80,14 +79,14 @@
|
||||
"destructive.loading": "Loading current facts…",
|
||||
"destructive.notice.activeSessionSkipped": "One or more sessions in this lane are still active and will be kept, not purged.",
|
||||
"destructive.notice.adopted": "This lane points at a directory you own. Only the dashboard's record of it is dropped — the directory and its files are left untouched.",
|
||||
"destructive.notice.missing": "The lane directory is already gone. Removing it drops the dashboard's record and clears Git's stale worktree entry; nothing is deleted from disk.",
|
||||
"destructive.notice.unreadable": "The lane directory cannot be read as a Git worktree. Removal is attempted; if Git itself refuses, its worktree record is cleared directly instead. Either way, the directory itself is never touched.",
|
||||
"destructive.notice.missing": "The lane directory is already gone. Deleting the lane drops the dashboard's record only; nothing on disk and no Git record is touched.",
|
||||
"destructive.notice.unreadable": "The lane directory cannot be read as a Git worktree. That changes nothing here: only the dashboard's record is deleted, the directory is never touched.",
|
||||
"destructive.purge.confirm": "Purge history",
|
||||
"destructive.purge.message": "Approve the current counts before deleting stored session history.",
|
||||
"destructive.purge.title": "Purge lane history?",
|
||||
"destructive.remove.confirm": "Remove lane",
|
||||
"destructive.remove.message": "Approve the current counts before this action.",
|
||||
"destructive.remove.title": "Remove lane?",
|
||||
"destructive.remove.confirm": "Delete lane record",
|
||||
"destructive.remove.message": "Only the dashboard's record of this lane is deleted. Its working tree, its branch and its database are left exactly as they are — delete them yourself if you want them gone.",
|
||||
"destructive.remove.title": "Delete lane record?",
|
||||
"destructive.reset.confirm": "Reset worktree",
|
||||
"destructive.reset.message": "Approve the current counts before resetting this worktree.",
|
||||
"destructive.reset.survives": "Gitignored files such as node_modules and .env are kept; untracked files are deleted.",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"action.clear": "Dọn trạng thái",
|
||||
"action.forget": "Xóa lane",
|
||||
"action.purge": "Xóa lịch sử",
|
||||
"action.remove": "Xóa làn + worktree",
|
||||
"action.reset": "Đặt lại worktree",
|
||||
"action.start": "Bắt đầu",
|
||||
"action.stop": "Dừng",
|
||||
@@ -79,15 +78,15 @@
|
||||
"destructive.force": "Tôi hiểu các commit chưa đẩy này sẽ bị loại bỏ.",
|
||||
"destructive.loading": "Đang tải trạng thái hiện tại…",
|
||||
"destructive.notice.activeSessionSkipped": "Một hoặc nhiều phiên trong làn đường này vẫn đang hoạt động và sẽ được giữ lại, không bị dọn.",
|
||||
"destructive.notice.adopted": "Làn đường này trỏ tới thư mục của bạn. Chỉ bản ghi trên bảng điều khiển bị xóa — thư mục và các tệp bên trong không bị thay đổi.",
|
||||
"destructive.notice.missing": "Thư mục làn đường đã không còn. Việc xóa chỉ bỏ bản ghi trên bảng điều khiển và dọn mục worktree cũ trong Git; không tệp nào bị xóa khỏi đĩa.",
|
||||
"destructive.notice.unreadable": "Không thể đọc thư mục làn đường như một Git worktree. Hệ thống sẽ thử xóa; nếu Git tự chối, mục ghi worktree của nó sẽ được dọn trực tiếp thay thế. Trong mọi trường hợp, thư mục vẫn không bị thay đổi.",
|
||||
"destructive.notice.adopted": "Lane này trỏ tới thư mục của bạn. Chỉ bản ghi trên bảng điều khiển bị xóa — thư mục và các tệp bên trong không bị thay đổi.",
|
||||
"destructive.notice.missing": "Thư mục lane đã không còn. Việc xóa chỉ bỏ bản ghi trên bảng điều khiển; không tệp nào trên đĩa và không bản ghi Git nào bị đụng tới.",
|
||||
"destructive.notice.unreadable": "Không thể đọc thư mục lane như một Git worktree. Điều đó không ảnh hưởng gì: chỉ bản ghi trên bảng điều khiển bị xóa, thư mục không bao giờ bị đụng tới.",
|
||||
"destructive.purge.confirm": "Xóa lịch sử",
|
||||
"destructive.purge.message": "Hãy xác nhận các số liệu hiện tại trước khi xóa lịch sử phiên đã lưu.",
|
||||
"destructive.purge.title": "Xóa lịch sử làn đường?",
|
||||
"destructive.remove.confirm": "Xóa làn đường",
|
||||
"destructive.remove.message": "Hãy xác nhận các số liệu hiện tại trước khi thực hiện thao tác này.",
|
||||
"destructive.remove.title": "Xóa làn đường?",
|
||||
"destructive.remove.confirm": "Xóa bản ghi lane",
|
||||
"destructive.remove.message": "Chỉ bản ghi của lane trên bảng điều khiển bị xóa. Working tree, nhánh và cơ sở dữ liệu của lane được giữ nguyên — bạn tự xóa nếu thực sự muốn bỏ chúng.",
|
||||
"destructive.remove.title": "Xóa bản ghi lane?",
|
||||
"destructive.reset.confirm": "Đặt lại worktree",
|
||||
"destructive.reset.message": "Hãy xác nhận các số liệu hiện tại trước khi đặt lại worktree này.",
|
||||
"destructive.reset.survives": "Các tệp Git bỏ qua như node_modules và .env được giữ lại; các tệp chưa theo dõi sẽ bị xóa.",
|
||||
|
||||
@@ -742,9 +742,9 @@ export function Workspace() {
|
||||
onClick={() => setRemoveConfirmOpen(true)}
|
||||
className="rounded px-2 py-0.5 text-xs text-status-danger transition-colors hover:bg-status-danger/15"
|
||||
>
|
||||
{currentLane.kind === "adopted"
|
||||
? tLanes("action.forget")
|
||||
: tLanes("action.remove")}
|
||||
{/* One label for both kinds: removal forgets the lane's row
|
||||
and never deletes a worktree, a branch or a database. */}
|
||||
{tLanes("action.forget")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+10
-10
@@ -118,18 +118,18 @@ agenthub lanes remove <id> [--force] --yes
|
||||
agenthub lanes purge <id> --yes
|
||||
```
|
||||
|
||||
Each command first fetches and prints its preflight counts. `reset` and `remove` show `head`, `dirty`, `untracked`, and `unpushed` (plus `database`, the name a data-isolated lane's reset/remove will drop — see "Data isolation" below); `purge` shows `sessions`, `events`, and `tokenRows`. The action refuses to run without `--yes`, and sends those exact facts back as its confirmation. Use `--force` only when the preflight reports unpushed commits. `--keep-db` (reset only) skips the whole drop-recreate-migrate-reseed sequence, leaving the lane's database exactly as it was. An adopted lane points at a directory you own, so the CLI refuses to `reset` it. `remove` IS allowed for an adopted lane: it drops only the dashboard's record and leaves the directory, its contents, and its database (if any) untouched.
|
||||
Each command first fetches and prints its preflight counts. `reset` and `remove` show `head`, `dirty`, `untracked`, and `unpushed` (plus `database`, the name a data-isolated lane's **reset** will drop — `remove` drops no database at all; see "Data isolation" below); `purge` shows `sessions`, `events`, and `tokenRows`. The action refuses to run without `--yes`, and sends those exact facts back as its confirmation. Use `--force` only when the preflight reports unpushed commits — it applies to `reset` alone, since `remove` deletes nothing that could hold them. `--keep-db` (reset only) skips the whole drop-recreate-migrate-reseed sequence, leaving the lane's database exactly as it was. An adopted lane points at a directory you own, so the CLI refuses to `reset` it. `remove` is allowed for **every** lane kind, adopted or managed, because it drops only the dashboard's record and leaves the working tree, its branch, and its database (if any) untouched — deleting those is yours to do by hand.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
### The two lane kinds
|
||||
|
||||
- **`managed`** — a git worktree the dashboard itself created (`agenthub lanes add --repo`), rooted under `LANES_ROOT`. The dashboard owns its full lifecycle: it may reset the worktree to its base branch or remove it entirely.
|
||||
- **`managed`** — a git worktree the dashboard itself created (`agenthub lanes add --repo`), rooted under `LANES_ROOT`. The dashboard may reset that worktree to its base branch. It does NOT delete it: removing the lane forgets the dashboard's record and leaves the directory and its branch on disk.
|
||||
- **`adopted`** — an existing directory you already had (`agenthub lanes add --cwd`). The dashboard only ever tracks it; it never resets or deletes that worktree. "Removing" an adopted lane drops the dashboard's record of it and leaves the directory untouched.
|
||||
|
||||
### The three independent safety checks
|
||||
|
||||
Before `reset` or `remove` touches a worktree, `assertDestroyable` (`server/lib/worktree.js`) runs three checks, all of which must pass:
|
||||
Before `reset` touches a worktree, `assertDestroyable` (`server/lib/worktree.js`) runs three checks, all of which must pass:
|
||||
|
||||
1. **`lane.kind === "managed"`.** An adopted lane fails immediately with `ENOTMANAGED` — this check alone is what keeps a directory you own off the destroy path.
|
||||
2. **The lane's `cwd`, resolved with `fs.realpathSync`, must sit inside the resolved `LANES_ROOT` on a path boundary.** `path.relative(root, cwd)` must not start with `..` or be absolute — `EOUTSIDEROOT` otherwise. This guards against a lane row that was hand-edited or drifted to point somewhere else.
|
||||
@@ -137,14 +137,14 @@ Before `reset` or `remove` touches a worktree, `assertDestroyable` (`server/lib/
|
||||
|
||||
These checks run unconditionally inside `resetWorktree`/`removeWorktree` themselves, not only when a confirmation is present — there is no code path to a git-destructive operation that skips them.
|
||||
|
||||
One documented exception, which does not weaken them: when a managed lane's directory has already been deleted by hand, `removeWorktree` takes a **prune path** instead. Checks 1 and 2 still hold (an adopted lane is refused outright; the recorded `cwd` must still be inside `LANES_ROOT`, verified lexically because a vanished path cannot be resolved), and check 3 is what the prune replaces — a worktree git no longer lists needs no removal. That path runs `git worktree prune` plus a safe branch delete against the source repo's bookkeeping and touches no directory at all.
|
||||
`removeWorktree` (with its prune path for a hand-deleted directory) still lives in `server/lib/worktree.js` and still runs those checks, but **no route calls it any more**: the lane `remove` action no longer destroys anything. It is kept as a guarded primitive, not a reachable lane verb.
|
||||
|
||||
### Each verb
|
||||
|
||||
- **`add`** — either adopts an existing directory (`--cwd`, kind `adopted`) or provisions a new managed worktree (`--repo`, kind `managed`, created under `LANES_ROOT` on branch `feat/<slug>`).
|
||||
- **`clear`** (UI/API action only, no CLI verb) — resets the lane's own bookkeeping (stage, status, gate/CI fields, notes) back to idle, **including the detected columns** (`detected_stage`/`detected_signal`/`detected_at`), since detection is forward-only and a surviving detection would both claim progress the reset tree no longer has and block every later detection. Touches only the `lanes` row; no git operation, no session/event data is deleted.
|
||||
- **`reset`** (managed lanes only) — checks out the base branch, `git reset --hard` to it, then `git clean -fd` (no `-x`, so **gitignored files such as `node_modules` and `.env` survive**; only untracked-but-not-ignored files are deleted), then recreates the feature branch off the base and clears the lane's bookkeeping. Requires `force: true` when the preflight reports unpushed commits.
|
||||
- **`remove`** — for a `managed` lane whose directory exists: `git worktree remove --force`, `git worktree prune`, then a safe branch delete, then the lane row is deleted. For a `managed` lane whose directory was deleted by hand: the prune path above (git's stale record and the branch are cleaned up, then the row is deleted) — a `missing` worktree does NOT make a lane unremovable. For an `adopted` lane: only the lane row is deleted (`removeWorktree` is never invoked for `adopted`, so the directory is never touched). The unpushed-commits force-gate only applies when the worktree is actually touched, i.e. `action === "remove" && kind === "managed"` — forgetting an adopted lane needs no `force` even with unpushed commits, since nothing on disk is at risk.
|
||||
- **`remove`** — **deletes the dashboard's record of the lane and nothing else**, for every lane kind. The working tree stays on disk, its branch stays in the source repo, git's own worktree bookkeeping is untouched, and the lane's database (if any) is left in place; deleting any of them is the user's own call (`git worktree remove`, `git branch -d`, dropping the database). Stopping the lane's run and stack still happens first — those are processes, not data — and the runtime bookkeeping under `LANES_ROOT/.state/lane<slot>/` (pid files, hook logs) is cleared, because it is AgentHub's own and would otherwise be inherited by the next lane to claim that slot. Consequently there is **no unpushed-commits force-gate on `remove`**: nothing it does can discard a commit.
|
||||
- **`purge`** — deletes the lane's own `sessions`/`events`/`token_usage` rows (excluding the lane's currently-bound or any still-active session), for its own `cwd` and subdirectories. No git operation, no worktree change; not gated on unpushed commits.
|
||||
|
||||
### The preflight contract
|
||||
@@ -156,11 +156,11 @@ One documented exception, which does not weaken them: when a managed lane's dire
|
||||
|
||||
`blocked[]` can contain: `adopted` (not a managed worktree), `missing` (the directory is gone), `unreadable` (directory exists but git failed against it), and `unpushed-commits` (unpushed count > 0 — the only one a `force: true` can override).
|
||||
|
||||
**Which entries actually prevent the action depends on the action, and the server is the authority.** All of them block `reset`. **None of them blocks `remove`** — the server supports removing an adopted lane (forget the row), a hand-deleted worktree (prune path), and an unreadable one: it attempts `git worktree remove --force`, and if git itself refuses (e.g. a corrupt worktree `.git` pointer, which git validates and rejects even with a second `--force`), it deregisters the worktree directly from the source repo's bookkeeping instead — the directory itself is never touched either way. A surface that greys out confirmation on any `blocked` entry regardless of action makes a documented capability unreachable; `client/src/components/lanes/DestructiveLaneModal.tsx` encodes the per-action set in `HARD_BLOCKERS` and renders the rest as context.
|
||||
**Which entries actually prevent the action depends on the action, and the server is the authority.** All of them block `reset`. **None of them blocks `remove`**, including `unpushed-commits`: removal only deletes the lane's row, so an adopted lane, a hand-deleted directory and an unreadable one are all removable and none of them risks anything on disk. For `remove` the preflight counts are context, not a destruction manifest. A surface that greys out confirmation on any `blocked` entry regardless of action makes a documented capability unreachable; `client/src/components/lanes/DestructiveLaneModal.tsx` encodes the per-action set in `HARD_BLOCKERS` and renders the rest as context.
|
||||
|
||||
`unpushed` is the count of commits the action would really discard: with remotes, commits on no remote; without remotes, the commits ahead of the lane's `base_branch`, which is this lane's own work. A freshly provisioned worktree in a local-only repository reports `0`, not that repository's whole history — the `no-remote` warning, not an inflated count, is what tells the user nothing is backed up. A lane with no `base_branch` at all (an adopted one) falls back to the total commit count. `warnings[]` holds purely informational facts that never gate the action: currently just `no-remote` (no git remote configured at all — nothing here is backed up remotely, but the action proceeds normally). Both arrays are data, not an exception.
|
||||
|
||||
`database` is the database name a `reset` (unless `--keep-db`) or `remove` will drop — `null` when the lane has no slot yet or its profile declares no `DB_PREFIX`. It is derived, not stored, the same way `slot`/`ports` are.
|
||||
`database` is the database name a `reset` (unless `--keep-db`) will drop — `remove` never drops one — `null` when the lane has no slot yet or its profile declares no `DB_PREFIX`. It is derived, not stored, the same way `slot`/`ports` are.
|
||||
|
||||
`bytesEstimate` is a **rough estimate**, not a measured disk size: it is derived as `(events + tokenRows) * 512`, a flat per-row byte guess, purely to give the confirmation dialog an order-of-magnitude sense of what purging will reclaim.
|
||||
|
||||
@@ -362,7 +362,7 @@ Parsed with the same literal `KEY=VALUE` reader as `profile.env` — never sourc
|
||||
- **Provisioning** a new managed worktree (`agenthub lanes add --repo`) — after the worktree is created: seed `.env`, run `bootstrap`, create the database, `migrate`, `seed`. Runs once.
|
||||
- **`up`** — repair `.env` (a hand-edited or never-seeded file gets fixed), ensure the database exists (cheap when it already does), `migrate` on every boot (a lane's schema drifts while it sits idle), and `seed` only on the boot that actually created the database.
|
||||
- **`reset`** — refresh `.env` with `--force` (preserving `ENV_PRESERVE` keys from the lane's own file — swapping in the source's `JWT_SECRET` would 401 a running lane's tokens until reboot), re-run `bootstrap` (a reset can land on a branch with new dependencies), clear the declared `LANE_DIRS`, then drop, recreate, migrate and reseed the database — unless `--keep-db`, which skips that whole block.
|
||||
- **`remove`** — drops the database and its `_test` sibling before the rest of teardown. Best-effort: a failed drop is logged, never blocks removing the lane's record. **Never runs for an adopted lane** — its data was never AgentHub's to create, so it is never AgentHub's to destroy, the same invariant that protects an adopted lane's worktree.
|
||||
- **`remove`** — drops **nothing**. `removeLaneData` still exists in `server/lib/lane-runtime.js` (and still refuses an adopted lane), but the `remove` action no longer calls it: a lane's database is the user's data and outlives the dashboard's record of it. Drop it yourself when you actually want it gone.
|
||||
|
||||
A missing source `.env` falls back to `.env.example` with a loud warning, never a silent success. And any hook output that echoes a value from `secrets.env` (the password, specifically) is redacted before it reaches `$LOG_DIR/<hook>.log` or the `lane_hook_output` WebSocket message — that stream reaches a browser tab, and a hook debugging its own environment must not publish a database password to everyone watching.
|
||||
|
||||
@@ -1321,7 +1321,7 @@ curl -X POST http://localhost:4820/api/lanes/5/clear
|
||||
|
||||
### remove
|
||||
|
||||
Delete the lane entirely, including all its history. **Requires confirmation** (`confirm: true`):
|
||||
Delete the dashboard's record of the lane, including its recorded history. The working tree, its branch and its database are left untouched. **Requires confirmation** (`confirm: true`):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:4820/api/lanes/5/remove \
|
||||
@@ -1329,7 +1329,7 @@ curl -X POST http://localhost:4820/api/lanes/5/remove \
|
||||
-d '{"confirm": true}'
|
||||
```
|
||||
|
||||
**Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidental deletion.
|
||||
**Actions gated behind confirmation:** `remove` requires the `confirm` flag to prevent accidentally dropping a lane's record. It deletes that record only — never the worktree, the branch or the database.
|
||||
|
||||
## Proof gallery
|
||||
|
||||
|
||||
@@ -673,18 +673,18 @@ describe("destructive lane lifecycle actions", () => {
|
||||
assert.equal(g(lane.cwd, "log", "-1", "--format=%s").trim(), "move head");
|
||||
});
|
||||
|
||||
it("remove tears down the worktree and the branch, and deletes the lane row", async () => {
|
||||
it("remove deletes the lane row only, leaving a managed worktree and its branch on disk", async () => {
|
||||
const lane = await createManagedLane("remove-managed");
|
||||
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`);
|
||||
const removed = await request("POST", `/api/lanes/${lane.id}/remove`, {
|
||||
confirm: true,
|
||||
force: true,
|
||||
expect: destructiveExpect(preflight.body),
|
||||
});
|
||||
assert.equal(removed.status, 200);
|
||||
assert.equal(removed.body.ok, true);
|
||||
assert.equal(fs.existsSync(lane.cwd), false);
|
||||
assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), "");
|
||||
// The user's code is the user's: forgetting a lane may never cost a branch.
|
||||
assert.equal(fs.existsSync(lane.cwd), true);
|
||||
assert.equal(g(SRC, "branch", "--list", lane.branch).trim().endsWith(lane.branch), true);
|
||||
const missing = await request("GET", `/api/lanes/${lane.id}`);
|
||||
assert.equal(missing.status, 404);
|
||||
});
|
||||
@@ -719,7 +719,7 @@ describe("destructive lane lifecycle actions", () => {
|
||||
assert.equal(fs.readFileSync(adoptedFile, "utf8"), "real project contents\n");
|
||||
});
|
||||
|
||||
it("forgets an adopted lane with unpushed commits without force, while managed removal still requires it", async () => {
|
||||
it("forgets a lane with unpushed commits without force, adopted or managed — removal destroys nothing", async () => {
|
||||
const adoptedDir = path.join(ROOT, "adopted-unpushed");
|
||||
fs.mkdirSync(adoptedDir, { recursive: true });
|
||||
g(adoptedDir, "init");
|
||||
@@ -750,13 +750,14 @@ describe("destructive lane lifecycle actions", () => {
|
||||
`/api/lanes/${managed.id}/preflight?action=remove`
|
||||
);
|
||||
assert.ok(managedPreflight.body.unpushed > 0);
|
||||
const refused = await request("POST", `/api/lanes/${managed.id}/remove`, {
|
||||
const forgottenManaged = await request("POST", `/api/lanes/${managed.id}/remove`, {
|
||||
confirm: true,
|
||||
expect: destructiveExpect(managedPreflight.body),
|
||||
});
|
||||
assert.equal(refused.status, 409);
|
||||
assert.equal(refused.body.error.code, "EUNPUSHED");
|
||||
await request("DELETE", `/api/lanes/${managed.id}`);
|
||||
assert.equal(forgottenManaged.status, 200);
|
||||
// The unpushed commit is still there, on a branch nothing deleted.
|
||||
assert.equal(fs.existsSync(managed.cwd), true);
|
||||
assert.equal(g(managed.cwd, "log", "-1", "--format=%s").trim(), "unpushed managed work");
|
||||
});
|
||||
|
||||
it("requires a complete expect object and returns stale diagnostics", async () => {
|
||||
@@ -885,11 +886,33 @@ describe("destructive lane lifecycle actions", () => {
|
||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||
});
|
||||
|
||||
it("removes a lane whose worktree was deleted by hand, taking the prune path", async () => {
|
||||
// The design promises "the lane reports `missing` and only `remove` is
|
||||
// offered, taking the prune path". Before this, `remove` hit check 2, which
|
||||
// maps a non-existent path to EOUTSIDEROOT → 400, so the lane row could
|
||||
// never be removed through the destructive action at all.
|
||||
it("removes a managed lane whose directory git no longer lists as a worktree (issue #28)", async () => {
|
||||
// Reported as "Lane action failed: lane is not listed as a worktree in …":
|
||||
// a managed lane whose directory is a plain repo git never registered (or no
|
||||
// longer registers) as a worktree used to hit assertDestroyable's check 3 and
|
||||
// become unremovable. Removal no longer asks git anything.
|
||||
const lane = await createManagedLane("remove-not-a-worktree");
|
||||
g(SRC, "worktree", "remove", "--force", lane.cwd);
|
||||
fs.mkdirSync(lane.cwd, { recursive: true });
|
||||
g(lane.cwd, "init");
|
||||
g(lane.cwd, "config", "user.email", "test@example.com");
|
||||
g(lane.cwd, "config", "user.name", "Test User");
|
||||
fs.writeFileSync(path.join(lane.cwd, "keep.txt"), "user code\n");
|
||||
|
||||
const preflight = await request("GET", `/api/lanes/${lane.id}/preflight?action=remove`);
|
||||
const removed = await request("POST", `/api/lanes/${lane.id}/remove`, {
|
||||
confirm: true,
|
||||
expect: destructiveExpect(preflight.body),
|
||||
});
|
||||
assert.equal(removed.status, 200, JSON.stringify(removed.body));
|
||||
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).status, 404);
|
||||
assert.equal(fs.readFileSync(path.join(lane.cwd, "keep.txt"), "utf8"), "user code\n");
|
||||
});
|
||||
|
||||
it("removes a lane whose worktree was deleted by hand, leaving git's own records alone", async () => {
|
||||
// A missing directory changes nothing about `remove` any more: it drops the
|
||||
// dashboard's row and never touches git. Cleaning up git's stale worktree
|
||||
// entry is the user's own `git worktree prune`.
|
||||
const lane = await createManagedLane("remove-hand-deleted");
|
||||
fs.rmSync(lane.cwd, { recursive: true, force: true });
|
||||
|
||||
@@ -905,13 +928,8 @@ describe("destructive lane lifecycle actions", () => {
|
||||
assert.equal(removed.status, 200, JSON.stringify(removed.body));
|
||||
assert.equal(removed.body.ok, true);
|
||||
assert.equal((await request("GET", `/api/lanes/${lane.id}`)).status, 404);
|
||||
// git's stale record and the lane's branch are both gone.
|
||||
const wt = require("../lib/worktree");
|
||||
assert.equal(
|
||||
(await wt.listWorktrees(SRC)).some((w) => w.path === lane.cwd),
|
||||
false
|
||||
);
|
||||
assert.equal(g(SRC, "branch", "--list", lane.branch).trim(), "");
|
||||
// The branch survives — it is where the deleted directory's commits live.
|
||||
assert.equal(g(SRC, "branch", "--list", lane.branch).trim().endsWith(lane.branch), true);
|
||||
});
|
||||
|
||||
it("refuses a second start while the first run is still live, so no tmux session is orphaned", async () => {
|
||||
|
||||
@@ -546,13 +546,14 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
assert.match(loser.stdout, /Current preflight:/);
|
||||
});
|
||||
|
||||
it("removes a managed lane's worktree and directory with --yes", async () => {
|
||||
it("removes a managed lane's record with --yes, leaving its directory on disk", async () => {
|
||||
const lane = await ownLane("CLI remove managed");
|
||||
assert.equal(fs.existsSync(lane.cwd), true);
|
||||
const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, new RegExp(`Removed lane #${lane.id}`));
|
||||
assert.equal(fs.existsSync(lane.cwd), false);
|
||||
assert.match(r.stdout, /left for you to delete yourself/);
|
||||
assert.equal(fs.existsSync(lane.cwd), true);
|
||||
assert.equal((await get(`/api/lanes/${lane.id}`)).status, 404);
|
||||
});
|
||||
|
||||
|
||||
@@ -76,8 +76,9 @@ async function preflight(lane, action) {
|
||||
}
|
||||
}
|
||||
|
||||
// The database name this action would drop (reset unless --keep-db,
|
||||
// remove always) — echoed the same way `head`/`dirty` are, so the
|
||||
// The database name this action would drop (reset unless --keep-db; never
|
||||
// for remove, which forgets the lane and leaves its data in place — see the
|
||||
// remove branch in routes/lanes.js) — echoed the same way `head`/`dirty` are, so the
|
||||
// confirmation dialog names the destructive fact rather than leaving it a
|
||||
// surprise. Null when the lane has no slot yet or the profile declares no
|
||||
// DB_PREFIX — nothing has been derived to drop.
|
||||
|
||||
+17
-20
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user