Skip to content

fix(disk): relocate cargo target off 28G / volume + anti-thrash cleanup gate (#4803) - #4834

Open
rysweet wants to merge 3 commits into
mainfrom
feat/issue-4803-fix-the-systemic-root-disk-saturation-crash-loop-s
Open

fix(disk): relocate cargo target off 28G / volume + anti-thrash cleanup gate (#4803)#4834
rysweet wants to merge 3 commits into
mainfrom
feat/issue-4803-fix-the-systemic-root-disk-saturation-crash-loop-s

Conversation

@rysweet

@rysweet rysweet commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Fixes #4803.

Problem

The root filesystem / (28G, holds /home and ~/.simard) saturated to 0 bytes free, driving a ~25-min emergency-cleanup crash-loop: each pass deleted target/debug + target/llvm-cov-target only for cargo to instantly rebuild and refill / within one cycle (observed 21:58→00:42). That 0-bytes-free condition cascaded into cognitive-open-lock refusals, typed database is locked, and memory-IPC write failures across goal-session engineers.

Root cause: cargo build artifacts defaulted onto the 28G / volume (under $HOME) while a 196G /tmp volume sat with free space; emergency cleanup freed space that instantly rebuilt — an ineffective band-aid that thrashed.

Fix (additive, non-breaking; confined to the cited surface)

  • agent_supervisor/tmux.rs — drop the $HOME/.cargo-targets default branch so the per-worktree CARGO_TARGET_DIR defaults onto the large-volume /tmp/simard-cargo-targets fallback even when HOME is set. SIMARD_CARGO_TARGETS_ROOT override still wins; empty-string guard preserved. Resolver made pub(crate) as the single source of truth.
  • agent_supervisor/lifecycle/spawn.rs — direct-exec path delegates to that unified resolver instead of the divergent hardcoded /tmp/simard-engineer-target.
  • disk_health.rsemergency_cleanup gains hysteresis (high=95 / low=85) plus a persistent time-backoff marker under <state_root>/disk-health/ (SIMARD_DISK_EMERGENCY_MIN_REFIRE_SECS, default 900, clamped [0, 86400]) so it cannot thrash within one build window. symlink_metadata + starts_with(repo_root|state_root) containment guards before every remove_dir_all; fail-open marker I/O with logging.
  • ooda_actions/advance_goal/spawn.rs — build-heavy dispatch preflight probes / via the existing disk_pressure gate (reused, no new thresholds): Refuse → benign retry-next-cycle skip with loud warn! (no silent fallback); probe error fails open (proceed but log) so a statvfs hiccup can't wedge all goal advancement.

llvm-cov artifacts stay discoverable under the relocated CARGO_TARGET_DIR.

Constraints honored

Verification

  • cargo check --lib ✓ · cargo clippy --all-targets --all-features --locked -- -D warnings
  • Unit tests green: disk_health hysteresis/backoff/clamp, compute_tmux_env off-/ relocation + never-under-HOME guard, preflight Proceed/Skip/fail-open.
  • Pre-push race-subset (482 tests) green.

Docs

Added concept / how-to / reference pages and wired into mkdocs.yml.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

rysweet and others added 3 commits July 27, 2026 02:24
…anup gate (#4803)

Fixes #4803.

The root filesystem `/` (28G, holds /home and ~/.simard) saturated to 0
bytes free, driving a ~25-min emergency-cleanup crash-loop: each pass
deleted target/debug + target/llvm-cov-target only for cargo to instantly
rebuild and refill `/` within one cycle. That 0-bytes-free condition
cascaded into cognitive-lock refusals, typed `database is locked`, and
memory-IPC write failures across goal-session engineers. Root cause: cargo
build artifacts defaulted onto `/` (under $HOME) while a 196G /tmp volume
sat with free space, and emergency cleanup freed space that instantly
rebuilt.

Additive, non-breaking fix confined to the cited surface:

- tmux.rs: drop the `$HOME/.cargo-targets` default branch so the
  per-worktree CARGO_TARGET_DIR defaults onto the large-volume
  `/tmp/simard-cargo-targets` fallback even when HOME is set;
  `SIMARD_CARGO_TARGETS_ROOT` override still wins. Resolver made
  pub(crate) as the single source of truth.
- lifecycle/spawn.rs: direct-exec path delegates to that unified resolver
  instead of the divergent hardcoded `/tmp/simard-engineer-target`.
- disk_health.rs: emergency_cleanup gains hysteresis (high=95/low=85) plus
  a persistent time-backoff marker under `<state_root>/disk-health/`
  (SIMARD_DISK_EMERGENCY_MIN_REFIRE_SECS, default 900, clamped [0,86400])
  so it cannot thrash within one build window; symlink + containment
  guards before every remove_dir_all; fail-open marker I/O.
- advance_goal/spawn.rs: build-heavy dispatch preflight probes `/` via the
  existing disk_pressure gate; Refuse -> benign retry-next-cycle skip with
  loud warn (no silent fallback); probe error fails open.

Structured tracing only (no new print!/println!), no silent fallbacks.
Docs added for concept/howto/reference. cargo check + clippy clean;
disk_health, tmux-env, and preflight unit tests green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
 preflight

Step 9 (refactor/simplify) for #4803. The build-heavy dispatch preflight
(BuildDispatchPreflight enum + build_dispatch_preflight fn) was inserted into
the MIDDLE of dispatch_spawn_engineer's doc-comment block, so its first
paragraph wrongly documented the new enum while dispatch_spawn_engineer kept
only the second half. Move the new items ahead of that doc block so each item
is documented correctly. Pure reordering, no behavior change; targeted tests
(disk_health, preflight, tmux, tests_tmux) green, spawn.rs fmt-clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Automatic checkpoint to preserve review-addressed changes.
Saved before running pre-commit hooks and tests.
@github-actions

Copy link
Copy Markdown

📊 Coverage Summary

Generated by cargo llvm-cov --workspace --summary-only (nightly, excluding test files)

Module Lines Covered Coverage
Total 205241 172720 84.2%

Coverage data from CI run. Test files matching tests?/ are excluded from line counts.

@rysweet rysweet left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Step 17b — Comprehensive Code Review ✅ APPROVE

Reviewed the full diff (13 files, +1516/-43): agent_supervisor/{tmux,lifecycle/spawn}.rs, disk_health.rs, disk_reclaim/{executor,mod}.rs, ooda_actions/advance_goal/spawn.rs, operator_commands_ooda/daemon/mod.rs, tests, and docs. This is a well-engineered, additive, non-breaking fix for the /-volume saturation crash-loop (#4803).

Verdict

Merge-ready. CI is fully green (coverage, install-real, e2e-dashboard, pre-commit, cargo-audit/deny/vet, GitGuardian), status is CLEAN / MERGEABLE, no print!/println!/eprintln! added, no "Bridge" naming, no silent fallbacks. Confirmed no src/self_deploy/ or src/safe_update/ files are touched — the P1 deploy-path work can safely rebase onto this.

Review checklist

  • Code quality & standards — pure decision seams (should_fire_emergency_cleanup, emergency_refire_min_secs_from, build_dispatch_preflight, prune_regenerable_build_artifacts) cleanly separated from I/O; single source of truth for the cargo-target resolver (pub(crate) default_cargo_target_for_worktree) shared by both spawn paths; structured tracing throughout.
  • Test coverage — strong. Unit tests pin hysteresis/backoff/clamp, off-/ relocation + never-under-HOME guard, and preflight Proceed/Skip/fail-open; plus a real outside-in integration test (tests/disk_reclaim_build_artifact_prune.rs) driving the production prune_build_tree_artifacts against an on-disk tree with the real remover/measurer//proc probe.
  • No TODOs / stubs / swallowed exceptions — none. All unwrap()/expect() are in test code. Fail-open paths (statvfs probe error, marker I/O) are logged via warn!, not swallowed.
  • No unimplemented functions.
  • Logic correctness — resolution order and gating are sound; the SIMARD_CARGO_TARGETS_ROOT override and empty-string guard are preserved.
  • Edge-case handling — excellent: symlink refusal via symlink_metadata (no redirect-follow), containment starts_with(repo_root|state_root) guards before every remove_dir_all, live-PID veto (fail-closed), root-euid refusal downgrades apply→dry-run, refire-secs clamp [0, 86400], explicit 0 escape hatch, and clock-skew (prev in future → suppress).

Non-blocking observations (optional, do not gate merge)

  1. EMERGENCY_LOW_WATERMARK_PCT (85) is telemetry/doc-only, not part of the fire decision. It appears only in the warn! log and in watermark_band_is_valid_hysteresis; should_fire_emergency_cleanup gates purely on the high watermark + time-backoff. The anti-thrash guarantee is therefore delivered entirely by the backoff, not a two-edge state machine — the code comment honestly says as much. Calling it a "hysteresis band" slightly overstates the mechanism (true hysteresis would track a "dropped below low ⇒ re-arm" state). Consider either wiring the low watermark into the decision or renaming to "time-backoff gate" for precision. Not a defect.

  2. The backoff marker is persisted unconditionally, even when freed == 0. In emergency_cleanup, write_emergency_marker(&marker, now) runs after the prune loop regardless of whether any artifact was actually removed. A fire that frees nothing (e.g., target dirs already absent) still suppresses re-fire for min_refire_secs. This is benign (a no-op refire would also free nothing, and post-relocation / is no longer the fill target), but if you want the marker to strictly track "we actually reclaimed space," gate the write on freed > 0. Debatable; current behavior is defensible.

Both are minor polish items, not merge gates. Approving.

@rysweet

rysweet commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

🔒 Step 17c — Security Review (PR #4834)

Scope: the 9 changed src/+tests/ files (docs excluded). Focus: file-deletion containment, command/env construction, privilege handling, PID/marker handling.

Verdict: No Critical/High exploitable vulnerabilities. 3 security-relevant findings (MEDIUM/LOW, all bounded by the local-write trust boundary) worth addressing; several security controls verified secure. Findings are non-blocking for merge but recommended as fast-follow hardening.


Findings

[MEDIUM · conf 7/10] Predictable, world-writable /tmp default for CARGO_TARGET_DIR — build-cache poisoning
src/agent_supervisor/tmux.rs:81,136 → consumed at lifecycle/spawn.rs:70-76
The default relocation now roots at /tmp/simard-cargo-targets/<basename> even when HOME is set (previously $HOME/.cargo-targets, user-owned). /tmp is world-writable; the path is fixed/predictable and nothing pre-creates it 0700 / verifies ownership. On a multi-user host another local user can pre-own it or symlink it before first use; cargo then reads/writes fingerprints + compiled artifacts (which the daemon later executes) in an attacker-controlled dir → cache-poisoning → code execution in the daemon context. This is a regression vs. the prior user-owned default.
Fix: root shared build output in a per-user, non-world-writable location (e.g. $XDG_STATE_HOME / ~/.simard/cargo-targets); or if /tmp is required, eagerly create_dir at 0700 and refuse if it pre-exists as a symlink or is not owned by the current uid.

[MEDIUM · conf 7/10] TOCTOU symlink race — containment re-assert collapses when allow_roots == deletion target
src/disk_reclaim/executor.rs:341-395, 432-440 + RealPathRemover:98-131
prune_build_tree_artifacts sets allow_roots to exactly the deletion targets (.../target/debug, .../target/llvm-cov-target). Inside RealPathRemover::remove, the re-assert under_any_root(&canon, allow_roots) canonicalizes both the path and the allow-root — identical paths resolve through any swapped symlink to the same destination, so the guard is trivially satisfied. The only real protection is the earlier non-atomic symlink_metadata (lstat) at :341, which is not atomic with canonicalize() + remove_dir_all(&canon) at :121-131. An attacker who can write under <build_tree>/target/ can swap debug→symlink after the lstat and cause deletion of an out-of-tree directory (bounded by daemon-user perms; root path is separately dry-run).
Fix: set allow_roots to the containing build tree (build_tree / build_tree/target), not the leaf. Better: eliminate the race — openat/fstatat the parent with O_NOFOLLOW, verify a real dir on the fd, and delete via the dir fd rather than re-resolving the path string. (Existing ..._toctou test doesn't cover this collapse because it uses a distinct allow-root.)

[LOW · conf 6/10] safe_to_remove containment is effectively a no-op (lexical starts_with + leaf-only lstat)
src/disk_health.rs:184-195 (emergency path; delete at :250-296)
Guard lstat's only the final component and checks containment via purely lexical path.starts_with(root). Since callers pass repo_root.join(...) with allowed_roots=[repo_root,...], starts_with is always true by construction and adds nothing. An intermediate symlink (e.g. repo_root/target/) is not detected by the leaf lstat and remove_dir_all follows it.
Fix: canonicalize the resolved path and check canonical containment against canonicalized roots (as the executor's under_any_root does); don't rely on lexical starts_with.

[Minor · DoS] Marker timestamp overflow panicsrc/disk_health.rs:146
UNIX_EPOCH + Duration::from_secs(secs) on an attacker-planted marker with a huge integer can panic (SystemTime add overflow), aborting the cleanup cycle. Local-write, DoS-only. Use checked_add.


✅ Verified secure (no action needed)

  • tmux command construction (tmux.rs:44-77): inner argv + log path POSIX single-quote escaped before sh -c; env passed as separate -e KEY=VALUE argv to tmux, not through the shell; session name is a distinct argv. No shell injection.
  • git invocation (executor.rs git_hardened): env_clear + arg vectors + -- separators + leading-dash rejection. No shell.
  • "root-euid downgrade": is_root() is a safe geteuid() check; euid==0 → forced dry-run (executor.rs:417-427). No seteuid/setuid/setgroups in the diff → no privilege-drop-ordering risk.
  • Clock-skew (should_fire_emergency_cleanup): duration_since Err (prev in future) → suppress; no panic; cannot reopen the thrash loop. Marker read is fail-open with no panic on malformed content (except the overflow note above).
  • Static symlink refusal confirmed via symlink_metadata (executor.rs:341, disk_health.rs:185; covered by prune_refuses_a_symlinked_artifact).
  • PID handling: "live-PID veto" only reads liveness to skip deletion; it does not signal/kill PIDs → no PID-reuse signaling risk.
  • build_dispatch_preflight: fail-open on probe error is a documented availability choice, not a security control.

Checklist: ✅ security requirements reviewed · ✅ new-vuln scan (3 local-write hardening findings, none remotely exploitable) · ✅ sensitive-data handling (none introduced) · ⏩ authn/authz (n/a — no auth surface) · ✅ injection review (command/shell/env — none; git/tmux hardened).

Automated security review — findings are advisory hardening, not merge blockers.

@rysweet

rysweet commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

🧭 Step 17d — Philosophy Guardian Review

PR #4834fix(disk): relocate cargo target off 28G / volume + anti-thrash cleanup gate (#4803)
Status: CLEAN / MERGEABLE, CI green. Reviewed against amplihack PHILOSOPHY.md.

Verdict: ✅ COMPLIANT — merge-ready on philosophy grounds

Criterion Status Evidence
Ruthless simplicity ✅ Pass Anti-thrash is a plain time-backoff marker, not a speculative state machine. Reclamation uses a straightforward largest-first + threshold-stop loop. No premature future-proofing.
Bricks & studs ✅ Pass disk_reclaim/ is a self-contained brick with an explicit, documented contract in mod.rs (candidate → guard → executor → recipe). Studs are declared via pub use; internals stay isolated. Clear module-doc rationale + linked design docs.
Zero-BS implementation ✅ Pass No stubs, TODOs, dead code, or unimplemented fns in production. No swallowed exceptions — errors surface via Result/telemetry. The two production unwrap_or(...) calls are intentional, visible fallbacks (byte-count 0; final_pct falls back to prior pct), not error suppression. All unwrap()/expect() are confined to tests.
No over-engineering ✅ Pass Traits (PathRemover, DiskStatProvider, SizeMeasurer, LiveProcessProbe) are minimal dependency-injection seams that exist to enable the real outside-in integration tests and TOCTOU re-validation — each abstraction earns its place. No generics/macros for their own sake.
Clean module boundaries ✅ Pass "Untrusted agent proposes, deterministic Rust executor disposes." The delete primitive lives only behind the non-bypassable guard::vet_candidate rail; no public path deletes without vetting. Excellent separation of concern and safety invariants.

Safety invariants worth calling out (all upheld)

  • Non-bypassable guard rail before every deletion (symlink refusal, containment/allow-root checks, leading-dash refusal, live-PID veto).
  • Hardcoded protected daemon dir preserved even under relocation.
  • Daemon apply gated behind an explicit env until sandboxing is verified in prod — appropriately conservative.

Non-blocking observations (not gates)

  1. EMERGENCY_LOW_WATERMARK_PCT is telemetry/doc-only — anti-thrash is delivered by time-backoff, not a two-edge hysteresis. Acceptable; consider a doc note so the constant's role is unambiguous.
  2. Backoff marker is persisted even when freed == 0. Benign; could gate on freed > 0 if a future change wants tighter semantics.

Philosophy compliance: PASS. No changes required to merge.

— Philosophy Guardian (Step 17d, automated)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant