From 9ec5c85a4bfa025a9e47d41b97179f1de0dcb0b4 Mon Sep 17 00:00:00 2001 From: JayLin Date: Thu, 2 Jul 2026 02:15:06 +0800 Subject: [PATCH 01/12] docs: plan governed worktree candidates --- .../2026-07-02-m6b-worktree-candidates.md | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md diff --git a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md new file mode 100644 index 0000000..402e09e --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md @@ -0,0 +1,231 @@ +# Governed Worktree Candidates Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `executing-plans` to implement this plan task by task, `test-driven-development` for every behavior change, and `verification-before-completion` before release claims. + +**Goal:** Add host-managed, no-checkout Git worktree leases in which a bounded implementation Subagent can make CAS-protected text edits, then persist an independently verified candidate that the parent may separately preview, approve, adopt, or discard. + +**Architecture:** A new `mini_code_agent.worktrees` package owns immutable policy models, a byte-safe fixed-argv Git adapter, secure state storage, index-based materialization, mutation-ledger capture, candidate snapshotting, exact cleanup, and rollback-aware adoption. The child never receives Git, shell, MCP, Skills, Hooks, adoption, or delegation capabilities. Parent-repository mutation remains a separate high-risk Tool operation and is never performed by child completion. + +**Tech Stack:** Python 3.12/3.13, Pydantic v2, `asyncio`, `subprocess` without a shell, SHA-256 canonical manifests, existing `AgentRuntime`, `WorkspaceBoundary`, Tool/Policy interfaces, pytest, Ruff, mypy, GitHub Actions. + +--- + +## Non-Negotiable Invariants + +- Model input supplies only an implementation task and reason. Lease IDs, candidate IDs, paths, repository identity, base SHA, profiles, limits, and Git argv are host-controlled. +- A lease starts from a clean, non-bare repository at an exact full HEAD and uses `git worktree add --detach --no-checkout --lock`. +- Materialization reads the parent index and Git object database. It never copies the parent working tree, ignored files, untracked files, credentials, caches, or virtual environments. +- The child can only read/search, perform CAS-protected Write/Edit operations, and optionally invoke one fixed governed test Tool. +- Candidate readiness requires an independent filesystem scan whose changed set and hash chains exactly match the host mutation ledger. +- Child completion persists evidence but does not mutate the parent checkout. Adoption and discard are separate high-risk parent Tools. +- Adoption preflights every path before the first parent write, revalidates immediately before applying, and rolls back in reverse order after partial I/O failure. +- Unsafe or ambiguous state fails closed. Dirty unsnapshotted leases are retained for diagnosis rather than force-removed. + +## Task 1: Define Immutable Worktree Contracts + +**Files:** +- Create: `src/mini_code_agent/worktrees/__init__.py` +- Create: `src/mini_code_agent/worktrees/models.py` +- Modify: `src/mini_code_agent/subagents/models.py` +- Test: `tests/unit/worktrees/test_models.py` +- Test: `tests/unit/subagents/test_models.py` + +- [ ] Write failing tests for hard ceilings, absolute/existing path requirements, literal allowed-prefix normalization, exact implementation profile mode, canonical identifiers, immutable records, and invalid limit relationships. +- [ ] Extend `SubagentProfile.mode` to support `implementation` without weakening analysis-profile validation. +- [ ] Implement `WorktreeLimits`, `WorktreeProfile`, repository/base/index records, lease states, ledger records, candidate states, candidate file records, and public error codes. +- [ ] Ensure canonical manifests exclude mutable state fields and reject duplicate/case-colliding paths. +- [ ] Run: + +```powershell +py -m uv run --no-sync pytest tests/unit/worktrees/test_models.py tests/unit/subagents/test_models.py -q +py -m uv run --no-sync ruff check src/mini_code_agent/worktrees src/mini_code_agent/subagents/models.py tests/unit/worktrees +py -m uv run --no-sync mypy src/mini_code_agent/worktrees src/mini_code_agent/subagents/models.py +``` + +- [ ] Commit: `feat: define governed worktree contracts` + +## Task 2: Build the Fixed Git and Secure State Foundations + +**Files:** +- Create: `src/mini_code_agent/worktrees/git.py` +- Create: `src/mini_code_agent/worktrees/state.py` +- Test: `tests/unit/worktrees/test_git.py` +- Test: `tests/unit/worktrees/test_state.py` + +- [ ] Write failing tests for executable revalidation, no shell invocation, fixed global options/config, bounded byte output, timeout cleanup, stable NUL index parsing, stage/mode rejection, batch blob validation, and hostile filenames. +- [ ] Write failing tests for an absolute state root outside the repository, secure ancestor checks, link/reparse rejection, POSIX permission checks, opaque state IDs, atomic writes/renames, canonical JSON, and immutable blob hashing. +- [ ] Implement a narrow Git adapter with allowlisted operations only: repository discovery, status/HEAD/index inspection, `cat-file --batch`, worktree add/lock/unlock/remove/prune/list. +- [ ] Implement the state layout: + +```text +leases/ +candidates/building/ +candidates/ready/ +candidates/applying/ +candidates/applied/ +candidates/rejected/ +candidates/uncertain/ +hooks-empty/ +``` + +- [ ] Run focused tests, Ruff, and mypy. +- [ ] Commit: `feat: add secure worktree git and state foundations` + +## Task 3: Create No-Checkout Leases and Materialize the Index + +**Files:** +- Create: `src/mini_code_agent/worktrees/materialize.py` +- Create: `src/mini_code_agent/worktrees/manager.py` +- Test: `tests/unit/worktrees/test_materialize.py` +- Test: `tests/unit/worktrees/test_manager_leases.py` +- Test: `tests/integration/test_worktree_materialization.py` + +- [ ] Write failing tests that verify exact top-level/non-bare identity, full clean status including untracked files, exact base SHA, active-lease limits, and host-generated paths. +- [ ] Write failing tests that assert the mandatory `--no-checkout`, detached/locked worktree argv and empty Hooks directory. +- [ ] Write failing tests for tracked file/byte/depth/path limits, 100644/100755-only entries, sparse/unmerged/gitlink/symlink/special rejection, duplicate/case collision rejection, and truncated Git output. +- [ ] Materialize regular files exclusively from index blob bytes, preserving only executable/non-executable regular modes. +- [ ] Build a fresh `WorkspaceBoundary` rooted at the lease and persist the immutable base manifest before child execution. +- [ ] Prove with a real Git repository that ignored, untracked, `.env`, cache, and virtual-environment files are absent. +- [ ] Commit: `feat: materialize governed worktree leases` + +## Task 4: Capture a Trusted Mutation Ledger + +**Files:** +- Create: `src/mini_code_agent/worktrees/ledger.py` +- Create: `src/mini_code_agent/worktrees/tools.py` +- Modify: `src/mini_code_agent/subagents/tools.py` +- Test: `tests/unit/worktrees/test_ledger.py` +- Test: `tests/unit/worktrees/test_child_tools.py` + +- [ ] Write failing tests for implementation child capability validation: Read/Search plus Write/Edit and optional fixed test Tool only. +- [ ] Prove rejection of arbitrary process/Git/MCP/Skills/Hooks/adoption/delegation Tools and all undeclared Tool names. +- [ ] Wrap successful Write/Edit execution so ledger entries are derived from parsed `MutationResult`, never model arguments. +- [ ] Enforce ordered contiguous before/after hash chains, exact path normalization, call-ID uniqueness, bounded records, and no ledger entry on failed or preview-only mutations. +- [ ] Preserve existing analysis Subagent behavior and read-only validator tests. +- [ ] Commit: `feat: record implementation mutation evidence` + +## Task 5: Snapshot and Persist Verified Candidates + +**Files:** +- Create: `src/mini_code_agent/worktrees/snapshot.py` +- Extend: `src/mini_code_agent/worktrees/state.py` +- Extend: `src/mini_code_agent/worktrees/manager.py` +- Test: `tests/unit/worktrees/test_snapshot.py` +- Test: `tests/unit/worktrees/test_candidate_store.py` + +- [ ] Write failing tests for an independent complete scan, link/reparse/special rejection, `.git` rejection, case collisions, path/file/byte/diff limits, and allowed-prefix enforcement. +- [ ] Compare every materialized base path by raw SHA-256 and detect additions, modifications, deletions, binary/invalid UTF-8 changes, and mode changes. +- [ ] Require the filesystem changed set to equal the ledger set and every final hash to equal the last ledger hash. +- [ ] Generate deterministic bounded unified diffs and store after-content blobs separately from the canonical immutable manifest. +- [ ] Persist valid candidates atomically from `building` to `ready`; return no candidate when there are no changes. +- [ ] Persist extra regular mutations as a forensic `rejected` manifest before cleanup; retain locked `cleanup_required` leases for severe unsafe or budget failures. +- [ ] Commit: `feat: persist verified worktree candidates` + +## Task 6: Implement Exact Cleanup and Cancellation Finalization + +**Files:** +- Extend: `src/mini_code_agent/worktrees/manager.py` +- Test: `tests/unit/worktrees/test_cleanup.py` +- Test: `tests/unit/worktrees/test_cancellation.py` +- Test: `tests/integration/test_worktree_cleanup.py` + +- [ ] Write failing tests for exact lease/repository/admin identity checks before unlock/remove, bounded prune, and postcondition verification. +- [ ] Permit manager-owned removal only after candidate persistence or a verified clean tree. +- [ ] Prove dirty unsnapshotted and path-ambiguous leases are retained and marked `cleanup_required`. +- [ ] Add bounded shielded snapshot/cleanup finalization on child cancellation, then re-raise `CancelledError`. +- [ ] Record actionable diagnostics when cleanup exceeds its budget. +- [ ] Commit: `feat: finalize worktree leases safely` + +## Task 7: Expose Bounded Implementation Delegation + +**Files:** +- Create: `src/mini_code_agent/worktrees/runner.py` +- Extend: `src/mini_code_agent/worktrees/tools.py` +- Modify: `src/mini_code_agent/app/composition.py` +- Modify: `src/mini_code_agent/cli.py` +- Test: `tests/unit/worktrees/test_runner.py` +- Test: `tests/unit/worktrees/test_delegate_tool.py` +- Test: `tests/integration/test_governed_worktree_agent.py` + +- [ ] Define a parent `delegate_implementation` Tool whose model-visible input is only `{task, reason}` and whose output is bounded candidate metadata/evidence. +- [ ] Compose the exact local implementation profile and host factories before any Provider I/O. +- [ ] Run one implementation child per Tool call inside its lease, project bounded child evidence, snapshot independently, and finalize cleanup. +- [ ] Ensure child timeout, failure, cancellation, no-change completion, rejected snapshot, and candidate-ready paths have deterministic public results. +- [ ] Add CLI/composition wiring without enabling the feature by default when no Worktree profile is configured. +- [ ] Prove end-to-end with a scripted Provider that child completion leaves the parent checkout unchanged. +- [ ] Commit: `feat: delegate bounded implementation work` + +## Task 8: Adopt, Roll Back, Recover, and Discard Candidates + +**Files:** +- Create: `src/mini_code_agent/worktrees/adoption.py` +- Extend: `src/mini_code_agent/worktrees/tools.py` +- Test: `tests/unit/worktrees/test_adoption.py` +- Test: `tests/unit/worktrees/test_discard.py` +- Test: `tests/integration/test_candidate_adoption.py` + +- [ ] Define separate high-risk WRITE Tools `adopt_subagent_candidate` and `discard_subagent_candidate`. +- [ ] Preview adoption by verifying manifest/blob hashes and returning bounded repo/base/path/byte/diff resources without parent mutation. +- [ ] On execute, atomically claim `ready -> applying`, require exact clean repo/HEAD base, preflight every path, stage same-directory temporary files, and revalidate all paths immediately before the first replacement. +- [ ] Apply in canonical order, verify the exact final set/hashes, and move `applying -> applied`; leave changes unstaged and uncommitted. +- [ ] On preflight conflict, perform zero writes and return to `ready`. +- [ ] On partial I/O failure, roll back in reverse order and persist `rolled_back` evidence or `uncertain` when rollback cannot be proven. +- [ ] Recover interrupted `applying` candidates: all-before to `ready`, all-after to `applied`, mixed to `uncertain`. +- [ ] Permit discard only for a verified `ready` candidate through an atomic claim; reject applied/applying/uncertain candidates. +- [ ] Commit: `feat: adopt and discard verified candidates` + +## Task 9: Run Adversarial and Cross-Version Quality Gates + +**Files:** +- Create: `tests/adversarial/test_worktree_safety.py` +- Extend: `tests/integration/test_governed_worktree_agent.py` +- Extend: `tests/integration/test_candidate_adoption.py` +- Modify: `.github/workflows/ci.yml` only if a required platform gate is missing + +- [ ] Cover hostile filenames, Unicode/case aliases, links/reparse points, path swaps, parent HEAD/status races, stale CAS hashes, duplicate IDs, output truncation, killed Git processes, lease exhaustion, candidate tampering, blob tampering, rollback failure, and cancellation races. +- [ ] Run focused real-Git integration tests on Python 3.12 and 3.13. +- [ ] Run all unit, integration, adversarial, type, lint, format, package, and coverage gates. +- [ ] Inspect coverage for all new trust-boundary modules and add missing branch tests. +- [ ] Commit: `test: harden governed worktree candidates` + +## Task 10: Document, Package, Publish, and Record Evidence + +**Files:** +- Modify: `README.md` +- Modify: `docs/learning/prerequisites-and-knowledge-map.md` +- Modify: `docs/resume/project-description.md` +- Modify: `docs/resume/technical-highlights.md` +- Modify: `docs/operations/release-process.md` +- Modify: `docs/operations/release-evidence.md` +- Modify: `pyproject.toml` +- Modify: `uv.lock` + +- [ ] Explain the trust boundaries, state machine, limits, failure modes, operator recovery, and why child completion is separated from parent adoption. +- [ ] Add prerequisite knowledge and implementation notes for Git index/object storage, worktrees, CAS writes, manifests, rollback, TOCTOU defenses, cancellation shielding, and fail-closed cleanup. +- [ ] Add resume-ready project description, stack, measurable highlights, and for each highlight: why it exists, the technical mechanism, the delivered function, the optimization, and the problem solved. +- [ ] Bump to `0.16.0a0`, build twice with a fixed epoch, compare artifacts byte-for-byte, and inspect members. +- [ ] Smoke-test wheel and sdist in isolated Python 3.12/3.13 environments, including real delegation and adoption flows. +- [ ] Push `codex/m6b-worktree-candidates`, open a PR, wait for all CI jobs, merge, verify merged-main CI, create annotated `v0.16.0-alpha.0`, publish a non-draft prerelease with verified artifacts, and update release evidence. +- [ ] Commit: `docs: prepare 0.16 worktree candidate alpha` + +## Final Verification Commands + +```powershell +py -m uv run --no-sync ruff format --check . +py -m uv run --no-sync ruff check . +py -m uv run --no-sync mypy src +py -m uv run --no-sync pytest -q +py -m uv build +git status --short +``` + +Run the full test suite and artifact smoke matrix under both supported Python versions. Do not claim completion from focused tests alone. + +## Plan Self-Review + +- The plan preserves the accepted two-phase security model: child work creates evidence; a separate approved Tool mutates the parent. +- Git interaction is intentionally narrower than the existing general command abstraction because index blobs and hostile paths require byte-safe, NUL-delimited handling. +- Candidate verification does not trust the child transcript or ledger alone; it reconciles the complete materialized tree, immutable base manifest, and ordered mutation hash chains. +- Adoption is process-serialized and rollback-aware, not described as crash-atomic. Recovery explicitly handles interrupted `applying` state. +- The first release supports one implementation child per delegation Tool call. The manager still enforces the accepted global active-lease ceiling, allowing independent parent calls without introducing multi-child adoption ambiguity. +- The release task includes code, tests, learning material, resume material, reproducible artifacts, CI, tag, release, and evidence rather than treating documentation as a later add-on. From 17d7430abf7323a5436178f7759c09d943701e84 Mon Sep 17 00:00:00 2001 From: JayLin Date: Thu, 2 Jul 2026 02:22:33 +0800 Subject: [PATCH 02/12] feat: define governed worktree contracts --- .../2026-07-02-m6b-worktree-candidates.md | 6 +- src/mini_code_agent/subagents/models.py | 2 +- src/mini_code_agent/worktrees/__init__.py | 27 ++ src/mini_code_agent/worktrees/models.py | 277 ++++++++++++++++++ tests/unit/subagents/test_models.py | 14 + tests/unit/worktrees/__init__.py | 1 + tests/unit/worktrees/test_models.py | 231 +++++++++++++++ 7 files changed, 554 insertions(+), 4 deletions(-) create mode 100644 src/mini_code_agent/worktrees/__init__.py create mode 100644 src/mini_code_agent/worktrees/models.py create mode 100644 tests/unit/worktrees/__init__.py create mode 100644 tests/unit/worktrees/test_models.py diff --git a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md index 402e09e..646bc04 100644 --- a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md +++ b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md @@ -39,7 +39,7 @@ ```powershell py -m uv run --no-sync pytest tests/unit/worktrees/test_models.py tests/unit/subagents/test_models.py -q py -m uv run --no-sync ruff check src/mini_code_agent/worktrees src/mini_code_agent/subagents/models.py tests/unit/worktrees -py -m uv run --no-sync mypy src/mini_code_agent/worktrees src/mini_code_agent/subagents/models.py +py -m uv run --no-sync pyright ``` - [ ] Commit: `feat: define governed worktree contracts` @@ -68,7 +68,7 @@ candidates/uncertain/ hooks-empty/ ``` -- [ ] Run focused tests, Ruff, and mypy. +- [ ] Run focused tests, Ruff, and Pyright. - [ ] Commit: `feat: add secure worktree git and state foundations` ## Task 3: Create No-Checkout Leases and Materialize the Index @@ -213,7 +213,7 @@ hooks-empty/ ```powershell py -m uv run --no-sync ruff format --check . py -m uv run --no-sync ruff check . -py -m uv run --no-sync mypy src +py -m uv run --no-sync pyright py -m uv run --no-sync pytest -q py -m uv build git status --short diff --git a/src/mini_code_agent/subagents/models.py b/src/mini_code_agent/subagents/models.py index 3198016..2157bcc 100644 --- a/src/mini_code_agent/subagents/models.py +++ b/src/mini_code_agent/subagents/models.py @@ -77,7 +77,7 @@ class SubagentProfile(BaseModel): description: str = Field(min_length=1, max_length=500) system_prompt: str = Field(min_length=1, max_length=20_000) tool_names: tuple[ToolName, ...] = Field(min_length=1, max_length=16) - mode: Literal["analysis"] = "analysis" + mode: Literal["analysis", "implementation"] = "analysis" agent_limits: AgentLimits = Field(default_factory=AgentLimits) limits: SubagentLimits = Field(default_factory=SubagentLimits) diff --git a/src/mini_code_agent/worktrees/__init__.py b/src/mini_code_agent/worktrees/__init__.py new file mode 100644 index 0000000..e8c91ca --- /dev/null +++ b/src/mini_code_agent/worktrees/__init__.py @@ -0,0 +1,27 @@ +"""Governed worktree leases and independently verified candidates.""" + +from mini_code_agent.worktrees.models import ( + CandidateFile, + CandidateOperation, + CandidateState, + GitIndexEntry, + MutationLedgerEntry, + WorktreeError, + WorktreeErrorCode, + WorktreeLeaseState, + WorktreeLimits, + WorktreeProfile, +) + +__all__ = [ + "CandidateFile", + "CandidateOperation", + "CandidateState", + "GitIndexEntry", + "MutationLedgerEntry", + "WorktreeError", + "WorktreeErrorCode", + "WorktreeLeaseState", + "WorktreeLimits", + "WorktreeProfile", +] diff --git a/src/mini_code_agent/worktrees/models.py b/src/mini_code_agent/worktrees/models.py new file mode 100644 index 0000000..7e9c991 --- /dev/null +++ b/src/mini_code_agent/worktrees/models.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import os +import stat +from enum import StrEnum +from pathlib import Path, PurePosixPath +from typing import Annotated, Literal, Self, cast + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) + +from mini_code_agent.subagents.models import SubagentProfile + +_IDENTIFIER = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$" +_SHA1 = r"^[0-9a-f]{40}$" +_SHA256 = r"^[0-9a-f]{64}$" +_MAX_TRACKED_BYTES = 512 * 1024 * 1024 +_MAX_CANDIDATE_BYTES = 8 * 1024 * 1024 +_MAX_FILE_BYTES = 2 * 1024 * 1024 + +RelativePath = Annotated[str, Field(min_length=1, max_length=1024)] +Sha256 = Annotated[str, Field(pattern=_SHA256)] + + +class WorktreeErrorCode(StrEnum): + INVALID_PROFILE = "invalid_profile" + REPOSITORY_DIRTY = "repository_dirty" + REPOSITORY_UNSUPPORTED = "repository_unsupported" + LEASE_LIMIT = "lease_limit" + WORKTREE_CREATE_FAILED = "worktree_create_failed" + MATERIALIZATION_FAILED = "materialization_failed" + SNAPSHOT_FAILED = "snapshot_failed" + CLEANUP_REQUIRED = "cleanup_required" + NO_CANDIDATE_CHANGES = "no_candidate_changes" + CANDIDATE_CORRUPT = "candidate_corrupt" + CANDIDATE_STALE = "candidate_stale" + CANDIDATE_CONFLICT = "candidate_conflict" + APPLY_FAILED_ROLLED_BACK = "apply_failed_rolled_back" + APPLY_UNCERTAIN = "apply_uncertain" + + +class WorktreeError(RuntimeError): + def __init__(self, code: WorktreeErrorCode, public_message: str) -> None: + super().__init__(public_message) + self.code = code + self.public_message = public_message + + +class WorktreeLeaseState(StrEnum): + CREATING = "creating" + ACTIVE = "active" + SNAPSHOTTING = "snapshotting" + CLEANUP_REQUIRED = "cleanup_required" + REMOVED = "removed" + + +class CandidateState(StrEnum): + BUILDING = "building" + READY = "ready" + APPLYING = "applying" + APPLIED = "applied" + REJECTED = "rejected" + UNCERTAIN = "uncertain" + + +class CandidateOperation(StrEnum): + ADD = "add" + MODIFY = "modify" + + +class WorktreeLimits(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + max_active_leases: int = Field(default=2, ge=1, le=4) + max_tracked_files: int = Field(default=10_000, ge=1, le=20_000) + max_tracked_bytes: int = Field( + default=256 * 1024 * 1024, + ge=1, + le=_MAX_TRACKED_BYTES, + ) + max_tracked_depth: int = Field(default=32, ge=1, le=64) + max_candidate_files: int = Field(default=32, ge=1, le=128) + max_candidate_after_bytes: int = Field( + default=2 * 1024 * 1024, + ge=1, + le=_MAX_CANDIDATE_BYTES, + ) + max_file_bytes: int = Field(default=1024 * 1024, ge=1, le=_MAX_FILE_BYTES) + max_path_chars: int = Field(default=1024, ge=1, le=1024) + max_diff_chars: int = Field(default=32_768, ge=1, le=65_536) + cleanup_timeout_seconds: float = Field(default=30, gt=0, le=300) + + @model_validator(mode="after") + def validate_relationships(self) -> Self: + if self.max_candidate_after_bytes < self.max_file_bytes: + raise ValueError("Candidate byte limit cannot be lower than the per-file limit.") + return self + + +class WorktreeProfile(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + repository_root: Path + state_root: Path + git_executable: Path + allowed_path_prefixes: tuple[RelativePath, ...] = Field(min_length=1, max_length=64) + implementation_profile: SubagentProfile + limits: WorktreeLimits = Field(default_factory=WorktreeLimits) + + @field_validator("repository_root", "state_root", "git_executable", mode="before") + @classmethod + def resolve_host_path(cls, value: object) -> Path: + path = Path(value) # type: ignore[arg-type] + if not path.is_absolute(): + raise ValueError("Worktree host paths must be absolute.") + try: + return path.resolve(strict=True) + except OSError: + raise ValueError("Worktree host paths must already exist.") from None + + @field_validator("allowed_path_prefixes", mode="before") + @classmethod + def normalize_allowed_prefixes(cls, value: object) -> tuple[str, ...]: + if not isinstance(value, (list, tuple)): + raise ValueError("Allowed path prefixes must be a sequence.") + items = cast(list[object] | tuple[object, ...], value) + normalized = tuple( + _normalize_relative_path(str(item), allow_trailing_slash=True) for item in items + ) + if len({item.casefold() for item in normalized}) != len(normalized): + raise ValueError("Allowed path prefixes must be case-insensitively unique.") + return normalized + + @model_validator(mode="after") + def validate_host_configuration(self) -> Self: + if not self.repository_root.is_dir(): + raise ValueError("Repository root must be an existing directory.") + if not self.state_root.is_dir(): + raise ValueError("State root must be an existing directory.") + if not _is_regular_unlinked_file(self.git_executable): + raise ValueError("Git executable must be an existing unlinked regular file.") + if _paths_overlap(self.repository_root, self.state_root): + raise ValueError("State root must be separate from the repository.") + if _is_link_or_reparse(self.repository_root) or _is_link_or_reparse(self.state_root): + raise ValueError("Worktree roots cannot be links or reparse points.") + _validate_secure_state_ancestors(self.state_root) + if self.implementation_profile.mode != "implementation": + raise ValueError("Worktree profile requires an implementation Subagent profile.") + return self + + +class GitIndexEntry(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + path: RelativePath + mode: Literal["100644", "100755"] + object_id: str = Field(pattern=_SHA1) + stage: Literal[0] = 0 + byte_count: int = Field(ge=0, le=_MAX_TRACKED_BYTES) + sha256: Sha256 + + @field_validator("path") + @classmethod + def validate_path(cls, value: str) -> str: + return _normalize_relative_path(value) + + +class MutationLedgerEntry(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + ordinal: int = Field(ge=0, le=127) + tool_call_id: str = Field(pattern=_IDENTIFIER) + tool_name: Literal["write_file", "edit_file"] + path: RelativePath + created: bool + before_sha256: Sha256 | None = None + after_sha256: Sha256 + byte_count: int = Field(ge=0, le=_MAX_FILE_BYTES) + line_count: int = Field(ge=0) + + @field_validator("path") + @classmethod + def validate_path(cls, value: str) -> str: + return _normalize_relative_path(value) + + @model_validator(mode="after") + def validate_hashes(self) -> Self: + if self.created != (self.before_sha256 is None): + raise ValueError("Created mutation and before hash are inconsistent.") + if self.before_sha256 == self.after_sha256: + raise ValueError("Mutation must change the content hash.") + return self + + +class CandidateFile(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + path: RelativePath + operation: CandidateOperation + mode: Literal["100644", "100755"] + before_sha256: Sha256 | None = None + after_sha256: Sha256 + byte_count: int = Field(ge=0, le=_MAX_FILE_BYTES) + line_count: int = Field(ge=0) + diff: str = Field(max_length=65_536) + content_blob_sha256: Sha256 + + @field_validator("path") + @classmethod + def validate_path(cls, value: str) -> str: + return _normalize_relative_path(value) + + @model_validator(mode="after") + def validate_operation(self) -> Self: + if (self.operation is CandidateOperation.ADD) != (self.before_sha256 is None): + raise ValueError("Candidate operation and before hash are inconsistent.") + if self.before_sha256 == self.after_sha256: + raise ValueError("Candidate must change the content hash.") + if self.content_blob_sha256 != self.after_sha256: + raise ValueError("Candidate content blob must match the after hash.") + return self + + +def _normalize_relative_path(value: str, *, allow_trailing_slash: bool = False) -> str: + if "\0" in value or "\\" in value: + raise ValueError("Worktree paths must be NUL-free POSIX paths.") + normalized = value[:-1] if allow_trailing_slash and value.endswith("/") else value + if not normalized or normalized.startswith("/") or "//" in normalized: + raise ValueError("Worktree paths must be canonical relative paths.") + path = PurePosixPath(normalized) + if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts): + raise ValueError("Worktree paths must not contain traversal segments.") + if any(part.casefold() == ".git" for part in path.parts): + raise ValueError("Worktree paths cannot traverse .git.") + if path.as_posix() != normalized: + raise ValueError("Worktree paths must already be normalized.") + return normalized + + +def _paths_overlap(first: Path, second: Path) -> bool: + return first == second or first in second.parents or second in first.parents + + +def _is_regular_unlinked_file(path: Path) -> bool: + if _is_link_or_reparse(path): + return False + try: + return stat.S_ISREG(path.stat(follow_symlinks=False).st_mode) + except OSError: + return False + + +def _is_link_or_reparse(path: Path) -> bool: + try: + metadata = path.lstat() + except OSError: + return True + if stat.S_ISLNK(metadata.st_mode): + return True + attributes = getattr(metadata, "st_file_attributes", 0) + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + return bool(attributes & reparse_flag) + + +def _validate_secure_state_ancestors(state_root: Path) -> None: + for path in (state_root, *state_root.parents): + if _is_link_or_reparse(path) or not path.is_dir(): + raise ValueError("State root ancestors must be unlinked directories.") + if os.name != "nt": + mode = state_root.stat(follow_symlinks=False).st_mode + if mode & (stat.S_IRWXG | stat.S_IRWXO): + raise ValueError("State root must exclude group and other access.") diff --git a/tests/unit/subagents/test_models.py b/tests/unit/subagents/test_models.py index fc7f763..28abade 100644 --- a/tests/unit/subagents/test_models.py +++ b/tests/unit/subagents/test_models.py @@ -121,6 +121,20 @@ def test_analysis_profile_is_exact_frozen_and_bounded() -> None: profile.tool_names = ("write_file",) # type: ignore[misc] +def test_profile_accepts_explicit_implementation_mode() -> None: + profile = SubagentProfile.model_validate( + profile_for().model_dump() + | { + "profile_id": "implementation", + "local_name": "delegate_implementation", + "mode": "implementation", + "tool_names": ("read_file", "write_file"), + } + ) + + assert profile.mode == "implementation" + + @pytest.mark.parametrize( "tool_names", [ diff --git a/tests/unit/worktrees/__init__.py b/tests/unit/worktrees/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/unit/worktrees/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/unit/worktrees/test_models.py b/tests/unit/worktrees/test_models.py new file mode 100644 index 0000000..c6dd790 --- /dev/null +++ b/tests/unit/worktrees/test_models.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import os +from collections.abc import Mapping +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from mini_code_agent.agent.models import AgentLimits +from mini_code_agent.subagents.models import SubagentProfile +from mini_code_agent.worktrees.models import ( + CandidateFile, + CandidateOperation, + GitIndexEntry, + MutationLedgerEntry, + WorktreeLimits, + WorktreeProfile, +) + + +def implementation_profile() -> SubagentProfile: + return SubagentProfile( + profile_id="implementation", + local_name="delegate_implementation", + description="Implement one bounded task in an isolated worktree.", + system_prompt="Change only files required by the assigned task.", + tool_names=("read_file", "search_text", "write_file", "edit_file"), + mode="implementation", + agent_limits=AgentLimits(max_turns=8, max_tool_calls=32), + ) + + +def limits_for(**changes: object) -> WorktreeLimits: + values: dict[str, object] = { + "max_active_leases": 2, + "max_tracked_files": 10_000, + "max_tracked_bytes": 256 * 1024 * 1024, + "max_tracked_depth": 32, + "max_candidate_files": 32, + "max_candidate_after_bytes": 2 * 1024 * 1024, + "max_file_bytes": 1024 * 1024, + "max_path_chars": 1024, + "max_diff_chars": 32_768, + "cleanup_timeout_seconds": 30, + } + values.update(changes) + return WorktreeLimits.model_validate(values) + + +def profile_for( + tmp_path: Path, + *, + allowed_path_prefixes: tuple[str, ...] = ("src", "tests/unit"), + limits: WorktreeLimits | None = None, +) -> WorktreeProfile: + repository = tmp_path / "repository" + state = tmp_path / "state" + executable = tmp_path / ("git.exe" if os.name == "nt" else "git") + repository.mkdir(exist_ok=True) + state.mkdir(exist_ok=True) + executable.touch(exist_ok=True) + if os.name != "nt": + state.chmod(0o700) + executable.chmod(0o700) + return WorktreeProfile( + repository_root=repository, + state_root=state, + git_executable=executable, + allowed_path_prefixes=allowed_path_prefixes, + implementation_profile=implementation_profile(), + limits=limits or limits_for(), + ) + + +def test_worktree_profile_resolves_and_freezes_host_configuration(tmp_path: Path) -> None: + profile = profile_for( + tmp_path, + allowed_path_prefixes=("src/", "tests/unit/"), + ) + + assert profile.repository_root == (tmp_path / "repository").resolve() + assert profile.state_root == (tmp_path / "state").resolve() + assert ( + profile.git_executable == (tmp_path / ("git.exe" if os.name == "nt" else "git")).resolve() + ) + assert profile.allowed_path_prefixes == ("src", "tests/unit") + assert profile.implementation_profile.mode == "implementation" + with pytest.raises(ValidationError): + profile.allowed_path_prefixes = ("docs",) # type: ignore[misc] + + +@pytest.mark.parametrize( + "changes", + [ + {"max_active_leases": 5}, + {"max_tracked_files": 20_001}, + {"max_tracked_bytes": 512 * 1024 * 1024 + 1}, + {"max_tracked_depth": 65}, + {"max_candidate_files": 129}, + {"max_candidate_after_bytes": 8 * 1024 * 1024 + 1}, + {"max_file_bytes": 2 * 1024 * 1024 + 1}, + {"max_path_chars": 1025}, + {"max_diff_chars": 65_537}, + {"cleanup_timeout_seconds": 301}, + {"max_file_bytes": 1024, "max_candidate_after_bytes": 512}, + ], +) +def test_worktree_limits_reject_hard_ceiling_or_relationship_violations( + changes: Mapping[str, object], +) -> None: + with pytest.raises(ValidationError): + limits_for(**changes) + + +@pytest.mark.parametrize( + "prefixes", + [ + (), + ("src", "src"), + ("src", "SRC"), + ("/absolute",), + ("../outside",), + ("src\\package",), + ("src//package",), + ("src/./package",), + ("src/../tests",), + (".git",), + ("src/.git/config",), + ("src\0unsafe",), + ], +) +def test_worktree_profile_rejects_ambiguous_or_unsafe_prefixes( + tmp_path: Path, + prefixes: tuple[str, ...], +) -> None: + with pytest.raises(ValidationError): + profile_for(tmp_path, allowed_path_prefixes=prefixes) + + +def test_worktree_profile_requires_separate_existing_absolute_paths( + tmp_path: Path, +) -> None: + valid = profile_for(tmp_path) + + with pytest.raises(ValidationError): + WorktreeProfile.model_validate( + valid.model_dump() | {"state_root": valid.repository_root / "state"} + ) + with pytest.raises(ValidationError): + WorktreeProfile.model_validate( + valid.model_dump() | {"repository_root": Path("relative-repository")} + ) + with pytest.raises(ValidationError): + WorktreeProfile.model_validate( + valid.model_dump() | {"git_executable": tmp_path / "missing-git"} + ) + + +def test_worktree_profile_requires_exact_implementation_subagent( + tmp_path: Path, +) -> None: + valid = profile_for(tmp_path) + analysis_profile = valid.implementation_profile.model_copy(update={"mode": "analysis"}) + + with pytest.raises(ValidationError): + WorktreeProfile.model_validate( + valid.model_dump() | {"implementation_profile": analysis_profile} + ) + + +def test_index_and_ledger_records_are_canonical_and_bounded() -> None: + entry = GitIndexEntry( + path="src/app.py", + mode="100644", + object_id="a" * 40, + byte_count=12, + sha256="b" * 64, + ) + ledger = MutationLedgerEntry( + ordinal=0, + tool_call_id="call-1", + tool_name="write_file", + path="src/app.py", + created=False, + before_sha256="b" * 64, + after_sha256="c" * 64, + byte_count=13, + line_count=1, + ) + + assert entry.stage == 0 + assert ledger.ordinal == 0 + with pytest.raises(ValidationError): + GitIndexEntry.model_validate(entry.model_dump() | {"mode": "120000"}) + with pytest.raises(ValidationError): + MutationLedgerEntry.model_validate(ledger.model_dump() | {"tool_name": "read_file"}) + + +def test_candidate_file_validates_operation_hashes_and_diff() -> None: + modified = CandidateFile( + path="src/app.py", + operation=CandidateOperation.MODIFY, + mode="100644", + before_sha256="a" * 64, + after_sha256="b" * 64, + byte_count=12, + line_count=1, + diff="--- a/src/app.py\n+++ b/src/app.py\n", + content_blob_sha256="b" * 64, + ) + added = CandidateFile( + path="src/new.py", + operation=CandidateOperation.ADD, + mode="100644", + before_sha256=None, + after_sha256="c" * 64, + byte_count=4, + line_count=1, + diff="--- /dev/null\n+++ b/src/new.py\n", + content_blob_sha256="c" * 64, + ) + + assert modified.operation is CandidateOperation.MODIFY + assert added.before_sha256 is None + with pytest.raises(ValidationError): + CandidateFile.model_validate(modified.model_dump() | {"before_sha256": None}) + with pytest.raises(ValidationError): + CandidateFile.model_validate(added.model_dump() | {"before_sha256": "a" * 64}) + with pytest.raises(ValidationError): + CandidateFile.model_validate(modified.model_dump() | {"diff": "x" * 65_537}) From 0906c6d03c73c24d07b0f2c8f5890250c2683f91 Mon Sep 17 00:00:00 2001 From: JayLin Date: Thu, 2 Jul 2026 02:30:54 +0800 Subject: [PATCH 03/12] feat: add secure worktree git and state foundations --- src/mini_code_agent/worktrees/__init__.py | 2 + src/mini_code_agent/worktrees/git.py | 537 ++++++++++++++++++++++ src/mini_code_agent/worktrees/models.py | 14 + src/mini_code_agent/worktrees/state.py | 217 +++++++++ tests/unit/worktrees/helpers.py | 37 ++ tests/unit/worktrees/test_git.py | 236 ++++++++++ tests/unit/worktrees/test_state.py | 109 +++++ 7 files changed, 1152 insertions(+) create mode 100644 src/mini_code_agent/worktrees/git.py create mode 100644 src/mini_code_agent/worktrees/state.py create mode 100644 tests/unit/worktrees/helpers.py create mode 100644 tests/unit/worktrees/test_git.py create mode 100644 tests/unit/worktrees/test_state.py diff --git a/src/mini_code_agent/worktrees/__init__.py b/src/mini_code_agent/worktrees/__init__.py index e8c91ca..44b2c92 100644 --- a/src/mini_code_agent/worktrees/__init__.py +++ b/src/mini_code_agent/worktrees/__init__.py @@ -5,6 +5,7 @@ CandidateOperation, CandidateState, GitIndexEntry, + GitIndexPointer, MutationLedgerEntry, WorktreeError, WorktreeErrorCode, @@ -18,6 +19,7 @@ "CandidateOperation", "CandidateState", "GitIndexEntry", + "GitIndexPointer", "MutationLedgerEntry", "WorktreeError", "WorktreeErrorCode", diff --git a/src/mini_code_agent/worktrees/git.py b/src/mini_code_agent/worktrees/git.py new file mode 100644 index 0000000..b9973a5 --- /dev/null +++ b/src/mini_code_agent/worktrees/git.py @@ -0,0 +1,537 @@ +from __future__ import annotations + +import asyncio +import os +import re +import signal +import stat +import subprocess # nosec B404 +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal, Protocol, cast + +from pydantic import ValidationError + +from mini_code_agent.command.environment import build_minimal_environment +from mini_code_agent.worktrees.models import ( + GitIndexPointer, + WorktreeError, + WorktreeErrorCode, + WorktreeProfile, +) + +_INDEX_HEADER = re.compile(rb"^(100644|100755) ([0-9a-f]{40}) ([0-3])\t") +_BATCH_HEADER = re.compile(rb"^([0-9a-f]{40}) blob ([0-9]+)$") +_SHA1 = re.compile(r"^[0-9a-f]{40}$") +_READ_CHUNK_BYTES = 64 * 1024 + + +class WorktreeGitError(WorktreeError): + pass + + +@dataclass(frozen=True, slots=True) +class GitByteCommand: + argv: tuple[str, ...] + cwd: Path + timeout_seconds: float + max_output_bytes: int + stdin: bytes | None = None + + def __post_init__(self) -> None: + if ( + not self.argv + or any(not argument or "\0" in argument for argument in self.argv) + or not self.cwd.is_absolute() + or not self.cwd.is_dir() + or not 0 < self.timeout_seconds <= 300 + or not 1 <= self.max_output_bytes <= 1024 * 1024 * 1024 + or (self.stdin is not None and len(self.stdin) > 2 * 1024 * 1024) + ): + raise ValueError("Invalid Git byte command.") + + +@dataclass(frozen=True, slots=True) +class GitByteResult: + stdout: bytes + stderr: bytes + exit_code: int | None + timed_out: bool + output_limit_exceeded: bool + + +class GitByteCommandRunner(Protocol): + async def run(self, command: GitByteCommand) -> GitByteResult: ... + + +@dataclass(slots=True) +class _OutputBudget: + limit: int + used: int = 0 + exceeded: asyncio.Event = field(default_factory=asyncio.Event) + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + async def retain(self, chunk: bytes) -> bytes: + async with self.lock: + remaining = self.limit - self.used + kept = chunk[:remaining] + self.used += len(kept) + if len(kept) != len(chunk): + self.exceeded.set() + return kept + + +class GitBytesRunner: + def __init__( + self, + *, + environment: Mapping[str, str] | None = None, + cleanup_timeout_seconds: float = 5, + ) -> None: + if not 0 < cleanup_timeout_seconds <= 30: + raise ValueError("Git cleanup timeout is invalid.") + self._environment = build_minimal_environment( + os.environ if environment is None else environment + ) + self._cleanup_timeout_seconds = cleanup_timeout_seconds + + async def run(self, command: GitByteCommand) -> GitByteResult: + process = await self._start(command) + stdout = bytearray() + stderr = bytearray() + budget = _OutputBudget(command.max_output_bytes) + readers = ( + asyncio.create_task(self._read(process.stdout, stdout, budget)), + asyncio.create_task(self._read(process.stderr, stderr, budget)), + ) + writer = asyncio.create_task(self._write_stdin(process, command.stdin)) + process_wait = asyncio.create_task(process.wait()) + output_wait = asyncio.create_task(budget.exceeded.wait()) + timed_out = False + try: + done, _ = await asyncio.wait( + (process_wait, output_wait), + timeout=command.timeout_seconds, + return_when=asyncio.FIRST_COMPLETED, + ) + if not done: + timed_out = True + await self._terminate_tree(process) + elif output_wait in done and output_wait.result(): + await self._terminate_tree(process) + else: + process_wait.result() + await asyncio.gather(writer, *readers) + except asyncio.CancelledError: + await asyncio.shield(self._terminate_tree(process)) + await self._cancel_tasks((writer, *readers)) + raise + except Exception: + await self._best_effort_terminate(process) + await self._cancel_tasks((writer, *readers)) + raise WorktreeGitError( + WorktreeErrorCode.REPOSITORY_UNSUPPORTED, + "Git command I/O failed.", + ) from None + finally: + output_wait.cancel() + await asyncio.gather(output_wait, return_exceptions=True) + if not process_wait.done(): + process_wait.cancel() + await asyncio.gather(process_wait, return_exceptions=True) + return GitByteResult( + stdout=bytes(stdout), + stderr=bytes(stderr), + exit_code=process.returncode, + timed_out=timed_out, + output_limit_exceeded=budget.exceeded.is_set(), + ) + + async def _start(self, command: GitByteCommand) -> asyncio.subprocess.Process: + creation_flags = 0 + start_new_session = os.name != "nt" + if os.name == "nt": + creation_flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr( + subprocess, + "CREATE_NO_WINDOW", + 0, + ) + try: + return await asyncio.create_subprocess_exec( + *command.argv, + cwd=command.cwd, + env=self._environment, + stdin=( + asyncio.subprocess.PIPE + if command.stdin is not None + else asyncio.subprocess.DEVNULL + ), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=start_new_session, + creationflags=creation_flags, + ) + except (FileNotFoundError, OSError): + raise WorktreeGitError( + WorktreeErrorCode.REPOSITORY_UNSUPPORTED, + "Git executable could not be started.", + ) from None + + @staticmethod + async def _read( + stream: asyncio.StreamReader | None, + destination: bytearray, + budget: _OutputBudget, + ) -> None: + if stream is None: + return + while chunk := await stream.read(_READ_CHUNK_BYTES): + destination.extend(await budget.retain(chunk)) + + @staticmethod + async def _write_stdin( + process: asyncio.subprocess.Process, + content: bytes | None, + ) -> None: + if content is None or process.stdin is None: + return + try: + process.stdin.write(content) + await process.stdin.drain() + process.stdin.close() + await process.stdin.wait_closed() + except (BrokenPipeError, ConnectionResetError): + return + + async def _best_effort_terminate(self, process: asyncio.subprocess.Process) -> None: + if process.returncode is None: + await asyncio.gather(self._terminate_tree(process), return_exceptions=True) + + async def _terminate_tree(self, process: asyncio.subprocess.Process) -> None: + if process.returncode is not None: + return + try: + if os.name == "nt": + await self._terminate_windows_tree(process) + else: + os.killpg(process.pid, signal.SIGTERM) + except (ProcessLookupError, ChildProcessError): + return + except OSError: + process.kill() + try: + async with asyncio.timeout(self._cleanup_timeout_seconds): + await process.wait() + except TimeoutError: + process.kill() + async with asyncio.timeout(self._cleanup_timeout_seconds): + await process.wait() + + async def _terminate_windows_tree(self, process: asyncio.subprocess.Process) -> None: + system_root = self._environment.get("SYSTEMROOT") + if system_root is None: + raise OSError("Windows system root is unavailable.") + taskkill = Path(system_root) / "System32" / "taskkill.exe" + killer = await asyncio.create_subprocess_exec( + str(taskkill), + "/PID", + str(process.pid), + "/T", + "/F", + env=self._environment, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + async with asyncio.timeout(self._cleanup_timeout_seconds): + await killer.wait() + + @staticmethod + async def _cancel_tasks(tasks: tuple[asyncio.Task[None], ...]) -> None: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + +class WorktreeGit: + def __init__( + self, + profile: WorktreeProfile, + *, + runner: GitByteCommandRunner | None = None, + command_timeout_seconds: float = 30, + ) -> None: + if not 0 < command_timeout_seconds <= 300: + raise ValueError("Git command timeout is invalid.") + self._profile = profile + self._runner = runner or GitBytesRunner( + cleanup_timeout_seconds=min(30, profile.limits.cleanup_timeout_seconds) + ) + self._timeout = command_timeout_seconds + + async def repository_info(self) -> tuple[Path, bool]: + output = await self._execute( + ("rev-parse", "--show-toplevel", "--is-bare-repository"), + max_output_bytes=16 * 1024, + ) + lines = _decode_lines(output) + if len(lines) != 2 or lines[1] not in {"true", "false"}: + raise _invalid_git_output() + try: + top_level = Path(lines[0]).resolve(strict=True) + except OSError: + raise _invalid_git_output() from None + return top_level, lines[1] == "true" + + async def head_sha(self) -> str: + output = await self._execute( + ("rev-parse", "--verify", "HEAD^{commit}"), + max_output_bytes=1024, + ) + value = _decode_single_line(output) + if _SHA1.fullmatch(value) is None: + raise _invalid_git_output() + return value + + async def status_porcelain(self) -> bytes: + return await self._execute( + ( + "status", + "--porcelain=v2", + "-z", + "--untracked-files=all", + "--ignore-submodules=none", + ), + max_output_bytes=16 * 1024 * 1024, + ) + + async def index_pointers(self) -> tuple[GitIndexPointer, ...]: + output = await self._execute( + ("ls-files", "--stage", "--sparse", "-z"), + max_output_bytes=32 * 1024 * 1024, + ) + return parse_index_pointers( + output, + max_entries=self._profile.limits.max_tracked_files, + max_path_chars=self._profile.limits.max_path_chars, + ) + + async def read_blobs(self, object_ids: tuple[str, ...]) -> dict[str, bytes]: + if ( + not object_ids + or len(object_ids) > self._profile.limits.max_tracked_files + or len(set(object_ids)) != len(object_ids) + or any(_SHA1.fullmatch(object_id) is None for object_id in object_ids) + ): + raise _invalid_git_output() + request = "".join(f"{object_id}\n" for object_id in object_ids).encode("ascii") + overhead = len(object_ids) * 80 + output = await self._execute( + ("cat-file", "--batch"), + stdin=request, + max_output_bytes=self._profile.limits.max_tracked_bytes + overhead, + ) + return parse_batch_blobs( + output, + object_ids, + max_total_bytes=self._profile.limits.max_tracked_bytes, + ) + + async def add_worktree(self, lease_id: str, path: Path, base_sha: str) -> None: + await self._execute( + ( + "worktree", + "add", + "--detach", + "--no-checkout", + "--lock", + "--reason", + f"mini-code-agent:{lease_id}", + str(path), + base_sha, + ), + max_output_bytes=1024 * 1024, + ) + + async def unlock_worktree(self, path: Path) -> None: + await self._execute( + ("worktree", "unlock", str(path)), + max_output_bytes=1024 * 1024, + ) + + async def remove_worktree(self, path: Path) -> None: + await self._execute( + ("worktree", "remove", "--force", str(path)), + max_output_bytes=1024 * 1024, + ) + + async def prune_worktrees(self) -> None: + await self._execute( + ("worktree", "prune", "--expire", "now"), + max_output_bytes=1024 * 1024, + ) + + async def worktree_list(self) -> bytes: + return await self._execute( + ("worktree", "list", "--porcelain", "-z"), + max_output_bytes=16 * 1024 * 1024, + ) + + async def _execute( + self, + operation: tuple[str, ...], + *, + max_output_bytes: int, + stdin: bytes | None = None, + ) -> bytes: + if not _is_regular_unlinked_file(self._profile.git_executable): + raise WorktreeGitError( + WorktreeErrorCode.REPOSITORY_UNSUPPORTED, + "Pinned Git executable is unavailable.", + ) + prefix = ( + str(self._profile.git_executable), + "--no-pager", + "--no-optional-locks", + "-c", + "core.fsmonitor=false", + "-c", + f"core.hooksPath={self._profile.state_root / 'hooks-empty'}", + "-C", + str(self._profile.repository_root), + ) + result = await self._runner.run( + GitByteCommand( + argv=(*prefix, *operation), + cwd=self._profile.repository_root, + timeout_seconds=self._timeout, + max_output_bytes=max_output_bytes, + stdin=stdin, + ) + ) + if result.timed_out: + raise WorktreeGitError( + WorktreeErrorCode.REPOSITORY_UNSUPPORTED, + "Git command timed out.", + ) + if result.output_limit_exceeded: + raise WorktreeGitError( + WorktreeErrorCode.REPOSITORY_UNSUPPORTED, + "Git command output exceeded its limit.", + ) + if result.exit_code != 0: + raise WorktreeGitError( + WorktreeErrorCode.REPOSITORY_UNSUPPORTED, + "Git command failed.", + ) + return result.stdout + + +def parse_index_pointers( + output: bytes, + *, + max_entries: int, + max_path_chars: int, +) -> tuple[GitIndexPointer, ...]: + if output and not output.endswith(b"\0"): + raise _invalid_git_output() + raw_records = output[:-1].split(b"\0") if output else [] + if len(raw_records) > max_entries or any(not record for record in raw_records): + raise _invalid_git_output() + entries: list[GitIndexPointer] = [] + casefolded: set[str] = set() + for record in raw_records: + matched = _INDEX_HEADER.match(record) + if matched is None: + raise _invalid_git_output() + mode, object_id, stage = matched.groups() + if stage != b"0": + raise _invalid_git_output() + try: + path = record[matched.end() :].decode("utf-8") + parsed_mode = mode.decode("ascii") + if parsed_mode not in {"100644", "100755"}: + raise _invalid_git_output() + entry = GitIndexPointer( + path=path, + mode=cast(Literal["100644", "100755"], parsed_mode), + object_id=object_id.decode("ascii"), + stage=0, + ) + except (UnicodeDecodeError, ValidationError): + raise _invalid_git_output() from None + folded = entry.path.casefold() + if folded in casefolded or len(entry.path) > max_path_chars: + raise _invalid_git_output() + casefolded.add(folded) + entries.append(entry) + return tuple(entries) + + +def parse_batch_blobs( + output: bytes, + object_ids: tuple[str, ...], + *, + max_total_bytes: int, +) -> dict[str, bytes]: + position = 0 + total = 0 + blobs: dict[str, bytes] = {} + for expected in object_ids: + newline = output.find(b"\n", position) + if newline < 0: + raise _invalid_git_output() + matched = _BATCH_HEADER.fullmatch(output[position:newline]) + if matched is None or matched.group(1).decode("ascii") != expected: + raise _invalid_git_output() + size = int(matched.group(2)) + total += size + if total > max_total_bytes: + raise _invalid_git_output() + start = newline + 1 + end = start + size + if end >= len(output) or output[end : end + 1] != b"\n": + raise _invalid_git_output() + blobs[expected] = output[start:end] + position = end + 1 + if position != len(output) or len(blobs) != len(object_ids): + raise _invalid_git_output() + return blobs + + +def _decode_lines(output: bytes) -> list[str]: + try: + return output.decode("utf-8").splitlines() + except UnicodeDecodeError: + raise _invalid_git_output() from None + + +def _decode_single_line(output: bytes) -> str: + lines = _decode_lines(output) + if len(lines) != 1: + raise _invalid_git_output() + return lines[0] + + +def _is_regular_unlinked_file(path: Path) -> bool: + try: + metadata = path.lstat() + except OSError: + return False + attributes = getattr(metadata, "st_file_attributes", 0) + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + return ( + stat.S_ISREG(metadata.st_mode) + and not stat.S_ISLNK(metadata.st_mode) + and not (attributes & reparse_flag) + ) + + +def _invalid_git_output() -> WorktreeGitError: + return WorktreeGitError( + WorktreeErrorCode.REPOSITORY_UNSUPPORTED, + "Git returned invalid bounded output.", + ) diff --git a/src/mini_code_agent/worktrees/models.py b/src/mini_code_agent/worktrees/models.py index 7e9c991..9fd691f 100644 --- a/src/mini_code_agent/worktrees/models.py +++ b/src/mini_code_agent/worktrees/models.py @@ -170,6 +170,20 @@ def validate_path(cls, value: str) -> str: return _normalize_relative_path(value) +class GitIndexPointer(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + path: RelativePath + mode: Literal["100644", "100755"] + object_id: str = Field(pattern=_SHA1) + stage: Literal[0] = 0 + + @field_validator("path") + @classmethod + def validate_path(cls, value: str) -> str: + return _normalize_relative_path(value) + + class MutationLedgerEntry(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) diff --git a/src/mini_code_agent/worktrees/state.py b/src/mini_code_agent/worktrees/state.py new file mode 100644 index 0000000..9032a8b --- /dev/null +++ b/src/mini_code_agent/worktrees/state.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +import tempfile +from contextlib import suppress +from pathlib import Path + +from mini_code_agent.worktrees.models import CandidateState, WorktreeProfile + +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_CANDIDATE_ROOT = "candidates" + + +class WorktreeStateError(RuntimeError): + pass + + +class WorktreeStateStore: + def __init__(self, profile: WorktreeProfile) -> None: + self._profile = profile + self._root = profile.state_root + + @property + def root(self) -> Path: + return self._root + + def initialize(self) -> None: + self._verify_directory(self._root) + self._ensure_managed_directory(self._root / "leases") + self._ensure_managed_directory(self._root / "hooks-empty") + candidate_root = self._root / _CANDIDATE_ROOT + self._ensure_managed_directory(candidate_root) + for state in CandidateState: + self._ensure_managed_directory(candidate_root / state.value) + + def begin_candidate(self, candidate_id: str) -> Path: + self._validate_identifier(candidate_id) + self._verify_layout() + if any(self._candidate_path(state, candidate_id).exists() for state in CandidateState): + raise WorktreeStateError("Candidate identifier already exists.") + path = self._candidate_path(CandidateState.BUILDING, candidate_id) + try: + path.mkdir(mode=0o700) + except OSError: + raise WorktreeStateError("Candidate directory could not be created.") from None + self._verify_directory(path) + self._ensure_managed_directory(path / "blobs") + return path + + def write_candidate_json( + self, + candidate_id: str, + filename: str, + payload: object, + ) -> Path: + candidate = self._building_candidate(candidate_id) + if ( + not filename.endswith(".json") + or filename in {".json", "..json"} + or "/" in filename + or "\\" in filename + or "\0" in filename + ): + raise WorktreeStateError("Candidate JSON filename is invalid.") + try: + encoded = ( + json.dumps( + payload, + ensure_ascii=True, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ) + + "\n" + ).encode("utf-8") + except (TypeError, ValueError): + raise WorktreeStateError("Candidate JSON payload is invalid.") from None + target = candidate / filename + self._publish_immutable(target, encoded) + return target + + def write_candidate_blob( + self, + candidate_id: str, + digest: str, + content: bytes, + ) -> Path: + candidate = self._building_candidate(candidate_id) + if _SHA256.fullmatch(digest) is None or hashlib.sha256(content).hexdigest() != digest: + raise WorktreeStateError("Candidate blob hash is invalid.") + if len(content) > self._profile.limits.max_file_bytes: + raise WorktreeStateError("Candidate blob exceeds the file limit.") + target = candidate / "blobs" / digest + self._publish_immutable(target, content) + return target + + def transition_candidate( + self, + candidate_id: str, + source: CandidateState, + target: CandidateState, + ) -> Path: + self._validate_identifier(candidate_id) + self._verify_layout() + source_path = self._candidate_path(source, candidate_id) + target_path = self._candidate_path(target, candidate_id) + self._verify_directory(source_path) + if target_path.exists(): + raise WorktreeStateError("Candidate target state already exists.") + try: + source_path.rename(target_path) + except OSError: + raise WorktreeStateError("Candidate state transition failed.") from None + self._verify_directory(target_path) + return target_path + + def _building_candidate(self, candidate_id: str) -> Path: + self._validate_identifier(candidate_id) + self._verify_layout() + candidate = self._candidate_path(CandidateState.BUILDING, candidate_id) + self._verify_directory(candidate) + self._verify_directory(candidate / "blobs") + return candidate + + def _candidate_path(self, state: CandidateState, candidate_id: str) -> Path: + return self._root / _CANDIDATE_ROOT / state.value / candidate_id + + def _verify_layout(self) -> None: + self._verify_directory(self._root) + self._verify_directory(self._root / _CANDIDATE_ROOT) + for state in CandidateState: + self._verify_directory(self._root / _CANDIDATE_ROOT / state.value) + + @staticmethod + def _validate_identifier(identifier: str) -> None: + if _IDENTIFIER.fullmatch(identifier) is None: + raise WorktreeStateError("State identifier is invalid.") + + @staticmethod + def _ensure_managed_directory(path: Path) -> None: + try: + path.mkdir(mode=0o700, exist_ok=True) + if os.name != "nt": + path.chmod(0o700) + except OSError: + raise WorktreeStateError("Managed state directory could not be created.") from None + WorktreeStateStore._verify_directory(path) + + @staticmethod + def _verify_directory(path: Path) -> None: + if _is_link_or_reparse(path): + raise WorktreeStateError("Managed state path cannot be a link.") + try: + mode = path.stat(follow_symlinks=False).st_mode + except OSError: + raise WorktreeStateError("Managed state directory is unavailable.") from None + if not stat.S_ISDIR(mode): + raise WorktreeStateError("Managed state path is not a directory.") + + @staticmethod + def _publish_immutable(target: Path, content: bytes) -> None: + WorktreeStateStore._verify_directory(target.parent) + if target.exists(): + raise WorktreeStateError("Immutable state file already exists.") + descriptor = -1 + temp_path: Path | None = None + try: + descriptor, raw_temp = tempfile.mkstemp( + prefix=".mini-code-agent-", + suffix=".tmp", + dir=target.parent, + ) + temp_path = Path(raw_temp) + if os.name != "nt": + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb", closefd=True) as stream: + descriptor = -1 + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.link(temp_path, target) + temp_path.unlink() + temp_path = None + _fsync_directory(target.parent) + except (FileExistsError, OSError): + raise WorktreeStateError("Immutable state file could not be published.") from None + finally: + if descriptor >= 0: + os.close(descriptor) + if temp_path is not None: + with suppress(OSError): + temp_path.unlink() + + +def _is_link_or_reparse(path: Path) -> bool: + try: + metadata = path.lstat() + except OSError: + return True + attributes = getattr(metadata, "st_file_attributes", 0) + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + return stat.S_ISLNK(metadata.st_mode) or bool(attributes & reparse_flag) + + +def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) diff --git a/tests/unit/worktrees/helpers.py b/tests/unit/worktrees/helpers.py new file mode 100644 index 0000000..5cac536 --- /dev/null +++ b/tests/unit/worktrees/helpers.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from mini_code_agent.agent.models import AgentLimits +from mini_code_agent.subagents.models import SubagentProfile +from mini_code_agent.worktrees.models import WorktreeProfile + + +def worktree_profile(tmp_path: Path, *, git_executable: Path | None = None) -> WorktreeProfile: + repository = tmp_path / "repository" + state = tmp_path / "state" + executable = git_executable or tmp_path / ("git.exe" if os.name == "nt" else "git") + repository.mkdir(exist_ok=True) + state.mkdir(exist_ok=True) + if git_executable is None: + executable.touch(exist_ok=True) + if os.name != "nt": + state.chmod(0o700) + if git_executable is None: + executable.chmod(0o700) + return WorktreeProfile( + repository_root=repository, + state_root=state, + git_executable=executable, + allowed_path_prefixes=("src", "tests"), + implementation_profile=SubagentProfile( + profile_id="implementation", + local_name="delegate_implementation", + description="Implement one bounded task.", + system_prompt="Change only files required by the task.", + tool_names=("read_file", "search_text", "write_file", "edit_file"), + mode="implementation", + agent_limits=AgentLimits(max_turns=8, max_tool_calls=32), + ), + ) diff --git a/tests/unit/worktrees/test_git.py b/tests/unit/worktrees/test_git.py new file mode 100644 index 0000000..7244b8c --- /dev/null +++ b/tests/unit/worktrees/test_git.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from mini_code_agent.worktrees.git import ( + GitByteCommand, + GitByteResult, + GitBytesRunner, + WorktreeGit, + WorktreeGitError, + parse_batch_blobs, + parse_index_pointers, +) +from mini_code_agent.worktrees.models import WorktreeErrorCode +from mini_code_agent.worktrees.state import WorktreeStateStore + +from .helpers import worktree_profile + + +class RecordingRunner: + def __init__(self, *results: GitByteResult) -> None: + self.results = list(results) + self.commands: list[GitByteCommand] = [] + + async def run(self, command: GitByteCommand) -> GitByteResult: + self.commands.append(command) + return self.results.pop(0) + + +def result(stdout: bytes = b"", *, exit_code: int = 0) -> GitByteResult: + return GitByteResult( + stdout=stdout, + stderr=b"", + exit_code=exit_code, + timed_out=False, + output_limit_exceeded=False, + ) + + +@pytest.mark.asyncio +async def test_git_uses_pinned_executable_fixed_prefix_and_no_shell(tmp_path: Path) -> None: + profile = worktree_profile(tmp_path) + runner = RecordingRunner(result(b"a" * 40 + b"\n")) + git = WorktreeGit(profile, runner=runner) + + assert await git.head_sha() == "a" * 40 + command = runner.commands[0] + assert command.argv == ( + str(profile.git_executable), + "--no-pager", + "--no-optional-locks", + "-c", + "core.fsmonitor=false", + "-c", + f"core.hooksPath={profile.state_root / 'hooks-empty'}", + "-C", + str(profile.repository_root), + "rev-parse", + "--verify", + "HEAD^{commit}", + ) + assert command.cwd == profile.repository_root + assert command.stdin is None + + +@pytest.mark.asyncio +async def test_git_revalidates_executable_before_every_command(tmp_path: Path) -> None: + profile = worktree_profile(tmp_path) + runner = RecordingRunner(result(b"a" * 40 + b"\n")) + git = WorktreeGit(profile, runner=runner) + profile.git_executable.unlink() + + with pytest.raises(WorktreeGitError) as raised: + await git.head_sha() + + assert raised.value.code is WorktreeErrorCode.REPOSITORY_UNSUPPORTED + assert runner.commands == [] + + +@pytest.mark.parametrize( + "payload", + [ + b"100644 " + b"a" * 40 + b" 0\tsrc/app.py", + b"100644 " + b"a" * 40 + b" 1\tsrc/app.py\0", + b"120000 " + b"a" * 40 + b" 0\tsrc/app.py\0", + b"100644 " + + b"a" * 40 + + b" 0\tsrc/app.py\x00" + + b"100644 " + + b"b" * 40 + + b" 0\tSRC/app.py\x00", + b"100644 " + b"a" * 40 + b" 0\tsrc/\xff.py\0", + ], +) +def test_index_parser_rejects_truncated_unsupported_or_colliding_entries( + payload: bytes, +) -> None: + with pytest.raises(WorktreeGitError): + parse_index_pointers(payload, max_entries=20_000, max_path_chars=1024) + + +def test_index_parser_accepts_hostile_but_valid_nul_delimited_names() -> None: + payload = ( + b"100644 " + b"a" * 40 + b" 0\tsrc/name with newline\nand tab\t.py\0" + b"100755 " + b"b" * 40 + b" 0\ttests/run.py\0" + ) + + entries = parse_index_pointers(payload, max_entries=20_000, max_path_chars=1024) + + assert [entry.path for entry in entries] == [ + "src/name with newline\nand tab\t.py", + "tests/run.py", + ] + assert entries[1].mode == "100755" + + +def test_batch_blob_parser_validates_order_size_and_terminators() -> None: + first = b"a" * 40 + second = b"b" * 40 + payload = first + b" blob 3\none\n" + second + b" blob 3\ntwo\n" + + blobs = parse_batch_blobs(payload, (first.decode(), second.decode()), max_total_bytes=6) + + assert blobs == {first.decode(): b"one", second.decode(): b"two"} + with pytest.raises(WorktreeGitError): + parse_batch_blobs(payload[:-1], (first.decode(), second.decode()), max_total_bytes=6) + with pytest.raises(WorktreeGitError): + parse_batch_blobs(payload, (second.decode(), first.decode()), max_total_bytes=6) + with pytest.raises(WorktreeGitError): + parse_batch_blobs(payload, (first.decode(), second.decode()), max_total_bytes=5) + + +@pytest.mark.asyncio +async def test_byte_runner_enforces_output_and_timeout_limits(tmp_path: Path) -> None: + runner = GitBytesRunner(cleanup_timeout_seconds=2) + output_command = GitByteCommand( + argv=(sys.executable, "-c", "import sys; sys.stdout.buffer.write(b'x' * 10000)"), + cwd=tmp_path, + timeout_seconds=5, + max_output_bytes=128, + ) + timeout_command = GitByteCommand( + argv=(sys.executable, "-c", "import time; time.sleep(30)"), + cwd=tmp_path, + timeout_seconds=0.1, + max_output_bytes=128, + ) + + output = await runner.run(output_command) + timed_out = await runner.run(timeout_command) + + assert output.output_limit_exceeded is True + assert len(output.stdout) + len(output.stderr) <= 128 + assert timed_out.timed_out is True + + +@pytest.mark.asyncio +async def test_git_rejects_failed_timed_out_or_truncated_commands(tmp_path: Path) -> None: + profile = worktree_profile(tmp_path) + failures = [ + GitByteResult( + stdout=b"", + stderr=b"secret", + exit_code=1, + timed_out=False, + output_limit_exceeded=False, + ), + GitByteResult( + stdout=b"", + stderr=b"", + exit_code=None, + timed_out=True, + output_limit_exceeded=False, + ), + GitByteResult( + stdout=b"a" * 40, + stderr=b"", + exit_code=0, + timed_out=False, + output_limit_exceeded=True, + ), + ] + + for failure in failures: + git = WorktreeGit(profile, runner=RecordingRunner(failure)) + with pytest.raises(WorktreeGitError): + await git.head_sha() + + +@pytest.mark.asyncio +async def test_git_reads_real_repository_index_and_raw_blobs(tmp_path: Path) -> None: + discovered_git = shutil.which("git") + if discovered_git is None: + pytest.skip("Git is unavailable.") + git_executable = Path(discovered_git).resolve(strict=True) + profile = worktree_profile(tmp_path, git_executable=git_executable) + WorktreeStateStore(profile).initialize() + content = b"\x00raw\r\nbytes\xff" + tracked = profile.repository_root / "src" / "raw.bin" + tracked.parent.mkdir() + tracked.write_bytes(content) + _git(profile.repository_root, "init") + _git(profile.repository_root, "config", "user.email", "agent@example.invalid") + _git(profile.repository_root, "config", "user.name", "Agent Test") + _git(profile.repository_root, "add", "--", "src/raw.bin") + _git(profile.repository_root, "commit", "-m", "initial") + git = WorktreeGit(profile) + + top_level, bare = await git.repository_info() + head = await git.head_sha() + status = await git.status_porcelain() + pointers = await git.index_pointers() + blobs = await git.read_blobs(tuple(pointer.object_id for pointer in pointers)) + + assert top_level == profile.repository_root + assert bare is False + assert len(head) == 40 + assert status == b"" + assert len(pointers) == 1 + assert pointers[0].path == "src/raw.bin" + assert blobs[pointers[0].object_id] == content + + +def _git(repository: Path, *arguments: str) -> None: + subprocess.run( + ("git", "-C", str(repository), *arguments), + check=True, + stdin=subprocess.DEVNULL, + capture_output=True, + shell=False, + ) diff --git a/tests/unit/worktrees/test_state.py b/tests/unit/worktrees/test_state.py new file mode 100644 index 0000000..aa3aba9 --- /dev/null +++ b/tests/unit/worktrees/test_state.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from mini_code_agent.worktrees.models import CandidateState +from mini_code_agent.worktrees.state import WorktreeStateError, WorktreeStateStore + +from .helpers import worktree_profile + + +def test_state_store_initializes_private_fixed_layout(tmp_path: Path) -> None: + profile = worktree_profile(tmp_path) + store = WorktreeStateStore(profile) + + store.initialize() + + expected = { + "leases", + "hooks-empty", + *(f"candidates/{state.value}" for state in CandidateState), + } + assert expected <= { + path.relative_to(profile.state_root).as_posix() + for path in profile.state_root.rglob("*") + if path.is_dir() + } + + +@pytest.mark.parametrize("candidate_id", ["../escape", "a/b", ".hidden", "x" * 97, "a\0b"]) +def test_state_store_rejects_non_opaque_identifiers( + tmp_path: Path, + candidate_id: str, +) -> None: + store = WorktreeStateStore(worktree_profile(tmp_path)) + store.initialize() + + with pytest.raises(WorktreeStateError): + store.begin_candidate(candidate_id) + + +def test_state_store_writes_canonical_json_and_content_addressed_blob( + tmp_path: Path, +) -> None: + profile = worktree_profile(tmp_path) + store = WorktreeStateStore(profile) + store.initialize() + candidate = store.begin_candidate("candidate-1") + content = b"print('safe')\n" + digest = hashlib.sha256(content).hexdigest() + + manifest_path = store.write_candidate_json( + "candidate-1", + "manifest.json", + {"z": 1, "a": "value"}, + ) + blob_path = store.write_candidate_blob("candidate-1", digest, content) + + assert candidate == profile.state_root / "candidates" / "building" / "candidate-1" + assert manifest_path.read_bytes() == b'{"a":"value","z":1}\n' + assert blob_path.read_bytes() == content + assert not list(candidate.rglob("*.tmp")) + with pytest.raises(WorktreeStateError): + store.write_candidate_blob("candidate-1", "0" * 64, content) + + +def test_state_store_transitions_by_atomic_directory_rename(tmp_path: Path) -> None: + profile = worktree_profile(tmp_path) + store = WorktreeStateStore(profile) + store.initialize() + store.begin_candidate("candidate-1") + store.write_candidate_json("candidate-1", "manifest.json", {"ready": True}) + + ready = store.transition_candidate( + "candidate-1", + CandidateState.BUILDING, + CandidateState.READY, + ) + + assert ready == profile.state_root / "candidates" / "ready" / "candidate-1" + assert json.loads((ready / "manifest.json").read_text(encoding="utf-8")) == {"ready": True} + with pytest.raises(WorktreeStateError): + store.transition_candidate( + "candidate-1", + CandidateState.BUILDING, + CandidateState.READY, + ) + + +def test_state_store_refuses_linked_managed_directories( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + profile = worktree_profile(tmp_path) + store = WorktreeStateStore(profile) + + def marks_leases_as_link(path: Path) -> bool: + return path.name == "leases" + + monkeypatch.setattr( + "mini_code_agent.worktrees.state._is_link_or_reparse", + marks_leases_as_link, + ) + + with pytest.raises(WorktreeStateError): + store.initialize() From b8b6a2ef2ea9b6f91fefa71ac1ad5d5063e050b0 Mon Sep 17 00:00:00 2001 From: JayLin Date: Thu, 2 Jul 2026 09:04:11 +0800 Subject: [PATCH 04/12] feat: materialize governed worktree leases --- src/mini_code_agent/worktrees/__init__.py | 4 + src/mini_code_agent/worktrees/git.py | 47 +++++ src/mini_code_agent/worktrees/manager.py | 164 ++++++++++++++++++ src/mini_code_agent/worktrees/materialize.py | 159 +++++++++++++++++ src/mini_code_agent/worktrees/models.py | 115 ++++++++++++ src/mini_code_agent/worktrees/state.py | 75 ++++++++ .../test_worktree_materialization.py | 82 +++++++++ tests/unit/worktrees/helpers.py | 10 +- tests/unit/worktrees/test_git.py | 37 ++++ tests/unit/worktrees/test_manager_leases.py | 132 ++++++++++++++ tests/unit/worktrees/test_materialize.py | 129 ++++++++++++++ 11 files changed, 952 insertions(+), 2 deletions(-) create mode 100644 src/mini_code_agent/worktrees/manager.py create mode 100644 src/mini_code_agent/worktrees/materialize.py create mode 100644 tests/integration/test_worktree_materialization.py create mode 100644 tests/unit/worktrees/test_manager_leases.py create mode 100644 tests/unit/worktrees/test_materialize.py diff --git a/src/mini_code_agent/worktrees/__init__.py b/src/mini_code_agent/worktrees/__init__.py index 44b2c92..46d1824 100644 --- a/src/mini_code_agent/worktrees/__init__.py +++ b/src/mini_code_agent/worktrees/__init__.py @@ -1,6 +1,7 @@ """Governed worktree leases and independently verified candidates.""" from mini_code_agent.worktrees.models import ( + BaseManifest, CandidateFile, CandidateOperation, CandidateState, @@ -9,12 +10,14 @@ MutationLedgerEntry, WorktreeError, WorktreeErrorCode, + WorktreeLease, WorktreeLeaseState, WorktreeLimits, WorktreeProfile, ) __all__ = [ + "BaseManifest", "CandidateFile", "CandidateOperation", "CandidateState", @@ -23,6 +26,7 @@ "MutationLedgerEntry", "WorktreeError", "WorktreeErrorCode", + "WorktreeLease", "WorktreeLeaseState", "WorktreeLimits", "WorktreeProfile", diff --git a/src/mini_code_agent/worktrees/git.py b/src/mini_code_agent/worktrees/git.py index b9973a5..17cc8ea 100644 --- a/src/mini_code_agent/worktrees/git.py +++ b/src/mini_code_agent/worktrees/git.py @@ -24,6 +24,7 @@ _INDEX_HEADER = re.compile(rb"^(100644|100755) ([0-9a-f]{40}) ([0-3])\t") _BATCH_HEADER = re.compile(rb"^([0-9a-f]{40}) blob ([0-9]+)$") _SHA1 = re.compile(r"^[0-9a-f]{40}$") +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") _READ_CHUNK_BYTES = 64 * 1024 @@ -341,6 +342,9 @@ async def read_blobs(self, object_ids: tuple[str, ...]) -> dict[str, bytes]: ) async def add_worktree(self, lease_id: str, path: Path, base_sha: str) -> None: + self._validate_lease_worktree_path(path, lease_id=lease_id, require_exists=False) + if _SHA1.fullmatch(base_sha) is None: + raise _invalid_git_output() await self._execute( ( "worktree", @@ -357,12 +361,14 @@ async def add_worktree(self, lease_id: str, path: Path, base_sha: str) -> None: ) async def unlock_worktree(self, path: Path) -> None: + self._validate_lease_worktree_path(path, require_exists=True) await self._execute( ("worktree", "unlock", str(path)), max_output_bytes=1024 * 1024, ) async def remove_worktree(self, path: Path) -> None: + self._validate_lease_worktree_path(path, require_exists=True) await self._execute( ("worktree", "remove", "--force", str(path)), max_output_bytes=1024 * 1024, @@ -380,6 +386,35 @@ async def worktree_list(self) -> bytes: max_output_bytes=16 * 1024 * 1024, ) + def _validate_lease_worktree_path( + self, + path: Path, + *, + lease_id: str | None = None, + require_exists: bool, + ) -> None: + if not path.is_absolute() or path.name != "worktree": + raise _invalid_git_output() + container = path.parent + expected_lease_root = self._profile.state_root / "leases" + if ( + _IDENTIFIER.fullmatch(container.name) is None + or (lease_id is not None and container.name != lease_id) + or not container.is_dir() + or _is_link_or_reparse(container) + or _is_link_or_reparse(expected_lease_root) + ): + raise _invalid_git_output() + try: + if container.parent.resolve(strict=True) != expected_lease_root.resolve(strict=True): + raise _invalid_git_output() + if require_exists and path.resolve(strict=True) != path: + raise _invalid_git_output() + except OSError: + raise _invalid_git_output() from None + if require_exists and (_is_link_or_reparse(path) or not path.is_dir()): + raise _invalid_git_output() + async def _execute( self, operation: tuple[str, ...], @@ -530,6 +565,18 @@ def _is_regular_unlinked_file(path: Path) -> bool: ) +def _is_link_or_reparse(path: Path) -> bool: + try: + metadata = path.lstat() + except FileNotFoundError: + return False + except OSError: + return True + attributes = getattr(metadata, "st_file_attributes", 0) + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + return stat.S_ISLNK(metadata.st_mode) or bool(attributes & reparse_flag) + + def _invalid_git_output() -> WorktreeGitError: return WorktreeGitError( WorktreeErrorCode.REPOSITORY_UNSUPPORTED, diff --git a/src/mini_code_agent/worktrees/manager.py b/src/mini_code_agent/worktrees/manager.py new file mode 100644 index 0000000..7c80f03 --- /dev/null +++ b/src/mini_code_agent/worktrees/manager.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import re +import secrets +from collections.abc import Callable +from contextlib import suppress +from pathlib import Path +from typing import Protocol + +from mini_code_agent.worktrees.git import WorktreeGit +from mini_code_agent.worktrees.materialize import MaterializationError, materialize_index +from mini_code_agent.worktrees.models import ( + BaseManifest, + GitIndexPointer, + WorktreeError, + WorktreeErrorCode, + WorktreeLease, + WorktreeLeaseState, + WorktreeProfile, +) +from mini_code_agent.worktrees.state import WorktreeStateError, WorktreeStateStore + +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") + + +class WorktreeGitService(Protocol): + async def repository_info(self) -> tuple[Path, bool]: ... + + async def head_sha(self) -> str: ... + + async def status_porcelain(self) -> bytes: ... + + async def index_pointers(self) -> tuple[GitIndexPointer, ...]: ... + + async def read_blobs(self, object_ids: tuple[str, ...]) -> dict[str, bytes]: ... + + async def add_worktree(self, lease_id: str, path: Path, base_sha: str) -> None: ... + + +class WorktreeManager: + def __init__( + self, + profile: WorktreeProfile, + *, + git: WorktreeGitService | None = None, + store: WorktreeStateStore | None = None, + id_factory: Callable[[], str] | None = None, + ) -> None: + self._profile = profile + self._git = git or WorktreeGit(profile) + self._store = store or WorktreeStateStore(profile) + self._id_factory = id_factory or _new_lease_id + + async def create_lease(self, *, child_id: str) -> WorktreeLease: + if _IDENTIFIER.fullmatch(child_id) is None: + raise WorktreeError( + WorktreeErrorCode.INVALID_PROFILE, + "Implementation child identifier is invalid.", + ) + try: + self._store.initialize() + if len(self._store.active_lease_ids()) >= self._profile.limits.max_active_leases: + raise WorktreeError( + WorktreeErrorCode.LEASE_LIMIT, + "Active Worktree lease limit was reached.", + ) + lease_id = self._id_factory() + paths = self._store.begin_lease(lease_id) + except WorktreeError: + raise + except (WorktreeStateError, ValueError): + raise WorktreeError( + WorktreeErrorCode.WORKTREE_CREATE_FAILED, + "Worktree lease state could not be created.", + ) from None + + worktree_created = False + try: + base_sha, pointers, blobs = await self._read_clean_base() + await self._git.add_worktree(lease_id, paths.worktree, base_sha) + worktree_created = True + entries = materialize_index( + paths.worktree, + pointers, + blobs, + limits=self._profile.limits, + ) + manifest = BaseManifest.from_entries( + repository_root=self._profile.repository_root, + base_sha=base_sha, + entries=entries, + ) + self._store.write_lease_json( + lease_id, + "base-manifest.json", + manifest.model_dump(mode="json"), + ) + lease = WorktreeLease( + lease_id=lease_id, + child_id=child_id, + repository_root=self._profile.repository_root, + container_path=paths.container, + worktree_path=paths.worktree, + base_sha=base_sha, + base_manifest=manifest, + state=WorktreeLeaseState.ACTIVE, + ) + self._store.write_lease_json( + lease_id, + "lease.json", + lease.model_dump(mode="json", exclude={"base_manifest"}), + ) + return lease + except WorktreeError: + if not worktree_created: + self._abandon_empty_lease(lease_id) + raise + except (MaterializationError, WorktreeStateError): + if not worktree_created: + self._abandon_empty_lease(lease_id) + raise WorktreeError( + WorktreeErrorCode.MATERIALIZATION_FAILED, + "Worktree lease could not be materialized.", + ) from None + except Exception: + if not worktree_created: + self._abandon_empty_lease(lease_id) + raise WorktreeError( + WorktreeErrorCode.WORKTREE_CREATE_FAILED, + "Worktree lease could not be created.", + ) from None + + async def _read_clean_base( + self, + ) -> tuple[str, tuple[GitIndexPointer, ...], dict[str, bytes]]: + top_level, bare = await self._git.repository_info() + if bare or top_level != self._profile.repository_root: + raise WorktreeError( + WorktreeErrorCode.REPOSITORY_UNSUPPORTED, + "Pinned repository identity is unsupported.", + ) + base_sha = await self._git.head_sha() + if await self._git.status_porcelain(): + raise WorktreeError( + WorktreeErrorCode.REPOSITORY_DIRTY, + "Pinned repository must be fully clean.", + ) + pointers = await self._git.index_pointers() + object_ids = tuple(dict.fromkeys(pointer.object_id for pointer in pointers)) + blobs = await self._git.read_blobs(object_ids) if object_ids else {} + if await self._git.head_sha() != base_sha or await self._git.status_porcelain(): + raise WorktreeError( + WorktreeErrorCode.REPOSITORY_DIRTY, + "Pinned repository changed during lease creation.", + ) + return base_sha, pointers, blobs + + def _abandon_empty_lease(self, lease_id: str) -> None: + with suppress(WorktreeStateError): + self._store.abandon_empty_lease(lease_id) + + +def _new_lease_id() -> str: + return f"lease-{secrets.token_hex(16)}" diff --git a/src/mini_code_agent/worktrees/materialize.py b/src/mini_code_agent/worktrees/materialize.py new file mode 100644 index 0000000..62f950a --- /dev/null +++ b/src/mini_code_agent/worktrees/materialize.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import hashlib +import os +import stat +from contextlib import suppress +from pathlib import Path + +from mini_code_agent.worktrees.models import ( + GitIndexEntry, + GitIndexPointer, + WorktreeLimits, +) + + +class MaterializationError(RuntimeError): + pass + + +def materialize_index( + root: Path, + pointers: tuple[GitIndexPointer, ...], + blobs: dict[str, bytes], + *, + limits: WorktreeLimits, +) -> tuple[GitIndexEntry, ...]: + resolved_root = _verify_initial_root(root) + ordered = tuple(sorted(pointers, key=lambda pointer: pointer.path)) + if len(ordered) > limits.max_tracked_files: + raise MaterializationError("Tracked file count exceeds the lease limit.") + if len({pointer.path.casefold() for pointer in ordered}) != len(ordered): + raise MaterializationError("Tracked paths collide.") + required_objects = {pointer.object_id for pointer in ordered} + if set(blobs) != required_objects: + raise MaterializationError("Tracked blobs do not match the index.") + total_bytes = sum(len(blobs[pointer.object_id]) for pointer in ordered) + if total_bytes > limits.max_tracked_bytes: + raise MaterializationError("Tracked bytes exceed the lease limit.") + + entries: list[GitIndexEntry] = [] + try: + for pointer in ordered: + if len(Path(pointer.path).parts) > limits.max_tracked_depth: + raise MaterializationError("Tracked path depth exceeds the lease limit.") + content = blobs[pointer.object_id] + target = resolved_root.joinpath(*pointer.path.split("/")) + parent = _ensure_parent_directories(resolved_root, target.parent) + _write_regular_file(parent, target, content, pointer.mode) + entries.append( + GitIndexEntry( + path=pointer.path, + mode=pointer.mode, + object_id=pointer.object_id, + byte_count=len(content), + sha256=hashlib.sha256(content).hexdigest(), + ) + ) + except MaterializationError: + raise + except OSError: + raise MaterializationError("Tracked files could not be materialized.") from None + return tuple(entries) + + +def _verify_initial_root(root: Path) -> Path: + if _is_link_or_reparse(root): + raise MaterializationError("Worktree root cannot be a link.") + try: + resolved = root.resolve(strict=True) + mode = root.stat(follow_symlinks=False).st_mode + children = tuple(root.iterdir()) + except OSError: + raise MaterializationError("Worktree root is unavailable.") from None + if not stat.S_ISDIR(mode): + raise MaterializationError("Worktree root is not a directory.") + if len(children) != 1 or children[0].name != ".git": + raise MaterializationError("No-checkout Worktree contains unexpected files.") + git_file = children[0] + if _is_link_or_reparse(git_file): + raise MaterializationError("Worktree administrative file cannot be a link.") + try: + if not stat.S_ISREG(git_file.stat(follow_symlinks=False).st_mode): + raise MaterializationError("Worktree administrative path is invalid.") + except OSError: + raise MaterializationError("Worktree administrative path is unavailable.") from None + return resolved + + +def _ensure_parent_directories(root: Path, parent: Path) -> Path: + try: + relative = parent.relative_to(root) + except ValueError: + raise MaterializationError("Tracked path escaped the Worktree.") from None + current = root + for part in relative.parts: + current = current / part + try: + current.mkdir(mode=0o700) + except FileExistsError: + pass + except OSError: + raise MaterializationError("Tracked parent directory could not be created.") from None + if _is_link_or_reparse(current): + raise MaterializationError("Tracked path traverses a link.") + try: + if not stat.S_ISDIR(current.stat(follow_symlinks=False).st_mode): + raise MaterializationError("Tracked parent path is not a directory.") + except OSError: + raise MaterializationError("Tracked parent directory is unavailable.") from None + return current + + +def _write_regular_file( + parent: Path, + target: Path, + content: bytes, + mode: str, +) -> None: + if _is_link_or_reparse(parent): + raise MaterializationError("Tracked parent directory became unsafe.") + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor = -1 + try: + descriptor = os.open(target, flags, 0o600) + with os.fdopen(descriptor, "wb", closefd=True) as stream: + descriptor = -1 + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + if os.name != "nt": + target.chmod(0o755 if mode == "100755" else 0o644, follow_symlinks=False) + except (FileExistsError, OSError): + raise MaterializationError("Tracked file could not be created safely.") from None + finally: + if descriptor >= 0: + os.close(descriptor) + if _is_link_or_reparse(target): + with suppress(OSError): + target.unlink() + raise MaterializationError("Tracked file became a link.") + try: + metadata = target.stat(follow_symlinks=False) + except OSError: + raise MaterializationError("Tracked file could not be verified.") from None + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size != len(content): + raise MaterializationError("Tracked file verification failed.") + + +def _is_link_or_reparse(path: Path) -> bool: + try: + metadata = path.lstat() + except FileNotFoundError: + return False + except OSError: + return True + attributes = getattr(metadata, "st_file_attributes", 0) + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + return stat.S_ISLNK(metadata.st_mode) or bool(attributes & reparse_flag) diff --git a/src/mini_code_agent/worktrees/models.py b/src/mini_code_agent/worktrees/models.py index 9fd691f..3865857 100644 --- a/src/mini_code_agent/worktrees/models.py +++ b/src/mini_code_agent/worktrees/models.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import json import os import stat from enum import StrEnum @@ -184,6 +186,91 @@ def validate_path(cls, value: str) -> str: return _normalize_relative_path(value) +class BaseManifest(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + repository_root: Path + base_sha: str = Field(pattern=_SHA1) + entries: tuple[GitIndexEntry, ...] = Field(max_length=20_000) + tracked_files: int = Field(ge=0, le=20_000) + tracked_bytes: int = Field(ge=0, le=_MAX_TRACKED_BYTES) + manifest_sha256: Sha256 + + @classmethod + def from_entries( + cls, + *, + repository_root: Path, + base_sha: str, + entries: tuple[GitIndexEntry, ...], + ) -> Self: + tracked_files = len(entries) + tracked_bytes = sum(entry.byte_count for entry in entries) + projection = _base_manifest_projection( + repository_root=repository_root, + base_sha=base_sha, + entries=entries, + tracked_files=tracked_files, + tracked_bytes=tracked_bytes, + ) + return cls( + repository_root=repository_root, + base_sha=base_sha, + entries=entries, + tracked_files=tracked_files, + tracked_bytes=tracked_bytes, + manifest_sha256=_canonical_sha256(projection), + ) + + @model_validator(mode="after") + def validate_projection(self) -> Self: + if tuple(sorted(self.entries, key=lambda entry: entry.path)) != self.entries: + raise ValueError("Base manifest entries must be in canonical path order.") + if len({entry.path.casefold() for entry in self.entries}) != len(self.entries): + raise ValueError("Base manifest paths must be case-insensitively unique.") + if self.tracked_files != len(self.entries) or self.tracked_bytes != sum( + entry.byte_count for entry in self.entries + ): + raise ValueError("Base manifest counts are inconsistent.") + projection = _base_manifest_projection( + repository_root=self.repository_root, + base_sha=self.base_sha, + entries=self.entries, + tracked_files=self.tracked_files, + tracked_bytes=self.tracked_bytes, + ) + if self.manifest_sha256 != _canonical_sha256(projection): + raise ValueError("Base manifest hash is inconsistent.") + return self + + +class WorktreeLease(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + lease_id: str = Field(pattern=_IDENTIFIER) + child_id: str = Field(pattern=_IDENTIFIER) + repository_root: Path + container_path: Path + worktree_path: Path + base_sha: str = Field(pattern=_SHA1) + base_manifest: BaseManifest + state: WorktreeLeaseState + + @model_validator(mode="after") + def validate_paths_and_base(self) -> Self: + if ( + not self.repository_root.is_absolute() + or not self.container_path.is_absolute() + or not self.worktree_path.is_absolute() + or self.worktree_path != self.container_path / "worktree" + or self.container_path.name != self.lease_id + or self.base_manifest.repository_root != self.repository_root + or self.base_manifest.base_sha != self.base_sha + ): + raise ValueError("Worktree lease identity is inconsistent.") + return self + + class MutationLedgerEntry(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) @@ -289,3 +376,31 @@ def _validate_secure_state_ancestors(state_root: Path) -> None: mode = state_root.stat(follow_symlinks=False).st_mode if mode & (stat.S_IRWXG | stat.S_IRWXO): raise ValueError("State root must exclude group and other access.") + + +def _base_manifest_projection( + *, + repository_root: Path, + base_sha: str, + entries: tuple[GitIndexEntry, ...], + tracked_files: int, + tracked_bytes: int, +) -> dict[str, object]: + return { + "base_sha": base_sha, + "entries": [entry.model_dump(mode="json") for entry in entries], + "repository_root": str(repository_root), + "tracked_bytes": tracked_bytes, + "tracked_files": tracked_files, + } + + +def _canonical_sha256(value: object) -> str: + encoded = json.dumps( + value, + ensure_ascii=True, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() diff --git a/src/mini_code_agent/worktrees/state.py b/src/mini_code_agent/worktrees/state.py index 9032a8b..574c4c3 100644 --- a/src/mini_code_agent/worktrees/state.py +++ b/src/mini_code_agent/worktrees/state.py @@ -7,6 +7,7 @@ import stat import tempfile from contextlib import suppress +from dataclasses import dataclass from pathlib import Path from mini_code_agent.worktrees.models import CandidateState, WorktreeProfile @@ -20,6 +21,12 @@ class WorktreeStateError(RuntimeError): pass +@dataclass(frozen=True, slots=True) +class LeasePaths: + container: Path + worktree: Path + + class WorktreeStateStore: def __init__(self, profile: WorktreeProfile) -> None: self._profile = profile @@ -52,6 +59,74 @@ def begin_candidate(self, candidate_id: str) -> Path: self._ensure_managed_directory(path / "blobs") return path + def active_lease_ids(self) -> tuple[str, ...]: + leases = self._root / "leases" + self._verify_directory(leases) + identifiers: list[str] = [] + try: + children = tuple(leases.iterdir()) + except OSError: + raise WorktreeStateError("Lease state could not be listed.") from None + for child in children: + self._validate_identifier(child.name) + self._verify_directory(child) + identifiers.append(child.name) + return tuple(sorted(identifiers)) + + def begin_lease(self, lease_id: str) -> LeasePaths: + self._validate_identifier(lease_id) + self._verify_directory(self._root / "leases") + container = self._root / "leases" / lease_id + try: + container.mkdir(mode=0o700) + if os.name != "nt": + container.chmod(0o700) + except OSError: + raise WorktreeStateError("Lease directory could not be created.") from None + self._verify_directory(container) + return LeasePaths(container=container, worktree=container / "worktree") + + def write_lease_json( + self, + lease_id: str, + filename: str, + payload: object, + ) -> Path: + self._validate_identifier(lease_id) + if filename not in {"base-manifest.json", "lease.json"}: + raise WorktreeStateError("Lease JSON filename is invalid.") + container = self._root / "leases" / lease_id + self._verify_directory(container) + try: + encoded = ( + json.dumps( + payload, + ensure_ascii=True, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ) + + "\n" + ).encode("utf-8") + except (TypeError, ValueError): + raise WorktreeStateError("Lease JSON payload is invalid.") from None + target = container / filename + self._publish_immutable(target, encoded) + return target + + def abandon_empty_lease(self, lease_id: str) -> None: + self._validate_identifier(lease_id) + container = self._root / "leases" / lease_id + self._verify_directory(container) + try: + if any(container.iterdir()): + raise WorktreeStateError("Non-empty lease cannot be abandoned.") + container.rmdir() + except WorktreeStateError: + raise + except OSError: + raise WorktreeStateError("Empty lease could not be abandoned.") from None + def write_candidate_json( self, candidate_id: str, diff --git a/tests/integration/test_worktree_materialization.py b/tests/integration/test_worktree_materialization.py new file mode 100644 index 0000000..540a8b6 --- /dev/null +++ b/tests/integration/test_worktree_materialization.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from mini_code_agent.agent.models import AgentLimits +from mini_code_agent.subagents.models import SubagentProfile +from mini_code_agent.worktrees.git import WorktreeGit +from mini_code_agent.worktrees.manager import WorktreeManager +from mini_code_agent.worktrees.models import WorktreeProfile + + +@pytest.mark.asyncio +async def test_real_no_checkout_lease_materializes_only_tracked_index( + tmp_path: Path, +) -> None: + discovered_git = shutil.which("git") + if discovered_git is None: + pytest.skip("Git is unavailable.") + repository = tmp_path / "repository" + state = tmp_path / "state" + repository.mkdir() + state.mkdir() + if os.name != "nt": + state.chmod(0o700) + _git(repository, "init") + _git(repository, "config", "user.email", "agent@example.invalid") + _git(repository, "config", "user.name", "Agent Test") + (repository / ".gitignore").write_text(".env\n.venv/\ncache/\n", encoding="utf-8") + (repository / "src").mkdir() + (repository / "src" / "app.py").write_bytes(b"print('tracked')\r\n") + (repository / ".env").write_text("SECRET=ignored\n", encoding="utf-8") + (repository / ".venv").mkdir() + (repository / ".venv" / "token").write_text("ignored\n", encoding="utf-8") + (repository / "cache").mkdir() + (repository / "cache" / "data").write_text("ignored\n", encoding="utf-8") + _git(repository, "add", "--", ".gitignore", "src/app.py") + _git(repository, "commit", "-m", "initial") + profile = WorktreeProfile( + repository_root=repository, + state_root=state, + git_executable=Path(discovered_git).resolve(strict=True), + allowed_path_prefixes=("src", "tests"), + implementation_profile=SubagentProfile( + profile_id="implementation", + local_name="delegate_implementation", + description="Implement one bounded task.", + system_prompt="Change only files required by the task.", + tool_names=("read_file", "search_text", "write_file", "edit_file"), + mode="implementation", + agent_limits=AgentLimits(max_turns=8, max_tool_calls=32), + ), + ) + git = WorktreeGit(profile) + manager = WorktreeManager(profile, git=git, id_factory=lambda: "lease-real") + + lease = await manager.create_lease(child_id="child-real") + + assert (lease.worktree_path / ".git").is_file() + assert (lease.worktree_path / ".gitignore").is_file() + assert (repository / "src" / "app.py").read_bytes() == b"print('tracked')\r\n" + assert (lease.worktree_path / "src" / "app.py").read_bytes() == b"print('tracked')\n" + assert not (lease.worktree_path / ".env").exists() + assert not (lease.worktree_path / ".venv").exists() + assert not (lease.worktree_path / "cache").exists() + await git.unlock_worktree(lease.worktree_path) + await git.remove_worktree(lease.worktree_path) + await git.prune_worktrees() + + +def _git(repository: Path, *arguments: str) -> None: + subprocess.run( + ("git", "-C", str(repository), *arguments), + check=True, + stdin=subprocess.DEVNULL, + capture_output=True, + shell=False, + ) diff --git a/tests/unit/worktrees/helpers.py b/tests/unit/worktrees/helpers.py index 5cac536..693bbc9 100644 --- a/tests/unit/worktrees/helpers.py +++ b/tests/unit/worktrees/helpers.py @@ -5,10 +5,15 @@ from mini_code_agent.agent.models import AgentLimits from mini_code_agent.subagents.models import SubagentProfile -from mini_code_agent.worktrees.models import WorktreeProfile +from mini_code_agent.worktrees.models import WorktreeLimits, WorktreeProfile -def worktree_profile(tmp_path: Path, *, git_executable: Path | None = None) -> WorktreeProfile: +def worktree_profile( + tmp_path: Path, + *, + git_executable: Path | None = None, + limits: WorktreeLimits | None = None, +) -> WorktreeProfile: repository = tmp_path / "repository" state = tmp_path / "state" executable = git_executable or tmp_path / ("git.exe" if os.name == "nt" else "git") @@ -34,4 +39,5 @@ def worktree_profile(tmp_path: Path, *, git_executable: Path | None = None) -> W mode="implementation", agent_limits=AgentLimits(max_turns=8, max_tool_calls=32), ), + limits=limits or WorktreeLimits(), ) diff --git a/tests/unit/worktrees/test_git.py b/tests/unit/worktrees/test_git.py index 7244b8c..ed376fe 100644 --- a/tests/unit/worktrees/test_git.py +++ b/tests/unit/worktrees/test_git.py @@ -82,6 +82,43 @@ async def test_git_revalidates_executable_before_every_command(tmp_path: Path) - assert runner.commands == [] +@pytest.mark.asyncio +async def test_git_worktree_mutations_require_exact_host_lease_path( + tmp_path: Path, +) -> None: + profile = worktree_profile(tmp_path) + store = WorktreeStateStore(profile) + store.initialize() + paths = store.begin_lease("lease-1") + runner = RecordingRunner(result()) + git = WorktreeGit(profile, runner=runner) + + await git.add_worktree("lease-1", paths.worktree, "a" * 40) + + assert runner.commands[0].argv[-9:] == ( + "worktree", + "add", + "--detach", + "--no-checkout", + "--lock", + "--reason", + "mini-code-agent:lease-1", + str(paths.worktree), + "a" * 40, + ) + with pytest.raises(WorktreeGitError): + await git.add_worktree("other", paths.worktree, "a" * 40) + with pytest.raises(WorktreeGitError): + await git.add_worktree( + "lease-1", + tmp_path / "outside" / "worktree", + "a" * 40, + ) + with pytest.raises(WorktreeGitError): + await git.add_worktree("lease-1", paths.worktree, "invalid") + assert len(runner.commands) == 1 + + @pytest.mark.parametrize( "payload", [ diff --git a/tests/unit/worktrees/test_manager_leases.py b/tests/unit/worktrees/test_manager_leases.py new file mode 100644 index 0000000..a4e4209 --- /dev/null +++ b/tests/unit/worktrees/test_manager_leases.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from mini_code_agent.worktrees.manager import WorktreeManager +from mini_code_agent.worktrees.models import ( + GitIndexPointer, + WorktreeError, + WorktreeErrorCode, + WorktreeLeaseState, + WorktreeLimits, +) + +from .helpers import worktree_profile + + +class FakeGit: + def __init__(self, profile_root: Path, *, status: bytes = b"") -> None: + self.profile_root = profile_root + self.status = status + self.added: list[tuple[str, Path, str]] = [] + + async def repository_info(self) -> tuple[Path, bool]: + return self.profile_root, False + + async def head_sha(self) -> str: + return "a" * 40 + + async def status_porcelain(self) -> bytes: + return self.status + + async def index_pointers(self) -> tuple[GitIndexPointer, ...]: + return ( + GitIndexPointer( + path="src/app.py", + mode="100644", + object_id="b" * 40, + ), + ) + + async def read_blobs(self, object_ids: tuple[str, ...]) -> dict[str, bytes]: + assert object_ids == ("b" * 40,) + return {"b" * 40: b"print('ok')\n"} + + async def add_worktree(self, lease_id: str, path: Path, base_sha: str) -> None: + self.added.append((lease_id, path, base_sha)) + path.mkdir() + (path / ".git").write_text("gitdir: admin\n", encoding="utf-8") + + +@pytest.mark.asyncio +async def test_manager_creates_host_owned_materialized_lease(tmp_path: Path) -> None: + profile = worktree_profile(tmp_path) + git = FakeGit(profile.repository_root) + manager = WorktreeManager( + profile, + git=git, + id_factory=lambda: "lease-1", + ) + + lease = await manager.create_lease(child_id="child-1") + + assert lease.lease_id == "lease-1" + assert lease.child_id == "child-1" + assert lease.base_sha == "a" * 40 + assert lease.state is WorktreeLeaseState.ACTIVE + assert lease.worktree_path == profile.state_root / "leases" / "lease-1" / "worktree" + assert (lease.worktree_path / "src" / "app.py").read_bytes() == b"print('ok')\n" + assert lease.base_manifest.tracked_files == 1 + assert lease.base_manifest.tracked_bytes == 12 + assert git.added == [("lease-1", lease.worktree_path, "a" * 40)] + assert (profile.state_root / "leases" / "lease-1" / "base-manifest.json").is_file() + + +@pytest.mark.asyncio +async def test_manager_rejects_dirty_wrong_or_bare_repository_before_add( + tmp_path: Path, +) -> None: + profile = worktree_profile(tmp_path) + dirty = FakeGit(profile.repository_root, status=b"? unsafe.txt\0") + manager = WorktreeManager(profile, git=dirty, id_factory=lambda: "lease-1") + + with pytest.raises(WorktreeError) as raised: + await manager.create_lease(child_id="child-1") + + assert raised.value.code is WorktreeErrorCode.REPOSITORY_DIRTY + assert dirty.added == [] + + +@pytest.mark.asyncio +async def test_manager_enforces_active_lease_limit_before_git_io(tmp_path: Path) -> None: + profile = worktree_profile( + tmp_path, + limits=WorktreeLimits(max_active_leases=1), + ) + first_git = FakeGit(profile.repository_root) + first = WorktreeManager(profile, git=first_git, id_factory=lambda: "lease-1") + await first.create_lease(child_id="child-1") + second_git = FakeGit(profile.repository_root) + second = WorktreeManager(profile, git=second_git, id_factory=lambda: "lease-2") + + with pytest.raises(WorktreeError) as raised: + await second.create_lease(child_id="child-2") + + assert raised.value.code is WorktreeErrorCode.LEASE_LIMIT + assert second_git.added == [] + + +@pytest.mark.asyncio +async def test_manager_rejects_duplicate_host_identifier_before_git_io( + tmp_path: Path, +) -> None: + profile = worktree_profile(tmp_path) + first = WorktreeManager( + profile, + git=FakeGit(profile.repository_root), + id_factory=lambda: "lease-1", + ) + await first.create_lease(child_id="child-1") + duplicate_git = FakeGit(profile.repository_root) + duplicate = WorktreeManager( + profile, + git=duplicate_git, + id_factory=lambda: "lease-1", + ) + + with pytest.raises(WorktreeError): + await duplicate.create_lease(child_id="child-2") + + assert duplicate_git.added == [] diff --git a/tests/unit/worktrees/test_materialize.py b/tests/unit/worktrees/test_materialize.py new file mode 100644 index 0000000..4cfe1ba --- /dev/null +++ b/tests/unit/worktrees/test_materialize.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import hashlib +import os +import stat +from pathlib import Path + +import pytest + +from mini_code_agent.worktrees.materialize import MaterializationError, materialize_index +from mini_code_agent.worktrees.models import GitIndexPointer, WorktreeLimits + + +def pointer( + path: str, + *, + object_id: str = "a" * 40, + mode: str = "100644", +) -> GitIndexPointer: + return GitIndexPointer.model_validate( + {"path": path, "object_id": object_id, "mode": mode, "stage": 0} + ) + + +def test_materializer_writes_raw_blobs_and_regular_modes(tmp_path: Path) -> None: + root = tmp_path / "worktree" + root.mkdir() + (root / ".git").write_text("gitdir: admin\n", encoding="utf-8") + script = b"#!/usr/bin/env python\nprint('ok')\r\n" + binary = b"\x00\xffraw" + pointers = ( + pointer("src/run.py", object_id="a" * 40, mode="100755"), + pointer("assets/raw.bin", object_id="b" * 40), + ) + + manifest = materialize_index( + root, + pointers, + {"a" * 40: script, "b" * 40: binary}, + limits=WorktreeLimits(), + ) + + assert (root / "src" / "run.py").read_bytes() == script + assert (root / "assets" / "raw.bin").read_bytes() == binary + assert [entry.path for entry in manifest] == ["assets/raw.bin", "src/run.py"] + assert manifest[0].sha256 == hashlib.sha256(binary).hexdigest() + if os.name != "nt": + assert (root / "src" / "run.py").stat().st_mode & stat.S_IXUSR + assert not (root / "assets" / "raw.bin").stat().st_mode & stat.S_IXUSR + + +@pytest.mark.parametrize( + ("pointers", "blobs", "limits"), + [ + ( + (pointer("src/missing.py"),), + {}, + WorktreeLimits(), + ), + ( + (pointer("a/b/c/d.py"),), + {"a" * 40: b"x"}, + WorktreeLimits(max_tracked_depth=3), + ), + ( + (pointer("large.py"),), + {"a" * 40: b"12345"}, + WorktreeLimits(max_tracked_bytes=4), + ), + ( + (pointer("one.py"), pointer("two.py", object_id="b" * 40)), + {"a" * 40: b"1", "b" * 40: b"2"}, + WorktreeLimits(max_tracked_files=1), + ), + ], +) +def test_materializer_rejects_missing_blobs_or_budget_excess( + tmp_path: Path, + pointers: tuple[GitIndexPointer, ...], + blobs: dict[str, bytes], + limits: WorktreeLimits, +) -> None: + root = tmp_path / "worktree" + root.mkdir() + (root / ".git").write_text("gitdir: admin\n", encoding="utf-8") + + with pytest.raises(MaterializationError): + materialize_index(root, pointers, blobs, limits=limits) + + +def test_materializer_rejects_unexpected_or_linked_worktree_content( + tmp_path: Path, +) -> None: + root = tmp_path / "worktree" + root.mkdir() + (root / ".git").write_text("gitdir: admin\n", encoding="utf-8") + (root / "unexpected").write_text("unsafe", encoding="utf-8") + + with pytest.raises(MaterializationError): + materialize_index( + root, + (pointer("src/app.py"),), + {"a" * 40: b"safe"}, + limits=WorktreeLimits(), + ) + + +def test_materializer_rejects_parent_directory_swap_to_link( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = tmp_path / "worktree" + root.mkdir() + (root / ".git").write_text("gitdir: admin\n", encoding="utf-8") + + def marks_src_as_link(path: Path) -> bool: + return path.name == "src" + + monkeypatch.setattr( + "mini_code_agent.worktrees.materialize._is_link_or_reparse", + marks_src_as_link, + ) + with pytest.raises(MaterializationError): + materialize_index( + root, + (pointer("src/app.py"),), + {"a" * 40: b"safe"}, + limits=WorktreeLimits(), + ) From 6899f62a34d4788a77c8c2f54d6996d8cddc4f83 Mon Sep 17 00:00:00 2001 From: JayLin Date: Thu, 2 Jul 2026 09:09:32 +0800 Subject: [PATCH 05/12] feat: record implementation mutation evidence --- src/mini_code_agent/worktrees/ledger.py | 138 +++++++++++++++++++ src/mini_code_agent/worktrees/models.py | 9 +- src/mini_code_agent/worktrees/tools.py | 54 ++++++++ tests/unit/subagents/test_contracts.py | 20 +++ tests/unit/worktrees/test_child_tools.py | 162 +++++++++++++++++++++++ tests/unit/worktrees/test_ledger.py | 153 +++++++++++++++++++++ 6 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 src/mini_code_agent/worktrees/ledger.py create mode 100644 src/mini_code_agent/worktrees/tools.py create mode 100644 tests/unit/worktrees/test_child_tools.py create mode 100644 tests/unit/worktrees/test_ledger.py diff --git a/src/mini_code_agent/worktrees/ledger.py b/src/mini_code_agent/worktrees/ledger.py new file mode 100644 index 0000000..3791b06 --- /dev/null +++ b/src/mini_code_agent/worktrees/ledger.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +from typing import Literal, cast + +from pydantic import ValidationError + +from mini_code_agent.domain.content import ToolCall, ToolResult +from mini_code_agent.policy.models import TrustSource +from mini_code_agent.tools.base import ToolDefinition, ToolExecutor +from mini_code_agent.workspace.models import MutationResult +from mini_code_agent.worktrees.models import MutationLedgerEntry + +_MUTATION_TOOLS = frozenset({"write_file", "edit_file"}) +_MAX_MUTATION_RESULT_CHARS = 2 * 1024 * 1024 + + +class MutationLedgerError(RuntimeError): + pass + + +class MutationLedger: + def __init__(self, *, max_entries: int = 128) -> None: + if not 1 <= max_entries <= 128: + raise ValueError("Mutation ledger entry limit is invalid.") + self._max_entries = max_entries + self._entries: list[MutationLedgerEntry] = [] + self._call_ids: set[str] = set() + self._last_by_path: dict[str, MutationLedgerEntry] = {} + self._compromised = False + + @property + def entries(self) -> tuple[MutationLedgerEntry, ...]: + return tuple(self._entries) + + @property + def compromised(self) -> bool: + return self._compromised + + def record(self, call: ToolCall, result: ToolResult) -> None: + if self._compromised: + raise MutationLedgerError("Mutation ledger is compromised.") + if call.name not in _MUTATION_TOOLS or result.is_error: + return + try: + self._record_success(call, result) + except MutationLedgerError: + self._compromised = True + raise + except Exception: + self._compromised = True + raise MutationLedgerError("Mutation evidence was invalid.") from None + + def _record_success(self, call: ToolCall, result: ToolResult) -> None: + if ( + result.tool_call_id != call.id + or call.id in self._call_ids + or len(self._entries) >= self._max_entries + or len(result.content) > _MAX_MUTATION_RESULT_CHARS + ): + raise MutationLedgerError("Mutation evidence identity was invalid.") + try: + raw = json.loads(result.content) + mutation = MutationResult.model_validate(raw) + except (json.JSONDecodeError, ValidationError, TypeError, ValueError): + raise MutationLedgerError("Mutation result was malformed.") from None + previous = self._last_by_path.get(mutation.path) + if previous is not None and ( + mutation.created or mutation.before_sha256 != previous.after_sha256 + ): + raise MutationLedgerError("Mutation hash chain was discontinuous.") + entry = MutationLedgerEntry( + ordinal=len(self._entries), + tool_call_id=call.id, + tool_name=cast(Literal["write_file", "edit_file"], call.name), + path=mutation.path, + created=mutation.created, + before_sha256=mutation.before_sha256, + after_sha256=mutation.after_sha256, + byte_count=mutation.byte_count, + line_count=mutation.line_count, + ) + self._entries.append(entry) + self._call_ids.add(call.id) + self._last_by_path[entry.path] = entry + + +class LedgerRecordingToolExecutor: + def __init__(self, tools: ToolExecutor, ledger: MutationLedger) -> None: + self._tools = tools + self._ledger = ledger + + @property + def definitions(self) -> tuple[ToolDefinition, ...]: + return self._tools.definitions + + @property + def governance_enforced(self) -> Literal[True]: + if getattr(self._tools, "governance_enforced", None) is not True: + raise ValueError("Wrapped Tool executor is not governed.") + return True + + def trust_source_for(self, tool_name: str) -> TrustSource: + resolver = getattr(self._tools, "trust_source_for", None) + if not callable(resolver): + raise ValueError("Wrapped Tool executor has no trust source.") + candidate = resolver(tool_name) + if not isinstance(candidate, TrustSource): + raise ValueError("Wrapped Tool trust source is invalid.") + return candidate + + async def execute(self, call: ToolCall) -> ToolResult: + if self._ledger.compromised and call.name in _MUTATION_TOOLS: + return _ledger_error(call.id) + result = await self._tools.execute(call) + try: + self._ledger.record(call, result) + except MutationLedgerError: + return _ledger_error(call.id) + return result + + +def _ledger_error(call_id: str) -> ToolResult: + return ToolResult( + tool_call_id=call_id, + content=json.dumps( + { + "error": { + "code": "mutation_ledger_failed", + "message": "Mutation evidence could not be recorded safely.", + } + }, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ), + is_error=True, + ) diff --git a/src/mini_code_agent/worktrees/models.py b/src/mini_code_agent/worktrees/models.py index 3865857..2362f15 100644 --- a/src/mini_code_agent/worktrees/models.py +++ b/src/mini_code_agent/worktrees/models.py @@ -275,7 +275,7 @@ class MutationLedgerEntry(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) ordinal: int = Field(ge=0, le=127) - tool_call_id: str = Field(pattern=_IDENTIFIER) + tool_call_id: str = Field(min_length=1, max_length=128) tool_name: Literal["write_file", "edit_file"] path: RelativePath created: bool @@ -289,6 +289,13 @@ class MutationLedgerEntry(BaseModel): def validate_path(cls, value: str) -> str: return _normalize_relative_path(value) + @field_validator("tool_call_id") + @classmethod + def reject_nul_call_id(cls, value: str) -> str: + if "\0" in value: + raise ValueError("Mutation ToolCall identifier cannot contain NUL.") + return value + @model_validator(mode="after") def validate_hashes(self) -> Self: if self.created != (self.before_sha256 is None): diff --git a/src/mini_code_agent/worktrees/tools.py b/src/mini_code_agent/worktrees/tools.py new file mode 100644 index 0000000..670e7fe --- /dev/null +++ b/src/mini_code_agent/worktrees/tools.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from mini_code_agent.policy.models import TrustSource +from mini_code_agent.subagents.contracts import SubagentCompositionError +from mini_code_agent.subagents.models import SubagentProfile +from mini_code_agent.tools.base import SideEffect, ToolExecutor + +_REQUIRED_IMPLEMENTATION_TOOLS = ( + "read_file", + "search_text", + "write_file", + "edit_file", +) +_OPTIONAL_TEST_TOOL = "run_tests" +_EXPECTED_SIDE_EFFECTS = { + "read_file": SideEffect.READ_ONLY, + "search_text": SideEffect.READ_ONLY, + "write_file": SideEffect.WRITE, + "edit_file": SideEffect.WRITE, + "run_tests": SideEffect.EXECUTE, +} + + +def validate_implementation_child_tools( + profile: SubagentProfile, + tools: ToolExecutor, +) -> None: + try: + definitions = tools.definitions + names = tuple(definition.name for definition in definitions) + accepted_names = { + _REQUIRED_IMPLEMENTATION_TOOLS, + (*_REQUIRED_IMPLEMENTATION_TOOLS, _OPTIONAL_TEST_TOOL), + } + if ( + profile.mode != "implementation" + or profile.tool_names not in accepted_names + or names != profile.tool_names + or any( + definition.side_effect is not _EXPECTED_SIDE_EFFECTS.get(definition.name) + for definition in definitions + ) + or getattr(tools, "governance_enforced", None) is not True + ): + raise SubagentCompositionError + trust_source_for = getattr(tools, "trust_source_for", None) + if not callable(trust_source_for): + raise SubagentCompositionError + if any(trust_source_for(name) is not TrustSource.SUBAGENT for name in names): + raise SubagentCompositionError + except SubagentCompositionError: + raise + except Exception: + raise SubagentCompositionError from None diff --git a/tests/unit/subagents/test_contracts.py b/tests/unit/subagents/test_contracts.py index a66b7a0..28a20b1 100644 --- a/tests/unit/subagents/test_contracts.py +++ b/tests/unit/subagents/test_contracts.py @@ -90,6 +90,26 @@ def test_validate_child_tools_accepts_exact_governed_subagent_contract() -> None validate_child_tools(profile_for(), valid_tools()) +def test_read_only_validator_does_not_grant_implementation_write_authority() -> None: + profile = SubagentProfile.model_validate( + profile_for().model_dump() + | { + "mode": "implementation", + "local_name": "delegate_implementation", + "tool_names": ("read_file", "write_file"), + } + ) + tools = StubTools( + ( + definition("read_file"), + definition("write_file", side_effect=SideEffect.WRITE), + ) + ) + + with pytest.raises(SubagentCompositionError): + validate_child_tools(profile, tools) + + @pytest.mark.parametrize( "tools", [ diff --git a/tests/unit/worktrees/test_child_tools.py b/tests/unit/worktrees/test_child_tools.py new file mode 100644 index 0000000..dd3c19d --- /dev/null +++ b/tests/unit/worktrees/test_child_tools.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from typing import Literal + +import pytest + +from mini_code_agent.domain.content import ToolCall, ToolResult +from mini_code_agent.policy.models import TrustSource +from mini_code_agent.subagents.contracts import SubagentCompositionError +from mini_code_agent.subagents.models import SubagentProfile +from mini_code_agent.tools.base import SideEffect, ToolDefinition +from mini_code_agent.worktrees.tools import validate_implementation_child_tools + + +def definition(name: str, side_effect: SideEffect) -> ToolDefinition: + return ToolDefinition( + name=name, + description=f"Governed {name}.", + input_schema={ + "type": "object", + "properties": {}, + "additionalProperties": False, + }, + side_effect=side_effect, + ) + + +class StubGovernedTools: + def __init__( + self, + definitions: tuple[ToolDefinition, ...], + *, + governed: object = True, + trust_source: TrustSource = TrustSource.SUBAGENT, + ) -> None: + self._definitions = definitions + self._governed = governed + self._trust_source = trust_source + self.results: dict[str, ToolResult] = {} + + @property + def definitions(self) -> tuple[ToolDefinition, ...]: + return self._definitions + + @property + def governance_enforced(self) -> object: + return self._governed + + def trust_source_for(self, tool_name: str) -> TrustSource: + assert tool_name + return self._trust_source + + async def execute(self, call: ToolCall) -> ToolResult: + return self.results.get( + call.id, + ToolResult(tool_call_id=call.id, content="unused"), + ) + + +class LiteralGovernedTools(StubGovernedTools): + @property + def governance_enforced(self) -> Literal[True]: + return True + + +def governed_tools(*, include_tests: bool = False) -> LiteralGovernedTools: + definitions = ( + definition("read_file", SideEffect.READ_ONLY), + definition("search_text", SideEffect.READ_ONLY), + definition("write_file", SideEffect.WRITE), + definition("edit_file", SideEffect.WRITE), + *((definition("run_tests", SideEffect.EXECUTE),) if include_tests else ()), + ) + return LiteralGovernedTools(definitions) + + +def implementation_profile(*, include_tests: bool = False) -> SubagentProfile: + names = ("read_file", "search_text", "write_file", "edit_file") + if include_tests: + names = (*names, "run_tests") + from mini_code_agent.agent.models import AgentLimits + + return SubagentProfile( + profile_id="implementation", + local_name="delegate_implementation", + description="Implement one bounded task.", + system_prompt="Change only files required by the task.", + tool_names=names, + mode="implementation", + agent_limits=AgentLimits(max_turns=8, max_tool_calls=32), + ) + + +def test_implementation_tools_accept_exact_bounded_capabilities() -> None: + validate_implementation_child_tools( + implementation_profile(), + governed_tools(), + ) + validate_implementation_child_tools( + implementation_profile(include_tests=True), + governed_tools(include_tests=True), + ) + + +@pytest.mark.parametrize( + ("profile", "tools"), + [ + ( + None, + governed_tools(), + ), + ( + implementation_profile(), + LiteralGovernedTools( + ( + definition("read_file", SideEffect.READ_ONLY), + definition("search_text", SideEffect.READ_ONLY), + definition("write_file", SideEffect.WRITE), + ) + ), + ), + ( + implementation_profile(), + LiteralGovernedTools( + ( + *governed_tools().definitions, + definition("run_command", SideEffect.EXECUTE), + ) + ), + ), + ( + implementation_profile(), + LiteralGovernedTools( + ( + definition("read_file", SideEffect.READ_ONLY), + definition("search_text", SideEffect.READ_ONLY), + definition("write_file", SideEffect.WRITE), + definition("edit_file", SideEffect.READ_ONLY), + ) + ), + ), + ( + implementation_profile(), + StubGovernedTools(governed_tools().definitions, governed=False), + ), + ( + implementation_profile(), + LiteralGovernedTools( + governed_tools().definitions, + trust_source=TrustSource.MODEL, + ), + ), + ], +) +def test_implementation_tools_reject_mode_or_authority_drift( + profile: SubagentProfile | None, + tools: StubGovernedTools, +) -> None: + if profile is None: + profile = implementation_profile().model_copy(update={"mode": "analysis"}) + with pytest.raises(SubagentCompositionError): + validate_implementation_child_tools(profile, tools) diff --git a/tests/unit/worktrees/test_ledger.py b/tests/unit/worktrees/test_ledger.py new file mode 100644 index 0000000..149d35c --- /dev/null +++ b/tests/unit/worktrees/test_ledger.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import json + +import pytest + +from mini_code_agent.domain.content import ToolCall, ToolResult +from mini_code_agent.workspace.models import MutationResult +from mini_code_agent.worktrees.ledger import ( + LedgerRecordingToolExecutor, + MutationLedger, + MutationLedgerError, +) + +from .test_child_tools import governed_tools + + +def call(call_id: str, *, name: str = "write_file") -> ToolCall: + return ToolCall(id=call_id, name=name, arguments={"unused": True}) + + +def mutation_result( + call_id: str, + *, + path: str = "src/app.py", + created: bool = False, + before: str | None = "a" * 64, + after: str = "b" * 64, +) -> ToolResult: + mutation = MutationResult( + path=path, + created=created, + before_sha256=before, + after_sha256=after, + byte_count=12, + line_count=1, + diff="bounded", + ) + return ToolResult( + tool_call_id=call_id, + content=json.dumps(mutation.model_dump(mode="json")), + ) + + +def test_ledger_records_ordered_host_result_hash_chain() -> None: + ledger = MutationLedger(max_entries=4) + + ledger.record(call("call:1"), mutation_result("call:1")) + ledger.record( + call("call:2", name="edit_file"), + mutation_result("call:2", before="b" * 64, after="c" * 64), + ) + ledger.record( + call("call:3"), + mutation_result( + "call:3", + path="src/new.py", + created=True, + before=None, + after="d" * 64, + ), + ) + + assert ledger.compromised is False + assert [entry.ordinal for entry in ledger.entries] == [0, 1, 2] + assert ledger.entries[1].before_sha256 == ledger.entries[0].after_sha256 + assert ledger.entries[2].created is True + + +@pytest.mark.parametrize( + ("second_call", "second_result"), + [ + ( + call("call-1"), + mutation_result("call-1", before="b" * 64, after="c" * 64), + ), + ( + call("call-2"), + mutation_result("call-2", before="f" * 64, after="c" * 64), + ), + ( + call("call-2"), + ToolResult(tool_call_id="call-2", content='{"forged":true}'), + ), + ( + call("call-2"), + mutation_result("different", before="b" * 64, after="c" * 64), + ), + ], +) +def test_ledger_fails_closed_on_duplicate_discontinuous_or_malformed_results( + second_call: ToolCall, + second_result: ToolResult, +) -> None: + ledger = MutationLedger(max_entries=4) + ledger.record(call("call-1"), mutation_result("call-1")) + + with pytest.raises(MutationLedgerError): + ledger.record(second_call, second_result) + + assert ledger.compromised is True + with pytest.raises(MutationLedgerError): + ledger.record( + call("call-3"), + mutation_result("call-3", before="b" * 64, after="c" * 64), + ) + + +def test_ledger_ignores_read_and_failed_mutation_results() -> None: + ledger = MutationLedger(max_entries=4) + + ledger.record( + call("read-1", name="read_file"), + ToolResult(tool_call_id="read-1", content="read"), + ) + ledger.record( + call("write-1"), + ToolResult(tool_call_id="write-1", content='{"error":{}}', is_error=True), + ) + + assert ledger.entries == () + assert ledger.compromised is False + + +@pytest.mark.asyncio +async def test_recording_executor_derives_ledger_only_after_successful_execution() -> None: + tools = governed_tools() + ledger = MutationLedger(max_entries=4) + wrapped = LedgerRecordingToolExecutor(tools, ledger) + write = call("call-1") + tools.results["call-1"] = mutation_result("call-1") + + result = await wrapped.execute(write) + + assert result.is_error is False + assert len(ledger.entries) == 1 + assert wrapped.definitions == tools.definitions + assert wrapped.governance_enforced is True + assert wrapped.trust_source_for("write_file") == tools.trust_source_for("write_file") + + +@pytest.mark.asyncio +async def test_recording_executor_returns_static_error_when_ledger_is_compromised() -> None: + tools = governed_tools() + ledger = MutationLedger(max_entries=4) + wrapped = LedgerRecordingToolExecutor(tools, ledger) + tools.results["call-1"] = ToolResult(tool_call_id="call-1", content='{"forged":true}') + + result = await wrapped.execute(call("call-1")) + + assert result.is_error is True + assert json.loads(result.content)["error"]["code"] == "mutation_ledger_failed" + assert ledger.compromised is True From 4fb8456cb79ad29d28ed82c7ab801899fa703ee4 Mon Sep 17 00:00:00 2001 From: JayLin Date: Thu, 2 Jul 2026 09:22:05 +0800 Subject: [PATCH 06/12] feat: persist verified worktree candidates --- .../2026-07-02-m6b-worktree-candidates.md | 62 +-- src/mini_code_agent/worktrees/__init__.py | 8 + src/mini_code_agent/worktrees/models.py | 202 +++++++- src/mini_code_agent/worktrees/snapshot.py | 471 ++++++++++++++++++ .../test_worktree_materialization.py | 109 +++- tests/unit/worktrees/test_models.py | 39 +- tests/unit/worktrees/test_snapshot.py | 463 +++++++++++++++++ 7 files changed, 1319 insertions(+), 35 deletions(-) create mode 100644 src/mini_code_agent/worktrees/snapshot.py create mode 100644 tests/unit/worktrees/test_snapshot.py diff --git a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md index 646bc04..fc75095 100644 --- a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md +++ b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md @@ -30,11 +30,11 @@ - Test: `tests/unit/worktrees/test_models.py` - Test: `tests/unit/subagents/test_models.py` -- [ ] Write failing tests for hard ceilings, absolute/existing path requirements, literal allowed-prefix normalization, exact implementation profile mode, canonical identifiers, immutable records, and invalid limit relationships. -- [ ] Extend `SubagentProfile.mode` to support `implementation` without weakening analysis-profile validation. -- [ ] Implement `WorktreeLimits`, `WorktreeProfile`, repository/base/index records, lease states, ledger records, candidate states, candidate file records, and public error codes. -- [ ] Ensure canonical manifests exclude mutable state fields and reject duplicate/case-colliding paths. -- [ ] Run: +- [x] Write failing tests for hard ceilings, absolute/existing path requirements, literal allowed-prefix normalization, exact implementation profile mode, canonical identifiers, immutable records, and invalid limit relationships. +- [x] Extend `SubagentProfile.mode` to support `implementation` without weakening analysis-profile validation. +- [x] Implement `WorktreeLimits`, `WorktreeProfile`, repository/base/index records, lease states, ledger records, candidate states, candidate file records, and public error codes. +- [x] Ensure canonical manifests exclude mutable state fields and reject duplicate/case-colliding paths. +- [x] Run: ```powershell py -m uv run --no-sync pytest tests/unit/worktrees/test_models.py tests/unit/subagents/test_models.py -q @@ -42,7 +42,7 @@ py -m uv run --no-sync ruff check src/mini_code_agent/worktrees src/mini_code_ag py -m uv run --no-sync pyright ``` -- [ ] Commit: `feat: define governed worktree contracts` +- [x] Commit: `feat: define governed worktree contracts` ## Task 2: Build the Fixed Git and Secure State Foundations @@ -52,10 +52,10 @@ py -m uv run --no-sync pyright - Test: `tests/unit/worktrees/test_git.py` - Test: `tests/unit/worktrees/test_state.py` -- [ ] Write failing tests for executable revalidation, no shell invocation, fixed global options/config, bounded byte output, timeout cleanup, stable NUL index parsing, stage/mode rejection, batch blob validation, and hostile filenames. -- [ ] Write failing tests for an absolute state root outside the repository, secure ancestor checks, link/reparse rejection, POSIX permission checks, opaque state IDs, atomic writes/renames, canonical JSON, and immutable blob hashing. -- [ ] Implement a narrow Git adapter with allowlisted operations only: repository discovery, status/HEAD/index inspection, `cat-file --batch`, worktree add/lock/unlock/remove/prune/list. -- [ ] Implement the state layout: +- [x] Write failing tests for executable revalidation, no shell invocation, fixed global options/config, bounded byte output, timeout cleanup, stable NUL index parsing, stage/mode rejection, batch blob validation, and hostile filenames. +- [x] Write failing tests for an absolute state root outside the repository, secure ancestor checks, link/reparse rejection, POSIX permission checks, opaque state IDs, atomic writes/renames, canonical JSON, and immutable blob hashing. +- [x] Implement a narrow Git adapter with allowlisted operations only: repository discovery, status/HEAD/index inspection, `cat-file --batch`, worktree add/lock/unlock/remove/prune/list. +- [x] Implement the state layout: ```text leases/ @@ -68,8 +68,8 @@ candidates/uncertain/ hooks-empty/ ``` -- [ ] Run focused tests, Ruff, and Pyright. -- [ ] Commit: `feat: add secure worktree git and state foundations` +- [x] Run focused tests, Ruff, and Pyright. +- [x] Commit: `feat: add secure worktree git and state foundations` ## Task 3: Create No-Checkout Leases and Materialize the Index @@ -80,13 +80,13 @@ hooks-empty/ - Test: `tests/unit/worktrees/test_manager_leases.py` - Test: `tests/integration/test_worktree_materialization.py` -- [ ] Write failing tests that verify exact top-level/non-bare identity, full clean status including untracked files, exact base SHA, active-lease limits, and host-generated paths. -- [ ] Write failing tests that assert the mandatory `--no-checkout`, detached/locked worktree argv and empty Hooks directory. -- [ ] Write failing tests for tracked file/byte/depth/path limits, 100644/100755-only entries, sparse/unmerged/gitlink/symlink/special rejection, duplicate/case collision rejection, and truncated Git output. -- [ ] Materialize regular files exclusively from index blob bytes, preserving only executable/non-executable regular modes. +- [x] Write failing tests that verify exact top-level/non-bare identity, full clean status including untracked files, exact base SHA, active-lease limits, and host-generated paths. +- [x] Write failing tests that assert the mandatory `--no-checkout`, detached/locked worktree argv and empty Hooks directory. +- [x] Write failing tests for tracked file/byte/depth/path limits, 100644/100755-only entries, sparse/unmerged/gitlink/symlink/special rejection, duplicate/case collision rejection, and truncated Git output. +- [x] Materialize regular files exclusively from index blob bytes, preserving only executable/non-executable regular modes. - [ ] Build a fresh `WorkspaceBoundary` rooted at the lease and persist the immutable base manifest before child execution. -- [ ] Prove with a real Git repository that ignored, untracked, `.env`, cache, and virtual-environment files are absent. -- [ ] Commit: `feat: materialize governed worktree leases` +- [x] Prove with a real Git repository that ignored, untracked, `.env`, cache, and virtual-environment files are absent. +- [x] Commit: `feat: materialize governed worktree leases` ## Task 4: Capture a Trusted Mutation Ledger @@ -97,12 +97,12 @@ hooks-empty/ - Test: `tests/unit/worktrees/test_ledger.py` - Test: `tests/unit/worktrees/test_child_tools.py` -- [ ] Write failing tests for implementation child capability validation: Read/Search plus Write/Edit and optional fixed test Tool only. -- [ ] Prove rejection of arbitrary process/Git/MCP/Skills/Hooks/adoption/delegation Tools and all undeclared Tool names. -- [ ] Wrap successful Write/Edit execution so ledger entries are derived from parsed `MutationResult`, never model arguments. -- [ ] Enforce ordered contiguous before/after hash chains, exact path normalization, call-ID uniqueness, bounded records, and no ledger entry on failed or preview-only mutations. -- [ ] Preserve existing analysis Subagent behavior and read-only validator tests. -- [ ] Commit: `feat: record implementation mutation evidence` +- [x] Write failing tests for implementation child capability validation: Read/Search plus Write/Edit and optional fixed test Tool only. +- [x] Prove rejection of arbitrary process/Git/MCP/Skills/Hooks/adoption/delegation Tools and all undeclared Tool names. +- [x] Wrap successful Write/Edit execution so ledger entries are derived from parsed `MutationResult`, never model arguments. +- [x] Enforce ordered contiguous before/after hash chains, exact path normalization, call-ID uniqueness, bounded records, and no ledger entry on failed or preview-only mutations. +- [x] Preserve existing analysis Subagent behavior and read-only validator tests. +- [x] Commit: `feat: record implementation mutation evidence` ## Task 5: Snapshot and Persist Verified Candidates @@ -113,13 +113,13 @@ hooks-empty/ - Test: `tests/unit/worktrees/test_snapshot.py` - Test: `tests/unit/worktrees/test_candidate_store.py` -- [ ] Write failing tests for an independent complete scan, link/reparse/special rejection, `.git` rejection, case collisions, path/file/byte/diff limits, and allowed-prefix enforcement. -- [ ] Compare every materialized base path by raw SHA-256 and detect additions, modifications, deletions, binary/invalid UTF-8 changes, and mode changes. -- [ ] Require the filesystem changed set to equal the ledger set and every final hash to equal the last ledger hash. -- [ ] Generate deterministic bounded unified diffs and store after-content blobs separately from the canonical immutable manifest. -- [ ] Persist valid candidates atomically from `building` to `ready`; return no candidate when there are no changes. -- [ ] Persist extra regular mutations as a forensic `rejected` manifest before cleanup; retain locked `cleanup_required` leases for severe unsafe or budget failures. -- [ ] Commit: `feat: persist verified worktree candidates` +- [x] Write failing tests for an independent complete scan, link/reparse/special rejection, `.git` rejection, case collisions, path/file/byte/diff limits, and allowed-prefix enforcement. +- [x] Compare every materialized base path by raw SHA-256 and detect additions, modifications, deletions, binary/invalid UTF-8 changes, and mode changes. +- [x] Require the filesystem changed set to equal the ledger set and every final hash to equal the last ledger hash. +- [x] Generate deterministic bounded unified diffs and store after-content blobs separately from the canonical immutable manifest. +- [x] Persist valid candidates atomically from `building` to `ready`; return no candidate when there are no changes. +- [x] Persist extra regular mutations as a forensic `rejected` manifest before cleanup; retain locked `cleanup_required` leases for severe unsafe or budget failures. +- [x] Commit: `feat: persist verified worktree candidates` ## Task 6: Implement Exact Cleanup and Cancellation Finalization diff --git a/src/mini_code_agent/worktrees/__init__.py b/src/mini_code_agent/worktrees/__init__.py index 46d1824..1506f3e 100644 --- a/src/mini_code_agent/worktrees/__init__.py +++ b/src/mini_code_agent/worktrees/__init__.py @@ -2,12 +2,16 @@ from mini_code_agent.worktrees.models import ( BaseManifest, + CandidateDisposition, CandidateFile, + CandidateManifest, CandidateOperation, CandidateState, GitIndexEntry, GitIndexPointer, MutationLedgerEntry, + SnapshotOutcome, + SnapshotStatus, WorktreeError, WorktreeErrorCode, WorktreeLease, @@ -18,12 +22,16 @@ __all__ = [ "BaseManifest", + "CandidateDisposition", "CandidateFile", + "CandidateManifest", "CandidateOperation", "CandidateState", "GitIndexEntry", "GitIndexPointer", "MutationLedgerEntry", + "SnapshotOutcome", + "SnapshotStatus", "WorktreeError", "WorktreeErrorCode", "WorktreeLease", diff --git a/src/mini_code_agent/worktrees/models.py b/src/mini_code_agent/worktrees/models.py index 2362f15..fa73c53 100644 --- a/src/mini_code_agent/worktrees/models.py +++ b/src/mini_code_agent/worktrees/models.py @@ -16,7 +16,7 @@ model_validator, ) -from mini_code_agent.subagents.models import SubagentProfile +from mini_code_agent.subagents.models import SubagentProfile, SubagentStatus _IDENTIFIER = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$" _SHA1 = r"^[0-9a-f]{40}$" @@ -75,6 +75,18 @@ class CandidateOperation(StrEnum): MODIFY = "modify" +class CandidateDisposition(StrEnum): + READY = "ready" + REJECTED = "rejected" + + +class SnapshotStatus(StrEnum): + READY = "ready" + REJECTED = "rejected" + NO_CHANGES = "no_changes" + CLEANUP_REQUIRED = "cleanup_required" + + class WorktreeLimits(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) @@ -334,6 +346,159 @@ def validate_operation(self) -> Self: return self +class CandidateManifest(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + candidate_id: str = Field(pattern=_IDENTIFIER) + lease_id: str = Field(pattern=_IDENTIFIER) + repository_root: Path + base_sha: str = Field(pattern=_SHA1) + profile_id: str = Field(pattern=r"^[a-z0-9][a-z0-9_-]{0,63}$") + child_id: str = Field(pattern=_IDENTIFIER) + child_status: SubagentStatus + evidence_sha256: Sha256 + disposition: CandidateDisposition + files: tuple[CandidateFile, ...] = Field(max_length=128) + observed_paths: tuple[RelativePath, ...] = Field(max_length=128) + changed_files: int = Field(ge=0, le=128) + after_content_bytes: int = Field(ge=0, le=_MAX_CANDIDATE_BYTES) + rejection_reasons: tuple[str, ...] = Field(default=(), max_length=32) + manifest_sha256: Sha256 + + @classmethod + def create( + cls, + *, + candidate_id: str, + lease_id: str, + repository_root: Path, + base_sha: str, + profile_id: str, + child_id: str, + child_status: SubagentStatus, + evidence_sha256: str, + disposition: CandidateDisposition, + files: tuple[CandidateFile, ...], + observed_paths: tuple[str, ...], + rejection_reasons: tuple[str, ...] = (), + ) -> Self: + changed_files = len(observed_paths) + after_content_bytes = sum(item.byte_count for item in files) + projection = _candidate_manifest_projection( + candidate_id=candidate_id, + lease_id=lease_id, + repository_root=repository_root, + base_sha=base_sha, + profile_id=profile_id, + child_id=child_id, + child_status=child_status, + evidence_sha256=evidence_sha256, + disposition=disposition, + files=files, + observed_paths=observed_paths, + changed_files=changed_files, + after_content_bytes=after_content_bytes, + rejection_reasons=rejection_reasons, + ) + return cls( + candidate_id=candidate_id, + lease_id=lease_id, + repository_root=repository_root, + base_sha=base_sha, + profile_id=profile_id, + child_id=child_id, + child_status=child_status, + evidence_sha256=evidence_sha256, + disposition=disposition, + files=files, + observed_paths=observed_paths, + changed_files=changed_files, + after_content_bytes=after_content_bytes, + rejection_reasons=rejection_reasons, + manifest_sha256=_canonical_sha256(projection), + ) + + @field_validator("observed_paths") + @classmethod + def validate_observed_paths(cls, value: tuple[str, ...]) -> tuple[str, ...]: + return tuple(_normalize_relative_path(path) for path in value) + + @field_validator("rejection_reasons") + @classmethod + def validate_rejection_reasons(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if any( + not reason + or len(reason) > 64 + or not reason.replace("_", "").isalnum() + or reason.lower() != reason + for reason in value + ): + raise ValueError("Candidate rejection reasons are invalid.") + return value + + @model_validator(mode="after") + def validate_manifest(self) -> Self: + file_paths = tuple(item.path for item in self.files) + if ( + tuple(sorted(file_paths)) != file_paths + or len({path.casefold() for path in file_paths}) != len(file_paths) + or tuple(sorted(self.observed_paths)) != self.observed_paths + or len({path.casefold() for path in self.observed_paths}) != len(self.observed_paths) + or self.changed_files != len(self.observed_paths) + or self.after_content_bytes != sum(item.byte_count for item in self.files) + ): + raise ValueError("Candidate manifest paths or counts are inconsistent.") + if self.disposition is CandidateDisposition.READY: + if self.rejection_reasons or not self.files or self.observed_paths != file_paths: + raise ValueError("Ready candidate manifest is inconsistent.") + elif not self.rejection_reasons: + raise ValueError("Rejected candidate manifest requires a reason.") + projection = _candidate_manifest_projection( + candidate_id=self.candidate_id, + lease_id=self.lease_id, + repository_root=self.repository_root, + base_sha=self.base_sha, + profile_id=self.profile_id, + child_id=self.child_id, + child_status=self.child_status, + evidence_sha256=self.evidence_sha256, + disposition=self.disposition, + files=self.files, + observed_paths=self.observed_paths, + changed_files=self.changed_files, + after_content_bytes=self.after_content_bytes, + rejection_reasons=self.rejection_reasons, + ) + if self.manifest_sha256 != _canonical_sha256(projection): + raise ValueError("Candidate manifest hash is inconsistent.") + return self + + +class SnapshotOutcome(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + lease_id: str = Field(pattern=_IDENTIFIER) + status: SnapshotStatus + candidate_id: str | None = Field(default=None, pattern=_IDENTIFIER) + manifest: CandidateManifest | None = None + + @model_validator(mode="after") + def validate_status(self) -> Self: + if self.status in {SnapshotStatus.READY, SnapshotStatus.REJECTED}: + expected = CandidateDisposition(self.status.value) + if ( + self.candidate_id is None + or self.manifest is None + or self.manifest.candidate_id != self.candidate_id + or self.manifest.lease_id != self.lease_id + or self.manifest.disposition is not expected + ): + raise ValueError("Snapshot candidate outcome is inconsistent.") + elif self.candidate_id is not None or self.manifest is not None: + raise ValueError("Snapshot non-candidate outcome is inconsistent.") + return self + + def _normalize_relative_path(value: str, *, allow_trailing_slash: bool = False) -> str: if "\0" in value or "\\" in value: raise ValueError("Worktree paths must be NUL-free POSIX paths.") @@ -411,3 +576,38 @@ def _canonical_sha256(value: object) -> str: sort_keys=True, ).encode("utf-8") return hashlib.sha256(encoded).hexdigest() + + +def _candidate_manifest_projection( + *, + candidate_id: str, + lease_id: str, + repository_root: Path, + base_sha: str, + profile_id: str, + child_id: str, + child_status: SubagentStatus, + evidence_sha256: str, + disposition: CandidateDisposition, + files: tuple[CandidateFile, ...], + observed_paths: tuple[str, ...], + changed_files: int, + after_content_bytes: int, + rejection_reasons: tuple[str, ...], +) -> dict[str, object]: + return { + "after_content_bytes": after_content_bytes, + "base_sha": base_sha, + "candidate_id": candidate_id, + "changed_files": changed_files, + "child_id": child_id, + "child_status": child_status.value, + "disposition": disposition.value, + "evidence_sha256": evidence_sha256, + "files": [item.model_dump(mode="json") for item in files], + "lease_id": lease_id, + "observed_paths": list(observed_paths), + "profile_id": profile_id, + "rejection_reasons": list(rejection_reasons), + "repository_root": str(repository_root), + } diff --git a/src/mini_code_agent/worktrees/snapshot.py b/src/mini_code_agent/worktrees/snapshot.py new file mode 100644 index 0000000..9f26479 --- /dev/null +++ b/src/mini_code_agent/worktrees/snapshot.py @@ -0,0 +1,471 @@ +from __future__ import annotations + +import asyncio +import difflib +import hashlib +import os +import stat +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Protocol + +from mini_code_agent.subagents.models import SubagentStatus +from mini_code_agent.worktrees.ledger import MutationLedger +from mini_code_agent.worktrees.models import ( + CandidateDisposition, + CandidateFile, + CandidateManifest, + CandidateOperation, + CandidateState, + GitIndexEntry, + SnapshotOutcome, + SnapshotStatus, + WorktreeLease, + WorktreeLeaseState, + WorktreeProfile, +) +from mini_code_agent.worktrees.state import WorktreeStateError, WorktreeStateStore + + +class CandidateBlobReader(Protocol): + async def read_blobs(self, object_ids: tuple[str, ...]) -> dict[str, bytes]: ... + + +class SnapshotUnsafeError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class _ObservedFile: + path: str + mode: Literal["100644", "100755"] + byte_count: int + sha256: str + + +@dataclass(frozen=True, slots=True) +class _ChangedContent: + observed: _ObservedFile + content: bytes + base: GitIndexEntry | None + + +@dataclass(frozen=True, slots=True) +class _Scan: + observed_paths: tuple[str, ...] + changed_content: tuple[_ChangedContent, ...] + reasons: tuple[str, ...] + + +class CandidateSnapshotter: + def __init__( + self, + profile: WorktreeProfile, + *, + store: WorktreeStateStore, + blob_reader: CandidateBlobReader, + ) -> None: + self._profile = profile + self._store = store + self._blob_reader = blob_reader + + async def snapshot( + self, + lease: WorktreeLease, + ledger: MutationLedger, + *, + candidate_id: str, + child_status: SubagentStatus, + evidence_sha256: str, + ) -> SnapshotOutcome: + if ( + lease.repository_root != self._profile.repository_root + or lease.state is not WorktreeLeaseState.ACTIVE + or lease.base_manifest.repository_root != self._profile.repository_root + ): + return self._cleanup_required(lease) + try: + scan = await asyncio.to_thread( + _scan_worktree, + self._profile, + lease, + ledger, + ) + if not scan.observed_paths: + return SnapshotOutcome( + lease_id=lease.lease_id, + status=SnapshotStatus.NO_CHANGES, + ) + files, content, build_reasons = await self._build_candidate_files(lease, scan) + rejection_reasons = tuple(sorted({*scan.reasons, *build_reasons})) + disposition = ( + CandidateDisposition.REJECTED if rejection_reasons else CandidateDisposition.READY + ) + manifest = CandidateManifest.create( + candidate_id=candidate_id, + lease_id=lease.lease_id, + repository_root=lease.repository_root, + base_sha=lease.base_sha, + profile_id=self._profile.implementation_profile.profile_id, + child_id=lease.child_id, + child_status=child_status, + evidence_sha256=evidence_sha256, + disposition=disposition, + files=files, + observed_paths=scan.observed_paths, + rejection_reasons=rejection_reasons, + ) + await asyncio.to_thread(self._persist_candidate, manifest, content) + except (SnapshotUnsafeError, WorktreeStateError, OSError, ValueError): + return self._cleanup_required(lease) + status = ( + SnapshotStatus.READY + if disposition is CandidateDisposition.READY + else SnapshotStatus.REJECTED + ) + return SnapshotOutcome( + lease_id=lease.lease_id, + status=status, + candidate_id=candidate_id, + manifest=manifest, + ) + + async def _build_candidate_files( + self, + lease: WorktreeLease, + scan: _Scan, + ) -> tuple[tuple[CandidateFile, ...], dict[str, bytes], set[str]]: + changed_base = tuple(item.base for item in scan.changed_content if item.base is not None) + object_ids = tuple(dict.fromkeys(item.object_id for item in changed_base)) + try: + base_blobs = await self._blob_reader.read_blobs(object_ids) if object_ids else {} + except Exception: + raise SnapshotUnsafeError("Base blobs could not be read.") from None + files: list[CandidateFile] = [] + content_by_hash: dict[str, bytes] = {} + reasons: set[str] = set() + for changed in scan.changed_content: + after = changed.content + after_text = _decode_candidate_text(after) + before_hash: str | None = None + before_text = "" + operation = CandidateOperation.ADD + if changed.base is not None: + operation = CandidateOperation.MODIFY + before_hash = changed.base.sha256 + before = base_blobs.get(changed.base.object_id) + if before is None or hashlib.sha256(before).hexdigest() != before_hash: + raise SnapshotUnsafeError("Base blob identity changed.") + before_text = _decode_candidate_text(before) + if before_text is None: + reasons.add("binary_file" if b"\0" in before else "invalid_utf8") + diff = "" + if before_text is not None and after_text is not None: + diff = _bounded_diff( + changed.observed.path, + before_text, + after_text, + self._profile.limits.max_diff_chars, + ) + digest = changed.observed.sha256 + content_by_hash[digest] = after + files.append( + CandidateFile( + path=changed.observed.path, + operation=operation, + mode=changed.observed.mode, + before_sha256=before_hash, + after_sha256=digest, + byte_count=changed.observed.byte_count, + line_count=len(after_text.splitlines()) if after_text is not None else 0, + diff=diff, + content_blob_sha256=digest, + ) + ) + return ( + tuple(sorted(files, key=lambda item: item.path)), + content_by_hash, + reasons, + ) + + def _persist_candidate( + self, + manifest: CandidateManifest, + content_by_hash: dict[str, bytes], + ) -> None: + self._store.begin_candidate(manifest.candidate_id) + for digest in sorted(content_by_hash): + self._store.write_candidate_blob( + manifest.candidate_id, + digest, + content_by_hash[digest], + ) + self._store.write_candidate_json( + manifest.candidate_id, + "manifest.json", + manifest.model_dump(mode="json"), + ) + target = ( + CandidateState.READY + if manifest.disposition is CandidateDisposition.READY + else CandidateState.REJECTED + ) + self._store.transition_candidate( + manifest.candidate_id, + CandidateState.BUILDING, + target, + ) + + @staticmethod + def _cleanup_required(lease: WorktreeLease) -> SnapshotOutcome: + return SnapshotOutcome( + lease_id=lease.lease_id, + status=SnapshotStatus.CLEANUP_REQUIRED, + ) + + +def _scan_worktree( + profile: WorktreeProfile, + lease: WorktreeLease, + ledger: MutationLedger, +) -> _Scan: + records = _walk_regular_files(profile, lease) + base_by_path = {entry.path: entry for entry in lease.base_manifest.entries} + record_by_path = {entry.path: entry for entry in records} + observed_paths: set[str] = set() + changed_content: list[_ChangedContent] = [] + reasons: set[str] = set() + + for path, base in base_by_path.items(): + observed = record_by_path.get(path) + if observed is None: + observed_paths.add(path) + reasons.add("deleted_path") + continue + if observed.mode != base.mode: + observed_paths.add(path) + reasons.add("mode_changed") + if observed.sha256 != base.sha256: + observed_paths.add(path) + content = _read_changed_file( + lease.worktree_path.joinpath(*path.split("/")), + observed, + profile, + ) + changed_content.append(_ChangedContent(observed, content, base)) + + for path, observed in record_by_path.items(): + if path in base_by_path: + continue + observed_paths.add(path) + content = _read_changed_file( + lease.worktree_path.joinpath(*path.split("/")), + observed, + profile, + ) + changed_content.append(_ChangedContent(observed, content, None)) + + ordered_paths = tuple(sorted(observed_paths)) + if len(ordered_paths) > profile.limits.max_candidate_files: + raise SnapshotUnsafeError("Candidate changed-file budget exceeded.") + if sum(item.observed.byte_count for item in changed_content) > ( + profile.limits.max_candidate_after_bytes + ): + raise SnapshotUnsafeError("Candidate after-content budget exceeded.") + for path in ordered_paths: + if not _is_allowed(path, profile.allowed_path_prefixes): + reasons.add("outside_allowed_prefix") + for item in changed_content: + if _decode_candidate_text(item.content) is None: + reasons.add("binary_file" if b"\0" in item.content else "invalid_utf8") + reasons.update(_validate_ledger(ledger, ordered_paths, base_by_path, record_by_path)) + return _Scan( + observed_paths=ordered_paths, + changed_content=tuple(sorted(changed_content, key=lambda item: item.observed.path)), + reasons=tuple(sorted(reasons)), + ) + + +def _walk_regular_files( + profile: WorktreeProfile, + lease: WorktreeLease, +) -> tuple[_ObservedFile, ...]: + root = lease.worktree_path + if _is_link_or_reparse(root) or not root.is_dir(): + raise SnapshotUnsafeError("Worktree root is unsafe.") + records: list[_ObservedFile] = [] + identities: set[str] = set() + base_by_path = {entry.path: entry for entry in lease.base_manifest.entries} + total_bytes = 0 + stack = [root] + while stack: + directory = stack.pop() + if _is_link_or_reparse(directory): + raise SnapshotUnsafeError("Worktree directory is linked.") + try: + children = tuple(directory.iterdir()) + except OSError: + raise SnapshotUnsafeError("Worktree directory could not be scanned.") from None + for child in children: + relative = child.relative_to(root).as_posix() + if len(relative) > profile.limits.max_path_chars: + raise SnapshotUnsafeError("Worktree path budget exceeded.") + if relative == ".git": + if _is_link_or_reparse(child) or not child.is_file(): + raise SnapshotUnsafeError("Worktree administrative file is unsafe.") + continue + if any(part.casefold() == ".git" for part in relative.split("/")): + raise SnapshotUnsafeError("Worktree contains nested Git administration.") + if _is_link_or_reparse(child): + raise SnapshotUnsafeError("Worktree contains a link.") + try: + metadata = child.stat(follow_symlinks=False) + except OSError: + raise SnapshotUnsafeError("Worktree entry could not be inspected.") from None + if stat.S_ISDIR(metadata.st_mode): + if len(relative.split("/")) > profile.limits.max_tracked_depth: + raise SnapshotUnsafeError("Worktree depth budget exceeded.") + stack.append(child) + continue + if not stat.S_ISREG(metadata.st_mode): + raise SnapshotUnsafeError("Worktree contains a special file.") + identity = relative.casefold() + if identity in identities: + raise SnapshotUnsafeError("Worktree paths collide.") + identities.add(identity) + total_bytes += metadata.st_size + if ( + len(records) + 1 + > profile.limits.max_tracked_files + profile.limits.max_candidate_files + or total_bytes + > profile.limits.max_tracked_bytes + profile.limits.max_candidate_after_bytes + ): + raise SnapshotUnsafeError("Worktree scan budget exceeded.") + base = base_by_path.get(relative) + records.append( + _ObservedFile( + path=relative, + mode=_observed_mode(metadata.st_mode, base), + byte_count=metadata.st_size, + sha256=_hash_regular_file(child, metadata.st_size), + ) + ) + return tuple(sorted(records, key=lambda item: item.path)) + + +def _hash_regular_file(path: Path, expected_size: int) -> str: + digest = hashlib.sha256() + count = 0 + try: + with path.open("rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise SnapshotUnsafeError("Worktree file changed type.") + while chunk := stream.read(64 * 1024): + count += len(chunk) + digest.update(chunk) + except SnapshotUnsafeError: + raise + except OSError: + raise SnapshotUnsafeError("Worktree file could not be hashed.") from None + if count != expected_size or _is_link_or_reparse(path): + raise SnapshotUnsafeError("Worktree file changed during snapshot.") + return digest.hexdigest() + + +def _read_changed_file( + path: Path, + observed: _ObservedFile, + profile: WorktreeProfile, +) -> bytes: + if observed.byte_count > profile.limits.max_file_bytes: + raise SnapshotUnsafeError("Candidate file budget exceeded.") + try: + content = path.read_bytes() + except OSError: + raise SnapshotUnsafeError("Candidate file could not be read.") from None + if ( + len(content) != observed.byte_count + or hashlib.sha256(content).hexdigest() != observed.sha256 + or _is_link_or_reparse(path) + ): + raise SnapshotUnsafeError("Candidate file changed during snapshot.") + return content + + +def _validate_ledger( + ledger: MutationLedger, + observed_paths: tuple[str, ...], + base_by_path: dict[str, GitIndexEntry], + record_by_path: dict[str, _ObservedFile], +) -> set[str]: + if ledger.compromised: + return {"ledger_compromised"} + entries = ledger.entries + if tuple(entry.ordinal for entry in entries) != tuple(range(len(entries))): + return {"ledger_mismatch"} + ledger_paths = {entry.path for entry in entries} + if ledger_paths != set(observed_paths): + return {"ledger_mismatch"} + previous_by_path: dict[str, str] = {} + for entry in entries: + expected_before = previous_by_path.get(entry.path) + if expected_before is None: + base = base_by_path.get(entry.path) + expected_before = base.sha256 if base is not None else None + if entry.before_sha256 != expected_before: + return {"ledger_mismatch"} + previous_by_path[entry.path] = entry.after_sha256 + for path, final_hash in previous_by_path.items(): + observed = record_by_path.get(path) + if observed is None or observed.sha256 != final_hash: + return {"ledger_mismatch"} + return set() + + +def _decode_candidate_text(content: bytes) -> str | None: + if b"\0" in content: + return None + try: + return content.decode("utf-8") + except UnicodeDecodeError: + return None + + +def _bounded_diff(path: str, before: str, after: str, limit: int) -> str: + diff = "".join( + difflib.unified_diff( + before.splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile=f"a/{path}", + tofile=f"b/{path}", + ) + ) + if len(diff) <= limit: + return diff + marker = "\n... diff truncated by mini-code-agent ...\n" + if limit <= len(marker): + return marker[:limit] + return diff[: max(0, limit - len(marker))] + marker + + +def _is_allowed(path: str, prefixes: tuple[str, ...]) -> bool: + return any(path == prefix or path.startswith(f"{prefix}/") for prefix in prefixes) + + +def _observed_mode( + raw_mode: int, + base: GitIndexEntry | None, +) -> Literal["100644", "100755"]: + if os.name == "nt": + return base.mode if base is not None else "100644" + return "100755" if raw_mode & stat.S_IXUSR else "100644" + + +def _is_link_or_reparse(path: Path) -> bool: + try: + metadata = path.lstat() + except OSError: + return True + attributes = getattr(metadata, "st_file_attributes", 0) + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + return stat.S_ISLNK(metadata.st_mode) or bool(attributes & reparse_flag) diff --git a/tests/integration/test_worktree_materialization.py b/tests/integration/test_worktree_materialization.py index 540a8b6..c23c6be 100644 --- a/tests/integration/test_worktree_materialization.py +++ b/tests/integration/test_worktree_materialization.py @@ -8,10 +8,17 @@ import pytest from mini_code_agent.agent.models import AgentLimits -from mini_code_agent.subagents.models import SubagentProfile +from mini_code_agent.domain.content import ToolCall +from mini_code_agent.subagents.models import SubagentProfile, SubagentStatus +from mini_code_agent.tools.edit_file import EditFileTool +from mini_code_agent.tools.write_file import WriteFileTool +from mini_code_agent.workspace.boundary import WorkspaceBoundary from mini_code_agent.worktrees.git import WorktreeGit +from mini_code_agent.worktrees.ledger import MutationLedger from mini_code_agent.worktrees.manager import WorktreeManager -from mini_code_agent.worktrees.models import WorktreeProfile +from mini_code_agent.worktrees.models import SnapshotStatus, WorktreeProfile +from mini_code_agent.worktrees.snapshot import CandidateSnapshotter +from mini_code_agent.worktrees.state import WorktreeStateStore @pytest.mark.asyncio @@ -72,6 +79,104 @@ async def test_real_no_checkout_lease_materializes_only_tracked_index( await git.prune_worktrees() +@pytest.mark.asyncio +async def test_real_lease_snapshot_persists_candidate_without_parent_mutation( + tmp_path: Path, +) -> None: + discovered_git = shutil.which("git") + if discovered_git is None: + pytest.skip("Git is unavailable.") + repository = tmp_path / "repository" + state = tmp_path / "state" + repository.mkdir() + state.mkdir() + if os.name != "nt": + state.chmod(0o700) + _git(repository, "init") + _git(repository, "config", "user.email", "agent@example.invalid") + _git(repository, "config", "user.name", "Agent Test") + (repository / "src").mkdir() + parent_content = b"VALUE = 'base'\n" + (repository / "src" / "app.py").write_bytes(parent_content) + _git(repository, "add", "--", "src/app.py") + _git(repository, "commit", "-m", "initial") + profile = _profile(repository, state, Path(discovered_git).resolve(strict=True)) + store = WorktreeStateStore(profile) + git = WorktreeGit(profile) + manager = WorktreeManager( + profile, + git=git, + store=store, + id_factory=lambda: "lease-candidate", + ) + lease = await manager.create_lease(child_id="child-candidate") + workspace = WorkspaceBoundary(lease.worktree_path) + ledger = MutationLedger(max_entries=8) + before = workspace.read_text("src/app.py") + edit_call = ToolCall( + id="edit-1", + name="edit_file", + arguments={ + "path": "src/app.py", + "old_text": "'base'", + "new_text": "'changed'", + "expected_sha256": before.sha256, + "reason": "Update the value.", + }, + ) + edit_result = await EditFileTool(workspace).execute(edit_call) + ledger.record(edit_call, edit_result) + write_call = ToolCall( + id="write-1", + name="write_file", + arguments={ + "path": "src/new.py", + "content": "NEW = True\n", + "reason": "Add the requested module.", + }, + ) + write_result = await WriteFileTool(workspace).execute(write_call) + ledger.record(write_call, write_result) + + outcome = await CandidateSnapshotter( + profile, + store=store, + blob_reader=git, + ).snapshot( + lease, + ledger, + candidate_id="candidate-real", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + + assert outcome.status is SnapshotStatus.READY + assert (repository / "src" / "app.py").read_bytes() == parent_content + assert not (repository / "src" / "new.py").exists() + assert (state / "candidates" / "ready" / "candidate-real" / "manifest.json").is_file() + await git.unlock_worktree(lease.worktree_path) + await git.remove_worktree(lease.worktree_path) + await git.prune_worktrees() + + +def _profile(repository: Path, state: Path, git_executable: Path) -> WorktreeProfile: + return WorktreeProfile( + repository_root=repository, + state_root=state, + git_executable=git_executable, + allowed_path_prefixes=("src", "tests"), + implementation_profile=SubagentProfile( + profile_id="implementation", + local_name="delegate_implementation", + description="Implement one bounded task.", + system_prompt="Change only files required by the task.", + tool_names=("read_file", "search_text", "write_file", "edit_file"), + mode="implementation", + agent_limits=AgentLimits(max_turns=8, max_tool_calls=32), + ), + ) + + def _git(repository: Path, *arguments: str) -> None: subprocess.run( ("git", "-C", str(repository), *arguments), diff --git a/tests/unit/worktrees/test_models.py b/tests/unit/worktrees/test_models.py index c6dd790..9170078 100644 --- a/tests/unit/worktrees/test_models.py +++ b/tests/unit/worktrees/test_models.py @@ -8,9 +8,11 @@ from pydantic import ValidationError from mini_code_agent.agent.models import AgentLimits -from mini_code_agent.subagents.models import SubagentProfile +from mini_code_agent.subagents.models import SubagentProfile, SubagentStatus from mini_code_agent.worktrees.models import ( + CandidateDisposition, CandidateFile, + CandidateManifest, CandidateOperation, GitIndexEntry, MutationLedgerEntry, @@ -229,3 +231,38 @@ def test_candidate_file_validates_operation_hashes_and_diff() -> None: CandidateFile.model_validate(added.model_dump() | {"before_sha256": "a" * 64}) with pytest.raises(ValidationError): CandidateFile.model_validate(modified.model_dump() | {"diff": "x" * 65_537}) + + +def test_candidate_manifest_hashes_canonical_ready_projection(tmp_path: Path) -> None: + candidate_file = CandidateFile( + path="src/app.py", + operation=CandidateOperation.MODIFY, + mode="100644", + before_sha256="a" * 64, + after_sha256="b" * 64, + byte_count=12, + line_count=1, + diff="bounded", + content_blob_sha256="b" * 64, + ) + manifest = CandidateManifest.create( + candidate_id="candidate-1", + lease_id="lease-1", + repository_root=tmp_path.resolve(), + base_sha="c" * 40, + profile_id="implementation", + child_id="child-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="d" * 64, + disposition=CandidateDisposition.READY, + files=(candidate_file,), + observed_paths=("src/app.py",), + ) + + assert manifest.changed_files == 1 + with pytest.raises(ValidationError): + CandidateManifest.model_validate(manifest.model_dump() | {"manifest_sha256": "0" * 64}) + with pytest.raises(ValidationError): + CandidateManifest.model_validate( + manifest.model_dump() | {"observed_paths": ("src/other.py",)} + ) diff --git a/tests/unit/worktrees/test_snapshot.py b/tests/unit/worktrees/test_snapshot.py new file mode 100644 index 0000000..1ee0d66 --- /dev/null +++ b/tests/unit/worktrees/test_snapshot.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path + +import pytest + +from mini_code_agent.domain.content import ToolCall, ToolResult +from mini_code_agent.subagents.models import SubagentStatus +from mini_code_agent.workspace.models import MutationResult +from mini_code_agent.worktrees.ledger import MutationLedger +from mini_code_agent.worktrees.models import ( + BaseManifest, + CandidateDisposition, + GitIndexEntry, + MutationLedgerEntry, + SnapshotStatus, + WorktreeLease, + WorktreeLeaseState, + WorktreeLimits, + WorktreeProfile, +) +from mini_code_agent.worktrees.snapshot import CandidateSnapshotter +from mini_code_agent.worktrees.state import WorktreeStateStore + +from .helpers import worktree_profile + + +class BlobReader: + def __init__(self, blobs: dict[str, bytes]) -> None: + self.blobs = blobs + self.requests: list[tuple[str, ...]] = [] + + async def read_blobs(self, object_ids: tuple[str, ...]) -> dict[str, bytes]: + self.requests.append(object_ids) + return {object_id: self.blobs[object_id] for object_id in object_ids} + + +def lease_for( + tmp_path: Path, + *, + files: dict[str, bytes] | None = None, + limits: WorktreeLimits | None = None, +) -> tuple[WorktreeProfile, WorktreeLease, WorktreeStateStore, dict[str, bytes]]: + profile = worktree_profile(tmp_path, limits=limits) + store = WorktreeStateStore(profile) + store.initialize() + paths = store.begin_lease("lease-1") + paths.worktree.mkdir() + (paths.worktree / ".git").write_text("gitdir: admin\n", encoding="utf-8") + base_files = files or {"src/app.py": b"print('base')\n"} + blobs: dict[str, bytes] = {} + entries: list[GitIndexEntry] = [] + for index, (path, content) in enumerate(sorted(base_files.items())): + target = paths.worktree.joinpath(*path.split("/")) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + object_id = f"{index + 1:040x}" + blobs[object_id] = content + entries.append( + GitIndexEntry( + path=path, + mode="100644", + object_id=object_id, + byte_count=len(content), + sha256=hashlib.sha256(content).hexdigest(), + ) + ) + manifest = BaseManifest.from_entries( + repository_root=profile.repository_root, + base_sha="a" * 40, + entries=tuple(entries), + ) + lease = WorktreeLease( + lease_id="lease-1", + child_id="child-1", + repository_root=profile.repository_root, + container_path=paths.container, + worktree_path=paths.worktree, + base_sha="a" * 40, + base_manifest=manifest, + state=WorktreeLeaseState.ACTIVE, + ) + return profile, lease, store, blobs + + +def ledger_for(*entries: MutationLedgerEntry) -> MutationLedger: + ledger = MutationLedger(max_entries=8) + for item in entries: + mutation = MutationResult( + path=item.path, + created=item.created, + before_sha256=item.before_sha256, + after_sha256=item.after_sha256, + byte_count=item.byte_count, + line_count=item.line_count, + diff="bounded", + ) + ledger.record( + ToolCall(id=item.tool_call_id, name=item.tool_name, arguments={}), + ToolResult( + tool_call_id=item.tool_call_id, + content=json.dumps(mutation.model_dump(mode="json")), + ), + ) + return ledger + + +def entry( + ordinal: int, + path: str, + *, + before: str | None, + after: str, + created: bool, +) -> MutationLedgerEntry: + return MutationLedgerEntry( + ordinal=ordinal, + tool_call_id=f"call-{ordinal}", + tool_name="write_file", + path=path, + created=created, + before_sha256=before, + after_sha256=after, + byte_count=12, + line_count=1, + ) + + +@pytest.mark.asyncio +async def test_snapshot_persists_ready_candidate_from_exact_scan_and_ledger( + tmp_path: Path, +) -> None: + profile, lease, store, blobs = lease_for(tmp_path) + before = lease.base_manifest.entries[0].sha256 + modified = b"print('changed')\n" + added = b"NEW = True\n" + (lease.worktree_path / "src" / "app.py").write_bytes(modified) + (lease.worktree_path / "src" / "new.py").write_bytes(added) + ledger = ledger_for( + entry( + 0, + "src/app.py", + before=before, + after=hashlib.sha256(modified).hexdigest(), + created=False, + ), + entry( + 1, + "src/new.py", + before=None, + after=hashlib.sha256(added).hexdigest(), + created=True, + ), + ) + snapshotter = CandidateSnapshotter( + profile, + store=store, + blob_reader=BlobReader(blobs), + ) + + outcome = await snapshotter.snapshot( + lease, + ledger, + candidate_id="candidate-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + + assert outcome.status is SnapshotStatus.READY + assert outcome.manifest is not None + assert outcome.manifest.disposition is CandidateDisposition.READY + assert [file.path for file in outcome.manifest.files] == [ + "src/app.py", + "src/new.py", + ] + assert outcome.manifest.changed_files == 2 + ready = profile.state_root / "candidates" / "ready" / "candidate-1" + persisted = json.loads((ready / "manifest.json").read_text(encoding="utf-8")) + assert persisted["manifest_sha256"] == outcome.manifest.manifest_sha256 + for file in outcome.manifest.files: + assert (ready / "blobs" / file.content_blob_sha256).read_bytes() in { + modified, + added, + } + + +@pytest.mark.asyncio +async def test_snapshot_returns_no_candidate_for_unchanged_tree(tmp_path: Path) -> None: + profile, lease, store, blobs = lease_for(tmp_path) + snapshotter = CandidateSnapshotter( + profile, + store=store, + blob_reader=BlobReader(blobs), + ) + + outcome = await snapshotter.snapshot( + lease, + MutationLedger(max_entries=8), + candidate_id="candidate-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + + assert outcome.status is SnapshotStatus.NO_CHANGES + assert outcome.candidate_id is None + assert not (profile.state_root / "candidates" / "ready" / "candidate-1").exists() + + +@pytest.mark.asyncio +async def test_snapshot_persists_extra_regular_mutation_as_rejected_forensics( + tmp_path: Path, +) -> None: + profile, lease, store, blobs = lease_for(tmp_path) + extra = b"not in ledger\n" + (lease.worktree_path / "src" / "extra.py").write_bytes(extra) + snapshotter = CandidateSnapshotter( + profile, + store=store, + blob_reader=BlobReader(blobs), + ) + + outcome = await snapshotter.snapshot( + lease, + MutationLedger(max_entries=8), + candidate_id="candidate-1", + child_status=SubagentStatus.FAILED, + evidence_sha256="e" * 64, + ) + + assert outcome.status is SnapshotStatus.REJECTED + assert outcome.manifest is not None + assert outcome.manifest.disposition is CandidateDisposition.REJECTED + assert "ledger_mismatch" in outcome.manifest.rejection_reasons + rejected = profile.state_root / "candidates" / "rejected" / "candidate-1" + assert (rejected / "blobs" / hashlib.sha256(extra).hexdigest()).read_bytes() == extra + + +@pytest.mark.asyncio +async def test_snapshot_rejects_deletion_binary_and_out_of_scope_changes( + tmp_path: Path, +) -> None: + profile, lease, store, blobs = lease_for( + tmp_path, + files={ + "src/app.py": b"base\n", + "docs/guide.md": b"guide\n", + }, + ) + (lease.worktree_path / "src" / "app.py").unlink() + (lease.worktree_path / "docs" / "guide.md").write_bytes(b"\x00binary") + snapshotter = CandidateSnapshotter( + profile, + store=store, + blob_reader=BlobReader(blobs), + ) + + outcome = await snapshotter.snapshot( + lease, + MutationLedger(max_entries=8), + candidate_id="candidate-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + + assert outcome.status is SnapshotStatus.REJECTED + assert outcome.manifest is not None + assert {"deleted_path", "binary_file", "outside_allowed_prefix"} <= set( + outcome.manifest.rejection_reasons + ) + + +@pytest.mark.asyncio +async def test_snapshot_retains_unsafe_linked_tree_for_cleanup( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + profile, lease, store, blobs = lease_for(tmp_path) + + def marks_app_as_link(path: Path) -> bool: + return path.name == "app.py" + + monkeypatch.setattr( + "mini_code_agent.worktrees.snapshot._is_link_or_reparse", + marks_app_as_link, + ) + snapshotter = CandidateSnapshotter( + profile, + store=store, + blob_reader=BlobReader(blobs), + ) + + outcome = await snapshotter.snapshot( + lease, + MutationLedger(max_entries=8), + candidate_id="candidate-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + + assert outcome.status is SnapshotStatus.CLEANUP_REQUIRED + assert outcome.candidate_id is None + assert lease.worktree_path.exists() + + +@pytest.mark.asyncio +async def test_snapshot_retains_candidate_that_exceeds_after_content_budget( + tmp_path: Path, +) -> None: + profile, lease, store, blobs = lease_for( + tmp_path, + limits=WorktreeLimits( + max_file_bytes=4, + max_candidate_after_bytes=4, + ), + ) + (lease.worktree_path / "src" / "app.py").write_bytes(b"12345") + snapshotter = CandidateSnapshotter( + profile, + store=store, + blob_reader=BlobReader(blobs), + ) + + outcome = await snapshotter.snapshot( + lease, + MutationLedger(max_entries=8), + candidate_id="candidate-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + + assert outcome.status is SnapshotStatus.CLEANUP_REQUIRED + assert not (profile.state_root / "candidates" / "rejected" / "candidate-1").exists() + + +@pytest.mark.asyncio +async def test_snapshot_diff_never_exceeds_profile_limit(tmp_path: Path) -> None: + profile, lease, store, blobs = lease_for( + tmp_path, + limits=WorktreeLimits(max_diff_chars=16), + ) + modified = b"print('a much longer changed value')\n" + base = lease.base_manifest.entries[0] + (lease.worktree_path / "src" / "app.py").write_bytes(modified) + ledger = ledger_for( + entry( + 0, + "src/app.py", + before=base.sha256, + after=hashlib.sha256(modified).hexdigest(), + created=False, + ) + ) + + outcome = await CandidateSnapshotter( + profile, + store=store, + blob_reader=BlobReader(blobs), + ).snapshot( + lease, + ledger, + candidate_id="candidate-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + + assert outcome.status is SnapshotStatus.READY + assert outcome.manifest is not None + assert len(outcome.manifest.files[0].diff) <= 16 + + +@pytest.mark.asyncio +async def test_snapshot_rejects_mode_change(tmp_path: Path) -> None: + if os.name == "nt": + pytest.skip("Windows does not expose the Git executable bit.") + profile, lease, store, blobs = lease_for(tmp_path) + (lease.worktree_path / "src" / "app.py").chmod(0o755) + + outcome = await CandidateSnapshotter( + profile, + store=store, + blob_reader=BlobReader(blobs), + ).snapshot( + lease, + MutationLedger(max_entries=8), + candidate_id="candidate-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + + assert outcome.status is SnapshotStatus.REJECTED + assert outcome.manifest is not None + assert "mode_changed" in outcome.manifest.rejection_reasons + + +@pytest.mark.asyncio +async def test_snapshot_retains_nested_git_administration(tmp_path: Path) -> None: + profile, lease, store, blobs = lease_for(tmp_path) + nested = lease.worktree_path / "src" / ".git" + nested.mkdir() + (nested / "config").write_text("unsafe", encoding="utf-8") + + outcome = await CandidateSnapshotter( + profile, + store=store, + blob_reader=BlobReader(blobs), + ).snapshot( + lease, + MutationLedger(max_entries=8), + candidate_id="candidate-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + + assert outcome.status is SnapshotStatus.CLEANUP_REQUIRED + + +@pytest.mark.asyncio +async def test_snapshot_retains_case_colliding_paths(tmp_path: Path) -> None: + if os.name == "nt": + pytest.skip("Windows test filesystem is case-insensitive.") + profile, lease, store, blobs = lease_for(tmp_path) + (lease.worktree_path / "src" / "APP.py").write_text("collision\n", encoding="utf-8") + + outcome = await CandidateSnapshotter( + profile, + store=store, + blob_reader=BlobReader(blobs), + ).snapshot( + lease, + MutationLedger(max_entries=8), + candidate_id="candidate-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + + assert outcome.status is SnapshotStatus.CLEANUP_REQUIRED + + +@pytest.mark.asyncio +@pytest.mark.skipif( + os.name == "nt" or not hasattr(os, "mkfifo"), + reason="FIFO creation is POSIX-only.", +) +async def test_snapshot_retains_special_file(tmp_path: Path) -> None: + profile, lease, store, blobs = lease_for(tmp_path) + os.mkfifo(lease.worktree_path / "src" / "pipe") + + outcome = await CandidateSnapshotter( + profile, + store=store, + blob_reader=BlobReader(blobs), + ).snapshot( + lease, + MutationLedger(max_entries=8), + candidate_id="candidate-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + + assert outcome.status is SnapshotStatus.CLEANUP_REQUIRED From 1e7956738aeba3469349232df779d6044d67fde4 Mon Sep 17 00:00:00 2001 From: JayLin Date: Thu, 2 Jul 2026 09:37:30 +0800 Subject: [PATCH 07/12] feat: finalize worktree leases safely --- .../2026-07-02-m6b-worktree-candidates.md | 12 +- src/mini_code_agent/worktrees/__init__.py | 6 + src/mini_code_agent/worktrees/finalization.py | 111 ++++++++++ src/mini_code_agent/worktrees/git.py | 49 ++++- src/mini_code_agent/worktrees/manager.py | 127 ++++++++++- src/mini_code_agent/worktrees/materialize.py | 32 +++ src/mini_code_agent/worktrees/models.py | 28 +++ src/mini_code_agent/worktrees/snapshot.py | 17 +- src/mini_code_agent/worktrees/state.py | 149 ++++++++++++- .../test_worktree_materialization.py | 23 +- tests/unit/worktrees/test_cancellation.py | 158 ++++++++++++++ tests/unit/worktrees/test_cleanup.py | 204 ++++++++++++++++++ tests/unit/worktrees/test_git.py | 16 ++ tests/unit/worktrees/test_manager_leases.py | 32 ++- tests/unit/worktrees/test_materialize.py | 16 +- tests/unit/worktrees/test_snapshot.py | 5 +- 16 files changed, 961 insertions(+), 24 deletions(-) create mode 100644 src/mini_code_agent/worktrees/finalization.py create mode 100644 tests/unit/worktrees/test_cancellation.py create mode 100644 tests/unit/worktrees/test_cleanup.py diff --git a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md index fc75095..f1f7cdf 100644 --- a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md +++ b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md @@ -129,12 +129,12 @@ hooks-empty/ - Test: `tests/unit/worktrees/test_cancellation.py` - Test: `tests/integration/test_worktree_cleanup.py` -- [ ] Write failing tests for exact lease/repository/admin identity checks before unlock/remove, bounded prune, and postcondition verification. -- [ ] Permit manager-owned removal only after candidate persistence or a verified clean tree. -- [ ] Prove dirty unsnapshotted and path-ambiguous leases are retained and marked `cleanup_required`. -- [ ] Add bounded shielded snapshot/cleanup finalization on child cancellation, then re-raise `CancelledError`. -- [ ] Record actionable diagnostics when cleanup exceeds its budget. -- [ ] Commit: `feat: finalize worktree leases safely` +- [x] Write failing tests for exact lease/repository/admin identity checks before unlock/remove, bounded prune, and postcondition verification. +- [x] Permit manager-owned removal only after candidate persistence or a verified clean tree. +- [x] Prove dirty unsnapshotted and path-ambiguous leases are retained and marked `cleanup_required`. +- [x] Add bounded shielded snapshot/cleanup finalization on child cancellation, then re-raise `CancelledError`. +- [x] Record actionable diagnostics when cleanup exceeds its budget. +- [x] Commit: `feat: finalize worktree leases safely` ## Task 7: Expose Bounded Implementation Delegation diff --git a/src/mini_code_agent/worktrees/__init__.py b/src/mini_code_agent/worktrees/__init__.py index 1506f3e..4ca77e6 100644 --- a/src/mini_code_agent/worktrees/__init__.py +++ b/src/mini_code_agent/worktrees/__init__.py @@ -7,6 +7,8 @@ CandidateManifest, CandidateOperation, CandidateState, + CleanupResult, + CleanupStatus, GitIndexEntry, GitIndexPointer, MutationLedgerEntry, @@ -14,6 +16,7 @@ SnapshotStatus, WorktreeError, WorktreeErrorCode, + WorktreeFinalizationResult, WorktreeLease, WorktreeLeaseState, WorktreeLimits, @@ -27,6 +30,8 @@ "CandidateManifest", "CandidateOperation", "CandidateState", + "CleanupResult", + "CleanupStatus", "GitIndexEntry", "GitIndexPointer", "MutationLedgerEntry", @@ -34,6 +39,7 @@ "SnapshotStatus", "WorktreeError", "WorktreeErrorCode", + "WorktreeFinalizationResult", "WorktreeLease", "WorktreeLeaseState", "WorktreeLimits", diff --git a/src/mini_code_agent/worktrees/finalization.py b/src/mini_code_agent/worktrees/finalization.py new file mode 100644 index 0000000..5864c0a --- /dev/null +++ b/src/mini_code_agent/worktrees/finalization.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Coroutine +from contextlib import suppress +from typing import Any, Protocol + +from mini_code_agent.subagents.models import SubagentStatus +from mini_code_agent.worktrees.ledger import MutationLedger +from mini_code_agent.worktrees.models import ( + CleanupResult, + CleanupStatus, + SnapshotOutcome, + SnapshotStatus, + WorktreeFinalizationResult, + WorktreeLease, +) + + +class LeaseSnapshotter(Protocol): + async def snapshot( + self, + lease: WorktreeLease, + ledger: MutationLedger, + *, + candidate_id: str, + child_status: SubagentStatus, + evidence_sha256: str, + ) -> SnapshotOutcome: ... + + +class LeaseCleaner(Protocol): + async def cleanup_lease( + self, + lease: WorktreeLease, + outcome: SnapshotOutcome, + ) -> CleanupResult: ... + + +class WorktreeFinalizer: + def __init__( + self, + *, + snapshotter: LeaseSnapshotter, + cleaner: LeaseCleaner, + ) -> None: + self._snapshotter = snapshotter + self._cleaner = cleaner + + async def finalize( + self, + lease: WorktreeLease, + ledger: MutationLedger, + *, + candidate_id: str, + child_status: SubagentStatus, + evidence_sha256: str, + ) -> WorktreeFinalizationResult: + snapshot = await self._snapshotter.snapshot( + lease, + ledger, + candidate_id=candidate_id, + child_status=child_status, + evidence_sha256=evidence_sha256, + ) + cleanup = ( + CleanupResult( + lease_id=lease.lease_id, + status=CleanupStatus.CLEANUP_REQUIRED, + ) + if snapshot.status is SnapshotStatus.CLEANUP_REQUIRED + else await self._cleaner.cleanup_lease(lease, snapshot) + ) + return WorktreeFinalizationResult( + lease_id=lease.lease_id, + snapshot=snapshot, + cleanup=cleanup, + ) + + +async def await_with_cancellation_finalization[T]( + child: Awaitable[T], + *, + finalize: Callable[[], Coroutine[Any, Any, object]], + timeout_seconds: float, + on_timeout: Callable[[], None] | None = None, +) -> T: + if not 0 < timeout_seconds <= 300: + raise ValueError("Cancellation finalization timeout is invalid.") + try: + return await child + except asyncio.CancelledError: + task = asyncio.create_task(finalize()) + try: + await asyncio.wait_for( + asyncio.shield(task), + timeout=timeout_seconds, + ) + except TimeoutError: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + if on_timeout is not None: + with suppress(Exception): + on_timeout() + except asyncio.CancelledError: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + except Exception: + pass + raise diff --git a/src/mini_code_agent/worktrees/git.py b/src/mini_code_agent/worktrees/git.py index 17cc8ea..ff8c9e9 100644 --- a/src/mini_code_agent/worktrees/git.py +++ b/src/mini_code_agent/worktrees/git.py @@ -120,7 +120,9 @@ async def run(self, command: GitByteCommand) -> GitByteResult: timed_out = True await self._terminate_tree(process) elif output_wait in done and output_wait.result(): - await self._terminate_tree(process) + exited, _ = await asyncio.wait((process_wait,), timeout=0.1) + if not exited: + await self._terminate_tree(process) else: process_wait.result() await asyncio.gather(writer, *readers) @@ -367,6 +369,23 @@ async def unlock_worktree(self, path: Path) -> None: max_output_bytes=1024 * 1024, ) + async def lock_worktree(self, path: Path, lease_id: str) -> None: + self._validate_lease_worktree_path( + path, + lease_id=lease_id, + require_exists=True, + ) + await self._execute( + ( + "worktree", + "lock", + "--reason", + f"mini-code-agent:{lease_id}", + str(path), + ), + max_output_bytes=1024 * 1024, + ) + async def remove_worktree(self, path: Path) -> None: self._validate_lease_worktree_path(path, require_exists=True) await self._execute( @@ -386,6 +405,9 @@ async def worktree_list(self) -> bytes: max_output_bytes=16 * 1024 * 1024, ) + async def worktree_paths(self) -> tuple[Path, ...]: + return parse_worktree_paths(await self.worktree_list()) + def _validate_lease_worktree_path( self, path: Path, @@ -537,6 +559,31 @@ def parse_batch_blobs( return blobs +def parse_worktree_paths(output: bytes) -> tuple[Path, ...]: + if output and not output.endswith(b"\0"): + raise _invalid_git_output() + paths: list[Path] = [] + identities: set[str] = set() + for record in output[:-1].split(b"\0") if output else (): + if not record: + continue + if not record.startswith(b"worktree "): + continue + try: + raw_path = record[len(b"worktree ") :].decode("utf-8") + path = Path(raw_path) + except UnicodeDecodeError: + raise _invalid_git_output() from None + if not path.is_absolute(): + raise _invalid_git_output() + identity = os.path.normcase(str(path)) + if identity in identities: + raise _invalid_git_output() + identities.add(identity) + paths.append(path) + return tuple(paths) + + def _decode_lines(output: bytes) -> list[str]: try: return output.decode("utf-8").splitlines() diff --git a/src/mini_code_agent/worktrees/manager.py b/src/mini_code_agent/worktrees/manager.py index 7c80f03..1a338ed 100644 --- a/src/mini_code_agent/worktrees/manager.py +++ b/src/mini_code_agent/worktrees/manager.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +import os import re import secrets from collections.abc import Callable @@ -8,16 +10,26 @@ from typing import Protocol from mini_code_agent.worktrees.git import WorktreeGit -from mini_code_agent.worktrees.materialize import MaterializationError, materialize_index +from mini_code_agent.worktrees.materialize import ( + MaterializationError, + materialize_index, + read_worktree_admin_dir, +) from mini_code_agent.worktrees.models import ( BaseManifest, + CandidateState, + CleanupResult, + CleanupStatus, GitIndexPointer, + SnapshotOutcome, + SnapshotStatus, WorktreeError, WorktreeErrorCode, WorktreeLease, WorktreeLeaseState, WorktreeProfile, ) +from mini_code_agent.worktrees.snapshot import verify_lease_base_clean from mini_code_agent.worktrees.state import WorktreeStateError, WorktreeStateStore _IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") @@ -36,6 +48,16 @@ async def read_blobs(self, object_ids: tuple[str, ...]) -> dict[str, bytes]: ... async def add_worktree(self, lease_id: str, path: Path, base_sha: str) -> None: ... + async def unlock_worktree(self, path: Path) -> None: ... + + async def lock_worktree(self, path: Path, lease_id: str) -> None: ... + + async def remove_worktree(self, path: Path) -> None: ... + + async def prune_worktrees(self) -> None: ... + + async def worktree_paths(self) -> tuple[Path, ...]: ... + class WorktreeManager: def __init__( @@ -79,6 +101,7 @@ async def create_lease(self, *, child_id: str) -> WorktreeLease: base_sha, pointers, blobs = await self._read_clean_base() await self._git.add_worktree(lease_id, paths.worktree, base_sha) worktree_created = True + git_admin_dir = read_worktree_admin_dir(paths.worktree) entries = materialize_index( paths.worktree, pointers, @@ -101,6 +124,7 @@ async def create_lease(self, *, child_id: str) -> WorktreeLease: repository_root=self._profile.repository_root, container_path=paths.container, worktree_path=paths.worktree, + git_admin_dir=git_admin_dir, base_sha=base_sha, base_manifest=manifest, state=WorktreeLeaseState.ACTIVE, @@ -155,10 +179,111 @@ async def _read_clean_base( ) return base_sha, pointers, blobs + async def cleanup_lease( + self, + lease: WorktreeLease, + outcome: SnapshotOutcome, + ) -> CleanupResult: + if not await self._cleanup_preconditions(lease, outcome): + return self._cleanup_required(lease) + unlocked = False + removed = False + try: + await self._git.unlock_worktree(lease.worktree_path) + unlocked = True + if outcome.status is SnapshotStatus.NO_CHANGES and not await asyncio.to_thread( + verify_lease_base_clean, + self._profile, + lease, + ): + await self._git.lock_worktree(lease.worktree_path, lease.lease_id) + return self._cleanup_required(lease) + await self._git.remove_worktree(lease.worktree_path) + removed = True + with suppress(Exception): + await self._git.prune_worktrees() + paths = await self._git.worktree_paths() + if ( + lease.worktree_path.exists() + or lease.git_admin_dir.exists() + or _contains_path(paths, lease.worktree_path) + ): + return self._cleanup_required(lease) + self._store.complete_lease(lease.lease_id) + except Exception: + if unlocked and not removed and lease.worktree_path.exists(): + with suppress(Exception): + await self._git.lock_worktree(lease.worktree_path, lease.lease_id) + return self._cleanup_required(lease) + return CleanupResult( + lease_id=lease.lease_id, + status=CleanupStatus.REMOVED, + ) + + async def _cleanup_preconditions( + self, + lease: WorktreeLease, + outcome: SnapshotOutcome, + ) -> bool: + try: + expected_container = self._profile.state_root / "leases" / lease.lease_id + if ( + lease.repository_root != self._profile.repository_root + or lease.container_path.resolve(strict=True) + != expected_container.resolve(strict=True) + or lease.worktree_path != lease.container_path / "worktree" + or outcome.lease_id != lease.lease_id + or outcome.status is SnapshotStatus.CLEANUP_REQUIRED + or read_worktree_admin_dir(lease.worktree_path) != lease.git_admin_dir + or not _contains_path( + await self._git.worktree_paths(), + lease.worktree_path, + ) + ): + return False + if outcome.status is SnapshotStatus.NO_CHANGES: + return await asyncio.to_thread( + verify_lease_base_clean, + self._profile, + lease, + ) + if ( + outcome.status not in {SnapshotStatus.READY, SnapshotStatus.REJECTED} + or outcome.candidate_id is None + or outcome.manifest is None + ): + return False + state = ( + CandidateState.READY + if outcome.status is SnapshotStatus.READY + else CandidateState.REJECTED + ) + persisted = await asyncio.to_thread( + self._store.load_candidate, + state, + outcome.candidate_id, + ) + return persisted == outcome.manifest + except Exception: + return False + def _abandon_empty_lease(self, lease_id: str) -> None: with suppress(WorktreeStateError): self._store.abandon_empty_lease(lease_id) + def _cleanup_required(self, lease: WorktreeLease) -> CleanupResult: + with suppress(WorktreeStateError): + self._store.record_cleanup_required(lease.lease_id, "cleanup_failed") + return CleanupResult( + lease_id=lease.lease_id, + status=CleanupStatus.CLEANUP_REQUIRED, + ) + def _new_lease_id() -> str: return f"lease-{secrets.token_hex(16)}" + + +def _contains_path(paths: tuple[Path, ...], expected: Path) -> bool: + expected_identity = os.path.normcase(str(expected)) + return any(os.path.normcase(str(path)) == expected_identity for path in paths) diff --git a/src/mini_code_agent/worktrees/materialize.py b/src/mini_code_agent/worktrees/materialize.py index 62f950a..2ba1768 100644 --- a/src/mini_code_agent/worktrees/materialize.py +++ b/src/mini_code_agent/worktrees/materialize.py @@ -17,6 +17,37 @@ class MaterializationError(RuntimeError): pass +def read_worktree_admin_dir(root: Path) -> Path: + git_file = root / ".git" + if _is_link_or_reparse(git_file): + raise MaterializationError("Worktree administrative file cannot be a link.") + try: + content = git_file.read_bytes() + except OSError: + raise MaterializationError("Worktree administrative file is unavailable.") from None + if ( + not content.endswith(b"\n") + or content.count(b"\n") != 1 + or len(content) > 4096 + or not content.startswith(b"gitdir: ") + ): + raise MaterializationError("Worktree administrative file is invalid.") + try: + raw_path = content[len(b"gitdir: ") : -1].decode("utf-8") + except UnicodeDecodeError: + raise MaterializationError("Worktree administrative file is invalid.") from None + candidate = Path(raw_path) + if not candidate.is_absolute(): + candidate = root / candidate + try: + resolved = candidate.resolve(strict=True) + except OSError: + raise MaterializationError("Worktree administrative directory is unavailable.") from None + if _is_link_or_reparse(resolved) or not resolved.is_dir(): + raise MaterializationError("Worktree administrative directory is unsafe.") + return resolved + + def materialize_index( root: Path, pointers: tuple[GitIndexPointer, ...], @@ -83,6 +114,7 @@ def _verify_initial_root(root: Path) -> Path: raise MaterializationError("Worktree administrative path is invalid.") except OSError: raise MaterializationError("Worktree administrative path is unavailable.") from None + read_worktree_admin_dir(resolved) return resolved diff --git a/src/mini_code_agent/worktrees/models.py b/src/mini_code_agent/worktrees/models.py index fa73c53..7335ab8 100644 --- a/src/mini_code_agent/worktrees/models.py +++ b/src/mini_code_agent/worktrees/models.py @@ -87,6 +87,11 @@ class SnapshotStatus(StrEnum): CLEANUP_REQUIRED = "cleanup_required" +class CleanupStatus(StrEnum): + REMOVED = "removed" + CLEANUP_REQUIRED = "cleanup_required" + + class WorktreeLimits(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) @@ -264,6 +269,7 @@ class WorktreeLease(BaseModel): repository_root: Path container_path: Path worktree_path: Path + git_admin_dir: Path base_sha: str = Field(pattern=_SHA1) base_manifest: BaseManifest state: WorktreeLeaseState @@ -274,6 +280,7 @@ def validate_paths_and_base(self) -> Self: not self.repository_root.is_absolute() or not self.container_path.is_absolute() or not self.worktree_path.is_absolute() + or not self.git_admin_dir.is_absolute() or self.worktree_path != self.container_path / "worktree" or self.container_path.name != self.lease_id or self.base_manifest.repository_root != self.repository_root @@ -499,6 +506,27 @@ def validate_status(self) -> Self: return self +class CleanupResult(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + lease_id: str = Field(pattern=_IDENTIFIER) + status: CleanupStatus + + +class WorktreeFinalizationResult(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + lease_id: str = Field(pattern=_IDENTIFIER) + snapshot: SnapshotOutcome + cleanup: CleanupResult + + @model_validator(mode="after") + def validate_lease_identity(self) -> Self: + if self.snapshot.lease_id != self.lease_id or self.cleanup.lease_id != self.lease_id: + raise ValueError("Worktree finalization lease identity is inconsistent.") + return self + + def _normalize_relative_path(value: str, *, allow_trailing_slash: bool = False) -> str: if "\0" in value or "\\" in value: raise ValueError("Worktree paths must be NUL-free POSIX paths.") diff --git a/src/mini_code_agent/worktrees/snapshot.py b/src/mini_code_agent/worktrees/snapshot.py index 9f26479..9748646 100644 --- a/src/mini_code_agent/worktrees/snapshot.py +++ b/src/mini_code_agent/worktrees/snapshot.py @@ -5,6 +5,7 @@ import hashlib import os import stat +from contextlib import suppress from dataclasses import dataclass from pathlib import Path from typing import Literal, Protocol @@ -216,8 +217,9 @@ def _persist_candidate( target, ) - @staticmethod - def _cleanup_required(lease: WorktreeLease) -> SnapshotOutcome: + def _cleanup_required(self, lease: WorktreeLease) -> SnapshotOutcome: + with suppress(WorktreeStateError): + self._store.record_cleanup_required(lease.lease_id, "snapshot_failed") return SnapshotOutcome( lease_id=lease.lease_id, status=SnapshotStatus.CLEANUP_REQUIRED, @@ -286,6 +288,17 @@ def _scan_worktree( ) +def verify_lease_base_clean( + profile: WorktreeProfile, + lease: WorktreeLease, +) -> bool: + try: + scan = _scan_worktree(profile, lease, MutationLedger(max_entries=1)) + except SnapshotUnsafeError: + return False + return not scan.observed_paths + + def _walk_regular_files( profile: WorktreeProfile, lease: WorktreeLease, diff --git a/src/mini_code_agent/worktrees/state.py b/src/mini_code_agent/worktrees/state.py index 574c4c3..98112bc 100644 --- a/src/mini_code_agent/worktrees/state.py +++ b/src/mini_code_agent/worktrees/state.py @@ -10,7 +10,14 @@ from dataclasses import dataclass from pathlib import Path -from mini_code_agent.worktrees.models import CandidateState, WorktreeProfile +from pydantic import ValidationError + +from mini_code_agent.worktrees.models import ( + CandidateDisposition, + CandidateManifest, + CandidateState, + WorktreeProfile, +) _IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") _SHA256 = re.compile(r"^[0-9a-f]{64}$") @@ -194,6 +201,142 @@ def transition_candidate( self._verify_directory(target_path) return target_path + def load_candidate( + self, + state: CandidateState, + candidate_id: str, + ) -> CandidateManifest: + self._validate_identifier(candidate_id) + self._verify_layout() + candidate = self._candidate_path(state, candidate_id) + self._verify_directory(candidate) + try: + candidate_children = {path.name for path in candidate.iterdir()} + except OSError: + raise WorktreeStateError("Candidate directory could not be listed.") from None + if candidate_children != {"manifest.json", "blobs"}: + raise WorktreeStateError("Candidate directory contains unexpected paths.") + manifest_path = candidate / "manifest.json" + if _is_link_or_reparse(manifest_path): + raise WorktreeStateError("Candidate manifest is unsafe.") + try: + if manifest_path.stat(follow_symlinks=False).st_size > 16 * 1024 * 1024: + raise WorktreeStateError("Candidate manifest exceeds its limit.") + manifest = CandidateManifest.model_validate_json(manifest_path.read_bytes()) + except WorktreeStateError: + raise + except (OSError, ValidationError, ValueError): + raise WorktreeStateError("Candidate manifest is invalid.") from None + expected_disposition = { + CandidateState.READY: CandidateDisposition.READY, + CandidateState.REJECTED: CandidateDisposition.REJECTED, + CandidateState.APPLYING: CandidateDisposition.READY, + CandidateState.APPLIED: CandidateDisposition.READY, + CandidateState.UNCERTAIN: CandidateDisposition.READY, + }.get(state) + if ( + manifest.candidate_id != candidate_id + or expected_disposition is None + or manifest.disposition is not expected_disposition + or manifest.repository_root != self._profile.repository_root + or manifest.profile_id != self._profile.implementation_profile.profile_id + or manifest.changed_files > self._profile.limits.max_candidate_files + or manifest.after_content_bytes > self._profile.limits.max_candidate_after_bytes + or ( + state is not CandidateState.REJECTED + and any( + not _is_allowed_candidate_path( + path, + self._profile.allowed_path_prefixes, + ) + for path in manifest.observed_paths + ) + ) + ): + raise WorktreeStateError("Candidate state and manifest do not match.") + blobs = candidate / "blobs" + self._verify_directory(blobs) + expected_hashes = {item.content_blob_sha256 for item in manifest.files} + try: + actual = tuple(blobs.iterdir()) + except OSError: + raise WorktreeStateError("Candidate blobs could not be listed.") from None + if {path.name for path in actual} != expected_hashes: + raise WorktreeStateError("Candidate blob set is invalid.") + for path in actual: + if _is_link_or_reparse(path): + raise WorktreeStateError("Candidate blob is unsafe.") + try: + content = path.read_bytes() + except OSError: + raise WorktreeStateError("Candidate blob could not be read.") from None + if ( + len(content) > self._profile.limits.max_file_bytes + or hashlib.sha256(content).hexdigest() != path.name + ): + raise WorktreeStateError("Candidate blob hash is invalid.") + return manifest + + def complete_lease(self, lease_id: str) -> None: + self._validate_identifier(lease_id) + leases = self._root / "leases" + self._verify_directory(leases) + container = leases / lease_id + self._verify_directory(container) + if (container / "worktree").exists(): + raise WorktreeStateError("Active Worktree lease cannot be completed.") + try: + children = tuple(container.iterdir()) + except OSError: + raise WorktreeStateError("Lease state could not be listed.") from None + allowed = {"base-manifest.json", "lease.json", "cleanup-required.json"} + if any(child.name not in allowed for child in children): + raise WorktreeStateError("Lease state contains unexpected paths.") + for child in children: + if _is_link_or_reparse(child): + raise WorktreeStateError("Lease metadata is unsafe.") + try: + if not stat.S_ISREG(child.stat(follow_symlinks=False).st_mode): + raise WorktreeStateError("Lease metadata is not a regular file.") + child.unlink() + except WorktreeStateError: + raise + except OSError: + raise WorktreeStateError("Lease metadata could not be removed.") from None + try: + container.rmdir() + except OSError: + raise WorktreeStateError("Lease directory could not be removed.") from None + + def record_cleanup_required(self, lease_id: str, stage: str) -> None: + self._validate_identifier(lease_id) + if stage not in { + "snapshot_failed", + "cleanup_failed", + "cancellation_timeout", + "creation_failed", + }: + raise WorktreeStateError("Cleanup diagnostic stage is invalid.") + container = self._root / "leases" / lease_id + self._verify_directory(container) + target = container / "cleanup-required.json" + if target.exists(): + return + payload = ( + json.dumps( + { + "lease_id": lease_id, + "stage": stage, + "status": "cleanup_required", + }, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ) + + "\n" + ).encode("ascii") + self._publish_immutable(target, payload) + def _building_candidate(self, candidate_id: str) -> Path: self._validate_identifier(candidate_id) self._verify_layout() @@ -290,3 +433,7 @@ def _fsync_directory(path: Path) -> None: os.fsync(descriptor) finally: os.close(descriptor) + + +def _is_allowed_candidate_path(path: str, prefixes: tuple[str, ...]) -> bool: + return any(path == prefix or path.startswith(f"{prefix}/") for prefix in prefixes) diff --git a/tests/integration/test_worktree_materialization.py b/tests/integration/test_worktree_materialization.py index c23c6be..37c8a3e 100644 --- a/tests/integration/test_worktree_materialization.py +++ b/tests/integration/test_worktree_materialization.py @@ -16,7 +16,12 @@ from mini_code_agent.worktrees.git import WorktreeGit from mini_code_agent.worktrees.ledger import MutationLedger from mini_code_agent.worktrees.manager import WorktreeManager -from mini_code_agent.worktrees.models import SnapshotStatus, WorktreeProfile +from mini_code_agent.worktrees.models import ( + CleanupStatus, + SnapshotOutcome, + SnapshotStatus, + WorktreeProfile, +) from mini_code_agent.worktrees.snapshot import CandidateSnapshotter from mini_code_agent.worktrees.state import WorktreeStateStore @@ -74,9 +79,14 @@ async def test_real_no_checkout_lease_materializes_only_tracked_index( assert not (lease.worktree_path / ".env").exists() assert not (lease.worktree_path / ".venv").exists() assert not (lease.worktree_path / "cache").exists() - await git.unlock_worktree(lease.worktree_path) - await git.remove_worktree(lease.worktree_path) - await git.prune_worktrees() + cleanup = await manager.cleanup_lease( + lease, + SnapshotOutcome( + lease_id=lease.lease_id, + status=SnapshotStatus.NO_CHANGES, + ), + ) + assert cleanup.status is CleanupStatus.REMOVED @pytest.mark.asyncio @@ -154,9 +164,8 @@ async def test_real_lease_snapshot_persists_candidate_without_parent_mutation( assert (repository / "src" / "app.py").read_bytes() == parent_content assert not (repository / "src" / "new.py").exists() assert (state / "candidates" / "ready" / "candidate-real" / "manifest.json").is_file() - await git.unlock_worktree(lease.worktree_path) - await git.remove_worktree(lease.worktree_path) - await git.prune_worktrees() + cleanup = await manager.cleanup_lease(lease, outcome) + assert cleanup.status is CleanupStatus.REMOVED def _profile(repository: Path, state: Path, git_executable: Path) -> WorktreeProfile: diff --git a/tests/unit/worktrees/test_cancellation.py b/tests/unit/worktrees/test_cancellation.py new file mode 100644 index 0000000..cf8d559 --- /dev/null +++ b/tests/unit/worktrees/test_cancellation.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from mini_code_agent.subagents.models import SubagentStatus +from mini_code_agent.worktrees.finalization import ( + WorktreeFinalizer, + await_with_cancellation_finalization, +) +from mini_code_agent.worktrees.ledger import MutationLedger +from mini_code_agent.worktrees.manager import WorktreeManager +from mini_code_agent.worktrees.snapshot import CandidateSnapshotter +from mini_code_agent.worktrees.state import WorktreeStateStore + +from .helpers import worktree_profile +from .test_manager_leases import FakeGit + + +@pytest.mark.asyncio +async def test_child_cancellation_waits_for_shielded_finalization_then_reraises() -> None: + child_started = asyncio.Event() + finalized = asyncio.Event() + + async def child() -> None: + child_started.set() + await asyncio.Event().wait() + + async def finalize() -> None: + await asyncio.sleep(0.01) + finalized.set() + + task = asyncio.create_task( + await_with_cancellation_finalization( + child(), + finalize=finalize, + timeout_seconds=1, + ) + ) + await child_started.wait() + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + assert finalized.is_set() + + +@pytest.mark.asyncio +async def test_cancellation_finalization_timeout_cancels_finalizer_and_reraises() -> None: + child_started = asyncio.Event() + finalizer_cancelled = asyncio.Event() + timeout_recorded = False + + async def child() -> None: + child_started.set() + await asyncio.Event().wait() + + async def finalize() -> None: + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + finalizer_cancelled.set() + raise + + def record_timeout() -> None: + nonlocal timeout_recorded + timeout_recorded = True + + task = asyncio.create_task( + await_with_cancellation_finalization( + child(), + finalize=finalize, + timeout_seconds=0.05, + on_timeout=lambda: record_timeout(), + ) + ) + await child_started.wait() + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=1) + + assert finalizer_cancelled.is_set() + assert timeout_recorded is True + + +@pytest.mark.asyncio +async def test_normal_child_completion_does_not_run_cancellation_finalizer() -> None: + called = False + + async def finalize() -> None: + nonlocal called + called = True + + result = await await_with_cancellation_finalization( + asyncio.sleep(0, result="complete"), + finalize=finalize, + timeout_seconds=1, + ) + + assert result == "complete" + assert called is False + + +@pytest.mark.asyncio +async def test_cancelled_child_runs_real_snapshot_and_cleanup_before_reraise( + tmp_path: Path, +) -> None: + profile = worktree_profile(tmp_path) + store = WorktreeStateStore(profile) + git = FakeGit(profile.repository_root) + manager = WorktreeManager( + profile, + git=git, + store=store, + id_factory=lambda: "lease-1", + ) + lease = await manager.create_lease(child_id="child-1") + finalizer = WorktreeFinalizer( + snapshotter=CandidateSnapshotter( + profile, + store=store, + blob_reader=git, + ), + cleaner=manager, + ) + child_started = asyncio.Event() + + async def child() -> None: + child_started.set() + await asyncio.Event().wait() + + async def finalize() -> object: + return await finalizer.finalize( + lease, + MutationLedger(max_entries=8), + candidate_id="candidate-1", + child_status=SubagentStatus.TIMED_OUT, + evidence_sha256="e" * 64, + ) + + task = asyncio.create_task( + await_with_cancellation_finalization( + child(), + finalize=finalize, + timeout_seconds=2, + ) + ) + await child_started.wait() + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + assert not lease.container_path.exists() diff --git a/tests/unit/worktrees/test_cleanup.py b/tests/unit/worktrees/test_cleanup.py new file mode 100644 index 0000000..74805ae --- /dev/null +++ b/tests/unit/worktrees/test_cleanup.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from mini_code_agent.domain.content import ToolCall, ToolResult +from mini_code_agent.subagents.models import SubagentStatus +from mini_code_agent.workspace.models import MutationResult +from mini_code_agent.worktrees.ledger import MutationLedger +from mini_code_agent.worktrees.manager import WorktreeManager +from mini_code_agent.worktrees.models import ( + CleanupStatus, + SnapshotOutcome, + SnapshotStatus, +) +from mini_code_agent.worktrees.snapshot import CandidateSnapshotter +from mini_code_agent.worktrees.state import WorktreeStateStore + +from .helpers import worktree_profile +from .test_manager_leases import FakeGit + + +async def managed_lease(tmp_path: Path): + profile = worktree_profile(tmp_path) + store = WorktreeStateStore(profile) + git = FakeGit(profile.repository_root) + manager = WorktreeManager( + profile, + git=git, + store=store, + id_factory=lambda: "lease-1", + ) + lease = await manager.create_lease(child_id="child-1") + return profile, store, git, manager, lease + + +def no_changes(lease_id: str) -> SnapshotOutcome: + return SnapshotOutcome( + lease_id=lease_id, + status=SnapshotStatus.NO_CHANGES, + ) + + +@pytest.mark.asyncio +async def test_cleanup_verifies_and_removes_exact_clean_lease(tmp_path: Path) -> None: + _, _, git, manager, lease = await managed_lease(tmp_path) + + result = await manager.cleanup_lease(lease, no_changes(lease.lease_id)) + + assert result.status is CleanupStatus.REMOVED + assert git.cleanup_calls == [ + ("unlock", lease.worktree_path), + ("remove", lease.worktree_path), + ("prune", None), + ] + assert not lease.container_path.exists() + + +@pytest.mark.asyncio +async def test_cleanup_refuses_dirty_unsnapshotted_tree(tmp_path: Path) -> None: + _, _, git, manager, lease = await managed_lease(tmp_path) + (lease.worktree_path / "src" / "app.py").write_text("dirty\n", encoding="utf-8") + + result = await manager.cleanup_lease(lease, no_changes(lease.lease_id)) + + assert result.status is CleanupStatus.CLEANUP_REQUIRED + assert git.cleanup_calls == [] + assert lease.worktree_path.exists() + diagnostic = lease.container_path / "cleanup-required.json" + assert json.loads(diagnostic.read_text(encoding="utf-8")) == { + "lease_id": "lease-1", + "stage": "cleanup_failed", + "status": "cleanup_required", + } + + +@pytest.mark.asyncio +async def test_cleanup_refuses_swapped_admin_identity(tmp_path: Path) -> None: + _, _, git, manager, lease = await managed_lease(tmp_path) + replacement = lease.container_path / "replacement-admin" + replacement.mkdir() + (lease.worktree_path / ".git").write_bytes(f"gitdir: {replacement}\n".encode()) + + result = await manager.cleanup_lease(lease, no_changes(lease.lease_id)) + + assert result.status is CleanupStatus.CLEANUP_REQUIRED + assert git.cleanup_calls == [] + + +@pytest.mark.asyncio +async def test_cleanup_relocks_lease_when_force_remove_fails(tmp_path: Path) -> None: + _, _, git, manager, lease = await managed_lease(tmp_path) + git.fail_remove = True + + result = await manager.cleanup_lease(lease, no_changes(lease.lease_id)) + + assert result.status is CleanupStatus.CLEANUP_REQUIRED + assert git.cleanup_calls == [ + ("unlock", lease.worktree_path), + ("remove", lease.worktree_path), + ("lock", lease.worktree_path), + ] + assert lease.worktree_path.exists() + + +@pytest.mark.asyncio +async def test_cleanup_accepts_verified_ready_candidate_and_preserves_it( + tmp_path: Path, +) -> None: + profile, store, git, manager, lease = await managed_lease(tmp_path) + target = lease.worktree_path / "src" / "app.py" + before = target.read_bytes() + after = b"print('candidate')\n" + target.write_bytes(after) + mutation = MutationResult( + path="src/app.py", + created=False, + before_sha256=hashlib.sha256(before).hexdigest(), + after_sha256=hashlib.sha256(after).hexdigest(), + byte_count=len(after), + line_count=1, + diff="bounded", + ) + ledger = MutationLedger(max_entries=8) + call = ToolCall(id="write-1", name="write_file", arguments={}) + ledger.record( + call, + ToolResult( + tool_call_id=call.id, + content=json.dumps(mutation.model_dump(mode="json")), + ), + ) + outcome = await CandidateSnapshotter( + profile, + store=store, + blob_reader=git, + ).snapshot( + lease, + ledger, + candidate_id="candidate-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + assert outcome.status is SnapshotStatus.READY + + result = await manager.cleanup_lease(lease, outcome) + + assert result.status is CleanupStatus.REMOVED + assert (profile.state_root / "candidates" / "ready" / "candidate-1").is_dir() + + +@pytest.mark.asyncio +async def test_cleanup_refuses_tampered_candidate_blob(tmp_path: Path) -> None: + profile, store, git, manager, lease = await managed_lease(tmp_path) + target = lease.worktree_path / "src" / "app.py" + before = target.read_bytes() + after = b"print('candidate')\n" + target.write_bytes(after) + mutation = MutationResult( + path="src/app.py", + created=False, + before_sha256=hashlib.sha256(before).hexdigest(), + after_sha256=hashlib.sha256(after).hexdigest(), + byte_count=len(after), + line_count=1, + diff="bounded", + ) + ledger = MutationLedger(max_entries=8) + call = ToolCall(id="write-1", name="write_file", arguments={}) + ledger.record( + call, + ToolResult( + tool_call_id=call.id, + content=json.dumps(mutation.model_dump(mode="json")), + ), + ) + outcome = await CandidateSnapshotter( + profile, + store=store, + blob_reader=git, + ).snapshot( + lease, + ledger, + candidate_id="candidate-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + blob = ( + profile.state_root + / "candidates" + / "ready" + / "candidate-1" + / "blobs" + / hashlib.sha256(after).hexdigest() + ) + blob.write_bytes(b"tampered") + + result = await manager.cleanup_lease(lease, outcome) + + assert result.status is CleanupStatus.CLEANUP_REQUIRED + assert git.cleanup_calls == [] diff --git a/tests/unit/worktrees/test_git.py b/tests/unit/worktrees/test_git.py index ed376fe..6c44047 100644 --- a/tests/unit/worktrees/test_git.py +++ b/tests/unit/worktrees/test_git.py @@ -15,6 +15,7 @@ WorktreeGitError, parse_batch_blobs, parse_index_pointers, + parse_worktree_paths, ) from mini_code_agent.worktrees.models import WorktreeErrorCode from mini_code_agent.worktrees.state import WorktreeStateStore @@ -172,6 +173,21 @@ def test_batch_blob_parser_validates_order_size_and_terminators() -> None: parse_batch_blobs(payload, (first.decode(), second.decode()), max_total_bytes=5) +def test_worktree_list_parser_extracts_absolute_unique_paths(tmp_path: Path) -> None: + first = (tmp_path / "first").resolve() + second = (tmp_path / "second").resolve() + payload = ( + f"worktree {first}\0HEAD {'a' * 40}\0locked reason\0\0" + f"worktree {second}\0HEAD {'b' * 40}\0detached\0\0" + ).encode() + + assert parse_worktree_paths(payload) == (first, second) + with pytest.raises(WorktreeGitError): + parse_worktree_paths(payload.rstrip(b"\0")) + with pytest.raises(WorktreeGitError): + parse_worktree_paths(f"worktree {first}\0worktree {first}\0".encode()) + + @pytest.mark.asyncio async def test_byte_runner_enforces_output_and_timeout_limits(tmp_path: Path) -> None: runner = GitBytesRunner(cleanup_timeout_seconds=2) diff --git a/tests/unit/worktrees/test_manager_leases.py b/tests/unit/worktrees/test_manager_leases.py index a4e4209..1d411e6 100644 --- a/tests/unit/worktrees/test_manager_leases.py +++ b/tests/unit/worktrees/test_manager_leases.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shutil from pathlib import Path import pytest @@ -21,6 +22,9 @@ def __init__(self, profile_root: Path, *, status: bytes = b"") -> None: self.profile_root = profile_root self.status = status self.added: list[tuple[str, Path, str]] = [] + self.active_paths: list[Path] = [] + self.cleanup_calls: list[tuple[str, Path | None]] = [] + self.fail_remove = False async def repository_info(self) -> tuple[Path, bool]: return self.profile_root, False @@ -47,7 +51,32 @@ async def read_blobs(self, object_ids: tuple[str, ...]) -> dict[str, bytes]: async def add_worktree(self, lease_id: str, path: Path, base_sha: str) -> None: self.added.append((lease_id, path, base_sha)) path.mkdir() - (path / ".git").write_text("gitdir: admin\n", encoding="utf-8") + admin = path.parent / "admin" + admin.mkdir() + (path / ".git").write_bytes(f"gitdir: {admin}\n".encode()) + self.active_paths.append(path) + + async def unlock_worktree(self, path: Path) -> None: + self.cleanup_calls.append(("unlock", path)) + + async def lock_worktree(self, path: Path, lease_id: str) -> None: + assert lease_id + self.cleanup_calls.append(("lock", path)) + + async def remove_worktree(self, path: Path) -> None: + self.cleanup_calls.append(("remove", path)) + if self.fail_remove: + raise RuntimeError("simulated remove failure") + shutil.rmtree(path) + admin = path.parent / "admin" + shutil.rmtree(admin) + self.active_paths.remove(path) + + async def prune_worktrees(self) -> None: + self.cleanup_calls.append(("prune", None)) + + async def worktree_paths(self) -> tuple[Path, ...]: + return tuple(self.active_paths) @pytest.mark.asyncio @@ -67,6 +96,7 @@ async def test_manager_creates_host_owned_materialized_lease(tmp_path: Path) -> assert lease.base_sha == "a" * 40 assert lease.state is WorktreeLeaseState.ACTIVE assert lease.worktree_path == profile.state_root / "leases" / "lease-1" / "worktree" + assert lease.git_admin_dir == profile.state_root / "leases" / "lease-1" / "admin" assert (lease.worktree_path / "src" / "app.py").read_bytes() == b"print('ok')\n" assert lease.base_manifest.tracked_files == 1 assert lease.base_manifest.tracked_bytes == 12 diff --git a/tests/unit/worktrees/test_materialize.py b/tests/unit/worktrees/test_materialize.py index 4cfe1ba..b3b82c5 100644 --- a/tests/unit/worktrees/test_materialize.py +++ b/tests/unit/worktrees/test_materialize.py @@ -25,7 +25,9 @@ def pointer( def test_materializer_writes_raw_blobs_and_regular_modes(tmp_path: Path) -> None: root = tmp_path / "worktree" root.mkdir() - (root / ".git").write_text("gitdir: admin\n", encoding="utf-8") + admin = tmp_path / "admin" + admin.mkdir() + (root / ".git").write_bytes(f"gitdir: {admin}\n".encode()) script = b"#!/usr/bin/env python\nprint('ok')\r\n" binary = b"\x00\xffraw" pointers = ( @@ -82,7 +84,9 @@ def test_materializer_rejects_missing_blobs_or_budget_excess( ) -> None: root = tmp_path / "worktree" root.mkdir() - (root / ".git").write_text("gitdir: admin\n", encoding="utf-8") + admin = tmp_path / "admin" + admin.mkdir() + (root / ".git").write_bytes(f"gitdir: {admin}\n".encode()) with pytest.raises(MaterializationError): materialize_index(root, pointers, blobs, limits=limits) @@ -93,7 +97,9 @@ def test_materializer_rejects_unexpected_or_linked_worktree_content( ) -> None: root = tmp_path / "worktree" root.mkdir() - (root / ".git").write_text("gitdir: admin\n", encoding="utf-8") + admin = tmp_path / "admin" + admin.mkdir() + (root / ".git").write_bytes(f"gitdir: {admin}\n".encode()) (root / "unexpected").write_text("unsafe", encoding="utf-8") with pytest.raises(MaterializationError): @@ -111,7 +117,9 @@ def test_materializer_rejects_parent_directory_swap_to_link( ) -> None: root = tmp_path / "worktree" root.mkdir() - (root / ".git").write_text("gitdir: admin\n", encoding="utf-8") + admin = tmp_path / "admin" + admin.mkdir() + (root / ".git").write_bytes(f"gitdir: {admin}\n".encode()) def marks_src_as_link(path: Path) -> bool: return path.name == "src" diff --git a/tests/unit/worktrees/test_snapshot.py b/tests/unit/worktrees/test_snapshot.py index 1ee0d66..aec8750 100644 --- a/tests/unit/worktrees/test_snapshot.py +++ b/tests/unit/worktrees/test_snapshot.py @@ -49,7 +49,9 @@ def lease_for( store.initialize() paths = store.begin_lease("lease-1") paths.worktree.mkdir() - (paths.worktree / ".git").write_text("gitdir: admin\n", encoding="utf-8") + admin = paths.container / "admin" + admin.mkdir() + (paths.worktree / ".git").write_bytes(f"gitdir: {admin}\n".encode()) base_files = files or {"src/app.py": b"print('base')\n"} blobs: dict[str, bytes] = {} entries: list[GitIndexEntry] = [] @@ -79,6 +81,7 @@ def lease_for( repository_root=profile.repository_root, container_path=paths.container, worktree_path=paths.worktree, + git_admin_dir=admin, base_sha="a" * 40, base_manifest=manifest, state=WorktreeLeaseState.ACTIVE, From 9f4b358c3721d3a0128edd65232ea85d12937d3e Mon Sep 17 00:00:00 2001 From: JayLin Date: Thu, 2 Jul 2026 09:51:41 +0800 Subject: [PATCH 08/12] feat: delegate bounded implementation work --- .../2026-07-02-m6b-worktree-candidates.md | 16 +- src/mini_code_agent/worktrees/__init__.py | 26 ++ src/mini_code_agent/worktrees/finalization.py | 51 ++- src/mini_code_agent/worktrees/manager.py | 17 + src/mini_code_agent/worktrees/models.py | 77 +++- src/mini_code_agent/worktrees/runner.py | 360 ++++++++++++++++ src/mini_code_agent/worktrees/tools.py | 233 ++++++++++- .../test_governed_worktree_agent.py | 304 ++++++++++++++ tests/smoke_test.py | 12 + tests/unit/worktrees/test_runner.py | 390 ++++++++++++++++++ 10 files changed, 1457 insertions(+), 29 deletions(-) create mode 100644 src/mini_code_agent/worktrees/runner.py create mode 100644 tests/integration/test_governed_worktree_agent.py create mode 100644 tests/unit/worktrees/test_runner.py diff --git a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md index f1f7cdf..0d0ac47 100644 --- a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md +++ b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md @@ -84,7 +84,7 @@ hooks-empty/ - [x] Write failing tests that assert the mandatory `--no-checkout`, detached/locked worktree argv and empty Hooks directory. - [x] Write failing tests for tracked file/byte/depth/path limits, 100644/100755-only entries, sparse/unmerged/gitlink/symlink/special rejection, duplicate/case collision rejection, and truncated Git output. - [x] Materialize regular files exclusively from index blob bytes, preserving only executable/non-executable regular modes. -- [ ] Build a fresh `WorkspaceBoundary` rooted at the lease and persist the immutable base manifest before child execution. +- [x] Build a fresh `WorkspaceBoundary` rooted at the lease and persist the immutable base manifest before child execution. - [x] Prove with a real Git repository that ignored, untracked, `.env`, cache, and virtual-environment files are absent. - [x] Commit: `feat: materialize governed worktree leases` @@ -147,13 +147,13 @@ hooks-empty/ - Test: `tests/unit/worktrees/test_delegate_tool.py` - Test: `tests/integration/test_governed_worktree_agent.py` -- [ ] Define a parent `delegate_implementation` Tool whose model-visible input is only `{task, reason}` and whose output is bounded candidate metadata/evidence. -- [ ] Compose the exact local implementation profile and host factories before any Provider I/O. -- [ ] Run one implementation child per Tool call inside its lease, project bounded child evidence, snapshot independently, and finalize cleanup. -- [ ] Ensure child timeout, failure, cancellation, no-change completion, rejected snapshot, and candidate-ready paths have deterministic public results. -- [ ] Add CLI/composition wiring without enabling the feature by default when no Worktree profile is configured. -- [ ] Prove end-to-end with a scripted Provider that child completion leaves the parent checkout unchanged. -- [ ] Commit: `feat: delegate bounded implementation work` +- [x] Define a parent `delegate_implementation` Tool whose model-visible input is only `{task, reason}` and whose output is bounded candidate metadata/evidence. +- [x] Compose the exact local implementation profile and host factories before any Provider I/O. +- [x] Run one implementation child per Tool call inside its lease, project bounded child evidence, snapshot independently, and finalize cleanup. +- [x] Ensure child timeout, failure, cancellation, no-change completion, rejected snapshot, and candidate-ready paths have deterministic public results. +- [x] Add public composition exports and smoke coverage without enabling the feature unless a host supplies a `WorktreeProfile`. +- [x] Prove end-to-end with a scripted Provider that child completion leaves the parent checkout unchanged. +- [x] Commit: `feat: delegate bounded implementation work` ## Task 8: Adopt, Roll Back, Recover, and Discard Candidates diff --git a/src/mini_code_agent/worktrees/__init__.py b/src/mini_code_agent/worktrees/__init__.py index 4ca77e6..be9b663 100644 --- a/src/mini_code_agent/worktrees/__init__.py +++ b/src/mini_code_agent/worktrees/__init__.py @@ -1,5 +1,9 @@ """Governed worktree leases and independently verified candidates.""" +from mini_code_agent.worktrees.finalization import WorktreeFinalizer +from mini_code_agent.worktrees.git import WorktreeGit +from mini_code_agent.worktrees.ledger import MutationLedger +from mini_code_agent.worktrees.manager import WorktreeManager from mini_code_agent.worktrees.models import ( BaseManifest, CandidateDisposition, @@ -11,6 +15,7 @@ CleanupStatus, GitIndexEntry, GitIndexPointer, + ImplementationRunResult, MutationLedgerEntry, SnapshotOutcome, SnapshotStatus, @@ -22,6 +27,16 @@ WorktreeLimits, WorktreeProfile, ) +from mini_code_agent.worktrees.runner import ( + WorktreeChildToolFactory, + WorktreeImplementationRunner, +) +from mini_code_agent.worktrees.snapshot import CandidateSnapshotter +from mini_code_agent.worktrees.state import WorktreeStateStore +from mini_code_agent.worktrees.tools import ( + DelegateImplementationTool, + build_worktree_tools, +) __all__ = [ "BaseManifest", @@ -29,19 +44,30 @@ "CandidateFile", "CandidateManifest", "CandidateOperation", + "CandidateSnapshotter", "CandidateState", "CleanupResult", "CleanupStatus", + "DelegateImplementationTool", "GitIndexEntry", "GitIndexPointer", + "ImplementationRunResult", + "MutationLedger", "MutationLedgerEntry", "SnapshotOutcome", "SnapshotStatus", + "WorktreeChildToolFactory", "WorktreeError", "WorktreeErrorCode", "WorktreeFinalizationResult", + "WorktreeFinalizer", + "WorktreeGit", + "WorktreeImplementationRunner", "WorktreeLease", "WorktreeLeaseState", "WorktreeLimits", + "WorktreeManager", "WorktreeProfile", + "WorktreeStateStore", + "build_worktree_tools", ] diff --git a/src/mini_code_agent/worktrees/finalization.py b/src/mini_code_agent/worktrees/finalization.py index 5864c0a..d1eb4d7 100644 --- a/src/mini_code_agent/worktrees/finalization.py +++ b/src/mini_code_agent/worktrees/finalization.py @@ -90,22 +90,37 @@ async def await_with_cancellation_finalization[T]( try: return await child except asyncio.CancelledError: - task = asyncio.create_task(finalize()) - try: - await asyncio.wait_for( - asyncio.shield(task), - timeout=timeout_seconds, - ) - except TimeoutError: - task.cancel() - await asyncio.gather(task, return_exceptions=True) - if on_timeout is not None: - with suppress(Exception): - on_timeout() - except asyncio.CancelledError: - if not task.done(): - task.cancel() - await asyncio.gather(task, return_exceptions=True) - except Exception: - pass + await run_cancellation_finalization( + finalize=finalize, + timeout_seconds=timeout_seconds, + on_timeout=on_timeout, + ) raise + + +async def run_cancellation_finalization( + *, + finalize: Callable[[], Coroutine[Any, Any, object]], + timeout_seconds: float, + on_timeout: Callable[[], None] | None = None, +) -> None: + if not 0 < timeout_seconds <= 300: + raise ValueError("Cancellation finalization timeout is invalid.") + task = asyncio.create_task(finalize()) + try: + await asyncio.wait_for( + asyncio.shield(task), + timeout=timeout_seconds, + ) + except TimeoutError: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + if on_timeout is not None: + with suppress(Exception): + on_timeout() + except asyncio.CancelledError: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + except Exception: + pass diff --git a/src/mini_code_agent/worktrees/manager.py b/src/mini_code_agent/worktrees/manager.py index 1a338ed..806733b 100644 --- a/src/mini_code_agent/worktrees/manager.py +++ b/src/mini_code_agent/worktrees/manager.py @@ -135,6 +135,16 @@ async def create_lease(self, *, child_id: str) -> WorktreeLease: lease.model_dump(mode="json", exclude={"base_manifest"}), ) return lease + except asyncio.CancelledError: + if worktree_created or paths.worktree.exists(): + with suppress(WorktreeStateError): + self._store.record_cleanup_required( + lease_id, + "creation_failed", + ) + else: + self._abandon_empty_lease(lease_id) + raise except WorktreeError: if not worktree_created: self._abandon_empty_lease(lease_id) @@ -271,6 +281,13 @@ def _abandon_empty_lease(self, lease_id: str) -> None: with suppress(WorktreeStateError): self._store.abandon_empty_lease(lease_id) + def record_cancellation_timeout(self, lease: WorktreeLease) -> None: + with suppress(WorktreeStateError): + self._store.record_cleanup_required( + lease.lease_id, + "cancellation_timeout", + ) + def _cleanup_required(self, lease: WorktreeLease) -> CleanupResult: with suppress(WorktreeStateError): self._store.record_cleanup_required(lease.lease_id, "cleanup_failed") diff --git a/src/mini_code_agent/worktrees/models.py b/src/mini_code_agent/worktrees/models.py index 7335ab8..3cf5713 100644 --- a/src/mini_code_agent/worktrees/models.py +++ b/src/mini_code_agent/worktrees/models.py @@ -16,7 +16,11 @@ model_validator, ) -from mini_code_agent.subagents.models import SubagentProfile, SubagentStatus +from mini_code_agent.subagents.models import ( + SubagentChildResult, + SubagentProfile, + SubagentStatus, +) _IDENTIFIER = r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$" _SHA1 = r"^[0-9a-f]{40}$" @@ -527,6 +531,62 @@ def validate_lease_identity(self) -> Self: return self +class ImplementationRunResult(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + profile_id: str = Field(pattern=r"^[a-z0-9][a-z0-9_-]{0,63}$") + child: SubagentChildResult + finalization: WorktreeFinalizationResult + duration_ms: int = Field(ge=0, le=3_700_000) + result_sha256: Sha256 + + @classmethod + def create( + cls, + *, + profile_id: str, + child: SubagentChildResult, + finalization: WorktreeFinalizationResult, + duration_ms: int, + ) -> Self: + projection = _implementation_run_projection( + profile_id=profile_id, + child=child, + finalization=finalization, + duration_ms=duration_ms, + ) + return cls( + profile_id=profile_id, + child=child, + finalization=finalization, + duration_ms=duration_ms, + result_sha256=_canonical_sha256(projection), + ) + + @model_validator(mode="after") + def validate_run(self) -> Self: + manifest = self.finalization.snapshot.manifest + if self.child.profile_id != self.profile_id or ( + manifest is not None + and ( + manifest.profile_id != self.profile_id + or manifest.child_id != self.child.child_id + or manifest.child_status is not self.child.status + or manifest.evidence_sha256 != self.child.result_sha256 + ) + ): + raise ValueError("Implementation run identity is inconsistent.") + projection = _implementation_run_projection( + profile_id=self.profile_id, + child=self.child, + finalization=self.finalization, + duration_ms=self.duration_ms, + ) + if self.result_sha256 != _canonical_sha256(projection): + raise ValueError("Implementation run hash is inconsistent.") + return self + + def _normalize_relative_path(value: str, *, allow_trailing_slash: bool = False) -> str: if "\0" in value or "\\" in value: raise ValueError("Worktree paths must be NUL-free POSIX paths.") @@ -639,3 +699,18 @@ def _candidate_manifest_projection( "rejection_reasons": list(rejection_reasons), "repository_root": str(repository_root), } + + +def _implementation_run_projection( + *, + profile_id: str, + child: SubagentChildResult, + finalization: WorktreeFinalizationResult, + duration_ms: int, +) -> dict[str, object]: + return { + "child": child.model_dump(mode="json"), + "duration_ms": duration_ms, + "finalization": finalization.model_dump(mode="json"), + "profile_id": profile_id, + } diff --git a/src/mini_code_agent/worktrees/runner.py b/src/mini_code_agent/worktrees/runner.py new file mode 100644 index 0000000..31b4925 --- /dev/null +++ b/src/mini_code_agent/worktrees/runner.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +import re +import time +from collections.abc import Callable +from typing import Protocol, cast +from uuid import uuid4 + +from mini_code_agent.agent.models import AgentResult, StopReason +from mini_code_agent.agent.runtime import AgentRuntime +from mini_code_agent.providers.base import ProviderCapabilities, TokenUsage +from mini_code_agent.subagents.contracts import ( + SubagentCompositionError, + SubagentProviderFactory, +) +from mini_code_agent.subagents.evidence import ( + SubagentEvidenceError, + extract_subagent_evidence, +) +from mini_code_agent.subagents.models import ( + SubagentChildResult, + SubagentErrorCode, + SubagentProfile, + SubagentStatus, +) +from mini_code_agent.tools.base import ToolExecutor +from mini_code_agent.workspace.boundary import WorkspaceBoundary +from mini_code_agent.workspace.models import WorkspaceLimits +from mini_code_agent.worktrees.finalization import ( + WorktreeFinalizer, + run_cancellation_finalization, +) +from mini_code_agent.worktrees.ledger import ( + LedgerRecordingToolExecutor, + MutationLedger, +) +from mini_code_agent.worktrees.manager import WorktreeManager +from mini_code_agent.worktrees.models import ( + ImplementationRunResult, + WorktreeFinalizationResult, + WorktreeLease, + WorktreeProfile, +) +from mini_code_agent.worktrees.tools import validate_implementation_child_tools + +_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") + + +class WorktreeChildToolFactory(Protocol): + def create( + self, + profile: SubagentProfile, + workspace: WorkspaceBoundary, + ) -> ToolExecutor: ... + + +class WorktreeImplementationRunner: + def __init__( + self, + profile: WorktreeProfile, + *, + manager: WorktreeManager, + finalizer: WorktreeFinalizer, + provider_factory: SubagentProviderFactory, + tool_factory: WorktreeChildToolFactory, + id_factory: Callable[[], str] | None = None, + monotonic: Callable[[], float] = time.monotonic, + ) -> None: + if profile.implementation_profile.mode != "implementation": + raise ValueError("Worktree runner requires an implementation profile.") + self._profile = profile + self._manager = manager + self._finalizer = finalizer + self._provider_factory = provider_factory + self._tool_factory = tool_factory + self._id_factory = id_factory or (lambda: str(uuid4())) + self._monotonic = monotonic + + @property + def profile(self) -> WorktreeProfile: + return self._profile + + async def run( + self, + *, + parent_tool_call_id: str, + task: str, + ) -> ImplementationRunResult: + implementation = self._profile.implementation_profile + if ( + not 1 <= len(parent_tool_call_id) <= 128 + or "\0" in parent_tool_call_id + or not 1 <= len(task) <= implementation.limits.max_task_chars + or "\0" in task + ): + raise ValueError("Implementation delegation request is invalid.") + child_id, candidate_id = self._allocate_ids() + started_at = self._monotonic() + lease = await self._manager.create_lease(child_id=child_id) + ledger = MutationLedger(max_entries=implementation.agent_limits.max_tool_calls) + try: + runtime = self._compose_runtime(lease, ledger) + except asyncio.CancelledError: + raise + except Exception: + failed = _error_child( + implementation, + child_id, + status=SubagentStatus.FAILED, + code=SubagentErrorCode.CHILD_FAILED, + message="Implementation child composition failed.", + ) + await self._finalize_after_child( + lease, + ledger, + candidate_id=candidate_id, + child_status=failed.status, + evidence_sha256=failed.result_sha256, + ) + raise SubagentCompositionError from None + + async def finalize_cancelled() -> object: + failed = _error_child( + implementation, + child_id, + status=SubagentStatus.FAILED, + code=SubagentErrorCode.CHILD_FAILED, + message="Implementation child was cancelled.", + ) + return await self._finalizer.finalize( + lease, + ledger, + candidate_id=candidate_id, + child_status=failed.status, + evidence_sha256=failed.result_sha256, + ) + + try: + async with asyncio.timeout(implementation.limits.child_timeout_seconds): + candidate = cast( + object, + await runtime.run( + user_prompt=task, + system_prompt=implementation.system_prompt, + run_id=_runtime_id(child_id), + ), + ) + if not isinstance(candidate, AgentResult): + raise TypeError("Invalid implementation Agent result.") + child = _project_agent_result(implementation, child_id, candidate) + except asyncio.CancelledError: + await run_cancellation_finalization( + finalize=finalize_cancelled, + timeout_seconds=self._profile.limits.cleanup_timeout_seconds, + on_timeout=lambda: self._manager.record_cancellation_timeout(lease), + ) + raise + except TimeoutError: + child = _error_child( + implementation, + child_id, + status=SubagentStatus.TIMED_OUT, + code=SubagentErrorCode.CHILD_TIMEOUT, + message="Implementation child timed out.", + ) + except Exception: + child = _error_child( + implementation, + child_id, + status=SubagentStatus.FAILED, + code=SubagentErrorCode.CHILD_FAILED, + message="Implementation child failed.", + ) + + finalization = await self._finalize_after_child( + lease, + ledger, + candidate_id=candidate_id, + child_status=child.status, + evidence_sha256=child.result_sha256, + ) + return ImplementationRunResult.create( + profile_id=implementation.profile_id, + child=child, + finalization=finalization, + duration_ms=_elapsed_ms(started_at, self._monotonic()), + ) + + async def _finalize_after_child( + self, + lease: WorktreeLease, + ledger: MutationLedger, + *, + candidate_id: str, + child_status: SubagentStatus, + evidence_sha256: str, + ) -> WorktreeFinalizationResult: + task = asyncio.create_task( + self._finalizer.finalize( + lease, + ledger, + candidate_id=candidate_id, + child_status=child_status, + evidence_sha256=evidence_sha256, + ) + ) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + try: + await asyncio.wait_for( + asyncio.shield(task), + timeout=self._profile.limits.cleanup_timeout_seconds, + ) + except TimeoutError: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + self._manager.record_cancellation_timeout(lease) + except Exception: + pass + raise + + def _allocate_ids(self) -> tuple[str, str]: + child_id = self._id_factory() + candidate_id = self._id_factory() + if ( + child_id == candidate_id + or _IDENTIFIER.fullmatch(child_id) is None + or _IDENTIFIER.fullmatch(candidate_id) is None + ): + raise SubagentCompositionError + return child_id, candidate_id + + def _compose_runtime( + self, + lease: WorktreeLease, + ledger: MutationLedger, + ) -> AgentRuntime: + implementation = self._profile.implementation_profile + workspace = WorkspaceBoundary( + lease.worktree_path, + limits=WorkspaceLimits( + max_file_bytes=self._profile.limits.max_file_bytes, + max_path_chars=self._profile.limits.max_path_chars, + max_write_bytes=self._profile.limits.max_file_bytes, + max_diff_chars=self._profile.limits.max_diff_chars, + ), + ) + tools = self._tool_factory.create(implementation, workspace) + validate_implementation_child_tools(implementation, tools) + provider = self._provider_factory.create(implementation, lease.child_id) + _validate_provider(provider) + return AgentRuntime( + provider, + LedgerRecordingToolExecutor(tools, ledger), + limits=implementation.agent_limits, + ) + + +def _project_agent_result( + profile: SubagentProfile, + child_id: str, + result: AgentResult, +) -> SubagentChildResult: + evidence = extract_subagent_evidence( + result, + max_items=profile.limits.max_evidence_items, + ) + if ( + result.turns > profile.agent_limits.max_turns + or result.tool_calls > profile.agent_limits.max_tool_calls + ): + raise SubagentEvidenceError + status = ( + SubagentStatus.COMPLETED + if result.stop_reason is StopReason.COMPLETED + else SubagentStatus.STOPPED + ) + summary = result.final_text + if summary is not None: + summary = summary[: profile.limits.max_summary_chars] + if "\0" in summary: + raise SubagentEvidenceError + projection: dict[str, object] = { + "child_id": child_id, + "ordinal": 0, + "profile_id": profile.profile_id, + "status": status.value, + "stop_reason": result.stop_reason.value, + "turns": result.turns, + "tool_calls": result.tool_calls, + "usage": result.usage.model_dump(mode="json"), + "untrusted_summary": summary, + "evidence": [item.model_dump(mode="json") for item in evidence], + "error_code": None, + "error_message": None, + } + return SubagentChildResult.model_validate( + projection | {"result_sha256": _canonical_sha256(projection)} + ) + + +def _error_child( + profile: SubagentProfile, + child_id: str, + *, + status: SubagentStatus, + code: SubagentErrorCode, + message: str, +) -> SubagentChildResult: + projection: dict[str, object] = { + "child_id": child_id, + "ordinal": 0, + "profile_id": profile.profile_id, + "status": status.value, + "stop_reason": None, + "turns": 0, + "tool_calls": 0, + "usage": TokenUsage().model_dump(mode="json"), + "untrusted_summary": None, + "evidence": [], + "error_code": code.value, + "error_message": message, + } + return SubagentChildResult.model_validate( + projection | {"result_sha256": _canonical_sha256(projection)} + ) + + +def _validate_provider(provider: object) -> None: + capabilities = getattr(provider, "capabilities", None) + if ( + not isinstance(capabilities, ProviderCapabilities) + or not callable(getattr(provider, "complete", None)) + or not callable(getattr(provider, "stream", None)) + ): + raise SubagentCompositionError + + +def _runtime_id(child_id: str) -> str: + digest = hashlib.sha256(child_id.encode("utf-8")).hexdigest()[:32] + return f"implementation-{digest}" + + +def _elapsed_ms(started_at: float, completed_at: float) -> int: + return max(0, min(3_700_000, int((completed_at - started_at) * 1000))) + + +def _canonical_sha256(value: object) -> str: + encoded = json.dumps( + value, + ensure_ascii=True, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() diff --git a/src/mini_code_agent/worktrees/tools.py b/src/mini_code_agent/worktrees/tools.py index 670e7fe..f94ffc9 100644 --- a/src/mini_code_agent/worktrees/tools.py +++ b/src/mini_code_agent/worktrees/tools.py @@ -1,9 +1,21 @@ from __future__ import annotations -from mini_code_agent.policy.models import TrustSource +import asyncio +import json +from collections.abc import Iterable +from typing import Protocol + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError + +from mini_code_agent.domain.content import ToolCall, ToolResult +from mini_code_agent.policy.models import ActionPreview, RiskLevel, TrustSource from mini_code_agent.subagents.contracts import SubagentCompositionError from mini_code_agent.subagents.models import SubagentProfile -from mini_code_agent.tools.base import SideEffect, ToolExecutor +from mini_code_agent.tools.base import SideEffect, ToolDefinition, ToolExecutor +from mini_code_agent.worktrees.models import ( + ImplementationRunResult, + WorktreeProfile, +) _REQUIRED_IMPLEMENTATION_TOOLS = ( "read_file", @@ -19,6 +31,121 @@ "edit_file": SideEffect.WRITE, "run_tests": SideEffect.EXECUTE, } +_INVALID_ARGUMENTS = "Implementation delegation arguments were invalid." +_FAILED = "Implementation delegation failed." + + +class _ImplementationRunner(Protocol): + @property + def profile(self) -> WorktreeProfile: ... + + async def run( + self, + *, + parent_tool_call_id: str, + task: str, + ) -> ImplementationRunResult: ... + + +class _ImplementationArguments(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + task: str = Field(min_length=1, max_length=20_000) + reason: str = Field(min_length=1, max_length=500) + + +class DelegateImplementationTool: + def __init__(self, runner: _ImplementationRunner) -> None: + self._runner = runner + self._profile = runner.profile + implementation = self._profile.implementation_profile + self._definition = ToolDefinition( + name=implementation.local_name, + description=implementation.description, + input_schema=_implementation_input_schema(self._profile), + side_effect=SideEffect.EXECUTE, + ) + + @property + def definition(self) -> ToolDefinition: + return self._definition + + async def preview(self, call: ToolCall) -> ActionPreview: + arguments = self._parse(call) + return ActionPreview( + tool_call_id=call.id, + tool_name=self._definition.name, + side_effect=SideEffect.EXECUTE, + risk=RiskLevel.CRITICAL, + summary="Run one bounded implementation child in an isolated Worktree.", + reason=arguments.reason, + resources=(".",), + ) + + async def execute(self, call: ToolCall) -> ToolResult: + try: + arguments = self._parse(call) + except ValueError: + return _tool_error(call.id, "invalid_arguments", _INVALID_ARGUMENTS) + try: + candidate = await self._runner.run( + parent_tool_call_id=call.id, + task=arguments.task, + ) + result = ImplementationRunResult.model_validate(candidate.model_dump(mode="json")) + content = _serialize_implementation_result( + result, + max_result_bytes=(self._profile.implementation_profile.limits.max_result_bytes), + ) + except asyncio.CancelledError: + raise + except SubagentCompositionError: + return _tool_error( + call.id, + "composition_failed", + "Implementation child composition failed.", + ) + except ValueError: + return _tool_error( + call.id, + "result_too_large", + "Implementation result exceeded its bounded contract.", + ) + except Exception: + return _tool_error(call.id, "child_failed", _FAILED) + return ToolResult(tool_call_id=call.id, content=content) + + def _parse(self, call: ToolCall) -> _ImplementationArguments: + if call.name != self._definition.name: + raise ValueError(_INVALID_ARGUMENTS) + try: + arguments = _ImplementationArguments.model_validate( + dict(call.arguments), + strict=True, + ) + except ValidationError: + raise ValueError(_INVALID_ARGUMENTS) from None + if ( + len(arguments.task) > self._profile.implementation_profile.limits.max_task_chars + or "\0" in arguments.task + or "\0" in arguments.reason + ): + raise ValueError(_INVALID_ARGUMENTS) + return arguments + + +def build_worktree_tools( + runners: Iterable[_ImplementationRunner], +) -> tuple[DelegateImplementationTool, ...]: + ordered = tuple(runners) + profiles = tuple(runner.profile.implementation_profile for runner in ordered) + if ( + len({profile.profile_id for profile in profiles}) != len(profiles) + or len({profile.local_name for profile in profiles}) != len(profiles) + or any(profile.mode != "implementation" for profile in profiles) + ): + raise ValueError("Implementation Tool profiles conflict.") + return tuple(DelegateImplementationTool(runner) for runner in ordered) def validate_implementation_child_tools( @@ -52,3 +179,105 @@ def validate_implementation_child_tools( raise except Exception: raise SubagentCompositionError from None + + +def _implementation_input_schema(profile: WorktreeProfile) -> dict[str, JsonValue]: + no_nul = r"^[^\u0000]+$" + return { + "type": "object", + "properties": { + "task": { + "type": "string", + "minLength": 1, + "maxLength": profile.implementation_profile.limits.max_task_chars, + "pattern": no_nul, + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "pattern": no_nul, + }, + }, + "required": ["task", "reason"], + "additionalProperties": False, + } + + +def _serialize_implementation_result( + result: ImplementationRunResult, + *, + max_result_bytes: int, +) -> str: + snapshot = result.finalization.snapshot + manifest = snapshot.manifest + candidate: dict[str, object] | None = None + if manifest is not None: + candidate = { + "after_content_bytes": manifest.after_content_bytes, + "base_sha": manifest.base_sha, + "candidate_id": manifest.candidate_id, + "changed_files": manifest.changed_files, + "disposition": manifest.disposition.value, + "files": [ + { + "after_sha256": item.after_sha256, + "before_sha256": item.before_sha256, + "byte_count": item.byte_count, + "line_count": item.line_count, + "operation": item.operation.value, + "path": item.path, + } + for item in manifest.files + ], + "manifest_sha256": manifest.manifest_sha256, + "rejection_reasons": list(manifest.rejection_reasons), + } + payload = { + "candidate": candidate, + "child": { + "child_id": result.child.child_id, + "error_code": ( + result.child.error_code.value if result.child.error_code is not None else None + ), + "evidence": [item.model_dump(mode="json") for item in result.child.evidence], + "result_sha256": result.child.result_sha256, + "status": result.child.status.value, + "stop_reason": ( + result.child.stop_reason.value if result.child.stop_reason is not None else None + ), + "tool_calls": result.child.tool_calls, + "turns": result.child.turns, + "usage": result.child.usage.model_dump(mode="json"), + }, + "cleanup_status": result.finalization.cleanup.status.value, + "content_type": "governed_worktree_result", + "duration_ms": result.duration_ms, + "lease_id": result.finalization.lease_id, + "profile_id": result.profile_id, + "result_sha256": result.result_sha256, + "snapshot_status": snapshot.status.value, + } + encoded = json.dumps( + payload, + ensure_ascii=True, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + if len(encoded) > max_result_bytes: + raise ValueError("Implementation result is too large.") + return encoded.decode("ascii") + + +def _tool_error(call_id: str, code: str, message: str) -> ToolResult: + return ToolResult( + tool_call_id=call_id, + content=json.dumps( + {"error": {"code": code, "message": message}}, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ), + is_error=True, + ) diff --git a/tests/integration/test_governed_worktree_agent.py b/tests/integration/test_governed_worktree_agent.py new file mode 100644 index 0000000..138c641 --- /dev/null +++ b/tests/integration/test_governed_worktree_agent.py @@ -0,0 +1,304 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +from pathlib import Path +from typing import cast + +import pytest + +from mini_code_agent.agent.models import AgentLimits, StopReason +from mini_code_agent.agent.runtime import AgentRuntime +from mini_code_agent.domain.content import ToolCall +from mini_code_agent.domain.messages import Message, MessageRole +from mini_code_agent.policy.approval import StaticApprovalHandler +from mini_code_agent.policy.engine import PolicyEngine +from mini_code_agent.policy.executor import GovernedToolExecutor +from mini_code_agent.policy.models import ( + PolicyDecision, + PolicyRule, + SessionMode, + TrustSource, +) +from mini_code_agent.providers.base import FinishReason, ModelProvider, ModelResponse +from mini_code_agent.providers.fake import ScriptedProvider +from mini_code_agent.subagents.models import SubagentLimits, SubagentProfile +from mini_code_agent.tools.base import SideEffect, ToolExecutor +from mini_code_agent.tools.edit_file import EditFileTool +from mini_code_agent.tools.read_file import ReadFileTool +from mini_code_agent.tools.registry import ToolRegistry +from mini_code_agent.tools.search_text import SearchTextTool +from mini_code_agent.tools.write_file import WriteFileTool +from mini_code_agent.workspace.boundary import WorkspaceBoundary +from mini_code_agent.worktrees.finalization import WorktreeFinalizer +from mini_code_agent.worktrees.git import WorktreeGit +from mini_code_agent.worktrees.manager import WorktreeManager +from mini_code_agent.worktrees.models import WorktreeProfile +from mini_code_agent.worktrees.runner import WorktreeImplementationRunner +from mini_code_agent.worktrees.snapshot import CandidateSnapshotter +from mini_code_agent.worktrees.state import WorktreeStateStore +from mini_code_agent.worktrees.tools import build_worktree_tools + + +def tool_response(call: ToolCall) -> ModelResponse: + return ModelResponse( + message=Message(role=MessageRole.ASSISTANT, content=(call,)), + finish_reason=FinishReason.TOOL_CALL, + ) + + +def stop_response(text: str) -> ModelResponse: + return ModelResponse( + message=Message.assistant_text(text), + finish_reason=FinishReason.STOP, + ) + + +class OneProviderFactory: + def __init__(self, provider: ModelProvider) -> None: + self.provider = provider + self.calls: list[tuple[str, str]] = [] + + def create(self, profile: SubagentProfile, child_id: str) -> ModelProvider: + self.calls.append((profile.profile_id, child_id)) + return self.provider + + +class RealImplementationToolFactory: + def create( + self, + profile: SubagentProfile, + workspace: WorkspaceBoundary, + ) -> ToolExecutor: + executor = GovernedToolExecutor( + ToolRegistry( + ( + ReadFileTool(workspace), + SearchTextTool(workspace), + WriteFileTool(workspace), + EditFileTool(workspace), + ) + ), + policy=PolicyEngine( + ( + PolicyRule( + id="allow-subagent-write", + decision=PolicyDecision.ALLOW, + rationale="The isolated implementation profile permits CAS writes.", + side_effect=SideEffect.WRITE, + trust_source=TrustSource.SUBAGENT, + ), + ) + ), + approval=StaticApprovalHandler(approved=False), + session_mode=SessionMode.NON_INTERACTIVE, + trust_source=TrustSource.SUBAGENT, + ) + assert tuple(item.name for item in executor.definitions) == profile.tool_names + return executor + + +@pytest.mark.asyncio +async def test_parent_delegates_real_child_and_receives_ready_candidate( + tmp_path: Path, +) -> None: + discovered_git = shutil.which("git") + if discovered_git is None: + pytest.skip("Git is unavailable.") + repository = tmp_path / "repository" + state = tmp_path / "state" + repository.mkdir() + state.mkdir() + if os.name != "nt": + state.chmod(0o700) + _git(repository, "init") + _git(repository, "config", "user.email", "agent@example.invalid") + _git(repository, "config", "user.name", "Agent Test") + (repository / "src").mkdir() + parent_content = b"VALUE = 'base'\n" + (repository / "src" / "app.py").write_bytes(parent_content) + _git(repository, "add", "--", "src/app.py") + _git(repository, "commit", "-m", "initial") + profile = _profile( + repository, + state, + Path(discovered_git).resolve(strict=True), + ) + before_sha256 = hashlib.sha256(parent_content).hexdigest() + child_provider = ScriptedProvider( + ( + tool_response( + ToolCall( + id="edit-1", + name="edit_file", + arguments={ + "path": "src/app.py", + "old_text": "'base'", + "new_text": "'changed'", + "expected_sha256": before_sha256, + "reason": "Implement the requested value change.", + }, + ) + ), + tool_response( + ToolCall( + id="write-1", + name="write_file", + arguments={ + "path": "src/new.py", + "content": "NEW = True\n", + "reason": "Add the requested module.", + }, + ) + ), + stop_response("CHILD_SECRET_SUMMARY"), + ) + ) + provider_factory = OneProviderFactory(child_provider) + store = WorktreeStateStore(profile) + git = WorktreeGit(profile) + manager = WorktreeManager( + profile, + git=git, + store=store, + id_factory=lambda: "lease-real", + ) + runner = WorktreeImplementationRunner( + profile, + manager=manager, + finalizer=WorktreeFinalizer( + snapshotter=CandidateSnapshotter( + profile, + store=store, + blob_reader=git, + ), + cleaner=manager, + ), + provider_factory=provider_factory, + tool_factory=RealImplementationToolFactory(), + id_factory=iter(("child-real", "candidate-real")).__next__, + ) + parent_provider = ScriptedProvider( + ( + tool_response( + ToolCall( + id="delegate-1", + name="delegate_implementation", + arguments={ + "task": "Change VALUE and add src/new.py.", + "reason": "Implement the bounded change in isolation.", + }, + ) + ), + stop_response("Parent received the candidate."), + ) + ) + parent_tools = GovernedToolExecutor( + ToolRegistry(build_worktree_tools((runner,))), + policy=PolicyEngine( + ( + PolicyRule( + id="allow-implementation-delegation", + decision=PolicyDecision.ALLOW, + rationale="The host explicitly enables isolated implementation.", + tool_glob="delegate_implementation", + side_effect=SideEffect.EXECUTE, + trust_source=TrustSource.MODEL, + ), + ) + ), + approval=StaticApprovalHandler(approved=False), + session_mode=SessionMode.NON_INTERACTIVE, + trust_source=TrustSource.MODEL, + ) + + result = await AgentRuntime( + parent_provider, + parent_tools, + limits=AgentLimits(max_turns=4, max_tool_calls=2), + ).run(user_prompt="Delegate the implementation.") + + assert result.stop_reason is StopReason.COMPLETED + assert provider_factory.calls == [("implementation", "child-real")] + payload = _delegated_payload(parent_provider) + assert payload["content_type"] == "governed_worktree_result" + assert payload["snapshot_status"] == "ready" + assert payload["cleanup_status"] == "removed" + candidate = cast(dict[str, object], payload["candidate"]) + assert candidate["candidate_id"] == "candidate-real" + assert candidate["changed_files"] == 2 + serialized = json.dumps(payload, sort_keys=True) + assert "Change VALUE" not in serialized + assert "CHILD_SECRET_SUMMARY" not in serialized + assert "NEW = True" not in serialized + assert (repository / "src" / "app.py").read_bytes() == parent_content + assert not (repository / "src" / "new.py").exists() + assert _git_output(repository, "status", "--porcelain") == b"" + assert not (state / "leases" / "lease-real").exists() + assert (state / "candidates" / "ready" / "candidate-real" / "manifest.json").is_file() + + +def _profile( + repository: Path, + state: Path, + git_executable: Path, +) -> WorktreeProfile: + return WorktreeProfile( + repository_root=repository, + state_root=state, + git_executable=git_executable, + allowed_path_prefixes=("src", "tests"), + implementation_profile=SubagentProfile( + profile_id="implementation", + local_name="delegate_implementation", + description="Implement one bounded task in an isolated Worktree.", + system_prompt="Use only the lease Tools and implement the assigned task.", + tool_names=("read_file", "search_text", "write_file", "edit_file"), + mode="implementation", + agent_limits=AgentLimits( + max_turns=6, + max_tool_calls=8, + provider_timeout_seconds=2, + tool_timeout_seconds=2, + ), + limits=SubagentLimits( + max_tasks=1, + max_concurrency=1, + max_task_chars=1_000, + child_timeout_seconds=5, + batch_timeout_seconds=5, + max_summary_chars=1_000, + max_evidence_items=8, + max_result_bytes=128_000, + ), + ), + ) + + +def _delegated_payload(provider: ScriptedProvider) -> dict[str, object]: + message = provider.requests[1].messages[-1] + result = message.tool_results[0] + return cast(dict[str, object], json.loads(result.content)) + + +def _git(repository: Path, *arguments: str) -> None: + subprocess.run( + ("git", "-C", str(repository), *arguments), + check=True, + stdin=subprocess.DEVNULL, + capture_output=True, + shell=False, + ) + + +def _git_output(repository: Path, *arguments: str) -> bytes: + return subprocess.run( + ("git", "-C", str(repository), *arguments), + check=True, + stdin=subprocess.DEVNULL, + capture_output=True, + shell=False, + ).stdout diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 5d75148..d1ad054 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -24,6 +24,13 @@ ) from mini_code_agent.testing import PytestRunner from mini_code_agent.tools import RunTestsTool +from mini_code_agent.worktrees import ( + CandidateSnapshotter, + DelegateImplementationTool, + WorktreeImplementationRunner, + WorktreeManager, + build_worktree_tools, +) def verify_installed_package() -> None: @@ -43,6 +50,11 @@ def verify_installed_package() -> None: assert len(schema_sha256({"type": "object"})) == 64 assert PytestRunner.__name__ == "PytestRunner" assert RunTestsTool.__name__ == "RunTestsTool" + assert CandidateSnapshotter.__name__ == "CandidateSnapshotter" + assert DelegateImplementationTool.__name__ == "DelegateImplementationTool" + assert WorktreeImplementationRunner.__name__ == "WorktreeImplementationRunner" + assert WorktreeManager.__name__ == "WorktreeManager" + assert build_worktree_tools.__name__ == "build_worktree_tools" executable = shutil.which("mini-code-agent") assert executable is not None result = subprocess.run( diff --git a/tests/unit/worktrees/test_runner.py b/tests/unit/worktrees/test_runner.py new file mode 100644 index 0000000..796daa2 --- /dev/null +++ b/tests/unit/worktrees/test_runner.py @@ -0,0 +1,390 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import ClassVar + +import pytest + +from mini_code_agent.domain.content import ToolCall, ToolResult +from mini_code_agent.domain.messages import Message, MessageRole +from mini_code_agent.policy.approval import StaticApprovalHandler +from mini_code_agent.policy.engine import PolicyEngine +from mini_code_agent.policy.executor import GovernedToolExecutor +from mini_code_agent.policy.models import ( + ActionPreview, + PolicyDecision, + PolicyRule, + RiskLevel, + SessionMode, + TrustSource, +) +from mini_code_agent.providers.base import FinishReason, ModelProvider, ModelResponse +from mini_code_agent.providers.fake import ScriptedProvider +from mini_code_agent.subagents.contracts import SubagentCompositionError +from mini_code_agent.subagents.models import SubagentLimits, SubagentProfile, SubagentStatus +from mini_code_agent.tools.base import SideEffect, ToolDefinition, ToolExecutor +from mini_code_agent.tools.edit_file import EditFileTool +from mini_code_agent.tools.read_file import ReadFileTool +from mini_code_agent.tools.registry import ToolRegistry +from mini_code_agent.tools.search_text import SearchTextTool +from mini_code_agent.tools.write_file import WriteFileTool +from mini_code_agent.workspace.boundary import WorkspaceBoundary +from mini_code_agent.worktrees.finalization import WorktreeFinalizer +from mini_code_agent.worktrees.manager import WorktreeManager +from mini_code_agent.worktrees.models import SnapshotStatus, WorktreeProfile +from mini_code_agent.worktrees.runner import WorktreeImplementationRunner +from mini_code_agent.worktrees.snapshot import CandidateSnapshotter +from mini_code_agent.worktrees.state import WorktreeStateStore +from mini_code_agent.worktrees.tools import DelegateImplementationTool + +from .helpers import worktree_profile +from .test_manager_leases import FakeGit + + +def stop_provider(*, delay_seconds: float = 0) -> ScriptedProvider: + return ScriptedProvider( + ( + ModelResponse( + message=Message.assistant_text("UNTRUSTED_CHILD_SUMMARY"), + finish_reason=FinishReason.STOP, + ), + ), + delay_seconds=delay_seconds, + ) + + +def tool_response(call: ToolCall) -> ModelResponse: + return ModelResponse( + message=Message(role=MessageRole.ASSISTANT, content=(call,)), + finish_reason=FinishReason.TOOL_CALL, + ) + + +class ProviderFactory: + def __init__(self, provider: ModelProvider) -> None: + self.provider = provider + self.calls: list[tuple[str, str]] = [] + + def create(self, profile: SubagentProfile, child_id: str) -> ModelProvider: + self.calls.append((profile.profile_id, child_id)) + return self.provider + + +class ToolFactory: + def create( + self, + profile: SubagentProfile, + workspace: WorkspaceBoundary, + ) -> ToolExecutor: + del profile + return GovernedToolExecutor( + ToolRegistry( + ( + ReadFileTool(workspace), + SearchTextTool(workspace), + WriteFileTool(workspace), + EditFileTool(workspace), + ) + ), + policy=PolicyEngine( + ( + PolicyRule( + id="allow-isolated-write", + decision=PolicyDecision.ALLOW, + rationale="Allow CAS writes in the isolated lease.", + side_effect=SideEffect.WRITE, + trust_source=TrustSource.SUBAGENT, + ), + ) + ), + approval=StaticApprovalHandler(approved=False), + session_mode=SessionMode.NON_INTERACTIVE, + trust_source=TrustSource.SUBAGENT, + ) + + +class FailingToolFactory: + def create( + self, + profile: SubagentProfile, + workspace: WorkspaceBoundary, + ) -> ToolExecutor: + del profile, workspace + raise RuntimeError("secret factory failure") + + +class MutatingTestTool: + _definition: ClassVar[ToolDefinition] = ToolDefinition( + name="run_tests", + description="Run a fixed test profile.", + input_schema={ + "type": "object", + "properties": {}, + "additionalProperties": False, + }, + side_effect=SideEffect.EXECUTE, + ) + + def __init__(self, workspace: WorkspaceBoundary) -> None: + self._workspace = workspace + + @property + def definition(self) -> ToolDefinition: + return self._definition + + async def preview(self, call: ToolCall) -> ActionPreview: + return ActionPreview( + tool_call_id=call.id, + tool_name=call.name, + side_effect=SideEffect.EXECUTE, + risk=RiskLevel.CRITICAL, + summary="Run the fixed test profile.", + ) + + async def execute(self, call: ToolCall) -> ToolResult: + (self._workspace.root / "src" / "test-output.py").write_text( + "OUT_OF_BAND = True\n", + encoding="utf-8", + ) + return ToolResult(tool_call_id=call.id, content='{"passed":true}') + + +class MutatingTestToolFactory: + def create( + self, + profile: SubagentProfile, + workspace: WorkspaceBoundary, + ) -> ToolExecutor: + del profile + return GovernedToolExecutor( + ToolRegistry( + ( + ReadFileTool(workspace), + SearchTextTool(workspace), + WriteFileTool(workspace), + EditFileTool(workspace), + MutatingTestTool(workspace), + ) + ), + policy=PolicyEngine( + ( + PolicyRule( + id="allow-fixed-tests", + decision=PolicyDecision.ALLOW, + rationale="Allow the fixed isolated test profile.", + side_effect=SideEffect.EXECUTE, + trust_source=TrustSource.SUBAGENT, + ), + ) + ), + approval=StaticApprovalHandler(approved=False), + session_mode=SessionMode.NON_INTERACTIVE, + trust_source=TrustSource.SUBAGENT, + ) + + +def runner_for( + tmp_path: Path, + *, + provider: ModelProvider, + profile: WorktreeProfile | None = None, + tool_factory: object | None = None, + ids: tuple[str, str] = ("child-1", "candidate-1"), +): + active_profile = profile or worktree_profile(tmp_path) + store = WorktreeStateStore(active_profile) + git = FakeGit(active_profile.repository_root) + manager = WorktreeManager( + active_profile, + git=git, + store=store, + id_factory=lambda: "lease-1", + ) + factory = ProviderFactory(provider) + iterator = iter(ids) + runner = WorktreeImplementationRunner( + active_profile, + manager=manager, + finalizer=WorktreeFinalizer( + snapshotter=CandidateSnapshotter( + active_profile, + store=store, + blob_reader=git, + ), + cleaner=manager, + ), + provider_factory=factory, + tool_factory=tool_factory or ToolFactory(), # type: ignore[arg-type] + id_factory=iterator.__next__, + ) + return active_profile, git, factory, runner + + +@pytest.mark.asyncio +async def test_runner_completes_no_change_child_and_removes_lease(tmp_path: Path) -> None: + profile, _, provider_factory, runner = runner_for( + tmp_path, + provider=stop_provider(), + ) + + result = await runner.run( + parent_tool_call_id="delegate-1", + task="Inspect and make no unnecessary changes.", + ) + + assert result.child.status is SubagentStatus.COMPLETED + assert result.finalization.snapshot.status is SnapshotStatus.NO_CHANGES + assert provider_factory.calls == [("implementation", "child-1")] + assert not (profile.state_root / "leases" / "lease-1").exists() + + +@pytest.mark.asyncio +async def test_runner_snapshots_timeout_then_removes_clean_lease(tmp_path: Path) -> None: + profile = worktree_profile(tmp_path) + implementation = profile.implementation_profile + limits = SubagentLimits.model_validate( + implementation.limits.model_dump() + | { + "child_timeout_seconds": 0.01, + "batch_timeout_seconds": 0.02, + } + ) + timed_profile = WorktreeProfile.model_validate( + profile.model_dump() + | {"implementation_profile": implementation.model_copy(update={"limits": limits})} + ) + _, _, _, runner = runner_for( + tmp_path, + profile=timed_profile, + provider=stop_provider(delay_seconds=1), + ) + + result = await runner.run( + parent_tool_call_id="delegate-1", + task="Time out safely.", + ) + + assert result.child.status is SubagentStatus.TIMED_OUT + assert result.finalization.snapshot.status is SnapshotStatus.NO_CHANGES + assert not (timed_profile.state_root / "leases" / "lease-1").exists() + + +@pytest.mark.asyncio +async def test_runner_cleans_lease_after_composition_failure(tmp_path: Path) -> None: + profile, _, provider_factory, runner = runner_for( + tmp_path, + provider=stop_provider(), + tool_factory=FailingToolFactory(), + ) + + with pytest.raises(SubagentCompositionError): + await runner.run( + parent_tool_call_id="delegate-1", + task="Fail composition safely.", + ) + + assert provider_factory.calls == [] + assert not (profile.state_root / "leases" / "lease-1").exists() + + +@pytest.mark.asyncio +async def test_runner_rejects_duplicate_ids_before_lease_or_provider( + tmp_path: Path, +) -> None: + profile, _, provider_factory, runner = runner_for( + tmp_path, + provider=stop_provider(), + ids=("duplicate", "duplicate"), + ) + + with pytest.raises(SubagentCompositionError): + await runner.run( + parent_tool_call_id="delegate-1", + task="Reject duplicate IDs.", + ) + + assert provider_factory.calls == [] + assert not (profile.state_root / "leases").exists() + + +@pytest.mark.asyncio +async def test_runner_persists_test_created_out_of_band_change_as_rejected( + tmp_path: Path, +) -> None: + profile = worktree_profile(tmp_path) + implementation = profile.implementation_profile.model_copy( + update={ + "tool_names": ( + "read_file", + "search_text", + "write_file", + "edit_file", + "run_tests", + ) + } + ) + test_profile = WorktreeProfile.model_validate( + profile.model_dump() | {"implementation_profile": implementation} + ) + provider = ScriptedProvider( + ( + tool_response(ToolCall(id="tests-1", name="run_tests", arguments={})), + ModelResponse( + message=Message.assistant_text("Tests completed."), + finish_reason=FinishReason.STOP, + ), + ) + ) + _, _, _, runner = runner_for( + tmp_path, + profile=test_profile, + provider=provider, + tool_factory=MutatingTestToolFactory(), + ) + + result = await runner.run( + parent_tool_call_id="delegate-1", + task="Run the fixed tests.", + ) + + assert result.finalization.snapshot.status is SnapshotStatus.REJECTED + manifest = result.finalization.snapshot.manifest + assert manifest is not None + assert "ledger_mismatch" in manifest.rejection_reasons + assert ( + test_profile.state_root / "candidates" / "rejected" / "candidate-1" / "manifest.json" + ).is_file() + assert not (test_profile.state_root / "leases" / "lease-1").exists() + + +@pytest.mark.asyncio +async def test_delegate_tool_strict_arguments_and_bounded_projection( + tmp_path: Path, +) -> None: + _, _, _, runner = runner_for(tmp_path, provider=stop_provider()) + tool = DelegateImplementationTool(runner) + invalid = await tool.execute( + ToolCall( + id="delegate-invalid", + name="delegate_implementation", + arguments={"task": "Missing reason."}, + ) + ) + preview_call = ToolCall( + id="delegate-1", + name="delegate_implementation", + arguments={"task": "Make no changes.", "reason": "Bounded test."}, + ) + + preview = await tool.preview(preview_call) + result = await tool.execute(preview_call) + payload = json.loads(result.content) + + assert invalid.is_error is True + assert preview.side_effect is SideEffect.EXECUTE + assert result.is_error is False + assert payload["content_type"] == "governed_worktree_result" + assert payload["snapshot_status"] == "no_changes" + assert "UNTRUSTED_CHILD_SUMMARY" not in result.content + assert "Make no changes" not in result.content From df76e420298e1f6779f93744c8d80afc3083c107 Mon Sep 17 00:00:00 2001 From: JayLin Date: Thu, 2 Jul 2026 10:08:34 +0800 Subject: [PATCH 09/12] feat: adopt and discard verified candidates --- .../2026-07-02-m6b-worktree-candidates.md | 18 +- src/mini_code_agent/worktrees/__init__.py | 16 + src/mini_code_agent/worktrees/adoption.py | 696 ++++++++++++++++++ src/mini_code_agent/worktrees/git.py | 51 ++ src/mini_code_agent/worktrees/models.py | 32 + src/mini_code_agent/worktrees/state.py | 83 ++- tests/integration/test_candidate_adoption.py | 421 +++++++++++ tests/smoke_test.py | 2 + tests/unit/worktrees/test_git.py | 23 + 9 files changed, 1332 insertions(+), 10 deletions(-) create mode 100644 src/mini_code_agent/worktrees/adoption.py create mode 100644 tests/integration/test_candidate_adoption.py diff --git a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md index 0d0ac47..9efc76a 100644 --- a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md +++ b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md @@ -164,15 +164,15 @@ hooks-empty/ - Test: `tests/unit/worktrees/test_discard.py` - Test: `tests/integration/test_candidate_adoption.py` -- [ ] Define separate high-risk WRITE Tools `adopt_subagent_candidate` and `discard_subagent_candidate`. -- [ ] Preview adoption by verifying manifest/blob hashes and returning bounded repo/base/path/byte/diff resources without parent mutation. -- [ ] On execute, atomically claim `ready -> applying`, require exact clean repo/HEAD base, preflight every path, stage same-directory temporary files, and revalidate all paths immediately before the first replacement. -- [ ] Apply in canonical order, verify the exact final set/hashes, and move `applying -> applied`; leave changes unstaged and uncommitted. -- [ ] On preflight conflict, perform zero writes and return to `ready`. -- [ ] On partial I/O failure, roll back in reverse order and persist `rolled_back` evidence or `uncertain` when rollback cannot be proven. -- [ ] Recover interrupted `applying` candidates: all-before to `ready`, all-after to `applied`, mixed to `uncertain`. -- [ ] Permit discard only for a verified `ready` candidate through an atomic claim; reject applied/applying/uncertain candidates. -- [ ] Commit: `feat: adopt and discard verified candidates` +- [x] Define separate high-risk WRITE Tools `adopt_subagent_candidate` and `discard_subagent_candidate`. +- [x] Preview adoption by verifying manifest/blob hashes and returning bounded repo/base/path/byte/diff resources without parent mutation. +- [x] On execute, atomically claim `ready -> applying`, require exact clean repo/HEAD base, preflight every path, stage same-directory temporary files, and revalidate all paths immediately before the first replacement. +- [x] Apply in canonical order, verify the exact final set/hashes, and move `applying -> applied`; leave changes unstaged and uncommitted. +- [x] On preflight conflict, perform zero writes and return to `ready`. +- [x] On partial I/O failure, roll back in reverse order and persist `rolled_back` evidence or `uncertain` when rollback cannot be proven. +- [x] Recover interrupted `applying` candidates: all-before to `ready`, all-after to `applied`, mixed to `uncertain`. +- [x] Permit discard only for a verified `ready` candidate through an atomic claim; reject applied/applying/uncertain candidates. +- [x] Commit: `feat: adopt and discard verified candidates` ## Task 9: Run Adversarial and Cross-Version Quality Gates diff --git a/src/mini_code_agent/worktrees/__init__.py b/src/mini_code_agent/worktrees/__init__.py index be9b663..75d91ae 100644 --- a/src/mini_code_agent/worktrees/__init__.py +++ b/src/mini_code_agent/worktrees/__init__.py @@ -1,10 +1,17 @@ """Governed worktree leases and independently verified candidates.""" +from mini_code_agent.worktrees.adoption import ( + AdoptSubagentCandidateTool, + CandidateAdoptionService, + DiscardSubagentCandidateTool, +) from mini_code_agent.worktrees.finalization import WorktreeFinalizer from mini_code_agent.worktrees.git import WorktreeGit from mini_code_agent.worktrees.ledger import MutationLedger from mini_code_agent.worktrees.manager import WorktreeManager from mini_code_agent.worktrees.models import ( + AdoptionResult, + AdoptionStatus, BaseManifest, CandidateDisposition, CandidateFile, @@ -13,6 +20,8 @@ CandidateState, CleanupResult, CleanupStatus, + DiscardResult, + DiscardStatus, GitIndexEntry, GitIndexPointer, ImplementationRunResult, @@ -39,7 +48,11 @@ ) __all__ = [ + "AdoptSubagentCandidateTool", + "AdoptionResult", + "AdoptionStatus", "BaseManifest", + "CandidateAdoptionService", "CandidateDisposition", "CandidateFile", "CandidateManifest", @@ -49,6 +62,9 @@ "CleanupResult", "CleanupStatus", "DelegateImplementationTool", + "DiscardResult", + "DiscardStatus", + "DiscardSubagentCandidateTool", "GitIndexEntry", "GitIndexPointer", "ImplementationRunResult", diff --git a/src/mini_code_agent/worktrees/adoption.py b/src/mini_code_agent/worktrees/adoption.py new file mode 100644 index 0000000..5862db2 --- /dev/null +++ b/src/mini_code_agent/worktrees/adoption.py @@ -0,0 +1,696 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import stat +import tempfile +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar, Literal, Protocol + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError + +from mini_code_agent.domain.content import ToolCall, ToolResult +from mini_code_agent.policy.models import ActionPreview, RiskLevel +from mini_code_agent.tools.base import SideEffect, ToolDefinition +from mini_code_agent.workspace.boundary import WorkspaceBoundary +from mini_code_agent.workspace.errors import WorkspaceError +from mini_code_agent.workspace.models import WorkspaceLimits +from mini_code_agent.worktrees.models import ( + AdoptionResult, + AdoptionStatus, + CandidateFile, + CandidateManifest, + CandidateState, + DiscardResult, + DiscardStatus, + WorktreeProfile, +) +from mini_code_agent.worktrees.state import ( + VerifiedCandidate, + WorktreeStateError, + WorktreeStateStore, +) + + +class AdoptionGit(Protocol): + async def repository_info(self) -> tuple[Path, bool]: ... + + async def head_sha(self) -> str: ... + + async def status_porcelain(self) -> bytes: ... + + async def changed_paths(self) -> tuple[str, ...]: ... + + +class CandidateAdoptionError(RuntimeError): + pass + + +@dataclass(slots=True) +class _PreparedFile: + candidate: CandidateFile + target: Path + after: bytes + before: bytes | None + mode: int + temp_path: Path | None + + +class CandidateAdoptionService: + def __init__( + self, + profile: WorktreeProfile, + *, + store: WorktreeStateStore, + git: AdoptionGit, + ) -> None: + self._profile = profile + self._store = store + self._git = git + self._workspace = WorkspaceBoundary( + profile.repository_root, + limits=WorkspaceLimits( + max_file_bytes=profile.limits.max_file_bytes, + max_path_chars=profile.limits.max_path_chars, + max_write_bytes=profile.limits.max_file_bytes, + max_diff_chars=profile.limits.max_diff_chars, + ), + ) + self._lock = asyncio.Lock() + + async def preview(self, candidate_id: str) -> CandidateManifest: + try: + return await asyncio.to_thread( + self._store.load_candidate, + CandidateState.READY, + candidate_id, + ) + except (WorktreeStateError, ValueError): + raise CandidateAdoptionError("Candidate is not ready.") from None + + async def adopt(self, candidate_id: str) -> AdoptionResult: + async with self._lock: + try: + ready = await asyncio.to_thread( + self._store.load_candidate, + CandidateState.READY, + candidate_id, + ) + await asyncio.to_thread( + self._store.transition_candidate, + candidate_id, + CandidateState.READY, + CandidateState.APPLYING, + ) + payload = await asyncio.to_thread( + self._store.load_candidate_payload, + CandidateState.APPLYING, + candidate_id, + ) + except (WorktreeStateError, ValueError): + raise CandidateAdoptionError("Candidate could not be claimed.") from None + + prepared: tuple[_PreparedFile, ...] = () + applied: list[_PreparedFile] = [] + try: + await self._verify_repository_before(payload.manifest) + prepared = await asyncio.to_thread(self._prepare_all, payload) + await asyncio.to_thread(self._revalidate_all, prepared) + except (_AdoptionConflict, WorkspaceError): + _cleanup_temps(prepared) + if not await self._return_to_ready(candidate_id): + return _adoption_result(ready, AdoptionStatus.APPLY_UNCERTAIN) + return _adoption_result(ready, AdoptionStatus.CONFLICT) + except Exception: + _cleanup_temps(prepared) + if not await self._return_to_ready(candidate_id): + return _adoption_result(ready, AdoptionStatus.APPLY_UNCERTAIN) + return _adoption_result( + ready, + AdoptionStatus.APPLY_FAILED_ROLLED_BACK, + ) + + try: + for item in prepared: + await asyncio.to_thread(_apply_prepared, item) + applied.append(item) + await asyncio.to_thread(_verify_after, prepared) + changed_paths = await self._git.changed_paths() + if changed_paths != tuple(item.candidate.path for item in prepared): + raise OSError("Parent changed-path set is inconsistent.") + await asyncio.to_thread( + self._store.transition_candidate, + candidate_id, + CandidateState.APPLYING, + CandidateState.APPLIED, + ) + except Exception: + _cleanup_temps(prepared) + rolled_back = await asyncio.to_thread(_rollback, tuple(applied)) + if rolled_back and await self._repository_is_clean_at_base(ready): + try: + await asyncio.to_thread( + self._store.transition_candidate, + candidate_id, + CandidateState.APPLYING, + CandidateState.READY, + ) + except WorktreeStateError: + rolled_back = False + if rolled_back: + return _adoption_result( + ready, + AdoptionStatus.APPLY_FAILED_ROLLED_BACK, + ) + await self._mark_uncertain(candidate_id) + return _adoption_result(ready, AdoptionStatus.APPLY_UNCERTAIN) + _cleanup_temps(prepared) + return _adoption_result(ready, AdoptionStatus.APPLIED) + + async def recover(self, candidate_id: str) -> AdoptionResult: + async with self._lock: + try: + payload = await asyncio.to_thread( + self._store.load_candidate_payload, + CandidateState.APPLYING, + candidate_id, + ) + except WorktreeStateError: + raise CandidateAdoptionError("Applying candidate is unavailable.") from None + manifest = payload.manifest + try: + top_level, bare = await self._git.repository_info() + head = await self._git.head_sha() + states = await asyncio.to_thread( + _classify_parent_files, + self._workspace, + payload, + ) + if bare or top_level != self._profile.repository_root or head != manifest.base_sha: + raise _AdoptionConflict + if all(state == "before" for state in states): + if await self._git.status_porcelain(): + raise _AdoptionConflict + await asyncio.to_thread( + self._store.transition_candidate, + candidate_id, + CandidateState.APPLYING, + CandidateState.READY, + ) + return _adoption_result( + manifest, + AdoptionStatus.RECOVERED_READY, + ) + if all(state == "after" for state in states): + if await self._git.changed_paths() != tuple( + item.path for item in manifest.files + ): + raise _AdoptionConflict + await asyncio.to_thread( + self._store.transition_candidate, + candidate_id, + CandidateState.APPLYING, + CandidateState.APPLIED, + ) + return _adoption_result(manifest, AdoptionStatus.APPLIED) + except Exception: + pass + await self._mark_uncertain(candidate_id) + return _adoption_result(manifest, AdoptionStatus.APPLY_UNCERTAIN) + + async def discard(self, candidate_id: str) -> DiscardResult: + async with self._lock: + try: + manifest = await asyncio.to_thread( + self._store.load_candidate, + CandidateState.READY, + candidate_id, + ) + await asyncio.to_thread( + self._store.transition_candidate, + candidate_id, + CandidateState.READY, + CandidateState.DISCARDING, + ) + await asyncio.to_thread( + self._store.delete_candidate, + CandidateState.DISCARDING, + candidate_id, + ) + except (WorktreeStateError, ValueError): + raise CandidateAdoptionError("Ready candidate could not be discarded.") from None + return DiscardResult( + candidate_id=candidate_id, + status=DiscardStatus.DISCARDED, + changed_files=manifest.changed_files, + manifest_sha256=manifest.manifest_sha256, + ) + + async def _verify_repository_before(self, manifest: CandidateManifest) -> None: + top_level, bare = await self._git.repository_info() + if ( + bare + or top_level != self._profile.repository_root + or await self._git.head_sha() != manifest.base_sha + or await self._git.status_porcelain() + ): + raise _AdoptionConflict + + def _prepare_all( + self, + payload: VerifiedCandidate, + ) -> tuple[_PreparedFile, ...]: + prepared: list[_PreparedFile] = [] + try: + for candidate in payload.manifest.files: + content = payload.blobs[candidate.content_blob_sha256] + text = _decode_text(content) + target = self._workspace.root.joinpath(*candidate.path.split("/")) + before: bytes | None = None + mode = 0o644 + if candidate.before_sha256 is not None: + before = _read_regular(target) + if hashlib.sha256(before).hexdigest() != candidate.before_sha256: + raise _AdoptionConflict + mode = stat.S_IMODE(target.stat(follow_symlinks=False).st_mode) + elif target.exists() or _is_link_or_reparse(target): + raise _AdoptionConflict + self._workspace.preview_write( + candidate.path, + text, + expected_sha256=candidate.before_sha256, + ) + temp_path = _stage_content(target.parent, content, mode) + prepared.append( + _PreparedFile( + candidate=candidate, + target=target, + after=content, + before=before, + mode=mode, + temp_path=temp_path, + ) + ) + except Exception: + _cleanup_temps(tuple(prepared)) + raise + return tuple(prepared) + + def _revalidate_all(self, prepared: tuple[_PreparedFile, ...]) -> None: + for item in prepared: + text = _decode_text(item.after) + self._workspace.preview_write( + item.candidate.path, + text, + expected_sha256=item.candidate.before_sha256, + ) + if item.before is None: + if item.target.exists() or _is_link_or_reparse(item.target): + raise _AdoptionConflict + elif hashlib.sha256(_read_regular(item.target)).hexdigest() != ( + item.candidate.before_sha256 + ): + raise _AdoptionConflict + + async def _return_to_ready(self, candidate_id: str) -> bool: + try: + await asyncio.to_thread( + self._store.transition_candidate, + candidate_id, + CandidateState.APPLYING, + CandidateState.READY, + ) + return True + except WorktreeStateError: + await self._mark_uncertain(candidate_id) + return False + + async def _mark_uncertain(self, candidate_id: str) -> None: + with suppress(WorktreeStateError): + await asyncio.to_thread( + self._store.write_candidate_recovery, + CandidateState.APPLYING, + candidate_id, + "apply_uncertain", + ) + with suppress(WorktreeStateError): + await asyncio.to_thread( + self._store.transition_candidate, + candidate_id, + CandidateState.APPLYING, + CandidateState.UNCERTAIN, + ) + + async def _repository_is_clean_at_base( + self, + manifest: CandidateManifest, + ) -> bool: + try: + top_level, bare = await self._git.repository_info() + return bool( + not bare + and top_level == self._profile.repository_root + and await self._git.head_sha() == manifest.base_sha + and not await self._git.status_porcelain() + ) + except Exception: + return False + + +class _CandidateArguments(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + candidate_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$") + reason: str = Field(min_length=1, max_length=500) + + +def _candidate_input_schema() -> dict[str, JsonValue]: + return { + "type": "object", + "properties": { + "candidate_id": { + "type": "string", + "pattern": r"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$", + }, + "reason": {"type": "string", "minLength": 1, "maxLength": 500}, + }, + "required": ["candidate_id", "reason"], + "additionalProperties": False, + } + + +class AdoptSubagentCandidateTool: + _definition: ClassVar[ToolDefinition] = ToolDefinition( + name="adopt_subagent_candidate", + description="Apply one verified ready Subagent candidate to the parent workspace.", + input_schema=_candidate_input_schema(), + side_effect=SideEffect.WRITE, + ) + + def __init__(self, service: CandidateAdoptionService) -> None: + self._service = service + + @property + def definition(self) -> ToolDefinition: + return self._definition + + async def preview(self, call: ToolCall) -> ActionPreview: + arguments = _parse_candidate_arguments(call, self._definition.name) + manifest = await self._service.preview(arguments.candidate_id) + return _candidate_preview(call, arguments.reason, manifest, "Adopt") + + async def execute(self, call: ToolCall) -> ToolResult: + try: + arguments = _parse_candidate_arguments(call, self._definition.name) + result = await self._service.adopt(arguments.candidate_id) + except (ValueError, CandidateAdoptionError): + return _candidate_error(call.id, "candidate_unavailable") + return _candidate_result(call.id, result) + + +class DiscardSubagentCandidateTool: + _definition: ClassVar[ToolDefinition] = ToolDefinition( + name="discard_subagent_candidate", + description="Discard one verified ready Subagent candidate.", + input_schema=_candidate_input_schema(), + side_effect=SideEffect.WRITE, + ) + + def __init__(self, service: CandidateAdoptionService) -> None: + self._service = service + + @property + def definition(self) -> ToolDefinition: + return self._definition + + async def preview(self, call: ToolCall) -> ActionPreview: + arguments = _parse_candidate_arguments(call, self._definition.name) + manifest = await self._service.preview(arguments.candidate_id) + return _candidate_preview(call, arguments.reason, manifest, "Discard") + + async def execute(self, call: ToolCall) -> ToolResult: + try: + arguments = _parse_candidate_arguments(call, self._definition.name) + result = await self._service.discard(arguments.candidate_id) + except (ValueError, CandidateAdoptionError): + return _candidate_error(call.id, "candidate_unavailable") + return _candidate_result(call.id, result) + + +class _AdoptionConflict(RuntimeError): + pass + + +def _apply_prepared(item: _PreparedFile) -> None: + if item.temp_path is None: + raise OSError("Candidate staging file is unavailable.") + if item.before is None: + if item.target.exists() or _is_link_or_reparse(item.target): + raise OSError("Candidate addition target changed before apply.") + os.link(item.temp_path, item.target) + item.temp_path.unlink() + else: + if hashlib.sha256(_read_regular(item.target)).hexdigest() != (item.candidate.before_sha256): + raise OSError("Candidate target changed before apply.") + os.replace(item.temp_path, item.target) + item.temp_path = None + _fsync_directory(item.target.parent) + + +def _verify_after(prepared: tuple[_PreparedFile, ...]) -> None: + for item in prepared: + if hashlib.sha256(_read_regular(item.target)).hexdigest() != (item.candidate.after_sha256): + raise OSError("Adopted file hash is inconsistent.") + + +def _rollback(applied: tuple[_PreparedFile, ...]) -> bool: + try: + for item in reversed(applied): + if hashlib.sha256(_read_regular(item.target)).hexdigest() != ( + item.candidate.after_sha256 + ): + return False + if item.before is None: + item.target.unlink() + _fsync_directory(item.target.parent) + else: + temp: Path | None = None + try: + temp = _stage_content(item.target.parent, item.before, item.mode) + if hashlib.sha256(_read_regular(item.target)).hexdigest() != ( + item.candidate.after_sha256 + ): + return False + os.replace(temp, item.target) + temp = None + _fsync_directory(item.target.parent) + finally: + if temp is not None: + with suppress(OSError): + temp.unlink() + for item in applied: + if item.before is None: + if item.target.exists() or _is_link_or_reparse(item.target): + return False + elif hashlib.sha256(_read_regular(item.target)).hexdigest() != ( + item.candidate.before_sha256 + ): + return False + except OSError: + return False + return True + + +def _classify_parent_files( + workspace: WorkspaceBoundary, + payload: VerifiedCandidate, +) -> tuple[Literal["before", "after", "unknown"], ...]: + states: list[Literal["before", "after", "unknown"]] = [] + for item in payload.manifest.files: + target = workspace.root.joinpath(*item.path.split("/")) + if not target.exists() or _is_link_or_reparse(target): + states.append("before" if item.before_sha256 is None else "unknown") + continue + try: + digest = hashlib.sha256(_read_regular(target)).hexdigest() + except OSError: + states.append("unknown") + continue + if digest == item.after_sha256: + states.append("after") + elif item.before_sha256 is not None and digest == item.before_sha256: + states.append("before") + else: + states.append("unknown") + return tuple(states) + + +def _stage_content(parent: Path, content: bytes, mode: int) -> Path: + descriptor = -1 + path: Path | None = None + try: + descriptor, raw_path = tempfile.mkstemp( + prefix=".mini-code-agent-adopt-", + suffix=".tmp", + dir=parent, + ) + path = Path(raw_path) + with os.fdopen(descriptor, "wb", closefd=True) as stream: + descriptor = -1 + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + path.chmod(mode) + return path + except OSError: + if descriptor >= 0: + os.close(descriptor) + if path is not None: + with suppress(OSError): + path.unlink() + raise + + +def _cleanup_temps(prepared: tuple[_PreparedFile, ...]) -> None: + for item in prepared: + if item.temp_path is not None: + with suppress(OSError): + item.temp_path.unlink() + item.temp_path = None + + +def _read_regular(path: Path) -> bytes: + if _is_link_or_reparse(path): + raise OSError("Parent path is linked.") + with path.open("rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise OSError("Parent path is not a regular file.") + content = stream.read(2 * 1024 * 1024 + 1) + if len(content) > 2 * 1024 * 1024 or _is_link_or_reparse(path): + raise OSError("Parent file exceeds its limit or changed type.") + return content + + +def _decode_text(content: bytes) -> str: + if b"\0" in content: + raise _AdoptionConflict + try: + return content.decode("utf-8") + except UnicodeDecodeError: + raise _AdoptionConflict from None + + +def _is_link_or_reparse(path: Path) -> bool: + try: + metadata = path.lstat() + except FileNotFoundError: + return False + except OSError: + return True + attributes = getattr(metadata, "st_file_attributes", 0) + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + return stat.S_ISLNK(metadata.st_mode) or bool(attributes & reparse_flag) + + +def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def _adoption_result( + manifest: CandidateManifest, + status: AdoptionStatus, +) -> AdoptionResult: + return AdoptionResult( + candidate_id=manifest.candidate_id, + status=status, + changed_files=manifest.changed_files, + manifest_sha256=manifest.manifest_sha256, + ) + + +def _parse_candidate_arguments( + call: ToolCall, + expected_name: str, +) -> _CandidateArguments: + if call.name != expected_name: + raise ValueError("Candidate Tool name is invalid.") + try: + arguments = _CandidateArguments.model_validate( + dict(call.arguments), + strict=True, + ) + except ValidationError: + raise ValueError("Candidate Tool arguments are invalid.") from None + if "\0" in arguments.reason: + raise ValueError("Candidate Tool arguments are invalid.") + return arguments + + +def _candidate_preview( + call: ToolCall, + reason: str, + manifest: CandidateManifest, + verb: str, +) -> ActionPreview: + combined_diff = "\n".join(item.diff for item in manifest.files) + summary = ( + f"{verb} candidate {manifest.candidate_id} at base {manifest.base_sha} " + f"with {manifest.changed_files} file(s) and " + f"{manifest.after_content_bytes} after-content byte(s)." + ) + return ActionPreview( + tool_call_id=call.id, + tool_name=call.name, + side_effect=SideEffect.WRITE, + risk=RiskLevel.HIGH, + summary=summary, + reason=reason, + resources=( + str(manifest.repository_root), + *(item.path for item in manifest.files[:31]), + ), + diff=combined_diff[:32_768], + ) + + +def _candidate_result( + call_id: str, + result: AdoptionResult | DiscardResult, +) -> ToolResult: + return ToolResult( + tool_call_id=call_id, + content=json.dumps( + result.model_dump(mode="json"), + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ), + ) + + +def _candidate_error(call_id: str, code: str) -> ToolResult: + return ToolResult( + tool_call_id=call_id, + content=json.dumps( + { + "error": { + "code": code, + "message": "Candidate operation could not be completed.", + } + }, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ), + is_error=True, + ) diff --git a/src/mini_code_agent/worktrees/git.py b/src/mini_code_agent/worktrees/git.py index ff8c9e9..eac13f9 100644 --- a/src/mini_code_agent/worktrees/git.py +++ b/src/mini_code_agent/worktrees/git.py @@ -311,6 +311,23 @@ async def status_porcelain(self) -> bytes: max_output_bytes=16 * 1024 * 1024, ) + async def changed_paths(self) -> tuple[str, ...]: + output = await self._execute( + ( + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--ignore-submodules=none", + ), + max_output_bytes=16 * 1024 * 1024, + ) + return parse_status_paths( + output, + max_entries=self._profile.limits.max_candidate_files, + max_path_chars=self._profile.limits.max_path_chars, + ) + async def index_pointers(self) -> tuple[GitIndexPointer, ...]: output = await self._execute( ("ls-files", "--stage", "--sparse", "-z"), @@ -584,6 +601,40 @@ def parse_worktree_paths(output: bytes) -> tuple[Path, ...]: return tuple(paths) +def parse_status_paths( + output: bytes, + *, + max_entries: int, + max_path_chars: int, +) -> tuple[str, ...]: + if output and not output.endswith(b"\0"): + raise _invalid_git_output() + paths: list[str] = [] + identities: set[str] = set() + for record in output[:-1].split(b"\0") if output else (): + if ( + len(record) < 4 + or record[2:3] != b" " + or record[:1] in {b"R", b"C"} + or record[1:2] in {b"R", b"C"} + ): + raise _invalid_git_output() + try: + path = record[3:].decode("utf-8") + except UnicodeDecodeError: + raise _invalid_git_output() from None + if not path or "\0" in path or len(path) > max_path_chars: + raise _invalid_git_output() + identity = path.casefold() + if identity in identities: + raise _invalid_git_output() + identities.add(identity) + paths.append(path) + if len(paths) > max_entries: + raise _invalid_git_output() + return tuple(sorted(paths)) + + def _decode_lines(output: bytes) -> list[str]: try: return output.decode("utf-8").splitlines() diff --git a/src/mini_code_agent/worktrees/models.py b/src/mini_code_agent/worktrees/models.py index 3cf5713..47b62f0 100644 --- a/src/mini_code_agent/worktrees/models.py +++ b/src/mini_code_agent/worktrees/models.py @@ -71,6 +71,7 @@ class CandidateState(StrEnum): APPLYING = "applying" APPLIED = "applied" REJECTED = "rejected" + DISCARDING = "discarding" UNCERTAIN = "uncertain" @@ -96,6 +97,19 @@ class CleanupStatus(StrEnum): CLEANUP_REQUIRED = "cleanup_required" +class AdoptionStatus(StrEnum): + APPLIED = "applied" + CONFLICT = "conflict" + APPLY_FAILED_ROLLED_BACK = "apply_failed_rolled_back" + APPLY_UNCERTAIN = "apply_uncertain" + RECOVERED_READY = "recovered_ready" + + +class DiscardStatus(StrEnum): + DISCARDED = "discarded" + CONFLICT = "conflict" + + class WorktreeLimits(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) @@ -517,6 +531,24 @@ class CleanupResult(BaseModel): status: CleanupStatus +class AdoptionResult(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + candidate_id: str = Field(pattern=_IDENTIFIER) + status: AdoptionStatus + changed_files: int = Field(ge=0, le=128) + manifest_sha256: Sha256 + + +class DiscardResult(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + candidate_id: str = Field(pattern=_IDENTIFIER) + status: DiscardStatus + changed_files: int = Field(ge=0, le=128) + manifest_sha256: Sha256 + + class WorktreeFinalizationResult(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) diff --git a/src/mini_code_agent/worktrees/state.py b/src/mini_code_agent/worktrees/state.py index 98112bc..b1b63bd 100644 --- a/src/mini_code_agent/worktrees/state.py +++ b/src/mini_code_agent/worktrees/state.py @@ -34,6 +34,12 @@ class LeasePaths: worktree: Path +@dataclass(frozen=True, slots=True) +class VerifiedCandidate: + manifest: CandidateManifest + blobs: dict[str, bytes] + + class WorktreeStateStore: def __init__(self, profile: WorktreeProfile) -> None: self._profile = profile @@ -214,7 +220,13 @@ def load_candidate( candidate_children = {path.name for path in candidate.iterdir()} except OSError: raise WorktreeStateError("Candidate directory could not be listed.") from None - if candidate_children != {"manifest.json", "blobs"}: + expected_children = {"manifest.json", "blobs"} + allowed_children = ( + {frozenset(expected_children), frozenset({*expected_children, "recovery.json"})} + if state in {CandidateState.APPLYING, CandidateState.UNCERTAIN} + else {frozenset(expected_children)} + ) + if frozenset(candidate_children) not in allowed_children: raise WorktreeStateError("Candidate directory contains unexpected paths.") manifest_path = candidate / "manifest.json" if _is_link_or_reparse(manifest_path): @@ -232,6 +244,7 @@ def load_candidate( CandidateState.REJECTED: CandidateDisposition.REJECTED, CandidateState.APPLYING: CandidateDisposition.READY, CandidateState.APPLIED: CandidateDisposition.READY, + CandidateState.DISCARDING: CandidateDisposition.READY, CandidateState.UNCERTAIN: CandidateDisposition.READY, }.get(state) if ( @@ -277,6 +290,74 @@ def load_candidate( raise WorktreeStateError("Candidate blob hash is invalid.") return manifest + def load_candidate_payload( + self, + state: CandidateState, + candidate_id: str, + ) -> VerifiedCandidate: + manifest = self.load_candidate(state, candidate_id) + blobs_dir = self._candidate_path(state, candidate_id) / "blobs" + blobs: dict[str, bytes] = {} + for digest in sorted({item.content_blob_sha256 for item in manifest.files}): + path = blobs_dir / digest + try: + blobs[digest] = path.read_bytes() + except OSError: + raise WorktreeStateError("Candidate blob could not be read.") from None + return VerifiedCandidate(manifest=manifest, blobs=blobs) + + def delete_candidate( + self, + state: CandidateState, + candidate_id: str, + ) -> None: + if state is not CandidateState.DISCARDING: + raise WorktreeStateError("Only a claimed discard candidate can be deleted.") + payload = self.load_candidate_payload(state, candidate_id) + candidate = self._candidate_path(state, candidate_id) + blobs = candidate / "blobs" + for digest in sorted(payload.blobs): + path = blobs / digest + if _is_link_or_reparse(path): + raise WorktreeStateError("Candidate blob is unsafe.") + try: + path.unlink() + except OSError: + raise WorktreeStateError("Candidate blob could not be removed.") from None + try: + blobs.rmdir() + (candidate / "manifest.json").unlink() + candidate.rmdir() + except OSError: + raise WorktreeStateError("Candidate could not be removed.") from None + + def write_candidate_recovery( + self, + state: CandidateState, + candidate_id: str, + status: str, + ) -> None: + if state is not CandidateState.APPLYING or status not in {"apply_uncertain"}: + raise WorktreeStateError("Candidate recovery evidence is invalid.") + self.load_candidate(state, candidate_id) + candidate = self._candidate_path(state, candidate_id) + target = candidate / "recovery.json" + if target.exists(): + return + encoded = ( + json.dumps( + { + "candidate_id": candidate_id, + "status": status, + }, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ) + + "\n" + ).encode("ascii") + self._publish_immutable(target, encoded) + def complete_lease(self, lease_id: str) -> None: self._validate_identifier(lease_id) leases = self._root / "leases" diff --git a/tests/integration/test_candidate_adoption.py b/tests/integration/test_candidate_adoption.py new file mode 100644 index 0000000..801faa4 --- /dev/null +++ b/tests/integration/test_candidate_adoption.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from mini_code_agent.agent.models import AgentLimits +from mini_code_agent.domain.content import ToolCall, ToolResult +from mini_code_agent.policy.approval import StaticApprovalHandler +from mini_code_agent.policy.engine import PolicyEngine +from mini_code_agent.policy.executor import GovernedToolExecutor +from mini_code_agent.policy.models import SessionMode, TrustSource +from mini_code_agent.subagents.models import SubagentProfile, SubagentStatus +from mini_code_agent.tools.registry import ToolRegistry +from mini_code_agent.workspace.models import MutationResult +from mini_code_agent.worktrees.adoption import ( + AdoptSubagentCandidateTool, + CandidateAdoptionError, + CandidateAdoptionService, + DiscardSubagentCandidateTool, +) +from mini_code_agent.worktrees.finalization import WorktreeFinalizer +from mini_code_agent.worktrees.git import WorktreeGit +from mini_code_agent.worktrees.ledger import MutationLedger +from mini_code_agent.worktrees.manager import WorktreeManager +from mini_code_agent.worktrees.models import ( + AdoptionStatus, + CandidateState, + CleanupStatus, + DiscardStatus, + SnapshotStatus, + WorktreeProfile, +) +from mini_code_agent.worktrees.snapshot import CandidateSnapshotter +from mini_code_agent.worktrees.state import WorktreeStateStore + + +@dataclass(frozen=True, slots=True) +class AdoptionFixture: + profile: WorktreeProfile + store: WorktreeStateStore + git: WorktreeGit + service: CandidateAdoptionService + base: dict[str, bytes] + after: dict[str, bytes] + + +async def ready_candidate(tmp_path: Path) -> AdoptionFixture: + discovered_git = shutil.which("git") + if discovered_git is None: + pytest.skip("Git is unavailable.") + repository = tmp_path / "repository" + state = tmp_path / "state" + tmp_path.mkdir(parents=True, exist_ok=True) + repository.mkdir() + state.mkdir() + if os.name != "nt": + state.chmod(0o700) + _git(repository, "init") + _git(repository, "config", "user.email", "agent@example.invalid") + _git(repository, "config", "user.name", "Agent Test") + (repository / "src").mkdir() + base = { + "src/a.py": b"A = 1\n", + "src/b.py": b"B = 1\n", + } + after = { + "src/a.py": b"A = 2\n", + "src/b.py": b"B = 2\n", + "src/new.py": b"NEW = True\n", + } + for path, content in base.items(): + repository.joinpath(*path.split("/")).write_bytes(content) + _git(repository, "add", "--", "src/a.py", "src/b.py") + _git(repository, "commit", "-m", "initial") + profile = _profile( + repository, + state, + Path(discovered_git).resolve(strict=True), + ) + store = WorktreeStateStore(profile) + git = WorktreeGit(profile) + manager = WorktreeManager( + profile, + git=git, + store=store, + id_factory=lambda: "lease-adoption", + ) + lease = await manager.create_lease(child_id="child-adoption") + ledger = MutationLedger(max_entries=8) + for ordinal, path in enumerate(sorted(after)): + target = lease.worktree_path.joinpath(*path.split("/")) + target.write_bytes(after[path]) + before = base.get(path) + mutation = MutationResult( + path=path, + created=before is None, + before_sha256=(hashlib.sha256(before).hexdigest() if before is not None else None), + after_sha256=hashlib.sha256(after[path]).hexdigest(), + byte_count=len(after[path]), + line_count=1, + diff="bounded", + ) + call = ToolCall(id=f"write-{ordinal}", name="write_file", arguments={}) + ledger.record( + call, + ToolResult( + tool_call_id=call.id, + content=json.dumps(mutation.model_dump(mode="json")), + ), + ) + finalization = await WorktreeFinalizer( + snapshotter=CandidateSnapshotter( + profile, + store=store, + blob_reader=git, + ), + cleaner=manager, + ).finalize( + lease, + ledger, + candidate_id="candidate-adoption", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + ) + assert finalization.snapshot.status is SnapshotStatus.READY + assert finalization.cleanup.status is CleanupStatus.REMOVED + return AdoptionFixture( + profile=profile, + store=store, + git=git, + service=CandidateAdoptionService(profile, store=store, git=git), + base=base, + after=after, + ) + + +@pytest.mark.asyncio +async def test_adoption_preview_is_read_only_and_execute_applies_exact_candidate( + tmp_path: Path, +) -> None: + fixture = await ready_candidate(tmp_path) + + tool = AdoptSubagentCandidateTool(fixture.service) + call = ToolCall( + id="adopt-1", + name="adopt_subagent_candidate", + arguments={ + "candidate_id": "candidate-adoption", + "reason": "Apply the verified implementation.", + }, + ) + preview = await tool.preview(call) + manifest = await fixture.service.preview("candidate-adoption") + assert await fixture.git.status_porcelain() == b"" + tool_result = await tool.execute(call) + result_payload = json.loads(tool_result.content) + + assert preview.risk.value == "high" + assert result_payload["status"] == AdoptionStatus.APPLIED.value + for path, content in fixture.after.items(): + assert fixture.profile.repository_root.joinpath(*path.split("/")).read_bytes() == content + assert await fixture.git.changed_paths() == ( + "src/a.py", + "src/b.py", + "src/new.py", + ) + applied = fixture.store.load_candidate( + CandidateState.APPLIED, + "candidate-adoption", + ) + assert applied.manifest_sha256 == manifest.manifest_sha256 + with pytest.raises(CandidateAdoptionError): + await fixture.service.discard("candidate-adoption") + + +@pytest.mark.asyncio +async def test_adoption_conflict_performs_zero_candidate_writes_and_returns_ready( + tmp_path: Path, +) -> None: + fixture = await ready_candidate(tmp_path) + user_content = b"A = 99\n" + (fixture.profile.repository_root / "src" / "a.py").write_bytes(user_content) + + result = await fixture.service.adopt("candidate-adoption") + + assert result.status is AdoptionStatus.CONFLICT + assert (fixture.profile.repository_root / "src" / "a.py").read_bytes() == user_content + assert (fixture.profile.repository_root / "src" / "b.py").read_bytes() == fixture.base[ + "src/b.py" + ] + fixture.store.load_candidate(CandidateState.READY, "candidate-adoption") + + +@pytest.mark.asyncio +async def test_partial_apply_failure_rolls_back_in_reverse_order( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + fixture = await ready_candidate(tmp_path) + original_replace = os.replace + calls = 0 + + def fail_second_replace(source: Path, target: Path) -> None: + nonlocal calls + calls += 1 + if calls == 2: + raise OSError("simulated second-file failure") + original_replace(source, target) + + monkeypatch.setattr("mini_code_agent.worktrees.adoption.os.replace", fail_second_replace) + + result = await fixture.service.adopt("candidate-adoption") + + assert result.status is AdoptionStatus.APPLY_FAILED_ROLLED_BACK + for path, content in fixture.base.items(): + assert fixture.profile.repository_root.joinpath(*path.split("/")).read_bytes() == content + assert await fixture.git.status_porcelain() == b"" + fixture.store.load_candidate(CandidateState.READY, "candidate-adoption") + + +@pytest.mark.asyncio +async def test_failed_rollback_marks_candidate_uncertain( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + fixture = await ready_candidate(tmp_path) + original_replace = os.replace + calls = 0 + + def fail_apply_and_rollback(source: Path, target: Path) -> None: + nonlocal calls + calls += 1 + if calls >= 2: + raise OSError("simulated apply and rollback failure") + original_replace(source, target) + + monkeypatch.setattr( + "mini_code_agent.worktrees.adoption.os.replace", + fail_apply_and_rollback, + ) + + result = await fixture.service.adopt("candidate-adoption") + + assert result.status is AdoptionStatus.APPLY_UNCERTAIN + fixture.store.load_candidate(CandidateState.UNCERTAIN, "candidate-adoption") + recovery = ( + fixture.profile.state_root + / "candidates" + / "uncertain" + / "candidate-adoption" + / "recovery.json" + ) + assert json.loads(recovery.read_text(encoding="utf-8"))["status"] == "apply_uncertain" + + +@pytest.mark.asyncio +async def test_recovery_classifies_all_before_and_all_after_states( + tmp_path: Path, +) -> None: + before_fixture = await ready_candidate(tmp_path / "before") + before_fixture.store.transition_candidate( + "candidate-adoption", + CandidateState.READY, + CandidateState.APPLYING, + ) + + before = await before_fixture.service.recover("candidate-adoption") + + assert before.status is AdoptionStatus.RECOVERED_READY + before_fixture.store.load_candidate(CandidateState.READY, "candidate-adoption") + + after_fixture = await ready_candidate(tmp_path / "after") + payload = after_fixture.store.load_candidate_payload( + CandidateState.READY, + "candidate-adoption", + ) + after_fixture.store.transition_candidate( + "candidate-adoption", + CandidateState.READY, + CandidateState.APPLYING, + ) + for item in payload.manifest.files: + after_fixture.profile.repository_root.joinpath(*item.path.split("/")).write_bytes( + payload.blobs[item.content_blob_sha256] + ) + + after = await after_fixture.service.recover("candidate-adoption") + + assert after.status is AdoptionStatus.APPLIED + after_fixture.store.load_candidate(CandidateState.APPLIED, "candidate-adoption") + + mixed_fixture = await ready_candidate(tmp_path / "mixed") + mixed_payload = mixed_fixture.store.load_candidate_payload( + CandidateState.READY, + "candidate-adoption", + ) + mixed_fixture.store.transition_candidate( + "candidate-adoption", + CandidateState.READY, + CandidateState.APPLYING, + ) + first = mixed_payload.manifest.files[0] + mixed_fixture.profile.repository_root.joinpath(*first.path.split("/")).write_bytes( + mixed_payload.blobs[first.content_blob_sha256] + ) + + mixed = await mixed_fixture.service.recover("candidate-adoption") + + assert mixed.status is AdoptionStatus.APPLY_UNCERTAIN + mixed_fixture.store.load_candidate(CandidateState.UNCERTAIN, "candidate-adoption") + + +@pytest.mark.asyncio +async def test_discard_tool_removes_only_ready_candidate(tmp_path: Path) -> None: + fixture = await ready_candidate(tmp_path) + adopt_tool = AdoptSubagentCandidateTool(fixture.service) + discard_tool = DiscardSubagentCandidateTool(fixture.service) + call = ToolCall( + id="discard-1", + name="discard_subagent_candidate", + arguments={ + "candidate_id": "candidate-adoption", + "reason": "Discard the unused candidate.", + }, + ) + + preview = await discard_tool.preview(call) + result = await discard_tool.execute(call) + + assert preview.side_effect.value == "write" + assert json.loads(result.content)["status"] == DiscardStatus.DISCARDED.value + assert not ( + fixture.profile.state_root / "candidates" / "discarding" / "candidate-adoption" + ).exists() + with pytest.raises(CandidateAdoptionError): + await fixture.service.preview("candidate-adoption") + assert adopt_tool.definition.name == "adopt_subagent_candidate" + + +@pytest.mark.asyncio +async def test_adoption_requires_separate_parent_approval(tmp_path: Path) -> None: + fixture = await ready_candidate(tmp_path) + call = ToolCall( + id="adopt-1", + name="adopt_subagent_candidate", + arguments={ + "candidate_id": "candidate-adoption", + "reason": "Apply only after explicit approval.", + }, + ) + denied_approval = StaticApprovalHandler(approved=False) + denied_executor = GovernedToolExecutor( + ToolRegistry((AdoptSubagentCandidateTool(fixture.service),)), + policy=PolicyEngine(), + approval=denied_approval, + session_mode=SessionMode.INTERACTIVE, + trust_source=TrustSource.MODEL, + ) + + denied = await denied_executor.execute(call) + + assert denied.is_error is True + assert all( + fixture.profile.repository_root.joinpath(*path.split("/")).read_bytes() == content + for path, content in fixture.base.items() + ) + fixture.store.load_candidate(CandidateState.READY, "candidate-adoption") + assert len(denied_approval.requests) == 1 + + approved_approval = StaticApprovalHandler(approved=True) + approved_executor = GovernedToolExecutor( + ToolRegistry((AdoptSubagentCandidateTool(fixture.service),)), + policy=PolicyEngine(), + approval=approved_approval, + session_mode=SessionMode.INTERACTIVE, + trust_source=TrustSource.MODEL, + ) + approved = await approved_executor.execute(call.model_copy(update={"id": "adopt-2"})) + + assert approved.is_error is False + assert json.loads(approved.content)["status"] == AdoptionStatus.APPLIED.value + assert len(approved_approval.requests) == 1 + + +def _profile( + repository: Path, + state: Path, + git_executable: Path, +) -> WorktreeProfile: + return WorktreeProfile( + repository_root=repository, + state_root=state, + git_executable=git_executable, + allowed_path_prefixes=("src", "tests"), + implementation_profile=SubagentProfile( + profile_id="implementation", + local_name="delegate_implementation", + description="Implement one bounded task.", + system_prompt="Change only files required by the task.", + tool_names=("read_file", "search_text", "write_file", "edit_file"), + mode="implementation", + agent_limits=AgentLimits(max_turns=8, max_tool_calls=32), + ), + ) + + +def _git(repository: Path, *arguments: str) -> None: + subprocess.run( + ("git", "-C", str(repository), *arguments), + check=True, + stdin=subprocess.DEVNULL, + capture_output=True, + shell=False, + ) diff --git a/tests/smoke_test.py b/tests/smoke_test.py index d1ad054..48a4b8c 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -25,6 +25,7 @@ from mini_code_agent.testing import PytestRunner from mini_code_agent.tools import RunTestsTool from mini_code_agent.worktrees import ( + AdoptSubagentCandidateTool, CandidateSnapshotter, DelegateImplementationTool, WorktreeImplementationRunner, @@ -51,6 +52,7 @@ def verify_installed_package() -> None: assert PytestRunner.__name__ == "PytestRunner" assert RunTestsTool.__name__ == "RunTestsTool" assert CandidateSnapshotter.__name__ == "CandidateSnapshotter" + assert AdoptSubagentCandidateTool.__name__ == "AdoptSubagentCandidateTool" assert DelegateImplementationTool.__name__ == "DelegateImplementationTool" assert WorktreeImplementationRunner.__name__ == "WorktreeImplementationRunner" assert WorktreeManager.__name__ == "WorktreeManager" diff --git a/tests/unit/worktrees/test_git.py b/tests/unit/worktrees/test_git.py index 6c44047..29fe5fe 100644 --- a/tests/unit/worktrees/test_git.py +++ b/tests/unit/worktrees/test_git.py @@ -15,6 +15,7 @@ WorktreeGitError, parse_batch_blobs, parse_index_pointers, + parse_status_paths, parse_worktree_paths, ) from mini_code_agent.worktrees.models import WorktreeErrorCode @@ -188,6 +189,28 @@ def test_worktree_list_parser_extracts_absolute_unique_paths(tmp_path: Path) -> parse_worktree_paths(f"worktree {first}\0worktree {first}\0".encode()) +def test_status_path_parser_accepts_modifications_and_additions() -> None: + payload = b" M src/app.py\0?? src/new.py\0" + + assert parse_status_paths( + payload, + max_entries=32, + max_path_chars=1024, + ) == ("src/app.py", "src/new.py") + with pytest.raises(WorktreeGitError): + parse_status_paths( + payload.rstrip(b"\0"), + max_entries=32, + max_path_chars=1024, + ) + with pytest.raises(WorktreeGitError): + parse_status_paths( + b"R src/renamed.py\0src/original.py\0", + max_entries=32, + max_path_chars=1024, + ) + + @pytest.mark.asyncio async def test_byte_runner_enforces_output_and_timeout_limits(tmp_path: Path) -> None: runner = GitBytesRunner(cleanup_timeout_seconds=2) From 9f6275e4d7b0b9f367f026c48341282cfd3bc69a Mon Sep 17 00:00:00 2001 From: JayLin Date: Thu, 2 Jul 2026 10:21:18 +0800 Subject: [PATCH 10/12] test: harden governed worktree candidates --- .../2026-07-02-m6b-worktree-candidates.md | 10 +- tests/adversarial/__init__.py | 1 + tests/adversarial/test_worktree_safety.py | 281 ++++++++++++++++++ 3 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 tests/adversarial/__init__.py create mode 100644 tests/adversarial/test_worktree_safety.py diff --git a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md index 9efc76a..20bf9ce 100644 --- a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md +++ b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md @@ -182,11 +182,11 @@ hooks-empty/ - Extend: `tests/integration/test_candidate_adoption.py` - Modify: `.github/workflows/ci.yml` only if a required platform gate is missing -- [ ] Cover hostile filenames, Unicode/case aliases, links/reparse points, path swaps, parent HEAD/status races, stale CAS hashes, duplicate IDs, output truncation, killed Git processes, lease exhaustion, candidate tampering, blob tampering, rollback failure, and cancellation races. -- [ ] Run focused real-Git integration tests on Python 3.12 and 3.13. -- [ ] Run all unit, integration, adversarial, type, lint, format, package, and coverage gates. -- [ ] Inspect coverage for all new trust-boundary modules and add missing branch tests. -- [ ] Commit: `test: harden governed worktree candidates` +- [x] Cover hostile filenames, Unicode/case aliases, links/reparse points, path swaps, parent HEAD/status races, stale CAS hashes, duplicate IDs, output truncation, killed Git processes, lease exhaustion, candidate tampering, blob tampering, rollback failure, and cancellation races. +- [x] Run focused real-Git integration tests on Python 3.12 and 3.13. +- [x] Run all unit, integration, adversarial, type, lint, format, package, and coverage gates. +- [x] Inspect coverage for all new trust-boundary modules and add missing branch tests. +- [x] Commit: `test: harden governed worktree candidates` ## Task 10: Document, Package, Publish, and Record Evidence diff --git a/tests/adversarial/__init__.py b/tests/adversarial/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/adversarial/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/adversarial/test_worktree_safety.py b/tests/adversarial/test_worktree_safety.py new file mode 100644 index 0000000..0ee859d --- /dev/null +++ b/tests/adversarial/test_worktree_safety.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +from pathlib import Path + +import pytest + +from mini_code_agent.agent.models import AgentLimits +from mini_code_agent.subagents.models import SubagentProfile, SubagentStatus +from mini_code_agent.worktrees.adoption import CandidateAdoptionService +from mini_code_agent.worktrees.git import ( + WorktreeGitError, + parse_status_paths, +) +from mini_code_agent.worktrees.manager import WorktreeManager +from mini_code_agent.worktrees.models import ( + CandidateDisposition, + CandidateFile, + CandidateManifest, + CandidateOperation, + CandidateState, + GitIndexPointer, + WorktreeProfile, +) +from mini_code_agent.worktrees.state import WorktreeStateError, WorktreeStateStore + + +def profile_for(tmp_path: Path) -> WorktreeProfile: + repository = tmp_path / "repository" + state = tmp_path / "state" + executable = tmp_path / ("git.exe" if os.name == "nt" else "git") + repository.mkdir() + state.mkdir() + executable.touch() + if os.name != "nt": + state.chmod(0o700) + executable.chmod(0o700) + return WorktreeProfile( + repository_root=repository, + state_root=state, + git_executable=executable, + allowed_path_prefixes=("src",), + implementation_profile=SubagentProfile( + profile_id="implementation", + local_name="delegate_implementation", + description="Implement one bounded task.", + system_prompt="Change only files required by the task.", + tool_names=("read_file", "search_text", "write_file", "edit_file"), + mode="implementation", + agent_limits=AgentLimits(max_turns=8, max_tool_calls=32), + ), + ) + + +def ready_candidate( + profile: WorktreeProfile, + *, + candidate_id: str = "candidate-1", +) -> CandidateManifest: + store = WorktreeStateStore(profile) + store.initialize() + before = b"VALUE = 1\n" + after = b"VALUE = 2\n" + source = profile.repository_root / "src" / "app.py" + source.parent.mkdir() + source.write_bytes(before) + file = CandidateFile( + path="src/app.py", + operation=CandidateOperation.MODIFY, + mode="100644", + before_sha256=hashlib.sha256(before).hexdigest(), + after_sha256=hashlib.sha256(after).hexdigest(), + byte_count=len(after), + line_count=1, + diff="bounded", + content_blob_sha256=hashlib.sha256(after).hexdigest(), + ) + manifest = CandidateManifest.create( + candidate_id=candidate_id, + lease_id="lease-1", + repository_root=profile.repository_root, + base_sha="a" * 40, + profile_id="implementation", + child_id="child-1", + child_status=SubagentStatus.COMPLETED, + evidence_sha256="e" * 64, + disposition=CandidateDisposition.READY, + files=(file,), + observed_paths=("src/app.py",), + ) + store.begin_candidate(candidate_id) + store.write_candidate_blob(candidate_id, file.content_blob_sha256, after) + store.write_candidate_json( + candidate_id, + "manifest.json", + manifest.model_dump(mode="json"), + ) + store.transition_candidate( + candidate_id, + CandidateState.BUILDING, + CandidateState.READY, + ) + return manifest + + +@pytest.mark.parametrize("tamper", ["manifest", "blob", "extra"]) +def test_candidate_store_fails_closed_on_state_tampering( + tmp_path: Path, + tamper: str, +) -> None: + profile = profile_for(tmp_path) + manifest = ready_candidate(profile) + candidate = profile.state_root / "candidates" / "ready" / manifest.candidate_id + if tamper == "manifest": + (candidate / "manifest.json").write_text('{"forged":true}\n', encoding="utf-8") + elif tamper == "blob": + (candidate / "blobs" / manifest.files[0].content_blob_sha256).write_bytes(b"forged") + else: + (candidate / "unexpected").write_text("forged", encoding="utf-8") + + with pytest.raises(WorktreeStateError): + WorktreeStateStore(profile).load_candidate( + CandidateState.READY, + manifest.candidate_id, + ) + + +class BlockingCreateGit: + def __init__(self, profile: WorktreeProfile) -> None: + self.profile = profile + self.started = asyncio.Event() + + async def repository_info(self) -> tuple[Path, bool]: + return self.profile.repository_root, False + + async def head_sha(self) -> str: + return "a" * 40 + + async def status_porcelain(self) -> bytes: + return b"" + + async def index_pointers(self) -> tuple[GitIndexPointer, ...]: + return ( + GitIndexPointer( + path="src/app.py", + mode="100644", + object_id="b" * 40, + ), + ) + + async def read_blobs(self, object_ids: tuple[str, ...]) -> dict[str, bytes]: + assert object_ids == ("b" * 40,) + return {"b" * 40: b"VALUE = 1\n"} + + async def add_worktree(self, lease_id: str, path: Path, base_sha: str) -> None: + assert lease_id == "lease-cancel" + assert base_sha == "a" * 40 + path.mkdir() + admin = path.parent / "admin" + admin.mkdir() + (path / ".git").write_bytes(f"gitdir: {admin}\n".encode()) + self.started.set() + await asyncio.Event().wait() + + async def unlock_worktree(self, path: Path) -> None: + raise AssertionError(path) + + async def lock_worktree(self, path: Path, lease_id: str) -> None: + raise AssertionError((path, lease_id)) + + async def remove_worktree(self, path: Path) -> None: + raise AssertionError(path) + + async def prune_worktrees(self) -> None: + raise AssertionError + + async def worktree_paths(self) -> tuple[Path, ...]: + return () + + +@pytest.mark.asyncio +async def test_cancellation_during_git_creation_retains_exact_diagnostic( + tmp_path: Path, +) -> None: + profile = profile_for(tmp_path) + git = BlockingCreateGit(profile) + manager = WorktreeManager( + profile, + git=git, + id_factory=lambda: "lease-cancel", + ) + task = asyncio.create_task(manager.create_lease(child_id="child-1")) + await git.started.wait() + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + lease = profile.state_root / "leases" / "lease-cancel" + assert (lease / "worktree").exists() + diagnostic = json.loads((lease / "cleanup-required.json").read_text(encoding="utf-8")) + assert diagnostic == { + "lease_id": "lease-cancel", + "stage": "creation_failed", + "status": "cleanup_required", + } + + +class AdoptionRaceGit: + def __init__(self, profile: WorktreeProfile, *, head: str = "a" * 40) -> None: + self.profile = profile + self.head = head + + async def repository_info(self) -> tuple[Path, bool]: + return self.profile.repository_root, False + + async def head_sha(self) -> str: + return self.head + + async def status_porcelain(self) -> bytes: + return b"" + + async def changed_paths(self) -> tuple[str, ...]: + return () + + +@pytest.mark.asyncio +@pytest.mark.parametrize("race", ["stale_file", "head"]) +async def test_adoption_races_return_ready_without_overwriting_parent( + tmp_path: Path, + race: str, +) -> None: + profile = profile_for(tmp_path) + manifest = ready_candidate(profile) + source = profile.repository_root / "src" / "app.py" + user_content = b"VALUE = 99\n" + git = AdoptionRaceGit( + profile, + head=("f" * 40 if race == "head" else "a" * 40), + ) + if race == "stale_file": + source.write_bytes(user_content) + service = CandidateAdoptionService( + profile, + store=WorktreeStateStore(profile), + git=git, + ) + + result = await service.adopt(manifest.candidate_id) + + assert result.status.value == "conflict" + assert source.read_bytes() == (user_content if race == "stale_file" else b"VALUE = 1\n") + WorktreeStateStore(profile).load_candidate( + CandidateState.READY, + manifest.candidate_id, + ) + + +def test_hostile_status_paths_are_bounded_and_case_unique() -> None: + payload = b" M src/line\nname.py\0?? src/tab\tname.py\0" + + assert parse_status_paths( + payload, + max_entries=2, + max_path_chars=64, + ) == ("src/line\nname.py", "src/tab\tname.py") + with pytest.raises(WorktreeGitError): + parse_status_paths( + b"?? src/App.py\0?? src/app.py\0", + max_entries=2, + max_path_chars=64, + ) + with pytest.raises(WorktreeGitError): + parse_status_paths( + payload, + max_entries=1, + max_path_chars=64, + ) From 78b9a0b5019104fc921f012ea9cc5ac29b48930e Mon Sep 17 00:00:00 2001 From: JayLin Date: Thu, 2 Jul 2026 11:04:14 +0800 Subject: [PATCH 11/12] docs: prepare 0.16 worktree candidate alpha --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 55 ++++ README.md | 36 ++- SECURITY.md | 32 ++- docs/adr/0015-governed-worktree-candidates.md | 98 +++++++ .../governed-worktree-candidates.md | 247 +++++++++++++++++ docs/architecture/threat-model.md | 39 ++- docs/learning/knowledge-map.md | 124 ++++++++- docs/learning/progress.md | 76 +++++- docs/resume/project-profile.md | 77 ++++-- .../2026-07-02-m6b-worktree-candidates.md | 22 +- pyproject.toml | 2 +- src/mini_code_agent/worktrees/adoption.py | 4 +- src/mini_code_agent/worktrees/finalization.py | 31 +-- src/mini_code_agent/worktrees/runner.py | 22 +- tests/cli/test_cli.py | 4 +- tests/integration/test_agent_loop.py | 2 +- tests/unit/test_package.py | 2 +- tests/unit/tools/test_runtime_info.py | 2 +- tests/worktree_artifact_test.py | 257 ++++++++++++++++++ uv.lock | 2 +- 21 files changed, 1062 insertions(+), 74 deletions(-) create mode 100644 docs/adr/0015-governed-worktree-candidates.md create mode 100644 docs/architecture/governed-worktree-candidates.md create mode 100644 tests/worktree_artifact_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cb9226..8922ec9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,8 @@ jobs: - run: uv run --no-sync python tests/artifact_test.py - run: uv run --python 3.13 --isolated --no-project --with dist/*.whl tests/smoke_test.py - run: uv run --python 3.13 --isolated --no-project --with dist/*.tar.gz tests/smoke_test.py + - run: uv run --python 3.13 --isolated --no-project --with dist/*.whl tests/worktree_artifact_test.py + - run: uv run --python 3.13 --isolated --no-project --with dist/*.tar.gz tests/worktree_artifact_test.py tests: strategy: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3024211..b93a88b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,61 @@ All notable changes follow Keep a Changelog. Versions follow Semantic Versioning ## [Unreleased] +## [0.16.0-alpha.0] - 2026-07-02 + +### Added + +- Immutable host-owned `WorktreeProfile` and bounded lease/candidate/adoption state models with + separate repository and state roots, exact implementation profiles, path prefixes, and hard + tree/content/cleanup limits. +- Byte-safe fixed-argv Git boundary for exact clean repository admission, NUL-delimited index + pointers, raw object reads, locked detached no-checkout Worktrees, administrative identity, + bounded process lifecycle, and verified cleanup. +- Raw index materialization for regular `100644`/`100755` files plus immutable base manifests, + successful structured-mutation hash chains, complete-tree reconciliation, bounded diffs, + content-addressed candidate blobs, and canonical manifest hashes. +- `delegate_implementation` with model-visible `task/reason` only, fresh non-interactive + implementation children, exact SUBAGENT-provenance Read/Search/Write/Edit Tools, optional + host-fixed tests, independent snapshot, and cancellation-safe finalization. +- Separate high-risk `adopt_subagent_candidate` and `discard_subagent_candidate` Tools with + verified previews, ready/applying claims, clean-base/path/hash revalidation, canonical apply, + rollback, interrupted-state recovery, and uncertain-state evidence. +- Real-Git implementation/adoption integration and adversarial tests for hostile paths, aliases, + links, races, tampering, output/process limits, lease exhaustion, cancellation, cleanup, and + rollback failure. +- M6b architecture, threat-model, ADR, learning, and resume documentation. + +### Changed + +- Public package exports and installed-package smoke now include the governed Worktree profile, + runner, candidate, adoption, and discard APIs. +- M6a analysis profiles remain read-only; implementation is a separate profile and parent Tool + rather than a capability upgrade. + +### Security + +- Child completion cannot mutate or authorize mutation of the parent checkout. Candidate adoption + requires a second Policy/approval decision and exact original clean `HEAD`. +- Ordinary checkout is avoided during lease population. Only bounded regular files from verified + Git index/object bytes are materialized; ignored/untracked files, links, gitlinks, aliases, + unsupported modes, and over-budget trees fail closed. +- Ready candidates come from independent full-tree/base/ledger reconciliation and verified stored + blobs, not child summaries or bounded diff text. +- Adoption conflicts write no candidate files. Partial failures are either proven rolled back or + persisted as uncertain; interrupted applying states are classified as all-before, all-after, or + mixed before reuse. +- Worktree separation is not OS isolation, and multi-file adoption is not power-loss atomic, + distributed, exactly-once, or a database two-phase commit. + +### Verification + +- Local Python 3.12.13 and 3.13.14 each passed 1184 tests with 13 platform/privilege skips. + Python 3.13 package branch coverage was 88.49%, above the 85% gate. Ruff format/check, strict + Pyright, Bandit, and locked runtime dependency audit passed. +- Final reproducible artifact hashes, isolated smoke evidence, PR/main CI run IDs, tag commit, + release URL, and remote asset digests will be recorded after the remaining release gates + complete. + ## [0.15.0-alpha.0] - 2026-07-02 ### Added diff --git a/README.md b/README.md index 93a4d05..b473e35 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,16 @@ A framework-light, provider-neutral coding agent built from first principles. -> Status: pre-alpha. M6a provides a provider-neutral Agent Core, Anthropic/OpenAI-compatible +> Status: pre-alpha. M6b provides a provider-neutral Agent Core, Anthropic/OpenAI-compatible > adapters, a schema-validating Tool Registry, a cross-platform Workspace boundary, bounded > Read/Search, conflict-aware Write/Edit, policy-governed argv command execution, and deterministic > context admission, hardened read-only Git evidence, governed Pytest diagnostics, versioned SQLite > Session/Trace persistence, fail-closed Checkpoint/Resume, and a host-controlled bounded Repair > loop, provenance-aware lazy Skills, deterministic host-registered Tool Hooks, and host-pinned -> local MCP stdio Tools, and bounded host-profiled read-only analysis Subagents. OS sandboxing, +> local MCP stdio Tools, bounded host-profiled read-only analysis Subagents, and host-managed +> Worktree implementation candidates with separately approved adoption. OS sandboxing, > shell-string execution, project-provided executable Hooks, automatic Repair resume, remote -> HTTP/OAuth MCP, write-capable Subagents/Worktrees, and live-provider CI are not implemented. +> HTTP/OAuth MCP, automatic commit/merge/push, and live-provider CI are not implemented. ## Requirements @@ -257,6 +258,33 @@ In-process context isolation is not an OS sandbox. M6a cannot write, run command Tools, open nested approval prompts, persist durable child traces, create Worktrees, or merge changes. See `docs/architecture/governed-subagents.md`. +## Governed Worktree Candidates + +M6b adds one separately governed implementation Tool. The parent model supplies only `task` and +`reason`; the host pins the exact clean repository, external state root, Git executable, allowed +path prefixes, implementation profile, optional fixed tests, and resource limits. + +The host creates a locked detached `--no-checkout` Worktree and materializes the exact Git index +from raw object bytes. A fresh non-interactive child may use only host-approved Read/Search/ +Write/Edit and optional `run_tests` Tools with `TrustSource.SUBAGENT`. It cannot use Git, arbitrary +commands, MCP/network, recursive delegation, or parent approval. + +After the child stops, the host independently reconciles the complete tree with the immutable base +manifest and mutation ledger. Ready candidates persist canonical manifests and content-addressed +blobs outside the repository; the temporary Worktree is then verified and removed. Child +completion never mutates the parent checkout. + +`adopt_subagent_candidate` is a separate high-risk WRITE Tool and approval. It requires the +original clean `HEAD`, revalidates every path/hash before the first replacement, applies only the +verified additions/modifications, and leaves them unstaged and uncommitted. Conflicts write +nothing; partial failures are either proven rolled back or marked uncertain for operator +recovery. `discard_subagent_candidate` accepts only a verified ready candidate. + +Worktree path separation and rollback-aware adoption are not OS sandboxing or crash-atomic +transactions. M6b does not delete/rename files, run arbitrary commands, commit, merge, push, or +claim token/latency improvements. See +`docs/architecture/governed-worktree-candidates.md`. + ## Documentation - Product design: `docs/superpowers/specs/2026-06-29-mini-code-agent-design.md` @@ -277,6 +305,7 @@ changes. See `docs/architecture/governed-subagents.md`. - Governed Skills and Hooks: `docs/architecture/governed-extensions.md` - Governed MCP stdio: `docs/architecture/governed-mcp.md` - Governed analysis Subagents: `docs/architecture/governed-subagents.md` +- Governed Worktree candidates: `docs/architecture/governed-worktree-candidates.md` - Threat model: `docs/architecture/threat-model.md` - Provider protocol ADR: `docs/adr/0002-provider-wire-protocols.md` - Workspace boundary ADR: `docs/adr/0003-workspace-boundary.md` @@ -291,6 +320,7 @@ changes. See `docs/architecture/governed-subagents.md`. - Inert Skills and host Hooks ADR: `docs/adr/0012-inert-skills-host-hooks.md` - Host-pinned stdio MCP ADR: `docs/adr/0013-host-pinned-stdio-mcp.md` - Bounded host-profiled Subagents ADR: `docs/adr/0014-bounded-host-profiled-subagents.md` +- Governed Worktree candidates ADR: `docs/adr/0015-governed-worktree-candidates.md` ## License diff --git a/SECURITY.md b/SECURITY.md index 565734e..bdcb274 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -59,8 +59,36 @@ text. M6a children are in-process and are not an OS, process, memory, credential, or network sandbox. Read-only Tool admission does not constrain malicious host-supplied Provider or Tool code. Evidence hashes are not signatures, semantic validation, confidentiality, or durable audit. -M6a does not support child writes, command/network Tools, recursive delegation, Worktrees, -candidate adoption, merge, rollback, or exactly-once execution. +M6a remains read-only and does not support child writes, command/network Tools, recursive +delegation, Worktrees, candidate adoption, merge, rollback, or exactly-once execution. + +M6b implementation delegation uses a separate immutable host profile. It pins an exact clean +repository and `HEAD`, an external non-overlapping state root, absolute Git executable, allowed +path prefixes, implementation Tool set, and hard tree/candidate/cleanup limits. The host creates +a locked detached no-checkout Worktree and materializes only regular `100644`/`100755` index +entries from raw Git object bytes. Ignored/untracked files, links, gitlinks, unsupported modes, +case aliases, and over-budget trees do not enter the lease. + +Implementation children are fresh, non-interactive, and limited to SUBAGENT-provenance +Read/Search/Write/Edit plus optional host-fixed tests. They receive no Git, arbitrary command, +network, MCP, recursive delegation, deletion, rename, or parent approval authority. Successful +structured mutations form a hash-chained ledger, but candidate readiness is decided by an +independent complete-tree reconciliation against the immutable base manifest, ledger, path +allowlist, modes, content hashes, and resource limits. + +Ready candidates persist canonical manifests and content-addressed blobs outside the repository. +Child completion never mutates the parent checkout. Adoption requires a separate high-risk WRITE +Tool, Policy decision, and approval. It revalidates the original clean `HEAD`, every path and +before-hash, applies only the verified candidate set, verifies after-hashes, and leaves changes +unstaged and uncommitted. Conflicts write no candidate files. Partial failure is either proven +rolled back or recorded as uncertain; interrupted applying state is classified before reuse. + +Worktree path separation is not an OS, process, memory, credential, filesystem, or network +sandbox. In-process trusted Provider/Tool code retains the Agent process authority. Clean/hash +checks narrow but do not eliminate races with another process. Multi-file adoption is +process-serialized and rollback-aware, not power-loss atomic, distributed, exactly-once, or a +database two-phase commit. M6b does not delete/rename files, automatically adopt, stage, commit, +merge, push, reset, clean, or durably resume a child. The project does not claim OS-level sandboxing unless an explicit sandbox backend is enabled and documented. It also does not claim that Hook timeout stops work delegated to another thread or diff --git a/docs/adr/0015-governed-worktree-candidates.md b/docs/adr/0015-governed-worktree-candidates.md new file mode 100644 index 0000000..b1abc92 --- /dev/null +++ b/docs/adr/0015-governed-worktree-candidates.md @@ -0,0 +1,98 @@ +# ADR 0015: Separate Implementation Candidates from Parent Adoption + +- Status: Accepted +- Date: 2026-07-02 + +## Context + +M6a proved that an analysis child can use a fresh context, exact host-owned read-only Tools, +structured concurrency, and bounded evidence. Allowing that child to write directly into the +parent checkout would introduce a different authority boundary: repository identity, dirty user +work, concurrent mutations, partial filesystem failure, durable candidate state, cleanup, and +approval to publish a change. + +A Git Worktree gives a separate checkout path but does not by itself provide a safe candidate +protocol. Ordinary checkout may execute conversion configuration. Child self-reported diffs +cannot be trusted. Automatically copying the result back would combine implementation and +publication authority and could overwrite user work. + +## Decision + +M6b uses a two-phase host-governed design. + +First, `delegate_implementation` creates one host-managed locked detached no-checkout Worktree +lease from an exact clean `HEAD`. The host reads index pointers and raw Git objects with fixed, +byte-safe argv, materializes only regular files, and records an immutable base manifest. + +The implementation child runs with a fresh context, exact SUBAGENT-provenance Read/Search/ +Write/Edit Tools, optional host-fixed tests, non-interactive policy, and no Git, arbitrary command, +network, MCP, delegation, or parent approval. Successful structured mutations form a hash-chained +ledger. + +After child completion, the host independently reconciles the complete lease tree, base manifest, +ledger, path allowlist, modes, content, and resource budgets. A verified candidate stores a +canonical manifest plus content-addressed blobs outside the repository. The Worktree is then +verified and removed. Rejected/no-change/cleanup-required outcomes remain distinct. + +Second, `adopt_subagent_candidate` is a separate high-risk WRITE Tool and approval. It atomically +claims a ready candidate, revalidates exact repository/base/clean state and every destination, +stages same-directory temporary files, applies canonical replacements, and verifies the final +changed set and hashes. Conflicts write nothing. Partial failures roll back in reverse order and +become either proven rolled-back or uncertain. Interrupted applying state is classified as +all-before, all-after, or mixed. Discard is separately governed and only accepts ready candidates. + +The initial release supports additions and modifications only. It never stages, commits, merges, +pushes, resets, or cleans the parent checkout. + +## Consequences + +Positive: + +- child implementation cannot directly mutate the user's checkout; +- child completion and parent publication require separate Policy/approval decisions; +- materialization avoids working-tree checkout filters and uses an exact index snapshot; +- candidate authority comes from independent tree reconciliation and stored blobs, not model text; +- stale base, dirty parent, path drift, and hash drift fail before the first candidate write; +- process-local adoption has deterministic conflict, rollback, uncertain, and recovery states; +- failed child work can be cleaned without granting merge or Git authority; +- M6a's read-only profile and no-recursion guarantees remain unchanged. + +Negative: + +- Git object-format support is initially limited to SHA-1; +- only regular UTF-8 additions/modifications with supported modes can become ready; +- repository-sized materialization is bounded but can still cost time and disk space; +- Worktrees isolate paths, not process memory, credentials, filesystem, or network; +- cleanup and adoption are not crash-atomic or distributed transactions; +- an uncertain candidate requires operator inspection rather than automatic retry; +- the first release runs one implementation child per ToolCall and provides no automatic merge. + +## Alternatives Rejected + +- **Write directly in the parent checkout:** can collide with user changes and combines child + implementation with publication authority. +- **Create a branch and auto-merge:** grants Git mutation and merge authority without a separate + review/adoption decision. +- **Use ordinary `git worktree add` checkout:** may invoke configured working-tree conversions and + makes exact byte provenance harder to constrain. +- **Trust `git diff` or child summary as the candidate:** bounded presentation text is not an + adoption source of truth and can omit or misstate files. +- **Copy the complete Worktree back:** cannot enforce exact path, mode, content, or conflict + preconditions. +- **Adopt immediately after child success:** conflates model completion with verified readiness + and user approval. +- **Use only filesystem backups:** lacks a durable candidate state machine and clear recovery + classification. +- **Call adoption atomic:** multiple filesystem replacements cannot provide power-loss atomicity + without a stronger transactional storage design. +- **Give the child Git or arbitrary shell:** expands authority beyond bounded source changes and + host-fixed tests. +- **Run multiple implementation children into one candidate:** introduces ordering and conflict + semantics that are intentionally deferred. + +## Follow-up + +Future work may add deletion/rename, stronger process isolation, non-SHA-1 repositories, durable +operator recovery commands, candidate review UI, or multi-candidate composition. Each requires a +new threat analysis and must not weaken exact base/path/hash validation or separate adoption +approval. diff --git a/docs/architecture/governed-worktree-candidates.md b/docs/architecture/governed-worktree-candidates.md new file mode 100644 index 0000000..57f79af --- /dev/null +++ b/docs/architecture/governed-worktree-candidates.md @@ -0,0 +1,247 @@ +# Governed Worktree Candidates + +## Purpose and Scope + +M6b lets a parent Agent delegate one bounded implementation task without allowing the child to +write into the parent checkout. The host creates a locked Git Worktree lease from an exact clean +base, runs one implementation Subagent inside that lease, persists an independently verified +candidate, removes the lease, and exposes adoption as a separate high-risk WRITE Tool. + +The first release supports: + +- one implementation child per `delegate_implementation` ToolCall; +- host-pinned repository, state root, Git executable, path prefixes, child profile, and limits; +- additions and modifications of regular UTF-8 files with modes `100644` or `100755`; +- optional host-fixed `run_tests` in addition to Read/Search/Write/Edit; +- content-addressed candidate blobs and immutable manifests; +- explicit adoption or discard of a verified ready candidate. + +It does not support deletion, rename, arbitrary command/Git/MCP/network access, recursive +delegation, stage/commit/merge/push, automatic adoption, or editing an already dirty parent. + +## Authority and Data Flow + +The trusted host owns `WorktreeProfile`, Provider and Tool factories, state storage, Git +executable, policy, approval, and every hard limit. The parent model supplies only `task` and +`reason`. The implementation child receives an exact non-interactive Tool profile and cannot +select its repository, capabilities, test command, candidate paths, or adoption policy. + +```mermaid +sequenceDiagram + participant Parent as "Parent Agent" + participant Policy as "Parent Tool governance" + participant Runner as "Implementation runner" + participant Lease as "Host Worktree lease" + participant Child as "Fresh implementation child" + participant Store as "Candidate store" + participant Adopt as "Adoption governance" + participant Repo as "Parent checkout" + + Parent->>Policy: "delegate_implementation(task, reason)" + Policy->>Runner: "approved ToolCall" + Runner->>Repo: "verify exact clean repository and HEAD" + Runner->>Lease: "create locked detached no-checkout Worktree" + Runner->>Lease: "materialize exact Git index blobs" + Runner->>Child: "fresh task with exact governed Tools" + Child->>Lease: "bounded Write/Edit and optional fixed tests" + Runner->>Store: "reconcile tree, ledger, limits, and hashes" + Store-->>Parent: "ready/rejected/no-change metadata" + Runner->>Lease: "verified cleanup" + Parent->>Adopt: "separate adopt(candidate_id, reason)" + Adopt->>Policy: "high-risk WRITE approval" + Policy->>Repo: "revalidate base, clean state, paths, and hashes" + Policy->>Repo: "apply exact candidate files or roll back" +``` + +Child completion and parent adoption are deliberately separate authority decisions. A child can +produce evidence that a change exists; it cannot authorize that change to mutate the user's +checkout. + +## Host Profile and Limits + +`WorktreeProfile` is immutable composition data. It requires absolute, existing, unlinked paths +for the repository, state root, and Git executable. The state root cannot overlap the repository. +Allowed path prefixes are normalized and case-insensitively unique. The embedded +`SubagentProfile` must use implementation mode. + +Default limits are: + +| Resource | Default | Hard model ceiling | +|---|---:|---:| +| Active leases | 2 | 4 | +| Tracked base files | 10,000 | 20,000 | +| Tracked base bytes | 256 MiB | 1 GiB | +| Tracked path depth | 32 | 64 | +| Candidate files | 32 | 128 | +| Candidate after-content | 2 MiB | 16 MiB | +| One candidate file | 1 MiB | 8 MiB | +| Relative path | 1,024 characters | 1,024 characters | +| Returned diff | 32 KiB | 64 KiB | +| Cleanup deadline | 30 seconds | 300 seconds | + +These are admission limits, not performance claims. + +## Lease Creation and Materialization + +`WorktreeManager` first verifies that the configured root is the exact non-bare repository, +captures `HEAD`, requires a fully clean status, reads NUL-delimited index pointers, and reads raw +objects through `git cat-file --batch`. It rechecks `HEAD` and status after object acquisition. + +The host creates the lease with: + +```text +git worktree add --detach --no-checkout --lock --reason +``` + +No checkout filters, hooks, smudge processes, or working-tree conversion are used to populate +files. `materialize_index()` writes only regular `100644`/`100755` index entries from verified +object bytes, enforces all path/file/tree budgets, rejects aliases and links, and records an +immutable base manifest. The exact Worktree administrative directory is persisted and rechecked +during cleanup. + +The initial release accepts Git SHA-1 object identifiers only. Repositories using another object +format fail closed. + +## Child Capability and Mutation Ledger + +The child gets a fresh context and a new `AgentRuntime`. Its exact governed Tool set is host +validated before Provider I/O: + +- required Read/Search/Write/Edit Tools; +- optional fixed-profile `run_tests`; +- `TrustSource.SUBAGENT` provenance; +- no Git, arbitrary command, MCP, network, Skill/Hook registration, delegation, or parent approval; +- `SessionMode.NON_INTERACTIVE`, so any `ASK` fails closed. + +`LedgerRecordingToolExecutor` records only successful structured `MutationResult` objects from +Write/Edit. Each immutable entry binds ToolCall identity, path, operation, before/after SHA-256, +byte/line counts, and a predecessor hash. Natural-language child output does not create mutation +authority. + +## Independent Candidate Snapshot + +After the child stops, `CandidateSnapshotter` walks the complete lease tree and reconciles it +against: + +1. the immutable Git base manifest; +2. the ordered mutation ledger; +3. the allowed path prefixes; +4. regular-file, mode, UTF-8, path, count, and byte limits; +5. the child status and evidence hash. + +A ready candidate contains sorted additions/modifications, bounded unified diffs, before/after +hashes, and content-addressed after-bytes. The manifest has a canonical SHA-256 and is persisted +outside the repository. Unknown changes, deletions, unsupported modes, links, case aliases, +ledger mismatches, invalid content, or budget violations produce a rejected candidate. No changes +produce `no_changes`. + +The candidate state machine is: + +```mermaid +stateDiagram-v2 + [*] --> building + building --> ready: "verified snapshot" + building --> rejected: "unsafe or invalid snapshot" + ready --> applying: "atomic adoption claim" + applying --> applied: "exact final hashes" + applying --> ready: "preflight conflict or all-before recovery" + applying --> uncertain: "mixed state or unproved rollback" + ready --> discarding: "atomic discard claim" + discarding --> [*]: "verified removal" + rejected --> [*] + applied --> [*] + uncertain --> [*] +``` + +## Cleanup and Cancellation + +Finalization snapshots before cleanup. Cleanup verifies the lease identity, registered Worktree +path, administrative directory, candidate persistence, and either the verified candidate state +or an unchanged base tree. It then unlocks, removes, and prunes through fixed Git argv. If removal +fails after unlock, it attempts to relock and records `cleanup_required`. + +External cancellation remains cancellation. Snapshot/cleanup run in a shielded bounded +finalization task; timeout records cleanup-required diagnostics. This avoids silently converting +an interrupted child into success, but it does not make cleanup crash-proof. + +## Adoption, Rollback, Recovery, and Discard + +`adopt_subagent_candidate` and `discard_subagent_candidate` are separate high-risk WRITE Tools. +Their previews verify the stored manifest and blobs and expose bounded repository/base/path/byte/ +diff resources before Policy and approval. + +Adoption: + +1. atomically claims `ready -> applying`; +2. requires the exact repository, clean status, and original base `HEAD`; +3. preflights every destination and before-hash; +4. stages same-directory temporary files; +5. revalidates every path immediately before the first replacement; +6. applies in canonical path order; +7. verifies the exact changed set and after-hashes; +8. records `applied`, leaving files unstaged and uncommitted. + +A preflight conflict writes nothing and returns the candidate to `ready`. An I/O failure rolls +back replacements in reverse order. Proven rollback records `apply_failed_rolled_back`; an +unproved rollback records `uncertain` with recovery evidence. + +Interrupted `applying` recovery compares every parent file with before/after hashes: + +- all-before becomes `ready`; +- all-after becomes `applied`; +- mixed/unknown becomes `uncertain`. + +Discard is allowed only from verified `ready`. Applied, applying, rejected, or uncertain +candidates are never silently deleted. + +## Failure Matrix + +| Failure | Public outcome | Parent mutation | +|---|---|---| +| Dirty/wrong/bare repository | typed repository error | none | +| Lease or tree budget exceeded | typed limit/materialization error | none | +| Child Tool/profile mismatch | composition failure before Provider I/O | none | +| Child timeout/failure | typed child result, then snapshot/finalize | none | +| Unknown tree mutation or ledger mismatch | rejected candidate | none | +| Snapshot persistence failure | cleanup required | none | +| Cancellation | re-raised after bounded finalization | none | +| Adoption stale base/path/hash | conflict, candidate returns ready | none | +| Adoption I/O failure with proven rollback | rolled back | restored before-state | +| Adoption I/O failure without proof | uncertain plus recovery record | unknown/mixed | +| Cleanup identity/removal failure | cleanup required | none | + +## Operational Verification + +The M6b test surface includes: + +- unit tests for profiles, byte-safe Git, state CAS, materialization, ledger, snapshot, + finalization, runner, Tools, adoption, discard, rollback, and recovery; +- real-Git integration for no-checkout materialization, child delegation, unchanged parent, + candidate persistence, adoption, discard, conflict, and cancellation; +- adversarial coverage for hostile names, case/Unicode aliases, links/reparse points, path swaps, + dirty/changed parent state, stale hashes, output limits, killed Git, lease exhaustion, candidate + or blob tampering, rollback failure, and cleanup races; +- Python 3.12/3.13 and Windows/Linux CI, strict Pyright, Ruff, coverage, reproducible build, archive + inspection, and isolated artifact smoke. + +Exact counts, CI run IDs, artifact hashes, and release links are recorded only after the release +gate in `docs/learning/progress.md`. + +## Threat Boundary and Non-Claims + +- A Worktree separates checkout paths; it is not a container, VM, filesystem sandbox, credential + boundary, network sandbox, or separate OS user. +- In-process Provider/Tool implementations still have the Agent process's memory and OS authority. +- Tool governance constrains calls through the executor; malicious trusted host code can bypass it. +- Git clean/hash checks and immediate revalidation narrow TOCTOU windows but cannot stop another + process from mutating files between checks and replacement. +- Adoption is process-serialized and rollback-aware, not power-loss atomic, distributed, or a + database two-phase commit. +- SHA-256 and Git object IDs are equality fingerprints, not signatures, provenance, confidentiality, + semantic correctness, or proof that tests passed. +- Candidate diffs are bounded presentation evidence; content-addressed blobs are the adoption + source of truth. +- Child completion does not imply candidate readiness, and candidate readiness does not imply + approval, correctness, compatibility, or adoption. +- M6b never commits, merges, pushes, resets, cleans, or overwrites a dirty parent checkout. +- No token, latency, cost, quality, or throughput improvement is claimed without a benchmark. diff --git a/docs/architecture/threat-model.md b/docs/architecture/threat-model.md index f2798a8..3943f10 100644 --- a/docs/architecture/threat-model.md +++ b/docs/architecture/threat-model.md @@ -96,6 +96,27 @@ - Child summaries are explicitly untrusted and bounded. Evidence stores ToolCall identity, error/count metadata, and ToolResult SHA-256 only; Subagent events exclude task, prompt, message, summary, argument, ToolResult content, repository content, and exception text. +- M6b implementation delegation requires a host-owned immutable profile, an exact clean + repository/HEAD, a separate non-overlapping state root, a fixed Git executable, path prefixes, + and hard tree/candidate/cleanup limits. +- The host creates locked detached no-checkout Worktrees and materializes only regular + `100644`/`100755` index entries from raw Git object bytes. Base identity is persisted as an + immutable manifest and exact Worktree administrative directory. +- Implementation children use fresh non-interactive contexts and exact SUBAGENT-provenance + Read/Search/Write/Edit plus optional host-fixed tests. Git, arbitrary commands, network, MCP, + delegation, nested approval, deletion, rename, and mode changes are unavailable. +- Successful structured mutations form a hash-chained ledger. Candidate readiness is decided by + independent complete-tree reconciliation against the base manifest, ledger, path allowlist, + modes, UTF-8/content hashes, and resource limits. +- Ready candidates persist canonical manifests and content-addressed blobs outside the repository. + Snapshot/cleanup is bounded and shielded during cancellation; identity or removal uncertainty is + recorded as `cleanup_required`. +- Parent adoption is a separate high-risk WRITE Tool and approval. It atomically claims candidate + state, requires original clean HEAD, preflights and immediately revalidates every path/hash, + applies in canonical order, verifies exact final changes, and leaves files unstaged/uncommitted. +- Adoption conflicts perform zero candidate writes. Partial failure triggers reverse rollback and + records either proven rolled-back or uncertain state; interrupted applying state is classified + as all-before, all-after, or mixed before any retry. ## Non-claims @@ -167,5 +188,19 @@ encryption, provenance, semantic correctness, or durable parent-child audit. - M6a child deadlines depend on cooperative asyncio cancellation and do not stop arbitrary threads or prove that an external Provider request incurred no cost. -- M6a does not implement write-capable children, Worktree isolation, candidate adoption, merging, - rollback, durable child Resume, recursive delegation, or token/cost/quality improvements. +- M6a remains read-only; M6b does not weaken its exact capability, no-recursion, or cancellation + boundary. +- A Git Worktree separates checkout paths. It does not isolate Python memory, OS identity, + credentials, filesystem, processes, network, or malicious host-supplied Provider/Tool code. +- No-checkout materialization avoids working-tree conversion during population but does not prove + repository content is safe or trustworthy. +- Candidate manifests, ledgers, Git IDs, and SHA-256 values are equality fingerprints, not + signatures, provenance, confidentiality, semantic correctness, or proof of test success. +- Git clean/hash checks and immediate path revalidation narrow but cannot eliminate races with a + concurrent process that has the same filesystem authority. +- Adoption is process-serialized and rollback-aware, not power-loss atomic, distributed, or + exactly-once. A mixed or unverifiable state intentionally becomes `uncertain`. +- M6b supports bounded additions/modifications only. It does not delete, rename, stage, commit, + merge, push, reset, clean, automatically adopt, durably resume a child, or recursively delegate. +- M6b does not claim lower token use, latency, cost, higher quality, or throughput without a + separate reproducible benchmark. diff --git a/docs/learning/knowledge-map.md b/docs/learning/knowledge-map.md index b0641e4..eca6953 100644 --- a/docs/learning/knowledge-map.md +++ b/docs/learning/knowledge-map.md @@ -819,7 +819,8 @@ M6 分成两个独立权限阶段: - **M6a 已实现**:同进程、fresh context、不可递归、只读分析 Subagent; -- **M6b 待实现**:宿主创建 Git Worktree、写入候选快照、单独审批 adoption。 +- **M6b 已实现,待发布**:宿主创建 Git Worktree、独立验证并持久化候选、单独审批 + adoption。 不要因为两者都叫 Subagent 就把“并行读”和“并行改”当成同一个安全问题。 @@ -902,6 +903,85 @@ M6 分成两个独立权限阶段: approval、cleanup 和 rollback uncertainty。M6a 不能把“只读 child 已安全”外推成“同进程 child 可以直接改 parent checkout”。 +**M6b 前置知识** + +- Git 的 `HEAD`、index、object database、working tree 是四个不同状态层;Worktree 共享 + object database,但有独立工作目录和 index/admin state。 +- `git worktree add --detach --no-checkout` 只创建 Worktree 关系,不自动把 index 内容写成 + working-tree 文件;本项目再通过 `ls-files --stage -z` 与 `cat-file --batch` 读取对象。 +- Git object ID 和 SHA-256 都是内容身份,不是签名。首版对象解析只接受 40 字符 SHA-1。 +- compare-and-set(CAS)状态迁移:只有观察到预期旧状态时才能写入新状态,例如 + `ready -> applying`;它用于拒绝并发重复采用。 +- content-addressed storage:以内容 hash 命名候选 blob,manifest 只引用 hash,采用时重新 + 验证内容。 +- TOCTOU:检查路径/哈希后到替换前仍可能被并发修改,因此要在第一次写入前对全部目标再 + 校验,但这仍不是 OS 隔离。 +- 多文件原子性与单文件原子替换不同。`os.replace` 只能保证一次替换;跨文件失败需要逆序 + rollback,并保留无法证明恢复时的 `uncertain`。 +- `asyncio.shield` 只保护 finalization Task 不被外层取消直接打断;仍需 deadline、join 和 + cleanup-required 状态。 + +**M6b 实现主线** + +1. **Host-pinned profile** + + `WorktreeProfile` 固定 repository/state root、Git executable、allowed path prefixes、 + implementation `SubagentProfile` 和所有 tree/candidate/cleanup budgets。state root 必须与 + repository 分离,模型只能传 `task/reason`。 + +2. **Clean base admission** + + `WorktreeManager` 要求 exact top-level、非 bare、完整 clean status,先后两次验证 `HEAD` + 与 status,并从 index pointers/raw blobs 形成不可变 `BaseManifest`。ignored/untracked + 内容不会复制进 child。 + +3. **No-checkout materialization** + + 宿主创建 locked detached Worktree,再只把 `100644/100755` Git blobs 写成普通文件。 + symlink、gitlink、非法/大小写别名、过深路径、超文件数/字节数全部 fail closed。 + +4. **Exact implementation capability** + + child 使用 fresh context、`NON_INTERACTIVE` 和 `TrustSource.SUBAGENT`。仅允许 + Read/Search/Write/Edit,可选 fixed `run_tests`;不允许 Git、任意 command、MCP/network、 + Skill/Hook 注册、递归 delegation、删除、rename 或 mode change。 + +5. **Mutation ledger** + + 只有成功返回结构化 `MutationResult` 的 Write/Edit 才追加 entry。entry 绑定 ToolCall、 + path、before/after hash、bytes/lines,并形成 predecessor hash chain;child 文字不能声明 + 某个文件已经成为候选。 + +6. **Independent snapshot** + + child 结束后,宿主遍历完整 Worktree,把实际树与 BaseManifest、ledger、allowed prefixes、 + mode/UTF-8/hash/预算逐项对账。通过后生成 sorted manifest、bounded diff 与 + content-addressed blobs;未知改动、删除或对账失败进入 rejected。 + +7. **Fail-closed finalization** + + snapshot 在 cleanup 前完成。cleanup 重新验证 lease、admin dir、Git worktree list 和候选 + 持久化,再 unlock/remove/prune;失败则 relock 并记录 `cleanup_required`。外部取消继续 + 上抛,但 bounded shielded finalization 先尝试收尾。 + +8. **Separate adoption approval** + + `adopt_subagent_candidate` 是独立 high-risk WRITE Tool。preview 先验 manifest/blob,执行时 + CAS claim `ready -> applying`,要求 parent 仍为原 clean `HEAD`,预检并在首次替换前再次 + 校验所有 path/hash。冲突零写入,成功后文件仍 unstaged/uncommitted。 + +9. **Rollback and recovery** + + 中途 I/O 失败按逆序 rollback:全部恢复才记录 `apply_failed_rolled_back`,不能证明则 + `uncertain`。进程中断后的 `applying` 根据全部文件的 before/after hash 分类: + all-before 回 `ready`、all-after 记 `applied`、mixed 进 `uncertain`。 + +10. **边界结论** + + Worktree 是 checkout 路径隔离,不是容器;adoption 是进程内串行和 rollback-aware, + 不是断电原子事务或 Flink exactly-once。candidate ready 只证明字节符合协议,不证明代码 + 正确,也不等于用户已批准采用。 + **Java / Flink / Spark 概念映射** | 既有经验 | M6a 对应概念 | 关键差异 | @@ -918,6 +998,19 @@ M6 分成两个独立权限阶段: | Spark task result accumulator | `SubagentBatchResult` | summary 不可信,证据只保留有界 metadata/hash | | 数据血缘 fingerprint | ToolResult SHA-256 | hash 是内容身份,不是业务正确性或来源签名 | +**M6b 的 Java / Flink / Spark 映射** + +| 既有经验 | M6b 对应概念 | 关键差异 | +|---|---|---| +| Maven 构建的固定 source revision | Git index/object base manifest | manifest 固定输入字节,不运行 checkout filter | +| 线程池 task slot / Flink subtask namespace | Worktree lease | lease 隔离目录与生命周期,不隔离 OS 进程/凭证 | +| Kafka changelog / Flink state delta | mutation ledger | ledger 记录受治理成功写入,但最终仍需全树对账 | +| Checkpoint metadata + immutable state files | candidate manifest + content blobs | ready 是字节协议状态,不是业务成功 | +| 乐观锁 `@Version` / CAS | candidate state claim | 防重复采用,不是分布式锁 | +| 两阶段发布 | implementation -> adoption | 类似 prepare/publish,但不是数据库 2PC 或 exactly-once | +| Spark output commit protocol | stage temp -> canonical replace -> verify | 多文件提交仍可能部分失败,需要 rollback/recovery | +| Flink checkpoint recovery classification | all-before/all-after/mixed | mixed 进入人工处理,不自动猜测重放 | + **代码阅读顺序** 1. `subagents/models.py`:Profile、Limits、Status、Result 及跨字段约束。 @@ -956,6 +1049,35 @@ M6 分成两个独立权限阶段: 12. 为 M6b 写一页威胁清单:clean base、no-checkout Worktree、allowed path、candidate snapshot、adoption approval、conflict 和 cleanup uncertainty;不要修改 M6a profile。 +**M6b 代码阅读顺序** + +1. `worktrees/models.py`:先看 Profile、Limits、lease/candidate state machine 和跨字段不变量。 +2. `worktrees/git.py` 与 `materialize.py`:看 fixed argv、NUL/byte protocol、index blob 和 + no-checkout materialization。 +3. `worktrees/state.py`:看外部 state root、原子写、CAS claim、manifest/blob 验证。 +4. `worktrees/manager.py`:按 clean admission、lease create、admin identity、cleanup 跟踪。 +5. `worktrees/ledger.py`:看为什么只接受成功的 structured mutation result。 +6. `worktrees/snapshot.py`:看 complete-tree reconciliation 和 ready/rejected 决策。 +7. `worktrees/finalization.py` 与 `runner.py`:看 child、snapshot、cleanup、cancellation 的所有权。 +8. `worktrees/tools.py`:看 parent 模型为什么只能传 task/reason。 +9. `worktrees/adoption.py`:按 preview、claim、preflight、revalidate、apply、rollback、recover + 阅读。 +10. `tests/integration/test_governed_worktree_agent.py`、`test_candidate_adoption.py` 与 + `tests/adversarial/test_worktree_safety.py`:用失败场景反推边界。 + +**M6b 验收练习** + +1. 在一个 tracked 文件旁放 ignored secret,创建 lease 后证明 ignored 文件没有进入 child。 +2. 比较普通 checkout 与 no-checkout/raw blob materialization,说明 filter/hook 执行面的差异。 +3. 让 child 直接改文件但不产生 ledger entry,验证 candidate 为什么 rejected。 +4. 在 snapshot 前制造删除、symlink、case alias 和 scope 外新增,记录 rejection reason。 +5. 在 adoption preview 后修改 parent 文件,证明 execute 阶段冲突且 candidate 文件零写入。 +6. 对第二个文件注入 replace failure,分别验证 rollback 成功与 rollback 失败两种状态。 +7. 人工构造 `applying` 的 all-before、all-after、mixed 三种磁盘状态,解释 recovery 结果。 +8. 取消 blocked child,验证 `CancelledError` 上抛、finalization 有界运行且无 orphan Task。 +9. 检查采用后的 `git status`,证明改动 unstaged/uncommitted,且 Harness 没有 merge/push 权限。 +10. 用自己的话解释:“Worktree candidate 像两阶段发布,但为什么不能写成 2PC/exactly-once”。 + ### L12:CI、Benchmark 与发布 **理论** diff --git a/docs/learning/progress.md b/docs/learning/progress.md index f20a5ba..ef99525 100644 --- a/docs/learning/progress.md +++ b/docs/learning/progress.md @@ -13,8 +13,8 @@ | L8 Git/test/repair | Complete and released | M4a Git + M4b Pytest + M4c bounded Repair | | L9 Skills and Hooks | Complete and released | Inert Skills + monotonic Tool Hooks; v0.13 evidence | | L10 MCP | Complete and released | Governed stdio, exact grants, real SDK integration; v0.14 evidence | -| L11 Subagent and Worktree | M6a complete and released; M6b not started | Host-profiled read-only Subagents, TaskGroup, real parent/child integration | -| L12 CI, benchmark and release | In progress | v0.15 prerelease and cross-platform evidence complete | +| L11 Subagent and Worktree | M6a released; M6b implementation complete, release gate in progress | Host-profiled analysis plus governed Worktree candidates/adoption | +| L12 CI, benchmark and release | In progress | v0.15 released; v0.16 local/cross-version gates in progress | ## L0 Notes @@ -796,3 +796,75 @@ `bba51dd17fb0d0ba8852c7be86c10add7e07e3ad`。非 draft GitHub prerelease 已发布, 远端 asset name、size 与 GitHub SHA-256 digest 均与上述本地制品一致。 + +## M6b Governed Worktree Candidate Notes + +- M6b 没有把 M6a analysis profile 改成可写,而是新增独立 implementation profile。Parent + `delegate_implementation` 的模型输入只有 `task/reason`;repository/state root、Git、 + allowed path、child Tool、固定测试和预算全部由宿主 immutable `WorktreeProfile` 固定。 +- lease admission 要求 exact non-bare repository top-level、完整 clean status 和稳定 `HEAD`。 + 宿主读取 NUL-delimited index pointers 与 raw Git blobs,并在读取后再次验证 HEAD/status。 +- Worktree 用 locked detached `--no-checkout` 创建。`materialize_index()` 只写 + `100644/100755` 普通文件,不复制 ignored/untracked 内容,也不经过 checkout filter; + symlink/gitlink、大小写 alias、非法路径和所有 tree budget fail closed。 +- child 使用 fresh context、`SessionMode.NON_INTERACTIVE` 和 + `TrustSource.SUBAGENT`。exact capability 是 Read/Search/Write/Edit 加可选 fixed + `run_tests`;Git、arbitrary command、MCP/network、Skills/Hooks、delegation、delete、 + rename、mode change 和 nested approval 都不可用。 +- `LedgerRecordingToolExecutor` 只从成功 structured `MutationResult` 生成 immutable ledger + entry,绑定 ToolCall/path/before-after hash/bytes/lines 并形成 hash chain。模型 summary + 或直接绕过 Tool 的磁盘修改不会自动获得候选身份。 +- `CandidateSnapshotter` 独立扫描完整树,与 immutable BaseManifest、ledger、allowed + prefixes、mode、UTF-8、hash 和资源预算对账。ready candidate 只包含 sorted add/modify、 + bounded diff 与 content-addressed after blobs;未知改动、删除、alias/link、ledger mismatch + 或超限进入 rejected。 +- snapshot 先于 cleanup。cleanup 重验 lease/admin dir/worktree registration/candidate + persistence,再 unlock/remove/prune;删除失败会尝试 relock 并记录 `cleanup_required`。 + cancellation 继续上抛,但先运行带 deadline 的 shielded finalization。 +- `adopt_subagent_candidate` 和 `discard_subagent_candidate` 是两个独立 high-risk WRITE + Tool。preview 先验证 manifest/blob;adoption execute CAS claim `ready -> applying`, + 要求 parent 仍为原 clean HEAD,预检全部目标、stage 同目录 temp,并在首次 replace 前再次 + 验证全部 path/hash。 +- preflight conflict 产生零 candidate 写入并回到 `ready`。中途 I/O failure 逆序 + rollback:能证明 all-before 才记录 rolled-back,不能证明就进入 `uncertain`。中断后的 + applying recovery 只接受 all-before->ready、all-after->applied、mixed->uncertain。 +- 成功 adoption 只把 exact candidate additions/modifications 留在 parent working tree, + 保持 unstaged/uncommitted;Harness 不执行 branch/commit/merge/push/reset/clean。 +- Worktree 是 checkout path separation,不是 OS sandbox。Adoption 是 process-serialized、 + rollback-aware 文件协议,不是 power-loss atomic transaction、2PC 或 exactly-once。 + +## M6b Review Lessons + +- 只调用 `git worktree add` 不等于安全 materialization。普通 checkout 的 filter/smudge 和 + repository config 是额外执行面;首版从 index/object bytes 显式构造受限树。 +- mutation ledger 是审计线索,不是最终事实。只有把完整实际树、base manifest 和 ledger + 三方对账,才能发现 Tool 外改动、删除或漏记。 +- bounded diff 只用于 preview;采用必须读取 content-addressed blob 并重新 hash,不能把 + 截断展示文本当 source of truth。 +- clean repository 是 point-in-time observation。adoption 必须在 preview 后重新验证 + repo/HEAD/status/path/hash,并在第一处替换前再次全量 revalidate。 +- 单文件 `os.replace` 原子不等于多文件原子。公开状态必须区分 conflict、rolled-back 和 + uncertain,不能在 rollback 未证明时返回普通失败。 +- cleanup failure 不是日志 warning。仍注册或 admin identity 不明的 Worktree 必须持久化 + `cleanup_required`,后续 operator 才有可诊断入口。 +- `asyncio.shield` 不是“忽略取消”。正确语义是 caller 仍收到 `CancelledError`,内部 + finalization 在独立 deadline 内完成或记录 timeout。 +- candidate ready、child success、tests passed 和 user approval 是四个不同事实,任何一个 + 都不能替代另一个。 + +## M6b Local Implementation Verification + +- 真实 Git 集成覆盖 no-checkout materialization、raw index blob、clean base race、parent + checkout bytes unchanged、implementation child、candidate persistence、adoption/discard、 + stale conflict、rollback/recovery 和 cancellation finalization。 +- adversarial suite 覆盖 hostile filename、Unicode/case alias、link/reparse point、path + swap、parent HEAD/status race、stale CAS、duplicate ID、Git output truncation/termination、 + lease exhaustion、candidate/blob tampering、rollback failure 与 cleanup race。 +- Python 3.13.14 完整质量门禁为 1184 passed、13 skipped,package branch coverage + 88.49%,超过 85% 门槛;Ruff format/check、strict Pyright 与 Bandit 通过,locked + runtime dependency audit 为 `No known vulnerabilities found`。 +- 独立 Python 3.12.13 环境同样为 1184 passed、13 skipped。13 个 Windows skip 包括既有 + symlink privilege 条件与仅在 POSIX 验证 mode/case/FIFO 的场景;Ubuntu CI 将执行对应 + POSIX 路径。 +- 最终源码冻结后的 reproducible build、四组 artifact smoke、PR/main CI、tag、Release 与 + 远端 digest 仍需完成后再记录,不能把中间构建写成发布成果。 diff --git a/docs/resume/project-profile.md b/docs/resume/project-profile.md index aeec602..55e1e75 100644 --- a/docs/resume/project-profile.md +++ b/docs/resume/project-profile.md @@ -5,12 +5,12 @@ > 、M3b 版本化 Session/追加式 Trace、M3c Checkpoint/Resume、M4a hardened 只读 Git > 、M4b 受治理 Pytest 诊断、M4c 宿主控制的有限 Repair 及 M5a 惰性 Skills/Tool Hooks > 、M5b host-pinned local stdio MCP 与 M6a host-profiled read-only analysis -> Subagent 已发布。Shell 字符串、项目可执行 Hook、OS 沙箱、remote -> HTTP/OAuth MCP、自动 Repair Resume、write-capable Subagent/Worktree 和真实凭证联调 -> 尚未实现。`v0.15.0-alpha.0` GitHub prerelease 已发布;Python 3.12/3.13 本地各 -> 1062 passed、10 个 Windows symlink 条件跳过、91.09% 分支覆盖率。PR/main 的 -> Ubuntu/Windows x Python 3.12/3.13 五个 CI job、四组 artifact smoke、annotated tag、 -> 非 draft prerelease 和远端 asset digest 验证均已通过。 +> Subagent 已发布;M6b host-managed Worktree implementation candidate 与单独 adoption +> approval 已完成实现,正在进行 `v0.16.0-alpha.0` 发布门禁。Shell 字符串、项目可执行 +> Hook、OS 沙箱、remote HTTP/OAuth MCP、自动 Repair Resume、自动 commit/merge/push 和 +> 真实凭证联调尚未实现。M6b 在 Python 3.12/3.13 本地各 1184 passed、13 个平台/权限 +> 条件跳过,Python 3.13 package branch coverage 88.49%;最终 artifact、远端 CI、tag、 +> prerelease 和 digest 需完成后再回填。 > > 本文中的功能、性能和指标是目标或验收方案。只有得到代码、测试、CI、Benchmark 或 Release 证据后,才能改写为已完成成果。 @@ -24,15 +24,20 @@ M5a 进一步加入 source-qualified Skill Catalog:严格解析 `SKILL.md`, 不能改写结果。M5b 再接入官方稳定 MCP Python SDK 的 local stdio Tools:启动前审批绝对 executable/argv/cwd,钉住 server identity、完整 Tool 集合和 input/output schema hash, 用 owner-worker 管理跨 Task 进程生命周期,并把 MCP alias 作为 extension 继续送入同一 -Policy/approval/result-boundary。下一阶段将实现 write-capable Subagent 的 Git Worktree -candidate/adoption 边界。 +Policy/approval/result-boundary。 M6a 已加入受限分析 Subagent:宿主以 immutable profile 固定 child system prompt、exact read-only Tool、Agent/timeout/result budgets 和 `TrustSource.SUBAGENT`;每个 child 使用 fresh context,所有 Task 归属一个 `asyncio.TaskGroup`,通过 Semaphore、per-child/outer timeout、ordinal slots 完成有界并发与有序聚合。Parent 只接收 labelled untrusted summary 和 ToolResult metadata/SHA-256 evidence;事件不记录 task/prompt/arguments/results。 -M6b 才会设计 Git Worktree candidate/adoption,M6a 不具备写入和合并权限。 +M6b 再加入独立 implementation profile:宿主从 exact clean HEAD 创建 locked detached +no-checkout Worktree,以 index/raw Git blobs 物化基线;child 只能用 SUBAGENT provenance +的 Read/Search/Write/Edit 与可选固定测试。完成后宿主用 BaseManifest、mutation ledger +和完整树扫描独立生成 content-addressed candidate,清理 Worktree,parent checkout 保持 +不变。采用候选必须经过另一个 high-risk WRITE Tool/approval,并重新验证 repo/HEAD/ +status/path/hash;冲突零写入,中途失败区分 rolled-back 与 uncertain,成功改动保持 +unstaged/uncommitted。 ## 2. 项目定位 @@ -47,11 +52,12 @@ M6b 才会设计 Git Worktree candidate/adoption,M6a 不具备写入和合并 最终技术栈以 `pyproject.toml`、ADR 和发布版本为准。 -M0 至 M6a 已实际使用 Python 3.12/3.13、`asyncio`、`asyncio.TaskGroup`、 +M0 至 M6b 已实际使用 Python 3.12/3.13、`asyncio`、`asyncio.TaskGroup`、 `asyncio.Semaphore`、`Protocol`、`dataclasses`、uv、 Hatchling、Pydantic v2、pydantic-settings、Platformdirs、HTTPX、httpx-sse、Typer、Rich、 JSON Schema Draft 2020-12、stdlib `sqlite3`、SQLite WAL/事务/索引、canonical JSON、SHA-256、 -Git porcelain v2、Pytest/JUnit XML、defusedxml、PyYAML、官方 MCP Python SDK v1、 +Git porcelain v2/NUL protocol、Git Worktree/index/object database、Pytest/JUnit XML、 +defusedxml、PyYAML、官方 MCP Python SDK v1、 JSON-RPC/stdio、pytest-asyncio、Coverage、Ruff 与 Pyright;其余技术随对应里程碑落地。 | 分类 | 技术 | @@ -71,6 +77,8 @@ JSON-RPC/stdio、pytest-asyncio、Coverage、Ruff 与 Pyright;其余技术随 | 扩展治理 | restricted PyYAML、source-qualified Skill Catalog、SHA/文件身份重验、typed async Tool Hooks、monotonic authorization | | MCP 互操作 | 官方 `mcp` SDK v1、JSON-RPC、local stdio、host-pinned grants、canonical schema SHA-256、owner-worker lifecycle | | Subagent 编排 | immutable capability profile、fresh context、`asyncio.TaskGroup`/Semaphore、结构化取消、SUBAGENT provenance、ordered aggregation、evidence SHA-256 | +| Worktree 候选 | locked detached no-checkout Worktree、raw index/blob materialization、immutable BaseManifest、mutation hash chain、content-addressed blobs、candidate CAS state machine | +| 候选采用 | 独立 high-risk WRITE approval、clean HEAD/path/hash revalidation、same-directory staging、canonical replace、rollback/recovery、uncertain state | | 测试与质量 | Pytest、pytest-asyncio、Coverage、Ruff、Pyright | | 构建与发布 | `uv`、`pyproject.toml`、GitHub Actions、SemVer、GitHub Release | | 文档与治理 | Markdown、ADR、威胁模型、贡献指南、Changelog | @@ -92,7 +100,7 @@ JSON-RPC/stdio、pytest-asyncio、Coverage、Ruff 与 Pyright;其余技术随 13. 惰性不可信 Skills 与单调授权 Tool Hooks。 14. Host-pinned、双审批、受治理的 MCP stdio Tools。 15. 宿主能力限定、结构化并发、不可递归的只读分析 Subagent。 -16. 面向 Git Worktree candidate/adoption 的后续扩展架构。 +16. Host-managed Git Worktree 实现候选与独立 adoption/rollback/recovery。 ## 5. 亮点拆解 @@ -118,8 +126,9 @@ JSON-RPC/stdio、pytest-asyncio、Coverage、Ruff 与 Pyright;其余技术随 | 惰性 Skills 与单调授权 Hooks | 仓库扩展既会占用上下文,也可能通过静默覆盖、动态导入或生命周期回调绕过权限 | restricted PyYAML/Pydantic、direct-child regular-file/reparse 检查、source-qualified ID、SHA-256 + file identity TOCTOU 重验、只读 list/load Tool、async Protocol Hook、稳定优先级、timeout、bounded audit | 模型先发现 metadata,再按 fingerprint 加载 labelled untrusted Markdown;宿主 pre-Hook 可 veto,post-Hook 可观察 | 阻止 Skill 注册执行能力、跨来源 shadow、内容漂移、无界扫描和 Hook 提权;pre 失败在副作用前关闭,post 失败不伪造执行事实 | 真实 Agent 证明恶意 Skill 不能绕过 deny、pre 阻断零落盘、post 失败后结果与后续 observer 保留;Python 3.12/3.13 各 867 passed、90.86% 分支覆盖率;PR/main 五 job CI、v0.13 prerelease 与远端制品摘要验证通过 | | 受治理 MCP stdio | 直接信任 `tools/list` 会让 server/package 替换新增权限;local server 在 Tool Policy 前已能执行代码,连接审批也不能代表每次调用获批 | 官方 SDK `mcp>=1.28.1,<2`、absolute executable + argv-only、SecretStr environment、独立 connection approver、protocol/server identity、exact grant set、canonical input/output schema hash、owner-worker、per-tool `TrustSource.EXTENSION`、bounded result validator | 宿主审核一个固定 local stdio server,验证后只发布 local aliases;每次调用继续经过 Schema/Preview/Hook/Policy/Tool approval,返回 text/structured JSON | 阻止 PATH/shell 注入、未授权 Tool、schema drift、server metadata 提权、跨 Task AnyIO context 泄漏、无界/多媒体结果和 approval 混淆;超时保留副作用不确定性 | 真实官方 SDK 进程覆盖 handshake/call/shutdown、deny/ask 零远端调用、extra Tool/schema drift 零 admission、cross-task close;本地 Python 3.12/3.13 各 961 passed/10 skips、90.84% branch coverage;PR/main 五 job CI、四组 artifact smoke、v0.14 prerelease 与远端制品摘要验证通过 | | 受限分析 Subagent | 单 Agent 串行探索会把独立调查混入 parent transcript;直接复制 parent 权限又会放大 Tool、成本、递归和取消风险 | immutable `SubagentProfile`、独立 Provider/Tool factory、fresh child `AgentRuntime`、exact read-only definitions、`TrustSource.SUBAGENT`、`SessionMode.NON_INTERACTIVE`、`asyncio.TaskGroup` + Semaphore、per-child/outer timeout、ordinal slots、canonical result/evidence SHA-256 | Parent 经一个受治理 Tool fan-out 1-4 个独立分析任务;child 乱序完成但按输入顺序聚合,单 child timeout/failure 不影响 sibling,parent cancellation 取消并 join 全部 child | 防止 parent/sibling context 隐式泄漏、模型动态扩权、递归 delegation、后台嵌套审批、orphan Task、原始 ToolResult 进入 parent/event;将 child 自述与可核对 hash metadata 分开 | 真实 parent/child Agent + Read/Search 集成覆盖 fresh context、SUBAGENT Policy、deny 零 factory、non-recursion、timeout isolation、Workspace bytes 不变和双 child cancellation;本地双版本各 1062 passed/10 skips/91.09%,PR/main 五 job CI、四组 artifact smoke、v0.15 prerelease 与远端 digest 验证通过;未宣称 token/latency 提升 | +| Worktree 实现候选与独立采用 | 可写 child 直接修改 parent 会覆盖用户工作;仅创建 Worktree 仍缺少 base identity、候选事实、清理、采用授权和失败恢复 | immutable `WorktreeProfile`、exact clean HEAD、locked detached `--no-checkout`、index/raw blob materialization、BaseManifest、SUBAGENT exact Tools、mutation hash chain、complete-tree reconciliation、content-addressed blobs、CAS candidate state、separate WRITE approval、path/hash revalidation、rollback/recovery | Parent 只提交 task/reason;child 在临时 lease 生成 bounded add/modify candidate,完成后 parent checkout 不变;ready candidate 可被单独批准采用或丢弃,采用后保持 unstaged/uncommitted | 阻断 child 自授权发布、ignored/untracked 污染、checkout filter 执行、Tool 外改动、stale base/path/hash 覆盖、重复采用和模糊 partial failure;cleanup/adoption 不确定性进入显式状态 | 真实 Git/Agent/adoption + adversarial 覆盖 clean race、hostile path、tamper、cancellation、rollback/recovery;Python 3.12/3.13 本地各 1184 passed/13 skips,3.13 package branch coverage 88.49%;不宣称 OS sandbox、2PC/exactly-once 或性能提升 | | 质量门禁 | 企业级项目需要稳定接口和回归保护 | Ruff、严格 Pyright、Pytest、85% 核心覆盖率门槛、哈希构建约束、CI、SemVer | 自动执行 lint、类型检查、测试、构建和安装验证 | 防止低质量变更进入发布版本 | v0.12:Python 3.12/3.13 本地各 798 通过、6 项 symlink 条件跳过,90.88% 分支覆盖率,Bandit/pip-audit 与四组 artifact smoke 通过;PR/main CI 的 Ubuntu/Windows × 3.12/3.13 与 quality 全成功,prerelease 及两个校验摘要一致的制品已发布 | -| 可扩展 Harness | Skills、Hooks、MCP、Subagent 会增加控制流复杂度 | 稳定 Protocol、EventBus、能力声明、依赖倒置、per-tool provenance | 在不侵入 Agent Core 的前提下加入 Skills、Hooks、MCP 与只读 Subagent | 避免扩展绕过权限、Trace 和 Session | Skills/Hooks/MCP/Subagent 均复用 Tool Registry 与 Policy;write-capable Worktree 待实现 | +| 可扩展 Harness | Skills、Hooks、MCP、Subagent 会增加控制流复杂度 | 稳定 Protocol、EventBus、能力声明、依赖倒置、per-tool provenance | 在不侵入 Agent Core 的前提下加入 Skills、Hooks、MCP、分析 Subagent 与 Worktree implementation profile | 避免扩展绕过权限、Trace、Session 与 adoption 权限 | Skills/Hooks/MCP/Subagent/Worktree Tools 均复用 Registry、Policy 和 approval;实现与采用保持两个权限边界 | ## 6. 指标回填规则 @@ -207,16 +216,28 @@ Semaphore 限并发、ordinal slot 保序、child/batch timeout 分层,外部 传播并 join 全部 child。返回的 summary 明确不可信,证据只保留 ToolResult hash metadata。 这不是 OS sandbox,也没有 benchmark 证明 token 或延迟改善。” -### 7.12 企业级体现在哪里 +### 7.12 为什么实现 child 与采用 candidate 要分开 + +“可写 Subagent 最大的问题不是怎么创建 Worktree,而是谁有权把结果发布到用户工作区。 +我把它拆成两个受治理 Tool:implementation 阶段从 exact clean HEAD 创建 locked detached +no-checkout Worktree,用 raw Git blobs 物化基线,child 只有 Read/Search/Write/Edit 和可选 +固定测试;宿主再把完整树与 BaseManifest、mutation ledger 对账,生成 +content-addressed candidate,并清理 lease。此时 parent checkout 没有变化。adoption 是 +另一个 high-risk WRITE approval,执行前重新验证 repo/HEAD/status 和所有 path/hash, +冲突零写入;中途失败只有能证明恢复才叫 rolled-back,否则进入 uncertain。成功结果也只 +留在 unstaged working tree,不自动 commit/merge/push。这个协议类似 prepare/publish, +但多文件替换不是 power-loss atomic,所以不能包装成 2PC 或 exactly-once。” + +### 7.13 企业级体现在哪里 “企业级不是功能数量,而是边界清晰、失败可诊断、状态可恢复、安全策略可测试、发布可重复。项目设置严格类型、测试覆盖率门槛、跨平台 CI、安全模型、SemVer 和发布 smoke test。” -### 7.13 如何避免过度设计 +### 7.14 如何避免过度设计 -“首版先完成单 Agent 的最小完整闭环。Skills、Hooks、MCP 和 M6a read-only Subagent -都沿已有 Tool、Event、Policy、Session 协议接入;write-capable Subagent/Worktree 要等 -candidate/adoption 边界单独实现,不能把只读 profile 直接放宽。remote MCP/OAuth 等独立 -威胁面也不与 local stdio 混做。” +“首版先完成单 Agent 的最小完整闭环。Skills、Hooks、MCP、M6a analysis Subagent 和 M6b +Worktree implementation 都沿已有 Tool、Policy、approval 和 typed result 协议接入。 +M6b 也只做一 child、一 candidate、add/modify、separate adoption,不提前做自动 merge、 +多 Agent 协同或分布式事务。remote MCP/OAuth 等独立威胁面也不与 local stdio 混做。” ## 8. 简历成果模板 @@ -287,6 +308,24 @@ candidate/adoption 边界单独实现,不能把只读 profile 直接放宽。r - 以 labelled untrusted summary 和 ToolResult metadata/SHA-256 聚合 child 结果,事件 排除 task/prompt/message/arguments/results/exception;真实 parent/child Read/Search 集成验证 Policy deny 零工厂调用、不可递归、Workspace bytes 不变和取消传播。 +- 实现 host-managed Worktree implementation candidate:从 exact clean HEAD 创建 locked + detached no-checkout lease,通过 Git index/raw object bytes 物化受限基线;child 只获得 + SUBAGENT provenance 的 Read/Search/Write/Edit 与可选宿主固定测试,parent checkout 在 + child 完成后保持不变。 +- 以 immutable BaseManifest、structured mutation hash chain 和 complete-tree scan 三方 + 对账生成 content-addressed candidate,拒绝 Tool 外修改、删除、link/case alias、scope + 越界和 mode/content/resource drift;snapshot 后验证 Worktree identity 并 fail-closed + cleanup。 +- 将 candidate adoption 设计为独立 high-risk WRITE Tool/approval,通过 CAS state claim、 + original clean HEAD、path/before-hash preflight、首次替换前全量 revalidation、同目录 + staging 和 exact final-set verification 防止 stale 覆盖与重复采用。 +- 对多文件采用实现 reverse rollback 与 applying recovery:all-before 回 ready、all-after + 记 applied、mixed 或无法证明 rollback 进入 uncertain;成功仅留下 unstaged/uncommitted + add/modify,不自动 branch/commit/merge/push。 +- 通过真实 Git/Agent/adoption 与 adversarial 测试覆盖 hostile path、ignored/untracked + exclusion、parent race、candidate/blob tamper、lease exhaustion、cancellation cleanup 和 + rollback failure;Python 3.12/3.13 本地各 1184 passed/13 skips,Python 3.13 package + branch coverage 88.49%;最终 artifact/CI/Release 证据按发布门禁单独回填。 - Python 3.12/3.13 各 678 项通过、5 项因 Windows symlink 权限跳过,分支覆盖率 90.25%;Bandit/pip-audit 与 wheel/sdist 四组隔离安装 smoke 通过。 - 完成 Mini CodeAgent M0 工程基础:显式配置优先级、Pydantic 强类型边界、密钥安全 JSON 日志与 `doctor` 诊断 CLI。 diff --git a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md index 20bf9ce..f35c56a 100644 --- a/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md +++ b/docs/superpowers/plans/2026-07-02-m6b-worktree-candidates.md @@ -191,18 +191,24 @@ hooks-empty/ ## Task 10: Document, Package, Publish, and Record Evidence **Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `CHANGELOG.md` - Modify: `README.md` -- Modify: `docs/learning/prerequisites-and-knowledge-map.md` -- Modify: `docs/resume/project-description.md` -- Modify: `docs/resume/technical-highlights.md` -- Modify: `docs/operations/release-process.md` -- Modify: `docs/operations/release-evidence.md` +- Modify: `SECURITY.md` +- Create: `docs/architecture/governed-worktree-candidates.md` +- Create: `docs/adr/0015-governed-worktree-candidates.md` +- Modify: `docs/architecture/threat-model.md` +- Modify: `docs/learning/knowledge-map.md` +- Modify: `docs/learning/progress.md` +- Modify: `docs/resume/project-profile.md` - Modify: `pyproject.toml` +- Create: `tests/worktree_artifact_test.py` +- Modify: release-version tests and `tests/smoke_test.py` - Modify: `uv.lock` -- [ ] Explain the trust boundaries, state machine, limits, failure modes, operator recovery, and why child completion is separated from parent adoption. -- [ ] Add prerequisite knowledge and implementation notes for Git index/object storage, worktrees, CAS writes, manifests, rollback, TOCTOU defenses, cancellation shielding, and fail-closed cleanup. -- [ ] Add resume-ready project description, stack, measurable highlights, and for each highlight: why it exists, the technical mechanism, the delivered function, the optimization, and the problem solved. +- [x] Explain the trust boundaries, state machine, limits, failure modes, operator recovery, and why child completion is separated from parent adoption. +- [x] Add prerequisite knowledge and implementation notes for Git index/object storage, worktrees, CAS writes, manifests, rollback, TOCTOU defenses, cancellation shielding, and fail-closed cleanup. +- [x] Add resume-ready project description, stack, measurable highlights, and for each highlight: why it exists, the technical mechanism, the delivered function, the optimization, and the problem solved. - [ ] Bump to `0.16.0a0`, build twice with a fixed epoch, compare artifacts byte-for-byte, and inspect members. - [ ] Smoke-test wheel and sdist in isolated Python 3.12/3.13 environments, including real delegation and adoption flows. - [ ] Push `codex/m6b-worktree-candidates`, open a PR, wait for all CI jobs, merge, verify merged-main CI, create annotated `v0.16.0-alpha.0`, publish a non-draft prerelease with verified artifacts, and update release evidence. diff --git a/pyproject.toml b/pyproject.toml index 5591486..56aa6b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mini-code-agent" -version = "0.15.0a0" +version = "0.16.0a0" description = "A framework-light, provider-neutral, enterprise-grade mini code agent." readme = "README.md" requires-python = ">=3.12,<3.14" diff --git a/src/mini_code_agent/worktrees/adoption.py b/src/mini_code_agent/worktrees/adoption.py index 5862db2..c452307 100644 --- a/src/mini_code_agent/worktrees/adoption.py +++ b/src/mini_code_agent/worktrees/adoption.py @@ -182,7 +182,7 @@ async def recover(self, candidate_id: str) -> AdoptionResult: except WorktreeStateError: raise CandidateAdoptionError("Applying candidate is unavailable.") from None manifest = payload.manifest - try: + with suppress(Exception): top_level, bare = await self._git.repository_info() head = await self._git.head_sha() states = await asyncio.to_thread( @@ -217,8 +217,6 @@ async def recover(self, candidate_id: str) -> AdoptionResult: CandidateState.APPLIED, ) return _adoption_result(manifest, AdoptionStatus.APPLIED) - except Exception: - pass await self._mark_uncertain(candidate_id) return _adoption_result(manifest, AdoptionStatus.APPLY_UNCERTAIN) diff --git a/src/mini_code_agent/worktrees/finalization.py b/src/mini_code_agent/worktrees/finalization.py index d1eb4d7..b2808cd 100644 --- a/src/mini_code_agent/worktrees/finalization.py +++ b/src/mini_code_agent/worktrees/finalization.py @@ -107,20 +107,19 @@ async def run_cancellation_finalization( if not 0 < timeout_seconds <= 300: raise ValueError("Cancellation finalization timeout is invalid.") task = asyncio.create_task(finalize()) - try: - await asyncio.wait_for( - asyncio.shield(task), - timeout=timeout_seconds, - ) - except TimeoutError: - task.cancel() - await asyncio.gather(task, return_exceptions=True) - if on_timeout is not None: - with suppress(Exception): - on_timeout() - except asyncio.CancelledError: - if not task.done(): + with suppress(Exception): + try: + await asyncio.wait_for( + asyncio.shield(task), + timeout=timeout_seconds, + ) + except TimeoutError: task.cancel() - await asyncio.gather(task, return_exceptions=True) - except Exception: - pass + await asyncio.gather(task, return_exceptions=True) + if on_timeout is not None: + with suppress(Exception): + on_timeout() + except asyncio.CancelledError: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) diff --git a/src/mini_code_agent/worktrees/runner.py b/src/mini_code_agent/worktrees/runner.py index 31b4925..cf0b123 100644 --- a/src/mini_code_agent/worktrees/runner.py +++ b/src/mini_code_agent/worktrees/runner.py @@ -6,6 +6,7 @@ import re import time from collections.abc import Callable +from contextlib import suppress from typing import Protocol, cast from uuid import uuid4 @@ -210,17 +211,16 @@ async def _finalize_after_child( try: return await asyncio.shield(task) except asyncio.CancelledError: - try: - await asyncio.wait_for( - asyncio.shield(task), - timeout=self._profile.limits.cleanup_timeout_seconds, - ) - except TimeoutError: - task.cancel() - await asyncio.gather(task, return_exceptions=True) - self._manager.record_cancellation_timeout(lease) - except Exception: - pass + with suppress(Exception): + try: + await asyncio.wait_for( + asyncio.shield(task), + timeout=self._profile.limits.cleanup_timeout_seconds, + ) + except TimeoutError: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + self._manager.record_cancellation_timeout(lease) raise def _allocate_ids(self) -> tuple[str, str]: diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index deb22e1..8f726dd 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -27,7 +27,7 @@ def test_version_option_prints_package_version() -> None: result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 - assert result.stdout.strip() == "0.15.0a0" + assert result.stdout.strip() == "0.16.0a0" def test_module_entrypoint_prints_package_version() -> None: @@ -39,7 +39,7 @@ def test_module_entrypoint_prints_package_version() -> None: ) assert result.returncode == 0 - assert result.stdout.strip() == "0.15.0a0" + assert result.stdout.strip() == "0.16.0a0" def test_doctor_json_never_prints_secrets( diff --git a/tests/integration/test_agent_loop.py b/tests/integration/test_agent_loop.py index d309e77..9b9404e 100644 --- a/tests/integration/test_agent_loop.py +++ b/tests/integration/test_agent_loop.py @@ -53,7 +53,7 @@ async def test_fake_provider_drives_native_tool_call_round_trip() -> None: assert tool_result_message.role is MessageRole.USER assert tool_result_message.tool_results[0].tool_call_id == "call-1" payload = json.loads(tool_result_message.tool_results[0].content) - assert payload["package_version"] == "0.15.0a0" + assert payload["package_version"] == "0.16.0a0" assert [type(event) for event in events.events] == [ RunStarted, ModelStarted, diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index 1eff080..dae4dfc 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -4,7 +4,7 @@ def test_package_exports_release_version() -> None: - assert __version__ == "0.15.0a0" + assert __version__ == "0.16.0a0" def test_package_includes_pep561_marker() -> None: diff --git a/tests/unit/tools/test_runtime_info.py b/tests/unit/tools/test_runtime_info.py index 86956e2..1fede63 100644 --- a/tests/unit/tools/test_runtime_info.py +++ b/tests/unit/tools/test_runtime_info.py @@ -35,7 +35,7 @@ async def test_runtime_info_returns_safe_structured_data() -> None: payload = json.loads(result.content) assert result.tool_call_id == "call-1" assert result.is_error is False - assert payload["package_version"] == "0.15.0a0" + assert payload["package_version"] == "0.16.0a0" assert payload["python_version"] assert payload["platform"] diff --git a/tests/worktree_artifact_test.py b/tests/worktree_artifact_test.py new file mode 100644 index 0000000..6e6c37b --- /dev/null +++ b/tests/worktree_artifact_test.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +from mini_code_agent.agent.models import AgentLimits +from mini_code_agent.domain.content import ToolCall +from mini_code_agent.domain.messages import Message, MessageRole +from mini_code_agent.policy.approval import StaticApprovalHandler +from mini_code_agent.policy.engine import PolicyEngine +from mini_code_agent.policy.executor import GovernedToolExecutor +from mini_code_agent.policy.models import ( + PolicyDecision, + PolicyRule, + SessionMode, + TrustSource, +) +from mini_code_agent.providers.base import FinishReason, ModelProvider, ModelResponse +from mini_code_agent.providers.fake import ScriptedProvider +from mini_code_agent.subagents.models import SubagentLimits, SubagentProfile +from mini_code_agent.tools.base import SideEffect, ToolExecutor +from mini_code_agent.tools.edit_file import EditFileTool +from mini_code_agent.tools.read_file import ReadFileTool +from mini_code_agent.tools.registry import ToolRegistry +from mini_code_agent.tools.search_text import SearchTextTool +from mini_code_agent.tools.write_file import WriteFileTool +from mini_code_agent.workspace.boundary import WorkspaceBoundary +from mini_code_agent.worktrees import ( + AdoptionStatus, + CandidateAdoptionService, + CandidateSnapshotter, + DelegateImplementationTool, + WorktreeFinalizer, + WorktreeGit, + WorktreeImplementationRunner, + WorktreeManager, + WorktreeProfile, + WorktreeStateStore, +) + + +def _tool_response(call: ToolCall) -> ModelResponse: + return ModelResponse( + message=Message(role=MessageRole.ASSISTANT, content=(call,)), + finish_reason=FinishReason.TOOL_CALL, + ) + + +def _stop_response(text: str) -> ModelResponse: + return ModelResponse( + message=Message.assistant_text(text), + finish_reason=FinishReason.STOP, + ) + + +class _ProviderFactory: + def __init__(self, provider: ModelProvider) -> None: + self._provider = provider + + def create(self, profile: SubagentProfile, child_id: str) -> ModelProvider: + assert profile.profile_id == "artifact-implementation" + assert child_id == "artifact-child" + return self._provider + + +class _ToolFactory: + def create( + self, + profile: SubagentProfile, + workspace: WorkspaceBoundary, + ) -> ToolExecutor: + executor = GovernedToolExecutor( + ToolRegistry( + ( + ReadFileTool(workspace), + SearchTextTool(workspace), + WriteFileTool(workspace), + EditFileTool(workspace), + ) + ), + policy=PolicyEngine( + ( + PolicyRule( + id="allow-artifact-candidate-write", + decision=PolicyDecision.ALLOW, + rationale="The isolated artifact smoke permits bounded writes.", + side_effect=SideEffect.WRITE, + trust_source=TrustSource.SUBAGENT, + ), + ) + ), + approval=StaticApprovalHandler(approved=False), + session_mode=SessionMode.NON_INTERACTIVE, + trust_source=TrustSource.SUBAGENT, + ) + assert tuple(item.name for item in executor.definitions) == profile.tool_names + return executor + + +async def verify_worktree_artifact() -> None: + discovered_git = shutil.which("git") + assert discovered_git is not None + with tempfile.TemporaryDirectory(prefix="mini-code-agent-artifact-") as temporary: + root = Path(temporary) + repository = root / "repository" + state = root / "state" + repository.mkdir() + state.mkdir() + if os.name != "nt": + state.chmod(0o700) + _git(repository, "init") + _git(repository, "config", "user.email", "artifact@example.invalid") + _git(repository, "config", "user.name", "Artifact Smoke") + (repository / "src").mkdir() + before = b"VALUE = 'base'\n" + (repository / "src" / "app.py").write_bytes(before) + _git(repository, "add", "--", "src/app.py") + _git(repository, "commit", "-m", "initial") + + profile = WorktreeProfile( + repository_root=repository, + state_root=state, + git_executable=Path(discovered_git).resolve(strict=True), + allowed_path_prefixes=("src",), + implementation_profile=SubagentProfile( + profile_id="artifact-implementation", + local_name="delegate_implementation", + description="Implement one bounded artifact smoke change.", + system_prompt="Use only the lease Tools.", + tool_names=("read_file", "search_text", "write_file", "edit_file"), + mode="implementation", + agent_limits=AgentLimits( + max_turns=6, + max_tool_calls=8, + provider_timeout_seconds=5, + tool_timeout_seconds=5, + ), + limits=SubagentLimits( + max_tasks=1, + max_concurrency=1, + max_task_chars=1_000, + child_timeout_seconds=15, + batch_timeout_seconds=15, + max_summary_chars=1_000, + max_evidence_items=8, + max_result_bytes=128_000, + ), + ), + ) + child_provider = ScriptedProvider( + ( + _tool_response( + ToolCall( + id="artifact-edit", + name="edit_file", + arguments={ + "path": "src/app.py", + "old_text": "'base'", + "new_text": "'adopted'", + "expected_sha256": hashlib.sha256(before).hexdigest(), + "reason": "Exercise candidate modification.", + }, + ) + ), + _tool_response( + ToolCall( + id="artifact-write", + name="write_file", + arguments={ + "path": "src/new.py", + "content": "NEW = True\n", + "reason": "Exercise candidate addition.", + }, + ) + ), + _stop_response("Implementation complete."), + ) + ) + store = WorktreeStateStore(profile) + git = WorktreeGit(profile) + manager = WorktreeManager( + profile, + git=git, + store=store, + id_factory=lambda: "artifact-lease", + ) + runner = WorktreeImplementationRunner( + profile, + manager=manager, + finalizer=WorktreeFinalizer( + snapshotter=CandidateSnapshotter( + profile, + store=store, + blob_reader=git, + ), + cleaner=manager, + ), + provider_factory=_ProviderFactory(child_provider), + tool_factory=_ToolFactory(), + id_factory=iter(("artifact-child", "artifact-candidate")).__next__, + ) + delegated = await DelegateImplementationTool(runner).execute( + ToolCall( + id="artifact-delegation", + name="delegate_implementation", + arguments={ + "task": "Change VALUE and add src/new.py.", + "reason": "Verify the installed Worktree implementation flow.", + }, + ) + ) + payload = json.loads(delegated.content) + assert delegated.is_error is False + assert payload["snapshot_status"] == "ready" + assert payload["cleanup_status"] == "removed" + assert payload["candidate"]["candidate_id"] == "artifact-candidate" + assert (repository / "src" / "app.py").read_bytes() == before + assert not (repository / "src" / "new.py").exists() + assert _git_output(repository, "status", "--porcelain") == b"" + + service = CandidateAdoptionService(profile, store=store, git=git) + await service.preview("artifact-candidate") + adopted = await service.adopt("artifact-candidate") + assert adopted.status is AdoptionStatus.APPLIED + assert (repository / "src" / "app.py").read_bytes() == b"VALUE = 'adopted'\n" + assert (repository / "src" / "new.py").read_bytes() == b"NEW = True\n" + assert await git.changed_paths() == ("src/app.py", "src/new.py") + + +def _git(repository: Path, *arguments: str) -> None: + subprocess.run( + ("git", "-C", str(repository), *arguments), + check=True, + stdin=subprocess.DEVNULL, + capture_output=True, + shell=False, + ) + + +def _git_output(repository: Path, *arguments: str) -> bytes: + return subprocess.run( + ("git", "-C", str(repository), *arguments), + check=True, + stdin=subprocess.DEVNULL, + capture_output=True, + shell=False, + ).stdout + + +if __name__ == "__main__": + asyncio.run(verify_worktree_artifact()) diff --git a/uv.lock b/uv.lock index a44bd0b..3665e8f 100644 --- a/uv.lock +++ b/uv.lock @@ -360,7 +360,7 @@ wheels = [ [[package]] name = "mini-code-agent" -version = "0.15.0a0" +version = "0.16.0a0" source = { editable = "." } dependencies = [ { name = "defusedxml" }, From 2310a489fcda8c5f753c0527a2eb9399dd2aa3e8 Mon Sep 17 00:00:00 2001 From: JayLin Date: Thu, 2 Jul 2026 11:08:29 +0800 Subject: [PATCH 12/12] test: pin worktree line ending conversion --- tests/integration/test_worktree_materialization.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/test_worktree_materialization.py b/tests/integration/test_worktree_materialization.py index 37c8a3e..2267681 100644 --- a/tests/integration/test_worktree_materialization.py +++ b/tests/integration/test_worktree_materialization.py @@ -42,6 +42,7 @@ async def test_real_no_checkout_lease_materializes_only_tracked_index( _git(repository, "init") _git(repository, "config", "user.email", "agent@example.invalid") _git(repository, "config", "user.name", "Agent Test") + _git(repository, "config", "core.autocrlf", "true") (repository / ".gitignore").write_text(".env\n.venv/\ncache/\n", encoding="utf-8") (repository / "src").mkdir() (repository / "src" / "app.py").write_bytes(b"print('tracked')\r\n")