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
1 change: 1 addition & 0 deletions .claude/skills/verify-berthd/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The maintained list of what a person can do in the app, and how to prove each on
| --- | --- | --- |
| Sidebar places: Pinned, More, Hidden, counts on rows (Home's waiting, Review's, a plugin's) | [sidebar-nav.md](sidebar-nav.md) | `plugin-nav-badges` |
| Back and forward: the window strip's arrows, ⌘[ and ⌘], the mouse's buttons; archiving or removing the worktree in front goes back, not Home; the dashboard keeps its scroll | [navigation-history.md](navigation-history.md) | `navigation-history`; `app/e2e/back-forward.spec.ts` asserts it |
| Stopping agents from the Agent Dashboard: one, the picked ones, a column, Clean up; archiving their worktrees where it's safe | [dashboard-stop.md](dashboard-stop.md) | `dashboard-stop-archive`; `app/e2e/dashboard-stop-archive.spec.ts` asserts it |
| Plugins: built-in and your own, what they add (screens, sidebar items, widgets, panels) | [plugins.md](plugins.md) | `plugin-nav-badges` (sidebar items) |
| Home's widget grid | [home-widgets.md](home-widgets.md) | none yet; `app/e2e/home-widgets.spec.ts` asserts it |
| Questions in chat: an agent's form of questions as a card, answered on a real box; kept with Retry and Copy when it can't be | [chat-questions.md](chat-questions.md) | `chat-questions-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` on the mock |
Expand Down
27 changes: 27 additions & 0 deletions .claude/skills/verify-berthd/features/dashboard-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Stopping agents from the Agent Dashboard

Stopping ends an agent's session on its box (`app/src/views/dashboard/stop.tsx`). Its worktree and files stay unless the bulk dialog is told to archive them. The archive goes through `removeWorktreeOnBox(…, "archive")` (`lib/remove-worktree.ts`), the same path as the sidebar's Archive… and the Worktrees table's bulk archive. `lib/archive-after-stop.ts` decides which worktrees to archive and which to keep (unit tests next to it). The box refuses a worktree with uncommitted or untracked files before it stops anything or runs the archive script (`dropWorktree` in `internal/box/api.go`), in git's words, so every Archive path keeps dirty work up front.

## Sub-features

- `stop-one`: a card's ⋯ → **Stop agent…** (asks first) and **Stop and remove worktree** (removes it without asking; git keeps a dirty one, and a toast offers Remove anyway).
- `stop-picked`: **Select**, click cards, **Stop N…** (or ⌫). Also a column's ⋯ → **Stop all N in <column>…**. The dialog lists them by box, each with a checkbox. A busy one says "working" or "needs you".
- `cleanup`: **Clean up…** in the header, or a Done/Ready column's ⋯ → **Clean up idle agents…**. It ticks the ones idle longer than 1 hour / 4 hours / 1 day (remembered in `berth.dashboard.cleanupAfter`). A parked agent is listed but never ticked for you.
- `stop-archive`: in both bulk dialogs, **Archive their worktrees where it's safe**. It shows only when a ticked row has a worktree that isn't the main checkout, starts unticked, and is remembered (`berth.dashboard.stopArchives`). Ticked, the agents stop first. Then each of their worktrees is archived once: one at a time on each box, the boxes side by side. A row reads `Stopped · Archived; its branch is kept`, `Stopped · Archiving: its archive script runs, then it goes` (the location has an archive script), `Stopped · Kept: uncommitted changes` (git refused), `Stopped · Kept: another agent is still running there` (an agent session outside the batch is live in it or in a folder under it, by the box's own fresh listing; plain shells and services don't count), `Stopped · Kept: an agent there didn't stop`, `Stopped · Kept: couldn't check for other agents on its box` (its box didn't answer the listing), or `Stopped · Couldn't archive: …`. A kept or failed one holds the dialog open on "Stopped N agents · archived/archiving M worktrees". Otherwise it closes with a toast saying the same.

## How to get to it (user POV)

- ⌘J opens the Agent Dashboard. **Select**, click cards, then **Stop N…** in the bar at the bottom.

## Driving it with drive.mjs

- `node .claude/skills/verify-berthd/scripts/drive.mjs dashboard-stop-archive --port $PORT` picks four agents. It covers each outcome: search-perf archives through shop's archive script, ci-flake on gpu archives at once, checkout-fix is kept for uncommitted changes, and order-export is kept because two more agents run there. It ticks the box, stops them, checks each row, then checks the Worktrees table (two gone, two still there), and that **Clean up…** opens with the box ticked.
- On a real box: `scripts/dev-box.sh up`, then `scripts/archive-dirty-live.sh` archives a dirty and a clean worktree of acme-shop under an archive script, through the agent's API. The dirty one is refused at once (400, "contains modified or untracked files"), and its file stays while the script doesn't run. The clean one answers 202, the script runs in it, and it goes. `dev-box.sh down` afterwards.
- `app/e2e/dashboard-stop-archive.spec.ts` asserts the same, plus unticked (nothing archived, the old copy and toast) and a main checkout alone (no box).

## Gotchas

- With the default prefs the sidebar is the rail layout, which shows one project at a time. Read the outcome in the Worktrees table, or open with `labs: false` prefs, as the e2e does, to use the sidebar's `worktree-row`s.
- In the mock, checkout-fix's agent is in Needs you, not Done: it is the only dirty worktree in the fixtures.
- The mock's archive script takes 1.5s, so search-perf leaves the table a moment after the dialog says Archiving.
- axe over the open dialog flags the board's cards behind it (`.opacity-70` meta text), not the dialog.
32 changes: 32 additions & 0 deletions .claude/skills/verify-berthd/scripts/archive-dirty-live.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# archive-dirty-live.sh: on the box dev-box.sh starts, archive a dirty and a
# clean worktree of acme-shop under an archive script, through the laptop
# agent's API as the app does. The dirty one must be refused up front in
# git's words (400), with its file kept and the script never run; the clean
# one archives (202), the script runs in it, and it goes.
# Run dev-box.sh up first; dev-box.sh down cleans it all up.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
eval "$("$here/dev-box.sh" env 2>/dev/null)"
api=http://127.0.0.1:9378/v1/boxes/devq/api
log=$(mktemp -d)/ran.log
call() { curl -sS -H "Authorization: Bearer $VERIFY_BERTH_TOKEN" -H "Content-Type: application/json" "$@"; }
fail() { echo "FAIL: $*" >&2; exit 1; }

call -X PUT "$api/locations/acme-shop/scripts" -d "{\"Archive\":\"echo \\\"ran in \$PWD\\\" >> $log\"}" >/dev/null
dirty=$(call -X POST "$api/locations/acme-shop/worktrees" -d '{"name":"verify-dirty"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["path"])')
call -X POST "$api/locations/acme-shop/worktrees" -d '{"name":"verify-clean"}' >/dev/null
echo "not committed" > "$dirty/notes.txt"

out=$(call -w '\n%{http_code}' -X DELETE "$api/locations/acme-shop/worktrees/verify-dirty")
echo "dirty: $out"
[[ $(tail -1 <<<"$out") == 400 && $out == *"contains modified or untracked files"* ]] || fail "the dirty worktree was not refused up front"
out=$(call -w '\n%{http_code}' -X DELETE "$api/locations/acme-shop/worktrees/verify-clean")
echo "clean: $out"
[[ $(tail -1 <<<"$out") == 202 ]] || fail "the clean worktree did not start archiving"

for _ in $(seq 40); do grep -q verify-clean "$log" 2>/dev/null && break; sleep 0.25; done
grep -q verify-clean "$log" || fail "the archive script did not run on the clean worktree"
! grep -q verify-dirty "$log" || fail "the archive script ran on the dirty worktree"
[[ -f $dirty/notes.txt ]] || fail "the dirty worktree lost its file"
echo "OK: dirty refused before its script, clean archived"
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// The Agent Dashboard's bulk Stop, with "Archive their worktrees where it's
// safe" ticked (views/dashboard/stop.tsx): a clean worktree with an archive
// script archives in the background, one without archives at once, a dirty
// one is kept, and one with another agent still in it is kept.
// features/dashboard-stop.md.

const picks = [
// devl / search-perf: shop has an archive script, so it runs first.
"Speed up product search",
// gpu / ci-flake: no archive script, so it goes at once.
"Move acme billing to the new ledger",
// devl / checkout-fix: uncommitted work, so git keeps it.
"Fix checkout webhook retries",
// devl / order-export: two more agents still run there.
"Export orders as CSV from the admin",
];

export async function run({ page, shot, step, expect }) {
const dialog = page.getByRole("alertdialog");
const archiveBox = dialog.getByRole("checkbox", { name: /Archive their worktrees where it's safe/ });
const row = (place) => dialog.getByRole("group", { name: "Agents to stop" }).getByRole("listitem").filter({ hasText: place });
// The Worktrees table lists every box's; the sidebar shows one project.
const listed = (name) => page.getByRole("checkbox", { name: `Select ${name}`, exact: true });

step("open the Agent Dashboard, Select, and pick four agents");
await page.keyboard.press("Meta+j");
await expect(page.getByRole("heading", { name: "Agent Dashboard" })).toBeVisible();
await page.getByRole("button", { name: "Select", exact: true }).click();
for (const t of picks) await page.locator("article[role=button]", { hasText: t }).getByText(t).click();
await page.getByRole("button", { name: `Stop ${picks.length}…` }).click();
await expect(archiveBox).not.toBeChecked();
await expect(dialog).toContainText("its worktree and files are kept");
await shot("1-stop-dialog-unticked");

step("tick 'Archive their worktrees where it's safe': the copy says so");
await archiveBox.click();
await expect(archiveBox).toBeChecked();
await expect(dialog).toContainText("then its worktree is archived where it's safe");
await shot("2-stop-dialog-ticked");

step("Stop 4 agents: kept work holds the dialog open, each row with its outcome");
await dialog.getByRole("button", { name: `Stop ${picks.length} agents` }).click();
await expect(dialog.getByRole("heading")).toHaveText("Stopped 4 agents · archiving 2 worktrees");
await expect(row("search-perf")).toContainText("Stopped · Archiving: its archive script runs, then it goes");
await expect(row("ci-flake")).toContainText("Stopped · Archived; its branch is kept");
await expect(row("checkout-fix")).toContainText("Stopped · Kept: uncommitted changes");
await expect(row("order-export")).toContainText("Stopped · Kept: another agent is still running there");
await shot("3-stop-dialog-done");

step("Done, then the Worktrees table: the archived ones are gone, the kept ones stay");
await dialog.getByRole("button", { name: "Done" }).click();
await page.getByTestId("nav-worktrees").click();
await expect(listed("checkout-fix")).toBeVisible();
await expect(listed("order-export")).toBeVisible();
await expect(listed("ci-flake")).toHaveCount(0, { timeout: 10_000 });
await expect(listed("search-perf")).toHaveCount(0, { timeout: 10_000 });
await shot("4-worktrees-after");

step("the choice is remembered: Clean up… opens with the box ticked");
await page.keyboard.press("Meta+j");
await page.getByRole("button", { name: "Clean up…" }).click();
await expect(archiveBox).toBeChecked();
await expect(dialog).toContainText("Their worktrees are archived where it's safe");
await shot("5-cleanup-remembers");
}
88 changes: 88 additions & 0 deletions app/e2e/dashboard-stop-archive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { expect, type App, mockOnly, test } from "./fixtures";

// The board's bulk Stop can archive the stopped agents' worktrees too, where
// it's safe. In the fixtures shop on devl has an archive script, so an
// archive runs it first; checkout-fix has uncommitted work, so git keeps it;
// order-export has three agents, so stopping one leaves the others there.

const prefs = { labs: false, labsChosen: true, version: 3 };

// A card on the board, picked while selecting (or opened otherwise).
const pick = (app: App, title: string) => app.page.locator("article[role=button]", { hasText: title }).getByText(title).click();

async function stopPicked(app: App, titles: string[]) {
const { page } = app;
await page.getByTestId("nav-home").click();
await page.getByRole("button", { name: "Select", exact: true }).click();
for (const t of titles) await pick(app, t);
await page.getByRole("button", { name: `Stop ${titles.length}…` }).click();
return page.getByRole("alertdialog");
}

const archiveBox = (app: App) => app.page.getByRole("checkbox", { name: /Archive their worktrees where it's safe/ });
const row = (app: App, place: string) => app.page.getByRole("group", { name: "Agents to stop" }).getByRole("listitem").filter({ hasText: place });

test("ticked, Stop archives a clean worktree and keeps a dirty one, saying why", async ({ app }) => {
mockOnly("stops agents and archives worktrees");
await app.open({ prefs });
const dialog = await stopPicked(app, ["Speed up product search", "Fix checkout webhook retries"]);
await expect(archiveBox(app)).not.toBeChecked();
await archiveBox(app).click();
await expect(dialog).toContainText("then its worktree is archived where it's safe");
await dialog.getByRole("button", { name: "Stop 2 agents" }).click();

// Kept work holds the dialog open, with each row's outcome.
await expect(dialog.getByRole("heading")).toHaveText("Stopped 2 agents · archiving 1 worktree");
await expect(row(app, "search-perf")).toContainText("Stopped · Archiving: its archive script runs, then it goes");
await expect(row(app, "checkout-fix")).toContainText("Stopped · Kept: uncommitted changes");
await dialog.getByRole("button", { name: "Done" }).click();

// The clean one goes once its script has run; the dirty one stays.
await expect(app.worktree("devl/search-perf")).toHaveCount(0, { timeout: 10_000 });
await expect(app.worktree("devl/checkout-fix")).toBeVisible();
});

test("a worktree where another agent still runs is kept", async ({ app }) => {
mockOnly("stops agents and archives worktrees");
await app.open({ prefs });
const dialog = await stopPicked(app, ["Export orders as CSV from the admin"]);
await archiveBox(app).click();
await dialog.getByRole("button", { name: "Stop 1 agent" }).click();
await expect(dialog.getByRole("heading")).toHaveText("Stopped 1 agent");
await expect(row(app, "order-export")).toContainText("Kept: another agent is still running there");
await dialog.getByRole("button", { name: "Done" }).click();
await expect(app.worktree("devl/order-export")).toBeVisible();
});

test("unticked, Stop only stops: the worktree stays", async ({ app }) => {
mockOnly("stops agents");
await app.open({ prefs });
const dialog = await stopPicked(app, ["Speed up product search"]);
await expect(dialog).toContainText("its worktree and files are kept");
await dialog.getByRole("button", { name: "Stop 1 agent" }).click();
await expect(dialog).toHaveCount(0);
await expect(app.page.locator("[data-slot=toast-viewport] > *", { hasText: "Stopped 1 agent" })).toContainText("Their worktrees are kept.");
await expect(app.worktree("devl/search-perf")).toBeVisible();
});

test("the archive choice is remembered, and not offered for the main checkout alone", async ({ app }) => {
mockOnly("stops agents");
await app.open({ prefs });
let dialog = await stopPicked(app, ["Speed up product search"]);
await archiveBox(app).click();
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).toHaveCount(0);
await expect.poll(() => app.stored("berth.dashboard.stopArchives")).toBe(true);

await app.page.getByRole("button", { name: "Stop 1…" }).click();
dialog = app.page.getByRole("alertdialog");
await expect(archiveBox(app)).toBeChecked();
await dialog.getByRole("button", { name: "Cancel" }).click();

// Only gpu's main checkout: there is nothing to archive.
await pick(app, "Speed up product search");
await pick(app, "Plan the checkout release");
await app.page.getByRole("button", { name: "Stop 1…" }).click();
await expect(app.page.getByRole("alertdialog")).toContainText("its worktree and files are kept");
await expect(archiveBox(app)).toHaveCount(0);
});
59 changes: 59 additions & 0 deletions app/src/lib/archive-after-stop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// node --experimental-strip-types --test src/lib/archive-after-stop.test.ts (pnpm test)
import assert from "node:assert/strict";
import { test } from "node:test";

import { KEPT_OTHER_AGENT, KEPT_STOP_FAILED, KEPT_UNCHECKED, planArchive, type StopTarget } from "./archive-after-stop.ts";

const at = (session: string, wt: string, main = false): StopTarget => ({ key: `devl/${session}`, box: "devl", where: { location: "shop", worktree: { name: wt, path: `/w/${wt}`, main } } });
const names = (ws: { wt: { name: string } }[]) => ws.map((w) => w.wt.name);

test("each worktree is archived once, however many stopped agents worked there", () => {
const targets = [at("a", "fix"), at("b", "fix"), at("c", "perf")];
const plan = planArchive(targets, new Set(targets.map((t) => t.key)), []);
assert.deepEqual(names(plan.archive), ["fix", "perf"]);
assert.deepEqual(plan.archive[0].rows, ["devl/a", "devl/b"]);
assert.deepEqual(plan.kept, []);
});

test("the main checkout and an agent with no worktree are never candidates", () => {
const loose: StopTarget = { key: "devl/x", box: "devl" };
const targets = [at("a", "shop", true), loose];
const plan = planArchive(targets, new Set(targets.map((t) => t.key)), []);
assert.deepEqual(plan, { archive: [], kept: [] });
});

test("another live agent outside the batch keeps the worktree; the batch's own don't", () => {
const targets = [at("a", "fix"), at("b", "perf")];
const live = [
{ box: "devl", dir: "/w/fix/app", name: "other" },
{ box: "devl", dir: "/w/perf-old", name: "beside" },
{ box: "devl", dir: "/w/perf", name: "b" },
{ box: "gpu", dir: "/w/perf", name: "elsewhere" },
];
const plan = planArchive(targets, new Set(targets.map((t) => t.key)), live);
assert.deepEqual(names(plan.archive), ["perf"]);
assert.deepEqual(
plan.kept.map((k) => [k.wt.name, k.why]),
[["fix", KEPT_OTHER_AGENT]],
);
});

test("a worktree whose stops all failed is left out; one where some failed is kept", () => {
const targets = [at("a", "fix"), at("b", "perf"), at("c", "perf")];
const plan = planArchive(targets, new Set(["devl/b"]), []);
assert.deepEqual(plan.archive, []);
assert.deepEqual(
plan.kept.map((k) => [k.wt.name, k.why]),
[["perf", KEPT_STOP_FAILED]],
);
});

test("a box that didn't list its sessions keeps its worktrees", () => {
const targets = [at("a", "fix"), { ...at("b", "perf"), key: "gpu/b", box: "gpu" }];
const plan = planArchive(targets, new Set(targets.map((t) => t.key)), [], new Set(["devl"]));
assert.deepEqual(names(plan.archive), ["perf"]);
assert.deepEqual(
plan.kept.map((k) => [k.wt.name, k.why]),
[["fix", KEPT_UNCHECKED]],
);
});
Loading