diff --git a/docs/v7next/LEDGER_CORRECTIONS.md b/docs/v7next/LEDGER_CORRECTIONS.md index 36fd1428c..e9bf21ed6 100644 --- a/docs/v7next/LEDGER_CORRECTIONS.md +++ b/docs/v7next/LEDGER_CORRECTIONS.md @@ -147,3 +147,53 @@ with evidence, found lane by lane. Applied to the campaign's carried ledger at F re-sweep loop (65b5d19f), so one live child yields two token-less sweep calls instead of one; the pinned durable fact (the owner-stop sweep is token-less) is unchanged. +## From the D17 lane (base def681bd, 2026-08-30) +7. Runtime split rows 465-494 (`headless.py` -> `headless_status.py` (11) + + `workspace_patch_capture.py` (19), "verbatim extraction") — RE-PROVEN at + this tip: all 30 spans byte-identical between the reference leaves and + `git show HEAD:ouroboros/headless.py` (hardened transplant --check, ast/ + tokens/bytes all green, both leaves, exit 0). The facade differs from the + reference only by upstream residue drift (child_ref promotion machinery, + `TASK_COST_META_FIELDS`/`replace_atomic` import changes) — replayed from + tip bytes, 947 lines. +8. Test-split rows for `tests/test_workspace_executor.py` -> + `test_workspace_executor_services.py` ("verbatim") — BYTE-FALSIFIED as a + copy source for exactly two functions, transform still valid: upstream + 06339bb7 ("fix: preserve service readiness truth") rewrote + `test_executor_local_service_lifecycle_hides_private_snapshot` (the READY + marker is now planted before a 25k log suffix and asserted scanned) and + upstream a849c9a6 ("fix: preserve executor probe uncertainty") extended + `test_executor_service_status_and_durable_record_redact_secret_like_args` + (adds the `'"readiness"' not in durable_text` clause). Both re-emitted + from tip giant bytes; the other 26 moved wexec spans are byte-identical. +9. Reference residual `tests/test_headless_cli.py` and sibling + `test_headless_workspace_shell.py` carry OTHER domains' v7 spellings + inside 9 moved/kept spans (`_run_shell_safety_check(registry, ...)` typed + result + `core_file_tools._repo_read` — D04/D05 split; `queue.init(path)` + 1-arg signature and `supervisor.state.QUEUE_SNAPSHOT_PATH` — D08/D33). + On this tree those leaves/signatures do not exist; per §5.3-Δ item 2 every + such span was reverse-mapped to the upstream spelling keyed to + `git show HEAD:tests/test_headless_cli.py` (upstream: string-returning + `registry._run_shell_safety_check`, module-binding `_repo_read`, + `queue.init(path, 600, 1800)`, `queue.QUEUE_SNAPSHOT_PATH`). These + adaptations return with their owning lanes, not with D17. +10. Thirteen upstream test functions written after the reference cutoff have + NO ledger rows (hcli: 4 task-api + 1 artifact-endpoint; wexec: 6 docker + stop/cleanup + 2 readiness). Placed by the split's own theme rule with + imports satisfied by the target headers (task_api×4, task_artifacts×1, + docker×6, services×2 + one `SimpleNamespace` header import); the carried + ledger needs rows minted for them at F5. Placement is disclosed, not + ledger-derived. +11. Row evidence `tests/test_headless_extraction.py` (rows 465-494): the + reference pin imports `ouroboros.tool_module_inventory` (a D04-family v7 + leaf absent from this tree); the transplanted pin keeps every clause that + types against THIS tree and replaces the frozen-tool-inventory clause + with an oracle-SHA note — the clause returns with the tools lane. +12. `ouroboros/task_results.py` (upstream-hot, +555 lines drift): the ledger + assigns NO D17 runtime split to it, and the reference copy is + byte-identical to the merge base (zero v7 delta) — nothing to transplant, + upstream bytes stand. Same zero-v7-delta fact re-proven for all 14 + non-split D17 runtime modules (task_status, retention, coop_checkpoint, + projects_registry, project_dialogue, project_lease, project_naming, + project_sources, tools/project_journal, workspace_admission, + workspace_preflight, workspace_executor, workspace_patch_rules). diff --git a/ouroboros/headless.py b/ouroboros/headless.py index 3cc967f67..e8d4af85f 100644 --- a/ouroboros/headless.py +++ b/ouroboros/headless.py @@ -11,19 +11,53 @@ import logging import os import pathlib import shutil -import subprocess -import tempfile -import threading +import subprocess # noqa: F401 +import tempfile # noqa: F401 +import threading # noqa: F401 from datetime import datetime, timezone from hashlib import sha256 -from typing import Any, BinaryIO, Dict, Iterable, List, Optional, Sequence, Tuple +from typing import Any, BinaryIO, Dict, Iterable, List, Optional, Sequence, Tuple # noqa: F401 -from ouroboros.contracts.task_constraint import normalize_task_constraint +from ouroboros.contracts.task_constraint import normalize_task_constraint # noqa: F401 from ouroboros.post_task_checkpoint import project_replica_task_result_fields from ouroboros.task_results import ( cancellation_blocks_child_result, load_task_result, validate_task_id, write_task_result, ) from ouroboros.utils import atomic_write_json, utc_now_iso +from ouroboros.headless_status import ( # noqa: F401 + ARTIFACT_STATUS_FAILED, + ARTIFACT_STATUS_FINALIZING, + ARTIFACT_STATUS_MISSING, + ARTIFACT_STATUS_PENDING, + ARTIFACT_STATUS_READY, + ARTIFACT_STATUS_READY_NO_CHANGES, + ARTIFACT_STATUS_READY_WITH_CHANGES, + ARTIFACT_TERMINAL_STATUSES, + _ARTIFACT_LIFECYCLE_FIELDS, + _FINAL_STATUSES, + _LOCAL_READONLY_SUBAGENT_MODE, +) +from ouroboros.workspace_patch_capture import ( # noqa: F401 + SCRATCH_MANIFEST_NAME, + _GIT_UNBORN_HEAD, + _acting_constraint_from_task, + _append_git_output, + _empty_patch_manifest, + _git_bytes, + _git_empty_tree_oid, + _git_path_list, + _git_stdout, + _head_reflog_exists, + _looks_like_git_oid, + _preflight_head_from_task, + _preflight_head_present, + _untracked_blob_exclude_reason, + _workspace_patch_base, + _write_patch_separator, + build_workspace_patch, + untracked_capture_veto_reason, + write_workspace_patch_artifacts, +) log = logging.getLogger(__name__) @@ -31,38 +65,8 @@ log = logging.getLogger(__name__) HEADLESS_TASKS_DIR = pathlib.Path("state") / "headless_tasks" ARTIFACTS_DIR = pathlib.Path("task_results") / "artifacts" TASK_DRIVES_DIR = pathlib.Path("task_drives") -ARTIFACT_STATUS_PENDING = "pending" -ARTIFACT_STATUS_FINALIZING = "finalizing" -ARTIFACT_STATUS_READY = "ready" -ARTIFACT_STATUS_READY_WITH_CHANGES = "ready_with_changes" -ARTIFACT_STATUS_READY_NO_CHANGES = "ready_no_changes" -ARTIFACT_STATUS_MISSING = "missing" -ARTIFACT_STATUS_FAILED = "failed" -ARTIFACT_TERMINAL_STATUSES = { - ARTIFACT_STATUS_READY, - ARTIFACT_STATUS_READY_WITH_CHANGES, - ARTIFACT_STATUS_READY_NO_CHANGES, - ARTIFACT_STATUS_MISSING, - ARTIFACT_STATUS_FAILED, -} -# Mirrors task_status.SETTLED_STATUSES; a module-level import would close the -# headless → task_status → outcomes → headless cycle, and the smoke test below -# pins equality so the literal cannot drift from the SSOT. -_FINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "rejected_duplicate"}) - -# Mirrors tool_capabilities.LOCAL_READONLY_SUBAGENT_MODE; a module-level import would risk -# an import cycle (same rationale as _FINAL_STATUSES above), and the smoke test pins equality -# so the literal cannot drift from this SSOT — the kind of re-derivation drift that stranded -# the reaper's artifact finalization before task_is_readonly_subagent consolidated the gate. -_LOCAL_READONLY_SUBAGENT_MODE = "local_readonly_subagent" -_ARTIFACT_LIFECYCLE_FIELDS = { - "artifact_status", - "artifact_error", - "artifact_bundle", - "artifact_finalized_at", -} # The PURE patch/snapshot eligibility rules (env/cache dirs, junk artifacts, # incidental lockfiles, credential-shaped names) live in their own module (size # gate); re-exported here (same objects) because project_sources, coop_checkpoint @@ -84,14 +88,6 @@ from ouroboros.workspace_patch_rules import ( # noqa: F401 _sensitive_untracked_reason, ) -# v6.52.2: the task-scoped manifest of {ABSOLUTE_path: sha256} fingerprints the agent declared via -# run_command/run_script `scratch=[...]` (ephemeral verification files). The patch capture below -# EXCLUDES a matching untracked path ONLY while its current content still matches the recorded sha -# (so a later real file at the same path is not dropped). SSOT for the name; ouroboros.artifacts -# imports this (headless is the lower-level module). -SCRATCH_MANIFEST_NAME = ".scratch_manifest.json" -_GIT_UNBORN_HEAD = "(unborn)" - def task_state_dir(drive_root: pathlib.Path, task_id: str) -> pathlib.Path: return pathlib.Path(drive_root) / HEADLESS_TASKS_DIR / validate_task_id(task_id) @@ -844,265 +840,6 @@ def finalize_task_artifacts(parent_drive_root: pathlib.Path, task: Dict[str, Any return artifacts -def build_workspace_patch(workspace_root: pathlib.Path) -> str: - """Return a git patch for tracked changes plus untracked files.""" - - with tempfile.TemporaryDirectory() as tmp: - artifacts, manifest = write_workspace_patch_artifacts( - pathlib.Path(workspace_root), - pathlib.Path(tmp), - task={}, - ) - if manifest.get("status") == ARTIFACT_STATUS_FAILED: - return "" - for artifact in artifacts: - if artifact.get("kind") == "workspace_patch": - path = pathlib.Path(str(artifact.get("path") or "")) - return path.read_text(encoding="utf-8") if path.is_file() else "" - return "" - - -def write_workspace_patch_artifacts( - workspace_root: pathlib.Path, - artifact_dir: pathlib.Path, - *, - task: Dict[str, Any], -) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: - """Stream workspace patch and manifest artifacts into ``artifact_dir``.""" - - root = pathlib.Path(workspace_root).resolve(strict=False) - artifact_dir.mkdir(parents=True, exist_ok=True) - patch_path = artifact_dir / "workspace.patch" - manifest_path = artifact_dir / "workspace_patch.json" - errors: List[Dict[str, Any]] = [] - diagnostics: List[Dict[str, Any]] = [] - excluded: List[Dict[str, str]] = [] - tracked_excluded: List[Dict[str, str]] = [] - sensitive: List[Dict[str, str]] = [] - included_untracked: List[str] = [] - acting_constraint = _acting_constraint_from_task(task) - task_base_sha = str(acting_constraint.base_sha or "").strip() if acting_constraint else "" - preflight_head = _preflight_head_from_task(task) - if not task_base_sha and not preflight_head and _preflight_head_present(task): - preflight_head = _GIT_UNBORN_HEAD - base_ref, base_head, base_is_empty_tree = _workspace_patch_base( - root, - errors, - expected_base_sha=task_base_sha or preflight_head, - ) - changed_tracked = _git_path_list( - ["git", "diff", "--name-only", "-z", "--no-ext-diff", "--no-color", base_ref, "--"], - root, - errors, - ) - diffstat = "" - untracked = _git_path_list(["git", "ls-files", "-z", "--others", "--exclude-standard"], root, errors) - # v6.52.2: exclude declared ephemeral scratch (run_command/run_script `scratch=[...]`) so a - # throwaway verification file the agent forgot to delete never leaks into the workspace patch. - # The manifest stores {abs_path: sha256}; a file is excluded ONLY while its CURRENT content - # still matches the recorded scratch sha — so a LATER real file written to the same path - # (different content) is NOT dropped. Empty/absent/mismatched => included (no regression). - scratch_sha_by_rel: dict = {} - scratch_sha_by_abs: dict = {} - try: - _scratch_map = json.loads((artifact_dir / SCRATCH_MANIFEST_NAME).read_text(encoding="utf-8")).get("scratch") - if isinstance(_scratch_map, dict): - for _abs, _sha in _scratch_map.items(): - try: - _resolved = pathlib.Path(str(_abs)).resolve(strict=False) - scratch_sha_by_abs[os.path.normcase(str(_resolved))] = str(_sha) - scratch_sha_by_rel[_resolved.relative_to(root).as_posix()] = str(_sha) - except Exception: - continue - except Exception: - scratch_sha_by_rel = {} - scratch_sha_by_abs = {} - for rel in untracked: - _want_sha = scratch_sha_by_rel.get(rel) or scratch_sha_by_abs.get(os.path.normcase(str((root / rel).resolve(strict=False)))) - if _want_sha: - try: - _cur_sha = sha256((root / rel).read_bytes()).hexdigest() - except OSError: - _cur_sha = None - if _cur_sha == _want_sha: - excluded.append({"path": rel, "reason": "declared ephemeral scratch (v6.52.2)"}) - continue - sensitive_reason = _sensitive_untracked_reason(rel) - if sensitive_reason: - sensitive.append({"path": rel, "reason": sensitive_reason}) - continue - reason = _patch_exclude_reason(rel) - if reason: - excluded.append({"path": rel, "reason": reason}) - continue - blob_reason = _untracked_blob_exclude_reason(root, rel) - if blob_reason: - excluded.append({"path": rel, "reason": blob_reason}) - continue - included_untracked.append(rel) - incidental_lock_excludes = _incidental_lockfile_excludes([*changed_tracked, *included_untracked]) - if incidental_lock_excludes: - kept_untracked: List[str] = [] - for rel in included_untracked: - if rel in incidental_lock_excludes: - excluded.append({"path": rel, "reason": "incidental lockfile without sibling manifest change"}) - else: - kept_untracked.append(rel) - included_untracked = kept_untracked - if sensitive: - errors.append({ - "type": "sensitive_untracked_files", - "message": "untracked sensitive-looking files are not included in workspace patch", - "paths": [item["path"] for item in sensitive], - }) - - hasher = sha256() - total_size = 0 - with patch_path.open("wb") as fh: - if not errors: - tracked_lock_excludes = sorted(set(changed_tracked) & incidental_lock_excludes) - tracked_pathspec = ["--"] - if tracked_lock_excludes: - tracked_pathspec += ["."] + [f":(exclude){rel}" for rel in tracked_lock_excludes] - for rel in tracked_lock_excludes: - tracked_excluded.append({"path": rel, "reason": "incidental lockfile without sibling manifest change"}) - diffstat = _git_stdout( - ["git", "diff", "--stat", "--no-ext-diff", "--no-color", base_ref, *tracked_pathspec], - root, - allow_rc={0}, - errors=errors, - ) - total_size += _append_git_output( - ["git", "diff", "--binary", "--no-ext-diff", "--no-color", base_ref, *tracked_pathspec], - root, - fh, - hasher, - allow_rc={0}, - errors=errors, - diagnostics=diagnostics, - ) - for rel in included_untracked: - if total_size: - total_size += _write_patch_separator(fh, hasher) - total_size += _append_git_output( - ["git", "diff", "--no-index", "--binary", "--no-ext-diff", "--no-color", "--", os.devnull, rel], - root, - fh, - hasher, - allow_rc={0, 1}, - errors=errors, - diagnostics=diagnostics, - ) - if errors: - try: - patch_path.unlink() - except OSError: - pass - total_size = 0 - digest = "" - else: - digest = hasher.hexdigest() - - head_error: Dict[str, Any] | None = None - head_errors: List[Dict[str, Any]] = [] - current_head = _git_stdout(["git", "rev-parse", "--verify", "HEAD"], root, allow_rc={0}, errors=head_errors).strip() - # Q11: the moved-HEAD fail-closed tripwire applies ONLY to a child's private - # self_worktree, where a moved HEAD can only mean the worktree itself - # rewrote history under the patch (its base is always a real provisioned - # commit, never unborn). In a SHARED tree (external_workspace/genesis) the - # parent's own legitimate commits move HEAD too — enforcing it there failed - # every innocent in-flight sibling; shared-tree integrity is verified by the - # reverse-patch check in tools/subagent_integration (verified_shared_workspace), - # and base_sha stays the patch BASE so parent-committed work is still captured. - if task_base_sha and acting_constraint is not None and acting_constraint.surface == "self_worktree": - if not current_head: - errors.extend(head_errors) - head_error = { - "type": "workspace_head_unverified", - "message": "workspace HEAD could not be verified at artifact finalization", - "expected_head": base_head, - "current_head": "", - } - errors.append(head_error) - elif current_head != base_head: - head_error = { - "type": "workspace_head_changed", - "message": "workspace HEAD changed during task execution; patch artifact is invalid", - "expected_head": base_head, - "current_head": current_head, - } - errors.append(head_error) - if head_error: - try: - patch_path.unlink() - except OSError: - pass - total_size = 0 - digest = "" - - if errors: - status = ARTIFACT_STATUS_FAILED - elif total_size > 0: - status = ARTIFACT_STATUS_READY_WITH_CHANGES - else: - status = ARTIFACT_STATUS_READY_NO_CHANGES - try: - patch_path.unlink() - except OSError: - pass - digest = "" - manifest = { - "schema_version": 1, - "created_at": utc_now_iso(), - "status": status, - "workspace_root": str(root), - "patch_name": "workspace.patch", - "manifest_name": "workspace_patch.json", - "base_ref": base_ref, - "base_head": base_head, - "base_is_empty_tree": base_is_empty_tree, - "current_head": current_head or (_GIT_UNBORN_HEAD if base_is_empty_tree else ""), - "patch_size": total_size, - "sha256": digest, - "diffstat": diffstat, - "counts": { - "tracked_changed": len(changed_tracked), - "tracked_excluded": len(tracked_excluded), - "untracked_included": len(included_untracked), - "untracked_excluded": len(excluded), - "sensitive_blocked": len(sensitive), - }, - "tracked_changed": changed_tracked, - "tracked_excluded": tracked_excluded, - "untracked_included": included_untracked, - "untracked_excluded": excluded, - "sensitive_blocked": sensitive, - "exclude_rules_version": _PATCH_EXCLUDE_RULES_VERSION, - "diagnostics": diagnostics, - "errors": errors, - } - atomic_write_json(manifest_path, manifest, trailing_newline=True) - artifacts = [ - { - "kind": "workspace_patch_manifest", - "name": "workspace_patch.json", - "path": str(manifest_path), - "size": manifest_path.stat().st_size if manifest_path.exists() else 0, - "workspace_root": str(root), - } - ] - if status == ARTIFACT_STATUS_READY_WITH_CHANGES: - artifacts.insert(0, { - "kind": "workspace_patch", - "name": "workspace.patch", - "path": str(patch_path), - "size": total_size, - "sha256": digest, - "workspace_root": str(root), - }) - return artifacts, manifest - - def build_memory_export(child_drive_root: pathlib.Path, task: Dict[str, Any]) -> Dict[str, Any]: """Create an explicit export artifact without merging it into parent memory.""" @@ -1167,369 +904,6 @@ def _workspace_root_from_task(task: Dict[str, Any]) -> Optional[pathlib.Path]: return pathlib.Path(text) if text else None -def _git_stdout( - cmd: Sequence[str], - cwd: pathlib.Path, - *, - allow_rc: Iterable[int] = (0,), - errors: Optional[List[Dict[str, Any]]] = None, -) -> str: - """Text projection of ``_git_bytes`` (same rc/timeout/error handling).""" - return _git_bytes(cmd, cwd, allow_rc=allow_rc, errors=errors).decode("utf-8", errors="replace") - - -def _workspace_patch_base( - root: pathlib.Path, - errors: List[Dict[str, Any]], - *, - expected_base_sha: str = "", -) -> Tuple[str, str, bool]: - """Return the git tree-ish used as the patch baseline. - - A freshly initialized external workspace is a valid git worktree even when - it has no commits. In that state ``git diff HEAD`` fails, so patch capture - compares against Git's canonical empty tree instead of forcing adapters to - create a synthetic target commit in the user's workspace. - """ - - if expected_base_sha: - if expected_base_sha == _GIT_UNBORN_HEAD: - empty_tree = _git_empty_tree_oid(root, errors) - if empty_tree: - return empty_tree, _GIT_UNBORN_HEAD, True - return "HEAD", _GIT_UNBORN_HEAD, False - if not _looks_like_git_oid(expected_base_sha): - errors.append({ - "type": "workspace_base_sha_invalid", - "message": "acting subagent base_sha is not a git object id; refusing to build patch artifact", - "base_sha": expected_base_sha, - }) - return "HEAD", expected_base_sha, False - verify_errors: List[Dict[str, Any]] = [] - resolved = _git_stdout( - ["git", "rev-parse", "--verify", f"{expected_base_sha}^{{commit}}"], - root, - allow_rc={0}, - errors=verify_errors, - ).strip() - if not resolved: - errors.extend(verify_errors) - errors.append({ - "type": "workspace_base_sha_missing", - "message": "acting subagent base_sha is not available in workspace git history", - "base_sha": expected_base_sha, - }) - return expected_base_sha, expected_base_sha, False - return resolved, resolved, False - - head_errors: List[Dict[str, Any]] = [] - head = _git_stdout(["git", "rev-parse", "--verify", "HEAD"], root, allow_rc={0}, errors=head_errors).strip() - if head: - return head, head, False - - worktree_errors: List[Dict[str, Any]] = [] - inside = _git_stdout( - ["git", "rev-parse", "--is-inside-work-tree"], - root, - allow_rc={0}, - errors=worktree_errors, - ).strip() - if inside == "true" and _head_reflog_exists(root): - errors.extend(head_errors) - errors.append({ - "type": "git_invalid_head", - "command": ["git", "rev-parse", "--verify", "HEAD"], - "message": "HEAD could not be resolved but the repository has HEAD history; refusing to treat it as unborn", - }) - return "HEAD", "", False - if inside == "true": - empty_tree = _git_empty_tree_oid(root, errors) - if empty_tree: - return empty_tree, _GIT_UNBORN_HEAD, True - - errors.extend(head_errors or worktree_errors) - return "HEAD", "", False - - -def _git_empty_tree_oid(root: pathlib.Path, errors: List[Dict[str, Any]]) -> str: - try: - result = subprocess.run( - ["git", "hash-object", "-t", "tree", "--stdin"], - cwd=str(root), - input="", - capture_output=True, - text=True, - timeout=30, - ) - except Exception as exc: - errors.append({"type": "git_exception", "command": ["git", "hash-object", "-t", "tree", "--stdin"], "message": f"{type(exc).__name__}: {exc}"}) - return "" - if result.returncode != 0: - errors.append({ - "type": "git_error", - "command": ["git", "hash-object", "-t", "tree", "--stdin"], - "returncode": result.returncode, - "stderr": (result.stderr or "")[-2000:], - }) - return "" - return (result.stdout or "").strip() - - -def _head_reflog_exists(root: pathlib.Path) -> bool: - path_text = _git_stdout(["git", "rev-parse", "--git-path", "logs/HEAD"], root, allow_rc={0}).strip() - if not path_text: - return False - path = pathlib.Path(path_text) - if not path.is_absolute(): - path = root / path - try: - return path.is_file() and path.stat().st_size > 0 - except OSError: - return False - - -def _looks_like_git_oid(value: str) -> bool: - text = str(value or "").strip() - return 7 <= len(text) <= 64 and all(ch in "0123456789abcdefABCDEF" for ch in text) - - -def _git_path_list(cmd: Sequence[str], root: pathlib.Path, errors: Optional[List[Dict[str, Any]]] = None) -> List[str]: - output = _git_bytes(cmd, root, errors=errors) - if not output: - return [] - return [part.decode("utf-8", errors="replace") for part in output.split(b"\0") if part] - - -def _git_bytes( - cmd: Sequence[str], - cwd: pathlib.Path, - *, - allow_rc: Iterable[int] = (0,), - errors: Optional[List[Dict[str, Any]]] = None, -) -> bytes: - try: - result = subprocess.run( - list(cmd), - cwd=str(cwd), - capture_output=True, - timeout=30, - ) - except subprocess.TimeoutExpired: - if errors is not None: - errors.append({"type": "git_timeout", "command": list(cmd), "message": "git command timed out"}) - return b"" - except Exception as exc: - if errors is not None: - errors.append({"type": "git_exception", "command": list(cmd), "message": f"{type(exc).__name__}: {exc}"}) - return b"" - if result.returncode not in set(allow_rc): - if errors is not None: - errors.append({ - "type": "git_error", - "command": list(cmd), - "returncode": result.returncode, - "stderr": (result.stderr or b"").decode("utf-8", errors="replace")[-2000:], - }) - return b"" - return result.stdout or b"" - - -def _append_git_output( - cmd: Sequence[str], - cwd: pathlib.Path, - fh: BinaryIO, - hasher: Any, - *, - allow_rc: set[int], - errors: List[Dict[str, Any]], - diagnostics: List[Dict[str, Any]], -) -> int: - written_box = {"value": 0} - read_errors: List[str] = [] - try: - with tempfile.TemporaryFile() as stderr_fh: - proc = subprocess.Popen( - list(cmd), - cwd=str(cwd), - stdout=subprocess.PIPE, - stderr=stderr_fh, - ) - assert proc.stdout is not None - - def _reader() -> None: - try: - while True: - chunk = proc.stdout.read(1024 * 128) - if not chunk: - break - fh.write(chunk) - hasher.update(chunk) - written_box["value"] += len(chunk) - except Exception as exc: - read_errors.append(f"{type(exc).__name__}: {exc}") - - reader = threading.Thread(target=_reader, name="workspace-patch-git-stdout", daemon=True) - reader.start() - try: - proc.wait(timeout=30) - except subprocess.TimeoutExpired: - try: - proc.kill() - except Exception: - pass - try: - proc.wait(timeout=5) - except Exception: - pass - reader.join(timeout=5) - if reader.is_alive(): - errors.append({"type": "git_timeout", "command": list(cmd), "message": "git stdout reader timed out"}) - errors.append({"type": "git_timeout", "command": list(cmd), "message": "git command timed out"}) - return int(written_box["value"]) - reader.join(timeout=5) - if reader.is_alive(): - errors.append({"type": "git_timeout", "command": list(cmd), "message": "git stdout reader timed out"}) - for read_error in read_errors: - errors.append({"type": "git_exception", "command": list(cmd), "message": read_error}) - stderr_fh.seek(0) - stderr = stderr_fh.read() or b"" - except subprocess.TimeoutExpired: - try: - proc.kill() # type: ignore[possibly-undefined] - except Exception: - pass - errors.append({"type": "git_timeout", "command": list(cmd), "message": "git command timed out"}) - return int(written_box["value"]) - except Exception as exc: - errors.append({"type": "git_exception", "command": list(cmd), "message": f"{type(exc).__name__}: {exc}"}) - return int(written_box["value"]) - if proc.returncode not in allow_rc: - errors.append({ - "type": "git_error", - "command": list(cmd), - "returncode": proc.returncode, - "stderr": stderr.decode("utf-8", errors="replace")[-2000:], - }) - written = int(written_box["value"]) - diagnostics.append({"command": list(cmd), "returncode": proc.returncode, "bytes": written}) - return written - - -def _write_patch_separator(fh: BinaryIO, hasher: Any) -> int: - data = b"\n" - fh.write(data) - hasher.update(data) - return len(data) - - -def _untracked_blob_exclude_reason(root: pathlib.Path, rel: str) -> str: - """Reason to drop an untracked file from the workspace patch when it is a - build/runtime BINARY or exceeds the per-file size cap. Keeps real-usage - patches source-shaped without losing data (the file stays in the workspace - and is recorded under ``untracked_excluded``). On any git/stat failure the - file is INCLUDED (conservative — the main binary diff still applies).""" - - try: - size = (root / rel).lstat().st_size - except OSError: - return "" # unreadable/symlink races: include and let git decide - if size > _PATCH_MAX_UNTRACKED_FILE_BYTES: - return f"untracked file exceeds size cap ({size}B > {_PATCH_MAX_UNTRACKED_FILE_BYTES}B)" - numstat = _git_stdout( - ["git", "diff", "--no-index", "--numstat", "--no-ext-diff", "--no-color", "--", os.devnull, rel], - root, - allow_rc={0, 1}, - errors=None, - ) - first = numstat.strip().splitlines()[0] if numstat.strip() else "" - if first.startswith("-\t-"): - return "binary file" - return "" - - -def untracked_capture_veto_reason(root: pathlib.Path, rel: str) -> str: - """Why an untracked file must NOT ride into a workspace snapshot or patch. - - The delegated-run baseline snapshot - (``subagent_worktrees.provision_execution_snapshot``) asks the SAME three - checks, in the SAME order, that ``write_workspace_patch_artifacts`` applies - to untracked files: sensitive/credential-shaped names first, then the - static junk rules, then the binary/size veto. One combined predicate here so - the snapshot and the patch cannot drift apart about eligibility. - Returns the human-readable reason, or "" when the file is eligible. - """ - reason = _sensitive_untracked_reason(rel) - if reason: - return reason - reason = _patch_exclude_reason(rel) - if reason: - return reason - return _untracked_blob_exclude_reason(root, rel) - - -def _preflight_head_from_task(task: Dict[str, Any]) -> str: - meta = task.get("metadata") if isinstance(task.get("metadata"), dict) else {} - preflight = meta.get("workspace_preflight") if isinstance(meta.get("workspace_preflight"), dict) else {} - git = preflight.get("git") if isinstance(preflight.get("git"), dict) else {} - return str(git.get("head") or "") - - -def _preflight_head_present(task: Dict[str, Any]) -> bool: - meta = task.get("metadata") if isinstance(task.get("metadata"), dict) else {} - preflight = meta.get("workspace_preflight") if isinstance(meta.get("workspace_preflight"), dict) else {} - git = preflight.get("git") if isinstance(preflight.get("git"), dict) else {} - return "head" in git - - -def _acting_constraint_from_task(task: Dict[str, Any]): - """Normalized acting-subagent constraint carried by ``task``, or None.""" - raw = task.get("task_constraint") if isinstance(task.get("task_constraint"), dict) else {} - if not raw: - meta = task.get("metadata") if isinstance(task.get("metadata"), dict) else {} - raw = meta.get("task_constraint") if isinstance(meta.get("task_constraint"), dict) else {} - try: - constraint = normalize_task_constraint(raw) - except Exception: - return None - return constraint if constraint and constraint.mode == "acting_subagent" else None - - -def _empty_patch_manifest( - workspace_root: pathlib.Path, - *, - status: str, - errors: List[Dict[str, Any]], -) -> Dict[str, Any]: - return { - "schema_version": 1, - "created_at": utc_now_iso(), - "status": status, - "workspace_root": str(workspace_root), - "patch_name": "workspace.patch", - "manifest_name": "workspace_patch.json", - "base_ref": "", - "base_head": "", - "base_is_empty_tree": False, - "current_head": "", - "patch_size": 0, - "sha256": "", - "diffstat": "", - "counts": { - "tracked_changed": 0, - "untracked_included": 0, - "untracked_excluded": 0, - "sensitive_blocked": 0, - }, - "tracked_changed": [], - "untracked_included": [], - "untracked_excluded": [], - "sensitive_blocked": [], - "exclude_rules_version": _PATCH_EXCLUDE_RULES_VERSION, - "diagnostics": [], - "errors": errors, - } - - def _merge_artifacts( existing: List[Dict[str, Any]], new_items: List[Dict[str, Any]], diff --git a/ouroboros/headless_status.py b/ouroboros/headless_status.py new file mode 100644 index 000000000..2120fc691 --- /dev/null +++ b/ouroboros/headless_status.py @@ -0,0 +1,50 @@ +"""Artifact and task lifecycle vocabulary shared by the headless owners. + +The artifact-status values a task result may carry, the terminal subset the +pruners and the copy-back gate test against, the lifecycle fields a late child +copy-back must preserve, and the two literals headless mirrors instead of +importing (settled task statuses, the local-readonly subagent mode) because a +module-level import of their SSOT would close an import cycle. Constants only: +every consumer of this vocabulary owns its own behaviour. +""" + +from __future__ import annotations + + +ARTIFACT_STATUS_PENDING = "pending" +ARTIFACT_STATUS_FINALIZING = "finalizing" +ARTIFACT_STATUS_READY = "ready" +ARTIFACT_STATUS_READY_WITH_CHANGES = "ready_with_changes" +ARTIFACT_STATUS_READY_NO_CHANGES = "ready_no_changes" +ARTIFACT_STATUS_MISSING = "missing" +ARTIFACT_STATUS_FAILED = "failed" + + +ARTIFACT_TERMINAL_STATUSES = { + ARTIFACT_STATUS_READY, + ARTIFACT_STATUS_READY_WITH_CHANGES, + ARTIFACT_STATUS_READY_NO_CHANGES, + ARTIFACT_STATUS_MISSING, + ARTIFACT_STATUS_FAILED, +} + + +# Mirrors task_status.SETTLED_STATUSES; a module-level import would close the +# headless → task_status → outcomes → headless cycle, and the smoke test below +# pins equality so the literal cannot drift from the SSOT. +_FINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "rejected_duplicate"}) + + +# Mirrors tool_capabilities.LOCAL_READONLY_SUBAGENT_MODE; a module-level import would risk +# an import cycle (same rationale as _FINAL_STATUSES above), and the smoke test pins equality +# so the literal cannot drift from this SSOT — the kind of re-derivation drift that stranded +# the reaper's artifact finalization before task_is_readonly_subagent consolidated the gate. +_LOCAL_READONLY_SUBAGENT_MODE = "local_readonly_subagent" + + +_ARTIFACT_LIFECYCLE_FIELDS = { + "artifact_status", + "artifact_error", + "artifact_bundle", + "artifact_finalized_at", +} diff --git a/ouroboros/size_ratchet_manifest.py b/ouroboros/size_ratchet_manifest.py index 99d09dd15..9029836bf 100644 --- a/ouroboros/size_ratchet_manifest.py +++ b/ouroboros/size_ratchet_manifest.py @@ -19,7 +19,6 @@ GIANT_PATHS = ( "supervisor/events.py", "supervisor/git_ops.py", "supervisor/workers.py", - "tests/test_agent_task_pipeline.py", "tests/test_cancel_intents_phase_a.py", "tests/test_claudexor_owned_daemon.py", "tests/test_delegated_subagent_transport.py", @@ -29,7 +28,6 @@ GIANT_PATHS = ( "tests/test_extensions_api.py", "tests/test_git_ops_recovery.py", "tests/test_git_review_pipeline.py", - "tests/test_headless_cli.py", "tests/test_loop_misc.py", "tests/test_model_slot_role_model.py", "tests/test_osworld_cu_bridge.py", @@ -46,7 +44,6 @@ GIANT_PATHS = ( "tests/test_task_status_flow.py", "tests/test_tool_capabilities.py", "tests/test_ui_smoke_playwright.py", - "tests/test_workspace_executor.py", "web/modules/chat.js", "web/tests/harness_accounts.test.js", ) diff --git a/ouroboros/workspace_patch_capture.py b/ouroboros/workspace_patch_capture.py new file mode 100644 index 000000000..950058dc0 --- /dev/null +++ b/ouroboros/workspace_patch_capture.py @@ -0,0 +1,668 @@ +"""Workspace patch capture: the patch artifact, its manifest, and its git plumbing. + +Owns the streamed `workspace.patch` and `workspace_patch.json` pair — patch +baseline resolution (including the unborn-HEAD empty-tree case and the acting +subagent `base_sha` binding), the bounded git process helpers the capture runs +on, the declared-scratch and untracked eligibility filtering, the moved-HEAD +tripwire for a private self worktree, and the empty manifest a failed +finalization falls back to. The static eligibility rules live in +``workspace_patch_rules``; the task-drive, child-result and artifact +finalization owners stay with ``headless``. +""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import tempfile +import threading +from hashlib import sha256 +from typing import Any, BinaryIO, Dict, Iterable, List, Optional, Sequence, Tuple + +from ouroboros.contracts.task_constraint import normalize_task_constraint +from ouroboros.headless_status import ( + ARTIFACT_STATUS_FAILED, + ARTIFACT_STATUS_READY_NO_CHANGES, + ARTIFACT_STATUS_READY_WITH_CHANGES, +) +from ouroboros.utils import atomic_write_json, utc_now_iso +from ouroboros.workspace_patch_rules import ( + _PATCH_EXCLUDE_RULES_VERSION, + _PATCH_MAX_UNTRACKED_FILE_BYTES, + _incidental_lockfile_excludes, + _patch_exclude_reason, + _sensitive_untracked_reason, +) + + +# v6.52.2: the task-scoped manifest of {ABSOLUTE_path: sha256} fingerprints the agent declared via +# run_command/run_script `scratch=[...]` (ephemeral verification files). The patch capture below +# EXCLUDES a matching untracked path ONLY while its current content still matches the recorded sha +# (so a later real file at the same path is not dropped). SSOT for the name; ouroboros.artifacts +# imports this (headless is the lower-level module). +SCRATCH_MANIFEST_NAME = ".scratch_manifest.json" +_GIT_UNBORN_HEAD = "(unborn)" + + +def build_workspace_patch(workspace_root: pathlib.Path) -> str: + """Return a git patch for tracked changes plus untracked files.""" + + with tempfile.TemporaryDirectory() as tmp: + artifacts, manifest = write_workspace_patch_artifacts( + pathlib.Path(workspace_root), + pathlib.Path(tmp), + task={}, + ) + if manifest.get("status") == ARTIFACT_STATUS_FAILED: + return "" + for artifact in artifacts: + if artifact.get("kind") == "workspace_patch": + path = pathlib.Path(str(artifact.get("path") or "")) + return path.read_text(encoding="utf-8") if path.is_file() else "" + return "" + + +def write_workspace_patch_artifacts( + workspace_root: pathlib.Path, + artifact_dir: pathlib.Path, + *, + task: Dict[str, Any], +) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """Stream workspace patch and manifest artifacts into ``artifact_dir``.""" + + root = pathlib.Path(workspace_root).resolve(strict=False) + artifact_dir.mkdir(parents=True, exist_ok=True) + patch_path = artifact_dir / "workspace.patch" + manifest_path = artifact_dir / "workspace_patch.json" + errors: List[Dict[str, Any]] = [] + diagnostics: List[Dict[str, Any]] = [] + excluded: List[Dict[str, str]] = [] + tracked_excluded: List[Dict[str, str]] = [] + sensitive: List[Dict[str, str]] = [] + included_untracked: List[str] = [] + acting_constraint = _acting_constraint_from_task(task) + task_base_sha = str(acting_constraint.base_sha or "").strip() if acting_constraint else "" + preflight_head = _preflight_head_from_task(task) + if not task_base_sha and not preflight_head and _preflight_head_present(task): + preflight_head = _GIT_UNBORN_HEAD + base_ref, base_head, base_is_empty_tree = _workspace_patch_base( + root, + errors, + expected_base_sha=task_base_sha or preflight_head, + ) + changed_tracked = _git_path_list( + ["git", "diff", "--name-only", "-z", "--no-ext-diff", "--no-color", base_ref, "--"], + root, + errors, + ) + diffstat = "" + untracked = _git_path_list(["git", "ls-files", "-z", "--others", "--exclude-standard"], root, errors) + # v6.52.2: exclude declared ephemeral scratch (run_command/run_script `scratch=[...]`) so a + # throwaway verification file the agent forgot to delete never leaks into the workspace patch. + # The manifest stores {abs_path: sha256}; a file is excluded ONLY while its CURRENT content + # still matches the recorded scratch sha — so a LATER real file written to the same path + # (different content) is NOT dropped. Empty/absent/mismatched => included (no regression). + scratch_sha_by_rel: dict = {} + scratch_sha_by_abs: dict = {} + try: + _scratch_map = json.loads((artifact_dir / SCRATCH_MANIFEST_NAME).read_text(encoding="utf-8")).get("scratch") + if isinstance(_scratch_map, dict): + for _abs, _sha in _scratch_map.items(): + try: + _resolved = pathlib.Path(str(_abs)).resolve(strict=False) + scratch_sha_by_abs[os.path.normcase(str(_resolved))] = str(_sha) + scratch_sha_by_rel[_resolved.relative_to(root).as_posix()] = str(_sha) + except Exception: + continue + except Exception: + scratch_sha_by_rel = {} + scratch_sha_by_abs = {} + for rel in untracked: + _want_sha = scratch_sha_by_rel.get(rel) or scratch_sha_by_abs.get(os.path.normcase(str((root / rel).resolve(strict=False)))) + if _want_sha: + try: + _cur_sha = sha256((root / rel).read_bytes()).hexdigest() + except OSError: + _cur_sha = None + if _cur_sha == _want_sha: + excluded.append({"path": rel, "reason": "declared ephemeral scratch (v6.52.2)"}) + continue + sensitive_reason = _sensitive_untracked_reason(rel) + if sensitive_reason: + sensitive.append({"path": rel, "reason": sensitive_reason}) + continue + reason = _patch_exclude_reason(rel) + if reason: + excluded.append({"path": rel, "reason": reason}) + continue + blob_reason = _untracked_blob_exclude_reason(root, rel) + if blob_reason: + excluded.append({"path": rel, "reason": blob_reason}) + continue + included_untracked.append(rel) + incidental_lock_excludes = _incidental_lockfile_excludes([*changed_tracked, *included_untracked]) + if incidental_lock_excludes: + kept_untracked: List[str] = [] + for rel in included_untracked: + if rel in incidental_lock_excludes: + excluded.append({"path": rel, "reason": "incidental lockfile without sibling manifest change"}) + else: + kept_untracked.append(rel) + included_untracked = kept_untracked + if sensitive: + errors.append({ + "type": "sensitive_untracked_files", + "message": "untracked sensitive-looking files are not included in workspace patch", + "paths": [item["path"] for item in sensitive], + }) + + hasher = sha256() + total_size = 0 + with patch_path.open("wb") as fh: + if not errors: + tracked_lock_excludes = sorted(set(changed_tracked) & incidental_lock_excludes) + tracked_pathspec = ["--"] + if tracked_lock_excludes: + tracked_pathspec += ["."] + [f":(exclude){rel}" for rel in tracked_lock_excludes] + for rel in tracked_lock_excludes: + tracked_excluded.append({"path": rel, "reason": "incidental lockfile without sibling manifest change"}) + diffstat = _git_stdout( + ["git", "diff", "--stat", "--no-ext-diff", "--no-color", base_ref, *tracked_pathspec], + root, + allow_rc={0}, + errors=errors, + ) + total_size += _append_git_output( + ["git", "diff", "--binary", "--no-ext-diff", "--no-color", base_ref, *tracked_pathspec], + root, + fh, + hasher, + allow_rc={0}, + errors=errors, + diagnostics=diagnostics, + ) + for rel in included_untracked: + if total_size: + total_size += _write_patch_separator(fh, hasher) + total_size += _append_git_output( + ["git", "diff", "--no-index", "--binary", "--no-ext-diff", "--no-color", "--", os.devnull, rel], + root, + fh, + hasher, + allow_rc={0, 1}, + errors=errors, + diagnostics=diagnostics, + ) + if errors: + try: + patch_path.unlink() + except OSError: + pass + total_size = 0 + digest = "" + else: + digest = hasher.hexdigest() + + head_error: Dict[str, Any] | None = None + head_errors: List[Dict[str, Any]] = [] + current_head = _git_stdout(["git", "rev-parse", "--verify", "HEAD"], root, allow_rc={0}, errors=head_errors).strip() + # Q11: the moved-HEAD fail-closed tripwire applies ONLY to a child's private + # self_worktree, where a moved HEAD can only mean the worktree itself + # rewrote history under the patch (its base is always a real provisioned + # commit, never unborn). In a SHARED tree (external_workspace/genesis) the + # parent's own legitimate commits move HEAD too — enforcing it there failed + # every innocent in-flight sibling; shared-tree integrity is verified by the + # reverse-patch check in tools/subagent_integration (verified_shared_workspace), + # and base_sha stays the patch BASE so parent-committed work is still captured. + if task_base_sha and acting_constraint is not None and acting_constraint.surface == "self_worktree": + if not current_head: + errors.extend(head_errors) + head_error = { + "type": "workspace_head_unverified", + "message": "workspace HEAD could not be verified at artifact finalization", + "expected_head": base_head, + "current_head": "", + } + errors.append(head_error) + elif current_head != base_head: + head_error = { + "type": "workspace_head_changed", + "message": "workspace HEAD changed during task execution; patch artifact is invalid", + "expected_head": base_head, + "current_head": current_head, + } + errors.append(head_error) + if head_error: + try: + patch_path.unlink() + except OSError: + pass + total_size = 0 + digest = "" + + if errors: + status = ARTIFACT_STATUS_FAILED + elif total_size > 0: + status = ARTIFACT_STATUS_READY_WITH_CHANGES + else: + status = ARTIFACT_STATUS_READY_NO_CHANGES + try: + patch_path.unlink() + except OSError: + pass + digest = "" + manifest = { + "schema_version": 1, + "created_at": utc_now_iso(), + "status": status, + "workspace_root": str(root), + "patch_name": "workspace.patch", + "manifest_name": "workspace_patch.json", + "base_ref": base_ref, + "base_head": base_head, + "base_is_empty_tree": base_is_empty_tree, + "current_head": current_head or (_GIT_UNBORN_HEAD if base_is_empty_tree else ""), + "patch_size": total_size, + "sha256": digest, + "diffstat": diffstat, + "counts": { + "tracked_changed": len(changed_tracked), + "tracked_excluded": len(tracked_excluded), + "untracked_included": len(included_untracked), + "untracked_excluded": len(excluded), + "sensitive_blocked": len(sensitive), + }, + "tracked_changed": changed_tracked, + "tracked_excluded": tracked_excluded, + "untracked_included": included_untracked, + "untracked_excluded": excluded, + "sensitive_blocked": sensitive, + "exclude_rules_version": _PATCH_EXCLUDE_RULES_VERSION, + "diagnostics": diagnostics, + "errors": errors, + } + atomic_write_json(manifest_path, manifest, trailing_newline=True) + artifacts = [ + { + "kind": "workspace_patch_manifest", + "name": "workspace_patch.json", + "path": str(manifest_path), + "size": manifest_path.stat().st_size if manifest_path.exists() else 0, + "workspace_root": str(root), + } + ] + if status == ARTIFACT_STATUS_READY_WITH_CHANGES: + artifacts.insert(0, { + "kind": "workspace_patch", + "name": "workspace.patch", + "path": str(patch_path), + "size": total_size, + "sha256": digest, + "workspace_root": str(root), + }) + return artifacts, manifest + + +def _git_stdout( + cmd: Sequence[str], + cwd: pathlib.Path, + *, + allow_rc: Iterable[int] = (0,), + errors: Optional[List[Dict[str, Any]]] = None, +) -> str: + """Text projection of ``_git_bytes`` (same rc/timeout/error handling).""" + return _git_bytes(cmd, cwd, allow_rc=allow_rc, errors=errors).decode("utf-8", errors="replace") + + +def _workspace_patch_base( + root: pathlib.Path, + errors: List[Dict[str, Any]], + *, + expected_base_sha: str = "", +) -> Tuple[str, str, bool]: + """Return the git tree-ish used as the patch baseline. + + A freshly initialized external workspace is a valid git worktree even when + it has no commits. In that state ``git diff HEAD`` fails, so patch capture + compares against Git's canonical empty tree instead of forcing adapters to + create a synthetic target commit in the user's workspace. + """ + + if expected_base_sha: + if expected_base_sha == _GIT_UNBORN_HEAD: + empty_tree = _git_empty_tree_oid(root, errors) + if empty_tree: + return empty_tree, _GIT_UNBORN_HEAD, True + return "HEAD", _GIT_UNBORN_HEAD, False + if not _looks_like_git_oid(expected_base_sha): + errors.append({ + "type": "workspace_base_sha_invalid", + "message": "acting subagent base_sha is not a git object id; refusing to build patch artifact", + "base_sha": expected_base_sha, + }) + return "HEAD", expected_base_sha, False + verify_errors: List[Dict[str, Any]] = [] + resolved = _git_stdout( + ["git", "rev-parse", "--verify", f"{expected_base_sha}^{{commit}}"], + root, + allow_rc={0}, + errors=verify_errors, + ).strip() + if not resolved: + errors.extend(verify_errors) + errors.append({ + "type": "workspace_base_sha_missing", + "message": "acting subagent base_sha is not available in workspace git history", + "base_sha": expected_base_sha, + }) + return expected_base_sha, expected_base_sha, False + return resolved, resolved, False + + head_errors: List[Dict[str, Any]] = [] + head = _git_stdout(["git", "rev-parse", "--verify", "HEAD"], root, allow_rc={0}, errors=head_errors).strip() + if head: + return head, head, False + + worktree_errors: List[Dict[str, Any]] = [] + inside = _git_stdout( + ["git", "rev-parse", "--is-inside-work-tree"], + root, + allow_rc={0}, + errors=worktree_errors, + ).strip() + if inside == "true" and _head_reflog_exists(root): + errors.extend(head_errors) + errors.append({ + "type": "git_invalid_head", + "command": ["git", "rev-parse", "--verify", "HEAD"], + "message": "HEAD could not be resolved but the repository has HEAD history; refusing to treat it as unborn", + }) + return "HEAD", "", False + if inside == "true": + empty_tree = _git_empty_tree_oid(root, errors) + if empty_tree: + return empty_tree, _GIT_UNBORN_HEAD, True + + errors.extend(head_errors or worktree_errors) + return "HEAD", "", False + + +def _git_empty_tree_oid(root: pathlib.Path, errors: List[Dict[str, Any]]) -> str: + try: + result = subprocess.run( + ["git", "hash-object", "-t", "tree", "--stdin"], + cwd=str(root), + input="", + capture_output=True, + text=True, + timeout=30, + ) + except Exception as exc: + errors.append({"type": "git_exception", "command": ["git", "hash-object", "-t", "tree", "--stdin"], "message": f"{type(exc).__name__}: {exc}"}) + return "" + if result.returncode != 0: + errors.append({ + "type": "git_error", + "command": ["git", "hash-object", "-t", "tree", "--stdin"], + "returncode": result.returncode, + "stderr": (result.stderr or "")[-2000:], + }) + return "" + return (result.stdout or "").strip() + + +def _head_reflog_exists(root: pathlib.Path) -> bool: + path_text = _git_stdout(["git", "rev-parse", "--git-path", "logs/HEAD"], root, allow_rc={0}).strip() + if not path_text: + return False + path = pathlib.Path(path_text) + if not path.is_absolute(): + path = root / path + try: + return path.is_file() and path.stat().st_size > 0 + except OSError: + return False + + +def _looks_like_git_oid(value: str) -> bool: + text = str(value or "").strip() + return 7 <= len(text) <= 64 and all(ch in "0123456789abcdefABCDEF" for ch in text) + + +def _git_path_list(cmd: Sequence[str], root: pathlib.Path, errors: Optional[List[Dict[str, Any]]] = None) -> List[str]: + output = _git_bytes(cmd, root, errors=errors) + if not output: + return [] + return [part.decode("utf-8", errors="replace") for part in output.split(b"\0") if part] + + +def _git_bytes( + cmd: Sequence[str], + cwd: pathlib.Path, + *, + allow_rc: Iterable[int] = (0,), + errors: Optional[List[Dict[str, Any]]] = None, +) -> bytes: + try: + result = subprocess.run( + list(cmd), + cwd=str(cwd), + capture_output=True, + timeout=30, + ) + except subprocess.TimeoutExpired: + if errors is not None: + errors.append({"type": "git_timeout", "command": list(cmd), "message": "git command timed out"}) + return b"" + except Exception as exc: + if errors is not None: + errors.append({"type": "git_exception", "command": list(cmd), "message": f"{type(exc).__name__}: {exc}"}) + return b"" + if result.returncode not in set(allow_rc): + if errors is not None: + errors.append({ + "type": "git_error", + "command": list(cmd), + "returncode": result.returncode, + "stderr": (result.stderr or b"").decode("utf-8", errors="replace")[-2000:], + }) + return b"" + return result.stdout or b"" + + +def _append_git_output( + cmd: Sequence[str], + cwd: pathlib.Path, + fh: BinaryIO, + hasher: Any, + *, + allow_rc: set[int], + errors: List[Dict[str, Any]], + diagnostics: List[Dict[str, Any]], +) -> int: + written_box = {"value": 0} + read_errors: List[str] = [] + try: + with tempfile.TemporaryFile() as stderr_fh: + proc = subprocess.Popen( + list(cmd), + cwd=str(cwd), + stdout=subprocess.PIPE, + stderr=stderr_fh, + ) + assert proc.stdout is not None + + def _reader() -> None: + try: + while True: + chunk = proc.stdout.read(1024 * 128) + if not chunk: + break + fh.write(chunk) + hasher.update(chunk) + written_box["value"] += len(chunk) + except Exception as exc: + read_errors.append(f"{type(exc).__name__}: {exc}") + + reader = threading.Thread(target=_reader, name="workspace-patch-git-stdout", daemon=True) + reader.start() + try: + proc.wait(timeout=30) + except subprocess.TimeoutExpired: + try: + proc.kill() + except Exception: + pass + try: + proc.wait(timeout=5) + except Exception: + pass + reader.join(timeout=5) + if reader.is_alive(): + errors.append({"type": "git_timeout", "command": list(cmd), "message": "git stdout reader timed out"}) + errors.append({"type": "git_timeout", "command": list(cmd), "message": "git command timed out"}) + return int(written_box["value"]) + reader.join(timeout=5) + if reader.is_alive(): + errors.append({"type": "git_timeout", "command": list(cmd), "message": "git stdout reader timed out"}) + for read_error in read_errors: + errors.append({"type": "git_exception", "command": list(cmd), "message": read_error}) + stderr_fh.seek(0) + stderr = stderr_fh.read() or b"" + except subprocess.TimeoutExpired: + try: + proc.kill() # type: ignore[possibly-undefined] + except Exception: + pass + errors.append({"type": "git_timeout", "command": list(cmd), "message": "git command timed out"}) + return int(written_box["value"]) + except Exception as exc: + errors.append({"type": "git_exception", "command": list(cmd), "message": f"{type(exc).__name__}: {exc}"}) + return int(written_box["value"]) + if proc.returncode not in allow_rc: + errors.append({ + "type": "git_error", + "command": list(cmd), + "returncode": proc.returncode, + "stderr": stderr.decode("utf-8", errors="replace")[-2000:], + }) + written = int(written_box["value"]) + diagnostics.append({"command": list(cmd), "returncode": proc.returncode, "bytes": written}) + return written + + +def _write_patch_separator(fh: BinaryIO, hasher: Any) -> int: + data = b"\n" + fh.write(data) + hasher.update(data) + return len(data) + + +def _untracked_blob_exclude_reason(root: pathlib.Path, rel: str) -> str: + """Reason to drop an untracked file from the workspace patch when it is a + build/runtime BINARY or exceeds the per-file size cap. Keeps real-usage + patches source-shaped without losing data (the file stays in the workspace + and is recorded under ``untracked_excluded``). On any git/stat failure the + file is INCLUDED (conservative — the main binary diff still applies).""" + + try: + size = (root / rel).lstat().st_size + except OSError: + return "" # unreadable/symlink races: include and let git decide + if size > _PATCH_MAX_UNTRACKED_FILE_BYTES: + return f"untracked file exceeds size cap ({size}B > {_PATCH_MAX_UNTRACKED_FILE_BYTES}B)" + numstat = _git_stdout( + ["git", "diff", "--no-index", "--numstat", "--no-ext-diff", "--no-color", "--", os.devnull, rel], + root, + allow_rc={0, 1}, + errors=None, + ) + first = numstat.strip().splitlines()[0] if numstat.strip() else "" + if first.startswith("-\t-"): + return "binary file" + return "" + + +def untracked_capture_veto_reason(root: pathlib.Path, rel: str) -> str: + """Why an untracked file must NOT ride into a workspace snapshot or patch. + + The delegated-run baseline snapshot + (``subagent_worktrees.provision_execution_snapshot``) asks the SAME three + checks, in the SAME order, that ``write_workspace_patch_artifacts`` applies + to untracked files: sensitive/credential-shaped names first, then the + static junk rules, then the binary/size veto. One combined predicate here so + the snapshot and the patch cannot drift apart about eligibility. + Returns the human-readable reason, or "" when the file is eligible. + """ + reason = _sensitive_untracked_reason(rel) + if reason: + return reason + reason = _patch_exclude_reason(rel) + if reason: + return reason + return _untracked_blob_exclude_reason(root, rel) + + +def _preflight_head_from_task(task: Dict[str, Any]) -> str: + meta = task.get("metadata") if isinstance(task.get("metadata"), dict) else {} + preflight = meta.get("workspace_preflight") if isinstance(meta.get("workspace_preflight"), dict) else {} + git = preflight.get("git") if isinstance(preflight.get("git"), dict) else {} + return str(git.get("head") or "") + + +def _preflight_head_present(task: Dict[str, Any]) -> bool: + meta = task.get("metadata") if isinstance(task.get("metadata"), dict) else {} + preflight = meta.get("workspace_preflight") if isinstance(meta.get("workspace_preflight"), dict) else {} + git = preflight.get("git") if isinstance(preflight.get("git"), dict) else {} + return "head" in git + + +def _acting_constraint_from_task(task: Dict[str, Any]): + """Normalized acting-subagent constraint carried by ``task``, or None.""" + raw = task.get("task_constraint") if isinstance(task.get("task_constraint"), dict) else {} + if not raw: + meta = task.get("metadata") if isinstance(task.get("metadata"), dict) else {} + raw = meta.get("task_constraint") if isinstance(meta.get("task_constraint"), dict) else {} + try: + constraint = normalize_task_constraint(raw) + except Exception: + return None + return constraint if constraint and constraint.mode == "acting_subagent" else None + + +def _empty_patch_manifest( + workspace_root: pathlib.Path, + *, + status: str, + errors: List[Dict[str, Any]], +) -> Dict[str, Any]: + return { + "schema_version": 1, + "created_at": utc_now_iso(), + "status": status, + "workspace_root": str(workspace_root), + "patch_name": "workspace.patch", + "manifest_name": "workspace_patch.json", + "base_ref": "", + "base_head": "", + "base_is_empty_tree": False, + "current_head": "", + "patch_size": 0, + "sha256": "", + "diffstat": "", + "counts": { + "tracked_changed": 0, + "untracked_included": 0, + "untracked_excluded": 0, + "sensitive_blocked": 0, + }, + "tracked_changed": [], + "untracked_included": [], + "untracked_excluded": [], + "sensitive_blocked": [], + "exclude_rules_version": _PATCH_EXCLUDE_RULES_VERSION, + "diagnostics": [], + "errors": errors, + } diff --git a/tests/_headless_cli_shared.py b/tests/_headless_cli_shared.py new file mode 100644 index 000000000..d82ef61ca --- /dev/null +++ b/tests/_headless_cli_shared.py @@ -0,0 +1,35 @@ +"""Shared fixtures and helpers for the headless task/CLI suites. + +Split out of ``tests/test_headless_cli.py`` when that module was divided by +theme; the definitions are verbatim so every sibling suite keeps the exact +fixture semantics it was written against. ``_managed_worker_pool_available`` +is autouse, so importing it into a test module re-applies it there. +""" +from __future__ import annotations + +import subprocess +from types import SimpleNamespace + +import pytest + + +@pytest.fixture(autouse=True) +def _managed_worker_pool_available(monkeypatch): + """HTTP task tests model a ready server unless a case overrides the pool.""" + import supervisor.workers as workers + + monkeypatch.setattr(workers, "WORKERS", {0: SimpleNamespace()}) + monkeypatch.setattr(workers, "_WORKER_POOL_DISABLED_REASON", "") + + +def _init_repo_with_file(repo, name="tracked.txt", content="old\n"): + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + (repo / name).write_text(content, encoding="utf-8") + subprocess.run(["git", "add", name], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "init"], + cwd=repo, + check=True, + capture_output=True, + ) diff --git a/tests/_workspace_executor_shared.py b/tests/_workspace_executor_shared.py new file mode 100644 index 000000000..b2c8783fb --- /dev/null +++ b/tests/_workspace_executor_shared.py @@ -0,0 +1,23 @@ +"""The git-repo builder shared by the workspace-executor suites. + +Split out of ``tests/test_workspace_executor.py`` when that module was divided by +theme; the helper is verbatim, so every sibling suite keeps the exact repository layout +it was written against. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + + + +def _init_repo(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "init"], cwd=path, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + subprocess.run(["git", "config", "user.email", "test@example.invalid"], cwd=path, check=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=path, check=True) + (path / "README.md").write_text("x\n", encoding="utf-8") + subprocess.run(["git", "add", "README.md"], cwd=path, check=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=path, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) diff --git a/tests/conftest.py b/tests/conftest.py index c3d2f3872..18d469bda 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -170,6 +170,11 @@ def _mock_pollution_files(root: pathlib.Path) -> set[pathlib.Path]: # See docs/DEVELOPMENT.md "Pytest marker lanes". _SERIAL_TEST_FILES = frozenset({ "test_workspace_executor.py", + # Themed siblings of test_workspace_executor.py; they spawn the same real + # processes, so the whole family stays in the serial lane. + "test_workspace_executor_services.py", + "test_workspace_executor_docker.py", + "test_workspace_executor_admission.py", "test_workspace_executor_cleanup.py", "test_process_custody.py", "test_kill_process_tree_orphans.py", diff --git a/tests/test_agent_task_pipeline.py b/tests/test_agent_task_pipeline.py index 6ba87b4c4..d8fae020a 100644 --- a/tests/test_agent_task_pipeline.py +++ b/tests/test_agent_task_pipeline.py @@ -1263,149 +1263,6 @@ def test_multi_round_zero_tool_task_uses_llm_summary_prompt(tmp_path, monkeypatc assert payload["rounds"] == 3 -def test_store_task_result_persists_review_evidence(tmp_path): - env = SimpleNamespace(drive_root=tmp_path) - - pipeline._store_task_result( - env=env, - task={"id": "task-store", "type": "task", "text": "hi"}, - text="done", - usage={"rounds": 2, "cost": 0.1}, - llm_trace={"tool_calls": [], "reasoning_notes": []}, - review_evidence={"has_evidence": True, "open_obligations": [{"item": "tests_affected"}]}, - ) - - payload = json.loads((tmp_path / "task_results" / "task-store.json").read_text(encoding="utf-8")) - assert payload["review_evidence"]["has_evidence"] is True - assert payload["review_evidence"]["open_obligations"][0]["item"] == "tests_affected" - - -def test_store_task_result_persists_only_compact_review_projection(tmp_path): - env = SimpleNamespace(drive_root=tmp_path) - trace = { - "tool_calls": [], - "review_runs": [{ - "request": {"surface": "task_acceptance", "policy": {"min_successful_slots": 1}}, - "authority": "host_root", - "aggregate_signal": "DEGRADED", - "actors": [{ - "slot_id": "slot_1", "model": "openai/gpt-5.6-sol", "status": "ok", - "parsed": {"verdict": "DEGRADED", "summary": "not enough evidence"}, - "signal": "DEGRADED", "raw_text": "PRIVATE RAW MODEL RESPONSE", - }], - }], - } - pipeline._store_task_result( - env=env, - task={"id": "task-review-projection", "type": "task", "text": "hi"}, - text="done", - usage={"rounds": 1, "cost": 0.0}, - llm_trace=trace, - review_evidence={}, - ) - - payload = json.loads( - (tmp_path / "task_results" / "task-review-projection.json").read_text(encoding="utf-8") - ) - actor = payload["review_projection"]["panels"][0]["actors"][0] - assert actor["model"] == "openai/gpt-5.6-sol" - assert actor["parse_status"] == "valid" - assert actor["semantic_verdict"] == "DEGRADED" - assert "raw_text" not in actor - assert "PRIVATE RAW MODEL RESPONSE" not in json.dumps(payload) - - -def test_store_task_result_preserves_failed_status(tmp_path): - from ouroboros.task_results import STATUS_FAILED, write_task_result - - env = SimpleNamespace(drive_root=tmp_path) - write_task_result(tmp_path, "task-failed", STATUS_FAILED, result="initial failure") - - pipeline._store_task_result( - env=env, - task={"id": "task-failed", "type": "task", "text": "hi"}, - text="final failure reply", - usage={"rounds": 1, "cost": 0.0}, - llm_trace={"tool_calls": [], "reasoning_notes": []}, - review_evidence={}, - ) - - payload = json.loads((tmp_path / "task_results" / "task-failed.json").read_text(encoding="utf-8")) - assert payload["status"] == STATUS_FAILED - assert payload["result"] == "final failure reply" - - -def test_store_task_result_marks_unresolved_tool_failure_failed(tmp_path): - from ouroboros.task_results import STATUS_COMPLETED - - env = SimpleNamespace(drive_root=tmp_path) - - pipeline._store_task_result( - env=env, - task={"id": "task-tool-failed", "type": "task", "text": "make file"}, - text="Created the file.", - usage={"rounds": 2, "cost": 0.0}, - llm_trace={ - "tool_calls": [{ - "tool": "run_command", - "args": {"cmd": "python3 -c ..."}, - "result": "⚠️ ARTIFACT_OUTPUT_ERROR: command succeeded but declared output registration failed.", - "is_error": True, - "status": "artifact_output_error", - }], - "reasoning_notes": [], - }, - review_evidence={}, - ) - - payload = json.loads((tmp_path / "task_results" / "task-tool-failed.json").read_text(encoding="utf-8")) - assert payload["status"] == STATUS_COMPLETED - assert payload["outcome_axes"]["execution"]["status"] == "degraded" - assert payload["outcome_axes"]["objective"]["status"] == "not_evaluated" - assert payload["reason_code"] == "tool_failure" - assert payload["loop_outcome"]["failure"]["tool_errors"][0]["status"] == "artifact_output_error" - - -def test_store_task_result_allows_recovered_tool_failure_success(tmp_path): - from ouroboros.task_results import STATUS_COMPLETED - - env = SimpleNamespace(drive_root=tmp_path) - - pipeline._store_task_result( - env=env, - task={"id": "task-tool-recovered", "type": "task", "text": "make file"}, - text="Created the file.", - usage={"rounds": 3, "cost": 0.0}, - llm_trace={ - "tool_calls": [ - { - "tool": "edit_text", - "args": {"path": "Desktop/report.html"}, - "result": "⚠️ EDIT_TEXT_ERROR: old_str matched 0 times", - "is_error": True, - "status": "edit_text_blocked", - }, - { - "tool": "write_file", - "args": {"root": "user_files", "path": "Desktop/report.html"}, - "result": "OK: wrote user_files:Desktop/report.html\nARTIFACT_OUTPUTS: registered user file -> artifact_store:report.html", - "is_error": False, - "status": "ok", - "artifact_registered": True, - }, - ], - "reasoning_notes": [], - }, - review_evidence={}, - ) - - payload = json.loads((tmp_path / "task_results" / "task-tool-recovered.json").read_text(encoding="utf-8")) - assert payload["status"] == STATUS_COMPLETED - assert payload["outcome_axes"]["execution"]["status"] == "ok" - assert payload["outcome_axes"]["objective"]["status"] == "not_evaluated" - assert payload["loop_outcome"]["failure"] is None - - def test_collect_review_evidence_keeps_recent_attempts_task_scoped(tmp_path): from ouroboros.review_evidence import collect_review_evidence from ouroboros.review_state import AdvisoryReviewState, CommitAttemptRecord, make_repo_key, save_state diff --git a/tests/test_headless_cli.py b/tests/test_headless_cli.py index bab5e3443..451bab6e2 100644 --- a/tests/test_headless_cli.py +++ b/tests/test_headless_cli.py @@ -1,2391 +1,29 @@ +"""Headless CLI surface: ouroboros run/watch/wait/patch and bench adapters. + +The task/workspace halves of this suite were split verbatim into +``tests/test_headless_task_api.py``, ``tests/test_headless_task_events.py``, +``tests/test_headless_workspace_shell.py``, +``tests/test_headless_workspace_patch.py`` and +``tests/test_headless_task_artifacts.py``; what remains is the command-line +entry point itself plus the benchmark adapters that drive it. +""" from __future__ import annotations import json import importlib.util -import os import pathlib import subprocess import sys -import time from types import SimpleNamespace import pytest -from starlette.applications import Starlette -from starlette.routing import Route -from starlette.testclient import TestClient -from ouroboros.gateway.tasks import ( - _compose_task_text, - _resolve_workspace_root, - api_task_artifact, - api_task_events, - api_task_get, - api_tasks_create, - api_tasks_list, - iter_task_events, -) -from ouroboros.headless import ( - ARTIFACT_STATUS_FAILED, - ARTIFACT_STATUS_FINALIZING, - ARTIFACT_STATUS_READY, - ARTIFACT_STATUS_READY_WITH_CHANGES, - _incidental_lockfile_excludes, - build_memory_export, - build_workspace_patch, - finalize_task_artifacts, - prune_headless_task_drives, - prune_task_drives, - task_artifacts_dir, - write_workspace_patch_artifacts, -) -from ouroboros.task_results import write_task_result -from ouroboros.tools.core import _repo_read -from ouroboros.tools.registry import ToolContext, ToolRegistry from ouroboros.utils import utc_now_iso -from ouroboros.workspace_preflight import _infer_tools_from_manifests -@pytest.fixture(autouse=True) -def _managed_worker_pool_available(monkeypatch): - """HTTP task tests model a ready server unless a case overrides the pool.""" - import supervisor.workers as workers - - monkeypatch.setattr(workers, "WORKERS", {0: SimpleNamespace()}) - monkeypatch.setattr(workers, "_WORKER_POOL_DISABLED_REASON", "") - - -def _init_repo_with_file(repo, name="tracked.txt", content="old\n"): - repo.mkdir() - subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) - (repo / name).write_text(content, encoding="utf-8") - subprocess.run(["git", "add", name], cwd=repo, check=True, capture_output=True) - subprocess.run( - ["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "init"], - cwd=repo, - check=True, - capture_output=True, - ) - - -def test_task_api_enqueue_workspace_creates_child_drive(tmp_path, monkeypatch): - workspace = tmp_path / "workspace" - workspace.mkdir() - subprocess.run(["git", "init"], cwd=workspace, check=True, capture_output=True) - repo = tmp_path / "repo" - repo.mkdir() - data = tmp_path / "data" - (data / "memory").mkdir(parents=True) - (data / "memory" / "identity.md").write_text("seed identity", encoding="utf-8") - - captured = [] - bootstrapped = [] - - def fake_enqueue(task): - captured.append(dict(task)) - return task - - monkeypatch.setattr("supervisor.queue.enqueue_task", fake_enqueue) - monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) - monkeypatch.setattr("ouroboros.workspace_admission.bootstrap_process_path", lambda: bootstrapped.append(True) or []) - - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) - app.state.drive_root = data - app.state.repo_dir = repo - response = TestClient(app).post( - "/api/tasks", - json={ - "description": "fix it", - "workspace_root": str(workspace), - "memory_mode": "forked", - "expected_output": "A workspace patch and concise handoff.", - "constraints": "No network.", - "allowed_resources": {"web": False, "network": False}, - "resource_policy": { - "protected_artifacts": [ - { - "id": "reference", - "role": "black_box_reference", - "paths": ["reference.bin"], - "allow": ["execute"], - } - ] - }, - "deadline_at": "2026-06-04T12:00:00Z", - "service_teardown": "keep", - "context_requires_self_body_docs": "false", - "metadata": { - "root_task_id": "forged-root", - "parent_task_id": "forged-parent", - "delegation_role": "root", - "child_drive_root": "/tmp/forged-child", - }, - }, - ) - - assert response.status_code == 200 - payload = response.json() - assert payload["task_id"] - assert bootstrapped - assert captured and captured[0]["workspace_root"] == str(workspace.resolve(strict=False)) - assert captured[0]["deadline_at"] == "2026-06-04T12:00:00Z" - assert captured[0]["metadata"]["service_teardown"] == "keep" - assert captured[0]["allowed_resources"] == {"web": False, "network": False} - assert captured[0]["context_requires_self_body_docs"] is False - assert captured[0]["task_contract"]["expected_output"] == "A workspace patch and concise handoff." - assert captured[0]["task_contract"]["constraints"] == "No network." - assert captured[0]["task_contract"]["context_requires_self_body_docs"] is False - assert captured[0]["task_contract"]["resource_policy"]["protected_artifacts"][0]["paths"] == ["reference.bin"] - child_drive = captured[0]["drive_root"] - assert child_drive - assert (tmp_path / "data" / "task_results" / f"{payload['task_id']}.json").is_file() - assert "seed identity" in (data / "state" / "headless_tasks" / payload["task_id"] / "data" / "memory" / "identity.md").read_text(encoding="utf-8") - result = json.loads((data / "task_results" / f"{payload['task_id']}.json").read_text(encoding="utf-8")) - assert result["artifact_status"] == "pending" - assert captured[0]["root_task_id"] == payload["task_id"] - assert captured[0]["parent_task_id"] is None - assert captured[0]["delegation_role"] == "root" - assert result["metadata"]["root_task_id"] == payload["task_id"] - assert result["metadata"]["parent_task_id"] == "" - assert result["metadata"]["delegation_role"] == "root" - assert result["task_contract"]["deadline_at"] == "2026-06-04T12:00:00Z" - assert result["task_contract"]["allowed_resources"] == {"web": False, "network": False} - assert result["task_contract"]["resource_policy"]["protected_artifacts"][0]["id"] == "reference" - assert result["metadata"]["child_drive_root"] == captured[0]["child_drive_root"] - assert "/tmp/forged-child" not in json.dumps(result["metadata"]) - assert result["metadata"]["workspace_preflight"]["git"]["head"] == "" - assert any(item["kind"] == "workspace_preflight" for item in result["artifacts"]) - assert "workspace_preflight:" in captured[0]["text"] - assert "target workspace, not the Ouroboros system repo" in captured[0]["text"] - - -def test_task_api_attachment_admission_is_atomic_by_default(tmp_path, monkeypatch): - import supervisor.queue as queue - from ouroboros.task_results import load_task_result - - data = tmp_path / "data" - repo = tmp_path / "repo" - data.mkdir() - repo.mkdir() - good = tmp_path / "good.txt" - good.write_text("ok", encoding="utf-8") - captured = [] - monkeypatch.setattr(queue, "enqueue_task", lambda task: captured.append(task) or task) - monkeypatch.setattr(queue, "persist_queue_snapshot", lambda reason="": True) - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) - app.state.drive_root = data - app.state.repo_dir = repo - - response = TestClient(app).post( - "/api/tasks", - json={ - "task_id": "atomic-attachments", - "description": "needs both", - "attachments": [ - {"path": str(good), "label": "good"}, - {"path": str(tmp_path / "missing.txt"), "label": "missing"}, - ], - }, - ) - - assert response.status_code == 422 - payload = response.json() - assert payload["reason_code"] == "attachment_admission_rejected" - assert [row["status"] for row in payload["attachment_manifest"]] == ["staged", "rejected"] - assert payload["attachment_manifest"][1]["reason"] == "source_missing" - assert captured == [] - assert load_task_result(data, "atomic-attachments") is None - assert "atomic-attachments" not in queue.ADMISSION_RESERVATIONS - assert not task_artifacts_dir(data, "atomic-attachments", create=False).exists() - - -def test_task_api_explicit_partial_attachments_reaches_caller_contract_and_actor( - tmp_path, monkeypatch, -): - import supervisor.queue as queue - - data = tmp_path / "data" - repo = tmp_path / "repo" - data.mkdir() - repo.mkdir() - good = tmp_path / "good.txt" - good.write_text("ok", encoding="utf-8") - captured = [] - monkeypatch.setattr(queue, "enqueue_task", lambda task: captured.append(task) or task) - monkeypatch.setattr(queue, "persist_queue_snapshot", lambda reason="": True) - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) - app.state.drive_root = data - app.state.repo_dir = repo - - response = TestClient(app).post( - "/api/tasks", - json={ - "task_id": "partial-attachments", - "description": "work with what arrived", - "allow_partial_attachments": True, - "attachments": [ - {"path": str(good), "label": "good"}, - {"path": str(tmp_path / "missing.txt"), "label": "missing"}, - ], - }, - ) - - assert response.status_code == 200 - manifest = response.json()["attachment_manifest"] - assert [row["status"] for row in manifest] == ["staged", "rejected"] - task = captured[0] - assert task["attachments"] == manifest - assert task["task_contract"]["attachment_manifest"] == manifest - assert "reason=source_missing" in task["text"] - result = json.loads( - (data / "task_results" / "partial-attachments.json").read_text(encoding="utf-8") - ) - assert result["attachment_manifest"] == manifest - - -def test_task_api_admission_refusal_is_terminal_not_scheduled_phantom(tmp_path, monkeypatch): - from ouroboros.task_results import STATUS_FAILED, load_task_result - - repo = tmp_path / "repo" - repo.mkdir() - data = tmp_path / "data" - (data / "memory").mkdir(parents=True) - persisted = [] - - monkeypatch.setattr( - "supervisor.queue.enqueue_task", - lambda task: { - **task, - "_admission_blocked": "project_routing_fence", - "_project_id": "closed-project", - "_project_lifecycle": "deleting", - }, - ) - monkeypatch.setattr( - "supervisor.queue.persist_queue_snapshot", - lambda reason="": persisted.append(reason), - ) - - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) - app.state.drive_root = data - app.state.repo_dir = repo - response = TestClient(app).post( - "/api/tasks", - json={ - "description": "must not run", - "task_id": "blocked-root", - "project_id": "closed-project", - }, - ) - - assert response.status_code == 409 - payload = response.json() - assert payload["task_id"] == "blocked-root" - assert payload["status"] == STATUS_FAILED - assert payload["admission"]["reason_code"] == "project_routing_fence" - assert payload["admission"]["project_lifecycle"] == "deleting" - assert persisted == [] - result = load_task_result(data, "blocked-root") - assert result["status"] == STATUS_FAILED - assert result["reason_code"] == "project_routing_fence" - assert result["admission_cleanup"] == {"child_drive_removed": True} - assert not (data / "state" / "headless_tasks" / "blocked-root").exists() - - -def test_task_api_refuses_when_durable_queue_snapshot_fails(tmp_path, monkeypatch): - import supervisor.queue as queue - from ouroboros.task_results import STATUS_FAILED, load_task_result - - repo = tmp_path / "repo" - repo.mkdir() - data = tmp_path / "data" - (data / "memory").mkdir(parents=True) - pending = [] - monkeypatch.setattr(queue, "DRIVE_ROOT", data) - monkeypatch.setattr(queue, "PENDING", pending) - monkeypatch.setattr(queue, "RUNNING", {}) - calls = [] - - def persist(reason=""): - calls.append(reason) - return reason == "api_task_create_rollback" - - monkeypatch.setattr(queue, "persist_queue_snapshot", persist) - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) - app.state.drive_root = data - app.state.repo_dir = repo - response = TestClient(app).post( - "/api/tasks", - json={"description": "must be durable", "task_id": "snapshot-fail"}, - ) - - assert response.status_code == 503 - assert response.json()["admission"]["reason_code"] == "queue_snapshot_persist_failed" - assert pending == [] - assert calls == ["api_task_create", "api_task_create_rollback"] - assert load_task_result(data, "snapshot-fail")["status"] == STATUS_FAILED - assert not (data / "state" / "headless_tasks" / "snapshot-fail").exists() - - -def test_late_api_identity_lookup_failure_preserves_exact_result(tmp_path): - from ouroboros.gateway.tasks import _admission_rejection_response - - result_path = tmp_path / "task_results" / "api-corrupt.json" - result_path.parent.mkdir() - original = b"{api-corrupt" - result_path.write_bytes(original) - response = _admission_rejection_response( - {"_admission_blocked": "task_id_lookup_failed"}, - drive_root=tmp_path, task_id="api-corrupt", project_id="", - workspace_root=None, child_drive=None, - ) - assert response is not None and response.status_code == 409 - assert json.loads(response.body)["admission"]["reason_code"] == "task_id_lookup_failed" - assert result_path.read_bytes() == original - - -def test_task_api_releases_reservation_when_payload_composition_fails( - tmp_path, monkeypatch, -): - import supervisor.queue as queue - from ouroboros.gateway import tasks - - data = tmp_path / "data" - repo = tmp_path / "repo" - data.mkdir() - repo.mkdir() - task_id = "compose-failure" - real_compose = tasks._compose_task_text - monkeypatch.setattr( - tasks, - "_compose_task_text", - lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("compose failed")), - ) - monkeypatch.setattr(queue, "enqueue_task", lambda task: task) - monkeypatch.setattr(queue, "persist_queue_snapshot", lambda **_kwargs: True) - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) - app.state.drive_root = data - app.state.repo_dir = repo - client = TestClient(app) - - failed = client.post( - "/api/tasks", json={"task_id": task_id, "description": "compose me"} - ) - assert failed.status_code == 503 - assert task_id not in queue.ADMISSION_RESERVATIONS - assert not task_artifacts_dir(data, task_id, create=False).exists() - - monkeypatch.setattr(tasks, "_compose_task_text", real_compose) - retried = client.post( - "/api/tasks", json={"task_id": task_id, "description": "compose me"} - ) - assert retried.status_code == 200, retried.text - - -def test_api_tasks_create_requires_description_not_legacy_aliases(monkeypatch): - captured = [] - monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: captured.append(task) or task) - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) - client = TestClient(app) - - for payload in ({"text": "legacy task"}, {"prompt": "legacy task"}, {"description": ""}): - response = client.post("/api/tasks", json=payload) - assert response.status_code == 400, (payload, response.text) - assert "description is required" in response.json().get("error", "") - - response = client.post("/api/tasks", json={"description": "x", "service_teardown": "detach"}) - assert response.status_code == 400 - assert "service_teardown" in response.json().get("error", "") - - assert captured == [] - - -def test_api_tasks_create_rejects_internal_task_types(tmp_path, monkeypatch): - repo = tmp_path / "repo" - repo.mkdir() - data = tmp_path / "data" - (data / "memory").mkdir(parents=True) - - monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: task) - monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) - monkeypatch.setattr("ouroboros.workspace_admission.bootstrap_process_path", lambda: []) - - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) - app.state.drive_root = data - app.state.repo_dir = repo - client = TestClient(app) - - for internal_type in ("evolution", "review", "deep_self_review"): - resp = client.post("/api/tasks", json={"description": "x", "type": internal_type}) - assert resp.status_code == 400, (internal_type, resp.text) - assert "internal" in resp.json().get("error", "").lower() - - # A normal task type is still accepted. - ok = client.post("/api/tasks", json={"description": "do normal work", "type": "task"}) - assert ok.status_code == 200, ok.text - - -def test_compose_task_text_extends_existing_headless_workspace_block(tmp_path): - text = _compose_task_text( - "fix\n\n[HEADLESS_WORKSPACE]\nexisting: yes\n[END_HEADLESS_WORKSPACE]", - workspace_root=tmp_path, - workspace_mode="external", - memory_mode="empty", - workspace_preflight={"error": "probe failed"}, - attachments=[], - ) - - assert text.count("[HEADLESS_WORKSPACE]") == 1 - assert "existing: yes" in text - assert "preflight_error: probe failed" in text - assert text.index("workspace_root:") < text.index("[END_HEADLESS_WORKSPACE]") - - -def test_task_api_rejects_unsafe_task_id_and_system_workspace(tmp_path, monkeypatch): - workspace = tmp_path / "workspace" - workspace.mkdir() - subprocess.run(["git", "init"], cwd=workspace, check=True, capture_output=True) - repo = tmp_path / "repo" - repo.mkdir() - subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) - data = tmp_path / "data" - data.mkdir() - monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: task) - monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) - - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) - app.state.drive_root = data - app.state.repo_dir = repo - client = TestClient(app) - - bad_id = client.post("/api/tasks", json={"description": "x", "task_id": "../settings", "workspace_root": str(workspace)}) - assert bad_id.status_code == 400 - assert not (data / "settings.json").exists() - - system_repo = client.post("/api/tasks", json={"description": "x", "workspace_root": str(repo)}) - assert system_repo.status_code == 400 - assert "system repo" in system_repo.json()["error"] - - bad_numbers = client.post("/api/tasks", json={"description": "x", "chat_id": "not-int", "workspace_root": str(workspace)}) - assert bad_numbers.status_code == 400 - bad_deadline = client.post("/api/tasks", json={"description": "x", "deadline_at": "not-a-date", "workspace_root": str(workspace)}) - assert bad_deadline.status_code == 400 - assert "deadline_at" in bad_deadline.json()["error"] - naive_deadline = client.post("/api/tasks", json={"description": "x", "deadline_at": "2026-06-04T12:00:00", "workspace_root": str(workspace)}) - assert naive_deadline.status_code == 400 - assert "timezone" in naive_deadline.json()["error"] - - first = client.post("/api/tasks", json={"description": "x", "task_id": "fixed1", "workspace_root": str(workspace)}) - assert first.status_code == 200 - duplicate = client.post("/api/tasks", json={"description": "x", "task_id": "fixed1", "workspace_root": str(workspace)}) - assert duplicate.status_code == 409 - - typed = client.post("/api/tasks", json={"description": "x", "type": "deep_self_review", "workspace_root": str(workspace)}) - assert typed.status_code == 400 - - -def test_task_api_rejects_negative_depth_before_reservation_or_queue(tmp_path, monkeypatch): - from supervisor import queue as queue_module - - repo = tmp_path / "repo" - data = tmp_path / "data" - repo.mkdir() - data.mkdir() - captured = [] - monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: captured.append(task) or task) - monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) - - def fail_reservation(*_args, **_kwargs): - pytest.fail("invalid depth must be rejected before admission reservation") - - monkeypatch.setattr(queue_module, "reserve_task_admission", fail_reservation) - - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) - app.state.drive_root = data - app.state.repo_dir = repo - client = TestClient(app) - - cases = ((-1, "depth must be a non-negative integer"), - (-0.5, "depth must be a non-negative integer"), - ("-1", "depth must be a non-negative integer"), - ("not-a-depth", "chat_id and depth must be integers")) - for index, (raw_depth, expected_error) in enumerate(cases): - task_id = f"api-invalid-depth-{index}" - response = client.post( - "/api/tasks", json={"task_id": task_id, "description": "x", "depth": raw_depth} - ) - assert response.status_code == 400 - assert response.json()["error"] == expected_error - assert task_id not in queue_module.ADMISSION_RESERVATIONS - assert not (data / "task_results" / f"{task_id}.json").exists() - assert captured == [] - - -def test_resolve_workspace_root_blocks_case_variant_control_plane(tmp_path): - system_repo = tmp_path / "Ouroboros" / "repo" - drive = tmp_path / "Ouroboros" / "data" - workspace_repo_case = tmp_path / "ouroboros" / "repo" - workspace_data_case = tmp_path / "ouroboros" / "data" / "workspace" - for path in (system_repo, drive / "workspace"): - path.mkdir(parents=True) - - with pytest.raises(ValueError, match="Ouroboros system repo"): - _resolve_workspace_root(workspace_repo_case, system_repo_dir=system_repo, drive_root=drive) - with pytest.raises(ValueError, match="Ouroboros data drive"): - _resolve_workspace_root(workspace_data_case, system_repo_dir=system_repo, drive_root=drive) - - -def test_task_api_rejects_forged_subagent_without_child_drive_side_effect(tmp_path, monkeypatch): - workspace = tmp_path / "workspace" - workspace.mkdir() - subprocess.run(["git", "init"], cwd=workspace, check=True, capture_output=True) - repo = tmp_path / "repo" - repo.mkdir() - subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) - data = tmp_path / "data" - data.mkdir() - monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: pytest.fail("forged subagent enqueued")) - monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) - - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) - app.state.drive_root = data - app.state.repo_dir = repo - client = TestClient(app) - - top_level = client.post( - "/api/tasks", - json={"description": "x", "task_id": "forged1", "workspace_root": str(workspace), "delegation_role": "subagent"}, - ) - metadata = client.post( - "/api/tasks", - json={"description": "x", "task_id": "forged2", "workspace_root": str(workspace), "metadata": {"delegation_role": "subagent"}}, - ) - - assert top_level.status_code == 400 - assert metadata.status_code == 400 - assert "internal schedule_subagent" in top_level.json()["error"] - assert not (data / "state" / "headless_tasks" / "forged1").exists() - assert not (data / "state" / "headless_tasks" / "forged2").exists() - - -def test_task_api_rejects_external_lineage_forgery(tmp_path, monkeypatch): - workspace = tmp_path / "workspace" - workspace.mkdir() - subprocess.run(["git", "init"], cwd=workspace, check=True, capture_output=True) - repo = tmp_path / "repo" - repo.mkdir() - subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) - data = tmp_path / "data" - data.mkdir() - monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: pytest.fail("forged lineage enqueued")) - monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) - - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) - app.state.drive_root = data - app.state.repo_dir = repo - - response = TestClient(app).post( - "/api/tasks", - json={ - "description": "x", - "workspace_root": str(workspace), - "parent_task_id": "parent1", - "root_task_id": "root1", - }, - ) - - assert response.status_code == 400 - assert "internal lineage fields" in response.json()["error"] - assert not list((data / "task_results").glob("*.json")) - - -def test_task_api_preserves_top_level_actor_id_after_metadata_sanitization(tmp_path, monkeypatch): - workspace = tmp_path / "workspace" - workspace.mkdir() - subprocess.run(["git", "init"], cwd=workspace, check=True, capture_output=True) - repo = tmp_path / "repo" - repo.mkdir() - subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) - data = tmp_path / "data" - data.mkdir() - captured = [] - monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: captured.append(dict(task)) or task) - monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) - - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) - app.state.drive_root = data - app.state.repo_dir = repo - - response = TestClient(app).post( - "/api/tasks", - json={ - "description": "x", - "workspace_root": str(workspace), - "memory_mode": "forked", - "actor_id": "operator-1", - "metadata": {"actor_id": "forged-metadata"}, - }, - ) - - assert response.status_code == 200 - assert captured[0]["actor_id"] == "operator-1" - result = json.loads((data / "task_results" / f"{response.json()['task_id']}.json").read_text(encoding="utf-8")) - assert result["metadata"]["actor_id"] == "operator-1" - assert "forged-metadata" not in json.dumps(result) - - -def test_task_event_replay_uses_existing_logs_and_result(tmp_path): - data = tmp_path / "data" - logs = data / "logs" - logs.mkdir(parents=True) - task_id = "abc123" - (logs / "progress.jsonl").write_text( - json.dumps({"ts": "2026-01-01T00:00:00Z", "task_id": task_id, "content": "working"}) + "\n", - encoding="utf-8", - ) - result_dir = data / "task_results" - result_dir.mkdir() - (result_dir / f"{task_id}.json").write_text( - json.dumps({"task_id": task_id, "status": "completed", "result": "done", "ts": "2026-01-01T00:00:01Z"}), - encoding="utf-8", - ) - - events = iter_task_events(data, task_id) - - assert [event["type"] for event in events] == ["progress", "task_result"] - assert events[0]["seq"] == 1 - assert events[1]["data"]["result"] == "done" - - -def test_task_event_replay_parent_includes_child_lineage_events(tmp_path): - data = tmp_path / "data" - logs = data / "logs" - logs.mkdir(parents=True) - parent_id = "parent1" - child_id = "child1" - (logs / "progress.jsonl").write_text( - "\n".join([ - json.dumps({"ts": "2026-01-01T00:00:00Z", "task_id": parent_id, "content": "parent"}), - json.dumps({ - "ts": "2026-01-01T00:00:01Z", - "task_id": child_id, - "parent_task_id": parent_id, - "root_task_id": parent_id, - "delegation_role": "subagent", - "subagent_task_id": child_id, - "content": "child progress", - }), - ]) + "\n", - encoding="utf-8", - ) - write_task_result( - data, - parent_id, - "running", - result="parent pending", - ts="2026-01-01T00:00:00Z", - ) - write_task_result( - data, - child_id, - "running", - result="child pending", - parent_task_id=parent_id, - root_task_id=parent_id, - delegation_role="subagent", - ts="2026-01-01T00:00:01Z", - ) - - events = iter_task_events(data, parent_id) - - progress_events = [event for event in events if event["type"] == "progress"] - assert [event["task_id"] for event in progress_events] == [parent_id, child_id] - assert progress_events[1]["data"]["content"] == "child progress" - - -def test_logs_tail_parent_filter_includes_child_lineage_events(tmp_path): - from ouroboros.gateway.logs import api_logs_tail - - data = tmp_path / "data" - logs = data / "logs" - logs.mkdir(parents=True) - (logs / "progress.jsonl").write_text( - "\n".join([ - json.dumps({"ts": "2026-01-01T00:00:00Z", "task_id": "parent1", "content": "parent"}), - json.dumps({ - "ts": "2026-01-01T00:00:01Z", - "task_id": "child1", - "subagent_task_id": "child1", - "parent_task_id": "parent1", - "root_task_id": "parent1", - "delegation_role": "subagent", - "content": "child", - }), - json.dumps({"ts": "2026-01-01T00:00:02Z", "task_id": "other", "content": "other"}), - ]) + "\n", - encoding="utf-8", - ) - app = Starlette(routes=[Route("/api/logs/{name}", endpoint=api_logs_tail, methods=["GET"])]) - app.state.drive_root = data - - response = TestClient(app).get("/api/logs/progress?task_id=parent1&limit=10") - payload = response.json() - - assert response.status_code == 200 - assert [row["content"] for row in payload["entries"]] == ["parent", "child"] - - -def test_workspace_event_replay_suppresses_task_done_until_artifacts_terminal(tmp_path): - data = tmp_path / "data" - logs = data / "logs" - logs.mkdir(parents=True) - task_id = "abc123" - (logs / "events.jsonl").write_text( - json.dumps({"ts": "2026-01-01T00:00:01Z", "type": "task_done", "task_id": task_id}) + "\n", - encoding="utf-8", - ) - write_task_result( - data, - task_id, - "completed", - workspace_root=str(tmp_path / "workspace"), - artifact_status="finalizing", - child_status="completed", - ) - - events = iter_task_events(data, task_id) - - assert "task_done" not in [event["type"] for event in events] - assert events[-1]["type"] == "task_result" - - -def test_effective_child_completion_waits_for_artifacts(tmp_path): - data = tmp_path / "data" - child = tmp_path / "child" - for root in (data, child): - (root / "task_results").mkdir(parents=True) - write_task_result( - data, - "task-artifacts", - "scheduled", - child_drive_root=str(child), - workspace_root=str(tmp_path / "workspace"), - artifact_status="pending", - result="queued", - ) - write_task_result( - child, - "task-artifacts", - "completed", - result="done", - ts="2026-01-01T00:00:02Z", - outcome_axes={ - "lifecycle": {"status": "completed"}, - "artifacts": {"status": "not_applicable"}, - }, - ) - - app = Starlette(routes=[Route("/api/tasks/{task_id}", endpoint=api_task_get, methods=["GET"])]) - app.state.drive_root = data - payload = TestClient(app).get("/api/tasks/task-artifacts").json() - - assert payload["status"] == "running" - assert payload["artifact_status"] == "finalizing" - assert payload["child_status"] == "completed" - assert payload["outcome_axes"]["lifecycle"]["status"] == "running" - assert payload["outcome_axes"]["artifacts"]["status"] == "finalizing" - - write_task_result(data, "task-artifacts", "completed", artifact_status="ready", child_drive_root=str(child), workspace_root=str(tmp_path / "workspace")) - payload = TestClient(app).get("/api/tasks/task-artifacts").json() - assert payload["status"] == "completed" - assert payload["artifact_status"] == "ready" - - -def test_public_task_result_strips_nested_legacy_result_status(tmp_path): - data = tmp_path / "data" - (data / "task_results").mkdir(parents=True) - write_task_result( - data, - "legacy-loop", - "completed", - result="done", - loop_outcome={"result_status": "failed", "compat_result_status": "failed", "reason_code": "legacy"}, - verification_ledger={ - "entries": [ - {"kind": "legacy", "result_status": "partial"}, - {"kind": "nested", "payload": {"compat_result_status": "infra_failed"}}, - {"kind": "list", "items": [{"result_status": "failed"}]}, - ], - }, - ) - app = Starlette(routes=[Route("/api/tasks/{task_id}", endpoint=api_task_get, methods=["GET"])]) - app.state.drive_root = data - - payload = TestClient(app).get("/api/tasks/legacy-loop").json() - - assert "result_status" not in payload - assert "result_status" not in payload["loop_outcome"] - assert "compat_result_status" not in payload["loop_outcome"] - rendered = json.dumps(payload) - assert "result_status" not in rendered - assert "compat_result_status" not in rendered - - -def test_effective_child_failure_waits_for_artifacts(tmp_path): - data = tmp_path / "data" - child = tmp_path / "child" - for root in (data, child): - (root / "task_results").mkdir(parents=True) - write_task_result( - data, - "task-failed", - "failed", - child_drive_root=str(child), - workspace_root=str(tmp_path / "workspace"), - artifact_status="finalizing", - child_status="failed", - result="boom", - ) - write_task_result(child, "task-failed", "failed", result="boom", ts="2026-01-01T00:00:02Z") - - app = Starlette(routes=[Route("/api/tasks/{task_id}", endpoint=api_task_get, methods=["GET"])]) - app.state.drive_root = data - payload = TestClient(app).get("/api/tasks/task-failed").json() - - assert payload["status"] == "running" - assert payload["artifact_status"] == "finalizing" - assert payload["child_status"] == "failed" - - -def test_task_sse_emits_final_result_after_cursor_saw_scheduled_result(tmp_path): - data = tmp_path / "data" - (data / "task_results").mkdir(parents=True) - task_id = "abc123" - (data / "task_results" / f"{task_id}.json").write_text( - json.dumps({"task_id": task_id, "status": "completed", "result": "done", "ts": "2026-01-01T00:00:01Z"}), - encoding="utf-8", - ) - app = Starlette(routes=[Route("/api/tasks/{task_id}/events", endpoint=api_task_events, methods=["GET"])]) - app.state.drive_root = data - - response = TestClient(app).get(f"/api/tasks/{task_id}/events?cursor=1&wait=0") - - assert response.status_code == 200 - assert '"type": "task_result"' in response.text - assert '"status": "completed"' in response.text - - -def test_task_list_filters_on_effective_child_status(tmp_path): - data = tmp_path / "data" - child_running = tmp_path / "child-running" - child_done = tmp_path / "child-done" - for root in (data, child_running, child_done): - (root / "task_results").mkdir(parents=True) - - write_task_result(data, "task-running", "scheduled", child_drive_root=str(child_running), result="queued") - write_task_result(child_running, "task-running", "running", result="working", ts="2026-01-01T00:00:01Z") - write_task_result(data, "task-done", "scheduled", child_drive_root=str(child_done), result="queued") - write_task_result(child_done, "task-done", "completed", result="done", ts="2026-01-01T00:00:02Z") - - app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_list, methods=["GET"])]) - app.state.drive_root = data - client = TestClient(app) - - running = client.get("/api/tasks?status=running").json()["tasks"] - completed = client.get("/api/tasks?status=completed").json()["tasks"] - - assert [task["task_id"] for task in running] == ["task-running"] - assert running[0]["result"] == "working" - assert [task["task_id"] for task in completed] == ["task-done"] - assert completed[0]["result"] == "done" - - -@pytest.mark.parametrize("status", ["cancelled", "failed"]) -def test_effective_task_result_preserves_parent_terminal_status(tmp_path, status): - data = tmp_path / "data" - child = tmp_path / "child" - for root in (data, child): - (root / "task_results").mkdir(parents=True) - write_task_result( - data, - "task-terminal", - status, - child_drive_root=str(child), - result="parent terminal", - ts="2026-01-01T00:00:02Z", - ) - write_task_result( - child, - "task-terminal", - "running", - result="child stale", - ts="2026-01-01T00:00:03Z", - ) - - app = Starlette(routes=[Route("/api/tasks/{task_id}", endpoint=api_task_get, methods=["GET"])]) - app.state.drive_root = data - - payload = TestClient(app).get("/api/tasks/task-terminal").json() - - assert payload["status"] == status - assert payload["result"] == "parent terminal" - assert payload["ts"] == "2026-01-01T00:00:02Z" - - -def test_workspace_context_routes_project_files_and_keeps_system_tools_reachable(tmp_path): - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - system_repo.mkdir() - workspace.mkdir() - data.mkdir() - (system_repo / "README.md").write_text("system", encoding="utf-8") - (workspace / "README.md").write_text("workspace", encoding="utf-8") - (workspace / "BIBLE.md").write_text("external bible", encoding="utf-8") - - ctx = ToolContext( - repo_dir=system_repo, - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - ) - - assert "workspace" in _repo_read(ctx, "README.md") - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - commit_result = registry.execute("commit_reviewed", {"commit_message": "nope"}) - assert "WORKSPACE_MODE_BLOCKED" not in commit_result - assert registry.get_schema_by_name("commit_reviewed") is not None - assert registry.get_schema_by_name("request_restart") is not None - assert "Written" in registry.execute("write_file", {"path": "BIBLE.md", "content": "external edit"}) - assert (workspace / "BIBLE.md").read_text(encoding="utf-8") == "external edit" - replaced = registry.execute( - "edit_text", - {"path": "README.md", "old_str": "workspace", "new_str": "workspace edited"}, - ) - assert "Replaced" in replaced - assert (workspace / "README.md").read_text(encoding="utf-8") == "workspace edited" - - -def test_workspace_run_shell_cwd_allows_scratch_and_explicit_system(tmp_path, monkeypatch): - """External-workspace tasks may run from host scratch (a sibling checkout, a - /tmp tree) and explicitly select the system repo; generic runtime data stays - off-limits and system-repo mutation remains independently governed.""" - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - # Pin $HOME outside tmp_path so the host-scratch cwd allowance holds on Windows - # CI too (where pytest's tmp dir lives UNDER home and the data-parent-under-home - # protection would otherwise block the sibling scratch cwd). See the same fixture - # in test_external_workspace_access.py. - fake_home = tmp_path / "_home" - fake_home.mkdir() - monkeypatch.setattr(pathlib.Path, "home", lambda: fake_home) - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - outside = tmp_path / "outside" - data = tmp_path / "data" - for path in (system_repo, workspace, outside, data): - path.mkdir() - ctx = ToolContext( - repo_dir=system_repo, - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - ) - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - # Host scratch outside the declared workspace is now a legitimate cwd... - scratch_cwd = registry.execute("run_command", {"cmd": ["pwd"], "cwd": str(outside)}) - assert "SHELL_CWD_BLOCKED" not in scratch_cwd - # The approved root contract makes system_repo an explicit cwd; generic - # runtime_data remains unavailable to process tools. - runtime_repo_cwd = registry.execute("run_command", {"cmd": ["pwd"], "cwd": str(system_repo)}) - assert "SHELL_CWD_BLOCKED" not in runtime_repo_cwd - assert f"cwd={system_repo.resolve()}" in runtime_repo_cwd - runtime_data_cwd = registry.execute("run_command", {"cmd": ["pwd"], "cwd": str(data)}) - assert "SHELL_CWD_BLOCKED" in runtime_data_cwd - # READ-ONLY git at a runtime target is ALLOWED (owner contract "read-only - # everywhere"; the f14baf8f false-block class). Only MUTATING git is target-checked. - git_read = registry._run_shell_safety_check( - {"cmd": ["git", "-C", str(system_repo), "status"]}, "advanced" - ) - assert git_read is None, git_read - git_escape = registry._run_shell_safety_check( - {"cmd": ["git", "-C", str(system_repo), "commit", "-m", "x"]}, "advanced" - ) - assert git_escape and "WORKSPACE_GIT_BLOCKED" in git_escape - git_chain = registry.execute("run_command", {"cmd": ["sh", "-c", "true && git --version; echo git binary OK"]}) - assert "WORKSPACE_GIT_BLOCKED" not in git_chain - outside_write = registry.execute("run_command", {"cmd": ["touch", str(system_repo / "README.md")]}) - assert "WORKSPACE_SHELL_BLOCKED" in outside_write - embedded_outside_write = registry.execute( - "run_command", - {"cmd": ["python", "-c", "open('/tmp/ouroboros-outside.txt','w').write('x')"]}, - ) - assert "WORKSPACE_SHELL_BLOCKED" in embedded_outside_write - - -def test_workspace_shell_safe_stdio_redirects_are_not_write_like(tmp_path, monkeypatch): - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - outside = tmp_path / "outside" - data = tmp_path / "data" - for path in (system_repo, workspace, outside, data): - path.mkdir() - (outside / "visible.txt").write_text("ok\n", encoding="utf-8") - ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace, workspace_mode="external") - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - stderr_sink = registry.execute("run_command", {"cmd": f"find {outside} -maxdepth 1 2>/dev/null"}) - fd_dup = registry.execute("run_command", {"cmd": f"ls {outside} 2>&1 | head -n 1"}) - fd_close = registry.execute("run_command", {"cmd": f"find {outside} -maxdepth 1 2>&-"}) - real_redirect = registry.execute("run_command", {"cmd": f"echo x > {outside / 'out.txt'}"}) - - assert "WORKSPACE_SHELL_BLOCKED" not in stderr_sink, stderr_sink - assert "WORKSPACE_SHELL_BLOCKED" not in fd_dup, fd_dup - assert "WORKSPACE_SHELL_BLOCKED" not in fd_close, fd_close - assert "WORKSPACE_SHELL_BLOCKED" in real_redirect - - -def test_workspace_shell_blocks_windows_absolute_redirects_before_shell_execution(tmp_path, monkeypatch): - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - for path in (system_repo, workspace, data): - path.mkdir() - ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace, workspace_mode="external") - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - drive_redirect = registry.execute("run_command", {"cmd": r"echo x > C:\ouroboros-outside\out.txt"}) - unc_redirect = registry.execute("run_command", {"cmd": r"echo x > \\server\share\out.txt"}) - - assert "WORKSPACE_SHELL_BLOCKED" in drive_redirect - assert "SHELL_SYNTAX_UNSUPPORTED" not in drive_redirect - assert "WORKSPACE_SHELL_BLOCKED" in unc_redirect - assert "SHELL_SYNTAX_UNSUPPORTED" not in unc_redirect - - -def test_workspace_shell_keeps_symlinked_workspace_absolute_paths_allowed(tmp_path, monkeypatch): - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - system_repo = tmp_path / "system" - real_workspace = tmp_path / "real_workspace" - workspace_link = tmp_path / "workspace_link" - data = tmp_path / "data" - for path in (system_repo, real_workspace, data): - path.mkdir() - try: - workspace_link.symlink_to(real_workspace, target_is_directory=True) - except OSError as exc: - pytest.skip(f"symlink unavailable on this platform: {exc}") - ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace_link, workspace_mode="external") - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - target = workspace_link / "inside.txt" - result = registry.execute("run_command", {"cmd": [sys.executable, "-c", f"open({str(target)!r}, 'w').write('ok')"]}) - - assert "WORKSPACE_SHELL_BLOCKED" not in result, result - assert (real_workspace / "inside.txt").exists() - - -def test_workspace_shell_blocks_nested_symlink_escape_absolute_path(tmp_path, monkeypatch): - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - outside = tmp_path / "outside" - data = tmp_path / "data" - for path in (system_repo, workspace, outside, data): - path.mkdir() - outlink = workspace / "outlink" - outside_file = outside / "target.txt" - outside_file.write_text("old\n", encoding="utf-8") - filelink = workspace / "filelink" - executable_name_link = workspace / "touch" - try: - outlink.symlink_to(outside, target_is_directory=True) - filelink.symlink_to(outside_file) - executable_name_link.symlink_to(outside_file) - except OSError as exc: - pytest.skip(f"symlink unavailable on this platform: {exc}") - ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace, workspace_mode="external") - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - result = registry.execute("run_command", {"cmd": f"touch {outlink / 'escaped.txt'}"}) - relative_result = registry.execute("run_command", {"cmd": "touch outlink/escaped-relative.txt"}) - bare_result = registry.execute("run_command", {"cmd": "touch outlink"}) - executable_name_result = registry.execute("run_command", {"cmd": ["touch", "touch"]}) - redirect_result = registry.execute("run_command", {"cmd": "echo changed > filelink"}) - compact_redirect_result = registry.execute("run_command", {"cmd": "echo changed >filelink"}) - shell_inline_result = registry.execute("run_command", {"cmd": ["sh", "-c", "echo changed > filelink"]}) - shell_inline_touch_result = registry.execute("run_command", {"cmd": ["sh", "-c", "touch filelink"]}) - bash_redirect_result = registry.execute("run_command", {"cmd": ["bash", "-c", "echo changed &> filelink"]}) - compact_bash_redirect_result = registry.execute("run_command", {"cmd": ["bash", "-c", "echo changed &>filelink"]}) - tee_result = registry.execute("run_command", {"cmd": "printf changed | tee filelink"}) - shell_inline_tee_result = registry.execute("run_command", {"cmd": ["sh", "-c", "printf changed | tee filelink"]}) - python_inline_result = registry.execute( - "run_command", - {"cmd": [sys.executable, "-c", "open('filelink', 'w').write('changed')"]}, - ) - python_versioned_result = registry.execute( - "run_command", - {"cmd": ["python3.12", "-c", "open('filelink', 'w').write('changed')"]}, - ) - node_script_result = registry.execute( - "run_script", - { - "interpreter": "node", - "script": "require('fs').writeFileSync('filelink', 'changed')", - }, - ) - - assert "WORKSPACE_SHELL_BLOCKED" in result - assert "WORKSPACE_SHELL_BLOCKED" in relative_result - assert "WORKSPACE_SHELL_BLOCKED" in bare_result - assert "WORKSPACE_SHELL_BLOCKED" in executable_name_result - assert "WORKSPACE_SHELL_BLOCKED" in redirect_result - assert "WORKSPACE_SHELL_BLOCKED" in compact_redirect_result - assert "WORKSPACE_SHELL_BLOCKED" in shell_inline_result - assert "WORKSPACE_SHELL_BLOCKED" in shell_inline_touch_result - assert "WORKSPACE_SHELL_BLOCKED" in bash_redirect_result - assert "WORKSPACE_SHELL_BLOCKED" in compact_bash_redirect_result - assert "WORKSPACE_SHELL_BLOCKED" in tee_result - assert "WORKSPACE_SHELL_BLOCKED" in shell_inline_tee_result - assert "WORKSPACE_SHELL_BLOCKED" in python_inline_result - assert "WORKSPACE_SHELL_BLOCKED" in python_versioned_result - assert "WORKSPACE_SHELL_BLOCKED" in node_script_result - assert not (outside / "escaped.txt").exists() - assert not (outside / "escaped-relative.txt").exists() - assert outside_file.read_text(encoding="utf-8") == "old\n" - - -def test_external_workspace_shell_allows_task_local_git(tmp_path, monkeypatch): - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - system_repo.mkdir() - data.mkdir() - _init_repo_with_file(workspace) - ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace, workspace_mode="external") - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - monkeypatch.setenv("OUROBOROS_TEST_RUNTIME_REPO", str(system_repo)) - - allowed = [ - ["git", "for-each-ref", "--format=%(refname)"], - ["git", "rev-list", "--count", "HEAD"], - ["git", "show-ref", "--heads"], - ["git", "branch", "--show-current"], - ["git", "branch", "--list"], - ["git", "branch", "--list", "ma*"], - ["git", "branch", "-av"], - ["git", "tag", "-l"], - ["git", "tag", "--list", "v*"], - ["git", "branch", "new-branch"], - ["git", "branch", "-v", "new-branch"], - ["git", "branch", "--verbose", "new-branch"], - ["git", "branch", "-d", "main"], - ["git", "tag", "v1"], - ["git", "tag", "-a", "v1", "-m", "x"], - ["git", "commit", "--allow-empty", "-m", "task-local commit"], - ["sh", "-c", "git --version; echo git binary OK"], - ] - - for cmd in allowed: - assert registry._run_shell_safety_check({"cmd": cmd}, "advanced") is None, cmd - - # READ-ONLY git reaches the runtime through EVERY retarget vector — that is the - # owner contract ("read-only everywhere, including at a runtime target") and the - # recorded false-block class f14baf8f. Before the Q4=A composition these four - # were refused: the target-aware resolver let them through and the - # external-workspace runtime-READ guard then blocked them as - # WORKSPACE_SHELL_BLOCKED, naming the wrong reason. - for cmd in ( - ["git", "-C", str(system_repo), "status"], - ["git", "--git-dir", str(system_repo / ".git"), "status"], - # as_posix(): a POSIX shell (sh -c) uses forward slashes; a Windows - # backslash literal would be eaten as shell escapes during parsing. - ["sh", "-c", f"cd {system_repo.as_posix()} && git status"], - ["sh", "-c", "git -C $OUROBOROS_TEST_RUNTIME_REPO status"], - ): - result = registry._run_shell_safety_check({"cmd": cmd}, "advanced") - assert result is None, (cmd, result) - - # ...while the MUTATING form of each vector stays blocked. - for cmd in ( - ["git", "-C", str(system_repo), "commit", "-m", "x"], - ["git", "--git-dir", str(system_repo / ".git"), "commit", "-m", "x"], - ["sh", "-c", f"cd {system_repo.as_posix()} && git commit -m x"], - ["sh", "-c", "git -C $OUROBOROS_TEST_RUNTIME_REPO commit -m x"], - ): - result = registry._run_shell_safety_check({"cmd": cmd}, "advanced") - assert result and "WORKSPACE_GIT_BLOCKED" in result, (cmd, result) - - # The read-only exemption is ALL-or-NOTHING per segment: a compound that only - # STARTS with git still meets the runtime/secret read guard in full. - mixed = registry._run_shell_safety_check( - {"cmd": ["sh", "-c", f"git status && cat {(data / 'settings.json').as_posix()}"]}, - "advanced", - ) - assert mixed and "WORKSPACE_SHELL_BLOCKED" in mixed, mixed - - -def test_workspace_shell_git_ls_remote_requires_network_contract(tmp_path): - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - system_repo.mkdir() - data.mkdir() - _init_repo_with_file(workspace) - contract = { - "allowed_resources": {"network": False}, - "resource_policy": {}, - } - ctx = ToolContext( - repo_dir=system_repo, - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_contract=contract, - task_metadata={"task_contract": contract}, - ) - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - for cmd in ( - ["git", "ls-remote", "origin"], - ["git", "submodule", "update", "--init", "--recursive"], - ): - result = registry._run_shell_safety_check({"cmd": cmd}, "advanced") - assert result and "RESOURCE_CONSTRAINT_BLOCKED" in result, (cmd, result) - - -def test_workspace_run_shell_allows_absolute_cwd_under_workspace_and_child_drive(tmp_path): - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - parent_data = tmp_path / "data" - parent_task_dir = parent_data / "task_drives" / "task-workspace" / "scratch" - child_drive = tmp_path / "child-data" - child_dir = child_drive / "task_drives" / "task-workspace" / "scratch" - child_control_dir = child_drive / "memory" - for path in (system_repo, workspace, parent_data / "logs", parent_task_dir, child_dir, child_control_dir): - path.mkdir(parents=True) - ctx = ToolContext( - repo_dir=system_repo, - drive_root=parent_data, - workspace_root=workspace, - workspace_mode="external", - task_id="task-workspace", - task_metadata={"drive_root": str(child_drive), "budget_drive_root": str(parent_data)}, - ) - registry = ToolRegistry(repo_dir=system_repo, drive_root=parent_data) - registry.set_context(ctx) - - def assert_python_cwd(path): - output = registry.execute( - "run_command", - {"cmd": [sys.executable, "-c", "import os; print(os.getcwd())"], "cwd": str(path)}, - ) - assert "exit_code=0" in output - cwd_output = output.rsplit("STDOUT:\n", 1)[-1].strip() - assert pathlib.Path(cwd_output).resolve() == path.resolve() - - assert_python_cwd(workspace) - assert_python_cwd(child_dir) - child_control = registry.execute("run_command", {"cmd": ["pwd"], "cwd": str(child_control_dir)}) - assert "SHELL_CWD_BLOCKED" in child_control - blocked = registry.execute("run_command", {"cmd": ["pwd"], "cwd": str(parent_data / "logs")}) - assert "SHELL_CWD_BLOCKED" in blocked - # Read-only git is allowed everywhere now; the escape check uses a MUTATING form. - git_read = registry._run_shell_safety_check( - {"cmd": ["git", "-C", "../other-repo", "status"], "cwd": str(child_dir)}, - "advanced", - ) - assert git_read is None, git_read - git_escape = registry._run_shell_safety_check( - {"cmd": ["git", "-C", "..", "commit", "-m", "x"], "cwd": str(child_dir)}, - "advanced", - ) - assert git_escape and "WORKSPACE_GIT_BLOCKED" in git_escape, git_escape - protected_escape = registry._run_shell_safety_check( - {"cmd": ["touch", "../data/state/state.json"]}, - "pro", - ) - assert "WORKSPACE_SHELL_BLOCKED" in protected_escape - task_drive_write = registry.execute("run_command", {"cmd": ["touch", "output.txt"], "cwd": str(child_dir)}) - assert "WORKSPACE_SHELL_BLOCKED" not in task_drive_write - assert (child_dir / "output.txt").is_file() - parent_task_drive_write = registry.execute("run_command", {"cmd": ["touch", "output.txt"], "cwd": str(parent_task_dir)}) - assert "WORKSPACE_SHELL_BLOCKED" not in parent_task_drive_write - assert (parent_task_dir / "output.txt").is_file() - absolute_task_drive_file = parent_task_dir / "absolute-python.txt" - absolute_task_drive_write = registry.execute( - "run_command", - {"cmd": [sys.executable, "-c", f"open({str(absolute_task_drive_file)!r}, 'w').write('ok')"]}, - ) - assert "WORKSPACE_SHELL_BLOCKED" not in absolute_task_drive_write - assert absolute_task_drive_file.read_text(encoding="utf-8") == "ok" - - -def test_workspace_shell_allows_nested_relative_write_paths(tmp_path): - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - for path in (system_repo, workspace, data): - path.mkdir(parents=True) - ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace, workspace_mode="external") - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - assert registry._run_shell_safety_check({"cmd": ["touch", "subdir/file.txt"]}, "advanced") is None - assert registry._run_shell_safety_check({"cmd": ["mkdir", "-p", "build/output"]}, "advanced") is None - python_write = {"cmd": [sys.executable, "-c", "open('subdir/python.txt', 'w').write('ok')"]} - assert registry._run_shell_safety_check(python_write, "advanced") is None - - -def test_workspace_shell_sudo_and_pro_passthrough_policy(tmp_path): - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - for path in (system_repo, workspace, data): - path.mkdir() - ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace, workspace_mode="external") - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - assert "SUDO_INTERACTIVE_BLOCKED" in registry._run_shell_safety_check({"cmd": ["sudo", "true"]}, "pro") - assert "SUDO_INTERACTIVE_BLOCKED" in registry._run_shell_safety_check({"cmd": ["sh", "-c", "sudo true"]}, "pro") - assert "SUDO_INTERACTIVE_BLOCKED" in registry._run_shell_safety_check({"cmd": ["sudo", "-S", "true"]}, "pro") - assert "SUDO_INTERACTIVE_BLOCKED" in registry._run_shell_safety_check({"cmd": ["sudo", "-nS", "true"]}, "pro") - assert "SUDO_INTERACTIVE_BLOCKED" in registry._run_shell_safety_check({"cmd": ["sudoedit", "/etc/hosts"]}, "pro") - assert registry._run_shell_safety_check({"cmd": ["sudo", "-n", "python", "-S", "-c", "print(1)"]}, "pro") is None - assert "SAFETY_VIOLATION" in registry._run_shell_safety_check({"cmd": ["sh", "-c", "gh\nrepo\ncreate x"]}, "pro") - assert "SAFETY_VIOLATION" in registry._run_shell_safety_check({"cmd": ["sh", "-c", "gh\nauth\nlogin"]}, "pro") - outside_write = {"cmd": ["python", "-c", "open('/tmp/ouroboros-pro.txt','w').write('x')"]} - assert "WORKSPACE_SHELL_BLOCKED" in registry._run_shell_safety_check(outside_write, "advanced") - assert registry._run_shell_safety_check(outside_write, "pro") is None - - -def test_workspace_preflight_infers_binaries_from_script_commands(): - tools = _infer_tools_from_manifests([ - { - "type": "node", - "scripts": ["test"], - "script_commands": {"test": "vitest --run"}, - } - ]) - assert "vitest" in tools - assert "test" not in tools - noisy = _infer_tools_from_manifests([ - { - "type": "node", - "scripts": ["build"], - "script_commands": {"build": "NODE_ENV=production cd web && vite build"}, - } - ]) - assert "NODE_ENV=production" not in noisy - assert "cd" not in noisy - assert "vite" in noisy - - -def test_workspace_patch_includes_tracked_and_untracked_files(tmp_path): - repo = tmp_path / "repo" - repo.mkdir() - subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) - (repo / "tracked.txt").write_text("old\n", encoding="utf-8") - subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True) - subprocess.run( - ["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "init"], - cwd=repo, - check=True, - capture_output=True, - ) - (repo / "tracked.txt").write_text("new\n", encoding="utf-8") - (repo / "new.txt").write_text("hello\n", encoding="utf-8") - - patch = build_workspace_patch(repo) - - assert "diff --git a/tracked.txt b/tracked.txt" in patch - assert "+new" in patch - assert "diff --git" in patch and "new.txt" in patch - - -def test_workspace_patch_lockfile_without_manifest_is_incidental_only_with_code_changes(): - assert _incidental_lockfile_excludes(["package-lock.json"]) == set() - assert _incidental_lockfile_excludes(["package-lock.json", "package.json", "app.js"]) == set() - assert _incidental_lockfile_excludes(["package-lock.json", "app.js"]) == {"package-lock.json"} - assert _incidental_lockfile_excludes(["pkg/poetry.lock", "pkg/module.py"]) == {"pkg/poetry.lock"} - - -def test_workspace_patch_preserves_lockfile_when_other_changes_are_junk(tmp_path): - repo = tmp_path / "repo" - repo.mkdir() - subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) - (repo / "README.md").write_text("base\n", encoding="utf-8") - subprocess.run(["git", "add", "README.md"], cwd=repo, check=True, capture_output=True) - subprocess.run( - ["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "init"], - cwd=repo, - check=True, - capture_output=True, - ) - (repo / "package-lock.json").write_text('{"lockfileVersion": 3}\n', encoding="utf-8") - (repo / "dist").mkdir() - (repo / "dist" / "out.txt").write_text("junk\n", encoding="utf-8") - - _artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) - patch = (tmp_path / "artifacts" / "workspace.patch").read_text(encoding="utf-8") - - assert "package-lock.json" in patch - assert "dist/out.txt" not in patch - assert manifest["counts"]["untracked_included"] == 1 - assert manifest["counts"]["untracked_excluded"] == 1 - - -def test_workspace_patch_excludes_binary_junk_and_oversize(tmp_path, monkeypatch): - """T7 (v6.35.0): the real-usage workspace patch drops untracked build/runtime - binaries, junk artifacts, and oversize blobs (recorded, not silently lost), - while keeping real source additions.""" - import ouroboros.headless as headless - - repo = tmp_path / "repo" - repo.mkdir() - subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) - (repo / "seed.txt").write_text("seed\n", encoding="utf-8") - subprocess.run(["git", "add", "seed.txt"], cwd=repo, check=True, capture_output=True) - subprocess.run( - ["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "init"], - cwd=repo, check=True, capture_output=True, - ) - # Untracked additions: a real source file (keep), a compiled binary (drop), - # a redis dump + log junk (drop), and an oversize text file (drop). - (repo / "fix.py").write_text("def fixed():\n return 1\n", encoding="utf-8") - (repo / "app").write_bytes(b"\x7fELF\x00\x01\x02\x03binary\x00blob") # compiled binary - (repo / "dump.rdb").write_bytes(b"REDIS\x00\x01") - (repo / "run.log").write_text("noise\n", encoding="utf-8") - (repo / "htmlcov").mkdir() - (repo / "htmlcov" / "index.html").write_text("\n", encoding="utf-8") # top-level coverage junk - monkeypatch.setattr(headless, "_PATCH_MAX_UNTRACKED_FILE_BYTES", 100) - (repo / "big.txt").write_text("x" * 200, encoding="utf-8") # 200 bytes > cap; small files pass size - - artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) - - assert manifest["exclude_rules_version"] == 2 - excluded = {item["path"]: item["reason"] for item in manifest["untracked_excluded"]} - assert "binary file" in excluded.get("app", "") - assert "binary file" in excluded.get("dump.rdb", "") or "junk artifact" in excluded.get("dump.rdb", "") - assert "junk artifact" in excluded.get("run.log", "") - assert "junk artifact" in excluded.get("htmlcov/index.html", "") # top-level htmlcov excluded - assert "size cap" in excluded.get("big.txt", "") - assert "fix.py" in manifest["untracked_included"] - patch = (tmp_path / "artifacts" / "workspace.patch").read_text(encoding="utf-8") - assert "fix.py" in patch - assert "diff --git a/app b/app" not in patch - assert "dump.rdb" not in patch - assert "run.log" not in patch - assert "big.txt" not in patch - - -def test_workspace_patch_supports_unborn_git_worktree(tmp_path): - repo = tmp_path / "repo" - repo.mkdir() - subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) - (repo / "created.txt").write_text("hello\n", encoding="utf-8") - - artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) - - assert manifest["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES - assert manifest["base_is_empty_tree"] is True - assert manifest["base_head"] == "(unborn)" - assert manifest["current_head"] == "(unborn)" - assert any(item["kind"] == "workspace_patch" for item in artifacts) - patch = (tmp_path / "artifacts" / "workspace.patch").read_text(encoding="utf-8") - assert "created.txt" in patch - assert "+hello" in patch - head = subprocess.run(["git", "rev-parse", "--verify", "HEAD"], cwd=repo, capture_output=True) - assert head.returncode != 0 - - -def test_workspace_patch_supports_unborn_sha256_git_worktree(tmp_path): - repo = tmp_path / "repo" - repo.mkdir() - init = subprocess.run(["git", "init", "--object-format=sha256"], cwd=repo, capture_output=True) - if init.returncode != 0: - pytest.skip("git does not support sha256 object-format") - (repo / "created.txt").write_text("hello\n", encoding="utf-8") - - artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) - - assert manifest["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES - assert manifest["base_is_empty_tree"] is True - assert len(manifest["base_ref"]) == 64 - assert any(item["kind"] == "workspace_patch" for item in artifacts) - - -def test_workspace_patch_allows_external_workspace_first_commit(tmp_path): - repo = tmp_path / "repo" - repo.mkdir() - subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) - (repo / "created.txt").write_text("hello\n", encoding="utf-8") - subprocess.run(["git", "add", "created.txt"], cwd=repo, check=True, capture_output=True) - subprocess.run( - ["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "first"], - cwd=repo, - check=True, - capture_output=True, - ) - task = {"metadata": {"workspace_preflight": {"git": {"head": ""}}}} - - artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task=task) - - assert manifest["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES - assert manifest["errors"] == [] - assert any(item["kind"] == "workspace_patch" for item in artifacts) - - -def test_workspace_patch_fails_on_invalid_head_not_unborn(tmp_path): - repo = tmp_path / "repo" - _init_repo_with_file(repo) - head_ref = subprocess.run(["git", "symbolic-ref", "--quiet", "HEAD"], cwd=repo, capture_output=True, text=True, check=True).stdout.strip() - ref_path = repo / ".git" / head_ref - ref_path.unlink() - - artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) - - assert manifest["status"] == ARTIFACT_STATUS_FAILED - assert any(error["type"] == "git_invalid_head" for error in manifest["errors"]) - assert not any(item["kind"] == "workspace_patch" for item in artifacts) - - -def test_workspace_patch_manifest_excludes_env_cache_dirs(tmp_path): - repo = tmp_path / "repo" - _init_repo_with_file(repo) - (repo / "new.txt").write_text("hello\n", encoding="utf-8") - (repo / "node_modules" / "pkg").mkdir(parents=True) - (repo / "node_modules" / "pkg" / "index.js").write_text("generated\n", encoding="utf-8") - artifact_dir = tmp_path / "artifacts" - - artifacts, manifest = write_workspace_patch_artifacts(repo, artifact_dir, task={}) - - assert manifest["status"] == "ready_with_changes" - assert "new.txt" in (artifact_dir / "workspace.patch").read_text(encoding="utf-8") - assert "node_modules" not in (artifact_dir / "workspace.patch").read_text(encoding="utf-8") - assert manifest["counts"]["untracked_excluded"] == 1 - assert any(item["kind"] == "workspace_patch_manifest" for item in artifacts) - - -def test_workspace_patch_fails_on_sensitive_untracked_file(tmp_path): - repo = tmp_path / "repo" - _init_repo_with_file(repo) - (repo / ".npmrc").write_text("//registry.npmjs.org/:_authToken=secret\n", encoding="utf-8") - - artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) - - assert manifest["status"] == ARTIFACT_STATUS_FAILED - assert manifest["errors"][0]["type"] == "sensitive_untracked_files" - assert manifest["sensitive_blocked"][0]["path"] == ".npmrc" - assert not any(item["kind"] == "workspace_patch" for item in artifacts) - - -def test_workspace_patch_fails_on_sensitive_untracked_file_inside_excluded_dir(tmp_path): - repo = tmp_path / "repo" - _init_repo_with_file(repo) - secret = repo / "node_modules" / "pkg" / "service-account.json" - secret.parent.mkdir(parents=True) - secret.write_text("TOKEN=secret\n", encoding="utf-8") - - artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) - - assert manifest["status"] == ARTIFACT_STATUS_FAILED - assert manifest["counts"]["sensitive_blocked"] == 1 - assert manifest["sensitive_blocked"][0]["path"] == "node_modules/pkg/service-account.json" - assert not any(item["kind"] == "workspace_patch" for item in artifacts) - - -def test_workspace_patch_fails_on_common_credential_paths(tmp_path): - repo = tmp_path / "repo" - _init_repo_with_file(repo) - (repo / "credentials").write_text("secret\n", encoding="utf-8") - (repo / "prod.env").write_text("SECRET=1\n", encoding="utf-8") - (repo / "settings.env.local").write_text("SECRET=1\n", encoding="utf-8") - (repo / ".aws").mkdir() - (repo / ".aws" / "credentials").write_text("secret\n", encoding="utf-8") - - artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) - - assert manifest["status"] == ARTIFACT_STATUS_FAILED - assert {item["path"] for item in manifest["sensitive_blocked"]} == { - "credentials", - "prod.env", - "settings.env.local", - ".aws/credentials", - } - assert not any(item["kind"] == "workspace_patch" for item in artifacts) - - -def test_workspace_patch_allows_benign_tokenizer_json(tmp_path): - repo = tmp_path / "repo" - _init_repo_with_file(repo) - (repo / "tokenizer.json").write_text("{}\n", encoding="utf-8") - - artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) - - assert manifest["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES - assert manifest["sensitive_blocked"] == [] - assert any(item["kind"] == "workspace_patch" for item in artifacts) - - -def test_failed_refinalization_drops_stale_workspace_patch_metadata(tmp_path): - parent = tmp_path / "data" - repo = tmp_path / "repo" - parent.mkdir() - _init_repo_with_file(repo) - (repo / "tracked.txt").write_text("new\n", encoding="utf-8") - task = {"id": "task-stale", "workspace_root": str(repo)} - write_task_result(parent, "task-stale", "completed", workspace_root=str(repo), artifact_status="finalizing") - finalize_task_artifacts(parent, task) - result = json.loads((parent / "task_results" / "task-stale.json").read_text(encoding="utf-8")) - assert any(item.get("kind") == "workspace_patch" for item in result["artifacts"]) - - (repo / ".env").write_text("TOKEN=secret\n", encoding="utf-8") - finalize_task_artifacts(parent, task) - - result = json.loads((parent / "task_results" / "task-stale.json").read_text(encoding="utf-8")) - assert result["artifact_status"] == ARTIFACT_STATUS_FAILED - assert not any(item.get("kind") == "workspace_patch" for item in result["artifacts"]) - - -def test_workspace_patch_preserves_untracked_paths_with_whitespace(tmp_path): - repo = tmp_path / "repo" - _init_repo_with_file(repo) - leading = repo / " leading.txt" - nested = repo / "dir with space" / "file name.txt" - leading.write_text("leading\n", encoding="utf-8") - nested.parent.mkdir() - nested.write_text("nested\n", encoding="utf-8") - - _artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) - - assert manifest["status"] == "ready_with_changes" - assert " leading.txt" in manifest["untracked_included"] - assert "dir with space/file name.txt" in manifest["untracked_included"] - assert manifest["patch_size"] > 0 - - -def test_finalize_workspace_patch_allows_external_workspace_head_changed(tmp_path): - parent = tmp_path / "data" - repo = tmp_path / "repo" - parent.mkdir() - _init_repo_with_file(repo) - old_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, check=True).stdout.strip() - (repo / "tracked.txt").write_text("new\n", encoding="utf-8") - subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True) - subprocess.run(["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "move"], cwd=repo, check=True, capture_output=True) - task = { - "id": "task-head", - "workspace_root": str(repo), - "metadata": {"workspace_preflight": {"git": {"head": old_head}}}, - } - write_task_result(parent, "task-head", "completed", workspace_root=str(repo), artifact_status="finalizing") - - finalize_task_artifacts(parent, task) - - result = json.loads((parent / "task_results" / "task-head.json").read_text(encoding="utf-8")) - assert result["artifact_status"] == ARTIFACT_STATUS_READY_WITH_CHANGES - manifest = json.loads((task_artifacts_dir(parent, "task-head") / "workspace_patch.json").read_text(encoding="utf-8")) - assert manifest["errors"] == [] - - -def test_finalize_workspace_patch_exception_manifest_keeps_base_fields(tmp_path, monkeypatch): - import ouroboros.headless as headless - - parent = tmp_path / "data" - repo = tmp_path / "repo" - parent.mkdir() - _init_repo_with_file(repo) - task = {"id": "task-exception", "workspace_root": str(repo)} - write_task_result(parent, "task-exception", "completed", workspace_root=str(repo), artifact_status="finalizing") - - def boom(*_args, **_kwargs): - raise RuntimeError("artifact failure") - - monkeypatch.setattr(headless, "write_workspace_patch_artifacts", boom) - headless.finalize_task_artifacts(parent, task) - - result = json.loads((parent / "task_results" / "task-exception.json").read_text(encoding="utf-8")) - manifest = json.loads((task_artifacts_dir(parent, "task-exception") / "workspace_patch.json").read_text(encoding="utf-8")) - assert result["artifact_status"] == ARTIFACT_STATUS_FAILED - assert manifest["status"] == ARTIFACT_STATUS_FAILED - assert manifest["base_ref"] == "" - assert manifest["base_head"] == "" - assert manifest["base_is_empty_tree"] is False - assert manifest["current_head"] == "" - - -def test_workspace_patch_uses_acting_base_sha_without_preflight_metadata(tmp_path): - repo = tmp_path / "repo" - _init_repo_with_file(repo) - base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, check=True).stdout.strip() - (repo / "tracked.txt").write_text("acting edit\n", encoding="utf-8") - task = { - "task_constraint": { - "mode": "acting_subagent", - "surface": "self_worktree", - "base_sha": base_head, - }, - } - - artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task=task) - - assert manifest["status"] == "ready_with_changes" - assert manifest["base_ref"] == base_head - assert manifest["base_head"] == base_head - assert manifest["current_head"] == base_head - assert any(item["kind"] == "workspace_patch" for item in artifacts) - - -def test_workspace_patch_fails_when_acting_base_sha_head_changed(tmp_path): - repo = tmp_path / "repo" - _init_repo_with_file(repo) - base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, check=True).stdout.strip() - (repo / "tracked.txt").write_text("committed by child\n", encoding="utf-8") - subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True) - subprocess.run(["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "child commit"], cwd=repo, check=True, capture_output=True) - moved_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, check=True).stdout.strip() - task = { - "task_constraint": { - "mode": "acting_subagent", - "surface": "self_worktree", - "base_sha": base_head, - }, - } - - artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task=task) - - assert manifest["status"] == ARTIFACT_STATUS_FAILED - assert manifest["base_ref"] == base_head - assert manifest["errors"][-1]["type"] == "workspace_head_changed" - assert manifest["errors"][-1]["expected_head"] == base_head - assert manifest["errors"][-1]["current_head"] == moved_head - assert not any(item["kind"] == "workspace_patch" for item in artifacts) - - -def test_copy_child_result_cannot_overwrite_finalized_accounting(tmp_path): - """F2: once the root's terminal checkpoint has finalized accounting - (task_cost_finalized rides the same write as post_task_synthesis), a late - headless-mirror copy-back may still enrich the result but the parent-owned - cost/round/token fields stay finalized (the saga displayed the $66 root-only - mirror cost instead of the $128 finalized subtree total).""" - from ouroboros.headless import copy_child_task_result - from ouroboros.task_results import STATUS_COMPLETED - - parent = tmp_path / "data" - child = tmp_path / "child" - parent.mkdir() - child.mkdir() - task_id = "costfinal" - write_task_result( - parent, task_id, STATUS_COMPLETED, - result="root done", - root_phase_checkpoint={"post_task_synthesis": "completed"}, - cost_usd=127.97, cost_final=True, - cost_usd_with_children=127.97, cost_with_children_partial=False, - total_rounds=200, prompt_tokens=1000, completion_tokens=500, - ) - write_task_result( - child, task_id, STATUS_COMPLETED, - result="mirror done", - cost_usd=66.30, cost_final=True, - cost_usd_with_children=66.30, cost_with_children_partial=True, - total_rounds=150, prompt_tokens=700, completion_tokens=300, - mirror_only_fact="from-child", - ) - - merged = copy_child_task_result(parent, {"id": task_id, "drive_root": str(child)}) - - assert merged is not None - assert merged["cost_usd"] == 127.97 - assert merged["cost_usd_with_children"] == 127.97 - assert merged["cost_with_children_partial"] is False - assert merged["total_rounds"] == 200 - assert merged["prompt_tokens"] == 1000 - assert merged["completion_tokens"] == 500 - # Non-accounting enrichment from the child mirror still lands. - assert merged["mirror_only_fact"] == "from-child" - assert merged["result"] == "mirror done" - assert merged["root_phase_checkpoint"]["post_task_synthesis"] == "completed" - - -def test_copy_child_result_merges_cost_before_finalization(tmp_path): - """Before the terminal checkpoint finalizes accounting, the child mirror's - cost projection is still the freshest fact and must keep flowing.""" - from ouroboros.headless import copy_child_task_result - from ouroboros.task_results import STATUS_COMPLETED - - parent = tmp_path / "data" - child = tmp_path / "child" - parent.mkdir() - child.mkdir() - task_id = "costlive" - write_task_result(parent, task_id, STATUS_COMPLETED, result="root running") - write_task_result( - child, task_id, STATUS_COMPLETED, - result="mirror done", cost_usd=12.5, total_rounds=42, - ) - - merged = copy_child_task_result(parent, {"id": task_id, "drive_root": str(child)}) - - assert merged is not None - assert merged["cost_usd"] == 12.5 - assert merged["total_rounds"] == 42 - - -def test_effective_result_preserves_workspace_artifact_status_with_child_drive(tmp_path): - from ouroboros.headless import copy_child_task_result - from ouroboros.task_results import STATUS_COMPLETED - from ouroboros.task_status import load_effective_task_result - - parent = tmp_path / "data" - child = tmp_path / "child" - repo = tmp_path / "repo" - parent.mkdir() - child.mkdir() - _init_repo_with_file(repo) - old_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, check=True).stdout.strip() - (repo / "tracked.txt").write_text("new\n", encoding="utf-8") - subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True) - subprocess.run( - ["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "move"], - cwd=repo, - check=True, - capture_output=True, - ) - task_id = "patchfail" - write_task_result( - child, - task_id, - STATUS_COMPLETED, - result="child done", - artifact_status=ARTIFACT_STATUS_READY, - artifact_bundle={"status": ARTIFACT_STATUS_READY, "artifacts": [], "errors": []}, - ts="2026-01-01T00:00:02Z", - ) - ledger_path = parent / "task_results" / "artifacts" / task_id / "verification_ledger.json" - ledger_path.parent.mkdir(parents=True) - ledger_path.write_text( - json.dumps({ - "schema_version": 2, - "outcome_axes": { - "artifacts": {"status": "finalizing"}, - "objective": {"status": "not_evaluated", "source": "none"}, - }, - "entries": [{"kind": "objective_outcome", "status": "not_evaluated"}], - }), - encoding="utf-8", - ) - write_task_result( - parent, - task_id, - STATUS_COMPLETED, - result="child done", - workspace_root=str(repo), - child_drive_root=str(child), - artifact_status="finalizing", - artifacts=[{"kind": "verification_ledger", "name": "verification_ledger.json", "path": str(ledger_path)}], - child_status=STATUS_COMPLETED, - ) - - finalize_task_artifacts( - parent, - { - "id": task_id, - "workspace_root": str(repo), - "drive_root": str(child), - "metadata": {"workspace_preflight": {"git": {"head": old_head}}}, - }, - ) - - effective = load_effective_task_result(parent, task_id) - assert effective["artifact_status"] == ARTIFACT_STATUS_READY_WITH_CHANGES - assert not effective.get("artifact_error") - assert effective["artifact_bundle"]["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES - refreshed_ledger = json.loads(ledger_path.read_text(encoding="utf-8")) - assert refreshed_ledger["outcome_axes"]["artifacts"]["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES - - copied = copy_child_task_result(parent, {"id": task_id, "workspace_root": str(repo), "drive_root": str(child)}) - assert copied is not None - assert copied["artifact_status"] == ARTIFACT_STATUS_READY_WITH_CHANGES - assert not copied.get("artifact_error") - assert copied["artifact_bundle"]["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES - - readonly_task_id = "readonlychild" - write_task_result( - child, - readonly_task_id, - STATUS_COMPLETED, - result="readonly handoff", - workspace_root=str(repo), - workspace_mode="external", - delegation_role="subagent", - task_constraint={"mode": "local_readonly_subagent"}, - ) - copied_readonly = copy_child_task_result( - parent, - { - "id": readonly_task_id, - "workspace_root": str(repo), - "drive_root": str(child), - "delegation_role": "subagent", - "task_constraint": {"mode": "local_readonly_subagent"}, - }, - ) - assert copied_readonly is not None - assert copied_readonly.get("artifact_status", "") != "finalizing" - assert "child_status" not in copied_readonly - effective_readonly = load_effective_task_result(parent, readonly_task_id) - assert effective_readonly["status"] == STATUS_COMPLETED - assert effective_readonly["workspace_root"] == str(repo) - - -def test_child_copyback_preserves_acceptance_verdict_and_terminal_post_task_marker(tmp_path): - from ouroboros.headless import copy_child_task_result - from ouroboros.task_results import STATUS_COMPLETED, write_task_result - - parent = tmp_path / "data" - child = tmp_path / "child" - parent.mkdir() - child.mkdir() - task_id = "root-checkpoint" - write_task_result( - child, - task_id, - STATUS_COMPLETED, - root_phase_checkpoint={ - "phase": "task_acceptance", - "status": "degraded", - "pass_index": 2, - "post_task_synthesis": "pending_once", - }, - ) - write_task_result( - parent, - task_id, - STATUS_COMPLETED, - root_phase_checkpoint={ - "phase": "task_acceptance", - "status": "not_required", - "pass_index": 0, - "post_task_synthesis": "completed", - }, - ) - - copied = copy_child_task_result(parent, {"id": task_id, "drive_root": str(child)}) - - assert copied is not None - assert copied["root_phase_checkpoint"] == { - "phase": "task_acceptance", - "status": "degraded", - "pass_index": 2, - "post_task_synthesis": "completed", - } - - -def test_finalize_task_artifacts_preserves_existing_artifact_axis_fields(tmp_path): - from ouroboros.cli import _is_terminal_result - from ouroboros.task_results import STATUS_COMPLETED, load_task_result - - parent = tmp_path / "data" - repo = tmp_path / "repo" - parent.mkdir() - _init_repo_with_file(repo) - (repo / "tracked.txt").write_text("new\n", encoding="utf-8") - task_id = "axisfields" - write_task_result( - parent, - task_id, - STATUS_COMPLETED, - workspace_root=str(repo), - artifact_status=ARTIFACT_STATUS_FINALIZING, - artifact_bundle={"schema_version": 1, "status": "pending", "artifacts": [], "errors": []}, - outcome_axes={ - "lifecycle": {"status": STATUS_COMPLETED}, - "artifacts": { - "status": ARTIFACT_STATUS_FINALIZING, - "diagnostics": {"existing": True}, - "error_count": 0, - }, - "objective": {"status": "not_evaluated", "source": "none"}, - }, - ) - - finalize_task_artifacts(parent, {"id": task_id, "workspace_root": str(repo)}) - - result = load_task_result(parent, task_id) - artifact_axis = result["outcome_axes"]["artifacts"] - assert artifact_axis["status"] == result["artifact_bundle"]["status"] - assert result["artifact_bundle"]["status"] == result["artifact_status"] - assert result["artifact_bundle"]["status"] not in {"pending", "finalizing"} - assert _is_terminal_result(result) is True - assert artifact_axis["diagnostics"] == {"existing": True} - assert artifact_axis["error_count"] == 0 - - -def test_effective_result_preserves_workspace_patch_kind_with_child_drive(tmp_path): - from ouroboros.artifacts import copy_file_to_task_artifacts - from ouroboros.cli import _patch_from_result - from ouroboros.task_results import STATUS_COMPLETED - from ouroboros.task_status import load_effective_task_result - - parent = tmp_path / "data" - child = tmp_path / "child" - repo = tmp_path / "repo" - parent.mkdir() - child.mkdir() - _init_repo_with_file(repo) - (repo / "tracked.txt").write_text("new\n", encoding="utf-8") - - task_id = "patchkind" - report = tmp_path / "report.html" - report.write_text("

done

", encoding="utf-8") - child_record = copy_file_to_task_artifacts(SimpleNamespace(drive_root=child, task_id=task_id), report, kind="user_file") - assert child_record is not None - write_task_result( - child, - task_id, - STATUS_COMPLETED, - result="child done", - artifacts=[child_record], - artifact_status=ARTIFACT_STATUS_READY, - ts="2026-01-01T00:00:02Z", - ) - write_task_result( - parent, - task_id, - STATUS_COMPLETED, - result="child done", - workspace_root=str(repo), - child_drive_root=str(child), - artifacts=[child_record], - artifact_status="finalizing", - child_status=STATUS_COMPLETED, - ) - - finalize_task_artifacts(parent, {"id": task_id, "workspace_root": str(repo), "drive_root": str(child)}) - - effective = load_effective_task_result(parent, task_id) - patch_artifacts = [ - item - for item in effective.get("artifacts") or [] - if isinstance(item, dict) and item.get("name") == "workspace.patch" - ] - assert patch_artifacts - assert patch_artifacts[0]["kind"] == "workspace_patch" - assert any(item.get("kind") == "user_file" for item in effective.get("artifacts") or [] if isinstance(item, dict)) - - class FakeClient: - def __init__(self): - self.paths = [] - - def get_bytes(self, path): - self.paths.append(path) - return b"diff --git a/tracked.txt b/tracked.txt\n" - - client = FakeClient() - assert _patch_from_result(client, task_id, effective, strict=True).startswith("diff --git") - assert client.paths == [f"/api/tasks/{task_id}/artifacts/workspace.patch"] - - -def test_task_artifact_endpoint_serves_only_declared_artifacts(tmp_path): - data = tmp_path / "data" - artifact_dir = task_artifacts_dir(data, "task-artifact") - patch_path = artifact_dir / "workspace.patch" - patch_path.write_text("diff --git a/a b/a\n", encoding="utf-8") - write_task_result( - data, - "task-artifact", - "completed", - artifacts=[{"kind": "workspace_patch", "name": "workspace.patch", "path": str(patch_path), "size": patch_path.stat().st_size}], - artifact_status="ready", - ) - app = Starlette(routes=[Route("/api/tasks/{task_id}/artifacts/{name}", endpoint=api_task_artifact, methods=["GET"])]) - app.state.drive_root = data - client = TestClient(app) - - assert client.get("/api/tasks/task-artifact/artifacts/workspace.patch").text.startswith("diff --git") - assert client.get("/api/tasks/task-artifact/artifacts/missing.patch").status_code == 404 - assert client.get("/api/tasks/task-artifact/artifacts/bad%5Cname").status_code == 400 - - -def test_task_artifact_endpoint_serves_exact_chat_media_without_task_result(tmp_path): - from ouroboros.artifacts import collect_task_artifact_records, store_chat_media_bytes - - data = tmp_path / "data" - stored = store_chat_media_bytes(data, "ephemeral1", b"photo-bytes", "image/png") - assert stored is not None - app = Starlette(routes=[Route("/api/tasks/{task_id}/artifacts/{name}", endpoint=api_task_artifact, methods=["GET"])]) - app.state.drive_root = data - client = TestClient(app) - - response = client.get(f"/api/tasks/ephemeral1/artifacts/{stored['name']}") - assert response.status_code == 200 - assert response.content == b"photo-bytes" - assert collect_task_artifact_records(data, "ephemeral1") == [] - - assert client.get("/api/tasks/ephemeral1/artifacts/chat-media-bad.png").status_code == 404 - - -def test_task_artifact_endpoint_serves_manifest_artifact_after_status_repair(tmp_path): - from ouroboros.artifacts import copy_file_to_task_artifacts - - data = tmp_path / "data" - source_dir = tmp_path / "Desktop" - source_dir.mkdir() - source = source_dir / "report.html" - source.write_text("

ok

", encoding="utf-8") - copy_file_to_task_artifacts(SimpleNamespace(drive_root=data, task_id="orphaned"), source, kind="user_file") - write_task_result( - data, - "orphaned", - "running", - result_status="infra_failed", - reason_code="provider_failure", - result="provider failed before normal finalization", - ) - (data / "state").mkdir(parents=True, exist_ok=True) - (data / "state" / "queue_snapshot.json").write_text('{"pending": [], "running": []}', encoding="utf-8") - app = Starlette(routes=[Route("/api/tasks/{task_id}/artifacts/{name}", endpoint=api_task_artifact, methods=["GET"])]) - app.state.drive_root = data - - response = TestClient(app).get("/api/tasks/orphaned/artifacts/report.html") - - assert response.status_code == 200 - assert response.text == "

ok

" - - -def test_task_artifact_endpoint_rebases_child_drive_artifact_after_status_repair(tmp_path): - from ouroboros.artifacts import collect_task_artifact_records, copy_file_to_task_artifacts - - data = tmp_path / "data" - child = tmp_path / "child" - source_dir = tmp_path / "Desktop" - source_dir.mkdir() - source = source_dir / "report.html" - source.write_text("

child

", encoding="utf-8") - copy_file_to_task_artifacts(SimpleNamespace(drive_root=child, task_id="childart"), source, kind="user_file") - child_artifacts = collect_task_artifact_records(child, "childart") - write_task_result( - child, - "childart", - "completed", - result="done", - artifacts=child_artifacts, - artifact_status="ready", - ts="2026-01-01T00:00:02Z", - ) - write_task_result( - data, - "childart", - "running", - child_drive_root=str(child), - workspace_root=str(tmp_path / "workspace"), - result_status="infra_failed", - reason_code="provider_failure", - result="provider failed before normal finalization", - ) - (data / "state").mkdir(parents=True, exist_ok=True) - (data / "state" / "queue_snapshot.json").write_text('{"pending": [], "running": []}', encoding="utf-8") - app = Starlette(routes=[Route("/api/tasks/{task_id}/artifacts/{name}", endpoint=api_task_artifact, methods=["GET"])]) - app.state.drive_root = data - - response = TestClient(app).get("/api/tasks/childart/artifacts/report.html") - - parent_artifact = task_artifacts_dir(data, "childart", create=False) / "report.html" - assert response.status_code == 200 - assert response.text == "

child

" - assert parent_artifact.read_text(encoding="utf-8") == "

child

" - - -def test_task_artifact_endpoint_rejects_metadata_name_path_mismatch(tmp_path): - data = tmp_path / "data" - artifact_dir = task_artifacts_dir(data, "task-artifact") - wrong_path = artifact_dir / "memory_export.json" - wrong_path.write_text("{}", encoding="utf-8") - write_task_result( - data, - "task-artifact", - "completed", - artifacts=[{"kind": "workspace_patch", "name": "workspace.patch", "path": str(wrong_path), "size": wrong_path.stat().st_size}], - artifact_status="ready", - ) - app = Starlette(routes=[Route("/api/tasks/{task_id}/artifacts/{name}", endpoint=api_task_artifact, methods=["GET"])]) - app.state.drive_root = data - - assert TestClient(app).get("/api/tasks/task-artifact/artifacts/workspace.patch").status_code == 500 - - -def test_memory_export_includes_nested_memory_files(tmp_path): - drive = tmp_path / "child" - memory = drive / "memory" - nested = memory / "knowledge" / "patterns" - nested.mkdir(parents=True) - (memory / "identity.md").write_text("id\n", encoding="utf-8") - (nested / "cli.md").write_text("pattern\n", encoding="utf-8") - - export = build_memory_export(drive, {"id": "task-1", "memory_mode": "forked"}) - - assert export["files"]["identity.md"] == "id\n" - assert export["files"]["knowledge/patterns/cli.md"] == "pattern\n" - - -def test_startup_prune_removes_only_old_terminal_child_drives(tmp_path): - data = tmp_path / "data" - terminal_dir = data / "state" / "headless_tasks" / "oldterminal" - pending_dir = data / "state" / "headless_tasks" / "oldpending" - fresh_timestamp_dir = data / "state" / "headless_tasks" / "freshresult" - terminal_drive = terminal_dir / "data" - pending_drive = pending_dir / "data" - fresh_timestamp_drive = fresh_timestamp_dir / "data" - terminal_drive.mkdir(parents=True) - pending_drive.mkdir(parents=True) - fresh_timestamp_drive.mkdir(parents=True) - - now = time.time() - old = now - (8 * 86400) - old_iso = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(old)) - fresh_iso = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(now)) - write_task_result(data, "oldterminal", "completed", child_drive_root=str(terminal_drive), artifact_status="ready", result="done", ts=old_iso) - write_task_result(data, "oldpending", "scheduled", child_drive_root=str(pending_drive), result="queued") - write_task_result(data, "freshresult", "completed", child_drive_root=str(fresh_timestamp_drive), artifact_status="ready", result="done", ts=fresh_iso) - os.utime(terminal_dir, (old, old)) - os.utime(pending_dir, (old, old)) - os.utime(fresh_timestamp_dir, (old, old)) - - report = prune_headless_task_drives(data, retention_days=7, now=now) - - assert [item["task_id"] for item in report["pruned"]] == ["oldterminal"] - assert not terminal_dir.exists() - assert pending_dir.exists() - assert fresh_timestamp_dir.exists() - assert any(item["task_id"] == "oldpending" and item["reason"] == "parent_not_terminal" for item in report["skipped"]) - assert any(item["task_id"] == "freshresult" and item["reason"] == "younger_than_retention" for item in report["skipped"]) - - -def test_startup_prune_uses_effective_terminal_status(tmp_path): - data = tmp_path / "data" - task_drive = data / "task_drives" / "stalerun" - child_dir = data / "state" / "headless_tasks" / "stalechild" - child_drive = child_dir / "data" - task_drive.mkdir(parents=True) - child_drive.mkdir(parents=True) - (task_drive / "scratch.txt").write_text("scratch", encoding="utf-8") - (child_drive / "scratch.txt").write_text("child", encoding="utf-8") - (data / "state").mkdir(parents=True, exist_ok=True) - (data / "state" / "queue_snapshot.json").write_text('{"pending": [], "running": []}', encoding="utf-8") - - now = time.time() - old = now - (8 * 86400) - old_iso = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(old)) - for task_id, extra in ( - ("stalerun", {}), - ("stalechild", {"child_drive_root": str(child_drive)}), - ): - write_task_result( - data, - task_id, - "running", - result_status="infra_failed", - reason_code="provider_failure", - result="provider failed", - ts=old_iso, - **extra, - ) - os.utime(task_drive, (old, old)) - os.utime(child_dir, (old, old)) - - direct_report = prune_task_drives(data, retention_days=7, now=now) - child_report = prune_headless_task_drives(data, retention_days=7, now=now) - - assert [item["task_id"] for item in direct_report["pruned"]] == ["stalerun"] - assert [item["task_id"] for item in child_report["pruned"]] == ["stalechild"] - assert not task_drive.exists() - assert not child_dir.exists() - - -def test_startup_prune_removes_only_old_terminal_task_scratch(tmp_path): - data = tmp_path / "data" - old_terminal = data / "task_drives" / "oldterminal" - old_pending = data / "task_drives" / "oldpending" - fresh_terminal = data / "task_drives" / "freshterminal" - for path in (old_terminal, old_pending, fresh_terminal): - path.mkdir(parents=True) - (path / "scratch.txt").write_text("scratch", encoding="utf-8") - - now = time.time() - old = now - (8 * 86400) - old_iso = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(old)) - fresh_iso = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(now)) - write_task_result(data, "oldterminal", "completed", result="done", ts=old_iso) - write_task_result(data, "oldpending", "running", result="running") - write_task_result(data, "freshterminal", "completed", result="done", ts=fresh_iso) - os.utime(old_terminal, (old, old)) - os.utime(old_pending, (old, old)) - os.utime(fresh_terminal, (old, old)) - - report = prune_task_drives(data, retention_days=7, now=now) - - assert [item["task_id"] for item in report["pruned"]] == ["oldterminal"] - assert not old_terminal.exists() - assert old_pending.exists() - assert fresh_terminal.exists() - assert any(item["task_id"] == "oldpending" and item["reason"] == "task_not_terminal" for item in report["skipped"]) - assert any(item["task_id"] == "freshterminal" and item["reason"] == "younger_than_retention" for item in report["skipped"]) - - -def test_external_child_task_budget_uses_parent_drive_state(tmp_path, monkeypatch): - from ouroboros import usage_accounting - from ouroboros.agent import Env, OuroborosAgent - - repo = tmp_path / "repo" - parent = tmp_path / "parent-data" - child = tmp_path / "child-data" - for root in (repo, parent, child): - root.mkdir() - for drive in (parent, child): - (drive / "state").mkdir() - (drive / "logs").mkdir() - # Compatibility projections are deliberately misleading here: the physical-attempt - # ledger in the parent budget root is the sole monetary authority. - (parent / "state" / "state.json").write_text('{"spent_usd": 0.0}\n', encoding="utf-8") - (child / "state" / "state.json").write_text('{"spent_usd": 0.0}\n', encoding="utf-8") - reservation = usage_accounting.reserve_attempt(usage_accounting.AttemptRequest( - model="test/model", - provider="test", - reservation_usd=9.0, - drive_root=parent, - task_id="prior-task", - root_task_id="prior-task", - source="test", - )) - usage_accounting.mark_dispatched(reservation) - usage_accounting.settle_attempt(reservation, {}, cost_usd=9.0, cost_final=True) - - monkeypatch.setenv("TOTAL_BUDGET", "10") - monkeypatch.setattr(OuroborosAgent, "_log_worker_boot_once", lambda self: None) - monkeypatch.setattr("ouroboros.agent.build_llm_messages", lambda **kwargs: ([], {})) - - agent = OuroborosAgent(Env(repo_dir=repo, drive_root=child)) - ctx, _messages, cap_info = agent._prepare_task_context({ - "id": "budget-task", - "type": "task", - "text": "x", - "budget_drive_root": str(parent), - }) - - assert cap_info["budget_remaining"] == 1.0 - assert ctx.task_metadata["budget_drive_root"] == str(parent) +from tests._headless_cli_shared import ( # noqa: F401 (autouse fixture applies on import) + _managed_worker_pool_available, +) def test_cli_patch_downloads_http_artifact(): diff --git a/tests/test_headless_extraction.py b/tests/test_headless_extraction.py new file mode 100644 index 000000000..e0451fac1 --- /dev/null +++ b/tests/test_headless_extraction.py @@ -0,0 +1,123 @@ +"""Structural contracts for the semantic-no-op headless extraction.""" + +from __future__ import annotations + +import ast +import pathlib + +from ouroboros import headless, headless_status, workspace_patch_capture + + +REPO = pathlib.Path(__file__).parents[1] + +_LEAVES = (headless_status, workspace_patch_capture) + +_MOVED_OWNERS = { + "ARTIFACT_STATUS_FAILED": headless_status, + "ARTIFACT_STATUS_FINALIZING": headless_status, + "ARTIFACT_STATUS_MISSING": headless_status, + "ARTIFACT_STATUS_PENDING": headless_status, + "ARTIFACT_STATUS_READY": headless_status, + "ARTIFACT_STATUS_READY_NO_CHANGES": headless_status, + "ARTIFACT_STATUS_READY_WITH_CHANGES": headless_status, + "ARTIFACT_TERMINAL_STATUSES": headless_status, + "_ARTIFACT_LIFECYCLE_FIELDS": headless_status, + "_FINAL_STATUSES": headless_status, + "_LOCAL_READONLY_SUBAGENT_MODE": headless_status, + "SCRATCH_MANIFEST_NAME": workspace_patch_capture, + "_GIT_UNBORN_HEAD": workspace_patch_capture, + "_acting_constraint_from_task": workspace_patch_capture, + "_append_git_output": workspace_patch_capture, + "_empty_patch_manifest": workspace_patch_capture, + "_git_bytes": workspace_patch_capture, + "_git_empty_tree_oid": workspace_patch_capture, + "_git_path_list": workspace_patch_capture, + "_git_stdout": workspace_patch_capture, + "_head_reflog_exists": workspace_patch_capture, + "_looks_like_git_oid": workspace_patch_capture, + "_preflight_head_from_task": workspace_patch_capture, + "_preflight_head_present": workspace_patch_capture, + "_untracked_blob_exclude_reason": workspace_patch_capture, + "_workspace_patch_base": workspace_patch_capture, + "_write_patch_separator": workspace_patch_capture, + "build_workspace_patch": workspace_patch_capture, + "untracked_capture_veto_reason": workspace_patch_capture, + "write_workspace_patch_artifacts": workspace_patch_capture, +} + + +def test_headless_leaves_are_non_catalog_owners_without_headless_backedges(): + for module in (headless, *_LEAVES): + source_path = pathlib.Path(module.__file__) + tree = ast.parse(source_path.read_text(encoding="utf-8")) + assert not any( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "get_tools" + for node in tree.body + ) + for module in _LEAVES: + tree = ast.parse(pathlib.Path(module.__file__).read_text(encoding="utf-8")) + assert not any( + isinstance(node, ast.ImportFrom) and node.module == "ouroboros.headless" + for node in ast.walk(tree) + ) + assert not any( + isinstance(node, ast.Import) + and any(alias.name == "ouroboros.headless" for alias in node.names) + for node in ast.walk(tree) + ) + + # v7next transplant note: the reference test (ouroboros_v7_wip @ 9f691656) + # additionally proves the three modules stay out of the frozen tool-module + # inventory via ouroboros.tool_module_inventory; that leaf belongs to the + # tools domain and is not on this integration branch yet — the clause + # returns with its lane. The static guarantee it rested on is kept above: + # none of the three modules defines get_tools, so no catalog can adopt them. + + +def test_headless_public_export_list_is_unchanged(): + """``__all__`` is the module's declared contract; the extraction moved owners, + never the surface, so every published name still resolves on ``headless``.""" + assert headless.__all__ == [ + "ARTIFACT_STATUS_FAILED", + "ARTIFACT_STATUS_FINALIZING", + "ARTIFACT_STATUS_PENDING", + "ARTIFACT_STATUS_READY", + "build_memory_export", + "build_workspace_patch", + "copy_child_task_result", + "finalize_task_artifacts", + "task_is_readonly_subagent", + "prepare_task_drive", + "prune_headless_task_drives", + "prune_task_drives", + "task_artifacts_dir", + "task_state_dir", + "write_workspace_patch_artifacts", + "write_workspace_preflight_artifact", + ] + for name in headless.__all__: + assert hasattr(headless, name), name + + +def test_headless_facade_reexports_every_moved_identity(): + """``headless`` keeps the exact objects, so the supervisor, the gateway, + outcomes, task_status, artifacts and the delegation owners see no identity + change.""" + for name, owner in _MOVED_OWNERS.items(): + assert hasattr(headless, name), name + assert getattr(headless, name) is getattr(owner, name), name + owned = {name for module in _LEAVES for name in vars(module)} + assert set(_MOVED_OWNERS) <= owned + + +def test_headless_extraction_size_bounds_have_meaningful_headroom(): + counts = { + module.__name__: len( + pathlib.Path(module.__file__).read_text(encoding="utf-8").splitlines() + ) + for module in (headless, *_LEAVES) + } + assert counts["ouroboros.headless"] <= 1000 + assert all(count <= 1000 for count in counts.values()) + assert 400 <= counts["ouroboros.workspace_patch_capture"] <= 1000 diff --git a/tests/test_headless_task_api.py b/tests/test_headless_task_api.py new file mode 100644 index 000000000..0f3829f34 --- /dev/null +++ b/tests/test_headless_task_api.py @@ -0,0 +1,596 @@ +"""Gateway task-creation API: admission, validation and lineage authority. + +Split verbatim out of ``tests/test_headless_cli.py`` by theme. This module +owns ``POST /api/tasks`` behaviour — child-drive creation, reservation and +admission refusals, payload validation, and the forgery guards on task id, +workspace root, subagent role and lineage. +""" +from __future__ import annotations + +import json +import subprocess + +import pytest +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.testclient import TestClient + +from ouroboros.gateway.tasks import ( + _compose_task_text, + _resolve_workspace_root, + api_tasks_create, +) +from ouroboros.headless import ( + task_artifacts_dir, +) + + +from tests._headless_cli_shared import ( # noqa: F401 (autouse fixture applies on import) + _managed_worker_pool_available, +) + + +def test_task_api_enqueue_workspace_creates_child_drive(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + subprocess.run(["git", "init"], cwd=workspace, check=True, capture_output=True) + repo = tmp_path / "repo" + repo.mkdir() + data = tmp_path / "data" + (data / "memory").mkdir(parents=True) + (data / "memory" / "identity.md").write_text("seed identity", encoding="utf-8") + + captured = [] + bootstrapped = [] + + def fake_enqueue(task): + captured.append(dict(task)) + return task + + monkeypatch.setattr("supervisor.queue.enqueue_task", fake_enqueue) + monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) + monkeypatch.setattr("ouroboros.workspace_admission.bootstrap_process_path", lambda: bootstrapped.append(True) or []) + + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) + app.state.drive_root = data + app.state.repo_dir = repo + response = TestClient(app).post( + "/api/tasks", + json={ + "description": "fix it", + "workspace_root": str(workspace), + "memory_mode": "forked", + "expected_output": "A workspace patch and concise handoff.", + "constraints": "No network.", + "allowed_resources": {"web": False, "network": False}, + "resource_policy": { + "protected_artifacts": [ + { + "id": "reference", + "role": "black_box_reference", + "paths": ["reference.bin"], + "allow": ["execute"], + } + ] + }, + "deadline_at": "2026-06-04T12:00:00Z", + "service_teardown": "keep", + "context_requires_self_body_docs": "false", + "metadata": { + "root_task_id": "forged-root", + "parent_task_id": "forged-parent", + "delegation_role": "root", + "child_drive_root": "/tmp/forged-child", + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["task_id"] + assert bootstrapped + assert captured and captured[0]["workspace_root"] == str(workspace.resolve(strict=False)) + assert captured[0]["deadline_at"] == "2026-06-04T12:00:00Z" + assert captured[0]["metadata"]["service_teardown"] == "keep" + assert captured[0]["allowed_resources"] == {"web": False, "network": False} + assert captured[0]["context_requires_self_body_docs"] is False + assert captured[0]["task_contract"]["expected_output"] == "A workspace patch and concise handoff." + assert captured[0]["task_contract"]["constraints"] == "No network." + assert captured[0]["task_contract"]["context_requires_self_body_docs"] is False + assert captured[0]["task_contract"]["resource_policy"]["protected_artifacts"][0]["paths"] == ["reference.bin"] + child_drive = captured[0]["drive_root"] + assert child_drive + assert (tmp_path / "data" / "task_results" / f"{payload['task_id']}.json").is_file() + assert "seed identity" in (data / "state" / "headless_tasks" / payload["task_id"] / "data" / "memory" / "identity.md").read_text(encoding="utf-8") + result = json.loads((data / "task_results" / f"{payload['task_id']}.json").read_text(encoding="utf-8")) + assert result["artifact_status"] == "pending" + assert captured[0]["root_task_id"] == payload["task_id"] + assert captured[0]["parent_task_id"] is None + assert captured[0]["delegation_role"] == "root" + assert result["metadata"]["root_task_id"] == payload["task_id"] + assert result["metadata"]["parent_task_id"] == "" + assert result["metadata"]["delegation_role"] == "root" + assert result["task_contract"]["deadline_at"] == "2026-06-04T12:00:00Z" + assert result["task_contract"]["allowed_resources"] == {"web": False, "network": False} + assert result["task_contract"]["resource_policy"]["protected_artifacts"][0]["id"] == "reference" + assert result["metadata"]["child_drive_root"] == captured[0]["child_drive_root"] + assert "/tmp/forged-child" not in json.dumps(result["metadata"]) + assert result["metadata"]["workspace_preflight"]["git"]["head"] == "" + assert any(item["kind"] == "workspace_preflight" for item in result["artifacts"]) + assert "workspace_preflight:" in captured[0]["text"] + assert "target workspace, not the Ouroboros system repo" in captured[0]["text"] + + +def test_task_api_admission_refusal_is_terminal_not_scheduled_phantom(tmp_path, monkeypatch): + from ouroboros.task_results import STATUS_FAILED, load_task_result + + repo = tmp_path / "repo" + repo.mkdir() + data = tmp_path / "data" + (data / "memory").mkdir(parents=True) + persisted = [] + + monkeypatch.setattr( + "supervisor.queue.enqueue_task", + lambda task: { + **task, + "_admission_blocked": "project_routing_fence", + "_project_id": "closed-project", + "_project_lifecycle": "deleting", + }, + ) + monkeypatch.setattr( + "supervisor.queue.persist_queue_snapshot", + lambda reason="": persisted.append(reason), + ) + + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) + app.state.drive_root = data + app.state.repo_dir = repo + response = TestClient(app).post( + "/api/tasks", + json={ + "description": "must not run", + "task_id": "blocked-root", + "project_id": "closed-project", + }, + ) + + assert response.status_code == 409 + payload = response.json() + assert payload["task_id"] == "blocked-root" + assert payload["status"] == STATUS_FAILED + assert payload["admission"]["reason_code"] == "project_routing_fence" + assert payload["admission"]["project_lifecycle"] == "deleting" + assert persisted == [] + result = load_task_result(data, "blocked-root") + assert result["status"] == STATUS_FAILED + assert result["reason_code"] == "project_routing_fence" + assert result["admission_cleanup"] == {"child_drive_removed": True} + assert not (data / "state" / "headless_tasks" / "blocked-root").exists() + + +def test_task_api_refuses_when_durable_queue_snapshot_fails(tmp_path, monkeypatch): + import supervisor.queue as queue + from ouroboros.task_results import STATUS_FAILED, load_task_result + + repo = tmp_path / "repo" + repo.mkdir() + data = tmp_path / "data" + (data / "memory").mkdir(parents=True) + pending = [] + monkeypatch.setattr(queue, "DRIVE_ROOT", data) + monkeypatch.setattr(queue, "PENDING", pending) + monkeypatch.setattr(queue, "RUNNING", {}) + calls = [] + + def persist(reason=""): + calls.append(reason) + return reason == "api_task_create_rollback" + + monkeypatch.setattr(queue, "persist_queue_snapshot", persist) + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) + app.state.drive_root = data + app.state.repo_dir = repo + response = TestClient(app).post( + "/api/tasks", + json={"description": "must be durable", "task_id": "snapshot-fail"}, + ) + + assert response.status_code == 503 + assert response.json()["admission"]["reason_code"] == "queue_snapshot_persist_failed" + assert pending == [] + assert calls == ["api_task_create", "api_task_create_rollback"] + assert load_task_result(data, "snapshot-fail")["status"] == STATUS_FAILED + assert not (data / "state" / "headless_tasks" / "snapshot-fail").exists() + + +def test_task_api_releases_reservation_when_payload_composition_fails( + tmp_path, monkeypatch, +): + import supervisor.queue as queue + from ouroboros.gateway import tasks + + data = tmp_path / "data" + repo = tmp_path / "repo" + data.mkdir() + repo.mkdir() + task_id = "compose-failure" + real_compose = tasks._compose_task_text + monkeypatch.setattr( + tasks, + "_compose_task_text", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("compose failed")), + ) + monkeypatch.setattr(queue, "enqueue_task", lambda task: task) + monkeypatch.setattr(queue, "persist_queue_snapshot", lambda **_kwargs: True) + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) + app.state.drive_root = data + app.state.repo_dir = repo + client = TestClient(app) + + failed = client.post( + "/api/tasks", json={"task_id": task_id, "description": "compose me"} + ) + assert failed.status_code == 503 + assert task_id not in queue.ADMISSION_RESERVATIONS + assert not task_artifacts_dir(data, task_id, create=False).exists() + + monkeypatch.setattr(tasks, "_compose_task_text", real_compose) + retried = client.post( + "/api/tasks", json={"task_id": task_id, "description": "compose me"} + ) + assert retried.status_code == 200, retried.text + + +def test_api_tasks_create_requires_description_not_legacy_aliases(monkeypatch): + captured = [] + monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: captured.append(task) or task) + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) + client = TestClient(app) + + for payload in ({"text": "legacy task"}, {"prompt": "legacy task"}, {"description": ""}): + response = client.post("/api/tasks", json=payload) + assert response.status_code == 400, (payload, response.text) + assert "description is required" in response.json().get("error", "") + + response = client.post("/api/tasks", json={"description": "x", "service_teardown": "detach"}) + assert response.status_code == 400 + assert "service_teardown" in response.json().get("error", "") + + assert captured == [] + + +def test_api_tasks_create_rejects_internal_task_types(tmp_path, monkeypatch): + repo = tmp_path / "repo" + repo.mkdir() + data = tmp_path / "data" + (data / "memory").mkdir(parents=True) + + monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: task) + monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) + monkeypatch.setattr("ouroboros.workspace_admission.bootstrap_process_path", lambda: []) + + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) + app.state.drive_root = data + app.state.repo_dir = repo + client = TestClient(app) + + for internal_type in ("evolution", "review", "deep_self_review"): + resp = client.post("/api/tasks", json={"description": "x", "type": internal_type}) + assert resp.status_code == 400, (internal_type, resp.text) + assert "internal" in resp.json().get("error", "").lower() + + # A normal task type is still accepted. + ok = client.post("/api/tasks", json={"description": "do normal work", "type": "task"}) + assert ok.status_code == 200, ok.text + + +def test_compose_task_text_extends_existing_headless_workspace_block(tmp_path): + text = _compose_task_text( + "fix\n\n[HEADLESS_WORKSPACE]\nexisting: yes\n[END_HEADLESS_WORKSPACE]", + workspace_root=tmp_path, + workspace_mode="external", + memory_mode="empty", + workspace_preflight={"error": "probe failed"}, + attachments=[], + ) + + assert text.count("[HEADLESS_WORKSPACE]") == 1 + assert "existing: yes" in text + assert "preflight_error: probe failed" in text + assert text.index("workspace_root:") < text.index("[END_HEADLESS_WORKSPACE]") + + +def test_task_api_rejects_unsafe_task_id_and_system_workspace(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + subprocess.run(["git", "init"], cwd=workspace, check=True, capture_output=True) + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + data = tmp_path / "data" + data.mkdir() + monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: task) + monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) + + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) + app.state.drive_root = data + app.state.repo_dir = repo + client = TestClient(app) + + bad_id = client.post("/api/tasks", json={"description": "x", "task_id": "../settings", "workspace_root": str(workspace)}) + assert bad_id.status_code == 400 + assert not (data / "settings.json").exists() + + system_repo = client.post("/api/tasks", json={"description": "x", "workspace_root": str(repo)}) + assert system_repo.status_code == 400 + assert "system repo" in system_repo.json()["error"] + + bad_numbers = client.post("/api/tasks", json={"description": "x", "chat_id": "not-int", "workspace_root": str(workspace)}) + assert bad_numbers.status_code == 400 + bad_deadline = client.post("/api/tasks", json={"description": "x", "deadline_at": "not-a-date", "workspace_root": str(workspace)}) + assert bad_deadline.status_code == 400 + assert "deadline_at" in bad_deadline.json()["error"] + naive_deadline = client.post("/api/tasks", json={"description": "x", "deadline_at": "2026-06-04T12:00:00", "workspace_root": str(workspace)}) + assert naive_deadline.status_code == 400 + assert "timezone" in naive_deadline.json()["error"] + + first = client.post("/api/tasks", json={"description": "x", "task_id": "fixed1", "workspace_root": str(workspace)}) + assert first.status_code == 200 + duplicate = client.post("/api/tasks", json={"description": "x", "task_id": "fixed1", "workspace_root": str(workspace)}) + assert duplicate.status_code == 409 + + typed = client.post("/api/tasks", json={"description": "x", "type": "deep_self_review", "workspace_root": str(workspace)}) + assert typed.status_code == 400 + + +def test_resolve_workspace_root_blocks_case_variant_control_plane(tmp_path): + system_repo = tmp_path / "Ouroboros" / "repo" + drive = tmp_path / "Ouroboros" / "data" + workspace_repo_case = tmp_path / "ouroboros" / "repo" + workspace_data_case = tmp_path / "ouroboros" / "data" / "workspace" + for path in (system_repo, drive / "workspace"): + path.mkdir(parents=True) + + with pytest.raises(ValueError, match="Ouroboros system repo"): + _resolve_workspace_root(workspace_repo_case, system_repo_dir=system_repo, drive_root=drive) + with pytest.raises(ValueError, match="Ouroboros data drive"): + _resolve_workspace_root(workspace_data_case, system_repo_dir=system_repo, drive_root=drive) + + +def test_task_api_rejects_forged_subagent_without_child_drive_side_effect(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + subprocess.run(["git", "init"], cwd=workspace, check=True, capture_output=True) + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + data = tmp_path / "data" + data.mkdir() + monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: pytest.fail("forged subagent enqueued")) + monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) + + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) + app.state.drive_root = data + app.state.repo_dir = repo + client = TestClient(app) + + top_level = client.post( + "/api/tasks", + json={"description": "x", "task_id": "forged1", "workspace_root": str(workspace), "delegation_role": "subagent"}, + ) + metadata = client.post( + "/api/tasks", + json={"description": "x", "task_id": "forged2", "workspace_root": str(workspace), "metadata": {"delegation_role": "subagent"}}, + ) + + assert top_level.status_code == 400 + assert metadata.status_code == 400 + assert "internal schedule_subagent" in top_level.json()["error"] + assert not (data / "state" / "headless_tasks" / "forged1").exists() + assert not (data / "state" / "headless_tasks" / "forged2").exists() + + +def test_task_api_rejects_external_lineage_forgery(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + subprocess.run(["git", "init"], cwd=workspace, check=True, capture_output=True) + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + data = tmp_path / "data" + data.mkdir() + monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: pytest.fail("forged lineage enqueued")) + monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) + + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) + app.state.drive_root = data + app.state.repo_dir = repo + + response = TestClient(app).post( + "/api/tasks", + json={ + "description": "x", + "workspace_root": str(workspace), + "parent_task_id": "parent1", + "root_task_id": "root1", + }, + ) + + assert response.status_code == 400 + assert "internal lineage fields" in response.json()["error"] + assert not list((data / "task_results").glob("*.json")) + + +def test_task_api_preserves_top_level_actor_id_after_metadata_sanitization(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + subprocess.run(["git", "init"], cwd=workspace, check=True, capture_output=True) + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + data = tmp_path / "data" + data.mkdir() + captured = [] + monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: captured.append(dict(task)) or task) + monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) + + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) + app.state.drive_root = data + app.state.repo_dir = repo + + response = TestClient(app).post( + "/api/tasks", + json={ + "description": "x", + "workspace_root": str(workspace), + "memory_mode": "forked", + "actor_id": "operator-1", + "metadata": {"actor_id": "forged-metadata"}, + }, + ) + + assert response.status_code == 200 + assert captured[0]["actor_id"] == "operator-1" + result = json.loads((data / "task_results" / f"{response.json()['task_id']}.json").read_text(encoding="utf-8")) + assert result["metadata"]["actor_id"] == "operator-1" + assert "forged-metadata" not in json.dumps(result) + + +def test_task_api_attachment_admission_is_atomic_by_default(tmp_path, monkeypatch): + import supervisor.queue as queue + from ouroboros.task_results import load_task_result + + data = tmp_path / "data" + repo = tmp_path / "repo" + data.mkdir() + repo.mkdir() + good = tmp_path / "good.txt" + good.write_text("ok", encoding="utf-8") + captured = [] + monkeypatch.setattr(queue, "enqueue_task", lambda task: captured.append(task) or task) + monkeypatch.setattr(queue, "persist_queue_snapshot", lambda reason="": True) + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) + app.state.drive_root = data + app.state.repo_dir = repo + + response = TestClient(app).post( + "/api/tasks", + json={ + "task_id": "atomic-attachments", + "description": "needs both", + "attachments": [ + {"path": str(good), "label": "good"}, + {"path": str(tmp_path / "missing.txt"), "label": "missing"}, + ], + }, + ) + + assert response.status_code == 422 + payload = response.json() + assert payload["reason_code"] == "attachment_admission_rejected" + assert [row["status"] for row in payload["attachment_manifest"]] == ["staged", "rejected"] + assert payload["attachment_manifest"][1]["reason"] == "source_missing" + assert captured == [] + assert load_task_result(data, "atomic-attachments") is None + assert "atomic-attachments" not in queue.ADMISSION_RESERVATIONS + assert not task_artifacts_dir(data, "atomic-attachments", create=False).exists() + + +def test_task_api_explicit_partial_attachments_reaches_caller_contract_and_actor( + tmp_path, monkeypatch, +): + import supervisor.queue as queue + + data = tmp_path / "data" + repo = tmp_path / "repo" + data.mkdir() + repo.mkdir() + good = tmp_path / "good.txt" + good.write_text("ok", encoding="utf-8") + captured = [] + monkeypatch.setattr(queue, "enqueue_task", lambda task: captured.append(task) or task) + monkeypatch.setattr(queue, "persist_queue_snapshot", lambda reason="": True) + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) + app.state.drive_root = data + app.state.repo_dir = repo + + response = TestClient(app).post( + "/api/tasks", + json={ + "task_id": "partial-attachments", + "description": "work with what arrived", + "allow_partial_attachments": True, + "attachments": [ + {"path": str(good), "label": "good"}, + {"path": str(tmp_path / "missing.txt"), "label": "missing"}, + ], + }, + ) + + assert response.status_code == 200 + manifest = response.json()["attachment_manifest"] + assert [row["status"] for row in manifest] == ["staged", "rejected"] + task = captured[0] + assert task["attachments"] == manifest + assert task["task_contract"]["attachment_manifest"] == manifest + assert "reason=source_missing" in task["text"] + result = json.loads( + (data / "task_results" / "partial-attachments.json").read_text(encoding="utf-8") + ) + assert result["attachment_manifest"] == manifest + + +def test_late_api_identity_lookup_failure_preserves_exact_result(tmp_path): + from ouroboros.gateway.tasks import _admission_rejection_response + + result_path = tmp_path / "task_results" / "api-corrupt.json" + result_path.parent.mkdir() + original = b"{api-corrupt" + result_path.write_bytes(original) + response = _admission_rejection_response( + {"_admission_blocked": "task_id_lookup_failed"}, + drive_root=tmp_path, task_id="api-corrupt", project_id="", + workspace_root=None, child_drive=None, + ) + assert response is not None and response.status_code == 409 + assert json.loads(response.body)["admission"]["reason_code"] == "task_id_lookup_failed" + assert result_path.read_bytes() == original + + +def test_task_api_rejects_negative_depth_before_reservation_or_queue(tmp_path, monkeypatch): + from supervisor import queue as queue_module + + repo = tmp_path / "repo" + data = tmp_path / "data" + repo.mkdir() + data.mkdir() + captured = [] + monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: captured.append(task) or task) + monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True) + + def fail_reservation(*_args, **_kwargs): + pytest.fail("invalid depth must be rejected before admission reservation") + + monkeypatch.setattr(queue_module, "reserve_task_admission", fail_reservation) + + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])]) + app.state.drive_root = data + app.state.repo_dir = repo + client = TestClient(app) + + cases = ((-1, "depth must be a non-negative integer"), + (-0.5, "depth must be a non-negative integer"), + ("-1", "depth must be a non-negative integer"), + ("not-a-depth", "chat_id and depth must be integers")) + for index, (raw_depth, expected_error) in enumerate(cases): + task_id = f"api-invalid-depth-{index}" + response = client.post( + "/api/tasks", json={"task_id": task_id, "description": "x", "depth": raw_depth} + ) + assert response.status_code == 400 + assert response.json()["error"] == expected_error + assert task_id not in queue_module.ADMISSION_RESERVATIONS + assert not (data / "task_results" / f"{task_id}.json").exists() + assert captured == [] diff --git a/tests/test_headless_task_artifacts.py b/tests/test_headless_task_artifacts.py new file mode 100644 index 000000000..c4b2854c2 --- /dev/null +++ b/tests/test_headless_task_artifacts.py @@ -0,0 +1,654 @@ +"""Child result copyback, artifact endpoints and task-drive lifecycle. + +Split verbatim out of ``tests/test_headless_cli.py`` by theme. This module +owns what happens to a finished task's artifacts: copyback accounting and +acceptance markers, the artifact-serving endpoint, memory export, startup +pruning of terminal drives/scratch, and external child budget state. +""" +from __future__ import annotations + +import json +import os +import subprocess +import time +from types import SimpleNamespace + +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.testclient import TestClient + +from ouroboros.gateway.tasks import ( + api_task_artifact, +) +from ouroboros.headless import ( + ARTIFACT_STATUS_FINALIZING, + ARTIFACT_STATUS_READY, + ARTIFACT_STATUS_READY_WITH_CHANGES, + build_memory_export, + finalize_task_artifacts, + prune_headless_task_drives, + prune_task_drives, + task_artifacts_dir, +) +from ouroboros.task_results import write_task_result + + +from tests._headless_cli_shared import ( # noqa: F401 (autouse fixture applies on import) + _init_repo_with_file, + _managed_worker_pool_available, +) + + +def test_copy_child_result_cannot_overwrite_finalized_accounting(tmp_path): + """F2: once the root's terminal checkpoint has finalized accounting + (task_cost_finalized rides the same write as post_task_synthesis), a late + headless-mirror copy-back may still enrich the result but the parent-owned + cost/round/token fields stay finalized (the saga displayed the $66 root-only + mirror cost instead of the $128 finalized subtree total).""" + from ouroboros.headless import copy_child_task_result + from ouroboros.task_results import STATUS_COMPLETED + + parent = tmp_path / "data" + child = tmp_path / "child" + parent.mkdir() + child.mkdir() + task_id = "costfinal" + write_task_result( + parent, task_id, STATUS_COMPLETED, + result="root done", + root_phase_checkpoint={"post_task_synthesis": "completed"}, + cost_usd=127.97, cost_final=True, + cost_usd_with_children=127.97, cost_with_children_partial=False, + total_rounds=200, prompt_tokens=1000, completion_tokens=500, + ) + write_task_result( + child, task_id, STATUS_COMPLETED, + result="mirror done", + cost_usd=66.30, cost_final=True, + cost_usd_with_children=66.30, cost_with_children_partial=True, + total_rounds=150, prompt_tokens=700, completion_tokens=300, + mirror_only_fact="from-child", + ) + + merged = copy_child_task_result(parent, {"id": task_id, "drive_root": str(child)}) + + assert merged is not None + assert merged["cost_usd"] == 127.97 + assert merged["cost_usd_with_children"] == 127.97 + assert merged["cost_with_children_partial"] is False + assert merged["total_rounds"] == 200 + assert merged["prompt_tokens"] == 1000 + assert merged["completion_tokens"] == 500 + # Non-accounting enrichment from the child mirror still lands. + assert merged["mirror_only_fact"] == "from-child" + assert merged["result"] == "mirror done" + assert merged["root_phase_checkpoint"]["post_task_synthesis"] == "completed" + + +def test_copy_child_result_merges_cost_before_finalization(tmp_path): + """Before the terminal checkpoint finalizes accounting, the child mirror's + cost projection is still the freshest fact and must keep flowing.""" + from ouroboros.headless import copy_child_task_result + from ouroboros.task_results import STATUS_COMPLETED + + parent = tmp_path / "data" + child = tmp_path / "child" + parent.mkdir() + child.mkdir() + task_id = "costlive" + write_task_result(parent, task_id, STATUS_COMPLETED, result="root running") + write_task_result( + child, task_id, STATUS_COMPLETED, + result="mirror done", cost_usd=12.5, total_rounds=42, + ) + + merged = copy_child_task_result(parent, {"id": task_id, "drive_root": str(child)}) + + assert merged is not None + assert merged["cost_usd"] == 12.5 + assert merged["total_rounds"] == 42 + + +def test_effective_result_preserves_workspace_artifact_status_with_child_drive(tmp_path): + from ouroboros.headless import copy_child_task_result + from ouroboros.task_results import STATUS_COMPLETED + from ouroboros.task_status import load_effective_task_result + + parent = tmp_path / "data" + child = tmp_path / "child" + repo = tmp_path / "repo" + parent.mkdir() + child.mkdir() + _init_repo_with_file(repo) + old_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, check=True).stdout.strip() + (repo / "tracked.txt").write_text("new\n", encoding="utf-8") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "move"], + cwd=repo, + check=True, + capture_output=True, + ) + task_id = "patchfail" + write_task_result( + child, + task_id, + STATUS_COMPLETED, + result="child done", + artifact_status=ARTIFACT_STATUS_READY, + artifact_bundle={"status": ARTIFACT_STATUS_READY, "artifacts": [], "errors": []}, + ts="2026-01-01T00:00:02Z", + ) + ledger_path = parent / "task_results" / "artifacts" / task_id / "verification_ledger.json" + ledger_path.parent.mkdir(parents=True) + ledger_path.write_text( + json.dumps({ + "schema_version": 2, + "outcome_axes": { + "artifacts": {"status": "finalizing"}, + "objective": {"status": "not_evaluated", "source": "none"}, + }, + "entries": [{"kind": "objective_outcome", "status": "not_evaluated"}], + }), + encoding="utf-8", + ) + write_task_result( + parent, + task_id, + STATUS_COMPLETED, + result="child done", + workspace_root=str(repo), + child_drive_root=str(child), + artifact_status="finalizing", + artifacts=[{"kind": "verification_ledger", "name": "verification_ledger.json", "path": str(ledger_path)}], + child_status=STATUS_COMPLETED, + ) + + finalize_task_artifacts( + parent, + { + "id": task_id, + "workspace_root": str(repo), + "drive_root": str(child), + "metadata": {"workspace_preflight": {"git": {"head": old_head}}}, + }, + ) + + effective = load_effective_task_result(parent, task_id) + assert effective["artifact_status"] == ARTIFACT_STATUS_READY_WITH_CHANGES + assert not effective.get("artifact_error") + assert effective["artifact_bundle"]["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES + refreshed_ledger = json.loads(ledger_path.read_text(encoding="utf-8")) + assert refreshed_ledger["outcome_axes"]["artifacts"]["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES + + copied = copy_child_task_result(parent, {"id": task_id, "workspace_root": str(repo), "drive_root": str(child)}) + assert copied is not None + assert copied["artifact_status"] == ARTIFACT_STATUS_READY_WITH_CHANGES + assert not copied.get("artifact_error") + assert copied["artifact_bundle"]["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES + + readonly_task_id = "readonlychild" + write_task_result( + child, + readonly_task_id, + STATUS_COMPLETED, + result="readonly handoff", + workspace_root=str(repo), + workspace_mode="external", + delegation_role="subagent", + task_constraint={"mode": "local_readonly_subagent"}, + ) + copied_readonly = copy_child_task_result( + parent, + { + "id": readonly_task_id, + "workspace_root": str(repo), + "drive_root": str(child), + "delegation_role": "subagent", + "task_constraint": {"mode": "local_readonly_subagent"}, + }, + ) + assert copied_readonly is not None + assert copied_readonly.get("artifact_status", "") != "finalizing" + assert "child_status" not in copied_readonly + effective_readonly = load_effective_task_result(parent, readonly_task_id) + assert effective_readonly["status"] == STATUS_COMPLETED + assert effective_readonly["workspace_root"] == str(repo) + + +def test_child_copyback_preserves_acceptance_verdict_and_terminal_post_task_marker(tmp_path): + from ouroboros.headless import copy_child_task_result + from ouroboros.task_results import STATUS_COMPLETED, write_task_result + + parent = tmp_path / "data" + child = tmp_path / "child" + parent.mkdir() + child.mkdir() + task_id = "root-checkpoint" + write_task_result( + child, + task_id, + STATUS_COMPLETED, + root_phase_checkpoint={ + "phase": "task_acceptance", + "status": "degraded", + "pass_index": 2, + "post_task_synthesis": "pending_once", + }, + ) + write_task_result( + parent, + task_id, + STATUS_COMPLETED, + root_phase_checkpoint={ + "phase": "task_acceptance", + "status": "not_required", + "pass_index": 0, + "post_task_synthesis": "completed", + }, + ) + + copied = copy_child_task_result(parent, {"id": task_id, "drive_root": str(child)}) + + assert copied is not None + assert copied["root_phase_checkpoint"] == { + "phase": "task_acceptance", + "status": "degraded", + "pass_index": 2, + "post_task_synthesis": "completed", + } + + +def test_finalize_task_artifacts_preserves_existing_artifact_axis_fields(tmp_path): + from ouroboros.cli import _is_terminal_result + from ouroboros.task_results import STATUS_COMPLETED, load_task_result + + parent = tmp_path / "data" + repo = tmp_path / "repo" + parent.mkdir() + _init_repo_with_file(repo) + (repo / "tracked.txt").write_text("new\n", encoding="utf-8") + task_id = "axisfields" + write_task_result( + parent, + task_id, + STATUS_COMPLETED, + workspace_root=str(repo), + artifact_status=ARTIFACT_STATUS_FINALIZING, + artifact_bundle={"schema_version": 1, "status": "pending", "artifacts": [], "errors": []}, + outcome_axes={ + "lifecycle": {"status": STATUS_COMPLETED}, + "artifacts": { + "status": ARTIFACT_STATUS_FINALIZING, + "diagnostics": {"existing": True}, + "error_count": 0, + }, + "objective": {"status": "not_evaluated", "source": "none"}, + }, + ) + + finalize_task_artifacts(parent, {"id": task_id, "workspace_root": str(repo)}) + + result = load_task_result(parent, task_id) + artifact_axis = result["outcome_axes"]["artifacts"] + assert artifact_axis["status"] == result["artifact_bundle"]["status"] + assert result["artifact_bundle"]["status"] == result["artifact_status"] + assert result["artifact_bundle"]["status"] not in {"pending", "finalizing"} + assert _is_terminal_result(result) is True + assert artifact_axis["diagnostics"] == {"existing": True} + assert artifact_axis["error_count"] == 0 + + +def test_effective_result_preserves_workspace_patch_kind_with_child_drive(tmp_path): + from ouroboros.artifacts import copy_file_to_task_artifacts + from ouroboros.cli import _patch_from_result + from ouroboros.task_results import STATUS_COMPLETED + from ouroboros.task_status import load_effective_task_result + + parent = tmp_path / "data" + child = tmp_path / "child" + repo = tmp_path / "repo" + parent.mkdir() + child.mkdir() + _init_repo_with_file(repo) + (repo / "tracked.txt").write_text("new\n", encoding="utf-8") + + task_id = "patchkind" + report = tmp_path / "report.html" + report.write_text("

done

", encoding="utf-8") + child_record = copy_file_to_task_artifacts(SimpleNamespace(drive_root=child, task_id=task_id), report, kind="user_file") + assert child_record is not None + write_task_result( + child, + task_id, + STATUS_COMPLETED, + result="child done", + artifacts=[child_record], + artifact_status=ARTIFACT_STATUS_READY, + ts="2026-01-01T00:00:02Z", + ) + write_task_result( + parent, + task_id, + STATUS_COMPLETED, + result="child done", + workspace_root=str(repo), + child_drive_root=str(child), + artifacts=[child_record], + artifact_status="finalizing", + child_status=STATUS_COMPLETED, + ) + + finalize_task_artifacts(parent, {"id": task_id, "workspace_root": str(repo), "drive_root": str(child)}) + + effective = load_effective_task_result(parent, task_id) + patch_artifacts = [ + item + for item in effective.get("artifacts") or [] + if isinstance(item, dict) and item.get("name") == "workspace.patch" + ] + assert patch_artifacts + assert patch_artifacts[0]["kind"] == "workspace_patch" + assert any(item.get("kind") == "user_file" for item in effective.get("artifacts") or [] if isinstance(item, dict)) + + class FakeClient: + def __init__(self): + self.paths = [] + + def get_bytes(self, path): + self.paths.append(path) + return b"diff --git a/tracked.txt b/tracked.txt\n" + + client = FakeClient() + assert _patch_from_result(client, task_id, effective, strict=True).startswith("diff --git") + assert client.paths == [f"/api/tasks/{task_id}/artifacts/workspace.patch"] + + +def test_task_artifact_endpoint_serves_only_declared_artifacts(tmp_path): + data = tmp_path / "data" + artifact_dir = task_artifacts_dir(data, "task-artifact") + patch_path = artifact_dir / "workspace.patch" + patch_path.write_text("diff --git a/a b/a\n", encoding="utf-8") + write_task_result( + data, + "task-artifact", + "completed", + artifacts=[{"kind": "workspace_patch", "name": "workspace.patch", "path": str(patch_path), "size": patch_path.stat().st_size}], + artifact_status="ready", + ) + app = Starlette(routes=[Route("/api/tasks/{task_id}/artifacts/{name}", endpoint=api_task_artifact, methods=["GET"])]) + app.state.drive_root = data + client = TestClient(app) + + assert client.get("/api/tasks/task-artifact/artifacts/workspace.patch").text.startswith("diff --git") + assert client.get("/api/tasks/task-artifact/artifacts/missing.patch").status_code == 404 + assert client.get("/api/tasks/task-artifact/artifacts/bad%5Cname").status_code == 400 + + +def test_task_artifact_endpoint_serves_manifest_artifact_after_status_repair(tmp_path): + from ouroboros.artifacts import copy_file_to_task_artifacts + + data = tmp_path / "data" + source_dir = tmp_path / "Desktop" + source_dir.mkdir() + source = source_dir / "report.html" + source.write_text("

ok

", encoding="utf-8") + copy_file_to_task_artifacts(SimpleNamespace(drive_root=data, task_id="orphaned"), source, kind="user_file") + write_task_result( + data, + "orphaned", + "running", + result_status="infra_failed", + reason_code="provider_failure", + result="provider failed before normal finalization", + ) + (data / "state").mkdir(parents=True, exist_ok=True) + (data / "state" / "queue_snapshot.json").write_text('{"pending": [], "running": []}', encoding="utf-8") + app = Starlette(routes=[Route("/api/tasks/{task_id}/artifacts/{name}", endpoint=api_task_artifact, methods=["GET"])]) + app.state.drive_root = data + + response = TestClient(app).get("/api/tasks/orphaned/artifacts/report.html") + + assert response.status_code == 200 + assert response.text == "

ok

" + + +def test_task_artifact_endpoint_rebases_child_drive_artifact_after_status_repair(tmp_path): + from ouroboros.artifacts import collect_task_artifact_records, copy_file_to_task_artifacts + + data = tmp_path / "data" + child = tmp_path / "child" + source_dir = tmp_path / "Desktop" + source_dir.mkdir() + source = source_dir / "report.html" + source.write_text("

child

", encoding="utf-8") + copy_file_to_task_artifacts(SimpleNamespace(drive_root=child, task_id="childart"), source, kind="user_file") + child_artifacts = collect_task_artifact_records(child, "childart") + write_task_result( + child, + "childart", + "completed", + result="done", + artifacts=child_artifacts, + artifact_status="ready", + ts="2026-01-01T00:00:02Z", + ) + write_task_result( + data, + "childart", + "running", + child_drive_root=str(child), + workspace_root=str(tmp_path / "workspace"), + result_status="infra_failed", + reason_code="provider_failure", + result="provider failed before normal finalization", + ) + (data / "state").mkdir(parents=True, exist_ok=True) + (data / "state" / "queue_snapshot.json").write_text('{"pending": [], "running": []}', encoding="utf-8") + app = Starlette(routes=[Route("/api/tasks/{task_id}/artifacts/{name}", endpoint=api_task_artifact, methods=["GET"])]) + app.state.drive_root = data + + response = TestClient(app).get("/api/tasks/childart/artifacts/report.html") + + parent_artifact = task_artifacts_dir(data, "childart", create=False) / "report.html" + assert response.status_code == 200 + assert response.text == "

child

" + assert parent_artifact.read_text(encoding="utf-8") == "

child

" + + +def test_task_artifact_endpoint_rejects_metadata_name_path_mismatch(tmp_path): + data = tmp_path / "data" + artifact_dir = task_artifacts_dir(data, "task-artifact") + wrong_path = artifact_dir / "memory_export.json" + wrong_path.write_text("{}", encoding="utf-8") + write_task_result( + data, + "task-artifact", + "completed", + artifacts=[{"kind": "workspace_patch", "name": "workspace.patch", "path": str(wrong_path), "size": wrong_path.stat().st_size}], + artifact_status="ready", + ) + app = Starlette(routes=[Route("/api/tasks/{task_id}/artifacts/{name}", endpoint=api_task_artifact, methods=["GET"])]) + app.state.drive_root = data + + assert TestClient(app).get("/api/tasks/task-artifact/artifacts/workspace.patch").status_code == 500 + + +def test_memory_export_includes_nested_memory_files(tmp_path): + drive = tmp_path / "child" + memory = drive / "memory" + nested = memory / "knowledge" / "patterns" + nested.mkdir(parents=True) + (memory / "identity.md").write_text("id\n", encoding="utf-8") + (nested / "cli.md").write_text("pattern\n", encoding="utf-8") + + export = build_memory_export(drive, {"id": "task-1", "memory_mode": "forked"}) + + assert export["files"]["identity.md"] == "id\n" + assert export["files"]["knowledge/patterns/cli.md"] == "pattern\n" + + +def test_startup_prune_removes_only_old_terminal_child_drives(tmp_path): + data = tmp_path / "data" + terminal_dir = data / "state" / "headless_tasks" / "oldterminal" + pending_dir = data / "state" / "headless_tasks" / "oldpending" + fresh_timestamp_dir = data / "state" / "headless_tasks" / "freshresult" + terminal_drive = terminal_dir / "data" + pending_drive = pending_dir / "data" + fresh_timestamp_drive = fresh_timestamp_dir / "data" + terminal_drive.mkdir(parents=True) + pending_drive.mkdir(parents=True) + fresh_timestamp_drive.mkdir(parents=True) + + now = time.time() + old = now - (8 * 86400) + old_iso = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(old)) + fresh_iso = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(now)) + write_task_result(data, "oldterminal", "completed", child_drive_root=str(terminal_drive), artifact_status="ready", result="done", ts=old_iso) + write_task_result(data, "oldpending", "scheduled", child_drive_root=str(pending_drive), result="queued") + write_task_result(data, "freshresult", "completed", child_drive_root=str(fresh_timestamp_drive), artifact_status="ready", result="done", ts=fresh_iso) + os.utime(terminal_dir, (old, old)) + os.utime(pending_dir, (old, old)) + os.utime(fresh_timestamp_dir, (old, old)) + + report = prune_headless_task_drives(data, retention_days=7, now=now) + + assert [item["task_id"] for item in report["pruned"]] == ["oldterminal"] + assert not terminal_dir.exists() + assert pending_dir.exists() + assert fresh_timestamp_dir.exists() + assert any(item["task_id"] == "oldpending" and item["reason"] == "parent_not_terminal" for item in report["skipped"]) + assert any(item["task_id"] == "freshresult" and item["reason"] == "younger_than_retention" for item in report["skipped"]) + + +def test_startup_prune_uses_effective_terminal_status(tmp_path): + data = tmp_path / "data" + task_drive = data / "task_drives" / "stalerun" + child_dir = data / "state" / "headless_tasks" / "stalechild" + child_drive = child_dir / "data" + task_drive.mkdir(parents=True) + child_drive.mkdir(parents=True) + (task_drive / "scratch.txt").write_text("scratch", encoding="utf-8") + (child_drive / "scratch.txt").write_text("child", encoding="utf-8") + (data / "state").mkdir(parents=True, exist_ok=True) + (data / "state" / "queue_snapshot.json").write_text('{"pending": [], "running": []}', encoding="utf-8") + + now = time.time() + old = now - (8 * 86400) + old_iso = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(old)) + for task_id, extra in ( + ("stalerun", {}), + ("stalechild", {"child_drive_root": str(child_drive)}), + ): + write_task_result( + data, + task_id, + "running", + result_status="infra_failed", + reason_code="provider_failure", + result="provider failed", + ts=old_iso, + **extra, + ) + os.utime(task_drive, (old, old)) + os.utime(child_dir, (old, old)) + + direct_report = prune_task_drives(data, retention_days=7, now=now) + child_report = prune_headless_task_drives(data, retention_days=7, now=now) + + assert [item["task_id"] for item in direct_report["pruned"]] == ["stalerun"] + assert [item["task_id"] for item in child_report["pruned"]] == ["stalechild"] + assert not task_drive.exists() + assert not child_dir.exists() + + +def test_startup_prune_removes_only_old_terminal_task_scratch(tmp_path): + data = tmp_path / "data" + old_terminal = data / "task_drives" / "oldterminal" + old_pending = data / "task_drives" / "oldpending" + fresh_terminal = data / "task_drives" / "freshterminal" + for path in (old_terminal, old_pending, fresh_terminal): + path.mkdir(parents=True) + (path / "scratch.txt").write_text("scratch", encoding="utf-8") + + now = time.time() + old = now - (8 * 86400) + old_iso = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(old)) + fresh_iso = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime(now)) + write_task_result(data, "oldterminal", "completed", result="done", ts=old_iso) + write_task_result(data, "oldpending", "running", result="running") + write_task_result(data, "freshterminal", "completed", result="done", ts=fresh_iso) + os.utime(old_terminal, (old, old)) + os.utime(old_pending, (old, old)) + os.utime(fresh_terminal, (old, old)) + + report = prune_task_drives(data, retention_days=7, now=now) + + assert [item["task_id"] for item in report["pruned"]] == ["oldterminal"] + assert not old_terminal.exists() + assert old_pending.exists() + assert fresh_terminal.exists() + assert any(item["task_id"] == "oldpending" and item["reason"] == "task_not_terminal" for item in report["skipped"]) + assert any(item["task_id"] == "freshterminal" and item["reason"] == "younger_than_retention" for item in report["skipped"]) + + +def test_external_child_task_budget_uses_parent_drive_state(tmp_path, monkeypatch): + from ouroboros import usage_accounting + from ouroboros.agent import Env, OuroborosAgent + + repo = tmp_path / "repo" + parent = tmp_path / "parent-data" + child = tmp_path / "child-data" + for root in (repo, parent, child): + root.mkdir() + for drive in (parent, child): + (drive / "state").mkdir() + (drive / "logs").mkdir() + # Compatibility projections are deliberately misleading here: the physical-attempt + # ledger in the parent budget root is the sole monetary authority. + (parent / "state" / "state.json").write_text('{"spent_usd": 0.0}\n', encoding="utf-8") + (child / "state" / "state.json").write_text('{"spent_usd": 0.0}\n', encoding="utf-8") + reservation = usage_accounting.reserve_attempt(usage_accounting.AttemptRequest( + model="test/model", + provider="test", + reservation_usd=9.0, + drive_root=parent, + task_id="prior-task", + root_task_id="prior-task", + source="test", + )) + usage_accounting.mark_dispatched(reservation) + usage_accounting.settle_attempt(reservation, {}, cost_usd=9.0, cost_final=True) + + monkeypatch.setenv("TOTAL_BUDGET", "10") + monkeypatch.setattr(OuroborosAgent, "_log_worker_boot_once", lambda self: None) + monkeypatch.setattr("ouroboros.agent.build_llm_messages", lambda **kwargs: ([], {})) + + agent = OuroborosAgent(Env(repo_dir=repo, drive_root=child)) + ctx, _messages, cap_info = agent._prepare_task_context({ + "id": "budget-task", + "type": "task", + "text": "x", + "budget_drive_root": str(parent), + }) + + assert cap_info["budget_remaining"] == 1.0 + assert ctx.task_metadata["budget_drive_root"] == str(parent) + + +def test_task_artifact_endpoint_serves_exact_chat_media_without_task_result(tmp_path): + from ouroboros.artifacts import collect_task_artifact_records, store_chat_media_bytes + + data = tmp_path / "data" + stored = store_chat_media_bytes(data, "ephemeral1", b"photo-bytes", "image/png") + assert stored is not None + app = Starlette(routes=[Route("/api/tasks/{task_id}/artifacts/{name}", endpoint=api_task_artifact, methods=["GET"])]) + app.state.drive_root = data + client = TestClient(app) + + response = client.get(f"/api/tasks/ephemeral1/artifacts/{stored['name']}") + assert response.status_code == 200 + assert response.content == b"photo-bytes" + assert collect_task_artifact_records(data, "ephemeral1") == [] + + assert client.get("/api/tasks/ephemeral1/artifacts/chat-media-bad.png").status_code == 404 diff --git a/tests/test_headless_task_events.py b/tests/test_headless_task_events.py new file mode 100644 index 000000000..fb6d1ad8e --- /dev/null +++ b/tests/test_headless_task_events.py @@ -0,0 +1,326 @@ +"""Task event replay, log tails and effective child status projection. + +Split verbatim out of ``tests/test_headless_cli.py`` by theme. This module +owns what readers see after a task runs: event replay, lineage-filtered log +tails, SSE finalization order, task listing, and the effective-status/result +projection that waits for workspace artifacts. +""" +from __future__ import annotations + +import json + +import pytest +from starlette.applications import Starlette +from starlette.routing import Route +from starlette.testclient import TestClient + +from ouroboros.gateway.tasks import ( + api_task_events, + api_task_get, + api_tasks_list, + iter_task_events, +) +from ouroboros.task_results import write_task_result + + +from tests._headless_cli_shared import ( # noqa: F401 (autouse fixture applies on import) + _managed_worker_pool_available, +) + + +def test_task_event_replay_uses_existing_logs_and_result(tmp_path): + data = tmp_path / "data" + logs = data / "logs" + logs.mkdir(parents=True) + task_id = "abc123" + (logs / "progress.jsonl").write_text( + json.dumps({"ts": "2026-01-01T00:00:00Z", "task_id": task_id, "content": "working"}) + "\n", + encoding="utf-8", + ) + result_dir = data / "task_results" + result_dir.mkdir() + (result_dir / f"{task_id}.json").write_text( + json.dumps({"task_id": task_id, "status": "completed", "result": "done", "ts": "2026-01-01T00:00:01Z"}), + encoding="utf-8", + ) + + events = iter_task_events(data, task_id) + + assert [event["type"] for event in events] == ["progress", "task_result"] + assert events[0]["seq"] == 1 + assert events[1]["data"]["result"] == "done" + + +def test_task_event_replay_parent_includes_child_lineage_events(tmp_path): + data = tmp_path / "data" + logs = data / "logs" + logs.mkdir(parents=True) + parent_id = "parent1" + child_id = "child1" + (logs / "progress.jsonl").write_text( + "\n".join([ + json.dumps({"ts": "2026-01-01T00:00:00Z", "task_id": parent_id, "content": "parent"}), + json.dumps({ + "ts": "2026-01-01T00:00:01Z", + "task_id": child_id, + "parent_task_id": parent_id, + "root_task_id": parent_id, + "delegation_role": "subagent", + "subagent_task_id": child_id, + "content": "child progress", + }), + ]) + "\n", + encoding="utf-8", + ) + write_task_result( + data, + parent_id, + "running", + result="parent pending", + ts="2026-01-01T00:00:00Z", + ) + write_task_result( + data, + child_id, + "running", + result="child pending", + parent_task_id=parent_id, + root_task_id=parent_id, + delegation_role="subagent", + ts="2026-01-01T00:00:01Z", + ) + + events = iter_task_events(data, parent_id) + + progress_events = [event for event in events if event["type"] == "progress"] + assert [event["task_id"] for event in progress_events] == [parent_id, child_id] + assert progress_events[1]["data"]["content"] == "child progress" + + +def test_logs_tail_parent_filter_includes_child_lineage_events(tmp_path): + from ouroboros.gateway.logs import api_logs_tail + + data = tmp_path / "data" + logs = data / "logs" + logs.mkdir(parents=True) + (logs / "progress.jsonl").write_text( + "\n".join([ + json.dumps({"ts": "2026-01-01T00:00:00Z", "task_id": "parent1", "content": "parent"}), + json.dumps({ + "ts": "2026-01-01T00:00:01Z", + "task_id": "child1", + "subagent_task_id": "child1", + "parent_task_id": "parent1", + "root_task_id": "parent1", + "delegation_role": "subagent", + "content": "child", + }), + json.dumps({"ts": "2026-01-01T00:00:02Z", "task_id": "other", "content": "other"}), + ]) + "\n", + encoding="utf-8", + ) + app = Starlette(routes=[Route("/api/logs/{name}", endpoint=api_logs_tail, methods=["GET"])]) + app.state.drive_root = data + + response = TestClient(app).get("/api/logs/progress?task_id=parent1&limit=10") + payload = response.json() + + assert response.status_code == 200 + assert [row["content"] for row in payload["entries"]] == ["parent", "child"] + + +def test_workspace_event_replay_suppresses_task_done_until_artifacts_terminal(tmp_path): + data = tmp_path / "data" + logs = data / "logs" + logs.mkdir(parents=True) + task_id = "abc123" + (logs / "events.jsonl").write_text( + json.dumps({"ts": "2026-01-01T00:00:01Z", "type": "task_done", "task_id": task_id}) + "\n", + encoding="utf-8", + ) + write_task_result( + data, + task_id, + "completed", + workspace_root=str(tmp_path / "workspace"), + artifact_status="finalizing", + child_status="completed", + ) + + events = iter_task_events(data, task_id) + + assert "task_done" not in [event["type"] for event in events] + assert events[-1]["type"] == "task_result" + + +def test_effective_child_completion_waits_for_artifacts(tmp_path): + data = tmp_path / "data" + child = tmp_path / "child" + for root in (data, child): + (root / "task_results").mkdir(parents=True) + write_task_result( + data, + "task-artifacts", + "scheduled", + child_drive_root=str(child), + workspace_root=str(tmp_path / "workspace"), + artifact_status="pending", + result="queued", + ) + write_task_result( + child, + "task-artifacts", + "completed", + result="done", + ts="2026-01-01T00:00:02Z", + outcome_axes={ + "lifecycle": {"status": "completed"}, + "artifacts": {"status": "not_applicable"}, + }, + ) + + app = Starlette(routes=[Route("/api/tasks/{task_id}", endpoint=api_task_get, methods=["GET"])]) + app.state.drive_root = data + payload = TestClient(app).get("/api/tasks/task-artifacts").json() + + assert payload["status"] == "running" + assert payload["artifact_status"] == "finalizing" + assert payload["child_status"] == "completed" + assert payload["outcome_axes"]["lifecycle"]["status"] == "running" + assert payload["outcome_axes"]["artifacts"]["status"] == "finalizing" + + write_task_result(data, "task-artifacts", "completed", artifact_status="ready", child_drive_root=str(child), workspace_root=str(tmp_path / "workspace")) + payload = TestClient(app).get("/api/tasks/task-artifacts").json() + assert payload["status"] == "completed" + assert payload["artifact_status"] == "ready" + + +def test_public_task_result_strips_nested_legacy_result_status(tmp_path): + data = tmp_path / "data" + (data / "task_results").mkdir(parents=True) + write_task_result( + data, + "legacy-loop", + "completed", + result="done", + loop_outcome={"result_status": "failed", "compat_result_status": "failed", "reason_code": "legacy"}, + verification_ledger={ + "entries": [ + {"kind": "legacy", "result_status": "partial"}, + {"kind": "nested", "payload": {"compat_result_status": "infra_failed"}}, + {"kind": "list", "items": [{"result_status": "failed"}]}, + ], + }, + ) + app = Starlette(routes=[Route("/api/tasks/{task_id}", endpoint=api_task_get, methods=["GET"])]) + app.state.drive_root = data + + payload = TestClient(app).get("/api/tasks/legacy-loop").json() + + assert "result_status" not in payload + assert "result_status" not in payload["loop_outcome"] + assert "compat_result_status" not in payload["loop_outcome"] + rendered = json.dumps(payload) + assert "result_status" not in rendered + assert "compat_result_status" not in rendered + + +def test_effective_child_failure_waits_for_artifacts(tmp_path): + data = tmp_path / "data" + child = tmp_path / "child" + for root in (data, child): + (root / "task_results").mkdir(parents=True) + write_task_result( + data, + "task-failed", + "failed", + child_drive_root=str(child), + workspace_root=str(tmp_path / "workspace"), + artifact_status="finalizing", + child_status="failed", + result="boom", + ) + write_task_result(child, "task-failed", "failed", result="boom", ts="2026-01-01T00:00:02Z") + + app = Starlette(routes=[Route("/api/tasks/{task_id}", endpoint=api_task_get, methods=["GET"])]) + app.state.drive_root = data + payload = TestClient(app).get("/api/tasks/task-failed").json() + + assert payload["status"] == "running" + assert payload["artifact_status"] == "finalizing" + assert payload["child_status"] == "failed" + + +def test_task_sse_emits_final_result_after_cursor_saw_scheduled_result(tmp_path): + data = tmp_path / "data" + (data / "task_results").mkdir(parents=True) + task_id = "abc123" + (data / "task_results" / f"{task_id}.json").write_text( + json.dumps({"task_id": task_id, "status": "completed", "result": "done", "ts": "2026-01-01T00:00:01Z"}), + encoding="utf-8", + ) + app = Starlette(routes=[Route("/api/tasks/{task_id}/events", endpoint=api_task_events, methods=["GET"])]) + app.state.drive_root = data + + response = TestClient(app).get(f"/api/tasks/{task_id}/events?cursor=1&wait=0") + + assert response.status_code == 200 + assert '"type": "task_result"' in response.text + assert '"status": "completed"' in response.text + + +def test_task_list_filters_on_effective_child_status(tmp_path): + data = tmp_path / "data" + child_running = tmp_path / "child-running" + child_done = tmp_path / "child-done" + for root in (data, child_running, child_done): + (root / "task_results").mkdir(parents=True) + + write_task_result(data, "task-running", "scheduled", child_drive_root=str(child_running), result="queued") + write_task_result(child_running, "task-running", "running", result="working", ts="2026-01-01T00:00:01Z") + write_task_result(data, "task-done", "scheduled", child_drive_root=str(child_done), result="queued") + write_task_result(child_done, "task-done", "completed", result="done", ts="2026-01-01T00:00:02Z") + + app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_list, methods=["GET"])]) + app.state.drive_root = data + client = TestClient(app) + + running = client.get("/api/tasks?status=running").json()["tasks"] + completed = client.get("/api/tasks?status=completed").json()["tasks"] + + assert [task["task_id"] for task in running] == ["task-running"] + assert running[0]["result"] == "working" + assert [task["task_id"] for task in completed] == ["task-done"] + assert completed[0]["result"] == "done" + + +@pytest.mark.parametrize("status", ["cancelled", "failed"]) +def test_effective_task_result_preserves_parent_terminal_status(tmp_path, status): + data = tmp_path / "data" + child = tmp_path / "child" + for root in (data, child): + (root / "task_results").mkdir(parents=True) + write_task_result( + data, + "task-terminal", + status, + child_drive_root=str(child), + result="parent terminal", + ts="2026-01-01T00:00:02Z", + ) + write_task_result( + child, + "task-terminal", + "running", + result="child stale", + ts="2026-01-01T00:00:03Z", + ) + + app = Starlette(routes=[Route("/api/tasks/{task_id}", endpoint=api_task_get, methods=["GET"])]) + app.state.drive_root = data + + payload = TestClient(app).get("/api/tasks/task-terminal").json() + + assert payload["status"] == status + assert payload["result"] == "parent terminal" + assert payload["ts"] == "2026-01-01T00:00:02Z" diff --git a/tests/test_headless_workspace_patch.py b/tests/test_headless_workspace_patch.py new file mode 100644 index 000000000..3fc2e78bc --- /dev/null +++ b/tests/test_headless_workspace_patch.py @@ -0,0 +1,412 @@ +"""Workspace patch capture and finalization. + +Split verbatim out of ``tests/test_headless_cli.py`` by theme. This module +owns ``build_workspace_patch``/``write_workspace_patch_artifacts``: which +files a patch carries, the unborn/invalid HEAD cases, the sensitive-file +vetoes, and the acting-base-sha manifest contract. +""" +from __future__ import annotations + +import json +import subprocess + +import pytest + +from ouroboros.headless import ( + ARTIFACT_STATUS_FAILED, + ARTIFACT_STATUS_READY_WITH_CHANGES, + _incidental_lockfile_excludes, + build_workspace_patch, + finalize_task_artifacts, + task_artifacts_dir, + write_workspace_patch_artifacts, +) +from ouroboros.task_results import write_task_result + + +from tests._headless_cli_shared import ( # noqa: F401 (autouse fixture applies on import) + _init_repo_with_file, + _managed_worker_pool_available, +) + + +def test_workspace_patch_includes_tracked_and_untracked_files(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + (repo / "tracked.txt").write_text("old\n", encoding="utf-8") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "init"], + cwd=repo, + check=True, + capture_output=True, + ) + (repo / "tracked.txt").write_text("new\n", encoding="utf-8") + (repo / "new.txt").write_text("hello\n", encoding="utf-8") + + patch = build_workspace_patch(repo) + + assert "diff --git a/tracked.txt b/tracked.txt" in patch + assert "+new" in patch + assert "diff --git" in patch and "new.txt" in patch + + +def test_workspace_patch_lockfile_without_manifest_is_incidental_only_with_code_changes(): + assert _incidental_lockfile_excludes(["package-lock.json"]) == set() + assert _incidental_lockfile_excludes(["package-lock.json", "package.json", "app.js"]) == set() + assert _incidental_lockfile_excludes(["package-lock.json", "app.js"]) == {"package-lock.json"} + assert _incidental_lockfile_excludes(["pkg/poetry.lock", "pkg/module.py"]) == {"pkg/poetry.lock"} + + +def test_workspace_patch_preserves_lockfile_when_other_changes_are_junk(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + (repo / "README.md").write_text("base\n", encoding="utf-8") + subprocess.run(["git", "add", "README.md"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "init"], + cwd=repo, + check=True, + capture_output=True, + ) + (repo / "package-lock.json").write_text('{"lockfileVersion": 3}\n', encoding="utf-8") + (repo / "dist").mkdir() + (repo / "dist" / "out.txt").write_text("junk\n", encoding="utf-8") + + _artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) + patch = (tmp_path / "artifacts" / "workspace.patch").read_text(encoding="utf-8") + + assert "package-lock.json" in patch + assert "dist/out.txt" not in patch + assert manifest["counts"]["untracked_included"] == 1 + assert manifest["counts"]["untracked_excluded"] == 1 + + +def test_workspace_patch_excludes_binary_junk_and_oversize(tmp_path, monkeypatch): + """T7 (v6.35.0): the real-usage workspace patch drops untracked build/runtime + binaries, junk artifacts, and oversize blobs (recorded, not silently lost), + while keeping real source additions.""" + import ouroboros.workspace_patch_capture as workspace_patch_capture + + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + (repo / "seed.txt").write_text("seed\n", encoding="utf-8") + subprocess.run(["git", "add", "seed.txt"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "init"], + cwd=repo, check=True, capture_output=True, + ) + # Untracked additions: a real source file (keep), a compiled binary (drop), + # a redis dump + log junk (drop), and an oversize text file (drop). + (repo / "fix.py").write_text("def fixed():\n return 1\n", encoding="utf-8") + (repo / "app").write_bytes(b"\x7fELF\x00\x01\x02\x03binary\x00blob") # compiled binary + (repo / "dump.rdb").write_bytes(b"REDIS\x00\x01") + (repo / "run.log").write_text("noise\n", encoding="utf-8") + (repo / "htmlcov").mkdir() + (repo / "htmlcov" / "index.html").write_text("\n", encoding="utf-8") # top-level coverage junk + monkeypatch.setattr(workspace_patch_capture, "_PATCH_MAX_UNTRACKED_FILE_BYTES", 100) + (repo / "big.txt").write_text("x" * 200, encoding="utf-8") # 200 bytes > cap; small files pass size + + artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) + + assert manifest["exclude_rules_version"] == 2 + excluded = {item["path"]: item["reason"] for item in manifest["untracked_excluded"]} + assert "binary file" in excluded.get("app", "") + assert "binary file" in excluded.get("dump.rdb", "") or "junk artifact" in excluded.get("dump.rdb", "") + assert "junk artifact" in excluded.get("run.log", "") + assert "junk artifact" in excluded.get("htmlcov/index.html", "") # top-level htmlcov excluded + assert "size cap" in excluded.get("big.txt", "") + assert "fix.py" in manifest["untracked_included"] + patch = (tmp_path / "artifacts" / "workspace.patch").read_text(encoding="utf-8") + assert "fix.py" in patch + assert "diff --git a/app b/app" not in patch + assert "dump.rdb" not in patch + assert "run.log" not in patch + assert "big.txt" not in patch + + +def test_workspace_patch_supports_unborn_git_worktree(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + (repo / "created.txt").write_text("hello\n", encoding="utf-8") + + artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) + + assert manifest["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES + assert manifest["base_is_empty_tree"] is True + assert manifest["base_head"] == "(unborn)" + assert manifest["current_head"] == "(unborn)" + assert any(item["kind"] == "workspace_patch" for item in artifacts) + patch = (tmp_path / "artifacts" / "workspace.patch").read_text(encoding="utf-8") + assert "created.txt" in patch + assert "+hello" in patch + head = subprocess.run(["git", "rev-parse", "--verify", "HEAD"], cwd=repo, capture_output=True) + assert head.returncode != 0 + + +def test_workspace_patch_supports_unborn_sha256_git_worktree(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + init = subprocess.run(["git", "init", "--object-format=sha256"], cwd=repo, capture_output=True) + if init.returncode != 0: + pytest.skip("git does not support sha256 object-format") + (repo / "created.txt").write_text("hello\n", encoding="utf-8") + + artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) + + assert manifest["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES + assert manifest["base_is_empty_tree"] is True + assert len(manifest["base_ref"]) == 64 + assert any(item["kind"] == "workspace_patch" for item in artifacts) + + +def test_workspace_patch_allows_external_workspace_first_commit(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + (repo / "created.txt").write_text("hello\n", encoding="utf-8") + subprocess.run(["git", "add", "created.txt"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "first"], + cwd=repo, + check=True, + capture_output=True, + ) + task = {"metadata": {"workspace_preflight": {"git": {"head": ""}}}} + + artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task=task) + + assert manifest["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES + assert manifest["errors"] == [] + assert any(item["kind"] == "workspace_patch" for item in artifacts) + + +def test_workspace_patch_fails_on_invalid_head_not_unborn(tmp_path): + repo = tmp_path / "repo" + _init_repo_with_file(repo) + head_ref = subprocess.run(["git", "symbolic-ref", "--quiet", "HEAD"], cwd=repo, capture_output=True, text=True, check=True).stdout.strip() + ref_path = repo / ".git" / head_ref + ref_path.unlink() + + artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) + + assert manifest["status"] == ARTIFACT_STATUS_FAILED + assert any(error["type"] == "git_invalid_head" for error in manifest["errors"]) + assert not any(item["kind"] == "workspace_patch" for item in artifacts) + + +def test_workspace_patch_manifest_excludes_env_cache_dirs(tmp_path): + repo = tmp_path / "repo" + _init_repo_with_file(repo) + (repo / "new.txt").write_text("hello\n", encoding="utf-8") + (repo / "node_modules" / "pkg").mkdir(parents=True) + (repo / "node_modules" / "pkg" / "index.js").write_text("generated\n", encoding="utf-8") + artifact_dir = tmp_path / "artifacts" + + artifacts, manifest = write_workspace_patch_artifacts(repo, artifact_dir, task={}) + + assert manifest["status"] == "ready_with_changes" + assert "new.txt" in (artifact_dir / "workspace.patch").read_text(encoding="utf-8") + assert "node_modules" not in (artifact_dir / "workspace.patch").read_text(encoding="utf-8") + assert manifest["counts"]["untracked_excluded"] == 1 + assert any(item["kind"] == "workspace_patch_manifest" for item in artifacts) + + +def test_workspace_patch_fails_on_sensitive_untracked_file(tmp_path): + repo = tmp_path / "repo" + _init_repo_with_file(repo) + (repo / ".npmrc").write_text("//registry.npmjs.org/:_authToken=secret\n", encoding="utf-8") + + artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) + + assert manifest["status"] == ARTIFACT_STATUS_FAILED + assert manifest["errors"][0]["type"] == "sensitive_untracked_files" + assert manifest["sensitive_blocked"][0]["path"] == ".npmrc" + assert not any(item["kind"] == "workspace_patch" for item in artifacts) + + +def test_workspace_patch_fails_on_sensitive_untracked_file_inside_excluded_dir(tmp_path): + repo = tmp_path / "repo" + _init_repo_with_file(repo) + secret = repo / "node_modules" / "pkg" / "service-account.json" + secret.parent.mkdir(parents=True) + secret.write_text("TOKEN=secret\n", encoding="utf-8") + + artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) + + assert manifest["status"] == ARTIFACT_STATUS_FAILED + assert manifest["counts"]["sensitive_blocked"] == 1 + assert manifest["sensitive_blocked"][0]["path"] == "node_modules/pkg/service-account.json" + assert not any(item["kind"] == "workspace_patch" for item in artifacts) + + +def test_workspace_patch_fails_on_common_credential_paths(tmp_path): + repo = tmp_path / "repo" + _init_repo_with_file(repo) + (repo / "credentials").write_text("secret\n", encoding="utf-8") + (repo / "prod.env").write_text("SECRET=1\n", encoding="utf-8") + (repo / "settings.env.local").write_text("SECRET=1\n", encoding="utf-8") + (repo / ".aws").mkdir() + (repo / ".aws" / "credentials").write_text("secret\n", encoding="utf-8") + + artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) + + assert manifest["status"] == ARTIFACT_STATUS_FAILED + assert {item["path"] for item in manifest["sensitive_blocked"]} == { + "credentials", + "prod.env", + "settings.env.local", + ".aws/credentials", + } + assert not any(item["kind"] == "workspace_patch" for item in artifacts) + + +def test_workspace_patch_allows_benign_tokenizer_json(tmp_path): + repo = tmp_path / "repo" + _init_repo_with_file(repo) + (repo / "tokenizer.json").write_text("{}\n", encoding="utf-8") + + artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) + + assert manifest["status"] == ARTIFACT_STATUS_READY_WITH_CHANGES + assert manifest["sensitive_blocked"] == [] + assert any(item["kind"] == "workspace_patch" for item in artifacts) + + +def test_failed_refinalization_drops_stale_workspace_patch_metadata(tmp_path): + parent = tmp_path / "data" + repo = tmp_path / "repo" + parent.mkdir() + _init_repo_with_file(repo) + (repo / "tracked.txt").write_text("new\n", encoding="utf-8") + task = {"id": "task-stale", "workspace_root": str(repo)} + write_task_result(parent, "task-stale", "completed", workspace_root=str(repo), artifact_status="finalizing") + finalize_task_artifacts(parent, task) + result = json.loads((parent / "task_results" / "task-stale.json").read_text(encoding="utf-8")) + assert any(item.get("kind") == "workspace_patch" for item in result["artifacts"]) + + (repo / ".env").write_text("TOKEN=secret\n", encoding="utf-8") + finalize_task_artifacts(parent, task) + + result = json.loads((parent / "task_results" / "task-stale.json").read_text(encoding="utf-8")) + assert result["artifact_status"] == ARTIFACT_STATUS_FAILED + assert not any(item.get("kind") == "workspace_patch" for item in result["artifacts"]) + + +def test_workspace_patch_preserves_untracked_paths_with_whitespace(tmp_path): + repo = tmp_path / "repo" + _init_repo_with_file(repo) + leading = repo / " leading.txt" + nested = repo / "dir with space" / "file name.txt" + leading.write_text("leading\n", encoding="utf-8") + nested.parent.mkdir() + nested.write_text("nested\n", encoding="utf-8") + + _artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task={}) + + assert manifest["status"] == "ready_with_changes" + assert " leading.txt" in manifest["untracked_included"] + assert "dir with space/file name.txt" in manifest["untracked_included"] + assert manifest["patch_size"] > 0 + + +def test_finalize_workspace_patch_allows_external_workspace_head_changed(tmp_path): + parent = tmp_path / "data" + repo = tmp_path / "repo" + parent.mkdir() + _init_repo_with_file(repo) + old_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, check=True).stdout.strip() + (repo / "tracked.txt").write_text("new\n", encoding="utf-8") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "move"], cwd=repo, check=True, capture_output=True) + task = { + "id": "task-head", + "workspace_root": str(repo), + "metadata": {"workspace_preflight": {"git": {"head": old_head}}}, + } + write_task_result(parent, "task-head", "completed", workspace_root=str(repo), artifact_status="finalizing") + + finalize_task_artifacts(parent, task) + + result = json.loads((parent / "task_results" / "task-head.json").read_text(encoding="utf-8")) + assert result["artifact_status"] == ARTIFACT_STATUS_READY_WITH_CHANGES + manifest = json.loads((task_artifacts_dir(parent, "task-head") / "workspace_patch.json").read_text(encoding="utf-8")) + assert manifest["errors"] == [] + + +def test_finalize_workspace_patch_exception_manifest_keeps_base_fields(tmp_path, monkeypatch): + import ouroboros.headless as headless + + parent = tmp_path / "data" + repo = tmp_path / "repo" + parent.mkdir() + _init_repo_with_file(repo) + task = {"id": "task-exception", "workspace_root": str(repo)} + write_task_result(parent, "task-exception", "completed", workspace_root=str(repo), artifact_status="finalizing") + + def boom(*_args, **_kwargs): + raise RuntimeError("artifact failure") + + monkeypatch.setattr(headless, "write_workspace_patch_artifacts", boom) + headless.finalize_task_artifacts(parent, task) + + result = json.loads((parent / "task_results" / "task-exception.json").read_text(encoding="utf-8")) + manifest = json.loads((task_artifacts_dir(parent, "task-exception") / "workspace_patch.json").read_text(encoding="utf-8")) + assert result["artifact_status"] == ARTIFACT_STATUS_FAILED + assert manifest["status"] == ARTIFACT_STATUS_FAILED + assert manifest["base_ref"] == "" + assert manifest["base_head"] == "" + assert manifest["base_is_empty_tree"] is False + assert manifest["current_head"] == "" + + +def test_workspace_patch_uses_acting_base_sha_without_preflight_metadata(tmp_path): + repo = tmp_path / "repo" + _init_repo_with_file(repo) + base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, check=True).stdout.strip() + (repo / "tracked.txt").write_text("acting edit\n", encoding="utf-8") + task = { + "task_constraint": { + "mode": "acting_subagent", + "surface": "self_worktree", + "base_sha": base_head, + }, + } + + artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task=task) + + assert manifest["status"] == "ready_with_changes" + assert manifest["base_ref"] == base_head + assert manifest["base_head"] == base_head + assert manifest["current_head"] == base_head + assert any(item["kind"] == "workspace_patch" for item in artifacts) + + +def test_workspace_patch_fails_when_acting_base_sha_head_changed(tmp_path): + repo = tmp_path / "repo" + _init_repo_with_file(repo) + base_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, check=True).stdout.strip() + (repo / "tracked.txt").write_text("committed by child\n", encoding="utf-8") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-m", "child commit"], cwd=repo, check=True, capture_output=True) + moved_head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, check=True).stdout.strip() + task = { + "task_constraint": { + "mode": "acting_subagent", + "surface": "self_worktree", + "base_sha": base_head, + }, + } + + artifacts, manifest = write_workspace_patch_artifacts(repo, tmp_path / "artifacts", task=task) + + assert manifest["status"] == ARTIFACT_STATUS_FAILED + assert manifest["base_ref"] == base_head + assert manifest["errors"][-1]["type"] == "workspace_head_changed" + assert manifest["errors"][-1]["expected_head"] == base_head + assert manifest["errors"][-1]["current_head"] == moved_head + assert not any(item["kind"] == "workspace_patch" for item in artifacts) diff --git a/tests/test_headless_workspace_shell.py b/tests/test_headless_workspace_shell.py new file mode 100644 index 000000000..8c72efe7e --- /dev/null +++ b/tests/test_headless_workspace_shell.py @@ -0,0 +1,483 @@ +"""Workspace tool context and run_shell routing/safety in headless tasks. + +Split verbatim out of ``tests/test_headless_cli.py`` by theme. This module +owns where a workspace task may read, write and execute: project-file +routing, allowed shell cwds, redirect and symlink-escape guards, task-local +git, and the preflight inference of binaries from manifests. +""" +from __future__ import annotations + +import pathlib +import sys + +import pytest + +from ouroboros.tools.core import _repo_read +from ouroboros.tools.registry import ToolContext, ToolRegistry +from ouroboros.workspace_preflight import _infer_tools_from_manifests + + +from tests._headless_cli_shared import ( # noqa: F401 (autouse fixture applies on import) + _init_repo_with_file, + _managed_worker_pool_available, +) + + +def test_workspace_context_routes_project_files_and_keeps_system_tools_reachable(tmp_path): + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + system_repo.mkdir() + workspace.mkdir() + data.mkdir() + (system_repo / "README.md").write_text("system", encoding="utf-8") + (workspace / "README.md").write_text("workspace", encoding="utf-8") + (workspace / "BIBLE.md").write_text("external bible", encoding="utf-8") + + ctx = ToolContext( + repo_dir=system_repo, + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + ) + + assert "workspace" in _repo_read(ctx, "README.md") + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + commit_result = registry.execute("commit_reviewed", {"commit_message": "nope"}) + assert "WORKSPACE_MODE_BLOCKED" not in commit_result + assert registry.get_schema_by_name("commit_reviewed") is not None + assert registry.get_schema_by_name("request_restart") is not None + assert "Written" in registry.execute("write_file", {"path": "BIBLE.md", "content": "external edit"}) + assert (workspace / "BIBLE.md").read_text(encoding="utf-8") == "external edit" + replaced = registry.execute( + "edit_text", + {"path": "README.md", "old_str": "workspace", "new_str": "workspace edited"}, + ) + assert "Replaced" in replaced + assert (workspace / "README.md").read_text(encoding="utf-8") == "workspace edited" + + +def test_workspace_run_shell_cwd_allows_scratch_and_explicit_system(tmp_path, monkeypatch): + """External-workspace tasks may run from host scratch (a sibling checkout, a + /tmp tree) and explicitly select the system repo; generic runtime data stays + off-limits and system-repo mutation remains independently governed.""" + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + # Pin $HOME outside tmp_path so the host-scratch cwd allowance holds on Windows + # CI too (where pytest's tmp dir lives UNDER home and the data-parent-under-home + # protection would otherwise block the sibling scratch cwd). See the same fixture + # in test_external_workspace_access.py. + fake_home = tmp_path / "_home" + fake_home.mkdir() + monkeypatch.setattr(pathlib.Path, "home", lambda: fake_home) + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + outside = tmp_path / "outside" + data = tmp_path / "data" + for path in (system_repo, workspace, outside, data): + path.mkdir() + ctx = ToolContext( + repo_dir=system_repo, + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + ) + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + # Host scratch outside the declared workspace is now a legitimate cwd... + scratch_cwd = registry.execute("run_command", {"cmd": ["pwd"], "cwd": str(outside)}) + assert "SHELL_CWD_BLOCKED" not in scratch_cwd + # The approved root contract makes system_repo an explicit cwd; generic + # runtime_data remains unavailable to process tools. + runtime_repo_cwd = registry.execute("run_command", {"cmd": ["pwd"], "cwd": str(system_repo)}) + assert "SHELL_CWD_BLOCKED" not in runtime_repo_cwd + assert f"cwd={system_repo.resolve()}" in runtime_repo_cwd + runtime_data_cwd = registry.execute("run_command", {"cmd": ["pwd"], "cwd": str(data)}) + assert "SHELL_CWD_BLOCKED" in runtime_data_cwd + # READ-ONLY git at a runtime target is ALLOWED (owner contract "read-only + # everywhere"; the f14baf8f false-block class). Only MUTATING git is target-checked. + git_read = registry._run_shell_safety_check( + {"cmd": ["git", "-C", str(system_repo), "status"]}, "advanced" + ) + assert git_read is None, git_read + git_escape = registry._run_shell_safety_check( + {"cmd": ["git", "-C", str(system_repo), "commit", "-m", "x"]}, "advanced" + ) + assert git_escape and "WORKSPACE_GIT_BLOCKED" in git_escape + git_chain = registry.execute("run_command", {"cmd": ["sh", "-c", "true && git --version; echo git binary OK"]}) + assert "WORKSPACE_GIT_BLOCKED" not in git_chain + outside_write = registry.execute("run_command", {"cmd": ["touch", str(system_repo / "README.md")]}) + assert "WORKSPACE_SHELL_BLOCKED" in outside_write + embedded_outside_write = registry.execute( + "run_command", + {"cmd": ["python", "-c", "open('/tmp/ouroboros-outside.txt','w').write('x')"]}, + ) + assert "WORKSPACE_SHELL_BLOCKED" in embedded_outside_write + + +def test_workspace_shell_safe_stdio_redirects_are_not_write_like(tmp_path, monkeypatch): + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + outside = tmp_path / "outside" + data = tmp_path / "data" + for path in (system_repo, workspace, outside, data): + path.mkdir() + (outside / "visible.txt").write_text("ok\n", encoding="utf-8") + ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace, workspace_mode="external") + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + stderr_sink = registry.execute("run_command", {"cmd": f"find {outside} -maxdepth 1 2>/dev/null"}) + fd_dup = registry.execute("run_command", {"cmd": f"ls {outside} 2>&1 | head -n 1"}) + fd_close = registry.execute("run_command", {"cmd": f"find {outside} -maxdepth 1 2>&-"}) + real_redirect = registry.execute("run_command", {"cmd": f"echo x > {outside / 'out.txt'}"}) + + assert "WORKSPACE_SHELL_BLOCKED" not in stderr_sink, stderr_sink + assert "WORKSPACE_SHELL_BLOCKED" not in fd_dup, fd_dup + assert "WORKSPACE_SHELL_BLOCKED" not in fd_close, fd_close + assert "WORKSPACE_SHELL_BLOCKED" in real_redirect + + +def test_workspace_shell_blocks_windows_absolute_redirects_before_shell_execution(tmp_path, monkeypatch): + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + for path in (system_repo, workspace, data): + path.mkdir() + ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace, workspace_mode="external") + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + drive_redirect = registry.execute("run_command", {"cmd": r"echo x > C:\ouroboros-outside\out.txt"}) + unc_redirect = registry.execute("run_command", {"cmd": r"echo x > \\server\share\out.txt"}) + + assert "WORKSPACE_SHELL_BLOCKED" in drive_redirect + assert "SHELL_SYNTAX_UNSUPPORTED" not in drive_redirect + assert "WORKSPACE_SHELL_BLOCKED" in unc_redirect + assert "SHELL_SYNTAX_UNSUPPORTED" not in unc_redirect + + +def test_workspace_shell_keeps_symlinked_workspace_absolute_paths_allowed(tmp_path, monkeypatch): + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + system_repo = tmp_path / "system" + real_workspace = tmp_path / "real_workspace" + workspace_link = tmp_path / "workspace_link" + data = tmp_path / "data" + for path in (system_repo, real_workspace, data): + path.mkdir() + try: + workspace_link.symlink_to(real_workspace, target_is_directory=True) + except OSError as exc: + pytest.skip(f"symlink unavailable on this platform: {exc}") + ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace_link, workspace_mode="external") + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + target = workspace_link / "inside.txt" + result = registry.execute("run_command", {"cmd": [sys.executable, "-c", f"open({str(target)!r}, 'w').write('ok')"]}) + + assert "WORKSPACE_SHELL_BLOCKED" not in result, result + assert (real_workspace / "inside.txt").exists() + + +def test_workspace_shell_blocks_nested_symlink_escape_absolute_path(tmp_path, monkeypatch): + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + outside = tmp_path / "outside" + data = tmp_path / "data" + for path in (system_repo, workspace, outside, data): + path.mkdir() + outlink = workspace / "outlink" + outside_file = outside / "target.txt" + outside_file.write_text("old\n", encoding="utf-8") + filelink = workspace / "filelink" + executable_name_link = workspace / "touch" + try: + outlink.symlink_to(outside, target_is_directory=True) + filelink.symlink_to(outside_file) + executable_name_link.symlink_to(outside_file) + except OSError as exc: + pytest.skip(f"symlink unavailable on this platform: {exc}") + ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace, workspace_mode="external") + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + result = registry.execute("run_command", {"cmd": f"touch {outlink / 'escaped.txt'}"}) + relative_result = registry.execute("run_command", {"cmd": "touch outlink/escaped-relative.txt"}) + bare_result = registry.execute("run_command", {"cmd": "touch outlink"}) + executable_name_result = registry.execute("run_command", {"cmd": ["touch", "touch"]}) + redirect_result = registry.execute("run_command", {"cmd": "echo changed > filelink"}) + compact_redirect_result = registry.execute("run_command", {"cmd": "echo changed >filelink"}) + shell_inline_result = registry.execute("run_command", {"cmd": ["sh", "-c", "echo changed > filelink"]}) + shell_inline_touch_result = registry.execute("run_command", {"cmd": ["sh", "-c", "touch filelink"]}) + bash_redirect_result = registry.execute("run_command", {"cmd": ["bash", "-c", "echo changed &> filelink"]}) + compact_bash_redirect_result = registry.execute("run_command", {"cmd": ["bash", "-c", "echo changed &>filelink"]}) + tee_result = registry.execute("run_command", {"cmd": "printf changed | tee filelink"}) + shell_inline_tee_result = registry.execute("run_command", {"cmd": ["sh", "-c", "printf changed | tee filelink"]}) + python_inline_result = registry.execute( + "run_command", + {"cmd": [sys.executable, "-c", "open('filelink', 'w').write('changed')"]}, + ) + python_versioned_result = registry.execute( + "run_command", + {"cmd": ["python3.12", "-c", "open('filelink', 'w').write('changed')"]}, + ) + node_script_result = registry.execute( + "run_script", + { + "interpreter": "node", + "script": "require('fs').writeFileSync('filelink', 'changed')", + }, + ) + + assert "WORKSPACE_SHELL_BLOCKED" in result + assert "WORKSPACE_SHELL_BLOCKED" in relative_result + assert "WORKSPACE_SHELL_BLOCKED" in bare_result + assert "WORKSPACE_SHELL_BLOCKED" in executable_name_result + assert "WORKSPACE_SHELL_BLOCKED" in redirect_result + assert "WORKSPACE_SHELL_BLOCKED" in compact_redirect_result + assert "WORKSPACE_SHELL_BLOCKED" in shell_inline_result + assert "WORKSPACE_SHELL_BLOCKED" in shell_inline_touch_result + assert "WORKSPACE_SHELL_BLOCKED" in bash_redirect_result + assert "WORKSPACE_SHELL_BLOCKED" in compact_bash_redirect_result + assert "WORKSPACE_SHELL_BLOCKED" in tee_result + assert "WORKSPACE_SHELL_BLOCKED" in shell_inline_tee_result + assert "WORKSPACE_SHELL_BLOCKED" in python_inline_result + assert "WORKSPACE_SHELL_BLOCKED" in python_versioned_result + assert "WORKSPACE_SHELL_BLOCKED" in node_script_result + assert not (outside / "escaped.txt").exists() + assert not (outside / "escaped-relative.txt").exists() + assert outside_file.read_text(encoding="utf-8") == "old\n" + + +def test_external_workspace_shell_allows_task_local_git(tmp_path, monkeypatch): + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + system_repo.mkdir() + data.mkdir() + _init_repo_with_file(workspace) + ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace, workspace_mode="external") + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + monkeypatch.setenv("OUROBOROS_TEST_RUNTIME_REPO", str(system_repo)) + + allowed = [ + ["git", "for-each-ref", "--format=%(refname)"], + ["git", "rev-list", "--count", "HEAD"], + ["git", "show-ref", "--heads"], + ["git", "branch", "--show-current"], + ["git", "branch", "--list"], + ["git", "branch", "--list", "ma*"], + ["git", "branch", "-av"], + ["git", "tag", "-l"], + ["git", "tag", "--list", "v*"], + ["git", "branch", "new-branch"], + ["git", "branch", "-v", "new-branch"], + ["git", "branch", "--verbose", "new-branch"], + ["git", "branch", "-d", "main"], + ["git", "tag", "v1"], + ["git", "tag", "-a", "v1", "-m", "x"], + ["git", "commit", "--allow-empty", "-m", "task-local commit"], + ["sh", "-c", "git --version; echo git binary OK"], + ] + + for cmd in allowed: + assert registry._run_shell_safety_check({"cmd": cmd}, "advanced") is None, cmd + + # READ-ONLY git reaches the runtime through EVERY retarget vector — that is the + # owner contract ("read-only everywhere, including at a runtime target") and the + # recorded false-block class f14baf8f. Before the Q4=A composition these four + # were refused: the target-aware resolver let them through and the + # external-workspace runtime-READ guard then blocked them as + # WORKSPACE_SHELL_BLOCKED, naming the wrong reason. + for cmd in ( + ["git", "-C", str(system_repo), "status"], + ["git", "--git-dir", str(system_repo / ".git"), "status"], + # as_posix(): a POSIX shell (sh -c) uses forward slashes; a Windows + # backslash literal would be eaten as shell escapes during parsing. + ["sh", "-c", f"cd {system_repo.as_posix()} && git status"], + ["sh", "-c", "git -C $OUROBOROS_TEST_RUNTIME_REPO status"], + ): + result = registry._run_shell_safety_check({"cmd": cmd}, "advanced") + assert result is None, (cmd, result) + + # ...while the MUTATING form of each vector stays blocked. + for cmd in ( + ["git", "-C", str(system_repo), "commit", "-m", "x"], + ["git", "--git-dir", str(system_repo / ".git"), "commit", "-m", "x"], + ["sh", "-c", f"cd {system_repo.as_posix()} && git commit -m x"], + ["sh", "-c", "git -C $OUROBOROS_TEST_RUNTIME_REPO commit -m x"], + ): + result = registry._run_shell_safety_check({"cmd": cmd}, "advanced") + assert result and "WORKSPACE_GIT_BLOCKED" in result, (cmd, result) + + # The read-only exemption is ALL-or-NOTHING per segment: a compound that only + # STARTS with git still meets the runtime/secret read guard in full. + mixed = registry._run_shell_safety_check( + {"cmd": ["sh", "-c", f"git status && cat {(data / 'settings.json').as_posix()}"]}, + "advanced", + ) + assert mixed and "WORKSPACE_SHELL_BLOCKED" in mixed, mixed + + +def test_workspace_shell_git_ls_remote_requires_network_contract(tmp_path): + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + system_repo.mkdir() + data.mkdir() + _init_repo_with_file(workspace) + contract = { + "allowed_resources": {"network": False}, + "resource_policy": {}, + } + ctx = ToolContext( + repo_dir=system_repo, + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_contract=contract, + task_metadata={"task_contract": contract}, + ) + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + for cmd in ( + ["git", "ls-remote", "origin"], + ["git", "submodule", "update", "--init", "--recursive"], + ): + result = registry._run_shell_safety_check({"cmd": cmd}, "advanced") + assert result and "RESOURCE_CONSTRAINT_BLOCKED" in result, (cmd, result) + + +def test_workspace_run_shell_allows_absolute_cwd_under_workspace_and_child_drive(tmp_path): + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + parent_data = tmp_path / "data" + parent_task_dir = parent_data / "task_drives" / "task-workspace" / "scratch" + child_drive = tmp_path / "child-data" + child_dir = child_drive / "task_drives" / "task-workspace" / "scratch" + child_control_dir = child_drive / "memory" + for path in (system_repo, workspace, parent_data / "logs", parent_task_dir, child_dir, child_control_dir): + path.mkdir(parents=True) + ctx = ToolContext( + repo_dir=system_repo, + drive_root=parent_data, + workspace_root=workspace, + workspace_mode="external", + task_id="task-workspace", + task_metadata={"drive_root": str(child_drive), "budget_drive_root": str(parent_data)}, + ) + registry = ToolRegistry(repo_dir=system_repo, drive_root=parent_data) + registry.set_context(ctx) + + def assert_python_cwd(path): + output = registry.execute( + "run_command", + {"cmd": [sys.executable, "-c", "import os; print(os.getcwd())"], "cwd": str(path)}, + ) + assert "exit_code=0" in output + cwd_output = output.rsplit("STDOUT:\n", 1)[-1].strip() + assert pathlib.Path(cwd_output).resolve() == path.resolve() + + assert_python_cwd(workspace) + assert_python_cwd(child_dir) + child_control = registry.execute("run_command", {"cmd": ["pwd"], "cwd": str(child_control_dir)}) + assert "SHELL_CWD_BLOCKED" in child_control + blocked = registry.execute("run_command", {"cmd": ["pwd"], "cwd": str(parent_data / "logs")}) + assert "SHELL_CWD_BLOCKED" in blocked + # Read-only git is allowed everywhere now; the escape check uses a MUTATING form. + git_read = registry._run_shell_safety_check( + {"cmd": ["git", "-C", "../other-repo", "status"], "cwd": str(child_dir)}, + "advanced", + ) + assert git_read is None, git_read + git_escape = registry._run_shell_safety_check( + {"cmd": ["git", "-C", "..", "commit", "-m", "x"], "cwd": str(child_dir)}, + "advanced", + ) + assert git_escape and "WORKSPACE_GIT_BLOCKED" in git_escape, git_escape + protected_escape = registry._run_shell_safety_check( + {"cmd": ["touch", "../data/state/state.json"]}, + "pro", + ) + assert "WORKSPACE_SHELL_BLOCKED" in protected_escape + task_drive_write = registry.execute("run_command", {"cmd": ["touch", "output.txt"], "cwd": str(child_dir)}) + assert "WORKSPACE_SHELL_BLOCKED" not in task_drive_write + assert (child_dir / "output.txt").is_file() + parent_task_drive_write = registry.execute("run_command", {"cmd": ["touch", "output.txt"], "cwd": str(parent_task_dir)}) + assert "WORKSPACE_SHELL_BLOCKED" not in parent_task_drive_write + assert (parent_task_dir / "output.txt").is_file() + absolute_task_drive_file = parent_task_dir / "absolute-python.txt" + absolute_task_drive_write = registry.execute( + "run_command", + {"cmd": [sys.executable, "-c", f"open({str(absolute_task_drive_file)!r}, 'w').write('ok')"]}, + ) + assert "WORKSPACE_SHELL_BLOCKED" not in absolute_task_drive_write + assert absolute_task_drive_file.read_text(encoding="utf-8") == "ok" + + +def test_workspace_shell_allows_nested_relative_write_paths(tmp_path): + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + for path in (system_repo, workspace, data): + path.mkdir(parents=True) + ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace, workspace_mode="external") + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + assert registry._run_shell_safety_check({"cmd": ["touch", "subdir/file.txt"]}, "advanced") is None + assert registry._run_shell_safety_check({"cmd": ["mkdir", "-p", "build/output"]}, "advanced") is None + python_write = {"cmd": [sys.executable, "-c", "open('subdir/python.txt', 'w').write('ok')"]} + assert registry._run_shell_safety_check(python_write, "advanced") is None + + +def test_workspace_shell_sudo_and_pro_passthrough_policy(tmp_path): + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + for path in (system_repo, workspace, data): + path.mkdir() + ctx = ToolContext(repo_dir=system_repo, drive_root=data, workspace_root=workspace, workspace_mode="external") + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + assert "SUDO_INTERACTIVE_BLOCKED" in registry._run_shell_safety_check({"cmd": ["sudo", "true"]}, "pro") + assert "SUDO_INTERACTIVE_BLOCKED" in registry._run_shell_safety_check({"cmd": ["sh", "-c", "sudo true"]}, "pro") + assert "SUDO_INTERACTIVE_BLOCKED" in registry._run_shell_safety_check({"cmd": ["sudo", "-S", "true"]}, "pro") + assert "SUDO_INTERACTIVE_BLOCKED" in registry._run_shell_safety_check({"cmd": ["sudo", "-nS", "true"]}, "pro") + assert "SUDO_INTERACTIVE_BLOCKED" in registry._run_shell_safety_check({"cmd": ["sudoedit", "/etc/hosts"]}, "pro") + assert registry._run_shell_safety_check({"cmd": ["sudo", "-n", "python", "-S", "-c", "print(1)"]}, "pro") is None + assert "SAFETY_VIOLATION" in registry._run_shell_safety_check({"cmd": ["sh", "-c", "gh\nrepo\ncreate x"]}, "pro") + assert "SAFETY_VIOLATION" in registry._run_shell_safety_check({"cmd": ["sh", "-c", "gh\nauth\nlogin"]}, "pro") + outside_write = {"cmd": ["python", "-c", "open('/tmp/ouroboros-pro.txt','w').write('x')"]} + assert "WORKSPACE_SHELL_BLOCKED" in registry._run_shell_safety_check(outside_write, "advanced") + assert registry._run_shell_safety_check(outside_write, "pro") is None + + +def test_workspace_preflight_infers_binaries_from_script_commands(): + tools = _infer_tools_from_manifests([ + { + "type": "node", + "scripts": ["test"], + "script_commands": {"test": "vitest --run"}, + } + ]) + assert "vitest" in tools + assert "test" not in tools + noisy = _infer_tools_from_manifests([ + { + "type": "node", + "scripts": ["build"], + "script_commands": {"build": "NODE_ENV=production cd web && vite build"}, + } + ]) + assert "NODE_ENV=production" not in noisy + assert "cd" not in noisy + assert "vite" in noisy diff --git a/tests/test_process_custody.py b/tests/test_process_custody.py index 0e733f2a5..f23d2ca49 100644 --- a/tests/test_process_custody.py +++ b/tests/test_process_custody.py @@ -46,7 +46,7 @@ _POPEN_ALLOWLIST = { "ouroboros/packaged_cli.py", # user-facing CLI wrapper (foreground) "ouroboros/cli.py", # dev CLI (foreground) "ouroboros/server_control.py", # restart exec path - "ouroboros/headless.py", # waited synchronous child + "ouroboros/workspace_patch_capture.py", # waited synchronous child "ouroboros/preflight_runner.py", # waited hermetic pytest child "ouroboros/tools/shell.py", # bounded foreground commands (waited + tracked) "ouroboros/tools/skill_exec.py", # bounded skill run (waited + tracked) diff --git a/tests/test_store_task_result.py b/tests/test_store_task_result.py new file mode 100644 index 000000000..1ccc10569 --- /dev/null +++ b/tests/test_store_task_result.py @@ -0,0 +1,155 @@ +"""``_store_task_result`` persistence semantics. + +Split out of ``tests/test_agent_task_pipeline.py`` when that module was divided +by theme; every moved block is verbatim. Covers review-evidence persistence, +the compact review projection (no raw model text), failed-status preservation, +and the unresolved-vs-recovered tool-failure outcome axes. +""" + +import json +from types import SimpleNamespace + +import ouroboros.agent_task_pipeline as pipeline + + +def test_store_task_result_persists_review_evidence(tmp_path): + env = SimpleNamespace(drive_root=tmp_path) + + pipeline._store_task_result( + env=env, + task={"id": "task-store", "type": "task", "text": "hi"}, + text="done", + usage={"rounds": 2, "cost": 0.1}, + llm_trace={"tool_calls": [], "reasoning_notes": []}, + review_evidence={"has_evidence": True, "open_obligations": [{"item": "tests_affected"}]}, + ) + + payload = json.loads((tmp_path / "task_results" / "task-store.json").read_text(encoding="utf-8")) + assert payload["review_evidence"]["has_evidence"] is True + assert payload["review_evidence"]["open_obligations"][0]["item"] == "tests_affected" + + +def test_store_task_result_persists_only_compact_review_projection(tmp_path): + env = SimpleNamespace(drive_root=tmp_path) + trace = { + "tool_calls": [], + "review_runs": [{ + "request": {"surface": "task_acceptance", "policy": {"min_successful_slots": 1}}, + "authority": "host_root", + "aggregate_signal": "DEGRADED", + "actors": [{ + "slot_id": "slot_1", "model": "openai/gpt-5.6-sol", "status": "ok", + "parsed": {"verdict": "DEGRADED", "summary": "not enough evidence"}, + "signal": "DEGRADED", "raw_text": "PRIVATE RAW MODEL RESPONSE", + }], + }], + } + pipeline._store_task_result( + env=env, + task={"id": "task-review-projection", "type": "task", "text": "hi"}, + text="done", + usage={"rounds": 1, "cost": 0.0}, + llm_trace=trace, + review_evidence={}, + ) + + payload = json.loads( + (tmp_path / "task_results" / "task-review-projection.json").read_text(encoding="utf-8") + ) + actor = payload["review_projection"]["panels"][0]["actors"][0] + assert actor["model"] == "openai/gpt-5.6-sol" + assert actor["parse_status"] == "valid" + assert actor["semantic_verdict"] == "DEGRADED" + assert "raw_text" not in actor + assert "PRIVATE RAW MODEL RESPONSE" not in json.dumps(payload) + + +def test_store_task_result_preserves_failed_status(tmp_path): + from ouroboros.task_results import STATUS_FAILED, write_task_result + + env = SimpleNamespace(drive_root=tmp_path) + write_task_result(tmp_path, "task-failed", STATUS_FAILED, result="initial failure") + + pipeline._store_task_result( + env=env, + task={"id": "task-failed", "type": "task", "text": "hi"}, + text="final failure reply", + usage={"rounds": 1, "cost": 0.0}, + llm_trace={"tool_calls": [], "reasoning_notes": []}, + review_evidence={}, + ) + + payload = json.loads((tmp_path / "task_results" / "task-failed.json").read_text(encoding="utf-8")) + assert payload["status"] == STATUS_FAILED + assert payload["result"] == "final failure reply" + + +def test_store_task_result_marks_unresolved_tool_failure_failed(tmp_path): + from ouroboros.task_results import STATUS_COMPLETED + + env = SimpleNamespace(drive_root=tmp_path) + + pipeline._store_task_result( + env=env, + task={"id": "task-tool-failed", "type": "task", "text": "make file"}, + text="Created the file.", + usage={"rounds": 2, "cost": 0.0}, + llm_trace={ + "tool_calls": [{ + "tool": "run_command", + "args": {"cmd": "python3 -c ..."}, + "result": "⚠️ ARTIFACT_OUTPUT_ERROR: command succeeded but declared output registration failed.", + "is_error": True, + "status": "artifact_output_error", + }], + "reasoning_notes": [], + }, + review_evidence={}, + ) + + payload = json.loads((tmp_path / "task_results" / "task-tool-failed.json").read_text(encoding="utf-8")) + assert payload["status"] == STATUS_COMPLETED + assert payload["outcome_axes"]["execution"]["status"] == "degraded" + assert payload["outcome_axes"]["objective"]["status"] == "not_evaluated" + assert payload["reason_code"] == "tool_failure" + assert payload["loop_outcome"]["failure"]["tool_errors"][0]["status"] == "artifact_output_error" + + +def test_store_task_result_allows_recovered_tool_failure_success(tmp_path): + from ouroboros.task_results import STATUS_COMPLETED + + env = SimpleNamespace(drive_root=tmp_path) + + pipeline._store_task_result( + env=env, + task={"id": "task-tool-recovered", "type": "task", "text": "make file"}, + text="Created the file.", + usage={"rounds": 3, "cost": 0.0}, + llm_trace={ + "tool_calls": [ + { + "tool": "edit_text", + "args": {"path": "Desktop/report.html"}, + "result": "⚠️ EDIT_TEXT_ERROR: old_str matched 0 times", + "is_error": True, + "status": "edit_text_blocked", + }, + { + "tool": "write_file", + "args": {"root": "user_files", "path": "Desktop/report.html"}, + "result": "OK: wrote user_files:Desktop/report.html\nARTIFACT_OUTPUTS: registered user file -> artifact_store:report.html", + "is_error": False, + "status": "ok", + "artifact_registered": True, + }, + ], + "reasoning_notes": [], + }, + review_evidence={}, + ) + + payload = json.loads((tmp_path / "task_results" / "task-tool-recovered.json").read_text(encoding="utf-8")) + assert payload["status"] == STATUS_COMPLETED + assert payload["outcome_axes"]["execution"]["status"] == "ok" + assert payload["outcome_axes"]["objective"]["status"] == "not_evaluated" + assert payload["loop_outcome"]["failure"] is None diff --git a/tests/test_workspace_executor.py b/tests/test_workspace_executor.py index 342264b8e..1a3adc708 100644 --- a/tests/test_workspace_executor.py +++ b/tests/test_workspace_executor.py @@ -1,18 +1,33 @@ +"""Routing a workspace command or script through an executor backend. + +This module owns the cross-platform absolute-path predicate and the shell path-token +extractor the routing depends on, the executor-reference normalization and backend-path +mapping, the run_command/run_script paths — including the local fallback for an +unmapped task drive, the temp script path and directory outputs of an external +workspace, the redacted trace and the trace a failure keeps — and the protected-artifact +policy that holds on host and backend alike. + +Services, the docker backend and the ``POST /api/tasks`` admission of an executor +reference were split verbatim into ``tests/test_workspace_executor_services.py``, +``tests/test_workspace_executor_docker.py`` and +``tests/test_workspace_executor_admission.py``; the repository builder they share lives +in ``tests/_workspace_executor_shared.py``. + +Whole-file serial suite: it spawns real processes, so ``tests/conftest.py`` tags this +module and its three siblings ``serial`` and the parallel pass excludes them. +""" from __future__ import annotations -import json -import asyncio import os -import subprocess import sys -from pathlib import Path -from types import SimpleNamespace import pytest from ouroboros.shell_parse import is_absolute_path_text, shell_argv_with_path_tokens from ouroboros.tools.registry import ToolContext, ToolRegistry -from ouroboros.workspace_executor import execute, map_backend_path, normalize_executor_ref +from ouroboros.workspace_executor import map_backend_path, normalize_executor_ref + +from tests._workspace_executor_shared import _init_repo def test_is_absolute_path_text_is_cross_platform(): @@ -45,16 +60,6 @@ def test_changed_path_covers_directory_entries(): assert not _changed_path_covers("site/index.html", {"other/"}) -def _init_repo(path: Path) -> None: - path.mkdir(parents=True, exist_ok=True) - subprocess.run(["git", "init"], cwd=path, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - subprocess.run(["git", "config", "user.email", "test@example.invalid"], cwd=path, check=True) - subprocess.run(["git", "config", "user.name", "Test"], cwd=path, check=True) - (path / "README.md").write_text("x\n", encoding="utf-8") - subprocess.run(["git", "add", "README.md"], cwd=path, check=True) - subprocess.run(["git", "commit", "-m", "init"], cwd=path, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - - def test_normalize_executor_ref_rejects_malformed_backend_paths(tmp_path): workspace = tmp_path / "workspace" workspace.mkdir() @@ -534,1462 +539,3 @@ def test_docker_executor_protected_artifact_policy_matches_host_and_backend_spel assert "RESOURCE_POLICY_BLOCKED" in backend_policy_host_arg assert "RESOURCE_POLICY_BLOCKED" in relative_policy_backend_arg assert "RESOURCE_POLICY_BLOCKED" in backend_policy_interpreter_arg - - -def test_executor_local_service_lifecycle_hides_private_snapshot(tmp_path, monkeypatch): - import ouroboros.safety as safety_mod - import ouroboros.workspace_executor as workspace_executor - - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) - bootstrap_calls: list[str] = [] - monkeypatch.setattr(workspace_executor, "bootstrap_process_path", lambda: bootstrap_calls.append("bootstrap")) - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(system_repo) - _init_repo(workspace) - data.mkdir() - ctx = ToolContext( - repo_dir=system_repo, - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_id="svc-test", - executor_ref={ - "type": "local", - "id": "local-service", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - started = json.loads( - registry.execute( - "start_service", - { - "name": "svc", - "cmd": [ - sys.executable, - "-c", - "import os,time; os.write(1, b'READY\\n' + b'x' * 25000); time.sleep(30)", - ], - "readiness": {"log_contains": "READY", "timeout_sec": 5}, - }, - ) - ) - status = json.loads(registry.execute("service_status", {"name": "svc"})) - logs = json.loads(registry.execute("service_logs", {"name": "svc", "tail": 1000})) - stopped_raw = registry.execute("stop_service", {"name": "svc"}) - stopped = json.loads(stopped_raw) - - assert started["ready"] is True - assert started["ready_observed_at"] - assert status["state"] == "running" - assert "READY" not in logs["tail"] - assert "x" in logs["tail"] - assert stopped["state"] == "stopped" - assert "_before_outputs" not in stopped_raw - assert bootstrap_calls - - -def test_executor_readiness_scans_before_large_log_suffix(tmp_path, monkeypatch): - import ouroboros.workspace_executor as workspace_executor - - log_path = tmp_path / "executor-service.log" - log_path.write_bytes(b"READY\n" + (b"x" * 25_000)) - record = SimpleNamespace( - executor=SimpleNamespace(kind="local"), - backend_log_path=str(log_path), - local_proc=SimpleNamespace(poll=lambda: None), - ready=False, - ) - monkeypatch.setattr(workspace_executor.time, "sleep", lambda _seconds: None) - - workspace_executor._wait_readiness( - record, - {"log_contains": "READY", "timeout_sec": 0.05}, - ) - - assert record.ready is True - - -def test_executor_terminal_payload_clears_readiness(tmp_path): - import ouroboros.workspace_executor as workspace_executor - - record = SimpleNamespace( - service_id="task:svc", - name="svc", - task_id="task", - executor=SimpleNamespace( - executor_id="local-service", - kind="local", - network="host", - ), - backend_pid="4321", - backend_cwd="/workspace", - host_cwd=tmp_path, - cwd_root="active_workspace", - cwd_base=str(tmp_path), - cwd_source="active_workspace", - skill_name="", - cmd=["service"], - outputs=[], - keep_alive=False, - backend_log_path=str(tmp_path / "service.log"), - started_at=workspace_executor.time.time(), - ready=True, - ) - - payload = workspace_executor._service_payload(record, state="exited") - - assert payload["state"] == "exited" - assert payload["ready"] is False - assert record.ready is False - - -def test_start_service_with_executor_ref_uses_local_for_unmapped_task_drive_cwd(tmp_path, monkeypatch): - import ouroboros.safety as safety_mod - from ouroboros.tool_access import resource_root_path - - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(system_repo) - _init_repo(workspace) - data.mkdir() - ctx = ToolContext( - repo_dir=system_repo, - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_id="svc-task-drive", - executor_ref={ - "type": "local", - "id": "local-service", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - task_drive = resource_root_path(ctx, "task_drive") - task_drive.mkdir(parents=True, exist_ok=True) - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - started = json.loads( - registry.execute( - "start_service", - { - "name": "svc", - "cmd": [sys.executable, "-c", "import time; print('READY', flush=True); time.sleep(30)"], - "cwd": str(task_drive), - "readiness": {"log_contains": "READY", "timeout_sec": 5}, - }, - ) - ) - status = json.loads(registry.execute("service_status", {"name": "svc"})) - logs = json.loads(registry.execute("service_logs", {"name": "svc", "tail": 1000})) - stopped = json.loads(registry.execute("stop_service", {"name": "svc"})) - - assert "executor" not in started - assert started["cwd_root"] == "task_drive" - assert status["state"] == "running" - assert "READY" in logs["tail"] - assert stopped["state"] == "exited" - - -def test_executor_local_service_can_restart_after_exit(tmp_path, monkeypatch): - import ouroboros.safety as safety_mod - - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(system_repo) - _init_repo(workspace) - data.mkdir() - ctx = ToolContext( - repo_dir=system_repo, - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_id="svc-restart", - executor_ref={ - "type": "local", - "id": "local-service", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - first = json.loads(registry.execute("start_service", {"name": "short", "cmd": [sys.executable, "-c", "print('one')"]})) - import time - - time.sleep(0.5) - second = json.loads(registry.execute("start_service", {"name": "short", "cmd": [sys.executable, "-c", "print('two')"]})) - - assert first["backend_pid"] != second["backend_pid"] - assert second.get("note") != "already_running" - records = list((data / "state" / "workspace_executor_processes").glob("*.json")) - assert len(records) == 1 - durable = json.loads(records[0].read_text(encoding="utf-8")) - assert str(durable["host_pid"]) == str(second["backend_pid"]) - assert str(durable["host_pid"]) != str(first["backend_pid"]) - - -def test_executor_local_service_sanitizes_env_and_redacts_logs(tmp_path, monkeypatch): - import ouroboros.safety as safety_mod - - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - monkeypatch.setenv("OPENROUTER_API_KEY", "sk-secret-executor-service") - monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(system_repo) - _init_repo(workspace) - data.mkdir() - ctx = ToolContext( - repo_dir=system_repo, - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_id="svc-env", - executor_ref={ - "type": "local", - "id": "local-service", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - registry.execute( - "start_service", - { - "name": "svc", - "cmd": [ - sys.executable, - "-c", - "import os, time; print(os.environ.get('OPENROUTER_API_KEY','missing'), flush=True); time.sleep(30)", - ], - "readiness": {"log_contains": "missing", "timeout_sec": 5}, - }, - ) - logs = json.loads(registry.execute("service_logs", {"name": "svc", "tail": 1000})) - registry.execute("stop_service", {"name": "svc"}) - - assert "missing" in logs["tail"] - assert "sk-secret-executor-service" not in logs["tail"] - - -def test_executor_service_status_and_durable_record_redact_secret_like_args(tmp_path, monkeypatch): - import ouroboros.safety as safety_mod - - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(system_repo) - _init_repo(workspace) - data.mkdir() - secret = "OPENAI_API_KEY=sk-secretservicetraceabcdefghijk123456" - ctx = ToolContext( - repo_dir=system_repo, - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_id="svc-redact", - executor_ref={ - "type": "local", - "id": "local-service", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - registry.execute( - "start_service", - { - "name": "svc", - "cmd": [sys.executable, "-c", "import time; print('READY', flush=True); time.sleep(30)", secret], - "readiness": {"log_contains": "READY", "timeout_sec": 5}, - }, - ) - try: - status_raw = registry.execute("service_status", {"name": "svc"}) - records = list((data / "state" / "workspace_executor_processes").glob("*.json")) - durable_text = "\n".join(path.read_text(encoding="utf-8") for path in records) - finally: - registry.execute("stop_service", {"name": "svc"}) - - assert secret not in status_raw - assert secret not in durable_text - assert '"readiness"' not in durable_text - assert "***REDACTED***" in status_raw - assert "***REDACTED***" in durable_text - - -def test_executor_services_participate_in_task_and_global_cleanup(tmp_path, monkeypatch): - import ouroboros.safety as safety_mod - from ouroboros.tools.services import kill_all_services, stop_task_services - import ouroboros.workspace_executor as workspace_executor - - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) - workspace_executor._SERVICES.clear() - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(system_repo) - _init_repo(workspace) - data.mkdir() - ctx = ToolContext( - repo_dir=system_repo, - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_id="svc-cleanup", - executor_ref={ - "type": "local", - "id": "local-service", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - registry.execute("start_service", {"name": "tasksvc", "cmd": [sys.executable, "-c", "import time; time.sleep(30)"]}) - stopped = stop_task_services(ctx) - assert any(item.get("name") == "tasksvc" for item in stopped) - assert workspace_executor.service_status(ctx, "tasksvc") is None - - registry.execute("start_service", {"name": "globalsvc", "cmd": [sys.executable, "-c", "import time; time.sleep(30)"]}) - killed = kill_all_services(data) - assert any(item.get("name") == "globalsvc" for item in killed) - assert workspace_executor.service_status(ctx, "globalsvc") is None - - -def test_executor_keep_alive_service_survives_task_teardown(tmp_path, monkeypatch): - import ouroboros.safety as safety_mod - import ouroboros.workspace_executor as workspace_executor - from ouroboros.tools.services import kill_all_services, stop_task_services - - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) - workspace_executor._SERVICES.clear() - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(system_repo) - _init_repo(workspace) - data.mkdir() - ctx = ToolContext( - repo_dir=system_repo, - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_id="svc-keep", - executor_ref={ - "type": "local", - "id": "local-service", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - registry.execute("start_service", { - "name": "keptsvc", - "cmd": [sys.executable, "-c", "import time; time.sleep(30)"], - "keep_alive": True, - }) - finalized = stop_task_services(ctx) - assert finalized[0]["name"] == "keptsvc" - assert finalized[0]["lifecycle"] == "kept" - assert workspace_executor.service_status(ctx, "keptsvc") is not None - - killed = kill_all_services(data) - assert any(item.get("name") == "keptsvc" for item in killed) - assert workspace_executor.service_status(ctx, "keptsvc") is None - - -def test_executor_panic_cleanup_kills_durable_foreground_and_service_processes(tmp_path): - import time - import ouroboros.workspace_executor as workspace_executor - from ouroboros.platform_layer import subprocess_new_group_kwargs - - data = tmp_path / "data" - data.mkdir() - foreground = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(30)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - **subprocess_new_group_kwargs(), - ) - service = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(30)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - **subprocess_new_group_kwargs(), - ) - try: - workspace_executor._register_process( - data, - { - "record_type": "foreground", - "executor_type": "local", - "executor_id": "local-foreground", - "host_pid": foreground.pid, - }, - ) - workspace_executor._register_process( - data, - { - "record_type": "service", - "service_id": "task:svc", - "task_id": "task", - "name": "svc", - "executor_type": "local", - "executor_id": "local-service", - "host_pid": service.pid, - }, - ) - - killed_foreground = workspace_executor.kill_all_foreground(data, wait=False) - killed_services = workspace_executor.kill_all_services(data, wait=False) - - deadline = time.time() + 15 - while time.time() < deadline and (foreground.poll() is None or service.poll() is None): - time.sleep(0.05) - assert foreground.poll() is not None - assert service.poll() is not None - assert any(item.get("executor_type") == "local" for item in killed_foreground) - assert any(item.get("service_id") == "task:svc" for item in killed_services) - assert not list((data / "state" / "workspace_executor_processes").glob("*.json")) - finally: - for proc in (foreground, service): - if proc.poll() is None: - proc.kill() - - -def test_executor_cleanup_scans_child_drive_records_from_parent_data_root(tmp_path): - import time - import ouroboros.workspace_executor as workspace_executor - from ouroboros.platform_layer import subprocess_new_group_kwargs - - data = tmp_path / "data" - child_data = data / "state" / "headless_tasks" / "task-1" / "data" - child_data.mkdir(parents=True) - proc = subprocess.Popen( - [sys.executable, "-c", "import time; time.sleep(30)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - **subprocess_new_group_kwargs(), - ) - # kill_all_foreground's PID-reuse safety check compares the process command-sha recorded at - # registration against the one it recomputes at kill time. Right after fork+exec the child's - # command line may not yet be readable, so registering too eagerly makes the two shas diverge - # and the kill is silently skipped (flaky). Wait until the command-sha is readable & stable. - for _ in range(200): - if workspace_executor._process_command_sha256(proc.pid): - break - time.sleep(0.02) - try: - workspace_executor._register_process( - child_data, - { - "record_type": "foreground", - "executor_type": "local", - "executor_id": "child-local", - "host_pid": proc.pid, - }, - ) - killed = workspace_executor.kill_all_foreground(data, wait=False) - deadline = time.time() + 15 - while time.time() < deadline and proc.poll() is None: - time.sleep(0.05) - assert proc.poll() is not None - assert any(item.get("executor_type") == "local" for item in killed) - assert not list((child_data / "state" / "workspace_executor_processes").glob("*.json")) - finally: - if proc.poll() is None: - proc.kill() - - -def test_docker_executor_stop_failure_preserves_service_handle(tmp_path, monkeypatch): - import ouroboros.workspace_executor as workspace_executor - - workspace = tmp_path / "workspace" - workspace.mkdir() - data = tmp_path / "data" - data.mkdir() - ctx = ToolContext( - repo_dir=tmp_path / "repo", - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_id="docker-stop", - executor_ref={ - "type": "docker_exec", - "id": "pb-container", - "container_name": "pb-container", - "network": "none", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - workspace_executor._SERVICES.clear() - calls: list[list[str]] = [] - - def fake_run(cmd, **kwargs): - calls.append([str(part) for part in cmd]) - if cmd[:3] == ["docker", "inspect", "-f"]: - return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") - if cmd[:2] == ["docker", "exec"] and "nohup" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="12345\n", stderr="") - if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="permission denied") - if cmd[:2] == ["docker", "exec"] and "kill -0" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="running\n", stderr="") - raise AssertionError(cmd) - - monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) - workspace_executor.start_service( - ctx, - name="svc", - cmd=["sleep", "30"], - host_cwd=workspace, - cwd_root="active_workspace", - readiness={}, - outputs=[], - before_outputs={}, - ) - failed = workspace_executor.stop_service(ctx, "svc") - - assert failed and failed["stop_failed"] is True - assert "permission denied" in failed["stop_error"] - assert workspace_executor.service_status(ctx, "svc") is not None - - -def test_docker_executor_stop_success_without_terminal_kill_preserves_handle(tmp_path, monkeypatch): - import ouroboros.workspace_executor as workspace_executor - - workspace = tmp_path / "workspace" - workspace.mkdir() - data = tmp_path / "data" - data.mkdir() - ctx = ToolContext( - repo_dir=tmp_path / "repo", - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_id="docker-stop-race", - executor_ref={ - "type": "docker_exec", - "id": "pb-container", - "container_name": "pb-container", - "network": "none", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - workspace_executor._SERVICES.clear() - - def fake_run(cmd, **kwargs): - if cmd[:3] == ["docker", "inspect", "-f"]: - return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") - if cmd[:2] == ["docker", "exec"] and "nohup" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="12345\n", stderr="") - if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): - # The stop shell itself returned success, but the subsequent - # kill-0 confirmation still observes a live backend. - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - if cmd[:2] == ["docker", "exec"] and "kill -0" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="running\n", stderr="") - raise AssertionError(cmd) - - monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) - workspace_executor.start_service( - ctx, - name="svc", - cmd=["sleep", "30"], - host_cwd=workspace, - cwd_root="active_workspace", - readiness={}, - outputs=[], - before_outputs={}, - ) - - failed = workspace_executor.stop_service(ctx, "svc") - - assert failed and failed["stop_failed"] is True - assert "kill-0" in failed["stop_error"] - assert workspace_executor.service_status(ctx, "svc") is not None - - -def test_docker_executor_stop_unknown_probe_preserves_handle(tmp_path, monkeypatch): - import ouroboros.workspace_executor as workspace_executor - - workspace = tmp_path / "workspace" - workspace.mkdir() - data = tmp_path / "data" - data.mkdir() - ctx = ToolContext( - repo_dir=tmp_path / "repo", - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_id="docker-stop-unknown", - executor_ref={ - "type": "docker_exec", - "id": "pb-container", - "container_name": "pb-container", - "network": "none", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - workspace_executor._SERVICES.clear() - - def fake_run(cmd, **kwargs): - if cmd[:3] == ["docker", "inspect", "-f"]: - return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") - if cmd[:2] == ["docker", "exec"] and "nohup" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="12345\n", stderr="") - if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - if cmd[:2] == ["docker", "exec"] and "kill -0" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 7, stdout="", stderr="daemon unavailable") - raise AssertionError(cmd) - - monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) - workspace_executor.start_service( - ctx, - name="svc", - cmd=["sleep", "30"], - host_cwd=workspace, - cwd_root="active_workspace", - readiness={}, - outputs=[], - before_outputs={}, - ) - - failed = workspace_executor.stop_service(ctx, "svc") - - assert failed and failed["stop_failed"] is True - assert "unknown" in failed["stop_error"] - assert failed["state"] == "unknown" - assert workspace_executor.service_status(ctx, "svc") is not None - - -def test_docker_executor_stop_state_exception_preserves_handle(tmp_path, monkeypatch): - import ouroboros.workspace_executor as workspace_executor - - workspace = tmp_path / "workspace" - workspace.mkdir() - data = tmp_path / "data" - data.mkdir() - ctx = ToolContext( - repo_dir=tmp_path / "repo", - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_id="docker-stop-exception", - executor_ref={ - "type": "docker_exec", - "id": "pb-container", - "container_name": "pb-container", - "network": "none", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - workspace_executor._SERVICES.clear() - - def fake_run(cmd, **kwargs): - if cmd[:3] == ["docker", "inspect", "-f"]: - return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") - if cmd[:2] == ["docker", "exec"] and "nohup" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="12345\n", stderr="") - if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - raise AssertionError(cmd) - - monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) - monkeypatch.setattr(workspace_executor, "_service_state", lambda _record: (_ for _ in ()).throw(RuntimeError("probe boom"))) - workspace_executor.start_service( - ctx, - name="svc", - cmd=["sleep", "30"], - host_cwd=workspace, - cwd_root="active_workspace", - readiness={}, - outputs=[], - before_outputs={}, - ) - - failed = workspace_executor.stop_service(ctx, "svc") - - assert failed and failed["stop_failed"] is True - assert failed["state"] == "unknown" - assert workspace_executor.service_status(ctx, "svc") is not None - - -def test_docker_executor_global_cleanup_unknown_state_keeps_handle(tmp_path, monkeypatch): - import ouroboros.workspace_executor as workspace_executor - - workspace = tmp_path / "workspace" - workspace.mkdir() - data = tmp_path / "data" - data.mkdir() - ctx = ToolContext( - repo_dir=tmp_path / "repo", - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_id="docker-cleanup-unknown", - executor_ref={ - "type": "docker_exec", - "id": "pb-container", - "container_name": "pb-container", - "network": "none", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - workspace_executor._SERVICES.clear() - - def fake_run(cmd, **kwargs): - if cmd[:3] == ["docker", "inspect", "-f"]: - return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") - if cmd[:2] == ["docker", "exec"] and "nohup" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="12345\n", stderr="") - if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - if cmd[:2] == ["docker", "exec"] and "kill -0" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 7, stdout="", stderr="daemon unavailable") - raise AssertionError(cmd) - - monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) - workspace_executor.start_service( - ctx, - name="svc", - cmd=["sleep", "30"], - host_cwd=workspace, - cwd_root="active_workspace", - readiness={}, - outputs=[], - before_outputs={}, - ) - - result = workspace_executor.kill_all_services(data) - - current = next(item for item in result if item.get("name") == "svc") - assert current["cleanup_dispatched"] is False - assert current["stop_failed"] is True - assert current["state"] == "unknown" - assert workspace_executor.service_status(ctx, "svc") is not None - - -def test_docker_durable_cleanup_keeps_record_until_kill_zero_terminal(tmp_path, monkeypatch): - import ouroboros.workspace_executor as workspace_executor - - data = tmp_path / "data" - data.mkdir() - workspace_executor._SERVICES.clear() - path = workspace_executor._register_process( - data, - { - "record_type": "service", - "executor_type": "docker_exec", - "executor_id": "pb-container", - "container_name": "pb-container", - "backend_pid": "12345", - "service_id": "task:durable", - "task_id": "task", - "name": "durable", - }, - ) - assert path is not None - - def fake_run(cmd, **kwargs): - if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - if cmd[:2] == ["docker", "exec"] and "kill -0" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="running\n", stderr="") - raise AssertionError(cmd) - - monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) - result = workspace_executor._kill_durable_service_records(data) - - assert result[0]["state"] == "cleanup_pending" - assert result[0]["cleanup_dispatched"] is False - assert path.exists() - - -def test_docker_durable_cleanup_keeps_record_on_unknown_kill_zero(tmp_path, monkeypatch): - import ouroboros.workspace_executor as workspace_executor - - data = tmp_path / "data" - data.mkdir() - workspace_executor._SERVICES.clear() - path = workspace_executor._register_process( - data, - { - "record_type": "service", - "executor_type": "docker_exec", - "executor_id": "pb-container", - "container_name": "pb-container", - "backend_pid": "12345", - "service_id": "task:durable-unknown", - "task_id": "task", - "name": "durable-unknown", - }, - ) - assert path is not None - - def fake_run(cmd, **kwargs): - if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - if cmd[:2] == ["docker", "exec"] and "kill -0" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 7, stdout="", stderr="daemon unavailable") - raise AssertionError(cmd) - - monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) - result = workspace_executor._kill_durable_service_records(data) - - assert result[0]["state"] == "cleanup_pending" - assert result[0]["cleanup_dispatched"] is False - assert path.exists() - - -def test_docker_executor_service_shell_uses_process_group_stop(): - from ouroboros.workspace_executor import _docker_service_start_shell, _docker_service_stop_shell - - record = SimpleNamespace(cmd=["python3", "-c", "import time; time.sleep(30)"], backend_cwd="/workspace") - - start_shell = _docker_service_start_shell(record, "/tmp/ouroboros-service-test.log") - stop_shell = _docker_service_stop_shell("12345") - - assert "setsid" in start_shell - assert "sh -c 'exec python3" in start_shell - assert "& echo $!" in start_shell - assert "kill -TERM -$pid" in stop_shell - assert "kill -KILL -$pid" in stop_shell - - -def test_docker_executor_run_script_uses_backend_script_path(tmp_path, monkeypatch): - import ouroboros.safety as safety_mod - import ouroboros.tools.shell as shell_mod - - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(system_repo) - _init_repo(workspace) - data.mkdir() - captured: dict[str, object] = {} - - def fake_execute(ctx, cmd, cwd, timeout_sec): - captured["cmd"] = list(cmd) - captured["cwd"] = str(cwd) - return SimpleNamespace(returncode=0, stdout="ok\n", stderr="", backend_trace={"executor_id": "pb-container"}, args=list(cmd)) - - monkeypatch.setattr(shell_mod, "executor_execute", fake_execute) - ctx = ToolContext( - repo_dir=system_repo, - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - executor_ref={ - "type": "docker_exec", - "id": "pb-container", - "container_name": "pb-container", - "network": "none", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - result = registry.execute("run_script", {"script": "print('ok')", "interpreter": "python3"}) - - assert "ok" in result - assert captured["cmd"][1].startswith("/workspace/.ouroboros/tmp_scripts/script_") - assert not str(captured["cmd"][1]).startswith(str(workspace)) - - -def test_docker_executor_accepts_backend_absolute_write_targets_and_outputs(tmp_path, monkeypatch): - import ouroboros.safety as safety_mod - import ouroboros.tools.shell as shell_mod - - monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") - monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) - system_repo = tmp_path / "system" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(system_repo) - _init_repo(workspace) - data.mkdir() - captured: dict[str, object] = {} - - def fake_execute(ctx, cmd, cwd, timeout_sec): - captured["cmd"] = list(cmd) - (workspace / "backend-output.txt").write_text("ok\n", encoding="utf-8") - return SimpleNamespace(returncode=0, stdout="wrote\n", stderr="", backend_trace={"executor_id": "pb-container"}, args=list(cmd)) - - monkeypatch.setattr(shell_mod, "executor_execute", fake_execute) - ctx = ToolContext( - repo_dir=system_repo, - drive_root=data, - workspace_root=workspace, - workspace_mode="external", - task_id="backend-output", - executor_ref={ - "type": "docker_exec", - "id": "pb-container", - "container_name": "pb-container", - "network": "none", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - registry = ToolRegistry(repo_dir=system_repo, drive_root=data) - registry.set_context(ctx) - - result = registry.execute( - "run_command", - { - "cmd": ["sh", "-c", "printf ok > /workspace/backend-output.txt"], - "outputs": ["/workspace/backend-output.txt"], - }, - ) - - assert "WORKSPACE_SHELL_BLOCKED" not in result - assert "ARTIFACT_OUTPUT_ERROR" not in result - assert "backend-output.txt" in result - assert captured["cmd"] == ["sh", "-c", "printf ok > /workspace/backend-output.txt"] - - -def test_docker_executor_enforces_network_none_before_exec(tmp_path, monkeypatch): - workspace = tmp_path / "workspace" - workspace.mkdir() - ctx = ToolContext( - repo_dir=tmp_path / "repo", - drive_root=tmp_path / "data", - workspace_root=workspace, - workspace_mode="external", - executor_ref={ - "type": "docker_exec", - "id": "pb-container", - "container_name": "pb-container", - "network": "none", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - calls: list[list[str]] = [] - - def fake_run(cmd, **kwargs): - calls.append([str(part) for part in cmd]) - if cmd[:3] == ["docker", "inspect", "-f"]: - return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") - raise AssertionError(cmd) - - class FakePopen: - pid = 999991 - returncode = 0 - - def __init__(self, cmd, **kwargs): - calls.append([str(part) for part in cmd]) - self.args = cmd - - def communicate(self, timeout=None): - return "ok\n", "" - - import ouroboros.workspace_executor as workspace_executor - - monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) - monkeypatch.setattr(workspace_executor.subprocess, "Popen", FakePopen) - result = execute(ctx, ["echo", "ok"], workspace, 30) - - assert result.returncode == 0 - assert result.stdout == "ok\n" - assert calls[0][:4] == ["docker", "inspect", "-f", "{{.HostConfig.NetworkMode}}"] - assert calls[1][:4] == ["docker", "exec", "--workdir", "/workspace"] - - -def test_docker_executor_timeout_cleans_backend_process(tmp_path, monkeypatch): - workspace = tmp_path / "workspace" - workspace.mkdir() - ctx = ToolContext( - repo_dir=tmp_path / "repo", - drive_root=tmp_path / "data", - workspace_root=workspace, - workspace_mode="external", - executor_ref={ - "type": "docker_exec", - "id": "pb-container", - "container_name": "pb-container", - "network": "none", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - calls: list[list[str]] = [] - - def fake_run(cmd, **kwargs): - calls.append([str(part) for part in cmd]) - if cmd[:3] == ["docker", "inspect", "-f"]: - return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") - if cmd[:2] == ["docker", "exec"] and "kill -TERM -$pid" in str(cmd[-1]): - return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") - raise AssertionError(cmd) - - class FakePopen: - pid = 999992 - returncode = None - - def __init__(self, cmd, **kwargs): - calls.append([str(part) for part in cmd]) - self.args = cmd - - def communicate(self, timeout=None): - raise subprocess.TimeoutExpired(self.args, timeout=timeout) - - def wait(self, timeout=None): - self.returncode = -9 - return self.returncode - - import ouroboros.workspace_executor as workspace_executor - - monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) - monkeypatch.setattr(workspace_executor.subprocess, "Popen", FakePopen) - with pytest.raises(subprocess.TimeoutExpired): - execute(ctx, ["sleep", "30"], workspace, 1) - - assert any("cat /tmp/ouroboros-exec-" in call[-1] for call in calls if call[:2] == ["docker", "exec"]) - assert any("kill -TERM -$pid" in call[-1] for call in calls if call[:2] == ["docker", "exec"]) - - -def test_docker_executor_rejects_network_none_when_container_has_network(tmp_path, monkeypatch): - workspace = tmp_path / "workspace" - workspace.mkdir() - ref = normalize_executor_ref( - { - "type": "docker_exec", - "container_name": "pb-container", - "network": "none", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - } - ) - assert ref is not None - ctx = ToolContext( - repo_dir=tmp_path / "repo", - drive_root=tmp_path / "data", - workspace_root=workspace, - workspace_mode="external", - executor_ref={ - "type": "docker_exec", - "container_name": "pb-container", - "network": "none", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - ) - - def fake_run(cmd, **kwargs): - return subprocess.CompletedProcess(cmd, 0, stdout="bridge\n", stderr="") - - import ouroboros.workspace_executor as workspace_executor - - monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) - try: - execute(ctx, ["echo", "ok"], workspace, 30) - except RuntimeError as exc: - assert "NetworkMode=none" in str(exc) - else: # pragma: no cover - kept explicit for failure readability - raise AssertionError("docker network mismatch was not rejected") - - -def test_api_task_metadata_accepts_normalized_executor_ref(tmp_path, monkeypatch): - from ouroboros.gateway import tasks - import supervisor.queue as queue - import supervisor.workers as workers - - captured: dict[str, object] = {} - - async def fake_request_json_or(_request, _default): - return { - "description": "x", - "workspace_root": str(tmp_path / "workspace"), - "workspace_mode": "external", - "memory_mode": "empty", - "executor_ref": { - "type": "local", - "id": "local-api", - "workspace_host_path": str(tmp_path / "workspace"), - "workspace_backend_path": "/workspace", - }, - } - - def fake_enqueue(task): - captured.update(task) - return task - - _init_repo(tmp_path / "workspace") - (tmp_path / "data").mkdir() - monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) - monkeypatch.setattr(tasks, "request_drive_root", lambda _request: tmp_path / "data") - monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: tmp_path / "repo") - monkeypatch.setattr(queue, "enqueue_task", fake_enqueue) - monkeypatch.setattr(queue, "persist_queue_snapshot", lambda *a, **k: True) - monkeypatch.setattr(workers, "WORKERS", {0: SimpleNamespace()}) - monkeypatch.setattr(workers, "_WORKER_POOL_DISABLED_REASON", "") - - request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) - response = asyncio.run(tasks.api_tasks_create(request)) - body = json.loads(response.body.decode("utf-8")) - - assert body["ok"] is True - metadata = captured["metadata"] - assert isinstance(metadata, dict) - assert metadata["executor_ref"]["type"] == "local" - assert metadata["executor_ref"]["id"] == "local-api" - assert metadata["executor_ref"]["workspace_backend_path"] == "/workspace" - assert metadata["executor_ref"]["path_mappings"][0]["host_path"] == str((tmp_path / "workspace").resolve(strict=False)) - - -def test_api_task_rejects_executor_ref_without_external_workspace(tmp_path, monkeypatch): - from ouroboros.gateway import tasks - - async def fake_request_json_or(_request, _default): - return { - "description": "x", - "executor_ref": {"type": "local", "workspace_host_path": str(tmp_path), "workspace_backend_path": "/workspace"}, - } - - monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) - monkeypatch.setattr(tasks, "request_drive_root", lambda _request: tmp_path / "data") - monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: tmp_path / "repo") - (tmp_path / "data").mkdir() - (tmp_path / "repo").mkdir() - - request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) - response = asyncio.run(tasks.api_tasks_create(request)) - body = json.loads(response.body.decode("utf-8")) - - assert response.status_code == 400 - assert "executor_ref requires an external workspace_root" in body["error"] - - -def test_api_task_rejects_empty_executor_ref(tmp_path, monkeypatch): - from ouroboros.gateway import tasks - - workspace = tmp_path / "workspace" - repo = tmp_path / "repo" - data = tmp_path / "data" - _init_repo(workspace) - repo.mkdir() - data.mkdir() - - async def fake_request_json_or(_request, _default): - return { - "description": "x", - "workspace_root": str(workspace), - "workspace_mode": "external", - "memory_mode": "empty", - "executor_ref": {}, - } - - monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) - monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) - monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) - - request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) - response = asyncio.run(tasks.api_tasks_create(request)) - body = json.loads(response.body.decode("utf-8")) - - assert response.status_code == 400 - assert "executor_ref must be a JSON object" in body["error"] - - -def test_api_task_rejects_executor_ref_mapping_to_system_repo(tmp_path, monkeypatch): - from ouroboros.gateway import tasks - - repo = tmp_path / "repo" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(repo) - _init_repo(workspace) - data.mkdir() - - async def fake_request_json_or(_request, _default): - return { - "description": "x", - "workspace_root": str(workspace), - "workspace_mode": "external", - "memory_mode": "empty", - "executor_ref": {"type": "local", "workspace_host_path": str(repo), "workspace_backend_path": "/workspace"}, - } - - monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) - monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) - monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) - - request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) - response = asyncio.run(tasks.api_tasks_create(request)) - body = json.loads(response.body.decode("utf-8")) - - assert response.status_code == 400 - assert "must not overlap the Ouroboros system repo" in body["error"] - - -def test_api_task_rejects_executor_ref_mapping_to_data_drive(tmp_path, monkeypatch): - from ouroboros.gateway import tasks - - repo = tmp_path / "repo" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(repo) - _init_repo(workspace) - data.mkdir() - - async def fake_request_json_or(_request, _default): - return { - "description": "x", - "workspace_root": str(workspace), - "workspace_mode": "external", - "memory_mode": "empty", - "executor_ref": {"type": "local", "workspace_host_path": str(data), "workspace_backend_path": "/workspace"}, - } - - monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) - monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) - monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) - - request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) - response = asyncio.run(tasks.api_tasks_create(request)) - body = json.loads(response.body.decode("utf-8")) - - assert response.status_code == 400 - assert "must not overlap the Ouroboros data drive" in body["error"] - - -def test_api_task_rejects_executor_ref_not_covering_workspace(tmp_path, monkeypatch): - from ouroboros.gateway import tasks - - repo = tmp_path / "repo" - workspace = tmp_path / "workspace" - other = tmp_path / "other" - data = tmp_path / "data" - _init_repo(repo) - _init_repo(workspace) - other.mkdir() - data.mkdir() - - async def fake_request_json_or(_request, _default): - return { - "description": "x", - "workspace_root": str(workspace), - "workspace_mode": "external", - "memory_mode": "empty", - "executor_ref": {"type": "local", "workspace_host_path": str(other), "workspace_backend_path": "/workspace"}, - } - - monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) - monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) - monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) - - request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) - response = asyncio.run(tasks.api_tasks_create(request)) - body = json.loads(response.body.decode("utf-8")) - - assert response.status_code == 400 - assert "mappings must cover workspace_root" in body["error"] - - -def test_api_task_rejects_reserved_executor_metadata_aliases(tmp_path, monkeypatch): - from ouroboros.gateway import tasks - - repo = tmp_path / "repo" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(repo) - _init_repo(workspace) - data.mkdir() - - async def fake_request_json_or(_request, _default): - return { - "description": "x", - "workspace_root": str(workspace), - "workspace_mode": "external", - "memory_mode": "empty", - "metadata": {"workspace_executor": {"type": "local"}}, - } - - monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) - monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) - monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) - - request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) - response = asyncio.run(tasks.api_tasks_create(request)) - body = json.loads(response.body.decode("utf-8")) - - assert response.status_code == 400 - assert "metadata.executor_ref/workspace_executor is reserved" in body["error"] - - -def test_api_task_rejects_reserved_executor_metadata_ref(tmp_path, monkeypatch): - from ouroboros.gateway import tasks - - repo = tmp_path / "repo" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(repo) - _init_repo(workspace) - data.mkdir() - - async def fake_request_json_or(_request, _default): - return { - "description": "x", - "workspace_root": str(workspace), - "workspace_mode": "external", - "memory_mode": "empty", - "metadata": {"executor_ref": {"type": "local"}}, - } - - monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) - monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) - monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) - - request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) - response = asyncio.run(tasks.api_tasks_create(request)) - body = json.loads(response.body.decode("utf-8")) - - assert response.status_code == 400 - assert "metadata.executor_ref/workspace_executor is reserved" in body["error"] - - -def test_api_task_rejects_local_network_none(tmp_path, monkeypatch): - from ouroboros.gateway import tasks - - repo = tmp_path / "repo" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(repo) - _init_repo(workspace) - data.mkdir() - - async def fake_request_json_or(_request, _default): - return { - "description": "x", - "workspace_root": str(workspace), - "workspace_mode": "external", - "memory_mode": "empty", - "executor_ref": { - "type": "local", - "network": "none", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - }, - } - - monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) - monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) - monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) - - request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) - response = asyncio.run(tasks.api_tasks_create(request)) - body = json.loads(response.body.decode("utf-8")) - - assert response.status_code == 400 - assert "local executor_ref cannot enforce network=none" in body["error"] - - -def test_api_task_rejects_malformed_executor_mapping_entry(tmp_path, monkeypatch): - from ouroboros.gateway import tasks - - repo = tmp_path / "repo" - workspace = tmp_path / "workspace" - data = tmp_path / "data" - _init_repo(repo) - _init_repo(workspace) - data.mkdir() - - async def fake_request_json_or(_request, _default): - return { - "description": "x", - "workspace_root": str(workspace), - "workspace_mode": "external", - "memory_mode": "empty", - "executor_ref": { - "type": "local", - "workspace_host_path": str(workspace), - "workspace_backend_path": "/workspace", - "path_mappings": [{"host_path": str(tmp_path / "missing_backend")}], - }, - } - - monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) - monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) - monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) - - request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) - response = asyncio.run(tasks.api_tasks_create(request)) - body = json.loads(response.body.decode("utf-8")) - - assert response.status_code == 400 - assert "path_mappings entries require host_path and backend_path" in body["error"] - - -def test_api_task_rejects_malformed_executor_ref(tmp_path, monkeypatch): - from ouroboros.gateway import tasks - - workspace = tmp_path / "workspace" - repo = tmp_path / "repo" - data = tmp_path / "data" - _init_repo(workspace) - repo.mkdir() - data.mkdir() - - async def fake_request_json_or(_request, _default): - return { - "description": "x", - "workspace_root": str(workspace), - "workspace_mode": "external", - "memory_mode": "empty", - "executor_ref": {"workspace_host_path": str(workspace), "workspace_backend_path": "/workspace"}, - } - - monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) - monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) - monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) - - request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) - response = asyncio.run(tasks.api_tasks_create(request)) - body = json.loads(response.body.decode("utf-8")) - - assert response.status_code == 400 - assert "executor_ref.type is required" in body["error"] diff --git a/tests/test_workspace_executor_admission.py b/tests/test_workspace_executor_admission.py new file mode 100644 index 000000000..647e1e602 --- /dev/null +++ b/tests/test_workspace_executor_admission.py @@ -0,0 +1,383 @@ +"""What ``POST /api/tasks`` accepts as an executor reference. + +Split verbatim out of ``tests/test_workspace_executor.py`` by theme. This module owns +the normalized reference it admits and every refusal around it: no external workspace, +an empty or malformed reference or mapping entry, a mapping onto the system repo or the +data drive, a mapping that does not cover the workspace, the reserved metadata aliases, +and ``network=none`` asked of the local backend. + +Whole-file serial suite: it spawns real processes, so ``tests/conftest.py`` tags it +``serial`` and the parallel pass excludes it. +""" + +from __future__ import annotations + +import json +import asyncio +from types import SimpleNamespace + + + +from tests._workspace_executor_shared import _init_repo + + +def test_api_task_metadata_accepts_normalized_executor_ref(tmp_path, monkeypatch): + from ouroboros.gateway import tasks + import supervisor.queue as queue + import supervisor.workers as workers + + captured: dict[str, object] = {} + + async def fake_request_json_or(_request, _default): + return { + "description": "x", + "workspace_root": str(tmp_path / "workspace"), + "workspace_mode": "external", + "memory_mode": "empty", + "executor_ref": { + "type": "local", + "id": "local-api", + "workspace_host_path": str(tmp_path / "workspace"), + "workspace_backend_path": "/workspace", + }, + } + + def fake_enqueue(task): + captured.update(task) + return task + + _init_repo(tmp_path / "workspace") + (tmp_path / "data").mkdir() + monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) + monkeypatch.setattr(tasks, "request_drive_root", lambda _request: tmp_path / "data") + monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: tmp_path / "repo") + monkeypatch.setattr(queue, "enqueue_task", fake_enqueue) + monkeypatch.setattr(queue, "persist_queue_snapshot", lambda *a, **k: True) + monkeypatch.setattr(workers, "WORKERS", {0: SimpleNamespace()}) + monkeypatch.setattr(workers, "_WORKER_POOL_DISABLED_REASON", "") + + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) + response = asyncio.run(tasks.api_tasks_create(request)) + body = json.loads(response.body.decode("utf-8")) + + assert body["ok"] is True + metadata = captured["metadata"] + assert isinstance(metadata, dict) + assert metadata["executor_ref"]["type"] == "local" + assert metadata["executor_ref"]["id"] == "local-api" + assert metadata["executor_ref"]["workspace_backend_path"] == "/workspace" + assert metadata["executor_ref"]["path_mappings"][0]["host_path"] == str((tmp_path / "workspace").resolve(strict=False)) + + +def test_api_task_rejects_executor_ref_without_external_workspace(tmp_path, monkeypatch): + from ouroboros.gateway import tasks + + async def fake_request_json_or(_request, _default): + return { + "description": "x", + "executor_ref": {"type": "local", "workspace_host_path": str(tmp_path), "workspace_backend_path": "/workspace"}, + } + + monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) + monkeypatch.setattr(tasks, "request_drive_root", lambda _request: tmp_path / "data") + monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: tmp_path / "repo") + (tmp_path / "data").mkdir() + (tmp_path / "repo").mkdir() + + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) + response = asyncio.run(tasks.api_tasks_create(request)) + body = json.loads(response.body.decode("utf-8")) + + assert response.status_code == 400 + assert "executor_ref requires an external workspace_root" in body["error"] + + +def test_api_task_rejects_empty_executor_ref(tmp_path, monkeypatch): + from ouroboros.gateway import tasks + + workspace = tmp_path / "workspace" + repo = tmp_path / "repo" + data = tmp_path / "data" + _init_repo(workspace) + repo.mkdir() + data.mkdir() + + async def fake_request_json_or(_request, _default): + return { + "description": "x", + "workspace_root": str(workspace), + "workspace_mode": "external", + "memory_mode": "empty", + "executor_ref": {}, + } + + monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) + monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) + monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) + + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) + response = asyncio.run(tasks.api_tasks_create(request)) + body = json.loads(response.body.decode("utf-8")) + + assert response.status_code == 400 + assert "executor_ref must be a JSON object" in body["error"] + + +def test_api_task_rejects_executor_ref_mapping_to_system_repo(tmp_path, monkeypatch): + from ouroboros.gateway import tasks + + repo = tmp_path / "repo" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(repo) + _init_repo(workspace) + data.mkdir() + + async def fake_request_json_or(_request, _default): + return { + "description": "x", + "workspace_root": str(workspace), + "workspace_mode": "external", + "memory_mode": "empty", + "executor_ref": {"type": "local", "workspace_host_path": str(repo), "workspace_backend_path": "/workspace"}, + } + + monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) + monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) + monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) + + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) + response = asyncio.run(tasks.api_tasks_create(request)) + body = json.loads(response.body.decode("utf-8")) + + assert response.status_code == 400 + assert "must not overlap the Ouroboros system repo" in body["error"] + + +def test_api_task_rejects_executor_ref_mapping_to_data_drive(tmp_path, monkeypatch): + from ouroboros.gateway import tasks + + repo = tmp_path / "repo" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(repo) + _init_repo(workspace) + data.mkdir() + + async def fake_request_json_or(_request, _default): + return { + "description": "x", + "workspace_root": str(workspace), + "workspace_mode": "external", + "memory_mode": "empty", + "executor_ref": {"type": "local", "workspace_host_path": str(data), "workspace_backend_path": "/workspace"}, + } + + monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) + monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) + monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) + + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) + response = asyncio.run(tasks.api_tasks_create(request)) + body = json.loads(response.body.decode("utf-8")) + + assert response.status_code == 400 + assert "must not overlap the Ouroboros data drive" in body["error"] + + +def test_api_task_rejects_executor_ref_not_covering_workspace(tmp_path, monkeypatch): + from ouroboros.gateway import tasks + + repo = tmp_path / "repo" + workspace = tmp_path / "workspace" + other = tmp_path / "other" + data = tmp_path / "data" + _init_repo(repo) + _init_repo(workspace) + other.mkdir() + data.mkdir() + + async def fake_request_json_or(_request, _default): + return { + "description": "x", + "workspace_root": str(workspace), + "workspace_mode": "external", + "memory_mode": "empty", + "executor_ref": {"type": "local", "workspace_host_path": str(other), "workspace_backend_path": "/workspace"}, + } + + monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) + monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) + monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) + + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) + response = asyncio.run(tasks.api_tasks_create(request)) + body = json.loads(response.body.decode("utf-8")) + + assert response.status_code == 400 + assert "mappings must cover workspace_root" in body["error"] + + +def test_api_task_rejects_reserved_executor_metadata_aliases(tmp_path, monkeypatch): + from ouroboros.gateway import tasks + + repo = tmp_path / "repo" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(repo) + _init_repo(workspace) + data.mkdir() + + async def fake_request_json_or(_request, _default): + return { + "description": "x", + "workspace_root": str(workspace), + "workspace_mode": "external", + "memory_mode": "empty", + "metadata": {"workspace_executor": {"type": "local"}}, + } + + monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) + monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) + monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) + + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) + response = asyncio.run(tasks.api_tasks_create(request)) + body = json.loads(response.body.decode("utf-8")) + + assert response.status_code == 400 + assert "metadata.executor_ref/workspace_executor is reserved" in body["error"] + + +def test_api_task_rejects_reserved_executor_metadata_ref(tmp_path, monkeypatch): + from ouroboros.gateway import tasks + + repo = tmp_path / "repo" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(repo) + _init_repo(workspace) + data.mkdir() + + async def fake_request_json_or(_request, _default): + return { + "description": "x", + "workspace_root": str(workspace), + "workspace_mode": "external", + "memory_mode": "empty", + "metadata": {"executor_ref": {"type": "local"}}, + } + + monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) + monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) + monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) + + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) + response = asyncio.run(tasks.api_tasks_create(request)) + body = json.loads(response.body.decode("utf-8")) + + assert response.status_code == 400 + assert "metadata.executor_ref/workspace_executor is reserved" in body["error"] + + +def test_api_task_rejects_local_network_none(tmp_path, monkeypatch): + from ouroboros.gateway import tasks + + repo = tmp_path / "repo" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(repo) + _init_repo(workspace) + data.mkdir() + + async def fake_request_json_or(_request, _default): + return { + "description": "x", + "workspace_root": str(workspace), + "workspace_mode": "external", + "memory_mode": "empty", + "executor_ref": { + "type": "local", + "network": "none", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + } + + monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) + monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) + monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) + + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) + response = asyncio.run(tasks.api_tasks_create(request)) + body = json.loads(response.body.decode("utf-8")) + + assert response.status_code == 400 + assert "local executor_ref cannot enforce network=none" in body["error"] + + +def test_api_task_rejects_malformed_executor_mapping_entry(tmp_path, monkeypatch): + from ouroboros.gateway import tasks + + repo = tmp_path / "repo" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(repo) + _init_repo(workspace) + data.mkdir() + + async def fake_request_json_or(_request, _default): + return { + "description": "x", + "workspace_root": str(workspace), + "workspace_mode": "external", + "memory_mode": "empty", + "executor_ref": { + "type": "local", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + "path_mappings": [{"host_path": str(tmp_path / "missing_backend")}], + }, + } + + monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) + monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) + monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) + + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) + response = asyncio.run(tasks.api_tasks_create(request)) + body = json.loads(response.body.decode("utf-8")) + + assert response.status_code == 400 + assert "path_mappings entries require host_path and backend_path" in body["error"] + + +def test_api_task_rejects_malformed_executor_ref(tmp_path, monkeypatch): + from ouroboros.gateway import tasks + + workspace = tmp_path / "workspace" + repo = tmp_path / "repo" + data = tmp_path / "data" + _init_repo(workspace) + repo.mkdir() + data.mkdir() + + async def fake_request_json_or(_request, _default): + return { + "description": "x", + "workspace_root": str(workspace), + "workspace_mode": "external", + "memory_mode": "empty", + "executor_ref": {"workspace_host_path": str(workspace), "workspace_backend_path": "/workspace"}, + } + + monkeypatch.setattr(tasks, "request_json_or", fake_request_json_or) + monkeypatch.setattr(tasks, "request_drive_root", lambda _request: data) + monkeypatch.setattr(tasks, "request_repo_dir", lambda _request: repo) + + request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(supervisor_ready_event=None))) + response = asyncio.run(tasks.api_tasks_create(request)) + body = json.loads(response.body.decode("utf-8")) + + assert response.status_code == 400 + assert "executor_ref.type is required" in body["error"] diff --git a/tests/test_workspace_executor_docker.py b/tests/test_workspace_executor_docker.py new file mode 100644 index 000000000..e64a5c6dd --- /dev/null +++ b/tests/test_workspace_executor_docker.py @@ -0,0 +1,623 @@ +"""The docker executor backend: paths, network fence, timeouts and stop failures. + +Split verbatim out of ``tests/test_workspace_executor.py`` by theme. This module owns +the service handle a failed stop must preserve, the process-group stop the service shell +uses, the backend script path and backend-absolute write targets, the ``network=none`` +enforced before exec and refused when the container already has a network, and the +backend process a timeout cleans up. + +Whole-file serial suite: it spawns real processes, so ``tests/conftest.py`` tags it +``serial`` and the parallel pass excludes it. +""" + +from __future__ import annotations + +import subprocess +from types import SimpleNamespace + +import pytest + +from ouroboros.tools.registry import ToolContext, ToolRegistry +from ouroboros.workspace_executor import execute, normalize_executor_ref + +from tests._workspace_executor_shared import _init_repo + + +def test_docker_executor_stop_failure_preserves_service_handle(tmp_path, monkeypatch): + import ouroboros.workspace_executor as workspace_executor + + workspace = tmp_path / "workspace" + workspace.mkdir() + data = tmp_path / "data" + data.mkdir() + ctx = ToolContext( + repo_dir=tmp_path / "repo", + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_id="docker-stop", + executor_ref={ + "type": "docker_exec", + "id": "pb-container", + "container_name": "pb-container", + "network": "none", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + workspace_executor._SERVICES.clear() + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append([str(part) for part in cmd]) + if cmd[:3] == ["docker", "inspect", "-f"]: + return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") + if cmd[:2] == ["docker", "exec"] and "nohup" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="12345\n", stderr="") + if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="permission denied") + if cmd[:2] == ["docker", "exec"] and "kill -0" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="running\n", stderr="") + raise AssertionError(cmd) + + monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) + workspace_executor.start_service( + ctx, + name="svc", + cmd=["sleep", "30"], + host_cwd=workspace, + cwd_root="active_workspace", + readiness={}, + outputs=[], + before_outputs={}, + ) + failed = workspace_executor.stop_service(ctx, "svc") + + assert failed and failed["stop_failed"] is True + assert "permission denied" in failed["stop_error"] + assert workspace_executor.service_status(ctx, "svc") is not None + + +def test_docker_executor_service_shell_uses_process_group_stop(): + from ouroboros.workspace_executor import _docker_service_start_shell, _docker_service_stop_shell + + record = SimpleNamespace(cmd=["python3", "-c", "import time; time.sleep(30)"], backend_cwd="/workspace") + + start_shell = _docker_service_start_shell(record, "/tmp/ouroboros-service-test.log") + stop_shell = _docker_service_stop_shell("12345") + + assert "setsid" in start_shell + assert "sh -c 'exec python3" in start_shell + assert "& echo $!" in start_shell + assert "kill -TERM -$pid" in stop_shell + assert "kill -KILL -$pid" in stop_shell + + +def test_docker_executor_run_script_uses_backend_script_path(tmp_path, monkeypatch): + import ouroboros.safety as safety_mod + import ouroboros.tools.shell as shell_mod + + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(system_repo) + _init_repo(workspace) + data.mkdir() + captured: dict[str, object] = {} + + def fake_execute(ctx, cmd, cwd, timeout_sec): + captured["cmd"] = list(cmd) + captured["cwd"] = str(cwd) + return SimpleNamespace(returncode=0, stdout="ok\n", stderr="", backend_trace={"executor_id": "pb-container"}, args=list(cmd)) + + monkeypatch.setattr(shell_mod, "executor_execute", fake_execute) + ctx = ToolContext( + repo_dir=system_repo, + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + executor_ref={ + "type": "docker_exec", + "id": "pb-container", + "container_name": "pb-container", + "network": "none", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + result = registry.execute("run_script", {"script": "print('ok')", "interpreter": "python3"}) + + assert "ok" in result + assert captured["cmd"][1].startswith("/workspace/.ouroboros/tmp_scripts/script_") + assert not str(captured["cmd"][1]).startswith(str(workspace)) + + +def test_docker_executor_accepts_backend_absolute_write_targets_and_outputs(tmp_path, monkeypatch): + import ouroboros.safety as safety_mod + import ouroboros.tools.shell as shell_mod + + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(system_repo) + _init_repo(workspace) + data.mkdir() + captured: dict[str, object] = {} + + def fake_execute(ctx, cmd, cwd, timeout_sec): + captured["cmd"] = list(cmd) + (workspace / "backend-output.txt").write_text("ok\n", encoding="utf-8") + return SimpleNamespace(returncode=0, stdout="wrote\n", stderr="", backend_trace={"executor_id": "pb-container"}, args=list(cmd)) + + monkeypatch.setattr(shell_mod, "executor_execute", fake_execute) + ctx = ToolContext( + repo_dir=system_repo, + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_id="backend-output", + executor_ref={ + "type": "docker_exec", + "id": "pb-container", + "container_name": "pb-container", + "network": "none", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + result = registry.execute( + "run_command", + { + "cmd": ["sh", "-c", "printf ok > /workspace/backend-output.txt"], + "outputs": ["/workspace/backend-output.txt"], + }, + ) + + assert "WORKSPACE_SHELL_BLOCKED" not in result + assert "ARTIFACT_OUTPUT_ERROR" not in result + assert "backend-output.txt" in result + assert captured["cmd"] == ["sh", "-c", "printf ok > /workspace/backend-output.txt"] + + +def test_docker_executor_enforces_network_none_before_exec(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + ctx = ToolContext( + repo_dir=tmp_path / "repo", + drive_root=tmp_path / "data", + workspace_root=workspace, + workspace_mode="external", + executor_ref={ + "type": "docker_exec", + "id": "pb-container", + "container_name": "pb-container", + "network": "none", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append([str(part) for part in cmd]) + if cmd[:3] == ["docker", "inspect", "-f"]: + return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") + raise AssertionError(cmd) + + class FakePopen: + pid = 999991 + returncode = 0 + + def __init__(self, cmd, **kwargs): + calls.append([str(part) for part in cmd]) + self.args = cmd + + def communicate(self, timeout=None): + return "ok\n", "" + + import ouroboros.workspace_executor as workspace_executor + + monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) + monkeypatch.setattr(workspace_executor.subprocess, "Popen", FakePopen) + result = execute(ctx, ["echo", "ok"], workspace, 30) + + assert result.returncode == 0 + assert result.stdout == "ok\n" + assert calls[0][:4] == ["docker", "inspect", "-f", "{{.HostConfig.NetworkMode}}"] + assert calls[1][:4] == ["docker", "exec", "--workdir", "/workspace"] + + +def test_docker_executor_timeout_cleans_backend_process(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + ctx = ToolContext( + repo_dir=tmp_path / "repo", + drive_root=tmp_path / "data", + workspace_root=workspace, + workspace_mode="external", + executor_ref={ + "type": "docker_exec", + "id": "pb-container", + "container_name": "pb-container", + "network": "none", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append([str(part) for part in cmd]) + if cmd[:3] == ["docker", "inspect", "-f"]: + return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") + if cmd[:2] == ["docker", "exec"] and "kill -TERM -$pid" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + raise AssertionError(cmd) + + class FakePopen: + pid = 999992 + returncode = None + + def __init__(self, cmd, **kwargs): + calls.append([str(part) for part in cmd]) + self.args = cmd + + def communicate(self, timeout=None): + raise subprocess.TimeoutExpired(self.args, timeout=timeout) + + def wait(self, timeout=None): + self.returncode = -9 + return self.returncode + + import ouroboros.workspace_executor as workspace_executor + + monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) + monkeypatch.setattr(workspace_executor.subprocess, "Popen", FakePopen) + with pytest.raises(subprocess.TimeoutExpired): + execute(ctx, ["sleep", "30"], workspace, 1) + + assert any("cat /tmp/ouroboros-exec-" in call[-1] for call in calls if call[:2] == ["docker", "exec"]) + assert any("kill -TERM -$pid" in call[-1] for call in calls if call[:2] == ["docker", "exec"]) + + +def test_docker_executor_rejects_network_none_when_container_has_network(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir() + ref = normalize_executor_ref( + { + "type": "docker_exec", + "container_name": "pb-container", + "network": "none", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + } + ) + assert ref is not None + ctx = ToolContext( + repo_dir=tmp_path / "repo", + drive_root=tmp_path / "data", + workspace_root=workspace, + workspace_mode="external", + executor_ref={ + "type": "docker_exec", + "container_name": "pb-container", + "network": "none", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, stdout="bridge\n", stderr="") + + import ouroboros.workspace_executor as workspace_executor + + monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) + try: + execute(ctx, ["echo", "ok"], workspace, 30) + except RuntimeError as exc: + assert "NetworkMode=none" in str(exc) + else: # pragma: no cover - kept explicit for failure readability + raise AssertionError("docker network mismatch was not rejected") + + +def test_docker_executor_stop_success_without_terminal_kill_preserves_handle(tmp_path, monkeypatch): + import ouroboros.workspace_executor as workspace_executor + + workspace = tmp_path / "workspace" + workspace.mkdir() + data = tmp_path / "data" + data.mkdir() + ctx = ToolContext( + repo_dir=tmp_path / "repo", + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_id="docker-stop-race", + executor_ref={ + "type": "docker_exec", + "id": "pb-container", + "container_name": "pb-container", + "network": "none", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + workspace_executor._SERVICES.clear() + + def fake_run(cmd, **kwargs): + if cmd[:3] == ["docker", "inspect", "-f"]: + return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") + if cmd[:2] == ["docker", "exec"] and "nohup" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="12345\n", stderr="") + if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): + # The stop shell itself returned success, but the subsequent + # kill-0 confirmation still observes a live backend. + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:2] == ["docker", "exec"] and "kill -0" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="running\n", stderr="") + raise AssertionError(cmd) + + monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) + workspace_executor.start_service( + ctx, + name="svc", + cmd=["sleep", "30"], + host_cwd=workspace, + cwd_root="active_workspace", + readiness={}, + outputs=[], + before_outputs={}, + ) + + failed = workspace_executor.stop_service(ctx, "svc") + + assert failed and failed["stop_failed"] is True + assert "kill-0" in failed["stop_error"] + assert workspace_executor.service_status(ctx, "svc") is not None + + +def test_docker_executor_stop_unknown_probe_preserves_handle(tmp_path, monkeypatch): + import ouroboros.workspace_executor as workspace_executor + + workspace = tmp_path / "workspace" + workspace.mkdir() + data = tmp_path / "data" + data.mkdir() + ctx = ToolContext( + repo_dir=tmp_path / "repo", + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_id="docker-stop-unknown", + executor_ref={ + "type": "docker_exec", + "id": "pb-container", + "container_name": "pb-container", + "network": "none", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + workspace_executor._SERVICES.clear() + + def fake_run(cmd, **kwargs): + if cmd[:3] == ["docker", "inspect", "-f"]: + return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") + if cmd[:2] == ["docker", "exec"] and "nohup" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="12345\n", stderr="") + if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:2] == ["docker", "exec"] and "kill -0" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 7, stdout="", stderr="daemon unavailable") + raise AssertionError(cmd) + + monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) + workspace_executor.start_service( + ctx, + name="svc", + cmd=["sleep", "30"], + host_cwd=workspace, + cwd_root="active_workspace", + readiness={}, + outputs=[], + before_outputs={}, + ) + + failed = workspace_executor.stop_service(ctx, "svc") + + assert failed and failed["stop_failed"] is True + assert "unknown" in failed["stop_error"] + assert failed["state"] == "unknown" + assert workspace_executor.service_status(ctx, "svc") is not None + + +def test_docker_executor_stop_state_exception_preserves_handle(tmp_path, monkeypatch): + import ouroboros.workspace_executor as workspace_executor + + workspace = tmp_path / "workspace" + workspace.mkdir() + data = tmp_path / "data" + data.mkdir() + ctx = ToolContext( + repo_dir=tmp_path / "repo", + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_id="docker-stop-exception", + executor_ref={ + "type": "docker_exec", + "id": "pb-container", + "container_name": "pb-container", + "network": "none", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + workspace_executor._SERVICES.clear() + + def fake_run(cmd, **kwargs): + if cmd[:3] == ["docker", "inspect", "-f"]: + return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") + if cmd[:2] == ["docker", "exec"] and "nohup" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="12345\n", stderr="") + if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + raise AssertionError(cmd) + + monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) + monkeypatch.setattr(workspace_executor, "_service_state", lambda _record: (_ for _ in ()).throw(RuntimeError("probe boom"))) + workspace_executor.start_service( + ctx, + name="svc", + cmd=["sleep", "30"], + host_cwd=workspace, + cwd_root="active_workspace", + readiness={}, + outputs=[], + before_outputs={}, + ) + + failed = workspace_executor.stop_service(ctx, "svc") + + assert failed and failed["stop_failed"] is True + assert failed["state"] == "unknown" + assert workspace_executor.service_status(ctx, "svc") is not None + + +def test_docker_executor_global_cleanup_unknown_state_keeps_handle(tmp_path, monkeypatch): + import ouroboros.workspace_executor as workspace_executor + + workspace = tmp_path / "workspace" + workspace.mkdir() + data = tmp_path / "data" + data.mkdir() + ctx = ToolContext( + repo_dir=tmp_path / "repo", + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_id="docker-cleanup-unknown", + executor_ref={ + "type": "docker_exec", + "id": "pb-container", + "container_name": "pb-container", + "network": "none", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + workspace_executor._SERVICES.clear() + + def fake_run(cmd, **kwargs): + if cmd[:3] == ["docker", "inspect", "-f"]: + return subprocess.CompletedProcess(cmd, 0, stdout="none\n", stderr="") + if cmd[:2] == ["docker", "exec"] and "nohup" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="12345\n", stderr="") + if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:2] == ["docker", "exec"] and "kill -0" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 7, stdout="", stderr="daemon unavailable") + raise AssertionError(cmd) + + monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) + workspace_executor.start_service( + ctx, + name="svc", + cmd=["sleep", "30"], + host_cwd=workspace, + cwd_root="active_workspace", + readiness={}, + outputs=[], + before_outputs={}, + ) + + result = workspace_executor.kill_all_services(data) + + current = next(item for item in result if item.get("name") == "svc") + assert current["cleanup_dispatched"] is False + assert current["stop_failed"] is True + assert current["state"] == "unknown" + assert workspace_executor.service_status(ctx, "svc") is not None + + +def test_docker_durable_cleanup_keeps_record_until_kill_zero_terminal(tmp_path, monkeypatch): + import ouroboros.workspace_executor as workspace_executor + + data = tmp_path / "data" + data.mkdir() + workspace_executor._SERVICES.clear() + path = workspace_executor._register_process( + data, + { + "record_type": "service", + "executor_type": "docker_exec", + "executor_id": "pb-container", + "container_name": "pb-container", + "backend_pid": "12345", + "service_id": "task:durable", + "task_id": "task", + "name": "durable", + }, + ) + assert path is not None + + def fake_run(cmd, **kwargs): + if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:2] == ["docker", "exec"] and "kill -0" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="running\n", stderr="") + raise AssertionError(cmd) + + monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) + result = workspace_executor._kill_durable_service_records(data) + + assert result[0]["state"] == "cleanup_pending" + assert result[0]["cleanup_dispatched"] is False + assert path.exists() + + +def test_docker_durable_cleanup_keeps_record_on_unknown_kill_zero(tmp_path, monkeypatch): + import ouroboros.workspace_executor as workspace_executor + + data = tmp_path / "data" + data.mkdir() + workspace_executor._SERVICES.clear() + path = workspace_executor._register_process( + data, + { + "record_type": "service", + "executor_type": "docker_exec", + "executor_id": "pb-container", + "container_name": "pb-container", + "backend_pid": "12345", + "service_id": "task:durable-unknown", + "task_id": "task", + "name": "durable-unknown", + }, + ) + assert path is not None + + def fake_run(cmd, **kwargs): + if cmd[:2] == ["docker", "exec"] and "kill -TERM" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:2] == ["docker", "exec"] and "kill -0" in str(cmd[-1]): + return subprocess.CompletedProcess(cmd, 7, stdout="", stderr="daemon unavailable") + raise AssertionError(cmd) + + monkeypatch.setattr(workspace_executor.subprocess, "run", fake_run) + result = workspace_executor._kill_durable_service_records(data) + + assert result[0]["state"] == "cleanup_pending" + assert result[0]["cleanup_dispatched"] is False + assert path.exists() diff --git a/tests/test_workspace_executor_services.py b/tests/test_workspace_executor_services.py new file mode 100644 index 000000000..38ce51630 --- /dev/null +++ b/tests/test_workspace_executor_services.py @@ -0,0 +1,521 @@ +"""Executor-backed services: their lifecycle, their records and their teardown. + +Split verbatim out of ``tests/test_workspace_executor.py`` by theme. This module owns +the private snapshot a local service hides, the restart after exit, the sanitized env +and redacted logs, the status and durable record that redact secret-like arguments, the +task and global cleanup they participate in, the keep-alive that survives task +teardown, the panic cleanup that kills durable foreground and service processes, and +the child-drive records a parent data root must scan. + +Whole-file serial suite: it spawns real processes, so ``tests/conftest.py`` tags it +``serial`` and the parallel pass excludes it. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from types import SimpleNamespace + + +from ouroboros.tools.registry import ToolContext, ToolRegistry + +from tests._workspace_executor_shared import _init_repo + + +def test_executor_local_service_lifecycle_hides_private_snapshot(tmp_path, monkeypatch): + import ouroboros.safety as safety_mod + import ouroboros.workspace_executor as workspace_executor + + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) + bootstrap_calls: list[str] = [] + monkeypatch.setattr(workspace_executor, "bootstrap_process_path", lambda: bootstrap_calls.append("bootstrap")) + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(system_repo) + _init_repo(workspace) + data.mkdir() + ctx = ToolContext( + repo_dir=system_repo, + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_id="svc-test", + executor_ref={ + "type": "local", + "id": "local-service", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + started = json.loads( + registry.execute( + "start_service", + { + "name": "svc", + "cmd": [ + sys.executable, + "-c", + "import os,time; os.write(1, b'READY\\n' + b'x' * 25000); time.sleep(30)", + ], + "readiness": {"log_contains": "READY", "timeout_sec": 5}, + }, + ) + ) + status = json.loads(registry.execute("service_status", {"name": "svc"})) + logs = json.loads(registry.execute("service_logs", {"name": "svc", "tail": 1000})) + stopped_raw = registry.execute("stop_service", {"name": "svc"}) + stopped = json.loads(stopped_raw) + + assert started["ready"] is True + assert started["ready_observed_at"] + assert status["state"] == "running" + assert "READY" not in logs["tail"] + assert "x" in logs["tail"] + assert stopped["state"] == "stopped" + assert "_before_outputs" not in stopped_raw + assert bootstrap_calls + + +def test_start_service_with_executor_ref_uses_local_for_unmapped_task_drive_cwd(tmp_path, monkeypatch): + import ouroboros.safety as safety_mod + from ouroboros.tool_access import resource_root_path + + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(system_repo) + _init_repo(workspace) + data.mkdir() + ctx = ToolContext( + repo_dir=system_repo, + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_id="svc-task-drive", + executor_ref={ + "type": "local", + "id": "local-service", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + task_drive = resource_root_path(ctx, "task_drive") + task_drive.mkdir(parents=True, exist_ok=True) + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + started = json.loads( + registry.execute( + "start_service", + { + "name": "svc", + "cmd": [sys.executable, "-c", "import time; print('READY', flush=True); time.sleep(30)"], + "cwd": str(task_drive), + "readiness": {"log_contains": "READY", "timeout_sec": 5}, + }, + ) + ) + status = json.loads(registry.execute("service_status", {"name": "svc"})) + logs = json.loads(registry.execute("service_logs", {"name": "svc", "tail": 1000})) + stopped = json.loads(registry.execute("stop_service", {"name": "svc"})) + + assert "executor" not in started + assert started["cwd_root"] == "task_drive" + assert status["state"] == "running" + assert "READY" in logs["tail"] + assert stopped["state"] == "exited" + + +def test_executor_local_service_can_restart_after_exit(tmp_path, monkeypatch): + import ouroboros.safety as safety_mod + + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(system_repo) + _init_repo(workspace) + data.mkdir() + ctx = ToolContext( + repo_dir=system_repo, + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_id="svc-restart", + executor_ref={ + "type": "local", + "id": "local-service", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + first = json.loads(registry.execute("start_service", {"name": "short", "cmd": [sys.executable, "-c", "print('one')"]})) + import time + + time.sleep(0.5) + second = json.loads(registry.execute("start_service", {"name": "short", "cmd": [sys.executable, "-c", "print('two')"]})) + + assert first["backend_pid"] != second["backend_pid"] + assert second.get("note") != "already_running" + records = list((data / "state" / "workspace_executor_processes").glob("*.json")) + assert len(records) == 1 + durable = json.loads(records[0].read_text(encoding="utf-8")) + assert str(durable["host_pid"]) == str(second["backend_pid"]) + assert str(durable["host_pid"]) != str(first["backend_pid"]) + + +def test_executor_local_service_sanitizes_env_and_redacts_logs(tmp_path, monkeypatch): + import ouroboros.safety as safety_mod + + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-secret-executor-service") + monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(system_repo) + _init_repo(workspace) + data.mkdir() + ctx = ToolContext( + repo_dir=system_repo, + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_id="svc-env", + executor_ref={ + "type": "local", + "id": "local-service", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + registry.execute( + "start_service", + { + "name": "svc", + "cmd": [ + sys.executable, + "-c", + "import os, time; print(os.environ.get('OPENROUTER_API_KEY','missing'), flush=True); time.sleep(30)", + ], + "readiness": {"log_contains": "missing", "timeout_sec": 5}, + }, + ) + logs = json.loads(registry.execute("service_logs", {"name": "svc", "tail": 1000})) + registry.execute("stop_service", {"name": "svc"}) + + assert "missing" in logs["tail"] + assert "sk-secret-executor-service" not in logs["tail"] + + +def test_executor_service_status_and_durable_record_redact_secret_like_args(tmp_path, monkeypatch): + import ouroboros.safety as safety_mod + + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(system_repo) + _init_repo(workspace) + data.mkdir() + secret = "OPENAI_API_KEY=sk-secretservicetraceabcdefghijk123456" + ctx = ToolContext( + repo_dir=system_repo, + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_id="svc-redact", + executor_ref={ + "type": "local", + "id": "local-service", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + registry.execute( + "start_service", + { + "name": "svc", + "cmd": [sys.executable, "-c", "import time; print('READY', flush=True); time.sleep(30)", secret], + "readiness": {"log_contains": "READY", "timeout_sec": 5}, + }, + ) + try: + status_raw = registry.execute("service_status", {"name": "svc"}) + records = list((data / "state" / "workspace_executor_processes").glob("*.json")) + durable_text = "\n".join(path.read_text(encoding="utf-8") for path in records) + finally: + registry.execute("stop_service", {"name": "svc"}) + + assert secret not in status_raw + assert secret not in durable_text + assert '"readiness"' not in durable_text + assert "***REDACTED***" in status_raw + assert "***REDACTED***" in durable_text + + +def test_executor_services_participate_in_task_and_global_cleanup(tmp_path, monkeypatch): + import ouroboros.safety as safety_mod + from ouroboros.tools.services import kill_all_services, stop_task_services + import ouroboros.workspace_executor as workspace_executor + + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) + workspace_executor._SERVICES.clear() + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(system_repo) + _init_repo(workspace) + data.mkdir() + ctx = ToolContext( + repo_dir=system_repo, + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_id="svc-cleanup", + executor_ref={ + "type": "local", + "id": "local-service", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + registry.execute("start_service", {"name": "tasksvc", "cmd": [sys.executable, "-c", "import time; time.sleep(30)"]}) + stopped = stop_task_services(ctx) + assert any(item.get("name") == "tasksvc" for item in stopped) + assert workspace_executor.service_status(ctx, "tasksvc") is None + + registry.execute("start_service", {"name": "globalsvc", "cmd": [sys.executable, "-c", "import time; time.sleep(30)"]}) + killed = kill_all_services(data) + assert any(item.get("name") == "globalsvc" for item in killed) + assert workspace_executor.service_status(ctx, "globalsvc") is None + + +def test_executor_keep_alive_service_survives_task_teardown(tmp_path, monkeypatch): + import ouroboros.safety as safety_mod + import ouroboros.workspace_executor as workspace_executor + from ouroboros.tools.services import kill_all_services, stop_task_services + + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "advanced") + monkeypatch.setattr(safety_mod, "check_safety", lambda *a, **k: (True, "")) + workspace_executor._SERVICES.clear() + system_repo = tmp_path / "system" + workspace = tmp_path / "workspace" + data = tmp_path / "data" + _init_repo(system_repo) + _init_repo(workspace) + data.mkdir() + ctx = ToolContext( + repo_dir=system_repo, + drive_root=data, + workspace_root=workspace, + workspace_mode="external", + task_id="svc-keep", + executor_ref={ + "type": "local", + "id": "local-service", + "workspace_host_path": str(workspace), + "workspace_backend_path": "/workspace", + }, + ) + registry = ToolRegistry(repo_dir=system_repo, drive_root=data) + registry.set_context(ctx) + + registry.execute("start_service", { + "name": "keptsvc", + "cmd": [sys.executable, "-c", "import time; time.sleep(30)"], + "keep_alive": True, + }) + finalized = stop_task_services(ctx) + assert finalized[0]["name"] == "keptsvc" + assert finalized[0]["lifecycle"] == "kept" + assert workspace_executor.service_status(ctx, "keptsvc") is not None + + killed = kill_all_services(data) + assert any(item.get("name") == "keptsvc" for item in killed) + assert workspace_executor.service_status(ctx, "keptsvc") is None + + +def test_executor_panic_cleanup_kills_durable_foreground_and_service_processes(tmp_path): + import time + import ouroboros.workspace_executor as workspace_executor + from ouroboros.platform_layer import subprocess_new_group_kwargs + + data = tmp_path / "data" + data.mkdir() + foreground = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + **subprocess_new_group_kwargs(), + ) + service = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + **subprocess_new_group_kwargs(), + ) + try: + workspace_executor._register_process( + data, + { + "record_type": "foreground", + "executor_type": "local", + "executor_id": "local-foreground", + "host_pid": foreground.pid, + }, + ) + workspace_executor._register_process( + data, + { + "record_type": "service", + "service_id": "task:svc", + "task_id": "task", + "name": "svc", + "executor_type": "local", + "executor_id": "local-service", + "host_pid": service.pid, + }, + ) + + killed_foreground = workspace_executor.kill_all_foreground(data, wait=False) + killed_services = workspace_executor.kill_all_services(data, wait=False) + + deadline = time.time() + 15 + while time.time() < deadline and (foreground.poll() is None or service.poll() is None): + time.sleep(0.05) + assert foreground.poll() is not None + assert service.poll() is not None + assert any(item.get("executor_type") == "local" for item in killed_foreground) + assert any(item.get("service_id") == "task:svc" for item in killed_services) + assert not list((data / "state" / "workspace_executor_processes").glob("*.json")) + finally: + for proc in (foreground, service): + if proc.poll() is None: + proc.kill() + + +def test_executor_cleanup_scans_child_drive_records_from_parent_data_root(tmp_path): + import time + import ouroboros.workspace_executor as workspace_executor + from ouroboros.platform_layer import subprocess_new_group_kwargs + + data = tmp_path / "data" + child_data = data / "state" / "headless_tasks" / "task-1" / "data" + child_data.mkdir(parents=True) + proc = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + **subprocess_new_group_kwargs(), + ) + # kill_all_foreground's PID-reuse safety check compares the process command-sha recorded at + # registration against the one it recomputes at kill time. Right after fork+exec the child's + # command line may not yet be readable, so registering too eagerly makes the two shas diverge + # and the kill is silently skipped (flaky). Wait until the command-sha is readable & stable. + for _ in range(200): + if workspace_executor._process_command_sha256(proc.pid): + break + time.sleep(0.02) + try: + workspace_executor._register_process( + child_data, + { + "record_type": "foreground", + "executor_type": "local", + "executor_id": "child-local", + "host_pid": proc.pid, + }, + ) + killed = workspace_executor.kill_all_foreground(data, wait=False) + deadline = time.time() + 15 + while time.time() < deadline and proc.poll() is None: + time.sleep(0.05) + assert proc.poll() is not None + assert any(item.get("executor_type") == "local" for item in killed) + assert not list((child_data / "state" / "workspace_executor_processes").glob("*.json")) + finally: + if proc.poll() is None: + proc.kill() + + +def test_executor_readiness_scans_before_large_log_suffix(tmp_path, monkeypatch): + import ouroboros.workspace_executor as workspace_executor + + log_path = tmp_path / "executor-service.log" + log_path.write_bytes(b"READY\n" + (b"x" * 25_000)) + record = SimpleNamespace( + executor=SimpleNamespace(kind="local"), + backend_log_path=str(log_path), + local_proc=SimpleNamespace(poll=lambda: None), + ready=False, + ) + monkeypatch.setattr(workspace_executor.time, "sleep", lambda _seconds: None) + + workspace_executor._wait_readiness( + record, + {"log_contains": "READY", "timeout_sec": 0.05}, + ) + + assert record.ready is True + + +def test_executor_terminal_payload_clears_readiness(tmp_path): + import ouroboros.workspace_executor as workspace_executor + + record = SimpleNamespace( + service_id="task:svc", + name="svc", + task_id="task", + executor=SimpleNamespace( + executor_id="local-service", + kind="local", + network="host", + ), + backend_pid="4321", + backend_cwd="/workspace", + host_cwd=tmp_path, + cwd_root="active_workspace", + cwd_base=str(tmp_path), + cwd_source="active_workspace", + skill_name="", + cmd=["service"], + outputs=[], + keep_alive=False, + backend_log_path=str(tmp_path / "service.log"), + started_at=workspace_executor.time.time(), + ready=True, + ) + + payload = workspace_executor._service_payload(record, state="exited") + + assert payload["state"] == "exited" + assert payload["ready"] is False + assert record.ready is False