diff --git a/docs/plans/2026-08-04-st1-electron-updater-audit-design.md b/docs/plans/2026-08-04-st1-electron-updater-audit-design.md new file mode 100644 index 00000000000..acb4b8dcefc --- /dev/null +++ b/docs/plans/2026-08-04-st1-electron-updater-audit-design.md @@ -0,0 +1,69 @@ +# ST-1 Electron Updater Audit Design + +## Context + +The host storage constitution requires a monthly audit of Electron updater +residue after Tencent Meeting left more than 15 GiB of stale update packages. +The existing storage-governance line already owns a cron-to-FDA bridge, a +single-instance lock, atomic JSON evidence, and Lark alert delivery. The audit +must extend that line instead of creating a second garbage-collection owner. + +## Considered approaches + +1. Extend `retention_worker.py` with a monthly-gated, read-only audit. This + reuses the existing scheduler, lock, evidence writer, and alert channel while + keeping updater scans out of the minute-level breaker hot path. This is the + selected approach. +2. Add a dedicated monthly LaunchAgent and script. This is easy to isolate, but + duplicates scheduling, locking, logging, and alert ownership. +3. Add updater scans to `storage_guard.py`. This gives fast visibility, but + recursive size scans would make the low-water breaker slower and less + predictable. + +## Architecture + +`retention_worker.py` gains a bounded scanner driven by an +`electron_updater_audit` config object. Each configured glob must resolve below +the configured home directory. Matches are de-duplicated, symlinks are rejected, +and directory traversal never follows symlinks. The scanner records bytes, file +count, newest and oldest mtimes, and age for each match; it never moves or +deletes data. + +The formal retention run checks for the current Shanghai calendar month's +evidence before touching the external archive volume. On or after the configured +day of month, a missing report triggers the scan. This ordering lets ST-1 leave +evidence even if the external archive is unavailable. A manual audit-only CLI +mode forces the same scanner for commissioning and incident response without +claiming cron lineage. + +The audit writes +`~/.org/metrics/st1-electron-updater-audit-YYYY-MM.json` atomically. `green` +means the scan completed and no configured size or stale-residue threshold was +crossed; `attention` means operator review is warranted; `red` means the scan +could not provide complete evidence. Formal `attention` and `red` results use +the existing alert channel. A `red` result also fails the retention invocation +closed, while `attention` does not block unrelated canary or GC reporting. + +## Initial scan surface + +The host config covers the known sharp edges without a full-disk search: + +- sandboxed `UpdatePackages` roots (Tencent Meeting pattern); +- `update.noindex`, `update_downloading`, and `Software Update` directories; +- Electron `*-updater` and scoped updater caches; +- Squirrel `*.ShipIt` caches; +- MiniMax Agent `hot-update` payloads; +- pending/update ZIP files under Multica daemon state. + +Thresholds are configuration, initially 5 GiB total, 1 GiB per candidate, and +45 days stale for candidates of at least 100 MiB. The audit is observational; +crossing a threshold does not authorize cleanup. + +## Verification + +Unit tests use temporary directory trees to prove discovery, de-duplication, +byte counts, no symlink following, threshold classification, month/day gating, +and forced audit behavior. Commissioning runs the audit-only CLI against the +real host configuration, validates the JSON schema and current-month mtime, and +then verifies the deployed worker and config match the reviewed repository +files. diff --git a/docs/superpowers/plans/2026-08-04-st1-electron-updater-audit.md b/docs/superpowers/plans/2026-08-04-st1-electron-updater-audit.md new file mode 100644 index 00000000000..61def65f49f --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-st1-electron-updater-audit.md @@ -0,0 +1,180 @@ +# ST-1 Electron Updater Audit Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a non-destructive, machine-verifiable monthly ST-1 audit of Electron updater residue to the existing storage-governance worker. + +**Architecture:** A bounded scanner in `retention_worker.py` reads explicit host-relative globs, emits one atomic report per Shanghai calendar month, and is invoked before external-volume checks by the existing formal cron worker. A manual audit-only mode commissions the same code path; no second scheduler or cleanup owner is introduced. + +**Tech Stack:** Python 3 standard library (`glob`, `os`, `pathlib`, `zoneinfo`, `unittest`), JSON configuration, existing cron-to-FDA storage-governance bridge. + +--- + +### Task 1: Specify scanner behavior with failing tests + +**Files:** +- Modify: `scripts/storage-governance/test_retention_worker.py` +- Test: `scripts/storage-governance/test_retention_worker.py` + +- [ ] **Step 1: Add imports and fixtures** + +Import `audit_electron_updaters` and `maybe_run_electron_updater_audit`. Build a +temporary home with an updater directory, a pending ZIP, and a symlink to an +out-of-root payload. + +- [ ] **Step 2: Assert discovery and safety behavior** + +Assert that the report de-duplicates overlapping globs, sums regular-file +payload, reports the real candidates, records the symlink as an error without +following it, and never changes source paths. + +- [ ] **Step 3: Assert threshold and monthly-gate behavior** + +Use an injected UTC time whose Shanghai date is before and after the configured +day. Assert `skipped` before the day, a report on/after the day, `skipped` when +valid current-month evidence exists, and a rerun with `force=True`. + +- [ ] **Step 4: Run the focused tests and verify RED** + +Run: `python3 -m unittest scripts.storage-governance.test_retention_worker.ElectronUpdaterAuditTest -v` + +Expected: import failure because the audit functions do not exist. + +### Task 2: Implement the scanner and monthly evidence gate + +**Files:** +- Modify: `scripts/storage-governance/retention_worker.py` +- Test: `scripts/storage-governance/test_retention_worker.py` + +- [ ] **Step 1: Add the audit functions** + +Implement: + +```python +def audit_electron_updaters( + config: Dict[str, Any], + *, + now: Callable[[], datetime] = utc_now, + invocation_source: str = "manual", +) -> Dict[str, Any]: ... + +def maybe_run_electron_updater_audit( + config: Dict[str, Any], + *, + now: Callable[[], datetime] = utc_now, + force: bool = False, + invocation_source: str = "manual", +) -> Dict[str, Any]: ... +``` + +Resolve the configured timezone with `ZoneInfo`, validate every match beneath +`home_path`, scan without following symlinks, atomically write the month-named +JSON report, and classify `green`, `attention`, or `red` from scan errors and +configured thresholds. + +- [ ] **Step 2: Run the focused tests and verify GREEN** + +Run: `python3 -m unittest scripts.storage-governance.test_retention_worker.ElectronUpdaterAuditTest -v` + +Expected: all `ElectronUpdaterAuditTest` cases pass. + +- [ ] **Step 3: Run the complete storage-governance tests** + +Run: `python3 -m unittest discover -s scripts/storage-governance -p 'test_*.py' -v` + +Expected: all tests pass with zero failures. + +### Task 3: Integrate scheduling and audit-only CLI + +**Files:** +- Modify: `scripts/storage-governance/retention_worker.py` +- Modify: `scripts/storage-governance/test_retention_worker.py` + +- [ ] **Step 1: Add failing integration tests** + +Patch external-volume construction and assert `run_worker` invokes the monthly +audit before external-volume checks. Add an argument-parser test or subprocess +test proving `--electron-audit-only` does not require cron lineage or external +storage. + +- [ ] **Step 2: Verify RED** + +Run the two new test names directly and confirm they fail because the formal and +CLI integration is absent. + +- [ ] **Step 3: Implement the integration** + +Call `maybe_run_electron_updater_audit` immediately after formal cron lineage +verification, attach its summary to the retention report, alert once when a +formal scan returns `attention`, and fail closed on `red`. Add +`--electron-audit-only` to `main`; this mode takes the existing lock, forces the +audit, prints a compact JSON result, and exits nonzero only for `red`. + +- [ ] **Step 4: Verify GREEN** + +Run the focused tests, then the complete storage-governance suite. + +### Task 4: Configure, document, deploy, and commission + +**Files:** +- Modify: `scripts/storage-governance/retention-config.example.json` +- Modify: `scripts/storage-governance/README.md` +- Modify: `/Users/tangyuanjc/.local/libexec/storage-governance/retention_worker.py` (deployed copy) +- Modify: `/Users/tangyuanjc/.local/libexec/storage-governance/retention-config.json` (deployed config) +- Create: `/Users/tangyuanjc/.org/metrics/st1-electron-updater-audit-2026-08.json` (runtime evidence) + +- [ ] **Step 1: Add the host-neutral example config** + +Document `enabled`, `timezone`, `day_of_month`, `home_path`, `report_dir`, +bounded `patterns`, `warn_total_gib`, `warn_candidate_gib`, `stale_days`, and +`stale_min_mib`. + +- [ ] **Step 2: Update the runbook** + +State that ST-1 is monthly-gated inside the formal retention owner, is strictly +read-only, produces month-named evidence, and can be commissioned with +`--electron-audit-only`. + +- [ ] **Step 3: Deploy reviewed files and config** + +Copy the reviewed worker to the existing libexec location, update only the +`electron_updater_audit` config object, preserve executable mode, and validate +both JSON files. + +- [ ] **Step 4: Run the real audit** + +Run: + +```bash +/usr/bin/python3 /Users/tangyuanjc/.local/libexec/storage-governance/retention_worker.py \ + --config /Users/tangyuanjc/.local/libexec/storage-governance/retention-config.json \ + --electron-audit-only +``` + +Expected: exit 0 with `green` or `attention`, plus a current-month evidence +path. Validate the report with `python3 -m json.tool`, confirm its mtime is in +the current Shanghai month, and confirm no candidate has `action: deleted` or +`action: moved` because the schema exposes observations only. + +### Task 5: Final verification and delivery + +**Files:** +- Verify all modified files + +- [ ] **Step 1: Run fresh verification** + +Run the full Python suite, `python3 -m py_compile` on all storage-governance +Python files, `python3 -m json.tool` on example and deployed configs and the +monthly report, `cmp` on repository/deployed worker, and `git diff --check`. + +- [ ] **Step 2: Commit and update the open storage-governance PR** + +Create atomic conventional commits, push them to the existing PR #6281 head, +and add `Closes WS-3010` to that PR body so this follow-up is linked and closes +on merge. + +- [ ] **Step 3: Deliver Multica evidence** + +Create the required Allen closing sub-issue, post exactly one concise WS-3010 +result comment with the audit command, exit code, raw compact output, report +summary, test count, and PR URL, then move WS-3010 to `in_review`. diff --git a/scripts/storage-governance/README.md b/scripts/storage-governance/README.md new file mode 100644 index 00000000000..a9bcac424c9 --- /dev/null +++ b/scripts/storage-governance/README.md @@ -0,0 +1,83 @@ +# Storage governance runbook + +This directory contains two deliberately small host-safety jobs: + +- `storage_guard.py` samples the host once per minute and applies configured + low-water admission controls. It never archives or deletes data. +- `retention_worker.py` is the single owner of external-volume canaries, + workspace GC eligibility, transactional archives, and the monthly ST-1 + Electron updater residue audit. + +## Safe rollout + +Start the retention worker with both `archive_enabled` and `delete_source` set +to `false`. In this mode every formal cron invocation checks the exact external +volume UUID, verifies a nested canary tree by file count, byte count, entry +metadata, and deterministic sample hashes, and writes only a GC dry-run report. + +GC eligibility is fail-closed. A workspace is listed as eligible only when its +issue is `done` or `cancelled`, its matching run is terminal, the configured +seven-day retention window has elapsed, children are terminal, no run/lease is +active, no pin or open file exists, no recent write exists, and local identity +agrees with the control plane. Filesystem traversal never follows symlinks; +out-of-tree symlinks reject the candidate. + +After an operator approves a dry-run list, its one-time `approval_token` values +must be put in `approved_candidates` before `archive_enabled` is enabled; keep +`delete_source` false. A completed archive marker consumes the token so a later +cron run cannot archive the same snapshot again. A transaction freezes the source manifest, +copies to `.partial`, fsyncs and verifies it, atomically renames the archive, +and writes `COMPLETE.json`, then re-hashes the committed payload. Automated +source deletion is deliberately rejected until Multica exposes a producer-shared +lease; filesystem isolation alone cannot close the open-file-descriptor race. + +The guard's minute path samples free space, swap, and daemon state before any +recursive work. Directory/category scans run from a 15-minute cache after the +breaker decision, so capacity attribution cannot delay low-water enforcement. + +## ST-1 Electron updater audit + +The retention worker also owns the read-only monthly audit for Electron updater +residue. On or after `electron_updater_audit.day_of_month`, it scans only the +configured home-relative globs and atomically writes +`st1-electron-updater-audit-YYYY-MM.json` below the configured report directory. +It does this before external-volume checks, so an unavailable archive disk does +not erase the month's ST-1 evidence. + +Matches are de-duplicated and recursively sized without following symlinks. +The report records file counts, bytes, mtimes, stale candidates, and threshold +reasons. It never moves or deletes a match. `attention` sends the existing Lark +alert once for that month's report; an incomplete `red` scan fails the formal +worker closed. A valid `green` or `attention` report suppresses later scans in +the same Shanghai calendar month. + +Commission the exact same scanner without cron lineage or the external archive: + +```bash +/usr/bin/python3 /Users/example/.local/libexec/storage-governance/retention_worker.py \ + --config /Users/example/.local/libexec/storage-governance/retention-config.json \ + --electron-audit-only +``` + +Audit-only mode still takes the retention worker's single-instance lock and +forces a fresh report. It is for commissioning and incident response; scheduled +evidence continues to come from the existing formal cron owner. + +## Formal cron lineage + +Use the same command for canary, GC audit, and archive. The environment marker +prevents a normal manual invocation from being mistaken for a cron result: + +On macOS, `/usr/sbin/cron` normally lacks Full Disk Access to external media. +The formal entry therefore runs a synchronous bridge. The bridge proves its +own live `cron` ancestry, writes a fresh one-time token, starts the FDA-capable +LaunchAgent, and waits for a token-matched receipt. The worker refuses a green +result unless that bridge process and its cron parent are still alive: + +```cron +*/15 * * * * /usr/bin/python3 /Users/example/.local/libexec/storage-governance/retention_cron_bridge.py --trigger /Users/example/.local/state/storage-governance/cron-trigger.json --receipt /Users/example/.local/state/storage-governance/cron-receipt.json --alert-log /Users/example/.local/state/storage-governance/retention-alerts.jsonl --config /Users/example/.local/libexec/storage-governance/retention-config.json +``` + +Keep the lock and report on the internal volume, and the archive root on the +external volume. A lock collision or canary failure exits nonzero and records +an alert; it never starts a second copy or removes a source. diff --git a/scripts/storage-governance/com.multica.storage-guard.plist.example b/scripts/storage-governance/com.multica.storage-guard.plist.example new file mode 100644 index 00000000000..987faa730fc --- /dev/null +++ b/scripts/storage-governance/com.multica.storage-guard.plist.example @@ -0,0 +1,37 @@ + + + + + Label + com.multica.storage-guard + ProgramArguments + + /usr/bin/python3 + -E + __INSTALL_ROOT__/storage_guard.py + --config + __INSTALL_ROOT__/config.json + + EnvironmentVariables + + HOME + __HOME__ + PATH + __HOME__/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + OPENAI_API_KEY + + OPENAI_BASE_URL + + + RunAtLoad + + StartInterval + 60 + ProcessType + Background + StandardOutPath + __HOME__/Library/Logs/storage-governance.guard.log + StandardErrorPath + __HOME__/Library/Logs/storage-governance.guard.err.log + + diff --git a/scripts/storage-governance/com.multica.storage-retention.plist.example b/scripts/storage-governance/com.multica.storage-retention.plist.example new file mode 100644 index 00000000000..59b3a86cc31 --- /dev/null +++ b/scripts/storage-governance/com.multica.storage-retention.plist.example @@ -0,0 +1,21 @@ + + + + + Label + com.multica.storage-retention + ProgramArguments + + /usr/bin/osascript + __INSTALL_ROOT__/retention-cron.scpt + + RunAtLoad + + ProcessType + Background + StandardOutPath + __HOME__/Library/Logs/multica-storage-retention.launchd.log + StandardErrorPath + __HOME__/Library/Logs/multica-storage-retention.launchd.err.log + + diff --git a/scripts/storage-governance/config.example.json b/scripts/storage-governance/config.example.json new file mode 100644 index 00000000000..e27ce7c1608 --- /dev/null +++ b/scripts/storage-governance/config.example.json @@ -0,0 +1,54 @@ +{ + "internal_path": "/", + "external_path": "/Volumes/MacMini-HotSSD", + "level1_free_gib": 25, + "level1_clear_gib": 28, + "level2_free_gib": 18, + "level2_clear_gib": 21, + "external_min_free_gib": 100, + "safety_floor_gib": 25, + "burst_reserve_gib": 10, + "minimum_observation_hours": 48, + "maximum_observation_hours": 72, + "expected_interval_seconds": 60, + "minimum_sample_coverage": 0.8, + "growth_scan_interval_seconds": 900, + "growth_scan_budget_seconds": 5, + "required_field_max_gap_seconds": 1800, + "retention_report_max_age_seconds": 1800, + "shadow_runs_path": "/Users/example/path/to/m0-shadow-observer/data/runs", + "workspace_roots": [ + "/Users/example/multica_workspaces", + "/Users/example/multica_workspaces_desktop-api.multica.ai" + ], + "retention_report_path": "/Users/example/.local/state/storage-governance/gc-dry-run.json", + "retention_days": 7, + "cursor_path": "/Users/example/Library/Application Support/Cursor", + "logs_paths": [ + "/Users/example/.multica", + "/Users/example/Library/Logs" + ], + "required_growth_fields": [ + "shadow_runs_bytes", + "workspace_total_bytes", + "workspace_inflight_bytes", + "workspace_gc_eligible_bytes", + "workspace_gc_backlog_bytes", + "workspace_unclassified_bytes", + "cursor_bytes", + "logs_bytes", + "swap_used_bytes", + "external_free_bytes" + ], + "observer_labels": [ + "ai.multica.ws2512.m0-shadow-observer" + ], + "nonproduction_launchagents": [], + "lark_open_id": "", + "alert_cooldown_seconds": 3600, + "legacy_sigstop_fallback": false, + "state_path": "/Users/example/.local/state/storage-governance/guard-state.json", + "growth_cache_path": "/Users/example/.local/state/storage-governance/growth-metrics-cache.json", + "metrics_path": "/Users/example/.local/state/storage-governance/host-samples.jsonl", + "capacity_report_path": "/Users/example/.local/state/storage-governance/capacity-report.json" +} diff --git a/scripts/storage-governance/retention-config.example.json b/scripts/storage-governance/retention-config.example.json new file mode 100644 index 00000000000..84e1fb5a3fa --- /dev/null +++ b/scripts/storage-governance/retention-config.example.json @@ -0,0 +1,88 @@ +{ + "external_path": "/Volumes/MacMini-HotSSD", + "canary_root": "/Volumes/MacMini-HotSSD/archive/multica-archive", + "external_volume_uuid": "00000000-0000-0000-0000-000000000000", + "external_min_free_gib": 100, + "archive_root": "/Volumes/MacMini-HotSSD/MulticaArchive/workspaces", + "workspace_roots": [ + "/Users/example/multica_workspaces", + "/Users/example/multica_workspaces_desktop-api.multica.ai" + ], + "retention_days": 7, + "recent_write_seconds": 86400, + "archive_enabled": false, + "approved_candidates": [], + "delete_source": false, + "require_cron_lineage": true, + "cron_bridge_trigger_path": "/Users/example/.local/state/storage-governance/cron-trigger.json", + "cron_bridge_receipt_path": "/Users/example/.local/state/storage-governance/cron-receipt.json", + "lock_path": "/Users/example/.local/state/storage-governance/retention.lock", + "report_path": "/Users/example/.local/state/storage-governance/gc-dry-run.json", + "alert_log_path": "/Users/example/.local/state/storage-governance/retention-alerts.jsonl", + "electron_updater_audit": { + "enabled": true, + "timezone": "Asia/Shanghai", + "day_of_month": 15, + "home_path": "/Users/example", + "report_dir": "/Users/example/.org/metrics", + "patterns": [ + { + "label": "sandbox_update_packages", + "glob": "Library/Containers/*/Data/Library/Global/UpdatePackages" + }, + { + "label": "application_support_update_noindex", + "glob": "Library/Application Support/*/update.noindex" + }, + { + "label": "application_support_update_downloading", + "glob": "Library/Application Support/*/update_downloading" + }, + { + "label": "application_support_software_update", + "glob": "Library/Application Support/*/Software Update" + }, + { + "label": "sandbox_application_support_update_noindex", + "glob": "Library/Containers/*/Data/Library/Application Support/*/update.noindex" + }, + { + "label": "sandbox_application_support_update_downloading", + "glob": "Library/Containers/*/Data/Library/Application Support/*/update_downloading" + }, + { + "label": "electron_software_update_cache", + "glob": "Library/Caches/*/Software Update" + }, + { + "label": "electron_updater_cache", + "glob": "Library/Caches/*-updater" + }, + { + "label": "scoped_electron_updater_cache", + "glob": "Library/Caches/@*-updater" + }, + { + "label": "squirrel_shipit_cache", + "glob": "Library/Caches/*.ShipIt" + }, + { + "label": "minimax_hot_update", + "glob": "Library/Application Support/MiniMax Agent/hot-update" + }, + { + "label": "multica_pending_update_zip", + "glob": ".multica/*update*.zip" + }, + { + "label": "multica_profile_pending_update_zip", + "glob": ".multica/profiles/*/*update*.zip" + } + ], + "warn_total_gib": 5, + "warn_candidate_gib": 1, + "stale_days": 45, + "stale_min_mib": 100 + }, + "lark_open_id": "" +} diff --git a/scripts/storage-governance/retention-cron.applescript.example b/scripts/storage-governance/retention-cron.applescript.example new file mode 100644 index 00000000000..563d895fe64 --- /dev/null +++ b/scripts/storage-governance/retention-cron.applescript.example @@ -0,0 +1,4 @@ +set commandText to "/usr/bin/env -i HOME=__HOME__ PATH=__HOME__/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin MULTICA_STORAGE_CRON_BRIDGE=1 OPENAI_API_KEY='' OPENAI_BASE_URL='' /usr/bin/python3 __INSTALL_ROOT__/retention_worker.py --config __INSTALL_ROOT__/retention-config.json >> __HOME__/Library/Logs/multica-storage-retention.log 2>&1" +with timeout of 7200 seconds + do shell script commandText +end timeout diff --git a/scripts/storage-governance/retention_cron_bridge.py b/scripts/storage-governance/retention_cron_bridge.py new file mode 100644 index 00000000000..a60362377ac --- /dev/null +++ b/scripts/storage-governance/retention_cron_bridge.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Synchronous cron-to-LaunchAgent bridge for macOS Full Disk Access.""" + +from __future__ import annotations + +import argparse +import fcntl +import json +import os +import subprocess +import tempfile +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from retention_worker import atomic_write_failure_report, send_alert + + +class SingleInstanceLock: + def __init__(self, path: Path): + self.path = path + self.handle = None + + def __enter__(self) -> "SingleInstanceLock": + self.path.parent.mkdir(parents=True, exist_ok=True) + self.handle = self.path.open("a+") + fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + if self.handle is not None: + fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN) + self.handle.close() + + +def launchctl_kickstart_command(label: str, *, uid: int) -> list[str]: + return ["/bin/launchctl", "kickstart", "gui/%d/%s" % (uid, label)] + + +def receipt_exit_code(value: dict, token: str) -> Optional[int]: + if value.get("token") != token: + return None + if value.get("status") == "green": + return 0 + if value.get("status") == "red": + return 1 + return None + + +def record_lock_collision(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write( + json.dumps( + { + "recorded_at": datetime.now(timezone.utc).isoformat(), + "status": "locked", + "message": "previous bridge is still running; skipped overlapping retention launch", + }, + sort_keys=True, + ) + + "\n" + ) + handle.flush() + os.fsync(handle.fileno()) + + +def report_bridge_failure(config_path: Optional[str], message: str) -> None: + if not config_path: + return + try: + config = json.loads(Path(config_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + for action in ( + lambda: atomic_write_failure_report(config, message), + lambda: send_alert(config, message), + ): + try: + action() + except Exception: + pass + + +def atomic_write(path: Path, value: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=".%s." % path.name, dir=str(path.parent)) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(value, handle, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def ancestry(pid: int, limit: int = 8) -> list[dict]: + values = [] + for _ in range(limit): + parent_result = subprocess.run( + ["/bin/ps", "-p", str(pid), "-o", "ppid="], capture_output=True, text=True, check=False + ) + command_result = subprocess.run( + ["/bin/ps", "-p", str(pid), "-o", "comm="], capture_output=True, text=True, check=False + ) + if parent_result.returncode != 0 or command_result.returncode != 0: + break + parent = int(parent_result.stdout.strip()) + command = command_result.stdout.strip() + values.append({"pid": pid, "parent_pid": parent, "command": command}) + if parent <= 1 or parent == pid: + break + pid = parent + return values + + +def run_bridge(args: argparse.Namespace) -> int: + lineage = ancestry(os.getpid()) + if not any(Path(str(item["command"])).name == "cron" for item in lineage): + raise SystemExit("retention bridge refuses non-cron ancestry") + token = uuid.uuid4().hex + trigger = Path(args.trigger) + receipt = Path(args.receipt) + atomic_write( + trigger, + { + "schema": "multica.storage-cron-trigger.v1", + "token": token, + "created_at": datetime.now(timezone.utc).isoformat(), + "bridge_pid": os.getpid(), + "cron_ancestry": lineage, + }, + ) + process = subprocess.run( + launchctl_kickstart_command(args.label, uid=os.getuid()), + capture_output=True, + text=True, + check=False, + ) + if process.returncode != 0: + raise SystemExit("launchctl kickstart failed: " + (process.stderr or process.stdout).strip()) + deadline = time.monotonic() + args.timeout + while time.monotonic() < deadline: + try: + value = json.loads(receipt.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, json.JSONDecodeError): + time.sleep(2) + continue + result = receipt_exit_code(value, token) + if result is not None: + return result + time.sleep(2) + raise SystemExit("retention worker receipt timeout") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--trigger", required=True) + parser.add_argument("--receipt", required=True) + parser.add_argument("--label", default="com.multica.storage-retention") + parser.add_argument("--timeout", type=int, default=7200) + parser.add_argument("--lock") + parser.add_argument("--alert-log") + parser.add_argument("--config") + args = parser.parse_args() + lock_path = Path(args.lock) if args.lock else Path(args.trigger).with_name("retention-cron-bridge.lock") + try: + with SingleInstanceLock(lock_path): + try: + return run_bridge(args) + except SystemExit as error: + report_bridge_failure(args.config, "storage retention cron bridge failed: %s" % error) + raise + except BlockingIOError: + alert_path = Path(args.alert_log) if args.alert_log else Path(args.trigger).with_name("retention-alerts.jsonl") + record_lock_collision(alert_path) + report_bridge_failure(args.config, "storage retention cron bridge skipped: previous bridge is still running") + return 75 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/storage-governance/retention_worker.py b/scripts/storage-governance/retention_worker.py new file mode 100755 index 00000000000..cad688a604a --- /dev/null +++ b/scripts/storage-governance/retention_worker.py @@ -0,0 +1,1343 @@ +#!/usr/bin/env python3 +"""Fail-closed canary, workspace GC audit, and transactional archiver. + +The same process owns all three operations. A production configuration starts +with ``archive_enabled=false`` and ``delete_source=false`` so the first run can +only prove the external-volume path and emit a GC dry-run report. +""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import fcntl +import glob +import hashlib +import json +import os +import plistlib +import shutil +import stat +import subprocess +import sys +import tempfile +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple +from zoneinfo import ZoneInfo + + +GIB = 1024**3 +TERMINAL_ISSUE_STATUSES = {"done", "cancelled"} +TERMINAL_RUN_STATUSES = {"completed", "failed", "cancelled"} + + +class ArchiveError(RuntimeError): + pass + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def parse_timestamp(value: str) -> datetime: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def atomic_write_json(path: Path, value: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=".%s." % path.name, dir=str(path.parent)) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(value, handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + fsync_directory(path.parent) + finally: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + + +def append_jsonl(path: Path, value: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(value, ensure_ascii=False, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + + +def fsync_directory(path: Path) -> None: + descriptor = os.open(str(path), os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def fsync_tree(root: Path) -> None: + """Make copied regular-file contents durable before the commit rename.""" + + directories: List[Path] = [] + for current, dirnames, filenames in os.walk(str(root), topdown=True, followlinks=False): + current_path = Path(current) + directories.append(current_path) + dirnames[:] = [name for name in dirnames if not (current_path / name).is_symlink()] + for name in filenames: + path = current_path / name + if path.is_symlink(): + continue + descriptor = os.open(str(path), os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + for directory in reversed(directories): + fsync_directory(directory) + + +def hash_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + while True: + block = handle.read(1024 * 1024) + if not block: + break + digest.update(block) + return digest.hexdigest() + + +def _select_samples(paths: List[str], limit: int) -> List[str]: + if len(paths) <= limit: + return paths + if limit <= 1: + return [paths[0]] + indexes = {round(index * (len(paths) - 1) / (limit - 1)) for index in range(limit)} + return [paths[index] for index in sorted(indexes)] + + +def tree_manifest( + root: Path, + *, + sample_limit: int = 16, + excluded_relative_paths: Iterable[str] = (), +) -> Dict[str, Any]: + """Describe a tree without following symlinks. + + The entry list freezes every file's size and mtime while hashes provide a + deterministic content sample. Both are compared before source deletion. + """ + + if not root.is_dir() or root.is_symlink(): + raise ArchiveError("archive source must be a real directory: %s" % root) + excluded = set(excluded_relative_paths) + directories: List[str] = [] + files: List[Dict[str, Any]] = [] + symlinks: List[Dict[str, str]] = [] + regular_paths: List[str] = [] + for current, dirnames, filenames in os.walk(str(root), topdown=True, followlinks=False): + current_path = Path(current) + kept_dirs: List[str] = [] + for name in sorted(dirnames): + path = current_path / name + relative = path.relative_to(root).as_posix() + if relative in excluded: + continue + if path.is_symlink(): + symlinks.append({"path": relative, "target": os.readlink(str(path))}) + else: + directories.append(relative) + kept_dirs.append(name) + dirnames[:] = kept_dirs + for name in sorted(filenames): + path = current_path / name + relative = path.relative_to(root).as_posix() + if relative in excluded: + continue + if path.is_symlink(): + symlinks.append({"path": relative, "target": os.readlink(str(path))}) + continue + stat = path.lstat() + if not path.is_file(): + raise ArchiveError("unsupported non-regular entry: %s" % path) + files.append({"path": relative, "size": stat.st_size, "mtime_ns": stat.st_mtime_ns}) + regular_paths.append(relative) + files.sort(key=lambda item: str(item["path"])) + directories.sort() + symlinks.sort(key=lambda item: item["path"]) + samples = _select_samples(sorted(regular_paths), sample_limit) + content_hashes = {relative: hash_file(root / relative) for relative in sorted(regular_paths)} + return { + "file_count": len(files), + "directory_count": len(directories), + "symlink_count": len(symlinks), + "total_bytes": sum(int(item["size"]) for item in files), + "directories": directories, + "files": files, + "symlinks": symlinks, + "content_hashes": content_hashes, + "sample_hashes": {relative: content_hashes[relative] for relative in samples}, + } + + +def read_volume_uuid(path: Path) -> str: + process = subprocess.run( + ["/usr/sbin/diskutil", "info", "-plist", str(path)], + capture_output=True, + check=False, + ) + if process.returncode != 0: + raise ArchiveError("diskutil could not inspect external volume") + try: + value = plistlib.loads(process.stdout) + except (plistlib.InvalidFileException, ValueError) as error: + raise ArchiveError("diskutil returned invalid volume metadata") from error + volume_uuid = value.get("VolumeUUID") + if not volume_uuid: + raise ArchiveError("external volume has no VolumeUUID") + return str(volume_uuid).upper() + + +def available_bytes(path: Path) -> int: + stat = os.statvfs(str(path)) + return stat.f_bavail * stat.f_frsize + + +def _audit_clock(now: Callable[[], datetime], timezone_name: str) -> Tuple[datetime, datetime]: + checked_at = now() + if checked_at.tzinfo is None: + checked_at = checked_at.replace(tzinfo=timezone.utc) + checked_at = checked_at.astimezone(timezone.utc) + try: + local_time = checked_at.astimezone(ZoneInfo(timezone_name)) + except Exception as error: + raise ArchiveError("electron updater audit timezone is invalid: %s" % timezone_name) from error + return checked_at, local_time + + +def _audit_report_path(audit_config: Dict[str, Any], local_time: datetime) -> Path: + report_dir = Path(str(audit_config["report_dir"])) + return report_dir / ("st1-electron-updater-audit-%04d-%02d.json" % (local_time.year, local_time.month)) + + +def _path_is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def _updater_candidate_metrics(path: Path) -> Tuple[Dict[str, Any], List[str]]: + errors: List[str] = [] + file_count = 0 + total_bytes = 0 + skipped_symlink_count = 0 + mtimes: List[float] = [] + try: + root_stat = path.lstat() + mtimes.append(root_stat.st_mtime) + if path.is_file(): + if stat.S_ISREG(root_stat.st_mode): + file_count = 1 + total_bytes = root_stat.st_size + elif path.is_dir(): + def record_walk_error(error: OSError) -> None: + errors.append("filesystem scan failed for %s: %s" % (path, error)) + + for current, dirnames, filenames in os.walk( + str(path), + topdown=True, + followlinks=False, + onerror=record_walk_error, + ): + current_path = Path(current) + kept_directories: List[str] = [] + for name in sorted(dirnames): + child = current_path / name + if child.is_symlink(): + skipped_symlink_count += 1 + continue + try: + mtimes.append(child.lstat().st_mtime) + except OSError as error: + errors.append("filesystem metadata failed for %s: %s" % (child, error)) + continue + kept_directories.append(name) + dirnames[:] = kept_directories + for name in sorted(filenames): + child = current_path / name + try: + child_stat = child.lstat() + except OSError as error: + errors.append("filesystem metadata failed for %s: %s" % (child, error)) + continue + if stat.S_ISLNK(child_stat.st_mode): + skipped_symlink_count += 1 + continue + mtimes.append(child_stat.st_mtime) + if stat.S_ISREG(child_stat.st_mode): + file_count += 1 + total_bytes += child_stat.st_size + else: + errors.append("unsupported updater residue type: %s" % path) + except OSError as error: + errors.append("filesystem scan failed for %s: %s" % (path, error)) + metrics = { + "file_count": file_count, + "total_bytes": total_bytes, + "skipped_symlink_count": skipped_symlink_count, + "oldest_mtime": datetime.fromtimestamp(min(mtimes), timezone.utc).isoformat() if mtimes else None, + "newest_mtime": datetime.fromtimestamp(max(mtimes), timezone.utc).isoformat() if mtimes else None, + "newest_mtime_epoch": max(mtimes) if mtimes else None, + } + return metrics, errors + + +def audit_electron_updaters( + config: Dict[str, Any], + *, + now: Callable[[], datetime] = utc_now, + invocation_source: str = "manual", +) -> Dict[str, Any]: + audit_config = config.get("electron_updater_audit") + if not isinstance(audit_config, dict): + raise ArchiveError("electron_updater_audit config is missing") + checked_at, local_time = _audit_clock(now, str(audit_config.get("timezone") or "Asia/Shanghai")) + home_path = Path(str(audit_config["home_path"])) + if home_path.is_symlink() or not home_path.is_dir(): + raise ArchiveError("electron updater audit home must be a real directory: %s" % home_path) + home = home_path.resolve(strict=True) + raw_matches: Dict[Path, set[str]] = {} + excluded_symlink_matches: Dict[str, Dict[str, str]] = {} + errors: List[str] = [] + patterns = audit_config.get("patterns") + if not isinstance(patterns, list) or not patterns: + raise ArchiveError("electron updater audit patterns must be a non-empty list") + for value in patterns: + if not isinstance(value, dict) or not value.get("label") or not value.get("glob"): + raise ArchiveError("electron updater audit pattern entries require label and glob") + label = str(value["label"]) + relative_pattern = Path(str(value["glob"])) + if relative_pattern.is_absolute() or ".." in relative_pattern.parts: + raise ArchiveError("electron updater audit glob must stay relative to home: %s" % relative_pattern) + if "**" in relative_pattern.parts: + raise ArchiveError("electron updater audit recursive globs are not allowed: %s" % relative_pattern) + absolute_pattern = str(home / relative_pattern) + for matched in glob.glob(absolute_pattern): + path = Path(matched) + if path.is_symlink(): + try: + target = os.readlink(str(path)) + except OSError as error: + errors.append("updater residue symlink is unreadable: %s: %s" % (path, error)) + continue + excluded_symlink_matches[str(path)] = { + "path": str(path), + "target": target, + "reason": "symlink match was not traversed or counted", + } + continue + try: + resolved = path.resolve(strict=True) + except OSError as error: + errors.append("updater residue match is unreadable: %s: %s" % (path, error)) + continue + if not _path_is_within(resolved, home): + errors.append("updater residue match escaped home: %s" % path) + continue + raw_matches.setdefault(resolved, set()).add(label) + + roots: List[Dict[str, Any]] = [] + for path, labels in sorted(raw_matches.items(), key=lambda item: (len(item[0].parts), str(item[0]))): + parent = next((item for item in roots if _path_is_within(path, Path(str(item["path"])))), None) + if parent is not None: + parent["matched_labels"] = sorted(set(parent["matched_labels"]) | labels) + continue + roots.append({"path": str(path), "matched_labels": sorted(labels)}) + + candidates: List[Dict[str, Any]] = [] + attention_reasons: List[str] = [] + warn_candidate_bytes = int(float(audit_config.get("warn_candidate_gib", 1)) * GIB) + stale_seconds = int(float(audit_config.get("stale_days", 45)) * 86400) + stale_min_bytes = int(float(audit_config.get("stale_min_mib", 100)) * 1024**2) + for root in roots: + path = Path(str(root["path"])) + metrics, scan_errors = _updater_candidate_metrics(path) + errors.extend(scan_errors) + newest_epoch = metrics.pop("newest_mtime_epoch") + age_seconds = max(0.0, checked_at.timestamp() - newest_epoch) if newest_epoch is not None else None + candidate = { + **root, + **metrics, + "age_seconds": age_seconds, + "stale": bool( + age_seconds is not None + and age_seconds >= stale_seconds + and int(metrics["total_bytes"]) >= stale_min_bytes + ), + } + candidates.append(candidate) + if warn_candidate_bytes > 0 and int(candidate["total_bytes"]) >= warn_candidate_bytes: + attention_reasons.append( + "candidate exceeds %.3f GiB threshold: %s" + % (float(audit_config.get("warn_candidate_gib", 1)), candidate["path"]) + ) + if candidate["stale"]: + attention_reasons.append( + "stale candidate exceeds %.3f MiB floor: %s" + % (float(audit_config.get("stale_min_mib", 100)), candidate["path"]) + ) + + total_bytes = sum(int(candidate["total_bytes"]) for candidate in candidates) + warn_total_bytes = int(float(audit_config.get("warn_total_gib", 5)) * GIB) + if warn_total_bytes > 0 and total_bytes >= warn_total_bytes: + attention_reasons.insert( + 0, + "total updater residue exceeds %.3f GiB threshold" + % float(audit_config.get("warn_total_gib", 5)), + ) + status = "red" if errors else ("attention" if attention_reasons else "green") + report_path = _audit_report_path(audit_config, local_time) + report: Dict[str, Any] = { + "schema": "multica.st1-electron-updater-audit.v1", + "duty": "ST-1", + "status": status, + "audit_month": "%04d-%02d" % (local_time.year, local_time.month), + "recorded_at": checked_at.isoformat(), + "recorded_at_local": local_time.isoformat(), + "timezone": str(audit_config.get("timezone") or "Asia/Shanghai"), + "invocation_source": invocation_source, + "report_path": str(report_path), + "candidate_count": len(candidates), + "total_bytes": total_bytes, + "stale_candidate_count": sum(1 for candidate in candidates if candidate["stale"]), + "attention_reasons": attention_reasons, + "errors": errors, + "excluded_symlink_matches": [ + excluded_symlink_matches[path] for path in sorted(excluded_symlink_matches) + ], + "candidates": candidates, + "destructive_actions": [], + } + atomic_write_json(report_path, report) + return report + + +def maybe_run_electron_updater_audit( + config: Dict[str, Any], + *, + now: Callable[[], datetime] = utc_now, + force: bool = False, + invocation_source: str = "manual", +) -> Dict[str, Any]: + audit_config = config.get("electron_updater_audit") + if not isinstance(audit_config, dict) or not audit_config.get("enabled", False): + return {"status": "skipped", "reason": "electron updater audit is disabled"} + _, local_time = _audit_clock(now, str(audit_config.get("timezone") or "Asia/Shanghai")) + due_day = int(audit_config.get("day_of_month", 15)) + if due_day < 1 or due_day > 28: + raise ArchiveError("electron updater audit day_of_month must be between 1 and 28") + if not force and local_time.day < due_day: + return {"status": "skipped", "reason": "current month is before day %d" % due_day} + report_path = _audit_report_path(audit_config, local_time) + if not force and report_path.is_file(): + try: + existing = json.loads(report_path.read_text(encoding="utf-8")) + modified = datetime.fromtimestamp(report_path.stat().st_mtime, ZoneInfo(str(audit_config.get("timezone") or "Asia/Shanghai"))) + except (OSError, ValueError, json.JSONDecodeError): + existing = {} + modified = None + if ( + isinstance(existing, dict) + and existing.get("schema") == "multica.st1-electron-updater-audit.v1" + and existing.get("audit_month") == "%04d-%02d" % (local_time.year, local_time.month) + and existing.get("status") in {"green", "attention"} + and modified is not None + and (modified.year, modified.month) == (local_time.year, local_time.month) + ): + return { + "status": "skipped", + "reason": "valid current-month evidence already exists", + "report_path": str(report_path), + "evidence_status": existing["status"], + } + return audit_electron_updaters(config, now=now, invocation_source=invocation_source) + + +class Canary: + def __init__( + self, + destination: Path, + *, + expected_uuid: str, + min_free_bytes: int, + volume_path: Optional[Path] = None, + uuid_reader: Callable[[Path], str] = read_volume_uuid, + free_bytes_reader: Callable[[Path], int] = available_bytes, + postflight: Optional[Callable[[], Dict[str, Any]]] = None, + ): + self.destination = destination + self.expected_uuid = expected_uuid.upper() + self.min_free_bytes = min_free_bytes + self.volume_path = volume_path or destination + self.uuid_reader = uuid_reader + self.free_bytes_reader = free_bytes_reader + self.postflight = postflight + + def run(self) -> Dict[str, Any]: + actual_uuid = self.uuid_reader(self.volume_path).upper() + if actual_uuid != self.expected_uuid: + raise ArchiveError( + "external volume UUID mismatch: expected %s, got %s" + % (self.expected_uuid, actual_uuid) + ) + free_bytes = self.free_bytes_reader(self.volume_path) + if free_bytes < self.min_free_bytes: + raise ArchiveError( + "external volume below low-water mark: %d < %d bytes" + % (free_bytes, self.min_free_bytes) + ) + + canary_root = self.destination / ".multica-storage-canary" + canary_root.mkdir(parents=True, exist_ok=True) + run_name = "%s-%s" % (utc_now().strftime("%Y%m%dT%H%M%S.%fZ"), uuid.uuid4().hex[:8]) + partial = canary_root / (run_name + ".partial") + final = canary_root / run_name + (partial / "nested" / "deeper").mkdir(parents=True) + (partial / "root.txt").write_text("multica storage canary\n", encoding="utf-8") + (partial / "nested" / "payload.bin").write_bytes(bytes(range(128))) + (partial / "nested" / "deeper" / "empty").write_bytes(b"") + fsync_tree(partial) + expected = tree_manifest(partial) + os.replace(str(partial), str(final)) + fsync_directory(canary_root) + actual = tree_manifest(final) + if expected != actual: + raise ArchiveError("canary verification failed after atomic rename") + if self.postflight is not None: + verified_volume = self.postflight() + actual_uuid = str(verified_volume["volume_uuid"]) + free_bytes = int(verified_volume["free_bytes"]) + result = { + "schema": "multica.external-volume-canary.v1", + "status": "green", + "checked_at": utc_now().isoformat(), + "volume_uuid": actual_uuid, + "free_bytes": free_bytes, + "minimum_free_bytes": self.min_free_bytes, + "path": str(final), + "manifest": actual, + } + atomic_write_json(final / "CANARY.json", result) + completed = sorted( + (path for path in canary_root.iterdir() if path.is_dir() and not path.name.endswith(".partial")), + key=lambda path: path.name, + reverse=True, + ) + for stale in completed[96:]: + shutil.rmtree(str(stale)) + return result + + +class ExternalVolumeGuard: + """Bind archive writes to one physical external volume at every phase.""" + + def __init__( + self, + external_root: Path, + archive_root: Path, + *, + expected_uuid: str, + min_free_bytes: int, + uuid_reader: Callable[[Path], str] = read_volume_uuid, + free_bytes_reader: Callable[[Path], int] = available_bytes, + ): + self.external_root = external_root + self.archive_root = archive_root + self.expected_uuid = expected_uuid.upper() + self.min_free_bytes = min_free_bytes + self.uuid_reader = uuid_reader + self.free_bytes_reader = free_bytes_reader + + def check(self, required_bytes: int = 0) -> Dict[str, Any]: + if self.external_root.is_symlink() or self.archive_root.is_symlink(): + raise ArchiveError("external or archive root must not be a symlink") + external = self.external_root.resolve(strict=True) + archive = self.archive_root.resolve(strict=True) + try: + archive.relative_to(external) + except ValueError as error: + raise ArchiveError("archive root is outside the verified external root") from error + if external.stat().st_dev != archive.stat().st_dev: + raise ArchiveError("archive root is not on the verified external device") + actual_uuid = self.uuid_reader(external).upper() + if actual_uuid != self.expected_uuid: + raise ArchiveError("archive volume UUID changed before commit") + free_bytes = self.free_bytes_reader(archive) + reserve = self.min_free_bytes + int(required_bytes * 1.10) + if free_bytes < reserve: + raise ArchiveError( + "archive volume lacks candidate budget: %d < %d bytes" % (free_bytes, reserve) + ) + return {"volume_uuid": actual_uuid, "free_bytes": free_bytes, "required_reserve_bytes": reserve} + + +class SingleInstanceLock: + def __init__(self, path: Path): + self.path = path + self.handle: Optional[Any] = None + + def __enter__(self) -> "SingleInstanceLock": + self.path.parent.mkdir(parents=True, exist_ok=True) + self.handle = self.path.open("a+") + try: + fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + self.handle.close() + self.handle = None + raise + self.handle.seek(0) + self.handle.truncate() + self.handle.write("%d\n" % os.getpid()) + self.handle.flush() + return self + + def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: + if self.handle is not None: + fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN) + self.handle.close() + self.handle = None + + +class ArchiveManager: + def __init__( + self, + destination: Path, + *, + fail_at: Optional[str] = None, + preflight: Optional[Callable[[int], Any]] = None, + ): + self.destination = destination + self.fail_at = fail_at + self.preflight = preflight + + def _fail(self, phase: str) -> None: + if self.fail_at == phase: + raise ArchiveError("injected failure at %s" % phase) + + def archive( + self, + source: Path, + candidate_id: str, + *, + delete_source: bool = False, + delete_gate: Optional[Callable[[], bool]] = None, + approval_token: Optional[str] = None, + post_commit_hook: Optional[Callable[[Path], None]] = None, + ) -> Dict[str, Any]: + if delete_source: + raise ArchiveError("source deletion requires a producer lease and is disabled") + source = source.absolute() + if not source.is_dir() or source.is_symlink(): + raise ArchiveError("refusing unsafe source: %s" % source) + destination = self.destination.absolute() + if source == destination or source in destination.parents or destination in source.parents: + raise ArchiveError("source and archive destination must not contain one another") + self.destination.mkdir(parents=True, exist_ok=True) + + frozen = tree_manifest(source) + if self.preflight is not None: + self.preflight(int(frozen["total_bytes"])) + suffix = utc_now().strftime("%Y%m%dT%H%M%S.%fZ") + final = self.destination / (candidate_id + "-" + suffix) + partial = self.destination / (final.name + ".partial") + if partial.exists() or final.exists(): + raise ArchiveError("archive destination already exists") + + try: + shutil.copytree(str(source), str(partial), symlinks=True, copy_function=shutil.copy2) + fsync_tree(partial) + fsync_directory(self.destination) + self._fail("after_copy") + + copied = tree_manifest(partial) + source_after_copy = tree_manifest(source) + if frozen != source_after_copy: + raise ArchiveError("source changed while archive copy was in progress") + if frozen != copied: + raise ArchiveError("archive count/bytes/entry/sample verification failed") + self._fail("after_verify") + + if self.preflight is not None: + self.preflight(0) + os.replace(str(partial), str(final)) + fsync_directory(self.destination) + self._fail("after_rename") + + if post_commit_hook is not None: + post_commit_hook(final) + committed = tree_manifest(final) + if committed != frozen: + raise ArchiveError("committed archive changed before COMPLETE marker") + + marker = { + "schema": "multica.transactional-archive.v1", + "completed_at": utc_now().isoformat(), + "candidate_id": candidate_id, + "source_path": str(source), + "archive_path": str(final), + "source_manifest": frozen, + "archive_manifest": committed, + "approval_token": approval_token, + "source_delete_enabled": False, + } + atomic_write_json(final / "COMPLETE.json", marker) + fsync_directory(final) + self._fail("after_complete") + + source_before_delete = tree_manifest(source) + persisted = json.loads((final / "COMPLETE.json").read_text(encoding="utf-8")) + if source_before_delete != frozen or persisted != marker: + raise ArchiveError("source or COMPLETE marker changed before delete gate") + return { + "status": "complete", + "source_path": str(source), + "archive_path": str(final), + "source_deleted": False, + "manifest": frozen, + } + except ArchiveError: + raise + except Exception as error: + raise ArchiveError("transactional archive failed: %s" % error) from error + + +class MulticaIssueClient: + def _json(self, argv: List[str]) -> Any: + try: + process = subprocess.run(argv, capture_output=True, text=True, check=False, timeout=15) + except subprocess.TimeoutExpired as error: + raise ArchiveError("multica query timed out") from error + if process.returncode != 0: + detail = (process.stderr or process.stdout).strip() + raise ArchiveError("multica query failed: %s" % detail) + try: + return json.loads(process.stdout) + except json.JSONDecodeError as error: + raise ArchiveError("multica query returned invalid JSON") from error + + def get_issue(self, issue_id: str) -> Dict[str, Any]: + value = self._json(["multica", "issue", "get", issue_id, "--output", "json"]) + if not isinstance(value, dict): + raise ArchiveError("issue response is not an object") + return value + + def get_runs(self, issue_id: str) -> List[Dict[str, Any]]: + value = self._json(["multica", "issue", "runs", issue_id, "--output", "json"]) + return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else [] + + def get_children(self, issue_id: str) -> List[Dict[str, Any]]: + value = self._json(["multica", "issue", "children", issue_id, "--output", "json"]) + if not isinstance(value, dict) or "stages" not in value or "unstaged" not in value or "total" not in value: + raise ArchiveError("children response schema is unknown") + children: List[Dict[str, Any]] = [] + for item in value.get("unstaged") or []: + if isinstance(item, dict): + children.append(item) + for stage in value.get("stages") or []: + if not isinstance(stage, dict): + continue + for item in stage.get("issues") or stage.get("children") or []: + if isinstance(item, dict): + children.append(item) + if int(value["total"]) != len(children): + raise ArchiveError("children response count does not match parsed entries") + return children + + +def default_open_file_checker(path: Path) -> bool: + process = subprocess.run( + ["/usr/sbin/lsof", "+D", str(path)], + capture_output=True, + text=True, + check=False, + timeout=30, + ) + if process.returncode not in (0, 1): + raise ArchiveError("lsof failed while checking %s" % path) + lines = [line for line in process.stdout.splitlines() if line.strip()] + return len(lines) > 1 + + +def _latest_mtime_size_and_unsafe_symlinks(root: Path) -> Tuple[float, int, List[str]]: + latest = root.lstat().st_mtime + total_bytes = 0 + unsafe: List[str] = [] + resolved_root = root.resolve() + for current, dirnames, filenames in os.walk(str(root), topdown=True, followlinks=False): + current_path = Path(current) + kept_dirs: List[str] = [] + for name in dirnames: + path = current_path / name + stat = path.lstat() + latest = max(latest, stat.st_mtime) + if path.is_symlink(): + try: + path.resolve(strict=False).relative_to(resolved_root) + except ValueError: + unsafe.append(path.relative_to(root).as_posix()) + else: + kept_dirs.append(name) + dirnames[:] = kept_dirs + for name in filenames: + path = current_path / name + stat = path.lstat() + latest = max(latest, stat.st_mtime) + if not path.is_symlink(): + total_bytes += stat.st_size + if path.is_symlink(): + try: + path.resolve(strict=False).relative_to(resolved_root) + except ValueError: + unsafe.append(path.relative_to(root).as_posix()) + return latest, total_bytes, sorted(unsafe) + + +class GCEvaluator: + def __init__( + self, + client: Any, + *, + now: Callable[[], datetime] = utc_now, + open_file_checker: Callable[[Path], bool] = default_open_file_checker, + retention_seconds: int = 7 * 86400, + recent_write_seconds: int = 24 * 3600, + ): + self.client = client + self.now = now + self.open_file_checker = open_file_checker + self.retention_seconds = retention_seconds + self.recent_write_seconds = recent_write_seconds + + def evaluate(self, candidate: Path) -> Dict[str, Any]: + reasons: List[str] = [] + details: Dict[str, Any] = {} + meta_path = candidate / ".gc_meta.json" + context_path = candidate / "workdir" / ".multica" / "daemon_task_context.json" + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + context = json.loads(context_path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, json.JSONDecodeError) as error: + return { + "path": str(candidate), + "eligible": False, + "reasons": ["identity metadata unreadable: %s" % error], + } + if not isinstance(meta, dict) or not isinstance(context, dict): + return {"path": str(candidate), "eligible": False, "reasons": ["identity metadata invalid"]} + + issue_id = str(meta.get("issue_id") or "") + workspace_id = str(meta.get("workspace_id") or "") + if ( + meta.get("kind") != "issue" + or not issue_id + or not workspace_id + or candidate.parent.name != workspace_id + or context.get("managed_by") != "multica-daemon-task" + or context.get("issue_id") != issue_id + ): + reasons.append("identity metadata does not match directory") + + try: + completed_at = parse_timestamp(str(meta.get("completed_at") or "")) + age = (self.now() - completed_at).total_seconds() + details["completed_at"] = completed_at.isoformat() + details["age_seconds"] = age + if age < self.retention_seconds: + reasons.append("retention window has not elapsed") + except (ValueError, TypeError): + completed_at = None + reasons.append("completed_at is invalid") + + try: + issue = self.client.get_issue(issue_id) + runs = self.client.get_runs(issue_id) + children = self.client.get_children(issue_id) + except Exception as error: + reasons.append("control-plane lookup failed closed: %s" % error) + issue, runs, children = {}, [], [] + + if issue.get("status") not in TERMINAL_ISSUE_STATUSES: + reasons.append("issue is not in an irreversible terminal status") + if issue.get("id") != issue_id or issue.get("workspace_id") != workspace_id: + reasons.append("identity differs from control plane") + metadata = issue.get("metadata") if isinstance(issue.get("metadata"), dict) else {} + if any(metadata.get(key) for key in ("gc_pin", "pinned", "retention_pin")) or (candidate / ".gc-pin").exists(): + reasons.append("candidate has a retention pin") + if any(child.get("status") not in TERMINAL_ISSUE_STATUSES for child in children): + reasons.append("nonterminal child or supplement verification exists") + if any(run.get("status") not in TERMINAL_RUN_STATUSES for run in runs): + reasons.append("active run or lease exists") + + matching_runs: List[Dict[str, Any]] = [] + for run in runs: + run_id = str(run.get("id") or "") + work_dir = Path(str(run.get("work_dir") or "")) + if run_id.startswith(candidate.name) and work_dir == candidate / "workdir": + matching_runs.append(run) + if len(matching_runs) != 1: + reasons.append("identity has no unique matching task run") + else: + run = matching_runs[0] + if ( + run.get("issue_id") not in (None, issue_id) + or run.get("workspace_id") not in (None, workspace_id) + or run.get("status") not in TERMINAL_RUN_STATUSES + ): + reasons.append("identity or terminal state differs from task run") + if completed_at is not None: + try: + run_completed = parse_timestamp(str(run.get("completed_at") or "")) + if abs((run_completed - completed_at).total_seconds()) > 1: + reasons.append("identity completed_at differs from task run") + except ValueError: + reasons.append("task run completed_at is invalid") + + try: + if self.open_file_checker(candidate): + reasons.append("open file exists under candidate") + except Exception as error: + reasons.append("open file check failed closed: %s" % error) + try: + latest_mtime, total_bytes, unsafe_symlinks = _latest_mtime_size_and_unsafe_symlinks(candidate) + details["latest_mtime"] = datetime.fromtimestamp(latest_mtime, timezone.utc).isoformat() + details["size_bytes"] = total_bytes + if self.now().timestamp() - latest_mtime < self.recent_write_seconds: + reasons.append("recent write exists under candidate") + if unsafe_symlinks: + reasons.append("out-of-bound symlink present (not followed): %s" % ", ".join(unsafe_symlinks)) + except OSError as error: + reasons.append("filesystem scan failed closed: %s" % error) + + result = { + "path": str(candidate), + "issue_id": issue_id, + "workspace_id": workspace_id, + "eligible": not reasons, + "reasons": reasons, + "details": details, + } + if not reasons: + try: + manifest = tree_manifest(candidate) + digest = hashlib.sha256( + json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + matching = matching_runs[0] + approval_identity = { + "source_path": str(candidate.resolve()), + "workspace_id": workspace_id, + "issue_id": issue_id, + "run_id": str(matching.get("id")), + "completed_at": str(meta.get("completed_at")), + "manifest_sha256": digest, + } + result["run_id"] = approval_identity["run_id"] + result["manifest_sha256"] = digest + result["approval_token"] = hashlib.sha256( + json.dumps(approval_identity, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + result["details"]["size_bytes"] = int(manifest["total_bytes"]) + except Exception as error: + reasons.append("approval manifest failed closed: %s" % error) + result["eligible"] = False + return result + + +def discover_candidates(roots: Iterable[Path]) -> List[Path]: + candidates: List[Path] = [] + for root in roots: + if not root.is_dir() or root.is_symlink(): + continue + for workspace in sorted(root.iterdir()): + if not workspace.is_dir() or workspace.is_symlink() or workspace.name.startswith("."): + continue + for candidate in sorted(workspace.iterdir()): + if candidate.is_dir() and not candidate.is_symlink() and (candidate / ".gc_meta.json").is_file(): + candidates.append(candidate) + return candidates + + +def consumed_approval_tokens(archive_root: Path) -> set[str]: + tokens: set[str] = set() + if not archive_root.is_dir(): + return tokens + for marker_path in archive_root.glob("*/COMPLETE.json"): + try: + marker = json.loads(marker_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + token = marker.get("approval_token") + if token: + tokens.add(str(token)) + return tokens + + +def has_message_id(value: Any) -> bool: + if isinstance(value, dict): + return bool(value.get("message_id")) or any(has_message_id(child) for child in value.values()) + if isinstance(value, list): + return any(has_message_id(child) for child in value) + return False + + +def send_alert(config: Dict[str, Any], message: str) -> None: + alert_path = Path(str(config["alert_log_path"])) + try: + append_jsonl(alert_path, {"recorded_at": utc_now().isoformat(), "message": message}) + except OSError: + pass + open_id = str(config.get("lark_open_id") or "") + if not open_id: + return + try: + process = subprocess.run( + [ + "lark-cli", + "im", + "+messages-send", + "--as", + "bot", + "--user-id", + open_id, + "--text", + message, + "--format", + "json", + ], + capture_output=True, + text=True, + check=False, + timeout=15, + ) + response = json.loads(process.stdout) if process.stdout else {} + delivered = process.returncode == 0 and has_message_id(response) + except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError): + delivered = False + process = None + if not delivered: + exit_code = process.returncode if process is not None else None + try: + append_jsonl( + alert_path, + {"recorded_at": utc_now().isoformat(), "message": "lark alert delivery failed", "exit_code": exit_code}, + ) + except OSError: + pass + + +def process_ancestry(start_pid: Optional[int] = None, limit: int = 8) -> List[Dict[str, Any]]: + pid = os.getpid() if start_pid is None else start_pid + values: List[Dict[str, Any]] = [] + for _ in range(limit): + process = subprocess.run( + ["/bin/ps", "-p", str(pid), "-o", "ppid=", "-o", "comm="], + capture_output=True, + text=True, + check=False, + ) + fields = process.stdout.strip().split(None, 1) + if process.returncode != 0 or len(fields) != 2: + break + parent = int(fields[0]) + command = fields[1] + values.append({"pid": pid, "parent_pid": parent, "command": command}) + if parent <= 1 or parent == pid: + break + pid = parent + return values + + +def verify_cron_bridge(config: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: + if os.environ.get("MULTICA_STORAGE_CRON_BRIDGE") != "1": + raise ArchiveError("formal cron bridge marker is missing") + trigger_path = Path(str(config["cron_bridge_trigger_path"])) + try: + trigger = json.loads(trigger_path.read_text(encoding="utf-8")) + bridge_pid = int(trigger["bridge_pid"]) + created_at = parse_timestamp(str(trigger["created_at"])) + except (FileNotFoundError, OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as error: + raise ArchiveError("formal cron bridge trigger is invalid") from error + if trigger.get("schema") != "multica.storage-cron-trigger.v1" or not trigger.get("token"): + raise ArchiveError("formal cron bridge trigger schema is invalid") + try: + receipt = json.loads(Path(str(config["cron_bridge_receipt_path"])).read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, json.JSONDecodeError): + receipt = {} + if receipt.get("token") == trigger["token"]: + raise ArchiveError("formal cron bridge token was already consumed") + age = (utc_now() - created_at).total_seconds() + if age < 0 or age > 120: + raise ArchiveError("formal cron bridge trigger is stale") + command = subprocess.run( + ["/bin/ps", "-p", str(bridge_pid), "-o", "command="], + capture_output=True, + text=True, + check=False, + ) + if command.returncode != 0 or "retention_cron_bridge.py" not in command.stdout: + raise ArchiveError("formal cron bridge process is not alive") + lineage = process_ancestry(bridge_pid) + if not any(Path(str(item["command"])).name == "cron" for item in lineage): + raise ArchiveError("formal cron bridge has no live cron ancestor") + return trigger, lineage + + +def write_cron_bridge_receipt( + config: Dict[str, Any], + *, + token: Optional[str], + status: str, + error: Optional[str] = None, +) -> None: + if not token: + return + atomic_write_json( + Path(str(config["cron_bridge_receipt_path"])), + {"token": token, "status": status, "recorded_at": utc_now().isoformat(), "error": error}, + ) + + +def atomic_write_failure_report(config: Dict[str, Any], message: str) -> None: + path = Path(str(config["report_path"])) + last_success_at: Optional[str] = None + try: + previous = json.loads(path.read_text(encoding="utf-8")) + if isinstance(previous, dict): + if previous.get("status") == "green": + last_success_at = str(previous.get("recorded_at") or "") or None + else: + last_success_at = previous.get("last_success_at") + except (FileNotFoundError, OSError, json.JSONDecodeError): + pass + atomic_write_json( + path, + { + "schema": "multica.storage-retention-run.v1", + "status": "red", + "failed_at": utc_now().isoformat(), + "last_success_at": last_success_at, + "error": message, + }, + ) + + +def run_worker(config: Dict[str, Any]) -> Dict[str, Any]: + ancestry = process_ancestry() + trigger: Optional[Dict[str, Any]] = None + cron_lineage: List[Dict[str, Any]] = [] + if config.get("require_cron_lineage", True): + trigger, cron_lineage = verify_cron_bridge(config) + config["_verified_cron_token"] = str(trigger["token"]) + write_cron_bridge_receipt(config, token=str(trigger["token"]), status="running") + electron_audit = maybe_run_electron_updater_audit( + config, + invocation_source="verified-cron-launchd-bridge" if trigger else "manual-worker", + ) + if electron_audit.get("status") == "attention": + send_alert( + config, + "ST-1 Electron updater audit needs review: %d candidates, %.3f GiB; report: %s" + % ( + int(electron_audit.get("candidate_count") or 0), + int(electron_audit.get("total_bytes") or 0) / GIB, + str(electron_audit.get("report_path") or "missing"), + ), + ) + elif electron_audit.get("status") == "red": + raise ArchiveError( + "ST-1 Electron updater audit was incomplete: %s" + % "; ".join(str(value) for value in electron_audit.get("errors") or ["unknown error"]) + ) + if config.get("delete_source", False): + raise ArchiveError("source deletion requires a producer lease and remains disabled") + external_path = Path(str(config["external_path"])) + archive_root = Path(str(config["archive_root"])) + archive_root.mkdir(parents=True, exist_ok=True) + volume_guard = ExternalVolumeGuard( + external_path, + archive_root, + expected_uuid=str(config["external_volume_uuid"]), + min_free_bytes=int(float(config.get("external_min_free_gib", 100)) * GIB), + ) + volume_guard.check() + canary_root = Path(str(config.get("canary_root") or external_path)) + canary_root.mkdir(parents=True, exist_ok=True) + canary_guard = ExternalVolumeGuard( + external_path, + canary_root, + expected_uuid=str(config["external_volume_uuid"]), + min_free_bytes=int(float(config.get("external_min_free_gib", 100)) * GIB), + ) + canary_guard.check() + canary = Canary( + canary_root, + expected_uuid=str(config["external_volume_uuid"]), + min_free_bytes=int(float(config.get("external_min_free_gib", 100)) * GIB), + volume_path=external_path, + postflight=canary_guard.check, + ).run() + evaluator = GCEvaluator( + MulticaIssueClient(), + retention_seconds=int(float(config.get("retention_days", 7)) * 86400), + recent_write_seconds=int(config.get("recent_write_seconds", 86400)), + ) + candidate_paths = discover_candidates(Path(str(item)) for item in config.get("workspace_roots", [])) + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + candidates = list(executor.map(evaluator.evaluate, candidate_paths)) + report: Dict[str, Any] = { + "schema": "multica.storage-retention-run.v1", + "status": "green", + "recorded_at": utc_now().isoformat(), + "pid": os.getpid(), + "parent_pid": os.getppid(), + "invocation_source": "verified-cron-launchd-bridge" if trigger else "manual", + "process_ancestry": ancestry, + "cron_bridge_ancestry": cron_lineage, + "cron_trigger_token": trigger.get("token") if trigger else None, + "canary": canary, + "electron_updater_audit": electron_audit, + "gc_mode": "dry-run", + "gc_candidates": candidates, + "eligible_count": sum(1 for candidate in candidates if candidate["eligible"]), + "archive_enabled": bool(config.get("archive_enabled", False)), + "delete_source": bool(config.get("delete_source", False)), + "archives": [], + } + if config.get("archive_enabled", False): + manager = ArchiveManager(archive_root, preflight=volume_guard.check) + approved = {str(value) for value in config.get("approved_candidates", [])} + consumed = consumed_approval_tokens(archive_root) + for candidate in candidates: + candidate_path = Path(str(candidate["path"])) + token = str(candidate.get("approval_token") or "") + if not candidate["eligible"] or token not in approved or token in consumed: + continue + fresh = evaluator.evaluate(candidate_path) + if not fresh["eligible"] or fresh.get("approval_token") != candidate.get("approval_token"): + continue + report["archives"].append( + manager.archive( + candidate_path, + candidate_path.name, + delete_source=False, + approval_token=token, + ) + ) + consumed.add(token) + atomic_write_json(Path(str(config["report_path"])), report) + if trigger: + write_cron_bridge_receipt(config, token=str(trigger["token"]), status="green") + return report + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--config", required=True) + parser.add_argument( + "--electron-audit-only", + action="store_true", + help="force the read-only ST-1 updater audit without cron lineage or external-volume checks", + ) + args = parser.parse_args() + config: Dict[str, Any] = {} + audit_only_report: Optional[Dict[str, Any]] = None + try: + config = json.loads(Path(args.config).read_text(encoding="utf-8")) + with SingleInstanceLock(Path(str(config["lock_path"]))): + if args.electron_audit_only: + audit_only_report = maybe_run_electron_updater_audit( + config, + force=True, + invocation_source="manual-audit-only", + ) + else: + report = run_worker(config) + except BlockingIOError: + message = "storage retention worker skipped: another owner holds the single-instance lock" + for action in ( + lambda: atomic_write_failure_report(config, message), + lambda: write_cron_bridge_receipt( + config, + token=str(config.get("_verified_cron_token") or "") or None, + status="red", + error=message, + ), + lambda: send_alert(config, message), + ): + try: + action() + except Exception: + pass + print(json.dumps({"status": "locked", "error": message}), file=sys.stderr) + return 75 + except Exception as error: + message = "storage retention worker failed closed: %s" % error + for action in ( + lambda: atomic_write_failure_report(config, message), + lambda: write_cron_bridge_receipt( + config, + token=str(config.get("_verified_cron_token") or "") or None, + status="red", + error=message, + ), + lambda: send_alert(config, message), + ): + try: + action() + except Exception: + pass + print(json.dumps({"status": "red", "error": message}, ensure_ascii=False), file=sys.stderr) + return 1 + if audit_only_report is not None: + print( + json.dumps( + { + "status": audit_only_report["status"], + "audit_month": audit_only_report["audit_month"], + "candidate_count": audit_only_report["candidate_count"], + "stale_candidate_count": audit_only_report["stale_candidate_count"], + "total_bytes": audit_only_report["total_bytes"], + "report_path": audit_only_report["report_path"], + }, + ensure_ascii=False, + sort_keys=True, + ) + ) + return 1 if audit_only_report["status"] == "red" else 0 + print( + json.dumps( + { + "status": report["status"], + "recorded_at": report["recorded_at"], + "eligible_count": report["eligible_count"], + "archive_count": len(report["archives"]), + "report_path": str(config["report_path"]), + }, + ensure_ascii=False, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/storage-governance/storage_guard.py b/scripts/storage-governance/storage_guard.py new file mode 100644 index 00000000000..ca22ef7d649 --- /dev/null +++ b/scripts/storage-governance/storage_guard.py @@ -0,0 +1,753 @@ +#!/usr/bin/env python3 +"""Minute-level storage guard for a Multica runtime host. + +The guard is deliberately small: sample, classify with hysteresis, pause new +Multica admissions, stop explicitly listed non-critical jobs, and alert. It +never deletes or archives data. +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import re +import signal +import subprocess +import tempfile +import time +from dataclasses import asdict, dataclass, replace +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple + + +GIB = 1024**3 + + +@dataclass(frozen=True) +class Sample: + recorded_at: str + internal_free_bytes: int + external_free_bytes: Optional[int] + swap_used_bytes: int + active_task_count: int + daemon_pid: Optional[int] + daemon_status: str + admission_pause_owners: Optional[Tuple[str, ...]] = None + shadow_runs_bytes: Optional[int] = None + workspace_total_bytes: Optional[int] = None + workspace_inflight_bytes: Optional[int] = None + workspace_gc_eligible_bytes: Optional[int] = None + workspace_gc_backlog_bytes: Optional[int] = None + workspace_unclassified_bytes: Optional[int] = None + cursor_bytes: Optional[int] = None + logs_bytes: Optional[int] = None + + +class CommandRunner: + def run(self, argv: List[str], *, tolerate_failure: bool = False) -> Tuple[int, str, str]: + try: + proc = subprocess.run(argv, capture_output=True, text=True, check=False, timeout=10) + except subprocess.TimeoutExpired as error: + if tolerate_failure: + return 124, "", "command timed out after 10 seconds" + raise RuntimeError("command timed out: %s" % argv[0]) from error + if proc.returncode != 0 and not tolerate_failure: + detail = (proc.stderr or proc.stdout).strip() + raise RuntimeError("command failed (%d): %s: %s" % (proc.returncode, argv[0], detail)) + return proc.returncode, proc.stdout, proc.stderr + + def signal(self, pid: int, sig: int) -> None: + os.kill(pid, sig) + + +class SystemCollector: + def __init__(self, config: Dict[str, Any], runner: CommandRunner): + self.config = config + self.runner = runner + + @staticmethod + def free_bytes(path: str) -> int: + stat = os.statvfs(path) + return stat.f_bavail * stat.f_frsize + + def swap_used_bytes(self) -> int: + code, stdout, stderr = self.runner.run(["/usr/sbin/sysctl", "vm.swapusage"]) + if code != 0: + raise RuntimeError("sysctl vm.swapusage failed: " + (stderr or stdout).strip()) + match = re.search(r"\bused\s*=\s*([0-9.]+)([KMGT])", stdout) + if not match: + raise RuntimeError("could not parse vm.swapusage") + units = {"K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4} + return int(float(match.group(1)) * units[match.group(2)]) + + def daemon_status(self) -> Dict[str, Any]: + code, stdout, _ = self.runner.run( + ["multica", "daemon", "status", "--output", "json"], + tolerate_failure=True, + ) + if code != 0: + return {"status": "stopped", "active_task_count": 0, "pid": None} + try: + value = json.loads(stdout) + except json.JSONDecodeError: + return {"status": "unknown", "active_task_count": 0, "pid": None} + return value if isinstance(value, dict) else {"status": "unknown"} + + @staticmethod + def directory_size(path: Path, *, deadline: Optional[float] = None) -> Optional[int]: + if deadline is not None and time.monotonic() >= deadline: + return None + if not path.is_dir() or path.is_symlink(): + return None + total = 0 + try: + for current, dirnames, filenames in os.walk(str(path), topdown=True, followlinks=False): + if deadline is not None and time.monotonic() >= deadline: + return None + current_path = Path(current) + dirnames[:] = [name for name in dirnames if not (current_path / name).is_symlink()] + for name in filenames: + if deadline is not None and time.monotonic() >= deadline: + return None + candidate = current_path / name + if not candidate.is_symlink(): + total += candidate.stat().st_size + except OSError: + return None + return total + + def growth_metrics(self) -> Dict[str, Optional[int]]: + deadline = time.monotonic() + max(0.0, float(self.config.get("growth_scan_budget_seconds", 5))) + + def scan(path: object) -> Optional[int]: + return self.directory_size(Path(str(path)), deadline=deadline) + + shadow = scan(self.config["shadow_runs_path"]) if self.config.get("shadow_runs_path") else None + cursor = scan(self.config["cursor_path"]) if self.config.get("cursor_path") else None + log_values = [scan(path) for path in self.config.get("logs_paths", [])] + logs = sum(value for value in log_values if value is not None) if log_values and all(value is not None for value in log_values) else None + workspace_values = [scan(path) for path in self.config.get("workspace_roots", [])] + workspace_total = ( + sum(value for value in workspace_values if value is not None) + if workspace_values and all(value is not None for value in workspace_values) + else None + ) + + eligible = inflight = backlog = None + report_path = self.config.get("retention_report_path") + if report_path: + try: + report = json.loads(Path(str(report_path)).read_text(encoding="utf-8")) + recorded_at = parse_timestamp(str(report["recorded_at"])) + maximum_age = float(self.config.get("retention_report_max_age_seconds", 1800)) + report_age = (datetime.now(timezone.utc) - recorded_at).total_seconds() + if report.get("status") == "green" and 0 <= report_age <= maximum_age: + eligible_value = inflight_value = backlog_value = 0 + for candidate in report.get("gc_candidates") or []: + size = int((candidate.get("details") or {}).get("size_bytes") or 0) + reasons = " ".join(str(value) for value in candidate.get("reasons") or []) + if candidate.get("eligible"): + eligible_value += size + elif any( + marker in reasons + for marker in ("not terminal", "nonterminal child", "active run", "open file", "recent write") + ): + inflight_value += size + elif float((candidate.get("details") or {}).get("age_seconds") or 0) >= float(self.config.get("retention_days", 7)) * 86400: + backlog_value += size + eligible, inflight, backlog = eligible_value, inflight_value, backlog_value + except (KeyError, OSError, ValueError, TypeError, json.JSONDecodeError): + pass + unclassified = None + if workspace_total is not None and eligible is not None and inflight is not None and backlog is not None: + categorized = eligible + inflight + backlog + if categorized <= workspace_total: + unclassified = workspace_total - categorized + else: + eligible = inflight = backlog = None + return { + "shadow_runs_bytes": shadow, + "workspace_total_bytes": workspace_total, + "workspace_inflight_bytes": inflight, + "workspace_gc_eligible_bytes": eligible, + "workspace_gc_backlog_bytes": backlog, + "workspace_unclassified_bytes": unclassified, + "cursor_bytes": cursor, + "logs_bytes": logs, + } + + def collect(self) -> Sample: + daemon = self.daemon_status() + external_free: Optional[int] + try: + external_free = self.free_bytes(str(self.config["external_path"])) + except OSError: + external_free = None + pid = daemon.get("pid") + owners_value = daemon.get("admission_pause_owners") + owners = tuple(str(value) for value in owners_value) if isinstance(owners_value, list) else None + return Sample( + recorded_at=datetime.now(timezone.utc).isoformat(), + internal_free_bytes=self.free_bytes(str(self.config["internal_path"])), + external_free_bytes=external_free, + swap_used_bytes=self.swap_used_bytes(), + active_task_count=int(daemon.get("active_task_count") or 0), + daemon_pid=int(pid) if pid else None, + daemon_status=str(daemon.get("status") or "unknown"), + admission_pause_owners=owners, + ) + + def enrich(self, sample: Sample) -> Sample: + cache_path = Path( + str( + self.config.get("growth_cache_path") + or Path(str(self.config["state_path"])).with_name("growth-metrics-cache.json") + ) + ) + interval = float(self.config.get("growth_scan_interval_seconds", 900)) + now = datetime.now(timezone.utc) + cached = load_json(cache_path, {}) + try: + cached_age = (now - parse_timestamp(str(cached["recorded_at"]))).total_seconds() + cached_values = cached["values"] + except (KeyError, TypeError, ValueError): + cached_age = float("inf") + cached_values = {} + if cached_age < interval: + return replace(sample, **cached_values) + values = self.growth_metrics() + if any(value is not None for value in values.values()): + atomic_write_json(cache_path, {"recorded_at": now.isoformat(), "values": values}) + return replace(sample, **values) + if cached_age < interval * 2: + return replace(sample, **cached_values) + return replace(sample, **values) + + +def classify_level( + free_bytes: int, + *, + previous: int, + level1: int, + level1_clear: int, + level2: int, + level2_clear: int, +) -> int: + if free_bytes <= level2: + return 2 + if previous >= 2 and free_bytes < level2_clear: + return 2 + if free_bytes <= level1: + return 1 + if previous >= 1 and free_bytes < level1_clear: + return 1 + return 0 + + +def atomic_write_json(path: Path, value: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temp_name = tempfile.mkstemp(prefix=".%s." % path.name, dir=str(path.parent)) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(value, handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_name, path) + finally: + try: + os.unlink(temp_name) + except FileNotFoundError: + pass + + +def append_jsonl(path: Path, value: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(value, ensure_ascii=False, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + + +def parse_timestamp(raw: str) -> datetime: + return datetime.fromisoformat(raw.replace("Z", "+00:00")) + + +def build_capacity_report( + samples: Iterable[Dict[str, Any]], + *, + safety_floor_bytes: int, + burst_reserve_bytes: int, + minimum_hours: float, + expected_interval_seconds: float = 3600, + maximum_window_hours: float = 72, + minimum_coverage: float = 0.8, + required_growth_fields: Iterable[str] = (), + required_field_max_gap_seconds: float = 1800, + discarded_sample_count: int = 0, + external_safety_floor_bytes: int = 100 * GIB, +) -> Dict[str, Any]: + ordered_all = sorted(samples, key=lambda item: parse_timestamp(str(item["recorded_at"]))) + if ordered_all: + cutoff = parse_timestamp(str(ordered_all[-1]["recorded_at"])) - timedelta(hours=maximum_window_hours) + ordered = [item for item in ordered_all if parse_timestamp(str(item["recorded_at"])) >= cutoff] + else: + ordered = [] + report: Dict[str, Any] = { + "schema": "multica.storage-capacity.v1", + "status": "INCONCLUSIVE", + "sample_count": len(ordered), + "discarded_sample_count": discarded_sample_count, + "observation_hours": 0.0, + "coverage_ratio": 0.0, + "maximum_gap_seconds": None, + "p95_growth_bytes_per_hour": None, + "peak_growth_bytes_per_hour": None, + "category_p95_growth_bytes_per_hour": {}, + "field_coverage_ratio": {}, + "field_maximum_gap_seconds": {}, + "safety_floor_bytes": safety_floor_bytes, + "burst_reserve_bytes": burst_reserve_bytes, + "days_remaining": None, + "external_safety_floor_bytes": external_safety_floor_bytes, + "external_days_remaining": None, + } + if len(ordered) < 2: + return report + start = parse_timestamp(str(ordered[0]["recorded_at"])) + end = parse_timestamp(str(ordered[-1]["recorded_at"])) + observation_hours = max(0.0, (end - start).total_seconds() / 3600) + report["observation_hours"] = observation_hours + gaps = [ + (parse_timestamp(str(current["recorded_at"])) - parse_timestamp(str(previous["recorded_at"]))).total_seconds() + for previous, current in zip(ordered, ordered[1:]) + ] + maximum_gap = max(gaps) if gaps else None + coverage = min(1.0, ((len(ordered) - 1) * expected_interval_seconds) / max(1.0, (end - start).total_seconds())) + report["coverage_ratio"] = coverage + report["maximum_gap_seconds"] = maximum_gap + minimum_samples = math.ceil(minimum_hours * 3600 / expected_interval_seconds * minimum_coverage) + 1 + required_fields = list(required_growth_fields) + fields_ready = True + for field in required_fields: + available = [item for item in ordered if item.get(field) is not None] + coverage_value = len(available) / len(ordered) + report["field_coverage_ratio"][field] = coverage_value + field_times = [parse_timestamp(str(item["recorded_at"])) for item in available] + field_gaps = [(current - previous).total_seconds() for previous, current in zip(field_times, field_times[1:])] + field_maximum_gap = max(field_gaps) if field_gaps else None + report["field_maximum_gap_seconds"][field] = field_maximum_gap + latest_age = (end - field_times[-1]).total_seconds() if field_times else float("inf") + if ( + coverage_value < minimum_coverage + or field_maximum_gap is None + or field_maximum_gap > required_field_max_gap_seconds + or latest_age > required_field_max_gap_seconds + ): + fields_ready = False + if ( + observation_hours < minimum_hours + or len(ordered) < minimum_samples + or coverage < minimum_coverage + or maximum_gap is None + or maximum_gap > expected_interval_seconds * 3 + or not fields_ready + ): + return report + + hourly_growth: Dict[str, float] = {} + hourly_categories: Dict[str, Dict[str, float]] = {field: {} for field in required_fields} + for previous, current in zip(ordered, ordered[1:]): + previous_at = parse_timestamp(str(previous["recorded_at"])) + current_at = parse_timestamp(str(current["recorded_at"])) + elapsed_seconds = (current_at - previous_at).total_seconds() + if elapsed_seconds <= 0 or elapsed_seconds > expected_interval_seconds * 3: + continue + bucket = current_at.replace(minute=0, second=0, microsecond=0).isoformat() + delta = int(previous["internal_free_bytes"]) - int(current["internal_free_bytes"]) + hourly_growth[bucket] = hourly_growth.get(bucket, 0.0) + delta + for field in required_fields: + if previous.get(field) is None or current.get(field) is None: + continue + if field.endswith("_free_bytes"): + category_delta = int(previous[field]) - int(current[field]) + else: + category_delta = int(current[field]) - int(previous[field]) + values = hourly_categories[field] + values[bucket] = values.get(bucket, 0.0) + category_delta + growth_rates = [max(0.0, value) for value in hourly_growth.values()] + if not growth_rates: + return report + + sorted_rates = sorted(growth_rates) + p95 = sorted_rates[max(0, math.ceil(0.95 * len(sorted_rates)) - 1)] + report["status"] = "READY" + report["p95_growth_bytes_per_hour"] = p95 + report["peak_growth_bytes_per_hour"] = max(growth_rates) + for field, buckets in hourly_categories.items(): + values = sorted(max(0.0, value) for value in buckets.values()) + report["category_p95_growth_bytes_per_hour"][field] = ( + values[max(0, math.ceil(0.95 * len(values)) - 1)] if values else None + ) + latest_free = int(ordered[-1]["internal_free_bytes"]) + budget = max(0, latest_free - safety_floor_bytes - burst_reserve_bytes) + report["days_remaining"] = (budget / p95 / 24) if p95 > 0 else None + external_growth = report["category_p95_growth_bytes_per_hour"].get("external_free_bytes") + if external_growth is not None and ordered[-1].get("external_free_bytes") is not None: + external_budget = max(0, int(ordered[-1]["external_free_bytes"]) - external_safety_floor_bytes) + report["external_days_remaining"] = ( + external_budget / float(external_growth) / 24 if float(external_growth) > 0 else None + ) + return report + + +def load_json(path: Path, default: Dict[str, Any]) -> Dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return dict(default) + return value if isinstance(value, dict) else dict(default) + + +def read_jsonl_with_errors(path: Path) -> Tuple[List[Dict[str, Any]], int]: + values: List[Dict[str, Any]] = [] + discarded = 0 + try: + lines = path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError: + return values, discarded + for line in lines: + try: + value = json.loads(line) + except json.JSONDecodeError: + discarded += 1 + continue + if isinstance(value, dict) and "recorded_at" in value and "internal_free_bytes" in value: + values.append(value) + else: + discarded += 1 + return values, discarded + + +def read_jsonl(path: Path) -> List[Dict[str, Any]]: + values, _ = read_jsonl_with_errors(path) + return values + + +def validate_config(config: Dict[str, Any]) -> None: + required = [ + "internal_path", + "external_path", + "level1_free_gib", + "level1_clear_gib", + "level2_free_gib", + "level2_clear_gib", + "state_path", + "metrics_path", + "capacity_report_path", + ] + missing = [key for key in required if key not in config] + if missing: + raise ValueError("missing config keys: " + ", ".join(missing)) + level1 = float(config["level1_free_gib"]) + level1_clear = float(config["level1_clear_gib"]) + level2 = float(config["level2_free_gib"]) + level2_clear = float(config["level2_clear_gib"]) + if not (0 < level2 < level2_clear <= level1 < level1_clear): + raise ValueError("watermarks must satisfy level2 < level2_clear <= level1 < level1_clear") + + +def has_message_id(value: Any) -> bool: + if isinstance(value, dict): + if value.get("message_id"): + return True + return any(has_message_id(child) for child in value.values()) + if isinstance(value, list): + return any(has_message_id(child) for child in value) + return False + + +class Guard: + def __init__(self, config: Dict[str, Any], runner: CommandRunner, collector: Any): + validate_config(config) + self.config = config + self.runner = runner + self.collector = collector + self.uid = int(config.get("uid", os.getuid())) + + def stop_launchagent(self, label: str, actions: List[str]) -> bool: + target = "gui/%d/%s" % (self.uid, label) + disable_code, _, disable_error = self.runner.run(["launchctl", "disable", target], tolerate_failure=True) + bootout_code, _, bootout_error = self.runner.run(["launchctl", "bootout", target], tolerate_failure=True) + print_code, _, print_error = self.runner.run(["launchctl", "print", target], tolerate_failure=True) + print_not_found = print_code != 0 and any( + marker in (print_error or "").lower() + for marker in ("not found", "could not find service", "no such process") + ) + if disable_code == 0 and print_not_found: + actions.append("launchagent_stopped:" + label) + return True + detail = (print_error or bootout_error or disable_error).strip()[:120] + actions.append("launchagent_stop_failed:%s:%s" % (label, detail)) + return False + + def pause_admission(self, sample: Sample, state: Dict[str, Any], actions: List[str]) -> bool: + code, stdout, stderr = self.runner.run( + ["multica", "daemon", "pause", "--owner", "storage-guard", "--output", "json"], + tolerate_failure=True, + ) + try: + response = json.loads(stdout) + except json.JSONDecodeError: + response = {} + if code == 0 and response.get("owner_paused") is True and response.get("admission_paused") is True: + state["legacy_daemon_sigstopped"] = False + state["legacy_daemon_pid"] = None + state["legacy_daemon_command"] = None + state["admission_pause_owned"] = True + actions.append("daemon_admission_paused") + return True + actions.append("daemon_admission_pause_failed:" + stderr.strip()[:200]) + actions.append("legacy_sigstop_disabled:unsafe_without_shared_claim_barrier") + return False + + def resume_admission(self, state: Dict[str, Any], actions: List[str]) -> bool: + released_legacy_fallback = False + if (state.get("legacy_daemon_sigstopped") or state.get("legacy_daemon_sigstop_intent")) and state.get("legacy_daemon_pid"): + pid = int(state["legacy_daemon_pid"]) + ps_code, command, _ = self.runner.run( + ["/bin/ps", "-p", str(pid), "-o", "command="], tolerate_failure=True + ) + expected = str(state.get("legacy_daemon_command") or "") + if ps_code != 0 or not expected or command.strip() != expected: + actions.append("legacy_daemon_sigcontinue_failed:pid_identity_mismatch") + state["resume_pending"] = True + return False + self.runner.signal(pid, signal.SIGCONT) + state["legacy_daemon_sigstopped"] = False + state["legacy_daemon_sigstop_intent"] = False + state["legacy_daemon_pid"] = None + state["legacy_daemon_command"] = None + atomic_write_json(Path(str(self.config["state_path"])), state) + actions.append("legacy_daemon_sigcontinued") + released_legacy_fallback = True + if released_legacy_fallback and not state.get("admission_pause_owned"): + state["resume_pending"] = False + return True + code, stdout, stderr = self.runner.run( + ["multica", "daemon", "resume", "--owner", "storage-guard", "--output", "json"], + tolerate_failure=True, + ) + try: + response = json.loads(stdout) + except json.JSONDecodeError: + response = {} + if code != 0 or response.get("owner_paused") is not False: + state["resume_pending"] = True + actions.append("daemon_admission_resume_failed:" + stderr.strip()[:200]) + return False + state["admission_pause_owned"] = False + state["resume_pending"] = False + actions.append("daemon_admission_resumed") + return True + + def alert( + self, + sample: Sample, + level: int, + state: Dict[str, Any], + actions: List[str], + reason: str, + *, + alert_key: str, + ) -> None: + open_id = str(self.config.get("lark_open_id") or "").strip() + if not open_id: + actions.append("lark_alert_skipped:no_recipient") + return + now = parse_timestamp(sample.recorded_at) + alert_times = state.setdefault("last_alert_at_by_key", {}) + previous_raw = alert_times.get(alert_key) + if previous_raw: + previous = parse_timestamp(str(previous_raw)) + cooldown = float(self.config.get("alert_cooldown_seconds", 3600)) + if (now - previous).total_seconds() < cooldown: + actions.append("lark_alert_suppressed:cooldown") + return + text = ( + "[主机储存熔断 L%d] %s;内置盘可用 %.2f GiB,swap %.2f GiB,活跃任务 %d。" + % ( + level, + reason, + sample.internal_free_bytes / GIB, + sample.swap_used_bytes / GIB, + sample.active_task_count, + ) + ) + code, stdout, _ = self.runner.run( + [ + "lark-cli", + "im", + "+messages-send", + "--as", + "bot", + "--user-id", + open_id, + "--text", + text, + "--format", + "json", + ], + tolerate_failure=True, + ) + try: + response = json.loads(stdout) + except json.JSONDecodeError: + response = {} + if code == 0 and has_message_id(response): + alert_times[alert_key] = sample.recorded_at + actions.append("lark_alert_sent") + else: + actions.append("lark_alert_failed") + + def run_once(self) -> Dict[str, Any]: + sample = self.collector.collect() + state_path = Path(str(self.config["state_path"])) + metrics_path = Path(str(self.config["metrics_path"])) + report_path = Path(str(self.config["capacity_report_path"])) + state = load_json( + state_path, + { + "level": 0, + "legacy_daemon_sigstopped": False, + "legacy_daemon_sigstop_intent": False, + "legacy_daemon_pid": None, + "admission_pause_owned": False, + "resume_pending": False, + }, + ) + if sample.admission_pause_owners is not None: + state["admission_pause_owned"] = "storage-guard" in sample.admission_pause_owners + previous = int(state.get("level") or 0) + level = classify_level( + sample.internal_free_bytes, + previous=previous, + level1=int(float(self.config["level1_free_gib"]) * GIB), + level1_clear=int(float(self.config["level1_clear_gib"]) * GIB), + level2=int(float(self.config["level2_free_gib"]) * GIB), + level2_clear=int(float(self.config["level2_clear_gib"]) * GIB), + ) + actions: List[str] = [] + enforcement_ok = True + + if level >= 1: + for label in self.config.get("observer_labels", []): + enforcement_ok = self.stop_launchagent(str(label), actions) and enforcement_ok + enforcement_ok = self.pause_admission(sample, state, actions) and enforcement_ok + if not enforcement_ok: + self.alert( + sample, + level, + state, + actions, + "一级低水位执行失败,任务入场可能仍开放", + alert_key="level1-enforcement-failed", + ) + elif previous >= 1 or state.get("legacy_daemon_sigstopped") or state.get("legacy_daemon_sigstop_intent") or state.get("resume_pending") or state.get("admission_pause_owned"): + enforcement_ok = self.resume_admission(state, actions) + if not enforcement_ok: + level = max(previous, 1) + self.alert( + sample, + level, + state, + actions, + "低水位恢复失败,将持续重试 storage-guard 自有入场屏障", + alert_key="level1-resume-failed", + ) + + if level >= 2: + level2_enforcement_ok = True + for label in self.config.get("nonproduction_launchagents", []): + level2_enforcement_ok = self.stop_launchagent(str(label), actions) and level2_enforcement_ok + enforcement_ok = level2_enforcement_ok and enforcement_ok + self.alert( + sample, + level, + state, + actions, + ( + "内置盘进入二级低水位,已暂停显式列出的非生产任务" + if level2_enforcement_ok + else "内置盘进入二级低水位,但非生产任务暂停未完全生效" + ), + alert_key="level2-internal-low-water", + ) + + external_min = int(float(self.config.get("external_min_free_gib", 100)) * GIB) + if sample.external_free_bytes is None: + self.alert( + sample, + max(level, 1), + state, + actions, + "外置归档卷不可用", + alert_key="external-unavailable", + ) + elif sample.external_free_bytes < external_min: + self.alert( + sample, + max(level, 1), + state, + actions, + "外置归档卷低于配置水位 (%.2f GiB)" % (sample.external_free_bytes / GIB), + alert_key="external-low-water", + ) + + if hasattr(self.collector, "enrich"): + sample = self.collector.enrich(sample) + state.update( + { + "level": level, + "previous_level": previous, + "last_sample": asdict(sample), + "last_actions": actions, + "enforcement_status": "verified" if enforcement_ok else "failed", + } + ) + append_jsonl(metrics_path, asdict(sample)) + samples, discarded = read_jsonl_with_errors(metrics_path) + report = build_capacity_report( + samples, + safety_floor_bytes=int(float(self.config.get("safety_floor_gib", 25)) * GIB), + burst_reserve_bytes=int(float(self.config.get("burst_reserve_gib", 10)) * GIB), + minimum_hours=float(self.config.get("minimum_observation_hours", 48)), + expected_interval_seconds=float(self.config.get("expected_interval_seconds", 60)), + maximum_window_hours=float(self.config.get("maximum_observation_hours", 72)), + minimum_coverage=float(self.config.get("minimum_sample_coverage", 0.8)), + required_growth_fields=self.config.get("required_growth_fields", []), + required_field_max_gap_seconds=float(self.config.get("required_field_max_gap_seconds", 1800)), + discarded_sample_count=discarded, + external_safety_floor_bytes=int(float(self.config.get("external_min_free_gib", 100)) * GIB), + ) + atomic_write_json(report_path, report) + atomic_write_json(state_path, state) + return {"level": level, "previous_level": previous, "sample": asdict(sample), "actions": actions, "capacity": report} + + +def main() -> int: + parser = argparse.ArgumentParser(description="Sample and protect a Multica runtime host") + parser.add_argument("--config", required=True, type=Path) + args = parser.parse_args() + config = json.loads(args.config.read_text(encoding="utf-8")) + runner = CommandRunner() + result = Guard(config, runner, SystemCollector(config, runner)).run_once() + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/storage-governance/test_retention_cron_bridge.py b/scripts/storage-governance/test_retention_cron_bridge.py new file mode 100644 index 00000000000..529a009789b --- /dev/null +++ b/scripts/storage-governance/test_retention_cron_bridge.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import fcntl +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from retention_cron_bridge import ( # noqa: E402 + SingleInstanceLock, + launchctl_kickstart_command, + receipt_exit_code, + record_lock_collision, +) + + +class CronBridgeTest(unittest.TestCase): + def test_kickstart_does_not_force_kill_an_existing_retention_run(self) -> None: + command = launchctl_kickstart_command("com.multica.storage-retention", uid=501) + self.assertEqual( + command, + ["/bin/launchctl", "kickstart", "gui/501/com.multica.storage-retention"], + ) + self.assertNotIn("-k", command) + + def test_bridge_single_instance_lock_is_nonblocking(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "bridge.lock" + with SingleInstanceLock(path): + descriptor = path.open("a+") + try: + with self.assertRaises(BlockingIOError): + fcntl.flock(descriptor.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + finally: + descriptor.close() + + def test_running_receipt_keeps_bridge_waiting(self) -> None: + self.assertIsNone(receipt_exit_code({"token": "t", "status": "running"}, "t")) + self.assertEqual(receipt_exit_code({"token": "t", "status": "green"}, "t"), 0) + self.assertEqual(receipt_exit_code({"token": "t", "status": "red"}, "t"), 1) + + def test_lock_collision_writes_machine_readable_alert(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "alerts.jsonl" + record_lock_collision(path) + self.assertIn("previous bridge is still running", path.read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/storage-governance/test_retention_worker.py b/scripts/storage-governance/test_retention_worker.py new file mode 100644 index 00000000000..41366ac21cb --- /dev/null +++ b/scripts/storage-governance/test_retention_worker.py @@ -0,0 +1,675 @@ +from __future__ import annotations + +import fcntl +import io +import json +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parent)) + +from retention_worker import ( # noqa: E402 + ArchiveError, + ArchiveManager, + Canary, + ExternalVolumeGuard, + GCEvaluator, + SingleInstanceLock, + _latest_mtime_size_and_unsafe_symlinks, + audit_electron_updaters, + consumed_approval_tokens, + main, + maybe_run_electron_updater_audit, + run_worker, + send_alert, + tree_manifest, + verify_cron_bridge, + write_cron_bridge_receipt, +) + + +class FakeIssueClient: + def __init__(self, issue: dict, runs: list[dict], children: list[dict] | None = None): + self.issue = issue + self.runs = runs + self.children = children or [] + + def get_issue(self, issue_id: str) -> dict: + return dict(self.issue) + + def get_runs(self, issue_id: str) -> list[dict]: + return list(self.runs) + + def get_children(self, issue_id: str) -> list[dict]: + return list(self.children) + + +def make_tree(root: Path) -> None: + (root / "nested" / "deeper").mkdir(parents=True) + (root / "root.txt").write_text("root payload\n", encoding="utf-8") + (root / "nested" / "data.bin").write_bytes(bytes(range(64))) + (root / "nested" / "deeper" / "empty").write_bytes(b"") + + +class CanaryTest(unittest.TestCase): + def test_uuid_is_checked_before_writing_representative_tree(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + destination = Path(tmp) + canary = Canary( + destination, + expected_uuid="expected", + min_free_bytes=1, + uuid_reader=lambda _: "wrong", + free_bytes_reader=lambda _: 10_000, + ) + with self.assertRaises(ArchiveError): + canary.run() + self.assertFalse((destination / ".multica-storage-canary").exists()) + + def test_external_low_water_is_red_before_writing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + destination = Path(tmp) + with self.assertRaises(ArchiveError): + Canary( + destination, + expected_uuid="volume-uuid", + min_free_bytes=100, + uuid_reader=lambda _: "volume-uuid", + free_bytes_reader=lambda _: 99, + ).run() + self.assertFalse((destination / ".multica-storage-canary").exists()) + + def test_writes_and_verifies_nested_tree_with_counts_bytes_and_hashes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + destination = Path(tmp) + result = Canary( + destination, + expected_uuid="volume-uuid", + min_free_bytes=1, + uuid_reader=lambda _: "volume-uuid", + free_bytes_reader=lambda _: 10_000, + ).run() + self.assertEqual(result["status"], "green") + self.assertGreaterEqual(result["manifest"]["file_count"], 3) + self.assertGreater(result["manifest"]["total_bytes"], 0) + self.assertGreaterEqual(len(result["manifest"]["sample_hashes"]), 2) + self.assertTrue(Path(result["path"], "nested", "deeper", "empty").is_file()) + + def test_postflight_volume_rebind_failure_prevents_green_marker(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + destination = Path(tmp) + + def rebound() -> dict: + raise ArchiveError("archive volume UUID changed before commit") + + with self.assertRaisesRegex(ArchiveError, "UUID changed"): + Canary( + destination, + expected_uuid="volume-uuid", + min_free_bytes=1, + uuid_reader=lambda _: "volume-uuid", + free_bytes_reader=lambda _: 10_000, + postflight=rebound, + ).run() + self.assertEqual(list(destination.glob("**/CANARY.json")), []) + + +class ArchiveTransactionTest(unittest.TestCase): + def test_manifest_hashes_every_file_not_only_samples(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for index in range(17): + (root / ("f%02d.bin" % index)).write_bytes(bytes([index]) * 32) + before = tree_manifest(root) + target = root / "f08.bin" + stat = target.stat() + target.write_bytes(b"x" * 32) + import os + + os.utime(target, ns=(stat.st_atime_ns, stat.st_mtime_ns)) + after = tree_manifest(root) + self.assertEqual(before["files"], after["files"]) + self.assertNotEqual(before["content_hashes"], after["content_hashes"]) + + def test_source_delete_is_refused_without_a_shared_producer_lease(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + destination = root / "archive" + source.mkdir() + make_tree(source) + + with self.assertRaisesRegex(ArchiveError, "producer lease"): + ArchiveManager(destination).archive(source, "candidate", delete_source=True) + self.assertTrue(source.exists()) + + def test_every_injected_failure_preserves_source(self) -> None: + for phase in ("after_copy", "after_verify", "after_rename", "after_complete"): + with self.subTest(phase=phase), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + source.mkdir() + make_tree(source) + with self.assertRaises(ArchiveError): + ArchiveManager(root / "archive", fail_at=phase).archive( + source, "candidate", delete_source=False + ) + self.assertTrue(source.exists()) + self.assertEqual(tree_manifest(source)["file_count"], 3) + + def test_committed_archive_is_rehashed_after_complete_marker(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + source.mkdir() + make_tree(source) + + def corrupt_committed(final: Path) -> None: + (final / "root.txt").write_text("corrupt\n", encoding="utf-8") + + with self.assertRaisesRegex(ArchiveError, "committed archive changed"): + ArchiveManager(root / "archive").archive( + source, + "candidate", + delete_source=False, + post_commit_hook=corrupt_committed, + ) + self.assertTrue(source.exists()) + self.assertEqual(list((root / "archive").glob("*/COMPLETE.json")), []) + self.assertEqual(consumed_approval_tokens(root / "archive"), set()) + + def test_single_instance_lock_is_nonblocking(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + lock_path = Path(tmp) / "worker.lock" + with SingleInstanceLock(lock_path): + descriptor = lock_path.open("a+") + try: + with self.assertRaises(BlockingIOError): + fcntl.flock(descriptor.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + finally: + descriptor.close() + + +class GCGateTest(unittest.TestCase): + def make_candidate(self, root: Path, *, completed: datetime) -> tuple[Path, str, str]: + workspace_id = "workspace-uuid" + issue_id = "issue-uuid" + task_id = "abcdef12-0000-0000-0000-000000000000" + candidate = root / workspace_id / task_id[:8] + (candidate / "workdir" / ".multica").mkdir(parents=True) + (candidate / ".gc_meta.json").write_text( + json.dumps( + { + "kind": "issue", + "issue_id": issue_id, + "workspace_id": workspace_id, + "completed_at": completed.isoformat(), + } + ), + encoding="utf-8", + ) + (candidate / "workdir" / ".multica" / "daemon_task_context.json").write_text( + json.dumps({"managed_by": "multica-daemon-task", "issue_id": issue_id}), + encoding="utf-8", + ) + (candidate / "payload.txt").write_text("payload", encoding="utf-8") + old = completed.timestamp() + for path in sorted(candidate.rglob("*"), reverse=True): + path.touch() if path.is_file() else None + if path.exists(): + import os + + os.utime(path, (old, old), follow_symlinks=False) + import os + + os.utime(candidate, (old, old)) + return candidate, issue_id, task_id + + def test_all_gates_must_pass_for_dry_run_candidate(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + now = datetime(2026, 8, 10, tzinfo=timezone.utc) + completed = now - timedelta(days=8) + candidate, issue_id, task_id = self.make_candidate(Path(tmp), completed=completed) + client = FakeIssueClient( + {"id": issue_id, "workspace_id": "workspace-uuid", "status": "done", "metadata": {}}, + [ + { + "id": task_id, + "issue_id": issue_id, + "workspace_id": "workspace-uuid", + "status": "completed", + "completed_at": completed.isoformat(), + "work_dir": str(candidate / "workdir"), + } + ], + [{"status": "done"}], + ) + result = GCEvaluator( + client, + now=lambda: now, + open_file_checker=lambda _: False, + retention_seconds=7 * 86400, + recent_write_seconds=3600, + ).evaluate(candidate) + self.assertTrue(result["eligible"]) + self.assertEqual(result["reasons"], []) + self.assertEqual(result["run_id"], task_id) + self.assertEqual(len(result["approval_token"]), 64) + + client.runs.append({"id": "other", "status": "dispatched", "work_dir": "/other"}) + rejected = GCEvaluator( + client, + now=lambda: now, + open_file_checker=lambda _: False, + retention_seconds=7 * 86400, + recent_write_seconds=3600, + ).evaluate(candidate) + self.assertFalse(rejected["eligible"]) + self.assertIn("active run", " ".join(rejected["reasons"])) + + def test_nonterminal_pin_open_file_recent_write_and_identity_mismatch_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + now = datetime(2026, 8, 10, tzinfo=timezone.utc) + completed = now - timedelta(days=8) + candidate, issue_id, task_id = self.make_candidate(Path(tmp), completed=completed) + client = FakeIssueClient( + { + "id": issue_id, + "workspace_id": "different-workspace", + "status": "in_review", + "metadata": {"gc_pin": True}, + }, + [{"id": task_id, "status": "running", "work_dir": str(candidate / "wrong")}], + [{"status": "todo"}], + ) + (candidate / "recent.txt").write_text("new", encoding="utf-8") + import os + + os.utime(candidate / "recent.txt", (now.timestamp(), now.timestamp())) + os.symlink(str(Path(tmp).parent / "outside"), candidate / "outside-link") + result = GCEvaluator( + client, + now=lambda: now, + open_file_checker=lambda _: True, + retention_seconds=7 * 86400, + recent_write_seconds=3600, + ).evaluate(candidate) + self.assertFalse(result["eligible"]) + reasons = " ".join(result["reasons"]) + for expected in ( + "terminal", + "pin", + "child", + "open file", + "recent write", + "identity", + "active run", + "out-of-bound symlink", + ): + self.assertIn(expected, reasons) + + +class ExternalVolumeGuardTest(unittest.TestCase): + def test_binds_archive_root_device_uuid_and_candidate_budget(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + external = Path(tmp) / "external" + archive = external / "archive" + archive.mkdir(parents=True) + guard = ExternalVolumeGuard( + external, + archive, + expected_uuid="volume-uuid", + min_free_bytes=100, + uuid_reader=lambda _: "volume-uuid", + free_bytes_reader=lambda _: 1210, + ) + self.assertEqual(guard.check(100)["required_reserve_bytes"], 210) + + outside = Path(tmp) / "outside" + outside.mkdir() + with self.assertRaises(ArchiveError): + ExternalVolumeGuard( + external, + outside, + expected_uuid="volume-uuid", + min_free_bytes=1, + uuid_reader=lambda _: "volume-uuid", + free_bytes_reader=lambda _: 100, + ).check() + + def test_size_scan_counts_regular_file_payload(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "payload.bin").write_bytes(b"x" * 4096) + _, total_bytes, _ = _latest_mtime_size_and_unsafe_symlinks(root) + self.assertGreaterEqual(total_bytes, 4096) + + def test_consumed_approval_tokens_are_read_from_complete_markers(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) + completed = archive / "candidate-1" + completed.mkdir() + (completed / "COMPLETE.json").write_text( + json.dumps({"approval_token": "one-time-token"}), + encoding="utf-8", + ) + self.assertEqual(consumed_approval_tokens(archive), {"one-time-token"}) + + +class CronBridgeVerificationTest(unittest.TestCase): + def make_config(self, root: Path, token: str = "fresh-token") -> dict: + trigger = root / "trigger.json" + trigger.write_text( + json.dumps( + { + "schema": "multica.storage-cron-trigger.v1", + "token": token, + "created_at": datetime.now(timezone.utc).isoformat(), + "bridge_pid": 4321, + } + ), + encoding="utf-8", + ) + return { + "cron_bridge_trigger_path": str(trigger), + "cron_bridge_receipt_path": str(root / "receipt.json"), + } + + @mock.patch("retention_worker.process_ancestry") + @mock.patch("retention_worker.subprocess.run") + def test_accepts_fresh_token_with_live_cron_ancestry(self, run: mock.Mock, ancestry: mock.Mock) -> None: + with tempfile.TemporaryDirectory() as tmp, mock.patch.dict( + "os.environ", {"MULTICA_STORAGE_CRON_BRIDGE": "1"} + ): + config = self.make_config(Path(tmp)) + run.return_value = mock.Mock( + returncode=0, + stdout="/usr/bin/python3 /opt/multica/retention_cron_bridge.py --trigger ...\n", + ) + ancestry.return_value = [ + {"pid": 4321, "parent_pid": 123, "command": "python3"}, + {"pid": 123, "parent_pid": 1, "command": "/usr/sbin/cron"}, + ] + + trigger, lineage = verify_cron_bridge(config) + + self.assertEqual(trigger["token"], "fresh-token") + self.assertEqual(lineage[-1]["command"], "/usr/sbin/cron") + + @mock.patch("retention_worker.process_ancestry") + @mock.patch("retention_worker.subprocess.run") + def test_rejects_token_already_consumed_by_a_receipt(self, run: mock.Mock, ancestry: mock.Mock) -> None: + with tempfile.TemporaryDirectory() as tmp, mock.patch.dict( + "os.environ", {"MULTICA_STORAGE_CRON_BRIDGE": "1"} + ): + root = Path(tmp) + config = self.make_config(root, token="replayed-token") + Path(config["cron_bridge_receipt_path"]).write_text( + json.dumps({"token": "replayed-token", "status": "green"}), + encoding="utf-8", + ) + run.return_value = mock.Mock( + returncode=0, + stdout="/usr/bin/python3 /opt/multica/retention_cron_bridge.py --trigger ...\n", + ) + ancestry.return_value = [ + {"pid": 4321, "parent_pid": 123, "command": "python3"}, + {"pid": 123, "parent_pid": 1, "command": "/usr/sbin/cron"}, + ] + + with self.assertRaisesRegex(ArchiveError, "already consumed"): + verify_cron_bridge(config) + + def test_receipt_is_bound_to_verified_token_not_reread_trigger(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config = self.make_config(root, token="newer-trigger") + write_cron_bridge_receipt(config, token="verified-token", status="green") + receipt = json.loads(Path(config["cron_bridge_receipt_path"]).read_text()) + self.assertEqual(receipt["token"], "verified-token") + + +class AlertDeliveryTest(unittest.TestCase): + @mock.patch("retention_worker.subprocess.run") + def test_success_exit_without_message_id_is_recorded_as_delivery_failure(self, run: mock.Mock) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + run.return_value = mock.Mock(returncode=0, stdout="{}", stderr="") + config = { + "lark_open_id": "ou_test", + "alert_log_path": str(root / "alerts.jsonl"), + } + send_alert(config, "disk full") + alert = json.loads((root / "alerts.jsonl").read_text().splitlines()[-1]) + self.assertEqual(alert["message"], "lark alert delivery failed") + + +class ElectronUpdaterAuditTest(unittest.TestCase): + def make_config(self, root: Path) -> tuple[dict, Path]: + home = root / "home" + home.mkdir() + return ( + { + "electron_updater_audit": { + "enabled": True, + "timezone": "Asia/Shanghai", + "day_of_month": 15, + "home_path": str(home), + "report_dir": str(root / "reports"), + "patterns": [ + {"label": "electron_updater_cache", "glob": "Library/Caches/*-updater"}, + {"label": "pending_update_zip", "glob": "Library/Caches/*-updater/*.zip"}, + ], + "warn_total_gib": 5, + "warn_candidate_gib": 1, + "stale_days": 45, + "stale_min_mib": 100, + } + }, + home, + ) + + def test_discovers_deduplicates_and_does_not_modify_updater_residue(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config, home = self.make_config(root) + updater = home / "Library" / "Caches" / "demo-updater" + updater.mkdir(parents=True) + (updater / "update.zip").write_bytes(b"z" * 128) + (updater / "metadata.json").write_bytes(b"m" * 64) + before = { + path.relative_to(updater).as_posix(): (path.stat().st_size, path.stat().st_mtime_ns) + for path in updater.iterdir() + } + + report = audit_electron_updaters( + config, + now=lambda: datetime(2026, 8, 5, tzinfo=timezone.utc), + invocation_source="test", + ) + + self.assertEqual(report["schema"], "multica.st1-electron-updater-audit.v1") + self.assertEqual(report["audit_month"], "2026-08") + self.assertEqual(report["status"], "green") + self.assertEqual(report["candidate_count"], 1) + self.assertEqual(report["total_bytes"], 192) + self.assertEqual(report["candidates"][0]["file_count"], 2) + self.assertEqual( + report["candidates"][0]["matched_labels"], + ["electron_updater_cache", "pending_update_zip"], + ) + after = { + path.relative_to(updater).as_posix(): (path.stat().st_size, path.stat().st_mtime_ns) + for path in updater.iterdir() + } + self.assertEqual(after, before) + self.assertEqual(json.loads(Path(report["report_path"]).read_text()), report) + + def test_records_a_symlink_match_without_following_or_counting_its_target(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config, home = self.make_config(root) + cache = home / "Library" / "Caches" + cache.mkdir(parents=True) + outside = root / "outside" + outside.mkdir() + (outside / "payload.bin").write_bytes(b"x" * 4096) + (cache / "escape-updater").symlink_to(outside, target_is_directory=True) + + report = audit_electron_updaters( + config, + now=lambda: datetime(2026, 8, 5, tzinfo=timezone.utc), + invocation_source="test", + ) + + self.assertEqual(report["status"], "green") + self.assertEqual(report["candidate_count"], 0) + self.assertEqual(report["total_bytes"], 0) + self.assertEqual(report["errors"], []) + self.assertEqual(len(report["excluded_symlink_matches"]), 1) + self.assertEqual( + report["excluded_symlink_matches"][0]["path"], + str(cache.resolve() / "escape-updater"), + ) + self.assertIn("not traversed", report["excluded_symlink_matches"][0]["reason"]) + self.assertEqual((outside / "payload.bin").read_bytes(), b"x" * 4096) + + def test_rejects_unbounded_recursive_globs(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + config, _ = self.make_config(Path(tmp)) + config["electron_updater_audit"]["patterns"] = [ + {"label": "too_broad", "glob": "Library/**/update.zip"} + ] + + with self.assertRaisesRegex(ArchiveError, "recursive"): + audit_electron_updaters(config) + + def test_marks_large_or_stale_candidates_for_attention_without_cleanup(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config, home = self.make_config(root) + audit_config = config["electron_updater_audit"] + audit_config["warn_total_gib"] = 0.0000001 + audit_config["warn_candidate_gib"] = 0.0000001 + audit_config["stale_min_mib"] = 0.0001 + updater = home / "Library" / "Caches" / "old-updater" + updater.mkdir(parents=True) + payload = updater / "update.zip" + payload.write_bytes(b"x" * 512) + old = datetime(2026, 5, 1, tzinfo=timezone.utc).timestamp() + import os + + os.utime(payload, (old, old)) + os.utime(updater, (old, old)) + + report = audit_electron_updaters( + config, + now=lambda: datetime(2026, 8, 5, tzinfo=timezone.utc), + invocation_source="test", + ) + + self.assertEqual(report["status"], "attention") + reasons = " ".join(report["attention_reasons"]) + self.assertIn("total", reasons) + self.assertIn("candidate", reasons) + self.assertIn("stale", reasons) + self.assertTrue(payload.exists()) + + def test_month_gate_runs_once_and_force_bypasses_day_and_existing_evidence(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config, home = self.make_config(root) + updater = home / "Library" / "Caches" / "demo-updater" + updater.mkdir(parents=True) + (updater / "update.zip").write_bytes(b"z") + before_due = lambda: datetime(2026, 8, 5, tzinfo=timezone.utc) + on_due = lambda: datetime(2026, 8, 15, tzinfo=timezone.utc) + + skipped = maybe_run_electron_updater_audit(config, now=before_due) + self.assertEqual(skipped["status"], "skipped") + self.assertIn("before day", skipped["reason"]) + + forced = maybe_run_electron_updater_audit(config, now=before_due, force=True) + self.assertEqual(forced["status"], "green") + report_path = Path(forced["report_path"]) + self.assertTrue(report_path.is_file()) + + already_recorded = maybe_run_electron_updater_audit(config, now=on_due) + self.assertEqual(already_recorded["status"], "skipped") + self.assertIn("already exists", already_recorded["reason"]) + + rerun = maybe_run_electron_updater_audit(config, now=on_due, force=True) + self.assertEqual(rerun["status"], "green") + self.assertEqual(rerun["audit_month"], "2026-08") + + def test_formal_worker_audits_before_external_volume_checks(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + archive = root / "external" / "archive" + archive.mkdir(parents=True) + config = { + "require_cron_lineage": False, + "delete_source": False, + "external_path": str(root / "external"), + "archive_root": str(archive), + "external_volume_uuid": "volume-uuid", + "external_min_free_gib": 1, + "workspace_roots": [], + } + events: list[str] = [] + + def audit(*args: object, **kwargs: object) -> dict: + events.append("audit") + return {"status": "skipped", "reason": "test"} + + def external_check(*args: object, **kwargs: object) -> dict: + events.append("external") + raise ArchiveError("stop after ordering proof") + + with mock.patch( + "retention_worker.maybe_run_electron_updater_audit", side_effect=audit + ), mock.patch.object(ExternalVolumeGuard, "check", side_effect=external_check): + with self.assertRaisesRegex(ArchiveError, "ordering proof"): + run_worker(config) + + self.assertEqual(events, ["audit", "external"]) + + def test_audit_only_cli_bypasses_cron_lineage_and_external_volume(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config, home = self.make_config(root) + config.update( + { + "require_cron_lineage": True, + "lock_path": str(root / "worker.lock"), + "external_path": str(root / "missing-external"), + "archive_root": str(root / "missing-external" / "archive"), + } + ) + updater = home / "Library" / "Caches" / "demo-updater" + updater.mkdir(parents=True) + (updater / "update.zip").write_bytes(b"z") + config_path = root / "config.json" + config_path.write_text(json.dumps(config), encoding="utf-8") + output = io.StringIO() + + with mock.patch.object( + sys, + "argv", + ["retention_worker.py", "--config", str(config_path), "--electron-audit-only"], + ), redirect_stdout(output): + exit_code = main() + + self.assertEqual(exit_code, 0) + summary = json.loads(output.getvalue()) + self.assertEqual(summary["status"], "green") + self.assertTrue(Path(summary["report_path"]).is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/storage-governance/test_storage_guard.py b/scripts/storage-governance/test_storage_guard.py new file mode 100644 index 00000000000..4d6d2382ae4 --- /dev/null +++ b/scripts/storage-governance/test_storage_guard.py @@ -0,0 +1,458 @@ +from __future__ import annotations + +import json +import plistlib +import signal +import sys +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parent)) + +from storage_guard import ( # noqa: E402 + Guard, + Sample, + build_capacity_report, + classify_level, + SystemCollector, +) + + +GIB = 1024**3 + + +class LaunchdContractTest(unittest.TestCase): + def test_guard_ignores_ambient_python_environment(self) -> None: + plist_path = Path(__file__).with_name("com.multica.storage-guard.plist.example") + with plist_path.open("rb") as handle: + program_arguments = plistlib.load(handle)["ProgramArguments"] + + self.assertEqual(program_arguments[:2], ["/usr/bin/python3", "-E"]) + + +def base_config(root: Path) -> dict: + return { + "internal_path": "/", + "external_path": "/Volumes/MacMini-HotSSD", + "level1_free_gib": 25, + "level1_clear_gib": 28, + "level2_free_gib": 18, + "level2_clear_gib": 21, + "external_min_free_gib": 100, + "safety_floor_gib": 25, + "burst_reserve_gib": 10, + "minimum_observation_hours": 48, + "observer_labels": ["ai.multica.ws2512.m0-shadow-observer"], + "nonproduction_launchagents": ["com.example.nonproduction"], + "lark_open_id": "ou_test", + "alert_cooldown_seconds": 3600, + "legacy_sigstop_fallback": True, + "state_path": str(root / "state.json"), + "metrics_path": str(root / "metrics.jsonl"), + "capacity_report_path": str(root / "capacity.json"), + } + + +class FakeCollector: + def __init__(self, sample: Sample): + self.sample = sample + + def collect(self) -> Sample: + return self.sample + + +class FakeRunner: + def __init__( + self, + *, + admission_supported: bool = True, + resume_supported: bool = True, + launchagent_stops: bool = True, + ): + self.admission_supported = admission_supported + self.resume_supported = resume_supported + self.launchagent_stops = launchagent_stops + self.calls: list[tuple[str, ...]] = [] + self.signals: list[tuple[int, int]] = [] + + def run(self, argv: list[str], *, tolerate_failure: bool = False) -> tuple[int, str, str]: + self.calls.append(tuple(argv)) + if argv[:3] == ["multica", "daemon", "pause"]: + if not self.admission_supported: + return 1, "", "endpoint returned 404" + return 0, json.dumps({"owner_paused": True, "admission_paused": True}), "" + if argv[:3] == ["multica", "daemon", "resume"]: + if not self.resume_supported: + return 1, "", "endpoint unavailable" + return 0, json.dumps({"owner_paused": False, "admission_paused": False}), "" + if argv[:3] == ["multica", "daemon", "status"]: + return 0, json.dumps({"pid": 4321, "status": "running", "active_task_count": 2}), "" + if argv[:3] == ["/bin/ps", "-p", "4321"]: + return 0, "/opt/homebrew/bin/multica daemon start --foreground\n", "" + if argv[:2] == ["launchctl", "print"]: + return (1, "", "not found") if self.launchagent_stops else (0, "running", "") + if argv and argv[0] == "lark-cli": + return 0, json.dumps({"data": {"message_id": "om_test"}}), "" + return 0, "{}", "" + + def signal(self, pid: int, sig: int) -> None: + self.signals.append((pid, sig)) + + +class LevelClassificationTest(unittest.TestCase): + def test_hysteresis_prevents_flapping(self) -> None: + self.assertEqual(classify_level(30 * GIB, previous=0, level1=25 * GIB, level1_clear=28 * GIB, level2=18 * GIB, level2_clear=21 * GIB), 0) + self.assertEqual(classify_level(24 * GIB, previous=0, level1=25 * GIB, level1_clear=28 * GIB, level2=18 * GIB, level2_clear=21 * GIB), 1) + self.assertEqual(classify_level(26 * GIB, previous=1, level1=25 * GIB, level1_clear=28 * GIB, level2=18 * GIB, level2_clear=21 * GIB), 1) + self.assertEqual(classify_level(29 * GIB, previous=1, level1=25 * GIB, level1_clear=28 * GIB, level2=18 * GIB, level2_clear=21 * GIB), 0) + self.assertEqual(classify_level(17 * GIB, previous=1, level1=25 * GIB, level1_clear=28 * GIB, level2=18 * GIB, level2_clear=21 * GIB), 2) + self.assertEqual(classify_level(19 * GIB, previous=2, level1=25 * GIB, level1_clear=28 * GIB, level2=18 * GIB, level2_clear=21 * GIB), 2) + self.assertEqual(classify_level(22 * GIB, previous=2, level1=25 * GIB, level1_clear=28 * GIB, level2=18 * GIB, level2_clear=21 * GIB), 1) + + +class GuardActionTest(unittest.TestCase): + def make_sample( + self, + free_gib: int, + timestamp: datetime, + active_tasks: int = 2, + admission_pause_owners: tuple[str, ...] = (), + ) -> Sample: + return Sample( + recorded_at=timestamp.isoformat(), + internal_free_bytes=free_gib * GIB, + external_free_bytes=1500 * GIB, + swap_used_bytes=9 * GIB, + active_task_count=active_tasks, + daemon_pid=4321, + daemon_status="running", + admission_pause_owners=admission_pause_owners, + ) + + def test_level1_stops_observer_and_pauses_admission_without_killing_tasks(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + runner = FakeRunner() + now = datetime(2026, 8, 3, tzinfo=timezone.utc) + result = Guard(base_config(root), runner, FakeCollector(self.make_sample(24, now))).run_once() + + self.assertEqual(result["level"], 1) + self.assertIn(("launchctl", "disable", "gui/501/ai.multica.ws2512.m0-shadow-observer"), runner.calls) + self.assertIn(("launchctl", "bootout", "gui/501/ai.multica.ws2512.m0-shadow-observer"), runner.calls) + self.assertIn(("multica", "daemon", "pause", "--owner", "storage-guard", "--output", "json"), runner.calls) + self.assertEqual(runner.signals, []) + state = json.loads(Path(base_config(root)["state_path"]).read_text()) + self.assertEqual(state["level"], 1) + self.assertFalse(state["legacy_daemon_sigstopped"]) + + def test_old_daemon_refuses_sigstop_with_active_tasks(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + cfg = base_config(root) + now = datetime(2026, 8, 3, tzinfo=timezone.utc) + runner = FakeRunner(admission_supported=False) + Guard(cfg, runner, FakeCollector(self.make_sample(24, now))).run_once() + self.assertEqual(runner.signals, []) + self.assertTrue( + any( + "legacy_sigstop_disabled:unsafe" in action + for action in json.loads(Path(cfg["state_path"]).read_text())["last_actions"] + ) + ) + + def test_old_daemon_never_uses_sigstop_even_when_idle(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + cfg = base_config(root) + now = datetime(2026, 8, 3, tzinfo=timezone.utc) + runner = FakeRunner(admission_supported=False) + result = Guard(cfg, runner, FakeCollector(self.make_sample(24, now, active_tasks=0))).run_once() + self.assertEqual(runner.signals, []) + self.assertEqual(result["level"], 1) + self.assertEqual(json.loads(Path(cfg["state_path"]).read_text())["enforcement_status"], "failed") + + def test_persisted_sigstop_intent_is_reconciled_after_space_recovers(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + cfg = base_config(root) + Path(cfg["state_path"]).write_text( + json.dumps( + { + "level": 0, + "legacy_daemon_sigstop_intent": True, + "legacy_daemon_sigstopped": False, + "legacy_daemon_pid": 4321, + "legacy_daemon_command": "/opt/homebrew/bin/multica daemon start --foreground", + } + ), + encoding="utf-8", + ) + runner = FakeRunner(admission_supported=False) + result = Guard( + cfg, + runner, + FakeCollector(self.make_sample(30, datetime(2026, 8, 3, tzinfo=timezone.utc))), + ).run_once() + self.assertIn((4321, signal.SIGCONT), runner.signals) + self.assertEqual(result["level"], 0) + + def test_daemon_owner_reconciles_when_local_pause_state_was_not_committed(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + cfg = base_config(root) + runner = FakeRunner() + result = Guard( + cfg, + runner, + FakeCollector( + self.make_sample( + 30, + datetime(2026, 8, 3, tzinfo=timezone.utc), + admission_pause_owners=("storage-guard",), + ) + ), + ).run_once() + self.assertIn( + ("multica", "daemon", "resume", "--owner", "storage-guard", "--output", "json"), + runner.calls, + ) + self.assertEqual(result["level"], 0) + + def test_level2_launchagent_failure_is_not_reported_as_verified(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + cfg = base_config(root) + runner = FakeRunner(launchagent_stops=False) + Guard( + cfg, + runner, + FakeCollector(self.make_sample(17, datetime(2026, 8, 3, tzinfo=timezone.utc))), + ).run_once() + state = json.loads(Path(cfg["state_path"]).read_text()) + self.assertEqual(state["enforcement_status"], "failed") + self.assertTrue(any("launchagent_stop_failed" in action for action in state["last_actions"])) + + def test_resume_failure_remains_pending_and_retries(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + cfg = base_config(root) + now = datetime(2026, 8, 3, tzinfo=timezone.utc) + first = FakeRunner() + Guard(cfg, first, FakeCollector(self.make_sample(24, now))).run_once() + + failing = FakeRunner(resume_supported=False) + result = Guard(cfg, failing, FakeCollector(self.make_sample(30, now + timedelta(minutes=1)))).run_once() + self.assertEqual(result["level"], 1) + state = json.loads(Path(cfg["state_path"]).read_text()) + self.assertTrue(state["resume_pending"]) + self.assertEqual(state["enforcement_status"], "failed") + + def test_level2_pauses_explicit_nonproduction_jobs_and_sends_lark_alert(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + runner = FakeRunner() + now = datetime(2026, 8, 3, tzinfo=timezone.utc) + result = Guard(base_config(root), runner, FakeCollector(self.make_sample(17, now))).run_once() + + self.assertEqual(result["level"], 2) + self.assertIn(("launchctl", "disable", "gui/501/com.example.nonproduction"), runner.calls) + self.assertTrue(any(call and call[0] == "lark-cli" for call in runner.calls)) + + def test_every_run_appends_one_machine_readable_sample(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + cfg = base_config(root) + now = datetime(2026, 8, 3, tzinfo=timezone.utc) + Guard(cfg, FakeRunner(), FakeCollector(self.make_sample(30, now))).run_once() + lines = Path(cfg["metrics_path"]).read_text().splitlines() + self.assertEqual(len(lines), 1) + self.assertEqual(json.loads(lines[0])["internal_free_bytes"], 30 * GIB) + + +class CapacityReportTest(unittest.TestCase): + def test_report_is_inconclusive_before_48_hours(self) -> None: + start = datetime(2026, 8, 3, tzinfo=timezone.utc) + samples = [ + {"recorded_at": start.isoformat(), "internal_free_bytes": 40 * GIB}, + {"recorded_at": (start + timedelta(hours=24)).isoformat(), "internal_free_bytes": 36 * GIB}, + ] + report = build_capacity_report(samples, safety_floor_bytes=25 * GIB, burst_reserve_bytes=10 * GIB, minimum_hours=48) + self.assertEqual(report["status"], "INCONCLUSIVE") + self.assertIsNone(report["days_remaining"]) + + def test_two_samples_across_48_hours_do_not_fake_ready_coverage(self) -> None: + start = datetime(2026, 8, 3, tzinfo=timezone.utc) + report = build_capacity_report( + [ + {"recorded_at": start.isoformat(), "internal_free_bytes": 40 * GIB}, + {"recorded_at": (start + timedelta(hours=48)).isoformat(), "internal_free_bytes": 39 * GIB}, + ], + safety_floor_bytes=25 * GIB, + burst_reserve_bytes=10 * GIB, + minimum_hours=48, + expected_interval_seconds=3600, + ) + self.assertEqual(report["status"], "INCONCLUSIVE") + self.assertLess(report["coverage_ratio"], 0.1) + + def test_report_uses_p95_observed_hourly_growth_and_reserves(self) -> None: + start = datetime(2026, 8, 3, tzinfo=timezone.utc) + samples = [] + free = 80 * GIB + for hour in range(50): + samples.append({"recorded_at": (start + timedelta(hours=hour)).isoformat(), "internal_free_bytes": free}) + free -= 1 * GIB + report = build_capacity_report(samples, safety_floor_bytes=25 * GIB, burst_reserve_bytes=10 * GIB, minimum_hours=48) + self.assertEqual(report["status"], "READY") + self.assertAlmostEqual(report["p95_growth_bytes_per_hour"], float(GIB)) + self.assertAlmostEqual(report["peak_growth_bytes_per_hour"], float(GIB)) + self.assertEqual(report["days_remaining"], 0.0) + + def test_one_missing_category_sample_uses_field_coverage_instead_of_poisoning_window(self) -> None: + start = datetime(2026, 8, 3, tzinfo=timezone.utc) + samples = [] + for hour in range(50): + samples.append( + { + "recorded_at": (start + timedelta(hours=hour)).isoformat(), + "internal_free_bytes": (80 - hour) * GIB, + "cursor_bytes": None if hour == 25 else hour * GIB, + } + ) + report = build_capacity_report( + samples, + safety_floor_bytes=25 * GIB, + burst_reserve_bytes=10 * GIB, + minimum_hours=48, + expected_interval_seconds=3600, + required_growth_fields=["cursor_bytes"], + required_field_max_gap_seconds=3 * 3600, + ) + self.assertEqual(report["status"], "READY") + self.assertGreater(report["field_coverage_ratio"]["cursor_bytes"], 0.9) + + +class SystemCollectorTest(unittest.TestCase): + def test_growth_metrics_respect_shared_scan_budget(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + workspace = Path(tmp) / "workspaces" + workspace.mkdir() + (workspace / "payload.bin").write_bytes(b"payload") + collector = SystemCollector( + { + "growth_scan_budget_seconds": 0, + "workspace_roots": [str(workspace)], + "logs_paths": [], + }, + mock.Mock(), + ) + + self.assertIsNone(collector.growth_metrics()["workspace_total_bytes"]) + + def test_fast_collect_does_not_scan_growth_directories(self) -> None: + runner = mock.Mock() + runner.run.side_effect = [ + (0, json.dumps({"status": "running", "pid": 1, "active_task_count": 0}), ""), + (0, "vm.swapusage: total = 0.00M used = 0.00M free = 0.00M", ""), + ] + collector = SystemCollector({"internal_path": "/", "external_path": "/"}, runner) + with mock.patch.object(collector, "growth_metrics", side_effect=AssertionError("slow scan on fast path")): + sample = collector.collect() + self.assertEqual(sample.active_task_count, 0) + + def test_stale_green_retention_report_is_not_capacity_evidence(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + report = root / "report.json" + report.write_text( + json.dumps( + { + "status": "green", + "recorded_at": "2000-01-01T00:00:00+00:00", + "gc_candidates": [{"eligible": True, "details": {"size_bytes": 123}}], + } + ), + encoding="utf-8", + ) + collector = SystemCollector( + { + "retention_report_path": str(report), + "retention_report_max_age_seconds": 1800, + "workspace_roots": [], + "logs_paths": [], + }, + mock.Mock(), + ) + metrics = collector.growth_metrics() + self.assertIsNone(metrics["workspace_gc_eligible_bytes"]) + + def test_future_green_retention_report_is_not_capacity_evidence(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + report = root / "report.json" + report.write_text( + json.dumps( + { + "status": "green", + "recorded_at": "2099-01-01T00:00:00+00:00", + "gc_candidates": [{"eligible": True, "details": {"size_bytes": 123}}], + } + ), + encoding="utf-8", + ) + collector = SystemCollector( + { + "retention_report_path": str(report), + "retention_report_max_age_seconds": 1800, + "workspace_roots": [], + "logs_paths": [], + }, + mock.Mock(), + ) + self.assertIsNone(collector.growth_metrics()["workspace_gc_eligible_bytes"]) + + def test_workspace_categories_conserve_file_payload_bytes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / "workspaces" + workspace.mkdir() + (workspace / "payload.bin").write_bytes(b"x" * 100) + report = root / "report.json" + report.write_text( + json.dumps( + { + "status": "green", + "recorded_at": datetime.now(timezone.utc).isoformat(), + "gc_candidates": [{"eligible": True, "details": {"size_bytes": 40}}], + } + ), + encoding="utf-8", + ) + collector = SystemCollector( + { + "retention_report_path": str(report), + "retention_report_max_age_seconds": 1800, + "workspace_roots": [str(workspace)], + "logs_paths": [], + }, + mock.Mock(), + ) + metrics = collector.growth_metrics() + total = metrics["workspace_total_bytes"] + categories = sum( + int(metrics[key] or 0) + for key in ( + "workspace_gc_eligible_bytes", + "workspace_inflight_bytes", + "workspace_gc_backlog_bytes", + "workspace_unclassified_bytes", + ) + ) + self.assertEqual(categories, total) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/cmd/multica/cmd_daemon.go b/server/cmd/multica/cmd_daemon.go index 724b033f4ae..0f168573ab9 100644 --- a/server/cmd/multica/cmd_daemon.go +++ b/server/cmd/multica/cmd_daemon.go @@ -8,6 +8,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -50,6 +51,24 @@ var daemonStatusCmd = &cobra.Command{ RunE: runDaemonStatus, } +var daemonPauseCmd = &cobra.Command{ + Use: "pause", + Short: "Pause admission of new tasks without cancelling active tasks", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runDaemonAdmission(cmd, true) + }, +} + +var daemonResumeCmd = &cobra.Command{ + Use: "resume", + Short: "Resume admission of new tasks", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runDaemonAdmission(cmd, false) + }, +} + var daemonProbeRuntimesCmd = &cobra.Command{ Use: "probe-runtimes", Short: "Probe locally configured runtimes for the Desktop app", @@ -104,6 +123,10 @@ func init() { daemonLogsCmd.Flags().IntP("lines", "n", 50, "Number of lines to show") daemonStatusCmd.Flags().String("output", "table", "Output format: table or json") + daemonPauseCmd.Flags().String("output", "table", "Output format: table or json") + daemonResumeCmd.Flags().String("output", "table", "Output format: table or json") + daemonPauseCmd.Flags().String("owner", "manual", "Independent pause owner (for example: manual or storage-guard)") + daemonResumeCmd.Flags().String("owner", "manual", "Release only this pause owner") // restart shares all the same flags as start rf := daemonRestartCmd.Flags() @@ -132,6 +155,8 @@ func init() { daemonCmd.AddCommand(daemonStopCmd) daemonCmd.AddCommand(daemonRestartCmd) daemonCmd.AddCommand(daemonStatusCmd) + daemonCmd.AddCommand(daemonPauseCmd) + daemonCmd.AddCommand(daemonResumeCmd) daemonCmd.AddCommand(daemonProbeRuntimesCmd) daemonCmd.AddCommand(daemonLogsCmd) daemonCmd.AddCommand(daemonDiskUsageCmd) @@ -1055,6 +1080,67 @@ func requestDaemonShutdown(healthPort int) error { return nil } +type daemonAdmissionState struct { + AdmissionPaused bool `json:"admission_paused"` + AdmissionPauseOwners []string `json:"admission_pause_owners"` + Owner string `json:"owner"` + OwnerPaused bool `json:"owner_paused"` + ClaimsInFlight int `json:"claims_in_flight"` + ActiveTaskCount int64 `json:"active_task_count"` +} + +func requestDaemonAdmission(client *http.Client, baseURL string, paused bool, owner string) (daemonAdmissionState, error) { + action := "resume" + if paused { + action = "pause" + } + endpoint := strings.TrimRight(baseURL, "/") + "/admission/" + action + "?" + url.Values{"owner": {owner}}.Encode() + req, err := http.NewRequest(http.MethodPost, endpoint, nil) + if err != nil { + return daemonAdmissionState{}, err + } + resp, err := client.Do(req) + if err != nil { + return daemonAdmissionState{}, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return daemonAdmissionState{}, fmt.Errorf("daemon admission endpoint returned status %d", resp.StatusCode) + } + var state daemonAdmissionState + if err := json.NewDecoder(resp.Body).Decode(&state); err != nil { + return daemonAdmissionState{}, fmt.Errorf("decode daemon admission response: %w", err) + } + return state, nil +} + +func runDaemonAdmission(cmd *cobra.Command, paused bool) error { + profile := resolveProfile(cmd) + port := healthPortForProfile(profile) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if !daemonAlive(checkDaemonHealthOnPort(ctx, port)) { + return fmt.Errorf("daemon is not running") + } + + client := &http.Client{Timeout: 5 * time.Second} + owner, _ := cmd.Flags().GetString("owner") + state, err := requestDaemonAdmission(client, fmt.Sprintf("http://127.0.0.1:%d", port), paused, owner) + if err != nil { + return err + } + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(cmd.OutOrStdout(), state) + } + action := "resumed" + if paused { + action = "paused" + } + fmt.Fprintf(cmd.OutOrStdout(), "Daemon admission %s (active tasks: %d, claims draining: %d).\n", action, state.ActiveTaskCount, state.ClaimsInFlight) + return nil +} + // --- daemon status --- func runDaemonStatus(cmd *cobra.Command, _ []string) error { diff --git a/server/cmd/multica/cmd_daemon_test.go b/server/cmd/multica/cmd_daemon_test.go index 136cfc44834..10ee78712e2 100644 --- a/server/cmd/multica/cmd_daemon_test.go +++ b/server/cmd/multica/cmd_daemon_test.go @@ -69,6 +69,53 @@ func TestPrintDaemonStatusIncludesCLIVersion(t *testing.T) { } } +func TestRequestDaemonAdmissionUsesLocalPostAndDecodesState(t *testing.T) { + t.Parallel() + + var gotMethod, gotPath, gotOwner string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotOwner = r.URL.Query().Get("owner") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"admission_paused":true,"admission_pause_owners":["storage-guard"],"owner":"storage-guard","owner_paused":true,"claims_in_flight":0,"active_task_count":2}`)) + })) + defer srv.Close() + + state, err := requestDaemonAdmission(srv.Client(), srv.URL, true, "storage-guard") + if err != nil { + t.Fatalf("requestDaemonAdmission: %v", err) + } + if gotMethod != http.MethodPost || gotPath != "/admission/pause" { + t.Fatalf("request = %s %s, want POST /admission/pause", gotMethod, gotPath) + } + if gotOwner != "storage-guard" || !state.OwnerPaused { + t.Fatalf("owner = %q, state = %+v", gotOwner, state) + } + if !state.AdmissionPaused || state.ClaimsInFlight != 0 || state.ActiveTaskCount != 2 { + t.Fatalf("state = %+v, want paused with two active tasks and no draining claims", state) + } +} + +func TestRequestDaemonAdmissionAcceptsDrainPending(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"admission_paused":true,"owner":"manual","owner_paused":true,"claims_in_flight":1,"active_task_count":2}`)) + })) + defer srv.Close() + + state, err := requestDaemonAdmission(srv.Client(), srv.URL, true, "manual") + if err != nil { + t.Fatalf("requestDaemonAdmission: %v", err) + } + if !state.AdmissionPaused || state.ClaimsInFlight != 1 { + t.Fatalf("state = %+v, want paused with one draining claim", state) + } +} + func TestBuildDaemonStartArgsForwardsCodexHandshakeTimeout(t *testing.T) { cmd := &cobra.Command{} cmd.Flags().Duration("codex-handshake-timeout", 0, "") diff --git a/server/internal/daemon/admission.go b/server/internal/daemon/admission.go new file mode 100644 index 00000000000..3d9fb7ad615 --- /dev/null +++ b/server/internal/daemon/admission.go @@ -0,0 +1,127 @@ +package daemon + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" +) + +const defaultAdmissionOwner = "manual" + +var admissionOwnerPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) + +type admissionPauseDiskState struct { + Owners []string `json:"owners"` +} + +func normalizeAdmissionOwner(owner string) (string, error) { + if owner == "" { + owner = defaultAdmissionOwner + } + if !admissionOwnerPattern.MatchString(owner) { + return "", fmt.Errorf("invalid admission owner %q", owner) + } + return owner, nil +} + +func (d *Daemon) admissionStatePath() string { + if d.cfg.WorkspacesRoot == "" { + return "" + } + return filepath.Join(d.cfg.WorkspacesRoot, ".multica-admission-pauses.json") +} + +func cloneAdmissionOwners(source map[string]struct{}) map[string]struct{} { + result := make(map[string]struct{}, len(source)) + for owner := range source { + result[owner] = struct{}{} + } + return result +} + +func sortedAdmissionOwners(source map[string]struct{}) []string { + owners := make([]string, 0, len(source)) + for owner := range source { + owners = append(owners, owner) + } + sort.Strings(owners) + return owners +} + +func (d *Daemon) persistAdmissionOwnersLocked(next map[string]struct{}) error { + path := d.admissionStatePath() + if path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create admission state directory: %w", err) + } + payload, err := json.Marshal(admissionPauseDiskState{Owners: sortedAdmissionOwners(next)}) + if err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".admission-pauses-*.tmp") + if err != nil { + return fmt.Errorf("create admission state temp file: %w", err) + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err := temporary.Chmod(0o600); err != nil { + temporary.Close() + return err + } + if _, err := temporary.Write(append(payload, '\n')); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryName, path); err != nil { + return fmt.Errorf("commit admission state: %w", err) + } + directory, err := os.Open(filepath.Dir(path)) + if err != nil { + return fmt.Errorf("open admission state directory: %w", err) + } + defer directory.Close() + if err := directory.Sync(); err != nil { + return fmt.Errorf("sync admission state directory: %w", err) + } + return nil +} + +func (d *Daemon) loadAdmissionOwners() error { + path := d.admissionStatePath() + if path == "" { + return nil + } + payload, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + var state admissionPauseDiskState + if err := json.Unmarshal(payload, &state); err != nil { + return err + } + owners := make(map[string]struct{}, len(state.Owners)) + for _, owner := range state.Owners { + normalized, err := normalizeAdmissionOwner(owner) + if err != nil { + return err + } + owners[normalized] = struct{}{} + } + d.admissionPauseOwners = owners + return nil +} diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 47610072ea6..543014ee3ab 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -376,9 +376,10 @@ type Daemon struct { // or any task is in handleTask. Together that closes the fetch-then-claim // race where a new task slipping in during the release-metadata fetch // would be cancelled by triggerRestart's root-ctx cancel. - claimMu sync.Mutex - pauseClaims bool // when true, the batch poller skips claiming - claimsInFlight int // pollers that have decided to claim but haven't yet handed the task off to handleTask + claimMu sync.Mutex + pauseClaims bool // auto-update barrier; successful upgrades keep it set until process restart + admissionPauseOwners map[string]struct{} // independently owned, restart-persistent operator barriers + claimsInFlight int // pollers that have decided to claim but haven't yet handed the task off to handleTask activeEnvRootsMu sync.Mutex activeEnvRootsCond *sync.Cond // signalled when an in-flight env-root GC mutation finishes @@ -462,6 +463,14 @@ func New(cfg Config, logger *slog.Logger) *Daemon { reconcile: newReconcileBroadcaster(), workspaceChanges: newWorkspaceChangeSignal(), wsRPC: newWSRPCClient(wsRPCResponseGrace), + admissionPauseOwners: make(map[string]struct{}), + } + if err := d.loadAdmissionOwners(); err != nil { + // A corrupt/unreadable persisted barrier must fail closed. The owner is + // intentionally not persisted over the corrupt file; an operator must + // repair/remove it before admission can resume. + d.admissionPauseOwners["state-load-error"] = struct{}{} + logger.Error("admission pause state failed to load; admission remains paused", "error", err) } d.activeEnvRootsCond = sync.NewCond(&d.activeEnvRootsMu) d.activeCodexStoresCond = sync.NewCond(&d.activeCodexStoresMu) @@ -3352,7 +3361,7 @@ func (d *Daemon) reportUpdateResultWithRetry(ctx context.Context, runtimeID, upd func (d *Daemon) tryEnterClaim() bool { d.claimMu.Lock() defer d.claimMu.Unlock() - if d.pauseClaims { + if d.pauseClaims || len(d.admissionPauseOwners) > 0 { return false } d.claimsInFlight++ diff --git a/server/internal/daemon/health.go b/server/internal/daemon/health.go index d50e16e2c57..d6591ec0053 100644 --- a/server/internal/daemon/health.go +++ b/server/internal/daemon/health.go @@ -25,14 +25,16 @@ type HealthResponse struct { // lifecycle CLI (`daemon start/stop`) acts on the host process namespace, // so a foreign-OS daemon can't be started/stopped by the app even though // /health is reachable. See #3916. - OS string `json:"os"` - Uptime string `json:"uptime"` - DaemonID string `json:"daemon_id"` - DeviceName string `json:"device_name"` - ServerURL string `json:"server_url"` - CLIVersion string `json:"cli_version"` - ActiveTaskCount int64 `json:"active_task_count"` - Agents []string `json:"agents"` + OS string `json:"os"` + Uptime string `json:"uptime"` + DaemonID string `json:"daemon_id"` + DeviceName string `json:"device_name"` + ServerURL string `json:"server_url"` + CLIVersion string `json:"cli_version"` + ActiveTaskCount int64 `json:"active_task_count"` + AdmissionPaused bool `json:"admission_paused"` + AdmissionPauseOwners []string `json:"admission_pause_owners"` + Agents []string `json:"agents"` // SkippedAgents maps a provider that WAS discovered on this machine to the // reason the last registration round dropped it (version undetectable, // below the minimum supported version). Purely diagnostic, and omitted when @@ -103,18 +105,20 @@ func (d *Daemon) healthHandler(startedAt time.Time) http.HandlerFunc { } resp := HealthResponse{ - Status: status, - PID: os.Getpid(), - OS: runtime.GOOS, - Uptime: time.Since(startedAt).Truncate(time.Second).String(), - DaemonID: d.cfg.DaemonID, - DeviceName: d.cfg.DeviceName, - ServerURL: d.cfg.ServerBaseURL, - CLIVersion: d.cfg.CLIVersion, - ActiveTaskCount: d.activeTasks.Load(), - Agents: agents, - SkippedAgents: d.skippedAgentsSnapshot(), - Workspaces: wsList, + Status: status, + PID: os.Getpid(), + OS: runtime.GOOS, + Uptime: time.Since(startedAt).Truncate(time.Second).String(), + DaemonID: d.cfg.DaemonID, + DeviceName: d.cfg.DeviceName, + ServerURL: d.cfg.ServerBaseURL, + CLIVersion: d.cfg.CLIVersion, + ActiveTaskCount: d.activeTasks.Load(), + AdmissionPaused: d.isAdmissionPaused(), + AdmissionPauseOwners: d.admissionPauseOwnersSnapshot(), + Agents: agents, + SkippedAgents: d.skippedAgentsSnapshot(), + Workspaces: wsList, } w.Header().Set("Content-Type", "application/json") @@ -122,6 +126,68 @@ func (d *Daemon) healthHandler(startedAt time.Time) http.HandlerFunc { } } +func (d *Daemon) isAdmissionPaused() bool { + d.claimMu.Lock() + defer d.claimMu.Unlock() + return len(d.admissionPauseOwners) > 0 +} + +func (d *Daemon) admissionPauseOwnersSnapshot() []string { + d.claimMu.Lock() + defer d.claimMu.Unlock() + return sortedAdmissionOwners(d.admissionPauseOwners) +} + +// admissionHandler changes only the operator-controlled claim barrier. Pausing +// does not cancel active tasks. A 202 response means a claim that began before +// the pause is still draining; the barrier already blocks every later claim. +func (d *Daemon) admissionHandler(paused bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + owner, err := normalizeAdmissionOwner(r.URL.Query().Get("owner")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + d.claimMu.Lock() + next := cloneAdmissionOwners(d.admissionPauseOwners) + if paused { + next[owner] = struct{}{} + } else { + delete(next, owner) + } + if err := d.persistAdmissionOwnersLocked(next); err != nil { + d.claimMu.Unlock() + http.Error(w, "persist admission barrier: "+err.Error(), http.StatusInternalServerError) + return + } + d.admissionPauseOwners = next + claimsInFlight := d.claimsInFlight + owners := sortedAdmissionOwners(next) + _, ownerPaused := next[owner] + d.claimMu.Unlock() + + statusCode := http.StatusOK + if paused && claimsInFlight > 0 { + statusCode = http.StatusAccepted + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(map[string]any{ + "admission_paused": len(owners) > 0, + "admission_pause_owners": owners, + "owner": owner, + "owner_paused": ownerPaused, + "claims_in_flight": claimsInFlight, + "active_task_count": d.activeTasks.Load(), + }) + } +} + // shutdownHandler triggers a graceful daemon shutdown by cancelling the // top-level context. Used by `multica daemon stop` so we don't depend on // OS-signal delivery, which is unreliable on Windows once the daemon is @@ -150,6 +216,8 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim mux := http.NewServeMux() mux.HandleFunc("/health", d.healthHandler(startedAt)) mux.HandleFunc("/shutdown", d.shutdownHandler()) + mux.HandleFunc("/admission/pause", d.admissionHandler(true)) + mux.HandleFunc("/admission/resume", d.admissionHandler(false)) mux.HandleFunc("/repo/checkout", d.repoCheckoutHandler()) srv := &http.Server{Handler: mux} diff --git a/server/internal/daemon/health_test.go b/server/internal/daemon/health_test.go index 5c5c31e6ed4..46975dc0aa4 100644 --- a/server/internal/daemon/health_test.go +++ b/server/internal/daemon/health_test.go @@ -53,6 +53,9 @@ func TestHealthHandlerReportsCLIVersionAndActiveTaskCount(t *testing.T) { if got, want := raw["active_task_count"], float64(3); got != want { t.Errorf("active_task_count key: got %v, want %v", got, want) } + if got, want := raw["admission_paused"], false; got != want { + t.Errorf("admission_paused key: got %v, want %v", got, want) + } if got, want := raw["status"], "running"; got != want { t.Errorf("status key: got %v, want %q", got, want) } @@ -75,6 +78,137 @@ func TestHealthHandlerReportsCLIVersionAndActiveTaskCount(t *testing.T) { if resp.ActiveTaskCount != 3 { t.Errorf("ActiveTaskCount: got %d, want 3", resp.ActiveTaskCount) } + if resp.AdmissionPaused { + t.Error("AdmissionPaused: got true, want false") + } +} + +func TestAdmissionHandlerPausesAndResumesNewClaims(t *testing.T) { + t.Parallel() + + d := &Daemon{} + + pauseRec := httptest.NewRecorder() + d.admissionHandler(true).ServeHTTP( + pauseRec, + httptest.NewRequest(http.MethodPost, "/admission/pause", nil), + ) + if pauseRec.Code != http.StatusOK { + t.Fatalf("pause status: got %d, want 200: %s", pauseRec.Code, pauseRec.Body.String()) + } + if d.tryEnterClaim() { + d.exitClaim() + t.Fatal("claim entered while admission was paused") + } + + resumeRec := httptest.NewRecorder() + d.admissionHandler(false).ServeHTTP( + resumeRec, + httptest.NewRequest(http.MethodPost, "/admission/resume", nil), + ) + if resumeRec.Code != http.StatusOK { + t.Fatalf("resume status: got %d, want 200: %s", resumeRec.Code, resumeRec.Body.String()) + } + if !d.tryEnterClaim() { + t.Fatal("claim remained blocked after admission resumed") + } + d.exitClaim() +} + +func TestAdmissionResumeDoesNotClearAutoUpdateBarrier(t *testing.T) { + t.Parallel() + + d := &Daemon{pauseClaims: true, admissionPauseOwners: map[string]struct{}{"manual": {}}} + rec := httptest.NewRecorder() + d.admissionHandler(false).ServeHTTP( + rec, + httptest.NewRequest(http.MethodPost, "/admission/resume", nil), + ) + if rec.Code != http.StatusOK { + t.Fatalf("resume status: got %d, want 200: %s", rec.Code, rec.Body.String()) + } + if d.tryEnterClaim() { + d.exitClaim() + t.Fatal("manual resume cleared the independent auto-update claim barrier") + } +} + +func TestAdmissionPauseReportsClaimDrainPending(t *testing.T) { + t.Parallel() + + d := &Daemon{claimsInFlight: 1} + rec := httptest.NewRecorder() + d.admissionHandler(true).ServeHTTP( + rec, + httptest.NewRequest(http.MethodPost, "/admission/pause", nil), + ) + if rec.Code != http.StatusAccepted { + t.Fatalf("pause status: got %d, want 202: %s", rec.Code, rec.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if got, want := body["claims_in_flight"], float64(1); got != want { + t.Fatalf("claims_in_flight: got %v, want %v", got, want) + } + if d.tryEnterClaim() { + d.exitClaim() + t.Fatal("new claim entered while a pre-pause claim was draining") + } +} + +func TestAdmissionHandlerRejectsNonPost(t *testing.T) { + t.Parallel() + + d := &Daemon{} + rec := httptest.NewRecorder() + d.admissionHandler(true).ServeHTTP( + rec, + httptest.NewRequest(http.MethodGet, "/admission/pause", nil), + ) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status: got %d, want 405", rec.Code) + } + if d.isAdmissionPaused() { + t.Fatal("GET request changed admission state") + } +} + +func TestAdmissionOwnersAreIndependentAndPersistAcrossRestart(t *testing.T) { + t.Parallel() + + root := t.TempDir() + d := &Daemon{cfg: Config{WorkspacesRoot: root}, admissionPauseOwners: make(map[string]struct{})} + request := func(path string) map[string]any { + rec := httptest.NewRecorder() + d.admissionHandler(strings.Contains(path, "/pause")).ServeHTTP( + rec, + httptest.NewRequest(http.MethodPost, path, nil), + ) + if rec.Code != http.StatusOK { + t.Fatalf("%s status: %d: %s", path, rec.Code, rec.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body + } + request("/admission/pause?owner=manual") + request("/admission/pause?owner=storage-guard") + body := request("/admission/resume?owner=storage-guard") + if body["admission_paused"] != true || body["owner_paused"] != false { + t.Fatalf("storage resume cleared manual owner: %v", body) + } + + restarted := &Daemon{cfg: Config{WorkspacesRoot: root}, admissionPauseOwners: make(map[string]struct{})} + if err := restarted.loadAdmissionOwners(); err != nil { + t.Fatal(err) + } + if got := restarted.admissionPauseOwnersSnapshot(); len(got) != 1 || got[0] != "manual" { + t.Fatalf("persisted owners = %v, want [manual]", got) + } } // TestHealthHandlerReportsStartingUntilReady pins the liveness/readiness split: