diff --git a/.doc/ops/short-term/2026-09-22-governance-automation.md b/.doc/ops/short-term/2026-09-22-governance-automation.md index 6fdbdac6..06731f00 100644 --- a/.doc/ops/short-term/2026-09-22-governance-automation.md +++ b/.doc/ops/short-term/2026-09-22-governance-automation.md @@ -13,7 +13,7 @@ worktree: .worktrees/governance-automation-2026-09-22 base_dev_sha: 364ed7f1a3f0393ef77ec4623a9518116ed2cb87 write_scope: [".doc", "scripts", "docker-compose.dev.yml", "docker-compose.dev.pi-image.yml"] allowed_environments: [] -head_sha: "ae001a85c15a2b9213125f9d1d8c3e006221d532" +head_sha: "1116058a885f842a65756d23c85ad4ff1514307e" mutex: agent-workflow-governance-automation review_state: pending integration_state: pending diff --git a/scripts/agent_worktree.py b/scripts/agent_worktree.py index ea5d18c9..6bb61e9b 100644 --- a/scripts/agent_worktree.py +++ b/scripts/agent_worktree.py @@ -199,33 +199,6 @@ def assert_no_process_cwd(path): raise Refused(f"process {pid} still uses this worktree as cwd") -def inspect_process_cwd(path): - """Structured process-cwd visibility for cleanup reports (never force-clears).""" - path = Path(path).resolve() - try: - for pid, cwd in iter_same_user_cwds(): - try: - resolved = cwd.resolve() - except OSError: - return {'state': 'unreadable', 'unreadable': [str(pid)]} - if resolved == path or path in resolved.parents: - return {'state': 'occupied', 'pid': str(pid)} - return {'state': 'clear'} - except Refused as exc: - msg = str(exc) - if 'unsupported' in msg: - return { - 'state': 'unsupported_platform', - 'platform': sys.platform, - 'message': msg, - } - if 'cannot verify cwd of same-user process' in msg: - # Extract pid list when present after the word process - return {'state': 'unreadable', 'unreadable': [msg], 'message': msg} - return {'state': 'unreadable', 'unreadable': [], 'message': msg} - - - @contextmanager def removal_lock(main, path): # Every agentctl run/registration holds the shared side of this same lock. @@ -286,134 +259,10 @@ def remove_checked(repo, main, branch, delete_branch, path): print(json.dumps({"removed": str(path), "branch": branch, "branch_deleted": delete_branch})) -def inspect_worktree(repo, main, entry): - """Read-only cleanup readiness for one porcelain worktree entry.""" - path = Path(entry.get("worktree", "")) - branch_ref = entry.get("branch", "") - report = { - "path": str(path), - "branch": branch_ref[len("refs/heads/"):] if branch_ref.startswith("refs/heads/") else branch_ref or None, - "verdict": "blocked", - "blockers": [], - "notes": [], - } - if "bare" in entry: - report["blockers"].append("bare_worktree") - return report - try: - safe_directory(main, path.resolve()) - except Refused as exc: - report["blockers"].append(f"path:{exc}") - return report - if "locked" in entry: - report["blockers"].append("git_locked") - if "prunable" in entry: - report["blockers"].append("git_prunable") - if not path.is_dir(): - report["blockers"].append("missing_directory") - return report - if not report["branch"]: - report["blockers"].append("detached_or_unmapped_branch") - return report - try: - actual = git(path, "symbolic-ref", "--quiet", "HEAD").stdout.strip() - if actual != f"refs/heads/{report['branch']}": - report["blockers"].append("branch_identity_mismatch") - head = git(path, "rev-parse", "HEAD").stdout.strip() - report["head"] = head - if git(main, "merge-base", "--is-ancestor", head, "refs/heads/dev", check=False).returncode: - report["blockers"].append("not_ancestor_of_dev") - else: - report["notes"].append("ancestor_of_dev") - for marker in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "BISECT_START"): - marker_path = Path(git(path, "rev-parse", "--path-format=absolute", "--git-path", marker).stdout.strip()) - if marker_path.exists(): - report["blockers"].append(f"unfinished_git:{marker}") - dirty = git(path, "status", "--porcelain=v1", "--untracked-files=all", "--ignored=matching").stdout - if dirty: - report["blockers"].append("dirty_or_ignored_files") - report["dirty_sample"] = dirty.splitlines()[:8] - except Refused as exc: - report["blockers"].append(f"git:{exc}") - check = control(repo, "worktree-check", "--path", str(path), check=False) - if check.returncode: - try: - payload = json.loads(check.stderr.strip().splitlines()[-1]) - report["blockers"].append(f"registry:{payload.get('code', 'DENIED')}") - if payload.get("message"): - report["notes"].append(payload["message"]) - except (ValueError, IndexError, json.JSONDecodeError): - report["blockers"].append("registry:worktree_check_failed") - else: - report["notes"].append("registry_clear") - process = inspect_process_cwd(path) - report["process"] = process - if process["state"] == "occupied": - report["blockers"].append(f"process_cwd:{process['pid']}") - elif process["state"] == "unreadable": - report["blockers"].append("process_cwd_unreadable") - elif process["state"] == "unsupported_platform": - report["blockers"].append("process_cwd_platform_pending") - report["notes"].append(process.get("message") or "platform process check pending") - key = hashlib.sha256(str(path.resolve()).encode()).hexdigest() - lock_path = main / ".locks" / "os" / f"worktree-{key}.lock" - if lock_path.exists(): - fd = None - try: - fd = os.open(lock_path, os.O_RDWR | os.O_NOFOLLOW, 0o600) - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - fcntl.flock(fd, fcntl.LOCK_UN) - report["notes"].append("lifecycle_lock_idle") - except BlockingIOError: - report["blockers"].append("lifecycle_lock_busy") - except OSError as exc: - report["blockers"].append(f"lifecycle_lock:{exc}") - finally: - if fd is not None: - os.close(fd) - if not report["blockers"]: - report["verdict"] = "ready" - return report - - -def cleanup_report(repo, main): - """Surface cleanup readiness without deleting anything.""" - try: - control(repo, "status") - registry = "initialized" - except Exception: - registry = "unavailable" - items = [] - for entry in worktrees(main): - path = entry.get("worktree") - if not path: - continue - resolved = Path(path).resolve() - if resolved == main.resolve(): - continue - if resolved.parent != (main / ".worktrees").resolve(): - items.append({ - "path": str(resolved), - "branch": entry.get("branch"), - "verdict": "blocked", - "blockers": ["outside_dot_worktrees"], - "notes": ["not managed by agent-worktree cleanup"], - }) - continue - items.append(inspect_worktree(repo, main, entry)) - ready = sum(1 for item in items if item.get("verdict") == "ready") - print(json.dumps({ - "status": "ok", - "registry": registry, - "worktrees": items, - "summary": {"total": len(items), "ready": ready, "blocked": len(items) - ready}, - }, ensure_ascii=False, sort_keys=True)) - def main(): parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("list", help="read-only Git worktree listing") - commands.add_parser("cleanup-report", help="read-only cleanup readiness for .worktrees entries") for name in ("create", "remove"): child = commands.add_parser(name) child.add_argument("branch") @@ -426,9 +275,6 @@ def main(): if args.command == "list": print(git(root, "worktree", "list").stdout, end="") return 0 - if args.command == "cleanup-report": - cleanup_report(repo, root) - return 0 safe_branch(args.branch) with write_lock(repo, root): if args.command == "create":