Fix disk/migration defects and add a real per-project lock

The compaction panel's headline action had never worked, three reclaim
paths could delete data with no confirmation and no grace period, and the
app's only mutual-exclusion primitive was one-way.

**A per-project lock (`project_lock.rs`).** `ACTIVE_MIGRATIONS` was the
app's only exclusion and everything but migration merely *polled* it once
at entry. Compaction, start/stop/recreate, Reset and destroy now
**acquire** a `ProjectGuard` and hold it for the whole operation;
`is_migrating` is a view onto the same registry. Closes the three verified
interleavings where a compaction commits `flat(A)` over a `:latest` that a
migration, a recreate or a Reset had already moved. In-process only — the
two-instance case is documented in the module, not solved, and the
daemon-wide reapers gained age gates to bound it.

**H1: compaction never ran.** `fold_shell_script` joined the scrub script's
lines with a space, so every build died on `syntax error: unexpected "do"`.
Replaced with the JSON exec form, which carries any script verbatim;
`sh -n` and a real end-to-end build now cover it (159.5 MB / 9 layers ->
33.7 MB / 1 layer, setuid and multi-line env preserved).

**H2/H4:** `reclaim_migration_pins` and `survey_rollback_pins` apply
`parse_rollback_tag` and `pin_is_reapable` like every other path, and stop
double-counting an image with two pin tags. The 14-day grace period is
re-anchored from the tag's timestamp (when the migration *started*) to a
tombstone recording when the record went missing, with clock skew handled
in both directions.

**H3:** `migration_store::load` no longer renames a corrupt record aside —
that destroyed the `has_record` signal both pin reapers depend on. `save`
fsyncs the file and the directory, and corruption backups are timestamped.

**H2b/M2:** a crashed compaction's `:compacting` tag and `triple-c-compact-*`
container are reaped at startup; the stale-container sweep moved from the
end of a compaction to the start, where its doc always claimed it was.

**M5/M6:** orphan-volume deletion moved from a `Safety::Safe` tick to the
destructive path with a typed volume name; `project_store_trust` reads the
real `projects.json` so a second instance's project is not offered as an
orphan.

Numbers: `images_total_bytes` uses `df()`'s deduplicated `layers_size`; the
Total column is derived from the same figure the Snapshot column shows;
partial container/staging reclaims report their failure count; `human()`
no longer prints "1000.0 KB"; `docker_cli` has a timeout; blocking `fs`
calls moved to `spawn_blocking`.

Also fixes `ProbeContainerGuard::remove_now`, which disarmed before the
await and so did nothing on the cancellation path it exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 11:45:28 -07:00
co-authored by Claude Opus 5
parent 0003793abb
commit 6abc7f27a4
8 changed files with 2371 additions and 342 deletions
+14
View File
@@ -5,6 +5,7 @@ mod docker;
mod install_helper;
mod logging;
mod models;
mod project_lock;
mod storage;
pub mod web_terminal;
@@ -263,6 +264,19 @@ pub fn run() {
let drag_temp_dir = app.path().temp_dir().ok();
tauri::async_runtime::spawn(async move {
crate::docker::reap_probe_containers().await;
// Before the sweep, and for the same reason the pins are:
// `triple-c-snapshot-*:compacting` is a *tagged* image, so the
// sweep's `dangling=true` filter cannot see it, and the
// `triple-c-compact-*` container a crashed compaction leaves
// behind pins that image open. Untagging first is what turns
// both into something the sweep can collect on the same pass.
let stranded = crate::docker::disk::reap_stale_compaction_artifacts().await;
if stranded > 0 {
log::info!(
"Startup housekeeping dropped {} stranded compaction staging tag(s)",
stranded
);
}
let reaped = crate::docker::reap_stale_migration_pins().await;
if reaped > 0 {
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);