#!/usr/bin/env python3 """Cache hit/miss check for investigating-agentforce-architecture. Replaces old agent Phase 0.5 (manifest read, schema-version check, TTL age check, path-existence check, env export on hit, RESULT-block assembly). Flow: 1. If FORCE_REFRESH=true → emit CACHE_HIT=false, exit 0. 2. Read $CACHE_DIR/manifest.json. Any I/O or parse failure → miss. 3. if schema_version != config.SCHEMA_VERSION → miss, emit CACHE_INVALIDATED_REASON=schema-version-mismatch, and `shutil.rmtree` the cache dir (gated to paths under CACHE_ROOT). 4. If data_path file missing → miss (atomic rename failed mid-write). 5. If age > ttl_days (default 7) → miss (no rmtree — just stale). 6. Hit path: - Copy {agent}_{ver}_metadata_tree.json from cache into DATA_DIR + WORK_DIR. - Copy `declared_action_tree.json` sidecar into WORK_DIR so emit ctx + downstream greps work. - Stdout: eval-able lines (CACHE_HIT=true, CACHED_AT_UTC, NODE_COUNT, DEPTH, AGENT_GENERATION, BOT_ID, VERSION_AUTO_PICKED, PARTIAL, UNRESOLVED_COUNT, OUTPUT_JSON_PATH, OUTPUT_SUMMARY_PATH). `{tree_base}.summary.md` was dropped from the output contract. Cache-hit no longer copies or regenerates a summary file; OUTPUT_SUMMARY_PATH is emitted empty for RESULT-block shape stability. Usage: eval "$(python3 cache_check.py)" Inputs (env): CACHE_DIR required — $CACHE_ROOT/$ORG_ID_15/$AGENT__$VER DATA_DIR required — $DATA_ROOT/$ORG_ID_15/$AGENT__$VER WORK_DIR required AGENT_API_NAME required AGENT_VERSION required FORCE_REFRESH 'true'|'false' (default false) Outputs: stdout: eval-able K=V lines files (hit only): DATA_DIR/{tree_base}.json and matching copy in WORK_DIR exit 0 always (miss is not an error) """ import json import os import pathlib import shlex import shutil import sys import datetime as dt import config # for SCHEMA_VERSION + CACHE_ROOT path-guard # refuse to operate if CACHE_ROOT itself is a symlink. # _safe_rmtree_under_cache_root calls resolve() on both target and root; a # symlinked CACHE_ROOT collapses the is_relative_to check because both sides # land on the symlink target, so rmtree would happily delete whatever lives # there — outside the sanctioned cache subtree. The guard was designed to # prevent rmtree escaping the cache; if the root itself can escape, the # guard is moot. Fail fast at import time so the failure surfaces at the # correct layer (configuration, not deletion). # # Path.is_symlink() returns False for non-existent paths, which is the # correct behaviour for pristine installs (CACHE_ROOT hasn't been created # yet; the script will mkdir it on first write and subsequent imports will # validate the real directory). if config.CACHE_ROOT.is_symlink(): raise RuntimeError( f"CACHE_ROOT is a symlink ({config.CACHE_ROOT} -> " f"{config.CACHE_ROOT.resolve()}). This is rejected for safety; the " "_safe_rmtree_under_cache_root guard cannot protect against a " "symlinked-root escape. Resolve the symlink or update " "config.CACHE_ROOT to a real directory." ) def miss(reason: str | None = None): """Emit CACHE_HIT=false and exit 0. if `reason` is provided, emit a second K=V line `CACHE_INVALIDATED_REASON=` so downstream emit_result can surface the invalidation cause in the RESULT block. """ sys.stdout.write("CACHE_HIT=false\n") if reason: sys.stdout.write(f"CACHE_INVALIDATED_REASON={shlex.quote(reason)}\n") sys.exit(0) def _safe_rmtree_under_cache_root(target: pathlib.Path) -> bool: """`shutil.rmtree(target)` ONLY if `target` resolves under CACHE_ROOT. Defence against a miscomputed cache_dir that could point outside the sanctioned cache root (symlink shenanigans, env-var tampering, or a future refactor that forgets to run through `build_agent_cache_dir`). We resolve both sides to absolute paths and confirm CACHE_ROOT is a prefix before handing to rmtree. Returns True on successful deletion, False if the path was refused or the rmtree itself failed. Never raises — callers on the miss() path can't act on a failure anyway. """ try: target_abs = target.resolve(strict=False) root_abs = config.CACHE_ROOT.resolve(strict=False) except (OSError, RuntimeError): return False # Path.is_relative_to exists on 3.9+. Explicit check is clearer than # a try/except on relative_to(). try: if not target_abs.is_relative_to(root_abs): return False except AttributeError: # pragma: no cover — safety for <3.9 try: target_abs.relative_to(root_abs) except ValueError: return False if not target_abs.exists(): return True # nothing to do, treat as successful no-op try: shutil.rmtree(target_abs) except OSError: return False return True def main() -> int: try: cache_dir = pathlib.Path(os.environ["CACHE_DIR"]) data_dir = pathlib.Path(os.environ["DATA_DIR"]) work_dir = pathlib.Path(os.environ["WORK_DIR"]) agent_api_name = os.environ["AGENT_API_NAME"] agent_version = os.environ["AGENT_VERSION"] except KeyError as e: sys.stderr.write(f"cache_check.py: missing env {e}\n") miss() if (os.environ.get("FORCE_REFRESH", "").strip().lower() == "true"): miss() manifest_path = cache_dir / "manifest.json" if not manifest_path.is_file(): miss() try: manifest = json.loads(manifest_path.read_text()) except (OSError, json.JSONDecodeError): miss() # Schema version gate. # strict match against config.SCHEMA_VERSION. Any mismatch (too # old OR unexpected future/unknown value) invalidates the cache, and # we delete the cache directory so the next run starts clean — a # stale tree under a legacy schema is worse than no cache, because # downstream code may parse shapes it no longer understands. The # legacy `< 2.4` check is subsumed (anything that doesn't equal the # current SCHEMA_VERSION triggers this branch). schema = str(manifest.get("schema_version") or "0") if schema != config.SCHEMA_VERSION: _safe_rmtree_under_cache_root(cache_dir) miss("schema-version-mismatch") # data_path existence data_path_s = manifest.get("data_path") or "" data_path = pathlib.Path(data_path_s) if data_path_s else None if not data_path or not data_path.is_file(): miss() # TTL age check try: built = dt.datetime.fromisoformat( (manifest.get("built_at_utc") or "").replace("Z", "+00:00") ) except ValueError: miss() age_days = (dt.datetime.now(dt.timezone.utc) - built).days ttl = int(manifest.get("ttl_days") or 7) if age_days > ttl: miss() # --- Hit path --- tree_base = f"{agent_api_name}_{agent_version}_metadata_tree" data_dir.mkdir(parents=True, exist_ok=True) work_dir.mkdir(parents=True, exist_ok=True) dst_json = data_dir / f"{tree_base}.json" # The manifest's data_path points at the authoritative tree copy. Re-copy # to DATA_DIR + WORK_DIR so the filesystem layout is stable for callers. try: shutil.copy(data_path, dst_json) except OSError: miss() # Stage a declared_action_tree.json sidecar in WORK_DIR for any downstream # script that expects the generic name. try: shutil.copy(dst_json, work_dir / "declared_action_tree.json") except OSError: pass # no summary.md handling — dropped from the output contract. agent_meta = manifest.get("agent") or {} kind_counts = manifest.get("kind_counts") or {} exports = [ ("CACHE_HIT", "true"), ("CACHED_AT_UTC", manifest.get("built_at_utc", "")), ("NODE_COUNT", str(manifest.get("node_count", 0))), ("DEPTH", str(manifest.get("depth", 0))), ("AGENT_GENERATION", agent_meta.get("generation") or "unknown"), ("BOT_ID", agent_meta.get("bot_id") or ""), ("BOT_MASTER_LABEL", agent_meta.get("master_label") or ""), ("VERSION_AUTO_PICKED", "true" if agent_meta.get("_version_auto_picked") else "false"), ("PARTIAL", "true" if manifest.get("partial") else "false"), ("UNRESOLVED_COUNT", str(manifest.get("unresolved_count", 0))), ("OUTPUT_JSON_PATH", str(dst_json)), # summary.md dropped from the output contract; field kept # empty for RESULT-block shape stability. ("OUTPUT_SUMMARY_PATH", ""), ("PLANNER_NAME", agent_meta.get("planner_name") or ""), ] # Pass kind counts too, in case finalize.py runs later without re-parsing # (it won't on cache hit, but the values keep the RESULT block complete). for k, v in kind_counts.items(): exports.append((f"KC_{k}", str(v))) sys.stdout.write("\n".join(f"{k}={shlex.quote(v)}" for k, v in exports) + "\n") return 0 if __name__ == "__main__": sys.exit(main())