From 57230ee2ef18c950ccea60bcbfce03c2710fb453 Mon Sep 17 00:00:00 2001
From: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
Date: Sun, 30 Aug 2026 16:27:21 +0000
Subject: [PATCH] v7next F1: domain D17 - headless split and three test-giant
splits, proof-green
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
headless.py (1573 at tip) gives up its two ledger-assigned leaves again:
ouroboros/headless_status.py (50, 11 symbols) and
ouroboros/workspace_patch_capture.py (668, 19 symbols). All 30 spans are
byte-identical between the reference leaves and git show HEAD bytes — the
hardened transplant --check (mandatory byte gate, undeclared-top-level check,
def681bd) is green on every symbol of both leaves. The facade (947) replays
the oracle's exact edit script over tip bytes: its only divergence from the
reference facade is genuine upstream residue drift (child_ref promotion
machinery, import changes), verified hunk by hunk.
Test splits per the ledger, upstream bytes as truth:
- test_headless_cli.py 2824 -> 462 + five themed siblings + shared fixtures;
93 test functions preserved exactly (lossless set equality), one adapted
span kept (the _PATCH_MAX_UNTRACKED_FILE_BYTES monkeypatch retargeted to
the new leaf, ledger row 739's own adaptation); nine oracle spans carrying
OTHER domains' v7 spellings reverse-mapped to upstream signatures keyed to
git show HEAD (registry._run_shell_safety_check string form, tools.core
_repo_read, queue.init 3-arg, queue.QUEUE_SNAPSHOT_PATH).
- test_workspace_executor.py 1995 -> 541 + three siblings + shared builder;
two reference spans byte-falsified by upstream drift (06339bb7 readiness
truth, a849c9a6 probe uncertainty) re-emitted from tip bytes and recorded
in docs/v7next/LEDGER_CORRECTIONS.md.
- test_agent_task_pipeline.py 1658 -> 1515: only the five ledger-assigned
_store_task_result rows carved into tests/test_store_task_result.py; the
other siblings belong to their own lanes.
Thirteen post-cutoff upstream tests have no ledger rows; placed by the
split's theme rule (task_api x4, task_artifacts x1, docker x6, services x2),
disclosed in LEDGER_CORRECTIONS for F5 row-minting.
Pins and mirrors: test_headless_extraction.py transplanted with the
tool_module_inventory clause reduced under an oracle-SHA note (that leaf
belongs to the tools lane); conftest serial table gains the executor family;
the process-custody Popen allowlist row moves headless.py ->
workspace_patch_capture.py with the oracle's justification. All 14 non-split
D17 runtime modules re-proven zero-v7-delta (task_results.py included:
upstream-hot drift stands, no ledger split assigned).
size-ratchet manifest regenerated with the official tool (three test giants
leave GIANT_PATHS, no new band entries); --check green. ruff F clean.
135/105/244/113 tests green in 4-var isolation; HEAD held after every run.
(cherry picked from commit 8dac8303006085bfdb636bacbfc402d6905fae7c)
---
docs/v7next/LEDGER_CORRECTIONS.md | 50 +
ouroboros/headless.py | 704 +-----
ouroboros/headless_status.py | 50 +
ouroboros/size_ratchet_manifest.py | 3 -
ouroboros/workspace_patch_capture.py | 668 ++++++
tests/_headless_cli_shared.py | 35 +
tests/_workspace_executor_shared.py | 23 +
tests/conftest.py | 5 +
tests/test_agent_task_pipeline.py | 143 --
tests/test_headless_cli.py | 2386 +-------------------
tests/test_headless_extraction.py | 123 +
tests/test_headless_task_api.py | 596 +++++
tests/test_headless_task_artifacts.py | 654 ++++++
tests/test_headless_task_events.py | 326 +++
tests/test_headless_workspace_patch.py | 412 ++++
tests/test_headless_workspace_shell.py | 483 ++++
tests/test_process_custody.py | 2 +-
tests/test_store_task_result.py | 155 ++
tests/test_workspace_executor.py | 1496 +-----------
tests/test_workspace_executor_admission.py | 383 ++++
tests/test_workspace_executor_docker.py | 623 +++++
tests/test_workspace_executor_services.py | 521 +++++
22 files changed, 5180 insertions(+), 4661 deletions(-)
create mode 100644 ouroboros/headless_status.py
create mode 100644 ouroboros/workspace_patch_capture.py
create mode 100644 tests/_headless_cli_shared.py
create mode 100644 tests/_workspace_executor_shared.py
create mode 100644 tests/test_headless_extraction.py
create mode 100644 tests/test_headless_task_api.py
create mode 100644 tests/test_headless_task_artifacts.py
create mode 100644 tests/test_headless_task_events.py
create mode 100644 tests/test_headless_workspace_patch.py
create mode 100644 tests/test_headless_workspace_shell.py
create mode 100644 tests/test_store_task_result.py
create mode 100644 tests/test_workspace_executor_admission.py
create mode 100644 tests/test_workspace_executor_docker.py
create mode 100644 tests/test_workspace_executor_services.py
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