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
+19 -4
View File
@@ -28,13 +28,14 @@ export function useProjectActions(project: Project) {
);
const run = useCallback(
async (label: string, fn: () => Promise<unknown>) => {
async <T,>(label: string, fn: () => Promise<T>): Promise<T | undefined> => {
setBusy(true);
setContainerProgress(project.id, null);
try {
await fn();
return await fn();
} catch (e) {
fail(`${label} failed for “${project.name}`, e);
return undefined;
} finally {
setContainerProgress(project.id, null);
setBusy(false);
@@ -54,8 +55,22 @@ export function useProjectActions(project: Project) {
);
const handleReset = useCallback(
() => run("Reset", () => rebuild(project.id)),
[run, rebuild, project.id],
() =>
run("Reset", async () => {
const outcome = await rebuild(project.id);
if (outcome.leftover_volumes.length > 0) {
const n = outcome.leftover_volumes.length;
pushToast({
kind: "error",
message: `Reset for “${project.name}” could not fully clean up`,
detail: `${n === 1 ? "A volume" : `${n} volumes`} could not be removed, so the new \
container may still contain data from before the reset. You may need to remove ${n === 1 ? "it" : "them"} \
manually with \`docker volume rm\`.`,
});
}
return outcome;
}),
[run, rebuild, project.id, project.name, pushToast],
);
const openClaudeTerminal = useCallback(async () => {
+3 -3
View File
@@ -136,9 +136,9 @@ export function useProjects() {
const rebuild = useCallback(
(id: string) =>
withOptimisticStatus(id, "starting", async () => {
const updated = await commands.rebuildProjectContainer(id);
updateProjectInList(updated);
return updated;
const outcome = await commands.rebuildProjectContainer(id);
updateProjectInList(outcome.project);
return outcome;
}),
[updateProjectInList, withOptimisticStatus],
);