From c001309ed5670c1ebc8d5316ce3d536de65b4bf4 Mon Sep 17 00:00:00 2001 From: pikastech Date: Tue, 21 Jul 2026 09:44:04 +0200 Subject: [PATCH] feat: govern merged NC01 worktrees --- .agents/skills/unidesk-gc/SKILL.md | 26 +++ config/unidesk-cli.yaml | 14 ++ scripts/src/gc-remote-merged-worktrees.py | 202 ++++++++++++++++++ .../src/gc-remote-merged-worktrees.test.py | 42 ++++ scripts/src/gc-remote-policy-runner.py | 4 +- scripts/src/gc-remote-runner.py | 15 +- scripts/src/gc-remote.ts | 28 ++- 7 files changed, 323 insertions(+), 8 deletions(-) create mode 100644 scripts/src/gc-remote-merged-worktrees.py create mode 100644 scripts/src/gc-remote-merged-worktrees.test.py diff --git a/.agents/skills/unidesk-gc/SKILL.md b/.agents/skills/unidesk-gc/SKILL.md index e8e6193c..a9d865b8 100644 --- a/.agents/skills/unidesk-gc/SKILL.md +++ b/.agents/skills/unidesk-gc/SKILL.md @@ -120,6 +120,32 @@ rm -rf -- /tmp/unidesk-cli-output - provider host 统一使用 `bun scripts/cli.ts gc remote ...`。 - 远端长任务必须异步执行,并用 `status --job-id` 短查询,不得维持长 SSH 会话。 +### 已合并 worktree + +先读取 YAML 策略并只生成计划: + +```bash +bun scripts/cli.ts gc remote merged-worktrees plan +``` + +- 仅枚举 Git 登记且位于 `workspaceRoots` 的 worktree; +- dirty、活跃、近期、未被 `baseRef` 吸收、cherry 检查超时和主 worktree 必须显示为 protected; +- 手动入口与 policy timer 复用同一筛选器,批量上限分别读取 YAML 的手动和自动字段; +- 执行前逐项重新计划,只允许 `git worktree remove`,不得删除 branch 或使用 `rm -rf`。 + +复核候选后才允许执行: + +```bash +bun scripts/cli.ts gc remote merged-worktrees run --confirm +``` + +周期 stage 通过以下入口核对 YAML 字段、stage 和 `inSync`: + +```bash +bun scripts/cli.ts gc remote policy plan +bun scripts/cli.ts gc remote policy status +``` + ### 内存分布与 Kubernetes 留存 先用紧凑 overview 归因真实内存压力: diff --git a/config/unidesk-cli.yaml b/config/unidesk-cli.yaml index fa0a2d46..e774d46b 100644 --- a/config/unidesk-cli.yaml +++ b/config/unidesk-cli.yaml @@ -154,6 +154,19 @@ gc: remote: targets: NC01: + mergedWorktrees: + enabled: true + mainRoot: /root/unidesk + workspaceRoots: + - /root/unidesk/.worktree + baseRef: origin/master + minAgeHours: 24 + scanBudgetMs: 20000 + cherryCheckTimeoutSeconds: 2 + manualMaxDeletePerRun: 20 + automatic: + enabled: true + maxDeletePerRun: 5 registryRetention: enabled: true configRef: config/hwlab-node-control-plane.yaml#nodes.NC01.registry @@ -363,6 +376,7 @@ gc: includeHostContainerdCache: false includeLocalPathOrphans: false includeKubernetesObjectRetention: true + includeMergedWorktrees: true pvcAttribution: namespaces: - agentrun-ci diff --git a/scripts/src/gc-remote-merged-worktrees.py b/scripts/src/gc-remote-merged-worktrees.py new file mode 100644 index 00000000..3f65f05a --- /dev/null +++ b/scripts/src/gc-remote-merged-worktrees.py @@ -0,0 +1,202 @@ +import os +import subprocess +import time + + +def _command(args, timeout_seconds): + started = time.time() + try: + result = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout_seconds) + return { + "exitCode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "elapsedMs": int((time.time() - started) * 1000), + } + except subprocess.TimeoutExpired as exc: + return { + "exitCode": 124, + "stdout": exc.stdout or "" if isinstance(exc.stdout, str) else "", + "stderr": exc.stderr or "" if isinstance(exc.stderr, str) else "", + "elapsedMs": int((time.time() - started) * 1000), + "timedOut": True, + } + + +def _worktree_entries(main_root): + result = _command(["git", "-C", main_root, "worktree", "list", "--porcelain"], 10) + if result["exitCode"] != 0: + raise RuntimeError(result["stderr"].strip() or "git worktree list failed") + entries = [] + current = None + for raw_line in result["stdout"].splitlines() + [""]: + line = raw_line.strip() + if not line: + if current: + entries.append(current) + current = None + continue + key, _, value = line.partition(" ") + if key == "worktree": + current = {"path": os.path.realpath(value), "head": None, "branch": None, "bare": False} + elif current is not None and key == "HEAD": + current["head"] = value + elif current is not None and key == "branch": + current["branch"] = value + elif current is not None and key == "bare": + current["bare"] = True + return entries + + +def _inside_roots(path, roots): + return any(path.startswith(root.rstrip("/") + "/") for root in roots) + + +def _recent(path, cutoff, deadline): + for root, dirs, files in os.walk(path): + if time.time() > deadline: + raise TimeoutError("scan-budget-exceeded") + for name in dirs + files: + try: + if os.lstat(os.path.join(root, name)).st_mtime >= cutoff: + return True + except OSError: + return True + try: + return os.lstat(path).st_mtime >= cutoff + except OSError: + return True + + +def _open_or_current(path): + resolved = os.path.realpath(path) + prefix = resolved.rstrip("/") + "/" + try: + pids = [name for name in os.listdir("/proc") if name.isdigit()] + except OSError: + return True + for pid in pids: + base = os.path.join("/proc", pid) + for name in ["cwd", "root"]: + try: + target = os.path.realpath(os.readlink(os.path.join(base, name))) + except OSError: + continue + if target == resolved or target.startswith(prefix): + return True + return False + + +def _clean(path): + result = _command(["git", "-C", path, "status", "--porcelain", "--untracked-files=all"], 15) + return result["exitCode"] == 0 and not result["stdout"].strip(), result + + +def _absorbed(main_root, base_ref, head, timeout_seconds): + ancestor = _command(["git", "-C", main_root, "merge-base", "--is-ancestor", head, base_ref], timeout_seconds) + if ancestor["exitCode"] == 0: + return True, "ancestor", 0 + cherry = _command(["git", "-C", main_root, "cherry", base_ref, head], timeout_seconds) + if cherry["exitCode"] != 0: + raise RuntimeError(cherry["stderr"].strip() or "git cherry failed") + unabsorbed = [line for line in cherry["stdout"].splitlines() if line.startswith("+")] + return len(unabsorbed) == 0, "cherry-equivalent" if not unabsorbed else "unmerged", len(unabsorbed) + + +def merged_worktree_plan(config, automatic=False): + cfg = config.get("mergedWorktrees") if isinstance(config, dict) else None + if not isinstance(cfg, dict) or cfg.get("enabled") is not True: + return {"id": "merged-worktrees", "ok": True, "skipped": True, "candidates": [], "protected": []} + automatic_cfg = cfg.get("automatic") if isinstance(cfg.get("automatic"), dict) else {} + if automatic and automatic_cfg.get("enabled") is not True: + return {"id": "merged-worktrees", "ok": True, "skipped": True, "candidates": [], "protected": []} + main_root = os.path.realpath(str(cfg.get("mainRoot") or "")) + roots = [os.path.realpath(item) for item in (cfg.get("workspaceRoots") or []) if isinstance(item, str) and item.startswith("/")] + base_ref = str(cfg.get("baseRef") or "") + min_age_hours = float(cfg.get("minAgeHours") or 0) + scan_budget_ms = max(1, int(cfg.get("scanBudgetMs") or 1)) + timeout_seconds = max(1, int(cfg.get("cherryCheckTimeoutSeconds") or 1)) + limit_key = "maxDeletePerRun" if automatic else "manualMaxDeletePerRun" + limit_source = automatic_cfg if automatic else cfg + limit = max(1, int(limit_source.get(limit_key) or 1)) + if not main_root.startswith("/") or not roots or not base_ref: + raise RuntimeError("mergedWorktrees requires absolute mainRoot/workspaceRoots and baseRef") + deadline = time.time() + scan_budget_ms / 1000.0 + cutoff = time.time() - min_age_hours * 3600.0 + candidates = [] + protected = [] + for entry in _worktree_entries(main_root): + path = entry["path"] + if not _inside_roots(path, roots): + continue + reason = None + detail = None + if time.time() > deadline: + reason = "scan-budget-exceeded" + elif entry.get("bare") or path == main_root or _open_or_current(path): + reason = "active-worktree" + elif not os.path.isdir(path) or os.path.islink(path): + reason = "missing-or-nonplain-worktree" + else: + try: + if _recent(path, cutoff, deadline): + reason = "recent-worktree" + except TimeoutError: + reason = "scan-budget-exceeded" + if reason is None: + clean, clean_result = _clean(path) + if not clean: + reason = "dirty-worktree" if clean_result["exitCode"] == 0 else "worktree-status-error" + detail = clean_result["stderr"].strip()[-400:] + if reason is None and not entry.get("head"): + reason = "worktree-head-missing" + if reason is None: + try: + absorbed, merge_mode, unabsorbed = _absorbed(main_root, base_ref, entry["head"], timeout_seconds) + if not absorbed: + reason = "unmerged-worktree" + detail = "unabsorbedCommitCount=%d" % unabsorbed + except Exception as exc: + reason = "worktree-merge-check-error" + detail = str(exc) + if reason is not None: + protected.append({"path": path, "reason": reason, "detail": detail}) + continue + candidates.append({"path": path, "head": entry["head"], "branch": entry.get("branch"), "mergeMode": merge_mode}) + return { + "id": "merged-worktrees", + "ok": True, + "automatic": automatic, + "configSource": "config/unidesk-cli.yaml#gc.remote.targets.%s.mergedWorktrees" % config.get("providerId"), + "baseRef": base_ref, + "candidateCount": len(candidates), + "protectedCount": len(protected), + "limit": limit, + "candidates": candidates[:limit], + "protected": protected, + } + + +def run_merged_worktrees(config, automatic=False): + plan = merged_worktree_plan(config, automatic=automatic) + if plan.get("skipped"): + return plan + results = [] + for candidate in plan["candidates"]: + refreshed = merged_worktree_plan(config, automatic=automatic) + safe_paths = {item["path"] for item in refreshed.get("candidates") or []} + path = candidate["path"] + if path not in safe_paths: + results.append({"path": path, "status": "protected-on-recheck"}) + continue + result = _command(["git", "-C", os.path.realpath(config["mergedWorktrees"]["mainRoot"]), "worktree", "remove", path], 30) + results.append({"path": path, "status": "removed" if result["exitCode"] == 0 else "failed", "error": result["stderr"].strip()[-400:] or None}) + return { + **plan, + "mutation": True, + "attemptedCount": len(results), + "removedCount": len([item for item in results if item["status"] == "removed"]), + "failedCount": len([item for item in results if item["status"] == "failed"]), + "results": results, + "ok": all(item["status"] != "failed" for item in results), + } diff --git a/scripts/src/gc-remote-merged-worktrees.test.py b/scripts/src/gc-remote-merged-worktrees.test.py new file mode 100644 index 00000000..297da581 --- /dev/null +++ b/scripts/src/gc-remote-merged-worktrees.test.py @@ -0,0 +1,42 @@ +import importlib.util +import os +import subprocess +import tempfile +import unittest + + +MODULE_PATH = os.path.join(os.path.dirname(__file__), "gc-remote-merged-worktrees.py") +SPEC = importlib.util.spec_from_file_location("gc_remote_merged_worktrees", MODULE_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class MergedWorktreeTest(unittest.TestCase): + def test_plan_protects_dirty_and_selects_absorbed_clean_worktree(self): + with tempfile.TemporaryDirectory() as root: + main = os.path.join(root, "repo") + worktrees = os.path.join(main, ".worktree") + subprocess.run(["git", "init", "-b", "master", main], check=True, stdout=subprocess.DEVNULL) + subprocess.run(["git", "-C", main, "config", "user.email", "test@example.com"], check=True) + subprocess.run(["git", "-C", main, "config", "user.name", "Test"], check=True) + with open(os.path.join(main, "base.txt"), "w", encoding="utf-8") as handle: + handle.write("base\n") + subprocess.run(["git", "-C", main, "add", "base.txt"], check=True) + subprocess.run(["git", "-C", main, "commit", "-m", "base"], check=True, stdout=subprocess.DEVNULL) + subprocess.run(["git", "-C", main, "remote", "add", "origin", main], check=True) + subprocess.run(["git", "-C", main, "fetch", "origin", "master"], check=True, stdout=subprocess.DEVNULL) + os.makedirs(worktrees) + clean = os.path.join(worktrees, "clean") + dirty = os.path.join(worktrees, "dirty") + subprocess.run(["git", "-C", main, "worktree", "add", "-b", "clean-branch", clean, "master"], check=True, stdout=subprocess.DEVNULL) + subprocess.run(["git", "-C", main, "worktree", "add", "-b", "dirty-branch", dirty, "master"], check=True, stdout=subprocess.DEVNULL) + with open(os.path.join(dirty, "dirty.txt"), "w", encoding="utf-8") as handle: + handle.write("dirty\n") + config = {"providerId": "TEST", "mergedWorktrees": {"enabled": True, "mainRoot": main, "workspaceRoots": [worktrees], "baseRef": "origin/master", "minAgeHours": 0, "scanBudgetMs": 5000, "cherryCheckTimeoutSeconds": 2, "manualMaxDeletePerRun": 10}} + plan = MODULE.merged_worktree_plan(config) + self.assertEqual([clean], [item["path"] for item in plan["candidates"]]) + self.assertIn("dirty-worktree", [item["reason"] for item in plan["protected"]]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/src/gc-remote-policy-runner.py b/scripts/src/gc-remote-policy-runner.py index 50c85b42..72c7521b 100644 --- a/scripts/src/gc-remote-policy-runner.py +++ b/scripts/src/gc-remote-policy-runner.py @@ -10,6 +10,8 @@ from datetime import datetime, timezone # __UNIDESK_GC_REMOTE_POLICY_KUBERNETES_RETENTION_HELPERS__ +# __UNIDESK_GC_REMOTE_POLICY_MERGED_WORKTREE_HELPERS__ + def now_iso(): return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") @@ -461,7 +463,7 @@ def main(): config_path = sys.argv[1] if len(sys.argv) > 1 else "" config = load_config(config_path) results = [] - for fn in [run_journal, run_apt, run_tmp, run_tool_caches, run_web_observe, run_session_pvcs, run_k3s_images, run_host_containerd, run_local_path_orphans, run_kubernetes_object_retention_policy]: + for fn in [run_journal, run_apt, run_tmp, run_tool_caches, run_web_observe, run_session_pvcs, run_k3s_images, run_host_containerd, run_local_path_orphans, run_kubernetes_object_retention_policy, lambda value: run_merged_worktrees(value, automatic=True)]: try: results.append(fn(config)) except Exception as exc: diff --git a/scripts/src/gc-remote-runner.py b/scripts/src/gc-remote-runner.py index 9879ed29..d37c0093 100644 --- a/scripts/src/gc-remote-runner.py +++ b/scripts/src/gc-remote-runner.py @@ -25,11 +25,14 @@ CONTAINERD_CONFIG = REMOTE_TARGET.get("containerdImageCache") if isinstance(REMO HOST_CONTAINERD_CONFIG = REMOTE_TARGET.get("hostContainerdCache") if isinstance(REMOTE_TARGET.get("hostContainerdCache"), dict) else {} LOCAL_PATH_CONFIG = REMOTE_TARGET.get("localPathStorage") if isinstance(REMOTE_TARGET.get("localPathStorage"), dict) else {} KUBERNETES_RETENTION_CONFIG = REMOTE_TARGET.get("kubernetesObjectRetention") if isinstance(REMOTE_TARGET.get("kubernetesObjectRetention"), dict) else {} +MERGED_WORKTREE_CONFIG = REMOTE_TARGET.get("mergedWorktrees") if isinstance(REMOTE_TARGET.get("mergedWorktrees"), dict) else {} POLICY_TIMER_CONFIG = REMOTE_TARGET.get("policyTimer") if isinstance(REMOTE_TARGET.get("policyTimer"), dict) else {} HOST_DOCKER_GC_CONFIG = REMOTE_TARGET.get("hostDockerGc") if isinstance(REMOTE_TARGET.get("hostDockerGc"), dict) else {} REGISTRY_CONFIG = REMOTE_TARGET.get("registryRetention") if isinstance(REMOTE_TARGET.get("registryRetention"), dict) else {} POLICY_RUNNER_SOURCE = base64.b64decode("__UNIDESK_GC_REMOTE_POLICY_RUNNER_BASE64__").decode("utf-8") +# __UNIDESK_GC_REMOTE_MERGED_WORKTREE_HELPERS__ + TMP_PREFIX_ALLOWLIST = [ "hwlab-agent-", "hwlab-cd-", @@ -1945,6 +1948,7 @@ def render_remote_policy(): include_host_containerd = required_policy_bool("includeHostContainerdCache") include_local_path_orphans = required_policy_bool("includeLocalPathOrphans") include_kubernetes_retention = required_policy_bool("includeKubernetesObjectRetention") + include_merged_worktrees = required_policy_bool("includeMergedWorktrees") state_dir = config_str(POLICY_TIMER_CONFIG, "stateDir", "/var/lib/unidesk-gc") config_dir = config_str(POLICY_TIMER_CONFIG, "configDir", "/etc/unidesk-gc") script_path = "/usr/local/sbin/%s.py" % unit_name @@ -1973,6 +1977,7 @@ def render_remote_policy(): {**KUBERNETES_RETENTION_CONFIG, "enabled": True} if include_kubernetes_retention else {"enabled": False} ), + "mergedWorktrees": ({**MERGED_WORKTREE_CONFIG, "enabled": True} if include_merged_worktrees else {"enabled": False}), "agentrunSessionPvcs": POLICY_TIMER_CONFIG.get("agentrunSessionPvcs") if isinstance(POLICY_TIMER_CONFIG.get("agentrunSessionPvcs"), dict) else {"enabled": False}, } stages = [ @@ -1986,6 +1991,7 @@ def render_remote_policy(): {"id": "host-containerd-orphans", "enabled": include_host_containerd, "risk": "medium", "mode": "ctr-metadata-empty-yaml-orphan-state"}, {"id": "local-path-orphans", "enabled": include_local_path_orphans, "risk": "medium", "mode": "pv-unreferenced-direct-child"}, {"id": "kubernetes-object-retention", "enabled": include_kubernetes_retention, "risk": "medium", "mode": "terminal-owner-cascade-bounded"}, + {"id": "merged-worktrees", "enabled": include_merged_worktrees, "risk": "medium", "mode": "git-registered-clean-inactive-absorbed-bounded"}, ] service = "\n".join([ "[Unit]", @@ -2036,6 +2042,7 @@ def render_remote_policy(): "includeHostContainerdCache": include_host_containerd, "includeLocalPathOrphans": include_local_path_orphans, "includeKubernetesObjectRetention": include_kubernetes_retention, + "includeMergedWorktrees": include_merged_worktrees, "stages": stages, "policyConfig": policy_config, "script": POLICY_RUNNER_SOURCE, @@ -2054,7 +2061,7 @@ def remote_policy_plan_payload(observed_at): "observedAt": observed_at, "configSource": "config/unidesk-cli.yaml#gc.remote.targets.%s.policyTimer" % PROVIDER_ID, "enabled": config_bool(POLICY_TIMER_CONFIG, "enabled", False), - "timer": {key: rendered.get(key) for key in ["unitName", "scriptPath", "configPath", "statePath", "servicePath", "timerPath", "onCalendar", "randomizedDelaySec", "journalTargetBytes", "journalTarget", "tmpMinAgeHours", "includeJournal", "includeTmp", "includeAptCache", "includeToolCaches", "includeWebObserveArtifacts", "includeK3sImageCache", "includeHostContainerdCache", "includeLocalPathOrphans", "includeKubernetesObjectRetention"]}, + "timer": {key: rendered.get(key) for key in ["unitName", "scriptPath", "configPath", "statePath", "servicePath", "timerPath", "onCalendar", "randomizedDelaySec", "journalTargetBytes", "journalTarget", "tmpMinAgeHours", "includeJournal", "includeTmp", "includeAptCache", "includeToolCaches", "includeWebObserveArtifacts", "includeK3sImageCache", "includeHostContainerdCache", "includeLocalPathOrphans", "includeKubernetesObjectRetention", "includeMergedWorktrees"]}, "stages": rendered.get("stages"), "servicePreview": rendered["service"] if bool(OPTIONS.get("full")) else None, "timerPreview": rendered["timer"] if bool(OPTIONS.get("full")) else None, @@ -2808,6 +2815,12 @@ def main(): if ACTION == "retention-run": emit_json(start_kubernetes_retention_job(observed_at), persist_large=False) return 0 + if ACTION == "merged-worktrees-plan": + emit_json(merged_worktree_plan({"providerId": PROVIDER_ID, "mergedWorktrees": MERGED_WORKTREE_CONFIG}), persist_large=True) + return 0 + if ACTION == "merged-worktrees-run": + emit_json(run_merged_worktrees({"providerId": PROVIDER_ID, "mergedWorktrees": MERGED_WORKTREE_CONFIG}), persist_large=True) + return 0 if bool(OPTIONS.get("memoryPressureOnly")) and ACTION in set(["plan", "run"]): candidates = collect_memory_reclaim_candidates() visible = visible_items(candidates) diff --git a/scripts/src/gc-remote.ts b/scripts/src/gc-remote.ts index 2e155c90..c803b1b8 100644 --- a/scripts/src/gc-remote.ts +++ b/scripts/src/gc-remote.ts @@ -5,7 +5,7 @@ import { type UniDeskConfig, rootPath } from "./config"; import { remoteGcDegradedFailure } from "./gc-remote-degraded"; import { runSshCommandCapture } from "./ssh"; -type RemoteGcAction = "plan" | "memory" | "memory-distribution" | "retention-plan" | "retention-run" | "snapshot" | "trend" | "run" | "status" | "policy-plan" | "policy-install" | "policy-status"; +type RemoteGcAction = "plan" | "memory" | "memory-distribution" | "retention-plan" | "retention-run" | "merged-worktrees-plan" | "merged-worktrees-run" | "snapshot" | "trend" | "run" | "status" | "policy-plan" | "policy-install" | "policy-status"; interface RemoteGcOptions { confirm: boolean; @@ -84,6 +84,9 @@ const GC_REMOTE_MEMORY_DISTRIBUTION_PLACEHOLDER = "# __UNIDESK_GC_REMOTE_MEMORY_ const GC_REMOTE_KUBERNETES_RETENTION_RELATIVE_PATH = "scripts/src/gc-remote-kubernetes-retention.py"; const GC_REMOTE_KUBERNETES_RETENTION_PLACEHOLDER = "# __UNIDESK_GC_REMOTE_KUBERNETES_RETENTION_HELPERS__"; const GC_REMOTE_POLICY_KUBERNETES_RETENTION_PLACEHOLDER = "# __UNIDESK_GC_REMOTE_POLICY_KUBERNETES_RETENTION_HELPERS__"; +const GC_REMOTE_MERGED_WORKTREE_RELATIVE_PATH = "scripts/src/gc-remote-merged-worktrees.py"; +const GC_REMOTE_MERGED_WORKTREE_PLACEHOLDER = "# __UNIDESK_GC_REMOTE_MERGED_WORKTREE_HELPERS__"; +const GC_REMOTE_POLICY_MERGED_WORKTREE_PLACEHOLDER = "# __UNIDESK_GC_REMOTE_POLICY_MERGED_WORKTREE_HELPERS__"; const GC_REMOTE_CONTAINERD_RELATIVE_PATH = "scripts/src/gc-remote-containerd.py"; const GC_REMOTE_CONTAINERD_PLACEHOLDER = "# __UNIDESK_GC_REMOTE_CONTAINERD_HELPERS__"; const GC_REMOTE_PVC_RELATIVE_PATH = "scripts/src/gc-remote-pvc.py"; @@ -134,6 +137,16 @@ export async function runRemoteGcCommand(config: UniDeskConfig, providerId: stri supportedActions: ["plan", "run", "status"], }; } + if (subaction === "merged-worktrees") { + const [worktreeAction = "plan", ...worktreeArgs] = args; + const options = parseRemoteGcOptions(worktreeArgs); + if (worktreeAction === "plan" || worktreeAction === "dry-run") return await runRemoteGc(config, providerId, "merged-worktrees-plan", options); + if (worktreeAction === "run") { + if (!options.confirm) return { ok: false, error: "gc-remote-merged-worktrees-run-requires-confirm", dryRun: true, mutation: false, requiredFlag: "--confirm", planCommand: `bun scripts/cli.ts gc remote ${providerId} merged-worktrees plan`, runCommand: `bun scripts/cli.ts gc remote ${providerId} merged-worktrees run --confirm` }; + return await runRemoteGc(config, providerId, "merged-worktrees-run", options); + } + return { ok: false, error: "unsupported-gc-remote-merged-worktrees-action", action: worktreeAction, supportedActions: ["plan", "run"] }; + } if (subaction === "policy") { const [policyAction = "plan", ...policyArgs] = args; const options = parseRemoteGcOptions(policyArgs); @@ -202,7 +215,7 @@ export async function runRemoteGcCommand(config: UniDeskConfig, providerId: stri ok: false, error: "unsupported-gc-remote-action", action: subaction, - supportedActions: ["memory", "memory-distribution", "retention", "plan", "snapshot", "trend", "run", "status", "policy"], + supportedActions: ["memory", "memory-distribution", "retention", "merged-worktrees", "plan", "snapshot", "trend", "run", "status", "policy"], }; } @@ -629,6 +642,7 @@ function remoteGcPython(configBase64: string): string { if (!template.includes(GC_REMOTE_KUBERNETES_RETENTION_PLACEHOLDER)) { throw new Error(`${GC_REMOTE_RUNNER_RELATIVE_PATH} missing ${GC_REMOTE_KUBERNETES_RETENTION_PLACEHOLDER}`); } + if (!template.includes(GC_REMOTE_MERGED_WORKTREE_PLACEHOLDER)) throw new Error(`${GC_REMOTE_RUNNER_RELATIVE_PATH} missing ${GC_REMOTE_MERGED_WORKTREE_PLACEHOLDER}`); if (!template.includes(GC_REMOTE_CONTAINERD_PLACEHOLDER)) { throw new Error(`${GC_REMOTE_RUNNER_RELATIVE_PATH} missing ${GC_REMOTE_CONTAINERD_PLACEHOLDER}`); } @@ -647,6 +661,7 @@ function remoteGcPython(configBase64: string): string { const webObserveHelpers = readFileSync(rootPath(GC_REMOTE_WEB_OBSERVE_RELATIVE_PATH), "utf8"); const memoryDistributionHelpers = readFileSync(rootPath(GC_REMOTE_MEMORY_DISTRIBUTION_RELATIVE_PATH), "utf8"); const kubernetesRetentionHelpers = readFileSync(rootPath(GC_REMOTE_KUBERNETES_RETENTION_RELATIVE_PATH), "utf8"); + const mergedWorktreeHelpers = readFileSync(rootPath(GC_REMOTE_MERGED_WORKTREE_RELATIVE_PATH), "utf8"); const containerdHelpers = readFileSync(rootPath(GC_REMOTE_CONTAINERD_RELATIVE_PATH), "utf8"); const pvcHelpers = readFileSync(rootPath(GC_REMOTE_PVC_RELATIVE_PATH), "utf8"); const growthHelpers = readFileSync(rootPath(GC_REMOTE_GROWTH_RELATIVE_PATH), "utf8"); @@ -655,13 +670,14 @@ function remoteGcPython(configBase64: string): string { if (!policyRunnerTemplate.includes(GC_REMOTE_POLICY_KUBERNETES_RETENTION_PLACEHOLDER)) { throw new Error(`${GC_REMOTE_POLICY_RUNNER_RELATIVE_PATH} missing ${GC_REMOTE_POLICY_KUBERNETES_RETENTION_PLACEHOLDER}`); } - const policyRunner = policyRunnerTemplate.replace( - GC_REMOTE_POLICY_KUBERNETES_RETENTION_PLACEHOLDER, - () => kubernetesRetentionHelpers.trimEnd(), - ); + if (!policyRunnerTemplate.includes(GC_REMOTE_POLICY_MERGED_WORKTREE_PLACEHOLDER)) throw new Error(`${GC_REMOTE_POLICY_RUNNER_RELATIVE_PATH} missing ${GC_REMOTE_POLICY_MERGED_WORKTREE_PLACEHOLDER}`); + const policyRunner = policyRunnerTemplate + .replace(GC_REMOTE_POLICY_KUBERNETES_RETENTION_PLACEHOLDER, () => kubernetesRetentionHelpers.trimEnd()) + .replace(GC_REMOTE_POLICY_MERGED_WORKTREE_PLACEHOLDER, () => mergedWorktreeHelpers.trimEnd()); return template .replace(GC_REMOTE_MEMORY_DISTRIBUTION_PLACEHOLDER, () => memoryDistributionHelpers.trimEnd()) .replace(GC_REMOTE_KUBERNETES_RETENTION_PLACEHOLDER, () => kubernetesRetentionHelpers.trimEnd()) + .replace(GC_REMOTE_MERGED_WORKTREE_PLACEHOLDER, () => mergedWorktreeHelpers.trimEnd()) .replace(GC_REMOTE_WEB_OBSERVE_PLACEHOLDER, () => webObserveHelpers.trimEnd()) .replace(GC_REMOTE_CONTAINERD_PLACEHOLDER, () => containerdHelpers.trimEnd()) .replace(GC_REMOTE_PVC_PLACEHOLDER, () => pvcHelpers.trimEnd())