agentic-dev-loop 1.0.0a11__tar.gz → 1.0.0a12__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of agentic-dev-loop might be problematic. Click here for more details.
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/PKG-INFO +1 -1
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/pyproject.toml +1 -1
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/config.py +7 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/isolation.py +122 -26
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/PKG-INFO +1 -1
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/SOURCES.txt +1 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_config.py +11 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_e2e_fixtures.py +5 -4
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_isolation.py +157 -3
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_isolation_integration.py +72 -0
- agentic_dev_loop-1.0.0a12/tests/test_trigger_script.py +111 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/README.md +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/agents.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/cli.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/deps.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/dryrun.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/graph.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/llm.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/loaders.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/registry.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/secrets.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/stages.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_agents.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_cli.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_deps.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_dry_run.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_graph.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_hostboundary_integration.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_isolation_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_loaders.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_registry.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_sdk_compat.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_secrets.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_secrets_pipeline.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_stages.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_verdict.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_viz.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agentic-dev-loop"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.0a12"
|
|
8
8
|
description = "Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -79,6 +79,13 @@ class IsolationConfig(BaseModel):
|
|
|
79
79
|
cpus: float | None = None # override exec container cpus
|
|
80
80
|
allow_git_mutations: bool = False # BR-6 knob
|
|
81
81
|
state_dir: str = "~/.agentic-dev-loop/state" # stamps + audit (host-only)
|
|
82
|
+
# 022 relocation opt-in: an out-of-workspace host dir that is bind-mounted
|
|
83
|
+
# at ``{ws}/.venv-sandbox`` (rw) in both the exec and provision containers,
|
|
84
|
+
# keeping the workspace free of container venv artifacts. Unset keeps the
|
|
85
|
+
# legacy workspace-local ``.venv-sandbox`` unchanged. Must resolve outside
|
|
86
|
+
# the workspace — validated at startup, not load (the workspace is unknown
|
|
87
|
+
# here).
|
|
88
|
+
sandbox_venv_dir: str | None = None
|
|
82
89
|
|
|
83
90
|
@field_validator("memory")
|
|
84
91
|
@classmethod
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/isolation.py
RENAMED
|
@@ -7,10 +7,13 @@ concrete implementation (user directive 2026-10-19). Everything else here is
|
|
|
7
7
|
either pure translation logic (the PBT-03 targets) or host-side audit/stamp
|
|
8
8
|
helpers.
|
|
9
9
|
|
|
10
|
-
Host-only vs sandboxed: the sandbox mount set is
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
Host-only vs sandboxed: the sandbox mount set is {workspace} plus the
|
|
11
|
+
host-only mask mounts (022): a read-only empty tmpfs at ``{ws}/.venv`` so the
|
|
12
|
+
host's throwaway venv can never be written from inside the sandbox. When
|
|
13
|
+
``isolation.sandbox_venv_dir`` relocates the sandbox venv, one extra rw bind
|
|
14
|
+
mount at ``{ws}/.venv-sandbox`` is added (opt-in). The isolation state dir
|
|
15
|
+
(stamps + audit JSONL) and the pipeline run-state dir (checkpoint DB +
|
|
16
|
+
last-error.md) are never mounted — sandboxed code cannot reach them.
|
|
14
17
|
"""
|
|
15
18
|
|
|
16
19
|
from __future__ import annotations
|
|
@@ -74,6 +77,10 @@ PROVISION_TIMEOUT_SECONDS = 3600
|
|
|
74
77
|
# container, so it must be detected and re-provisioned there.
|
|
75
78
|
SANDBOX_VENV_DIR = ".venv-sandbox"
|
|
76
79
|
SANDBOX_VENV_PYTHON = ".venv-sandbox/bin/python"
|
|
80
|
+
# Host-only dirs masked inside the sandbox (022): each gets a read-only empty
|
|
81
|
+
# tmpfs submount at ``{ws}/<dir>`` so agents fail loudly on any write instead
|
|
82
|
+
# of silently mutating a host-owned throwaway venv.
|
|
83
|
+
HOST_ONLY_DIRS = (".venv",)
|
|
77
84
|
|
|
78
85
|
|
|
79
86
|
class SandboxError(RuntimeError):
|
|
@@ -277,6 +284,27 @@ def translate_mount_path(path: str) -> str:
|
|
|
277
284
|
return str(p)
|
|
278
285
|
|
|
279
286
|
|
|
287
|
+
def resolve_sandbox_venv_dir(sandbox_venv_dir: str | None,
|
|
288
|
+
workspace: str | Path) -> str | None:
|
|
289
|
+
"""Resolve the opt-in relocated sandbox venv (022).
|
|
290
|
+
|
|
291
|
+
``None`` keeps the legacy workspace-local ``.venv-sandbox`` (bit-compatible
|
|
292
|
+
default — payloads are unchanged). A set value is expanduser + absolute and
|
|
293
|
+
must resolve OUTSIDE the workspace: an in-workspace value defeats the whole
|
|
294
|
+
relocation and fails loud (AE-SBX-015).
|
|
295
|
+
"""
|
|
296
|
+
if sandbox_venv_dir is None:
|
|
297
|
+
return None
|
|
298
|
+
resolved = Path(translate_mount_path(sandbox_venv_dir))
|
|
299
|
+
ws = Path(translate_mount_path(os.fspath(workspace)))
|
|
300
|
+
if resolved == ws or ws in resolved.parents:
|
|
301
|
+
raise SandboxError(
|
|
302
|
+
f"AE-SBX-015: isolation.sandbox_venv_dir {resolved!r} must resolve "
|
|
303
|
+
"outside the workspace — an in-workspace value defeats the 022 "
|
|
304
|
+
"relocation (docs/setup.md §3)")
|
|
305
|
+
return str(resolved)
|
|
306
|
+
|
|
307
|
+
|
|
280
308
|
def setup_stamp_key(setup_cmd: str, lockfile_hashes: dict[str, str]) -> str:
|
|
281
309
|
"""sha256 hex of canonical JSON {setup_cmd, lockfiles: sorted hashes}."""
|
|
282
310
|
canonical = {
|
|
@@ -323,10 +351,44 @@ def venv_probe_state(stdout: str, exit_code: int | None) -> str:
|
|
|
323
351
|
return line if line in ("valid", "missing", "broken") else "unknown"
|
|
324
352
|
|
|
325
353
|
|
|
326
|
-
def
|
|
327
|
-
"""
|
|
328
|
-
|
|
329
|
-
|
|
354
|
+
def _clear_dir_contents(path: Path) -> None:
|
|
355
|
+
"""Delete every entry under ``path``, keeping ``path`` itself. The 022
|
|
356
|
+
relocated venv is the bind-mount source for the still-running execution
|
|
357
|
+
container, so the source dir must survive removal (deleting it would leave
|
|
358
|
+
the container pointed at a stale inode)."""
|
|
359
|
+
if not path.exists():
|
|
360
|
+
return
|
|
361
|
+
for child in path.iterdir():
|
|
362
|
+
if child.is_symlink() or child.is_file():
|
|
363
|
+
child.unlink()
|
|
364
|
+
elif child.is_dir():
|
|
365
|
+
shutil.rmtree(child)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def remove_sandbox_venv(workspace: str | Path, *,
|
|
369
|
+
sandbox_venv_dir: str | None = None) -> None:
|
|
370
|
+
"""Remove the sandbox venv before re-provision (016 self-heal). Fails loud
|
|
371
|
+
when the venv exists but cannot be removed — a silent skip would re-create
|
|
372
|
+
the ENOENT failure the next run.
|
|
373
|
+
|
|
374
|
+
With the 022 relocation opt-in (``sandbox_venv_dir``), the venv lives at
|
|
375
|
+
that host path and is the source of a live bind mount in the execution
|
|
376
|
+
container — so only its CONTENTS are removed (the mount source dir stays),
|
|
377
|
+
leaving the container's ``{ws}/.venv-sandbox`` mount valid for re-provision.
|
|
378
|
+
The default (unset) path removes the whole workspace-local dir as before.
|
|
379
|
+
"""
|
|
380
|
+
if sandbox_venv_dir is not None:
|
|
381
|
+
venv = Path(sandbox_venv_dir)
|
|
382
|
+
try:
|
|
383
|
+
if venv.is_symlink() or venv.is_file():
|
|
384
|
+
venv.unlink()
|
|
385
|
+
elif venv.is_dir():
|
|
386
|
+
_clear_dir_contents(venv)
|
|
387
|
+
except OSError as e:
|
|
388
|
+
raise SandboxError(
|
|
389
|
+
f"AE-SBX-014: cannot remove invalid sandbox venv at {venv}: {e} "
|
|
390
|
+
f"— remove it manually and re-run (docs/setup.md §5)") from e
|
|
391
|
+
return
|
|
330
392
|
venv = Path(workspace) / SANDBOX_VENV_DIR
|
|
331
393
|
try:
|
|
332
394
|
if venv.is_symlink() or venv.is_file():
|
|
@@ -360,15 +422,34 @@ def exec_container_name(run_id: str, network: bool = False) -> str:
|
|
|
360
422
|
return f"{run_id}-exec-net" if network else f"{run_id}-exec"
|
|
361
423
|
|
|
362
424
|
|
|
425
|
+
def _sandbox_mounts(workspace: str, sandbox_venv_dir: str | None) -> list[str]:
|
|
426
|
+
"""The mount segment shared by the exec and provision payloads (022).
|
|
427
|
+
|
|
428
|
+
``{workspace}`` rw is always first; each host-only dir is then shadowed by
|
|
429
|
+
a read-only empty tmpfs submount (Docker cannot exclude a subdirectory of a
|
|
430
|
+
bind — a submount is the only way to hide it, and ``ro`` makes any write
|
|
431
|
+
attempt fail loudly instead of silently succeeding against a decoy). When
|
|
432
|
+
``sandbox_venv_dir`` relocates the sandbox venv, one rw bind mount maps it
|
|
433
|
+
to the unchanged container path ``{ws}/.venv-sandbox``.
|
|
434
|
+
"""
|
|
435
|
+
mounts = ["-v", f"{workspace}:{workspace}"]
|
|
436
|
+
for host_only in HOST_ONLY_DIRS:
|
|
437
|
+
mounts += ["--tmpfs", f"{workspace}/{host_only}:ro"]
|
|
438
|
+
if sandbox_venv_dir is not None:
|
|
439
|
+
mounts += ["-v", f"{sandbox_venv_dir}:{workspace}/{SANDBOX_VENV_DIR}"]
|
|
440
|
+
return mounts
|
|
441
|
+
|
|
442
|
+
|
|
363
443
|
def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
|
|
364
|
-
workspace: str, network: bool = False
|
|
444
|
+
workspace: str, network: bool = False,
|
|
445
|
+
sandbox_venv_dir: str | None = None) -> list[str]:
|
|
365
446
|
"""Exact ``docker run -d --rm`` argv for the execution container (§3.3, KD-8).
|
|
366
|
-
|
|
367
|
-
is true — 014 per-stage networking), --read-only,
|
|
368
|
-
host uid:gid (host-owned bind mounts, AE-SBX-009
|
|
369
|
-
HOME=/tmp (the rootfs is read-only; /tmp is a 1777 tmpfs).
|
|
370
|
-
daemon auto-remove the container the instant it exits,
|
|
371
|
-
(D1) — a SIGKILLed CLI never reaches close()."""
|
|
447
|
+
Mount set {workspace} + host-only masks (022); ``--network none`` (bridge
|
|
448
|
+
when ``network`` is true — 014 per-stage networking), --read-only,
|
|
449
|
+
--cap-drop ALL; --user host uid:gid (host-owned bind mounts, AE-SBX-009
|
|
450
|
+
root cause) with HOME=/tmp (the rootfs is read-only; /tmp is a 1777 tmpfs).
|
|
451
|
+
``--rm`` lets the daemon auto-remove the container the instant it exits,
|
|
452
|
+
however it exits (D1) — a SIGKILLed CLI never reaches close()."""
|
|
372
453
|
payload = [
|
|
373
454
|
"docker", "run", "--detach", "--rm", "--name",
|
|
374
455
|
exec_container_name(run_id, network),
|
|
@@ -380,8 +461,8 @@ def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
|
|
|
380
461
|
user = host_user_flag()
|
|
381
462
|
if user is not None:
|
|
382
463
|
payload.extend(["--user", user, "--env", "HOME=/tmp"])
|
|
464
|
+
payload.extend(_sandbox_mounts(workspace, sandbox_venv_dir))
|
|
383
465
|
payload.extend([
|
|
384
|
-
"-v", f"{workspace}:{workspace}",
|
|
385
466
|
"--workdir", workspace,
|
|
386
467
|
image, "sleep", "infinity",
|
|
387
468
|
])
|
|
@@ -389,10 +470,12 @@ def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
|
|
|
389
470
|
|
|
390
471
|
|
|
391
472
|
def docker_provision_payload(*, run_id: str, image: str, workspace: str,
|
|
392
|
-
setup_cmd: str
|
|
473
|
+
setup_cmd: str,
|
|
474
|
+
sandbox_venv_dir: str | None = None) -> list[str]:
|
|
393
475
|
"""Exact ``docker run --rm`` argv for the provision container (§3.4).
|
|
394
|
-
Network ON (default bridge), rw rootfs, --cap-drop ALL,
|
|
395
|
-
{workspace} (
|
|
476
|
+
Network ON (default bridge), rw rootfs, --cap-drop ALL, mount set
|
|
477
|
+
{workspace} + host-only masks (022); --user host uid:gid (AE-SBX-009) with
|
|
478
|
+
HOME=/tmp."""
|
|
396
479
|
payload = [
|
|
397
480
|
"docker", "run", "--rm", "--name", f"{run_id}-provision",
|
|
398
481
|
"--network", "bridge", "--cap-drop", "ALL",
|
|
@@ -401,8 +484,8 @@ def docker_provision_payload(*, run_id: str, image: str, workspace: str,
|
|
|
401
484
|
user = host_user_flag()
|
|
402
485
|
if user is not None:
|
|
403
486
|
payload.extend(["--user", user, "--env", "HOME=/tmp"])
|
|
487
|
+
payload.extend(_sandbox_mounts(workspace, sandbox_venv_dir))
|
|
404
488
|
payload.extend([
|
|
405
|
-
"-v", f"{workspace}:{workspace}",
|
|
406
489
|
"--workdir", workspace,
|
|
407
490
|
image, "sh", "-c", setup_cmd,
|
|
408
491
|
])
|
|
@@ -502,7 +585,7 @@ class DockerBackend(Sandbox):
|
|
|
502
585
|
|
|
503
586
|
def __init__(self, *, image: str, workspace: str, iso_state_dir: str,
|
|
504
587
|
profile: EffectiveProfile, runner: CommandRunner | None = None,
|
|
505
|
-
network: bool = False):
|
|
588
|
+
network: bool = False, sandbox_venv_dir: str | None = None):
|
|
506
589
|
self._image = image
|
|
507
590
|
self._workspace = workspace
|
|
508
591
|
self._iso_state_dir = Path(iso_state_dir) # host-only (audit)
|
|
@@ -512,6 +595,13 @@ class DockerBackend(Sandbox):
|
|
|
512
595
|
self._container = exec_container_name(self._run_id, network=False)
|
|
513
596
|
self._network_container = exec_container_name(self._run_id, network=True)
|
|
514
597
|
self._network_enabled = network
|
|
598
|
+
self._sandbox_venv_dir = sandbox_venv_dir # 022 relocated venv (opt-in)
|
|
599
|
+
if sandbox_venv_dir is not None:
|
|
600
|
+
# 022: the relocated venv dir is a bind-mount source. On native
|
|
601
|
+
# Linux, Docker auto-creates a missing source as root, which the
|
|
602
|
+
# --user uid:gid containers then cannot write (the AE-SBX-009
|
|
603
|
+
# ownership class). Pre-create it host-side so it is uid-owned.
|
|
604
|
+
Path(sandbox_venv_dir).mkdir(parents=True, exist_ok=True)
|
|
515
605
|
self._started = False
|
|
516
606
|
self._network_started = False
|
|
517
607
|
|
|
@@ -551,7 +641,8 @@ class DockerBackend(Sandbox):
|
|
|
551
641
|
infrastructure work happens mid-run (014 per-stage networking)."""
|
|
552
642
|
payload = docker_create_payload(
|
|
553
643
|
run_id=self._run_id, image=self._image, profile=self._profile,
|
|
554
|
-
workspace=self._workspace, network=False
|
|
644
|
+
workspace=self._workspace, network=False,
|
|
645
|
+
sandbox_venv_dir=self._sandbox_venv_dir)
|
|
555
646
|
res = self._runner.run(payload, timeout=60)
|
|
556
647
|
if res.exit_code != 0:
|
|
557
648
|
raise SandboxError(
|
|
@@ -562,7 +653,8 @@ class DockerBackend(Sandbox):
|
|
|
562
653
|
return
|
|
563
654
|
net_payload = docker_create_payload(
|
|
564
655
|
run_id=self._run_id, image=self._image, profile=self._profile,
|
|
565
|
-
workspace=self._workspace, network=True
|
|
656
|
+
workspace=self._workspace, network=True,
|
|
657
|
+
sandbox_venv_dir=self._sandbox_venv_dir)
|
|
566
658
|
net_res = self._runner.run(net_payload, timeout=60)
|
|
567
659
|
if net_res.exit_code != 0:
|
|
568
660
|
# reclaim the already-started network-none container — never leak it.
|
|
@@ -587,12 +679,13 @@ class DockerBackend(Sandbox):
|
|
|
587
679
|
"""
|
|
588
680
|
if not force and read_stamp(Path(stamp_dir), stamp_key):
|
|
589
681
|
if self._probe_venv() == "broken":
|
|
590
|
-
remove_sandbox_venv(self._workspace
|
|
682
|
+
remove_sandbox_venv(self._workspace,
|
|
683
|
+
sandbox_venv_dir=self._sandbox_venv_dir)
|
|
591
684
|
else:
|
|
592
685
|
return
|
|
593
686
|
payload = docker_provision_payload(
|
|
594
687
|
run_id=self._run_id, image=self._image, workspace=self._workspace,
|
|
595
|
-
setup_cmd=setup_cmd)
|
|
688
|
+
setup_cmd=setup_cmd, sandbox_venv_dir=self._sandbox_venv_dir)
|
|
596
689
|
res = self._runner.run(payload, timeout=PROVISION_TIMEOUT_SECONDS)
|
|
597
690
|
if res.exit_code != 0:
|
|
598
691
|
tail = (res.stdout or res.stderr or "")[-4000:]
|
|
@@ -846,9 +939,12 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
|
|
|
846
939
|
image = resolve_image_ref(profile.image)
|
|
847
940
|
iso_state_dir = isolation_state_dir(cfg)
|
|
848
941
|
needs_network = any(step.network for step in cfg.steps)
|
|
942
|
+
sandbox_venv_dir = resolve_sandbox_venv_dir(
|
|
943
|
+
cfg.isolation.sandbox_venv_dir if cfg.isolation else None, workspace)
|
|
849
944
|
backend = DockerBackend(
|
|
850
945
|
image=image, workspace=str(workspace), iso_state_dir=str(iso_state_dir),
|
|
851
|
-
profile=profile, runner=runner, network=needs_network
|
|
946
|
+
profile=profile, runner=runner, network=needs_network,
|
|
947
|
+
sandbox_venv_dir=sandbox_venv_dir)
|
|
852
948
|
backend.ensure_image()
|
|
853
949
|
backend.create_execution_container()
|
|
854
950
|
if refresh is None:
|
|
@@ -402,6 +402,17 @@ class TestIsolationConfig:
|
|
|
402
402
|
data = _base() | {"isolation": {"memory": memory}}
|
|
403
403
|
assert _load(tmp_path, data).isolation.memory == memory
|
|
404
404
|
|
|
405
|
+
def test_sandbox_venv_dir_accepts_and_round_trips(self, tmp_path):
|
|
406
|
+
"""022: `sandbox_venv_dir` is an optional relocation opt-in. It loads
|
|
407
|
+
verbatim here; the outside-workspace validation happens at startup
|
|
408
|
+
(where the workspace is known), not at config load."""
|
|
409
|
+
data = _base() | {"isolation": {"sandbox_venv_dir":
|
|
410
|
+
"~/.agentic-dev-loop/venvs/engine"}}
|
|
411
|
+
iso = _load(tmp_path, data).isolation
|
|
412
|
+
assert iso.sandbox_venv_dir == "~/.agentic-dev-loop/venvs/engine"
|
|
413
|
+
roundtripped = type(iso).model_validate(iso.model_dump())
|
|
414
|
+
assert roundtripped.sandbox_venv_dir == iso.sandbox_venv_dir
|
|
415
|
+
|
|
405
416
|
@pytest.mark.parametrize("memory", ["8", "g", "8G", "8 g", ""])
|
|
406
417
|
def test_invalid_memory_rejected(self, tmp_path, memory):
|
|
407
418
|
data = _base() | {"isolation": {"memory": memory}}
|
|
@@ -27,10 +27,11 @@ EXAMPLE = Path(__file__).resolve().parent.parent / "agentic-dev-loop"
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
# Scripted stand-in for the shipped example's mechanical test command. The
|
|
30
|
-
# example runs `.venv/bin/python -m pytest tests/ -q
|
|
31
|
-
# the consumer-repo copy (no .venv, no tests/)
|
|
32
|
-
# seam locally. The real subprocess path stays
|
|
33
|
-
# test_graph.py. Behaviors: ("pass",) →
|
|
30
|
+
# example runs `.venv-sandbox/bin/python -m pytest tests/ -q -m "not docker"`,
|
|
31
|
+
# which cannot run inside the consumer-repo copy (no .venv-sandbox, no tests/)
|
|
32
|
+
# — so this fixture scripts the seam locally. The real subprocess path stays
|
|
33
|
+
# covered by test_stages.py and test_graph.py. Behaviors: ("pass",) →
|
|
34
|
+
# (0, "", False); ("fail", output) →
|
|
34
35
|
# (1, output, False); ("timeout", output) → (None, output, True); the last
|
|
35
36
|
# behavior repeats; empty queue → default pass.
|
|
36
37
|
_COMMAND_QUEUE: list[tuple] = []
|
|
@@ -21,7 +21,8 @@ from agentic_dev_loop.engine.isolation import (
|
|
|
21
21
|
clean_stamps, docker_create_payload, docker_provision_payload,
|
|
22
22
|
exec_container_name, is_git_mutation, probe_docker, read_stamp,
|
|
23
23
|
remove_sandbox_venv, resolve_effective_profile, resolve_image_ref,
|
|
24
|
-
resolve_sandbox_env,
|
|
24
|
+
resolve_sandbox_env, resolve_sandbox_venv_dir, select_backend,
|
|
25
|
+
setup_stamp_key, translate_mount_path,
|
|
25
26
|
venv_probe_argv, venv_probe_command, venv_probe_state, write_stamp,
|
|
26
27
|
)
|
|
27
28
|
|
|
@@ -303,6 +304,23 @@ class TestRemoveSandboxVenv:
|
|
|
303
304
|
with pytest.raises(SandboxError, match="AE-SBX-014"):
|
|
304
305
|
remove_sandbox_venv(ws)
|
|
305
306
|
|
|
307
|
+
def test_relocated_clears_contents_keeps_dir(self, tmp_path):
|
|
308
|
+
"""022: a relocated venv is the bind-mount source for the still-running
|
|
309
|
+
exec container, so only its CONTENTS are removed — deleting the dir
|
|
310
|
+
would leave the container pointed at a stale inode."""
|
|
311
|
+
venv = tmp_path / "outside-venv"
|
|
312
|
+
(venv / "bin").mkdir(parents=True)
|
|
313
|
+
(venv / "bin" / "python").symlink_to("/opt/homebrew/opt/python/bin/python3.14")
|
|
314
|
+
(venv / "lib").mkdir(parents=True)
|
|
315
|
+
(venv / "lib" / "x.txt").write_text("x")
|
|
316
|
+
remove_sandbox_venv(tmp_path / "ws", sandbox_venv_dir=str(venv))
|
|
317
|
+
assert venv.is_dir()
|
|
318
|
+
assert not list(venv.iterdir()) # contents gone, mount source intact
|
|
319
|
+
|
|
320
|
+
def test_relocated_missing_is_noop(self, tmp_path):
|
|
321
|
+
remove_sandbox_venv(tmp_path / "ws",
|
|
322
|
+
sandbox_venv_dir=str(tmp_path / "nope"))
|
|
323
|
+
|
|
306
324
|
|
|
307
325
|
class TestPayloads:
|
|
308
326
|
def _profile(self):
|
|
@@ -388,6 +406,74 @@ class TestPayloads:
|
|
|
388
406
|
mounts = [p[i + 1] for i, a in enumerate(p) if a == "-v"]
|
|
389
407
|
assert mounts == [f"{ws}:{ws}"]
|
|
390
408
|
|
|
409
|
+
def test_create_payload_masks_host_only_venv(self, tmp_path):
|
|
410
|
+
"""022: `.venv` is host-only. It cannot be excluded from the workspace
|
|
411
|
+
bind, so it is shadowed by a read-only empty tmpfs submount — any
|
|
412
|
+
container-side write fails loudly instead of mutating the host venv."""
|
|
413
|
+
ws = str(tmp_path)
|
|
414
|
+
p = docker_create_payload(run_id="r1", image="img",
|
|
415
|
+
profile=self._profile(), workspace=ws)
|
|
416
|
+
mask = f"{ws}/.venv:ro"
|
|
417
|
+
assert mask in p
|
|
418
|
+
assert p[p.index(mask) - 1] == "--tmpfs"
|
|
419
|
+
# the mask comes after the workspace bind (submount shadows the bind)
|
|
420
|
+
assert p.index(mask) > p.index(f"{ws}:{ws}")
|
|
421
|
+
|
|
422
|
+
def test_provision_payload_masks_host_only_venv(self, tmp_path):
|
|
423
|
+
ws = str(tmp_path)
|
|
424
|
+
p = docker_provision_payload(run_id="r1", image="img", workspace=ws,
|
|
425
|
+
setup_cmd="make")
|
|
426
|
+
mask = f"{ws}/.venv:ro"
|
|
427
|
+
assert mask in p
|
|
428
|
+
assert p[p.index(mask) - 1] == "--tmpfs"
|
|
429
|
+
assert p.index(mask) > p.index(f"{ws}:{ws}")
|
|
430
|
+
|
|
431
|
+
def test_relocated_venv_mount_present_in_both_payloads(self, tmp_path):
|
|
432
|
+
"""022: when ``sandbox_venv_dir`` is set, the outside dir is mounted rw
|
|
433
|
+
at the unchanged container path ``{ws}/.venv-sandbox`` in BOTH the exec
|
|
434
|
+
and provision payloads (the provision container runs the setup hook that
|
|
435
|
+
creates the venv)."""
|
|
436
|
+
ws = str(tmp_path)
|
|
437
|
+
venv = str(tmp_path / "outside-venv")
|
|
438
|
+
target = f"{venv}:{ws}/.venv-sandbox"
|
|
439
|
+
create = docker_create_payload(run_id="r1", image="img",
|
|
440
|
+
profile=self._profile(), workspace=ws,
|
|
441
|
+
sandbox_venv_dir=venv)
|
|
442
|
+
provision = docker_provision_payload(run_id="r1", image="img",
|
|
443
|
+
workspace=ws, setup_cmd="make",
|
|
444
|
+
sandbox_venv_dir=venv)
|
|
445
|
+
for payload in (create, provision):
|
|
446
|
+
assert target in payload
|
|
447
|
+
assert payload[payload.index(target) - 1] == "-v"
|
|
448
|
+
# the venv mount is a submount: it must come after the workspace bind
|
|
449
|
+
assert payload.index(target) > payload.index(f"{ws}:{ws}")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class TestResolveSandboxVenvDir:
|
|
453
|
+
def test_none_returns_none(self, tmp_path):
|
|
454
|
+
assert resolve_sandbox_venv_dir(None, tmp_path) is None
|
|
455
|
+
|
|
456
|
+
def test_expanduser_and_absolute(self, tmp_path, monkeypatch):
|
|
457
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
458
|
+
out = resolve_sandbox_venv_dir("~/venvs/engine", tmp_path / "ws")
|
|
459
|
+
assert out == str(tmp_path / "venvs" / "engine")
|
|
460
|
+
assert os.path.isabs(out)
|
|
461
|
+
|
|
462
|
+
def test_outside_workspace_accepted(self, tmp_path):
|
|
463
|
+
ws = tmp_path / "ws"
|
|
464
|
+
out = resolve_sandbox_venv_dir(str(tmp_path / "venvs"), ws)
|
|
465
|
+
assert out == str(tmp_path / "venvs")
|
|
466
|
+
|
|
467
|
+
def test_in_workspace_fails_loud(self, tmp_path):
|
|
468
|
+
ws = tmp_path / "ws"
|
|
469
|
+
with pytest.raises(SandboxError, match="AE-SBX-015"):
|
|
470
|
+
resolve_sandbox_venv_dir(str(ws / "nested" / "venv"), ws)
|
|
471
|
+
|
|
472
|
+
def test_equals_workspace_fails_loud(self, tmp_path):
|
|
473
|
+
ws = tmp_path / "ws"
|
|
474
|
+
with pytest.raises(SandboxError, match="AE-SBX-015"):
|
|
475
|
+
resolve_sandbox_venv_dir(str(ws), ws)
|
|
476
|
+
|
|
391
477
|
|
|
392
478
|
class TestExecContainerName:
|
|
393
479
|
def test_default_is_network_none(self):
|
|
@@ -460,13 +546,35 @@ class TestResolveImageRef:
|
|
|
460
546
|
|
|
461
547
|
|
|
462
548
|
def _backend(tmp_path, runner, *, image="img@sha256:abc", git_blocked=True,
|
|
463
|
-
network=False):
|
|
549
|
+
network=False, sandbox_venv_dir=None):
|
|
464
550
|
return DockerBackend(
|
|
465
551
|
image=image, workspace=str(tmp_path / "ws"),
|
|
466
552
|
iso_state_dir=str(tmp_path / "state"),
|
|
467
553
|
profile=EffectiveProfile(True, True, True, True, git_blocked,
|
|
468
554
|
image, "4g", 2.0),
|
|
469
|
-
runner=runner, network=network
|
|
555
|
+
runner=runner, network=network,
|
|
556
|
+
sandbox_venv_dir=sandbox_venv_dir)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
class TestRelocatedVenvDirCreation:
|
|
560
|
+
"""022: the relocated venv dir is the bind-mount source. It must be
|
|
561
|
+
pre-created host-side so Docker never auto-creates a missing source as
|
|
562
|
+
root on native Linux (the --user uid:gid EACCES class, AE-SBX-009)."""
|
|
563
|
+
|
|
564
|
+
def test_missing_dir_created_on_construction(self, tmp_path):
|
|
565
|
+
venv_dir = tmp_path / "outside-venv"
|
|
566
|
+
assert not venv_dir.exists()
|
|
567
|
+
_backend(tmp_path, FakeRunner(), sandbox_venv_dir=str(venv_dir))
|
|
568
|
+
assert venv_dir.is_dir()
|
|
569
|
+
|
|
570
|
+
def test_nested_missing_dir_created(self, tmp_path):
|
|
571
|
+
venv_dir = tmp_path / "a" / "b" / "venv"
|
|
572
|
+
_backend(tmp_path, FakeRunner(), sandbox_venv_dir=str(venv_dir))
|
|
573
|
+
assert venv_dir.is_dir()
|
|
574
|
+
|
|
575
|
+
def test_unset_creates_nothing(self, tmp_path):
|
|
576
|
+
_backend(tmp_path, FakeRunner())
|
|
577
|
+
assert not (tmp_path / ".venv-sandbox").exists()
|
|
470
578
|
|
|
471
579
|
|
|
472
580
|
class TestEnsureImage:
|
|
@@ -887,3 +995,49 @@ class TestStartupSandbox:
|
|
|
887
995
|
sandbox2 = startup_sandbox(cfg, workspace=ws, runner=runner2, refresh=True)
|
|
888
996
|
sandbox2.close()
|
|
889
997
|
assert [c for c in runner2.calls if "python -m venv .venv-sandbox" in c]
|
|
998
|
+
|
|
999
|
+
def test_relocated_venv_dir_flows_into_payloads(self, tmp_path, monkeypatch):
|
|
1000
|
+
"""022: a set `sandbox_venv_dir` is resolved and threaded into BOTH the
|
|
1001
|
+
exec and provision payloads; the workspace stays free of `.venv-sandbox`
|
|
1002
|
+
noise (the outside dir is mounted at the unchanged container path)."""
|
|
1003
|
+
from agentic_dev_loop.engine.isolation import startup_sandbox
|
|
1004
|
+
|
|
1005
|
+
ws = tmp_path / "ws"
|
|
1006
|
+
ws.mkdir()
|
|
1007
|
+
venv_dir = tmp_path / "outside-venv"
|
|
1008
|
+
cfg = self._cfg(tmp_path, with_setup=True)
|
|
1009
|
+
cfg.isolation.sandbox_venv_dir = str(venv_dir)
|
|
1010
|
+
monkeypatch.setattr("agentic_dev_loop.engine.isolation.shutil.which",
|
|
1011
|
+
lambda *a: "/usr/bin/docker")
|
|
1012
|
+
monkeypatch.setenv("AGENTIC_DEV_LOOP_DEV_IMAGE", "img:tag")
|
|
1013
|
+
runner = FakeRunner(
|
|
1014
|
+
ok("1.54"), # probe version
|
|
1015
|
+
ok("linux"), # probe ostype
|
|
1016
|
+
ok(""), # image inspect
|
|
1017
|
+
ok(""), # docker run -d --rm (exec container)
|
|
1018
|
+
ok(""), # docker run provision
|
|
1019
|
+
)
|
|
1020
|
+
sandbox = startup_sandbox(cfg, workspace=ws, runner=runner)
|
|
1021
|
+
try:
|
|
1022
|
+
target = f"{venv_dir}:{ws}/.venv-sandbox"
|
|
1023
|
+
for argv in runner.calls:
|
|
1024
|
+
if argv[:2] == ["docker", "run"]:
|
|
1025
|
+
assert target in argv
|
|
1026
|
+
assert argv[argv.index(target) - 1] == "-v"
|
|
1027
|
+
assert f"{ws}/.venv:ro" in argv
|
|
1028
|
+
finally:
|
|
1029
|
+
sandbox.close()
|
|
1030
|
+
|
|
1031
|
+
def test_in_workspace_sandbox_venv_dir_fails_loud(self, tmp_path, monkeypatch):
|
|
1032
|
+
from agentic_dev_loop.engine.isolation import startup_sandbox
|
|
1033
|
+
|
|
1034
|
+
ws = tmp_path / "ws"
|
|
1035
|
+
ws.mkdir()
|
|
1036
|
+
cfg = self._cfg(tmp_path, with_setup=False)
|
|
1037
|
+
cfg.isolation.sandbox_venv_dir = str(ws / "nested")
|
|
1038
|
+
monkeypatch.setattr("agentic_dev_loop.engine.isolation.shutil.which",
|
|
1039
|
+
lambda *a: "/usr/bin/docker")
|
|
1040
|
+
monkeypatch.setenv("AGENTIC_DEV_LOOP_DEV_IMAGE", "img:tag")
|
|
1041
|
+
runner = FakeRunner(ok("1.54"), ok("linux"))
|
|
1042
|
+
with pytest.raises(SandboxError, match="AE-SBX-015"):
|
|
1043
|
+
startup_sandbox(cfg, workspace=ws, runner=runner)
|
|
@@ -215,3 +215,75 @@ class TestVenvSelfHealE2E:
|
|
|
215
215
|
assert result.exit_code == 0, result.stderr
|
|
216
216
|
assert "ok" in result.stdout
|
|
217
217
|
assert read_stamp(stamp_dir, key)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class TestRelocatedVenvE2E:
|
|
221
|
+
"""022: `sandbox_venv_dir` relocates the sandbox venv outside the workspace.
|
|
222
|
+
The outside dir is bind-mounted at `{ws}/.venv-sandbox` in BOTH containers,
|
|
223
|
+
so the venv materializes there and stays absent from the repo dir — and the
|
|
224
|
+
probe/remove_sandbox_venv self-heal keeps operating at the unchanged
|
|
225
|
+
container path (the mount)."""
|
|
226
|
+
|
|
227
|
+
def _relocated_backend(self, tmp_path):
|
|
228
|
+
reason = _docker_available()
|
|
229
|
+
if reason is not None:
|
|
230
|
+
pytest.skip(reason)
|
|
231
|
+
inspect = SubprocessRunner().run(
|
|
232
|
+
["docker", "image", "inspect", TEST_IMAGE, "--format", "{{.Id}}"],
|
|
233
|
+
timeout=30)
|
|
234
|
+
if inspect.exit_code != 0:
|
|
235
|
+
pytest.skip(f"integration image {TEST_IMAGE!r} not present locally")
|
|
236
|
+
ws = tmp_path / "ws"
|
|
237
|
+
ws.mkdir()
|
|
238
|
+
venv_dir = tmp_path / "outside-venv"
|
|
239
|
+
profile = resolve_effective_profile(IsolationConfig(image=TEST_IMAGE))
|
|
240
|
+
b = DockerBackend(image=TEST_IMAGE, workspace=str(ws),
|
|
241
|
+
iso_state_dir=str(tmp_path / "state"), profile=profile,
|
|
242
|
+
sandbox_venv_dir=str(venv_dir))
|
|
243
|
+
b.ensure_image()
|
|
244
|
+
b.create_execution_container()
|
|
245
|
+
return b, ws, venv_dir
|
|
246
|
+
|
|
247
|
+
def test_relocated_venv_materializes_outside_workspace(self, tmp_path):
|
|
248
|
+
b, ws, venv_dir = self._relocated_backend(tmp_path)
|
|
249
|
+
try:
|
|
250
|
+
b.provision("python -m venv .venv-sandbox", "k1",
|
|
251
|
+
str(tmp_path / "stamps"), force=False)
|
|
252
|
+
assert (venv_dir / "bin" / "python").exists()
|
|
253
|
+
assert not (ws / ".venv-sandbox").exists()
|
|
254
|
+
req = SandboxExecutionRequest(
|
|
255
|
+
argv=[".venv-sandbox/bin/python", "-c", "import sys; print('ok')"],
|
|
256
|
+
cwd=ws,
|
|
257
|
+
env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp"})
|
|
258
|
+
result = b.run(req)
|
|
259
|
+
assert result.exit_code == 0, result.stderr
|
|
260
|
+
assert "ok" in result.stdout
|
|
261
|
+
finally:
|
|
262
|
+
b.close()
|
|
263
|
+
|
|
264
|
+
def test_relocated_self_heal_reprovisions_broken_venv(self, tmp_path):
|
|
265
|
+
b, ws, venv_dir = self._relocated_backend(tmp_path)
|
|
266
|
+
try:
|
|
267
|
+
# Simulate a host-built venv broken inside the Linux container.
|
|
268
|
+
(venv_dir / "bin").mkdir(parents=True)
|
|
269
|
+
(venv_dir / "bin" / "python").symlink_to(
|
|
270
|
+
"/opt/homebrew/opt/python@3.14/bin/python3.14")
|
|
271
|
+
stamp_dir = tmp_path / "stamps"
|
|
272
|
+
key = "warm-stamp"
|
|
273
|
+
write_stamp(stamp_dir, key)
|
|
274
|
+
|
|
275
|
+
b.provision("python -m venv .venv-sandbox", key, str(stamp_dir),
|
|
276
|
+
force=False)
|
|
277
|
+
|
|
278
|
+
# contents cleared (mount source dir kept) and re-provisioned.
|
|
279
|
+
assert venv_dir.is_dir()
|
|
280
|
+
req = SandboxExecutionRequest(
|
|
281
|
+
argv=[".venv-sandbox/bin/python", "-c", "import sys; print('ok')"],
|
|
282
|
+
cwd=ws,
|
|
283
|
+
env={"PATH": "/usr/local/bin:/usr/bin:/bin", "HOME": "/tmp"})
|
|
284
|
+
result = b.run(req)
|
|
285
|
+
assert result.exit_code == 0, result.stderr
|
|
286
|
+
assert "ok" in result.stdout
|
|
287
|
+
assert read_stamp(stamp_dir, key)
|
|
288
|
+
finally:
|
|
289
|
+
b.close()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Trigger-script contract tests.
|
|
2
|
+
|
|
3
|
+
The trigger script must keep the pipx-managed engine install current:
|
|
4
|
+
a stale install rejects newer pipeline configs at load time (e.g.
|
|
5
|
+
`deps_policy` in a11 rejected by an a10 install), so an existing install
|
|
6
|
+
is upgraded on every run. Tests drive the real script with stub
|
|
7
|
+
executables on PATH — no network, no mocks of the script's logic.
|
|
8
|
+
|
|
9
|
+
The script is bash, so it is executed through `bash` on every platform:
|
|
10
|
+
Windows cannot CreateProcess a `.sh` file (shebangs are a POSIX concept)
|
|
11
|
+
and its MSYS bash (git-bash, preinstalled on windows-latest runners)
|
|
12
|
+
understands only POSIX-style paths (`/c/...`), so PATH entries and
|
|
13
|
+
redirect targets inside stub bodies are converted accordingly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
24
|
+
SCRIPT = REPO_ROOT / "trigger-agentic-dev-loop.sh"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _msys(path: Path) -> str:
|
|
28
|
+
"""POSIX-style form of a Windows path (`C:\\x` -> `/c/x`).
|
|
29
|
+
|
|
30
|
+
MSYS bash splits PATH on `:` and treats backslashes as escapes, so a
|
|
31
|
+
native `C:\\...` entry would fragment into `C` + `\\...`. Pass the
|
|
32
|
+
mount-style form instead. On POSIX the path is returned unchanged.
|
|
33
|
+
"""
|
|
34
|
+
if os.name != "nt":
|
|
35
|
+
return str(path)
|
|
36
|
+
drive, rest = os.path.splitdrive(str(path))
|
|
37
|
+
return "/" + drive[0].lower() + rest.replace("\\", "/")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _bash() -> str:
|
|
41
|
+
bash = shutil.which("bash")
|
|
42
|
+
if bash is None:
|
|
43
|
+
pytest.skip("bash is required to run the trigger script")
|
|
44
|
+
return bash
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _run_script(*args: str, env: dict) -> subprocess.CompletedProcess:
|
|
48
|
+
return subprocess.run(
|
|
49
|
+
[_bash(), _msys(SCRIPT), *args],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
env=env,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _make_stub(bin_dir: Path, name: str, body: str) -> None:
|
|
57
|
+
path = bin_dir / name
|
|
58
|
+
path.write_text(f"#!/bin/bash\n{body}\n")
|
|
59
|
+
path.chmod(0o755)
|
|
60
|
+
if not os.access(path, os.X_OK):
|
|
61
|
+
pytest.skip("tmp filesystem is mounted noexec; stub executables cannot run")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _env_with(bin_dir: Path | None = None) -> dict:
|
|
65
|
+
entries = ["/usr/bin", "/bin"]
|
|
66
|
+
if bin_dir is not None:
|
|
67
|
+
entries.insert(0, _msys(bin_dir))
|
|
68
|
+
return {**os.environ, "PATH": ":".join(entries)}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_trigger_upgrades_existing_install(tmp_path):
|
|
72
|
+
bin_dir = tmp_path / "bin"
|
|
73
|
+
bin_dir.mkdir()
|
|
74
|
+
pipx_log = tmp_path / "pipx.log"
|
|
75
|
+
_make_stub(
|
|
76
|
+
bin_dir,
|
|
77
|
+
"agentic-dev-loop",
|
|
78
|
+
'echo "cli-ran: $*"',
|
|
79
|
+
)
|
|
80
|
+
_make_stub(
|
|
81
|
+
bin_dir,
|
|
82
|
+
"pipx",
|
|
83
|
+
f'echo "$*" >> "{_msys(pipx_log)}"',
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
result = _run_script("--plan", "plan.md", env=_env_with(bin_dir))
|
|
87
|
+
|
|
88
|
+
assert result.returncode == 0, result.stderr
|
|
89
|
+
assert pipx_log.read_text().strip() == "upgrade --cooldown 0 --pip-args=--prerelease=allow agentic-dev-loop"
|
|
90
|
+
# The upgrade happens before the loop is launched.
|
|
91
|
+
assert "cli-ran:" in result.stdout
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_trigger_fails_fast_when_upgrade_fails(tmp_path):
|
|
95
|
+
bin_dir = tmp_path / "bin"
|
|
96
|
+
bin_dir.mkdir()
|
|
97
|
+
_make_stub(bin_dir, "agentic-dev-loop", "echo should-not-run")
|
|
98
|
+
_make_stub(bin_dir, "pipx", "echo upgrade-broken >&2; exit 1")
|
|
99
|
+
|
|
100
|
+
result = _run_script(env=_env_with(bin_dir))
|
|
101
|
+
|
|
102
|
+
assert result.returncode != 0
|
|
103
|
+
assert "pipx upgrade" in result.stderr
|
|
104
|
+
assert "should-not-run" not in result.stdout
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_trigger_errors_when_cli_absent():
|
|
108
|
+
result = _run_script(env=_env_with())
|
|
109
|
+
|
|
110
|
+
assert result.returncode != 0
|
|
111
|
+
assert "pipx install" in result.stderr
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/__init__.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/results.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/verdict.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/__init__.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/actions.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/agents.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/dryrun.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/graph.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/hostboundary.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/loaders.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/outputs.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/registry.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/secrets.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/stages.py
RENAMED
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/__init__.py
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/requires.txt
RENAMED
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_hostboundary_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|