Address review findings: durability, stale container ids, honest toasts
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 6m18s
Build App (Preview) / build-linux (pull_request) Successful in 7m48s
Build App (Preview) / prune-previews (pull_request) Successful in 1s

An Opus review of the previous commit found several real gaps:

- pending_cleanup::save used plain write-temp-then-rename, unlike
  migration_store's fsync'd write it claimed to mirror — a crash in that
  window left a truncated record that list() would skip forever, silently
  reproducing the exact bug this module exists to fix. Now matches
  migration_store's File::create/write_all/sync_all/rename/sync_dir shape,
  and the tests exercise the real save/list/clear functions against a temp
  dir instead of re-implementing their bodies inline.
- remove_project and rebuild_project_container only ever looked at
  project.container_id, unlike every other container-destroying path in the
  codebase, which falls back to find_existing_container for exactly this
  race (a crash between creating a container and persisting its id). A miss
  here left a container that then blocked every subsequent volume removal
  with a 409, forever. Both now resolve the same way the rest of the
  codebase does, and record the container by its deterministic name rather
  than its id so a retry still has something that resolves.
- remove_project's toast promised an automatic retry unconditionally, even
  when writing the pending-cleanup record itself failed (the one case
  where nothing will actually retry). ProjectRemovalReport now carries
  retry_scheduled, and the UI is honest about which case it's in.
- remove_volumes_by_name now retries once after a short delay on a 409,
  since Docker releasing a volume's mount reference right after its
  container is removed is not always instantaneous, and this is exactly
  the sequence remove_project runs.
- rebuild_project_container (Reset) returns ProjectResetOutcome so the UI
  can warn when Reset could not fully clear a project's volumes, instead
  of only logging it — the new container silently reuses old data
  otherwise, which is what Reset promises not to do.
- retry_pending_cleanup_logged escalates a record's log level after it has
  failed for a week, since recorded_at was otherwise write-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
This commit is contained in:
2026-08-27 08:36:27 -07:00
co-authored by Claude Sonnet 5
parent 4827170715
commit d8bb5ab262
9 changed files with 351 additions and 100 deletions
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import type { ProjectRemovalReport } from "../../../lib/types";
import { projectRemovalIsClean, type ProjectRemovalReport } from "../../../lib/types";
import { useAppState } from "../../../store/appState";
import { useProjectActions } from "../../../hooks/useProjectActions";
import { useProjects } from "../../../hooks/useProjects";
@@ -31,7 +31,15 @@ const TABS = [
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
/** Names what a `ProjectRemovalReport` says survived, for the leftover toast. */
/**
* Names what a `ProjectRemovalReport` says survived, for the leftover toast.
*
* Worded as "could not confirm" rather than "is still on disk": the same
* report shape covers a genuine leftover (a locked volume) and a daemon that
* was simply unreachable at the time, in which case nothing was ever created
* and there is nothing to find — asserting certainty either way would be
* wrong in one of those cases.
*/
function describeLeftovers(report: ProjectRemovalReport): string {
const parts: string[] = [];
if (report.container) parts.push("its container");
@@ -294,12 +302,22 @@ export default function ProjectHome({ projectId, active }: Props) {
setConfirmRemove(false);
try {
const report = await remove(project.id);
if (report.container || report.image || report.volumes.length > 0) {
useAppState.getState().pushToast({
kind: "info",
message: `${project.name}” was removed, but some Docker resources are still on disk`,
detail: `Triple-C will retry removing ${describeLeftovers(report)} the next time it starts.`,
});
if (!projectRemovalIsClean(report)) {
if (report.retry_scheduled) {
useAppState.getState().pushToast({
kind: "info",
message: `${project.name}” was removed, but Triple-C could not confirm all its Docker resources were cleaned up`,
detail: `Triple-C could not confirm ${describeLeftovers(report)} were removed. It will check again the next time it starts.`,
});
} else {
// The pending-cleanup record itself failed to save — no
// retry will happen, so this must not promise one.
useAppState.getState().pushToast({
kind: "error",
message: `${project.name}” was removed, but its Docker resources could not be cleaned up`,
detail: `Triple-C could not confirm ${describeLeftovers(report)} were removed, and could not record this for a retry. You may need to remove them manually (\`docker rm\` / \`docker rmi\` / \`docker volume rm\`).`,
});
}
}
} catch (e) {
useAppState.getState().pushToast({