- {controller.error && (
-
-
- Storage action failed
- {controller.error}
-
- )}
+
{
+ test("explains busy activity and allows Run anyway without horizontal overflow", async ({
+ testPage,
+ prCapture,
+ }) => {
+ let runAttempts = 0;
+ await testPage.route("**/api/v1/system/storage/run", async (route) => {
+ runAttempts += 1;
+ if (runAttempts === 1) {
+ await route.fulfill({
+ status: 409,
+ contentType: "application/json",
+ body: JSON.stringify({
+ error: "storage cleanup is blocked by active Kandev work",
+ busy_resources: [{ kind: "test_command", label: "A test command is running" }],
+ force_available: true,
+ }),
+ });
+ return;
+ }
+ await route.fulfill({
+ status: 202,
+ contentType: "application/json",
+ body: JSON.stringify({ job_id: "mobile-force-cleanup" }),
+ });
+ });
+
+ await testPage.goto("/settings/system/storage");
+ await testPage.getByTestId("storage-run-now").tap();
+ await expect(testPage.getByTestId("storage-busy")).toContainText("A test command is running");
+ await expect(testPage.getByTestId("storage-run-anyway")).toBeVisible();
+ expect(
+ await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
+ ).toBe(true);
+ await prCapture.screenshot("busy-feedback", {
+ caption: "Mobile storage cleanup keeps the warning and Run anyway action in one column",
+ fullPage: true,
+ });
+ const forceRequest = testPage.waitForRequest(
+ (request) =>
+ request.method() === "POST" &&
+ new URL(request.url()).pathname === "/api/v1/system/storage/run" &&
+ request.postDataJSON()?.force === true,
+ );
+ await testPage.getByTestId("storage-run-anyway").tap();
+ expect((await forceRequest).postDataJSON()).toEqual({ force: true });
+ });
+
test("opens Storage from mobile navigation and analyzes without horizontal overflow", async ({
testPage,
backend,
diff --git a/apps/web/e2e/tests/system/storage-maintenance.spec.ts b/apps/web/e2e/tests/system/storage-maintenance.spec.ts
index ce7f59bc19..c934c5fdd0 100644
--- a/apps/web/e2e/tests/system/storage-maintenance.spec.ts
+++ b/apps/web/e2e/tests/system/storage-maintenance.spec.ts
@@ -174,6 +174,7 @@ test.describe("System storage maintenance", () => {
testPage,
apiClient,
seedData,
+ prCapture,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
@@ -224,7 +225,31 @@ test.describe("System storage maintenance", () => {
status: 409,
body: { busy_resources: expect.any(Array) },
});
- await expect(testPage.getByTestId("storage-error")).toContainText(/busy|active/i);
- await expect(testPage.getByTestId("storage-run-now")).not.toHaveAttribute("data-job-state");
+ expect(responseBody.force_available).toBe(true);
+ expect(responseBody.busy_resources[0]).toMatchObject({
+ kind: expect.any(String),
+ label: expect.any(String),
+ });
+ await expect(testPage.getByTestId("storage-busy")).toContainText(
+ responseBody.busy_resources[0].label,
+ );
+ await expect(testPage.getByTestId("storage-busy")).toContainText(/may disrupt/i);
+ await prCapture.screenshot("busy-feedback", {
+ caption: "Desktop storage cleanup explains the active work and offers Run anyway",
+ fullPage: true,
+ });
+
+ const forceRequest = testPage.waitForRequest(
+ (request) =>
+ request.method() === "POST" &&
+ new URL(request.url()).pathname === "/api/v1/system/storage/run",
+ );
+ await testPage.getByTestId("storage-run-anyway").click();
+ expect((await forceRequest).postDataJSON()).toEqual({ force: true });
+ await expect(testPage.getByTestId("storage-run-now")).toHaveAttribute(
+ "data-job-state",
+ "succeeded",
+ { timeout: 20_000 },
+ );
});
});
diff --git a/apps/web/hooks/domains/system/use-storage-maintenance.test.tsx b/apps/web/hooks/domains/system/use-storage-maintenance.test.tsx
index 6c14fc3ea8..40bfc4f9e0 100644
--- a/apps/web/hooks/domains/system/use-storage-maintenance.test.tsx
+++ b/apps/web/hooks/domains/system/use-storage-maintenance.test.tsx
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
+import { ApiError } from "@/lib/api/client";
import type { StorageMaintenanceSettings, StorageOverviewResponse } from "@/lib/types/system";
const mocks = vi.hoisted(() => ({
@@ -91,6 +92,8 @@ const cleanupJob = {
state: "running",
started_at: "2026-07-15T00:00:00Z",
};
+const STORAGE_BUSY_ERROR_MESSAGE = "storage cleanup is blocked by active Kandev work";
+const TEST_COMMAND_BUSY_LABEL = "A test command is running";
function deferred() {
let resolve!: (value: T) => void;
@@ -183,6 +186,84 @@ describe("useStorageMaintenance", () => {
});
});
+describe("useStorageMaintenance busy feedback", () => {
+ it("retains labeled busy feedback and reruns the same resources with force", async () => {
+ mocks.run.mockRejectedValueOnce(
+ new ApiError(STORAGE_BUSY_ERROR_MESSAGE, 409, {
+ busy_resources: [{ kind: "test_command", label: TEST_COMMAND_BUSY_LABEL }],
+ force_available: true,
+ }),
+ );
+ const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
+ await waitFor(() => expect(result.current.overview).toEqual(overview));
+
+ await act(async () => {
+ await result.current.runNow(["go_cache"]);
+ });
+ expect(result.current.busy).toEqual({
+ resources: [{ kind: "test_command", label: TEST_COMMAND_BUSY_LABEL }],
+ forceAvailable: true,
+ resourceSelection: ["go_cache"],
+ });
+
+ await act(async () => {
+ await result.current.runAnyway();
+ });
+ expect(mocks.run).toHaveBeenNthCalledWith(2, ["go_cache"], true);
+ });
+
+ it("restores busy feedback when the forced retry is rejected", async () => {
+ const initialBusyError = new ApiError(STORAGE_BUSY_ERROR_MESSAGE, 409, {
+ busy_resources: [{ kind: "test_command", label: TEST_COMMAND_BUSY_LABEL }],
+ force_available: true,
+ });
+ const forcedBusyError = new ApiError(STORAGE_BUSY_ERROR_MESSAGE, 409, {
+ busy_resources: [{ kind: "maintenance_running", label: "Storage maintenance is running" }],
+ force_available: false,
+ });
+ mocks.run.mockRejectedValueOnce(initialBusyError).mockRejectedValueOnce(forcedBusyError);
+ const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
+ await waitFor(() => expect(result.current.overview).toEqual(overview));
+
+ await act(async () => {
+ await result.current.runNow(["go_cache"]);
+ });
+ await act(async () => {
+ await result.current.runAnyway();
+ });
+
+ expect(result.current.busy).toEqual({
+ resources: [{ kind: "maintenance_running", label: "Storage maintenance is running" }],
+ forceAvailable: false,
+ resourceSelection: ["go_cache"],
+ });
+ expect(mocks.toast).not.toHaveBeenCalledWith(
+ expect.objectContaining({ title: "Storage action failed" }),
+ );
+ });
+
+ it("clears stale busy feedback when another storage action starts", async () => {
+ mocks.run.mockRejectedValueOnce(
+ new ApiError(STORAGE_BUSY_ERROR_MESSAGE, 409, {
+ busy_resources: [{ kind: "test_command", label: TEST_COMMAND_BUSY_LABEL }],
+ force_available: true,
+ }),
+ );
+ const { result } = renderHook(() => useStorageMaintenance(), { wrapper });
+ await waitFor(() => expect(result.current.overview).toEqual(overview));
+
+ await act(async () => {
+ await result.current.runNow();
+ });
+ expect(result.current.busy).not.toBeNull();
+
+ await act(async () => {
+ await result.current.save(settings);
+ });
+ expect(result.current.busy).toBeNull();
+ });
+});
+
describe("useStorageMaintenance pending action tracking", () => {
it("returns to a pending resource action after an overlapping save finishes", async () => {
const pendingRun = deferred<{ job_id: string }>();
diff --git a/apps/web/hooks/domains/system/use-storage-maintenance.ts b/apps/web/hooks/domains/system/use-storage-maintenance.ts
index 2134202b2b..33b0c0efa4 100644
--- a/apps/web/hooks/domains/system/use-storage-maintenance.ts
+++ b/apps/web/hooks/domains/system/use-storage-maintenance.ts
@@ -11,6 +11,7 @@ import {
} from "react";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
+import { ApiError } from "@/lib/api/client";
import {
adoptStorageGoCache,
analyzeStorage,
@@ -22,7 +23,12 @@ import {
runStorageMaintenance,
saveStorageSettings,
} from "@/lib/api/domains/system-api";
-import type { StorageMaintenanceSettings, SystemJob } from "@/lib/types/system";
+import type {
+ StorageBusyResource,
+ StorageBusyResponse,
+ StorageMaintenanceSettings,
+ SystemJob,
+} from "@/lib/types/system";
import { useSystemJob } from "./use-system-jobs";
export type StoragePendingAction =
@@ -35,6 +41,12 @@ export type StoragePendingAction =
| "delete"
| null;
+export interface StorageBusyState {
+ resources: StorageBusyResource[];
+ forceAvailable: boolean;
+ resourceSelection?: string[];
+}
+
function messageFromError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -43,6 +55,22 @@ function isTerminal(state?: string): boolean {
return state === "succeeded" || state === "failed";
}
+function busyStateFromError(error: unknown, resourceSelection?: string[]): StorageBusyState | null {
+ if (!(error instanceof ApiError) || error.status !== 409) return null;
+ const body = error.body as Partial | null;
+ if (!body || !Array.isArray(body.busy_resources)) return null;
+ const resources = body.busy_resources.filter(
+ (resource): resource is StorageBusyResource =>
+ Boolean(resource) && typeof resource.kind === "string" && typeof resource.label === "string",
+ );
+ if (resources.length === 0) return null;
+ return {
+ resources,
+ forceAvailable: body.force_available === true,
+ resourceSelection,
+ };
+}
+
export function settingsWithDockerAcknowledgement(
settings: StorageMaintenanceSettings,
acknowledged: boolean,
@@ -105,19 +133,16 @@ function useStorageActionRunner() {
return { pendingAction, error, setError, finishLoading, perform };
}
-function useStorageActions(reload: Reload) {
- const { toast } = useToast();
- const { pendingAction, error, setError, finishLoading, perform } = useStorageActionRunner();
- const [analysisJobId, setAnalysisJobId] = useState(null);
- const [cleanupJobId, setCleanupJobId] = useState(null);
- const [deleteJobId, setDeleteJobId] = useState(null);
- const analysisJob = useSystemJob(analysisJobId);
- const cleanupJob = useSystemJob(cleanupJobId);
- const deleteJob = useSystemJob(deleteJobId);
-
+function useStoragePolicyActions(
+ perform: ReturnType["perform"],
+ reload: Reload,
+ toast: ReturnType["toast"],
+ clearBusy: () => void,
+) {
const save = useCallback(
- async (settings: StorageMaintenanceSettings, confirmation?: "DEDICATED") =>
- perform(
+ async (settings: StorageMaintenanceSettings, confirmation?: "DEDICATED") => {
+ clearBusy();
+ return perform(
"save",
async () => {
await saveStorageSettings(settings, confirmation);
@@ -125,61 +150,121 @@ function useStorageActions(reload: Reload) {
toast({ title: "Storage policy saved", variant: "success" });
},
true,
- ),
- [perform, reload, toast],
+ );
+ },
+ [clearBusy, perform, reload, toast],
);
const adopt = useCallback(
- async (path: string) =>
- perform("adopt", async () => {
+ async (path: string) => {
+ clearBusy();
+ return perform("adopt", async () => {
await adoptStorageGoCache(path);
await reload();
toast({ title: "Go build cache adopted", variant: "success" });
- }),
- [perform, reload, toast],
+ });
+ },
+ [clearBusy, perform, reload, toast],
);
- const analyze = useCallback(
- async () =>
- perform("analyze", async () => {
- const accepted = await analyzeStorage();
- setAnalysisJobId(accepted.job_id);
- toast({ title: "Storage analysis started", variant: "success" });
- }),
- [perform, toast],
+ return { save, adopt };
+}
+
+function useStorageDeleteAction(
+ perform: ReturnType["perform"],
+ toast: ReturnType["toast"],
+ clearBusy: () => void,
+ setDeleteJobId: Dispatch>,
+) {
+ return useCallback(
+ async (id: string) => {
+ clearBusy();
+ return perform("delete", async () => {
+ const accepted = await deleteStorageQuarantine(id);
+ setDeleteJobId(accepted.job_id);
+ toast({ title: "Permanent deletion started", variant: "success" });
+ });
+ },
+ [clearBusy, perform, setDeleteJobId, toast],
);
+}
+
+function useStorageActions(reload: Reload) {
+ const { toast } = useToast();
+ const { pendingAction, error, setError, finishLoading, perform } = useStorageActionRunner();
+ const [analysisJobId, setAnalysisJobId] = useState(null);
+ const [cleanupJobId, setCleanupJobId] = useState(null);
+ const [deleteJobId, setDeleteJobId] = useState(null);
+ const [busy, setBusy] = useState(null);
+ const analysisJob = useSystemJob(analysisJobId);
+ const cleanupJob = useSystemJob(cleanupJobId);
+ const deleteJob = useSystemJob(deleteJobId);
+ const clearBusy = useCallback(() => setBusy(null), []);
+ const { save, adopt } = useStoragePolicyActions(perform, reload, toast, clearBusy);
+
+ const analyze = useCallback(async () => {
+ clearBusy();
+ return perform("analyze", async () => {
+ const accepted = await analyzeStorage();
+ setAnalysisJobId(accepted.job_id);
+ toast({ title: "Storage analysis started", variant: "success" });
+ });
+ }, [clearBusy, perform, toast]);
const runNow = useCallback(
async (resources?: string[]) => {
setCleanupJobId(null);
+ clearBusy();
return perform("run", async () => {
- const accepted = await runStorageMaintenance(resources);
- setCleanupJobId(accepted.job_id);
- toast({ title: "Storage maintenance started", variant: "success" });
+ try {
+ const accepted = await runStorageMaintenance(resources);
+ setCleanupJobId(accepted.job_id);
+ toast({ title: "Storage maintenance started", variant: "success" });
+ } catch (error) {
+ const nextBusy = busyStateFromError(error, resources);
+ if (nextBusy) {
+ setBusy(nextBusy);
+ return;
+ }
+ throw error;
+ }
});
},
- [perform, toast],
+ [clearBusy, perform, toast],
);
+ const runAnyway = useCallback(async () => {
+ if (!busy?.forceAvailable) return;
+ const resources = busy.resourceSelection;
+ clearBusy();
+ setCleanupJobId(null);
+ return perform("run", async () => {
+ try {
+ const accepted = await runStorageMaintenance(resources, true);
+ setCleanupJobId(accepted.job_id);
+ toast({ title: "Storage maintenance started", variant: "success" });
+ } catch (error) {
+ const nextBusy = busyStateFromError(error, resources);
+ if (nextBusy) {
+ setBusy(nextBusy);
+ return;
+ }
+ throw error;
+ }
+ });
+ }, [busy, clearBusy, perform, toast]);
const restore = useCallback(
- async (id: string) =>
- perform("restore", async () => {
+ async (id: string) => {
+ clearBusy();
+ return perform("restore", async () => {
await restoreStorageQuarantine(id);
await reload();
toast({ title: "Quarantined resource restored", variant: "success" });
- }),
- [perform, reload, toast],
- );
-
- const permanentlyDelete = useCallback(
- async (id: string) =>
- perform("delete", async () => {
- const accepted = await deleteStorageQuarantine(id);
- setDeleteJobId(accepted.job_id);
- toast({ title: "Permanent deletion started", variant: "success" });
- }),
- [perform, toast],
+ });
+ },
+ [clearBusy, perform, reload, toast],
);
+ const permanentlyDelete = useStorageDeleteAction(perform, toast, clearBusy, setDeleteJobId);
return {
pendingAction,
@@ -189,6 +274,8 @@ function useStorageActions(reload: Reload) {
save,
analyze,
runNow,
+ runAnyway,
+ busy,
adopt,
restore,
permanentlyDelete,
diff --git a/apps/web/lib/api/domains/system-api.test.ts b/apps/web/lib/api/domains/system-api.test.ts
index de97636eeb..8300ff1aca 100644
--- a/apps/web/lib/api/domains/system-api.test.ts
+++ b/apps/web/lib/api/domains/system-api.test.ts
@@ -383,11 +383,17 @@ describe("storage maintenance", () => {
it("starts analysis, selected cleanup, and restore operations", async () => {
fetchSpy
.mockResolvedValueOnce(jsonResponse({ job_id: "analysis" }))
- .mockResolvedValueOnce(jsonResponse({ job_id: "cleanup" }))
- .mockResolvedValueOnce(jsonResponse({ entry: { id: "restored" } }));
+ .mockResolvedValueOnce(jsonResponse({ job_id: "cleanup" }));
expect((await analyzeStorage()).job_id).toBe("analysis");
expect((await runStorageMaintenance(["workspaces"])).job_id).toBe("cleanup");
expect(JSON.parse(String(lastCall().init?.body))).toEqual({ resources: ["workspaces"] });
+ fetchSpy.mockResolvedValueOnce(jsonResponse({ job_id: "forced-cleanup" }));
+ expect((await runStorageMaintenance(["workspaces"], true)).job_id).toBe("forced-cleanup");
+ expect(JSON.parse(String(lastCall().init?.body))).toEqual({
+ resources: ["workspaces"],
+ force: true,
+ });
+ fetchSpy.mockResolvedValueOnce(jsonResponse({ entry: { id: "restored" } }));
expect((await restoreStorageQuarantine("entry-1")).id).toBe("restored");
});
});
diff --git a/apps/web/lib/api/domains/system-api.ts b/apps/web/lib/api/domains/system-api.ts
index f5ca75d7e1..a80ca6b01e 100644
--- a/apps/web/lib/api/domains/system-api.ts
+++ b/apps/web/lib/api/domains/system-api.ts
@@ -247,6 +247,7 @@ export function analyzeStorage(options?: ApiRequestOptions): Promise {
return fetchJson(`${SYSTEM_BASE}/storage/run`, {
@@ -254,7 +255,10 @@ export function runStorageMaintenance(
init: {
...(options?.init ?? {}),
method: "POST",
- body: JSON.stringify(resources?.length ? { resources } : {}),
+ body: JSON.stringify({
+ ...(resources?.length ? { resources } : {}),
+ ...(force ? { force: true } : {}),
+ }),
},
});
}
diff --git a/apps/web/lib/types/system.ts b/apps/web/lib/types/system.ts
index 2bc8d95918..57ab16dc15 100644
--- a/apps/web/lib/types/system.ts
+++ b/apps/web/lib/types/system.ts
@@ -259,6 +259,17 @@ export type StorageRunState =
| "cancelled"
| "skipped_busy";
+export interface StorageBusyResource {
+ kind: string;
+ label: string;
+}
+
+export interface StorageBusyResponse {
+ error: string;
+ busy_resources: StorageBusyResource[];
+ force_available: boolean;
+}
+
export interface StorageMaintenanceRun {
id: string;
trigger: "scheduled" | "manual" | "analysis";
diff --git a/docs/plans/storage-busy-override/plan.md b/docs/plans/storage-busy-override/plan.md
new file mode 100644
index 0000000000..18851cc692
--- /dev/null
+++ b/docs/plans/storage-busy-override/plan.md
@@ -0,0 +1,124 @@
+---
+spec: docs/specs/system-page/storage-maintenance.md
+created: 2026-07-27
+status: implemented
+---
+
+# Implementation Plan: Storage Busy Feedback and Override
+
+## Overview
+
+Make a blocked manual cleanup explain the Kandev activity categories that hold the gate, then let
+the operator explicitly run the same cleanup alongside current task activity. Preserve the
+install-wide mutual exclusion that prevents overlapping maintenance runs, and keep future task work
+able to cancel maintenance as it does today. The work proceeds from backend activity/API semantics,
+through the shared frontend controller and Storage page, to desktop and Pixel 5 coverage.
+
+## Backend
+
+### Activity-gate and manual-run contract
+
+- Extend `apps/backend/internal/agent/runtime/activity/coordinator.go` with a typed public busy
+ resource shape that retains the stable `Kind` and supplies the user-facing label. Preserve the
+ existing kind values and sorting, but do not expose task prompts, paths, IDs, or names.
+- Add an explicit manual-maintenance acquisition mode that may ignore already-active task work but
+ still rejects `maintenance_running`. It must install the normal maintenance lease so a task that
+ begins after the force run can cancel it through `AcquireTask`.
+- Thread an explicit `force` flag through `apps/backend/internal/system/storage/operations.go` and
+ `runner.go`. Normal manual and scheduled paths retain their current admission behavior; only a
+ manual force request skips the current-activity check.
+- Extend the `Mutations` interface and `runRequest` in
+ `apps/backend/internal/system/storage/handler.go`. A busy response returns typed
+ `busy_resources` and `force_available`; accepted forced runs retain the existing `202 { job_id }`
+ response.
+
+### Backend tests
+
+- Update `apps/backend/internal/agent/runtime/activity/coordinator_test.go` to prove force admission
+ coexists with active task work, refuses an existing maintenance lease, and remains preemptible by
+ subsequently admitted task activity.
+- Extend `apps/backend/internal/system/storage/operations_test.go`, `runner_test.go`,
+ `handler_test.go`, and `storage_routes_test.go` for the typed 409 response, force=false rejection,
+ force=true job start, and maintenance-running refusal.
+
+## Frontend
+
+### API, types, and domain controller
+
+- Add the typed busy response/resource contracts to `apps/web/lib/types/system.ts` and let
+ `runStorageMaintenance` in `apps/web/lib/api/domains/system-api.ts` send an optional `force` body
+ field without changing callers that omit it.
+- In `apps/web/hooks/domains/system/use-storage-maintenance.ts`, inspect `ApiError.body` for the
+ Storage 409 shape. Keep normal failures as ordinary action errors, but expose a structured busy
+ state and a `runAnyway` action that repeats the original resource selection with `force: true`.
+
+### Storage page and mobile contract
+
+- Update `apps/web/components/settings/system/storage/storage-maintenance-settings.tsx` to replace
+ the generic busy alert with a plain-language list of the reported activity labels, a disruption
+ warning, and a distinct **Run anyway** button when `force_available` is true. An existing
+ maintenance run remains informational with no override control.
+- Desktop keeps the busy feedback directly below the existing Analyze/Run-now action bar. On phones,
+ the existing one-column Storage actions layout remains the nearest shipped exemplar: the warning
+ and full-width, 44px **Run anyway** action stack inline with the page, need no drawer or dialog,
+ preserve the document as the single scroll owner, and retain zero horizontal overflow.
+
+## Tests
+
+- **Activity and operation semantics:** force cleanup runs beside existing task activity, cannot run
+ beside maintenance, and later task work still cancels it. **Files:**
+ `apps/backend/internal/agent/runtime/activity/coordinator_test.go`,
+ `apps/backend/internal/system/storage/operations_test.go`, and `runner_test.go`.
+ **How:** deterministic lease/channel tests plus existing tracked-job tests.
+- **HTTP contract:** the Storage route returns labeled blockers and availability on 409, accepts
+ `force: true`, and does not change the ordinary 202 job response. **Files:**
+ `apps/backend/internal/system/storage/handler_test.go` and `storage_routes_test.go`.
+- **Controller and rendered feedback:** the hook preserves resource selection after a structured
+ 409, sends force only from **Run anyway**, and the settings page renders warning/list/button only
+ when supplied by the backend. **Files:**
+ `apps/web/hooks/domains/system/use-storage-maintenance.test.tsx`,
+ `apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx`, and
+ `apps/web/lib/api/domains/system-api.test.ts`.
+
+## E2E Tests
+
+- **Scenario:** a running task blocks manual cleanup. **File:**
+ `apps/web/e2e/tests/system/storage-maintenance.spec.ts`. **Verify:** the page identifies the
+ active work, exposes **Run anyway**, sends `{ force: true }`, and receives a normal cleanup job.
+- **Scenario:** the same busy feedback is usable on Pixel 5. **File:**
+ `apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts`. **Verify:** the warning and
+ override remain visible/tappable, start the cleanup, and leave document horizontal overflow at
+ zero.
+
+## Implementation Waves
+
+Wave 1:
+
+- [x] [Task 01: Backend activity and API contract](task-01-backend-activity-api.md)
+
+Wave 2:
+
+- [x] [Task 02: Storage busy feedback and override UI](task-02-frontend-busy-override.md)
+
+Wave 3:
+
+- [x] [Task 03: Desktop and mobile override coverage](task-03-e2e-busy-override.md)
+
+## Risks
+
+- A forced cleanup can disrupt the active work it names; the inline warning must remain explicit
+ and the feature must never silently retry with force.
+- Bypassing an active task must not become a bypass for an existing maintenance run; the latter
+ remains an unavailable override.
+- The activity coordinator currently knows categories rather than user/task identity. The API must
+ stay at that privacy-safe category granularity unless a later, separately designed feature adds
+ activity identity.
+
+## Validation Results
+
+- Backend activity/storage/system race tests: passed (107 tests).
+- Backend backendapp/storage/system integration tests: passed (312 tests).
+- Web Storage unit tests: passed (57 tests).
+- Web typecheck: passed.
+- Managed desktop/mobile Storage E2E: passed (7 tests).
+- Commit hooks and GitHub PR checks: passed.
diff --git a/docs/plans/storage-busy-override/task-01-backend-activity-api.md b/docs/plans/storage-busy-override/task-01-backend-activity-api.md
new file mode 100644
index 0000000000..a32eabe584
--- /dev/null
+++ b/docs/plans/storage-busy-override/task-01-backend-activity-api.md
@@ -0,0 +1,58 @@
+---
+id: "01-backend-activity-api"
+title: "Backend activity and API contract"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+spec: "../../specs/system-page/storage-maintenance.md"
+---
+
+# Task 01: Backend Activity and API Contract
+
+## Acceptance
+
+- A blocked manual Storage run returns stable busy kinds with user-facing labels and declares
+ whether a force request is available.
+- `force: true` starts a cleanup beside current task activity, but never beside another maintenance
+ run; a later task acquisition still cancels the forced maintenance lease.
+- Existing scheduled and non-forced manual admission behavior, run persistence, and 202 job shape
+ remain unchanged.
+
+## Verification
+
+```bash
+cd apps/backend && go test ./internal/agent/runtime/activity ./internal/system/storage ./internal/system
+```
+
+## Files likely touched
+
+- `apps/backend/internal/agent/runtime/activity/coordinator.go`
+- `apps/backend/internal/agent/runtime/activity/coordinator_test.go`
+- `apps/backend/internal/system/storage/runner.go`
+- `apps/backend/internal/system/storage/runner_test.go`
+- `apps/backend/internal/system/storage/operations.go`
+- `apps/backend/internal/system/storage/operations_test.go`
+- `apps/backend/internal/system/storage/handler.go`
+- `apps/backend/internal/system/storage/handler_test.go`
+- `apps/backend/internal/system/storage_routes_test.go`
+
+## Dependencies
+
+None.
+
+## Inputs
+
+- Spec: Manual Run now, API surface, and busy-override scenarios
+- Plan: Backend and Tests
+- Existing activity lease behavior in `internal/agent/runtime/activity/coordinator.go`
+
+## Output contract
+
+Report acceptance status, changed request/response shapes, focused test results, risks, and update
+this task plus `plan.md` to `done` in the same conversation.
+
+## Validation Results
+
+- `go test ./internal/agent/runtime/activity ./internal/system/storage ./internal/system -count=1 -race`: passed.
+- `go test ./internal/backendapp ./internal/system/storage ./internal/system -count=1`: passed.
diff --git a/docs/plans/storage-busy-override/task-02-frontend-busy-override.md b/docs/plans/storage-busy-override/task-02-frontend-busy-override.md
new file mode 100644
index 0000000000..b9ae4b3239
--- /dev/null
+++ b/docs/plans/storage-busy-override/task-02-frontend-busy-override.md
@@ -0,0 +1,60 @@
+---
+id: "02-frontend-busy-override"
+title: "Storage busy feedback and override UI"
+status: done
+wave: 2
+depends_on: ["01-backend-activity-api"]
+plan: "plan.md"
+spec: "../../specs/system-page/storage-maintenance.md"
+---
+
+# Task 02: Storage Busy Feedback and Override UI
+
+## Acceptance
+
+- The Storage controller recognizes the typed 409 response, retains the original cleanup resource
+ selection, and sends `force: true` only after the user selects **Run anyway**.
+- The settings page names every reported active category, warns about disruption, and renders the
+ direct override only when the backend says it is available.
+- The existing phone one-column action composition presents the warning and an accessible, full-width
+ override button without a dialog, hover-only behavior, or horizontal overflow.
+
+## Verification
+
+```bash
+cd apps && pnpm --filter @kandev/web test -- --run \
+ hooks/domains/system/use-storage-maintenance.test.tsx \
+ components/settings/system/storage/storage-maintenance-settings.test.tsx \
+ lib/api/domains/system-api.test.ts
+cd apps/web && pnpm run typecheck
+```
+
+## Files likely touched
+
+- `apps/web/lib/types/system.ts`
+- `apps/web/lib/api/domains/system-api.ts`
+- `apps/web/lib/api/domains/system-api.test.ts`
+- `apps/web/hooks/domains/system/use-storage-maintenance.ts`
+- `apps/web/hooks/domains/system/use-storage-maintenance.test.tsx`
+- `apps/web/components/settings/system/storage/storage-maintenance-settings.tsx`
+- `apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx`
+
+## Dependencies
+
+Task 01 defines the typed 409 response and the `force` request field.
+
+## Inputs
+
+- Spec: API surface and busy-override scenarios
+- Plan: Frontend and Storage page/mobile contract
+- Existing `ApiError.body` handling in `apps/web/lib/api/client.ts`
+
+## Output contract
+
+Report acceptance status, UI/API contract changes, focused test results, mobile evidence or blocker,
+risks, and update this task plus `plan.md` to `done` in the same conversation.
+
+## Validation Results
+
+- Focused web tests for the hook, Storage settings, and system API: passed (57 tests).
+- `cd apps/web && pnpm run typecheck`: passed.
diff --git a/docs/plans/storage-busy-override/task-03-e2e-busy-override.md b/docs/plans/storage-busy-override/task-03-e2e-busy-override.md
new file mode 100644
index 0000000000..f502a75832
--- /dev/null
+++ b/docs/plans/storage-busy-override/task-03-e2e-busy-override.md
@@ -0,0 +1,53 @@
+---
+id: "03-e2e-busy-override"
+title: "Desktop and mobile override coverage"
+status: done
+wave: 3
+depends_on: ["01-backend-activity-api", "02-frontend-busy-override"]
+plan: "plan.md"
+spec: "../../specs/system-page/storage-maintenance.md"
+---
+
+# Task 03: Desktop and Mobile Override Coverage
+
+## Acceptance
+
+- Desktop E2E proves a running task yields labeled busy feedback, then **Run anyway** starts the
+ normal cleanup job with `force: true`.
+- Pixel 5 E2E proves the same action is visible and tappable in the Storage page’s one-column flow,
+ completes the user outcome, and leaves document horizontal overflow at zero.
+- The maintenance-running case remains unbypassable when practical to cover at the public route;
+ otherwise the backend contract test is named as its coverage.
+
+## Verification
+
+```bash
+cd apps/web && pnpm e2e:run --project chromium tests/system/storage-maintenance.spec.ts
+cd apps/web && pnpm e2e:run --project mobile-chrome tests/system/mobile-storage-maintenance.spec.ts
+```
+
+## Files likely touched
+
+- `apps/web/e2e/tests/system/storage-maintenance.spec.ts`
+- `apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts`
+- `apps/web/e2e/helpers/storage-maintenance.ts` only if a deterministic activity fixture is needed
+
+## Dependencies
+
+Tasks 01 and 02.
+
+## Inputs
+
+- Spec: busy-override scenarios
+- Plan: E2E Tests and mobile contract
+- Existing active-task busy scenario in `tests/system/storage-maintenance.spec.ts`
+
+## Output contract
+
+Report acceptance status, exact E2E command results, screenshots/traces on failure, risks, and
+update this task plus `plan.md` to `done` in the same conversation.
+
+## Validation Results
+
+- Desktop Storage E2E: passed (4 tests in the focused suite).
+- Pixel 5 Storage E2E: passed (3 tests in the focused suite).
diff --git a/docs/public/operations.md b/docs/public/operations.md
index 88bcda608d..2f5f079938 100644
--- a/docs/public/operations.md
+++ b/docs/public/operations.md
@@ -77,7 +77,9 @@ The supported SQLite layout for the System database and restore pages is the der
Open **Settings > System > Storage** to inspect Kandev-managed disk usage and configure cleanup.
**Analyze** is read-only. **Run now** applies only the enabled cleanup rules and refuses to start
-while task resources are active or another maintenance run owns the cleanup gate.
+while another maintenance run owns the cleanup gate. If task resources are active, the page names
+the active work and offers **Run anyway** after an explicit disruption warning. Use that override
+only when the active task work can tolerate cleanup running alongside it.
Storage analysis results are cached in the running backend for 15 minutes, so page reloads and
policy saves reuse the displayed snapshot instead of scanning disk again. The page shows when that
diff --git a/docs/specs/system-page/storage-maintenance.md b/docs/specs/system-page/storage-maintenance.md
index a6a560710d..8f20368106 100644
--- a/docs/specs/system-page/storage-maintenance.md
+++ b/docs/specs/system-page/storage-maintenance.md
@@ -1,7 +1,7 @@
---
-status: shipped
+status: building
created: 2026-07-14
-updated: 2026-07-23
+updated: 2026-07-27
owner: cfl
---
@@ -54,8 +54,13 @@ reclaim that space without maintaining cron or systemd configuration outside Kan
context, waits for the active provider operation to stop, and then proceeds. Maintenance
never races a newly admitted task.
- Manual **Run now** uses the same mutual-exclusion/current-activity gate, but does not wait out
- the configured quiet period. It returns a visible busy result when task activity is current or
- another maintenance run holds the gate, and has no force-while-busy mode.
+ the configured quiet period. When current Kandev activity blocks it, the page names each activity
+ type it found (for example, a running agent session or test command), warns that cleanup can
+ disrupt that work, and offers a distinct **Run anyway** action directly in the busy state.
+- **Run anyway** starts the requested manual cleanup alongside the activity that originally blocked
+ it. It skips only the current-activity admission check: it neither stops the activity nor allows
+ two storage-maintenance runs to overlap. New task work may still preempt the cleanup through the
+ normal maintenance cancellation path.
- The Go-cache analysis row exposes a resource-specific **Clean Go cache** action only when the
cache is Kandev-owned and above its configured maximum. That action submits an explicit
`go_cache` selection through the same manual-run gate.
@@ -307,9 +312,13 @@ POST /api/v1/system/storage/analyze
-> 202 { job_id }
POST /api/v1/system/storage/run
- body: { resources?: string[] }
+ body: { resources?: string[], force?: boolean }
-> 202 { job_id }
- -> 409 { error, busy_resources[] } when the idle gate is unavailable
+ -> 409 {
+ error: string,
+ busy_resources: [{ kind: string, label: string }],
+ force_available: boolean
+ } when current activity or another maintenance run holds the gate
GET /api/v1/system/storage/runs?limit=20
-> { runs: StorageMaintenanceRun[] }
@@ -336,6 +345,13 @@ window and replaces the cached snapshot only when the forced analysis succeeds.
Storage operations use the existing `system.job.update` WebSocket event and polling fallback.
Job kinds are `storage-analysis`, `storage-cleanup`, and `storage-quarantine-delete`.
+`busy_resources` uses stable machine-readable `kind` values and plain-language `label` values.
+The response exposes activity categories, not task names, prompts, paths, or other session content.
+`force_available` is true only when `force: true` can bypass the reported current task activity;
+it is false when another storage-maintenance run already holds the install-wide maintenance lease.
+When `force: true` is accepted, the response remains the ordinary `202 { job_id }` cleanup-job
+contract.
+
The task unarchive response may additionally include:
```json
@@ -452,6 +468,14 @@ enabling host-global Docker cleanup require explicit UI confirmation and server-
arrives, **THEN** the run is recorded as `skipped_busy` and no provider changes resources.
- **GIVEN** maintenance holds the idle gate, **WHEN** a new task launch arrives, **THEN** maintenance
is cancelled and the launch proceeds only after the active provider stops.
+- **GIVEN** a running agent session or command blocks manual cleanup, **WHEN** the user selects
+ **Run now**, **THEN** the page names the activity categories found, warns that cleanup can disrupt
+ them, and shows **Run anyway** without opening a confirmation dialog.
+- **GIVEN** a manual cleanup is blocked only by current task activity, **WHEN** the user selects
+ **Run anyway**, **THEN** Kandev starts the requested cleanup with `force: true` while the existing
+ activity continues and records the normal cleanup-job result.
+- **GIVEN** a manual cleanup is blocked by another storage-maintenance run, **WHEN** the user views
+ the busy feedback, **THEN** it identifies maintenance as the blocker and does not offer a bypass.
- **GIVEN** an unreferenced task directory older than the orphan grace period contains
`node_modules`, **WHEN** workspace cleanup runs with a successful authoritative inventory,
**THEN** the whole task root moves to quarantine and its measured bytes appear in the run result.