diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 86396f05c8b7f..05598f27273b7 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -7381,14 +7381,23 @@ def complete_task( ) if survivor: metadata = dict(metadata or {}, survivor=survivor) - survivor_note = ( - f"survivor=patch {survivor['path']} {survivor['sha256']} {survivor['bytes']} NOT PUSHED" - if survivor['kind'] == 'patch' else - f"survivor=bundle {survivor['sidecar']} NOT PUSHED" - if survivor['kind'] == 'bundle' else "survivor=ref " + " ".join( - f"{ref['remote']}/{ref['branch']}@{ref['sha']}" for ref in survivor["refs"] + if survivor['kind'] == 'patch': + survivor_note = ( + f"survivor=patch {survivor['path']} {survivor['sha256']} {survivor['bytes']} NOT PUSHED" + ) + elif survivor['kind'] == 'bundle': + survivor_note = f"survivor=bundle {survivor['sidecar']} NOT PUSHED" + elif survivor['kind'] == 'landed': + survivor_note = "survivor=landed " + " ".join( + f"{entry['repository']}@{entry['sha']} ({entry['matched_by']})" + for entry in survivor["landed"] + ) + else: + survivor_note = "survivor=ref " + " ".join( + f"{ref.get('repository_path') or ref['remote']}/{ref['branch']}@{ref['sha']}" + + (" (live tree)" if ref.get("matched_by") == "canonical" else "") + for ref in survivor["refs"] ) - ) result = '\n'.join(filter(None, [result, survivor_note])) metadata = _merge_completion_prose_artifacts( conn, task_id, metadata, summary=summary, result=result, diff --git a/hermes_cli/kanban_survivor.py b/hermes_cli/kanban_survivor.py index cb8bc1d8897ab..ff23c70238247 100644 --- a/hermes_cli/kanban_survivor.py +++ b/hermes_cli/kanban_survivor.py @@ -39,12 +39,15 @@ class SurvivorUnavailable(ValueError): log = logging.getLogger(__name__) -def _git(repo, *args, env=None, check=True, input=None): +def _git(repo, *args, env=None, check=True, input=None, timeout=30): + # Local replace refs can substitute another tree for the only raw commit. + # Every Git authority read must inspect raw objects before deleting work. + git_env = dict(os.environ if env is None else env, GIT_NO_REPLACE_OBJECTS="1") result = subprocess.run( ["git", "-C", str(repo), *args], stdin=subprocess.DEVNULL if input is None else None, input=input, - capture_output=True, timeout=30, env=env, + capture_output=True, timeout=timeout, env=git_env, ) if check and result.returncode: # Git stderr can contain credential-bearing remote URLs, so it is never @@ -498,9 +501,85 @@ def _temporary_roots(): return [Path(tempfile.gettempdir()), Path("/tmp"), Path("/var/tmp")] -def _durable_remote(repo, remote, workspace): +def _excluded_roots(workspace): + """Roots a repo must NOT live under to count as durable/canonical.""" + return [workspace, kb.workspaces_root(), kb.kanban_home() / "kanban" / "workspaces", + kb.kanban_home() / "kanban" / "boards", *_temporary_roots()] + + +def _independent_storage(repo, workspace): + """Require local ref authority to retain its Git storage after disposal. + + Gitfiles and linked worktrees are fine when BOTH their private and common + directories are durable. Reject alternates and internal storage symlinks + conservatively: proving a checkout path alone says nothing about the objects + it borrows. Ordinary hardlinked clones remain independent on unlink. + """ + if any(os.environ.get(key) for key in ( + "GIT_DIR", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + )): + return False + try: + excluded = [root.resolve() for root in _excluded_roots(workspace)] + + def durable(path): + return not any(path.resolve(strict=True).is_relative_to(root) for root in excluded) + + if not durable(repo): + return False + config = _git(repo, "config", "--null", "--get-regexp", + r"^(extensions\.partialclone|remote\..*\.promisor)$", check=False) + if config.returncode not in (0, 1): + return False + for field in config.stdout.split(b"\0"): + key, _, value = field.partition(b"\n") + if key.lower() == b"extensions.partialclone" and value: + return False + if key.lower().endswith(b".promisor") and value.lower() in (b"true", b"yes", b"on", b"1"): + return False + directories = set() + result = _git(repo, "rev-parse", "--path-format=absolute", "--git-dir", + "--git-common-dir", "--git-path", "objects", check=False) + paths = result.stdout.splitlines() + if result.returncode or len(paths) != 3: + return False + for raw in paths: + path = Path(os.fsdecode(raw).strip()) + if not path.is_dir() or not durable(path): + return False + directories.add(path.resolve(strict=True)) + + def fail(exc): + raise exc + + for directory in directories: + for root, dirs, files in os.walk(directory, followlinks=False, onerror=fail): + for name in dirs + files: + path = Path(root) / name + if path.is_symlink(): + return False + if name.endswith(".promisor"): + return False + if name in {"alternates", "http-alternates"} and path.is_file() and path.stat().st_size: + return False + complete = _git( + repo, "rev-list", "--objects", "--missing=print", "HEAD", check=False, + ) + if complete.returncode or any( + line.startswith(b"?") for line in complete.stdout.splitlines() + ): + return False + return True + except (OSError, RuntimeError, subprocess.TimeoutExpired): + return False + + +def _durable_remote(repo, remote, workspace, storage_cache=None): # Expand insteadOf aliases, then resolve symlinks before checking scope. url = _git(repo, "remote", "get-url", remote).stdout.decode().strip() + if storage_cache is not None: + storage_cache[("url", remote)] = url parsed = urlsplit(url) if parsed.scheme in {"https", "http", "ssh", "git"}: return True @@ -510,14 +589,21 @@ def _durable_remote(repo, remote, workspace): return False path = Path(unquote(parsed.path) if parsed.scheme else url).expanduser() path = (repo / path).resolve() - roots = [workspace, kb.workspaces_root(), kb.kanban_home() / "kanban" / "workspaces", - kb.kanban_home() / "kanban" / "boards", *_temporary_roots()] - return remote == "origin" and not any(path.is_relative_to(root.resolve()) for root in roots) - - -def _published_refs(repo, workspace): - for remote in _git(repo, "remote").stdout.decode().splitlines(): - if not _durable_remote(repo, remote, workspace): + if remote != "origin": + return False + if storage_cache is not None: + if path not in storage_cache: + storage_cache[path] = _independent_storage(path, workspace) + return storage_cache[path] + return _independent_storage(path, workspace) + + +def _published_refs(repo, workspace, storage_cache=None): + remotes = _git(repo, "remote").stdout.decode().splitlines() + if storage_cache is not None: + storage_cache[("remotes",)] = remotes + for remote in remotes: + if not _durable_remote(repo, remote, workspace, storage_cache): continue try: advertised = _git(repo, "ls-remote", "--heads", remote, check=False) @@ -627,6 +713,314 @@ def _remote_survivor(repo, head, published): return dict(covered[0], head=head) +# A rewriting mirror (hermes-home's "isolated remote sync") republishes every +# commit under a NEW sha, so `_remote_survivor` can never match a clone of it and +# the capture falls through to a bundle of a 94 MB home tree, which always +# exceeds KANBAN_ATTACHMENT_MAX_BYTES (2026-09-20, t_e69d693a). +# +# `git patch-id` survives the rewrite, but it is the identity of a commit's +# DIFF, not of its content: two commits on different parents can produce +# byte-identical diffs over different trees, and patch-id normalises whitespace +# away. The live hermes-home mirror demonstrates this on the very pair the fix +# was designed around — 9f25d7cce and e747d18db share a patch-id while their +# trees differ by 58 files. So a patch-id hit is ADVISORY ANNOTATION ONLY and +# never authority to delete a workspace (Apollo ruling, 2026-09-20). +# +# The durable target for a home-clone workspace is the LIVE CANONICAL TREE it +# was cloned from — a local repository outside every kanban/temp root — which is +# covered by the fleet-backup tier. The rewriting mirror is not a faithful copy +# and must not be the bar. +_CONTENT_SCAN_DEPTH = 25 +_CONTENT_SCAN_BUDGET = 100 + + +def _canonical_repos(repo, workspace, storage_cache=None): + """Local repositories this repo's remotes resolve to, outside every disposable root. + + A `file:`/path remote pointing at a live checkout (``~/.hermes``) is the + canonical tree: it holds the real objects, it is what the workspace was + cloned from, and it is what the fleet-backup tier covers. Remotes inside a + kanban workspace, board, or temp root are disposable and never canonical. + """ + remotes = (storage_cache.get(("remotes",)) if storage_cache is not None else None) + if remotes is None: + remotes = _git(repo, "remote").stdout.decode().splitlines() + for remote in remotes: + raw = storage_cache.get(("url", remote)) if storage_cache is not None else None + if raw is None: + url = _git(repo, "remote", "get-url", remote, check=False) + if url.returncode: + continue + raw = url.stdout.decode().strip() + parsed = urlsplit(raw) + if parsed.scheme not in {"", "file"} or parsed.netloc not in {"", "localhost"}: + continue + if not parsed.scheme and ":" in raw and not raw.startswith(("/", ".")): + continue # scp-style SSH: not a local path + path = Path(unquote(parsed.path) if parsed.scheme else raw).expanduser() + try: + path = (repo / path).resolve(strict=True) + except OSError: + continue + if storage_cache is not None and path in storage_cache: + independent = storage_cache[path] + else: + independent = _independent_storage(path, workspace) + if storage_cache is not None: + storage_cache[path] = independent + if not independent: + continue + yield {"remote": remote, "repository_path": str(path)} + + +def _canonical_survivor(repo, head, workspace, storage_cache=None): + """Accept ``head`` when a LIVE canonical tree can reach it from its HEAD. + + This is the deletion authority for a home-clone workspace: the commit is not + only present in a durable local repo, it is on that repo's current line of + work. Reachability (not sha equality) is the predicate, so the rewriting + mirror never enters into it. + """ + for candidate in _canonical_repos(repo, workspace, storage_cache): + live = Path(candidate["repository_path"]) + if _git(live, "merge-base", "--is-ancestor", head, "HEAD", check=False).returncode == 0: + return dict(candidate, sha=head, head=head, matched_by="canonical", + branch=_git(live, "rev-parse", "--abbrev-ref", "HEAD", + check=False).stdout.decode().strip() or "HEAD") + return None + + +def _patch_id(repo, sha, env=None): + """Content identity of one commit's diff. None when it cannot be computed.""" + diff = _git(repo, "diff-tree", "-p", "--full-index", "--no-ext-diff", + "--no-textconv", "--no-renames", "--root", sha, env=env, check=False) + if diff.returncode or not diff.stdout: + return None + result = subprocess.run( + ["git", "-C", str(repo), "patch-id", "--stable"], + input=diff.stdout, capture_output=True, timeout=30, + env=dict(os.environ if env is None else env, GIT_NO_REPLACE_OBJECTS="1"), + ) + if result.returncode or not result.stdout.strip(): + return None + return result.stdout.split()[0].decode() + + +def _exact_commit_diff(repo, sha): + """Full blob-identified diff; unlike patch-id this retains whitespace and bytes.""" + diff = _git(repo, "diff-tree", "-p", "--binary", "--no-commit-id", + "--full-index", "--no-ext-diff", "--no-textconv", + "--no-renames", "--root", sha, check=False) + return diff.stdout if not diff.returncode and diff.stdout else None + + +def _landed_contains_history(workspace_repo, landed_repo, landed_sha): + """Return how ``landed_sha`` contains the workspace's final committed tree.""" + workspace_head = _git( + workspace_repo, "rev-parse", "--verify", "HEAD^{commit}", check=False, + ) + if workspace_head.returncode: + return None + workspace_head = workspace_head.stdout.decode().strip() + # Historical ancestry and identical commit diffs can both be undone by a + # later canonical commit. Only the current live tree can authorize deletion. + workspace_history = _git(workspace_repo, "rev-list", workspace_head, check=False) + landed_history = _git(landed_repo, "rev-list", landed_sha, check=False) + if workspace_history.returncode or landed_history.returncode: + return None + landed_commits = set(landed_history.stdout.decode().split()) + work_commits = workspace_history.stdout.decode().split() + shared = next((commit for commit in work_commits if commit in landed_commits), None) + # Compare net worker changes, not paths touched by inherited history. + # A shared HEAD still needs its path guard against later live reverts. + if shared and shared != workspace_head: + touched = _git(workspace_repo, "diff", "--no-renames", "--name-only", "-z", shared, workspace_head, check=False) + else: + touched = _git(workspace_repo, "log", "--no-renames", "--name-only", "-z", "--format=", workspace_head, check=False) + live_head = _git(landed_repo, "rev-parse", "--verify", "HEAD^{commit}", check=False) + if touched.returncode or live_head.returncode or not touched.stdout: + return None + work_tree = _git(workspace_repo, "ls-tree", "-rz", "--full-tree", workspace_head, check=False) + live_tree = _git(landed_repo, "ls-tree", "-rz", "--full-tree", live_head.stdout.decode().strip(), check=False) + if work_tree.returncode or live_tree.returncode: + return None + def entries(tree): + return dict(item.split(b"\t", 1)[::-1] for item in tree.stdout.split(b"\0") if item) + work_entries, live_entries = entries(work_tree), entries(live_tree) + if any(work_entries.get(path) != live_entries.get(path) for path in set(touched.stdout.split(b"\0")) - {b""}): + return None + if _git( + landed_repo, "merge-base", "--is-ancestor", workspace_head, landed_sha, + check=False, + ).returncode == 0: + return "ancestor" + + missing = [ + commit for commit in work_commits + if commit not in landed_commits + ] + if not missing: + return "ancestor" + + needed = set() + for commit in missing: + diff = _exact_commit_diff(workspace_repo, commit) + if diff is None: + return None + needed.add(diff) + for commit in landed_commits: + diff = _exact_commit_diff(landed_repo, commit) + if diff in needed: + needed.remove(diff) + if not needed: + return "exact-diff" + return None + + +def _content_advisory(repo, head, published, *, budget=_CONTENT_SCAN_BUDGET): + """ADVISORY ONLY: a published commit with the same normalized patch-id. + + Recorded in the sidecar as a recovery hint ("the mirror's looks like + this work"), NEVER as authority to delete a workspace: patch-id is diff + identity, not content identity, and the live hermes-home mirror produces + false positives on exactly the pair this feature was designed around. + + Fetches each durable remote head shallowly into a throwaway bare repo that + borrows ``repo``'s objects, then walks it looking for a matching patch-id. + The deepest fetched commit is a shallow boundary — Git would diff it against + the empty tree and invent a bogus patch-id — so it is never scanned. + """ + target = _patch_id(repo, head) + if target is None: + return None + objects = _git(repo, "rev-parse", "--path-format=absolute", "--git-path", "objects").stdout.decode().strip() + env = dict(os.environ, GIT_ALTERNATE_OBJECT_DIRECTORIES=objects) + with tempfile.TemporaryDirectory(prefix="kanban-content-") as tmp: + probe = Path(tmp) / "probe.git" + _git(repo, "init", "--bare", str(probe)) + for ref in published: + if budget <= 0: + return None + url = _git(repo, "remote", "get-url", ref["remote"]).stdout.decode().strip() + fetch_env = dict(env, KANBAN_FETCH_URL=url) + try: + fetched = _git( + probe, "--config-env=remote.candidate.url=KANBAN_FETCH_URL", + "fetch", "--no-tags", "--depth", str(_CONTENT_SCAN_DEPTH + 1), + "candidate", f"+refs/heads/{ref['branch']}:refs/heads/candidate", + env=fetch_env, check=False, timeout=120, + ) + except subprocess.TimeoutExpired: + continue + if fetched.returncode: + continue + walk = _git(probe, "rev-list", "--max-count", str(_CONTENT_SCAN_DEPTH), + "refs/heads/candidate", env=env, check=False) + if walk.returncode: + continue + for sha in walk.stdout.decode().split(): + if budget <= 0: + return None + budget -= 1 + if _patch_id(probe, sha, env=env) == target: + return dict(ref, sha=sha, head=head, matched_by="patch-id", + advisory=True, patch_id=target) + _git(probe, "update-ref", "-d", "refs/heads/candidate", env=env, check=False) + return None + + +def _verify_landed(entries, workspace): + """Verify an explicit `landed` claim against a LIVE CANONICAL repository. + + The escape hatch for work that was committed into a repo the workspace only + mirrors. The bar (Apollo ruling, 2026-09-20): the named repository must be a + real repository OUTSIDE every disposable root — a live tree covered by the + fleet-backup tier — and the sha must resolve there AND be reachable from that + tree's HEAD. Publication on a durable remote is NOT required, because the + hermes-home mirror rewrites trees and is not a faithful copy; requiring it + would make the claim unsatisfiable by construction. + + Every repository in the workspace must be clean and its committed history + must be represented by a named landed commit, either by exact ancestry or + byte-exact commit diffs. Patch-id remains advisory for remote + mirrors; neither it nor a normalized diff can bind workspace commits. + + Every failure mode raises — an unverifiable claim must never be accepted, + because accepting it authorises deleting the only remaining copy of the code. + """ + verified = [] + for entry in entries: + if not isinstance(entry, dict): + raise SurvivorUnavailable("survivor_unavailable: landed entry is not an object") + repo_path, sha = entry.get("repo_path"), entry.get("sha") + if not repo_path or not sha: + raise SurvivorUnavailable("survivor_unavailable: landed entry needs repo_path and sha") + repo = Path(repo_path).expanduser() + if not repo.is_dir(): + raise SurvivorUnavailable("survivor_unavailable: landed repository missing") + repo = repo.resolve(strict=True) + if _git(repo, "rev-parse", "--git-dir", check=False).returncode: + raise SurvivorUnavailable("survivor_unavailable: landed path is not a repository") + if not _independent_storage(repo, workspace): + # A workspace/board/temp tree is itself disposable: pointing `landed` + # at one would let the capture authorise deleting its own only copy. + raise SurvivorUnavailable("survivor_unavailable: landed repository is not a durable tree") + resolved = _git(repo, "rev-parse", "--verify", f"{sha}^{{commit}}", check=False) + if resolved.returncode: + raise SurvivorUnavailable("survivor_unavailable: landed commit not in repository") + sha = resolved.stdout.decode().strip() + if _git(repo, "merge-base", "--is-ancestor", sha, "HEAD", check=False).returncode: + raise SurvivorUnavailable("survivor_unavailable: landed commit not reachable from HEAD") + record = {"repository": str(repo), "sha": sha, "matched_by": "canonical", + "branch": _git(repo, "rev-parse", "--abbrev-ref", "HEAD", + check=False).stdout.decode().strip() or "HEAD"} + published = list(_published_refs(repo, workspace)) + ref = _remote_survivor(repo, sha, published) + if ref: + record["published"] = {"remote": ref["remote"], "branch": ref["branch"], + "sha": ref["sha"], "matched_by": "sha"} + else: + hint = _content_advisory(repo, sha, published) + if hint: + # Advisory: same diff on the mirror. Not why we accepted. + record["published"] = {"remote": hint["remote"], "branch": hint["branch"], + "sha": hint["sha"], "matched_by": "patch-id", + "advisory": True} + verified.append(record) + + workspace_repos = _repos(workspace) + if not workspace_repos or _loose_files(workspace, workspace_repos): + raise SurvivorUnavailable("survivor_unavailable: landed workspace has uncaptured files") + used_entries = set() + for repo in workspace_repos: + status = _git(repo, "status", "--porcelain", "--untracked-files=all", check=False) + if status.returncode or status.stdout: + raise SurvivorUnavailable("survivor_unavailable: landed workspace is not clean") + workspace_head = _git(repo, "rev-parse", "--verify", "HEAD^{commit}", check=False) + if workspace_head.returncode: + raise SurvivorUnavailable("survivor_unavailable: workspace repository has no commit") + binding = None + for index, record in enumerate(verified): + matched_by = _landed_contains_history(repo, Path(record["repository"]), record["sha"]) + if matched_by: + binding = (index, record, matched_by) + break + if binding is None: + raise SurvivorUnavailable( + "survivor_unavailable: landed commit does not contain workspace history" + ) + index, record, matched_by = binding + used_entries.add(index) + record.setdefault("workspace_repositories", []).append({ + "repository": str(repo.relative_to(workspace)), + "head": workspace_head.stdout.decode().strip(), + "matched_by": matched_by, + }) + if used_entries != set(range(len(verified))): + raise SurvivorUnavailable("survivor_unavailable: landed claim is unrelated to workspace") + return verified + + def _base(repo, published): # The nearest published ancestor of HEAD is a BOUNDARY commit of # `rev-list HEAD ^`: git stops there precisely because the @@ -701,11 +1095,25 @@ def _capture(repo, key, workspace): Extracted verbatim from `preserve`'s loop so the object-reading steps sit inside a single `try` the caller can classify. Behaviour is unchanged. """ - published = list(_published_refs(repo, workspace)) + storage_cache = {} + published = list(_published_refs(repo, workspace, storage_cache)) head = _git(repo, "rev-parse", "--verify", "HEAD", check=False) dirty = _git(repo, "status", "--porcelain", "--untracked-files=all").stdout if not dirty and head.returncode == 0: - ref = _remote_survivor(repo, head.stdout.decode().strip(), published) + sha = head.stdout.decode().strip() + # A rewriting mirror republishes the same content under a new sha, so + # sha equality can never hold for a home clone. The fallback is the + # LIVE CANONICAL TREE the workspace was cloned from — never a patch-id + # hit, which is diff identity only. + ref = (_remote_survivor(repo, sha, published) + or _canonical_survivor(repo, sha, workspace, storage_cache)) + if ref and ref.get("matched_by") == "canonical": + hint = _content_advisory(repo, sha, published) + if hint: + ref = dict(ref, mirror_hint={ + "remote": hint["remote"], "branch": hint["branch"], + "sha": hint["sha"], "matched_by": "patch-id", "advisory": True, + }) if ref: return ref, None, None base = _base(repo, published) @@ -1214,6 +1622,31 @@ def _external(conn, task_id, metadata, evidence, urls, explicit, *, discover, cl _PATCH_KEYS = ("path", "sha256", "bytes", "sidecar") +def _carries_recovery_artifact(survivor): + """Does this row's OWN sidecar stand beside recoverable bytes? + + `sidecar` alone does not make a row a recovery pointer. Two kinds write an + `implementation.json` that is pure METADATA -- `landed` (which repo/sha a + live canonical tree holds the work at) and the canonical `ref` arm (which + LIVE tree holds a sha the published remote does not carry). Neither stores + a byte of the implementation: the durable copy is the named repository. + + A genuine recovery row does store bytes, and always alongside a pointer to + them -- `path` for a concatenated `implementation.patch`, `bundles` for + git bundles. Discriminating on the ARTIFACT rather than on the `kind` + label covers both new metadata-only kinds at once and keeps covering any + later one, and it cannot be fooled by a row `_unshrunk` has already + relabelled. + + Getting this wrong corrupts the durable recovery index rather than merely + mislabelling it: a metadata sidecar treated as a patch makes `_unshrunk` + relabel the row `kind: "patch"` with `notice: "NOT PUSHED"` and a + `patches` list of JSON manifests holding no patch bytes, after the + workspace has already been deleted (2026-09-23 review of #796). + """ + entry = survivor or {} + return bool(entry.get("path") or entry.get("bundles")) + def _patch_pointers(survivor): """Every stored patch a survivor row points at, top-level slot first. @@ -1224,12 +1657,22 @@ def _patch_pointers(survivor): must retain a patch from an earlier capture as well keeps the extras in `patches`. Reading through one accessor keeps every consumer -- the non-shrink guard and the sidecar manifest scan -- seeing all of them. + + The top-level slot is gated by :func:`_carries_recovery_artifact` so a + metadata-only `landed`/canonical-`ref` sidecar is not mistaken for stored + patch bytes. Entries already displaced into `patches` are NOT re-gated: + they were vetted when they were displaced, and a bundle row's pointer + legitimately carries only a `sidecar` once its `bundles` have been merged + into the fresh row. """ pointers = [] - for entry in ((survivor or {}), *((survivor or {}).get("patches") or ())): + row = survivor if isinstance(survivor, dict) else {} + for index, entry in enumerate((row, *(row.get("patches") or ()))): if not isinstance(entry, dict): continue pointer = {key: entry.get(key) for key in _PATCH_KEYS} + if index == 0 and not _carries_recovery_artifact(entry): + continue if pointer["path"] or pointer["sidecar"]: pointers.append(pointer) return pointers @@ -1461,6 +1904,48 @@ def preserve(conn, task_id, metadata=None, *, cleanup=False, workspace=None, ) return None workspace = workspace.resolve(strict=True) + landed = (metadata or {}).get("landed") + if cleanup and not landed and previous and previous.get("kind") == "landed": + # Revalidate the recorded live commits before removing the workspace. + landed = [{"repo_path": entry["repository"], "sha": entry["sha"]} + for entry in previous["landed"]] + if landed: + if not isinstance(landed, list): + raise SurvivorUnavailable("survivor_unavailable: landed must be a list") + survivor = {"kind": "landed", "landed": _verify_landed(landed, workspace)} + present = {entry["repository"] for claim in survivor["landed"] + for entry in claim["workspace_repositories"]} + missing = set(bases) - present + if missing: + # A landed commit binds only repositories still on disk. A + # replacement root can ignore the files of a vanished child + # repository and appear clean while its work is still here. + refs = {} + if cleanup and _reusable(previous): + refs = {ref["repository"]: ref for ref in (previous or {}).get("refs", ()) + if isinstance(ref, dict) and ref.get("repository") in missing} + if explicit: + if None in explicit: + if len(missing) != 1 or len(explicit) != 1: + raise SurvivorUnavailable( + "survivor_unavailable: qualify each missing repository survivor" + ) + explicit = {next(iter(missing)): explicit[None]} + if set(explicit) - missing: + raise SurvivorUnavailable( + "survivor_unavailable: operator survivors name repositories still present" + ) + refs.update({key: dict(ref, repository=key) for key, ref in explicit.items()}) + if missing - refs.keys(): + raise SurvivorUnavailable( + f"survivor_unavailable: recorded repository missing; {_ext.HINT}" + ) + survivor["refs"] = [refs[key] for key in sorted(missing)] + survivor["sidecar"] = _store( + conn, task_id, "implementation.json", + json.dumps(survivor, sort_keys=True).encode(), "application/json", + )["path"] + return _record(conn, task_id, survivor, previous) # Git's registries answer "is there a nested repository here" in O(refs). # For the `dir`-workspace-rooted-at-a-home shape this is the whole fix: # that tree ALWAYS reaches the nested-repository refusal below, so the @@ -1632,6 +2117,11 @@ def preserve(conn, task_id, metadata=None, *, cleanup=False, workspace=None, repositories.append({"repository": key, "base_sha": base}) if repos and not bundles and len(refs) == len(repos) + len(carried): survivor = {"kind": "ref", "refs": refs} + if any(ref.get("matched_by") == "canonical" for ref in refs): + survivor["sidecar"] = _store( + conn, task_id, "implementation.json", + json.dumps(survivor, sort_keys=True).encode(), "application/json", + )["path"] elif patches or bundles: data = b"".join(patches) survivor = {"kind": "bundle" if bundles else "patch", "notice": "NOT PUSHED", diff --git a/tests/hermes_cli/test_kanban_survivor_landed.py b/tests/hermes_cli/test_kanban_survivor_landed.py new file mode 100644 index 0000000000000..361f2bf0f5480 --- /dev/null +++ b/tests/hermes_cli/test_kanban_survivor_landed.py @@ -0,0 +1,315 @@ +"""Survivors for workspaces whose only remote REWRITES the shas it publishes. + +A home clone (`~/.hermes` mirrored to ANG-Ventures/hermes-home by the "isolated +remote sync") can never match `_remote_survivor`: the mirror republishes every +commit under a new sha. Before this, such a workspace fell through to the +patch/bundle path and a 94 MB home tree always tripped +``KANBAN_ATTACHMENT_MAX_BYTES`` — blocking completion of work that was committed +and running (2026-09-20, t_e69d693a). Under the round-2 ruling, patch-id is only +an advisory diff match; live canonical reachability is the landed authority. + +TEST-REPIN: fdedf3fc2e6a21e808b0b8b9cd94557b46856c72 ANG-Ventures/hermes-agent#795 — merged survivor authority supersedes artifact-shape and inert mutation assertions. +""" +import json +import subprocess +from pathlib import Path + +import pytest + +from hermes_cli import kanban_db as kb + + +def git(repo, *args): + return subprocess.run( + ["git", "-C", str(repo), *args], stdin=subprocess.DEVNULL, + capture_output=True, check=True, + ).stdout.decode().strip() + + +@pytest.fixture +def board(tmp_path, monkeypatch): + import hermes_cli.kanban_survivor as survivor + monkeypatch.setattr(survivor, "_temporary_roots", lambda: [tmp_path / "temporary"]) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + with kb.connect_closing() as conn: + yield conn + + +def commit(repo, name, text, message): + (repo / name).write_text(text) + git(repo, "add", "-A") + git(repo, "commit", "-m", message) + return git(repo, "rev-parse", "HEAD") + + +def restore_artifact(survivor, tmp_path, published): + restored = tmp_path / "artifact-restored" + if survivor["kind"] == "bundle": + git(tmp_path, "clone", survivor["bundles"][0]["path"], str(restored)) + else: + git(tmp_path, "clone", "-b", "main", str(published), str(restored)) + manifest = json.loads(Path(survivor["sidecar"]).read_text()) + git(restored, "checkout", "--detach", manifest["repositories"][0]["base_sha"]) + git(restored, "apply", survivor["path"]) + return restored + + +def rewriting_mirror(repo, mirror, *, branch="main"): + """Publish repo's history to `mirror` under DIFFERENT shas, same content. + + This fixture rewrites identity only. The live sync also changes trees; + test_kanban_survivor_live_tree.py covers that stricter case. + """ + git(repo, "init", "--bare", str(mirror)) + staging = mirror.parent / f"{mirror.stem}-staging" + git(mirror.parent, "clone", "--no-local", "-b", branch, str(repo), str(staging)) + git(staging, "config", "user.name", "Mirror Sync") + git(staging, "config", "user.email", "sync@example.invalid") + git(staging, "-c", "rebase.instructionFormat=%s", "filter-branch", "--force", + "--env-filter", + 'export GIT_COMMITTER_NAME="Mirror Sync";' + 'export GIT_COMMITTER_EMAIL="sync@example.invalid";' + 'export GIT_COMMITTER_DATE="2001-02-03T04:05:06Z"', + "--", "--all") + git(staging, "push", "--force", str(mirror), f"HEAD:refs/heads/{branch}") + return git(staging, "rev-parse", "HEAD") + + +def home_clone_task(conn, tmp_path, *, publish=True): + """A workspace that is a clone of a repo published through a rewriting mirror.""" + source = tmp_path / "home-source" + source.mkdir() + git(source, "init", "-b", "main") + git(source, "config", "user.name", "Test") + git(source, "config", "user.email", "test@example.invalid") + commit(source, "code.py", "value = 1\n", "base") + tid = kb.create_task(conn, title="home clone work") + ws = kb.resolve_workspace(kb.get_task(conn, tid)) + ws.parent.mkdir(parents=True, exist_ok=True) + git(tmp_path, "clone", "--no-local", str(source), str(ws)) + git(ws, "config", "user.name", "Worker") + git(ws, "config", "user.email", "worker@example.invalid") + git(ws, "remote", "remove", "origin") + local = commit(ws, "code.py", "value = 2\n", "implementation") + mirror = tmp_path / "hermes-home.git" + if publish: + # The worker's commit reaches the mirror, but with a rewritten sha. + git(source, "fetch", str(ws), "main") + git(source, "reset", "--hard", "FETCH_HEAD") + remote_sha = rewriting_mirror(source, mirror) + assert remote_sha != local + else: + git(source, "init", "--bare", str(mirror)) + git(source, "push", str(mirror), "HEAD:refs/heads/main") + remote_sha = None + git(ws, "remote", "add", "origin", str(mirror)) + kb.set_workspace_path(conn, tid, ws) + return tid, ws, local, remote_sha + + +def test_rewritten_mirror_alone_requires_a_recovery_bundle(board, tmp_path): + tid, ws, local, remote_sha = home_clone_task(board, tmp_path) + assert kb.complete_task(board, tid, metadata={"changed_files": ["code.py"]}) + survivor = kb.latest_run(board, tid).metadata["survivor"] + # Round-2 ruling: a rewritten diff is only an advisory, not a survivor. + assert survivor["kind"] == "bundle", survivor + manifest = json.loads(Path(survivor["sidecar"]).read_text()) + assert manifest["bundles"] + assert not ws.exists() + # The published content really is the implementation. + restored = tmp_path / "restored" + git(tmp_path, "clone", "-b", "main", str(tmp_path / "hermes-home.git"), str(restored)) + assert (restored / "code.py").read_text() == "value = 2\n" + + +def test_unpublished_commit_still_fails_closed_on_a_rewriting_mirror(board, tmp_path): + """The mirror is reachable but does NOT carry this content: keep the code.""" + tid, ws, local, _ = home_clone_task(board, tmp_path) + commit(ws, "code.py", "value = 3\n", "never published") + assert kb.complete_task(board, tid, metadata={"changed_files": ["code.py"]}) + survivor = kb.latest_run(board, tid).metadata["survivor"] + assert survivor["kind"] != "ref-by-content", survivor + assert survivor["kind"] in {"patch", "bundle"} + restored = restore_artifact(survivor, tmp_path, tmp_path / "hermes-home.git") + assert (restored / "code.py").read_text() == "value = 3\n" + mirror = tmp_path / "mirror-control" + git(tmp_path, "clone", "-b", "main", str(tmp_path / "hermes-home.git"), str(mirror)) + assert (mirror / "code.py").read_text() == "value = 2\n" + + +def test_content_match_requires_the_patch_id_check(board, tmp_path, monkeypatch): + """Mutation guard: patch-id is what creates the advisory mirror hint.""" + import hermes_cli.kanban_survivor as survivor_mod + tid, ws, local, remote_sha = home_clone_task(board, tmp_path) + published = list(survivor_mod._published_refs(ws, ws)) + hint = survivor_mod._content_advisory(ws, local, published) + assert hint and hint["sha"] == remote_sha + monkeypatch.setattr(survivor_mod, "_patch_id", lambda *a, **k: None) + assert survivor_mod._content_advisory(ws, local, published) is None + + +def test_content_scan_keeps_credential_bearing_url_out_of_fetch_argv(board, tmp_path, monkeypatch): + import hermes_cli.kanban_survivor as survivor_mod + tid, ws, local, _ = home_clone_task(board, tmp_path) + raw_url = "https://user:secret@example.invalid/private.git" + real_git = survivor_mod._git + fetch_calls = [] + + def recording_git(repo, *args, **kwargs): + if args[:3] == ("remote", "get-url", "origin"): + return subprocess.CompletedProcess([], 0, raw_url.encode(), b"") + if "fetch" in args: + fetch_calls.append((args, kwargs.get("env", {}))) + return subprocess.CompletedProcess([], 1, b"", b"") + return real_git(repo, *args, **kwargs) + + monkeypatch.setattr(survivor_mod, "_git", recording_git) + assert survivor_mod._content_advisory( + ws, local, [{"remote": "origin", "branch": "main", "sha": "0" * 40}], + ) is None + assert len(fetch_calls) == 1 + args, env = fetch_calls[0] + assert all(raw_url not in str(arg) for arg in args) + assert env["KANBAN_FETCH_URL"] == raw_url + assert "candidate" in args + + +def test_patch_id_is_stable_across_a_sha_rewrite(board, tmp_path): + """The advisory signal survives an identity-only rewrite.""" + from hermes_cli.kanban_survivor import _patch_id + tid, ws, local, remote_sha = home_clone_task(board, tmp_path) + mirror_work = tmp_path / "mirror-work" + git(tmp_path, "clone", "-b", "main", str(tmp_path / "hermes-home.git"), str(mirror_work)) + assert git(mirror_work, "rev-parse", "HEAD") == remote_sha != local + assert _patch_id(ws, local) == _patch_id(mirror_work, remote_sha) is not None + + +def test_landed_escape_accepts_a_verified_published_commit(board, tmp_path): + """`landed` lets a worker point at a repo the workspace merely mirrors.""" + tid, ws, local, remote_sha = home_clone_task(board, tmp_path) + # A second clone standing in for the live home tree the work landed in. + live = tmp_path / "live-home" + git(tmp_path, "clone", "-b", "main", str(tmp_path / "hermes-home.git"), str(live)) + landed_sha = git(live, "rev-parse", "HEAD") + assert kb.complete_task(board, tid, metadata={ + "changed_files": ["code.py"], + "landed": [{"repo_path": str(live), "sha": landed_sha}], + }) + survivor = kb.latest_run(board, tid).metadata["survivor"] + assert survivor["kind"] == "landed", survivor + entry = survivor["landed"][0] + assert entry["sha"] == landed_sha + assert entry["published"]["sha"] == landed_sha + assert entry["matched_by"] == "canonical" + assert json.loads(Path(survivor["sidecar"]).read_text())["landed"][0]["sha"] == landed_sha + assert not ws.exists() + + +def test_landed_escape_accepts_a_content_match_on_a_rewriting_mirror(board, tmp_path): + """The real hermes-home shape: local sha absent from the mirror, content present.""" + tid, ws, local, remote_sha = home_clone_task(board, tmp_path) + live = tmp_path / "live-home-local-shas" + git(tmp_path, "clone", "--no-local", "-b", "main", str(ws), str(live)) + git(live, "remote", "set-url", "origin", str(tmp_path / "hermes-home.git")) + assert git(live, "rev-parse", "HEAD") == local + assert remote_sha != local + assert kb.complete_task(board, tid, metadata={ + "changed_files": ["code.py"], + "landed": [{"repo_path": str(live), "sha": local}], + }) + entry = kb.latest_run(board, tid).metadata["survivor"]["landed"][0] + assert entry["matched_by"] == "canonical" + assert entry["sha"] == local + assert entry["published"]["sha"] == remote_sha + assert entry["published"]["advisory"] is True + + +@pytest.mark.parametrize("break_it", ["disposable", "unreachable", "missing_repo", "no_such_sha"]) +def test_landed_escape_fails_closed_on_every_unverifiable_claim(board, tmp_path, break_it): + """A `landed` claim authorises deleting the code; it must never be taken on trust.""" + tid, ws, local, remote_sha = home_clone_task(board, tmp_path) + live = tmp_path / "live-home" + git(tmp_path, "clone", "-b", "main", str(tmp_path / "hermes-home.git"), str(live)) + git(live, "config", "user.name", "Live") + git(live, "config", "user.email", "live@example.invalid") + if break_it == "disposable": + sha = local + repo_path = str(ws) + elif break_it == "unreachable": + # PUBLISHED but on an abandoned branch HEAD cannot reach: not this line + # of work. Publication alone must not satisfy the claim, so this commit + # is pushed to the mirror — only the reachability check can reject it. + git(live, "checkout", "-q", "-b", "sidebranch") + sha = commit(live, "side.py", "side = True\n", "orphan work") + git(live, "push", str(tmp_path / "hermes-home.git"), "HEAD:refs/heads/sidebranch") + git(live, "checkout", "-q", "main") + repo_path = str(live) + elif break_it == "missing_repo": + sha, repo_path = git(live, "rev-parse", "HEAD"), str(tmp_path / "not-a-repo") + else: + sha, repo_path = "0" * 40, str(live) + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata={ + "changed_files": ["code.py"], + "landed": [{"repo_path": repo_path, "sha": sha}], + }) + assert kb.get_task(board, tid).status != "done" + assert ws.exists() + assert any(e.kind == "workspace_held" for e in kb.list_events(board, tid)) + + +def test_landed_escape_rejects_a_malformed_claim(board, tmp_path): + tid, ws, local, remote_sha = home_clone_task(board, tmp_path) + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata={ + "changed_files": ["code.py"], "landed": [{"sha": local}], + }) + assert ws.exists() + + +def test_content_scan_does_not_match_the_shallow_boundary_commit(board, tmp_path): + """The deepest fetched commit diffs against nothing; it must never match. + + `git diff-tree --root` on a shallow boundary yields the entire tree as an + addition, which collides with any unpublished ROOT commit carrying the same + content — blessing it as published and authorising deletion of the only + copy. The boundary must be fetched (so real commits are complete) but never + scanned. + """ + import shutil + from hermes_cli.kanban_survivor import ( + _content_advisory, _published_refs, _CONTENT_SCAN_DEPTH, + ) + source = tmp_path / "deep-source" + source.mkdir() + git(source, "init", "-b", "main") + git(source, "config", "user.name", "Test") + git(source, "config", "user.email", "test@example.invalid") + for i in range(_CONTENT_SCAN_DEPTH + 6): + commit(source, f"file{i}.py", f"n = {i}\n", f"commit {i}") + mirror = tmp_path / "deep-mirror.git" + git(source, "init", "--bare", str(mirror)) + git(source, "push", str(mirror), "HEAD:refs/heads/main") + # The commit the probe's shallow fetch will land on as its boundary. + boundary = git(source, "rev-parse", f"HEAD~{_CONTENT_SCAN_DEPTH}") + + # An UNPUBLISHED local root commit whose tree equals the boundary's tree. + # Its --root diff is byte-identical to the boundary's would-be --root diff. + local = tmp_path / "boundary-twin" + local.mkdir() + git(source, "worktree", "add", "--detach", str(tmp_path / "at-boundary"), boundary) + for item in (tmp_path / "at-boundary").iterdir(): + if item.name != ".git": + (shutil.copytree if item.is_dir() else shutil.copy2)(item, local / item.name) + git(local, "init", "-b", "main") + git(local, "config", "user.name", "Test") + git(local, "config", "user.email", "test@example.invalid") + git(local, "add", "-A") + git(local, "commit", "-m", "unpublished root with the boundary's content") + git(local, "remote", "add", "origin", str(mirror)) + head = git(local, "rev-parse", "HEAD") + assert head != boundary + + # The verdict that matters: this content is NOT published, so no survivor. + assert _content_advisory(local, head, list(_published_refs(local, local))) is None diff --git a/tests/hermes_cli/test_kanban_survivor_live_tree.py b/tests/hermes_cli/test_kanban_survivor_live_tree.py new file mode 100644 index 0000000000000..e3bce95fa5e81 --- /dev/null +++ b/tests/hermes_cli/test_kanban_survivor_live_tree.py @@ -0,0 +1,967 @@ +"""A home-clone workspace closes against the LIVE tree, never against the mirror. + +Round-2 contract (Apollo ruling, 2026-09-20, superseding this card's original +spec). The hermes-home "isolated remote sync" rewrites TREES, not just shas: the +card's own evidence pair 9f25d7cce / e747d18db shares a `git patch-id` while the +trees differ by 58 files. So: + + * "published on the mirror" is unsatisfiable as a content claim — do not chase + it, and never let a patch-id hit authorise deleting a workspace; + * the durable target is the LIVE CANONICAL TREE the workspace was cloned from + (a local repo outside every kanban/temp root), which the fleet-backup tier + covers; + * patch-id survives only as an ADVISORY sidecar annotation. + +These tests pin that split: the live tree is the deletion authority, and the +reviewer's collision reproducer (same diff, different base, an unpublished +commit) must fail closed. + +TEST-REPIN: fdedf3fc2e6a21e808b0b8b9cd94557b46856c72 ANG-Ventures/hermes-agent#795 — merged survivor authority supersedes unconditional landed acceptance. +""" +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from hermes_cli import kanban_db as kb + + +def git(repo, *args): + return subprocess.run( + [str(a) for a in ["git", "-C", str(repo), *args]], stdin=subprocess.DEVNULL, + capture_output=True, check=True, + ).stdout.decode().strip() + + +def commit(repo, name, text, message): + (repo / name).write_text(text) + git(repo, "add", "-A") + git(repo, "commit", "-m", message) + return git(repo, "rev-parse", "HEAD") + + +def init(repo, *, bare=False): + repo.mkdir(parents=True, exist_ok=True) + git(repo, "init", *(["--bare"] if bare else []), "-b", "main") + if not bare: + git(repo, "config", "user.name", "Test") + git(repo, "config", "user.email", "test@example.invalid") + return repo + + +def restore_artifact(survivor, tmp_path, published): + """Restore either recovery shape and return its checkout.""" + restored = tmp_path / "restored" + if survivor["kind"] == "bundle": + git(tmp_path, "clone", survivor["bundles"][0]["path"], str(restored)) + else: + git(tmp_path, "clone", "-b", "main", str(published), str(restored)) + manifest = json.loads(Path(survivor["sidecar"]).read_text()) + git(restored, "checkout", "--detach", manifest["repositories"][0]["base_sha"]) + git(restored, "apply", survivor["path"]) + return restored + + +def test_landed_refuses_missing_recorded_repo_even_when_replacement_ignores_its_files(board, tmp_path): + import hermes_cli.kanban_survivor as survivor_mod + + tid = kb.create_task(board, title="missing recorded implementation") + ws = kb.resolve_workspace(kb.get_task(board, tid)) + original = init(ws / "a") + commit(original, "lost_impl.py", "unpublished original implementation\n", "original") + kb.set_workspace_path(board, tid, ws) + survivor_mod.record_baseline(board, tid, ws) + assert set(survivor_mod._state(board, tid)[0]) == {"a"} + + shutil.rmtree(original / ".git") + init(ws) + commit(ws, ".gitignore", "a/\n", "ignore original") + commit(ws, "replacement.txt", "replacement\n", "replacement") + live = tmp_path / "live-replacement" + git(tmp_path, "clone", "--no-local", str(ws), str(live)) + assert not (live / "a" / "lost_impl.py").exists() + assert git(ws, "status", "--porcelain", "--untracked-files=all") == "" + + with pytest.raises(ValueError, match="recorded repository missing"): + kb.complete_task(board, tid, metadata={ + "changed_files": ["a/lost_impl.py", "replacement.txt"], + "landed": [{"repo_path": str(live), "sha": git(live, "rev-parse", "HEAD")}], + }) + assert kb.get_task(board, tid).status != "done" + assert (original / "lost_impl.py").read_text() == "unpublished original implementation\n" + assert survivor_mod._state(board, tid)[1] + + +def test_landed_covers_all_recorded_repositories_with_independent_live_trees(board, tmp_path): + import hermes_cli.kanban_survivor as survivor_mod + + tid = kb.create_task(board, title="two landed repositories") + ws = kb.resolve_workspace(kb.get_task(board, tid)) + entries = [] + for key in ("a", "b"): + repo = init(ws / key) + sha = commit(repo, f"{key}.py", f"implementation {key}\n", "implementation") + live = tmp_path / f"live-{key}" + git(tmp_path, "clone", "--no-local", str(repo), str(live)) + entries.append({"repo_path": str(live), "sha": sha}) + kb.set_workspace_path(board, tid, ws) + survivor_mod.record_baseline(board, tid, ws) + assert set(survivor_mod._state(board, tid)[0]) == {"a", "b"} + + assert kb.complete_task(board, tid, metadata={ + "changed_files": ["a/a.py", "b/b.py"], "landed": entries, + }) + assert kb.latest_run(board, tid).metadata["survivor"]["kind"] == "landed" + assert not ws.exists() + + +@pytest.mark.parametrize("unbound", [False, True], ids=["bound-reclaims", "unbound-holds"]) +def test_landed_missing_repo_explicit_rescue_requires_bound_ref_for_cleanup( + board, tmp_path, monkeypatch, unbound, +): + """A vanished recorded repo needs its own authority at completion AND reclamation.""" + import hermes_cli.kanban_survivor as survivor_mod + + tid = kb.create_task(board, title="missing child with explicit rescue") + task = kb.get_task(board, tid) + assert task is not None + ws = kb.resolve_workspace(task) + child = init(ws / "a") + commit(child, "lost_impl.py", "unpublished child bytes\n", "implementation") + kb.set_workspace_path(board, tid, ws) + survivor_mod.record_baseline(board, tid, ws) + assert set(survivor_mod._state(board, tid)[0]) == {"a"} + + shutil.rmtree(child / ".git") + init(ws) + commit(ws, ".gitignore", "a/\n", "ignore child") + sha = commit(ws, "replacement.txt", "replacement\n", "replacement") + live = tmp_path / "live-replacement" + git(tmp_path, "clone", "--no-local", str(ws), str(live)) + assert not (live / "a" / "lost_impl.py").exists() + assert git(ws, "status", "--porcelain", "--untracked-files=all") == "" + + real_run = subprocess.run + + def remote_pr(args, **kwargs): + if args[0] == "gh": + payload = {"state": "OPEN", "headRefOid": "a1" * 20, + "mergeCommit": None, "headRefName": f"operator/{tid}-landed", + "title": "", "body": ""} + return subprocess.CompletedProcess(args, 0, json.dumps(payload).encode(), b"") + return real_run(args, **kwargs) + + monkeypatch.setattr(subprocess, "run", remote_pr) + assert kb.complete_task(board, tid, metadata={ + "changed_files": ["a/lost_impl.py", "replacement.txt"], + "landed": [{"repo_path": str(live), "sha": sha}], + }, survivor_pr="example/project#68", survivor_unbound=unbound) + run = kb.latest_run(board, tid) + assert run is not None and run.metadata is not None + receipt = run.metadata["survivor"] + assert receipt["kind"] == "landed" + assert len(receipt["refs"]) == 1 + assert receipt["refs"][0]["repository"] == "a" + assert bool(receipt["refs"][0].get("unbound")) is unbound + if unbound: + assert ws.exists() + assert (child / "lost_impl.py").read_text() == "unpublished child bytes\n" + held = survivor_mod._state(board, tid)[1] + assert held is not None and "recorded repository missing" in held + else: + assert not ws.exists() + assert survivor_mod._state(board, tid)[1] is None + + +@pytest.fixture +def board(tmp_path, monkeypatch): + import hermes_cli.kanban_survivor as survivor + monkeypatch.setattr(survivor, "_temporary_roots", lambda: [tmp_path / "temporary"]) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + with kb.connect_closing() as conn: + yield conn + + +def rewriting_mirror(repo, mirror, *, branch="main"): + """Publish `repo` under DIFFERENT shas AND a DIFFERENT tree. + + This is the real hermes-home shape the ruling names: the sync does not merely + re-date commits, it republishes a tree that is not a faithful copy (the live + pair differs by 58 files). It must REWRITE history, not append to it — an + appended "drop the file" commit would leave the local head an ANCESTOR of the + mirror tip, which `_remote_survivor` accepts, making every test below vacuous. + The diff of the tip commit is preserved across the rewrite, so the patch-id + still matches — which is exactly why patch-id must stay advisory. + """ + init(mirror, bare=True) + staging = mirror.parent / f"{mirror.stem}-staging" + git(mirror.parent, "clone", "--no-local", "-b", branch, str(repo), str(staging)) + git(staging, "config", "user.name", "Mirror Sync") + git(staging, "config", "user.email", "sync@example.invalid") + subprocess.run( + ["git", "-C", str(staging), "filter-branch", "--force", + "--index-filter", "git rm -q --cached --ignore-unmatch local-only.txt", + "--env-filter", + 'export GIT_COMMITTER_NAME="Mirror Sync";' + 'export GIT_COMMITTER_EMAIL="sync@example.invalid";' + 'export GIT_COMMITTER_DATE="2001-02-03T04:05:06Z"', + "--", "--all"], + stdin=subprocess.DEVNULL, capture_output=True, check=True, + env={**os.environ, "FILTER_BRANCH_SQUELCH_WARNING": "1"}, + ) + git(staging, "push", "--force", str(mirror), f"HEAD:refs/heads/{branch}") + return git(staging, "rev-parse", "HEAD") + + +def home_clone(board, tmp_path, *, live_remote=True): + """A workspace whose DURABLE remote is a rewriting mirror. + + Shape, matching the live card scenario (t_e69d693a): + + * ``origin`` -> the rewriting mirror. This is the only *durable* remote + (``_durable_remote`` only accepts a local path when it is ``origin``), + and it republishes every commit under a new sha over a different tree, + so ``_remote_survivor`` can never match. + * ``live`` -> the canonical tree the clone came from, when + ``live_remote`` is set. Not durable (not ``origin``), so only + ``_canonical_survivor`` can see it. This is the auto-escape. + + ``live_remote=False`` is the REAL hermes-home shape measured on the live + box: ``~/.hermes/kanban/workspaces/t_e69d693a/code`` has exactly two + remotes, both ``git@github.com:ANG-Ventures/hermes-home.git``, and no local + path remote at all — so ``_canonical_repos`` is empty there and the explicit + ``landed`` claim is the only escape. The tests below pin both halves. + """ + live = init(tmp_path / "live-home") + commit(live, "code.py", "value = 1\n", "base") + (live / "local-only.txt").write_text("only here\n") + git(live, "add", "-A") + git(live, "commit", "-m", "local-only content") + + tid = kb.create_task(board, title="home clone work") + ws = kb.resolve_workspace(kb.get_task(board, tid)) + ws.parent.mkdir(parents=True, exist_ok=True) + git(tmp_path, "clone", "--no-local", str(live), str(ws)) + git(ws, "config", "user.name", "Worker") + git(ws, "config", "user.email", "worker@example.invalid") + head = commit(ws, "code.py", "value = 2\n", "implementation") + + # The work lands in the live tree (this is what "landed" means here). + git(live, "fetch", str(ws), "main") + git(live, "reset", "--hard", "FETCH_HEAD") + mirror = tmp_path / "hermes-home.git" + mirror_sha = rewriting_mirror(live, mirror) + git(live, "remote", "add", "origin", str(mirror)) + + git(ws, "remote", "set-url", "origin", str(mirror)) + if live_remote: + git(ws, "remote", "add", "live", str(live)) + kb.set_workspace_path(board, tid, ws) + return tid, ws, live, head, mirror_sha + + +def test_the_mirror_is_not_a_faithful_copy(board, tmp_path): + """Premise check: if this stops holding, the tests below are vacuous.""" + from hermes_cli.kanban_survivor import _patch_id, _published_refs, _remote_survivor + + _, ws, live, head, mirror_sha = home_clone(board, tmp_path) + probe = tmp_path / "mirror-probe" + git(tmp_path, "clone", "-b", "main", str(tmp_path / "hermes-home.git"), str(probe)) + assert mirror_sha != head + assert git(live, "rev-parse", f"{head}^{{tree}}") != git(probe, "rev-parse", f"{mirror_sha}^{{tree}}") + assert (live / "local-only.txt").exists() + assert not (probe / "local-only.txt").exists() + # REWRITE, not append: the sha path must be genuinely unreachable here, or + # every test below passes for the wrong reason. + assert _remote_survivor(ws, head, list(_published_refs(ws, ws))) is None + # The diff still matches — the advisory signal survives, the tree claim does not. + assert _patch_id(ws, head) == _patch_id(probe, mirror_sha) is not None + + +def test_home_clone_closes_against_the_live_tree(board, tmp_path): + """The whole point of the card: this workspace can now complete.""" + tid, ws, live, head, mirror_sha = home_clone(board, tmp_path) + assert kb.complete_task(board, tid, metadata={"changed_files": ["code.py"]}) + survivor = kb.latest_run(board, tid).metadata["survivor"] + assert survivor["kind"] == "ref", survivor + ref = survivor["refs"][0] + assert ref["matched_by"] == "canonical" + assert ref["repository_path"] == str(live.resolve()) + assert ref["sha"] == head + assert not ws.exists() + # The live tree really carries the work the workspace held. + assert git(live, "rev-parse", "HEAD") == head + # Advisory only: the mirror hint never stands alone as the accept reason. + assert ref.get("mirror_hint", {}).get("advisory") is True + assert json.loads(Path(survivor["sidecar"]).read_text())["refs"][0]["sha"] == head + + +def test_rewritten_mirror_over_cap_uses_live_tree_instead(board, tmp_path, monkeypatch): + """The recovery bundle exceeds the cap; a bound live ref fits.""" + import hermes_cli.kanban_survivor as survivor_mod + + tid, ws, live, head, _ = home_clone(board, tmp_path) + bundle = survivor_mod._snapshot(ws, None, "") + assert len(bundle) > 750, "fixture must exceed the limit before asserting the escape" + monkeypatch.setattr(kb, "KANBAN_ATTACHMENT_MAX_BYTES", 750) + + assert kb.complete_task(board, tid, metadata={"changed_files": ["code.py"]}) + recorded = kb.latest_run(board, tid).metadata["survivor"] + assert recorded["kind"] == "ref" + assert recorded["refs"][0]["repository_path"] == str(live.resolve()) + assert recorded["refs"][0]["sha"] == head + assert not ws.exists() + + +def test_no_survivor_kind_permits_deletion_by_patch_id(board, tmp_path): + """`ref-by-content` is gone: a diff match is never a deletion authority. + + This is the REAL hermes-home shape (`live_remote=False`): the only remote is + the rewriting mirror, exactly as measured on + `~/.hermes/kanban/workspaces/t_e69d693a/code`. Patch-id matches the mirror, + and that must buy nothing. + """ + import hermes_cli.kanban_survivor as survivor_mod + from hermes_cli.kanban_survivor import ( + _canonical_repos, _canonical_survivor, _content_advisory, _published_refs, + ) + + assert not hasattr(survivor_mod, "_content_survivor") + tid, ws, live, head, mirror_sha = home_clone(board, tmp_path, live_remote=False) + # Premise: the live tree is NOT visible from this workspace, but the mirror + # DOES carry the diff. (The fixture's mirror is a local bare repo, so it is + # itself "canonical-shaped" — it just cannot reach `head`, because it + # rewrote it. On the live box the mirror is a github URL and not local at + # all.) What matters is that no canonical repo vouches for this commit. + assert str(live.resolve()) not in [c["repository_path"] for c in _canonical_repos(ws, ws)] + assert _canonical_survivor(ws, head, ws) is None + assert _content_advisory(ws, head, list(_published_refs(ws, ws))) + assert kb.complete_task(board, tid, metadata={"changed_files": ["code.py"]}) + survivor = kb.latest_run(board, tid).metadata["survivor"] + assert survivor["kind"] in {"patch", "bundle"}, survivor + assert "ref-by-content" not in json.dumps(survivor) + + +def test_canonical_match_requires_reachability_from_the_live_head(board, tmp_path): + """Mutation guard: drop the reachability check and this must stop passing. + + The live tree exists and is durable, but it does NOT carry this commit — + presence of a canonical repo is not itself authority. + """ + from hermes_cli.kanban_survivor import _canonical_survivor, _canonical_repos + + tid, ws, live, head, _ = home_clone(board, tmp_path) + orphan = commit(ws, "code.py", "value = 99\n", "never reaches the live tree") + assert orphan != head + # The canonical repo is still discovered — only reachability rejects it. + assert str(live.resolve()) in [c["repository_path"] for c in _canonical_repos(ws, ws)] + assert _canonical_survivor(ws, orphan, ws) is None + + assert kb.complete_task(board, tid, metadata={"changed_files": ["code.py"]}) + survivor = kb.latest_run(board, tid).metadata["survivor"] + assert survivor["kind"] in {"patch", "bundle"}, survivor + + +def test_a_disposable_repo_is_never_canonical(board, tmp_path): + """A workspace/temp tree cannot vouch for itself.""" + from hermes_cli.kanban_survivor import _canonical_repos + + tid, ws, live, head, _ = home_clone(board, tmp_path) + sibling = init(tmp_path / "temporary" / "sibling") + commit(sibling, "code.py", "value = 1\n", "base") + git(ws, "remote", "set-url", "origin", str(sibling)) + git(ws, "remote", "remove", "live") + assert list(_canonical_repos(ws, ws)) == [] + + +# --- the reviewer's collision reproducer, as a committed regression test --- + + +def divergent_history(tmp_path): + """Local HEAD is patch-identical to the published tip over a DIFFERENT tree. + + Published: base -> fix + Local: base -> unpublished -> fix' (same diff, new base) + + A diff-only check blesses this as published; `unpublished.py` exists nowhere + durable. This is the shape that made round 1 a data-loss path. + """ + source = init(tmp_path / "divergent-source") + commit(source, "core.py", "value = 1\n", "base") + base = git(source, "rev-parse", "HEAD") + published_fix = commit(source, "fix.py", "y = 2\n", "fix") + mirror = init(tmp_path / "divergent-mirror.git", bare=True) + git(source, "push", str(mirror), "HEAD:refs/heads/main") + + local = tmp_path / "divergent-local" + git(tmp_path, "clone", "--no-local", "-b", "main", str(source), str(local)) + git(local, "config", "user.name", "Worker") + git(local, "config", "user.email", "worker@example.invalid") + git(local, "remote", "remove", "origin") + git(local, "reset", "--hard", base) + commit(local, "unpublished.py", "secret_work = 1\n", "exists nowhere else") + head = commit(local, "fix.py", "y = 2\n", "fix") + git(local, "remote", "add", "origin", str(mirror)) + return local, head, published_fix, mirror + + +def test_same_diff_different_base_fails_closed(board, tmp_path): + """The round-1 data-loss reproducer: completion must NOT delete this tree.""" + from hermes_cli.kanban_survivor import _patch_id, _published_refs, _canonical_survivor + + tid = kb.create_task(board, title="divergent work") + ws = kb.resolve_workspace(kb.get_task(board, tid)) + ws.parent.mkdir(parents=True, exist_ok=True) + local, head, published_fix, mirror = divergent_history(tmp_path) + # `cp -R src dst` NESTS when dst exists, which silently produced a + # workspace with no `.git` at all (and a vacuously-passing test). + assert not any(ws.iterdir()) + shutil.copytree(local, ws, symlinks=True, dirs_exist_ok=True) + assert (ws / ".git").exists() + kb.set_workspace_path(board, tid, ws) + + # Premise: the diffs ARE identical, so a patch-id check would match here. + probe = tmp_path / "divergent-probe" + git(tmp_path, "clone", "-b", "main", str(mirror), str(probe)) + assert _patch_id(ws, head) == _patch_id(probe, published_fix) is not None + # And there is no live canonical tree vouching for it. + assert _canonical_survivor(ws, head, ws) is None + assert list(_published_refs(ws, ws)), "mirror must be a durable remote here" + + assert kb.complete_task(board, tid, metadata={"changed_files": ["fix.py"]}) + survivor = kb.latest_run(board, tid).metadata["survivor"] + assert survivor["kind"] in {"patch", "bundle"}, survivor + # The commit that exists nowhere else is still recoverable from the artifact. + restored = restore_artifact(survivor, tmp_path, mirror) + assert (restored / "unpublished.py").read_text() == "secret_work = 1\n" + + +def test_landed_claim_needs_a_live_tree_that_reaches_the_sha(board, tmp_path): + """CLASS-SWEEP: `landed` used the same diff-identity shape; it must not now.""" + tid, ws, live, head, _ = home_clone(board, tmp_path) + local, divergent_head, _, _ = divergent_history(tmp_path) + # Patch-identical to the mirror, unreachable from any live tree's HEAD. + git(local, "checkout", "-q", "-b", "parked") + git(local, "checkout", "-q", "main") + git(local, "reset", "--hard", "HEAD~2") + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata={ + "changed_files": ["fix.py"], + "landed": [{"repo_path": str(local), "sha": divergent_head}], + }) + assert ws.exists() + assert kb.get_task(board, tid).status != "done" + + +def test_landed_accepts_the_live_home_tree_without_any_mirror_claim(board, tmp_path): + """The card's actual scenario: work committed into ~/.hermes, mirror irrelevant.""" + tid, ws, live, head, _ = home_clone(board, tmp_path) + assert kb.complete_task(board, tid, metadata={ + "changed_files": ["code.py"], + "landed": [{"repo_path": str(live), "sha": head}], + }) + survivor = kb.latest_run(board, tid).metadata["survivor"] + assert survivor["kind"] == "landed", survivor + entry = survivor["landed"][0] + assert entry["sha"] == head + assert entry["matched_by"] == "canonical" + # Any mirror annotation is advisory; it is never the accept reason and the + # old `published_sha` field (which WAS the accept reason) is gone. + assert "published_sha" not in entry + assert entry.get("published", {}).get("matched_by") in {None, "sha", "patch-id"} + assert not ws.exists() + + +def test_landed_rejects_patch_id_whitespace_collision(board, tmp_path): + """Whitespace-normalized diffs can execute differently; never discard the original.""" + from hermes_cli.kanban_survivor import _patch_id + + source = init(tmp_path / "collision-source") + commit(source, "implementation.py", "if False:\n safe = True\n", "base") + tid = kb.create_task(board, title="whitespace collision") + ws = kb.resolve_workspace(kb.get_task(board, tid)) + git(tmp_path, "clone", "--no-local", str(source), str(ws)) + git(ws, "config", "user.name", "Workspace") + git(ws, "config", "user.email", "workspace@example.invalid") + work_head = commit(ws, "implementation.py", "if False:\n safe = True\nresult = 42\n", "work") + live = tmp_path / "collision-live" + git(tmp_path, "clone", "--no-local", str(source), str(live)) + git(live, "config", "user.name", "Live") + git(live, "config", "user.email", "live@example.invalid") + live_head = commit(live, "implementation.py", "if False:\n safe = True\n result = 42\n", "landed") + assert _patch_id(ws, work_head) == _patch_id(live, live_head) + assert git(ws, "rev-parse", f"{work_head}^:implementation.py") == git(live, "rev-parse", f"{live_head}^:implementation.py") + assert _execution_value(ws) == 42 + assert _execution_value(live) is None + kb.set_workspace_path(board, tid, ws) + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata={ + "changed_files": ["implementation.py"], + "landed": [{"repo_path": str(live), "sha": live_head}], + }) + assert ws.exists() + assert _execution_value(ws) == 42 + + +def _execution_value(repo): + scope = {} + exec((repo / "implementation.py").read_text(), scope) + return scope.get("result") + + +def test_landed_rejects_a_valid_but_unrelated_commit(board, tmp_path): + tid, ws, _, _, _ = home_clone(board, tmp_path) + unrelated = init(tmp_path / "unrelated-live") + unrelated_sha = commit(unrelated, "other.py", "other = True\n", "unrelated") + + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata={ + "changed_files": ["code.py"], + "landed": [{"repo_path": str(unrelated), "sha": unrelated_sha}], + }) + + assert ws.exists() + assert (ws / "code.py").read_text() == "value = 2\n" + + +@pytest.mark.parametrize("dirty", ["tracked", "untracked"]) +def test_landed_rejects_a_dirty_workspace(board, tmp_path, dirty): + tid, ws, live, head, _ = home_clone(board, tmp_path) + if dirty == "tracked": + (ws / "code.py").write_text("value = 3\n") + else: + (ws / "untracked.py").write_text("only_here = True\n") + + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata={ + "changed_files": ["code.py"], + "landed": [{"repo_path": str(live), "sha": head}], + }) + + assert ws.exists() + + +def test_landed_allows_ignored_workspace_files(board, tmp_path): + tid, ws, live, _, _ = home_clone(board, tmp_path) + (ws / ".gitignore").write_text("scratch.log\n") + git(ws, "add", ".gitignore") + git(ws, "commit", "-m", "ignore scratch output") + head = git(ws, "rev-parse", "HEAD") + git(live, "fetch", str(ws), "main") + git(live, "reset", "--hard", "FETCH_HEAD") + (ws / "scratch.log").write_text("ignored\n") + + assert kb.complete_task(board, tid, metadata={ + "changed_files": [".gitignore"], + "landed": [{"repo_path": str(live), "sha": head}], + }) + assert not ws.exists() + + +def test_landed_accepts_byte_identical_rewritten_workspace_history(board, tmp_path): + source = init(tmp_path / "patch-source") + commit(source, "code.py", "value = 1\n", "base") + tid = kb.create_task(board, title="rewritten landed history") + ws = kb.resolve_workspace(kb.get_task(board, tid)) + git(tmp_path, "clone", "--no-local", str(source), str(ws)) + git(ws, "config", "user.name", "Workspace") + git(ws, "config", "user.email", "workspace@example.invalid") + workspace_head = commit(ws, "code.py", "value = 2\n", "workspace implementation") + + live = tmp_path / "patch-live" + git(tmp_path, "clone", "--no-local", str(source), str(live)) + git(live, "config", "user.name", "Live") + git(live, "config", "user.email", "live@example.invalid") + landed_head = commit(live, "code.py", "value = 2\n", "rewritten implementation") + assert landed_head != workspace_head + kb.set_workspace_path(board, tid, ws) + + assert kb.complete_task(board, tid, metadata={ + "changed_files": ["code.py"], + "landed": [{"repo_path": str(live), "sha": landed_head}], + }) + entry = kb.latest_run(board, tid).metadata["survivor"]["landed"][0] + assert entry["workspace_repositories"] == [{ + "repository": ".", "head": workspace_head, "matched_by": "exact-diff", + }] + assert not ws.exists() + + +def test_landed_rejects_replaced_workspace_commit_without_discarding_raw_work(board, tmp_path): + source = init(tmp_path / "replace-source") + base = commit(source, "code.py", "value = 1\n", "base") + tid = kb.create_task(board, title="replaced workspace history") + ws = kb.resolve_workspace(kb.get_task(board, tid)) + git(tmp_path, "clone", "--no-local", str(source), str(ws)) + git(ws, "config", "user.name", "Test") + git(ws, "config", "user.email", "test@example.invalid") + work = commit(ws, "code.py", "value = 2\n", "implementation") + live = tmp_path / "replace-live" + git(tmp_path, "clone", "--no-local", str(source), str(live)) + git(ws, "replace", work, base) + git(ws, "reset", "--hard", "HEAD") + assert subprocess.run( + ["git", "-C", str(ws), "--no-replace-objects", "show", f"{work}:code.py"], + stdin=subprocess.DEVNULL, capture_output=True, check=True, + ).stdout == b"value = 2\n" + kb.set_workspace_path(board, tid, ws) + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata={ + "changed_files": ["code.py"], "landed": [{"repo_path": str(live), "sha": base}], + }) + assert ws.exists() + assert kb.get_task(board, tid).status != "done" + + +def test_landed_allows_unrelated_edit_to_inherited_path(board, tmp_path): + source = init(tmp_path / "inherited-source") + commit(source, "config.txt", "version=1\n", "baseline config") + commit(source, "implementation.py", "result=0\n", "baseline implementation") + tid = kb.create_task(board, title="inherited path edited independently") + ws = kb.resolve_workspace(kb.get_task(board, tid)) + live = tmp_path / "inherited-live" + for path in (ws, live): + git(tmp_path, "clone", "--no-local", str(source), str(path)) + git(path, "config", "user.name", "Test") + git(path, "config", "user.email", "test@example.invalid") + work = commit(ws, "implementation.py", "result=1\n", "implementation") + landed = commit(live, "implementation.py", "result=1\n", "rewritten implementation") + assert work != landed + commit(live, "config.txt", "version=2\n", "unrelated configuration") + kb.set_workspace_path(board, tid, ws) + assert kb.complete_task(board, tid, metadata={ + "changed_files": ["implementation.py"], + "landed": [{"repo_path": str(live), "sha": landed}], + }) + assert not ws.exists() + + +@pytest.mark.parametrize("rewritten", [False, True]) +def test_landed_rejects_work_reverted_from_canonical_head(board, tmp_path, rewritten): + source = init(tmp_path / "reverted-source") + commit(source, "implementation.py", "result = 0\n", "base") + tid = kb.create_task(board, title="reverted landed work") + ws = kb.resolve_workspace(kb.get_task(board, tid)) + git(tmp_path, "clone", "--no-local", str(source), str(ws)) + live = tmp_path / "reverted-live" + git(tmp_path, "clone", "--no-local", str(source), str(live)) + for repo in (ws, live): + git(repo, "config", "user.name", "Test") + git(repo, "config", "user.email", "test@example.invalid") + work = commit(ws, "implementation.py", "result = 1\n", "workspace implementation") + if rewritten: + landed = commit(live, "implementation.py", "result = 1\n", "landed then removed") + assert landed != work + from hermes_cli.kanban_survivor import _exact_commit_diff + assert _exact_commit_diff(ws, work) == _exact_commit_diff(live, landed) + else: + git(live, "fetch", str(ws), "main") + git(live, "reset", "--hard", "FETCH_HEAD") + reverted = commit(live, "implementation.py", "result = 0\n", "revert implementation") + assert _execution_value(ws) == 1 + assert _execution_value(live) == 0 + kb.set_workspace_path(board, tid, ws) + + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata={ + "changed_files": ["implementation.py"], + "landed": [{"repo_path": str(live), "sha": reverted}], + }) + assert ws.exists() + assert _execution_value(ws) == 1 + assert kb.get_task(board, tid).status != "done" + + +def test_landed_allows_unrelated_canonical_addition(board, tmp_path): + source = init(tmp_path / "addition-source") + commit(source, "implementation.py", "result = 0\n", "base") + tid = kb.create_task(board, title="canonical independent addition") + ws = kb.resolve_workspace(kb.get_task(board, tid)) + git(tmp_path, "clone", "--no-local", str(source), str(ws)) + git(ws, "config", "user.name", "Test") + git(ws, "config", "user.email", "test@example.invalid") + work = commit(ws, "implementation.py", "result = 1\n", "implementation") + live = tmp_path / "addition-live" + git(tmp_path, "clone", "--no-local", str(source), str(live)) + git(live, "config", "user.name", "Test") + git(live, "config", "user.email", "test@example.invalid") + git(live, "fetch", str(ws), "main") + git(live, "reset", "--hard", "FETCH_HEAD") + commit(live, "independent.py", "other = 2\n", "unrelated addition") + kb.set_workspace_path(board, tid, ws) + assert kb.complete_task(board, tid, metadata={ + "changed_files": ["implementation.py"], + "landed": [{"repo_path": str(live), "sha": work}], + }) + assert not ws.exists() + assert _execution_value(live) == 1 + + +@pytest.mark.parametrize("restored", [False, True]) +def test_landed_checks_deleted_paths_and_modes_at_live_head(board, tmp_path, restored): + source = init(tmp_path / "mode-source") + commit(source, "implementation.py", "result = 1\n", "base") + tid = kb.create_task(board, title="deleted path or mode reverted") + ws = kb.resolve_workspace(kb.get_task(board, tid)) + git(tmp_path, "clone", "--no-local", str(source), str(ws)) + live = tmp_path / "mode-live" + git(tmp_path, "clone", "--no-local", str(source), str(live)) + for repo in (ws, live): + git(repo, "config", "user.name", "Test") + git(repo, "config", "user.email", "test@example.invalid") + if restored: + git(ws, "update-index", "--chmod=+x", "implementation.py") + git(ws, "commit", "-m", "make executable") + else: + (ws / "implementation.py").unlink() + git(ws, "add", "-u") + git(ws, "commit", "-m", "remove implementation") + git(live, "fetch", str(ws), "main") + git(live, "reset", "--hard", "FETCH_HEAD") + if restored: + git(live, "update-index", "--chmod=-x", "implementation.py") + git(live, "commit", "-m", "remove executable mode") + else: + commit(live, "implementation.py", "result = 1\n", "restore deleted path") + kb.set_workspace_path(board, tid, ws) + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata={ + "changed_files": ["implementation.py"], + "landed": [{"repo_path": str(live), "sha": git(live, "rev-parse", "HEAD")}], + }) + assert ws.exists() + assert kb.get_task(board, tid).status != "done" + + +@pytest.mark.parametrize("restore_source", [False, True]) +@pytest.mark.parametrize("shared_history", [False, True]) +def test_landed_rename_checks_both_source_and_destination(board, tmp_path, restore_source, shared_history): + source = init(tmp_path / "rename-source") + commit(source, "old.py", "value = 1\n", "base") + tid = kb.create_task(board, title="renamed implementation") + ws = kb.resolve_workspace(kb.get_task(board, tid)) + live = tmp_path / "rename-live" + git(tmp_path, "clone", "--no-local", str(source), str(ws)) + if shared_history: + git(tmp_path, "clone", "--no-local", str(source), str(live)) + else: + init(live) + commit(live, "old.py", "value = 1\n", "independent base") + for repo in (ws, live): + git(repo, "config", "user.name", "Test") + git(repo, "config", "user.email", "test@example.invalid") + for repo, message in ((ws, "workspace rename"), (live, "independent rename")): + git(repo, "mv", "old.py", "new.py") + git(repo, "commit", "-m", message) + if restore_source: + commit(live, "old.py", "value = 1\n", "restore old path") + kb.set_workspace_path(board, tid, ws) + metadata = { + "changed_files": ["old.py", "new.py"], + "landed": [{"repo_path": str(live), "sha": git(live, "rev-parse", "HEAD")}], + } + if restore_source: + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata=metadata) + assert ws.exists() + assert kb.get_task(board, tid).status != "done" + else: + assert kb.complete_task(board, tid, metadata=metadata) + assert kb.latest_run(board, tid).metadata["survivor"]["kind"] == "landed" + assert not ws.exists() + + +def test_landed_rejects_a_disposable_repository(board, tmp_path): + """Pointing `landed` at the workspace itself must not authorise its deletion.""" + tid, ws, live, head, _ = home_clone(board, tmp_path) + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata={ + "changed_files": ["code.py"], + "landed": [{"repo_path": str(ws), "sha": head}], + }) + assert ws.exists() + + +def test_landed_rejects_a_directory_that_is_not_a_repository(board, tmp_path): + tid, ws, live, head, _ = home_clone(board, tmp_path) + plain = tmp_path / "not-a-repo" + plain.mkdir() + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata={ + "changed_files": ["code.py"], + "landed": [{"repo_path": str(plain), "sha": head}], + }) + assert ws.exists() + + +def test_landed_rejects_an_unbound_nested_repository(board, tmp_path): + """A root-repo receipt cannot discard a nested repo's independent objects.""" + tid, ws, live, head, _ = home_clone(board, tmp_path, live_remote=False) + nested = init(ws / "nested") + nested_head = commit(nested, "nested.py", "only_here = True\n", "nested implementation") + git(ws, "add", "nested") + git(ws, "commit", "-m", "record nested repository") + root_head = git(ws, "rev-parse", "HEAD") + git(live, "fetch", str(ws), "main") + git(live, "reset", "--hard", "FETCH_HEAD") + + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata={ + "changed_files": ["nested/nested.py"], + "landed": [{"repo_path": str(live), "sha": root_head}], + }) + + assert ws.exists() + assert git(nested, "rev-parse", "HEAD") == nested_head + assert (nested / "nested.py").read_text() == "only_here = True\n" + + +def test_landed_cleanup_rechecks_live_reachability(board, tmp_path): + """A stored receipt cannot authorize deletion after its live ref disappears.""" + from hermes_cli.kanban_survivor import preserve, remove_workspace_dir + tid, ws, live, head, _ = home_clone(board, tmp_path, live_remote=False) + preserve(board, tid, {"landed": [{"repo_path": str(live), "sha": head}]}) + git(live, "reset", "--hard", "HEAD~1") + assert not remove_workspace_dir(board, tid, ws) + assert ws.exists() + + +def test_diff_collision_over_attachment_limit_holds_workspace(board, tmp_path, monkeypatch): + """A matching patch-id cannot bypass the cap by claiming a durable ref.""" + tid = kb.create_task(board, title="oversized divergent work") + ws, _, _, _ = divergent_history(tmp_path) + kb.set_workspace_path(board, tid, ws) + monkeypatch.setattr(kb, "KANBAN_ATTACHMENT_MAX_BYTES", 1) + with pytest.raises(ValueError, match="exceeds attachment limit"): + kb.complete_task(board, tid, metadata={"changed_files": ["unpublished.py"]}) + assert kb.get_task(board, tid).status != "done" + assert (ws / "unpublished.py").read_text() == "secret_work = 1\n" + + +def stored_survivor(conn, tid): + """The DURABLE recovery row -- what a later recovery actually reads.""" + import hermes_cli.kanban_survivor as survivor_mod + return survivor_mod._state(conn, tid)[2] + + +def assert_truthful_recovery_row(survivor): + """A row may only call itself a patch/bundle if it POINTS AT bytes. + + The class this pins: a recovery row whose `kind`/`notice`/`patches` promise + an artifact that does not exist is worse than a mislabelled one -- the + workspace is already deleted by then, so the index is the only map back to + the implementation and it now points nowhere. + """ + if survivor.get("kind") == "patch": + path = survivor.get("path") + assert path, f"kind=patch with no patch path: {survivor}" + assert Path(path).exists(), f"kind=patch pointing at missing bytes: {path}" + if survivor.get("kind") == "bundle": + bundles = survivor.get("bundles") or () + assert bundles, f"kind=bundle with no bundles: {survivor}" + for bundle in bundles: + assert Path(bundle["path"]).exists(), bundle + # A displaced pointer is retained because it is the only copy of some + # work: it must name real bytes (a patch `path`, or a bundle row's + # manifest whose `bundles` were merged into the fresh row), never a bare + # metadata manifest. + for pointer in (survivor.get("patches") or ()): + path = pointer.get("path") + assert path, f"displaced recovery pointer with no patch path: {pointer}" + assert Path(path).exists(), f"displaced pointer to missing bytes: {path}" + if survivor.get("notice") == "NOT PUSHED": + assert survivor.get("path") or survivor.get("bundles"), ( + f"NOT PUSHED promises unpushed bytes this row does not hold: {survivor}") + + +def test_landed_cleanup_keeps_a_truthful_row_when_the_manifest_changes(board, tmp_path): + """A re-captured metadata sidecar is not a second PATCH to carry forward. + + Completion and cleanup are separate calls, and cleanup re-verifies the + claim and re-stores `implementation.json`. Because the row carries that + manifest in the same `sidecar` slot a real patch uses, the non-shrink guard + read the completion's manifest as a stored patch being dropped, carried it + forward, and relabelled the durable row `kind: "patch"` / `NOT PUSHED` with + a `patches` list of JSON manifests holding no patch bytes -- after the + workspace had already been deleted. + """ + from hermes_cli.kanban_survivor import preserve, remove_workspace_dir + + tid, ws, live, head, _ = home_clone(board, tmp_path, live_remote=False) + first = preserve(board, tid, {"landed": [{"repo_path": str(live), "sha": head}]}) + assert first["kind"] == "landed", first + + # Anything that changes the re-stored manifest reaches this path; a branch + # rename is the cheapest (`_verify_landed` records the live branch). + git(live, "branch", "-m", "renamed") + + assert remove_workspace_dir(board, tid, ws) + assert not ws.exists() + + stored = stored_survivor(board, tid) + assert stored["kind"] == "landed", stored + assert "patches" not in stored, stored + assert stored.get("notice") != "NOT PUSHED", stored + assert stored["landed"][0]["sha"] == head, stored + assert_truthful_recovery_row(stored) + + +def test_canonical_ref_cleanup_keeps_a_truthful_row_when_the_manifest_changes(board, tmp_path): + """Same class, the OTHER sidecar-bearing kind this PR added. + + The canonical `ref` arm writes `implementation.json` for exactly the same + reason `landed` does -- to name the live tree holding a sha the published + remote lacks -- so it is exposed to the identical mislabelling. + """ + from hermes_cli.kanban_survivor import preserve + + tid, ws, live, head, _ = home_clone(board, tmp_path) + first = preserve(board, tid, {"changed_files": ["code.py"]}) + assert first["kind"] == "ref" and first.get("sidecar"), first + + # Advance the work so the re-capture's manifest genuinely differs (a + # stable manifest re-stores to the SAME path and never reaches the carry + # path -- that is the coverage boundary the previous round tested). + second_head = commit(ws, "code.py", "value = 3\n", "more implementation") + git(live, "fetch", str(ws), "main") + git(live, "reset", "--hard", "FETCH_HEAD") + second = preserve(board, tid, {"changed_files": ["code.py"]}) + assert second["kind"] == "ref", second + assert second["sidecar"] != first["sidecar"], (first, second) + assert second["refs"][0]["sha"] == second_head, second + assert "patches" not in second, second + assert second.get("notice") != "NOT PUSHED", second + assert_truthful_recovery_row(stored_survivor(board, tid)) + + +def test_a_real_patch_is_still_carried_when_a_recapture_drops_it(board, tmp_path): + """Positive control: narrowing the sidecar rule must not inert non-shrink. + + If this ever fails, the fix above has traded a mislabelled row for actual + data loss -- the case the non-shrink guard exists for. + """ + import hermes_cli.kanban_survivor as survivor_mod + + tid, ws, live, head, _ = home_clone(board, tmp_path, live_remote=False) + (ws / "unpublished.py").write_text("secret_work = 1\n") + git(ws, "add", "-A") + git(ws, "commit", "-m", "unpublished implementation") + captured = survivor_mod.preserve(board, tid, {"changed_files": ["unpublished.py"]}) + assert captured["kind"] in ("patch", "bundle"), captured + assert_truthful_recovery_row(captured) + + # The work then lands in the live tree, so a re-capture emits a bare ref + # and would otherwise drop the only copy of the pre-landing bytes. + git(live, "fetch", str(ws), "main") + git(live, "reset", "--hard", "FETCH_HEAD") + survivor_mod.preserve(board, tid, {"landed": [ + {"repo_path": str(live), "sha": git(ws, "rev-parse", "HEAD")}]}) + + stored = stored_survivor(board, tid) + assert stored["kind"] in ("patch", "bundle"), stored + assert stored.get("notice") == "NOT PUSHED", stored + assert_truthful_recovery_row(stored) diff --git a/tests/hermes_cli/test_kanban_survivor_storage.py b/tests/hermes_cli/test_kanban_survivor_storage.py new file mode 100644 index 0000000000000..1dca4d85906ae --- /dev/null +++ b/tests/hermes_cli/test_kanban_survivor_storage.py @@ -0,0 +1,116 @@ +"""A durable checkout must not borrow Git storage from a disposable source. + +TEST-REPIN: fdedf3fc2e6a21e808b0b8b9cd94557b46856c72 ANG-Ventures/hermes-agent#795 — merged survivor authority supersedes incomplete storage-independence coverage. +""" +import shutil +import subprocess +from pathlib import Path + +import pytest + +from hermes_cli import kanban_db as kb +from hermes_cli import kanban_survivor as survivor + + +def git(repo, *args): + return subprocess.run( + ["git", "-C", str(repo), *map(str, args)], stdin=subprocess.DEVNULL, + capture_output=True, check=True, + ).stdout.decode().strip() + + +@pytest.fixture +def board(tmp_path, monkeypatch): + monkeypatch.setattr(survivor, "_temporary_roots", lambda: [tmp_path / "temporary"]) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + with kb.connect_closing() as conn: + yield conn + + +def source(board): + tid = kb.create_task(board, title="storage independence") + ws = kb.resolve_workspace(kb.get_task(board, tid)) + ws.mkdir(parents=True, exist_ok=True) + git(ws, "init", "-b", "main") + git(ws, "config", "user.name", "Test") + git(ws, "config", "user.email", "test@example.invalid") + (ws / "implementation.py").write_text("value = 42\n") + git(ws, "add", "implementation.py") + git(ws, "commit", "-m", "implementation") + kb.set_workspace_path(board, tid, ws) + return tid, ws, git(ws, "rev-parse", "HEAD") + + +@pytest.mark.parametrize("mode", ["live", "origin", "landed"]) +@pytest.mark.parametrize("storage", [ + "linked", "gitfile", "symlink", "alternates", "object-symlink", + "promisor-config", "partial-clone", "promisor-pack", "missing-object", +]) +def test_disposable_git_storage_never_authorizes_cleanup(board, tmp_path, mode, storage): + tid, ws, sha = source(board) + live = tmp_path / "live" + if storage == "linked": + git(ws, "worktree", "add", "--detach", live, sha) + elif storage in {"gitfile", "symlink"}: + live.mkdir() + if storage == "gitfile": + (live / ".git").write_text(f"gitdir: {ws / '.git'}\n") + else: + (live / ".git").symlink_to(ws / ".git", target_is_directory=True) + elif storage in {"alternates", "object-symlink"}: + git(tmp_path, "clone", "--shared" if storage == "alternates" else "--no-local", ws, live) + if storage == "object-symlink": + shutil.rmtree(live / ".git" / "objects") + (live / ".git" / "objects").symlink_to(ws / ".git" / "objects", target_is_directory=True) + else: + git(tmp_path, "clone", "--no-hardlinks" if storage == "missing-object" else "--no-local", ws, live) + if storage == "promisor-config": + git(live, "config", "remote.origin.promisor", "true") + elif storage == "partial-clone": + git(live, "config", "extensions.partialClone", "origin") + elif storage == "promisor-pack": + marker = live / ".git" / "objects" / "pack" / "fixture.promisor" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.touch() + else: + blob = git(live, "rev-parse", "HEAD:implementation.py") + (live / ".git" / "objects" / blob[:2] / blob[2:]).unlink() + assert git(live, "rev-parse", "HEAD") == sha + git(live, "cat-file", "-e", sha) + metadata = {"changed_files": ["implementation.py"]} + if mode == "landed": + metadata["landed"] = [{"repo_path": str(live), "sha": sha}] + with pytest.raises(ValueError, match="survivor_unavailable"): + kb.complete_task(board, tid, metadata=metadata) + assert ws.exists() + assert kb.get_task(board, tid).status != "done" + else: + git(ws, "remote", "add", mode, live) + assert kb.complete_task(board, tid, metadata=metadata) + receipt = kb.latest_run(board, tid).metadata["survivor"] + assert receipt["kind"] == "bundle" + restored = tmp_path / "restored" + git(tmp_path, "clone", receipt["bundles"][0]["path"], restored) + assert (restored / "implementation.py").read_text() == "value = 42\n" + assert not ws.exists() + + +@pytest.mark.parametrize("mode", ["live", "origin", "landed"]) +def test_independent_linked_worktree_survives_cleanup(board, tmp_path, mode): + tid, ws, sha = source(board) + durable = tmp_path / "durable" + git(tmp_path, "clone", "--no-local", ws, durable) + live = tmp_path / "linked" + git(durable, "worktree", "add", "--detach", live, sha) + metadata = {"changed_files": ["implementation.py"]} + if mode == "landed": + metadata["landed"] = [{"repo_path": str(live), "sha": sha}] + else: + git(ws, "remote", "add", mode, live) + assert kb.complete_task(board, tid, metadata=metadata) + receipt = kb.latest_run(board, tid).metadata["survivor"] + assert receipt["kind"] == ("landed" if mode == "landed" else "ref") + assert not ws.exists() + git(live, "cat-file", "-e", sha) + assert git(live, "show", f"{sha}:implementation.py") == "value = 42" diff --git a/tests/hermes_cli/test_kanban_terminal_transition_ref_cost.py b/tests/hermes_cli/test_kanban_terminal_transition_ref_cost.py index 3715d21b7cf41..2622e7ac4be72 100644 --- a/tests/hermes_cli/test_kanban_terminal_transition_ref_cost.py +++ b/tests/hermes_cli/test_kanban_terminal_transition_ref_cost.py @@ -113,7 +113,7 @@ def test_complete_is_not_starved_by_ref_count(board, monkeypatch): # The regression the card is about: the transition must LAND, durably. assert len(spawns) < SPAWN_CEILING, ( f"complete_task issued {len(spawns)} git spawns against {NHEADS} " - f"published heads — a per-ref scan is back" + f"published heads — a per-ref scan is back: {spawns}" ) # And it must not have "fixed" the timeout by dropping the recovery index. row = board.execute(