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.

Files changed (70) hide show
  1. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/PKG-INFO +1 -1
  2. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/pyproject.toml +1 -1
  3. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/config.py +7 -0
  4. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/isolation.py +122 -26
  5. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/PKG-INFO +1 -1
  6. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/SOURCES.txt +1 -0
  7. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_config.py +11 -0
  8. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_e2e_fixtures.py +5 -4
  9. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_isolation.py +157 -3
  10. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_isolation_integration.py +72 -0
  11. agentic_dev_loop-1.0.0a12/tests/test_trigger_script.py +111 -0
  12. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/LICENSE +0 -0
  13. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/README.md +0 -0
  14. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/setup.cfg +0 -0
  15. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/__init__.py +0 -0
  16. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/__init__.py +0 -0
  17. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/results.py +0 -0
  18. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/core/verdict.py +0 -0
  19. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/__init__.py +0 -0
  20. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/actions.py +0 -0
  21. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/agents.py +0 -0
  22. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/cli.py +0 -0
  23. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/deps.py +0 -0
  24. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/dryrun.py +0 -0
  25. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/extensions/__init__.py +0 -0
  26. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/extensions/aidlc.py +0 -0
  27. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/graph.py +0 -0
  28. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/hostboundary.py +0 -0
  29. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/llm.py +0 -0
  30. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/loaders.py +0 -0
  31. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/outputs.py +0 -0
  32. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/proc.py +0 -0
  33. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/registry.py +0 -0
  34. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/secrets.py +0 -0
  35. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/engine/stages.py +0 -0
  36. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/py.typed +0 -0
  37. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/__init__.py +0 -0
  38. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/__main__.py +0 -0
  39. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop/viz/server.py +0 -0
  40. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/dependency_links.txt +0 -0
  41. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/entry_points.txt +0 -0
  42. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/requires.txt +0 -0
  43. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/src/agentic_dev_loop.egg-info/top_level.txt +0 -0
  44. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_actions.py +0 -0
  45. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_agents.py +0 -0
  46. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_aidlc.py +0 -0
  47. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_cli.py +0 -0
  48. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_deps.py +0 -0
  49. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_dry_run.py +0 -0
  50. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_graph.py +0 -0
  51. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_hostboundary.py +0 -0
  52. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_hostboundary_integration.py +0 -0
  53. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_isolation_pbt.py +0 -0
  54. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_llm.py +0 -0
  55. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_loaders.py +0 -0
  56. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_module_contract.py +0 -0
  57. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_outputs.py +0 -0
  58. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_outputs_pbt.py +0 -0
  59. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_proc.py +0 -0
  60. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_publish.py +0 -0
  61. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_registry.py +0 -0
  62. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_results.py +0 -0
  63. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_sdk_compat.py +0 -0
  64. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_secrets.py +0 -0
  65. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_secrets_integration.py +0 -0
  66. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_secrets_pipeline.py +0 -0
  67. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_source_resolution.py +0 -0
  68. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_stages.py +0 -0
  69. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_verdict.py +0 -0
  70. {agentic_dev_loop-1.0.0a11 → agentic_dev_loop-1.0.0a12}/tests/test_viz.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a11
3
+ Version: 1.0.0a12
4
4
  Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.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.0a11"
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
@@ -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 EXACTLY {workspace} (Q1).
11
- The isolation state dir (stamps + audit JSONL) and the pipeline run-state dir
12
- (checkpoint DB + last-error.md) are never mounted — sandboxed code cannot
13
- reach them.
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 remove_sandbox_venv(workspace: str | Path) -> None:
327
- """Remove the workspace's sandbox venv before re-provision (016 self-heal).
328
- Fails loud when the venv exists but cannot be removed — a silent skip
329
- would re-create the ENOENT failure the next run."""
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) -> list[str]:
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
- Single mount {workspace} (Q1); ``--network none`` (bridge when ``network``
367
- is true — 014 per-stage networking), --read-only, --cap-drop ALL; --user
368
- host uid:gid (host-owned bind mounts, AE-SBX-009 root cause) with
369
- HOME=/tmp (the rootfs is read-only; /tmp is a 1777 tmpfs). ``--rm`` lets the
370
- daemon auto-remove the container the instant it exits, however 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) -> list[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, single mount
395
- {workspace} (Q1); --user host uid:gid (AE-SBX-009) with HOME=/tmp."""
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-dev-loop
3
- Version: 1.0.0a11
3
+ Version: 1.0.0a12
4
4
  Summary: Turn an LLM into a gated, sandboxed, resumable construction loop over your codebase.
5
5
  Author: Luke Wilson
6
6
  License-Expression: Apache-2.0
@@ -63,5 +63,6 @@ tests/test_secrets_integration.py
63
63
  tests/test_secrets_pipeline.py
64
64
  tests/test_source_resolution.py
65
65
  tests/test_stages.py
66
+ tests/test_trigger_script.py
66
67
  tests/test_verdict.py
67
68
  tests/test_viz.py
@@ -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`, which cannot run inside
31
- # the consumer-repo copy (no .venv, no tests/) — so this fixture scripts the
32
- # seam locally. The real subprocess path stays covered by test_stages.py and
33
- # test_graph.py. Behaviors: ("pass",) → (0, "", False); ("fail", output) →
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, select_backend, setup_stamp_key, translate_mount_path,
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