agentic-dev-loop 1.0.0a11__tar.gz → 1.0.0a13__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.0a13}/PKG-INFO +1 -1
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/pyproject.toml +1 -1
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/core/config.py +7 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/cli.py +5 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/isolation.py +145 -28
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/loaders.py +41 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/PKG-INFO +1 -1
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/SOURCES.txt +1 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_cli.py +48 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_config.py +11 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_e2e_fixtures.py +5 -4
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_isolation.py +172 -3
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_isolation_integration.py +79 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_loaders.py +110 -1
- agentic_dev_loop-1.0.0a13/tests/test_trigger_script.py +191 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/LICENSE +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/README.md +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/setup.cfg +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/core/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/core/results.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/core/verdict.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/actions.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/agents.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/deps.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/dryrun.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/graph.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/llm.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/outputs.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/proc.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/registry.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/secrets.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/stages.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/py.typed +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/viz/__init__.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/viz/__main__.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/viz/server.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_actions.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_agents.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_aidlc.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_deps.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_dry_run.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_graph.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_hostboundary.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_hostboundary_integration.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_isolation_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_llm.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_module_contract.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_outputs.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_outputs_pbt.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_proc.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_publish.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_registry.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_results.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_sdk_compat.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_secrets.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_secrets_integration.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_secrets_pipeline.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_source_resolution.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_stages.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/tests/test_verdict.py +0 -0
- {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/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.0a13"
|
|
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
|
|
@@ -12,6 +12,7 @@ from pathlib import Path
|
|
|
12
12
|
from agentic_dev_loop.core.config import PipelineConfig, load_config
|
|
13
13
|
from .dryrun import dry_run
|
|
14
14
|
from .isolation import clean_stamps, isolation_state_dir
|
|
15
|
+
from .loaders import mark_plan_completed
|
|
15
16
|
from .registry import Registry, default_registry, load_extensions
|
|
16
17
|
|
|
17
18
|
|
|
@@ -112,6 +113,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
112
113
|
# dry run, --clean-stamps, and --help stay side-effect free.
|
|
113
114
|
from .graph import run_pipeline
|
|
114
115
|
success, _attempts = run_pipeline(args.pipeline, args, registry=registry)
|
|
116
|
+
if success and args.plan is not None:
|
|
117
|
+
# Only a completed run earns the stamp — failed runs leave the plan
|
|
118
|
+
# as-is. AI-DLC runs never pass --plan, so aidlc docs stay untouched.
|
|
119
|
+
mark_plan_completed(args.plan)
|
|
115
120
|
if not success:
|
|
116
121
|
print("agentic-dev-loop: pipeline failed — exiting with code 1",
|
|
117
122
|
file=sys.stderr)
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/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):
|
|
@@ -267,16 +274,58 @@ def is_git_mutation(argv: list[str]) -> bool:
|
|
|
267
274
|
return True
|
|
268
275
|
|
|
269
276
|
|
|
277
|
+
def _expand_home(path: str) -> str:
|
|
278
|
+
"""Expand a leading ``~`` against ``$HOME``, falling back to
|
|
279
|
+
``USERPROFILE`` (Windows).
|
|
280
|
+
|
|
281
|
+
The engine is driven from bash on every platform (Windows via Git Bash),
|
|
282
|
+
where HOME is the authoritative home. CPython's Windows expanduser ignores
|
|
283
|
+
HOME in favour of USERPROFILE, so a literal ``~`` in a config file would
|
|
284
|
+
resolve inconsistently on Windows. Only a leading tilde (``~``, ``~/``,
|
|
285
|
+
``~\\``) is handled here; ``~user`` forms and the no-HOME/no-USERPROFILE
|
|
286
|
+
case fall through to the stdlib below.
|
|
287
|
+
"""
|
|
288
|
+
if path != "~" and not path.startswith(("~/", "~\\")):
|
|
289
|
+
return path
|
|
290
|
+
home = os.environ.get("HOME") or os.environ.get("USERPROFILE")
|
|
291
|
+
if home:
|
|
292
|
+
return home + path[1:]
|
|
293
|
+
return path
|
|
294
|
+
|
|
295
|
+
|
|
270
296
|
def translate_mount_path(path: str) -> str:
|
|
271
297
|
"""expanduser + absolute resolution without symlink chasing. Identity on
|
|
272
298
|
macOS/Windows/WSL2-with-Docker-Desktop (verbatim passthrough — Docker
|
|
273
|
-
Desktop translates). Idempotent: translate(translate(p)) == translate(p).
|
|
274
|
-
|
|
299
|
+
Desktop translates). Idempotent: translate(translate(p)) == translate(p).
|
|
300
|
+
A leading ``~`` resolves against $HOME first (USERPROFILE fallback), then
|
|
301
|
+
the stdlib handles anything left (pwd on POSIX)."""
|
|
302
|
+
p = Path(_expand_home(path)).expanduser()
|
|
275
303
|
if not p.is_absolute():
|
|
276
304
|
p = p.absolute()
|
|
277
305
|
return str(p)
|
|
278
306
|
|
|
279
307
|
|
|
308
|
+
def resolve_sandbox_venv_dir(sandbox_venv_dir: str | None,
|
|
309
|
+
workspace: str | Path) -> str | None:
|
|
310
|
+
"""Resolve the opt-in relocated sandbox venv (022).
|
|
311
|
+
|
|
312
|
+
``None`` keeps the legacy workspace-local ``.venv-sandbox`` (bit-compatible
|
|
313
|
+
default — payloads are unchanged). A set value is expanduser + absolute and
|
|
314
|
+
must resolve OUTSIDE the workspace: an in-workspace value defeats the whole
|
|
315
|
+
relocation and fails loud (AE-SBX-015).
|
|
316
|
+
"""
|
|
317
|
+
if sandbox_venv_dir is None:
|
|
318
|
+
return None
|
|
319
|
+
resolved = Path(translate_mount_path(sandbox_venv_dir))
|
|
320
|
+
ws = Path(translate_mount_path(os.fspath(workspace)))
|
|
321
|
+
if resolved == ws or ws in resolved.parents:
|
|
322
|
+
raise SandboxError(
|
|
323
|
+
f"AE-SBX-015: isolation.sandbox_venv_dir {resolved!r} must resolve "
|
|
324
|
+
"outside the workspace — an in-workspace value defeats the 022 "
|
|
325
|
+
"relocation (docs/setup.md §3)")
|
|
326
|
+
return str(resolved)
|
|
327
|
+
|
|
328
|
+
|
|
280
329
|
def setup_stamp_key(setup_cmd: str, lockfile_hashes: dict[str, str]) -> str:
|
|
281
330
|
"""sha256 hex of canonical JSON {setup_cmd, lockfiles: sorted hashes}."""
|
|
282
331
|
canonical = {
|
|
@@ -323,10 +372,44 @@ def venv_probe_state(stdout: str, exit_code: int | None) -> str:
|
|
|
323
372
|
return line if line in ("valid", "missing", "broken") else "unknown"
|
|
324
373
|
|
|
325
374
|
|
|
326
|
-
def
|
|
327
|
-
"""
|
|
328
|
-
|
|
329
|
-
|
|
375
|
+
def _clear_dir_contents(path: Path) -> None:
|
|
376
|
+
"""Delete every entry under ``path``, keeping ``path`` itself. The 022
|
|
377
|
+
relocated venv is the bind-mount source for the still-running execution
|
|
378
|
+
container, so the source dir must survive removal (deleting it would leave
|
|
379
|
+
the container pointed at a stale inode)."""
|
|
380
|
+
if not path.exists():
|
|
381
|
+
return
|
|
382
|
+
for child in path.iterdir():
|
|
383
|
+
if child.is_symlink() or child.is_file():
|
|
384
|
+
child.unlink()
|
|
385
|
+
elif child.is_dir():
|
|
386
|
+
shutil.rmtree(child)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def remove_sandbox_venv(workspace: str | Path, *,
|
|
390
|
+
sandbox_venv_dir: str | None = None) -> None:
|
|
391
|
+
"""Remove the sandbox venv before re-provision (016 self-heal). Fails loud
|
|
392
|
+
when the venv exists but cannot be removed — a silent skip would re-create
|
|
393
|
+
the ENOENT failure the next run.
|
|
394
|
+
|
|
395
|
+
With the 022 relocation opt-in (``sandbox_venv_dir``), the venv lives at
|
|
396
|
+
that host path and is the source of a live bind mount in the execution
|
|
397
|
+
container — so only its CONTENTS are removed (the mount source dir stays),
|
|
398
|
+
leaving the container's ``{ws}/.venv-sandbox`` mount valid for re-provision.
|
|
399
|
+
The default (unset) path removes the whole workspace-local dir as before.
|
|
400
|
+
"""
|
|
401
|
+
if sandbox_venv_dir is not None:
|
|
402
|
+
venv = Path(sandbox_venv_dir)
|
|
403
|
+
try:
|
|
404
|
+
if venv.is_symlink() or venv.is_file():
|
|
405
|
+
venv.unlink()
|
|
406
|
+
elif venv.is_dir():
|
|
407
|
+
_clear_dir_contents(venv)
|
|
408
|
+
except OSError as e:
|
|
409
|
+
raise SandboxError(
|
|
410
|
+
f"AE-SBX-014: cannot remove invalid sandbox venv at {venv}: {e} "
|
|
411
|
+
f"— remove it manually and re-run (docs/setup.md §5)") from e
|
|
412
|
+
return
|
|
330
413
|
venv = Path(workspace) / SANDBOX_VENV_DIR
|
|
331
414
|
try:
|
|
332
415
|
if venv.is_symlink() or venv.is_file():
|
|
@@ -360,15 +443,34 @@ def exec_container_name(run_id: str, network: bool = False) -> str:
|
|
|
360
443
|
return f"{run_id}-exec-net" if network else f"{run_id}-exec"
|
|
361
444
|
|
|
362
445
|
|
|
446
|
+
def _sandbox_mounts(workspace: str, sandbox_venv_dir: str | None) -> list[str]:
|
|
447
|
+
"""The mount segment shared by the exec and provision payloads (022).
|
|
448
|
+
|
|
449
|
+
``{workspace}`` rw is always first; each host-only dir is then shadowed by
|
|
450
|
+
a read-only empty tmpfs submount (Docker cannot exclude a subdirectory of a
|
|
451
|
+
bind — a submount is the only way to hide it, and ``ro`` makes any write
|
|
452
|
+
attempt fail loudly instead of silently succeeding against a decoy). When
|
|
453
|
+
``sandbox_venv_dir`` relocates the sandbox venv, one rw bind mount maps it
|
|
454
|
+
to the unchanged container path ``{ws}/.venv-sandbox``.
|
|
455
|
+
"""
|
|
456
|
+
mounts = ["-v", f"{workspace}:{workspace}"]
|
|
457
|
+
for host_only in HOST_ONLY_DIRS:
|
|
458
|
+
mounts += ["--tmpfs", f"{workspace}/{host_only}:ro"]
|
|
459
|
+
if sandbox_venv_dir is not None:
|
|
460
|
+
mounts += ["-v", f"{sandbox_venv_dir}:{workspace}/{SANDBOX_VENV_DIR}"]
|
|
461
|
+
return mounts
|
|
462
|
+
|
|
463
|
+
|
|
363
464
|
def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
|
|
364
|
-
workspace: str, network: bool = False
|
|
465
|
+
workspace: str, network: bool = False,
|
|
466
|
+
sandbox_venv_dir: str | None = None) -> list[str]:
|
|
365
467
|
"""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()."""
|
|
468
|
+
Mount set {workspace} + host-only masks (022); ``--network none`` (bridge
|
|
469
|
+
when ``network`` is true — 014 per-stage networking), --read-only,
|
|
470
|
+
--cap-drop ALL; --user host uid:gid (host-owned bind mounts, AE-SBX-009
|
|
471
|
+
root cause) with HOME=/tmp (the rootfs is read-only; /tmp is a 1777 tmpfs).
|
|
472
|
+
``--rm`` lets the daemon auto-remove the container the instant it exits,
|
|
473
|
+
however it exits (D1) — a SIGKILLed CLI never reaches close()."""
|
|
372
474
|
payload = [
|
|
373
475
|
"docker", "run", "--detach", "--rm", "--name",
|
|
374
476
|
exec_container_name(run_id, network),
|
|
@@ -380,8 +482,8 @@ def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
|
|
|
380
482
|
user = host_user_flag()
|
|
381
483
|
if user is not None:
|
|
382
484
|
payload.extend(["--user", user, "--env", "HOME=/tmp"])
|
|
485
|
+
payload.extend(_sandbox_mounts(workspace, sandbox_venv_dir))
|
|
383
486
|
payload.extend([
|
|
384
|
-
"-v", f"{workspace}:{workspace}",
|
|
385
487
|
"--workdir", workspace,
|
|
386
488
|
image, "sleep", "infinity",
|
|
387
489
|
])
|
|
@@ -389,10 +491,12 @@ def docker_create_payload(*, run_id: str, image: str, profile: EffectiveProfile,
|
|
|
389
491
|
|
|
390
492
|
|
|
391
493
|
def docker_provision_payload(*, run_id: str, image: str, workspace: str,
|
|
392
|
-
setup_cmd: str
|
|
494
|
+
setup_cmd: str,
|
|
495
|
+
sandbox_venv_dir: str | None = None) -> list[str]:
|
|
393
496
|
"""Exact ``docker run --rm`` argv for the provision container (§3.4).
|
|
394
|
-
Network ON (default bridge), rw rootfs, --cap-drop ALL,
|
|
395
|
-
{workspace} (
|
|
497
|
+
Network ON (default bridge), rw rootfs, --cap-drop ALL, mount set
|
|
498
|
+
{workspace} + host-only masks (022); --user host uid:gid (AE-SBX-009) with
|
|
499
|
+
HOME=/tmp."""
|
|
396
500
|
payload = [
|
|
397
501
|
"docker", "run", "--rm", "--name", f"{run_id}-provision",
|
|
398
502
|
"--network", "bridge", "--cap-drop", "ALL",
|
|
@@ -401,8 +505,8 @@ def docker_provision_payload(*, run_id: str, image: str, workspace: str,
|
|
|
401
505
|
user = host_user_flag()
|
|
402
506
|
if user is not None:
|
|
403
507
|
payload.extend(["--user", user, "--env", "HOME=/tmp"])
|
|
508
|
+
payload.extend(_sandbox_mounts(workspace, sandbox_venv_dir))
|
|
404
509
|
payload.extend([
|
|
405
|
-
"-v", f"{workspace}:{workspace}",
|
|
406
510
|
"--workdir", workspace,
|
|
407
511
|
image, "sh", "-c", setup_cmd,
|
|
408
512
|
])
|
|
@@ -502,7 +606,7 @@ class DockerBackend(Sandbox):
|
|
|
502
606
|
|
|
503
607
|
def __init__(self, *, image: str, workspace: str, iso_state_dir: str,
|
|
504
608
|
profile: EffectiveProfile, runner: CommandRunner | None = None,
|
|
505
|
-
network: bool = False):
|
|
609
|
+
network: bool = False, sandbox_venv_dir: str | None = None):
|
|
506
610
|
self._image = image
|
|
507
611
|
self._workspace = workspace
|
|
508
612
|
self._iso_state_dir = Path(iso_state_dir) # host-only (audit)
|
|
@@ -512,6 +616,13 @@ class DockerBackend(Sandbox):
|
|
|
512
616
|
self._container = exec_container_name(self._run_id, network=False)
|
|
513
617
|
self._network_container = exec_container_name(self._run_id, network=True)
|
|
514
618
|
self._network_enabled = network
|
|
619
|
+
self._sandbox_venv_dir = sandbox_venv_dir # 022 relocated venv (opt-in)
|
|
620
|
+
if sandbox_venv_dir is not None:
|
|
621
|
+
# 022: the relocated venv dir is a bind-mount source. On native
|
|
622
|
+
# Linux, Docker auto-creates a missing source as root, which the
|
|
623
|
+
# --user uid:gid containers then cannot write (the AE-SBX-009
|
|
624
|
+
# ownership class). Pre-create it host-side so it is uid-owned.
|
|
625
|
+
Path(sandbox_venv_dir).mkdir(parents=True, exist_ok=True)
|
|
515
626
|
self._started = False
|
|
516
627
|
self._network_started = False
|
|
517
628
|
|
|
@@ -551,7 +662,8 @@ class DockerBackend(Sandbox):
|
|
|
551
662
|
infrastructure work happens mid-run (014 per-stage networking)."""
|
|
552
663
|
payload = docker_create_payload(
|
|
553
664
|
run_id=self._run_id, image=self._image, profile=self._profile,
|
|
554
|
-
workspace=self._workspace, network=False
|
|
665
|
+
workspace=self._workspace, network=False,
|
|
666
|
+
sandbox_venv_dir=self._sandbox_venv_dir)
|
|
555
667
|
res = self._runner.run(payload, timeout=60)
|
|
556
668
|
if res.exit_code != 0:
|
|
557
669
|
raise SandboxError(
|
|
@@ -562,7 +674,8 @@ class DockerBackend(Sandbox):
|
|
|
562
674
|
return
|
|
563
675
|
net_payload = docker_create_payload(
|
|
564
676
|
run_id=self._run_id, image=self._image, profile=self._profile,
|
|
565
|
-
workspace=self._workspace, network=True
|
|
677
|
+
workspace=self._workspace, network=True,
|
|
678
|
+
sandbox_venv_dir=self._sandbox_venv_dir)
|
|
566
679
|
net_res = self._runner.run(net_payload, timeout=60)
|
|
567
680
|
if net_res.exit_code != 0:
|
|
568
681
|
# reclaim the already-started network-none container — never leak it.
|
|
@@ -587,12 +700,13 @@ class DockerBackend(Sandbox):
|
|
|
587
700
|
"""
|
|
588
701
|
if not force and read_stamp(Path(stamp_dir), stamp_key):
|
|
589
702
|
if self._probe_venv() == "broken":
|
|
590
|
-
remove_sandbox_venv(self._workspace
|
|
703
|
+
remove_sandbox_venv(self._workspace,
|
|
704
|
+
sandbox_venv_dir=self._sandbox_venv_dir)
|
|
591
705
|
else:
|
|
592
706
|
return
|
|
593
707
|
payload = docker_provision_payload(
|
|
594
708
|
run_id=self._run_id, image=self._image, workspace=self._workspace,
|
|
595
|
-
setup_cmd=setup_cmd)
|
|
709
|
+
setup_cmd=setup_cmd, sandbox_venv_dir=self._sandbox_venv_dir)
|
|
596
710
|
res = self._runner.run(payload, timeout=PROVISION_TIMEOUT_SECONDS)
|
|
597
711
|
if res.exit_code != 0:
|
|
598
712
|
tail = (res.stdout or res.stderr or "")[-4000:]
|
|
@@ -846,9 +960,12 @@ def startup_sandbox(cfg: PipelineConfig, *, workspace: Path,
|
|
|
846
960
|
image = resolve_image_ref(profile.image)
|
|
847
961
|
iso_state_dir = isolation_state_dir(cfg)
|
|
848
962
|
needs_network = any(step.network for step in cfg.steps)
|
|
963
|
+
sandbox_venv_dir = resolve_sandbox_venv_dir(
|
|
964
|
+
cfg.isolation.sandbox_venv_dir if cfg.isolation else None, workspace)
|
|
849
965
|
backend = DockerBackend(
|
|
850
966
|
image=image, workspace=str(workspace), iso_state_dir=str(iso_state_dir),
|
|
851
|
-
profile=profile, runner=runner, network=needs_network
|
|
967
|
+
profile=profile, runner=runner, network=needs_network,
|
|
968
|
+
sandbox_venv_dir=sandbox_venv_dir)
|
|
852
969
|
backend.ensure_image()
|
|
853
970
|
backend.create_execution_container()
|
|
854
971
|
if refresh is None:
|
{agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a13}/src/agentic_dev_loop/engine/loaders.py
RENAMED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Built-in `plan` document loader + the loader-selection flow."""
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import re
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from agentic_dev_loop.core.config import PipelineConfig
|
|
@@ -18,6 +19,46 @@ def resolve_plan_docs(plan_path: Path | str) -> dict[str, str]:
|
|
|
18
19
|
return {path.name: path.read_text()}
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
# A status line at the top of a planning doc, in any recorded form:
|
|
23
|
+
# `> **Status: COMPLETED** (date)`, `**Status:** ...`, `- **Status**: ...`,
|
|
24
|
+
# or bare `Status: ...`.
|
|
25
|
+
_STATUS_LINE = re.compile(r"^\s*(?:>\s*)?(?:[-*]\s+)?\*{0,2}Status\*{0,2}\s*:")
|
|
26
|
+
# The status only counts as "recorded at the top" when it appears early —
|
|
27
|
+
# buried history (e.g. `## Status history` or a later `Status: ...` line)
|
|
28
|
+
# is not the doc's current status.
|
|
29
|
+
_STATUS_SCAN_LINES = 10
|
|
30
|
+
_STAMP = "> **Status: COMPLETED**"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def mark_plan_completed(plan_path: Path | str) -> None:
|
|
34
|
+
"""Stamp the plan doc as COMPLETED after a successful run.
|
|
35
|
+
|
|
36
|
+
Replaces the first status line found within the top of the doc, or
|
|
37
|
+
inserts the stamp right after the `# ` title (blank separators around
|
|
38
|
+
it) — or at the very top when there is no title. Trailing-newline
|
|
39
|
+
presence is preserved."""
|
|
40
|
+
path = Path(plan_path)
|
|
41
|
+
text = path.read_text(encoding="utf-8")
|
|
42
|
+
lines = text.splitlines()
|
|
43
|
+
trailing = "\n" if text.endswith("\n") else ""
|
|
44
|
+
|
|
45
|
+
for idx in range(min(len(lines), _STATUS_SCAN_LINES)):
|
|
46
|
+
if _STATUS_LINE.match(lines[idx]):
|
|
47
|
+
lines[idx] = _STAMP
|
|
48
|
+
break
|
|
49
|
+
else:
|
|
50
|
+
title_idx = next((i for i, line in enumerate(lines)
|
|
51
|
+
if line.startswith("# ")), None)
|
|
52
|
+
insert_at = 0 if title_idx is None else title_idx + 1
|
|
53
|
+
stamp_block = [_STAMP]
|
|
54
|
+
if insert_at > 0 and lines[insert_at - 1] != "":
|
|
55
|
+
stamp_block.insert(0, "") # blank separator above the stamp
|
|
56
|
+
if insert_at < len(lines) and lines[insert_at] != "":
|
|
57
|
+
stamp_block.append("") # blank separator below the stamp
|
|
58
|
+
lines[insert_at:insert_at] = stamp_block
|
|
59
|
+
path.write_text("\n".join(lines) + trailing, encoding="utf-8")
|
|
60
|
+
|
|
61
|
+
|
|
21
62
|
class PlanDocumentLoader:
|
|
22
63
|
name = "plan"
|
|
23
64
|
defaultable = False # needs a path — never autodetects
|
|
@@ -202,6 +202,54 @@ class TestRun:
|
|
|
202
202
|
# dev completed before the crash — only test re-ran on resume
|
|
203
203
|
assert fake_agents.agents_called() == ["dev", "check", "check"]
|
|
204
204
|
|
|
205
|
+
def test_success_stamps_plan_completed(self, repo, fake_agents):
|
|
206
|
+
fake_agents.set("dev", [("write", "implementation-summary.md", "done")])
|
|
207
|
+
|
|
208
|
+
assert main(["pipeline.yaml", "--plan", "plan.md", "--no-viz"]) == 0
|
|
209
|
+
|
|
210
|
+
assert (repo / "plan.md").read_text() == (
|
|
211
|
+
"> **Status: COMPLETED**\n\nTHE PLAN")
|
|
212
|
+
|
|
213
|
+
def test_success_replaces_existing_status(self, repo, fake_agents):
|
|
214
|
+
(repo / "plan.md").write_text(
|
|
215
|
+
"# Plan\n\n> **Status: DONE**\n\nTHE PLAN\n")
|
|
216
|
+
git(repo, "add", "-A")
|
|
217
|
+
git(repo, "commit", "-m", "record status")
|
|
218
|
+
fake_agents.set("dev", [("write", "implementation-summary.md", "done")])
|
|
219
|
+
|
|
220
|
+
assert main(["pipeline.yaml", "--plan", "plan.md", "--no-viz"]) == 0
|
|
221
|
+
|
|
222
|
+
assert (repo / "plan.md").read_text() == (
|
|
223
|
+
"# Plan\n\n> **Status: COMPLETED**\n\nTHE PLAN\n")
|
|
224
|
+
|
|
225
|
+
def test_failed_run_does_not_stamp(self, repo, fake_agents):
|
|
226
|
+
write_pipeline(repo, "retry.yaml", RETRY_YAML)
|
|
227
|
+
fake_agents.set("dev", [("write", "implementation-summary.md", "done")])
|
|
228
|
+
fake_agents.set("check", [("write", "ci-fix.md", "VERDICT: FAIL")])
|
|
229
|
+
|
|
230
|
+
assert main(["retry.yaml", "--plan", "plan.md", "--no-viz"]) == 1
|
|
231
|
+
|
|
232
|
+
assert (repo / "plan.md").read_text() == "THE PLAN"
|
|
233
|
+
|
|
234
|
+
def test_aidlc_run_leaves_docs_untouched(self, repo, fake_agents):
|
|
235
|
+
write_pipeline(repo, "aidlc.yaml", with_extensions(
|
|
236
|
+
PLAIN_YAML, "agentic_dev_loop.engine.extensions.aidlc:AidlcExtension"))
|
|
237
|
+
unit_dir = repo / "aidlc-docs" / "construction" / "unit-3"
|
|
238
|
+
unit_dir.mkdir(parents=True)
|
|
239
|
+
(unit_dir / "design.md").write_text("# Design")
|
|
240
|
+
(repo / "aidlc-docs" / "aidlc-state.md").write_text(
|
|
241
|
+
"# State\n\n- **Current Stage**: CONSTRUCTION — Unit unit-3 (SCAN).\n")
|
|
242
|
+
git(repo, "add", "-A")
|
|
243
|
+
git(repo, "commit", "-m", "aidlc fixture")
|
|
244
|
+
fake_agents.set("dev", [("write", "implementation-summary.md", "done")])
|
|
245
|
+
state_before = (repo / "aidlc-docs" / "aidlc-state.md").read_text()
|
|
246
|
+
|
|
247
|
+
assert main(["aidlc.yaml", "--aidlc-unit", "unit-3", "--no-viz"]) == 0
|
|
248
|
+
|
|
249
|
+
assert (repo / "aidlc-docs" / "aidlc-state.md").read_text() == state_before
|
|
250
|
+
assert (repo / "aidlc-docs" / "construction" / "unit-3" / "design.md").read_text() == "# Design"
|
|
251
|
+
assert (repo / "plan.md").read_text() == "THE PLAN"
|
|
252
|
+
|
|
205
253
|
|
|
206
254
|
class TestViz:
|
|
207
255
|
def test_no_viz_flags_parse(self, repo):
|
|
@@ -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] = []
|