mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
v7next F1: domain D17 - headless split and three test-giant splits, proof-green
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 (06339bb7readiness truth,a849c9a6probe 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)
This commit is contained in:
parent
88479fa756
commit
57230ee2ef
22 changed files with 5180 additions and 4661 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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]],
|
||||
|
|
|
|||
50
ouroboros/headless_status.py
Normal file
50
ouroboros/headless_status.py
Normal file
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
668
ouroboros/workspace_patch_capture.py
Normal file
668
ouroboros/workspace_patch_capture.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
35
tests/_headless_cli_shared.py
Normal file
35
tests/_headless_cli_shared.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
23
tests/_workspace_executor_shared.py
Normal file
23
tests/_workspace_executor_shared.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
123
tests/test_headless_extraction.py
Normal file
123
tests/test_headless_extraction.py
Normal file
|
|
@ -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
|
||||
596
tests/test_headless_task_api.py
Normal file
596
tests/test_headless_task_api.py
Normal file
|
|
@ -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 == []
|
||||
654
tests/test_headless_task_artifacts.py
Normal file
654
tests/test_headless_task_artifacts.py
Normal file
|
|
@ -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("<h1>done</h1>", 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("<h1>ok</h1>", 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 == "<h1>ok</h1>"
|
||||
|
||||
|
||||
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("<h1>child</h1>", 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 == "<h1>child</h1>"
|
||||
assert parent_artifact.read_text(encoding="utf-8") == "<h1>child</h1>"
|
||||
|
||||
|
||||
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
|
||||
326
tests/test_headless_task_events.py
Normal file
326
tests/test_headless_task_events.py
Normal file
|
|
@ -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"
|
||||
412
tests/test_headless_workspace_patch.py
Normal file
412
tests/test_headless_workspace_patch.py
Normal file
|
|
@ -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("<html></html>\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)
|
||||
483
tests/test_headless_workspace_shell.py
Normal file
483
tests/test_headless_workspace_shell.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
155
tests/test_store_task_result.py
Normal file
155
tests/test_store_task_result.py
Normal file
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load diff
383
tests/test_workspace_executor_admission.py
Normal file
383
tests/test_workspace_executor_admission.py
Normal file
|
|
@ -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"]
|
||||
623
tests/test_workspace_executor_docker.py
Normal file
623
tests/test_workspace_executor_docker.py
Normal file
|
|
@ -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()
|
||||
521
tests/test_workspace_executor_services.py
Normal file
521
tests/test_workspace_executor_services.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue